jailmaker/jlmkr.py

1176 lines
41 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import argparse
import configparser
import contextlib
import ctypes
2023-08-12 16:14:09 +00:00
import glob
import hashlib
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 readline
import shlex
import shutil
import stat
import subprocess
import sys
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
# Only set a color if we have an interactive tty
if sys.stdout.isatty():
BOLD = '\033[1m'
RED = '\033[91m'
YELLOW = '\033[93m'
2023-08-15 11:03:43 +00:00
UNDERLINE = '\033[4m'
NORMAL = '\033[0m'
else:
2023-08-15 11:03:43 +00:00
BOLD = RED = YELLOW = UNDERLINE = NORMAL = ''
DISCLAIMER = f"""{YELLOW}{BOLD}USE THIS SCRIPT AT YOUR OWN RISK!
IT COMES WITHOUT WARRANTY AND IS NOT SUPPORTED BY IXSYSTEMS.{NORMAL}"""
DESCRIPTION = "Create persistent Linux 'jails' on TrueNAS SCALE, with full access to all files \
2023-08-12 16:14:09 +00:00
via bind mounts, thanks to systemd-nspawn!"
2023-11-18 17:20:07 +00:00
VERSION = '1.0.1'
JAILS_DIR_PATH = 'jails'
JAIL_CONFIG_NAME = 'config'
JAIL_ROOTFS_NAME = 'rootfs'
DOWNLOAD_SCRIPT_DIGEST = '6cca2eda73c7358c232fecb4e750b3bf0afa9636efb5de6a9517b7df78be12a4'
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__)
2023-08-15 05:41:54 +00:00
SYMLINK_NAME = 'jlmkr'
TEXT_EDITOR = 'nano'
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)
2023-03-03 21:33:41 +00:00
def passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args):
if gpu_passthrough_intel != '1':
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')
2023-08-14 14:12:33 +00:00
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),
2023-08-15 05:41:54 +00:00
f'etc/ld.so.conf.d/{SYMLINK_NAME}-nvidia.conf')
2023-03-03 21:33:41 +00:00
if gpu_passthrough_nvidia != '1':
# Cleanup the config file we made when passthrough was enabled
ld_so_conf_path.unlink(missing_ok=True)
2023-03-03 21:33:41 +00:00
return
2023-03-04 17:31:56 +00:00
try:
# 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
subprocess.run(['nvidia-smi', '-f', '/dev/null'], check=True)
except:
eprint("Skip passthrough of nvidia GPU.")
return
2023-03-04 10:36:43 +00:00
2023-03-03 21:33:41 +00:00
try:
2023-03-04 17:31:56 +00:00
nvidia_files = set(([x for x in subprocess.check_output(
['nvidia-container-cli', 'list']).decode().split('\n') if x]))
2023-03-03 21:33:41 +00:00
except:
eprint(dedent("""
Unable to detect which nvidia driver files to mount.
2023-03-04 17:31:56 +00:00
Skip passthrough of nvidia GPU."""))
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
2023-03-03 21:33:41 +00:00
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
2023-08-14 14:12:33 +00:00
print(
f"Skipped mounting {file_path}, it doesn't exist on the host...")
2023-03-03 21:33:41 +00:00
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)
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(
x for x in ld_so_conf_path.read_text().splitlines() if x)
if library_folders != existing_conf_libraries:
2023-08-14 14:12:33 +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(
['systemd-nspawn',
'--quiet',
f"--machine={jail_name}",
2023-08-14 14:12:33 +00:00
f"--directory={jail_rootfs_path}",
*nvidia_mounts,
"ldconfig"])
2023-03-03 21:33:41 +00:00
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
2023-08-15 12:45:52 +00:00
def exec_jail(jail_name, cmd, args):
"""
Execute a command in the jail with given name.
"""
subprocess.run(['systemd-run', '--machine', jail_name, '--quiet', '--pipe',
'--wait', '--collect', '--service-type=exec', cmd] + args, check=True)
2023-08-15 11:49:52 +00:00
def status_jail(jail_name):
"""
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
subprocess.run(["systemctl", "status", f"{SYMLINK_NAME}-{jail_name}"])
def log_jail(jail_name):
"""
Show the log file of the jail with given name.
"""
subprocess.run(["journalctl", "-u", f"{SYMLINK_NAME}-{jail_name}"])
2023-08-15 11:21:28 +00:00
def shell_jail(jail_name):
"""
Open a shell in the jail with given name.
"""
subprocess.run(["machinectl", "shell", jail_name])
2023-08-15 11:13:51 +00:00
def stop_jail(jail_name):
"""
Stop jail with given name.
"""
subprocess.run(["machinectl", "poweroff", jail_name])
2023-08-15 17:28:43 +00:00
def parse_config(jail_config_path):
config = configparser.ConfigParser()
try:
# Workaround to read config file without section headers
config.read_string('[DEFAULT]\n'+Path(jail_config_path).read_text())
except FileNotFoundError:
eprint(f'Unable to find config file: {jail_config_path}.')
return
config = dict(config['DEFAULT'])
return config
def start_jail(jail_name, check_startup_enabled=False):
"""
Start jail with given name.
"""
2023-08-15 17:28:43 +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
2023-08-15 17:28:43 +00:00
if not check_startup_enabled and jail_is_running(jail_name):
fail(skip_start_message)
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)
2023-08-15 17:28:43 +00:00
config = parse_config(jail_config_path)
2023-08-15 17:28:43 +00:00
if not config:
2024-01-20 15:47:51 +00:00
fail("Aborting...")
2023-08-15 17:28:43 +00:00
# Only start if the startup setting is enabled in the config
if check_startup_enabled:
if config.get('startup') == '1':
# We should start this jail based on the startup config...
if jail_is_running(jail_name):
# ...but we can skip if it's already running
eprint(skip_start_message)
return
else:
# Skip starting this jail since the startup config setting isnot enabled
return
systemd_run_additional_args = [
2023-08-15 05:41:54 +00:00
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}",
]
if config.get('docker_compatible') == '1':
# 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."""))
# To properly run docker inside the jail, we need to lift restrictions
# Without DevicePolicy=auto images with device nodes may not be pulled
# For example docker pull ljishen/sysbench would fail
# Fortunately I didn't encounter many images with device nodes...
#
# Issue: https://github.com/moby/moby/issues/35245
#
# The systemd-nspawn manual explicitly mentions:
# Device nodes may not be created
# https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html
#
# Workaround: https://github.com/kinvolk/kube-spawn/pull/328
#
# However, it seems like the DeviceAllow= workaround may break in
# a future Debian release with systemd version 250 or higher
# https://github.com/systemd/systemd/issues/21987
#
# As of 29-1-2023 it still works with debian bookworm (nightly) and sid
# using the latest systemd version 252.4-2 so I think we're good!
#
# Use SYSTEMD_SECCOMP=0: https://github.com/systemd/systemd/issues/18370
systemd_run_additional_args += [
'--setenv=SYSTEMD_SECCOMP=0',
'--property=DevicePolicy=auto',
]
# Add additional flags required for docker
systemd_nspawn_additional_args += [
'--capability=all',
'--system-call-filter=add_key keyctl bpf',
]
# Legacy gpu_passthrough config setting
if config.get('gpu_passthrough') == '1':
gpu_passthrough_intel = '1'
gpu_passthrough_nvidia = '1'
2023-03-02 17:23:34 +00:00
else:
gpu_passthrough_intel = config.get('gpu_passthrough_intel')
gpu_passthrough_nvidia = config.get('gpu_passthrough_nvidia')
if gpu_passthrough_intel == '1' or gpu_passthrough_nvidia == '1':
systemd_nspawn_additional_args.append(
'--property=DeviceAllow=char-drm rw')
2023-03-03 21:33:41 +00:00
passthrough_intel(gpu_passthrough_intel, systemd_nspawn_additional_args)
passthrough_nvidia(gpu_passthrough_nvidia,
2023-08-14 14:12:33 +00:00
systemd_nspawn_additional_args, jail_name)
cmd = ['systemd-run',
*shlex.split(config.get('systemd_run_default_args', '')),
*systemd_run_additional_args,
'--',
'systemd-nspawn',
*shlex.split(config.get('systemd_nspawn_default_args', '')),
*systemd_nspawn_additional_args,
*shlex.split(config.get('systemd_nspawn_user_args', ''))
]
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)}
"""))
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError:
fail(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:
2023-08-15 05:41:54 +00:00
{SYMLINK_NAME} edit {jail_name}
"""))
def cleanup(jail_path):
"""
Cleanup after aborted jail creation.
"""
if os.path.isdir(jail_path):
eprint(f"Cleaning up: {jail_path}")
shutil.rmtree(jail_path)
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
2023-02-05 16:22:46 +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')
# 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/58520263041b6864cadad96278848f9b8ce78ee9/templates/lxc-download.in", lxc_download_script)
if not validate_sha256(lxc_download_script, DOWNLOAD_SCRIPT_DIGEST):
fail("Abort! Downloaded script has unexpected contents.")
stat_chmod(lxc_download_script, 0o700)
2023-08-15 15:40:10 +00:00
2023-08-15 15:39:28 +00:00
check_exit_code = False
2023-02-05 16:22:46 +00:00
if None not in [jail_name, jail_path, jail_rootfs_path, distro, release]:
2023-08-15 15:39:28 +00:00
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}']
2023-02-05 16:22:46 +00:00
else:
2023-08-15 15:39:28 +00:00
cmd = [lxc_download_script, '--list', f'--arch={arch}']
2023-08-15 15:40:10 +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()
2023-08-15 15:40:10 +00:00
# Filter out the known incompatible distros
2023-08-15 15:39:28 +00:00
if not re.match(r"^(alpine|amazonlinux|busybox|devuan|funtoo|openwrt|plamo|voidlinux)\s", line):
print(line)
p1.wait()
2023-08-15 15:40:10 +00:00
2023-08-15 15:39:28 +00:00
if check_exit_code and p1.returncode != 0:
fail("Aborting...")
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 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
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.
"""
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
2023-10-28 08:52:31 +00:00
def create_jail(jail_name, distro='debian', release='bookworm'):
"""
Create jail with given name.
"""
print(DISCLAIMER)
if os.path.basename(os.getcwd()) != 'jailmaker':
fail(dedent(f"""
2023-08-15 15:52:25 +00:00
{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 directory called 'jailmaker', store {SCRIPT_NAME} there and try again."""))
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.
If you continue, jails will be stored under:
{SCRIPT_DIR_PATH}
"""))
if not agree("Do you wish to ignore this warning and continue?", 'n'):
fail("Aborting...")
# Create the dir where to store the jails
os.makedirs(JAILS_DIR_PATH, exist_ok=True)
stat_chmod(JAILS_DIR_PATH, 0o700)
print()
2023-08-15 06:07:36 +00:00
if not agree(f"Install the recommended image ({distro} {release})?", 'y'):
print(dedent(f"""
{YELLOW}{BOLD}WARNING: ADVANCED USAGE{NORMAL}
You may now choose from a list which distro to install.
2023-08-15 15:52:25 +00:00
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()
2023-02-05 16:22:46 +00:00
run_lxc_download_script()
print(dedent("""
Choose from the DIST column.
"""))
distro = input("Distro: ")
print(dedent("""
Choose from the RELEASE column (or ARCH if RELEASE is empty).
"""))
release = input("Release: ")
2023-02-28 19:25:11 +00:00
while True:
print()
jail_name = input_with_default("Enter jail name: ", jail_name).strip()
2023-02-28 19:25:11 +00:00
if check_jail_name_valid(jail_name):
if check_jail_name_available(jail_name):
break
2023-02-28 19:25:11 +00:00
jail_path = get_jail_path(jail_name)
# Cleanup in except, but only once the jail_path is final
# Otherwise we may cleanup the wrong directory
try:
print(dedent(f"""
2023-08-15 15:52:25 +00:00
Docker won't be installed by {COMMAND_NAME}.
But it can setup the jail with the capabilities required to run docker.
You can turn DOCKER_COMPATIBLE mode on/off post-install.
"""))
docker_compatible = 0
if agree('Make jail docker compatible right now?', 'n'):
docker_compatible = 1
print()
gpu_passthrough_intel = 0
if os.path.exists('/dev/dri'):
2023-03-04 17:31:56 +00:00
print("Detected the presence of an intel GPU.\n")
if agree('Passthrough the intel GPU?', 'n'):
gpu_passthrough_intel = 1
gpu_passthrough_nvidia = 0
2023-03-04 10:36:43 +00:00
try:
2023-03-04 17:31:56 +00:00
subprocess.run(['nvidia-smi'], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
2023-03-04 10:36:43 +00:00
nvidia_detected = True
except:
nvidia_detected = False
if nvidia_detected:
2023-03-04 17:31:56 +00:00
print("Detected the presence of an nvidia GPU.\n")
if agree('Passthrough the nvidia GPU?', 'n'):
gpu_passthrough_nvidia = 1
print(dedent(f"""
{YELLOW}{BOLD}WARNING: CHECK SYNTAX{NORMAL}
2023-02-27 19:49:25 +00:00
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'):
2023-08-15 12:01:05 +00:00
subprocess.run(['man', 'systemd-nspawn'])
else:
2023-08-24 08:03:48 +00:00
try:
base_os_version = platform.freedesktop_os_release().get('VERSION_CODENAME', release)
except AttributeError:
base_os_version = release
print(dedent(f"""
You may read the systemd-nspawn manual online:
2023-08-15 06:07:36 +00:00
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 for static IP, 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
systemd_nspawn_user_args = input("Additional flags: ") or ""
# Disable tab auto completion
readline.parse_and_bind('tab: self-insert')
2023-08-15 17:28:43 +00:00
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).
"""))
startup = int(agree(
f"Do you want to start this jail when running: {COMMAND_NAME} startup?", 'n'))
print()
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()
2023-02-05 16:22:46 +00:00
run_lxc_download_script(jail_name, jail_path,
jail_rootfs_path, distro, release)
# 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}
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}
"""))
if agree("Abort creating jail?", 'y'):
exit(1)
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"))
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
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"))
# 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
systemd_run_default_args = [
'--property=KillMode=mixed',
'--property=Type=notify',
'--property=RestartForceExitStatus=133',
'--property=SuccessExitStatus=133',
'--property=Delegate=yes',
'--property=TasksMax=infinity',
'--collect',
'--setenv=SYSTEMD_NSPAWN_LOCK=0'
]
systemd_nspawn_default_args = [
'--keep-unit',
'--quiet',
'--boot'
]
config = cleandoc(f"""
2023-08-15 17:28:43 +00:00
startup={startup}
docker_compatible={docker_compatible}
gpu_passthrough_intel={gpu_passthrough_intel}
gpu_passthrough_nvidia={gpu_passthrough_nvidia}
systemd_nspawn_user_args={systemd_nspawn_user_args}
# You generally will not need to change the options below
systemd_run_default_args={' '.join(systemd_run_default_args)}
systemd_nspawn_default_args={' '.join(systemd_nspawn_default_args)}
""")
2023-08-15 15:52:25 +00:00
print(config, file=open(jail_config_path, 'w'))
os.chmod(jail_config_path, 0o600)
# Cleanup on any exception and rethrow
except BaseException as error:
cleanup(jail_path)
raise error
print()
2023-08-15 17:28:43 +00:00
if agree(f"Do you want to start jail {jail_name} right now?", 'y'):
start_jail(jail_name)
2023-08-14 15:31:59 +00:00
def jail_is_running(jail_name):
return subprocess.run(["machinectl", "show", jail_name], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0
2023-08-14 14:12:33 +00:00
def edit_jail(jail_name):
"""
Edit jail with given name.
"""
if check_jail_name_valid(jail_name):
if check_jail_name_available(jail_name, False):
eprint(f"A jail with name {jail_name} does not exist.")
else:
2023-08-15 05:41:54 +00:00
jail_config_path = get_jail_config_path(jail_name)
if not shutil.which(TEXT_EDITOR):
2023-08-15 11:34:37 +00:00
eprint(f"Unable to edit config file: {jail_config_path}.")
eprint(f"The {TEXT_EDITOR} text editor is not available.")
2023-08-15 05:41:54 +00:00
else:
2023-08-15 12:01:05 +00:00
subprocess.run([TEXT_EDITOR, get_jail_config_path(jail_name)])
2023-08-15 05:41:54 +00:00
if jail_is_running(jail_name):
print("\nRestart the jail for edits to apply (if you made any).")
2023-08-14 14:12:33 +00:00
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
if check_jail_name_valid(jail_name):
if check_jail_name_available(jail_name, False):
2023-02-27 21:01:36 +00:00
eprint(f"A jail with name {jail_name} does not exist.")
2023-02-28 19:25:11 +00:00
else:
2023-02-28 21:17:20 +00:00
check = input(
f'\nCAUTION: Type "{jail_name}" to confirm jail deletion!\n\n') or ""
2023-02-28 19:25:11 +00:00
if check == jail_name:
jail_path = get_jail_path(jail_name)
2023-08-14 15:31:59 +00:00
if jail_is_running(jail_name):
print(f"\nWait for {jail_name} to stop...", end="")
2023-08-15 11:13:51 +00:00
stop_jail(jail_name)
2023-08-14 15:31:59 +00:00
# Need to sleep since deleting immediately after stop causes problems...
while jail_is_running(jail_name):
time.sleep(1)
print(".", end="", flush=True)
print(f"\nCleaning up: {jail_path}")
2023-02-28 19:25:11 +00:00
shutil.rmtree(jail_path)
else:
eprint("Wrong name, nothing happened.")
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:
widths[hdr] = max(widths[hdr], len(
str(obj.get(hdr))), 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, empty_value_indicator)).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:
print(f"Error parsing JSON: {e}")
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
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 = {}
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:
2023-08-15 11:03:43 +00:00
print('No jails.')
return
2023-08-15 17:28:43 +00:00
for jail in jail_names:
2023-08-15 11:03:43 +00:00
jails[jail] = {"name": jail, "running": False}
# Get running jails from machinectl
running_machines = run_command_and_parse_json(
['machinectl', 'list', '-o', 'json'])
# Augment the jails dict with output from machinectl
for machine in running_machines:
machine_name = machine['machine']
# We're only interested in the list of jails made with jailmaker
if machine['service'] == 'systemd-nspawn' and machine_name in jails:
addresses = (machine.get('addresses')
or empty_value_indicator).split('\n')
if len(addresses) > 1:
addresses = addresses[0] + ''
else:
addresses = addresses[0]
jails[machine_name] = {
"name": machine_name,
"running": True,
"os": machine['os'],
"version": machine['version'],
"addresses": addresses
}
# TODO: add additional properties from the jails config file
2023-02-28 19:49:44 +00:00
2023-08-15 17:28:43 +00:00
for jail_name in jails:
config = parse_config(get_jail_config_path(jail_name))
startup = False
if config:
startup = bool(int(config.get('startup', '0')))
# TODO: in case config is missing or parsing fails,
# should an error message be thrown here?
jails[jail_name]['startup'] = startup
print_table(["name", "running", "startup", "os", "version", "addresses"],
2023-08-15 11:03:43 +00:00
sorted(jails.values(), key=lambda x: x['name']), empty_value_indicator)
2023-02-27 19:25:15 +00:00
2023-08-14 14:12:33 +00:00
2023-08-14 13:42:20 +00:00
def install_jailmaker():
2023-08-12 16:14:09 +00:00
# Check if command exists in path
if shutil.which('systemd-nspawn'):
print("systemd-nspawn is already installed.")
2023-08-14 14:12:33 +00:00
else:
2023-08-14 13:42:20 +00:00
print("Installing jailmaker dependencies...")
original_permissions = {}
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
print("Temporarily enable apt and dpkg (if not already enabled) to install systemd-nspawn.")
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
# 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)
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
subprocess.run(['apt-get', 'update'], check=True)
2023-08-14 14:12:33 +00:00
subprocess.run(['apt-get', 'install', '-y',
'systemd-container'], check=True)
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
# Restore original permissions
print("Restore permissions of apt and dpkg.")
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
for file, original_permission in original_permissions.items():
stat_chmod(file, original_permission)
2023-08-14 14:12:33 +00:00
2023-08-15 05:41:54 +00:00
target = f'/usr/local/sbin/{SYMLINK_NAME}'
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
# Check if command exists in path
2023-08-15 05:41:54 +00:00
if shutil.which(SYMLINK_NAME):
print(f"The {SYMLINK_NAME} command is available.")
2023-08-14 13:42:20 +00:00
elif not os.path.lexists(target):
print(f"Creating symlink {target} to {SCRIPT_PATH}.")
os.symlink(SCRIPT_PATH, target)
else:
2023-08-14 14:12:33 +00:00
print(
f"File {target} already exists... Maybe it's a broken symlink from a previous install attempt?")
2023-08-14 13:42:20 +00:00
print(f"Skipped creating new symlink {target} to {SCRIPT_PATH}.")
2023-08-12 16:14:09 +00:00
2023-08-14 13:42:20 +00:00
print("Done installing jailmaker.")
2023-02-28 21:17:20 +00:00
2023-08-14 14:12:33 +00:00
2023-08-15 17:28:43 +00:00
def startup_jails():
install_jailmaker()
for jail_name in get_all_jail_names():
start_jail(jail_name, True)
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(
f"This script should be owned by the root user... Fix it manually with: `chown root {SCRIPT_PATH}`.")
parser = argparse.ArgumentParser(
description=DESCRIPTION, epilog=DISCLAIMER)
2023-03-01 20:06:04 +00:00
parser.add_argument('--version', action='version', version=VERSION)
2023-02-05 16:22:46 +00:00
subparsers = parser.add_subparsers(
title='commands', dest='subcommand', metavar="")
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='install', epilog=DISCLAIMER,
help='install jailmaker dependencies and create symlink')
subparsers.add_parser(name='create', epilog=DISCLAIMER,
help='create a new jail').add_argument(
'name', nargs='?', help='name of the jail')
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='start', epilog=DISCLAIMER,
help='start a previously created jail').add_argument(
'name', help='name of the jail')
2023-02-27 19:25:15 +00:00
2023-08-15 11:21:28 +00:00
subparsers.add_parser(name='shell', epilog=DISCLAIMER,
help='open shell in running jail').add_argument(
'name', help='name of the jail')
2023-08-15 12:45:52 +00:00
exec_parser = subparsers.add_parser(name='exec', epilog=DISCLAIMER,
help='execute a command in the jail')
exec_parser.add_argument('name', help='name of the jail')
exec_parser.add_argument('cmd', help='command to execute')
2023-08-15 11:49:52 +00:00
subparsers.add_parser(name='status', epilog=DISCLAIMER,
help='show jail status').add_argument(
'name', help='name of the jail')
subparsers.add_parser(name='log', epilog=DISCLAIMER,
help='show jail log').add_argument(
'name', help='name of the jail')
2023-08-15 11:13:51 +00:00
subparsers.add_parser(name='stop', epilog=DISCLAIMER,
help='stop a running jail').add_argument(
'name', help='name of the jail')
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='edit', epilog=DISCLAIMER,
2023-08-15 05:41:54 +00:00
help=f'edit jail config with {TEXT_EDITOR} text editor').add_argument(
2023-08-14 14:12:33 +00:00
'name', help='name of the jail to edit')
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='remove', epilog=DISCLAIMER,
help='remove a previously created jail').add_argument(
2023-03-10 22:13:23 +00:00
'name', help='name of the jail to remove')
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='list', epilog=DISCLAIMER,
2023-08-15 11:13:51 +00:00
help='list jails')
2023-02-05 16:22:46 +00:00
2023-08-15 05:31:27 +00:00
subparsers.add_parser(name='images', epilog=DISCLAIMER,
help='list available images to create jails from')
2023-08-15 17:28:43 +00:00
subparsers.add_parser(name='startup', epilog=DISCLAIMER,
help=f'install {SYMLINK_NAME} and startup selected jails')
if os.getuid() != 0:
parser.print_usage()
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
# Work relative to this script
os.chdir(SCRIPT_DIR_PATH)
2023-08-15 12:45:52 +00:00
args, additional_args = parser.parse_known_args()
2023-08-15 17:28:43 +00:00
if args.subcommand == 'install':
install_jailmaker()
elif args.subcommand == 'create':
create_jail(args.name)
elif args.subcommand == 'start':
2023-08-15 11:13:51 +00:00
start_jail(args.name)
2023-08-15 11:21:28 +00:00
elif args.subcommand == 'shell':
shell_jail(args.name)
2023-08-15 12:45:52 +00:00
elif args.subcommand == 'exec':
exec_jail(args.name, args.cmd, additional_args)
2023-08-15 11:49:52 +00:00
elif args.subcommand == 'status':
status_jail(args.name)
elif args.subcommand == 'log':
log_jail(args.name)
2023-08-15 11:21:28 +00:00
elif args.subcommand == 'stop':
2023-08-15 11:13:51 +00:00
stop_jail(args.name)
2023-08-14 14:12:33 +00:00
elif args.subcommand == 'edit':
edit_jail(args.name)
2023-03-10 22:13:23 +00:00
elif args.subcommand == 'remove':
remove_jail(args.name)
2023-02-27 19:25:15 +00:00
elif args.subcommand == 'list':
list_jails()
2023-02-05 16:22:46 +00:00
elif args.subcommand == 'images':
run_lxc_download_script()
2023-08-15 17:28:43 +00:00
elif args.subcommand == 'startup':
startup_jails()
else:
if agree("Create a new jail?", 'y'):
print()
create_jail("")
else:
parser.print_usage()
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
sys.exit(130)