jailmaker/jlmkr.py

1846 lines
59 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Create persistent Linux 'jails' on TrueNAS SCALE, \
with full access to all files via bind mounts, \
thanks to systemd-nspawn!"""
2024-06-24 08:29:51 +00:00
__version__ = "2.1.0"
__author__ = "Jip-Hop"
2024-05-28 18:10:14 +00:00
__copyright__ = "Copyright (C) 2023, Jip-Hop"
__license__ = "LGPL-3.0-only"
__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 hashlib
import io
2023-08-15 11:03:43 +00:00
import json
import os
2023-08-15 06:07:36 +00:00
import platform
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
2023-02-28 19:25:11 +00:00
import time
import urllib.request
2023-08-15 13:21:53 +00:00
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
# 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 command/script to run on the HOST after starting the jail
# For example to attach to multiple bridge interfaces
# when using --network-veth-extra=ve-myjail-1:veth1
post_start_hook=
# post_start_hook=#!/usr/bin/bash
# set -euo pipefail
# echo 'POST_START_HOOK_EXAMPLE'
# ip link set dev ve-myjail-1 master br2
# ip link set dev ve-myjail-1 up
# 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
2024-01-21 17:21:24 +00:00
DOWNLOAD_SCRIPT_DIGEST = (
"cfcb5d08b24187d108f2ab0d21a6cc4b73dcd7f5d7dfc80803bfd7f1642d638d"
2024-01-21 17:21:24 +00:00
)
2023-08-14 13:19:25 +00:00
SCRIPT_PATH = os.path.realpath(__file__)
SCRIPT_NAME = os.path.basename(SCRIPT_PATH)
SCRIPT_DIR_PATH = os.path.dirname(SCRIPT_PATH)
2023-08-15 15:52:25 +00:00
COMMAND_NAME = os.path.basename(__file__)
2024-07-03 15:27:30 +00:00
JAILS_DIR_PATH = os.path.join(SCRIPT_DIR_PATH, "jails")
JAIL_CONFIG_NAME = "config"
JAIL_ROOTFS_NAME = "rootfs"
SHORTNAME = "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
2024-07-09 10:49:13 +00:00
self._comment_regex = re.compile(
r"^#\d+\s*" + re.escape(delimiter) + r"[^\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
2024-03-02 16:20:47 +00:00
# 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))
2024-03-01 16:35:05 +00:00
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)
2024-03-01 16:35:05 +00:00
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)
2024-06-24 12:04:40 +00:00
class Chroot:
def __init__(self, new_root):
self.new_root = new_root
self.old_root = None
self.initial_cwd = None
def __enter__(self):
self.old_root = os.open("/", os.O_PATH)
self.initial_cwd = os.path.abspath(os.getcwd())
os.chdir(self.new_root)
os.chroot(".")
def __exit__(self, exc_type, exc_value, traceback):
os.chdir(self.old_root)
os.chroot(".")
os.close(self.old_root)
os.chdir(self.initial_cwd)
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)
2023-02-28 19:25:11 +00:00
def get_jail_path(jail_name):
return os.path.join(JAILS_DIR_PATH, jail_name)
2023-08-14 14:12:33 +00:00
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!'
2023-03-03 21:33:41 +00:00
def passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args):
2024-03-02 21:18:48 +00:00
if not gpu_passthrough_intel:
2023-03-03 21:33:41 +00:00
return
2024-01-21 17:21:24 +00:00
if not os.path.exists("/dev/dri"):
eprint(
dedent(
"""
2023-03-03 21:33:41 +00:00
No intel GPU seems to be present...
2024-01-21 17:21:24 +00:00
Skip passthrough of intel GPU."""
)
)
2023-03-03 21:33:41 +00:00
return
2024-01-21 17:21:24 +00:00
systemd_nspawn_additional_args.append("--bind=/dev/dri")
2023-03-03 21:33:41 +00:00
2024-01-21 17:21:24 +00:00
def passthrough_nvidia(
gpu_passthrough_nvidia, systemd_nspawn_additional_args, jail_name
):
2024-01-27 04:44:05 +00:00
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/{SHORTNAME}-nvidia.conf"
2024-01-27 04:44:05 +00:00
)
2024-03-02 21:18:48 +00:00
if not gpu_passthrough_nvidia:
2024-01-27 04:44:05 +00:00
# Cleanup the config file we made when passthrough was enabled
ld_so_conf_path.unlink(missing_ok=True)
return
2024-01-26 21:33:33 +00:00
# Load the nvidia kernel module
if subprocess.run(["modprobe", "nvidia-current-uvm"]).returncode != 0:
eprint(
dedent(
"""
Failed to load nvidia-current-uvm kernel module."""
2024-01-26 21:33:33 +00:00
)
)
2024-01-27 04:44:05 +00:00
# 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
2024-01-26 21:33:33 +00:00
if subprocess.run(["nvidia-smi", "-f", "/dev/null"]).returncode != 0:
2023-03-04 17:31:56 +00:00
eprint("Skip passthrough of nvidia GPU.")
return
2023-03-04 10:36:43 +00:00
2023-03-03 21:33:41 +00:00
try:
# Get list of libraries
nvidia_libraries = set(
[
x
for x in subprocess.check_output(
["nvidia-container-cli", "list", "--libraries"]
)
.decode()
.split("\n")
if x
]
)
# Get full list of files, but excluding library ones from above
2024-01-21 17:21:24 +00:00
nvidia_files = set(
(
[
x
for x in subprocess.check_output(["nvidia-container-cli", "list"])
.decode()
.split("\n")
if x and x not in nvidia_libraries
2024-01-21 17:21:24 +00:00
]
)
)
2024-03-02 22:17:06 +00:00
except Exception:
2024-01-21 17:21:24 +00:00
eprint(
dedent(
"""
2023-03-03 21:33:41 +00:00
Unable to detect which nvidia driver files to mount.
2024-01-21 17:21:24 +00:00
Skip passthrough of nvidia GPU."""
)
)
2023-03-04 17:31:56 +00:00
return
2023-03-03 21:33:41 +00:00
# Also make nvidia-smi available inside the path,
2023-03-04 17:31:56 +00:00
# while mounting the symlink will be resolved and nvidia-smi will appear as a regular file
2024-01-21 17:21:24 +00:00
nvidia_files.add("/usr/bin/nvidia-smi")
2023-03-03 21:33:41 +00:00
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
2024-01-21 17:21:24 +00:00
print(f"Skipped mounting {file_path}, it doesn't exist on the host...")
2023-03-03 21:33:41 +00:00
continue
2024-01-21 17:21:24 +00:00
if file_path.startswith("/dev/"):
2023-03-03 21:33:41 +00:00
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():
library_folders = set(str(Path(x).parent) for x in nvidia_libraries)
# Add the library folders as mounts
for lf in library_folders:
nvidia_mounts.append(f"--bind-ro={lf}")
2023-03-03 21:33:41 +00:00
# Only write if the conf file doesn't yet exist or has different contents
existing_conf_libraries = set()
if ld_so_conf_path.exists():
2023-08-14 14:12:33 +00:00
existing_conf_libraries.update(
2024-01-21 17:21:24 +00:00
x for x in ld_so_conf_path.read_text().splitlines() if x
)
if library_folders != existing_conf_libraries:
2024-01-21 17:21:24 +00:00
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(
2024-01-21 17:21:24 +00:00
[
"systemd-nspawn",
"--quiet",
f"--machine={jail_name}",
2023-08-14 14:12:33 +00:00
f"--directory={jail_rootfs_path}",
*nvidia_mounts,
2024-01-21 17:21:24 +00:00
"ldconfig",
]
)
2023-03-03 21:33:41 +00:00
else:
2024-01-21 17:21:24 +00:00
eprint(
dedent(
"""
2023-03-03 21:33:41 +00:00
Unable to write the ld.so.conf.d directory inside the jail (it doesn't exist).
Skipping call to ldconfig.
2024-01-21 17:21:24 +00:00
The nvidia drivers will probably not be detected..."""
)
)
2023-03-03 21:33:41 +00:00
systemd_nspawn_additional_args += nvidia_mounts
2024-03-01 16:35:05 +00:00
def exec_jail(jail_name, cmd):
2023-08-15 12:45:52 +00:00
"""
Execute a command in the jail with given name.
"""
2024-02-08 00:15:38 +00:00
return subprocess.run(
[
"systemd-run",
"--machine",
jail_name,
"--quiet",
"--pipe",
"--wait",
"--collect",
"--service-type=exec",
2024-03-01 16:35:05 +00:00
*cmd,
2024-02-08 00:15:38 +00:00
]
).returncode
2023-08-15 12:45:52 +00:00
2024-06-28 10:09:16 +00:00
def status_jail(jail_name, args):
2023-08-15 11:49:52 +00:00
"""
2023-08-15 12:45:52 +00:00
Show the status of the systemd service wrapping the jail with given name.
2023-08-15 11:49:52 +00:00
"""
# Alternatively `machinectl status jail_name` could be used
2024-02-08 00:15:38 +00:00
return subprocess.run(
2024-06-28 10:09:16 +00:00
["systemctl", "status", f"{SHORTNAME}-{jail_name}", *args]
2024-02-08 00:15:38 +00:00
).returncode
2023-08-15 11:49:52 +00:00
2024-06-28 10:09:16 +00:00
def log_jail(jail_name, args):
2023-08-15 11:49:52 +00:00
"""
Show the log file of the jail with given name.
"""
2024-06-28 10:09:16 +00:00
return subprocess.run(
["journalctl", "-u", f"{SHORTNAME}-{jail_name}", *args]
).returncode
2023-08-15 11:49:52 +00:00
2024-02-08 00:15:38 +00:00
def shell_jail(args):
2023-08-15 11:21:28 +00:00
"""
Open a shell in the jail with given name.
"""
2024-02-08 00:15:38 +00:00
return subprocess.run(["machinectl", "shell"] + args).returncode
2023-08-15 11:21:28 +00:00
2024-02-16 09:46:51 +00:00
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)
2023-08-15 17:28:43 +00:00
try:
with open(jail_config_path, "r") as fp:
config.read_file(fp)
return config
2023-08-15 17:28:43 +00:00
except FileNotFoundError:
2024-01-21 17:21:24 +00:00
eprint(f"Unable to find config file: {jail_config_path}.")
2023-08-15 17:28:43 +00:00
return
def systemd_escape_path(path):
"""
Escape path containing spaces, while properly handling backslashes in filenames.
https://manpages.debian.org/bookworm/systemd/systemd.syntax.7.en.html#QUOTING
https://manpages.debian.org/bookworm/systemd/systemd.service.5.en.html#COMMAND_LINES
"""
return "".join(
map(
2024-07-09 10:49:13 +00:00
lambda char: r"\s" if char == " " else "\\\\" if char == "\\" else char,
path,
)
)
2024-02-09 15:41:19 +00:00
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}={systemd_escape_path(hook_file)}"
]
2024-02-09 15:41:19 +00:00
2024-02-10 12:21:49 +00:00
def start_jail(jail_name):
"""
Start jail with given name.
"""
2024-01-21 17:21:24 +00:00
skip_start_message = (
f"Skipped starting jail {jail_name}. It appears to be running already..."
)
2023-08-15 11:49:52 +00:00
2024-02-10 12:21:49 +00:00
if jail_is_running(jail_name):
2024-02-08 00:15:38 +00:00
eprint(skip_start_message)
2024-02-10 12:21:49 +00:00
return 0
2023-02-28 19:25:11 +00:00
jail_path = get_jail_path(jail_name)
2023-08-14 14:12:33 +00:00
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)
2023-08-15 17:28:43 +00:00
if not config:
2024-02-08 00:15:38 +00:00
eprint("Aborting...")
return 1
seccomp = config.my_getboolean("seccomp")
systemd_run_additional_args = [
f"--unit={SHORTNAME}-{jail_name}",
2024-07-03 15:27:30 +00:00
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
# Add hooks to execute commands on the host before/after starting and after stopping a jail
2024-02-09 15:41:19 +00:00
add_hook(
jail_path,
systemd_run_additional_args,
config.my_get("pre_start_hook"),
2024-02-09 15:41:19 +00:00
"ExecStartPre",
)
add_hook(
jail_path,
systemd_run_additional_args,
config.my_get("post_start_hook"),
"ExecStartPost",
)
2024-02-09 15:41:19 +00:00
add_hook(
jail_path,
systemd_run_additional_args,
config.my_get("post_stop_hook"),
2024-02-09 15:41:19 +00:00
"ExecStopPost",
)
gpu_passthrough_intel = config.my_getboolean("gpu_passthrough_intel")
gpu_passthrough_nvidia = config.my_getboolean("gpu_passthrough_nvidia")
2023-03-03 21:33:41 +00:00
passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args)
2024-01-21 17:21:24 +00:00
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")
):
2024-06-24 07:41:24 +00:00
# initial_setup has been assigned due to := expression above
# Ensure the jail init system is ready before we start the initial_setup
systemd_nspawn_additional_args += [
"--notify-ready=yes",
]
2024-01-21 17:21:24 +00:00
cmd = [
"systemd-run",
*shlex.split(config.my_get("systemd_run_default_args")),
2024-01-21 17:21:24 +00:00
*systemd_run_additional_args,
"--",
"systemd-nspawn",
*shlex.split(config.my_get("systemd_nspawn_default_args")),
2024-01-21 17:21:24 +00:00
*systemd_nspawn_additional_args,
*shlex.split(config.my_get("systemd_nspawn_user_args")),
2024-01-21 17:21:24 +00:00
]
print(
dedent(
f"""
2023-08-15 17:28:43 +00:00
Starting jail {jail_name} with the following command:
2023-02-27 19:49:25 +00:00
{shlex.join(cmd)}
2024-01-21 17:21:24 +00:00
"""
)
)
2024-02-08 00:15:38 +00:00
returncode = subprocess.run(cmd).returncode
if returncode != 0:
eprint(
2024-01-21 17:21:24 +00:00
dedent(
f"""
2023-08-15 17:28:43 +00:00
Failed to start jail {jail_name}...
In case of a config error, you may fix it with:
{COMMAND_NAME} edit {jail_name}
2024-01-21 17:21:24 +00:00
"""
)
)
return returncode
# Handle initial setup after jail is up and running (for the first time)
if initial_setup:
2024-06-24 07:41:24 +00:00
if not initial_setup.startswith("#!"):
initial_setup = "#!/bin/sh\n" + initial_setup
with tempfile.NamedTemporaryFile(
mode="w+t",
prefix="jlmkr-initial-setup.",
dir=jail_rootfs_path,
delete=False,
) as initial_setup_file:
# Write a script file to call during initial setup
initial_setup_file.write(initial_setup)
initial_setup_file_name = os.path.basename(initial_setup_file.name)
initial_setup_file_host_path = os.path.abspath(initial_setup_file.name)
stat_chmod(initial_setup_file_host_path, 0o700)
print(f"About to run the initial setup script: {initial_setup_file_name}.")
print("Waiting for networking in the jail to be ready.")
2024-06-24 07:41:24 +00:00
print(
"Please wait (this may take 90s in case of bridge networking with STP is enabled)..."
)
returncode = exec_jail(
jail_name,
[
"--",
"systemd-run",
2024-06-24 07:41:24 +00:00
f"--unit={initial_setup_file_name}",
"--quiet",
"--pipe",
"--wait",
"--service-type=exec",
"--property=After=network-online.target",
"--property=Wants=network-online.target",
2024-06-24 07:41:24 +00:00
"/" + initial_setup_file_name,
],
)
if returncode != 0:
eprint("Tried to run the following commands inside the jail:")
eprint(initial_setup)
eprint()
2024-06-24 07:41:24 +00:00
eprint(f"{RED}{BOLD}Failed to run initial setup...")
eprint(
2024-06-24 07:41:24 +00:00
f"You may want to manually run /{initial_setup_file_name} inside the jail for debugging purposes."
)
2024-06-24 07:41:24 +00:00
eprint(f"Or stop and remove the jail and try again.{NORMAL}")
return returncode
else:
2024-06-24 07:41:24 +00:00
# Cleanup the initial_setup_file_host_path
Path(initial_setup_file_host_path).unlink(missing_ok=True)
2024-05-08 20:49:28 +00:00
print(f"Done with initial setup of jail {jail_name}!")
2024-02-08 00:15:38 +00:00
return returncode
2024-02-08 00:57:48 +00:00
def restart_jail(jail_name):
"""
Restart jail with given name.
"""
2024-02-16 09:39:48 +00:00
returncode = stop_jail(jail_name)
2024-02-08 00:57:48 +00:00
if returncode != 0:
eprint("Abort restart.")
return returncode
return start_jail(jail_name)
def cleanup(jail_path):
"""
2024-04-07 09:58:09 +00:00
Cleanup jail.
"""
2024-06-24 12:04:40 +00:00
if get_zfs_dataset(jail_path):
eprint(f"Cleaning up: {jail_path}.")
remove_zfs_dataset(jail_path)
elif os.path.isdir(jail_path):
2024-03-02 22:17:06 +00:00
# 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 issubclass(exc_type, PermissionError):
# Update the file permissions with the immutable and append-only bit cleared
subprocess.run(["chattr", "-i", "-a", path])
# Reattempt the removal
func(path)
elif not issubclass(exc_type, FileNotFoundError):
2024-03-02 22:17:06 +00:00
raise exc_value
eprint(f"Cleaning up: {jail_path}.")
2024-03-02 22:17:06 +00:00
shutil.rmtree(jail_path, onerror=_onerror)
def validate_sha256(file_path, digest):
"""
Validates if a file matches a sha256 digest.
"""
try:
2024-01-21 17:21:24 +00:00
with open(file_path, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
return file_hash == digest
except FileNotFoundError:
return False
2024-01-21 17:21:24 +00:00
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")
2023-02-05 16:22:46 +00:00
# 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)
2023-08-15 11:03:43 +00:00
try:
if os.stat(lxc_download_script).st_uid != 0:
os.remove(lxc_download_script)
except FileNotFoundError:
pass
2023-02-05 16:22:46 +00:00
# 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",
2024-01-21 17:21:24 +00:00
lxc_download_script,
)
2023-02-05 16:22:46 +00:00
if not validate_sha256(lxc_download_script, DOWNLOAD_SCRIPT_DIGEST):
2024-02-08 00:15:38 +00:00
eprint("Abort! Downloaded script has unexpected contents.")
return 1
2023-02-05 16:22:46 +00:00
stat_chmod(lxc_download_script, 0o700)
2023-08-15 15:40:10 +00:00
2023-02-05 16:22:46 +00:00
if None not in [jail_name, jail_path, jail_rootfs_path, distro, release]:
2024-01-21 17:21:24 +00:00
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}",
]
if rc := subprocess.run(cmd, env={"LXC_CACHE_PATH": lxc_cache}).returncode != 0:
eprint("Aborting...")
return rc
2023-02-05 16:22:46 +00:00
else:
# List images
2024-01-21 17:21:24 +00:00
cmd = [lxc_download_script, "--list", f"--arch={arch}"]
2023-08-15 15:39:28 +00:00
p1 = subprocess.Popen(
cmd, stdout=subprocess.PIPE, env={"LXC_CACHE_PATH": lxc_cache}
)
2023-08-15 15:39:28 +00:00
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,
):
# TODO: check if output matches expected output, if it does then return 0
# Else treat this as an error and return 1
print(line)
2024-07-09 10:49:13 +00:00
rc = p1.wait()
# Currently --list will always return a non-zero exit code, even when listing the images was successful
# https://github.com/lxc/lxc/pull/4462
# Therefore we must currently return 0, to prevent aborting the interactive create process
2023-08-15 15:40:10 +00:00
# return rc
2024-02-08 00:15:38 +00:00
return 0
2023-02-05 16:22:46 +00:00
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 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
2024-07-09 10:49:13 +00:00
def get_relative_path_in_jailmaker_dir(absolute_path):
2024-07-03 15:27:30 +00:00
return PurePath(absolute_path).relative_to(SCRIPT_DIR_PATH)
2024-07-09 10:49:13 +00:00
def get_zfs_dataset(path):
"""
Get ZFS dataset path.
"""
2024-05-08 16:59:58 +00:00
def clean_field(field):
# Put back spaces which were encoded
# https://github.com/openzfs/zfs/issues/11182
2024-05-08 16:59:58 +00:00
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
2024-07-03 15:27:30 +00:00
def create_zfs_dataset(absolute_path):
"""
2024-07-03 15:27:30 +00:00
Create a ZFS Dataset inside the jailmaker directory at the provided absolute path.
E.g. "/mnt/mypool/jailmaker/jails" or "/mnt/mypool/jailmaker/jails/newjail").
"""
2024-07-03 15:27:30 +00:00
relative_path = get_relative_path_in_jailmaker_dir(absolute_path)
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)
2024-07-03 15:27:30 +00:00
def remove_zfs_dataset(absolute_path):
"""
2024-07-03 15:27:30 +00:00
Remove a ZFS Dataset inside the jailmaker directory at the provided absolute path.
E.g. "/mnt/mypool/jailmaker/jails/oldjail".
"""
2024-07-03 15:27:30 +00:00
relative_path = get_relative_path_in_jailmaker_dir(absolute_path)
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)
2023-02-28 19:25:11 +00:00
def check_jail_name_valid(jail_name, warn=True):
"""
Return True if jail name matches the required format.
"""
2024-01-21 17:21:24 +00:00
if (
re.match(r"^[.a-zA-Z0-9-]{1,64}$", jail_name)
and not jail_name.startswith(".")
and ".." not in jail_name
):
2023-02-28 19:25:11 +00:00
return True
if warn:
2024-01-21 17:21:24 +00:00
eprint(
dedent(
f"""
2023-02-28 19:25:11 +00:00
{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
2024-01-21 17:21:24 +00:00
- max 64 characters"""
)
)
2023-02-28 19:25:11 +00:00
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 get_text_editor():
def get_from_environ(key):
if editor := os.environ.get(key):
return shutil.which(editor)
2024-05-08 16:59:58 +00:00
return (
get_from_environ("VISUAL")
or get_from_environ("EDITOR")
or shutil.which("editor")
or shutil.which("/usr/bin/editor")
or "nano"
2024-05-08 16:59:58 +00:00
)
2024-03-01 16:35:05 +00:00
def create_jail(**kwargs):
2024-03-07 18:27:43 +00:00
print(DISCLAIMER)
2024-07-03 15:27:30 +00:00
if os.path.basename(SCRIPT_DIR_PATH) != "jailmaker":
2024-03-07 18:27:43 +00:00
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."""
2024-03-07 18:27:43 +00:00
)
)
return 1
2024-07-03 15:27:30 +00:00
if not PurePath(get_mount_point(SCRIPT_DIR_PATH)).is_relative_to("/mnt"):
2024-03-07 18:27:43 +00:00
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")
2024-03-01 16:35:05 +00:00
start_now = False
if not check_jail_name_valid(jail_name):
return 1
if not check_jail_name_available(jail_name):
return 1
2024-03-01 16:35:05 +00:00
start_now = kwargs.pop("start", start_now)
jail_config_path = kwargs.pop("config")
2024-03-01 16:35:05 +00:00
config = KeyValueParser()
2024-03-01 16:35:05 +00:00
if jail_config_path:
# TODO: fallback to default values for e.g. distro and release if they are not in the config file
if jail_config_path == "-":
print(f"Creating jail {jail_name} from config template passed via stdin.")
config.read_string(sys.stdin.read())
2024-03-01 16:35:05 +00:00
else:
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 template {jail_config_path}.")
return 1
2024-03-01 16:35:05 +00:00
else:
print(f"Creating jail {jail_name} with default config.")
config.read_string(DEFAULT_CONFIG)
for option in [
"distro",
"gpu_passthrough_intel",
"gpu_passthrough_nvidia",
"release",
"seccomp",
"startup",
"systemd_nspawn_user_args",
]:
value = kwargs.pop(option)
if (
value is not None
# String, non-empty list of args or int
and (isinstance(value, int) or 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)
2024-03-01 16:35:05 +00:00
jail_path = get_jail_path(jail_name)
2024-01-21 17:21:24 +00:00
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)
2024-04-07 09:58:09 +00:00
2023-08-14 14:12:33 +00:00
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()
2024-07-09 10:49:13 +00:00
if (
returncode := run_lxc_download_script(
jail_name, jail_path, jail_rootfs_path, distro, release
)
!= 0
):
cleanup(jail_path)
2024-02-08 00:15:38 +00:00
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...
2024-06-24 12:04:40 +00:00
with Chroot(jail_rootfs_path):
# Use chroot to correctly resolve absolute /sbin/init symlink
init_system_name = os.path.basename(os.path.realpath("/sbin/init"))
2024-06-24 08:24:33 +00:00
2024-01-21 17:21:24 +00:00
if (
2024-06-24 08:24:33 +00:00
init_system_name != "systemd"
2024-06-24 08:27:33 +00:00
and parse_os_release(jail_rootfs_path).get("ID") != "nixos"
2024-01-21 17:21:24 +00:00
):
print(
dedent(
f"""
{YELLOW}{BOLD}WARNING: DISTRO NOT SUPPORTED{NORMAL}
2023-02-27 19:49:25 +00:00
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
2023-08-15 15:52:25 +00:00
{BOLD}Using this distro with {COMMAND_NAME} is NOT recommended.{NORMAL}
2024-01-21 17:21:24 +00:00
"""
)
)
2024-02-16 12:41:12 +00:00
2024-02-16 10:11:20 +00:00
print("Autostart has been disabled.")
print("You need to start this jail manually.")
config.my_set("startup", 0)
2024-03-01 16:35:05 +00:00
start_now = False
2024-06-23 09:27:48 +00:00
# Remove config which systemd handles for us
with contextlib.suppress(FileNotFoundError):
2024-01-21 17:21:24 +00:00
os.remove(os.path.join(jail_rootfs_path, "etc/machine-id"))
2024-06-23 09:27:48 +00:00
with contextlib.suppress(FileNotFoundError):
2024-01-21 17:21:24 +00:00
os.remove(os.path.join(jail_rootfs_path, "etc/resolv.conf"))
# https://github.com/systemd/systemd/issues/852
2024-01-21 17:21:24 +00:00
print(
"\n".join([f"pts/{i}" for i in range(0, 11)]),
file=open(os.path.join(jail_rootfs_path, "etc/securetty"), "w"),
)
2024-01-21 17:21:24 +00:00
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(
2024-01-21 17:21:24 +00:00
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(
2024-01-21 17:21:24 +00:00
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
2024-01-21 17:21:24 +00:00
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
2024-01-21 17:21:24 +00:00
print(
cleandoc(
"""
[Match]
Virtualization=container
Name=mv-*
[Network]
DHCP=yes
LinkLocalAddressing=ipv6
[DHCPv4]
UseDNS=true
UseTimezone=true
2024-01-21 17:21:24 +00:00
"""
),
file=open(os.path.join(network_dir_path, "mv-dhcp.network"), "w"),
)
# Setup DHCP for veth-extra network interfaces
# This config applies when using the --network-veth-extra 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=vee-*
[Network]
DHCP=yes
LinkLocalAddressing=ipv6
[DHCPv4]
UseDNS=true
UseTimezone=true
"""
),
file=open(os.path.join(network_dir_path, "vee-dhcp.network"), "w"),
)
2024-01-21 17:10:09 +00:00
# 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
2024-01-21 17:21:24 +00:00
preset_path = os.path.join(jail_rootfs_path, "etc/systemd/system-preset")
2024-01-21 17:10:09 +00:00
os.makedirs(preset_path, exist_ok=True)
2024-01-21 17:21:24 +00:00
print(
"enable systemd-networkd.service",
file=open(os.path.join(preset_path, "00-jailmaker.preset"), "w"),
)
2024-01-21 17:10:09 +00:00
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
2024-03-01 16:35:05 +00:00
if start_now:
return start_jail(jail_name)
return 0
2023-08-14 15:31:59 +00:00
def jail_is_running(jail_name):
2024-01-21 17:21:24 +00:00
return (
subprocess.run(
["machinectl", "show", jail_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
2023-08-14 15:31:59 +00:00
2023-08-14 14:12:33 +00:00
def edit_jail(jail_name):
"""
Edit jail with given name.
"""
2024-02-08 00:15:38 +00:00
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)
2024-05-08 16:59:58 +00:00
returncode = subprocess.run([get_text_editor(), jail_config_path]).returncode
2024-02-08 00:15:38 +00:00
if returncode != 0:
eprint(f"An error occurred while editing {jail_config_path}.")
2024-02-08 00:15:38 +00:00
return returncode
if jail_is_running(jail_name):
print("\nRestart the jail for edits to apply (if you made any).")
return 0
2023-08-14 14:12:33 +00:00
2024-02-16 09:39:48 +00:00
def stop_jail(jail_name):
2024-02-08 00:57:48 +00:00
"""
2024-02-16 09:39:48 +00:00
Stop jail with given name and wait until stopped.
2024-02-08 00:57:48 +00:00
"""
if not jail_is_running(jail_name):
return 0
2024-02-16 09:39:48 +00:00
returncode = subprocess.run(["machinectl", "poweroff", jail_name]).returncode
2024-02-08 00:57:48 +00:00
if returncode != 0:
eprint("Error while stopping jail.")
return returncode
print(f"Wait for {jail_name} to stop", end="", flush=True)
2024-02-16 09:39:48 +00:00
2024-02-08 00:57:48 +00:00
while jail_is_running(jail_name):
time.sleep(1)
print(".", end="", flush=True)
return 0
2023-03-10 22:13:23 +00:00
def remove_jail(jail_name):
2023-02-27 19:25:15 +00:00
"""
2023-03-10 22:13:23 +00:00
Remove jail with given name.
2023-02-27 19:25:15 +00:00
"""
2023-02-28 19:25:11 +00:00
2024-02-08 00:15:38 +00:00
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
2024-07-09 02:27:34 +00:00
# TODO: print that all zfs snapshots will be removed if jail has it's own zfs dataset
2024-02-08 00:15:38 +00:00
check = input(f'\nCAUTION: Type "{jail_name}" to confirm jail deletion!\n\n')
if check == jail_name:
print()
2024-02-08 00:15:38 +00:00
jail_path = get_jail_path(jail_name)
2024-02-16 09:39:48 +00:00
returncode = stop_jail(jail_name)
if returncode != 0:
return returncode
2024-02-08 00:15:38 +00:00
print()
cleanup(jail_path)
2024-02-08 00:15:38 +00:00
return 0
else:
eprint("Wrong name, nothing happened.")
return 1
2023-02-27 19:25:15 +00:00
2023-02-28 21:17:20 +00:00
2023-08-15 11:03:43 +00:00
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:
2024-03-02 17:20:25 +00:00
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)))
2023-08-15 11:03:43 +00:00
# Print header
2024-01-21 17:21:24 +00:00
print(
UNDERLINE + " ".join(hdr.upper().ljust(widths[hdr]) for hdr in header) + NORMAL
)
2023-08-15 11:03:43 +00:00
# Print rows
for obj in list_of_objects:
2024-03-02 17:20:25 +00:00
print(" ".join(str(obj.get(hdr)).ljust(widths[hdr]) for hdr in header))
2023-08-15 11:03:43 +00:00
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:
2024-02-08 00:15:38 +00:00
eprint(f"Error parsing JSON: {e}")
2023-08-15 11:03:43 +00:00
return None
2023-08-15 17:28:43 +00:00
def get_all_jail_names():
try:
jail_names = os.listdir(JAILS_DIR_PATH)
except FileNotFoundError:
jail_names = []
return jail_names
2024-06-24 12:04:40 +00:00
def parse_os_release(new_root):
result = {}
2024-06-24 12:04:40 +00:00
with Chroot(new_root):
# Use chroot to correctly resolve os-release symlink (for nixos)
for candidate in ["/etc/os-release", "/usr/lib/os-release"]:
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?
result = platform._parse_os_release(f)
break
except OSError:
# Silently ignore failing to read os release info
pass
return result
def list_jails():
"""
2023-02-28 19:49:44 +00:00
List all available and running jails.
"""
2023-08-15 11:03:43 +00:00
jails = {}
2024-01-21 17:21:24 +00:00
empty_value_indicator = "-"
2023-02-28 19:49:44 +00:00
2023-08-15 17:28:43 +00:00
jail_names = get_all_jail_names()
2023-08-15 11:03:43 +00:00
2023-08-15 17:28:43 +00:00
if not jail_names:
2024-01-21 17:21:24 +00:00
print("No jails.")
2024-02-08 00:15:38 +00:00
return 0
2023-08-15 11:03:43 +00:00
# Get running jails from machinectl
2024-01-21 17:21:24 +00:00
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"
}
2023-08-15 11:03:43 +00:00
2024-02-16 12:41:12 +00:00
for jail_name in jail_names:
jail_rootfs_path = get_jail_rootfs_path(jail_name)
2024-02-16 12:41:12 +00:00
jails[jail_name] = {"name": jail_name, "running": False}
jail = jails[jail_name]
2023-08-15 17:28:43 +00:00
2024-02-16 12:41:12 +00:00
config = parse_config_file(get_jail_config_path(jail_name))
2023-08-15 17:28:43 +00:00
if config:
jail["startup"] = config.my_getboolean("startup")
jail["gpu_intel"] = config.my_getboolean("gpu_passthrough_intel")
jail["gpu_nvidia"] = config.my_getboolean("gpu_passthrough_nvidia")
2024-02-16 12:41:12 +00:00
if jail_name in running_machines:
machine = running_machines[jail_name]
# Augment the jails dict with output from machinectl
jail["running"] = True
2024-03-02 17:20:25 +00:00
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(jail_rootfs_path)
jail["os"] = jail_platform.get("ID")
2024-03-02 17:20:25 +00:00
jail["version"] = jail_platform.get("VERSION_ID") or jail_platform.get(
"VERSION_CODENAME"
)
2023-08-15 17:28:43 +00:00
2024-01-21 17:21:24 +00:00
print_table(
2024-02-16 12:41:12 +00:00
[
"name",
"running",
"startup",
"gpu_intel",
"gpu_nvidia",
"os",
"version",
"addresses",
],
2024-01-21 17:21:24 +00:00
sorted(jails.values(), key=lambda x: x["name"]),
empty_value_indicator,
)
2023-02-27 19:25:15 +00:00
2024-02-08 00:15:38 +00:00
return 0
2023-08-14 14:12:33 +00:00
2023-08-15 17:28:43 +00:00
def startup_jails():
2024-02-10 12:21:49 +00:00
start_failure = False
2023-08-15 17:28:43 +00:00
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"):
2024-02-10 12:21:49 +00:00
if start_jail(jail_name) != 0:
start_failure = True
2024-02-10 12:21:49 +00:00
if start_failure:
return 1
2024-02-08 00:15:38 +00:00
return 0
2023-08-15 17:28:43 +00:00
2024-03-01 16:35:05 +00:00
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["formatter_class"] = argparse.RawDescriptionHelpFormatter
2024-03-01 16:35:05 +00:00
kwargs["exit_on_error"] = False
2024-03-02 16:20:47 +00:00
func = kwargs.pop("func")
2024-03-01 16:35:05 +00:00
parser = subparser.add_parser(**kwargs)
2024-03-02 16:20:47 +00:00
parser.set_defaults(func=func)
2024-03-01 16:35:05 +00:00
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():
2023-08-14 13:19:25 +00:00
if os.stat(SCRIPT_PATH).st_uid != 0:
2023-08-14 14:12:33 +00:00
fail(
2024-01-21 17:21:24 +00:00
f"This script should be owned by the root user... Fix it manually with: `chown root {SCRIPT_PATH}`."
)
2024-03-01 16:35:05 +00:00
parser = argparse.ArgumentParser(
description=__doc__,
allow_abbrev=False,
epilog=f"For more info on some command, run: {COMMAND_NAME} some_command --help.\n{DISCLAIMER}",
formatter_class=argparse.RawDescriptionHelpFormatter,
2024-03-01 16:35:05 +00:00
)
2023-03-01 20:06:04 +00:00
parser.add_argument("--version", action="version", version=__version__)
2024-03-01 16:35:05 +00:00
subparsers = parser.add_subparsers(
title="commands", dest="command", metavar="", parser_class=CustomSubParser
2024-01-21 17:21:24 +00:00
)
2023-08-15 05:31:27 +00:00
split_commands = ["create", "exec", "log", "status"]
2024-03-01 16:35:05 +00:00
commands = {}
for d in [
dict(
name="create", #
help="create a new jail",
2024-03-02 16:20:47 +00:00
func=create_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="edit",
help=f"edit jail config with {get_text_editor()} text editor",
2024-03-02 16:20:47 +00:00
func=edit_jail,
2024-03-01 16:35:05 +00:00
),
dict(
2024-03-02 16:20:47 +00:00
name="exec", #
2024-03-01 16:35:05 +00:00
help="execute a command in the jail",
2024-03-02 16:20:47 +00:00
func=exec_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="images",
help="list available images to create jails from",
2024-03-02 16:20:47 +00:00
func=run_lxc_download_script,
2024-03-01 16:35:05 +00:00
),
dict(
name="list", #
help="list jails",
2024-03-02 16:20:47 +00:00
func=list_jails,
2024-03-01 16:35:05 +00:00
),
dict(
name="log", #
help="show jail log",
2024-03-02 16:20:47 +00:00
func=log_jail,
2024-03-01 16:35:05 +00:00
),
dict(
2024-03-02 16:20:47 +00:00
name="remove", #
2024-03-01 16:35:05 +00:00
help="remove previously created jail",
2024-03-02 16:20:47 +00:00
func=remove_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="restart", #
help="restart a running jail",
2024-03-02 16:20:47 +00:00
func=restart_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="shell",
help="open shell in running jail (alias for machinectl shell)",
2024-03-02 16:20:47 +00:00
func=shell_jail,
2024-03-01 16:35:05 +00:00
add_help=False,
),
dict(
2024-03-02 16:20:47 +00:00
name="start", #
2024-03-01 16:35:05 +00:00
help="start previously created jail",
2024-03-02 16:20:47 +00:00
func=start_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="startup",
help="startup selected jails",
2024-03-02 16:20:47 +00:00
func=startup_jails,
2024-03-01 16:35:05 +00:00
),
dict(
name="status", #
help="show jail status",
2024-03-02 16:20:47 +00:00
func=status_jail,
2024-03-01 16:35:05 +00:00
),
dict(
name="stop", #
help="stop a running jail",
2024-03-02 16:20:47 +00:00
func=stop_jail,
2024-03-01 16:35:05 +00:00
),
]:
commands[d["name"]] = add_parser(subparsers, **d)
for cmd in [
"create",
"edit",
"exec",
"log",
"remove",
"restart",
"start",
"status",
"stop",
]:
2024-03-02 16:20:47 +00:00
commands[cmd].add_argument("jail_name", help="name of the jail")
2024-03-01 16:35:05 +00:00
2024-03-02 16:20:47 +00:00
commands["exec"].add_argument(
"cmd",
nargs="*",
help="command to execute",
)
commands["shell"].add_argument(
"args",
nargs="*",
help="args to pass to machinectl shell",
)
2024-06-28 10:09:16 +00:00
commands["log"].add_argument(
"args",
nargs="*",
help="args to pass to journalctl",
)
commands["status"].add_argument(
"args",
nargs="*",
help="args to pass to systemctl",
)
2024-03-01 16:35:05 +00:00
commands["create"].add_argument("--distro")
commands["create"].add_argument("--release")
2024-03-02 16:34:55 +00:00
commands["create"].add_argument(
"--start", #
help="start jail after create",
action="store_true",
)
2024-03-01 16:35:05 +00:00
commands["create"].add_argument(
"--startup",
type=int,
choices=[0, 1],
help=f"start this jail when running: {SCRIPT_NAME} startup",
)
commands["create"].add_argument(
"--seccomp", #
type=int,
choices=[0, 1],
help="turning off seccomp filtering improves performance at the expense of security",
2024-03-01 16:35:05 +00:00
)
commands["create"].add_argument(
2024-03-02 16:20:47 +00:00
"-c", #
"--config",
help="path to config file template or - for stdin",
2024-03-01 16:35:05 +00:00
)
commands["create"].add_argument(
2024-03-02 16:20:47 +00:00
"-gi", #
"--gpu_passthrough_intel",
type=int,
choices=[0, 1],
)
commands["create"].add_argument(
"-gn", #
"--gpu_passthrough_nvidia",
type=int,
choices=[0, 1],
2024-03-01 16:35:05 +00:00
)
commands["create"].add_argument(
"systemd_nspawn_user_args",
nargs="*",
help="add additional systemd-nspawn flags",
)
2023-08-15 17:28:43 +00:00
if os.getuid() != 0:
2024-03-01 16:35:05 +00:00
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
2023-08-15 06:07:19 +00:00
stat_chmod(SCRIPT_PATH, 0o700)
2023-02-05 16:22:46 +00:00
2024-03-01 16:35:05 +00:00
# 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")
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)
parser.print_help()
sys.exit()
2024-03-01 16:35:05 +00:00
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))
2024-01-21 17:21:24 +00:00
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)