#!/usr/bin/env bash
# sshfs-watchdog.sh
# Keeps an SSHFS mount alive on macOS. Run from a launchd agent with a
# ~60s StartInterval. Idempotent: exits fast when the mount is healthy.
# Requires macFUSE + sshfs. Optional: a `timeout`/`gtimeout` binary
# (coreutils) for a bounded liveness probe.

set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"

# ── config ──────────────────────────────────────────────────────────
SSH_ALIAS="your-server"        # ssh config alias for the remote host
SSH_ALIAS_LAN=""               # optional alias for a direct LAN route
LAN_IP=""                      # LAN IP to probe first (empty = skip)
REMOTE_PATH="/srv/data"        # remote directory to mount
MOUNTPOINT="$HOME/Remote"      # local mountpoint
LOGFILE="$HOME/Library/Logs/sshfs-watchdog.log"
STATE_DIR="$HOME/Library/Caches/sshfs-watchdog"
# ────────────────────────────────────────────────────────────────────

mkdir -p "$(dirname "$LOGFILE")" "$STATE_DIR"

ts() { date -u "+%Y-%m-%dT%H:%M:%SZ"; }
log() { echo "$(ts)  $*" >> "$LOGFILE"; }

LOCK_FILE="$STATE_DIR/watchdog.pid"
COUNTER_FILE="$STATE_DIR/watchdog.counter"
LIVENESS_STATE_FILE="$STATE_DIR/liveness-fails"

# Self-terminate after 45s so a hung run never stacks up with the next tick.
( sleep 45 && kill -9 $$ 2>/dev/null ) &
WATCHDOG_PID=$!
disown "$WATCHDOG_PID" 2>/dev/null || true
trap 'kill "$WATCHDOG_PID" 2>/dev/null; rm -f "$LOCK_FILE" 2>/dev/null' EXIT

# PID-file lock. A live holder younger than 5 min wins (skip this tick);
# a holder hung longer than that gets killed and replaced.
acquire_lock_or_exit() {
  if [ -f "$LOCK_FILE" ]; then
    local old_pid etime secs=0 parts
    old_pid=$(cat "$LOCK_FILE" 2>/dev/null)
    if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
      etime=$(ps -p "$old_pid" -o etime= 2>/dev/null | tr -d ' ')
      [ -z "$etime" ] && exit 0
      # etime format: [[DD-]HH:]MM:SS
      case "$etime" in
        *-*) secs=$(( ${etime%%-*} * 86400 )); etime="${etime#*-}" ;;
      esac
      IFS=':' read -ra parts <<< "$etime"
      case ${#parts[@]} in
        3) secs=$((secs + parts[0]*3600 + parts[1]*60 + parts[2])) ;;
        2) secs=$((secs + parts[0]*60 + parts[1])) ;;
        1) secs=$((secs + parts[0])) ;;
      esac
      if [ "$secs" -lt 300 ]; then
        exit 0
      fi
      log "previous invocation (pid=$old_pid) hung for ${secs}s - killing it"
      kill -9 "$old_pid" 2>/dev/null || true
      sleep 1
    elif [ -n "$old_pid" ]; then
      log "stale lock found (pid=$old_pid not running) - clearing"
    fi
  fi
  echo "$$" > "$LOCK_FILE"
}
acquire_lock_or_exit

# Heartbeat every 10th invocation so the log shows the script is alive.
counter=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
counter=$((counter + 1))
echo "$counter" > "$COUNTER_FILE"
if [ $((counter % 10)) -eq 0 ]; then
  log "heartbeat: invocation #$counter"
fi

# Liveness probe: readdir the mount root with a timeout. A zombie macFUSE
# mount still passes `mount` and `stat` checks but hangs on readdir.
is_live() {
  local TIMEOUT
  if [ -x /opt/homebrew/bin/timeout ]; then
    TIMEOUT=/opt/homebrew/bin/timeout
  elif [ -x /opt/homebrew/bin/gtimeout ]; then
    TIMEOUT=/opt/homebrew/bin/gtimeout
  elif [ -x /usr/local/bin/timeout ]; then
    TIMEOUT=/usr/local/bin/timeout
  else
    /bin/ls "$MOUNTPOINT" >/dev/null 2>&1
    return $?
  fi
  "$TIMEOUT" 2 /bin/ls "$MOUNTPOINT" >/dev/null 2>&1
}

