bridge.sh: die Strophe, gegen vier Anbieterfaelle geprueft

write_bridge_stanza ist von build_bridge getrennt: das Schreiben ist das,
was eine Maschine umbringt, und so ist es pruefbar, ohne dafuer ein Netz
neu laden zu muessen. Beide Pfade sind ueberschreibbar
(CLUPILOT_INTERFACES_FILE, CLUPILOT_IFRELOAD), dieselbe Technik wie
CLUPILOT_STORAGE_CFG beim Vorlagenbau.

Drei Dinge kann die Fassung mehr als die alte in network.sh:

- hwaddress festgenagelt. Eine Bruecke waehlt sonst die kleinste MAC
  ihrer Ports; bei einem Port ist das dieselbe, aber 'ist dieselbe' und
  'bleibt dieselbe' sind zweierlei.
- Zusatzrouten des Anbieters wandern mit. Ausgelassen bleiben die
  Kernel-Route zum eigenen Subnetz und die Link-Route zum Gateway —
  beide entstehen von selbst, und ein gescheitertes 'up' nimmt bei
  ifreload die ganze Strophe mit.
- IPv6, aber nur statisch und global. SLAAC/DHCPv6 werden bewusst nicht
  nachgebaut (forwarding=1 laesst den Kernel RAs ohne accept_ra=2
  verwerfen) — dafuer gibt es eine Zeile ins Protokoll statt eines
  stillen Verlusts.

Geprueft gegen echte sh: Hetzner /32 routed mit pointopoint, netcup
Subnetz ohne, DHCP ohne Adresse, statisches IPv6 mit fe80::1, SLAAC
faellt weg, Zusatzrouten kommen mit.

network.sh hat sein eigenes build_bridge abgegeben; ein Test haelt jetzt
jede der sieben Brueckenfunktionen auf genau einer Stelle im Repo fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 12:37:17 +02:00
parent 81df08aaa6
commit b630c079ca
3 changed files with 348 additions and 65 deletions

View File

