Scope a project's names to that project's jobs
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m30s
tests / compose-schema (pull_request_target) Successful in 48s
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.
This commit is contained in:
2026-09-01 12:03:19 -07:00
parent 7df4f6ef35
commit 8cb4fb0447
9 changed files with 640 additions and 56 deletions
+1 -1
View File
@@ -1 +1 @@
2
3
+210 -19
View File
@@ -26,6 +26,7 @@ usage() {
cat >&2 <<EOF
Usage: $PROG [render|check|show] [--config PATH]
$PROG repos [list|add|remove] [owner/repo...]
$PROG projects [list|assign|unassign] [owner/repo]
render fetch, validate and install the allowlist, reloading the resolver
only if it changed (the default; what the timer runs)
@@ -37,6 +38,9 @@ Usage: $PROG [render|check|show] [--config PATH]
repos list, add to, or remove from the repositories trusted to widen this
allowlist. Adding one used to mean re-running install.sh and
retyping every entry already there
projects give a repository a network and firewall set of its own, so its
names are reachable from its jobs and not from every project's.
Unassigned repositories share one set, as they always have
Reads $CONFIG unless --config or \$DEJIMA_CI_CONFIG says otherwise.
EOF
@@ -51,6 +55,11 @@ die() { echo "$PROG: $*" >&2; exit 2; }
say() { printf '%s\n' "$*" >&2; }
# CFG holds every known key, so an unset one is empty rather than absent.
# Names, not lines. Each name emits a server= and an ipset= line now, so
# counting lines and subtracting the header reported double and would have gone
# on reporting double quietly.
name_count() { grep -c '^server=/' "$1" 2>/dev/null || true; }
cfg_or_default() { [ -n "${CFG[$1]:-}" ] && printf '%s\n' "${CFG[$1]}" || printf '%s\n' "$2"; }
# --- config -----------------------------------------------------------------
@@ -66,6 +75,7 @@ declare -A CFG=(
[forge_api]=""
[token_file]="/etc/dejima-ci/forge-token"
[repos_file]="/etc/dejima-ci/repos"
[projects_file]="/etc/dejima-ci/projects"
[base_file]="/etc/dejima-ci/base-domains"
[upstream]=""
[out]="/etc/dnsmasq.d/ci-allow.conf"
@@ -294,32 +304,108 @@ collect() {
if [ -r "${CFG[base_file]}" ]; then
say "collecting:"
say " ${CFG[base_file]}: $(sed 's/#.*//' "${CFG[base_file]}" | grep -c . || true) name(s) always allowed"
sed 's/#.*//' "${CFG[base_file]}"
sed 's/#.*//' "${CFG[base_file]}" | sed "s|^|$(base_set)\t|"
fi
while read -r repo || [ -n "$repo" ]; do
repo="${repo%%#*}"; repo="${repo//[[:space:]]/}"
[ -n "$repo" ] || continue
fetch_repo "$repo" "$token"
# Tagged with the set the names belong in, because which project asked
# for a name is the whole of what makes it scopable. Collection used to
# return bare names and the caller had no way to tell them apart again.
# A repository that declares names and has no set of its own is
# recorded rather than rendered. Recorded, not died on here: a die
# inside a command substitution ends only the subshell, and the caller
# would print its own vaguer message on top of this one.
# Status propagated explicitly. Capturing into a variable put
# fetch_repo's exit code where nothing looked at it, so a forge that
# rejected the token became a repository that happened to declare
# nothing, and the render carried on and installed a shorter allowlist.
# It used to be the last command of a pipeline, where pipefail carried
# the failure out for free.
declared=$(fetch_repo "$repo" "$token") || return 1
[ -n "$declared" ] || continue
if [ -z "$(project_id "$repo")" ]; then
printf '%s\n' "$repo" >> "$UNSCOPED"
continue
fi
printf '%s\n' "$declared" | sed "s|^|$(project_set "$repo")\t|"
done < "${CFG[repos_file]}"
}
# --- which set a project's names go in ---------------------------------------
#
# A repository that declares names needs a set of its own. There is no shared
# fallback, because a shared set is a set every project can reach, which is the
# thing this exists to end - and it would be arrived at by doing nothing rather
# than by choosing it.
#
# So an unassigned declaration refuses the whole render. Nothing is installed on
# a failure, so the previous allowlist stays in force: the cost of arriving here
# is a loud error on the timer, not a host that has lost its allowlist.
#
# A repository declaring nothing needs no assignment, having no names to scope.
# It needs one the day it declares something, and the refusal is what tells
# whoever added that declaration.
project_id() { # project_id owner/repo -> id, or empty
[ -r "${CFG[projects_file]}" ] || return 0
sed 's/#.*//' "${CFG[projects_file]}" \
| awk -v r="$1" '$1 == r { print $2; exit }'
}
project_set() { # project_set owner/repo -> the ipset its names belong in
printf '%s-%s\n' "${CFG[ipset]}" "$(project_id "$1")"
}
# Base names are reachable from every project, so they get a set of their own
# rather than sharing the one unassigned projects use - otherwise an assigned
# project matching "base" would be matching every unassigned project too, and
# the scoping would be for nothing.
base_set() { printf '%s-base\n' "${CFG[ipset]}"; }
render() {
local raw domains d out
local raw pairs domains d out sets
# Collected first, filtered second, so that the two failures stay
# distinguishable. Filtering in the same pipeline makes "the forge did not
# answer" and "every declaration is empty" arrive as the same error under
# pipefail - grep exits 1 on no matches - and they send whoever reads it
# to entirely different places.
UNSCOPED=$(mktemp); export UNSCOPED
raw=$(collect) || die "collection failed; leaving the existing allowlist in place"
if [ -s "$UNSCOPED" ]; then
local list; list=$(sed 's/^/ /' "$UNSCOPED"); rm -f "$UNSCOPED"
die "these repositories declare names but have no set of their own:
$list
Their names are not rendered, and nothing was installed: the allowlist already
in place is untouched. A repository without a set of its own would have to share
one with every other project, which is what this stopped doing.
For each, assign it and then do what the assignment prints:
sudo $PROG projects assign owner/repo
A repository that should declare nothing at all belongs out of
${CFG[repos_file]} instead:
sudo $PROG repos remove owner/repo"
fi
rm -f "$UNSCOPED"
# COMMENTS STRIPPED HERE, over everything, rather than at each source. The
# first attempt at this stripped base-domains only, and the very next run
# died on a `#` from a repository's own declaration - the same bug one
# layer over, because a fetched body was cat'd raw too. There is one place
# every name arrives, and this is it. A domain cannot contain `#`, so
# nothing legitimate is lost.
domains=$(printf '%s\n' "$raw" | sed 's/#.*//' \
| tr -d '\r' | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
# Each line arrives as SET<tab>name. Blanks are squeezed out of the name
# only: the tab is the structure, and tr -d '[:blank:]' across the line
# would eat it and glue the two together.
pairs=$(printf '%s\n' "$raw" | sed 's/#.*//' | tr -d '\r' \
| awk -F'\t' 'NF == 2 { gsub(/[[:blank:]]/, "", $2); if ($2 != "") print $1 "\t" $2 }' \
| sort -u || true)
domains=$(printf '%s\n' "$pairs" | cut -f2 | sort -u)
# An empty render is refused rather than installed. This is the failure
# direction that matters: an allowlist of nothing looks identical to a
@@ -335,6 +421,22 @@ It came from ${CFG[base_file]} or from a .dejima/allowed-domains on one of the
repositories in ${CFG[repos_file]}. Nothing was installed; the existing
allowlist is untouched."
printf 'server=/%s/%s\n' "$d" "${CFG[upstream]}" >> "$out"
# THE SETS ARE WHERE THE SCOPING LIVES, not the server= line above.
#
# Every job can still resolve every allowlisted name: one dnsmasq has
# one view, and giving each project its own would mean an instance per
# project. That leak is names, and declarations are public in the
# repositories they come from.
#
# What a job can CONNECT to is the set its bridge matches, and a name
# only lands in the sets of the projects that asked for it. dnsmasq
# takes several per name, so one lookup fills each project's set that
# declared it and no others - which is what makes a shared resolver
# safe here.
sets=$(printf '%s\n' "$pairs" | awk -F'\t' -v n="$d" '$2 == n { print $1 }' \
| sort -u | paste -sd, -)
printf 'ipset=/%s/%s\n' "$d" "$sets" >> "$out"
done <<< "$domains"
# Only replace a working config with one that parses. dnsmasq exits on a
@@ -412,13 +514,77 @@ cmd_repos() { # cmd_repos [list|add|remove] [owner/repo...]
say " sudo $PROG render"
}
cmd_projects() { # cmd_projects [list|assign|unassign] [owner/repo]
local action=${1:-list}; shift || true
local f="${CFG[projects_file]}"
case "$action" in
list)
if [ -s "$f" ]; then
printf '%-40s %s\n' REPOSITORY SET
sed 's/#.*//' "$f" | awk -v s="${CFG[ipset]}" \
'NF >= 2 { printf "%-40s %s-%s\n", $1, s, $2 }'
else
say "no project is scoped yet."
fi
say ""
say "A repository that declares names needs one of these. Without it the"
say "render refuses, rather than putting its names somewhere every other"
say "project can reach. One that declares nothing needs no entry."
return 0 ;;
assign|unassign) ;;
*) die "unknown projects action: $action (list, assign, unassign)" ;;
esac
[ "$#" -ge 1 ] || die "$action needs an owner/repo"
local repo="$1"
valid_repo_entry "$repo" || die "not an owner/repo: $repo"
local current; current=$([ -r "$f" ] && sed 's/#.*//' "$f" | grep -v '^[[:space:]]*$' || true)
if [ "$action" = unassign ]; then
printf '%s\n' "$current" | awk -v r="$repo" '$1 != r' | grep -v '^$' > "$f.new" || true
chmod 0640 "$f.new"; mv "$f.new" "$f"
say "$repo now shares ${CFG[ipset]} again."
say "Re-run: sudo $PROG render, then sudo dejima-ci-firewall"
return 0
fi
local existing; existing=$(printf '%s\n' "$current" | awk -v r="$repo" '$1 == r { print $2; exit }')
[ -z "$existing" ] || { say "$repo already has id $existing (${CFG[ipset]}-$existing)"; return 0; }
# Never reused, even when an id is freed. An id names a set and a bridge
# that may still hold addresses and still carry rules, and handing a
# different project the same one would give it whatever the last occupant
# left behind.
local next; next=$(printf '%s\n' "$current" | awk 'NF >= 2 { print $2 }' | sort -n | tail -1)
next=$(( ${next:-0} + 1 ))
printf '%s\n' "$current" | grep -v '^$' > "$f.new" || true
printf '%s %s\n' "$repo" "$next" >> "$f.new"
chmod 0640 "$f.new"; mv "$f.new" "$f"
say "$repo -> ${CFG[ipset]}-$next, on bridge dejima-ci$next"
say ""
say "Nothing is scoped until all three of these have run:"
say " 1. create the network, once:"
say " docker network create --driver bridge \\"
say " -o com.docker.network.bridge.name=dejima-ci$next dejima-ci-$next"
say " 2. sudo $PROG render # puts its names in its own set"
say " 3. sudo dejima-ci-firewall # points that bridge at that set"
say ""
say "And a runner pool registered to $repo alone, on that network, or its"
say "jobs will keep landing on the shared one:"
say " sudo dejima-ci-runners --role confined --network dejima-ci-$next"
}
# --- commands ---------------------------------------------------------------
cmd=render
args=()
repos_args=()
while [ $# -gt 0 ]; do
case "$1" in
render|check|show|repos) cmd="$1" ;;
render|check|show|repos|projects) cmd="$1" ;;
--config)
[ $# -ge 2 ] && [ -n "${2:-}" ] || die "--config needs a path"
CONFIG="$2"; shift ;;
@@ -431,7 +597,7 @@ done
# the point `repos` is seen would have been simpler and wrong: it swallowed a
# following --config, so `repos add x --config /path` edited the file named by
# the default config rather than the one asked for, silently.
if [ "$cmd" = repos ]; then
if [ "$cmd" = repos ] || [ "$cmd" = projects ]; then
repos_args=("${args[@]}")
else
[ ${#args[@]} -eq 0 ] || usage
@@ -444,13 +610,17 @@ read_config "$CONFIG"
# an already-rendered file and the other edits a list of names. Requiring a
# working configuration to look at either would make them useless on the host
# whose configuration is the thing being diagnosed.
case "$cmd" in show|repos) ;; *) preflight ;; esac
case "$cmd" in show|repos|projects) ;; *) preflight ;; esac
case "$cmd" in
repos)
[ -n "${CFG[repos_file]}" ] || die "no repos_file in $CONFIG"
cmd_repos "${repos_args[@]:-}"
;;
projects)
[ -n "${CFG[projects_file]}" ] || die "no projects_file in $CONFIG"
cmd_projects "${repos_args[@]:-}"
;;
show)
[ -f "${CFG[out]}" ] || die "nothing installed at ${CFG[out]}"
cat "${CFG[out]}"
@@ -458,7 +628,7 @@ case "$cmd" in
check)
new=$(render); trap 'rm -f "$new"' EXIT
if [ -f "${CFG[out]}" ] && cmp -s "$new" "${CFG[out]}"; then
echo "up to date: $(( $(wc -l < "${CFG[out]}") - 1 )) names"
echo "up to date: $(name_count "${CFG[out]}") names"
elif [ -f "${CFG[out]}" ]; then
echo "would change:"
diff -u "${CFG[out]}" "$new" || true
@@ -467,7 +637,7 @@ case "$cmd" in
# :- substitutes an EMPTY variable, not a missing file, so this
# used to hand diff a path that is not there and print its
# complaint - on a first install, where nothing is wrong at all.
echo "would install ${CFG[out]}: $(( $(wc -l < "$new") - 1 )) names"
echo "would install ${CFG[out]}: $(name_count "$new") names"
diff -u /dev/null "$new" || true
exit 1
fi
@@ -512,19 +682,40 @@ case "$cmd" in
# again. Established connections survive either way - the firewall
# accepts ESTABLISHED,RELATED before it consults the set.
if [ -n "$removed" ]; then
set_name=$(cfg_or_default ipset ci-allow)
if command -v ipset >/dev/null 2>&1 && ipset list -n 2>/dev/null | grep -qx "$set_name"; then
ipset flush "$set_name" \
&& say "flushed $set_name: $(printf '%s\n' "$removed" | grep -c .) name(s) revoked" \
|| say "WARNING: could not flush $set_name - the revoked names' addresses stay reachable until it is emptied or the host reboots"
else
say "WARNING: $set_name does not exist here, so nothing was revoked."
# EVERY set, not the one named by the config key.
#
# That key is a prefix now: names live in a set per project plus a
# base set, and the bare name holds nothing. Flushing it alone would
# have emptied an unused set and reported a revocation that had not
# happened - the same failure this flush was added to fix, wearing
# the fix as a disguise.
#
# A set holds addresses with no memory of which name put them there,
# so there is no way to flush only the project a removed name
# belonged to. All of them, and they refill from the next lookup.
set_prefix=$(cfg_or_default ipset ci-allow)
if ! command -v ipset >/dev/null 2>&1; then
say "WARNING: ipset is not installed here, so nothing was revoked."
say " The names are gone from the resolver, but any address already"
say " in the firewall's set stays reachable. Run dejima-ci-firewall."
say " in the firewall's sets stays reachable."
else
flushed=0
while IFS= read -r one; do
case "$one" in "$set_prefix"|"$set_prefix"-*) ;; *) continue ;; esac
ipset flush "$one" && flushed=$(( flushed + 1 )) \
|| say "WARNING: could not flush $one"
done < <(ipset list -n 2>/dev/null || true)
if [ "$flushed" -gt 0 ]; then
say "flushed $flushed set(s): $(printf '%s\n' "$removed" | grep -c .) name(s) revoked"
else
say "WARNING: no $set_prefix set exists here, so nothing was revoked."
say " The names are gone from the resolver, but any address already"
say " in the firewall's sets stays reachable. Run dejima-ci-firewall."
fi
fi
fi
${CFG[reload]}
echo "allowlist updated: $(( $(wc -l < "${CFG[out]}") - 1 )) names"
echo "allowlist updated: $(name_count "${CFG[out]}") names"
;;
esac
+43 -2
View File
@@ -114,6 +114,37 @@ fi
# open one.
ipset create "$SET" hash:net -exist
# --- one set per project, plus one every project matches ---------------------
#
# The set holds addresses and nothing else - no memory of which name put one
# there - so a single shared set is a union at the address layer, not just at
# the name layer. A project that cannot resolve another's name could still
# connect to the address that name resolved to, and declarations are public.
# That is why scoping the resolver alone would have bought nothing.
#
# A project assigned an id in the projects file gets a set and a bridge of its
# own; its jobs match that set and the base set, and nothing else. A project
# with no id keeps using the shared set, which is what every project did before
# this existed - so this file changes no behaviour until an id is assigned.
PROJECTS=${PROJECTS:-$(cfg_get projects_file)}
: "${PROJECTS:=/etc/dejima-ci/projects}"
ipset create "${SET}-base" hash:net -exist
declare -A BRIDGE_SET=()
if [ -r "$PROJECTS" ]; then
while read -r repo id extra || [ -n "$repo" ]; do
repo="${repo%%#*}"
[ -n "$repo" ] && [ -n "${id:-}" ] || continue
# The bridge name is derived, not configured, because it has to agree
# with what created the network. 15 characters is the kernel's limit on
# an interface name, which is why the id is a number and not the
# repository's own name.
ipset create "${SET}-${id}" hash:net -exist
BRIDGE_SET["${BRIDGE_BASE:-dejima-ci}${id}"]="${SET}-${id}"
done < "$PROJECTS"
fi
# --- job egress: DOCKER-USER is the chain Docker jumps to first from FORWARD,
# --- and the only one whose contents Docker will not rewrite underneath you.
# Own a chain per table rather than appending into shared ones. This script
@@ -151,8 +182,18 @@ for b in "${BRIDGES[@]}"; do
# The allowlist proper. Port-restricted: a name being allowed is not a reason
# for every port on it to be reachable.
iptables -A DEJIMA-CI-FWD -i "$b" -p tcp -m set --match-set "$SET" dst \
-m multiport --dports 80,443 -j ACCEPT
#
# Two sets per bridge: the one this bridge's project declared, and the base
# every project shares. A bridge with no entry in the projects file falls
# back to the shared set, which is the pre-existing behaviour.
# Base only where a bridge has no project. There is no shared set of
# project names any more, so a bridge nothing claims reaches the names every
# project shares and nothing else. A job there fails rather than reaching
# too far, which is the direction to be wrong in.
for set_for_bridge in ${BRIDGE_SET[$b]:+"${BRIDGE_SET[$b]}"} "${SET}-base"; do
iptables -A DEJIMA-CI-FWD -i "$b" -p tcp -m set --match-set "$set_for_bridge" dst \
-m multiport --dports 80,443 -j ACCEPT
done
# Uncomment for git-over-ssh to a forge. Prefer https where the forge offers
# it, so that this stays closed.
+15 -2
View File
@@ -39,6 +39,7 @@ fi
ROLE=""
DIR=""
OPT_NETWORK=""
FORGE=""
OPT_NAME=""
OPT_LABEL=""
@@ -61,6 +62,9 @@ Usage: sudo $PROG --role confined|privileged [--dir PATH] [--forge URL]
network namespace
--dir PATH where the compose project goes (default ~/runners of the
user who invoked sudo)
--network NAME the job network, overriding the one in the config. For a
pool scoped to one project, whose network and firewall set
are its own - see dejima-ci-allowlist projects
--forge URL the forge, e.g. https://gitea.example.com. Read from
$CONFIG when install.sh has run; required on a privileged
host, where it has not
@@ -91,6 +95,7 @@ while [ $# -gt 0 ]; do
--role) ROLE="${2:?}"; shift ;;
--dir) DIR="${2:?}"; shift ;;
--forge) FORGE="${2:?}"; shift ;;
--network) OPT_NETWORK="${2:?}"; shift ;;
--name) OPT_NAME="${2:?}"; shift ;;
--label) OPT_LABEL="${2:?}"; shift ;;
--count) OPT_COUNT="${2:?}"; shift ;;
@@ -175,7 +180,11 @@ say "=============================================================="
say
# --- values install.sh already knows ----------------------------------------
network="$(cfg_get network)"
# --network overrides the one config records, for a pool scoped to a single
# project. That project has a network and a firewall set of its own, and a pool
# left on the shared network would put its jobs back among everybody else's
# names however carefully the sets were assigned.
network="${OPT_NETWORK:-$(cfg_get network)}"
gateway="$(cfg_get gateway)"
forge_api="$(cfg_get forge_api)"
instance="${forge_api%/api/v1}"
@@ -190,7 +199,11 @@ The confined tier's runners attach jobs to the network install.sh creates; there
is nothing to point them at until it has run."
[ -n "$gateway" ] || die "no gateway in $CONFIG - re-run install.sh"
say "From $CONFIG, not asked again:"
say " job network $network (container.network)"
if [ -n "${OPT_NETWORK:-}" ]; then
say " job network $network (container.network, from --network)"
else
say " job network $network (container.network)"
fi
say " resolver $gateway (--dns, the allowlisting resolver)"
say
else
+27 -29
View File
@@ -170,15 +170,15 @@ there is one.
- **Exfiltration, and command-and-control.** Egress to the internet is unbounded
from the VM, deliberately. The confined tier's allowlist bounds it for a job,
and an escape to root on the VM is precisely what leaves that behind.
- **One trusted project widening another's job network.** The allowlist is
rendered as the union of every declaration in `repos_file`, so a name added to
any of them is reachable from every project's jobs. It is bounded by who can
do it: the declaration is read from `main` of a repository you listed, and a
repository on that list is already trusted to run code on this host. So the
exposure is between trusted-but-separate projects, and to a dependency that
reaches one project's `main`, rather than to anyone who can open a pull
request. [Per-project allowlists](#rejected-with-reasons) says what isolating
them would take.
- **Resolution, though not reach.** A job can resolve every allowlisted name;
what it cannot do is connect to one outside its own project's set. One dnsmasq
has one view, and a per-project view would mean a resolver per project. The
leak is names, which are public in the repositories that declared them.
- **A project whose runners are on the wrong network.** The sets are only as
good as which network a job lands on, and a runner takes whatever jobs it is
offered. A pool registered to an organisation rather than a repository puts
those jobs on a network that is not that project's, which is why the upgrade
note treats moving the pools as part of the change rather than as a follow-up.
- **The forge and the resolver.** Both are reachable from this VM on the ports
listed, by anything on it. "No LAN access" is not literally true.
- **The forge credential the runner registers with.** It is on the box, and
@@ -224,30 +224,28 @@ What to check, rather than re-argue:
## Rejected, with reasons
### Per-project allowlists, for now
### A per-project resolver, rather than a per-project set
Every job on the confined tier resolves against one list and is filtered by one
ipset, so every project's declaration is reachable from every project's jobs.
Isolating them is possible and is not yet done, which is a scheduling answer
rather than a design one.
Scoping the resolver is the obvious way to keep one project's names away from
another's jobs, and it would have bought almost nothing.
What it takes, in the order the pieces constrain each other:
The set holds addresses with no memory of which name put them there, and every
bridge's rule matched the same one, so the union was at the address layer and
not only at the name layer. A project that could not resolve a name could still
connect to the address another project's lookup had already put in the set, and
declarations are public in the repositories they come from.
- **A set per project.** The current set holds addresses with no memory of which
name put them there, and every bridge's rule matches the same one. So a
per-project *resolver* alone buys almost nothing: a project that cannot
resolve a name can still connect to the address another project's lookup put
in the set, and declarations are public. dnsmasq will populate several sets
per name (`ipset=/name/set-a,set-b`), so this part needs one resolver, not
one per project.
- **A bridge per project**, because the only thing the firewall can tell about a
job is which network it came in on. That is the handle, and there is no other.
- **A runner pool per project**, registered to that repository, so a pool only
ever receives jobs it has the network for.
So the set is what got scoped. dnsmasq will place a name's addresses in several
sets at once (`ipset=/name/set-a,set-b`), which means one resolver can fill each
declaring project's set and no others, and the shared resolver stops mattering.
The cost is that every job can still resolve every name, which is a names leak
rather than a reach one.
The cost is a network, a set and a runner registration per isolated project,
which is why the likely shape is one pool per repository that actually declares
domains and one shared pool for those that declare none.
The three pieces have to move together: a set per project, a bridge per project
because the incoming network is the only thing the firewall can tell about a
job, and a runner pool registered to that repository so a pool only receives
jobs it has the network for. A pool left on the shared network undoes the other
two however carefully they are set up.
Recorded so they are not re-proposed without new information.
+177
View File
@@ -50,11 +50,16 @@ EOF
echo token > "$T/token"
echo "owner/repo" > "$T/repos"
: > "$T/base"
# A correctly configured host: a repository that declares names has a set of
# its own. Without one the render refuses, which is its own test rather than
# the state every other test starts from.
echo "owner/repo 1" > "$T/projects"
cat > "$T/config" <<EOF
forge_api = https://gitea.example.com/api/v1
token_file = $T/token
repos_file = $T/repos
projects_file = $T/projects
base_file = $T/base
upstream = 198.51.100.53
out = $T/out/ci-allow.conf
@@ -546,6 +551,7 @@ stub_ipset() {
#!/usr/bin/env bash
case "$1" in
list) printf '%s\n' "${STUB_IPSET_NAMES:-ci-allow}" ;;
create) ;;
flush) printf '%s\n' "$2" >> "$STUB_DIR/flushed" ;;
esac
exit 0
@@ -553,6 +559,37 @@ IPSETEOF
chmod +x "$T/stub/ipset"
}
@test "a revocation flushes the project sets, not just the bare name" {
# The config key is a prefix now: names live in a set per project plus a
# base set, and the bare name holds nothing. Flushing it alone would empty
# an unused set and report a revocation that had not happened.
stub_ipset
STUB_IPSET_NAMES=$'ci-allow\nci-allow-base\nci-allow-1'
export STUB_IPSET_NAMES
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx ci-allow-1 "$STUB_DIR/flushed"
grep -qx ci-allow-base "$STUB_DIR/flushed"
}
@test "a set belonging to something else is not flushed" {
# The prefix is the boundary. Another tool's set on the same host is not
# this program's to empty.
stub_ipset
STUB_IPSET_NAMES=$'ci-allow-1\nsomeone-elses-set'
export STUB_IPSET_NAMES
mkdir -p "$STUB_DIR/owner"
printf 'keep.example.com\ndrop.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
printf 'keep.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
! grep -qx someone-elses-set "$STUB_DIR/flushed"
}
@test "removing a name flushes the set, or it stays reachable by address" {
stub_ipset
mkdir -p "$STUB_DIR/owner"
@@ -633,3 +670,143 @@ IPSETEOF
[ "$(head -1 "$STUB_DIR/order")" = flush ]
[ "$(tail -1 "$STUB_DIR/order")" = reload ]
}
# --- scoping a project to its own set ----------------------------------------
#
# One list and one set meant every project's declaration was reachable from
# every project's jobs. The set is the part that matters: it holds addresses
# with no memory of which name put them there, so scoping the resolver alone
# would leave a project able to connect by address to anything another project
# resolved.
@test "a declaration with no set of its own refuses the render" {
# There is no shared fallback. A set every project can reach is the thing
# this exists to end, and falling back to one would be arriving there by
# doing nothing rather than by choosing it.
: > "$T/projects"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 2 ]
[[ "$output" == *"owner/repo"* ]]
[[ "$output" == *"projects assign"* ]]
}
@test "a refused render leaves the allowlist that was already there" {
# The cost of an unfinished migration is a loud error on the timer, not a
# host that has lost its allowlist.
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
before=$(cat "$T/out/ci-allow.conf")
: > "$T/projects"
run_tool render
[ "$status" -eq 2 ]
[ "$(cat "$T/out/ci-allow.conf")" = "$before" ]
}
@test "a repository that declares nothing needs no set" {
# It has no names to scope. Requiring an assignment would be friction with
# nothing behind it.
: > "$T/projects"
mkdir -p "$STUB_DIR/owner"; echo 404 > "$STUB_DIR/owner/repo.status"
echo base.example.com > "$T/base"
run_tool render
[ "$status" -eq 0 ]
}
@test "base names go in a set of their own, not the shared one" {
# Otherwise an assigned project matching base would be matching every
# unassigned project too, and the scoping would be for nothing.
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
echo base.example.com > "$T/base"
run_tool render
grep -qx 'ipset=/base.example.com/ci-allow-base' "$T/out/ci-allow.conf"
}
@test "an assigned project's names go in its own set" {
printf 'owner/repo 1\n' > "$T/projects"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
[ "$status" -eq 0 ]
grep -qx 'ipset=/one.example.com/ci-allow-1' "$T/out/ci-allow.conf"
! grep -qx 'ipset=/one.example.com/ci-allow' "$T/out/ci-allow.conf"
}
@test "a name two projects declare lands in both their sets" {
# dnsmasq takes several sets per name, which is what lets one resolver
# serve every project without the answer widening anyone else's reach.
printf 'owner/repo 1\nother/repo 2\n' > "$T/projects"
printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo shared.example.com > "$STUB_DIR/owner/repo.body"
echo shared.example.com > "$STUB_DIR/other/repo.body"
run_tool render
grep -qx 'ipset=/shared.example.com/ci-allow-1,ci-allow-2' "$T/out/ci-allow.conf"
}
@test "a name one project declares does not reach the other's set" {
# The whole point.
printf 'owner/repo 1\nother/repo 2\n' > "$T/projects"
printf 'owner/repo\nother/repo\n' > "$T/repos"
mkdir -p "$STUB_DIR/owner" "$STUB_DIR/other"
echo mine.example.com > "$STUB_DIR/owner/repo.body"
echo theirs.example.com > "$STUB_DIR/other/repo.body"
run_tool render
grep -qx 'ipset=/mine.example.com/ci-allow-1' "$T/out/ci-allow.conf"
grep -qx 'ipset=/theirs.example.com/ci-allow-2' "$T/out/ci-allow.conf"
}
@test "every name still resolves, since the scoping is the set and not the view" {
# One dnsmasq has one view. The leak is names, which are public in the
# repositories they come from; what a job can reach is the set.
printf 'owner/repo 1\n' > "$T/projects"
mkdir -p "$STUB_DIR/owner"; echo one.example.com > "$STUB_DIR/owner/repo.body"
run_tool render
grep -q '^server=/one.example.com/' "$T/out/ci-allow.conf"
}
@test "the name count is names, not lines" {
# Each name emits a server= and an ipset= line now. Counting lines and
# subtracting the header reported double.
mkdir -p "$STUB_DIR/owner"; printf 'a.example.com\nb.example.com\n' > "$STUB_DIR/owner/repo.body"
run_tool render
[[ "$output" == *"2 names"* ]]
}
@test "projects assign gives out an id and never reuses one" {
# An id names a set and a bridge that may still hold addresses and still
# carry rules. Handing a freed one to a different project would give it
# whatever the last occupant left behind.
: > "$T/projects" # the fixture assigns owner/repo id 1
run_tool projects assign acme/one
run_tool projects assign acme/two
run_tool projects unassign acme/one
run_tool projects assign acme/three
[ "$(awk '$1 == "acme/three" { print $2 }' "$T/projects")" = 3 ]
}
@test "projects assign is idempotent" {
run_tool projects assign acme/one
run_tool projects assign acme/one
[ "$(grep -c 'acme/one' "$T/projects")" -eq 1 ]
[[ "$output" == *"already has id"* ]]
}
@test "projects assign says the three things that have to happen next" {
# Writing the file scopes nothing on its own: the network has to exist, the
# allowlist has to be re-rendered, and the firewall has to point the bridge
# at the set.
run_tool projects assign acme/one
[[ "$output" == *"docker network create"* ]]
[[ "$output" == *"render"* ]]
[[ "$output" == *"dejima-ci-firewall"* ]]
[[ "$output" == *"--network"* ]]
}
@test "projects list says what an entry is for, and what its absence costs" {
# The absence of an entry is the interesting state, and a list that only
# shows entries never mentions it.
run_tool projects list
[[ "$output" == *"render refuses"* ]]
[[ "$output" == *"declares nothing needs no entry"* ]]
}
+17
View File
@@ -308,3 +308,20 @@ privileged() {
parse "$OUT/docker-compose.yml"
parse "$OUT/config.yaml"
}
@test "--network overrides the job network the config records" {
# A pool scoped to one project needs that project's network. Left on the
# shared one, its jobs land among every other project's names however
# carefully the sets were assigned.
grep -q '\-\-network) OPT_NETWORK=' "$REPO/bin/dejima-ci-runners"
grep -q 'OPT_NETWORK:-\$(cfg_get network)' "$REPO/bin/dejima-ci-runners"
}
@test "the flag dejima-ci-allowlist tells operators to run exists" {
# `projects assign` prints a dejima-ci-runners command. A note telling
# somebody to run a flag that was never added is the shape of mistake this
# repository keeps making, so the two are held together here.
flag=$(grep -oE '\-\-network' "$REPO/bin/dejima-ci-allowlist" | head -1)
[ "$flag" = "--network" ]
grep -q '\-\-network)' "$REPO/bin/dejima-ci-runners"
}
+10 -3
View File
@@ -71,17 +71,24 @@ stub_check() {
}
@test "an up-to-date confined runner reports nothing outstanding" {
write_state tier=confined guest_version=2 node_version=2 node_asserted=2026-08-25 node_verified=yes
# Read from VERSION, not written in. Hard-coding the current version makes
# this fail on the next bump for a reason that has nothing to do with what
# it checks.
v=$(cat "$REPO/VERSION")
write_state tier=confined guest_version=$v node_version=$v node_asserted=2026-08-25 node_verified=yes
run_tool status --no-verify
[ "$status" -eq 0 ]
[[ "$output" == *"Nothing."* ]]
}
@test "v2 needs node work only, so it must not ask for a re-snapshot" {
@test "a node-only upgrade must not ask for a re-snapshot" {
# The cheap kind of upgrade. Everything it changes lives on the hypervisor,
# where no rollback reaches it. Reporting otherwise would train an operator
# to ignore the sequence when it does matter.
write_state tier=confined guest_version=2 node_version=1 node_asserted=2026-06-01
#
# The guest is held at the current version so the only thing outstanding is
# v2's node work, which is what this is about.
write_state tier=confined guest_version=$(cat "$REPO/VERSION") node_version=1 node_asserted=2026-06-01
run_tool status --no-verify
[[ "$output" == *"console work outstanding"* ]]
[[ "$output" != *"not one step"* ]]
+140
View File
@@ -0,0 +1,140 @@
---
version: 3
title: A project can be given a firewall set of its own
layers: guest
tiers: confined
snapshot: required
---
# v3: every project gets a firewall set of its own
Confined tier only. **This one has a migration**: after it, a repository that
declares names needs a set of its own, and the render refuses until each of them
has one. Do the whole sequence in one sitting.
## Why
Every job on this host 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 the part that matters. It holds addresses with no memory of which
name put them there, so giving each project its own resolver would have changed
almost nothing: a project that could not resolve another's name could still
connect to the address that name resolved to, and declarations are public in the
repositories they come from.
## What is new
`dejima-ci-allowlist` writes an `ipset=` line per name, listing the sets of the
projects that declared it. dnsmasq accepts several per name, so one resolver
fills each declaring project's set and no others.
`dejima-ci-firewall` matches each bridge against its own project's set and a
base set every project shares.
There is no shared fallback. A repository that declares names and has no set is
refused rather than put somewhere every other project can reach: a set everyone
shares is what this exists to end, and falling back to one would mean arriving
there by doing nothing rather than by choosing it.
A repository that declares nothing needs no assignment, having no names to
scope. It needs one the day it declares something, and the refusal is what tells
whoever added that declaration.
## 1. Before you install: what will need a set
```sh
sudo dejima-ci-allowlist render
```
Read the collection lines. Every repository reporting names declared is one that
needs a set; the ones reporting no declaration do not. Write that list down.
## 2. Install
```sh
cd ~/dejima-ci-hardener
git pull
sudo ./install.sh
```
The timer's next render will refuse, naming the repositories that need a set.
That is expected here, and it is safe: nothing is installed on a failure, so the
allowlist already in force stays in force while you work through the rest.
**Re-snapshot before this tier's next scheduled rollback.** These are guest
changes on a runner rebuilt from a snapshot, so one taken before this restores a
host without them: the tools go back, the projects file goes back, and the
render starts refusing again with nothing to explain it.
## 3. Assign each declaring repository
One at a time:
```sh
sudo dejima-ci-allowlist projects assign owner/repo
```
It prints the id and the commands that make it real, including the network. Do
those before moving on: an assignment with no network is a bridge the firewall
has no rules for.
## 4. Render and apply
Once every declaring repository has a set:
```sh
sudo dejima-ci-allowlist render # should stop refusing
sudo dejima-ci-firewall
```
If it still refuses, it names what is missing. Nothing has been installed in the
meantime.
## 5. Move each project's runners onto its own network
**This is the step that decides whether any of it took effect.** A runner takes
whatever jobs it is offered, so a pool on the shared network puts that project's
jobs back among everyone else's names however carefully the sets were assigned.
Register a pool to that repository alone, from the forge's
**Settings, Actions, Runners** page on the repository rather than the
organisation, and point it at the new network:
```sh
sudo dejima-ci-runners --role confined --network dejima-ci-1 \
--dir ~/runners/owner-repo
```
Then take that repository out of any organisation-wide pool's reach, or its jobs
will keep landing wherever they did before.
Until a project's pool has moved, its jobs run on the shared network and reach
the base names only, because the shared bridge now matches the base set and
nothing else. That is a job that fails rather than a job that reaches too far,
which is the direction to be wrong in, but it is why steps 4 and 5 belong in one
sitting.
## Checking it worked
```sh
sudo dejima-ci-allowlist projects list
sudo ipset list ci-allow-1 | head
sudo iptables -S DEJIMA-CI-FWD | grep dejima-ci1
```
The rule for that bridge should name `ci-allow-1` and `ci-allow-base`, and
nothing else. If it names `ci-allow`, the bridge has no entry in the projects
file or the firewall has not been re-run.
## What this does not do
**Every job can still resolve every allowlisted name.** One dnsmasq has one
view, and giving each project its own would mean a resolver instance per
project. What a job can reach is the set its bridge matches, so resolving a name
it may not connect to gets it an address the firewall drops. The leak is names,
which are already public in the repositories that declared them.
It does not scope the privileged tier, which has no allowlist and no job network
to scope: its jobs are in the host's network namespace by design.