The policy in force is the one on main
passThe live tailnet policy is read back and compared byte for byte with the render of main. The console stores the file verbatim, so any difference is real.
commit 4a0b7e4 · schedule
A Zero Trust access model on a real home network, built in the open. Every status below was read from GitHub Actions when this page was built, and links to the run that produced it. None of them is typed by hand. A check older than its schedule turns amber on its own.
Built · repository · status and plan · decision log
2 of ~35
devices the automation hub knows are reachable from the network overlay — each by one grant, on one port (measured live: 2 routes in effect, the widest /32). Everything else is refused by default.
The policy in force equals main. 71 policy
assertions — 58 of them refusals — run against the live network. CI holds no stored key for
the network or the cloud.
Measured once, not counted: after a 15-minute link loss the telemetry path came back in 11 s with no hands; the readings sent meanwhile were lost, not queued.
Field units: deferred — the house's sensors cannot run a client, and the cloud host that would run simulated ones serves two projects. Device-management posture and multi-user sign-in: a paid tier ($8/user/mo) and one user. Just-in-time access and log streaming: $18/user/mo. The exposure reading's "before" half: missed while the network was still open, and stated rather than reconstructed — the "after" half is measured below.
Posture on this plan is reported by the client itself: it shows how a device is configured, not that it is intact. The break-glass path into production — the cloud provider's console — was exercised on 2026-09-13: it works, and it means the cloud account, not the overlay, is the boundary around that host (D-042).
This panel reads GitHub's public Actions API from your browser: what is running this minute, step by step, and the last runs. Everything else on the page moves forward with it.
Where each part lives, which tool manages it, and who may reach what. Contours and words are drawn by hand; every access arrow is parsed from the policy file, so the picture cannot show a path the policy does not grant. Counts on the tiles are live, counted on 2026-10-26T13:12:36Z.
Green arrows are grants parsed from the policy, with the port each names; dashed green is a grant with nothing live at one end -- a role declared in the policy with no host, or an identity kept suspended between drills; it starts working the moment that end does, with no change to the policy. Red arrows are refusals asserted by the policy tests. Dotted green is data moving inside the house, where the tailnet has no path. Everything not drawn is refused by default.
| Part | Managed by · cost | What it is for | Why |
|---|---|---|---|
| GitHub Actions | workflows | Runs every check. Holds no network or cloud key: each job gets a short-lived token by OIDC. | D-041 |
| Tailscale | policy as code | Evaluates the policy and refuses a save whose tests fail. The policy is not in Terraform: two tools writing one global file fight. | D-001 |
| CI plan role | terraform | Lets CI run terraform plan and nothing more. Its trust names this repository by immutable id; wildcards are refused three ways. | D-028 |
| Archive bucket | terraform | Where the collector's data is meant to land. It exists so the certificate credential has something real to authorise. | D-039 |
| Roles Anywhere | terraform | Cloud access for a machine that holds a certificate, not a key. The collector presents its certificate and gets an hour of credentials that may write to the archive and nothing else -- measured: PUT 200, GET and DELETE 403. | D-067 |
| Terraform state | terraform | State joins the account's existing bucket rather than creating one; locking is the native lockfile. | D-029 |
| Operator laptop | by hand | The physical console. Applies the policy by hand after CI has validated it. Its posture attributes are reported by the client on the device itself: they establish configuration, not integrity. | D-040 |
| Operator phones | by hand | Posture subjects, and the way back in if everything else fails — verified off-network. | D-016 |
| Home server | by hand | Runs the camera recorder and the house's duty timers, and is someone's daily machine, so it stays user-owned with no machine identity. Members reach their own devices on SSH only -- the house's recovery path, restored after the lab's policy had cut it. | D-051 |
| Work machine | tag | Somebody's work computer, joined so it can be opened from home. It is reached and it reaches nothing: there is no grant anywhere with this role as the source, and the tests say so for every destination in the house. It advertises no routes and is no exit node, so the tailnet cannot reach its employer's network either. | D-075 |
| Media appliance | tag | A device on the network that should reach nothing — and is tested to reach nothing. It is also the one destination the test user is granted: a real device the household owns, rather than a container raised for the demonstration, and one whose worst case is audible from the sofa. | D-059 |
| Automation hub | tag | The only door into the home: it advertises one /32 route per exposed device, never the subnet. | D-024 |
| Collector | tag | Telemetry sink: a container on the cloud dev host, outside the house. The hub pushes readings to it on one port; it has no way in, and the tests assert that. | D-049 |
| Mobile units | decided | A unit that takes commands over the tailnet. The candidate was the house's robot vacuum; a command channel was built, reviewed and then retired when the owner chose to have the house trigger it itself. The role stays empty on purpose: nothing outside the house can move that machine. | D-056 |
| Sensors | deferred | Push-only field units. The house's own sensors cannot run a client, so their readings arrive through the hub. Simulated ones would run on the cloud dev host, which serves two projects on 2 GB and no swap: deferred. A real one is a single-board computer and a sensor, about $30. | Phase 6 |
| Test user | dormant | A second person, kept for drills. In the tailnet and in a group worth exactly one port on one real device -- the media appliance's AirPlay port, and nothing else anywhere. Suspended between drills, so the grant is dormant: measured at 0 of 10 destinations while suspended, 1 of 10 when restored -- the appliance answering on its own port -- and the switch between the two takes under two seconds (D-058, D-059). | D-059 |
| Production VM | by hand | The production stand-in. A destination, never a source. Outside Terraform; also where a person runs terraform apply. | D-036 |
| Smart plug | one /32 | The action tier: operators may switch it, only from a device whose client reports the required posture. On this plan the report comes from the device itself, so a compromised node is not constrained by it — which is what the device-management tile below would change. | D-040 |
| Smart plug | one /32 | The same model on the same port, never granted. Proves least privilege is per host, not per protocol. | D-010 |
| Cat camera | no route | The one camera the owner released for the lab. Nothing on the tailnet can reach it: the recorder in the house watches it, and at the end of each visit the hub pushes the event -- id, times, scores, no image -- to the collector. Named in the recorder, never addressed. | D-049 |
| Robot vacuum | no route | The lab's only moving machine. It cannot run a client, and the hub reaches it through the vendor's cloud rather than the home network. It reports state, battery, task and area through the hub; the house alone starts it, on its own trigger when the litter box finishes. A command channel was built for this and retired unused, so nothing outside the house can move it at all. | D-056 |
| Identity provider | Standard $8/user/mo | One login for many people; their groups become policy sources instead of every member. Plugs into the control plane and cloud sign-in. | multi-user |
| MDM / EDR | Standard $8/user/mo | Posture from the fleet's management system instead of the client's own report. An integration a paid plan adds. | D-040 |
| Just-in-time | Premium $18/user/mo | Standing access to the action tier replaced by grants that expire. Needs a paid plan: one time-boxed month. | Phase 4 |
| Log streaming | Premium $18/user/mo | Who connected to what, kept and searchable. The drills in Phase 6 would read it. | Phase 6 |
| Identity Center | AWS: no charge | Cloud sign-in for people through the identity provider. Lives in the organisation's management account, not this stack. | D-030 |
| Managed private CA | $400/mo | The lab's CA is an openssl key on the operator's laptop, which already buys the property that matters: the machine using a certificate cannot mint one. What money would add is custody, an audit trail and per-certificate revocation. | D-064 |
| The rest of the house | no route | About 35 devices by type, counted by hand on 2026-09-12 from the hub's registry — devices, not entities, and only those the hub knows: a camera outside it and phone-vendor accessories are not in the count. None is reachable from the tailnet; the hub talks to them locally. The camera count is withheld, and categories that would let it be subtracted are folded together. | D-053 |
The live tailnet policy is read back and compared byte for byte with the render of main. The console stores the file verbatim, so any difference is real.
commit 4a0b7e4 · schedule
The tailnet itself parses the policy and runs its tests — including every “this must be refused” assertion — against the real devices. Read-only; no credential is stored anywhere.
commit 4a0b7e4 · schedule
A pull request that deliberately asserted a forbidden path. The pipeline refused it. A check that has only ever been seen passing proves nothing.
PR #8: failure, closed without merging · commit aebaa0b · pull_request
A read-only plan against the cloud account, authenticated by federation — no access keys exist. The role that plans cannot change anything; the role that applies is assumable only from an environment that holds the run until a named person approves it, and cannot modify its own permissions.
terraform plan: no changes · commit 4a0b7e4 · schedule
Counted in the account itself by the same read-only job that plans the stack: IAM users, and whether the root user has access keys. Every identity that reaches this account is federated and short-lived — and this is read from the account, not inferred from the code that creates none.
0 IAM users · root access keys: none
Every file and every commit message is swept for real-world values before merge, including a private list of this network's own names.
commit 4a0b7e4 · push
The whole git history is scanned for credentials on every change.
commit 4a0b7e4 · push
GitHub enforces the checks above on main for everyone, the owner included.
required: disclosure sweep, secret scanner, policy template lint, terraform guards · applies to: everyone · commit 4a0b7e4 · read from GitHub at build
Not a check that passes or fails: numbers observed while something was deliberately broken, counted from both ends, and written down once. They change only when the drill is run again.
Re-run every 90 days, checked weekly by drills-due.yml · not tested: a stolen browser session registering a new device, and whether an already-open session survives · runbook, with the timeline
Re-run every 90 days, checked weekly by drills-due.yml · not tested: a device that is offline when the suspension lands, and what a suspended user still sees in the console · runbook, with the timeline
Re-run every 30 days, checked weekly by drills-due.yml · not tested: posture, and anything the operator's own devices may reach · runbook, with the timeline
Re-run every 30 days, checked weekly by drills-due.yml · not tested: the hub's own link, and a node restart · runbook, with the timeline
Three kinds of claim are on this page and they are not equally checkable, so they are separated rather than blended. The checks below run in your browser, against GitHub, with no account and nothing of mine in the path.
What only I can attest — the drill numbers. They were measured on a private network: seconds from a click to a refused connection, destinations reachable and not. You cannot repeat them without access to the tailnet, which is the thing being protected, and this page will not pretend otherwise. What you can judge is the method: every drill links a runbook with the exact commands, the controls used, and a section on what it did not test.
curl -s https://raw.githubusercontent.com/UVE-QA/zero-trust-lab/4a0b7e4c7da4443a108f9e1c5994f057fc892b5d/policy/policy.hujson.tmpl | shasum -a 256 # expect 57dbc8ea8b69e0fbd2f7b97e8504ca130097b0b6ddaaa7e58833b9fc187b0616
Then read tailnet-check.yml: the job renders that template with values held as secrets and asks the tailnet to compare the result, byte for byte, with the policy actually in force. The run log is public; the credential it uses is minted per job and can only read.
Counted from the repository's files when the page was built. The live checks above are what show these are the rules actually in force.
policy template · decision log
What this lab intends and does not have, why, and what each would take. An item leaves this list when it is built; nothing here is drawn on the diagram as if it existed. The full plan is in STATUS.md.
| What | State | Why not yet | What it takes | Ref |
|---|---|---|---|---|
| Sensors as tailnet nodes | deferred | The house's sensors cannot run a client. Simulated ones would share a cloud host that serves two projects on 2 GB with no swap. | a single-board computer and a sensor, about $30 | Phase 6 |
| A mobile unit that takes commands | not built, by decision | A command channel was built and reviewed, then retired: the owner chose to have the house trigger the robot on its own event instead. The lab receives the robot's telemetry and can move nothing. Nothing outside the house can reach it at all, which is the stronger claim. | nothing — this is the decision, not a gap | docs/designs/mobile-unit.md |
| The dashboard tablet in a role of its own | not built, by decision | It is not a kiosk. The tablet shows the house's dashboard on a wall and is also a personal device that leaves the house, so a machine role would take the second use away with the first: a tagged node stops carrying its owner's identity, and everything the person reaches from it away from home goes with the tag. The residual risk -- an unlocked tablet holds what its owner holds -- is answered by revocation rather than by a tag, and that revocation is measured (D-061). | nothing; this is the decision, not a gap | D-068 |
| A check that the household's own paths still work | half built | The half that can be automated is running: a daily job fails when an address a grant names has no approved route behind it, which is exactly what went silently missing for two weeks (D-074). The half that cannot is the experience -- whether a person outside the house can open the house -- and that is now the fourth line of every drill's pre-flight rather than a job. | a device that stays outside the home network and reports: a spare tablet on mobile data, or a single-board computer with an LTE stick, about $30 plus data | D-073 |
| Lost comms on the household side | planned | Measured so far only where the lab could break its own link. The hub's link and the control plane are the house's. | an agreed window with the household | Phase 6 |
| A check a visitor can start from the page itself | built another way | A button on the page would need an endpoint holding a credential to start a workflow, and this page holds none on purpose. The same thing is now done without one: a fixed phrase in a labelled issue re-runs the read-only checks and answers in the thread (D-063). What is still missing is only the convenience of not leaving the page. | nothing worth its price — the remaining gap is one click, and closing it costs a credential | D-063 |
| Refusals measured in public, not asserted | owner decision | A nightly ephemeral node with no grants at all could attempt a fixed list of connections and publish "0 of N reachable" as a machine-produced log. It would turn the deny-by-default claim from something this lab says into something a stranger can read off a run. The cost is the first credential in CI that can create anything — a key that registers a node. | an ephemeral pre-authorised key for a tag with no grants, target addresses as secrets, and the disclosure sweep extended over the artifact | docs/designs/viewer-verification.md |
| The standby way into the house | policy done, devices pending | Two personal devices advertise the whole home /24 as a deliberate second way in, approved by nobody. Approval is all-or-nothing per prefix, so switching the standby on would have opened the entire subnet -- and it would not have worked anyway, since the grant names one gateway. The always-on appliance now carries a standby role and will advertise the same two /32s as the primary, giving automatic failover with no widening (D-069). | two device-side steps by the owner, then approving exactly those routes; and a drill, because the failover has never been exercised | D-069 |
| Just-in-time access to the action tier | needs a paid plan | Standing access to the socket would become a grant that expires. | one time-boxed month, $18/user | Phase 4 |