@ -182,3 +182,141 @@ bridge_is_up() {
&& bridge_carries_default_route "$CLUPILOT_BRIDGE" \
&& bridge_has_address "$CLUPILOT_BRIDGE"
}
# ---------------------------------------------------------------------------
# Die Strophe
# ---------------------------------------------------------------------------
# Zusatzrouten des Anbieters — alles auf der Karte, was der Kernel nicht von
# selbst wieder anlegt.
#
# Ausgelassen werden zwei Sorten, und beide mit Grund: die Kernel-Route zum
# eigenen Subnetz (`proto kernel`) entsteht, sobald die Adresse steht, und die
# Link-Route zum Gateway legt `pointopoint` an. Sie trotzdem einzutragen ließe
# `ip route add` scheitern — und ein gescheitertes `up` nimmt bei `ifreload`
# die ganze Strophe mit.
extra_routes() {
_iface="$1"
_gw="$2"
"$CLUPILOT_IP" -4 route show dev "$_iface" 2>/dev/null | while IFS= read -r _route; do
[ -n "$_route" ] || continue
case "$_route" in
default*) continue ;;
"${_gw} "*|"${_gw}") continue ;;
*'proto kernel'*) continue ;;
esac
printf ' up ip route add %s dev %s || true\n' "$_route" "$CLUPILOT_BRIDGE"
done
}
# Eine statische, globale IPv6-Adresse — oder nichts.
#
# `dynamic` (SLAAC/DHCPv6) und `temporary` (Privacy Extensions) fliegen raus:
# was der Kernel selbst vergibt, gehört nicht in eine Datei geschrieben.
static_ipv6_on() {
"$CLUPILOT_IP" -6 -o addr show dev "$1" scope global 2>/dev/null \
| grep -v '[[:space:]]dynamic[[:space:]]' \
| grep -v '[[:space:]]temporary[[:space:]]' \
| awk '{ print $4; exit }'
}
ipv6_gateway() {
"$CLUPILOT_IP" -6 route show default 2>/dev/null \
| awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i+1); exit } }'
}
# Schreibt die Strophe — und sonst nichts. Kein `ifreload`, kein Sichern, kein
# Zeitgeber. Getrennt von build_bridge, damit genau das prüfbar ist, was eine
# Maschine umbringt, ohne dafür ein Netz neu laden zu müssen.
write_bridge_stanza() {
_iface="$1"
_style="$2"
_cidr="$3"
_gw="$4"
_ip4="${_cidr%%/*}"
_mac="$(cat "${CLUPILOT_SYS_NET}/${_iface}/address" 2>/dev/null)"
case "$_style" in
dhcp)
_inet="iface ${CLUPILOT_BRIDGE} inet dhcp"
_addr=''
;;
routed)
# Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
# der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
# dem das Gateway nicht liegt, und die Maschine wäre still weg.
_inet="iface ${CLUPILOT_BRIDGE} inet static"
_addr=" address ${_ip4}/32
pointopoint ${_gw}
gateway ${_gw}"
;;
*)
_inet="iface ${CLUPILOT_BRIDGE} inet static"
_addr=" address ${_cidr}
gateway ${_gw}"
;;
esac
# Festgenagelt: eine Brücke wählt sonst die kleinste MAC ihrer Ports, und
# manche Anbieter binden den Switchport an die der Karte. Bei einem Port ist
# das dieselbe — aber „ist dieselbe" und „bleibt dieselbe" sind zweierlei.
_hw=''
[ -n "$_mac" ] && _hw=" hwaddress ether ${_mac}"
_inet6=''
_v6="$(static_ipv6_on "$_iface")"
_v6gw="$(ipv6_gateway)"
if [ -n "$_v6" ] && [ -n "$_v6gw" ]; then
_inet6="
iface ${CLUPILOT_BRIDGE} inet6 static
address ${_v6}
gateway ${_v6gw}"
elif [ -n "$_v6" ] || [ -n "$_v6gw" ]; then
log 'IPv6 wird NICHT mit auf die Brücke genommen: keine statische globale Adresse mit Standardgateway (SLAAC/DHCPv6 werden bewusst nicht nachgebaut — Proxmox setzt forwarding=1, und der Kernel verwirft RAs dann ohne accept_ra=2).'
fi
cat > "$CLUPILOT_INTERFACES_FILE" <<EOF
# Von CluPilot geschrieben (Schritt EnsureNetworkBridge), weil auf dieser
# Maschine keine ${CLUPILOT_BRIDGE} lag: Proxmox auf Debian legt keine an, nur
# der ISO-Installer tut das.
#
# Abgeleitet aus dem LAUFENDEN Zustand, nicht aus der Datei des Anbieters.
# Anbieterform: ${_style}. Karte: ${_iface}.
#
# Die vorherige Fassung liegt unter ${CLUPILOT_NET_BACKUP}.tar.gz.
source ${CLUPILOT_INTERFACES_D}/*
auto lo
iface lo inet loopback
iface ${_iface} inet manual
auto ${CLUPILOT_BRIDGE}
${_inet}
${_addr}
${_hw}
bridge-ports ${_iface}
bridge-stp off
bridge-fd 0
$(extra_routes "$_iface" "$_gw")${_inet6}
EOF
}
# Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr.
build_bridge() {
write_bridge_stanza "$@"
log 'Strophe geschrieben:'
cat "$CLUPILOT_INTERFACES_FILE"
if command -v "$CLUPILOT_IFRELOAD" >/dev/null 2>&1; then
"$CLUPILOT_IFRELOAD" -a
else
systemctl restart networking
fi
}

View File

@ -144,64 +144,12 @@ host_still_reachable() {
http_get "$CLUPILOT_PROBE_URL" >/dev/null 2>&1
}
# Schreibt eine Brücke über die primäre Netzkarte.
# `build_bridge` und `write_bridge_stanza` stehen in lib/bridge.sh.
#
# Läuft NUR, wenn der ISO-Installer keine hinterlassen hat — und dann unter dem
# Zeitgeber. Die drei Formen unterscheiden sich in genau der Zeile, die
# entscheidet, ob die Maschine danach noch da ist.
build_bridge() {
_iface="$1"
_style="$2"
_cidr="$3"
_gw="$4"
_ip="${_cidr%%/*}"
case "$_style" in
dhcp)
_inet='iface vmbr0 inet dhcp'
_addr=''
;;
routed)
# Gateway außerhalb des eigenen Subnetzes. Ohne `pointopoint` findet
# der Kernel keinen Weg dorthin: die Route zeigte auf ein Netz, in
# dem das Gateway nicht liegt, und die Maschine wäre still weg.
_inet='iface vmbr0 inet static'
_addr=" address ${_ip}/32
pointopoint ${_gw}
gateway ${_gw}"
;;
*)
_inet='iface vmbr0 inet static'
_addr=" address ${_cidr}
gateway ${_gw}"
;;
esac
cat > /etc/network/interfaces <<EOF
# Von CluPilots Bootstrap geschrieben, weil der Installer keine vmbr0
# hinterlassen hat. Anbieterform: ${_style}.
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
iface ${_iface} inet manual
auto vmbr0
${_inet}
${_addr}
bridge-ports ${_iface}
bridge-stp off
bridge-fd 0
EOF
if command -v ifreload >/dev/null 2>&1; then
ifreload -a
else
systemctl restart networking
fi
}
# Die dortige Fassung kann drei Dinge mehr, und jedes davon hat einen Grund:
# sie nagelt die MAC fest, nimmt die Zusatzrouten des Anbieters mit, und trennt
# das SCHREIBEN der Strophe vom Anwenden — damit prüfbar ist, was eine Maschine
# umbringt, ohne dafür ein Netz neu laden zu müssen.
# ---------------------------------------------------------------------------
# WireGuard

View File

@ -159,12 +159,15 @@ it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', functio
expect($out)->toBe('NEIN');
});
it('hält detect_primary_interface an genau einer Stelle im Repo', function () {
it('hält jede Brückenfunktion an genau einer Stelle im Repo', function () {
// Zwei Fassungen wären zwei Installationen, die bei jeder Proxmox-Version
// nachgezogen werden müssten — und die zweite fiele erst auf, wenn jemand
// sie benutzt. Genau die Entscheidung, die den Rettungssystem-Weg
// stillgelegt hat.
//
// Die Liste wächst mit bridge.sh mit: wer eine Funktion hierher zieht und
// die alte Fassung stehen lässt, fällt hier auf und nicht auf einem Host.
//
// array_merge, NICHT `+`: bei numerischen Schlüsseln behält der
// Vereinigungsoperator die linke Seite und wirft die rechte still weg. Und
// PHPs glob() kennt kein rekursives `**`, also zwei Muster.
@ -173,14 +176,208 @@ it('hält detect_primary_interface an genau einer Stelle im Repo', function () {
glob(base_path('deploy/bootstrap/*.sh')) ?: [],
);
$hits = [];
foreach ($files as $file) {
if (preg_match('/^detect_primary_interface\(\)/m', (string) file_get_contents($file))) {
$hits[] = str_replace(base_path().'/', '', $file);
}
}
$functions = [
'detect_primary_interface',
'detect_network_style',
'bridge_exists',
'bridge_carries_default_route',
'bridge_has_address',
'build_bridge',
'write_bridge_stanza',
];
expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']);
foreach ($functions as $function) {
$hits = [];
foreach ($files as $file) {
if (preg_match('/^'.$function.'\(\)/m', (string) file_get_contents($file))) {
$hits[] = str_replace(base_path().'/', '', $file);
}
}
expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh'], $function.' steht nicht genau einmal im Repo');
}
});
/*
|--------------------------------------------------------------------------
| Die Strophe
|--------------------------------------------------------------------------
|
| Vier Anbieterfälle, und sie unterscheiden sich in genau der Zeile, die
| entscheidet, ob die Maschine danach noch da ist.
*/
it('schreibt für eine geroutete /32 eine pointopoint-Strophe', function () {
// Hetzner dediziert. Ohne `pointopoint` findet der Kernel keinen Weg zum
// Gateway: die Route zeigte auf ein Netz, in dem das Gateway nicht liegt,
// und die Maschine wäre still weg.
$ip = fakeIp($this->dir, [
'-4 route show dev enp0s31f6' => "49.12.121.65 scope link\ndefault via 49.12.121.65",
'-6 -o addr show dev enp0s31f6 scope global' => '',
'-6 route show default' => '',
]);
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('address 49.12.121.79/32')
->toContain('pointopoint 49.12.121.65')
->toContain('gateway 49.12.121.65')
->toContain('bridge-ports enp0s31f6')
->toContain('iface enp0s31f6 inet manual')
// Festgenagelt, damit die Brücke nicht irgendeine MAC wählt: manche
// Anbieter binden den Switchport an die der Karte.
->toContain('hwaddress ether a8:a1:59:00:11:22')
->toContain('auto vmbr0');
});
it('schreibt für ein gewöhnliches Subnetz Adresse und Gateway ohne pointopoint', function () {
// netcup und die meisten anderen. `pointopoint` wäre hier falsch.
$ip = fakeIp($this->dir, [
'-4 route show dev ens3' => '192.168.10.0/24 proto kernel scope link src 192.168.10.7',
'-6 -o addr show dev ens3 scope global' => '',
'-6 route show default' => '',
]);
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:01');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza ens3 subnet 192.168.10.7/24 192.168.10.1', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('address 192.168.10.7/24')
->toContain('gateway 192.168.10.1')
->not->toContain('pointopoint')
// Die Route zum eigenen Subnetz gehört NICHT in die Zusatzrouten: die
// legt der Kernel selbst an, sobald die Adresse steht. Ein zweites Mal
// hinzufügen scheitert — und ein gescheitertes `up` nimmt bei
// `ifreload` die ganze Strophe mit.
->not->toContain('up ip route add 192.168.10.0/24');
});
it('schreibt für DHCP keine Adresse in die Strophe', function () {
$ip = fakeIp($this->dir, [
'-4 route show dev ens3' => '',
'-6 -o addr show dev ens3 scope global' => '',
'-6 route show default' => '',
]);
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:02');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza ens3 dhcp 10.0.0.7/24 10.0.0.1', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('iface vmbr0 inet dhcp')
->not->toContain('address 10.0.0.7')
->toContain('bridge-ports ens3');
});
it('nimmt eine statische globale IPv6 samt link-local Gateway mit', function () {
// Hetzners Form: 2a01:…::2/64 mit fe80::1. Statisch, also derselbe Dreisatz
// wie bei v4 — und damit der Fall, der laut Entwurf nichts kostet.
$ip = fakeIp($this->dir, [
'-4 route show dev enp0s31f6' => '',
'-6 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet6 2a01:4f8:1c1c::2/64 scope global \ valid_lft forever preferred_lft forever',
'-6 route show default' => 'default via fe80::1 dev enp0s31f6 metric 1024',
]);
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('iface vmbr0 inet6 static')
->toContain('address 2a01:4f8:1c1c::2/64')
->toContain('gateway fe80::1');
});
it('lässt SLAAC-IPv6 liegen und sagt es ins Protokoll', function () {
// Proxmox setzt forwarding=1, und der Kernel verwirft
// Router-Advertisements dann ohne accept_ra=2. Das sauber hinzubekommen
// wäre ein zweiter Satz Fehlerfälle in genau dem Fenster, das diese
// Konstruktion zu überleben versucht — und v6 trägt hier keinen Verkehr.
// Still verlieren gilt trotzdem nicht.
$ip = fakeIp($this->dir, [
'-4 route show dev ens3' => '',
'-6 -o addr show dev ens3 scope global' => '2: ens3 inet6 2a01:4f8:aaaa::17/64 scope global dynamic \ valid_lft 86000sec preferred_lft 14000sec',
'-6 route show default' => 'default via fe80::1 dev ens3',
]);
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:03');
$out = $this->dir.'/interfaces';
$log = runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))->not->toContain('inet6 static')
->and($log)->toContain('IPv6');
});
it('nimmt die Zusatzrouten des Anbieters mit auf die Brücke', function () {
// Was der Anbieter extra eingetragen hat, verliert der Host sonst — und es
// fällt erst auf, wenn jemand das Ziel dahinter braucht.
$ip = fakeIp($this->dir, [
'-4 route show dev enp0s31f6' => "49.12.121.65 scope link\n10.128.0.0/16 via 49.12.121.65\n49.12.121.64/26 proto kernel scope link src 49.12.121.79",
'-6 -o addr show dev enp0s31f6 scope global' => '',
'-6 route show default' => '',
]);
$sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza enp0s31f6 subnet 49.12.121.79/26 49.12.121.65', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('up ip route add 10.128.0.0/16 via 49.12.121.65 dev vmbr0')
// Weder die Link-Route zum Gateway noch die Kernel-Route zum eigenen
// Subnetz: beide entstehen von selbst, und ein zweiter Versuch
// scheitert und nimmt die Strophe mit.
->not->toContain('up ip route add 49.12.121.65 ')
->not->toContain('up ip route add 49.12.121.64/26');
});
it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () {
// Wer in sechs Monaten in diese Datei sieht, muss wissen, wer sie
// geschrieben hat und woraus.
$ip = fakeIp($this->dir, [
'-4 route show dev ens3' => '',
'-6 -o addr show dev ens3 scope global' => '',
'-6 route show default' => '',
]);
$sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:04');
$out = $this->dir.'/interfaces';
runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [
'CLUPILOT_IP' => $ip,
'CLUPILOT_SYS_NET' => $sys,
'CLUPILOT_INTERFACES_FILE' => $out,
]);
expect(file_get_contents($out))
->toContain('Von CluPilot geschrieben')
->toContain('EnsureNetworkBridge');
});
it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () {