#!/bin/bash
# uhhm-corp-setup: enrol this machine as a uhhm corp device.
# Idempotent; run as root (via sudo, so the invoking user is known). Steps:
#   1. join the headscale VPN (vpn.uhhm.no) via tailscale
#   2. ensure the [uhhm] pacman repo + its signing key (shipped keyring)
#   3. wire kanidm into NSS and PAM, start kanidm-unixd
#   4. FS-Cache: start cachefilesd where the kernel supports it, else drop the
#      `fsc` option from the mount units (kernels without cachefiles fail it)
#   5. create the invoking user's machine-local toolchain dirs in /var/local
#   6. (--with-nfs-home) automount /home from ergo over the VPN. A local /home
#      (btrfs subvolume etc.) blocks this; --take-over-home comments it out of
#      /etc/fstab (data untouched) and asks for a reboot.
#   7. (--with-basement) mount the basement archive from kasse (on demand)
#
# --undo reverses 3, 6 and 7 (PAM, NSS, mounts) so the package can be removed
# without leaving a login stack that points at kanidm.
# --status checks a machine without changing anything.
set -euo pipefail

[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }

PAM=/etc/pam.d/system-auth
# 0.2.12: the automount units are gone. /home is a static, on-demand
# home.mount that logind pulls in per session (see files/home.mount).
STALE_AUTOMOUNTS=(home.automount home-bl-basement.automount)

undo() {
    echo "==> mounts: stopping the NFS home and archive (if mounted)"
    systemctl disable "${STALE_AUTOMOUNTS[@]}" 2>/dev/null || true   # pre-0.2.12
    systemctl stop home-bl-basement.mount 2>/dev/null || true
    echo "    /home stays mounted while sessions are using it; it is gone after a reboot"

    echo "==> PAM: removing pam_kanidm from $PAM"
    if grep -q pam_kanidm "$PAM"; then
        cp "$PAM" "$PAM.pre-uhhm-undo"
        sed -i '/pam_kanidm\.so/d' "$PAM"
        echo "    removed (backup at $PAM.pre-uhhm-undo)"
    fi

    echo "==> NSS: removing kanidm from /etc/nsswitch.conf"
    sed -i -E '/^(passwd|group):/s/ kanidm\b//' /etc/nsswitch.conf

    # fsc_dropin is defined further down, so clean these up directly.
    rm -f /etc/systemd/system/home.mount.d/10-no-fsc.conf \
          /etc/systemd/system/home-bl-basement.mount.d/10-no-fsc.conf
    rmdir /etc/systemd/system/home.mount.d /etc/systemd/system/home-bl-basement.mount.d 2>/dev/null || true

    echo "==> kanidm-unixd: stopping"
    systemctl disable --now kanidm-unixd-tasks kanidm-unixd 2>/dev/null || true

    if [ -e /etc/fstab.pre-uhhm-corp ]; then
        echo "==> NOTE: --take-over-home commented out your local /home in /etc/fstab."
        echo "    Restore it with: cp /etc/fstab.pre-uhhm-corp /etc/fstab (then reboot)"
    fi
    echo "==> done. Left in place: tailscale (\`tailscale logout\` to leave the VPN)"
    echo "    and the [uhhm] repo in /etc/pacman.conf."
}

# --status: check everything that has silently broken a corp machine before.
# Never exits non-zero on its own findings; it prints and returns a count.
_ok()   { echo "  [ ok ] $*"; }
# stdout, not stderr: --status is a report, and mixing streams scrambles the
# order of the sections when it is piped.
_warn() { echo "  [warn] $*"; _issues=$((_issues + 1)); }

