feat: enhance user account management during VM creation with automatic user creation and metadata preservation
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
create.add_argument(
|
||||
"--username",
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
help="guest login account; created if missing (defaults to root)",
|
||||
)
|
||||
credentials = create.add_mutually_exclusive_group()
|
||||
credentials.add_argument(
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
<form id="create-form" class="create-layout">
|
||||
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
|
||||
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Missing users are created with a home directory. Credentials are applied to the machine’s private disk.</p></section>
|
||||
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
|
||||
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
|
||||
</form>
|
||||
|
||||
+115
-2
@@ -6,6 +6,7 @@ import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,6 +14,7 @@ from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||
from .errors import UvmError, ValidationError
|
||||
from .integrity import load_manifest, verify_file
|
||||
from .system import CommandRunner
|
||||
from .validation import parse_username
|
||||
|
||||
|
||||
_FIRECRACKER_DEMO_PUBLIC_KEY = (
|
||||
@@ -183,8 +185,9 @@ class ImageStore:
|
||||
os.fsync(image.fileno())
|
||||
|
||||
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
|
||||
"""Set an existing guest account password in an offline ext4 disk."""
|
||||
"""Create a missing guest account and set its password in an offline ext4 disk."""
|
||||
|
||||
parse_username(username)
|
||||
password_hash = self._password_hash(password)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="uvm-credentials-") as temporary_name:
|
||||
@@ -212,7 +215,7 @@ class ImageStore:
|
||||
if ":" in line
|
||||
}
|
||||
if username not in account_names:
|
||||
raise UvmError(f"guest user does not exist in rootfs: {username}")
|
||||
self._create_guest_user(disk, username, passwd, shadow, temporary)
|
||||
|
||||
shadow.path.write_text(
|
||||
_set_shadow_password(
|
||||
@@ -241,6 +244,109 @@ class ImageStore:
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise UvmError(f"could not provision guest credentials: {error}") from error
|
||||
|
||||
def _create_guest_user(
|
||||
self,
|
||||
disk: Path,
|
||||
username: str,
|
||||
passwd: _GuestFile,
|
||||
shadow: _GuestFile,
|
||||
temporary: Path,
|
||||
) -> None:
|
||||
if username == "root":
|
||||
raise UvmError("guest rootfs is missing the root account")
|
||||
group = self._read_guest_file(disk, "/etc/group", temporary / "group")
|
||||
gshadow = self._read_guest_file(
|
||||
disk, "/etc/gshadow", temporary / "gshadow", required=False,
|
||||
)
|
||||
assert group is not None
|
||||
passwd_entries = [
|
||||
line.split(":")
|
||||
for line in passwd.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
]
|
||||
group_entries = [
|
||||
line.split(":")
|
||||
for line in group.path.read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line
|
||||
]
|
||||
try:
|
||||
used_uids = {int(fields[2]) for fields in passwd_entries}
|
||||
used_gids = {int(fields[3]) for fields in passwd_entries}
|
||||
used_gids.update(int(fields[2]) for fields in group_entries)
|
||||
except (IndexError, ValueError) as error:
|
||||
raise UvmError("guest rootfs has invalid passwd or group entries") from error
|
||||
|
||||
uid = _next_guest_id(used_uids)
|
||||
existing_group = next((fields for fields in group_entries if fields[0] == username), None)
|
||||
if existing_group is not None:
|
||||
gid = int(existing_group[2])
|
||||
else:
|
||||
gid = uid if uid not in used_gids else _next_guest_id(used_gids)
|
||||
|
||||
home = f"/home/{username}"
|
||||
updates = [
|
||||
("/etc/passwd", passwd, f"{username}:x:{uid}:{gid}::{home}:/bin/sh"),
|
||||
("/etc/shadow", shadow, f"{username}:!:{int(time.time() // 86400)}:0:99999:7:::"),
|
||||
]
|
||||
if existing_group is None:
|
||||
updates.append(("/etc/group", group, f"{username}:x:{gid}:"))
|
||||
if gshadow is not None:
|
||||
updates.append(("/etc/gshadow", gshadow, f"{username}:!::"))
|
||||
for guest_path, source, entry in updates:
|
||||
contents = source.path.read_text(encoding="utf-8")
|
||||
if any(line.split(":", 1)[0] == username for line in contents.splitlines()):
|
||||
raise UvmError(f"guest rootfs already has an entry for {username} in {guest_path}")
|
||||
separator = "" if not contents or contents.endswith("\n") else "\n"
|
||||
source.path.write_text(f"{contents}{separator}{entry}\n", encoding="utf-8")
|
||||
source.path.chmod(source.mode)
|
||||
|
||||
self._create_guest_directory(disk, "/home", 0o755, 0, 0, exist_ok=True)
|
||||
self._create_guest_directory(disk, home, 0o700, uid, gid)
|
||||
for guest_path, source, _entry in updates:
|
||||
if guest_path != "/etc/shadow": # Published with its password below.
|
||||
self._write_guest_file(disk, guest_path, source)
|
||||
|
||||
def _create_guest_directory(
|
||||
self,
|
||||
disk: Path,
|
||||
guest_path: str,
|
||||
mode: int,
|
||||
uid: int,
|
||||
gid: int,
|
||||
*,
|
||||
exist_ok: bool = False,
|
||||
) -> None:
|
||||
command = ("debugfs", "-R", f"stat {guest_path}", disk)
|
||||
existing = self._runner.run(command, check=False, capture=True)
|
||||
if exist_ok and re.search(r"Type:\s+directory\b", existing.stdout):
|
||||
return
|
||||
if "file not found" not in f"{existing.stdout}\n{existing.stderr}".lower():
|
||||
raise UvmError(f"cannot create guest rootfs directory: {guest_path}")
|
||||
|
||||
self._runner.run(("debugfs", "-w", "-R", f"mkdir {guest_path}", disk), capture=True)
|
||||
for field, value in (
|
||||
("mode", f"0{0o40000 | mode:o}"),
|
||||
("uid", str(uid)),
|
||||
("gid", str(gid)),
|
||||
):
|
||||
self._runner.run(
|
||||
("debugfs", "-w", "-R", f"set_inode_field {guest_path} {field} {value}", disk),
|
||||
capture=True,
|
||||
)
|
||||
|
||||
# debugfs can report command failures while still returning exit status zero.
|
||||
created = self._runner.run(command, capture=True)
|
||||
mode_match = _MODE_PATTERN.search(created.stdout)
|
||||
owner_match = _OWNER_PATTERN.search(created.stdout)
|
||||
if (
|
||||
re.search(r"Type:\s+directory\b", created.stdout) is None
|
||||
or mode_match is None
|
||||
or owner_match is None
|
||||
or int(mode_match.group(1), 8) != mode
|
||||
or (int(owner_match.group(1)), int(owner_match.group(2))) != (uid, gid)
|
||||
):
|
||||
raise UvmError(f"could not verify guest rootfs directory: {guest_path}")
|
||||
|
||||
def _password_hash(self, password: str) -> str:
|
||||
result = self._runner.run(
|
||||
("openssl", "passwd", "-6", "-stdin"),
|
||||
@@ -429,6 +535,13 @@ class ImageStore:
|
||||
raise UvmError("could not remove the insecure Firecracker demo SSH key")
|
||||
|
||||
|
||||
def _next_guest_id(used: set[int]) -> int:
|
||||
for candidate in range(1000, 60001):
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
raise UvmError("guest rootfs has no available regular user/group IDs")
|
||||
|
||||
|
||||
def _set_shadow_password(contents: str, username: str, password_hash: str) -> str:
|
||||
lines = contents.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
|
||||
Reference in New Issue
Block a user