Building a Security Center: Exposure Control, Data Silos, and Invite-Code Auth for a Personal Ecosystem
When your personal project crosses 47 services on 23 ports, "security" stops being a feature you bolt on and becomes a structural problem you solve at the architecture layer. I learned this the hard way around service #30, when I realized I had built a sprawling autonomous agent ecosystem — ARKONA — with roughly the same access control discipline as a home NAS circa 2014. Everything trusted everything. That had to change.
This is the story of how I designed and shipped ARKONA's Security Center: a unified surface for exposure control, service-level data silo enforcement, and an invite-code authentication layer. No third-party IAM vendor, no cloud auth proxy — just deliberate engineering on top of Tailscale, WebAuthn, and a threat model grounded in NIST 800-30.
Starting With a Threat Model, Not a Firewall Rule
The instinct when you discover you're overexposed is to start blocking ports. I've done that. It's the wrong first move. Before touching a single iptables rule, I ran a NIST 800-30 Rev 1 risk evaluation across the six ARKONA domains: CoreOps (cyber-physical RE), BizOps (business ops), COMET (AI governance), DevOps (software factory), REOps (hardware reverse engineering), and Rango (personal productivity). The output wasn't a firewall ruleset — it was a tiered asset classification: what needs to be internet-exposed, what needs LAN access, what should be loopback-only, and what should be zero-trust even for authenticated users.
That classification drove everything downstream. CIPHER, my hardware RE pipeline with Ghidra integration running on port 5174, handles forensic artifacts — PCB images, firmware binaries, decompiled ASM. That gets loopback-only with MFA-gated tunnel access. COMET's governance dashboard on port 5175 needs cross-device access for delegation reviews but should never be indexable. MuXD, my hybrid LLM router on port 4891, needs to be callable from every agent in the ecosystem but never from the public internet.
Tailscale as the Zero-Trust Fabric
The entire ecosystem runs on Tailscale. Every service binds to its Tailscale address — arkonaresearch.tail85a379.ts.net — not to 0.0.0.0. This is the single most impactful security decision I've made. Before Tailscale, I was maintaining a pile of Nginx reverse proxy configs with SSL termination and IP allowlists that rotted the moment I traveled. Now the network perimeter is managed by WireGuard-under-the-hood with hardware-attested device keys.
The ACL policy in Tailscale lets me express the tier classification directly:
{
"acls": [
{
"action": "accept",
"src": ["tag:arkona-core"],
"dst": ["tag:arkona-core:*"]
},
{
"action": "accept",
"src": ["tag:arkona-guest"],
"dst": ["tag:arkona-apps:5173", "tag:arkona-apps:5176"]
},
{
"action": "deny",
"src": ["tag:arkona-guest"],
"dst": ["tag:arkona-core:*"]
}
],
"tagOwners": {
"tag:arkona-core": ["autogroup:owner"],
"tag:arkona-guest": ["autogroup:owner"]
}
}
Guest-tagged devices — devices I hand to collaborators or use for demos — can reach the public-facing portfolio and the FORGE project tracker, and nothing else. Core devices get full mesh access. The policy is version-controlled alongside the rest of the infrastructure and gets committed on every change.
Service-Level Data Silos
Network segmentation keeps services separated at the transport layer, but I also needed semantic separation at the data layer. ARKONA's 26 autonomous agents communicate over a pub/sub broker (my inter-agent communication layer, running on port 4892), and early on, agents were promiscuously subscribing to topics they had no business reading. The research agent publishing daily 0200 sweep results to research.findings.raw should not be readable by the FORGE backlog agent — those are two different trust domains even if they're both internal.
I solved this with a silo manifest: a YAML file that maps each agent and service to a named silo, and a middleware layer on the broker that enforces silo boundaries at publish/subscribe time:
silos:
reops:
members: [cipher-agent, vault-ingest, research-agent]
topics: ["research.*", "vault.*", "cipher.*"]
devops:
members: [forge-agent, idea-generator, editorial-pipeline]
topics: ["forge.*", "editorial.*"]
bizops:
members: [webmaster-agent, comet-delegate]
topics: ["biz.*", "governance.*"]
cross_silo_allow:
- from: reops
to: devops
topics: ["research.summary.published"]
Cross-silo communication is explicitly allowlisted — no implicit inheritance, no wildcard grants. When the research agent publishes a finalized summary, it crosses into the DevOps silo via the research.summary.published topic so FORGE can ingest it as a backlog candidate. The raw findings never leave REOps. This mirrors the MITRE ATT&CK principle of least privilege applied to data flow rather than process execution — limit blast radius not just at ingress but at every propagation point.
Invite-Code Authentication
WebAuthn with Face ID handles primary authentication for my own devices — biometric, hardware-attested, phishing-resistant. But ARKONA occasionally needs to grant time-limited access to collaborators: a colleague reviewing a CIPHER report, a client browsing a FORGE project summary. Full WebAuthn enrollment for a one-time review is overkill. A shared password is a liability. I wanted something that felt like an API key but with a UX that non-engineers could use.
The solution is a lightweight invite-code system sitting in front of the guest-accessible services. An invite code is a 128-bit random token, HMAC-signed with a server secret, stored with a TTL and a scope list. When a guest hits a protected route, they're redirected to the invite redemption flow — single input field, no account creation. On redemption, the server issues a short-lived JWT scoped to the services listed in the invite, and the invite is marked consumed.
import secrets, hmac, hashlib, time, json
def generate_invite(scope: list[str], ttl_hours: int = 48) -> dict:
token = secrets.token_hex(16)
payload = {
"token": token,
"scope": scope,
"expires": int(time.time()) + ttl_hours * 3600,
"consumed": False
}
sig = hmac.new(
SERVER_SECRET.encode(),
json.dumps(payload, sort_keys=True).encode(),
hashlib.sha256
).hexdigest()
return {"invite": token, "sig": sig, **payload}
def redeem_invite(token: str, sig: str) -> str | None:
record = invite_store.get(token)
if not record or record["consumed"]:
return None
if time.time() > record["expires"]:
return None
expected = hmac.new(
SERVER_SECRET.encode(),
json.dumps(record, sort_keys=True).encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
invite_store.mark_consumed(token)
return issue_jwt(scope=record["scope"])
Every invite is tied to provenance: who generated it, when, for which services, and whether it was redeemed or expired. Invite logs feed into COMET's audit trail, SHA-256 signed alongside every other governance event. If an invite is ever misused — forwarded, replayed — there's a clear chain of custody for post-incident review. NIST 800-30 calls this "accountability" in its risk response taxonomy. I call it not having to wonder what happened.
The Security Center Dashboard
All of this is surfaced in a single Security Center view: live service exposure map (which services are bound to which interfaces), silo topology diagram, active invite inventory with TTL countdown, and a WebAuthn device manager. It's built with the same stack as the rest of the ARKONA control plane — Svelte frontend, FastAPI backend, all served over Tailscale HTTPS. The dashboard itself is loopback-only. No irony lost on an insecure security dashboard.
With 21 of 22 services currently online and 240 commits landed in the last week, the Security Center has become one of the highest-value views in the ecosystem. Not because anything has gone wrong — but because it makes the current security posture legible at a glance rather than requiring me to grep through configs when I'm half-asleep at 0300.
Key Takeaway
The lesson I'd carry into any future system: exposure control is not a network problem, it's a classification problem. Until you know what each service is worth, what it touches, and who legitimately needs to reach it, no amount of firewall rules will give you a coherent security posture — just a pile of exceptions you'll regret later. Build the threat model first. Let the architecture follow. The invite codes and silo manifests are just mechanical expressions of decisions that were already made on a whiteboard.
```