feat: enhance user account management during VM creation with automatic user creation and metadata preservation

This commit is contained in:
2026-09-22 00:07:37 +05:30
parent c75c7d9baf
commit 71425a98e3
7 changed files with 347 additions and 22 deletions
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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):