Scope a project's names to that project's jobs
Every job on the confined tier resolved against one list and was filtered by one ipset, so a name any trusted repository declared was reachable from every repository's jobs. One project's declaration widened all of them. THE SET IS WHAT HAD TO BE SCOPED, not the resolver. It holds addresses with no memory of which name put them there, and every bridge matched the same one, so the union was at the address layer: a project that could not resolve another's name could still connect to the address that name resolved to, and declarations are public. A per-project resolver would have looked like progress and changed almost nothing. dnsmasq places a name's addresses in several sets at once, so one resolver fills each declaring project's set and no others. The allowlist writes an ipset= line per name listing the sets that asked for it; the firewall matches each bridge against its own project's set and a base set every project shares. NO SHARED FALLBACK, and no opt-in. A repository that declares names and has no set of its own refuses the render, naming what is missing. A set every project can reach is the thing this exists to end, and falling back to one would mean arriving there by doing nothing rather than by choosing it. Updating is the opt-in; not finishing the update is not a quieter way to keep the old behaviour. Refusing installs nothing, so the allowlist already in force stays in force while the migration is worked through - the cost is a loud error on the timer, not a host that has lost its allowlist. A repository that declares nothing needs no assignment, having no names to scope, and meets the refusal on the day it declares something. Ids are never reused. One names a set and a bridge that may still hold addresses and still carry rules, and handing a freed id to another project would give it whatever the last occupant left behind. The upgrade note is a migration in five steps, ending with moving each project's runners onto its own network - the step that decides whether any of it takes effect, since a runner takes whatever jobs it is offered. dejima-ci-runners gained --network for that, rather than the note naming a flag that does not exist. Three things caught while doing it. The name count was lines minus the header, which doubled the moment a name emitted two lines. A guest-layer note must require a snapshot, or this tier's scheduled rollback quietly undoes the install. And the revocation flush from the previous change flushed the set named by the config key, which is a prefix now and holds nothing - it would have emptied an unused set and reported a revocation that had not happened, which is the failure that flush was added to fix. Capturing fetch_repo into a variable also dropped its exit status, so a forge that rejected the token became a repository that declared nothing. It was the last command of a pipeline before, where pipefail carried the failure out.
This commit is contained in:
+210
-19
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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"* ]]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"* ]]
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user