diff --git a/deploy/README.md b/deploy/README.md index a781934..6865a9c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,7 +8,7 @@ host: change these files in git and deploy again. |---|---| | `deploy.sh` | build + test → release → flip → `up -d` → health gate → rollback on failure | | `deploy.sh --rollback` | restore the previous release in one command | -| `docker-compose.yml` | runtime container: `127.0.0.1:8096`, `mem_limit == memswap_limit == 120m`, capped logs | +| `docker-compose.yml` | runtime container: published on `127.0.0.1:8096` **and** `192.168.20.13:8096` (never `0.0.0.0`), `mem_limit == memswap_limit == 120m`, capped logs | | `Dockerfile` | runtime image only — alpine + the prebuilt release payload, no toolchain, no network | | `rehearse.sh` | repeatable proof that deploy, fail-fast, health-gate rollback and `--rollback` all work | @@ -67,7 +67,8 @@ the script works on a host where `go` is not on `PATH`. It builds three throwaway services in a scratch directory, runs the real script against the real release root and the real port, and asserts: -1. a good release deploys, answers `/healthz`, publishes on `127.0.0.1` only, +1. a good release deploys, answers `/healthz` over loopback and the LAN + address, publishes on exactly those two addresses with no wildcard bind, and has `memswap_limit == mem_limit`; 2. a build failure exits non-zero, leaves `current` untouched and keeps serving; 3. a release whose `/healthz` is `500` is rolled back by the health gate, exits @@ -85,9 +86,26 @@ host swap on a host that is already swapping. Measured RSS for a Go static server is single-digit MiB; `deploy.sh` prints the real figure after every deploy so drift shows up in the log. +## Bind addresses — decided 2026-10-26 + +The container publishes on two explicit addresses and nothing else: + +| Address | Why | +|---|---| +| `127.0.0.1:8096` | `deploy.sh`'s health gate and any check made on `.13` itself | +| `192.168.20.13:8096` | the LAN address Caddy on `.35` proxies to (`docs/phase-3-deploy.md` §3.3) | + +A wildcard bind (`0.0.0.0`) is still forbidden: Caddy on `.35` stays the only +public listener. The phase document asked for loopback only, but a loopback-only +listener is unreachable from `.35`; Sam resolved the contradiction on AUD-133 +with "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096". `APP_LAN_IP` +overrides the LAN address (default `192.168.20.13`); `deploy.sh --lan-ip ADDR` +sets it for one run. Both `deploy.sh` and `rehearse.sh` assert that the LAN +address answers, not just loopback. + ## Caddy `docs/phase-3-deploy.md` §3.3 holds the block for `.35`. It is additive, applied -with `caddy reload` (never a restart), and **Sam applies it** — see -`docs/phase-3-evidence.md` for the open bind-address question that decides -whether that block points at `192.168.20.13:8096` or at `.13`'s Caddy. +with `caddy reload` (never a restart), and **Sam applies it**. The block proxies +to `192.168.20.13:8096`, which is why the container publishes on that address as +well as loopback (see *Bind addresses* above). diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 1ec9efc..f9eb38a 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -28,6 +28,10 @@ DEFAULT_ROOT="/home/sam/deployment/resume" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="${DEPLOY_ROOT:-$DEFAULT_ROOT}" PORT="${APP_PORT:-$DEFAULT_PORT}" +# Caddy on .35 proxies to this host's LAN address (phase doc §3.3), so the +# container publishes on loopback *and* on that address. Sam's decision on +# AUD-133, 2026-10-26: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096". +LAN_IP="${APP_LAN_IP:-192.168.20.13}" DO_ROLLBACK=0 HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-40}" @@ -39,6 +43,7 @@ Usage: deploy.sh [options] --source DIR build from DIR instead of the repo this script lives in --root DIR release root (default /home/sam/deployment/resume) --port N app port (default 8096) + --lan-ip ADDR LAN address the container also publishes on (default 192.168.20.13) --health-timeout N seconds to wait for /healthz (default 40) -h, --help this text @@ -46,6 +51,7 @@ Environment: GO path to the go toolchain (auto-detected, incl. /nix/store) DEPLOY_ROOT same as --root APP_PORT same as --port + APP_LAN_IP same as --lan-ip EOF } @@ -55,6 +61,7 @@ while [[ $# -gt 0 ]]; do --source) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;; --root) ROOT="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; + --lan-ip) LAN_IP="$2"; shift 2 ;; --health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; @@ -110,6 +117,17 @@ check_health() { fi fi log "health: $url -> 200" + + # The container also publishes on the LAN address because that is what Caddy + # on .35 proxies to. A loopback-only success would be a site nobody can reach. + local lan_url lan_code + lan_url="http://${LAN_IP}:${PORT}/healthz" + lan_code=$(curl -s --max-time 3 -o /dev/null -w '%{http_code}' "$lan_url" 2>/dev/null || true) + if [[ "$lan_code" != "200" ]]; then + log "health: $lan_url -> HTTP ${lan_code:-none} (LAN bind missing?)" + return 1 + fi + log "health: $lan_url -> 200" return 0 } @@ -218,7 +236,7 @@ do_deploy() { echo "built_on=$(hostname)" echo "binary_sha256=$(sha256sum "$release/bin/server" | cut -d' ' -f1)" } > "$release/manifest.txt" - printf 'APP_VERSION=%s\nAPP_PORT=%s\n' "$ts" "$PORT" > "$release/.env" + printf 'APP_VERSION=%s\nAPP_PORT=%s\nAPP_LAN_IP=%s\n' "$ts" "$PORT" "$LAN_IP" > "$release/.env" # 3. Runtime image, tagged with this release. log "deploy: docker build resume/app:$ts" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d1c7978..5543d87 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -6,7 +6,14 @@ # # Limits that are not negotiable (AGENTS.md §4.3): # mem_limit == memswap_limit == 120m -> the container cannot touch host swap -# ports 127.0.0.1:$PORT:8096 -> Caddy on .35 is the only public listener +# ports never 0.0.0.0 -> Caddy on .35 is the only public listener +# +# Published on two explicit addresses and nothing else (Sam, 2026-10-26, on +# AUD-133: "A - dual bind: 127.0.0.1:8096 and 192.168.20.13:8096"): +# 127.0.0.1 -> deploy.sh health gate and local checks on .13 +# 192.168.20.13 -> the LAN address Caddy on .35 proxies to (phase doc §3.3) +# A wildcard bind is still forbidden; if APP_LAN_IP is unset the LAN address +# defaults to this host's own 192.168.20.13. # # .13 has every default Docker address pool subnetted out (many projects), so a # single-container service uses the host bridge and publishes its port rather @@ -22,6 +29,7 @@ services: network_mode: bridge ports: - "127.0.0.1:${APP_PORT:-8096}:8096" + - "${APP_LAN_IP:-192.168.20.13}:${APP_PORT:-8096}:8096" environment: PORT: "8096" APP_VERSION: "${APP_VERSION}" diff --git a/deploy/rehearse.sh b/deploy/rehearse.sh index 812aebe..0a4a205 100755 --- a/deploy/rehearse.sh +++ b/deploy/rehearse.sh @@ -4,7 +4,7 @@ # Builds three throwaway Go services in a scratch directory and runs the real # deploy mechanism against the real release root and the real port: # -# 1. good -> deploys, health-gated, serving +# 1. good -> deploys, health-gated, serving on loopback and the LAN address # 2. broken -> does not compile: deploy must fail fast, current must not move # 3. unhealthy -> builds and runs, but /healthz is 500: the health gate must # roll back to the previous release and exit non-zero @@ -24,6 +24,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEPLOY="$HERE/deploy.sh" ROOT="${DEPLOY_ROOT:-/home/sam/deployment/resume}" PORT="${APP_PORT:-8096}" +LAN_IP="${APP_LAN_IP:-192.168.20.13}" SCRATCH="${REHEARSAL_DIR:-$(mktemp -d -t resume-rehearsal-XXXXXX)}" KEEP="${KEEP_REHEARSAL:-0}" @@ -33,19 +34,19 @@ no() { printf ' FAIL %s\n' "$*"; fail=$((fail + 1)); } hdr() { printf '\n== %s\n' "$*"; } cleanup() { + if [[ "$KEEP" == "1" ]]; then + printf '\nkept for inspection: container resume-app, images resume/app:*, release root %s, scratch %s\n' "$ROOT" "$SCRATCH" + return 0 + fi if docker ps -a --format '{{.Names}}' | grep -qx 'resume-app'; then docker rm -f resume-app >/dev/null 2>&1 || true fi docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^resume/app:' \ && docker images --format '{{.Repository}}:{{.Tag}}' | grep '^resume/app:' \ | xargs -r docker rmi -f >/dev/null 2>&1 || true - if [[ "$KEEP" == "1" ]]; then - printf '\nkept: release root %s, scratch %s, images resume/app:*\n' "$ROOT" "$SCRATCH" - else - rm -rf "$ROOT" - [[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH" - printf '\ncleaned: release root and rehearsal sources removed\n' - fi + rm -rf "$ROOT" + [[ "$SCRATCH" == /tmp/resume-rehearsal-* || -n "${REHEARSAL_DIR:-}" ]] && rm -rf "$SCRATCH" + printf '\ncleaned: container, images, release root and rehearsal sources removed\n' } trap cleanup EXIT @@ -111,6 +112,8 @@ PY } health() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" || true; } +health_lan() { curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$LAN_IP:$PORT/healthz" || true; } +listeners() { ss -ltn 2>/dev/null | awk -v p=":${PORT}\$" '$4 ~ p {print $4}' | sort -u; } serving_version() { curl -s -m 3 "http://127.0.0.1:$PORT/healthz" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p'; } current_target() { [[ -L "$ROOT/current" ]] && readlink -f "$ROOT/current" || echo none; } @@ -136,7 +139,14 @@ else fi [[ "$(health)" == "200" ]] && ok "/healthz 200 on 127.0.0.1:$PORT" || no "/healthz not 200" v1=$(serving_version); [[ -n "$v1" ]] && ok "serving release $v1" || no "no version reported" -ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1 only" || no "unexpected bind" +ss -ltn | grep -q "127.0.0.1:$PORT" && ok "container publishes on 127.0.0.1:$PORT (loopback, used by the deploy.sh health gate)" || no "no loopback bind" +ss -ltn | grep -q "$LAN_IP:$PORT" && ok "container publishes on $LAN_IP:$PORT (the address Caddy on .35 proxies to)" || no "no LAN bind on $LAN_IP:$PORT" +if listeners | grep -qE '^(0\.0\.0\.0|\*|\[::\]|::)'; then + no "wildcard bind on $PORT: $(listeners | tr '\n' ' ')" +else + ok "no wildcard bind on $PORT — only: $(listeners | tr '\n' ' ')" +fi +[[ "$(health_lan)" == "200" ]] && ok "/healthz 200 over the LAN address http://$LAN_IP:$PORT" || no "LAN address not answering" mem=$(docker inspect -f '{{.HostConfig.Memory}}' resume-app); swp=$(docker inspect -f '{{.HostConfig.MemorySwap}}' resume-app) [[ "$mem" == "$swp" && "$mem" != "0" ]] && ok "memswap_limit == mem_limit ($mem bytes)" || no "mem limits differ ($mem vs $swp)" @@ -175,8 +185,12 @@ rel=$(readlink -f "$ROOT/current") docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" down >/dev/null 2>&1 [[ "$(health)" != "200" ]] && ok "service down after compose down" || no "still serving after compose down" docker compose -p resume --project-directory "$rel" -f "$rel/docker-compose.yml" up -d >/dev/null 2>&1 -sleep 3 -[[ "$(health)" == "200" ]] && ok "service restored after compose up" || no "service did not come back" +restored=0 +for _ in $(seq 1 20); do + if [[ "$(health)" == "200" ]]; then restored=1; break; fi + sleep 1 +done +[[ $restored == 1 ]] && ok "service restored after compose up" || no "service did not come back within 20s" # --------------------------------------------------------------- verdict hdr "verdict" diff --git a/docs/phase-3-evidence.md b/docs/phase-3-evidence.md index 8c86b09..1680e58 100644 --- a/docs/phase-3-evidence.md +++ b/docs/phase-3-evidence.md @@ -1,12 +1,13 @@ # Phase 3 evidence — public deployment -**Status: mechanism complete and rehearsed; public URL deferred.** +**Status: mechanism complete and rehearsed; bind decided by Sam; public URL deferred.** The deploy machinery exists, is rehearsed against the real release root and the -real port on `.13`, and leaves nothing running. The site itself is not deployed -because Phase 2 has not produced a binary yet, and one decision is needed from -Sam before the container can be reachable by Caddy on `.35`. +real port on `.13`, and leaves nothing running. Sam answered the bind question on +2026-10-26 (**dual bind**, §5), and that answer is now in the committed compose +file and proven reachable from `.35`. The site itself is still not deployed +because Phase 2 has not produced a binary yet. -Recorded 2026-10-26 on `.13` (`nixos-desktop`), run `630e2ed9`. +Recorded 2026-10-26 on `.13` (`nixos-desktop`), runs `630e2ed9` and `a61a5b22`. --- @@ -14,9 +15,9 @@ Recorded 2026-10-26 on `.13` (`nixos-desktop`), run `630e2ed9`. | # | Item | Verdict | Evidence | |---|---|---|---| -| 1 | `deploy.sh` and `deploy.sh --rollback` work; a deliberate bad build proves rollback | **met** | §1, `deploy/rehearse.sh` 21/21 | +| 1 | `deploy.sh` and `deploy.sh --rollback` work; a deliberate bad build proves rollback | **met** | §1, `deploy/rehearse.sh` 24/24 | | 2 | `https://resume.lab.audasmedia.com.au` serves the site | **deferred** | Phase 2 has not produced a binary; Caddy block not yet applied (§4, §5) | -| 3 | Container bound to `127.0.0.1` only, `ss -ltn` | **met for the container**; conflicts with reachability from `.35` — see §5 | §2 | +| 3 | Container bound to `127.0.0.1` only, `ss -ltn` | **met as amended by Sam**: loopback **and** `192.168.20.13`, no wildcard — see §5 | §2 | | 4 | `memswap_limit == mem_limit`, `docker inspect` | **met** | §2 | | 5 | Security headers present, CSP does not break the page | **deferred** | headers belong to the `.35` Caddy block (§4) | | 6 | A1 passes over the public URL with the AI layer stopped | **deferred** | needs a live URL | @@ -31,46 +32,59 @@ Nothing in the deferred column is asserted. Each depends on the Phase 2 binary ## 1. What was rehearsed, and the result `deploy/rehearse.sh` builds throwaway services and drives the real script. Full -transcript: +transcript of the run after the dual-bind change (24 assertions): ``` == 0. preconditions PASS port 8096 free - PASS three throwaway sources built in /tmp/.../resume-rehearsal-snpEjy + PASS three throwaway sources built in /tmp/.../rehearsal4 == 1. good release deploys and health-gates PASS deploy.sh exit 0 PASS /healthz 200 on 127.0.0.1:8096 - PASS serving release 20261026T062957Z - PASS container publishes on 127.0.0.1 only + PASS serving release 20261026T064248Z + PASS container publishes on 127.0.0.1:8096 (loopback, used by the deploy.sh health gate) + PASS container publishes on 192.168.20.13:8096 (the address Caddy on .35 proxies to) + PASS no wildcard bind on 8096 — only: 127.0.0.1:8096 192.168.20.13:8096 + PASS /healthz 200 over the LAN address http://192.168.20.13:8096 PASS memswap_limit == mem_limit (125829120 bytes) == 2. build failure fails fast, current does not move PASS deploy.sh exit 1 (non-zero, as required) - PASS current unchanged (/home/sam/deployment/resume/releases/20261026T062957Z) + PASS current unchanged (/home/sam/deployment/resume/releases/20261026T064248Z) PASS previous release still serving == 3. unhealthy release is rolled back by the health gate PASS deploy.sh exit 1 (non-zero, as required) - PASS current restored to /home/sam/deployment/resume/releases/20261026T062957Z + PASS current restored to /home/sam/deployment/resume/releases/20261026T064248Z PASS rolled-back release serving again PASS log records the health-gate failure == 4. deploy.sh --rollback restores the previous release in one command PASS second good release deployed PASS rollback exit 0 - PASS current flipped back to /home/sam/deployment/resume/releases/20261026T062957Z + PASS current flipped back to /home/sam/deployment/resume/releases/20261026T064248Z PASS rolled-back release healthy - PASS serving 20261026T062957Z again (was 20261026T063021Z) + PASS serving 20261026T064248Z again (was 20261026T064307Z) == 5. N3: docker compose down && up restores the service PASS service down after compose down PASS service restored after compose up == verdict -passed 21, failed 0 +passed 24, failed 0 ``` +Reachability from the Caddy host itself (`.35`), run during that rehearsal: + +``` +$ ssh sam@192.168.20.35 'curl -sS -m3 -w "%{http_code}" http://192.168.20.13:8096/healthz' +200 +``` + +That is the exact hop §3.3's `reverse_proxy` will take, proven before the Caddy +block is applied. + The health-gate rollback in the deploy log, verbatim: ``` @@ -101,22 +115,31 @@ Re-run it any time with `./deploy/rehearse.sh` (exit 0 = every assertion held). ``` $ ss -ltn | grep 8096 -LISTEN 0 4096 127.0.0.1:8096 0.0.0.0:* +LISTEN 0 4096 127.0.0.1:8096 0.0.0.0:* +LISTEN 0 4096 192.168.20.13:8096 0.0.0.0:* -$ docker inspect -f '...' resume-app -name=/resume-app -mem_limit=125829120 bytes # 120 MiB -memswap_limit=125829120 bytes # 120 MiB — equal, so no host swap -port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"}]} +$ docker inspect resume-app +mem_limit=125829120 memswap_limit=125829120 +port_bindings={"8096/tcp":[{"HostIp":"127.0.0.1","HostPort":"8096"},{"HostIp":"192.168.20.13","HostPort":"8096"}]} restart=unless-stopped log_config={"Type":"json-file","Config":{"max-file":"3","max-size":"10m"}} +image=resume/app:20261026T064248Z + +$ curl -sS http://127.0.0.1:8096/healthz +{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...} +$ curl -sS http://192.168.20.13:8096/healthz +{"status":"ok","static":true,"ai":"disabled","version":"20261026T064248Z",...} ``` +Two explicit addresses, no wildcard listener: `0.0.0.0` never appears, so +nothing but Caddy on `.35` can be reached from outside the LAN. The reasoning is +in §5. + Measured memory (idle Go static server, placeholder build): ``` -rss=1.5MiB / 120MiB pct=1.25% -rss=6.9MiB / 120MiB pct=5.78% # just after start, before settling +$ docker stats --no-stream --format 'rss={{.MemUsage}} pct={{.MemPerc}}' resume-app +rss=1.73MiB / 120MiB pct=1.44% ``` Host pressure at the time of the rehearsal — this is why the cap is 120m: @@ -124,7 +147,7 @@ Host pressure at the time of the rehearsal — this is why the cap is 120m: ``` $ free -m total used free available -Mem: 15918 14255 227 1663 +Mem: 15918 13943 223 1975 ``` No residue was left behind: after the rehearsal there is no `resume-app` @@ -190,14 +213,15 @@ breaks it, the CSP is wrong — do not weaken it to `unsafe-inline` for scripts. --- -## 5. Open question for Sam — bind address vs Caddy reachability +## 5. Bind address — decided by Sam, 2026-10-26 (dual bind) -The phase document says two things that cannot both be true today: +The phase document said two things that could not both be true: - §3.2 and DoD item 3: the container publishes on **`127.0.0.1:8096` only**. - §3.3: Caddy **on `.35`** proxies to **`192.168.20.13:8096`**. -A loopback-only listener on `.13` is not reachable from `.35`. Measured: +A loopback-only listener on `.13` is not reachable from `.35`. Measured at the +time of the question: ``` $ curl -sS -m3 http://192.168.20.13:8096/healthz @@ -206,17 +230,27 @@ curl: (7) Failed to connect to 192.168.20.13:8096 `.13`'s own Caddy (`*:8000`, `/etc/static/caddy/caddy_config`) only serves eight legacy hostnames; every service added since (hub, compass, email-agent, -webrestrict) publishes on `0.0.0.0` and is proxied by `.35` directly to its LAN -port. That is the working pattern, and it is what §3.3's block assumes. +webrestrict) is proxied by `.35` straight to its LAN port. §3.3's block assumes +that pattern. -| Option | What changes | Consequence | -|---|---|---| -| **A (recommended, matches every current service)** | publish `127.0.0.1:8096` **and** `192.168.20.13:8096` | `deploy.sh` keeps its loopback health gate; `.35` reaches the app; §3.3 applied verbatim; no NixOS change. Exposure is the `.13` LAN, same as hub/compass today. DoD item 3 / N1 can then only claim "not on `0.0.0.0`" — needs your written change | -| **B (strict loopback)** | add `http://resume.lab.audasmedia.com.au:8000 { reverse_proxy localhost:8096 }` to `.13`'s Caddyfile, and `.35`'s block becomes `reverse_proxy 192.168.20.13:8000` | Satisfies N1 literally. But `/etc/static/caddy/caddy_config` is NixOS-generated — it needs a NixOS config change and `nixos-rebuild`, which this phase forbids and only you can do | +**Answer (AUD-133, 2026-10-26):** "A - dual bind: 127.0.0.1:8096 and +192.168.20.13:8096". That is now the committed behaviour: -The committed `docker-compose.yml` currently follows the phase document (option -**A**'s loopback half only: `127.0.0.1:8096`). Choosing A or B is a one-line -change in `deploy/docker-compose.yml` plus, for B, the NixOS change on `.13`. +| Change | Where | +|---|---| +| both addresses in `ports:` | `deploy/docker-compose.yml` (`${APP_LAN_IP:-192.168.20.13}`) | +| LAN address carried into the release `.env`; `--lan-ip ADDR` / `APP_LAN_IP` override | `deploy/deploy.sh` | +| health gate now requires `/healthz` 200 on **loopback and** the LAN address | `deploy/deploy.sh` `check_health()` | +| assertions for both binds, no wildcard, and LAN reachability | `deploy/rehearse.sh` | + +Consequence for DoD item 3, stated plainly: the container is **not** bound to +`127.0.0.1` only. It is bound to `127.0.0.1` and `192.168.20.13`, never +`0.0.0.0`, so it is reachable on the `.13` LAN exactly like hub/compass today and +not from anywhere else. That is a written change to §3.2 / N1 made by Sam, not a +judgement call by the agent; §2 and §4 of this document carry the output. + +No NixOS change and no `.13` Caddy change is needed under this option. §3.3's +block is applied verbatim. --- @@ -228,4 +262,3 @@ change in `deploy/docker-compose.yml` plus, for B, the NixOS change on `.13`. | Header + CSP proof (DoD 5) | this issue, after the Caddy block is applied | Sam applies §4 | | A1 over the public URL (DoD 6) | this issue, after 2 and 5 | — | | Caddy block applied and other hostnames confirmed (DoD 7) | **Sam** | §4 | -| Bind-address decision (§5) | **Sam** | A or B above |