feat: enhance user account management during VM creation with automatic user creation and metadata preservation
This commit is contained in:
+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