#!/usr/bin/env sh
#
# scripts/vpn/connect.sh: standalone copy
#
# Generated from https://hutzelmann.github.io/thi-linux-macos-setup/
# Community-maintained and unofficial. Not THI IT Support. No warranty.
#
# Read it before running it. Every script here supports --dry-run, which prints
# what it would do and changes nothing.
#
# Released into the public domain under CC0 1.0.

# --- documented values, baked in at build time ----------------------------
# Generated from facts/*.yaml. Do not edit here: change the value in that
# repository and the next build regenerates this file.
#
# fact_env is consulted first, exactly as scripts/lib/facts.sh does it, so a
# downloaded script honours FACT_<DOMAIN>_<KEY> like the repository one and a
# substituted value shows up in the result either way.
fact() {
  if fact_env "$1" "$2"; then
    return 0
  fi

  case "$1 $2" in
    'network registration_form_url') printf '%s\n' 'https://forms.office.com/e/rhkQsdbp2R' ;;
    'network registration_validity') printf '%s\n' '1 year' ;;
    'network official_url') printf '%s\n' 'https://mythi.de/index.php?kc=13&pid=moid&objectid=61' ;;
    'network wired_eap') printf '%s\n' 'PEAP' ;;
    'network wired_phase2') printf '%s\n' 'MSCHAPv2' ;;
    'network wired_ca') printf '%s\n' 'HARICA TLS RSA Root CA 2021' ;;
    'network wired_domain_suffix') printf '%s\n' 'rz.fh-ingolstadt.de' ;;
    'network wired_server_name') printf '%s\n' 'rz-radius-01.rz.fh-ingolstadt.de' ;;
    'network wired_official_pdf') printf '%s\n' 'https://mythi.de/kcdownload/kc_13/fileid_285/802.1X-Konfiguration-LAN.pdf' ;;
    'official service_url') printf '%s\n' 'https://www.thi.de/service/it-service/' ;;
    'official kb_url') printf '%s\n' 'https://help.thi.de/help/de-de' ;;
    'official support_mail') printf '%s\n' 'support@thi.de' ;;
    'official ticket_url') printf '%s\n' 'https://help.thi.de/' ;;
    'official department') printf '%s\n' 'Zentraler IT Service' ;;
    'printing server') printf '%s\n' 'rz-print-marb.rz.fh-ingolstadt.de' ;;
    'printing server_previous') printf '%s\n' 'marb-mfp.rz.fh-ingolstadt.de' ;;
    'printing queue') printf '%s\n' 'MARB-color' ;;
    'printing queue_students') printf '%s\n' 'STUD-color' ;;
    'printing model') printf '%s\n' 'Kyocera TASKalfa 6054ci' ;;
    'printing smb_domain') printf '%s\n' 'ESPL_001' ;;
    'printing ppd_path') printf '%s\n' '/usr/share/kyocera10.0/ppd10.0/Kyocera_TASKalfa_6054ci.ppd' ;;
    'printing ppd_model') printf '%s\n' 'kyocera/Kyocera_TASKalfa_6054ci.ppd' ;;
    'printing driver_url') printf '%s\n' 'https://www.kyoceradocumentsolutions.us/en/support/downloads.name-L3VzL2VuL21mcC9UQVNLQUxGQTYwNTRDSQ==.html#tab=driver' ;;
    'printing driver_deb') printf '%s\n' 'Ubuntu/EU/kyodialog_amd64/kyodialog_10.0-0_amd64.deb' ;;
    'printing driver_aur') printf '%s\n' 'kyocera-cups' ;;
    'printing driver_aur_url') printf '%s\n' 'https://aur.archlinux.org/packages/kyocera-cups' ;;
    'printing job_retention') printf '%s\n' 'approximately 24 hours' ;;
    'printing webprint_url') printf '%s\n' 'https://print.thi.de' ;;
    'printing official_url') printf '%s\n' 'https://www.thi.de/service/it-service/drucken/' ;;
    'project repo') printf '%s\n' 'hutzelmann/thi-linux-macos-setup' ;;
    'project stale_after_days') printf '%s\n' '180' ;;
    'shares home_server') printf '%s\n' 'rz-home.rz.fh-ingolstadt.de' ;;
    'shares file_server') printf '%s\n' 'rz-fs.rz.fh-ingolstadt.de' ;;
    'shares research_server') printf '%s\n' 'carissma.rz.fh-ingolstadt.de' ;;
    'shares domain') printf '%s\n' 'ESPL_001' ;;
    'shares official_url') printf '%s\n' 'https://help.thi.de/help/de-de/6-netzlaufwerke' ;;
    'vpn host') printf '%s\n' 'vpn.thi.de' ;;
    'vpn port') printf '%s\n' '443' ;;
    'vpn auth') printf '%s\n' 'saml' ;;
    'vpn issuer') printf '%s\n' 'GEANT TLS ECC 1' ;;
    'vpn root_ca') printf '%s\n' 'HARICA TLS ECC Root CA 2021' ;;
    'vpn intermediate_url') printf '%s\n' 'http://crt.harica.gr/HARICA-GEANT-TLS-E1.cer' ;;
    'vpn intermediate_file') printf '%s\n' 'HARICA-GEANT-TLS-E1.pem' ;;
    'vpn ca_bundle') printf '%s\n' '/etc/openfortivpn/ca-bundle.pem' ;;
    'vpn system_roots') printf '%s\n' '/etc/ssl/certs/ca-certificates.crt' ;;
    'vpn macos_root_keychain') printf '%s\n' '/System/Library/Keychains/SystemRootCertificates.keychain' ;;
    'vpn official_url') printf '%s\n' 'https://www.thi.de/service/it-service/vpn/' ;;
    'wifi eduroam_ssid') printf '%s\n' 'eduroam' ;;
    'wifi eduroam_eap') printf '%s\n' 'PEAP' ;;
    'wifi eduroam_phase2') printf '%s\n' 'MSCHAPv2' ;;
    'wifi eduroam_ca') printf '%s\n' 'HARICA TLS RSA Root CA 2021' ;;
    'wifi eduroam_ca_path_arch') printf '%s\n' '/etc/ca-certificates/extracted/cadir/HARICA_TLS_RSA_Root_CA_2021.pem' ;;
    'wifi eduroam_ca_path_debian') printf '%s\n' '/etc/ssl/certs/HARICA_TLS_RSA_Root_CA_2021.pem' ;;
    'wifi eduroam_domain_suffix') printf '%s\n' 'rz.fh-ingolstadt.de' ;;
    'wifi eduroam_realm') printf '%s\n' 'thi.de' ;;
    'wifi cat_url') printf '%s\n' 'https://cat.eduroam.org/' ;;
    'wifi cat_profile') printf '%s\n' '5182' ;;
    'wifi thi_ssid') printf '%s\n' '@thi' ;;
    'wifi thi_eap') printf '%s\n' 'TTLS' ;;
    'wifi thi_phase2') printf '%s\n' 'MSCHAPv2' ;;
    'wifi thi_ca') printf '%s\n' 'HARICA TLS RSA Root CA 2021' ;;
    'wifi thi_domain_suffix') printf '%s\n' 'rz.fh-ingolstadt.de' ;;
    'wifi onboard_ssid') printf '%s\n' 'thi-onboard' ;;
    'wifi official_url') printf '%s\n' 'https://www.thi.de/service/it-service/wifi/' ;;
    *) echo "unknown fact: $1 $2" >&2; return 1 ;;
  esac
}

