├── .gitignore ├── LICENSE ├── README.md ├── nspawn2go.py └── screenshot.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | /.idea 4 | /.vscode 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Alexei Boronine 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nspawn2go 2 | 3 | ![screenshot](https://raw.githubusercontent.com/boronine/nspawn2go/master/screenshot.jpg) 4 | 5 | ## What is it? 6 | 7 | An interactive script to provision lightweight Debian VMs. Access your tiny VMs via 8 | SSH or VNC. 9 | 10 | ``` 11 | sudo python3 nspawn2go.py 12 | ``` 13 | 14 | - Username: `debian` 15 | - Password: `debian` (default) 16 | - SSH server port: `2022` (default) 17 | - VNC port: `5901` (default) 18 | - VNC geometry: `1280x720` (default) 19 | - Try `800x480` for a minimal desktop 20 | - Graphical environments: `icewm` or `xfce4` 21 | - Root directory: `/var/lib/machines/VMNAME` 22 | 23 | ## What is it for? 24 | 25 | - Run a tiny graphical desktop: 26 | - on a dirt-cheap 512mb VPS 27 | - on a Raspberry Pi in your home network 28 | - access from any desktop or even from your phone via VNC 29 | - Containerize your servers: 30 | - Isolate your server configuration from your host 31 | - All-systemd, no need for third-party tools like Docker 32 | - Portability: 33 | - Transfer your VM simply by copying the root directory 34 | - Host can be any systemd distro 35 | - Host can be bare metal, KVM, VirtualBox, QEMU etc. 36 | 37 | ## How does it work? 38 | 39 | Modern systemd-based Linux hosts come equipped with a lightweight container system called 40 | [nspawn](https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html). For most 41 | practical purposes, these containers are lightweight, portable VMs. 42 | 43 | Instead of a disk image, these containers boot into a directory on your host: 44 | `/var/lib/machines/VMNAME`. That's why systemd-nspawn is known as "chroot on steroids". 45 | 46 | Like Docker, you can use these containers to run ad-hoc commands but what makes this most 47 | interesting is when you run systemd inside the container to bring up an isolated Linux 48 | system. 49 | 50 | You can manage these VMs using [machinectl](https://www.freedesktop.org/software/systemd/man/machinectl.html) 51 | (start, stop, reboot, enable, disable). 52 | 53 | ## Instructions 54 | 55 | Host dependencies: 56 | 57 | - python3 58 | - systemd-container (provides `machinectl`) 59 | - debootstrap 60 | 61 | On Debian/Ubuntu hosts: 62 | 63 | ``` 64 | apt-get install python3 systemd-container debootstrap 65 | ``` 66 | 67 | On Arch hosts: 68 | 69 | ``` 70 | pacman -S python3 debootstrap 71 | ``` 72 | 73 | Download and run nspawn2go: 74 | 75 | ``` 76 | wget https://raw.githubusercontent.com/boronine/nspawn2go/master/nspawn2go.py 77 | sudo python3 nspawn2go.py 78 | ``` 79 | 80 | You can automate this script using environment variables. See the printed variables 81 | as you go through the interactive mode. 82 | 83 | ## Cleanup 84 | 85 | If you wish to delete your unused containers you can do it safely like so: 86 | 87 | ``` 88 | machinectl stop $VMNAME 89 | rm -rf /var/lib/machines/$VMNAME 90 | rm /etc/systemd/nspawn/$VMNAME.nspawn 91 | ``` 92 | 93 | ## Caveats 94 | 95 | This script disables the [--private-users=](https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html#--private-users=) 96 | security feature of systemd-nspawn. This feature maps container UIDs and GIDs to a private 97 | set of UIDs and GIDs on the host. We disable it because it makes working with container files 98 | from the host a hassle. 99 | 100 | This script does not take advantage of systemd-nspawn's [--private-network=](https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html#--private-network) 101 | feature, instead containers share the host network. Pick unique ports for your services if you 102 | plan on running multiple instances. 103 | -------------------------------------------------------------------------------- /nspawn2go.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Copyright 2021 Alexei Boronine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and 8 | to permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of 11 | the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 14 | THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 16 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | 19 | Interactive mode: python3 nspawn2go.py 20 | Sample automated usage: VMNAME=vm1 VMGRAPHICS=1 VMDISPLAY=5 VMDESKTOP=icewm python3 nspawn2go.py 21 | """ 22 | 23 | import os 24 | import re 25 | import shutil 26 | import subprocess 27 | import sys 28 | from pathlib import Path 29 | 30 | 31 | def print_blue(s: str): 32 | print(f'\033[94m{s}\033[0m') 33 | 34 | 35 | def print_cyan(s: str): 36 | print(f'\033[96m{s}\033[0m') 37 | 38 | 39 | def print_green(s: str): 40 | print(f'\033[92m{s}\033[0m') 41 | 42 | 43 | def print_red(s: str): 44 | print(f'\033[91m{s}\033[0m') 45 | 46 | 47 | def parse_boolean(s: str): 48 | s = s.lower() 49 | if s in ('y', 'yes', 't', 'true', '1'): 50 | return True 51 | elif s in ('n', 'no', 'f', 'false', '0'): 52 | return False 53 | 54 | 55 | def parse_integer(s: str): 56 | try: 57 | return int(s) 58 | except ValueError: 59 | return None 60 | 61 | 62 | def param(env: str, prompt: str, default, boolean=False, integer=False, choices=None): 63 | t = os.environ.get(env, '').strip() 64 | if t != '': 65 | return t 66 | if boolean: 67 | s = 'Yn' if default else 'yN' 68 | print_blue(f'{prompt} [{s}]') 69 | elif choices: 70 | c = ', '.join(choices) 71 | print_blue(f'{prompt} (choices: {c}, default: {default})') 72 | else: 73 | print_blue(f'{prompt} (default: {default})') 74 | while True: 75 | t = input(f'{env}=').strip() 76 | if boolean: 77 | b = default if t == '' else parse_boolean(t) 78 | if b is None: 79 | print_red("Invalid boolean") 80 | continue 81 | print_green(f'{env}={int(b)}') 82 | return b 83 | elif integer: 84 | i = default if t == '' else parse_integer(t) 85 | if i is None: 86 | print_red("Invalid integer") 87 | continue 88 | print_green(f'{env}={i}') 89 | return i 90 | elif choices: 91 | t = t or default 92 | if t not in choices: 93 | print_red("Invalid choice") 94 | continue 95 | print_green(f'{env}={t}') 96 | return t 97 | else: 98 | t = t or default 99 | print_green(f'{env}={t}') 100 | return t 101 | 102 | 103 | # Check dependencies 104 | 105 | if shutil.which('machinectl') is None: 106 | print_red('dependency not found: systemd-container') 107 | print('install: apt-get install systemd-container') 108 | sys.exit(1) 109 | 110 | if shutil.which('debootstrap') is None: 111 | print_red('dependency not found: debootstrap') 112 | print('install: apt-get install debootstrap') 113 | sys.exit(1) 114 | 115 | # Configuration 116 | 117 | VMARCH = os.environ.get('VMARCH', None) 118 | 119 | VMNAME = param('VMNAME', 120 | prompt="Give your VM a name.", 121 | default='vm1') 122 | 123 | VMRELEASE: str = param('VMRELEASE', 124 | prompt="Debian release", 125 | default='stable', 126 | choices=['stable', 'testing']) 127 | 128 | VMSSHD: bool = param('VMSSHD', 129 | prompt='Install SSH server?', 130 | default=False, 131 | boolean=True) 132 | 133 | VMSSHDPORT: int = 2022 134 | if VMSSHD: 135 | VMSSHDPORT = param('VMSSHDPORT', 136 | prompt='SSH server port', 137 | default=VMSSHDPORT, 138 | integer=True) 139 | # VMSSHKEY = param('VMSSHDKEY', 140 | # prompt=f'SSH public key for {USER_NAME}', 141 | # default='') 142 | 143 | VMGRAPHICS: bool = param('VMGRAPHICS', 144 | prompt='Should we install a graphical environment?', 145 | default=False, 146 | boolean=True) 147 | 148 | VMDISPLAY: int = 1 149 | VMDESKTOP: str = 'icewm' 150 | VMGEOMETRY: str = '1280x720' 151 | if VMGRAPHICS: 152 | VMDISPLAY = param('VMDISPLAY', 153 | prompt="VNC display, corresponds to TCP port: 1 -> 5901, 2 -> 5902", 154 | default=VMDISPLAY, 155 | integer=True) 156 | VMDESKTOP = param('VMDESKTOP', 157 | prompt="Desktop environment", 158 | default=VMDESKTOP, 159 | choices=['icewm', 'xfce4']) 160 | VMGEOMETRY = param('VMGEOMETRY', 161 | prompt="VNC display resolution", 162 | default=VMGEOMETRY) 163 | 164 | VMPASS = param('VMPASS', 165 | prompt="User password", 166 | default='debian') 167 | 168 | USER_NAME = 'debian' 169 | VNC_PORT = 5900 + VMDISPLAY 170 | 171 | # This is a security feature of systemd-nspawn that is a pain in the ass to work with 172 | PRIVATE_USERS = 'no' 173 | 174 | DHOST_HOME = Path.home().resolve() 175 | # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 176 | D_CACHE = DHOST_HOME / '.cache' 177 | D_CACHE_DEB = D_CACHE / 'b9_provision_nspawn_deb' 178 | D_MACHINES = Path('/var/lib/machines') 179 | D_NSPAWN = Path('/etc/systemd/nspawn') 180 | F_NSPAWN = D_NSPAWN / f'{VMNAME}.nspawn' 181 | D_MACHINE = D_MACHINES / VMNAME 182 | F_HOSTNAME = D_MACHINE / 'etc/hostname' 183 | F_HOSTS = D_MACHINE / 'etc/hosts' 184 | F_SUDOER = D_MACHINE / f'etc/sudoers.d/{USER_NAME}' 185 | F_SSHD_CONFDIR = D_MACHINE / 'etc/ssh/sshd_config.d' 186 | F_SSHD_CONF = F_SSHD_CONFDIR / 'custom_port.conf' 187 | 188 | INCLUDE = [ 189 | # needed for 'machinectl login' 190 | 'dbus', 191 | # needed for 'machinectl start' (not needed for basic 'system-nspawn' usage) 192 | 'systemd', 193 | # We are including sudo by default 194 | 'sudo' 195 | ] 196 | if VMGRAPHICS: 197 | INCLUDE.extend([ 198 | 'tigervnc-standalone-server', 199 | # This package is necessary for lxqt settings, pcmanfm-qt, thunar settings and many other programs 200 | 'dbus-x11', 201 | ]) 202 | if VMDESKTOP == 'icewm': 203 | INCLUDE.extend([ 204 | 'icewm', 205 | # no terminal emulator is included by default 206 | 'xterm' 207 | ]) 208 | elif VMDESKTOP == 'xfce4': 209 | INCLUDE.extend([ 210 | 'xfce4', 211 | # no terminal emulator is included by default 212 | 'xfce4-terminal' 213 | ]) 214 | 215 | DEBOOTSTRAP_OPTS = ['--variant=minbase'] 216 | if len(INCLUDE) > 0: 217 | pgks = ','.join(INCLUDE) 218 | DEBOOTSTRAP_OPTS.append(f'--include={pgks}') 219 | 220 | 221 | def run_local(command: str): 222 | print_cyan(command) 223 | subprocess.run(command, check=True, shell=True) 224 | 225 | 226 | def run_nspawn(command: str, user='root'): 227 | command_spawn = f"systemd-nspawn --private-users={PRIVATE_USERS} --user={user} --machine={VMNAME} /bin/sh -c '{command}'" 228 | run_local(command_spawn) 229 | 230 | 231 | try: 232 | 233 | D_CACHE_DEB.mkdir(parents=True, exist_ok=True) 234 | os.chdir(D_MACHINES) 235 | p = subprocess.run('debootstrap --version', stdout=subprocess.PIPE, shell=True, encoding='utf-8') 236 | debootstrap_version_m = re.search('(\\d+\\.)+\\d+', p.stdout) 237 | if debootstrap_version_m is None: 238 | print_red("Could not detect debootstrap version") 239 | sys.exit(2) 240 | 241 | debootstrap_version = debootstrap_version_m.group(0) 242 | print('debootstrap version detected:', debootstrap_version) 243 | 244 | [v1, v2, v3] = [int(i) for i in debootstrap_version.split('.')] 245 | # Minimum version that supports --cache-dir is 1.0.97 246 | # https://metadata.ftp-master.debian.org/changelogs//main/d/debootstrap/debootstrap_1.0.123_changelog 247 | if not (v1 < 1 or v2 < 0 or v3 < 97): 248 | DEBOOTSTRAP_OPTS.append(f'--cache-dir={D_CACHE_DEB}') 249 | 250 | if VMARCH is not None: 251 | DEBOOTSTRAP_OPTS.append(f'--arch={VMARCH}') 252 | 253 | DEBOOTSTRAP_OPTS.extend([VMRELEASE, VMNAME, 'http://deb.debian.org/debian/']) 254 | 255 | opts = ' '.join(DEBOOTSTRAP_OPTS) 256 | run_local(f'debootstrap {opts}') 257 | 258 | print('injecting hostname', F_HOSTNAME) 259 | F_HOSTNAME.write_text(f'{VMNAME}\n') 260 | 261 | print('injecting sudoer', F_SUDOER) 262 | F_SUDOER.write_text(f'{USER_NAME} ALL=(ALL:ALL) ALL') 263 | 264 | if VMSSHD: 265 | print('injecting sshd port', F_SSHD_CONF) 266 | F_SSHD_CONFDIR.mkdir(parents=True) 267 | F_SSHD_CONF.write_text(f'Port {VMSSHDPORT}') 268 | # We have to install openssh-server after everything else, so it picks up port config 269 | run_nspawn('apt-get install -y openssh-server') 270 | 271 | print('injecting hostname', F_HOSTS) 272 | with F_HOSTS.open('a') as f: 273 | f.write(f'127.0.1.1 {VMNAME}') 274 | 275 | print('writing', F_NSPAWN) 276 | D_NSPAWN.mkdir(exist_ok=True) 277 | F_NSPAWN.write_text(f''' 278 | [Exec] 279 | PrivateUsers={PRIVATE_USERS} 280 | 281 | [Network] 282 | VirtualEthernet=no 283 | ''') 284 | 285 | # NOTE: This command accepts --password "{VMPASS}", but this doesn't work for some reason 286 | run_nspawn(f'useradd --create-home --shell /bin/bash {USER_NAME}') 287 | run_nspawn(f'echo {USER_NAME}:{VMPASS} | chpasswd') 288 | run_nspawn(f'echo root:{VMPASS} | chpasswd') 289 | 290 | if VMGRAPHICS: 291 | # NOTE: session corresponds to files like /usr/share/xsessions/XYZ.desktop 292 | if VMDESKTOP == 'icewm': 293 | session = 'icewm-session' 294 | elif VMDESKTOP == 'lxqt': 295 | session = 'lxqt' 296 | elif VMDESKTOP == 'xfce4': 297 | session = 'xfce' 298 | else: 299 | raise Exception() 300 | VNC_CONFIG = f'session={session}\\ngeometry={VMGEOMETRY}\\nlocalhost=no\\nalwaysshared' 301 | run_nspawn(f'echo ":{VMDISPLAY}={USER_NAME}" >> /etc/tigervnc/vncserver.users') 302 | run_nspawn(f'mkdir /home/{USER_NAME}/.vnc', user=USER_NAME) 303 | run_nspawn(f'echo {VMPASS} | vncpasswd -f > /home/{USER_NAME}/.vnc/passwd', user=USER_NAME) 304 | run_nspawn(f'chmod 600 /home/{USER_NAME}/.vnc/passwd') 305 | run_nspawn(f'echo "{VNC_CONFIG}" > /home/{USER_NAME}/.vnc/config', user=USER_NAME) 306 | run_nspawn(f'systemctl enable tigervncserver@:{VMDISPLAY}') 307 | 308 | print("Setup finished") 309 | print_blue(f'root password: {VMPASS}') 310 | print_blue(f'{USER_NAME} password: {VMPASS}') 311 | print(f"Start your new VM:") 312 | print_green(f" machinectl start {VMNAME}") 313 | print(f"Enable your new VM on boot:") 314 | print_green(f" machinectl enable {VMNAME}") 315 | print(f"Log into your new VM:") 316 | print_green(f" machinectl login {VMNAME}") 317 | if VMSSHD: 318 | print(f"You can connect to your VM using:") 319 | print_green(f" ssh debian@HOSTNAME -p {VMSSHDPORT}") 320 | if VMGRAPHICS: 321 | print(f"You can expect the VNC server to be running on {VNC_PORT}") 322 | print_blue(f"VNC password: {VMPASS}") 323 | print("You can delete your new VM with:") 324 | print_green(f" machinectl stop {VMNAME}") 325 | print_green(f" rm -rf {D_MACHINE}") 326 | print_green(f" rm {F_NSPAWN}") 327 | 328 | except BaseException as e: 329 | print_red("Something went wrong with the installation, run this to clean up your system:") 330 | print_red(f" rm -rf {D_MACHINE}") 331 | print_red(f" rm {F_NSPAWN}") 332 | raise e 333 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boronine/nspawn2go/b3f7cb52c330d0e74a7bf0d26c7e7c6780dc1de2/screenshot.jpg --------------------------------------------------------------------------------