#!/bin/sh # Install Solcis on this host, in one command, without obtaining a download first. # # curl -fsSL https://get.solcis.ai | SOLCIS_PUBLIC_HOST=solcis.example.com sh # # This is the script served at https://get.solcis.ai. It fetches the Solcis deployment package and, before # it unpacks anything where it will run or runs anything at all, checks that the package is signed by the # Solcis release key written into this script (RELEASE_KEY, below) and that every file in it is one that # signature covers. Only then does it unpack the package to /opt/solcis and run the installer inside it, # which checks the package's signatures again before it uses any of it, exactly as it does for a # package obtained by hand. Everything you can pass to that installer, you pass here: export it, or put it # right before `sh`. # # Run it as yourself. It has to run as root, and it asks for sudo itself, keeping the settings you # exported (it names them before it asks). See "HOW IT BECOMES ROOT" below. # # To download the package yourself instead, open https://get.solcis.ai/download in a browser. # # Before it unpacks anything where it will run, it names the Solcis licence and terms and asks you, at your # terminal, to accept them. A run with no terminal (an automated install) accepts them by naming their # version, which the refusal prints: # # curl -fsSL https://get.solcis.ai | SOLCIS_ACCEPT_TERMS= sh # # TWO DOWNLOADS ARRIVE FROM TWO PLACES, and this script only fetches the first. The package is under a # megabyte — the installer, the Compose files, the Helm charts and the signed list of images that belong # with this release. The container images themselves are around a gigabyte and are pulled from the # registry by Docker during the install. Both are public, so an install needs no credential. # # `SOLCIS_REGISTRY_TOKEN` exists for the site that has put either behind its own authenticated mirror: # this script sends it as a bearer token when fetching the package, and the installer signs Docker in to # the registry with it. It is not part of a normal install and the guides do not ask for one. # # AN AIR-GAPPED HOST CANNOT RUN THIS, having nothing to fetch from. Those sites are sent the offline # package, which carries the images inside it, and run its `./install.sh` directly — the same installer # this script would have run. See INSTALL.md in that download. # # WHAT THE RELEASE KEY BELOW IS FOR. Every Solcis release is signed by one long-lived key, and this # script carries the public half of it. The package carries a list of every file in it with its SHA-256 # (SHA256SUMS) and an Ed25519 signature over that list (SHA256SUMS.sig), and this script checks the # signature against THIS copy of the key, never against the copy inside the package: a signature and the # key to check it against travelling together only tell you the two agree with each other. It then checks # every file in the package against the signed list, so the installer it runs is one Solcis signed. # # The check is only as good as the key it uses, and this script arrived from the same place as the # package. So the key is a short string you can read in the script before you run it, and compare against # the fingerprint Solcis sent you separately, in your contract documents: # # curl -fsSL https://get.solcis.ai | grep RELEASE_KEY= # # This script needs OpenSSL 3.0 or later to check the signature, as the installer does. # # HOW IT BECOMES ROOT (GitHub Issue #7791). The guides used to pipe it to sudo with -E, but on a host whose # sudo is sudo-rs (Ubuntu 25.10 and 26.04 by default) `-E` is ignored, and every exported setting was # dropped. So the script raises itself: run by anyone but root, it pipes its own body (the here-document # in solcis_script_body, below) to `sudo -u root --preserve-env= -- /bin/sh -s`, naming exactly # the variables to keep. Root therefore runs the bytes that are already running, with the RELEASE_KEY and # the version in them, and still checks the package against that key before it unpacks anything. It never # fetches the script a second time (root would run a copy nobody read) and never writes it to a file # first (the operator's account could change the file before sudo ran it). Run as root, or started # by hand through sudo, it runs the body directly. The rules for which names are kept, and when to stop # rather than ask sudo, are the installer's own (deploy/compose/lib/elevate.sh); see the carried functions below. # # NOTHING RUNS UNTIL THE LAST LINE. The script defines functions and calls one on its last line, and the # body is a here-document inside one of those functions, so a connection that drops mid-transfer leaves an # incomplete definition the shell refuses to run rather than a half-run install. set -eu # CARRIED FROM deploy/compose/lib/elevate.sh, byte for byte (GitHub Issue #7791). This script runs before # any package is on the host, so it cannot source the library; it carries the three functions that decide # which settings reach root, which sudo is used, and whether a run sudo started by hand is told its settings # did not arrive. deploy/get-solcis-test.sh fails when a function or the proxy list here differs from the # library's, so change them there and copy them here. ELEVATE_PROXY_NAMES='http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY' # The old setting names the installer refuses, carried from the `file` and `env-generic` rows of # deploy/compose/lib/renamed-settings.tsv, which is in the package and not on this host yet. They are kept on the # way to root so that the installer refuses one exported under its old name, as it does when run from the # package, rather than installing with that setting at its default. deploy/get-solcis-test.sh fails when this # list and the file differ, and scripts/check-renamed-settings.sh skips the lines between the two markers, here only. # >>> old setting names, carried from deploy/compose/lib/renamed-settings.tsv ELEVATE_OLD_SETTING_NAMES='API_MEM_LIMIT API_PIDS_LIMIT CADDY_ANALYTICS_BIND_ADDR CADDY_ANALYTICS_HTTPS_PORT CADDY_HTTPS_PORT CADDY_HTTP_PORT CADDY_MEM_LIMIT CADDY_PIDS_LIMIT CONSOLE_MEM_LIMIT CONSOLE_PIDS_LIMIT DB_MEM_LIMIT DB_PIDS_LIMIT DRIFT_DETECTOR_MEM_LIMIT DRIFT_DETECTOR_PIDS_LIMIT KEYCLOAK_BOOTSTRAP_CLIENT_ID KEYCLOAK_BOOTSTRAP_CLIENT_SECRET KEYCLOAK_BREAK_GLASS_ADMIN_EMAIL KEYCLOAK_BREAK_GLASS_ADMIN_GROUP KEYCLOAK_BREAK_GLASS_ADMIN_PASSWORD KEYCLOAK_DB_INIT_MEM_LIMIT KEYCLOAK_DB_NAME KEYCLOAK_DB_PASSWORD KEYCLOAK_DB_USER KEYCLOAK_INVITE_LINK_LIFESPAN_SECONDS KEYCLOAK_MEM_LIMIT KEYCLOAK_PIDS_LIMIT KEYCLOAK_PROVISIONER_CLIENT_ID KEYCLOAK_REALM KEYCLOAK_SETUP_CLIENT_ID KEYCLOAK_SETUP_MEM_LIMIT KEYCLOAK_SMTP_FROM KEYCLOAK_SMTP_FROM_DISPLAY_NAME KEYCLOAK_SMTP_HOST KEYCLOAK_SMTP_PASSWORD KEYCLOAK_SMTP_PORT KEYCLOAK_SMTP_SSL KEYCLOAK_SMTP_STARTTLS KEYCLOAK_SMTP_USER LOG_LEVEL MACHINE_TRUST_PROXY MODEL_SERVER_PIDS_LIMIT OTEL_ENABLED OTEL_MEM_LIMIT OTEL_PIDS_LIMIT POLICY_SAMPLE_SEGMENT_MAX_SECS POLICY_STUDIO SESSION_COOKIE_NAME SESSION_TTL SOLCIS_AUTHORING_API SOLCIS_AUTHORING_FILE_FALLBACK SOLCIS_BACKTEST_INTERVAL SOLCIS_BACKTEST_RUNNER_ENABLED SOLCIS_CADDY_GLOBAL_TLS SOLCIS_CADDY_SITE_TLS SOLCIS_COMPOSE_PREVIEW_MAX_CONCURRENT SOLCIS_DRIFT_SWEEP_INTERVAL SOLCIS_ENGINE_ALLOW_WIDENING SOLCIS_ENGINE_BIND_ADDR SOLCIS_ENGINE_CONTAINER SOLCIS_ENGINE_ENVIRONMENT SOLCIS_ENGINE_ENVIRONMENTS SOLCIS_ENGINE_HOST SOLCIS_ENGINE_MEM_LIMIT SOLCIS_ENGINE_MODEL_ALLOWLIST SOLCIS_ENGINE_PIDS_LIMIT SOLCIS_ENGINE_PORT SOLCIS_ENGINE_PROXY_MEM_LIMIT SOLCIS_ENGINE_PROXY_PIDS_LIMIT SOLCIS_ENGINE_SLUG SOLCIS_EXPORTER_MEM_LIMIT SOLCIS_EXPORTER_PIDS_LIMIT SOLCIS_FLEET_ENABLED SOLCIS_HOSTED_MCP_ENABLED SOLCIS_HOSTED_MCP_POSTURE SOLCIS_LEARNING_ENABLED SOLCIS_LEARNING_SWEEP_INTERVAL SOLCIS_LICENCE SOLCIS_LOCAL_IDP_ADMIN_CLIENT_ID SOLCIS_LOCAL_IDP_ADMIN_CLIENT_SECRET SOLCIS_LOCAL_IDP_SETUP_CLIENT_SECRET SOLCIS_MAX_QUARANTINE_BYTES SOLCIS_PDP_IMAGE SOLCIS_PDP_OUTCOME_REPORTING SOLCIS_PDP_PROXY_IMAGE SOLCIS_PDP_PULL_ENABLED SOLCIS_PDP_SAMPLING SOLCIS_PROMOTION_ATTACH_BINDINGS SUPERSET_ADMIN_EMAIL SUPERSET_ADMIN_PASSWORD SUPERSET_ADMIN_USERNAME SUPERSET_DASHBOARD_ID SUPERSET_DB_MEM_LIMIT SUPERSET_DB_PIDS_LIMIT SUPERSET_EMBEDDED_DASHBOARD_IDS SUPERSET_ENABLE_PROXY_FIX SUPERSET_FORCE_HTTPS SUPERSET_GUEST_TOKEN_JWT_SECRET SUPERSET_GUEST_TOKEN_PASSWORD SUPERSET_GUEST_TOKEN_USERNAME SUPERSET_INTERNAL_URL SUPERSET_MEM_LIMIT SUPERSET_METADATA_DB_PASSWORD SUPERSET_OIDC_CLIENT_ID SUPERSET_OIDC_CLIENT_SECRET SUPERSET_OIDC_DISCOVERY_URL SUPERSET_OIDC_REDIRECT_URI SUPERSET_PIDS_LIMIT SUPERSET_PUBLIC_URL SUPERSET_REDIS_MEM_LIMIT SUPERSET_REDIS_PASSWORD SUPERSET_REDIS_PIDS_LIMIT SUPERSET_RO_PASSWORD SUPERSET_SECRET_KEY SUPERSET_SESSION_COOKIE_NAME SUPERSET_URL SWAGGER_UI_ENABLED TRUST_PROXY' # <<< old setting names # The names to keep, one per line, sorted. $1 is renamed-settings.tsv, whose old setting names are kept; a caller # with no such file (the one-command install, before the package is on the host) passes it empty and gives the # old names themselves as $2, separated by spaces or line breaks. elevate_names() { _en_old=${2:-} if [ -n "${1:-}" ] && [ -r "$1" ]; then _en_old=$(awk -F '\t' '$1 ~ /^[A-Za-z_][A-Za-z0-9_]*$/ && ($3 == "file" || $3 == "env-generic") { print $1 }' "$1") fi _ELEVATE_OLD_NAMES="$_en_old" awk -v proxies="$ELEVATE_PROXY_NAMES" 'BEGIN { n = split(ENVIRON["_ELEVATE_OLD_NAMES"], old) for (i = 1; i <= n; i++) if (old[i] != "") keep[old[i]] = 1 n = split(proxies, p, " ") for (i = 1; i <= n; i++) keep[p[i]] = 1 keep["RAILS_ENGINE_JUDGE"] = 1 for (k in ENVIRON) { if (k ~ /^SOLCIS_[A-Za-z0-9_]+$/) { if (k != "SOLCIS_TERMS_ACCEPTED_AT_TERMINAL" && k != "SOLCIS_BACKTEST_RUNNER_LIB") print k } else if (k in keep) print k } }' | LC_ALL=C sort } # The sudo to use: the system's own first, so a `sudo` planted earlier on the operator's PATH never sees # the password; PATH is searched only on a host that has none of them. Fails when there is no sudo at all. # _ELEVATE_SUDO_PATHS lets the tests put a stand-in there. elevate_find_sudo() { for _efs in ${_ELEVATE_SUDO_PATHS-/usr/bin/sudo /bin/sudo}; do [ -x "$_efs" ] && { printf '%s\n' "$_efs"; return 0; } done command -v sudo 2>/dev/null } # The note for a run someone started with sudo by hand, whose exported settings may not have arrived (the rules # are in lib/elevate.sh, beside the library's copy). - says the script is read from standard input. elevate_started_by_hand_note() { [ "$(id -u)" = 0 ] || return 0 [ -n "${SUDO_USER:-}" ] || return 0 [ "${_SOLCIS_RAISED:-}" = 1 ] && return 0 printf '%s\n' "${SUDO_COMMAND:-}" | awk -v want="$1" ' function base(p, parts, n) { n = split(p, parts, "/"); return parts[n] } NR == 1 { if (want != "-" && base($1) == want) exit 0 if (base($1) !~ /^(sh|ash|dash|bash|ksh|ksh93|mksh|zsh)$/) exit 1 for (i = 2; i <= NF; i++) { if ($i == "-s" || $i == "--") exit (want == "-") ? 0 : 1 if ($i == "-c") exit 1 if ($i ~ /^-/) continue exit (want != "-" && base($i) == want) ? 0 : 1 } exit (want == "-") ? 0 : 1 }' || return 0 if [ "${2:-}" = --if-nothing-arrived ] && [ -n "$(elevate_names "" | grep '^SOLCIS_')" ]; then return 0 fi _esn_rs= if "$(elevate_find_sudo || echo sudo)" --version 2>/dev/null | head -n 1 | grep -q '^sudo-rs'; then _esn_rs=" This host's sudo is sudo-rs, which ignores -E." fi echo "This run was started with sudo, so settings exported in your shell did not reach it.$_esn_rs To pass them, run the command without sudo: it asks for sudo itself and keeps your SOLCIS_ settings." } # The working body of the script, as one quoted here-document, so nothing in it is expanded here. Run as # root it is evaluated as it stands; run as anyone else, these same bytes are what sudo's shell reads. solcis_script_body() { cat <<'SOLCIS_SCRIPT_BODY' set -eu # The Solcis release-signing public key (Ed25519, 64 hex characters). It is the same key every package # carries in release-anchor.pub and the one shown on https://get.solcis.ai/download, and it changes only # if the key is rotated. RELEASE_KEY=9cf599a9ef71e4a6aba241d293515e54d49d9612cfe4c17899ded9235ece5b94 main() { # Stamped by the release that publishes this script. A checkout has no version to default to, and # guessing one would install something other than what the operator asked for. VERSION="${SOLCIS_VERSION:-0.9.0}" BASE="${SOLCIS_DOWNLOAD_BASE:-https://get.solcis.ai}" PREFIX="${SOLCIS_INSTALL_DIR:-/opt/solcis}" TOKEN="${SOLCIS_REGISTRY_TOKEN:-${SOLCIS_REGISTRY_TOKEN:-}}" # Repeated back in the diagnostics below, so a corrected command names the address the operator gave. HOST_HINT="${SOLCIS_PUBLIC_HOST:-${SOLCIS_PUBLIC_HOST:-solcis.example.com}}" # Matched by shape, never by spelling the placeholder: the release stamps the version in, and a refusal # that named the placeholder could be rewritten into one that refuses the stamped version. case "$VERSION" in ''|@*@) echo "This copy of the script carries no version to install — it is the one in the repository, not the" >&2 echo "one published with a release. Name the version to install:" >&2 echo "" >&2 echo " curl -fsSL $BASE | SOLCIS_VERSION=1.2.3 SOLCIS_PUBLIC_HOST=$HOST_HINT sh" >&2 exit 1 ;; esac # The version names the download and the folder it unpacks to, so it is held to a release's shape: a # path separator or a pattern character in it would point both somewhere else. printf '%s\n' "$VERSION" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?' || { echo "'$VERSION' is not a Solcis version. A version looks like 1.2.3 or 1.2.3-rc.1:" >&2 echo "" >&2 echo " curl -fsSL $BASE | SOLCIS_VERSION=1.2.3 SOLCIS_PUBLIC_HOST=$HOST_HINT sh" >&2 exit 1; } [ "$(uname -s)" = Linux ] || { echo "Solcis installs on Linux. This host reports $(uname -s)." >&2 echo "On macOS or Windows, install it on a Linux virtual machine — the install guides cover creating one." >&2 exit 1; } # Reached as anyone but root only where there was no sudo to ask (solcis_get, at the end of the script), # or where the sudo it asked did not make the run root: the marker says which, and that run stops too, # so nothing is ever installed unprivileged after sudo was tried. [ "$(id -u)" = 0 ] || { if [ "${_SOLCIS_RAISED:-}" = 1 ]; then echo "sudo did not make this run root, so it stops here. Nothing has been changed." >&2 exit 1 fi echo "This has to run as root: it writes to $PREFIX and drives Docker, and this host has no sudo" >&2 echo "for it to ask. Run the same command as root:" >&2 echo "" >&2 echo " curl -fsSL $BASE | SOLCIS_PUBLIC_HOST=$HOST_HINT sh" >&2 exit 1; } for _tool in curl tar openssl; do command -v "$_tool" >/dev/null 2>&1 || { case "$_tool" in curl) _for="fetched" ;; tar) _for="unpacked" ;; *) _for="checked against the Solcis release key" ;; esac echo "$_tool is not installed, so the package cannot be $_for." >&2 echo "Install $_tool and run the command again." >&2 exit 1; } done # The token goes to curl as one line of its configuration (fetch, below), so a line break in it would # start a second line, and a token never holds one. _cr=$(printf '\r') case "$TOKEN" in *" "* | *"$_cr"*) echo "SOLCIS_REGISTRY_TOKEN holds a line break, so it is not a token. Nothing has been fetched." >&2 exit 1 ;; esac NAME="solcis-$VERSION" TARBALL="$NAME.tar.gz" WORK=$(mktemp -d "${TMPDIR:-/tmp}/solcis-install.XXXXXX") # shellcheck disable=SC2064 # WORK is fixed from here; expanding it now is what makes the trap correct. # The signals too: dash runs no EXIT trap for a signal it does not catch, so closing the terminal while # the licence-and-terms question waits would leave the download behind. Each one exits after the clean-up, # as the signal would have, rather than carrying on without the download. trap "rm -rf '$WORK'" EXIT # shellcheck disable=SC2064 # As above. trap "rm -rf '$WORK'; exit 129" HUP # shellcheck disable=SC2064 # As above. trap "rm -rf '$WORK'; exit 130" INT # shellcheck disable=SC2064 # As above. trap "rm -rf '$WORK'; exit 143" TERM echo "Solcis $VERSION" echo " fetching the deployment package from $BASE" fetch "$TARBALL" "$WORK/$TARBALL" || { echo "" >&2 echo "Could not fetch $BASE/$TARBALL." >&2 if [ -z "$TOKEN" ]; then echo "Nothing here needs a credential, so this is a network problem or a version that was never" >&2 echo "published. Check that this host can reach $BASE, and that $VERSION is a released version." >&2 else echo "The token was sent and refused, or version $VERSION is not published there. Check both." >&2 fi exit 1; } # The checksum is a separate file so a package that arrives corrupted or truncated is refused before # anything inside it is read. It is not what makes the package trustworthy, because it comes from the # same place as the package: that is the signature, checked below against the key this script carries. fetch "$TARBALL.sha256" "$WORK/$TARBALL.sha256" || { echo "" >&2 echo "The package downloaded, but its checksum file $TARBALL.sha256 did not. Nothing is installed from" >&2 echo "a package that cannot be checked — try again, and if it persists, obtain the package by hand." >&2 exit 1; } echo " checking it against the published checksum" ( cd "$WORK" && verify_checksum "$TARBALL" ) || exit 1 # A package that unpacks anywhere other than into its own directory is not the package. Refuse rather # than let a crafted archive write over the filesystem this is running as root on. Every entry is # checked, not only the first, and none may climb out with `..`: the archive is unpacked as root, and # whether tar refuses a `..` member on its own differs between tar implementations. TOP=$(tar -tzf "$WORK/$TARBALL" | sed -n '1s#^\([^/]*\)/.*#\1#p') # Compared as text, not as a pattern: the dots in the version are literal. _stray=$(tar -tzf "$WORK/$TARBALL" | awk -v n="$NAME" '$0 != n && index($0, n "/") != 1' | head -n1 || true) _climb=$(tar -tzf "$WORK/$TARBALL" | grep -E '(^|/)\.\.(/|$)' | head -n1 || true) [ "$TOP" = "$NAME" ] && [ -z "$_stray" ] && [ -z "$_climb" ] || { if [ "$TOP" != "$NAME" ]; then _where=${TOP:-nothing}; else _where=${_stray:-$_climb}; fi echo "The package unpacks to '$_where', not into $NAME. It is not the package for this version," >&2 echo "and nothing has been unpacked. Obtain it by hand and check it yourself." >&2 exit 1; } # Only files and folders, each named once. A link unpacked as root can send a later entry's write # anywhere on this host, and a name that appears twice is two different files of which tar keeps the # last, so the file checked and the file run could differ. A Solcis package has neither. The first # character of each listed entry is its type, in every tar's verbose listing: `-` a file, `d` a folder. _odd=$(tar -tvzf "$WORK/$TARBALL" | grep -v '^[-d]' | head -n1 || true) _twice=$(tar -tzf "$WORK/$TARBALL" | sed 's#/*$##' | LC_ALL=C sort | uniq -d | head -n1) [ -z "$_odd" ] && [ -z "$_twice" ] || { if [ -n "$_odd" ]; then echo "The package holds an entry that is not a file or a folder:" >&2 echo " $_odd" >&2 else echo "The package holds '$_twice' more than once." >&2 fi echo "A Solcis package has none, so this is not one, and nothing has been unpacked. Obtain it by" >&2 echo "hand and check it yourself." >&2 exit 1; } # THE KEY THE PACKAGE CARRIES, against the one this script carries. On its own this proves nothing, # because anyone can copy a public key into an archive; the signature check below is what does. It is # here for the one honest way the two differ, a release key that has been rotated since this copy of # the script was fetched, so that case is named as itself rather than reported as tampering. PACKAGED_KEY=$(tar -xzOf "$WORK/$TARBALL" "$NAME/release-anchor.pub" 2>/dev/null | tr -d '[:space:]' || true) [ "$PACKAGED_KEY" = "$RELEASE_KEY" ] || { echo "This package is signed against a different release key than this script expects." >&2 echo "" >&2 echo " this script expects $RELEASE_KEY" >&2 echo " the package carries ${PACKAGED_KEY:-no key at all}" >&2 echo "" >&2 echo "Nothing has been unpacked. If the Solcis release key was rotated, fetch this script again so you" >&2 echo "have the current one. Otherwise this package did not come from Solcis — please report it." >&2 exit 1; } # THE SIGNATURE, against the key this script carries. The signed list and its signature are read out of # the archive into this script's own temporary directory, and nothing else in the package is unpacked # until the signature over that list verifies against RELEASE_KEY. echo " checking its signature against the Solcis release key $RELEASE_KEY" ed25519_supported || { echo "" >&2 echo "This host's openssl cannot check the package's signature: it has no Ed25519 support, which needs" >&2 echo "OpenSSL 3.0 or later ($(openssl version 2>/dev/null || echo 'version unknown')). Install a current" >&2 echo "openssl and run the command again. Nothing has been unpacked, and nothing is installed unchecked." >&2 exit 1; } if tar -xzOf "$WORK/$TARBALL" "$NAME/SHA256SUMS" > "$WORK/SHA256SUMS" 2>/dev/null && tar -xzOf "$WORK/$TARBALL" "$NAME/SHA256SUMS.sig" > "$WORK/SHA256SUMS.sig" 2>/dev/null && [ -s "$WORK/SHA256SUMS" ] && [ -s "$WORK/SHA256SUMS.sig" ]; then : else echo "" >&2 echo "The package carries no signed inventory (SHA256SUMS and SHA256SUMS.sig), so nothing in it can be" >&2 echo "checked. Nothing has been unpacked or run. Obtain the package by hand and check it yourself." >&2 exit 1 fi release_verify "$WORK/SHA256SUMS" "$WORK/SHA256SUMS.sig" || { echo "" >&2 echo "The package is not signed by the Solcis release key this script carries:" >&2 echo "" >&2 echo " $RELEASE_KEY" >&2 echo "" >&2 echo "It has been altered, or it did not come from Solcis. Nothing has been unpacked or run. Please" >&2 echo "report it to Solcis." >&2 exit 1; } # EVERY FILE, against the signed list. The package is unpacked into this script's own temporary # directory first, where nothing runs it, and every file in it must be listed with the SHA-256 it has. # A file the list does not name is refused too: nothing vouches for it, and it would be unpacked into # the deployment beside the files that were checked. mkdir "$WORK/check" tar -xzf "$WORK/$TARBALL" -C "$WORK/check" _mismatch=$(cd "$WORK/check/$NAME" && check_inventory "$WORK/SHA256SUMS") || { echo "" >&2 echo "A file in the package does not match the signed inventory:" >&2 printf '%s\n' "$_mismatch" | sed 's/^/ /' >&2 echo "The package was altered after Solcis signed it. Nothing has been unpacked or run. Please report" >&2 echo "it to Solcis." >&2 exit 1; } _unlisted=$(unlisted_files "$WORK/check/$NAME" "$WORK/SHA256SUMS") [ -z "$_unlisted" ] || { echo "" >&2 echo "The package carries files its signed inventory does not list, so nothing vouches for them:" >&2 printf '%s\n' "$_unlisted" | sed 's/^/ /' >&2 echo "Nothing has been unpacked or run. Please report it to Solcis." >&2 exit 1; } echo " every file in it is signed by that key" # A LICENSE TO INSTALL WITH, before the terms are asked about and before anything is unpacked where it will # run (GitHub Issue #6982). The check is the package's own, read from the copy checked above, so this # refuses with the installer's words: a license exported in this shell, or one an earlier install under # $PREFIX keeps. When none is found (a folder under $PREFIX may be only a package an earlier upgrade moved # out of) but Docker holds a Solcis deployment, the installer finds that deployment and its license, so the # check is left to it. Whether the license # verifies is the installer's question, once the images are on this host. A release from before this # carries no check, and its installer asks for no license, so neither does this. if [ -f "$WORK/check/$NAME/compose/lib/licence-required.sh" ]; then # shellcheck source=deploy/compose/lib/env-layers.sh . "$WORK/check/$NAME/compose/lib/env-layers.sh" # shellcheck source=deploy/compose/lib/licence-required.sh . "$WORK/check/$NAME/compose/lib/licence-required.sh" set -- for _ldir in "$PREFIX"/*/compose; do [ ! -d "$_ldir" ] || set -- "$@" "$_ldir" done _lelsewhere=0 if [ -z "$(licence_effective "$@" 2>/dev/null)" ] && command -v docker >/dev/null 2>&1; then _lproject=$(sed -n 's/^name:[[:space:]]*//p' "$WORK/check/$NAME/compose/docker-compose.yml" | head -n1) # Both questions are asked: `docker ps` succeeds with nothing to list, so a deployment whose containers # were removed and whose volumes remain is found only by the second. if [ -n "$_lproject" ] && { docker ps -aq --filter "label=com.docker.compose.project=$_lproject" 2>/dev/null docker volume ls -q --filter "label=com.docker.compose.project=$_lproject" 2>/dev/null; } | grep -q .; then _lelsewhere=1 fi fi if [ "$_lelsewhere" = 0 ]; then licence_require "curl -fsSL $BASE | sh" "$@" >&2 || { hand_started_note; exit 1; } fi fi # THE LICENCE AND TERMS, accepted before anything is unpacked where it will run (GitHub Issue #5881). The # acceptance step is the package's own, read from the copy checked above, so the terms shown are the # ones Solcis signed and the rules are the installer's. An earlier install under $PREFIX that recorded # this version, or set it in its overrides.env, counts, as it does for the installer. Once accepted, the # installer is told so, and does not ask the person a second time. A release from before the terms # existed carries no acceptance step, and its installer asks for none, so neither does this. if [ -f "$WORK/check/$NAME/compose/lib/terms.sh" ]; then # shellcheck source=deploy/compose/lib/terms.sh . "$WORK/check/$NAME/compose/lib/terms.sh" set -- for _tdir in "$PREFIX"/*/compose; do [ ! -d "$_tdir" ] || set -- "$@" "$_tdir" done # At a terminal, the text is named where it sits while the question waits: checked, and readable as # root (the folder is this script's own) by a person whose network cannot reach the address. A refusal # does not name it, because that copy is deleted as this script exits. _ttext="" ! terms_have_terminal || _ttext="$WORK/check/$NAME/TERMS.txt" terms_require "curl -fsSL $BASE | @ACCEPT@ sh" "$_ttext" "$@" >&2 || { hand_started_note; exit 1; } SOLCIS_ACCEPT_TERMS=$TERMS_VERSION export SOLCIS_ACCEPT_TERMS [ "$TERMS_ACCEPTED_AT_TERMINAL" != 1 ] || { SOLCIS_TERMS_ACCEPTED_AT_TERMINAL=1; export SOLCIS_TERMS_ACCEPTED_AT_TERMINAL; } fi rm -rf "$WORK/check" # A STABLE LOCATION, not a temporary one: the installer keeps this deployment's configuration and its # engine's state inside the package directory. Re-running this command for the same version unpacks # over that copy, replacing the files the package carries and leaving that state where it is. A new # version unpacks beside it, and its installer finds the deployment already on this host and moves # itself into that deployment's directory (GitHub Issue #5085), so the state never has to move. # # Unpacked from the same archive the checks above read, which sits in a directory only this script # can write to, so what is unpacked here is what was checked. # # `--no-same-owner` because tar run as root restores the owner the archive records, and root runs # this folder's installer and its address-move helper. A package that recorded another account as its # owner (the 0.7.0 package recorded uid 1001, the build machine's) would otherwise give that account # on this host the files root later runs (GitHub Issue #2988, finding DP-1). Unpacked this way, every # file is owned by root, whatever the archive says. mkdir -p "$PREFIX" echo " unpacking it to $PREFIX/$NAME" tar --no-same-owner -xzf "$WORK/$TARBALL" -C "$PREFIX" [ -x "$PREFIX/$NAME/install.sh" ] || { echo "$PREFIX/$NAME/install.sh is missing or not executable — the package is incomplete." >&2 exit 1; } # `exec` replaces this process, so the trap above will not run. Clear the download now instead: the # package is unpacked and nothing further reads it. rm -rf "$WORK" trap - EXIT HUP INT TERM # The note's decision, made before this run exported the acceptance above, which the installer would read # as a setting that arrived (solcis_get, at the end of the script). Set, even when empty, it is what the # installer goes by. _SOLCIS_HAND_STARTED_NOTE=${_SOLCIS_HAND_STARTED_NOTE:-} export _SOLCIS_HAND_STARTED_NOTE echo "" cd "$PREFIX/$NAME" exec ./install.sh } # Appended to the license and terms refusals for a run started with sudo by hand: the license or the acceptance # may be missing only because sudo dropped what the operator exported. hand_started_note() { [ -z "${_SOLCIS_HAND_STARTED_NOTE:-}" ] || printf '\n%s\n' "$_SOLCIS_HAND_STARTED_NOTE" >&2 } # One token, used here and by the installer. Sent as a header rather than in the URL so it does not reach # an access log or travel to a redirect target. # # The header goes to curl as a line of configuration on its standard input, never as an argument: every # account on this host can read a process's arguments (GitHub Issue #7791). printf is the shell's own, so # the token is in no process's arguments on the way either. A double quote or a backslash in it is escaped # for curl's quoted string, and main refuses a token with a line break, which would end that line. fetch() { # $1 = file to fetch, relative to BASE; $2 = where to put it if [ -n "$TOKEN" ]; then printf 'header = "Authorization: Bearer %s"\n' "$(printf '%s' "$TOKEN" | sed 's/[\\"]/\\&/g')" | curl --config - --fail --silent --show-error --location --retry 3 --retry-delay 2 \ --output "$2" "$BASE/$1" else curl --fail --silent --show-error --location --retry 3 --retry-delay 2 \ --output "$2" "$BASE/$1" fi } # Both spellings exist on a Linux host and neither is guaranteed. The checker is given the published file, # so it names anything that disagrees rather than only reporting that something did. verify_checksum() { # $1 = the file, with $1.sha256 beside it if command -v sha256sum >/dev/null 2>&1; then sha256sum --check --quiet "$1.sha256" && return 0 elif command -v shasum >/dev/null 2>&1; then shasum --algorithm 256 --check --status "$1.sha256" && return 0 else echo "Neither sha256sum nor shasum is installed, so the package cannot be checked. Install either one" >&2 echo "and run the command again." >&2 return 1 fi echo "$1 does not match the checksum published for it. The download is incomplete or has been altered," >&2 echo "and nothing has been unpacked. Try again on a good connection; if it persists, obtain the package" >&2 echo "by hand and check it yourself." >&2 return 1 } # Hex to raw bytes, on stdout. Octal escapes rather than `\x`, which not every `sh` understands, and the # command substitution carries the escape text rather than the bytes, because a shell variable cannot # hold the zero byte most 32-byte keys contain. hex_bytes() { # $1 = lowercase hex printf '%b' "$(printf '%s\n' "$1" | awk '{ digits = "0123456789abcdef" for (i = 1; i < length($0); i += 2) printf "\\0%o", (index(digits, substr($0, i, 1)) - 1) * 16 + index(digits, substr($0, i + 1, 1)) - 1 }')" } # Verify a detached Ed25519 signature against RELEASE_KEY: the same signature format and the same openssl # check the installer makes, and the commands the install guide gives for checking a package by hand. # $1 = the signed file, $2 = its signature as 128 hex characters. Non-zero for a malformed key, a # malformed signature and a signature that does not verify alike, because none of them is a state an # install may continue from. The key is wrapped in Ed25519's fixed public-key header to make it a key # openssl will read. release_verify() { # $1 = signed file, $2 = signature file _rv_key=$(printf '%s' "$RELEASE_KEY" | tr -d '[:space:]') case "$_rv_key" in *[!0-9a-f]* | '') return 1 ;; esac [ ${#_rv_key} -eq 64 ] || return 1 _rv_sig=$(tr -d '[:space:]' < "$2") case "$_rv_sig" in *[!0-9a-f]* | '') return 1 ;; esac [ ${#_rv_sig} -eq 128 ] || return 1 { printf -- '-----BEGIN PUBLIC KEY-----\n' hex_bytes "302a300506032b6570032100$_rv_key" | openssl base64 -A printf -- '\n-----END PUBLIC KEY-----\n' } > "$WORK/release-key.pem" hex_bytes "$_rv_sig" > "$WORK/signature.bin" openssl pkeyutl -verify -pubin -inkey "$WORK/release-key.pem" -rawin \ -in "$1" -sigfile "$WORK/signature.bin" >/dev/null 2>&1 } # Can this host's openssl check an Ed25519 signature at all? Proven by doing it, on a key and bytes of its # own, so that an openssl too old to have the algorithm is reported as that rather than as a package that # failed its check. LibreSSL, which some hosts install as `openssl`, is the usual case. ed25519_supported() { mkdir "$WORK/selftest" || return 1 printf 'solcis' > "$WORK/selftest/message" { openssl genpkey -algorithm ed25519 -out "$WORK/selftest/key.pem" && openssl pkey -in "$WORK/selftest/key.pem" -pubout -out "$WORK/selftest/key.pub" && openssl pkeyutl -sign -inkey "$WORK/selftest/key.pem" -rawin \ -in "$WORK/selftest/message" -out "$WORK/selftest/signature" && openssl pkeyutl -verify -pubin -inkey "$WORK/selftest/key.pub" -rawin \ -in "$WORK/selftest/message" -sigfile "$WORK/selftest/signature" } >/dev/null 2>&1 } # Check every file a SHA256SUMS lists, from the current directory. Prints each file that is missing or # does not match, and fails if there is one. Only those lines are kept: `shasum` has no quiet mode, and # both tools end with a count of failures the operator does not need. check_inventory() { # $1 = the SHA256SUMS file if command -v sha256sum >/dev/null 2>&1; then _ci_out=$(sha256sum --check --quiet "$1" 2>&1) && return 0 else _ci_out=$(shasum --algorithm 256 --check "$1" 2>&1) && return 0 fi printf '%s\n' "$_ci_out" | grep -v -e ': OK$' -e 'WARNING: ' || true return 1 } # Every file under a directory that a SHA256SUMS does not list, one per line, apart from the list and # its signature. The list's own format puts each path from column 67: 64 hex characters and two spaces. unlisted_files() { # $1 = the directory, $2 = its SHA256SUMS ( cd "$1" && find . \( -type f -o -type l \) | sed 's#^\./##' | grep -vx 'SHA256SUMS' | grep -vx 'SHA256SUMS\.sig' | LC_ALL=C sort ) > "$WORK/present" cut -c 67- < "$2" | LC_ALL=C sort -u > "$WORK/listed" LC_ALL=C comm -23 "$WORK/present" "$WORK/listed" } main "$@" SOLCIS_SCRIPT_BODY } # Run the body as root: directly when this run already is root, otherwise through sudo, keeping the # operator's settings. The rules are elevate_raise's in deploy/compose/lib/elevate.sh, and its messages, # except that what sudo runs is this script's body on standard input rather than a file. It never carries # on unprivileged after sudo was tried. solcis_get() { _sg_body=$(solcis_script_body) if [ "$(id -u)" != 0 ]; then # The marker says sudo already started this run, so asking again would loop. (The body sudo runs # makes the same check where it needs root, because what sudo runs is the body, not this function.) if [ "${_SOLCIS_RAISED:-}" = 1 ]; then echo "sudo did not make this run root, so it stops here. Nothing has been changed." >&2 exit 1 fi # INSTALLER_NO_RAISE keeps a test's run unprivileged, and a host with no sudo carries on as it always # did: the body then refuses, because it has to run as root. if [ "${INSTALLER_NO_RAISE:-}" != 1 ] && _sg_sudo=$(elevate_find_sudo); then case $_sg_sudo in /*) ;; *) echo "This has to run as root, and the sudo on your PATH ($_sg_sudo) is not an absolute path, so it is not used. Run it as root, or fix PATH. Nothing has been changed." >&2 exit 1 ;; esac _sg_list=$(elevate_names "" "$ELEVATE_OLD_SETTING_NAMES" | paste -s -d , -) if [ -n "$_sg_list" ]; then echo "Running as root through sudo, keeping: $(printf '%s' "$_sg_list" | sed 's/,/, /g')" else echo "Running as root through sudo (no settings exported in this shell to keep)." fi # No terminal means nobody can type a password, and sudo would say only that a terminal is required, # so sudo is asked with -n whether it needs one, and the run stops when it does (lib/elevate.sh says # why it never asks sudo -n for the install itself). if ! (: <"${_ELEVATE_TTY:-/dev/tty}") 2>/dev/null && ! "$_sg_sudo" -n true 2>/dev/null; then echo "This has to run as root, and sudo needs a password, which an unattended run cannot type. Run it as root, or give this account sudo without a password. Nothing has been changed." >&2 exit 1 fi _SOLCIS_RAISED=1 export _SOLCIS_RAISED # The body reaches root's shell on its standard input, so any question it asks reads the terminal # (/dev/tty), as it already did under `curl … | sh`. /bin/sh rather than `sh`: classic sudo with no # secure_path would look `sh` up on the operator's PATH. sudo's exit status is the install's. printf '%s\n' "$_sg_body" | "$_sg_sudo" -u root --preserve-env="${_sg_list:+$_sg_list,}_SOLCIS_RAISED" -- /bin/sh -s -- "$@" || exit $? exit 0 fi fi # A run started with sudo by hand (`curl … | sudo sh`) may be missing the operator's settings, and the body # exports settings of its own before it runs the installer, so whether to say so is decided here, before it # does. The body gives the note on its license and terms refusals and hands it to the installer # (_SOLCIS_HAND_STARTED_NOTE), which goes by it rather than deciding again: what sudo started was a shell, # not the installer, and by then the acceptance has been exported. A run the body was piped to through sudo # has the marker, and no note. The script is one a shell reads from standard input unless it was given to # the shell as a file. case ${0##*/} in sh | ash | dash | bash | ksh | ksh93 | mksh | zsh) _sg_script=- ;; *) _sg_script=${0##*/} ;; esac _SOLCIS_HAND_STARTED_NOTE=$(elevate_started_by_hand_note "$_sg_script" --if-nothing-arrived) eval "$_sg_body" } solcis_get "$@"