read_fails() {
  [ -f "$LIVENESS_STATE_FILE" ] && cat "$LIVENESS_STATE_FILE" || echo 0
}

if mount | grep -q " on $MOUNTPOINT "; then
  if is_live; then
    echo 0 > "$LIVENESS_STATE_FILE"
    exit 0
  fi
  # Two consecutive failures before declaring stale; a single slow tick
  # over a flaky link is not enough to trigger a remount.
  fails=$(read_fails)
  fails=$((fails + 1))
  echo "$fails" > "$LIVENESS_STATE_FILE"
  if [ "$fails" -lt 2 ]; then
    log "liveness check failed (#$fails) - waiting one more tick"
    exit 0
  fi
  log "stale mount detected (failed liveness $fails consecutive checks); force-unmounting"
  /usr/sbin/diskutil unmount force "$MOUNTPOINT" >>"$LOGFILE" 2>&1 \
    || /sbin/umount -f "$MOUNTPOINT" >>"$LOGFILE" 2>&1 \
    || log "force-unmount FAILED - manual intervention needed"
  echo 0 > "$LIVENESS_STATE_FILE"

  # Kill orphaned sshfs / mount_macfuse processes for this mountpoint.
  # Only safe here, after the mount has been declared stale.
  pkill -9 -f "sshfs .*:$REMOTE_PATH $MOUNTPOINT" 2>/dev/null || true
  pkill -9 -f "mount_macfuse.*$MOUNTPOINT" 2>/dev/null || true

  sleep 1
fi

# ── remount path ────────────────────────────────────────────────────

# LAN route first if configured, ssh alias as fallback.
TARGET=""
if [ -n "$LAN_IP" ] && [ -n "$SSH_ALIAS_LAN" ] \
   && nc -z -w 2 "$LAN_IP" 22 >/dev/null 2>&1; then
  TARGET="$SSH_ALIAS_LAN"
elif ssh -o ConnectTimeout=5 -o BatchMode=yes "$SSH_ALIAS" 'true' 2>/dev/null; then
  TARGET="$SSH_ALIAS"
else
  log "server not reachable, skipping"
  exit 0
fi

# Half-connected sshfs orphans block fresh mounts. Safe to kill here:
# this path only runs when nothing is mounted.
pkill -9 -f "sshfs .*:$REMOTE_PATH" 2>/dev/null || true

# macFUSE workaround: mounting onto a previously-used-then-emptied path
# can fail silently. Recreating the empty directory gives it a fresh
# inode and the mount succeeds. rmdir refuses non-empty directories, so
# this never removes real content.
if [ -d "$MOUNTPOINT" ] && [ -z "$(ls -A "$MOUNTPOINT" 2>/dev/null)" ]; then
  rmdir "$MOUNTPOINT" 2>/dev/null && log "recreated mountpoint inode"
fi
mkdir -p "$MOUNTPOINT"

SSHFS=$(command -v sshfs || true)
[ -z "$SSHFS" ] && { log "sshfs not found"; exit 1; }

# ServerAliveInterval=30 x CountMax=10 = 5 min tolerance before SSH
# declares the link dead, so brief blips do not kill the mount.
"$SSHFS" "$TARGET":"$REMOTE_PATH" "$MOUNTPOINT" \
  -o reconnect \
  -o defer_permissions \
  -o noappledouble \
  -o follow_symlinks \
  -o kernel_cache \
  -o auto_cache \
  -o cache_timeout=20 \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=10 \
  -o volname="$(basename "$MOUNTPOINT")" \
  2>>"$LOGFILE"

# The mount can register a moment after sshfs returns; check twice.
if mount | grep -q " on $MOUNTPOINT " || { sleep 3; mount | grep -q " on $MOUNTPOINT "; }; then
  log "mounted ok"
else
  log "mount FAILED"
fi
# src: ezra, contact: ezkru69@mail.com