status() {
    _issues=0
    echo "== VPN"
    local have
    have=$(tailscale debug prefs 2>/dev/null | jq -r '.ControlURL // empty' 2>/dev/null || true)
    if [ "$have" = "https://vpn.uhhm.no" ]; then
        _ok "control plane $have ($(tailscale ip -4 2>/dev/null | head -1))"
    else
        _warn "control plane is '${have:-none}', expected https://vpn.uhhm.no"
    fi

    echo "== identity"
    for u in kanidm-unixd kanidm-unixd-tasks; do
        systemctl -q is-active "$u" && _ok "$u running" || _warn "$u not running"
    done
    # pam_kanidm falls back to /etc/passwd when the socket is unreachable, which
    # surfaces as "Authentication service cannot retrieve authentication info".
    [ -S /run/kanidm-unixd/sock ] && _ok "resolver socket present" \
        || _warn "no /run/kanidm-unixd/sock - pam_kanidm will fall back to /etc/passwd"
    kanidm-unix status >/dev/null 2>&1 && _ok "resolver reaches kanidm" \
        || _warn "kanidm-unix status failed (server unreachable?)"
    local n
    n=$(grep -c pam_kanidm "$PAM" 2>/dev/null || true); n=${n:-0}
    [ "$n" -ge 4 ] && _ok "pam_kanidm wired ($n lines)" || _warn "pam_kanidm missing from $PAM ($n lines) - re-run uhhm-corp-setup"
    grep -qE '^passwd:.*\bkanidm\b' /etc/nsswitch.conf && _ok "nsswitch has kanidm" \
        || _warn "nsswitch.conf is missing kanidm"
    local shell
    shell=$(sed -n 's/^default_shell *= *"\(.*\)".*/\1/p' /etc/kanidm/unixd 2>/dev/null | head -1)
    if [ -z "$shell" ] || [ -x "$shell" ]; then
        _ok "default_shell ${shell:-unset} present"
    else
        _warn "default_shell $shell missing - logins authenticate then fail"
    fi

    echo "== collisions (local entries shadow kanidm: files comes first)"
    # A local entry only matters when kanidm claims the same id: then `files`
    # wins and the kanidm account/group is shadowed (the `alarm` at uid 1000
    # case). Local ids kanidm knows nothing about are fine.
    local id name hit clash=""
    while IFS=: read -r name _ id _; do
        [ "$id" -ge 1000 ] 2>/dev/null && [ "$id" -lt 65000 ] || continue
        hit=$(getent -s kanidm passwd "$id" 2>/dev/null | cut -d: -f1)
        [ -n "$hit" ] && [ "$hit" != "$name" ] && clash="$clash $name(uid $id, kanidm: $hit)"
    done < /etc/passwd
    [ -z "$clash" ] && _ok "no local user shadows a kanidm uid" \
        || _warn "local users shadowing kanidm:$clash - userdel/usermod -u"
    clash=""
    while IFS=: read -r name _ id _; do
        [ "$id" -ge 1000 ] 2>/dev/null && [ "$id" -lt 65000 ] || continue
        hit=$(getent -s kanidm group "$id" 2>/dev/null | cut -d: -f1)
        [ -n "$hit" ] && [ "$hit" != "$name" ] && clash="$clash $name(gid $id, kanidm: $hit)"
    done < /etc/group
    [ -z "$clash" ] && _ok "no local group shadows a kanidm gid" \
        || _warn "local groups shadowing kanidm:$clash - groupdel -f, then kanidm-unix cache-invalidate"

    echo "== admin rights"
    if [ -r /etc/sudoers.d/10-uhhm-corp ]; then
        getent group admins >/dev/null 2>&1 \
            && _ok "sudo rule present and 'admins' resolves ($(getent group admins | cut -d: -f3))" \
            || _warn "sudo rule present but 'admins' does not resolve - kanidm group posix set admins"
    else
        _warn "/etc/sudoers.d/10-uhhm-corp missing"
    fi

    echo "== /home"
    local st
    st=$(systemctl is-enabled home.mount 2>&1 || true)
    case "$st" in
        masked*) _warn "home.mount is $st - systemctl unmask${st#masked} home.mount" ;;
        *)       _ok "home.mount not masked ($st)" ;;
    esac
    local src
    src=$(findmnt -no SOURCE /home 2>/dev/null || true)
    if [ -z "$src" ]; then
        _ok "/home not mounted (logind mounts it at login)"
    elif [ "$src" = "ergo.ts.uhhm.no:/home" ]; then
        _ok "/home from $src"
    elif awk '$1 !~ /^#/ && $2 == "/home" { found = 1 } END { exit !found }' /etc/fstab; then
        _warn "/home is a local mount from fstab ($src) - see --take-over-home"
    else
        _ok "/home is $src (not from ergo; fine on the home server itself)"
    fi

    echo "== FS-Cache"
    if systemctl -q is-active cachefilesd; then
        [ -e /etc/systemd/system/home.mount.d/10-no-fsc.conf ] \
            && _warn "cachefilesd runs but fsc is still dropped - re-run uhhm-corp-setup" \
            || _ok "cachefilesd running, fsc enabled"
    else
        [ -e /etc/systemd/system/home.mount.d/10-no-fsc.conf ] \
            && _ok "no cachefilesd; fsc dropped from the mounts (as intended)" \
            || _warn "cachefilesd not running but mounts still ask for fsc"
    fi

    echo
    [ "$_issues" -eq 0 ] && echo "==> all checks passed" || echo "==> $_issues issue(s) above"
}

