Skip to content

Instantly share code, notes, and snippets.

@Juma7C9
Last active February 24, 2026 23:33
Show Gist options
  • Select an option

  • Save Juma7C9/49103015adcd90980d3b951e1c0ed362 to your computer and use it in GitHub Desktop.

Select an option

Save Juma7C9/49103015adcd90980d3b951e1c0ed362 to your computer and use it in GitHub Desktop.
cloud-config user configuration (btrfs+luks+ssh+unattended upgrades)
#!/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()
#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
#!/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