aboutsummaryrefslogtreecommitdiff
path: root/straper/doctor.sh
blob: 6aed53e519bec9499de446a172bc02970965c42f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#!/usr/bin/env bash
set -Eeuo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"

SCRIPT_NAME="doctor.sh"
STRICT=false
OUTFILE=""

usage() {
  cat <<USAGE
Usage: sudo ./doctor.sh [options]

Purpose:
  Read-only health and readiness checks for a rebuilt sanctum/labunix host.

Options:
  --db-dir PATH
  --role lab|hardware|replacement
  --strict            Exit non-zero if any FAIL is found
  --output PATH       Write human report to this path
  --dry-run           Still read-only; only affects report/state writes
  --help
USAGE
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --db-dir) DB_DIR="$2"; PUBLIC_DIR="${DB_DIR}/db/public"; SECRET_DIR="${DB_DIR}/db/secret"; shift ;;
    --role) ROLE="$2"; shift ;;
    --strict) STRICT=true ;;
    --output) OUTFILE="$2"; shift ;;
    --dry-run) DRY_RUN=true ;;
    --help) usage; exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
  shift
done

ensure_root
load_optional_config
ensure_runtime_dirs
require_cmd ip systemctl ss awk sed grep

OUTFILE="${OUTFILE:-${LOG_DIR}/doctor-${RUN_ID}.txt}"
: > "${OUTFILE}"

FAILS=0
WARNS=0
OKS=0
MANUALS=0

emit() {
  printf '%s\n' "$*" | tee -a "${OUTFILE}"
}

ok_line() {
  ((OKS+=1))
  emit "OK    | $1"
  report "${SCRIPT_NAME}" doctor "$1" check ok "$1" ""
}
warn_line() {
  ((WARNS+=1))
  emit "WARN  | $1"
  report "${SCRIPT_NAME}" doctor "$1" check warn "$1" ""
}
fail_line() {
  ((FAILS+=1))
  emit "FAIL  | $1"
  report "${SCRIPT_NAME}" doctor "$1" check failed "$1" ""
}
manual_line() {
  ((MANUALS+=1))
  emit "MANUAL| $1"
  report "${SCRIPT_NAME}" doctor "$1" manual manual "$1" ""
}

service_report() {
  local svc="$1"
  local unit="$svc"
  local load active enabled

  [[ "${unit}" == *.* ]] || unit="${unit}.service"

  load="$(systemctl show -P LoadState "${unit}" 2>/dev/null || true)"
  if [[ -z "${load}" || "${load}" == "not-found" ]]; then
    warn_line "service ${unit} not installed"
    return 0
  fi

  active="$(systemctl show -P ActiveState "${unit}" 2>/dev/null || true)"
  enabled="$(systemctl show -P UnitFileState "${unit}" 2>/dev/null || true)"

  if [[ "${active}" == "active" ]]; then
    ok_line "service ${unit} active (${enabled})"
  else
    warn_line "service ${unit} ${active:-unknown} (${enabled:-unknown})"
  fi
}

emit "labunix/sanctum rebuild doctor"
emit "timestamp: $(now_iso)"
emit "role: ${ROLE}"
emit "db: ${DB_DIR}"
emit ""

virt="$(detect_virtualization)"
if [[ -n ${virt} ]]; then
  warn_line "virtualization detected: ${virt}"
else
  ok_line "bare metal or virtualization not detected"
fi

current_host="$(hostname -s 2>/dev/null || true)"
captured_host="$(head -n1 "${PUBLIC_DIR}/system/etc-hostname" 2>/dev/null || true)"
if [[ -n ${captured_host} && ${current_host} == ${captured_host} ]]; then
  ok_line "hostname matches DB (${current_host})"
elif [[ -n ${captured_host} ]]; then
  warn_line "hostname differs: current=${current_host} captured=${captured_host}"
else
  warn_line "captured hostname not found in DB"
fi

if has_default_route; then
  ok_line "default route present"
else
  fail_line "no default route"
fi

