Last active
February 24, 2026 23:33
-
-
Save Juma7C9/49103015adcd90980d3b951e1c0ed362 to your computer and use it in GitHub Desktop.
cloud-config user configuration (btrfs+luks+ssh+unattended upgrades)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| import sys, os, argparse, textwrap | |
| import yaml | |
| parser = argparse.ArgumentParser( | |
| description=textwrap.dedent('''\ | |
| Given a cloud-init yaml-file in input, dump all files of the 'write_files' | |
| array to a given directory and replace the 'content' field with | |
| a reference to the file. | |
| Conversely, perform the reverse, i.e. given the files and the template, | |
| assemble the original back. | |
| Note: the operation disassemble -> reassemble may add leading whitespaces | |
| to empty lines to match their indentation, which should not influence | |
| the behaviour of the project. | |
| Note 2: the script assumes no mixed indent, assuming two whitespaces (' ') | |
| by default, or whatever is passed to the `-s | --set-indent` switch. | |
| No complex heuristic is performed.'''), | |
| formatter_class = argparse.RawDescriptionHelpFormatter, | |
| epilog=f"Protip: `{sys.argv[0]} -d dest <infile.yaml >outfile.yaml` to disassemble;\n" | |
| f" `{sys.argv[0]} <infile.yaml >outfile.yaml` to reassemble.") | |
| parser.add_argument("-i", "--in", dest="infile", | |
| help="Parse this file instead of stdin" ) | |
| parser.add_argument("-o", "--out", dest="outfile", | |
| help="Write the output here instead of stdout. Overwrites if exists." ) | |
| parser.add_argument("-d", "--disassemble-to", metavar="DIR", | |
| help="Dump the original parsed files to this directory" ) | |
| parser.add_argument("-s", "--set-indents", default=' ', metavar="WS", | |
| help="Set the indents used by the file, instead of ' '" ) | |
| parser.add_argument("-v", "--verbose", action="store_true", | |
| help="Print additional information" ) | |
| args = parser.parse_args() | |
| def debug_print(*_args, **_kwargs): | |
| if args.verbose: | |
| return sys.stderr.write(*_args, **_kwargs) | |
| def files_from_yaml(yaml_in, prefix, indent=' '): | |
| yaml_dict = yaml.safe_load(yaml_in) | |
| files = yaml_dict["write_files"] | |
| template_out = yaml_in | |
| for file in files: | |
| content = file.get("content", '') | |
| path = file["path"] | |
| dump_path = os.path.normpath( prefix + '/' + path ) | |
| mode = file.get("permissions", None) | |
| os.makedirs( os.path.dirname(dump_path), mode=0o755, exist_ok=True ) | |
| with open(dump_path, 'w') as outfile: | |
| outfile.write(content) | |
| if mode is not None: | |
| os.chmod(dump_path, int(mode, base=8)) | |
| # The `content` in the YAML file is usually indented, but empty lines | |
| # may break the assumption that _everything_ is indented the same | |
| # (empty lines may be indented as the rest, or just remain empty lines), | |
| # so try all the options and see what sticks. | |
| if content: | |
| if content in template_out: | |
| template_out = template_out.replace(content, f'##file:"{dump_path}"\n') | |
| debug_print(f"{dump_path}: simple replacement\n") | |
| else: | |
| nospc_indented_cont=textwrap.indent(content, 3*indent) | |
| fully_indented_cont=textwrap.indent(content, 3*indent, lambda _: True) | |
| if fully_indented_cont in template_out: | |
| template_out = template_out.replace(fully_indented_cont, f'{3*indent}##file:"{dump_path}"\n') | |
| debug_print(f"{dump_path}: full replacement\n") | |
| elif nospc_indented_cont in template_out: | |
| template_out = template_out.replace(nospc_indented_cont, f'{3*indent}##file:"{dump_path}"\n') | |
| debug_print(f"{dump_path}: nospc replacement\n") | |
| else: | |
| debug_print(f"{dump_path}: no replacement :(\n") | |
| return template_out | |
| def yaml_from_files(template_in, indent=' '): | |
| yaml_dict = yaml.safe_load(template_in) | |
| files = yaml_dict["write_files"] | |
| yaml_out = template_in | |
| for file in files: | |
| source_str = file.get("content", None) | |
| if source_str: | |
| source_path = source_str.removeprefix("##file:").strip('"\n') | |
| with open(os.path.normpath(source_path), 'r') as input_file: | |
| content = input_file.read() | |
| yaml_out = yaml_out.replace(source_str, content.replace('\n', '\n' + indent*3)) | |
| return yaml_out | |
| if args.infile is not None: | |
| infile = open(args.infile, 'r') | |
| else: | |
| infile = sys.stdin | |
| yaml_in = infile.read() | |
| infile.close() | |
| if args.disassemble_to is not None: | |
| file_out = files_from_yaml(yaml_in, args.disassemble_to, args.set_indents) | |
| else: | |
| file_out = yaml_from_files(yaml_in, args.set_indents) | |
| if args.outfile is not None: | |
| outfile = open(args.outfile, 'w') | |
| else: | |
| outfile = sys.stdout | |
| outfile.write(file_out) | |
| outfile.close() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #cloud-config | |
| # Cloud config user configuration for Debian cloud images: | |
| # * Adds ssh keys and reenables root account, disabling password auth; | |
| # * Enables unattended upgrades; | |
| # * Adds a zram swap device; | |
| # * Configures systemd-resolved to use DNSSEC and opportunistic DoT; | |
| # * Converts the disk image to btrfs and rollbacks if it fails; | |
| # * Encrypts the disk with an empty password (no need to manually unlock the disk at first boot); | |
| # * Setups Dracut for remote unlock via ssh, local terminal, or automatically using an empty pw; | |
| # * Prepares some coffee (WIP). | |
| fqdn: "my.machine.name" | |
| ssh_authorized_keys: | |
| - your-keys-here | |
| - your-keys-here | |
| packages: | |
| - openssh-server | |
| - unattended-upgrades | |
| - btrfs-progs | |
| - systemd-zram-generator | |
| - dropbear-bin | |
| - dracut-network | |
| disable_root: false | |
| prefer_fqdn_over_hostname: true | |
| package_update: true | |
| package_upgrade: true | |
| apt: | |
| conf: | | |
| APT::Periodic { | |
| Update-Package-Lists "1"; | |
| Unattended-Upgrade "1"; | |
| }; | |
| Unattended-Upgrade { | |
| Origins-Pattern { | |
| "origin=Debian,codename=${distro_codename}-updates"; | |
| "origin=Debian,codename=${distro_codename},label=Debian"; | |
| "origin=Debian,codename=${distro_codename},label=Debian-Security"; | |
| "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; | |
| }; | |
| MinimalSteps "true"; | |
| Remove-Unused-Kernel-Packages "true"; | |
| Remove-New-Unused-Dependencies "true"; | |
| }; | |
| write_files: | |
| - path: /root/.bashrc | |
| content: | | |
| # ~/.bashrc: executed by bash(1) for non-login shells. | |
| alias ls='ls --color=auto' | |
| alias ll='ls -Alh' | |
| alias largest='du --max-depth=1 2> /dev/null | sort -n -r | head -n20' | |
| alias lft='lft -N' | |
| alias sp='du -h --max-depth=1 | sort -h' | |
| _col() { echo -n "\[\e[1;${1}m\]${2}\[\e[m\]"; } | |
| _red() { _col 31 "$1"; } | |
| _grn() { _col 32 "$1"; } | |
| _ylw() { _col 33 "$1"; } | |
| _blu() { _col 34 "$1"; } | |
| _mgt() { _col 35 "$1"; } | |
| _cyn() { _col 36 "$1"; } | |
| _gry() { _col 37 "$1"; } | |
| PS1="$(_red '\u')$(_grn @)$(_ylw '\H')$(_grn ': \w ')$(_red '\$ ')" | |
| # systemd-resolved configuration | |
| - path: /etc/systemd/resolved.conf.d/local.conf | |
| content: | | |
| [Resolve] | |
| FallbackDNS=9.9.9.9#dns.quad9.net 2620:fe::9#dns.quad9.net 1.1.1.1#cloudflare-dns.com\ | |
| 2606:4700:4700::1111#cloudflare-dns.com 8.8.8.8#dns.google 2001:4860:4860::8888#dns.google | |
| DNSSEC=yes | |
| DNSOverTLS=opportunistic | |
| - path: /etc/systemd/zram-generator.conf | |
| content: | | |
| [zram0] | |
| zram-size = ram * 2 | |
| compression-algorithm = zstd(level=3) zstd(level=9) (type=idle) | |
| # OpenSSH options | |
| - path: /etc/ssh/sshd_config.d/hostkeys.conf | |
| content: | | |
| HostKeyAlgorithms ssh-ed25519 | |
| # Force grub to use UUIDs as /dev/mapper devices have no PARTUUID | |
| - path: /etc/default/grub.d/99_use_uuid.cfg | |
| content: | | |
| GRUB_DISABLE_LINUX_UUID=false | |
| # Dropbear+Dracut (initramfs ssh server) configuration | |
| - path: /etc/systemd/system/dropbear-initrd.service | |
| content: | | |
| [Unit] | |
| Description=Lightweight SSH server | |
| Documentation=man:dropbear(8) | |
| Wants=network-online.target | |
| After=network-online.target | |
| Before=cryptsetup.target | |
| DefaultDependencies=no | |
| [Service] | |
| Environment=DB_PORT=22 DB_RECV_WIN=65536 | |
| Environment=DB_HOST_KEY=/var/lib/dracut/dropbear_host_key | |
| Environment=DROPBEAR_EXTRA_ARGS="-sjk -c /usr/bin/systemd-tty-ask-password-agent" | |
| ExecStart=/usr/sbin/dropbear -EF -p "$DB_PORT" -W "$DB_RECV_WIN"\ | |
| -r "$DB_HOST_KEY" $DROPBEAR_EXTRA_ARGS | |
| KillMode=process | |
| Restart=on-failure | |
| [Install] | |
| WantedBy=remote-cryptsetup.target | |
| - path: /etc/dracut.conf.d/dropbear.conf | |
| content: | | |
| add_dracutmodules+=" network systemd-cryptsetup " | |
| # /etc/shells is needed otherwise dropbear will accept only /bin/{sh,csh} | |
| install_items+=" /usr/sbin/dropbear /etc/shells " | |
| install_items+=" /etc/systemd/system/remote-cryptsetup.target.wants/dropbear-initrd.service " | |
| install_items+=" /var/lib/dracut/dropbear_host_key /root/.ssh/authorized_keys " | |
| # SYSTEMD_SULOGIN_FORCE allows to drop onto an emergency shell even if root has no pw set. | |
| kernel_cmdline+=" rd.auto=1 rd.neednet=1 ip=single-dhcp SYSTEMD_SULOGIN_FORCE=1 " | |
| # Add some useful info to the MOTD | |
| - path: /etc/update-motd.d/50-local-info | |
| permissions: '0755' | |
| content: | | |
| #!/bin/bash | |
| cat <<SSHKEYS | |
| #======================================================================================== | |
| # Valid ssh keys used by $(</etc/hostname): | |
| $(ssh-keyscan -q $(</etc/hostname) | ssh-keygen -lf -) | |
| # Valid ssh keys for password unlock: | |
| $(ssh-keygen -lf /var/lib/dracut/dropbear_host_key.pub) | |
| #======================================================================================== | |
| SSHKEYS | |
| _luks_device="/dev/$(lsblk -Q 'TYPE=="crypt" && MOUNTPOINTS=="/"' -no PKNAME)" | |
| if (cryptsetup open --test-passphrase "${_luks_device}" <<<''); then | |
| cat <<LUKS | |
| # The default encryption passphrase is empty. To set or change it use the command: | |
| # \`cryptsetup luksChangeKey ${_luks_device}\` | |
| #======================================================================================== | |
| LUKS | |
| fi | |
| # First phase, after system initialization copy a minimal rootfs to ram, | |
| # and use systemd's soft-reboot to switch and unload the real rootfs | |
| - path: /run/cloud-init-scripts/init.sh | |
| permissions: '0755' | |
| content: | | |
| #!/bin/bash | |
| set -xeuo pipefail | |
| # Lock (password login) for root account, e.g. if it was enabled by vendor | |
| passwd -l root | |
| findmnt -no PARTUUID / >/run/root-partuuid | |
| # If `/boot` resides under the same fs as `/`, | |
| # use EFIpart as /boot to avoid encrypting the boot partition | |
| if ! (mountpoint -q /boot) && (mountpoint -q /boot/efi); then | |
| EFI_PART=/dev/disk/by-uuid/$(findmnt -no UUID /boot/efi) | |
| umount /boot/efi | |
| mkdir /run/boot | |
| rmdir /boot/efi | |
| mount $EFI_PART /run/boot | |
| mv /boot/* /run/boot | |
| umount /run/boot | |
| sed 's#/boot/efi#/boot#' -i /etc/fstab | |
| mount /boot | |
| fi | |
| # If `/run/nextroot` contains a valid rootfs, systemd will automatically | |
| # switch to it on soft-reboot. | |
| # See also https://unix.stackexchange.com/questions/226872/ | |
| mkdir /run/nextroot | |
| # Put zramctl in a loop as it sometimes fails :( | |
| while ! (zramctl -fs 1G -a zstd -p level=9 >/run/zram-root-device); do sleep 2; done | |
| mkfs.ext2 $(</run/zram-root-device) | |
| mount $(</run/zram-root-device) /run/nextroot | |
| mkdir /run/nextroot/{proc,sys,dev,run,usr{,/share},var,tmp,root,oldroot} | |
| cp -ax /{bin,etc,sbin,lib,root} /run/nextroot/ | |
| cp -ax /usr/{bin,sbin,lib,lib64,libexec} /run/nextroot/usr/ | |
| cp -ax /usr/share/{dbus-1,polkit-1} /run/nextroot/usr/share | |
| cp -ax /var/{lib,lock,log,run,tmp} /run/nextroot/var | |
| cp -a /run/{cloud-init-scripts,nextroot/var/lib/cloud/scripts/per-boot}/repart.sh | |
| systemctl soft-reboot | |
| # Second phase, we are in the volatile (tmpfs) rootfs | |
| # Find the real rootfs, try to convert it to btrfs, then convert the partition to LUKS | |
| - path: /run/cloud-init-scripts/repart.sh | |
| permissions: '0755' | |
| content: | | |
| #!/bin/bash | |
| set -xeuo pipefail | |
| # Try to convert rootfs to btrfs, rollback if it fails | |
| # (see https://bugzilla.kernel.org/show_bug.cgi?id=206995) | |
| # Resize the part to make room for LUKS header, then encrypt it | |
| ROOT_PART=/dev/disk/by-partuuid/$(</run/root-partuuid) | |
| e2fsck -fy $ROOT_PART | |
| NEW_SIZE=$(( $(lsblk -nbo SIZE $ROOT_PART) / 1024 - 32*1024 )) | |
| resize2fs $ROOT_PART ${NEW_SIZE}K | |
| e2fsck -fy $ROOT_PART | |
| # Check for OOMs: run benchmark: if it is killed then set the maximum memory available | |
| # in increments of 128MiB. TODO: also limit the parallel threads? | |
| _default_pbkdf="$(cryptsetup --help | grep -Po 'Default PBKDF for LUKS2:\s+\K\w+')" | |
| if ! (cryptsetup benchmark --pbkdf "${_default_pbkdf}"); then | |
| _max_mem=$(( ($(grep MemFree /proc/meminfo | awk '{print $2}')/(128*1024)) * (128*1024) )) | |
| _pbkdf_opts=(--pbkdf-memory ${_max_mem}) | |
| fi | |
| LUKS_LABEL="luks-$(</run/root-partuuid)" | |
| LUKS_PATH="/dev/mapper/${LUKS_LABEL}" | |
| cryptsetup reencrypt --new --reduce-device-size 32M ${_pbkdf_opts[@]} \ | |
| --label "${LUKS_LABEL}" --uuid $(</run/root-partuuid) \ | |
| $ROOT_PART <<< '' | |
| cryptsetup open $ROOT_PART "${LUKS_LABEL}" <<< '' | |
| btrfs-convert --no-progress --uuid copy --copy-label "${LUKS_PATH}" | |
| mkdir /run/btrfs | |
| if (mount "${LUKS_PATH}" /run/btrfs); then | |
| btrfs subvolume delete /run/btrfs/ext2_saved | |
| btrfs filesystem defrag -r -f /run/btrfs | |
| btrfs filesystem balance --full-balance /run/btrfs | |
| # Comment out the default '/' fstab entry, and add a new one | |
| sed -E '/\s+\/\s+/s/(.*)/#\1/' -i /run/btrfs/etc/fstab | |
| echo "UUID=$(findmnt -no UUID /run/btrfs) / btrfs defaults,compress=zstd 0 0"\ | |
| >>/run/btrfs/etc/fstab | |
| umount /run/btrfs | |
| else | |
| btrfs-convert --no-progress --rollback "${LUKS_PATH}" | |
| fi | |
| # Soft-reboot a last time and reconfigure initramfs | |
| mount "${LUKS_PATH}" /run/nextroot | |
| mount --bind /boot /run/nextroot/boot | |
| mount -t tmpfs none /run/nextroot/var/lib/cloud/scripts/per-boot | |
| cp -a /run/{cloud-init-scripts,nextroot/var/lib/cloud/scripts/per-boot}/update-initramfs.sh | |
| systemctl soft-reboot | |
| # Third (final) phase: we are back in the real, now luks-encrypted rootfs; | |
| # reconfigure the GRUB image and config, together with the initramfs image, | |
| # and finally reboot to the configured system. | |
| - path: /run/cloud-init-scripts/update-initramfs.sh | |
| permissions: '0755' | |
| content: | | |
| #!/bin/bash | |
| set -xeuo pipefail | |
| zramctl -r $(</run/zram-root-device) | |
| echo "luks-$(</run/root-partuuid) PARTUUID=$(</run/root-partuuid) none \ | |
| luks,try-empty-password=yes,x-initrd.attach" | sed -E 's@\s+@ @g' \ | |
| >/etc/crypttab | |
| #echo "kernel_cmdline+=\" rd.info rd.timeout=30 rd.shell \"" \ | |
| # >/etc/dracut.conf.d/debug.conf | |
| # Generate a hostonly initrd (~20M) instead of a generic one (~50M) | |
| # if the boot partition is on the smaller side (<120M of free space) | |
| # Otherwise include the crypttab as it is excluded by default. | |
| if [ $(df --output=avail /boot | tail -1) -lt 120000 ]; then | |
| echo 'hostonly="yes"' >/etc/dracut.conf.d/hostonly.conf | |
| else | |
| echo 'install_items+=" /etc/crypttab "' >/etc/dracut.conf.d/crypttab.conf | |
| fi | |
| # Generate a hostkey for Dropbear and enable (intrd) service | |
| dropbearkey -t ed25519 -f /var/lib/dracut/dropbear_host_key | |
| systemctl enable dropbear-initrd.service | |
| dracut --regenerate-all --force | |
| # Suppose we have at most an `EFI` dir under the root of mounted FSs... | |
| if [ -d /sys/firmware/efi ]; then | |
| efi_dir="$(find $(findmnt -lno TARGET) -maxdepth 1 -name EFI -type d -printf '%h')" | |
| grub_opts=( --target=x86_64-efi --efi-directory="$efi_dir" ) | |
| else | |
| grub_dest="$(lsblk -no PKNAME $(findmnt -no SOURCE /boot))" | |
| grub_opts=( --target=i386-pc "/dev/${grub_dest}" ) | |
| fi | |
| grub-install "${grub_opts[@]}" | |
| update-grub | |
| touch /etc/cloud/cloud-init.disabled | |
| systemctl reboot | |
| runcmd: | |
| - bash -x /run/cloud-init-scripts/init.sh |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| import sys, argparse | |
| import yaml | |
| from gzip import compress as gz | |
| from base64 import b64encode | |
| parser = argparse.ArgumentParser( | |
| description="YAML Mangler for cloud-config" | |
| "Reads a YAML file from stdin and prints the output to stdout." | |
| "The entries of 'write_files' list are gz compressed and b64 encoded," | |
| "replacing the original 'content' if is smaller than the plain form." | |
| "Uses PyYAML which will futher mangle the file, e.g. stripping comments.", | |
| epilog=f"Protip: {sys.argv[0]} <infile.yaml >outfile.yaml") | |
| parser.add_argument("-i", "--in", dest="infile", | |
| help="Parse this file instead of stdin" ) | |
| parser.add_argument("-o", "--out", dest="outfile", | |
| help="Write the output here instead of stdout. Overwrites if exists." ) | |
| parser.add_argument("-s", "--strip-sh", action="store_true", | |
| help="Also strip comment lines from '*.sh' files" ) | |
| parser.add_argument("-x", "--always-encode", action="store_true", | |
| help="Always encode entries, without checking the size" ) | |
| args = parser.parse_args() | |
| if args.infile is not None: | |
| yaml_in = open(args.infile, 'r') | |
| else: | |
| yaml_in = sys.stdin | |
| if args.outfile is not None: | |
| yaml_out = open(args.outfile, 'w') | |
| else: | |
| yaml_out = sys.stdout | |
| yaml_dict = yaml.safe_load(yaml_in) | |
| files = yaml_dict["write_files"] | |
| for file in files: | |
| content = file.get("content", '') | |
| path = file["path"] | |
| if path.endswith('.sh') and args.strip_sh: | |
| _content = "" | |
| for line in content.split('\n'): | |
| if line.startswith('#!'): | |
| _content += line + '\n' | |
| elif line.startswith('#'): | |
| pass | |
| else: | |
| _content += line + '\n' | |
| content = _content | |
| gz_content = b64encode( gz( bytes(content, "UTF-8") ) ) | |
| # Add the overhead of the additional headers and type specs (~25 chars) | |
| if len(gz_content) + 25 < len(content) or args.always_encode: | |
| file["content"] = gz_content | |
| file["encoding"] = "gz+b64" | |
| out_yaml = yaml.safe_dump(yaml_dict) | |
| yaml_out.write('#cloud-config\n') | |
| yaml_out.write(out_yaml) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment