#!/bin/bash # Version: 2026-11-10-17-07 — WLAN-Einrichtung vollständig entfernt; Portauswahl nur Ethernet. # Farben nur für geeignete Terminals; NO_COLOR deaktiviert die Darstellung. ff_tint() { local color=$1 text=$2 fd=${3:-1} if [ -t "$fd" ] && [ -z "${NO_COLOR+x}" ] && [ "${TERM:-dumb}" != dumb ]; then printf '\033[%sm%s\033[0m' "$color" "$text" else printf '%s' "$text" fi } ff_prompt() { ff_tint '1;36' "$*" 2; } export -f ff_tint ff_prompt ff_session_installer_hash() { python3 - "$FF_SESSION" "$FF_INSTALLER_PATH" "$mode" <<'FF_SESSION_HASH' import hashlib,pathlib,sys,os,re,datetime session=pathlib.Path(sys.argv[1]);installer=pathlib.Path(sys.argv[2]);resume=sys.argv[3]=='--resume' record=session/'installer.sha256' current=hashlib.sha256(installer.read_bytes()).hexdigest() if resume: if not record.is_file() or record.is_symlink(): raise SystemExit('Sitzung ohne gespeicherten Installer-Hash. Bitte einen neuen Installationslauf starten.') expected=record.read_text().strip() if not re.fullmatch(r'[0-9a-f]{64}',expected): raise SystemExit('Gespeicherter Installer-Hash ist ungültig. Fortsetzung abgebrochen.') if current!=expected: raise SystemExit('Installer wurde seit Beginn dieser Sitzung verändert. Fortsetzung abgebrochen. Bitte ursprüngliche Version verwenden oder einen neuen Installationslauf starten.') message='Installer-Hash bei --resume verglichen: unveränderte Skriptversion; keine Signaturprüfung.' else: # Never replace the session baseline, even if initialization is called twice. fd=os.open(record,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) with os.fdopen(fd,'w') as f: f.write(current+'\n');f.flush();os.fsync(f.fileno()) message='Installer-SHA-256 bei Sitzungsbeginn gespeichert: '+current with (session/'ereignisse.log').open('a') as f: f.write(datetime.datetime.now(datetime.timezone.utc).isoformat()+'\t'+message+'\n') FF_SESSION_HASH } ff_state() { python3 "$FF_SESSION/rollback_state.py" "$@"; } ff_security_begin() { command -v python3 >/dev/null || { echo 'Für das Änderungsjournal wird python3 benötigt. Bitte zunächst aus Debian installieren.'; return 1; } if [ ! -f "$FF_SESSION/rollback_state.py" ]; then cat > "$FF_SESSION/rollback_state.py" <<'FF_ROLLBACK_PY' #!/usr/bin/python3 """Privates Änderungsjournal. Keine Schlüssel-/Passwortinhalte in Ausgaben.""" import pathlib,os,sys,json,hashlib,shutil,stat,glob,subprocess root=pathlib.Path(os.environ.get('FF_TEST_ROOT','/'));session=pathlib.Path(os.environ['FF_SESSION']);store=session/'rollback';store.mkdir(mode=0o700,exist_ok=True) def path(p):return root/p.lstrip('/') def query_packages(): r=subprocess.run(['dpkg-query','-W','-f=${binary:Package}\t${Version}\t${db:Status-Status}\n'],text=True,capture_output=True,check=True) return {l.split('\t')[0]:l.split('\t')[1] for l in r.stdout.splitlines() if len(l.split('\t'))==3 and l.split('\t')[2]=='installed'} def dump(name,obj): p=store/name;p.write_text(json.dumps(obj,ensure_ascii=False,indent=2)+'\n');p.chmod(0o600) def load(name):return json.loads((store/name).read_text()) patterns=['/etc','/etc/network','/etc/NetworkManager','/etc/apache2','/etc/fastd','/etc/systemd/system','/etc/rc.local','/etc/freifunk','/etc/freifunk-status.htpasswd','/etc/default/fastd','/etc/cron.d/freifunk','/etc/sudoers.d/freifunk-status','/etc/sysctl.d/90-freifunk-offloader.conf','/etc/apt/apt.conf.d/52offloader-security','/etc/apt/sources.list.d/ookla*','/etc/apt/keyrings/ookla*','/etc/php/*/apache2/conf.d/99-upload-progress-off.ini','/etc/ssl/certs/ssl-cert-snakeoil.pem','/etc/ssl/private/ssl-cert-snakeoil.key','/etc/nftables.d/freifunk-status.nft','/usr/local/lib/freifunk*','/usr/local/sbin/freifunk*','/root/.ssh','/root/.vimrc','/opt/ext-respondd','/opt/freifunk','/var/lib/freifunk','/var/www/html/index.php','/var/www/html/index.html'] if (store/'extra-paths.json').exists():patterns+=load('extra-paths.json') def record(p,copy=False): s=p.lstat();r={'mode':stat.S_IMODE(s.st_mode),'uid':s.st_uid,'gid':s.st_gid} if p.is_symlink():r.update(type='link',target=os.readlink(p)) elif p.is_file(): data=p.read_bytes();h=hashlib.sha256(data).hexdigest();r.update(type='file',hash=h) if copy: blob=store/'blobs'/h;blob.parent.mkdir(mode=0o700,exist_ok=True) if not blob.exists():blob.write_bytes(data);blob.chmod(0o600) elif p.is_dir():r.update(type='dir') else:raise SystemExit('Nicht unterstützter Dateityp: '+str(p)) return r def scan(copy=False): out={} def visit(p): k='/'+str(p.relative_to(root)) if k in out or k in ('/etc/ld.so.cache',):return out[k]=record(p,copy) if p.is_dir() and not p.is_symlink(): for child in p.iterdir():visit(child) for pattern in patterns: for name in glob.glob(str(path(pattern))): p=pathlib.Path(name) if p.exists() or p.is_symlink():visit(p) for name in load('extra-directories.json') if (store/'extra-directories.json').exists() else []: p=path(name) if p.exists():out[name]=record(p,copy) return out def services(): names=['networking','chrony','systemd-timesyncd','apache2','cron','fastd','fastd@freifunk','ext-respondd','freifunk-mesh-link-watch','freifunk-wlan.service','freifunk-wan-recovery.timer','freifunk-wan-recovery.service','NetworkManager','nftables','freifunk-status-firewall','vnstat','freifunk-vnstat-registrieren.service','freifunk-vnstat-registrieren.timer','isc-dhcp-server','openvpn','radvd','kea-dhcp4-server','kea-dhcp6-server','kea-ctrl-agent','kea-dhcp-ddns-server','nut-server','nut-monitor','apt-daily.timer','apt-daily-upgrade.timer'] out={} for name in names: def get(args):return subprocess.run(['systemctl',*args,name],capture_output=True,text=True).stdout.strip() if get(['show','-p','LoadState','--value'])=='not-found':continue out[name]={'enabled':get(['is-enabled']),'active':get(['is-active'])} return out def nft(): return subprocess.run(['nft','list','table','inet','freifunk_status'],capture_output=True,text=True).stdout if shutil.which('nft') else '' mode=sys.argv[1] if mode=='begin': if not (store/'before.json').exists(): dump('before.json',scan(True));dump('packages.json',query_packages());dump('services.json',services());dump('nft-before.json',nft()) r=subprocess.run(['apt-mark','showauto'],text=True,capture_output=True,check=True);dump('auto.json',r.stdout.splitlines()) elif mode=='before-upgrade':dump('pre-upgrade.json',scan(False)) elif mode=='after-upgrade': # Changes caused by the OS upgrade become part of the retained baseline. before=load('before.json');pre=load('pre-upgrade.json');post=scan(True) for k in pre.keys()|post.keys(): if pre.get(k)!=post.get(k): if k in post:before[k]=post[k] else:before.pop(k,None) dump('before.json',before);dump('packages.json',query_packages());dump('auto.json',subprocess.check_output(['apt-mark','showauto'],text=True).splitlines());dump('services.json',services());dump('nft-before.json',nft());(store/'upgrade-completed').touch() elif mode=='track-web': base=sys.argv[2] if not base.startswith('/'):raise SystemExit('Absolutes Webverzeichnis erforderlich.') extra=load('extra-paths.json') if (store/'extra-paths.json').exists() else [] before=load('before.json') dirs=load('extra-directories.json') if (store/'extra-directories.json').exists() else [] if base not in dirs: dirs.append(base) if path(base).exists():before[base]=record(path(base),True) else: ancestor=pathlib.PurePosixPath(base).parent while str(ancestor)!='/' and not path(str(ancestor)).exists(): if str(ancestor) not in dirs:dirs.append(str(ancestor)) ancestor=ancestor.parent dump('extra-directories.json',dirs) for name in [base.rstrip('/')+'/index.html',base.rstrip('/')+'/index.php',base.rstrip('/')+'/vnstat']: if name not in extra: extra.append(name);p=path(name) if p.exists() or p.is_symlink():before[name]=record(p,True) if p.is_dir() and not p.is_symlink(): import os for directory,subdirs,files in os.walk(p,followlinks=False): for child in subdirs+files: item=pathlib.Path(directory)/child before['/'+str(item.relative_to(root))]=record(item,True) dump('extra-paths.json',extra);dump('before.json',before) elif mode=='checkpoint':dump('after.json',scan(False));dump('packages-after.json',query_packages());dump('nft-after.json',nft()) elif mode in ('plan','restore'): before=load('before.json');after=load('after.json');changed=sorted(k for k in before.keys()|after.keys() if before.get(k)!=after.get(k));conflicts=[] for k in changed: p=path(k);current=record(p) if p.exists() or p.is_symlink() else None if current!=after.get(k) and not k.startswith('/var/lib/freifunk/daten/'): conflicts.append(k) if conflicts and not (mode=='restore' and os.environ.get('FF_ROLLBACK_APPROVED')=='1'):raise SystemExit('Seit der Installation nachträglich verändert; Rücknahme angehalten:\n'+'\n'.join(conflicts)) for k in changed:print(('WIEDERHERSTELLEN ' if k in before else 'ENTFERNEN ')+k) if mode=='restore': for k in sorted(changed,key=lambda k:(k.count('/'),k)): if k not in before:continue p=path(k);r=before[k];p.parent.mkdir(parents=True,exist_ok=True) if p.is_dir() and not p.is_symlink() and r['type']!='dir':p.rmdir() if p.is_symlink() or (p.exists() and not p.is_dir()):p.unlink() if r['type']=='dir':p.mkdir(exist_ok=True) elif r['type']=='link':p.symlink_to(r['target']) else:shutil.copyfile(store/'blobs'/r['hash'],p) os.chown(p,r['uid'],r['gid'],follow_symlinks=False) if r['type']!='link':p.chmod(r['mode']) for k in sorted(changed,key=lambda k:(k.count('/'),k),reverse=True): if k in before:continue p=path(k) if p.is_symlink() or p.is_file():p.unlink() elif p.is_dir(): try:p.rmdir() except OSError:print('Verzeichnis mit zusätzlichen Daten erhalten: '+k) # Restore permissions of pre-existing directories after creating their children. for k in changed: if k in before and before[k]['type']=='dir': p=path(k);r=before[k];os.chown(p,r['uid'],r['gid']);p.chmod(r['mode']) else:raise SystemExit('Unbekannter Journalmodus.') FF_ROLLBACK_PY chmod 0600 "$FF_SESSION/rollback_state.py" fi ff_state begin } ff_rollback() { FF_SESSION=${1:-$(cat /var/lib/freifunk-installation/aktuelle-sitzung)} [[ "$FF_SESSION" = /var/lib/freifunk-installation/* ]] && [ -f "$FF_SESSION/rollback/after.json" ] || { echo 'Für diese Installation gibt es kein vollständiges Rücknahmejournal.'; return 1; } export FF_SESSION if command -v nft >/dev/null; then current_rules=$(nft list table inet freifunk_status 2>/dev/null || true) expected_rules=$(python3 - "$FF_SESSION" <<'FF_NFT_EXPECTED' import pathlib,json,sys print(json.loads((pathlib.Path(sys.argv[1])/'rollback/nft-after.json').read_text())) FF_NFT_EXPECTED ) [ "$current_rules" = "$expected_rules" ] || { echo 'Eigene Firewall seit dem letzten Journalstand verändert; Rücknahme angehalten.'; return 1; } fi ff_state plan echo 'Die Rücknahme von WAN/SSH-Einstellungen kann die Verbindung trennen; bitte an einer lokalen Konsole ausführen.' python3 - "$FF_SESSION" <<'FF_PACKAGE_PLAN' import pathlib,json,sys,subprocess p=pathlib.Path(sys.argv[1])/'rollback';before=json.loads((p/'packages.json').read_text()) r=subprocess.check_output(['dpkg-query','-W','-f=${binary:Package}\t${db:Status-Status}\n'],text=True) now={l.split('\t')[0] for l in r.splitlines() if l.endswith('\tinstalled')} recorded=set(json.loads((p/'packages-after.json').read_text())) script_added=recorded-set(before) if (set(before) & recorded)-now:raise SystemExit('Bestandspakete wurden nach der Installation entfernt; Rücknahme zuerst prüfen.') missing=sorted(set(before)-now);added=sorted((now & script_added));args=missing+[x+'-' for x in added] protected=(now-script_added)|set(before) if args: plan=subprocess.check_output(['apt-get','-s','install','--',*args],text=True);print(plan) removed={l.split()[1] for l in plan.splitlines() if l.startswith('Remv ')} if {x.split(':')[0] for x in removed} & {x.split(':')[0] for x in protected}:raise SystemExit('Rücknahme würde Bestandspakete entfernen; keine Rücknahme ausgeführt.') (p/'package-transaction.json').write_text(json.dumps(args)) FF_PACKAGE_PLAN echo 'Das setzt die protokollierten Skriptänderungen zurück. System-Upgrades bleiben erhalten.' echo 'Sammlerdaten werden auf den gesicherten Stand zurückgesetzt; Berichte und Sicherungen bleiben erhalten.' read -r -p "$(ff_prompt 'Diese Rücknahme ausführen? ja/nein [nein]: ')" answer /dev/null || true systemctl stop freifunk-wan-recovery.timer freifunk-wan-recovery.service 2>/dev/null || true systemctl stop freifunk-wlan.service freifunk-mesh-link-watch.service ext-respondd.service 2>/dev/null || true systemctl stop 'freifunk-wan-dhcp@*.service' 2>/dev/null || true systemctl stop freifunk-status-firewall.service 2>/dev/null || true if command -v nft >/dev/null; then nft delete table inet freifunk_status 2>/dev/null || true python3 - "$FF_SESSION" > "$FF_SESSION/rollback/nft-restore" <<'FF_NFT_RESTORE' import pathlib,json,sys print(json.loads((pathlib.Path(sys.argv[1])/'rollback/nft-before.json').read_text())) FF_NFT_RESTORE if [ -s "$FF_SESSION/rollback/nft-restore" ]; then nft -f "$FF_SESSION/rollback/nft-restore"; fi fi python3 - "$FF_SESSION" <<'FF_PACKAGE_RESTORE' import pathlib,json,sys,subprocess p=pathlib.Path(sys.argv[1])/'rollback';args=json.loads((p/'package-transaction.json').read_text()) if args:subprocess.run(['apt-get','install','-y','--',*args],check=True) FF_PACKAGE_RESTORE # The cron file was deliberately moved above; allow that one tracked transition. if [ -f "$FF_SESSION/rollback/cron-disabled" ]; then cp -a "$FF_SESSION/rollback/cron-disabled" /etc/cron.d/freifunk; fi ff_state restore systemctl daemon-reload python3 - "$FF_SESSION" <<'FF_RESTORE_SERVICES' import pathlib,json,sys,subprocess p=pathlib.Path(sys.argv[1])/'rollback';before=json.loads((p/'services.json').read_text()) for name,info in before.items(): if info['enabled'] in ('enabled','disabled'): subprocess.run(['systemctl','enable' if info['enabled']=='enabled' else 'disable',name],check=False) if name!='networking':subprocess.run(['systemctl','start' if info['active']=='active' else 'stop',name],check=False) # Restore package auto/manual marks without downgrading any versions. auto=set(json.loads((p/'auto.json').read_text()));packages=set(json.loads((p/'packages.json').read_text())) for mode,names in [('auto',packages & auto),('manual',packages-auto)]: if names:subprocess.run(['apt-mark',mode,*sorted(names)],check=True) FF_RESTORE_SERVICES echo 'Rücknahme abgeschlossen. System-Upgrades, Installationsberichte und Sicherungen wurden erhalten.' echo 'Die wiederhergestellte Netzwerkkonfiguration wird beim Neustart aktiv.' read -r -p "$(ff_prompt 'Jetzt neu starten? ja/nein [nein]: ')" answer > "$FF_SESSION/ereignisse.log" } ff_run() { local id=$1 name=$2 file="$FF_SESSION/schritte/$1.sh" cat > "$file" chmod 0600 "$file" if [ "${FF_NETWORK_ONLY:-nein}" = ja ]; then case "$id" in 01|01b|05|06|07|08|18|19|20) ;; *) return ;; esac fi if [ -f "$FF_SESSION/schritte/$id.ok" ]; then echo "Bereits erledigt: $name"; return; fi printf '\n'; ff_tint '1;34' "===== $name ====="; printf '\n' if bash "$file"; then :; else status=$?; if [ "$id" = 00 ] && [ -f "$FF_SESSION/rollback/pre-upgrade.json" ] && [ ! -f "$FF_SESSION/rollback/upgrade-completed" ]; then ff_state after-upgrade; fi; ff_state checkpoint; return "$status"; fi ff_state checkpoint touch "$FF_SESSION/schritte/$id.ok" } ff_afterboot() { bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Nach dem Neustart: Dienste und vnStat prüfen' ff_note "BEGINN: $FF_STEP" for dienst in networking chrony fastd@freifunk ext-respondd freifunk-mesh-link-watch freifunk-wan-recovery.timer; do printf '\n%s: ' "$dienst" ff_check "Dienst läuft: $dienst" systemctl is-active "$dienst" || true done ip -brief address printf '\nMulticast für Nachbarnamen auf BATMAN-Ports:\n' if mesh_ports=$(batctl if); then printf '%s\n' "$mesh_ports" if [ -z "$mesh_ports" ]; then ff_note "WARNUNG: Keine BATMAN-Ports für die Multicast-Prüfung vorhanden." else while IFS= read -r zeile; do if [[ "$zeile" =~ ^[[:space:]]*([^[:space:]:]+):[[:space:]] ]]; then port=${BASH_REMATCH[1]} if ip -6 maddr show dev "$port" | grep -E '[[:space:]]ff02::2:1001([[:space:]]|$)' >/dev/null; then ff_note "PRÜFUNG OK: $port – ff02::2:1001 eingetragen." else ff_note "WARNUNG: $port – ff02::2:1001 fehlt oder ist nicht lesbar." fi fi done <<< "$mesh_ports" fi else ff_note "WARNUNG: BATMAN-Ports für die Multicast-Prüfung nicht lesbar." fi batctl n || true if command -v vnstat >/dev/null; then if [ -d /sys/class/net/vpn3 ]; then if ! vnstat -i vpn3 --json >/dev/null 2>&1; then vnstat --add -i vpn3 fi systemctl enable --now vnstat.service systemctl restart vnstat.service vnstat -i vpn3 || true else echo "vpn3 fehlt; zuerst fastd prüfen." fi fi if [ "$VARIANTE" != a ]; then for aufgabe in status internet nodes nutzer; do ff_check "Datensammler nach Neustart: $aufgabe" /usr/bin/php /opt/freifunk/datensammler.php "$aufgabe" || true done printf '\nWebseitenadressen (passendes Netz und DocumentRoot vorausgesetzt):\n' python3 <<'FREIFUNK_ADRESSEN' import json,subprocess for interface in json.loads(subprocess.check_output(['ip','-j','address'],text=True)): if interface['ifname']=='lo':continue for a in interface.get('addr_info',[]): if a.get('scope')!='global':continue ip=a['local'];host=f'[{ip}]' if ':' in ip else ip print(f"{interface['ifname']}: http://{host}/index.php?hilfe") FREIFUNK_ADRESSEN fi printf '\nBei Dienstfehlern:\njournalctl -b -u networking -u fastd@freifunk -u ext-respondd --no-pager\n' ff_note "ABGESCHLOSSEN: $FF_STEP" ff_report FREIFUNK_SCHRITT } # Freifunk Halle: interaktive Debian-13-Installation. Keine Geheimnisse im Protokoll. set -Eeuo pipefail [ "$(id -u)" -eq 0 ] || { echo "Bitte als root ausführen (su -)." >&2; exit 1; } [ -r /dev/tty ] || { echo "Ein interaktives Terminal ist erforderlich." >&2; exit 1; } source /etc/os-release [ "$ID" = debian ] && [ "${VERSION_ID%%.*}" = 13 ] || { echo "Diese Fassung ist für Debian 13 vorgesehen."; exit 1; } umask 077 exec 8>/run/freifunk-installation.lock flock -n 8 || { echo "Eine andere Installation läuft bereits."; exit 1; } install -d -m 0755 /usr/local/sbin mode=${1:-} case "$mode" in ''|--resume|--check|--network|--rollback) ;; *) echo "Aufruf: $0 [--resume|--check|--network|--rollback]"; exit 1 ;; esac if [ "$mode" = --rollback ]; then ff_rollback "${2:-}"; exit $?; fi if [ "$mode" = --network ] && [ ! -s /etc/freifunk/installation.conf ]; then echo "Noch keine gespeicherte Einrichtung. Zuerst die normale Installation ausführen."; exit 1 fi if [ "$mode" = --check ]; then [ -f /usr/local/lib/freifunk-installation.sh ] || { echo "Noch keine vorbereitete Installation."; exit 1; } ff_afterboot exit $? fi install -d -m 0700 /var/lib/freifunk-installation if [ "$mode" = --resume ]; then FF_SESSION=$(cat /var/lib/freifunk-installation/aktuelle-sitzung) [[ "$FF_SESSION" = /var/lib/freifunk-installation/* ]] && [ -d "$FF_SESSION" ] || exit 1 echo "Setze die unterbrochene Installationssitzung fort: $FF_SESSION" else if [ -s /etc/freifunk/installation.conf ]; then echo "Vorhandene Offloader-Einrichtung erkannt. Vorschläge werden übernommen; Portzuordnung ist änderbar." fi FF_SESSION=$(mktemp -d "/var/lib/freifunk-installation/$(TZ=Europe/Berlin date +%Y-%m-%d-%H-%M)-XXXXXX") dpkg-query -W -f='${binary:Package}\t${Version}\t${db:Status-Status}\n' > "$FF_SESSION/pakete-vorher.tsv" TZ=Europe/Berlin date --iso-8601=seconds > "$FF_SESSION/start.txt" printf '%s\n' "$FF_SESSION" > /var/lib/freifunk-installation/aktuelle-sitzung printf '%s\tInstallationsbeginn; Paket-Ausgangsbestand gesichert.\n' "$(TZ=Europe/Berlin date --iso-8601=seconds)" > "$FF_SESSION/ereignisse.log" fi export FF_SESSION export FF_INSTALLER_PATH=$(readlink -f "$0") ff_session_installer_hash || exit $? if [ "$mode" = --resume ] && [ ! -f "$FF_SESSION/rollback/before.json" ]; then echo "Alte Sitzung ohne Rücknahmejournal: bitte neuen vollständigen Lauf starten."; exit 1; fi ff_security_begin if [ "$(readlink -f "$0")" != /usr/local/sbin/freifunk-installation ]; then [ ! -L /usr/local/sbin/freifunk-installation ] || { echo "Installer-Ziel ist ein Symlink."; exit 1; } install -o root -g root -m 0700 "$0" /usr/local/sbin/freifunk-installation fi FF_NETWORK_ONLY=nein if [ "$mode" = --network ] || [ -f "$FF_SESSION/network-only" ]; then touch "$FF_SESSION/network-only" FF_NETWORK_ONLY=ja source /etc/freifunk/installation.conf printf '%s\n' "$VARIANTE" > "$FF_SESSION/variante" echo "Netzwerkeinrichtung: Ports sowie optionale br0- und VLAN-7-Adressen erneut abfragen." echo "Systemupdate und Paketinstallation werden dabei übersprungen." fi export FF_NETWORK_ONLY install -d -m 0700 "$FF_SESSION/schritte" trap 'code=$?; printf "%s\tFEHLER: Installationsablauf beendet (Exit %s).\n" "$(TZ=Europe/Berlin date --iso-8601=seconds)" "$code" >> "$FF_SESSION/ereignisse.log"; if [ -f /usr/local/lib/freifunk-installationsbericht.py ]; then python3 /usr/local/lib/freifunk-installationsbericht.py || true; fi; echo "Fortsetzen: /usr/local/sbin/freifunk-installation --resume"; exit "$code"' ERR export -f ff_state ff_verify_sources ff_run 00 'System aktualisieren und Pakete installieren' <<'FF_STAGE_00' set -euo pipefail if [ -s "$FF_SESSION/variante" ]; then read -r auswahl < "$FF_SESSION/variante" else desktop=$(python3 <<'FF_DESKTOP' from configparser import ConfigParser, Error from pathlib import Path from shlex import split from shutil import which # An entry from a login manager can launch a fallback shell without a desktop. # Require an actual desktop component as well as a usable session entry. desktop_components = ( 'gnome-shell', 'gnome-panel', 'plasmashell', 'xfce4-session', 'cinnamon', 'mate-session', 'lxqt-session', 'lxsession', 'budgie-desktop', ) if not any(which(component) for component in desktop_components): print('nein') raise SystemExit(0) fallback_launchers = { 'default', 'xsession', 'x-session-manager', 'x-window-manager', 'x-terminal-emulator', 'startx', 'xinit', 'xterm', 'sh', 'bash', 'dash', } for directory in (Path('/usr/share/xsessions'), Path('/usr/share/wayland-sessions')): for path in directory.glob('*.desktop'): entry = ConfigParser(interpolation=None, strict=False) try: with path.open(encoding='utf-8') as file: entry.read_file(file) session = entry['Desktop Entry'] if session.get('Hidden', '').lower() == 'true': continue command = split(session.get('Exec', '')) if not command or Path(command[0]).name.lower() in fallback_launchers: continue launcher = session.get('TryExec') or command[0] if which(command[0]) and which(launcher): print(path.stem) raise SystemExit(0) except (OSError, UnicodeError, Error, KeyError, ValueError): continue print('nein') FF_DESKTOP ) if [ "$desktop" != nein ]; then echo "Grafische Desktop-Sitzung erkannt ($desktop). Variante d ergänzt Programme für den Büro-PC." standard=d muster='^[abcd]$' frage='a, b, c, [d empfohlen]: ' hinweis='Bitte a, b, c oder d eingeben.' else echo "Keine grafische Desktop-Sitzung erkannt. Variante d setzt einen Desktop voraus." standard=c muster='^[abc]$' frage='a, b, c [c ist Standard]: ' hinweis='Bitte a, b oder c eingeben.' fi while :; do read -r -p "$(ff_prompt "$frage")" auswahl "$FF_SESSION/variante" fi case "$auswahl" in a) pakete='openssh-server openssh-client batctl bridge-utils rsync iptables net-tools git fastd ipv6calc isc-dhcp-client python3 iputils-ping chrony cron ifupdown iproute2 kmod curl wget ca-certificates python3-netifaces nano ethtool lsb-release vlan dialog iw wireless-regdb' ;; b) pakete='openssh-server openssh-client batctl bridge-utils rsync iptables net-tools git fastd ipv6calc isc-dhcp-client python3 iputils-ping chrony cron ifupdown iproute2 kmod curl wget ca-certificates python3-netifaces nano ethtool lsb-release vlan dialog iw wireless-regdb vim apache2 apache2-utils php php-curl php-zip php-xml php-gd php-mbstring sudo php-cli lm-sensors nut-client nut-server nftables ssl-cert iperf3 vnstat hardinfo2 duf' ;; c) pakete='openssh-server openssh-client batctl bridge-utils rsync iptables net-tools git fastd ipv6calc isc-dhcp-client python3 iputils-ping chrony cron ifupdown iproute2 kmod curl wget ca-certificates python3-netifaces nano ethtool lsb-release vlan dialog iw wireless-regdb vim apache2 apache2-utils php php-curl php-zip php-xml php-gd php-mbstring sudo php-cli lm-sensors nut-client nut-server nftables ssl-cert iperf3 vnstat hardinfo2 duf iftop htop rsyslog unattended-upgrades glances etherape links2 gpm letsencrypt python3-certbot-apache traceroute iperf' ;; d) pakete='openssh-server openssh-client batctl bridge-utils rsync iptables net-tools git fastd ipv6calc isc-dhcp-client python3 iputils-ping chrony cron ifupdown iproute2 kmod curl wget ca-certificates python3-netifaces nano ethtool lsb-release vlan dialog iw wireless-regdb vim apache2 apache2-utils php php-curl php-zip php-xml php-gd php-mbstring sudo php-cli lm-sensors nut-client nut-server nftables ssl-cert iperf3 vnstat hardinfo2 duf iftop htop rsyslog unattended-upgrades glances etherape links2 gpm letsencrypt python3-certbot-apache traceroute iperf nextcloud-desktop numlockx gimp libreoffice libreoffice-l10n-de libreoffice-help-de libreoffice-impress libreoffice-draw libreoffice-base simple-scan chromium pdfarranger keepassxc gparted vlc x11-apps thunderbird' ;; *) exit 1 ;; esac # Keine Auswahl oder Entfernung optionaler Server-/VPN-Komponenten mehr. # Bestehende Installationen bleiben durch diese Auswahl unverändert. read -r -a paketliste <<< "$pakete" printf '%s\tPAKETE: gewählte Variante %s; Systemupdate beginnt.\n' "$(TZ=Europe/Berlin date --iso-8601=seconds)" "$auswahl" >> "$FF_SESSION/ereignisse.log" ff_state before-upgrade apt update && apt upgrade -y || { echo "Systemupdate fehlgeschlagen; Paketinstallation angehalten."; exit 1; } ff_state after-upgrade apt-get install -y gpg curl ca-certificates ff_verify_sources temporary_nft_mask=nein if [ "$auswahl" != a ] && ! dpkg-query -W -f='${db:Status-Status}' nftables 2>/dev/null | grep -qx installed; then if [ "$(systemctl is-enabled nftables.service 2>/dev/null || true)" != masked ] && [ "$(systemctl is-enabled nftables.service 2>/dev/null || true)" != masked-runtime ]; then systemctl mask --runtime nftables.service temporary_nft_mask=ja trap 'if [ "$temporary_nft_mask" = ja ]; then systemctl unmask --runtime nftables.service; fi' EXIT fi fi apt install -y "${paketliste[@]}" if [ "$temporary_nft_mask" = ja ]; then systemctl disable nftables.service; systemctl unmask --runtime nftables.service; temporary_nft_mask=nein; fi printf '%s\tPAKETE: Systemupdate und Variante %s abgeschlossen.\n' "$(TZ=Europe/Berlin date --iso-8601=seconds)" "$auswahl" >> "$FF_SESSION/ereignisse.log" FF_STAGE_00 FF_VARIANT=$(cat "$FF_SESSION/variante");export FF_VARIANT ff_run 01 'Gemeinsame Angaben und Sicherungen vorbereiten' <<'FF_STAGE_01' bash <<'FREIFUNK_SCHRITT' set -euo pipefail [ "$(id -u)" -eq 0 ] || { echo "Bitte als root ausführen." >&2; exit 1; } install -d -m 0755 /usr/local/lib /etc/freifunk arbeit=$(mktemp -d /tmp/freifunk-vorbereitung.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT cat > "$arbeit/helper" <<'FREIFUNK_HELPER' #!/bin/bash [ "$(id -u)" -eq 0 ] || { echo "Bitte als root ausführen." >&2; exit 1; } umask 027 FF_CONFIG=/etc/freifunk/installation.conf FF_SESSION=$(cat /var/lib/freifunk-installation/aktuelle-sitzung 2>/dev/null) || { echo "Bitte zuerst Installationsprotokoll beginnen ausführen." >&2; exit 1; } case "$FF_SESSION" in /var/lib/freifunk-installation/*) ;; *) echo "Ungültige Installationssitzung." >&2; exit 1 ;; esac [ -d "$FF_SESSION" ] || exit 1 export FF_SESSION FF_BACKUP="/var/backups/freifunk/$(TZ=Europe/Berlin date +%Y-%m-%d-%H-%M-%S)-$$" # Farben nur für geeignete Terminals; NO_COLOR deaktiviert die Darstellung. ff_tint() { local color=$1 text=$2 fd=${3:-1} if [ -t "$fd" ] && [ -z "${NO_COLOR+x}" ] && [ "${TERM:-dumb}" != dumb ]; then printf '\033[%sm%s\033[0m' "$color" "$text" else printf '%s' "$text" fi } ff_prompt() { ff_tint '1;36' "$*" 2; } export -f ff_tint ff_prompt ff_note() { printf '%s\t%s\n' "$(TZ=Europe/Berlin date --iso-8601=seconds)" "$*" >> "$FF_SESSION/ereignisse.log" chmod 0600 "$FF_SESSION/ereignisse.log" case "$*" in *FEHLER*|*FEHLGESCHLAGEN*) ff_tint '1;31' "$*"; printf '\n' ;; *WARNUNG*|*ACHTUNG*) ff_tint '1;33' "$*"; printf '\n' ;; 'PRÜFUNG OK:'*|'ABGESCHLOSSEN:'*) ff_tint '1;32' "$*"; printf '\n' ;; esac } ff_fail() { local code=$1 zeile=$2 ff_note "FEHLER: ${FF_STEP:-Terminalblock}; Zeile $zeile; Exit-Code $code. Keine Befehlsinhalte oder Geheimnisse mitgeschrieben." if [ -f /usr/local/lib/freifunk-installationsbericht.py ]; then python3 /usr/local/lib/freifunk-installationsbericht.py >/dev/null 2>&1 || true fi } set -E trap 'ff_fail "$?" "$LINENO"' ERR ff_check() { local text=$1 shift if "$@"; then ff_note "PRÜFUNG OK: $text"; else ff_note "PRÜFUNG FEHLGESCHLAGEN: $text"; return 1; fi } ff_backup() { local ziel=$1 if [ -e "$ziel" ] || [ -L "$ziel" ]; then install -d -m 0700 "$(dirname "$FF_BACKUP$ziel")" cp -a -- "$ziel" "$FF_BACKUP$ziel" printf 'Sicherung: %s\n' "$FF_BACKUP$ziel" ff_note "SICHERUNG: $ziel -> $FF_BACKUP$ziel" fi } ff_install() { local quelle=$1 ziel=$2 modus=${3:-0644} gruppe=${4:-root} [ ! -L "$ziel" ] || { echo "Zieldatei ist ein Symlink: $ziel" >&2; return 1; } if [ -f "$ziel" ] && cmp -s -- "$quelle" "$ziel"; then chown root:"$gruppe" "$ziel"; chmod "$modus" "$ziel" ff_note "DATEI unverändert: $ziel; Rechte $modus; root:$gruppe." return fi mkdir -p -- "$(dirname "$ziel")" ff_backup "$ziel" install -o root -g "$gruppe" -m "$modus" -- "$quelle" "$ziel" ff_note "DATEI installiert/geändert: $ziel; Rechte $modus; root:$gruppe." } ff_ask() { local ff_ask_target=$1 ff_ask_question=$2 ff_ask_default=${3-} ff_ask_input read -r -p "$(ff_prompt "$ff_ask_question${ff_ask_default:+ [$ff_ask_default]}: ")" ff_ask_input /dev/tty printf -v "$ff_ask_target" '%s' "${ff_ask_input:-$ff_ask_default}" } ff_yesno() { local variable=$1 frage=$2 vorgabe=${3:-nein} wert while :; do ff_ask wert "$frage (ja/nein)" "$vorgabe" case "${wert,,}" in ja|j) printf -v "$variable" ja; return ;; nein|n) printf -v "$variable" nein; return ;; esac echo "Bitte ja oder nein eingeben." done } ff_save() { local tmp variable tmp=$(mktemp) for variable in WAN_PORTS MESH_PORTS VLAN_PORTS LAN_PORTS ETH_PORTS PREV_LAN_PORTS PREV_WAN_PORTS PREV_MESH_PORTS PREV_VLAN_PORTS VARIANTE FF_EDITOR WAN MESH GEMEINSAM WAN_MODE WEBZIEL VLANS VLANPORT VLAN7_IP BR0_IPV4 BR0_IPV6 DHCP_EXTRA DHCP_INTERVAL PREV_WAN PREV_MESH PREV_VLANPORT PREV_VLANS PREV_VLAN7_IP KEEP_INVALID_FIELDS USV_ENABLED SPEED_ENABLED IPERF_ENABLED WAN_PROFILE_UUID FF_WLAN_PLAN; do printf '%s=%q\n' "$variable" "${!variable}" >> "$tmp" done ff_install "$tmp" "$FF_CONFIG" 0600 rm -f -- "$tmp" export WAN_PORTS MESH_PORTS VLAN_PORTS LAN_PORTS ETH_PORTS PREV_LAN_PORTS PREV_WAN_PORTS PREV_MESH_PORTS PREV_VLAN_PORTS VARIANTE FF_EDITOR WAN MESH GEMEINSAM WAN_MODE WEBZIEL VLANS VLANPORT VLAN7_IP BR0_IPV4 BR0_IPV6 DHCP_EXTRA DHCP_INTERVAL PREV_WAN PREV_MESH PREV_VLANPORT PREV_VLANS PREV_VLAN7_IP KEEP_INVALID_FIELDS USV_ENABLED SPEED_ENABLED IPERF_ENABLED WAN_PROFILE_UUID FF_WLAN_PLAN python3 <<'FF_AUSWAHL' import json,os,pathlib keys=['LAN_PORTS','ETH_PORTS','PREV_LAN_PORTS','WAN_PORTS', 'MESH_PORTS', 'VLAN_PORTS', 'PREV_WAN_PORTS', 'PREV_MESH_PORTS', 'PREV_VLAN_PORTS']+['VARIANTE','FF_EDITOR','WAN','MESH','GEMEINSAM','WAN_MODE','WEBZIEL','VLANS','VLANPORT','VLAN7_IP','BR0_IPV4','BR0_IPV6','DHCP_EXTRA','DHCP_INTERVAL','PREV_WAN','PREV_MESH','PREV_VLANPORT','PREV_VLANS','PREV_VLAN7_IP','KEEP_INVALID_FIELDS','USV_ENABLED','SPEED_ENABLED','IPERF_ENABLED','WAN_PROFILE_UUID','FF_WLAN_PLAN'] p=pathlib.Path(os.environ['FF_SESSION'])/'auswahl.json' p.write_text(json.dumps({k:os.environ[k] for k in keys},ensure_ascii=False,indent=2)+'\n') p.chmod(0o600) FF_AUSWAHL ff_note "PORTROLLEN: WAN=$WAN_PORTS; Mesh=$MESH_PORTS; VLAN-Paket=$VLAN_PORTS." ff_note "AUSWAHL: Variante=$VARIANTE; WAN=$WAN (DHCP/$WAN_MODE); Mesh=$MESH; gemeinsam=$GEMEINSAM; VLANs=$VLANS; VLAN-Port=$VLANPORT; Webverzeichnis=$WEBZIEL." } ff_web() { if [ "$VARIANTE" = a ]; then echo "Dieser Schritt gehört zu b, c und d; bei a wird er übersprungen." ff_note "ÜBERSPRUNGEN (Variante a): ${FF_STEP:-Web-Schritt}" exit 0 fi } ff_report() { python3 /usr/local/lib/freifunk-installationsbericht.py } LAN_PORTS=; ETH_PORTS=; PREV_LAN_PORTS=; WAN_PORTS=; MESH_PORTS=; VLAN_PORTS=; PREV_WAN_PORTS=; PREV_MESH_PORTS=; PREV_VLAN_PORTS= VARIANTE=${FF_VARIANT:-c}; FF_EDITOR=nano; WAN=; MESH=; WEBZIEL=/var/www/html GEMEINSAM=nein; WAN_MODE=ifupdown; VLANS=nein; VLANPORT= FF_WLAN_PLAN='[]'; WAN_PROFILE_UUID=; USV_ENABLED=nein; SPEED_ENABLED=ja; IPERF_ENABLED=ja; KEEP_INVALID_FIELDS=; VLAN7_IP=; BR0_IPV4=; BR0_IPV6=; DHCP_EXTRA=nein; DHCP_INTERVAL=5 PREV_WAN=; PREV_MESH=; PREV_VLANPORT=; PREV_VLANS=nein; PREV_VLAN7_IP= [ ! -f "$FF_CONFIG" ] || source "$FF_CONFIG" VARIANTE=${FF_VARIANT:-$VARIANTE} export WAN_PORTS MESH_PORTS VLAN_PORTS LAN_PORTS ETH_PORTS PREV_LAN_PORTS PREV_WAN_PORTS PREV_MESH_PORTS PREV_VLAN_PORTS VARIANTE FF_EDITOR WAN MESH GEMEINSAM WAN_MODE WEBZIEL VLANS VLANPORT VLAN7_IP BR0_IPV4 BR0_IPV6 DHCP_EXTRA DHCP_INTERVAL PREV_WAN PREV_MESH PREV_VLANPORT PREV_VLANS PREV_VLAN7_IP KEEP_INVALID_FIELDS USV_ENABLED SPEED_ENABLED IPERF_ENABLED WAN_PROFILE_UUID FF_WLAN_PLAN FREIFUNK_HELPER if [ -e /usr/local/lib/freifunk-installation.sh ]; then sicherung="/var/backups/freifunk/$(TZ=Europe/Berlin date +%Y-%m-%d-%H-%M-%S)-$$" install -d -m 0700 "$sicherung" cp -a /usr/local/lib/freifunk-installation.sh "$sicherung/" fi install -o root -g root -m 0644 "$arbeit/helper" /usr/local/lib/freifunk-installation.sh source /usr/local/lib/freifunk-installation.sh FF_STEP='Gemeinsame Angaben und Sicherungen vorbereiten' ff_note "BEGINN: $FF_STEP" cat > "$arbeit/bericht.py" <<'FREIFUNK_BERICHT_PY' import datetime,json,os,pathlib,subprocess,tempfile from zoneinfo import ZoneInfo wans=os.environ.get('WAN_PORTS','').split() meshes=os.environ.get('MESH_PORTS','').split() vports=os.environ.get('VLAN_PORTS','').split() base=pathlib.Path(os.environ.get('FF_TEST_ROOT','/')) def system_path(s):return base/s.lstrip('/') session=pathlib.Path(os.environ['FF_SESSION']) def read(p):return p.read_text(errors='replace') if p.exists() else '' def packages(text): result={} for line in text.splitlines(): parts=line.split('\t') if len(parts)==3 and parts[2]=='installed':result[parts[0]]=parts[1] return result query=subprocess.run(['dpkg-query','-W','-f=${binary:Package}\t${Version}\t${db:Status-Status}\n'],text=True,capture_output=True) if query.returncode:raise SystemExit('Paketbestand nicht lesbar; Bericht nicht überschrieben.') now=packages(query.stdout);before=packages(read(session/'pakete-vorher.tsv')) selected=json.loads(read(session/'auswahl.json') or '{}') events=read(session/'ereignisse.log') reportpathfile=session/'bericht-pfad' if reportpathfile.exists(): target=pathlib.Path(reportpathfile.read_text().strip()) else: target=system_path('/root')/('install-'+datetime.datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y-%m-%d-%H-%M')+'.txt') if target.exists():raise SystemExit(f'{target} existiert bereits aus einer anderen Sitzung; bitte in der nächsten Minute erneut erzeugen.') if target.is_symlink():raise SystemExit('Berichtsdatei ist ein Symlink; abgebrochen.') lines=['FREIFUNK-OFFLOADER – INSTALLATIONSBERICHT', '='*45, 'Beginn: '+read(session/'start.txt').strip(), 'Berichtsstand: '+datetime.datetime.now(ZoneInfo('Europe/Berlin')).isoformat(timespec='seconds'), 'Rechner: '+read(system_path('/etc/hostname')).strip(),'', 'Dieser Bericht dokumentiert die ausgeführten Anleitungsschritte. Er ist kein vollständiges Terminalmitschnittprotokoll.', 'Passwörter, private Schlüssel, Passwortdateien und fastd-Secrets werden nicht eingelesen oder aufgenommen.','', 'AUSWAHL UND NETZWERK','-'*25] labels={'VARIANTE':'Installationsvariante','FF_EDITOR':'Editor','WAN':'WAN-Port','MESH':'Ungetaggter Kabel-Mesh-Port','GEMEINSAM':'WAN und Mesh gemeinsam','WAN_MODE':'WAN-Verwaltung','VLANS':'VLAN-Paket aktiviert','VLANPORT':'Physischer VLAN-Port','WEBZIEL':'Webverzeichnis','VLAN7_IP':'Zusätzliche reservierte Managementadresse','BR0_IPV4':'Zusätzliche reservierte br0-IPv4','BR0_IPV6':'Zusätzliche reservierte br0-IPv6','DHCP_EXTRA':'LTE-DHCP-Zusatzabfrage','DHCP_INTERVAL':'Zusatzintervall in Minuten'} for key,label in labels.items():lines.append(label+': '+str(selected.get(key,'nicht ausgewählt'))) lines+=['','Freifunk-LAN ungetaggt: '+selected.get('LAN_PORTS','')+'; Management-VLAN 7 bei Auswahl auf allen Ethernet-Ports über br7: '+selected.get('ETH_PORTS',''), 'Portrollen: WAN='+selected.get('WAN_PORTS','')+'; Mesh='+selected.get('MESH_PORTS','')+'; VLAN-Paket='+selected.get('VLAN_PORTS',''), 'IPv4-Routen: physische WANs ab Metrik 100; VLAN 6 ab 500. WAN-Pfadwechsel für fastd werden nach dem Neustart überwacht.'] lines+=['','Der physische WAN-Port wird als DHCP-Client eingerichtet.', 'Ungetaggtes Mesh und DHCP auf demselben Port teilen sich das physische Netzwerk; es besteht dort keine VLAN-Trennung.'] lines.append('WLAN wird vom Installer nicht eingerichtet. Bestehende WLAN-Verbindungen bleiben unverändert.') if selected.get('VLANS')=='ja': port=selected.get('VLANPORT','?') lines += [f'{port}.5: zusätzliches Kabel-Mesh über BATMAN-adv.',f'{port}.6: WAN / Internetzugang, DHCP-Client.',f'{port}.8: Freifunk-LAN; fflan wird nachträglich in br0 eingehängt.', 'WAN-Port und VLAN 6 sind beide DHCP-Clients. Wenn beide eine Standardroute liefern, entscheidet die Routingtabelle des Systems.'] lines += ['', 'NETZWERK-START UND DATEIEN','-'*28, 'Netzwerkdateien werden erst beim abschließenden Neustart aktiviert. Der Installationsblock startet networking nicht neu.', 'rc.local behält systemctl restart networking sowie ifdown -a && ifup -a als bisherigen Workaround.', 'Die vermutete IPv6-DAD-/Timing-Ursache ist unbestätigt. Eine dauerhafte Lösung wird zuerst auf der Datenschleuder getestet.', 'Die Ereignisliste nennt tatsächlich gesicherte und installierte Dateien. Bestehende br0-Adressen werden beim Erzeugen übernommen.', 'staticv6.sh richtet zwei automatische IPv6-Adressen ein: aus der Bridge-MAC und aus der MAC in der Node-ID; identische Adressen nur einmal.', 'VLAN-Paket: 5, 6 und 8; Management-VLAN 7 nur nach optionaler Eingabe einer reservierten Adresse. Bestehende VLANs bleiben erhalten.', '', 'PROGRAMME UND DIENSTE','-'*23, 'ext-respondd liefert die Daten für die Netzkarte; alias.json und config.json werden nach Eingabe erzeugt.', 'Der Patch für Nachbarnamen aktiviert Multicast je Mesh-Port. rc.local startet ext-respondd nach der Portzuordnung erneut und wartet bis zu 15 Sekunden auf vpn3.', 'fastd verwendet vpn3. Ein vorhandener privater Schlüssel bleibt erhalten; dessen Inhalt gehört nicht in den Bericht.', 'Der öffentliche fastd-Schlüssel muss separat unter https://freifunk-halle.org/fastd/ registriert werden.', 'Ab b: Webdateien im gewählten Ziel, Datensammler /opt/freifunk/datensammler.php, Daten /var/lib/freifunk/daten (root:www-data, 0750).', 'index.html leitet auf index.php? weiter. Die Hilfe liegt auf dem Offloader unter index.php?hilfe.', 'PHP-Upload-Progress wird für die vorhandenen Apache-PHP-Versionen deaktiviert.', 'Die sudoers-Datei erlaubt www-data nur die sechs festen batctl-Aufrufe und wird mit visudo geprüft.', 'chrony synchronisiert die Uhrzeit. Bei installiertem unattended-upgrades werden periodische Updates ohne automatischen Neustart eingerichtet.'] # Nur bekannte, nicht geheime ext-respondd-Felder aufnehmen; keine vollständigen Konfigurationen kopieren. for filename, keys in [('alias.json',None),('config.json',['batman','bridge','mesh-vpn','fastd_socket','rate_limit','rate_limit_burst'])]: p=system_path('/opt/ext-respondd')/filename if not p.exists():continue try:data=json.loads(p.read_text()) except (ValueError,OSError): lines+=['',filename+': nicht lesbar oder ungültiges JSON.'];continue lines+=['','EXT-RESPONDD '+filename] if filename=='alias.json': n=data.get('nodeinfo',{}) values={'Knotenname':n.get('hostname'),'Node-ID':n.get('node_id'),'Hardware':n.get('hardware',{}).get('model'),'Kontakt':n.get('owner',{}).get('contact'),'Standort':n.get('location'),'firstseen':data.get('firstseen')} else:values={key:data.get(key) for key in keys} for key,value in values.items():lines.append(str(key)+': '+json.dumps(value,ensure_ascii=False)) lines+=['','TATSÄCHLICHE BR0-/VLAN-KONFIGURATION','-'*34] for config in [system_path('/etc/network/interfaces'),*sorted(system_path('/etc/network/interfaces.d').glob('*'))]: if not config.is_file():continue for m in __import__('re').finditer(r'(?m)^\s*iface\s+(?:br0|[^\s]+\.7)\s+[^\n]*\n(?:(?!\s*(?:auto|allow-\S+|iface|mapping|source|source-directory)\s).*(?:\n|$))*',read(config)): for line in m[0].splitlines(): if __import__('re').match(r'^\s*(?:iface|address|netmask|gateway)\b',line):lines.append(str(config)+': '+line.strip()) cron=system_path('/etc/cron.d/freifunk') lines+=['','CRON-AUFGABEN','-'*13,read(cron).strip() if cron.exists() else 'Keine Datei /etc/cron.d/freifunk vorhanden.'] lines+=['','PAKETÄNDERUNGEN SEIT BEGINN','-'*26] if (session/'pakete-vorher.tsv').exists(): added=sorted(set(now)-set(before));removed=sorted(set(before)-set(now));changed=sorted(p for p in now.keys()&before.keys() if now[p]!=before[p]) lines+=['Neu installiert:']+[f' {p} {now[p]}' for p in added]+([' keine'] if not added else []) lines+=['Versionsänderungen:']+[f' {p}: {before[p]} → {now[p]}' for p in changed]+([' keine'] if not changed else []) lines+=['Entfernt:']+[f' {p} {before[p]}' for p in removed]+([' keine'] if not removed else []) else:lines+=['Ausgangsbestand fehlt; keine Aussage über tatsächliche Paketänderungen möglich.'] lines+=['','AKTUELL INSTALLIERTE PAKETE','-'*26]+[f'{p}\t{now[p]}' for p in sorted(now)] lines+=['','AUSGEFÜHRTE SCHRITTE, SICHERUNGEN UND PRÜFUNGEN','-'*46,events.strip() or 'Keine Ereignisse protokolliert.', '', 'OFFENE PUNKTE UND GRENZEN','-'*24, 'KEEP_INVALID_FIELDS: bewusst beibehaltene gespeicherte Abweichungen werden nicht neu angewendet. Vorhandene Interface-Konfiguration bleibt erhalten; Ausnahme und Entscheidung stehen in der Ereignisliste.', 'Nur als erfolgreich protokollierte Schritte gelten als ausgeführt. Nicht durchlaufene oder fehlgeschlagene Schritte sind nicht automatisch erledigt.', 'USV: NUT muss zur tatsächlich angeschlossenen USV konfiguriert werden. Ein eingebauter Akku benötigt dafür kein NUT.', 'DHCP-Server: ISC/Kea und Standort-Adressbereiche separat konfigurieren; WAN-DHCP-Client ist davon unabhängig.', 'Ookla-speedtest: eigener Installationsschritt ab b vor Cron; tatsächlichen Erfolg in der Ereignisliste prüfen. speedtest-cli ist kein Ersatz.', 'Die VLAN-Zuordnung am angeschlossenen Switch/Router muss zum gewählten Port passen.', 'Bei einem eigenen Webverzeichnis muss der Apache-VirtualHost auf dieses DocumentRoot zeigen.', 'Die nach dem Neustart protokollierten Prüfergebnisse ergänzen die Vorprüfung; ein aktivierter Dienst ist noch kein Funktionsnachweis.', '', 'Berichtsdatei: '+str(target)] target.parent.mkdir(parents=True,exist_ok=True) fd,tmp=tempfile.mkstemp(dir=target.parent,prefix='.freifunk-bericht-') try: with os.fdopen(fd,'w') as out:out.write('\n'.join(lines)+'\n') os.chmod(tmp,0o600);os.chown(tmp,0,0);os.replace(tmp,target) finally: if os.path.exists(tmp):os.unlink(tmp) reportpathfile.write_text(str(target)+'\n');reportpathfile.chmod(0o600) print('Installationsbericht: '+str(target)) FREIFUNK_BERICHT_PY ff_install "$arbeit/bericht.py" /usr/local/lib/freifunk-installationsbericht.py 0644 echo "Gewählte Variante: $VARIANTE" PREV_LAN_PORTS=${LAN_PORTS:-}; PREV_WAN_PORTS=${WAN_PORTS:-$WAN}; PREV_MESH_PORTS=${MESH_PORTS:-$MESH}; PREV_VLAN_PORTS=${VLAN_PORTS:-$VLANPORT}; PREV_WAN=$WAN; PREV_MESH=$MESH; PREV_VLANPORT=$VLANPORT; PREV_VLANS=$VLANS; PREV_VLAN7_IP=$VLAN7_IP if [ "${FF_NETWORK_ONLY:-nein}" != ja ]; then while :; do ff_ask FF_EDITOR "Editor: nano oder vi" "$FF_EDITOR" [[ "$FF_EDITOR" =~ ^(nano|vi)$ ]] && command -v "$FF_EDITOR" >/dev/null && break done fi printf '\nVorhandene Schnittstellen, MAC-Adressen und Verbindungsstatus:\n' ip -brief link ip -brief address mapfile -t ethports < <(python3 <<'FREIFUNK_ETH' import pathlib ports=[] for p in pathlib.Path('/sys/class/net').iterdir(): if p.name=='lo' or (p/'phy80211').exists() or (p/'wireless').exists() or not (p/'device').exists():continue if (p/'type').read_text().strip()!='1':continue ports.append((int((p/'ifindex').read_text()),p.name)) for _,name in sorted(ports):print(name) FREIFUNK_ETH ) [ "${#ethports[@]}" -gt 0 ] || { echo "Kein physischer Ethernet-Port erkannt." >&2; exit 1; } printf 'Ethernet-Ports: %s\n' "${ethports[*]}" if command -v dialog >/dev/null && [ "${TERM:-dumb}" != dumb ]; then cat > "$arbeit/port-menu.py" <<'FF_PORT_MENU' import json import os import re import shlex import subprocess import sys from pathlib import Path SYS = Path("/sys/class/net") class Cancel(Exception): pass class Back(Exception): pass class Navigation: def __init__(self): self.confirmed = [] self.remembered = {} self.cursor = 0 def rewind(self): if not self.confirmed: raise Cancel self.confirmed.pop() self.cursor = 0 def retry(self): self.confirmed.pop() self.cursor -= 1 def dialog(self, widget, prompt, *args): # Rebuild the current plan by replaying confirmed pages up to the one to edit. key = widget, prompt if self.cursor < len(self.confirmed): if widget == 'msgbox': return '' old_key, answer = self.confirmed[self.cursor] if old_key == key: self.cursor += 1 return answer del self.confirmed[self.cursor:] size = ['19', '74'] if widget in ('checklist', 'radiolist'): size.append('9') saved = self.remembered.get(key) if widget == 'inputbox' and saved is not None: args = (saved,) if widget in ('checklist', 'radiolist') and saved is not None: selected = set(shlex.split(saved)) if widget == 'checklist' else {saved} if widget == 'checklist' or selected <= set(args[::3]): args = [part for tag, desc in zip(args[::3], args[1::3]) for part in (tag, desc, 'on' if tag in selected else 'off')] values = [token for arg in args for token in (('--', arg) if arg.startswith('--') else (arg,))] cmd = ['dialog', '--backtitle', 'Freifunk Halle · Offloader', '--no-collapse', '--no-mouse'] if widget == 'radiolist' and 'on' in args[2::3]: cmd += ['--default-item', args[3 * args[2::3].index('on')]] back_label = 'Zurück' if self.cursor else 'Abbrechen' if widget == 'yesno': cmd += ['--yes-label', 'Übernehmen', '--no-label', back_label] elif widget != 'msgbox': cmd += ['--cancel-label', back_label] result = subprocess.run([*cmd, '--' + widget, prompt, *size, *values], stdin=tty_in, stdout=tty_out, stderr=subprocess.PIPE, text=True) if result.returncode == 255: raise Cancel if result.returncode == 1 and self.cursor: raise Back if result.returncode == 1: raise Cancel if result.returncode: raise RuntimeError(result.stderr.strip() or 'Dialog fehlgeschlagen.') answer = result.stderr if widget == 'inputbox' else result.stderr.strip() if widget != 'msgbox': self.confirmed.append((key, answer)) self.remembered[key] = answer self.cursor += 1 return answer nav = Navigation() dialog = nav.dialog def choose(prompt, options, default): args = [] for key, desc in options.items(): args += [key, desc, 'on' if key == default else 'off'] return dialog('radiolist', prompt, *args) def check(prompt, options, defaults): args = [] for key, desc in options.items(): args += [key, desc, 'on' if key in defaults else 'off'] return set(shlex.split(dialog('checklist', prompt, *args))) def status(port): try: return (SYS / port / 'operstate').read_text().strip() except OSError: return 'unbekannt' def select_ports(eths): keys = {'WAN_PORTS': 'WAN / Internet über DHCP', 'MESH_PORTS': 'Kabel-Mesh ungetaggt', 'LAN_PORTS': 'Freifunk-LAN ungetaggt', 'VLAN_PORTS': 'VLAN-Paket 5 / 6 / 8'} ports = {key: [] for key in keys} old = {key: os.environ.get('PREV_' + key, '').split() for key in keys} route = subprocess.run(['ip', '-4', '-j', 'route', 'show', 'default'], capture_output=True, text=True) active = (json.loads(route.stdout) if route.returncode == 0 else []) or [{}] primary = os.environ.get('WAN') or active[0].get('dev', '') if not any(old.values()) and primary in eths: old['WAN_PORTS'] = [primary] for port in eths: offered = {key: desc for key, desc in keys.items() if key != 'VLAN_PORTS' or len(port) <= 13} defaults = {key for key in offered if port in old[key]} while True: selected = check(f'{port} · Ethernet · Link {status(port)}\n' 'Freifunk-LAN schließt ungetaggtes WAN und Mesh auf diesem Port aus.', offered, defaults) if 'LAN_PORTS' in selected and selected & {'WAN_PORTS', 'MESH_PORTS'}: dialog('msgbox', 'Für ungetaggtes Freifunk-LAN bitte WAN und Kabel-Mesh abwählen. ' 'Das VLAN-Paket kann ausgewählt bleiben.') nav.retry() defaults = selected continue for key in selected: ports[key].append(port) break wans = ports['WAN_PORTS'] if len(wans) > 1: preferred = choose('Bevorzugten Ethernet-WAN-Port auswählen', {port: 'Link ' + status(port) for port in wans}, primary if primary in wans else wans[0]) wans.remove(preferred) wans.insert(0, preferred) if not (wans or ports['VLAN_PORTS']): dialog('msgbox', 'Mindestens einen WAN-Zugang oder ein VLAN-Paket mit VLAN 6 wählen.') raise Back summary = [f'{desc}: {", ".join(ports[key]) or "–"}' for key, desc in keys.items()] dialog('yesno', 'Portzuordnung übernehmen?\n\n' + '\n'.join(summary)) return {**{key: ' '.join(value) for key, value in ports.items()}, 'ETH_PORTS': ' '.join(eths), 'WAN': wans[0] if wans else ''} if __name__ == '__main__': try: with open('/dev/tty') as tty_in, open('/dev/tty', 'w') as tty_out: while True: try: values = select_ports(sys.argv[1:]) break except Back: nav.rewind() for key, value in values.items(): print(f'{key}={shlex.quote(value)}') except Cancel: sys.exit('Grafische Portauswahl abgebrochen.') except (RuntimeError, ValueError, OSError, subprocess.SubprocessError) as error: sys.exit(f'Grafische Portauswahl: {error}') FF_PORT_MENU python3 "$arbeit/port-menu.py" "${ethports[@]}" > "$arbeit/portauswahl" source "$arbeit/portauswahl" else echo "Grafische Portauswahl nicht verfügbar. Ethernet-Ports werden im Textmodus abgefragt." wanports=("${ethports[@]}") printf '\n=== WAN AUSWÄHLEN: ETHERNET ===\n' for i in "${!wanports[@]}"; do dev=${wanports[i]} state=$(cat "/sys/class/net/$dev/operstate" 2>/dev/null || echo unbekannt) kind=Ethernet printf '%2d) %-12s %-9s Status: %s\n' "$((i+1))" "$dev" "$kind" "$state" done wan_vorschlag=$(ip -4 route show default | awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}') echo "Bisherige Zuordnung ist nur eine Vorgabe. Nummer oder Gerätename auswählen." while :; do ff_ask wan_wahl "WAN auswählen: Ethernet (Nummer/Gerätename)" "${WAN:-$wan_vorschlag}" if [[ "$wan_wahl" =~ ^[0-9]{1,3}$ ]]; then nummer=$((10#$wan_wahl)) if [ "$nummer" -ge 1 ] && [ "$nummer" -le "${#wanports[@]}" ]; then WAN=${wanports[nummer-1]}; break; fi elif printf '%s\n' "${wanports[@]}" | grep -qxF "$wan_wahl"; then WAN=$wan_wahl; break; fi echo "Bitte eine Nummer oder einen Gerätenamen aus der Liste wählen." done WAN_PORTS=; MESH_PORTS=; VLAN_PORTS=; LAN_PORTS= ETH_PORTS="${ethports[*]}" # Die erste WAN-Auswahl legt den bevorzugten Zugang fest; jeder Ethernet-Port # kann zusätzlich unabhängig alle drei Rollen erhalten oder ungenutzt bleiben. for dev in "${ethports[@]}"; do echo "=== Rollen für Ethernet-Port $dev ===" w=nein; m=nein; v=nein; lan=nein case " ${PREV_LAN_PORTS:-} " in *" $dev "*) lan=ja ;; esac [ "$dev" != "$WAN" ] || w=ja case " ${PREV_MESH_PORTS:-$MESH} " in *" $dev "*) m=ja ;; esac case " ${PREV_VLAN_PORTS:-$VLANPORT} " in *" $dev "*) v=ja ;; esac ff_yesno lan "Ungetaggtes Freifunk-LAN über macvlan auf $dev? (schließt WAN und Mesh ungetaggt aus)" "$lan" if [ "$lan" = ja ]; then w=nein; m=nein LAN_PORTS="${LAN_PORTS:+$LAN_PORTS }$dev" echo "Freifunk-LAN gewählt: kein ungetaggtes WAN oder Mesh auf $dev; VLANs bleiben zulässig." else ff_yesno w "WAN (DHCP) auf $dev?" "$w" ff_yesno m "Ungetaggtes Kabel-Mesh auf $dev?" "$m" fi ff_yesno v "VLAN-Paket 5 (Mesh), 6 (WAN), 8 (Freifunk-LAN) auf $dev?" "$v" [ "$v" != ja ] || [ "${#dev}" -le 13 ] || { echo "Portname zu lang für VLANs."; exit 1; } [ "$w" != ja ] || WAN_PORTS="${WAN_PORTS:+$WAN_PORTS }$dev" [ "$m" != ja ] || MESH_PORTS="${MESH_PORTS:+$MESH_PORTS }$dev" [ "$v" != ja ] || VLAN_PORTS="${VLAN_PORTS:+$VLAN_PORTS }$dev" done fi FF_WLAN_PLAN='[]' [ -n "$WAN_PORTS$VLAN_PORTS" ] || { echo "Mindestens ein WAN-Zugang (physisch oder VLAN 6) erforderlich."; exit 1; } # Bevorzugten Zugang an erster Stelle halten, danach deterministische Metriken. case " $WAN_PORTS " in *" $WAN "*) WAN_PORTS="$WAN $(for dev in $WAN_PORTS; do [ "$dev" = "$WAN" ] || printf '%s ' "$dev"; done)" ;; *) WAN=${WAN_PORTS%% *} ;; esac MESH=${MESH_PORTS%% *}; VLANPORT=${VLAN_PORTS%% *} VLANS=nein; [ -z "$VLAN_PORTS" ] || VLANS=ja GEMEINSAM=nein for dev in $WAN_PORTS; do case " $MESH_PORTS " in *" $dev "*) GEMEINSAM=ja ;; esac; done echo "WAN: ${WAN_PORTS:--}; Mesh: ${MESH_PORTS:--}; Freifunk-LAN: ${LAN_PORTS:--}; VLAN-Paket: ${VLAN_PORTS:--}" echo "IPv4-WAN-Routen erhalten unterschiedliche Metriken ab 100, VLAN 6 ab 500. WAN-Pfadwechsel für fastd werden nach dem Neustart überwacht." WAN_MODE=ifupdown WAN_PROFILE_UUID= ff_save ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_01 ff_run 02 'VIM-Einstellung ergänzen' <<'FF_STAGE_02' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='VIM-Einstellung ergänzen' ff_note "BEGINN: $FF_STEP" if [ "$FF_EDITOR" = vi ]; then tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT [ ! -f /root/.vimrc ] || cat /root/.vimrc > "$tmp" if ! grep -Eq '^[[:space:]]*set[[:space:]]+mouse-=a([[:space:]]|$)' "$tmp"; then printf '\nset mouse-=a\n' >> "$tmp" fi ff_install "$tmp" /root/.vimrc 0644 else echo "nano gewählt; keine VIM-Einstellung erforderlich." fi ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_02 ff_run 03 'Öffentliche SSH-Schlüssel eintragen' <<'FF_STAGE_03' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Öffentliche SSH-Schlüssel eintragen' ff_note "BEGINN: $FF_STEP" echo "Beliebig viele öffentliche Schlüssel nacheinander: je Schlüssel eine vollständige Zeile aus einer .pub-Datei." echo "Keine privaten Schlüssel! Eine leere Eingabe beendet die Erfassung. Bestehende Schlüssel bleiben erhalten." [ ! -L /root/.ssh ] && [ ! -L /root/.ssh/authorized_keys ] || { echo "SSH-Ziel ist ein Symlink; bitte prüfen."; exit 1; } install -d -o root -g root -m 0700 /root/.ssh tmp=$(mktemp);trap 'rm -f -- "$tmp"' EXIT anzahl=0 while :; do read -r -p "$(ff_prompt "Öffentlicher SSH-Schlüssel (leer = fertig): ")" schluessel "$tmp" if ! ssh-keygen -l -f "$tmp" >/dev/null 2>&1; then echo "Kein gültiger öffentlicher Schlüssel. Bitte erneut eingeben."; continue; fi # Vergleich des Schlüsselmaterials ignoriert abweichende Kommentare. blob=$(awk '{print $2}' "$tmp") if awk -v b="$blob" '{for(i=1;i<=NF;i++)if($i==b)found=1} END{exit !found}' /root/.ssh/authorized_keys 2>/dev/null; then echo "Dieser Schlüssel ist bereits vorhanden." else ff_backup /root/.ssh/authorized_keys printf '\n%s\n' "$schluessel" >> /root/.ssh/authorized_keys anzahl=$((anzahl+1)) fi done if [ -f /root/.ssh/authorized_keys ]; then chown root:root /root/.ssh/authorized_keys;chmod 0600 /root/.ssh/authorized_keys;fi ff_note "SSH: $anzahl neue öffentliche Schlüssel ergänzt; bestehende erhalten." ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_03 ff_run 04 'ext-respondd installieren' <<'FF_STAGE_04' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='ext-respondd installieren' ff_note "BEGINN: $FF_STEP" if [ ! -e /opt/ext-respondd ]; then EXT_COMMIT=e6d9e688c76ba948c24b9304131fd1371bf5ab1f git init /opt/ext-respondd git -C /opt/ext-respondd remote add origin https://github.com/ffggrz/ext-respondd GIT_SSL_NO_VERIFY=false git -c http.sslVerify=true -C /opt/ext-respondd fetch --depth 1 https://github.com/ffggrz/ext-respondd "$EXT_COMMIT" git -C /opt/ext-respondd checkout --detach FETCH_HEAD ff_note "Quelle über HTTPS abgerufen: ext-respondd von GitHub; Git-Commit $EXT_COMMIT." elif [ ! -d /opt/ext-respondd/.git ]; then echo "/opt/ext-respondd ist keine Git-Installation; bitte prüfen." >&2 exit 1 else echo "Vorhandene ext-respondd-Installation wird verwendet." fi [ -f /opt/ext-respondd/ext-respondd.service.example ] EXT_COMMIT=e6d9e688c76ba948c24b9304131fd1371bf5ab1f [ "$(git -C /opt/ext-respondd rev-parse HEAD)" = "$EXT_COMMIT" ] || { echo "Vorhandener ext-respondd-Stand weicht vom vorgesehenen Git-Stand ab; nicht überschrieben."; exit 1; } client=lib/respondd_client.py if ! git -C /opt/ext-respondd diff --cached --quiet || ! git -C /opt/ext-respondd diff --quiet -- . ":(exclude)$client"; then echo "ext-respondd: Andere lokale Änderungen an versionierten Dateien erkannt. Bitte prüfen. Keine Datei überschrieben." >&2 exit 1 fi ziel=/opt/ext-respondd/$client if [ -L /opt/ext-respondd/lib ] || [ -L "$ziel" ] || [ ! -f "$ziel" ]; then echo "ext-respondd: $ziel ist keine reguläre Datei am erwarteten Pfad." >&2 exit 1 fi arbeit=$(mktemp -d) trap 'rm -rf -- "$arbeit"' EXIT git -C /opt/ext-respondd show "HEAD:$client" > "$arbeit/original.py" python3 - "$arbeit/original.py" "$arbeit/patched.py" <<'FF_RESPONDD_PATCH' from pathlib import Path import sys original, patched = map(Path, sys.argv[1:]) code = original.read_text() try: anfang = code.index(" def start(self):") ende = code.index(" def buildStruct(self, responseType):", anfang) except ValueError: raise SystemExit("ext-respondd: Der Git-Quelltext entspricht nicht der erwarteten Vorlage.") empfang = """ def openSocket(self, interface, group): sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + bytes(1)) sock.bind(('::', self._config['port'])) self.joinMCAST(sock, group, interface) return sock def start(self): bridge = self._config['bridge'] sockets = [self.openSocket(bridge, self._config['addr'])] lines = lib.helper.callBatctl([self._config['batman'], 'if']) for line in lines: interface, separator, _ = line.partition(':') if separator and interface.strip(): sockets.append(self.openSocket(interface.strip(), 'ff02::2:1001')) while True: readable, _, _ = select.select(sockets, [], []) for sock in readable: msg, sourceAddress = sock.recvfrom(2048) requests = msg.decode('UTF-8').split(' ') if requests[0] == 'GET': response = {request: self.buildStruct(request) for request in requests[1:]} self.sendStruct(sock, sourceAddress, response, True) else: response = self.buildStruct(requests[0]) self.sendStruct(sock, sourceAddress, response, False) """ code = code[:anfang] + empfang + code[ende:] for alt, neu in ( ("import re\n", ""), (" self._sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)\n\n", ""), (" def sendStruct(self, destAddress, responseStruct, withCompression):", " def sendStruct(self, sock, destAddress, responseStruct, withCompression):"), (" self._sock.sendto(responseData, destAddress)", " sock.sendto(responseData, destAddress)"), ): if code.count(alt) != 1: raise SystemExit("ext-respondd: Der Git-Quelltext entspricht nicht der erwarteten Vorlage.") code = code.replace(alt, neu, 1) if "self._sock" in code: raise SystemExit("ext-respondd: Verweise auf den alten Socket gefunden.") compile(code, str(patched), "exec") patched.write_text(code) FF_RESPONDD_PATCH if cmp -s -- "$arbeit/patched.py" "$ziel"; then echo "ext-respondd: Patch für Nachbarnamen bereits vorhanden." elif cmp -s -- "$arbeit/original.py" "$ziel"; then ff_install "$arbeit/patched.py" "$ziel" 0644 echo "ext-respondd: Patch für Nachbarnamen installiert." else echo "ext-respondd: Unbekannte Änderungen in $ziel erkannt. Datei nicht überschrieben." >&2 exit 1 fi ff_note "ext-respondd: Git-Commit $EXT_COMMIT; erwarteter Patch für Nachbarnamen eingerichtet. Keine separate Signaturprüfung." ff_install /opt/ext-respondd/ext-respondd.service.example /etc/systemd/system/ext-respondd.service 0644 systemctl daemon-reload systemctl enable ext-respondd.service ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_04 ff_run 05 'alias.json und config.json für die Netzkarte erzeugen' <<'FF_STAGE_05' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='alias.json und config.json für die Netzkarte erzeugen' ff_note "BEGINN: $FF_STEP" if [ -s /opt/ext-respondd/alias.json ] && [ -s /opt/ext-respondd/config.json ]; then python3 -m json.tool /opt/ext-respondd/alias.json >/dev/null python3 -m json.tool /opt/ext-respondd/config.json >/dev/null ff_yesno json_aendern "Vorhandene ext-respondd-Angaben ändern?" nein if [ "$json_aendern" != ja ]; then ff_note "Vorhandene ext-respondd-JSON-Dateien unverändert übernommen."; exit 0; fi fi python3 <<'FREIFUNK_JSON' import datetime, json, math, pathlib, re, shutil, subprocess, tempfile, os root = pathlib.Path('/opt/ext-respondd') backup = pathlib.Path('/var/backups/freifunk') / (datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')+'-'+str(os.getpid())) tty_in = open('/dev/tty', 'r') tty_out = open('/dev/tty', 'w') class FFColorTerminal: def __init__(self,stream):self.stream=stream def write(self,text): import os enabled=self.stream.isatty() and os.isatty(1) and 'NO_COLOR' not in os.environ and os.environ.get('TERM','dumb')!='dumb' code='1;31' if 'ACHTUNG' in text or 'Ungültig' in text else '1;36' if text.endswith(': ') else '1;33' return self.stream.write(('\033['+code+'m'+text+'\033[0m') if enabled else text) def flush(self):self.stream.flush() tty_out=FFColorTerminal(tty_out) def ask(label, default='', allow_clear=False): suffix = ' ("-" = entfernen)' if allow_clear else '' tty_out.write(label + (f' [{default}]' if default != '' else '') + suffix + ': ') tty_out.flush() line = tty_in.readline() if line == '': raise SystemExit('Eingabe abgebrochen.') value = line.strip() return '' if allow_clear and value == '-' else (value if value else default) def load(name): p = root/name if not p.exists(): return {} try: return json.loads(p.read_text()) except (ValueError, OSError) as exc: raise SystemExit(f'{p}: {exc}; zuerst korrigieren.') def write(name, data): target = root/name if target.is_symlink(): raise SystemExit(f'Symlink bitte manuell prüfen: {target}') payload=json.dumps(data, ensure_ascii=False, indent=2)+'\n' json.loads(payload) if target.exists(): dest=backup/name dest.parent.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(backup,0o700) shutil.copy2(target,dest) print('Sicherung:',dest) fd, temporary=tempfile.mkstemp(dir=root, prefix='.installation-') try: with os.fdopen(fd,'w') as out: out.write(payload) os.chmod(temporary,0o644) os.chown(temporary,0,0) os.replace(temporary,target) finally: if os.path.exists(temporary): os.unlink(temporary) a=load('alias.json') n=a.setdefault('nodeinfo',{}) host=pathlib.Path('/etc/hostname').read_text().strip() while True: hostname=ask('Knotenname',n.get('hostname',host)) if hostname and not any(c.isspace() for c in hostname): break print('Bitte einen Knotennamen ohne Leerzeichen eingeben.') n['hostname']=hostname interfaces=[] for p in pathlib.Path('/sys/class/net').iterdir(): if p.name=='lo': continue mac=(p/'address').read_text().strip().lower() if not re.fullmatch(r'(?:[0-9a-f]{2}:){5}[0-9a-f]{2}',mac) or mac=='00:00:00:00:00:00': continue interfaces.append((0 if (p/'device').exists() else 1,int((p/'ifindex').read_text()),p.name,mac)) interfaces.sort() if interfaces: _,_,iface,mac=interfaces[0] print(f'MAC-Vorschlag von {iface}: {mac}') proposal=mac.replace(':','') else: proposal='' while True: nodeid=ask('Node-ID ohne Doppelpunkte',n.get('node_id',proposal)).lower().replace(':','') if re.fullmatch(r'[0-9a-f]{12}',nodeid): break print('Die Node-ID muss aus 12 hexadezimalen Zeichen bestehen.') n['node_id']=nodeid hardware=[] for path in ['/sys/class/dmi/id/sys_vendor','/sys/class/dmi/id/product_name']: p=pathlib.Path(path) if p.is_file(): value=p.read_text().strip() if value and value not in hardware: hardware.append(value) model=' '.join(hardware) if not model: r=subprocess.run(['hostnamectl'],text=True,capture_output=True) for line in r.stdout.splitlines(): if 'Hardware Model:' in line: model=line.split('Hardware Model:',1)[1].strip() if not model: p=pathlib.Path('/proc/device-tree/model') if p.is_file(): model=p.read_bytes().decode(errors='replace').strip('\0\n ') n.setdefault('hardware',{})['model']=ask('Hardwaremodell',n.get('hardware',{}).get('model',model or 'unbekannt')) owner=n.setdefault('owner',{}) contact=ask('Kontakt: E-Mail-Adresse oder Telefonnummer (Enter = keine Kontaktdaten; - = entfernen)') if contact and contact!='-': owner['contact']=contact else: owner.pop('contact',None) if not owner: n.pop('owner',None) location=n.get('location',{}) def coordinate(key,label,low,high): while True: # Ohne Eingabe bewusst keine Koordinate übernehmen. existing=location.get(key) if existing is not None: print(f'Bisheriger {label}: {existing}') raw=ask(label+' (leer = nicht angeben)') if not raw: return None try: value=float(raw.replace(',','.')) except ValueError: print('Bitte eine Zahl eingeben.'); continue if math.isfinite(value) and low<=value<=high: return value print(f'Erlaubt: {low} bis {high}.') lat=coordinate('latitude','Breitengrad / latitude',-90,90) lon=coordinate('longitude','Längengrad / longitude',-180,180) if (lat is None)!=(lon is None): raise SystemExit('Bitte beide Koordinaten angeben oder beide leer lassen. Noch nichts gespeichert.') if lat is None: n.pop('location',None) else: location.update(latitude=lat,longitude=lon) n['location']=location n.setdefault('system',{}).update(site_code='ffhal',role='node') n.setdefault('pages',['https://freifunk-halle.org','https://map.freifunk-halle.org']) while True: first=ask('firstseen: Datum yyyy-mm-dd',a.get('firstseen',datetime.date.today().isoformat())) try: if datetime.date.fromisoformat(first).isoformat()!=first: raise ValueError() break except ValueError: print('Bitte yyyy-mm-dd verwenden.') a['firstseen']=first c=load('config.json') c.update({'batman':'bat0','bridge':'br0','mesh-vpn':['vpn3'],'fastd_socket':'/var/run/fastd.vpn3.socket','rate_limit':30,'rate_limit_burst':10}) print('\nVorschau alias.json:\n'+json.dumps(a,ensure_ascii=False,indent=2)) print('\nVorschau config.json:\n'+json.dumps(c,ensure_ascii=False,indent=2)) if ask('Beide Dateien speichern? ja/nein','ja').lower() not in ('ja','j'): raise SystemExit('Nicht gespeichert.') write('alias.json',a) write('config.json',c) print('Beide JSON-Dateien erstellt und auf gültiges JSON geprüft.') FREIFUNK_JSON ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_05 ff_run 01b 'Automatische IPv6 anzeigen und feste Adressen abfragen' <<'FF_STAGE_01B' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Automatische IPv6 anzeigen und feste Adressen abfragen' ethports=() for device in /sys/class/net/*; do [ -e "$device/device" ] && [ ! -d "$device/wireless" ] && ethports+=("${device##*/}") done python3 <<'FF_IPV6_PREVIEW' import json,pathlib,ipaddress,re node=json.loads(pathlib.Path('/opt/ext-respondd/alias.json').read_text())['nodeinfo']['node_id'] if not isinstance(node,str) or not re.fullmatch(r'[0-9a-fA-F]{12}',node):raise SystemExit('Ungültige Node-ID für automatische IPv6.') def addr(mac): raw=bytes.fromhex(mac.replace(':','')) if len(raw)!=6 or raw[0]&1 or raw==bytes(6):raise SystemExit('Ungültige MAC für automatische IPv6.') iid=bytes([raw[0]^2])+raw[1:3]+b'\xff\xfe'+raw[3:] return str(ipaddress.IPv6Address(int(ipaddress.IPv6Address('fd0f:8db3:d50a::'))+int.from_bytes(iid,'big')))+'/64' print('\nAutomatische IPv6 für br0 aus der Node-ID: '+addr(node)) bridge=pathlib.Path('/sys/class/net/br0/address') if bridge.exists():print('Automatische IPv6 aus der aktuellen Bridge-MAC: '+addr(bridge.read_text().strip())) else:print('Weitere automatische IPv6 aus der Bridge-MAC: wird beim Anlegen von br0 ermittelt.') print('Beide Adressen werden automatisch eingerichtet. Eine zusätzliche reservierte IPv6 bleibt optional. Gleiche Adressen werden nur einmal angelegt.') FF_IPV6_PREVIEW printf '\n=== ADRESSEN EINGEBEN: br0-IPv4, br0-IPv6, danach VLAN 7 ===\n' echo 'Diese drei Eingaben sind optional. Ohne Reservierung jeweils Enter drücken.' echo 'Vorhandene Adressen bleiben bei leerer Eingabe erhalten.' export VLAN7_IP BR0_IPV4 BR0_IPV6 printf '\n=== VORHANDENE FESTE ADRESSEN PRÜFEN ===\n' python3 <<'FF_BESTANDSADRESSEN' """Nur Vorschau: bestehende feste Adressen prüfen, Entscheidungen sammeln.""" import os,re,json,pathlib,ipaddress,hashlib wans=os.environ.get('WAN_PORTS','').split() meshes=os.environ.get('MESH_PORTS','').split() vports=os.environ.get('VLAN_PORTS','').split() base=pathlib.Path(os.environ.get('FF_TEST_ROOT','/')) session=pathlib.Path(os.environ['FF_SESSION']) r=open('/dev/tty','r');w=open('/dev/tty','w',buffering=1) class FFColorTerminal: def __init__(self,stream):self.stream=stream def write(self,text): import os enabled=self.stream.isatty() and os.isatty(1) and 'NO_COLOR' not in os.environ and os.environ.get('TERM','dumb')!='dumb' code='1;31' if 'ACHTUNG' in text or 'Ungültig' in text else '1;36' if text.endswith(': ') else '1;33' return self.stream.write(('\033['+code+'m'+text+'\033[0m') if enabled else text) def flush(self):self.stream.flush() w=FFColorTerminal(w) def ask(prompt): w.write(prompt+': ');w.flush();v=r.readline();w.write('\n') if not v:raise SystemExit('Keine Entscheidung eingegeben; vorhandene Adressen nicht geändert.') return v.strip() def check(value,key): if '%' in value:raise ValueError('Zone nicht zulässig') prefix=24 if key=='VLAN7_IP' else 16 if key=='BR0_IPV4' else 64 a=ipaddress.ip_interface(value if '/' in value else value+'/'+str(prefix)) if key in ('VLAN7_IP','BR0_IPV4'): net=ipaddress.ip_network('192.168.62.0/24' if key=='VLAN7_IP' else '10.62.0.0/16') if a.version!=4 or a.ip not in net or a.ip in (net.network_address,net.broadcast_address) or a.network.prefixlen!=prefix:raise ValueError('Adresse oder Präfix außerhalb der Vorgabe') else: net=ipaddress.ip_network('fd0f:8db3:d50a::/112') if a.version!=6 or a.ip not in net or a.ip==net.network_address or a.network.prefixlen!=64:raise ValueError('Feste IPv6-Adresse oder Präfix außerhalb der Vorgabe') return str(a) # MAC-abgeleitete EUI-64-Adressen sind keine frei vergebenen festen Adressen. derived=set() for p in (base/'sys/class/net').glob('*/address'): try: octets=bytes.fromhex(p.read_text().strip().replace(':','')) if len(octets)!=6:continue iid=bytes([octets[0]^2])+octets[1:3]+b'\xff\xfe'+octets[3:] derived.add(ipaddress.IPv6Address(int(ipaddress.IPv6Address('fd0f:8db3:d50a::'))+int.from_bytes(iid,'big'))) except (ValueError,OSError):pass def generated(value,key): if key!='BR0_IPV6':return False try:return ipaddress.ip_interface(value).ip in derived except ValueError:return False choices=[];cache={} def decide(label,value,key): try:check(value,key);return None except ValueError:pass if generated(value,key):return None # Match same address in installation.conf and interface files with one explicit decision. try:normalized=str(ipaddress.ip_interface(value)) except ValueError:normalized=value identity=(key,normalized) if identity in cache:return cache[identity] w.write('\nBESTEHENDE ANGABE AUSSERHALB DER VORGABE\n'+label+'\nBisher: '+value+'\n') w.write('1. Unverändert beibehalten\n2. Durch eine reservierte, gültige Adresse ersetzen\n') while True: v=ask('Entscheidung 1 oder 2 (keine automatische Auswahl)') if v in ('1','2'):break w.write('Bitte ausdrücklich 1 oder 2 auswählen. Ohne Entscheidung keine Änderung.\n') result={'action':'keep','old':value,'key':key,'label':label} if v=='2': w.write('ACHTUNG: Nur eine für diesen Offloader reservierte Adresse eingeben!\n') while True: new=ask('Neue reservierte Adresse (br0 IPv4 /16, br0 IPv6 /64, VLAN 7 /24)') try:result.update(action='replace',new=check(new,key));break except ValueError:w.write('Ungültig. Erlaubt: br0 10.62.0.0/16 bzw. fd0f:8db3:d50a::1 bis ::ffff; VLAN 7 192.168.62.0/24. Netz-/Broadcastadressen sind nicht zulässig.\n') cache[identity]=result;choices.append(result) return result values={k:os.environ.get(k,'') for k in ('BR0_IPV4','BR0_IPV6','VLAN7_IP')};keep=[] for key,value in values.items(): if not value:continue d=decide('Gespeicherte Installer-Auswahl '+key,value,key) if d: if d['action']=='replace':values[key]=d['new'] else:keep.append(key) files=[] root=base/'etc/network/interfaces';folder=base/'etc/network/interfaces.d' paths=([root] if root.exists() else [])+sorted(p for p in folder.glob('*') if p.is_file()) for p in paths: original=p.read_text();lines=original.splitlines(keepends=True);newlines=list(lines) # Stanza boundaries preserve comments/hooks without rewriting unrelated settings. starts=[i for i,line in enumerate(lines) if re.match(r'^\s*(?:iface|auto|allow-\S+|mapping|source|source-directory)\s+',line)]+[len(lines)] masks={};mask_keep=set() for a,b in zip(starts,starts[1:]): m=re.match(r'^\s*iface\s+(\S+)\s+(inet6?)\s+\S+',lines[a]) if not m:continue name,family=m.groups() if name=='br0':key='BR0_IPV6' if family=='inet6' else 'BR0_IPV4' elif re.fullmatch(r'.+\.7',name) and family=='inet':key='VLAN7_IP' else:continue maskrow=next((i for i in range(a+1,b) if re.match(r'^\s*netmask\s+',lines[i])),None) mask=lines[maskrow].split()[1] if maskrow is not None else ('64' if family=='inet6' else '32') for i in range(a+1,b): primary=re.match(r'^(\s*address\s+)(\S+)(.*?)(\n?)$',lines[i]) hook=re.match(r'^(\s*(?:post-up|up)\s+ip\s+(?:-[46]\s+)?(?:address|addr|a)\s+(?:add|replace)\s+)(\S+)(\s+dev\s+br0\b.*?)(\n?)$',lines[i]) if name=='br0' else None match=primary or hook if not match:continue raw=match[2];value=raw if '/' in raw or hook else raw+'/'+mask address_key=('BR0_IPV6' if ':' in raw else 'BR0_IPV4') if hook else key d=decide(str(p)+':'+str(i+1)+' ('+name+')',value,address_key) if not d:continue if d['action']=='keep': if primary and '/' not in raw and maskrow is not None:mask_keep.add(maskrow) continue value=d['new'] if primary and maskrow is not None: aobj=ipaddress.ip_interface(value);value=str(aobj.ip) mask_new=str(aobj.netmask) if aobj.version==4 else str(aobj.network.prefixlen) masks[maskrow]=mask_new newlines[i]=match[1]+value+match[3]+match[4] if mask_keep & masks.keys():raise SystemExit('Widersprüchliche Entscheidungen bei gemeinsam verwendeter Netzmaske in '+str(p)+'; keine Netzwerkdatei geändert.') for i,mask in masks.items():newlines[i]=re.sub(r'^(\s*netmask\s+)\S+',lambda m:m[1]+mask,newlines[i]) changed=''.join(newlines) if changed!=original:files.append({'path':'/'+str(p.relative_to(base)),'sha256':hashlib.sha256(original.encode()).hexdigest(),'text':changed}) # A replacement of a saved optional address also replaces matching configured address above. review={'files':files,'decisions':choices,'values':values,'keep_fields':keep} p=session/'address-review.json';p.write_text(json.dumps(review,ensure_ascii=False,indent=2)+'\n');p.chmod(0o600) with (session/'ereignisse.log').open('a') as out: from datetime import datetime from zoneinfo import ZoneInfo for d in choices: text=('ABWEICHUNG BEWUSST BEIBEHALTEN: '+d['old']) if d['action']=='keep' else ('ADRESSÄNDERUNG GEPLANT: '+d['old']+' -> '+d['new']) out.write(datetime.now(ZoneInfo('Europe/Berlin')).isoformat(timespec='seconds')+'\t'+d['label']+': '+text+'\n') w.write('\nBestandsprüfung abgeschlossen. Änderungen sind nur vorgemerkt; Netzwerkdateien werden erst nach der Planbestätigung geschrieben.\n') FF_BESTANDSADRESSEN while IFS='=' read -r key value; do printf -v "$key" '%s' "$value"; done < <(python3 - <<'FF_BESTAND_LADEN' import json,os,pathlib v=json.loads((pathlib.Path(os.environ['FF_SESSION'])/'address-review.json').read_text()) for k,value in v['values'].items():print(k+'='+value) print('KEEP_INVALID_FIELDS='+','.join(v['keep_fields'])) FF_BESTAND_LADEN ) export VLAN7_IP BR0_IPV4 BR0_IPV6 KEEP_INVALID_FIELDS python3 <<'FF_OPTIONALE_ADRESSEN' import ipaddress,os,pathlib,json,re r=open('/dev/tty','r');w=open('/dev/tty','w',buffering=1) class FFColorTerminal: def __init__(self,stream):self.stream=stream def write(self,text): import os enabled=self.stream.isatty() and os.isatty(1) and 'NO_COLOR' not in os.environ and os.environ.get('TERM','dumb')!='dumb' code='1;31' if 'ACHTUNG' in text or 'Ungültig' in text else '1;36' if text.endswith(': ') else '1;33' return self.stream.write(('\033['+code+'m'+text+'\033[0m') if enabled else text) def flush(self):self.stream.flush() w=FFColorTerminal(w) def ask(prompt): w.write(prompt+': ');w.flush();s=r.readline();w.write('\n') if not s:raise SystemExit('Terminaleingabe beendet.') return s.strip() def optional(key,family,network=None): w.write('\n'+('VLAN 7 für Management einrichten?' if key=='VLAN7_IP' else 'Optionale feste IPv'+str(family)+'-Adresse für br0')+'\n') if network: w.write('\nACHTUNG: Hier darf ausschließlich eine für diesen Offloader reservierte\nIPv4-Adresse aus dem Bereich 192.168.62.0/24 eingetragen werden!\nOhne bestehende Reservierung keine Adresse eingeben.\n\n') else: w.write('ACHTUNG: Hier darf ausschließlich eine für diesen Offloader reservierte IPv'+str(family)+'-Adresse eingetragen werden!\nOhne bestehende Reservierung keine Adresse eingeben.\n') if key=='BR0_IPV4':w.write('Zulässiger Bereich: 10.62.0.0/16; nur Hostadressen, Vorgabe /16.\n') if key=='BR0_IPV6':w.write('Zulässige feste Adresse: fd0f:8db3:d50a::xxxx; xxxx von 1 bis ffff, Vorgabe /64. Auch nur xxxx ist erlaubt, z. B. 151 für fd0f:8db3:d50a::151. Die Kurzform ist hexadezimal.\n') if key in os.environ.get('KEEP_INVALID_FIELDS','').split(','): w.write('Diese gespeicherte Abweichung wurde ausdrücklich beibehalten; keine erneute zusätzliche Eingabe.\n') return os.environ.get(key,'') if os.environ.get(key):w.write('Bisherige zusätzliche Angabe bleibt erhalten: '+os.environ[key]+'\n') while True: existing=os.environ.get(key,'') hint=('Enter = '+existing+' beibehalten') if existing else ('leer = '+('VLAN 7 nicht anlegen' if network else 'keine zusätzliche Adresse')) s=ask(('Reservierte IPv' if network else 'br0 – reservierte IPv')+str(family)+'-Adresse ['+hint+']') if not s:return os.environ.get(key,'') try: if key=='BR0_IPV6' and re.fullmatch(r'[0-9a-fA-F]{1,4}(?:/64)?',s): s='fd0f:8db3:d50a::'+s if '%' in s:raise ValueError() obj=ipaddress.ip_interface(s if '/' in s else s+('/24' if network else '/16' if family==4 else '/64')) if obj.version!=family or obj.ip.is_multicast or obj.ip.is_unspecified or obj.ip.is_loopback:raise ValueError() if network or family==4: net=ipaddress.ip_network(network or '10.62.0.0/16') if obj.ip not in net or obj.network.prefixlen!=net.prefixlen or obj.ip in (net.network_address,net.broadcast_address):raise ValueError() else: net=ipaddress.ip_network('fd0f:8db3:d50a::/112') if obj.ip not in net or obj.ip==net.network_address or obj.network.prefixlen!=64:raise ValueError() return str(obj) except ValueError:w.write('Ungültige Adresse: br0 IPv4 nur 10.62.0.1 bis 10.62.255.254 (/16); br0 IPv6 nur fd0f:8db3:d50a::1 bis ::ffff (/64); VLAN 7 nur 192.168.62.1 bis .254 (/24). Reservierung erforderlich!\n') values={key:optional(key,family,network) for key,family,network in [('BR0_IPV4',4,None),('BR0_IPV6',6,None),('VLAN7_IP',4,'192.168.62.0/24')]} reviewpath=pathlib.Path(os.environ['FF_SESSION'])/'address-review.json' review=json.loads(reviewpath.read_text()) kept=[k for k in review['keep_fields'] if values[k]==review['values'][k]] values['KEEP_INVALID_FIELDS']=','.join(kept) p=pathlib.Path(os.environ['FF_SESSION'])/'adressen.json';p.write_text(json.dumps(values));p.chmod(0o600) FF_OPTIONALE_ADRESSEN while IFS='=' read -r key value; do printf -v "$key" '%s' "$value"; done < <(python3 - <<'FF_ADRESSEN_LADEN' import json,os,pathlib for k,v in json.loads((pathlib.Path(os.environ['FF_SESSION'])/'adressen.json').read_text()).items():print(k+'='+v) FF_ADRESSEN_LADEN ) if [ -n "$VLAN7_IP" ]; then echo "Management-VLAN 7 wird auf allen Ethernet-Ports eingerichtet: $ETH_PORTS; IP einmal auf br7." fi if [ "$VARIANTE" != a ] && [ "${FF_NETWORK_ONLY:-nein}" != ja ]; then while :; do ff_ask WEBZIEL "Webverzeichnis für index.html und index.php" "$WEBZIEL" [[ "$WEBZIEL" = /* && "$WEBZIEL" != *$'\n'* && "$WEBZIEL" != *$'\r'* ]] && break done fi ff_save printf 'Gespeichert: WAN %s (DHCP/%s), Mesh %s, VLANs %s auf %s.\n' "$WAN" "$WAN_MODE" "$MESH" "$VLANS" "${VLANPORT:--}" ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_01B ff_run 06 'IPv6-Hilfsskript automatisch anlegen' <<'FF_STAGE_06' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='IPv6-Hilfsskript automatisch anlegen' ff_note "BEGINN: $FF_STEP" tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT cat > "$tmp" <<'FREIFUNK_STATICV6' #!/usr/bin/env bash # Deux adresses: MAC de br0 et MAC enregistrée dans nodeinfo.node_id. set -euo pipefail interface=${IFACE:-br0} case "${1:-}" in add|del) ;; *) echo 'Aufruf: staticv6.sh add|del' >&2; exit 1;; esac [[ "$interface" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.:-]{0,14}$ ]] || exit 1 addresses=$(python3 - "$interface" <<'FF_DUAL_IPV6' import ipaddress,json,pathlib,re,sys node=json.loads(pathlib.Path('/opt/ext-respondd/alias.json').read_text())['nodeinfo']['node_id'] if not isinstance(node,str) or not re.fullmatch(r'[0-9a-fA-F]{12}',node):raise SystemExit('Ungültige Node-ID: zwölf Hex-Zeichen erforderlich.') bridge=pathlib.Path('/sys/class/net',sys.argv[1],'address').read_text().strip().replace(':','') def address(mac): raw=bytes.fromhex(mac) if len(raw)!=6 or raw[0]&1 or raw==bytes(6):raise SystemExit('Ungültige MAC für automatische IPv6.') iid=bytes([raw[0]^2])+raw[1:3]+b'\xff\xfe'+raw[3:] return str(ipaddress.IPv6Address(int(ipaddress.IPv6Address('fd0f:8db3:d50a::'))+int.from_bytes(iid,'big')))+'/64' for a in dict.fromkeys([address(bridge),address(node)]):print(a) FF_DUAL_IPV6 ) while IFS= read -r address; do printf 'Automatische IPv6 auf %s: %s\n' "$interface" "$address" if [ "$1" = add ]; then ip -6 address replace "$address" dev "$interface" else if ip -6 -o address show dev "$interface" | awk '{print $4}' | grep -qxF "$address"; then ip -6 address del "$address" dev "$interface" fi fi done <<< "$addresses" FREIFUNK_STATICV6 ff_check "Syntax staticv6.sh" bash -n "$tmp" ff_install "$tmp" /etc/network/staticv6.sh 0755 ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_06 ff_run 07 'Netzwerk und rc.local aus der Portauswahl erzeugen' <<'FF_STAGE_07' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Netzwerk und rc.local aus der Portauswahl erzeugen' ff_note "BEGINN: $FF_STEP" arbeit=$(mktemp -d /tmp/freifunk-netzwerk.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT cat > "$arbeit/erzeugen.py" <<'FREIFUNK_NETZ_PY' import json, os, pathlib, re, sys, ipaddress, hashlib # Erzeugt zunächst nur eine Vorschau im Arbeitsverzeichnis; Installation erfolgt danach. out=pathlib.Path(sys.argv[1]); wan,mesh,vlans,vport,wan_mode=sys.argv[2:7] wans=os.environ.get('WAN_PORTS',wan).split() meshes=os.environ.get('MESH_PORTS',mesh).split() vports=os.environ.get('VLAN_PORTS',vport if vlans=='ja' else '').split() lans=os.environ.get('LAN_PORTS','').split() eths=os.environ.get('ETH_PORTS','').split() base=pathlib.Path(os.environ.get('FF_TEST_ROOT','/')) def system_path(s):return base/s.lstrip('/') # Apply reviewed edits only in the preview. Detect configuration changes since the questions. review_files={} reviewpath=pathlib.Path(os.environ.get('FF_SESSION','/nonexistent'))/'address-review.json' if reviewpath.exists(): for item in json.loads(reviewpath.read_text())['files']: p=system_path(item['path']) if hashlib.sha256(p.read_bytes()).hexdigest()!=item['sha256']:raise SystemExit('Konfiguration seit der Bestandsprüfung geändert: '+str(p)+'; bitte erneut starten.') review_files[str(p)]=item['text'] def read_network(p):return review_files[str(p)] if str(p) in review_files else p.read_text() valid=lambda s:bool(re.fullmatch(r'[a-zA-Z0-9_-][a-zA-Z0-9_.:-]{0,14}',s)) if not (wans or vports) or any(not valid(x) or x in ('lo','bat0','br0','fflan') for x in wans+meshes+vports+lans+eths): raise SystemExit('Ungültige WAN-/Mesh-Auswahl.') if vlans not in ('ja','nein') or wan_mode != 'ifupdown': raise SystemExit('Ungültige Auswahl.') if vlans=='ja' and (not valid(vport) or len(vport)>13):raise SystemExit('VLAN-Portname ist ungültig oder zu lang.') if set(lans)&set(wans+meshes):raise SystemExit('Unzulässige Rollen: Freifunk-LAN darf auf demselben Port weder ungetaggtes WAN noch Mesh haben.') if not set(wans+meshes+lans+vports).issubset(set(eths)):raise SystemExit('LAN/VLAN-Port muss ein erkannter Ethernet-Port sein; Auswahl neu durchführen.') vlan_names=[f'{p}.{x}' for p in vports for x in (5,6,8)] managed={'bat0','br0',*lans,*wans,*meshes,*vports,*vlan_names,*('fflan' if i==0 else f'fflan{i}' for i in range(len(vports)+len(lans)))} if vlans=='ja':managed.add(vport);managed.add('fflan') kept=set(os.environ.get('KEEP_INVALID_FIELDS','').split(',')) v7='' if 'VLAN7_IP' in kept else os.environ.get('VLAN7_IP','') extra4='' if 'BR0_IPV4' in kept else os.environ.get('BR0_IPV4','') extra6='' if 'BR0_IPV6' in kept else os.environ.get('BR0_IPV6','') if v7: a=ipaddress.ip_interface(v7) if a.version!=4 or a.ip not in ipaddress.ip_network('192.168.62.0/24') or a.network.prefixlen!=24 or int(str(a.ip).split('.')[-1]) in (0,255):raise SystemExit('Ungültige reservierte Managementadresse.') if not eths or any(not valid(p) or len(p)>13 for p in eths):raise SystemExit('Keine gültigen Ethernet-Ports für Management-VLAN 7; Auswahl neu durchführen.') managed.update(eths);managed.update(p+'.7' for p in eths);managed.add('br7') for value,version in [(extra4,4),(extra6,6)]: if value and ipaddress.ip_interface(value).version!=version:raise SystemExit('Adressfamilie stimmt nicht.') rootfile=system_path('/etc/network/interfaces') folder=system_path('/etc/network/interfaces.d') paths=([rootfile] if rootfile.exists() else [])+([p for p in sorted(folder.iterdir()) if p.is_file()] if folder.exists() else []) # Prüfen statt fremde feste Einstellungen auf neu gewählten Ports zu überschreiben. owned=folder/'freifunk' oldroles=set(os.environ.get('PREV_LAN_PORTS','').split()) | set((os.environ.get('PREV_WAN_PORTS',os.environ.get('PREV_WAN',''))+' '+os.environ.get('PREV_MESH_PORTS',os.environ.get('PREV_MESH',''))).split()) for p in paths: text=read_network(p) for m in re.finditer(r'(?m)^\s*iface\s+(\S+)\s+inet6?\s+(\S+)[^\n]*\n(?:(?!\s*(?:auto|allow-\S+|iface|mapping|source|source-directory)\s).*(?:\n|$))*',text): name,method=m[1],m[2] if name in set(wans+meshes+vports+lans+eths) and method=='static':raise SystemExit('Konflikt: feste Adresse auf gewähltem Port '+name+' in '+str(p)+'; nicht überschrieben.') if name in managed and name not in ('br0','bat0',wan,mesh) and p!=owned: raise SystemExit('Konflikt: '+name+' ist bereits außerhalb der Installer-Datei konfiguriert: '+str(p)) # Rollen aus einer vorigen Installer-Datei dürfen bei Neuzuordnung entfernt werden. oldroles={name for name in oldroles if not (system_path('/sys/class/net')/name/'phy80211').exists() and not (system_path('/sys/class/net')/name/'wireless').exists()} cleanup=managed|oldroles for old in os.environ.get('PREV_VLAN_PORTS',os.environ.get('PREV_VLANPORT','')).split(): cleanup.update(old+'.'+str(x) for x in (5,6,8)); cleanup.add(old) # Alle von diesem Installer erzeugten fflan-Macvlans bei Rollenwechsel erneuern. if owned.exists(): if re.search(r'(?m)^iface br7 inet ',read_network(owned)): cleanup.add('br7');cleanup.update(re.findall(r'(?m)^iface (\S+\.7) inet ',read_network(owned))) if owned.exists(): cleanup.update(re.findall(r'(?m)^iface (fflan\d*) inet ',read_network(owned))) oldport=os.environ.get('PREV_VLANPORT','') if oldport and os.environ.get('PREV_VLANS')=='ja':cleanup.update(oldport+'.'+str(x) for x in (5,6,8));cleanup.add(oldport);cleanup.add('fflan') if oldport and os.environ.get('PREV_VLAN7_IP') and v7:cleanup.add(oldport+'.7') # Unabhängige VLANs/Ports der eigenen Datei erhalten (z.B. VLAN 7 mit festen Adressen). retained_owned='' if owned.exists(): def keep_unmanaged(text): result=[];drop=False for line in text.splitlines(keepends=True): m=re.match(r'^\s*iface\s+(\S+)\s+',line) if m:drop=m[1] in cleanup elif re.match(r'^\s*(?:auto|allow-\S+|source|source-directory)\s+',line):drop=False if drop:continue m=re.match(r'^(\s*(?:auto|allow-\S+))\s+([^#\n]+)(.*)$',line.rstrip('\n')) if m: names=[n for n in m[2].split() if n not in cleanup] if names:result.append(m[1]+' '+' '.join(names)+m[3]+'\n') elif not line.startswith('# Freifunk-Offloader:') and not line.startswith('# WAN:'):result.append(line) return ''.join(result) retained_owned=keep_unmanaged(read_network(owned)) # Alte br0-Adressdaten und bestehende BATMAN-MTU erhalten. mtu='1380'; bridge=[] for p in paths: text=read_network(p) for match in re.finditer(r'(?m)^\s*iface\s+br0\s+inet6?\s+\S+[^\n]*\n(?:(?!\s*(?:auto|allow-\S+|iface|mapping|source|source-directory)\s).*(?:\n|$))*',text): stanza=match.group(0).strip('\n') bridge.append(stanza) m=re.search(r'\bmtu\s+(\d+)',stanza) # Die Installer-eigene Bridge-MTU korrigieren; fremde Stanzas sonst erhalten. if p==owned: bridge[-1]=re.sub(r'(\bmtu\s+)\d+',r'\g<1>1380',bridge[-1]) if bridge: families=re.findall(r'(?m)^\s*iface\s+br0\s+(inet6?)\s+', '\n'.join(bridge)) if len(families)!=len(set(families)):raise SystemExit('br0 ist mehrfach je Adressfamilie definiert. Bitte zuerst bereinigen.') def clean(text): result=[];drop=False for line in text.splitlines(keepends=True): m=re.match(r'^\s*iface\s+(\S+)\s+',line) if m:drop=m.group(1) in managed elif re.match(r'^\s*(?:auto|allow-\S+|mapping|source|source-directory)\s+',line):drop=False if drop:continue m=re.match(r'^(\s*(?:auto|allow-\S+))\s+([^#\n]+)(.*)$',line.rstrip('\n')) if m: names=[name for name in m.group(2).split() if name not in managed] if names:result.append(m.group(1)+' '+' '.join(names)+m.group(3)+'\n') else:result.append(line) return ''.join(result) changes=[] for n,p in enumerate(paths): if p==folder/'freifunk':continue text=read_network(p);new=clean(text) if p==rootfile: # Gleiche Verzeichnisse nicht zweimal einlesen. if re.search(r'(?m)^\s*source\s+/etc/network/interfaces.d/\*\s*$',new): new=re.sub(r'(?m)^\s*source-directory\s+/etc/network/interfaces.d/?\s*\n','',new) if not re.search(r'(?m)^\s*(?:source\s+/etc/network/interfaces.d/(?:\*|freifunk)|source-directory\s+/etc/network/interfaces.d/?)\s*$',new): new+='\nsource /etc/network/interfaces.d/freifunk\n' if new!=p.read_text(): filename=f'existing-{n}';(out/filename).write_text(new) changes.append({'source':filename,'target':'/'+str(p.relative_to(base)),'mode':'0644'}) if not rootfile.exists(): (out/'interfaces-main').write_text('auto lo\niface lo inet loopback\n\nsource /etc/network/interfaces.d/freifunk\n') changes.append({'source':'interfaces-main','target':'/etc/network/interfaces','mode':'0644'}) parts=[f'''# Freifunk-Offloader: aus der gespeicherten Portauswahl erzeugt. # WAN: {wan}; Mesh: {mesh}; VLANs: {vlans} auto bat0 iface bat0 inet manual pre-up sleep 5 pre-up modprobe batman-adv pre-up sleep 5 up ip link set dev bat0 mtu {mtu} up ip link set dev bat0 up '''] if bridge: b='\n\n'.join(bridge)+'\n' if 'bridge_ports' not in b: b=b.replace('\n', '\n bridge_ports bat0\n',1) else: def addbat(m): ports=m[1].split() return ' bridge_ports '+' '.join(ports if 'bat0' in ports else ['bat0']+ports) b=re.sub(r'(?m)^\s*bridge_ports\s+([^\n]+)$',addbat,b) else: b=f"""iface br0 inet manual bridge_ports bat0 bridge_stp off bridge_fd 0 bridge_maxwait 0 pre-up sleep 5 up ip link set dev br0 mtu {mtu} """ # Add hooks only to first br0 stanza; remove duplicate managed hooks before inserting. b=re.sub(r'(?m)^\s*(?:post-up|pre-down)\s+/etc/network/staticv6.sh\s+(?:add|del)\s*\n','',b) hooks=' post-up /etc/network/staticv6.sh add\n pre-down /etc/network/staticv6.sh del\n' for value in (extra4,extra6): if value: obj=ipaddress.ip_interface(value) line=f' post-up ip address replace {obj} dev br0\n' if line.strip() not in b:hooks+=line b=b.replace('\n','\n'+hooks,1) parts.append('auto br0\n'+b) physical=list(dict.fromkeys(wans+meshes+vports+lans+(eths if v7 else []))) for iface in physical: method='dhcp' if iface in wans else 'manual' portmtu=1560 if iface in meshes or iface in vports else 1500 hooks=f' pre-up ip link set dev {iface} mtu {portmtu}\n up ip link set dev {iface} up\n' if iface in wans: hooks+=f' metric {100+wans.index(iface)*10}\n' if iface in meshes:hooks+=f' post-up batctl if add {iface}\n' parts.append(f'auto {iface}\niface {iface} inet {method}\n'+hooks) for vi,vlanport in enumerate(vports): for vid,method,portmtu in [(5,'manual',1560),(6,'dhcp',1500),(8,'manual',1500)]: iface=f'{vlanport}.{vid}' hooks=f' vlan-raw-device {vlanport}\n pre-up ip link set dev {vlanport} up\n post-up ip link set dev {iface} mtu {portmtu}\n post-up ip link set dev {iface} up\n' if vid==6: hooks+=f' metric {500+vi*10}\n' if vid==5:hooks+=f' post-up batctl if add {iface}\n' parts.append(f'auto {iface}\niface {iface} inet {method}\n'+hooks) lan='fflan' if vi==0 else f'fflan{vi}' parts.append(f'''auto {lan} iface {lan} inet manual pre-up test -d /sys/class/net/{lan} || ip link add name {lan} link {vlanport}.8 type macvlan mode passthru post-down ip link delete dev {lan} post-up brctl addif br0 {lan} post-up ip link set dev {lan} mtu 1500 post-up ip link set dev {lan} up ''') for li,lanport in enumerate(lans,start=len(vports)): lan='fflan' if li==0 else f'fflan{li}' parts.append(f'auto {lan}\niface {lan} inet manual\n pre-up test -d /sys/class/net/{lan} || ip link add name {lan} link {lanport} type macvlan mode passthru\n post-down ip link delete dev {lan}\n post-up brctl addif br0 {lan}\n post-up ip link set dev {lan} mtu 1500\n post-up ip link set dev {lan} up\n') if v7: for port in eths: parts.append(f'auto {port}.7\niface {port}.7 inet manual\n vlan-raw-device {port}\n pre-up ip link set dev {port} up\n post-up ip link set dev {port}.7 mtu 1500\n') parts.append(f"auto br7\niface br7 inet static\n address {v7}\n bridge_ports {' '.join(p+'.7' for p in eths)}\n bridge_stp on\n bridge_fd 4\n bridge_maxwait 0\n up ip link set dev br7 mtu 1500\n") if retained_owned.strip():parts.append('# Erhaltene zusätzliche Konfiguration\n'+retained_owned) (out/'freifunk').write_text('\n'.join(parts)) changes.append({'source':'freifunk','target':'/etc/network/interfaces.d/freifunk','mode':'0644'}) # Bestehende NM-Ausnahmen werden nicht ersetzt: eigene device-Abschnitte je Interface. managed.update(re.findall(r'(?m)^\s*iface\s+(\S+)\s+',retained_owned)) nm_names=sorted(name for name in managed if not (system_path('/sys/class/net')/name/'phy80211').exists() and not (system_path('/sys/class/net')/name/'wireless').exists()) nm='\n'.join(f'[device-freifunk-{n}]\nmatch-device=interface-name:{name}\nmanaged=0\n' for n,name in enumerate(nm_names)) previous_nm=system_path('/etc/NetworkManager/conf.d/90-freifunk-offloader.conf') if previous_nm.exists(): for section in re.split(r'(?m)(?=^\[)',previous_nm.read_text()): match=re.search(r'(?m)^match-device=interface-name:([^\n]+)',section) if match: device=system_path('/sys/class/net')/match[1].strip() if (device/'phy80211').exists() or (device/'wireless').exists(): section=re.sub(r'^\[[^\]]+\]', '[device-preserved-wireless-'+match[1].strip()+']',section) nm+='\n'+section (out/'nm.conf').write_text(nm) # Bestehende fremde rc.local-Befehle behalten; nur bekannte Freifunk-Zeilen ersetzen. rcfile=system_path('/etc/rc.local') rc=rcfile.read_text() if rcfile.exists() else '#!/bin/bash\n' rc=re.sub(r'(?ms)^# FREIFUNK-OFFLOADER BEGIN\n.*?^# FREIFUNK-OFFLOADER END\n?','',rc) retained=[] for line in rc.splitlines(): stripped=line.strip() if stripped in ('#!/bin/bash','#!/bin/sh','#!/bin/sh -e','exit 0'):continue if re.fullmatch(r'systemctl\s+(?:restart\s+networking(?:\.service)?|daemon-reload|(?:enable|restart)\s+ext-respondd(?:\.service)?)',stripped):continue if stripped=='ifdown -a && ifup -a':continue m=re.match(r'batctl\s+(?:-m\s+bat0\s+)?if\s+add\s+(\S+)',stripped) if m and m.group(1) in managed:continue # Alte Wartungs-IP aus früheren Installationen entfernen, auch bei einem Portwechsel. m=re.fullmatch(r'ip\s+(?:address|addr)\s+(?:add|replace)\s+192\.168\.1\.100/24\s+dev\s+(\S+)',stripped) if m and m.group(1) in cleanup:continue retained.append(line) rc='#!/bin/bash\n'+'\n'.join(retained).strip()+'\n\n' rc+='''# FREIFUNK-OFFLOADER BEGIN # Bestehender Netzwerk-Workaround. systemctl restart networking sleep 5 ifdown -a && ifup -a sleep 5 batman_port_vorhanden() { batctl if | awk -v port="$1" '$1 == port ":" { gefunden=1 } END { exit !gefunden }' } mesh_port_hinzufuegen() { if ! batman_port_vorhanden "$1"; then batctl if add "$1" fi } ''' for port in meshes: rc+=f'mesh_port_hinzufuegen {port}\n' for port in vports: rc+=f'mesh_port_hinzufuegen {port}.5\n' rc+=''' # ext-respondd erfasst die Mesh-Interfaces bei seinem Start. for ((versuch = 0; versuch < 15; versuch++)); do if batman_port_vorhanden vpn3; then break fi sleep 1 done if ! batman_port_vorhanden vpn3; then echo 'Freifunk: vpn3 fehlt in BATMAN. ext-respondd startet mit den vorhandenen Schnittstellen.' fi systemctl restart ext-respondd.service || exit 1 # FREIFUNK-OFFLOADER END exit 0 ''' (out/'rc.local').write_text(rc) changes.append({'source':'rc.local','target':'/etc/rc.local','mode':'0755'}) (out/'mesh-link-ports').write_text(json.dumps({ 'wan': sorted(set(wans+[p+'.6' for p in vports])), 'dhcp': { **{p: 100+i*10 for i,p in enumerate(wans) }, **{p+'.6': 500+i*10 for i,p in enumerate(vports)}, }, 'carrier': sorted(set(meshes+vports)), 'batman': sorted(set(meshes+[p+'.5' for p in vports]+['vpn3'])), })+'\n') for name,target in ( ('mesh-link-ports','/etc/freifunk/mesh-link-ports.json'), ('mesh-link-watch.py','/usr/local/lib/freifunk-mesh-link-watch.py'), ('mesh-link-watch.service','/etc/systemd/system/freifunk-mesh-link-watch.service'), ('wan-recovery.py','/usr/local/lib/freifunk-wan-recovery.py'), ('wan-recovery.service','/etc/systemd/system/freifunk-wan-recovery.service'), ('wan-recovery.timer','/etc/systemd/system/freifunk-wan-recovery.timer'), ('wan-dhcp.py','/usr/local/lib/freifunk-wan-dhcp.py'), ('wan-dhcp.service','/etc/systemd/system/freifunk-wan-dhcp@.service'), ('dhcp-erneuern','/usr/local/sbin/freifunk-dhcp-erneuern'), ): changes.append({'source':name,'target':target,'mode':'0755' if name=='dhcp-erneuern' else '0644'}) (out/'plan.json').write_text(json.dumps(changes,indent=2)) print(f'Vorbereitet: WAN {wan} (DHCP über {wan_mode}), Mesh {mesh}.') if vlans=='ja':print(f'VLANs 5 (Mesh), 6 (DHCP), 8 (fflan → br0) auf {vport}.') print('Keine laufende Netzwerkschnittstelle verändert.') FREIFUNK_NETZ_PY python3 "$arbeit/erzeugen.py" "$arbeit" "$WAN" "$MESH" "$VLANS" "$VLANPORT" "$WAN_MODE" cat > "$arbeit/mesh-link-watch.py" <<'FF_MESH_WATCH_PY' import json import os import re import selectors import subprocess import time from pathlib import Path IP = '/usr/sbin/ip' CONFIG = Path('/etc/freifunk/mesh-link-ports.json') LINK = re.compile(rb'^(Deleted )?(\d+): ([^:@ ]+)(?:@[^: ]+)?: <([^>]*)>(.*)') def link_state(line, carrier, batman): match = LINK.match(line) if not match: return None deleted, index, name, flags, details = match.groups() name = name.decode('ascii', errors='replace') if name not in carrier and name not in batman: return None if deleted: return name, None return name, ( int(index), b'LOWER_UP' in flags.split(b',') if name in carrier else None, bool(re.search(rb'(?:^| )master bat0(?: |$)', details)) if name in batman else None, ) def main(): config = json.loads(CONFIG.read_text()) mesh_carrier = set(config['carrier']) dhcp_ports = set(config.get('dhcp', {})) carrier = mesh_carrier | dhcp_ports batman = set(config['batman']) with subprocess.Popen(['/usr/bin/stdbuf', '-oL', IP, '-o', 'monitor', 'link'], stdout=subprocess.PIPE) as monitor, selectors.DefaultSelector() as selector: try: selector.register(monitor.stdout, selectors.EVENT_READ) initial = subprocess.check_output([IP, '-o', 'link', 'show']) states = dict(item for line in initial.splitlines() if (item := link_state(line, carrier, batman)) is not None) pending = set() deadline = None buffer = b'' while True: timeout = None if deadline is None else max(0, deadline - time.monotonic()) if selector.select(timeout): data = os.read(monitor.stdout.fileno(), 65536) if not data: raise SystemExit('Mesh-Linküberwachung: ip monitor link wurde beendet.') lines = (buffer + data).split(b'\n') buffer = lines.pop() for line in lines: item = link_state(line, carrier, batman) if item is None: continue name, state = item previous = states.get(name) if previous == state: continue states[name] = state if (name in dhcp_ports and state and state[1] and (not previous or previous[:2] != state[:2])): print('WAN-Link aktiv (' + name + '); DHCP wird neu angefragt.', flush=True) unit = subprocess.check_output( ['/usr/bin/systemd-escape', '--template=freifunk-wan-dhcp@.service', name], text=True).strip() subprocess.run(['/usr/bin/systemctl', '--no-block', 'restart', unit], check=True) if name in mesh_carrier or name in batman: pending.add(name) deadline = time.monotonic() + 3 if deadline is not None and time.monotonic() >= deadline: print('Mesh-Link geändert (' + ', '.join(sorted(pending)) + '); ext-respondd wird neu gestartet.', flush=True) subprocess.run(['/usr/bin/systemctl', '--no-block', 'restart', 'ext-respondd.service'], check=True) pending.clear() deadline = None finally: monitor.terminate() if __name__ == '__main__': main() FF_MESH_WATCH_PY cat > "$arbeit/mesh-link-watch.service" <<'FF_MESH_WATCH_UNIT' [Unit] Description=Freifunk-Mesh-Links für ext-respondd überwachen After=rc-local.service ext-respondd.service [Service] ExecStart=/usr/bin/python3 /usr/local/lib/freifunk-mesh-link-watch.py Restart=on-failure RestartSec=3s [Install] WantedBy=multi-user.target FF_MESH_WATCH_UNIT ff_check "Python-Syntax der Mesh-Linküberwachung" python3 -m py_compile "$arbeit/mesh-link-watch.py" cat > "$arbeit/wan-recovery.py" <<'FF_WAN_RECOVERY_PY' import ipaddress import json import re import socket import subprocess import time from pathlib import Path CONFIG = Path('/etc/freifunk/mesh-link-ports.json') PEER_CONFIG = Path('/etc/fastd/freifunk/server.conf') STATE = Path('/run/freifunk-wan-recovery.json') SYS_NET = Path('/sys/class/net') STATUS_SOCKET = '/run/fastd.vpn3.socket' GRACE_SECONDS = 30 CHECK_SECONDS = 150 def gateway_addresses(remotes): addresses = set() for family, host in remotes: database = {'ipv4': 'ahostsv4', 'ipv6': 'ahostsv6'}.get(family, 'ahosts') try: result = subprocess.run(['/usr/bin/getent', database, host], timeout=3, text=True, capture_output=True) except subprocess.TimeoutExpired: continue for line in result.stdout.splitlines(): if not line.strip(): continue try: addresses.add(ipaddress.ip_address(line.split()[0])) except ValueError: continue return addresses def wan_paths(ports, addresses): paths = set() for address in addresses: try: output = subprocess.check_output( ['/usr/sbin/ip', '-'+str(address.version), '-j', 'route', 'get', str(address)], stderr=subprocess.DEVNULL, timeout=2) for route in json.loads(output): dev = route.get('dev') source = route.get('prefsrc') or route.get('src') if dev in ports and source and (SYS_NET/dev/'carrier').read_text().strip() == '1': paths.add((address.version, dev, source, route.get('gateway', ''))) except (subprocess.SubprocessError, OSError, ValueError): continue return [list(path) for path in sorted(paths)] def peer_connections(): try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.settimeout(2) client.connect(STATUS_SOCKET) with client.makefile('rb') as stream: status = json.loads(stream.read(1024*1024)) if status.get('interface') != 'vpn3': return None peers = status.get('peers') if not isinstance(peers, dict) or not peers: return None return [peer['connection'] for peer in peers.values() if isinstance(peer['connection'], dict)] except (OSError, ValueError, AttributeError, KeyError, TypeError): return None def observe(state, paths, now, connections): if paths != state['paths']: state.update(paths=paths, since=now if paths else None, restarted=False) if paths: print('WAN-Pfad zur VPN-Gegenstelle geändert: '+', '.join(sorted({p[1] for p in paths}))) since = state['since'] if since is None: return False elapsed = now-since if connections and any(isinstance(c.get('established'), (int, float)) and 0 <= c['established'] <= elapsed*1000 for c in connections): print('VPN-Mesh über vpn3 wieder verbunden.') state['since'] = None elif elapsed >= CHECK_SECONDS: if not connections: print('WAN-Wechsel: VPN-Verbindung konnte nicht bestätigt werden.') state['since'] = None elif elapsed >= GRACE_SECONDS and connections == [] and not state['restarted']: state['restarted'] = True return True return False def main(): ports = json.loads(CONFIG.read_text())['wan'] if not PEER_CONFIG.is_file(): return remotes = re.findall(r'^\s*remote\s+(?:(ipv4|ipv6)\s+)?"([a-zA-Z0-9.-]+)"\s+port\s+\d+\s*;', PEER_CONFIG.read_text(), re.M) if not remotes: return paths = wan_paths(ports, gateway_addresses(remotes)) config = [ports, [list(remote) for remote in remotes]] try: state = json.loads(STATE.read_text()) except (OSError, ValueError): state = {} restart = False if state.get('config') != config: state = {'config': config, 'paths': paths, 'since': None, 'restarted': False} else: restart = observe(state, paths, time.monotonic(), peer_connections() if paths and (state['since'] is not None or paths != state['paths']) else None) temporary = STATE.with_suffix('.tmp') temporary.write_text(json.dumps(state)) temporary.replace(STATE) if restart: print('WAN-Route vorhanden, VPN-Peer weiterhin getrennt. fastd wird einmal neu gestartet.', flush=True) subprocess.run(['/usr/bin/systemctl', '--no-block', 'try-restart', 'fastd@freifunk.service'], check=True) if __name__ == '__main__': main() FF_WAN_RECOVERY_PY cat > "$arbeit/wan-recovery.service" <<'FF_WAN_RECOVERY_UNIT' [Unit] Description=Freifunk-fastd nach Wechsel des WAN-Pfads prüfen After=rc-local.service fastd@freifunk.service [Service] Type=oneshot ExecStart=/usr/bin/python3 /usr/local/lib/freifunk-wan-recovery.py TimeoutStartSec=25s FF_WAN_RECOVERY_UNIT cat > "$arbeit/wan-recovery.timer" <<'FF_WAN_RECOVERY_TIMER' [Unit] Description=Freifunk-WAN-Pfad zu vpn3 überwachen [Timer] OnBootSec=15s OnUnitInactiveSec=10s AccuracySec=1s [Install] WantedBy=timers.target FF_WAN_RECOVERY_TIMER ff_check "Python-Syntax der WAN-Wiederanbindung" python3 -m py_compile "$arbeit/wan-recovery.py" cat > "$arbeit/wan-dhcp.py" <<'FF_WAN_DHCP_PY' import json import os import re import subprocess import sys import time from pathlib import Path CONFIG = Path('/etc/freifunk/mesh-link-ports.json') SYS_NET = Path('/sys/class/net') PROC = Path('/proc') DHCLIENT = '/usr/sbin/dhclient' def clients(port): found = set() for proc in PROC.iterdir(): if not proc.name.isdecimal(): continue try: if (proc/'exe').resolve(strict=True).name != 'dhclient': continue args = (proc/'cmdline').read_bytes().split(b'\0') except OSError: continue if port.encode() in args[1:] and b'-6' not in args: found.add(int(proc.name)) return found def has_carrier(port): try: return (SYS_NET/port/'carrier').read_text().strip() == '1' except OSError: return False def main(port): metrics = json.loads(CONFIG.read_text())['dhcp'] if not re.fullmatch(r'[a-zA-Z0-9_-][a-zA-Z0-9_.:-]{0,14}', port) or port not in metrics: raise SystemExit('DHCP: keine konfigurierte ifupdown-WAN-Schnittstelle.') if not has_carrier(port): return pidfile = Path('/run')/f'dhclient.{port}.pid' leases = Path('/var/lib/dhcp')/f'dhclient.{port}.leases' running = clients(port) if running: try: managed_pid = int(pidfile.read_text()) except (OSError, ValueError): managed_pid = None if running != {managed_pid}: raise SystemExit(f'DHCP: anderer Client auf {port}; kein zusätzlicher Client gestartet.') subprocess.run([DHCLIENT, '-4', '-x', '-pf', str(pidfile), port], check=True, timeout=5) for _ in range(20): if not clients(port): break time.sleep(.1) else: raise SystemExit(f'DHCP: bisheriger Client auf {port} läuft noch.') if not has_carrier(port): return leases.parent.mkdir(mode=0o755, parents=True, exist_ok=True) print(f'DHCPv4 wird auf {port} neu angefragt (Metrik {metrics[port]}).', flush=True) os.execv(DHCLIENT, [DHCLIENT, '-4', '-d', '-pf', str(pidfile), '-lf', str(leases), '-I', '-df', str(leases.with_name(f'dhclient6.{port}.leases')), '-e', f'IF_METRIC={metrics[port]}', port]) if __name__ == '__main__': main(sys.argv[1]) FF_WAN_DHCP_PY cat > "$arbeit/wan-dhcp.service" <<'FF_WAN_DHCP_UNIT' [Unit] Description=Freifunk DHCPv4 auf WAN-Schnittstelle %I After=networking.service [Service] Type=exec ExecStart=/usr/bin/python3 /usr/local/lib/freifunk-wan-dhcp.py %I TimeoutStopSec=5s FF_WAN_DHCP_UNIT cat > "$arbeit/dhcp-erneuern" <<'FF_DHCP_HELPER' #!/bin/bash # Optionaler periodischer Neuversuch nutzt denselben DHCP-Dienst wie der Linkwechsel. set -euo pipefail source /etc/freifunk/installation.conf [ "${DHCP_EXTRA:-nein}" = ja ] || exit 0 [[ "${DHCP_INTERVAL:-}" =~ ^[0-9]+$ ]] || exit 1 (( DHCP_INTERVAL >= 1 && DHCP_INTERVAL <= 1440 )) || exit 1 exec 9>/run/freifunk-dhcp-extra.lock flock -n 9 || exit 0 now=$(date +%s) last=0 if [ -f /run/freifunk-dhcp-extra.last ]; then read -r last < /run/freifunk-dhcp-extra.last; fi [[ "$last" =~ ^[0-9]+$ ]] || last=0 (( now < last || now/(DHCP_INTERVAL*60) != last/(DHCP_INTERVAL*60) )) || exit 0 printf '%s\n' "$now" > /run/freifunk-dhcp-extra.last ports=$(python3 -c 'import json; print("\n".join(json.load(open("/etc/freifunk/mesh-link-ports.json"))["dhcp"]))') while IFS= read -r port; do [ -n "$port" ] || continue carrier=$(cat "/sys/class/net/$port/carrier" 2>/dev/null) || continue [ "$carrier" = 1 ] || continue unit=$(systemd-escape --template=freifunk-wan-dhcp@.service "$port") systemctl --no-block restart "$unit" done <<< "$ports" FF_DHCP_HELPER ff_check "Python-Syntax für DHCP bei WAN-Linkwechsel" python3 -m py_compile "$arbeit/wan-dhcp.py" ff_check "Syntax des periodischen DHCP-Aufrufs" bash -n "$arbeit/dhcp-erneuern" ff_check "Syntax rc.local" bash -n "$arbeit/rc.local" ff_check "ifupdown-Konfiguration lesbar" ifquery --interfaces "$arbeit/freifunk" --list # Zuerst alle Zielpfade prüfen, bevor die erste Datei ersetzt wird. python3 - "$arbeit/plan.json" <<'FREIFUNK_NETZ_PFADE' import json,pathlib,sys for item in json.loads(pathlib.Path(sys.argv[1]).read_text()): if pathlib.Path(item['target']).is_symlink():raise SystemExit('Zieldatei ist ein Symlink: '+item['target']) FREIFUNK_NETZ_PFADE python3 - "$arbeit/plan.json" > "$arbeit/plan.tsv" <<'FREIFUNK_PLAN' import json,pathlib,sys for item in json.loads(pathlib.Path(sys.argv[1]).read_text()): print(item['source'],item['target'],item['mode'],sep='\t') FREIFUNK_PLAN echo "Geplante Netzwerkdateien (nur Vorschau):" cat "$arbeit/freifunk" python3 - "$arbeit/plan.json" <<'FF_PLAN_ANZEIGEN' import json,pathlib,sys for p in json.loads(pathlib.Path(sys.argv[1]).read_text()):print('Schreiben:',p['target']) FF_PLAN_ANZEIGEN echo "Ethernet-/Bridge-Dateien werden erst beim Neustart aktiviert." ff_yesno freigabe "Diese Netzwerkzuordnung schreiben?" ja if [ "$freigabe" != ja ]; then echo "Netzwerkdateien nicht geändert; Installation angehalten."; exit 1; fi while IFS=$'\t' read -r quelle ziel modus; do ff_install "$arbeit/$quelle" "$ziel" "$modus" done < "$arbeit/plan.tsv" if command -v nmcli >/dev/null; then ff_install "$arbeit/nm.conf" /etc/NetworkManager/conf.d/90-freifunk-offloader.conf 0644 fi systemctl daemon-reload systemctl enable networking.service freifunk-mesh-link-watch.service freifunk-wan-recovery.timer # rc.local wird von systemd beim Booten berücksichtigt, weil es ausführbar ist. if [ "$(systemctl show -p LoadState --value rc-local.service)" = not-found ]; then echo "rc-local.service fehlt; der bisherige Workaround benötigt diesen Dienst." >&2 exit 1 fi ff_note "NETZWERK vorbereitet: WAN-DHCP, Mesh und ggf. VLAN 5/6/8; rc.local startet ext-respondd nach der Mesh-Portzuordnung erneut. Mesh-Linküberwachung und DHCP bei WAN-Linkwechsel werden beim nächsten Systemstart aktiviert." echo "Dateien gesichert und installiert. Das laufende Netzwerk wurde nicht neu gestartet." ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_07 ff_run 08 'IP-Forwarding vorbereiten' <<'FF_STAGE_08' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='IP-Forwarding vorbereiten' ff_note "BEGINN: $FF_STEP" tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT printf 'net.ipv4.ip_forward = 1\nnet.ipv6.conf.all.forwarding = 1\n' > "$tmp" # Ein ausgefallener Link soll keine bevorzugte WAN-Route blockieren. wan_links=(${WAN_PORTS:-$WAN}) if [ "$VLANS" = ja ]; then for dev in ${VLAN_PORTS:-$VLANPORT}; do wan_links+=("$dev.6"); done fi for dev in "${wan_links[@]}"; do printf 'net/ipv4/conf/%s/ignore_routes_with_linkdown = 1\n' "$dev" >> "$tmp" printf 'net/ipv6/conf/%s/ignore_routes_with_linkdown = 1\n' "$dev" >> "$tmp" done metric=100 for dev in ${WAN_PORTS:-$WAN}; do printf 'net/ipv6/conf/%s/accept_ra = 2\n' "$dev" >> "$tmp"; printf 'net/ipv6/conf/%s/ra_defrtr_metric = %s\n' "$dev" "$metric" >> "$tmp"; metric=$((metric+10)); done if [ "$VLANS" = ja ]; then metric=500; for dev in ${VLAN_PORTS:-$VLANPORT}; do printf 'net/ipv6/conf/%s.6/accept_ra = 2\n' "$dev" >> "$tmp"; printf 'net/ipv6/conf/%s.6/ra_defrtr_metric = %s\n' "$dev" "$metric" >> "$tmp"; metric=$((metric+10)); done; fi ff_install "$tmp" /etc/sysctl.d/90-freifunk-offloader.conf 0644 echo "Forwarding-Datei erstellt; Übernahme beim nächsten Neustart." ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_08 ff_run 09 'fastd konfigurieren und Schlüssel erhalten' <<'FF_STAGE_09' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='fastd konfigurieren und Schlüssel erhalten' ff_note "BEGINN: $FF_STEP" if [ -s /etc/fastd/freifunk/fastd.conf ]; then fastd --verify-config --config /etc/fastd/freifunk/fastd.conf echo "Vorhandene fastd-Konfiguration und Schlüssel werden beibehalten." ff_note "Vorhandene fastd-Konfiguration geprüft und unverändert erhalten." systemctl daemon-reload systemctl enable fastd@freifunk.service exit 0 fi for fremd in /etc/fastd/*/fastd.conf; do [ -f "$fremd" ] || continue [ "$fremd" = /etc/fastd/freifunk/fastd.conf ] || { echo "Weitere fastd-Konfiguration gefunden: $fremd. Bitte zuerst die Instanzen prüfen." >&2; exit 1; } done install -d -o root -g root -m 0700 /etc/fastd/freifunk arbeit=$(mktemp -d /tmp/freifunk-fastd.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT chmod 0700 "$arbeit" if [ -s /etc/fastd/freifunk/fastd.conf ]; then python3 - /etc/fastd/freifunk/fastd.conf "$arbeit/secret" <<'FREIFUNK_SECRET' import pathlib,re,sys text=pathlib.Path(sys.argv[1]).read_text() m=re.search(r'^\s*secret\s+"([0-9a-fA-F]{64})"\s*;',text,re.M) if not m: raise SystemExit('Vorhandene fastd.conf enthält keinen lesbaren privaten Schlüssel; nicht überschrieben.') p=pathlib.Path(sys.argv[2]);p.write_text(m.group(1));p.chmod(0o600) FREIFUNK_SECRET elif [ -s /etc/fastd/freifunk/key ]; then python3 - /etc/fastd/freifunk/key "$arbeit/secret" <<'FREIFUNK_SECRET' import pathlib,re,sys text=pathlib.Path(sys.argv[1]).read_text() m=re.search(r'Secret\s*:\s*([0-9a-fA-F]{64})',text,re.I) if not m: raise SystemExit('Vorhandene key-Datei nicht erkannt. Bitte prüfen; kein neuer Schlüssel erzeugt.') p=pathlib.Path(sys.argv[2]);p.write_text(m.group(1));p.chmod(0o600) FREIFUNK_SECRET else fastd --generate-key > "$arbeit/keypair" python3 - "$arbeit/keypair" "$arbeit/secret" <<'FREIFUNK_SECRET' import pathlib,re,sys m=re.search(r'Secret\s*:\s*([0-9a-fA-F]{64})',pathlib.Path(sys.argv[1]).read_text(),re.I) if not m: raise SystemExit('fastd-Schlüsselausgabe nicht erkannt.') p=pathlib.Path(sys.argv[2]);p.write_text(m.group(1));p.chmod(0o600) FREIFUNK_SECRET fi cat > "$arbeit/server.conf" <<'FREIFUNK_PEER' key "c21abf04f9ee3a1906607cf4c8e734addaa8bba8eeb3d405be76248082b0b748"; remote "vpn3.freifunk-halle.org" port 10000; FREIFUNK_PEER cat > "$arbeit/fastd.conf" <<'FREIFUNK_FASTD' log level warn; interface "vpn3"; method "null"; include peer "server.conf"; secret "FREIFUNK_PRIVATE_KEY"; mtu 1426; mode tap; on up sync " ip link set up dev $INTERFACE batctl -m bat0 if add $INTERFACE "; on down sync " ip link set down dev $INTERFACE "; status socket "/var/run/fastd.vpn3.socket"; FREIFUNK_FASTD python3 - "$arbeit" <<'FREIFUNK_SECRET' import pathlib,sys p=pathlib.Path(sys.argv[1]);f=p/'fastd.conf' f.write_text(f.read_text().replace('FREIFUNK_PRIVATE_KEY',(p/'secret').read_text())) f.chmod(0o600) FREIFUNK_SECRET (cd "$arbeit" && fastd --verify-config --config fastd.conf) # Die systemd-Vorlage ist paketabhängig: vorhandene Vorlage bevorzugen. if [ "$(systemctl show -p LoadState --value fastd@freifunk.service)" = not-found ]; then cat > "$arbeit/fastd.service" <<'FREIFUNK_DIENST' [Unit] Description=Freifunk fastd VPN (freifunk) Wants=network-online.target After=network-online.target networking.service [Service] Type=simple WorkingDirectory=/etc/fastd/freifunk ExecStart=/usr/bin/fastd --config /etc/fastd/freifunk/fastd.conf Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target FREIFUNK_DIENST fastd_pfad=$(command -v fastd) sed -i "s|/usr/bin/fastd --config|$fastd_pfad --config|" "$arbeit/fastd.service" ff_install "$arbeit/fastd.service" /etc/systemd/system/fastd@freifunk.service 0644 fi ff_install "$arbeit/server.conf" /etc/fastd/freifunk/server.conf 0600 ff_install "$arbeit/fastd.conf" /etc/fastd/freifunk/fastd.conf 0600 install -d -m 0755 /etc/systemd/system/fastd@freifunk.service.d printf '[Unit]\nWants=network-online.target\nAfter=network-online.target networking.service\n' > "$arbeit/order.conf" ff_install "$arbeit/order.conf" /etc/systemd/system/fastd@freifunk.service.d/90-freifunk-order.conf 0644 # Kein paralleler SysV-Autostart derselben Konfiguration. if [ -f /etc/default/fastd ]; then python3 - /etc/default/fastd "$arbeit/default" <<'FREIFUNK_AUTOSTART' import pathlib,re,sys s=pathlib.Path(sys.argv[1]).read_text() s=re.sub(r'(?m)^\s*AUTOSTART\s*=.*$', 'AUTOSTART="none"',s) if not re.search(r'(?m)^AUTOSTART=',s):s+='\nAUTOSTART="none"\n' pathlib.Path(sys.argv[2]).write_text(s) FREIFUNK_AUTOSTART ff_install "$arbeit/default" /etc/default/fastd 0644 fi systemctl daemon-reload systemctl enable fastd@freifunk.service printf '\nDiesen öffentlichen Schlüssel registrieren:\n' (cd /etc/fastd/freifunk && fastd --config fastd.conf --show-key --machine-readable) echo "Danach: https://freifunk-halle.org/fastd/" ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_09 ff_run 09v 'VPN-Interfaces automatisch bei vnStat aufnehmen' <<'FF_STAGE_09V' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh ff_web arbeit=$(mktemp -d /tmp/freifunk-vnstat.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT cat > "$arbeit/register" <<'FF_VNSTAT_HELPER' #!/bin/bash set -euo pipefail exec 9>/run/freifunk-vnstat.lock flock -n 9 || exit 0 command -v vnstat >/dev/null changed=nein for interface in vpn3 tap0; do [ -d "/sys/class/net/$interface" ] || continue if ! vnstat -i "$interface" --json >/dev/null 2>&1; then vnstat --add -i "$interface" changed=ja logger -t freifunk-vnstat "Interface $interface in vnStat aufgenommen." fi done if [ "$changed" = ja ]; then systemctl restart vnstat.service; fi FF_VNSTAT_HELPER cat > "$arbeit/service" <<'FF_VNSTAT_UNIT' [Unit] Description=Freifunk VPN-Interfaces in vnStat aufnehmen After=vnstat.service fastd@freifunk.service Wants=vnstat.service [Service] Type=oneshot ExecStart=/usr/local/sbin/freifunk-vnstat-registrieren FF_VNSTAT_UNIT cat > "$arbeit/timer" <<'FF_VNSTAT_TIMER' [Unit] Description=Neue Freifunk VPN-Interfaces automatisch erfassen [Timer] OnBootSec=30s OnUnitActiveSec=1min Unit=freifunk-vnstat-registrieren.service [Install] WantedBy=timers.target FF_VNSTAT_TIMER ff_install "$arbeit/register" /usr/local/sbin/freifunk-vnstat-registrieren 0755 ff_install "$arbeit/service" /etc/systemd/system/freifunk-vnstat-registrieren.service 0644 ff_install "$arbeit/timer" /etc/systemd/system/freifunk-vnstat-registrieren.timer 0644 systemctl daemon-reload systemctl enable --now vnstat.service freifunk-vnstat-registrieren.timer systemctl start freifunk-vnstat-registrieren.service ff_note "vnStat: vpn3 und optional tap0 werden bei vorhandenem Interface automatisch aufgenommen; fehlende Interfaces werden per Timer nachgeholt. Bestehende Daten bleiben erhalten." FREIFUNK_SCHRITT FF_STAGE_09V ff_run 10 'Webseite und Datensammler installieren (ab b)' <<'FF_STAGE_10' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Webseite und Datensammler installieren (ab b)' ff_note "BEGINN: $FF_STEP" ff_web [[ "$WEBZIEL" = /* && "$WEBZIEL" != *$'\n'* && "$WEBZIEL" != *$'\r'* ]] || { echo "Gespeichertes Webverzeichnis ist ungültig; bitte die gemeinsame Auswahl erneut ausführen." >&2; exit 1; } printf 'Verwende gespeichertes Webverzeichnis: %s\n' "$WEBZIEL" arbeit=$(mktemp -d /tmp/offloader-installation.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT curl --proto '=https' --proto-redir '=https' --fail --show-error --location https://freifunk-halle.org/offloader/html/offloaderwebseite.tar --output "$arbeit/offloaderwebseite.tar" WEB_SHA256=$(sha256sum "$arbeit/offloaderwebseite.tar" | cut -d' ' -f1) ff_note "Quelle über HTTPS abgerufen: offloaderwebseite.tar; SHA-256 zur Dokumentation berechnet: $WEB_SHA256. Keine separate Signaturprüfung." # Nur die erwarteten regulären Dateien und das zugehörige Verzeichnis zulassen. python3 - "$arbeit/offloaderwebseite.tar" <<'FREIFUNK_ARCHIV' import sys,tarfile,pathlib expected={'offloaderwebseite/index.html','offloaderwebseite/index.php','offloaderwebseite/datensammler.php'} seen=set();found=set() with tarfile.open(sys.argv[1]) as archive: for m in archive.getmembers(): name=m.name.removeprefix('./').rstrip('/') parts=pathlib.PurePosixPath(name).parts if not parts or '..' in parts or name.startswith('/') or name in seen:raise SystemExit('Ungültiger oder doppelter Archiveintrag: '+m.name) seen.add(name) if name=='offloaderwebseite' and m.isdir():continue if name in expected and m.isfile():found.add(name);continue if (name=='offloaderwebseite/vnstat' or name.startswith('offloaderwebseite/vnstat/')) and (m.isfile() or m.isdir()):continue raise SystemExit('Unerwarteter Archiveintrag oder Link: '+m.name) if found!=expected:raise SystemExit('Das Archiv enthält nicht alle drei erwarteten Hauptdateien.') FREIFUNK_ARCHIV ff_note "Tar-Struktur geprüft: drei erforderliche Hauptdateien und optional vnstat-Verzeichnis; nur reguläre Dateien/Verzeichnisse, keine Links oder Pfadwechsel." tar -xf "$arbeit/offloaderwebseite.tar" -C "$arbeit" quelle="$arbeit/offloaderwebseite" php -l "$quelle/datensammler.php" php -l "$quelle/index.php" python3 "$FF_SESSION/rollback_state.py" track-web "$WEBZIEL" mkdir -p -- "$WEBZIEL" install -d -o root -g root -m 0755 /opt/freifunk install -d -o root -g www-data -m 0750 /var/lib/freifunk/daten ff_install "$quelle/datensammler.php" /opt/freifunk/datensammler.php 0644 ff_install "$quelle/index.html" "$WEBZIEL/index.html" 0644 ff_install "$quelle/index.php" "$WEBZIEL/index.php" 0644 if [ -d "$quelle/vnstat" ]; then python3 - "$quelle/vnstat" "$WEBZIEL/vnstat" <<'FF_VNSTAT_WEB_CHECK' import pathlib,sys,os source,target=map(pathlib.Path,sys.argv[1:]) for item in [target,*[target/p.relative_to(source) for p in source.rglob('*')]]: if item.is_symlink() or any(p.is_symlink() for p in item.parents):raise SystemExit('Symlink im Webziel: '+str(item)) origin=source/item.relative_to(target) if item.exists() and origin.is_dir()!=item.is_dir():raise SystemExit('Datei-/Verzeichniskonflikt: '+str(item)) FF_VNSTAT_WEB_CHECK ff_backup "$WEBZIEL/vnstat" python3 - "$quelle/vnstat" "$WEBZIEL/vnstat" <<'FF_VNSTAT_WEB_COPY' import pathlib,shutil,sys,os source,target=map(pathlib.Path,sys.argv[1:]) for directory,dirs,files in os.walk(source): dest=target/pathlib.Path(directory).relative_to(source) dest.mkdir(parents=True,exist_ok=True);dest.chmod(0o755);os.chown(dest,0,0) for name in files: out=dest/name;shutil.copyfile(pathlib.Path(directory)/name,out);out.chmod(0o644);os.chown(out,0,0) FF_VNSTAT_WEB_COPY ff_note "VNSTAT-WEB: gesamter Archivordner nach $WEBZIEL/vnstat kopiert; vorhandene gleichnamige Dateien gesichert und überschrieben." else ff_note "HINWEIS: Archiv enthält noch keinen vnstat-Ordner; Hauptdateien installiert." fi ff_save printf 'Installiert: /opt/freifunk/datensammler.php und %s/index.html sowie index.php\n' "$WEBZIEL" ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_10 ff_run 11 'Apache-HTTPS und PHP vorbereiten (ab b)' <<'FF_STAGE_11' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Apache-HTTPS und PHP vorbereiten (ab b)' ff_note "BEGINN: $FF_STEP" ff_web if [ ! -s /etc/ssl/certs/ssl-cert-snakeoil.pem ] || [ ! -s /etc/ssl/private/ssl-cert-snakeoil.key ]; then command -v make-ssl-cert >/dev/null || { echo "Bitte zuerst apt install -y ssl-cert ausführen." >&2; exit 1; } make-ssl-cert generate-default-snakeoil fi a2enmod ssl a2ensite default-ssl # Bereits vereinbarte PHP-Einstellung auf alle vorhandenen Apache-PHP-Versionen anwenden. tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT printf '; Freifunk: Upload-Progress deaktiviert\nsession.upload_progress.enabled = Off\n' > "$tmp" for pfad in /etc/php/*/apache2/conf.d; do [ -d "$pfad" ] || continue ff_install "$tmp" "$pfad/99-upload-progress-off.ini" 0644 done apache2ctl configtest systemctl enable apache2.service if systemctl is-active --quiet apache2; then systemctl reload apache2 else systemctl start apache2 fi ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_11 ff_run 12 'Webserver-Zugriff auf batctl einrichten (ab b)' <<'FF_STAGE_12' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Webserver-Zugriff auf batctl einrichten (ab b)' ff_note "BEGINN: $FF_STEP" ff_web command -v visudo >/dev/null [ -x /usr/sbin/batctl ] || { echo "batctl unter /usr/sbin/batctl fehlt." >&2; exit 1; } tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT cat > "$tmp" <<'FREIFUNK_SUDOERS' Defaults:www-data !requiretty www-data ALL=(root) NOPASSWD: /usr/sbin/batctl n, /usr/sbin/batctl gwl, /usr/sbin/batctl o, /usr/sbin/batctl tg, /usr/sbin/batctl -v, /usr/sbin/batctl if FREIFUNK_SUDOERS visudo -cf "$tmp" install -d -m 0755 /etc/sudoers.d ff_install "$tmp" /etc/sudoers.d/freifunk-status 0440 visudo -c ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_12 ff_run 13 'Anmeldung für geschützte Statusseiten einrichten (ab b)' <<'FF_STAGE_13' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Anmeldung für geschützte Statusseiten einrichten (ab b)' ff_note "BEGINN: $FF_STEP" ff_web while :; do read -r -p "$(ff_prompt "Benutzername für die Statusseite (leer = überspringen): ")" benutzer &2; exit 1; } ff_backup /etc/freifunk-status.htpasswd if [ -e /etc/freifunk-status.htpasswd ]; then htpasswd -B /etc/freifunk-status.htpasswd "$benutzer" /dev/null; then tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT cat > "$tmp" <<'FREIFUNK_UPDATES' APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; Unattended-Upgrade::Automatic-Reboot "false"; FREIFUNK_UPDATES ff_install "$tmp" /etc/apt/apt.conf.d/52offloader-security 0644 systemctl enable --now apt-daily.timer apt-daily-upgrade.timer echo "Probelauf für die vorhandenen Upgrade-Ursprünge:" unattended-upgrade --dry-run --debug else echo "unattended-upgrades gehört zur Paketliste ab c." fi ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_14 ff_run 15 'Offiziellen Ookla-Speedtest installieren (ab b)' <<'FF_STAGE_15' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Offiziellen Ookla-Speedtest installieren (ab b)' ff_note "BEGINN: $FF_STEP" ff_web if dpkg-query -W -f='${db:Status-Status}' speedtest-cli 2>/dev/null | grep -qx installed; then apt-get remove -y speedtest-cli fi if ! command -v speedtest >/dev/null || ! speedtest --version 2>&1 | grep -qi ookla; then arbeit=$(mktemp -d /tmp/freifunk-ookla.XXXXXX) trap 'rm -rf -- "$arbeit"' EXIT ff_backup /etc/apt/sources.list.d/ookla_speedtest-cli.list curl --proto '=https' --proto-redir '=https' -fSL https://packagecloud.io/ookla/speedtest-cli/gpgkey -o "$arbeit/ookla-key.asc" gpg --batch --with-colons --import-options show-only --import "$arbeit/ookla-key.asc" > "$arbeit/ookla-key-info" actual=$(awk -F: '$1=="pub"{n++;if($2~/[reid]/ || ($7!="" && $7 "$arbeit/ookla.list" ff_install "$arbeit/ookla.list" /etc/apt/sources.list.d/ookla_speedtest-cli.list 0644 apt-get update apt-get install -y speedtest fi version=$(speedtest --version 2>&1) printf '%s\n' "$version" if ! grep -qi ookla <<< "$version"; then ff_note "PRÜFUNG FEHLGESCHLAGEN: speedtest ist nicht das Ookla-Programm." exit 1 fi ff_note "PRÜFUNG OK: offizielles Ookla-speedtest installiert." echo "Beim ersten echten Speedtest werden die Ookla-Lizenz- und Datenschutzbedingungen abgefragt." echo "Die Zustimmung für automatische Messungen wird im folgenden Cron-Schritt ausdrücklich abgefragt." ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_15 ff_run 16 'Cron und zusätzliche DHCP-Abfrage einrichten' <<'FF_STAGE_16' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Cron für den Datensammler einrichten (ab b)' ff_note "BEGINN: $FF_STEP" if [ "$VARIANTE" != a ]; then [ -f /opt/freifunk/datensammler.php ]; fi if [ -s /etc/cron.d/freifunk ]; then echo "Bisherige Installer-Cron-Datei:"; cat /etc/cron.d/freifunk ff_yesno cron_aendern "Diese Cron-Einstellungen neu abfragen und ersetzen?" nein if [ "$cron_aendern" != ja ]; then USV_ENABLED=nein; SPEED_ENABLED=nein; IPERF_ENABLED=nein if grep -Eq '^[^#].*datensammler\.php usv([[:space:]]|$)' /etc/cron.d/freifunk; then USV_ENABLED=ja; fi if grep -Eq '^[^#].*datensammler\.php speed([[:space:]]|$)' /etc/cron.d/freifunk; then SPEED_ENABLED=ja; fi if grep -Eq '^[^#].*datensammler\.php iperf([[:space:]]|$)' /etc/cron.d/freifunk; then IPERF_ENABLED=ja; fi ff_save ff_note "Cron-Datei unverändert übernommen; Sammler-Auswahl daraus übernommen." exit 0 fi fi alt=$(crontab -l 2>/dev/null || true) if printf '%s\n' "$alt" | grep -Ev '^[[:space:]]*(#|$)' | grep -E 'datensammler\.php|check-inet\.py|ups_collector|run_speedtest|run_iperf3|nodes\.json|/sbin/dhclient|/sbin/reboot'; then echo "Diese alten Einträge zuerst entfernen: crontab -e" >&2 echo "Danach diesen Block erneut ausführen." >&2 exit 1 fi usv=nein;speed=nein;iperf=nein if [ "$VARIANTE" != a ]; then ff_yesno usv "USV-/Akku-Erfassung jede Minute aktivieren?" "$USV_ENABLED" ff_yesno speed "Ookla-Speedtest stündlich aktivieren und Ookla-Lizenz-/Datenschutzbedingungen akzeptieren?" "$SPEED_ENABLED" ff_yesno iperf "iperf3 stündlich aktivieren?" "$IPERF_ENABLED" fi ff_yesno neustart "Täglichen Neustart um 05:00 Uhr aktivieren?" ja if [[ "$speed" =~ ^(ja|j)$ ]]; then if ! command -v speedtest >/dev/null || ! speedtest --version 2>&1 | grep -qi ookla; then echo "Zuerst den Ookla-Installationsblock ausführen; danach diesen Cron-Block wiederholen." >&2 ff_note "FEHLER: Cron nicht eingerichtet, weil Ookla-speedtest fehlt. Nach Installation Cron-Block wiederholen." exit 1 fi speedtest --accept-license --accept-gdpr --version fi if [[ "$iperf" =~ ^(ja|j)$ ]] && ! command -v iperf3 >/dev/null; then echo "iperf3 fehlt (Paketliste ab b)." >&2 exit 1 fi tmp=$(mktemp) trap 'rm -f -- "$tmp"' EXIT cat > "$tmp" <<'FREIFUNK_CRON' SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # Vom Freifunk-Installer verwaltet. Einstellungen bei erneutem Lauf bewusst ändern. FREIFUNK_CRON if [ "$VARIANTE" != a ]; then cat >> "$tmp" <<'FREIFUNK_CRON_WEB' */5 * * * * root /usr/bin/php /opt/freifunk/datensammler.php status */5 * * * * root /usr/bin/php /opt/freifunk/datensammler.php internet */5 * * * * root /usr/bin/php /opt/freifunk/datensammler.php nodes */5 * * * * root /usr/bin/php /opt/freifunk/datensammler.php nutzer FREIFUNK_CRON_WEB fi [[ ! "$usv" =~ ^(ja|j)$ ]] || echo '* * * * * root /usr/bin/php /opt/freifunk/datensammler.php usv' >> "$tmp" [[ ! "$speed" =~ ^(ja|j)$ ]] || echo '0 * * * * root /usr/bin/php /opt/freifunk/datensammler.php speed' >> "$tmp" [[ ! "$iperf" =~ ^(ja|j)$ ]] || echo '30 * * * * root /usr/bin/php /opt/freifunk/datensammler.php iperf' >> "$tmp" [[ ! "$neustart" =~ ^(ja|j)$ ]] || echo '0 5 * * * root /usr/sbin/reboot' >> "$tmp" ff_yesno DHCP_EXTRA "Zusätzliche DHCP-Anfrage für problematische LTE-Router aktivieren?" "$DHCP_EXTRA" if [ "$DHCP_EXTRA" = ja ]; then echo "Der Zusatzaufruf startet den ifupdown-DHCP-Client ohne DHCPRELEASE erneut. Eine kurze Verbindungsunterbrechung ist möglich." while :; do ff_ask DHCP_INTERVAL "Intervall in Minuten (1 bis 1440)" "${DHCP_INTERVAL:-5}" [[ "$DHCP_INTERVAL" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$DHCP_INTERVAL" -le 1440 ] && break echo "Bitte eine ganze Zahl von 1 bis 1440 eingeben." done fi USV_ENABLED=$usv; SPEED_ENABLED=$speed; IPERF_ENABLED=$iperf ff_save if [ "$DHCP_EXTRA" = ja ]; then if [ "$DHCP_INTERVAL" -lt 60 ] && [ "$((60 % DHCP_INTERVAL))" -eq 0 ]; then if [ "$DHCP_INTERVAL" -eq 1 ]; then dhcp_schedule='* * * * *'; else dhcp_schedule="*/$DHCP_INTERVAL * * * *"; fi elif [ "$((DHCP_INTERVAL % 60))" -eq 0 ] && [ "$((24 % (DHCP_INTERVAL / 60)))" -eq 0 ]; then hours=$((DHCP_INTERVAL / 60)) if [ "$hours" -eq 24 ]; then dhcp_schedule='0 0 * * *'; elif [ "$hours" -eq 1 ]; then dhcp_schedule='0 * * * *'; else dhcp_schedule="0 */$hours * * *"; fi else dhcp_schedule='* * * * *' echo "# DHCP-Intervall $DHCP_INTERVAL Minuten: Zeitprüfung im Helfer, da nicht exakt durch Cron ausdrückbar." >> "$tmp" fi printf '%s root /usr/local/sbin/freifunk-dhcp-erneuern\n' "$dhcp_schedule" >> "$tmp" ff_note "DHCP-Zusatzabfrage: alle $DHCP_INTERVAL Minuten für WAN (ifupdown) und ggf. VLAN 6. Cron-Zeitplan: $dhcp_schedule; der Helfer verhindert überlappende oder doppelte Ausführungen." fi ff_install "$tmp" /etc/cron.d/freifunk 0644 systemctl enable --now cron.service cat /etc/cron.d/freifunk ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_16 ff_run 17 'Datensammler erstmals ausführen (ab b)' <<'FF_STAGE_17' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Datensammler erstmals ausführen (ab b)' ff_note "BEGINN: $FF_STEP" ff_web aufgaben=(status internet nodes) if [ -d /sys/class/net/bat0/mesh ] && batctl -m bat0 if >/dev/null 2>&1; then aufgaben+=(nutzer) else echo "Nutzererfassung übersprungen: Das Mesh ist noch nicht eingerichtet. Die Erfassung erfolgt nach dem Neustart." ff_note "ÜBERSPRUNGEN: erste Nutzererfassung; bat0 noch nicht bereit. Cron bleibt aktiviert." fi if [ "$USV_ENABLED" = ja ]; then aufgaben+=(usv); fi for aufgabe in "${aufgaben[@]}"; do ff_check "Erste Datensammler-Aufgabe: $aufgabe" /usr/bin/php /opt/freifunk/datensammler.php "$aufgabe" || true done if [ "$SPEED_ENABLED" = ja ]; then ff_yesno speed_jetzt "Speedtest jetzt ausführen?" ja if [ "$speed_jetzt" = ja ]; then ff_check "Erste Speedtest-Messung" /usr/bin/php /opt/freifunk/datensammler.php speed || true; fi fi if [ "$IPERF_ENABLED" = ja ]; then ff_yesno iperf_jetzt "iperf3 jetzt ausführen? Vor der VPN-Freischaltung normalerweise überspringen" nein if [ "$iperf_jetzt" = ja ]; then ff_check "Erste iperf3-Messung" /usr/bin/php /opt/freifunk/datensammler.php iperf || true; fi fi ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_17 ff_run 18 'Abschlussprüfung vor dem Neustart' <<'FF_STAGE_18' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Abschlussprüfung vor dem Neustart' ff_note "BEGINN: $FF_STEP" probleme=0 for datei in /opt/ext-respondd/alias.json /opt/ext-respondd/config.json; do ff_check "JSON gültig: $datei" python3 -m json.tool "$datei" >/dev/null || probleme=$((probleme+1)) done ff_check "fastd-Konfiguration" fastd --verify-config --config /etc/fastd/freifunk/fastd.conf || probleme=$((probleme+1)) for dienst in networking chrony fastd@freifunk ext-respondd; do ff_check "Dienst aktiviert: $dienst" systemctl is-enabled "$dienst" || probleme=$((probleme+1)) done ff_check "rc.local ausführbar" test -x /etc/rc.local || probleme=$((probleme+1)) ff_check "Cron-Datei vorhanden" test -s /etc/cron.d/freifunk || probleme=$((probleme+1)) ff_check "Cron-Dienst läuft" systemctl is-active cron || probleme=$((probleme+1)) if [ "$VARIANTE" != a ]; then ff_check "Apache-Konfiguration" apache2ctl configtest || probleme=$((probleme+1)) ff_check "sudoers-Konfiguration" visudo -c || probleme=$((probleme+1)) ff_check "PHP-Datensammler Syntax" php -l /opt/freifunk/datensammler.php || probleme=$((probleme+1)) ff_check "Cron-Datei vorhanden" test -s /etc/cron.d/freifunk || probleme=$((probleme+1)) ff_check "Cron-Dienst läuft" systemctl is-active cron || probleme=$((probleme+1)) for programm in vnstat duf sensors routel; do ff_check "Diagnoseprogramm vorhanden: $programm" command -v "$programm" || probleme=$((probleme+1)) done fi ff_note "Vorprüfung beendet; $probleme auffällige Prüfungen. Aktivierte Dienste sind noch kein Funktionsnachweis." ff_report ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_18 ff_run 19 'Installationsbericht unter /root speichern' <<'FF_STAGE_19' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Installationsbericht unter /root speichern' ff_note "BEGINN: $FF_STEP" ff_note "ABSCHLUSSBERICHT angefordert; ausgeführte Schritte und Paketbestand zusammengeführt." ff_report ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_19 ff_run 20 'Apache-WAN-Firewall' <<'FF_STAGE_20' bash <<'FREIFUNK_SCHRITT' set -euo pipefail source /usr/local/lib/freifunk-installation.sh FF_STEP='Apache-Zugriff am WAN und Firewall' ff_note "BEGINN: $FF_STEP" ff_web ff_yesno APACHE_WAN "Soll Apache auch am WAN antworten?" nein ports=$(python3 - <<'FF_APACHE_PORTS' import pathlib,re p=pathlib.Path('/etc/apache2/ports.conf');out=set() for line in p.read_text().splitlines() if p.exists() else []: m=re.match(r'\s*Listen\s+(\S+)',line) if m: s=m[1].rsplit(':',1)[-1] if s.isdecimal() and 1<=int(s)<=65535:out.add(int(s)) print(', '.join(map(str,sorted(out or {80,443})))) FF_APACHE_PORTS ) # Do not flush or replace unrelated firewall rules. for service in ufw firewalld; do if systemctl is-active --quiet "$service"; then echo "Aktive Firewall $service erkannt; Integration zuerst prüfen. Keine Regeln ersetzt."; exit 1; fi done nft -j list ruleset > "$FF_SESSION/firewall-existing.json" python3 - "$FF_SESSION/firewall-existing.json" <<'FF_FIREWALL_CONFLICT' import json,sys for x in json.load(open(sys.argv[1])).get('nftables',[]): c=x.get('chain',{}) if c.get('hook')=='input' and c.get('policy')=='drop' and c.get('table')!='freifunk_status': raise SystemExit('Bestehende Input-Drop-Policy erkannt. SSH-Zugriff kann nicht ohne Änderung fremder Regeln garantiert werden; zuerst integrieren.') FF_FIREWALL_CONFLICT arbeit=$(mktemp -d /tmp/freifunk-firewall.XXXXXX);trap 'rm -rf -- "$arbeit"' EXIT cat > "$arbeit/rules" <<'FF_NFT_HEAD' table inet freifunk_status { chain input { type filter hook input priority -5; policy accept; iifname "lo" accept FF_NFT_HEAD # User explicitly requested SSH on all addresses. Existing external restrictions are not silently erased. ssh_ports=$(sshd -T 2>/dev/null | awk '$1=="port"{print $2}' | paste -sd, -) ssh_ports=${ssh_ports:-22} printf ' tcp dport { %s } accept\n' "$ssh_ports" >> "$arbeit/rules" if [ "$APACHE_WAN" != ja ]; then export WAN VLANPORT VLANS PREV_WAN PREV_VLANPORT PREV_VLANS python3 - "$ports" >> "$arbeit/rules" <<'FF_NFT_WAN' import os,json,sys interfaces=set((os.environ.get('WAN_PORTS',os.environ['WAN'])+' '+os.environ.get('PREV_WAN_PORTS',os.environ.get('PREV_WAN',''))).split()) for prefix in ('','PREV_'): if os.environ.get(prefix+'VLANS')=='ja': interfaces.update(p+'.6' for p in os.environ.get(prefix+'VLAN_PORTS',os.environ.get(prefix+'VLANPORT','')).split()) interfaces.discard('') for i in sorted(interfaces):print(' iifname '+json.dumps(i)+' tcp dport { '+sys.argv[1]+' } reject with tcp reset') FF_NFT_WAN fi printf ' }\n}\n' >> "$arbeit/rules" cat > "$arbeit/apply" <<'FF_NFT_APPLY' #!/bin/bash set -euo pipefail tmp=$(mktemp /run/freifunk-nft.XXXXXX);trap 'rm -f -- "$tmp"' EXIT if /usr/sbin/nft list table inet freifunk_status >/dev/null 2>&1; then echo 'delete table inet freifunk_status' > "$tmp"; fi cat /etc/nftables.d/freifunk-status.nft >> "$tmp" /usr/sbin/nft -c -f "$tmp" /usr/sbin/nft -f "$tmp" FF_NFT_APPLY cat > "$arbeit/unit" <<'FF_NFT_UNIT' [Unit] Description=Freifunk Apache WAN-Zugriff; SSH bleibt erlaubt After=network-pre.target Before=apache2.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/sbin/freifunk-status-firewall ExecStop=-/usr/sbin/nft delete table inet freifunk_status [Install] WantedBy=multi-user.target FF_NFT_UNIT ff_install "$arbeit/rules" /etc/nftables.d/freifunk-status.nft 0644 ff_install "$arbeit/apply" /usr/local/sbin/freifunk-status-firewall 0755 ff_install "$arbeit/unit" /etc/systemd/system/freifunk-status-firewall.service 0644 systemctl daemon-reload /usr/local/sbin/freifunk-status-firewall systemctl enable freifunk-status-firewall.service ff_note "FIREWALL: SSH auf allen lokalen Adressen zugelassen; Apache am WAN=$APACHE_WAN; Apache-Ports=$ports. Eigene inet-Tabelle, fremde Regeln nicht gelöscht." ff_note "ABGESCHLOSSEN: $FF_STEP" FREIFUNK_SCHRITT FF_STAGE_20 ff_state checkpoint python3 /usr/local/lib/freifunk-installationsbericht.py echo "Schlüsselverteilung: Upload auf VPN1; Übernahme auf die vier Gateways kann bis zu fünf Minuten dauern." printf '\n'; ff_tint '1;34' 'Abschließende Übersicht der Portrollen'; printf '\n' python3 <<'FF_PORT_OVERVIEW' import json,os,pathlib session=pathlib.Path(os.environ['FF_SESSION']) c=json.loads((session/'auswahl.json').read_text()) def ports(key):return list(dict.fromkeys(c.get(key,'').split())) eth=ports('ETH_PORTS');wan=ports('WAN_PORTS');mesh=ports('MESH_PORTS');lan=ports('LAN_PORTS');vlan=ports('VLAN_PORTS') allports=list(dict.fromkeys(eth+wan+mesh+lan+vlan)) v7=c.get('VLAN7_IP','') if 'VLAN7_IP' in c.get('KEEP_INVALID_FIELDS','').split(','):v7='' macvlans={} for i,(port,tagged) in enumerate([(p,True) for p in vlan]+[(p,False) for p in lan]): macvlans[(port,tagged)]='fflan' if i==0 else f'fflan{i}' rows=[] for port in allports: roles=[] if port in wan:roles.append('WAN / DHCP') if port in mesh:roles.append('Kabel-Mesh -> bat0') if port in lan:roles.append('Freifunk-LAN -> '+macvlans[(port,False)]+' -> br0') rows.append((port,'; '.join(roles) if roles else 'Keine ungetaggte Rolle')) if port in vlan: rows.extend([(port+'.5','VLAN 5: Kabel-Mesh -> bat0'),(port+'.6','VLAN 6: WAN / DHCP'),(port+'.8','VLAN 8: Freifunk-LAN -> '+macvlans[(port,True)]+' -> br0')]) if v7 and port in eth:rows.append((port+'.7','VLAN 7: Management -> br7')) rows.append(('vpn3','fastd-VPN -> bat0')) rows.append(('br0','Freifunk-Bridge: bat0 und gewählte Freifunk-LAN-Macvlans')) if v7:rows.append(('br7','Management-Bridge: '+v7+' (einmal auf br7)')) width=max([len(p) for p,_ in rows]+[4]) lines=['PORTÜBERSICHT – EINGERICHTETE KONFIGURATION','Port / Interface'.ljust(max(width,16))+' Funktion','-'*78] for port,role in rows:lines.append(port.ljust(max(width,16))+' '+role) lines+=['','Die Übersicht beschreibt die gespeicherte Konfiguration, keinen Verbindungstest.', 'Neu geschriebene Ethernet-/Bridge-Rollen werden beim Neustart aktiviert.', 'WLAN wird vom Installer nicht eingerichtet.'] text='\n'.join(lines)+'\n';print(text) p=session/'port-uebersicht.txt';p.write_text(text);p.chmod(0o600) with (session/'ereignisse.log').open('a') as f:f.write('\n'+text+'\n') FF_PORT_OVERVIEW python3 /usr/local/lib/freifunk-installationsbericht.py echo "Vorbereitungen beendet. Ein aktivierter Dienst ist noch kein Funktionsnachweis." # Always display the actual public key, including when stage 09 was skipped on resume # or retained an existing configuration. Never print the private config/keypair. printf '\n============================================================\n' echo "WICHTIG: Öffentlichen fastd-Schlüssel registrieren" if public_key=$(cd /etc/fastd/freifunk && fastd --config fastd.conf --show-key --machine-readable); then if [[ "$public_key" =~ ^[0-9a-fA-F]{64}$ ]]; then printf '\n'; ff_tint '1;36' 'Dein öffentlicher fastd-Schlüssel:'; printf '\n'; ff_tint '1;32' "$public_key"; printf '\n\n' echo "Diesen Schlüssel bitte hier hochladen / registrieren:" echo "https://freifunk-halle.org/fastd/" echo "Ist genau dieser Schlüssel bereits freigeschaltet, ist keine erneute Registrierung nötig." echo "Bei einem neuen Schlüssel: erst registrieren und freischalten lassen; dann kann vpn3 verbinden." else echo "WARNUNG: Öffentlicher fastd-Schlüssel konnte nicht eindeutig gelesen werden." >&2 echo "Bitte vor dem VPN-Test prüfen; Registrierung: https://freifunk-halle.org/fastd/" >&2 fi else echo "WARNUNG: Öffentlicher fastd-Schlüssel konnte nicht ausgelesen werden." >&2 echo "Bitte vor dem VPN-Test prüfen; Registrierung: https://freifunk-halle.org/fastd/" >&2 fi printf '============================================================\n\n' echo "Nach dem Neustart prüfen: /usr/local/sbin/freifunk-installation --check" read -r -p "$(ff_prompt "Offloader jetzt neu starten? ja/nein [nein]: ")" reboot_now > "$FF_SESSION/ereignisse.log" python3 /usr/local/lib/freifunk-installationsbericht.py systemctl reboot fi