user guide · 05 · machine setup

Set up a secure hub

A copy/paste walkthrough for turning one always-on machine into the secure 24/7 hub the rest of your fleet relies on. The hub runs Redis (the coordination substrate) and the darkmux daemon (the live viewer + flow endpoints). This page assumes a clean machine and a tailnet: every command after brew install should work as written. Setting up a worker peer? Do this page first, then the peer setup guide.

Who this page is for

You have one machine that's going to stay on (a Mac mini, a Mac Studio, a Linux box in a closet) and you want other machines on your tailnet to coordinate through it. This page hardens THAT machine: the one everything else depends on.

If you only have one machine and you're staying single-machine, you don't need a hub at all. Getting started + the daemon + viewer is the whole story; flow records land on disk locally and the viewer reads them. Come back here when you add a second machine.

The security model: the tailnet is the perimeter

This guide sets up password-less Redis, and that is a deliberate, safe choice because of how the listener is bound. The logic:

Never bind 0.0.0.0. That makes Redis listen on every interface, including whatever LAN or coffee-shop WiFi the machine is on. The old advice of "0.0.0.0 + a password" trades a structural guarantee for a credential you have to manage and protect. Binding the specific loopback + tailnet addresses makes the port structurally unreachable from anywhere but the tailnet: no password to leak, rotate, or fat-finger.

