zero-trust-lab · live evidence

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

Live here: running now the map, moving live checks measured drills verify it yourself — or run one

The one number

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.

Live, checked daily

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.

Not built, and why

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.

Read with care

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

Now

Reading GitHub…

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.

The lab on one picture

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.

Show:
CI · GitHub Actions no stored keys · OIDC per job Control plane · Tailscale SaaS · policy as code Cloud · AWS us-west-2 — managed by Terraform planned by CI · applied by a person Tailnet — WireGuard overlay · 100.64/10 · one identity per node · deny by default laptop + phones = one policy role grey: roles declared in the policy, no host yet — see "not built yet" below the vacuum answers to the hub through its vendor's cloud, not this network — and to nobody else one /32 route per exposed device, through the hub · the /24 is advertised by two devices, approved by none, being narrowed to those /32s (D-069) connected over: Matter over Thread and Wi-Fi · HomeKit · Wi-Fi · a Thread / Zigbee radio Home network — flat private /24 · reached only through the hub The rest of the houseabout 35 devices known to the hub · 2 reachable from the tailnet8 lights, plugs and switches5 temperature sensors4 radios and bridges3 media and TV2 phones, as presence2 blinds2 buttons1 robot vacuum6 pet care, other, inactive or withheldcounted 2026-09-12 · cameras withheld Growth path — not built. What a multi-user or company deployment adds, what it plugs into, and what it would cost GitHub Actions — Runs every check. Holds no network or cloud key: each job gets a short-lived token by OIDC.GitHub Actionsvalidate · drift · planworkflowsTailscale — Evaluates the policy and refuses a save whose tests fail. The policy is not in Terraform: two tools writing one global file fight.Tailscalecontrol plane · 71 testspolicy as codeCI plan role — Lets CI run terraform plan and nothing more. Its trust names this repository by immutable id; wildcards are refused three ways.CI plan roleOIDC · 0 IAM usersterraformArchive bucket — Where the collector's data is meant to land. It exists so the certificate credential has something real to authorise.Archive bucketS3 · encrypted · GlacierterraformRoles Anywhere — 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.Roles Anywherecertificate → role · liveterraformTerraform state — State joins the account's existing bucket rather than creating one; locking is the native lockfile.StateterraformOperator laptop — 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.Operator laptopposture: self-reportedlive · 7 user-ownedOperator phones — Posture subjects, and the way back in if everything else fails — verified off-network.Operator phonesbreak-glass pathby handHome server — 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.Home serveruser-owned · untaggedby handWork machine — 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.Work machinetag:workstationlive · 0/1 onlineMedia appliance — 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.Media appliancetag:appliancelive · 1/1 onlineAutomation hub — The only door into the home: it advertises one /32 route per exposed device, never the subnet.Automation hubtag:gateway-homelive · 1/1 onlineCollector — 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.Collectortag:collector · containerlive · 1/1 onlineMobile units — 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.Mobile unitstag:drone · not built, by choicedecidedSensors — 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.Sensorstag:sensor · not builtdeferredTest user — 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).Test usergroup:testers · suspendeddormantProduction VM — The production stand-in. A destination, never a source. Outside Terraform; also where a person runs terraform apply.Production VMtag:prod · Lightsaillive · 1/1 onlineSmart plug — 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.Smart pluggranted · postureone /32Smart plug — The same model on the same port, never granted. Proves least privilege is per host, not per protocol.Smart plugidentical · refusedone /32Cat camera — 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.Cat camerareleased · events onlyno routeRobot vacuum — 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.Robot vacuumreports onlyno routeIdentity provider — One login for many people; their groups become policy sources instead of every member. Plugs into the control plane and cloud sign-in.Identity providerSSO · users, groupsStandard $8/user/moMDM / EDR — Posture from the fleet's management system instead of the client's own report. An integration a paid plan adds.MDM / EDRposture from the fleetStandard $8/user/moJust-in-time — Standing access to the action tier replaced by grants that expire. Needs a paid plan: one time-boxed month.Just-in-timegrants that expirePremium $18/user/moLog streaming — Who connected to what, kept and searchable. The drills in Phase 6 would read it.Log streamingflow + audit → SIEMPremium $18/user/moIdentity Center — Cloud sign-in for people through the identity provider. Lives in the organisation's management account, not this stack.Identity Centerpeople's cloud sign-inAWS: no chargeManaged private CA — 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.Managed private CAhardware custody · audit trail$400/moread-onlyterraform plan · OIDCpolicy apply · a personenforcesterraform apply · a personcertificate → archive · hourly credentialsvisit events · LANstate · via the vendor's cloud✗ refused · 2 tested✗ refused · 3 tested✗ refused✗ operators refused · tested8123, 2222, 590022 · posture844370008443844352432 · posture22, 3389, 211188444

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.

