#!/bin/sh # Miko Host installer bootstrap (POSIX sh). Environment: prod. # # curl -fsSL https://get.getmiko.app | MIKO_CODE=XXXX-XXXX sh # curl -fsSL https://get.getmiko.app | sh -s -- --dry-run show the checks and the plan, change nothing # # This script checks the server, gets root once (sudo or doas) and downloads the Miko Host release for this # architecture from https://dl.getmiko.app. Nothing is unpacked before the release list is verified: signed by a Miko # release key below (Ed25519, checked with OpenSSL), not expired, not older than what is installed, and the # download has the listed size and sha256. Then the verified installer (miko-host install) shows what it # will change and asks before it changes anything. Everything runs from main "$@" on the last line, so a # cut-off download runs nothing. # The pairing code comes from MIKO_CODE, never from the command line (any local user can read command # lines). It is moved into a variable at once and passed on through pipes only; it is never printed or logged. # MIKO_TZ (the Connect page adds the browser's time zone) sets the update window's zone on a new install. env_name='prod' install_url='https://get.getmiko.app' dl_url='https://dl.getmiko.app' api_url='https://api.getmiko.app' enroll_url='wss://gw.getmiko.app/v1/enroll' support='contact@getmiko.app' channel_default='stable' # Release public keys for this environment, current first (Ed25519, SPKI DER in base64). release_keys='MCowBQYDK2VwAyEAEQAHv8yMcLUwtieEVWnChRxATLTzzXSk7sTmlDItl98= MCowBQYDK2VwAyEAPDfiWoBQY/QpJ72DDXXwAJU0tBgH2R6EWeJKVTxtvgs=' marker='# Miko Host installer bootstrap (POSIX sh). Environment: prod.' # A download cut inside the last line can leave just `main`, which would run without the arguments given. args_given=$# usage="usage: curl -fsSL $install_url | MIKO_CODE=XXXX-XXXX sh -s -- [options] --yes --dry-run --json --channel NAME --profile connect|cloud --image-build --proxy URL --keep-crash-dumps --lock-timeout SECONDS --base-url URL --uninstall [--purge [--delete-user-data] [--confirm-delete=NAME]]" say() { printf '%s\n' "$*" >&2; } now_ms() { s=$(date +%s%N 2>/dev/null); case $s in *N | '') echo $(($(date +%s) * 1000)) ;; *) echo $((s / 1000000)) ;; esac; } # A JSON string body: backslashes, quotes, tabs and newlines escaped, other control characters dropped. js() { printf '%s' "$1" | tr -d '\000-\010\013-\037' | awk 'BEGIN { ORS = "" } { gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); if (NR > 1) print "\\n"; print }' } emit() { [ "$json" = 1 ] && printf '{"v":1,"t":%s,%s}\n' "$(($(now_ms) - t0))" "$1"; return 0; } ok() { say "✓ $1" if [ -n "$3" ]; then emit "\"step\":\"$2\",\"ok\":true,\"detail\":{$3}"; else emit "\"step\":\"$2\",\"ok\":true"; fi case $2 in preflight | verify | download) [ -z "$code" ] || progress "$2" true ;; esac } # Best effort: the page shows how far the install got. Only the nameplate (the code's first half) is sent, on # curl's stdin: a command line would show it to every local user, who could race the pairing with it. progress() { printf '{"nameplate":"%s","step":"%s","ok":%s%s}' "${code%-*}" "$1" "$2" "${3:+,\"error\":\"$3\"}" | ( [ -z "$proxy" ] || export https_proxy="$proxy" curl -fsS -o /dev/null -m 3 --proto =https --tlsv1.2 -H 'Content-Type: application/json' --data-binary @- "$api_url/v1/enroll/progress" ) >/dev/null 2>&1 || true } # fail CODE REASON MESSAGE NEXT: plain words on stderr, one JSON line with --json, exit 1 (E_USAGE: 2). fail() { say "$3" [ -z "$4" ] || say "$4" [ "$json" = 1 ] && printf '{"v":1,"error":"%s",%s"message":"%s","next":"%s","id":"%s","changed":false}\n' \ "$1" "${2:+\"reason\":\"$2\",}" "$(js "$3")" "$(js "$4")" "$run_id" if [ -n "$code" ] && [ "$1" != E_USAGE ]; then progress "$step" false "$1"; fi [ "$1" = E_USAGE ] && exit 2 exit 1 } usage_fail() { fail E_USAGE "" "$1 Nothing was changed." "$usage"; } parse_args() { for a; do [ "$a" = --json ] && json=1; done while [ $# -gt 0 ]; do case $1 in --*=*) a=${1%%=*} v=${1#*=} ;; *) a=$1 v=${2-} ;; esac case $a in --yes | --dry-run | --json | --image-build | --keep-crash-dumps | --uninstall | --purge | --delete-user-data | --help) [ "$a" = "$1" ] || usage_fail "$a takes no value." ;; --channel | --profile | --proxy | --lock-timeout | --base-url | --confirm-delete | --code-fd | --run-id | --started) [ "$a" = "$1" ] && { [ $# -ge 2 ] || usage_fail "$a needs a value."; shift; } ;; *) usage_fail "Unknown option $(printf '%.40s' "$1" | tr -cd 'A-Za-z0-9=._-')." ;; esac case $a in --yes) yes=1 ;; --dry-run) dry=1 ;; --json) json=1 ;; --image-build) image=1 ;; --keep-crash-dumps) dumps=1 ;; --uninstall) uninstall=1 ;; --purge) purge=1 ;; --delete-user-data) wipe=1 ;; --help) say "$usage"; exit 0 ;; --channel) case $v in [a-z0-9]*) ;; *) usage_fail "--channel needs a channel name." ;; esac case $v in *[!a-z0-9-]*) usage_fail "--channel needs a channel name." ;; esac; channel=$v ;; --profile) case $v in connect | cloud) profile=$v ;; *) usage_fail "--profile is connect or cloud." ;; esac ;; --proxy) case $v in http://?* | https://?*) ;; *) usage_fail "--proxy needs an http:// or https:// URL." ;; esac case $v in *@*) usage_fail "--proxy with a user name or password is not supported yet; use a proxy that needs no login." ;; esac case $v in *[!A-Za-z0-9:/._~+\[\]-]*) usage_fail "--proxy has characters a URL cannot have." ;; esac; proxy=$v ;; --lock-timeout) case $v in '' | *[!0-9]* | ?????*) usage_fail "--lock-timeout needs seconds (0-3600)." ;; esac [ "$v" -le 3600 ] || usage_fail "--lock-timeout needs seconds (0-3600)."; lock_timeout=$v ;; --base-url) case $v in https://?*) ;; *) usage_fail "--base-url needs an https:// URL." ;; esac case $v in *[!A-Za-z0-9:/._~%+-]*) usage_fail "--base-url has characters a URL cannot have." ;; esac; base=${v%/} ;; --confirm-delete) case $v in '' | *[!A-Za-z0-9._-]*) usage_fail "--confirm-delete needs the machine's name." ;; esac; confirm=$v ;; --code-fd) [ "$v" = 3 ] || usage_fail "--code-fd is internal."; code_fd=3 ;; --run-id) case $v in ???????? ) ;; *) usage_fail "--run-id is internal." ;; esac case $v in *[!0-9a-f]*) usage_fail "--run-id is internal." ;; esac; run_id=$v quiet=1 ;; --started) case $v in '' | *[!0-9]*) usage_fail "--started is internal." ;; esac; t0=$v ;; esac shift done [ "$purge" = 0 ] && [ "$wipe" = 1 ] && usage_fail "--delete-user-data goes with --purge." [ "$uninstall" = 0 ] && { [ "$purge" = 1 ] || [ -n "$confirm" ]; } && usage_fail "--purge and --confirm-delete go with --uninstall." [ "$profile" = cloud ] && [ "$image" = 0 ] && fail E_PROFILE "" "--profile cloud is only for building Miko's cloud image. Nothing was changed." \ "Leave out --profile; the installer picks the right one for this server." return 0 } # The code: K7QF-9M2D (Crockford base32; I and L read as 1, O as 0). From the root re-run it arrives on fd 3. read_code() { case $tz in *[!A-Za-z0-9_+/-]*) tz= ;; esac [ "${#tz}" -le 64 ] || tz= if [ "$code_fd" = 3 ]; then IFS= read -r code <&3 || :; exec 3<&-; fi [ -n "$code" ] || return 0 code=$(printf '%s' "$code" | tr -d ' \t-' | tr 'abcdefghijklmnopqrstuvwxyz' 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' | tr 'ILO' '110') c='[0123456789ABCDEFGHJKMNPQRSTVWXYZ]' # shellcheck disable=SC2254 case $code in $c$c$c$c$c$c$c$c) code=${code%????}-${code#????} ;; *) code=; usage_fail "MIKO_CODE is not a Miko code (a code looks like K7QF-9M2D). Copy the command from the Connect page again." ;; esac } gb() { awk -v k="$1" 'BEGIN { printf "%.1f", k / 1048576 }'; } first_line() { sed -n "s/^$1=//p" "$F/etc/os-release" | tr -d '"' | head -n 1; } # LOFTE-113-WINDOWS-OPPSTART: WSL 2 runs its own Linux kernel in a lightweight VM (osrelease …-microsoft-standard-WSL2), # so Miko installs there as on any Linux VM, through every check below (systemd as init, cgroup v2, then the Host's own # sandbox self-checks, which roll the install back when Landlock or user namespaces are missing). WSL 1 translates system # calls and has no Linux kernel: 1. Prints 2, 1 or 0 (not WSL). wsl_kind() { wsl_r=$(head -c 128 "$F/proc/sys/kernel/osrelease" 2>/dev/null) case $wsl_r in *[Mm]icrosoft*WSL2* | *[Mm]icrosoft*wsl2*) echo 2 ;; *[Mm]icrosoft* | *WSL*) echo 1 ;; *) echo 0 ;; esac } local_preflight() { step=preflight virt='' os_id=unknown os_ver='' os_name='' if [ -e "$F/.dockerenv" ]; then virt=Docker elif [ -s "$F/run/systemd/container" ]; then virt=$(head -c 32 "$F/run/systemd/container" | tr -cd 'a-z0-9-') elif [ -r "$F/proc/1/environ" ] && tr '\000' '\n' <"$F/proc/1/environ" 2>/dev/null | grep -q '^container='; then virt=$(tr '\000' '\n' <"$F/proc/1/environ" | sed -n 's/^container=//p' | head -n 1 | tr -cd 'a-z0-9-') elif [ -d "$F/proc/vz" ] && [ ! -d "$F/proc/bc" ]; then virt=OpenVZ elif grep -qi microsoft "$F/proc/sys/kernel/osrelease" 2>/dev/null; then virt=WSL elif command -v systemd-detect-virt >/dev/null 2>&1 && v=$(systemd-detect-virt -c 2>/dev/null) && [ "$v" != none ]; then virt=$v fi case $virt in lxc | lxc-libvirt) virt=LXC ;; docker) virt=Docker ;; podman) virt=Podman ;; wsl) virt=WSL ;; esac # systemd names WSL a container ("wsl" in /run/systemd/container); only the kernel tells WSL 2 from WSL 1. A container # found otherwise (Docker in WSL 2 has the WSL 2 kernel too) stays a container. if [ "$virt" = WSL ] || { [ -z "$virt" ] && [ "$(wsl_kind)" != 0 ]; }; then case $(wsl_kind) in 2) virt='' ;; *) fail E_CONTAINER "" "This is WSL 1, which has no Linux kernel of its own. Miko needs WSL 2. Nothing was changed." \ "In Windows, run: wsl --set-version 2 (or install with Miko's Windows installer, install-windows.ps1)." ;; esac fi [ -z "$virt" ] || fail E_CONTAINER "" "This looks like a container (${virt:-unknown}). Install Miko on the VM or server itself. Nothing was changed." \ "Containers can't give Miko file capabilities or its own cgroup. Run the command on the VM or server that hosts this container." [ -r "$F/etc/os-release" ] && os_id=$(first_line ID) os_ver=$(first_line VERSION_ID) os_name=$(first_line PRETTY_NAME | tr -cd 'A-Za-z0-9 ()./_-') case "$os_id $os_ver" in "ubuntu 24.04" | "ubuntu 26.04" | "debian 12" | "debian 13") ;; *) fail E_OS "" "Miko supports Ubuntu 24.04/26.04 and Debian 12/13. This server runs ${os_name:-an unknown system}. Nothing was changed." \ "Upgrade the server (Ubuntu: sudo do-release-upgrade), or use a Miko cloud agent." ;; esac machine=$(uname -m) case $machine in x86_64) arch=x64 ;; aarch64 | arm64) arch=arm64 ;; *) fail E_OS "" "Miko supports x86_64 and arm64 servers. This one is $machine. Nothing was changed." "Use an x86_64 or arm64 server." ;; esac kernel=$(uname -r | sed 's/[^0-9.].*//') [ "$(vcmp "$kernel.0" 5.14.0)" -ge 0 ] 2>/dev/null || fail E_OS "" "Miko needs Linux 5.14 or newer. This server runs $kernel. Nothing was changed." "Upgrade the kernel, or use a newer server image." glibc=$(getconf GNU_LIBC_VERSION 2>/dev/null | sed -n 's/^glibc \([0-9.]*\).*/\1/p') [ "$(vcmp "${glibc:-0.0}.0" 2.36.0)" -ge 0 ] 2>/dev/null || fail E_OS "" "Miko needs glibc 2.36 or newer. This server has ${glibc:-none}. Nothing was changed." "Use Ubuntu 24.04/26.04 or Debian 12/13." [ "$image" = 1 ] || [ "$(head -c 16 "$F/proc/1/comm" 2>/dev/null)" = systemd ] || fail E_SYSTEMD "" \ "Miko needs systemd as the init system. This system starts with $(head -c 16 "$F/proc/1/comm" 2>/dev/null | tr -cd 'A-Za-z0-9._-'). Nothing was changed." "Use a server image that boots with systemd (every supported Ubuntu and Debian image does)." systemd=$(systemctl --version 2>/dev/null | sed -n '1s/^systemd \([0-9]*\).*/\1/p') [ "${systemd:-0}" -ge 252 ] 2>/dev/null || fail E_SYSTEMD "" "Miko needs systemd 252 or newer. This system has ${systemd:-no systemd}. Nothing was changed." "Use Ubuntu 24.04/26.04 or Debian 12/13." if [ "$image" = 0 ]; then grep -q '^[^ ]* /sys/fs/cgroup cgroup2 ' "$F/proc/mounts" 2>/dev/null || fail E_CGROUP "" \ "Miko needs systemd and cgroup v2. This system boots with cgroup v1. Nothing was changed." "Remove systemd.unified_cgroup_hierarchy=0 from the kernel command line and reboot." controllers=" $(cat "$F/sys/fs/cgroup/cgroup.controllers" 2>/dev/null) " case $controllers in *" memory "*" pids "* | *" pids "*" memory "*) ;; *) fail E_CGROUP "" "Miko needs the memory and pids cgroup controllers. This system has:$(printf '%s' "$controllers" | tr -cd 'a-z ' | tr -s ' ' ' ')Nothing was changed." "Enable them on the kernel command line (cgroup_enable=memory) and reboot." ;; esac if [ "$arch" = x64 ]; then flags=" $(grep -m 1 '^flags' "$F/proc/cpuinfo" 2>/dev/null | sed 's/^[^:]*://') " case $flags in *" avx "*) case $flags in *" avx2 "*) ;; *) flags='' ;; esac ;; *) flags='' ;; esac [ -n "$flags" ] || fail E_CPU "" "This CPU has no AVX2, which Claude Code needs on x86_64. Nothing was changed." "Ask your provider for a newer CPU type, or use an arm64 server." fi fi mem=$(sed -n 's/^MemTotal: *\([0-9]*\) kB$/\1/p' "$F/proc/meminfo" 2>/dev/null) disk=$(df -Pk /usr /home 2>/dev/null | awk 'NR > 1 && ($4 < m || m == "") { m = $4 } END { print m + 0 }') [ "${mem:-0}" -ge 1900000 ] || fail E_RESOURCES "" "Miko needs at least 2 GB of memory and 3 GB of free disk. This server has $(gb "${mem:-0}") GB of memory. Nothing was changed." "Use a server with at least 2 GB of memory (4 GB recommended)." [ "$disk" -ge 3145728 ] || fail E_RESOURCES "" "Miko needs at least 2 GB of memory and 3 GB of free disk. This server has $(gb "$disk") GB free. Nothing was changed." "Free some disk space (sudo apt-get clean, old logs), or use a bigger disk." for t in curl openssl tar gzip sha256sum mktemp date; do command -v "$t" >/dev/null 2>&1 && continue case $t in curl) why="downloads Miko" ;; openssl) why="checks Miko's signature" ;; tar | gzip) why="unpacks Miko" ;; *) why="checks the download" ;; esac case $t in sha256sum | mktemp | date) pkg=coreutils ;; *) pkg=$t ;; esac fail E_DEPS "" "This server has no $t, which $why. Nothing was changed." "Install it (sudo apt-get install $pkg) and run the command again." done [ "$quiet" = 1 ] && return 0 [ "$mem" -ge 3800000 ] || say "! $(gb "$mem") GB of memory: Miko runs, but agents are more comfortable with 4 GB or more." [ "$disk" -ge 10485760 ] || say "! $(gb "$disk") GB of free disk: Miko runs, but 10 GB or more is recommended." [ "$os_id $os_ver" != "debian 12" ] || say "! Debian 12 is supported until 2028-06-30. Debian 13 is recommended." ok "$os_name · $machine · systemd $systemd · $(gb "$mem") GB memory · $(gb "$disk") GB free" preflight \ "\"os\":\"$(js "$os_id")\",\"osVersion\":\"$(js "$os_ver")\",\"arch\":\"$machine\",\"systemd\":$systemd" } # vcmp A B: -1, 0 or 1 for x.y.z versions. vcmp() { awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, "."); for (i = 1; i <= 3; i++) { if (x[i] + 0 < y[i] + 0) { print -1; exit } if (x[i] + 0 > y[i] + 0) { print 1; exit } } print 0 }'; } # The script as a file root can run, if this run came from one we trust: a regular file (not a link) of ours or # root's, writable by nobody else, carrying our marker. Root runs a copy of it, checked against its sha256 taken # here (a directory another user may write in could swap the file during the sudo prompt). Otherwise (curl | sh) # root downloads it again. self_file() { case $0 in */* | *.sh) ;; *) return 1 ;; esac [ -f "$0" ] && [ ! -L "$0" ] || return 1 owner=$(stat -c %u -- "$0" 2>/dev/null) && perm=$(stat -c %a -- "$0" 2>/dev/null) || return 1 [ "$owner" = 0 ] || [ "$owner" = "$(id -u)" ] || return 1 case $perm in *[2367]? | *[2367]) return 1 ;; esac [ "$(sed -n 2p -- "$0")" = "$marker" ] || return 1 (cd -P -- "$(dirname -- "$0")" && printf '%s/%s\n' "$(pwd -P)" "$(basename -- "$0")") } # Root once. The code never goes in argv: it goes in on a pipe, or, when there is a terminal, through a FIFO in a # private directory (the root shell moves either to fd 3). With a terminal, sudo gets it as stdin: sudo-rs (Ubuntu # 26.04's sudo) leaves the command's children stopped when they read the terminal while sudo's stdin is a pipe, so # the installer's question would hang. The root run's exit status comes back offset by 10, so a refused sudo is # told apart from a failed install. # shellcheck disable=SC2016 root_run='src=$1 where=$2 proxy=$3 fifo=$4 sum=$5; shift 5 if [ -n "$fifo" ]; then exec 3<"$fifo"; else exec 3<&0; fi; exec /dev/null 2>&1; then su=sudo; elif command -v doas >/dev/null 2>&1; then su=doas else fail E_ROOT "" "Miko needs root once to install. Run as root, or install sudo. Nothing was changed." "Run the command again as root (su -), or install sudo."; fi if file=$(self_file); then src=file where=$file sum=$(sha256sum <"$file" | cut -c 1-64); else src=url where=$install_url sum=''; fi say "Miko needs root once to install; asking $su." emit "\"step\":\"elevate\",\"ok\":true,\"detail\":{\"with\":\"$su\"}" in=/dev/null fifo='' writer='' rc=0 (exec /dev/null && in=/dev/tty if [ -n "$code" ] && [ "$in" = /dev/tty ]; then nospace="Could not create a private directory in /tmp. Nothing was changed." fifo=$(mktemp -d) || fail E_RESOURCES "" "$nospace" "Check that /tmp exists and has free space." trap 'kill "$writer" 2>/dev/null; rm -rf "$fifo"' EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP mkfifo -m 0600 "$fifo/code" || fail E_RESOURCES "" "$nospace" "Check that /tmp exists and has free space." printf '%s\n' "$code" >"$fifo/code" & writer=$! fi set -- "$src" "$where" "$proxy" "${fifo:+$fifo/code}" "$sum" --run-id "$run_id" --started "$t0" "$@" if [ -n "$code" ] && [ -z "$fifo" ]; then printf '%s\n' "$code" | $su /bin/sh -c "$root_run" miko-install "$@" --code-fd 3 || rc=$? elif [ -n "$code" ]; then $su /bin/sh -c "$root_run" miko-install "$@" --code-fd 3 <"$in" || rc=$? else $su /bin/sh -c "$root_run" miko-install "$@" <"$in" || rc=$?; fi case $rc in 10 | 11 | 12) exit $((rc - 10)) ;; 13) fail E_NET "" "Got root, but could not download the installer again from $install_url. Nothing was changed." "Check this server's network, or download the script and run it as root: sudo sh install.sh" ;; 14) fail E_VERIFY changed "The script changed while root was being asked for, so it did not run. Nothing was changed." "Download it again into a directory only you can write to, then run it." ;; esac [ "$rc" -gt 128 ] && exit 1 fail E_ROOT "" "$su did not give root to the installer. Nothing was changed." "Run the command as a user who may use $su, or as root." } fetch() { # URL FILE MAX-BYTES [HEADERS-FILE] curl -fsS --proto =https --tlsv1.2 --retry 2 --connect-timeout 10 -m 600 ${proxy:+--proxy "$proxy"} --max-filesize "$3" \ ${4:+-D "$4"} -o "$2" "$1" 2>"$d/curl.err" } host_of() { h=${1#*://}; h=${h%%/*}; case $h in *:*) echo "$h" ;; *) echo "$h:443" ;; esac; } net_fail() { # URL CURL-STATUS hp=$(host_of "$1") via="end the command with sh -s -- --proxy http://PROXY:PORT instead of sh" case $2 in 63) fail E_VERIFY size "The download is larger than the release list says, so nothing was installed. Try again. If it repeats, email $support with id $run_id." "" ;; 18) fail E_NET "" "The download was cut off. Nothing was installed." "Run the command again; if it repeats, check this server's network, or $via." ;; 22) http=$(sed -n 's/.*returned error: \([0-9][0-9]*\).*/\1/p' "$d/curl.err" 2>/dev/null | head -n 1) fail E_NET "" "${hp%:*} answered HTTP ${http:-with an error}, so nothing was installed. Nothing was changed." "Try again in a few minutes. If it repeats, email $support with id $run_id." ;; esac fail E_NET "" "Can't reach ${hp%:*} on port ${hp##*:}. Miko needs outgoing HTTPS only. Nothing was changed." "Allow outgoing HTTPS to ${hp%:*}, or $via." } verify_fail() { fail E_VERIFY "$1" "$2 Try again. If it repeats, email $support with id $run_id." ""; } # A field of the canonical manifest (one per line, M4_PLAN §5.1); read only after the signature verified. field() { sed -n "s/^ \"$1\": \"\\([^\"]*\\)\",\$/\\1/p" "$d/manifest.json"; } verify_manifest() { step=verify m=$d/manifest.json url=$base/host/$channel/manifest.json fetch "$url" "$m" 65536 "$d/headers" || { s=$?; [ "$s" = 22 ] && [ "$channel_given" = 1 ] && usage_fail "There is no Miko release on channel $channel."; net_fail "$url" "$s"; } fetch "$url.sig" "$m.sig" 1024 || net_fail "$url.sig" $? openssl base64 -d -A -in "$m.sig" -out "$d/sig.bin" 2>/dev/null && [ "$(wc -c <"$d/sig.bin")" -eq 64 ] || verify_fail signature "The release list's signature is malformed, so nothing was installed." verified=0 n=0 for k in $release_keys; do n=$((n + 1)) printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$k" >"$d/key$n.pem" openssl pkeyutl -verify -pubin -inkey "$d/key$n.pem" -rawin -in "$m" -sigfile "$d/sig.bin" >/dev/null 2>&1 && verified=1 done [ "$verified" = 1 ] || verify_fail signature "The download failed its signature check, so nothing was installed." [ "$(sed -n 2p "$m")" = ' "schema": 1,' ] && [ "$(sed -n 3p "$m")" = ' "product": "miko-host",' ] || verify_fail format "The release list is not one this installer reads, so nothing was installed." [ "$(field env)" = "$env_name" ] || verify_fail env "The release list is for another Miko environment ($(field env | tr -cd 'a-z0-9-')), so nothing was installed." [ "$(field channel)" = "$channel" ] || verify_fail channel "The release list is for another channel, so nothing was installed." version=$(field version) issued=$(field issuedAt) expires=$(field expiresAt) case $version in *[!0-9.]* | *..* | '') verify_fail format "The release list names no valid version, so nothing was installed." ;; esac if [ "${#version}" -gt 32 ] || ! printf '%s\n' "$version" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then verify_fail format "The release list names no valid version, so nothing was installed." fi now=$(date -u +%s) from=$(date -u -d "$issued" +%s 2>/dev/null) until=$(date -u -d "$expires" +%s 2>/dev/null) || verify_fail format "The release list has invalid dates, so nothing was installed." [ $((until - from)) -le 1209600 ] && [ "$until" -gt "$from" ] || verify_fail format "The release list claims to be valid for too long, so nothing was installed." if [ "$now" -lt $((from - 300)) ] || [ "$now" -gt $((until + 300)) ]; then server=$(sed -n 's/^[Dd]ate: *//p' "$d/headers" | tr -d '\r' | tail -n 1) server=$(date -u -d "$server" +%s 2>/dev/null) || server=$now off=$((now - server)); [ "$off" -ge 0 ] || off=$((-off)) if [ "$off" -gt 300 ]; then if [ "$off" -ge 5400 ]; then off="$(((off + 1800) / 3600)) h"; else off="$(((off + 30) / 60)) min"; fi fail E_CLOCK "" "The server clock is $off off, so the signature can't be checked. Nothing was changed." "Turn on time sync (sudo timedatectl set-ntp true), then run the command again." fi [ "$now" -gt $((until + 300)) ] && fail E_VERIFY expired "The release list from getmiko.app is out of date, so nothing was installed. Try again in an hour. If it repeats, email $support with id $run_id." "" verify_fail future "The release list is dated in the future, so nothing was installed." fi line=$(sed -n "s/^ \"linux-$arch\": {\"path\": \"\\([a-z0-9./-]*\\)\", \"size\": \\([0-9]*\\), \"sha256\": \"\\([0-9a-f]*\\)\"},\\{0,1\\}\$/\\1 \\2 \\3/p" "$m") apath=${line%% *} rest=${line#* } size=${rest%% *} sha=${rest#* } case $apath in host/*.tar.gz) ;; *) fail E_OS "" "Miko $version has no build for $machine. Nothing was changed." "Use an x86_64 or arm64 server." ;; esac case $apath in *..*) verify_fail format "The release list names an invalid file, so nothing was installed." ;; esac case ${#sha}$size in 64[1-9]*) ;; *) verify_fail format "The release list names an invalid download, so nothing was installed." ;; esac [ "$size" -le 268435456 ] || verify_fail size "The release is larger than any Miko release, so nothing was installed." ok "Release $version ($channel), signed by Miko, valid until ${expires%T*}" verify "\"version\":\"$version\",\"channel\":\"$channel\"" } net_preflight() { step=preflight for url in "https://$(host_of "$enroll_url")/" https://registry.npmjs.org/; do curl -sS -o /dev/null --proto =https --tlsv1.2 --connect-timeout 8 -m 8 ${proxy:+--proxy "$proxy"} "$url" 2>/dev/null || net_fail "$url" 7 done ok "Reached $(host_of "$enroll_url" | sed 's/:443$//') and registry.npmjs.org" network "" } download() { step=download t=$d/release.tar.gz # A connection lost mid-download is curl 18 on older and 56 on newer versions: a partial file tells it apart. fetch "$base/$apath" "$t" "$size" || { s=$?; [ "$s" != 63 ] && [ -s "$t" ] && s=18; net_fail "$base/$apath" "$s"; } [ "$(wc -c <"$t" | tr -d ' ')" = "$size" ] || verify_fail size "The download was cut short or is too long, so nothing was installed." [ "$(sha256sum "$t" | cut -c 1-64)" = "$sha" ] || verify_fail sha256 "The download failed its checksum, so nothing was installed." if ! mkdir "$d/x" || ! tar -xzf "$t" -C "$d/x" --no-same-owner --same-permissions 2>/dev/null; then verify_fail format "The download could not be unpacked, so nothing was installed." fi tree=$d/x/miko-host-$version-linux-$arch if [ "$(cat "$tree/VERSION" 2>/dev/null)" != "$version" ] || [ ! -f "$tree/app/miko-host.mjs" ] || [ ! -x "$tree/node/bin/node" ]; then verify_fail format "The download is not a complete Miko release, so nothing was installed." fi ok "Downloaded Miko $version for $machine ($(awk -v b="$size" 'BEGIN { printf "%.1f", b / 1048576 }') MB), sha256 verified" download "\"version\":\"$version\",\"bytes\":$size" } # Node with the proxy (its fetch follows HTTPS_PROXY only with NODE_USE_ENV_PROXY), for the installer's own posts. node_run() { if [ -n "$proxy" ]; then env NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$proxy" HTTP_PROXY="$proxy" NO_PROXY=localhost,127.0.0.1,::1 "$tree/node/bin/node" --use-system-ca "$@" else "$tree/node/bin/node" --use-system-ca "$@"; fi } # Whether the release's installer knows an option (an installed release may be older than this script). knows() { grep -q -- "$1" "$tree/app/miko-host.mjs" 2>/dev/null; } # The installed release, checked against its own RELEASE.json (releases that cannot check themselves: Node must run). intact() { "$tree/node/bin/node" -e 0 /dev/null 2>&1 || return 1 knows --check-only || return 0 "$tree/node/bin/node" "$tree/app/miko-host.mjs" install --check-only --tree "$tree" /dev/null 2>&1 } # The verified installer in $tree; the code reaches it on stdin only. run_installer() { set -- install --tree "$tree" --manifest "$d/manifest.json" --env "$env_name" --channel "$channel" --base-url "$base" \ --api-url "$api_url" --enroll-url "$enroll_url" --run-id "$run_id" --started "$t0" if knows --install-url; then set -- "$@" --install-url "$install_url"; [ -z "$tz" ] || set -- "$@" --tz "$tz"; fi [ "$yes" = 0 ] || set -- "$@" --yes [ "$dry" = 0 ] || set -- "$@" --dry-run [ "$json" = 0 ] || set -- "$@" --json [ "$image" = 0 ] || set -- "$@" --image-build [ "$dumps" = 0 ] || set -- "$@" --keep-crash-dumps [ -z "$profile" ] || set -- "$@" --profile "$profile" [ -z "$proxy" ] || set -- "$@" --proxy "$proxy" [ -z "$lock_timeout" ] || set -- "$@" --lock-timeout "$lock_timeout" if [ -n "$code" ]; then printf '%s\n' "$code" | node_run "$tree/app/miko-host.mjs" "$@" --code-stdin else node_run "$tree/app/miko-host.mjs" "$@" /dev/null || return 0 if [ "$channel_given" = 1 ]; then node_run "$tree/app/miko-host.mjs" update --channel "$channel" /dev/null 2>&1 || : else printf 'check\n' >"$F/var/lib/miko-update/request" 2>/dev/null && systemctl start --no-block miko-update.service >/dev/null 2>&1 || :; fi say "Miko $version is ready; it installs when no agent is working (sudo miko-host update shows when)." emit "\"step\":\"update\",\"ok\":true,\"detail\":{\"installed\":\"$installed\",\"version\":\"$version\"}" } # A private directory where programs may run (/var/tmp is noexec on hardened servers). private_dir() { for pd in /var/tmp /var/lib; do [ -d "$pd" ] || continue case ",$(findmnt -rno OPTIONS -T "$pd" 2>/dev/null)," in *,noexec,*) continue ;; esac d=$(mktemp -d "$pd/miko-install.XXXXXX") && return 0 done fail E_RESOURCES "" "Could not create a private directory that may run programs in /var/tmp or /var/lib (noexec or full). Nothing was changed." "Free some space, or remount /var/tmp with exec." } # The verified release's uninstaller (for a machine whose own CLI is broken); it asks on /dev/tty. run_uninstaller() { set -- uninstall [ "$purge" = 0 ] || set -- "$@" --purge [ "$wipe" = 0 ] || set -- "$@" --delete-user-data [ -z "$confirm" ] || set -- "$@" "--confirm-delete=$confirm" [ "$yes" = 0 ] || set -- "$@" --yes if [ "$dry" = 1 ]; then say "Dry run: the verified release would now run: miko-host $*" emit "\"step\":\"uninstall\",\"ok\":true,\"detail\":{\"dryRun\":true,\"args\":\"$*\"}" return 0 fi node_run "$tree/app/miko-host.mjs" "$@" /dev/null | tr -cd '0-9.') [ -n "$channel" ] || channel=$(sed -n 's/.*"channel": *"\([a-z0-9-]*\)".*/\1/p' "$F/etc/miko-host/update.json" 2>/dev/null | head -n 1) fi [ -n "$channel" ] || channel=$channel_default private_dir trap '[ -n "${d:-}" ] && rm -rf -- "$d"' EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP verify_manifest if [ "$uninstall" = 1 ]; then download; run_uninstaller; exit; fi [ "$image" = 1 ] || net_preflight # Installed already: the installed release repairs (and pairs) in place, and a newer one is the updater's, at a # quiet point. Forward only: never an older release. A damaged installed release is replaced by the download. if [ -n "$installed" ]; then [ "$channel_given" = 1 ] && [ "$(vcmp "$installed" "$version")" -gt 0 ] && fail E_VERIFY downgrade \ "This server already runs Miko $installed, newer than $version offered here. Nothing was changed." "Run the command without --channel to repair or pair this install." tree=$F/usr/lib/miko-host if intact; then say "Miko $installed is installed; using it." rc=0; run_installer || rc=$? [ "$(vcmp "$installed" "$version")" -ge 0 ] || request_update exit $rc fi say "! The installed Miko $installed is damaged; installing the verified download instead." fi download run_installer } main "$@"