#!/usr/bin/env bash ############################################################ # Filename : gns3-remote-install.sh # # Author : Shane Sexton # # Created : April 18 2026 # # Last edit : April 22 2026 # # Purpose : Enhancement to remote-install.sh # # Reference : GNS3:gns3-server - remote-install.sh # # Depends : Ubuntu Server 22.04, 24.04, 26.04 LTS # # To do : Consistency, readability, & structure need # # improvements at all magnification levels # ############################################################ ############################################################ # PLEASE SEE README FOR FEATURE COMPARISON # ############################################################ ######################################################################## # Usage: sudo ./gns3-remote-install-redux.sh [OPTIONS] # # # # --with-openvpn Install & configure OpenVPN # # --with-wireguard Install & configure WireGuard # # --with-welcome Install GNS3-VM welcome.py console UI # # --without-kvm Disable KVM (hurts Qemu performance) # # --without-docker Skip Docker installation # # --without-firewall Skip automatic UFW rule configuration # # --without-system-upgrade Skip apt upgrade # # --unsafe-configs Serve VPN configs unencrypted (less safe) # # --unstable Use GNS3 unstable PPA # # --custom-repository REPO Use custom GNS3 PPA name # # -h, --help Show this help # ######################################################################## ######################################################################## # Conventions # # # # Variables: # # Globals: ROARING_SNEK_CASE # # Local and iters: _snake_case (prefix) # # Var expansion: "${VAR}" (not "$VAR") # # # # Functions: # # Use lowercase_snake_case names # # Start with `local` declaration block for all locals # # Return 0 - success # # 1 - error; carrying on # # log_fatal - panic # # # # Logic: # # Avoid shorthand [[ condition ]] &&... use if/else/fi # # # # File writes: # # ensure_config() for anything that needs specific perms # # ensure_directory() before writing to a new path # # require_file() after any action that should produce a file # # # # Logging: # # log_info(): what we're about to do (or did, non-critical) # # log_ok(): confirmation that something worked # # log_warn(): non-fatal issue, not actionable # # log_warn_sticky(): non-fatal but user informed at summary time # # log_error(): something failed but we're continuing # # log_fatal(): cannot proceed; exits # # # # Services: # # enable_and_start() — not systemctl enable/start/restart # # # # External resources: # # fetch_resource() with ordered fallbacks (local→canonical→mirror) # # # # Idempotency: # # Every function should be safe to re-run # # Key writes: [[ -f key ]] || generate (never overwrite keys) # # Config writes: always overwrite existing configs # # Groups/users: ensure_group / id checks # ######################################################################## ### Constants ################################################################ # This repo readonly REPO_BASE_URL="https://raw.githubusercontent.com/lark-cx/gns3-server-deploy/refs/heads/main/" readonly REPO_LANDING_HTML="template.html" readonly REPO_DOCKER_GPG="docker-gpg" # Docker readonly DOCKER_BASE_URL="https://download.docker.com/linux/ubuntu" readonly DOCKER_KEYRING="/etc/apt/keyrings/docker.asc" # GNS3 configurations readonly GNS3_USER="gns3" readonly GNS3_HOME="/opt/gns3" readonly GNS3_CONF_DIR="/etc/gns3" readonly GNS3_PORT=3080 readonly GNS3_SERVICE_FILE="/lib/systemd/system/gns3.service" readonly GNS3_VENV="/usr/share/gns3/gns3-server" # Config deployment server readonly CONFIG_SERVE_PORT=8003 readonly CONFIG_SERVE_DIR="/var/lib/gns3-config-serve" readonly CONFIG_SERVE_HOURS=2 readonly DEPLOY_MARKER="# deployed by gns3-remote-install-redux" readonly DEPLOY_DIR="/root/.gns3-deploy" ### VPN Constants ############################################################# readonly OVPN_CONF_DIR="/etc/openvpn" readonly OVPN_PORT=1194 readonly WG_CONF_DIR="/etc/wireguard" readonly WG_PORT=51820 ### Colors #################################################################### readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly CYAN='\033[0;36m' readonly BOLD='\033[1m' readonly NC='\033[0m' ### Logging ################################################################## log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$1" >&2; } log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$1" >&2; } log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1" >&2; } log_error() { printf "${RED}[FAIL]${NC} %s\n" "$1" >&2; } log_fatal() { printf "${RED}[FATAL]${NC} %s\n" "$1" >&2 exit 1 } on_error() { local _line_no="$1" local _cmd="$2" local _exit_code="$3" log_error "Failed at line ${_line_no}: ${_cmd} (exit ${_exit_code})" # Only try to revive GNS3 if systemd knows about it if systemctl list-unit-files gns3.service >/dev/null 2>&1; then systemctl daemon-reload >/dev/null 2>&1 || true sleep 2 systemctl restart gns3 >/dev/null 2>&1 || systemctl start gns3 >/dev/null 2>&1 || true fi exit "${_exit_code}" } # Testability wrapper — EUID is readonly in bash, so tests override this function is_root() { [[ ${EUID} -eq 0 ]]; } # Sticky warnings — collected throughout execution, displayed before summary WARNINGS=() log_warn_sticky() { log_warn "$1" WARNINGS+=("$1") } ### Mutable arrays (built up by option flags) ##############################─ REQUIRED_CMDS=(apt apt-add-repository dpkg chown chmod useradd usermod lsmod systemctl ss openssl) REQUIRED_PORTS=("${GNS3_PORT}") REQUIRED_GROUPS=(kvm ubridge) REQUIRED_MODS=(kvm) REQUIRED_PKGS=( software-properties-common ca-certificates curl gns3-server dynamips vpcs python3 python3-pip python3-setuptools qemu-system-x86 qemu-utils ) readonly PKGS_OPENVPN=(openvpn dnsutils) readonly PKGS_WIREGUARD=(wireguard-tools) readonly PKGS_DOCKER=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin) readonly PKGS_WELCOME=(net-tools dialog python3-dialog) ### Option defaults ########################################################## WITH_OPENVPN=0 WITH_WIREGUARD=0 WITH_DOCKER=1 WITH_WELCOME=0 DISABLE_KVM=0 DISABLE_FIREWALL=0 NO_SYSTEM_UPGRADE=0 UNSAFE_CONFIGS=0 USE_LEGACY_RSA=0 REPOSITORY="ppa" ### Help ####################################################################─ show_help() { cat >&2 <<_EOF_HELP ${BOLD}gns3-remote-install-redux.sh${NC} — GNS3 remote server installer ${BOLD}Usage:${NC} sudo $0 [OPTIONS] ${BOLD}Options:${NC} --with-openvpn Install and configure OpenVPN --with-wireguard Install and configure WireGuard --with-welcome Install GNS3-VM welcome.py console UI --without-kvm Disable KVM hardware acceleration --without-docker Skip Docker CE installation --without-firewall Skip automatic UFW rule configuration --without-system-upgrade Skip apt upgrade step --unsafe-configs Serve VPN configs unencrypted --unstable Use the GNS3 unstable PPA --custom-repository REPO Use a custom GNS3 PPA name -h, --help Show this help _EOF_HELP } ### Argument parsing ########################################################## TEMP=$(getopt -o h --long with-openvpn,with-wireguard,with-welcome,without-kvm,without-docker,without-firewall,without-system-upgrade,unsafe-configs,legacy-rsa,unstable,custom-repository:,help -n "$0" -- "$@") || { show_help exit 1 } eval set -- "${TEMP}" while true; do case "$1" in --with-openvpn) WITH_OPENVPN=1 shift ;; --with-wireguard) WITH_WIREGUARD=1 shift ;; --with-welcome) WITH_WELCOME=1 shift ;; --without-kvm) DISABLE_KVM=1 shift ;; --without-docker) WITH_DOCKER=0 shift ;; --without-firewall) DISABLE_FIREWALL=1 shift ;; --without-system-upgrade) NO_SYSTEM_UPGRADE=1 shift ;; --unsafe-configs) UNSAFE_CONFIGS=1 shift ;; --legacy-rsa) USE_LEGACY_RSA=1 shift ;; --unstable) REPOSITORY="unstable" shift ;; --custom-repository) REPOSITORY="$2" shift 2 ;; -h | --help) show_help exit 0 ;; --) shift break ;; *) log_fatal "Unknown option: $1" ;; esac done ### Roll up arrays based on flags ##########################################─ if [[ ${WITH_OPENVPN} -eq 1 ]]; then REQUIRED_PORTS+=("${OVPN_PORT}") REQUIRED_PKGS+=("${PKGS_OPENVPN[@]}") fi if [[ ${WITH_WIREGUARD} -eq 1 ]]; then REQUIRED_PORTS+=("${WG_PORT}") REQUIRED_MODS+=(wireguard) REQUIRED_PKGS+=("${PKGS_WIREGUARD[@]}") fi # Config server port always needed (landing page is default-on) REQUIRED_PORTS+=("${CONFIG_SERVE_PORT}") if [[ ${WITH_DOCKER} -eq 1 ]]; then REQUIRED_PKGS+=("${PKGS_DOCKER[@]}") REQUIRED_GROUPS+=(docker) fi if [[ ${WITH_WELCOME} -eq 1 ]]; then REQUIRED_PKGS+=("${PKGS_WELCOME[@]}") fi ### Preflight checks ######################################################## preflight_checks() { local _has_errors=0 if [[ ${OSTYPE} != linux-gnu* ]]; then log_fatal "This script requires Linux (detected: ${OSTYPE})." fi if [[ -f /etc/os-release ]]; then source /etc/os-release else log_fatal "/etc/os-release not found. Is this Ubuntu?" fi if [[ ${ID:-} != "ubuntu" ]]; then log_fatal "This script requires Ubuntu (detected: ${ID:-unknown})." fi local -a _missing_cmds=() for _cmd in "${REQUIRED_CMDS[@]}"; do command -v "${_cmd}" &>/dev/null || _missing_cmds+=("${_cmd}") done if [[ ${#_missing_cmds[@]} -gt 0 ]]; then log_error "Missing commands: ${_missing_cmds[*]}" _has_errors=1 fi # Detect reconfigure: our config marker + gns3 service running if [[ -f "${GNS3_CONF_DIR}/gns3_server.conf" ]] && grep -q "${DEPLOY_MARKER}" "${GNS3_CONF_DIR}/gns3_server.conf" 2>/dev/null && systemctl is-active --quiet gns3 2>/dev/null; then log_info "Existing installation detected — running in reconfigure mode" systemctl stop gns3 systemctl stop gns3-config-serve.service 2>/dev/null || true fi local -a _busy_ports=() for _port in "${REQUIRED_PORTS[@]}"; do if ss -tlnH | grep -q ":${_port} "; then _busy_ports+=("${_port}") fi done if [[ ${#_busy_ports[@]} -gt 0 ]]; then log_error "Port(s) already in use: ${_busy_ports[*]}" _has_errors=1 fi # Ensure time synchronization is active for VPN certificate validity if command -v timedatectl &>/dev/null; then timedatectl set-ntp true || log_warn_sticky "Couldn't start NTP. Ensure accurate time for certs." fi # Load missing kernel modules automatically unless --without-kvm local -a _missing_mods=() for _mod in "${REQUIRED_MODS[@]}"; do if ! lsmod | grep -wq "${_mod}" 2>/dev/null; then if [[ ${_mod} == "kvm" && ${DISABLE_KVM} -eq 1 ]]; then continue fi log_info "Loading kernel module: ${_mod}" if modprobe "${_mod}" 2>/dev/null; then log_ok "Loaded ${_mod}" if ! grep -qx "${_mod}" /etc/modules-load.d/gns3.conf 2>/dev/null; then echo "${_mod}" >>/etc/modules-load.d/gns3.conf fi else _missing_mods+=("${_mod}") fi fi done if [[ ${#_missing_mods[@]} -gt 0 ]]; then log_warn_sticky "Kernel module(s) could not be loaded: ${_missing_mods[*]}" log_warn_sticky " Manual fix: modprobe ${_missing_mods[*]}" fi if [[ ${DISABLE_KVM} -eq 0 ]] && [[ $(grep -Ec '(vmx|svm)' /proc/cpuinfo) -eq 0 ]]; then log_warn_sticky "CPU virtualization extensions not detected. KVM will not function." log_warn_sticky " If running in a VM without nested virt, pass --without-kvm" fi if [[ ${REPOSITORY} == "ppa-v3" ]]; then if ! python3 -c 'import sys; assert sys.version_info >= (3,9)' &>/dev/null; then log_error "GNS3 v3+ requires Python >= 3.9" _has_errors=1 fi fi if [[ ${_has_errors} -eq 1 ]]; then log_fatal "Preflight failed. Fix the above and re-run." fi log_ok "Preflight checks passed" } ### Helpers ################################################################## # Retry apt operation 3 times, quietly. # Return final lines from apt on failure. apt_retry() { local _attempts=3 _i _apt_log _apt_log=$(mktemp) for ((_i = 1; _i <= _attempts; _i++)); do if apt-get "$@" -qq >"${_apt_log}" 2>&1; then rm -f "${_apt_log}" return 0 fi log_warn "apt failed (attempt ${_i}/${_attempts}), retrying in 5s..." tail -5 "${_apt_log}" >&2 sleep 5 done log_error "apt output:" tail -10 "${_apt_log}" >&2 rm -f "${_apt_log}" log_fatal "apt failed after ${_attempts} attempts: apt-get $*" } # Groups exists or is created ensure_group() { getent group "$1" &>/dev/null || groupadd --system "$1" } # Who invoked the script with sudo? detect_invoking_user() { if [[ -n ${SUDO_USER:-} && ${SUDO_USER} != "root" ]]; then echo "${SUDO_USER}" fi } # Get public IP with fallback chain (dig → curl → UNKNOWN) get_public_ip() { local _ip _ip=$(dig @ns1.google.com -t txt o-o.myaddr.l.google.com +short -4 2>/dev/null | sed 's/"//g') if [[ -n ${_ip} ]]; then echo "${_ip}" return fi _ip=$(curl -sf --max-time 5 https://icanhazip.com 2>/dev/null) if [[ -n ${_ip} ]]; then echo "${_ip}" return fi _ip=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null) if [[ -n ${_ip} ]]; then echo "${_ip}" return fi echo "UNKNOWN" } # Get LAN IP — primary interface address get_lan_ip() { hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1" } # Generate a readable one-time passphrase: XXXX-XXXX-#### generate_passphrase() { local _hex _hex=$(openssl rand -hex 4) printf "%s-%s" "${_hex:0:4}" "${_hex:4:4}" | tr 'a-f' 'A-F' } # Encrypt a file with a passphrase, output .enc alongside original encrypted_copy() { local _src="$1" _dst="$2" _pass="$3" openssl aes-256-cbc -pbkdf2 -iter 10000 -pass "pass:${_pass}" -a -in "${_src}" -out "${_dst}" } # Enable IPv4 forwarding, if needed enable_ip_forwarding() { log_info "Enabling IPv4 forwarding..." sysctl -w net.ipv4.ip_forward=1 >/dev/null if ! grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf 2>/dev/null; then echo "net.ipv4.ip_forward=1" >>/etc/sysctl.conf fi log_ok "IPv4 forwarding enabled (persistent)" } # Apply baseline sysctl hardening — safe, standard CIS recommendations apply_sysctl_hardening() { log_info "Applying sysctl hardening..." local -A _sysctls=( ["net.ipv4.conf.all.rp_filter"]="2" ["net.ipv4.conf.default.rp_filter"]="2" ["net.ipv4.tcp_syncookies"]="1" ["net.ipv4.conf.all.accept_redirects"]="0" ["net.ipv4.conf.default.accept_redirects"]="0" ["net.ipv4.conf.all.send_redirects"]="0" ["net.ipv4.conf.default.send_redirects"]="0" ["net.ipv4.conf.all.accept_source_route"]="0" ["net.ipv4.conf.default.accept_source_route"]="0" ["net.ipv6.conf.all.accept_redirects"]="0" ["net.ipv6.conf.default.accept_redirects"]="0" ) local _sysctl_file="/etc/sysctl.d/90-gns3-hardening.conf" : >"${_sysctl_file}" for _key in "${!_sysctls[@]}"; do local _val="${_sysctls[${_key}]}" echo "${_key}=${_val}" >>"${_sysctl_file}" sysctl -w "${_key}=${_val}" >/dev/null 2>&1 done log_ok "Sysctl hardening applied (${_sysctl_file})" } # Safe and quiet service (re)start - with mulligan enable_and_start() { local _svc="$1" local _attempt _max=3 log_info "Enabling and starting service: ${_svc}" systemctl daemon-reload systemctl enable "${_svc}" >/dev/null 2>&1 || true for ((_attempt=1; _attempt<=_max; _attempt++)); do if systemctl restart "${_svc}" >/dev/null 2>&1 || systemctl start "${_svc}" >/dev/null 2>&1; then log_ok "Service running: ${_svc}" return 0 fi if [[ ${_attempt} -lt ${_max} ]]; then log_warn "Service start failed for ${_svc} (attempt ${_attempt}/${_max}); retrying in 1s..." sleep 1 systemctl daemon-reload fi done log_warn_sticky "Service failed after ${_max} attempts: ${_svc}" log_warn_sticky " Check: journalctl -u ${_svc} --no-pager -n 30" return 1 } # Verify a file exists and is non-empty. # Returns 1 and logs sticky warning on failure. require_file() { local _path="$1" _desc="${2:-file}" if [[ ! -s ${_path} ]]; then log_warn_sticky "${_desc} missing or empty: ${_path}" return 1 fi return 0 } # Idempotent directory creation with ownership and mode. Creates if missing, # applies chown/chmod either way (in case perms drifted). ensure_directory() { local _path="$1" _owner="${2:-root}" _mode="${3:-}" if [[ -d ${_path} ]]; then log_info "Directory exists: ${_path}" else mkdir -p "${_path}" log_info "Created directory: ${_path}" fi chown "${_owner}:${_owner}" "${_path}" [[ -n ${_mode} ]] && chmod "${_mode}" "${_path}" } # Atomically write a config file with the right perms from start. # Content from stdin -> tempfile in target dir -> perms -> rename # # Usage: # ensure_config /etc/wireguard/wg0.conf root 600 <"${_tmp}") chown "${_owner}:${_owner}" "${_tmp}" chmod "${_mode}" "${_tmp}" mv "${_tmp}" "${_path}" # Self-verify: confirm the file actually made it and is non-empty require_file "${_path}" "config" || return 1 log_info "Wrote config: ${_path} (mode ${_mode}, owner ${_owner})" } # Try sequential sources until one succeeds. First arg is # destination, remaining args are sources, tried in order. # Local paths detected by absence of http(s):// prefix. # Returns 1 if all sources fail. # # Usage: # fetch_resource /etc/apt/keyrings/docker.asc \ # "${_script_dir}/docker-gpg" \ # "https://download.docker.com/linux/ubuntu/gpg" \ # "${REPO_BASE_URL}docker-gpg" fetch_resource() { local _dst="$1" shift local _src for _src in "$@"; do if [[ ${_src} =~ ^https?:// ]]; then if curl -fsSL --max-time 10 "${_src}" -o "${_dst}" 2>/dev/null; then log_info "Fetched from remote: ${_src}" return 0 fi log_warn "Remote fetch failed: ${_src}" elif [[ -f ${_src} ]]; then if cp "${_src}" "${_dst}" 2>/dev/null; then log_info "Copied from local: ${_src}" return 0 fi log_warn "Local copy failed: ${_src}" fi done log_warn_sticky "All sources failed for: ${_dst}" return 1 } ### Ephemeral config file server ############################################## setup_config_server() { log_info "Config server starting (port ${CONFIG_SERVE_PORT}, ${CONFIG_SERVE_HOURS}h TTL)..." # Stop existing server if running from a previous install if systemctl is-active --quiet gns3-config-serve.service 2>/dev/null; then systemctl stop gns3-config-serve.service 2>/dev/null || true systemctl stop gns3-config-serve-stop.timer 2>/dev/null || true log_info "Stopped previous config server" fi # Clean previous serve directory (public-facing only) [[ -d ${CONFIG_SERVE_DIR} ]] && rm -rf "${CONFIG_SERVE_DIR}" # Ensure deploy directory exists for secrets mkdir -p "${DEPLOY_DIR}" chmod 700 "${DEPLOY_DIR}" # Persist slug across re-runs so URL stays stable local _serve_slug if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") log_info "Reusing existing serve slug: ${_serve_slug}" else _serve_slug=$(openssl rand -hex 3) echo "${_serve_slug}" >"${DEPLOY_DIR}/serve_slug" chmod 600 "${DEPLOY_DIR}/serve_slug" fi local _serve_path="${CONFIG_SERVE_DIR}/${_serve_slug}" mkdir -p "${_serve_path}" log_info "Config serve path: ${_serve_path}" local _lan_ip _public_ip _gns3_bin _gns3_ver _lan_ip=$(get_lan_ip) _public_ip=$(get_public_ip) _gns3_bin="/usr/bin/gns3server" [[ -x "${GNS3_VENV}/bin/gns3server" ]] && _gns3_bin="${GNS3_VENV}/bin/gns3server" # Extract exact semantic version, fallback to "unknown" _gns3_ver=$("${_gns3_bin}" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) || true [[ -z ${_gns3_ver} ]] && _gns3_ver="unknown" # VPN config files (encrypted by default) local _conf_passphrase="" if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then # Fatal if we can't determine public IP for VPN configs if [[ ${_public_ip} == "UNKNOWN" ]]; then log_fatal "Couldn't find public IP. VPN configs need a reachable IP." fi if [[ ${UNSAFE_CONFIGS} -eq 0 ]]; then # Persist passphrase across re-runs if [[ -f "${DEPLOY_DIR}/passphrase" ]]; then _conf_passphrase=$(cat "${DEPLOY_DIR}/passphrase") log_info "Reusing existing config passphrase" else _conf_passphrase=$(generate_passphrase) echo "${_conf_passphrase}" >"${DEPLOY_DIR}/passphrase" chmod 600 "${DEPLOY_DIR}/passphrase" fi fi fi if [[ ${WITH_OPENVPN} -eq 1 && -f /root/client.ovpn ]]; then if [[ ${UNSAFE_CONFIGS} -eq 1 ]]; then cp /root/client.ovpn "${_serve_path}/o" else encrypted_copy /root/client.ovpn "${_serve_path}/o" "${_conf_passphrase}" fi fi if [[ ${WITH_WIREGUARD} -eq 1 && -f "${WG_CONF_DIR}/client1.conf" ]]; then if [[ ${UNSAFE_CONFIGS} -eq 1 ]]; then cp "${WG_CONF_DIR}/client1.conf" "${_serve_path}/w" else encrypted_copy "${WG_CONF_DIR}/client1.conf" "${_serve_path}/w" "${_conf_passphrase}" fi fi # Also stash client configs in deploy dir for safekeeping [[ -f /root/client.ovpn ]] && cp /root/client.ovpn "${DEPLOY_DIR}/client.ovpn" [[ -f "${WG_CONF_DIR}/client1.conf" ]] && cp "${WG_CONF_DIR}/client1.conf" "${DEPLOY_DIR}/wg-client1.conf" # ## Landing page ########################################################## log_info "Fetching UI template..." local _script_dir _template_src _template_dst _template_ok _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" _template_src="${_script_dir}/${REPO_LANDING_HTML}" _template_dst="${CONFIG_SERVE_DIR}/index.html" _template_ok=0 if fetch_resource "${_template_dst}" \ "${_template_src}" \ "${REPO_BASE_URL}${REPO_LANDING_HTML}"; then _template_ok=1 else log_error "Failed to obtain template. Using basic fallback UI." echo "Error" >"${_template_dst}" echo "