WITH_NFS_HOME=0
WITH_BASEMENT=0
TAKE_OVER_HOME=0
HOME_PENDING=0
for arg in "$@"; do
    case "$arg" in
        --with-nfs-home) WITH_NFS_HOME=1 ;;
        --take-over-home) TAKE_OVER_HOME=1 ;;   # comment out a local /home in fstab
        --with-basement) WITH_BASEMENT=1 ;;   # kasse archive; only once kasse is on the tailnet
        --undo) undo; exit 0 ;;
        --status) status; exit 0 ;;
        *) echo "unknown option: $arg (use --with-nfs-home, --take-over-home," >&2
           echo "   --with-basement, --status or --undo)" >&2; exit 1 ;;
    esac
done

echo "==> VPN: joining headscale at vpn.uhhm.no"
systemctl enable --now tailscaled
WANT="https://vpn.uhhm.no"
# Check the actual control plane, not just "tailscaled is running": a machine
# already logged into commercial Tailscale passes `tailscale status` yet is
# NOT on our VPN. Switching control planes needs --force-reauth.
HAVE=$(tailscale debug prefs 2>/dev/null | jq -r '.ControlURL // empty' 2>/dev/null || true)
if [ "$HAVE" != "$WANT" ]; then
    echo "    control plane is '${HAVE:-none}', moving to $WANT"
    # HEADSCALE_AUTHKEY (preauth key) makes this non-interactive; otherwise
    # it prints a Kanidm OIDC URL to complete in a browser. The key goes via
    # a private file, not argv, so it never shows up in `ps`.
    authargs=()
    if [ -n "${HEADSCALE_AUTHKEY:-}" ]; then
        keyfile=$(mktemp)
        trap 'rm -f "$keyfile"' EXIT
        printf '%s' "$HEADSCALE_AUTHKEY" >"$keyfile"
        authargs=(--auth-key "file:$keyfile")
    else
        # --qr also renders the login URL as a QR code: scan it with a phone
        # that is already signed into Kanidm and the node registers itself via
        # OIDC. No `headscale nodes register` on the server.
        authargs=(--qr)
    fi
    tailscale up --login-server="$WANT" --accept-dns --accept-routes --force-reauth \
        "${authargs[@]}"
else
    echo "    already on $WANT ($(tailscale ip -4 2>/dev/null | head -1))"
fi
# A failed join must NOT abort the run: NSS/PAM/kanidm-unixd are what make
# logins work, and leaving them unconfigured (especially right after --undo)
# locks the user out of their own machine. Only the NFS home needs the tailnet.
VPN_OK=1
if ! tailscale status >/dev/null 2>&1; then
    VPN_OK=0
    echo "    WARN: VPN join did not complete; continuing with identity setup." >&2
    echo "          Join later with: tailscale up --login-server=$WANT --qr" >&2
fi

echo "==> pacman: ensure the [uhhm] package registry + its signing key"
if ! grep -q '^\[uhhm\]' /etc/pacman.conf; then
    cat >> /etc/pacman.conf <<'PAC'

[uhhm]
SigLevel = Required DatabaseOptional
Server = https://project.uhhm.no/api/packages/bl/arch/$repo/$arch
PAC
    echo "    added [uhhm] repo"