# --- shared helpers -------------------------------------------------------
# Shared helpers. Sourceable without side effects: define, never execute.
#
# Every script here follows the same shape so that tests can source it and call
# individual functions. Nothing runs until main() is called behind a guard.

DRY_RUN=${DRY_RUN:-0}
JSON=${JSON:-0}
REPORT=${REPORT:-0}

# Where captured evidence is written, or empty for not capturing any.
# Set by --evidence; see evidence_open below for why this exists at all.
EVIDENCE_DIR=${EVIDENCE_DIR:-}

log() {
  [ "$JSON" = "1" ] && return 0
  printf '%s\n' "$*"
}

warn() {
  printf 'warning: %s\n' "$*" >&2
}

die() {
  printf 'error: %s\n' "$*" >&2
  exit 1
}

# run <command...>: execute, or print it under --dry-run.
#
# Dry-run output is exactly what the page tells the reader to type, which is
# what makes it testable off-campus: assert the emitted command matches the
# documented one.
run() {
  if [ "$DRY_RUN" = "1" ]; then
    printf '  %s\n' "$*"
    return 0
  fi
  "$@"
}

have() {
  command -v "$1" >/dev/null 2>&1
}

# resolved_addresses <host>: what this machine's resolver answers, verbatim.
#
# Captured as evidence rather than reduced to yes or no, because "the name
# resolves" and "the name resolves to the address it used to" are different
# observations and only the second one catches a server being moved. Four
# implementations because a stock macOS has no getent and a stock Arch has no
# host.
resolved_addresses() {
  if have getent; then
    getent ahosts "$1" 2>/dev/null
  elif have dscacheutil; then
    dscacheutil -q host -a name "$1" 2>/dev/null
  elif have host; then
    host "$1" 2>/dev/null
  else
    nslookup "$1" 2>/dev/null
  fi
}

