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 <noreply@anthropic.com>
This commit is contained in:
1 parent
be68a76a44
commit
f11a70c6ba
9 files changed
+80
-86
No files matched your search
@@ -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/
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+11
-11
@@ -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 `<target>/home/<user>/.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 `<target>/home/<user>/.ssh/authorized_keys`.
|
||||
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
|
||||
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
|
||||
- `arch-chroot <target> 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 <user>@<vm>` 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 <user>@<vm>` 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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user