Compare commits

...
2 Commits
7 changed files with 347 additions and 22 deletions
+19 -7
View File
@@ -526,18 +526,28 @@ make recovery convenient. Proven-gone legacy processes can be cleaned up.
### Template Contract ### Template Contract
`images/ubuntu.ext4` is a template only. Each create operation copies it into `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 the VM runtime directory before UVM provisions the requested account, its
shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees shadow entry, and OpenSSH password policy with `debugfs`. Firecracker only sees
the resulting private copy. Never change that to a shared writable rootfs. 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 Copies use coreutils `cp --reflink=auto --sparse=always`; the new disk is resized
to the requested capacity before credential provisioning. Existing disk resizes to the requested capacity before credential provisioning. Existing disk resizes
do not reprovision credentials or host keys. do not reprovision credentials or host keys.
Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's Guest images must provide `/etc/passwd`, `/etc/shadow`, and OpenSSH's
`/etc/ssh/sshd_config`, and the requested account. UVM does not create missing `/etc/ssh/sshd_config`. Existing accounts retain their identity, groups, home,
users. It replaces conventional RSA, ECDSA, and Ed25519 host keys in each shell, and password-aging policy. Missing custom users are created offline,
private disk and removes the known public login key from Firecracker's bionic using `/etc/group` and an available `/bin/sh` login shell. UVM allocates unused
image. A VM must never rely on reusable SSH private keys from a shared template. 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 ### Integrity Model
@@ -880,6 +890,8 @@ exercise the kernel packet path.
`test_storage.py` also exercises real e2fsprogs against temporary unmounted, `test_storage.py` also exercises real e2fsprogs against temporary unmounted,
sparse ext4 images when the tools are installed: default 30 GiB creation, sparse ext4 images when the tools are installed: default 30 GiB creation,
growth, shrinking, geometry, and retained data/password-hash/host-key fixtures. 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, Lifecycle resize tests use fake processes/networking to verify ordering,
single-lock ownership, identity preservation, pending intent, and failure recovery. single-lock ownership, identity preservation, pending intent, and failure recovery.
+10 -3
View File
@@ -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 the VM runtime directory before Firecracker opens it read-write, so VMs do not
share a mutable root disk. share a mutable root disk.
During creation, UVM updates the private ext4 disk with a password for an During creation, UVM updates the private ext4 disk with a password for the
existing guest account and enables OpenSSH password authentication. 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: defaults are username `root` and password `root`. A custom image must provide:
- `sshd` - `sshd`
- `/etc/passwd`, `/etc/shadow`, and `/etc/ssh/sshd_config` - `/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 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, removed from each private disk. UVM also replaces the conventional RSA, ECDSA,
+11 -6
View File
@@ -234,7 +234,7 @@ private port rules. The default password produces a warning on stderr. Use
### Names and Credentials ### Names and Credentials
```sh ```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 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 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. create DNS records, and identifiers are not matched by ID prefix.
`--username` must name an account already present in the guest image. A `--username` selects the guest login account. If a custom username is missing,
`--password` value is visible in process arguments and may enter shell 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 history. Prefer `--password-stdin`, which reads one line, strips the trailing
line ending, and rejects empty passwords, control characters, or passwords line ending, and rejects empty passwords, control characters, or passwords
longer than 128 characters. It cannot be combined with `--password`. 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 ## 7. Connect with SSH
UVM provisions a password for an existing account. The default credentials are UVM provisions a password for the requested account, creating missing custom
`root` / `root`. Your guest image must include: users during VM creation. The default credentials are `root` / `root`. Your
guest image must include:
- An SSH server. - 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 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. disk during creation and removes Firecracker's publicly known demo login key.
+190 -2
View File
@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import shutil
import tempfile import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from uvm.config import Settings from uvm.config import Settings
from uvm.errors import UvmError from uvm.errors import UvmError, ValidationError
from uvm.images import ( from uvm.images import (
_FIRECRACKER_DEMO_PUBLIC_KEY, _FIRECRACKER_DEMO_PUBLIC_KEY,
_GuestFile, _GuestFile,
@@ -17,7 +19,7 @@ from uvm.images import (
ImageStore, ImageStore,
) )
from uvm.integrity import write_manifest from uvm.integrity import write_manifest
from uvm.system import CommandResult from uvm.system import CommandResult, CommandRunner
class PasswordHashRunner: class PasswordHashRunner:
@@ -69,6 +71,16 @@ class ImageStoreTests(unittest.TestCase):
self.assertEqual(runner.options["input_text"], "secret-value\n") self.assertEqual(runner.options["input_text"], "secret-value\n")
self.assertTrue(runner.options["sensitive"]) 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: def test_updates_only_the_requested_shadow_entry(self) -> None:
original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n" original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n"
@@ -187,3 +199,179 @@ class ImageStoreTests(unittest.TestCase):
) )
self.assertEqual(runner.calls, []) 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
View File
@@ -111,7 +111,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
create.add_argument( create.add_argument(
"--username", "--username",
default=application.settings.default_ssh_user, 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 = create.add_mutually_exclusive_group()
credentials.add_argument( credentials.add_argument(
+1 -1
View File
@@ -193,7 +193,7 @@
<form id="create-form" class="create-layout"> <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> <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"><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> <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> <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> </form>
+115 -2
View File
@@ -6,6 +6,7 @@ import os
import re import re
import stat import stat
import tempfile import tempfile
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -13,6 +14,7 @@ from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError, ValidationError from .errors import UvmError, ValidationError
from .integrity import load_manifest, verify_file from .integrity import load_manifest, verify_file
from .system import CommandRunner from .system import CommandRunner
from .validation import parse_username
_FIRECRACKER_DEMO_PUBLIC_KEY = ( _FIRECRACKER_DEMO_PUBLIC_KEY = (
@@ -183,8 +185,9 @@ class ImageStore:
os.fsync(image.fileno()) os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None: 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) password_hash = self._password_hash(password)
try: try:
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name: with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
@@ -212,7 +215,7 @@ class ImageStore:
if ":" in line if ":" in line
} }
if username not in account_names: 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( shadow.path.write_text(
_set_shadow_password( _set_shadow_password(
@@ -241,6 +244,109 @@ class ImageStore:
except (OSError, UnicodeError) as error: except (OSError, UnicodeError) as error:
raise UvmError(f"could not provision guest credentials: {error}") from 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: def _password_hash(self, password: str) -> str:
result = self._runner.run( result = self._runner.run(
("openssl", "passwd", "-6", "-stdin"), ("openssl", "passwd", "-6", "-stdin"),
@@ -429,6 +535,13 @@ class ImageStore:
raise UvmError("could not remove the insecure Firecracker demo SSH key") 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: def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
lines = contents.splitlines() lines = contents.splitlines()
for index, line in enumerate(lines): for index, line in enumerate(lines):