A runner VM is pinned to the day it was built, with half its controls typed into a hypervisor console no `git pull` can reach. dejima needs nothing like this - short-lived containers, only the tip of main supported, a pull closes any gap - and the difference is worth stating rather than inheriting. AND THIS PROJECT HAS A SECOND WAY OF GOING BACKWARDS that the sister project does not: the scheduled rollback. A guest-side change applied to a running runner and never baked into the snapshot is undone at the next rebuild, on a timer, with nothing to see. So a guest-layer upgrade here is a seven-step sequence, and `status` prints it whenever one is outstanding rather than leaving it to be remembered. The counter-intuitive step is rolling back to the clean snapshot BEFORE applying the change, so the new rollback target is not whatever the last few weeks of jobs left behind. The state file lives in the guest, inside the snapshot, so it rolls back with everything else. That is correct rather than unfortunate: forget to re-snapshot and the marker reverts alongside the change it recorded, so the tool goes back to reporting the upgrade as outstanding. It fails towards saying there is work to do. Integer versions, one note per version including versions that need nothing. Two numbers tracked: guest_version written by install.sh, node_version asserted by an operator, because nothing in the guest can read the hypervisor's rules - which is what makes those rules the boundary. TIER IS RECORDED, NEVER DETECTED. An absent /etc/dejima-ci/config identifies a privileged host correctly and also identifies a confined one whose install.sh has not run, and guessing wrong selects a different set of upgrades - the same failure test/ci-runner.bats takes DEJIMA_CI_TIER rather than sniffing for. The privileged tier reports no guest half at all rather than an unrecorded one, since installing nothing there is the grant that tier exists for. `check-lan` gives mark-node something to contradict a wrong assertion with: it asks ff02::1 who is on the link and counts the replies that are not this VM's own. It is a narrow test of one node rule, not the in-VM audit that PR #18 said was out of scope, and the docs say so where an asymmetry with the sister project would otherwise look like parity. v2 needs node work only, so it asks for no re-snapshot. A test pins that, because reporting the sequence when it does not apply would train an operator to ignore it when it does. Tested with `bats test/` (125 passing, up from 100).
879 lines
38 KiB
Bash
Executable File
879 lines
38 KiB
Bash
Executable File
#!/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.
|
|
# Existing values become the defaults, files are only rewritten when their
|
|
# content actually differs, and an existing token is kept unless you type a new
|
|
# one. So this is also the way to change one setting later.
|
|
#
|
|
# Runs on a CI host. Never on a host that runs dejima.
|
|
|
|
set -euo pipefail
|
|
|
|
PROG=${0##*/}
|
|
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}
|
|
SHARE_DIR=${SHARE_DIR:-/usr/local/share/dejima-ci}
|
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
CONFIG="$CONF_DIR/config"
|
|
TOKEN_FILE="$CONF_DIR/forge-token"
|
|
REPOS_FILE="$CONF_DIR/repos"
|
|
BASE_FILE="$CONF_DIR/base-domains"
|
|
|
|
NONINTERACTIVE=0
|
|
[ -t 0 ] || NONINTERACTIVE=1
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
-n|--non-interactive) NONINTERACTIVE=1 ;;
|
|
-h|--help)
|
|
sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
|
|
echo
|
|
echo "Usage: $PROG [--non-interactive]"
|
|
exit 0 ;;
|
|
*) echo "$PROG: unknown argument: $a" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
die() { echo "$PROG: $*" >&2; exit 1; }
|
|
say() { printf '%s\n' "$*" >&2; }
|
|
|
|
# Writability rather than uid: root is only a proxy for being able to write
|
|
# these, and the proxy is wrong whenever the directories are overridden.
|
|
writable() { mkdir -p "$1" 2>/dev/null || true; [ -d "$1" ] && [ -w "$1" ]; }
|
|
for d in "$CONF_DIR" "$BIN_DIR"; do
|
|
writable "$d" || die "cannot write $d - run as root, or set CONF_DIR/BIN_DIR/UNIT_DIR"
|
|
done
|
|
INSTALL_UNITS=1
|
|
writable "$UNIT_DIR" || INSTALL_UNITS=0
|
|
|
|
# --- prompting --------------------------------------------------------------
|
|
# Reads from the terminal rather than stdin, so this still works when the
|
|
# script itself 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"
|
|
}
|
|
|
|
# Only writes when the content differs, so a no-change run reports as such and
|
|
# leaves mtimes alone.
|
|
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
|
|
# Only create a missing parent, and never re-mode an existing one. This
|
|
# writes into /etc/dnsmasq.d as well as its own directory, and tightening
|
|
# a system directory that other tools read is not this installer's call to
|
|
# make - as root it would have succeeded silently.
|
|
[ -d "$(dirname "$path")" ] || install -d -m 0750 "$(dirname "$path")"
|
|
printf '%s\n' "$content" > "$path.new"
|
|
chmod "$mode" "$path.new"
|
|
mv "$path.new" "$path"
|
|
say " written $path"
|
|
}
|
|
|
|
# --- existing values become defaults ----------------------------------------
|
|
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"
|
|
}
|
|
|
|
# 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() {
|
|
local ip=""
|
|
if command -v resolvectl >/dev/null 2>&1; then
|
|
ip=$(resolvectl status 2>/dev/null \
|
|
| awk '/DNS Servers:/ {print $3; exit}')
|
|
fi
|
|
[ -n "$ip" ] || ip=$(awk '/^nameserver/ {print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
|
|
printf '%s' "$ip"
|
|
}
|
|
|
|
# ipset in particular: the firewall's first command creates the set, so
|
|
# without it that script aborts at line one - and what it leaves behind
|
|
# resembles a working setup closely enough to pass a look. DNS still refuses
|
|
# unlisted names, because that is the resolver's doing rather than the
|
|
# firewall's, while every job has unrestricted egress.
|
|
missing=""
|
|
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 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. Without docker there is no job network to
|
|
create, which is the first thing this does."
|
|
|
|
say "dejima-ci-hardener installer"
|
|
say
|
|
say "This renders the CI job network's DNS allowlist from .dejima/allowed-domains"
|
|
say "on each served repository's main branch. Press Enter to keep a value shown"
|
|
say "in brackets."
|
|
say
|
|
|
|
# --- the job network --------------------------------------------------------
|
|
say "=============================================================="
|
|
say " The job network"
|
|
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 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"
|
|
say "sits in front of them and the rules below never apply to those jobs. If"
|
|
say "you have such a runner, the egress it gets is the host's own: give that"
|
|
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_cfg network "Job network name" network dejima-ci
|
|
say
|
|
|
|
# Derived, not asked. Docker takes the first usable address of the subnet as
|
|
# the gateway, and the bridge is whatever name we give at creation - so two
|
|
# more prompts would only be two more chances for the answers to disagree
|
|
# with reality.
|
|
valid_cidr() {
|
|
[[ "$1" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$ ]] || return 1
|
|
local o=("${BASH_REMATCH[@]:1:4}") pfx="${BASH_REMATCH[5]}"
|
|
for n in "${o[@]}"; do [ "$n" -le 255 ] || return 1; done
|
|
[ "$pfx" -ge 8 ] && [ "$pfx" -le 30 ]
|
|
}
|
|
is_private() {
|
|
[[ "$1" =~ ^10\. ]] && return 0
|
|
[[ "$1" =~ ^192\.168\. ]] && return 0
|
|
[[ "$1" =~ ^172\.(1[6-9]|2[0-9]|3[01])\. ]] && return 0
|
|
return 1
|
|
}
|
|
|
|
# Docker decides the gateway, so ask it first - but do not stake the install on
|
|
# one template expression. When a network is created with --subnet and no
|
|
# explicit --gateway, some daemons record no Gateway in IPAM.Config at all and
|
|
# only ever put the address on the bridge. Reading it back is right; reading it
|
|
# back from exactly one place is not.
|
|
#
|
|
# Sets: gateway, gw_src. Requires: $1 network, $2 bridge, $3 subnet.
|
|
read_gateway() {
|
|
gateway=$(docker network inspect "$1" \
|
|
-f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null || true)
|
|
gw_src="docker"
|
|
if [ -z "$gateway" ] && [ -n "$2" ]; then
|
|
# The address actually on the bridge - as true as Docker's answer, and
|
|
# true for the same reason: it is what containers route to.
|
|
gateway=$(ip -4 -o addr show "$2" 2>/dev/null \
|
|
| awk '{print $4}' | cut -d/ -f1 | head -1)
|
|
gw_src="bridge $2"
|
|
fi
|
|
if [ -z "$gateway" ] && [ -n "$3" ]; then
|
|
# Last resort: the subnet's first usable address, which is what Docker
|
|
# picks when nothing says otherwise.
|
|
IFS=./ read -r o1 o2 o3 o4 _pfx <<< "$3"
|
|
gateway="$o1.$o2.$o3.$((o4 + 1))"
|
|
gw_src="derived from $3"
|
|
fi
|
|
[ -n "$gateway" ]
|
|
}
|
|
|
|
if docker network inspect "$network" >/dev/null 2>&1; then
|
|
# Already there: read what it actually is rather than asking and hoping.
|
|
subnet=$(docker network inspect "$network" -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null || true)
|
|
bridge=$(docker network inspect "$network" \
|
|
-f '{{index .Options "com.docker.network.bridge.name"}}' 2>/dev/null || true)
|
|
if [ -z "$bridge" ]; then
|
|
nid=$(docker network inspect "$network" -f '{{.Id}}' 2>/dev/null || true)
|
|
bridge="br-${nid:0:12}"
|
|
say " NOTE: $network was created without a fixed bridge name, so Docker"
|
|
say " generated $bridge. That works, but it changes if the network is ever"
|
|
say " recreated - and the firewall rules would then match nothing."
|
|
say
|
|
fi
|
|
read_gateway "$network" "$bridge" "$subnet" \
|
|
|| die "$network exists but its gateway cannot be determined by any means"
|
|
say " $network already exists:"
|
|
say " subnet $subnet"
|
|
say " gateway $gateway ($gw_src - the resolver listens here)"
|
|
say " bridge $bridge"
|
|
say
|
|
say " Of these, only the NAME goes in a runner's config (container.network),"
|
|
say " and the gateway goes in its --dns. The bridge is used by the firewall"
|
|
say " rules here and appears in no runner configuration."
|
|
else
|
|
say "Its subnet. Must be PRIVATE - dnsmasq's stop-dns-rebind is keyed on the"
|
|
say "private ranges, so a reserved-but-not-private subnet is one that check"
|
|
say "silently does not cover. Pick one outside Docker's default pool"
|
|
say "(172.17-172.31, 192.168.x) so an unrelated network cannot collide."
|
|
say
|
|
while :; do
|
|
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
|
|
done
|
|
|
|
|
|
# Interface names are capped at 15 characters by the kernel.
|
|
bridge="${network}0"
|
|
if [ ${#bridge} -gt 15 ]; then
|
|
bridge="${bridge:0:15}"
|
|
say " NOTE: bridge name truncated to $bridge (interface names cap at 15)."
|
|
fi
|
|
|
|
say
|
|
docker network create \
|
|
--opt com.docker.network.bridge.name="$bridge" \
|
|
--subnet "$subnet" "$network" >/dev/null \
|
|
|| die "could not create the network - is $subnet already in use? (docker network ls)"
|
|
|
|
read_gateway "$network" "$bridge" "$subnet" \
|
|
|| die "created $network but its gateway cannot be determined by any means"
|
|
|
|
say " created docker network $network"
|
|
say " subnet $subnet"
|
|
say " gateway $gateway ($gw_src - the resolver listens here)"
|
|
say " bridge $bridge"
|
|
say
|
|
say " Only the NAME goes in a runner's config (container.network), and the"
|
|
say " gateway goes in its --dns. The bridge is what the firewall rules match"
|
|
say " on and appears in no runner configuration - nothing to copy."
|
|
fi
|
|
say
|
|
|
|
# --- forge ------------------------------------------------------------------
|
|
existing_api="$(cfg_get forge_api)"
|
|
default_instance="${existing_api%/api/v1}"
|
|
ask instance "Forge URL (e.g. https://gitea.example.com)" "$default_instance"
|
|
[ -n "$instance" ] || die "a forge URL is required"
|
|
instance="${instance%/}"
|
|
case "$instance" in
|
|
*/api/v1) forge_api="$instance" ;;
|
|
*) forge_api="$instance/api/v1" ;;
|
|
esac
|
|
say " API root: $forge_api"
|
|
say
|
|
|
|
# --- upstream resolver ------------------------------------------------------
|
|
say "The upstream resolver is the one dnsmasq forwards ALLOWED queries to -"
|
|
say "the resolver that actually knows the answers. It is NOT the dnsmasq this"
|
|
say "configures, and NOT the job bridge gateway. It must be able to resolve"
|
|
say "your forge, so if that name is internal this has to be your internal DNS."
|
|
existing_upstream="$(cfg_get upstream)"
|
|
# Detection is a guess and has to be checked. resolvectl reports the first
|
|
# server it lists, which on a host with several - or with a local stub in
|
|
# front - can be one that does not actually answer. The host still resolves,
|
|
# because it falls through to another; dnsmasq points at exactly one and gets
|
|
# silence. That surfaces as every job failing at apt with "Temporary failure
|
|
# resolving", four layers away from the setting that caused it.
|
|
upstream_answers() {
|
|
python3 - "$1" "$2" <<'PROBE'
|
|
import socket, struct, sys
|
|
q = b''.join(bytes([len(p)]) + p.encode() for p in sys.argv[2].split('.')) + b'\x00'
|
|
pkt = struct.pack('>HHHHHH', 0x5a5a, 0x0100, 1, 0, 0, 0) + q + struct.pack('>HH', 1, 1)
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(4)
|
|
try:
|
|
s.sendto(pkt, (sys.argv[1], 53))
|
|
d, _ = s.recvfrom(512)
|
|
except OSError:
|
|
sys.exit(1)
|
|
sys.exit(0 if (d[3] & 0xf) == 0 and struct.unpack('>H', d[6:8])[0] > 0 else 1)
|
|
PROBE
|
|
}
|
|
|
|
# The forge, because that is the name an upstream most plausibly cannot
|
|
# resolve: a public resolver handles deb.debian.org and would pass this check
|
|
# while leaving every job unable to reach the forge it checks out from.
|
|
probe_name="${instance#*://}"; probe_name="${probe_name%%/*}"; probe_name="${probe_name%%:*}"
|
|
: "${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
|
|
say " (python3 absent - cannot check that $upstream answers; verify by hand)"
|
|
break
|
|
fi
|
|
if upstream_answers "$upstream" "$probe_name"; then
|
|
say " $upstream resolves $probe_name - good."
|
|
break
|
|
fi
|
|
say
|
|
say " $upstream did not answer a query for $probe_name."
|
|
say
|
|
say " This host resolving names is not the same thing: it may be using a"
|
|
say " different server, or a local stub that falls through to one. dnsmasq"
|
|
say " will point at this address and nothing else, so if it is silent every"
|
|
say " job fails at apt with 'Temporary failure resolving' and nothing names"
|
|
say " this setting."
|
|
say
|
|
say " Servers this host has configured:"
|
|
# 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
|
|
|
|
# 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
|
|
y|yes) existing_upstream="" ;;
|
|
*) say " continuing with $upstream - fix it before expecting jobs to resolve"; break ;;
|
|
esac
|
|
done
|
|
case "$upstream" in
|
|
127.0.0.53|127.0.0.1|::1)
|
|
say
|
|
say " NOTE: $upstream is a local stub. That usually works, but it means the"
|
|
say " allowlist depends on whatever that stub forwards to. Prefer the real"
|
|
say " upstream if you know it." ;;
|
|
esac
|
|
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 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
|
|
fi
|
|
if [ -n "$token" ]; then
|
|
install -d -m 0750 "$CONF_DIR"
|
|
printf '%s\n' "$token" > "$TOKEN_FILE.new"
|
|
chmod 600 "$TOKEN_FILE.new"
|
|
mv "$TOKEN_FILE.new" "$TOKEN_FILE"
|
|
say " written $TOKEN_FILE"
|
|
unset token
|
|
elif [ ! -s "$TOKEN_FILE" ]; then
|
|
install -d -m 0750 "$CONF_DIR"
|
|
: > "$TOKEN_FILE"; chmod 600 "$TOKEN_FILE"
|
|
say " NOTE: no token supplied. Write one into $TOKEN_FILE before this runs."
|
|
fi
|
|
fi
|
|
say
|
|
|
|
# --- repositories -----------------------------------------------------------
|
|
say "Repositories whose main branch may widen this runner's allowlist."
|
|
say "Adding one trusts that repository's main branch. One owner/repo per line,"
|
|
say "blank line to finish."
|
|
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
|
|
fi
|
|
if [ "${keep_repos,,}" = n ] || [ "${keep_repos,,}" = no ]; then
|
|
repos=""
|
|
if [ "$NONINTERACTIVE" -eq 0 ]; then
|
|
read_list repos "owner/repo" valid_repo_entry
|
|
fi
|
|
[ -n "$repos" ] || repos="$existing_repos"
|
|
write_if_changed "$REPOS_FILE" 0640 "$repos"
|
|
else
|
|
write_if_changed "$REPOS_FILE" 0640 "$existing_repos"
|
|
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"
|
|
say "firewalled container is a different proposition on a host on your network."
|
|
forge_host="${instance#*://}"; forge_host="${forge_host%%/*}"; forge_host="${forge_host%%:*}"
|
|
existing_base=""
|
|
[ -r "$BASE_FILE" ] && existing_base="$(grep -vE '^\s*(#|$)' "$BASE_FILE" || true)"
|
|
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
|
|
read_list base "domain" valid_domain_entry
|
|
fi
|
|
[ -n "$base" ] || base="$default_base"
|
|
else
|
|
base="$default_base"
|
|
fi
|
|
write_if_changed "$BASE_FILE" 0640 "$base"
|
|
say
|
|
|
|
# --- remaining settings -----------------------------------------------------
|
|
ipset="$(cfg_get ipset)"; : "${ipset:=ci-allow}"
|
|
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 ------------------------------------------------------------------
|
|
write_if_changed "$CONFIG" 0640 "# Written by $PROG. Re-run it to change a value.
|
|
network = $network
|
|
bridge = $bridge
|
|
gateway = $gateway
|
|
subnet = $subnet
|
|
ipset = $ipset
|
|
forge_api = $forge_api
|
|
token_file = $TOKEN_FILE
|
|
repos_file = $REPOS_FILE
|
|
base_file = $BASE_FILE
|
|
upstream = $upstream
|
|
out = $out
|
|
ref = $ref
|
|
reload = $reload"
|
|
|
|
for prog in dejima-ci-allowlist dejima-ci-firewall dejima-ci-runners dejima-ci-static-ip \
|
|
dejima-ci-upgrade; do
|
|
if cmp -s "$SRC/bin/$prog" "$BIN_DIR/$prog" 2>/dev/null; then
|
|
say " unchanged $BIN_DIR/$prog"
|
|
else
|
|
install -m 0755 "$SRC/bin/$prog" "$BIN_DIR/$prog"
|
|
say " installed $BIN_DIR/$prog"
|
|
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 upgrade notes, so an operator on a runner with no checkout can still read
|
|
# what an upgrade asks of them. The installed copy knows only the versions that
|
|
# existed when it ran, and dejima-ci-upgrade says so rather than reporting "up
|
|
# to date" about versions it has never heard of.
|
|
if writable "$SHARE_DIR/upgrades"; then
|
|
for note in "$SRC"/upgrades/*.md; do
|
|
[ -f "$note" ] || continue
|
|
if cmp -s "$note" "$SHARE_DIR/upgrades/${note##*/}" 2>/dev/null; then
|
|
say " unchanged $SHARE_DIR/upgrades/${note##*/}"
|
|
else
|
|
install -m 0644 "$note" "$SHARE_DIR/upgrades/${note##*/}"
|
|
say " installed $SHARE_DIR/upgrades/${note##*/}"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# What this host is now at, guest-side, and which tier it is.
|
|
#
|
|
# Both are recorded rather than asked or detected. The version comes from the
|
|
# checkout's VERSION, so it records something this program just did. The tier is
|
|
# 'confined' by construction: install.sh does not run on a privileged host, and
|
|
# the whole point of that tier is that nothing here is installed there.
|
|
#
|
|
# THIS FILE IS INSIDE THE SNAPSHOT. On a runner that is correct rather than
|
|
# unfortunate - forget to re-snapshot after a guest-side upgrade and the marker
|
|
# rolls back alongside the change it was recording, so dejima-ci-upgrade goes
|
|
# back to reporting the upgrade as outstanding. It fails towards saying there is
|
|
# work to do.
|
|
version=$(tr -d '[:space:]' < "$SRC/VERSION" 2>/dev/null || true)
|
|
[ -n "$version" ] || die "no version in $SRC/VERSION - refusing to record an empty one"
|
|
{
|
|
[ -r "$CONF_DIR/state" ] && grep -vE '^(guest_version|tier)=' "$CONF_DIR/state" || true
|
|
printf 'guest_version=%s\ntier=confined\n' "$version"
|
|
} > "$CONF_DIR/state.new"
|
|
sort -o "$CONF_DIR/state.new" "$CONF_DIR/state.new"
|
|
chmod 0640 "$CONF_DIR/state.new"
|
|
mv "$CONF_DIR/state.new" "$CONF_DIR/state"
|
|
say " recorded $CONF_DIR/state (guest_version=$version, tier=confined)"
|
|
|
|
# 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
|
|
# the list that is actually derived from a branch you control.
|
|
forge_host="${instance#*://}"; forge_host="${forge_host%%/*}"; forge_host="${forge_host%%:*}"
|
|
write_if_changed "$DNSMASQ_DIR/dejima-ci.conf" 0644 "# Written by $PROG. The allowed names live in the file
|
|
# dejima-ci-allowlist renders; do not add server= lines here.
|
|
listen-address=$gateway
|
|
# bind-dynamic, not bind-interfaces: a Docker bridge is recreated whenever its
|
|
# network is, and bind-interfaces would leave dnsmasq holding a socket on a
|
|
# device that no longer exists - bound according to ss, answering nothing.
|
|
bind-dynamic
|
|
no-hosts
|
|
no-resolv
|
|
ipset=/#/$ipset
|
|
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 \
|
|
"$(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"
|
|
fi
|
|
|
|
say
|
|
if [ "$INSTALL_UNITS" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
|
systemctl daemon-reload
|
|
ask enable_timer "Enable and start the refresh timer? (Y/n)" "y"
|
|
if [ "${enable_timer,,}" != n ] && [ "${enable_timer,,}" != no ]; then
|
|
systemctl enable --now dejima-ci-allowlist.timer
|
|
say " timer enabled"
|
|
fi
|
|
# PartOf + WantedBy on docker.service, so the rules are reapplied whenever
|
|
# 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
|
|
say " firewall unit enabled"
|
|
fi
|
|
# After the network, always. Even with bind-dynamic the config was just
|
|
# rewritten, and if the bridge was created moments ago this is what picks
|
|
# it up.
|
|
systemctl restart dnsmasq 2>/dev/null || say " NOTE: could not restart dnsmasq - do it by hand"
|
|
fi
|
|
|
|
say
|
|
say "=============================================================="
|
|
say " The runners"
|
|
say "=============================================================="
|
|
say
|
|
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 "$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
|
|
|
|
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 "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 " sudo $BIN_DIR/dejima-ci-firewall && sudo iptables -S DOCKER-USER"
|
|
say
|
|
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"
|
|
say " allowlist $out (generated - do not hand-edit)"
|
|
say " resolver $DNSMASQ_DIR/dejima-ci.conf"
|
|
say " firewall $BIN_DIR/dejima-ci-firewall (unit: dejima-ci-firewall.service)"
|
|
say
|
|
say "=============================================================="
|
|
say
|
|
say "Checking the configuration..."
|
|
rc=0
|
|
"$BIN_DIR/dejima-ci-allowlist" check --config "$CONFIG" || rc=$?
|
|
case "$rc" in
|
|
0) say "Up to date." ;;
|
|
1) say; say "There is a pending change, shown above. Apply it with:"
|
|
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
|