From f11a70c6ba30fad3008c7a25c910cc66034b12bb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 4 Aug 2026 06:08:33 -0700 Subject: [PATCH] Take authorized_keys on the cidata drive instead of ssh.json The JSON wrapper was the one piece of autoinstall input that wasn't an existing format: the other files are the configurator's own output, and the keys end up in sshd's authorized_keys anyway. So take that file directly -- one key per line, comments and key options work as they do everywhere else -- and drop the parsing in between. The only thing JSON bought was parse-error detection, and the phase still fails loudly when the file carries no usable keys. Co-Authored-By: Claude Fable 5 --- README.md | 12 ++-- configs/airootfs/root/.automated_script.sh | 2 +- .../usr/local/bin/omarchy-cidata-load | 6 +- .../usr/local/bin/omarchy-iso-install | 14 ++-- .../share/omarchy-iso/orchestrator/context.py | 8 +-- .../omarchy-iso/orchestrator/phases_impl.py | 20 +++--- plans/autoinstall.md | 22 +++---- test/cidata-load-test.sh | 16 ++--- test/test_configure_ssh_access.py | 66 +++++++++---------- 9 files changed, 80 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 2e2b53c..2cafd76 100644 --- a/README.md +++ b/README.md @@ -37,25 +37,25 @@ These are the configurator's own output files, so the way to get a starting set | `user_full_name.txt` | No | Git full name | | `user_email_address.txt` | No | Git email | | `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | -| `ssh.json` | No | JSON array of SSH public keys | +| `authorized_keys` | No | SSH public keys in sshd's own format, one per line | Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. -`ssh.json` is a JSON array of public keys: +`authorized_keys` is the same file sshd reads — copy your own or write one key per line: -```json -["ssh-ed25519 AAAA... you@host"] +``` +ssh-ed25519 AAAA... you@host ``` -When `ssh.json` is present, autoinstall writes the keys to the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An unparseable or empty `ssh.json` fails the install rather than producing a machine nobody can reach. +When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach. ### Building the drive ```bash mkdir cidata -cp user_configuration.json user_credentials.json ssh.json cidata/ +cp user_configuration.json user_credentials.json authorized_keys cidata/ genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/ ``` diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index dffe289..29f6262 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -116,4 +116,4 @@ rm -f /run/omarchy-install/state.json --full-name-file /root/user_full_name.txt \ --email-file /root/user_email_address.txt \ --encrypt-file /root/user_encrypt_installation.txt \ - --ssh-keys-file /root/ssh.json + --authorized-keys-file /root/authorized_keys diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load index 24189e2..28c4baa 100755 --- a/configs/airootfs/usr/local/bin/omarchy-cidata-load +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -38,15 +38,15 @@ mount -o ro "$device" "$mnt" || exit 1 loaded=1 if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) - for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do + for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do [[ -f $mnt/$file ]] && files+=("$mnt/$file") done if cp "${files[@]}" "$dest"/; then loaded=0 else # A partial copy must not leave files behind: the wizard cleans up only - # what it writes itself, so a half-copied ssh.json would leak into the - # interactive install that follows. + # what it writes itself, so a half-copied authorized_keys would leak into + # the interactive install that follows. for file in "${files[@]}"; do rm -f "$dest/${file##*/}" done diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install index 0380a1b..9f0c716 100755 --- a/configs/airootfs/usr/local/bin/omarchy-iso-install +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -11,13 +11,13 @@ set -eEo pipefail # doesn't choke on flags it doesn't recognize. while [[ $# -gt 0 ]]; do case $1 in - --config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;; - --creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;; - --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; - --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; - --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; - --ssh-keys-file) export OMARCHY_INSTALL_SSH_KEYS_FILE="$2"; shift 2 ;; - *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; + --config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;; + --creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;; + --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; + --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; + --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; + --authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;; + *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; esac done diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 34e8de9..5b6008b 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -18,7 +18,7 @@ class InstallContext: full_name: str email: str encrypt: bool - ssh_keys_path: Path | None + authorized_keys_path: Path | None user_configuration: dict user_credentials: dict @@ -60,7 +60,7 @@ class InstallContext: full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")), email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")), encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), - ssh_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_SSH_KEYS_FILE")), + authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), arch_config_path=arch_config_path, @@ -107,8 +107,8 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: def _optional_path(path: str | None) -> Path | None: """A path the caller always passes but that need not exist. The live ISO - passes --ssh-keys-file on every install; only autoinstall drives put a file - there.""" + passes --authorized-keys-file on every install; only autoinstall drives put + a file there.""" if not path: return None p = Path(path) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 4be987c..e391225 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -22,7 +22,6 @@ Phase ordering (full-disk and protected/pre-mounted): from __future__ import annotations import hashlib -import json import os import re import shutil @@ -1314,10 +1313,10 @@ def configure_login(ctx: InstallContext) -> None: # ───────────────────────────────────────────────────────────────────────────── def configure_ssh_access(ctx: InstallContext) -> None: - if ctx.ssh_keys_path is None: + if ctx.authorized_keys_path is None: return - keys = _authorized_keys(ctx.ssh_keys_path) + keys = _authorized_keys(ctx.authorized_keys_path) info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") ssh_dir = ctx.target / "home" / ctx.username / ".ssh" @@ -1362,21 +1361,20 @@ def configure_ssh_access(ctx: InstallContext) -> None: def _authorized_keys(path: Path) -> list[str]: - """Parse the autoinstall ssh.json: a JSON array of public key strings. + """Read the autoinstall authorized_keys: sshd's own format, one public key + per line, with blank lines and # comments dropped. Raise rather than skip on anything unusable. An install that "succeeds" into a machine nobody can log into is worse than one that stops with the reason on screen. """ try: - keys = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as exc: - raise RuntimeError(f"{path} is not readable JSON: {exc}") from exc + lines = path.read_text().splitlines() + except OSError as exc: + raise RuntimeError(f"{path} is not readable: {exc}") from exc - if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys): - raise RuntimeError(f"{path} must be a JSON array of public key strings") - - keys = [key.strip() for key in keys if key.strip()] + keys = [line.strip() for line in lines] + keys = [key for key in keys if key and not key.startswith("#")] if not keys: raise RuntimeError(f"{path} contains no SSH keys") diff --git a/plans/autoinstall.md b/plans/autoinstall.md index ed318d2..4093dd1 100644 --- a/plans/autoinstall.md +++ b/plans/autoinstall.md @@ -68,11 +68,11 @@ Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. I - Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists. - Mount read-only on a temp mountpoint; exit non-zero if the mount fails. - Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero. - - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `ssh.json` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error. + - Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error. - Unmount before exiting. Leave nothing mounted for the install to trip over. - The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing. - Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`. -- Pass `--ssh-keys-file /root/ssh.json` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. +- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line. ### Notes - The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies. @@ -116,23 +116,23 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom - `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py` ### Changes -- `omarchy-iso-install`: add `--ssh-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_SSH_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). -- `context.py`: add an `ssh_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. +- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`). +- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist. - `phases_impl.py`: add `configure_ssh_access(ctx)`: - - Return immediately when `ctx.ssh_keys_path` is `None`. - - Parse the file as a JSON array of public key strings; write them one per line to `/home//.ssh/authorized_keys`. + - Return immediately when `ctx.authorized_keys_path` is `None`. + - Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `/home//.ssh/authorized_keys`. - `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot chown -R :`. Do not hardcode uid 1000 as PR #72 did — ask the target for it. - `arch-chroot systemctl enable sshd.service`. Works cleanly in the chroot. - `arch-chroot ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing. - - A malformed or empty `ssh.json` must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. + - An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen. - `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`. ### Model to follow `configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure. ### Acceptance criteria -- Install with `ssh.json`: `ssh @` works with the corresponding private key on first boot, no password. -- Install without `ssh.json`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. +- Install with `authorized_keys`: `ssh @` works with the corresponding private key on first boot, no password. +- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created. - `authorized_keys` is `0600` and owned by the install user, not root. - Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it. - Password SSH auth is left at the distro default — autoinstall does not enable it. @@ -147,7 +147,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom ### Changes - New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward. -- Show `openssl passwd -6` for the credential hash and the `ssh.json` array format. +- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format. - State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote. - Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists. @@ -168,7 +168,7 @@ The dashboard is the only process that owns the visible UI. Suppressing its prom ### Negative tests - Drive labeled `cidata` with no files → configurator runs. - Drive with `user_configuration.json` only → configurator runs. -- Malformed `ssh.json` → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine. +- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine. - Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang. ### Regression surface diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh index ede60fa..ae5cee1 100644 --- a/test/cidata-load-test.sh +++ b/test/cidata-load-test.sh @@ -88,9 +88,9 @@ write_required_pair echo "Jeff" >"$sandbox/media/user_full_name.txt" echo "jeff@example.com" >"$sandbox/media/user_email_address.txt" echo "false" >"$sandbox/media/user_encrypt_installation.txt" -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" run_load || fail "full file set loads" -for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt ssh.json; do +for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do [[ -f $sandbox/root/$file ]] || fail "full file set copies $file" done grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive" @@ -109,16 +109,16 @@ new_sandbox attach_drive cidata write_required_pair run_load || fail "required pair alone loads" -[[ ! -e $sandbox/root/ssh.json ]] || fail "required pair alone copies no optional files" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files" pass "required pair alone loads without optional files" # Optional files are copied individually when present. new_sandbox attach_drive cidata write_required_pair -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" -run_load || fail "required pair plus ssh.json loads" -[[ -f $sandbox/root/ssh.json ]] || fail "ssh.json is copied when present" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" +run_load || fail "required pair plus authorized_keys loads" +[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present" [[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied" pass "present optional files are copied, absent ones skipped" @@ -162,9 +162,9 @@ pass "copy failure falls back and unmounts" new_sandbox attach_drive cidata write_required_pair -echo '["ssh-ed25519 AAAA jeff@host"]' >"$sandbox/media/ssh.json" +echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" mkdir "$sandbox/root/user_credentials.json" ! run_load 2>/dev/null || fail "partial copy exits non-zero" [[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied" -[[ ! -e $sandbox/root/ssh.json ]] || fail "partial copy leaves no ssh.json behind" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind" pass "partial copy cleans up what it copied" diff --git a/test/test_configure_ssh_access.py b/test/test_configure_ssh_access.py index 71159af..812761e 100644 --- a/test/test_configure_ssh_access.py +++ b/test/test_configure_ssh_access.py @@ -5,7 +5,6 @@ the tests run it against a temp directory with subprocess.run recorded and the ufw side effect (writing user.rules) simulated by the fake. """ -import json import stat import sys import tempfile @@ -56,12 +55,12 @@ class ConfigureSshAccessTest(unittest.TestCase): return CompletedProcess(cmd, 1) return CompletedProcess(cmd, 0) - def ctx(self, ssh_keys=None, ssh_keys_text=None): - ssh_keys_path = None - if ssh_keys is not None or ssh_keys_text is not None: - ssh_keys_path = self.target / "ssh.json" - ssh_keys_path.write_text(ssh_keys_text if ssh_keys_text is not None else json.dumps(ssh_keys)) - return types.SimpleNamespace(target=self.target, username="jeff", ssh_keys_path=ssh_keys_path) + def ctx(self, authorized_keys=None): + authorized_keys_path = None + if authorized_keys is not None: + authorized_keys_path = self.target / "authorized_keys" + authorized_keys_path.write_text(authorized_keys) + return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path) def configure(self, **kwargs): phases_impl.configure_ssh_access(self.ctx(**kwargs)) @@ -72,42 +71,47 @@ class ConfigureSshAccessTest(unittest.TestCase): def chrooted(self, program): return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program] - def test_no_ssh_json_is_a_no_op(self): + def test_no_authorized_keys_is_a_no_op(self): self.configure() self.assertEqual(self.calls, []) self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists()) - def test_writes_keys_one_per_line(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", "ssh-rsa BBBB jeff@work"]) + def test_installs_keys_one_per_line(self): + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n") self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n") - def test_strips_whitespace_and_drops_blank_entries(self): - self.configure(ssh_keys=[" ssh-ed25519 AAAA jeff@host\n", "", " "]) + def test_drops_blank_lines_and_comments(self): + self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n") self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n") + def test_keys_with_options_pass_through(self): + key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host' + self.configure(authorized_keys=f"{key}\n") + self.assertEqual(self.authorized_keys().read_text(), f"{key}\n") + def test_ssh_dir_is_private(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode) self.assertEqual(mode, 0o700) def test_authorized_keys_is_private(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600) def test_chowns_ssh_dir_to_the_user(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("chown"), [ ["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"], ]) def test_enables_sshd(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("systemctl"), [ ["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"], ]) def test_allows_ssh_through_ufw_despite_chroot_exit_status(self): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") self.assertEqual(self.chrooted("ufw"), [ ["arch-chroot", str(self.target), "ufw", "allow", "ssh"], ]) @@ -115,27 +119,19 @@ class ConfigureSshAccessTest(unittest.TestCase): def test_fails_when_ufw_does_not_record_the_rule(self): self.ufw_writes_rule = False with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host"]) + self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n") - def test_malformed_json_fails_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "not readable JSON"): - self.configure(ssh_keys_text="not json") - - def test_non_array_json_fails_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): - self.configure(ssh_keys={"key": "ssh-ed25519 AAAA"}) - - def test_non_string_entries_fail_the_phase(self): - with self.assertRaisesRegex(RuntimeError, "JSON array of public key strings"): - self.configure(ssh_keys=["ssh-ed25519 AAAA jeff@host", 42]) - - def test_empty_key_list_fails_the_phase(self): + def test_empty_file_fails_the_phase(self): with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): - self.configure(ssh_keys=[]) + self.configure(authorized_keys="") - def test_blank_only_key_list_fails_the_phase(self): + def test_blank_only_file_fails_the_phase(self): with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): - self.configure(ssh_keys=["", " "]) + self.configure(authorized_keys="\n \n") + + def test_comment_only_file_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"): + self.configure(authorized_keys="# no keys here\n") class OptionalPathTest(unittest.TestCase): @@ -144,7 +140,7 @@ class OptionalPathTest(unittest.TestCase): self.assertIsNone(_optional_path(None)) self.assertIsNone(_optional_path("")) - self.assertIsNone(_optional_path("/does/not/exist/ssh.json")) + self.assertIsNone(_optional_path("/does/not/exist/authorized_keys")) def test_existing_path_is_returned(self): from orchestrator.context import _optional_path