diff --git a/README.md b/README.md index 24c5e3e..c7f3419 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Every module remains standalone: download only the check you need, verify its ch ## Safety contract - Active scripts are read-only checks. They do not install packages, restart services, delete mail, modify firewall rules, or rewrite configuration. -- Scripts do not intentionally display credentials, message contents, machine identifiers, or unredacted public network addresses. +- Default script output does not intentionally display credentials, message contents, machine identifiers, or unredacted public network addresses. Explicit `--show-mounts`, `--show-units`, `--show-names`, and `--show-paths` options reveal only the selected local metadata. - The master uses the checked-in `catalog.tsv` allowlist. It never sources modules, evaluates argument strings, downloads code, installs packages, or performs remediation. - Catalog paths are constrained to regular executable files under a real, non-linked `scripts/` directory; linked catalogs, module directories, and module files are rejected. - Remote commands download a version-tagged file, verify SHA-256, and execute only after verification succeeds. @@ -16,7 +16,7 @@ Every module remains standalone: download only the check you need, verify its ch ## Master `onescript` CLI -The master requires a complete verified checkout containing `onescript`, `catalog.tsv`, `SHA256SUMS`, and `scripts/`. Wave 0 is not part of the existing `v2.0.0` release; a version-pinned installation command will be added only when its release is approved and published. +The master requires a complete verified checkout containing `onescript`, `catalog.tsv`, `SHA256SUMS`, and `scripts/`. The launcher and Linux server diagnostics wave are v2.1.0 release candidates and are not part of the existing `v2.0.0` release; version-pinned installation commands will be added only when that release is approved and published. Verify the installed catalog: @@ -98,6 +98,92 @@ One-line invocation: (tmp_dir="$(mktemp -d)" && chmod 700 "$tmp_dir" && trap 'rm -rf "$tmp_dir"' EXIT && curl -fsSLo "$tmp_dir/exim-queue-health.sh" 'https://raw.githubusercontent.com/RomeoRaven/ONEScripts/v2.0.0/scripts/exim-queue-health.sh' && printf '%s %s\n' 'cf4606e3cd496dd9d8ca9db1281488507e3abeb2f45f460f1af070297a3fec22' "$tmp_dir/exim-queue-health.sh" | sha256sum -c - && chmod 700 "$tmp_dir/exim-queue-health.sh" && "$tmp_dir/exim-queue-health.sh") ``` +## Linux server diagnostics wave (v2.1.0 candidate) + +These modules are available from the current checkout. Findings use concise `key=value` output. A successful collection returns `0` even when `status=warning|critical|stale`; usage errors return `2`, and collection failures return `1`. + +### `filesystem-health.sh` + +Reports mounted-filesystem capacity and inode pressure by index, without device or mount names by default. Default warning and critical thresholds are 85% and 95%; use `--show-mounts` for local diagnosis. + +```bash +./scripts/filesystem-health.sh +``` + +### `memory-pressure.sh` + +Reports memory and swap use, one-minute load normalized by online CPU count, and Linux PSI values when available. It does not list processes or command lines. + +```bash +./scripts/memory-pressure.sh +``` + +### `service-health.sh` + +Reports aggregate systemd state and failed-unit count. Unit names remain omitted unless `--show-units` is explicitly supplied. + +```bash +./scripts/service-health.sh +``` + +### `listener-exposure.sh` + +Lists listener protocol, port, and bind scope (`loopback`, `specific-address`, or `all-interfaces`) without displaying literal addresses or process details. + +```bash +./scripts/listener-exposure.sh +``` + +### `update-posture.sh` + +Counts updates visible in existing APT or DNF metadata and checks the reboot-required marker. It never refreshes metadata, lists package names, installs updates, or reboots. + +```bash +./scripts/update-posture.sh +``` + +### `backup-freshness.sh` + +Finds the newest regular file among explicit files and immediate directory entries. It does not recurse and reports age/count only; use `--show-names` or `--show-paths` explicitly for local diagnosis. + +```bash +./scripts/backup-freshness.sh --max-hours 24 /srv/backups +``` + +### `endpoint-health.sh` + +Probes explicit HTTP(S) endpoints and reports status and latency by input index. URLs, query strings, headers, and response bodies are omitted. + +```bash +./scripts/endpoint-health.sh https://example.com/health +``` + +### `tls-expiry.sh` + +Verifies explicit TLS targets and reports certificate lifetime by input index. Hostnames, addresses, subjects, and issuers are omitted. + +```bash +./scripts/tls-expiry.sh example.com:443 +``` + +### `container-health.sh` + +Reports aggregate Docker or Podman running, stopped, and unhealthy counts. Names, images, IDs, ports, labels, and commands are omitted. If Docker is installed but unavailable, a usable Podman runtime is tried. + +```bash +./scripts/container-health.sh +``` + +### `log-storage.sh` + +Measures allocated size for explicit log directories, defaulting to `/var/log`, without reading log contents. Paths are omitted unless `--show-paths` is supplied. If permissions hide a child directory but `du` still returns a numeric total, the result is retained and marked `collection_complete=no`. + +```bash +./scripts/log-storage.sh +``` + +These modules intentionally have no remote one-line invocation until the v2.1.0 tag and release checksums are approved and published. + ## Direct module use Clone the repository and run a script directly: @@ -134,7 +220,7 @@ A new active module must: 4. Include behavioral coverage where practical. 5. Add one valid `catalog.tsv` row with a stable ID, category, display name, description, requirements summary, path, and `yes|no` safe-default flag. 6. Pass Bash parsing, ShellCheck, the repository test suite, launcher tests, and checksum verification. -7. Add the module checksum to `SHA256SUMS` and include one version-pinned, checksum-bound invocation in this README. +7. Add the module checksum to `SHA256SUMS`, document direct checkout use, and add a version-pinned checksum-bound remote invocation when the containing release is published. Ordinary module additions do not require launcher code changes. Use `menu_safe=no` when a module cannot run meaningfully or safely without explicit arguments; it remains available through direct execution and `onescript run`. diff --git a/SHA256SUMS b/SHA256SUMS index b39a3fd..0ed7a97 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,6 +1,16 @@ 877de32b38eaa36295cf16276e1073ddc306f9312663ee5291e5d44aed641c58 onescript -705c2d2342e4a6180a917d3de20e734fb9d312d827aba98cf5eb94507e9fdb0e catalog.tsv +2af806038a55f729f6cff932ea9ac825a9cb0318901176d023c5979d5a685325 catalog.tsv +d260a50a2fb3fa0af09edf310da8006246924cf4319a05ad2935c19fd91911ed scripts/vps-profile.sh 5bb1e14491437c42cf6d5169186a2545176dcbbf5ba4367d84aabf0e851f0a23 scripts/cpanel-followup-triage.sh 156ea405829ec5c27ed7a462a23e2fe9752704467c93fbc652aef205d24ca65d scripts/directory-sizes.sh cf4606e3cd496dd9d8ca9db1281488507e3abeb2f45f460f1af070297a3fec22 scripts/exim-queue-health.sh -d260a50a2fb3fa0af09edf310da8006246924cf4319a05ad2935c19fd91911ed scripts/vps-profile.sh +0b4608698315cf1ed19b532e453ff08fe9aeab93a0a8492793217c9002c5a12f scripts/filesystem-health.sh +859fcefc6b631f9b63e28db8c2f58372e55adfe567e70c3d2317e609b5543d59 scripts/memory-pressure.sh +c35c04f1f5f54eb945b46cbd6ebecb05146f02baf8f7de5bbe967c494ec5bb56 scripts/service-health.sh +a12029e59604066a57edf0bd12668a34031779d59838957d7991973580ea8c2f scripts/listener-exposure.sh +7e04e99afcaad2d704e69f5efc5e4b1866804f451cf90e730f83419b42ff1da9 scripts/update-posture.sh +fe3e47781e5694ebcc2c260df97bb3421fa5e13fa353c0e25e00c9ecffaf2a8b scripts/backup-freshness.sh +46d3961f9364b9ccf7cda154dcef52810855aaf6040ac08a416d641b014e84fb scripts/endpoint-health.sh +ea624496235f3d78cd38be3173f6bb18d898d7cc25b71fe36d678459691b61f0 scripts/tls-expiry.sh +6166f768a4f242db8e077438ebdc2736ffbafd5306dd0d1adbb1da7b0b75b26b scripts/container-health.sh +3eb2acfbbaf9643ebf50b7822fa6e6d7908f421abb7edfc2521ac5ebafa0422a scripts/log-storage.sh diff --git a/catalog.tsv b/catalog.tsv index 5299457..c609d45 100644 --- a/catalog.tsv +++ b/catalog.tsv @@ -3,3 +3,13 @@ vps-profile system VPS profile Privacy-reduced VPS and cPanel profile Bash 4+, s cpanel-followup-triage cpanel cPanel follow-up triage Focused cPanel service, mail queue, and listener triage Bash 4+, systemctl, ss; Exim utilities when available scripts/cpanel-followup-triage.sh yes directory-sizes storage Directory sizes Immediate subdirectory disk usage, largest first Bash 4+, find, du, sort scripts/directory-sizes.sh yes exim-queue-health mail Exim queue health Privacy-safe Exim queue counts Bash 4+, Exim; exiqgrep recommended scripts/exim-queue-health.sh yes +filesystem-health system Filesystem health Capacity and inode pressure without device names Bash 4+, GNU df scripts/filesystem-health.sh yes +memory-pressure system Memory pressure Memory, swap, load, and PSI summary Bash 4+, Linux procfs, awk scripts/memory-pressure.sh yes +service-health services Service health Aggregate systemd state and failed-unit count Bash 4+, systemctl scripts/service-health.sh yes +listener-exposure network Listener exposure Address-free listener port and scope inventory Bash 4+, ss scripts/listener-exposure.sh yes +update-posture packages Update posture Cached APT or DNF update and reboot posture Bash 4+, apt-get or dnf scripts/update-posture.sh yes +backup-freshness backup Backup freshness Newest explicit backup artifact age Bash 4+, find, stat, GNU date, mktemp scripts/backup-freshness.sh no +endpoint-health network Endpoint health HTTP status and latency without URL disclosure Bash 4+, curl scripts/endpoint-health.sh no +tls-expiry network TLS expiry TLS verification and expiry without target disclosure Bash 4+, OpenSSL, GNU date, timeout scripts/tls-expiry.sh no +container-health containers Container health Aggregate Docker or Podman container state Bash 4+; optional Docker or Podman scripts/container-health.sh yes +log-storage storage Log storage Allocated log-directory size without content reads Bash 4+, GNU du scripts/log-storage.sh yes diff --git a/scripts/backup-freshness.sh b/scripts/backup-freshness.sh new file mode 100755 index 0000000..aa0e417 --- /dev/null +++ b/scripts/backup-freshness.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Report freshness of explicitly selected backup files or directories. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +MAX_HOURS=24 +SHOW_PATHS=0 +SHOW_NAMES=0 +TARGETS=() + +usage() { + printf '%s\n' \ + 'Usage: backup-freshness.sh [--max-hours N] [--show-names] [--show-paths] FILE_OR_DIRECTORY...' \ + '' \ + 'Find the newest regular file among explicit files and immediate directory entries.' \ + 'Directories are not searched recursively. Artifact names and paths are omitted by default.' \ + '' \ + 'Options:' \ + ' --max-hours N Maximum acceptable age (default: 24)' \ + ' --show-names Include the latest basename in output' \ + ' --show-paths Include the latest full path and basename in output' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --max-hours) + (($# >= 2)) || { printf 'ERROR: --max-hours requires a value\n' >&2; exit 2; } + MAX_HOURS="$2" + shift + ;; + --show-names) SHOW_NAMES=1 ;; + --show-paths) SHOW_PATHS=1; SHOW_NAMES=1 ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + --) + shift + while (($#)); do TARGETS+=("$1"); shift; done + break + ;; + -*) printf 'ERROR: unsupported option: %s\n' "$1" >&2; usage >&2; exit 2 ;; + *) TARGETS+=("$1") ;; + esac + shift +done + +if ! MAX_HOURS="$(normalize_uint "$MAX_HOURS" 2147483647)" || ((MAX_HOURS < 1)); then + printf 'ERROR: --max-hours must be a positive integer\n' >&2 + exit 2 +fi +if ((${#TARGETS[@]} == 0)); then + printf 'ERROR: at least one backup file or directory is required\n' >&2 + exit 2 +fi + +FIND_BIN="${FIND_BIN:-$(command -v find 2>/dev/null || true)}" +if [[ -z "$FIND_BIN" || ! -x "$FIND_BIN" ]]; then + printf 'ERROR: find was not found\n' >&2 + exit 1 +fi +DISCOVERY_FILE='' +cleanup() { + [[ -z "$DISCOVERY_FILE" ]] || rm -f -- "$DISCOVERY_FILE" +} +trap cleanup EXIT + +candidates=() +for target in "${TARGETS[@]}"; do + [[ "$target" == -* ]] && target="./$target" + if [[ -L "$target" ]]; then + printf 'ERROR: linked backup targets are not accepted\n' >&2 + exit 1 + elif [[ -f "$target" ]]; then + candidates+=("$target") + elif [[ -d "$target" ]]; then + if [[ -z "$DISCOVERY_FILE" ]]; then + old_umask="$(umask)" + umask 077 + if ! DISCOVERY_FILE="$(mktemp)"; then + umask "$old_umask" + printf 'ERROR: backup discovery workspace could not be created\n' >&2 + exit 1 + fi + umask "$old_umask" + fi + if ! "$FIND_BIN" -P "$target" -mindepth 1 -maxdepth 1 -type f -print0 >"$DISCOVERY_FILE" 2>/dev/null; then + printf 'ERROR: backup directory could not be enumerated\n' >&2 + exit 1 + fi + while IFS= read -r -d '' file; do + candidates+=("$file") + done <"$DISCOVERY_FILE" + else + printf 'ERROR: backup target is not a regular file or directory\n' >&2 + exit 1 + fi +done + +candidate_count="${#candidates[@]}" +if ((candidate_count == 0)); then + printf 'status=missing\ncandidate_count=0\nlatest_name=none\nage_hours=-1\nnames_omitted=yes\npaths_omitted=yes\n' + exit 0 +fi + +latest='' +latest_mtime=-1 +for file in "${candidates[@]}"; do + if ! mtime="$(stat -c %Y -- "$file" 2>/dev/null)" || [[ ! "$mtime" =~ ^[0-9]+$ ]]; then + printf 'ERROR: backup candidate metadata could not be read\n' >&2 + exit 1 + fi + if ((mtime > latest_mtime)); then + latest_mtime="$mtime" + latest="$file" + fi +done + +now="$(date +%s)" +age_seconds=$((now - latest_mtime)) +((age_seconds >= 0)) || age_seconds=0 +age_hours=$((age_seconds / 3600)) +status='ok' +((age_seconds <= MAX_HOURS * 3600)) || status='stale' + +latest_name='omitted' +names_omitted='yes' +if ((SHOW_NAMES)); then + latest_name="$(basename -- "$latest")" + names_omitted='no' +fi +printf 'status=%s\ncandidate_count=%d\nlatest_name=%s\nage_hours=%d\nnames_omitted=%s\n' \ + "$status" "$candidate_count" "$latest_name" "$age_hours" "$names_omitted" +if ((SHOW_PATHS)); then + printf 'paths_omitted=no\nlatest_path=%s\n' "$latest" +else + printf 'paths_omitted=yes\n' +fi diff --git a/scripts/container-health.sh b/scripts/container-health.sh new file mode 100755 index 0000000..d2b8b95 --- /dev/null +++ b/scripts/container-health.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Report aggregate Docker or Podman container state without names or image metadata. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" + +usage() { + printf '%s\n' \ + 'Usage: container-health.sh' \ + '' \ + 'Report aggregate Docker or Podman container state.' \ + 'Container names, images, ports, labels, IDs, and commands are omitted.' \ + '' \ + 'Options:' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + '') ;; + *) printf 'ERROR: this script accepts no arguments\n' >&2; usage >&2; exit 2 ;; +esac +if (($# > 1)); then + printf 'ERROR: this script accepts no positional arguments\n' >&2 + exit 2 +fi + +DOCKER_BIN="${DOCKER_BIN:-$(command -v docker 2>/dev/null || true)}" +PODMAN_BIN="${PODMAN_BIN:-$(command -v podman 2>/dev/null || true)}" +engine='none' +engine_bin='' +runtime_cli_present=0 +if [[ -n "$DOCKER_BIN" && -x "$DOCKER_BIN" ]]; then + runtime_cli_present=1 + if "$DOCKER_BIN" info --format '{{.ServerVersion}}' >/dev/null 2>&1; then + engine='docker' + engine_bin="$DOCKER_BIN" + fi +fi +if [[ -z "$engine_bin" && -n "$PODMAN_BIN" && -x "$PODMAN_BIN" ]]; then + runtime_cli_present=1 + if "$PODMAN_BIN" info --format '{{.Version.Version}}' >/dev/null 2>&1; then + engine='podman' + engine_bin="$PODMAN_BIN" + fi +fi +if [[ -z "$engine_bin" ]]; then + if ((runtime_cli_present)); then + printf 'ERROR: installed container runtimes are unavailable or inaccessible\n' >&2 + exit 1 + fi + printf 'status=unavailable\nengine=none\ncontainer_count=0\nrunning_count=0\nstopped_count=0\nunhealthy_count=0\ncontainer_names_omitted=yes\n' + exit 0 +fi +if ! status_rows="$("$engine_bin" ps -a --format '{{.Status}}' 2>/dev/null)"; then + printf 'ERROR: container state collection failed\n' >&2 + exit 1 +fi + +container_count=0 +running_count=0 +stopped_count=0 +unhealthy_count=0 +while IFS= read -r row; do + [[ -n "$row" ]] || continue + container_count=$((container_count + 1)) + case "$row" in + Up*|up*|Running*|running*) running_count=$((running_count + 1)) ;; + *) stopped_count=$((stopped_count + 1)) ;; + esac + if [[ "${row,,}" == *unhealthy* ]]; then + unhealthy_count=$((unhealthy_count + 1)) + fi +done <<<"$status_rows" + +status='ok' +((unhealthy_count == 0)) || status='warning' +printf 'status=%s\nengine=%s\ncontainer_count=%d\nrunning_count=%d\nstopped_count=%d\nunhealthy_count=%d\ncontainer_names_omitted=yes\n' \ + "$status" "$engine" "$container_count" "$running_count" "$stopped_count" "$unhealthy_count" diff --git a/scripts/endpoint-health.sh b/scripts/endpoint-health.sh new file mode 100755 index 0000000..2e01c98 --- /dev/null +++ b/scripts/endpoint-health.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Probe explicit HTTP(S) endpoints without displaying URLs or response bodies. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +TIMEOUT_SECONDS=8 +SLOW_MS=2000 +URLS=() + +usage() { + printf '%s\n' \ + 'Usage: endpoint-health.sh [--timeout-seconds N] [--slow-ms N] URL...' \ + '' \ + 'Report HTTP status and latency for explicit HTTP(S) endpoints.' \ + 'URLs, query strings, headers, and response bodies are never displayed.' \ + '' \ + 'Options:' \ + ' --timeout-seconds N Per-endpoint timeout (default: 8)' \ + ' --slow-ms N Warning latency threshold (default: 2000)' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --timeout-seconds) + (($# >= 2)) || { printf 'ERROR: --timeout-seconds requires a value\n' >&2; exit 2; } + TIMEOUT_SECONDS="$2" + shift + ;; + --slow-ms) + (($# >= 2)) || { printf 'ERROR: --slow-ms requires a value\n' >&2; exit 2; } + SLOW_MS="$2" + shift + ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + --) + shift + while (($#)); do URLS+=("$1"); shift; done + break + ;; + -*) printf 'ERROR: unsupported option\n' >&2; usage >&2; exit 2 ;; + *) URLS+=("$1") ;; + esac + shift +done + +if ! TIMEOUT_SECONDS="$(normalize_uint "$TIMEOUT_SECONDS" 2147483647)" \ + || ! SLOW_MS="$(normalize_uint "$SLOW_MS" 2147483647)" \ + || ((TIMEOUT_SECONDS < 1 || SLOW_MS < 1)); then + printf 'ERROR: timeout and slow threshold must be positive integers\n' >&2 + exit 2 +fi +if ((${#URLS[@]} == 0)); then + printf 'ERROR: at least one URL is required\n' >&2 + exit 2 +fi +for url in "${URLS[@]}"; do + if [[ "$url" != http://* && "$url" != https://* ]]; then + printf 'ERROR: only http:// and https:// URLs are accepted\n' >&2 + exit 2 + fi +done + +CURL_BIN="${CURL_BIN:-$(command -v curl 2>/dev/null || true)}" +if [[ -z "$CURL_BIN" || ! -x "$CURL_BIN" ]]; then + printf 'ERROR: curl was not found\n' >&2 + exit 1 +fi + +records=() +failed_count=0 +slow_count=0 +index=0 +for url in "${URLS[@]}"; do + index=$((index + 1)) + if raw="$("$CURL_BIN" -q -sS -o /dev/null --max-time "$TIMEOUT_SECONDS" -w '%{http_code} %{time_total}' -- "$url" 2>/dev/null)" \ + && [[ "$raw" =~ ^([0-9]{3})[[:space:]]+([0-9]+([.][0-9]+)?)$ ]]; then + http_code="${BASH_REMATCH[1]}" + elapsed="${BASH_REMATCH[2]}" + latency_ms="$(awk -v value="$elapsed" 'BEGIN { printf "%d", value * 1000 }')" + else + http_code=000 + latency_ms=0 + fi + if ((10#$http_code < 200 || 10#$http_code >= 400)); then + failed_count=$((failed_count + 1)) + fi + if ((latency_ms >= SLOW_MS)); then + slow_count=$((slow_count + 1)) + fi + records+=("endpoint=$index http_code=$http_code latency_ms=$latency_ms") +done + +status='ok' +if ((failed_count > 0)); then + status='critical' +elif ((slow_count > 0)); then + status='warning' +fi +printf 'status=%s\nendpoint_count=%d\nfailed_count=%d\nslow_count=%d\nurls_omitted=yes\n' \ + "$status" "${#URLS[@]}" "$failed_count" "$slow_count" +printf '%s\n' "${records[@]}" diff --git a/scripts/filesystem-health.sh b/scripts/filesystem-health.sh new file mode 100755 index 0000000..7cc7eee --- /dev/null +++ b/scripts/filesystem-health.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Report filesystem capacity and inode pressure without displaying device names. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +WARN_PERCENT=85 +CRITICAL_PERCENT=95 +SHOW_MOUNTS=0 + +usage() { + printf '%s\n' \ + 'Usage: filesystem-health.sh [--warn-percent N] [--critical-percent N] [--show-mounts]' \ + '' \ + 'Report capacity and inode pressure for local persistent filesystems.' \ + 'Device and mount names are omitted by default.' \ + '' \ + 'Options:' \ + ' --warn-percent N Warning threshold (default: 85)' \ + ' --critical-percent N Critical threshold (default: 95)' \ + ' --show-mounts Include mount points in output' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --warn-percent) + (($# >= 2)) || { printf 'ERROR: --warn-percent requires a value\n' >&2; exit 2; } + WARN_PERCENT="$2" + shift + ;; + --critical-percent) + (($# >= 2)) || { printf 'ERROR: --critical-percent requires a value\n' >&2; exit 2; } + CRITICAL_PERCENT="$2" + shift + ;; + --show-mounts) SHOW_MOUNTS=1 ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + *) printf 'ERROR: unsupported argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if ! WARN_PERCENT="$(normalize_uint "$WARN_PERCENT" 100)" \ + || ! CRITICAL_PERCENT="$(normalize_uint "$CRITICAL_PERCENT" 100)" \ + || ((WARN_PERCENT < 1 || WARN_PERCENT >= CRITICAL_PERCENT)); then + printf 'ERROR: thresholds must be integers with 1 <= warning < critical <= 100\n' >&2 + exit 2 +fi + +DF_BIN="${DF_BIN:-$(command -v df 2>/dev/null || true)}" +if [[ -z "$DF_BIN" || ! -x "$DF_BIN" ]]; then + printf 'ERROR: df was not found\n' >&2 + exit 1 +fi +if ! raw="$("$DF_BIN" -l -x tmpfs -x devtmpfs --output=target,pcent,ipcent 2>/dev/null)"; then + printf 'ERROR: filesystem collection failed\n' >&2 + exit 1 +fi + +records=() +filesystem_count=0 +warning_count=0 +critical_count=0 +overall_status='ok' +while IFS= read -r line; do + if [[ "$line" =~ ^(.*[^[:space:]])[[:space:]]+([0-9]+)%[[:space:]]+(-|[0-9]+)%[[:space:]]*$ ]]; then + mount="${BASH_REMATCH[1]}" + used_percent="${BASH_REMATCH[2]}" + inode_raw="${BASH_REMATCH[3]}" + inode_percent=0 + [[ "$inode_raw" == '-' ]] || inode_percent="$inode_raw" + row_status='ok' + if ((used_percent >= CRITICAL_PERCENT || inode_percent >= CRITICAL_PERCENT)); then + row_status='critical' + critical_count=$((critical_count + 1)) + overall_status='critical' + elif ((used_percent >= WARN_PERCENT || inode_percent >= WARN_PERCENT)); then + row_status='warning' + warning_count=$((warning_count + 1)) + [[ "$overall_status" == 'critical' ]] || overall_status='warning' + fi + filesystem_count=$((filesystem_count + 1)) + if ((SHOW_MOUNTS)); then + records+=("mount=$mount used_percent=$used_percent inode_percent=$inode_percent status=$row_status") + else + records+=("target=$filesystem_count used_percent=$used_percent inode_percent=$inode_percent status=$row_status") + fi + fi +done <<<"$raw" + +if ((filesystem_count == 0)); then + printf 'ERROR: df returned no parseable filesystem rows\n' >&2 + exit 1 +fi + +mounts_omitted='yes' +((SHOW_MOUNTS == 0)) || mounts_omitted='no' +printf 'status=%s\nfilesystem_count=%d\nwarning_count=%d\ncritical_count=%d\nmounts_omitted=%s\n' \ + "$overall_status" "$filesystem_count" "$warning_count" "$critical_count" "$mounts_omitted" +printf '%s\n' "${records[@]}" diff --git a/scripts/listener-exposure.sh b/scripts/listener-exposure.sh new file mode 100755 index 0000000..0d771f7 --- /dev/null +++ b/scripts/listener-exposure.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Classify listening sockets by bind scope without displaying network addresses. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" + +usage() { + printf '%s\n' \ + 'Usage: listener-exposure.sh' \ + '' \ + 'List listening protocol/port pairs and classify their bind scope.' \ + 'Literal addresses, process names, and command lines are omitted.' \ + '' \ + 'Options:' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + '') ;; + *) printf 'ERROR: this script accepts no arguments\n' >&2; usage >&2; exit 2 ;; +esac +if (($# > 1)); then + printf 'ERROR: this script accepts no positional arguments\n' >&2 + exit 2 +fi + +SS_BIN="${SS_BIN:-$(command -v ss 2>/dev/null || true)}" +if [[ -z "$SS_BIN" || ! -x "$SS_BIN" ]]; then + printf 'ERROR: ss was not found\n' >&2 + exit 1 +fi +if ! raw="$("$SS_BIN" -H -lntu 2>/dev/null)"; then + printf 'ERROR: listener collection failed\n' >&2 + exit 1 +fi + +records=() +listener_count=0 +loopback_count=0 +all_interfaces_count=0 +specific_address_count=0 +while read -r protocol _state _recv_q _send_q local_address _peer_address _remainder; do + [[ -n "${local_address:-}" ]] || continue + port="${local_address##*:}" + [[ "$port" =~ ^[0-9]+$ ]] || continue + scope='specific-address' + if [[ "$local_address" == 127.*:* || "$local_address" == '[::1]:'* ]]; then + scope='loopback' + loopback_count=$((loopback_count + 1)) + elif [[ "$local_address" == 0.0.0.0:* || "$local_address" == '*:'* || "$local_address" == '[::]:'* ]]; then + scope='all-interfaces' + all_interfaces_count=$((all_interfaces_count + 1)) + else + specific_address_count=$((specific_address_count + 1)) + fi + listener_count=$((listener_count + 1)) + records+=("protocol=$protocol port=$port scope=$scope") +done <<<"$raw" + +status='ok' +((all_interfaces_count == 0)) || status='review' +printf 'status=%s\nlistener_count=%d\nloopback_count=%d\nall_interfaces_count=%d\nspecific_address_count=%d\n' \ + "$status" "$listener_count" "$loopback_count" "$all_interfaces_count" "$specific_address_count" +if ((${#records[@]} > 0)); then + printf '%s\n' "${records[@]}" +fi diff --git a/scripts/log-storage.sh b/scripts/log-storage.sh new file mode 100755 index 0000000..65f807a --- /dev/null +++ b/scripts/log-storage.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Measure selected log directories without reading contents or displaying paths by default. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +WARN_GIB=1 +CRITICAL_GIB=5 +SHOW_PATHS=0 +TARGETS=() + +usage() { + printf '%s\n' \ + 'Usage: log-storage.sh [--warn-gib N] [--critical-gib N] [--show-paths] [DIRECTORY...]' \ + '' \ + 'Report allocated size for selected log directories.' \ + 'Defaults to /var/log. Log contents are never read; paths are omitted by default.' \ + '' \ + 'Options:' \ + ' --warn-gib N Warning threshold per target (default: 1)' \ + ' --critical-gib N Critical threshold per target (default: 5)' \ + ' --show-paths Include selected paths in output' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --warn-gib) + (($# >= 2)) || { printf 'ERROR: --warn-gib requires a value\n' >&2; exit 2; } + WARN_GIB="$2"; shift ;; + --critical-gib) + (($# >= 2)) || { printf 'ERROR: --critical-gib requires a value\n' >&2; exit 2; } + CRITICAL_GIB="$2"; shift ;; + --show-paths) SHOW_PATHS=1 ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + --) + shift + while (($#)); do TARGETS+=("$1"); shift; done + break ;; + -*) printf 'ERROR: unsupported option: %s\n' "$1" >&2; usage >&2; exit 2 ;; + *) TARGETS+=("$1") ;; + esac + shift +done + +if ! WARN_GIB="$(normalize_uint "$WARN_GIB" 2147483647)" \ + || ! CRITICAL_GIB="$(normalize_uint "$CRITICAL_GIB" 2147483647)" \ + || ((WARN_GIB < 1 || WARN_GIB >= CRITICAL_GIB)); then + printf 'ERROR: require positive integer thresholds with warning < critical\n' >&2 + exit 2 +fi +((${#TARGETS[@]} > 0)) || TARGETS=(/var/log) +for target in "${TARGETS[@]}"; do + if [[ -L "$target" || ! -d "$target" ]]; then + printf 'ERROR: every log target must be a non-linked directory\n' >&2 + exit 1 + fi +done + +DU_BIN="${DU_BIN:-$(command -v du 2>/dev/null || true)}" +if [[ -z "$DU_BIN" || ! -x "$DU_BIN" ]]; then + printf 'ERROR: du was not found\n' >&2 + exit 1 +fi +warn_bytes=$((WARN_GIB * 1024 * 1024 * 1024)) +critical_bytes=$((CRITICAL_GIB * 1024 * 1024 * 1024)) +records=() +warning_count=0 +critical_count=0 +partial_count=0 +index=0 +for target in "${TARGETS[@]}"; do + index=$((index + 1)) + if du_output="$("$DU_BIN" -s -B1 -x -- "$target" 2>/dev/null)"; then + du_status=0 + else + du_status=$? + fi + bytes="${du_output%%[[:space:]]*}" + if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then + printf 'ERROR: du returned no usable size measurement\n' >&2 + exit 1 + fi + row_status='ok' + if ((bytes >= critical_bytes)); then + row_status='critical' + critical_count=$((critical_count + 1)) + elif ((bytes >= warn_bytes)); then + row_status='warning' + warning_count=$((warning_count + 1)) + fi + if ((du_status != 0)); then + partial_count=$((partial_count + 1)) + case "$row_status" in + critical) row_status='critical-partial' ;; + warning) row_status='warning-partial' ;; + ok) row_status='partial' ;; + esac + fi + row="target=$index bytes=$bytes status=$row_status" + if ((SHOW_PATHS)); then + row+=" path=$target" + fi + records+=("$row") +done + +status='ok' +if ((critical_count > 0)); then + status='critical' +elif ((warning_count > 0 || partial_count > 0)); then + status='warning' +fi +collection_complete='yes' +((partial_count == 0)) || collection_complete='no' +printf 'status=%s\ntarget_count=%d\nwarning_count=%d\ncritical_count=%d\npartial_count=%d\ncollection_complete=%s\npaths_omitted=%s\n' \ + "$status" "${#TARGETS[@]}" "$warning_count" "$critical_count" "$partial_count" "$collection_complete" \ + "$([[ $SHOW_PATHS -eq 1 ]] && printf 'no' || printf 'yes')" +printf '%s\n' "${records[@]}" diff --git a/scripts/memory-pressure.sh b/scripts/memory-pressure.sh new file mode 100755 index 0000000..908307f --- /dev/null +++ b/scripts/memory-pressure.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Report memory, swap, load, and Linux pressure-stall information without process details. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +WARN_PERCENT=85 +CRITICAL_PERCENT=95 +PROC_ROOT="${PROC_ROOT:-/proc}" + +usage() { + printf '%s\n' \ + 'Usage: memory-pressure.sh [--warn-percent N] [--critical-percent N]' \ + '' \ + 'Report memory, swap, normalized load, and memory PSI when available.' \ + 'No process names, arguments, or machine identifiers are displayed.' \ + '' \ + 'Options:' \ + ' --warn-percent N Warning memory/swap threshold (default: 85)' \ + ' --critical-percent N Critical memory/swap threshold (default: 95)' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --warn-percent) + (($# >= 2)) || { printf 'ERROR: --warn-percent requires a value\n' >&2; exit 2; } + WARN_PERCENT="$2" + shift + ;; + --critical-percent) + (($# >= 2)) || { printf 'ERROR: --critical-percent requires a value\n' >&2; exit 2; } + CRITICAL_PERCENT="$2" + shift + ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + *) printf 'ERROR: unsupported argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if ! WARN_PERCENT="$(normalize_uint "$WARN_PERCENT" 100)" \ + || ! CRITICAL_PERCENT="$(normalize_uint "$CRITICAL_PERCENT" 100)" \ + || ((WARN_PERCENT < 1 || WARN_PERCENT >= CRITICAL_PERCENT)); then + printf 'ERROR: thresholds must be integers with 1 <= warning < critical <= 100\n' >&2 + exit 2 +fi +if [[ ! -r "$PROC_ROOT/meminfo" || ! -r "$PROC_ROOT/loadavg" ]]; then + printf 'ERROR: Linux memory/load information is unavailable\n' >&2 + exit 1 +fi + +mem_total="$(awk '/^MemTotal:/{print $2; exit}' "$PROC_ROOT/meminfo")" +mem_available="$(awk '/^MemAvailable:/{print $2; exit}' "$PROC_ROOT/meminfo")" +swap_total="$(awk '/^SwapTotal:/{print $2; exit}' "$PROC_ROOT/meminfo")" +swap_free="$(awk '/^SwapFree:/{print $2; exit}' "$PROC_ROOT/meminfo")" +if [[ ! "$mem_total" =~ ^[0-9]+$ || ! "$mem_available" =~ ^[0-9]+$ ]] || ((mem_total == 0)); then + printf 'ERROR: memory information has an unexpected format\n' >&2 + exit 1 +fi +[[ "$swap_total" =~ ^[0-9]+$ ]] || swap_total=0 +[[ "$swap_free" =~ ^[0-9]+$ ]] || swap_free=0 + +memory_used_percent=$(((mem_total - mem_available) * 100 / mem_total)) +swap_used_percent=0 +if ((swap_total > 0)); then + swap_used_percent=$(((swap_total - swap_free) * 100 / swap_total)) +fi +load1="$(awk '{print $1; exit}' "$PROC_ROOT/loadavg")" +cores="${CPU_COUNT:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')}" +if [[ ! "$cores" =~ ^[0-9]+$ ]] || ((cores < 1)); then + cores=1 +fi +if [[ ! "$load1" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf 'ERROR: load information has an unexpected format\n' >&2 + exit 1 +fi +load_per_core="$(awk -v value="$load1" -v count="$cores" 'BEGIN { printf "%.2f", value / count }')" + +memory_psi='unavailable' +if [[ -r "$PROC_ROOT/pressure/memory" ]]; then + memory_psi="$(awk '/^some / { for (i=1;i<=NF;i++) if ($i ~ /^avg10=/) { sub(/^avg10=/,"",$i); print $i; exit } }' "$PROC_ROOT/pressure/memory")" + [[ -n "$memory_psi" ]] || memory_psi='unavailable' +fi + +overall_status='ok' +if ((memory_used_percent >= CRITICAL_PERCENT || swap_used_percent >= CRITICAL_PERCENT)) \ + || awk -v value="$load_per_core" 'BEGIN { exit !(value >= 2.0) }'; then + overall_status='critical' +elif ((memory_used_percent >= WARN_PERCENT || swap_used_percent >= WARN_PERCENT)) \ + || awk -v value="$load_per_core" 'BEGIN { exit !(value >= 1.0) }'; then + overall_status='warning' +fi + +printf 'status=%s\n' "$overall_status" +printf 'memory_used_percent=%d\nswap_used_percent=%d\n' "$memory_used_percent" "$swap_used_percent" +printf 'load1=%s cores=%d load_per_core=%s\n' "$load1" "$cores" "$load_per_core" +printf 'memory_psi_some_avg10=%s\n' "$memory_psi" diff --git a/scripts/service-health.sh b/scripts/service-health.sh new file mode 100755 index 0000000..b95fb5b --- /dev/null +++ b/scripts/service-health.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Report aggregate systemd health without exposing unit names by default. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +SHOW_UNITS=0 + +usage() { + printf '%s\n' \ + 'Usage: service-health.sh [--show-units]' \ + '' \ + 'Report systemd state and failed-unit count.' \ + 'Unit names and descriptions are omitted unless --show-units is supplied.' \ + '' \ + 'Options:' \ + ' --show-units Include failed unit names (descriptions remain omitted)' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +case "${1:-}" in + --show-units) SHOW_UNITS=1 ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + '') ;; + *) printf 'ERROR: unsupported argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; +esac +if (($# > 1)); then + printf 'ERROR: this script accepts at most one option\n' >&2 + exit 2 +fi + +SYSTEMCTL_BIN="${SYSTEMCTL_BIN:-$(command -v systemctl 2>/dev/null || true)}" +if [[ -z "$SYSTEMCTL_BIN" || ! -x "$SYSTEMCTL_BIN" ]]; then + printf 'ERROR: systemctl was not found; this module requires systemd\n' >&2 + exit 1 +fi + +system_state="$("$SYSTEMCTL_BIN" is-system-running 2>/dev/null || true)" +system_state="${system_state%%$'\n'*}" +[[ -n "$system_state" ]] || system_state='unknown' +if ! failed_output="$("$SYSTEMCTL_BIN" --failed --no-legend --no-pager --plain 2>/dev/null)"; then + printf 'ERROR: failed systemd units could not be collected\n' >&2 + exit 1 +fi + +failed_units=() +while IFS= read -r line; do + [[ -n "$line" ]] || continue + unit="${line%%[[:space:]]*}" + [[ -n "$unit" ]] && failed_units+=("$unit") +done <<<"$failed_output" +failed_count="${#failed_units[@]}" + +status='ok' +if ((failed_count > 0)); then + status='warning' +elif [[ "$system_state" == 'offline' || "$system_state" == 'unknown' ]]; then + status='unavailable' +elif [[ "$system_state" != 'running' ]]; then + status='warning' +fi + +printf 'status=%s\nsystem_state=%s\nfailed_count=%d\n' "$status" "$system_state" "$failed_count" +if ((SHOW_UNITS)); then + printf 'unit_names_omitted=no\n' + for unit in "${failed_units[@]}"; do + printf 'failed_unit=%s\n' "$unit" + done +else + printf 'unit_names_omitted=yes\n' +fi diff --git a/scripts/tls-expiry.sh b/scripts/tls-expiry.sh new file mode 100755 index 0000000..0a24f9d --- /dev/null +++ b/scripts/tls-expiry.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Check TLS verification and certificate expiry without displaying target names. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +WARN_DAYS=21 +CRITICAL_DAYS=7 +TIMEOUT_SECONDS=8 +TARGETS=() + +usage() { + printf '%s\n' \ + 'Usage: tls-expiry.sh [--warn-days N] [--critical-days N] [--timeout-seconds N] HOST[:PORT]...' \ + '' \ + 'Verify TLS connections and report certificate lifetime by input index.' \ + 'Hostnames, certificate subjects, issuers, and addresses are omitted.' \ + 'Bracket IPv6 literals, for example [::1]:443.' \ + '' \ + 'Options:' \ + ' --warn-days N Warning at or below N days (default: 21)' \ + ' --critical-days N Critical at or below N days (default: 7)' \ + ' --timeout-seconds N Per-target timeout (default: 8)' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +normalize_uint() { + local raw="$1" max="$2" normalized + [[ "$raw" =~ ^0*([0-9]+)$ ]] || return 1 + normalized="${BASH_REMATCH[1]}" + if ((${#normalized} > ${#max})) \ + || { ((${#normalized} == ${#max})) && [[ "$normalized" > "$max" ]]; }; then + return 1 + fi + printf '%s\n' "$normalized" +} + +while (($#)); do + case "$1" in + --warn-days) + (($# >= 2)) || { printf 'ERROR: --warn-days requires a value\n' >&2; exit 2; } + WARN_DAYS="$2"; shift ;; + --critical-days) + (($# >= 2)) || { printf 'ERROR: --critical-days requires a value\n' >&2; exit 2; } + CRITICAL_DAYS="$2"; shift ;; + --timeout-seconds) + (($# >= 2)) || { printf 'ERROR: --timeout-seconds requires a value\n' >&2; exit 2; } + TIMEOUT_SECONDS="$2"; shift ;; + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + --) + shift + while (($#)); do TARGETS+=("$1"); shift; done + break ;; + -*) printf 'ERROR: unsupported option\n' >&2; usage >&2; exit 2 ;; + *) TARGETS+=("$1") ;; + esac + shift +done + +if ! WARN_DAYS="$(normalize_uint "$WARN_DAYS" 2147483647)" \ + || ! CRITICAL_DAYS="$(normalize_uint "$CRITICAL_DAYS" 2147483647)" \ + || ! TIMEOUT_SECONDS="$(normalize_uint "$TIMEOUT_SECONDS" 2147483647)"; then + printf 'ERROR: thresholds and timeout must be integers\n' >&2 + exit 2 +fi +if ((WARN_DAYS <= CRITICAL_DAYS || TIMEOUT_SECONDS < 1)); then + printf 'ERROR: require 0 <= critical days < warning days and a positive timeout\n' >&2 + exit 2 +fi +if ((${#TARGETS[@]} == 0)); then + printf 'ERROR: at least one TLS target is required\n' >&2 + exit 2 +fi + +OPENSSL_BIN="${OPENSSL_BIN:-$(command -v openssl 2>/dev/null || true)}" +TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v timeout 2>/dev/null || true)}" +if [[ -z "$OPENSSL_BIN" || ! -x "$OPENSSL_BIN" || -z "$TIMEOUT_BIN" || ! -x "$TIMEOUT_BIN" ]]; then + printf 'ERROR: openssl and timeout are required\n' >&2 + exit 1 +fi + +now="${NOW_EPOCH:-$(date +%s)}" +[[ "$now" =~ ^[0-9]+$ ]] || { printf 'ERROR: current time is unavailable\n' >&2; exit 1; } +records=() +verified_count=0 +warning_count=0 +critical_count=0 +index=0 +for target in "${TARGETS[@]}"; do + index=$((index + 1)) + host='' + port=443 + connect_target='' + if [[ "$target" =~ ^\[([0-9A-Fa-f:]+)\](:([0-9]+))?$ ]]; then + host="${BASH_REMATCH[1]}" + [[ -z "${BASH_REMATCH[3]:-}" ]] || port="${BASH_REMATCH[3]}" + connect_target="[$host]:$port" + elif [[ "$target" == *:* ]]; then + host="${target%:*}" + port="${target##*:}" + if [[ "$host" == *:* ]]; then + printf 'ERROR: invalid TLS target syntax\n' >&2 + exit 2 + fi + connect_target="$host:$port" + else + host="$target" + connect_target="$host:$port" + fi + if [[ -z "$host" ]] || ! port="$(normalize_uint "$port" 65535)" || ((port < 1)); then + printf 'ERROR: invalid TLS target syntax\n' >&2 + exit 2 + fi + if [[ "$host" == *:* ]]; then + connect_target="[$host]:$port" + else + connect_target="$host:$port" + fi + + verified='no' + openssl_args=(s_client -connect "$connect_target") + if [[ "$host" == *:* || "$host" =~ ^([0-9]{1,3}[.]){3}[0-9]{1,3}$ ]]; then + openssl_args+=(-verify_ip "$host") + else + openssl_args+=(-servername "$host" -verify_hostname "$host") + fi + openssl_args+=(-verify_return_error) + if cert_output="$("$TIMEOUT_BIN" "$TIMEOUT_SECONDS" "$OPENSSL_BIN" "${openssl_args[@]}" /dev/null)"; then + verified='yes' + verified_count=$((verified_count + 1)) + fi + end_line="$(printf '%s\n' "$cert_output" | "$OPENSSL_BIN" x509 -noout -enddate 2>/dev/null || true)" + end_date="${end_line#notAfter=}" + expires=0 + if [[ "$end_line" == notAfter=* ]]; then + expires="$(date -d "$end_date" +%s 2>/dev/null || printf '0')" + fi + days_remaining=-1 + row_status='critical' + if ((expires > 0)); then + days_remaining=$(((expires - now) / 86400)) + if [[ "$verified" != 'yes' ]] || ((days_remaining <= CRITICAL_DAYS)); then + row_status='critical' + elif ((days_remaining <= WARN_DAYS)); then + row_status='warning' + else + row_status='ok' + fi + fi + case "$row_status" in + critical) critical_count=$((critical_count + 1)) ;; + warning) warning_count=$((warning_count + 1)) ;; + esac + records+=("target=$index days_remaining=$days_remaining verified=$verified status=$row_status") +done + +status='ok' +if ((critical_count > 0)); then + status='critical' +elif ((warning_count > 0)); then + status='warning' +fi +printf 'status=%s\ntarget_count=%d\nverified_count=%d\nwarning_count=%d\ncritical_count=%d\ntarget_names_omitted=yes\n' \ + "$status" "${#TARGETS[@]}" "$verified_count" "$warning_count" "$critical_count" +printf '%s\n' "${records[@]}" diff --git a/scripts/update-posture.sh b/scripts/update-posture.sh new file mode 100755 index 0000000..21eaba6 --- /dev/null +++ b/scripts/update-posture.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Report cached package-update and reboot posture without refreshing package metadata. +set -euo pipefail +export LC_ALL=C + +VERSION="2.1.0" +REBOOT_REQUIRED_FILE="${REBOOT_REQUIRED_FILE:-/var/run/reboot-required}" + +usage() { + printf '%s\n' \ + 'Usage: update-posture.sh' \ + '' \ + 'Count updates visible in the existing package-manager cache.' \ + 'This script never refreshes metadata, installs packages, or reboots.' \ + 'Package names are omitted.' \ + '' \ + 'Options:' \ + ' -h, --help Show this help' \ + ' --version Print the script version' +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + --version) printf '%s\n' "$VERSION"; exit 0 ;; + '') ;; + *) printf 'ERROR: this script accepts no arguments\n' >&2; usage >&2; exit 2 ;; +esac +if (($# > 1)); then + printf 'ERROR: this script accepts no positional arguments\n' >&2 + exit 2 +fi + +manager='none' +updates_total=0 +updates_security=0 +APT_GET_BIN="${APT_GET_BIN:-$(command -v apt-get 2>/dev/null || true)}" +DNF_BIN="${DNF_BIN:-$(command -v dnf 2>/dev/null || true)}" +if [[ -n "$APT_GET_BIN" && -x "$APT_GET_BIN" ]]; then + manager='apt' + if ! raw="$("$APT_GET_BIN" -s upgrade 2>/dev/null)"; then + printf 'ERROR: apt update simulation failed\n' >&2 + exit 1 + fi + updates_total="$(awk '/^Inst /{count++} END{print count+0}' <<<"$raw")" + updates_security="$(awk 'BEGIN{IGNORECASE=1} /^Inst / && /security/{count++} END{print count+0}' <<<"$raw")" +elif [[ -n "$DNF_BIN" && -x "$DNF_BIN" ]]; then + manager='dnf' + if raw="$("$DNF_BIN" --cacheonly check-update -q 2>/dev/null)"; then + dnf_status=0 + else + dnf_status=$? + fi + if ((dnf_status != 0 && dnf_status != 100)); then + printf 'ERROR: DNF update inspection failed\n' >&2 + exit 1 + fi + updates_total="$(awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting|Security:)/ {count++} END{print count+0}' <<<"$raw")" + if security_raw="$("$DNF_BIN" --cacheonly updateinfo list security -q 2>/dev/null)"; then + updates_security="$(awk 'NF >= 3 {count++} END{print count+0}' <<<"$security_raw")" + else + printf 'ERROR: DNF security-update inspection failed\n' >&2 + exit 1 + fi +else + printf 'ERROR: no supported package manager was found\n' >&2 + exit 1 +fi + +reboot_required='no' +[[ -f "$REBOOT_REQUIRED_FILE" ]] && reboot_required='yes' +status='ok' +if ((updates_total > 0 || updates_security > 0)) || [[ "$reboot_required" == 'yes' ]]; then + status='warning' +fi + +printf 'status=%s\nmanager=%s\nupdates_total=%d\nupdates_security=%d\nreboot_required=%s\n' \ + "$status" "$manager" "$updates_total" "$updates_security" "$reboot_required" diff --git a/tests/test-launcher.sh b/tests/test-launcher.sh index de7a826..606cee2 100755 --- a/tests/test-launcher.sh +++ b/tests/test-launcher.sh @@ -41,6 +41,16 @@ if list_output="$("$ROOT/onescript" list 2>&1)"; then assert_contains "launcher list includes cpanel follow-up" "$list_output" $'cpanel-followup-triage\tcpanel\tyes\tcPanel follow-up triage\tFocused cPanel service, mail queue, and listener triage' assert_contains "launcher list includes directory sizes" "$list_output" $'directory-sizes\tstorage\tyes\tDirectory sizes\tImmediate subdirectory disk usage, largest first' assert_contains "launcher list includes Exim queue health" "$list_output" $'exim-queue-health\tmail\tyes\tExim queue health\tPrivacy-safe Exim queue counts' + assert_contains "launcher list includes filesystem health" "$list_output" $'filesystem-health\tsystem\tyes\tFilesystem health\tCapacity and inode pressure without device names' + assert_contains "launcher list includes memory pressure" "$list_output" $'memory-pressure\tsystem\tyes\tMemory pressure\tMemory, swap, load, and PSI summary' + assert_contains "launcher list includes service health" "$list_output" $'service-health\tservices\tyes\tService health\tAggregate systemd state and failed-unit count' + assert_contains "launcher list includes listener exposure" "$list_output" $'listener-exposure\tnetwork\tyes\tListener exposure\tAddress-free listener port and scope inventory' + assert_contains "launcher list includes update posture" "$list_output" $'update-posture\tpackages\tyes\tUpdate posture\tCached APT or DNF update and reboot posture' + assert_contains "launcher list includes backup freshness" "$list_output" $'backup-freshness\tbackup\tno\tBackup freshness\tNewest explicit backup artifact age' + assert_contains "launcher list includes endpoint health" "$list_output" $'endpoint-health\tnetwork\tno\tEndpoint health\tHTTP status and latency without URL disclosure' + assert_contains "launcher list includes TLS expiry" "$list_output" $'tls-expiry\tnetwork\tno\tTLS expiry\tTLS verification and expiry without target disclosure' + assert_contains "launcher list includes container health" "$list_output" $'container-health\tcontainers\tyes\tContainer health\tAggregate Docker or Podman container state' + assert_contains "launcher list includes log storage" "$list_output" $'log-storage\tstorage\tyes\tLog storage\tAllocated log-directory size without content reads' else fail "launcher list command succeeds ($list_output)" fi diff --git a/tests/test-linux-modules.sh b/tests/test-linux-modules.sh new file mode 100755 index 0000000..3f23cd0 --- /dev/null +++ b/tests/test-linux-modules.sh @@ -0,0 +1,687 @@ +#!/usr/bin/env bash +set -uo pipefail +export LC_ALL=C + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +PASS=0 +FAIL=0 +trap 'rm -rf -- "$TMP"' EXIT + +pass() { + printf 'PASS: %s\n' "$1" + PASS=$((PASS + 1)) +} + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + FAIL=$((FAIL + 1)) +} + +assert_contains() { + local name="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + pass "$name" + else + fail "$name (missing '$needle')" + fi +} + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$name" + else + fail "$name (expected '$expected', got '$actual')" + fi +} + +assert_not_contains() { + local name="$1" haystack="$2" needle="$3" + if [[ "$haystack" != *"$needle"* ]]; then + pass "$name" + else + fail "$name (unexpected '$needle')" + fi +} + +mkdir -p "$TMP/filesystem bin" +cat >"$TMP/filesystem bin/df" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *'-P'* && "$*" == *'--output='* ]]; then + printf 'df: options -P and --output are mutually exclusive\n' >&2 + exit 1 +fi +cat <<'OUT' +Mounted on Use% IUse% +/ 91% 40% +/data 50% 96% +OUT +if [[ " $* " != *' -l '* ]]; then + printf '/remote-share 99%% 99%%\n' +fi +EOF +chmod 700 "$TMP/filesystem bin/df" +if filesystem_output="$(DF_BIN="$TMP/filesystem bin/df" "$ROOT/scripts/filesystem-health.sh" --warn-percent 85 --critical-percent 95 2>&1)"; then + filesystem_status=0 +else + filesystem_status=$? +fi +assert_eq "filesystem health completes" "0" "$filesystem_status" +assert_contains "filesystem health reports critical aggregate status" "$filesystem_output" 'status=critical' +assert_contains "filesystem health counts filesystems" "$filesystem_output" 'filesystem_count=2' +assert_contains "filesystem health reports capacity warning without device names" "$filesystem_output" 'target=1 used_percent=91 inode_percent=40 status=warning' +assert_contains "filesystem health reports inode critical pressure" "$filesystem_output" 'target=2 used_percent=50 inode_percent=96 status=critical' +assert_contains "filesystem health declares mount names omitted" "$filesystem_output" 'mounts_omitted=yes' +assert_not_contains "filesystem health omits mount names by default" "$filesystem_output" 'mount=/data' +if filesystem_named_output="$(DF_BIN="$TMP/filesystem bin/df" "$ROOT/scripts/filesystem-health.sh" --show-mounts 2>&1)"; then + filesystem_named_status=0 +else + filesystem_named_status=$? +fi +assert_eq "filesystem health supports explicit mount-name output" "0" "$filesystem_named_status" +assert_contains "filesystem health shows requested mount names" "$filesystem_named_output" 'mount=/data used_percent=50 inode_percent=96 status=critical' +assert_contains "filesystem health declares mount names visible" "$filesystem_named_output" 'mounts_omitted=no' +assert_not_contains "filesystem health excludes remote filesystems" "$filesystem_named_output" 'remote-share' + +cat >"$TMP/filesystem bin/df-decimal" <<'EOF' +#!/usr/bin/env bash +printf 'Mounted on Use%% IUse%%\n/data 9%% 1%%\n' +EOF +chmod 700 "$TMP/filesystem bin/df-decimal" +if filesystem_decimal_output="$(DF_BIN="$TMP/filesystem bin/df-decimal" "$ROOT/scripts/filesystem-health.sh" --warn-percent 08 --critical-percent 010 2>&1)"; then + filesystem_decimal_status=0 +else + filesystem_decimal_status=$? +fi +assert_eq "filesystem health accepts leading-zero decimal thresholds" "0" "$filesystem_decimal_status" +assert_contains "filesystem health treats 010 as decimal ten" "$filesystem_decimal_output" 'target=1 used_percent=9 inode_percent=1 status=warning' +if filesystem_huge_output="$(DF_BIN="$TMP/filesystem bin/df" "$ROOT/scripts/filesystem-health.sh" --warn-percent 85 --critical-percent 18446744073709551711 2>&1)"; then + filesystem_huge_status=0 +else + filesystem_huge_status=$? +fi +assert_eq "filesystem health rejects oversized thresholds as usage errors" "2" "$filesystem_huge_status" +assert_not_contains "filesystem health suppresses arithmetic diagnostics" "$filesystem_huge_output" 'value too great for base' + +mkdir -p "$TMP/proc/pressure" +cat >"$TMP/proc/meminfo" <<'EOF' +MemTotal: 100000 kB +MemAvailable: 5000 kB +SwapTotal: 100000 kB +SwapFree: 90000 kB +EOF +printf '%s\n' '1.50 0.80 0.25 1/100 123' >"$TMP/proc/loadavg" +printf '%s\n' 'some avg10=2.50 avg60=1.00 avg300=0.50 total=100' >"$TMP/proc/pressure/memory" +if memory_output="$(PROC_ROOT="$TMP/proc" CPU_COUNT=2 "$ROOT/scripts/memory-pressure.sh" 2>&1)"; then + memory_status=0 +else + memory_status=$? +fi +assert_eq "memory pressure completes" "0" "$memory_status" +assert_contains "memory pressure reports critical aggregate status" "$memory_output" 'status=critical' +assert_contains "memory pressure calculates available-memory usage" "$memory_output" 'memory_used_percent=95' +assert_contains "memory pressure calculates swap usage" "$memory_output" 'swap_used_percent=10' +assert_contains "memory pressure normalizes load by core" "$memory_output" 'load1=1.50 cores=2 load_per_core=0.75' +assert_contains "memory pressure reports PSI when available" "$memory_output" 'memory_psi_some_avg10=2.50' +if memory_decimal_output="$(PROC_ROOT="$TMP/proc" CPU_COUNT=2 "$ROOT/scripts/memory-pressure.sh" --warn-percent 085 --critical-percent 095 2>&1)"; then + memory_decimal_status=0 +else + memory_decimal_status=$? +fi +assert_eq "memory pressure accepts leading-zero decimal thresholds" "0" "$memory_decimal_status" +assert_contains "memory pressure preserves decimal threshold behavior" "$memory_decimal_output" 'status=critical' +if memory_huge_output="$(PROC_ROOT="$TMP/proc" "$ROOT/scripts/memory-pressure.sh" --warn-percent 85 --critical-percent 18446744073709551711 2>&1)"; then + memory_huge_status=0 +else + memory_huge_status=$? +fi +assert_eq "memory pressure rejects oversized thresholds as usage errors" "2" "$memory_huge_status" +assert_not_contains "memory pressure suppresses arithmetic diagnostics" "$memory_huge_output" 'value too great for base' + +mkdir -p "$TMP/service bin" +cat >"$TMP/service bin/systemctl" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == 'is-system-running' ]]; then + printf 'degraded\n' + exit 1 +fi +if [[ "${1:-}" == '--failed' ]]; then + printf '%s\n' \ + 'private-app.service loaded failed failed Private application' \ + 'backup-job.timer loaded failed failed Backup job' + exit 0 +fi +exit 2 +EOF +chmod 700 "$TMP/service bin/systemctl" +if service_output="$(SYSTEMCTL_BIN="$TMP/service bin/systemctl" "$ROOT/scripts/service-health.sh" 2>&1)"; then + service_status=0 +else + service_status=$? +fi +assert_eq "service health completes for a degraded system" "0" "$service_status" +assert_contains "service health reports aggregate warning" "$service_output" 'status=warning' +assert_contains "service health reports systemd state" "$service_output" 'system_state=degraded' +assert_contains "service health counts failed units" "$service_output" 'failed_count=2' +assert_contains "service health declares private names omitted" "$service_output" 'unit_names_omitted=yes' +assert_not_contains "service health omits unit names by default" "$service_output" 'private-app.service' + +cat >"$TMP/service bin/systemctl-starting" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == 'is-system-running' ]]; then + printf 'starting\n' + exit 1 +fi +if [[ "${1:-}" == '--failed' ]]; then + exit 0 +fi +exit 2 +EOF +chmod 700 "$TMP/service bin/systemctl-starting" +if service_starting_output="$(SYSTEMCTL_BIN="$TMP/service bin/systemctl-starting" "$ROOT/scripts/service-health.sh" 2>&1)"; then + service_starting_status=0 +else + service_starting_status=$? +fi +assert_eq "service health collects transitional systemd state" "0" "$service_starting_status" +assert_contains "service health warns while systemd is not fully running" "$service_starting_output" 'status=warning' +assert_contains "service health reports transitional state" "$service_starting_output" 'system_state=starting' + +mkdir -p "$TMP/listener bin" +cat >"$TMP/listener bin/ss" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' \ + 'tcp LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:*' \ + 'tcp LISTEN 0 4096 0.0.0.0:443 0.0.0.0:*' \ + 'tcp LISTEN 0 4096 [::]:22 [::]:*' \ + 'udp UNCONN 0 0 192.168.1.5:53 0.0.0.0:*' +EOF +chmod 700 "$TMP/listener bin/ss" +if listener_output="$(SS_BIN="$TMP/listener bin/ss" "$ROOT/scripts/listener-exposure.sh" 2>&1)"; then + listener_status=0 +else + listener_status=$? +fi +assert_eq "listener exposure completes" "0" "$listener_status" +assert_contains "listener exposure counts listeners" "$listener_output" 'listener_count=4' +assert_contains "listener exposure counts loopback scope" "$listener_output" 'loopback_count=1' +assert_contains "listener exposure counts all-interface scope" "$listener_output" 'all_interfaces_count=2' +assert_contains "listener exposure reports port and scope" "$listener_output" 'protocol=tcp port=443 scope=all-interfaces' +assert_contains "listener exposure reports specific-address scope" "$listener_output" 'protocol=udp port=53 scope=specific-address' +assert_not_contains "listener exposure omits literal addresses" "$listener_output" '192.168.1.5' + +mkdir -p "$TMP/update bin" +cat >"$TMP/update bin/apt-get" <<'EOF' +#!/usr/bin/env bash +[[ "$*" == '-s upgrade' ]] || exit 2 +printf '%s\n' \ + 'Inst openssl [1.0] (1.1 Ubuntu:security [amd64])' \ + 'Inst bash [5.0] (5.1 Ubuntu:updates [amd64])' \ + 'Inst curl [7.0] (7.1 Ubuntu:updates [amd64])' +EOF +chmod 700 "$TMP/update bin/apt-get" +printf 'reboot required\n' >"$TMP/reboot-required" +if update_output="$(APT_GET_BIN="$TMP/update bin/apt-get" REBOOT_REQUIRED_FILE="$TMP/reboot-required" "$ROOT/scripts/update-posture.sh" 2>&1)"; then + update_status=0 +else + update_status=$? +fi +assert_eq "update posture completes" "0" "$update_status" +assert_contains "update posture detects apt" "$update_output" 'manager=apt' +assert_contains "update posture counts cached updates" "$update_output" 'updates_total=3' +assert_contains "update posture counts cached security updates" "$update_output" 'updates_security=1' +assert_contains "update posture reports reboot marker" "$update_output" 'reboot_required=yes' +assert_contains "update posture reports warning state" "$update_output" 'status=warning' +assert_not_contains "update posture omits package names" "$update_output" 'openssl' + +cat >"$TMP/update bin/dnf" <<'EOF' +#!/usr/bin/env bash +case "$*" in + '--cacheonly check-update -q') + printf '%s\n' \ + 'kernel.x86_64 6.1 updates' \ + 'openssl.x86_64 3.0 updates' + exit 100 + ;; + '--cacheonly updateinfo list security -q') + printf '%s\n' 'FEDORA-2026-0001 Important/Sec. openssl.x86_64' + exit 0 + ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/update bin/dnf" +if dnf_update_output="$(APT_GET_BIN=/not-present DNF_BIN="$TMP/update bin/dnf" REBOOT_REQUIRED_FILE="$TMP/no-reboot" "$ROOT/scripts/update-posture.sh" 2>&1)"; then + dnf_update_status=0 +else + dnf_update_status=$? +fi +assert_eq "update posture supports DNF cache inspection" "0" "$dnf_update_status" +assert_contains "update posture detects DNF" "$dnf_update_output" 'manager=dnf' +assert_contains "update posture counts DNF updates" "$dnf_update_output" 'updates_total=2' +assert_contains "update posture counts DNF security updates" "$dnf_update_output" 'updates_security=1' +assert_not_contains "update posture omits DNF package names" "$dnf_update_output" 'openssl' + +mkdir -p "$TMP/backups" +printf 'old\n' >"$TMP/backups/old.tar.gz" +printf 'new\n' >"$TMP/backups/new.tar.gz" +touch -d '30 hours ago' "$TMP/backups/old.tar.gz" +touch -d '2 hours ago' "$TMP/backups/new.tar.gz" +if backup_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 24 "$TMP/backups" 2>&1)"; then + backup_status=0 +else + backup_status=$? +fi +assert_eq "backup freshness completes" "0" "$backup_status" +assert_contains "backup freshness reports fresh status" "$backup_output" 'status=ok' +assert_contains "backup freshness counts candidate files" "$backup_output" 'candidate_count=2' +assert_contains "backup freshness omits latest name by default" "$backup_output" 'latest_name=omitted' +assert_contains "backup freshness reports latest age" "$backup_output" 'age_hours=2' +assert_contains "backup freshness declares paths omitted" "$backup_output" 'paths_omitted=yes' +assert_contains "backup freshness declares names omitted" "$backup_output" 'names_omitted=yes' +assert_not_contains "backup freshness omits target directory" "$backup_output" "$TMP/backups" +assert_not_contains "backup freshness omits artifact names by default" "$backup_output" 'new.tar.gz' +if backup_named_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 24 --show-names "$TMP/backups" 2>&1)"; then + backup_named_status=0 +else + backup_named_status=$? +fi +assert_eq "backup freshness supports explicit artifact-name output" "0" "$backup_named_status" +assert_contains "backup freshness shows requested artifact name" "$backup_named_output" 'latest_name=new.tar.gz' +assert_contains "backup freshness declares names visible" "$backup_named_output" 'names_omitted=no' + +mkdir -p "$TMP/backups-stale" +printf 'stale\n' >"$TMP/backups-stale/item" +stale_mtime=$(( $(date +%s) - 88200 )) +touch -d "@$stale_mtime" "$TMP/backups-stale/item" +if backup_stale_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 24 "$TMP/backups-stale" 2>&1)"; then + backup_stale_status=0 +else + backup_stale_status=$? +fi +assert_eq "backup freshness collects sub-hour threshold case" "0" "$backup_stale_status" +assert_contains "backup freshness compares exact age rather than floored display hours" "$backup_stale_output" 'status=stale' + +mkdir -p "$TMP/backups-find-failure" "$TMP/backup bin" +cat >"$TMP/backup bin/find" <<'EOF' +#!/usr/bin/env bash +printf 'synthetic find failure\n' >&2 +exit 1 +EOF +chmod 700 "$TMP/backup bin/find" +if backup_find_output="$(FIND_BIN="$TMP/backup bin/find" "$ROOT/scripts/backup-freshness.sh" "$TMP/backups-find-failure" 2>&1)"; then + backup_find_status=0 +else + backup_find_status=$? +fi +assert_eq "backup freshness propagates directory enumeration failure" "1" "$backup_find_status" +assert_contains "backup freshness reports generic enumeration failure" "$backup_find_output" 'ERROR: backup directory could not be enumerated' +assert_not_contains "backup freshness suppresses raw enumeration errors" "$backup_find_output" 'synthetic find failure' + +mkdir -p "$TMP/backups-leading/-delete" "$TMP/backup leading bin" +printf 'backup\n' >"$TMP/backups-leading/-delete/item" +cat >"$TMP/backup leading bin/find" <<'EOF' +#!/usr/bin/env bash +[[ "${1:-}" == '-P' && "${2:-}" == './-delete' ]] || exit 2 +printf './-delete/item\0' +EOF +chmod 700 "$TMP/backup leading bin/find" +if backup_leading_output="$(cd "$TMP/backups-leading" && FIND_BIN="$TMP/backup leading bin/find" "$ROOT/scripts/backup-freshness.sh" -- -delete 2>&1)"; then + backup_leading_status=0 +else + backup_leading_status=$? +fi +assert_eq "backup freshness safely handles leading-dash directory names" "0" "$backup_leading_status" +assert_contains "backup freshness treats leading-dash target as data" "$backup_leading_output" 'candidate_count=1' +if [[ -f "$TMP/backups-leading/-delete/item" ]]; then + pass "backup freshness does not delete leading-dash target contents" +else + fail "backup freshness does not delete leading-dash target contents" +fi +if backup_decimal_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 024 "$TMP/backups/new.tar.gz" 2>&1)"; then + backup_decimal_status=0 +else + backup_decimal_status=$? +fi +assert_eq "backup freshness accepts leading-zero decimal hours" "0" "$backup_decimal_status" +assert_contains "backup freshness preserves decimal hour behavior" "$backup_decimal_output" 'status=ok' +printf 'decimal boundary\n' >"$TMP/backups/decimal-boundary.tar.gz" +touch -d '22 hours ago' "$TMP/backups/decimal-boundary.tar.gz" +if backup_decimal_boundary_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 024 "$TMP/backups/decimal-boundary.tar.gz" 2>&1)"; then + backup_decimal_boundary_status=0 +else + backup_decimal_boundary_status=$? +fi +assert_eq "backup freshness collects decimal boundary case" "0" "$backup_decimal_boundary_status" +assert_contains "backup freshness treats 024 as decimal twenty-four" "$backup_decimal_boundary_output" 'status=ok' +if backup_huge_output="$("$ROOT/scripts/backup-freshness.sh" --max-hours 18446744073709551640 "$TMP/backups/new.tar.gz" 2>&1)"; then + backup_huge_status=0 +else + backup_huge_status=$? +fi +assert_eq "backup freshness rejects oversized hours as usage errors" "2" "$backup_huge_status" +assert_not_contains "backup freshness suppresses arithmetic diagnostics" "$backup_huge_output" 'value too great for base' + +mkdir -p "$TMP/endpoint bin" +cat >"$TMP/endpoint bin/curl" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" != '-q' ]]; then + printf 'unsafe inherited curl configuration\n' >&2 + exit 3 +fi +[[ " $* " == *' --max-time 8 '* ]] || exit 4 +url="${!#}" +case "$url" in + *healthy*) printf '200 0.125' ;; + *failing*) printf '503 0.500' ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/endpoint bin/curl" +if endpoint_output="$(CURL_BIN="$TMP/endpoint bin/curl" "$ROOT/scripts/endpoint-health.sh" \ + 'https://healthy.example.test/status?token=SECRET_QUERY' \ + 'https://failing.example.test/status' 2>&1)"; then + endpoint_status=0 +else + endpoint_status=$? +fi +assert_eq "endpoint health completes" "0" "$endpoint_status" +assert_contains "endpoint health reports aggregate critical status" "$endpoint_output" 'status=critical' +assert_contains "endpoint health counts endpoints" "$endpoint_output" 'endpoint_count=2' +assert_contains "endpoint health counts failures" "$endpoint_output" 'failed_count=1' +assert_contains "endpoint health reports successful timing" "$endpoint_output" 'endpoint=1 http_code=200 latency_ms=125' +assert_contains "endpoint health reports HTTP failures" "$endpoint_output" 'endpoint=2 http_code=503 latency_ms=500' +assert_not_contains "endpoint health omits URLs" "$endpoint_output" 'healthy.example.test' +assert_not_contains "endpoint health omits query secrets" "$endpoint_output" 'SECRET_QUERY' +if endpoint_decimal_output="$(CURL_BIN="$TMP/endpoint bin/curl" "$ROOT/scripts/endpoint-health.sh" --timeout-seconds 08 --slow-ms 02000 'https://healthy.example.test/' 2>&1)"; then + endpoint_decimal_status=0 +else + endpoint_decimal_status=$? +fi +assert_eq "endpoint health accepts leading-zero decimal options" "0" "$endpoint_decimal_status" +assert_contains "endpoint health preserves decimal option behavior" "$endpoint_decimal_output" 'status=ok' +if endpoint_huge_output="$(CURL_BIN="$TMP/endpoint bin/curl" "$ROOT/scripts/endpoint-health.sh" --timeout-seconds 18446744073709551624 'https://healthy.example.test/' 2>&1)"; then + endpoint_huge_status=0 +else + endpoint_huge_status=$? +fi +assert_eq "endpoint health rejects oversized options as usage errors" "2" "$endpoint_huge_status" +assert_not_contains "endpoint health suppresses arithmetic diagnostics" "$endpoint_huge_output" 'value too great for base' + +mkdir -p "$TMP/tls bin" +cat >"$TMP/tls bin/openssl" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + s_client) + printf '%s\n' '-----BEGIN CERTIFICATE-----' 'FAKE' '-----END CERTIFICATE-----' + [[ " $* " == *' -connect private.example.test:443 '* \ + && " $* " == *' -verify_hostname private.example.test '* \ + && " $* " == *' -verify_return_error '* ]] || exit 1 + exit 0 + ;; + x509) + printf 'notAfter=Aug 23 00:00:00 2026 GMT\n' + exit 0 + ;; + *) exit 2 ;; +esac +EOF +cat >"$TMP/tls bin/timeout" <<'EOF' +#!/usr/bin/env bash +shift +exec "$@" +EOF +chmod 700 "$TMP/tls bin/openssl" "$TMP/tls bin/timeout" +tls_now="$(date -u -d '2026-07-24 00:00:00' +%s)" +if tls_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" 'private.example.test:0443' 2>&1)"; then + tls_status=0 +else + tls_status=$? +fi +assert_eq "TLS expiry completes" "0" "$tls_status" +assert_contains "TLS expiry reports healthy aggregate status" "$tls_output" 'status=ok' +assert_contains "TLS expiry counts targets" "$tls_output" 'target_count=1' +assert_contains "TLS expiry counts verified connections" "$tls_output" 'verified_count=1' +assert_contains "TLS expiry reports indexed remaining days" "$tls_output" 'target=1 days_remaining=30 verified=yes status=ok' +assert_contains "TLS expiry declares names omitted" "$tls_output" 'target_names_omitted=yes' +assert_not_contains "TLS expiry omits hostnames" "$tls_output" 'private.example.test' + +cat >"$TMP/tls bin/openssl-mismatch" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + s_client) + printf '%s\n' '-----BEGIN CERTIFICATE-----' 'FAKE' '-----END CERTIFICATE-----' + if [[ " $* " == *' -verify_hostname mismatch.example.test '* ]]; then + exit 1 + fi + exit 0 + ;; + x509) printf 'notAfter=Aug 23 00:00:00 2026 GMT\n' ;; + *) exit 2 ;; +esac +EOF +cat >"$TMP/tls bin/openssl-ip" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + s_client) + printf '%s\n' '-----BEGIN CERTIFICATE-----' 'FAKE' '-----END CERTIFICATE-----' + [[ " $* " == *' -verify_ip 192.0.2.1 '* ]] || exit 1 + ;; + x509) printf 'notAfter=Aug 23 00:00:00 2026 GMT\n' ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/tls bin/openssl-mismatch" "$TMP/tls bin/openssl-ip" +if tls_mismatch_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-mismatch" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" 'mismatch.example.test:443' 2>&1)"; then + tls_mismatch_status=0 +else + tls_mismatch_status=$? +fi +assert_eq "TLS expiry collects a hostname mismatch" "0" "$tls_mismatch_status" +assert_contains "TLS expiry rejects a trusted certificate for the wrong hostname" "$tls_mismatch_output" 'status=critical' +assert_contains "TLS expiry marks hostname mismatch unverified" "$tls_mismatch_output" 'target=1 days_remaining=30 verified=no status=critical' +if tls_ip_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-ip" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" '192.0.2.1:443' 2>&1)"; then + tls_ip_status=0 +else + tls_ip_status=$? +fi +assert_eq "TLS expiry completes IP identity verification" "0" "$tls_ip_status" +assert_contains "TLS expiry uses IP identity verification" "$tls_ip_output" 'target=1 days_remaining=30 verified=yes status=ok' + +cat >"$TMP/tls bin/openssl-threshold" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + s_client) + printf '%s\n' '-----BEGIN CERTIFICATE-----' 'FAKE' '-----END CERTIFICATE-----' + [[ " $* " == *' -verify_hostname threshold.example.test '* ]] || exit 1 + ;; + x509) printf 'notAfter=%s\n' "${CERT_END_DATE:?}" ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/tls bin/openssl-threshold" +if tls_critical_boundary_output="$(CERT_END_DATE='Jul 31 00:00:00 2026 GMT' TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-threshold" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" 'threshold.example.test:443' 2>&1)"; then + tls_critical_boundary_status=0 +else + tls_critical_boundary_status=$? +fi +assert_eq "TLS expiry collects exact critical boundary" "0" "$tls_critical_boundary_status" +assert_contains "TLS expiry treats exact critical-day threshold as critical" "$tls_critical_boundary_output" 'target=1 days_remaining=7 verified=yes status=critical' +if tls_warning_boundary_output="$(CERT_END_DATE='Aug 14 00:00:00 2026 GMT' TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-threshold" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" 'threshold.example.test:443' 2>&1)"; then + tls_warning_boundary_status=0 +else + tls_warning_boundary_status=$? +fi +assert_eq "TLS expiry collects exact warning boundary" "0" "$tls_warning_boundary_status" +assert_contains "TLS expiry treats exact warning-day threshold as warning" "$tls_warning_boundary_output" 'target=1 days_remaining=21 verified=yes status=warning' +if tls_decimal_output="$(CERT_END_DATE='Aug 2 00:00:00 2026 GMT' TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-threshold" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" --warn-days 010 --critical-days 07 --timeout-seconds 08 'threshold.example.test:443' 2>&1)"; then + tls_decimal_status=0 +else + tls_decimal_status=$? +fi +assert_eq "TLS expiry accepts leading-zero decimal options" "0" "$tls_decimal_status" +assert_contains "TLS expiry treats 010 as decimal ten" "$tls_decimal_output" 'target=1 days_remaining=9 verified=yes status=warning' +if tls_huge_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-threshold" "$ROOT/scripts/tls-expiry.sh" --warn-days 18446744073709551637 'threshold.example.test:443' 2>&1)"; then + tls_huge_status=0 +else + tls_huge_status=$? +fi +assert_eq "TLS expiry rejects oversized options as usage errors" "2" "$tls_huge_status" +assert_not_contains "TLS expiry suppresses arithmetic diagnostics" "$tls_huge_output" 'value too great for base' +if tls_unbracketed_ipv6_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-ip" "$ROOT/scripts/tls-expiry.sh" '2001:db8::1' 2>&1)"; then + tls_unbracketed_ipv6_status=0 +else + tls_unbracketed_ipv6_status=$? +fi +assert_eq "TLS expiry rejects unbracketed IPv6" "2" "$tls_unbracketed_ipv6_status" +assert_contains "TLS expiry reports generic invalid target syntax" "$tls_unbracketed_ipv6_output" 'ERROR: invalid TLS target syntax' +if tls_multicolon_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-ip" "$ROOT/scripts/tls-expiry.sh" 'host:443:extra' 2>&1)"; then + tls_multicolon_status=0 +else + tls_multicolon_status=$? +fi +assert_eq "TLS expiry rejects malformed multi-colon targets" "2" "$tls_multicolon_status" +assert_contains "TLS expiry reports malformed multi-colon syntax generically" "$tls_multicolon_output" 'ERROR: invalid TLS target syntax' +if tls_huge_port_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-ip" "$ROOT/scripts/tls-expiry.sh" 'host:18446744073709552059' 2>&1)"; then + tls_huge_port_status=0 +else + tls_huge_port_status=$? +fi +assert_eq "TLS expiry rejects oversized ports as usage errors" "2" "$tls_huge_port_status" +assert_not_contains "TLS expiry suppresses port arithmetic diagnostics" "$tls_huge_port_output" 'value too great for base' + +cat >"$TMP/tls bin/openssl-ipv6" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + s_client) + [[ " $* " == *' -verify_ip 2001:db8::1 '* && " $* " == *' -verify_return_error '* ]] || exit 1 + printf 'CERTIFICATE DATA\n' + ;; + x509) printf 'notAfter=Aug 23 00:00:00 2026 GMT\n' ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/tls bin/openssl-ipv6" +if tls_bracketed_ipv6_output="$(TIMEOUT_BIN="$TMP/tls bin/timeout" OPENSSL_BIN="$TMP/tls bin/openssl-ipv6" NOW_EPOCH="$tls_now" "$ROOT/scripts/tls-expiry.sh" '[2001:db8::1]:443' 2>&1)"; then + tls_bracketed_ipv6_status=0 +else + tls_bracketed_ipv6_status=$? +fi +assert_eq "TLS expiry preserves bracketed IPv6 support" "0" "$tls_bracketed_ipv6_status" +assert_contains "TLS expiry verifies bracketed IPv6 identity" "$tls_bracketed_ipv6_output" 'target=1 days_remaining=30 verified=yes status=ok' + +mkdir -p "$TMP/container bin" +cat >"$TMP/container bin/docker" <<'EOF' +#!/usr/bin/env bash +case "$*" in + 'info --format {{.ServerVersion}}') printf '27.0.0\n' ;; + 'ps -a --format {{.Status}}') + printf '%s\n' \ + 'Up 2 hours (healthy)' \ + 'Up 3 hours (unhealthy)' \ + 'Exited (0) 1 hour ago' \ + 'Created' + ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/container bin/docker" +if container_output="$(DOCKER_BIN="$TMP/container bin/docker" PODMAN_BIN=/not-present "$ROOT/scripts/container-health.sh" 2>&1)"; then + container_status=0 +else + container_status=$? +fi +assert_eq "container health completes" "0" "$container_status" +assert_contains "container health detects Docker" "$container_output" 'engine=docker' +assert_contains "container health counts all containers" "$container_output" 'container_count=4' +assert_contains "container health counts running containers" "$container_output" 'running_count=2' +assert_contains "container health counts stopped containers" "$container_output" 'stopped_count=2' +assert_contains "container health counts unhealthy containers" "$container_output" 'unhealthy_count=1' +assert_contains "container health reports warning state" "$container_output" 'status=warning' +assert_contains "container health declares names omitted" "$container_output" 'container_names_omitted=yes' +assert_not_contains "container health omits image and container names" "$container_output" 'private-app' + +cat >"$TMP/container bin/docker-offline" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF +cat >"$TMP/container bin/podman" <<'EOF' +#!/usr/bin/env bash +case "$*" in + 'info --format {{.Version.Version}}') printf '5.0.0\n' ;; + 'ps -a --format {{.Status}}') printf '%s\n' 'running' 'exited' ;; + *) exit 2 ;; +esac +EOF +chmod 700 "$TMP/container bin/docker-offline" "$TMP/container bin/podman" +if podman_output="$(DOCKER_BIN="$TMP/container bin/docker-offline" PODMAN_BIN="$TMP/container bin/podman" "$ROOT/scripts/container-health.sh" 2>&1)"; then + podman_status=0 +else + podman_status=$? +fi +assert_eq "container health falls back from offline Docker to Podman" "0" "$podman_status" +assert_contains "container health detects Podman fallback" "$podman_output" 'engine=podman' +assert_contains "container health counts Podman running state" "$podman_output" 'running_count=1' +assert_contains "container health counts Podman stopped state" "$podman_output" 'stopped_count=1' + +mkdir -p "$TMP/log bin" "$TMP/private-logs" +cat >"$TMP/log bin/du" <<'EOF' +#!/usr/bin/env bash +path="${!#}" +printf '2147483648\t%s\n' "$path" +EOF +chmod 700 "$TMP/log bin/du" +if log_output="$(DU_BIN="$TMP/log bin/du" "$ROOT/scripts/log-storage.sh" --warn-gib 1 --critical-gib 5 "$TMP/private-logs" 2>&1)"; then + log_status=0 +else + log_status=$? +fi +assert_eq "log storage completes" "0" "$log_status" +assert_contains "log storage reports aggregate warning" "$log_output" 'status=warning' +assert_contains "log storage counts targets" "$log_output" 'target_count=1' +assert_contains "log storage reports indexed allocated bytes" "$log_output" 'target=1 bytes=2147483648 status=warning' +assert_contains "log storage declares paths omitted" "$log_output" 'paths_omitted=yes' +assert_not_contains "log storage omits target paths" "$log_output" "$TMP/private-logs" + +cat >"$TMP/log bin/du-partial" <<'EOF' +#!/usr/bin/env bash +path="${!#}" +printf '536870912\t%s\n' "$path" +exit 1 +EOF +chmod 700 "$TMP/log bin/du-partial" +if partial_log_output="$(DU_BIN="$TMP/log bin/du-partial" "$ROOT/scripts/log-storage.sh" --warn-gib 1 --critical-gib 5 "$TMP/private-logs" 2>&1)"; then + partial_log_status=0 +else + partial_log_status=$? +fi +assert_eq "log storage accepts useful partial du output" "0" "$partial_log_status" +assert_contains "log storage reports partial aggregate warning" "$partial_log_output" 'status=warning' +assert_contains "log storage counts partial targets" "$partial_log_output" 'partial_count=1' +assert_contains "log storage marks collection incomplete" "$partial_log_output" 'collection_complete=no' +assert_contains "log storage preserves partial byte measurement" "$partial_log_output" 'target=1 bytes=536870912 status=partial' +assert_not_contains "partial log storage omits target paths" "$partial_log_output" "$TMP/private-logs" +cat >"$TMP/log bin/du-decimal" <<'EOF' +#!/usr/bin/env bash +path="${!#}" +printf '9663676416\t%s\n' "$path" +EOF +chmod 700 "$TMP/log bin/du-decimal" +if log_decimal_output="$(DU_BIN="$TMP/log bin/du-decimal" "$ROOT/scripts/log-storage.sh" --warn-gib 08 --critical-gib 010 "$TMP/private-logs" 2>&1)"; then + log_decimal_status=0 +else + log_decimal_status=$? +fi +assert_eq "log storage accepts leading-zero decimal thresholds" "0" "$log_decimal_status" +assert_contains "log storage treats 010 as decimal ten" "$log_decimal_output" 'target=1 bytes=9663676416 status=warning' +if log_huge_output="$(DU_BIN="$TMP/log bin/du" "$ROOT/scripts/log-storage.sh" --warn-gib 1 --critical-gib 18446744073709551621 "$TMP/private-logs" 2>&1)"; then + log_huge_status=0 +else + log_huge_status=$? +fi +assert_eq "log storage rejects oversized thresholds as usage errors" "2" "$log_huge_status" +assert_not_contains "log storage suppresses arithmetic diagnostics" "$log_huge_output" 'value too great for base' + +printf '\nRESULT: %d passed, %d failed\n' "$PASS" "$FAIL" +((FAIL == 0)) diff --git a/tests/test-scripts.sh b/tests/test-scripts.sh index 1724906..9abbe88 100755 --- a/tests/test-scripts.sh +++ b/tests/test-scripts.sh @@ -8,7 +8,32 @@ SCRIPTS=( "scripts/cpanel-followup-triage.sh" "scripts/directory-sizes.sh" "scripts/exim-queue-health.sh" + "scripts/filesystem-health.sh" + "scripts/memory-pressure.sh" + "scripts/service-health.sh" + "scripts/listener-exposure.sh" + "scripts/update-posture.sh" + "scripts/backup-freshness.sh" + "scripts/endpoint-health.sh" + "scripts/tls-expiry.sh" + "scripts/container-health.sh" + "scripts/log-storage.sh" ) +RELEASED_SCRIPTS=( + "scripts/vps-profile.sh" + "scripts/cpanel-followup-triage.sh" + "scripts/directory-sizes.sh" + "scripts/exim-queue-health.sh" +) + +expected_version() { + case "$1" in + scripts/vps-profile.sh|scripts/cpanel-followup-triage.sh|scripts/directory-sizes.sh|scripts/exim-queue-health.sh) + printf '2.0.0\n' + ;; + *) printf '2.1.0\n' ;; + esac +} PASS=0 FAIL=0 TMP="$(mktemp -d)" @@ -55,7 +80,8 @@ for relative in "${SCRIPTS[@]}"; do fi version_output="$("$path" --version 2>/dev/null || true)" - assert_eq "$relative --version" "$VERSION" "$version_output" + script_version="$(expected_version "$relative")" + assert_eq "$relative --version" "$script_version" "$version_output" isolated_dir="$TMP/isolated-$(basename "$relative" .sh)" mkdir -p "$isolated_dir" @@ -67,7 +93,7 @@ for relative in "${SCRIPTS[@]}"; do fail "$relative remains runnable outside the catalog" fi isolated_version="$("$isolated_dir/module.sh" --version 2>/dev/null || true)" - assert_eq "$relative isolated --version" "$VERSION" "$isolated_version" + assert_eq "$relative isolated --version" "$script_version" "$isolated_version" done PROFILE_SCRIPT="$ROOT/scripts/vps-profile.sh" @@ -432,6 +458,9 @@ if [[ -f "$ROOT/README.md" ]]; then else fail "README catalogs $name" fi + done + for relative in "${RELEASED_SCRIPTS[@]}"; do + name="${relative##*/}" expected_url="https://raw.githubusercontent.com/RomeoRaven/ONEScripts/v${VERSION}/${relative}" checksum="$(sha256sum "$ROOT/$relative" 2>/dev/null | awk '{print $1}')" invocation_line="$(grep -F "$expected_url" "$ROOT/README.md" 2>/dev/null || true)" @@ -464,6 +493,12 @@ else fail "legacy executables are absent from the active tree (${legacy_present[*]})" fi +if "$ROOT/tests/test-linux-modules.sh"; then + pass "Linux module behavioral suite passes" +else + fail "Linux module behavioral suite passes" +fi + if "$ROOT/tests/test-launcher.sh"; then pass "master launcher behavioral suite passes" else