#!/usr/bin/env bash set -euo pipefail ############################################################################### # login.sh — OIDC kubeconfig for k8s-isg-dev # # curl -fsSL https://home.k8s-isg-dev.inf.ethz.ch/login.sh | bash # curl -fsSL https://home.k8s-isg-dev.inf.ethz.ch/login.sh | bash -s -- -s # standalone file # # Served by the cluster itself (home-base), with the cluster baked in. It # installs what's missing (kubectl, kubelogin), downloads this cluster's # kubeconfig from https://home.k8s-isg-dev.inf.ethz.ch/kubeconfig — built from the cluster's live CA, # so it never goes stale after a CA rotation — verifies the API server against # that CA, merges it into ~/.kube/config and logs you in. # # Run with -h for usage and flags. ############################################################################### # ---------------------------------------------------------------------------- # Configuration (rendered per cluster by home-base) # ---------------------------------------------------------------------------- CLUSTER_NAME="k8s-isg-dev" HOME_URL="https://home.k8s-isg-dev.inf.ethz.ch" API_SERVER="https://k8s-isg-dev.inf.ethz.ch:6443" ISSUER_URL="https://dex.k8s-isg-dev.inf.ethz.ch" CONTEXT_NAME="oidc@k8s-isg-dev" # kubelogin and kubectl are installed from their official releases when # missing; each download is checked against the SHA-256 published next to it. KUBELOGIN_LATEST="https://github.com/int128/kubelogin/releases/latest/download" # curl timeouts: short for API/Dex probes, generous for binary downloads. NET_TIMEOUT=(--connect-timeout 5 --max-time 15) DL_TIMEOUT=(--connect-timeout 10 --max-time 300) # kubelogin's local callback ports for the browser OAuth redirect, as written # into the kubeconfig (each is a redirect URI in Dex). Only used to warn early # when both are taken. LISTEN_ADDRS="127.0.0.1:8000 127.0.0.1:18000" # ---------------------------------------------------------------------------- # Output helpers # ---------------------------------------------------------------------------- if [ -t 2 ]; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' else C_RED=""; C_GRN=""; C_YEL=""; C_DIM=""; C_RST="" fi info() { echo "${C_DIM}•${C_RST} $*" >&2; } ok() { echo "${C_GRN}✓${C_RST} $*" >&2; } warn() { echo "${C_YEL}!${C_RST} $*" >&2; } die() { echo "${C_RED}✗ ERROR:${C_RST} $*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } # ---------------------------------------------------------------------------- # Callback-port probing — kubelogin needs a free local port for the browser # OAuth redirect; if it's taken (and no fallback is free) no window ever opens. # ---------------------------------------------------------------------------- port_in_use() { # port_in_use -> 0 if something is LISTENING, 1 if free ( exec 3<>"/dev/tcp/$1/$2" ) >/dev/null 2>&1 && return 0 || return 1 } who_on_port() { # best-effort name of the process holding a port have lsof || { printf 'unknown'; return; } lsof -nP -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | awk 'NR==2{printf "%s pid %s", $1, $2}' || printf 'unknown' } check_callback_ports() { local a host port busy="" free=0 for a in $LISTEN_ADDRS; do host="${a%:*}"; port="${a##*:}" if port_in_use "$host" "$port"; then busy="${busy:+$busy; }${a} (in use by $(who_on_port "$port"))" else free=$((free + 1)) fi done if [ "$free" -eq 0 ]; then warn "no free login callback port — $busy" warn "kubelogin can't open the browser window without one. Free a port (stop" warn "whatever is using it) and retry." elif [ -n "$busy" ]; then info "callback port busy: $busy — kubelogin will fall back to a free one" fi } # ---------------------------------------------------------------------------- # Interactive prompts — read from the controlling terminal, not stdin, so the # prompts still work when the script is piped in via `curl ... | bash`. # ---------------------------------------------------------------------------- TTY="" # `-r /dev/tty` can be true with no controlling terminal (then opening it fails), # so probe by actually opening it for reading. if { : < /dev/tty; } 2>/dev/null; then TTY=/dev/tty; fi ASSUME_YES=false confirm() { # confirm "question" -> 0 = yes, 1 = no local q="$1" reply $ASSUME_YES && return 0 [ -z "$TTY" ] && return 1 # non-interactive defaults to "no" printf "%s [Y/n] " "$q" > "$TTY" read -r reply < "$TTY" || return 1 case "$reply" in [nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac } usage() { cat >&2 <] [-t] [-y] Flags: -s, --separate Write a standalone file (~/.kube/${CLUSTER_NAME}.oidc.kubeconfig) instead of merging ~/.kube/config -o, --output-dir Output directory for the separate file (implies -s) -t, --skip-test Skip the post-login auth test -y, --yes Assume "yes" to prompts (install prereqs, replace an existing ${CONTEXT_NAME} entry without asking) -h, --help Show this help The kubeconfig entries are all named ${CONTEXT_NAME} (cluster, user, context). Re-running replaces them; the Talos admin@${CLUSTER_NAME} entries are never touched. On Linux the kubelogin args include --skip-open-browser (most Wayland setups can't open the browser popup); kubelogin then prints the login URL for you to open manually. On macOS the browser opens automatically. Without this script: download ${HOME_URL}/kubeconfig EOF exit "${1:-1}" } # ---------------------------------------------------------------------------- # Platform detection # ---------------------------------------------------------------------------- detect_platform() { OS="$(uname -s | tr '[:upper:]' '[:lower:]')" # darwin / linux case "$(uname -m)" in x86_64|amd64) GOARCH=amd64 ;; arm64|aarch64) GOARCH=arm64 ;; *) GOARCH="$(uname -m)" ;; esac PKG="" if [ "$OS" = "darwin" ]; then have brew && PKG=brew else for m in apt-get dnf yum pacman zypper apk; do have "$m" && { PKG="$m"; break; } done fi SUDO="" if [ "$(id -u)" -ne 0 ] && have sudo; then SUDO=sudo; fi } pkg_install() { # pkg_install case "$PKG" in brew) brew install "$@" ;; apt-get) $SUDO apt-get update -y >/dev/null && $SUDO apt-get install -y "$@" ;; dnf) $SUDO dnf install -y "$@" ;; yum) $SUDO yum install -y "$@" ;; pacman) $SUDO pacman -Sy --noconfirm "$@" ;; zypper) $SUDO zypper install -y "$@" ;; apk) $SUDO apk add "$@" ;; *) return 1 ;; esac } # Pick a writable bin dir on PATH; fall back to ~/.local/bin and add it. PATH_WARN="" choose_bindir() { if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then BINDIR=/usr/local/bin else BINDIR="$HOME/.local/bin" mkdir -p "$BINDIR" fi case ":$PATH:" in *":$BINDIR:"*) ;; *) export PATH="$BINDIR:$PATH"; PATH_WARN="$BINDIR" ;; esac } sha256() { # sha256 -> hex digest (coreutils on Linux, shasum on macOS) if have sha256sum; then sha256sum "$1" | cut -d' ' -f1 elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1 else openssl dgst -sha256 -r "$1" | cut -d' ' -f1 fi } verify_sha256() { # verify_sha256 local got got="$(sha256 "$1")" [ -n "$2" ] && [ "$got" = "$2" ] || die "$3 checksum mismatch (expected '$2', got '$got')" } install_kubectl() { info "installing kubectl ..." local ver base tmp want ver="$(curl -fsSL "${NET_TIMEOUT[@]}" https://dl.k8s.io/release/stable.txt)" || die "could not determine kubectl version" base="https://dl.k8s.io/release/${ver}/bin/${OS}/${GOARCH}" tmp="$(mktemp -d)" curl -fsSL "${DL_TIMEOUT[@]}" "${base}/kubectl" -o "$tmp/kubectl" \ || { rm -rf "$tmp"; die "kubectl download failed"; } want="$(curl -fsSL "${NET_TIMEOUT[@]}" "${base}/kubectl.sha256" | awk '{print $1}')" \ || { rm -rf "$tmp"; die "kubectl checksum download failed"; } verify_sha256 "$tmp/kubectl" "$want" kubectl install -m 0755 "$tmp/kubectl" "$BINDIR/kubectl" rm -rf "$tmp" } install_kubelogin() { info "installing latest kubelogin (kubectl oidc-login plugin) ..." have unzip || pkg_install unzip || die "need 'unzip' to install kubelogin; install it and retry" local tmp want zip="kubelogin_${OS}_${GOARCH}.zip" tmp="$(mktemp -d)" curl -fsSL "${DL_TIMEOUT[@]}" "${KUBELOGIN_LATEST}/${zip}" -o "$tmp/$zip" \ || { rm -rf "$tmp"; die "kubelogin download failed (no build for ${OS}/${GOARCH}?)"; } # Release assets ship a " *" line per zip. want="$(curl -fsSL "${NET_TIMEOUT[@]}" "${KUBELOGIN_LATEST}/${zip}.sha256" | awk '{print $1}')" \ || { rm -rf "$tmp"; die "kubelogin checksum download failed"; } verify_sha256 "$tmp/$zip" "$want" kubelogin unzip -qo "$tmp/$zip" -d "$tmp" install -m 0755 "$tmp/kubelogin" "$BINDIR/kubectl-oidc_login" rm -rf "$tmp" } kubelogin_present() { have kubectl-oidc_login || kubectl oidc-login --help >/dev/null 2>&1; } missing_prereqs() { have curl || echo curl have kubectl || echo kubectl kubelogin_present || echo kubelogin } ensure_prereqs() { local missing t missing="$(missing_prereqs | xargs)" if [ -z "$missing" ]; then ok "all prerequisites present" return 0 fi warn "missing prerequisites: ${missing}" info "platform: ${OS}/${GOARCH}${PKG:+ (package manager: $PKG)}" confirm "Install the missing prerequisite(s) now?" \ || die "cannot continue without: ${missing} (see ${HOME_URL}/docs/login)" choose_bindir for t in $missing; do case "$t" in curl) pkg_install curl || die "please install curl manually" ;; kubectl) install_kubectl ;; kubelogin) install_kubelogin ;; esac done hash -r 2>/dev/null || true missing="$(missing_prereqs | xargs)" [ -z "$missing" ] || die "still missing after install: ${missing}" ok "prerequisites installed" if [ -n "$PATH_WARN" ]; then warn "added $PATH_WARN to PATH for this run — add it to your shell profile to persist:" warn " echo 'export PATH=\"$PATH_WARN:\$PATH\"' >> ~/.profile" fi } # ---------------------------------------------------------------------------- # Argument parsing # ---------------------------------------------------------------------------- SEPARATE=false OUTPUT_DIR="" SKIP_TEST=false need_val() { [ -n "${2:-}" ] || die "option $1 needs a value"; } while [ $# -gt 0 ]; do case "$1" in -s|--separate) SEPARATE=true; shift ;; -o|--output-dir) need_val "$@"; OUTPUT_DIR="$2"; SEPARATE=true; shift 2 ;; -t|--skip-test) SKIP_TEST=true; shift ;; -y|--yes) ASSUME_YES=true; shift ;; -h|--help) usage 0 ;; # The old clusterlogin.sh took the cluster as argument (positional or -n); # tolerate it when it names this cluster. -n|--name) need_val "$@"; set -- "$2" "${@:3}" ;; -*) die "unknown option: $1" ;; "$CLUSTER_NAME") shift ;; *) die "this script logs in to ${CLUSTER_NAME} only — for '$1' use https://home.$1.inf.ethz.ch/login.sh" ;; esac done # ---------------------------------------------------------------------------- # Main # ---------------------------------------------------------------------------- detect_platform ensure_prereqs if $SEPARATE; then OUTPUT_FILE="${OUTPUT_DIR:-${HOME}/.kube}/${CLUSTER_NAME}.oidc.kubeconfig" else OUTPUT_FILE="${HOME}/.kube/config" fi mkdir -p "$(dirname "$OUTPUT_FILE")" # ---------------------------------------------------------------------------- # Existing-entry guard — never silently clobber a cluster that is already # configured: offer to replace, or cancel and leave everything untouched. # ---------------------------------------------------------------------------- if $SEPARATE; then if [ -e "$OUTPUT_FILE" ]; then warn "a kubeconfig for '${CLUSTER_NAME}' already exists: $OUTPUT_FILE" confirm "Overwrite the existing file?" \ || die "cancelled — existing kubeconfig left untouched (re-run with -y to overwrite)" fi elif [ -f "$OUTPUT_FILE" ] \ && KUBECONFIG="$OUTPUT_FILE" kubectl config get-contexts -o name 2>/dev/null | grep -qxF "$CONTEXT_NAME"; then warn "'${CONTEXT_NAME}' is already configured in $OUTPUT_FILE" confirm "Replace it?" \ || die "cancelled — existing entry left untouched (re-run with -y to replace)" fi info "cluster : $CLUSTER_NAME" info "api server : $API_SERVER" info "issuer : $ISSUER_URL" info "output : $OUTPUT_FILE" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # Preflight: Dex must serve a discovery document for exactly this issuer — # the API server rejects tokens whose `iss` doesn't match. info "checking Dex discovery ..." curl -fsS "${NET_TIMEOUT[@]}" "${ISSUER_URL}/.well-known/openid-configuration" -o "$TMP/discovery.json" \ || die "Dex issuer not reachable: ${ISSUER_URL}/.well-known/openid-configuration" grep -Eq "\"issuer\"[[:space:]]*:[[:space:]]*\"${ISSUER_URL}\"" "$TMP/discovery.json" \ || die "Dex at ${ISSUER_URL} announces a different issuer — tokens would be rejected by the API server" # The kubeconfig and the CA come from the cluster's own kube-root-ca.crt. # Kubelogin's --skip-open-browser is requested on Linux only. SKIP_BROWSER=false [ "$OS" = "linux" ] && SKIP_BROWSER=true info "downloading kubeconfig and cluster CA from ${HOME_URL} ..." curl -fsSL "${NET_TIMEOUT[@]}" "${HOME_URL}/kubeconfig?skip-open-browser=${SKIP_BROWSER}" -o "$TMP/kubeconfig" \ || die "could not download ${HOME_URL}/kubeconfig" curl -fsSL "${NET_TIMEOUT[@]}" "${HOME_URL}/ca.crt" -o "$TMP/ca.crt" \ || die "could not download ${HOME_URL}/ca.crt" # Prove the CA validates the live API server (hostname included). info "verifying ${API_SERVER} against the cluster CA ..." rc=0 curl -sS -o /dev/null "${NET_TIMEOUT[@]}" --cacert "$TMP/ca.crt" "${API_SERVER}/livez" 2>/dev/null || rc=$? case "$rc" in 0) ;; # any HTTP status (200, 401, ...) means TLS checked out 6) die "cannot resolve ${API_SERVER#https://} — DNS is unreachable or the cluster is gone" ;; 35|51|60) die "TLS verification of ${API_SERVER} failed against the CA from ${HOME_URL}/ca.crt — is something intercepting the connection?" ;; 28) die "timed out connecting to ${API_SERVER}" ;; *) die "cannot reach ${API_SERVER} (curl exit code $rc)" ;; esac if $SEPARATE; then info "writing kubeconfig: $OUTPUT_FILE" install -m 0600 "$TMP/kubeconfig" "$OUTPUT_FILE" else info "merging into existing kubeconfig: $OUTPUT_FILE" # Drop what an earlier run wrote, so nothing stale survives. Only the # oidc@ entries — talosctl's admin@ ones stay. if [ -f "$OUTPUT_FILE" ]; then export KUBECONFIG="$OUTPUT_FILE" kubectl config delete-context "$CONTEXT_NAME" >/dev/null 2>&1 || true kubectl config delete-user "$CONTEXT_NAME" >/dev/null 2>&1 || true kubectl config delete-cluster "$CONTEXT_NAME" >/dev/null 2>&1 || true fi # The downloaded file comes first, so its current-context wins: the merge # switches to the new context. Written next to the target, then moved over # it, so an interrupted run never leaves a truncated ~/.kube/config. MERGED="$(mktemp "${OUTPUT_FILE}.XXXXXX")" KUBECONFIG="$TMP/kubeconfig:$OUTPUT_FILE" kubectl config view --flatten > "$MERGED" \ || { rm -f "$MERGED"; die "merging the kubeconfig failed — $OUTPUT_FILE left untouched"; } chmod 600 "$MERGED" mv "$MERGED" "$OUTPUT_FILE" fi ok "kubeconfig ready" # Post-login auth test. Drive kubelogin's browser flow from the controlling # terminal so a first-time login works even under `curl | bash`. if ! $SKIP_TEST; then check_callback_ports # kubelogin talks on stderr. With --skip-open-browser (Linux) the login URL # is printed there, so it must reach the terminal instead of being discarded. ERR_SINK=/dev/null if $SKIP_BROWSER; then ERR_SINK="${TTY:-/dev/stderr}" info "testing login (kubelogin will print a URL — open it in your browser) ..." else info "testing login (a browser window may open for Dex) ..." fi export KUBECONFIG="$OUTPUT_FILE" whoami_cmd=(kubectl --context "$CONTEXT_NAME" auth whoami -o 'jsonpath={.status.userInfo.username}') if [ -n "$TTY" ]; then who="$("${whoami_cmd[@]}" < "$TTY" 2>"$ERR_SINK" || true)" else who="$("${whoami_cmd[@]}" 2>"$ERR_SINK" || true)" fi if [ -n "$who" ]; then ok "logged in as ${who}" else warn "login test did not succeed yet. Common causes:" warn " - OIDC not enabled on the API server (401 Unauthorized)" warn " - your user/group has no RBAC binding (403 Forbidden)" warn " - stale cached token — clear it and retry: rm -rf ~/.kube/cache/oidc-login" fi fi echo if $SEPARATE; then echo "To use this context:" echo " export KUBECONFIG=${OUTPUT_FILE}" echo " kubectl config use-context ${CONTEXT_NAME}" else echo "Active context is now: ${CONTEXT_NAME}" echo " kubectl get namespaces" fi