#!/usr/bin/env sh # termio — SSH alias & connection manager # POSIX sh compatible (bash & zsh safe) # Version 3.9.0 # # USAGE: # termio [-v] Interactive TUI/CLI menu # termio [-v] add Add a new SSH alias # termio [-v] connect Connect to a saved alias # termio [-v] ls|list [--group ] List all aliases (optionally filter by group) # termio [-v] info Show alias details + fingerprint # termio [-v] edit Edit an alias # termio [-v] rm Remove an alias # termio [-v] test [alias] Test one alias, or all if no alias given # termio [-v] rotate Rotate SSH key for an alias # termio [-v] export [file] Export aliases to a portable file # termio [-v] import Import aliases from export file # termio [-v] run Run a one-off remote command # termio [-v] copy Copy files via sftp (alias:/path) # termio [-v] status Test all connections (dashboard) # termio [-v] audit Audit SSH key ages # termio prefer [whiptail|cli] Show or set preferences # termio prefer audit_threshold Key rotation threshold (days) # termio prefer key_type Default key type (ed25519/rsa/ecdsa) # termio [-v] sync Show sync status # termio [-v] sync init Set up sync folder # termio [-v] sync detach Remove sync, restore files locally # termio [-v] sync keys Show key sync status # termio [-v] sync keys enable Opt in to encrypted key sync # termio [-v] sync keys disable Remove encrypted key archive # termio [-v] sync keys update Rebuild encrypted key archive # termio [-v] snip List all snippets # termio [-v] snip add Add a new snippet # termio [-v] snip run [--group] Run a snippet on one or more hosts # termio [-v] snip edit Edit a snippet # termio [-v] snip rm Remove a snippet # termio [-v] snip info Show snippet details # termio [-v] tunnel List tunnels and status # termio [-v] tunnel add Add a named port-forward tunnel # termio [-v] tunnel start Start a tunnel # termio [-v] tunnel stop Stop a running tunnel # termio [-v] tunnel rm Remove a tunnel # termio [-v] agent List alias keys in ssh-agent # termio [-v] agent add [alias] Load alias key(s) into agent # termio [-v] agent rm Remove alias key from agent # termio [-v] agent clear Remove all keys from agent # termio [-v] backup List SSH config backups # termio [-v] backup restore Restore a numbered backup # termio pin Pin alias to top of list # termio unpin Unpin alias # termio [-v] clone [] Clone an alias as a new starting point # termio [-v] rename Rename an alias (updates keys + prefs) # termio tag Add a group tag to an alias # termio untag Remove a group tag from an alias # termio [-v] open Open an SFTP session to an alias # termio [-v] wake Send Wake-on-LAN magic packet # termio [-v] log [alias] Show connection history # termio [-v] diff Diff local aliases vs sync export # termio template list List saved add-wizard templates # termio template save Save current defaults as a template # termio template rm Remove a template # termio version Show version # termio help Show this help # # FLAGS: # -v | --verbose Show background operations normally run silently TERMIO_VERSION="3.9.0" set -e # ── Verbose flag — parse before anything else ───────────────────────────────── # Supports: termio -v or termio --verbose # Strips the flag so $1 is always the subcommand downstream. TERMIO_VERBOSE=0 if [ "${1:-}" = "-v" ] || [ "${1:-}" = "--verbose" ]; then TERMIO_VERBOSE=1 shift fi # ── Paths ───────────────────────────────────────────────────────────────────── SSH_DIR="$HOME/.ssh" CONFIG_FILE="$SSH_DIR/config" CONF_DIR="$HOME/.config/termio" PREF_FILE="$CONF_DIR/preferences" # ── Colours ─────────────────────────────────────────────────────────────────── # Whiptail/newt color theme — dark background, cyan accents, matches CLI palette export NEWT_COLORS='root=white,black:border=cyan,black:window=white,black:shadow=black,black:title=cyan,black:button=black,cyan:actbutton=black,brightcyan:checkbox=white,black:actcheckbox=black,cyan:entry=white,black:label=cyan,black:listbox=white,black:actlistbox=white,black:textbox=white,black:acttextbox=white,black:helpline=black,black:roottext=cyan,black:sellistbox=white,black:actsellistbox=black,cyan' C_RESET='\033[0m' C_BOLD='\033[1m' C_DIM='\033[2m' C_CYAN='\033[0;36m' C_GREEN='\033[0;32m' C_YELLOW='\033[0;33m' C_RED='\033[0;31m' C_MAGENTA='\033[0;35m' C_BLUE='\033[0;34m' C_WHITE='\033[0;37m' C_ORANGE='\033[38;5;214m' # warm amber — accent colour C_LBLUE='\033[38;5;39m' # bright sky-blue — primary chrome # ── Output helpers ──────────────────────────────────────────────────────────── die() { printf "${C_RED}Error:${C_RESET} %b\n" "$1" >&2; exit 1; } info() { printf "${C_CYAN} →${C_RESET} %b\n" "$1"; } success() { printf "${C_GREEN} ✔${C_RESET} %b\n" "$1"; } warn() { printf "${C_YELLOW} ⚠${C_RESET} %b\n" "$1"; } # vlog — verbose-only output, silent unless -v flag set vlog() { [ "$TERMIO_VERBOSE" -eq 1 ] && printf "${C_DIM} [v] %b${C_RESET}\n" "$1" || true; } # ── Display helpers ─────────────────────────────────────────────────────────── # Convert "YYYY-MM-DD HH:MM" string to epoch seconds — GNU date -d or BSD date -j _ts_to_epoch() { date -d "$1" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "$1" +%s 2>/dev/null } # base64 decode — GNU: -d, BSD (macOS): -D _base64_decode() { printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 -D 2>/dev/null } # Convert stored YYYY-MM-DD HH:MM timestamp to human-readable relative string _rel_time() { _rt_ts="$1" [ -z "$_rt_ts" ] && { printf 'never'; return; } _rt_now=$(date +%s) _rt_then=$(_ts_to_epoch "$_rt_ts") || { printf '%s' "$_rt_ts" | cut -c1-10; return; } _rt_diff=$(( _rt_now - _rt_then )) if [ "$_rt_diff" -lt 3600 ]; then printf 'just now' elif [ "$_rt_diff" -lt 86400 ]; then printf 'today' elif [ "$_rt_diff" -lt 172800 ]; then printf 'yesterday' elif [ "$_rt_diff" -lt 604800 ]; then printf '%d days ago' "$(( _rt_diff / 86400 ))" elif [ "$_rt_diff" -lt 2592000 ]; then printf '%d weeks ago' "$(( _rt_diff / 604800 ))" else printf '%d months ago' "$(( _rt_diff / 2592000 ))"; fi } # Deterministic group → ANSI color (awk char-sum hash mod 6) _group_color() { _gc_name="$1" [ -z "$_gc_name" ] && { printf '%s' "$C_DIM"; return; } _gc_hash=$(printf '%s' "$_gc_name" | awk '{s=0;for(i=1;i<=length($0);i++)s+=index("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",substr($0,i,1));print s%6}') case "$_gc_hash" in 0) printf '%s' "$C_LBLUE" ;; 1) printf '%s' "$C_GREEN" ;; 2) printf '%s' "$C_YELLOW" ;; 3) printf '%s' "$C_MAGENTA" ;; 4) printf '%s' "$C_ORANGE" ;; 5) printf '%s' "$C_CYAN" ;; *) printf '%s' "$C_DIM" ;; esac } # Extract key type badge from key filename (always 5 chars wide) _key_badge() { case "$1" in *id_rsa_*) printf '[rsa]' ;; *id_ecdsa_*) printf '[ec] ' ;; *) printf '[ed] ' ;; esac } # Draw a horizontal box rule: _ls_hline LEFT SEP RIGHT w1 w2 ... _ls_hline() { _lh_l="$1"; _lh_s="$2"; _lh_r="$3"; shift 3 printf '%s' "$_lh_l" _lh_first=1 for _lh_w in "$@"; do [ "$_lh_first" -eq 0 ] && printf '%s' "$_lh_s" _lh_first=0 _lh_i=0 while [ "$_lh_i" -lt "$_lh_w" ]; do printf '─'; _lh_i=$(( _lh_i + 1 )); done done printf '%s\n' "$_lh_r" } print_banner() { # Build sync status line for banner _sync_path=$(pref_get "sync_path") if [ -n "$_sync_path" ]; then if [ -d "$_sync_path" ]; then _last_export=$(pref_get "sync_last_export") _sync_line="Sync: ${C_GREEN}✔${C_RESET} ${_sync_path}" [ -n "$_last_export" ] && _sync_line="${_sync_line} ${C_DIM}(exported: ${_last_export})${C_RESET}" else _sync_line="Sync: ${C_YELLOW}⚠${C_RESET} folder not reachable" fi else _sync_line="Sync: ${C_DIM}not configured${C_RESET}" fi printf '\n' printf "${C_LBLUE}╔══════════════════════════════════════════════╗${C_RESET}\n" printf "${C_LBLUE}║${C_RESET} ${C_BOLD}${C_WHITE}termio${C_RESET} ${C_DIM}—${C_RESET} SSH Connection Manager ${C_ORANGE}v%-6s${C_RESET} ${C_LBLUE}║${C_RESET}\n" "$TERMIO_VERSION" printf "${C_LBLUE}╚══════════════════════════════════════════════╝${C_RESET}\n" printf " %b\n\n" "$_sync_line" } # ── Preferences ─────────────────────────────────────────────────────────────── pref_get() { [ -f "$PREF_FILE" ] || { printf ''; return; } awk -F'=' -v k="$1" '$1 == k { print $2; exit }' "$PREF_FILE" } pref_set() { mkdir -p "$CONF_DIR" touch "$PREF_FILE"; chmod 600 "$PREF_FILE" if grep -q "^$1=" "$PREF_FILE" 2>/dev/null; then awk -F'=' -v k="$1" -v v="$2" \ 'BEGIN{OFS="="} $1==k{$2=v} {print}' \ "$PREF_FILE" > "${PREF_FILE}.tmp" \ && mv "${PREF_FILE}.tmp" "$PREF_FILE" else printf '%s=%s\n' "$1" "$2" >> "$PREF_FILE" fi vlog "pref_set: $1=$2 → $PREF_FILE" } # ── Distro detection ────────────────────────────────────────────────────────── detect_distro() { DISTRO_ID=""; DISTRO_FAMILY=""; WHIPTAIL_PKG=""; WHIPTAIL_INSTALL_CMD="" if [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then DISTRO_ID="macos"; DISTRO_FAMILY="darwin" WHIPTAIL_PKG="newt" vlog "Distro: macOS → package: newt (brew install newt)" return fi if [ -f /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_FAMILY="${ID_LIKE:-$ID}" fi case "$DISTRO_FAMILY $DISTRO_ID" in *debian*|*ubuntu*|*mint*|*pop*|*elementary*|*kali*|*raspbian*) WHIPTAIL_PKG="whiptail" WHIPTAIL_INSTALL_CMD="apt-get install -y whiptail" ;; *fedora*|*rhel*|*centos*|*rocky*|*alma*|*ol*) WHIPTAIL_PKG="newt" WHIPTAIL_INSTALL_CMD="dnf install -y newt" ;; *arch*|*manjaro*|*endeavour*) WHIPTAIL_PKG="libnewt" WHIPTAIL_INSTALL_CMD="pacman -S --noconfirm libnewt" ;; *suse*|*opensuse*) WHIPTAIL_PKG="newt" WHIPTAIL_INSTALL_CMD="zypper install -y newt" ;; *) WHIPTAIL_PKG="whiptail or newt" WHIPTAIL_INSTALL_CMD="" ;; esac vlog "Distro: $DISTRO_ID (family: $DISTRO_FAMILY) → package: $WHIPTAIL_PKG" } install_whiptail() { detect_distro printf '\n' printf "${C_CYAN} About whiptail:${C_RESET}\n" printf " Whiptail provides graphical dialog boxes in the terminal —\n" printf " the same library used by the Debian/Raspberry Pi installers.\n" printf " Package to install: ${C_BOLD}%s${C_RESET}\n\n" "$WHIPTAIL_PKG" if [ -z "$WHIPTAIL_INSTALL_CMD" ]; then if [ "$DISTRO_ID" = "macos" ]; then warn "macOS detected — cannot auto-install via termio." printf " Install with Homebrew:\n" printf " ${C_BOLD}brew install newt${C_RESET}\n" printf " Then run: ${C_BOLD}termio prefer whiptail${C_RESET}\n\n" else warn "Unrecognised distro — cannot auto-install." printf " Install whiptail or newt manually, then run:\n" printf " ${C_BOLD}termio prefer whiptail${C_RESET}\n\n" fi return 1 fi printf " ${C_BOLD}Command that will be run:${C_RESET}\n" printf " sudo %s\n\n" "$WHIPTAIL_INSTALL_CMD" printf " ${C_BOLD}Install now? (y/N):${C_RESET} " IFS= read -r _confirm case "$_confirm" in [yY]|[yY][eE][sS]) : ;; *) printf '\n Installation cancelled.\n' printf " Preference saved — termio will use whiptail once installed.\n\n" return 1 ;; esac printf "\n ${C_BOLD}sudo password${C_RESET} (will not echo): " stty -echo 2>/dev/null || true IFS= read -r _sudo_pass stty echo 2>/dev/null || true printf '\n\n' info "Installing $WHIPTAIL_PKG ..." printf '%s\n' "$_sudo_pass" | sudo -S sh -c "$WHIPTAIL_INSTALL_CMD" 2>&1 \ | sed 's/^/ /' || { unset _sudo_pass warn "Installation failed. Check output above." printf " Try manually: ${C_BOLD}sudo %s${C_RESET}\n\n" "$WHIPTAIL_INSTALL_CMD" return 1 } unset _sudo_pass if command -v whiptail >/dev/null 2>&1; then HAS_WHIPTAIL=1 success "whiptail installed successfully." return 0 else warn "whiptail still not found after install." return 1 fi } # ── UI mode resolution ──────────────────────────────────────────────────────── HAS_WHIPTAIL=0 _WHIPTAIL_INSTALLED=0 command -v whiptail >/dev/null 2>&1 && _WHIPTAIL_INSTALLED=1 _UI_PREF=$(pref_get "ui") case "$_UI_PREF" in cli) HAS_WHIPTAIL=0 vlog "UI: forced CLI by preference" ;; whiptail) if [ "$_WHIPTAIL_INSTALLED" -eq 1 ]; then HAS_WHIPTAIL=1 vlog "UI: whiptail (preferred + installed)" else printf "${C_YELLOW} ⚠${C_RESET} UI preference is whiptail but it is not installed.\n" printf " Run ${C_BOLD}termio prefer whiptail${C_RESET} to install it.\n\n" HAS_WHIPTAIL=0 fi ;; *) HAS_WHIPTAIL=$_WHIPTAIL_INSTALLED vlog "UI: auto-detect → $([ "$HAS_WHIPTAIL" -eq 1 ] && printf 'whiptail' || printf 'cli')" ;; esac TW=$(tput cols 2>/dev/null || printf '80') TH=$(tput lines 2>/dev/null || printf '24') [ "$TW" -gt 100 ] && TW=100 [ "$TH" -gt 30 ] && TH=30 [ "$TW" -lt 60 ] && TW=60 [ "$TH" -lt 20 ] && TH=20 # ── Dependency check ────────────────────────────────────────────────────────── check_deps() { for _cmd in ssh ssh-keygen ssh-copy-id awk; do command -v "$_cmd" >/dev/null 2>&1 \ || die "'$_cmd' not found. Install openssh-client first." done } # ── CLI prompt helpers ──────────────────────────────────────────────────────── cli_prompt() { _label="$1"; _default="${2:-}" _PROMPT_CANCELLED=0 if [ -n "$_default" ]; then printf " ${C_BOLD}%s${C_RESET} [${C_DIM}%s${C_RESET}]: " "$_label" "$_default" else printf " ${C_BOLD}%s${C_RESET}: " "$_label" fi if IFS= read -r _PROMPT_RESULT; then [ -z "$_PROMPT_RESULT" ] && _PROMPT_RESULT="$_default" else _PROMPT_RESULT=""; _PROMPT_CANCELLED=1 fi } cli_prompt_secret() { printf " ${C_BOLD}%s${C_RESET} (will not echo): " "$1" stty -echo 2>/dev/null || true IFS= read -r _PROMPT_RESULT stty echo 2>/dev/null || true printf '\n' } # ── Whiptail / CLI wrappers ─────────────────────────────────────────────────── wt_input() { _label="$1"; _default="${2:-}" _PROMPT_CANCELLED=0 if [ "$HAS_WHIPTAIL" -eq 1 ]; then _PROMPT_RESULT=$(whiptail --inputbox "$_label" 8 64 "$_default" \ --title "termio" --backtitle "termio v${TERMIO_VERSION}" \ 3>&1 1>&2 2>&3) || { _PROMPT_RESULT=""; _PROMPT_CANCELLED=1; } else cli_prompt "$_label" "$_default" fi } wt_password() { if [ "$HAS_WHIPTAIL" -eq 1 ]; then _PROMPT_RESULT=$(whiptail --passwordbox \ "$1\n(used once — never saved)" \ 10 64 --title "termio" --backtitle "termio v${TERMIO_VERSION}" \ 3>&1 1>&2 2>&3) || _PROMPT_RESULT="" else cli_prompt_secret "$1" fi } wt_yesno() { if [ "$HAS_WHIPTAIL" -eq 1 ]; then whiptail --yesno "$1" 10 64 \ --title "termio" --backtitle "termio v${TERMIO_VERSION}" \ 3>&1 1>&2 2>&3 return $? else cli_prompt "$1 (y/N)" "N" case "$_PROMPT_RESULT" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac fi } wt_msg() { if [ "$HAS_WHIPTAIL" -eq 1 ]; then whiptail --msgbox "$1" 14 64 \ --title "termio" --backtitle "termio v${TERMIO_VERSION}" \ 3>&1 1>&2 2>&3 || true else printf '\n %s\n\n Press Enter to continue...' "$1" IFS= read -r _dummy fi } # ── ~/.ssh/config helpers ───────────────────────────────────────────────────── list_aliases() { [ -f "$CONFIG_FILE" ] || return 0 grep '^Host ' "$CONFIG_FILE" | awk '{print $2}' | grep -v '^\*$' || true } _warn_ssh_includes() { [ -f "$CONFIG_FILE" ] || return 0 grep -q '^Include ' "$CONFIG_FILE" 2>/dev/null || return 0 warn "~/.ssh/config contains Include directives — termio won't manage those hosts." printf " Hosts in included files are visible to ssh but not to termio ls/status/audit.\n\n" } get_field() { [ -f "$CONFIG_FILE" ] || { printf ''; return; } awk -v a="$1" -v f="$2" ' /^Host / { in_block = ($2 == a); next } in_block && tolower($1) == tolower(f) { print $2; exit } ' "$CONFIG_FILE" } get_comment() { [ -f "$CONFIG_FILE" ] || { printf ''; return; } awk -v a="$1" -v k="$2" ' /^Host / { in_block = ($2 == a); next } in_block && /^[[:space:]]*#/ { line = $0 sub(/^[[:space:]]*# */, "", line) split(line, parts, ": ") if (parts[1] == k) { print parts[2]; exit } } ' "$CONFIG_FILE" } remove_config_block() { backup_config awk -v a="$1" ' /^Host / { in_block = ($2 == a) } !in_block { print } ' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" \ && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" vlog "Removed config block: $1" } sanitize_alias() { printf '%s' "$1" \ | tr '[:upper:]' '[:lower:]' \ | tr ' .' '-' \ | tr -cd 'a-z0-9-' } alias_exists() { grep -q "^Host $1$" "$CONFIG_FILE" 2>/dev/null } # ── Config backup ───────────────────────────────────────────────────────────── BACKUP_DIR="$CONF_DIR/backups" HISTORY_LOG="$CONF_DIR/history.log" TEMPLATE_FILE="$CONF_DIR/templates" BACKUP_MAX=5 backup_config() { [ -f "$CONFIG_FILE" ] || return 0 mkdir -p "$BACKUP_DIR" _bc_stamp=$(date '+%Y%m%d-%H%M%S') cp "$CONFIG_FILE" "$BACKUP_DIR/config.${_bc_stamp}" # Prune oldest beyond BACKUP_MAX _bc_count=$(ls "$BACKUP_DIR"/config.* 2>/dev/null | wc -l) if [ "$_bc_count" -gt "$BACKUP_MAX" ]; then ls -t "$BACKUP_DIR"/config.* 2>/dev/null \ | tail -n "+$(( BACKUP_MAX + 1 ))" \ | while IFS= read -r _f; do rm -f "$_f"; done fi vlog "Backed up SSH config → $BACKUP_DIR/config.${_bc_stamp}" } cmd_backup() { _bsub="${1:-list}" case "$_bsub" in list|ls|"") _bfiles=$(ls -t "$BACKUP_DIR"/config.* 2>/dev/null || true) if [ -z "$_bfiles" ]; then printf '\n No backups found in %s\n\n' "$BACKUP_DIR"; return 0 fi printf '\n' printf "${C_LBLUE}${C_BOLD} SSH config backups${C_RESET} ${C_DIM}(most recent first)${C_RESET}\n\n" _bn=1 printf '%s\n' "$_bfiles" | while IFS= read -r _bf; do _bts=$(printf '%s' "$(basename "$_bf")" | sed 's/config\.//') _bsize=$(wc -c < "$_bf" 2>/dev/null | tr -d ' ') printf " ${C_BOLD}%2d)${C_RESET} %s ${C_DIM}(%s bytes)${C_RESET}\n" \ "$_bn" "$_bts" "$_bsize" _bn=$(( _bn + 1 )) done printf '\n' printf " Restore with: ${C_BOLD}termio backup restore ${C_RESET}\n\n" ;; restore) _bn="${2:-}" [ -z "$_bn" ] && die "Usage: termio backup restore " case "$_bn" in *[!0-9]*) die "Backup number must be a positive integer." ;; esac _bfiles=$(ls -t "$BACKUP_DIR"/config.* 2>/dev/null || true) [ -z "$_bfiles" ] && die "No backups found." _btgt=$(printf '%s\n' "$_bfiles" | sed -n "${_bn}p") [ -z "$_btgt" ] && die "Backup #${_bn} not found." _bts=$(printf '%s' "$(basename "$_btgt")" | sed 's/config\.//') printf '\n' warn "This will replace your current ${C_BOLD}~/.ssh/config${C_RESET} with backup #${_bn} (${_bts})." printf '\n' wt_yesno "Restore backup #${_bn}?" || { printf ' Aborting.\n\n'; return 0; } backup_config # save current state before restoring cp "$_btgt" "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" success "Restored backup #${_bn} (${_bts}) to ~/.ssh/config" printf '\n' ;; *) die "Usage: termio backup [list | restore ]" ;; esac } # ── bootstrap ────────────────────────────────────────────────────────────────── # Generates the shell profile content deployed to remote hosts. # Uses single quotes throughout to avoid variable expansion at definition time. _termio_profile_content() { cat << 'PROFILE_END' # termio shell profile — sourced by .zshrc / .bashrc # Snippet fuzzy-insert: Ctrl+X s (requires fzf on remote) _termio_snip() { _tsf="$HOME/.config/termio/snippet-list.txt" [ -f "$_tsf" ] || return 0 command -v fzf >/dev/null 2>&1 || return 0 _tsel=$(awk -F'\t' '{printf "%-26s %s\n", $1, $3}' "$_tsf" \ | fzf --height=40% --reverse --no-sort --prompt="snippet> " \ --preview='f=~/.config/termio/snippet-list.txt n=$(echo {1} | tr -d " ") awk -F"\t" -v n="$n" "\$1==n{print \$2}" "$f"' \ --preview-window=down:2:wrap) [ -z "$_tsel" ] && return 0 _tname=$(printf '%s' "$_tsel" | awk '{print $1}') _tcmd=$(awk -F'\t' -v n="$_tname" '$1==n{print $2; exit}' "$_tsf") [ -z "$_tcmd" ] && return 0 if [ -n "$ZSH_VERSION" ]; then LBUFFER="${LBUFFER}${_tcmd}" zle reset-prompt elif [ -n "$BASH_VERSION" ]; then READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}${_tcmd}${READLINE_LINE:$READLINE_POINT}" READLINE_POINT=$(( READLINE_POINT + ${#_tcmd} )) fi } if [ -n "$ZSH_VERSION" ]; then zle -N _termio_snip bindkey '^Xs' _termio_snip fi if [ -n "$BASH_VERSION" ]; then bind -x '"\C-xs": _termio_snip' 2>/dev/null || true fi PROFILE_END } # Check for fzf on remote and offer to install it if missing. # Sets _fzf_path_prefix to a PATH prefix line if installed via git (no sudo). _bootstrap_check_deps() { _bcd_alias="$1" _fzf_path_prefix="" printf ' Checking dependencies on %s…\n' "$_bcd_alias" _fzf_ok=$(ssh -F "$CONFIG_FILE" "$_bcd_alias" \ 'command -v fzf >/dev/null 2>&1 && echo yes || echo no' 2>/dev/null) if [ "$_fzf_ok" = "yes" ]; then printf " ${C_GREEN}✓${C_RESET} fzf found\n" return 0 fi printf " ${C_YELLOW}⚠${C_RESET} fzf not found — required for the snippet widget\n" # Detect package manager on remote _bcd_pm=$(ssh -F "$CONFIG_FILE" "$_bcd_alias" ' for _p in apt-get dnf yum pacman brew apk zypper; do command -v "$_p" >/dev/null 2>&1 && printf "%s" "$_p" && exit 0 done ' 2>/dev/null) # Build package-manager install command _bcd_pm_cmd="" case "$_bcd_pm" in apt-get|apt) _bcd_pm_cmd="sudo apt-get install -y fzf" ;; dnf) _bcd_pm_cmd="sudo dnf install -y fzf" ;; yum) _bcd_pm_cmd="sudo yum install -y fzf" ;; pacman) _bcd_pm_cmd="sudo pacman -S --noconfirm fzf" ;; brew) _bcd_pm_cmd="brew install fzf" ;; apk) _bcd_pm_cmd="sudo apk add --no-cache fzf" ;; zypper) _bcd_pm_cmd="sudo zypper install -y fzf" ;; esac # Check if git is available for no-sudo fallback _bcd_git=$(ssh -F "$CONFIG_FILE" "$_bcd_alias" \ 'command -v git >/dev/null 2>&1 && echo yes || echo no' 2>/dev/null) printf '\n' if [ -n "$_bcd_pm_cmd" ]; then printf " ${C_BOLD}1${C_RESET}) Install via %s (${C_DIM}%s${C_RESET})\n" \ "$_bcd_pm" "$_bcd_pm_cmd" fi if [ "$_bcd_git" = "yes" ]; then printf " ${C_BOLD}2${C_RESET}) Install via git ${C_DIM}(no sudo — installs to ~/.fzf/bin)${C_RESET}\n" fi printf " ${C_BOLD}s${C_RESET}) Skip ${C_DIM}(snippet widget inactive until fzf is installed)${C_RESET}\n\n" printf ' Choice [1/2/s]: ' IFS= read -r _bcd_ans case "$_bcd_ans" in 1) if [ -z "$_bcd_pm_cmd" ]; then warn "No supported package manager detected."; return 0 fi printf ' Running: %s\n\n' "$_bcd_pm_cmd" ssh -t -F "$CONFIG_FILE" "$_bcd_alias" "$_bcd_pm_cmd" _fzf_ok2=$(ssh -F "$CONFIG_FILE" "$_bcd_alias" \ 'command -v fzf >/dev/null 2>&1 && echo yes || echo no' 2>/dev/null) if [ "$_fzf_ok2" = "yes" ]; then success "fzf installed on $_bcd_alias" else warn "fzf install may have failed — verify manually on $_bcd_alias" fi ;; 2) if [ "$_bcd_git" != "yes" ]; then warn "git not available on $_bcd_alias."; return 0 fi printf ' Cloning fzf to ~/.fzf on %s…\n' "$_bcd_alias" ssh -t -F "$CONFIG_FILE" "$_bcd_alias" \ 'git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf 2>&1 \ && ~/.fzf/install --bin --no-update-rc 2>&1' _fzf_ok3=$(ssh -F "$CONFIG_FILE" "$_bcd_alias" \ 'test -x ~/.fzf/bin/fzf && echo yes || echo no' 2>/dev/null) if [ "$_fzf_ok3" = "yes" ]; then success "fzf installed to ~/.fzf/bin on $_bcd_alias" # Profile needs to add ~/.fzf/bin to PATH _fzf_path_prefix='[ -d "$HOME/.fzf/bin" ] && export PATH="$HOME/.fzf/bin:$PATH"' else warn "fzf git install may have failed — verify manually on $_bcd_alias" fi ;; s|S|"") printf ' Skipping — snippet widget inactive until fzf is available.\n' ;; *) printf ' Skipping.\n' ;; esac printf '\n' } # Generates TSV snippet list: name cmd desc _termio_snip_list_content() { _tsnames=$(snip_list_names) [ -z "$_tsnames" ] && return 0 printf '%s\n' "$_tsnames" | while IFS= read -r _tsn; do [ -z "$_tsn" ] && continue _tsc=$(snip_get "$_tsn" "cmd") _tsd=$(snip_get "$_tsn" "desc") printf '%s\t%s\t%s\n' "$_tsn" "$_tsc" "$_tsd" done } cmd_bootstrap() { _bsub="${1:-}"; _bal="${2:-}" case "$_bsub" in --list|-l|list) _bootstrap_list; return 0 ;; --remove|-r|remove) [ -z "$_bal" ] && die "Usage: termio bootstrap remove " alias_exists "$_bal" || die "Alias '$_bal' not found" _bootstrap_remove "$_bal"; return 0 ;; --update|-u|update) [ -z "$_bal" ] && die "Usage: termio bootstrap update " alias_exists "$_bal" || die "Alias '$_bal' not found" _bootstrap_push_snips "$_bal" && success "Snippets synced to $_bal"; return 0 ;; "") die "Usage: termio bootstrap | list | update | remove " ;; *) # treat first arg as alias if it's not a subcommand _bal="$_bsub" alias_exists "$_bal" || die "Alias '$_bal' not found" _bootstrap_install "$_bal" ;; esac } _bootstrap_install() { _bia="$1" info "Installing snippet widget on ${C_BOLD}$_bia${C_RESET}…" printf '\n' # Check / install dependencies (|| true: dep failures are advisory, not fatal) _fzf_path_prefix="" _bootstrap_check_deps "$_bia" || true # Push profile script (prepend PATH fix if fzf was git-installed) { [ -n "$_fzf_path_prefix" ] && printf '%s\n' "$_fzf_path_prefix" _termio_profile_content } | ssh -F "$CONFIG_FILE" "$_bia" \ 'mkdir -p ~/.config/termio && cat > ~/.config/termio/shell-init.sh && chmod 600 ~/.config/termio/shell-init.sh' \ || die "Failed to copy profile to $_bia" # Push snippet list _bootstrap_push_snips "$_bia" || true # Add source guard to .zshrc and .bashrc (idempotent) ssh -F "$CONFIG_FILE" "$_bia" ' _sl="[ -f \"$HOME/.config/termio/shell-init.sh\" ] && . \"$HOME/.config/termio/shell-init.sh\"" for _sf in ~/.zshrc ~/.bashrc; do [ -f "$_sf" ] || continue grep -qF "termio/shell-init.sh" "$_sf" 2>/dev/null && continue printf "\n# termio profile\n%s\n" "$_sl" >> "$_sf" printf " appended to %s\n" "$_sf" done ' || true pref_set "bootstrap_$_bia" "$(date '+%Y-%m-%d')" success "Snippet widget installed on $_bia — active from next login (or: source ~/.zshrc)" printf '\n' } _bootstrap_push_snips() { _bpa="$1" _termio_snip_list_content | ssh -F "$CONFIG_FILE" "$_bpa" \ 'mkdir -p ~/.config/termio && cat > ~/.config/termio/snippet-list.txt' } _bootstrap_remove() { _bra="$1" info "Removing snippet widget from ${C_BOLD}$_bra${C_RESET}…" ssh -F "$CONFIG_FILE" "$_bra" ' rm -f ~/.config/termio/shell-init.sh ~/.config/termio/snippet-list.txt for _sf in ~/.zshrc ~/.bashrc; do [ -f "$_sf" ] || continue grep -qF "termio/shell-init.sh" "$_sf" 2>/dev/null || continue # Remove "# termio profile\n" block sed -i '/^# termio profile$/{N;d}' "$_sf" 2>/dev/null || \ sed -i'' '/^# termio profile$/{N;d}' "$_sf" 2>/dev/null || true sed -i "/termio\/shell-init.sh/d" "$_sf" 2>/dev/null || \ sed -i'' "/termio\/shell-init.sh/d" "$_sf" 2>/dev/null || true printf " cleaned %s\n" "$_sf" done ' pref_set "bootstrap_$_bra" "" success "Snippet widget removed from $_bra" printf '\n' } _bootstrap_list() { printf "\n ${C_LBLUE}${C_BOLD}Aliases with snippet widget installed${C_RESET}\n\n" _bfound=0 awk '/^Host / && !/[*?]/ {print $2}' "$CONFIG_FILE" 2>/dev/null | \ while IFS= read -r _ba; do _bdate=$(pref_get "bootstrap_$_ba" "") [ -z "$_bdate" ] && continue printf " ${C_BOLD}%-28s${C_RESET} %s\n" "$_ba" "$_bdate" _bfound=1 done [ "$_bfound" -eq 0 ] && printf ' (none)\n' printf '\n' } # Ephemeral per-session profile injection. Called from cmd_connect with --profile. _connect_with_profile() { _cpa="$1" # alias _tag="t$$" _tdir="/tmp/._termio_${_tag}" info "Preparing session profile for ${C_BOLD}$_cpa${C_RESET}…" printf '\n' # Check deps — offer install if fzf missing _fzf_path_prefix="" _bootstrap_check_deps "$_cpa" # Push profile + snippets in two non-interactive connections { [ -n "$_fzf_path_prefix" ] && printf '%s\n' "$_fzf_path_prefix" _termio_profile_content } | ssh -F "$CONFIG_FILE" "$_cpa" \ "mkdir -p '${_tdir}' ~/.config/termio && cat > '${_tdir}/profile.sh'" \ || die "Could not push profile to $_cpa" _termio_snip_list_content | ssh -F "$CONFIG_FILE" "$_cpa" \ "cat > '${_tdir}/snips.txt' && cp '${_tdir}/snips.txt' ~/.config/termio/snippet-list.txt" # Detect remote login shell _cpsh=$(ssh -F "$CONFIG_FILE" "$_cpa" 'printf "%s" "$SHELL"' 2>/dev/null) sleep 0.2 clear 2>/dev/null || printf '\033[2J\033[H' set +e case "$_cpsh" in */zsh) ssh -F "$CONFIG_FILE" "$_cpa" " mkdir -p '${_tdir}/zd' { printf '%s\n' '[ -f \"\$HOME/.zshrc\" ] && . \"\$HOME/.zshrc\" 2>/dev/null' cat '${_tdir}/profile.sh'; } > '${_tdir}/zd/.zshrc' " ssh -t -F "$CONFIG_FILE" "$_cpa" \ "ZDOTDIR='${_tdir}/zd' zsh -i; rm -rf '${_tdir}'" ;; *) ssh -F "$CONFIG_FILE" "$_cpa" " { printf '%s\n' '[ -f \"\$HOME/.bashrc\" ] && . \"\$HOME/.bashrc\" 2>/dev/null' cat '${_tdir}/profile.sh'; } > '${_tdir}/rc.sh' " ssh -t -F "$CONFIG_FILE" "$_cpa" \ "bash --rcfile '${_tdir}/rc.sh' -i; rm -rf '${_tdir}'" ;; esac _cp_exit=$? set -e return "$_cp_exit" } # write_config_block # [group] write_config_block() { _a="$1"; _h="$2"; _u="$3"; _p="$4"; _k="$5" _j="$6"; _n="$7"; _alive="$8"; _fwda="$9" shift 9; _lfwd="$1"; _grp="${2:-}" backup_config touch "$CONFIG_FILE"; chmod 600 "$CONFIG_FILE" [ -s "$CONFIG_FILE" ] && printf '\n' >> "$CONFIG_FILE" { printf 'Host %s\n' "$_a" [ -n "$_n" ] && printf ' # Note: %s\n' "$_n" [ -n "$_grp" ] && printf ' # Group: %s\n' "$_grp" [ -n "$_j" ] && printf ' # JumpAlias: %s\n' "$_j" printf ' HostName %s\n' "$_h" printf ' User %s\n' "$_u" printf ' Port %s\n' "$_p" printf ' IdentityFile %s\n' "$_k" printf ' IdentitiesOnly yes\n' printf ' AddKeysToAgent yes\n' [ -n "$_j" ] && printf ' ProxyJump %s\n' "$_j" [ -n "$_alive" ] && printf ' ServerAliveInterval %s\n' "$_alive" [ -n "$_fwda" ] && printf ' ForwardAgent %s\n' "$_fwda" [ -n "$_lfwd" ] && printf ' LocalForward %s\n' "$_lfwd" } >> "$CONFIG_FILE" vlog "Wrote config block: $_a → $_h" } # ── Sync auto-export (silent unless -v) ─────────────────────────────────────── # Called after every write operation (add/edit/rm/rotate). # Updates the aliases-export.txt in the sync folder silently. # On failure just warns — never interrupts the main operation. sync_auto_export() { _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] && { vlog "sync_auto_export: no sync path configured"; return 0; } [ -d "$_sync_path" ] || { warn "Sync folder not reachable: $_sync_path" printf " Auto-export skipped. Check sync folder exists.\n" return 0 } _export_file="$_sync_path/aliases-export.txt" vlog "sync_auto_export: writing $_export_file" _aliases=$(list_aliases) { printf '# termio aliases export — auto-updated\n' printf '# version %s — %s\n' "$TERMIO_VERSION" "$(date)" printf '# Import with: termio import \n\n' printf '%s\n' "$_aliases" | while IFS= read -r _alias; do [ -z "$_alias" ] && continue _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" _jump=$(get_field "$_alias" "ProxyJump") _alive=$(get_field "$_alias" "ServerAliveInterval") _fwda=$(get_field "$_alias" "ForwardAgent") _lfwd=$(get_field "$_alias" "LocalForward") _note=$(get_comment "$_alias" "Note") _grp=$(get_comment "$_alias" "Group") printf '[alias]\n' printf 'name=%s\n' "$_alias" printf 'hostname=%s\n' "$_host" printf 'user=%s\n' "$_user" printf 'port=%s\n' "$_port" [ -n "$_note" ] && printf 'note=%s\n' "$_note" [ -n "$_grp" ] && printf 'group=%s\n' "$_grp" [ -n "$_jump" ] && printf 'jump=%s\n' "$_jump" [ -n "$_alive" ] && printf 'keepalive=%s\n' "$_alive" [ -n "$_fwda" ] && printf 'fwdagent=%s\n' "$_fwda" [ -n "$_lfwd" ] && printf 'localfwd=%s\n' "$_lfwd" printf '\n' done } > "$_export_file" 2>/dev/null || { warn "Auto-export to sync folder failed." return 0 } _ts=$(date '+%Y-%m-%d %H:%M') pref_set "sync_last_export" "$_ts" vlog "sync_auto_export: done at $_ts" } # ── Key generation helper ──────────────────────────────────────────────────── _keygen() { _kf="$1"; _type="${2:-ed25519}"; _comment="$3" case "$_type" in rsa) ssh-keygen -t rsa -b 4096 -f "$_kf" -N "" -C "$_comment" >/dev/null 2>&1 ;; ecdsa) ssh-keygen -t ecdsa -b 521 -f "$_kf" -N "" -C "$_comment" >/dev/null 2>&1 ;; *) ssh-keygen -t ed25519 -f "$_kf" -N "" -C "$_comment" >/dev/null 2>&1 ;; esac } # ── Key copy helper ─────────────────────────────────────────────────────────── copy_key_to_remote() { _kf="$1"; _u="$2"; _h="$3"; _p="$4"; _pass="$5"; _jump="$6" _jopt=""; [ -n "$_jump" ] && _jopt="-o ProxyJump=$_jump" if command -v sshpass >/dev/null 2>&1 && [ -n "$_pass" ]; then _ckr_tmp=$(mktemp) # Pass password via env var (avoids process-list exposure and handles all special chars). # Force password auth so SSH agent keys don't exhaust MaxAuthTries before the password # attempt, which causes "Too many authentication failures" on default server configs. # shellcheck disable=SC2086 SSHPASS="$_pass" sshpass -e ssh-copy-id \ -i "${_kf}.pub" -p "$_p" \ -o StrictHostKeyChecking=accept-new \ -o PubkeyAuthentication=no \ -o PreferredAuthentications=password \ $_jopt "${_u}@${_h}" >"$_ckr_tmp" 2>&1 _ckr_rc=$? grep -v '^$' <"$_ckr_tmp" || true rm -f "$_ckr_tmp" unset SSHPASS return $_ckr_rc else [ -z "$_pass" ] \ && warn "No password — ssh-copy-id will prompt you." \ || warn "sshpass not installed — ssh-copy-id will prompt you." # shellcheck disable=SC2086 ssh-copy-id -i "${_kf}.pub" -p "$_p" \ -o StrictHostKeyChecking=accept-new \ -o PubkeyAuthentication=no \ -o PreferredAuthentications=password \ $_jopt "${_u}@${_h}" fi } # ── Whiptail alias picker ───────────────────────────────────────────────────── wt_pick_alias() { _title="$1" _aliases=$(list_aliases) if [ -z "$_aliases" ]; then wt_msg "No aliases saved yet."; SELECTED_ALIAS=""; return 1 fi set -- while IFS= read -r _a; do _h=$(get_field "$_a" "HostName") _n=$(get_comment "$_a" "Note") _lbl="${_h:-unknown}"; [ -n "$_n" ] && _lbl="${_lbl} — ${_n}" set -- "$@" "$_a" "$_lbl" done <<_EOF $(printf '%s\n' "$_aliases") _EOF SELECTED_ALIAS=$(whiptail \ --title "termio v${TERMIO_VERSION}" \ --cancel-button "Back" \ --menu "$_title" "$TH" "$TW" 10 \ "$@" \ 3>&1 1>&2 2>&3) || { SELECTED_ALIAS=""; return 1; } } # ── Advanced options ────────────────────────────────────────────────────────── prompt_advanced() { ADV_ALIVE=""; ADV_FWDAGENT=""; ADV_LOCALFWD="" if wt_yesno "Configure advanced SSH options?\n(ServerAliveInterval, ForwardAgent, LocalForward)"; then wt_input "ServerAliveInterval in seconds (blank = skip)" "" ADV_ALIVE="$_PROMPT_RESULT" wt_input "ForwardAgent? (yes / no / blank = skip)" "" case "$_PROMPT_RESULT" in yes|no) ADV_FWDAGENT="$_PROMPT_RESULT" ;; *) ADV_FWDAGENT="" ;; esac wt_input "LocalForward e.g. 8080 localhost:80 (blank = skip)" "" ADV_LOCALFWD="$_PROMPT_RESULT" fi } # ═══════════════════════════════════════════════════════════════════════════════ # ── Templates ───────────────────────────────────────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════════ template_list_names() { [ -f "$TEMPLATE_FILE" ] || return 0 grep '^name=' "$TEMPLATE_FILE" | sed 's/^name=//' 2>/dev/null || true } template_get() { [ -f "$TEMPLATE_FILE" ] || { printf ''; return; } awk -v tgt="$1" -v fld="$2" ' /^\[template\]/ { matched=0 } /^name=/ { if (substr($0,6)==tgt) matched=1 } matched && index($0, fld "=") == 1 { print substr($0, length(fld)+2); exit } ' "$TEMPLATE_FILE" } template_remove_block() { [ -f "$TEMPLATE_FILE" ] || return 0 awk -v tgt="$1" ' /^\[template\]/ { buf=$0; skip=0; next } buf && /^name=/ { if (substr($0,6)==tgt) { skip=1; buf=""; next } printf "%s\n",buf; buf="" } buf { printf "%s\n",buf; buf="" } skip && /^$/ { skip=0; next } !skip { print } ' "$TEMPLATE_FILE" > "${TEMPLATE_FILE}.tmp" \ && mv "${TEMPLATE_FILE}.tmp" "$TEMPLATE_FILE" || true } template_write() { _tn="$1"; _tu="$2"; _tp="$3"; _tg="$4"; _tk="$5"; _tnote="$6"; _ta="$7" template_remove_block "$_tn" { printf '[template]\n' printf 'name=%s\n' "$_tn" printf 'user=%s\n' "$_tu" printf 'port=%s\n' "$_tp" printf 'group=%s\n' "$_tg" printf 'key_type=%s\n' "$_tk" printf 'note=%s\n' "$_tnote" printf 'alive=%s\n' "$_ta" printf '\n' } >> "$TEMPLATE_FILE" } cmd_template() { _tsub="${1:-list}" case "$_tsub" in list|ls|"") _tnames=$(template_list_names) if [ -z "$_tnames" ]; then printf '\n No templates saved. Create one with: termio template save \n\n' return 0 fi printf "\n ${C_BOLD}Saved templates:${C_RESET}\n\n" printf '%s\n' "$_tnames" | while IFS= read -r _tn; do _tu=$(template_get "$_tn" "user") _tp=$(template_get "$_tn" "port") _tg=$(template_get "$_tn" "group") _tk=$(template_get "$_tn" "key_type") printf " ${C_BOLD}%-20s${C_RESET} user=%-12s port=%-6s group=%-14s key=%s\n" \ "$_tn" "${_tu:-(prompt)}" "${_tp:-22}" "${_tg:-(none)}" "${_tk:-ed25519}" done printf '\n' ;; save) _tname=$(sanitize_alias "${2:-}") [ -z "$_tname" ] && die "Usage: termio template save " _def_ktype=$(pref_get "key_type"); [ -z "$_def_ktype" ] && _def_ktype="ed25519" wt_input "Default username" "$(whoami)"; _tu="$_PROMPT_RESULT" wt_input "Default SSH port" "22"; _tp="$_PROMPT_RESULT" wt_input "Default group(s) / tags" ""; _tg="$_PROMPT_RESULT" wt_input "Default key type (ed25519/rsa/ecdsa)" "$_def_ktype" case "$_PROMPT_RESULT" in ed25519|rsa|ecdsa) _tk="$_PROMPT_RESULT" ;; *) _tk="$_def_ktype" ;; esac wt_input "Default note prefix (optional)" ""; _tnote="$_PROMPT_RESULT" wt_input "ServerAliveInterval (blank = none)" ""; _ta="$_PROMPT_RESULT" template_write "$_tname" "$_tu" "$_tp" "$_tg" "$_tk" "$_tnote" "$_ta" success "Template \"$_tname\" saved." ;; rm|remove) _tname="${2:-}" [ -z "$_tname" ] && die "Usage: termio template rm " template_remove_block "$_tname" success "Template \"$_tname\" removed." ;; *) die "Unknown subcommand: '$_tsub' (use: list, save, rm)" ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # ── Commands ────────────────────────────────────────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════════ # ── ADD ─────────────────────────────────────────────────────────────────────── cmd_add() { [ "$HAS_WHIPTAIL" -eq 0 ] && { printf '\n This will:\n' printf ' 1. Collect connection details\n' printf ' 2. Generate a unique SSH key\n' printf ' 3. Copy the public key to the remote (password used once)\n' printf ' 4. Write a named alias to ~/.ssh/config\n' printf " 5. Connect with: ${C_BOLD}ssh ${C_RESET}\n\n" } # Template pre-fill (optional) _T_USER=""; _T_PORT="22"; _T_GROUP=""; _T_KTYPE=""; _T_NOTE=""; _T_ALIVE="" _tnames=$(template_list_names) if [ -n "$_tnames" ]; then if wt_yesno "Use a saved template as starting point?"; then if [ "$HAS_WHIPTAIL" -eq 1 ]; then set -- while IFS= read -r _tn; do set -- "$@" "$_tn" " user=$(template_get "$_tn" user) port=$(template_get "$_tn" port)" done <<_TTEOF $(printf '%s\n' "$_tnames") _TTEOF _tsel=$(whiptail --title "termio v${TERMIO_VERSION}" \ --cancel-button "Skip" \ --menu "Select template:" "$TH" "$TW" 6 "$@" \ 3>&1 1>&2 2>&3) || _tsel="" else printf '\n Available templates:\n' printf '%s\n' "$_tnames" | while IFS= read -r _tn; do printf ' %s\n' "$_tn"; done cli_prompt "Template name (blank = skip)" ""; _tsel="$_PROMPT_RESULT" fi if [ -n "$_tsel" ]; then _T_USER=$(template_get "$_tsel" "user") _T_PORT=$(template_get "$_tsel" "port") _T_GROUP=$(template_get "$_tsel" "group") _T_KTYPE=$(template_get "$_tsel" "key_type") _T_NOTE=$(template_get "$_tsel" "note") _T_ALIVE=$(template_get "$_tsel" "alive") info "Using template: ${C_BOLD}$_tsel${C_RESET}" fi fi fi wt_input "Alias name (e.g. homeserver, vps-prod)" "" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Alias cannot be empty."; wt_input "Alias name" "" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } ALIAS=$(sanitize_alias "$_PROMPT_RESULT") [ -z "$ALIAS" ] && die "Alias contains no valid characters." info "Using alias: ${C_BOLD}$ALIAS${C_RESET}" if alias_exists "$ALIAS"; then if wt_yesno "Alias \"$ALIAS\" already exists. Overwrite it?"; then remove_config_block "$ALIAS" success "Existing alias removed." else printf ' Aborting.\n'; return 0 fi fi wt_input "Short description / note (optional)" "${_T_NOTE:-}" NOTE="$_PROMPT_RESULT" wt_input "Group(s) / tags (optional, comma-separated, e.g. homelab,work)" "${_T_GROUP:-}" GROUP="$_PROMPT_RESULT" wt_input "Remote hostname or IP address" "" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Hostname cannot be empty."; wt_input "Remote hostname or IP" "" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } REMOTE_HOST="$_PROMPT_RESULT" wt_input "SSH port" "${_T_PORT:-22}" REMOTE_PORT="$_PROMPT_RESULT" case "$REMOTE_PORT" in ''|*[!0-9]*) REMOTE_PORT=22 ;; esac wt_input "Remote username" "${_T_USER:-$(whoami)}" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Username cannot be empty."; wt_input "Remote username" "$(whoami)" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } REMOTE_USER="$_PROMPT_RESULT" JUMP_HOST="" _saved=$(list_aliases) if [ -n "$_saved" ]; then if wt_yesno "Route through a jump / bastion host?"; then if [ "$HAS_WHIPTAIL" -eq 1 ]; then if wt_pick_alias "Select first jump host:"; then JUMP_HOST="$SELECTED_ALIAS" wt_input "Additional hops? (comma-separated aliases, blank = none)" "" [ -n "$_PROMPT_RESULT" ] && JUMP_HOST="${JUMP_HOST},${_PROMPT_RESULT}" fi else cmd_list printf " Chains supported — e.g. bastion,internal\n" cli_prompt "Jump host alias(es) (comma-separated, blank = none)" "" JUMP_HOST="$_PROMPT_RESULT" fi [ -n "$JUMP_HOST" ] && info "Jump chain: ${C_BOLD}$JUMP_HOST${C_RESET}" fi fi wt_password "Remote password" REMOTE_PASS="$_PROMPT_RESULT" prompt_advanced _def_ktype=$(pref_get "key_type"); [ -z "$_def_ktype" ] && _def_ktype="ed25519" [ -n "$_T_KTYPE" ] && _def_ktype="$_T_KTYPE" wt_input "SSH key type (ed25519 / rsa / ecdsa)" "$_def_ktype" case "$_PROMPT_RESULT" in ed25519|rsa|ecdsa) KEY_TYPE="$_PROMPT_RESULT" ;; *) KEY_TYPE="$_def_ktype" [ -n "$_PROMPT_RESULT" ] && warn "Unknown key type — using $KEY_TYPE" ;; esac mkdir -p "$SSH_DIR"; chmod 700 "$SSH_DIR" KEY_FILE="$SSH_DIR/id_${KEY_TYPE}_${ALIAS}" if [ -f "$KEY_FILE" ]; then warn "Key already exists — reusing: $KEY_FILE" else info "Generating ${KEY_TYPE} key: $KEY_FILE" _keygen "$KEY_FILE" "$KEY_TYPE" "${REMOTE_USER}@${REMOTE_HOST}-${ALIAS}" chmod 600 "$KEY_FILE"; chmod 644 "${KEY_FILE}.pub" success "Key generated." fi info "Copying public key to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} ..." if copy_key_to_remote "$KEY_FILE" "$REMOTE_USER" "$REMOTE_HOST" \ "$REMOTE_PORT" "$REMOTE_PASS" "$JUMP_HOST"; then unset REMOTE_PASS success "Public key copied. Password cleared from memory." else unset REMOTE_PASS die "Key copy failed — check the connection and try again." fi write_config_block "$ALIAS" "$REMOTE_HOST" "$REMOTE_USER" "$REMOTE_PORT" \ "$KEY_FILE" "$JUMP_HOST" "$NOTE" "$ADV_ALIVE" "$ADV_FWDAGENT" "$ADV_LOCALFWD" "$GROUP" success "Config written." sync_auto_export sync_keys_update printf '\n' printf "${C_CYAN}╔══════════════════════════════════════════════╗${C_RESET}\n" printf "${C_CYAN}║${C_RESET} ${C_GREEN}${C_BOLD}All done! ✔${C_RESET} ${C_CYAN}║${C_RESET}\n" printf "${C_CYAN}╚══════════════════════════════════════════════╝${C_RESET}\n\n" printf " Connect with: ${C_BOLD}${C_GREEN}ssh %s${C_RESET}\n\n" "$ALIAS" } # ── PIN ─────────────────────────────────────────────────────────────────────── cmd_pin() { _pa="${1:-}" if [ -z "$_pa" ]; then _pinned=$(pref_get "pinned_aliases") if [ -z "$_pinned" ]; then printf '\n No pinned aliases.\n\n'; return 0 fi printf "\n ${C_BOLD}Pinned aliases:${C_RESET}\n" printf '%s' "$_pinned" | tr ',' '\n' | while IFS= read -r _p; do [ -n "$_p" ] && printf ' ★ %s\n' "$_p" done printf '\n' return 0 fi alias_exists "$_pa" || die "Alias \"$_pa\" not found." _cur=$(pref_get "pinned_aliases") # Check already pinned printf '%s' "${_cur}," | tr ',' '\n' | grep -qx "$_pa" && \ { info "\"$_pa\" is already pinned."; return 0; } _new=$([ -n "$_cur" ] && printf '%s,%s' "$_cur" "$_pa" || printf '%s' "$_pa") pref_set "pinned_aliases" "$_new" success "\"$_pa\" pinned — it will appear at the top of termio ls." } cmd_unpin() { _ua="${1:-}" [ -z "$_ua" ] && die "Usage: termio unpin " _cur=$(pref_get "pinned_aliases") [ -z "$_cur" ] && { info "\"$_ua\" is not pinned."; return 0; } _new=$(printf '%s' "$_cur" | tr ',' '\n' | grep -vx "$_ua" | tr '\n' ',' | sed 's/,$//' || true) pref_set "pinned_aliases" "$_new" success "\"$_ua\" unpinned." } # ── LIST ────────────────────────────────────────────────────────────────────── cmd_list() { _filter_group=""; _show_all=0; _sort_mode="" while [ "$#" -gt 0 ]; do case "$1" in --group|-g) _filter_group="${2:-}"; shift ;; --all|-a) _show_all=1 ;; --sort|-s) _sort_mode="${2:-alpha}"; shift ;; esac shift done _warn_ssh_includes _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf '\n No aliases found in %s\n\n' "$CONFIG_FILE"; return 0 fi # Filter: skip "hidden" unless --all; apply group filter _aliases=$(printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _ag=$(get_comment "$_a" "Group") if [ "$_show_all" -eq 0 ]; then printf '%s' "$_ag" | tr ',' '\n' | tr -d ' ' | grep -qx "hidden" && continue fi if [ -n "$_filter_group" ]; then printf '%s' "$_ag" | tr ',' '\n' | tr -d ' ' | grep -qx "$_filter_group" \ || continue fi printf '%s\n' "$_a" done) # Sorting _pinned=$(pref_get "pinned_aliases") case "${_sort_mode:-}" in alpha) _aliases=$(printf '%s\n' "$_aliases" | sort) ;; recent) # Sort by last_connect timestamp descending (most recent first) _aliases=$(printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _lc=$(pref_get "last_connect_${_a}") printf '%s\t%s\n' "${_lc:-0000-00-00}" "$_a" done | sort -r | awk -F'\t' '{print $2}') ;; group) _aliases=$(printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _ag=$(get_comment "$_a" "Group") printf '%s\t%s\n' "${_ag:--}" "$_a" done | sort | awk -F'\t' '{print $2}') ;; *) # Default: pinned aliases first (in pin order), then rest if [ -n "$_pinned" ]; then _pinned_list=$(printf '%s' "$_pinned" | tr ',' '\n' | while IFS= read -r _p; do [ -z "$_p" ] && continue printf '%s\n' "$_aliases" | grep -x "$_p" || true done) _unpinned=$(printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue printf '%s,' "$_pinned" | tr ',' '\n' | grep -qx "$_a" || printf '%s\n' "$_a" done) _aliases=$(printf '%s\n%s' "$_pinned_list" "$_unpinned" | grep -v '^$' || true) fi ;; esac if [ -z "$_aliases" ]; then [ -n "$_filter_group" ] && \ printf '\n No aliases in group "%s".\n\n' "$_filter_group" || \ printf '\n No aliases found.\n\n' return 0 fi # Collect agent fingerprints once for the whole table _agent_fps=$(ssh-add -l 2>/dev/null | awk '{print $2}' || true) # Box widths (chars between │ borders, including 1 space each side): # W1=24: │ alias(16) space badge(5) │ # W2=22: │ host(20) │ # W3=14: │ group(12) │ # W4=12: │ last(10) │ # Total: 2(indent)+│+24+│+22+│+14+│+12+│ = 2+5+72 = 79 chars _W1=24; _W2=22; _W3=14; _W4=12 printf '\n' [ -n "$_filter_group" ] && \ printf " ${C_DIM}Group filter: ${C_BOLD}${C_LBLUE}%s${C_RESET}\n\n" "$_filter_group" _ls_hline " ┌" "┬" "┐" "$_W1" "$_W2" "$_W3" "$_W4" printf " │ ${C_BOLD}${C_LBLUE}%-16s %-5s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-20s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-12s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-10s${C_RESET} │\n" \ "ALIAS" "TYPE" "HOSTNAME" "GROUP" "LAST" _ls_hline " ├" "┼" "┤" "$_W1" "$_W2" "$_W3" "$_W4" printf '%s\n' "$_aliases" | while IFS= read -r _alias; do [ -z "$_alias" ] && continue _host=$(get_field "$_alias" "HostName") _key=$(get_field "$_alias" "IdentityFile") _grp=$(get_comment "$_alias" "Group") _last=$(pref_get "last_connect_${_alias}") _badge=$(_key_badge "$_key") _relts=$(_rel_time "$_last") _grp_disp=$(printf '%.12s' "${_grp:--}") _grp_col=$(_group_color "$_grp") # Pinned indicator: '*' suffix on alias (1 char), alias truncated to 15 _is_pinned=0 printf '%s,' "$_pinned" | tr ',' '\n' | grep -qx "$_alias" 2>/dev/null \ && _is_pinned=1 if [ "$_is_pinned" -eq 1 ]; then _alias_field=$(printf '%-15.15s*' "$_alias") else _alias_field=$(printf '%-15.15s ' "$_alias") fi # Check if key is loaded in ssh-agent (use bold if so) _acol="${_grp_col}" if [ -n "$_key" ] && [ -f "$_key" ] && [ -n "$_agent_fps" ]; then _kfp=$(ssh-keygen -lf "$_key" 2>/dev/null | awk '{print $2}') printf '%s\n' "$_agent_fps" | grep -qF "$_kfp" 2>/dev/null \ && _acol="${C_BOLD}${C_WHITE}" fi # All args to %-Ns are plain strings (ANSI codes in format string only) printf " │ ${_acol}%s${C_RESET} ${C_DIM}%-5.5s${C_RESET} │ %-20.20s │ ${_grp_col}%-12.12s${C_RESET} │ ${C_DIM}%-10.10s${C_RESET} │\n" \ "$_alias_field" "$_badge" "$_host" "$_grp_disp" "$_relts" done _ls_hline " └" "┴" "┘" "$_W1" "$_W2" "$_W3" "$_W4" printf '\n' } # ── INFO ────────────────────────────────────────────────────────────────────── cmd_info() { _alias="$1" [ -z "$_alias" ] && die "Usage: termio info " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" _key=$(get_field "$_alias" "IdentityFile") _jump=$(get_field "$_alias" "ProxyJump") _alive=$(get_field "$_alias" "ServerAliveInterval") _fwda=$(get_field "$_alias" "ForwardAgent") _lfwd=$(get_field "$_alias" "LocalForward") _note=$(get_comment "$_alias" "Note") _grp=$(get_comment "$_alias" "Group") _last=$(pref_get "last_connect_$_alias") _fp="" if [ -f "$_key" ]; then _fp=$(ssh-keygen -lf "$_key" 2>/dev/null | awk '{print $2}') fi _badge=$(_key_badge "$_key") _relts=$(_rel_time "$_last") _grp_col=$(_group_color "$_grp") # Determine key age in days _kdays="" if [ -f "$_key" ]; then _kmt=$(stat -c %Y "$_key" 2>/dev/null || stat -f %m "$_key" 2>/dev/null || true) if [ -n "$_kmt" ]; then _now=$(date +%s) _kdays=$(( ( _now - _kmt ) / 86400 )) fi fi # Box width = 56 inner chars _IW=56 _info_title=" ${_alias} " _fill=$(( _IW - ${#_info_title} - 2 )) [ "$_fill" -lt 0 ] && _fill=0 printf '\n' # Top border with alias name embedded printf " ${C_LBLUE}┌─${C_BOLD}${C_WHITE}%s${C_RESET}${C_LBLUE}" "$_info_title" _i=0; while [ "$_i" -lt "$_fill" ]; do printf '─'; _i=$(( _i + 1 )); done printf "┐${C_RESET}\n" # _info_row: print one labeled row inside the info box # ANSI codes must NOT be embedded in the value arg (breaks %-38s padding). # Use separate format strings for color; keep args as plain text. _info_row() { _ir_label="$1"; _ir_val="$2" printf " ${C_LBLUE}│${C_RESET} ${C_BOLD}${C_LBLUE}%-13s${C_RESET} %-38.38s ${C_LBLUE}│${C_RESET}\n" \ "$_ir_label" "$_ir_val" } _info_row "Host" "$_host" _info_row "User" "$_user" _info_row "Port" "$_port" # Group with color prefix in format string (not in value arg) [ -n "$_grp" ] && \ printf " ${C_LBLUE}│${C_RESET} ${C_BOLD}${C_LBLUE}%-13s${C_RESET} ${_grp_col}%-38.38s${C_RESET} ${C_LBLUE}│${C_RESET}\n" \ "Group" "$_grp" [ -n "$_note" ] && _info_row "Note" "$_note" [ -n "$_jump" ] && _info_row "Jump" "$_jump" [ -n "$_alive" ] && _info_row "KeepAlive" "${_alive}s" [ -n "$_fwda" ] && _info_row "FwdAgent" "$_fwda" [ -n "$_lfwd" ] && _info_row "LocalFwd" "$_lfwd" # Separator before key section printf " ${C_LBLUE}├" _i=0; while [ "$_i" -lt "$_IW" ]; do printf '─'; _i=$(( _i + 1 )); done printf "┤${C_RESET}\n" if [ -f "$_key" ]; then # Key type with cyan color in format string printf " ${C_LBLUE}│${C_RESET} ${C_BOLD}${C_LBLUE}%-13s${C_RESET} ${C_CYAN}%-38.38s${C_RESET} ${C_LBLUE}│${C_RESET}\n" \ "Key type" "$_badge" # Key file + age (plain text in arg) _kf_disp="$(basename "$_key")" [ -n "$_kdays" ] && _kf_disp="${_kf_disp} (${_kdays}d old)" _info_row "Key file" "$_kf_disp" [ -n "$_fp" ] && _info_row "Fingerprint" "$_fp" else printf " ${C_LBLUE}│${C_RESET} ${C_YELLOW}⚠ Key file not found on disk${C_RESET}%$(( _IW - 30 ))s ${C_LBLUE}│${C_RESET}\n" "" fi _info_row "Last seen" "$_relts" # Bottom border with connect hint printf " ${C_LBLUE}├" _i=0; while [ "$_i" -lt "$_IW" ]; do printf '─'; _i=$(( _i + 1 )); done printf "┤${C_RESET}\n" _conn_pad=$(( _IW - 13 - ${#_alias} )) [ "$_conn_pad" -lt 1 ] && _conn_pad=1 printf " ${C_LBLUE}│${C_RESET} ${C_DIM}Connect:${C_RESET} ${C_BOLD}${C_GREEN}ssh %-s${C_RESET}%${_conn_pad}s ${C_LBLUE}│${C_RESET}\n" \ "$_alias" "" printf " ${C_LBLUE}└" _i=0; while [ "$_i" -lt "$_IW" ]; do printf '─'; _i=$(( _i + 1 )); done printf "┘${C_RESET}\n" printf '\n' } # ── EDIT ────────────────────────────────────────────────────────────────────── cmd_edit() { _alias="$1" [ -z "$_alias" ] && die "Usage: termio edit " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _ch=$(get_field "$_alias" "HostName") _cu=$(get_field "$_alias" "User") _cp=$(get_field "$_alias" "Port"); [ -z "$_cp" ] && _cp="22" _ck=$(get_field "$_alias" "IdentityFile") _cj=$(get_field "$_alias" "ProxyJump") _ca=$(get_field "$_alias" "ServerAliveInterval") _cf=$(get_field "$_alias" "ForwardAgent") _cl=$(get_field "$_alias" "LocalForward") _cn=$(get_comment "$_alias" "Note") _cgrp=$(get_comment "$_alias" "Group") info "Editing ${C_BOLD}$_alias${C_RESET} — blank = keep current value" printf '\n' wt_input "Note" "$_cn"; _nn="${_PROMPT_RESULT:-$_cn}" wt_input "Group(s) / tags" "$_cgrp"; _ngrp="${_PROMPT_RESULT:-$_cgrp}" wt_input "Hostname or IP" "$_ch"; _nh="${_PROMPT_RESULT:-$_ch}" wt_input "SSH port" "$_cp"; _np="${_PROMPT_RESULT:-$_cp}" case "$_np" in ''|*[!0-9]*) _np="$_cp" ;; esac wt_input "Remote username" "$_cu"; _nu="${_PROMPT_RESULT:-$_cu}" _nj="$_cj" if wt_yesno "Change jump / bastion host(s)? (current: ${_cj:-none})"; then _saved=$(list_aliases) if [ -n "$_saved" ]; then if [ "$HAS_WHIPTAIL" -eq 1 ]; then if wt_pick_alias "Select first jump host (Cancel = remove):"; then _nj="$SELECTED_ALIAS" wt_input "Additional hops? (comma-separated aliases, blank = none)" "" [ -n "$_PROMPT_RESULT" ] && _nj="${_nj},${_PROMPT_RESULT}" else _nj="" fi else cmd_list printf " Chains supported — e.g. bastion,internal (blank = remove)\n" cli_prompt "Jump host alias(es)" ""; _nj="$_PROMPT_RESULT" fi else wt_msg "No other aliases to use as jump host." fi fi wt_input "ServerAliveInterval (seconds)" "$_ca"; _na="${_PROMPT_RESULT:-$_ca}" wt_input "ForwardAgent (yes/no)" "$_cf" case "$_PROMPT_RESULT" in yes|no) _nf="$_PROMPT_RESULT" ;; *) _nf="$_cf" ;; esac wt_input "LocalForward tunnel" "$_cl"; _nl="${_PROMPT_RESULT:-$_cl}" remove_config_block "$_alias" write_config_block "$_alias" "$_nh" "$_nu" "$_np" \ "$_ck" "$_nj" "$_nn" "$_na" "$_nf" "$_nl" "$_ngrp" success "Alias \"$_alias\" updated." sync_auto_export printf '\n' printf " ${C_BOLD}%-12s${C_RESET} %s\n" "Hostname:" "$_nh" printf " ${C_BOLD}%-12s${C_RESET} %s\n" "User:" "$_nu" printf " ${C_BOLD}%-12s${C_RESET} %s\n\n" "Port:" "$_np" } # ── CLONE ───────────────────────────────────────────────────────────────────── cmd_clone() { _src="${1:-}" [ -z "$_src" ] && die "Usage: termio clone []" alias_exists "$_src" || die "Alias \"$_src\" not found." _ch=$(get_field "$_src" "HostName") _cu=$(get_field "$_src" "User") _cp=$(get_field "$_src" "Port"); [ -z "$_cp" ] && _cp="22" _cj=$(get_field "$_src" "ProxyJump") _cn=$(get_comment "$_src" "Note") _cgrp=$(get_comment "$_src" "Group") _ca=$(get_field "$_src" "ServerAliveInterval") _cf=$(get_field "$_src" "ForwardAgent") _cl=$(get_field "$_src" "LocalForward") _def_ktype=$(pref_get "key_type"); [ -z "$_def_ktype" ] && _def_ktype="ed25519" info "Cloning ${C_BOLD}$_src${C_RESET} — enter new alias details (blank = keep source value)" printf '\n' if [ -n "${2:-}" ]; then ALIAS=$(sanitize_alias "$2") else wt_input "New alias name" "" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Alias cannot be empty."; wt_input "New alias name" "" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } ALIAS=$(sanitize_alias "$_PROMPT_RESULT") fi [ -z "$ALIAS" ] && die "Alias contains no valid characters." if alias_exists "$ALIAS"; then wt_yesno "Alias \"$ALIAS\" already exists. Overwrite?" || { printf ' Aborting.\n'; return 0; } remove_config_block "$ALIAS" fi wt_input "Note" "$_cn"; _nn="${_PROMPT_RESULT:-$_cn}" wt_input "Group(s) / tags" "$_cgrp"; _ngrp="${_PROMPT_RESULT:-$_cgrp}" wt_input "Remote hostname or IP" "$_ch"; _nh="${_PROMPT_RESULT:-$_ch}" wt_input "SSH port" "$_cp"; _np="${_PROMPT_RESULT:-$_cp}" case "$_np" in ''|*[!0-9]*) _np="$_cp" ;; esac wt_input "Remote username" "$_cu"; _nu="${_PROMPT_RESULT:-$_cu}" wt_input "SSH key type (ed25519 / rsa / ecdsa)" "$_def_ktype" case "$_PROMPT_RESULT" in ed25519|rsa|ecdsa) _ktype="$_PROMPT_RESULT" ;; *) _ktype="$_def_ktype" ;; esac mkdir -p "$SSH_DIR"; chmod 700 "$SSH_DIR" KEY_FILE="$SSH_DIR/id_${_ktype}_${ALIAS}" info "Generating ${_ktype} key: $KEY_FILE" _keygen "$KEY_FILE" "$_ktype" "${_nu}@${_nh}-${ALIAS}" chmod 600 "$KEY_FILE"; chmod 644 "${KEY_FILE}.pub" success "Key generated." wt_password "Remote password (to copy key — blank to skip)" REMOTE_PASS="$_PROMPT_RESULT" if [ -n "$REMOTE_PASS" ]; then info "Copying public key to ${_nu}@${_nh}:${_np} ..." if copy_key_to_remote "$KEY_FILE" "$_nu" "$_nh" "$_np" "$REMOTE_PASS" "$_cj"; then success "Public key copied." else warn "Key copy failed — run: ssh-copy-id -i ${KEY_FILE}.pub ${_nu}@${_nh}" fi else info "Skipping key copy — run: ssh-copy-id -i ${KEY_FILE}.pub ${_nu}@${_nh}" fi unset REMOTE_PASS write_config_block "$ALIAS" "$_nh" "$_nu" "$_np" \ "$KEY_FILE" "$_cj" "$_nn" "$_ca" "$_cf" "$_cl" "$_ngrp" success "Alias \"$ALIAS\" created from clone of \"$_src\"." sync_auto_export sync_keys_update } # ── REMOVE ──────────────────────────────────────────────────────────────────── cmd_remove() { _alias="$1" [ -z "$_alias" ] && die "Usage: termio rm " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _key=$(get_field "$_alias" "IdentityFile") _host=$(get_field "$_alias" "HostName") printf '\n' warn "About to remove alias \"${C_BOLD}$_alias${C_RESET}\" → $_host" printf '\n' if ! wt_yesno "Remove alias \"$_alias\" from ~/.ssh/config?"; then printf ' Aborting.\n\n'; return 0 fi remove_config_block "$_alias" success "Alias \"$_alias\" removed." if [ -n "$_key" ] && [ -f "$_key" ]; then if wt_yesno "Also delete key files?\n $_key\n ${_key}.pub"; then rm -f "$_key" "${_key}.pub" success "Key files deleted." else info "Key files kept at: $_key" fi fi sync_auto_export sync_keys_update printf '\n' } # ── RENAME ──────────────────────────────────────────────────────────────────── cmd_rename() { _old="${1:-}"; _new="${2:-}" [ -z "$_old" ] || [ -z "$_new" ] && die "Usage: termio rename " alias_exists "$_old" || die "Alias \"$_old\" not found." _new=$(sanitize_alias "$_new") [ -z "$_new" ] && die "New alias name contains no valid characters." [ "$_old" = "$_new" ] && { info "Names are identical — nothing to do."; return 0; } alias_exists "$_new" && die "Alias \"$_new\" already exists. Remove it first." _ch=$(get_field "$_old" "HostName") _cu=$(get_field "$_old" "User") _cp=$(get_field "$_old" "Port"); [ -z "$_cp" ] && _cp="22" _ck=$(get_field "$_old" "IdentityFile") _cj=$(get_field "$_old" "ProxyJump") _cn=$(get_comment "$_old" "Note") _cgrp=$(get_comment "$_old" "Group") _ca=$(get_field "$_old" "ServerAliveInterval") _cf=$(get_field "$_old" "ForwardAgent") _cl=$(get_field "$_old" "LocalForward") # Rename key files if they follow the per-alias naming convention _new_key="$_ck" if [ -n "$_ck" ] && [ -f "$_ck" ]; then _key_dir=$(dirname "$_ck") _key_base=$(basename "$_ck") _new_base=$(printf '%s' "$_key_base" | sed "s/_${_old}$/_${_new}/") if [ "$_key_base" != "$_new_base" ]; then _new_key="$_key_dir/$_new_base" mv "$_ck" "$_new_key" 2>/dev/null || true [ -f "${_ck}.pub" ] && mv "${_ck}.pub" "${_new_key}.pub" 2>/dev/null || true vlog "Key renamed: $_ck → $_new_key" fi fi remove_config_block "$_old" write_config_block "$_new" "$_ch" "$_cu" "$_cp" "$_new_key" "$_cj" "$_cn" "$_ca" "$_cf" "$_cl" "$_cgrp" # Migrate per-alias preferences for _pkey in last_connect auto_agent sshpass wol_mac copy_last_remote; do _pval=$(pref_get "${_pkey}_${_old}") if [ -n "$_pval" ]; then pref_set "${_pkey}_${_new}" "$_pval" # Clear old key by setting empty (pref_set with empty value removes it) sed -i.bak "/^${_pkey}_${_old}=/d" "$PREF_FILE" 2>/dev/null \ || sed -i "" "/^${_pkey}_${_old}=/d" "$PREF_FILE" 2>/dev/null || true rm -f "${PREF_FILE}.bak" 2>/dev/null || true fi done # Update pinned_aliases list _pinned=$(pref_get "pinned_aliases") if printf '%s' "${_pinned}," | tr ',' '\n' | grep -qx "$_old"; then _new_pinned=$(printf '%s' "$_pinned" | tr ',' '\n' | \ sed "s/^${_old}$/${_new}/" | tr '\n' ',' | sed 's/,$//' || true) pref_set "pinned_aliases" "$_new_pinned" fi success "Alias \"$_old\" renamed to \"$_new\"." sync_auto_export sync_keys_update } # ── TEST ────────────────────────────────────────────────────────────────────── cmd_test() { _alias="${1:-}" if [ -z "$_alias" ]; then cmd_status; return $? fi alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" printf '\n' info "Testing ${C_BOLD}$_alias${C_RESET} (${_user}@${_host}:${_port}) ..." _start=$(date +%s 2>/dev/null || printf '0') if ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o StrictHostKeyChecking=accept-new \ "$_alias" true 2>/dev/null; then _end=$(date +%s 2>/dev/null || printf '0') _elapsed=$(( _end - _start )) success "Connection ${C_GREEN}successful${C_RESET} (${_elapsed}s)" else _code=$? printf "${C_RED} ✘${C_RESET} Connection ${C_RED}failed${C_RESET} (exit %s)\n\n" "$_code" printf " Possible causes:\n" printf " • Host unreachable or firewall blocking port %s\n" "$_port" printf " • Key not accepted — try: termio rotate %s\n" "$_alias" printf " • Wrong details — check: termio info %s\n" "$_alias" fi printf '\n' } # ── ROTATE ──────────────────────────────────────────────────────────────────── cmd_rotate() { _alias="$1" [ -z "$_alias" ] && die "Usage: termio rotate " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" _old_key=$(get_field "$_alias" "IdentityFile") _jump=$(get_field "$_alias" "ProxyJump") printf '\n' warn "Key rotation for ${C_BOLD}$_alias${C_RESET} (${_user}@${_host})" printf '\n This will generate a new key, copy it, and remove the old one.\n\n' if ! wt_yesno "Proceed with key rotation for \"$_alias\"?"; then printf ' Aborting.\n\n'; return 0 fi wt_password "Remote password (to copy new key)" _pass="$_PROMPT_RESULT" _old_type=$(basename "$_old_key" | awk -F'_' '{print $2}') [ -z "$_old_type" ] && { _old_type=$(pref_get "key_type"); [ -z "$_old_type" ] && _old_type="ed25519"; } wt_input "New key type (ed25519 / rsa / ecdsa)" "$_old_type" case "$_PROMPT_RESULT" in ed25519|rsa|ecdsa) _new_type="$_PROMPT_RESULT" ;; *) _new_type="$_old_type" ;; esac _new_key="${_old_key}_new" info "Generating new ${_new_type} key: $_new_key" _keygen "$_new_key" "$_new_type" "${_user}@${_host}-${_alias}-rotated" chmod 600 "$_new_key"; chmod 644 "${_new_key}.pub" success "New key generated." info "Copying new key to remote ..." if copy_key_to_remote "$_new_key" "$_user" "$_host" "$_port" "$_pass" "$_jump"; then unset _pass success "New key copied. Password cleared." else unset _pass die "Key copy failed — rotation aborted. Old key still active." fi info "Removing old key from remote authorized_keys ..." _old_pub=$(cat "${_old_key}.pub" 2>/dev/null || printf '') if [ -n "$_old_pub" ]; then ssh -F "$CONFIG_FILE" -o BatchMode=yes -o ConnectTimeout=10 "$_alias" \ "sed -i.bak '/$(printf '%s' "$_old_pub" | sed 's/[\/&]/\\&/g')/d' \ ~/.ssh/authorized_keys" 2>/dev/null \ && success "Old key removed from remote." \ || warn "Could not auto-remove old key — remove manually from remote authorized_keys." fi mv "$_new_key" "$_old_key" mv "${_new_key}.pub" "${_old_key}.pub" success "Key files updated locally." sync_auto_export sync_keys_update success "Rotation complete." printf "\n Connect as usual: ${C_BOLD}${C_GREEN}ssh %s${C_RESET}\n\n" "$_alias" } # ── EXPORT ──────────────────────────────────────────────────────────────────── cmd_export() { _outfile="${1:-$HOME/termio-export-$(date +%Y%m%d).txt}" _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf '\n No aliases to export.\n\n'; return 0 fi { printf '# termio export — version %s — %s\n' "$TERMIO_VERSION" "$(date)" printf '# Import with: termio import \n' printf '# NOTE: Private keys are not included.\n\n' printf '%s\n' "$_aliases" | while IFS= read -r _alias; do _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" _jump=$(get_field "$_alias" "ProxyJump") _alive=$(get_field "$_alias" "ServerAliveInterval") _fwda=$(get_field "$_alias" "ForwardAgent") _lfwd=$(get_field "$_alias" "LocalForward") _note=$(get_comment "$_alias" "Note") _grp=$(get_comment "$_alias" "Group") printf '[alias]\n' printf 'name=%s\nhostname=%s\nuser=%s\nport=%s\n' \ "$_alias" "$_host" "$_user" "$_port" [ -n "$_note" ] && printf 'note=%s\n' "$_note" [ -n "$_grp" ] && printf 'group=%s\n' "$_grp" [ -n "$_jump" ] && printf 'jump=%s\n' "$_jump" [ -n "$_alive" ] && printf 'keepalive=%s\n' "$_alive" [ -n "$_fwda" ] && printf 'fwdagent=%s\n' "$_fwda" [ -n "$_lfwd" ] && printf 'localfwd=%s\n' "$_lfwd" printf '\n' done } > "$_outfile" _count=$(printf '%s\n' "$_aliases" | grep -c '.' || printf '0') success "Exported $_count alias(es) to: $_outfile" printf '\n' } # ── IMPORT ──────────────────────────────────────────────────────────────────── cmd_import() { _infile="$1" [ -z "$_infile" ] && die "Usage: termio import " [ -f "$_infile" ] || die "File not found: $_infile" printf '\n'; info "Reading aliases from: $_infile\n" _cur_name=""; _cur_host=""; _cur_user=""; _cur_port="22" _cur_note=""; _cur_grp=""; _cur_jump=""; _cur_alive=""; _cur_fwda=""; _cur_lfwd="" _imp_ktype=$(pref_get "key_type"); [ -z "$_imp_ktype" ] && _imp_ktype="ed25519" _imported=0 _do_import() { [ -z "$_cur_name" ] && return [ -z "$_cur_host" ] && { warn "Skipping \"$_cur_name\" — no hostname."; return; } printf " ${C_BOLD}Importing:${C_RESET} %s → %s@%s:%s\n" \ "$_cur_name" "$_cur_user" "$_cur_host" "$_cur_port" if alias_exists "$_cur_name"; then if ! wt_yesno "Alias \"$_cur_name\" exists. Overwrite?"; then info "Skipped."; return fi remove_config_block "$_cur_name" fi mkdir -p "$SSH_DIR"; chmod 700 "$SSH_DIR" _key="$SSH_DIR/id_${_imp_ktype}_${_cur_name}" if [ -f "$_key" ]; then warn "Existing key found — reusing: $_key" else info "Generating $_imp_ktype key: $_key" _keygen "$_key" "$_imp_ktype" "${_cur_user}@${_cur_host}-${_cur_name}" chmod 600 "$_key"; chmod 644 "${_key}.pub" success "Key generated." fi wt_password "Password for ${_cur_user}@${_cur_host}" _pass="$_PROMPT_RESULT" if copy_key_to_remote "$_key" "$_cur_user" "$_cur_host" \ "$_cur_port" "$_pass" "$_cur_jump"; then success "Key copied." else warn "Key copy failed — run: ssh-copy-id -i ${_key}.pub ${_cur_user}@${_cur_host}" fi unset _pass write_config_block "$_cur_name" "$_cur_host" "$_cur_user" "$_cur_port" \ "$_key" "$_cur_jump" "$_cur_note" "$_cur_alive" "$_cur_fwda" "$_cur_lfwd" "$_cur_grp" success "\"$_cur_name\" imported."; printf '\n' _imported=$(( _imported + 1 )) } while IFS= read -r _line || [ -n "$_line" ]; do case "$_line" in '#'*|'') continue ;; esac case "$_line" in '[alias]') _do_import _cur_name=""; _cur_host=""; _cur_user=""; _cur_port="22" _cur_note=""; _cur_grp=""; _cur_jump=""; _cur_alive=""; _cur_fwda=""; _cur_lfwd="" ;; name=*) _cur_name="${_line#name=}" ;; hostname=*) _cur_host="${_line#hostname=}" ;; user=*) _cur_user="${_line#user=}" ;; port=*) _cur_port="${_line#port=}" ;; note=*) _cur_note="${_line#note=}" ;; group=*) _cur_grp="${_line#group=}" ;; jump=*) _cur_jump="${_line#jump=}" ;; keepalive=*) _cur_alive="${_line#keepalive=}" ;; fwdagent=*) _cur_fwda="${_line#fwdagent=}" ;; localfwd=*) _cur_lfwd="${_line#localfwd=}" ;; esac done < "$_infile" _do_import sync_auto_export printf '\n'; success "Import complete. $_imported alias(es) added.\n\n" } # ── PREFER ──────────────────────────────────────────────────────────────────── _cmd_prefer_alias() { _pa="$1"; _pk="${2:-}"; _pv="${3:-}" case "$_pk" in auto_agent) case "$_pv" in on|yes) pref_set "auto_agent_$_pa" "yes" success "Auto agent-add enabled for ${C_BOLD}$_pa${C_RESET}." printf " On connect, the alias key is loaded into ssh-agent automatically.\n\n" ;; off|no|"") pref_set "auto_agent_$_pa" "" success "Auto agent-add disabled for ${C_BOLD}$_pa${C_RESET}.\n" ;; *) die "auto_agent must be on or off" ;; esac ;; sshpass) case "$_pv" in clear|rm|remove|off) pref_set "sshpass_$_pa" "" success "Stored password cleared for ${C_BOLD}$_pa${C_RESET}.\n" ;; "") _cur=$(pref_get "sshpass_$_pa") if [ -n "$_cur" ]; then warn "A password is already stored for $_pa." printf " To clear it: ${C_BOLD}termio prefer $_pa sshpass clear${C_RESET}\n\n" wt_yesno "Overwrite stored password for \"$_pa\"?" || return 0 fi printf '\n' warn "Password will be stored in obfuscated form (not encrypted)." printf " sshpass passes it via process environment — visible to root and in some logs.\n" printf " For security-sensitive hosts, use key-based auth instead.\n\n" command -v sshpass >/dev/null 2>&1 || \ die "sshpass not installed — needed for password auth" wt_password "SSH password for $_pa" [ -z "$_PROMPT_RESULT" ] && { warn "No password entered. Nothing saved.\n"; return 0; } _enc=$(printf '%s' "$_PROMPT_RESULT" | base64) unset _PROMPT_RESULT pref_set "sshpass_$_pa" "$_enc" unset _enc success "Password stored for ${C_BOLD}$_pa${C_RESET}." printf " termio connect $_pa — auto-fills password\n" printf " termio connect $_pa --sshpass — one-time prompt (override)\n\n" ;; *) die "Usage: termio prefer $_pa sshpass OR termio prefer $_pa sshpass clear" ;; esac ;; wol) _wm="${3:-}" if [ -z "$_wm" ]; then _cur_mac=$(pref_get "wol_mac_${_pa}") printf '\n WOL MAC for %s: %s\n\n' "$_pa" "${_cur_mac:-not set}" printf " ${C_BOLD}termio prefer %s wol ${C_RESET} — set MAC address\n" "$_pa" printf " ${C_BOLD}termio prefer %s wol clear${C_RESET} — remove MAC address\n\n" "$_pa" return 0 fi if [ "$_wm" = "clear" ]; then pref_set "wol_mac_${_pa}" "" success "WOL MAC address cleared for \"$_pa\"." else # Normalise MAC to lowercase colon-separated _wm_norm=$(printf '%s' "$_wm" | tr '[:upper:]' '[:lower:]' | \ sed 's/-/:/g' | sed 's/[^0-9a-f:]//g') _wm_octets=$(printf '%s' "$_wm_norm" | tr ':' '\n' | wc -l) [ "$_wm_octets" -ne 6 ] 2>/dev/null && die "Invalid MAC address: $_wm" pref_set "wol_mac_${_pa}" "$_wm_norm" success "WOL MAC set for \"$_pa\": $wm_norm" printf " Send WOL packet with: ${C_BOLD}termio wake $_pa${C_RESET}\n\n" fi ;; audit_threshold|audit-threshold) _global_thresh=$(pref_get "audit_threshold") [ -z "$_global_thresh" ] && _global_thresh="90 (default)" if [ -z "$_pv" ]; then _cur=$(pref_get "audit_threshold_${_pa}") printf '\n Audit threshold for %s: %s (global: %s)\n\n' \ "$_pa" "${_cur:-inherited (${_global_thresh})}" "$_global_thresh" printf " ${C_BOLD}termio prefer %s audit_threshold ${C_RESET} — set per-host threshold\n" "$_pa" printf " ${C_BOLD}termio prefer %s audit_threshold off${C_RESET} — disable auditing for this host\n" "$_pa" printf " ${C_BOLD}termio prefer %s audit_threshold clear${C_RESET} — remove override (inherit global)\n\n" "$_pa" elif [ "$_pv" = "clear" ] || [ "$_pv" = "reset" ]; then pref_set "audit_threshold_${_pa}" "" success "Per-host audit threshold cleared for ${C_BOLD}$_pa${C_RESET} — inheriting global." elif [ "$_pv" = "off" ] || [ "$_pv" = "never" ] || [ "$_pv" = "disable" ]; then warn "⚠ Key rotation auditing disabled for ${C_BOLD}$_pa${C_RESET}." printf " Re-enable with: ${C_BOLD}termio prefer %s audit_threshold clear${C_RESET}\n\n" "$_pa" pref_set "audit_threshold_${_pa}" "off" else case "$_pv" in *[!0-9]*|0) die "audit_threshold must be a positive integer, 'off', or 'clear'" ;; esac pref_set "audit_threshold_${_pa}" "$_pv" success "Audit threshold for ${C_BOLD}${_pa}${C_RESET} set to ${C_BOLD}${_pv} days${C_RESET}." fi ;; "") _aa=$(pref_get "auto_agent_$_pa") _sp=$(pref_get "sshpass_$_pa") _wm=$(pref_get "wol_mac_$_pa") _at=$(pref_get "audit_threshold_$_pa") _global_thresh=$(pref_get "audit_threshold"); [ -z "$_global_thresh" ] && _global_thresh=90 printf '\n Per-alias preferences for: %s\n\n' "$_pa" printf " auto_agent: %s\n" "${_aa:-off}" printf " sshpass: %s\n" "$([ -n "$_sp" ] && printf 'set (stored)' || printf 'not set')" printf " wol_mac: %s\n" "${_wm:-not set}" printf " audit_threshold: %s\n\n" "${_at:-inherited (${_global_thresh} days)}" printf " ${C_BOLD}termio prefer %s auto_agent on|off${C_RESET}\n" "$_pa" printf " ${C_BOLD}termio prefer %s sshpass${C_RESET} — store password\n" "$_pa" printf " ${C_BOLD}termio prefer %s sshpass clear${C_RESET} — remove stored password\n" "$_pa" printf " ${C_BOLD}termio prefer %s wol ${C_RESET} — set Wake-on-LAN MAC\n" "$_pa" printf " ${C_BOLD}termio prefer %s audit_threshold ${C_RESET} — per-host key rotation threshold\n\n" "$_pa" ;; *) die "Unknown per-alias preference: '$_pk' (use: auto_agent, sshpass, wol, audit_threshold)" ;; esac } cmd_prefer() { _mode="${1:-}"; _val="${2:-}"; _val3="${3:-}" if [ -z "$_mode" ]; then _cur=$(pref_get "ui") _thresh=$(pref_get "audit_threshold") printf '\n' case "$_cur" in whiptail) printf " Current UI preference: ${C_BOLD}${C_CYAN}whiptail${C_RESET}\n" ;; cli) printf " Current UI preference: ${C_BOLD}${C_GREEN}cli${C_RESET}\n" ;; *) printf " Current UI preference: ${C_DIM}not set (auto-detect)${C_RESET}\n" ;; esac printf " Whiptail installed: %s\n" \ "$([ "$_WHIPTAIL_INSTALLED" -eq 1 ] \ && printf "${C_GREEN}yes${C_RESET}" \ || printf "${C_YELLOW}no${C_RESET}")" _ktype=$(pref_get "key_type") printf " Audit threshold: %s days\n" "${_thresh:-90 (default)}" printf " Default key type: %s\n\n" "${_ktype:-ed25519 (default)}" printf " ${C_BOLD}termio prefer whiptail${C_RESET} — use TUI dialogs\n" printf " ${C_BOLD}termio prefer cli${C_RESET} — use plain terminal\n" printf " ${C_BOLD}termio prefer key_type ${C_RESET} — default key type\n" printf " ${C_BOLD}termio prefer audit_threshold ${C_RESET} — key rotation threshold (global)\n" printf " ${C_BOLD}termio prefer audit_threshold ${C_RESET} — per-host key rotation threshold\n" printf " ${C_BOLD}termio prefer audit_threshold clear${C_RESET} — remove per-host override\n" printf " ${C_BOLD}termio prefer auto_agent on|off${C_RESET} — auto-load key to agent\n" printf " ${C_BOLD}termio prefer sshpass${C_RESET} — store password for alias\n\n" return 0 fi case "$_mode" in whiptail|tui) if [ "$_WHIPTAIL_INSTALLED" -eq 1 ]; then pref_set "ui" "whiptail"; HAS_WHIPTAIL=1 success "UI preference set to ${C_BOLD}whiptail${C_RESET}.\n" else printf '\n'; warn "whiptail is not installed.\n" printf '\n' printf " termio will use whiptail once it is installed.\n" printf " Your preference will be saved either way.\n\n" printf " ${C_BOLD}Install whiptail now? (y/N):${C_RESET} " IFS= read -r _ans; printf '\n' pref_set "ui" "whiptail" success "UI preference saved as ${C_BOLD}whiptail${C_RESET}." case "$_ans" in [yY]|[yY][eE][sS]) install_whiptail \ && success "whiptail ready. TUI active on next launch." \ || info "Preference saved. CLI used until whiptail is available." ;; *) info "Skipping install. Manual command:" detect_distro [ -n "$WHIPTAIL_INSTALL_CMD" ] \ && printf " ${C_BOLD}sudo %s${C_RESET}\n\n" "$WHIPTAIL_INSTALL_CMD" \ || printf " Install ${C_BOLD}whiptail${C_RESET} via your package manager.\n\n" ;; esac fi ;; cli|plain) pref_set "ui" "cli"; HAS_WHIPTAIL=0 success "UI preference set to ${C_BOLD}cli${C_RESET}." printf " Whiptail will not be used even if installed.\n\n" ;; key_type|key-type) if [ -z "$_val" ]; then _cur=$(pref_get "key_type") printf '\n Default key type: %s\n\n' "${_cur:-ed25519 (default)}" else case "$_val" in ed25519|rsa|ecdsa) ;; *) die "key_type must be ed25519, rsa, or ecdsa" ;; esac pref_set "key_type" "$_val" success "Default key type set to ${C_BOLD}${_val}${C_RESET}." printf " New aliases will use %s keys. Existing keys are unchanged.\n\n" "$_val" fi ;; audit_threshold|audit-threshold) if [ -z "$_val" ]; then _cur=$(pref_get "audit_threshold") printf '\n Audit threshold: %s\n\n' "${_cur:-90 days (default)}" else case "$_val" in off|never|disable|disabled) warn "⚠ Disabling key rotation auditing. SSH keys will not be flagged for age." printf " Re-enable with: ${C_BOLD}termio prefer audit_threshold 90${C_RESET}\n\n" pref_set "audit_threshold" "off" ;; reset|default) pref_set "audit_threshold" "" success "Audit threshold reset to default (90 days)." ;; *[!0-9]*|0) die "audit_threshold must be a positive integer, 'off', or 'reset'" ;; *) pref_set "audit_threshold" "$_val" success "Audit threshold set to ${C_BOLD}${_val} days${C_RESET}." printf " Run ${C_BOLD}termio audit${C_RESET} to check all keys.\n\n" ;; esac fi ;; *) if alias_exists "$_mode" 2>/dev/null; then _cmd_prefer_alias "$_mode" "$_val" "$_val3" else die "Unknown preference: '$_mode' (use: whiptail, cli, key_type, audit_threshold, or an alias name)" fi ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # ── Sync commands ─────────────────────────────────────════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════ # ── SYNC STATUS ─────────────────────────────────────────────────────────────── cmd_sync_status() { _sync_path=$(pref_get "sync_path") _last=$(pref_get "sync_last_export") printf '\n' printf "${C_CYAN}${C_BOLD} Sync Status${C_RESET}\n" printf ' '; printf '%.0s─' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 2>/dev/null; printf '\n' if [ -z "$_sync_path" ]; then printf " Sync folder: ${C_DIM}not configured${C_RESET}\n" printf " Run ${C_BOLD}termio sync init${C_RESET} to set up sync.\n\n" return 0 fi printf " Sync folder: %s\n" "$_sync_path" if [ -d "$_sync_path" ]; then printf " Folder status: ${C_GREEN}reachable ✔${C_RESET}\n" else printf " Folder status: ${C_RED}NOT reachable ✘${C_RESET}\n" printf " ${C_YELLOW}Folder not found — is the sync provider mounted?${C_RESET}\n\n" return 0 fi # Marker file _marker="$_sync_path/.termio-sync" if [ -f "$_marker" ]; then _init_date=$(awk -F'=' '/^init_date=/{print $2}' "$_marker") _init_host=$(awk -F'=' '/^init_host=/{print $2}' "$_marker") printf " Initialised: %s (on %s)\n" "$_init_date" "$_init_host" fi # Export file _export="$_sync_path/aliases-export.txt" if [ -f "$_export" ]; then _count=$(grep -c '^\[alias\]' "$_export" 2>/dev/null || printf '0') printf " Aliases export: ${C_GREEN}present${C_RESET} (%s aliases)\n" "$_count" else printf " Aliases export: ${C_YELLOW}not found${C_RESET}\n" fi printf " Last export: %s\n" "${_last:-never}" # Key sync status _ks=$(pref_get "keys_sync") _klast=$(pref_get "keys_sync_last_update") _kcount=$(pref_get "keys_sync_count") printf "\n ${C_BOLD}Key sync:${C_RESET}\n" if [ "$_ks" = "enabled" ]; then printf " Status: ${C_GREEN}enabled${C_RESET}\n" printf " Keys: %s\n" "${_kcount:-unknown}" printf " Last update: %s\n" "${_klast:-never}" [ -f "$_sync_path/keys.enc" ] \ && printf " Archive: ${C_GREEN}present ✔${C_RESET}\n" \ || printf " Archive: ${C_YELLOW}missing — run: termio sync keys update${C_RESET}\n" else printf " Status: ${C_DIM}disabled${C_RESET}\n" printf " Run ${C_BOLD}termio sync keys enable${C_RESET} to opt in.\n" fi # Symlink status printf "\n ${C_BOLD}Symlink status:${C_RESET}\n" for _f in preferences; do _local="$CONF_DIR/$_f" if [ -L "$_local" ]; then _target=$(readlink "$_local" 2>/dev/null || printf 'unknown') printf " %-16s ${C_GREEN}symlinked${C_RESET} → %s\n" "$_f" "$_target" elif [ -f "$_local" ]; then printf " %-16s ${C_DIM}local file (not synced)${C_RESET}\n" "$_f" else printf " %-16s ${C_DIM}not found${C_RESET}\n" "$_f" fi done printf '\n' vlog "sync_status complete" } # ── SYNC INIT ───────────────────────────────────────────────────────────────── cmd_sync_init() { printf '\n' printf "${C_CYAN}${C_BOLD} termio sync init${C_RESET}\n\n" printf " This wizard will:\n" printf " 1. Ask where your sync folder is (Nextcloud, Google Drive, Syncthing…)\n" printf " 2. Create a termio-sync directory inside it\n" printf " 3. Symlink your preferences file into the sync folder\n" printf " 4. Write an initial aliases export\n" printf " 5. If an existing termio-sync is found, offer to import from it\n\n" # Check if already configured _existing=$(pref_get "sync_path") if [ -n "$_existing" ]; then printf " ${C_YELLOW}Sync is already configured:${C_RESET} %s\n\n" "$_existing" if ! wt_yesno "Reconfigure sync? (current config will be replaced)"; then printf ' Aborting.\n\n'; return 0 fi fi # Ask for sync root folder _default_paths="" for _p in \ "$HOME/Nextcloud" \ "$HOME/ownCloud" \ "$HOME/Google Drive" \ "$HOME/Dropbox" \ "$HOME/Syncthing" \ "$HOME/sync"; do [ -d "$_p" ] && _default_paths="$_p" && break done wt_input "Path to your sync root folder" "${_default_paths:-$HOME/Nextcloud}" _sync_root="$_PROMPT_RESULT" [ -z "$_sync_root" ] && die "No sync path provided." [ -d "$_sync_root" ] || die "Directory not found: $_sync_root" _sync_path="$_sync_root/termio-sync" vlog "Target sync path: $_sync_path" # Check if an existing termio-sync is there from another machine _found_existing=0 if [ -d "$_sync_path" ] && [ -f "$_sync_path/.termio-sync" ]; then _init_host=$(awk -F'=' '/^init_host=/{print $2}' "$_sync_path/.termio-sync") _init_date=$(awk -F'=' '/^init_date=/{print $2}' "$_sync_path/.termio-sync") printf '\n' info "Found existing termio-sync folder (from ${_init_host}, ${_init_date})" _found_existing=1 fi # Create sync dir mkdir -p "$_sync_path" vlog "Created sync directory: $_sync_path" # Write marker file _marker="$_sync_path/.termio-sync" cat > "$_marker" </dev/null || printf 'unknown') EOF vlog "Wrote marker: $_marker" # Save sync path to prefs pref_set "sync_path" "$_sync_path" # Symlink preferences file _local_pref="$CONF_DIR/preferences" _sync_pref="$_sync_path/preferences" mkdir -p "$CONF_DIR" if [ -L "$_local_pref" ]; then # Already a symlink — remove and re-link rm "$_local_pref" vlog "Removed existing symlink: $_local_pref" elif [ -f "$_local_pref" ]; then # Real file exists — copy it to sync folder then replace with symlink if [ -f "$_sync_pref" ] && [ "$_found_existing" -eq 1 ]; then # Sync folder already has a preferences — ask which to keep if wt_yesno "Sync folder has an existing preferences file.\nKeep the REMOTE (sync) version? (No = use local)"; then vlog "Keeping remote preferences" else cp "$_local_pref" "$_sync_pref" vlog "Copied local preferences to sync folder" fi else cp "$_local_pref" "$_sync_pref" vlog "Copied local preferences → $_sync_pref" fi rm "$_local_pref" elif [ -f "$_sync_pref" ]; then # Nothing local, sync has a file — link will pull it in vlog "No local preferences — will link from sync" else # Neither exists — create empty in sync folder touch "$_sync_pref" vlog "Created empty preferences in sync folder" fi # Re-read sync_path pref into the now-linked file after linking ln -sf "$_sync_pref" "$_local_pref" success "Preferences symlinked → $(_sync_path)/preferences" # After linking, make sure sync_path is still written # (the symlink now points to the sync copy of prefs) pref_set "sync_path" "$_sync_path" # Write initial auto-export info "Writing initial aliases export ..." sync_auto_export success "Aliases export written." # Offer import if existing data found from another machine if [ "$_found_existing" -eq 1 ] && [ -f "$_sync_path/aliases-export.txt" ]; then _count=$(grep -c '^\[alias\]' "$_sync_path/aliases-export.txt" 2>/dev/null || printf '0') if [ "$_count" -gt 0 ]; then printf '\n' if wt_yesno "Found $_count alias(es) from another machine.\nImport them now?"; then cmd_import "$_sync_path/aliases-export.txt" else info "Skipped import. You can import later with:" printf " ${C_BOLD}termio import %s/aliases-export.txt${C_RESET}\n" "$_sync_path" fi fi fi printf '\n' printf "${C_CYAN}╔══════════════════════════════════════════════╗${C_RESET}\n" printf "${C_CYAN}║${C_RESET} ${C_GREEN}${C_BOLD}Sync configured! ✔${C_RESET} ${C_CYAN}║${C_RESET}\n" printf "${C_CYAN}╚══════════════════════════════════════════════╝${C_RESET}\n\n" printf " Sync folder: %s\n" "$_sync_path" printf " From now on, alias changes are auto-exported to this folder.\n" printf " On a new machine, run: ${C_BOLD}termio sync init${C_RESET} and point to the same folder.\n\n" } # ── SYNC DETACH ─────────────────────────────────────────────────────────────── cmd_sync_detach() { _sync_path=$(pref_get "sync_path") if [ -z "$_sync_path" ]; then printf '\n Sync is not configured.\n\n'; return 0 fi printf '\n' warn "Detaching sync from: $_sync_path" printf '\n' printf " This will:\n" printf " 1. Replace symlinks with local copies of the files\n" printf " 2. Remove the sync path from preferences\n" printf " 3. Leave the sync folder itself untouched\n\n" if ! wt_yesno "Detach termio from sync folder?"; then printf ' Aborting.\n\n'; return 0 fi # Replace preferences symlink with real file _local_pref="$CONF_DIR/preferences" _sync_pref="$_sync_path/preferences" if [ -L "$_local_pref" ]; then if [ -f "$_sync_pref" ]; then cp "$_sync_pref" "${_local_pref}.tmp" rm "$_local_pref" mv "${_local_pref}.tmp" "$_local_pref" vlog "Replaced preferences symlink with local copy" success "preferences restored as local file." else rm "$_local_pref" touch "$_local_pref" warn "Sync preferences file missing — created empty local file." fi fi # Clear sync_path from prefs (now a local file) pref_set "sync_path" "" pref_set "sync_last_export" "" success "Sync path cleared from preferences." printf '\n' printf " termio is now running fully locally.\n" printf " Sync folder at %s is untouched.\n\n" "$_sync_path" vlog "sync_detach complete" } # ── Key sync: silent update hook ───────────────────────────────────────────── # Called after add/rotate/rm when key sync is enabled. # Rebuilds keys.enc silently. Prompts for passphrase — there is no stored copy. sync_keys_update() { _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] && { vlog "sync_keys_update: no sync path"; return 0; } [ -d "$_sync_path" ] || { warn "Key sync: sync folder not reachable."; return 0; } _ks=$(pref_get "keys_sync"); [ "$_ks" != "enabled" ] && { vlog "sync_keys_update: key sync not enabled"; return 0; } _enc_file="$_sync_path/keys.enc" vlog "sync_keys_update: rebuilding $_enc_file" # Collect all keys managed by termio _key_list=$(find "$SSH_DIR" -maxdepth 1 \ \( -name 'id_ed25519_*' -o -name 'id_rsa_*' -o -name 'id_ecdsa_*' \) \ ! -name '*.pub' 2>/dev/null || true) if [ -z "$_key_list" ]; then vlog "sync_keys_update: no keys found — skipping" return 0 fi printf '\n' info "Key sync is enabled — rebuilding encrypted key archive." printf " Enter your key-sync passphrase to update the archive.\n" printf " ${C_DIM}(This is separate from any SSH or sudo password)${C_RESET}\n" cli_prompt_secret "Key-sync passphrase" _kpass="$_PROMPT_RESULT" # Build a temp tar of all private keys + their pub files _tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t termio) _tarball="$_tmpdir/keys.tar" # Copy keys into temp dir preserving names printf '%s\n' "$_key_list" | while IFS= read -r _kf; do cp "$_kf" "$_tmpdir/" [ -f "${_kf}.pub" ] && cp "${_kf}.pub" "$_tmpdir/" done # Add a manifest { printf '# termio key archive manifest\n' printf 'version=%s\n' "$TERMIO_VERSION" printf 'created=%s\n' "$(date '+%Y-%m-%d %H:%M')" printf 'host=%s\n' "$(hostname 2>/dev/null || printf 'unknown')" printf 'keys=%s\n' "$(printf '%s\n' "$_key_list" | wc -l | tr -d ' ')" } > "$_tmpdir/MANIFEST" tar -cf "$_tarball" -C "$_tmpdir" . 2>/dev/null # Encrypt with AES-256-CBC + PBKDF2 printf '%s' "$_kpass" | openssl enc -aes-256-cbc -pbkdf2 -iter 600000 \ -pass stdin \ -in "$_tarball" \ -out "$_enc_file" 2>/dev/null || { unset _kpass rm -rf "$_tmpdir" warn "Encryption failed — key archive not updated." return 1 } unset _kpass rm -rf "$_tmpdir" _kcount=$(printf '%s\n' "$_key_list" | grep -c '.' || printf '0') _ts=$(date '+%Y-%m-%d %H:%M') pref_set "keys_sync_last_update" "$_ts" pref_set "keys_sync_count" "$_kcount" success "Key archive updated ($_kcount key(s)) — encrypted with AES-256." vlog "sync_keys_update: wrote $_enc_file at $_ts" } # ── Key sync: commands ──────────────────────────────────────────────────────── cmd_sync_keys() { _sub="${1:-status}" case "$_sub" in # ── STATUS ────────────────────────────────────────────────────────── status) _sync_path=$(pref_get "sync_path") _ks=$(pref_get "keys_sync") _last=$(pref_get "keys_sync_last_update") _kcount=$(pref_get "keys_sync_count") printf '\n' printf "${C_CYAN}${C_BOLD} Key Sync Status${C_RESET}\n" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 \ 2>/dev/null; printf '\n' if [ "$_ks" = "enabled" ]; then printf " Key sync: ${C_GREEN}enabled${C_RESET}\n" else printf " Key sync: ${C_DIM}disabled${C_RESET}\n" printf " Run ${C_BOLD}termio sync keys enable${C_RESET} to opt in.\n\n" return 0 fi [ -n "$_sync_path" ] && [ -d "$_sync_path" ] \ && printf " Sync folder: ${C_GREEN}reachable${C_RESET} (%s)\n" "$_sync_path" \ || printf " Sync folder: ${C_YELLOW}not reachable${C_RESET}\n" _enc="$_sync_path/keys.enc" if [ -f "$_enc" ]; then _size=$(wc -c < "$_enc" 2>/dev/null | tr -d ' ') printf " Archive: ${C_GREEN}present${C_RESET} (%s bytes)\n" "$_size" else printf " Archive: ${C_YELLOW}not found${C_RESET} — run: termio sync keys update\n" fi printf " Keys archived: %s\n" "${_kcount:-unknown}" printf " Last updated: %s\n\n" "${_last:-never}" printf " ${C_BOLD}Security reminder:${C_RESET}\n" printf " Your passphrase is never stored. You will be prompted\n" printf " each time the archive is rebuilt or restored.\n\n" ;; # ── ENABLE ────────────────────────────────────────────────────────── enable) _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] && die "Sync not configured. Run: termio sync init" [ -d "$_sync_path" ] || die "Sync folder not reachable: $_sync_path" command -v openssl >/dev/null 2>&1 \ || die "openssl not found. Install it first:\n Debian/Mint: sudo apt install openssl\n Fedora: sudo dnf install openssl" _ks=$(pref_get "keys_sync") if [ "$_ks" = "enabled" ]; then printf '\n' warn "Key sync is already enabled." printf " Use ${C_BOLD}termio sync keys update${C_RESET} to rebuild the archive.\n\n" return 0 fi # Security briefing + explicit CONFIRM gate printf '\n' printf "${C_CYAN}${C_BOLD} Encrypted Key Sync — Opt In${C_RESET}\n\n" printf " This feature stores your private SSH keys in an AES-256\n" printf " encrypted archive (keys.enc) inside your sync folder.\n\n" printf " ${C_BOLD}What this means:${C_RESET}\n" printf " • Your keys travel with your sync provider\n" printf " • Security depends on your passphrase strength AND\n" printf " your sync provider's security\n" printf " • The passphrase is NEVER stored anywhere by termio\n" printf " • You will be prompted for it each time keys are\n" printf " archived or restored\n" printf " • AES-256-CBC with PBKDF2 (600,000 iterations)\n\n" printf " ${C_BOLD}Recommended if:${C_RESET}\n" printf " • Your sync folder is on an encrypted volume or\n" printf " a self-hosted provider you control (e.g. Nextcloud)\n" printf " • You use a strong, unique passphrase\n" printf " • You understand the risks\n\n" printf " ${C_YELLOW}${C_BOLD} Type CONFIRM to enable key sync, anything else to cancel:${C_RESET} " IFS= read -r _gate printf '\n' if [ "$_gate" != "CONFIRM" ]; then printf ' Cancelled — key sync not enabled.\n\n' return 0 fi pref_set "keys_sync" "enabled" success "Key sync enabled." printf '\n' # Immediately build the first archive sync_keys_update ;; # ── DISABLE ───────────────────────────────────────────────────────── disable) _ks=$(pref_get "keys_sync") if [ "$_ks" != "enabled" ]; then printf '\n Key sync is not enabled.\n\n'; return 0 fi _sync_path=$(pref_get "sync_path") _enc="$_sync_path/keys.enc" printf '\n' warn "Disabling key sync." printf '\n' printf " This will:\n" printf " 1. Verify the archive can be decrypted (integrity check)\n" printf " 2. Delete keys.enc from the sync folder\n" printf " 3. Disable key sync in preferences\n\n" if ! wt_yesno "Disable key sync and delete the encrypted archive?"; then printf ' Aborting.\n\n'; return 0 fi # Integrity check — decrypt to /dev/null to verify passphrase works if [ -f "$_enc" ]; then printf '\n' info "Verifying archive integrity before deletion ..." cli_prompt_secret "Key-sync passphrase (to verify)" _kpass="$_PROMPT_RESULT" printf '%s' "$_kpass" | openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ -pass stdin -in "$_enc" -out /dev/null 2>/dev/null && { unset _kpass success "Archive verified — passphrase correct." } || { unset _kpass warn "Could not decrypt archive — wrong passphrase?" printf " Archive NOT deleted. Fix the passphrase issue first.\n\n" return 1 } rm -f "$_enc" vlog "Deleted: $_enc" success "Encrypted archive deleted from sync folder." else warn "Archive file not found — skipping deletion." fi pref_set "keys_sync" "" pref_set "keys_sync_last_update" "" pref_set "keys_sync_count" "" success "Key sync disabled." printf '\n' ;; # ── UPDATE ────────────────────────────────────────────────────────── update) _ks=$(pref_get "keys_sync") [ "$_ks" = "enabled" ] || die "Key sync is not enabled. Run: termio sync keys enable" sync_keys_update ;; # ── RESTORE ───────────────────────────────────────────────────────── restore) _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] && die "Sync not configured. Run: termio sync init" _enc="$_sync_path/keys.enc" [ -f "$_enc" ] || die "No key archive found at: $_enc" command -v openssl >/dev/null 2>&1 || die "openssl not found." printf '\n' printf "${C_CYAN}${C_BOLD} Restore Keys from Archive${C_RESET}\n\n" printf " This will decrypt keys.enc and restore all private keys\n" printf " to ~/.ssh/ with correct permissions.\n\n" warn "Existing keys with the same name will be overwritten." printf '\n' if ! wt_yesno "Restore keys from encrypted archive?"; then printf ' Aborting.\n\n'; return 0 fi cli_prompt_secret "Key-sync passphrase" _kpass="$_PROMPT_RESULT" _tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t termio) printf '%s' "$_kpass" | openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ -pass stdin -in "$_enc" -out "$_tmpdir/keys.tar" 2>/dev/null || { unset _kpass rm -rf "$_tmpdir" die "Decryption failed — wrong passphrase or corrupted archive." } unset _kpass tar -xf "$_tmpdir/keys.tar" -C "$_tmpdir" 2>/dev/null # Show manifest if present if [ -f "$_tmpdir/MANIFEST" ]; then printf '\n' info "Archive manifest:" grep -v '^#' "$_tmpdir/MANIFEST" | while IFS='=' read -r _mk _mv; do printf " ${C_BOLD}%-10s${C_RESET} %s\n" "$_mk:" "$_mv" done printf '\n' fi mkdir -p "$SSH_DIR"; chmod 700 "$SSH_DIR" _restored=0 for _kf in "$_tmpdir"/id_ed25519_* "$_tmpdir"/id_rsa_* "$_tmpdir"/id_ecdsa_*; do [ -f "$_kf" ] || continue case "$_kf" in *.pub) continue ;; esac _name=$(basename "$_kf") cp "$_kf" "$SSH_DIR/$_name" chmod 600 "$SSH_DIR/$_name" [ -f "${_kf}.pub" ] && { cp "${_kf}.pub" "$SSH_DIR/${_name}.pub" chmod 644 "$SSH_DIR/${_name}.pub" } vlog "Restored: $_name" _restored=$(( _restored + 1 )) done rm -rf "$_tmpdir" success "Restored $_restored key(s) to ~/.ssh/" printf '\n' ;; *) die "Unknown keys subcommand: '$_sub' (use: status, enable, disable, update, restore)" ;; esac } # ── SYNC dispatcher ─────────────────────────────────────────────────────────── cmd_sync() { case "${1:-status}" in init) cmd_sync_init ;; detach) cmd_sync_detach ;; status) cmd_sync_status ;; keys) cmd_sync_keys "${2:-status}" ;; *) die "Unknown sync subcommand: '$1' (use: init, status, detach, keys)" ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # ── Snippets ──────────────────────────────────────────────────────────════════ # ═══════════════════════════════════════════════════════════════════════════════ # # Snippets are named, reusable commands run sequentially on one or more aliases. # Storage: ~/.config/termio/snippets # Also auto-exported to /snippets-export.txt when sync is active. SNIPPET_FILE="$CONF_DIR/snippets" _snip_init() { mkdir -p "$CONF_DIR"; touch "$SNIPPET_FILE"; } snip_list_names() { [ -f "$SNIPPET_FILE" ] || return 0 awk '/^\[snippet\]/{found=0} /^name=/{if(!found){print substr($0,6); found=1}}' \ "$SNIPPET_FILE" 2>/dev/null || true } # snip_get snip_get() { [ -f "$SNIPPET_FILE" ] || { printf ''; return; } awk -v tgt="$1" -v fld="$2" ' /^\[snippet\]/ { in_s=0; matched=0 } /^name=/ { if (substr($0,6)==tgt) matched=1 } matched && $0 ~ "^"fld"=" { print substr($0, length(fld)+2); exit } ' "$SNIPPET_FILE" } snip_exists() { snip_list_names | grep -qx "$1" 2>/dev/null; } snip_remove_block() { [ -f "$SNIPPET_FILE" ] || return 0 awk -v tgt="$1" ' /^\[snippet\]/ { buf=""; skip=0 } { buf = buf $0 "\n" } /^name=/ && substr($0,6)==tgt { skip=1 } /^$/ { if (!skip) printf "%s", buf; buf=""; skip=0 } END { if (!skip && buf!="") printf "%s", buf } ' "$SNIPPET_FILE" > "${SNIPPET_FILE}.tmp" \ && mv "${SNIPPET_FILE}.tmp" "$SNIPPET_FILE" vlog "snip_remove_block: $1" } # snip_write snip_write() { _snip_init { printf '\n[snippet]\n' printf 'name=%s\n' "$1" printf 'desc=%s\n' "$2" printf 'cmd=%s\n' "$3" printf 'use_sudo=%s\n' "$4" printf 'created=%s\n' "$(date '+%Y-%m-%d %H:%M')" printf '\n' } >> "$SNIPPET_FILE" vlog "snip_write: $1" } snip_auto_export() { _sp=$(pref_get "sync_path") [ -z "$_sp" ] && return 0 [ -d "$_sp" ] || { vlog "snip_auto_export: sync folder not reachable"; return 0; } [ -f "$SNIPPET_FILE" ] || return 0 cp "$SNIPPET_FILE" "$_sp/snippets-export.txt" 2>/dev/null \ || warn "Could not export snippets to sync folder." vlog "snip_auto_export: done" } # ── snip list ───────────────────────────────────────────────────────────────── cmd_snip_list() { _names=$(snip_list_names) if [ -z "$_names" ]; then printf '\n No snippets saved. Run: termio snip add\n\n'; return 0 fi printf '\n' printf "${C_CYAN}${C_BOLD} %-22s %-6s %s${C_RESET}\n" "NAME" "SUDO" "DESCRIPTION" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 \ 2>/dev/null; printf '\n' printf '%s\n' "$_names" | while IFS= read -r _n; do [ -z "$_n" ] && continue _d=$(snip_get "$_n" "desc") _s=$(snip_get "$_n" "use_sudo") if [ "$_s" = "yes" ]; then _sf_col="${C_YELLOW}"; _sf_val="yes" else _sf_col="${C_DIM}"; _sf_val="no"; fi printf " ${C_BOLD}%-22s${C_RESET} ${_sf_col}%-6s${C_RESET} %s\n" "$_n" "$_sf_val" "$_d" done printf '\n' } # ── snip info ───────────────────────────────────────────────────────────────── cmd_snip_info() { _name="$1" [ -z "$_name" ] && die "Usage: termio snip info " snip_exists "$_name" || die "Snippet \"$_name\" not found." _desc=$(snip_get "$_name" "desc") _cmd=$(snip_get "$_name" "cmd") _sudo=$(snip_get "$_name" "use_sudo") _cre=$(snip_get "$_name" "created") printf '\n' printf " ${C_CYAN}${C_BOLD}%-12s${C_RESET} %s\n" "Name:" "$_name" printf " ${C_CYAN}${C_BOLD}%-12s${C_RESET} %s\n" "Desc:" "$_desc" printf " ${C_CYAN}${C_BOLD}%-12s${C_RESET} %s\n" "sudo:" "${_sudo:-no}" printf " ${C_CYAN}${C_BOLD}%-12s${C_RESET} %s\n" "Created:" "$_cre" printf " ${C_CYAN}${C_BOLD}%-12s${C_RESET}\n" "Command:" printf " ${C_DIM}%s${C_RESET}\n\n" "$_cmd" } # ── snip add ────────────────────────────────────────────────────────────────── cmd_snip_add() { printf '\n' wt_input "Snippet name (e.g. docker-restart, update-system)" "" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Name cannot be empty."; wt_input "Snippet name" "" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } _sname=$(printf '%s' "$_PROMPT_RESULT" \ | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-') [ -z "$_sname" ] && die "Snippet name contains no valid characters." if snip_exists "$_sname"; then if wt_yesno "Snippet \"$_sname\" already exists. Overwrite?"; then snip_remove_block "$_sname" else printf ' Aborting.\n\n'; return 0 fi fi wt_input "Short description" "" _sdesc="$_PROMPT_RESULT" wt_input "Command to run on the remote host" "" while [ "$_PROMPT_CANCELLED" -eq 0 ] && [ -z "$_PROMPT_RESULT" ]; do wt_msg "Command cannot be empty."; wt_input "Command" "" done [ "$_PROMPT_CANCELLED" -eq 1 ] && { printf ' Cancelled.\n\n'; return 0; } _scmd="$_PROMPT_RESULT" _ssudo="no" if wt_yesno "Does this command use sudo on the remote?\n(You will be prompted for the remote sudo password at run time)"; then _ssudo="yes" fi snip_write "$_sname" "$_sdesc" "$_scmd" "$_ssudo" snip_auto_export success "Snippet \"$_sname\" saved." printf "\n Run with: ${C_BOLD}termio snip run %s${C_RESET}\n\n" "$_sname" } # ── snip edit ───────────────────────────────────────────────────────────────── cmd_snip_edit() { _name="$1" [ -z "$_name" ] && die "Usage: termio snip edit " snip_exists "$_name" || die "Snippet \"$_name\" not found." _cd=$(snip_get "$_name" "desc") _cc=$(snip_get "$_name" "cmd") _cs=$(snip_get "$_name" "use_sudo") info "Editing snippet: ${C_BOLD}$_name${C_RESET} — blank = keep current" printf '\n' wt_input "Description" "$_cd"; _nd="${_PROMPT_RESULT:-$_cd}" wt_input "Command" "$_cc"; _nc="${_PROMPT_RESULT:-$_cc}" _ns="no" wt_yesno "Uses sudo on remote? (current: ${_cs:-no})" && _ns="yes" || _ns="no" snip_remove_block "$_name" snip_write "$_name" "$_nd" "$_nc" "$_ns" snip_auto_export success "Snippet \"$_name\" updated.\n" } # ── snip remove ─────────────────────────────────────────────────────────────── cmd_snip_rm() { _name="$1" [ -z "$_name" ] && die "Usage: termio snip rm " snip_exists "$_name" || die "Snippet \"$_name\" not found." printf '\n' warn "About to remove snippet \"${C_BOLD}$_name${C_RESET}\"" printf '\n' if ! wt_yesno "Remove snippet \"$_name\"?"; then printf ' Aborting.\n\n'; return 0 fi snip_remove_block "$_name" snip_auto_export success "Snippet \"$_name\" removed.\n" } # ── snip run ────────────────────────────────────────────────────────────────── cmd_snip_run() { _name="${1:-}" [ -z "$_name" ] && die "Usage: termio snip run [--group ] [--parallel]" snip_exists "$_name" || die "Snippet \"$_name\" not found." shift _run_group=""; _run_parallel=0 while [ "$#" -gt 0 ]; do case "$1" in --group|-g) shift; _run_group="${1:-}" ;; --parallel|-p) _run_parallel=1 ;; esac shift done _rcmd=$(snip_get "$_name" "cmd") _rdesc=$(snip_get "$_name" "desc") _rsudo=$(snip_get "$_name" "use_sudo") _aliases=$(list_aliases) [ -z "$_aliases" ] && die "No SSH aliases configured. Add one with: termio add" if [ -n "$_run_group" ]; then _aliases=$(printf '%s\n' "$_aliases" | while IFS= read -r _a; do _g=$(get_comment "$_a" "Group") printf '%s' "$_g" | tr ',' '\n' | tr -d ' ' | grep -qx "$_run_group" \ && printf '%s\n' "$_a" done) [ -z "$_aliases" ] && die "No aliases found in group \"$_run_group\"" info "Running snippet on group: ${C_BOLD}$_run_group${C_RESET}" fi printf '\n' printf "${C_CYAN}${C_BOLD} Snippet: %s${C_RESET}\n" "$_name" [ -n "$_rdesc" ] && printf " %s\n" "$_rdesc" printf '\n' printf " ${C_BOLD}Command:${C_RESET} ${C_DIM}%s${C_RESET}\n\n" "$_rcmd" # ── Target selection ────────────────────────────────────────────────────── if [ "$HAS_WHIPTAIL" -eq 1 ]; then set -- while IFS= read -r _a; do [ -z "$_a" ] && continue _h=$(get_field "$_a" "HostName") set -- "$@" "$_a" "${_a} — ${_h:-unknown}" "OFF" done <<_EOAL $(printf '%s\n' "$_aliases") _EOAL _sel=$(whiptail \ --title "termio — Run Snippet" \ --backtitle "termio v${TERMIO_VERSION}" \ --checklist "Select target host(s) — SPACE to toggle, ENTER to confirm:" \ "$TH" "$TW" 10 \ "$@" \ 3>&1 1>&2 2>&3) || { printf ' Cancelled.\n\n'; return 0; } _targets=$(printf '%s\n' "$_sel" | tr -d '"' | tr ' ' '\n' | grep -v '^$' || true) else cmd_list printf " Enter alias names separated by spaces (or ${C_BOLD}all${C_RESET}):\n" cli_prompt "Target aliases" "" if [ "$_PROMPT_RESULT" = "all" ]; then _targets="$_aliases" else _targets=$(printf '%s' "$_PROMPT_RESULT" | tr ' ' '\n' | grep -v '^$' || true) fi fi [ -z "$_targets" ] && { printf ' No targets selected.\n\n'; return 0; } # ── sudo password ───────────────────────────────────────────────────────── _sudo_same="no" _sudo_pass_shared="" if [ "$_rsudo" = "yes" ]; then _tcount=$(printf '%s\n' "$_targets" | grep -c '.' 2>/dev/null || printf '1') if [ "$_tcount" -gt 1 ]; then printf '\n' info "This snippet uses sudo on the remote host." if wt_yesno "Use the same sudo password for all $_tcount hosts?"; then wt_password "Shared sudo password for all hosts" _sudo_pass_shared="$_PROMPT_RESULT" _sudo_same="yes" else printf " You will be prompted for each host's sudo password.\n\n" fi fi fi printf '\n' _total=$(printf '%s\n' "$_targets" | grep -c '.' 2>/dev/null || printf '0') if [ "$_run_parallel" -eq 1 ]; then # ── Parallel execution ──────────────────────────────────────────────── if [ "$_rsudo" = "yes" ]; then warn "Parallel mode skips sudo prompts — running without sudo." printf '\n' fi _par_tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t termio) info "Running ${C_BOLD}$_name${C_RESET} on ${_total} host(s) in parallel..." printf '\n' while IFS= read -r _target; do [ -z "$_target" ] && continue alias_exists "$_target" || { warn "\"$_target\" not found — skipping."; continue; } ( ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout=15 \ -o StrictHostKeyChecking=accept-new \ "$_target" "$_rcmd" > "$_par_tmpdir/$_target" 2>&1 printf '%d' "$?" > "$_par_tmpdir/${_target}.exit" ) & done <<_EOPAR $(printf '%s\n' "$_targets") _EOPAR wait while IFS= read -r _target; do [ -z "$_target" ] && continue alias_exists "$_target" || continue _th=$(get_field "$_target" "HostName") _pec=$(cat "$_par_tmpdir/${_target}.exit" 2>/dev/null || printf '1') if [ "$_pec" = "0" ]; then printf "${C_GREEN} ✔ ${C_BOLD}%s${C_RESET} (%s)\n" "$_target" "$_th" else printf "${C_RED} ✘ ${C_BOLD}%s${C_RESET} (%s) exit: %s\n" "$_target" "$_th" "$_pec" fi sed 's/^/ /' "$_par_tmpdir/$_target" 2>/dev/null || true printf '\n' done <<_EOPRD $(printf '%s\n' "$_targets") _EOPRD rm -rf "$_par_tmpdir" else # ── Sequential execution ────────────────────────────────────────────── while IFS= read -r _target; do [ -z "$_target" ] && continue if ! alias_exists "$_target"; then warn "\"$_target\" not found — skipping."; continue fi _th=$(get_field "$_target" "HostName") printf "${C_CYAN} ▶ ${C_BOLD}%s${C_RESET} (%s)\n" "$_target" "$_th" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 2>/dev/null; printf '\n' _exec_cmd="$_rcmd" _sudo_pass="" if [ "$_rsudo" = "yes" ]; then if [ "$_sudo_same" = "yes" ]; then _sudo_pass="$_sudo_pass_shared" else wt_password "sudo password for ${_target} (${_th})" _sudo_pass="$_PROMPT_RESULT" fi _inner=$(printf '%s' "$_rcmd" | sed 's/^[[:space:]]*sudo[[:space:]]*//') # Password piped via stdin — never embedded in the command string _exec_cmd="sudo -S sh -c '$(printf '%s' "$_inner" | sed "s/'/'\\\\''/g")'" fi set +e if [ "$_rsudo" = "yes" ]; then printf '%s\n' "$_sudo_pass" | \ ssh -F "$CONFIG_FILE" \ -o BatchMode=yes -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ "$_target" "$_exec_cmd" 2>&1 | sed 's/^/ /' else ssh -F "$CONFIG_FILE" \ -o BatchMode=yes -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ "$_target" "$_exec_cmd" 2>&1 | sed 's/^/ /' fi _sexit=$? set -e unset _sudo_pass _exec_cmd if [ "$_sexit" -eq 0 ]; then printf '\n'; success "Completed on $_target" else printf '\n'; warn "Failed on $_target (exit code $_sexit)" fi printf '\n' done <<_EOTGT $(printf '%s\n' "$_targets") _EOTGT fi unset _sudo_pass_shared printf "${C_LBLUE} ─────────────────────────────────────${C_RESET}\n" printf " Snippet ${C_BOLD}%s${C_RESET} complete (targets: %s)\n\n" "$_name" "$_total" } # ── Snippet whiptail picker (sets SELECTED_SNIPPET) ─────────────────────────── wt_pick_snippet() { _title="$1" _names=$(snip_list_names) if [ -z "$_names" ]; then wt_msg "No snippets saved yet."; SELECTED_SNIPPET=""; return 1 fi set -- while IFS= read -r _n; do [ -z "$_n" ] && continue _d=$(snip_get "$_n" "desc") set -- "$@" "$_n" "${_d:-no description}" done <<_EONS $(printf '%s\n' "$_names") _EONS SELECTED_SNIPPET=$(whiptail \ --title "termio — Snippets" \ --menu "$_title" "$TH" "$TW" 10 \ "$@" \ 3>&1 1>&2 2>&3) || { SELECTED_SNIPPET=""; return 1; } } # ── Snippet dispatcher ──────────────────────────────────────────────────────── cmd_snip() { _ssub="${1:-list}" shift 2>/dev/null || true case "$_ssub" in list|ls) cmd_snip_list ;; add) cmd_snip_add ;; info) cmd_snip_info "${1:-}" ;; edit) cmd_snip_edit "${1:-}" ;; rm|remove) cmd_snip_rm "${1:-}" ;; run) cmd_snip_run "$@" ;; *) die "Unknown snip subcommand: '$_ssub' (use: list, add, info, edit, rm, run)" ;; esac } # ── Whiptail display wrappers ───────────────────────────────────────────────── # Strip ANSI color codes from a string (POSIX-portable via actual ESC char) _strip_colors() { _sc_esc=$(printf '\033') printf '%s\n' "$1" | sed "s/${_sc_esc}\[[0-9;]*m//g" } # Scrollable msgbox — falls back to press-enter in CLI mode wt_scrollmsg() { _title="$1"; _body="$2" if [ "$HAS_WHIPTAIL" -eq 1 ]; then whiptail --title "$_title" --backtitle "termio v${TERMIO_VERSION}" \ --scrolltext --msgbox "$_body" "$TH" "$TW" \ 3>&1 1>&2 2>&3 || true else printf '\n%s\n\n Press Enter to continue...' "$_body" IFS= read -r _dummy fi } # Plain-text tunnel status table (no ANSI) — used by wt and by _wt_tunnel_menu _wt_tunnel_status_text() { _tnames=$(tunnel_list_names) if [ -z "$_tnames" ]; then printf 'No tunnels configured.\n\nAdd one:\n termio tunnel add ::\n' return 0 fi printf '%-18s %-9s %-20s %s\n' "NAME" "STATUS" "ALIAS" "FORWARD" printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 \ 61 62 2>/dev/null printf '\n' printf '%s\n' "$_tnames" | while IFS= read -r _tn; do [ -z "$_tn" ] && continue _ta=$(tunnel_get "$_tn" "alias") _tf=$(tunnel_get "$_tn" "forward") if tunnel_is_running "$_tn"; then printf '%-18s %-9s %-20s %s\n' "$_tn" "running" "$_ta" "$_tf" else printf '%-18s %-9s %-20s %s\n' "$_tn" "stopped" "$_ta" "$_tf" fi done } # Plain-text agent list (no ANSI) — used by _wt_agent_menu _wt_agent_list_text() { _loaded=$(ssh-add -l 2>/dev/null || true) _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf 'No aliases configured.\n'; return 0 fi printf '%-18s %-8s %s\n' "ALIAS" "LOADED" "KEY FILE" printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 2>/dev/null printf '\n' printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _key=$(get_field "$_a" "IdentityFile") [ -z "$_key" ] && continue _fp="" [ -f "$_key" ] && _fp=$(ssh-keygen -lf "$_key" 2>/dev/null | awk '{print $2}') if [ -n "$_fp" ] && printf '%s\n' "$_loaded" | grep -qF "$_fp"; then printf '%-18s %-8s %s\n' "$_a" "yes" "$(basename "$_key")" else printf '%-18s %-8s %s\n' "$_a" "no" "$(basename "$_key")" fi done } # Whiptail status dashboard wrapper wt_status() { _aliases=$(list_aliases) if [ -z "$_aliases" ]; then wt_msg "No aliases configured.\n\nAdd one with: termio add" return 0 fi _timeout=8 _total=$(printf '%s\n' "$_aliases" | grep -c . || true) _tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t termio) while IFS= read -r _a; do [ -z "$_a" ] && continue ( _ts=$(date +%s%3N 2>/dev/null || printf '%s000' "$(date +%s)") if ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout="$_timeout" \ -o StrictHostKeyChecking=accept-new \ "$_a" true 2>/dev/null; then _te=$(date +%s%3N 2>/dev/null || printf '%s000' "$(date +%s)") printf 'ok:%d' "$(( _te - _ts ))" > "$_tmpdir/$_a" else printf 'fail' > "$_tmpdir/$_a" fi ) & done <<_EOWST $(printf '%s\n' "$_aliases") _EOWST # Gauge progress bar while probes run { while true; do _done=$(ls "$_tmpdir" 2>/dev/null | wc -l | tr -d ' ') printf '%d\n' "$(( _done * 100 / _total ))" [ "$_done" -ge "$_total" ] && break sleep 0.3 done } | whiptail --title "termio — Status" \ --gauge "Testing $_total aliases in parallel (timeout: ${_timeout}s)..." 7 60 0 || true wait _txt=$( printf '%-18s %-22s %-16s %s\n' "ALIAS" "HOSTNAME" "STATUS" "LAST SEEN" printf '─%.0s' $(seq 1 70) 2>/dev/null || \ printf '──────────────────────────────────────────────────────────────────────' printf '\n' printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _h=$(get_field "$_a" "HostName") _last=$(pref_get "last_connect_$_a") _relts=$(_rel_time "$_last") _res=""; [ -f "$_tmpdir/$_a" ] && _res=$(cat "$_tmpdir/$_a") case "$_res" in ok:*) _ms=${_res#ok:}; _st=$(printf '✔ up %dms' "$_ms") ;; *) _st='✘ unreachable' ;; esac printf '%-18s %-22s %-16s %s\n' "$_a" "$_h" "$_st" "$_relts" done ) rm -rf "$_tmpdir" wt_scrollmsg "termio — Status Dashboard" "$_txt" } # Whiptail audit wrapper wt_audit() { _threshold=$(pref_get "audit_threshold") [ -z "$_threshold" ] && _threshold=90 if [ "$_threshold" = "off" ]; then wt_msg "Key rotation auditing is globally disabled.\n\nRe-enable via Preferences → Key rotation threshold." return 0 fi _aliases=$(list_aliases) if [ -z "$_aliases" ]; then wt_msg "No aliases configured.\n\nAdd one with: termio add" return 0 fi _txt=$( printf 'Key Age Audit (global threshold: %s days)\n' "$_threshold" printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 2>/dev/null printf '\n\n' printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _alias_thresh=$(pref_get "audit_threshold_${_a}") [ -z "$_alias_thresh" ] && _alias_thresh=$_threshold if [ "$_alias_thresh" = "off" ]; then printf ' — %-18s auditing disabled\n' "$_a" continue fi _thresh_note="" [ "$_alias_thresh" != "$_threshold" ] && _thresh_note=" [override: ${_alias_thresh}d]" _key=$(get_field "$_a" "IdentityFile") if [ -z "$_key" ] || [ ! -f "$_key" ]; then printf ' ⚠ %-18s key file not found%s\n' "$_a" "$_thresh_note" continue fi _kname=$(basename "$_key") if find "$SSH_DIR" -maxdepth 1 -name "$_kname" -mtime "+${_alias_thresh}" \ 2>/dev/null | grep -q .; then printf ' ⚠ %-18s key >%s days old%s\n' "$_a" "$_alias_thresh" "$_thresh_note" else printf ' ✔ %-18s key is current%s\n' "$_a" "$_thresh_note" fi done ) wt_scrollmsg "termio — Key Age Audit" "$_txt" } wt_show_info() { _alias="$1" _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" _key=$(get_field "$_alias" "IdentityFile") _jump=$(get_field "$_alias" "ProxyJump") _alive=$(get_field "$_alias" "ServerAliveInterval") _fwda=$(get_field "$_alias" "ForwardAgent") _lfwd=$(get_field "$_alias" "LocalForward") _note=$(get_comment "$_alias" "Note") _fp="" [ -f "$_key" ] && _fp=$(ssh-keygen -lf "$_key" 2>/dev/null | awk '{print $2}') _txt=$( printf '%-14s %s\n' "Alias:" "$_alias" [ -n "$_note" ] && printf '%-14s %s\n' "Note:" "$_note" printf '%-14s %s\n' "Hostname:" "$_host" printf '%-14s %s\n' "User:" "$_user" printf '%-14s %s\n' "Port:" "$_port" [ -n "$_jump" ] && printf '%-14s %s\n' "Jump host:" "$_jump" [ -n "$_alive" ] && printf '%-14s %s\n' "KeepAlive:" "${_alive}s" [ -n "$_fwda" ] && printf '%-14s %s\n' "FwdAgent:" "$_fwda" [ -n "$_lfwd" ] && printf '%-14s %s\n' "LocalFwd:" "$_lfwd" printf '%-14s %s\n' "Key file:" "$_key" if [ -n "$_fp" ]; then printf '%-14s %s\n' "Fingerprint:" "$_fp" else printf '%-14s %s\n' "Key file:" "(not found on disk)" fi printf '\n%-14s ssh %s\n' "Connect:" "$_alias" ) whiptail --title "termio — Info: $_alias" \ --scrolltext --msgbox "$_txt" "$TH" "$TW" } wt_test_alias() { _alias="$1" _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" whiptail --title "termio — Test" \ --infobox "Testing $_alias (${_user}@${_host}:${_port})..." 5 60 _start=$(date +%s 2>/dev/null || printf '0') if ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout=8 \ -o StrictHostKeyChecking=accept-new \ "$_alias" true 2>/dev/null; then _end=$(date +%s 2>/dev/null || printf '0') _elapsed=$(( _end - _start )) wt_msg "Connection successful\n\n Alias: $_alias\n Host: ${_user}@${_host}:${_port}\n Time: ${_elapsed}s" else _code=$? wt_msg "Connection FAILED (exit code $_code)\n\n Alias: $_alias\n Host: ${_user}@${_host}:${_port}\n\nPossible causes:\n - Host unreachable or firewall blocking port $_port\n - Key not accepted — try: termio rotate $_alias\n - Wrong details — check alias info" fi } wt_snip_list() { _names=$(snip_list_names) if [ -z "$_names" ]; then wt_msg "No snippets saved yet.\n\nUse Add to create one."; return 0 fi _txt=$( printf '%-22s %-5s %s\n' "NAME" "SUDO" "DESCRIPTION" printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 2>/dev/null; printf '\n' printf '%s\n' "$_names" | while IFS= read -r _n; do [ -z "$_n" ] && continue _d=$(snip_get "$_n" "desc") _s=$(snip_get "$_n" "use_sudo") _sf="no"; [ "$_s" = "yes" ] && _sf="yes" printf '%-22s %-5s %s\n' "$_n" "$_sf" "$_d" done ) whiptail --title "termio — Snippets" \ --scrolltext --msgbox "$_txt" "$TH" "$TW" } wt_show_snip_info() { _name="$1" _desc=$(snip_get "$_name" "desc") _cmd=$(snip_get "$_name" "cmd") _sudo=$(snip_get "$_name" "use_sudo") _cre=$(snip_get "$_name" "created") _txt=$( printf '%-12s %s\n' "Name:" "$_name" printf '%-12s %s\n' "Desc:" "$_desc" printf '%-12s %s\n' "sudo:" "${_sudo:-no}" printf '%-12s %s\n' "Created:" "$_cre" printf '\n%-12s\n' "Command:" printf ' %s\n' "$_cmd" ) whiptail --title "termio — Snippet: $_name" \ --scrolltext --msgbox "$_txt" "$TH" "$TW" } wt_sync_status() { _sync_path=$(pref_get "sync_path") _last=$(pref_get "sync_last_export") _ks=$(pref_get "keys_sync") _klast=$(pref_get "keys_sync_last_update") _kcount=$(pref_get "keys_sync_count") _txt=$( printf 'Sync Status\n' printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 2>/dev/null printf '\n' if [ -z "$_sync_path" ]; then printf 'Sync folder: not configured\n' printf 'Use "init" in the sync menu to set up.\n' else printf '%-16s %s\n' "Sync folder:" "$_sync_path" [ -d "$_sync_path" ] \ && printf '%-16s reachable\n' "Folder status:" \ || printf '%-16s NOT reachable\n' "Folder status:" _marker="$_sync_path/.termio-sync" if [ -f "$_marker" ]; then _init_date=$(awk -F'=' '/^init_date=/{print $2}' "$_marker") _init_host=$(awk -F'=' '/^init_host=/{print $2}' "$_marker") printf '%-16s %s (on %s)\n' "Initialised:" "$_init_date" "$_init_host" fi _export="$_sync_path/aliases-export.txt" if [ -f "$_export" ]; then _ecount=$(grep -c '^\[alias\]' "$_export" 2>/dev/null || printf '0') printf '%-16s %s aliases\n' "Aliases export:" "$_ecount" else printf '%-16s not found\n' "Aliases export:" fi printf '%-16s %s\n' "Last export:" "${_last:-never}" printf '\nKey sync:\n' if [ "$_ks" = "enabled" ]; then printf ' %-14s enabled\n' "Status:" printf ' %-14s %s\n' "Keys:" "${_kcount:-unknown}" printf ' %-14s %s\n' "Last update:" "${_klast:-never}" [ -f "$_sync_path/keys.enc" ] \ && printf ' %-14s present\n' "Archive:" \ || printf ' %-14s missing\n' "Archive:" else printf ' %-14s disabled\n' "Status:" fi printf '\nSymlinks:\n' _lp="$CONF_DIR/preferences" if [ -L "$_lp" ]; then printf ' preferences -> %s\n' "$(readlink "$_lp" 2>/dev/null || printf 'unknown')" elif [ -f "$_lp" ]; then printf ' preferences (local, not synced)\n' else printf ' preferences not found\n' fi fi ) whiptail --title "termio — Sync Status" \ --scrolltext --msgbox "$_txt" "$TH" "$TW" } wt_sync_keys_status() { _sync_path=$(pref_get "sync_path") _ks=$(pref_get "keys_sync") _last=$(pref_get "keys_sync_last_update") _kcount=$(pref_get "keys_sync_count") _txt=$( printf 'Key Sync Status\n' printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 2>/dev/null printf '\n' if [ "$_ks" = "enabled" ]; then printf '%-16s enabled\n' "Key sync:" [ -n "$_sync_path" ] && [ -d "$_sync_path" ] \ && printf '%-16s reachable (%s)\n' "Sync folder:" "$_sync_path" \ || printf '%-16s not reachable\n' "Sync folder:" _enc="$_sync_path/keys.enc" if [ -f "$_enc" ]; then _size=$(wc -c < "$_enc" 2>/dev/null | tr -d ' ') printf '%-16s present (%s bytes)\n' "Archive:" "$_size" else printf '%-16s not found\n' "Archive:" fi printf '%-16s %s\n' "Keys archived:" "${_kcount:-unknown}" printf '%-16s %s\n' "Last updated:" "${_last:-never}" printf '\nSecurity note:\n' printf ' Your passphrase is never stored.\n' printf ' You will be prompted each time the archive\n' printf ' is rebuilt or restored.\n' else printf '%-16s disabled\n' "Key sync:" printf '\nUse Enable in the key sync menu to opt in.\n' fi ) whiptail --title "termio — Key Sync Status" \ --scrolltext --msgbox "$_txt" "$TH" "$TW" } wt_sync_keys_enable() { _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] \ && { wt_msg "Sync not configured.\nRun sync init first."; return 1; } [ -d "$_sync_path" ] \ && true || { wt_msg "Sync folder not reachable:\n$_sync_path"; return 1; } command -v openssl >/dev/null 2>&1 \ || { wt_msg "openssl not found.\n\nInstall it:\n Debian/Mint: sudo apt install openssl\n Fedora: sudo dnf install openssl"; return 1; } _ks=$(pref_get "keys_sync") if [ "$_ks" = "enabled" ]; then wt_msg "Key sync is already enabled.\n\nUse Update to rebuild the archive."; return 0 fi whiptail --title "termio — Encrypted Key Sync" --scrolltext --msgbox \ "Encrypted Key Sync — Opt In This feature stores your private SSH keys in an AES-256 encrypted archive (keys.enc) inside your sync folder. What this means: - Your keys travel with your sync provider - Security depends on your passphrase strength AND your sync provider's security - The passphrase is NEVER stored anywhere by termio - You will be prompted each time keys are archived or restored - AES-256-CBC with PBKDF2 (600,000 iterations) Recommended if: - Your sync folder is on an encrypted volume or a self-hosted provider you control (e.g. Nextcloud) - You use a strong, unique passphrase - You understand the risks Press OK to continue to the confirmation prompt." "$TH" "$TW" if ! wt_yesno "Enable encrypted key sync?"; then return 0; fi pref_set "keys_sync" "enabled" wt_msg "Key sync enabled.\n\nBuilding the initial archive now..." sync_keys_update } # ── Whiptail snippet submenu ────────────────────────────────────────────────── _wt_snip_menu() { while true; do _sc=$(whiptail --title "termio — Snippets" \ --cancel-button "Back" \ --menu "Reusable remote commands:" "$TH" "$TW" 6 \ "list" " List all snippets" \ "add" " Add a new snippet" \ "run" " Run a snippet on host(s)" \ "info" " Show snippet details" \ "edit" " Edit a snippet" \ "rm" " Remove a snippet" \ 3>&1 1>&2 2>&3) || break case "$_sc" in list) wt_snip_list ;; add) cmd_snip_add ;; run) wt_pick_snippet "Select snippet to run:" && cmd_snip_run "$SELECTED_SNIPPET" ;; info) wt_pick_snippet "Select snippet for info:" && wt_show_snip_info "$SELECTED_SNIPPET" ;; edit) wt_pick_snippet "Select snippet to edit:" && cmd_snip_edit "$SELECTED_SNIPPET" ;; rm) wt_pick_snippet "Select snippet to remove:" && cmd_snip_rm "$SELECTED_SNIPPET" ;; "") break ;; esac done } # ── Command: CONNECT ────────────────────────────────────────────────────────── # Launches an SSH session directly. Clears the screen beforehand so the # connection gets a clean terminal, then redraws the banner on return. cmd_connect() { _use_agent=0; _use_sshpass=0; _use_profile=0; _alias="" while [ "$#" -gt 0 ]; do case "$1" in --agent) _use_agent=1 ;; --sshpass) _use_sshpass=1 ;; --profile|-P) _use_profile=1 ;; *) _alias="$1" ;; esac shift done [ -z "$_alias" ] && die "Usage: termio connect [--agent] [--sshpass] [--profile|-P]" alias_exists "$_alias" || die "Alias \"$_alias\" not found." # Apply per-alias preferences (flags override) [ "$(pref_get "auto_agent_$_alias")" = "yes" ] && _use_agent=1 _stored_pw=$(pref_get "sshpass_$_alias") [ -n "$_stored_pw" ] && [ "$_use_sshpass" -eq 0 ] && _use_sshpass=2 _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" printf '\n' info "Connecting to ${C_BOLD}$_alias${C_RESET} (${_user}@${_host}:${_port})" printf " Press ${C_BOLD}Ctrl+D${C_RESET} or type ${C_BOLD}exit${C_RESET} to return to termio.\n\n" pref_set "last_connect_${_alias}" "$(date '+%Y-%m-%d %H:%M')" # Auto-bootstrap: if enabled and alias not yet bootstrapped, prompt once if [ "$(pref_get 'auto_bootstrap' '0')" = "1" ]; then if [ -z "$(pref_get "bootstrap_$_alias" "")" ]; then printf " ${C_YELLOW}⚡${C_RESET} No snippet widget on %s — install now? [Y/n] " \ "$_alias" IFS= read -r _ab_ans case "${_ab_ans:-y}" in n|N) ;; *) _bootstrap_install "$_alias" ;; esac fi fi # Ephemeral profile mode: inject init files, connect with modified shell startup if [ "$_use_profile" -eq 1 ]; then _connect_with_profile "$_alias" return $? fi # Auto-add key to agent if requested if [ "$_use_agent" -eq 1 ]; then _akey=$(get_field "$_alias" "IdentityFile") if [ -n "$_akey" ] && [ -f "$_akey" ] && command -v ssh-add >/dev/null 2>&1; then ssh-add "$_akey" 2>/dev/null && vlog "Key auto-added to agent for $_alias" fi fi _conn_start=$(date +%s 2>/dev/null || printf '0') sleep 0.4 clear 2>/dev/null || printf '\033[2J\033[H' set +e if [ "$_use_sshpass" -eq 1 ]; then command -v sshpass >/dev/null 2>&1 || { warn "sshpass not installed."; set -e; return 1; } info "Password used for this session only — not retained after disconnect." printf 'SSH password for %s@%s: ' "$_user" "$_host" stty -echo 2>/dev/null; IFS= read -r _sp; stty echo 2>/dev/null; printf '\n' SSHPASS="$_sp" sshpass -e ssh -F "$CONFIG_FILE" "$_alias" _ssh_exit=$? unset _sp SSHPASS elif [ "$_use_sshpass" -eq 2 ]; then command -v sshpass >/dev/null 2>&1 || { warn "sshpass not installed."; set -e; return 1; } warn "Password auth via sshpass — consider key-based auth: termio rotate $_alias" _sp=$(_base64_decode "$_stored_pw") unset _stored_pw SSHPASS="$_sp" sshpass -e ssh -F "$CONFIG_FILE" "$_alias" _ssh_exit=$? unset _sp SSHPASS else ssh -F "$CONFIG_FILE" "$_alias" _ssh_exit=$? fi set -e clear 2>/dev/null || printf '\033[2J\033[H' _conn_end=$(date +%s 2>/dev/null || printf '0') _conn_dur=$(( _conn_end - _conn_start )) printf '%s|%s|%s|%s\n' \ "$(date '+%Y-%m-%d %H:%M')" "$_alias" "$_conn_dur" "$_ssh_exit" \ >> "$HISTORY_LOG" 2>/dev/null || true if [ "$_ssh_exit" -ne 0 ] && [ "$_ssh_exit" -ne 130 ]; then warn "SSH exited with code $_ssh_exit" fi unset _stored_pw _use_agent _use_sshpass } # ── RUN ─────────────────────────────────────────────────────────────────────── cmd_run() { _alias="$1"; shift _cmd="$*" [ -z "$_alias" ] && die "Usage: termio run " [ -z "$_cmd" ] && die "Usage: termio run " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") _port=$(get_field "$_alias" "Port"); [ -z "$_port" ] && _port="22" printf '\n' info "Running on ${C_BOLD}$_alias${C_RESET} (${_user}@${_host}:${_port})" printf " ${C_BOLD}Command:${C_RESET} ${C_DIM}%s${C_RESET}\n\n" "$_cmd" set +e ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ "$_alias" "$_cmd" _rc=$? set -e printf '\n' [ "$_rc" -eq 0 ] && success "Done." || warn "Command exited with code $_rc" printf '\n' } # ── COPY ────────────────────────────────────────────────────────────────────── cmd_copy() { _src="${1:-}"; _dst="${2:-}" { [ -z "$_src" ] || [ -z "$_dst" ]; } && \ die "Usage: termio copy :/remote/path /local/path\n termio copy /local/path :/remote/path" command -v sftp >/dev/null 2>&1 || die "sftp not found — install openssh-client" _alias="" case "$_src" in *:*) _alias="${_src%%:*}" ;; esac case "$_dst" in *:*) [ -z "$_alias" ] && _alias="${_dst%%:*}" ;; esac [ -z "$_alias" ] && die "One path must be in :/path format (e.g. myserver:/etc/hosts)" alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") printf '\n' info "Copying: ${C_BOLD}$_src${C_RESET} → ${C_BOLD}$_dst${C_RESET}" printf " (via sftp %s@%s using ~/.ssh/config)\n\n" "$_user" "$_host" set +e case "$_src" in *:*) # Download: remote → local sftp -F "$CONFIG_FILE" -r "$_src" "$_dst" _rc=$? ;; *) # Upload: local → remote (sftp batch mode) _remote_path="${_dst#*:}" _batch=$(mktemp 2>/dev/null || mktemp -t termio) printf 'put -r "%s" "%s"\n' "$_src" "$_remote_path" > "$_batch" sftp -F "$CONFIG_FILE" -b "$_batch" "$_alias" _rc=$? rm -f "$_batch" ;; esac set -e printf '\n' [ "$_rc" -eq 0 ] && success "Copy complete." || warn "sftp exited with code $_rc" printf '\n' unset _remote_path _batch } # Interactive copy wizard: pick alias → direction → paths cmd_copy_wizard() { # Step 1: pick alias if [ "$HAS_WHIPTAIL" -eq 1 ]; then wt_pick_alias "Select remote alias:" _cw_alias="$SELECTED_ALIAS" else cmd_list cli_prompt "Remote alias" "" _cw_alias="$_PROMPT_RESULT" fi [ -z "$_cw_alias" ] && return 0 alias_exists "$_cw_alias" || { warn "Alias \"$_cw_alias\" not found."; return 1; } # Step 2: direction if [ "$HAS_WHIPTAIL" -eq 1 ]; then _cw_dir=$(whiptail --title "termio — Copy" \ --menu "Direction for ${_cw_alias}:" "$TH" "$TW" 2 \ "download" " ↓ Copy from remote to local" \ "upload" " ↑ Copy from local to remote" \ 3>&1 1>&2 2>&3) || return 0 else printf '\n' printf " ${C_BOLD}1)${C_RESET} Download — copy FROM remote TO local\n" printf " ${C_BOLD}2)${C_RESET} Upload — copy FROM local TO remote\n\n" cli_prompt "Direction (1/2)" "1" case "$_PROMPT_RESULT" in 1|download|d) _cw_dir="download" ;; 2|upload|u) _cw_dir="upload" ;; *) _cw_dir="download" ;; esac fi # Remember last remote path used for this alias (as a suggestion) _cw_last_remote=$(pref_get "copy_last_remote_${_cw_alias}") # Step 3: paths based on direction case "$_cw_dir" in download) _cw_remote_default="${_cw_last_remote:-~/}" wt_input "Remote path on ${_cw_alias}" "$_cw_remote_default" _cw_remote="$_PROMPT_RESULT" [ -z "$_cw_remote" ] && return 0 wt_input "Local destination" "$HOME/" _cw_local="$_PROMPT_RESULT" [ -z "$_cw_local" ] && return 0 pref_set "copy_last_remote_${_cw_alias}" "$_cw_remote" cmd_copy "${_cw_alias}:${_cw_remote}" "$_cw_local" ;; upload) wt_input "Local file or directory to upload" "" _cw_local="$_PROMPT_RESULT" [ -z "$_cw_local" ] && return 0 _cw_remote_default="${_cw_last_remote:-~/}" wt_input "Remote destination on ${_cw_alias}" "$_cw_remote_default" _cw_remote="$_PROMPT_RESULT" [ -z "$_cw_remote" ] && return 0 pref_set "copy_last_remote_${_cw_alias}" "$_cw_remote" cmd_copy "$_cw_local" "${_cw_alias}:${_cw_remote}" ;; esac } # ── OPEN (SFTP) ─────────────────────────────────────────────────────────────── cmd_open() { _alias="${1:-}" [ -z "$_alias" ] && die "Usage: termio open " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _host=$(get_field "$_alias" "HostName") _user=$(get_field "$_alias" "User") info "Opening SFTP session to ${C_BOLD}$_alias${C_RESET} (${_user}@${_host})" printf " Type ${C_BOLD}bye${C_RESET} to close the session.\n\n" sleep 0.2 sftp -F "$CONFIG_FILE" "$_alias" } # ── TAG / UNTAG ─────────────────────────────────────────────────────────────── cmd_tag() { _alias="${1:-}"; _grp="${2:-}" [ -z "$_alias" ] || [ -z "$_grp" ] && die "Usage: termio tag " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _cur_grp=$(get_comment "$_alias" "Group") if [ -n "$_cur_grp" ]; then printf '%s' "$_cur_grp" | tr ',' '\n' | grep -qx "$_grp" && { info "\"$_alias\" is already in group \"$_grp\"." return 0 } _new_grp="${_cur_grp},${_grp}" else _new_grp="$_grp" fi _ch=$(get_field "$_alias" "HostName"); _cu=$(get_field "$_alias" "User") _cp=$(get_field "$_alias" "Port"); [ -z "$_cp" ] && _cp="22" _ck=$(get_field "$_alias" "IdentityFile"); _cj=$(get_field "$_alias" "ProxyJump") _cn=$(get_comment "$_alias" "Note"); _ca=$(get_field "$_alias" "ServerAliveInterval") _cf=$(get_field "$_alias" "ForwardAgent"); _cl=$(get_field "$_alias" "LocalForward") remove_config_block "$_alias" write_config_block "$_alias" "$_ch" "$_cu" "$_cp" "$_ck" "$_cj" "$_cn" "$_ca" "$_cf" "$_cl" "$_new_grp" sync_auto_export success "\"$_alias\" tagged with group \"$_grp\"." } cmd_untag() { _alias="${1:-}"; _grp="${2:-}" [ -z "$_alias" ] || [ -z "$_grp" ] && die "Usage: termio untag " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _cur_grp=$(get_comment "$_alias" "Group") if [ -z "$_cur_grp" ]; then info "\"$_alias\" has no groups."; return 0 fi printf '%s' "$_cur_grp" | tr ',' '\n' | grep -qx "$_grp" || { info "\"$_alias\" is not in group \"$_grp\"."; return 0 } _new_grp=$(printf '%s' "$_cur_grp" | tr ',' '\n' | grep -vx "$_grp" | tr '\n' ',' | sed 's/,$//' || true) _ch=$(get_field "$_alias" "HostName"); _cu=$(get_field "$_alias" "User") _cp=$(get_field "$_alias" "Port"); [ -z "$_cp" ] && _cp="22" _ck=$(get_field "$_alias" "IdentityFile"); _cj=$(get_field "$_alias" "ProxyJump") _cn=$(get_comment "$_alias" "Note"); _ca=$(get_field "$_alias" "ServerAliveInterval") _cf=$(get_field "$_alias" "ForwardAgent"); _cl=$(get_field "$_alias" "LocalForward") remove_config_block "$_alias" write_config_block "$_alias" "$_ch" "$_cu" "$_cp" "$_ck" "$_cj" "$_cn" "$_ca" "$_cf" "$_cl" "$_new_grp" sync_auto_export success "\"$_alias\" removed from group \"$_grp\"." } # ── STATUS DASHBOARD ────────────────────────────────────────────────────────── cmd_status() { _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf '\n No aliases configured. Add one with: termio add\n\n'; return 0 fi _timeout=8 _tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t termio) _total=$(printf '%s\n' "$_aliases" | grep -c . || true) # Launch parallel SSH probes with ms latency measurement while IFS= read -r _a; do [ -z "$_a" ] && continue ( _ts=$(date +%s%3N 2>/dev/null || printf '%s000' "$(date +%s)") if ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout="$_timeout" \ -o StrictHostKeyChecking=accept-new \ "$_a" true 2>/dev/null; then _te=$(date +%s%3N 2>/dev/null || printf '%s000' "$(date +%s)") printf 'ok:%d' "$(( _te - _ts ))" > "$_tmpdir/$_a" else printf 'fail' > "$_tmpdir/$_a" fi ) & done <<_EOST $(printf '%s\n' "$_aliases") _EOST # Spinner while probes run printf '\n' _spin_i=0 while true; do _done=$(ls "$_tmpdir" 2>/dev/null | wc -l) [ "$_done" -ge "$_total" ] && break case $(( _spin_i % 10 )) in 0) _sc='⠋' ;; 1) _sc='⠙' ;; 2) _sc='⠹' ;; 3) _sc='⠸' ;; 4) _sc='⠼' ;; 5) _sc='⠴' ;; 6) _sc='⠦' ;; 7) _sc='⠧' ;; 8) _sc='⠇' ;; 9) _sc='⠏' ;; esac printf '\r %s Probing %d / %d aliases...' "$_sc" "$_done" "$_total" sleep 0.1 _spin_i=$(( _spin_i + 1 )) done printf '\r%60s\r' '' wait # Boxed results table — col widths: alias(18) host(22) status(15) last(13) _SW1=20; _SW2=24; _SW3=17; _SW4=15 _ls_hline " ┌" "┬" "┐" "$_SW1" "$_SW2" "$_SW3" "$_SW4" printf " │ ${C_BOLD}${C_LBLUE}%-18s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-22s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-15s${C_RESET} │ ${C_BOLD}${C_LBLUE}%-13s${C_RESET} │\n" \ "ALIAS" "HOSTNAME" "STATUS" "LAST SEEN" _ls_hline " ├" "┼" "┤" "$_SW1" "$_SW2" "$_SW3" "$_SW4" while IFS= read -r _a; do [ -z "$_a" ] && continue _h=$(get_field "$_a" "HostName") _last=$(pref_get "last_connect_$_a") _relts=$(_rel_time "$_last") _res="" [ -f "$_tmpdir/$_a" ] && _res=$(cat "$_tmpdir/$_a") case "$_res" in ok:*) _ms=${_res#ok:} if [ "$_ms" -lt 50 ]; then _lat_col="$C_GREEN" elif [ "$_ms" -lt 200 ]; then _lat_col="$C_YELLOW" else _lat_col="$C_RED"; fi _ms_str="${_ms}ms" # visible: "✔ up " = 6 cols + pure-ASCII ms string _pad=$(( 15 - 6 - ${#_ms_str} )) [ "$_pad" -lt 0 ] && _pad=0 _status_str="${C_GREEN}✔ up${C_RESET} ${_lat_col}${_ms_str}${C_RESET}" ;; *) # visible: "✘ unreachable" = 13 cols → 2 spaces padding _pad=2 _status_str="${C_RED}✘ unreachable${C_RESET}" ;; esac _status_cell=$(printf '%s%*s' "$_status_str" "$_pad" '') printf " │ ${C_BOLD}%-18.18s${C_RESET} │ %-22.22s │ %s │ ${C_DIM}%-13.13s${C_RESET} │\n" \ "$_a" "$_h" "$_status_cell" "$_relts" done <<_EOST2 $(printf '%s\n' "$_aliases") _EOST2 _ls_hline " └" "┴" "┘" "$_SW1" "$_SW2" "$_SW3" "$_SW4" rm -rf "$_tmpdir" printf '\n' } # ── AUDIT ───────────────────────────────────────────────────────────────────── cmd_audit() { _threshold=$(pref_get "audit_threshold") [ -z "$_threshold" ] && _threshold=90 if [ "$_threshold" = "off" ]; then info "Key rotation auditing is globally disabled." printf " Re-enable: ${C_BOLD}termio prefer audit_threshold 90${C_RESET}\n\n" return 0 fi _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf '\n No aliases configured.\n\n'; return 0 fi printf '\n' printf "${C_CYAN}${C_BOLD} Key Age Audit${C_RESET} (global threshold: %s days)\n" "$_threshold" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 2>/dev/null; printf '\n\n' _flagged=0; _ok=0; _skipped=0 while IFS= read -r _a; do [ -z "$_a" ] && continue _alias_thresh=$(pref_get "audit_threshold_${_a}") [ -z "$_alias_thresh" ] && _alias_thresh=$_threshold if [ "$_alias_thresh" = "off" ]; then printf " ${C_DIM}— %-18s auditing disabled${C_RESET}\n" "$_a" _skipped=$(( _skipped + 1 )) continue fi _thresh_note="" [ "$_alias_thresh" != "$_threshold" ] && _thresh_note=" ${C_DIM}[override: ${_alias_thresh}d]${C_RESET}" _key=$(get_field "$_a" "IdentityFile") if [ -z "$_key" ] || [ ! -f "$_key" ]; then printf " ${C_YELLOW}⚠${C_RESET} ${C_BOLD}%-18s${C_RESET} key file not found%b\n" "$_a" "$_thresh_note" _flagged=$(( _flagged + 1 )) continue fi _kname=$(basename "$_key") if find "$SSH_DIR" -maxdepth 1 -name "$_kname" -mtime "+${_alias_thresh}" \ 2>/dev/null | grep -q .; then printf " ${C_YELLOW}⚠${C_RESET} ${C_BOLD}%-18s${C_RESET} key >%s days old — " \ "$_a" "$_alias_thresh" printf "rotate: ${C_DIM}termio rotate %s${C_RESET}%b\n" "$_a" "$_thresh_note" _flagged=$(( _flagged + 1 )) else printf " ${C_GREEN}✔${C_RESET} ${C_BOLD}%-18s${C_RESET} key is current%b\n" "$_a" "$_thresh_note" _ok=$(( _ok + 1 )) fi done <<_EOAUDIT $(printf '%s\n' "$_aliases") _EOAUDIT printf '\n' if [ "$_flagged" -eq 0 ]; then success "All ${_ok} key(s) within the rotation window.$([ "$_skipped" -gt 0 ] && printf ' (%d skipped)' "$_skipped")" else warn "${_flagged} key(s) flagged for rotation (${_ok} current$([ "$_skipped" -gt 0 ] && printf ', %d skipped' "$_skipped"))" printf " Set global threshold: ${C_BOLD}termio prefer audit_threshold ${C_RESET}\n" printf " Set per-host: ${C_BOLD}termio prefer audit_threshold ${C_RESET}\n" fi printf '\n' } # ── LOG ─────────────────────────────────────────────────────────────────────── cmd_log() { _filter="${1:-}" if [ ! -f "$HISTORY_LOG" ] || [ ! -s "$HISTORY_LOG" ]; then printf '\n No connection history yet.\n\n'; return 0 fi _CW_DATE=19; _CW_ALIAS=16; _CW_DUR=10; _CW_EXIT=6 _log_data=$([ -n "$_filter" ] \ && grep "^[^|]*|${_filter}|" "$HISTORY_LOG" 2>/dev/null \ || cat "$HISTORY_LOG") if [ -z "$_log_data" ]; then printf '\n No history for alias "%s".\n\n' "$_filter"; return 0 fi printf '\n' _ls_hline " ┌" "┬" "┐" "$_CW_DATE" "$_CW_ALIAS" "$_CW_DUR" "$_CW_EXIT" printf " ${C_LBLUE}│${C_RESET} ${C_BOLD}%-*s${C_RESET} ${C_LBLUE}│${C_RESET} ${C_BOLD}%-*s${C_RESET} ${C_LBLUE}│${C_RESET} ${C_BOLD}%-*s${C_RESET} ${C_LBLUE}│${C_RESET} ${C_BOLD}%-*s${C_RESET} ${C_LBLUE}│${C_RESET}\n" \ "$_CW_DATE" "DATE" "$_CW_ALIAS" "ALIAS" "$_CW_DUR" "DURATION" "$_CW_EXIT" "EXIT" _ls_hline " ├" "┼" "┤" "$_CW_DATE" "$_CW_ALIAS" "$_CW_DUR" "$_CW_EXIT" printf '%s\n' "$_log_data" | tail -50 | while IFS='|' read -r _lts _la _ld _lx; do [ -z "$_la" ] && continue if [ "$_ld" -ge 3600 ] 2>/dev/null; then _ldstr=$(printf '%dh %dm' "$(( _ld / 3600 ))" "$(( (_ld % 3600) / 60 ))") elif [ "$_ld" -ge 60 ] 2>/dev/null; then _ldstr=$(printf '%dm %ds' "$(( _ld / 60 ))" "$(( _ld % 60 ))") else _ldstr=$(printf '%ds' "$_ld") fi [ "$_lx" = "0" ] && _xc="${C_GREEN}" || _xc="${C_YELLOW}" printf " ${C_LBLUE}│${C_RESET} %-*s ${C_LBLUE}│${C_RESET} %-*s ${C_LBLUE}│${C_RESET} %-*s ${C_LBLUE}│${C_RESET} ${_xc}%-*s${C_RESET} ${C_LBLUE}│${C_RESET}\n" \ "$_CW_DATE" "$_lts" "$_CW_ALIAS" "$_la" "$_CW_DUR" "$_ldstr" "$_CW_EXIT" "$_lx" done _ls_hline " └" "┴" "┘" "$_CW_DATE" "$_CW_ALIAS" "$_CW_DUR" "$_CW_EXIT" printf '\n' } # ── WAKE-ON-LAN ─────────────────────────────────────────────────────────────── cmd_wake() { _alias="${1:-}" [ -z "$_alias" ] && die "Usage: termio wake " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _mac=$(pref_get "wol_mac_${_alias}") if [ -z "$_mac" ]; then die "No MAC address stored for \"$_alias\".\nSet one with: termio prefer $_alias wol " fi _host=$(get_field "$_alias" "HostName") info "Sending Wake-on-LAN magic packet to ${C_BOLD}$_alias${C_RESET} (MAC: ${_mac}, host: ${_host})" if command -v wakeonlan >/dev/null 2>&1; then wakeonlan "$_mac" >/dev/null 2>&1 elif command -v python3 >/dev/null 2>&1; then python3 - "$_mac" <<'PYEOF' import socket, sys, re mac = re.sub(r'[^0-9a-fA-F]', '', sys.argv[1]) if len(mac) != 12: raise SystemExit("Invalid MAC address") payload = bytes.fromhex('ff' * 6 + mac * 16) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.sendto(payload, ('255.255.255.255', 9)) PYEOF else die "wakeonlan or python3 required to send WOL packets.\n Install: sudo apt install wakeonlan" fi success "Magic packet sent. Host may take 10-30 seconds to boot." } # ── DIFF ────────────────────────────────────────────────────────────────────── cmd_diff() { _sync_path=$(pref_get "sync_path") [ -z "$_sync_path" ] && die "Sync is not configured. Run: termio sync init" [ -d "$_sync_path" ] || die "Sync folder not reachable: $_sync_path" _remote_export="$_sync_path/aliases-export.txt" [ -f "$_remote_export" ] || die "No sync export found at: $_remote_export" _local_tmp=$(mktemp 2>/dev/null || mktemp -t termio) cmd_export "$_local_tmp" >/dev/null 2>&1 printf '\n' info "Diff: local aliases vs sync export" printf " ${C_DIM}− removed from local / + added locally${C_RESET}\n\n" if diff "$_remote_export" "$_local_tmp" >/dev/null 2>&1; then success "Local aliases match the sync export — no differences." else diff "$_remote_export" "$_local_tmp" | grep '^[+\-]' | grep -v '^---\|^+++' \ | while IFS= read -r _dl; do case "$_dl" in +*) printf " ${C_GREEN}%s${C_RESET}\n" "$_dl" ;; -*) printf " ${C_YELLOW}%s${C_RESET}\n" "$_dl" ;; esac done printf '\n' fi rm -f "$_local_tmp" } # ═══════════════════════════════════════════════════════════════════════════════ # ── Tunnel management ───────────────────────────────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════════ TUNNEL_FILE="$CONF_DIR/tunnels" TUNNEL_SOCK_DIR="$CONF_DIR/tunnel-socks" tunnel_list_names() { [ -f "$TUNNEL_FILE" ] || return 0 awk '/^\[tunnel\]/{found=0} /^name=/{if(!found){print substr($0,6); found=1}}' \ "$TUNNEL_FILE" 2>/dev/null || true } tunnel_get() { [ -f "$TUNNEL_FILE" ] || { printf ''; return; } awk -v tgt="$1" -v fld="$2" ' /^\[tunnel\]/ { matched=0 } /^name=/ { if (substr($0,6)==tgt) matched=1 } matched && $0 ~ "^"fld"=" { print substr($0, length(fld)+2); exit } ' "$TUNNEL_FILE" } tunnel_exists() { tunnel_list_names | grep -qx "$1" 2>/dev/null; } tunnel_remove_block() { [ -f "$TUNNEL_FILE" ] || return 0 awk -v tgt="$1" ' /^\[tunnel\]/ { buf=""; skip=0 } { buf = buf $0 "\n" } /^name=/ && substr($0,6)==tgt { skip=1 } /^$/ { if (!skip) printf "%s", buf; buf=""; skip=0 } END { if (!skip && buf!="") printf "%s", buf } ' "$TUNNEL_FILE" > "${TUNNEL_FILE}.tmp" \ && mv "${TUNNEL_FILE}.tmp" "$TUNNEL_FILE" } tunnel_write() { mkdir -p "$CONF_DIR"; touch "$TUNNEL_FILE" { printf '\n[tunnel]\n' printf 'name=%s\n' "$1" printf 'alias=%s\n' "$2" printf 'forward=%s\n' "$3" printf 'type=%s\n' "${4:-local}" printf 'created=%s\n' "$(date '+%Y-%m-%d %H:%M')" printf '\n' } >> "$TUNNEL_FILE" } tunnel_is_running() { _sock="$TUNNEL_SOCK_DIR/$1.sock" [ -S "$_sock" ] || return 1 ssh -O check -S "$_sock" placeholder 2>/dev/null } cmd_tunnel() { _sub="${1:-status}" case "$_sub" in add) _tname="${2:-}"; _talias="${3:-}"; _tfwd="${4:-}"; _ttype="local" shift 4 2>/dev/null || true while [ "$#" -gt 0 ]; do case "$1" in --type|-t) shift; _ttype="${1:-local}" ;; esac shift done { [ -z "$_tname" ] || [ -z "$_talias" ] || [ -z "$_tfwd" ]; } && \ die "Usage: termio tunnel add [--type local|remote|socks]" alias_exists "$_talias" || die "Alias \"$_talias\" not found." case "$_ttype" in local|remote) case "$_tfwd" in *:*:*) : ;; *) die "Forward spec must be ::" ;; esac ;; socks) case "$_tfwd" in *[!0-9]*) die "SOCKS forward spec must be a port number" ;; esac ;; *) die "Tunnel type must be: local, remote, or socks" ;; esac if tunnel_exists "$_tname"; then wt_yesno "Tunnel \"$_tname\" already exists. Overwrite?" || \ { printf ' Aborting.\n\n'; return 0; } if tunnel_is_running "$_tname"; then _ta_old=$(tunnel_get "$_tname" "alias") _sock="$TUNNEL_SOCK_DIR/$_tname.sock" ssh -O exit -S "$_sock" "$_ta_old" 2>/dev/null || true rm -f "$_sock" fi tunnel_remove_block "$_tname" fi tunnel_write "$_tname" "$_talias" "$_tfwd" "$_ttype" success "Tunnel \"$_tname\" saved (type: ${_ttype})." printf " Start with: ${C_BOLD}termio tunnel start %s${C_RESET}\n\n" "$_tname" ;; start) _tname="${2:-}"; [ -z "$_tname" ] && die "Usage: termio tunnel start " tunnel_exists "$_tname" || die "Tunnel \"$_tname\" not found." if tunnel_is_running "$_tname"; then info "Tunnel \"$_tname\" is already running."; return 0 fi _ta=$(tunnel_get "$_tname" "alias") _tf=$(tunnel_get "$_tname" "forward") _tt=$(tunnel_get "$_tname" "type"); [ -z "$_tt" ] && _tt="local" alias_exists "$_ta" || die "Alias \"$_ta\" no longer exists." _th=$(get_field "$_ta" "HostName") case "$_tt" in remote) _ssh_fwd_flag="-R" ;; socks) _ssh_fwd_flag="-D" ;; *) _ssh_fwd_flag="-L" ;; esac _sock="$TUNNEL_SOCK_DIR/$_tname.sock" mkdir -p "$TUNNEL_SOCK_DIR" # Remove stale socket left by a crashed SSH process if [ -S "$_sock" ] && ! ssh -O check -S "$_sock" placeholder 2>/dev/null; then vlog "Removing stale control socket: $_sock" rm -f "$_sock" fi printf '\n' info "Starting tunnel ${C_BOLD}$_tname${C_RESET} (${_tt}): ${_ssh_fwd_flag} $_tf via ${_ta} (${_th})" set +e ssh -F "$CONFIG_FILE" -N -f \ -M -S "$_sock" \ -o BatchMode=yes \ -o ExitOnForwardFailure=yes \ -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ "$_ssh_fwd_flag" "$_tf" "$_ta" _rc=$? set -e if [ "$_rc" -eq 0 ]; then success "Tunnel started." printf " Stop with: ${C_BOLD}termio tunnel stop %s${C_RESET}\n\n" "$_tname" else rm -f "$_sock" warn "Tunnel failed to start — check alias and forward spec." printf '\n' fi ;; stop) _tname="${2:-}"; [ -z "$_tname" ] && die "Usage: termio tunnel stop " tunnel_exists "$_tname" || die "Tunnel \"$_tname\" not found." if ! tunnel_is_running "$_tname"; then info "Tunnel \"$_tname\" is not running."; return 0 fi _ta=$(tunnel_get "$_tname" "alias") _sock="$TUNNEL_SOCK_DIR/$_tname.sock" printf '\n' ssh -O exit -S "$_sock" "$_ta" 2>/dev/null || true rm -f "$_sock" success "Tunnel \"$_tname\" stopped." printf '\n' ;; rm|remove) _tname="${2:-}"; [ -z "$_tname" ] && die "Usage: termio tunnel rm " tunnel_exists "$_tname" || die "Tunnel \"$_tname\" not found." printf '\n' warn "About to remove tunnel \"${C_BOLD}$_tname${C_RESET}\"" printf '\n' wt_yesno "Remove tunnel \"$_tname\"?" || { printf ' Aborting.\n\n'; return 0; } if tunnel_is_running "$_tname"; then _ta=$(tunnel_get "$_tname" "alias") _sock="$TUNNEL_SOCK_DIR/$_tname.sock" ssh -O exit -S "$_sock" "$_ta" 2>/dev/null && info "Stopped running tunnel." || true rm -f "$_sock" fi tunnel_remove_block "$_tname" success "Tunnel \"$_tname\" removed.\n" ;; ls|list|status) _tnames=$(tunnel_list_names) if [ -z "$_tnames" ]; then printf '\n No tunnels configured.\n' printf " Add one: ${C_BOLD}termio tunnel add [--type local|remote|socks]${C_RESET}\n\n" return 0 fi printf '\n' printf "${C_LBLUE}${C_BOLD} %-18s %-9s %-7s %-16s %s${C_RESET}\n" \ "NAME" "STATUS" "TYPE" "ALIAS" "FORWARD" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 \ 61 62 63 64 65 66 67 68 2>/dev/null; printf '\n' printf '%s\n' "$_tnames" | while IFS= read -r _tn; do [ -z "$_tn" ] && continue _ta=$(tunnel_get "$_tn" "alias") _tf=$(tunnel_get "$_tn" "forward") _tt=$(tunnel_get "$_tn" "type"); [ -z "$_tt" ] && _tt="local" if tunnel_is_running "$_tn"; then printf " ${C_BOLD}%-18s${C_RESET} ${C_GREEN}running${C_RESET} %-7s %-16s %s\n" \ "$_tn" "$_tt" "$_ta" "$_tf" else printf " ${C_BOLD}%-18s${C_RESET} ${C_DIM}stopped${C_RESET} %-7s %-16s %s\n" \ "$_tn" "$_tt" "$_ta" "$_tf" fi done printf '\n' ;; *) die "Unknown tunnel subcommand: '$_sub' (use: add, start, stop, status, ls, rm)" ;; esac } # ═══════════════════════════════════════════════════════════════════════════════ # ── SSH agent integration ───────────────────────────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════════ cmd_agent() { _sub="${1:-list}"; _alias="${2:-}" command -v ssh-add >/dev/null 2>&1 || die "ssh-add not found — install openssh-client" case "$_sub" in list|ls) _loaded=$(ssh-add -l 2>/dev/null || true) _aliases=$(list_aliases) if [ -z "$_aliases" ]; then printf '\n No aliases configured.\n\n'; return 0 fi printf '\n' printf "${C_CYAN}${C_BOLD} %-18s %-8s %s${C_RESET}\n" "ALIAS" "LOADED" "KEY FILE" printf ' '; printf '%.0s─' \ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \ 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 \ 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 2>/dev/null; printf '\n' printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _key=$(get_field "$_a" "IdentityFile") [ -z "$_key" ] && continue _fp="" [ -f "$_key" ] && _fp=$(ssh-keygen -lf "$_key" 2>/dev/null | awk '{print $2}') if [ -n "$_fp" ] && printf '%s\n' "$_loaded" | grep -qF "$_fp"; then printf " ${C_BOLD}%-18s${C_RESET} ${C_GREEN}yes${C_RESET} %s\n" \ "$_a" "$(basename "$_key")" else printf " ${C_BOLD}%-18s${C_RESET} ${C_DIM}no${C_RESET} %s\n" \ "$_a" "$(basename "$_key")" fi done printf '\n' ;; add) if [ -z "$_alias" ]; then _aliases=$(list_aliases) printf '\n' printf '%s\n' "$_aliases" | while IFS= read -r _a; do [ -z "$_a" ] && continue _key=$(get_field "$_a" "IdentityFile") { [ -z "$_key" ] || [ ! -f "$_key" ]; } && continue ssh-add "$_key" 2>/dev/null \ && success "Added key for: $_a" \ || warn "Could not add key for: $_a" done printf '\n' else alias_exists "$_alias" || die "Alias \"$_alias\" not found." _key=$(get_field "$_alias" "IdentityFile") [ -z "$_key" ] && die "No key configured for alias \"$_alias\"" [ -f "$_key" ] || die "Key file not found: $_key" printf '\n' ssh-add "$_key" && success "Key added to agent for $_alias." printf '\n' fi ;; rm|remove) [ -z "$_alias" ] && die "Usage: termio agent rm " alias_exists "$_alias" || die "Alias \"$_alias\" not found." _key=$(get_field "$_alias" "IdentityFile") [ -z "$_key" ] && die "No key configured for alias \"$_alias\"" [ -f "$_key" ] || die "Key file not found: $_key" printf '\n' ssh-add -d "$_key" && success "Key removed from agent for $_alias." printf '\n' ;; clear) printf '\n' warn "About to remove ALL keys from ssh-agent." printf '\n' if wt_yesno "Remove all keys from ssh-agent?"; then ssh-add -D && success "All keys removed from ssh-agent." else printf ' Aborted.\n' fi printf '\n' ;; *) die "Unknown agent subcommand: '$_sub' (use: list, add, rm, clear)" ;; esac } # Whiptail tunnel submenu _wt_tunnel_menu() { while true; do _tc=$(whiptail --title "termio — Tunnels" \ --cancel-button "Back" \ --menu "SSH tunnel management" "$TH" "$TW" 5 \ "status" " List / status all tunnels" \ "add" " Add a new tunnel" \ "start" " Start a tunnel" \ "stop" " Stop a running tunnel" \ "rm" " Remove a tunnel" \ 3>&1 1>&2 2>&3) || return 0 case "$_tc" in status) wt_scrollmsg "termio — Tunnel Status" "$(_wt_tunnel_status_text)" ;; add) wt_input "Tunnel name" ""; [ -z "$_PROMPT_RESULT" ] && continue _ttn="$_PROMPT_RESULT" wt_pick_alias "Select SSH alias"; [ -z "$SELECTED_ALIAS" ] && continue _tta="$SELECTED_ALIAS" _ttype=$(whiptail --title "termio — Tunnel Type" \ --menu "Forward type:" "$TH" "$TW" 3 \ "local" " -L Local port forward (default)" \ "remote" " -R Remote port forward (expose local to server)" \ "socks" " -D SOCKS5 dynamic proxy" \ 3>&1 1>&2 2>&3) || _ttype="local" case "$_ttype" in socks) wt_input "Local SOCKS port (e.g. 1080)" "1080" ;; *) wt_input "Forward spec (local_port:remote_host:remote_port)" "" ;; esac if [ -n "$_PROMPT_RESULT" ]; then cmd_tunnel add "$_ttn" "$_tta" "$_PROMPT_RESULT" --type "$_ttype" wt_scrollmsg "termio — Tunnels" "$(_wt_tunnel_status_text)" fi ;; start) wt_input "Tunnel name to start" ""; [ -z "$_PROMPT_RESULT" ] && continue _ttn="$_PROMPT_RESULT" whiptail --title "termio — Tunnels" \ --infobox "Starting tunnel \"$_ttn\"..." 5 50 _tmpout=$(mktemp 2>/dev/null || mktemp -t termio) cmd_tunnel start "$_ttn" >"$_tmpout" 2>&1 wt_msg "$(_strip_colors "$(cat "$_tmpout")")" rm -f "$_tmpout" ;; stop) wt_input "Tunnel name to stop" ""; [ -z "$_PROMPT_RESULT" ] && continue _tmpout=$(mktemp 2>/dev/null || mktemp -t termio) cmd_tunnel stop "$_PROMPT_RESULT" >"$_tmpout" 2>&1 wt_msg "$(_strip_colors "$(cat "$_tmpout")")" rm -f "$_tmpout" ;; rm) wt_input "Tunnel name to remove" ""; [ -z "$_PROMPT_RESULT" ] && continue cmd_tunnel rm "$_PROMPT_RESULT" wt_scrollmsg "termio — Tunnels" "$(_wt_tunnel_status_text)" ;; "") return 0 ;; esac done } # Whiptail agent submenu _wt_agent_menu() { while true; do _ac=$(whiptail --title "termio — SSH Agent" \ --cancel-button "Back" \ --menu "SSH agent key management" "$TH" "$TW" 4 \ "list" " List alias keys loaded in agent" \ "add" " Add alias key(s) to agent" \ "rm" " Remove alias key from agent" \ "clear" " Clear all keys from agent" \ 3>&1 1>&2 2>&3) || return 0 case "$_ac" in list) wt_scrollmsg "termio — SSH Agent" "$(_wt_agent_list_text)" ;; add) wt_input "Alias name (blank = add all)" "" _tmpout=$(mktemp 2>/dev/null || mktemp -t termio) cmd_agent add "$_PROMPT_RESULT" >"$_tmpout" 2>&1 wt_msg "$(_strip_colors "$(cat "$_tmpout")")" rm -f "$_tmpout" ;; rm) wt_pick_alias "Select alias to remove key from agent:" if [ -n "$SELECTED_ALIAS" ]; then _tmpout=$(mktemp 2>/dev/null || mktemp -t termio) cmd_agent rm "$SELECTED_ALIAS" >"$_tmpout" 2>&1 wt_msg "$(_strip_colors "$(cat "$_tmpout")")" rm -f "$_tmpout" fi ;; clear) if wt_yesno "Remove all keys from ssh-agent?"; then if ssh-add -D 2>/dev/null; then wt_msg "All keys removed from ssh-agent." else wt_msg "Failed to clear agent — is ssh-agent running?" fi fi ;; "") return 0 ;; esac done } # Whiptail sync submenu _wt_sync_menu() { _sync_path=$(pref_get "sync_path") _sync_label="${_sync_path:-not configured}" _sync_status="$([ -n "$_sync_path" ] && [ -d "$_sync_path" ] \ && printf 'Active: %s' "$_sync_path" \ || printf '%s' "${_sync_path:+OFFLINE: }${_sync_path:-not configured}")" _sc=$(whiptail --title "termio — Sync" \ --cancel-button "Back" \ --menu "Sync: $_sync_status" "$TH" "$TW" 4 \ "status" " Show sync status" \ "init" " Set up or reconfigure sync folder" \ "detach" " Detach from sync (restore local files)" \ "keys" " Encrypted key sync (enable/disable/restore)" \ 3>&1 1>&2 2>&3) || return 0 case "$_sc" in status) wt_sync_status ;; init) cmd_sync_init ;; detach) cmd_sync_detach ;; keys) _kc=$(whiptail --title "termio — Key Sync" \ --menu "Encrypted key archive (AES-256)" "$TH" "$TW" 5 \ "status" " Key sync status" \ "enable" " Enable encrypted key sync" \ "disable" " Disable & delete archive" \ "update" " Rebuild archive now" \ "restore" " Restore keys from archive" \ 3>&1 1>&2 2>&3) || return 0 case "$_kc" in status) wt_sync_keys_status ;; enable) wt_sync_keys_enable ;; *) cmd_sync_keys "$_kc" ;; esac ;; esac } # Whiptail prefs submenu _wt_prefs_menu() { while true; do _cur_ui=$(pref_get "ui") _cur_thresh=$(pref_get "audit_threshold"); [ -z "$_cur_thresh" ] && _cur_thresh=90 _cur_ktype=$(pref_get "key_type"); [ -z "$_cur_ktype" ] && _cur_ktype=ed25519 _pc=$(whiptail --title "termio — Preferences" \ --cancel-button "Back" \ --menu "" 13 64 3 \ "ui" " UI mode: ${_cur_ui:-auto-detect}" \ "threshold" " Key rotation threshold: ${_cur_thresh} days" \ "key_type" " Default key type: ${_cur_ktype}" \ 3>&1 1>&2 2>&3) || return 0 case "$_pc" in ui) _wt_prefs_ui_menu ;; threshold) _wt_prefs_threshold ;; key_type) _wt_prefs_keytype ;; esac done } _wt_prefs_ui_menu() { _cur_pref=$(pref_get "ui") _pc=$(whiptail --title "termio — UI Mode" \ --cancel-button "Back" \ --menu "Current: ${_cur_pref:-auto-detect} | whiptail installed: $([ "$_WHIPTAIL_INSTALLED" -eq 1 ] && printf 'yes' || printf 'no')" \ 12 64 3 \ "whiptail" " Use TUI dialogs (graphical menus)" \ "cli" " Use plain terminal always" \ "auto" " Auto-detect (default)" \ 3>&1 1>&2 2>&3) || return 0 case "$_pc" in whiptail) cmd_prefer whiptail ;; cli) cmd_prefer cli ;; auto) pref_set "ui" ""; success "Preference cleared — auto-detect." ;; esac } _wt_prefs_threshold() { _cur=$(pref_get "audit_threshold"); [ -z "$_cur" ] && _cur=90 _pt=$(whiptail --title "termio — Key Rotation Threshold" \ --cancel-button "Back" \ --menu "Current: ${_cur}" \ 12 64 3 \ "days" " Set threshold in days (current: ${_cur})" \ "off" " Disable key rotation auditing" \ "reset" " Reset to default (90 days)" \ 3>&1 1>&2 2>&3) || return 0 case "$_pt" in days) wt_input "Key rotation threshold in days" "$_cur" [ -z "$_PROMPT_RESULT" ] && return 0 case "$_PROMPT_RESULT" in *[!0-9]*|0) wt_msg "Must be a positive integer greater than 0."; return 1 ;; esac pref_set "audit_threshold" "$_PROMPT_RESULT" wt_msg "Threshold set to ${_PROMPT_RESULT} days." ;; off) if wt_yesno "⚠ Disable key rotation auditing?\n\nYou will no longer be warned when SSH keys are old.\nThis is not recommended for security-sensitive systems."; then pref_set "audit_threshold" "off" wt_msg "Key rotation auditing disabled." fi ;; reset) pref_set "audit_threshold" "" wt_msg "Threshold reset to default (90 days)." ;; esac } _wt_prefs_keytype() { _cur=$(pref_get "key_type"); [ -z "$_cur" ] && _cur=ed25519 _kt=$(whiptail --title "termio — Default Key Type" \ --cancel-button "Back" \ --menu "Current: $_cur" \ 11 48 3 \ "ed25519" " Modern, fast, recommended" \ "rsa" " Widest compatibility (4096-bit)" \ "ecdsa" " NIST curve, good compatibility" \ 3>&1 1>&2 2>&3) || return 0 pref_set "key_type" "$_kt" success "Default key type set to ${C_BOLD}${_kt}${C_RESET}." } # Whiptail help screen wt_help() { _ht=$(cat <<'HELPEOF' MENU ITEMS connect Open an SSH session to a saved alias. add Guided wizard — alias name, host, user, port, note, optional jump/bastion host, and advanced SSH options. Generates a unique ed25519 key and copies it to the remote (password used once, never stored). info Full details for an alias: host, user, port, jump host, key fingerprint, note. test Quick non-interactive connectivity check. status Test all aliases in parallel and show a color-coded reachability dashboard. run One-off remote command without saving a snippet. Example: termio run myserver "df -h" copy Copy files to/from a remote alias using sftp. Example: termio copy myserver:/etc/hosts /tmp/ edit Change any saved field for an alias. rotate Generate a new key, deploy it to the remote, and retire the old one. audit Check all alias keys for age. Flags keys older than the configured threshold (default: 90 days). Set threshold: termio prefer audit_threshold rm Remove an alias and optionally delete its key file. export Write all aliases to a portable text file. import Load aliases from an export file. snip Saved remote commands. Run one command across multiple hosts at once; supports --group filtering and optional sudo. Password prompted, never stored. tunnel Named SSH port-forward profiles. Add: termio tunnel add db-tun myserver 5432:localhost:5432 Then: termio tunnel start db-tun / stop / status agent Load or inspect alias keys in ssh-agent. termio agent list — show which alias keys are loaded termio agent add [alias] — load one or all keys termio agent rm — unload a specific key termio agent clear — remove all keys sync Mirror aliases, snippets, and preferences to any folder — NAS, cloud drive, or USB. Key sync (opt-in): AES-256 encrypted archive of private keys. Passphrase is never stored anywhere. prefs Switch between TUI (whiptail) and plain CLI mode. Set default key type: termio prefer key_type ed25519 quit Exit termio. KEYBOARD SHORTCUTS Arrow keys Navigate lists Space Toggle a checklist item on / off Tab Move between Ok and Cancel buttons Enter Confirm Escape Cancel current dialog NOTES Aliases live in ~/.ssh/config — you can also connect with: ssh Each alias has its own key: ~/.ssh/id_ed25519_ Revoking one host does not affect any other. Passwords are never written to disk — used once then cleared. Sync auto-exports after every alias or snippet change. Key sync rebuilds the encrypted archive after add/rotate/rm. HELPEOF ) whiptail --title "termio v${TERMIO_VERSION} — Help" \ --scrolltext --msgbox "$_ht" "$TH" "$TW" } # Build whiptail title with sync status indicator _wt_title() { _sp=$(pref_get "sync_path") if [ -n "$_sp" ] && [ -d "$_sp" ]; then printf 'termio v%s [Sync: ✔]' "$TERMIO_VERSION" elif [ -n "$_sp" ]; then printf 'termio v%s [Sync: OFFLINE]' "$TERMIO_VERSION" else printf 'termio v%s' "$TERMIO_VERSION" fi } _wt_manage_menu() { while true; do _title=$(_wt_title) _mchoice=$(whiptail --title "$_title — Manage" \ --cancel-button "Back" \ --menu "Manage aliases:" "$TH" "$TW" 8 \ "edit" " Edit an alias" \ "rotate" " Rotate SSH key for an alias" \ "pin" " Pin / unpin aliases (show at top of list)" \ "audit" " Audit SSH key ages" \ "backup" " View / restore SSH config backups" \ "rm" " Remove an alias" \ "export" " Export aliases to file" \ "import" " Import aliases from file" \ 3>&1 1>&2 2>&3) || return 0 case "$_mchoice" in edit) wt_pick_alias "Select alias to edit:" [ -n "$SELECTED_ALIAS" ] && cmd_edit "$SELECTED_ALIAS" ;; rotate) wt_pick_alias "Select alias to rotate:" [ -n "$SELECTED_ALIAS" ] && cmd_rotate "$SELECTED_ALIAS" ;; pin) _ptitle=$(_wt_title) _pchoice=$(whiptail --title "$_ptitle — Pin" \ --menu "Pin management:" "$TH" "$TW" 3 \ "pin" " Pin an alias (show at top of list)" \ "unpin" " Unpin an alias" \ "list" " Show current pinned aliases" \ 3>&1 1>&2 2>&3) || true case "$_pchoice" in pin) wt_pick_alias "Select alias to pin:" [ -n "$SELECTED_ALIAS" ] && cmd_pin "$SELECTED_ALIAS" ;; unpin) wt_pick_alias "Select alias to unpin:" [ -n "$SELECTED_ALIAS" ] && cmd_unpin "$SELECTED_ALIAS" ;; list) _plist=$(pref_get "pinned_aliases") [ -z "$_plist" ] && _plist="(none)" wt_msg "Pinned aliases:\n\n$(printf '%s' "$_plist" | tr ',' '\n')" ;; esac ;; audit) wt_audit ;; backup) _btxt=$( _bfiles=$(ls -t "$BACKUP_DIR"/config.* 2>/dev/null || true) if [ -z "$_bfiles" ]; then printf 'No backups found in %s\n' "$BACKUP_DIR" else printf 'SSH config backups (most recent first):\n\n' _bn=1 printf '%s\n' "$_bfiles" | while IFS= read -r _bf; do _bts=$(printf '%s' "$(basename "$_bf")" | sed 's/config\.//') printf ' %2d) %s\n' "$_bn" "$_bts" _bn=$(( _bn + 1 )) done fi ) wt_scrollmsg "termio — SSH Config Backups" "$_btxt" wt_input "Restore backup number (blank = cancel)" "" [ -n "$_PROMPT_RESULT" ] && cmd_backup restore "$_PROMPT_RESULT" ;; rm) wt_pick_alias "Select alias to remove:" [ -n "$SELECTED_ALIAS" ] && cmd_remove "$SELECTED_ALIAS" ;; export) _def="$HOME/termio-export-$(date +%Y%m%d).txt" wt_input "Export file path" "$_def" if [ -n "$_PROMPT_RESULT" ]; then cmd_export "$_PROMPT_RESULT" wt_msg "Aliases exported to:\n$_PROMPT_RESULT" fi ;; import) wt_input "Path to import file" "$HOME/termio-export.txt" [ -n "$_PROMPT_RESULT" ] && cmd_import "$_PROMPT_RESULT" ;; "") return 0 ;; esac done } _wt_bootstrap_menu() { while true; do _title=$(_wt_title) _bchoice=$(whiptail --title "$_title — Bootstrap" \ --cancel-button "Back" \ --menu "Manage snippet widget (Ctrl+X s) on remote hosts:" "$TH" "$TW" 6 \ "install" " Install snippet widget on alias" \ "connect" " Connect with profile (ephemeral session)" \ "update" " Sync snippets to alias" \ "remove" " Remove snippet widget from alias" \ "list" " List aliases with snippet widget" \ "prefs" " Bootstrap auto-prompt preference" \ 3>&1 1>&2 2>&3) || return 0 case "$_bchoice" in install) wt_pick_alias "Select alias to bootstrap:" [ -n "$SELECTED_ALIAS" ] && cmd_bootstrap "$SELECTED_ALIAS" ;; connect) wt_pick_alias "Select alias for profile session:" [ -n "$SELECTED_ALIAS" ] && cmd_connect --profile "$SELECTED_ALIAS" ;; update) wt_pick_alias "Select alias to push snippets to:" [ -n "$SELECTED_ALIAS" ] && cmd_bootstrap update "$SELECTED_ALIAS" ;; remove) wt_pick_alias "Select alias to remove profile from:" [ -n "$SELECTED_ALIAS" ] && cmd_bootstrap remove "$SELECTED_ALIAS" ;; list) _bltxt=$(cmd_bootstrap list 2>&1) wt_scrollmsg "termio — Bootstrapped Aliases" "$_bltxt" ;; prefs) _bpchoice=$(whiptail --title "$(_wt_title) — Bootstrap Prefs" \ --menu "Bootstrap preferences:" "$TH" "$TW" 2 \ "auto_on" " Auto-prompt to bootstrap on first connect" \ "auto_off" " Disable auto-prompt (manual only)" \ 3>&1 1>&2 2>&3) || true case "$_bpchoice" in auto_on) pref_set "auto_bootstrap" "1"; success "Auto-bootstrap enabled." ;; auto_off) pref_set "auto_bootstrap" "0"; success "Auto-bootstrap disabled." ;; esac ;; "") return 0 ;; esac done } # Startup audit check — print one-liner warning if keys need rotation (interactive only) _startup_audit_warn() { _threshold=$(pref_get "audit_threshold") [ -z "$_threshold" ] && _threshold=90 [ "$_threshold" = "off" ] && return 0 _aliases=$(list_aliases) [ -z "$_aliases" ] && return 0 _flagged=0 while IFS= read -r _a; do [ -z "$_a" ] && continue _alias_thresh=$(pref_get "audit_threshold_${_a}") [ -z "$_alias_thresh" ] && _alias_thresh=$_threshold [ "$_alias_thresh" = "off" ] && continue _key=$(get_field "$_a" "IdentityFile") if [ -z "$_key" ] || [ ! -f "$_key" ]; then _flagged=$(( _flagged + 1 )); continue fi _kname=$(basename "$_key") if find "$SSH_DIR" -maxdepth 1 -name "$_kname" -mtime "+${_alias_thresh}" \ 2>/dev/null | grep -q .; then _flagged=$(( _flagged + 1 )) fi done <<_EOSW $(printf '%s\n' "$_aliases") _EOSW if [ "$_flagged" -gt 0 ]; then printf " ${C_YELLOW}⚠${C_RESET} ${C_BOLD}%d key(s) need rotation${C_RESET} ${C_DIM}(run: termio audit)${C_RESET}\n\n" \ "$_flagged" fi } interactive_menu() { _warn_ssh_includes if [ "$HAS_WHIPTAIL" -eq 1 ]; then # Show audit warning as infobox briefly if keys need rotation _startup_audit_warn 2>/dev/null | grep -q 'need rotation' && \ whiptail --title "termio — Key Alert" \ --infobox "⚠ Some SSH keys need rotation.\n\nGo to Manage ▶ Audit to review." \ 7 50 || true while true; do _title=$(_wt_title) CHOICE=$(whiptail --title "$_title" \ --cancel-button "Exit" \ --menu "Choose an action:" "$TH" "$TW" 15 \ "connect" " Connect to a saved alias" \ "add" " Add a new SSH alias" \ "info" " Show alias details" \ "test" " Test a connection" \ "status" " Status dashboard — all connections" \ "run" " Run a one-off remote command" \ "copy" " Copy files to/from alias (sftp)" \ "bootstrap" " Bootstrap ▶ — install/sync snippet widget on remotes" \ "manage" " Manage ▶ (edit / rotate / audit / rm / export)" \ "snip" " Snippets ▶ — reusable remote commands" \ "tunnel" " Tunnels ▶ — named port-forward profiles" \ "agent" " Agent ▶ — SSH key agent management" \ "sync" " Sync ▶ — mirror to NAS / cloud / USB" \ "prefs" " Preferences" \ "help" " Help" \ 3>&1 1>&2 2>&3) || break case "$CHOICE" in connect) if wt_pick_alias "Select alias to connect:"; then cmd_connect "$SELECTED_ALIAS" fi ;; add) cmd_add ;; info) if wt_pick_alias "Select alias to inspect:"; then wt_show_info "$SELECTED_ALIAS" fi ;; test) _tc=$(whiptail --title "termio v${TERMIO_VERSION}" \ --menu "Test connections:" "$TH" "$TW" 2 \ "one" " Test a single alias" \ "all" " Test all connections" \ 3>&1 1>&2 2>&3) || _tc="" case "$_tc" in one) if wt_pick_alias "Select alias to test:"; then wt_test_alias "$SELECTED_ALIAS" fi ;; all) wt_status ;; esac ;; status) wt_status ;; run) if wt_pick_alias "Select alias to run command on:" && [ -n "$SELECTED_ALIAS" ]; then wt_input "Command to run on ${SELECTED_ALIAS}" "" if [ -n "$_PROMPT_RESULT" ]; then _wt_run_alias="$SELECTED_ALIAS" _wt_run_cmd="$_PROMPT_RESULT" whiptail --title "termio — Run" \ --infobox "Running on ${_wt_run_alias}..." 5 50 _tmpout=$(mktemp 2>/dev/null || mktemp -t termio) set +e ssh -F "$CONFIG_FILE" \ -o BatchMode=yes \ -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ "$_wt_run_alias" "$_wt_run_cmd" >"$_tmpout" 2>&1 _wt_rc=$? set -e _wt_out=$(cat "$_tmpout"); rm -f "$_tmpout" _wt_hdr="$(printf 'Command: %s\nAlias: %s (exit: %s)\n%s\n' \ "$_wt_run_cmd" "$_wt_run_alias" "$_wt_rc" \ "──────────────────────────────────────")" wt_scrollmsg "termio — Run: $_wt_run_alias" \ "$(printf '%s\n%s' "$_wt_hdr" "$_wt_out")" fi fi ;; copy) cmd_copy_wizard ;; bootstrap) _wt_bootstrap_menu ;; manage) _wt_manage_menu ;; tunnel) _wt_tunnel_menu ;; agent) _wt_agent_menu ;; snip) _wt_snip_menu ;; sync) _wt_sync_menu ;; prefs) _wt_prefs_menu ;; help) wt_help ;; "") break ;; esac done else while true; do print_banner _startup_audit_warn printf " ${C_LBLUE}${C_BOLD}── Connect ──────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD} 1)${C_RESET} Add a new SSH alias\n" printf " ${C_BOLD} 2)${C_RESET} Connect to an alias\n" printf " ${C_BOLD} 3)${C_RESET} List all saved aliases\n" printf " ${C_BOLD} 4)${C_RESET} Show alias details\n" printf " ${C_BOLD} 5)${C_RESET} Test a connection\n" printf " ${C_BOLD} 6)${C_RESET} Run a one-off remote command\n" printf " ${C_BOLD} 7)${C_RESET} Copy files to/from alias (sftp)\n" printf " ${C_BOLD} 8)${C_RESET} Status dashboard (test all connections)\n" printf " ${C_LBLUE}${C_BOLD}── Manage ───────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD} 9)${C_RESET} Edit an alias\n" printf " ${C_BOLD}10)${C_RESET} Rotate key for an alias\n" printf " ${C_BOLD}11)${C_RESET} Audit SSH key ages\n" printf " ${C_BOLD}12)${C_RESET} Remove an alias\n" printf " ${C_BOLD}13)${C_RESET} Export aliases to file\n" printf " ${C_BOLD}14)${C_RESET} Import aliases from file\n" printf " ${C_BOLD}pi)${C_RESET} Pin / unpin aliases\n" printf " ${C_BOLD}bk)${C_RESET} SSH config backups\n" printf " ${C_LBLUE}${C_BOLD}── Tools ────────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}tu)${C_RESET} Tunnels — named port-forward profiles\n" printf " ${C_BOLD}ag)${C_RESET} Agent — SSH key agent management\n" printf " ${C_BOLD}sn)${C_RESET} Snippets — reusable remote commands\n" printf " ${C_BOLD}sy)${C_RESET} Sync — mirror to NAS / cloud / USB\n" printf " ${C_BOLD}bs)${C_RESET} Bootstrap — deploy snippet widget to remote hosts\n" printf " ${C_BOLD} p)${C_RESET} Preferences\n" printf " ${C_BOLD} h)${C_RESET} Help\n" printf " ${C_BOLD} q)${C_RESET} Quit\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|add) cmd_add ;; 2|connect) cmd_list cli_prompt "Alias to connect to" "" [ -n "$_PROMPT_RESULT" ] && cmd_connect "$_PROMPT_RESULT" ;; 3|list) cmd_list ;; 4|info) cmd_list cli_prompt "Alias for details" "" [ -n "$_PROMPT_RESULT" ] && cmd_info "$_PROMPT_RESULT" ;; 5|test) cmd_list cli_prompt "Alias to test" "" [ -n "$_PROMPT_RESULT" ] && cmd_test "$_PROMPT_RESULT" ;; 6|run) cmd_list cli_prompt "Alias to run command on" "" [ -z "$_PROMPT_RESULT" ] && continue _run_a="$_PROMPT_RESULT" cli_prompt "Command to run on $_run_a" "" [ -n "$_PROMPT_RESULT" ] && cmd_run "$_run_a" "$_PROMPT_RESULT" ;; 7|copy) cmd_copy_wizard ;; 8|status) cmd_status ;; 9|edit) cmd_list cli_prompt "Alias to edit" "" [ -n "$_PROMPT_RESULT" ] && cmd_edit "$_PROMPT_RESULT" ;; 10|rotate) cmd_list cli_prompt "Alias to rotate" "" [ -n "$_PROMPT_RESULT" ] && cmd_rotate "$_PROMPT_RESULT" ;; 11|audit) cmd_audit ;; 12|rm|remove) cmd_list cli_prompt "Alias to remove" "" [ -n "$_PROMPT_RESULT" ] && cmd_remove "$_PROMPT_RESULT" ;; 13|export) cli_prompt "Output file" "$HOME/termio-export-$(date +%Y%m%d).txt" cmd_export "$_PROMPT_RESULT" ;; 14|import) cli_prompt "Import file path" "" [ -n "$_PROMPT_RESULT" ] && cmd_import "$_PROMPT_RESULT" ;; pi|pin|unpin) printf '\n' printf " ${C_BOLD}1)${C_RESET} Pin an alias (show at top of list)\n" printf " ${C_BOLD}2)${C_RESET} Unpin an alias\n" printf " ${C_BOLD}3)${C_RESET} Show pinned aliases\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|pin) cmd_list cli_prompt "Alias to pin" "" [ -n "$_PROMPT_RESULT" ] && cmd_pin "$_PROMPT_RESULT" ;; 2|unpin) cli_prompt "Alias to unpin" "" [ -n "$_PROMPT_RESULT" ] && cmd_unpin "$_PROMPT_RESULT" ;; 3|list) cmd_pin ;; esac ;; bk|backup) printf '\n' printf " ${C_BOLD}1)${C_RESET} List backups\n" printf " ${C_BOLD}2)${C_RESET} Restore a backup\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|list) cmd_backup list ;; 2|restore) cmd_backup list cli_prompt "Backup number to restore" "" [ -n "$_PROMPT_RESULT" ] && cmd_backup restore "$_PROMPT_RESULT" ;; esac ;; tu|tunnel) while true; do printf "\n ${C_LBLUE}${C_BOLD}── Tunnels ─────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}1)${C_RESET} List / status all tunnels\n" printf " ${C_BOLD}2)${C_RESET} Add a tunnel\n" printf " ${C_BOLD}3)${C_RESET} Start a tunnel\n" printf " ${C_BOLD}4)${C_RESET} Stop a tunnel\n" printf " ${C_BOLD}5)${C_RESET} Remove a tunnel\n" printf " ${C_BOLD}b)${C_RESET} Back\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|list|status) cmd_tunnel status ;; 2|add) cli_prompt "Tunnel name" "" [ -z "$_PROMPT_RESULT" ] && continue _ttn="$_PROMPT_RESULT" cmd_list cli_prompt "SSH alias" "" [ -z "$_PROMPT_RESULT" ] && continue _tta="$_PROMPT_RESULT" printf '\n' printf " ${C_BOLD}1)${C_RESET} local — -L local port forward (default)\n" printf " ${C_BOLD}2)${C_RESET} remote — -R expose local port on server\n" printf " ${C_BOLD}3)${C_RESET} socks — -D SOCKS5 dynamic proxy\n\n" cli_prompt "Type (1/2/3)" "1" case "$_PROMPT_RESULT" in 2|remote) _ttype="remote" ;; 3|socks) _ttype="socks" ;; *) _ttype="local" ;; esac case "$_ttype" in socks) cli_prompt "Local SOCKS port (e.g. 1080)" "1080" ;; *) cli_prompt "Forward spec (local_port:remote_host:remote_port)" "" ;; esac [ -n "$_PROMPT_RESULT" ] && cmd_tunnel add "$_ttn" "$_tta" "$_PROMPT_RESULT" --type "$_ttype" ;; 3|start) cmd_tunnel status cli_prompt "Tunnel name to start" "" [ -n "$_PROMPT_RESULT" ] && cmd_tunnel start "$_PROMPT_RESULT" ;; 4|stop) cmd_tunnel status cli_prompt "Tunnel name to stop" "" [ -n "$_PROMPT_RESULT" ] && cmd_tunnel stop "$_PROMPT_RESULT" ;; 5|rm|remove) cmd_tunnel status cli_prompt "Tunnel name to remove" "" [ -n "$_PROMPT_RESULT" ] && cmd_tunnel rm "$_PROMPT_RESULT" ;; b|back|"") break ;; *) warn "Unknown option." ;; esac done ;; ag|agent) while true; do printf "\n ${C_LBLUE}${C_BOLD}── Agent ───────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}1)${C_RESET} List loaded keys\n" printf " ${C_BOLD}2)${C_RESET} Add alias key(s) to agent\n" printf " ${C_BOLD}3)${C_RESET} Remove alias key from agent\n" printf " ${C_BOLD}4)${C_RESET} Clear all keys from agent\n" printf " ${C_BOLD}b)${C_RESET} Back\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|list) cmd_agent list ;; 2|add) cmd_list cli_prompt "Alias name (blank = add all)" "" cmd_agent add "$_PROMPT_RESULT" ;; 3|rm|remove) cmd_list cli_prompt "Alias name" "" [ -n "$_PROMPT_RESULT" ] && cmd_agent rm "$_PROMPT_RESULT" ;; 4|clear) cmd_agent clear ;; b|back|"") break ;; *) warn "Unknown option." ;; esac done ;; sn|snip|snippets) while true; do printf "\n ${C_LBLUE}${C_BOLD}── Snippets ────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}1)${C_RESET} List snippets\n" printf " ${C_BOLD}2)${C_RESET} Add a snippet\n" printf " ${C_BOLD}3)${C_RESET} Run a snippet\n" printf " ${C_BOLD}4)${C_RESET} Snippet details\n" printf " ${C_BOLD}5)${C_RESET} Edit a snippet\n" printf " ${C_BOLD}6)${C_RESET} Remove a snippet\n" printf " ${C_BOLD}b)${C_RESET} Back\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|list) cmd_snip_list ;; 2|add) cmd_snip_add ;; 3|run) cmd_snip_list cli_prompt "Snippet name to run" "" [ -n "$_PROMPT_RESULT" ] && cmd_snip_run "$_PROMPT_RESULT" ;; 4|info) cmd_snip_list cli_prompt "Snippet name for details" "" [ -n "$_PROMPT_RESULT" ] && cmd_snip_info "$_PROMPT_RESULT" ;; 5|edit) cmd_snip_list cli_prompt "Snippet name to edit" "" [ -n "$_PROMPT_RESULT" ] && cmd_snip_edit "$_PROMPT_RESULT" ;; 6|rm|remove) cmd_snip_list cli_prompt "Snippet name to remove" "" [ -n "$_PROMPT_RESULT" ] && cmd_snip_rm "$_PROMPT_RESULT" ;; b|back|"") break ;; *) warn "Unknown option." ;; esac done ;; sy|sync) while true; do printf "\n ${C_LBLUE}${C_BOLD}── Sync ────────────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}1)${C_RESET} Sync status\n" printf " ${C_BOLD}2)${C_RESET} Init / reconfigure sync\n" printf " ${C_BOLD}3)${C_RESET} Detach from sync\n" printf " ${C_BOLD}4)${C_RESET} Encrypted key sync\n" printf " ${C_BOLD}b)${C_RESET} Back\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|status) cmd_sync_status ;; 2|init) cmd_sync_init ;; 3|detach) cmd_sync_detach ;; 4|keys) printf '\n' printf " ${C_BOLD}1)${C_RESET} Key sync status\n" printf " ${C_BOLD}2)${C_RESET} Enable encrypted key sync\n" printf " ${C_BOLD}3)${C_RESET} Disable & delete archive\n" printf " ${C_BOLD}4)${C_RESET} Rebuild archive now\n" printf " ${C_BOLD}5)${C_RESET} Restore keys from archive\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|status) cmd_sync_keys status ;; 2|enable) cmd_sync_keys enable ;; 3|disable) cmd_sync_keys disable ;; 4|update) cmd_sync_keys update ;; 5|restore) cmd_sync_keys restore ;; esac ;; b|back|"") break ;; esac done ;; bs|bootstrap) while true; do printf "\n ${C_LBLUE}${C_BOLD}── Bootstrap ───────────────────────────────────${C_RESET}\n" printf " ${C_BOLD}1)${C_RESET} Install snippet widget on alias\n" printf " ${C_BOLD}2)${C_RESET} Connect with profile (ephemeral session)\n" printf " ${C_BOLD}3)${C_RESET} Sync snippets to alias\n" printf " ${C_BOLD}4)${C_RESET} Remove snippet widget from alias\n" printf " ${C_BOLD}5)${C_RESET} List aliases with snippet widget\n" printf " ${C_BOLD}6)${C_RESET} Enable auto-bootstrap prompt on connect\n" printf " ${C_BOLD}7)${C_RESET} Disable auto-bootstrap prompt\n" printf " ${C_BOLD}b)${C_RESET} Back\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|install) cmd_list cli_prompt "Alias to bootstrap" "" [ -n "$_PROMPT_RESULT" ] && cmd_bootstrap "$_PROMPT_RESULT" ;; 2|connect) cmd_list cli_prompt "Alias for profile session" "" [ -n "$_PROMPT_RESULT" ] && cmd_connect --profile "$_PROMPT_RESULT" ;; 3|update|push) cmd_list cli_prompt "Alias to push updated snippets to" "" [ -n "$_PROMPT_RESULT" ] && cmd_bootstrap update "$_PROMPT_RESULT" ;; 4|remove) cmd_list cli_prompt "Alias to remove profile from" "" [ -n "$_PROMPT_RESULT" ] && cmd_bootstrap remove "$_PROMPT_RESULT" ;; 5|list) cmd_bootstrap list ;; 6|auto_on) pref_set "auto_bootstrap" "1"; success "Auto-bootstrap enabled." ;; 7|auto_off) pref_set "auto_bootstrap" "0"; success "Auto-bootstrap disabled." ;; b|back|"") break ;; *) warn "Unknown option." ;; esac done ;; p|prefs|prefer) printf '\n' printf " ${C_BOLD}1)${C_RESET} Prefer whiptail (TUI)\n" printf " ${C_BOLD}2)${C_RESET} Prefer cli (plain terminal)\n" printf " ${C_BOLD}3)${C_RESET} Auto-detect (default)\n\n" cli_prompt "Choice" "" case "$_PROMPT_RESULT" in 1|whiptail) cmd_prefer whiptail ;; 2|cli) cmd_prefer cli ;; 3|auto) pref_set "ui" ""; success "Preference cleared." ;; esac ;; h|help) cmd_help ;; q|quit|exit) break ;; *) warn "Unknown option." ;; esac done fi } # ── Help ────────────────────────────────────────────────────────────────────── cmd_help() { print_banner printf " ${C_BOLD}COMMANDS${C_RESET}\n" printf " %-36s %s\n" "termio [-v]" "Interactive menu" printf " %-36s %s\n" "termio [-v] add" "Add a new SSH alias" printf " %-36s %s\n" "termio [-v] connect " "Connect to a saved alias" printf " %-36s %s\n" "termio [-v] ls|list" "List all aliases" printf " %-36s %s\n" "termio [-v] info " "Full details + fingerprint" printf " %-36s %s\n" "termio [-v] test " "Test SSH connectivity" printf " %-36s %s\n" "termio [-v] edit " "Edit an alias" printf " %-36s %s\n" "termio [-v] rotate " "Rotate SSH key" printf " %-36s %s\n" "termio [-v] rm " "Remove alias (+ optional key delete)" printf " %-36s %s\n" "termio [-v] export [file]" "Export aliases to portable file" printf " %-36s %s\n" "termio [-v] import " "Import aliases from export file" printf " %-36s %s\n" "termio [-v] run " "Run a one-off remote command" printf " %-36s %s\n" "termio [-v] copy " "Copy files via sftp (alias:/path)" printf " %-36s %s\n" "termio [-v] status" "Test all connections (dashboard)" printf " %-36s %s\n" "termio [-v] audit" "Audit SSH key ages" printf " %-36s %s\n" "termio prefer" "Show current preferences" printf " %-36s %s\n" "termio prefer whiptail" "Use TUI (installs if needed)" printf " %-36s %s\n" "termio prefer cli" "Use plain terminal always" printf " %-36s %s\n" "termio prefer audit_threshold " "Key rotation warning threshold" printf " %-36s %s\n" "termio prefer key_type " "Default key type (ed25519/rsa/ecdsa)" printf " %-36s %s\n" "termio [-v] sync" "Show sync status" printf " %-36s %s\n" "termio [-v] sync init" "Set up sync folder" printf " %-36s %s\n" "termio [-v] sync detach" "Detach from sync" printf " %-36s %s\n" "termio [-v] sync keys" "Key sync status" printf " %-36s %s\n" "termio [-v] sync keys enable" "Opt in to encrypted key sync" printf " %-36s %s\n" "termio [-v] sync keys disable" "Remove encrypted archive" printf " %-36s %s\n" "termio [-v] sync keys update" "Rebuild key archive" printf " %-36s %s\n" "termio [-v] sync keys restore" "Restore keys from archive" printf " %-36s %s\n" "termio [-v] snip" "List all snippets" printf " %-36s %s\n" "termio [-v] snip add" "Add a new snippet" printf " %-36s %s\n" "termio [-v] snip run " "Run snippet on host(s)" printf " %-36s %s\n" "termio [-v] snip run --group " "Run snippet on group" printf " %-36s %s\n" "termio [-v] snip info " "Show snippet details" printf " %-36s %s\n" "termio [-v] snip edit " "Edit a snippet" printf " %-36s %s\n" "termio [-v] snip rm " "Remove a snippet" printf " %-36s %s\n" "termio [-v] tunnel" "List tunnels and status" printf " %-36s %s\n" "termio [-v] tunnel add " "Add a named tunnel" printf " %-36s %s\n" "termio [-v] tunnel start " "Start a tunnel" printf " %-36s %s\n" "termio [-v] tunnel stop " "Stop a running tunnel" printf " %-36s %s\n" "termio [-v] tunnel rm " "Remove a tunnel" printf " %-36s %s\n" "termio [-v] agent" "List alias keys in ssh-agent" printf " %-36s %s\n" "termio [-v] agent add [alias]" "Load alias key(s) into agent" printf " %-36s %s\n" "termio [-v] agent rm " "Remove alias key from agent" printf " %-36s %s\n" "termio [-v] agent clear" "Remove all keys from agent" printf " %-36s %s\n" "termio [-v] bootstrap " "Install snippet widget on alias" printf " %-36s %s\n" "termio [-v] bootstrap update " "Sync snippets to alias" printf " %-36s %s\n" "termio [-v] bootstrap remove " "Remove snippet widget from alias" printf " %-36s %s\n" "termio [-v] bootstrap list" "List aliases with snippet widget" printf " %-36s %s\n" "termio [-v] connect --profile" "Ephemeral session with profile active" printf " %-36s %s\n" "termio version" "Show version" printf " %-36s %s\n" "termio help|--help|-h" "Show this help" printf '\n' printf " ${C_BOLD}FLAGS${C_RESET}\n" printf " -v | --verbose Show background operations (sync, key gen, config writes)\n" printf '\n' printf " ${C_BOLD}FEATURES${C_RESET}\n" printf " • Per-alias keys: ed25519 (default), rsa-4096, or ecdsa-521\n" printf " • Passwords never stored — used once then cleared from memory\n" printf " • Jump / bastion host support via ProxyJump\n" printf " • Groups/tags — tag aliases and filter: termio ls --group homelab\n" printf " • Optional: note, ServerAliveInterval, ForwardAgent, LocalForward\n" printf " • Export/import for machine migration\n" printf " • Sync via any folder-based provider (Nextcloud, Syncthing, Drive…)\n" printf " • Aliases auto-exported to sync on: add, edit, rm, rotate, import\n" printf " • Key sync archive rebuilt on: add, rm, rotate (when key sync is enabled)\n" printf " • Encrypted key sync opt-in — AES-256-CBC, PBKDF2, passphrase never stored\n" printf " • Snippets — named reusable commands, run sequentially on any host(s)\n" printf " • Snippets support sudo (password prompted per run, never stored)\n" printf " • Snippets auto-exported to sync on: snip add, snip edit, snip rm\n" printf " • TUI (whiptail) or plain CLI — user preference, auto-installs whiptail\n" printf " • run — one-off remote commands without saving a snippet\n" printf " • copy — sftp wrapper using alias names (no host/port/key lookup needed)\n" printf " • status — parallel reachability dashboard for all aliases\n" printf " • audit — flag SSH keys older than N days (configurable threshold)\n" printf " • tunnel — named port-forward profiles, start/stop/status management\n" printf " • agent — load/unload per-alias keys in ssh-agent\n" printf " • Connection history tracked per alias (last seen in list/info)\n" printf " • bootstrap — deploy Ctrl+X s snippet widget (zsh ZLE / bash readline) to remote\n" printf " • Tab completion for bash + zsh — source termio-completion\n" printf " • Config: ~/.config/termio/ | Keys: ~/.ssh/id__\n" printf '\n' printf " ${C_BOLD}OPTIONAL DEPS${C_RESET}\n" printf " sshpass — non-interactive key copy\n" printf " openssl — required for encrypted key sync\n" printf " whiptail — graphical TUI (termio prefer whiptail to auto-install)\n" printf " Debian/Mint: sudo apt install sshpass whiptail openssl\n" printf " Fedora: sudo dnf install sshpass newt openssl\n" printf " Arch: sudo pacman -S libnewt openssl\n" printf " macOS: brew install sshpass newt (openssl pre-installed)\n" printf '\n' printf " ${C_BOLD}INSTALL${C_RESET}\n" printf " chmod +x termio && sudo mv termio /usr/local/bin/termio\n" printf '\n' } # ── Entry point ─────────────────────────────────────────────────────────────── check_deps case "${1:-}" in "") interactive_menu ;; add) cmd_add ;; connect) shift; cmd_connect "$@" ;; ls|list) shift; cmd_list "$@" ;; info) cmd_info "${2:-}" ;; test) cmd_test "${2:-}" ;; edit) cmd_edit "${2:-}" ;; rotate) cmd_rotate "${2:-}" ;; rm|remove) cmd_remove "${2:-}" ;; export) cmd_export "${2:-}" ;; import) cmd_import "${2:-}" ;; prefer) cmd_prefer "${2:-}" "${3:-}" "${4:-}" ;; run) _run_alias="${2:-}"; shift 2 2>/dev/null || true; cmd_run "$_run_alias" "$@" ;; copy) shift; cmd_copy "${1:-}" "${2:-}" ;; status) cmd_status ;; audit) cmd_audit ;; snip|snippets) shift; cmd_snip "$@" ;; sync) cmd_sync "${2:-}" "${3:-}" ;; tunnel) shift; cmd_tunnel "$@" ;; agent) cmd_agent "${2:-}" "${3:-}" ;; backup) cmd_backup "${2:-}" "${3:-}" ;; bootstrap) shift; cmd_bootstrap "$@" ;; pin) cmd_pin "${2:-}" ;; unpin) cmd_unpin "${2:-}" ;; open) cmd_open "${2:-}" ;; clone) cmd_clone "${2:-}" "${3:-}" ;; rename) cmd_rename "${2:-}" "${3:-}" ;; tag) cmd_tag "${2:-}" "${3:-}" ;; untag) cmd_untag "${2:-}" "${3:-}" ;; log) cmd_log "${2:-}" ;; diff) cmd_diff ;; wake) cmd_wake "${2:-}" ;; template) shift; cmd_template "$@" ;; version|--version) printf 'termio version %s\n' "$TERMIO_VERSION" ;; help|--help|-h) cmd_help ;; *) die "Unknown command: '$1' (try: termio help)" ;; esac