One caveat to know: Tailscale assigns each device a stable 100.x.y.z address that persists across reboots. It only changes if you remove and re-add the device in the admin console. If you ever do that, update the bind line (and peers' redis.host) and restart. In normal operation the address is stable.

The quick path (brew-only)

If your hub already has its OS basics hardened (see harden the hub below if not) and Tailscale running, this is the whole setup. Run it on the hub:

# 1. Install darkmux.
brew tap kstrat2001/darkmux
brew install darkmux

# 2. Install Redis (the coordination substrate).
brew install redis

# 3. Bind Redis to loopback + your tailnet interface only, password-less.
#    TS_IP is this hub's Tailscale address.
TS_IP=$(tailscale ip -4)
brew services start redis
redis-cli CONFIG SET bind "127.0.0.1 $TS_IP"
redis-cli CONFIG SET protected-mode no
redis-cli CONFIG SET appendonly yes              # crash-durable (see Phase 1)
redis-cli CONFIG REWRITE                          # persist to redis.conf
brew services restart redis

# 4. Tell darkmux this machine is the hub, using the local Redis.
darkmux config set fleet.mode hub
darkmux config set redis.enabled true
darkmux config set redis.host 127.0.0.1
darkmux config set machine_id studio              # operator-named; your call

# 5. Start the daemon (live viewer + flow endpoints) under brew services.
brew services start darkmux

# 6. Verify.
darkmux doctor

darkmux doctor ends with a one-line verdict banner (● ok / needs attention / broken). Green on daemon reachable, flow sink health (should read a Redis-backed sink), and machine_id means the hub is live. The peer machines now point their redis.host at this hub's $TS_IP; that's the peer setup guide.

Why this works without a Redis password in any config file: darkmux resolves its Redis connection from config.redis (the enabled + host you just set) and only layers in a Keychain password if one exists. With none stored, it connects password-less, exactly matching the password-less bind. The brew services daemon follows the same resolution. Nothing to store, nothing to leak.

The rest of this page is the deeper operational reference: what each piece does, how to run the daemon under explicit launchd control, the audit substrate, log rotation, daily integrity checks, the cross-tailnet viewer, and the optional requirepass defense-in-depth layer. The quick path above is enough to be operational; read on to harden for weeks of unattended operation.

What you'll have at the end

The hub is one machine, but the discipline matters because other machines depend on it. A flaky hub means a flaky fleet, no matter how stable the peers are.

Before you start: harden the hub's OS

The most common multi-machine failure mode is the operator-default-macOS one: the hub sleeps overnight, drops Tailscale, your other machines' dispatches start timing out, and you don't notice until you try to use it. Walk through this once on the hub:

# Stop sleeping. Mac defaults assume "lid closed = sleep"; wrong for a 24/7 hub.
sudo pmset -a sleep 0 disksleep 0 autorestart 1
pmset -g                                          # verify the settings landed

# Tailscale:  System Settings → Tailscale → "Run at login" + "Always on"
# Auto-login: System Settings → Users & Groups → Login Options → Automatic login
# Updates:    set major-version auto-install OFF; schedule them manually.

autorestart 1 recovers from a brief power blip. Tailscale "Run at login" + an auto-login user means: after any reboot the machine boots straight into a session where Tailscale + your services start automatically, instead of sitting at the login screen with Tailscale down and no remote way in.

You also need Homebrew (to install darkmux and Redis) and a private mesh network reaching the hub. This guide assumes Tailscale; WireGuard / ZeroTier / plain LAN work the same. And run darkmux on a version that ships darkmux machine + darkmux config (check with darkmux config --help; if it errors, brew upgrade darkmux).

macOS vs Linux: the deep phases below use launchd + newsyslog (macOS-native). Linux operators substitute systemd units for launchd plists, logrotate for newsyslog, and your distro's redis.conf path (/etc/redis/redis.conf). The decisions and verification steps are the same; only the file formats change.

Phase 1: Redis, the secure password-less posture (in depth)

The quick path already set the bind, protected-mode, and AOF. This section explains each line so you can verify and tune it.

The bind: loopback + tailnet only

TS_IP=$(tailscale ip -4)                           # this hub's 100.x.y.z address
redis-cli CONFIG SET bind "127.0.0.1 $TS_IP"
redis-cli CONFIG SET protected-mode no
redis-cli CONFIG REWRITE
brew services restart redis

Two interfaces, deliberately: 127.0.0.1 so the hub's own daemon connects over loopback, and the tailnet IP so peers can reach it across the tailnet. Nothing else. protected-mode no is required for the password-less posture: with protected-mode yes (the default) and no requirepass, Redis refuses every non-loopback connection, so peers couldn't connect. Turning it off is safe here precisely because the bind already restricts the listener to the tailnet interface.

Confirm:

redis-cli CONFIG GET bind                           # "127.0.0.1 100.x.y.z"
redis-cli CONFIG GET protected-mode                 # "no"

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 negligible cost for hub-class write volume.

Back up redis.conf first (always), then apply at runtime + persist:

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

redis-cli CONFIG SET appendonly yes
redis-cli CONFIG SET appendfsync everysec
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy noeviction
redis-cli CONFIG REWRITE
redis-cli 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 never needs to evict. You do NOT want it to, either, because eviction policies that touch streams can drop 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 CONFIG GET appendonly                     # "yes"
redis-cli CONFIG GET appendfsync                    # "everysec"
ls /opt/homebrew/var/db/redis/appendonlydir/        # appendonly.aof.* files appear

Cross-tailnet reachability check

From a peer machine on the same tailnet, prove the hub's Redis is reachable, and that it's reachable only over the tailnet:

# On a peer:
nc -zvw1 <hub-tailnet-addr> 6379                    # expect "succeeded"
redis-cli -h <hub-tailnet-addr> PING                # expect PONG (no -a flag; password-less)
redis-cli -h <hub-tailnet-addr> XLEN darkmux:flow

Tailscale ACL: when a third device joins

With one operator and two devices, the default "allow everything within the tailnet" ACL is fine. The moment you add a third device you don't fully trust (a CI runner, a friend's laptop, a tagged ephemeral), tighten with a tag so only fleet devices can reach the hub's ports:

