user guide · 06 · operations

Always-on hub operations

A walkthrough for turning one machine into the stable, secure 24/7 hub other machines on your tailnet rely on. Sibling to fleet: that page tells you what a fleet looks like; this page tells you how to make the hub at the center of it survive crashes, reboots, and weeks of operator absence.

Who this page is for

You've already done getting started on at least one machine, and read fleet well enough to know you want a multi-machine setup. You have one always-on machine — a Mac mini, a Mac Studio, a Linux box in a closet — that's going to be the coordinator. This page is the deep operational walkthrough for hardening THAT machine specifically: the one other machines depend on.

If you only have one machine and you're staying single-machine, you don't need this page. The casual darkmux serve + brew services start redis path from the fleet page is enough.

Shorter path available: the kstrat2001/homebrew-darkmux tap is live. Phase 2 of this guide can collapse to brew tap kstrat2001/darkmux && brew install darkmux && brew services start darkmux if you'd rather not hand-roll the launchd plist. The deep walkthrough below stays as the "what brew is doing for you" reference and as the manual fallback. Continuing through the phases either way is recommended on first read: the Redis hardening, audit substrate, log rotation, and integrity-check pieces stay operator-driven even on the brew path. (See #618 for ongoing brew packaging work: bottled binaries, etc.)

What you'll have at the end

The hub itself is one machine, but the discipline matters because other machines will start depending on it. A flaky hub means flaky fleet, no matter how stable the satellites are.

Before you start

macOS vs Linux: this guide uses launchd and newsyslog, which are macOS-native. Linux operators substitute systemd units for launchd plists, logrotate for newsyslog, and your distro's secret store (keyring, libsecret) for the macOS Keychain. The decisions and the verification steps are the same; only the file formats change.

One decision before you start: should the hub also serve viewers cross-network?

Two postures:

You can start substrate-only and upgrade later. See the upgrade-to-viewer section at the bottom of this page.

Phase 1: Harden Redis

Install Redis if you haven't:

brew install redis
brew services start redis

Then set requirepass (defense-in-depth: the tailnet is your perimeter; the password is your authentication). Store the password in macOS Keychain rather than the rc file:

# Generate a strong password (or pick your own); store in keychain.
PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 48)
security add-generic-password -a "$USER" -s darkmux-redis -w "$PASS"
unset PASS

# Read it back and set requirepass on the running instance.
DARKMUX_REDIS_PASS=$(security find-generic-password -a "$USER" -s darkmux-redis -w)
echo "requirepass \"$DARKMUX_REDIS_PASS\"" | sudo tee -a /opt/homebrew/etc/redis.conf > /dev/null
brew services restart redis

(If you already had Redis with a password, skip the above; just confirm the password is in keychain under service darkmux-redis.)

AOF persistence

The default Redis install does periodic RDB snapshots. A crash between snapshots loses minutes of flow records. AOF (append-only file) with appendfsync everysec caps loss at ≤1 second on crash at minimal throughput cost for hub-class write volume.

Back up redis.conf first (always):

cp /opt/homebrew/etc/redis.conf \
   /opt/homebrew/etc/redis.conf.bak-pre-hub-$(date +%Y%m%d-%H%M%S)

Then apply at runtime + persist back to redis.conf:

DARKMUX_REDIS_PASS=$(security find-generic-password -a "$USER" -s darkmux-redis -w)

redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG SET appendonly yes
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG SET appendfsync everysec
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG SET maxmemory 4gb
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG SET maxmemory-policy noeviction
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG REWRITE
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning BGREWRITEAOF

The maxmemory 4gb + noeviction pair is deliberate: the flow stream is already XADD MAXLEN-bounded by darkmux (default 10,000 entries via DARKMUX_REDIS_MAXLEN), so Redis doesn't need to evict, and you do NOT want it to, because eviction policies that touch streams can lose records silently. noeviction means "if you'd hit the cap, reject the write and surface an error": the right posture for a record-keeping substrate.

Verify:

redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG GET appendonly      # "yes"
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG GET appendfsync     # "everysec"
ls /opt/homebrew/var/db/redis/appendonlydir/                                    # appendonly.aof.* files appear

Bind decision: keep 0.0.0.0

The temptation is to dual-bind 127.0.0.1 + the hub's tailnet IP (e.g. 100.x.y.z) for "least exposure." Don't. Two reasons:

Confirm the existing safe defaults:

redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG GET protected-mode  # "yes"
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG GET bind            # "0.0.0.0 ::1"

Then verify cross-network reachability, from a peer machine on the same tailnet:

