#!/bin/sh # Sets up dbe-darts on this computer as the systemd service dbe-darts, or updates it to the newest # version and repairs it (README.txt). The download address serves it at its root: # # curl -fsSL | sh installs, updates or repairs # curl -fsSL | sh -s remove stops and removes the service; the data stay # # Run it as the user who plays, who needs sudo for the service file and systemctl; started with # sudo, it takes SUDO_USER as that user. The board lives in ~/dbe-darts of that user: # # start.sh the start script; the service runs it # current -> versions/ # versions// dartsctl, start.sh, README.txt, VERSION # update/ trial, failed, downloads in progress # data/ the board's data, which this script never touches # # Besides these, it writes only /etc/systemd/system/dbe-darts.service, and it leaves any other # file in the folder alone. Run again, it installs the newest version without a trial run. # # POSIX sh, as sh is dash on Raspberry Pi OS and Debian. The whole body is the function main, # called on the last line, so that a download cut off halfway runs nothing. It never takes input # from its standard input, which is the script itself; sudo asks for its password on the # terminal. scripts/board-assemble.sh fills in the download address and the public key that the # releases are signed with. fail() { printf '%s\n' "$*" >&2 exit 1 } usage() { printf '%s\n' "usage: curl -fsSL $download_url | sh, or curl -fsSL $download_url | sh -s remove" >&2 exit 2 } # The public key that the releases are signed with, as PEM. public_key() { cat << 'PEM' -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAAZI88lqjuPGDe5azEa2YqD2XOrNithilUtJkrz1t9NM= -----END PUBLIC KEY----- PEM } # Runs a command as the user who plays, through sudo -u when this script runs as root. as_user() { if [ "$root" = yes ]; then sudo -u "$user" -- "$@" else "$@" fi } # Runs a command as root, through sudo unless this script runs as root. as_root() { if [ "$root" = yes ]; then "$@" else sudo -- "$@" fi } # Sets arch to this machine's architecture as uname -m names it. A 32-bit system on a 64-bit # kernel (Raspberry Pi OS 32-bit on a Pi 4 says aarch64) cannot run the aarch64 program, so it is # refused too. check_machine() { arch=$(uname -m) case $arch in x86_64 | aarch64) ;; *) arch= ;; esac if [ -z "$arch" ] || [ "$(getconf LONG_BIT 2> /dev/null || echo 64)" != 64 ]; then fail "this board runs on 64-bit Linux on x86_64 or aarch64 only" fi } # Without systemd running (another init system, a container), there is no service. check_systemd() { if ! command -v systemctl > /dev/null 2>&1 || [ ! -d /run/systemd/system ]; then fail "this needs systemd" fi } # Names every command of its arguments that is missing, in one message. check_commands() { missing= count=0 for name in "$@"; do if ! command -v "$name" > /dev/null 2>&1; then missing="${missing:+$missing, }$name" count=$((count + 1)) fi done case $count in 0) ;; 1) fail "$missing is missing: install it first" ;; *) fail "$missing are missing: install them first" ;; esac } # The signature check needs pkeyutl -rawin, which came with OpenSSL 3.0. check_openssl() { openssl_version=$(openssl version 2> /dev/null || :) case $openssl_version in "OpenSSL "[3-9].* | "OpenSSL "[1-9][0-9].*) ;; *) fail "openssl is too old (${openssl_version:-no version}): this needs OpenSSL 3.0 or newer" ;; esac } # systemd reads escapes (\) and specifiers (%) in the service file's values, and variables ($) and # quotes (" ') in ExecStart=, whose program path must not hold a quote at all; and it trims the # spaces at a value's end. A folder whose path holds any of these, or a control character such as # a line break, is refused rather than escaped. The folder is always /dbe-darts, so the home # folder's path is what holds them. check_path() { case $folder in *[\"\'\\%\$]* | *[[:cntrl:]]* | *[[:space:]]) fail "the path \"$folder\" holds a character that a service file cannot take" \ "(\" ' \\ % \$, a control character or a space at its end): fix the path of the" \ "home folder $home, then run the install line again" ;; esac } # Whether $1 is a version, .. without leading zeros, as the board reads one. is_version() { case $1 in *[!0-9.]*) return 1 ;; esac printf '%s\n' "$1" | grep -Eq '^20[0-9]{2}\.([0-9]|[1-9][0-9])\.(0|[1-9][0-9]*)$' } # Fetches the address $1 into the file $2, as the user who plays. fetch() { as_user curl -fsSL -o "$2" "$1" || fail "cannot fetch $1" } # Removes the download folder, and a version folder still being unpacked: once it is complete, # it has been renamed, and nothing is left under its .part name. cleanup() { if [ -n "${tmp-}" ]; then rm -rf "$tmp" fi if [ -n "${part-}" ]; then rm -rf "$part" fi } # Has sudo check, once and before anything is downloaded or changed, that the user may run # commands as root, asking for the password on the terminal if it needs one. check_sudo() { sudo -v || fail "sudo does not let $user run commands as root, which the service file and" \ "systemctl need: nothing was changed" } # The service file. unit() { cat << EOF [Unit] Description=dbe-darts dart scoring After=network.target [Service] User=$user SupplementaryGroups=video WorkingDirectory=$folder ExecStart="$folder/start.sh" service Restart=always RestartSec=5 TimeoutStopSec=30 [Install] WantedBy=multi-user.target EOF } # The addresses of the board's own page, as the board prints them: the host's name, which a tablet # resolves by mDNS (.local), then its IPv4 addresses for a tablet that cannot. The page opens to # other devices once the board's local network is open, or by a setup link from the platform. print_address() { host=$(uname -n) addresses=$(hostname -I 2> /dev/null) || addresses=$(ip -4 -o address show scope global 2> /dev/null | awk '{ sub("/.*", "", $4); print $4 }') || addresses= others= for address in $addresses; do case $address in *:*) ;; *) others="${others:+$others or }http://$address:8080/" ;; esac done line="http://${host%%.*}.local:8080/" if [ -n "$others" ]; then line="$line — or $others" fi printf '%s\n' "the board's own page: $line" } install_board() { # Emptied before the trap is set: a variable of the same name from the environment must never # reach cleanup's rm -rf. tmp= part= trap cleanup EXIT trap 'exit 1' HUP INT TERM tmp=$(as_user mktemp -d) # The newest version, its manifest and the manifest's signature. version=$(as_user curl -fsSL "$download_url/board/latest") || fail "cannot fetch $download_url/board/latest" is_version "$version" || fail "$download_url/board/latest names no version" release=$download_url/board/$version fetch "$release/manifest.json" "$tmp/manifest.json" fetch "$release/manifest.sig" "$tmp/manifest.sig" public_key | as_user tee "$tmp/key.pem" > /dev/null as_user openssl pkeyutl -verify -pubin -inkey "$tmp/key.pem" -rawin \ -in "$tmp/manifest.json" -sigfile "$tmp/manifest.sig" > /dev/null || fail "the manifest's signature does not match: nothing was installed" # The archive's line in the signed manifest, which scripts/board-assemble.sh writes in a fixed # layout, one line an archive: # "aarch64": { "file": "dbe-darts--aarch64.tar.gz", "sha256": "", "size": }, entry='^ "'"$arch"'": \{1,\}{ "file": "\([^"/]*\)", "sha256": "\([0-9a-f]\{64\}\)", "size": \([0-9][0-9]*\) },\{0,1\}$' file=$(sed -n "s|$entry|\\1|p" "$tmp/manifest.json") sha256=$(sed -n "s|$entry|\\2|p" "$tmp/manifest.json") size=$(sed -n "s|$entry|\\3|p" "$tmp/manifest.json") [ -n "$file" ] || fail "version $version has no package for $arch: nothing was installed" printf '%s\n' "installing dbe-darts $version into $folder" archive=$tmp/archive.tar.gz fetch "$release/$file" "$archive" [ "$(wc -c < "$archive" | tr -d ' ')" = "$size" ] || fail "$file is not of the size that the manifest gives: nothing was installed" [ "$(sha256sum < "$archive" | cut -d ' ' -f 1)" = "$sha256" ] || fail "$file does not match the manifest's SHA-256: nothing was installed" # Unpacked beside the other versions, and put in place once it is complete. If the script # stops before that, cleanup removes the .part folder. versions=$folder/versions part=$versions/$version.part as_user mkdir -p "$versions" "$folder/update" as_user rm -rf "$part" as_user mkdir "$part" as_user tar -xzf "$archive" --no-same-owner -C "$part" || fail "cannot unpack $file: nothing was installed" [ "$(head -n 1 "$part/VERSION" 2> /dev/null | cut -d ' ' -f 1)" = "$version" ] || fail "$file holds no VERSION file of version $version: nothing was installed" as_user chmod 755 "$part/dartsctl" "$part/start.sh" || fail "$file lacks dartsctl or start.sh: nothing was installed" as_user rm -rf "$versions/$version" as_user mv -T "$part" "$versions/$version" # The start script and current, each through a new file renamed over the old one. The # unpacked files reach the disk first: a power cut must not leave current pointing at empty # files. as_user rm -f "$folder/start.sh.tmp" as_user cp "$versions/$version/start.sh" "$folder/start.sh.tmp" as_user chmod 755 "$folder/start.sh.tmp" sync as_user mv -f "$folder/start.sh.tmp" "$folder/start.sh" as_user ln -sfn "versions/$version" "$folder/current.new" as_user mv -T "$folder/current.new" "$folder/current" # Installed by hand, the version runs without a trial, and may be tried again if it failed # one before. as_user rm -f "$folder/update/trial" if [ -f "$folder/update/failed" ]; then pattern=$(printf '%s\n' "$version" | sed 's/\./\\./g') as_user sed -i "/^[[:space:]]*${pattern}[[:space:]]*\$/d" "$folder/update/failed" fi as_user mkdir -p "$folder/data" unit | as_root tee "$unit_file" > /dev/null as_root systemctl daemon-reload as_root systemctl enable --quiet dbe-darts as_root systemctl restart dbe-darts # A program that cannot start fails within a moment, and systemd then waits to restart it. # Asked as root, as the other systemctl calls: a user's systemctl reaches systemd only over # the D-Bus system bus, which a minimal system may lack ("Failed to connect to bus"). sleep 2 as_root systemctl is-active --quiet dbe-darts || fail "the service dbe-darts did not start: \"journalctl -u dbe-darts\" shows why, and the" \ "log is in $folder/data/logs/" printf '%s\n' "dbe-darts $version runs as the service dbe-darts" printf '%s\n' \ "to pair it, hold a pairing code (\"Add board\" on the platform) in front of one of its cameras," \ "or, to use it in the local network, the code \"Open the local network\" (on the platform's start page)" print_address } remove_board() { # Nothing to stop or disable when it is not installed: "not loaded" is not an error here. as_root systemctl disable --now --quiet dbe-darts 2> /dev/null || true as_root rm -f "$unit_file" as_root systemctl daemon-reload printf '%s\n' "the service is removed; $folder and its data stay" } main() { set -eu # The download address, without a slash at its end. download_url='https://get.dbe-darts.com' unit_file=/etc/systemd/system/dbe-darts.service [ $# -le 1 ] || usage case ${1-} in "") action=install ;; remove) action=remove ;; *) usage ;; esac # The user who plays, and the folder in their home. if [ "$(id -u)" -eq 0 ]; then root=yes user=${SUDO_USER-} if [ -z "$user" ] || [ "$user" = root ]; then fail "run it as the user who plays, not as root" fi else root=no user=$(id -un) fi home=$(getent passwd "$user" | cut -d : -f 6) [ -n "$home" ] || fail "the user $user has no home folder" folder=$home/dbe-darts if [ "$action" = remove ]; then check_systemd if [ "$root" = no ]; then check_commands sudo check_sudo fi remove_board else check_machine check_systemd check_commands curl openssl tar sha256sum sudo check_openssl check_path if [ "$root" = no ]; then check_sudo fi install_board fi } main "$@"