What each part is for, and why it is managed the way it is
PartManaged by · costWhat it is forWhy
GitHub ActionsworkflowsRuns every check. Holds no network or cloud key: each job gets a short-lived token by OIDC.D-041
Tailscalepolicy as codeEvaluates 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 roleterraformLets CI run terraform plan and nothing more. Its trust names this repository by immutable id; wildcards are refused three ways.D-028
Archive bucketterraformWhere the collector's data is meant to land. It exists so the certificate credential has something real to authorise.D-039
Roles AnywhereterraformCloud 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 stateterraformState joins the account's existing bucket rather than creating one; locking is the native lockfile.D-029
Operator laptopby handThe 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 phonesby handPosture subjects, and the way back in if everything else fails — verified off-network.D-016
Home serverby handRuns 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 machinetagSomebody'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 appliancetagA 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 hubtagThe only door into the home: it advertises one /32 route per exposed device, never the subnet.D-024
CollectortagTelemetry 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 unitsdecidedA 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
SensorsdeferredPush-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 userdormantA 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 VMby handThe production stand-in. A destination, never a source. Outside Terraform; also where a person runs terraform apply.D-036
Smart plugone /32The 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 plugone /32The same model on the same port, never granted. Proves least privilege is per host, not per protocol.D-010
Cat camerano routeThe 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 vacuumno routeThe 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 providerStandard $8/user/moOne 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 / EDRStandard $8/user/moPosture from the fleet's management system instead of the client's own report. An integration a paid plan adds.D-040
Just-in-timePremium $18/user/moStanding access to the action tier replaced by grants that expire. Needs a paid plan: one time-boxed month.Phase 4
Log streamingPremium $18/user/moWho connected to what, kept and searchable. The drills in Phase 6 would read it.Phase 6
Identity CenterAWS: no chargeCloud sign-in for people through the identity provider. Lives in the organisation's management account, not this stack.D-030
Managed private CA$400/moThe 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 houseno routeAbout 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

Checked against the live network

The policy in force is the one on main

pass

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

· open the run →

Every policy test passes on the live network

pass

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

· open the run →

The tests can fail

refused, as it should be

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

· open the run →

Cloud infrastructure matches its code

pass

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

· open the run →

Nobody holds a long-lived key to the cloud account

pass

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

· open the run →

Checked on every change

No real address, hostname or account id can merge

pass

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

· open the run →

No secrets in history

pass

The whole git history is scanned for credentials on every change.

commit 4a0b7e4 · push

· open the run →

main accepts only changes that passed its checks

pass

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

· see the branch →

Measured by watching the system

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.

A personal device is lost, and does not cooperate with its own revocation · 2026-09-13 due again in 47 days

under 2 sfrom the suspend click to every connection refused — while the device was cut off from the control plane and never learned it had been revoked
no windowfor a device that was switched off during the revocation: refused from its first packet on return, and for the 60 s that followed
at the destinationwhere the refusal happens: the target received the new netmap; nothing was asked of the device that had been taken away

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

A second user joins, is granted one real device, suspended and restored · 2026-09-13 due again in 47 days

1 of 10destinations the test user reached with their one grant: the media appliance's AirPlay port — and it answered, so the grant points at a live service, not a hole in a policy file
0 of 10the same probe with the user suspended: the appliance, the collector, the hub's UI, shell and broker, production, the home server and both plugs, all refused
~1.6 sfrom the suspend click to the port refusing; restoring took about 1.7 s, both measured at one-second poll resolution

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

A device joins, changes role, and is revoked · 2026-09-11 overdue by 15 days

1 of 8destinations a node in the sensor role could reach: the collector's ingest port, nothing else
0 of 8the same machine after its role was changed in the console — with no edit to the policy
1.6 sfrom removing the device to it being off the network, every destination refused

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

Lost comms, collector side · 2026-09-11 overdue by 15 days

11 sfor the network path to come back after a 15 min 9 s outage, with no hands
7 of 7readings sent during the outage were lost; 0 replayed — nothing is buffered, by design
nonemanual steps to recover

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

Verify this yourself

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.

  1. Reading GitHub…

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.

Without trusting this page's JavaScript
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.

From the code, not measured

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.

10access grants, each naming one port
71policy assertions — 58 of them must be refused
2real addresses in the whole policy
nonewildcard grants
80recorded decisions
4of them correct an earlier one

policy template · decision log

In the project, not built yet

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.

WhatStateWhy not yet What it takesRef
Sensors as tailnet nodesdeferredThe 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 $30Phase 6
A mobile unit that takes commandsnot built, by decisionA 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 gapdocs/designs/mobile-unit.md
The dashboard tablet in a role of its ownnot built, by decisionIt 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 gapD-068
A check that the household's own paths still workhalf builtThe 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 dataD-073
Lost comms on the household sideplannedMeasured 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 householdPhase 6
A check a visitor can start from the page itselfbuilt another wayA 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 credentialD-063
Refusals measured in public, not assertedowner decisionA 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 artifactdocs/designs/viewer-verification.md
The standby way into the housepolicy done, devices pendingTwo 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 exercisedD-069
Just-in-time access to the action tierneeds a paid planStanding access to the socket would become a grant that expires.one time-boxed month, $18/userPhase 4