diff --git a/bin/ec2/ebs_bundle.py b/bin/ec2/ebs_bundle.py index d38472c..0d44036 100755 --- a/bin/ec2/ebs_bundle.py +++ b/bin/ec2/ebs_bundle.py @@ -30,8 +30,6 @@ import sys import time -from botocore.exceptions import ClientError - import utils log = utils.get_logger("ebs-bundle") @@ -102,6 +100,8 @@ def create(self, size, zone=None): def delete(self, max_attempts=10): if self.vol: + from botocore.exceptions import ClientError + attempt = 0 while True: attempt += 1 @@ -153,8 +153,8 @@ def __del__(self): class Device: - def __init__(self): - self.real_path = self._get_freedevice() + def __init__(self, real_path=None): + self.real_path = real_path if real_path else self._get_freedevice() if not self.real_path: raise EbsBundleError("no free devices available...") @@ -197,13 +197,47 @@ def mkpart(self): subprocess.run(["partprobe", self.real_path], check=True) time.sleep(5) self.root_path = self.real_path - self.real_path = self.real_path + "2" + separator = "p" if os.path.basename(self.real_path).startswith("nvme") else "" + self.real_path = self.real_path + separator + "2" def __del__(self): if self.is_mounted(): self.umount() +def populate(rootfs, device, filesystem="ext4"): + """Populate one pre-attached block device without making an AWS call.""" + log.info("creating partitions") + device.mkpart() + device.mkfs(filesystem) + mount_path = rootfs + ".mount" + device.mount(mount_path) + + submounts = [] + try: + log.info("syncing rootfs to partition") + utils.rsync(rootfs, mount_path) + os.chmod(mount_path, 0o755) + + log.info("installing GRUB on volume") + for submount in ("/sys", "/proc", "/dev"): + subprocess.run( + ["mount", "--bind", "--make-rslave", submount, mount_path + submount], + check=True, + ) + submounts.append(submount) + subprocess.run(["chroot", mount_path, "grub-install", device.root_path], check=True) + subprocess.run(["chroot", mount_path, "update-grub"], check=True) + subprocess.run(["chroot", mount_path, "update-initramfs", "-u"], check=True) + finally: + for submount in reversed(submounts): + subprocess.run(["umount", "-l", mount_path + submount], check=False) + if device.is_mounted(): + device.umount() + if os.path.isdir(mount_path): + os.rmdir(mount_path) + + def bundle(rootfs, snapshot_name, size=10, filesystem="ext4"): log.info(f"target snapshot - {snapshot_name} ") @@ -214,33 +248,8 @@ def bundle(rootfs, snapshot_name, size=10, filesystem="ext4"): device = Device() volume.attach(utils.get_instanceid(), device) - log.info("creating partitions") - device.mkpart() - device.mkfs(filesystem) - mount_path = rootfs + ".mount" - device.mount(mount_path) - - log.info("syncing rootfs to partition") - utils.rsync(rootfs, mount_path) - - log.info("installing GRUB on volume") - submounts = ["/sys", "/proc", "/dev"] - for s in submounts: - subprocess.run(["mount", "--bind", "--make-rslave", s, mount_path + s], - check=True) - subprocess.run(["chroot", mount_path, "grub-install", device.root_path], - check=True) - subprocess.run(["chroot", mount_path, "update-grub"], check=True) - subprocess.run(["chroot", mount_path, "update-initramfs", "-u"], - check=True) - - submounts.reverse() - for s in submounts: - subprocess.run(["umount", "-l", mount_path + s], check=True) - - device.umount() + populate(rootfs, device, filesystem) volume.detach() - os.removedirs(mount_path) log.info("creating snapshot from volume") snapshot = Snapshot() diff --git a/bin/ec2/ebs_populate.py b/bin/ec2/ebs_populate.py new file mode 100755 index 0000000..891dd0c --- /dev/null +++ b/bin/ec2/ebs_populate.py @@ -0,0 +1,23 @@ +#!/usr/bin/python3 +"""Populate a pre-attached EBS device from a prepared root filesystem.""" + +import argparse +import os + +from ebs_bundle import Device, populate + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("rootfs") + parser.add_argument("device") + args = parser.parse_args() + if not os.path.isdir(args.rootfs): + parser.error("rootfs path does not exist") + if not os.path.exists(args.device): + parser.error("device path does not exist") + populate(args.rootfs, Device(args.device)) + + +if __name__ == "__main__": + main() diff --git a/bin/ec2/utils.py b/bin/ec2/utils.py index 4c6b76c..b5c03a8 100644 --- a/bin/ec2/utils.py +++ b/bin/ec2/utils.py @@ -14,19 +14,19 @@ import subprocess import sys -import boto3 -import ec2metadata - import conf def connect_boto3(region=None): + import boto3 + region = region if region else get_region() return boto3.client( "ec2", region_name=region, aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), - aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY")) + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + aws_session_token=os.environ.get("AWS_SESSION_TOKEN")) def get_turnkey_version(rootfs): @@ -35,14 +35,20 @@ def get_turnkey_version(rootfs): def get_instanceid(): + import ec2metadata + return ec2metadata.get("instance-id") def get_zone(): + import ec2metadata + return ec2metadata.get("availability-zone") def get_region(): + import ec2metadata + return ec2metadata.get("availability-zone")[0:-1] diff --git a/bin/iso-release b/bin/iso-release index b8ac922..7d45e95 100755 --- a/bin/iso-release +++ b/bin/iso-release @@ -90,7 +90,49 @@ cp build/product.iso "$O/$name.iso" if [[ -e $BT_PROFILES/$appname ]]; then mkdir -p "$O/$name.tklbam" export PROFILES_CONF=$BT_PROFILES - "$BT/bin/generate-tklbam-profile" "$O/$name.iso" "$O/$name.tklbam" + if [[ -x /usr/lib/tklbam-pypy2/bin/pypy ]]; then + "$BT/bin/generate-tklbam-profile" "$O/$name.iso" "$O/$name.tklbam" + else + ( + rootfs=$(readlink -f "$rootfs") + [[ -x "$rootfs/usr/lib/tklbam-pypy2/bin/pypy" ]] || \ + fatal "TKLBAM runtime not found in $rootfs" + staging=$(mktemp -d "$rootfs/var/tmp/buildtasks-tklbam-profile.XXXXXX") + guest_staging=${staging#"$rootfs"} + cleanup() { + status=$? + trap - EXIT + rm -rf -- "$staging" + exit "$status" + } + trap cleanup EXIT + + install -d "$staging/profiles" "$staging/output" + install -m 0755 "$BT/bin/generate-tklbam-profile" \ + "$staging/generate-tklbam-profile" + cat > "$staging/run-tklbam-profile" < /usr/share/doc/tklamq/turnkey-v19-build-input.json <<'EOF' +{ + "artifact": "tklamq_0.12.1_all.deb", + "sha256": "d972012e6aae81ada59b071ede494f46194db706a6805ce048215b510a168a2d", + "source_commit": "85e3d33c2d943e34db2c17e05403ea4a0f756784", + "source_pr": "https://github.com/turnkeylinux/tklamq/pull/5" +} +EOF + +/usr/bin/install -d -m 0755 /usr/share/doc/hubdns +cat > /usr/share/doc/hubdns/turnkey-v19-build-input.json <<'EOF' +{ + "artifact": "hubdns_1.4.0+fix1_all.deb", + "sha256": "4e255c838cbd6c2a6f64041c3a3d088acd3166d935f60aaf41d30d39d95b6352", + "source_commits": [ + "cc127d63c306089059ab8eebe1d67b1db119cca6", + "75b616701c55fb0a2aac2b4e9535f30ed29accfb", + "8706ec3a5acc3b3118dbec8dc8f681c6a999160b" + ] +} +EOF # grub tweaks DEFAULT=/etc/default/grub @@ -23,15 +62,50 @@ chmod 644 /var/lib/inithooks/turnkey-init-fence/htdocs/*.png # enable sudoadmin in rootfs turnkey-sudoadmin on --disable-setpass -# provide for AWSMP requirment of "no default passwords" +# AWS Marketplace images authenticate the privileged admin account with the +# EC2 key injected at first boot. Remove generated password hashes without +# expiring admin, so key login remains available while passwords stay locked. additional_users=$(grep ":[1-9][0-9][0-9][0-9]:" /etc/passwd | cut -d: -f1) for user in $additional_users; do - passwd -l $user + usermod --password '*' "$user" if [[ $user = "ansible" ]]; then ansible_init=/usr/lib/inithooks/firstboot.d/50ansible-key sed -i "/.ssh/ s|root|home/admin|" $ansible_init fi done +usermod --expiredate -1 admin + +# Load the EC2-only policy before any package defaults. OpenSSH uses the first +# value it obtains for these global options. +sshd_config=/etc/ssh/sshd_config +sshd_include='Include /etc/ssh/sshd_config.d/*.conf' +sed -i '\|^[[:space:]]*Include[[:space:]]\+/etc/ssh/sshd_config.d/\*\.conf[[:space:]]*$|d' \ + "$sshd_config" +sed -i "1i$sshd_include" "$sshd_config" + +# Fail the conversion if account state, embedded keys, or the effective SSH +# policy diverges from the Marketplace guest contract. +admin_shadow=$(getent shadow admin) +test "$(echo "$admin_shadow" | cut -d: -f2)" = '*' +test -z "$(echo "$admin_shadow" | cut -d: -f8)" +test ! -s /root/.ssh/authorized_keys +test ! -s /home/admin/.ssh/authorized_keys +grep -qx 'SUDOADMIN=true' /etc/default/inithooks +grep -qx 'admin ALL=(ALL) NOPASSWD:ALL' /etc/sudoers.d/99_admin + +sshd_testdir=$(mktemp -d) +trap 'rm -rf "$sshd_testdir"' EXIT +ssh-keygen -q -t ed25519 -N '' -f "$sshd_testdir/host_key" +sshd -T -h "$sshd_testdir/host_key" > "$sshd_testdir/effective" +for required in \ + 'passwordauthentication no' \ + 'kbdinteractiveauthentication no' \ + 'permitrootlogin no' \ + 'pubkeyauthentication yes'; do + grep -qx "$required" "$sshd_testdir/effective" +done +rm -rf "$sshd_testdir" +trap - EXIT # init fence now launches confconsole with full options, so autolaunch disabled # on other headless builds. However, we want it on Hub builds. diff --git a/patches/ec2/overlay/etc/cron.d/turnkey-tklapp-letsencrypt b/patches/ec2/overlay/etc/cron.d/turnkey-tklapp-letsencrypt new file mode 100644 index 0000000..838b149 --- /dev/null +++ b/patches/ec2/overlay/etc/cron.d/turnkey-tklapp-letsencrypt @@ -0,0 +1,2 @@ +# Retry initial Hub-managed TLS without delaying first-boot completion. +* * * * * root /usr/bin/flock -n /run/turnkey-tklapp-letsencrypt.lock /usr/local/sbin/turnkey-tklapp-letsencrypt >/dev/null 2>&1 diff --git a/patches/ec2/overlay/etc/cron.hourly/turnkey-tklapp-letsencrypt b/patches/ec2/overlay/etc/cron.hourly/turnkey-tklapp-letsencrypt new file mode 120000 index 0000000..ce38557 --- /dev/null +++ b/patches/ec2/overlay/etc/cron.hourly/turnkey-tklapp-letsencrypt @@ -0,0 +1 @@ +/usr/local/sbin/turnkey-tklapp-letsencrypt \ No newline at end of file diff --git a/patches/ec2/overlay/etc/ssh/sshd_config.d/00-turnkey-aws-marketplace.conf b/patches/ec2/overlay/etc/ssh/sshd_config.d/00-turnkey-aws-marketplace.conf new file mode 100644 index 0000000..8a1bb6e --- /dev/null +++ b/patches/ec2/overlay/etc/ssh/sshd_config.d/00-turnkey-aws-marketplace.conf @@ -0,0 +1,4 @@ +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin no +PubkeyAuthentication yes diff --git a/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/28ec2-sudoadmin b/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/28ec2-sudoadmin deleted file mode 100755 index a5b3da0..0000000 --- a/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/28ec2-sudoadmin +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -e - -[ -n "$_TURNKEY_INIT" ] && exit 0 - -if grep -q SERVERID= /var/lib/hubclient/server.conf >/dev/null 2>&1; then - # hub launch, disable sudoadmin - sed -i "s/^SUDOADMIN=.*/SUDOADMIN=false/" /etc/default/inithooks -else - # non-hub launch - exit 0 -fi - diff --git a/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/82hub-letsencrypt b/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/82hub-letsencrypt new file mode 100755 index 0000000..568c10e --- /dev/null +++ b/patches/ec2/overlay/usr/lib/inithooks/firstboot.d/82hub-letsencrypt @@ -0,0 +1,13 @@ +#!/bin/bash + +if ! systemctl start hubdns.service; then + logger -t turnkey-tklapp-letsencrypt \ + "HubDNS could not publish the managed hostname before TLS setup" +fi + +if ! /usr/local/sbin/turnkey-tklapp-letsencrypt; then + logger -t turnkey-tklapp-letsencrypt \ + "Initial certificate request failed; the hourly retry remains enabled" +fi + +exit 0 diff --git a/patches/ec2/overlay/usr/local/sbin/turnkey-tklapp-letsencrypt b/patches/ec2/overlay/usr/local/sbin/turnkey-tklapp-letsencrypt new file mode 100755 index 0000000..b170c4a --- /dev/null +++ b/patches/ec2/overlay/usr/local/sbin/turnkey-tklapp-letsencrypt @@ -0,0 +1,132 @@ +#!/bin/bash + +set -u + +readonly APP="${0##*/}" +readonly RETRY_HOOK="/etc/cron.hourly/turnkey-tklapp-letsencrypt" +readonly RETRY_CRON="/etc/cron.d/turnkey-tklapp-letsencrypt" +readonly HUBDNS_FQDN="/var/lib/hubdns/fqdn" +readonly WRAPPER="/usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper" +readonly SHARE="/usr/share/confconsole/letsencrypt" +readonly DEHYDRATED_ETC="/etc/dehydrated" +readonly CONFIG="$DEHYDRATED_ETC/confconsole.config" +readonly DOMAINS="$DEHYDRATED_ETC/confconsole.domains.txt" +readonly CERT="/etc/ssl/private/cert.pem" + +log() { + logger -t "$APP" -- "$*" + echo "$APP: $*" +} + +disable_retry() { + rm -f -- "$RETRY_HOOK" "$RETRY_CRON" +} + +certificate_is_current() { + local managed_cert="/var/lib/dehydrated/certs/$fqdn/fullchain.pem" + + [[ -s "$managed_cert" ]] || return 1 + openssl x509 -in "$managed_cert" -noout -checkhost "$fqdn" >/dev/null 2>&1 && + openssl x509 -in "$managed_cert" -noout -checkend 2592000 >/dev/null 2>&1 +} + +configure_webmin_chain() { + local chain + local miniserv + + chain="/var/lib/dehydrated/certs/$fqdn/chain.pem" + miniserv="/etc/webmin/miniserv.conf" + [[ -s "$chain" && -f "$miniserv" ]] || return 0 + + if grep -q '^extracas=' "$miniserv"; then + sed -i "s|^extracas=.*|extracas=$chain|" "$miniserv" + else + printf 'extracas=%s\n' "$chain" >> "$miniserv" + fi + systemctl try-restart webmin.service >/dev/null 2>&1 || true +} + +fqdn="${FQDN:-}" +if [[ -z "$fqdn" && -s "$HUBDNS_FQDN" ]]; then + read -r fqdn < "$HUBDNS_FQDN" +fi +fqdn="${fqdn%.}" +fqdn="${fqdn,,}" + +if [[ -z "$fqdn" ]]; then + log "Hub-managed hostname is not available yet; deferring certificate request" + exit 1 +fi + +case "$fqdn" in + *.tklapp.com) ;; + *) + disable_retry + exit 0 + ;; +esac + +if [[ ! "$fqdn" =~ ^[a-z0-9.-]+$ ]]; then + log "Refusing invalid Hub-managed hostname: $fqdn" + disable_retry + exit 0 +fi + +if [[ ! -x "$WRAPPER" ]]; then + log "Confconsole Let's Encrypt client is unavailable" + exit 1 +fi + +if certificate_is_current; then + configure_webmin_chain + disable_retry + exit 0 +fi + +public_ip="$(ec2metadata --public-ipv4 2>/dev/null || true)" +if [[ ! "$public_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + log "Public IPv4 address is not available yet" + exit 1 +fi + +dns_ready= +for _attempt in {1..24}; do + resolved="$(RES_OPTIONS=rotate getent ahostsv4 "$fqdn" 2>/dev/null \ + | awk '{print $1}' | sort -u || true)" + if grep -Fxq -- "$public_ip" <<< "$resolved"; then + dns_ready=y + break + fi + sleep 5 +done + +if [[ -z "$dns_ready" ]]; then + log "$fqdn does not resolve to this instance yet; deferring certificate request" + exit 1 +fi + +install -d -m 0755 "$DEHYDRATED_ETC" +if [[ ! -f "$CONFIG" ]]; then + install -m 0644 "$SHARE/dehydrated-confconsole.config" "$CONFIG" +fi + +domains_tmp="$(mktemp "$DEHYDRATED_ETC/confconsole.domains.txt.XXXXXX")" +printf '%s\n' "$fqdn" > "$domains_tmp" +chmod 0644 "$domains_tmp" +mv -f -- "$domains_tmp" "$DOMAINS" + +if ! "$WRAPPER" --register --challenge http-01 --log-info; then + log "Let's Encrypt certificate request failed" + exit 1 +fi + +if ! certificate_is_current; then + log "Let's Encrypt did not install a current certificate for $fqdn" + exit 1 +fi + +configure_webmin_chain +disable_retry +log "Installed and enabled automatic renewal for $fqdn" + +exit 0 diff --git a/patches/ec2/overlay/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh b/patches/ec2/overlay/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh new file mode 100755 index 0000000..5e98c5c --- /dev/null +++ b/patches/ec2/overlay/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh @@ -0,0 +1,97 @@ +#!/bin/bash -e + +# This dehydrated hook script is packaged with Confconsole. +# It is designed to be used in conjunction with the TurnKey dehydrated-wrapper. +# For more info, please see https://www.turnkeylinux.org/docs/letsencypt + +# HTTP-01 Hook Script + +function hook_log { + default="[$(date "+%F %T")] $(basename "$0"):" + case ${1} in + info) echo "$default INFO: ${2}";; + success) echo "$default SUCCESS: ${2}" >&2;; + fatal) echo "$default FATAL: ${2}" >&2; exit 1;; + esac +} + +for var in HTTP HTTP_BIN HTTP_PID HTTP_LOG TKL_KEYFILE TKL_CERTFILE TKL_COMBINED TKL_DHPARAM; do + eval "z=\$$var" + [[ -z "$z" ]] && hook_log fatal "$var is not set. Exiting..." +done + +function deploy_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + local challenge_url="http://$DOMAIN/.well-known/acme-challenge/$TOKEN_FILENAME" + local public_ip + local ready_count=0 + local token_path="$WELLKNOWN/$TOKEN_FILENAME" + + hook_log info "Deploying challenge for $DOMAIN" + hook_log info "Serving $token_path on http://$DOMAIN/.well-known/acme-challenge/$TOKEN_FILENAME" + + public_ip="$(ec2metadata --public-ipv4 2>/dev/null || true)" + if [[ ! "$public_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + hook_log fatal "Public IPv4 address is unavailable for challenge verification" + fi + + for _attempt in {1..50}; do + if "$HTTP_BIN" --deploy "$token_path" 2>/dev/null; then + break + fi + sleep 0.1 + done + + for _attempt in {1..50}; do + if curl --silent --fail --max-time 1 --noproxy '*' \ + --resolve "$DOMAIN:80:$public_ip" "$challenge_url" \ + | cmp -s - "$token_path"; then + ((ready_count += 1)) + if (( ready_count >= 3 )); then + return 0 + fi + else + ready_count=0 + fi + sleep 0.2 + done + + hook_log fatal "HTTP challenge was not stable on the public instance address" +} + +function clean_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + hook_log info "Clean challenge for $DOMAIN" + $HTTP_BIN --clean "$WELLKNOWN/$TOKEN_FILENAME" +} + +function deploy_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" + + hook_log success "Cert request successful. Writing relevant files for $DOMAIN." + hook_log info "fullchain: $FULLCHAINFILE" + hook_log info "keyfile: $KEYFILE" + cat "$KEYFILE" > "$TKL_KEYFILE" + cat "$FULLCHAINFILE" > "$TKL_CERTFILE" + cat "$TKL_CERTFILE" "$TKL_KEYFILE" "$TKL_DHPARAM" > "$TKL_COMBINED" + hook_log success "Files written/created for $DOMAIN: $TKL_CERTFILE - $TKL_KEYFILE - $TKL_COMBINED." +} + +function unchanged_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" + + hook_log info "cert for $DOMAIN is unchanged - nothing to do" +} + +HANDLER="$1"; shift +case "$HANDLER" in + deploy_challenge) + deploy_challenge "$@";; + clean_challenge) + clean_challenge "$@";; + deploy_cert) + deploy_cert "$@";; + unchanged_cert) + unchanged_cert "$@";; +esac diff --git a/tests/ec2-v19 b/tests/ec2-v19 new file mode 100755 index 0000000..c604dd6 --- /dev/null +++ b/tests/ec2-v19 @@ -0,0 +1,322 @@ +#!/usr/bin/python3 +"""Focused checks for the credential-free v19 EBS population path.""" + +import importlib +import importlib.machinery +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +EC2_ROOT = Path(__file__).resolve().parents[1] / "bin/ec2" +BT_EC2 = Path(__file__).resolve().parents[1] / "bt-ec2" +SSHKEYS_HOOK = ( + Path(__file__).resolve().parents[1] + / "patches/cloud/overlay/usr/lib/inithooks/firstboot.d/40ec2-sshkeys" +) +EC2_PATCH_CONF = Path(__file__).resolve().parents[1] / "patches/ec2/conf" +MARKETPLACE_SSH_POLICY = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/etc/ssh/sshd_config.d" + / "00-turnkey-aws-marketplace.conf" +) +LEGACY_HUB_ROOT_HOOK = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/usr/lib/inithooks/firstboot.d/28ec2-sudoadmin" +) +TLS_HELPER = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/usr/local/sbin/turnkey-tklapp-letsencrypt" +) +TLS_CHALLENGE_HOOK = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/usr/share/confconsole/letsencrypt" + / "dehydrated-confconsole.hook-http-01.sh" +) +TLS_FIRSTBOOT_HOOK = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/usr/lib/inithooks/firstboot.d/82hub-letsencrypt" +) +TLS_RETRY_CRON = ( + Path(__file__).resolve().parents[1] + / "patches/ec2/overlay/etc/cron.d/turnkey-tklapp-letsencrypt" +) +sys.path.insert(0, str(EC2_ROOT)) +sys.modules.setdefault("ec2metadata", mock.Mock()) +ebs_bundle = importlib.import_module("ebs_bundle") +ebs_populate = importlib.import_module("ebs_populate") +sshkeys_loader = importlib.machinery.SourceFileLoader("ec2_sshkeys", str(SSHKEYS_HOOK)) +sshkeys_spec = importlib.util.spec_from_loader("ec2_sshkeys", sshkeys_loader) +sshkeys = importlib.util.module_from_spec(sshkeys_spec) +sshkeys_loader.exec_module(sshkeys) + + +class FakeDevice: + def __init__(self): + self.root_path = "/dev/test" + self.mounted = False + self.calls = [] + + def mkpart(self): + self.calls.append("mkpart") + + def mkfs(self, filesystem): + self.calls.append(("mkfs", filesystem)) + + def mount(self, path): + Path(path).mkdir() + self.mounted = True + self.calls.append(("mount", path)) + + def is_mounted(self): + return self.mounted + + def umount(self): + self.mounted = False + self.calls.append("umount") + + +class PopulateTests(unittest.TestCase): + def test_v19_iso_verification_uses_the_trixie_images_key(self): + config = (EC2_ROOT.parents[1] / "config.example/common.cfg").read_text() + trixie_key = "DEE90D9970AE35D55B7C6C2DC6D254B76A9D9430" + bookworm_key = "26147592087C0EDE42143B637761DEBABBCFBA7C" + self.assertIn(f"export BT_GPGKEY={trixie_key}", config) + self.assertNotIn(bookworm_key, config) + + def test_bt_ec2_can_delegate_aws_orchestration_to_its_caller(self): + command = BT_EC2.read_text() + self.assertIn("--local-device=*", command) + self.assertIn('[[ -b "$local_device" ]]', command) + local_populate = command.index( + '"$BT/bin/ec2/ebs_populate.py" "$rootfs" "$local_device"' + ) + direct_aws = command.index( + '"$BT/bin/ec2/ebs.py" "${ebs_opts[@]}" "$rootfs"' + ) + self.assertLess(local_populate, direct_aws) + + def test_boto3_client_preserves_temporary_credential_token(self): + boto3 = mock.Mock() + credentials = { + "AWS_ACCESS_KEY_ID": "temporary-access-key", + "AWS_SECRET_ACCESS_KEY": "temporary-secret-key", + "AWS_SESSION_TOKEN": "temporary-session-token", + } + with mock.patch.dict(os.environ, credentials, clear=False), \ + mock.patch.dict(sys.modules, {"boto3": boto3}): + ebs_bundle.utils.connect_boto3("us-east-1") + + boto3.client.assert_called_once_with( + "ec2", + region_name="us-east-1", + aws_access_key_id="temporary-access-key", + aws_secret_access_key="temporary-secret-key", + aws_session_token="temporary-session-token", + ) + + def test_ssh_key_follows_the_configured_login_principal(self): + with tempfile.TemporaryDirectory() as temporary: + config = Path(temporary) / "inithooks" + for value, expected in ( + ("true", "admin"), ("TRUE", "admin"), ("false", "root"), + ): + with self.subTest(value=value): + config.write_text(f"SUDOADMIN={value}\n", encoding="utf-8") + self.assertEqual(expected, sshkeys.login_username(config)) + + config.write_text("OTHER=value\n", encoding="utf-8") + self.assertEqual("root", sshkeys.login_username(config)) + + def test_marketplace_ssh_policy_is_key_only_and_non_root(self): + directives = {} + for line in MARKETPLACE_SSH_POLICY.read_text().splitlines(): + key, value = line.split(None, 1) + directives[key] = value + + self.assertEqual("no", directives["PasswordAuthentication"]) + self.assertEqual("no", directives["KbdInteractiveAuthentication"]) + self.assertEqual("no", directives["PermitRootLogin"]) + self.assertEqual("yes", directives["PubkeyAuthentication"]) + + def test_ec2_conversion_keeps_admin_usable_without_a_password_secret(self): + conf = EC2_PATCH_CONF.read_text() + self.assertIn("turnkey-sudoadmin on --disable-setpass", conf) + self.assertIn("usermod --password '*' \"$user\"", conf) + self.assertIn("usermod --expiredate -1 admin", conf) + self.assertNotIn("passwd -l", conf) + self.assertIn("test ! -s /root/.ssh/authorized_keys", conf) + self.assertIn("test ! -s /home/admin/.ssh/authorized_keys", conf) + self.assertIn("grep -qx 'SUDOADMIN=true' /etc/default/inithooks", conf) + self.assertIn("grep -qx 'admin ALL=(ALL) NOPASSWD:ALL'", conf) + self.assertIn('sshd -T -h "$sshd_testdir/host_key"', conf) + self.assertIn("'passwordauthentication no'", conf) + self.assertIn("'permitrootlogin no'", conf) + + def test_hub_launch_does_not_switch_the_ec2_key_back_to_root(self): + self.assertFalse(LEGACY_HUB_ROOT_HOOK.exists()) + + def test_local_command_imports_without_aws_site_packages(self): + completed = subprocess.run( + [sys.executable, "-S", str(EC2_ROOT / "ebs_populate.py"), "--help"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_ec2_patch_installs_reviewed_tklamq_before_signed_hubclient(self): + conf = (EC2_ROOT.parents[1] / "patches/ec2/conf").read_text() + digest = "d972012e6aae81ada59b071ede494f46194db706a6805ce048215b510a168a2d" + source_commit = "85e3d33c2d943e34db2c17e05403ea4a0f756784" + + self.assertIn(digest, conf) + self.assertIn(source_commit, conf) + self.assertIn("https://github.com/turnkeylinux/tklamq/pull/5", conf) + candidate_install = conf.index('install "$tklamq_candidate"') + hubclient_install = conf.index("install hubclient=0.3.0") + self.assertLess(candidate_install, hubclient_install) + self.assertIn('rm -f "$tklamq_candidate"', conf) + self.assertIn("dpkg-query -W", conf) + self.assertIn("/usr/bin/install -d -m 0755 /usr/share/doc/tklamq", conf) + self.assertNotIn("\ninstall -d", conf) + for forbidden in ("trusted=yes", "sources.list", "preferences.d", "GH_TOKEN", "AWS_"): + self.assertNotIn(forbidden, conf) + + hubdns_digest = "4e255c838cbd6c2a6f64041c3a3d088acd3166d935f60aaf41d30d39d95b6352" + self.assertIn(hubdns_digest, conf) + self.assertLess(conf.index('install "$hubdns_candidate"'), hubclient_install) + self.assertIn('rm -f "$hubdns_candidate"', conf) + + def test_tls_automation_is_limited_to_hub_managed_names(self): + helper = TLS_HELPER.read_text() + self.assertIn("*.tklapp.com", helper) + self.assertIn("--register --challenge http-01", helper) + self.assertIn('-checkhost "$fqdn"', helper) + self.assertIn('managed_cert="/var/lib/dehydrated/certs/$fqdn/fullchain.pem"', helper) + self.assertEqual(helper.count('openssl x509 -in "$managed_cert" -noout'), 2) + self.assertIn("/chain.pem", helper) + self.assertIn("disable_retry", helper) + + def test_tls_automation_retries_until_hubdns_assigns_a_name(self): + helper = TLS_HELPER.read_text() + self.assertIn('if [[ -z "$fqdn" ]]; then', helper) + self.assertIn("hostname is not available yet; deferring certificate request", helper) + self.assertIn('RES_OPTIONS=rotate getent ahostsv4 "$fqdn"', helper) + + def test_tls_automation_retries_minutely_until_certificate_is_ready(self): + helper = TLS_HELPER.read_text() + retry = TLS_RETRY_CRON.read_text() + self.assertIn("* * * * * root", retry) + self.assertIn("/usr/local/sbin/turnkey-tklapp-letsencrypt", retry) + self.assertIn("/usr/bin/flock -n /run/turnkey-tklapp-letsencrypt.lock", retry) + self.assertIn('RETRY_CRON="/etc/cron.d/turnkey-tklapp-letsencrypt"', helper) + self.assertIn('rm -f -- "$RETRY_HOOK" "$RETRY_CRON"', helper) + + def test_tls_firstboot_publishes_hubdns_before_requesting_a_certificate(self): + hook = TLS_FIRSTBOOT_HOOK.read_text() + publish = hook.index("systemctl start hubdns.service") + certificate = hook.index("/usr/local/sbin/turnkey-tklapp-letsencrypt") + self.assertLess(publish, certificate) + + def test_tls_challenge_waits_until_the_token_is_served(self): + hook = TLS_CHALLENGE_HOOK.read_text() + self.assertIn('ec2metadata --public-ipv4', hook) + self.assertIn('--resolve "$DOMAIN:80:$public_ip"', hook) + self.assertIn("curl --silent --fail --max-time 1 --noproxy '*'", hook) + self.assertIn('cmp -s - "$token_path"', hook) + self.assertIn("ready_count >= 3", hook) + self.assertIn("HTTP challenge was not stable", hook) + + def test_nvme_partition_path_uses_p_separator(self): + device = ebs_bundle.Device("/dev/nvme1n1") + with mock.patch.object(ebs_bundle.subprocess, "run") as run, \ + mock.patch.object(ebs_bundle.time, "sleep"): + device.mkpart() + self.assertEqual(device.root_path, "/dev/nvme1n1") + self.assertEqual(device.real_path, "/dev/nvme1n1p2") + self.assertEqual(run.call_count, 2) + + def test_populates_and_unmounts_without_aws(self): + with tempfile.TemporaryDirectory() as temporary: + rootfs = str(Path(temporary) / "rootfs") + Path(rootfs).mkdir() + device = FakeDevice() + commands = [] + + def run(command, **kwargs): + commands.append((command, kwargs)) + return mock.Mock(returncode=0) + + with mock.patch.object(ebs_bundle.subprocess, "run", side_effect=run), \ + mock.patch.object(ebs_bundle.utils, "rsync") as rsync: + ebs_bundle.populate(rootfs, device) + + self.assertEqual(device.calls[:2], ["mkpart", ("mkfs", "ext4")]) + self.assertEqual(device.calls[-1], "umount") + rsync.assert_called_once_with(rootfs, rootfs + ".mount") + self.assertIn((["chroot", rootfs + ".mount", "grub-install", "/dev/test"], {"check": True}), commands) + self.assertFalse(Path(rootfs + ".mount").exists()) + + def test_populate_normalizes_filesystem_root_before_chroot(self): + with tempfile.TemporaryDirectory() as temporary: + rootfs = str(Path(temporary) / "rootfs") + Path(rootfs).mkdir(mode=0o700) + device = FakeDevice() + root_modes = [] + + def rsync(source, destination): + Path(destination).chmod(Path(source).stat().st_mode & 0o777) + + def run(command, **kwargs): + if command[:2] == ["chroot", rootfs + ".mount"]: + root_modes.append(Path(rootfs + ".mount").stat().st_mode & 0o777) + return mock.Mock(returncode=0) + + with mock.patch.object(ebs_bundle.subprocess, "run", side_effect=run), \ + mock.patch.object(ebs_bundle.utils, "rsync", side_effect=rsync): + ebs_bundle.populate(rootfs, device) + + self.assertTrue(root_modes) + self.assertEqual(root_modes, [0o755] * len(root_modes)) + + def test_unmounts_after_chroot_failure(self): + with tempfile.TemporaryDirectory() as temporary: + rootfs = str(Path(temporary) / "rootfs") + Path(rootfs).mkdir() + device = FakeDevice() + + def run(command, **kwargs): + if command[:3] == ["chroot", rootfs + ".mount", "grub-install"]: + raise RuntimeError("reproduced grub failure") + return mock.Mock(returncode=0) + + with mock.patch.object(ebs_bundle.subprocess, "run", side_effect=run), \ + mock.patch.object(ebs_bundle.utils, "rsync"): + with self.assertRaisesRegex(RuntimeError, "reproduced grub failure"): + ebs_bundle.populate(rootfs, device) + + self.assertFalse(device.mounted) + self.assertFalse(Path(rootfs + ".mount").exists()) + + def test_command_uses_explicit_local_device(self): + with tempfile.TemporaryDirectory() as temporary: + rootfs = Path(temporary) / "rootfs" + device = Path(temporary) / "device" + rootfs.mkdir() + device.touch() + with mock.patch.object(sys, "argv", ["ebs_populate.py", str(rootfs), str(device)]), \ + mock.patch.object(ebs_populate, "Device") as device_class, \ + mock.patch.object(ebs_populate, "populate") as populate: + ebs_populate.main() + device_class.assert_called_once_with(str(device)) + populate.assert_called_once_with(str(rootfs), device_class.return_value) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test-iso-release-tklbam-runtime.sh b/tests/test-iso-release-tklbam-runtime.sh new file mode 100755 index 0000000..7e6b903 --- /dev/null +++ b/tests/test-iso-release-tklbam-runtime.sh @@ -0,0 +1,269 @@ +#!/bin/bash +set -Eeuo pipefail + +[[ $EUID -eq 0 ]] || { + echo "this fixture must run as root inside LibreSBX" >&2 + exit 1 +} + +repo=$(cd "$(dirname "$0")/.." && pwd) +fixture=$(mktemp -d) +host_pypy=/usr/lib/tklbam-pypy2/bin/pypy +host_parent_created= +host_pypy_created= + +cleanup() { + if [[ -n "$host_pypy_created" ]]; then + rm -f -- "$host_pypy" + fi + if [[ -n "$host_parent_created" ]]; then + rmdir /usr/lib/tklbam-pypy2/bin /usr/lib/tklbam-pypy2 2>/dev/null || true + fi + rm -rf -- "$fixture" +} +trap cleanup EXIT + +[[ ! -e "$host_pypy" ]] || { + echo "$host_pypy already exists in the test sandbox" >&2 + exit 1 +} + +bt=$fixture/buildtasks +profiles=$fixture/profiles +commands=$fixture/bin +log=$fixture/calls +name=turnkey-tkldev-19.0-trixie-amd64 +install -d "$bt/bin" "$bt/config" "$profiles/hooks/tkldev" "$commands" +cp "$repo/bin/iso-release" "$repo/bin/generate-tklbam-profile" "$bt/bin/" +printf 'core profile\n' > "$profiles/core" +printf 'tkldev profile\n' > "$profiles/tkldev" +printf 'profile hook\n' > "$profiles/hooks/tkldev/profile-hook" + +cat > "$bt/config/common.cfg" <<'EOF' +export BT_PROFILES=${TEST_BT_PROFILES:?} +EOF + +for helper in generate-signature generate-manifest generate-buildenv; do + cat > "$bt/bin/$helper" <<'EOF' +#!/bin/bash +case "$(basename "$0")" in + generate-manifest) echo manifest ;; + generate-buildenv) echo buildenv ;; +esac +EOF + chmod 0755 "$bt/bin/$helper" +done + +cat > "$commands/turnkey-version" < "$commands/fab-chroot" <<'EOF' +#!/bin/bash +set -eu + +[[ $# -eq 5 ]] +[[ $1 == -e ]] +[[ $2 == PROFILES_CONF:TKLBAM_LIB_PATH:LD_LIBRARY_PATH ]] +rootfs=$3 +[[ $4 == --script ]] +wrapper=$5 +[[ $PROFILES_CONF == /var/tmp/buildtasks-tklbam-profile.*/profiles ]] +[[ $TKLBAM_LIB_PATH == /usr/lib/tklbam ]] +[[ $LD_LIBRARY_PATH == /usr/lib/tklbam-pypy2/bin ]] +stage=${PROFILES_CONF%/profiles} +guest_profiles=$PROFILES_CONF +guest_generator=$stage/generate-tklbam-profile +guest_output=$stage/output +[[ $wrapper == "$rootfs$stage/run-tklbam-profile" ]] +[[ -x $rootfs/usr/lib/tklbam-pypy2/bin/pypy ]] + +cmp -s "$TEST_PROFILES/core" "$rootfs$guest_profiles/core" +cmp -s "$TEST_PROFILES/tkldev" "$rootfs$guest_profiles/tkldev" +cmp -s "$TEST_PROFILES/hooks/tkldev/profile-hook" \ + "$rootfs$guest_profiles/hooks/tkldev/profile-hook" + +expected=$(mktemp) +mapped=$(mktemp) +cleanup_fab() { + rm -f -- "$expected" "$mapped" +} +trap cleanup_fab EXIT +cat > "$expected" < "$mapped" +chmod 0755 "$mapped" +TEST_ROOTFS=$rootfs "$mapped" + +archive_root=$(mktemp -d) +trap 'rm -rf -- "$archive_root"; cleanup_fab' EXIT +printf 'dirindex\n' > "$archive_root/dirindex" +printf 'dirindex conf\n' > "$archive_root/dirindex.conf" +awk ' + /^Package: / { package = $2 } + /^Status: install ok installed$/ && + package !~ /^(di-live|tkl-installer|live-boot|live-boot-initramfs-tools|live-tools)$/ { + print package + } +' "$rootfs/var/lib/dpkg/status" | sort > "$archive_root/packages" +tar -C "$archive_root" -zcf "$rootfs$guest_output/$TEST_NAME.tar.gz" . +EOF +chmod 0755 "$commands/fab-chroot" + +cat > "$commands/root-pypy" <<'EOF' +#!/bin/bash +set -eu + +guest_generator=$1 +[[ $2 == / ]] +guest_output=$3 +cmp -s "$TEST_GENERATOR" "$TEST_ROOTFS$guest_generator" +printf 'root:%s:%s\n' "$guest_generator" "$guest_output" >> "$TEST_LOG" +if [[ -n ${TEST_CHROOT_FAIL:-} ]]; then + exit "$TEST_CHROOT_FAIL" +fi +EOF +chmod 0755 "$commands/root-pypy" + +cat > "$commands/cp" <<'EOF' +#!/bin/bash +set -eu +if [[ -n ${TEST_COPY_FAIL:-} && " $* " == *'/buildtasks-tklbam-profile.'*'/output/. '* ]]; then + exit "$TEST_COPY_FAIL" +fi +exec /bin/cp "$@" +EOF +chmod 0755 "$commands/cp" + +make_product() { + local case_name=$1 + local product=$fixture/$case_name/product + local rootfs=$product/build/root.sandbox + install -d "$rootfs/usr/lib/tklbam-pypy2/bin" \ + "$rootfs/usr/lib/tklbam" "$rootfs/var/lib/dpkg" "$rootfs/var/tmp" \ + "$fixture/$case_name/output" + install -m 0755 /bin/true "$rootfs/usr/lib/tklbam-pypy2/bin/pypy" + printf 'changelog\n' > "$product/changelog" + printf 'iso\n' > "$product/build/product.iso" + cat > "$rootfs/var/lib/dpkg/status" <<'EOF' +Package: bash +Status: install ok installed + +Package: live-tools +Status: install ok installed + +Package: tkl-installer +Status: install ok installed + +Package: removed-package +Status: deinstall ok config-files +EOF + printf '%s\n' "$product" +} + +run_release() { + local product=$1 + local output=$2 + local chroot_fail=${3:-} + local copy_fail=${4:-} + ( + cd "$product" + export PATH="$commands:$PATH" + export TEST_BT_PROFILES=$profiles + export TEST_CHROOT_FAIL=$chroot_fail + export TEST_COPY_FAIL=$copy_fail + export TEST_GENERATOR=$bt/bin/generate-tklbam-profile + export TEST_LOG=$log + export TEST_NAME=$name + export TEST_PYPY=$commands/root-pypy + export TEST_PROFILES=$profiles + "$bt/bin/iso-release" --no-screens "$output" + ) +} + +install_host_pypy() { + if [[ ! -d /usr/lib/tklbam-pypy2 ]]; then + host_parent_created=yes + fi + install -d /usr/lib/tklbam-pypy2/bin + cat > "$host_pypy" <<'EOF' +#!/bin/bash +set -eu +printf 'host:%s\n' "$*" >> "$TEST_LOG" +[[ $1 == "$TEST_GENERATOR" ]] +archive_root=$(mktemp -d) +trap 'rm -rf -- "$archive_root"' EXIT +printf 'host runtime\n' > "$archive_root/runtime" +tar -C "$archive_root" -zcf "$3/$TEST_NAME.tar.gz" . +EOF + chmod 0755 "$host_pypy" + host_pypy_created=yes +} + +remove_host_pypy() { + rm -f -- "$host_pypy" + host_pypy_created= + if [[ -n "$host_parent_created" ]]; then + rmdir /usr/lib/tklbam-pypy2/bin /usr/lib/tklbam-pypy2 + host_parent_created= + fi +} + +assert_no_stage() { + ! find "$1/build/root.sandbox/var/tmp" -mindepth 1 -maxdepth 1 \ + -name 'buildtasks-tklbam-profile.*' -print -quit | grep -q . +} + +: > "$log" +install_host_pypy +host_product=$(make_product host) +run_release "$host_product" "$fixture/host/output" +grep -q '^host:' "$log" +! grep -q '^root:' "$log" +[[ -f "$fixture/host/output/$name.tklbam/$name.tar.gz" ]] +remove_host_pypy + +: > "$log" +root_product=$(make_product root) +run_release "$root_product" "$fixture/root/output" +grep -q '^root:' "$log" +archive=$fixture/root/output/$name.tklbam/$name.tar.gz +[[ -f "$archive" ]] +tar -tzf "$archive" | grep -qx './dirindex' +tar -tzf "$archive" | grep -qx './dirindex.conf' +tar -tzf "$archive" | grep -qx './packages' +tar -xOzf "$archive" ./packages | grep -qx bash +! tar -xOzf "$archive" ./packages | grep -Eq '^(live-tools|tkl-installer)$' +assert_no_stage "$root_product" + +: > "$log" +failure_product=$(make_product failure) +set +e +run_release "$failure_product" "$fixture/failure/output" 37 +status=$? +set -e +[[ $status -eq 37 ]] +grep -q '^root:' "$log" +[[ ! -e "$fixture/failure/output/$name.tklbam/$name.tar.gz" ]] +assert_no_stage "$failure_product" + +: > "$log" +copy_product=$(make_product copy-failure) +set +e +run_release "$copy_product" "$fixture/copy-failure/output" '' 41 +status=$? +set -e +[[ $status -eq 41 ]] +grep -q '^root:' "$log" +[[ ! -e "$fixture/copy-failure/output/$name.tklbam/$name.tar.gz" ]] +assert_no_stage "$copy_product"