# --- evidence -----------------------------------------------------------------
#
# A check that prints pass or fail can only be debugged by the person standing
# on the network it failed on. That is the expensive part of this project: the
# campus is a place you have to travel to, and a failure you did not record in
# full is a second trip.
#
# So every check can also write down what it saw: the whole certificate chain,
# not "verifies: no"; the resolved addresses, not "reachable: no"; the printer's
# attributes, not "queue: missing". Those go to files rather than into the JSON
# result, because they are multi-line and because the result is meant to stay
# small enough to paste.
#
# Nothing is uploaded. The directory is left on the machine that made it, and
# the closing note says to read it before it goes anywhere, because it is the
# richest thing this repository can produce about somebody's computer.

# evidence_open <domain>: start a capture directory, if --evidence asked for one.
#
# Called by main() before any observation. A no-op when capture is off, so the
# evidence calls below can stand unguarded in the body of a check.
evidence_open() {
  [ -n "$EVIDENCE_DIR" ] || return 0

  if [ "$EVIDENCE_DIR" = "auto" ]; then
    EVIDENCE_DIR="./evidence-$1-$(date +%Y%m%d-%H%M%S)"
  fi

  mkdir -p "$EVIDENCE_DIR" || die "cannot write to $EVIDENCE_DIR"

  {
    printf 'What this is\n'
    printf '\n'
    printf 'Output captured by %s on %s.\n' "${0##*/}" "$(date +%Y-%m-%d)"
    printf 'Operating system: %s\n' "$(detect_os)"
    _overrides=$(facts_overridden)
    [ -n "$_overrides" ] &&
      printf 'Documented values replaced for this run: %s\n' "$_overrides"
    printf '\n'
    printf 'Usernames, home paths and hardware addresses have been replaced with\n'
    printf 'placeholders. Nothing else has. Read these files before you attach them\n'
    printf 'to anything public: this is a description of your machine, and only you\n'
    printf 'can tell whether some line of it names you.\n'
  } >"$EVIDENCE_DIR/README.txt"
}

# evidence <name>: write stdin to <name>.txt in the capture directory.
#
# Reads stdin either way, so a pipeline into it behaves the same whether or not
# capture is on and a check cannot accidentally block on an unread pipe.
evidence() {
  if [ -z "$EVIDENCE_DIR" ]; then
    cat >/dev/null
    return 0
  fi
  redact >"$EVIDENCE_DIR/$1.txt"
}

# evidence_stream <name>: like evidence, but appends.
#
# For a check that observes the same kind of thing several times over, one file
# per host would be a directory nobody reads. Same file, one block per subject.
evidence_stream() {
  if [ -z "$EVIDENCE_DIR" ]; then
    cat >/dev/null
    return 0
  fi
  redact >>"$EVIDENCE_DIR/$1.txt"
}

# evidence_close: say where it went, once, at the end of a readable run.
evidence_close() {
  [ -n "$EVIDENCE_DIR" ] || return 0
  [ "$JSON" = "1" ] && return 0
  printf '\n'
  printf 'What this run observed is written out in full here:\n'
  printf '  %s\n' "$EVIDENCE_DIR"
  printf 'Read it before attaching it to an issue. It describes your machine.\n'
}

# --- documented values, and running against something else --------------------
#
# fact_env <domain> <key>: print an override from the environment, or fail.
#
# Any documented value can be replaced for one run by setting FACT_<DOMAIN>_<KEY>
# uppercased: FACT_VPN_CA_BUNDLE, FACT_PRINTING_SERVER. Both the repository's
# fact() (scripts/lib/facts.sh) and the generated one in the standalone
# downloads consult this first, so the two behave the same.
#
# This exists so a check can run without changing the machine it documents.
# Verifying the VPN bundle from off campus otherwise means writing to /etc, and
# the container that exercises the print queue has no campus print server to
# point at. The documented value stays the default, so an override is only ever
# visible in the command that set it.
fact_env() {
  # The name is built into an eval below. Keys in facts/*.yaml are [a-z0-9_] by
  # contract (AGENTS.md), so anything else is a caller bug, not a lookup.
  case "$1$2" in
    *[!a-z0-9_]*) return 1 ;;
  esac

  _name=$(printf 'FACT_%s_%s' "$1" "$2" | tr '[:lower:]' '[:upper:]')
  # shellcheck disable=SC2154  # _value is assigned by the eval on the line above
  eval "_value=\${${_name}-}"
  [ -n "$_value" ] || return 1

  printf '%s\n' "$_value"
}

# facts_overridden: the overrides in force, as "domain.key", space separated.
#
# Read back out of the environment rather than recorded as fact_env runs,
# because fact() is almost always called inside a command substitution and a
# variable set in a subshell does not survive it.
#
# Everything that reports a result says what was substituted. A run against a
# replaced value observed something real, but it did not observe what the page
# documents, and the two must not be able to look alike. Splitting at the first
# underscore is exact while every facts/ domain is one word, which is what the
# filenames are.
facts_overridden() {
  env |
    sed -n 's/^FACT_\([A-Z0-9]\{1,\}\)_\([A-Z0-9_]\{1,\}\)=..*/\1.\2/p' |
    tr '[:upper:]' '[:lower:]' |
    sort |
    tr '\n' ' ' |
    sed 's/ *$//'
}


detect_os() {
  case "$(uname -s)" in
    Darwin) printf 'macos\n' ;;
    Linux)
      if [ -f /etc/os-release ]; then
        # shellcheck disable=SC1091
        . /etc/os-release
        case "${ID:-}${ID_LIKE:-}" in
          *arch*) printf 'arch\n' ;;
          *debian*|*ubuntu*) printf 'debian\n' ;;
          *) printf 'unknown\n' ;;
        esac
      else
        printf 'unknown\n'
      fi
      ;;
    *) printf 'unknown\n' ;;
  esac
}

# Strip anything that identifies a person before results are pasted into a
# public issue. Contributors should not have to remember this.
#
# A hardware address is on this list because of the wired page: the campus
# registers a MAC against a person for a year, so a MAC in a public issue is
# that person's name with an extra step. The reader still sees their own in the
# readable output, which is theirs to look at; only what leaves the machine is
# replaced. Same for the 802.1X identity, which is the campus username itself.
redact() {
  sed -E \
    -e "s#/home/[^/ ]+#/home/<kennung>#g" \
    -e "s#/Users/[^/ ]+#/Users/<kennung>#g" \
    -e "s#[A-Za-z0-9._%+-]+@(thi|fh-ingolstadt)\.de#<vorname.nachname>@thi.de#g" \
    -e "s#(user|username|-U)[= ][A-Za-z0-9._-]+#\1=<kennung>#g" \
    -e "s#([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}#<mac>#g" \
    -e "s#(identity|anonymous-identity)[:= ]+[A-Za-z0-9._@-]+#\1=<kennung>#g"
}

# json_result <status> <message> [key=value ...]
#
# Stamps any environment override into every result, in one place, so no caller
# can produce a result that hides which value it actually used.
json_result() {
  _status="$1"
  _message="$2"
  shift 2
  printf '{"status":"%s","message":"%s"' "$_status" "$_message"
  for _pair in "$@"; do
    printf ',"%s":"%s"' "${_pair%%=*}" "${_pair#*=}"
  done
  _overrides=$(facts_overridden)
  [ -n "$_overrides" ] && printf ',"facts_overridden":"%s"' "$_overrides"
  printf '}\n'
}

parse_common_args() {
  for _arg in "$@"; do
    case "$_arg" in
      --dry-run) DRY_RUN=1 ;;
      --json) JSON=1 ;;
      --report) REPORT=1 ;;
      --evidence) EVIDENCE_DIR=auto ;;
      --evidence=*) EVIDENCE_DIR=${_arg#--evidence=} ;;
    esac
  done
}

# Percent-encode one argument.
#
# Hand-rolled because the payload has to survive a URL on a stock macOS shell,
# where there is no jq and no python guaranteed. Slow, and it only ever runs on
# a few hundred characters when somebody asks to file a report.
urlencode() {
  _string="$1"
  while [ -n "$_string" ]; do
    _char=$(printf '%s' "$_string" | cut -c1)
    case "$_char" in
      [a-zA-Z0-9.~_-]) printf '%s' "$_char" ;;
      *) printf '%%%02X' "'$_char" ;;
    esac
    _string=$(printf '%s' "$_string" | cut -c2-)
  done
}

# report_url <page> <outcome> <json>: a check-record form, already filled in.
#
# The reader has followed a page and the script knows almost everything the form
# asks for: which page, which OS, what date, and what it observed. Asking them
# to retype it is how a report stops being worth filing.
#
# Prints only. Nothing is sent, and the contributor sees the whole payload in
# the address bar before deciding to publish it. This repository is public and
# the payload describes their machine, so that order matters.
report_url() {
  _page="$1"
  _outcome="$2"
  _json="$3"
  _repo=$(fact project repo) || return 1

  case "$(detect_os)" in
    arch) _os='Arch Linux' ;;
    debian) _os='Debian / Ubuntu' ;;
    macos) _os='macOS' ;;
    *) _os='Other Linux' ;;
  esac

  printf 'https://github.com/%s/issues/new?template=check-record.yml' "$_repo"
  printf '&labels=check-record'
  printf '&page=%s' "$(urlencode "$_page")"
  printf '&os=%s' "$(urlencode "$_os")"
  printf '&date=%s' "$(urlencode "$(date +%Y-%m-%d)")"
  # Prefilled only on a clean pass. A failing script may mean the page is wrong,
  # or may only mean the reader is off campus, and it cannot tell the two apart.
  [ -n "$_outcome" ] && printf '&outcome=%s' "$(urlencode "$_outcome")"
  printf '&report=%s' "$(urlencode "$_json")"
  printf '\n'
}

