diff options
| author | dd <dd@wx.tnyzeq.icu> | 2026-08-24 12:56:43 +0200 |
|---|---|---|
| committer | dd <dd@wx.tnyzeq.icu> | 2026-08-24 12:56:43 +0200 |
| commit | 93d3f030604552719adc762fa249144e6ee86ee3 (patch) | |
| tree | d5b58880fb761c57c5b8b1269a4a21ad92726285 | |
| download | vyos-modifyiso-93d3f030604552719adc762fa249144e6ee86ee3.tar.gz vyos-modifyiso-93d3f030604552719adc762fa249144e6ee86ee3.zip | |
| -rw-r--r-- | .gitignore | 3 | ||||
| -rwxr-xr-x | modifyiso.py | 368 | ||||
| -rw-r--r-- | readme.md | 63 | ||||
| -rw-r--r-- | requirements.txt | 1 | ||||
| -rw-r--r-- | scripts/example.sh | 14 |
5 files changed, 449 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d0c8ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build/* +/scripts/* +!/scripts/example.sh diff --git a/modifyiso.py b/modifyiso.py new file mode 100755 index 0000000..d39cce7 --- /dev/null +++ b/modifyiso.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +import argparse +import logging +import os +import re +import shlex +import shutil +import subprocess +import sys +from time import monotonic, sleep +from typing import Union, Optional + + +class ModifyIso: + logger = logging + + def __init__(self, source_iso, temp_dir, chroot_script, target_iso, interactive=False, hostname="vyos", + skip_extract=False, skip_chroot=False, skip_iso=False, stdout_only=False): + self.source_iso: str = os.path.realpath(source_iso) + self.temp_dir = temp_dir + self.chroot_script = chroot_script + self.target_iso = target_iso + self.interactive = interactive + self.hostname = hostname + self.skip_extract = skip_extract + self.skip_chroot = skip_chroot + self.skip_iso = skip_iso + self.stdout_only = stdout_only + + self.mount_dir = os.path.join(self.temp_dir, "mount") + self.iso_dir = os.path.join(self.temp_dir, "iso") + self.squashfs_dir = os.path.join(self.temp_dir, "squashfs") + self.original_working_dir = os.getcwd() + + def run(self): + if not self.skip_extract: + self.mount_and_extract() + + if not self.skip_chroot: + self.execute_chroot() + + if not self.skip_iso: + self.generate_iso() + + def mount_and_extract(self): + self.mount_iso(self.source_iso) + + if os.path.exists(self.iso_dir): + shutil.rmtree(self.iso_dir) + os.makedirs(self.iso_dir) + + self.logger.info("Copying ISO contents to '%s'" % self.iso_dir) + self.execute(["/usr/bin/cp", "-a", os.path.join(self.mount_dir, "."), self.iso_dir]) + + self.logger.info("Unmounting ISO") + self.umount(self.mount_dir) + + if os.path.exists(self.squashfs_dir): + self.umount_all(self.squashfs_dir) + shutil.rmtree(self.squashfs_dir) + + self.logger.info("Extracting squashfs filesystem") + os.chdir(self.temp_dir) + self.execute([ + "/usr/bin/unsquashfs", + "-d", self.squashfs_dir, + os.path.join(self.iso_dir, "live/filesystem.squashfs") + ]) + + def execute_chroot(self): + os.chdir(self.original_working_dir) + self.logger.info("Preparing chroot") + + apt_sources_path = os.path.join(self.squashfs_dir, "etc/apt/sources.list") + apt_sources_backup_path = "%s.bak" % apt_sources_path + if not os.path.exists(apt_sources_backup_path): + os.rename(apt_sources_path, apt_sources_backup_path) + + with open(apt_sources_backup_path, "r") as source_file: + lines = [] + for line in source_file: + url = "http://ftp.debian.org/debian/" + if "security" in line: + url = "http://security.debian.org/debian-security" + line = re.sub(r"(deb|deb-src)\s+[^\s]+\s+", "\\1 %s " % url, line) + lines.append(line) + + with open(apt_sources_path, "w") as target_file: + for line in lines: + target_file.write(line) + target_file.write("\n") + + resolv_path = os.path.join(self.squashfs_dir, "etc/resolv.conf") + resolv_backup_path = "%s.bak" % resolv_path + if not os.path.exists(resolv_backup_path): + os.rename(resolv_path, resolv_backup_path) + shutil.copy2("/etc/resolv.conf", resolv_path) + + for directory in ["dev", "proc", "sys"]: + target = os.path.join(self.squashfs_dir, directory) + self.execute(["/usr/bin/mount", "--bind", os.path.join("/", directory), target]) + + temp_script_path = os.path.join(self.squashfs_dir, "custom.sh") + chroot_temp_script_path = temp_script_path[len(self.squashfs_dir):] + if self.chroot_script: + shutil.copy2(self.chroot_script, temp_script_path) + os.chmod(temp_script_path, 0o755) + + try: + self.logger.info("Executing chroot '%s'" % self.squashfs_dir) + + if self.hostname: + import unshare + unshare.unshare(unshare.CLONE_NEWUTS) + self.execute(["/usr/bin/hostname", self.hostname]) + + if self.chroot_script: + self.execute(["/usr/sbin/chroot", self.squashfs_dir, "/bin/sh", "-c", chroot_temp_script_path]) + + if self.interactive: + self.execute(["/usr/sbin/chroot", self.squashfs_dir, "/bin/sh"], stdin=sys.stdin) + + self.logger.info("Cleaning chroot") + + if os.path.exists(temp_script_path): + os.remove(temp_script_path) + + os.remove(apt_sources_path) + os.rename(apt_sources_backup_path, apt_sources_path) + + os.remove(resolv_path) + os.rename(resolv_backup_path, resolv_path) + + finally: + for directory in ["dev", "proc", "sys"]: + target = os.path.join(self.squashfs_dir, directory) + self.execute(["/usr/bin/umount", "-l", target], valid_codes=[0, 32]) + + def generate_iso(self): + self.logger.info("Generating squashfs") + + squashfs_path = os.path.join(self.iso_dir, "live/filesystem.squashfs") + if os.path.exists(squashfs_path): + os.remove(squashfs_path) + + self.execute(["/usr/bin/mksquashfs", self.squashfs_dir, squashfs_path, "-comp", "xz"]) + + self.logger.info("Generating checksums") + self.generate_checksums(self.iso_dir) + + self.logger.info("Generating ISO") + target_iso_path = self.target_iso + if target_iso_path == "auto": + parts = os.path.splitext(self.source_iso) + target_iso_path = "%s-custom%s" % parts + + if os.path.exists(target_iso_path): + os.remove(target_iso_path) + + os.chdir(self.original_working_dir) + command = [ + "/usr/bin/xorriso", + "-as", "mkisofs", + ] + command.extend(self.get_xorriso_arguments(self.source_iso)) + command.extend([ + "-output", target_iso_path, + self.iso_dir, + ]) + self.execute(command) + + self.verify_iso(target_iso_path) + + self.logger.info("ISO '%s' generated successfully" % target_iso_path) + + def get_xorriso_arguments(self, iso_path): + command = [ + "/usr/bin/xorriso", + "-indev", iso_path, + "-report_el_torito", "as_mkisofs", + ] + output = self.execute(command, stdout=subprocess.PIPE) + return shlex.split(str(output)) + + def generate_checksums(self, directory): + os.chdir(directory) + excluded_paths = [ + "./sha256sum.txt", + "./isolinux", + ] + with open("sha256sum.txt", "w") as file: + for parent, directories, files in os.walk("."): + for file_name in files: + path = os.path.join(parent, file_name) + + excluded = False + for pattern in excluded_paths: + if path.startswith(pattern): + excluded = True + break + + if excluded: + continue + + output = self.execute(["/usr/bin/sha256sum", path], stdout=subprocess.PIPE) + file.write(str(output).strip()) + file.write("\n") + + def verify_iso(self, iso_path): + self.logger.info("Verifying ISO") + + self.mount_iso(iso_path, silent=True) + + os.chdir(self.iso_dir) + self.execute(["/usr/bin/sha256sum", "-c", "sha256sum.txt"], stdout=subprocess.DEVNULL) + + self.umount(self.iso_dir) + + self.logger.info("ISO verified successfully") + + def mount_iso(self, iso_path, silent=False): + if not os.path.exists(iso_path): + raise ErrorException("Source ISO '%s' doesn't exist" % iso_path) + + if os.path.exists(self.mount_dir): + self.umount(self.mount_dir) + else: + os.makedirs(self.mount_dir) + + if not silent: + self.logger.info("Mounting ISO '%s' to '%s'" % (iso_path, self.mount_dir)) + + self.execute(["/usr/bin/mount", "-o", "loop,ro", iso_path, self.mount_dir]) + + def umount(self, mount): + self.execute(["/usr/bin/umount", mount], valid_codes=[0, 32], stdout=subprocess.DEVNULL) + + def umount_all(self, target_dir): + target_dir = os.path.realpath(target_dir) + + related_mounts = [] + with open("/proc/mounts", "r") as file: + for line in file: + parts = line.split() + if len(parts) >= 2: + mount_point = os.path.realpath(parts[1].encode("utf-8").decode("unicode_escape")) + if mount_point == target_dir or mount_point.startswith(target_dir + os.sep): + related_mounts.append(mount_point) + + related_mounts.sort(key=lambda path: path.count(os.sep), reverse=True) + + for mount_point in related_mounts: + self.umount(mount_point) + + def execute(self, command, valid_codes: Optional[Union[int, list]] = 0, timeout=0, **kwargs) -> Optional[str]: + if "stdout" not in kwargs: + kwargs.update({ + "stdout": sys.stdout, + "stderr": sys.stdout if self.stdout_only else sys.stderr, + "stdin": sys.stdin, + }) + + if "stderr" not in kwargs: + kwargs["stderr"] = subprocess.DEVNULL + + process = subprocess.Popen(command, **kwargs) + + deadline = monotonic() + timeout + while process.poll() is None: + if timeout > 0 and monotonic() > deadline: + process.kill() + raise TimeoutError("Command '%s' timed out" % command) + sleep(0.100) + + if valid_codes is not None: + if not isinstance(valid_codes, list): + valid_codes = [valid_codes] + + if process.returncode not in valid_codes: + raise ProcessException(command, process.returncode, process.stdout, process.stderr) + + if process.stdout is None: + return None + return process.stdout.read().decode("utf-8") + + +class ProcessException(Exception): + def __init__(self, command, returncode, stdout=None, stderr=None): + self.command = command + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + def __str__(self): + message = "Command '%s' failed with code %s" % (self.command, self.returncode) + if self.stdout: + message += ", stdout: %s" % self.stdout.read().decode("utf-8") + if self.stderr: + message += ", stderr: %s" % self.stderr.read().decode("utf-8") + return message + + +def main(): + project_dir = os.path.realpath(os.path.dirname(__file__)) + + parser = argparse.ArgumentParser() + parser.add_argument("source_iso") + parser.add_argument("--temp-dir", default=os.path.join(project_dir, "build")) + parser.add_argument("--chroot-script") + parser.add_argument("--target-iso", default="auto") + parser.add_argument("--interactive", action="store_true") + parser.add_argument("--hostname") + parser.add_argument("--skip-extract", action="store_true") + parser.add_argument("--skip-chroot", action="store_true") + parser.add_argument("--skip-iso", action="store_true") + + args = parser.parse_args() + values = vars(args) + + ModifyIso(**values).run() + + +class ErrorException(Exception): + pass + + +class LessThanLevelFilter(logging.Filter): + def __init__(self, exclusive_maximum, name="LessThanLevelFilter"): + super(LessThanLevelFilter, self).__init__(name) + self.maximum_level = exclusive_maximum + + def filter(self, record): + return 1 if record.levelno < self.maximum_level else 0 + + +def setup_logging(): + logger = logging.getLogger() + logger.setLevel(logging.INFO) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + + stderr_level = logging.WARNING + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.INFO) + stdout_handler.addFilter(LessThanLevelFilter(stderr_level)) + stdout_handler.setFormatter(formatter) + logger.addHandler(stdout_handler) + + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(stderr_level) + stderr_handler.setFormatter(formatter) + logger.addHandler(stderr_handler) + + +if __name__ == "__main__": + setup_logging() + + try: + main() + + except ErrorException as e: + logging.error(e) + exit(1) + except KeyboardInterrupt: + exit(1) + except Exception as e: + logging.exception(e) + exit(1) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..3e7619c --- /dev/null +++ b/readme.md @@ -0,0 +1,63 @@ +> [!CAUTION] +> This project is an **independent third-party utility**. +> It is **not affiliated with, endorsed by, or sponsored by VyOS Networks Corporation** by any means. +> VyOS® is a registered trademark of VyOS Networks Corporation. + +Utility for customizing existing VyOS images +== + +This utility allows unpacking VyOS ISO image, modifying it within a chroot environment, +and repacking it into a new ISO image. + +The process isn't exactly straightforward. Especially if we intended to keep the VyOS upgrade mechanism intact +and also to avoid breaking secure boot compatibility as far as possible. That's why this utility was created. + +Modifying existing images may be useful as an alternative to building a new image and all packages from scratch +with the aim to have customized images. Especially for VyOS Stream where building all packages is unfriendly, +time-consuming, and there isn't an official nor reliable method to do so straightforwardly. + +Usage +-- + +Install additional required tools above the base system: + +```shell +sudo apt-get install squashfs-tools xorriso git +``` + +Get the utility: + +```shell +git clone https://github.com/dd010101/vyos-modifyiso.git +``` + +Get image to modify: + +```shell +wget https://community-downloads.vyos.dev/stream/2026.03/vyos-2026.03-generic-amd64.iso +``` + +Prepare your chroot script. For demonstration, we will use the example script `./scripts/example.sh`. + +```shell +sudo ./vyos-modifyiso/modifyiso.py ./vyos-2026.03-generic-amd64.iso --chroot-script ./vyos-modifyiso/scripts/example.sh +``` + +If there is a need to interact with the chroot manually, we can use `--interactive` option. + +```shell +sudo ./vyos-modifyiso/modifyiso.py ./vyos-2026.03-generic-amd64.iso --interactive +``` + +After the interactive shell exists successfully, the process of assembling the image continues. +This can be also mixed with `--chroot-script` option, then the script will be executed first. +The `--interactive` option presents a limited shell, if there is a need for a full shell, then use the `--interactive` +option, wait for the utility to enter the limited shell, and enter the chroot manually from another shell. + +Caution on using APT +-- +The chroot environment doesn't have access to VyOS APT repositories, and that's why upgrading packages is +strongly discouraged. Such action may lead to replacing custom packages with those from Debian upstream, +breaking the OS in the process. Ideally, the VyOS packages shouldn't have overlap like that, thus this situation +shouldn't arise, but that is not given. I use this utility and install packages with `apt-get install --no-upgrade` +and I didn't find any issues with various VyOS stream versions so far, but your mileage may vary. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..374d8c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +unshare~=0.22 diff --git a/scripts/example.sh b/scripts/example.sh new file mode 100644 index 0000000..7c3daa3 --- /dev/null +++ b/scripts/example.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +# basics +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt_install="apt-get install --no-upgrade -y" + +# example +$apt_install strace + +# cleanup +apt clean +rm -rf /var/lib/apt/lists/* |
