Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

flatcar-update: Support Flatcar OEM and extension payloads #101

Merged
merged 2 commits into from
Sep 8, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 142 additions & 34 deletions bin/flatcar-update
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/bin/bash
set -euo pipefail

opts=$(getopt --name "$(basename "${0}")" --options 'hV:P:L:M:DFA' \
--longoptions 'help,to-version:,to-payload:,listen-port-1:,listen-port-2:,force-dev-key,force-flatcar-key,disable-afterwards' -- "${@}")
opts=$(getopt --name "$(basename "${0}")" --options 'hV:P:E:L:M:DFA' \
--longoptions 'help,to-version:,to-payload:,extension:,listen-port-1:,listen-port-2:,force-dev-key,force-flatcar-key,disable-afterwards' -- "${@}")
eval set -- "${opts}"

USER_PAYLOAD=
PAYLOAD=
VERSION=
EXTENSIONS=()
FORCE_DEV_KEY=
FORCE_FLATCAR_KEY=
DISABLE_AFTERWARDS=
Expand All @@ -17,20 +18,24 @@ LISTEN_PORT_2=9091
while true; do
case "$1" in
-h|--help)
echo "Usage: $(basename "${0}") --to-version VERSION [--to-payload FILENAME] [--listen-port-1 PORT] [--listen-port-2 PORT] [--force-dev-key|--force-flatcar-key|--disable-afterwards]"
echo "Usage: $(basename "${0}") --to-version VERSION [--to-payload FILENAME [--extension FILENAME]...] [--listen-port-1 PORT] [--listen-port-2 PORT] [--force-dev-key|--force-flatcar-key|--disable-afterwards]"
echo " Updates Flatcar Container Linux through a temporary local update service on localhost."
echo " The update-engine service will be unmasked (to disable updates again use -A)."
echo " The reboot should be done after applying the update, either manually or through your reboot manager (check locksmithd/FLUO)."
echo " An error will be reported if a previously applied update wasn't booted into yet (you may discard it with 'update_engine_client -reset_status')."
echo " Warning: If you jump between channels, delete any GROUP configured in /etc/flatcar/update.conf for the new defaults to apply."
echo "Options:"
echo " -V, --to-version <VERSION> Updates to the version, by default using the matching release from update.release.flatcar-linux.net"
echo " -P, --to-payload <FILENAME> Updates to the given update payload file instead of downloading it"
echo " -D, --force-dev-key Bind-mounts the dev key over /usr/share/update_engine/update-payload-key.pub.pem"
echo " -F, --force-flatcar-key Bind-mounts the Flatcar release key over /usr/share/update_engine/update-payload-key.pub.pem"
echo " -A, --disable-afterwards Writes SERVER=disabled to /etc/flatcar/update.conf when done (this overwrites any custom SERVER)"
echo " -L, --listen-port-1 <PORT> Overwrites standard listen port 9090"
echo " -M, --listen-port-2 <PORT> Overwrites standard listen port 9091"
echo " -V, --to-version <VERSION> Updates to the version, by default using the matching release from update.release.flatcar-linux.net"
echo " -P, --to-payload <FILENAME> Updates to the given Flatcar base update payload file instead of downloading it"
echo " (filename does not matter and internally flatcar_production_update.gz is used)"
echo " -E, --extension <FILENAME> Provides the given extension image as part of the update, required for -P if the system needs an OEM"
echo " or a Flatcar extension, can/must be specified multiple times (filename matters and should end with"
echo " either oem-OEMID.gz or flatcar-NAME.gz)"
echo " -D, --force-dev-key Bind-mounts the dev key over /usr/share/update_engine/update-payload-key.pub.pem"
echo " -F, --force-flatcar-key Bind-mounts the Flatcar release key over /usr/share/update_engine/update-payload-key.pub.pem"
echo " -A, --disable-afterwards Writes SERVER=disabled to /etc/flatcar/update.conf when done (this overwrites any custom SERVER)"
echo " -L, --listen-port-1 <PORT> Overwrites standard listen port 9090"
echo " -M, --listen-port-2 <PORT> Overwrites standard listen port 9091"
echo
echo "Example for updating to the latest Stable release and disabling automatic updates afterwards:"
echo ' VER=$(curl -fsSL https://stable.release.flatcar-linux.net/amd64-usr/current/version.txt | grep FLATCAR_VERSION= | cut -d = -f 2)'
Expand All @@ -49,6 +54,16 @@ while true; do
echo "Error: --to-payload must not have an empty value" > /dev/stderr ; exit 1
fi
;;
-E|--extension)
shift
if [ "$1" = "" ]; then
echo "Error: --extension must not have an empty value" > /dev/stderr ; exit 1
fi
if [[ ! "$(basename -- "$1")" =~ ^(flatcar|oem).*gz$ ]]; then
echo "Error: --extension expects paths to files named oem-OEMID.gz or flatcar-NAME.gz (with possible 'flatcar_test_update-' prefix), found: $1" > /dev/stderr ; exit 1
fi
EXTENSIONS+=("$1")
;;
-L|--listen-port-1)
shift
LISTEN_PORT_1="$1"
Expand All @@ -58,7 +73,7 @@ while true; do
;;
-M|--listen-port-2)
shift
LISTEN_PORT_2="$1"
LISTEN_PORT_2="$1"
if [ "$LISTEN_PORT_2" = "" ]; then
echo "Error: --listen-port-2 must not have an empty value" > /dev/stderr ; exit 1
fi
Expand All @@ -81,6 +96,14 @@ while true; do
shift
done

