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
|
### 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user