diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..900bda8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*~ +*.bak +*.swp +.vscode/ diff --git a/LICENSE.txt b/LICENSE.txt index 9304e54..ab6f63d 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2017-2020 Michael Crute +Copyright (c) 2017-2021 Jake Buchholz Göktürk, Michael Crute 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 diff --git a/Makefile b/Makefile index 394004e..03d2a5c 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,5 @@ PREFIX?=/ .PHONY: install install: - install -Dm 755 tiny-ec2-bootstrap $(PREFIX)/etc/init.d/tiny-ec2-bootstrap + cp -r etc $(PREFIX) + cp -r lib $(PREFIX) diff --git a/README.md b/README.md index 7ddea38..e2beca0 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,125 @@ -# Tiny EC2 Bootstrapper +# Tiny Cloud -This is designed to do the minimal amount of work required to bootstrap an EC2 -instance based on the local settings assigned at boot time as well as the -user's configured settings. This is, in-concept, similar to -[cloud-init](https://cloudinit.readthedocs.io/en/latest/) but trades features -and cloud platform support for small size and limited external dependencies. +The Tiny Cloud bootstrapper performs critical initialization tasks for cloud +instances during their first boot. Unlike the more popular and feature-rich +[cloud-init](https://cloudinit.readthedocs.io/en/latest), Tiny Cloud seeks to +do just what is necessary with a small footprint and minimal dependencies. + +A direct descendant of [tiny-ec2-bootstrap]( +https://gitlab.alpinelinux.org/alpine/cloud/tiny-ec2-bootstrap), Tiny Cloud +works with multiple cloud providers. Currently, the following are supported: +* AWS (Amazon Web Services) +* Azure (Microsoft Azure) +* GCP (Google Cloud Platform) +* OCI (Oracle Cloud Infrastructure) + +## Features + +The following actions will occur ***only once***, during the initial boot of an +instance: +* expand the root filesystem to use all available root device space, during the + **sysinit** runlevel +* set the instance's hostname from instance metadata +* install SSH keys from instance metadata to the cloud user account's + `authorized_keys` file (the user must already exist) +* save the instance user-data to a file and if it's a script, execute it at the + end of the **default** runlevel + +Optional features, which may not be universally necessary: +* manage symlinks from NVMe block devices to `/dev/xvd` and `/dev/sd` devices + (i.e. AWS Nitro instances) +* manage secondary IPv4 and IPv6 addresses on network interfaces ## Requirements -The most important feature of this bootstrapper is the very limited set of -dependencies. In-fact, this works with just BusyBox (provided ash and wget -are built in) and a couple utilities for expanding the root filesystem. -The full list of required dependencies are: +As Tiny Cloud is meant to be tiny, it has very few dependencies: +* Busybox (`ash`, `wget`, etc.) +* `partx` +* `resize2fs` +* `sfdisk` -- bash-like shell (e.g. bash, dash, ash) -- wget -- sfdisk -- partx -- resize2fs +Tiny Cloud has been developed specifically for use with the +[Alpine Cloud Images](https://gitlab.alpinelinux.org/alpine/cloud/alpine-cloud-images) +project, and as such, it is currently tailored for use with [Alpine Linux]( +https://alpinelinux.org), the [OpenRC](https://github.com/OpenRC/openrc) init +system, and the `ext4` root filesystem. If you would like to see Tiny Cloud +supported on additional distributions, init systems, and/or filesystems, please +open an issue with your request -- or better yet, submit a merge request! -## Supported Features and Environments +## Installation -cloud-init has support for many different cloud providers. This project only -supports EC2; [EC2 metadata -service](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) -is a hard requirement of using this bootstrapper. All of the data for the -supported features below is sourced from the EC2 instance metadata service -which runs on every EC2 instance at IP 169.254.169.254. - -cloud-init also has a very rich feature set with support for adding users, -installing packages, and many other things. This bootstrap does not support -those things. Instead it supports: - -- setting system hostname -- installing the instance's SSH keys in the EC2 user's authorized_keys file -- running any script-like user data (must start with #!) -- disabling root and the EC2 user's password -- expanding root partition to available disk space - -These steps only run once. After the initial bootstrap the bootstrapper script -is a no-op. To force the script to run again at boot time remove the file -`/var/lib/cloud/.bootstrap-complete` and reboot the instance. - -The default EC2 user is `alpine`; this can be overriden with a -`/etc/conf.d/tiny-ec2-bootstrap` containing... +Typically, Tiny Cloud is installed and configured when building a cloud image, +and is available on Alpine Linux as the [`tiny-cloud`]( +https://pkgs.alpinelinux.org/packages?name=tiny-cloud) APK... ``` -EC2_USER="otheruser" +apk install tiny-cloud ``` -The EC2 user *must* already exist in the AMI -- `tiny-ec2-bootstrap` will -**NOT** add the user automatically. +This will install the necessary init scripts, libraries, etc. plus any missing +dependencies. -## User Data +Alternately, you can download a release tarball, and use `make` to install it. -User data is provided at instance boot time and can be any arbitrary string of -data. The bootstrapper will consider any user data that begins with the ASCII -characters `#!` to be a script. It will write the entire contents of the user -data to `/var/lib/cloud/user-data.sh`, make the file executable, and execute -the file piping any output to `/var/log/cloud-bootstrap.log`. +Next, enable the three primary init scripts... +``` +rc-update add tiny-cloud-early sysinit +rc-update add tiny-cloud default +rc-update add tiny-cloud-final default +``` -The user data script can do anything it pleases with the instance. It will be -run as root and networking will be up. No other guarantees about system state -are made at the point the script runs. +## Configuration -## Assumptions +Tiny Cloud looks expects configuration to be found at +[`/etc/conf.d/tiny-cloud`](etc/conf.d/tiny-cloud), which documents all +tuneable parameters (and their defaults). -- This was written for Alpine Linux; use on other distributions has not been -tested. +However, because Tiny Cloud does not do auto-detection, you ***must*** set a +value for `CLOUD` indicating which cloud provider which will be used when +instantiating the image. Current valid values are `aws`, `azure`, `gcp`, and +`oci`. -- The script is run by OpenRC. +## Operation + +The first time an instance boots -- either freshly instantiated from an image, +or after installation on an existing instance -- Tiny Cloud sets up the +instance in three phases... + +### Early Phase + +The `tiny-cloud-early` init script does not depend on the cloud provider's +Instance MetaData Service (IMDS), and does therefore does not have a dependency +on networking. During this "early" phase, the root filesystem is expanded, and +any necessary `mdev` rules for device hotplug are set up. + +### Main Phase + +The main `tiny-cloud` init script *does* depend on the cloud provider's IMDS +data, and sets up instance's hostname and the cloud user's SSH keys before +`sshd` starts. + +### Final Phase + +`tiny-cloud-final` should be the very last init script to run in the +**default** runlevel, and saves the instance's user data to +`/var/lib/cloud/user-data`. + +If the user data is a script that starts with `#!` (aka "[shebang]( +https://en.wikipedia.org/wiki/Shebang_(Unix))"), it will be executed; its +output (combined STDOUT and STDERR) is saved to `/var/log/cloud/user-data.log` +and the exit code can be found in `/var/log/cloud/user-data.exit`. + +If all went well, the very last thing `tiny-cloud-final` does is touch +`/var/lib/cloud/.bootstrap-complete` into existence. + +### Further Reboots + +After the initial bootstrap of an instance, the init scripts are largely a +no-op. + +To force the init scripts to re-run on the next boot... +``` +rm -f /var/lib/cloud/.bootstrap-complete +``` +If you're instantiating an instance in order to create a new cloud image +(using [Packer](https://packer.io), or some other means), you will need to +remove this file before creating the image to ensure that instances using the +new image will also run Tiny Cloud init scripts during their first boot. diff --git a/etc/conf.d/tiny-cloud b/etc/conf.d/tiny-cloud new file mode 100644 index 0000000..74b3f26 --- /dev/null +++ b/etc/conf.d/tiny-cloud @@ -0,0 +1,10 @@ +# tiny-cloud configuration + +# REQUIRED: The instance's cloud provider (valid: aws, azure, gcp, oci) +CLOUD= + +# User account where instance SSH keys are installed +#CLOUD_USER=alpine + +# Number of seconds an AWS IMDS token is valid +#IMDS_TOKEN_TTL=5 \ No newline at end of file diff --git a/etc/init.d/tiny-cloud b/etc/init.d/tiny-cloud new file mode 100644 index 0000000..736aff6 --- /dev/null +++ b/etc/init.d/tiny-cloud @@ -0,0 +1,51 @@ +#!/sbin/openrc-run +# vim:set ft=sh noet ts=4: + +description="Tiny Cloud Bootstrap - main phase" + +depend() { + need net + before sshd +} + +_update_hostname() { + local fqdn=$(imds_hostname) + local host="${fqdn%%\.*}" + echo "$host" > /etc/hostname + hostname -F /etc/hostname + echo -e "127.0.1.1\t$fqdn $host" >> /etc/hosts +} + +_set_ssh_keys() { + local user="$1" + local pwent=$(getent passwd "$user") + local group=$(echo "$pwent" | cut -d: -f4) + local ssh_dir="$(echo "$pwent" | cut -d: -f6)/.ssh" + local keys_file="$ssh_dir/authorized_keys" + + if [ ! -d "$ssh_dir" ]; then + mkdir -p "$ssh_dir" + chmod 700 "$ssh_dir" + fi + + touch "$keys_file" + chmod 600 "$keys_file" + chown -R "$user:$group" "$ssh_dir" + imds_ssh_keys > "$keys_file" +} + +start() { + [ -f "/var/lib/cloud/.bootstrap-complete" ] && return 0 + [ -d "/var/lib/cloud" ] || mkdir -p /var/lib/cloud + + source /var/lib/tiny-cloud/imds + CLOUD_USER="${CLOUD_USER:-alpine}" + + ebegin "Setting Instance Hostname"; _update_hostname; eend $? + + ebegin "Installing SSH Keys for $CLOUD_USER User" + _set_ssh_keys "$CLOUD_USER" + eend $? + + # TODO: background tiny-cloud-network watcher? +} \ No newline at end of file diff --git a/etc/init.d/tiny-cloud-early b/etc/init.d/tiny-cloud-early new file mode 100644 index 0000000..184177a --- /dev/null +++ b/etc/init.d/tiny-cloud-early @@ -0,0 +1,36 @@ +#!/sbin/openrc-run +# vim:set ft=sh noet ts=4: + +description="Tiny Cloud Bootstrap - early phase" + +depend() { + before mdev +} + +_expand_root_partition() { + local mountpoint=$(busybox mountpoint -n / | cut -d' ' -f1) + local volume=$(echo "$mountpoint" | + sed -Ee "s/(nvme\d+n\d|(xv|s)d[a-z])p?\d?$/\1/" + ) + + if [ "$mountpoint" != "$volume" ]; then + partition=$(echo "$volume" | sed -Ee "s/.*(\d+)$/\2/") + einfo "Expanding root partition to volume size..." + echo ", +" | sfdisk -q --no-reread -N "$partition" "$volume" + einfo "Updating kernel with new partition table..." + partx -u "$volume" + fi + einfo "Resizing root filesystem..." + resize2fs "$mountpoint" +} + +start() { + [ -f "/var/lib/cloud/.bootstrap-complete" ] && return 0 + [ -d "/var/lib/cloud" ] || mkdir -p /var/lib/cloud + + source /etc/conf.d/tiny-cloud # or use auto-sourced tiny-cloud-early? + + ebegin "Expanding Root Partition"; _expand_root_partition; eend $? + + # TODO: _setup mdev things, if applicable +} \ No newline at end of file diff --git a/etc/init.d/tiny-cloud-final b/etc/init.d/tiny-cloud-final new file mode 100644 index 0000000..9c9a64e --- /dev/null +++ b/etc/init.d/tiny-cloud-final @@ -0,0 +1,37 @@ +#!/sbin/openrc-run +# vim:set ft=sh noet ts=4: + +description="Tiny Cloud Bootstrap - final phase" + +depend() { + after * + provide cloud-final +} + +_save_userdata() { + imds_userdata > "$USERDATA" +} + +_run_userdata() { + local log="/var/log/user-data.log" + local exit="/var/log/user-data.exit" + + chmod +x "$USERDATA" + { "$USERDATA" 2>& 1; echo $? > "$exit"; } | tee "$log" + + return $(cat "$exit") +} + +start() { + [ -f "/var/lib/cloud/.bootstrap-complete" ] && return 0 + [ -d "/var/lib/cloud" ] || mkdir -p /var/lib/cloud + + source /lib/tiny-cloud/imds + + USERDATA="/var/lib/cloud/user-data" + + ebegin "Saving Instance UserData"; _save_userdata; eend $? + if head -n1 "$USERDATA" | grep -q '^#!/'; then + ebegin "Executing UserData Script"; _run_userdata; eend $? + fi +} \ No newline at end of file diff --git a/lib/tiny-cloud/aws/imds b/lib/tiny-cloud/aws/imds new file mode 100644 index 0000000..06b8b91 --- /dev/null +++ b/lib/tiny-cloud/aws/imds @@ -0,0 +1,28 @@ +# AWS Instance MetaData Service related variables and functions +# vim:set ft=sh noet ts=4: + +IMDS_HEADER="X-aws-ec2-metadata-token" +IMDS_TOKEN_TTL_HEADER="X-aws-ec2-metadata-token-ttl-seconds" +IMDS_TOKEN_TTL=${IMDS_TOKEN_TTL:-5} +IMDS_URL="http://169.254.169.254/latest" + +IMDS_HOSTNAME="meta-data/hostname" +IMDS_SSH_KEYS="meta-data/public-keys" +IMDS_USERDATA="user-data" + +_imds_token() { + echo -ne "PUT /latest/api/token HTTP/1.0\r\n"\ + "$IMDS_TOKEN_TTL_HEADER: $IMDS_TOKEN_TTL\r\n\r\n" | + nc 169.254.169.254 80 | tail -n 1 +} + +_imds_header() { + echo "$IMDS_HEADER: $(_imds_token)" +} + +# digs deeper than the default +imds_ssh_keys() { + for key in $(imds "$IMDS_SSH_KEYS"); do + imds "$IMDS_SSH_KEYS/${key%=*}/openssh-key" + done | sort -u +} diff --git a/lib/tiny-cloud/azure/imds b/lib/tiny-cloud/azure/imds new file mode 100644 index 0000000..7e63a32 --- /dev/null +++ b/lib/tiny-cloud/azure/imds @@ -0,0 +1,26 @@ +# Azure metadata-related variables and functions +# vim:set ft=sh noet ts=4: + +IMDS_HEADER="Metadata" +IMDS_QUERY="?format=text&api-version=2021-05-01" +IMDS_URL="http://169.254.169.254/metadata/instance" + +IMDS_HOSTNAME="compute/name" +IMDS_SSH_KEYS="compute/publicKeys" +IMDS_USERDATA="compute/userData" + +_imds_header() { + echo "$IMDS_HEADER: true" +} + +# dig deeper than default +imds_ssh_keys() { + for key in $(imds "$IMDS_SSH_KEYS"); do + imds "$IMDS_SSH_KEYS/${key}/keyData" + done | sort -u +} + +# decode userdata value +imds_userdata() { + imds "$IMDS_USERDATA" | base64 -d | _enforce_ending_newline +} \ No newline at end of file diff --git a/lib/tiny-cloud/gcp/imds b/lib/tiny-cloud/gcp/imds new file mode 100644 index 0000000..ea70b3f --- /dev/null +++ b/lib/tiny-cloud/gcp/imds @@ -0,0 +1,24 @@ +# Google Cloud metadata-related variables and functions +# vim:set ft=sh noet ts=4: + +IMDS_HEADER="Metadata-Flavor" +IMDS_URL="http://169.254.169.254/computeMetadata/v1" + +IMDS_HOSTNAME="instance/hostname" +IMDS_SSH_KEYS=" + project/attributes/ssh-keys + instance/attributes/ssh-keys +" +IMDS_USERDATA="instance/attributes/user-data" + +_imds_header() { + echo "$IMDS_HEADER: Google" +} + +# merge project and instance keys +imds_ssh_keys() { + for ssh_keys in $IMDS_SSH_KEYS; do + # ignore errors and strip leading ':' + _imds "$ssh_keys" | cut -d: -f2- | _enforce_ending_newline + done | sort -u +} \ No newline at end of file diff --git a/lib/tiny-cloud/imds b/lib/tiny-cloud/imds new file mode 100644 index 0000000..f8b43b7 --- /dev/null +++ b/lib/tiny-cloud/imds @@ -0,0 +1,60 @@ +# tiny-cloud Instance MetaData Service related functions and variables +# vim:set ft=sh ts=4 noet: + +CONF_DIR="/etc/conf.d" +LIB_DIR="/lib/tiny-cloud" + +### load configuration + +[ -f "$CONF_DIR"/tiny-cloud ] && source "$CONF_DIR"/tiny-cloud + +### configuration defaults + +CLOUD="${CLOUD:-unknown}" +CLOUD_LOGIN="${CLOUD_LOGIN:-alpine}" +if [ ! -d "$LIB_DIR/$CLOUD" ]; then + echo "ERROR: Unknown Cloud '$CLOUD'" >&2 + exit 1 +fi + +IMDS_HEADER= +IMDS_QUERY= +IMDS_URL= + +IMDS_HOSTNAME= +IMDS_SSH_KEYS= +IMDS_USERDATA= + +### default/common functions + +_imds() { + wget --quiet --header "$(_imds_header)" --output-document - \ + "$IMDS_URL/$1/$IMDS_QUERY" +} + +_enforce_ending_newline() { sed '$a\'; } + +imds() { + set -o pipefail + _imds "$1" | _enforce_ending_newline + RV=$? + set +o pipefail + [ $RV != 0 ] && echo "ERROR: $RV" >&2 + return $RV +} + +imds_hostname() { + imds "$IMDS_HOSTNAME" +} + +imds_userdata() { + imds "$IMDS_USERDATA" +} + +imds_ssh_keys() { + imds "$IMDS_SSH_KEYS" +} + +# cloud-specific things (potentially overriding the above) + +[ -f "$LIB_DIR/$CLOUD/metadata" ] && source "$LIB_DIR/$CLOUD"/imds diff --git a/lib/tiny-cloud/oci/imds b/lib/tiny-cloud/oci/imds new file mode 100644 index 0000000..264b634 --- /dev/null +++ b/lib/tiny-cloud/oci/imds @@ -0,0 +1,13 @@ +# OCI metadata-related variables and functions +# vim:set ft=sh noet ts=4: + +IMDS_HEADER="Authorization" +IMDS_URL="http://169.254.169.254/opc/v2" + +IMDS_HOSTNAME="instance/hostname" +IMDS_SSH_KEYS="instance/metadata/ssh_authorized_keys" +IMDS_USERDATA="instance/metadata/userdata" + +_imds_header() { + echo "$IMDS_HEADER: Bearer Oracle" +} diff --git a/tiny-ec2-bootstrap b/tiny-ec2-bootstrap deleted file mode 100644 index 42514e6..0000000 --- a/tiny-ec2-bootstrap +++ /dev/null @@ -1,110 +0,0 @@ -#!/sbin/openrc-run -# vim:set ft=sh noet ts=4: - -description="Provides EC2 cloud bootstrap" - -# override in /etc/conf.d/tiny-ec2-bootstrap -EC2_USER=${EC2_USER:-alpine} -IMDS2_TOKEN_TTL=${IMDS2_TOKEN_TTL:-5} - -depend() { - need net - provide cloud-final -} - -_get_metadata_token() { - echo -ne "PUT /latest/api/token HTTP/1.0\r\nX-aws-ec2-metadata-token-ttl-seconds: $IMDS2_TOKEN_TTL\r\n\r\n" | - nc 169.254.169.254 80 | tail -n 1 -} - -_get_metadata() { - local uri="$1" - wget -qO - --header "X-aws-ec2-metadata-token: $(_get_metadata_token)" \ - "http://169.254.169.254/latest/$uri" 2>/dev/null -} - -_update_hostname() { - local ec2_fqdn="$(_get_metadata meta-data/hostname)" - local short_hostname="${ec2_fqdn%%\.*}" - echo "$short_hostname" > /etc/hostname - hostname -F /etc/hostname - echo -e "127.0.1.1\t$ec2_fqdn $short_hostname" >> /etc/hosts -} - -_set_ssh_keys() { - local user="$1" - local group="$(getent passwd "$user" | cut -d: -f4)" - local ssh_dir="$(getent passwd "$user" | cut -d: -f6)/.ssh" - local keys_file="$ssh_dir/authorized_keys" - - if [ ! -d "$ssh_dir" ]; then - mkdir -p "$ssh_dir" - chmod 755 "$ssh_dir" - fi - - [ -f "$keys_file" ] && rm "$keys_file" - - touch "$keys_file" - chmod 600 "$keys_file" - chown -R "$user:$group" "$ssh_dir" - - for key in $(_get_metadata meta-data/public-keys/); do - _get_metadata "meta-data/public-keys/${key%=*}/openssh-key/" >> "$keys_file" - done -} - -_run_userdata() { - local user_data="$(_get_metadata user-data)" - if printf '%s' "$user_data" | head -n1 | grep -q '^#!/'; then - printf '%s' "$user_data" >/var/lib/cloud/user-data.sh - chmod +x /var/lib/cloud/user-data.sh - - local log_file=/var/log/cloud-bootstrap.log - local ec_file=/var/log/cloud-bootstrap.exit - - { /var/lib/cloud/user-data.sh 2>&1 ; echo $? >"$ec_file"; } | tee "$log_file" - ec=$(cat "$ec_file") - - echo "User Data Script Exit Status: $ec" - return "$ec" - fi -} - -_resize_root_partition() { - local mountpoint="$(busybox mountpoint -n / | cut -d' ' -f1)" - - # mountpoint is the second partition... - if echo "$mountpoint" | cut -d' ' -f1 | grep -qE '/(nvme\d+n\d+p|xvd[a-z]+)2$'; then - local volume="$(echo "$mountpoint" | sed -Ee 's/(nvme\d+n\d+|xvd[a-z]+)p?2/\1/')" - einfo "Expanding root partition to volume size..." - echo ", +" | sfdisk -q --no-reread -N 2 "$volume" - einfo "Updating kernel with new partition table..." - partx -u "$volume" - fi - einfo "Resizing..." - resize2fs "$mountpoint" -} - -_lock_root_account() { - passwd -l root -} - -_disable_password() { - echo "$1:*" | chpasswd -e -} - -start() { - # Don't bootstrap if the host has already been bootstrapped - [ -f "/var/lib/cloud/.bootstrap-complete" ] && return 0 - - [ -d "/var/lib/cloud" ] || mkdir -p /var/lib/cloud - - ebegin "Locking root account"; _lock_root_account; eend $? - ebegin "Disabling $EC2_USER password"; _disable_password "$EC2_USER"; eend $? - ebegin "Expanding root partition"; _resize_root_partition; eend $? - ebegin "Setting ec2 hostname"; _update_hostname; eend $? - ebegin "Setting ec2 user ssh keys"; _set_ssh_keys "$EC2_USER"; eend $? - ebegin "Running ec2 user data script"; _run_userdata; eend $? - - touch "/var/lib/cloud/.bootstrap-complete" -}