#!/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 "$@"