Module 5: Remote Machines, Networking, and Scripting
8. Final project: remote diagnostics toolkit
Description
In this lesson you are going to build ops-toolkit: three shell scripts that combine to diagnose
whether a remote server is healthy, and a README.md that documents how to use it and how to defend
it in front of someone else. check-env.sh audits that a machine has what it needs to operate
(dependencies, PATH, environment variables, permissions on a private key). probe.sh diagnoses a
domain layer by layer (DNS, HTTP, local port). remote-report.sh connects both over SSH to a remote
machine, brings back the results with rsync, and builds a summary with grep, sort, and uniq -c.
Each script exits with a different code depending on what failed, documented in a table — not "it went
wrong," but "it went wrong because a dependency is missing" versus "it went wrong because DNS does not
resolve."
This is exactly what an engineer does when the message "the site is not responding" arrives at 11 at night: they do not open an IDE, they open a terminal, connect to the server, and run a handful of commands in an order they already know by heart because they automated it a while ago. The difference between "it took me forty minutes typing the usual stuff" and "I ran a script and in ten seconds I knew it was DNS" is exactly the content of this module.
Connection to the module: this project does not teach anything new — it wires together everything you
already saw. Environment variables and file permissions (lesson 1), IP/ports/DNS (lesson 2),
dig/curl/ss (lesson 3), SSH with keys and ~/.ssh/config (lesson 4), rsync (lesson 5), and
arguments, case, loops, exit codes, set -euo pipefail, and trap (lessons 6 and 7) turn here into
a single tool that runs from end to end.
The toolkit's architecture: three layers, one responsibility each
Think of an electrician called to a house they have never seen. They do not start by tearing open
walls. First they check the electrical panel: is power coming in, are the breakers where they should
be, is the panel labeled or is it chaos? That is check-env.sh — it does not diagnose anything about
the actual problem, it just confirms the tools and the environment are in shape for a real diagnosis.
Then they test each outlet separately, in order: does power reach the box (DNS), does the appliance
respond when you plug it in (HTTP), is the specific breaker switched on (local port)? That is
probe.sh. And if the house is in another city, the electrician does not drive out there empty-handed:
they send someone with the same tools, that person checks on site, and the result comes back by
courier. That is remote-report.sh.
The reason there are three scripts and not one with five hundred lines is the same one you saw in
lesson 7 about when a script has "already grown too big": each one does one thing, each one can be
tested on its own, and remote-report.sh does not repeat the diagnostic logic — it reuses it by
copying it to the remote machine and running it there. It is the Unix philosophy applied to your own
tools, not just to other people's.
One warning before you start: ss is part of iproute2, exclusive to Linux. If you write and test
these scripts from macOS, layer 3 of probe.sh is going to fail locally with "command not found" —
which is exactly why check-env.sh verifies its presence instead of assuming it. The real design is
that you run probe.sh on the server (almost always Linux) through remote-report.sh, and use it on
your laptop only for the "from outside" view (DNS and HTTP).
Create a folder ops-toolkit/ and build this inside it as you go:
ops-toolkit/
├── check-env.sh
├── probe.sh
├── remote-report.sh
├── domains.txt
└── README.md
Worked example: check-env.sh, the entry gate
check-env.sh does not test the network or servers — it tests that your own machine is ready to
operate the toolkit. It checks four things, in order: that PATH includes the basics, that the
commands the rest of the toolkit needs actually exist, that the TOOLKIT_SSH_KEY variable (the path to
your private key) is defined, and that key has permissions only you can read.
#!/usr/bin/env bash
# check-env.sh — audits that this machine is ready to run ops-toolkit:
# PATH, required commands, environment variables, and SSH key permissions.
set -euo pipefail
# Exit codes for the whole toolkit (also documented in README.md).
readonly EX_OK=0
readonly EX_USAGE=64 # invalid arguments
readonly EX_UNAVAILABLE=69 # a required command is missing
readonly EX_NOPERM=77 # unsafe permissions on a sensitive file
readonly EX_CONFIG=78 # an environment variable is missing
SCRIPT_NAME="$(basename "$0")"
DRY_RUN=0
usage() {
cat <<EOF
Usage: ${SCRIPT_NAME} [--dry-run] [--help]
Checks that this machine has what it needs to run ops-toolkit:
required commands on PATH, the TOOLKIT_SSH_KEY variable, and safe
permissions on the private key it points to.
--dry-run Shows what would be checked, without aborting on any failure.
--help Shows this help and exits with code 0.
Exit codes: see the table in README.md.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--help) usage; exit "${EX_OK}" ;;
*)
echo "Error: unknown argument '$1'" >&2
usage >&2
exit "${EX_USAGE}"
;;
esac
done
fail() {
local code="$1" msg="$2"
echo "FAILED: ${msg}" >&2
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo " (dry-run: not aborting, but in normal mode it would exit with code ${code})"
else
exit "${code}"
fi
}
echo "== PATH =="
echo " current PATH: ${PATH}"
for dir in /usr/bin /bin; do
case ":${PATH}:" in
*":${dir}:"*) echo " ok: ${dir} is in PATH" ;;
*) echo " warning: ${dir} is not in PATH (typical if this runs under cron)" ;;
esac
done
echo "== dependencies =="
REQUIRED_COMMANDS=(dig curl ss ssh rsync)
for cmd in "${REQUIRED_COMMANDS[@]}"; do
if command -v "${cmd}" >/dev/null 2>&1; then
echo " ok: ${cmd} -> $(command -v "${cmd}")"
else
fail "${EX_UNAVAILABLE}" "missing command '${cmd}' on PATH"
fi
done
echo "== TOOLKIT_SSH_KEY environment variable =="
if [[ -z "${TOOLKIT_SSH_KEY:-}" ]]; then
fail "${EX_CONFIG}" "TOOLKIT_SSH_KEY is not set (example: export TOOLKIT_SSH_KEY=\$HOME/.ssh/id_ed25519)"
else
echo " ok: TOOLKIT_SSH_KEY=${TOOLKIT_SSH_KEY}"
fi
echo "== private key permissions =="
if [[ -n "${TOOLKIT_SSH_KEY:-}" && -f "${TOOLKIT_SSH_KEY}" ]]; then
# %Lp (BSD/macOS) or %a (GNU/Linux) return the octal mode, e.g. "600".
# Assumes the key has no setuid/sticky bit, which is the normal case.
PERMS="$(stat -f "%Lp" "${TOOLKIT_SSH_KEY}" 2>/dev/null || stat -c "%a" "${TOOLKIT_SSH_KEY}")"
if [[ "${PERMS}" =~ ^[0-7]00$ ]]; then
echo " ok: permissions ${PERMS} (only the owner can read/write)"
else
fail "${EX_NOPERM}" "${TOOLKIT_SSH_KEY} has permissions ${PERMS}; fix with: chmod 600 ${TOOLKIT_SSH_KEY}"
fi
elif [[ -n "${TOOLKIT_SSH_KEY:-}" ]]; then
fail "${EX_CONFIG}" "TOOLKIT_SSH_KEY points to '${TOOLKIT_SSH_KEY}', which does not exist"
fi
echo "check-env.sh: everything is fine."
exit "${EX_OK}"
How to read it: the fail() function centralizes the message ("FAILED: ...") and the exit code in one
single place, and it respects --dry-run — instead of deciding at every failure point whether to abort
or not, every call to fail() already knows what to do based on the mode. The permissions check uses a
regular expression (^[0-7]00$) instead of comparing numbers, because comparing "640" against "600"
with -gt compares decimal integers, not permission bits — it works most of the time by coincidence,
but the regex says exactly what you mean: "the last two digits must be zero."
What to expect when you run it with the variable set correctly and the key at 600:
$ chmod +x check-env.sh
$ export TOOLKIT_SSH_KEY=$HOME/.ssh/id_ed25519
$ ./check-env.sh
== PATH ==
current PATH: /usr/local/bin:/usr/bin:/bin
ok: /usr/bin is in PATH
ok: /bin is in PATH
== dependencies ==
ok: dig -> /usr/bin/dig
ok: curl -> /usr/bin/curl
ok: ss -> /usr/sbin/ss
ok: ssh -> /usr/bin/ssh
ok: rsync -> /usr/bin/rsync
== TOOLKIT_SSH_KEY environment variable ==
ok: TOOLKIT_SSH_KEY=/home/ana/.ssh/id_ed25519
== private key permissions ==
ok: permissions 600 (only the owner can read/write)
check-env.sh: everything is fine.
$ echo $?
0
And if the key ended up with 644 permissions (readable by anyone in the group):
$ chmod 644 ~/.ssh/id_ed25519
$ ./check-env.sh; echo "exit: $?"
...
== private key permissions ==
FAILED: /home/ana/.ssh/id_ed25519 has permissions 644; fix with: chmod 600 /home/ana/.ssh/id_ed25519
exit: 77
The 77 is not arbitrary — it is the same code you are going to document in the README.md table, and
it is the one remote-report.sh is going to interpret later on.
probe.sh: diagnosing a domain layer by layer
The idea of "diagnosing layer by layer" comes straight from lesson 3: when something does not respond,
the order of suspicion goes from the most basic to the most specific. If the name does not resolve,
there is no point testing HTTP. If HTTP responds, there is no point suspecting DNS. probe.sh
automates exactly that elimination order, splitting it into three layers: dig for DNS, curl -w for
HTTP with timing, and ss to confirm whether a process is listening locally on the port — this last
one meant for when you run the script on the server itself (via remote-report.sh), where it tells
you whether the problem is that the service never started, rather than a network issue.
#!/usr/bin/env bash
# probe.sh — diagnoses a domain across three layers: DNS, HTTP, and the local
# port's state. Results go to stdout, errors to stderr.
set -euo pipefail
readonly EX_OK=0
readonly EX_USAGE=64
readonly EX_UNAVAILABLE=69 # DNS does not resolve
readonly EX_SOFTWARE=70 # the HTTP request failed (timeout, connection refused)
SCRIPT_NAME="$(basename "$0")"
PORT=""
SCHEME="https"
DRY_RUN=0
DOMAIN=""
usage() {
cat <<EOF
Usage: ${SCRIPT_NAME} <domain> [--port PORT] [--scheme http|https] [--dry-run] [--help]
Diagnoses a domain across three layers: DNS (dig), HTTP (curl -w), and the
local port's state (ss).
--port PORT Port for ss (default: 443 if scheme=https, 80 if http)
--scheme http|https Scheme for the curl request (default: https)
--dry-run Shows the commands, without running them
--help Shows this help and exits with code 0
Exit codes: see the table in README.md.
EOF
}
fail() {
local code="$1" msg="$2"
echo "FAILED: ${msg}" >&2
exit "${code}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--scheme) SCHEME="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--help) usage; exit "${EX_OK}" ;;
-*) fail "${EX_USAGE}" "unknown option '$1'" ;;
*)
[[ -n "${DOMAIN}" ]] && fail "${EX_USAGE}" "only one domain is accepted, already got '${DOMAIN}'"
DOMAIN="$1"
shift
;;
esac
done
[[ -z "${DOMAIN}" ]] && { usage >&2; fail "${EX_USAGE}" "missing the domain to diagnose"; }
[[ -z "${PORT}" ]] && { [[ "${SCHEME}" == "https" ]] && PORT=443 || PORT=80; }
echo "== Layer 1/3: DNS (${DOMAIN}) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "[dry-run] dig +short ${DOMAIN}"
else
# dig can fail (network down) or simply have no records; in both cases IP
# ends up empty and we treat it as a single failure condition.
IP="$(dig +short "${DOMAIN}" | tail -n1 || true)"
[[ -z "${IP}" ]] && fail "${EX_UNAVAILABLE}" "no record resolves for '${DOMAIN}'"
echo "resolves to: ${IP}"
fi
echo "== Layer 2/3: HTTP (${SCHEME}://${DOMAIN}) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "[dry-run] curl -o /dev/null -s -w '...' --max-time 5 ${SCHEME}://${DOMAIN}"
else
if ! curl -o /dev/null -s --max-time 5 \
-w 'http_code=%{http_code} time_namelookup=%{time_namelookup}s time_connect=%{time_connect}s time_total=%{time_total}s\n' \
"${SCHEME}://${DOMAIN}"; then
fail "${EX_SOFTWARE}" "curl could not complete the request to ${SCHEME}://${DOMAIN} (timeout or connection refused)"
fi
fi
echo "== Layer 3/3: local port ${PORT} (ss) =="
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "[dry-run] ss -tuln | grep -E ':${PORT}\b'"
else
if ss -tuln | grep -qE ":${PORT}\b"; then
echo "there is a process listening locally on port ${PORT}"
else
echo "nothing is listening locally on port ${PORT} (normal if this runs outside the server)"
fi
fi
echo "OK"
exit "${EX_OK}"
A detail worth looking at twice: I use tail -n1, not head -n1, on dig +short's output. When a
domain has a CNAME chain, dig +short prints the name it points to first and then the final IP —
taking the first line gets you a hostname, not an address. tail -n1 assumes the chain ends in an A
record, which is the normal case, but it is not foolproof (we come back to this in common mistakes).
What to expect against a real domain:
$ ./probe.sh example.com
== Layer 1/3: DNS (example.com) ==
resolves to: 93.184.216.34
== Layer 2/3: HTTP (https://example.com) ==
http_code=200 time_namelookup=0.012s time_connect=0.045s time_total=0.187s
== Layer 3/3: local port 443 (ss) ==
nothing is listening locally on port 443 (normal if this runs outside the server)
OK
$ echo $?
0
And against a domain that does not exist:
$ ./probe.sh domain-that-does-not-exist-xyz.test
== Layer 1/3: DNS (domain-that-does-not-exist-xyz.test) ==
FAILED: no record resolves for 'domain-that-does-not-exist-xyz.test'
$ echo $?
69
remote-report.sh: orchestrating the remote diagnosis
This script does not diagnose anything by itself — it orchestrates. It takes an alias from
~/.ssh/config and a file with a list of domains (one per line), and for each one: runs check-env.sh
once on the remote as an entry gate, runs probe.sh for each domain saving the output to files on the
server itself, brings everything back with a single rsync, and builds a local summary with
grep | sort | uniq -c. The reason for writing the results to disk on the remote and bringing them
back afterward with rsync — instead of letting ssh stream the output straight to your terminal — is
deliberate: that way the "retrieve results" exercise is real, not cosmetic, and if the connection drops
halfway through a long diagnostic run, the results of whatever already ran are still there to recover
afterward.
#!/usr/bin/env bash
# remote-report.sh — runs check-env.sh and probe.sh on a remote server for a
# list of domains, brings back the results with rsync, and summarizes with grep/sort/uniq.
set -euo pipefail
readonly EX_OK=0
readonly EX_USAGE=64
readonly EX_UNAVAILABLE=69 # check-env.sh failed on the remote
readonly EX_CONFIG=78 # invalid SSH alias or domains file
SCRIPT_NAME="$(basename "$0")"
# Resolves the folder this script lives in, no matter where you invoke it from.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=0
usage() {
cat <<EOF
Usage: ${SCRIPT_NAME} <ssh-alias> <domains-file> [--dry-run] [--help]
Connects to <ssh-alias> (must exist as a 'Host' in ~/.ssh/config, lesson 4),
runs check-env.sh and probe.sh for each domain in <domains-file> (one per
line), brings back the results with rsync, and shows a final summary.
--dry-run Shows what would be run, without connecting to anything.
--help Shows this help and exits with code 0.
Exit codes: see the table in README.md.
EOF
}
fail() {
local code="$1" msg="$2"
echo "FAILED: ${msg}" >&2
exit "${code}"
}
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--help) usage; exit "${EX_OK}" ;;
-*) fail "${EX_USAGE}" "unknown option '$1'" ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
set -- "${POSITIONAL[@]}"
[[ $# -ne 2 ]] && { usage >&2; fail "${EX_USAGE}" "exactly 2 arguments are expected: alias and domains file"; }
ALIAS="$1"
DOMAINS_FILE="$2"
[[ -f "${DOMAINS_FILE}" ]] || fail "${EX_CONFIG}" "domains file '${DOMAINS_FILE}' does not exist"
# Validated even under --dry-run: it is a local read, costs no network trip,
# and catches the most common configuration mistake before touching anything.
grep -qE "^Host[[:space:]]+${ALIAS}([[:space:]]|\$)" "${HOME}/.ssh/config" 2>/dev/null \
|| fail "${EX_CONFIG}" "'${ALIAS}' is not defined as a Host in ~/.ssh/config (lesson 4)"
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "[dry-run] ssh ${ALIAS} mktemp -d"
echo "[dry-run] rsync check-env.sh probe.sh -> ${ALIAS}:<remote>/"
echo "[dry-run] ssh ${ALIAS} bash check-env.sh"
while IFS= read -r domain || [[ -n "${domain}" ]]; do
[[ -z "${domain}" || "${domain}" == \#* ]] && continue
echo "[dry-run] ssh ${ALIAS} bash probe.sh ${domain}"
done < "${DOMAINS_FILE}"
echo "[dry-run] rsync ${ALIAS}:<remote>/results/ -> ./reports/<timestamp>/"
exit "${EX_OK}"
fi
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
LOCAL_REPORTS_DIR="./reports/${TIMESTAMP}"
REMOTE_DIR=""
# Unlike check-env.sh and probe.sh, this trap does not delete the final
# report — it only cleans up what this script left on the remote server.
cleanup() {
[[ -n "${REMOTE_DIR}" ]] && ssh "${ALIAS}" "rm -rf '${REMOTE_DIR}'" 2>/dev/null || true
}
trap cleanup EXIT
mkdir -p "${LOCAL_REPORTS_DIR}"
echo "== Preparing remote directory on ${ALIAS} =="
REMOTE_DIR="$(ssh "${ALIAS}" mktemp -d)"
ssh "${ALIAS}" "mkdir -p '${REMOTE_DIR}/results'"
echo "== Copying scripts with rsync =="
rsync -az "${SCRIPT_DIR}/check-env.sh" "${SCRIPT_DIR}/probe.sh" "${ALIAS}:${REMOTE_DIR}/"
echo "== Running check-env.sh on the remote =="
if ! ssh "${ALIAS}" "bash '${REMOTE_DIR}/check-env.sh'" \
> "${LOCAL_REPORTS_DIR}/check-env.out" 2> "${LOCAL_REPORTS_DIR}/check-env.err"; then
cat "${LOCAL_REPORTS_DIR}/check-env.err" >&2
fail "${EX_UNAVAILABLE}" "check-env.sh failed on '${ALIAS}'; check ${LOCAL_REPORTS_DIR}/check-env.err"
fi
echo "== Diagnosing domains =="
while IFS= read -r domain || [[ -n "${domain}" ]]; do
[[ -z "${domain}" || "${domain}" == \#* ]] && continue
echo " -- ${domain} --"
# The '||' is intentional: if one domain fails, the rest of the list must
# keep running. Without it, set -e would take down the whole batch on the first failure.
ssh "${ALIAS}" \
"bash '${REMOTE_DIR}/probe.sh' '${domain}' > '${REMOTE_DIR}/results/${domain}.out' 2> '${REMOTE_DIR}/results/${domain}.err'" \
|| echo " (probe.sh reported a failure for ${domain}; continuing with the rest)"
done < "${DOMAINS_FILE}"
echo "== Bringing back results with rsync =="
rsync -az "${ALIAS}:${REMOTE_DIR}/results/" "${LOCAL_REPORTS_DIR}/"
echo "== Summary =="
echo "-- Failures recorded --"
grep -h "^FAILED" "${LOCAL_REPORTS_DIR}"/*.err 2>/dev/null | sort | uniq -c | sort -rn || echo " (none)"
echo "-- HTTP codes seen --"
grep -hoE 'http_code=[0-9]+' "${LOCAL_REPORTS_DIR}"/*.out 2>/dev/null | sort | uniq -c || echo " (none)"
echo "Full results in: ${LOCAL_REPORTS_DIR}"
exit "${EX_OK}"
The point most worth defending out loud if someone asks you about this script: the || at the end of
the ssh line inside the loop is not decoration. Under set -e, a command that fails inside a loop
without that || ends the entire script the moment the first domain in the list fails — the rest of
the list never gets tested, and the report looks like "the script broke" when in reality only one
domain was down. The || turns that individual failure into a data point in the report, not an
accident.
What to expect with a three-line domains.txt where one is down:
$ cat domains.txt
example.com
example.org
domain-that-does-not-exist-xyz.test
$ ./remote-report.sh webserver domains.txt
== Preparing remote directory on webserver ==
== Copying scripts with rsync ==
== Running check-env.sh on the remote ==
== Diagnosing domains ==
-- example.com --
-- example.org --
-- domain-that-does-not-exist-xyz.test --
(probe.sh reported a failure for domain-that-does-not-exist-xyz.test; continuing with the rest)
== Bringing back results with rsync ==
== Summary ==
-- Failures recorded --
1 FAILED: no record resolves for 'domain-that-does-not-exist-xyz.test'
-- HTTP codes seen --
2 http_code=200
Full results in: ./reports/20260721-221045
$ echo $?
0
The script exits with 0 even though a domain failed, because the report itself got generated
correctly — it is the content of the summary, not remote-report.sh's exit code, that tells you
something is down. That distinction between "the diagnosis failed" and "the diagnosis found a problem"
is intentional and worth being able to explain.
README.md and how to defend it live
The README.md is the first thing someone who did not write the toolkit reads — including you
yourself, six months later. At minimum, it needs the exit-code table (the same one you already used
above, now in one single place), the requirements, and usage examples:
# ops-toolkit
Remote server diagnostics: local environment, a domain's network layers, and
orchestration via SSH with an aggregated report.
## Requirements
- bash 4 or newer
- `dig` (package `bind-utils` / `dnsutils`), `curl`, `ssh`, `rsync`
- `ss` (package `iproute2`) — only needed on the remote server, not on your laptop
## Environment variables
| Variable | Used by | Description |
|-------------------|-----------------|---------------------------------------------|
| `TOOLKIT_SSH_KEY` | check-env.sh | Path to the SSH private key to audit |
## Exit codes
| Code | Constant | When it appears | Script(s) |
|--------|----------------|------------------------------------------------------------------|--------------------------------------------|
| 0 | EX_OK | Everything is fine | all three |
| 64 | EX_USAGE | Invalid or missing argument | all three |
| 69 | EX_UNAVAILABLE | A command is missing / DNS does not resolve / check-env failed remotely | check-env.sh, probe.sh, remote-report.sh |
| 70 | EX_SOFTWARE | The HTTP request failed (timeout, connection refused) | probe.sh |
| 77 | EX_NOPERM | Unsafe permissions on the private key | check-env.sh |
| 78 | EX_CONFIG | Missing env var / SSH alias / domains file | check-env.sh, remote-report.sh |
## Usage
./check-env.sh --help
./probe.sh example.com --scheme https
./remote-report.sh webserver domains.txt
## How to demo it live
1. `./check-env.sh --help` and `./probe.sh --help` — shows the toolkit documents itself.
2. `./probe.sh example.com --dry-run` — shows the plan without touching the network.
3. Trigger a real failure (temporarily rename `dig`, or use a domain that does not exist) and
show `echo $?` — the code matches the table above.
4. `./remote-report.sh <alias> domains.txt` against a list with one domain down on purpose —
the final summary should show it without the whole script blowing up.
5. Open one of the three scripts and point to a specific decision: why that exit code and
not another, what the `trap` cleans up, what would break if you removed `set -o pipefail`.
Defending this project live is not reading the code out loud — it is being able to answer three
questions without looking at the screen: "why this exit code and not another?" (because you decided it
and documented it, not because some law imposes it — more on this in common mistakes), "what happens if
I run this without set -euo pipefail?" (a cd that fails silently, an empty variable treated as if it
had a value, a failure in the middle of a pipe that nobody notices), and "what does the trap clean
up?" (in remote-report.sh, the temporary folder on the server, never your local report). If you can
answer those three without opening the file, you have already defended the project.
Common mistakes
Believing exit codes are a standard the system enforces. You defined 69 as "a dependency is
missing" and 78 as "configuration is missing" — but that is a convention of yours, documented in
your own README.md, not a law other programs respect. curl, for instance, has its own completely
different table: code 6 means "could not resolve host" and 28 means "operation timed out" — nothing
to do with the sysexits numbering this toolkit uses. How to spot it: if you chain your script's output
with an external tool's and assume the same number means the same thing in both, you are going to
misdiagnose the failure. How to fix it: never reuse an external program's exit code as if it were your
own — check it in that program's own documentation (man curl has the full list) and decide yourself
which of your own codes matches that case.
Trusting the first line of dig +short when the domain has a CNAME chain. A domain can resolve
through several aliases before reaching an IP (www.example.com → cdn.example.net →
93.184.216.34), and dig +short prints the whole chain, one line per hop. Taking the first line with
head -n1 gets you the first alias, not the final IP — and if something later in the script expects an
IP address to, say, filter with ss, it is going to fail confusingly. How to spot it: run
dig +short <domain> by hand and count how many lines come out; if there is more than one, the first
one is almost never the IP. How to fix it: probe.sh uses tail -n1, which assumes the chain ends in
an A record (the normal case) — for the general, robust case, filter explicitly with an IPv4 regular
expression instead of trusting the line's position.
Writing remote-report.sh's loop without the safety || echo. Under set -euo pipefail, the
first domain in the list that fails aborts the entire script right there — the domains after it in
domains.txt never get tested, and you do not even find out they existed, because the final summary
never gets generated either. It is the exact counterpart to the lesson-7 case where -e does not
save you: here -e works exactly as designed (it aborts on a failure), but that behavior is the
opposite of what this script needs for a batch of domains. How to spot it: run the toolkit against a
list with one domain down in the middle and check whether the domains after it show up in the summary.
How to fix it: any command inside a loop whose individual failure should not stop the rest needs its
own explicit || — the "abort on the first error" policy is global unless you, command by command,
decide otherwise.
Exercises
Exercise 1. check-env.sh does not check that ~/.ssh/config exists before remote-report.sh
tries to read it later on. Add a check at the end of check-env.sh: if ~/.ssh/config does not exist
or is not readable, exit with EX_CONFIG (78) and a clear message.
See solution
echo "== ~/.ssh/config =="
if [[ -r "${HOME}/.ssh/config" ]]; then
echo " ok: ${HOME}/.ssh/config exists and is readable"
else
fail "${EX_CONFIG}" "${HOME}/.ssh/config does not exist or cannot be read (needed for remote-report.sh)"
fi
Why it works: -r tests both existence and read permission in a single step — it avoids the most
common mistake of only checking -f and then still failing when trying to read a file with no
permissions.
Exercise 2. Common mistake #2 points out that tail -n1 on dig +short is an assumption, not a
guarantee. Rewrite that line in probe.sh so it specifically extracts the last IPv4 address from the
output, no matter how many CNAME lines come before it.
See solution
IP="$(dig +short "${DOMAIN}" | grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' | tail -n1 || true)"
Why it works: the grep -E with that regular expression discards any line that does not have the shape
of an IPv4 address (a CNAME's hostnames do not match the pattern), so no matter how many hops there
are, only real IP addresses remain, and tail -n1 now correctly takes the last of those.
Exercise 3. Add a third counter to remote-report.sh's summary: how many of the domains in
domains.txt ended up with no FAILED line at all in their .err (total success) out of the total
number of domains processed.
See solution
TOTAL=0
OK_COUNT=0
for f in "${LOCAL_REPORTS_DIR}"/*.err; do
[[ -e "${f}" ]] || continue
[[ "$(basename "${f}")" == "check-env.err" ]] && continue
TOTAL=$((TOTAL + 1))
[[ -s "${f}" ]] || OK_COUNT=$((OK_COUNT + 1))
done
echo "-- Domains with no failures: ${OK_COUNT}/${TOTAL} --"
Why it works: -s is true when the file exists and has a size greater than zero — an empty .err
means probe.sh never wrote anything to stderr for that domain, meaning there was no fail() call at
all. Counting empty files against the total gives you the proportion without having to read each one's
content again.
Summary and next step
What you built in ops-toolkit is not an isolated exercise: it is the final form of everything you saw
in this module — and, in a sense, in this entire guide. A script is no longer "a few lines to avoid
typing the same thing twice," it is a piece with an explicit contract (documented arguments, --help,
--dry-run, exit codes in a table) that someone else — or you, with less context, months later — can
run without having to read the code first. That is the real boundary between a personal script and a
tool.
You also touched lesson 7's limit: remote-report.sh already coordinates three scripts, two protocols
(SSH and rsync over SSH), and its own report format. If tomorrow you also needed retries with backoff,
parallel execution across several servers, or a JSON output format to feed a dashboard, that is exactly
the point where it is worth rewriting it in Python — not because bash is "bad," but because you already
used everything bash does well and what comes next is data structure, not more text.
Before considering this guide closed, you should be able to: write a script with its own argument
parsing, a documented exit code per failure type, trap for cleanup, and a real (not cosmetic)
--dry-run; diagnose a network problem explaining which layer you start with and why you rule out the
others; and connect to a remote server to run something there and bring back the result, without
needing to copy and paste commands by hand. If you are missing any of the three, go back to this
module's lesson that covers it — you have the index above.
Resources
- curl(1) — write-out variables — the
-w, --write-outsection documents every variable likehttp_code,time_total, andtime_connectthatprobe.shuses. - sysexits(3), OpenBSD manual — the origin of the exit-code convention (64, 69, 70, 78...) this toolkit adapts.
- ss(8), Linux manual — full syntax for
ssfilters, including the state-and-port variant used inprobe.sh. - ssh_config(5), OpenBSD manual — full reference for
Hostand its patterns, the basis for the validationremote-report.shdoes. - Google Shell Style Guide — a real organization's conventions for when to use shell, when not to, and how to structure scripts other people are going to maintain.