diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3b843e5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test installer + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + container: + image: debian:12 + + steps: + - name: Install dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends \ + python3 python3-yaml git ca-certificates + + - name: Checkout + uses: actions/checkout@v4 + + - name: Run tests + run: bash tests/test_install.sh + + shellcheck: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: ShellCheck install.sh + run: shellcheck install.sh + + - name: ShellCheck test_install.sh + run: shellcheck tests/test_install.sh diff --git a/README.md b/README.md index 8aaf62c..346f9c8 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,55 @@ Created by Travis Kreikemeier @ NETWAR (http://www.netwar.org) Gamebridge allows you to carve up your LAN party network into many VLANs. Usually, this is a no-no for LAN parties due to the broadcasts needed to find local LAN game servers. Gamebridge rebroadcasts those game server finding beacons across many VLANs without passing anything else (ARP, BPDU, other non-game server broadcasts, etc.). -# INSTALL -* Follow steps in gamebridge_setup.txt - # REQUIREMENTS * Tested at large LAN events with Debian 12 as a guest on VMWare ESX 8 * Any other distros may require modifications +* Python 3 with PyYAML (`apt-get install python3-yaml`) * Two NICS: One connected to a single VLAN for management * The other NIC connected to a trunk port on your switch with all VLANs tagged * If using VMWare vSwitch, the trunk port group needs to have VLAN 4095 set so that it passes all VLANs tagged to the guest vNIC. -# TODO -* Create installer script to simplify installation +# INSTALL + +1. Copy the example config and edit it for your network: + ``` + cp gamebridge.conf.example.yaml gamebridge.conf.yaml + vi gamebridge.conf.yaml + ``` + +2. Preview what the installer will do: + ``` + ./install.sh -c gamebridge.conf.yaml --dry-run + ``` + +3. Run the installer: + ``` + sudo ./install.sh -c gamebridge.conf.yaml + ``` + +4. Reboot to apply: + ``` + sudo reboot + ``` + +5. Verify after reboot: + ``` + sudo ./install.sh --status + ``` + +# CONFIGURATION + +Edit `gamebridge.conf.yaml` to match your environment: + +| Field | Description | +|---|---| +| `management.interface` | Management NIC name (e.g. `ens192`) | +| `management.address` | Management IP address | +| `management.netmask` | Management subnet mask | +| `management.gateway` | Management gateway | +| `trunk_interface` | Trunk NIC connected to tagged VLANs (e.g. `ens224`) | +| `bridge` | Bridge interface name (default: `br0`) | +| `vlans` | List of VLAN IDs to bridge | +| `game_ports` | List of UDP port ranges to allow (e.g. `"27014:27025"`) | + +See `gamebridge.conf.example.yaml` for a full working example. diff --git a/gamebridge.conf.example.yaml b/gamebridge.conf.example.yaml new file mode 100644 index 0000000..69cdcdd --- /dev/null +++ b/gamebridge.conf.example.yaml @@ -0,0 +1,105 @@ +--- +# Gamebridge Configuration +# Copy this file and customize for your network environment. +# Save as .yaml or .yml and pass to install.sh with -c/--config. + +# Management interface - for SSH/admin access (not bridged) +management: + interface: ens192 + address: 10.10.10.12 + netmask: 255.255.255.0 + gateway: 10.10.10.1 + +# Trunk interface - connected to switch port with all VLANs tagged +trunk_interface: ens224 + +# Bridge interface name +bridge: br0 + +# VLAN IDs to bridge together +vlans: + - 10 + - 11 + - 101 + - 102 + - 103 + - 104 + - 105 + - 106 + - 107 + - 108 + - 109 + - 110 + - 111 + - 112 + - 113 + - 114 + - 115 + - 116 + - 117 + - 118 + - 119 + - 120 + - 121 + - 122 + - 123 + - 124 + - 125 + - 126 + - 127 + - 128 + - 129 + - 130 + - 131 + - 132 + - 133 + - 134 + +# UDP port ranges for game server discovery broadcasts. +# Format: "start:end" for ranges, or "port:port" for single ports. +game_ports: + - "1301:1312" + - "1716:1727" + - "2234:2245" + - "2301:2312" + - "2350:2360" + - "3074:3074" + - "3454:3465" + - "3657:3668" + - "4242:4242" + - "4321:4321" + - "5001:5001" + - "5120:5131" + - "6112:6112" + - "7140:7140" + - "7776:7788" + - "8086:8088" + - "10480:10491" + - "10777:10777" + - "10999:10999" + - "12299:12310" + - "14001:14001" + - "17500:17500" + - "20099:20110" + - "21999:22010" + - "23757:23757" + - "24298:24298" + - "25299:25310" + - "25565:25565" + - "25999:26010" + - "26001:26011" + - "26900:26905" + - "27014:27025" + - "27215:27215" + - "27887:27898" + - "27900:27900" + - "27959:27970" + - "28069:28080" + - "28959:28970" + - "29069:29080" + - "29252:29263" + - "29899:29910" + - "30719:30730" + - "44400:44410" + - "58202:58203" + - "65117:65117" diff --git a/gamebridge_setup.txt b/gamebridge_setup.txt deleted file mode 100644 index b7c633b..0000000 --- a/gamebridge_setup.txt +++ /dev/null @@ -1,223 +0,0 @@ -## GAME BRIDGE SETUP -## By Travis Kreikemeier -## Version 6 - 1/22/2023 for Debian 12 - - -## The Setup - ----------------------- -# Install utilities we need - -apt-get install bridge-utils -apt-get install ebtables - -# Enable tagging on interfaces -echo 8021q >> /etc/modules - ----------------------- -# Add tagged interfaces -vi /etc/network/interfaces -# PUT BELOW IN (You'll need to customize to your interfaces, IP address, and VLANs) - -# /etc/network/interfaces -auto ens192 -iface ens192 inet static - address 10.10.10.12 - netmask 255.255.255.0 - gateway 10.10.10.1 - -auto ens224.10 -iface ens224.10 inet manual - -auto ens224.11 -iface ens224.11 inet manual - -auto ens224.101 -iface ens224.101 inet manual - -auto ens224.102 -iface ens224.102 inet manual - -auto ens224.103 -iface ens224.103 inet manual - -auto ens224.104 -iface ens224.104 inet manual - -auto ens224.105 -iface ens224.105 inet manual - -auto ens224.106 -iface ens224.106 inet manual - -auto ens224.107 -iface ens224.107 inet manual - -auto ens224.108 -iface ens224.108 inet manual - -auto ens224.109 -iface ens224.109 inet manual - -auto ens224.110 -iface ens224.110 inet manual - -auto ens224.111 -iface ens224.111 inet manual - -auto ens224.112 -iface ens224.112 inet manual - -auto ens224.113 -iface ens224.113 inet manual - -auto ens224.114 -iface ens224.114 inet manual - -auto ens224.115 -iface ens224.115 inet manual - -auto ens224.116 -iface ens224.116 inet manual - -auto ens224.117 -iface ens224.117 inet manual - -auto ens224.118 -iface ens224.118 inet manual - -auto ens224.119 -iface ens224.119 inet manual - -auto ens224.120 -iface ens224.120 inet manual - -auto ens224.121 -iface ens224.121 inet manual - -auto ens224.122 -iface ens224.122 inet manual - -auto ens224.123 -iface ens224.123 inet manual - -auto ens224.124 -iface ens224.124 inet manual - -auto ens224.125 -iface ens224.125 inet manual - -auto ens224.126 -iface ens224.126 inet manual - -auto ens224.127 -iface ens224.127 inet manual - -auto ens224.128 -iface ens224.128 inet manual - -auto ens224.129 -iface ens224.129 inet manual - -auto ens224.130 -iface ens224.130 inet manual - -auto ens224.131 -iface ens224.131 inet manual - -auto ens224.132 -iface ens224.132 inet manual - -auto ens224.133 -iface ens224.133 inet manual - -auto ens224.134 -iface ens224.134 inet manual - -# auto br0 - We'll manually bring it up after ebtables filtering -iface br0 inet manual - bridge_ports ens224.10 ens224.11 ens224.101 ens224.102 ens224.103 ens224.104 ens224.105 ens224.106 ens224.107 ens224.108 ens224.109 ens224.110 ens224.111 ens224.112 ens224.113 ens224.114 ens224.115 ens224.116 ens224.117 ens224.118 ens224.119 ens224.120 ens224.121 ens224.122 ens224.123 ens224.124 ens224.125 ens224.126 ens224.127 ens224.128 ens224.129 ens224.130 ens224.131 ens224.132 ens224.133 ens224.134 - - ----------------------- -# Insert into /etc/rc.local -# Replace entire rc.local with the below - -#!/bin/sh -e -ebtables -P FORWARD DROP -ebtables -F FORWARD -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 1301:1312 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 1716:1727 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 2234:2245 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 2301:2312 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 2350:2360 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 3074:3074 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 3454:3465 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 3454:3465 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 3657:3668 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 4242:4242 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 4321:4321 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 5001:5001 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 5120:5131 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 6112:6112 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 7140:7140 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 7776:7788 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 8086:8088 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 10480:10491 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 10777:10777 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 10999:10999 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 12299:12310 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 14001:14001 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 17500:17500 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 20099:20110 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 21999:22010 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 23757:23757 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 24298:24298 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 25299:25310 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 25565:25565 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 25999:26010 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 26001:26011 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 26900:26905 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 27014:27025 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 27215:27215 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 27887:27898 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 27900:27900 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 27959:27970 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 28069:28080 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 28959:28970 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 29069:29080 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 29252:29263 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 29899:29910 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 30719:30730 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 44400:44410 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 58202:58203 -j ACCEPT -ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port 65117:65117 -j ACCEPT - -ifup br0 - -# SNAT the Source MAC Address of frames to your bridge interface. -br0_mac=`cat /sys/class/net/br0/address` -ebtables -t nat -A POSTROUTING -j snat --to-src $br0_mac --snat-target ACCEPT - -exit 0 - -## End of /etc/rc.local ## - - ----------------------- -# Enable rc.local file (Only need to do this once) -# From https://blog.wijman.net/enable-rc-local-in-debian-bullseye/ - -chmod +x /etc/rc.local -systemctl daemon-reload -systemctl start rc-local -systemctl status rc-local - - -## END OF SETUP ## - ----------------------- -# show commands to confirm working -ip link -brctl show -ebtables -L --Lc --Ln diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..0b594c0 --- /dev/null +++ b/install.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# Gamebridge Installer +# Reads a YAML config file and automates the gamebridge setup. +# See gamebridge.conf.example.yaml for configuration reference. +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +DRY_RUN=false +CONFIG_FILE="" +CONFIG_JSON="" + +usage() { + cat <&2; } +die() { err "$@"; exit 1; } + +# --------------------------------------------------------------------------- +# Config parsing (uses Python 3 — available by default on Debian 12) +# --------------------------------------------------------------------------- + +parse_config() { + python3 - "$1" << 'PYEOF' +import sys, json, os + +config_file = sys.argv[1] +ext = os.path.splitext(config_file)[1].lower() + +if ext not in ('.yaml', '.yml'): + print(f"ERROR: Config file must be YAML (.yaml or .yml), got '{ext}'", file=sys.stderr) + sys.exit(1) + +try: + import yaml +except ImportError: + print("ERROR: python3-yaml is required. Install with: apt-get install python3-yaml", file=sys.stderr) + sys.exit(1) + +with open(config_file) as f: + data = yaml.safe_load(f) + +# --- validate required fields --- +errors = [] +for key in ('management', 'trunk_interface', 'vlans', 'game_ports'): + if key not in data: + errors.append(f"Missing required key: {key}") +if 'management' in data: + for sub in ('interface', 'address', 'netmask', 'gateway'): + if sub not in data['management']: + errors.append(f"Missing required key: management.{sub}") +if 'vlans' in data and not isinstance(data['vlans'], list): + errors.append("'vlans' must be a list") +if 'game_ports' in data and not isinstance(data['game_ports'], list): + errors.append("'game_ports' must be a list") + +if errors: + for e in errors: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + +# Apply defaults +data.setdefault('bridge', 'br0') + +json.dump(data, sys.stdout) +PYEOF +} + +# Retrieve a dotted key from CONFIG_JSON. Lists are printed one element per line. +config_get() { + echo "$CONFIG_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for k in sys.argv[1].split('.'): + data = data[int(k)] if isinstance(data, list) else data[k] +if isinstance(data, list): + for item in data: + print(item) +else: + print(data) +" "$1" +} + +# --------------------------------------------------------------------------- +# Installation steps +# --------------------------------------------------------------------------- + +install_packages() { + log "Installing required packages (bridge-utils, ebtables)..." + if $DRY_RUN; then + echo " [DRY RUN] apt-get update -qq" + echo " [DRY RUN] apt-get install -y bridge-utils ebtables" + else + apt-get update -qq + apt-get install -y bridge-utils ebtables + fi +} + +enable_8021q() { + log "Enabling 802.1Q VLAN tagging module..." + if grep -q '^8021q$' /etc/modules 2>/dev/null; then + log "802.1Q already present in /etc/modules — skipping" + else + if $DRY_RUN; then + echo " [DRY RUN] echo 8021q >> /etc/modules" + else + echo 8021q >> /etc/modules + fi + fi +} + +generate_interfaces() { + log "Generating /etc/network/interfaces..." + + local mgmt_iface mgmt_addr mgmt_netmask mgmt_gateway trunk_iface bridge + mgmt_iface=$(config_get management.interface) + mgmt_addr=$(config_get management.address) + mgmt_netmask=$(config_get management.netmask) + mgmt_gateway=$(config_get management.gateway) + trunk_iface=$(config_get trunk_interface) + bridge=$(config_get bridge) + + local tmp_file + tmp_file=$(mktemp) + + # Header + loopback + management interface + cat > "$tmp_file" << EOF +# /etc/network/interfaces +# Generated by gamebridge installer on $(date -Iseconds) + +auto lo +iface lo inet loopback + +auto ${mgmt_iface} +iface ${mgmt_iface} inet static + address ${mgmt_addr} + netmask ${mgmt_netmask} + gateway ${mgmt_gateway} + +EOF + + # VLAN sub-interfaces + local bridge_ports="" + while IFS= read -r vlan; do + cat >> "$tmp_file" << EOF +auto ${trunk_iface}.${vlan} +iface ${trunk_iface}.${vlan} inet manual + +EOF + bridge_ports="${bridge_ports:+${bridge_ports} }${trunk_iface}.${vlan}" + done < <(config_get vlans) + + # Bridge (not auto — brought up manually after ebtables rules are applied) + cat >> "$tmp_file" << EOF +# ${bridge} is brought up manually by rc.local after ebtables filtering is in place +iface ${bridge} inet manual + bridge_ports ${bridge_ports} +EOF + + if $DRY_RUN; then + log "Would write /etc/network/interfaces:" + echo "--- begin ---" + cat "$tmp_file" + echo "--- end ---" + else + if [ -f /etc/network/interfaces ]; then + cp /etc/network/interfaces "/etc/network/interfaces.bak.$(date +%Y%m%d%H%M%S)" + log "Backed up existing /etc/network/interfaces" + fi + cp "$tmp_file" /etc/network/interfaces + log "Wrote /etc/network/interfaces" + fi + rm -f "$tmp_file" +} + +generate_rclocal() { + log "Generating /etc/rc.local..." + + local bridge + bridge=$(config_get bridge) + + local tmp_file + tmp_file=$(mktemp) + + cat > "$tmp_file" << 'EOF' +#!/bin/sh -e +# /etc/rc.local +# Generated by gamebridge installer +# Sets up ebtables filtering and brings up the bridge. + +# Drop all forwarded traffic by default +ebtables -P FORWARD DROP +ebtables -F FORWARD + +# Allow game server discovery broadcasts (UDP) +EOF + + # Append one ebtables rule per port range + while IFS= read -r port_range; do + echo "ebtables -A FORWARD -d Broadcast -p IPv4 --ip-protocol udp --ip-destination-port ${port_range} -j ACCEPT" >> "$tmp_file" + done < <(config_get game_ports) + + # Bring up bridge and SNAT + cat >> "$tmp_file" << EOF + +# Bring up bridge interface +ifup ${bridge} + +# SNAT source MAC address of forwarded frames to the bridge MAC +BRIDGE_MAC=\$(cat /sys/class/net/${bridge}/address) +ebtables -t nat -A POSTROUTING -j snat --to-src \$BRIDGE_MAC --snat-target ACCEPT + +exit 0 +EOF + + if $DRY_RUN; then + log "Would write /etc/rc.local:" + echo "--- begin ---" + cat "$tmp_file" + echo "--- end ---" + else + if [ -f /etc/rc.local ]; then + cp /etc/rc.local "/etc/rc.local.bak.$(date +%Y%m%d%H%M%S)" + log "Backed up existing /etc/rc.local" + fi + cp "$tmp_file" /etc/rc.local + chmod +x /etc/rc.local + log "Wrote /etc/rc.local" + fi + rm -f "$tmp_file" +} + +enable_rclocal() { + log "Enabling rc-local service..." + if $DRY_RUN; then + echo " [DRY RUN] systemctl daemon-reload" + echo " [DRY RUN] systemctl enable rc-local" + else + systemctl daemon-reload + systemctl enable rc-local + fi +} + +show_status() { + log "Network links:" + ip link + echo "" + log "Bridge status:" + brctl show + echo "" + log "ebtables FORWARD rules:" + ebtables -L FORWARD --Lc --Ln + echo "" + log "ebtables nat POSTROUTING rules:" + ebtables -t nat -L POSTROUTING --Lc --Ln +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + local show_status_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + -c|--config) CONFIG_FILE="$2"; shift 2 ;; + -d|--dry-run) DRY_RUN=true; shift ;; + -s|--status) show_status_only=true; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown option: $1\nRun '$SCRIPT_NAME --help' for usage." ;; + esac + done + + if $show_status_only; then + show_status + exit 0 + fi + + if [[ -z "$CONFIG_FILE" ]]; then + die "Config file is required. Use -c/--config to specify.\nRun '$SCRIPT_NAME --help' for usage." + fi + + if [[ ! -f "$CONFIG_FILE" ]]; then + die "Config file not found: $CONFIG_FILE" + fi + + if ! command -v python3 &>/dev/null; then + die "python3 is required but not found" + fi + + if [[ "$(id -u)" -ne 0 ]] && ! $DRY_RUN; then + die "This script must be run as root (use sudo) or pass --dry-run to preview." + fi + + log "Parsing configuration from ${CONFIG_FILE}..." + CONFIG_JSON=$(parse_config "$CONFIG_FILE") + if [[ -z "$CONFIG_JSON" ]]; then + die "Failed to parse config file" + fi + + if $DRY_RUN; then + warn "DRY RUN MODE — no changes will be made" + echo "" + fi + + install_packages + enable_8021q + generate_interfaces + generate_rclocal + enable_rclocal + + echo "" + if $DRY_RUN; then + log "Dry run complete. Review the output above." + else + log "Installation complete!" + log "Reboot the system to apply all changes:" + log " reboot" + echo "" + log "After reboot, verify with:" + log " sudo $SCRIPT_NAME --status" + fi +} + +main "$@" diff --git a/tests/minimal.yaml b/tests/minimal.yaml new file mode 100644 index 0000000..39fdd4f --- /dev/null +++ b/tests/minimal.yaml @@ -0,0 +1,19 @@ +--- +management: + interface: eth0 + address: 192.168.1.10 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + +trunk_interface: eth1 + +bridge: br0 + +vlans: + - 10 + - 20 + - 30 + +game_ports: + - "27014:27025" + - "7777:7777" diff --git a/tests/missing_mgmt_field.yaml b/tests/missing_mgmt_field.yaml new file mode 100644 index 0000000..b33e79c --- /dev/null +++ b/tests/missing_mgmt_field.yaml @@ -0,0 +1,13 @@ +--- +management: + interface: eth0 + address: 192.168.1.10 + # netmask and gateway intentionally omitted + +trunk_interface: eth1 + +vlans: + - 10 + +game_ports: + - "27015:27015" diff --git a/tests/missing_vlans.yaml b/tests/missing_vlans.yaml new file mode 100644 index 0000000..84f8eb1 --- /dev/null +++ b/tests/missing_vlans.yaml @@ -0,0 +1,11 @@ +--- +management: + interface: eth0 + address: 192.168.1.10 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + +trunk_interface: eth1 + +game_ports: + - "27015:27015" diff --git a/tests/no_bridge_field.yaml b/tests/no_bridge_field.yaml new file mode 100644 index 0000000..a9c8b80 --- /dev/null +++ b/tests/no_bridge_field.yaml @@ -0,0 +1,15 @@ +--- +management: + interface: eth0 + address: 192.168.1.10 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + +trunk_interface: eth1 + +vlans: + - 10 + - 20 + +game_ports: + - "27015:27015" diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..3679c3a --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# Test suite for install.sh +# Runs entirely in --dry-run mode — no root or real hardware needed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +INSTALL="$REPO_DIR/install.sh" +FIXTURES="$SCRIPT_DIR" + +PASS=0 +FAIL=0 +TESTS_RUN=0 + +# ---------- helpers ---------- + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) + local name="$1" + shift + if "$@"; then + PASS=$((PASS + 1)) + echo " PASS $name" + else + FAIL=$((FAIL + 1)) + echo " FAIL $name" + fi +} + +assert_exit_code() { + local expected="$1" + shift + local actual + set +e + "$@" > /dev/null 2>&1 + actual=$? + set -e + [[ "$actual" -eq "$expected" ]] +} + +assert_output_contains() { + local pattern="$1" + shift + local output + set +e + output=$("$@" 2>&1) + set -e + echo "$output" | grep -qF "$pattern" +} + +# ---------- argument handling tests ---------- + +echo "=== Argument handling ===" + +run_test "--help exits 0" \ + assert_exit_code 0 bash "$INSTALL" --help + +run_test "--help shows usage" \ + assert_output_contains "Usage:" bash "$INSTALL" --help + +run_test "no args exits non-zero" \ + assert_exit_code 1 bash "$INSTALL" + +run_test "no args shows error about config" \ + assert_output_contains "Config file is required" bash "$INSTALL" + +run_test "missing config file exits non-zero" \ + assert_exit_code 1 bash "$INSTALL" -c /nonexistent/file.yaml --dry-run + +run_test "missing config file shows error" \ + assert_output_contains "Config file not found" bash "$INSTALL" -c /nonexistent/file.yaml --dry-run + +run_test "unknown option exits non-zero" \ + assert_exit_code 1 bash "$INSTALL" --bogus + +# ---------- config validation tests ---------- + +echo "" +echo "=== Config validation ===" + +run_test "missing vlans field is caught" \ + assert_output_contains "Missing required key: vlans" \ + bash "$INSTALL" -c "$FIXTURES/missing_vlans.yaml" --dry-run + +run_test "missing management sub-field is caught" \ + assert_output_contains "Missing required key: management.netmask" \ + bash "$INSTALL" -c "$FIXTURES/missing_mgmt_field.yaml" --dry-run + +run_test "bridge defaults to br0 when omitted" \ + assert_output_contains "br0" \ + bash "$INSTALL" -c "$FIXTURES/no_bridge_field.yaml" --dry-run + +# ---------- YAML dry-run output tests ---------- + +echo "" +echo "=== YAML dry-run output ===" + +YAML_OUTPUT=$(bash "$INSTALL" -c "$FIXTURES/minimal.yaml" --dry-run 2>&1) + +check_yaml_output() { + echo "$YAML_OUTPUT" | grep -qF "$1" +} + +run_test "YAML: management interface present" \ + check_yaml_output "auto eth0" + +run_test "YAML: management IP configured" \ + check_yaml_output "address 192.168.1.10" + +run_test "YAML: management netmask configured" \ + check_yaml_output "netmask 255.255.255.0" + +run_test "YAML: management gateway configured" \ + check_yaml_output "gateway 192.168.1.1" + +run_test "YAML: VLAN 10 interface created" \ + check_yaml_output "auto eth1.10" + +run_test "YAML: VLAN 20 interface created" \ + check_yaml_output "auto eth1.20" + +run_test "YAML: VLAN 30 interface created" \ + check_yaml_output "auto eth1.30" + +run_test "YAML: VLAN interfaces set to manual" \ + check_yaml_output "iface eth1.10 inet manual" + +run_test "YAML: bridge_ports lists all VLANs" \ + check_yaml_output "bridge_ports eth1.10 eth1.20 eth1.30" + +run_test "YAML: ebtables default DROP policy" \ + check_yaml_output "ebtables -P FORWARD DROP" + +run_test "YAML: ebtables flush FORWARD" \ + check_yaml_output "ebtables -F FORWARD" + +run_test "YAML: ebtables rule for port 27014:27025" \ + check_yaml_output "ip-destination-port 27014:27025 -j ACCEPT" + +run_test "YAML: ebtables rule for port 7777:7777" \ + check_yaml_output "ip-destination-port 7777:7777 -j ACCEPT" + +run_test "YAML: bridge ifup in rc.local" \ + check_yaml_output "ifup br0" + +run_test "YAML: SNAT MAC rewrite in rc.local" \ + check_yaml_output "/sys/class/net/br0/address" + +run_test "YAML: rc.local shebang" \ + check_yaml_output "#!/bin/sh -e" + +run_test "YAML: DRY RUN banner shown" \ + check_yaml_output "DRY RUN" + +# ---------- non-YAML file rejected ---------- + +echo "" +echo "=== File extension validation ===" + +# Create a temp .json file to verify it's rejected +TEMP_JSON=$(mktemp --suffix=.json) +echo '{}' > "$TEMP_JSON" + +run_test "non-YAML extension is rejected" \ + assert_exit_code 1 bash "$INSTALL" -c "$TEMP_JSON" --dry-run + +run_test "non-YAML error message is clear" \ + assert_output_contains "must be YAML" bash "$INSTALL" -c "$TEMP_JSON" --dry-run + +rm -f "$TEMP_JSON" + +# ---------- full example config test ---------- + +echo "" +echo "=== Full example config ===" + +run_test "example config parses without error" \ + assert_exit_code 0 bash "$INSTALL" -c "$REPO_DIR/gamebridge.conf.example.yaml" --dry-run + +FULL_OUTPUT=$(bash "$INSTALL" -c "$REPO_DIR/gamebridge.conf.example.yaml" --dry-run 2>&1) + +check_full_output() { + echo "$FULL_OUTPUT" | grep -qF "$1" +} + +count_vlan_interfaces() { + local count + count=$(echo "$FULL_OUTPUT" | grep -c "^auto ens224\." 2>/dev/null || true) + [[ "$count" -eq 36 ]] +} + +count_ebtables_rules() { + local count + count=$(echo "$FULL_OUTPUT" | grep -c "ip-destination-port" 2>/dev/null || true) + [[ "$count" -eq 45 ]] +} + +run_test "example: all 36 VLAN interfaces created" \ + count_vlan_interfaces + +run_test "example: all 45 ebtables port rules created" \ + count_ebtables_rules + +run_test "example: management interface is ens192" \ + check_full_output "auto ens192" + +run_test "example: trunk interface is ens224" \ + check_full_output "auto ens224.10" + +# ---------- summary ---------- + +echo "" +echo "===============================" +echo " $TESTS_RUN tests: $PASS passed, $FAIL failed" +echo "===============================" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi