#!/usr/bin/env sh
#
# scripts/network/verify.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 ---------------------------------------------------------------
# Check a campus Ethernet port: what the machine is set up to send, and what
# the port gave back.
#
# Usage:
#   ./verify.sh                    readable
#   ./verify.sh --interface=NAME   a particular wired interface
#   ./verify.sh --json             machine-readable, identifiers stripped
#   ./verify.sh --evidence         write down everything it saw, for debugging later
#
# A wired port is the one thing on this site that cannot be checked from
# anywhere else: you have to be standing next to it with a cable. So this
# script is written to be run once, on campus, and to record enough that the
# rest of the work can happen at a desk. --evidence is the point of it.
#
# Nothing is sent and no password is used. Every observation is of this machine:
# its own interfaces, its own NetworkManager profiles, its own addresses.
set -eu


# The page these checks belong to, for --report. Not derivable from the
# directory name: scripts/network/ documents /en/network/ethernet-802-1x.
REPORT_PAGE=/en/network/ethernet-802-1x

IFACE=${IFACE:-}

select_interface() {
  for _arg in "$@"; do
    case "$_arg" in
      --interface=*) IFACE=${_arg#--interface=} ;;
    esac
  done
  [ -n "$IFACE" ] || IFACE=$(first_wired)
}

# The first wired interface that is not something this machine made up.
#
# Container bridges, virtual machine taps and the veth halves of both are all
# `ethernet` to the kernel and to NetworkManager, and a laptop that runs either
# has more of them than it has sockets. Matching the kernel's own naming is
# cruder than asking NetworkManager and works on a machine that does not run it.
first_wired() {
  [ -d /sys/class/net ] || return 0
  for _path in /sys/class/net/*; do
    _name=${_path##*/}
    case "$_name" in
      en* | eth*) ;;
      *) continue ;;
    esac
    # A physical port has a device behind it in the sysfs tree. A tap does not.
    [ -e "$_path/device" ] || continue
    printf '%s\n' "$_name"
    return 0
  done
}

carrier() {
  [ -r "/sys/class/net/$1/carrier" ] || return 1
  [ "$(cat "/sys/class/net/$1/carrier" 2>/dev/null)" = "1" ]
}

# Prints: assigned, self-assigned, or none.
#
# The page says the same thing in prose: an address means the port let you
# through, 169.254 means it did not, and nothing at all means the same. Which
# of the two you get is a detail of how long the machine waited.
#
# `assigned` and not `campus`: this machine cannot tell a campus address from a
# home router's, no documented range says which is which, and guessing one here
# would be the kind of plausible value rule 3 exists to keep out. Whether the
# port was a campus port is something the person holding the cable knows.
address_kind() {
  _addr=$(ip -4 -brief address show "$1" 2>/dev/null | awk '{print $3}')
  case "$_addr" in
    '') printf 'none\n' ;;
    169.254.*) printf 'self-assigned\n' ;;
    *) printf 'assigned\n' ;;
  esac
}

# Every NetworkManager profile that would answer an 802.1X port on this
# interface. Usually one, occasionally several left over from trying.
wired_8021x_profiles() {
  have nmcli || return 0
  nmcli -t -f NAME,TYPE connection show 2>/dev/null |
    while IFS=: read -r _name _type; do
      [ "$_type" = "802-3-ethernet" ] || continue
      [ -n "$(nm_field "$_name" 802-1x.eap)" ] || continue
      printf '%s\n' "$_name"
    done
}

nm_field() {
  nmcli -t -f "$2" connection show "$1" 2>/dev/null | cut -d: -f2- | head -1
}

