Merge pull request 'Name the tiers after what a job gets, and put the reader first' (#10) from docs-restructure into main
Reviewed-on: #10 Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Domains this repository's CI jobs need, read from this file on `main` by
|
||||
# dejima-ci-allowlist and rendered into the confined tier's resolver.
|
||||
#
|
||||
# This repository is served by a runner it configured, which makes it its own
|
||||
# first consumer: the runner-env (confined) job installs bats from Debian and
|
||||
# then asserts that an off-list name does NOT resolve, so this file is what
|
||||
# separates the two outcomes.
|
||||
#
|
||||
# deb.debian.org is also in install.sh's suggested base-domains, so on most
|
||||
# hosts this is a restatement rather than a widening. Stated anyway: a job that
|
||||
# depends on the operator having accepted a suggestion is a job that breaks on
|
||||
# a host configured slightly differently, and the whole point of this format is
|
||||
# that a repository declares what it needs rather than inheriting it.
|
||||
#
|
||||
# One name per line. Adding one here widens what every job on that runner can
|
||||
# reach, so keep it to what the workflow actually fetches.
|
||||
deb.debian.org
|
||||
@@ -0,0 +1,412 @@
|
||||
# Runs the bats suite on every pull request and on every push to one.
|
||||
# `pull_request` fires on opened/reopened/synchronize, and synchronize is the
|
||||
# push-to-an-open-PR event, so both are covered without listing types.
|
||||
#
|
||||
# Split by what each half NEEDS. `bats test/` runs anywhere a shell does - it
|
||||
# stubs curl, dnsmasq and docker, on purpose, because the properties worth
|
||||
# checking are in what these programs decide rather than in what the system
|
||||
# does afterwards. The compose job needs a real Docker daemon and a real
|
||||
# compose plugin, because the thing it checks is precisely what a stub cannot:
|
||||
# whether what dejima-ci-runners emits is a compose file Docker will accept.
|
||||
#
|
||||
# Both jobs name the same runner anyway. Only one label is registered for this
|
||||
# repository, and a job that "runs anywhere" is a statement about what it needs
|
||||
# rather than about what it should be handed.
|
||||
name: tests
|
||||
|
||||
# Two triggers, deliberately, and only for now.
|
||||
#
|
||||
# `pull_request_target` is the one that matters: under `on: pull_request` the
|
||||
# workflow is read from the HEAD of the pull request, so the branch under test
|
||||
# decides what runs and, critically, `runs-on:`. A branch can point its jobs at
|
||||
# any runner its repository can reach, which makes every control the runner
|
||||
# provides opt-out by whoever wrote the branch. Under `pull_request_target`
|
||||
# Gitea reads the workflow from the BASE branch and explicitly discards any
|
||||
# pull_request_target workflow found on the head commit, so a branch can
|
||||
# neither edit this file's copy nor introduce its own.
|
||||
#
|
||||
# ADOPTING IT TAKES TWO MERGES. The trigger only takes effect once it is on the
|
||||
# base branch, because that is where it is read from - so the change that
|
||||
# introduces it cannot be tested by it. With only `pull_request_target` here
|
||||
# and nothing on `main`, the head's copy is skipped, the base has nothing to
|
||||
# contribute, and no checks run at all: a bootstrap problem that presents as a
|
||||
# broken workflow.
|
||||
#
|
||||
# So this carries both through the first merge. Every pull request in the
|
||||
# meantime gets two runs - an untrusted one from the head and a trusted one
|
||||
# from the base - which is noisy, and for the untrusted half no worse than
|
||||
# having only `pull_request`. Keep the window short:
|
||||
#
|
||||
# THE NEXT PULL REQUEST AFTER THIS ONE MERGES SHOULD MAKE TWO CHANGES:
|
||||
# 1. DELETE THE `pull_request:` LINE BELOW.
|
||||
# 2. DROP THE `ref:` FROM THE TWO runner-env CHECKOUTS, LEAVING A BARE
|
||||
# `- uses: actions/checkout@v4`.
|
||||
#
|
||||
# That follow-up is itself tested by the copy on `main`, which is the point.
|
||||
#
|
||||
# The second change is the same bootstrap problem one level down. The
|
||||
# runner-env jobs assert on the RUNNER rather than on the change, so their
|
||||
# assertions should come from the base branch - under pull_request_target a
|
||||
# bare checkout gives exactly that, and a branch can then neither weaken those
|
||||
# tests nor delete them. But test/ci-runner.bats does not exist on `main`
|
||||
# until this merges, so a bare checkout today would fail on a missing file
|
||||
# rather than on anything about the runner. They name the head until it does.
|
||||
#
|
||||
# The jobs that test the branch already name the head permanently, and for the
|
||||
# opposite reason: under pull_request_target the run's ref is the base branch,
|
||||
# so a bare checkout there would test `main` on every pull request and pass
|
||||
# regardless of the change - green checks that mean nothing.
|
||||
#
|
||||
# See dejima's docs/ci.md, "Pinning the workflow with pull_request_target":
|
||||
# https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
|
||||
# No `concurrency:` block. Gitea does not implement one - so writing it here
|
||||
# would only claim a cancellation that never happens. On a single-capacity
|
||||
# runner, expect a backlog rather than a supersede, and cancel a stale run by
|
||||
# hand if it is in the way.
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
# The privileged tier, registered at organization scope so that this
|
||||
# repository and dejima both reach it. Not a generic label: whether a job
|
||||
# container gets the Docker socket is a property of the runner's
|
||||
# config.yaml (container.docker_host), not of anything this file can ask
|
||||
# for or refuse, so naming a specific label is the only say this file has
|
||||
# in which runner that is.
|
||||
#
|
||||
# If no runner carries this label, these jobs queue. That is the intended
|
||||
# failure, and a better one than a green run on a host that grants
|
||||
# everything.
|
||||
runs-on: dejima-privileged
|
||||
container:
|
||||
# Debian bookworm plus a node binary - node is what actions/checkout
|
||||
# needs to run at all, and apt-get is what everything below needs.
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
# Before apt, because apt's failure for this says "Unable to locate
|
||||
# package bats" and nothing about why.
|
||||
#
|
||||
# This tier has no egress allowlist and no job bridge - its jobs are in
|
||||
# the host's network namespace, which is the grant the tier exists for -
|
||||
# so a name that does not resolve here is the host's resolver or the
|
||||
# node firewall's DNS pinhole, never this repository's allowlist. The
|
||||
# diagnosis below says so, rather than sending anyone to
|
||||
# `dejima-ci-allowlist show` on a host that does not run it.
|
||||
#
|
||||
# Retried rather than decided on one attempt: a resolver can be a moment
|
||||
# behind a container's start, and apt itself retries, so failing a whole
|
||||
# job on a single lookup would be stricter than the step it protects.
|
||||
# Six attempts over ~15s, then a diagnosis.
|
||||
#
|
||||
# Uses only what the image already has: getent is libc.
|
||||
- name: Check this host can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "This is the privileged tier: no allowlist and no job bridge is" >&2
|
||||
echo "in front of this job, so nothing this repository installs can" >&2
|
||||
echo "be the cause. Check, on the runner host and then the node:" >&2
|
||||
echo " cat /etc/resolv.conf - does the VM have a nameserver?" >&2
|
||||
echo " getent hosts deb.debian.org - does the VM itself resolve?" >&2
|
||||
echo "" >&2
|
||||
echo "If the VM cannot resolve either, the node firewall's udp/53 and" >&2
|
||||
echo "tcp/53 pinholes are the usual cause - they must name the VM's" >&2
|
||||
echo "NAMESERVER, not its gateway, and must sit ABOVE the LAN DROP" >&2
|
||||
echo "rules. See proxmox/README.md section 3." >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
# python3-yaml, not a YAML parser fetched at test time: two tests
|
||||
# assert that the templates and the rendered files parse, and they
|
||||
# skip rather than fail when no parser is present. A skip reads as a
|
||||
# pass in the run summary, so the parser is installed here to make
|
||||
# sure those two actually run.
|
||||
apt-get install -y --no-install-recommends bats python3 python3-yaml ca-certificates
|
||||
|
||||
# The code under test, named explicitly. Under pull_request_target the
|
||||
# run's ref is the base branch, so a bare checkout here would test `main`
|
||||
# on every pull request and pass regardless of the change - green checks
|
||||
# that mean nothing.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# As an ordinary user, not root. One test asserts that the allowlist
|
||||
# reports an unreadable config differently from a missing one, and that
|
||||
# message exists only for a caller who cannot read the file. `[ -r ]` is
|
||||
# access(2) with R_OK, which always succeeds for a process holding
|
||||
# CAP_DAC_OVERRIDE - so as root the file is readable at mode 000, the
|
||||
# branch is unreachable, and the test fails on the privilege of whoever
|
||||
# ran it rather than on anything the program does.
|
||||
#
|
||||
# The test skips itself under root rather than failing, so this is what
|
||||
# makes it actually run rather than what stops it breaking.
|
||||
#
|
||||
# Nothing else in the suite needs either privilege: it stubs curl,
|
||||
# dnsmasq and docker, and writes only into its own temp directories.
|
||||
# `node` is uid 1000 in this image and needs to own the checkout, which
|
||||
# actions/checkout leaves owned by root.
|
||||
- run: |
|
||||
chown -R node:node .
|
||||
su node -c "bats test/"
|
||||
|
||||
compose-schema:
|
||||
# The only job here that needs anything the privileged tier grants, and the
|
||||
# reason it is a separate check: it asserts on the RUNNER as much as on the
|
||||
# branch. dejima-ci-runners emits compose files, and every test in
|
||||
# test/runners.bats checks them with a YAML parser - which says the syntax
|
||||
# is well formed and nothing about whether Docker would accept the result.
|
||||
# A valid-YAML compose file with a misspelled key, a broken anchor merge or
|
||||
# a service referring to an undeclared volume passes all 47 of those and
|
||||
# fails on the host.
|
||||
#
|
||||
# So this renders both tiers with the real generator and hands the output
|
||||
# to a real `docker compose config`. That needs a daemon and the compose
|
||||
# plugin, which is exactly what this tier grants and the confined tier does
|
||||
# not - making this job also a live check that the runner built by PR #11
|
||||
# was configured the way it was meant to be.
|
||||
runs-on: dejima-privileged
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Check this host can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts. See the same" >&2
|
||||
echo "check in the job above for what to look at." >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 python3-yaml ca-certificates curl
|
||||
# The CLI only - the daemon is the host's, reached over the socket the
|
||||
# runner binds in. Debian ships no docker-cli package that does not
|
||||
# drag in a daemon this job would never start, so take the static
|
||||
# binary.
|
||||
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz" \
|
||||
| tar -xzC /usr/local/bin --strip-components=1 docker/docker
|
||||
# compose ships separately from that tarball, as a CLI plugin.
|
||||
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
|
||||
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
# Checked rather than assumed. A download that returned an error page
|
||||
# instead of a binary still chmods fine, and the failure would then
|
||||
# surface as the generator refusing to run for a reason that has
|
||||
# nothing to do with the generator.
|
||||
docker compose version || {
|
||||
echo "The compose plugin did not load. Check that the release tag and" >&2
|
||||
echo "architecture in the URL above still exist." >&2
|
||||
exit 1
|
||||
}
|
||||
env:
|
||||
DOCKER_CLI_VERSION: 27.5.1
|
||||
COMPOSE_VERSION: 2.32.4
|
||||
|
||||
# Fails here, with the fix in the message, rather than deep in a render
|
||||
# that cannot work. Both of these are runner configuration, not anything
|
||||
# a change to this repository can cause - so a red check here means the
|
||||
# machine needs attention, not the branch.
|
||||
- name: Check the runner granted Docker and the host netns
|
||||
run: |
|
||||
docker info >/dev/null 2>&1 || {
|
||||
echo "No Docker daemon reachable from this job." >&2
|
||||
echo "In act_runner's config.yaml, container.docker_host must not be \"-\"" >&2
|
||||
echo "- that is the one value that stops the socket being mounted into job" >&2
|
||||
echo "containers. dejima-ci-runners --role privileged writes it unset." >&2
|
||||
echo "" >&2
|
||||
echo "If it IS unset and this still fails, check CONFIG_FILE=/config.yaml" >&2
|
||||
echo "is in the runner's environment: without it act_runner never reads the" >&2
|
||||
echo "config file at all and every default applies, with nothing saying so." >&2
|
||||
exit 1
|
||||
}
|
||||
# Read sysfs rather than shell out to `ip`: /sys/class/net is
|
||||
# per-netns and always there, while iproute2 is not in this image.
|
||||
[ -e /sys/class/net/docker0 ] || {
|
||||
echo "This job is not in the host network namespace - docker0 is not" >&2
|
||||
echo "visible. Set container.network: host in act_runner's config.yaml;" >&2
|
||||
echo "dejima-ci-runners --role privileged writes it." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Docker reachable and in the host netns - the privileged tier's two grants."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# Render both tiers exactly as an operator would, then let Docker judge
|
||||
# the output. --no-start because registering runners against the forge is
|
||||
# not this job's business; --non-interactive because there is no terminal.
|
||||
#
|
||||
# CONF_DIR points at a fixture rather than /etc/dejima-ci: this host is
|
||||
# the privileged tier and never ran install.sh, so it has no config - and
|
||||
# the confined render needs one. Writing the fixture into a temp dir
|
||||
# rather than installing anything keeps this job read-only with respect
|
||||
# to both the host and the checkout.
|
||||
#
|
||||
# forge.invalid, not a real forge: nothing here contacts one. The .invalid
|
||||
# TLD is reserved for exactly this, so a URL that leaks into a request
|
||||
# fails to resolve rather than reaching someone.
|
||||
- name: Render both tiers and let Docker validate the compose files
|
||||
run: |
|
||||
set -eu
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
export CONF_DIR="$work/conf"
|
||||
export TEMPLATE_DIR="$PWD/examples"
|
||||
mkdir -p "$CONF_DIR"
|
||||
cat > "$CONF_DIR/config" <<'EOF'
|
||||
network = ci-schema-check
|
||||
gateway = 10.99.0.1
|
||||
forge_api = https://forge.invalid/api/v1
|
||||
EOF
|
||||
printf 'acme/widget\n' > "$CONF_DIR/repos"
|
||||
|
||||
for role in confined privileged; do
|
||||
out="$work/render-$role"
|
||||
bin/dejima-ci-runners --role "$role" --dir "$out" \
|
||||
--forge https://forge.invalid --non-interactive --no-start
|
||||
# -q: report problems and print nothing when there are none. This
|
||||
# is the check a YAML parser cannot make - it resolves the anchor
|
||||
# merges, rejects unknown keys, and fails on a service naming a
|
||||
# volume the file never declares.
|
||||
docker compose -f "$out/docker-compose.yml" config -q || {
|
||||
echo "The $role tier rendered a compose file Docker will not accept." >&2
|
||||
echo "What was generated:" >&2
|
||||
sed 's/^/ /' "$out/docker-compose.yml" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "ok $role tier: compose file accepted by docker compose config"
|
||||
done
|
||||
|
||||
# Deliberately not re-running test/runners.bats here. Its setup() puts a
|
||||
# docker stub at the front of PATH unconditionally, so it would exercise
|
||||
# the stub on this host exactly as it does in the job above - the same
|
||||
# assertions, taking twice as long, while looking like a stronger check
|
||||
# than it is. What this host adds is the real compose plugin, and that is
|
||||
# what the step above uses it for.
|
||||
|
||||
# The runners' own configuration, asserted from inside a job on each. Two
|
||||
# jobs rather than two steps on the jobs above, because the point is that
|
||||
# these are two different runners and either can be wrong on its own - one
|
||||
# red check per tier says which.
|
||||
#
|
||||
# These fail on how a RUNNER is configured, not on anything in a pull
|
||||
# request. A red check here means the machine needs attention; it does not
|
||||
# mean the branch does.
|
||||
#
|
||||
# This is the check nothing else in the repository can make. install.sh
|
||||
# reports what it wrote and dejima-ci-runners reports that a runner
|
||||
# registered and is reading the generated config - and a runner can do both
|
||||
# of those while granting its jobs everything. Whether the socket is absent
|
||||
# and whether egress is filtered are only observable from inside a job.
|
||||
runner-env-confined:
|
||||
runs-on: dejima-confined
|
||||
name: runner-env (confined)
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Check the job network can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts, so this job" >&2
|
||||
echo "cannot install anything." >&2
|
||||
echo "" >&2
|
||||
echo "This job's resolver:" >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
echo "" >&2
|
||||
echo "This is the confined tier, so this is the allowlist or the" >&2
|
||||
echo "resolver in front of it. On the runner host, in order:" >&2
|
||||
echo " dejima-ci-allowlist show - a server= line for this name?" >&2
|
||||
echo " systemctl status dnsmasq - running?" >&2
|
||||
echo " iptables -S DEJIMA-CI-IN - port 53 allowed from the bridge?" >&2
|
||||
echo " journalctl -u dnsmasq -n 50 - does the query arrive, and is it" >&2
|
||||
echo " forwarded without a reply?" >&2
|
||||
echo "" >&2
|
||||
echo "If other jobs in this run resolved fine, the runners in the pool" >&2
|
||||
echo "are configured differently: every runner answering this label" >&2
|
||||
echo "needs the same --dns and container.network. One missing it fails" >&2
|
||||
echo "only the jobs that land on it, which looks intermittent." >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
|
||||
# See the note on the runner-env checkouts near the top of this file:
|
||||
# this names the head for now, and becomes a bare checkout in the same
|
||||
# follow-up that drops the pull_request trigger.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
# Declared, not detected: landing on the wrong runner should fail
|
||||
# rather than quietly select a different set of assertions.
|
||||
DEJIMA_CI_TIER: confined
|
||||
|
||||
runner-env-privileged:
|
||||
runs-on: dejima-privileged
|
||||
name: runner-env (privileged)
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- name: Check this host can resolve before installing anything
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 1 ] || echo "resolved on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "deb.debian.org did not resolve after 6 attempts. This tier has" >&2
|
||||
echo "no allowlist in front of it, so this is the host's resolver or" >&2
|
||||
echo "the node firewall's DNS pinhole - see proxmox/README.md." >&2
|
||||
sed -n 's/^nameserver/ nameserver/p' /etc/resolv.conf >&2
|
||||
exit 1
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends bats python3 ca-certificates
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- run: bats test/ci-runner.bats
|
||||
env:
|
||||
DEJIMA_CI_TIER: privileged
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,101 +1,20 @@
|
||||
# dejima-ci-hardener
|
||||
|
||||
Hardens a CI runner that executes agent-authored code on every pull request,
|
||||
and gives it as little as possible to execute it with: no Docker socket in job
|
||||
containers, and deny-by-default egress whose allowlist comes from
|
||||
`.dejima/allowed-domains` on each served repository's `main`.
|
||||
Hardens a CI runner host that executes agent-authored code on every pull
|
||||
request, and gives its jobs as little as possible to execute it with.
|
||||
|
||||
**This is the only thing you clone onto a CI box.** It is self-contained -
|
||||
there is no step that copies files out of a dejima checkout, and dejima itself
|
||||
never needs to be present here.
|
||||
|
||||
There are two kinds of runner host. Both are built by the same runbook and the
|
||||
same command, differing in one flag; what differs is what a job on them is
|
||||
granted, and so where the boundary containing it sits. Pick the tier first -
|
||||
only one of the two halves below applies.
|
||||
|
||||
For *why* any of this is necessary - what a CI run adds over a sandbox, and
|
||||
why no approval gate is offered - see dejima's `docs/ci.md`. You do not need
|
||||
it to follow this.
|
||||
|
||||
## The runner host itself
|
||||
|
||||
`install.sh` hardens the runners on a machine you already have. For the
|
||||
privileged tier that is the wrong starting point - its jobs get the Docker
|
||||
socket, so the assumption has to be that a compromised job gets root on that
|
||||
host, and handing an existing machine to that assumption is not a decision
|
||||
many people would make deliberately.
|
||||
|
||||
**[proxmox/README.md](proxmox/README.md)** builds one instead: a runbook for
|
||||
the Proxmox web console, then one script inside the VM.
|
||||
|
||||
```sh
|
||||
# inside the new VM, once the console steps are done
|
||||
sudo ./bin/dejima-ci-bootstrap --role runtime --mgmt <your-ip>
|
||||
```
|
||||
|
||||
On `--role runtime` that deliberately stops short of running `install.sh`. The
|
||||
job network, its resolver and its DOCKER-USER rules all work by sitting in
|
||||
front of job traffic on a bridge, and this tier's jobs are in the host's
|
||||
network namespace where no bridge is. Installing them would cost an evening
|
||||
and leave a host that looks like it has an egress allowlist.
|
||||
|
||||
The node half is a runbook rather than a script on purpose. Something that
|
||||
runs `qm create` and writes into `/etc/pve/firewall/` is exactly what gets
|
||||
pasted into a root shell unread, and the blast radius is every VM on the node.
|
||||
The in-VM half is a script, because running that one blind costs you a machine
|
||||
built to be lost.
|
||||
|
||||
### The boundary is on the node, not in the VM
|
||||
|
||||
The privileged tier hands job containers the Docker socket, which is
|
||||
root-equivalent on their host. So the working assumption is that a compromised
|
||||
job **has root in that VM** — and root can flush any firewall it can see.
|
||||
|
||||
Everything installed inside the VM is therefore defence in depth: real against
|
||||
a compromised job that has not escaped its container, which is the ordinary
|
||||
case, and worth nothing against one that has.
|
||||
|
||||
What holds either way is the Proxmox firewall, applied to the VM's tap device
|
||||
and unreachable from the guest. It denies the VM every RFC1918 destination
|
||||
except two pinholes — the forge on 443 and a resolver on 53 — and those two
|
||||
are the entire remaining LAN exposure.
|
||||
|
||||
Public egress is deliberately *not* restricted there - not by address and not
|
||||
by port. An address allowlist on the node would break on every CDN rotation,
|
||||
and a port allowlist buys nothing: an earlier version permitted only tcp/80,
|
||||
tcp/443 and udp/123, which broke apt on a first install, and anyone who owns
|
||||
that box tunnels over 443 like everybody else. Neither would hold against VM
|
||||
root in any case.
|
||||
|
||||
Note what that leaves. On this tier there is no egress allowlist in front of
|
||||
job containers either - they are in the host's network namespace, so nothing
|
||||
is in front of them. A privileged job reaches the whole public internet by
|
||||
design. What it does not reach is your LAN.
|
||||
|
||||
The guarantee is narrower and honest, and it is the one worth making: an
|
||||
attacker who owns that VM reaches the internet, and not your network.
|
||||
|
||||
One thing to check rather than assume, because its absence looks exactly like
|
||||
it working: **the datacenter firewall must be enabled** (Datacenter → Firewall
|
||||
→ Options → Firewall: Yes), and the VM's network device must have its own
|
||||
**Firewall** checkbox ticked. Without either, the per-VM rules apply to
|
||||
nothing. `pve-firewall compile` on the node is the check, and
|
||||
[proxmox/README.md](proxmox/README.md) has a from-inside-the-VM check that
|
||||
does not rely on reading configuration at all.
|
||||
|
||||
### Snapshot, and roll back on a cadence
|
||||
|
||||
The VM is worth losing, which is only true if losing it is cheap. Register the
|
||||
runner, then snapshot — **VM → Snapshots → Take Snapshot**, named `clean`,
|
||||
**before the first job runs** and with *Include RAM* unticked. One taken later
|
||||
preserves whatever that job left; one taken before registration drops the
|
||||
runner off the forge on every rollback; one with RAM restores the guest's clock
|
||||
along with it, and a VM that thinks it is last month cannot verify a
|
||||
certificate.
|
||||
|
||||
Rolling back after anything suspicious is not optional. Rolling back *on a
|
||||
schedule* is the more valuable of the two, because it does not depend on your
|
||||
having noticed — and it is the one that stops happening once doing it by hand
|
||||
gets old. Proxmox schedules backups, not rollbacks, so it is a cron entry on
|
||||
the node; [proxmox/README.md](proxmox/README.md) §6 has the line, why each part
|
||||
of it is what it is, how to confirm it is still running, and why the baseline
|
||||
needs refreshing every month or two.
|
||||
why no approval gate is offered - see [dejima's `docs/ci.md`](https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md). You do
|
||||
not need it to follow this.
|
||||
|
||||
## Do not install this next to dejima
|
||||
|
||||
@@ -105,84 +24,142 @@ makes either worth anything, and this program holds a forge token and rewrites
|
||||
a resolver's configuration - neither of which belongs within reach of a
|
||||
sandbox session.
|
||||
|
||||
## Install
|
||||
## Two tiers, and you must pick one
|
||||
|
||||
The difference is not how much a job is granted. It is **where the security
|
||||
boundary sits**, and therefore what you have to build to have one at all.
|
||||
|
||||
| | **confined** | **privileged** |
|
||||
| --- | --- | --- |
|
||||
| Built by | `--role confined` | `--role privileged` |
|
||||
| Docker socket in job containers | no | **yes** |
|
||||
| Job networking | its own bridge | the host's namespace |
|
||||
| Egress allowlist in front of jobs | yes, from each repo's `main` | **none, by design** |
|
||||
| A compromised job reaches | only the declared domains | the whole public internet |
|
||||
| The boundary is | in the host | on the hypervisor |
|
||||
| Treat this host as | expected to hold | already lost |
|
||||
| Runs `install.sh` | yes | no, deliberately |
|
||||
| For | tests, lint, anything needing no daemon | image builds, tests needing a real daemon or host networking |
|
||||
|
||||
**Confined** is where everything belongs that can go there. Its jobs get no
|
||||
Docker socket, run on a bridge this tool creates, and reach only the domains
|
||||
the served repositories declare in `.dejima/allowed-domains` on their `main`.
|
||||
`dejima-ci-bootstrap --role confined` builds all of it.
|
||||
|
||||
**Privileged** exists for the jobs that cannot work otherwise: building a
|
||||
Docker image, or dejima's own runtime tests, which assert things about a real
|
||||
daemon and a real host network. Both grants are root-equivalent on the host,
|
||||
and neither can be reduced without breaking the job they exist for. That tier
|
||||
needs a machine built to be lost, not a machine you own.
|
||||
|
||||
### Why confine anything, if the hypervisor holds either way
|
||||
|
||||
Worth answering directly, because the objection is sound as far as it goes. The
|
||||
node firewall's guarantee is identical on both tiers — *reaches the internet,
|
||||
not your network* — and it is enforced somewhere neither tier can touch. If
|
||||
your LAN were the whole of the threat, the confined tier would be ceremony.
|
||||
|
||||
What that guarantee leaves open is the internet, and the internet is where the
|
||||
damage in this threat model actually happens. A prompt-injected session, or a
|
||||
dependency that ships something unwanted, has no need of your LAN. It needs
|
||||
somewhere to send a checkout, a token or a set of CI secrets, and somewhere to
|
||||
fetch its next instruction from. Every one of those is a public address, and
|
||||
the node firewall permits all of them by design.
|
||||
|
||||
The confined tier closes that, in two places at once:
|
||||
|
||||
- **Its jobs are handed no Docker socket**, so the ordinary compromise stays
|
||||
inside a job container instead of becoming root on the host.
|
||||
- **Their egress is deny-by-default.** An unlisted name does not resolve, and
|
||||
an unlisted address is unreachable even when named as a literal IP — the
|
||||
resolver and the packet filter are separate controls, and a job has to get
|
||||
past both. Reaching anywhere off the list at all means escaping the container
|
||||
first.
|
||||
|
||||
The list is the part that carries the most weight, and it is not a file on the
|
||||
host. It is `.dejima/allowed-domains` on each served repository's `main`, so
|
||||
widening it is a reviewed, attributable diff on a branch a pull request cannot
|
||||
write. **A branch cannot grant itself a destination**, which is the property
|
||||
the whole tool exists for.
|
||||
|
||||
So the tiers bound different things, and neither is a weaker version of the
|
||||
other. The privileged tier bounds the catastrophic case — an attacker on your
|
||||
network — and accepts the ordinary one, since a job there reaches the whole
|
||||
internet by design. That is why that machine is built to be lost and rolled
|
||||
back on a schedule. The confined tier bounds the ordinary case, which is the
|
||||
one that actually happens, and which a hypervisor firewall cannot see: it is
|
||||
all outbound traffic to legitimate public addresses.
|
||||
|
||||
Put the other way round: the node firewall decides *whose* network a job can
|
||||
reach. Only the confined tier decides *what it can say and to whom*.
|
||||
|
||||
### The privileged tier has no egress allowlist, on purpose
|
||||
|
||||
This is the split's most important consequence and the easiest to read as an
|
||||
omission, so plainly: it is not one.
|
||||
|
||||
The confined tier's allowlist works because its jobs cannot reach it. The
|
||||
rules sit on a bridge their traffic has to cross, in a network namespace they
|
||||
are not in, enforced by a kernel they hold no privileged access to.
|
||||
|
||||
A privileged job has all three. It holds the Docker socket, which is
|
||||
root-equivalent on that host, and it runs in the host's own network namespace.
|
||||
Any filter installed there is one it can flush in a single command. Installing
|
||||
it anyway would cost an evening and leave a host that *looks* like it has an
|
||||
egress allowlist, which is worse than one that plainly does not: the next
|
||||
person to reason about that machine reasons from the label.
|
||||
|
||||
So `dejima-ci-bootstrap --role privileged` deliberately stops short of running
|
||||
`install.sh`, and the boundary moves outward - to the hypervisor's firewall,
|
||||
applied to the VM's tap device, where the guest cannot reach it.
|
||||
|
||||
The guarantee that survives is narrower and honest, and it is the one worth
|
||||
making: **an attacker who owns that VM reaches the internet, and not your
|
||||
network.**
|
||||
|
||||
---
|
||||
|
||||
# Build one
|
||||
|
||||
Two pages, in this order. Neither repeats the other.
|
||||
|
||||
1. **Your hypervisor.** On Proxmox:
|
||||
[proxmox/README.md](proxmox/README.md) — the VM, the firewall applied to
|
||||
it, and its snapshots. On anything else, the equivalent is a VM that
|
||||
reaches the internet and, on your LAN, only your forge and a resolver.
|
||||
2. **The host inside it.**
|
||||
[docs/building-a-runner.md](docs/building-a-runner.md) — the Debian
|
||||
install, one bootstrap command, and the runners. The same on any
|
||||
hypervisor, and the same for both tiers bar one flag.
|
||||
|
||||
The whole of the tier decision is that flag, which the second page runs for
|
||||
you at the point it belongs:
|
||||
|
||||
```sh
|
||||
sudo apt-get install ipset iptables dnsmasq
|
||||
git clone https://gitbox.branderson.io/branderson/dejima-ci-hardener.git
|
||||
cd dejima-ci-hardener
|
||||
sudo ./install.sh
|
||||
--role confined --role privileged
|
||||
```
|
||||
|
||||
`install.sh` prompts for each value, offering whatever is already configured
|
||||
as the default, and does the whole job: creates the Docker network with a
|
||||
fixed bridge name, writes the resolver config, installs
|
||||
`dejima-ci-allowlist` and `dejima-ci-firewall`, installs and enables the
|
||||
systemd units, and renders the first allowlist.
|
||||
On `confined` the bootstrap additionally runs `install.sh`, which builds the job network,
|
||||
its allowlisting resolver and the egress rules. On `privileged` it stops short
|
||||
of that, for [the reason above](#the-privileged-tier-has-no-egress-allowlist-on-purpose).
|
||||
|
||||
**Idempotent** - re-running and pressing Enter at every prompt changes
|
||||
nothing, so it is also how to change one setting later.
|
||||
`--non-interactive` re-installs from the existing configuration.
|
||||
One thing that is not in either page, because no program can do it: **do not
|
||||
put a privileged runner on a machine you would mind losing.** Its jobs are
|
||||
handed the Docker socket, so the working assumption has to be that a
|
||||
compromised job gets root on that host, and handing an existing machine to that
|
||||
assumption is not a decision many people would make deliberately.
|
||||
|
||||
What it asks for:
|
||||
---
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Job network | name, bridge, gateway and subnet - a **private** range |
|
||||
| Forge URL | `https://gitea.example.com` - the API root is derived |
|
||||
| Upstream resolver | see below; it offers what this host already uses |
|
||||
| Forge token | read access only, never echoed, kept if one is already installed |
|
||||
| Repositories | `owner/repo` per line - adding one trusts that repository's `main` |
|
||||
| Base domains | allowed regardless of any project declaration |
|
||||
# Both tiers
|
||||
|
||||
### The upstream resolver
|
||||
|
||||
The one setting that is genuinely ambiguous, so: it is the resolver **dnsmasq
|
||||
forwards allowed queries to** - the one that actually knows the answers.
|
||||
|
||||
It is *not* the dnsmasq this configures (that would loop), and *not* the job
|
||||
bridge gateway. It is whatever this host already resolves with:
|
||||
|
||||
```sh
|
||||
grep nameserver /etc/resolv.conf
|
||||
```
|
||||
|
||||
On a host running systemd-resolved that gives you `127.0.0.53`, the local
|
||||
stub, which is not an answer - `resolvectl status | grep 'DNS Servers'` is,
|
||||
and names the servers behind it. Anywhere else `resolvectl` is simply absent,
|
||||
including on a VM built by the runbook above. Do not install it to find this
|
||||
out; `/etc/resolv.conf` is the answer on that machine.
|
||||
|
||||
It has to resolve everything on the allowlist, **including your forge** - so
|
||||
if that name is internal, this has to be your internal DNS. `no-resolv` in the
|
||||
dnsmasq config means it will not read `/etc/resolv.conf` itself, which is why
|
||||
it must be named explicitly.
|
||||
|
||||
### The runners
|
||||
|
||||
`install.sh` does not register runners - that needs a token from your forge
|
||||
and is the one step it cannot do for you. `examples/` has the configuration
|
||||
they need:
|
||||
|
||||
| File | Install as |
|
||||
| --- | --- |
|
||||
| `examples/act-runner-ci.yaml` | the ordinary runner's `config.yaml` |
|
||||
| `examples/act-runner-runtime.yaml` | the privileged runner's, on its own host |
|
||||
| `examples/docker-compose.yml` | several ordinary runners on one host |
|
||||
| `examples/docker-compose-runtime.yml` | the privileged one |
|
||||
| `examples/docker-daemon.json` | `/etc/docker/daemon.json` - merge, do not overwrite |
|
||||
|
||||
Set `--dns=<your gateway>` in the ordinary runner's `container.options`, and
|
||||
register at **repository or organization** scope. That last part is not
|
||||
housekeeping — see below.
|
||||
|
||||
### Register scope is the boundary; the label is not
|
||||
## Register scope is the boundary; the label is not
|
||||
|
||||
A dedicated label routes honest jobs to the runner you prepared. It stops
|
||||
nothing else, because `runs-on:` lives in the workflow file and the workflow
|
||||
file lives in the branch. A branch can name any label it likes, so a session
|
||||
that changes `runs-on: dejima-ci` to `runs-on: ubuntu-latest` and opens a pull
|
||||
request has stepped outside everything this tool installs — provided some
|
||||
request has stepped outside everything this tool installs - provided some
|
||||
runner answering that label will take the job.
|
||||
|
||||
Whether one will depends on where your runners are registered:
|
||||
@@ -213,20 +190,67 @@ less than it appears to. In rough order of preference:
|
||||
|
||||
Two failures matter here and both look exactly like success from inside a job:
|
||||
a job container that was handed the Docker socket, and a job that can reach an
|
||||
off-list address. Neither shows up as a red run.
|
||||
off-list address. Neither shows up as a red run. They show up as nothing at
|
||||
all.
|
||||
|
||||
dejima ships `test/ci-runner.bats` for exactly this, run by its own CI as
|
||||
`runner-env (sandbox)` and `runner-env (host-docker)`. Point it at your runner
|
||||
and it will tell you which half is wrong - if the DNS checks pass while the
|
||||
egress checks fail, the resolver is fine and the packet filter is not.
|
||||
Nothing else in this repository can catch either. `install.sh` reports what it
|
||||
wrote; `dejima-ci-runners` reports that a runner registered and is reading the
|
||||
config it generated - and a runner can do all of that while granting its jobs
|
||||
everything. The properties the tool exists for are only observable from inside
|
||||
a job on the runner.
|
||||
|
||||
So `test/ci-runner.bats` runs there. It takes the tier from `DEJIMA_CI_TIER`
|
||||
rather than sniffing it, so landing on the wrong runner fails instead of
|
||||
quietly selecting a different set of assertions, and it skips entirely when
|
||||
that variable is unset - which is why `bats test/` is still safe to run on a
|
||||
laptop.
|
||||
|
||||
| | `confined` | `privileged` |
|
||||
| --- | --- | --- |
|
||||
| Docker socket absent | asserted | asserted **present** |
|
||||
| Host network namespace | — | asserted |
|
||||
| Allowlisted name resolves and connects | asserted | n/a |
|
||||
| Off-list name refused | asserted | n/a |
|
||||
| Off-list resolver on `:53` unreachable | asserted | n/a |
|
||||
| Off-list address on `:443` unreachable | asserted | n/a |
|
||||
| IPv6 not an unfiltered way around | asserted | n/a |
|
||||
|
||||
The egress half is scoped to the confined tier on purpose. The privileged
|
||||
tier's jobs are in the host's network namespace, so the bridge rules are not
|
||||
in front of them at all and asserting an allowlist there would test nothing
|
||||
and pass anyway - which is the failure mode the file exists to catch.
|
||||
|
||||
Read the two together. If the DNS checks pass while the egress checks fail,
|
||||
the resolver is fine and the packet filter is not. Each failure message names
|
||||
the setting and the command to check it.
|
||||
|
||||
`.gitea/workflows/tests.yml` runs both tiers as `runner-env (confined)` and
|
||||
`runner-env (privileged)`, one red check each, so a broken runner says which
|
||||
one it was. A failure there means the machine needs attention, not the branch.
|
||||
|
||||
## Use
|
||||
|
||||
Confined-tier commands. A privileged host installs none of them, which is the
|
||||
point of that tier rather than a gap in it.
|
||||
|
||||
```sh
|
||||
dejima-ci-allowlist check # what would change, without changing it
|
||||
dejima-ci-allowlist render # fetch, validate, install, reload if changed
|
||||
dejima-ci-allowlist show # what is installed right now
|
||||
dejima-ci-firewall # reapply the egress rules
|
||||
sudo dejima-ci-allowlist check # what would change, without changing it
|
||||
sudo dejima-ci-allowlist render # fetch, validate, install, reload if changed
|
||||
sudo dejima-ci-allowlist show # what is installed right now
|
||||
sudo dejima-ci-firewall # reapply the egress rules
|
||||
```
|
||||
|
||||
All four need root, `show` included: every one of them reads
|
||||
`/etc/dejima-ci/config`, which names a forge token's path and is `0640 root` in
|
||||
a `0750 root` directory. Without `sudo` they cannot see it — and say that,
|
||||
rather than reporting it as absent.
|
||||
|
||||
`dejima-ci-runners` is installed on both tiers, and is how the runners
|
||||
themselves are changed:
|
||||
|
||||
```sh
|
||||
sudo dejima-ci-runners --role confined --count 5 --non-interactive # grow the pool
|
||||
sudo dejima-ci-runners --role confined # change anything else
|
||||
```
|
||||
|
||||
Exit codes are distinguishable on purpose, because a timer's alerting has to
|
||||
@@ -272,145 +296,27 @@ every job has unrestricted egress.
|
||||
bats test/
|
||||
```
|
||||
|
||||
Runs against a stubbed `curl` and `dnsmasq`.
|
||||
Runs against a stubbed `curl`, `dnsmasq` and `docker`, so it needs none of
|
||||
them and tells you what these programs decide rather than what the system does
|
||||
afterwards.
|
||||
|
||||
---
|
||||
`.gitea/workflows/tests.yml` runs that suite on every pull request, plus one
|
||||
check the stubs cannot make: it renders both tiers with the real generator and
|
||||
hands the output to a real `docker compose config`. A compose file can be
|
||||
valid YAML - which is all `bats test/` establishes - and still be rejected by
|
||||
Docker for a misspelled key or an anchor that does not merge. That job needs a
|
||||
Docker daemon and the host's own egress, so it runs on the privileged tier,
|
||||
and failing it means the runner needs attention rather than the branch.
|
||||
|
||||
# Reference
|
||||
## Further reading
|
||||
|
||||
Everything below is background. You do not need it to complete the setup.
|
||||
| | |
|
||||
| --- | --- |
|
||||
| [docs/](docs/) | Index of everything below |
|
||||
| [docs/reference.md](docs/reference.md) | The two `docker.sock` settings, `CONFIG_FILE`, the `/data` volume, compose networks against job networks, sharing a job network between runners, why the privileged runner is separate, why the resolver is per-runner, the cache server |
|
||||
| [proxmox/README.md](proxmox/README.md) | Building the sacrificial VM: node firewall rules, bootstrap, snapshot, scheduled rollback |
|
||||
| [dejima's `docs/ci.md`](https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md) | Why a CI runner needs any of this, and why there is no approval gate |
|
||||
|
||||
## The two settings named `docker.sock`
|
||||
## License
|
||||
|
||||
The compose bind and `container.docker_host` name the same path and do
|
||||
opposite jobs. This is the easiest thing here to conflate:
|
||||
|
||||
| | Effect | Needed? |
|
||||
| --- | --- | --- |
|
||||
| The compose bind | The runner can reach the daemon | Yes — without it nothing runs |
|
||||
| `docker_host: "-"` | Job containers *cannot* | Yes — without it every job is root on this host |
|
||||
|
||||
Setting the second does not remove the first, and removing the first does not
|
||||
make jobs safe — it just stops the runner working.
|
||||
|
||||
Whether a job gets the socket is a property of **the runner**, not of the job:
|
||||
a job asking for nothing unusual still receives whatever its runner grants
|
||||
everything it runs.
|
||||
|
||||
## `CONFIG_FILE`, and why an edit can appear to do nothing
|
||||
|
||||
Without `CONFIG_FILE=/config.yaml` in the environment, `act_runner` does not
|
||||
read the config file **at all** — it starts happily on built-in defaults,
|
||||
which means the socket goes into every job container and nothing in any log
|
||||
says so. If a config change appears to have no effect, check this first.
|
||||
|
||||
Mount the config read-only: a runner has no reason to rewrite the file that
|
||||
decides what its jobs are granted.
|
||||
|
||||
## The `/data` volume
|
||||
|
||||
It holds `.runner`, written at registration: the runner's identity and its
|
||||
credential for your forge (`runner.file: .runner`, resolved against the
|
||||
container's working directory, which is `/data` in the official image).
|
||||
|
||||
- **Keep it** and `--force-recreate`, an image bump or a reboot brings back
|
||||
*the same registered runner*. Without it the container comes up with no
|
||||
identity — with a token still in the environment it silently registers a
|
||||
*new* runner, leaving a dead entry in your forge's runner list; without one
|
||||
it fails to start.
|
||||
- **Delete it deliberately** to re-register: a runner moved to another
|
||||
instance, or a label set you would rather establish fresh.
|
||||
- **It is the only state.** Nothing else in the container is worth preserving,
|
||||
which is what lets the rest of this treat a runner as disposable.
|
||||
|
||||
Labels come from `runner.labels` on every start rather than being frozen at
|
||||
registration, so changing one is an edit and a restart.
|
||||
|
||||
## Compose networks and the job network are different worlds
|
||||
|
||||
Running several runners from one compose file makes them share a network — and
|
||||
that is not the network worth worrying about.
|
||||
|
||||
Every service in that file is an `act_runner` *process*. The containers that
|
||||
execute agent-authored code are created by the *daemon*, on the network named
|
||||
by `container.network`, and compose knows nothing about them. Attaching a
|
||||
service to a compose network does not put its jobs there.
|
||||
|
||||
So the shared network holds runner processes and nothing else, and those are
|
||||
mutually trusted — each already holds the Docker socket, so separating them
|
||||
protects nothing. The example names one `runners` network explicitly rather
|
||||
than relying on the implicit `default`, so nothing joins it later by accident.
|
||||
|
||||
The compose file deliberately does **not** attach a runner to the job network
|
||||
(a process holding the socket has no business sharing a network with untrusted
|
||||
code), and does **not** put the runner behind the egress allowlist (those rules
|
||||
filter the job bridge only — the runner is trusted, the jobs are not).
|
||||
|
||||
## Several runners, and whether they should share a job network
|
||||
|
||||
What *is* shared, with one compose file or five, is the job network: every
|
||||
runner whose config names `dejima-ci` puts its jobs on that bridge. With N
|
||||
runners at `capacity: 1` you get up to N jobs there at once, able to reach each
|
||||
other and each other's service containers.
|
||||
|
||||
- **One shared job network** — the default, and why several runners can share
|
||||
one config file. Every job on it has the same egress allowlist and none can
|
||||
reach the host or the runners, so the added exposure is strictly job-to-job.
|
||||
If the concern is a prompt-injected session reaching *outward*, this already
|
||||
covers it.
|
||||
- **One per runner** — worth it if concurrent jobs come from repositories that
|
||||
should not interfere, or if service containers hold anything a neighbour
|
||||
should not see. Costs a config file and a `docker network create` each, and
|
||||
the firewall rules looped over each bridge.
|
||||
|
||||
The `ci-allow` ipset is shared either way — it holds destinations, not
|
||||
per-bridge state, so one set and one dnsmasq serve every bridge. The wrinkle
|
||||
is DNS: `daemon.json`'s `dns` is a single daemon-wide address that would sit
|
||||
on one bridge and be unreachable from the others. Give dnsmasq a stable
|
||||
address on a dummy interface, point `daemon.json` there, and let each
|
||||
bridge's `INPUT` rule allow port 53 to it.
|
||||
|
||||
## Why the privileged runner is separate
|
||||
|
||||
It is handed the Docker daemon and the host's network namespace, because one
|
||||
job cannot work without them and neither grant can be reduced without breaking
|
||||
it. A job on it can take the machine it runs on — so that machine should exist
|
||||
for nothing else. A separate compose file makes a separate host the default,
|
||||
rather than something to disentangle later.
|
||||
|
||||
Where the two tiers do share a machine, be clear about what that is: **label
|
||||
tiering, not host separation.** A job denied the socket still shares a kernel
|
||||
with a runner that has it. A real reduction over granting everything to
|
||||
everything; not isolation between the tiers.
|
||||
|
||||
It needs no bind for `/tmp/dejima-ci` — the job container gets that path
|
||||
because the workflow asks for it and `container.valid_volumes` permits it, and
|
||||
the host daemon resolves the source host-side.
|
||||
|
||||
## Why the resolver is set per-runner, not daemon-wide
|
||||
|
||||
`daemon.json` has a `dns` key, and pointing it at the allowlisting resolver is
|
||||
the obvious way to make job containers use it. It is also wrong here, and the
|
||||
failure is not obvious.
|
||||
|
||||
That setting applies to **every container the daemon starts**. On a host that
|
||||
also runs the privileged tier, that includes containers created by its jobs
|
||||
over the Docker socket — which are not job containers and were never meant to
|
||||
be behind the CI resolver.
|
||||
|
||||
A dejima sandbox is exactly such a container, and it has a firewall of its
|
||||
own. It allowlists precisely one host address: its own bridge gateway, taken
|
||||
from its default route. A resolver on a *different* bridge is therefore
|
||||
rejected by the sandbox's own rules, so nothing inside it resolves — and the
|
||||
symptom is not "DNS is misconfigured" but the sandbox's startup self-check
|
||||
reporting that a host it allows is unreachable.
|
||||
|
||||
`--dns` in `container.options` scopes the resolver to the containers that
|
||||
runner starts, which is the set that should have it.
|
||||
|
||||
## The cache server
|
||||
|
||||
`act_runner` enables it by default, and it binds on the host *specifically* so
|
||||
job containers can reach it — an inbound service exposed to untrusted code. The
|
||||
firewall here blocks exactly that. Leave it off, or add a rule knowing what you
|
||||
are exposing.
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
+87
-4
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# dejima-ci-allowlist - render a CI job network's DNS allowlist from the
|
||||
# .dejima/allowed-domains declarations of the repositories this runner serves,
|
||||
# read from the branch the operator controls.
|
||||
@@ -69,6 +72,21 @@ read_config() {
|
||||
# user saw for a file that was sitting right there - sending them to
|
||||
# create something that already existed.
|
||||
if [ ! -e "$file" ]; then
|
||||
# -e cannot tell "absent" from "I am not allowed to look". The config
|
||||
# lives in a 0750 root-owned directory, so every unprivileged run lands
|
||||
# here - and being told to run install.sh, when install.sh has already
|
||||
# run and the only thing missing is sudo, sends someone to redo work
|
||||
# that is already done.
|
||||
local dir; dir=$(dirname "$file")
|
||||
if [ -d "$dir" ] && [ ! -x "$dir" ]; then
|
||||
die "cannot tell whether $file exists: $dir is not searchable by $(id -un).
|
||||
|
||||
It is mode $(stat -c %a "$dir" 2>/dev/null || echo '?') and owned by $(stat -c %U "$dir" 2>/dev/null || echo '?'), so only that owner can look
|
||||
inside it. This says nothing about whether the configuration is there.
|
||||
|
||||
Run the same command with sudo. This writes the resolver's configuration and
|
||||
reloads it, so it needs root regardless of what it can read."
|
||||
fi
|
||||
die "no config at $file - run install.sh to create it"
|
||||
elif [ ! -r "$file" ]; then
|
||||
die "$file exists but is not readable by $(id -un) - it is mode $(stat -c %a "$file" 2>/dev/null || echo '?') and owned by $(stat -c %U "$file" 2>/dev/null || echo '?'). Run this as root, or with sudo."
|
||||
@@ -103,7 +121,7 @@ declare -A HINT=(
|
||||
[out]="where the generated allowlist is written, e.g. /etc/dnsmasq.d/ci-allow.conf"
|
||||
[ref]="the branch treated as operator-approved, normally main"
|
||||
[reload]="the command that reloads the resolver, e.g. systemctl reload dnsmasq"
|
||||
[token_file]="a file holding a READ-ONLY forge token, mode 0600"
|
||||
[token_file]="a file holding a read-only forge API token, mode 0600 - from the forge's Settings -> Applications page, NOT a runner registration token"
|
||||
[repos_file]="one owner/repo per line - the repositories allowed to widen this runner's allowlist"
|
||||
[base_file]="names allowed regardless of any project declaration, one per line"
|
||||
)
|
||||
@@ -134,7 +152,7 @@ preflight() {
|
||||
{
|
||||
printf '%s: cannot run - %d problem(s) with the configuration:\n\n' "$PROG" "${#problems[@]}"
|
||||
printf ' - %s\n' "${problems[@]}"
|
||||
printf '\nFix all of them, or re-run install.sh, which prompts for each.\n'
|
||||
printf '\nFix all of them, or re-run `sudo ./install.sh`, which prompts for each.\n'
|
||||
} >&2
|
||||
# 2, not 1: `check` uses 1 for "there is a pending change", which is an
|
||||
# ordinary answer rather than a fault. A caller - install.sh, or a timer's
|
||||
@@ -180,6 +198,56 @@ fetch_repo() {
|
||||
case "$http" in
|
||||
200) cat "$body" ;;
|
||||
404) ;; # no declaration is a normal state, not an error
|
||||
# 401 and 403 are the two an operator will actually meet, and they mean
|
||||
# different things: rejected outright, against accepted but not allowed
|
||||
# to read this. "unexpected HTTP 401" named neither, and left the one
|
||||
# credential this program holds as the last place anyone would look.
|
||||
401) rm -f "$body"
|
||||
die "the forge rejected the token (HTTP 401) reading $repo.
|
||||
|
||||
The token in ${CFG[token_file]} is present and not empty - an empty one is
|
||||
refused before this point - so it is wrong rather than missing. Mistyped or
|
||||
truncated when pasted, revoked since, expired, or carrying a stray space or
|
||||
carriage return picked up on the way in.
|
||||
|
||||
Check first that it is an API token at all. This wants one from the forge's
|
||||
Settings -> Applications page, with read access to repository contents. A
|
||||
RUNNER REGISTRATION token - the one from Actions -> Runners, which registers a
|
||||
runner and is what dejima-ci-runners asks for - is a different string that the
|
||||
API rejects exactly like this. Swapping the two is easy and looks like nothing
|
||||
else.
|
||||
|
||||
Reproduce it by hand with the request this just made:
|
||||
|
||||
curl -sS -o /dev/null -w '%{http_code}\\n' \\
|
||||
-H \"Authorization: token \$(cat ${CFG[token_file]})\" \\
|
||||
'${CFG[forge_api]}/repos/$repo/raw/.dejima/allowed-domains?ref=${CFG[ref]}'
|
||||
|
||||
200 or 404 means the token is good. Re-run install.sh to replace it." ;;
|
||||
403) rm -f "$body"
|
||||
die "the forge accepted the token but refused the read (HTTP 403) for $repo.
|
||||
|
||||
The token in ${CFG[token_file]} authenticates, so this is permission rather
|
||||
than identity. Either it lacks the scope to read repository contents - Gitea
|
||||
tokens are scoped, and one created with none selected authenticates and can
|
||||
read nothing - or the account behind it cannot see $repo.
|
||||
|
||||
Read access to repository contents is all this needs, and all it should have:
|
||||
it only ever GETs one file per repository." ;;
|
||||
# Not followed. curl would follow this happily, and the whole premise
|
||||
# of this program is that the allowlist comes from a named branch of a
|
||||
# named repository - so quietly reading whatever the forge points at
|
||||
# instead is the one thing it must not do. A rename is also the most
|
||||
# likely cause, and saying so beats reporting a number.
|
||||
30[1278]) rm -f "$body"
|
||||
die "the forge redirected (HTTP $http) for $repo.
|
||||
|
||||
That repository has been renamed or moved to another owner, and this does not
|
||||
follow redirects: the allowlist is read from a repository you named, and
|
||||
following one would mean reading whichever repository the forge points at now.
|
||||
|
||||
Update the entry in ${CFG[repos_file]} to its current owner/repo and run this
|
||||
again. Nothing has been changed; the previous allowlist is still in place." ;;
|
||||
*) rm -f "$body"; die "unexpected HTTP $http for $repo" ;;
|
||||
esac
|
||||
rm -f "$body"
|
||||
@@ -188,6 +256,14 @@ fetch_repo() {
|
||||
collect() {
|
||||
local token repo
|
||||
token=$(< "${CFG[token_file]}")
|
||||
# Trimmed, because the failure it prevents is invisible. Command
|
||||
# substitution strips trailing newlines and nothing else, so a token file
|
||||
# written with a trailing space, or saved by an editor that left a carriage
|
||||
# return, produces an Authorization header the forge rejects - and the file
|
||||
# looks correct in every way a person would check it, including `cat`.
|
||||
token="${token//$'\r'/}"
|
||||
token="${token#"${token%%[![:space:]]*}"}"
|
||||
token="${token%"${token##*[![:space:]]}"}"
|
||||
[ -n "$token" ] || die "empty token in ${CFG[token_file]}"
|
||||
|
||||
# The base set first: what the runner needs regardless of any project.
|
||||
@@ -263,9 +339,16 @@ case "$cmd" in
|
||||
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"
|
||||
else
|
||||
elif [ -f "${CFG[out]}" ]; then
|
||||
echo "would change:"
|
||||
diff -u "${CFG[out]:-/dev/null}" "$new" || true
|
||||
diff -u "${CFG[out]}" "$new" || true
|
||||
exit 1
|
||||
else
|
||||
# :- 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"
|
||||
diff -u /dev/null "$new" || true
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
+504
-91
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Take a fresh Debian install to a hardened CI runner host.
|
||||
#
|
||||
# Runs INSIDE the VM. The firewall that keeps this machine off your LAN is not
|
||||
@@ -19,19 +22,29 @@ SRC="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||
ROLE=""
|
||||
MGMT=""
|
||||
SKIP_HARDENER=0
|
||||
STATIC_IP=0
|
||||
PINNED=0
|
||||
NONINTERACTIVE=0
|
||||
[ -t 0 ] || NONINTERACTIVE=1
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: sudo $PROG --role ci|runtime [--mgmt IP] [--skip-hardener]
|
||||
Usage: sudo $PROG --role confined|privileged [--mgmt IP] [--skip-hardener]
|
||||
|
||||
--role ci the ordinary tier: no Docker socket in job containers,
|
||||
deny-by-default job egress
|
||||
--role runtime the privileged tier: its jobs get the socket and the host
|
||||
network namespace. This host is the one to treat as lost
|
||||
--mgmt IP restrict sshd to this source as well as the node firewall
|
||||
--skip-hardener do the host hardening only, not the allowlist install.
|
||||
Implied by --role runtime, where the allowlist has nothing
|
||||
to attach to
|
||||
--role confined jobs are confined on this host: no Docker socket in job
|
||||
containers, own bridge network, deny-by-default egress
|
||||
--role privileged jobs get the socket and the host network namespace, so
|
||||
nothing on this host can confine them. Treat it as lost;
|
||||
the boundary is the hypervisor's firewall
|
||||
--mgmt IP restrict sshd to this source as well as the node firewall
|
||||
--skip-hardener do the host hardening only, not the allowlist install.
|
||||
Implied by --role privileged, where the allowlist has
|
||||
nothing to attach to
|
||||
--static-ip pin this host to the address, gateway and resolver it
|
||||
currently has, so they stop depending on a DHCP lease.
|
||||
Offered anyway when the interface is on DHCP and there is
|
||||
a terminal to ask on
|
||||
--non-interactive never prompt; take what the flags say and no more
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
@@ -41,12 +54,23 @@ while [ $# -gt 0 ]; do
|
||||
--role) ROLE="${2:?}"; shift ;;
|
||||
--mgmt) MGMT="${2:?}"; shift ;;
|
||||
--skip-hardener) SKIP_HARDENER=1 ;;
|
||||
--static-ip) STATIC_IP=1 ;;
|
||||
-n|--non-interactive) NONINTERACTIVE=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "$PROG: unknown argument: $1" >&2; usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$ROLE" in ci|runtime) ;; *) usage ;; esac
|
||||
# `ci` and `runtime` were the original spellings. They named the tiers after
|
||||
# what dejima used them for rather than after what a job on them is granted,
|
||||
# which is the thing that has to be legible to anyone else. Still accepted,
|
||||
# and said out loud rather than translated silently.
|
||||
case "$ROLE" in
|
||||
confined|privileged) ;;
|
||||
ci) echo "$PROG: --role ci is now --role confined" >&2; ROLE=confined ;;
|
||||
runtime) echo "$PROG: --role runtime is now --role privileged" >&2; ROLE=privileged ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
die() { echo "$PROG: $*" >&2; exit 1; }
|
||||
say() { printf '%s\n' "$*" >&2; }
|
||||
@@ -62,6 +86,90 @@ say
|
||||
# third-party apt source on a machine whose whole point is a small, known
|
||||
# surface, and the daemon's version matters less here than what it is allowed
|
||||
# to do.
|
||||
# Resolution first, because everything below needs it and apt's failure for a
|
||||
# host with no resolver does not mention DNS at all - it reports that it could
|
||||
# not reach the mirrors, which reads as the network being down rather than as
|
||||
# one missing line in one file.
|
||||
#
|
||||
# The case worth naming: a host converted to static addressing while a DHCP
|
||||
# client still owns /etc/resolv.conf. The client has no lease to work from, so
|
||||
# it rewrites the file with comments and no nameserver, and every symptom
|
||||
# afterwards names something other than DNS.
|
||||
say "Checking this host can resolve..."
|
||||
if ! getent hosts deb.debian.org >/dev/null 2>&1; then
|
||||
{
|
||||
printf '%s: this host cannot resolve deb.debian.org, so nothing below\n' "$PROG"
|
||||
printf 'would work. Fix DNS first; none of this is installed yet.\n\n'
|
||||
printf 'What it is configured to use:\n'
|
||||
if [ -s /etc/resolv.conf ]; then
|
||||
sed 's/^/ /' /etc/resolv.conf
|
||||
else
|
||||
printf ' /etc/resolv.conf is empty or absent\n'
|
||||
fi
|
||||
printf '\n'
|
||||
if ! grep -q '^[[:space:]]*nameserver' /etc/resolv.conf 2>/dev/null; then
|
||||
printf 'There is no nameserver line at all. If the first line says it was\n'
|
||||
printf 'generated by a DHCP client and this host is statically addressed,\n'
|
||||
printf 'that client rewrote the file with nothing in it - it has no lease\n'
|
||||
printf 'to take servers from. Put one back, and stop it happening again:\n\n'
|
||||
printf ' printf %s | sudo tee /etc/resolv.conf.head\n' "'nameserver <ip>\\n'"
|
||||
printf ' systemctl disable --now dhcpcd\n'
|
||||
printf ' printf %s | sudo tee /etc/resolv.conf\n\n' "'nameserver <ip>\\n'"
|
||||
printf 'See proxmox/README.md section 2, "give it a stable address".\n'
|
||||
else
|
||||
printf 'A nameserver is configured, so it is not answering rather than\n'
|
||||
printf 'missing. Check it is reachable from here, and that the node\n'
|
||||
printf 'firewall permits udp/53 AND tcp/53 to that exact address - the\n'
|
||||
printf 'gateway is the usual wrong answer. See proxmox/README.md section 3.\n'
|
||||
fi
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- addressing -------------------------------------------------------------
|
||||
# Offered before anything is installed, because the values it writes are read
|
||||
# off the running interface and this is the moment they are known good: the
|
||||
# host resolved a name a line ago, so its address, route and resolver are all
|
||||
# working. Doing it later means reading them back through whatever the rest of
|
||||
# this script has changed.
|
||||
#
|
||||
# Skipped silently unless the interface is actually on DHCP, so re-running this
|
||||
# on a pinned host says nothing about it.
|
||||
if [ "$STATIC_IP" -ne 0 ] || {
|
||||
[ "$NONINTERACTIVE" -eq 0 ] \
|
||||
&& iface=$(ip route show default 2>/dev/null | awk '{print $5; exit}') \
|
||||
&& [ -n "${iface:-}" ] \
|
||||
&& grep -qE "^[[:space:]]*iface[[:space:]]+$iface[[:space:]]+inet[[:space:]]+dhcp" \
|
||||
/etc/network/interfaces 2>/dev/null
|
||||
}; then
|
||||
if [ "$STATIC_IP" -eq 0 ]; then
|
||||
say
|
||||
say "$iface is configured by DHCP. A runner wants an address that stays"
|
||||
say "put: you reach this machine by it for the life of the VM, and a"
|
||||
say "lease that moves is a machine you have to go and find."
|
||||
say
|
||||
say "This can pin it to exactly what it has now - same address, same"
|
||||
say "gateway, same resolver - so nothing about reachability changes and"
|
||||
say "there is nothing to type. Reserve the address on your DHCP server"
|
||||
say "afterwards, or put it outside the pool."
|
||||
say
|
||||
printf 'Pin this host to its current address? (Y/n) [y]: ' >&2
|
||||
IFS= read -r pin </dev/tty || pin=""
|
||||
case "${pin:-y}" in n|N|no|No|NO) pin="" ;; *) pin=y ;; esac
|
||||
else
|
||||
pin=y
|
||||
fi
|
||||
if [ -n "$pin" ]; then
|
||||
say
|
||||
if "$SRC/bin/dejima-ci-static-ip" \
|
||||
$( [ "$NONINTERACTIVE" -eq 1 ] && printf '%s' --non-interactive ); then
|
||||
PINNED=1
|
||||
else
|
||||
die "could not pin the address; nothing else has been changed yet"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
say "Installing packages..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
@@ -70,18 +178,47 @@ apt-get update -qq
|
||||
# machine whose value is a small surface - drops it. apt then reports success,
|
||||
# the daemon is installed, and there is no `docker` binary at all.
|
||||
#
|
||||
# ipset and dnsmasq belong to install.sh, which only runs on the ordinary
|
||||
# tier. The runtime tier's jobs are in the host network namespace, where a job
|
||||
# network's resolver and its ipset have nothing to sit in front of - so on that
|
||||
# role these install an unused listener on port 53, on the machine this same
|
||||
# script strips listeners from forty lines below. iptables stays either way:
|
||||
# the host's own egress policy uses it on both roles.
|
||||
# ipset and dnsmasq belong to install.sh, which only runs on the confined
|
||||
# tier. The privileged tier's jobs are in the host network namespace, where a
|
||||
# job network's resolver and its ipset have nothing to sit in front of - so on
|
||||
# that role these install an unused listener on port 53, on the machine this
|
||||
# same script strips listeners from forty lines below. iptables stays either
|
||||
# way: the host's own egress policy uses it on both roles.
|
||||
tier_packages=""
|
||||
if [ "$ROLE" = ci ]; then
|
||||
if [ "$ROLE" = confined ]; then
|
||||
tier_packages="ipset dnsmasq"
|
||||
fi
|
||||
# dbus named explicitly. systemd itself runs without it, but systemctl talks to
|
||||
# the manager over the system bus for anything beyond a unit-file lookup - so
|
||||
# on a host without it, everything below that disables a service, reloads sshd,
|
||||
# runs daemon-reload or enables a unit fails with "Failed to connect to system
|
||||
# scope bus" while `systemctl disable <absent unit>` still answers normally.
|
||||
#
|
||||
# It arrives as a recommendation of systemd rather than a dependency, which
|
||||
# means --no-install-recommends here does not add it and an install that never
|
||||
# had it, or a host where something removed it as an unused dependency, is
|
||||
# indistinguishable from a working one until a unit needs managing.
|
||||
# qemu-guest-agent, on a machine otherwise kept to a small surface, so the
|
||||
# reasoning is worth stating.
|
||||
#
|
||||
# It listens on a virtio-serial channel to the hypervisor and on no network
|
||||
# socket at all, and job containers have no access to /dev/virtio-ports, so it
|
||||
# adds nothing reachable from a job or from the LAN. What it does add is
|
||||
# node-to-guest authority - guest-exec and friends are root in here - and that
|
||||
# direction is already total: the node owns this disk, can roll it back, power
|
||||
# it off and attach a console. The agent grants the node nothing it lacks and
|
||||
# grants this guest nothing at all.
|
||||
#
|
||||
# What it buys is three things the rest of this design already depends on: a
|
||||
# filesystem freeze, so a snapshot of a running VM is consistent rather than
|
||||
# equivalent to pulling the plug; the address in the hypervisor's summary,
|
||||
# which is the first place anyone looks for a machine they cannot reach; and a
|
||||
# shutdown that is negotiated rather than an ACPI event hopefully acted on -
|
||||
# which matters because the rollback cadence runs `qm shutdown --forceStop 1`,
|
||||
# and forceStop is the plug.
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
docker.io docker-cli \
|
||||
dbus qemu-guest-agent \
|
||||
openssh-server \
|
||||
iptables $tier_packages \
|
||||
ca-certificates curl git jq \
|
||||
@@ -103,6 +240,38 @@ if ! command -v docker >/dev/null 2>&1; then
|
||||
fi
|
||||
say " docker $(docker --version 2>&1 | head -1)"
|
||||
|
||||
# Said here because the failure is silent at both ends: with the option off the
|
||||
# channel does not exist, the agent has nothing to attach to, and the
|
||||
# hypervisor reports no address while snapshots of a running VM stay
|
||||
# crash-consistent - with nothing anywhere saying why.
|
||||
if [ -e /dev/virtio-ports/org.qemu.guest_agent.0 ]; then
|
||||
say " agent qemu-guest-agent, channel present"
|
||||
else
|
||||
say " agent qemu-guest-agent installed, but this VM has no agent"
|
||||
say " channel - the hypervisor's Qemu Agent option is off. Turn"
|
||||
say " it on for consistent live snapshots and for this machine's"
|
||||
say " address to appear in its summary."
|
||||
fi
|
||||
|
||||
# Checked rather than assumed, for the same reason as docker above: apt
|
||||
# reporting success is not the same as the thing working, and every remaining
|
||||
# section of this script manages a unit.
|
||||
if ! systemctl is-system-running >/dev/null 2>&1 \
|
||||
&& ! systemctl list-units --type=service >/dev/null 2>&1; then
|
||||
{
|
||||
printf '%s: systemctl cannot reach the service manager.\n\n' "$PROG"
|
||||
printf 'PID 1 is %s. If that is systemd, the system bus is missing:\n' "$(ps -p 1 -o comm= 2>/dev/null)"
|
||||
printf ' dbus installed: %s\n' "$(dpkg -l dbus 2>/dev/null | grep -c '^ii')"
|
||||
printf ' socket: %s\n\n' "$(ls /run/dbus/system_bus_socket 2>&1)"
|
||||
printf 'dbus was just installed, so this usually means the bus has not\n'
|
||||
printf 'started yet on a host that has been running without it. Restart\n'
|
||||
printf 'the machine and run this again - everything above is idempotent.\n\n'
|
||||
printf 'Nothing below this point would work: disabling a service,\n'
|
||||
printf 'reloading sshd, daemon-reload and enabling a unit all need it.\n'
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Compose, by whatever route this release actually offers.
|
||||
#
|
||||
# The package is docker-compose-v2 from trixie onward and does not exist on
|
||||
@@ -193,11 +362,11 @@ fi
|
||||
# particular announces the machine to the LAN it is meant to be isolated from.
|
||||
say "Removing services a runner does not need..."
|
||||
unwanted="avahi-daemon cups bluetooth modemmanager rpcbind"
|
||||
# dnsmasq is not installed on a runtime host any more, but it was by earlier
|
||||
# runs of this script, and an upgrade that leaves the old listener running is
|
||||
# not an upgrade. Disabled rather than purged: someone co-locating both tiers
|
||||
# on one machine still needs the package.
|
||||
if [ "$ROLE" = runtime ]; then
|
||||
# dnsmasq is not installed on a privileged host any more, but it was by
|
||||
# earlier runs of this script, and an upgrade that leaves the old listener
|
||||
# running is not an upgrade. Disabled rather than purged: someone co-locating
|
||||
# both tiers on one machine still needs the package.
|
||||
if [ "$ROLE" = privileged ]; then
|
||||
unwanted="$unwanted dnsmasq"
|
||||
fi
|
||||
for svc in $unwanted; do
|
||||
@@ -219,10 +388,36 @@ apt-get purge -y -qq avahi-daemon >/dev/null 2>&1 || true
|
||||
# survives the firewall being wrong.
|
||||
say "Hardening sshd..."
|
||||
sshd_drop=/etc/ssh/sshd_config.d/60-dejima-ci.conf
|
||||
# Password authentication is the one setting here that can lock you out, and
|
||||
# it does so silently: sshd keeps answering, the client is simply told that
|
||||
# publickey is all that is on offer, and the log says "Connection closed by
|
||||
# authenticating user" - which reads like a network fault rather than a
|
||||
# decision this script made.
|
||||
#
|
||||
# So it is only applied where a key already exists to log in with. Checked
|
||||
# across every home directory rather than only the invoking user's, because
|
||||
# `sudo` from one account does not mean that is the account anyone SSHes in as.
|
||||
have_authorized_key() {
|
||||
local f
|
||||
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
||||
[ -s "$f" ] || continue
|
||||
grep -qE '^[[:space:]]*[^#[:space:]]' "$f" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if have_authorized_key; then
|
||||
pw_auth="PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no"
|
||||
else
|
||||
pw_auth="# PasswordAuthentication left ON: no authorized_keys was found on this
|
||||
# host when $PROG ran, and turning it off would have left no way in but the
|
||||
# hypervisor's console. Install a key and re-run this to have it disabled."
|
||||
fi
|
||||
|
||||
new_sshd="# Written by $PROG.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
$pw_auth
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no"
|
||||
@@ -242,7 +437,109 @@ else
|
||||
say " written $sshd_drop"
|
||||
fi
|
||||
|
||||
if ! have_authorized_key; then
|
||||
say
|
||||
say " PASSWORD AUTHENTICATION IS STILL ON, deliberately. No authorized_keys"
|
||||
say " exists anywhere on this host, so disabling it would have left the"
|
||||
say " hypervisor's console as the only way in."
|
||||
say
|
||||
say " From the machine you administer this one from:"
|
||||
say
|
||||
say " ssh-copy-id ${SUDO_USER:-<user>}@\$(hostname -I | awk '{print \$1}')"
|
||||
say
|
||||
say " then re-run this script, which will disable it. Until then this host"
|
||||
say " accepts a password from your management address - narrower than it"
|
||||
say " sounds, since the node firewall admits only that address on 22, and"
|
||||
say " wider than it should stay."
|
||||
|
||||
fi
|
||||
|
||||
# --- automatic security updates ---------------------------------------------
|
||||
# A machine that exists to run untrusted code should not also be behind on
|
||||
# kernel and container-runtime fixes.
|
||||
say "Enabling unattended security upgrades..."
|
||||
dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null 2>&1 || true
|
||||
|
||||
# --- the hardener itself ----------------------------------------------------
|
||||
# Only for the confined tier, and the reason is the same one this whole
|
||||
# arrangement rests on: the privileged tier's jobs run in the HOST network
|
||||
# namespace. No bridge sits in front of them, so a job network, its resolver
|
||||
# and its DOCKER-USER rules never see their traffic. Installing that machinery
|
||||
# here would take a working evening and protect nothing, and - worse - would
|
||||
# leave a privileged host that looks like it has an egress allowlist.
|
||||
#
|
||||
# What protects the privileged host is the node's firewall. That is the whole
|
||||
# list.
|
||||
if [ "$SKIP_HARDENER" -eq 1 ]; then
|
||||
say
|
||||
say "Skipping install.sh (--skip-hardener)."
|
||||
elif [ "$ROLE" = privileged ]; then
|
||||
say
|
||||
say "Not running install.sh on a privileged host, deliberately."
|
||||
say
|
||||
say "Its jobs run in the host network namespace - that is the grant the tier"
|
||||
say "exists for - so no bridge is in front of them and a job network, its"
|
||||
say "resolver and its DOCKER-USER rules would never see their traffic."
|
||||
say "Installing them would leave this host looking like it has an egress"
|
||||
say "allowlist while having nothing of the sort."
|
||||
say
|
||||
say "What protects this host is the Proxmox node's firewall, and that is the"
|
||||
say "entire list. See proxmox/README.md."
|
||||
say
|
||||
say "If this machine also runs confined runners - not recommended, and not"
|
||||
say "what the tiering is for - run ./install.sh by hand."
|
||||
|
||||
# The runner itself still has to be built, and on this role install.sh is
|
||||
# not there to do it. Install the generator and its templates directly, so
|
||||
# that this host ends up with the same "re-run it to change something"
|
||||
# story as a confined one rather than a hand-written compose file.
|
||||
install -m 0755 "$SRC/bin/dejima-ci-runners" /usr/local/sbin/dejima-ci-runners
|
||||
install -d -m 0755 /usr/local/share/dejima-ci/templates
|
||||
for tpl in "$SRC"/examples/*; do
|
||||
[ -f "$tpl" ] && install -m 0644 "$tpl" "/usr/local/share/dejima-ci/templates/${tpl##*/}"
|
||||
done
|
||||
say
|
||||
say " installed /usr/local/sbin/dejima-ci-runners"
|
||||
say
|
||||
/usr/local/sbin/dejima-ci-runners --role privileged || \
|
||||
say " runner setup did not finish - re-run: sudo dejima-ci-runners --role privileged"
|
||||
else
|
||||
say
|
||||
say "Running install.sh for the allowlist, resolver and job firewall..."
|
||||
say
|
||||
# Recorded rather than fatal. Dying here skips the summary below, and by
|
||||
# this point the host is most of the way configured - packages in, sshd
|
||||
# hardened, units installed, quite possibly runners registered and taking
|
||||
# jobs. An operator who is told only "install.sh did not complete" has to
|
||||
# work out for themselves which of those happened. The run still fails, at
|
||||
# the end, after saying what state the machine is in.
|
||||
HARDENER_FAILED=0
|
||||
"$SRC/install.sh" || HARDENER_FAILED=1
|
||||
fi
|
||||
|
||||
# --- the VM's own egress ----------------------------------------------------
|
||||
# Confined only, and for the same reason install.sh is confined only: on the
|
||||
# privileged tier a job holds the Docker socket and runs in this host's network
|
||||
# namespace, so it can flush an OUTPUT chain in one command. Installing one
|
||||
# there would leave a host that looks like it restricts its own egress and does
|
||||
# not - which is the failure this project argues against everywhere else, and
|
||||
# it would be no less true here for being our own rule set.
|
||||
#
|
||||
# The node's firewall drops the same destinations and cannot be reached from
|
||||
# the guest at all, so on that tier it is not a weaker copy of this: it is the
|
||||
# only copy, and it is the real one.
|
||||
if [ "$ROLE" = privileged ]; then
|
||||
if systemctl list-unit-files dejima-ci-host-egress.service >/dev/null 2>&1; then
|
||||
say
|
||||
say "Removing the host egress policy - a privileged job can flush it, so"
|
||||
say "it is not a control on this tier. The node's firewall is."
|
||||
systemctl disable --now dejima-ci-host-egress >/dev/null 2>&1 || true
|
||||
rm -f /etc/systemd/system/dejima-ci-host-egress.service \
|
||||
/usr/local/sbin/dejima-ci-host-egress
|
||||
systemctl daemon-reload
|
||||
say " removed dejima-ci-host-egress"
|
||||
fi
|
||||
else
|
||||
# Defence in depth, and labelled as such. A job that escapes its container
|
||||
# reaches root here and can flush this in one command; what it cannot flush is
|
||||
# the node's firewall. This exists for the case that does NOT escape, and to
|
||||
@@ -259,6 +556,10 @@ Wants=network-online.target
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/dejima-ci-host-egress
|
||||
# Without this, `systemctl stop` marks the unit inactive and leaves every rule
|
||||
# in place - so the one command an operator reaches for when this policy has
|
||||
# cut them off does nothing at all.
|
||||
ExecStop=/usr/local/sbin/dejima-ci-host-egress --remove
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target docker.service
|
||||
@@ -274,6 +575,18 @@ cat > /usr/local/sbin/dejima-ci-host-egress <<'EGRESS'
|
||||
# something that had to step over a rule rather than something free.
|
||||
set -euo pipefail
|
||||
CHAIN=DEJIMA-HOST-OUT
|
||||
|
||||
# --remove takes the whole policy back off, which is what ExecStop calls. A
|
||||
# firewall you cannot remove by the obvious means is one you cannot safely
|
||||
# experiment with.
|
||||
if [ "${1:-}" = --remove ]; then
|
||||
iptables -D OUTPUT -j "$CHAIN" 2>/dev/null || true
|
||||
iptables -F "$CHAIN" 2>/dev/null || true
|
||||
iptables -X "$CHAIN" 2>/dev/null || true
|
||||
echo "dejima-ci-host-egress: policy removed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
iptables -N "$CHAIN" 2>/dev/null || true
|
||||
iptables -F "$CHAIN"
|
||||
iptables -C OUTPUT -j "$CHAIN" 2>/dev/null || iptables -I OUTPUT -j "$CHAIN"
|
||||
@@ -293,6 +606,49 @@ done
|
||||
# The LAN, minus whatever /etc/dejima-ci/host-egress-allow lists as
|
||||
# host:port pairs. The node firewall enforces the same thing; a divergence
|
||||
# between them is worth noticing, not worth relying on.
|
||||
# This host's own resolvers, on 53, derived from the machine rather than copied
|
||||
# into a second file by hand.
|
||||
#
|
||||
# They are on the LAN on almost every network, so without this the DROPs below
|
||||
# reject every DNS query the host makes - and an ICMP admin-prohibited on a UDP
|
||||
# socket is reported by Go, and so by Docker, as "connection refused". The
|
||||
# symptom is `docker pull` failing against the registry with a connection error
|
||||
# that names the registry and not the resolver, on a host that was working
|
||||
# until this policy was enabled.
|
||||
#
|
||||
# Requiring the address in host-egress-allow instead would be one more pair of
|
||||
# values that have to agree, of exactly the kind this project keeps warning
|
||||
# about, with the same silence when they do not.
|
||||
resolvers=$(awk '/^nameserver/ {print $2}' /etc/resolv.conf 2>/dev/null || true)
|
||||
# systemd-resolved puts a loopback stub in resolv.conf and keeps the real
|
||||
# servers to itself; loopback is already accepted above, but the queries the
|
||||
# stub forwards leave this host and would be rejected.
|
||||
if command -v resolvectl >/dev/null 2>&1; then
|
||||
resolvers="$resolvers $(resolvectl status 2>/dev/null | sed -n 's/.*DNS Servers: *//p' || true)"
|
||||
fi
|
||||
allowed_dns=0
|
||||
for ns in $resolvers; do
|
||||
case "$ns" in *:*) continue ;; esac # this chain is iptables, not ip6tables
|
||||
iptables -A "$CHAIN" -d "$ns" -p udp --dport 53 -j ACCEPT
|
||||
iptables -A "$CHAIN" -d "$ns" -p tcp --dport 53 -j ACCEPT
|
||||
echo "dejima-ci-host-egress: allowing DNS to $ns"
|
||||
allowed_dns=$((allowed_dns + 1))
|
||||
done
|
||||
if [ "$allowed_dns" -eq 0 ]; then
|
||||
# Said loudly because the alternative is silence. If this host resolves
|
||||
# through something on the LAN and nothing here found it, the rules below
|
||||
# reject every query it makes, and the first symptom is some unrelated
|
||||
# program reporting a connection refused by whatever it was trying to
|
||||
# reach - never by the resolver, and never naming this policy.
|
||||
echo "dejima-ci-host-egress: WARNING - found no nameserver to allow." >&2
|
||||
echo "dejima-ci-host-egress: /etc/resolv.conf named none, and resolvectl" >&2
|
||||
echo "dejima-ci-host-egress: either is absent or reported none. If this" >&2
|
||||
echo "dejima-ci-host-egress: host resolves through a LAN address, the" >&2
|
||||
echo "dejima-ci-host-egress: rules below now reject it: add it to" >&2
|
||||
echo "dejima-ci-host-egress: /etc/dejima-ci/host-egress-allow as '<ip> 53 udp'" >&2
|
||||
echo "dejima-ci-host-egress: and '<ip> 53 tcp', or run this with --remove." >&2
|
||||
fi
|
||||
|
||||
if [ -r /etc/dejima-ci/host-egress-allow ]; then
|
||||
while read -r host port proto; do
|
||||
case "${host:-}" in ''|'#'*) continue ;; esac
|
||||
@@ -308,13 +664,19 @@ chmod 755 /usr/local/sbin/dejima-ci-host-egress
|
||||
install -d -m 0750 /etc/dejima-ci
|
||||
if [ ! -f /etc/dejima-ci/host-egress-allow ]; then
|
||||
cat > /etc/dejima-ci/host-egress-allow <<'ALLOW'
|
||||
# host port [proto] - LAN destinations this VM may reach. Keep it to the two
|
||||
# the runner cannot work without: the forge, and a resolver. The Proxmox
|
||||
# node's firewall enforces the same list; this copy is defence in depth.
|
||||
# host port [proto] - LAN destinations this host may reach, beyond the ones it
|
||||
# works out for itself. The Proxmox node's firewall enforces the same list;
|
||||
# this copy is defence in depth.
|
||||
#
|
||||
# 10.0.30.20 443 tcp
|
||||
# 10.0.30.11 53 udp
|
||||
# 10.0.30.11 53 tcp
|
||||
# You do NOT need to list a resolver here. dejima-ci-host-egress reads this
|
||||
# host's own nameservers from /etc/resolv.conf and allows them on 53 - if it
|
||||
# did not, enabling the policy would stop the host resolving anything, and
|
||||
# Docker reports a rejected DNS query as a connection refused by the registry
|
||||
# rather than by the resolver.
|
||||
#
|
||||
# In practice that leaves the forge, which is reached by name and by address:
|
||||
#
|
||||
# <forge-ip> 443 tcp
|
||||
ALLOW
|
||||
chmod 640 /etc/dejima-ci/host-egress-allow
|
||||
say " created /etc/dejima-ci/host-egress-allow - fill it in before enabling"
|
||||
@@ -323,83 +685,107 @@ fi
|
||||
systemctl daemon-reload
|
||||
say " installed dejima-ci-host-egress.service (not enabled yet - see below)"
|
||||
|
||||
# --- automatic security updates ---------------------------------------------
|
||||
# A machine that exists to run untrusted code should not also be behind on
|
||||
# kernel and container-runtime fixes.
|
||||
say "Enabling unattended security upgrades..."
|
||||
dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null 2>&1 || true
|
||||
|
||||
# --- the hardener itself ----------------------------------------------------
|
||||
# Only for the ordinary tier, and the reason is the same one this whole
|
||||
# arrangement rests on: the runtime tier's jobs run in the HOST network
|
||||
# namespace. No bridge sits in front of them, so a job network, its resolver
|
||||
# and its DOCKER-USER rules never see their traffic. Installing that machinery
|
||||
# here would take a working evening and protect nothing, and - worse - would
|
||||
# leave a runtime host that looks like it has an egress allowlist.
|
||||
# --- fill it in and turn it on ----------------------------------------------
|
||||
# The two destinations this host cannot work without are the resolver and the
|
||||
# forge. The script derives the resolver from /etc/resolv.conf; the forge is
|
||||
# derived here, from the URL install.sh just recorded, and resolved to
|
||||
# addresses with the resolver that is about to be allowed.
|
||||
#
|
||||
# What protects the runtime host is the node's firewall. That is the whole
|
||||
# list.
|
||||
if [ "$SKIP_HARDENER" -eq 1 ]; then
|
||||
say
|
||||
say "Skipping install.sh (--skip-hardener)."
|
||||
elif [ "$ROLE" = runtime ]; then
|
||||
say
|
||||
say "Not running install.sh on a runtime host, deliberately."
|
||||
say
|
||||
say "Its jobs run in the host network namespace - that is the grant the tier"
|
||||
say "exists for - so no bridge is in front of them and a job network, its"
|
||||
say "resolver and its DOCKER-USER rules would never see their traffic."
|
||||
say "Installing them would leave this host looking like it has an egress"
|
||||
say "allowlist while having nothing of the sort."
|
||||
say
|
||||
say "What protects this host is the Proxmox node's firewall, and that is the"
|
||||
say "entire list. See proxmox/README.md."
|
||||
say
|
||||
say "If this machine also runs ordinary runners - not recommended, and not"
|
||||
say "what the tiering is for - run ./install.sh by hand."
|
||||
# Asking for it instead would be one more value to transcribe, and the failure
|
||||
# from getting it wrong is a host that cannot reach its forge - which presents
|
||||
# as the runner going quiet rather than as a firewall rule.
|
||||
forge_host=$(sed -n 's|^ *forge_api *= *||p' /etc/dejima-ci/config 2>/dev/null \
|
||||
| head -1 | sed 's|https\?://||; s|/.*||; s|:.*||')
|
||||
forge_ips=""
|
||||
[ -z "$forge_host" ] || forge_ips=$(getent ahostsv4 "$forge_host" 2>/dev/null \
|
||||
| awk '{print $1}' | sort -u | tr '\n' ' ')
|
||||
|
||||
if [ -n "$forge_ips" ]; then
|
||||
{
|
||||
echo "# Written by $PROG. host port [proto] - LAN destinations this host"
|
||||
echo "# may reach beyond the resolvers it works out for itself."
|
||||
echo "#"
|
||||
echo "# Derived from forge_api in /etc/dejima-ci/config: $forge_host"
|
||||
for ip in $forge_ips; do echo "$ip 443 tcp"; done
|
||||
} > /etc/dejima-ci/host-egress-allow
|
||||
chmod 640 /etc/dejima-ci/host-egress-allow
|
||||
say " written /etc/dejima-ci/host-egress-allow ($forge_host: $forge_ips)"
|
||||
|
||||
systemctl enable --now dejima-ci-host-egress >/dev/null 2>&1 \
|
||||
&& say " enabled dejima-ci-host-egress" \
|
||||
|| say " NOTE: could not enable dejima-ci-host-egress"
|
||||
|
||||
# Verified rather than assumed, and rolled back if it does not hold. This
|
||||
# policy has exactly one way to go wrong and it takes the host off the
|
||||
# network, at which point the reason is no longer reachable either.
|
||||
if getent hosts "$forge_host" >/dev/null 2>&1; then
|
||||
say " verified $forge_host still resolves with the policy applied"
|
||||
else
|
||||
say
|
||||
say " The policy is on and $forge_host no longer resolves, so it is"
|
||||
say " removing something this host needs. Taking it back off."
|
||||
systemctl disable --now dejima-ci-host-egress >/dev/null 2>&1 || true
|
||||
say " removed dejima-ci-host-egress - fix the cause and re-run this"
|
||||
fi
|
||||
else
|
||||
say
|
||||
say "Running install.sh for the allowlist, resolver and job firewall..."
|
||||
say
|
||||
"$SRC/install.sh" || die "install.sh did not complete"
|
||||
say " NOTE: could not work out the forge's address, so"
|
||||
say " /etc/dejima-ci/host-egress-allow is left as it is and the"
|
||||
say " egress policy is NOT enabled. Enabling it without the forge in"
|
||||
say " that file would cut this host off from it."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
=============================================================
|
||||
What is left
|
||||
=============================================================
|
||||
EOF
|
||||
|
||||
1. Fill in /etc/dejima-ci/host-egress-allow with the forge and resolver
|
||||
addresses, then enable the host's own egress policy:
|
||||
if [ "${PINNED:-0}" -eq 1 ]; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
systemctl enable --now dejima-ci-host-egress
|
||||
0. This host's address, gateway and resolver were pinned to what it already
|
||||
had, so nothing about reaching it changed - but two things are left, and
|
||||
both are outside this machine:
|
||||
|
||||
Enabling it before that file is filled in will cut this host off from the
|
||||
forge, which is why it is not enabled for you.
|
||||
- RESERVE THAT ADDRESS on whatever hands out leases, or move it outside
|
||||
the pool. Until then the server is free to give it to something else,
|
||||
and the collision presents as two machines intermittently unreachable.
|
||||
- RESTART THIS VM once and check it comes back. The configuration takes
|
||||
effect at the next start, which is also the first time it is proven,
|
||||
and a snapshot taken before any of this makes that free to find out.
|
||||
EOF
|
||||
fi
|
||||
|
||||
On a runtime host this is the only egress control inside the VM, and it is
|
||||
still not a boundary: root here can drop it. The node's firewall is the
|
||||
boundary.
|
||||
cat >&2 <<EOF
|
||||
|
||||
2. Register the runner. This script does not, because it needs a token from
|
||||
your forge:
|
||||
1. If you skipped the runner setup above, or it did not finish, run it. It
|
||||
writes the compose project and the runner config together, so nothing has
|
||||
to be kept in step by hand:
|
||||
|
||||
- role ci: label it something no other runner carries, and point
|
||||
container.network / --dns at what install.sh printed
|
||||
- role runtime: this is the privileged tier - socket and host netns.
|
||||
Give it one label that nothing else carries, and grant
|
||||
it to as few repositories as you can stand.
|
||||
sudo dejima-ci-runners --role $ROLE
|
||||
|
||||
3. In the Proxmox console, once step 2 shows the runner Online, snapshot this
|
||||
VM - BEFORE the first job runs, and with 'Include RAM' UNTICKED:
|
||||
It needs a registration token from your forge and names the exact page to
|
||||
mint one on. Grant it to as few repositories as you can stand.
|
||||
|
||||
2. RESTART this VM and check it comes back - address, route, name resolution,
|
||||
and 'systemctl is-system-running' answering rather than degraded. Nothing
|
||||
above has been through a boot yet, and a static network configuration takes
|
||||
effect at boot and not before.
|
||||
|
||||
3. Then, once step 1 shows the runner Online, shut this VM down and snapshot it
|
||||
- BEFORE the first job runs, and with 'Include RAM' UNTICKED:
|
||||
|
||||
VM -> Snapshots -> Take Snapshot, named 'clean'
|
||||
|
||||
Order matters. Snapshot before registering and every rollback drops the
|
||||
runner off the forge. Snapshot after a job and you have preserved whatever
|
||||
worried you. Include RAM and the guest's clock comes back with it, so a
|
||||
rolled-back VM cannot verify a certificate.
|
||||
Order matters, in four ways. Snapshot before registering and every rollback
|
||||
drops the runner off the forge. Snapshot after a job and you have preserved
|
||||
whatever worried you. Include RAM and the guest's clock comes back with it,
|
||||
so a rolled-back VM cannot verify a certificate. And snapshot before that
|
||||
restart and you have frozen a configuration nobody has tested - a rollback
|
||||
IS a cold boot, so the cadence would apply it unattended, every time.
|
||||
|
||||
4. Schedule the rollback on the NODE - nothing in here can be trusted to
|
||||
revert this VM, and a cadence you perform by hand is one you stop
|
||||
@@ -407,11 +793,38 @@ cat >&2 <<EOF
|
||||
|
||||
/etc/cron.d/dejima-ci-rollback on the Proxmox node
|
||||
|
||||
proxmox/README.md section 6 has the line and what each part of it is for.
|
||||
proxmox/README.md section 4 has the line and what each part of it is for.
|
||||
Roll back after anything suspicious too; that one is not optional.
|
||||
|
||||
Remember what is and is not enforced here. The node's firewall keeps this
|
||||
machine off your LAN and cannot be reached from inside it. Everything this
|
||||
script installed can be removed by root on this host - which, on the runtime
|
||||
role, is what a compromised job is assumed to get.
|
||||
script installed can be removed by root on this host - which, on the
|
||||
privileged role, is what a compromised job is assumed to get.
|
||||
EOF
|
||||
|
||||
if [ "${HARDENER_FAILED:-0}" -ne 0 ]; then
|
||||
cat >&2 <<EOF
|
||||
|
||||
=============================================================
|
||||
install.sh did not finish
|
||||
=============================================================
|
||||
|
||||
The host hardening above is done. What is not done is the job allowlist: no
|
||||
server= lines have been rendered, so the resolver job containers are pointed
|
||||
at answers for nothing, and any runner already registered will accept jobs
|
||||
that then fail to resolve anything at all.
|
||||
|
||||
The reason was printed above, in install.sh's own output. The usual one is the
|
||||
forge token: a token that is wrong rather than missing is only detectable by
|
||||
asking the forge, and that is what fails.
|
||||
|
||||
Fix the cause, then run either of these - the first re-prompts for every
|
||||
setting, the second only re-renders:
|
||||
|
||||
sudo $SRC/install.sh
|
||||
sudo /usr/local/sbin/dejima-ci-allowlist render
|
||||
|
||||
Nothing needs undoing first. Both are idempotent.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Binary file not shown.
Executable
+596
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Build this host's act_runner compose project, start it, and check that it
|
||||
# actually registered.
|
||||
#
|
||||
# The steps this replaces were a numbered list in install.sh's closing output,
|
||||
# and every one of them was a value that had to agree with a value somewhere
|
||||
# else: container.network with the network install.sh created, --dns with the
|
||||
# resolver's listen-address, CONFIG_FILE with the path the compose file mounts
|
||||
# to. A list of things to keep in step by hand is a list of things that drift,
|
||||
# and each of these drifts silently - the runner comes up, jobs run, and the
|
||||
# grant you thought you removed is still there.
|
||||
#
|
||||
# So nothing here is copied by a person. What install.sh already knows is read
|
||||
# from its config; the rest is asked once and rendered into both files at the
|
||||
# same time.
|
||||
#
|
||||
# Idempotent: re-running and pressing Enter at every prompt changes nothing, so
|
||||
# it is also how to add a runner or change the label later.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROG=${0##*/}
|
||||
CONF_DIR=${CONF_DIR:-/etc/dejima-ci}
|
||||
CONFIG="$CONF_DIR/config"
|
||||
REPOS_FILE="$CONF_DIR/repos"
|
||||
SRC="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||
|
||||
# Installed templates first, the checkout second. The generator is installed to
|
||||
# /usr/local/sbin and has to keep working once the clone it came from is gone.
|
||||
TEMPLATE_DIR=${TEMPLATE_DIR:-}
|
||||
if [ -z "$TEMPLATE_DIR" ]; then
|
||||
for d in /usr/local/share/dejima-ci/templates "$SRC/examples"; do
|
||||
[ -d "$d" ] && { TEMPLATE_DIR="$d"; break; }
|
||||
done
|
||||
fi
|
||||
|
||||
ROLE=""
|
||||
DIR=""
|
||||
FORGE=""
|
||||
OPT_NAME=""
|
||||
OPT_LABEL=""
|
||||
OPT_COUNT=""
|
||||
OPT_VOLUMES=""
|
||||
OPT_JOB_IMAGE=""
|
||||
OPT_ACT_IMAGE=""
|
||||
NONINTERACTIVE=0
|
||||
START=1
|
||||
[ -t 0 ] || NONINTERACTIVE=1
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: sudo $PROG --role confined|privileged [--dir PATH] [--forge URL]
|
||||
[--no-start] [--non-interactive]
|
||||
|
||||
--role confined a pool of runners whose jobs get no Docker socket, on the
|
||||
job network install.sh created, behind its allowlist
|
||||
--role privileged one runner whose jobs get the socket and this host's
|
||||
network namespace
|
||||
--dir PATH where the compose project goes (default ~/runners of the
|
||||
user who invoked sudo)
|
||||
--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
|
||||
--name NAME identifies this group of runners on this host: its
|
||||
directory, compose project, containers and volumes. Give
|
||||
a second group its own name to register a second token -
|
||||
another organization, or a user account - alongside the
|
||||
first. Defaults to the label
|
||||
--label NAME the label a workflow names in runs-on:
|
||||
--count N how many runners (confined only; privileged is always 1)
|
||||
--volumes PATHS host paths jobs may bind-mount, space-separated, or
|
||||
'none' (privileged only)
|
||||
--job-image IMAGE the image jobs run in
|
||||
--runner-image IMG the act_runner image
|
||||
--no-start write the files and stop, rather than bringing them up
|
||||
--non-interactive take every default; do not prompt
|
||||
|
||||
Every flag supplies the default for its prompt, so the two modes agree: with
|
||||
--non-interactive the flag IS the answer, and without it you still see and can
|
||||
change what was passed. That makes changing one thing later a single line -
|
||||
'--count 5 --non-interactive' adds two runners to a pool of three.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--role) ROLE="${2:?}"; shift ;;
|
||||
--dir) DIR="${2:?}"; shift ;;
|
||||
--forge) FORGE="${2:?}"; shift ;;
|
||||
--name) OPT_NAME="${2:?}"; shift ;;
|
||||
--label) OPT_LABEL="${2:?}"; shift ;;
|
||||
--count) OPT_COUNT="${2:?}"; shift ;;
|
||||
--volumes) OPT_VOLUMES="${2:?}"; shift ;;
|
||||
--job-image) OPT_JOB_IMAGE="${2:?}"; shift ;;
|
||||
--runner-image) OPT_ACT_IMAGE="${2:?}"; shift ;;
|
||||
--no-start) START=0 ;;
|
||||
-n|--non-interactive) NONINTERACTIVE=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "$PROG: unknown argument: $1" >&2; usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$ROLE" in
|
||||
confined|privileged) ;;
|
||||
ci) echo "$PROG: --role ci is now --role confined" >&2; ROLE=confined ;;
|
||||
runtime) echo "$PROG: --role runtime is now --role privileged" >&2; ROLE=privileged ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
die() { echo "$PROG: $*" >&2; exit 1; }
|
||||
say() { printf '%s\n' "$*" >&2; }
|
||||
|
||||
[ -n "$TEMPLATE_DIR" ] && [ -d "$TEMPLATE_DIR" ] \
|
||||
|| die "no templates found - looked in /usr/local/share/dejima-ci/templates and $SRC/examples"
|
||||
|
||||
command -v docker >/dev/null 2>&1 || die "docker is not installed"
|
||||
docker compose version >/dev/null 2>&1 \
|
||||
|| die "docker compose v2 is not usable - 'docker compose version' fails"
|
||||
|
||||
# --- prompting --------------------------------------------------------------
|
||||
# From the terminal rather than stdin, so this still works when invoked from a
|
||||
# script that arrived on a pipe.
|
||||
ask() {
|
||||
local __var="$1" prompt="$2" def="${3:-}" reply=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
if [ -n "$def" ]; then printf '%s [%s]: ' "$prompt" "$def" >&2
|
||||
else printf '%s: ' "$prompt" >&2; fi
|
||||
IFS= read -r reply </dev/tty || reply=""
|
||||
fi
|
||||
[ -n "$reply" ] || reply="$def"
|
||||
printf -v "$__var" '%s' "$reply"
|
||||
}
|
||||
|
||||
write_if_changed() {
|
||||
local path="$1" mode="$2" content="$3"
|
||||
if [ -f "$path" ] && [ "$content" = "$(cat "$path")" ]; then
|
||||
say " unchanged $path"
|
||||
chmod "$mode" "$path"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$content" > "$path.new"
|
||||
chmod "$mode" "$path.new"
|
||||
mv "$path.new" "$path"
|
||||
say " written $path"
|
||||
}
|
||||
|
||||
# install.sh's config, parsed the same way it writes it.
|
||||
cfg_get() {
|
||||
local key="$1" line
|
||||
[ -r "$CONFIG" ] || return 0
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
[[ "$line" == *=* ]] || continue
|
||||
[ "$(echo "${line%%=*}" | tr -d '[:space:]')" = "$key" ] || continue
|
||||
echo "${line#*=}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
return 0
|
||||
done < "$CONFIG"
|
||||
}
|
||||
|
||||
# --- where it goes ----------------------------------------------------------
|
||||
# The invoking user's home, not root's. Whoever runs `docker compose` here is a
|
||||
# person, and putting the project somewhere they cannot cd to without sudo is
|
||||
# how a directory ends up edited as root and owned by root.
|
||||
target_user="${SUDO_USER:-$(id -un)}"
|
||||
target_home=$(getent passwd "$target_user" | cut -d: -f6)
|
||||
[ -n "$target_home" ] || target_home="$HOME"
|
||||
|
||||
say "=============================================================="
|
||||
say " Runner setup - $ROLE tier"
|
||||
say "=============================================================="
|
||||
say
|
||||
|
||||
# --- values install.sh already knows ----------------------------------------
|
||||
network="$(cfg_get network)"
|
||||
gateway="$(cfg_get gateway)"
|
||||
forge_api="$(cfg_get forge_api)"
|
||||
instance="${forge_api%/api/v1}"
|
||||
|
||||
if [ "$ROLE" = confined ]; then
|
||||
# Not prompted, deliberately. These three are the ones whose halves
|
||||
# disagreeing produces a runner that starts, runs jobs, and filters
|
||||
# nothing - so they come from the file that recorded what was actually
|
||||
# built rather than from a person recalling it.
|
||||
[ -n "$network" ] || die "no job network in $CONFIG - run install.sh first.
|
||||
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)"
|
||||
say " resolver $gateway (--dns, the allowlisting resolver)"
|
||||
say
|
||||
else
|
||||
# No install.sh on this tier, so there is no config to read and nothing on
|
||||
# the host that a wrong answer here would silently disagree with.
|
||||
say "This tier runs no allowlist and no job network - its jobs are in this"
|
||||
say "host's network namespace. Nothing here is read from $CONFIG, because"
|
||||
say "install.sh does not run on a privileged host."
|
||||
say
|
||||
fi
|
||||
|
||||
# --forge wins over the config, so a host serving a second forge does not need
|
||||
# its config edited first.
|
||||
[ -z "$FORGE" ] || instance="$FORGE"
|
||||
if [ -z "$instance" ]; then
|
||||
ask instance "Forge URL (e.g. https://gitea.example.com)" ""
|
||||
[ -n "$instance" ] || die "a forge URL is required - pass --forge URL, or run install.sh first"
|
||||
fi
|
||||
instance="${instance%/}"
|
||||
instance="${instance%/api/v1}"
|
||||
say " forge $instance"
|
||||
say
|
||||
|
||||
# --- what it asks -----------------------------------------------------------
|
||||
|
||||
default_label="dejima-$ROLE"
|
||||
say
|
||||
say "The runner label. A workflow reaches these runners by naming it in"
|
||||
say "runs-on:, and no other runner on the instance may carry it - if one does,"
|
||||
say "a job naming it can land there instead, on a host nothing here hardened."
|
||||
say
|
||||
ask label "Runner label" "${OPT_LABEL:-$default_label}"
|
||||
[ -n "$label" ] || die "a label is required"
|
||||
# The group's identity, kept separate from its label. Two groups on one host
|
||||
# may share a label - that is the point of registering one at organization
|
||||
# scope and another for a user account, so either scope's workflows reach the
|
||||
# same pool - but they cannot share a compose project, container names or
|
||||
# volumes without colliding.
|
||||
say
|
||||
say "A name for this group of runners on this host. It names the directory,"
|
||||
say "the compose project, the containers and the volumes - not anything a"
|
||||
say "workflow sees. Give a second group its own name to register a second"
|
||||
say "token alongside this one."
|
||||
ask name "Group name" "${OPT_NAME:-$label}"
|
||||
[ -n "$name" ] || die "a group name is required"
|
||||
case "$name" in
|
||||
*/*|*' '*) die "a group name cannot contain a slash or a space: $name" ;;
|
||||
esac
|
||||
: "${DIR:=$target_home/runners/$name}"
|
||||
|
||||
say
|
||||
say "Where this group's compose project goes. One directory per group, so a"
|
||||
say "second group registered later sits beside this one rather than replacing"
|
||||
say "it - which is why the default carries the group name."
|
||||
ask DIR "Compose project directory" "$DIR"
|
||||
[ -n "$DIR" ] || die "a directory is required"
|
||||
|
||||
case "$label" in
|
||||
ubuntu-*|ubuntu|latest|self-hosted)
|
||||
say
|
||||
say " WARNING: '$label' is a label other runners commonly carry. The point"
|
||||
say " of a label here is that nothing else answers it. Continuing anyway." ;;
|
||||
esac
|
||||
|
||||
say
|
||||
ask job_image "Image jobs run in" "${OPT_JOB_IMAGE:-node:20-bookworm}"
|
||||
ask act_image "act_runner image" "${OPT_ACT_IMAGE:-gitea/act_runner:0.6.1}"
|
||||
|
||||
if [ "$ROLE" = confined ]; then
|
||||
say
|
||||
say "How many runners. Each takes one job at a time (capacity: 1), and all"
|
||||
say "of them share this label and this job network, so they are a pool: a"
|
||||
say "job naming $label lands on whichever is free."
|
||||
while :; do
|
||||
say
|
||||
ask count "Number of runners" "${OPT_COUNT:-3}"
|
||||
[[ "$count" =~ ^[0-9]+$ ]] && [ "$count" -ge 1 ] && [ "$count" -le 32 ] && break
|
||||
say " a whole number between 1 and 32"
|
||||
# A bad --count would otherwise be re-offered forever without a tty to
|
||||
# correct it on.
|
||||
[ "$NONINTERACTIVE" -eq 0 ] || die "--count must be a whole number between 1 and 32"
|
||||
OPT_COUNT=""
|
||||
done
|
||||
else
|
||||
# One, and not asked. The tier's premise is that this whole machine is
|
||||
# forfeit if any job on it is hostile; a second runner on the same host
|
||||
# adds jobs to that machine without adding a second machine.
|
||||
count=1
|
||||
say
|
||||
say "One runner, not asked: this tier's premise is that a hostile job takes"
|
||||
say "the whole host, so a second runner here would share the machine that is"
|
||||
say "already assumed lost rather than getting one of its own."
|
||||
fi
|
||||
|
||||
if [ "$ROLE" = privileged ]; then
|
||||
say
|
||||
say "Host paths a workflow may bind-mount into its job containers. A volume"
|
||||
say "a workflow asks for and this list does not name is dropped SILENTLY,"
|
||||
say "so this has to match what your workflows request."
|
||||
say
|
||||
say "Type 'none' to allow no host paths at all. Pressing Enter takes the"
|
||||
say "default shown, which is not the same thing."
|
||||
say
|
||||
ask valid_volumes "Allowed volume paths (space-separated, or none)" "${OPT_VOLUMES:-/tmp/dejima-ci}"
|
||||
[ "${valid_volumes,,}" != none ] || valid_volumes=""
|
||||
fi
|
||||
|
||||
# --- render -----------------------------------------------------------------
|
||||
project="${name}-runners"
|
||||
|
||||
install -d -m 0755 "$DIR" || die "cannot create $DIR"
|
||||
|
||||
# Repeat the fenced block once per runner, substituting the index. Everything
|
||||
# outside the fence is emitted once. The fence markers are comments, so the
|
||||
# template on disk is still a readable, parseable example of a single runner.
|
||||
#
|
||||
# The match is on the WHOLE line, not a substring. The template's own header
|
||||
# names these markers while explaining them, and a substring match treated that
|
||||
# sentence as the opening fence - swallowing the project name, the anchors and
|
||||
# the services: key into the repeated block, which then rendered three runners
|
||||
# and nothing to hang them on.
|
||||
expand_block() {
|
||||
local text="$1" begin="$2" end="$3" n="$4"
|
||||
awk -v begin="# $2" -v end="# $3" -v n="$n" '
|
||||
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
|
||||
trim($0) == begin { inblk = 1; blk = ""; next }
|
||||
trim($0) == end {
|
||||
inblk = 0
|
||||
for (i = 1; i <= n; i++) { b = blk; gsub(/@@N@@/, i, b); printf "%s", b }
|
||||
next
|
||||
}
|
||||
inblk { blk = blk $0 "\n"; next }
|
||||
{ print }
|
||||
' <<< "$text"
|
||||
}
|
||||
|
||||
# Swap a fenced block for something else entirely. Used where the value is a
|
||||
# YAML structure rather than a scalar: a bare @@MARKER@@ cannot stand in for
|
||||
# one, because `@` is a reserved indicator in YAML and a plain scalar may not
|
||||
# begin with it - so a template written that way stops being parseable, and
|
||||
# the annotated original is meant to be read in an editor that highlights it.
|
||||
replace_block() {
|
||||
local text="$1" begin="# $2" end="# $3" repl="$4"
|
||||
awk -v begin="$begin" -v end="$end" -v repl="$repl" '
|
||||
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
|
||||
trim($0) == begin { inblk = 1; if (repl != "") print repl; next }
|
||||
trim($0) == end { inblk = 0; next }
|
||||
inblk { next }
|
||||
{ print }
|
||||
' <<< "$text"
|
||||
}
|
||||
|
||||
subst() {
|
||||
# Values here are hostnames, image tags, label strings and paths, none of
|
||||
# which may contain the delimiter. Anything that does is refused above or
|
||||
# would break the YAML anyway.
|
||||
sed -e "s|@@PROJECT@@|$project|g" \
|
||||
-e "s|@@LABEL@@|$label|g" \
|
||||
-e "s|@@NAME@@|$name|g" \
|
||||
-e "s|@@IMAGE@@|$act_image|g" \
|
||||
-e "s|@@JOB_IMAGE@@|$job_image|g" \
|
||||
-e "s|@@INSTANCE_URL@@|$instance|g" \
|
||||
-e "s|@@NETWORK@@|$network|g" \
|
||||
-e "s|@@GATEWAY@@|$gateway|g"
|
||||
}
|
||||
|
||||
compose_tpl="$TEMPLATE_DIR/docker-compose-$ROLE.yml"
|
||||
config_tpl="$TEMPLATE_DIR/act-runner-$ROLE.yaml"
|
||||
for f in "$compose_tpl" "$config_tpl"; do
|
||||
[ -r "$f" ] || die "missing template: $f"
|
||||
done
|
||||
|
||||
# The rendered copy says it is generated, rather than inheriting the template's
|
||||
# note about being a template. Those are opposite instructions, and the one
|
||||
# that ends up on the host is the one that matters: an operator editing
|
||||
# ~/runners/config.yaml loses the edit at the next run, so the file has to say
|
||||
# so itself rather than relying on them having read the repository.
|
||||
#
|
||||
# No timestamp in it, deliberately. A generated-on line would differ on every
|
||||
# run and defeat the unchanged check below, which is what makes re-running this
|
||||
# safe to do casually.
|
||||
generated_header() {
|
||||
printf '%s\n' \
|
||||
"# Generated by $PROG --role $ROLE. DO NOT EDIT." \
|
||||
"#" \
|
||||
"# Re-running the generator overwrites this file, so an edit made here is lost" \
|
||||
"# at the next run. Change a value by re-running it instead - that is also how" \
|
||||
"# to add a runner, change the label, or move to a new act_runner image." \
|
||||
"#" \
|
||||
"# The annotated original, which explains why each setting is what it is:" \
|
||||
"# ${1#"$TEMPLATE_DIR/"} (in $TEMPLATE_DIR)"
|
||||
}
|
||||
|
||||
compose_out="$(cat "$compose_tpl")"
|
||||
compose_out="$(expand_block "$compose_out" '@@NOTE_BEGIN@@' '@@NOTE_END@@' 0)"
|
||||
if [ "$ROLE" = confined ]; then
|
||||
compose_out="$(expand_block "$compose_out" '@@SERVICE_BEGIN@@' '@@SERVICE_END@@' "$count")"
|
||||
compose_out="$(expand_block "$compose_out" '@@VOLUME_BEGIN@@' '@@VOLUME_END@@' "$count")"
|
||||
fi
|
||||
compose_out="$(generated_header "$compose_tpl"; subst <<< "$compose_out")"
|
||||
|
||||
config_out="$(cat "$config_tpl")"
|
||||
config_out="$(expand_block "$config_out" '@@NOTE_BEGIN@@' '@@NOTE_END@@' 0)"
|
||||
if [ "$ROLE" = privileged ]; then
|
||||
if [ -n "${valid_volumes:-}" ]; then
|
||||
vols=" valid_volumes:"
|
||||
for v in $valid_volumes; do vols="$vols"$'\n'" - $v"; done
|
||||
else
|
||||
vols=" valid_volumes: []"
|
||||
fi
|
||||
config_out="$(replace_block "$config_out" '@@VOLUMES_BEGIN@@' '@@VOLUMES_END@@' "$vols")"
|
||||
fi
|
||||
config_out="$(generated_header "$config_tpl"; subst <<< "$config_out")"
|
||||
|
||||
say
|
||||
write_if_changed "$DIR/docker-compose.yml" 0644 "$compose_out"
|
||||
write_if_changed "$DIR/config.yaml" 0644 "$config_out"
|
||||
|
||||
# --- the token --------------------------------------------------------------
|
||||
# The one thing this cannot do for you: it is minted by the forge, in a UI this
|
||||
# host has no credential for. So name the exact page rather than describing
|
||||
# where to look.
|
||||
ENV_FILE="$DIR/.env"
|
||||
existing_token=""
|
||||
if [ -r "$ENV_FILE" ]; then
|
||||
existing_token=$(sed -n 's/^GITEA_RUNNER_REGISTRATION_TOKEN=//p' "$ENV_FILE" | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$existing_token" ]; then
|
||||
say
|
||||
say "A registration token is already in $ENV_FILE."
|
||||
say
|
||||
ask replace_token "Replace it? (y/N)" "n"
|
||||
else
|
||||
replace_token=y
|
||||
org=""
|
||||
[ -r "$REPOS_FILE" ] && org=$(grep -vE '^\s*(#|$)' "$REPOS_FILE" 2>/dev/null | head -1 | cut -d/ -f1 || true)
|
||||
say
|
||||
say "=============================================================="
|
||||
say " Get a registration token"
|
||||
say "=============================================================="
|
||||
say
|
||||
say "This is minted by your forge and is the one value this cannot fetch."
|
||||
say "Open whichever scope you want these runners to serve:"
|
||||
say
|
||||
if [ -n "$org" ]; then
|
||||
say " organization $instance/org/$org/settings/actions/runners"
|
||||
say " (every repository in $org)"
|
||||
else
|
||||
say " organization $instance/org/<org>/settings/actions/runners"
|
||||
fi
|
||||
say " repository $instance/<owner>/<repo>/settings/actions/runners"
|
||||
say " (narrower, and the safer default)"
|
||||
say
|
||||
say "Do NOT use the instance-level page unless you mean it: a runner"
|
||||
say "registered there serves every repository on the instance, and a"
|
||||
say "workflow on any of them can then reach these runners."
|
||||
say
|
||||
say "Click 'Create new runner' and copy the registration token."
|
||||
say
|
||||
fi
|
||||
|
||||
if [ "${replace_token,,}" = y ] || [ "${replace_token,,}" = yes ]; then
|
||||
token=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
say
|
||||
printf 'Registration token (not echoed, Enter to skip): ' >&2
|
||||
IFS= read -rs token </dev/tty || true
|
||||
printf '\n' >&2
|
||||
fi
|
||||
[ -n "$token" ] || token="$existing_token"
|
||||
else
|
||||
token="$existing_token"
|
||||
fi
|
||||
|
||||
# 0600 and owned by the person who will run compose here. The token is a
|
||||
# credential: anyone holding it can register a runner on your forge and start
|
||||
# receiving jobs, secrets included.
|
||||
write_if_changed "$ENV_FILE" 0600 "# Written by $PROG. Read by docker compose from this directory.
|
||||
#
|
||||
# Needed only until each runner has registered - the identity then lives in
|
||||
# that runner's own data volume. Worth emptying once they are online.
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=$token"
|
||||
|
||||
chown -R "$target_user" "$DIR" 2>/dev/null || true
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
say
|
||||
say "No token yet, so nothing is started. Put one in $ENV_FILE:"
|
||||
say
|
||||
say " GITEA_RUNNER_REGISTRATION_TOKEN=<token from your forge>"
|
||||
say
|
||||
say "then re-run this, or bring them up yourself:"
|
||||
say
|
||||
say " cd $DIR && docker compose up -d"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$START" -eq 0 ]; then
|
||||
say
|
||||
say "--no-start given. Bring them up with:"
|
||||
say " cd $DIR && docker compose up -d"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- start ------------------------------------------------------------------
|
||||
say
|
||||
say "Starting $count runner(s)..."
|
||||
( cd "$DIR" && docker compose up -d ) || die "docker compose up failed in $DIR"
|
||||
|
||||
# --- verify -----------------------------------------------------------------
|
||||
# Facts, not log lines. act_runner's messages change between releases, and a
|
||||
# grep that silently stops matching reports success forever.
|
||||
#
|
||||
# /data/.runner is written only on successful registration, so its presence is
|
||||
# the registration having happened rather than a message about it.
|
||||
say
|
||||
say "Checking each runner actually registered..."
|
||||
services=$( cd "$DIR" && docker compose config --services )
|
||||
failed=0
|
||||
for svc in $services; do
|
||||
ok=0
|
||||
for _ in $(seq 1 30); do
|
||||
if ( cd "$DIR" && docker compose exec -T "$svc" test -s /data/.runner ) 2>/dev/null; then
|
||||
ok=1; break
|
||||
fi
|
||||
# A container that has stopped will never register, so stop waiting for
|
||||
# it rather than spending the full timeout on a decided outcome.
|
||||
state=$( cd "$DIR" && docker compose ps -a --format '{{.State}}' "$svc" 2>/dev/null | head -1 )
|
||||
case "$state" in running|starting|restarting|"") ;; *) break ;; esac
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
# CONFIG_FILE is the failure that makes every other setting inert: with
|
||||
# it unset act_runner never reads the file at all and runs on defaults,
|
||||
# which on the confined tier means the socket in every job container
|
||||
# and nothing anywhere saying so.
|
||||
cfgenv=$( cd "$DIR" && docker compose exec -T "$svc" printenv CONFIG_FILE 2>/dev/null | tr -d '\r' || true )
|
||||
if [ "$cfgenv" != "/config.yaml" ]; then
|
||||
say " FAIL $svc registered, but CONFIG_FILE=${cfgenv:-unset}"
|
||||
say " The config file is not being read; every default applies."
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
if ( cd "$DIR" && docker compose exec -T "$svc" cat /config.yaml ) 2>/dev/null \
|
||||
| diff -q - "$DIR/config.yaml" >/dev/null 2>&1; then
|
||||
say " ok $svc registered, config mounted and current"
|
||||
else
|
||||
say " FAIL $svc registered, but /config.yaml differs from $DIR/config.yaml"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
say " FAIL $svc did not register within 60s"
|
||||
say " Most often a bad or already-used registration token, or"
|
||||
say " a forge this host cannot reach. Its own account:"
|
||||
( cd "$DIR" && docker compose logs --tail 15 "$svc" 2>&1 | sed 's/^/ /' >&2 ) || true
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
say
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
say "Some runners did not come up. Nothing has been rolled back - fix the"
|
||||
say "cause and re-run this; it is idempotent."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "=============================================================="
|
||||
say " Up, and what is still unverified"
|
||||
say "=============================================================="
|
||||
say
|
||||
say "Every runner registered, is reading the config written here, and shows"
|
||||
say "the configuration this generated. Confirm they show Online in your forge."
|
||||
say
|
||||
say "What none of that establishes is the property the tier exists for."
|
||||
if [ "$ROLE" = confined ]; then
|
||||
say "That job containers get NO Docker socket and cannot reach off-list"
|
||||
say "addresses cannot be seen from out here - it needs a job to run. A"
|
||||
say "runner that is misconfigured in exactly that way looks identical to"
|
||||
say "one that is not, from every check above and from the forge's UI."
|
||||
say
|
||||
say "Run the runner-environment checks against it before trusting it. dejima"
|
||||
say "ships test/ci-runner.bats for this; see 'Verify' in the README."
|
||||
else
|
||||
say "This tier grants the socket and host networking on purpose, so there is"
|
||||
say "nothing here to verify about confinement. What matters instead is"
|
||||
say "outside this host: the hypervisor firewall, and the clean snapshot."
|
||||
say
|
||||
say "Take that snapshot NOW, before the first job runs - see"
|
||||
say "proxmox/README.md section 4. It is worth nothing taken later."
|
||||
fi
|
||||
say
|
||||
say " project $DIR"
|
||||
say " token $ENV_FILE (0600 - empty it once they are Online)"
|
||||
say " restart cd $DIR && docker compose restart"
|
||||
say " change re-run $PROG --role $ROLE --name $name"
|
||||
say
|
||||
say "To register a second token on this host - another organization, or a user"
|
||||
say "account - run this again with a different --name. The label may be the"
|
||||
say "same; everything else is kept apart:"
|
||||
say
|
||||
say " $PROG --role $ROLE --name <other>"
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Pin this host's network configuration to what it currently has.
|
||||
#
|
||||
# A CI runner wants a stable address: you reach it by name or number for the
|
||||
# life of the machine, and a lease that moves is a machine you have to go and
|
||||
# find. The Debian installer never asks - with a working DHCP server it
|
||||
# configures the interface and moves on - so converting is a job for afterwards,
|
||||
# and doing it by hand means transcribing four values into two files with no
|
||||
# feedback until something fails hours later.
|
||||
#
|
||||
# So nothing here is invented. The address, prefix, gateway and nameservers are
|
||||
# read off the running system and written back as configuration. The values do
|
||||
# not change, which is what makes this safe to run over SSH: the interface
|
||||
# already has this address, so there is nothing to renumber and no moment where
|
||||
# the host is unreachable.
|
||||
#
|
||||
# What does change is who decides them next time. Before: a DHCP client, every
|
||||
# boot, and whatever it is handed. After: these files.
|
||||
#
|
||||
# Idempotent. On a host already configured statically it reports that and
|
||||
# changes nothing.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROG=${0##*/}
|
||||
IFACES=${IFACES:-/etc/network/interfaces}
|
||||
RESOLV=${RESOLV:-/etc/resolv.conf}
|
||||
RESOLV_HEAD=${RESOLV_HEAD:-/etc/resolv.conf.head}
|
||||
|
||||
IFACE=""
|
||||
ADDRESS=""
|
||||
GATEWAY=""
|
||||
NAMESERVERS=""
|
||||
NONINTERACTIVE=0
|
||||
[ -t 0 ] || NONINTERACTIVE=1
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: sudo $PROG [--iface NAME] [--address CIDR] [--gateway IP]
|
||||
[--nameserver IP]... [--non-interactive]
|
||||
|
||||
Writes this host's current addressing into $IFACES and
|
||||
$RESOLV, so it survives without a DHCP client.
|
||||
|
||||
Every value defaults to what the interface already has, so the usual
|
||||
invocation takes no arguments at all and changes nothing about how the host is
|
||||
reachable - only what decides that after the next start.
|
||||
|
||||
--iface NAME interface to pin (default: the one carrying the default route)
|
||||
--address CIDR e.g. 192.0.2.5/24 (default: its current address)
|
||||
--gateway IP (default: the current default route)
|
||||
--nameserver IP repeatable (default: what $RESOLV names)
|
||||
--non-interactive take the defaults; do not prompt
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--iface) IFACE="${2:?}"; shift ;;
|
||||
--address) ADDRESS="${2:?}"; shift ;;
|
||||
--gateway) GATEWAY="${2:?}"; shift ;;
|
||||
--nameserver) NAMESERVERS="${NAMESERVERS:+$NAMESERVERS }${2:?}"; shift ;;
|
||||
-n|--non-interactive) NONINTERACTIVE=1 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "$PROG: unknown argument: $1" >&2; usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
die() { echo "$PROG: $*" >&2; exit 1; }
|
||||
say() { printf '%s\n' "$*" >&2; }
|
||||
|
||||
ask() {
|
||||
local __var="$1" prompt="$2" def="${3:-}" reply=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
if [ -n "$def" ]; then printf '%s [%s]: ' "$prompt" "$def" >&2
|
||||
else printf '%s: ' "$prompt" >&2; fi
|
||||
IFS= read -r reply </dev/tty || reply=""
|
||||
fi
|
||||
[ -n "$reply" ] || reply="$def"
|
||||
printf -v "$__var" '%s' "$reply"
|
||||
}
|
||||
|
||||
# --- what the host has right now --------------------------------------------
|
||||
[ -n "$IFACE" ] || IFACE=$(ip route show default 2>/dev/null | awk '{print $5; exit}')
|
||||
[ -n "$IFACE" ] || die "no default route, so there is no interface to pin.
|
||||
Bring the network up first - there is nothing here to read."
|
||||
|
||||
[ -n "$ADDRESS" ] || ADDRESS=$(ip -o -4 addr show dev "$IFACE" scope global 2>/dev/null \
|
||||
| awk '{print $4; exit}')
|
||||
[ -n "$GATEWAY" ] || GATEWAY=$(ip route show default 2>/dev/null | awk '{print $3; exit}')
|
||||
|
||||
if [ -z "$NAMESERVERS" ]; then
|
||||
NAMESERVERS=$(awk '/^[[:space:]]*nameserver/ {print $2}' "$RESOLV" 2>/dev/null | tr '\n' ' ')
|
||||
# A loopback stub is not an answer: it is systemd-resolved forwarding to
|
||||
# servers this file does not name, and writing 127.0.0.53 into a static
|
||||
# config leaves the host pointing at a stub that will not be there.
|
||||
case "$NAMESERVERS" in
|
||||
*127.0.0.53*|*127.0.0.1*)
|
||||
if command -v resolvectl >/dev/null 2>&1; then
|
||||
real=$(resolvectl status 2>/dev/null | sed -n 's/.*DNS Servers: *//p' | tr '\n' ' ')
|
||||
[ -z "$real" ] || NAMESERVERS="$real"
|
||||
fi ;;
|
||||
esac
|
||||
NAMESERVERS=$(printf '%s' "$NAMESERVERS" | tr -s ' ' | sed 's/^ *//; s/ *$//')
|
||||
fi
|
||||
|
||||
[ -n "$ADDRESS" ] || die "$IFACE has no global IPv4 address to pin."
|
||||
[ -n "$GATEWAY" ] || die "no default gateway found for $IFACE."
|
||||
# Refused rather than written empty. A static configuration with no resolver is
|
||||
# a host that comes back from its next start unable to resolve anything, and
|
||||
# the cause is invisible by then: every symptom names whatever was being
|
||||
# reached rather than the file that stopped naming a server.
|
||||
[ -n "$NAMESERVERS" ] || die "no nameserver found in $RESOLV, and none given.
|
||||
Writing a static configuration now would leave this host with no resolver at
|
||||
all after its next start. Pass --nameserver <ip>, or fix $RESOLV first."
|
||||
|
||||
# --- already done? -----------------------------------------------------------
|
||||
if grep -qE "^[[:space:]]*iface[[:space:]]+$IFACE[[:space:]]+inet[[:space:]]+static" "$IFACES" 2>/dev/null; then
|
||||
say "$IFACE is already configured statically in $IFACES - nothing to do."
|
||||
say " address $ADDRESS"
|
||||
say " gateway $GATEWAY"
|
||||
say " resolver $NAMESERVERS"
|
||||
exit 0
|
||||
fi
|
||||
grep -qE "^[[:space:]]*iface[[:space:]]+$IFACE[[:space:]]+inet[[:space:]]+dhcp" "$IFACES" 2>/dev/null \
|
||||
|| die "no 'iface $IFACE inet dhcp' stanza in $IFACES.
|
||||
This only converts an ifupdown DHCP stanza. Something else is configuring this
|
||||
interface, and changing $IFACES would not affect it."
|
||||
|
||||
say "=============================================================="
|
||||
say " Pinning $IFACE to what it already has"
|
||||
say "=============================================================="
|
||||
say
|
||||
say "Read from the running system, not invented - so none of these change how"
|
||||
say "this host is reachable. What changes is that they stop depending on a DHCP"
|
||||
say "lease being renewed."
|
||||
say
|
||||
|
||||
say
|
||||
ask ADDRESS "Address and prefix" "$ADDRESS"
|
||||
ask GATEWAY "Gateway" "$GATEWAY"
|
||||
ask NAMESERVERS "Nameserver(s)" "$NAMESERVERS"
|
||||
|
||||
case "$ADDRESS" in */*) ;; *) die "address must carry a prefix, e.g. $ADDRESS/24" ;; esac
|
||||
|
||||
# --- write -------------------------------------------------------------------
|
||||
backup="$IFACES.dejima-$$"
|
||||
cp -a "$IFACES" "$backup"
|
||||
[ ! -e "$RESOLV" ] || cp -a "$RESOLV" "$RESOLV.dejima-$$"
|
||||
|
||||
# Replace the dhcp stanza and everything indented under it. `auto` rather than
|
||||
# `allow-hotplug`, so the interface comes up as part of boot rather than when a
|
||||
# device happens to be enumerated - which is what a machine that is restarted on
|
||||
# a schedule needs.
|
||||
new_stanza="auto $IFACE
|
||||
iface $IFACE inet static
|
||||
address $ADDRESS
|
||||
gateway $GATEWAY"
|
||||
|
||||
awk -v iface="$IFACE" -v stanza="$new_stanza" '
|
||||
# allow-hotplug/auto lines for this interface are replaced by the stanza,
|
||||
# which carries its own auto line.
|
||||
$0 ~ "^[[:space:]]*(allow-hotplug|auto)[[:space:]]+" iface "[[:space:]]*$" { next }
|
||||
$0 ~ "^[[:space:]]*iface[[:space:]]+" iface "[[:space:]]+inet[[:space:]]+dhcp" {
|
||||
print stanza; inblk = 1; next
|
||||
}
|
||||
inblk && /^[[:space:]]+[^[:space:]]/ { next } # indented options of the old stanza
|
||||
{ inblk = 0; print }
|
||||
' "$backup" > "$IFACES.new"
|
||||
mv "$IFACES.new" "$IFACES"
|
||||
|
||||
{
|
||||
echo "# Written by $PROG. This host is statically addressed; nothing"
|
||||
echo "# regenerates this file, so an edit here is the whole of the change."
|
||||
for ns in $NAMESERVERS; do echo "nameserver $ns"; done
|
||||
} > "$RESOLV.new"
|
||||
cp "$RESOLV.new" "$RESOLV_HEAD"
|
||||
mv "$RESOLV.new" "$RESOLV"
|
||||
|
||||
# --- validate, and put it back if it does not hold ---------------------------
|
||||
# ifup --no-act parses the file and resolves the stanza without touching the
|
||||
# interface. A file that does not parse would cost the host its network at the
|
||||
# next start, by which time nobody is watching.
|
||||
if command -v ifup >/dev/null 2>&1; then
|
||||
if ! ifup --no-act "$IFACE" >/dev/null 2>&1; then
|
||||
cp -a "$backup" "$IFACES"
|
||||
[ ! -e "$RESOLV.dejima-$$" ] || cp -a "$RESOLV.dejima-$$" "$RESOLV"
|
||||
rm -f "$backup" "$RESOLV.dejima-$$"
|
||||
die "the generated $IFACES did not parse; it has been put back unchanged.
|
||||
Nothing was applied and this host's networking is as it was."
|
||||
fi
|
||||
fi
|
||||
|
||||
say
|
||||
say " written $IFACES"
|
||||
say " written $RESOLV"
|
||||
say " written $RESOLV_HEAD"
|
||||
say " backup $backup"
|
||||
say
|
||||
|
||||
# A DHCP client that still runs owns resolv.conf and will rewrite it, emptying
|
||||
# it on a statically addressed host because it has no lease to take servers
|
||||
# from. resolv.conf.head above survives that; disabling it is better.
|
||||
for unit in dhcpcd dhcpcd5 isc-dhcp-client; do
|
||||
if systemctl list-unit-files "$unit.service" >/dev/null 2>&1 \
|
||||
&& systemctl is-enabled "$unit" >/dev/null 2>&1; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
say " disabled $unit - a static host has no use for a DHCP client"
|
||||
fi
|
||||
done
|
||||
|
||||
say
|
||||
say "Nothing about the running network changed: the address written is the one"
|
||||
say "$IFACE already has. It takes effect as configuration at the next start,"
|
||||
say "which is also when it is first proven - so restart this VM before trusting"
|
||||
say "it, while there is still a snapshot to go back to."
|
||||
say
|
||||
say " address $ADDRESS"
|
||||
say " gateway $GATEWAY"
|
||||
say " resolver $NAMESERVERS"
|
||||
say
|
||||
say "Reserve this address on your DHCP server, or put it outside the pool."
|
||||
say "Nothing here can do that, and until it is done the server is free to hand"
|
||||
say "the same address to something else."
|
||||
@@ -0,0 +1,17 @@
|
||||
# Documentation
|
||||
|
||||
The [README](../README.md) is the map: what this is, which tier you want, and
|
||||
how to operate one once it exists. The build itself is two pages, and this is
|
||||
where the second one lives.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| [building-a-runner.md](building-a-runner.md) | The build, inside the VM: the Debian install, addressing, one bootstrap command, `install.sh`'s settings, and the runners. The same on any hypervisor and for both tiers. Start at your hypervisor's page and it sends you here |
|
||||
| [reference.md](reference.md) | The two settings named `docker.sock`, `CONFIG_FILE` and why an edit can appear to do nothing, the `/data` volume, compose networks against job networks, whether several runners should share a job network, why the privileged runner is separate, why public egress is not restricted on the node, why the resolver is set per-runner, the cache server |
|
||||
| [../proxmox/README.md](../proxmox/README.md) | The node-side half on Proxmox: the VM, the datacenter firewall, the security group and the order its rules go in, and all three snapshot moments including the scheduled rollback |
|
||||
| [../proxmox/firewall.fw.template](../proxmox/firewall.fw.template) | The same node firewall as one annotated file, easier to review in one piece than as a table. Reference, not something to install |
|
||||
| [../examples/](../examples/) | The annotated templates `dejima-ci-runners` renders into `~/runners`. Every setting carries the reasoning for its value, which the generated copies do not - read them here rather than reading what lands on the host |
|
||||
|
||||
One document lives outside this repository: [dejima's `docs/ci.md`](https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md), which
|
||||
covers why a CI run needs any of this over a sandbox, and why no approval gate
|
||||
is offered. You do not need it to follow the README.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Building a runner host
|
||||
|
||||
Everything done *inside* the VM, for either tier. Nothing here is specific to
|
||||
a hypervisor: it assumes a fresh Debian on a machine you are willing to lose,
|
||||
with whatever isolation your hypervisor provides already in place.
|
||||
|
||||
**On Proxmox, do [the node-side steps](../proxmox/README.md) first** — the
|
||||
datacenter firewall, the VM itself, and the firewall rules applied to it. Come
|
||||
back here once the installer has booted. On anything else, the equivalent is:
|
||||
a VM that reaches the internet and, on your LAN, only your forge and a
|
||||
resolver.
|
||||
|
||||
Two moments below need your hypervisor rather than this page, both snapshots.
|
||||
They are one line each and linked where they fall.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install Debian
|
||||
|
||||
|
||||
Install Debian normally, with two things to get right while it runs.
|
||||
|
||||
**Leave the root password blank.** Debian reads an empty root password as
|
||||
"use sudo", installs it, and puts your first user in the `sudo` group. Set a
|
||||
root password instead and it does neither — your user has no `sudo` at all,
|
||||
and the bootstrap step below opens with two `sudo` commands that will tell you
|
||||
so.
|
||||
|
||||
If you have already set one, fix it from the console as root:
|
||||
|
||||
```sh
|
||||
apt-get install -y sudo
|
||||
usermod -aG sudo <your-user>
|
||||
```
|
||||
|
||||
then log out and back in — group membership is read at login, so the shell you
|
||||
are already in will not have it.
|
||||
|
||||
**The software selection screen.** This is the one that bites, because it is
|
||||
a checklist where the defaults are wrong for this machine and clearing them
|
||||
all is worse. Navigate with the arrow keys and toggle with **space** — typing
|
||||
does nothing useful, and clearing every entry leaves you without the tools the
|
||||
next section assumes.
|
||||
|
||||
| Entry | Set it to |
|
||||
| --- | --- |
|
||||
| Debian desktop environment (and any desktop under it) | **off** |
|
||||
| web server | off |
|
||||
| **SSH server** | **on** — this is how you reach the VM, and nothing here installs it later |
|
||||
| **standard system utilities** | **on** — the one that quietly costs you the most if it is off; see below. It does *not* give you `git`; see §4 below |
|
||||
|
||||
Leave SSH server *on*, even though `dejima-ci-bootstrap` configures sshd
|
||||
later. Configuring is not installing: skip the task and the VM has no sshd to
|
||||
configure and no way in except your hypervisor's console.
|
||||
|
||||
**Leave standard system utilities on as well.** It is where `sudo` comes from,
|
||||
and `dbus` with it. `systemctl` reaches the service manager over the system
|
||||
bus, so a host without `dbus` cannot start, stop, enable or reload anything -
|
||||
while `systemctl` on a unit that does not exist still answers normally, which
|
||||
makes the state easy to miss.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. Addressing: leave it on DHCP
|
||||
|
||||
A runner wants an address that stays put — you reach this machine by it for the
|
||||
life of the VM, and a lease that moves is a machine you have to go and find.
|
||||
|
||||
The installer will not have asked. With a working DHCP server on the segment it
|
||||
configures the interface and moves on, and there is no static-or-DHCP question
|
||||
to answer.
|
||||
|
||||
**Do nothing about that here.** §4's `dejima-ci-bootstrap` pins this host to the
|
||||
address it already has — same address, same gateway, same resolver, read off
|
||||
the running interface rather than typed — so there is nothing to transcribe and
|
||||
nothing about reachability changes at the moment it runs. Leaving it until then
|
||||
also means the values it copies are ones you have just watched work.
|
||||
|
||||
Two things it cannot do for you:
|
||||
|
||||
- **Reserve the address**, on your DHCP server or by putting it outside the
|
||||
pool. Until that is done the server is free to hand the same one to something
|
||||
else.
|
||||
- **Prove it survives a restart**, which is the only real test of an addressing
|
||||
change. §4 takes a snapshot before any of this, so the way to find out costs
|
||||
nothing.
|
||||
|
||||
**What a moving address does *not* break.** Not the firewall. Every rule in
|
||||
[the node firewall](../proxmox/README.md#3-the-firewall-rules) keys on a
|
||||
*destination*, except the SSH rule, which keys on *your* management address —
|
||||
and it is applied to this VM's virtual interface, identified by the VM rather
|
||||
than by its address. Nothing anywhere names this VM by its own
|
||||
address, so a rebuilt VM that comes up on a different one is still fully
|
||||
firewalled.
|
||||
|
||||
What it breaks is you finding the machine: `ssh <you>@<vm-ip>` in §4, and every
|
||||
later visit. That is reason enough to pin it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Check the base, then snapshot it
|
||||
|
||||
Everything so far is a stock Debian with its networking settled. Everything
|
||||
after is a script making changes across the machine. Check the base is sound,
|
||||
then preserve it — a bad run becomes a revert rather than an investigation,
|
||||
and the machine stays identical to what this page describes rather than to
|
||||
whatever a half-finished run left.
|
||||
|
||||
```sh
|
||||
ip -o -4 addr show scope global # the address you meant
|
||||
ip route | grep default
|
||||
getent hosts deb.debian.org # a public name
|
||||
getent hosts <your-forge-hostname> # and yours
|
||||
systemctl is-system-running # running, not degraded
|
||||
```
|
||||
|
||||
All five have to answer before going on. The last one matters more than it
|
||||
looks: `systemctl` reaches the service manager over the system bus, and a host
|
||||
without `dbus` cannot start, stop, enable or reload anything — which is most of
|
||||
what the next step does.
|
||||
|
||||
Then take a snapshot named `preflight`, with RAM excluded.
|
||||
**Proxmox: [§4, Snapshots and rollback](../proxmox/README.md#4-snapshots-and-rollback).**
|
||||
|
||||
---
|
||||
|
||||
## 4. Build it
|
||||
|
||||
Both tiers are the same command with a different flag. Pick the one you
|
||||
decided on in [the README's tier table](../README.md#two-tiers-and-you-must-pick-one);
|
||||
if you have not decided, that table is the decision.
|
||||
|
||||
```sh
|
||||
ssh <you>@<vm-ip>
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
git clone https://gitbox.branderson.io/dejima/dejima-ci-hardener.git
|
||||
cd dejima-ci-hardener
|
||||
|
||||
sudo ./bin/dejima-ci-bootstrap --role confined --mgmt <your-management-ip>
|
||||
# ^^^^^^^^ or: privileged
|
||||
```
|
||||
|
||||
**`git` is a real step, not a precaution.** A Debian netinst does not install
|
||||
it at any task selection, including "standard system utilities" — that task is
|
||||
`sudo` and the base tooling, and `git` is not in it. `dejima-ci-bootstrap`
|
||||
installs `git` too, which does not help here: you need it to fetch the script
|
||||
that would install it. This one line is the bootstrap's own bootstrap.
|
||||
|
||||
Two ways this stalls, both from choices made during the install:
|
||||
|
||||
- **`ssh` refuses the connection** — the SSH server task was not selected. Log
|
||||
in at your hypervisor's console, which needs no network, and
|
||||
`apt-get install -y openssh-server git`, then `systemctl enable --now ssh`.
|
||||
- **`sudo` says you are not in the sudoers file** — a root password was set
|
||||
during the install, so Debian did not add you. See §1 for the fix, or just
|
||||
run these as root from the console.
|
||||
|
||||
**Install your SSH key before running it**, if you have not already:
|
||||
|
||||
```sh
|
||||
ssh-copy-id <you>@<vm-ip> # from the machine you administer this one from
|
||||
```
|
||||
|
||||
The bootstrap turns password authentication off, which is what you want on a
|
||||
machine that executes untrusted code — but only if there is a key to log in
|
||||
with instead. It checks, and leaves passwords on where it finds no
|
||||
`authorized_keys` anywhere on the host, saying so rather than locking you out.
|
||||
Installing the key first means it can do the thing it is for.
|
||||
|
||||
If you are reading this because you are already locked out: the hypervisor's
|
||||
console does not use SSH. Log in there, add the key, and re-run the bootstrap -
|
||||
or comment out the `PasswordAuthentication` line in
|
||||
`/etc/ssh/sshd_config.d/60-dejima-ci.conf` and `systemctl reload ssh`.
|
||||
|
||||
### What it does, on both tiers
|
||||
|
||||
It checks this host can resolve, offers to [pin the
|
||||
addressing](#2-addressing-leave-it-on-dhcp), installs packages, removes
|
||||
listeners a runner has no use for, hardens sshd, installs `qemu-guest-agent`
|
||||
so the hypervisor can freeze this filesystem for a consistent snapshot and shut
|
||||
it down by negotiation rather than by ACPI alone, and enables unattended
|
||||
security upgrades.
|
||||
|
||||
The packages are worth a word, because they are not a list you could usefully
|
||||
paste from a README. `install.sh` needs Docker; `dejima-ci-runners` needs
|
||||
Compose; Compose has no single package name that works across releases, since
|
||||
`docker-compose-v2` does not exist before trixie and the fallbacks are
|
||||
backports and then Docker's own apt repository. Debian also splits the Docker
|
||||
client into `docker-cli`, which `--no-install-recommends` drops, leaving a
|
||||
daemon and no `docker` binary. The script tries each route and checks the
|
||||
result rather than trusting apt's exit code.
|
||||
|
||||
### What it does only on `--role confined`
|
||||
|
||||
It then runs `install.sh`, which builds the job network, its allowlisting
|
||||
resolver and the DOCKER-USER rules that filter job egress. And it installs the
|
||||
host's own outbound policy, denying this machine every RFC1918 destination
|
||||
except its resolvers and its forge — both derived rather than asked for, the
|
||||
resolvers from `/etc/resolv.conf` and the forge from the URL `install.sh` just
|
||||
recorded — then turns it on and checks the forge still resolves, taking it
|
||||
back off if it does not.
|
||||
|
||||
None of that happens on `--role privileged`, and the reason is the same for
|
||||
every part of it: a job there holds the Docker socket and runs in this host's
|
||||
network namespace, so it can flush any of those rules in one command.
|
||||
Installing them would leave a host that
|
||||
[looks filtered and is not](../README.md#the-privileged-tier-has-no-egress-allowlist-on-purpose),
|
||||
which is worse than one that plainly is not. What contains that tier is the
|
||||
hypervisor's firewall, which the guest cannot reach.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. What `install.sh` asks for
|
||||
|
||||
`--role confined` only; the privileged tier never runs it.
|
||||
|
||||
It prompts for each value, offering whatever is already configured as the
|
||||
default, and does the whole job: creates the Docker network with a fixed
|
||||
bridge name, writes the resolver config, installs `dejima-ci-allowlist` and
|
||||
`dejima-ci-firewall`, installs and enables the systemd units, and renders the
|
||||
first allowlist.
|
||||
|
||||
**Idempotent** - re-running and pressing Enter at every prompt changes
|
||||
nothing, so it is also how to change one setting later.
|
||||
`--non-interactive` re-installs from the existing configuration.
|
||||
|
||||
What it asks for:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Job network | name, bridge, gateway and subnet - a **private** range |
|
||||
| Forge URL | `https://gitea.example.com` - the API root is derived |
|
||||
| Upstream resolver | see below; it offers what this host already uses |
|
||||
| Forge token | read access only, never echoed, kept if one is already installed |
|
||||
| Repositories | `owner/repo` per line - adding one trusts that repository's `main` |
|
||||
| Base domains | allowed regardless of any project declaration |
|
||||
|
||||
### The upstream resolver
|
||||
|
||||
The one setting that is genuinely ambiguous, so: it is the resolver **dnsmasq
|
||||
forwards allowed queries to** - the one that actually knows the answers.
|
||||
|
||||
It is *not* the dnsmasq this configures (that would loop), and *not* the job
|
||||
bridge gateway. It is whatever this host already resolves with:
|
||||
|
||||
```sh
|
||||
grep nameserver /etc/resolv.conf
|
||||
```
|
||||
|
||||
On a host running systemd-resolved that gives you `127.0.0.53`, the local
|
||||
stub, which is not an answer - `resolvectl status | grep 'DNS Servers'` is,
|
||||
and names the servers behind it. Anywhere else `resolvectl` is simply absent,
|
||||
including on a VM built by these steps. Do not install it to find this
|
||||
out; `/etc/resolv.conf` is the answer on that machine.
|
||||
|
||||
It has to resolve everything on the allowlist, **including your forge** - so
|
||||
if that name is internal, this has to be your internal DNS. `no-resolv` in the
|
||||
dnsmasq config means it will not read `/etc/resolv.conf` itself, which is why
|
||||
it must be named explicitly.
|
||||
|
||||
### Running it again, later
|
||||
|
||||
It is a separate program and stays one, but not because building a host is a
|
||||
two-step job. It is what you run **afterwards**, to change something:
|
||||
|
||||
```sh
|
||||
sudo ./install.sh # add a repository, change the forge, move the resolver
|
||||
```
|
||||
|
||||
It is idempotent, so pressing Enter at every prompt changes nothing, and
|
||||
re-running the whole bootstrap to edit one setting would mean another
|
||||
`apt-get update`, another sshd rewrite and another egress policy install to
|
||||
change a line in a config file.
|
||||
|
||||
It installs no packages, on the grounds that adding a Docker daemon to a
|
||||
machine is not a thing to do on someone's behalf — so on a host that
|
||||
`dejima-ci-bootstrap` did not build, it stops and names what is missing rather
|
||||
than fetching it. That is the deliberate case; the ordinary one is that
|
||||
bootstrap already put them there.
|
||||
|
||||
Note what running it alone leaves undone: **the host hardening.** You get the
|
||||
job network, the allowlist, the resolver and the DOCKER-USER rules, on a
|
||||
machine that still answers on every listener the install left running, with
|
||||
whatever sshd configuration it came with. The confined tier's controls sit in
|
||||
front of jobs, not in front of the host.
|
||||
|
||||
---
|
||||
|
||||
## 6. The runners
|
||||
|
||||
`install.sh` offers to do this at the end, and it is the same command either
|
||||
way:
|
||||
|
||||
```sh
|
||||
sudo dejima-ci-runners --role confined
|
||||
```
|
||||
|
||||
It writes a compose project to `~/runners` - `docker-compose.yml`,
|
||||
`config.yaml` and a `0600` `.env` - brings it up, and then checks each runner
|
||||
actually registered rather than assuming it did.
|
||||
|
||||
It asks for a label and how many runners you want. It does **not** ask for the
|
||||
job network or the resolver address: those come from the config `install.sh`
|
||||
wrote, because they have to match what it built, and a value that has to match
|
||||
something else is not a value to retype. That was the failure this replaces -
|
||||
`container.network`, `--dns` and `CONFIG_FILE` were three things to keep in
|
||||
step by hand, and each of them drifts silently, leaving a runner that starts,
|
||||
accepts jobs, and filters nothing.
|
||||
|
||||
The one thing it cannot do is mint the registration token, which lives in your
|
||||
forge's UI. It prints the exact page for the scope you want and waits.
|
||||
|
||||
To change something later - another runner, a different label, a new
|
||||
`act_runner` image - re-run it. Re-running with the same answers changes
|
||||
nothing, so it is safe to do casually:
|
||||
|
||||
```sh
|
||||
sudo dejima-ci-runners --role confined --count 5 --non-interactive
|
||||
```
|
||||
|
||||
`examples/` holds the annotated templates it renders. Read them there to see
|
||||
why each setting is what it is; the rendered copies say `DO NOT EDIT` because
|
||||
the next run overwrites them. `examples/docker-daemon.json` is the exception -
|
||||
it is not rendered, and goes into `/etc/docker/daemon.json` by hand, merged
|
||||
rather than overwritten.
|
||||
|
||||
Register at **repository or organization** scope - see [Register scope is the
|
||||
boundary](../README.md#register-scope-is-the-boundary-the-label-is-not).
|
||||
|
||||
### On `--role privileged`
|
||||
|
||||
`dejima-ci-bootstrap` offers to do this at the end. On its own:
|
||||
|
||||
```sh
|
||||
sudo dejima-ci-runners --role privileged --forge https://gitea.example.com
|
||||
```
|
||||
|
||||
Same command as the other tier, same `~/runners` project, same registration
|
||||
check. Three things differ, and each follows from the tier rather than being a
|
||||
setting you pick:
|
||||
|
||||
- **One runner, not asked.** A second runner here would share the machine that
|
||||
is already assumed lost rather than getting one of its own.
|
||||
- **`--forge` is required.** There is no `install.sh` config on this host to
|
||||
read it from, because `install.sh` never runs here.
|
||||
- **It asks which host paths jobs may bind-mount.** A volume a workflow asks
|
||||
for and `valid_volumes` does not name is dropped *silently*, so this has to
|
||||
match what your workflows request. `none` allows nothing.
|
||||
|
||||
There is no `--dns` to set and no job network to join: its jobs are in the
|
||||
host's namespace, which is the grant the tier exists for.
|
||||
|
||||
Give it one label that nothing else carries, and grant it to as few
|
||||
repositories as you can stand. Then take the clean snapshot, which §7 below
|
||||
covers for both tiers.
|
||||
|
||||
### More than one registration token on the same host
|
||||
|
||||
A registration token is minted at a scope — an organization, or a user
|
||||
account — and serves only repositories inside it. To serve two scopes from one
|
||||
host, register two groups:
|
||||
|
||||
```sh
|
||||
sudo dejima-ci-runners --role confined --name dejima --label ci-shared
|
||||
sudo dejima-ci-runners --role confined --name branderson --label ci-shared
|
||||
```
|
||||
|
||||
Each gets its own directory under `~/runners` — named for the group, which is
|
||||
what the directory prompt offers by default — along with its own compose
|
||||
project, containers, volumes and `.env`, so the tokens never meet and either
|
||||
group can be changed or removed without touching the other. Run the command again per group; it is idempotent, so the
|
||||
first is undisturbed by the second.
|
||||
|
||||
**The label may be the same for both, and usually should be.** It is what a
|
||||
workflow names in `runs-on:`, and a job from either scope should be able to
|
||||
reach a runner. That is why the group name is a separate thing: sharing an
|
||||
identity as well would collide on the compose project and the container names,
|
||||
and the second group would replace the first rather than join it.
|
||||
|
||||
What the group name must not be shared with is another group. Everything the
|
||||
host uses to tell them apart is derived from it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Snapshot it clean, once the runner is registered
|
||||
|
||||
The VM is worth losing, which is only true if losing it is cheap. The runner's
|
||||
identity lives on disk, in its `.runner` file, so a snapshot taken before
|
||||
registration drops the runner off the forge on every rollback — and one taken
|
||||
after the first job preserves whatever that job left, which is precisely what
|
||||
you want to be able to throw away.
|
||||
|
||||
**Restart the VM before you snapshot it**, and check it comes back. This is the
|
||||
step that proves the addressing written in §4: a static configuration takes
|
||||
effect at boot and not before, so until the machine has booted once with it,
|
||||
nobody has tested it — including you.
|
||||
|
||||
It matters here rather than anywhere else because rolling back *is* a cold
|
||||
boot. The disk is restored and the VM is started, so every rollback re-applies
|
||||
that configuration and re-runs every unit. Snapshot a machine whose network
|
||||
configuration has never been proven and you have not frozen a working state;
|
||||
you have frozen an untested one, and the cadence will apply it unattended, on a
|
||||
schedule, every time.
|
||||
|
||||
```sh
|
||||
ip -o -4 addr show scope global # the address you pinned, after a real boot
|
||||
ip route | grep default
|
||||
getent hosts <your-forge-hostname>
|
||||
systemctl is-system-running # running, not degraded
|
||||
```
|
||||
|
||||
Then confirm the runner shows **Online** in your forge, and snapshot, named
|
||||
`clean`, before the first job runs.
|
||||
**Proxmox: [§4, Snapshots and rollback](../proxmox/README.md#4-snapshots-and-rollback)**,
|
||||
which also covers rolling back on a schedule — the half that does not depend on
|
||||
your having noticed anything.
|
||||
|
||||
If any of those four fail, fix the cause and restart again before snapshotting.
|
||||
A `clean` taken now is the state you return to indefinitely, so it is worth one
|
||||
more boot to know it is the state you meant.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
The host is built. What it is *not* is verified: whether job containers get the
|
||||
Docker socket, and whether they can reach an off-list address, are only
|
||||
observable from inside a job.
|
||||
|
||||
**→ [Verify](../README.md#verify)**
|
||||
@@ -0,0 +1,168 @@
|
||||
# Reference
|
||||
|
||||
Background. You do not need any of it to complete the setup - the
|
||||
[README](../README.md) is self-contained. This is where the reasoning lives
|
||||
for the decisions that look arbitrary from outside, and the failure modes that
|
||||
present as something other than what they are.
|
||||
|
||||
## The two settings named `docker.sock`
|
||||
|
||||
The compose bind and `container.docker_host` name the same path and do
|
||||
opposite jobs. This is the easiest thing here to conflate:
|
||||
|
||||
| | Effect | Needed? |
|
||||
| --- | --- | --- |
|
||||
| The compose bind | The runner can reach the daemon | Yes — without it nothing runs |
|
||||
| `docker_host: "-"` | Job containers *cannot* | Yes — without it every job is root on this host |
|
||||
|
||||
Setting the second does not remove the first, and removing the first does not
|
||||
make jobs safe — it just stops the runner working.
|
||||
|
||||
Whether a job gets the socket is a property of **the runner**, not of the job:
|
||||
a job asking for nothing unusual still receives whatever its runner grants
|
||||
everything it runs.
|
||||
|
||||
## `CONFIG_FILE`, and why an edit can appear to do nothing
|
||||
|
||||
Without `CONFIG_FILE=/config.yaml` in the environment, `act_runner` does not
|
||||
read the config file **at all** — it starts happily on built-in defaults,
|
||||
which means the socket goes into every job container and nothing in any log
|
||||
says so. If a config change appears to have no effect, check this first.
|
||||
|
||||
Mount the config read-only: a runner has no reason to rewrite the file that
|
||||
decides what its jobs are granted.
|
||||
|
||||
`dejima-ci-runners` sets both, and checks after starting that `CONFIG_FILE` is
|
||||
what it wrote — it is the one variable whose absence makes every other setting
|
||||
inert, so it is verified rather than assumed. That check is worth knowing about
|
||||
if you ever hand-edit the generated compose file: removing it there produces a
|
||||
runner that looks configured and is not.
|
||||
|
||||
## The `/data` volume
|
||||
|
||||
It holds `.runner`, written at registration: the runner's identity and its
|
||||
credential for your forge (`runner.file: .runner`, resolved against the
|
||||
container's working directory, which is `/data` in the official image).
|
||||
|
||||
- **Keep it** and `--force-recreate`, an image bump or a reboot brings back
|
||||
*the same registered runner*. Without it the container comes up with no
|
||||
identity — with a token still in the environment it silently registers a
|
||||
*new* runner, leaving a dead entry in your forge's runner list; without one
|
||||
it fails to start.
|
||||
- **Delete it deliberately** to re-register: a runner moved to another
|
||||
instance, or a label set you would rather establish fresh.
|
||||
- **It is the only state.** Nothing else in the container is worth preserving,
|
||||
which is what lets the rest of this treat a runner as disposable.
|
||||
|
||||
Labels come from `runner.labels` on every start rather than being frozen at
|
||||
registration, so changing one is an edit and a restart.
|
||||
|
||||
## Compose networks and the job network are different worlds
|
||||
|
||||
Running several runners from one compose file makes them share a network — and
|
||||
that is not the network worth worrying about.
|
||||
|
||||
Every service in that file is an `act_runner` *process*. The containers that
|
||||
execute agent-authored code are created by the *daemon*, on the network named
|
||||
by `container.network`, and compose knows nothing about them. Attaching a
|
||||
service to a compose network does not put its jobs there.
|
||||
|
||||
So the shared network holds runner processes and nothing else, and those are
|
||||
mutually trusted — each already holds the Docker socket, so separating them
|
||||
protects nothing. The example names one `runners` network explicitly rather
|
||||
than relying on the implicit `default`, so nothing joins it later by accident.
|
||||
|
||||
The compose file deliberately does **not** attach a runner to the job network
|
||||
(a process holding the socket has no business sharing a network with untrusted
|
||||
code), and does **not** put the runner behind the egress allowlist (those rules
|
||||
filter the job bridge only — the runner is trusted, the jobs are not).
|
||||
|
||||
## Several runners, and whether they should share a job network
|
||||
|
||||
What *is* shared, with one compose file or five, is the job network: every
|
||||
runner whose config names `dejima-ci` puts its jobs on that bridge. With N
|
||||
runners at `capacity: 1` you get up to N jobs there at once, able to reach each
|
||||
other and each other's service containers.
|
||||
|
||||
- **One shared job network** — the default, and why several runners can share
|
||||
one config file. Every job on it has the same egress allowlist and none can
|
||||
reach the host or the runners, so the added exposure is strictly job-to-job.
|
||||
If the concern is a prompt-injected session reaching *outward*, this already
|
||||
covers it.
|
||||
- **One per runner** — worth it if concurrent jobs come from repositories that
|
||||
should not interfere, or if service containers hold anything a neighbour
|
||||
should not see. Costs a config file and a `docker network create` each, and
|
||||
the firewall rules looped over each bridge.
|
||||
|
||||
The `ci-allow` ipset is shared either way — it holds destinations, not
|
||||
per-bridge state, so one set and one dnsmasq serve every bridge. The wrinkle
|
||||
is DNS: `daemon.json`'s `dns` is a single daemon-wide address that would sit
|
||||
on one bridge and be unreachable from the others. Give dnsmasq a stable
|
||||
address on a dummy interface, point `daemon.json` there, and let each
|
||||
bridge's `INPUT` rule allow port 53 to it.
|
||||
|
||||
## Why the privileged runner is separate
|
||||
|
||||
It is handed the Docker daemon and the host's network namespace, because one
|
||||
job cannot work without them and neither grant can be reduced without breaking
|
||||
it. A job on it can take the machine it runs on — so that machine should exist
|
||||
for nothing else. A separate compose file makes a separate host the default,
|
||||
rather than something to disentangle later.
|
||||
|
||||
Where the two tiers do share a machine, be clear about what that is: **label
|
||||
tiering, not host separation.** A job denied the socket still shares a kernel
|
||||
with a runner that has it. A real reduction over granting everything to
|
||||
everything; not isolation between the tiers.
|
||||
|
||||
It needs no bind for `/tmp/dejima-ci` — the job container gets that path
|
||||
because the workflow asks for it and `container.valid_volumes` permits it, and
|
||||
the host daemon resolves the source host-side.
|
||||
|
||||
## Why public egress is not restricted on the node
|
||||
|
||||
The Proxmox firewall in [proxmox/README.md](../proxmox/README.md) denies the
|
||||
privileged VM every RFC1918 destination and leaves the public internet to the
|
||||
default policy. That asymmetry is deliberate.
|
||||
|
||||
An address allowlist there would break on every CDN rotation. A port allowlist
|
||||
buys nothing: permitting only tcp/80, tcp/443 and udp/123 breaks apt on a first
|
||||
install, and each breakage presents as a network fault rather than as a
|
||||
firewall rule. Anyone who owns that box tunnels over 443 like everybody else,
|
||||
and neither restriction would hold against VM root in any case.
|
||||
|
||||
Deny-by-default earns its keep where the protected set is enumerable, which is
|
||||
the LAN. The internet is not, and treating it as though it were gets you a rule
|
||||
set that looks strict and only gets in the way.
|
||||
|
||||
Note what that leaves. On the privileged tier there is no egress allowlist in
|
||||
front of job containers either — they are in the host's network namespace, so
|
||||
nothing is in front of them. A privileged job reaches the whole public
|
||||
internet by design. What it does not reach is your LAN.
|
||||
|
||||
## Why the resolver is set per-runner, not daemon-wide
|
||||
|
||||
`daemon.json` has a `dns` key, and pointing it at the allowlisting resolver is
|
||||
the obvious way to make job containers use it. It is also wrong here, and the
|
||||
failure is not obvious.
|
||||
|
||||
That setting applies to **every container the daemon starts**. On a host that
|
||||
also runs the privileged tier, that includes containers created by its jobs
|
||||
over the Docker socket — which are not job containers and were never meant to
|
||||
be behind the CI resolver.
|
||||
|
||||
A dejima sandbox is exactly such a container, and it has a firewall of its
|
||||
own. It allowlists precisely one host address: its own bridge gateway, taken
|
||||
from its default route. A resolver on a *different* bridge is therefore
|
||||
rejected by the sandbox's own rules, so nothing inside it resolves — and the
|
||||
symptom is not "DNS is misconfigured" but the sandbox's startup self-check
|
||||
reporting that a host it allows is unreachable.
|
||||
|
||||
`--dns` in `container.options` scopes the resolver to the containers that
|
||||
runner starts, which is the set that should have it.
|
||||
|
||||
## The cache server
|
||||
|
||||
`act_runner` enables it by default, and it binds on the host *specifically* so
|
||||
job containers can reach it — an inbound service exposed to untrusted code. The
|
||||
firewall here blocks exactly that. Leave it off, or add a rule knowing what you
|
||||
are exposing.
|
||||
+7
-2
@@ -24,8 +24,13 @@ ipset = ci-allow
|
||||
# The forge's API root. Read-only access is all this needs.
|
||||
forge_api = https://gitea.example.com/api/v1
|
||||
|
||||
# A token file, mode 0600, owned by root. Give it read access and nothing
|
||||
# else - this program only ever GETs one file per repository.
|
||||
# A token file, mode 0600, owned by root. A forge API token, from the forge's
|
||||
# Settings -> Applications page, with read access to repository contents and
|
||||
# nothing else - this program only ever GETs one file per repository.
|
||||
#
|
||||
# NOT a runner registration token. That is the string from Actions -> Runners
|
||||
# that registers a runner, which dejima-ci-runners asks for separately. The API
|
||||
# rejects it with a 401, and nothing about the file says which kind it holds.
|
||||
token_file = /etc/dejima-ci/forge-token
|
||||
|
||||
# One owner/repo per line. These are the repositories whose declarations are
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# One owner/repo per line. Adding a line trusts that repository's main branch
|
||||
# to widen this runner's egress allowlist.
|
||||
branderson/dejima
|
||||
dejima/dejima
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# act_runner configuration for a runner that executes agent-authored code and
|
||||
# is granted nothing to execute it with. This is the ordinary tier: everything
|
||||
# is granted nothing to execute it with. This is the confined tier: everything
|
||||
# except the one job that genuinely needs a Docker daemon belongs here.
|
||||
#
|
||||
# Written against act_runner v0.6.1. Check yours with `act_runner --version`,
|
||||
@@ -7,10 +7,16 @@
|
||||
# defaults are what most of this is overriding, and a new release can change
|
||||
# one without anything in a job's output looking different.
|
||||
#
|
||||
# Not runnable as written: pick your own network name, subnet and label, and
|
||||
# see ci/README.md for the order things have to be created in.
|
||||
# @@NOTE_BEGIN@@
|
||||
# TEMPLATE. `dejima-ci-runners` renders this to ~/runners/config.yaml,
|
||||
# substituting the @@MARKER@@ values from what install.sh already knows and
|
||||
# what it asks you. Those are exactly the values that have to agree with
|
||||
# something else on the host, which is why they are not left for a person to
|
||||
# keep in step. This header is replaced in the rendered copy.
|
||||
# @@NOTE_END@@
|
||||
#
|
||||
# See docs/ci.md for why each of these is here.
|
||||
# See dejima's docs/ci.md for why each of these is here:
|
||||
# https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md
|
||||
|
||||
log:
|
||||
level: info
|
||||
@@ -31,7 +37,7 @@ runner:
|
||||
# Deliberately NOT ubuntu-latest or any other generic label. A generic label
|
||||
# is an invitation for any repository on the instance to schedule work here.
|
||||
labels:
|
||||
- "dejima-ci:docker://node:20-bookworm"
|
||||
- "@@LABEL@@:docker://@@JOB_IMAGE@@"
|
||||
|
||||
# Register at REPOSITORY scope, not instance scope (that is a choice made at
|
||||
# `act_runner register` time, not here): an unrelated repository should not
|
||||
@@ -72,11 +78,11 @@ container:
|
||||
# A network created ahead of time, with a named bridge, so that the
|
||||
# interface name and gateway are stable enough to write firewall rules
|
||||
# against. Left empty, act_runner creates one per job and there is nothing
|
||||
# fixed to match on. See ci/iptables-ci.example.sh.
|
||||
# fixed to match on. See bin/dejima-ci-firewall.
|
||||
#
|
||||
# NOT `host`: that would put jobs in this machine's network namespace, where
|
||||
# no bridge-based filtering applies to them at all.
|
||||
network: dejima-ci
|
||||
network: "@@NETWORK@@"
|
||||
|
||||
privileged: false
|
||||
|
||||
@@ -97,7 +103,7 @@ container:
|
||||
# rejected by its own firewall and nothing inside it resolves at all.
|
||||
#
|
||||
# Must match listen-address in the dnsmasq config.
|
||||
options: "--dns=203.0.113.1 --init --security-opt=no-new-privileges --cap-drop=NET_ADMIN --cap-drop=NET_RAW --cap-drop=SYS_ADMIN --cap-drop=SYS_MODULE --cap-drop=SYS_PTRACE --pids-limit=2048"
|
||||
options: "--dns=@@GATEWAY@@ --init --security-opt=no-new-privileges --cap-drop=NET_ADMIN --cap-drop=NET_RAW --cap-drop=SYS_ADMIN --cap-drop=SYS_MODULE --cap-drop=SYS_PTRACE --pids-limit=2048"
|
||||
|
||||
# Empty is the default and it is the right value: no host path can be
|
||||
# mounted into a job container. Stated explicitly so that adding one has to
|
||||
@@ -14,14 +14,20 @@
|
||||
# - holds no credentials beyond its own registration token,
|
||||
# - is rebuilt from a snapshot on a schedule.
|
||||
#
|
||||
# Sharing a machine with the ordinary runner (ci/act-runner-ci.example.yaml)
|
||||
# Sharing a machine with the confined runner (examples/act-runner-confined.yaml)
|
||||
# gives you label tiering, not host separation: a job denied the socket still
|
||||
# shares a kernel and a network with a runner that has it. That is a real
|
||||
# reduction and worth doing if one machine is what you have - just do not read
|
||||
# it as isolation.
|
||||
#
|
||||
# Written against act_runner v0.6.1. Not runnable as written.
|
||||
# See docs/ci.md.
|
||||
# @@NOTE_BEGIN@@
|
||||
# TEMPLATE. `dejima-ci-runners` renders this to ~/runners/config.yaml,
|
||||
# substituting the @@MARKER@@ values. This header is replaced in the rendered
|
||||
# copy.
|
||||
# @@NOTE_END@@
|
||||
#
|
||||
# Written against act_runner v0.6.1. See dejima's docs/ci.md:
|
||||
# https://gitbox.branderson.io/dejima/dejima/src/branch/main/docs/ci.md
|
||||
|
||||
log:
|
||||
level: info
|
||||
@@ -35,7 +41,7 @@ runner:
|
||||
# asking for them - and a job that never asked is exactly the one nobody
|
||||
# reviews for whether it should have them.
|
||||
labels:
|
||||
- "dejima-runtime:docker://node:20-bookworm"
|
||||
- "@@LABEL@@:docker://@@JOB_IMAGE@@"
|
||||
|
||||
envs: {}
|
||||
env_file: ""
|
||||
@@ -46,7 +52,7 @@ cache:
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
# Left unset ON PURPOSE, which is the opposite of the ordinary runner. Unset
|
||||
# Left unset ON PURPOSE, which is the opposite of the confined runner. Unset
|
||||
# means act_runner mounts /var/run/docker.sock into job containers, and the
|
||||
# job here builds an image and boots real containers over it.
|
||||
#
|
||||
@@ -80,8 +86,10 @@ container:
|
||||
#
|
||||
# A volume a workflow asks for and this list does not name is dropped
|
||||
# silently, so this has to match what the workflow requests.
|
||||
# @@VOLUMES_BEGIN@@
|
||||
valid_volumes:
|
||||
- /tmp/dejima-ci
|
||||
# @@VOLUMES_END@@
|
||||
|
||||
force_pull: true
|
||||
force_rebuild: false
|
||||
@@ -1,5 +1,5 @@
|
||||
# The static half of the job network's resolver. Install as
|
||||
# /etc/dnsmasq.d/dejima-ci.conf - see "Where each file goes" in ci/README.md.
|
||||
# /etc/dnsmasq.d/ci-allow.conf - written by install.sh. See this repository's README.
|
||||
#
|
||||
# There are no `server=` lines here on purpose. Every allowed name is rendered
|
||||
# into a SECOND file, /etc/dnsmasq.d/ci-allow.conf, by
|
||||
@@ -16,7 +16,7 @@
|
||||
# off encoding data into query names to a domain you never allowed.
|
||||
#
|
||||
# Not runnable as written: the addresses below are RFC 5737 documentation
|
||||
# ranges. Substitute your own, and read the subnet note in ci/README.md before
|
||||
# ranges. Substitute your own, and read the subnet note in etc/config.example before
|
||||
# picking one - "private" is load-bearing here, not tidiness.
|
||||
|
||||
# bind-dynamic, not bind-interfaces. Both keep this resolver off every other
|
||||
@@ -52,7 +52,7 @@ ipset=/#/ci-allow
|
||||
# never on any list.
|
||||
#
|
||||
# This is keyed on the private ranges, which is one of the reasons the job
|
||||
# bridge itself should use one - see ci/README.md.
|
||||
# bridge itself should use one - see this repository's README.
|
||||
stop-dns-rebind
|
||||
|
||||
# A self-hosted forge legitimately resolves to a private address, which
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# The ordinary runners: several on one host, sharing one config file and
|
||||
# The confined runners: several on one host, sharing one config file and
|
||||
# therefore one label, so a job naming that label lands on whichever is free.
|
||||
#
|
||||
# The privileged runner is NOT here. It has its own compose file, because it
|
||||
# belongs on its own host - see ci/docker-compose-runtime.example.yml.
|
||||
# belongs on its own host - see examples/docker-compose-privileged.yml.
|
||||
#
|
||||
# The thing to understand before reading the rest: compose's networks and the
|
||||
# job network are different worlds. Every service below is an act_runner
|
||||
@@ -10,14 +10,17 @@
|
||||
# created by the Docker daemon, on the network named by `container.network` in
|
||||
# each runner's config.yaml, and compose knows nothing about them. Attaching a
|
||||
# service here to a network does not put its jobs there, and vice versa. See
|
||||
# ci/README.md for what that means for isolating jobs from each other.
|
||||
# docs/reference.md for what that means for isolating jobs from each other.
|
||||
#
|
||||
# Not runnable as written: substitute your own instance URL, image tag and
|
||||
# subnet, and create the job network first (ci/README.md, step 2).
|
||||
# @@NOTE_BEGIN@@
|
||||
# TEMPLATE. `dejima-ci-runners` renders this to ~/runners/docker-compose.yml,
|
||||
# substituting the @@MARKER@@ values and repeating the fenced service block
|
||||
# once per runner you asked for. This header is replaced in the rendered copy.
|
||||
# @@NOTE_END@@
|
||||
|
||||
name: dejima-runners
|
||||
name: "@@PROJECT@@"
|
||||
|
||||
# Shared by every ordinary runner. Volumes are per-service rather than here,
|
||||
# Shared by every confined runner. Volumes are per-service rather than here,
|
||||
# because each runner needs its own /data - and YAML merge keys merge mappings
|
||||
# but not lists, so a volumes: in a service replaces this one rather than
|
||||
# adding to it. Repeating them is the honest form.
|
||||
@@ -25,7 +28,7 @@ x-ci-runner: &ci-runner
|
||||
# Pin the tag. These processes execute untrusted code on every pull request,
|
||||
# and :latest means the thing doing that can change without you deciding it
|
||||
# should.
|
||||
image: gitea/act_runner:0.6.1
|
||||
image: "@@IMAGE@@"
|
||||
restart: unless-stopped
|
||||
networks: [runners]
|
||||
|
||||
@@ -35,7 +38,7 @@ x-ci-env: &ci-env
|
||||
# every job container and nothing anywhere says so. If you change one thing
|
||||
# in a config file and see no effect, check this first.
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_INSTANCE_URL: https://gitea.example.com
|
||||
GITEA_INSTANCE_URL: "@@INSTANCE_URL@@"
|
||||
# Needed only until each runner has registered; the identity then lives in
|
||||
# that runner's own /data volume. Comes from a .env file beside this one
|
||||
# (chmod 600) so it is not in the file you commit, and is worth deleting
|
||||
@@ -44,49 +47,37 @@ x-ci-env: &ci-env
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
|
||||
|
||||
services:
|
||||
# --- ordinary runners -----------------------------------------------------
|
||||
# All three share one config file, and therefore one label and one job
|
||||
# network. That is what makes them a pool: a job naming dejima-ci lands on
|
||||
# whichever is free. Only the name and the data volume differ.
|
||||
ci-1:
|
||||
# --- confined runners -----------------------------------------------------
|
||||
# Every one of them shares this directory's config.yaml, and therefore one
|
||||
# label and one job network. That is what makes them a pool: a job naming
|
||||
# @@LABEL@@ lands on whichever is free. Only the name and the data volume
|
||||
# differ.
|
||||
#
|
||||
# Those names come from @@NAME@@, which identifies this GROUP of runners on
|
||||
# this host, rather than from the label. The two are separate on purpose: a
|
||||
# host can carry a second group registered with a different token - another
|
||||
# organization, or a user account - and both may legitimately answer the
|
||||
# same label. Sharing an identity as well would collide on the compose
|
||||
# project, the container names and the volumes.
|
||||
# @@SERVICE_BEGIN@@
|
||||
"@@NAME@@-@@N@@":
|
||||
<<: *ci-runner
|
||||
container_name: dejima-ci-1
|
||||
container_name: "@@NAME@@-@@N@@"
|
||||
environment:
|
||||
<<: *ci-env
|
||||
GITEA_RUNNER_NAME: dejima-ci-1
|
||||
GITEA_RUNNER_NAME: "@@NAME@@-@@N@@"
|
||||
volumes:
|
||||
# The runner's OWN access to the daemon, so it can create job
|
||||
# containers. NOT the same setting as container.docker_host, which
|
||||
# decides whether job containers get the socket - see ci/README.md.
|
||||
# decides whether job containers get the socket - see docs/reference.md.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Read-only: a runner has no reason to rewrite the file deciding what
|
||||
# its jobs are granted.
|
||||
- ./act-runner-ci.yaml:/config.yaml:ro
|
||||
- ./config.yaml:/config.yaml:ro
|
||||
# Its registration identity. One per runner, never shared - two runners
|
||||
# cannot share a .runner.
|
||||
- ci-1-data:/data
|
||||
|
||||
ci-2:
|
||||
<<: *ci-runner
|
||||
container_name: dejima-ci-2
|
||||
environment:
|
||||
<<: *ci-env
|
||||
GITEA_RUNNER_NAME: dejima-ci-2
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./act-runner-ci.yaml:/config.yaml:ro
|
||||
- ci-2-data:/data
|
||||
|
||||
ci-3:
|
||||
<<: *ci-runner
|
||||
container_name: dejima-ci-3
|
||||
environment:
|
||||
<<: *ci-env
|
||||
GITEA_RUNNER_NAME: dejima-ci-3
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./act-runner-ci.yaml:/config.yaml:ro
|
||||
- ci-3-data:/data
|
||||
- "@@NAME@@-@@N@@-data:/data"
|
||||
# @@SERVICE_END@@
|
||||
|
||||
networks:
|
||||
# The runner containers' own network, and nothing else's. They need it only
|
||||
@@ -96,7 +87,7 @@ networks:
|
||||
# would protect nothing.
|
||||
#
|
||||
# The job network is deliberately absent. It is created by hand with a fixed
|
||||
# bridge name so the firewall can match on it (ci/README.md, step 2), and
|
||||
# bridge name so the firewall can match on it (install.sh does this), and
|
||||
# act_runner attaches job containers to it by name. Declaring it here would
|
||||
# invite attaching a service to it, which is exactly what must not happen: a
|
||||
# process holding the Docker socket has no business sharing a network with
|
||||
@@ -104,6 +95,6 @@ networks:
|
||||
runners:
|
||||
|
||||
volumes:
|
||||
ci-1-data:
|
||||
ci-2-data:
|
||||
ci-3-data:
|
||||
# @@VOLUME_BEGIN@@
|
||||
"@@NAME@@-@@N@@-data":
|
||||
# @@VOLUME_END@@
|
||||
@@ -9,7 +9,7 @@
|
||||
# nothing else: no other service, no route to a LAN or management network, no
|
||||
# credentials beyond its own registration token, rebuilt from a snapshot on a
|
||||
# schedule. That is why this is a separate file rather than another service
|
||||
# alongside the ordinary runners - a separate host is the intended
|
||||
# alongside the confined runners - a separate host is the intended
|
||||
# arrangement, and a compose file that assumes it is harder to drift away from
|
||||
# than one that has to be split later.
|
||||
#
|
||||
@@ -18,19 +18,22 @@
|
||||
# with a runner that has it. That is a real reduction over granting everything
|
||||
# to everything, and it is not isolation between the tiers.
|
||||
#
|
||||
# Pair with ci/act-runner-runtime.example.yaml. Not runnable as written:
|
||||
# substitute your own instance URL and image tag.
|
||||
# @@NOTE_BEGIN@@
|
||||
# TEMPLATE. `dejima-ci-runners` renders this to ~/runners/docker-compose.yml,
|
||||
# substituting the @@MARKER@@ values. This header is replaced in the rendered
|
||||
# copy.
|
||||
# @@NOTE_END@@
|
||||
|
||||
name: dejima-runtime
|
||||
name: "@@PROJECT@@"
|
||||
|
||||
services:
|
||||
runtime:
|
||||
"@@NAME@@":
|
||||
# Pin the tag. This process executes untrusted code on every pull request,
|
||||
# and :latest means the thing doing that can change without you deciding
|
||||
# it should.
|
||||
image: gitea/act_runner:0.6.1
|
||||
image: "@@IMAGE@@"
|
||||
restart: unless-stopped
|
||||
container_name: dejima-runtime
|
||||
container_name: "@@NAME@@"
|
||||
networks: [runners]
|
||||
|
||||
environment:
|
||||
@@ -39,8 +42,8 @@ services:
|
||||
# resemble the intended configuration closely enough that the mistake is
|
||||
# invisible. Check it first when an edit appears to do nothing.
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_INSTANCE_URL: https://gitea.example.com
|
||||
GITEA_RUNNER_NAME: dejima-runtime
|
||||
GITEA_INSTANCE_URL: "@@INSTANCE_URL@@"
|
||||
GITEA_RUNNER_NAME: "@@NAME@@"
|
||||
# Needed only until registration completes; the identity then lives in
|
||||
# the volume below. Comes from a .env file beside this one (chmod 600)
|
||||
# so it is not in the file you commit, and is worth removing once
|
||||
@@ -52,12 +55,12 @@ services:
|
||||
# The runner's own access to the daemon, so it can create job
|
||||
# containers. On this tier the config file ALSO passes the socket into
|
||||
# those containers, which is the grant that makes this host sacrificial;
|
||||
# on the ordinary tier it does not. Same bind, different consequence,
|
||||
# on the confined tier it does not. Same bind, different consequence,
|
||||
# decided entirely by container.docker_host in the config.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./act-runner-runtime.yaml:/config.yaml:ro
|
||||
- ./config.yaml:/config.yaml:ro
|
||||
# Its own registration identity. Never shared with another runner.
|
||||
- runtime-data:/data
|
||||
- "@@NAME@@-data:/data"
|
||||
# No bind for /tmp/dejima-ci. The job container gets that path because
|
||||
# the workflow asks for it and container.valid_volumes permits it, and
|
||||
# the host daemon resolves the source host-side. This container never
|
||||
@@ -71,4 +74,4 @@ networks:
|
||||
runners:
|
||||
|
||||
volumes:
|
||||
runtime-data:
|
||||
"@@NAME@@-data":
|
||||
+322
-78
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Install dejima-ci-allowlist and walk through its configuration.
|
||||
#
|
||||
# Idempotent: re-running and pressing Enter at every prompt changes nothing.
|
||||
@@ -15,6 +18,7 @@ BIN_DIR=${BIN_DIR:-/usr/local/sbin}
|
||||
CONF_DIR=${CONF_DIR:-/etc/dejima-ci}
|
||||
UNIT_DIR=${UNIT_DIR:-/etc/systemd/system}
|
||||
DNSMASQ_DIR=${DNSMASQ_DIR:-/etc/dnsmasq.d}
|
||||
TEMPLATE_DIR=${TEMPLATE_DIR:-/usr/local/share/dejima-ci/templates}
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
CONFIG="$CONF_DIR/config"
|
||||
@@ -95,6 +99,78 @@ cfg_get() {
|
||||
done < "$CONFIG"
|
||||
}
|
||||
|
||||
# ask, defaulting to the config file's value and falling back to a built-in
|
||||
# when there is none.
|
||||
#
|
||||
# The fallback is passed to ask rather than applied to its answer afterwards,
|
||||
# and that is the whole point: ask only prints [brackets] when it is given a
|
||||
# non-empty default, so a fallback applied after the fact is invisible. On a
|
||||
# first install cfg_get returns nothing for every one of these, so each prompt
|
||||
# appeared bare - asking for a path, a branch and a shell command with no
|
||||
# indication that pressing Enter would produce a working answer, or what it
|
||||
# would be.
|
||||
ask_cfg() {
|
||||
local __var="$1" prompt="$2" key="$3" fallback="$4" def
|
||||
def="$(cfg_get "$key")"
|
||||
[ -n "$def" ] || def="$fallback"
|
||||
ask "$__var" "$prompt" "$def"
|
||||
}
|
||||
|
||||
# Collect a list, with a way to take an entry back and a look at the whole of
|
||||
# it before it is written.
|
||||
#
|
||||
# Entry used to be one-way: a mistyped line was accepted in silence, and the
|
||||
# only ways out were to live with it or abandon the run and start the installer
|
||||
# again. Neither is proportionate to a typo, and the list this reads decides
|
||||
# which repositories may widen a runner's allowlist - somewhere a wrong entry
|
||||
# should be easy to notice and cheap to undo.
|
||||
read_list() { # read_list VAR PROMPT VALIDATOR
|
||||
local __var="$1" prompt="$2" validate="$3" acc="" one ok removed
|
||||
while :; do
|
||||
acc=""
|
||||
say "One per line. '-' takes back the last one. Blank line to finish."
|
||||
while :; do
|
||||
ask one " $prompt" ""
|
||||
case "$one" in
|
||||
"") break ;;
|
||||
-) if [ -n "$acc" ]; then
|
||||
removed="${acc##*$'\n'}"
|
||||
if [ "$removed" = "$acc" ]; then acc=""; else acc="${acc%$'\n'*}"; fi
|
||||
say " took back $removed"
|
||||
else
|
||||
say " nothing to take back"
|
||||
fi
|
||||
continue ;;
|
||||
esac
|
||||
"$validate" "$one" || continue
|
||||
acc="${acc:+$acc$'\n'}$one"
|
||||
done
|
||||
if [ -z "$acc" ]; then printf -v "$__var" '%s' ""; return 0; fi
|
||||
say
|
||||
say " That is:"
|
||||
sed 's/^/ /' <<< "$acc" >&2
|
||||
say
|
||||
ask ok "Is that right? (Y/n)" "y"
|
||||
case "${ok,,}" in n|no) say; continue ;; esac
|
||||
printf -v "$__var" '%s' "$acc"
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
# Checked on the way in, so a malformed line is rejected while the person who
|
||||
# typed it is still looking at it. Neither of these can tell a real repository
|
||||
# from an imagined one - only the forge can - but both catch the shape.
|
||||
valid_repo_entry() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] && return 0
|
||||
say " not an owner/repo - e.g. acme/widget"
|
||||
return 1
|
||||
}
|
||||
valid_domain_entry() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] && return 0
|
||||
say " not a domain name - e.g. deb.debian.org"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The upstream resolver is the setting people get wrong, so offer what this
|
||||
# host already uses rather than making them go and find it.
|
||||
detect_upstream() {
|
||||
@@ -117,9 +193,25 @@ for tool in ipset iptables ip6tables dnsmasq docker; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
|
||||
done
|
||||
[ -z "$missing" ] || die "missing required commands:$missing
|
||||
On Debian/Ubuntu: apt-get install ipset iptables dnsmasq
|
||||
|
||||
On a host built for this, run the bootstrap instead of this - it installs
|
||||
these, hardens the host, and then runs this script itself:
|
||||
|
||||
sudo $SRC/bin/dejima-ci-bootstrap --role confined --mgmt <your-ip>
|
||||
|
||||
Running this on its own is for changing a setting afterwards, on a host that
|
||||
already has what it needs.
|
||||
|
||||
If you are deliberately not using the bootstrap, install them yourself. Note
|
||||
that dejima-ci-runners will want Docker Compose v2 as well, and compose has no
|
||||
single package name that works across releases: docker-compose-v2 does not
|
||||
exist before trixie, and the fallbacks are backports and then Docker's own
|
||||
repository. This installer fetches nothing on your behalf - adding a Docker
|
||||
daemon to someone's machine is not a thing to do without being asked.
|
||||
|
||||
Without ipset the firewall cannot be applied, and a runner with no egress
|
||||
policy looks exactly like one with."
|
||||
policy looks exactly like one with. Without docker there is no job network to
|
||||
create, which is the first thing this does."
|
||||
|
||||
say "dejima-ci-hardener installer"
|
||||
say
|
||||
@@ -135,7 +227,7 @@ say "=============================================================="
|
||||
say
|
||||
say "A Docker network for job containers to run on, which this installer will"
|
||||
say "create. Name it whatever you like - it only has to match container.network"
|
||||
say "in each ordinary runner's config.yaml."
|
||||
say "in each confined runner's config.yaml."
|
||||
say
|
||||
say "A runner configured for host networking (container.network: host) needs"
|
||||
say "no job network - its jobs are in the host's own namespace, so no bridge"
|
||||
@@ -145,8 +237,7 @@ say "machine a firewall of its own, or treat it as one you are willing to lose."
|
||||
say "Set this up for the runners that do use a bridge, and skip it for those."
|
||||
say
|
||||
|
||||
ask network "Job network name (e.g. dejima-ci)" "$(cfg_get network)"
|
||||
: "${network:=dejima-ci}"
|
||||
ask_cfg network "Job network name" network dejima-ci
|
||||
say
|
||||
|
||||
# Derived, not asked. Docker takes the first usable address of the subnet as
|
||||
@@ -224,8 +315,7 @@ else
|
||||
say "(172.17-172.31, 192.168.x) so an unrelated network cannot collide."
|
||||
say
|
||||
while :; do
|
||||
ask subnet "Subnet in CIDR form (e.g. 10.89.0.0/24)" "${subnet:-$(cfg_get subnet)}"
|
||||
: "${subnet:=10.89.0.0/24}"
|
||||
ask_cfg subnet "Subnet in CIDR form" subnet 10.89.0.0/24
|
||||
valid_cidr "$subnet" || { say " not a CIDR block - it needs the form 10.89.0.0/24"; subnet=""; continue; }
|
||||
is_private "$subnet" || { say " $subnet is not a private range - use 10.x, 172.16-31.x or 192.168.x"; subnet=""; continue; }
|
||||
break
|
||||
@@ -306,6 +396,7 @@ probe_name="${instance#*://}"; probe_name="${probe_name%%/*}"; probe_name="${pro
|
||||
: "${probe_name:=deb.debian.org}"
|
||||
|
||||
while :; do
|
||||
say
|
||||
ask upstream "Upstream resolver address" "${existing_upstream:-$(detect_upstream)}"
|
||||
[ -n "$upstream" ] || die "an upstream resolver is required"
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
@@ -326,14 +417,37 @@ while :; do
|
||||
say " this setting."
|
||||
say
|
||||
say " Servers this host has configured:"
|
||||
{ resolvectl status 2>/dev/null | sed -n 's/.*DNS Servers: */ /p'
|
||||
sed -n 's/^nameserver/ /p' /etc/resolv.conf 2>/dev/null; } | sort -u >&2
|
||||
# resolvectl guarded by command -v, and the whole thing allowed to fail.
|
||||
# Without the guard this aborted the installer outright on any host that
|
||||
# does not have it - which is most of them, including a VM built by
|
||||
# proxmox/README.md. `set -o pipefail` makes the inner pipeline return the
|
||||
# 127 from a missing resolvectl, `set -e` acts on it, and the run ends on
|
||||
# exit 127 with no message, immediately after printing this heading. A
|
||||
# diagnostic that kills the program it is diagnosing is worse than no
|
||||
# diagnostic.
|
||||
{
|
||||
if command -v resolvectl >/dev/null 2>&1; then
|
||||
resolvectl status 2>/dev/null | sed -n 's/.*DNS Servers: */ /p' || true
|
||||
fi
|
||||
sed -n 's/^nameserver/ /p' /etc/resolv.conf 2>/dev/null || true
|
||||
} | sort -u >&2 || true
|
||||
say
|
||||
ask retry_upstream "Try a different address? (Y/n)" "y"
|
||||
|
||||
# Non-interactive cannot answer, and the default it would take used to be
|
||||
# "retry" - which re-offered the same detected address, failed the same
|
||||
# probe, and looped forever with no terminal to say so on.
|
||||
if [ "$NONINTERACTIVE" -eq 1 ]; then
|
||||
say " continuing with $upstream - fix it before expecting jobs to resolve"
|
||||
break
|
||||
fi
|
||||
# Defaults to no, so Enter moves on. Defaulting to yes meant that pressing
|
||||
# Enter - which this installer tells you to do to keep the value shown -
|
||||
# asked the same question about the same address indefinitely.
|
||||
ask retry_upstream "Try a different address? (y/N)" "n"
|
||||
case "${retry_upstream,,}" in
|
||||
n|no) say " continuing with $upstream - fix it before expecting jobs to resolve"; break ;;
|
||||
y|yes) existing_upstream="" ;;
|
||||
*) say " continuing with $upstream - fix it before expecting jobs to resolve"; break ;;
|
||||
esac
|
||||
existing_upstream=""
|
||||
done
|
||||
case "$upstream" in
|
||||
127.0.0.53|127.0.0.1|::1)
|
||||
@@ -347,16 +461,30 @@ say
|
||||
# --- token ------------------------------------------------------------------
|
||||
if [ -s "$TOKEN_FILE" ]; then
|
||||
say "A forge token is already installed at $TOKEN_FILE."
|
||||
say
|
||||
ask replace_token "Replace it? (y/N)" "n"
|
||||
else
|
||||
replace_token=y
|
||||
say "A forge token is needed. Read access only - this program only ever GETs"
|
||||
say "one file per repository, and a token that can do more is a token handed"
|
||||
say "to a machine that runs untrusted code."
|
||||
say "A forge API token is needed, so this can read .dejima/allowed-domains"
|
||||
say "from the main branch of each repository listed below."
|
||||
say
|
||||
say "Create one here:"
|
||||
say " $instance/user/settings/applications"
|
||||
say
|
||||
say "Give it read access to repository contents and nothing else. This only"
|
||||
say "ever GETs one file per repository, and a token that can do more is a"
|
||||
say "token handed to a machine that runs untrusted code."
|
||||
say
|
||||
say "THIS IS NOT THE RUNNER REGISTRATION TOKEN. That is a different string"
|
||||
say "from a different page - Actions -> Runners - used once to register a"
|
||||
say "runner, and the API rejects it with a 401. dejima-ci-runners asks for"
|
||||
say "that one separately, later. The two are easy to swap and the only"
|
||||
say "symptom is this program failing to read anything."
|
||||
fi
|
||||
if [ "${replace_token,,}" = y ] || [ "${replace_token,,}" = yes ]; then
|
||||
token=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
say
|
||||
printf 'Forge token (not echoed): ' >&2
|
||||
IFS= read -rs token </dev/tty || true
|
||||
printf '\n' >&2
|
||||
@@ -384,6 +512,7 @@ existing_repos=""
|
||||
[ -r "$REPOS_FILE" ] && existing_repos="$(grep -vE '^\s*(#|$)' "$REPOS_FILE" || true)"
|
||||
if [ -n "$existing_repos" ]; then
|
||||
say "Currently:"; sed 's/^/ /' <<< "$existing_repos" >&2
|
||||
say
|
||||
ask keep_repos "Keep this list? (Y/n)" "y"
|
||||
else
|
||||
keep_repos=n
|
||||
@@ -391,11 +520,7 @@ fi
|
||||
if [ "${keep_repos,,}" = n ] || [ "${keep_repos,,}" = no ]; then
|
||||
repos=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
while :; do
|
||||
ask one " owner/repo" ""
|
||||
[ -n "$one" ] || break
|
||||
repos="${repos:+$repos$'\n'}$one"
|
||||
done
|
||||
read_list repos "owner/repo" valid_repo_entry
|
||||
fi
|
||||
[ -n "$repos" ] || repos="$existing_repos"
|
||||
write_if_changed "$REPOS_FILE" 0640 "$repos"
|
||||
@@ -404,6 +529,96 @@ else
|
||||
fi
|
||||
say
|
||||
|
||||
# --- does the forge actually accept that token? -----------------------------
|
||||
# The token is the one setting here that cannot be judged by looking at it. A
|
||||
# wrong one and a right one are both a non-empty 0600 file; only the forge can
|
||||
# tell them apart, and it is asked here rather than at the end so that a
|
||||
# rejected token is fixed while it is still the thing you are doing - not after
|
||||
# the units are enabled and the runners are registered and running.
|
||||
#
|
||||
# Probed against the first configured repository, using the same request the
|
||||
# allowlist will make. 200 and 404 both mean the token authenticated: 404 is a
|
||||
# repository with no declaration, which is ordinary.
|
||||
token_status() { # token_status TOKEN REPO -> HTTP status, or 000 if unreachable
|
||||
curl -sS --max-time 20 -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $1" \
|
||||
"$forge_api/repos/$2/raw/.dejima/allowed-domains?ref=main" 2>/dev/null \
|
||||
|| printf '000'
|
||||
}
|
||||
|
||||
probe_repo=$(grep -vE '^\s*(#|$)' "$REPOS_FILE" 2>/dev/null | head -1 || true)
|
||||
if command -v curl >/dev/null 2>&1 && [ -s "$TOKEN_FILE" ] && [ -n "$probe_repo" ]; then
|
||||
while :; do
|
||||
status=$(token_status "$(cat "$TOKEN_FILE")" "$probe_repo")
|
||||
case "$status" in
|
||||
200|404) say " the forge accepted the token."; break ;;
|
||||
esac
|
||||
say
|
||||
case "$status" in
|
||||
401) say " The forge REJECTED the token (401)."
|
||||
say
|
||||
say " Check first that it is an API token. A RUNNER REGISTRATION token"
|
||||
say " - from Actions -> Runners, the one dejima-ci-runners asks for -"
|
||||
say " is a different string that the API rejects exactly like this, and"
|
||||
say " the two are asked for minutes apart by this same tool."
|
||||
say
|
||||
say " An API token comes from:"
|
||||
say " $instance/user/settings/applications"
|
||||
say
|
||||
say " Otherwise it is present but wrong: mistyped or truncated when"
|
||||
say " pasted, revoked, or expired." ;;
|
||||
403) say " The forge accepted the token but refused the read (403)."
|
||||
say " It authenticates, so this is scope rather than identity - a Gitea"
|
||||
say " token created with no scopes selected reads nothing. It needs read"
|
||||
say " access to repository contents, and nothing more." ;;
|
||||
30[1278])
|
||||
say " The forge redirected ($status) for $probe_repo, which is"
|
||||
say " about that repository rather than about the token: it has"
|
||||
say " been renamed or moved to another owner."
|
||||
say
|
||||
say " Fix the entry in $REPOS_FILE - the rest of this"
|
||||
say " run is unaffected, and re-running this installer or"
|
||||
say " 'sudo $BIN_DIR/dejima-ci-allowlist render' picks it up." ;;
|
||||
000) say " Could not reach $forge_api to check the token."
|
||||
say " That is the network or the forge, not the token itself." ;;
|
||||
*) say " The forge answered $status when checking the token against"
|
||||
say " $probe_repo." ;;
|
||||
esac
|
||||
say
|
||||
say " Until this is fixed the allowlist cannot be rendered, and job"
|
||||
say " containers get a resolver that answers for nothing."
|
||||
say
|
||||
if [ "$NONINTERACTIVE" -eq 1 ]; then
|
||||
say " Continuing - fix the token and run: sudo $BIN_DIR/dejima-ci-allowlist render"
|
||||
break
|
||||
fi
|
||||
case "$status" in
|
||||
30[1278]) say " Continuing. Fix $REPOS_FILE, then run:"
|
||||
say " sudo $BIN_DIR/dejima-ci-allowlist render"
|
||||
break ;;
|
||||
esac
|
||||
ask retry_token "Enter a different token now? (Y/n)" "y"
|
||||
case "${retry_token,,}" in
|
||||
n|no) say " Continuing. Fix $TOKEN_FILE, then run:"
|
||||
say " $BIN_DIR/dejima-ci-allowlist render"
|
||||
break ;;
|
||||
esac
|
||||
printf 'Forge token (not echoed): ' >&2
|
||||
IFS= read -rs newtok </dev/tty || true
|
||||
printf '\n' >&2
|
||||
if [ -z "$newtok" ]; then
|
||||
say " Nothing entered - leaving the current token in place."
|
||||
break
|
||||
fi
|
||||
printf '%s\n' "$newtok" > "$TOKEN_FILE.new"
|
||||
chmod 600 "$TOKEN_FILE.new"
|
||||
mv "$TOKEN_FILE.new" "$TOKEN_FILE"
|
||||
unset newtok
|
||||
say " written $TOKEN_FILE"
|
||||
done
|
||||
say
|
||||
fi
|
||||
|
||||
# --- base domains -----------------------------------------------------------
|
||||
say "Names allowed regardless of any project declaration. Keep this short, and"
|
||||
say "narrower than dejima's built-in domains - a name judged reasonable for a"
|
||||
@@ -414,20 +629,17 @@ existing_base=""
|
||||
default_base="${existing_base:-$forge_host$'\n'deb.debian.org}"
|
||||
if [ -n "$existing_base" ]; then
|
||||
say "Currently:"; sed 's/^/ /' <<< "$existing_base" >&2
|
||||
say
|
||||
ask keep_base "Keep this list? (Y/n)" "y"
|
||||
else
|
||||
say "Suggested:"; sed 's/^/ /' <<< "$default_base" >&2
|
||||
say
|
||||
ask keep_base "Use this list? (Y/n)" "y"
|
||||
fi
|
||||
if [ "${keep_base,,}" = n ] || [ "${keep_base,,}" = no ]; then
|
||||
base=""
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
say "One name per line, blank line to finish."
|
||||
while :; do
|
||||
ask one " domain" ""
|
||||
[ -n "$one" ] || break
|
||||
base="${base:+$base$'\n'}$one"
|
||||
done
|
||||
read_list base "domain" valid_domain_entry
|
||||
fi
|
||||
[ -n "$base" ] || base="$default_base"
|
||||
else
|
||||
@@ -438,12 +650,12 @@ say
|
||||
|
||||
# --- remaining settings -----------------------------------------------------
|
||||
ipset="$(cfg_get ipset)"; : "${ipset:=ci-allow}"
|
||||
ask out "Generated allowlist path" "$(cfg_get out)"
|
||||
ask ref "Branch treated as operator-approved" "$(cfg_get ref)"
|
||||
ask reload "Command that reloads the resolver" "$(cfg_get reload)"
|
||||
: "${out:=/etc/dnsmasq.d/ci-allow.conf}"
|
||||
: "${ref:=main}"
|
||||
: "${reload:=systemctl reload dnsmasq}"
|
||||
say "Where the generated allowlist goes, which branch is trusted, and how the"
|
||||
say "resolver is reloaded. The defaults are right unless you have a reason."
|
||||
say
|
||||
ask_cfg out "Generated allowlist path" out /etc/dnsmasq.d/ci-allow.conf
|
||||
ask_cfg ref "Branch treated as operator-approved" ref main
|
||||
ask_cfg reload "Command that reloads the resolver" reload "systemctl reload dnsmasq"
|
||||
say
|
||||
|
||||
# --- write ------------------------------------------------------------------
|
||||
@@ -462,7 +674,7 @@ out = $out
|
||||
ref = $ref
|
||||
reload = $reload"
|
||||
|
||||
for prog in dejima-ci-allowlist dejima-ci-firewall; do
|
||||
for prog in dejima-ci-allowlist dejima-ci-firewall dejima-ci-runners dejima-ci-static-ip; do
|
||||
if cmp -s "$SRC/bin/$prog" "$BIN_DIR/$prog" 2>/dev/null; then
|
||||
say " unchanged $BIN_DIR/$prog"
|
||||
else
|
||||
@@ -471,6 +683,24 @@ for prog in dejima-ci-allowlist dejima-ci-firewall; do
|
||||
fi
|
||||
done
|
||||
|
||||
# The templates dejima-ci-runners renders. Installed rather than read from the
|
||||
# checkout, because it is installed too and has to keep working once whatever
|
||||
# clone it came from is gone.
|
||||
if writable "$TEMPLATE_DIR"; then
|
||||
for tpl in "$SRC"/examples/*; do
|
||||
[ -f "$tpl" ] || continue
|
||||
if cmp -s "$tpl" "$TEMPLATE_DIR/${tpl##*/}" 2>/dev/null; then
|
||||
say " unchanged $TEMPLATE_DIR/${tpl##*/}"
|
||||
else
|
||||
install -m 0644 "$tpl" "$TEMPLATE_DIR/${tpl##*/}"
|
||||
say " installed $TEMPLATE_DIR/${tpl##*/}"
|
||||
fi
|
||||
done
|
||||
else
|
||||
say " skipped $TEMPLATE_DIR (not writable) - dejima-ci-runners will read"
|
||||
say " them from this checkout instead"
|
||||
fi
|
||||
|
||||
# The resolver's static half. Its generated half - the server= lines - is
|
||||
# written by dejima-ci-allowlist from origin/main, into a separate file in the
|
||||
# same directory. Keeping them apart is what stops a hand-edit drifting from
|
||||
@@ -490,8 +720,24 @@ stop-dns-rebind
|
||||
rebind-domain-ok=/$forge_host/"
|
||||
|
||||
if [ "$INSTALL_UNITS" -eq 1 ]; then
|
||||
# @@BIN_DIR@@ substituted rather than written into the units, because the
|
||||
# path was previously in two places and they disagreed: the units named
|
||||
# /usr/local/bin while this installs to /usr/local/sbin, so the timer's
|
||||
# service failed with 203/EXEC on every tick. Nothing said so - a timer
|
||||
# that never succeeds looks exactly like one that has nothing to do, and
|
||||
# the allowlist simply stopped being refreshed.
|
||||
for unit in dejima-ci-allowlist.service dejima-ci-allowlist.timer dejima-ci-firewall.service; do
|
||||
write_if_changed "$UNIT_DIR/$unit" 0644 "$(cat "$SRC/systemd/$unit")"
|
||||
write_if_changed "$UNIT_DIR/$unit" 0644 \
|
||||
"$(sed "s|@@BIN_DIR@@|$BIN_DIR|g" "$SRC/systemd/$unit")"
|
||||
done
|
||||
|
||||
# And checked, because a unit that names a program which is not there is
|
||||
# only visible as a failed start, long after this run has finished.
|
||||
for unit in dejima-ci-allowlist.service dejima-ci-firewall.service; do
|
||||
exe=$(sed -n 's|^ExecStart=\([^ ]*\).*|\1|p' "$UNIT_DIR/$unit" | head -1)
|
||||
[ -x "$exe" ] || die "$UNIT_DIR/$unit runs $exe, which is not executable.
|
||||
That is this installer's own mistake rather than anything you did. The programs
|
||||
are installed to $BIN_DIR; report the mismatch."
|
||||
done
|
||||
else
|
||||
say " skipped $UNIT_DIR (not writable) - install the units by hand"
|
||||
@@ -509,6 +755,7 @@ if [ "$INSTALL_UNITS" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
# Docker starts. Docker recreates its own chains on every start and
|
||||
# DOCKER-USER comes back empty, so "apply them once" is not an
|
||||
# instruction that stays true.
|
||||
say
|
||||
ask enable_fw "Enable the firewall unit (reapplies rules on every Docker start)? (Y/n)" "y"
|
||||
if [ "${enable_fw,,}" != n ] && [ "${enable_fw,,}" != no ]; then
|
||||
systemctl enable --now dejima-ci-firewall.service
|
||||
@@ -522,56 +769,53 @@ fi
|
||||
|
||||
say
|
||||
say "=============================================================="
|
||||
say " What is left to do"
|
||||
say " The runners"
|
||||
say "=============================================================="
|
||||
say
|
||||
say "This installer cannot configure or register your runners - that needs a"
|
||||
say "token from your forge, and the config file lives wherever your runner's"
|
||||
say "compose mounts it from. Everything below is on you."
|
||||
say "Everything above is the machinery a runner's jobs sit behind. Nothing is"
|
||||
say "behind it yet, because no runner is configured on this host."
|
||||
say
|
||||
say "1. In the config.yaml of every runner whose jobs should be filtered:"
|
||||
say "$BIN_DIR/dejima-ci-runners writes the compose project and the"
|
||||
say "runner config together, taking the job network and the resolver address"
|
||||
say "from $CONFIG. A runner whose container.network or"
|
||||
say "--dns disagrees with what was set up here starts, accepts jobs, and"
|
||||
say "filters nothing - so neither is left for you to copy across."
|
||||
say
|
||||
say " container:"
|
||||
say " network: $network"
|
||||
say " docker_host: \"-\""
|
||||
say " options: \"--dns=$gateway --init --security-opt=no-new-privileges\""
|
||||
say " valid_volumes: []"
|
||||
say " privileged: false"
|
||||
|
||||
run_runners=n
|
||||
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
||||
ask run_runners "Set up the runners now? (Y/n)" "y"
|
||||
fi
|
||||
if [ "${run_runners,,}" != n ] && [ "${run_runners,,}" != no ]; then
|
||||
say
|
||||
# Not exec'd and not fatal: everything above this point is installed and
|
||||
# correct, and a runner that needs a second attempt should not read as the
|
||||
# allowlist having failed.
|
||||
"$BIN_DIR/dejima-ci-runners" --role confined || \
|
||||
say " runner setup did not finish - re-run: sudo $BIN_DIR/dejima-ci-runners --role confined"
|
||||
else
|
||||
say "Skipped. When you are ready:"
|
||||
say
|
||||
say " sudo $BIN_DIR/dejima-ci-runners --role confined"
|
||||
say
|
||||
say "It asks for a label and a count, takes the network and resolver from"
|
||||
say "$CONFIG, and tells you which page of your forge mints"
|
||||
say "the registration token."
|
||||
fi
|
||||
|
||||
say
|
||||
say " network must be exactly \"$network\" - the name, not the bridge."
|
||||
say " --dns must be exactly $gateway, matching the resolver's listen-address."
|
||||
say " docker_host: \"-\" is the one value that stops the Docker socket being"
|
||||
say " mounted into job containers; unset is the default and the default mounts."
|
||||
say "Then verify from inside a job rather than from this host. A runner that"
|
||||
say "grants the socket looks identical to one that does not, from out here and"
|
||||
say "from your forge's UI. dejima ships test/ci-runner.bats for exactly this."
|
||||
say "If the DNS checks pass while the egress checks fail, the resolver is fine"
|
||||
say "and the firewall is not:"
|
||||
say
|
||||
say "2. Give that runner a label no other runner carries, so a job naming it"
|
||||
say " cannot land somewhere unhardened:"
|
||||
say " sudo $BIN_DIR/dejima-ci-firewall && sudo iptables -S DOCKER-USER"
|
||||
say
|
||||
say " runner:"
|
||||
say " labels:"
|
||||
say " - \"your-label:docker://node:20-bookworm\""
|
||||
say " capacity: 1"
|
||||
say " cache:"
|
||||
say " enabled: false"
|
||||
say
|
||||
say "3. Make sure CONFIG_FILE=/config.yaml is in that runner's environment."
|
||||
say " Without it the file is not read at all and every default applies -"
|
||||
say " including the socket reaching every job - with nothing saying so."
|
||||
say
|
||||
say "4. Restart the runner, then confirm it took:"
|
||||
say
|
||||
say " docker compose restart # wherever your runner lives"
|
||||
say
|
||||
say "5. Verify from inside a job, rather than from this host. dejima ships"
|
||||
say " test/ci-runner.bats for exactly this; a job on the hardened runner"
|
||||
say " should show the Docker socket absent, an off-list name not resolving,"
|
||||
say " and an off-list address unreachable. If the DNS checks pass while the"
|
||||
say " egress checks fail, the resolver is fine and the firewall is not:"
|
||||
say
|
||||
say " $BIN_DIR/dejima-ci-firewall && iptables -S DOCKER-USER"
|
||||
say
|
||||
say "For a runner using host networking, none of steps 1-2 apply: its jobs are"
|
||||
say "in the host's namespace, no bridge is in front of them, and the rules"
|
||||
say "installed here do not filter them. Its egress is that machine's own."
|
||||
say "A runner using host networking is not covered by any of this: its jobs"
|
||||
say "are in the host's namespace, no bridge is in front of them, and the rules"
|
||||
say "installed here do not filter them. That is the privileged tier, and it"
|
||||
say "belongs on its own machine - see the README."
|
||||
say
|
||||
say "Files this installer owns, for later reference:"
|
||||
say " config $CONFIG"
|
||||
@@ -587,7 +831,7 @@ rc=0
|
||||
case "$rc" in
|
||||
0) say "Up to date." ;;
|
||||
1) say; say "There is a pending change, shown above. Apply it with:"
|
||||
say " $BIN_DIR/dejima-ci-allowlist render" ;;
|
||||
say " sudo $BIN_DIR/dejima-ci-allowlist render" ;;
|
||||
*) say; say "The configuration is not usable yet - the problems are listed above."
|
||||
say "Re-run this installer to fix them."; exit "$rc" ;;
|
||||
esac
|
||||
|
||||
+157
-129
@@ -1,6 +1,12 @@
|
||||
# Building the sacrificial runner VM
|
||||
# Provisioning a runner VM on Proxmox
|
||||
|
||||
A runbook, not a script. Everything here is done in the Proxmox web console.
|
||||
The node-side half: the VM itself, the firewall applied to it, and its
|
||||
snapshots. Everything here is done in the Proxmox web console or on the node.
|
||||
|
||||
What happens *inside* the VM is
|
||||
[docs/building-a-runner.md](../docs/building-a-runner.md), which is the same
|
||||
on any hypervisor. Do this page first, up to the end of §3, then go there — it
|
||||
sends you back for §4 twice, once for each snapshot.
|
||||
|
||||
That is deliberate. A script that runs `qm create` and writes into
|
||||
`/etc/pve/firewall/` is exactly the kind of artifact people paste into a root
|
||||
@@ -12,9 +18,9 @@ doing while you do it.
|
||||
The in-VM half **is** a script: [`bin/dejima-ci-bootstrap`](../bin/dejima-ci-bootstrap).
|
||||
Running that blind costs you a VM built to be lost.
|
||||
|
||||
One step on the node is not console work, because it cannot be: Proxmox
|
||||
schedules backups but not rollbacks, so §6 is a three-command cron entry. It
|
||||
is printed in full and each part of it is explained, which is the standard the
|
||||
One step here is not console work, because it cannot be: Proxmox schedules
|
||||
backups but not rollbacks, so the cadence in §4 is a three-command cron entry.
|
||||
It is printed in full and each part of it explained, which is the standard the
|
||||
rest of this page is held to as well.
|
||||
|
||||
---
|
||||
@@ -28,9 +34,11 @@ root-equivalent on their host. So the working assumption is not *if* but
|
||||
*when*: a compromised job has root on this VM. Everything below follows from
|
||||
taking that seriously rather than hoping.
|
||||
|
||||
Two VMs if you are following [the tiering](../README.md#register-scope-is-the-boundary-the-label-is-not):
|
||||
one for the privileged runner, one for the ordinary ones. The steps are the
|
||||
same; only the runner you register on each differs.
|
||||
Two VMs if you are following [the
|
||||
tiering](../README.md#two-tiers-and-you-must-pick-one): one for the privileged
|
||||
runner, one for the confined ones. Every step on this page is the same for
|
||||
both - the tiers differ only in a flag, and that flag is in
|
||||
[docs/building-a-runner.md](../docs/building-a-runner.md).
|
||||
|
||||
**The firewall goes on the node, not in the guest.** Root in the VM can flush
|
||||
any rule set it can see. It cannot reach one applied to its own tap device.
|
||||
@@ -63,85 +71,85 @@ before you flip it rather than after.
|
||||
| --- | --- | --- |
|
||||
| General | Name | `oubliette`, or whatever you prefer |
|
||||
| OS | ISO | Debian netinst, current stable |
|
||||
| System | Qemu Agent | ✓ |
|
||||
| System | Qemu Agent | ✓ — and see the note below, the tick alone does nothing |
|
||||
| Disks | Size | 64 GB is comfortable for image builds |
|
||||
| CPU | Cores | 4 |
|
||||
| Memory | | 8192 MB |
|
||||
| Network | Bridge | your normal bridge |
|
||||
| Network | **Firewall** | **✓ — this is the checkbox everything else depends on** |
|
||||
|
||||
That last one is easy to miss and easy to uncheck later without noticing. If
|
||||
it is off, the per-VM rules do not apply to the interface and the VM is simply
|
||||
on your LAN.
|
||||
The Firewall checkbox is easy to miss and easy to uncheck later without
|
||||
noticing. If it is off, the per-VM rules do not apply to the interface and the
|
||||
VM is simply on your LAN.
|
||||
|
||||
Install Debian normally, with two things to get right.
|
||||
**Qemu Agent is two halves, and this tick is one of them.** It opens a
|
||||
virtio-serial channel on the host side; answering on it takes the
|
||||
`qemu-guest-agent` package inside the guest, which `dejima-ci-bootstrap`
|
||||
installs and which tells you if this tick is missing. Leave it on: without the
|
||||
channel the agent has nothing to attach to, the Summary pane shows no address,
|
||||
`qm shutdown` falls back to hoping ACPI is acted on, and a snapshot of a
|
||||
running VM stays crash-consistent.
|
||||
|
||||
**Static addressing**, not DHCP. The firewall names this VM by address, and a
|
||||
lease that moves is a rule that no longer describes it.
|
||||
**What a moving address does *not* break.** Not the firewall. Every rule in the
|
||||
next section keys on a *destination*, except the SSH rule, which keys on *your*
|
||||
management address — and Proxmox applies the whole set to this VM's tap device,
|
||||
identified by VMID rather than by IP. Nothing anywhere names this VM by its own
|
||||
address, so a rebuilt VM that comes up on a different one is still fully
|
||||
firewalled.
|
||||
|
||||
**Leave the root password blank.** Debian reads an empty root password as
|
||||
"use sudo", installs it, and puts your first user in the `sudo` group. Set a
|
||||
root password instead and it does neither — your user has no `sudo` at all,
|
||||
and the bootstrap step below opens with two `sudo` commands that will tell you
|
||||
so.
|
||||
|
||||
If you have already set one, fix it from the console as root:
|
||||
|
||||
```sh
|
||||
apt-get install -y sudo
|
||||
usermod -aG sudo <your-user>
|
||||
```
|
||||
|
||||
then log out and back in — group membership is read at login, so the shell you
|
||||
are already in will not have it.
|
||||
|
||||
**The software selection screen.** This is the one that bites, because it is
|
||||
a checklist where the defaults are wrong for this machine and clearing them
|
||||
all is worse. Navigate with the arrow keys and toggle with **space** — typing
|
||||
does nothing useful, and clearing every entry leaves you without the tools the
|
||||
next section assumes.
|
||||
|
||||
| Entry | Set it to |
|
||||
| --- | --- |
|
||||
| Debian desktop environment (and any desktop under it) | **off** |
|
||||
| web server | off |
|
||||
| **SSH server** | **on** — this is how you reach the VM, and nothing here installs it later |
|
||||
| **standard system utilities** | **on** — without it you have no `git`, no `sudo`, and very little else |
|
||||
|
||||
Leave SSH server *on*. An earlier version of this runbook said to leave it out
|
||||
on the grounds that `dejima-ci-bootstrap` configures sshd — it does, and
|
||||
configuring is not installing, so following that left a VM with no way in
|
||||
except the Proxmox console.
|
||||
What it breaks is you finding the machine: `ssh <you>@<vm-ip>` in §4, and every
|
||||
later visit. That is reason enough to pin it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The firewall rules
|
||||
|
||||
**VM → Firewall → Options** first:
|
||||
Every runner VM gets the same eight rules, whichever tier it is, so build them
|
||||
once as a **security group** and apply that group to each. Two VMs with two
|
||||
hand-entered copies is two chances to get the ordering wrong and one of them to
|
||||
drift out of step later.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Firewall | `Yes` |
|
||||
| Input Policy | `DROP` |
|
||||
| Output Policy | `ACCEPT` |
|
||||
### Both tiers get these, for different reasons
|
||||
|
||||
Inbound denies by default — this VM offers no service anyone should reach, so
|
||||
the exceptions are short and knowable. Outbound accepts by default, and the
|
||||
LAN is denied explicitly below.
|
||||
Apply them to the confined tier as well as the privileged one. The rules are
|
||||
identical; what differs is the job they are doing.
|
||||
|
||||
That asymmetry is deliberate, and it is the opposite of what an earlier
|
||||
version of this runbook said. Denying outbound and then allowing tcp/80,
|
||||
tcp/443 and udp/123 breaks things — apt during a first install, and anything
|
||||
else needing a port nobody listed — and each breakage presents as a network
|
||||
fault rather than as a firewall rule. It also bought almost nothing: this VM
|
||||
is assumed to be owned by an attacker with root, and the guarantee is *reaches
|
||||
the internet, not your network*, not *reaches only three ports*. Anyone who
|
||||
owns the box tunnels over 443 like everybody else. Deny-by-default earns its
|
||||
keep where the protected set is enumerable, which is the LAN; the internet is
|
||||
not, and pretending otherwise produced a rule set that looked strict and only
|
||||
got in the way.
|
||||
On the **privileged** tier this *is* the boundary. Its jobs hold the Docker
|
||||
socket and run in the host's network namespace, so root on that VM is the
|
||||
working assumption, and anything installed inside it can be removed by whatever
|
||||
holds root. These rules are on the hypervisor, applied to the tap device, and
|
||||
the guest cannot reach them.
|
||||
|
||||
Then **VM → Firewall → Add**, in this order:
|
||||
On the **confined** tier the primary control is inside the VM: the job network,
|
||||
its allowlisting resolver, and the DOCKER-USER rules. Those are real against a
|
||||
job precisely because a job cannot reach them, and they decide job egress by
|
||||
name and by address. The node firewall sits behind that as defence in depth. It
|
||||
changes nothing the allowlist has already decided; it covers the case the
|
||||
allowlist cannot, which is a job that escapes its container and reaches root on
|
||||
the host. At that point what is left is this, and it is doing exactly what it
|
||||
does on the other tier.
|
||||
|
||||
So: primary in the guest and defence in depth on the node for confined, nothing
|
||||
in the guest and everything on the node for privileged.
|
||||
|
||||
**One thing to weigh before applying them to a confined host.** Rules 5–8 drop
|
||||
every RFC1918 destination, including ones a *job* might legitimately want. If a
|
||||
served repository declares an internal name in `.dejima/allowed-domains` — a
|
||||
package mirror, an internal registry — the allowlist will resolve it and the
|
||||
node firewall will drop the connection, which reads as the allowlist not
|
||||
working. An internal NTP server is the same story. Give anything in that
|
||||
position its own pinhole above the DROPs, or keep it off the allowlist so the
|
||||
failure is at least honest about where it comes from.
|
||||
|
||||
And one value that has to agree: the resolver in rules 3–4 must be the same
|
||||
address as `upstream` in `/etc/dejima-ci/config`, since that is where the
|
||||
confined tier's dnsmasq forwards allowed queries. Point them at different
|
||||
servers and every job fails to resolve while the host itself is fine.
|
||||
|
||||
### Build the group once
|
||||
|
||||
**Datacenter → Firewall → Security Group → Create**, named
|
||||
`dejima-ci-hardener`. Then **Add** these to it, in this order:
|
||||
|
||||
| # | Direction | Action | Protocol | Dest. port | Source / Dest | Why |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
@@ -154,7 +162,45 @@ Then **VM → Firewall → Add**, in this order:
|
||||
| 7 | out | DROP | — | — | **Dest:** `192.168.0.0/16` | The three that matter |
|
||||
| 8 | out | DROP | — | — | **Dest:** `169.254.0.0/16` | Link-local, incl. metadata services |
|
||||
|
||||
**Order matters, and it is the one thing here that is easy to get wrong.**
|
||||
### Then apply it, per VM
|
||||
|
||||
A security group carries **rules and nothing else**. The policies are not part
|
||||
of it, so each VM still needs its own:
|
||||
|
||||
**VM → Firewall → Options**
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Firewall | `Yes` |
|
||||
| Input Policy | `DROP` |
|
||||
| Output Policy | `ACCEPT` |
|
||||
|
||||
Inbound denies by default — this VM offers no service anyone should reach, so
|
||||
the exceptions are short and knowable. Outbound accepts by default, and the LAN
|
||||
is denied explicitly by the group.
|
||||
|
||||
That asymmetry is deliberate, and it is worth resisting the obvious-looking
|
||||
alternative: deny outbound too, then allow tcp/80, tcp/443 and udp/123. That
|
||||
breaks things — apt during a first install, and anything else needing a port
|
||||
nobody listed — and each breakage presents as a network fault rather than as a
|
||||
firewall rule. It also buys almost nothing: this VM is assumed to be owned by
|
||||
an attacker with root, and the guarantee being made is *reaches the internet,
|
||||
not your network*, not *reaches only three ports*. Anyone who owns the box
|
||||
tunnels over 443 like everybody else.
|
||||
|
||||
Deny-by-default earns its keep where the protected set is enumerable, which is
|
||||
the LAN. The internet is not, and treating it as though it were gets you a rule
|
||||
set that looks strict and only gets in the way.
|
||||
|
||||
Then **VM → Firewall → Add → Security Group**, and select
|
||||
`dejima-ci-hardener`. That single entry is the whole rule set, in the order the
|
||||
group defines, and it is the only firewall rule the VM needs.
|
||||
|
||||
Adding another runner later is that one step. The ordering below was got right
|
||||
once, in one place, and cannot drift between hosts.
|
||||
|
||||
**Order matters within the group, and it is the one thing here that is easy to
|
||||
get wrong.**
|
||||
Rules are evaluated top to bottom and the first match wins. Your forge and
|
||||
your resolver are *inside* the ranges rules 5–8 drop, so if either pinhole
|
||||
ends up below them it is dead and the runner cannot reach the forge or resolve
|
||||
@@ -171,9 +217,9 @@ Nothing follows rule 8. The public internet is reached by the default policy.
|
||||
### Values that must agree
|
||||
|
||||
Three pairs here are entered in two places each, and each pair is silent when
|
||||
the halves disagree. Read the real value off the machine rather than
|
||||
recalling it — that is the whole of the advice, and every one of these has
|
||||
been got wrong at least once.
|
||||
the halves disagree. Read the real value off the machine rather than recalling
|
||||
it — that is the whole of the advice, and every one of these is easy to get
|
||||
wrong from memory.
|
||||
|
||||
| This | Must equal this | Read the truth from |
|
||||
| --- | --- | --- |
|
||||
@@ -200,10 +246,10 @@ nothing:
|
||||
|
||||
```sh
|
||||
# what is actually loaded right now
|
||||
iptables -S | grep "tap<vmid>i0"
|
||||
sudo iptables -S | grep "tap<vmid>i0"
|
||||
|
||||
# what Proxmox would generate from the current config
|
||||
pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
|
||||
sudo pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
|
||||
```
|
||||
|
||||
**What you are looking for.** Proxmox names the per-VM chains after the tap
|
||||
@@ -258,62 +304,46 @@ Read them together, because the pattern says which thing is wrong:
|
||||
|
||||
---
|
||||
|
||||
## 4. Bootstrap the VM
|
||||
## 4. Snapshots and rollback
|
||||
|
||||
```sh
|
||||
ssh <you>@<vm-ip>
|
||||
sudo apt-get update && sudo apt-get install -y git # if the install left it out
|
||||
git clone https://gitbox.branderson.io/branderson/dejima-ci-hardener.git
|
||||
cd dejima-ci-hardener
|
||||
sudo ./bin/dejima-ci-bootstrap --role runtime --mgmt <your-management-ip>
|
||||
```
|
||||
Three moments, and [docs/building-a-runner.md](../docs/building-a-runner.md)
|
||||
says when each falls. The mechanics are the same for all of them: **shut the VM
|
||||
down**, then **VM → Snapshots → Take Snapshot**, with *Include RAM*
|
||||
**unticked**, then start it again.
|
||||
|
||||
Two ways this stalls, both from choices made during the install:
|
||||
| Name | When | What it is for |
|
||||
| --- | --- | --- |
|
||||
| `preflight` | after the OS is installed and its network verified, before anything is run on it | a known base to return to while iterating |
|
||||
| `clean` | after the runner registers and the VM has been restarted once, before its first job | the state the rollback cadence returns to |
|
||||
|
||||
- **`ssh` refuses the connection** — the SSH server task was not selected. Log
|
||||
in at the Proxmox console, which needs no network, and
|
||||
`apt-get install -y openssh-server git`, then `systemctl enable --now ssh`.
|
||||
- **`sudo` says you are not in the sudoers file** — a root password was set
|
||||
during the install, so Debian did not add you. See "Leave the root password
|
||||
blank" above for the fix, or just run these as root from the console.
|
||||
**Snapshot with it shut down, unless the guest agent is answering.** Proxmox
|
||||
will snapshot a running VM, and without the agent what you get is
|
||||
crash-consistent: the disk as it was at an instant, mid-writes included,
|
||||
recovered by journal replay on the next boot. That is what pulling the plug
|
||||
gives you, and it is a poor thing to restore unattended every week
|
||||
indefinitely.
|
||||
|
||||
`--role ci` for the ordinary tier. Either way the script installs packages,
|
||||
hardens sshd, removes listeners a runner has no use for, and sets up
|
||||
unattended security upgrades. On `--role ci` it then runs `install.sh` for the
|
||||
job allowlist, its resolver and its firewall; on `--role runtime` it
|
||||
deliberately does not, because those work by sitting in front of job traffic
|
||||
on a bridge and this tier's jobs are in the host's network namespace, where
|
||||
there is no bridge to sit on.
|
||||
`qemu-guest-agent` removes the question — Proxmox asks it to freeze the guest's
|
||||
filesystems for the duration, so a live snapshot is consistent.
|
||||
`dejima-ci-bootstrap` installs it, and §2's Qemu Agent tick is what gives it a
|
||||
channel to answer on. With both, snapshot the VM where it stands. With either
|
||||
missing, shut it down first.
|
||||
|
||||
It does not register the runner — that needs a token from your forge — and it
|
||||
does not enable the host's own egress policy until you have filled in
|
||||
`/etc/dejima-ci/host-egress-allow`, because enabling it empty would cut the VM
|
||||
off from the forge.
|
||||
**And `clean` has an order it depends on**, which is easy to get wrong because
|
||||
nothing complains at the time. It must be taken of a state that has already
|
||||
booted successfully at least once *since the last change to the machine's
|
||||
network configuration*. Rolling back is a cold boot - the disk is restored, the
|
||||
VM is started - so every rollback re-applies whatever is in
|
||||
`/etc/network/interfaces` and re-runs every unit. If that configuration is
|
||||
wrong, the rollback is when it takes effect, and every rollback after it does
|
||||
the same thing. Restart once and check the machine comes back before taking
|
||||
`clean`, and the cadence cannot surprise you later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Register the runner, then snapshot it clean
|
||||
|
||||
The VM is worth losing, which is only true if losing it is cheap.
|
||||
|
||||
**Register the runner first**, and confirm it shows *Online* in the forge. The
|
||||
registration lives on disk, in the runner's `.runner` file. A snapshot taken
|
||||
before that does not contain it, and every later rollback drops the runner off
|
||||
the forge — or, if the registration token is still in its environment, has it
|
||||
re-register as a *new* runner, accumulating a dead entry per rollback. Neither
|
||||
announces itself; you find out when a job sits queued.
|
||||
|
||||
Then **VM → Snapshots → Take Snapshot**, named `clean`, described
|
||||
*bootstrapped and registered, before any job ran*. Two things about it:
|
||||
|
||||
- **Before the first job.** One taken afterwards preserves whatever that job
|
||||
left, which is precisely the thing you want to be able to throw away.
|
||||
- **Without RAM.** Leave *Include RAM* unticked. A snapshot with RAM restores
|
||||
the guest's clock along with everything else, so a VM rolled back to last
|
||||
month believes it is last month: TLS verification fails, apt rejects Release
|
||||
files as not yet valid, and every timestamp a job writes is wrong. Nothing
|
||||
in this VM's memory is worth resuming, and a rollback without RAM is smaller
|
||||
and faster besides.
|
||||
**Leave *Include RAM* unticked.** A snapshot with RAM restores the guest's
|
||||
clock along with everything else, so a VM rolled back to last month believes it
|
||||
is last month: TLS verification fails, apt rejects Release files as not yet
|
||||
valid, and every timestamp a job writes is wrong. Nothing in this VM's memory
|
||||
is worth resuming, and a rollback without RAM is smaller and faster besides.
|
||||
|
||||
Roll back from the same tab whenever you want it clean again. The snapshot
|
||||
survives being rolled back to, so `clean` stays usable indefinitely —
|
||||
@@ -325,11 +355,9 @@ Two triggers:
|
||||
current enough that rolling back costs you nothing you mind losing.
|
||||
- **On a cadence.** This is what bounds how long a foothold can live *without
|
||||
your having noticed it* — which is the case the first trigger cannot cover,
|
||||
and the reason the next section exists.
|
||||
and the reason the rest of this section exists.
|
||||
|
||||
---
|
||||
|
||||
## 6. Automate the cadence
|
||||
### Automate the cadence
|
||||
|
||||
The reactive trigger depends on you noticing. The scheduled one does not, which
|
||||
makes it the more valuable of the two — and the one that quietly stops
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
#
|
||||
# Substitute the three placeholders if you do use it directly.
|
||||
#
|
||||
# For more than one runner VM, prefer a security group: the same rules, defined
|
||||
# once at datacenter level and referenced by each VM, so the ordering below is
|
||||
# got right in one place and cannot drift between hosts. proxmox/README.md
|
||||
# section 3 builds it that way. The [OPTIONS] block is NOT part of a group and
|
||||
# stays per-VM either way.
|
||||
#
|
||||
# THIS IS THE BOUNDARY. Everything dejima-ci-hardener installs inside the VM -
|
||||
# the job network's allowlist, the DOCKER-USER rules - is defence in depth,
|
||||
# because the premise of the privileged runner is that a compromised job
|
||||
@@ -30,28 +36,43 @@ policy_in: DROP
|
||||
|
||||
# Outbound ACCEPTs by default, and the LAN is denied explicitly below.
|
||||
#
|
||||
# The earlier version of this file did the opposite - deny outbound, then
|
||||
# allow tcp/80, tcp/443 and udp/123 - and it was wrong twice over.
|
||||
# Do not be tempted to invert this - deny outbound, then allow tcp/80, tcp/443
|
||||
# and udp/123 - because it is wrong twice over.
|
||||
#
|
||||
# It broke things: apt during a first install, and anything else that happens
|
||||
# to need a port nobody listed. Every such breakage looks like a network
|
||||
# fault rather than a firewall rule, and each one is found the hard way.
|
||||
# It breaks things: apt during a first install, and anything else that happens
|
||||
# to need a port nobody listed. Every such breakage looks like a network fault
|
||||
# rather than a firewall rule, so each one is found the hard way.
|
||||
#
|
||||
# And it bought almost nothing. This VM is assumed to be owned by an attacker
|
||||
# And it buys almost nothing. This VM is assumed to be owned by an attacker
|
||||
# with root; the guarantee being made is "reaches the internet, not your
|
||||
# network", not "reaches only three ports". Someone who owns the box tunnels
|
||||
# over 443 like everyone else, so the port list constrained honest software
|
||||
# and not them.
|
||||
# over 443 like everyone else, so the port list constrains honest software and
|
||||
# not them.
|
||||
#
|
||||
# Deny-by-default still applies where the protected set is enumerable, which
|
||||
# Deny-by-default earns its keep where the protected set is enumerable, which
|
||||
# is what the four DROPs below are. It does not apply to the internet, which
|
||||
# is not, and pretending otherwise produced a rule set that looked strict and
|
||||
# only got in the way.
|
||||
# is not enumerable, and treating it as though it were gets you a rule set
|
||||
# that looks strict and only gets in the way.
|
||||
policy_out: ACCEPT
|
||||
|
||||
log_level_in: nolog
|
||||
log_level_out: nolog
|
||||
|
||||
# Uncomment if this VM gets its address by DHCP rather than statically.
|
||||
#
|
||||
# The four OUT DROPs below cover every RFC1918 range, and a DHCP server is
|
||||
# almost always inside one - so a lease renewal at T1, which is a unicast
|
||||
# DHCPREQUEST to that server, is dropped. The lease usually survives on the
|
||||
# fallback: at T2 the client stops unicasting and rebinds by broadcast, and
|
||||
# 255.255.255.255 matches none of those DROPs. That is a protocol's last
|
||||
# resort doing routine work, and when it does fail the VM simply drops off
|
||||
# the network, which looks nothing like a firewall rule.
|
||||
#
|
||||
# The alternative to this line is an explicit OUT ACCEPT to the DHCP server on
|
||||
# udp/67, which widens the LAN exposure below from two hosts to three. This
|
||||
# option is the narrower of the two. Narrower still is not using DHCP.
|
||||
# dhcp: 1
|
||||
|
||||
[RULES]
|
||||
|
||||
# --- inbound ----------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/dejima-ci-allowlist render
|
||||
ExecStart=@@BIN_DIR@@/dejima-ci-allowlist render
|
||||
|
||||
# This reads a token and writes one file. It needs neither a home directory
|
||||
# nor the rest of the filesystem.
|
||||
|
||||
@@ -15,22 +15,22 @@
|
||||
# that was never the problem.
|
||||
#
|
||||
# Install as /etc/systemd/system/dejima-ci-firewall.service, then:
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now dejima-ci-firewall
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now dejima-ci-firewall
|
||||
#
|
||||
# Verify it actually rebinds, rather than assuming:
|
||||
# systemctl restart docker && sudo iptables -S DOCKER-USER
|
||||
# sudo systemctl restart docker && sudo iptables -S DOCKER-USER
|
||||
|
||||
[Unit]
|
||||
Description=dejima CI job-network egress rules
|
||||
Documentation=https://gitbox.example.com/branderson/dejima/src/branch/main/ci/README.md
|
||||
Documentation=https://gitbox.branderson.io/dejima/dejima-ci-hardener
|
||||
After=docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/dejima-ci-firewall
|
||||
ExecStart=@@BIN_DIR@@/dejima-ci-firewall
|
||||
|
||||
# Failing to apply the rules must be loud. A runner that came up without them
|
||||
# is a runner with unrestricted egress, which is the state this whole setup
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Keeps real network addresses out of the repository.
|
||||
#
|
||||
# Everything here is a runbook for building a machine on someone's LAN, so
|
||||
# every example wants a plausible-looking address - and the easiest way to
|
||||
# write one is to paste the address in front of you. That is how an operator's
|
||||
# forge, resolver and VM addressing ended up in a public repository once
|
||||
# already: not as a decision, but as three lines of an example that were
|
||||
# already correct on the machine they were written on.
|
||||
#
|
||||
# This is the failure that cannot be caught by review, because a pasted address
|
||||
# looks exactly like an invented one. So the check is mechanical: every IPv4
|
||||
# literal must be on the list below, and adding to that list is a deliberate
|
||||
# edit that says what the address is for.
|
||||
#
|
||||
# Prefer, in order:
|
||||
# - a placeholder, <forge-ip> or <iface>, which is this repository's idiom
|
||||
# and cannot be mistaken for a working value
|
||||
# - RFC 5737 documentation ranges: 192.0.2.0/24, 198.51.100.0/24,
|
||||
# 203.0.113.0/24, which exist for exactly this and route nowhere
|
||||
# - an RFC1918 literal ONLY where the example has to be private to make
|
||||
# sense, as the job network's subnet does
|
||||
|
||||
setup() {
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
}
|
||||
|
||||
@test "no unexplained IPv4 literal is committed anywhere in the repository" {
|
||||
# Each entry is here because something needs it, not because it appeared.
|
||||
allowed=(
|
||||
0.0.0.0 # wildcard bind, sshd's ListenAddress
|
||||
127.0.0.1 # loopback, as a rejected upstream resolver
|
||||
127.0.0.53 # systemd-resolved's stub, named as NOT an answer
|
||||
255.255.255.255 # limited broadcast, in the DHCP rebind note
|
||||
169.254.169.254 # the cloud metadata address, named as a target
|
||||
|
||||
10.0.0.0/8 # the four RFC1918 + link-local ranges the node
|
||||
172.16.0.0/12 # firewall drops. Ranges, not hosts: they name
|
||||
192.168.0.0/16 # every LAN rather than anybody's
|
||||
169.254.0.0/16
|
||||
|
||||
10.89.0.0/24 # the suggested job network. Has to be private -
|
||||
10.89.0.1 # dnsmasq's stop-dns-rebind is keyed on the
|
||||
# private ranges - so RFC 5737 will not do
|
||||
10.99.0.1 # the same, in test fixtures
|
||||
|
||||
192.0.2.0/24 # the three RFC 5737 documentation ranges, named
|
||||
198.51.100.0/24 # in the guidance above as what to reach for
|
||||
203.0.113.0/24
|
||||
198.51.100.53 # RFC 5737 TEST-NET-2, as an upstream resolver
|
||||
203.0.113.1 # RFC 5737 TEST-NET-3, as a bridge gateway
|
||||
1.1.1.1 # a public resolver, as the off-list probe in
|
||||
# ci-runner.bats. Has to be one that ANSWERS, or
|
||||
# the negative checks pass with no network at all
|
||||
1.2.3.4 # obviously-fake, in an allowlist parser fixture
|
||||
)
|
||||
|
||||
# Anything inside the RFC 5737 documentation ranges is fine without being
|
||||
# listed: they exist for examples, route nowhere, and belong to nobody.
|
||||
# Listing each host individually would grow this list with every fixture
|
||||
# until nobody read it.
|
||||
pattern="$(printf '%s\n' "${allowed[@]}" | sed 's/[.]/\\./g; s|/|\\/|g' | paste -sd'|' -)"
|
||||
doc='(192\.0\.2|198\.51\.100|203\.0\.113)\.[0-9]+(/[0-9]{1,2})?'
|
||||
pattern="$pattern|$doc"
|
||||
|
||||
# git ls-files: only what is committed, and never .git or a stray render
|
||||
# left in the working tree by a local run.
|
||||
found=$(cd "$REPO" && git ls-files -z \
|
||||
| xargs -0 grep -hoE '\b([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?\b' 2>/dev/null \
|
||||
| sort -u | grep -vxE "$pattern" || true)
|
||||
|
||||
if [ -n "$found" ]; then
|
||||
echo "IPv4 literals that are not on the allowlist in ${BASH_SOURCE[0]}:" >&2
|
||||
printf ' %s\n' $found >&2
|
||||
echo "" >&2
|
||||
echo "If one of these is a real address from a real network, replace it" >&2
|
||||
echo "with a <placeholder> or an RFC 5737 documentation address. If it" >&2
|
||||
echo "genuinely has to be there, add it to the list above with a comment" >&2
|
||||
echo "saying what it is for - the comment is the point of the list." >&2
|
||||
echo "" >&2
|
||||
echo "Where each one appears:" >&2
|
||||
for ip in $found; do
|
||||
( cd "$REPO" && git grep -n -F -- "$ip" | sed 's/^/ /' >&2 ) || true
|
||||
done
|
||||
false
|
||||
fi
|
||||
}
|
||||
+116
-2
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Drives bin/dejima-ci-allowlist against a stubbed curl and dnsmasq, so the
|
||||
# properties being checked are the ones that decide whether this program is
|
||||
@@ -19,6 +21,9 @@ out=""; url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift ;;
|
||||
# Recorded so a test can assert on what actually went over the wire,
|
||||
# which is the only place token mangling is visible.
|
||||
-H) printf '%s\n' "$2" >> "$STUB_DIR/last-headers"; shift ;;
|
||||
http*) url="$1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -105,6 +110,63 @@ run_tool() { run "$BIN" "$@" --config "$T/config"; }
|
||||
[[ "$output" == *"HTTP 500"* ]]
|
||||
}
|
||||
|
||||
@test "a rejected token says so, rather than reporting an HTTP number" {
|
||||
# 401 is the one an operator actually meets, and "unexpected HTTP 401" sends
|
||||
# nobody to the token. It has to name the file and say that a present-but-
|
||||
# wrong token is what this looks like, because an empty one is refused
|
||||
# earlier with a different message entirely.
|
||||
mkdir -p "$STUB_DIR/owner"
|
||||
echo 401 > "$STUB_DIR/owner/repo.status"
|
||||
echo example.com > "$T/base"
|
||||
run_tool render
|
||||
[ "$status" -ne 0 ]
|
||||
[ ! -f "$T/out/ci-allow.conf" ]
|
||||
[[ "$output" == *"rejected the token"* ]]
|
||||
[[ "$output" == *"$T/token"* ]]
|
||||
}
|
||||
|
||||
@test "a token refused permission is not confused with one refused outright" {
|
||||
# 403 authenticates and cannot read. Telling someone to replace a token
|
||||
# that is fine, when the answer is a scope they did not grant, costs them
|
||||
# the one thing the message was supposed to save.
|
||||
mkdir -p "$STUB_DIR/owner"
|
||||
echo 403 > "$STUB_DIR/owner/repo.status"
|
||||
echo example.com > "$T/base"
|
||||
run_tool render
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"refused the read"* ]]
|
||||
[[ "$output" == *"scope"* ]]
|
||||
[[ "$output" != *"rejected the token"* ]]
|
||||
}
|
||||
|
||||
@test "surrounding whitespace in the token file never reaches the forge" {
|
||||
# Command substitution strips trailing newlines and nothing else, so a
|
||||
# trailing space or a carriage return survives into the Authorization
|
||||
# header and the forge rejects it - while the file still looks correct to
|
||||
# anyone who cats it.
|
||||
printf 'tok123 \r\n' > "$T/token"
|
||||
echo example.com > "$T/base"
|
||||
run_tool render
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'Authorization: token tok123$' "$STUB_DIR/last-headers"
|
||||
}
|
||||
|
||||
@test "a renamed repository is named as such, and not followed" {
|
||||
# Gitea 301s the old path after a rename or a transfer. Following it would
|
||||
# mean reading the allowlist from whichever repository the forge points at
|
||||
# now, which is the one thing this program must not do - the whole premise
|
||||
# is that the list comes from a branch of a repository the operator named.
|
||||
mkdir -p "$STUB_DIR/owner"
|
||||
echo 301 > "$STUB_DIR/owner/repo.status"
|
||||
echo example.com > "$T/base"
|
||||
run_tool render
|
||||
[ "$status" -ne 0 ]
|
||||
[ ! -f "$T/out/ci-allow.conf" ]
|
||||
[[ "$output" == *"renamed or moved"* ]]
|
||||
[[ "$output" == *"$T/repos"* ]]
|
||||
[[ "$output" != *"unexpected HTTP"* ]]
|
||||
}
|
||||
|
||||
@test "an empty collection is refused rather than installed" {
|
||||
mkdir -p "$STUB_DIR/owner"; : > "$STUB_DIR/owner/repo.body"
|
||||
run_tool render
|
||||
@@ -177,6 +239,22 @@ run_tool() { run "$BIN" "$@" --config "$T/config"; }
|
||||
[[ "$output" == *"unknown key"* ]]
|
||||
}
|
||||
|
||||
@test "a first install reports what it would install, not a diff error" {
|
||||
# The allowlist does not exist yet on a first run, which is the ordinary
|
||||
# case and not a fault. Handing diff a path that is not there prints its
|
||||
# complaint instead of the answer, on the one run where the operator is
|
||||
# least able to tell the difference.
|
||||
mkdir -p "$STUB_DIR/owner"
|
||||
printf 'example.com\ndeb.debian.org\n' > "$STUB_DIR/owner/repo.body"
|
||||
[ ! -f "$T/out/ci-allow.conf" ]
|
||||
run_tool check
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"would install"* ]]
|
||||
[[ "$output" == *"2 names"* ]]
|
||||
[[ "$output" != *"No such file"* ]]
|
||||
[ ! -f "$T/out/ci-allow.conf" ]
|
||||
}
|
||||
|
||||
@test "check reports a pending change without installing it" {
|
||||
mkdir -p "$STUB_DIR/owner"; echo example.com > "$STUB_DIR/owner/repo.body"
|
||||
run_tool check
|
||||
@@ -198,7 +276,12 @@ run_tool() { run "$BIN" "$@" --config "$T/config"; }
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"token_file points at $T/token"* ]]
|
||||
[[ "$output" == *"does not exist"* ]]
|
||||
[[ "$output" == *"READ-ONLY forge token"* ]]
|
||||
# Which kind of token, not just that one is wanted. A forge hands out two
|
||||
# and this tool asks for both; naming only "a token" is how the wrong one
|
||||
# ends up in the file, where it authenticates against nothing and the only
|
||||
# symptom is a 401 from an unrelated-looking command.
|
||||
[[ "$output" == *"API token"* ]]
|
||||
[[ "$output" == *"registration token"* ]]
|
||||
}
|
||||
|
||||
@test "every configuration problem is reported in one run, not one per run" {
|
||||
@@ -257,12 +340,43 @@ run_tool() { run "$BIN" "$@" --config "$T/config"; }
|
||||
# there at a root-owned path - sending them to create something that already
|
||||
# existed. The two cases need different messages because they need different
|
||||
# actions.
|
||||
@test "a missing config and an unreadable one are different messages" {
|
||||
@test "a config behind an unsearchable directory is not reported as missing" {
|
||||
# /etc/dejima-ci is 0750 root-owned, so every unprivileged run cannot even
|
||||
# stat the file inside it. Reporting that as "no config - run install.sh"
|
||||
# sends someone to redo an install that already succeeded, when the only
|
||||
# thing missing is sudo.
|
||||
[ "$(id -u)" -ne 0 ] || skip "root searches any directory, so this cannot arise"
|
||||
mkdir -p "$T/locked"
|
||||
cp "$T/config" "$T/locked/config"
|
||||
chmod 000 "$T/locked"
|
||||
run "$BIN" check --config "$T/locked/config"
|
||||
chmod 755 "$T/locked"
|
||||
[ "$status" -eq 2 ]
|
||||
[[ "$output" == *"not searchable"* ]]
|
||||
[[ "$output" == *"sudo"* ]]
|
||||
[[ "$output" != *"run install.sh to create it"* ]]
|
||||
}
|
||||
|
||||
@test "a missing config says so, and says what creates one" {
|
||||
# Not run_tool: it appends its own --config, which would win over this one.
|
||||
run "$BIN" check --config "$T/does-not-exist"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"no config at"* ]]
|
||||
[[ "$output" == *"install.sh"* ]]
|
||||
}
|
||||
|
||||
@test "an unreadable config is a different message from a missing one" {
|
||||
# Only a caller who cannot read the file ever sees this branch, which makes
|
||||
# the assertion depend on who is running it. `[ -r ]` is access(2) with
|
||||
# R_OK, and that always succeeds for a process holding CAP_DAC_OVERRIDE -
|
||||
# so as root the file is readable at mode 000, the branch is unreachable,
|
||||
# and this fails on the privilege of the caller rather than on anything the
|
||||
# program does.
|
||||
#
|
||||
# Skipped rather than asserted-around, because there is nothing here to
|
||||
# check when the caller can bypass the permission being tested. CI runs the
|
||||
# suite as an ordinary user precisely so that this runs there.
|
||||
[ "$(id -u)" -ne 0 ] || skip "root reads a mode-000 file, so this branch is unreachable"
|
||||
|
||||
chmod 000 "$T/config"
|
||||
run_tool render
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Asserts on the RUNNER, not on this repository. Every other suite here tests
|
||||
# what this repository's programs decide, against stubs. This one tests the
|
||||
# machine those programs configured, from inside a job on it - which is the
|
||||
# only place the answer is knowable.
|
||||
#
|
||||
# It exists because both failures that matter look exactly like success from
|
||||
# inside a job. Nothing in a job's log mentions whether the Docker socket was
|
||||
# mounted into it, and a job that can reach an off-list resolver still passes
|
||||
# every check that only inspects DOCKER-USER. Neither shows up as a red run;
|
||||
# they show up as nothing at all.
|
||||
#
|
||||
# This is also the check the rest of the repository cannot make. install.sh
|
||||
# reports what it wrote, dejima-ci-runners reports that a runner registered and
|
||||
# is reading the config it generated - and a runner can do all of that while
|
||||
# granting its jobs everything. The properties below are the ones the whole
|
||||
# tool exists for, and they are only observable from in here.
|
||||
#
|
||||
# DEJIMA_CI_TIER says which runner this is supposed to be, set by the workflow
|
||||
# rather than sniffed, so landing on the wrong runner is a failure rather than
|
||||
# a different set of assertions quietly being chosen. Unset means this is not a
|
||||
# CI runner and there is nothing here to assert - which is what makes the file
|
||||
# safe to leave in `bats test/` on a laptop.
|
||||
#
|
||||
# Its values are the tier names this repository uses everywhere else, and the
|
||||
# names of the jobs whose environment they verify. A reader looking at a red
|
||||
# `runner-env (confined)` should not have to work out which job that is.
|
||||
|
||||
setup() {
|
||||
[ -n "${DEJIMA_CI_TIER:-}" ] || skip "not on a CI runner (DEJIMA_CI_TIER unset)"
|
||||
case "$DEJIMA_CI_TIER" in
|
||||
confined|privileged) ;;
|
||||
# The old spellings, so a workflow that has not caught up says so
|
||||
# rather than silently asserting nothing.
|
||||
ci|runtime|sandbox|host-docker)
|
||||
echo "DEJIMA_CI_TIER=$DEJIMA_CI_TIER is an old tier name." >&2
|
||||
echo "This repository's tiers are 'confined' and 'privileged'." >&2
|
||||
return 1 ;;
|
||||
*) echo "unknown DEJIMA_CI_TIER: $DEJIMA_CI_TIER" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# An off-list destination that would answer if it were reachable. This matters
|
||||
# more than it looks: pointing these at an address that never answers - a
|
||||
# documentation range, say - would pass in an environment with no network at
|
||||
# all, which is the exact failure this suite exists to detect.
|
||||
OFF_LIST_IP=1.1.1.1
|
||||
|
||||
# The positive control's destination. Overridable because it has to be a name
|
||||
# THIS runner allows, and an allowlist is per-operator - hardcoding one would
|
||||
# fail on a runner correctly configured for a different set. The default is
|
||||
# what install.sh offers in base-domains, and what .dejima/allowed-domains in
|
||||
# this repository declares.
|
||||
ALLOWED_HOST=${DEJIMA_CI_ALLOWED_HOST:-deb.debian.org}
|
||||
|
||||
connects() { # connects HOST PORT -> 0 if a TCP connection completes
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import socket, sys
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=6).close()
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# --- the grant that decides everything --------------------------------------
|
||||
|
||||
@test "the confined tier does not hand jobs the Docker daemon" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "asserted for the confined runner"
|
||||
# act_runner mounts /var/run/docker.sock into every job container unless
|
||||
# container.docker_host is "-". An unset value is the default, and the
|
||||
# default mounts - so this is the assertion that a runner was actually
|
||||
# configured rather than merely intended.
|
||||
[ ! -e /var/run/docker.sock ] || {
|
||||
echo "this runner grants job containers the Docker socket, which is" >&2
|
||||
echo "root-equivalent on the runner host." >&2
|
||||
echo "" >&2
|
||||
echo "container.docker_host must be exactly \"-\" in its config.yaml." >&2
|
||||
echo "dejima-ci-runners --role confined writes that; if it is right in" >&2
|
||||
echo "the file and wrong here, check CONFIG_FILE=/config.yaml is in the" >&2
|
||||
echo "runner's environment - without it the file is not read at all." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the privileged tier does have the daemon it is scheduled for" {
|
||||
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
|
||||
# The positive control for the label routing. Without it, a privileged job
|
||||
# that landed on a confined runner would fail later and obscurely, and a
|
||||
# confined job that landed on this one would be caught only by the test
|
||||
# above - one direction of a two-direction problem.
|
||||
[ -e /var/run/docker.sock ]
|
||||
}
|
||||
|
||||
@test "the privileged tier is in the host network namespace" {
|
||||
[ "$DEJIMA_CI_TIER" = privileged ] || skip "asserted for the privileged runner"
|
||||
# The tier's second grant, and the reason its jobs have no allowlist in
|
||||
# front of them. Read sysfs rather than shelling out to `ip`:
|
||||
# /sys/class/net is per-netns and always present, while iproute2 may not
|
||||
# be installed - which would turn "the tool is missing" into "the host
|
||||
# netns is missing".
|
||||
[ -e /sys/class/net/docker0 ] || {
|
||||
echo "this job is not in the host network namespace - docker0 is not" >&2
|
||||
echo "visible. Set container.network: host in the runner's config.yaml;" >&2
|
||||
echo "dejima-ci-runners --role privileged writes it." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "the two tiers are not the same runner" {
|
||||
# Whatever else is true, exactly one of them gets the daemon. If both jobs
|
||||
# report the same answer here, the labels are not routing to two different
|
||||
# runners and the tiering exists only on paper.
|
||||
if [ "$DEJIMA_CI_TIER" = confined ]; then
|
||||
[ ! -e /var/run/docker.sock ]
|
||||
else
|
||||
[ -e /var/run/docker.sock ]
|
||||
fi
|
||||
}
|
||||
|
||||
# --- egress, for the tier the rules actually apply to ------------------------
|
||||
#
|
||||
# Deliberately scoped to the confined runner. The privileged tier's jobs run in
|
||||
# the HOST network namespace - that is the grant they exist for - so the bridge
|
||||
# rules dejima-ci-firewall installs do not sit in front of them at all. Their
|
||||
# egress is the host's own, bounded by the hypervisor rather than by anything
|
||||
# this repository installs. Asserting an allowlist there would test nothing and
|
||||
# pass anyway, which is the failure mode this whole suite is about.
|
||||
|
||||
@test "an allowlisted destination is reachable" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# The positive control for everything below. Without it, a runner with no
|
||||
# network at all passes every negative assertion in this file.
|
||||
run getent hosts "$ALLOWED_HOST"
|
||||
[ "$status" -eq 0 ] || {
|
||||
echo "$ALLOWED_HOST did not resolve, so the negative checks below" >&2
|
||||
echo "would pass for the wrong reason." >&2
|
||||
echo "" >&2
|
||||
echo "On the runner host: dejima-ci-allowlist show - is there a server=" >&2
|
||||
echo "line for this name? It comes from .dejima/allowed-domains on the" >&2
|
||||
echo "main branch of each served repository, plus base-domains. Or set" >&2
|
||||
echo "DEJIMA_CI_ALLOWED_HOST to a name this runner does allow." >&2
|
||||
false
|
||||
}
|
||||
run connects "$ALLOWED_HOST" 80
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "a name that is on no allowlist does not resolve" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# .invalid is reserved by RFC 2606 precisely so that it never resolves
|
||||
# anywhere - but here it has to be REFUSED by a resolver that answers for
|
||||
# allowlisted names, which the positive control above establishes.
|
||||
run getent hosts example.invalid
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "a job cannot reach a resolver of its own choosing" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# The check most easily left out, and the one that decides whether the DNS
|
||||
# allowlist means anything: a job that can query any resolver it likes can
|
||||
# resolve anything it likes, whatever dnsmasq is configured with. Traffic
|
||||
# to the resolver on the bridge gateway is addressed to the host, so it is
|
||||
# an INPUT packet and never passes through DOCKER-USER - filtering only
|
||||
# DOCKER-USER leaves this open.
|
||||
run connects "$OFF_LIST_IP" 53
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:53, so this job can use any resolver it likes" >&2
|
||||
echo "and the DNS allowlist is decorative. The INPUT rules are missing -" >&2
|
||||
echo "re-run dejima-ci-firewall on the runner host." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "a destination that is on no allowlist is unreachable" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# An IP literal rather than a name, so this tests the firewall rather than
|
||||
# the resolver. Reaching it by name would fail at DNS first and prove
|
||||
# nothing about DOCKER-USER.
|
||||
run connects "$OFF_LIST_IP" 443
|
||||
[ "$status" -ne 0 ] || {
|
||||
echo "reached $OFF_LIST_IP:443, so this job has unrestricted egress." >&2
|
||||
echo "" >&2
|
||||
echo "If the DNS checks above passed, the resolver is fine and the packet" >&2
|
||||
echo "filter is not - which usually means DOCKER-USER is empty. Docker" >&2
|
||||
echo "recreates its own chains on every daemon start, so a 'systemctl" >&2
|
||||
echo "restart docker' drops these rules and nothing looks different" >&2
|
||||
echo "afterwards. Check with: iptables -S DOCKER-USER" >&2
|
||||
echo "" >&2
|
||||
echo "dejima-ci-firewall.service reapplies them on every Docker start," >&2
|
||||
echo "which is the fix rather than a one-off re-run." >&2
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@test "IPv6 egress is not an unfiltered way around all of the above" {
|
||||
[ "$DEJIMA_CI_TIER" = confined ] || skip "bridge rules do not apply in the host netns"
|
||||
# An address family with no allowlist is not a smaller hole than the one
|
||||
# the rules above close; on a runner with IPv6 egress it is the same hole.
|
||||
#
|
||||
# Unlike the checks above, this one passes for two different reasons - v6
|
||||
# rejected, or v6 never configured - and both are acceptable outcomes, so
|
||||
# it is not given a positive control. What it rules out is the third case:
|
||||
# a runner with working IPv6 egress and rules written only for v4.
|
||||
run connects 2606:4700:4700::1111 443
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Drives bin/dejima-ci-runners against a stubbed docker, because the properties
|
||||
# worth checking are all in what it renders rather than in what Docker does
|
||||
# with it afterwards.
|
||||
#
|
||||
# The values under test are the ones whose halves disagreeing produces a runner
|
||||
# that starts, accepts jobs, and enforces nothing - container.network against
|
||||
# the network install.sh created, --dns against the resolver's address,
|
||||
# docker_host against the tier. Every one of those failures is silent on the
|
||||
# host and invisible in the forge, which is the whole reason they are generated
|
||||
# rather than copied.
|
||||
|
||||
setup() {
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
RUNNERS="$REPO/bin/dejima-ci-runners"
|
||||
TMP="$BATS_TEST_TMPDIR"
|
||||
|
||||
export TEMPLATE_DIR="$REPO/examples"
|
||||
export CONF_DIR="$TMP/conf"
|
||||
mkdir -p "$CONF_DIR"
|
||||
cat > "$CONF_DIR/config" <<EOF
|
||||
network = testnet
|
||||
gateway = 10.99.0.1
|
||||
forge_api = https://forge.example.com/api/v1
|
||||
EOF
|
||||
printf 'acme/widget\n' > "$CONF_DIR/repos"
|
||||
|
||||
# Enough docker for the generator to get past its preflight. It never
|
||||
# reaches `up` in these tests: --no-start stops before it.
|
||||
mkdir -p "$TMP/stub"
|
||||
cat > "$TMP/stub/docker" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
[ "$1 $2" = "compose version" ] && { echo "Docker Compose version v2.29.0"; exit 0; }
|
||||
exit 0
|
||||
STUB
|
||||
# getent passwd, so the home the directory is derived from is this test's
|
||||
# rather than whoever is running it.
|
||||
cat > "$TMP/stub/getent" <<STUB
|
||||
#!/bin/sh
|
||||
[ "\$1" = passwd ] && { echo "tester:x:1000:1000::$TMP/home:/bin/bash"; exit 0; }
|
||||
exec /usr/bin/getent "\$@"
|
||||
STUB
|
||||
chmod +x "$TMP/stub/docker" "$TMP/stub/getent"
|
||||
export PATH="$TMP/stub:$PATH"
|
||||
|
||||
OUT="$TMP/runners"
|
||||
}
|
||||
|
||||
confined() {
|
||||
run bash "$RUNNERS" --role confined --dir "$OUT" --non-interactive --no-start "$@"
|
||||
}
|
||||
privileged() {
|
||||
run bash "$RUNNERS" --role privileged --dir "$OUT" --non-interactive --no-start \
|
||||
--forge https://forge.example.com "$@"
|
||||
}
|
||||
|
||||
@test "the job network and resolver come from the config, not from a prompt" {
|
||||
confined
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^ network: "testnet"$' "$OUT/config.yaml"
|
||||
grep -q -- '--dns=10.99.0.1 ' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "the confined tier denies job containers the Docker socket" {
|
||||
confined
|
||||
grep -q '^ docker_host: "-"$' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "the privileged tier grants it, and host networking with it" {
|
||||
privileged
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^ docker_host: ""$' "$OUT/config.yaml"
|
||||
grep -q '^ network: host$' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "CONFIG_FILE is set, without which the config is never read at all" {
|
||||
confined
|
||||
grep -q 'CONFIG_FILE: /config.yaml' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "the compose file mounts the config this run wrote, read-only" {
|
||||
confined
|
||||
grep -q './config.yaml:/config.yaml:ro' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "one service and one data volume per runner asked for" {
|
||||
confined --count 4
|
||||
[ "$(grep -c 'container_name:' "$OUT/docker-compose.yml")" -eq 4 ]
|
||||
[ "$(grep -c -- '-data":$' "$OUT/docker-compose.yml")" -eq 4 ]
|
||||
}
|
||||
|
||||
@test "the repeated block does not swallow the keys around it" {
|
||||
# The fence markers appear in the template's own explanatory header. A
|
||||
# substring match treated that sentence as the opening fence and consumed
|
||||
# the project name, the anchors and the services: key into the block.
|
||||
confined --count 2
|
||||
grep -q '^name: ' "$OUT/docker-compose.yml"
|
||||
grep -q '^services:$' "$OUT/docker-compose.yml"
|
||||
grep -q '^x-ci-runner: &ci-runner$' "$OUT/docker-compose.yml"
|
||||
grep -q '^volumes:$' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "no template marker survives into either rendered file" {
|
||||
confined
|
||||
! grep -q '@@' "$OUT/docker-compose.yml"
|
||||
! grep -q '@@' "$OUT/config.yaml"
|
||||
privileged
|
||||
! grep -q '@@' "$OUT/docker-compose.yml"
|
||||
! grep -q '@@' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "the label reaches runs-on, the container name and the volume together" {
|
||||
confined --label pool-x --count 2
|
||||
grep -q '"pool-x:docker://' "$OUT/config.yaml"
|
||||
grep -q 'container_name: "pool-x-1"' "$OUT/docker-compose.yml"
|
||||
grep -q 'pool-x-2-data:/data' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "the job image is what the label offers to run jobs in" {
|
||||
confined --job-image debian:bookworm
|
||||
grep -q 'docker://debian:bookworm"' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "the act_runner image is pinned to what was asked for" {
|
||||
confined --runner-image gitea/act_runner:0.7.0
|
||||
grep -q 'image: "gitea/act_runner:0.7.0"' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "'none' means no host path may be bind-mounted, and is not the default" {
|
||||
privileged --volumes none
|
||||
grep -q '^ valid_volumes: \[\]$' "$OUT/config.yaml"
|
||||
privileged --volumes "/tmp/a /tmp/b"
|
||||
grep -q '^ - /tmp/a$' "$OUT/config.yaml"
|
||||
grep -q '^ - /tmp/b$' "$OUT/config.yaml"
|
||||
}
|
||||
|
||||
@test "the token file is 0600, because it can register runners on your forge" {
|
||||
confined
|
||||
[ "$(stat -c %a "$OUT/.env")" = "600" ]
|
||||
}
|
||||
|
||||
@test "a second run with the same answers changes nothing" {
|
||||
confined
|
||||
confined
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"unchanged $OUT/docker-compose.yml"* ]]
|
||||
[[ "$output" == *"unchanged $OUT/config.yaml"* ]]
|
||||
}
|
||||
|
||||
@test "a token already present is kept rather than blanked by a re-run" {
|
||||
confined
|
||||
printf 'GITEA_RUNNER_REGISTRATION_TOKEN=keepme\n' > "$OUT/.env"
|
||||
confined
|
||||
grep -q 'GITEA_RUNNER_REGISTRATION_TOKEN=keepme' "$OUT/.env"
|
||||
}
|
||||
|
||||
@test "nothing is started while the token is still missing" {
|
||||
run bash "$RUNNERS" --role confined --dir "$OUT" --non-interactive
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"No token yet"* ]]
|
||||
}
|
||||
|
||||
@test "the token page named is the forge's, and org scope comes from repos" {
|
||||
confined
|
||||
[[ "$output" == *"https://forge.example.com/org/acme/settings/actions/runners"* ]]
|
||||
[[ "$output" == *"Do NOT use the instance-level page"* ]]
|
||||
}
|
||||
|
||||
@test "a confined host with no job network is refused, not guessed at" {
|
||||
rm -f "$CONF_DIR/config"
|
||||
confined
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"run install.sh first"* ]]
|
||||
}
|
||||
|
||||
@test "a privileged host needs no config, since install.sh never ran there" {
|
||||
rm -f "$CONF_DIR/config"
|
||||
privileged
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'GITEA_INSTANCE_URL: "https://forge.example.com"' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "a forge that cannot be determined is an error rather than an empty URL" {
|
||||
rm -f "$CONF_DIR/config"
|
||||
run bash "$RUNNERS" --role privileged --dir "$OUT" --non-interactive --no-start
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"forge URL is required"* ]]
|
||||
}
|
||||
|
||||
@test "an api root given as the forge URL is not doubled up" {
|
||||
privileged --forge https://forge.example.com/api/v1
|
||||
grep -q 'GITEA_INSTANCE_URL: "https://forge.example.com"$' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "the privileged tier is one runner regardless of --count" {
|
||||
privileged --count 5
|
||||
[ "$(grep -c 'container_name:' "$OUT/docker-compose.yml")" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "a count that is not a usable number stops rather than being coerced" {
|
||||
confined --count abc
|
||||
[ "$status" -ne 0 ]
|
||||
confined --count 0
|
||||
[ "$status" -ne 0 ]
|
||||
confined --count 99
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "the old role spellings still work and say what they are now" {
|
||||
run bash "$RUNNERS" --role ci --dir "$OUT" --non-interactive --no-start
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"--role ci is now --role confined"* ]]
|
||||
}
|
||||
|
||||
@test "the templates themselves are parseable YAML" {
|
||||
# Not incidental. The markers are read in an editor - that is what makes
|
||||
# examples/ worth keeping - and a bare @@MARKER@@ as a value is invalid
|
||||
# YAML, because `@` is a reserved indicator that a plain scalar may not
|
||||
# begin with. Every marker standing in for a scalar is quoted for that
|
||||
# reason, and anything standing in for a structure is a fenced block.
|
||||
if ! command -v js-yaml >/dev/null 2>&1 && ! python3 -c 'import yaml' 2>/dev/null; then
|
||||
skip "no YAML parser available"
|
||||
fi
|
||||
parse() {
|
||||
if command -v js-yaml >/dev/null 2>&1; then js-yaml "$1" >/dev/null
|
||||
else python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$1"; fi
|
||||
}
|
||||
for f in "$TEMPLATE_DIR"/*.yaml "$TEMPLATE_DIR"/*.yml; do
|
||||
parse "$f" || { echo "unparseable template: $f"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "with no --dir, the project lands under the group's own directory" {
|
||||
# The default has to exist and has to carry the group name: a prompt with
|
||||
# nothing in its brackets asks the operator to invent a path, and inventing
|
||||
# one is how a second group ends up somewhere the first cannot sit beside.
|
||||
run bash "$RUNNERS" --role confined --non-interactive --no-start --name grp
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$TMP/home/runners/grp/docker-compose.yml" ]
|
||||
[ -f "$TMP/home/runners/grp/config.yaml" ]
|
||||
}
|
||||
|
||||
@test "the derived directory follows the group name, not the label" {
|
||||
run bash "$RUNNERS" --role confined --non-interactive --no-start \
|
||||
--name second --label shared
|
||||
[ "$status" -eq 0 ]
|
||||
[ -d "$TMP/home/runners/second" ]
|
||||
[ ! -d "$TMP/home/runners/shared" ]
|
||||
}
|
||||
|
||||
@test "two groups on one host can share a label without colliding" {
|
||||
# The reason to register a second token at all: one group at organization
|
||||
# scope and another for a user account, both answering the same label so
|
||||
# either scope's workflows reach a runner. Sharing the label is the point;
|
||||
# sharing a compose project, container names or volumes would mean the
|
||||
# second group quietly replacing the first.
|
||||
confined --name org --label shared --count 2
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
OUT="$TMP/second" confined --name user --label shared --count 2
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
grep -q '^name: "org-runners"$' "$TMP/runners/docker-compose.yml"
|
||||
grep -q '^name: "user-runners"$' "$TMP/second/docker-compose.yml"
|
||||
grep -q 'container_name: "org-1"' "$TMP/runners/docker-compose.yml"
|
||||
grep -q 'container_name: "user-1"' "$TMP/second/docker-compose.yml"
|
||||
grep -q '"org-1-data":' "$TMP/runners/docker-compose.yml"
|
||||
grep -q '"user-1-data":' "$TMP/second/docker-compose.yml"
|
||||
|
||||
# Nothing identifying one group appears in the other.
|
||||
! grep -q 'user-' "$TMP/runners/docker-compose.yml"
|
||||
! grep -q '\borg-' "$TMP/second/docker-compose.yml"
|
||||
|
||||
# ...while both answer the same label, which is what a workflow names.
|
||||
grep -q '"shared:docker://' "$TMP/runners/config.yaml"
|
||||
grep -q '"shared:docker://' "$TMP/second/config.yaml"
|
||||
}
|
||||
|
||||
@test "the group name defaults to the label, so one group needs no --name" {
|
||||
confined --label solo
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^name: "solo-runners"$' "$OUT/docker-compose.yml"
|
||||
grep -q 'container_name: "solo-1"' "$OUT/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "a group name that would break a path or a compose project is refused" {
|
||||
confined --name 'a/b'
|
||||
[ "$status" -ne 0 ]
|
||||
confined --name 'a b'
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "both rendered files are parseable YAML" {
|
||||
if ! command -v js-yaml >/dev/null 2>&1 && ! python3 -c 'import yaml' 2>/dev/null; then
|
||||
skip "no YAML parser available"
|
||||
fi
|
||||
parse() {
|
||||
if command -v js-yaml >/dev/null 2>&1; then js-yaml "$1" >/dev/null
|
||||
else python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$1"; fi
|
||||
}
|
||||
confined --count 3
|
||||
parse "$OUT/docker-compose.yml"
|
||||
parse "$OUT/config.yaml"
|
||||
privileged
|
||||
parse "$OUT/docker-compose.yml"
|
||||
parse "$OUT/config.yaml"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# Drives bin/dejima-ci-static-ip against a stubbed ip/ifup/systemctl and
|
||||
# fixture copies of the two files it edits.
|
||||
#
|
||||
# What is worth testing here is not that it can write a stanza. It is that the
|
||||
# values it writes are the ones the host already has - a conversion that
|
||||
# renumbers anything is one that costs you the machine over SSH - and that
|
||||
# every way of producing an unusable configuration ends with the original files
|
||||
# back in place. A host whose /etc/network/interfaces does not parse, or whose
|
||||
# resolv.conf names no server, does not fail now: it fails at its next start,
|
||||
# with nobody watching and nothing pointing here.
|
||||
|
||||
setup() {
|
||||
BIN="${BATS_TEST_DIRNAME}/../bin/dejima-ci-static-ip"
|
||||
T="$BATS_TEST_TMPDIR"
|
||||
mkdir -p "$T/bin"
|
||||
|
||||
cat > "$T/bin/ip" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "$*" in
|
||||
"route show default")
|
||||
[ -n "${STUB_NO_ROUTE:-}" ] && exit 0
|
||||
echo "default via 192.0.2.1 dev ens18 onlink" ;;
|
||||
*"addr show dev ens18"*)
|
||||
[ -n "${STUB_NO_ADDR:-}" ] && exit 0
|
||||
echo "2: ens18 inet 192.0.2.58/24 brd 192.0.2.255 scope global ens18" ;;
|
||||
esac
|
||||
EOF
|
||||
# ifup --no-act: the syntax check. STUB_IFUP_FAIL makes it reject.
|
||||
cat > "$T/bin/ifup" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${STUB_IFUP_FAIL:-}" ] && exit 1
|
||||
exit 0
|
||||
EOF
|
||||
# No DHCP client units by default.
|
||||
printf '#!/bin/sh\nexit 1\n' > "$T/bin/systemctl"
|
||||
chmod +x "$T/bin"/*
|
||||
export PATH="$T/bin:$PATH"
|
||||
|
||||
export IFACES="$T/interfaces" RESOLV="$T/resolv" RESOLV_HEAD="$T/resolv.head"
|
||||
cat > "$IFACES" <<'EOF'
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
allow-hotplug ens18
|
||||
iface ens18 inet dhcp
|
||||
EOF
|
||||
printf 'nameserver 192.0.2.11\n' > "$RESOLV"
|
||||
}
|
||||
|
||||
run_tool() { run bash "$BIN" --non-interactive "$@"; }
|
||||
|
||||
@test "writes the address the interface already has, not a new one" {
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^ address 192.0.2.58/24$' "$IFACES"
|
||||
grep -q '^ gateway 192.0.2.1$' "$IFACES"
|
||||
}
|
||||
|
||||
@test "the dhcp stanza becomes static, and comes up at boot" {
|
||||
run_tool
|
||||
grep -q '^iface ens18 inet static$' "$IFACES"
|
||||
! grep -q 'inet dhcp' "$IFACES"
|
||||
# auto, not allow-hotplug: a machine restarted on a schedule needs its
|
||||
# network back as part of boot rather than when a device is enumerated.
|
||||
grep -q '^auto ens18$' "$IFACES"
|
||||
! grep -q 'allow-hotplug' "$IFACES"
|
||||
}
|
||||
|
||||
@test "other interfaces are left exactly as they were" {
|
||||
run_tool
|
||||
grep -q '^auto lo$' "$IFACES"
|
||||
grep -q '^iface lo inet loopback$' "$IFACES"
|
||||
grep -q '^source /etc/network/interfaces.d/\*$' "$IFACES"
|
||||
}
|
||||
|
||||
@test "the resolver is written to both the file and the head file" {
|
||||
run_tool
|
||||
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
|
||||
grep -q '^nameserver 192.0.2.11$' "$RESOLV_HEAD"
|
||||
}
|
||||
|
||||
@test "several nameservers all survive" {
|
||||
printf 'nameserver 192.0.2.11\nnameserver 192.0.2.12\n' > "$RESOLV"
|
||||
run_tool
|
||||
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
|
||||
grep -q '^nameserver 192.0.2.12$' "$RESOLV"
|
||||
}
|
||||
|
||||
@test "running it twice changes nothing the second time" {
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
cp "$IFACES" "$T/first"
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"already configured statically"* ]]
|
||||
cmp "$T/first" "$IFACES"
|
||||
}
|
||||
|
||||
@test "a host with no resolver is refused rather than pinned without one" {
|
||||
# The failure this prevents is silent and delayed: the configuration is
|
||||
# written, the running host keeps resolving on what it already had, and the
|
||||
# next start produces a machine that resolves nothing.
|
||||
: > "$RESOLV"
|
||||
run_tool
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"no nameserver"* ]]
|
||||
grep -q 'inet dhcp' "$IFACES"
|
||||
}
|
||||
|
||||
@test "an explicit nameserver satisfies a host that has none" {
|
||||
: > "$RESOLV"
|
||||
run_tool --nameserver 192.0.2.11
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
|
||||
}
|
||||
|
||||
@test "a loopback stub is not written as if it were a real server" {
|
||||
# 127.0.0.53 is systemd-resolved's stub. Pinning it leaves the host
|
||||
# pointing at something that is not there once resolved is not running.
|
||||
printf 'nameserver 127.0.0.53\n' > "$RESOLV"
|
||||
cat > "$T/bin/resolvectl" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo " DNS Servers: 192.0.2.11"
|
||||
EOF
|
||||
chmod +x "$T/bin/resolvectl"
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
|
||||
! grep -q '127.0.0.53' "$RESOLV"
|
||||
}
|
||||
|
||||
@test "a file that will not parse is put back, not left behind" {
|
||||
STUB_IFUP_FAIL=1 run_tool
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"put back unchanged"* ]]
|
||||
grep -q 'inet dhcp' "$IFACES"
|
||||
! grep -q 'inet static' "$IFACES"
|
||||
}
|
||||
|
||||
@test "an interface configured some other way is refused, not rewritten" {
|
||||
printf 'auto lo\niface lo inet loopback\n' > "$IFACES"
|
||||
run_tool
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"inet dhcp"* ]]
|
||||
}
|
||||
|
||||
@test "no default route means there is nothing to read, and it says so" {
|
||||
STUB_NO_ROUTE=1 run_tool
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"no default route"* ]]
|
||||
}
|
||||
|
||||
@test "an address given without a prefix is refused" {
|
||||
run_tool --address 192.0.2.58
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"prefix"* ]]
|
||||
}
|
||||
|
||||
@test "the original interfaces file is kept as a backup" {
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
ls "$IFACES".dejima-* >/dev/null 2>&1
|
||||
grep -q 'inet dhcp' "$IFACES".dejima-*
|
||||
}
|
||||
|
||||
@test "a DHCP client still enabled is disabled" {
|
||||
cat > "$T/bin/systemctl" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
list-unit-files) [ "$2" = "dhcpcd.service" ] && exit 0; exit 1 ;;
|
||||
is-enabled) [ "$2" = "dhcpcd" ] && exit 0; exit 1 ;;
|
||||
disable) echo "disabled dhcpcd" >> "$STUB_LOG"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$T/bin/systemctl"
|
||||
export STUB_LOG="$T/systemctl.log"; : > "$STUB_LOG"
|
||||
run_tool
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q 'disabled dhcpcd' "$STUB_LOG"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bats
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Brad Anderson
|
||||
#
|
||||
# The systemd units, checked against where the installer actually puts things.
|
||||
#
|
||||
# These exist because the failure they guard has no symptom worth the name. A
|
||||
# unit naming a program that is not there fails with 203/EXEC on every start,
|
||||
# and for a timer that means the allowlist silently stops being refreshed: no
|
||||
# error anyone sees, no change to the file, and a host that looks configured
|
||||
# while merging a domain to main does nothing at all.
|
||||
|
||||
setup() {
|
||||
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
T="$BATS_TEST_TMPDIR"
|
||||
}
|
||||
|
||||
@test "no unit hard-codes a path to a program the installer places" {
|
||||
# The path belongs to install.sh. Written down a second time it can only
|
||||
# agree by luck, and it did not.
|
||||
run grep -nE '^Exec(Start|Stop)=/(usr|opt|bin|sbin)' "$REPO"/systemd/*.service
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "every unit's ExecStart resolves to the directory the installer uses" {
|
||||
bin=$(sed -n 's|^BIN_DIR=${BIN_DIR:-\(.*\)}|\1|p' "$REPO/install.sh")
|
||||
[ -n "$bin" ]
|
||||
for u in "$REPO"/systemd/*.service; do
|
||||
exe=$(sed -n 's|^ExecStart=\([^ ]*\).*|\1|p' "$u" | head -1)
|
||||
[ -n "$exe" ]
|
||||
# After substitution it must name a program this repository ships.
|
||||
resolved="${exe/@@BIN_DIR@@/$bin}"
|
||||
[ "$resolved" != "$exe" ] || { echo "$u: ExecStart is not templated: $exe"; false; }
|
||||
prog="${resolved##*/}"
|
||||
[ -f "$REPO/bin/$prog" ] || { echo "$u: names $prog, which bin/ does not ship"; false; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "install.sh substitutes the marker rather than copying it through" {
|
||||
grep -q '@@BIN_DIR@@' "$REPO/install.sh"
|
||||
# And refuses to finish if what it wrote is not executable.
|
||||
grep -q 'which is not executable' "$REPO/install.sh"
|
||||
}
|
||||
Reference in New Issue
Block a user