#!/bin/sh # bootstrap.sh — self-service host bootstrap for the TeleICU fleet. # # curl -fsSL https://install.tellmey.fyi | sudo sh # # Brings a host to PULL-READY state (mirror of pull/bootstrap-pull.yml # steps 0-6) with no operator machine involved. Everything after the first # pull is owned by the ansible-pull timer (every 15 min, self-healing). # # Idempotent: every step is check-then-do. Re-running converges a drifted # host. Once keys exist the script is fully non-interactive, so re-curling # heals silently. # # Interactive inputs (read from /dev/tty — stdin is the curl pipe): # 1. host identity (e.g. sk-mangan-dh) — skipped if already enrolled # 2. bundle passphrase — skipped if keys already present # (or export BOOTSTRAP_PASSPHRASE / BOOTSTRAP_IDENTITY to skip prompts) # # Secrets: fetched as an openssl-encrypted bundle (bundle.age) from the same # host. Contains only the node-tier keys: RO clone key + node age key. # # The whole script is a function invoked on the LAST line, so a truncated # download can never execute a partial script. set -eu BASE_URL="${BOOTSTRAP_BASE_URL:-https://install.tellmey.fyi}" PULL_REPO="git@github.com:10bedicu/ansible-pull.git" PULL_BRANCH="nix-node-mvp" PULL_DIR="/opt/ansible-pull" PULL_INTERVAL="*:0/15" NIX_BIN="/nix/var/nix/profiles/default/bin" PROFILE="/home/teleicu/.nix-profile" TDIR="/etc/teleicu-pull" TIMEZONE="Asia/Kolkata" CURL="curl -fsSL --retry 5 --retry-delay 10 --retry-connrefused" log() { echo "[bootstrap] $*"; } die() { echo "[bootstrap] ERROR: $*" >&2; exit 1; } # A usable controlling terminal? (curl|sh leaves stdin as the pipe) has_tty() { [ -r /dev/tty ] && [ -w /dev/tty ] && sh -c ': /dev/null; } main() { [ "$(id -u)" = 0 ] || die "run as root: curl -fsSL $BASE_URL | sudo sh" command -v systemctl >/dev/null 2>&1 || die "systemd required" command -v openssl >/dev/null 2>&1 || die "openssl required (apt-get install -y openssl)" # ── Step 0: repair host (dpkg, clock) — best effort ─────────────────────── DEBIAN_FRONTEND=noninteractive dpkg --configure -a 2>/dev/null || true if ! pgrep -x apt-get >/dev/null 2>&1 && ! pgrep -x apt >/dev/null 2>&1 \ && ! pgrep -x dpkg >/dev/null 2>&1; then rm -f /var/lib/dpkg/lock /var/lib/dpkg/lock-frontend \ /var/lib/apt/lists/lock /var/cache/apt/archives/lock fi systemctl enable --now systemd-timesyncd 2>/dev/null || true timedatectl set-ntp true 2>/dev/null || true command -v chronyc >/dev/null 2>&1 && chronyc makestep 2>/dev/null || true command -v ntpdate >/dev/null 2>&1 && ntpdate -b time.google.com 2>/dev/null || true if ! getent hosts github.com >/dev/null 2>&1; then systemctl restart systemd-resolved 2>/dev/null || true sleep 2 fi timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true log "clock: $(date)" # ── Step 1: Determinate Nix + FlakeHub disable ───────────────────────────── if [ ! -f /nix/receipt.json ]; then log "installing Determinate Nix..." $CURL --proto '=https' --tlsv1.2 https://install.determinate.systems/nix -o /tmp/nix-installer.sh sh /tmp/nix-installer.sh install linux --determinate --no-confirm rm -f /tmp/nix-installer.sh fi "$NIX_BIN/nix" --version >/dev/null || die "nix install failed" if ! grep -q "use-registries = false" /etc/nix/nix.custom.conf 2>/dev/null; then cat > /etc/nix/nix.custom.conf <<'EOF' # Disable Determinate's FlakeHub source redirect and registry — fleet # nodes have unreliable internet; fetch straight from GitHub. extra-nix-path = use-registries = false EOF systemctl restart nix-daemon fi log "nix: $("$NIX_BIN/nix" --version)" # ── Step 2: teleicu user + passwordless sudo (validate BEFORE install) ──── id teleicu >/dev/null 2>&1 || useradd -m -s /bin/bash teleicu sudotmp="$(mktemp)" cat > "$sudotmp" <<'EOF' Defaults:teleicu !secure_path Defaults:teleicu env_keep += "PATH" teleicu ALL=(ALL) NOPASSWD:ALL EOF visudo -cf "$sudotmp" >/dev/null || { rm -f "$sudotmp"; die "sudoers validation failed"; } install -m 0440 -o root -g root "$sudotmp" /etc/sudoers.d/teleicu-nopasswd rm -f "$sudotmp" # ── Step 3: host identity + hostname ─────────────────────────────────────── mkdir -p "$TDIR" if [ -f "$TDIR/identity" ]; then IDENTITY="$(cat "$TDIR/identity")" elif [ -n "${BOOTSTRAP_IDENTITY:-}" ]; then IDENTITY="$BOOTSTRAP_IDENTITY" elif has_tty; then default="$(hostname -s)" while :; do printf "Host identity (fleet name, e.g. sk-mangan-dh) [%s]: " "$default" > /dev/tty read -r IDENTITY < /dev/tty || IDENTITY="" IDENTITY="${IDENTITY:-$default}" case "$IDENTITY" in *[!a-z0-9-]*|-*|"") echo "invalid: lowercase letters, digits, '-' only" > /dev/tty ;; [a-z][a-z]-*) break ;; *) printf "'%s' lacks a state prefix (xx-...). Use anyway? [y/N]: " "$IDENTITY" > /dev/tty read -r yn < /dev/tty || yn="" [ "$yn" = y ] || [ "$yn" = Y ] && break ;; esac done else die "no TTY and no BOOTSTRAP_IDENTITY set — cannot determine host identity. Non-interactive usage: curl -fsSL $BASE_URL | sudo BOOTSTRAP_IDENTITY= BOOTSTRAP_PASSPHRASE= sh" fi case "$IDENTITY" in *[!a-z0-9-]*|-*|"") die "identity '$IDENTITY' is invalid (lowercase, digits, '-')" ;; esac printf '%s\n' "$IDENTITY" > "$TDIR/identity" if [ "$(hostname -s)" != "$IDENTITY" ]; then hostnamectl set-hostname "$IDENTITY" 2>/dev/null || { hostname "$IDENTITY"; printf '%s\n' "$IDENTITY" > /etc/hostname; } fi if grep -q '^127\.0\.1\.1' /etc/hosts; then sed -i "s/^127\.0\.1\.1.*/127.0.1.1 $IDENTITY/" /etc/hosts else sed -i "/^127\.0\.0\.1/a 127.0.1.1 $IDENTITY" /etc/hosts fi log "identity: $IDENTITY" # ── Step 4: keys (from encrypted bundle; skipped when already present) ──── if [ ! -s "$TDIR/sops-age.key" ] || [ ! -s "$TDIR/deploy-key" ]; then tmp="$(mktemp)" # Restore terminal echo on any exit (Ctrl-C during passphrase prompt). trap 'rm -f "$tmp"; has_tty && stty echo < /dev/tty 2>/dev/null || true' EXIT log "fetching secrets bundle..." $CURL "$BASE_URL/bundle.age" -o "$tmp" if [ -n "${BOOTSTRAP_PASSPHRASE:-}" ]; then PASS="$BOOTSTRAP_PASSPHRASE" elif has_tty; then printf "Bundle passphrase: " > /dev/tty stty -echo < /dev/tty read -r PASS < /dev/tty stty echo < /dev/tty echo > /dev/tty else die "no TTY and no BOOTSTRAP_PASSPHRASE set — cannot decrypt secrets bundle" fi if ! printf '%s\n' "$PASS" | openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -a \ -in "$tmp" -pass stdin 2>/dev/null | tar xz -C "$TDIR" 2>/dev/null; then die "bundle decryption failed — wrong passphrase or corrupted download. Re-run to retry." fi unset PASS rm -f "$tmp"; trap - EXIT [ -s "$TDIR/sops-age.key" ] && [ -s "$TDIR/deploy-key" ] || \ die "bundle did not contain expected keys" log "keys installed" fi chown -R teleicu:teleicu "$TDIR" chmod 0600 "$TDIR/sops-age.key" "$TDIR/deploy-key" install -d -m 0700 -o teleicu -g teleicu /home/teleicu/.ssh cat > /home/teleicu/.ssh/config < /usr/local/bin/connectivity-watchdog <<'EOF' #!/bin/bash # Connectivity watchdog — provisioned by bootstrap, survives teardowns. LOG="logger -t connectivity-watchdog" # 1. Ensure sshd is running if ! systemctl is-active --quiet ssh && ! systemctl is-active --quiet sshd; then $LOG "sshd down — starting" systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null fi # 2. Ensure localhost SSH is reachable (k3s iptables fix) for port in 22 2222; do if ss -tln | grep -q ":${port} " && ! nc -z -w1 127.0.0.1 $port 2>/dev/null; then $LOG "localhost:${port} blocked — inserting iptables ACCEPT" iptables -C INPUT -i lo -p tcp --dport $port -j ACCEPT 2>/dev/null || \ iptables -I INPUT 1 -i lo -p tcp --dport $port -j ACCEPT fi done # 3. Ensure cloudflared is running if systemctl is-enabled --quiet cloudflared 2>/dev/null && ! systemctl is-active --quiet cloudflared; then $LOG "cloudflared down — restarting" systemctl restart cloudflared fi # 4. Ensure tailscaled is running (if unit exists) if [ -f /etc/systemd/system/tailscaled.service ] && ! systemctl is-active --quiet tailscaled; then $LOG "tailscaled down — restarting" systemctl restart tailscaled fi # 5. Ensure resolv.conf is not the broken stub if [ -L /etc/resolv.conf ] && [ "$(readlink /etc/resolv.conf)" = "/run/systemd/resolve/stub-resolv.conf" ]; then if ! getent hosts github.com >/dev/null 2>&1; then $LOG "DNS stub broken — switching to upstream resolv.conf" ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf fi fi EOF chmod 0755 /usr/local/bin/connectivity-watchdog cat > /etc/systemd/system/connectivity-watchdog.service <<'EOF' [Unit] Description=Connectivity watchdog (restore SSH access paths) [Service] Type=oneshot ExecStart=/usr/local/bin/connectivity-watchdog EOF cat > /etc/systemd/system/connectivity-watchdog.timer <<'EOF' [Unit] Description=Run connectivity watchdog every 2 minutes [Timer] OnBootSec=30 OnUnitActiveSec=120 AccuracySec=30 [Install] WantedBy=timers.target EOF # ── Step 6: ansible-pull service + timer ─────────────────────────────────── install -d -m 0755 "$PULL_DIR" chown -R teleicu:teleicu "$PULL_DIR" cat > /etc/systemd/system/ansible-pull.service < /etc/systemd/system/ansible-pull.timer <