# On the peer:
nc -zvw1 <hub-tailnet-addr> 6379                                # expect "succeeded"
redis-cli -h <hub-tailnet-addr> -a "<password>" --no-auth-warning PING    # expect PONG
redis-cli -h <hub-tailnet-addr> -a "<password>" --no-auth-warning XLEN darkmux:flow

Tailscale ACL: when a third device joins

With one operator and two devices, the trivial-ACL "allow everything within the tailnet" is fine. The moment you add a third device (a CI runner, a friend's laptop, a tagged ephemeral), tighten with a tag:

{
  "tagOwners": {
    "tag:darkmux-fleet": ["your-email@example.com"]
  },
  "acls": [{
    "action": "accept",
    "src":    ["tag:darkmux-fleet"],
    "dst":    ["tag:darkmux-fleet:6379", "tag:darkmux-fleet:8765"]
  }]
}

Tag each device that's part of the fleet in the Tailscale admin console; untagged devices can't reach the hub's Redis or darkmux daemon port.

Phase 1 rollback

# Restore the backup; turn AOF off; restart.
cp /opt/homebrew/etc/redis.conf.bak-pre-hub-YYYYMMDD-HHMMSS /opt/homebrew/etc/redis.conf
DARKMUX_REDIS_PASS=$(security find-generic-password -a "$USER" -s darkmux-redis -w)
redis-cli -a "$DARKMUX_REDIS_PASS" --no-auth-warning CONFIG SET appendonly no
brew services restart redis

Linux substitution: swap brew install redisapt install redis-server or dnf install redis; swap macOS Keychain (security) → distro secret store (libsecret secret-tool, GNOME Keyring, or pass) or ~/.darkmux/redis-pass chmod 600; /opt/homebrew/etc/redis.conf/etc/redis/redis.conf.

Phase 2: Run darkmux serve as a launchd service

darkmux serve is the local HTTP daemon: flow records, mission/sprint state, model status, the bundled topology viewer. Running it as a foreground process is fine for casual use; for a 24/7 hub you want it under launchd so it auto-starts at boot and KeepAlive-respawns on crash.

Paths

mkdir -p ~/Library/Logs/darkmux

# If you've been running darkmux serve manually, move any legacy log out of the way.
mv ~/.darkmux/serve.log ~/Library/Logs/darkmux/serve.legacy.log 2>/dev/null || true

Keychain pre-grant (do this BEFORE loading the plist)

The hidden-prompt gotcha. The launchd plist will exec security find-generic-password at process-start to read the Redis password from Keychain. The FIRST time a launchd-spawned process accesses a keychain item, macOS may show a GUI prompt asking whether to allow it. A launchd-spawned process has no controlling TTY, so the prompt has no UI to display in; the daemon then sits silently failing under KeepAlive in a loop until you notice. Pre-grant by running the access interactively once:
# Run in a fresh terminal. If a GUI prompt appears, click "Always Allow".
# If it just prints the password and exits, your ACL is already permissive.
security find-generic-password -a "$USER" -s darkmux-redis -w

The plist

File: ~/Library/LaunchAgents/com.darkmux.serve.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.darkmux.serve</string>

  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>-lc</string>
    <string>export DARKMUX_REDIS_URL="redis://:$(security find-generic-password -a $USER -s darkmux-redis -w)@127.0.0.1:6379"; exec /Users/<your-user>/.cargo/bin/darkmux serve --bind 127.0.0.1 --port 8765</string>
  </array>

  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key><string>/Users/<your-user></string>
    <key>PATH</key><string>/Users/<your-user>/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    <key>DARKMUX_MACHINE_ID</key><string><your-machine-id></string>
    <key>DARKMUX_ORCHESTRATOR</key><string>claude-code</string>
    <key>DARKMUX_AUDIT_DIR</key><string>/Users/<your-user>/.darkmux/audit</string>
    <key>DARKMUX_FLOWS_DIR</key><string>/Users/<your-user>/.darkmux/flows</string>
    <key>DARKMUX_REDIS_STREAM</key><string>darkmux:flow</string>
    <key>DARKMUX_REDIS_MAXLEN</key><string>10000</string>
  </dict>

  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>ThrottleInterval</key><integer>10</integer>

  <key>StandardOutPath</key><string>/Users/<your-user>/Library/Logs/darkmux/serve.out</string>
  <key>StandardErrorPath</key><string>/Users/<your-user>/Library/Logs/darkmux/serve.err</string>

  <key>WorkingDirectory</key><string>/Users/<your-user></string>
  <key>ProcessType</key><string>Background</string>
</dict>
</plist>

Replace <your-user> and <your-machine-id>. The /bin/zsh -lc wrapper is the load-bearing part: it reads the Redis password from Keychain at process-start and exports it before exec-ing darkmux. The password never lives in the plist file (which is plaintext on disk) and never appears in ps aux; only the resolved URL goes into the child process's environment, not the command line.

Load + verify

plutil -lint ~/Library/LaunchAgents/com.darkmux.serve.plist
launchctl load -w ~/Library/LaunchAgents/com.darkmux.serve.plist
sleep 3
launchctl list | grep com.darkmux.serve                # expect: <pid> 0 com.darkmux.serve
curl -s http://127.0.0.1:8765/health                    # expect: {"darkmux_version":"…","flow_schema_version":"…"}
darkmux doctor 2>&1 | grep -i "daemon reachable"        # expect: ✓
tail -20 ~/Library/Logs/darkmux/serve.err

KeepAlive smoke test

Prove auto-recovery actually works by killing the daemon:

PID=$(launchctl list | awk '/com.darkmux.serve/ {print $1}')
kill -TERM "$PID"
sleep 14    # ThrottleInterval=10s + 4s margin
launchctl list | grep com.darkmux.serve                # expect: a NEW PID (different from before)

Register the hub in its own fleet roster

darkmux fleet add <your-machine-id> --address 127.0.0.1:8765
darkmux fleet status

Phase 2 rollback

launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
rm ~/Library/LaunchAgents/com.darkmux.serve.plist

Linux substitution: swap the plist for a systemd user unit at ~/.config/systemd/user/darkmux-serve.service (Restart=always, RestartSec=10, env vars in [Service]), enable with systemctl --user enable --now darkmux-serve; swap launchctl listsystemctl --user status; swap Console.app → journalctl --user -u darkmux-serve -f.

Phase 3: Enable the audit substrate

The casual per-day JSONL under ~/.darkmux/flows/ is enough for personal record-keeping. The audit sink adds BLAKE3 hash-chained records whose edits a daily darkmux flow integrity-check surfaces (Phase 4) — absent a full re-chain, since the chain is un-anchored: a strong substrate to build a compliance posture on (ISO 27001, AI Act, HIPAA-as-covered-entity) and a useful tripwire even for personal use. (An external anchor — OS append-only flags or an off-box co-signature — is the unbuilt next step for tamper-proof, vs tamper-detecting, guarantees.)

Use the skill, don't re-implement. The authoritative walkthrough for enabling the audit substrate is the /darkmux-enable-audit Claude Code skill (shipped with darkmux). Invoke it in a Claude Code session on the hub; it'll walk you through the use-case framing, the dir choice, the env-var setup, the first-write verification, and the doctor check. This section captures only the HUB-specific decisions that overlay the skill's defaults.

Hub-specific decisions

Skill walkthrough in one block (for reference; run via Claude Code session)

# Add to ~/.zshrc:
export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"

# Trigger first write — auto-creates the dir + emits the first hash-chained record.
source ~/.zshrc
darkmux flow note --text "audit substrate enable smoke"

# Verify chain integrity.
darkmux flow integrity-check                        # expect: ✓ valid …
echo "exit=$?"                                       # expect: exit=0

# Confirm doctor agrees — and that the sink composition includes AuditFile.
DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit" darkmux doctor 2>&1 | grep -iE "audit integrity|flow sink"
# expect: ✓ audit integrity
#         ⚠ or ✓ flow sink health   Tee([AuditFile, LocalFile, Redis])

# Reload the daemon so it picks up DARKMUX_AUDIT_DIR from the plist.
launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
launchctl load   -w ~/Library/LaunchAgents/com.darkmux.serve.plist

Phase 3 rollback

# Disable audit at the daemon: remove DARKMUX_AUDIT_DIR from plist + reload.
# Disable audit interactively: remove DARKMUX_AUDIT_DIR line from ~/.zshrc.
# On-disk audit JSONLs under ~/.darkmux/audit/ are your call to keep or delete —
# disabling the sink stops new writes but doesn't touch existing files.
launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
# edit plist to remove DARKMUX_AUDIT_DIR key
launchctl load   -w ~/Library/LaunchAgents/com.darkmux.serve.plist

Linux substitution: the audit substrate is POSIX-only by design: it uses flock(2) for cross-process safety. Same env var, same skill, same integrity check, same exit codes. Only the path conventions differ (use ~/.local/share/darkmux/audit/ or wherever your XDG layout puts it).

Phase 4: Survive at scale

Two operational habits the hub needs that the daemon itself doesn't ship: log rotation so serve.err doesn't grow forever, and a daily integrity check that fires a notification if the audit chain ever breaks.

Log rotation via newsyslog

macOS's native rotation system is newsyslog, invoked daily by /System/Library/LaunchDaemons/com.apple.newsyslog.plist. Drop-in configs live under /etc/newsyslog.d/.

File: /etc/newsyslog.d/darkmux.conf

# darkmux serve daemon logs — rotate weekly Saturday 00:00, keep 8, compress
# fields: logfilename                              owner:group  mode count size  when    flags
/Users/<your-user>/Library/Logs/darkmux/serve.out        kain:staff   644  8     *     $W6D0   ZN
/Users/<your-user>/Library/Logs/darkmux/serve.err        kain:staff   644  8     *     $W6D0   ZN
/Users/<your-user>/Library/Logs/darkmux/integrity.log    kain:staff   644  8     *     $W6D0   ZN

Apply:

sudo install -m 644 -o root -g wheel /tmp/darkmux-newsyslog.conf /etc/newsyslog.d/darkmux.conf
sudo newsyslog -nv | grep -i darkmux
# expect three lines, each "will trim at Sat <next-saturday> 00:00:00"
Redis log can't share this rotation. Tempting to add /opt/homebrew/var/log/redis.log to the same file. Don't. redis-server doesn't reopen its log fd on SIGHUP; rotating its file mid-process silently orphans the fd and you stop seeing Redis logs without any error. Hub-scale Redis log volume is small (banner + occasional notices); manual brew services restart redis a couple times per year is the practical workaround.

Daily integrity-check via launchd

cron works on macOS but launchd is the native choice: it survives reboots, integrates with launchctl, and respects StandardOutPath/StandardErrorPath so the audit findings naturally flow into your log directory.

File: ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.darkmux.flow-integrity-check</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>-lc</string>
    <string>export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"; /Users/<your-user>/.cargo/bin/darkmux flow integrity-check; ec=$?; if [ $ec -eq 2 ]; then /usr/bin/osascript -e 'display notification "darkmux audit chain BROKEN — see ~/Library/Logs/darkmux/integrity.log" with title "darkmux integrity-check"'; fi; exit $ec</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key><string>/Users/<your-user></string>
    <key>PATH</key><string>/Users/<your-user>/.cargo/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>15</integer></dict>
  <key>StandardOutPath</key><string>/Users/<your-user>/Library/Logs/darkmux/integrity.log</string>
  <key>StandardErrorPath</key><string>/Users/<your-user>/Library/Logs/darkmux/integrity.log</string>
  <key>RunAtLoad</key><false/>
</dict>
</plist>

The wrapped shell does the load-bearing exit-2 handling: darkmux flow integrity-check exits 2 only on chain break; that case fires a desktop notification you can't miss. Exit 0 (the happy path) just appends a ✓ valid line to integrity.log silently.

Load and smoke-test (don't wait until 04:15):

plutil -lint ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
launchctl load -w ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
launchctl start com.darkmux.flow-integrity-check
sleep 3
cat ~/Library/Logs/darkmux/integrity.log    # expect: ✓ valid …

Phase 4 rollback

sudo rm /etc/newsyslog.d/darkmux.conf
launchctl unload -w ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
rm ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist

Linux substitution: swap newsyslog for /etc/logrotate.d/darkmux (same fields, different syntax: weekly rotate 8 compress missingok notifempty); swap the integrity-check launchd plist for a systemd timer (darkmux-integrity.timer + .service, OnCalendar=*-*-* 04:15:00); swap osascript notification for your distro's notify-send (libnotify) or a desktop bus call.

Phase 5: Join satellite machines

The hub is operational and the first peer is the second machine you wire up. This phase is delegated to the /darkmux-add-machine Claude Code skill (also see fleet → Add a new machine to an existing fleet for the fleet-roster context). Per the operator-sovereignty principle, the skill is read-and-propose: it surfaces decisions and you run the commands.

Hub-specific overlays on the skill walkthrough

End-to-end verification

After all five phases, the load-bearing test is reboot the hub and confirm everything comes back unaided. Don't skip this: KeepAlive and "starts on boot" are two different guarantees, and only a real reboot exercises both.

sudo shutdown -r now
# Log back in once the machine reboots, then:

# 1) Confirm both services came up at boot via launchd
launchctl list | grep -E 'redis|darkmux'
# expect: homebrew.mxcl.redis, com.darkmux.serve, com.darkmux.flow-integrity-check

# 2) Doctor green on the load-bearing checks
darkmux doctor
# expect ✓ on: daemon reachable, machine_id, orchestrator, audit integrity
# expect ⚠ on: anything you've left out-of-scope (models loaded if you haven't
#   swapped a profile yet, recommendation drift if your hardware tier is unbaked, etc.)

# 3) Cross-network write from a satellite
# (on a peer:)
darkmux flow note --text "post-reboot satellite smoke $(date +%s)"

# 4) Cross-network read from the satellite via the hub's daemon
# (still on the peer:)
curl -s http://<hub-tailnet-addr>:8765/flow/$(date +%F) | grep "satellite smoke"
# expect: a hit on the line you just wrote

# 5) KeepAlive smoke — kill serve, expect a new PID after ThrottleInterval
PID=$(launchctl list | awk '/com.darkmux.serve/ {print $1}')
kill -TERM "$PID"
sleep 14
launchctl list | grep com.darkmux.serve
# expect: a NEW pid (proves auto-recovery happened)

# 6) Integrity-check fired since boot (or fire it manually to verify the path)
ls -la ~/Library/Logs/darkmux/integrity.log
# expect: timestamped today; tail shows ✓ valid

# 7) Redis XLEN includes the new satellite-smoke records
DARKMUX_REDIS_PASS=$(security find-generic-password -a "$USER" -s darkmux-redis -w)
redis-cli -h 127.0.0.1 -a "$DARKMUX_REDIS_PASS" --no-auth-warning XLEN darkmux:flow
# expect: a number higher than your pre-reboot baseline

All seven pass → the hub is operational. Document the rollback paths somewhere you can find them (a personal runbook, a sticky note, the rollback sections of this page) in case you need to undo any phase later.

What this guide does NOT solve for you

Upgrading to substrate + viewer later

If you decided substrate-only at the start and now want the daemon-hosted viewer reachable from any peer's browser pointed at the hub, you have two options. Both leave the substrate (Redis + audit + log rotation + integrity check) unchanged.

Option A: Tailscale Serve (recommended)

Tailscale Serve terminates TLS at the local Tailscale node and proxies inbound HTTPS connections to 127.0.0.1:8765 without ever exposing the daemon directly to the tailnet interface. The daemon stays bound to localhost; Tailscale handles the cross-tailnet hop. End result is a stable https://<hub>.<your-tailnet>.ts.net/ URL that any peer on your tailnet can load in a browser, with proper HTTPS (no mixed-content errors, no CORS gymnastics):

# On the hub, expose the daemon at the tailnet HTTPS port (443).
# Background flag persists across reboots via tailscaled.
tailscale serve --bg --https=443 http://127.0.0.1:8765

# Confirm the URL Tailscale assigned and the proxy mapping.
tailscale serve status

Now https://<hub-magic-dns-name>.ts.net/ resolves on any peer in your tailnet and loads the live viewer. To also allow the bundled viewer to be loaded from THAT origin (browsers send the page's origin in the Origin header on fetch/EventSource calls; the daemon's CORS check has to pass it), add the corresponding entry to the launchd plist:

# Add to the EnvironmentVariables dict in
# ~/Library/LaunchAgents/com.darkmux.serve.plist:
#   <key>DARKMUX_DAEMON_CORS_ORIGINS</key>
#   <string>https://<hub-magic-dns-name>.ts.net</string>

launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
launchctl load   -w ~/Library/LaunchAgents/com.darkmux.serve.plist

The daemon stays bound to 127.0.0.1. Tailscale Serve handles the cross-tailnet hop. Trust boundary stays at the tailnet ACL; HTTPS is real (not self-signed); peers don't need a local daemon to load the hub's fleet view.

Option B: Direct tailnet bind

The lower-overhead path: change the daemon to bind on the tailnet IP directly. Two-line plist edit:

  1. Change --bind 127.0.0.1 to --bind 0.0.0.0 in the ProgramArguments exec line.
  2. Add DARKMUX_DAEMON_CORS_ORIGINS=http://<hub-tailnet-addr>:8765 to the EnvironmentVariables dict.

Then launchctl unload + launchctl load the plist. The daemon now listens on the tailnet interface with CORS for that origin only; Redis stays at requirepass, the audit substrate stays on, everything else is unchanged.

Tradeoff: Option B serves HTTP only (no TLS), so peers loading http://<hub-tailnet-addr>:8765/ get a plain HTTP origin. Modern browsers don't flag this as insecure for tailnet hostnames specifically (they're not public), but mixed-content interactions (loading http:// resources from an https:// page) can bite if you ever proxy through a different layer later. Option A's HTTPS termination at the Tailscale node future-proofs against that.

How to pick