#!/bin/sh # Give Emon SSH access to this machine (Linux or macOS). https://bootstrap.ihemon.me # # curl -fsSL https://bootstrap.ihemon.me/grant.sh | sudo sh # for you # curl -fsSL https://bootstrap.ihemon.me/grant.sh | sudo sh -s -- --root # for root # curl -fsSL https://bootstrap.ihemon.me/grant.sh | sudo sh -s -- --user NAME # for another user # ... | sudo sh -s -- --tailscale tskey-auth-... --ts-hostname guest-... # + join Emon's Tailscale # (the key comes only from the page, after Emon approved the request) # # What it does: # 1. Makes sure an SSH server is installed and running (and allowed by ufw/firewalld if they are on). # 2. Adds ONE public key, Emon-Key, to the user's ~/.ssh/authorized_keys. The key is read from # https://github.com/emon5122.keys and only the line with Emon-Key's fingerprint is used. # Running it again adds nothing new. # 3. Only with --tailscale: installs Tailscale if it is missing and joins Emon's tailnet as a guest with the # one-time key from the page (the device can reach nothing there; it disappears when it goes offline). # 4. Prints a short block to send to Emon. # It writes no other files (no copy of itself, no logs), never changes passwords, password login or # any other key. Undo: curl -fsSL https://bootstrap.ihemon.me/revoke.sh | sudo sh set -eu KEYS_URL="https://github.com/emon5122.keys" EMON_FP="SHA256:wVrLsx8kwxTscCD6oAcmcslPxagnZDKl1yaM2rwl39g" KEY_COMMENT="emon-key@bootstrap.ihemon.me" TRACE_URLS="https://bootstrap.ihemon.me/cdn-cgi/trace https://1.1.1.1/cdn-cgi/trace" # Emon's tailnet (MagicDNS suffix): a guest node of THIS tailnet is recognised by it (revoke.sh pins it too). TAILNET_SUFFIX="tailbdfb8.ts.net" say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } note() { printf ' %s\n' "$*"; } die() { printf '\nERROR: %s\n' "$*" >&2 exit 1 } have() { command -v "$1" >/dev/null 2>&1; } usage() { say "usage: grant.sh [--root | --user NAME] [--tailscale KEY --ts-hostname NAME]" say " default: the user who ran sudo (SUDO_USER), else the current user" say " --tailscale: the one-time key shown on https://bootstrap.ihemon.me after Emon approved your request" } # fetch URL [seconds] -> stdout fetch() { if have curl; then curl -fsSL --max-time "${2:-30}" "$1" elif have wget; then wget -qO- -T "${2:-30}" "$1" else return 127 fi } # key_fp "type blob" -> SHA256:... (ssh-keygen comes with the SSH server) key_fp() { printf '%s\n' "$1" | ssh-keygen -lf - 2>/dev/null | awk '{print $2}' } # stdin: an authorized_keys-style list -> stdout: "type blob" of the one line whose fingerprint is $1 select_key() { found="" while read -r ktype kblob _ || [ -n "${ktype:-}" ]; do case ${ktype:-} in ssh-* | ecdsa-* | sk-*) ;; *) continue ;; esac case ${kblob:-x} in *[!A-Za-z0-9+/=]*) continue ;; esac if [ "$(key_fp "$ktype $kblob")" = "$1" ]; then found="$ktype $kblob" fi ktype="" done [ -n "$found" ] || return 1 printf '%s\n' "$found" } detect_os() { OS_KIND=linux OS_NAME=$(uname -sr) if [ "$(uname -s)" = Darwin ]; then OS_KIND=macos OS_NAME="macOS $(sw_vers -productVersion 2>/dev/null || true)" return fi if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 OS_NAME=$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-${NAME:-Linux}}") fi if [ -d /etc/pve ]; then OS_NAME="$OS_NAME (Proxmox VE)"; fi } user_home() { if [ "$OS_KIND" = macos ]; then dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null | awk '{print $2; exit}' elif have getent; then getent passwd "$1" | cut -d: -f6 else awk -F: -v u="$1" '$1 == u {print $6; exit}' /etc/passwd fi } find_sshd() { for p in /usr/sbin/sshd /usr/bin/sshd /usr/local/sbin/sshd /sbin/sshd; do if [ -x "$p" ]; then printf '%s\n' "$p" return 0 fi done command -v sshd 2>/dev/null } install_sshd() { if have apt-get; then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server >/dev/null elif have dnf; then dnf install -y -q openssh-server >/dev/null elif have yum; then yum install -y -q openssh-server >/dev/null elif have pacman; then # Without -y first: a sync-only upgrade of one package can break an out-of-date Arch box. pacman -S --needed --noconfirm openssh >/dev/null 2>&1 || pacman -Sy --needed --noconfirm openssh >/dev/null elif have apk; then apk add --no-cache -q openssh-server openssh-keygen elif have zypper; then zypper --non-interactive -q install openssh-server >/dev/null else die "no known package manager. Install the OpenSSH server yourself, then run this again." fi } # true when an sshd process is running (Linux /proc scan; no ps/pgrep needed) sshd_process_running() { for d in /proc/[0-9]*; do [ "$(cat "$d/comm" 2>/dev/null)" = sshd ] || continue # a killed sshd that nobody reaped yet (zombie, state Z) is not running [ "$(awk '{print $3}' "$d/stat" 2>/dev/null)" = Z ] || return 0 done return 1 } mac_ssh_listening() { lsof -nP -iTCP:22 -sTCP:LISTEN >/dev/null 2>&1 } ensure_ssh_server() { NEWLY=no if [ "$OS_KIND" = macos ]; then if mac_ssh_listening; then note "Remote Login (SSH) is already on" return fi systemsetup -setremotelogin on >/dev/null 2>&1 || true if ! mac_ssh_listening; then launchctl enable system/com.openssh.sshd >/dev/null 2>&1 || true launchctl bootstrap system /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1 || true sleep 1 fi mac_ssh_listening || die "could not turn on Remote Login. Open System Settings > General > Sharing, turn on \"Remote Login\", then run this again." NEWLY=yes note "Remote Login (SSH) turned on" return fi SSHD=$(find_sshd || true) if [ -z "$SSHD" ]; then note "installing the OpenSSH server" install_sshd SSHD=$(find_sshd || true) [ -n "$SSHD" ] || die "the OpenSSH server did not install" NEWLY=yes fi have ssh-keygen || die "ssh-keygen is missing (it comes with OpenSSH)" if ! ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1; then ssh-keygen -A >/dev/null; fi if [ -d /run/systemd/system ] && have systemctl; then unit="" for u in ssh sshd; do if systemctl cat "$u.service" >/dev/null 2>&1; then unit=$u break fi done [ -n "$unit" ] || die "SSH server installed but no ssh/sshd systemd unit found" # Ubuntu 22.10+ starts sshd on demand through ssh.socket. if systemctl is-active --quiet "$unit.service" || systemctl is-active --quiet ssh.socket 2>/dev/null; then if ! systemctl is-enabled --quiet "$unit.service" 2>/dev/null && ! systemctl is-enabled --quiet ssh.socket 2>/dev/null; then systemctl enable "$unit.service" >/dev/null 2>&1 NEWLY=yes note "SSH server set to start at boot" else note "SSH server is already running" fi else # No `sshd -t` here: before the first start /run/sshd is missing (the unit creates it), and the units # check the config themselves (ExecStartPre). if ! systemctl enable --now "$unit.service" >/dev/null 2>&1; then systemctl status --no-pager -n 5 "$unit.service" >&2 || true die "could not start $unit.service (see above)" fi NEWLY=yes note "SSH server started and set to start at boot" fi elif have rc-service && [ -e /etc/init.d/sshd ] && [ -d /run/openrc ]; then if rc-service sshd status >/dev/null 2>&1; then note "SSH server is already running" else rc-update add sshd default >/dev/null 2>&1 || true rc-service sshd start >/dev/null || die "could not start sshd (rc-service sshd start)" NEWLY=yes note "SSH server started and set to start at boot" fi else # No service manager (container, WSL without systemd): start it directly. if sshd_process_running; then note "SSH server is already running" else mkdir -p /run/sshd chmod 755 /run/sshd "$SSHD" -t || die "the SSH server config has an error (see above)" "$SSHD" || die "could not start $SSHD" NEWLY=yes note "SSH server started (no service manager here: it will NOT start again after a reboot)" fi fi } ssh_port() { p="" if [ -n "${SSHD:-}" ]; then p=$("$SSHD" -T 2>/dev/null | awk '$1 == "port" {print $2; exit}') fi printf '%s\n' "${p:-22}" } open_firewall() { FW="" if [ "$OS_KIND" = linux ]; then if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then # An existing rule for the port (maybe limited to a subnet) is left exactly as it is. if ufw status 2>/dev/null | grep -Eq "^($1(/tcp)?|OpenSSH)[[:space:]]"; then FW="ufw already has a rule for $1/tcp (left as it is)" else ufw allow "$1/tcp" >/dev/null FW="ufw now allows $1/tcp (revoke does not remove this rule)" fi fi if have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then if firewall-cmd -q --query-port="$1/tcp" || { [ "$1" = 22 ] && firewall-cmd -q --query-service=ssh; }; then FW="${FW:+$FW, }firewalld already allows $1/tcp" else firewall-cmd -q --permanent --add-port="$1/tcp" firewall-cmd -q --add-port="$1/tcp" FW="${FW:+$FW, }firewalld opened $1/tcp" fi fi fi if [ -n "$FW" ]; then note "firewall: $FW"; fi } mac_allow_user() { # macOS "Allow access for: Only these users" is the com.apple.access_ssh group. if dscl . -read /Groups/com.apple.access_ssh >/dev/null 2>&1; then if ! dseditgroup -o checkmember -m "$1" com.apple.access_ssh >/dev/null 2>&1; then dseditgroup -o edit -a "$1" -t user com.apple.access_ssh note "added $1 to the Remote Login allowed users" fi fi } install_key() { tuser=$1 key=$2 home=$(user_home "$tuser") if [ -z "$home" ] || [ ! -d "$home" ]; then die "home folder of $tuser not found"; fi group=$(id -gn "$tuser") dir="$home/.ssh" ak="$dir/authorized_keys" [ ! -L "$dir" ] || die "$dir is a symlink; not touching it" [ ! -L "$ak" ] || die "$ak is a symlink; not touching it" if [ ! -d "$dir" ]; then mkdir -m 700 "$dir" chown "$tuser:$group" "$dir" fi chmod 700 "$dir" if [ ! -f "$ak" ]; then : >"$ak" chown "$tuser:$group" "$ak" fi chmod 600 "$ak" blob=${key#* } # present = on a line that is not commented out if LC_ALL=C awk -v b="$blob" '!/^[[:space:]]*#/ && index($0, b) {f = 1} END {exit !f}' "$ak"; then ADDED=no note "Emon-Key is already in $ak (nothing added)" else if [ -s "$ak" ] && [ -n "$(tail -c 1 "$ak")" ]; then printf '\n' >>"$ak"; fi printf '%s %s\n' "$key" "$KEY_COMMENT" >>"$ak" ADDED=yes note "Emon-Key added to $ak" fi if have restorecon; then restorecon -R "$dir" >/dev/null 2>&1 || true; fi } local_ips() { if [ "$OS_KIND" = macos ]; then ifconfig 2>/dev/null | awk '/inet / && $2 != "127.0.0.1" {printf "%s ", $2}' elif have ip; then ip -4 -o addr show scope global 2>/dev/null | awk '{sub(/\/.*/, "", $4); printf "%s ", $4}' elif hostname -I >/dev/null 2>&1; then hostname -I fi } public_ip() { for u in $TRACE_URLS; do ip=$(fetch "$u" 5 2>/dev/null | sed -n 's/^ip=//p') || true if [ -n "$ip" ]; then printf '%s\n' "$ip" return fi done printf 'unknown\n' } tailscale_ip() { ts="" for t in tailscale /Applications/Tailscale.app/Contents/MacOS/Tailscale; do if have "$t"; then ts=$("$t" ip -4 2>/dev/null | head -n 1) || true break fi done printf '%s\n' "${ts:-none}" } # ---------- Tailscale (optional, --tailscale) ---------- ts_cli() { # the tailscale CLI, or nothing for t in tailscale /Applications/Tailscale.app/Contents/MacOS/Tailscale; do if have "$t"; then printf '%s\n' "$t" return 0 fi done return 1 } tailscaled_running() { for d in /proc/[0-9]*; do [ "$(cat "$d/comm" 2>/dev/null)" = tailscaled ] || continue [ "$(awk '{print $3}' "$d/stat" 2>/dev/null)" = Z ] || return 0 done return 1 } # Linux: make sure tailscaled runs (service manager, else started directly, state in memory only). start_tailscaled() { if [ -d /run/systemd/system ] && have systemctl; then systemctl enable --now tailscaled >/dev/null 2>&1 || return 1 elif have rc-service && [ -e /etc/init.d/tailscale ] && [ -d /run/openrc ]; then rc-update add tailscale default >/dev/null 2>&1 || true rc-service tailscale start >/dev/null 2>&1 || return 1 elif ! tailscaled_running; then have tailscaled || return 1 tun="" [ -c /dev/net/tun ] || tun="--tun=userspace-networking" mkdir -p /var/run/tailscale # shellcheck disable=SC2086 # $tun is empty or one flag nohup tailscaled --state=mem: $tun >/dev/null 2>&1 & TS_NOTE=" (tailscaled started by hand: it stops at reboot)" fi i=0 while [ "$i" -lt 15 ]; do case $("$TS" status --json --peers=false 2>/dev/null) in *BackendState*) return 0 ;; esac sleep 1 i=$((i + 1)) done return 1 } # tailscale_join KEY HOSTNAME -> sets TS_RESULT; returns 1 when it did not join tailscale_join() { TS_RESULT="" TS_NOTE="" TS_INSTALLED=no TS=$(ts_cli || true) if [ -z "$TS" ]; then if [ "$OS_KIND" = macos ]; then TS_RESULT="not installed. Install Tailscale from https://tailscale.com/download/mac, open it once, then run this line again" return 1 fi note "installing Tailscale (https://tailscale.com/install.sh)" if ! fetch https://tailscale.com/install.sh 60 | sh >/dev/null; then TS_RESULT="could not install Tailscale (see above)" return 1 fi TS=$(ts_cli || true) [ -n "$TS" ] || { TS_RESULT="Tailscale did not install" return 1 } TS_INSTALLED=yes fi if [ "$OS_KIND" = linux ]; then start_tailscaled || { TS_RESULT="could not start tailscaled" return 1 } fi # status as one line without blanks, so the checks below don't depend on the JSON layout st=$("$TS" status --json --peers=false 2>/dev/null | tr -d ' \t\r\n' || true) case $st in *'"Tags":["tag:guest"]'*'"DNSName":"guest-'*".$TAILNET_SUFFIX."* | *'"DNSName":"guest-'*".$TAILNET_SUFFIX."*'"Tags":["tag:guest"]'*) case $st in *'"BackendState":"Running"'*) TS_RESULT="already joined as a guest: $("$TS" ip -4 2>/dev/null | head -n 1)" return 0 ;; esac ;; esac # Join only a client that is in no tailnet at all: logged out and without a node key. Anything else (running, # stopped with `tailscale down`, an expired key, an unreadable status) may be someone's own Tailscale: never moved. case $st in *'"BackendState":"NeedsLogin"'* | *'"BackendState":"NoState"'*) ;; *) TS_RESULT="skipped: this computer's Tailscale is already set up for a network (left as it is)" return 1 ;; esac case $st in *'"HaveNodeKey":true'*) TS_RESULT="skipped: this computer's Tailscale is already set up for a network (left as it is)" return 1 ;; esac note "joining Emon's Tailscale as $2 (guest: it can reach nothing there)" if ! "$TS" up --reset --auth-key="$1" --hostname="$2" --accept-routes=false --accept-dns=false --timeout=90s >/dev/null; then TS_RESULT="tailscale up failed (key used already or older than 1 hour? ask Emon for a new one)" return 1 fi TS_RESULT="joined as $2: $("$TS" ip -4 2>/dev/null | head -n 1)$TS_NOTE" } # Warn (never change anything) when sshd's own config would refuse Emon's key for this user. sshd_warnings() { [ -n "${SSHD:-}" ] || return 0 cfg=$("$SSHD" -T -C "user=$1,host=localhost,addr=127.0.0.1" 2>/dev/null) || return 0 sshd_opt() { printf '%s\n' "$cfg" | awk -v k="$1" '$1 == k {$1 = ""; sub(/^ /, ""); print; exit}'; } if [ "$(sshd_opt pubkeyauthentication)" = no ]; then note "WARNING: sshd has PubkeyAuthentication no: Emon's key will not work until it is yes" fi if [ "$1" = root ] && [ "$(sshd_opt permitrootlogin)" = no ]; then note "WARNING: sshd has PermitRootLogin no: Emon cannot log in as root (use --user NAME instead)" fi au=$(sshd_opt allowusers) if [ -n "$au" ]; then case " $au " in *" $1 "* | *" $1@"*) ;; *) note "WARNING: sshd AllowUsers ($au) does not list $1" ;; esac fi if [ -n "$(sshd_opt allowgroups)" ]; then note "note: sshd has AllowGroups ($(sshd_opt allowgroups)); $1 must be in one of them"; fi } main() { target="" ts_key="" ts_host="" while [ $# -gt 0 ]; do case $1 in --root) target=root ;; --user) [ $# -ge 2 ] || die "--user needs a name" target=$2 shift ;; --tailscale) [ $# -ge 2 ] || die "--tailscale needs the key from the page" ts_key=$2 shift ;; --ts-hostname) [ $# -ge 2 ] || die "--ts-hostname needs a name" ts_host=$2 shift ;; -h | --help) usage exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac shift done if [ -n "$ts_key$ts_host" ]; then case $ts_key in tskey-auth-*) ;; *) die "--tailscale: that is not a Tailscale auth key (copy the whole line from the page)" ;; esac case $ts_key in *[!A-Za-z0-9-]*) die "--tailscale: the key has characters a key never has" ;; esac case $ts_host in guest-?*) ;; *) die "--ts-hostname must be the guest-... name from the page" ;; esac case $ts_host in *[!a-z0-9-]*) die "--ts-hostname: only a-z, 0-9 and -" ;; esac fi say "Give Emon SSH access (https://bootstrap.ihemon.me)" [ "$(id -u)" -eq 0 ] || die "run it with sudo: curl -fsSL https://bootstrap.ihemon.me/grant.sh | sudo sh" have curl || have wget || die "curl or wget is needed" if [ -z "$target" ]; then target=${SUDO_USER:-}; fi if [ -z "$target" ]; then target=$(id -un); fi case $target in -* | *[!A-Za-z0-9._@-]*) die "bad user name: $target" ;; esac id "$target" >/dev/null 2>&1 || die "user '$target' does not exist" detect_os # The key first: a failed download or a missing fingerprint must stop us before anything changes. keys=$(fetch "$KEYS_URL") || die "could not download $KEYS_URL (internet?). Nothing was changed." key="" if have ssh-keygen; then key=$(printf '%s\n' "$keys" | select_key "$EMON_FP") || die "no key with fingerprint $EMON_FP at $KEYS_URL. Nothing was changed; tell Emon." fi step "SSH server" ensure_ssh_server step "Emon's key" if [ -z "$key" ]; then # ssh-keygen only came with the SSH server we just installed key=$(printf '%s\n' "$keys" | select_key "$EMON_FP") || die "no key with fingerprint $EMON_FP at $KEYS_URL. No key was added, but the SSH server was installed; to turn it off again run revoke.sh --stop-ssh. Tell Emon." fi install_key "$target" "$key" if [ "$OS_KIND" = macos ]; then mac_allow_user "$target"; fi port=$(ssh_port) open_firewall "$port" sshd_warnings "$target" ts_line="" if [ -n "$ts_key" ]; then step "Tailscale (guest)" if tailscale_join "$ts_key" "$ts_host"; then note "$TS_RESULT"; else note "Tailscale: $TS_RESULT"; fi ts_line=$TS_RESULT fi ips=$(local_ips) first_ip=${ips%% *} host=$(hostname 2>/dev/null || uname -n) say "" say "============ Done. Copy everything below and send it to Emon ============" say "user: $target" say "hostname: $host" say "os: $OS_NAME" say "local ips: ${ips:-unknown}" say "public ip: $(public_ip)" say "tailscale: $(tailscale_ip)" if [ -n "$ts_line" ]; then say "ts guest: $ts_line"; fi say "ssh port: $port" if [ "$NEWLY" = yes ]; then say "ssh server: newly turned on"; else say "ssh server: was already on"; fi if [ "$ADDED" = yes ]; then say "key: added"; else say "key: was already there"; fi say "login: ssh -p $port $target@${first_ip:-}" say "=========================================================================" say "To remove Emon's access later: curl -fsSL https://bootstrap.ihemon.me/revoke.sh | sudo sh" if [ "${TS_INSTALLED:-no}" = yes ]; then say "Tailscale was installed for this. revoke.sh logs the guest out; to remove Tailscale itself afterwards use" say "your package manager (e.g. apt-get remove tailscale / dnf remove tailscale / apk del tailscale)." fi } main "$@"