#!/bin/bash
# Keep a Kerberos ticket cache alive for the duration of a login session.
#
# Runs forever, checking every CHECK_INTERVAL seconds how much longer the
# krbtgt ticket in the default cache is valid for. Once less than
# RENEW_THRESHOLD seconds remain, it first tries a silent `kinit -R`
# (renew using the existing ticket -- no password needed, only works while
# still inside the renewable lifetime). If that fails -- renewable
# lifetime exhausted, cache empty, wrong/no ticket for PRINCIPAL -- it
# falls back to a GUI password prompt (zenity) and does a full `kinit`.
# Status is echoed to stdout and mirrored via notify-send so it's visible
# even when nothing is watching the log.
#
# Defaults to the generic gac-bernina account; override with
# KRB_PRINCIPAL or -p <principal> to manage a different one.
#
# RENEW_THRESHOLD default: confirmed via check_kerberos_renewal that PSI's
# actual ticket lifetime here is only ~10h (renewable for 7 days) -- the
# old 24h default was longer than that entire lifetime, so every single
# hourly check always concluded "renewal needed" and silently renewed +
# notified every hour forever. 2h leaves several retry cycles of buffer
# before real expiry while only actually renewing (and notifying) roughly
# once per ticket lifetime instead of on every check.

set -uo pipefail  # no -e: one failed klist/kinit must not kill the loop

PRINCIPAL="${KRB_PRINCIPAL:-gac-bernina}"
CHECK_INTERVAL="${KRB_CHECK_INTERVAL:-3600}"   # seconds between checks (1h)
RENEW_THRESHOLD="${KRB_RENEW_THRESHOLD:-7200}" # renew once <2h remain
ONCE=0

usage() {
    cat >&2 <<EOF
Usage: $(basename "$0") [-p principal] [-i check-interval-s] [-t renew-threshold-s] [-1]

  -p  Kerberos principal to manage (default: \$KRB_PRINCIPAL or gac-bernina)
  -i  seconds between validity checks (default: \$KRB_CHECK_INTERVAL or 3600)
  -t  renew once fewer than this many seconds remain (default: \$KRB_RENEW_THRESHOLD or 7200)
  -1  check once and exit instead of looping (for testing)
EOF
    exit 1
}

while getopts "p:i:t:1h" opt; do
    case "$opt" in
        p) PRINCIPAL="$OPTARG" ;;
        i) CHECK_INTERVAL="$OPTARG" ;;
        t) RENEW_THRESHOLD="$OPTARG" ;;
        1) ONCE=1 ;;
        h|*) usage ;;
    esac
done

# Singleton: an accidental second autostart (or a manual re-run) must not
# stack up duplicate loops/notifications. XDG_RUNTIME_DIR is private to the
# user (mode 700), so it's safe against the /tmp symlink races a fixed
# world-writable path would invite.
LOCKFILE="${XDG_RUNTIME_DIR:-/tmp}/kerberos_ticket_renew.lock"
exec 9>"$LOCKFILE"
flock -n 9 || { echo "already running (lock held: $LOCKFILE)" >&2; exit 0; }

notify() {
    local urgency="$1" title="$2" body="$3"
    echo "$(date '+%F %T') $title: $body"
    command -v notify-send >/dev/null 2>&1 && notify-send -u "$urgency" "$title" "$body"
}

# Epoch seconds the krbtgt ticket in the default cache expires at, or
# nothing on stdout if there isn't one. Rebuilds the date as ISO 8601
# ourselves so parsing doesn't depend on locale/date-format guessing --
# klist's date format itself DOES depend on locale (confirmed: en_IE.UTF-8
# prints "DD/MM/YY", LANG=C or unset prints "MM/DD/YY"), and a background
# process started at login may not inherit the same locale an interactive
# shell has. Force LC_ALL=C on klist so the format is always the same
# regardless of caller's environment, instead of guessing which one applies.
krb_tgt_expiry_epoch() {
    local expiry_field m d y
    expiry_field=$(LC_ALL=C klist 2>/dev/null | awk '/krbtgt\// {print $3, $4; exit}')
    [ -n "$expiry_field" ] || return 1
    IFS='/' read -r m d y <<<"${expiry_field%% *}"
    date -d "20${y}-${m}-${d} ${expiry_field#* }" +%s 2>/dev/null
}

# Human-readable new expiry for the notification, or "unknown" if the
# post-renewal klist/parse didn't work for some reason -- never let a
# formatting failure mask that the renewal itself actually succeeded.
new_expiry_str() {
    local epoch
    epoch=$(krb_tgt_expiry_epoch) && date -d "@${epoch}" '+%Y-%m-%d %H:%M' || echo "unknown"
}

# Seconds -> "Xd Yh" (or "Yh Zm" once under a day) -- raw seconds like
# "32155s" isn't a useful thing to read in a notification.
format_duration() {
    local secs="$1" d h m
    d=$(( secs / 86400 ))
    h=$(( (secs % 86400) / 3600 ))
    if [ "$d" -gt 0 ]; then
        echo "${d}d ${h}h"
    else
        m=$(( (secs % 3600) / 60 ))
        echo "${h}h ${m}m"
    fi
}

renew_or_prompt() {
    local err
    if err=$(kinit -R "$PRINCIPAL" 2>&1); then
        notify low "Kerberos" "Ticket for $PRINCIPAL renewed silently (kinit -R). New expiry: $(new_expiry_str)."
        return 0
    fi

    local pass
    pass=$(zenity --password \
        --title="Kerberos ticket renewal" \
        --text="Kerberos ticket for $PRINCIPAL could not be renewed silently:\n$err\n\nEnter the password to renew it:" \
        2>/dev/null)
    if [ -z "$pass" ]; then
        notify critical "Kerberos" "Renewal cancelled or empty password -- ticket for $PRINCIPAL will expire."
        return 1
    fi

    if err=$(echo "$pass" | kinit "$PRINCIPAL" 2>&1); then
        notify normal "Kerberos" "Ticket for $PRINCIPAL renewed. New expiry: $(new_expiry_str)."
        pass=""
        return 0
    else
        notify critical "Kerberos" "kinit failed for $PRINCIPAL: $err"
        pass=""
        return 1
    fi
}

check_once() {
    local expiry remaining
    if ! expiry=$(krb_tgt_expiry_epoch); then
        notify critical "Kerberos" "No valid ticket found for $PRINCIPAL -- renewing."
        renew_or_prompt
        return
    fi

    remaining=$(( expiry - $(date +%s) ))
    if [ "$remaining" -lt "$RENEW_THRESHOLD" ]; then
        notify low "Kerberos" "Ticket for $PRINCIPAL has $(format_duration "$remaining") left (<$(format_duration "$RENEW_THRESHOLD")) -- renewing."
        renew_or_prompt
    fi
}

if [ "$ONCE" -eq 1 ]; then
    check_once
    exit 0
fi

while true; do
    check_once
    sleep "$CHECK_INTERVAL"
done
