From 71425a98e39e16bb9d8fdea05f976cfb4801d6dd Mon Sep 17 00:00:00 2001 From: kstyagi Date: Tue, 22 Sep 2026 00:07:37 +0530 Subject: [PATCH] feat: enhance user account management during VM creation with automatic user creation and metadata preservation --- DEVELOPER.md | 26 +++-- README.md | 13 ++- USAGE.md | 17 ++- tests/test_images.py | 192 +++++++++++++++++++++++++++++++- uvm/cli.py | 2 +- uvm/dashboard/static/index.html | 2 +- uvm/images.py | 117 ++++++++++++++++++- 7 files changed, 347 insertions(+), 22 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index f5a7085..bb5dc44 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -526,18 +526,28 @@ make recovery convenient. Proven-gone legacy processes can be cleaned up. ### Template Contract `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 -shadow entry and OpenSSH password policy with `debugfs`. Firecracker only sees +the VM runtime directory before UVM provisions the requested account, its +shadow entry, and OpenSSH password policy with `debugfs`. Firecracker only sees 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 to the requested capacity before credential provisioning. Existing disk resizes do not reprovision credentials or host keys. -Guest images must provide `/etc/passwd`, `/etc/shadow`, OpenSSH's -`/etc/ssh/sshd_config`, and the requested account. UVM does not create missing -users. It 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. +Guest images must provide `/etc/passwd`, `/etc/shadow`, and OpenSSH's +`/etc/ssh/sshd_config`. Existing accounts retain their identity, groups, home, +shell, and password-aging policy. Missing custom users are created offline, +using `/etc/group` and an available `/bin/sh` login shell. UVM allocates unused +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 @@ -880,6 +890,8 @@ exercise the kernel packet path. `test_storage.py` also exercises real e2fsprogs against temporary unmounted, sparse ext4 images when the tools are installed: default 30 GiB creation, 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, single-lock ownership, identity preservation, pending intent, and failure recovery. diff --git a/README.md b/README.md index 9066a2f..b0ff9fe 100644 --- a/README.md +++ b/README.md @@ -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 share a mutable root disk. -During creation, UVM updates the private ext4 disk with a password for an -existing guest account and enables OpenSSH password authentication. The +During creation, UVM updates the private ext4 disk with a password for 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: - `sshd` - `/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 removed from each private disk. UVM also replaces the conventional RSA, ECDSA, diff --git a/USAGE.md b/USAGE.md index c9dd533..d5dc2a4 100644 --- a/USAGE.md +++ b/USAGE.md @@ -234,7 +234,7 @@ private port rules. The default password produces a warning on stderr. Use ### Names and Credentials ```sh -sudo ./uvm.py create --name web --username root --password '' +sudo ./uvm.py create --name web --username codemate-user --password '' ``` 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 create DNS records, and identifiers are not matched by ID prefix. -`--username` must name an account already present in the guest image. A -`--password` value is visible in process arguments and may enter shell +`--username` selects the guest login account. If a custom username is missing, +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 line ending, and rejects empty passwords, control characters, or passwords 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 -UVM provisions a password for an existing account. The default credentials are -`root` / `root`. Your guest image must include: +UVM provisions a password for the requested account, creating missing custom +users during VM creation. The default credentials are `root` / `root`. Your +guest image must include: - 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 disk during creation and removes Firecracker's publicly known demo login key. diff --git a/tests/test_images.py b/tests/test_images.py index 09ee22a..b0eea24 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -1,13 +1,15 @@ from __future__ import annotations import hashlib +import shutil import tempfile +import time import unittest from pathlib import Path from unittest.mock import patch from uvm.config import Settings -from uvm.errors import UvmError +from uvm.errors import UvmError, ValidationError from uvm.images import ( _FIRECRACKER_DEMO_PUBLIC_KEY, _GuestFile, @@ -17,7 +19,7 @@ from uvm.images import ( ImageStore, ) from uvm.integrity import write_manifest -from uvm.system import CommandResult +from uvm.system import CommandResult, CommandRunner class PasswordHashRunner: @@ -69,6 +71,16 @@ class ImageStoreTests(unittest.TestCase): self.assertEqual(runner.options["input_text"], "secret-value\n") 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: original = "root:*:1:0:99999:7:::\nservice:!:1:0:99999:7:::\n" @@ -187,3 +199,179 @@ class ImageStoreTests(unittest.TestCase): ) 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"]) diff --git a/uvm/cli.py b/uvm/cli.py index b67e34d..b29c269 100644 --- a/uvm/cli.py +++ b/uvm/cli.py @@ -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( diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html index c018018..23d444f 100644 --- a/uvm/dashboard/static/index.html +++ b/uvm/dashboard/static/index.html @@ -193,7 +193,7 @@

01A name and a little space

Give your new machine a shape.
Start with a size
-

02A way to connect

Private networking and guest access.

Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.

+

02A way to connect

Private networking and guest access.

Missing users are created with a home directory. Credentials are applied to the machine’s private disk.

03Open the right doors

Choose which private TCP services are allowed.
diff --git a/uvm/images.py b/uvm/images.py index c4519ec..f9842e4 100644 --- a/uvm/images.py +++ b/uvm/images.py @@ -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): -- 2.54.0