#!/bin/sh # vim:set ts=4: set -eu : ${ALPINE_RELEASE:="3.7"} # not tested against edge : ${APK_TOOLS_URI:="https://github.com/alpinelinux/apk-tools/releases/download/v2.8.0/apk-tools-2.8.0-x86_64-linux.tar.gz"} : ${APK_TOOLS_SHA256:="da21cefd2121e3a6cd4e8742b38118b2a1132aad7f707646ee946a6b32ee6df9"} : ${ALPINE_KEYS:="http://dl-cdn.alpinelinux.org/alpine/v3.7/main/x86_64/alpine-keys-2.1-r1.apk"} : ${ALPINE_KEYS_SHA256:="7b2d1e9a00324c8eee49785dc22355be02534201e77473ba9762027e1a475cc7"} die() { printf '\033[1;31mERROR:\033[0m %s\n' "$@" >&2 # bold red exit 1 } einfo() { printf '\n\033[1;36m> %s\033[0m\n' "$@" >&2 # bold cyan } rc_add() { local target="$1"; shift # target directory local runlevel="$1"; shift # runlevel name local services="$*" # names of services local svc; for svc in $services; do mkdir -p "$target"/etc/runlevels/$runlevel ln -s /etc/init.d/$svc "$target"/etc/runlevels/$runlevel/$svc echo " * service $svc added to runlevel $runlevel" done } wgets() ( local url="$1" # url to fetch local sha256="$2" # expected SHA256 sum of output local dest="$3" # output path and filename wget -T 10 -q -O "$dest" "$url" echo "$sha256 $dest" | sha256sum -c > /dev/null ) validate_block_device() { local dev="$1" # target directory lsblk -P --fs "$dev" >/dev/null 2>&1 || \ die "'$dev' is not a valid block device" if lsblk -P --fs "$dev" | grep -vq 'FSTYPE=""'; then die "Block device '$dev' is not blank" fi } fetch_apk_tools() { local store="$(mktemp -d)" local tarball="$(basename $APK_TOOLS_URI)" wgets "$APK_TOOLS_URI" "$APK_TOOLS_SHA256" "$store/$tarball" tar -C "$store" -xf "$store/$tarball" find "$store" -name apk } make_filesystem() { local device="$1" # target device path local target="$2" # mount target mkfs.ext4 "$device" e2label "$device" / mount "$device" "$target" } setup_repositories() { local target="$1" # target directory mkdir -p "$target"/etc/apk/keys cat > "$target"/etc/apk/repositories <<-EOF http://dl-cdn.alpinelinux.org/alpine/v$ALPINE_RELEASE/main http://dl-cdn.alpinelinux.org/alpine/v$ALPINE_RELEASE/community EOF } # This is mostly a temporary measure because some required packages have not # yet been accepted upstream. This can be removed when the following pull # requests are merged: # # - https://github.com/alpinelinux/aports/pull/2962 # - https://github.com/alpinelinux/aports/pull/2961 setup_staging_repos() { local target="$1" # target directory echo "https://mcrute-build-artifacts.s3.us-west-2.amazonaws.com/alpine-packages/$ALPINE_RELEASE/testing" >> "$target"/etc/apk/repositories cat > "$target"/etc/apk/keys/mcrute-5a3eecec.rsa.pub <<-EOF -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5fW5dyTqgs9Yf93xKn5U cYzY9t//M3TAaiDWH7rFxqBqTGnVGkP9QAGqsbXyoo/JpIalazkOfm/1L+XaK7NI IUD/8KxfrnBW53cc/KOkPcGAga36aTBz/HmLQQvjWcizPxWepjdfvAnRTMV69Oud zaRPGKx8nCRqLy1YFAEXn+zpHRh+OHCzzQFlkJop+2PCXqDFaMWC7+oWwrqFs1i0 CXc4pq5oT6vAQyt6pUwN85sLVxtxXSt5G5ALYzQtaIj7IAR3jGlwU26wOAv5YP7z xn/Z1ebQsPbAl3rw48v2T2ohPEX2TUtUq4OuwOG+z1pi3woIGOlOFVAP3k6lm8Z9 9QIDAQAB -----END PUBLIC KEY----- EOF } fetch_keys() { local target="$1" local tmp="$(mktemp -d)" wgets "$ALPINE_KEYS" "$ALPINE_KEYS_SHA256" "$tmp/alpine-keys.apk" tar -C "$target" -xvf "$tmp"/alpine-keys.apk etc/apk/keys rm -rf "$tmp" } setup_chroot() { local target="$1" mount -t proc none "$target"/proc mount --bind /dev "$target"/dev mount --bind /sys "$target"/sys # Don't want to ship this but it's needed for bootstrap. Will be removed in # the cleanup stage. install -Dm644 /etc/resolv.conf "$target"/etc/resolv.conf } install_core_packages() { local target="$1" # Most from: https://git.alpinelinux.org/cgit/alpine-iso/tree/alpine-virt.packages # # acct - installed by some configurations, so added here # aws-ena-driver-hardened - required for ENA enabled instances # e2fsprogs - required by init scripts to maintain ext4 volumes # linux-hardened - can't use virthardened because it's missing NVME support # mkinitfs - required to build custom initfs # sudo - to allow alpine user to become root, disallow root SSH logins # tiny-ec2-bootstrap - to bootstrap system from EC2 metadata chroot "$target" apk --no-cache add \ acct \ alpine-mirrors \ aws-ena-driver-hardened \ chrony \ e2fsprogs \ linux-hardened \ mkinitfs \ openssh \ sudo \ tiny-ec2-bootstrap \ tzdata chroot "$target" apk --no-cache add --no-scripts syslinux # Disable starting getty for physical ttys because they're all inaccessible # anyhow. With this configuration boot messages will still display in the # EC2 console. sed -Ei '/^tty\d/s/^/#/' /etc/inittab # Make it a little more obvious who is logged in by adding username to the # prompt sed -i "s/^export PS1='/&\\\\u@/" /etc/profile } create_initfs() { local target="$1" # Create ENA feature for mkinitfs # Submitted upstream: https://github.com/alpinelinux/mkinitfs/pull/19 echo "kernel/drivers/net/ethernet/amazon" > \ "$target"/etc/mkinitfs/features.d/ena.modules # Enable ENA and NVME features these don't hurt for any instance and are # hard requirements of the 5 series and i3 series of instances sed -Ei 's/^features="([^"]+)"/features="\1 nvme ena"/' \ "$target"/etc/mkinitfs/mkinitfs.conf chroot "$target" /sbin/mkinitfs $(basename $(find "$target"/lib/modules/* -maxdepth 0)) } setup_extlinux() { local target="$1" # Must use disk labels instead of UUID or devices paths so that this works # across instance familes. UUID works for many instances but breaks on the # NVME ones because EBS volumes are hidden behind NVME devices. # # Enable ext4 because the root device is formatted ext4 # # Shorten timeout because EC2 has no way to interact with instance console # # ttyS0 is the target for EC2s "Get System Log" feature whereas tty0 is the # target for EC2s "Get Instance Screenshot" feature. Enabling the serial # port early in extlinux gives the most complete output in the system log. sed -Ei -e "s|^[# ]*(root)=.*|\1=LABEL=/|" \ -e "s|^[# ]*(default_kernel_opts)=.*|\1=\"console=ttyS0 console=tty0\"|" \ -e "s|^[# ]*(serial_port)=.*|\1=ttyS0|" \ -e "s|^[# ]*(modules)=.*|\1=sd-mod,usb-storage,ext4|" \ -e "s|^[# ]*(default)=.*|\1=hardened|" \ -e "s|^[# ]*(timeout)=.*|\1=1|" \ "$target"/etc/update-extlinux.conf } install_extlinux() { local target="$1" chroot "$target" /sbin/extlinux --install /boot chroot "$target" /sbin/update-extlinux --warn-only } setup_fstab() { local target="$1" cat > "$target"/etc/fstab <<-EOF # LABEL=/ / ext4 defaults,noatime 1 1 EOF } setup_networking() { local target="$1" cat > "$target"/etc/network/interfaces <<-EOF auto lo iface lo inet loopback auto eth0 iface eth0 inet dhcp EOF } enable_services() { local target="$1" rc_add "$target" default sshd chronyd networking tiny-ec2-bootstrap rc_add "$target" sysinit devfs dmesg mdev hwdrivers rc_add "$target" boot modules hwclock swap hostname sysctl bootmisc syslog acpid rc_add "$target" shutdown killprocs savecache mount-ro } create_alpine_user() { local target="$1" # Allow members of the wheel group to sudo without a password. By default # this will only be the alpine user. This allows us to ship an AMI that is # accessible via SSH using the user's configured SSH keys (thanks to # tiny-ec2-bootstrap) but does not allow remote root access which is the # best-practice. sed -i '/%wheel .* NOPASSWD: .*/s/^# //' "$target"/etc/sudoers # There is no real standard ec2 username across AMIs, Amazon uses ec2-user # for their Amazon Linux AMIs but Ubuntu uses ubuntu, Fedora uses fedora, # etc... (see: https://alestic.com/2014/01/ec2-ssh-username/). So our user # and group are alpine because this is Alpine Linux. On instance bootstrap # the user can create whatever users they want and delete this one. chroot "$target" /usr/sbin/addgroup alpine chroot "$target" /usr/sbin/adduser -h /home/alpine -s /bin/sh -G alpine -D alpine chroot "$target" /usr/sbin/addgroup alpine wheel chroot "$target" /usr/bin/passwd -u alpine } configure_ntp() { local target="$1" # EC2 provides an instance-local NTP service syncronized with GPS and # atomic clocks in-region. Prefer this over external NTP hosts when running # in EC2. # # See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/set-time.html sed -i 's/^server .*/server 169.254.169.123/' "$target"/etc/chrony/chrony.conf } cleanup() { local target="$1" # Sweep cruft out of the image that doesn't need to ship or will be # re-generated when the image boots rm -f \ "$target"/var/cache/apk/* \ "$target"/etc/resolv.conf \ "$target"/root/.ash_history \ "$target"/etc/*- umount \ "$target"/dev \ "$target"/proc \ "$target"/sys umount "$target" } main() { [ "$#" -ne 1 ] && { echo "usage: $0 "; exit 1; } device="$1" target="/mnt/target" validate_block_device "$device" [ -d "$target" ] || mkdir "$target" einfo "Fetching static APK tools" apk="$(fetch_apk_tools)" einfo "Creating root filesystem" make_filesystem "$device" "$target" setup_repositories "$target" einfo "Fetching Alpine signing keys" fetch_keys "$target" setup_staging_repos "$target" einfo "Installing base system" $apk add --root "$target" --update-cache --initdb alpine-base setup_chroot "$target" einfo "Installing core packages" install_core_packages "$target" einfo "Configuring and enabling boot loader" create_initfs "$target" setup_extlinux "$target" install_extlinux "$target" einfo "Configuring system" setup_fstab "$target" setup_networking "$target" enable_services "$target" create_alpine_user "$target" configure_ntp "$target" einfo "All done, cleaning up" cleanup "$target" } main "$@"