# report_outcome <status>: the form's outcome field, or nothing.
#
# Prefilled only on a clean pass. A failing script may mean the page is wrong,
# or may only mean the reader is off campus, and it cannot tell those apart. An
# unset dropdown asks the one question only the person who followed the page can
# answer; a wrong default would be answered by reflex.
#
# An overridden value is the same situation with the doubt made explicit: the
# run passed against a value the page does not document, so it says nothing
# about whether the page works. Never prefill an outcome from one.
report_outcome() {
  [ "$1" = ok ] || return 0
  [ -z "$(facts_overridden)" ] || return 0
  # The dropdown option verbatim, German gloss included, because GitHub selects
  # an option by exact text and would otherwise leave the field empty. The gloss
  # is taken off again by stripGloss() in tools/check-record.mjs.
  printf 'Worked exactly as written (hat genau so funktioniert)\n'
}

# print_report <page> <outcome> <json>: the closing block of verify.sh --report.
print_report() {
  printf '\n'
  printf 'File this as a check record. Nothing is sent until you press submit:\n\n'
  printf '  %s\n\n' "$(report_url "$1" "$2" "$3")"
  printf 'The form arrives filled in. Correct the outcome if the page misled you.\n'

  _overrides=$(facts_overridden)
  if [ -n "$_overrides" ]; then
    printf '\n'
    printf 'This run replaced documented values from the environment:\n'
    printf '  %s\n' "$_overrides"
    printf 'It checked those, not the ones on the page, so the outcome is left\n'
    printf 'unset and this is not a check record for the page as written.\n'
  fi
}

# --- script ---------------------------------------------------------------
# Connect to the campus VPN, with a readable diagnosis when the bundle is missing.
#
# Usage:
#   ./connect.sh              connect (stays in foreground; Ctrl+C disconnects)
#   ./connect.sh --dry-run    print what would run
#
# The gateway omits its intermediate certificate, so a stock client fails with a
# TLS error that reads like a local misconfiguration. openfortivpn is pointed at
# a bundle of its own instead (--ca-file), which leaves the system trust store
# alone. Checking that bundle first turns the TLS error into an actionable one.
set -eu


# Does the documented bundle exist and does the gateway verify against it?
bundle_verifies() {
  _bundle=$(fact vpn ca_bundle)
  [ -r "$_bundle" ] || return 1

  _host=$(fact vpn host)
  _port=$(fact vpn port)
  _leaf=$(mktemp)
  echo | timeout 15 openssl s_client -connect "${_host}:${_port}" \
    -servername "$_host" 2>/dev/null |
    openssl x509 -out "$_leaf" 2>/dev/null || true

  if [ -s "$_leaf" ] && openssl verify -CAfile "$_bundle" "$_leaf" >/dev/null 2>&1; then
    rm -f "$_leaf"
    return 0
  fi
  rm -f "$_leaf"
  return 1
}

explain_missing_bundle() {
  cat <<TXT
$(fact vpn ca_bundle) is missing or does not verify $(fact vpn host).

This is expected until the bundle is built once: the gateway does not send its
intermediate certificate. The bundle is $(fact vpn issuer), from

  $(fact vpn intermediate_url)

followed by the system root certificates. See the VPN page for the commands for
your system. Nothing outside openfortivpn is changed by this, and the bundle
outlives the gateway certificate.
TXT
}

main() {
  parse_common_args "$@"

  have openfortivpn || die "openfortivpn not installed."

  if [ "$DRY_RUN" != "1" ] && ! bundle_verifies; then
    explain_missing_bundle
    exit 1
  fi

  log "Connecting to $(fact vpn host). openfortivpn prints an SSO URL below."
  log "Open it in a browser yourself: nothing opens it for you. The connection"
  log "continues here once the login is done."
  log "Keep this running; Ctrl+C disconnects."
  run sudo openfortivpn "$(fact vpn host)" --saml-login \
    --ca-file="$(fact vpn ca_bundle)"
}

# Entry point. Guarded so tests can source this file and call individual
# functions; the glob also matches the standalone build, which is named
# <domain>-connect.sh.
case "${0##*/}" in
  *connect.sh) main "$@" ;;
esac
