jailmaker/jlmkr.py

2167 lines
70 KiB
Python
Executable File

#!/usr/bin/env python3
"""Create persistent Linux 'jails' on TrueNAS SCALE, \
with full access to all files via bind mounts, \
thanks to systemd-nspawn!"""
__version__ = "1.4.0"
__disclaimer__ = """USE THIS SCRIPT AT YOUR OWN RISK!
IT COMES WITHOUT WARRANTY AND IS NOT SUPPORTED BY IXSYSTEMS."""
import argparse
import configparser
import contextlib
import ctypes
import errno
import glob
import hashlib
import io
import json
import os
import platform
import re
import readline
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import urllib.request
from collections import defaultdict
from inspect import cleandoc
from pathlib import Path, PurePath
from textwrap import dedent
DEFAULT_CONFIG = """startup=0
gpu_passthrough_intel=0
gpu_passthrough_nvidia=0
# The docker_compatible option is deprecated and will be removed in a future release
docker_compatible=0
# Turning off seccomp filtering improves performance at the expense of security
seccomp=1
# Below you may add additional systemd-nspawn flags behind systemd_nspawn_user_args=
# To mount host storage in the jail, you may add: --bind='/mnt/pool/dataset:/home'
# To readonly mount host storage, you may add: --bind-ro=/etc/certificates
# To use macvlan networking add: --network-macvlan=eno1 --resolv-conf=bind-host
# To use bridge networking add: --network-bridge=br1 --resolv-conf=bind-host
# Ensure to change eno1/br1 to the interface name you want to use
# To allow syscalls required by docker add: --system-call-filter='add_key keyctl bpf'
systemd_nspawn_user_args=
# Specify command/script to run on the HOST before starting the jail
# For example to load kernel modules and config kernel settings
pre_start_hook=
# pre_start_hook=#!/usr/bin/bash
# set -euo pipefail
# echo 'PRE_START_HOOK_EXAMPLE'
# echo 1 > /proc/sys/net/ipv4/ip_forward
# modprobe br_netfilter
# echo 1 > /proc/sys/net/bridge/bridge-nf-call-iptables
# echo 1 > /proc/sys/net/bridge/bridge-nf-call-ip6tables
# Specify a command/script to run on the HOST after stopping the jail
post_stop_hook=
# post_stop_hook=echo 'POST_STOP_HOOK_EXAMPLE'
# Only used while creating the jail
distro=debian
release=bookworm
# Specify command/script to run IN THE JAIL on the first start (once networking is ready in the jail)
# Useful to install packages on top of the base rootfs
initial_setup=
# initial_setup=bash -c 'apt-get update && apt-get -y upgrade'
# Usually no need to change systemd_run_default_args
systemd_run_default_args=--collect
--property=Delegate=yes
--property=RestartForceExitStatus=133
--property=SuccessExitStatus=133
--property=TasksMax=infinity
--property=Type=notify
--setenv=SYSTEMD_NSPAWN_LOCK=0
--property=KillMode=mixed
# Usually no need to change systemd_nspawn_default_args
systemd_nspawn_default_args=--bind-ro=/sys/module
--boot
--inaccessible=/sys/module/apparmor
--quiet
--keep-unit"""
# Use mostly default settings for systemd-nspawn but with systemd-run instead of a service file:
# https://github.com/systemd/systemd/blob/main/units/systemd-nspawn%40.service.in
# Use TasksMax=infinity since this is what docker does:
# https://github.com/docker/engine/blob/master/contrib/init/systemd/docker.service
# Use SYSTEMD_NSPAWN_LOCK=0: otherwise jail won't start jail after a shutdown (but why?)
# Would give "directory tree currently busy" error and I'd have to run
# `rm /run/systemd/nspawn/locks/*` and remove the .lck file from jail_path
# Disabling locking isn't a big deal as systemd-nspawn will prevent starting a container
# with the same name anyway: as long as we're starting jails using this script,
# it won't be possible to start the same jail twice
# Always add --bind-ro=/sys/module to make lsmod happy
# https://manpages.debian.org/bookworm/manpages/sysfs.5.en.html
JAILS_DIR_PATH = "jails"
JAIL_CONFIG_NAME = "config"
JAIL_ROOTFS_NAME = "rootfs"
DOWNLOAD_SCRIPT_DIGEST = (
"cfcb5d08b24187d108f2ab0d21a6cc4b73dcd7f5d7dfc80803bfd7f1642d638d"
)
SCRIPT_PATH = os.path.realpath(__file__)
SCRIPT_NAME = os.path.basename(SCRIPT_PATH)
SCRIPT_DIR_PATH = os.path.dirname(SCRIPT_PATH)
COMMAND_NAME = os.path.basename(__file__)
SYMLINK_NAME = "jlmkr"
# Only set a color if we have an interactive tty
if sys.stdout.isatty():
BOLD = "\033[1m"
RED = "\033[91m"
YELLOW = "\033[93m"
UNDERLINE = "\033[4m"
NORMAL = "\033[0m"
else:
BOLD = RED = YELLOW = UNDERLINE = NORMAL = ""
DISCLAIMER = f"""{YELLOW}{BOLD}{__disclaimer__}{NORMAL}"""
# Used in parser getters to indicate the default behavior when a specific
# option is not found. Created to enable `None` as a valid fallback value.
_UNSET = object()
class KeyValueParser(configparser.ConfigParser):
"""Simple comment preserving parser based on ConfigParser.
Reads a file containing key/value pairs and/or comments.
Values can span multiple lines, as long as they are indented
deeper than the first line of the value. Comments or keys
must NOT be indented.
"""
def __init__(self, *args, **kwargs):
# Set defaults if not specified by user
if "interpolation" not in kwargs:
kwargs["interpolation"] = None
if "allow_no_value" not in kwargs:
kwargs["allow_no_value"] = True
if "comment_prefixes" not in kwargs:
kwargs["comment_prefixes"] = "#"
super().__init__(*args, **kwargs)
# Backup _comment_prefixes
self._comment_prefixes_backup = self._comment_prefixes
# Unset _comment_prefixes so comments won't be skipped
self._comment_prefixes = ()
# Starting point for the comment IDs
self._comment_id = 0
# Default delimiter to use
delimiter = self._delimiters[0]
# Template to store comments as key value pair
self._comment_template = "#{0} " + delimiter + " {1}"
# Regex to match the comment prefix
self._comment_regex = re.compile(f"^#\d+\s*{re.escape(delimiter)}[^\S\n]*")
# Regex to match cosmetic newlines (skips newlines in multiline values):
# consecutive whitespace from start of line followed by a line not starting with whitespace
self._cosmetic_newlines_regex = re.compile(r"^(\s+)(?=^\S)", re.MULTILINE)
# Dummy section name
self._section_name = "a"
def _find_cosmetic_newlines(self, text):
# Indices of the lines containing cosmetic newlines
cosmetic_newline_indices = set()
for match in re.finditer(self._cosmetic_newlines_regex, text):
start_index = text.count("\n", 0, match.start())
end_index = start_index + text.count("\n", match.start(), match.end())
cosmetic_newline_indices.update(range(start_index, end_index))
return cosmetic_newline_indices
# TODO: can I create a solution which not depends on the internal _read method?
def _read(self, fp, fpname):
lines = fp.readlines()
cosmetic_newline_indices = self._find_cosmetic_newlines("".join(lines))
# Preprocess config file to preserve comments
for i, line in enumerate(lines):
if i in cosmetic_newline_indices or line.startswith(
self._comment_prefixes_backup
):
# Store cosmetic newline or comment with unique key
lines[i] = self._comment_template.format(self._comment_id, line)
self._comment_id += 1
# Convert to in-memory file and prepend a dummy section header
lines = io.StringIO(f"[{self._section_name}]\n" + "".join(lines))
# Feed preprocessed file to original _read method
return super()._read(lines, fpname)
def read_default_string(self, string, source="<string>"):
# Ignore all comments when parsing default key/values
string = "\n".join(
[
line
for line in string.splitlines()
if not line.startswith(self._comment_prefixes_backup)
]
)
# Feed preprocessed file to original _read method
return super()._read(io.StringIO("[DEFAULT]\n" + string), source)
def write(self, fp, space_around_delimiters=False):
# Write the config to an in-memory file
with io.StringIO() as sfile:
super().write(sfile, space_around_delimiters)
# Start from the beginning of sfile
sfile.seek(0)
line = sfile.readline()
# Throw away lines until we reach the dummy section header
while line.strip() != f"[{self._section_name}]":
line = sfile.readline()
lines = sfile.readlines()
for i, line in enumerate(lines):
# Remove the comment id prefix
lines[i] = self._comment_regex.sub("", line, 1)
fp.write("".join(lines).rstrip())
# Set value for specified option key
def my_set(self, option, value):
if isinstance(value, bool):
value = str(int(value))
elif isinstance(value, list):
value = str("\n ".join(value))
elif not isinstance(value, str):
value = str(value)
super().set(self._section_name, option, value)
# Return value for specified option key
def my_get(self, option, fallback=_UNSET):
return super().get(self._section_name, option, fallback=fallback)
# Return value converted to boolean for specified option key
def my_getboolean(self, option, fallback=_UNSET):
return super().getboolean(self._section_name, option, fallback=fallback)
class ExceptionWithParser(Exception):
def __init__(self, parser, message):
self.parser = parser
self.message = message
super().__init__(message)
# Workaround for exit_on_error=False not applying to:
# "error: the following arguments are required"
# https://github.com/python/cpython/issues/103498
class CustomSubParser(argparse.ArgumentParser):
def error(self, message):
if self.exit_on_error:
super().error(message)
else:
raise ExceptionWithParser(self, message)
def eprint(*args, **kwargs):
"""
Print to stderr.
"""
print(*args, file=sys.stderr, **kwargs)
def fail(*args, **kwargs):
"""
Print to stderr and exit.
"""
eprint(*args, **kwargs)
sys.exit(1)
def get_jail_path(jail_name):
return os.path.join(JAILS_DIR_PATH, jail_name)
def get_jail_config_path(jail_name):
return os.path.join(get_jail_path(jail_name), JAIL_CONFIG_NAME)
def get_jail_rootfs_path(jail_name):
return os.path.join(get_jail_path(jail_name), JAIL_ROOTFS_NAME)
# Test intel GPU by decoding mp4 file (output is discarded)
# Run the commands below in the jail:
# curl -o bunny.mp4 https://www.w3schools.com/html/mov_bbb.mp4
# ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format vaapi -i bunny.mp4 -f null - && echo 'SUCCESS!'
def passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args):
if not gpu_passthrough_intel:
return
if not os.path.exists("/dev/dri"):
eprint(
dedent(
"""
No intel GPU seems to be present...
Skip passthrough of intel GPU."""
)
)
return
systemd_nspawn_additional_args.append("--bind=/dev/dri")
def passthrough_nvidia(
gpu_passthrough_nvidia, systemd_nspawn_additional_args, jail_name
):
jail_rootfs_path = get_jail_rootfs_path(jail_name)
ld_so_conf_path = Path(
os.path.join(jail_rootfs_path), f"etc/ld.so.conf.d/{SYMLINK_NAME}-nvidia.conf"
)
if not gpu_passthrough_nvidia:
# Cleanup the config file we made when passthrough was enabled
ld_so_conf_path.unlink(missing_ok=True)
return
# Load the nvidia kernel module
if subprocess.run(["modprobe", "nvidia-current-uvm"]).returncode != 0:
eprint(
dedent(
"""
Failed to load nvidia-current-uvm kernel module."""
)
)
# Run nvidia-smi to initialize the nvidia driver
# If we can't run nvidia-smi successfully,
# then nvidia-container-cli list will fail too:
# we shouldn't continue with gpu passthrough
if subprocess.run(["nvidia-smi", "-f", "/dev/null"]).returncode != 0:
eprint("Skip passthrough of nvidia GPU.")
return
try:
nvidia_files = set(
(
[
x
for x in subprocess.check_output(["nvidia-container-cli", "list"])
.decode()
.split("\n")
if x
]
)
)
except Exception:
eprint(
dedent(
"""
Unable to detect which nvidia driver files to mount.
Skip passthrough of nvidia GPU."""
)
)
return
# Also make nvidia-smi available inside the path,
# while mounting the symlink will be resolved and nvidia-smi will appear as a regular file
nvidia_files.add("/usr/bin/nvidia-smi")
nvidia_mounts = []
for file_path in nvidia_files:
if not os.path.exists(file_path):
# Don't try to mount files not present on the host
print(f"Skipped mounting {file_path}, it doesn't exist on the host...")
continue
if file_path.startswith("/dev/"):
nvidia_mounts.append(f"--bind={file_path}")
else:
nvidia_mounts.append(f"--bind-ro={file_path}")
# Check if the parent dir exists where we want to write our conf file
if ld_so_conf_path.parent.exists():
nvidia_libraries = set(
Path(x)
for x in subprocess.check_output(
["nvidia-container-cli", "list", "--libraries"]
)
.decode()
.split("\n")
if x
)
library_folders = set(str(x.parent) for x in nvidia_libraries)
# Only write if the conf file doesn't yet exist or has different contents
existing_conf_libraries = set()
if ld_so_conf_path.exists():
existing_conf_libraries.update(
x for x in ld_so_conf_path.read_text().splitlines() if x
)
if library_folders != existing_conf_libraries:
print("\n".join(x for x in library_folders), file=ld_so_conf_path.open("w"))
# Run ldconfig inside systemd-nspawn jail with nvidia mounts...
subprocess.run(
[
"systemd-nspawn",
"--quiet",
f"--machine={jail_name}",
f"--directory={jail_rootfs_path}",
*nvidia_mounts,
"ldconfig",
]
)
else:
eprint(
dedent(
"""
Unable to write the ld.so.conf.d directory inside the jail (it doesn't exist).
Skipping call to ldconfig.
The nvidia drivers will probably not be detected..."""
)
)
systemd_nspawn_additional_args += nvidia_mounts
def exec_jail(jail_name, cmd):
"""
Execute a command in the jail with given name.
"""
return subprocess.run(
[
"systemd-run",
"--machine",
jail_name,
"--quiet",
"--pipe",
"--wait",
"--collect",
"--service-type=exec",
*cmd,
]
).returncode
def status_jail(jail_name):
"""
Show the status of the systemd service wrapping the jail with given name.
"""
# Alternatively `machinectl status jail_name` could be used
return subprocess.run(
["systemctl", "status", f"{SYMLINK_NAME}-{jail_name}"]
).returncode
def log_jail(jail_name):
"""
Show the log file of the jail with given name.
"""
return subprocess.run(
["journalctl", "-u", f"{SYMLINK_NAME}-{jail_name}"]
).returncode
def shell_jail(args):
"""
Open a shell in the jail with given name.
"""
return subprocess.run(["machinectl", "shell"] + args).returncode
def parse_config_file(jail_config_path):
config = KeyValueParser()
# Read default config to fallback to default values
# for keys not found in the jail_config_path file
config.read_default_string(DEFAULT_CONFIG)
try:
with open(jail_config_path, "r") as fp:
config.read_file(fp)
return config
except FileNotFoundError:
eprint(f"Unable to find config file: {jail_config_path}.")
return
def add_hook(jail_path, systemd_run_additional_args, hook_command, hook_type):
if not hook_command:
return
# Run the command directly if it doesn't start with a shebang
if not hook_command.startswith("#!"):
systemd_run_additional_args += [f"--property={hook_type}={hook_command}"]
return
# Otherwise write a script file and call that
hook_file = os.path.abspath(os.path.join(jail_path, f".{hook_type}"))
# Only write if contents are different
if not os.path.exists(hook_file) or Path(hook_file).read_text() != hook_command:
print(hook_command, file=open(hook_file, "w"))
stat_chmod(hook_file, 0o700)
systemd_run_additional_args += [f"--property={hook_type}={hook_file}"]
def start_jail(jail_name):
"""
Start jail with given name.
"""
skip_start_message = (
f"Skipped starting jail {jail_name}. It appears to be running already..."
)
if jail_is_running(jail_name):
eprint(skip_start_message)
return 0
jail_path = get_jail_path(jail_name)
jail_config_path = get_jail_config_path(jail_name)
jail_rootfs_path = get_jail_rootfs_path(jail_name)
config = parse_config_file(jail_config_path)
if not config:
eprint("Aborting...")
return 1
seccomp = config.my_getboolean("seccomp")
systemd_run_additional_args = [
f"--unit={SYMLINK_NAME}-{jail_name}",
f"--working-directory=./{jail_path}",
f"--description=My nspawn jail {jail_name} [created with jailmaker]",
]
systemd_nspawn_additional_args = [
f"--machine={jail_name}",
f"--directory={JAIL_ROOTFS_NAME}",
]
# The systemd-nspawn manual explicitly mentions:
# Device nodes may not be created
# https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html
# This means docker images containing device nodes can't be pulled
# https://github.com/moby/moby/issues/35245
#
# The solution is to use DevicePolicy=auto
# https://github.com/kinvolk/kube-spawn/pull/328
#
# DevicePolicy=auto is the default for systemd-run and allows access to all devices
# as long as we don't add any --property=DeviceAllow= flags
# https://manpages.debian.org/bookworm/systemd/systemd.resource-control.5.en.html
#
# We can now successfully run:
# mknod /dev/port c 1 4
# Or pull docker images containing device nodes:
# docker pull oraclelinux@sha256:d49469769e4701925d5145c2676d5a10c38c213802cf13270ec3a12c9c84d643
if config.my_getboolean("docker_compatible"):
eprint("WARNING: DEPRECATED OPTION")
eprint(
"The `docker_compatible` option is deprecated and will be removed in a future release."
)
eprint("Please refer to the recommended way to run docker in a jail:")
eprint("https://github.com/Jip-Hop/jailmaker/tree/main/templates/docker")
# Enable ip forwarding on the host (docker needs it)
print(1, file=open("/proc/sys/net/ipv4/ip_forward", "w"))
# Load br_netfilter kernel module and enable bridge-nf-call to fix warning when running docker info:
# WARNING: bridge-nf-call-iptables is disabled
# WARNING: bridge-nf-call-ip6tables is disabled
#
# If we are using Apps then this should already be enabled
# May cause "guest container traffic to be blocked by iptables rules that are intended for the host"
# https://unix.stackexchange.com/q/720105/477308
# https://github.com/moby/moby/issues/24809
# https://docs.oracle.com/en/operating-systems/oracle-linux/docker/docker-KnownIssues.html#docker-issues
# https://wiki.libvirt.org/page/Net.bridge.bridge-nf-call_and_sysctl.conf
# https://serverfault.com/questions/963759/docker-breaks-libvirt-bridge-network
if subprocess.run(["modprobe", "br_netfilter"]).returncode == 0:
print(1, file=open("/proc/sys/net/bridge/bridge-nf-call-iptables", "w"))
print(1, file=open("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "w"))
else:
eprint(
dedent(
"""
Failed to load br_netfilter kernel module."""
)
)
print("The `docker_compatible` option disables seccomp filtering...")
seccomp = False
# Add additional flags required for docker
systemd_nspawn_additional_args += [
"--capability=all",
]
# Add hooks to execute commands on the host before starting and after stopping a jail
add_hook(
jail_path,
systemd_run_additional_args,
config.my_get("pre_start_hook"),
"ExecStartPre",
)
add_hook(
jail_path,
systemd_run_additional_args,
config.my_get("post_stop_hook"),
"ExecStopPost",
)
# Legacy gpu_passthrough config setting
if config.my_getboolean("gpu_passthrough", False):
eprint("WARNING: DEPRECATED OPTION")
eprint(
"The `gpu_passthrough` option is deprecated and will be removed in a future release."
)
eprint(
"Please use `gpu_passthrough_intel` and/or `gpu_passthrough_nvidia` instead."
)
gpu_passthrough_intel = True
gpu_passthrough_nvidia = True
else:
gpu_passthrough_intel = config.my_getboolean("gpu_passthrough_intel")
gpu_passthrough_nvidia = config.my_getboolean("gpu_passthrough_nvidia")
passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args)
passthrough_nvidia(
gpu_passthrough_nvidia, systemd_nspawn_additional_args, jail_name
)
if seccomp is False:
# Disabling seccomp filtering by passing --setenv=SYSTEMD_SECCOMP=0 to systemd-run will improve performance
# at the expense of security: it allows syscalls which otherwise would be blocked or would have to be explicitly allowed by passing
# --system-call-filter to systemd-nspawn
# https://github.com/systemd/systemd/issues/18370
#
# However, and additional layer of seccomp filtering may be undesirable
# For example when using docker to run containers inside the jail created with systemd-nspawn
# Even though seccomp filtering is disabled for the systemd-nspawn jail itself, docker can still use seccomp filtering
# to restrict the actions available within its containers
#
# Proof that seccomp can be used inside a jail started with --setenv=SYSTEMD_SECCOMP=0:
# Run a command in a docker container which is blocked by the default docker seccomp profile:
# docker run --rm -it debian:jessie unshare --map-root-user --user sh -c whoami
# unshare: unshare failed: Operation not permitted
# Now run unconfined to show command runs successfully:
# docker run --rm -it --security-opt seccomp=unconfined debian:jessie unshare --map-root-user --user sh -c whoami
# root
systemd_run_additional_args += [
"--setenv=SYSTEMD_SECCOMP=0",
]
initial_setup = False
# If there's no machine-id, then this the first time the jail is started
if not os.path.exists(os.path.join(jail_rootfs_path, "etc/machine-id")) and (
initial_setup := config.my_get("initial_setup")
):
if not initial_setup.startswith("#!"):
initial_setup = "#!/bin/sh\n" + initial_setup
initial_setup_file_jailed_path = "/root/jlmkr-initial-setup"
initial_setup_file_host_path = os.path.abspath(
jail_rootfs_path + initial_setup_file_jailed_path
)
# Write a script file to call during initial setup
print(initial_setup, file=open(initial_setup_file_host_path, "w"))
stat_chmod(initial_setup_file_host_path, 0o700)
# Ensure the jail init system is ready before we start the initial_setup
systemd_nspawn_additional_args += [
"--notify-ready=yes",
]
cmd = [
"systemd-run",
*shlex.split(config.my_get("systemd_run_default_args")),
*systemd_run_additional_args,
"--",
"systemd-nspawn",
*shlex.split(config.my_get("systemd_nspawn_default_args")),
*systemd_nspawn_additional_args,
*shlex.split(config.my_get("systemd_nspawn_user_args")),
]
print(
dedent(
f"""
Starting jail {jail_name} with the following command:
{shlex.join(cmd)}
"""
)
)
returncode = subprocess.run(cmd).returncode
if returncode != 0:
eprint(
dedent(
f"""
Failed to start jail {jail_name}...
In case of a config error, you may fix it with:
{SYMLINK_NAME} edit {jail_name}
"""
)
)
return returncode
# Handle initial setup after jail is up and running (for the first time)
if initial_setup:
print("About to run the initial setup.")
print("Waiting for networking in the jail to be ready.")
print("Please wait (this may take 90s in case of bridge networking)...")
returncode = exec_jail(
jail_name,
[
"--",
"systemd-run",
f"--unit={os.path.basename(initial_setup_file_jailed_path)}",
"--quiet",
"--pipe",
"--wait",
"--service-type=exec",
"--property=After=network-online.target",
"--property=Wants=network-online.target",
initial_setup_file_jailed_path,
],
)
# Cleanup the initial_setup_file_host_path
if initial_setup_file_host_path:
Path(initial_setup_file_host_path).unlink(missing_ok=True)
if returncode != 0:
eprint("Tried to run the following commands inside the jail:")
eprint(initial_setup)
eprint()
eprint(
f"""{RED}{BOLD}Failed to run initial setup... you may want to stop and remove the jail and try again.{NORMAL}"""
)
return returncode
else:
print(f"Done with initial setup of jail {jail_name}!")
return returncode
def restart_jail(jail_name):
"""
Restart jail with given name.
"""
returncode = stop_jail(jail_name)
if returncode != 0:
eprint("Abort restart.")
return returncode
return start_jail(jail_name)
def cleanup(jail_path):
"""
Cleanup jail.
"""
if get_zfs_dataset(jail_path):
eprint(f"Cleaning up: {jail_path}.")
remove_zfs_dataset(jail_path)
elif os.path.isdir(jail_path):
# Workaround for https://github.com/python/cpython/issues/73885
# Should be fixed in Python 3.13 https://stackoverflow.com/a/70549000
def _onerror(func, path, exc_info):
exc_type, exc_value, exc_traceback = exc_info
if not issubclass(exc_type, FileNotFoundError):
raise exc_value
eprint(f"Cleaning up: {jail_path}.")
shutil.rmtree(jail_path, onerror=_onerror)
def input_with_default(prompt, default):
"""
Ask user for input with a default value already provided.
"""
readline.set_startup_hook(lambda: readline.insert_text(default))
try:
return input(prompt)
finally:
readline.set_startup_hook()
def validate_sha256(file_path, digest):
"""
Validates if a file matches a sha256 digest.
"""
try:
with open(file_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
return file_hash == digest
except FileNotFoundError:
return False
def run_lxc_download_script(
jail_name=None, jail_path=None, jail_rootfs_path=None, distro=None, release=None
):
arch = "amd64"
lxc_dir = ".lxc"
lxc_cache = os.path.join(lxc_dir, "cache")
lxc_download_script = os.path.join(lxc_dir, "lxc-download.sh")
# Create the lxc dirs if nonexistent
os.makedirs(lxc_dir, exist_ok=True)
stat_chmod(lxc_dir, 0o700)
os.makedirs(lxc_cache, exist_ok=True)
stat_chmod(lxc_cache, 0o700)
try:
if os.stat(lxc_download_script).st_uid != 0:
os.remove(lxc_download_script)
except FileNotFoundError:
pass
# Fetch the lxc download script if not present locally (or hash doesn't match)
if not validate_sha256(lxc_download_script, DOWNLOAD_SCRIPT_DIGEST):
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/Jip-Hop/lxc/97f93be72ebf380f3966259410b70b1c966b0ff0/templates/lxc-download.in",
lxc_download_script,
)
if not validate_sha256(lxc_download_script, DOWNLOAD_SCRIPT_DIGEST):
eprint("Abort! Downloaded script has unexpected contents.")
return 1
stat_chmod(lxc_download_script, 0o700)
check_exit_code = False
if None not in [jail_name, jail_path, jail_rootfs_path, distro, release]:
check_exit_code = True
cmd = [
lxc_download_script,
f"--name={jail_name}",
f"--path={jail_path}",
f"--rootfs={jail_rootfs_path}",
f"--arch={arch}",
f"--dist={distro}",
f"--release={release}",
]
else:
cmd = [lxc_download_script, "--list", f"--arch={arch}"]
p1 = subprocess.Popen(
cmd, stdout=subprocess.PIPE, env={"LXC_CACHE_PATH": lxc_cache}
)
for line in iter(p1.stdout.readline, b""):
line = line.decode().strip()
# Filter out the known incompatible distros
if not re.match(
r"^(alpine|amazonlinux|busybox|devuan|funtoo|openwrt|plamo|voidlinux)\s",
line,
):
print(line)
p1.wait()
if check_exit_code and p1.returncode != 0:
eprint("Aborting...")
return p1.returncode
return 0
def stat_chmod(file_path, mode):
"""
Change mode if file doesn't already have this mode.
"""
if mode != stat.S_IMODE(os.stat(file_path).st_mode):
os.chmod(file_path, mode)
def agree(question, default=None):
"""
Ask user a yes/no question.
"""
hint = "[Y/n]" if default == "y" else ("[y/N]" if default == "n" else "[y/n]")
while True:
user_input = input(f"{question} {hint} ") or default
if user_input.lower() in ["y", "n"]:
return user_input.lower() == "y"
eprint("Invalid input. Please type 'y' for yes or 'n' for no and press enter.")
def get_mount_point(path):
"""
Return the mount point on which the given path resides.
"""
path = os.path.abspath(path)
while not os.path.ismount(path):
path = os.path.dirname(path)
return path
def get_zfs_dataset(path):
"""
Get ZFS dataset path.
"""
def clean_field(field):
# Put back spaces which were encoded
# https://github.com/openzfs/zfs/issues/11182
return field.replace("\\040", " ")
path = os.path.realpath(path)
with open("/proc/mounts", "r") as f:
for line in f:
fields = line.split()
if "zfs" == fields[2] and path == clean_field(fields[1]):
return clean_field(fields[0])
def get_zfs_base_path():
"""
Get ZFS dataset path for jailmaker directory.
"""
zfs_base_path = get_zfs_dataset(SCRIPT_DIR_PATH)
if not zfs_base_path:
fail("Failed to get dataset path for jailmaker directory.")
return zfs_base_path
def create_zfs_dataset(relative_path):
"""
Create a ZFS Dataset.
Receives the dataset to be created relative to the jailmaker script (e.g. "jails" or "jails/newjail").
"""
dataset_to_create = os.path.join(get_zfs_base_path(), relative_path)
eprint(f"Creating ZFS Dataset {dataset_to_create}")
subprocess.run(["zfs", "create", dataset_to_create], check=True)
def remove_zfs_dataset(relative_path):
"""
Remove a ZFS Dataset.
Receives the dataset to be removed relative to the jailmaker script (e.g. "jails/oldjail").
"""
dataset_to_remove = os.path.join((get_zfs_base_path()), relative_path)
eprint(f"Removing ZFS Dataset {dataset_to_remove}")
subprocess.run(["zfs", "destroy", "-r", dataset_to_remove], check=True)
def check_jail_name_valid(jail_name, warn=True):
"""
Return True if jail name matches the required format.
"""
if (
re.match(r"^[.a-zA-Z0-9-]{1,64}$", jail_name)
and not jail_name.startswith(".")
and ".." not in jail_name
):
return True
if warn:
eprint(
dedent(
f"""
{YELLOW}{BOLD}WARNING: INVALID NAME{NORMAL}
A valid name consists of:
- allowed characters (alphanumeric, dash, dot)
- no leading or trailing dots
- no sequences of multiple dots
- max 64 characters"""
)
)
return False
def check_jail_name_available(jail_name, warn=True):
"""
Return True if jail name is not yet taken.
"""
if not os.path.exists(get_jail_path(jail_name)):
return True
if warn:
print()
eprint("A jail with this name already exists.")
return False
def ask_jail_name(jail_name=""):
while True:
print()
jail_name = input_with_default("Enter jail name: ", jail_name).strip()
if check_jail_name_valid(jail_name):
if check_jail_name_available(jail_name):
return jail_name
def agree_with_default(config, key, question):
default_answer = "y" if config.my_getboolean(key) else "n"
config.my_set(key, agree(question, default_answer))
def get_text_editor():
def get_from_environ(key):
if editor := os.environ.get(key):
return shutil.which(editor)
return (
get_from_environ("VISUAL")
or get_from_environ("EDITOR")
or shutil.which("editor")
or shutil.which("/usr/bin/editor")
or "nano"
)
def interactive_config():
config = KeyValueParser()
config.read_string(DEFAULT_CONFIG)
recommended_distro = config.my_get("distro")
recommended_release = config.my_get("release")
#################
# Config handling
#################
jail_name = ""
print()
if agree("Do you wish to create a jail from a config template?", "n"):
print(
dedent(
"""
A text editor will open so you can provide the config template.
1. Please copy your config
2. Paste it into the text editor
3. Save and close the text editor
"""
)
)
input("Press Enter to open the text editor.")
with tempfile.NamedTemporaryFile(mode="w+t") as f:
subprocess.call([get_text_editor(), f.name])
f.seek(0)
# Start over with a new KeyValueParser to parse user config
config = KeyValueParser()
config.read_file(f)
# Ask for jail name
jail_name = ask_jail_name(jail_name)
else:
print()
if not agree(
f"Install the recommended image ({recommended_distro} {recommended_release})?",
"y",
):
print(
dedent(
f"""
{YELLOW}{BOLD}WARNING: ADVANCED USAGE{NORMAL}
You may now choose from a list which distro to install.
But not all of them may work with {COMMAND_NAME} since these images are made for LXC.
Distros based on systemd probably work (e.g. Ubuntu, Arch Linux and Rocky Linux).
"""
)
)
input("Press Enter to continue...")
print()
returncode = run_lxc_download_script()
if returncode != 0:
return returncode
print(
dedent(
"""
Choose from the DIST column.
"""
)
)
config.my_set("distro", input("Distro: "))
print(
dedent(
"""
Choose from the RELEASE column (or ARCH if RELEASE is empty).
"""
)
)
config.my_set("release", input("Release: "))
jail_name = ask_jail_name(jail_name)
print()
agree_with_default(
config, "gpu_passthrough_intel", "Passthrough the intel GPU (if present)?"
)
print()
agree_with_default(
config, "gpu_passthrough_nvidia", "Passthrough the nvidia GPU (if present)?"
)
print(
dedent(
f"""
{YELLOW}{BOLD}WARNING: CHECK SYNTAX{NORMAL}
You may pass additional flags to systemd-nspawn.
With incorrect flags the jail may not start.
It is possible to correct/add/remove flags post-install.
"""
)
)
if agree("Show the man page for systemd-nspawn?", "n"):
subprocess.run(["man", "systemd-nspawn"])
else:
try:
base_os_version = platform.freedesktop_os_release().get(
"VERSION_CODENAME", recommended_release
)
except AttributeError:
base_os_version = recommended_release
print(
dedent(
f"""
You may read the systemd-nspawn manual online:
https://manpages.debian.org/{base_os_version}/systemd-container/systemd-nspawn.1.en.html"""
)
)
# Backslashes and colons need to be escaped in bind mount options:
# e.g. to bind mount a file called:
# weird chars :?\"
# the corresponding command would be:
# --bind-ro='/mnt/data/weird chars \:?\\"'
print(
dedent(
"""
Would you like to add additional systemd-nspawn flags?
For example to mount directories inside the jail you may:
Mount the TrueNAS location /mnt/pool/dataset to the /home directory of the jail with:
--bind='/mnt/pool/dataset:/home'
Or the same, but readonly, with:
--bind-ro='/mnt/pool/dataset:/home'
Or create macvlan interface with:
--network-macvlan=eno1 --resolv-conf=bind-host
"""
)
)
# Enable tab auto completion of file paths after the = symbol
readline.set_completer_delims("=")
readline.parse_and_bind("tab: complete")
readline_lib = ctypes.CDLL(readline.__file__)
rl_completer_quote_characters = ctypes.c_char_p.in_dll(
readline_lib, "rl_completer_quote_characters"
)
# Let the readline library know about quote characters for completion
rl_completer_quote_characters.value = "\"'".encode("utf-8")
# TODO: more robust tab completion of file paths with space or = character
# Currently completing these only works when the path is quoted
# https://thoughtbot.com/blog/tab-completion-in-gnu-readline
# https://stackoverflow.com/a/67118744
# https://github.com/python-cmd2/cmd2/blob/ee7599f9ac0dbb6ce3793f6b665ba1200d3ef9a3/cmd2/cmd2.py
# https://stackoverflow.com/a/40152927
config.my_set(
"systemd_nspawn_user_args",
"\n ".join(shlex.split(input("Additional flags: ") or "")),
)
# Disable tab auto completion
readline.parse_and_bind("tab: self-insert")
print(
dedent(
f"""
The `{COMMAND_NAME} startup` command can automatically ensure {COMMAND_NAME} is installed properly and start a selection of jails.
This comes in handy when you want to automatically start multiple jails after booting TrueNAS SCALE (e.g. from a Post Init Script).
"""
)
)
config.my_set(
"startup",
agree(
f"Do you want to start this jail when running: {COMMAND_NAME} startup?",
"n",
),
)
print()
start_now = agree("Do you want to start this jail now (when create is done)?", "y")
print()
return jail_name, config, start_now
def create_jail(**kwargs):
print(DISCLAIMER)
if os.path.basename(os.getcwd()) != "jailmaker":
eprint(
dedent(
f"""
{COMMAND_NAME} needs to create files.
Currently it can not decide if it is safe to create files in:
{SCRIPT_DIR_PATH}
Please create a dedicated dataset called "jailmaker", store {SCRIPT_NAME} there and try again."""
)
)
return 1
if not PurePath(get_mount_point(os.getcwd())).is_relative_to("/mnt"):
print(
dedent(
f"""
{YELLOW}{BOLD}WARNING: BEWARE OF DATA LOSS{NORMAL}
{SCRIPT_NAME} should be on a dataset mounted under /mnt (it currently is not).
Storing it on the boot-pool means losing all jails when updating TrueNAS.
Jails will be stored under:
{SCRIPT_DIR_PATH}
"""
)
)
jail_name = kwargs.pop("jail_name", None)
start_now = False
# Non-interactive create
if jail_name:
if not check_jail_name_valid(jail_name):
return 1
if not check_jail_name_available(jail_name):
return 1
start_now = kwargs.pop("start", start_now)
jail_config_path = kwargs.pop("config")
config = KeyValueParser()
if jail_config_path:
# TODO: fallback to default values for e.g. distro and release if they are not in the config file
print(f"Creating jail {jail_name} from config template {jail_config_path}.")
if jail_config_path not in config.read(jail_config_path):
eprint(f"Failed to read config config template {jail_config_path}.")
return 1
else:
print(f"Creating jail {jail_name} with default config.")
config.read_string(DEFAULT_CONFIG)
user_overridden = False
for option in [
"distro",
"docker_compatible",
"gpu_passthrough_intel",
"gpu_passthrough_nvidia",
"release",
"seccomp",
"startup",
"systemd_nspawn_user_args",
]:
value = kwargs.pop(option)
if (
value is not None
and len(value)
and value is not config.my_get(option, None)
):
# TODO: this will wipe all systemd_nspawn_user_args from the template...
# Should there be an option to append them instead?
print(f"Overriding {option} config value with {value}.")
config.my_set(option, value)
user_overridden = True
if not user_overridden:
print(
dedent(
f"""
TIP: Run `{SYMLINK_NAME} create` without any arguments for interactive config.
Or use CLI args to override the default options.
For more info, run: `{SYMLINK_NAME} create --help`
"""
)
)
else:
jail_name, config, start_now = interactive_config()
jail_path = get_jail_path(jail_name)
distro = config.my_get("distro")
release = config.my_get("release")
# Cleanup in except, but only once the jail_path is final
# Otherwise we may cleanup the wrong directory
try:
# Create the dir or dataset where to store the jails
if not os.path.exists(JAILS_DIR_PATH):
if get_zfs_dataset(SCRIPT_DIR_PATH):
# Creating "jails" dataset if "jailmaker" is a ZFS Dataset
create_zfs_dataset(JAILS_DIR_PATH)
else:
os.makedirs(JAILS_DIR_PATH, exist_ok=True)
stat_chmod(JAILS_DIR_PATH, 0o700)
# Creating a dataset for the jail if the jails dir is a dataset
if get_zfs_dataset(JAILS_DIR_PATH):
create_zfs_dataset(jail_path)
jail_config_path = get_jail_config_path(jail_name)
jail_rootfs_path = get_jail_rootfs_path(jail_name)
# Create directory for rootfs
os.makedirs(jail_rootfs_path, exist_ok=True)
# LXC download script needs to write to this file during install
# but we don't need it so we will remove it later
open(jail_config_path, "a").close()
returncode = run_lxc_download_script(
jail_name, jail_path, jail_rootfs_path, distro, release
)
if returncode != 0:
return returncode
# Assuming the name of your jail is "myjail"
# and "machinectl shell myjail" doesn't work
# Try:
#
# Stop the jail with:
# machinectl stop myjail
# And start a shell inside the jail without the --boot option:
# systemd-nspawn -q -D jails/myjail/rootfs /bin/sh
# Then set a root password with:
# In case of amazonlinux you may need to run:
# yum update -y && yum install -y passwd
# passwd
# exit
# Then you may login from the host via:
# machinectl login myjail
#
# You could also enable SSH inside the jail to login
#
# Or if that doesn't work (e.g. for alpine) get a shell via:
# nsenter -t $(machinectl show myjail -p Leader --value) -a /bin/sh -l
# But alpine jails made with jailmaker have other issues
# They don't shutdown cleanly via systemctl and machinectl...
if (
os.path.basename(
os.path.realpath(os.path.join(jail_rootfs_path, "sbin/init"))
)
!= "systemd"
):
print(
dedent(
f"""
{YELLOW}{BOLD}WARNING: DISTRO NOT SUPPORTED{NORMAL}
Chosen distro appears not to use systemd...
You probably will not get a shell with:
machinectl shell {jail_name}
You may get a shell with this command:
nsenter -t $(machinectl show {jail_name} -p Leader --value) -a /bin/sh -l
Read about the downsides of nsenter:
https://github.com/systemd/systemd/issues/12785#issuecomment-503019081
{BOLD}Using this distro with {COMMAND_NAME} is NOT recommended.{NORMAL}
"""
)
)
print("Autostart has been disabled.")
print("You need to start this jail manually.")
config.my_set("startup", 0)
start_now = False
with contextlib.suppress(FileNotFoundError):
# Remove config which systemd handles for us
os.remove(os.path.join(jail_rootfs_path, "etc/machine-id"))
os.remove(os.path.join(jail_rootfs_path, "etc/resolv.conf"))
# https://github.com/systemd/systemd/issues/852
print(
"\n".join([f"pts/{i}" for i in range(0, 11)]),
file=open(os.path.join(jail_rootfs_path, "etc/securetty"), "w"),
)
network_dir_path = os.path.join(jail_rootfs_path, "etc/systemd/network")
# Modify default network settings, if network_dir_path exists
if os.path.isdir(network_dir_path):
default_host0_network_file = os.path.join(
jail_rootfs_path, "lib/systemd/network/80-container-host0.network"
)
# Check if default host0 network file exists
if os.path.isfile(default_host0_network_file):
override_network_file = os.path.join(
network_dir_path, "80-container-host0.network"
)
# Override the default 80-container-host0.network file (by using the same name)
# This config applies when using the --network-bridge option of systemd-nspawn
# Disable LinkLocalAddressing on IPv4, or else the container won't get IP address via DHCP
# But keep it enabled on IPv6, as SLAAC and DHCPv6 both require a local-link address to function
print(
Path(default_host0_network_file)
.read_text()
.replace("LinkLocalAddressing=yes", "LinkLocalAddressing=ipv6"),
file=open(override_network_file, "w"),
)
# Setup DHCP for macvlan network interfaces
# This config applies when using the --network-macvlan option of systemd-nspawn
# https://www.debian.org/doc/manuals/debian-reference/ch05.en.html#_the_modern_network_configuration_without_gui
print(
cleandoc(
"""
[Match]
Virtualization=container
Name=mv-*
[Network]
DHCP=yes
LinkLocalAddressing=ipv6
[DHCPv4]
UseDNS=true
UseTimezone=true
"""
),
file=open(os.path.join(network_dir_path, "mv-dhcp.network"), "w"),
)
# Override preset which caused systemd-networkd to be disabled (e.g. fedora 39)
# https://www.freedesktop.org/software/systemd/man/latest/systemd.preset.html
# https://github.com/lxc/lxc-ci/blob/f632823ecd9b258ed42df40449ec54ed7ef8e77d/images/fedora.yaml#L312C5-L312C38
preset_path = os.path.join(jail_rootfs_path, "etc/systemd/system-preset")
os.makedirs(preset_path, exist_ok=True)
print(
"enable systemd-networkd.service",
file=open(os.path.join(preset_path, "00-jailmaker.preset"), "w"),
)
with open(jail_config_path, "w") as fp:
config.write(fp)
os.chmod(jail_config_path, 0o600)
# Cleanup on any exception and rethrow
except BaseException as error:
cleanup(jail_path)
raise error
if start_now:
return start_jail(jail_name)
return 0
def jail_is_running(jail_name):
return (
subprocess.run(
["machinectl", "show", jail_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def edit_jail(jail_name):
"""
Edit jail with given name.
"""
if not check_jail_name_valid(jail_name):
return 1
if check_jail_name_available(jail_name, False):
eprint(f"A jail with name {jail_name} does not exist.")
return 1
jail_config_path = get_jail_config_path(jail_name)
returncode = subprocess.run([get_text_editor(), jail_config_path]).returncode
if returncode != 0:
eprint(f"An error occurred while editing {jail_config_path}.")
return returncode
if jail_is_running(jail_name):
print("\nRestart the jail for edits to apply (if you made any).")
return 0
def stop_jail(jail_name):
"""
Stop jail with given name and wait until stopped.
"""
if not jail_is_running(jail_name):
return 0
returncode = subprocess.run(["machinectl", "poweroff", jail_name]).returncode
if returncode != 0:
eprint("Error while stopping jail.")
return returncode
print(f"Wait for {jail_name} to stop", end="", flush=True)
while jail_is_running(jail_name):
time.sleep(1)
print(".", end="", flush=True)
return 0
def remove_jail(jail_name):
"""
Remove jail with given name.
"""
if not check_jail_name_valid(jail_name):
return 1
if check_jail_name_available(jail_name, False):
eprint(f"A jail with name {jail_name} does not exist.")
return 1
# TODO: print which dataset is about to be removed before the user confirmation
check = input(f'\nCAUTION: Type "{jail_name}" to confirm jail deletion!\n\n')
if check == jail_name:
print()
jail_path = get_jail_path(jail_name)
returncode = stop_jail(jail_name)
if returncode != 0:
return returncode
print()
cleanup(jail_path)
return 0
else:
eprint("Wrong name, nothing happened.")
return 1
def print_table(header, list_of_objects, empty_value_indicator):
# Find max width for each column
widths = defaultdict(int)
for obj in list_of_objects:
for hdr in header:
value = obj.get(hdr)
if value is None:
obj[hdr] = value = empty_value_indicator
widths[hdr] = max(widths[hdr], len(str(value)), len(str(hdr)))
# Print header
print(
UNDERLINE + " ".join(hdr.upper().ljust(widths[hdr]) for hdr in header) + NORMAL
)
# Print rows
for obj in list_of_objects:
print(" ".join(str(obj.get(hdr)).ljust(widths[hdr]) for hdr in header))
def run_command_and_parse_json(command):
result = subprocess.run(command, capture_output=True, text=True)
output = result.stdout.strip()
try:
parsed_output = json.loads(output)
return parsed_output
except json.JSONDecodeError as e:
eprint(f"Error parsing JSON: {e}")
return None
def get_all_jail_names():
try:
jail_names = os.listdir(JAILS_DIR_PATH)
except FileNotFoundError:
jail_names = []
return jail_names
def parse_os_release(candidates):
for candidate in candidates:
try:
with open(candidate, encoding="utf-8") as f:
# TODO: can I create a solution which not depends on the internal _parse_os_release method?
return platform._parse_os_release(f)
except OSError:
# Silently ignore failing to read os release info
pass
return {}
def list_jails():
"""
List all available and running jails.
"""
jails = {}
empty_value_indicator = "-"
jail_names = get_all_jail_names()
if not jail_names:
print("No jails.")
return 0
# Get running jails from machinectl
running_machines = run_command_and_parse_json(["machinectl", "list", "-o", "json"])
# Index running_machines by machine name
# We're only interested in systemd-nspawn machines
running_machines = {
item["machine"]: item
for item in running_machines
if item["service"] == "systemd-nspawn"
}
for jail_name in jail_names:
jail_rootfs_path = get_jail_rootfs_path(jail_name)
jails[jail_name] = {"name": jail_name, "running": False}
jail = jails[jail_name]
config = parse_config_file(get_jail_config_path(jail_name))
if config:
jail["startup"] = config.my_getboolean("startup")
# TODO: remove gpu_passthrough in future release
if config.my_getboolean("gpu_passthrough", False):
jail["gpu_intel"] = True
jail["gpu_nvidia"] = True
else:
jail["gpu_intel"] = config.my_getboolean("gpu_passthrough_intel")
jail["gpu_nvidia"] = config.my_getboolean("gpu_passthrough_nvidia")
if jail_name in running_machines:
machine = running_machines[jail_name]
# Augment the jails dict with output from machinectl
jail["running"] = True
jail["os"] = machine["os"] or None
jail["version"] = machine["version"] or None
addresses = machine.get("addresses")
if not addresses:
jail["addresses"] = empty_value_indicator
else:
addresses = addresses.split("\n")
jail["addresses"] = addresses[0]
if len(addresses) > 1:
jail["addresses"] += ""
else:
# Parse os-release info ourselves
jail_platform = parse_os_release(
(
os.path.join(jail_rootfs_path, "etc/os-release"),
os.path.join(jail_rootfs_path, "usr/lib/os-release"),
)
)
jail["os"] = jail_platform.get("ID")
jail["version"] = jail_platform.get("VERSION_ID") or jail_platform.get(
"VERSION_CODENAME"
)
print_table(
[
"name",
"running",
"startup",
"gpu_intel",
"gpu_nvidia",
"os",
"version",
"addresses",
],
sorted(jails.values(), key=lambda x: x["name"]),
empty_value_indicator,
)
return 0
def replace_or_add_string(file_path, regex, replacement_string):
"""
Replace all occurrences of a regular expression in a file with a given string.
Add the string to the end of the file if regex doesn't match.
Args:
file_path (str): The path to the file.
regex (str): The regular expression to search for.
replacement_string (str): The string to replace the matches with.
"""
with open(file_path, "a+") as f:
f.seek(0)
updated = False
found = False
new_text = ""
replacement_line = f"{replacement_string}\n"
for line in f:
if not re.match(regex, line):
new_text += line
continue
found = True
new_text += replacement_line
if replacement_line != line:
updated = True
if not new_text.strip():
# In case of an empty file just write the replacement_string
new_text = replacement_line
updated = True
elif not found:
# Add a newline to the end of the file in case it's not there
if not new_text.endswith("\n"):
new_text += "\n"
# Then add our replacement_string to the end of the file
new_text += replacement_line
updated = True
# Only overwrite in case there are change to the file
if updated:
f.seek(0)
f.truncate()
f.write(new_text)
return True
return False
def install_jailmaker():
# Check if command exists in path
if shutil.which("systemd-nspawn"):
print("systemd-nspawn is already installed.")
else:
print("Installing jailmaker dependencies...")
original_permissions = {}
print(
"Temporarily enable apt and dpkg (if not already enabled) to install systemd-nspawn."
)
# Make /bin/apt* and /bin/dpkg* files executable
for file in glob.glob("/bin/apt*") + (glob.glob("/bin/dpkg*")):
original_permissions[file] = os.stat(file).st_mode
stat_chmod(file, 0o755)
subprocess.run(["apt-get", "update"], check=True)
subprocess.run(["apt-get", "install", "-y", "systemd-container"], check=True)
# Restore original permissions
print("Restore permissions of apt and dpkg.")
for file, original_permission in original_permissions.items():
stat_chmod(file, original_permission)
symlink = f"/usr/local/sbin/{SYMLINK_NAME}"
if os.path.lexists(symlink) and not os.path.islink(symlink):
print(
f"Unable to create symlink at {symlink}. File already exists but is not a symlink."
)
# Check if the symlink is already pointing to the desired destination
elif os.path.realpath(symlink) != SCRIPT_PATH:
try:
Path(symlink).unlink(missing_ok=True)
os.symlink(SCRIPT_PATH, symlink)
print(f"Created symlink {symlink} to {SCRIPT_PATH}.")
except OSError as e:
if e.errno != errno.EROFS:
raise e
print(
f"Cannot create symlink because {symlink} is on a readonly filesystem."
)
alias = f"alias jlmkr={shlex.quote(SCRIPT_PATH)} # managed by jailmaker"
alias_regex = re.compile(r"^\s*alias jlmkr=.*# managed by jailmaker\s*")
shell_env = os.getenv("SHELL")
for shell_type in ["bash", "zsh"]:
file = "/root/.bashrc" if shell_type == "bash" else "/root/.zshrc"
if replace_or_add_string(file, alias_regex, alias):
print(f"Created {shell_type} alias {SYMLINK_NAME}.")
if shell_env.endswith(shell_type):
print(
f"Please source {file} manually for the {SYMLINK_NAME} alias to become effective immediately."
)
else:
print(f"The {shell_type} alias {SYMLINK_NAME} is already present.")
print("Done installing jailmaker.")
return 0
def startup_jails():
returncode = install_jailmaker()
if returncode != 0:
eprint("Failed to install jailmaker. Abort startup.")
return returncode
start_failure = False
for jail_name in get_all_jail_names():
config = parse_config_file(get_jail_config_path(jail_name))
if config and config.my_getboolean("startup"):
if start_jail(jail_name) != 0:
start_failure = True
if start_failure:
return 1
return 0
def split_at_string(lst, string):
try:
index = lst.index(string)
return lst[:index], lst[index + 1 :]
except ValueError:
return lst, []
def add_parser(subparser, **kwargs):
if kwargs.get("add_help") is False:
# Don't add help if explicitly disabled
add_help = False
else:
# Never add help with the built in add_help
kwargs["add_help"] = False
add_help = True
kwargs["epilog"] = DISCLAIMER
kwargs["exit_on_error"] = False
func = kwargs.pop("func")
parser = subparser.add_parser(**kwargs)
parser.set_defaults(func=func)
if add_help:
parser.add_argument(
"-h", "--help", help="show this help message and exit", action="store_true"
)
# Setting the add_help after the parser has been created with add_parser has no effect,
# but it allows us to look up if this parser has a help message available
parser.add_help = add_help
return parser
def main():
if os.stat(SCRIPT_PATH).st_uid != 0:
fail(
f"This script should be owned by the root user... Fix it manually with: `chown root {SCRIPT_PATH}`."
)
parser = argparse.ArgumentParser(
description=__doc__, epilog=DISCLAIMER, allow_abbrev=False
)
parser.add_argument("--version", action="version", version=__version__)
subparsers = parser.add_subparsers(
title="commands", dest="command", metavar="", parser_class=CustomSubParser
)
split_commands = ["create", "exec"]
commands = {}
for d in [
dict(
name="create", #
help="create a new jail",
func=create_jail,
),
dict(
name="edit",
help=f"edit jail config with {get_text_editor()} text editor",
func=edit_jail,
),
dict(
name="exec", #
help="execute a command in the jail",
func=exec_jail,
),
dict(
name="images",
help="list available images to create jails from",
func=run_lxc_download_script,
),
dict(
name="install",
help="install jailmaker dependencies and create symlink",
func=install_jailmaker,
),
dict(
name="list", #
help="list jails",
func=list_jails,
),
dict(
name="log", #
help="show jail log",
func=log_jail,
),
dict(
name="remove", #
help="remove previously created jail",
func=remove_jail,
),
dict(
name="restart", #
help="restart a running jail",
func=restart_jail,
),
dict(
name="shell",
help="open shell in running jail (alias for machinectl shell)",
func=shell_jail,
add_help=False,
),
dict(
name="start", #
help="start previously created jail",
func=start_jail,
),
dict(
name="startup",
help=f"install {SYMLINK_NAME} and startup selected jails",
func=startup_jails,
),
dict(
name="status", #
help="show jail status",
func=status_jail,
),
dict(
name="stop", #
help="stop a running jail",
func=stop_jail,
),
]:
commands[d["name"]] = add_parser(subparsers, **d)
for cmd in ["edit", "exec", "log", "remove", "restart", "start", "status", "stop"]:
commands[cmd].add_argument("jail_name", help="name of the jail")
commands["exec"].add_argument(
"cmd",
nargs="*",
help="command to execute",
)
commands["shell"].add_argument(
"args",
nargs="*",
help="args to pass to machinectl shell",
)
commands["create"].add_argument(
"jail_name", #
nargs="?",
help="name of the jail",
)
commands["create"].add_argument("--distro")
commands["create"].add_argument("--release")
commands["create"].add_argument(
"--start", #
help="start jail after create",
action="store_true",
)
commands["create"].add_argument(
"--startup",
type=int,
choices=[0, 1],
help=f"start this jail when running: {SCRIPT_NAME} startup",
)
commands["create"].add_argument(
"--docker_compatible", #
type=int,
choices=[0, 1],
help="DEPRECATED",
)
commands["create"].add_argument(
"--seccomp", #
type=int,
choices=[0, 1],
help="turning off seccomp filtering improves performance at the expense of security",
)
commands["create"].add_argument(
"-c", #
"--config",
help="path to config file template",
)
commands["create"].add_argument(
"-gi", #
"--gpu_passthrough_intel",
type=int,
choices=[0, 1],
)
commands["create"].add_argument(
"-gn", #
"--gpu_passthrough_nvidia",
type=int,
choices=[0, 1],
)
commands["create"].add_argument(
"systemd_nspawn_user_args",
nargs="*",
help="add additional systemd-nspawn flags",
)
if os.getuid() != 0:
parser.print_help()
fail("Run this script as root...")
# Set appropriate permissions (if not already set) for this file, since it's executed as root
stat_chmod(SCRIPT_PATH, 0o700)
# Work relative to this script
os.chdir(SCRIPT_DIR_PATH)
# Ignore all args after the first "--"
args_to_parse = split_at_string(sys.argv[1:], "--")[0]
# Check for help
if any(item in args_to_parse for item in ["-h", "--help"]):
# Likely we need to show help output...
try:
args = vars(parser.parse_known_args(args_to_parse)[0])
# We've exited by now if not invoking a subparser: jlmkr.py --help
if args.get("help"):
need_help = True
command = args.get("command")
# Edge case for some commands
if command in split_commands and args["jail_name"]:
# Ignore all args after the jail name
args_to_parse = split_at_string(args_to_parse, args["jail_name"])[0]
# Add back the jail_name as it may be a required positional and we
# don't want to end up in the except clause below
args_to_parse += [args["jail_name"]]
# Parse one more time...
args = vars(parser.parse_known_args(args_to_parse)[0])
# ...and check if help is still in the remaining args
need_help = args.get("help")
print(need_help)
if need_help:
commands[command].print_help()
sys.exit()
except ExceptionWithParser as e:
# Print help output on error, e.g. due to:
# "error: the following arguments are required"
if e.parser.add_help:
e.parser.print_help()
sys.exit()
# Exit on parse errors (e.g. missing positional args)
for command in commands:
commands[command].exit_on_error = True
# Parse to find command and function and ignore unknown args which may be present
# such as args intended to pass through to systemd-run
args = vars(parser.parse_known_args()[0])
command = args.pop("command", None)
# Start over with original args
args_to_parse = sys.argv[1:]
if not command:
# Parse args and show error for unknown args
parser.parse_args(args_to_parse)
if agree("Create a new jail?", "y"):
print()
sys.exit(create_jail())
else:
parser.print_help()
sys.exit()
elif command == "shell":
# Pass anything after the "shell" command to machinectl
_, shell_args = split_at_string(args_to_parse, command)
sys.exit(args["func"](shell_args))
elif command in split_commands and args["jail_name"]:
jlmkr_args, remaining_args = split_at_string(args_to_parse, args["jail_name"])
if remaining_args and remaining_args[0] != "--":
# Add "--" after the jail name to ensure further args, e.g.
# --help or --version, are captured as systemd_nspawn_user_args
args_to_parse = jlmkr_args + [args["jail_name"], "--"] + remaining_args
# Parse args again, but show error for unknown args
args = vars(parser.parse_args(args_to_parse))
# Clean the args
args.pop("help")
args.pop("command", None)
func = args.pop("func")
sys.exit(func(**args))
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)