#!/usr/bin/env bash # # iSeed — Stage-1 bootstrap for a new machine in Daniel Klier's infinity network # # ONELINER (maintained ONLY here; README.md quotes this block): # # curl -fsSL /iseed | sudo bash -s -- --base-url --hostname [--authkey ] # # is where this file (and pad.pub next to it) is served from. 2026-10-21: # LAN : http://192.168.1.230:8000 pad, started with ~/my/net/onboarding/iseed-serve-lan.sh # WAN : https://seed.labs.ooo infinix, GitOps app deploy/k8s-iseed/ — prepared, NOT applied # # Steps (each idempotent; every mutating step honours --dry-run): # 0. os Debian/Ubuntu/Arch/Fedora → continue; macOS/other → exit 2 with instructions # 1. clock compare local clock with the HTTP Date header of a plain http:// URL (works # even when the wrong clock breaks TLS); step the clock if off by > 60 s; # set timezone if --tz given # 2. packages curl, ca-certificates, openssh-server, sudo, an NTP service if none exists # 3. ntp enable systemd-timesyncd / chrony, wait for "synchronized" # 4. sshd enable + start # 5. user create (default daniel) with sudo; install pad's pubkey (from # /pad.pub, or --pubkey / --pubkey-file) into authorized_keys # 6. hostname set if --hostname given # 7. mesh ONLY if --authkey given: install tailscale, join headscale at --login-server # (default https://vpn.infinix.labs.ooo) # 8. summary what to verify from pad # # The authkey is the only secret: never embedded, never echoed, never logged. # Exit codes: 0 ok · 1 runtime error · 2 unsupported OS / usage · 3 not root # # Author: Daniel Klier · Source of truth: ~/my/net/onboarding/iseed (repo ~/my/net) set -euo pipefail ISEED_VERSION="2026-10-21.1" # ------------------------------------------------------------------ defaults BASE_URL="${ISEED_BASE_URL:-https://seed.labs.ooo}" LOGIN_SERVER="${ISEED_LOGIN_SERVER:-https://vpn.infinix.labs.ooo}" AUTHKEY="${ISEED_AUTHKEY:-}" TARGET_USER="${ISEED_USER:-daniel}" NEW_HOSTNAME="" TIMEZONE="${ISEED_TZ:-}" PUBKEY="" PUBKEY_FILE="" DRY_RUN=0 DO_CLOCK=1 CLOCK_REF_URL="${ISEED_CLOCK_URL:-http://infinity.labs.ooo/}" # plain http on purpose CLOCK_TOLERANCE=60 # ------------------------------------------------------------------ output if [[ -t 1 ]]; then C_B='\033[0;34m'; C_G='\033[0;32m'; C_R='\033[0;31m'; C_Y='\033[1;33m'; C_0='\033[0m' else C_B=''; C_G=''; C_R=''; C_Y=''; C_0='' fi log() { printf '%b[∞]%b %s\n' "$C_B" "$C_0" "$*"; } ok() { printf '%b[✓]%b %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%b[!]%b %s\n' "$C_Y" "$C_0" "$*" >&2; } err() { printf '%b[✗]%b %s\n' "$C_R" "$C_0" "$*" >&2; } die() { err "$1"; exit "${2:-1}"; } # run: execute a mutating command, or only print it under --dry-run run() { if (( DRY_RUN )); then printf ' [dry-run] %s\n' "$*" return 0 fi "$@" } # run_sh: same for a shell snippet (pipes etc.) run_sh() { if (( DRY_RUN )); then printf ' [dry-run] sh -c %q\n' "$1" return 0 fi sh -c "$1" } usage() { cat </iseed | sudo bash -s -- [options] Options --base-url URL where iseed + pad.pub are served (default: $BASE_URL) --hostname NAME set the machine hostname --user NAME local user to create / grant access (default: $TARGET_USER) --pubkey 'KEY' ssh public key line to authorize (overrides pad.pub) --pubkey-file PATH file with the public key line --authkey KEY headscale pre-auth key → installs tailscale and joins the mesh --login-server URL headscale control URL (default: $LOGIN_SERVER) --tz ZONE set timezone, e.g. Europe/Berlin --no-clock skip the clock check / correction --dry-run print every mutating command instead of running it -h, --help this text Environment equivalents: ISEED_BASE_URL ISEED_USER ISEED_AUTHKEY ISEED_LOGIN_SERVER ISEED_TZ EOF } # ------------------------------------------------------------------ args while [[ $# -gt 0 ]]; do case "$1" in --base-url) BASE_URL="$2"; shift 2 ;; --hostname) NEW_HOSTNAME="$2"; shift 2 ;; --user) TARGET_USER="$2"; shift 2 ;; --pubkey) PUBKEY="$2"; shift 2 ;; --pubkey-file) PUBKEY_FILE="$2"; shift 2 ;; --authkey) AUTHKEY="$2"; shift 2 ;; --login-server) LOGIN_SERVER="$2"; shift 2 ;; --tz) TIMEZONE="$2"; shift 2 ;; --no-clock) DO_CLOCK=0; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown argument: $1" 2 ;; esac done BASE_URL="${BASE_URL%/}" # ------------------------------------------------------------------ 0. os detect_os() { OS_ID="unknown"; OS_LIKE=""; PKG="" case "$(uname -s)" in Darwin) err "macOS detected. iSeed does not manage macOS." err "Manual path: install Tailscale from the App Store, then" err " tailscale up --login-server $LOGIN_SERVER --authkey --hostname " err "and enable Remote Login (System Settings → General → Sharing)." exit 2 ;; Linux) ;; *) die "unsupported kernel: $(uname -s)" 2 ;; esac if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}"; OS_LIKE="${ID_LIKE:-}" fi case " $OS_ID $OS_LIKE " in *" debian "*|*" ubuntu "*) PKG=apt ;; *" arch "*) PKG=pacman ;; *" fedora "*|*" rhel "*) PKG=dnf ;; *) err "unsupported Linux distribution: ID=$OS_ID ID_LIKE='$OS_LIKE'" err "iSeed knows apt (Debian/Ubuntu), pacman (Arch) and dnf (Fedora/RHEL)." exit 2 ;; esac ok "OS: ${PRETTY_NAME:-$OS_ID} (package manager: $PKG)" } HAVE_SYSTEMD=0 [[ -d /run/systemd/system ]] && command -v systemctl >/dev/null 2>&1 && HAVE_SYSTEMD=1 pkg_install() { # install packages that are not yet present; $@ = package names (apt naming) local want=() p for p in "$@"; do case "$PKG" in apt) dpkg -s "$p" >/dev/null 2>&1 || want+=("$p") ;; pacman) pacman -Qi "$(pkg_map "$p")" >/dev/null 2>&1 || want+=("$(pkg_map "$p")") ;; dnf) rpm -q "$(pkg_map "$p")" >/dev/null 2>&1 || want+=("$(pkg_map "$p")") ;; esac done (( ${#want[@]} )) || { ok "packages present: $*"; return 0; } log "installing: ${want[*]}" case "$PKG" in apt) run_sh "DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ${want[*]}" ;; pacman) run pacman -Sy --noconfirm --needed "${want[@]}" ;; dnf) run dnf install -y -q "${want[@]}" ;; esac } pkg_map() { # apt name → distro name case "$PKG:$1" in pacman:openssh-server) echo openssh ;; dnf:openssh-server) echo openssh-server ;; pacman:systemd-timesyncd|dnf:systemd-timesyncd) echo chrony ;; *) echo "$1" ;; esac } # ------------------------------------------------------------------ 1. clock http_date_epoch() { # Date header of a plain-http response → epoch; empty on failure. 301/404 bodies are fine. local d d=$(curl -sI --max-time 8 "$CLOCK_REF_URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1)=="date:"{sub(/^[Dd]ate: /,""); print; exit}') [[ -n "$d" ]] || return 1 date -u -d "$d" +%s 2>/dev/null } step_clock() { (( DO_CLOCK )) || { warn "clock check skipped (--no-clock)"; return 0; } log "clock: local $(date -Is)" if command -v timedatectl >/dev/null 2>&1 && (( HAVE_SYSTEMD )); then timedatectl show -p Timezone -p NTP -p NTPSynchronized -p CanNTP 2>/dev/null | sed 's/^/ /' || true fi if [[ -n "$TIMEZONE" ]]; then if command -v timedatectl >/dev/null 2>&1 && (( HAVE_SYSTEMD )); then run timedatectl set-timezone "$TIMEZONE" else run ln -sfn "/usr/share/zoneinfo/$TIMEZONE" /etc/localtime fi ok "timezone → $TIMEZONE" fi local ref now off if ! ref=$(http_date_epoch); then warn "no HTTP Date from $CLOCK_REF_URL — cannot verify the clock (offline?)" return 0 fi now=$(date +%s) off=$(( ref - now )) if (( off > CLOCK_TOLERANCE || off < -CLOCK_TOLERANCE )); then warn "clock is off by ${off}s against $CLOCK_REF_URL — stepping" run date -u -s "@$ref" >/dev/null || warn "could not set the clock (container / no CAP_SYS_TIME?)" if command -v hwclock >/dev/null 2>&1; then run hwclock --systohc 2>/dev/null || true; fi ok "clock stepped to $(date -u -d "@$ref" -Is)" CLOCK_WAS_OFF=$off else ok "clock within ${off}s of reference" CLOCK_WAS_OFF=0 fi } CLOCK_WAS_OFF=0 # ------------------------------------------------------------------ 3. ntp step_ntp() { (( DO_CLOCK )) || return 0 (( HAVE_SYSTEMD )) || { warn "no systemd — NTP service left alone"; return 0; } local can can=$(timedatectl show -p CanNTP --value 2>/dev/null || echo unknown) if [[ "$can" != "yes" ]]; then # timedatectl says "NTP service: n/a" / "NTP not supported" → no time daemon installed log "no NTP service present (CanNTP=$can) — installing" pkg_install systemd-timesyncd fi if systemctl list-unit-files chronyd.service chrony.service 2>/dev/null | grep -q -E '^chrony'; then run systemctl enable --now chrony.service 2>/dev/null || run systemctl enable --now chronyd.service else run timedatectl set-ntp true fi (( DRY_RUN )) && return 0 for _ in $(seq 1 20); do [[ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" == "yes" ]] && { ok "NTP synchronized"; return 0; } sleep 1 done warn "NTP not synchronized after 20s — check 'timedatectl timesync-status' / 'chronyc tracking'" } # ------------------------------------------------------------------ 4. sshd step_sshd() { (( HAVE_SYSTEMD )) || { warn "no systemd — not enabling sshd"; return 0; } local unit for unit in ssh sshd; do if systemctl list-unit-files "$unit.service" 2>/dev/null | grep -q "^$unit.service"; then if systemctl is-enabled --quiet "$unit" 2>/dev/null && systemctl is-active --quiet "$unit" 2>/dev/null; then ok "sshd ($unit) enabled and running" else run systemctl enable --now "$unit" ok "sshd ($unit) enabled + started" fi return 0 fi done warn "no ssh/sshd unit found" } # ------------------------------------------------------------------ 5. user + key fetch_pubkey() { if [[ -n "$PUBKEY" ]]; then return 0; fi if [[ -n "$PUBKEY_FILE" ]]; then PUBKEY=$(head -n1 "$PUBKEY_FILE"); return 0; fi PUBKEY=$(curl -fsSL --max-time 10 "$BASE_URL/pad.pub" 2>/dev/null | head -n1 || true) [[ -n "$PUBKEY" ]] || die "no public key: $BASE_URL/pad.pub unreachable and no --pubkey given" } step_user() { fetch_pubkey [[ "$PUBKEY" =~ ^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp[0-9]+)\ [A-Za-z0-9+/=]+ ]] || die "public key does not look like an OpenSSH key line" local sudo_group=sudo home case "$PKG" in pacman|dnf) sudo_group=wheel ;; esac if id "$TARGET_USER" >/dev/null 2>&1; then ok "user $TARGET_USER exists" else run useradd -m -s /bin/bash -G "$sudo_group" "$TARGET_USER" ok "user $TARGET_USER created (group $sudo_group)" fi if (( ! DRY_RUN )) && ! id -nG "$TARGET_USER" | tr ' ' '\n' | grep -qx "$sudo_group"; then run usermod -aG "$sudo_group" "$TARGET_USER" fi home=$(getent passwd "$TARGET_USER" | cut -d: -f6 || true) home="${home:-/home/$TARGET_USER}" local ak="$home/.ssh/authorized_keys" if [[ -f "$ak" ]] && grep -qF "$(awk '{print $2}' <<<"$PUBKEY")" "$ak"; then ok "pubkey already authorized for $TARGET_USER" else run install -d -m 700 -o "$TARGET_USER" -g "$TARGET_USER" "$home/.ssh" run_sh "printf '%s\n' '$PUBKEY' >> '$ak'" run chown "$TARGET_USER:$TARGET_USER" "$ak" run chmod 600 "$ak" ok "pubkey authorized for $TARGET_USER ($(awk '{print $NF}' <<<"$PUBKEY"))" fi } # ------------------------------------------------------------------ 6. hostname step_hostname() { [[ -n "$NEW_HOSTNAME" ]] || { log "hostname unchanged: $(hostname)"; return 0; } if [[ "$(hostname)" == "$NEW_HOSTNAME" ]]; then ok "hostname already $NEW_HOSTNAME"; return 0; fi if command -v hostnamectl >/dev/null 2>&1 && (( HAVE_SYSTEMD )); then run hostnamectl set-hostname "$NEW_HOSTNAME" else run_sh "printf '%s\n' '$NEW_HOSTNAME' > /etc/hostname" run hostname "$NEW_HOSTNAME" 2>/dev/null || warn "hostname written to /etc/hostname, runtime change refused (container?)" fi ok "hostname → $NEW_HOSTNAME" } # ------------------------------------------------------------------ 7. mesh step_mesh() { if [[ -z "$AUTHKEY" ]]; then log "no --authkey → mesh join skipped. Later, as root:" log " tailscale up --login-server $LOGIN_SERVER --authkey --hostname ${NEW_HOSTNAME:-$(hostname)}" return 0 fi if ! command -v tailscale >/dev/null 2>&1; then log "installing tailscale (official installer, needs TLS → clock must be right)" run_sh "curl -fsSL https://tailscale.com/install.sh | sh" else ok "tailscale present: $(tailscale version 2>/dev/null | head -n1)" fi (( HAVE_SYSTEMD )) && run systemctl enable --now tailscaled if (( ! DRY_RUN )) && tailscale status --json 2>/dev/null | grep -q '"BackendState": *"Running"'; then ok "tailscale already running — re-running 'up' to apply hostname/login-server" fi # the key is passed as an argument but never printed: run() would echo it, so mask here if (( DRY_RUN )); then printf ' [dry-run] tailscale up --login-server %s --authkey --hostname %s --accept-dns=true\n' \ "$LOGIN_SERVER" "${NEW_HOSTNAME:-$(hostname)}" else tailscale up --login-server "$LOGIN_SERVER" --authkey "$AUTHKEY" \ --hostname "${NEW_HOSTNAME:-$(hostname)}" --accept-dns=true ok "joined mesh: $(tailscale ip -4 2>/dev/null | head -n1)" fi } # ------------------------------------------------------------------ main main() { log "iSeed $ISEED_VERSION $( (( DRY_RUN )) && echo '(DRY-RUN — nothing is changed)' )" if (( EUID != 0 )) && (( ! DRY_RUN )); then die "must run as root: curl -fsSL $BASE_URL/iseed | sudo bash -s -- ..." 3 fi detect_os # curl is needed for the clock reference; apt/pacman/dnf mirrors are plain http, so this # works before the clock is fixed command -v curl >/dev/null 2>&1 || pkg_install curl ca-certificates step_clock pkg_install curl ca-certificates openssh-server sudo step_ntp step_sshd step_user step_hostname step_mesh local ip4 ip4=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1 || true) [[ -n "$ip4" ]] || ip4=$(hostname -I 2>/dev/null | awk '{print $1}' || true) echo ok "iSeed done on ${NEW_HOSTNAME:-$(hostname)} (${ip4:-no ipv4})" (( CLOCK_WAS_OFF != 0 )) && warn "clock was off by ${CLOCK_WAS_OFF}s — browser/TLS errors should be gone now" echo " verify from pad: ssh $TARGET_USER@${ip4:-} hostname" [[ -n "$AUTHKEY" ]] && echo " verify mesh: tailscale ping ${NEW_HOSTNAME:-$(hostname)} (on pad)" echo " clock: timedatectl (System clock synchronized: yes)" } main "$@"