Skip to content

Commit cee2339

Browse files
edimarlnxclaude
andauthored
Authorized keys and Match blocks from the TUI (#11)
* Answer "who can actually log in", and let a setting apply to only some of them Two gaps the first version left open, and they are the same gap seen from two sides. `PubkeyAuthentication yes` on the config screen says the server accepts keys; nothing said whose keys those are. And every keyword the editor wrote applied to the whole server, so "passwords off, except for ana" was a change the tool could describe and not make. A sixth screen lists the local accounts with the public keys on them, each with the fingerprint and comment `ssh-keygen` would print. `A` adds a key: the paste is checked before anything is staged — one line, not a private key, no per-key options smuggled in front of it — and then the staged file is handed to `ssh-keygen -lf`, so the fingerprint the dialog shows is the one ssh-keygen reported. `R` on that screen removes the selected key by fingerprint rather than by position, rewriting the file without that one line and copying everything else through byte for byte: unlike the drop-in, authorized_keys is not a file this tool owns, and a rewrite that dropped what it did not understand would lose somebody's `from=` restriction. Both plans install through `install` with the mode and the owner set in the same call. That is not tidiness either: with StrictModes on, sshd ignores a group-writable ~/.ssh and leaves you staring at a key that is plainly there and plainly not working. `m` opens the same editor with two fields in front of it and writes the keyword inside a `Match User`, `Match Group` or `Match Address` block. The renderer now keeps every file-scope keyword above every block, whichever order they were written in, because sshd reads what follows a Match line as part of that block — a keyword appended after one would silently stop applying to the server. The criteria is validated too: `Match Address 10.0` looks fine and selects nothing. The fingerprint is computed in-process rather than shelled out for once per key per read, and a table test pins it to what ssh-keygen prints for three real keys. A fuzz target on the authorized_keys parser found a line with a carriage return inside it being copied back into the file it rewrote; such a line is now left alone rather than understood, and the input is committed with the rest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q * Name the sample machine's service accounts in the secret scanner's allowlist The users screen needs home directories to show a key file path, and the sample machine's are /home/deploy and /home/backup. The family's developer-home-path rule reads those as a captured workstation path and fails the run — which is what the rule is for, and why the answer is the allowlist beside it rather than a weaker regex or a path spelled around the scanner. Neither name is a person. `deploy` is a service account and `backup` a job account, which is what a server's /home actually holds and what this screen has to be able to show; both are invented for the demo, like `ana` already on that list. The key file paths themselves are now derived through AuthorizedKeysPathFor rather than spelled out, so the fixtures cannot drift from the function the product uses to build the same path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f411e9d commit cee2339

20 files changed

Lines changed: 3168 additions & 133 deletions

File tree

‎.gitleaks.toml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ regexes = [
3232
'''^(user|username|you|me|example|root|runner|ubuntu|debian|fedora|arch|vagrant|linuxbrew|Shared)$''',
3333
# The stand-in people of the family's demos, fixtures and screenshots, and
3434
# the placeholder every manual page reaches for. Invented, not captured.
35-
'''^(ana|alice|bob|johndoe)$''',
35+
#
36+
# `deploy` and `backup` are the other two accounts on tui-ssh's sample
37+
# machine: a service account and a job account, which is what a server's
38+
# /home actually holds and what the users screen has to be able to show.
39+
# Neither is a person, and neither was captured from a workstation.
40+
'''^(ana|alice|bob|johndoe|deploy|backup)$''',
3641
]
3742

3843
# Build output. CI scans a fresh checkout, where none of this exists, but a

‎README.md‎

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,31 @@ believed to put it in the same place, but that has not been verified here and
259259
nothing in the tool depends on it — the include order is read from the machine
260260
in front of you on every start, and it is what the warning is computed from.
261261

262+
### `Match` blocks, and why they go last
263+
264+
`m` opens the same editor with two fields in front of it: what the block selects
265+
on — `User`, `Group` or `Address` — and the value it selects. The keyword list
266+
and the validators are the ones the file-scope editor uses, so a value sshd
267+
would refuse is refused here too, and the criteria is checked as well: a
268+
`Match Address 10.0` looks fine, selects nothing, and is a mistake you would
269+
otherwise find later.
270+
271+
The generated file keeps **every file-scope keyword above every `Match` block**,
272+
whichever order you write them in. That is not tidiness. `sshd` reads everything
273+
following a `Match` line as part of that block until the next `Match` or the end
274+
of the file, so a keyword appended after one would silently stop applying to the
275+
server and start applying to whoever the block selects. The renderer regenerates
276+
the whole file in the one order that means what it says, and the same
277+
`sshd -t -f` gate, diff and two commands apply.
278+
279+
```
280+
PermitRootLogin no
281+
PasswordAuthentication no
282+
283+
Match User ana
284+
PasswordAuthentication yes
285+
```
286+
262287
### Changing the port
263288

264289
Moving `Port` or `ListenAddress` is the one change that can lock you out of a
@@ -307,6 +332,45 @@ directory rather than deleted, so a regeneration done by mistake is reversible
307332
with a `mv` you can read, and the dialog says in as many words what it does to
308333
every client that has ever connected.
309334

335+
## Who has a key
336+
337+
`6` is the accounts somebody could log into — `root`, and the ordinary accounts
338+
with a real shell — each row a public key from that account's
339+
`~/.ssh/authorized_keys`, with its type, its SHA256 fingerprint and its comment.
340+
An account with no key is a row too, saying so: "nobody logs into `backup` with
341+
a key" is as much of an answer as a fingerprint is, and an account missing from
342+
the screen would read as an account missing from the machine.
343+
344+
`PubkeyAuthentication yes` on the config screen says the server accepts keys.
345+
This screen is who has one.
346+
347+
`A` adds a key: pick the account, paste the `.pub` line. Before anything is
348+
staged the paste is checked — one line, not a private key, no per-key options
349+
smuggled in front of it — and then the staged file is handed to `ssh-keygen -lf`
350+
and the dialog reports the fingerprint **ssh-keygen** gave back, not one this
351+
tool worked out for itself. Two commands follow, both previewed:
352+
353+
```sh
354+
install -d -m 700 -o ana -g ana /home/ana/.ssh
355+
install -m 600 -o ana -g ana <staged file> /home/ana/.ssh/authorized_keys
356+
```
357+
358+
The modes are part of the plan rather than left to the umask, because with
359+
`StrictModes` on — the default — sshd ignores a group-writable `~/.ssh` or
360+
`authorized_keys` and leaves you staring at a key that is plainly there and
361+
plainly not working.
362+
363+
`R` on this screen removes the selected key, identified by its **fingerprint**
364+
rather than by its position: the file is re-read, rewritten without that one
365+
line, and installed the same way. Everything else in it — the comments, the
366+
`from=` restrictions, the other keys, the order — is copied through byte for
367+
byte, because unlike the drop-in this is not a file `tui-ssh` owns. Removing the
368+
last key leaves the file empty rather than deleting it, and the dialog says
369+
whose access ends and whether a password would still get in.
370+
371+
`R` means re-read the server on every other screen, as it always has; `ctrl+r`
372+
does that from here too.
373+
310374
## Usage
311375

312376
```sh
@@ -398,14 +462,21 @@ Every one of these is previewed and confirmed first.
398462
| Key | What runs |
399463
| --- | --- |
400464
| `e` | `sshd -t -f <staged file>` (a read, before the question), then `install -m 600 <staged file> /etc/ssh/sshd_config.d/90-tui-ssh.conf` and `systemctl reload <unit>` |
465+
| `m` | the same three, with the keyword written inside a `Match` block |
466+
| `A` | `ssh-keygen -l -f <staged file>` (a read, before the question), then `install -d -m 700 -o <user> -g <group> <home>/.ssh` and `install -m 600 -o <user> -g <group> <staged file> <home>/.ssh/authorized_keys` |
467+
| `R` | on the users screen: `install -m 600 -o <user> -g <group> <staged file> <home>/.ssh/authorized_keys`, with the selected key rewritten out |
401468
| `t` | `loginctl terminate-session <id>` |
402469
| `b` | `ufw deny from <address>`, and only when ufw is the active firewall |
403470
| `K` | `install -d -m 700 /etc/ssh/tui-ssh-hostkeys-<stamp>`, `mv -- <the key files> <that directory>/`, `ssh-keygen -A`, `systemctl reload <unit>` |
404471
| `r` | `systemctl reload <unit>` |
405472

406473
Nothing else. There is no code path that writes to `/etc` other than that one
407474
`install`, its destination is a constant rather than a parameter, and the only
408-
`mv` refuses any path that is not `/etc/ssh/ssh_host_*_key`.
475+
`mv` refuses any path that is not `/etc/ssh/ssh_host_*_key`. The two `install`s
476+
that write under a home directory take their account name, group and path from
477+
`/etc/passwd` — and check all three against the shape a local account has before
478+
building a command, because a file this tool *reads* is not a source it trusts
479+
with an argv that runs as root.
409480

410481
The unit is `sshd` on Fedora, Arch and openSUSE and `ssh` on Debian and Ubuntu.
411482
It is read from systemd, not assumed.
@@ -414,26 +485,30 @@ It is read from systemd, not assumed.
414485

415486
| Key | Action |
416487
| --- | --- |
417-
| `tab` / `1`–`5` | Move between config, sessions, auth log, host keys and service |
488+
| `tab` / `1`–`6` | Move between config, sessions, auth log, host keys, service and users |
418489
| `↑`/`k`, `↓`/`j` | Move the selection, or scroll the detail screen |
419490
| `g` / `G` | First / last row |
420491
| `pgup` / `pgdn` | Scroll a page |
421492
| `enter` | Open the selected row in full |
422493
| `esc` | Leave the detail screen |
423494
| `/` | Filter this screen (`esc` clears) |
424495
| `e` | Change a setting, written to the drop-in with a diff to confirm |
496+
| `m` | Change a setting inside a `Match` block, for some connections only |
497+
| `A` | Add a public key to an account's `authorized_keys` |
498+
| `R` | On the users screen, remove the selected key; elsewhere, re-read the server |
425499
| `t` | End the selected session |
426500
| `b` | Block the selected address at the firewall |
427501
| `w` | Switch the log window between 24 hours and 7 days |
428502
| `K` | Regenerate the host keys, old ones moved aside |
429503
| `r` | Reload the SSH service |
430-
| `R` | Re-read the server |
504+
| `ctrl+r` | Re-read the server, from any screen |
431505
| `?` | Help |
432506
| `q` | Quit |
433507

434-
In the **setting editor**: `tab` moves between the two fields, `←`/`→` cycles a
508+
In the **setting editor**: `tab` moves between the fields, `←`/`→` cycles a
435509
value, `space` opens the list, `enter` builds the change and shows it, `esc`
436-
cancels.
510+
cancels. `m` opens the same editor with two more fields at the top, for the
511+
block's criteria.
437512

438513
![The setting editor](docs/screenshots/tui-ssh-edit.png)
439514

@@ -456,6 +531,12 @@ cancels.
456531
`/etc/ssh/sshd_config.d/90-tui-ssh.conf`, checked by `sshd -t -f` before you
457532
are asked, reviewed as a unified diff, installed with `install -m 600` and
458533
applied with `systemctl reload`.
534+
- Set that same keyword inside a `Match User`, `Match Group` or `Match Address`
535+
block instead, with the criteria validated and every block kept below
536+
everything at file scope, which is the only order sshd reads as written.
537+
- List the local accounts and the public keys on them, with fingerprints and
538+
comments; add a key, checked with `ssh-keygen -lf` before you are asked; and
539+
remove one by fingerprint, with the rest of the file copied through unchanged.
459540
- List the live logins with the address, terminal and session behind each, and
460541
end one.
461542
- Summarise the authentication log over a day or a week, with the busiest
@@ -471,9 +552,14 @@ cancels.
471552
- **It does not edit `sshd_config`.** One drop-in, one keyword at a time, from a
472553
closed set. Everything else is a job for `$EDITOR` — and the file view tells
473554
you which file and which line to open.
474-
- **No `Match` block editing.** Blocks are parsed and shown, and a keyword set
475-
only inside one is labelled as such, but the form writes at file scope.
476-
- **No authorized_keys.** Whose key is on which account is
555+
- **`Match` blocks, but only the three criteria it can check.** `User`, `Group`
556+
and `Address`. `LocalPort`, `RDomain` and the rest are parsed and shown when
557+
another file carries them, and a keyword set only inside one is labelled as
558+
such, but the form writes the three it can validate.
559+
- **authorized_keys, but only the default one.** `~/.ssh/authorized_keys` for a
560+
local account. An account whose `AuthorizedKeysFile` points somewhere else, an
561+
`AuthorizedKeysCommand`, a key with per-key options, and anything about a
562+
certificate authority are all left alone. Creating the accounts themselves is
477563
[tui-users](https://github.com/tui-tools/tui-users)' question, not this one's.
478564
- **No `ssh_config`**: this is the server, not the client.
479565
- **No fail2ban or sshguard rules.** Their presence is reported, read-only;
@@ -543,9 +629,11 @@ talks to `internal/ssh.Backend`, which returns a server-neutral model:
543629

544630
```
545631
Model{Effective, EffectiveReason, Settings, Files, Service, Sessions, Auth,
546-
HostKeys, Firewall}
632+
HostKeys, Users, Firewall}
547633
Setting{Key, Value, Effective, Security, Verdict, Note, Sources, Default}
548634
Source{File, Line, Text, Match}
635+
User{Name, Group, Home, KeysPath, Keys, Unreadable}
636+
AuthorizedKey{Line, Options, Type, Bits, Fingerprint, Comment, Raw}
549637
```
550638

551639
`internal/openssh` is the only package that starts a process. It drives eight
@@ -607,11 +695,18 @@ widgets, the config loader and the command runner shared by the whole family.
607695
- Ending a session kills everything running in it. Replacing the host keys makes
608696
every client that has connected before refuse to connect until its
609697
`known_hosts` entry is removed. Both say so before you agree.
698+
- **Adding a key grants access; removing one ends it.** The dialog names the
699+
fingerprint either way, and says when the key being removed is the last one
700+
that account has — and, if `PasswordAuthentication` is `no` as well, that
701+
there is then no way into it over SSH at all.
610702
- Two reads escalate, unprompted, through `sudo -n`: `sshd -T`, and `cat` for a
611-
configuration file the plain read cannot open. Fedora and Arch ship
612-
`/etc/ssh/sshd_config` mode 0600, so without the second one those machines
613-
would show every setting as coming from nowhere. Both fall back rather than
614-
demanding a password, and the screen says which answer you are looking at.
703+
file the plain read cannot open. Fedora and Arch ship `/etc/ssh/sshd_config`
704+
mode 0600, so without the second one those machines would show every setting
705+
as coming from nowhere; the same fallback is what reads another account's
706+
`~/.ssh/authorized_keys`, which is mode 700 all the way down. Both fall back
707+
rather than demanding a password, and the screen says which answer you are
708+
looking at — an account whose keys could not be read says so on its row,
709+
rather than showing an empty list that would read as "nobody has a key here".
615710
- `tui-ssh` re-reads the server after every change, so what you see is what the
616711
system reports, not what the tool assumed.
617712

0 commit comments

Comments
 (0)