#!/bin/sh # Hatamoto VPS installer. Assembled by deploy/install/build-install.sh: do not # edit the assembled file, edit the parts under deploy/install/src. # # Inputs (environment, all optional): # HATAMOTO_DOMAIN public host name; empty serves on the sslip.io name of the public IP # HATAMOTO_VERSION release to install (default: newest fully ramped stable release) # HATAMOTO_ADMIN_EMAIL contact address for the certificate authority # HATAMOTO_SETUP_CODE pin the setup code (default: generated at first boot) # HATAMOTO_BACKUPS 1 (default) nightly local backups, 0 off # HATAMOTO_PUBLIC_IP public IPv4 address (default: detected) # HATAMOTO_TLS auto (default), internal or off # HATAMOTO_FIREWALL 1 (default) configure ufw, 0 skip # HATAMOTO_RELEASE_BASE release site (signatures still bind) set -eu # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_DOMAIN="${HATAMOTO_DOMAIN:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_VERSION="${HATAMOTO_VERSION:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_EMAIL="${HATAMOTO_ADMIN_EMAIL:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_SETUP_CODE="${HATAMOTO_SETUP_CODE:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_BACKUPS="${HATAMOTO_BACKUPS:-1}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_PUBLIC_IP="${HATAMOTO_PUBLIC_IP:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_TLS="${HATAMOTO_TLS:-auto}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_FIREWALL="${HATAMOTO_FIREWALL:-1}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts RELEASE_BASE="${HATAMOTO_RELEASE_BASE:-https://hatamoto.dev/releases}" # R prefixes every path the installer writes; empty on a real host. R="${HATAMOTO_INSTALL_ROOT:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts LIB_DIR=/usr/local/lib/hatamoto # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts STATE_DIR=/var/lib/hatamoto-install log() { printf '%s\n' "hatamoto: $*"; } warn() { printf '%s\n' "hatamoto: warning: $*" >&2; } die() { printf '%s\n' "hatamoto: error: $*" >&2; exit 1; } need_root() { [ -n "$R" ] || [ "$(id -u)" = 0 ] || die "run as root (for example: curl -fsSL https://hatamoto.dev/install.sh | sudo sh)" } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1" } # dest prints an absolute path under the install root. dest() { printf '%s%s\n' "$R" "$1" } # arch prints amd64 or arm64. arch() { case "$(uname -m)" in x86_64 | amd64) echo amd64 ;; aarch64 | arm64) echo arm64 ;; *) die "unsupported CPU architecture: $(uname -m) (amd64 and arm64 only)" ;; esac } # Generated by build-install.sh from the key files. Not editable. TRUSTED_MANIFEST_KEYS='c15ece9d6b1fc201 JD0WuhsLzlZEgfnJ4w/wmZem3koouXcLaeO++/TRciM= ' TRUSTED_RELEASE_KEYS='a90b01139fbdc440 g62uY1mMi/C5TYF45BgRhPy3XmpM4/zoe9TiVP1zKMg= d7effd9870698586 C2ylGDFL5pgifkSa7/sVlWit+4EhNiOuU4fHiAdVNj8= ' # Trust: pick a release and fetch its binary only after the signed chain checks out. # # Chain (the same one the updater runs, backend/internal/release/verify.go): # channel index -> signed by a manifest key from TRUSTED_MANIFEST_KEYS, fresh # release record -> signed by the release key named in the index entry # binary -> size and sha256 from the record # The keys are the generated block above: nothing in the environment can change them. # Every refusal prints "FAILED CHECK: : ..." and exits non-zero. TRUST_TMP="" TRUST_MAX_SIZE=268435456 TRUST_CHANNEL=stable # trust_die removes the work directory, then dies naming the failed check. trust_die() { [ -z "$TRUST_TMP" ] || rm -rf "$TRUST_TMP" TRUST_TMP="" die "FAILED CHECK: $1: $2" } # trust_valid_version succeeds for vMAJOR.MINOR.PATCH[-prerelease], one line only. trust_valid_version() { case "$1" in *' '*) return 1 ;; esac printf '%s\n' "$1" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' } # trust_get URL OUTFILE downloads over https (or http on loopback, for tests). trust_get() { case "$RELEASE_BASE" in https://*) curl --fail --silent --show-error --retry 3 --proto '=https' --proto-redir '=https' --tlsv1.2 --location \ --output "$2" "$1" ;; http://127.0.0.1 | http://127.0.0.1/* | http://127.0.0.1:*) curl --fail --silent --show-error --retry 3 --proto '=http' --output "$2" "$1" ;; *) die "release base must be an https URL: $RELEASE_BASE" ;; esac } # trust_get_max URL OUTFILE MAXBYTES is trust_get with a size cap. trust_get_max() { case "$RELEASE_BASE" in https://*) curl --fail --silent --show-error --retry 3 --proto '=https' --proto-redir '=https' --tlsv1.2 --location \ --max-filesize "$3" --output "$2" "$1" ;; *) curl --fail --silent --show-error --retry 3 --proto '=http' \ --max-filesize "$3" --output "$2" "$1" ;; esac } # trust_check_tools dies early when a command the chain needs is missing. trust_check_tools() { need_cmd curl need_cmd jq need_cmd openssl need_cmd base64 need_cmd sha256sum need_cmd awk need_cmd wc need_cmd tr case "$RELEASE_BASE" in https://* | http://127.0.0.1 | http://127.0.0.1/* | http://127.0.0.1:*) ;; *) die "release base must be an https URL: $RELEASE_BASE" ;; esac } # trust_key_b64 SET ID prints the base64 public key of ID in SET, or fails. trust_key_b64() { printf '%s\n' "$1" | awk -v id="$2" '$1 == id { print $2; found = 1 } END { exit !found }' } # trust_verify SET ID MSGFILE SIGB64 succeeds when the Ed25519 signature is valid. trust_verify() { tv_b64="$(trust_key_b64 "$1" "$2")" || return 1 [ -n "$TRUST_TMP" ] || return 1 # Raw key to DER with the fixed header (octal escapes: dash printf has no \x). { printf '\060\052\060\005\006\003\053\145\160\003\041\000'; printf '%s' "$tv_b64" | base64 -d; } | openssl pkey -pubin -inform DER -out "$TRUST_TMP/k.pem" 2>/dev/null || return 1 printf '%s' "$4" | base64 -d > "$TRUST_TMP/sig.bin" 2>/dev/null || return 1 [ -s "$TRUST_TMP/sig.bin" ] || return 1 openssl pkeyutl -verify -pubin -inkey "$TRUST_TMP/k.pem" -rawin -in "$3" -sigfile "$TRUST_TMP/sig.bin" >/dev/null 2>&1 } # trust_load_index fetches and verifies the channel index. It leaves the # compact signed index in $TRUST_TMP/index.json and creates TRUST_TMP. trust_load_index() { TRUST_TMP="$(mktemp -d)" || die "cannot create a work directory" chmod 0700 "$TRUST_TMP" trust_get "$RELEASE_BASE/channels/$TRUST_CHANNEL.json" "$TRUST_TMP/channel.json" || trust_die index-download "cannot fetch $RELEASE_BASE/channels/$TRUST_CHANNEL.json" jq -e 'type == "object" and (.index | type == "object")' "$TRUST_TMP/channel.json" >/dev/null 2>&1 || trust_die index-signature "the channel file is not a signed index" ti_signer="$(jq -r '.signer // "" | strings' "$TRUST_TMP/channel.json")" ti_alg="$(jq -r '.algorithm // "" | strings' "$TRUST_TMP/channel.json")" ti_sig="$(jq -r '.signature // "" | strings' "$TRUST_TMP/channel.json")" printf '%s\n' "$ti_signer" | grep -Eq '^[0-9a-f]{16}$' || trust_die index-signer "bad signer id" trust_key_b64 "$TRUSTED_MANIFEST_KEYS" "$ti_signer" >/dev/null || trust_die index-signer "signer $ti_signer is not a trusted manifest key" case "$ti_alg" in "" | ed25519) ;; *) trust_die index-signature "unsupported algorithm: $ti_alg" ;; esac [ -n "$ti_sig" ] || trust_die index-signature "the index is unsigned" # The signed bytes are the index with all insignificant whitespace removed. jq -c '.index' "$TRUST_TMP/channel.json" | tr -d '\n' > "$TRUST_TMP/index.json" trust_verify "$TRUSTED_MANIFEST_KEYS" "$ti_signer" "$TRUST_TMP/index.json" "$ti_sig" || trust_die index-signature "the signature does not match the index" [ "$(jq -r '.channel // ""' "$TRUST_TMP/index.json")" = "$TRUST_CHANNEL" ] || trust_die index-channel "the index is not for the $TRUST_CHANNEL channel" ti_now="$(date -u +%s)" ti_fresh="$(jq -r --argjson now "$ti_now" ' try ( (.issued_at | fromdateiso8601) as $i | (.expires_at | fromdateiso8601) as $e | if $e <= $i or ($e - $i) > 1296000 then "lifetime" elif $i > ($now + 600) then "future" elif $now >= $e then "expired" else "ok" end ) catch "format"' "$TRUST_TMP/index.json")" case "$ti_fresh" in ok) ;; future) trust_die index-freshness "the index is dated in the future: is the clock of this machine wrong?" ;; expired) trust_die index-freshness "the index has expired: is the clock of this machine wrong, or is the mirror stale?" ;; *) trust_die index-freshness "the index carries no usable issue and expiry time ($ti_fresh)" ;; esac } # trust_check_entry VERSION: the version is listed, not halted and not yanked. trust_check_entry() { jq -e --arg v "$1" '[.releases[]? | select(.version == $v)] | length == 1' "$TRUST_TMP/index.json" >/dev/null 2>&1 || trust_die index-entry "$1 is not a release on the $TRUST_CHANNEL channel" if jq -e --arg v "$1" 'any(.yanked[]?; .version == $v)' "$TRUST_TMP/index.json" >/dev/null 2>&1; then trust_die index-yanked "$1 was yanked" fi if jq -e --arg v "$1" 'any(.releases[]?; .version == $v and .halted == true)' "$TRUST_TMP/index.json" >/dev/null 2>&1; then trust_die index-halted "$1 is halted" fi } # trust_select prints the newest plain x.y.z release whose ramp is at least $1 percent now. # $1 is 100 (fully rolled out) or 1 (rollout started). trust_select() { jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson min "$1" ' def at: try (.from | fromdateiso8601) catch null; def pct: ($now | fromdateiso8601) as $n | [.ramp[]? | select((at) != null and (at) <= $n)] | if length == 0 then 0 else (max_by(at) | .pct) end | if . > 100 then 100 elif . < 0 then 0 else . end; [.yanked[]?.version] as $yanked | [.releases[]? | select(.halted != true) | select(.version as $v | ($yanked | index($v)) == null) | select(.version | test("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")) | select(pct >= $min)] | sort_by(.version | ltrimstr("v") | split(".") | map(tonumber)) | last | .version // empty' "$TRUST_TMP/index.json" } # trust_pick_version prints the version to install: HATAMOTO_VERSION when set # (it must be a listed, live release), else the newest fully rolled-out one. trust_pick_version() { trust_check_tools if [ -n "$IN_VERSION" ]; then trust_valid_version "$IN_VERSION" || die "FAILED CHECK: version: HATAMOTO_VERSION is not a version like v1.2.3" fi trust_load_index if [ -n "$IN_VERSION" ]; then trust_check_entry "$IN_VERSION" tp_version="$IN_VERSION" else tp_version="$(trust_select 100)" if [ -z "$tp_version" ]; then tp_version="$(trust_select 1)" [ -z "$tp_version" ] || warn "no release is fully rolled out yet; using $tp_version, which is still ramping" fi [ -n "$tp_version" ] || trust_die index-entry "no usable release on the $TRUST_CHANNEL channel; set HATAMOTO_VERSION" fi rm -rf "$TRUST_TMP" TRUST_TMP="" printf '%s\n' "$tp_version" } # trust_fetch VERSION OUTFILE downloads the binary for this CPU and writes it to # OUTFILE only after every check passed. On any failure OUTFILE is not created. trust_fetch() { [ $# -eq 2 ] || die "usage: trust_fetch VERSION OUTFILE" tf_version="$1" tf_out="$2" trust_valid_version "$tf_version" || die "FAILED CHECK: version: not a version like v1.2.3" trust_check_tools tf_arch="$(arch)" trust_load_index trust_check_entry "$tf_version" tf_recsig="$(jq -r --arg v "$tf_version" '.releases[] | select(.version == $v) | .record_sig // "" | strings' "$TRUST_TMP/index.json")" tf_keyid="${tf_recsig%%:*}" tf_sig="${tf_recsig#*:}" case "$tf_recsig" in *:*) ;; *) trust_die record-signature "the index entry carries no record signature" ;; esac printf '%s\n' "$tf_keyid" | grep -Eq '^[0-9a-f]{16}$' || trust_die record-signature "bad key id in the record signature" trust_key_b64 "$TRUSTED_RELEASE_KEYS" "$tf_keyid" >/dev/null || trust_die record-signature "key $tf_keyid is not a trusted release key" trust_get "$RELEASE_BASE/$tf_version/release.json" "$TRUST_TMP/release.json" || trust_die record-download "cannot fetch the release record for $tf_version" # The signature covers the file exactly as downloaded. trust_verify "$TRUSTED_RELEASE_KEYS" "$tf_keyid" "$TRUST_TMP/release.json" "$tf_sig" || trust_die record-signature "the release record does not match its signature" [ "$(jq -r '.version // ""' "$TRUST_TMP/release.json")" = "$tf_version" ] || trust_die record-version "the signed record describes another version" tf_facts="$(jq -r --arg p "linux-$tf_arch" '.artifacts[$p] | "\(.sha256) \(.size)"' "$TRUST_TMP/release.json" 2>/dev/null)" || trust_die record-version "the record has no linux-$tf_arch build" tf_sha="${tf_facts% *}" tf_size="${tf_facts#* }" printf '%s\n' "$tf_sha" | grep -Eq '^[0-9a-fA-F]{64}$' || trust_die artifact-sha256 "the record carries no valid sha256" printf '%s\n' "$tf_size" | grep -Eq '^[1-9][0-9]*$' || trust_die artifact-size "the record carries no valid size" [ "$tf_size" -le "$TRUST_MAX_SIZE" ] || trust_die artifact-size "the record size is above the limit" log "downloading hatamoto $tf_version (linux-$tf_arch)" trust_get_max "$RELEASE_BASE/$tf_version/hatamoto-linux-$tf_arch" "$TRUST_TMP/binary" "$tf_size" || trust_die artifact-size "download failed or larger than the signed size of $tf_size bytes" tf_got="$(wc -c < "$TRUST_TMP/binary" | tr -d ' ')" [ "$tf_got" = "$tf_size" ] || trust_die artifact-size "got $tf_got bytes, the signed record says $tf_size" tf_sum="$(sha256sum "$TRUST_TMP/binary" | awk '{ print $1 }')" [ "$tf_sum" = "$(printf '%s' "$tf_sha" | tr 'A-F' 'a-f')" ] || trust_die artifact-sha256 "the binary does not match the signed record" chmod 0755 "$TRUST_TMP/binary" mv -f "$TRUST_TMP/binary" "$tf_out" || trust_die artifact-install "cannot write $tf_out" rm -rf "$TRUST_TMP" TRUST_TMP="" log "verified hatamoto $tf_version" } # Build stage: packages, the verified binary, the unit, the firewall. Safe to # bake into an image: it creates no env file, secret, database role or Caddy # site, and it never starts or enables hatamoto-api (the first-boot stage does). APP_DIR=/opt/hatamoto APP_USER=hatamoto PGDG_KEY=/usr/share/keyrings/postgresql-archive-keyring.asc BUILD_PACKAGES="ca-certificates curl jq openssl sudo iproute2 ufw unattended-upgrades postgresql-16 caddy" # build_os prints "ID VERSION_ID CODENAME" from the os-release file, or dies. build_os() { bo_file="$(dest /etc/os-release)" [ -r "$bo_file" ] || die "cannot read /etc/os-release: supported systems are Ubuntu 24.04 and Debian 12" # shellcheck source=/dev/null bo_line="$( . "$bo_file" && printf '%s %s %s' "${ID:-}" "${VERSION_ID:-}" "${VERSION_CODENAME:-}" )" case "$bo_line" in "ubuntu 24.04 "* | "debian 12 "*) printf '%s\n' "$bo_line" ;; *) die "unsupported system: ${bo_line% *}: supported systems are Ubuntu 24.04 and Debian 12 (amd64 or arm64)" ;; esac } # build_policy_deny keeps packages from starting services while they install # (a started Postgres or Caddy would create state the image must not carry). build_policy_deny() { bp_file="$(dest /usr/sbin/policy-rc.d)" BUILD_POLICY="" [ ! -e "$bp_file" ] || return 0 mkdir -p "$(dirname "$bp_file")" printf '#!/bin/sh\nexit 101\n' > "$bp_file" chmod 0755 "$bp_file" BUILD_POLICY="$bp_file" } build_policy_restore() { [ -z "${BUILD_POLICY:-}" ] || rm -f "$BUILD_POLICY" BUILD_POLICY="" } # build_apt runs apt-get non-interactively; a failure removes the policy file and dies. # A fresh cloud image holds the dpkg lock for its first minutes (apt-daily, cloud-init): wait for it. build_apt() { if ! DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 "$@"; then build_policy_restore die "apt-get $1 failed" fi } # build_pgdg adds the PostgreSQL project's apt repository (Debian ships 15, not 16). build_pgdg() { bg_list="$(dest /etc/apt/sources.list.d/pgdg.list)" [ ! -f "$bg_list" ] || return 0 mkdir -p "$(dirname "$(dest "$PGDG_KEY")")" "$(dirname "$bg_list")" curl --fail --silent --show-error --retry 3 --proto '=https' --tlsv1.2 \ --output "$(dest "$PGDG_KEY")" https://www.postgresql.org/media/keys/ACCC4CF8.asc || { build_policy_restore; die "cannot fetch the PostgreSQL repository key"; } printf 'deb [signed-by=%s] https://apt.postgresql.org/pub/repos/apt %s-pgdg main\n' "$PGDG_KEY" "$1" > "$bg_list" } # build_check_pg_dump: backups need a pg_dump of the server's major version (16). build_check_pg_dump() { need_cmd pg_dump bc_major="$(pg_dump --version | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+(\.[0-9]+)*$/) { split($i, p, "."); print p[1]; exit } }')" [ "$bc_major" = 16 ] || die "pg_dump is version ${bc_major:-unknown}, not 16: nightly backups would fail" } build_user_and_dirs() { id -u "$APP_USER" >/dev/null 2>&1 || useradd --system --home "$APP_DIR" --shell /usr/sbin/nologin "$APP_USER" || die "cannot create the $APP_USER user" install -d -m 0755 "$(dest "$APP_DIR")" install -d -o root -g root -m 0755 "$(dest "$APP_DIR/bin")" install -d -o "$APP_USER" -g "$APP_USER" -m 0750 "$(dest "$APP_DIR/data")" "$(dest "$APP_DIR/uploads")" install -d -o root -g "$APP_USER" -m 0750 "$(dest "$APP_DIR/config")" } # build_binary installs the verified binary. An existing install is kept unless # a version was asked for: the supervisor owns updates after that. build_binary() { bb_bin="$(dest "$APP_DIR/bin/hatamoto")" if [ -x "$bb_bin" ] && [ -z "$IN_VERSION" ]; then log "hatamoto is already installed, keeping it" return 0 fi bb_version="$(trust_pick_version)" bb_dir="$(mktemp -d)" trust_fetch "$bb_version" "$bb_dir/hatamoto" install -m 0755 -o root -g root "$bb_dir/hatamoto" "$bb_bin" rm -rf "$bb_dir" log "installed hatamoto $bb_version" } # build_unit installs the shipped unit with the shipped installer, both embedded verbatim. build_unit() { bu_unit="$(dest /etc/systemd/system/hatamoto-api.service)" mkdir -p "$(dirname "$bu_unit")" bu_dir="$(mktemp -d)" payload_unit > "$bu_unit.new" payload_install_unit > "$bu_dir/install-unit.sh" UNIT="$bu_unit" sh "$bu_dir/install-unit.sh" >/dev/null || { rm -rf "$bu_dir"; die "cannot install the hatamoto-api unit"; } rm -rf "$bu_dir" } build_firewall() { [ "$IN_FIREWALL" != 0 ] || { log "firewall left alone (HATAMOTO_FIREWALL=0)"; return 0; } { ufw default deny incoming && ufw default allow outgoing && ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable } >/dev/null || die "cannot configure the firewall (set HATAMOTO_FIREWALL=0 if the provider firewall does the job)" } build_unattended() { bu_conf="$(dest /etc/apt/apt.conf.d/20auto-upgrades)" mkdir -p "$(dirname "$bu_conf")" printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > "$bu_conf" systemctl enable unattended-upgrades >/dev/null 2>&1 || warn "cannot enable unattended-upgrades" } # build_firstboot_files installs the first-boot script and the unit that runs it once. build_firstboot_files() { bf_lib="$(dest "$LIB_DIR")" bf_unit="$(dest /etc/systemd/system/hatamoto-firstboot.service)" mkdir -p "$bf_lib" "$(dirname "$bf_unit")" payload_firstboot > "$bf_lib/firstboot.sh" chmod 0755 "$bf_lib/firstboot.sh" # /etc/default/hatamoto-install may carry the HATAMOTO_* inputs for a sealed image. cat > "$bf_unit" </dev/null 2>&1 || die "cannot enable hatamoto-firstboot.service" } stage_build() { sb_os="$(build_os)" sb_id="${sb_os%% *}" sb_codename="${sb_os##* }" arch >/dev/null need_cmd apt-get log "build stage on $sb_os" build_policy_deny build_apt update -qq build_apt install -y --no-install-recommends ca-certificates curl gnupg if [ "$sb_id" = debian ]; then build_pgdg "$sb_codename" build_apt update -qq fi # shellcheck disable=SC2086 # the list is words on purpose build_apt install -y --no-install-recommends -o Dpkg::Options::=--force-confold $BUILD_PACKAGES build_policy_restore build_check_pg_dump build_user_and_dirs build_binary build_unit build_firewall build_unattended build_firstboot_files log "build stage done" } # Seal stage (build --seal only): strip host identity, history, caches and logs # so a snapshot of this machine carries nothing of the build. Never part of # `all`: cloud-init clean would erase the user-data run that is driving it. # # Commands that act on the live system (apt-get, passwd, cloud-init) only run # on a real host; with HATAMOTO_INSTALL_ROOT set the files are edited directly, # so a test can never touch the machine it runs on. # seal_homes prints the home directories whose per-user files are scrubbed. seal_homes() { printf '%s\n' "$(dest /root)" for sh_dir in "$(dest /home)"/*; do [ -d "$sh_dir" ] && printf '%s\n' "$sh_dir" done return 0 } seal_lock_passwords() { sl_shadow="$(dest /etc/shadow)" [ -f "$sl_shadow" ] || return 0 sl_users="root $(awk -F: '$3 >= 1000 && $3 < 65534 { print $1 }' "$(dest /etc/passwd)" 2>/dev/null)" for sl_user in $sl_users; do grep -q "^$sl_user:" "$sl_shadow" || continue sl_hash="$(awk -F: -v u="$sl_user" '$1 == u { print $2 }' "$sl_shadow")" case "$sl_hash" in "!"* | "*"*) continue ;; esac if [ -z "$R" ]; then passwd -l "$sl_user" >/dev/null else sed -i "s/^$sl_user:[^:]*:/$sl_user:!:/" "$sl_shadow" fi done } # seal_pg_install_exists succeeds when this machine's PostgreSQL already holds an # install of Hatamoto (only answerable while the server runs; a test replaces it). seal_pg_install_exists() { command -v psql >/dev/null 2>&1 || return 1 [ -n "$(sudo -n -u postgres psql -X -tA -c "select 1 from pg_roles where rolname = 'hatamoto' union all select 1 from pg_database where datname = 'hatamoto'" 2>/dev/null)" ] } # seal_refuse_used dies when first boot already ran here: the snapshot would carry that # install's database and password hash, which no scrub below removes. seal_refuse_used() { for sru_f in "$(dest "$STATE_DIR")/firstboot.done" "$(dest "$APP_DIR/config/hatamoto.env")" \ "$(dest /etc/systemd/system/multi-user.target.wants/hatamoto-api.service)"; do [ ! -e "$sru_f" ] || die "this machine already ran first boot ($sru_f exists): seal a fresh build, not an installed server" done ! seal_pg_install_exists || die "this machine's PostgreSQL already has a hatamoto role or database: seal a fresh build, not an installed server" } stage_seal() { seal_refuse_used log "sealing the image" if [ -z "$R" ]; then # Bring security updates in before the package lists go. DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq || warn "apt-get update failed before sealing" DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 -y -qq -o Dpkg::Options::=--force-confold upgrade || warn "apt-get upgrade failed before sealing" apt-get -o DPkg::Lock::Timeout=600 clean fi # Host identity. for ss_f in "$(dest /etc/ssh)"/ssh_host_*; do [ -e "$ss_f" ] && rm -f "$ss_f" done mkdir -p "$(dest /etc)" : > "$(dest /etc/machine-id)" rm -f "$(dest /var/lib/dbus/machine-id)" # Shared by every clone otherwise: the RNG seed and the TLS key PostgreSQL uses # (first boot makes a new pair). rm -f "$(dest /var/lib/systemd/random-seed)" "$(dest /etc/ssl/private/ssl-cert-snakeoil.key)" "$(dest /etc/ssl/certs/ssl-cert-snakeoil.pem)" # Per-user traces: keys, known hosts, history. seal_homes | while IFS= read -r ss_home; do rm -f "$ss_home"/.ssh/authorized_keys "$ss_home"/.ssh/known_hosts "$ss_home"/.ssh/id_* \ "$ss_home"/.bash_history "$ss_home"/.history "$ss_home"/.lesshst "$ss_home"/.viminfo done seal_lock_passwords # Packages and temp files. # ${:?} guards the globs: dest always yields a non-empty path, so this never expands to /*. seal_apt_lists="$(dest /var/lib/apt/lists)" rm -rf "${seal_apt_lists:?}"/* "$(dest /var/cache/apt)"/*.bin "$(dest /var/cache/apt/archives)"/*.deb if [ -d "$(dest /tmp)" ]; then find "$(dest /tmp)" -mindepth 1 -delete; fi if [ -d "$(dest /var/tmp)" ]; then find "$(dest /var/tmp)" -mindepth 1 -delete; fi # Anything that could be a secret of this install. rm -f "$(dest "$APP_DIR/config/hatamoto.env")" "$(dest "$APP_DIR/data/install_id")" rm -rf "$(dest "$APP_DIR/data/secrets")" "$(dest "$STATE_DIR")" \ "$(dest /var/lib/caddy/.local/share/caddy)" "$(dest /var/lib/caddy/.config/caddy)" # cloud-init: a fresh instance must run its modules again. if [ -z "$R" ]; then if command -v cloud-init >/dev/null 2>&1; then cloud-init clean --logs >/dev/null 2>&1 || warn "cloud-init clean failed"; fi else rm -rf "$(dest /var/lib/cloud)"/instance "$(dest /var/lib/cloud)"/instances fi # Logs last: everything above may have written some. if [ -d "$(dest /var/log/journal)" ]; then find "$(dest /var/log/journal)" -type f -delete; fi if [ -d "$(dest /var/log)" ]; then find "$(dest /var/log)" -type f \( -name '*.gz' -o -name '*.xz' -o -name '*.old' -o -name '*.[0-9]' -o -name '*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]' \) -delete find "$(dest /var/log)" -type f -exec sh -c ': > "$1"' _ {} \; fi [ -n "$R" ] || sync log "sealed: power off and take the snapshot now. Do not log in first, it would leave history and logs behind." } payload_unit() { cat <<'HM_PAYLOAD_UNIT' # hatamoto (API, site, admin, worker) on the deploy box. The unit name stays # hatamoto-api. Installed idempotently by `make deploy` / scripts/ship.sh. [Unit] Description=Hatamoto After=network-online.target postgresql.service Wants=network-online.target # Keep retrying forever (e.g. database down at boot) instead of giving up after 5 fast failures. StartLimitIntervalSec=0 [Service] Type=simple # Install keeps the User= and Group= of the unit already on the box # (deploy/install-unit.sh); these values only apply to a fresh install. User=hatamoto Group=hatamoto WorkingDirectory=/opt/hatamoto # Explicit, so the data dir does not depend on the working directory. The env # file below overrides it when it sets HATAMOTO_DATA itself. Environment=HATAMOTO_DATA=/opt/hatamoto/data EnvironmentFile=/opt/hatamoto/config/hatamoto.env # The supervisor runs `serve` as its child, switches releases under a health # gate and rolls back on its own. KillMode=mixed: systemd stops only the # supervisor, which forwards SIGTERM to the child and waits for it (up to 40 s); # TimeoutStopSec stays above that. ExecStart=/opt/hatamoto/bin/hatamoto supervise KillMode=mixed TimeoutStopSec=60 Restart=always RestartSec=5 LimitNOFILE=65536 NoNewPrivileges=true PrivateTmp=true [Install] WantedBy=multi-user.target HM_PAYLOAD_UNIT } payload_install_unit() { cat <<'HM_PAYLOAD_INSTALL_UNIT' #!/bin/sh # Runs ON THE BOX (fed to `sh -s` over ssh by `make install-unit` / `make check-unit`). # Installs the uploaded $UNIT.new over $UNIT, keeping the User= and Group= already # configured there (the tracked file only names defaults for a fresh install). # # Refuses (exit 1, nothing changed) when the unit on the box carries settings the # tracked unit would silently drop: drop-in files, or Environment= lines the # tracked unit lacks. Set FORCE_UNIT=1 to install anyway. The diff is always printed. # CHECK_ONLY=1 stops after the checks and never touches the unit. set -eu : "${UNIT:?}" new=$UNIT.new trap 'rm -f "$new"' EXIT u=$(systemctl show -p User --value hatamoto-api 2>/dev/null || true) g=$(systemctl show -p Group --value hatamoto-api 2>/dev/null || true) for kv in "User=$u" "Group=$g"; do k=${kv%%=*}; v=${kv#*=} case "$v" in ''|*[!a-zA-Z0-9_.-]*) ;; *) sed -i "s/^$k=.*/$k=$v/" "$new" ;; esac done # a unit without Group= in the tracked file but with one on the box: add it if [ -n "$g" ] && ! grep -q '^Group=' "$new"; then case "$g" in *[!a-zA-Z0-9_.-]*) ;; *) sed -i "/^User=/a Group=$g" "$new" ;; esac fi cur=$(systemctl show -p FragmentPath --value hatamoto-api 2>/dev/null || true) [ -n "$cur" ] || cur=$UNIT echo "-- unit file differences, box ($cur) vs tracked (after keeping User/Group)" if [ -f "$cur" ]; then diff -u "$cur" "$new" || true; else echo "(no unit on the box yet)"; fi problems="" dropins=$(systemctl show -p DropInPaths --value hatamoto-api 2>/dev/null || true) [ -z "$dropins" ] || problems="$problems drop-ins that stay active: $dropins" if [ -f "$cur" ]; then lost=$(grep '^Environment=' "$cur" | while IFS= read -r l; do grep -qxF "$l" "$new" || echo " $l"; done || true) [ -z "$lost" ] || problems="$problems Environment= lines the tracked unit lacks: $lost" fi if [ -n "$problems" ] && [ "${FORCE_UNIT:-}" != 1 ]; then echo "REFUSING to install the unit; it would change settings the repo does not know:$problems" >&2 echo "Fold them into deploy/systemd/hatamoto-api.service (or the env file), or rerun with FORCE_UNIT=1." >&2 exit 1 fi [ "${CHECK_ONLY:-}" != 1 ] || exit 0 if cmp -s "$new" "$UNIT"; then :; else mv -f "$new" "$UNIT"; fi systemctl daemon-reload HM_PAYLOAD_INSTALL_UNIT } payload_firstboot() { cat <<'HM_PAYLOAD_FIRSTBOOT' #!/bin/sh # Hatamoto VPS installer. Assembled by deploy/install/build-install.sh: do not # edit the assembled file, edit the parts under deploy/install/src. # # Inputs (environment, all optional): # HATAMOTO_DOMAIN public host name; empty serves on the sslip.io name of the public IP # HATAMOTO_VERSION release to install (default: newest fully ramped stable release) # HATAMOTO_ADMIN_EMAIL contact address for the certificate authority # HATAMOTO_SETUP_CODE pin the setup code (default: generated at first boot) # HATAMOTO_BACKUPS 1 (default) nightly local backups, 0 off # HATAMOTO_PUBLIC_IP public IPv4 address (default: detected) # HATAMOTO_TLS auto (default), internal or off # HATAMOTO_FIREWALL 1 (default) configure ufw, 0 skip # HATAMOTO_RELEASE_BASE release site (signatures still bind) set -eu # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_DOMAIN="${HATAMOTO_DOMAIN:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_VERSION="${HATAMOTO_VERSION:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_EMAIL="${HATAMOTO_ADMIN_EMAIL:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_SETUP_CODE="${HATAMOTO_SETUP_CODE:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_BACKUPS="${HATAMOTO_BACKUPS:-1}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_PUBLIC_IP="${HATAMOTO_PUBLIC_IP:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_TLS="${HATAMOTO_TLS:-auto}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts IN_FIREWALL="${HATAMOTO_FIREWALL:-1}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts RELEASE_BASE="${HATAMOTO_RELEASE_BASE:-https://hatamoto.dev/releases}" # R prefixes every path the installer writes; empty on a real host. R="${HATAMOTO_INSTALL_ROOT:-}" # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts LIB_DIR=/usr/local/lib/hatamoto # shellcheck disable=SC2034 # part contract: used by the install or firstboot parts STATE_DIR=/var/lib/hatamoto-install log() { printf '%s\n' "hatamoto: $*"; } warn() { printf '%s\n' "hatamoto: warning: $*" >&2; } die() { printf '%s\n' "hatamoto: error: $*" >&2; exit 1; } need_root() { [ -n "$R" ] || [ "$(id -u)" = 0 ] || die "run as root (for example: curl -fsSL https://hatamoto.dev/install.sh | sudo sh)" } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1" } # dest prints an absolute path under the install root. dest() { printf '%s%s\n' "$R" "$1" } # arch prints amd64 or arm64. arch() { case "$(uname -m)" in x86_64 | amd64) echo amd64 ;; aarch64 | arm64) echo arm64 ;; *) die "unsupported CPU architecture: $(uname -m) (amd64 and arm64 only)" ;; esac } # First-boot stage: runs once per instance (idempotent, resumes after a failure). # Creates every secret, the database, the env file and the Caddy site, starts # the services and prints the setup link. Nothing here runs at build time. APP_DIR=/opt/hatamoto APP_USER=hatamoto APP_PORT=8090 FB_ALPHABET='ABCDEFGHJKMNPQRSTUVWXYZ23456789' FB_TMP="" FB_IP="" FB_HOST="" FB_PENDING_DOMAIN="" fb_sleep() { sleep "${HATAMOTO_FIRSTBOOT_POLL:-1}"; } # fb_valid_ip IP succeeds for a dotted IPv4 address. fb_valid_ip() { printf '%s\n' "$1" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || return 1 fvi_ok=0 for fvi_o in $(printf '%s' "$1" | tr '.' ' '); do [ "$fvi_o" -le 255 ] || fvi_ok=1 done return "$fvi_ok" } # fb_valid_domain NAME: lower-case letters, digits, dots and dashes; no leading # or trailing dash or dot, no empty label, at least one dot. fb_valid_domain() { [ "${#1}" -le 253 ] || return 1 printf '%s\n' "$1" | grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' } # fb_sslip_name IP prints the dashed sslip.io name of an address. fb_sslip_name() { fb_valid_ip "$1" || die "not an IPv4 address: $1" printf '%s.sslip.io\n' "$(printf '%s' "$1" | tr '.' '-')" } # fb_gen_hex BYTES prints that many random bytes as lower-case hex. fb_gen_hex() { od -An -N"$1" -tx1 /dev/urandom | tr -d ' \n' echo } # fb_check_code CODE succeeds when the setup code is acceptable to the server # (backend/internal/config/admin.go): letters, digits and dashes only, at least # 12 characters once normalised, at least 4 distinct, not one short unit repeated. fb_check_code() { printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9-]+$' || { echo "use letters, digits and dashes only" >&2; return 1; } fcc_norm="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '-')" [ "${#fcc_norm}" -ge 12 ] || { echo "it needs at least 12 letters or digits (dashes do not count)" >&2; return 1; } fcc_distinct="$(printf '%s' "$fcc_norm" | fold -w1 | sort -u | wc -l | tr -d ' ')" [ "$fcc_distinct" -ge 4 ] || { echo "it needs at least 4 different characters" >&2; return 1; } fcc_rep="$(awk -v c="$fcc_norm" 'BEGIN { n = length(c); for (u = 1; u <= n / 2; u++) if (n % u == 0) { s = substr(c, 1, u); r = ""; for (i = 0; i < n / u; i++) r = r s; if (r == c) { print "rep"; exit } } }')" [ -z "$fcc_rep" ] || { echo "it must not be one short piece repeated" >&2; return 1; } } # fb_gen_code prints a fresh setup code like K7M2-RT9P-QW4X. fb_gen_code() { while :; do fgc_raw="$(LC_ALL=C tr -dc "$FB_ALPHABET" < /dev/urandom | head -c 12)" [ "${#fgc_raw}" -eq 12 ] || continue fgc_code="$(printf '%s' "$fgc_raw" | cut -c1-4)-$(printf '%s' "$fgc_raw" | cut -c5-8)-$(printf '%s' "$fgc_raw" | cut -c9-12)" fb_check_code "$fgc_code" 2>/dev/null || continue printf '%s\n' "$fgc_code" return 0 done } # fb_render_env DBPASSWORD SECRET CODE BASE_URL prints the service environment file. # Plain KEY=value lines (systemd reads them); the password is hex so it needs no URL encoding. # HATAMOTO_UPDATES is left unset on purpose: the default is automatic signed updates. fb_render_env() { cat < %s;\n' "$3" "${4:-}" printf ' will switch automatically)\n' fi printf ' Updates: automatic (stable channel)\n' printf ' Status: systemctl status hatamoto-api\n\n' printf ' This message disappears once the owner account exists.\n\n' } # fb_is_public IP succeeds for a valid IPv4 address outside the private, loopback, # link-local and "this network" ranges. fb_is_public() { fb_valid_ip "$1" || return 1 case "$1" in 0.* | 10.* | 127.* | 169.254.* | 192.168.*) return 1 ;; 172.*) fip_o="$(printf '%s' "$1" | cut -d. -f2)"; [ "$fip_o" -lt 16 ] || [ "$fip_o" -gt 31 ] ;; *) return 0 ;; esac } # fb_local_ips prints the IPv4 addresses bound to this machine's interfaces. fb_local_ips() { ip -4 -o addr show scope global 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "inet") { sub(/\/.*/, "", $(i + 1)); print $(i + 1) } }' } # fb_route_src prints the address this machine uses to reach the internet. fb_route_src() { ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit } }' } # fb_public_ip prints this machine's public IPv4 address. The setup link and the # site name are built from it, so the answer of a third party (ipify) is not trusted blindly: # - HATAMOTO_PUBLIC_IP wins, unchecked; # - an ipify answer that is private, loopback or 0.0.0.0 is ignored; # - an ipify answer that is bound to a local interface is used; # - one that differs from a public address bound here loses to the local address (a warning names both); # - with no public address bound here (NAT providers such as EC2 or Lightsail) it is used, with a # warning naming both: set HATAMOTO_PUBLIC_IP to silence it; # - without an answer, the source address of the default route. fb_public_ip() { if [ -n "$IN_PUBLIC_IP" ]; then fb_valid_ip "$IN_PUBLIC_IP" || die "HATAMOTO_PUBLIC_IP is not an IPv4 address: $IN_PUBLIC_IP" printf '%s\n' "$IN_PUBLIC_IP" return 0 fi fpi_net="$(curl -4 --silent --fail --max-time 8 --retry 2 --proto '=https' --tlsv1.2 https://api.ipify.org 2>/dev/null || true)" fpi_src="$(fb_route_src)" if fb_valid_ip "$fpi_net" && ! fb_is_public "$fpi_net"; then warn "ignoring $fpi_net from api.ipify.org: not a public address" fpi_net="" fi if fb_valid_ip "$fpi_net"; then fpi_bound=0 fpi_pub="" fb_is_public "$fpi_src" && fpi_pub="$fpi_src" for fpi_a in $(fb_local_ips); do [ "$fpi_a" != "$fpi_net" ] || fpi_bound=1 [ -n "$fpi_pub" ] || ! fb_is_public "$fpi_a" || fpi_pub="$fpi_a" done if [ "$fpi_bound" = 1 ] || { [ "$fpi_pub" = "$fpi_net" ] && [ -n "$fpi_pub" ]; }; then printf '%s\n' "$fpi_net" elif [ -n "$fpi_pub" ]; then warn "api.ipify.org says $fpi_net but this machine has the public address $fpi_pub: using $fpi_pub (set HATAMOTO_PUBLIC_IP to choose)" printf '%s\n' "$fpi_pub" else warn "api.ipify.org says $fpi_net, which is not bound on this machine (behind NAT, source address ${fpi_src:-unknown}): using it; set HATAMOTO_PUBLIC_IP if it is wrong" printf '%s\n' "$fpi_net" fi return 0 fi fb_valid_ip "$fpi_src" || die "cannot find the public IP address: set HATAMOTO_PUBLIC_IP" printf '%s\n' "$fpi_src" } # fb_dns_matches DOMAIN IP succeeds when DOMAIN has an A record equal to IP. fb_dns_matches() { getent ahostsv4 "$1" 2>/dev/null | awk -v ip="$2" '$1 == ip { found = 1 } END { exit !found }' } # fb_env_get FILE KEY prints the value of KEY in a plain env file. fb_env_get() { sed -n "s/^$2=//p" "$1" | head -n 1 } # fb_psql runs psql as the postgres superuser; SQL comes on stdin so no secret is in a process list. fb_psql() { sudo -u postgres psql -X -q -v ON_ERROR_STOP=1 "$@" } # fb_as_app CMD... runs the hatamoto binary as the service user from the app # directory with the service environment (never as root). fb_as_app() { sudo -u "$APP_USER" sh -c 'envfile=$1; app=$2; shift 2; set -a; . "$envfile"; set +a; cd "$app" && HATAMOTO_DATA="$app/data" exec "$app/bin/hatamoto" "$@"' sh \ "$(dest "$APP_DIR/config/hatamoto.env")" "$(dest "$APP_DIR")" "$@" } # fb_database creates the login role and database, or resets the role's password on a rerun. # The role needs CREATEROLE: plugins get their own Postgres roles (see "Plugin # database access" in the configuration reference) and the server creates them # through the application role. It is not a superuser. fb_database() { fd_pw="$1" fd_ready=0 fd_i=0 while [ "$fd_i" -lt 60 ]; do if [ "$(fb_psql -tA -c 'select 1' 2>/dev/null)" = 1 ]; then fd_ready=1; break; fi fd_i=$((fd_i + 1)) fb_sleep done [ "$fd_ready" = 1 ] || die "PostgreSQL does not answer: check systemctl status postgresql" if [ "$(fb_psql -tA -c "select 1 from pg_roles where rolname = '$APP_USER'")" = 1 ]; then printf "ALTER ROLE %s WITH LOGIN CREATEROLE PASSWORD '%s';\n" "$APP_USER" "$fd_pw" | fb_psql else printf "CREATE ROLE %s WITH LOGIN CREATEROLE PASSWORD '%s';\n" "$APP_USER" "$fd_pw" | fb_psql fi if [ "$(fb_psql -tA -c "select 1 from pg_database where datname = '$APP_USER'")" != 1 ]; then printf 'CREATE DATABASE %s OWNER %s;\n' "$APP_USER" "$APP_USER" | fb_psql fi } # fb_wait_ready waits up to 120 s for the app's own readiness probe. fb_wait_ready() { fwr_i=0 while [ "$fwr_i" -lt 120 ]; do if [ "$(curl -fsS --max-time 3 "http://127.0.0.1:$APP_PORT/ready" 2>/dev/null || true)" = ok ]; then return 0; fi fwr_i=$((fwr_i + 1)) fb_sleep done die "hatamoto did not become ready in 120 s: see journalctl -u hatamoto-api" } # fb_admin_url prints the private admin address (the first line must be a URL: before the # server's first start the command prints a sentence instead, so retry). fb_admin_url() { fau_i=0 while [ "$fau_i" -lt 30 ]; do fau_line="$(fb_as_app admin-url 2>/dev/null | head -n 1 || true)" case "$fau_line" in http://* | https://*) printf '%s\n' "$fau_line"; return 0 ;; esac fau_i=$((fau_i + 1)) fb_sleep done die "hatamoto did not report its admin address: run 'hatamoto admin-url' as the $APP_USER user" } # fb_wait_proxy checks /health through Caddy. A certificate that is not issued yet # is not an error: Caddy keeps retrying, so this only reports. fb_wait_proxy() { fwp_host="$1" fwp_i=0 while [ "$fwp_i" -lt 60 ]; do case "$IN_TLS" in off) fwp_out="$(curl -fsS --max-time 5 --resolve "$fwp_host:80:127.0.0.1" "http://$fwp_host/health" 2>/dev/null || true)" ;; internal) fwp_out="$(curl -fsSk --max-time 5 --resolve "$fwp_host:443:127.0.0.1" "https://$fwp_host/health" 2>/dev/null || true)" ;; *) fwp_out="$(curl -fsS --max-time 5 --resolve "$fwp_host:443:127.0.0.1" "https://$fwp_host/health" 2>/dev/null || true)" ;; esac [ -z "$fwp_out" ] || return 0 fwp_i=$((fwp_i + 1)) fb_sleep done return 1 } # fb_ensure_host_keys: a sealed image has none; sshd needs them before anyone can log in. fb_ensure_host_keys() { command -v ssh-keygen >/dev/null 2>&1 || return 0 for feh_k in "$(dest /etc/ssh)"/ssh_host_*_key; do [ ! -e "$feh_k" ] || return 0 done mkdir -p "$(dest /etc/ssh)" if [ -n "$R" ]; then ssh-keygen -A -f "$R" >/dev/null 2>&1 || true; else ssh-keygen -A >/dev/null 2>&1 || true; fi systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true } # fb_fresh_snakeoil makes this instance's own copy of the key PostgreSQL's default # config points at (the seal removes the image's; a shared key would be no secret). fb_fresh_snakeoil() { command -v make-ssl-cert >/dev/null 2>&1 || return 0 # make-ssl-cert reads debconf, which another package job may hold locked for a while at boot. ffs_i=0 until ffs_out="$(make-ssl-cert generate-default-snakeoil --force-overwrite 2>&1)"; do ffs_i=$((ffs_i + 1)) [ "$ffs_i" -lt 120 ] || die "cannot generate the TLS key PostgreSQL needs: $ffs_out" fb_sleep done } # fb_persist_one FILE NAME VALUE appends NAME=VALUE unless the file already has NAME # (an image author's value wins) or VALUE is empty or not plain enough for systemd's # EnvironmentFile and sh alike. fb_persist_one() { [ -n "$3" ] || return 0 ! grep -q "^$2=" "$1" || return 0 case "$3" in *[!A-Za-z0-9@._:/+%-]*) warn "$2 has unusual characters: not saved for a resumed run"; return 0 ;; esac printf '%s=%s\n' "$2" "$3" >> "$1" } # fb_persist_inputs saves the non-secret inputs where hatamoto-firstboot.service reads them, # so a run that fails and resumes at the next boot sees what the user asked for. # Never the setup code or any secret. fb_persist_inputs() { fpi_file="$(dest /etc/default/hatamoto-install)" mkdir -p "$(dirname "$fpi_file")" [ -e "$fpi_file" ] || : > "$fpi_file" chmod 0600 "$fpi_file" fb_persist_one "$fpi_file" HATAMOTO_DOMAIN "${HATAMOTO_DOMAIN:-}" fb_persist_one "$fpi_file" HATAMOTO_TLS "${HATAMOTO_TLS:-}" fb_persist_one "$fpi_file" HATAMOTO_BACKUPS "${HATAMOTO_BACKUPS:-}" fb_persist_one "$fpi_file" HATAMOTO_PUBLIC_IP "${HATAMOTO_PUBLIC_IP:-}" fb_persist_one "$fpi_file" HATAMOTO_FIREWALL "${HATAMOTO_FIREWALL:-}" fb_persist_one "$fpi_file" HATAMOTO_ADMIN_EMAIL "${HATAMOTO_ADMIN_EMAIL:-}" fb_persist_one "$fpi_file" HATAMOTO_VERSION "${HATAMOTO_VERSION:-}" } # fb_stage NAME runs a stage defined by another part. fb_stage() { command -v "$1" >/dev/null 2>&1 || die "stage not available in this build: $1" "$1" } stage_firstboot() { set +x umask 077 cd / fs_done="$(dest "$STATE_DIR")/firstboot.done" if [ -f "$fs_done" ]; then log "first boot already done" return 0 fi [ -x "$(dest "$APP_DIR/bin/hatamoto")" ] || die "hatamoto is not installed: run the build stage first (install.sh build)" need_cmd sudo need_cmd curl case "$IN_TLS" in auto | internal | off) ;; *) die "HATAMOTO_TLS must be auto, internal or off" ;; esac fb_persist_inputs FB_TMP="$(mktemp -d)" trap 'rm -rf "$FB_TMP"' EXIT install -d -m 0700 "$(dest "$STATE_DIR")" fb_ensure_host_keys # Where the site lives: the domain when it already points here, else the sslip.io name. fs_ip="$(fb_public_ip)" fs_host="$(fb_sslip_name "$fs_ip")" fs_pending="" if [ -n "$IN_DOMAIN" ]; then fs_domain="$(printf '%s' "$IN_DOMAIN" | tr '[:upper:]' '[:lower:]')" fb_valid_domain "$fs_domain" || die "HATAMOTO_DOMAIN is not a valid host name: $IN_DOMAIN" if fb_dns_matches "$fs_domain" "$fs_ip"; then fs_host="$fs_domain" else fs_pending="$fs_domain" warn "$fs_domain does not point to $fs_ip yet: serving on $fs_host and switching automatically once it does" fi fi # The parts below (and the DNS switch) read these. # shellcheck disable=SC2034 # read by the DNS-switch part FB_IP="$fs_ip" # shellcheck disable=SC2034 # read by the DNS-switch part FB_HOST="$fs_host" # shellcheck disable=SC2034 # read by the DNS-switch part FB_PENDING_DOMAIN="$fs_pending" if [ "$IN_TLS" = off ]; then fs_base="http://$fs_host"; else fs_base="https://$fs_host"; fi # Secrets: a rerun after a failure keeps the ones already written. fs_envfile="$(dest "$APP_DIR/config/hatamoto.env")" if [ -f "$fs_envfile" ] && [ -n "$(fb_env_get "$fs_envfile" HATAMOTO_SECRET)" ]; then fs_dbpw="$(fb_env_get "$fs_envfile" DATABASE_URL | sed -n 's#^postgres://[^:]*:\([^@]*\)@.*#\1#p')" fs_secret="$(fb_env_get "$fs_envfile" HATAMOTO_SECRET)" fs_code="$(fb_env_get "$fs_envfile" HATAMOTO_SETUP_CODE)" if [ -z "$fs_dbpw" ] || [ -z "$fs_code" ]; then die "the existing env file is incomplete: remove $fs_envfile and run again" fi log "resuming a first boot that did not finish" else fs_dbpw="$(fb_gen_hex 24)" fs_secret="$(fb_gen_hex 32)" if [ -n "$IN_SETUP_CODE" ]; then fb_check_code "$IN_SETUP_CODE" || die "HATAMOTO_SETUP_CODE is not acceptable" fs_code="$IN_SETUP_CODE" else fs_code="$(fb_gen_code)" fi fi fb_fresh_snakeoil systemctl enable --now postgresql >/dev/null 2>&1 || die "cannot start postgresql" # A sealed image started PostgreSQL at boot without the key: bring it up again. systemctl restart postgresql >/dev/null 2>&1 || die "cannot restart postgresql" fb_database "$fs_dbpw" fb_render_env "$fs_dbpw" "$fs_secret" "$fs_code" "$fs_base" > "$FB_TMP/hatamoto.env" install -m 0640 -o root -g "$APP_USER" "$FB_TMP/hatamoto.env" "$fs_envfile" fb_as_app migrate >/dev/null || die "database migration failed: see the output above" fs_caddy="$(dest /etc/caddy/Caddyfile)" mkdir -p "$(dirname "$fs_caddy")" if [ -f "$fs_caddy" ] && ! grep -q '^# Written by the Hatamoto installer' "$fs_caddy" && [ ! -e "$fs_caddy.orig" ]; then cp "$fs_caddy" "$fs_caddy.orig" fi fb_render_caddyfile "$fs_host" "$IN_TLS" "$IN_EMAIL" > "$FB_TMP/Caddyfile" install -m 0644 "$FB_TMP/Caddyfile" "$fs_caddy" # Restart, not just start: on a sealed image or a resumed run both services may # already be running with the package default config / the old env file. systemctl enable caddy >/dev/null 2>&1 || die "cannot enable caddy" systemctl restart caddy >/dev/null 2>&1 || die "cannot start caddy: see journalctl -u caddy" systemctl enable hatamoto-api >/dev/null 2>&1 || die "cannot enable hatamoto-api" systemctl restart hatamoto-api >/dev/null 2>&1 || die "cannot start hatamoto-api: see journalctl -u hatamoto-api" fb_wait_ready fs_admin="$(fb_admin_url)" printf '%s\n' "$fs_admin" > "$FB_TMP/link" install -m 0600 -o root "$FB_TMP/link" "$(dest "$STATE_DIR")/link" fs_link="$fs_admin/setup#code=$fs_code" if ! fb_wait_proxy "$fs_host"; then # With a public certificate the issuance may still be pending; with Caddy's own CA or plain http nothing can be. [ "$IN_TLS" = auto ] || die "no answer through Caddy: see journalctl -u caddy" warn "no answer through Caddy yet: the certificate may still be pending (Caddy keeps retrying); see journalctl -u caddy" fi # Housekeeping parts, then the done marker last. fb_stage install_motd fb_stage install_dns_switch fb_stage setup_swap fb_stage setup_backups fb_banner "$fs_link" "$fs_code" "$fs_pending" "$fs_ip" if [ -z "$R" ] && [ -w /dev/console ]; then fb_banner "$fs_link" "$fs_code" "$fs_pending" "$fs_ip" > /dev/console 2>/dev/null || true fi : > "$fs_done" log "first boot done" } # Login message and the DNS switch. # # The message is a script in /etc/update-motd.d: it reads the setup code from the # env file and the admin address cached by first boot, and stays silent once the # owner account exists. The DNS switch is a timer that moves the site from its # sslip.io name to the owner's domain as soon as that domain points here. DNS_UNIT=hatamoto-dns-switch install_motd() { im_file="$(dest /etc/update-motd.d/99-hatamoto)" mkdir -p "$(dirname "$im_file")" cat > "$im_file" <<'HM_MOTD' #!/bin/sh # Hatamoto first-run message. Shown at login until the owner account exists. lib="${HATAMOTO_INSTALL_ROOT:-}/usr/local/lib/hatamoto/firstboot.sh" [ -r "$lib" ] || exit 0 HATAMOTO_INSTALL_SOURCE_ONLY=1 export HATAMOTO_INSTALL_SOURCE_ONLY . "$lib" envfile="$(dest /opt/hatamoto/config/hatamoto.env)" linkfile="$(dest "$STATE_DIR/link")" [ -r "$envfile" ] && [ -r "$linkfile" ] || exit 0 owners="$(cd / && sudo -n -u postgres psql -X -d hatamoto -tAc 'select count(*) from hatamoto_users' 2>/dev/null)" || exit 0 [ "$owners" = 0 ] || exit 0 code="$(fb_env_get "$envfile" HATAMOTO_SETUP_CODE)" [ -n "$code" ] || exit 0 domain="" ip="" if [ -r "$(dest "$STATE_DIR/dns-pending")" ]; then read -r domain ip _ < "$(dest "$STATE_DIR/dns-pending")" || true fi fb_banner "$(head -n 1 "$linkfile")/setup#code=$code" "$code" "$domain" "$ip" HM_MOTD chmod 0755 "$im_file" } # dns_remove drops the timer and its files. dns_remove() { if [ -f "$(dest /etc/systemd/system/$DNS_UNIT.timer)" ]; then systemctl disable --now "$DNS_UNIT.timer" >/dev/null 2>&1 || true fi rm -f "$(dest /etc/systemd/system/$DNS_UNIT.timer)" "$(dest /etc/systemd/system/$DNS_UNIT.service)" \ "$(dest "$LIB_DIR/dns-switch.sh")" "$(dest "$STATE_DIR/dns-pending")" } # install_dns_switch sets up the timer when first boot served the sslip.io name # because the domain did not point here yet (FB_PENDING_DOMAIN, FB_IP). install_dns_switch() { if [ -z "$FB_PENDING_DOMAIN" ]; then dns_remove return 0 fi fb_valid_domain "$FB_PENDING_DOMAIN" || die "not a valid host name: $FB_PENDING_DOMAIN" fb_valid_ip "$FB_IP" || die "not an IPv4 address: $FB_IP" ids_state="$(dest "$STATE_DIR")" ids_unit="$(dest /etc/systemd/system)" install -d -m 0700 "$ids_state" mkdir -p "$ids_unit" "$(dest "$LIB_DIR")" printf '%s %s %s %s\n' "$FB_PENDING_DOMAIN" "$FB_IP" "$IN_TLS" "${IN_EMAIL:--}" > "$ids_state/dns-pending" cat > "$(dest "$LIB_DIR/dns-switch.sh")" <<'HM_SWITCH' #!/bin/sh # Moves the site to the owner's domain once it points at this machine. Run by # hatamoto-dns-switch.timer; prints nothing a setup code could be read from. set -eu HATAMOTO_INSTALL_SOURCE_ONLY=1 export HATAMOTO_INSTALL_SOURCE_ONLY . "${HATAMOTO_INSTALL_ROOT:-}/usr/local/lib/hatamoto/firstboot.sh" umask 077 pending="$(dest "$STATE_DIR/dns-pending")" if [ ! -f "$pending" ]; then systemctl disable --now hatamoto-dns-switch.timer >/dev/null 2>&1 || true exit 0 fi read -r domain ip tls email < "$pending" [ "$email" != - ] || email="" fb_valid_domain "$domain" || die "bad domain in $pending" fb_dns_matches "$domain" "$ip" || exit 0 log "$domain now points here: switching the site to it" if [ "$tls" = off ]; then base="http://$domain"; else base="https://$domain"; fi envfile="$(dest /opt/hatamoto/config/hatamoto.env)" tmp="$envfile.new" cp -p "$envfile" "$tmp" if grep -q '^PUBLIC_BASE_URL=' "$envfile"; then sed "s#^PUBLIC_BASE_URL=.*#PUBLIC_BASE_URL=$base#" "$envfile" > "$tmp" else { cat "$envfile"; printf 'PUBLIC_BASE_URL=%s\n' "$base"; } > "$tmp" fi mv -f "$tmp" "$envfile" caddyfile="$(dest /etc/caddy/Caddyfile)" fb_render_caddyfile "$domain" "$tls" "$email" > "$caddyfile.new" chmod 0644 "$caddyfile.new" mv -f "$caddyfile.new" "$caddyfile" systemctl restart hatamoto-api systemctl reload caddy || systemctl restart caddy fb_wait_ready fb_admin_url > "$(dest "$STATE_DIR/link").new" chmod 0600 "$(dest "$STATE_DIR/link").new" mv -f "$(dest "$STATE_DIR/link").new" "$(dest "$STATE_DIR/link")" rm -f "$pending" systemctl disable --now hatamoto-dns-switch.timer >/dev/null 2>&1 || true log "the site now answers on $domain" HM_SWITCH chmod 0755 "$(dest "$LIB_DIR/dns-switch.sh")" cat > "$ids_unit/$DNS_UNIT.service" < "$ids_unit/$DNS_UNIT.timer" <<'HM_TIMER' [Unit] Description=Check whether the Hatamoto domain points here [Timer] OnActiveSec=1min OnUnitActiveSec=5min [Install] WantedBy=timers.target HM_TIMER systemctl daemon-reload systemctl enable --now "$DNS_UNIT.timer" >/dev/null 2>&1 || die "cannot start $DNS_UNIT.timer" } # Swap for small machines and the nightly backup timer. # # Retention is the CLI's job (docs/BACKUPS.md: HATAMOTO_BACKUP_KEEP prunes after # every backup), so the unit does not delete files itself. BACKUP_UNIT=hatamoto-backup SWAP_MIN_KB=1048576 SWAP_MAX_KB=2097152 # setup_swap adds /swapfile when the machine has 2 GiB of RAM or less and no swap. setup_swap() { ssw_mem="$(awk '/^MemTotal:/ { print $2 }' "$(dest /proc/meminfo)" 2>/dev/null)" case "$ssw_mem" in '' | *[!0-9]*) warn "cannot read the memory size: no swap added"; return 0 ;; esac [ "$ssw_mem" -le 2097152 ] || return 0 if [ -f "$(dest /proc/swaps)" ] && [ "$(wc -l < "$(dest /proc/swaps)")" -gt 1 ]; then return 0; fi ssw_file="$(dest /swapfile)" [ ! -e "$ssw_file" ] || { log "swap file exists already"; return 0; } ssw_kb="$ssw_mem" [ "$ssw_kb" -ge "$SWAP_MIN_KB" ] || ssw_kb="$SWAP_MIN_KB" [ "$ssw_kb" -le "$SWAP_MAX_KB" ] || ssw_kb="$SWAP_MAX_KB" umask 077 if ! fallocate -l "${ssw_kb}K" "$ssw_file" 2>/dev/null; then dd if=/dev/zero of="$ssw_file" bs=1024 count="$ssw_kb" 2>/dev/null || { rm -f "$ssw_file"; warn "cannot create the swap file"; return 0; } fi chmod 0600 "$ssw_file" if ! mkswap "$ssw_file" >/dev/null 2>&1 || ! swapon "$ssw_file" 2>/dev/null; then rm -f "$ssw_file" warn "swap cannot be enabled here (a container?): continuing without it" return 0 fi ssw_fstab="$(dest /etc/fstab)" mkdir -p "$(dirname "$ssw_fstab")" grep -qs '^/swapfile[[:space:]]' "$ssw_fstab" || printf '/swapfile none swap sw 0 0\n' >> "$ssw_fstab" log "added $((ssw_kb / 1024)) MiB of swap" } # setup_backups installs the nightly backup timer when HATAMOTO_BACKUPS is 1, else removes it. setup_backups() { sb_dir="$(dest /etc/systemd/system)" if [ "$IN_BACKUPS" != 1 ]; then if [ -f "$sb_dir/$BACKUP_UNIT.timer" ]; then systemctl disable --now "$BACKUP_UNIT.timer" >/dev/null 2>&1 || true fi rm -f "$sb_dir/$BACKUP_UNIT.timer" "$sb_dir/$BACKUP_UNIT.service" return 0 fi mkdir -p "$sb_dir" cat > "$sb_dir/$BACKUP_UNIT.service" < "$sb_dir/$BACKUP_UNIT.timer" <<'HM_TIMER' [Unit] Description=Nightly Hatamoto backup [Timer] OnCalendar=*-*-* 03:30 RandomizedDelaySec=30min Persistent=true [Install] WantedBy=timers.target HM_TIMER systemctl daemon-reload systemctl enable --now "$BACKUP_UNIT.timer" >/dev/null 2>&1 || die "cannot enable $BACKUP_UNIT.timer" } # First-boot entry point. Carries no trust keys and downloads nothing. main() { [ -z "${HATAMOTO_INSTALL_SOURCE_ONLY:-}" ] || return 0 cmd="${1:-first-boot}" case "$cmd" in first-boot) need_root command -v stage_firstboot >/dev/null 2>&1 || die "stage not available in this build: stage_firstboot" stage_firstboot ;; version) echo "hatamoto-firstboot 1" ;; *) echo "usage: firstboot.sh [first-boot | version]" >&2 exit 2 ;; esac } main "$@" HM_PAYLOAD_FIRSTBOOT } # Entry point. Everything above is a function definition, so a download cut # short never runs half a script: main "$@" is the very last line. # run_part calls a stage defined by another part, or says it is not in this build. run_part() { command -v "$1" >/dev/null 2>&1 || die "stage not available in this build: $1" "$@" } # run_firstboot runs the installed first-boot script (a pipe cannot be re-read). run_firstboot() { [ -f "$(dest "$LIB_DIR")/firstboot.sh" ] || die "first-boot script is not installed: run the build stage first" sh "$(dest "$LIB_DIR")/firstboot.sh" first-boot } usage() { cat <<'HM_USAGE' usage: install.sh [all | build [--seal] | first-boot | download VERSION OUTFILE | version] all (default) build stage, then first-boot stage; never seals build install packages, the verified binary and the unit build --seal as build, then remove host identity and caches for an image first-boot create secrets, database and site, start the service download verify and fetch one release binary (used by the tests) version print the installer version HM_USAGE } main() { [ -z "${HATAMOTO_INSTALL_SOURCE_ONLY:-}" ] || return 0 cmd="${1:-all}" [ $# -eq 0 ] || shift case "$cmd" in all) [ $# -eq 0 ] || die "all takes no arguments" need_root run_part stage_build run_firstboot ;; build) need_root seal=0 case "${1:-}" in "") ;; --seal) seal=1 ;; *) die "unknown argument: $1" ;; esac run_part stage_build if [ "$seal" = 1 ]; then run_part stage_seal; fi ;; first-boot) need_root run_firstboot ;; download) [ $# -eq 2 ] || die "usage: install.sh download VERSION OUTFILE" run_part trust_fetch "$1" "$2" ;; version) echo "hatamoto-install 1" ;; -h | --help | help) usage ;; *) usage >&2 exit 2 ;; esac } main "$@"