if [ "$#" != 0 ]; then
echo "Error: unexpected extra argumuents: $*" > /dev/stderr ; exit 1
fi

if [ "$PAYLOAD" = "" ] && [ "${#EXTENSIONS[@]}" != 0 ]; then
echo "Error: local extensions are only supported with --to-payload" > /dev/stderr ; exit 1
fi

if [ "${VERSION}" = "" ]; then
echo "Error: must specify --to-version" > /dev/stderr ; exit 1
fi
Expand All @@ -89,6 +112,27 @@ if [ "${FORCE_DEV_KEY}" = "1" ] && [ "${FORCE_FLATCAR_KEY}" = "1" ]; then
echo "Error: must only specify one of --force-dev-key or --force-flatcar-key" > /dev/stderr ; exit 1
fi

# Use the old mount point for compatibility with old instances, where the script gets copied to
OEMID=$({ grep -m 1 -o "^ID=.*" /usr/share/oem/oem-release 2> /dev/null || true ; } | cut -d = -f 2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
OEMID=$({ grep -m 1 -o "^ID=.*" /usr/share/oem/oem-release 2> /dev/null || true ; } | cut -d = -f 2)
OEMID=$({ grep -m 1 -o "^ID=.*" /usr/share/oem/oem-release 2> /dev/null || true ; } | cut -d = -f 2-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delimiter is = and not supposed to be part of the OEMID, or?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope so. :) Another option here is (if you want):

Suggested change
OEMID=$({ grep -m 1 -o "^ID=.*" /usr/share/oem/oem-release 2> /dev/null || true ; } | cut -d = -f 2)
OEMID=$(source /usr/share/oem/oem-release || :; echo "${ID:-}")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, since this is used at other places, I think we should change it everywhere in a follow-up

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested it and it breaks the main script execution if oem-release would have an invalid syntax.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What works is this here: $(sh -c "source /usr/share/oem/oem-release" || :; echo "${ID:-}")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested it and it breaks the main script execution if oem-release would have an invalid syntax.

Alright, let's have a solution that works. I'll have a look at making sure that this file is valid for sourcing.

Copy link
Member Author

@pothos pothos Sep 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure it's valid as we ship it but if it's broken due to invalid user changes I wanted the script not to crash, hence the sh -c workaround (since it's a subshell, || : won't be needed then).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, no, my example is broken because the echo should be part of the sh -c

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here works ID=$(sh -c 'source /usr/share/oem/oem-release; echo "${ID:-}"' 2>/dev/null || true)


# Determine what to download from release server if no local payload is given.
# Using /usr/share/flatcar/oems/ from the currently running version means the download is only best-effort
# to prevent a later fallback download when updating old instances that aren't fully migrated yet
if [ "${OEMID}" != "" ] && { [ -e "/usr/share/flatcar/oems/${OEMID}" ] || [ -e "/usr/share/oem/sysext/active-oem-${OEMID}" ]; }; then
if [ "$PAYLOAD" = "" ]; then
EXTENSIONS+=("/var/tmp/flatcar-update/oem-${OEMID}.gz")
elif ! echo " ${EXTENSIONS[*]} " | grep -q -P "[ /](flatcar_test_update-)?oem-${OEMID}.gz "; then # Surrounded with space to only match base name
echo "Error: system requires '${OEMID}' OEM extension but not passed in --extension" > /dev/stderr ; exit 1
fi
fi
for NAME in $(grep -h -o '^[^#]*' /etc/flatcar/enabled-sysext.conf /usr/share/flatcar/enabled-sysext.conf 2> /dev/null | grep -v -x -f <(grep '^-' /etc/flatcar/enabled-sysext.conf 2> /dev/null | cut -d - -f 2-) | grep -v -P '^(-).*'); do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You loooooove grep, don't you? :) (grep | grep <(grep) | grep).

On a serious note - a comment would be nice here. As I understand it, it gets a list of enabled/disabled sysexts from /etc and /usr, then filters out disabled sysexts (prefixed with -) based on /etc, and drops the disabled sysexts again. Would the following work too?

Suggested change
for NAME in $(grep -h -o '^[^#]*' /etc/flatcar/enabled-sysext.conf /usr/share/flatcar/enabled-sysext.conf 2> /dev/null | grep -v -x -f <(grep '^-' /etc/flatcar/enabled-sysext.conf 2> /dev/null | cut -d - -f 2-) | grep -v -P '^(-).*'); do
for NAME in $(grep -h -v '^[^#-]' /etc/flatcar/enabled-sysext.conf /usr/share/flatcar/enabled-sysext.conf 2> /dev/null | grep -v -x -f <(grep '^-' /etc/flatcar/enabled-sysext.conf 2> /dev/null | cut -d - -f 2-)); do

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion assumes that comments may happen only in the beginning of the line, not after the extension name. I'd say it's fair as I don't see a point in complicating the file format too much.

if [ "$PAYLOAD" = "" ]; then
EXTENSIONS+=("/var/tmp/flatcar-update/flatcar-${NAME}.gz")
elif ! echo " ${EXTENSIONS[*]} " | grep -q -P "[ /](flatcar_test_update-)?flatcar-${NAME}.gz "; then
echo "Error: system requires '${NAME}' Flatcar extension but not passed in --extension" > /dev/stderr ; exit 1
fi
done

[ "$EUID" = "0" ] || { echo "Need to be root: sudo $0 $opts" > /dev/stderr ; exit 1 ; }

if mount | grep -q /usr/share/update_engine/update-payload-key.pub.pem; then
Expand Down Expand Up @@ -131,41 +175,104 @@ if [ "$BOARD" = "" ]; then
echo "Error: could not find board from /usr/share/coreos/release" > /dev/stderr ; exit 1
fi

SHA256_TO_CHECK=
mkdir -p "/var/tmp/flatcar-update"
if [ "$PAYLOAD" = "" ]; then
PAYLOAD="/var/tmp/update_payload"
rm -f "$PAYLOAD"
echo "Downloading update payload..."
curl -fsSL -o "$PAYLOAD" --retry-delay 1 --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 "https://update.release.flatcar-linux.net/${BOARD}/${VERSION}/flatcar_production_update.gz"
SHA256_TO_CHECK=$(curl -fsSL --retry-delay 1 --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 "https://update.release.flatcar-linux.net/${BOARD}/${VERSION}/flatcar_production_update.gz.sha256" | cut -d " " -f 1)
if [ "${SHA256_TO_CHECK}" = "" ]; then
echo "Error: could not download sha256 checksum file" > /dev/stderr ; exit 1
fi
SHA256_HEX=$(sha256sum -b "$PAYLOAD" | cut -d " " -f 1)
if [ "${SHA256_TO_CHECK}" != "${SHA256_HEX}" ]; then
echo "Error: mismatch with downloaded SHA256 checksum (${SHA256_TO_CHECK})" > /dev/stderr ; exit 1
fi
echo "When restarting after an error you may reuse it with '--to-payload $PAYLOAD'"
echo "Downloading update payloads..."
PAYLOAD="/var/tmp/flatcar-update/flatcar_production_update.gz"
for DOWNLOAD_FILE in "$PAYLOAD" "${EXTENSIONS[@]}"; do
rm -f "${DOWNLOAD_FILE}"
BASEFILENAME="$(basename -- "${DOWNLOAD_FILE}")"
curl -fsSL -o "${DOWNLOAD_FILE}" --retry-delay 1 --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 "https://update.release.flatcar-linux.net/${BOARD}/${VERSION}/${BASEFILENAME}"
SHA256_TO_CHECK=$(curl -fsSL --retry-delay 1 --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 "https://update.release.flatcar-linux.net/${BOARD}/${VERSION}/${BASEFILENAME}.sha256" | cut -d " " -f 1)
if [ "${SHA256_TO_CHECK}" = "" ]; then
echo "Error: could not download sha256 checksum file" > /dev/stderr ; exit 1
fi
SHA256_HEX=$(sha256sum -b "${DOWNLOAD_FILE}" | cut -d " " -f 1)
if [ "${SHA256_TO_CHECK}" != "${SHA256_HEX}" ]; then
echo "Error: mismatch with downloaded SHA256 checksum (${SHA256_TO_CHECK})" > /dev/stderr ; exit 1
fi
done
echo "When restarting after an error you may reuse them with '--to-payload $PAYLOAD --extension ${EXTENSIONS[*]}' (add --extension for before each extension)"
else
for DOWNLOAD_FILE in "$PAYLOAD" "${EXTENSIONS[@]}"; do
BASEFILENAME="$(basename -- "${DOWNLOAD_FILE}" | sed 's/flatcar_test_update-//g')"
if [ "${DOWNLOAD_FILE}" = "${PAYLOAD}" ]; then
BASEFILENAME="flatcar_production_update.gz"
fi
# The user may pass in the cached files on error
if [ "${DOWNLOAD_FILE}" != "/var/tmp/flatcar-update/${BASEFILENAME}" ]; then
ln -fs "$(readlink -f "${DOWNLOAD_FILE}")" "/var/tmp/flatcar-update/${BASEFILENAME}"
fi
done
fi

BASE="http://localhost:${LISTEN_PORT_2}/"
HASH=$(openssl dgst -binary -sha1 < "$PAYLOAD" | base64)
SHA256=$(openssl dgst -binary -sha256 < "$PAYLOAD" | base64)
SIZE=$(stat --printf='%s\n' "$PAYLOAD")

rm -f /tmp/response
tee /tmp/response > /dev/null <<-EOF
<response protocol="3.0" server="flatcar-update"><daystart elapsed_seconds="0"></daystart>
<app appid="{e96281a6-d1af-4bde-9a0a-97b76e56dc57}" status="ok"><ping status="ok"></ping>
<updatecheck status="ok"><urls><url codebase="${BASE}"></url></urls>
<manifest version="${VERSION}"><packages><package name="flatcar_production_update.gz" hash="${HASH}" size="${SIZE}" required="true"></package></packages>
<manifest version="${VERSION}">
<packages>
EOF


for DOWNLOAD_FILE in "$PAYLOAD" "${EXTENSIONS[@]}"; do
HASH=$(openssl dgst -binary -sha1 < "${DOWNLOAD_FILE}" | base64)
SIZE=$(stat -L --printf='%s\n' "${DOWNLOAD_FILE}")
BASEFILENAME="$(basename -- "${DOWNLOAD_FILE}" | sed 's/flatcar_test_update-//g')"
REQUIRED="false"
if [ "${DOWNLOAD_FILE}" = "${PAYLOAD}" ]; then
# In case a local payload is given we have to use the correct name
BASEFILENAME="flatcar_production_update.gz"
REQUIRED="true"
fi
tee -a /tmp/response > /dev/null <<-EOF
<package name="${BASEFILENAME}" hash="${HASH}" size="${SIZE}" required="${REQUIRED}"></package>
EOF
done

SHA256=$(openssl dgst -binary -sha256 < "$PAYLOAD" | base64)
tee -a /tmp/response > /dev/null <<-EOF
</packages>
<actions><action event="postinstall" sha256="${SHA256}" DisablePayloadBackoff="true"></action></actions></manifest>
</updatecheck><event status="ok"></event></app></response>
EOF

ncat --keep-open -c "echo -en 'HTTP/1.1 200 OK\ncontent-type: application/gzip\ncontent-length: $SIZE\n\n'; cat \"$PAYLOAD\"" -l "$LISTEN_PORT_2" &
true > /tmp/payload-server-pids
trap "umount /usr/share/update_engine/update-payload-key.pub.pem 2> /dev/null || true; rm -f /tmp/response /tmp/payload-server ; cat /tmp/payload-server-pids | xargs -r kill ; rm -f /tmp/payload-server-pids" EXIT INT
ncat --keep-open -c "echo -en 'HTTP/1.1 200 OK\ncontent-type: text/xml\ncontent-length: $(stat --printf='%s\n' /tmp/response)\n\n'; cat /tmp/response" -l "$LISTEN_PORT_1" &
trap "umount /usr/share/update_engine/update-payload-key.pub.pem 2> /dev/null || true; rm -f /tmp/response ; kill 0" EXIT INT
CHILDPID="$!"
echo "${CHILDPID}" >> /tmp/payload-server-pids


# Helper script because inline quoting is insane
tee /tmp/payload-server > /dev/null <<'EOF'
#!/bin/bash
set -euo pipefail
SERVE="$1"
TYPE="$2"
read -a WORDS
if [ "${#WORDS[@]}" != 3 ] || [ "${WORDS[0]}" != "GET" ]; then
echo -ne "HTTP/1.1 400 Bad request\r\n\r\n"; exit 0
fi
# Subfolders are not supported for security reasons as this avoids having to deal with ../../ attacks
FILE="${SERVE}/$(basename -- "${WORDS[1]}")"
if [ -d "${FILE}" ] || [ ! -e "${FILE}" ]; then
echo -ne "HTTP/1.1 404 Not found\r\n\r\n" ; exit 0
fi
echo -ne "HTTP/1.1 200 OK\r\n"
echo -ne "Content-Type: ${TYPE};\r\n"
LEN=$(stat -L --printf='%s\n' "${FILE}")
echo -ne "Content-Length: ${LEN}\r\n"
echo -ne "\r\n"
cat "${FILE}"
EOF

chmod +x /tmp/payload-server
socat TCP-LISTEN:"${LISTEN_PORT_2}",reuseaddr,fork SYSTEM:'/tmp/payload-server /var/tmp/flatcar-update/ application/gzip' &
CHILDPID="$!"
echo "${CHILDPID}" >> /tmp/payload-server-pids

if [ "${FORCE_DEV_KEY}" = "1" ] || [ "${FORCE_FLATCAR_KEY}" = "1" ]; then
rm -f /tmp/key
Expand Down Expand Up @@ -198,8 +305,9 @@ if [ "$STATUS" = "" ]; then
fi

if [ "${USER_PAYLOAD}" = "" ]; then
echo "Removing payload $PAYLOAD"
rm -f "$PAYLOAD"
echo "Removing payload $PAYLOAD ${EXTENSIONS[*]}"
fi
# For the case that user payloads were given, this only removes the symlinks
rm -rf "/var/tmp/flatcar-update"

echo "Done, please make sure to reboot either manually or through your reboot manager (check locksmithd/FLUO)"