Commit Graph
140 Commits
Author SHA1 Message Date
branderson 045f8d817f Write down what this migration taught, where the next agent will read it
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / tests (pull_request_target) Successful in 1m33s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
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.
2026-09-01 23:23:50 -07:00
branderson 931938d9b9 Recreate the runners when a file they mount was rewritten
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m41s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
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.
2026-09-01 23:10:45 -07:00
branderson 062fe95db4 Read the project before asking the questions it answers
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / tests (pull_request_target) Successful in 1m39s
tests / compose-schema (pull_request_target) Successful in 47s
`--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.
2026-09-01 22:42:38 -07:00
branderson 09b6ea4dce Let --pool say which tier, since only one tier has pools
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m36s
tests / compose-schema (pull_request_target) Successful in 48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
`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.
2026-09-01 22:33:32 -07:00
branderson 8cfa423e20 Name the pool, rather than a network and a path that must agree
tests / runner-env (confined) (pull_request_target) Failing after 24s
tests / tests (pull_request_target) Successful in 1m35s
tests / compose-schema (pull_request_target) Successful in 48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
`--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.
2026-09-01 22:14:08 -07:00
branderson ed288c7dcd Stop the migration from costing an operator their runner config
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m36s
tests / compose-schema (pull_request_target) Successful in 48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
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.
2026-09-01 18:48:50 -07:00
branderson e4e0816f6f Give each runner pool the allowlist it could actually run
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / tests (pull_request_target) Successful in 1m31s
tests / compose-schema (pull_request_target) Successful in 49s
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.
2026-09-01 13:34:15 -07:00
branderson 8cb4fb0447 Scope a project's names to that project's jobs
tests / tests (pull_request_target) Successful in 1m30s
tests / compose-schema (pull_request_target) Successful in 48s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
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.
2026-09-01 12:03:19 -07:00
branderson 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>
2026-09-01 02:51:16 -07:00
branderson f8366eccb5 Make removing a name actually revoke it
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m24s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / compose-schema (pull_request_target) Successful in 46s
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.
2026-09-01 02:43:31 -07:00
branderson 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>
2026-08-31 23:31:14 -07:00
branderson c539c82796 Strip comments where every name arrives, not at one source
tests / runner-env (confined) (pull_request_target) Successful in 1m1s
tests / tests (pull_request_target) Successful in 1m20s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / compose-schema (pull_request_target) Successful in 46s
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.
2026-08-31 23:21:53 -07:00
branderson 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>
2026-08-31 23:04:29 -07:00
branderson 11b841500e Make trusting a repository something you can do in one command
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / tests (pull_request_target) Successful in 1m19s
tests / compose-schema (pull_request_target) Successful in 45s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
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.
2026-08-31 22:58:28 -07:00
branderson 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>
2026-08-27 21:00:16 -07:00
branderson 5d61050f87 Do not assume /usr/local/sbin is on the PATH sudo hands you
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / tests (pull_request_target) Successful in 1m29s
tests / compose-schema (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
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.
2026-08-27 20:47:52 -07:00
branderson f6316e6c0e Give the privileged tier a working v1 -> v2 path, and link the upgrade docs
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m28s
tests / compose-schema (pull_request_target) Successful in 50s
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.
2026-08-27 20:27:30 -07:00
branderson 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>
2026-08-27 20:13:41 -07:00
branderson e5e5a307b3 Install the upgrade tool on the privileged tier, which never got it
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m23s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
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.
2026-08-27 19:57:08 -07:00
branderson 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>
2026-08-27 17:44:35 -07:00
branderson d879b6d5b3 Fix a tool that could not find itself, and a note that told you not to
tests / runner-env (confined) (pull_request_target) Successful in 1m3s
tests / tests (pull_request_target) Successful in 1m33s
tests / compose-schema (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
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.
2026-08-27 17:32:48 -07:00
branderson 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>
2026-08-27 17:14:03 -07:00
branderson 8748ee550b Check that every fenced sh block in the docs parses
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / tests (pull_request_target) Successful in 1m23s
tests / compose-schema (pull_request_target) Successful in 49s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
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.
2026-08-27 03:15:57 -07:00
branderson 67f1028975 Give the runbook a map, and put the reasoning after the doing
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m21s
tests / compose-schema (pull_request_target) Successful in 48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
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.
2026-08-27 03:06:33 -07:00
branderson fe1953b456 Front-load the prose, and stop restating the same arguments
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / tests (pull_request_target) Successful in 1m21s
tests / compose-schema (pull_request_target) Successful in 46s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
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.
2026-08-27 02:53:25 -07:00
branderson 49562a1c47 Fix commands the upgrade notes gave that cannot run on the host they target
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / tests (pull_request_target) Successful in 1m18s
tests / compose-schema (pull_request_target) Successful in 48s
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.
2026-08-26 02:54:18 -07:00
branderson 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).
2026-08-26 02:54:18 -07:00
branderson 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.
2026-08-26 02:54:10 -07:00
branderson 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>
2026-08-25 16:55:17 -07:00
branderson afbe827e0f Describe the boundary as it is, not as it changed
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m12s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
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.
2026-08-25 16:38:49 -07:00
branderson ec521afd84 Bound the LAN over IPv6, and over the range a tailnet uses
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m11s
tests / compose-schema (pull_request_target) Successful in 46s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
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).
2026-08-25 14:50:52 -07:00
branderson 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>
2026-08-11 20:39:46 -07:00
branderson 7f107f267c Send the reader between the two halves, in the order that works
tests / runner-env (confined) (pull_request_target) Successful in 32s
tests / tests (pull_request_target) Successful in 1m4s
tests / compose-schema (pull_request_target) Successful in 44s
tests / runner-env (privileged) (pull_request_target) Successful in 31s
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.
2026-08-11 19:12:41 -07:00
branderson 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>
2026-08-10 22:36:40 -07:00
branderson 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>
2026-08-10 21:04:19 -07:00
branderson e7360de7ad Restate how the push block is enforced, not just that it exists
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 2m13s
tests / compose-schema (pull_request_target) Successful in 1m54s
tests / runner-env (privileged) (pull_request_target) Successful in 1m35s
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.
2026-08-10 20:47:26 -07:00
branderson 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.
2026-08-10 20:47:26 -07:00
branderson 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.
2026-08-10 20:47:04 -07:00
branderson 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.
2026-08-10 20:47:04 -07:00
branderson 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.
2026-08-10 20:47:04 -07:00
branderson 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.
2026-08-10 20:47:04 -07:00
branderson 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.
2026-08-10 20:47:04 -07:00
branderson c61eebb5b5 Pin the maturity disclaimer on every entry point
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 2m8s
tests / compose-schema (pull_request_target) Successful in 1m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
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.
2026-08-10 20:46:08 -07:00
branderson 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.
2026-08-10 20:46:08 -07:00
branderson 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>
2026-08-09 22:06:58 -07:00
branderson 41a98120c3 Give a DHCP runner the two rules its lease actually needs
tests / runner-env (confined) (pull_request_target) Successful in 1m37s
tests / tests (pull_request_target) Successful in 2m9s
tests / compose-schema (pull_request_target) Successful in 1m46s
tests / runner-env (privileged) (pull_request_target) Successful in 1m38s
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.
2026-08-09 22:05:27 -07:00
branderson d5f9bf9030 Update README.md 2026-08-09 12:18:15 -07:00
branderson 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>
2026-08-09 03:13:56 -07:00
branderson 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>
2026-08-09 03:13:39 -07:00
branderson 9c49451529 Put the wordmark on the README, and point at the canonical terms
tests / compose-schema (pull_request_target) Successful in 1m34s
tests / runner-env (privileged) (pull_request_target) Successful in 1m23s
tests / runner-env (confined) (pull_request_target) Successful in 1m25s
tests / tests (pull_request_target) Successful in 1m50s
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.
2026-08-08 23:26:01 -07:00