# Prints: <eap-ok> <phase2-ok> <identity-ok> <ca-set> <suffix-set>
#
# The identity check is the one that earns its place. The page's own known
# quirks say wired wants the bare kennung and eduroam wants it with a realm,
# that mixing them up fails with no useful message, and a machine that has both
# profiles is exactly where that happens.
inspect_profile() {
  _eap=$(nm_field "$1" 802-1x.eap)
  _phase2=$(nm_field "$1" 802-1x.phase2-auth)
  _identity=$(nm_field "$1" 802-1x.identity)
  _ca=$(nm_field "$1" 802-1x.ca-cert)
  _suffix=$(nm_field "$1" 802-1x.domain-suffix-match)

  _eap_ok=no
  [ "$_eap" = "$(lower "$(fact network wired_eap)")" ] && _eap_ok=yes

  _phase2_ok=no
  [ "$_phase2" = "$(lower "$(fact network wired_phase2)")" ] && _phase2_ok=yes

  _identity_ok=yes
  case "$_identity" in
    '') _identity_ok=empty ;;
    *@*) _identity_ok=has-realm ;;
  esac

  _ca_set=no
  [ -n "$_ca" ] && _ca_set=yes

  # Three answers, not two. A suffix that is set but says something else is a
  # profile that will refuse the real server, and it fails in a way an empty
  # field never does: the port stops working rather than quietly accepting
  # anybody. Reporting both as `no` would send the reader to the wrong page.
  _suffix_set=no
  [ -n "$_suffix" ] && _suffix_set=other
  [ "$_suffix" = "$(fact network wired_domain_suffix)" ] && _suffix_set=yes

  printf '%s %s %s %s %s\n' \
    "$_eap_ok" "$_phase2_ok" "$_identity_ok" "$_ca_set" "$_suffix_set"
}

lower() {
  printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}

# Hardware addresses of the wired interfaces, for the registration path.
#
# One registration per address, which is the thing the page warns about and the
# thing people get wrong: a laptop with a built-in port and a dock is two
# submissions, and the dock's address only exists while it is plugged in.
wired_macs() {
  [ -d /sys/class/net ] || return 0
  for _path in /sys/class/net/*; do
    _name=${_path##*/}
    case "$_name" in
      en* | eth*) ;;
      *) continue ;;
    esac
    [ -e "$_path/device" ] || continue
    [ -r "$_path/address" ] || continue
    printf '%s %s\n' "$_name" "$(cat "$_path/address")"
  done
}

macos_main() {
  log "macOS keeps 802.1X in the network service, not in a file this can read."
  log "System Settings -> Network -> Ethernet -> Details -> 802.1X shows it."
  log ""
  log "Wired interfaces and their hardware addresses:"
  networksetup -listallhardwareports 2>/dev/null |
    awk '/Hardware Port: (Ethernet|Thunderbolt|USB)/,/^$/' |
    sed 's/^/  /' || true
  log ""
  log "Each address needs its own registration if this port checks addresses."

  networksetup -listallhardwareports 2>&1 | evidence hardware-ports
  ifconfig 2>&1 | evidence interfaces

  _json=$(json_result incomplete "network check" \
    "reason=macos_profile_not_readable" "os=macos" | redact)
  [ "$JSON" = "1" ] && printf '%s\n' "$_json"
  evidence_close
  # Not a failure and not a pass. Nothing was observed that could be either.
  exit 0
}