{
  "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 fleet device in the Tailscale admin console; untagged devices then can't reach the hub's Redis (6379) or darkmux daemon (8765) ports. This is also the point at which adding requirepass (see defense-in-depth below) earns its keep.

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
redis-cli CONFIG SET appendonly no
brew services restart redis

Linux substitution: swap brew install redisapt install redis-server or dnf install redis; /opt/homebrew/etc/redis.conf/etc/redis/redis.conf; tailscale ip -4 is the same on Linux.

Phase 2: Run the daemon as a managed service

darkmux serve is the local HTTP daemon: flow records, mission/phase state, machine status, the bundled live viewer. For a 24/7 hub you want it under a service manager so it auto-starts at boot and respawns on crash.

The easy path: brew services

If you installed via Homebrew, the formula already ships a launchd-managed service. Because your Redis is password-less, there's nothing to wire up: the daemon resolves its Redis connection from the config.redis you set in the quick path:

brew services start darkmux
sleep 3
curl -s http://127.0.0.1:8765/health               # {"darkmux_version":"…","flow_schema_version":"…"}
darkmux doctor 2>&1 | grep -i "daemon reachable"   # expect ✓ + the viewer URL

The daemon binds 127.0.0.1:8765 by default. That's correct: the live viewer loads on the hub itself, and for cross-tailnet browser access you proxy through Tailscale Serve (see the viewer section) rather than exposing the daemon's port directly. To set the operator-named machine id the service reports, export DARKMUX_MACHINE_ID in your shell rc before brew services start (the service picks it up at load), or rely on the config set machine_id you already ran.

The brew service + Redis passwords: the bundled service wrapper reads a Redis password from the Keychain item darkmux-redis only if it exists. On the password-less posture there's no such item, so the wrapper does nothing and the daemon connects from config.redis password-less. If you later add requirepass (below), store the password in that Keychain item and the wrapper picks it up automatically, no plist edit.

The explicit path: a hand-rolled launchd plist

If you'd rather control the service definition directly (or you built from source), here's the equivalent 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>/opt/homebrew/bin/darkmux</string>
    <string>serve</string>
    <string>--bind</string><string>127.0.0.1</string>
    <string>--port</string><string>8765</string>
  </array>

  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key><string>/Users/<your-user></string>
    <key>PATH</key><string>/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>
  </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>

No Redis URL in the plist: the daemon resolves Redis from config.redis (the ~/.darkmux/config.json the quick path wrote). The password-less posture means there's no secret to inject at process-start, so the /bin/zsh -lc Keychain-reading wrapper the old guide used is unnecessary. (If you add requirepass later, the simplest path is to switch to brew services, whose wrapper does the Keychain read for you.)

mkdir -p ~/Library/Logs/darkmux
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

KeepAlive smoke test

Prove auto-recovery works by killing the daemon and confirming a new PID:

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

Register the hub in its own fleet roster

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

Phase 2 rollback

# brew path:
brew services stop darkmux
# explicit-plist path:
launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
rm ~/Library/LaunchAgents/com.darkmux.serve.plist

Linux substitution: a systemd user unit at ~/.config/systemd/user/darkmux-serve.service (Restart=always, RestartSec=10, env vars in [Service]), enabled with systemctl --user enable --now darkmux-serve; journalctl --user -u darkmux-serve -f for logs.

Phase 3: Enable the audit substrate (optional)

The casual per-day JSONL under ~/.darkmux/flows/ is enough for personal record-keeping. The audit sink adds BLAKE3 hash-chained records; a daily darkmux flow integrity-check (Phase 4) walks the chain and surfaces edits. It's 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. The chain is un-anchored, so it detects edits (absent a full re-chain) rather than preventing them: tamper-detecting, not tamper-proof. For a stronger guarantee, pair it with OS append-only flags on the audit files or an off-box co-signature.

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 walks 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.

Hub-specific decisions

# In ~/.zshrc:
export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"
source ~/.zshrc
darkmux flow note --text "audit substrate enable smoke"
darkmux flow integrity-check                        # expect: ✓ valid …  (exit 0)
darkmux doctor 2>&1 | grep -iE "audit integrity|flow sink"
brew services restart darkmux                       # pick up the audit dir

Linux substitution: the audit substrate is POSIX-only by design (uses flock(2)). Same env var, same skill, same integrity check + exit codes; only the path conventions differ.

Phase 4: Survive at scale

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

Log rotation via newsyslog

macOS's native rotation system is newsyslog. Drop-in configs live under /etc/newsyslog.d/. File: /etc/newsyslog.d/darkmux.conf

# darkmux 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        <you>:staff  644  8     *     $W6D0   ZN
/Users/<your-user>/Library/Logs/darkmux/serve.err        <you>:staff  644  8     *     $W6D0   ZN
/Users/<your-user>/Library/Logs/darkmux/integrity.log    <you>:staff  644  8     *     $W6D0   ZN
sudo install -m 644 -o root -g wheel /tmp/darkmux-newsyslog.conf /etc/newsyslog.d/darkmux.conf
sudo newsyslog -nv | grep -i darkmux               # "will trim at Sat … 00:00:00" ×3
Redis log can't share this rotation. 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 with no error. Hub-scale Redis log volume is tiny; a manual brew services restart redis a couple times a year is the practical workaround.

Daily integrity-check via launchd (if audit is enabled)

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"; /opt/homebrew/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>/opt/homebrew/bin:/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>

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) appends a ✓ valid line silently. Load + smoke-test:

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 …

Linux substitution: /etc/logrotate.d/darkmux (weekly rotate 8 compress missingok notifempty); a systemd timer (OnCalendar=*-*-* 04:15:00) for the integrity check; notify-send for the notification.

