per-project-sets
140
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
045f8d817f |
Write down what this migration taught, where the next agent will read it
This repository had no .dejima.md. The first rule in it is the one the v3 migration paid for: every step that asked an operator to type something was a step that went wrong on a real host, and every fix was to stop asking rather than to ask more clearly. --network and --dir became --pool; the count, the label and the group name became things read from the project already in the directory. Three more, all from the same week and all invisible from reading the code: run a migration before shipping the note describing it, since the suite passed throughout while every step of that note failed; the scoping is the registration and never the label, because a branch writes its own runs-on; and a set is addresses with no memory of what put them there, which is why revocation has to flush and why scoping the resolver alone would have bought nothing. |
||
|
|
931938d9b9 |
Recreate the runners when a file they mount was rewritten
Reported from the migration. The runners came up, registered, and then failed their own check with "registered, but /config.yaml differs" - twice, because re-running changed nothing. config.yaml is bind-mounted into each container as a FILE, so the mount follows the inode rather than the path, and write_if_changed replaces the file by rename. The new content gets a new inode and a container that is already up keeps reading the old one. Compose does not recreate it either, because the service definition did not change; only a file it mounts did. So the pool runs with the previous allowlist and the previous network while every file on disk says otherwise, and the check that noticed had no way to fix it. --force-recreate when something was written, and not otherwise: recreating on every run would restart a healthy pool for nothing and lose whatever job a runner was mid-way through. --remove-orphans always. Dropping a pool from three runners to two left the third running, with the config and the network it started with - on this tier that is a runner still taking jobs on the allowlist the change existed to move it off, and no file here describes it any more. Two smaller things from the same run. A directory named by a pool with no compose project in it now says so, rather than falling through to built-in defaults and suggesting a count from nowhere. And the project summary prints once rather than at both call sites, where it read as two different projects. |
||
|
|
062fe95db4 |
Read the project before asking the questions it answers
`--pool branderson` knew the project directory and still offered "dejima-confined" as the group name, because the name is asked before the compose directory is, and the existing project was only read after that prompt. The group name is the one default that must not be wrong. It names the containers and the volumes, so accepting a default that does not match the project already there writes a second set of both into the same directory and orphans the running ones - and the default was derived from the label, which is shared across pools by design, so it was wrong for every pool but the first. Reading moves ahead of the questions, which --pool makes possible by knowing the directory before anything is asked. Group name, count, label and job image all come from the project when there is one. Running it non-interactively against an existing pool now reproduces that pool exactly, which is what taking every default should mean. The guard against pointing a project at another pool's network came back with it: an earlier edit of this block took it out along with the code it replaced. |
||
|
|
09b6ea4dce |
Let --pool say which tier, since only one tier has pools
`dejima-ci-runners --pool dejima` printed usage, because --role was still required. That is a second flag to get right for a decision the operator is not making: the privileged tier has no allowlist and no job network to scope, so a pool is a confined-tier idea and naming one says which tier it is. --pool now implies --role confined, and --pool with --role privileged is refused rather than quietly switched, since changing the tier under somebody who asked for privileged would be worse than the usage message this replaces. The command in the upgrade note was the one that failed, so a test holds the note to a form that runs. |
||
|
|
8cfa423e20 |
Name the pool, rather than a network and a path that must agree
`--network dejima-ci-1 --dir ~/runners/whatever` is two values that have to agree with each other and with a third file, in a command run once per pool. The second time it is run the path gets changed, because the path is the part that obviously differs, and both pools end up on the first one's network. That is one pool running with another pool's allowlist, and it looks exactly like it worked. So the pools file records which runner project belongs to which pool, and dejima-ci-runners takes --pool: the network and the directory both come from the file, and neither is typed. `pools add <scope> <project-dir>` records it, and re-running pools add on a scope that already has an id records the directory rather than reporting the id and leaving the operator to edit the file. The hand-written form is still there and now refuses the mistake it invites: if the pools file says a project belongs to one pool and --network names another's, this stops and says which is which. Catching it matters more than the flag being available, because the failure is silent - the runners come up, the jobs pass, and the allowlist is somebody else's. pools list shows each pool's network and project, so the state is readable without opening the file. |
||
|
|
ed288c7dcd |
Stop the migration from costing an operator their runner config
Reported from performing it. The installer is meant to be re-run by pressing enter through, and on a host with two pools already, enter at "Set up the runners now? (Y/n)" walked into generating a new pool from built-in defaults. To answer it the operator had to background the installer, read their own count and label out of a docker-compose.yml in another terminal, and come back to a prompt that had moved on - or ctrl-c and start over. Three things, each removing a place to get it wrong. THE RUNNER PROMPT DEFAULTS TO NO WHERE RUNNERS EXIST. Answering the safe way should be what happens when nobody answers, and on a re-run there is usually nothing to add. It also reads out the projects that are there, with each one's count, label and network, so the first question anybody has at that prompt is answered in front of them rather than in another terminal. THE GENERATOR READS AN EXISTING PROJECT AND OFFERS ITS VALUES BACK. Count, label, job image and network come from the compose file and config already in the directory, so re-running it to change one thing reproduces the rest instead of replacing it with defaults. That is what makes moving a pool to its own network a one-line change rather than a re-registration. POOLS ADD CREATES THE NETWORK. The bridge name has to match what the firewall writes rules for, and it is the one value in the procedure that nothing else checks: a typo produces a network that works, a set that fills, and a firewall rule naming an interface that does not exist. Idempotent, and on a host with no docker it says what to run where instead. The note is shorter by a step and by a hand-typed docker command. |
||
|
|
e4e0816f6f |
Give each runner pool the allowlist it could actually run
Every job on the confined tier resolved against one list and was filtered by one ipset, so a name any trusted repository declared was reachable from every repository's jobs. One project's declaration widened all of them. THE SET IS WHAT HAD TO BE SCOPED, not the resolver. It holds addresses with no memory of which name put them there, and every bridge matched the same one, so the union was at the address layer: a job that could not resolve another project's name could still connect to the address that name resolved to, and declarations are public. A SET PER RUNNER POOL, holding the union of every declaration that pool could be made to run. That is the only union that is safe. A pool takes jobs from the repositories it is registered for, so a hostile branch in one of them can already put a job on it, and its names being in that pool's set gives away nothing the branch did not already have. What the set leaves out is every repository the pool cannot run at all. An earlier draft scoped per project and leaned on labels to route jobs, which is not a boundary: a branch writes its own runs-on, so a label is a request the job makes rather than a property the operator sets. What a runner may take is decided by its registration, which a branch cannot change. So the set is built from the registration - one repository, or an owner - and labels are left alone. Pools may share them. Registering a pool to one repository is how its union gets small, and is the recommendation rather than the mechanism. An owner-scoped pool is not a misconfiguration: it is a pool that genuinely can run any of those repositories' jobs, holding exactly what that implies. So the dejima organisation's repositories can keep one org-scoped pool and one union, a user-scoped pool keeps a small one, and a repository needing an unusual domain can have a pool of its own without that domain reaching anybody else's. No shared fallback. A declaration no pool can run refuses the render, naming what is missing: that is almost always a repository trusted to widen the allowlist and never given a runner, which is a job queueing rather than a security decision, and rendering its names into nothing would hide it. Refusing installs nothing, so the allowlist already in force stays in force. dnsmasq places a name's addresses in several sets at once, so one resolver fills each pool's set and no others. The firewall matches each bridge against its pool's set and a base set every pool shares, and takes rules for a pool's bridge whether or not the config's bridge list knew about it. Three things caught while doing it. The name count was lines minus the header, which doubled the moment a name emitted two lines. A guest-layer note must require a snapshot, or this tier's scheduled rollback quietly undoes the install. And the revocation flush from the previous change flushed the set named by the config key, which is a prefix now and holds nothing - it would have emptied an unused set and reported a revocation that had not happened. Capturing fetch_repo into a variable also dropped its exit status, so a forge that rejected the token became a repository that declared nothing. It was the last command of a pipeline before, where pipefail carried the failure out. |
||
|
|
8cb4fb0447 |
Scope a project's names to that project's jobs
Every job on the confined tier resolved against one list and was filtered by one ipset, so a name any trusted repository declared was reachable from every repository's jobs. One project's declaration widened all of them. THE SET IS WHAT HAD TO BE SCOPED, not the resolver. It holds addresses with no memory of which name put them there, and every bridge matched the same one, so the union was at the address layer: a project that could not resolve another's name could still connect to the address that name resolved to, and declarations are public. A per-project resolver would have looked like progress and changed almost nothing. dnsmasq places a name's addresses in several sets at once, so one resolver fills each declaring project's set and no others. The allowlist writes an ipset= line per name listing the sets that asked for it; the firewall matches each bridge against its own project's set and a base set every project shares. NO SHARED FALLBACK, and no opt-in. A repository that declares names and has no set of its own refuses the render, naming what is missing. A set every project can reach is the thing this exists to end, and falling back to one would mean arriving there by doing nothing rather than by choosing it. Updating is the opt-in; not finishing the update is not a quieter way to keep the old behaviour. Refusing installs nothing, so the allowlist already in force stays in force while the migration is worked through - the cost is a loud error on the timer, not a host that has lost its allowlist. A repository that declares nothing needs no assignment, having no names to scope, and meets the refusal on the day it declares something. Ids are never reused. One names a set and a bridge that may still hold addresses and still carry rules, and handing a freed id to another project would give it whatever the last occupant left behind. The upgrade note is a migration in five steps, ending with moving each project's runners onto its own network - the step that decides whether any of it takes effect, since a runner takes whatever jobs it is offered. dejima-ci-runners gained --network for that, rather than the note naming a flag that does not exist. Three things caught while doing it. The name count was lines minus the header, which doubled the moment a name emitted two lines. A guest-layer note must require a snapshot, or this tier's scheduled rollback quietly undoes the install. And the revocation flush from the previous change flushed the set named by the config key, which is a prefix now and holds nothing - it would have emptied an unused set and reported a revocation that had not happened, which is the failure that flush was added to fix. Capturing fetch_repo into a variable also dropped its exit status, so a forge that rejected the token became a repository that declared nothing. It was the last command of a pipeline before, where pipefail carried the failure out. |
||
|
|
7df4f6ef35 |
Merge pull request 'Make removing a name actually revoke it' (#25) from revoke-takes-effect into main
Reviewed-on: #25 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
f8366eccb5 |
Make removing a name actually revoke it
dnsmasq puts the addresses it answers with into the firewall's set, and nothing ever took them out. So removing a domain from a declaration and re-rendering stopped the name resolving and left every address it had ever resolved to reachable, until the host rebooted. A job that had already looked the name up, or that read the address out of the declaration's history, connected exactly as before. Revocation looked like it worked and did not. The set holds addresses with no memory of which name put them there, so the only revocation ipset offers is to empty it. Flushed on a removal, and only on a removal: flushing whenever anything changed would empty it every time a name was added, and a job mid-run would find a destination it had already resolved refused until it looked it up again. Established connections are unaffected either way, since the firewall accepts ESTABLISHED,RELATED before it consults the set. Before the reload, not after. The reload is a SIGHUP, which clears dnsmasq's cache, so the next query for a name that is still allowed goes upstream and puts its address back. The other order leaves cached answers that repopulate nothing and names still on the list unreachable until their TTL expires. A set that is not there is reported rather than passed over. The names go out of the resolver either way, so it half-succeeds, and an operator who is not told believes a revocation took effect. The threat model now states the union as a bounded property rather than leaving it to be discovered: one project's declaration is reachable from every project's jobs, bounded by the fact that a repository on that list is already trusted to run code on the host. What isolating them would take is written down under Rejected, with the constraint that decides the shape - a per-project resolver without a per-project set buys almost nothing, because the set is addresses and the addresses are shared. |
||
|
|
3489b5f167 |
Merge pull request 'Strip comments where every name arrives, not at one source' (#24) from strip-comments-once into main
Reviewed-on: #24 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
c539c82796 |
Strip comments where every name arrives, not at one source
The previous commit stripped base-domains and stopped there. The next run on a real host died the same way, on a bare `#` from a repository's own declaration: a fetched body was cat'd raw too, so the bug was the same one a layer over, and fixing the source I happened to be looking at left the other one open. There is one place every name arrives, and comments are stripped there now. Both sources reach it, and so will any source added later. A domain cannot contain `#`, so nothing legitimate is lost. The per-repository count was of lines, so a comment block counted as declared names: "15 name(s) declared" for a file declaring rather fewer. It counts names. The bare `#` is not an odd thing to write. It is the separator line in a comment block, which is how the generated files in these repositories are written, so the first project to document its own declaration would have hit this whatever else it contained. |
||
|
|
9ae7bb982a |
Merge pull request 'Make trusting a repository something you can do in one command' (#23) from repos-usability into main
Reviewed-on: #23 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
11b841500e |
Make trusting a repository something you can do in one command
Reported from a real host. A new project added .dejima/allowed-domains and got none of its names, with nothing anywhere saying why; the operator had to remember that a fixed list of repositories exists at all. Fixing it meant re-running install.sh, whose prompt offered "keep this list?" and treated no as "type all nine again, from memory". Then the run claimed no runners were configured, on a host with runners running. Then the allowlist refused to render, complaining about a `#` that was not where it pointed. Five things, all of them that one session. A COMMENT IN base-domains KILLED THE RENDER. It was cat'd raw while the repos file one loop below was comment-stripped, and install.sh strips comments when it reads the same file back. So a `#` line reached valid_domain and died with "refusing a malformed entry: #namesallowedregardless...", which names no file. The operator looked at /etc/dejima-ci/config, which has a comment at the top that install.sh put there and which was never the problem. Stripped now, and the message names both files an entry can come from. `repos list|add|remove` on dejima-ci-allowlist, so adding a project is one command rather than a pass through the installer. It needs no forge and no token: it edits a list of names, and requiring a working configuration to do that makes it useless on the host whose configuration is being diagnosed. COLLECTION SAYS WHAT EACH REPOSITORY GAVE. A repository absent from repos_file is never fetched, so it produced no line, no error and no trace - the operator had to already suspect the list to find it. Every consulted repository now reports its count, and one that is missing is missing from a printed list. install.sh offers keep, add or replace, and points at the new command first. And it no longer asserts that no runner is configured. That was printed without checking, on a re-run, which is the normal way to change one setting - so the claim was most likely to be read by somebody it was false for, and it reads as the installer having undone something. Found while testing: `repos` consumed the rest of the command line where it was seen, so a following --config was swallowed and the edit landed in the file named by the default config instead. Silently, on the file deciding whose branch may widen an allowlist. |
||
|
|
665113be51 |
Merge pull request 'Give the privileged tier a working v1 to v2 path, and link the upgrade docs' (#22) from upgrade-path-privileged into main
Reviewed-on: #22 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
5d61050f87 |
Do not assume /usr/local/sbin is on the PATH sudo hands you
Reported from a real box: after the install commands in v2's privileged block, `sudo dejima-ci-upgrade set-tier privileged` was not found. Everything installs to /usr/local/sbin and every instruction in this repository reads `sudo dejima-ci-...`. That works only where sudoers sets a secure_path including sbin. Without one, sudo inherits the caller's PATH - and Debian leaves every sbin directory off a non-root PATH, which is exactly what the reporting operator's PATH showed. The result is a correct install whose documented commands all report "command not found", read as a broken install rather than as a PATH. Nothing checked it, in either project. install.sh reported what it wrote; whether anything could find it afterwards was never asked. install.sh and the privileged branch of bootstrap now check and say so. The check is honest rather than a proxy: both run under sudo, so their own PATH is the one the operator's later sudo commands will get, which makes `command -v` there the real question. v2's install blocks use the full path for the commands immediately after an install, on both tiers, because that is where the problem bites and where naming the path costs nothing. The note also says how to tell which case you are in, since `sudo printenv PATH` answers it and `sudo echo $PATH` does not - the shell expands that one before sudo runs, so it prints the caller's PATH and looks like an answer. `bats test/` 136 passing. |
||
|
|
f6316e6c0e |
Give the privileged tier a working v1 -> v2 path, and link the upgrade docs
Two findings, both from reading the path rather than the code. THE "RECORD IT PROPERLY" BLOCK WAS CONFINED-SHAPED. It ran install.sh - which does not run on a privileged host, deliberately - and then invoked dejima-ci-upgrade bare, which only works because install.sh had just put it there. A privileged operator following it got exactly the error that started this: the tool reporting it could not find itself. There is now a block per tier. Confined keeps install.sh. Privileged gets the three install commands bootstrap uses, because bootstrap is the only thing that installs the tool on that tier and re-running it on a built machine also redoes runner setup - a real cost for a machine that is already serving. Those three duplicate bootstrap's lines and can drift from them, so a test pins both ends to the same destinations. A tool installed to the wrong path is a tool that is not there, reported three sections later as "not installed here", which is how this whole thread started. THE UPGRADE DOCS WERE NOT LINKED FROM THE README AT ALL. Only docs/README.md mentioned them, so an operator with an existing runner had no path from the front page to the flow. There is now an "Updating an existing runner" section with the command to check a version, the instruction to walk the notes in order, and the two things a runner operator needs before starting: run from a checkout, and recording is itself a guest-side change. The runbook and the docs index point at it, and so does the intro, where someone skimming will be. `bats test/` 136 passing, 1 new. |
||
|
|
a76f38bcc4 |
Merge pull request 'Install the upgrade tool on the privileged tier, which never got it' (#21) from privileged-upgrade-tool into main
Reviewed-on: #21 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
e5e5a307b3 |
Install the upgrade tool on the privileged tier, which never got it
Reported: dejima-ci-upgrade never reaches a privileged box. install.sh puts it on a confined host and does not run on a privileged one - that is the grant the tier exists for - and nothing else installed it. So the one tier whose ONLY boundary is the node firewall was the one tier unable to ask which node-side versions it was missing. bootstrap now installs it there, alongside dejima-ci-runners and for the same reason that already justifies that line: on this role install.sh is not there to do it, and the host should end up with the same "re-run it to change something" story as a confined one. It also records tier=privileged. The tool refuses to guess the tier, which is right - an absent config identifies a privileged host and equally a confined one whose install.sh has not run - but this branch IS the privileged path, so there is nothing to guess and no reason to make an operator type it. guest_version is deliberately still not written there. It tracks the guest-side controls, and this tier runs none; the two programs bootstrap places are tools, and a stale installed copy is something the tool already reports about itself. The comment claiming the tier "installs nothing in the guest" was true when written and is not any more, so it says what it means now. WHAT THIS DOES NOT FIX is a privileged host already built at v1. Bootstrap is not part of the v2 upgrade, so those hosts still have no copy and will not get one from anything on that page. The note now says so where a privileged operator will be looking, points at the checkout as the answer for v2, and notes that re-running bootstrap is the alternative and is a guest-side change with a rollback cycle behind it. `bats test/` 135 passing, 2 new. |
||
|
|
26f96e8e2b |
Merge pull request 'Fix a tool that could not find itself, and a note that told you not to' (#20) from verify-resolution into main
Reviewed-on: #20 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
d879b6d5b3 |
Fix a tool that could not find itself, and a note that told you not to
Reported from a real runner: `sudo ./bin/dejima-ci-upgrade mark-node 2` answered "cannot check - dejima-ci-upgrade check-lan is not installed here" and recorded the assertion unverified. THE TOOL COULD NOT FIND ITSELF. verify-node commands were resolved on $PATH only, and the notes tell an operator to run this from a checkout because on the runner being upgraded it is not installed - it arrives WITH the version. So the lookup failed on the program doing the looking, mark-node recorded a weaker claim than it had evidence for, and status would have said UNVERIFIED forever. $PATH still wins where both exist, since the installed copy is what the machine runs; the fallback beside this program is what makes the documented workflow work. A CHECK THAT COULD NOT TELL WAS READ AS A CHECK THAT FAILED. Fixing the lookup exposed it immediately: check-lan exits 2 for "could not ask", and mark-node then refused to record, telling the operator their console work was missing on the strength of a check that never ran. Wrong in the alarming direction, about the one thing they cannot see for themselves. The contract is now written down - 0 in force, 2 could not determine, anything else not in force - and 2 records as unverified with its own message. AND THE NOTE'S ADVICE WAS INCOHERENT. It said not to run install.sh because that is a guest-side change a rollback reverts, while telling the operator to run set-tier and mark-node - which write /etc/dejima-ci/state, are equally guest-side, and are equally thrown away. Recording anything on a runner takes the snapshot cycle, so §2 now says that, and says to install the tool while you are in it rather than paying for the dance twice. It also says recording is OPTIONAL, which it is: the rules are on the hypervisor and in force either way. That is only safe advice because status no longer nags about it - for an upgrade with a verify-node command it runs the check before calling the work outstanding, and reports "NOT RECORDED, but its check passes" where it passes. Otherwise every rebuilt runner would report console work that is already done, and an operator who believed it would go and do it again. v2 is amended rather than superseded: it was deployed today and nobody else is on it. `bats test/` 133 passing, 5 new. |
||
|
|
022c5f6ce2 |
Merge pull request 'Version the deployed state, so an existing runner can be brought forward' (#19) from versioning into main
Reviewed-on: #19 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
8748ee550b |
Check that every fenced sh block in the docs parses
The same guard as the sister project, for the same reason: the runbooks are the product, and a command in one is something an operator pastes into a root shell on a hypervisor rather than an illustration of one. It was written there after a prose restructure turned a `\` line continuation into `\\` inside a fenced block - rendering fine, reading as prose in the diff, and breaking for anyone who pasted it. Nothing else in either repository looks inside a code fence. Placeholders are substituted before parsing, because `<name>` is this project's idiom and shell reads it as redirection. This repository is where that mattered: `ssh <you>@<vm-ip>` and `getent hosts <your-forge-hostname>` in docs/building-a-runner.md do not parse, while the sister project's `try <forge-ip> 443` happens to. Testing that distinction would have been testing nothing, and would have pushed the docs towards whichever spelling appeased the parser. Parses without executing, so it checks syntax rather than correctness - the only safe thing to do with a file full of `sudo iptables` and a cron entry that rolls a VM back. Verified by reintroducing the original bug and watching it fail with the right file and line. 128 passing. |
||
|
|
67f1028975 |
Give the runbook a map, and put the reasoning after the doing
The page opened with 592 words before the first click and no indication of how much work was coming. That is most of what made it daunting: not the length, but that the length was unmeasurable from the top. It now opens with the whole job as a five-row table - what you do, where it is, and roughly how much - so the shape and the scale are visible before any of the reasoning. Four of the five rows are one setting or one form; the table says so, and says that step 4 is the substance. Someone who has done this before can go straight to the rule table from there. The console-only argument moved to the end as "Why there is no script for this page". It is the page's philosophy, not something a reader needs before turning on a firewall switch. Verification was one 1,100-word run of prose with the two halves separated by a lowercase "and". It is now two labelled passes, each with its commands first: pass one reads configuration, pass two tests behaviour, and the text says once, at the top, that only the second is proof. `bats test/` 126 passing. docs/building-a-runner.md updated for the renamed anchor. |
||
|
|
fe1953b456 |
Front-load the prose, and stop restating the same arguments
The same audit as the sister project, against attention budget: a reader stops within 100 words and then skims or leaves. THE README ANNOUNCED A DECISION AND THEN BLOCKED THE PATH TO MAKING IT. "Pick the tier first" arrives at word eighty; the table that lets you pick is just below it, and then 826 words of justification sit between that table and anything actionable. Those two arguments are good and they are evaluation material, not build material, so they now sit at the end of the page with pointers from the tier section - including one saying plainly that the privileged tier's missing allowlist is deliberate and worth reading about BEFORE choosing that tier. Both headings keep their text, because two other documents link to them by anchor. THE POLICY AND PROBE ARGUMENTS WERE RESTATED FROM THE TEMPLATE. The deny-outbound rebuttal appears above `policy_out` in firewall.fw.template and was repeated in full in the runbook; so were the reasons the gateway and ff02::1 are the right probes. The runbook now links. Found while editing: "Then apply it, per VM" said "Nothing follows rule 8" and the upgrade note still described the old blast-radius wording. Stale since rules 9 and 10 landed. The sister project had the same class of error in the same paragraph, which is its own small lesson about copied prose drifting in two places at once. No argument removed. `bats test/` 126 passing. |
||
|
|
49562a1c47 |
Fix commands the upgrade notes gave that cannot run on the host they target
dejima-ci-upgrade arrives WITH a version, and v2 has no guest-side step - so on the runner being upgraded it is not installed, and nothing on that page puts it there. Both notes told the operator to run it as though it were on PATH. Corrected to run from a checkout, which is the better way on either tier anyway. A test pins the rule so a future note cannot repeat it: a note whose layers do not include 'guest' may not invoke the tool bare. It found the second instance immediately. Also adds what the notes were missing against the v2 work: - that installing iputils-ping so check-lan can run is itself a guest-side change a rollback reverts, with the two honest options and no third. The node rule is unaffected either way; only the ability to check it is; - that running install.sh merely to get the tool onto a confined box is a guest-side change too, so the next rollback takes it and the guest_version it wrote, leaving no sign anything happened; - the per-VM case for the security group edit, matching what the sister project's note already said; - a closing step that confirms both halves landed, and is what to run again in six months. |
||
|
|
7816bb7834 |
Version the deployed state, so an existing runner can be brought forward
A runner VM is pinned to the day it was built, with half its controls typed into a hypervisor console no `git pull` can reach. dejima needs nothing like this - short-lived containers, only the tip of main supported, a pull closes any gap - and the difference is worth stating rather than inheriting. AND THIS PROJECT HAS A SECOND WAY OF GOING BACKWARDS that the sister project does not: the scheduled rollback. A guest-side change applied to a running runner and never baked into the snapshot is undone at the next rebuild, on a timer, with nothing to see. So a guest-layer upgrade here is a seven-step sequence, and `status` prints it whenever one is outstanding rather than leaving it to be remembered. The counter-intuitive step is rolling back to the clean snapshot BEFORE applying the change, so the new rollback target is not whatever the last few weeks of jobs left behind. The state file lives in the guest, inside the snapshot, so it rolls back with everything else. That is correct rather than unfortunate: forget to re-snapshot and the marker reverts alongside the change it recorded, so the tool goes back to reporting the upgrade as outstanding. It fails towards saying there is work to do. Integer versions, one note per version including versions that need nothing. Two numbers tracked: guest_version written by install.sh, node_version asserted by an operator, because nothing in the guest can read the hypervisor's rules - which is what makes those rules the boundary. TIER IS RECORDED, NEVER DETECTED. An absent /etc/dejima-ci/config identifies a privileged host correctly and also identifies a confined one whose install.sh has not run, and guessing wrong selects a different set of upgrades - the same failure test/ci-runner.bats takes DEJIMA_CI_TIER rather than sniffing for. The privileged tier reports no guest half at all rather than an unrecorded one, since installing nothing there is the grant that tier exists for. `check-lan` gives mark-node something to contradict a wrong assertion with: it asks ff02::1 who is on the link and counts the replies that are not this VM's own. It is a narrow test of one node rule, not the in-VM audit that PR #18 said was out of scope, and the docs say so where an asymmetry with the sister project would otherwise look like parity. v2 needs node work only, so it asks for no re-snapshot. A test pins that, because reporting the sequence when it does not apply would train an operator to ignore it when it does. Tested with `bats test/` (125 passing, up from 100). |
||
|
|
6796ff1cd1 |
Name both halves of the reason an SSH push has no transport
The paragraph on how strongly the GitHub push block is enforced rests on two properties of dejima and named one. The allowlist decides IPv4; what makes port 22 unreachable at all is that dejima also blocks IPv6 in the container. An allowlist naming one address family says nothing about the other, so without the second property a session reaches GitHub's v6 ranges on any port it likes and the argument collapses without a line of it changing. Both are dejima's to keep true, which is why they are written down here. A claim about another repository's behaviour fails by that behaviour changing while the paragraph depending on it does not - the same shape as the node templates inheriting an IPv4-only rule list from reasoning that had been made correctly twice elsewhere. Found by the agent working in dejima, who read the equivalent claim there, found it did not make an IPv4-only assumption, and pointed out that the IPv4-shaped version might be on this side instead. It was. |
||
|
|
7a0a3becbf |
Merge pull request 'Bound the LAN over IPv6, and over the range a tailnet uses' (#18) from ipv6-boundary into main
Reviewed-on: #18 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
afbe827e0f |
Describe the boundary as it is, not as it changed
The threat model and the runbook are descriptions of the current tool, not a record of what moved. The IPv6 sections read as a changelog: what the rule list used to name, and what was reachable before. That belongs in the pull request, which has it. Also drops 'already' where it described this repository's own history rather than the relationship between two layers, which is what the sentence was about. Same rules, same reasoning, stated in the present tense. |
||
|
|
ec521afd84 |
Bound the LAN over IPv6, and over the range a tailnet uses
The node firewall's DROPs named IPv4 ranges only, so the boundary this runbook calls "the boundary" did not apply to IPv6 at all. That is open by default on every network including ones with no IPv6 in them: every Linux host brings up an fe80:: address on every interface whether or not anything routes v6, and a daemon bound to :: answers on it. Neighbours were reachable across the segment with all four DROPs in force and `ip -4 route` showing nothing unusual. bin/dejima-ci-firewall already blocked v6, and it is a different position. It covers a confined job reaching off its bridge. It is not in front of the VM's own traffic, and on the privileged tier it is not in front of the jobs either, since those run in the host's network namespace. The node rule is the one covering root on the VM, which on that tier is the ordinary case rather than the worst one. Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6 network is numbered out of a globally routable delegated prefix, so no equivalent range list can be written here - fc00::/7 catches ULAs most networks do not use and misses the prefix a neighbour is actually on. On the node rather than in the guest, for the reason the whole file is on the node. Also drops 100.64.0.0/10, where a tailnet is numbered. Rule 10 gets a by-hand check in proxmox/README.md rather than an automated one: it asks ff02::1 who is on the link and counts the replies. Nothing in this repository runs inside the VM - test/ci-runner.bats runs inside a job, which is the wrong vantage point, since a confined job's traffic is stopped by dejima-ci-firewall before it reaches the tap. That asymmetry with the sister project is noted where the test is. Docs: the threat model gains a section on the two address families and where each one's rule sits, a "Not bounded" list collecting the negative space in one place, two new entries under "Rejected, with reasons", and a quarterly review cadence with conditions to check rather than re-argue. Tested with `bats test/` (100 passing). |
||
|
|
d3f3219d5b |
Merge pull request 'Send the reader between the two halves, in the order that works' (#17) from runbook-order into main
Reviewed-on: #17 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
7f107f267c |
Send the reader between the two halves, in the order that works
The node runbook never told anyone to install an operating system. It created a VM in section 2, configured a firewall in section 3, and finished section 3 by testing that firewall from inside the VM - a VM with no OS on it, because installing one is the guest runbook's section 1 and this page never pointed there. Its header claimed to do this page through the end of section 3 before going to the guest, which cannot happen for that reason. The guest runbook said to do the node-side steps first and come back once the installer had booted, which is the same contradiction from the other side: the installer boots in its own section 1. There is now an order section near the top, an explicit handoff to install the OS at the end of section 2, and a handoff back to the guest at the end of section 3. The guest runbook defers to that order instead of stating its own, and keeps its note about the two snapshot moments, which was always accurate. It also names the trap that makes the order load-bearing rather than a preference. The Debian installer takes its address by DHCP whatever you pin it to afterwards, and the lease reply needs the inbound rule from section 3. Apply the group before installing, without the DHCP pair, and the installer has no network - which presents as a broken mirror or a dead link, not as a firewall switched on ten minutes earlier. Ticking the Firewall checkbox in section 2 is safe on its own. |
||
|
|
b6a6151512 |
Merge pull request 'Bring across what the sister project grew, and write down the threat model' (#15) from parity into main
Reviewed-on: #15 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
4cb86b4bdd |
Merge pull request 'Add the badge and the maturity disclaimer' (#16) from disclaimer-and-badge into main
Reviewed-on: #16 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
e7360de7ad |
Restate how the push block is enforced, not just that it exists
The entry linked out for the whole of it, which left a reader unable to judge the control without leaving the page. Whether not granting push access counts as a control at all depends on how it is enforced, and that is a fact rather than an argument: GitHub's ranges are allowlisted on 443 only, so a session clones and calls the API normally while port 22 is unreachable and an SSH push has no transport, whatever git-push-guard.sh catches. The argument stays behind the link and the mechanism comes back. Arguments drift when they are copied - this repository proved that within a day - and a port number does not. |
||
|
|
1faf85be5c |
Point at dejima's push reasoning, and pin the maturity disclaimer
The push-access entry carried its own copy of the argument, and one line of it had already drifted from dejima's: it said the eager agent's pull request is something the operator should have decided rather than discovered, which puts the cost on the operator and implies approving it would be fine. dejima's version puts the cost where it lands - on maintainers already triaging a flood of unsolicited AI-generated pull requests - and says the block exists so it cannot happen, not so you get to approve it first. That is the drift a second copy produces, found within a day of writing it. The entry now keeps only what this document needs, states that closing the CI chain follows from the principle rather than being the reason for it, and links to dejima's section for the argument so there is one copy to keep right. The maturity disclaimer is carried in the same three places dejima and the sister project use, with test/disclaimer.bats pinning it. Duplicated rather than linked, which is the opposite of the decision above and deliberate: a disclaimer behind a link is one the reader does not see, and its whole value is being on the page where the security argument is being made. |
||
|
|
2af7b15fe6 |
Give push-blocking its own reason rather than borrowing this one
The entry said not granting push access is a real answer and that it is why dejima blocks GitHub push by default. The second half was a wrong attribution: it read the default as a mitigation for the CI chain, which is this document's concern and not the reason the default exists. The reason is narrower and holds whether or not CI exists anywhere: a session not being used to push to a forge should not be able to push to it. The case that makes it concrete is an eager agent rather than a compromised one - it finds a real bug in an unrelated project and helpfully pushes a fix branch to somebody else's repository. Nothing about that is an attack and the change might even be good, and it is still something the operator should have decided rather than discovered. Closing the CI chain falls out of that principle rather than being the point of it. dejima's default is stated as the observable behaviour it is - GitHub push blocked at the network layer, port 22 unreachable without --allow-github-push, so there is no transport regardless of what any guard catches - rather than as evidence for a motive its own documentation does not claim. |
||
|
|
e7bffbcbbc |
State that reviewing main is a dependency, not an aside
The approval-gate entry explained that review cannot gate a CI run, and stopped there. Read on its own that invites the conclusion the tool is meant to replace reviewing what you merge, which is the opposite of dejima's stated position and of what this repository actually relies on. dejima-ci-allowlist reads .dejima/allowed-domains from origin/main, never from a branch or a local checkout, and treats what it finds there as already reviewed. That is the entire mechanism by which a branch cannot widen its own egress: the answer comes from somewhere a branch cannot write. The only way content reaches main is a pull request somebody merged, so the allowlist is exactly as trustworthy as the merges are, and merging a diff unread is not a lapse this tool compensates for - it is how a widened allowlist arrives, with the tool then serving it faithfully. A What this tool assumes section says that, in the same place the sister project states its own precondition, and links to dejima's responsible-use.md for the safe-against-responsible distinction. The approval-gate entry now says explicitly that it is about the run and nothing else: review still gates the merge, that is still the operator's job, and neither control substitutes for the other. |
||
|
|
9e3367b927 |
Give the real reason there is no approval gate
The entry said a human approving a diff they have not read is a control in name only, and that reviewers who do read it are the bottleneck the agent workflow removes. That is wrong twice: it is not the reason, and it reads as a judgement that code review is too weak to gate CI, which implies CI could be gated on review if you were willing to wait. It cannot. dejima's docs/ci.md works through every version of an approval gate and each fails mechanically rather than through inattention. Changing the trigger does not help because the workflow file lives in the tree the branch controls. Dispatching by hand does not, because a token that can open a pull request generally covers the dispatch endpoint. Leaving the runner stopped does not, because a job that cannot land on your runner lands on someone else's. Gitea's own approval requirement comes closest and applies to pull requests from forks, which is exactly what a session pushing a branch to the same repository is not. Review is downstream of the run by construction. The chain from a session writing code to a runner executing it needs no operator action at any step, so by the time there is a diff to read it has already run. The entry now also names the control that does work and that the old wording obscured: not granting the session push access at all. No push, no branch, no pull request, no run. It is why dejima blocks GitHub push by default and it is a legitimate answer for some projects. This repository exists so it is not the only one - granting push access should be a decision about how you want to work rather than a decision to accept unbounded code execution on your network. |
||
|
|
04430b9a95 |
Add the Developed in dejima badge
The same badge and asset as dejima carries, placed where dejima places it: at the end of the opening block, before the first section heading. The image test in this commit's sibling only knew about <img src="...png">. The badge is markdown image syntax and an svg, so neither half of it would have seen the badge - the assertion would have kept passing while pointing at nothing. It now walks every image the README references in either syntax and either format, and requires at least two so a README that lost one fails rather than passing with the other. |
||
|
|
9656b8c6ea |
Bring across what the sister project grew, and write down the threat model
A parity check against dejima-devbox-hardener found gaps in one direction only. This closes them. The runbook's verification step asked the reader to probe a named host on their own LAN, which means writing down something worth attacking on a machine the same page says to treat as already lost. It now probes the default gateway: already in the routing table so it reveals nothing, guaranteed alive where an arbitrary host is not, and inside the ranges the DROPs cover so it must be unreachable as a destination while traffic through it is untouched. The note that a blocked first probe means nothing when the other three also fail comes with it. test/firewall.bats is new. This repository's own runbook calls rule ordering the one thing here that is easy to get wrong and nothing checked it. Every exception is to an address inside a range the same file drops, the console appends new rules at the bottom, and a dead rule is silent. It also pins policy_out: ACCEPT, since inverting it is the change the template argues against at length, and the DHCP pair as both-or-neither. test/links.bats is new, and this repository has more documentation than the sister one. units.bats had a test that passes when systemd/ is empty or absent, because grep failing to open a file gives the same non-zero status the assertion wanted. In a file whose subject is a check that silently asserted nothing, that was worth fixing. docs/threat-model.md is new and written for this project. The adversary is a pull request. The tiers have different boundaries and the table says which. A privileged job reaches root by design, with no exploit, which is why that tier's containment is entirely external. Rollback is named as the distinctive control and as explicitly not a detection mechanism, since it destroys evidence along with the intruder. On escape it says the accurate thing: an escape grants more than the container had, because root is more than a job container has, and it does not leave the model, since root on the runner VM is already a row in the table reached on the privileged tier with no exploit at all. |
||
|
|
c61eebb5b5 |
Pin the maturity disclaimer on every entry point
dejima carries this and its wording covers all three repositories by name: these projects, any of these repositories. A reader landing here would not have seen it. Duplicated rather than linked, which is the opposite of what attribution and the security policy do here, and deliberately. A disclaimer behind a link is a disclaimer the reader does not see, and its whole value is being on the page where the security argument is being made. Everything else that gets linked is reference material someone goes looking for; this is the one thing that has to arrive uninvited. test/disclaimer.bats pins it. The line is the one most likely to be dropped without anyone deciding to drop it - by a rewrite of some section that happened to contain it - and every other test here would still pass on a repository that had lost it. |
||
|
|
3b95ff7a30 |
Add the Developed in dejima badge
The same asset and markup dejima carries, placed where dejima places it: at the end of the opening block, before the first section heading. |
||
|
|
a34ad1cf27 |
Merge pull request 'Give a DHCP runner the two rules its lease actually needs' (#14) from dhcp-rules into main
Reviewed-on: #14 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
41a98120c3 |
Give a DHCP runner the two rules its lease actually needs
The firewall template offered `dhcp: 1` as a commented option and told the reader the lease would survive without it, on the grounds that rebinding at T2 broadcasts to 255.255.255.255 and none of the RFC1918 DROPs match that. The outbound half of that is right. The inbound half was never checked: the reply comes from the server's own address to the VM's, which is not the tuple the request created, so connection tracking is unlikely to match it against policy_in: DROP and the reply is dropped. A lease that cannot complete is not renewed, whether the request left by unicast or by broadcast. Nothing fails at first boot, because the initial lease is obtained before the firewall matters to it. The VM stops renewing and drops off the network up to a full lease time later, which looks nothing like a firewall rule - the exact failure shape the old comment was written to warn about. Two ordinary rules fix it, and both go in through the console like every other rule here: OUT ACCEPT udp/67 and IN ACCEPT udp/68. They name a port and no host, so they add no reachable host to the LAN exceptions, which makes them narrower than the explicit rule to the DHCP server that the old comment weighed `dhcp: 1` against. `dhcp: 1` is dropped entirely: it is not reliably reachable from the web console, and this runbook is built through the console on purpose. Both rules or neither. Half the path renews nothing, and the guest runbook now says so where it tells the reader to leave the interface on DHCP, including for an address reserved on the router - reserved or not, the guest still speaks DHCP. |
||
|
|
d5f9bf9030 | Update README.md | ||
|
|
2b4c18f59e |
Merge pull request 'Pin the workflow to main, and put the wordmark on the README' (#12) from branding-and-triggers into main
Reviewed-on: #12 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
3af05cb943 |
Merge pull request 'State plainly that there is no support, and where to send a vulnerability' (#13) from security-policy into main
Reviewed-on: #13 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
9c49451529 |
Put the wordmark on the README, and point at the canonical terms
The suffix names the mode a file is for, not the colour of its ink, so `-darkmode` is the pale wordmark that belongs on a dark page. Naming by ink reads backwards at the moment someone is picking a file in a hurry, and it collides with the logo variants, which are already named by mode. The README points at one file unconditionally rather than switching on `prefers-color-scheme`, which reports the browser's setting and never the site's. A forge that renders dark whichever way the browser is set would then show dark ink on a dark page, silently, to a reader whose browser prefers light. Both files ship, so a mirror on a light-themed host changes one line where someone can see whether it worked. The artwork is not covered by the GPL. Both the README and assets/README.md point at dejima's ATTRIBUTION.md for who holds the marks and what may be done with them, by absolute link, so a fork resolving it does not find its own divergent copy of the terms. |