#!/usr/bin/env bash # CoreCP bootstrap — served as https://get. # # curl -fsSL https://get. | bash # # Installs the CoreCP apt source and, by default, corectl (corecp-agent) on a # clean Ubuntu 26.04 server. Then run: `corectl setup --roles web,db,...` # # Ubuntu 24.04 installs too, with a warning: it is outside support (D-R5-13). # Any other release is refused unless CORECP_FORCE=1. # # WHERE THE PACKAGES COME FROM (round 6, session r6-productiebron) # # This script names no package source of its own. It reads its ORIGIN — the # get. it was fetched from, stamped into it by whoever publishes it — # and asks that origin for source.json: the apt repository, the installer, the # apt keyring's fingerprint and the root keys a server is to trust. It prints # those fingerprints so you can compare them with your runbook, writes the root # keys to /etc/corecp/trusted-keys.d and the source to /etc/corecp/node.yaml # (updates.repo), and only then adds the apt source. The same script therefore # installs a staging server from the build server and a production server # from the production panel, and nothing in it knows which # (docs/research/run6/r4-sleutelontwerp.md §3.5). # # CORECP_ORIGIN=https://get.example.net use this origin instead of the stamp # # TWO MODES (session r2c-panel-deploy) # # CORECP_PANEL=1 install corecp-panel instead of corecp-agent — # the whole control panel, its database, its # units, its nginx vhost and its own node CA. # CORECP_CONNECT_CODE= and, having installed it, redeem that code # against the primary that issued it. Implies # CORECP_PANEL=1, because a connect code is a # thing only a panel can redeem. # # So the one line the connect wizard shows is: # # curl -fsSL https://get. | CORECP_PANEL=1 CORECP_CONNECT_CODE=… bash # # and while it runs, this script reports its own progress back to that primary, # outbound, so the operator watches named phases in the browser instead of a # terminal they may not be looking at. Nothing about that reporting can fail an # install: if the panel cannot be reached, the machine still becomes a panel and # the person at the terminal still sees every line. # # Other switches: # CORECP_FORCE=1 proceed on an OS we do not support # CORECP_CHANNEL= install from a channel other than the source's default set -euo pipefail # The origin: stamped by the publisher (build-deb.sh, provision.sh, or # `corecp-panel dist publish`), overridable for a copy run by hand. ORIGIN_DEFAULT="https://get.prodsim.lab.corecp.dev" ORIGIN=${CORECP_ORIGIN:-} if [ -z "$ORIGIN" ] && [ "$ORIGIN_DEFAULT" != "@@CORECP_ORIGIN""@@" ]; then ORIGIN=$ORIGIN_DEFAULT fi ORIGIN=${ORIGIN%/} KEYRING=/usr/share/keyrings/corecp-archive-keyring.gpg TRUSTED_KEYS_DIR=/etc/corecp/trusted-keys.d NODE_YAML=/etc/corecp/node.yaml CONNECT_CODE=${CORECP_CONNECT_CODE:-} PANEL=${CORECP_PANEL:-0} [ -n "$CONNECT_CODE" ] && PANEL=1 log() { echo -e "\033[1;36m[corecp]\033[0m $*"; } fail() { echo -e "\033[1;31m[corecp] ERROR:\033[0m $*" >&2; report "${PHASE:-preflight}" error fail "$*"; exit 1; } [ "$(id -u)" -eq 0 ] || { echo "[corecp] ERROR: run this script as root" >&2; exit 1; } # --------------------------------------------------------------------------- # Progress, outbound. # # The connect code carries where to report and which run to report into (the # panel put them there when it minted the code, fleetplane/code.go). Decoding it # needs base64url and a JSON reader, so this happens after the base packages are # in — before that, PHASE is remembered and the events simply do not go out. # # The three reporters on this machine take disjoint sequence ranges so they # never have to agree on a counter: this script 1-999, the package's setup.sh # from 2000, `corecp-panel connect` from 3000. The panel stores events unique on # (run, seq), so a retry writes nothing twice and a collision would silently # drop somebody's line. # --------------------------------------------------------------------------- REPORT_URL="" REPORT_ID="" REPORT_TOKEN="" SEQ=0 PHASE=preflight report() { # report [ -n "$REPORT_URL" ] && [ -n "$REPORT_ID" ] && [ -n "$REPORT_TOKEN" ] || return 0 SEQ=$((SEQ + 1)) local body body=$(RID="$REPORT_ID" SEQ="$SEQ" P="$1" K="$2" S="$3" M="$4" python3 - <<'PY' 2>/dev/null || true import json, os, datetime print(json.dumps({ "provision_id": os.environ["RID"], "events": [{ "seq": int(os.environ["SEQ"]), "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "phase": os.environ["P"], "kind": os.environ["K"], "status": os.environ["S"], "message": os.environ["M"], }], })) PY ) [ -n "$body" ] || return 0 curl -fsS --max-time 15 -o /dev/null \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $REPORT_TOKEN" \ -X POST "$REPORT_URL/api/v1/managed-panels/provision/report" \ --data "$body" 2>/dev/null || true } phase() { PHASE=$1; log "== $1 == $2"; report "$1" phase running "$2"; } check() { report "$PHASE" check "${2:-pass}" "$3"; printf ' %-4s %s\n' "${2:-ok}" "$1"; } # --------------------------------------------------------------------------- # The Ubuntu matrix (owner decision D-R5-13). # # One release is supported and it is the one the repository builds for; 24.04 # is what providers still image by default, so it installs and joins with a # warning that says what it costs; everything else is refused. The same three # lines are stated by the node's own preflight (corectl/internal/core/ # preflight.go) and by docs/versions-policy.md, and the acceptance suite # compares all three — before this decision the installer refused 24.04 while # the preflight called it supported, so a machine was told two different # things about itself depending on which door it came through. # # OS_SUPPORT is remembered rather than re-derived: the preflight phase further # down reports the same verdict to the panel's join card, and a second `case` # over VERSION_ID is a second opinion waiting to happen. # --------------------------------------------------------------------------- SUPPORTED_SUITE=26.04 TOLERATED_SUITE=24.04 OUT_OF_SUPPORT_PHRASE="outside CoreCP support" . /etc/os-release if [ "${ID:-}" = ubuntu ] && [ "${VERSION_ID:-}" = "$SUPPORTED_SUITE" ]; then OS_SUPPORT=supported elif [ "${ID:-}" = ubuntu ] && [ "${VERSION_ID:-}" = "$TOLERATED_SUITE" ]; then OS_SUPPORT=out-of-support else OS_SUPPORT=refused fi case "$OS_SUPPORT" in out-of-support) log "warning: Ubuntu $TOLERATED_SUITE is $OUT_OF_SUPPORT_PHRASE — it installs and it joins, but" log " the packages are built for Ubuntu $SUPPORTED_SUITE LTS and nothing on this release" log " is supported. Plan a reinstall or an upgrade to Ubuntu $SUPPORTED_SUITE LTS." ;; refused) if [ "${CORECP_FORCE:-0}" != "1" ]; then fail "CoreCP supports Ubuntu $SUPPORTED_SUITE LTS (found: ${PRETTY_NAME:-unknown}). Ubuntu $TOLERATED_SUITE still installs, $OUT_OF_SUPPORT_PHRASE; this release does not. Set CORECP_FORCE=1 to continue anyway." fi log "warning: unsupported OS (${PRETTY_NAME:-unknown}), continuing due to CORECP_FORCE=1" ;; esac # The origin is needed before anything else is fetched; a script that does # not know where it came from must not guess a source. if [ -z "$ORIGIN" ]; then # A machine that already has a source can be re-run from it. if [ -f "$NODE_YAML" ]; then ORIGIN=$(awk '/^updates:/{u=1} u && /^ *repo:/{r=1} r && /^ *installer:/{print $2; exit}' "$NODE_YAML" 2>/dev/null || true) ORIGIN=${ORIGIN%/} fi [ -n "$ORIGIN" ] || fail "this copy of the installer does not know its origin. Run it from your package source — curl -fsSL https://get. | bash — or set CORECP_ORIGIN=https://get. for a copy run by hand." fi log "installing base packages" apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ ca-certificates curl gnupg python3 >/dev/null # Now the code can be read, and from here on the browser sees what happens. if [ -n "$CONNECT_CODE" ]; then eval "$(CODE="$CONNECT_CODE" python3 - <<'PY' 2>/dev/null || true import base64, json, os, shlex, sys raw = os.environ["CODE"].strip() parts = raw.split(".") if len(parts) != 3 or parts[0] != "corecpf1": sys.exit(0) pad = "=" * (-len(parts[1]) % 4) try: c = json.loads(base64.urlsafe_b64decode(parts[1] + pad)) except Exception: sys.exit(0) print("REPORT_URL=%s" % shlex.quote((c.get("u") or "").rstrip("/"))) print("REPORT_ID=%s" % shlex.quote(c.get("n") or "")) # The bearer for the progress endpoint is the SECRET half of the code, not the # pasted string: that is what the primary stored the hash of, and what # `corecp-panel connect` puts on the wire when it redeems it. print("REPORT_TOKEN=%s" % shlex.quote(c.get("s") or "")) print("PANEL_ID=%s" % shlex.quote(c.get("p") or "")) # The channel the primary already assigned this panel. Installing from stable # and then being told "you are on beta" makes the first thing a fresh panel does # an upgrade of itself, which is a strange way to arrive. An explicit # CORECP_CHANNEL still wins — see below. print("CODE_CHANNEL=%s" % shlex.quote(c.get("k") or "")) PY )" fi phase preflight "checking this machine before anything is installed" check "running as root" pass "uid 0" case "$OS_SUPPORT" in supported) check "operating system" pass "${PRETTY_NAME:-unknown}" ;; out-of-support) check "operating system" warn "${PRETTY_NAME:-unknown} — $OUT_OF_SUPPORT_PHRASE: reinstall or upgrade to Ubuntu $SUPPORTED_SUITE LTS" ;; *) check "operating system" warn "${PRETTY_NAME:-unknown} — not a release CoreCP supports (CORECP_FORCE=1)" ;; esac if [ "$PANEL" = 1 ]; then # A panel is the machine everything else is managed from, so the two things # that make it unusable later are checked before anything is written: a name # it can be reached by, and room for a database that grows. FQDN=$(hostname -f 2>/dev/null || hostname) case "$FQDN" in *.*) check "this machine has a domain name" pass "$FQDN" ;; *) check "this machine has a domain name" warn \ "hostname is '$FQDN' — set a fully qualified one before publishing this panel" ;; esac FREE=$(df -BG --output=avail / 2>/dev/null | tail -1 | tr -dc '0-9') if [ "${FREE:-0}" -lt 10 ]; then check "free disk space" warn "${FREE:-?} GB on / — a panel wants more than 10 GB" else check "free disk space" pass "${FREE} GB on /" fi if [ -n "$REPORT_URL" ]; then check "the primary panel is reachable" pass "$REPORT_URL" elif [ -n "$CONNECT_CODE" ]; then check "the connect code is readable" warn \ "this code carries no progress address; the install continues and the wizard will show the result at the end" fi fi report preflight phase pass "this machine can carry what is about to be installed" # --------------------------------------------------------------------------- # The source, from the origin. # --------------------------------------------------------------------------- phase source "reading the package source from $ORIGIN" SOURCE_JSON=$(curl -fsSL --max-time 30 "$ORIGIN/source.json") || fail "$ORIGIN/source.json could not be fetched. The origin of this installer publishes it beside install.sh; without it this script does not know which repository and which keys this server is to trust, and it will not guess." eval "$(SRC="$SOURCE_JSON" python3 - <<'PY' || echo 'SOURCE_OK=0' import json, os, re, shlex, sys try: s = json.loads(os.environ["SRC"]) except Exception as e: print("SOURCE_ERR=%s" % shlex.quote("source.json is not valid JSON: %s" % e)); sys.exit(0) def bad(msg): print("SOURCE_ERR=%s" % shlex.quote(msg)); sys.exit(0) if not isinstance(s, dict) or s.get("schema") != 1: bad("source.json has no schema 1") repo = str(s.get("repo_url", "")).rstrip("/") if not re.fullmatch(r"https://[a-z0-9.-]+", repo): bad("source.json: repo_url must be https://: %r" % repo) installer = str(s.get("installer_url", "")).rstrip("/") if not re.fullmatch(r"https://[a-z0-9.-]+", installer): bad("source.json: installer_url must be https://: %r" % installer) chan = str(s.get("default_channel", "stable")) suites = s.get("suites") or [] if not isinstance(suites, list) or not all(re.fullmatch(r"[a-z0-9-]+", str(x)) for x in suites): bad("source.json: suites must be a list of suite names") if not re.fullmatch(r"[a-z0-9-]+", chan): bad("source.json: default_channel is not a suite name") fpr = str(s.get("keyring_fingerprint", "")).upper() if not re.fullmatch(r"[0-9A-F]{40}", fpr): bad("source.json: keyring_fingerprint must be the 40-hex fingerprint of the apt signing key") keyring_url = str(s.get("keyring_url") or (repo + "/corecp-archive-keyring.asc")) if not keyring_url.startswith("https://"): bad("source.json: keyring_url must be https://") roots = s.get("trust_roots") or [] if not isinstance(roots, list) or not roots: bad("source.json names no trust_roots; a source without a root key is not a source") lines = [] for r in roots: pk = str((r or {}).get("public_key", "")).strip() rid = str((r or {}).get("id", "")).strip() if not re.fullmatch(r"[A-Za-z0-9+/=]{56}", pk) or not re.fullmatch(r"[0-9a-f]{16}", rid): bad("source.json: a trust root is not a minisign public key with its id") lines.append(rid + " " + pk + " " + str((r or {}).get("comment", "")).replace("\n", " ")) print("SOURCE_OK=1") print("REPO_URL=%s" % shlex.quote(repo)) print("INSTALLER_URL=%s" % shlex.quote(installer)) print("SOURCE_CHANNEL=%s" % shlex.quote(chan)) print("SOURCE_SUITES=%s" % shlex.quote(" ".join(str(x) for x in suites))) print("KEYRING_URL=%s" % shlex.quote(keyring_url)) print("KEYRING_FPR=%s" % shlex.quote(fpr)) print("TRUST_ROOTS=%s" % shlex.quote("\n".join(lines))) PY )" [ "${SOURCE_OK:-0}" = 1 ] || fail "${SOURCE_ERR:-source.json could not be read}" if [ -n "${CORECP_CHANNEL:-}" ]; then CHANNEL=$CORECP_CHANNEL elif [ -n "${CODE_CHANNEL:-}" ]; then CHANNEL=$CODE_CHANNEL else CHANNEL=$SOURCE_CHANNEL fi if [ -n "$SOURCE_SUITES" ]; then case " $SOURCE_SUITES " in *" $CHANNEL "*) ;; *) fail "the source $REPO_URL publishes only: $SOURCE_SUITES — not '$CHANNEL'" ;; esac fi check "package source" pass "$REPO_URL ($CHANNEL)" # The fingerprints, for a person to compare with the runbook. They are what a # server will trust from now on, and they are shown before they are written. log "this server will trust:" log " apt signing key $KEYRING_FPR" while IFS= read -r line; do [ -n "$line" ] || continue log " root key ${line%% *} (${line#* * })" done <<<"$TRUST_ROOTS" # The apt keyring, checked against the fingerprint the origin named: a keyring # served by the repository host that does not match what get. says is # a repository host and an installer host that disagree, and this script # stops rather than pick one. TMPKEY=$(mktemp) curl -fsSL --max-time 30 "$KEYRING_URL" -o "$TMPKEY" || fail "the apt keyring could not be fetched from $KEYRING_URL" GOT_FPR=$(gpg --batch --with-colons --import-options show-only --import "$TMPKEY" 2>/dev/null | awk -F: '/^fpr:/{print $10; exit}') [ "$GOT_FPR" = "$KEYRING_FPR" ] || fail "the apt keyring at $KEYRING_URL has fingerprint '${GOT_FPR:-none}', but $ORIGIN/source.json says $KEYRING_FPR. Nothing was installed. The two doors of one source must agree; ask whoever runs it." gpg --dearmor --yes -o "$KEYRING" <"$TMPKEY" rm -f "$TMPKEY" check "apt keyring" pass "$KEYRING ($KEYRING_FPR)" # The root keys, where corectl reads them (trusted-keys.d), and the source in # node.yaml (updates.repo), so that `corectl update trust` shows exactly what # was printed above. node.yaml is written only when there is none: on a # machine that already has one, corectl's own migration reads the apt source. install -d -m 0755 "$TRUSTED_KEYS_DIR" ROOT_LINES="" while IFS= read -r line; do [ -n "$line" ] || continue rid=${line%% *}; rest=${line#* }; pk=${rest%% *}; comment=${rest#* } printf 'untrusted comment: %s\n%s\n' "${comment:-CoreCP root key $rid}" "$pk" >"$TRUSTED_KEYS_DIR/$rid.pub" chmod 0644 "$TRUSTED_KEYS_DIR/$rid.pub" ROOT_LINES="$ROOT_LINES - $pk " done <<<"$TRUST_ROOTS" if [ ! -f "$NODE_YAML" ]; then install -d -m 0755 /etc/corecp SUITES_YAML="" for s in $SOURCE_SUITES; do SUITES_YAML="$SUITES_YAML$s, "; done SUITES_YAML="[${SUITES_YAML%, }]" umask 077 cat >"$NODE_YAML" < /etc/apt/sources.list.d/corecp.sources </dev/null || true) CANDIDATE=$(awk '/Candidate:/ {print $2; exit}' <<<"$POLICY") case "${CANDIDATE:-none}" in [0-9]*) ;; *) fail "$WANT is not in the '$CHANNEL' channel of $REPO_URL. Either point this install at a channel that carries it — curl -fsSL $INSTALLER_URL | CORECP_CHANNEL= … bash or publish it on the source first." ;; esac check "$WANT is available" pass "$CANDIDATE" report repository phase pass "packages will come from the signed source $REPO_URL" if [ "$PANEL" = 1 ]; then phase install "installing corecp-panel and everything it runs on" # The credentials the package's own provisioning reports with. Handed over in # a file rather than in the environment: dpkg's environment is not a contract, # and a postinst that silently stopped reporting because apt sanitised a # variable would be invisible. if [ -n "$REPORT_URL" ] && [ -n "$REPORT_ID" ]; then umask 077 cat > /run/corecp-panel-bootstrap.env </dev/null || echo '?')" # apt exiting 0 is not proof that the machine is a panel. The postinst is # deliberately not fatal — a first install that cannot finish must leave a # package installed and a sentence, not a broken dpkg state that blocks every # later apt run — so the postcondition is checked HERE, where the run can stop # and say so instead of going on to connect a panel that does not exist. if [ ! -s /etc/corecp-panel/panel.yaml ]; then fail "corecp-panel was installed but its provisioning did not finish; there is no /etc/corecp-panel/panel.yaml. The output above says why. Then run: bash /usr/lib/corecp-panel/setup.sh all" fi check "it was provisioned" pass "/etc/corecp-panel/panel.yaml exists" report install phase pass "corecp-panel is installed and provisioned" if [ -n "$CONNECT_CODE" ]; then # `corecp-panel connect` reports the connect and health phases itself: it is # the thing that knows the panel id, the channel, and the two fingerprints # that have to differ. This script does not repeat them. log "== connect == redeeming the code against the primary" PHASE=connect # CORECP_BOOTSTRAP=1 tells it not to end the run: this script restarts the # service afterwards and owns the health phase, and two reporters both # claiming to have finished is how a card goes green while the panel it # describes is still starting. if ! CORECP_BOOTSTRAP=1 corecp-panel connect --code "$CONNECT_CODE" \ --config /etc/corecp-panel/panel.yaml; then fail "the panel is installed but did not connect. Issue a new code in the wizard and run: corecp-panel connect --code --config /etc/corecp-panel/panel.yaml" fi phase health "starting the panel and checking that it answers" systemctl restart corecp-panel || true UP=no for _ in 1 2 3 4 5 6 7 8 9 10; do if systemctl is-active --quiet corecp-panel; then UP=yes; break; fi sleep 2 done if [ "$UP" = yes ]; then check "corecp-panel is running" pass "$(corecp-panel version 2>/dev/null || echo '?')" else check "corecp-panel is running" fail "the service did not stay up — journalctl -u corecp-panel -n 40" fi if corecp-panel connect status --config /etc/corecp-panel/panel.yaml >/dev/null 2>&1; then check "it reports to the primary" pass "connected as ${PANEL_ID:-this panel}" fi if [ "$UP" = yes ]; then report health done pass "this machine is a panel and it is connected" else report health error fail "corecp-panel was installed and connected but did not stay running" fi echo log "done. This machine is a panel and it is connected." log " its own address https://$(hostname -f 2>/dev/null || hostname)" log " create an admin corecp-panel user add --email --level admin --config /etc/corecp-panel/panel.yaml" else echo log "done. This machine is a panel." log " connect it later corecp-panel connect --code --config /etc/corecp-panel/panel.yaml" log " create an admin corecp-panel user add --email --level admin --config /etc/corecp-panel/panel.yaml" fi exit 0 fi log "installing corecp-agent" DEBIAN_FRONTEND=noninteractive apt-get install -y -qq corecp-agent >/dev/null echo corectl version echo log "done. Next step: corectl setup --roles web,db,mail,dns" log "health check: corectl doctor" log "what this server trusts: corectl update trust"