Phase 5: Join worker peers

The hub is operational. Bringing the next machine online (pointing its redis.host at this hub's tailnet address, declaring it fleet.mode peer, and verifying it reaches the hub) is its own walkthrough: the peer setup guide. It's a clean-machine, brew-only, copy/paste recipe, and it opens by linking back here in case you landed there without a hub yet.

The one fact peers need from this page: the hub's tailnet address (tailscale ip -4 on the hub). That's the value each peer sets as its redis.host. No password, because the bind is the perimeter.

The cross-tailnet viewer

The daemon binds 127.0.0.1:8765 and serves the live viewer there. To open it from another device's browser (your phone, a peer), proxy through Tailscale Serve. The daemon stays on loopback; Tailscale handles the cross-tailnet hop and TLS:

# On the hub. --bg persists across reboots via tailscaled.
tailscale serve --bg --https=443 http://127.0.0.1:8765
tailscale serve status                              # shows the assigned https://<hub>.<tailnet>.ts.net/ URL

Now https://<hub-magic-dns-name>.ts.net/ loads the live viewer on any device in your tailnet, with real HTTPS, and the daemon never leaves loopback.

Why Tailscale Serve, not a non-loopback --bind: binding the daemon to the tailnet IP directly would trip darkmux's remote-auth gate. A non-loopback bind requires a bearer token, and a browser page-navigation can't send an Authorization header, so the viewer would 401. Tailscale Serve keeps the daemon on 127.0.0.1, so the daemon sees a loopback connection and serves the viewer freely while Tailscale enforces the tailnet boundary. It's the same reason the bind-to-loopback + serve pattern is the recommended phone-dashboard setup.

HTTP (--http=80 instead of --https=443) also works and is fine on a tailnet (the WireGuard tunnel is already encrypted); HTTPS needs the cert toggle enabled once in the Tailscale admin console. Tailscale Serve is tailnet-only: never enable Funnel, which would expose the viewer publicly.

Optional: add a Redis password (defense-in-depth)

Password-less + tailnet-bound is sufficient on a single-operator tailnet. Add requirepass if you want a second layer: say you run untrusted tagged devices on the tailnet, or your compliance posture asks for authentication independent of the network boundary. The password lives in the macOS Keychain, never in a file:

# Generate a URL-safe password and store it in the Keychain.
PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 48)
security add-generic-password -a "$USER" -s darkmux-redis -w "$PASS"

# Set it on Redis.
redis-cli CONFIG SET requirepass "$PASS"
redis-cli -a "$PASS" --no-auth-warning CONFIG REWRITE
unset PASS
brew services restart redis

With the password in the darkmux-redis Keychain item, darkmux picks it up automatically on both sides:

Keep the password URL-safe. Use the generator above (alphanumeric + .~_- only). A password containing @ : / # ? & or whitespace breaks the Redis URL parser; the brew service wrapper validates this and exits with a clear message rather than silently failing to connect. The protected-mode setting can stay no (the bind still restricts the listener) or go back to yes (belt-and-suspenders) once a password is set.

End-to-end verification

After setup, the load-bearing test is reboot the hub and confirm everything comes back unaided. KeepAlive and "starts on boot" are different guarantees; only a real reboot exercises both.

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

# 1) Both services came up at boot
launchctl list | grep -E 'redis|darkmux'
# expect: homebrew.mxcl.redis, com.darkmux.serve (+ flow-integrity-check if enabled)

# 2) Doctor green on the load-bearing checks
darkmux doctor
# expect ✓: daemon reachable, flow sink health (Redis-backed), machine_id
# ⚠ on anything left out of scope (no profile loaded yet, etc.) is fine

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

# 4) Cross-network read via the hub's daemon (on the peer or your phone):
#    https://<hub>.<tailnet>.ts.net/  (the note appears in the live viewer)

# 5) KeepAlive smoke: kill serve, expect a new PID
PID=$(launchctl list | awk '/com.darkmux.serve/ {print $1}')
kill -TERM "$PID"; sleep 14
launchctl list | grep com.darkmux.serve

# 6) Redis stream includes the peer-smoke records (no -a flag; password-less)
redis-cli XLEN darkmux:flow                         # higher than your pre-reboot baseline

All pass → the hub is operational. Now point your peers at it via the peer setup guide.

Good to know