fi
# The key ships in this package (/usr/share/pacman/keyrings/uhhm*), so no
# trust-on-first-use download; the install script also populates it.
pacman-key --populate uhhm >/dev/null \
    && echo "    uhhm registry key trusted" \
    || echo "    WARN: pacman-key --populate uhhm failed (is the keyring initialised?)"

echo "==> NSS: adding kanidm to /etc/nsswitch.conf"
for db in passwd group; do
    if ! grep -qE "^${db}:.*\bkanidm\b" /etc/nsswitch.conf; then
        sed -i "s/^${db}:.*/& kanidm/" /etc/nsswitch.conf
        echo "    ${db}: + kanidm"
    fi
done

echo "==> PAM: wiring pam_kanidm into system-auth"
if ! grep -q pam_kanidm "$PAM"; then
    cp "$PAM" "$PAM.pre-uhhm-corp"
    sed -i \
        -e '/^auth.*pam_unix.so/i auth       sufficient                  pam_kanidm.so ignore_unknown_user' \
        -e '/^account.*pam_unix.so/i account    [default=bad success=ok user_unknown=ignore] pam_kanidm.so' \
        -e '/^password.*pam_unix.so/i password   sufficient                  pam_kanidm.so ignore_unknown_user' \
        -e '/^session.*pam_unix.so/i session    optional                    pam_kanidm.so' \
        "$PAM"
    echo "    patched (backup at $PAM.pre-uhhm-corp)"
else
    echo "    already wired"
fi
# system-auth belongs to pambase; an upgrade that changes it lands as .pacnew
# WITHOUT our lines. Merging it naively drops kanidm from the login stack.
if [ -e "$PAM.pacnew" ]; then
    echo "    WARN: $PAM.pacnew exists. After merging it, re-run uhhm-corp-setup"
    echo "          to re-add the pam_kanidm lines."
fi

# Two things that let a user authenticate and then fail to get a session.
echo "==> sanity checks for kanidm logins"
_shell=$(sed -n 's/^default_shell *= *"\(.*\)".*/\1/p' /etc/kanidm/unixd | head -1)
if [ -n "$_shell" ] && [ ! -x "$_shell" ]; then
    echo "    WARN: default_shell $_shell does not exist on this machine." >&2
    echo "          kanidm logins authenticate and then fail. Install it." >&2
fi
# nsswitch has `files` before `kanidm`, so a local /etc/passwd entry wins every
# lookup. A local user with the SAME name and uid as the kanidm account is fine
# (that is how ergo is set up). A local user with a DIFFERENT name on the same
# uid is not: uid lookups resolve to that name, and the kanidm user
# authenticates but cannot get a session of its own.
_owner="${SUDO_USER:-}"
_clash=$(awk -F: -v me="$_owner" '$3 >= 1000 && $3 < 65000 && $1 != me \
    { print "      " $1 " (uid " $3 ", gid " $4 ")" }' /etc/passwd)
if [ -n "$_clash" ]; then
    echo "    WARN: local /etc/passwd users that are not ${_owner:-the corp user}:" >&2
    printf '%s\n' "$_clash" >&2
    echo "          If one of these holds a uid a kanidm account also uses (1000 is" >&2
    echo "          the usual collision), that account cannot start a session here." >&2
    echo "          Renumber or remove it:  usermod -u <free-uid> -g <free-gid> <name>" >&2
fi
# Deleting such a user leaves its group behind, still holding the gid. Then
# `id <corp user>` reports the stale group name and files look mis-owned.
# groupdel refuses ("primary group of user") because the kanidm account now has
# that gid, so it needs -f.
_gclash=$(awk -F: -v me="$_owner" '$3 >= 1000 && $3 < 65000 && $1 != me \
    { print "      " $1 " (gid " $3 ")" }' /etc/group)
if [ -n "$_gclash" ]; then
    echo "    WARN: local /etc/group groups that are not ${_owner:-the corp user}:" >&2
    printf '%s\n' "$_gclash" >&2
    echo "          One holding a kanidm gid (1000) shadows the kanidm group:" >&2
    echo "            groupdel -f <name>   # -f: it looks like a primary group" >&2
    echo "            kanidm-unix cache-invalidate" >&2
fi

echo "==> kanidm-unixd"
systemctl enable --now kanidm-unixd kanidm-unixd-tasks