GNS3 Config Server

" >>"${_template_dst}" echo "Template missing. Check console for details.

" >>"${_template_dst}" fi # Only run sed injections if we got the template (from either local or curl) if [[ ${_template_ok} -eq 1 ]]; then log_info "Injecting server data into UI template..." # Build warnings JSON array for injection local _warnings_json="[]" if [[ ${#WARNINGS[@]} -gt 0 ]]; then _warnings_json="[" for _w in "${WARNINGS[@]}"; do _warnings_json+="\"$(echo "${_w}" | sed 's/"/\\"/g')\"," done _warnings_json="${_warnings_json%,}]" fi # Use | as the sed delimiter to avoid conflicts with slashes in URLs/paths sed -i "s|{{LAN_IP}}|${_lan_ip}|g" "${_template_dst}" sed -i "s|{{PUBLIC_IP}}|${_public_ip}|g" "${_template_dst}" sed -i "s|{{HOSTNAME}}|$(hostname)|g" "${_template_dst}" sed -i "s|{{GNS3_VERSION}}|${_gns3_ver}|g" "${_template_dst}" sed -i "s|{{GNS3_PORT}}|${GNS3_PORT}|g" "${_template_dst}" sed -i "s|{{WITH_OPENVPN}}|${WITH_OPENVPN}|g" "${_template_dst}" sed -i "s|{{WITH_WIREGUARD}}|${WITH_WIREGUARD}|g" "${_template_dst}" sed -i "s|{{WITH_DOCKER}}|${WITH_DOCKER}|g" "${_template_dst}" sed -i "s|{{DISABLE_KVM}}|${DISABLE_KVM}|g" "${_template_dst}" sed -i "s|{{UNSAFE_CONFIGS}}|${UNSAFE_CONFIGS}|g" "${_template_dst}" sed -i "s|{{SERVE_SLUG}}|${_serve_slug}|g" "${_template_dst}" sed -i "s|{{SERVE_HOURS}}|${CONFIG_SERVE_HOURS}|g" "${_template_dst}" sed -i "s|{{SERVE_PORT}}|${CONFIG_SERVE_PORT}|g" "${_template_dst}" sed -i "s|{{WARNINGS_JSON}}|${_warnings_json}|g" "${_template_dst}" sed -i "s|{{OVPN_PORT}}|${OVPN_PORT}|g" "${_template_dst}" sed -i "s|{{WG_PORT}}|${WG_PORT}|g" "${_template_dst}" log_ok "Landing page generated" fi # ## Systemd units ########################################################## ensure_config /lib/systemd/system/gns3-config-serve.service root 644 <<_EOF_HTTP_SVC [Unit] Description=GNS3 ephemeral config server After=network-online.target [Service] Type=simple WorkingDirectory=${CONFIG_SERVE_DIR} ExecStart=/usr/bin/python3 -m http.server ${CONFIG_SERVE_PORT} --bind 0.0.0.0 Restart=no [Install] WantedBy=multi-user.target _EOF_HTTP_SVC ensure_config /lib/systemd/system/gns3-config-serve-stop.timer root 644 <<_EOF_CONF_TMR [Unit] Description=Stop GNS3 config server after ${CONFIG_SERVE_HOURS} hours [Timer] OnActiveSec=${CONFIG_SERVE_HOURS}h AccuracySec=1min Unit=gns3-config-serve-stop.service [Install] WantedBy=timers.target _EOF_CONF_TMR ensure_config /lib/systemd/system/gns3-config-serve-stop.service root 644 <<_EOF_CONF_SVC [Unit] Description=Stop and clean up GNS3 config server [Service] Type=oneshot ExecStart=/bin/systemctl stop gns3-config-serve.service ExecStart=/bin/systemctl disable gns3-config-serve.service ExecStart=/bin/rm -rf ${CONFIG_SERVE_DIR} ExecStart=/bin/systemctl disable gns3-config-serve-stop.timer ExecStart=/usr/sbin/ufw delete allow ${CONFIG_SERVE_PORT}/tcp _EOF_CONF_SVC enable_and_start gns3-config-serve.service enable_and_start gns3-config-serve-stop.timer log_ok "Config server live on port ${CONFIG_SERVE_PORT} (auto-stops in ${CONFIG_SERVE_HOURS}h)" # MOTD ensure_config /etc/update-motd.d/70-gns3-vpn root 755 <<_EOF_MOTD #!/bin/sh if systemctl is-active --quiet gns3-config-serve.service 2>/dev/null; then echo "" echo " GNS3 config: http://${_lan_ip}:${CONFIG_SERVE_PORT}/" echo " (auto-expires — download configs now)" fi _EOF_MOTD } ### Firewall (ufw) ########################################################## configure_firewall() { if [[ ${DISABLE_FIREWALL} -eq 1 ]]; then log_info "Skipping firewall configuration (--without-firewall)" return fi if ! command -v ufw &>/dev/null; then log_warn "No ufw detected. Ensure these ports are open in your firewall:" log_warn " ${REQUIRED_PORTS[*]}" return fi if ! ufw status | grep -q "Status: active"; then log_warn "ufw is installed but inactive. Skipping rule creation." log_warn " If you enable ufw later, allow ports: ${REQUIRED_PORTS[*]}" return fi log_info "Configuring ufw rules..." # Detect SSH source IP and local subnets for scoped rules local _remote_ssh_ip="" _remote_ssh_ip=$(echo "${SSH_CONNECTION:-}" | awk '{print $1}') local -a _local_subnets=() readarray -t _local_subnets < <(ip -o -f inet addr show | grep -E '(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | awk '{print $4}') if [[ -n ${_remote_ssh_ip} ]]; then log_info "SSH connection from ${_remote_ssh_ip}" else log_warn_sticky "No SSH session detected. Allow your IP in ufw or use VPN for remote access." fi if [[ ${#_local_subnets[@]} -gt 0 ]]; then log_info "RFC1918 subnets detected: ${_local_subnets[*]}" fi for _port in "${REQUIRED_PORTS[@]}"; do local _status="PERM" case "${_port}" in "${OVPN_PORT}" | "${WG_PORT}") # VPN ports: open from anywhere, both TCP and UDP ufw allow "${_port}"/tcp comment "GNS3 - ${_status} (VPN)" >/dev/null 2>&1 ufw allow "${_port}"/udp comment "GNS3 - ${_status} (VPN)" >/dev/null 2>&1 ;; "${CONFIG_SERVE_PORT}") _status="CLEAR" ufw allow "${_port}"/tcp comment "GNS3 - ${_status} (ephem. webserver)" >/dev/null 2>&1 ;; *) # Non-VPN ports: scope to SSH source and local subnets if [[ -n "${_remote_ssh_ip}" ]]; then ufw allow from "${_remote_ssh_ip}" to any port "${_port}" proto tcp \ comment "GNS3 - ${_status}" >/dev/null 2>&1 fi for _subnet in "${_local_subnets[@]}"; do ufw allow from "${_subnet}" to any port "${_port}" proto tcp \ comment "GNS3 - ${_status}" >/dev/null 2>&1 done ;; esac done # Enable forwarding in ufw if any VPN is configured if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then local _ufw_default="/etc/default/ufw" if [[ -f ${_ufw_default} ]]; then if grep -q '^DEFAULT_FORWARD_POLICY="DROP"' "${_ufw_default}"; then sed -i 's/^DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' "${_ufw_default}" log_info "Set UFW DEFAULT_FORWARD_POLICY=ACCEPT" fi fi ufw reload >/dev/null 2>&1 fi log_ok "ufw rules applied for ports: ${REQUIRED_PORTS[*]}" } ### Core setup functions ################################################# setup_groups() { log_info "Creating required system groups..." for _grp in "${REQUIRED_GROUPS[@]}"; do ensure_group "${_grp}" done log_ok "Groups: ${REQUIRED_GROUPS[*]}" } setup_gns3_user() { log_info "Setting up GNS3 service user..." mkdir -p "${GNS3_HOME}"/{images,projects,appliances,configs} if ! id "${GNS3_USER}" &>/dev/null; then local _groups_csv printf -v _groups_csv '%s,' "${REQUIRED_GROUPS[@]}" _groups_csv="${_groups_csv%,}" useradd --system \ --home-dir "${GNS3_HOME}" \ --no-create-home \ --comment "GNS3 server" \ --groups "${_groups_csv}" \ --shell /usr/sbin/nologin \ "${GNS3_USER}" log_ok "Created user ${GNS3_USER}" else for _grp in "${REQUIRED_GROUPS[@]}"; do usermod -aG "${_grp}" "${GNS3_USER}" done log_ok "User ${GNS3_USER} already exists — updated groups" fi chown -R "${GNS3_USER}:${GNS3_USER}" "${GNS3_HOME}" } propagate_groups_to_invoker() { local _invoker _invoker=$(detect_invoking_user) if [[ -n ${_invoker} ]]; then log_info "Adding ${_invoker} to groups: ${REQUIRED_GROUPS[*]}" for _grp in "${REQUIRED_GROUPS[@]}"; do usermod -aG "${_grp}" "${_invoker}" done log_ok "Group membership updated for ${_invoker} (log out/in to take effect)" fi } # Is repo present? gns3_repo_present() { grep -rEq "^.*ppa.launchpadcontent.net/gns3/ppa/ubuntu/" \ /etc/apt/sources.list.d/ 2>/dev/null } # Add gns3 repo (if not already present) add_gns3_repository() { if gns3_repo_present; then log_info "GNS3 PPA already configured" return fi log_info "Adding GNS3 PPA: ppa:gns3/${REPOSITORY}" apt-add-repository -y "ppa:gns3/${REPOSITORY}" >/dev/null log_ok "GNS3 repository added" } # Add Docker repo add_docker_repository() { if [[ -f ${DOCKER_KEYRING} ]]; then log_info "Docker GPG key already present — skipping download" else log_info "Adding Docker CE repository..." install -m 0755 -d /etc/apt/keyrings local _script_dir _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" # Try local GPG file, their URL, my URL, then fail if ! fetch_resource "${DOCKER_KEYRING}" \ "${_script_dir}/${REPO_DOCKER_GPG}" \ "${DOCKER_BASE_URL}/gpg" \ "${REPO_BASE_URL}${REPO_DOCKER_GPG}"; then log_fatal "Could not obtain Docker GPG key from any source." fi # Sanity check — make sure we got an actual key, not an error page if ! file "${DOCKER_KEYRING}" 2>/dev/null | grep -qiE 'pgp|openpgp|gpg'; then log_warn_sticky "Docker keyring file may not be a valid GPG key. Check: ${DOCKER_KEYRING}" fi chmod a+r "${DOCKER_KEYRING}" fi cat >/etc/apt/sources.list.d/docker.list </dev/null | grep -q "install ok installed"; then return 0 # at least one package needs install fi done return 1 # everything installed } # Updates, ugprades, and installs - quietly install_packages() { apt_retry update -qq if [[ "${NO_SYSTEM_UPGRADE}" -eq 0 ]]; then log_info "Upgrading system packages..." apt_retry upgrade -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" log_ok "System upgraded" else log_info "Skipping system upgrade" fi if needs_install; then log_info "Installing required packages..." NEEDRESTART_MODE=a apt_retry install -y "${REQUIRED_PKGS[@]}" log_ok "All packages installed" else log_ok "All required packages already installed" fi } ### GNS3 server configuration ##################################### configure_gns3() { log_info "Writing GNS3 server configuration..." local _listen_host="0.0.0.0" local _hw_accel="True" if [[ ${DISABLE_KVM} -eq 1 ]]; then _hw_accel="False" log_warn "KVM disabled — Qemu performance will be degraded" fi ensure_directory "${GNS3_CONF_DIR}" "${GNS3_USER}" 700 ensure_config "${GNS3_CONF_DIR}/gns3_server.conf" "${GNS3_USER}" 600 <<_EOF_GNS3_CONF ${DEPLOY_MARKER} [Server] host = ${_listen_host} port = ${GNS3_PORT} images_path = ${GNS3_HOME}/images projects_path = ${GNS3_HOME}/projects appliances_path = ${GNS3_HOME}/appliances configs_path = ${GNS3_HOME}/configs report_errors = True [Qemu] enable_hardware_acceleration = ${_hw_accel} require_hardware_acceleration = ${_hw_accel} _EOF_GNS3_CONF log_ok "GNS3 configuration written" } ### Systemd service ############################################# install_gns3_service() { log_info "Installing GNS3 systemd service..." local _gns3_bin="/usr/bin/gns3server" if [[ -x "${GNS3_VENV}/bin/gns3server" ]]; then _gns3_bin="${GNS3_VENV}/bin/gns3server" log_info "Using venv binary: ${_gns3_bin}" fi ensure_config "${GNS3_SERVICE_FILE}" root 644 <<_EOF_GNS3_SVC [Unit] Description=GNS3 server After=network-online.target Wants=network-online.target Conflicts=shutdown.target [Service] User=gns3 Group=gns3 PermissionsStartOnly=true EnvironmentFile=/etc/environment ExecStartPre=/bin/mkdir -p /var/log/gns3 /var/run/gns3 ExecStartPre=/bin/chown -R gns3:gns3 /var/log/gns3 /var/run/gns3 ExecStart=${_gns3_bin} --log /var/log/gns3/gns3.log ExecReload=/bin/kill -s HUP \$MAINPID Restart=on-failure RestartSec=5 LimitNOFILE=16384 [Install] WantedBy=multi-user.target _EOF_GNS3_SVC enable_and_start gns3 log_ok "GNS3 service installed and enabled" } ### OpenVPN setup ################################################ configure_openvpn() { log_info "Configuring OpenVPN..." local _public_ip _public_ip=$(get_public_ip) if [[ ${_public_ip} == "UNKNOWN" ]]; then log_fatal "Could not determine public IP for OpenVPN configuration." fi log_info "Public IP detected: ${_public_ip}" log_info "Generating OpenVPN keys..." ensure_directory "${OVPN_CONF_DIR}" root 755 # If using *RSA* if [[ ${USE_LEGACY_RSA} -eq 1 ]]; then log_info "Using legacy RSA crypto (DH params may take a minute)..." if [[ -f "${OVPN_CONF_DIR}/dh.pem" ]]; then log_info "Already have DH params. Skipping." else openssl dhparam -quiet 2048 | ensure_config "${OVPN_CONF_DIR}/dh.pem" root 600 fi require_file "${OVPN_CONF_DIR}/dh.pem" "OpenVPN DH params" || log_fatal "DH param generation failed" if [[ -f "${OVPN_CONF_DIR}/key.pem" ]]; then log_info "Already have OpenVPN key. Skipping." else openssl genrsa 2048 | ensure_config "${OVPN_CONF_DIR}/key.pem" root 600 fi # If using *ECC* else log_info "Using elliptic curve crypto (P-384)..." if [[ -f "${OVPN_CONF_DIR}/key.pem" ]]; then log_info "Already have OpenVPN key. Skipping." else openssl ecparam -name secp384r1 -genkey -noout | ensure_config "${OVPN_CONF_DIR}/key.pem" root 600 fi fi require_file "${OVPN_CONF_DIR}/key.pem" "OpenVPN private key" || log_fatal "Key generation failed" if [[ -f "${OVPN_CONF_DIR}/csr.pem" ]]; then log_info "csr.pem already present." else openssl req -new -key "${OVPN_CONF_DIR}/key.pem" -out "${OVPN_CONF_DIR}/csr.pem" \ -subj /CN=OpenVPN/ 2>/dev/null fi require_file "${OVPN_CONF_DIR}/csr.pem" "OpenVPN CSR" || log_fatal "CSR generation failed" if [[ -f "${OVPN_CONF_DIR}/cert.pem" ]]; then log_info "cert.pem already present." else openssl x509 -req -in "${OVPN_CONF_DIR}/csr.pem" -out "${OVPN_CONF_DIR}/cert.pem" \ -signkey "${OVPN_CONF_DIR}/key.pem" -days 3650 &>/dev/null fi require_file "${OVPN_CONF_DIR}/cert.pem" "OpenVPN certificate" || log_fatal "Certificate signing failed" local _dh_client_block="" local _dh_server_line="dh none" if [[ ${USE_LEGACY_RSA} -eq 1 ]]; then _dh_client_block="$(cat "${OVPN_CONF_DIR}/dh.pem")" _dh_server_line="dh dh.pem" fi ensure_config /root/client.ovpn root 600 <<_EOF_OVPN_CLI client nobind dev tun $(cat "${OVPN_CONF_DIR}/key.pem") $(cat "${OVPN_CONF_DIR}/cert.pem") $(cat "${OVPN_CONF_DIR}/cert.pem") ${_dh_client_block} remote ${_public_ip} ${OVPN_PORT} udp _EOF_OVPN_CLI ensure_config "${OVPN_CONF_DIR}/udp${OVPN_PORT}.conf" root 644 <<_EOF_OVPN server 172.16.253.0 255.255.255.0 verb 3 duplicate-cn key key.pem ca cert.pem cert cert.pem ${_dh_server_line} keepalive 10 60 persist-key persist-tun proto udp port ${OVPN_PORT} dev tun${OVPN_PORT} status openvpn-status-${OVPN_PORT}.log log-append /var/log/openvpn-udp${OVPN_PORT}.log _EOF_OVPN enable_and_start openvpn log_ok "OpenVPN configured" } ### WireGuard setup #############################################─ configure_wireguard() { log_info "Configuring WireGuard..." local _public_ip _public_ip=$(get_public_ip) if [[ ${_public_ip} == "UNKNOWN" ]]; then log_fatal "Could not determine public IP for WireGuard configuration." fi ensure_directory "${WG_CONF_DIR}" root 700 if [[ ! -f "${WG_CONF_DIR}/server.key" ]]; then ( umask 077 wg genkey | tee "${WG_CONF_DIR}/server.key" | wg pubkey >"${WG_CONF_DIR}/server.pub" ) fi require_file "${WG_CONF_DIR}/server.key" "WireGuard server private key" || log_fatal "WG server keygen failed" require_file "${WG_CONF_DIR}/server.pub" "WireGuard server public key" || log_fatal "WG server keygen failed" local _server_privkey _server_pubkey _server_privkey=$(cat "${WG_CONF_DIR}/server.key") _server_pubkey=$(cat "${WG_CONF_DIR}/server.pub") if [[ ! -f "${WG_CONF_DIR}/client1.key" ]]; then ( umask 077 wg genkey | tee "${WG_CONF_DIR}/client1.key" | wg pubkey >"${WG_CONF_DIR}/client1.pub" ) fi require_file "${WG_CONF_DIR}/client1.key" "WireGuard client private key" || log_fatal "WG client keygen failed" local _client_privkey _client_pubkey _client_privkey=$(cat "${WG_CONF_DIR}/client1.key") _client_pubkey=$(cat "${WG_CONF_DIR}/client1.pub") ensure_config "${WG_CONF_DIR}/wg0.conf" root 600 <<_EOF_WG_SRV [Interface] Address = 172.16.254.1/24 ListenPort = ${WG_PORT} PrivateKey = ${_server_privkey} PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE [Peer] # client1 PublicKey = ${_client_pubkey} AllowedIPs = 172.16.254.2/32 _EOF_WG_SRV ensure_config "${WG_CONF_DIR}/client1.conf" root 600 <<_EOF_WG_CLI [Interface] Address = 172.16.254.2/24 PrivateKey = ${_client_privkey} DNS = 1.1.1.1 [Peer] PublicKey = ${_server_pubkey} Endpoint = ${_public_ip}:${WG_PORT} AllowedIPs = 172.16.253.0/24, 172.16.254.0/24 PersistentKeepalive = 25 _EOF_WG_CLI enable_and_start wg-quick@wg0 log_ok "WireGuard configured" } ### Welcome setup ##############################################################─ configure_welcome() { log_info "Setting up GNS3-VM welcome console..." ensure_config "/etc/sudoers.d/gns3" root 440 <<_EOF gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-key gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-get gns3 ALL = (ALL) NOPASSWD: /usr/sbin/reboot _EOF if ! visudo -cf /etc/sudoers.d/gns3 &>/dev/null; then log_warn_sticky "sudoers fragment failed syntax check — removing" rm -f /etc/sudoers.d/gns3 fi curl -fsSL https://raw.githubusercontent.com/GNS3/gns3-server/master/scripts/welcome.py \ -o /usr/local/bin/welcome.py chmod 755 /usr/local/bin/welcome.py chown "${GNS3_USER}:${GNS3_USER}" /usr/local/bin/welcome.py mkdir -p /etc/systemd/system/getty@tty1.service.d ensure_config "/etc/systemd/system/getty@tty1.service.d/override.conf" root 644 <<'_EOF' [Service] ExecStart= ExecStart=-/sbin/agetty -a gns3 --noclear %I $TERM _EOF grep -q 'welcome.py' "${GNS3_HOME}/.bashrc" 2>/dev/null || echo "python3 /usr/local/bin/welcome.py" >>"${GNS3_HOME}/.bashrc" echo "gns3:gns3" | chpasswd usermod --shell /bin/bash "${GNS3_USER}" usermod -aG sudo "${GNS3_USER}" log_ok "Welcome console configured" } ### Start services ########################################################## start_services() { log_info "Starting GNS3 service..." systemctl restart gns3 sleep 2 if systemctl is-active --quiet gns3; then log_ok "GNS3 service running" else log_warn_sticky "GNS3 service failed to start" log_warn_sticky " Check logs: journalctl -u gns3 --no-pager -n 20" fi if [[ ${WITH_DOCKER} -eq 1 ]]; then enable_and_start docker if systemctl is-active --quiet docker; then log_ok "Docker service running" else log_warn_sticky "Docker service failed to start" fi fi } ### Post-install validation ################################################## # Helper: curl a URL and report success/failure. Returns 0/1 for chaining. probe_http() { local _url="$1" _desc="$2" if curl -sf --max-time 5 "${_url}" &>/dev/null; then log_ok "${_desc} responding: ${_url}" return 0 fi log_warn_sticky "${_desc} not responding: ${_url}" return 1 } # Helper: check if a TCP (default) or UDP port is listening probe_port_listening() { local _port="$1" _desc="$2" _proto="${3:-tcp}" local _flag="-tlnH" [[ "${_proto}" == "udp" ]] && _flag="-ulnH" if ss ${_flag} 2>/dev/null | grep -q ":${_port} "; then log_ok "${_desc} listening on ${_proto} port ${_port}" return 0 fi log_warn_sticky "${_desc} not listening on ${_proto} port ${_port}" return 1 } validate() { log_info "Running post-install validation..." local _lan_ip _serve_slug _lan_ip=$(get_lan_ip) sleep 3 # GNS3 API responding if ! probe_http "http://${_lan_ip}:${GNS3_PORT}/v2/version" "GNS3 API"; then probe_http "http://localhost:${GNS3_PORT}/v2/version" "GNS3 API (localhost fallback)" || log_warn_sticky " Check: journalctl -u gns3 --no-pager -n 20" fi # Port listening checks probe_port_listening "${GNS3_PORT}" "GNS3 server" || true probe_port_listening "${CONFIG_SERVE_PORT}" "Config server" || true # Config server landing page probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/" "Landing page" || true # VPN config downloadable through the slug path if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") if [[ ${WITH_WIREGUARD} -eq 1 ]]; then probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/w" "WireGuard config download" || true fi if [[ ${WITH_OPENVPN} -eq 1 ]]; then probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/o" "OpenVPN config download" || true fi fi # VPN tunnel interfaces if [[ ${WITH_WIREGUARD} -eq 1 ]]; then probe_port_listening "${WG_PORT}" "WireGuard" udp || true if ip link show wg0 &>/dev/null; then log_ok "WireGuard interface wg0 is up" else log_warn_sticky "WireGuard interface wg0 not found" fi fi if [[ ${WITH_OPENVPN} -eq 1 ]]; then if ip link show "tun${OVPN_PORT}" &>/dev/null; then log_ok "OpenVPN interface tun${OVPN_PORT} is up" else log_warn_sticky "OpenVPN interface tun${OVPN_PORT} not found" fi fi # Config server auto-stop timer armed if systemctl list-timers --all 2>/dev/null | grep -q "gns3-config-serve-stop"; then log_ok "Config server auto-stop timer armed" else log_warn_sticky "Config server auto-stop timer NOT armed — server will not expire on schedule" fi # Sysctl hardening applied if [[ -f /etc/sysctl.d/90-gns3-hardening.conf ]]; then log_ok "Sysctl hardening file present" else log_warn_sticky "Sysctl hardening file missing" fi # IP forwarding enabled when VPN configured if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then if [[ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" == "1" ]]; then log_ok "IPv4 forwarding enabled" else log_warn_sticky "IPv4 forwarding NOT enabled — VPN routing will fail" fi fi # Deploy dir perms — secrets must not be world-readable if [[ -d ${DEPLOY_DIR} ]]; then local _mode _mode=$(stat -c %a "${DEPLOY_DIR}" 2>/dev/null) if [[ ${_mode} == "700" ]]; then log_ok "Deploy directory permissions: 700" else log_warn_sticky "Deploy directory permissions are ${_mode}, expected 700" fi fi # Docker if [[ ${WITH_DOCKER} -eq 1 ]]; then if docker info &>/dev/null; then log_ok "Docker engine responding" else log_warn_sticky "Docker installed but not responding" fi fi # GNS3 binary sanity local _gns3_bin="/usr/bin/gns3server" [[ -x "${GNS3_VENV}/bin/gns3server" ]] && _gns3_bin="${GNS3_VENV}/bin/gns3server" if "${_gns3_bin}" --version &>/dev/null; then log_ok "gns3server binary: $("${_gns3_bin}" --version 2>&1 | head -1)" else log_warn_sticky "gns3server binary cannot execute — possible Python venv issue" log_warn_sticky " Binary: ${_gns3_bin}" log_warn_sticky " Check: ${_gns3_bin} --version" fi # UFW active when firewall configuration was attempted if [[ ${DISABLE_FIREWALL} -eq 0 ]] && command -v ufw &>/dev/null; then if ufw status 2>/dev/null | grep -q "Status: active"; then log_ok "UFW active" else log_warn_sticky "UFW is installed but not active — no firewall rules enforced" fi fi if [[ ${#WARNINGS[@]} -eq 0 ]]; then log_ok "All post-install checks passed" fi } ### Summary banner ########################################################## print_summary() { local _lan_ip _public_ip _lan_ip=$(get_lan_ip) _public_ip=$(get_public_ip) echo "" printf "${GREEN}${BOLD} GNS3 Server Install Complete${NC}\n" printf " ##################################\n\n" printf " Server: http://%s:%s\n" "${_lan_ip}" "${GNS3_PORT}" printf " Config: %s/gns3_server.conf\n" "${GNS3_CONF_DIR}" printf " Data: %s/\n" "${GNS3_HOME}" echo " Logs: /var/log/gns3/gns3.log" echo " Status: systemctl status gns3" echo "" if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then echo " VPN" echo " ##################################" if [[ ${WITH_WIREGUARD} -eq 1 ]]; then printf " WireGuard: %s:%s\n" "${_public_ip}" "${WG_PORT}" printf " Tunnel: 172.16.254.1:%s\n" "${GNS3_PORT}" fi if [[ ${WITH_OPENVPN} -eq 1 ]]; then printf " OpenVPN: %s:%s/udp\n" "${_public_ip}" "${OVPN_PORT}" printf " Tunnel: 172.16.253.1:%s\n" "${GNS3_PORT}" fi echo "" fi # Config server info if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then local _serve_slug _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") printf " Landing page:\n" printf " http://%s:%s/\n" "${_lan_ip}" "${CONFIG_SERVE_PORT}" printf " ${YELLOW}Expires in %sh${NC}\n\n" "${CONFIG_SERVE_HOURS}" fi # Encrypted config download commands if [[ -f "${DEPLOY_DIR}/passphrase" ]]; then local _pass _serve_slug _pass=$(cat "${DEPLOY_DIR}/passphrase") _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") echo " Secure config download" echo " ##################################" if [[ ${WITH_WIREGUARD} -eq 1 ]]; then printf " ${CYAN}WireGuard:${NC}\n" echo " curl -s http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/w | openssl aes-256-cbc -d -pbkdf2 -pass pass:${_pass} -a > wg.conf" echo "" fi if [[ ${WITH_OPENVPN} -eq 1 ]]; then printf " ${CYAN}OpenVPN:${NC}\n" echo " curl -s http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/o | openssl aes-256-cbc -d -pbkdf2 -pass pass:${_pass} -a > conf.ovpn" echo "" fi fi if [[ ${DISABLE_KVM} -eq 1 ]]; then printf " ${YELLOW}KVM: DISABLED${NC}\n" echo "" fi local _invoker _invoker=$(detect_invoking_user) if [[ -n ${_invoker} ]]; then printf " ${CYAN}Log out/in as ${_invoker}${NC}" echo " for group changes" echo "" fi if [[ ${#WARNINGS[@]} -gt 0 ]]; then printf " ${YELLOW}${BOLD}Action Required${NC}\n" echo " ##################################" for _warning in "${WARNINGS[@]}"; do printf " ${YELLOW}!${NC} %s\n" "${_warning}" done echo "" fi } ############################################################################## # MAIN ############################################################################## main() { export DEBIAN_FRONTEND="noninteractive" if ! is_root; then log_fatal "Must run as root. Try: sudo $0" fi # Ensure exclusive execution exec 9>/var/lock/gns3-install.lock if ! flock -n 9; then log_fatal "Another instance of this script is already running." fi log_info "Bootstrapping essential packages..." apt-get update -qq >/dev/null 2>&1 && log_info "Update done" apt-get install -y -qq curl software-properties-common >/dev/null 2>&1 && log_ok "Bootstrap complete" set -Eeuo pipefail trap 'on_error "${LINENO}" "${BASH_COMMAND}" "$?"' ERR trap 'log_warn "Interrupted by user."; exit 130' INT TERM # Initialize deploy directory for secrets and state mkdir -p "${DEPLOY_DIR}" chmod 700 "${DEPLOY_DIR}" # Log everything to file for post-mortem exec > >(tee -a "${DEPLOY_DIR}/install.log") 2>&1 preflight_checks readonly OS_CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME}}" # # Phase 1: Repositories ################################################## add_gns3_repository if [[ "${WITH_DOCKER}" -eq 1 ]]; then add_docker_repository; fi # # Phase 2: Packages (single apt pass) #################################### install_packages # # Phase 3: Users and groups ############################################## setup_groups setup_gns3_user propagate_groups_to_invoker # # Phase 4: Configuration ################################################─ configure_gns3 install_gns3_service if [[ ${WITH_OPENVPN} -eq 1 ]]; then configure_openvpn; fi if [[ ${WITH_WIREGUARD} -eq 1 ]]; then configure_wireguard; fi if [[ ${WITH_WELCOME} -eq 1 ]]; then configure_welcome; fi if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then enable_ip_forwarding fi setup_config_server configure_firewall apply_sysctl_hardening # ## Phase 5: Start ########################################################## start_services # ## Phase 6: Validate ###################################################### validate # ## Phase 7: Welcome post-install repair #################################### if [[ ${WITH_WELCOME} -eq 1 ]]; then python3 -c 'import sys; sys.path.append("/usr/local/bin/"); import welcome; ws = welcome.Welcome_dialog(); ws.repair_remote_install()' || true fi # ## Done #################################################################### print_summary } if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then main "$@" fi