iface="$(primary_iface)"
src_ip="$(primary_src_ip)"
[[ -n ${iface} ]] && ok_line "primary route uses iface=${iface} src=${src_ip}" || fail_line "could not determine primary interface via ip route get 1.1.1.1"

if can_reach_ip 1.1.1.1; then
  ok_line "outbound IP reachability to 1.1.1.1"
else
  fail_line "cannot reach 1.1.1.1"
fi

if can_resolve_name deb.debian.org; then
  ok_line "DNS resolves deb.debian.org"
else
  fail_line "DNS resolution failed for deb.debian.org"
fi

owner="$(guess_dns_owner)"
case "${owner}" in
  resolved) ok_line "DNS owner appears to be systemd-resolved" ;;
  dnsmasq)  ok_line "DNS owner appears to be dnsmasq" ;;
  unbound)  ok_line "DNS owner appears to be unbound" ;;
  chain)    warn_line "DNS owner appears chained (dnsmasq + unbound); verify port ownership intentionally" ;;
  *)        warn_line "DNS owner unclear" ;;
esac

failed_units="$(systemctl list-units --state=failed --no-legend --plain 2>/dev/null | awk '{print $1}')"
if [[ -z ${failed_units} ]]; then
  ok_line "no failed systemd units"
else
  fail_line "failed units present: $(echo "${failed_units}" | paste -sd, -)"
fi

# Config validators
have_cmd sshd && validate_sshd && ok_line "sshd config validates" || warn_line "sshd config validation failed or sshd missing"
have_cmd nginx && validate_nginx && ok_line "nginx config validates" || warn_line "nginx config validation failed or nginx missing"
have_cmd nft && [[ -f /etc/nftables.conf ]] && validate_nft && ok_line "nftables config validates" || warn_line "nftables config validation failed or /etc/nftables.conf missing"
have_cmd unbound-checkconf && validate_unbound && ok_line "unbound config validates" || warn_line "unbound config validation failed or unbound missing"
have_cmd dnsmasq && validate_dnsmasq && ok_line "dnsmasq config validates" || warn_line "dnsmasq config validation failed or dnsmasq missing"
have_cmd postfix && validate_postfix && ok_line "postfix config validates" || warn_line "postfix config validation failed or postfix missing"

emit ""
emit "Service snapshot"
service_report ssh
service_report systemd-resolved
service_report dnsmasq
service_report unbound
service_report nftables
service_report nginx
service_report mariadb
service_report postfix
service_report prosody
service_report docker
service_report prometheus
service_report grafana-server
service_report tor
service_report i2pd

emit ""
emit "Listening ports snapshot"
ss -tlnp 2>/dev/null | tee -a "${OUTFILE}" >/dev/null || true

if have_cmd docker; then
  emit ""
  emit "Docker snapshot"
  docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | tee -a "${OUTFILE}" >/dev/null || warn_line "docker present but docker ps failed"
fi

if [[ -d ${PUBLIC_DIR}/docker/compose-redacted || -d ${SECRET_DIR}/docker/compose-full ]]; then
  local_count="$(find /srv /opt /home /root -maxdepth 4 \( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) 2>/dev/null | wc -l | tr -d ' ')"
  ok_line "compose files found locally: ${local_count}"
fi

if [[ -f /etc/resolv.conf ]]; then
  emit ""
  emit "/etc/resolv.conf"
  sed -n '1,20p' /etc/resolv.conf | tee -a "${OUTFILE}" >/dev/null || true
fi

if [[ -f ${STATE_DIR}/state-${RUN_ID}.env ]]; then
  manual_line "current run state file: ${STATE_DIR}/state-${RUN_ID}.env"
fi

emit ""
emit "Summary: ok=${OKS} warn=${WARNS} fail=${FAILS} manual=${MANUALS}"

set_state doctor_done true
report "${SCRIPT_NAME}" run finish exit ok "ok=${OKS}; warn=${WARNS}; fail=${FAILS}; manual=${MANUALS}" ""

if [[ ${STRICT} == true && ${FAILS} -gt 0 ]]; then
  exit 1
fi