# The `fsc` mount option needs the kernel's cachefiles backend. Some kernels
# (e.g. linux-asahi) ship without it, and then cachefilesd cannot bind a cache
# AND an fsc mount fails outright — so drop fsc from the mount units there.
# Both directions are idempotent, so a kernel change is picked up on re-run.
fsc_dropin() {   # $1 = unit, $2 = on|off
    local dir f opts
    dir="/etc/systemd/system/$1.d"
    f="$dir/10-no-fsc.conf"
    if [ "$2" = off ]; then
        opts=$(sed -n 's/^Options=//p' "/usr/lib/systemd/system/$1" | sed -e 's/,fsc//' -e 's/fsc,//')
        install -dm755 "$dir"
        printf '# Written by uhhm-corp-setup: this kernel has no cachefiles backend.\n[Mount]\nOptions=%s\n' \
            "$opts" > "$f"
    else
        rm -f "$f"
        rmdir "$dir" 2>/dev/null || true
    fi
}

echo "==> NFS FS-Cache (local disk cache for the NFS mounts)"
modprobe -q cachefiles 2>/dev/null || true
if [ -e /dev/cachefiles ] || [ -e /proc/fs/cachefiles ] || [ -d /sys/module/cachefiles ]; then
    install -dm700 /var/cache/fscache   # cachefilesd refuses to start without it
    if systemctl list-unit-files cachefilesd.service >/dev/null 2>&1 &&
       systemctl enable --now cachefilesd 2>/dev/null; then
        echo "    cachefilesd running; mounts keep the fsc option"
        fsc_dropin home.mount on
        fsc_dropin home-bl-basement.mount on
    else
        echo "    WARN: cachefilesd not installed or failed to start; dropping fsc"
        fsc_dropin home.mount off
        fsc_dropin home-bl-basement.mount off
    fi
else
    echo "    no cachefiles backend in this kernel ($(uname -r)); dropping fsc"
    echo "    from the NFS mounts (they work fine without the local cache)"
    fsc_dropin home.mount off
    fsc_dropin home-bl-basement.mount off
fi
systemctl daemon-reload

# Machine-local, arch-specific toolchains. The shared fish config sets
# CARGO_HOME=/var/local/cargo, RUSTUP_HOME=/var/local/rustup and
# PNPM_HOME=/var/local/pnpm-<arch>, so on a mixed-arch fleet each machine
# keeps its own compiled toolchains outside the NFS home. ~/.local/bin stays
# roamed and is for arch-independent scripts only. These paths are shared by
# design (one toolchain per machine), owned by the user who ran the setup.
echo "==> machine-local toolchain dirs in /var/local"
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
    install -dm755 -o "$SUDO_USER" -g "$(id -g "$SUDO_USER")" \
        /var/local/cargo /var/local/rustup "/var/local/pnpm-$(uname -m)"
    echo "    owned by $SUDO_USER"
else
    echo "    skipped: run via sudo so the owning user is known"
fi

# A local /home (btrfs subvolume, LVM, whatever) blocks the NFS home twice
# over: home.automount refuses to start when /home is already a mount point,
# and an /etc/fstab entry for /home generates a home.mount in
# /run/systemd/generator that takes priority over our packaged unit. So the
# local mount has to leave fstab before /home can come from ergo. Nothing is
# reformatted or deleted: the local filesystem keeps its data, it just stops
# being mounted at /home.
free_local_home() {
    local src fstype opts
    findmnt -n /home >/dev/null 2>&1 || return 0
    fstype=$(findmnt -no FSTYPE /home)
    src=$(findmnt -no SOURCE /home)
    opts=$(findmnt -no OPTIONS /home)
    case "$fstype" in nfs|nfs4) return 0 ;; esac   # already ours

    # For btrfs, quote the subvolume back so the suggested mount command is
    # the exact one (a bare `mount <dev>` would give the top-level subvolume).
    local mountopt=""
    case "$opts" in
        *subvol=*) mountopt="-o subvol=${opts##*subvol=}"; mountopt="${mountopt%%,*}" ;;
    esac

    if ! awk '$1 !~ /^#/ && $2 == "/home" { found = 1 } END { exit !found }' /etc/fstab; then
        echo "    /home is mounted from $src ($fstype) but not via /etc/fstab;" >&2
        echo "    unmount it (or remove whatever mounts it) and re-run." >&2
        return 1
    fi

    if [ "$TAKE_OVER_HOME" -eq 0 ]; then
        cat >&2 <<EOM
    /home is a local mount: $src ($fstype)
      options: $opts
    The NFS home cannot be enabled while that is in /etc/fstab. Re-run with
    --take-over-home to comment out the /home line (backup: /etc/fstab.pre-uhhm-corp),
    then reboot and run this again. The local data is untouched and stays
    reachable, e.g.:
      mkdir -p /mnt/local-home && mount $mountopt $src /mnt/local-home
