Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a58daa56 | ||
|
|
71425a98e3 |
+19
-7
@@ -526,18 +526,28 @@ make recovery convenient. Proven-gone legacy processes can be cleaned up.
|
||||
### Template Contract
|
||||
|
||||
`images/ubuntu.ext4` is a template only. Each create operation copies it into
|
||||
the VM runtime directory before UVM modifies the requested existing account's
|
||||
shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees
|
||||
the VM runtime directory before UVM provisions the requested account, its
|
||||
shadow entry, and OpenSSH password policy with `debugfs`. Firecracker only sees
|
||||
the resulting private copy. Never change that to a shared writable rootfs.
|
||||
Copies use coreutils `cp --reflink=auto --sparse=always`; the new disk is resized
|
||||
to the requested capacity before credential provisioning. Existing disk resizes
|
||||
do not reprovision credentials or host keys.
|
||||
|
||||
Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's
|
||||
`/etc/ssh/sshd_config`, and the requested account. UVM does not create missing
|
||||
users. It replaces conventional RSA, ECDSA, and Ed25519 host keys in each
|
||||
private disk and removes the known public login key from Firecracker's bionic
|
||||
image. A VM must never rely on reusable SSH private keys from a shared template.
|
||||
Guest images must provide `/etc/passwd`, `/etc/shadow`, and OpenSSH's
|
||||
`/etc/ssh/sshd_config`. Existing accounts retain their identity, groups, home,
|
||||
shell, and password-aging policy. Missing custom users are created offline,
|
||||
using `/etc/group` and an available `/bin/sh` login shell. UVM allocates unused
|
||||
UIDs/GIDs from 1000-60000, accounting for primary GIDs in passwd as well as
|
||||
group entries, and reuses a same-named group when present. New group entries
|
||||
also update `/etc/gshadow` if it exists. Account-file metadata is preserved.
|
||||
The new `/home/USERNAME` directory is mode `0700`, owned by the new UID/GID;
|
||||
`/home` is created if absent and existing home paths are not overwritten.
|
||||
Directory ownership/modes and account-file writes are read back before the
|
||||
disk can be marked ready. The default `root` account must already exist.
|
||||
|
||||
UVM replaces conventional RSA, ECDSA, and Ed25519 host keys in each private
|
||||
disk and removes the known public login key from Firecracker's bionic image.
|
||||
A VM must never rely on reusable SSH private keys from a shared template.
|
||||
|
||||
### Integrity Model
|
||||
|
||||
@@ -880,6 +890,8 @@ exercise the kernel packet path.
|
||||
`test_storage.py` also exercises real e2fsprogs against temporary unmounted,
|
||||
sparse ext4 images when the tools are installed: default 30 GiB creation,
|
||||
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures.
|
||||
`test_images.py` uses temporary ext4 images to verify custom-user creation,
|
||||
UID/GID allocation, account/home metadata, existing accounts, and password hashes.
|
||||
Lifecycle resize tests use fake processes/networking to verify ordering,
|
||||
single-lock ownership, identity preservation, pending intent, and failure recovery.
|
||||
|
||||
|
||||
@@ -351,13 +351,20 @@ The installed kernel and rootfs are templates. `create` copies the rootfs to
|
||||
the VM runtime directory before Firecracker opens it read-write, so VMs do not
|
||||
share a mutable root disk.
|
||||
|
||||
During creation, UVM updates the private ext4 disk with a password for an
|
||||
existing guest account and enables OpenSSH password authentication. The
|
||||
During creation, UVM updates the private ext4 disk with a password for the
|
||||
requested guest account and enables OpenSSH password authentication. Missing
|
||||
custom usernames are created with an unused regular-user UID, a primary group,
|
||||
a private `/home/USERNAME` directory, and `/bin/sh` as their login shell. The
|
||||
defaults are username `root` and password `root`. A custom image must provide:
|
||||
|
||||
- `sshd`
|
||||
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config`
|
||||
- The requested user account; UVM does not create missing users
|
||||
- `/etc/group` and `/bin/sh` when creating a new user
|
||||
|
||||
Existing accounts retain their UID, groups, home directory, and shell. A new
|
||||
user reuses a same-named group if present; otherwise UVM creates one with an
|
||||
unused GID. `/etc/gshadow` is updated when present. The default `root` account
|
||||
must already exist in the image.
|
||||
|
||||
The known public demo key bundled in Firecracker's default bionic image is
|
||||
removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
|
||||
|
||||
@@ -234,7 +234,7 @@ private port rules. The default password produces a warning on stderr. Use
|
||||
### Names and Credentials
|
||||
|
||||
```sh
|
||||
sudo ./uvm.py create --name web --username root --password '<guest-password>'
|
||||
sudo ./uvm.py create --name web --username codemate-user --password '<guest-password>'
|
||||
```
|
||||
|
||||
Names are optional, unique within the registry, and case-sensitive. They must
|
||||
@@ -243,8 +243,11 @@ that impersonate generated IDs (`vm-` plus 32 hexadecimal characters) are
|
||||
rejected. Names and IPs stay reserved while a VM is stopped. Names do not
|
||||
create DNS records, and identifiers are not matched by ID prefix.
|
||||
|
||||
`--username` must name an account already present in the guest image. A
|
||||
`--password` value is visible in process arguments and may enter shell
|
||||
`--username` selects the guest login account. If a custom username is missing,
|
||||
UVM creates it on the private disk with a primary group, `/home/USERNAME`, and
|
||||
the `/bin/sh` login shell. Existing accounts keep their identity and home.
|
||||
|
||||
A `--password` value is visible in process arguments and may enter shell
|
||||
history. Prefer `--password-stdin`, which reads one line, strips the trailing
|
||||
line ending, and rejects empty passwords, control characters, or passwords
|
||||
longer than 128 characters. It cannot be combined with `--password`.
|
||||
@@ -405,11 +408,13 @@ guest, or prove SSH/egress connectivity. Use inspect for per-VM policy health.
|
||||
|
||||
## 7. Connect with SSH
|
||||
|
||||
UVM provisions a password for an existing account. The default credentials are
|
||||
`root` / `root`. Your guest image must include:
|
||||
UVM provisions a password for the requested account, creating missing custom
|
||||
users during VM creation. The default credentials are `root` / `root`. Your
|
||||
guest image must include:
|
||||
|
||||
- An SSH server.
|
||||
- The requested login user plus standard passwd and shadow files.
|
||||
- Standard passwd and shadow files, plus `/etc/ssh/sshd_config`.
|
||||
- `/etc/group` and `/bin/sh` for new users; the default `root` account must exist.
|
||||
|
||||
UVM replaces conventional RSA, ECDSA, and Ed25519 SSH host keys in the private
|
||||
disk during creation and removes Firecracker's publicly known demo login key.
|
||||
|
||||
+190
-2
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from uvm.config import Settings
|
||||
from uvm.errors import UvmError
|
||||
from uvm.errors import UvmError, ValidationError
|
||||
from uvm.images import (
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY,
|
||||
_GuestFile,
|
||||
@@ -17,7 +19,7 @@ from uvm.images import (
|
||||
ImageStore,
|
||||
)
|
||||
from uvm.integrity import write_manifest
|
||||
from uvm.system import CommandResult
|
||||
from uvm.system import CommandResult, CommandRunner
|
||||
|
||||
|
||||
class PasswordHashRunner:
|
||||
@@ -69,6 +71,16 @@ class ImageStoreTests(unittest.TestCase):
|
||||
self.assertEqual(runner.options["input_text"], "secret-value\n")
|
||||
self.assertTrue(runner.options["sensitive"])
|
||||
|
||||
def test_rejects_invalid_usernames_before_accessing_the_guest_disk(self) -> None:
|
||||
runner = PasswordHashRunner()
|
||||
store = ImageStore(Settings(), runner=runner) # type: ignore[arg-type]
|
||||
|
||||
for username in ("../root", "user\nroot", "user:1000", "user name"):
|
||||
with self.subTest(username=username), self.assertRaises(ValidationError):
|
||||
store.provision_credentials(Path("/tmp/rootfs.ext4"), username, "secret")
|
||||
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_updates_only_the_requested_shadow_entry(self) -> None:
|
||||
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
|
||||
|
||||
@@ -187,3 +199,179 @@ class ImageStoreTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_directory_creation_checks_effects_even_when_debugfs_reports_success(self) -> None:
|
||||
class FailedMkdirRunner:
|
||||
@staticmethod
|
||||
def run(command, **_options) -> CommandResult:
|
||||
return CommandResult(
|
||||
args=tuple(map(str, command)),
|
||||
returncode=0,
|
||||
stderr="File not found by ext2_lookup" if "stat " in str(command[2]) else "",
|
||||
)
|
||||
|
||||
store = ImageStore(Settings(), runner=FailedMkdirRunner()) # type: ignore[arg-type]
|
||||
with self.assertRaisesRegex(UvmError, "could not verify guest rootfs directory"):
|
||||
store._create_guest_directory(Path("/tmp/rootfs.ext4"), "/home/user", 0o700, 1000, 1000)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp", "openssl", "ssh-keygen")),
|
||||
"e2fsprogs, coreutils, OpenSSL, and OpenSSH are required for offline credential checks",
|
||||
)
|
||||
class OfflineGuestCredentialsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.base = Path(temporary.name)
|
||||
self.template = self.base / "template.ext4"
|
||||
self.disk = self.base / "rootfs.ext4"
|
||||
self.runner = CommandRunner(emit=None)
|
||||
self.store = ImageStore(Settings(base=self.base), self.runner)
|
||||
self.files = {
|
||||
"/etc/passwd": (
|
||||
"root:x:0:0:root:/root:/bin/bash\n"
|
||||
"service:x:999:1002::/nonexistent:/usr/sbin/nologin\n"
|
||||
"ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash\n"
|
||||
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n"
|
||||
),
|
||||
"/etc/shadow": (
|
||||
"root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
|
||||
"ubuntu:!:2:0:99999:7:::\nnobody:*:1:0:99999:7:::\n"
|
||||
),
|
||||
"/etc/group": "root:x:0:\nshadow:x:42:\nubuntu:x:1000:\nreserved:x:1001:\nnogroup:x:65534:\n",
|
||||
"/etc/gshadow": "root:*::\nshadow:*::\nubuntu:!::\nreserved:!::\nnogroup:*::\n",
|
||||
"/etc/ssh/sshd_config": "PasswordAuthentication no\nPermitRootLogin no\n",
|
||||
"/root/.ssh/authorized_keys": f"{_FIRECRACKER_DEMO_PUBLIC_KEY} demo\nssh-ed25519 AAAA-own-key\n",
|
||||
}
|
||||
|
||||
def make_disk(self) -> None:
|
||||
root = self.base / "guest"
|
||||
for guest_path, contents in self.files.items():
|
||||
path = root / guest_path.lstrip("/")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
path.chmod(0o640 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0o644)
|
||||
with self.template.open("wb") as image:
|
||||
image.truncate(16 * 1024 * 1024)
|
||||
self.runner.run(("mkfs.ext4", "-q", "-F", "-d", root, self.template), capture=True)
|
||||
for guest_path in self.files:
|
||||
gid = 42 if guest_path in ("/etc/shadow", "/etc/gshadow") else 0
|
||||
for field, value in (("uid", 0), ("gid", gid)):
|
||||
self.runner.run(
|
||||
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", self.template),
|
||||
capture=True,
|
||||
)
|
||||
self.store._copy_sparse(self.template, self.disk)
|
||||
|
||||
def contents(self, guest_path: str) -> str:
|
||||
return self.runner.run(("debugfs", "-R", f"cat {guest_path}", self.disk), capture=True).stdout
|
||||
|
||||
def entry(self, guest_path: str, username: str) -> list[str]:
|
||||
return next(line.split(":") for line in self.contents(guest_path).splitlines()
|
||||
if line.split(":", 1)[0] == username)
|
||||
|
||||
def assert_metadata(self, guest_path: str, mode: int, uid: int, gid: int) -> None:
|
||||
result = self.runner.run(("debugfs", "-R", f"stat {guest_path}", self.disk), capture=True)
|
||||
self.assertRegex(result.stdout, rf"Mode:\s+0*{mode:o}\b")
|
||||
self.assertRegex(result.stdout, rf"User:\s+{uid}\s+Group:\s+{gid}\b")
|
||||
|
||||
def assert_password(self, username: str, password: str) -> None:
|
||||
password_hash = self.entry("/etc/shadow", username)[1]
|
||||
self.assertTrue(password_hash.startswith("$6$"))
|
||||
result = self.runner.run(
|
||||
("openssl", "passwd", "-6", "-salt", password_hash.split("$")[2], "-stdin"),
|
||||
capture=True, input_text=f"{password}\n", sensitive=True,
|
||||
)
|
||||
self.assertEqual(password_hash, result.stdout.strip())
|
||||
|
||||
def test_custom_username_creates_a_login_account_on_the_private_disk(self) -> None:
|
||||
self.make_disk()
|
||||
template_digest = hashlib.sha256(self.template.read_bytes()).hexdigest()
|
||||
day_before = int(time.time() // 86400)
|
||||
|
||||
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||
|
||||
# UID 1000 and GIDs 1000/1001 are occupied; GID 1002 is used only in passwd.
|
||||
self.assertEqual(
|
||||
self.contents("/etc/passwd"),
|
||||
self.files["/etc/passwd"] + "codemate-user:x:1001:1003::/home/codemate-user:/bin/sh\n",
|
||||
)
|
||||
self.assertEqual(self.contents("/etc/group"), self.files["/etc/group"] + "codemate-user:x:1003:\n")
|
||||
self.assertEqual(self.contents("/etc/gshadow"), self.files["/etc/gshadow"] + "codemate-user:!::\n")
|
||||
self.assertTrue(self.contents("/etc/shadow").startswith(self.files["/etc/shadow"]))
|
||||
shadow = self.entry("/etc/shadow", "codemate-user")
|
||||
self.assertLessEqual(day_before, int(shadow[2]))
|
||||
self.assertLessEqual(int(shadow[2]), int(time.time() // 86400))
|
||||
self.assertEqual(shadow[3:], ["0", "99999", "7", "", "", ""])
|
||||
self.assert_password("codemate-user", "custom-password")
|
||||
for guest_path in ("/etc/passwd", "/etc/group"):
|
||||
self.assert_metadata(guest_path, 0o644, 0, 0)
|
||||
for guest_path in ("/etc/shadow", "/etc/gshadow"):
|
||||
self.assert_metadata(guest_path, 0o640, 0, 42)
|
||||
self.assert_metadata("/home", 0o755, 0, 0)
|
||||
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1003)
|
||||
sshd_config = self.contents("/etc/ssh/sshd_config")
|
||||
self.assertIn("Match User codemate-user\n PasswordAuthentication yes\n", sshd_config)
|
||||
self.assertIn("PermitRootLogin no", sshd_config)
|
||||
self.assertEqual(self.contents("/root/.ssh/authorized_keys"), "ssh-ed25519 AAAA-own-key\n")
|
||||
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
|
||||
self.assertEqual(hashlib.sha256(self.template.read_bytes()).hexdigest(), template_digest)
|
||||
|
||||
def test_existing_root_and_non_root_accounts_keep_their_identity_and_home(self) -> None:
|
||||
self.files["/home/ubuntu/data"] = "existing guest data\n"
|
||||
self.make_disk()
|
||||
|
||||
for username in ("root", "ubuntu"):
|
||||
with self.subTest(username=username):
|
||||
self.store._copy_sparse(self.template, self.disk)
|
||||
original_shadow = self.entry("/etc/shadow", username)
|
||||
self.store.provision_credentials(self.disk, username, "changed-password")
|
||||
|
||||
for guest_path in ("/etc/passwd", "/etc/group", "/etc/gshadow", "/home/ubuntu/data"):
|
||||
self.assertEqual(self.contents(guest_path), self.files[guest_path])
|
||||
self.assertEqual(self.entry("/etc/shadow", username)[2:], original_shadow[2:])
|
||||
self.assert_password(username, "changed-password")
|
||||
for line in self.files["/etc/shadow"].splitlines():
|
||||
if line.split(":", 1)[0] != username:
|
||||
self.assertIn(line + "\n", self.contents("/etc/shadow"))
|
||||
|
||||
def test_new_user_reuses_an_existing_named_group(self) -> None:
|
||||
self.files["/etc/group"] += "codemate-user:x:1100:ubuntu\n"
|
||||
self.files["/etc/gshadow"] += "codemate-user:!::ubuntu\n"
|
||||
self.make_disk()
|
||||
|
||||
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||
|
||||
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1100"])
|
||||
for guest_path in ("/etc/group", "/etc/gshadow"):
|
||||
self.assertEqual(self.contents(guest_path), self.files[guest_path])
|
||||
self.assert_metadata("/home/codemate-user", 0o700, 1001, 1100)
|
||||
|
||||
def test_new_user_supports_images_without_gshadow_and_without_final_newlines(self) -> None:
|
||||
del self.files["/etc/gshadow"]
|
||||
for guest_path in ("/etc/passwd", "/etc/shadow", "/etc/group"):
|
||||
self.files[guest_path] = self.files[guest_path].rstrip("\n")
|
||||
self.files["/home/retained"] = "existing home directory contents\n"
|
||||
self.make_disk()
|
||||
|
||||
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||
|
||||
self.assertEqual(self.entry("/etc/passwd", "codemate-user")[2:4], ["1001", "1003"])
|
||||
self.assertEqual(self.entry("/etc/group", "codemate-user"), ["codemate-user", "x", "1003", ""])
|
||||
self.assert_password("codemate-user", "custom-password")
|
||||
self.assertEqual(self.contents("/home/retained"), self.files["/home/retained"])
|
||||
self.assertIsNone(self.store._read_guest_file(
|
||||
self.disk, "/etc/gshadow", self.base / "gshadow", required=False,
|
||||
))
|
||||
|
||||
def test_existing_home_path_is_not_overwritten(self) -> None:
|
||||
self.files["/home/codemate-user"] = "existing guest file\n"
|
||||
self.make_disk()
|
||||
|
||||
with self.assertRaisesRegex(UvmError, "cannot create guest rootfs directory: /home/codemate-user"):
|
||||
self.store.provision_credentials(self.disk, "codemate-user", "custom-password")
|
||||
|
||||
self.assertEqual(self.contents("/home/codemate-user"), self.files["/home/codemate-user"])
|
||||
self.assertEqual(self.contents("/etc/passwd"), self.files["/etc/passwd"])
|
||||
self.assertEqual(self.contents("/etc/shadow"), self.files["/etc/shadow"])
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
create.add_argument(
|
||||
"--username",
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
help="guest login account; created if missing (defaults to root)",
|
||||
)
|
||||
credentials = create.add_mutually_exclusive_group()
|
||||
credentials.add_argument(
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
<form id="create-form" class="create-layout">
|
||||
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
||||
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory. Credentials are applied to the machine’s private disk.</p></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
||||
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
||||
</form>
|
||||
|
||||
+115
-2
@@ -6,6 +6,7 @@ import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,6 +14,7 @@ from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||
from .errors import UvmError, ValidationError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
from .validation import parse_username
|
||||
|
||||
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY = (
|
||||
@@ -183,8 +185,9 @@ class ImageStore:
|
||||
os.fsync(image.fileno())
|
||||
|
||||
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
"""Create a missing guest account and set its password in an offline ext4 disk."""
|
||||
|
||||
parse_username(username)
|
||||
password_hash = self._password_hash(password)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
|
||||
@@ -212,7 +215,7 @@ class ImageStore:
|
||||
if ":" in line
|
||||
}
|
||||
if username not in account_names:
|
||||
raise UvmError(f"guest user does not exist in rootfs: {username}")
|
||||
self._create_guest_user(disk, username, passwd, shadow, temporary)
|
||||
|
||||
shadow.path.write_text(
|
||||
_set_shadow_password(
|
||||
@@ -241,6 +244,109 @@ class ImageStore:
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise UvmError(f"could not provision guest credentials: {error}") from error
|
||||
|
||||
def _create_guest_user(
|
||||
self,
|
||||
disk: Path,
|
||||
username: str,
|
||||
passwd: _GuestFile,
|
||||
shadow: _GuestFile,
|
||||
temporary: Path,
|
||||
) -> None:
|
||||
if username == "root":
|
||||
raise UvmError("guest rootfs is missing the root account")
|
||||
group = self._read_guest_file(disk, "/etc/group", temporary / "group")
|
||||
gshadow = self._read_guest_file(
|
||||
disk, "/etc/gshadow", temporary / "gshadow", required=False,
|
||||
)
|
||||
assert group is not None
|
||||
passwd_entries = [
|
||||
line.split(":")
|
||||
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
]
|
||||
group_entries = [
|
||||
line.split(":")
|
||||
for line in group.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
]
|
||||
try:
|
||||
used_uids = {int(fields[2]) for fields in passwd_entries}
|
||||
used_gids = {int(fields[3]) for fields in passwd_entries}
|
||||
used_gids.update(int(fields[2]) for fields in group_entries)
|
||||
except (IndexError, ValueError) as error:
|
||||
raise UvmError("guest rootfs has invalid passwd or group entries") from error
|
||||
|
||||
uid = _next_guest_id(used_uids)
|
||||
existing_group = next((fields for fields in group_entries if fields[0] == username), None)
|
||||
if existing_group is not None:
|
||||
gid = int(existing_group[2])
|
||||
else:
|
||||
gid = uid if uid not in used_gids else _next_guest_id(used_gids)
|
||||
|
||||
home = f"/home/{username}"
|
||||
updates = [
|
||||
("/etc/passwd", passwd, f"{username}:x:{uid}:{gid}::{home}:/bin/sh"),
|
||||
("/etc/shadow", shadow, f"{username}:!:{int(time.time() // 86400)}:0:99999:7:::"),
|
||||
]
|
||||
if existing_group is None:
|
||||
updates.append(("/etc/group", group, f"{username}:x:{gid}:"))
|
||||
if gshadow is not None:
|
||||
updates.append(("/etc/gshadow", gshadow, f"{username}:!::"))
|
||||
for guest_path, source, entry in updates:
|
||||
contents = source.path.read_text(encoding="utf-8")
|
||||
if any(line.split(":", 1)[0] == username for line in contents.splitlines()):
|
||||
raise UvmError(f"guest rootfs already has an entry for {username} in {guest_path}")
|
||||
separator = "" if not contents or contents.endswith("\n") else "\n"
|
||||
source.path.write_text(f"{contents}{separator}{entry}\n", encoding="utf-8")
|
||||
source.path.chmod(source.mode)
|
||||
|
||||
self._create_guest_directory(disk, "/home", 0o755, 0, 0, exist_ok=True)
|
||||
self._create_guest_directory(disk, home, 0o700, uid, gid)
|
||||
for guest_path, source, _entry in updates:
|
||||
if guest_path != "/etc/shadow": # Published with its password below.
|
||||
self._write_guest_file(disk, guest_path, source)
|
||||
|
||||
def _create_guest_directory(
|
||||
self,
|
||||
disk: Path,
|
||||
guest_path: str,
|
||||
mode: int,
|
||||
uid: int,
|
||||
gid: int,
|
||||
*,
|
||||
exist_ok: bool = False,
|
||||
) -> None:
|
||||
command = ("debugfs", "-R", f"stat {guest_path}", disk)
|
||||
existing = self._runner.run(command, check=False, capture=True)
|
||||
if exist_ok and re.search(r"Type:\s+directory\b", existing.stdout):
|
||||
return
|
||||
if "file not found" not in f"{existing.stdout}\n{existing.stderr}".lower():
|
||||
raise UvmError(f"cannot create guest rootfs directory: {guest_path}")
|
||||
|
||||
self._runner.run(("debugfs", "-w", "-R", f"mkdir {guest_path}", disk), capture=True)
|
||||
for field, value in (
|
||||
("mode", f"0{0o40000 | mode:o}"),
|
||||
("uid", str(uid)),
|
||||
("gid", str(gid)),
|
||||
):
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", disk),
|
||||
capture=True,
|
||||
)
|
||||
|
||||
# debugfs can report command failures while still returning exit status zero.
|
||||
created = self._runner.run(command, capture=True)
|
||||
mode_match = _MODE_PATTERN.search(created.stdout)
|
||||
owner_match = _OWNER_PATTERN.search(created.stdout)
|
||||
if (
|
||||
re.search(r"Type:\s+directory\b", created.stdout) is None
|
||||
or mode_match is None
|
||||
or owner_match is None
|
||||
or int(mode_match.group(1), 8) != mode
|
||||
or (int(owner_match.group(1)), int(owner_match.group(2))) != (uid, gid)
|
||||
):
|
||||
raise UvmError(f"could not verify guest rootfs directory: {guest_path}")
|
||||
|
||||
def _password_hash(self, password: str) -> str:
|
||||
result = self._runner.run(
|
||||
("openssl", "passwd", "-6", "-stdin"),
|
||||
@@ -429,6 +535,13 @@ class ImageStore:
|
||||
raise UvmError("could not remove the insecure Firecracker demo SSH key")
|
||||
|
||||
|
||||
def _next_guest_id(used: set[int]) -> int:
|
||||
for candidate in range(1000, 60001):
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
raise UvmError("guest rootfs has no available regular user/group IDs")
|
||||
|
||||
|
||||
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
|
||||
lines = contents.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
|
||||
Reference in New Issue
Block a user