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.
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.
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.)
darkmux serve running as an OS-managed service with auto-restart on crashThe 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.
darkmux fleet (run darkmux fleet --help to confirm; if it errors, upgrade with brew upgrade darkmux, or cargo install --path . --force from source).Two postures:
127.0.0.1:8765. Redis carries the cross-machine data; viewers load on the hub itself, or on satellites via their own local daemon. Simplest, safest. Recommended starting posture.http://<hub-tailnet-addr>:8765/ from a browser on any peer.You can start substrate-only and upgrade later. See the upgrade-to-viewer section at the bottom of this page.
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.)
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
0.0.0.0The 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:
protected-mode yes + requirepass already reject unauthenticated non-localhost connections at the Redis layer. The bind is just "which interfaces does the listener attach to." Security is enforced separately.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
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.
# 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 redis → apt 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.
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.
/opt/homebrew/bin/darkmux (Homebrew) or ~/.cargo/bin/darkmux (from source); which darkmux confirms~/Library/Logs/darkmux/serve.{out,err}: macOS convention; visible in Console.app and rotates cleanly via newsyslog (Phase 4)com.darkmux.serve (reverse-DNS, namespaces cleanly alongside Homebrew's homebrew.mxcl.*)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
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
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.
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
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)
darkmux fleet add <your-machine-id> --address 127.0.0.1:8765
darkmux fleet status
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 list → systemctl --user status; swap Console.app → journalctl --user -u darkmux-serve -f.
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.)
/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.
~/.darkmux/audit/. Same FS as ~/.darkmux/flows/: simplest backup posture, lives or dies together with the casual sink. If you want the audit chain on a different volume (Time Machine excluded, separate backup, etc.), put it there instead.com.darkmux.serve launchd plist's EnvironmentVariables dict (so the daemon writes audit records for its own activity: heartbeats, fleet probes, internal record-keeping).~/.zshrc (so interactive darkmux flow note, darkmux crew dispatch, etc. from the hub's shell also write to the audit chain).com.darkmux.serve after the plist edit. launchd reads env vars at process-start; the running daemon was launched before the audit env var was set. Without a reload the daemon keeps writing only to Tee([LocalFile, Redis]) and you've got a silent two-tier sink that looks right in darkmux flow status from your shell but isn't actually capturing the daemon's writes.# 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
# 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).
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.
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"
/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.
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 …
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.
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.
darkmux-redis), same password, different machine: the rc files end up identical across machines except for which hostname the URL points at.
# On the satellite, once:
security add-generic-password -a "$USER" -s darkmux-redis -w
# prompts for the password — paste the same one as the hub
~/.zshrc mirrors the hub's pattern, but the Redis URL points at the hub's tailnet address:
export DARKMUX_MACHINE_ID=<satellite-id>
DARKMUX_REDIS_PASS="$(security find-generic-password -a "$USER" -s darkmux-redis -w 2>/dev/null)"
export DARKMUX_REDIS_URL="redis://:${DARKMUX_REDIS_PASS}@<hub-tailnet-addr>:6379"
unset DARKMUX_REDIS_PASS
export DARKMUX_ORCHESTRATOR=claude-code
# Optional: enable audit on the satellite too if you want full-chain coverage:
export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"
# On the satellite:
source ~/.zshrc
darkmux doctor 2>&1 | grep -E "machine_id|flow sink health"
darkmux flow note --text "hello from <satellite-id>"
# On the hub — verify Redis XLEN incremented and the satellite's id appears:
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
curl -s http://127.0.0.1:8765/flow/$(date +%F) | grep machine_id | head
~/.darkmux/fleet.json is local, so adding a satellite means updating BOTH machines' rosters:
# On the hub:
darkmux fleet add <satellite-id> --address <satellite-tailnet-addr>:8765
# On the satellite:
darkmux fleet add <hub-id> --address <hub-tailnet-addr>:8765
darkmux fleet status
darkmux serve under launchd? Operator's call. If yes, repeat Phase 2 on the satellite (the plist is identical except for the machine_id). If you just want the satellite to write flow records cross-tailnet without exposing its own daemon, you can run darkmux serve only when you actively need its viewer endpoint.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.
~/.darkmux/flows/ sinks survive independently, so per-machine history is preserved on every satellite. The hub is the coordination point, not the source of truth.~/.darkmux/fleet.json is hand-maintained; adding the Nth machine means N fleet add commands./darkmux-enable-redis skill. #178. Once it ships, the Phase 1 manual Redis-hardening steps in this guide are replaced by a guided read-and-propose walkthrough.darkmux fleet verbs for routing, tier-aware dispatch, etc.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.
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.
The lower-overhead path: change the daemon to bind on the tailnet IP directly. Two-line plist edit:
--bind 127.0.0.1 to --bind 0.0.0.0 in the ProgramArguments exec line.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.
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.