EOM
        return 1
    fi

    echo "    commenting out the local /home in /etc/fstab (was: $src $fstype)"
    cp /etc/fstab /etc/fstab.pre-uhhm-corp
    awk '$1 !~ /^#/ && $2 == "/home" { print "# uhhm-corp: /home now comes from ergo over NFS"; print "#" $0; next } { print }' \
        /etc/fstab.pre-uhhm-corp > /etc/fstab
    systemctl daemon-reload
    echo "    done (backup at /etc/fstab.pre-uhhm-corp). REBOOT, then re-run"
    echo "    'uhhm-corp-setup --with-nfs-home' to enable the NFS automount."
    echo "    Local home data stays on $src; reach it later with:"
    echo "      mkdir -p /mnt/local-home && mount $mountopt $src /mnt/local-home"
    return 1   # /home is still mounted until the reboot
}

if [ "$WITH_NFS_HOME" -eq 1 ] && [ "$VPN_OK" -eq 0 ]; then
    echo "==> NFS home: skipped, this machine is not on the tailnet yet" >&2
    HOME_PENDING=1
elif [ "$WITH_NFS_HOME" -eq 1 ]; then
    echo "==> NFS home from ergo (shadows local /home!)"
    if free_local_home; then
        systemctl daemon-reload
        # Nothing to enable: logind requires /home/<user> for every session, so
        # home.mount is pulled in at login, after tailscaled is up. Enabling a
        # unit here would put /home in the boot transaction, which is what
        # deadlocked network bring-up before 0.2.9. Mount it now so the current
        # session sees it too.
        systemctl disable "${STALE_AUTOMOUNTS[@]}" 2>/dev/null || true   # pre-0.2.12 leftovers
        systemctl start home.mount
        echo "    mounted now, and on every login from here on (no unit enabled)"
    else
        echo "    NFS home NOT enabled (see above)." >&2
        HOME_PENDING=1
    fi
fi
if [ "$WITH_BASEMENT" -eq 1 ]; then
    echo "==> basement archive from kasse"
    systemctl daemon-reload
    if systemctl start home-bl-basement.mount; then
        echo "    mounted at /home/bl/basement"
    else
        echo "    WARN: could not mount (is kasse on the tailnet?)" >&2
    fi
    echo "    On demand only — it is not enabled, so it never gates boot:"
    echo "      systemctl start home-bl-basement.mount"
fi

echo "==> SSH: kanidm-authorised logins are configured (etc/ssh drop-ins)."
echo "    Each user should now give this machine its own key:  uhhm-ssh-enroll"
echo "    Break-glass: drop a local admin key in /etc/ssh/authorized_keys.d/<user>"
echo "    so the box stays reachable if kanidm or the NFS home are down at boot."
echo
echo "==> IMPORTANT: every corp user needs a Kanidm POSIX password (SEPARATE from"
echo "    their web password+TOTP), or pam_kanidm's account phase refuses ALL logins"
echo "    for that user here (even key-based SSH): 'administrator enforced password"
echo "    change'. Set it with:"
echo "        kanidm login -D <user> && kanidm person posix set-password <user>"
if [ "$HOME_PENDING" -eq 1 ]; then
    echo
    echo "==> NFS home is NOT active. See the note above (a local /home needs"
    echo "    --take-over-home + a reboot; a missing tailnet needs the VPN join),"
    echo "    then re-run with --with-nfs-home."
fi
echo "==> done. Verify with: kanidm login -D <user>; getent passwd <user>"