main() {
  parse_common_args "$@"
  evidence_open network

  [ "$(detect_os)" = macos ] && macos_main

  have ip || die "ip not found. This check assumes iproute2."

  select_interface "$@"
  [ -n "$IFACE" ] || die "no wired interface found. Pass --interface=NAME."

  _carrier=no
  carrier "$IFACE" && _carrier=yes
  _address=$(address_kind "$IFACE")

  _profiles=$(wired_8021x_profiles)
  _profile_count=$(printf '%s' "$_profiles" | grep -c . || true)

  _eap_ok=n/a
  _phase2_ok=n/a
  _identity_ok=n/a
  _ca_set=n/a
  _suffix_set=n/a

  if [ -n "$_profiles" ]; then
    # The first one. Several profiles for one port is itself worth reporting,
    # and the count in the result says so, but a check that averaged them would
    # describe a profile nobody has.
    _first=$(printf '%s\n' "$_profiles" | head -1)
    # shellcheck disable=SC2046  # deliberate word splitting of five fields
    set -- $(inspect_profile "$_first")
    _eap_ok=$1
    _phase2_ok=$2
    _identity_ok=$3
    _ca_set=$4
    _suffix_set=$5
  fi

  # Evidence, before any of it is reduced to a word.
  ip -brief address show 2>&1 | evidence interfaces
  ip -brief link show 2>&1 | evidence_stream interfaces
  printf '%s\n' "$_profiles" | while read -r _p; do
    [ -n "$_p" ] || continue
    {
      printf '=== %s ===\n' "$_p"
      nmcli connection show "$_p" 2>&1
      printf '\n'
    } | evidence_stream profiles
  done
  wired_macs | evidence hardware-addresses

  _status=ok
  [ "$_address" = assigned ] || _status=incomplete
  [ "$_identity_ok" = has-realm ] && _status=misconfigured
  [ "$_profile_count" -gt 0 ] && [ "$_eap_ok" = no ] && _status=misconfigured
  [ "$_profile_count" -gt 0 ] && [ "$_phase2_ok" = no ] && _status=misconfigured

  # Last, so it wins. A profile can be several kinds of wrong at once and the
  # detail lines say which; this is the one that decides whether the campus
  # password can be collected, so it is the one the single word should carry.
  [ "$_profile_count" -gt 0 ] && [ "$_ca_set" = no ] && _status=insecure
  [ "$_profile_count" -gt 0 ] && [ "$_suffix_set" != yes ] && _status=insecure

  # Built once, whoever asked for it. --json prints it, --report puts it in the
  # form, and both are the same observation so they cannot disagree.
  _json=$(json_result "$_status" "network check" \
    "carrier=$_carrier" "address=$_address" "profiles=$_profile_count" \
    "eap=$_eap_ok" "phase2=$_phase2_ok" "identity=$_identity_ok" \
    "ca_certificate=$_ca_set" "server_name_match=$_suffix_set" \
    "os=$(detect_os)" | redact)

  if [ "$JSON" = "1" ]; then
    printf '%s\n' "$_json"
    evidence_close
    [ "$_status" = ok ] || exit 1
    return 0
  fi

  log "Interface:             ${IFACE}"
  log "Cable carrying link:   ${_carrier}"
  log "Address from the port: ${_address}"
  log "802.1X profiles:       ${_profile_count}"

  if [ "$_profile_count" -gt 0 ]; then
    log "  Method matches:      ${_eap_ok}  (documented: $(fact network wired_eap))"
    log "  Inner method:        ${_phase2_ok}  (documented: $(fact network wired_phase2))"
    log "  Identity:            ${_identity_ok}"
    log "  CA certificate set:  ${_ca_set}"
    log "  Server name matched: ${_suffix_set}"
  fi

  log ""
  log "Hardware addresses, one registration each if this port checks them:"
  wired_macs | sed 's/^/  /'

  if [ "$_identity_ok" = has-realm ]; then
    log ""
    log "The identity carries a realm. Wired ports and $(fact wifi thi_ssid) want the bare"
    log "<kennung>; only eduroam wants it with @$(fact wifi eduroam_realm). This fails with"
    log "no useful message, so it is worth ruling out first."
  elif [ "$_carrier" = no ]; then
    log ""
    log "No link on ${IFACE}. Nothing below the cable can be observed from here."
  elif [ "$_address" = self-assigned ] || [ "$_address" = none ]; then
    log ""
    log "The port did not hand out an address. Either the 802.1X login was not"
    log "accepted, or this port checks hardware addresses and this one is not"
    log "registered. The page describes both paths."
  fi

  if [ "$_profile_count" -gt 0 ] && [ "$_ca_set" = no ]; then
    log ""
    log "No CA certificate on this profile. The port authenticates either way, and"
    log "so would one impersonating it, which is why this is worth setting rather"
    log "than leaving as the official guide leaves it. Documented: $(fact network wired_ca)."
  fi

  if [ "$_profile_count" -gt 0 ] && [ "$_suffix_set" = no ]; then
    log ""
    log "No server name to match. Naming a CA without one accepts any server that"
    log "authority ever signed. Documented: $(fact network wired_domain_suffix)."
  elif [ "$_profile_count" -gt 0 ] && [ "$_suffix_set" = other ]; then
    log ""
    log "The server name on this profile is not the documented one"
    log "($(fact network wired_domain_suffix)). A port that stopped working after"
    log "a change here is the expected result, not a coincidence."
  fi

  # After the advice, not instead of it: a report of what did not work is worth
  # filing too, and it is the report the page most needs.
  [ "$REPORT" = "1" ] && print_report "$REPORT_PAGE" "$(report_outcome "$_status")" "$_json"
  evidence_close

  [ "$_status" = ok ] || exit 1
}


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