#!/usr/bin/env bash # shellcheck shell=bash set -euo pipefail IFS=$'\n\t' readonly _BINS_="/usr/local/bin:/usr/bin:/bin" readonly _SBINS_="/usr/local/sbin:/usr/sbin:/sbin" export PATH="${_BINS_}:${_SBINS_}" readonly PATH ############################################################ # Filename : gns3-remote.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 # ######################################################################## ### Constants ################################################################ # This repo readonly _REPO_BASE_URL_="https://raw.githubusercontent.com/lark-cx/gns3-server-deploy/refs/heads/main/" readonly _REPO_LANDING_HTML_="templates/template.html" readonly _REPO_DOCKER_GPG_="resources/docker-gpg" readonly _REPO_WELCOME_PY_="resources/welcome.py" readonly _TEMPLATE_CONFIG_SERVE_SERVICE_="templates/gns3-config-serve.service.temp" readonly _TEMPLATE_CONFIG_SERVE_STOP_SERVICE_="templates/gns3-config-serve-stop.service" readonly _TEMPLATE_CONFIG_SERVE_TIMER_="templates/gns3-config-serve.timer.temp" readonly _TEMPLATE_GNS3_SERVICE_="templates/gns3.service.temp" readonly _TEMPLATE_GNS3_SERVER_CONF_="templates/gns3_server.conf.temp" readonly _TEMPLATE_MOTD_="templates/70-gns3-vpn.temp" readonly _TEMPLATE_WG_CLIENT_="templates/client1.conf.temp" readonly _TEMPLATE_WG_SERVER_="templates/wg0.conf.temp" # 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_="/etc/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 _BOLD_RED_=$'\033[1;31m' readonly _RST_=$'\033[0m' # =================================================================== # LOGGING # =================================================================== _log() { local _level="${1:-}" shift || true local _message="${*}" local _ts="" local _color="${_RST_}" local _prefix="LOG" _ts="$(date +"%H:%M:%S")" case "${_level}" in info) _color="${_CYAN_}"; _prefix="INFO" ;; ok) _color="${_GREEN_}"; _prefix="OK" ;; warn) _color="${_YELLOW_}"; _prefix="WARN" ;; error) _color="${_RED_}"; _prefix="ERROR" ;; fatal) _color="${_BOLD_RED_}"; _prefix="FATAL" ;; esac printf "%s%s [%s]: %s%s\n" \ "${_color}" "${_ts}" "${_prefix}" "${_message}" "${_RST_}" >&2 } log_info() { _log info "${@}"; } log_ok() { _log ok "${@}"; } log_warn() { _log warn "${@}"; } log_error() { _log error "${@}"; } log_fatal() { _log fatal "${@}" exit 1 } log_warn_sticky() { _log warn "${@}" WARN_MESSAGES+=("${*}") WARNINGS_OCCURRED=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 # shellcheck disable=SC2310 systemctl daemon-reload >/dev/null 2>&1 || true sleep 2 # shellcheck disable=SC2310 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 ]]; } ### Mutable arrays (built up by option flags) ############################## REQUIRED_CMDS=(apt apt-add-repository dpkg chown chmod useradd usermod lsmod modprobe systemctl ss ip openssl grep sed awk hostname mktemp tee file stat) REQUIRED_PORTS=("${_GNS3_PORT_}") REQUIRED_GROUPS=(kvm ubridge) REQUIRED_MODS=(kvm) WARN_MESSAGES=() WARNINGS_OCCURRED=0 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${_RST_} — GNS3 remote server installer ${_BOLD_}Usage:${_RST_} sudo ${0} [OPTIONS] ${_BOLD_}Options:${_RST_} --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 --legacy-rsa Use RSA instead of ECC (OpenVPN) --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 ########################################################## parse_args() { local _arg="" while [[ "${#}" -gt 0 ]]; do _arg="${1:-}" case "${_arg}" 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) shift if [[ "${#}" -eq 0 || "${1:-}" == --* ]]; then log_fatal "--custom-repository requires a repository name" fi REPOSITORY="${1}" shift ;; -h | --help) show_help exit 0 ;; *) log_fatal "Unknown option: ${_arg}" ;; esac done } ### Roll up arrays based on flags ########################################## apply_option_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 } ### Boolean Helpers ######################################################## file_exists() { [[ -f "${1:-}" ]]; } var_exists() { [[ -n "${1:-}" ]]; } dir_exists() { [[ -d "${1:-}" ]]; } port_open() { ss -tlnp | grep -q ":${1:-} " ; } ### Preflight checks ######################################################## preflight_checks() { local _has_errors=0 if [[ "${OSTYPE}" != linux-gnu* ]]; then log_fatal "This script requires Linux (detected: ${OSTYPE})." fi # shellcheck disable=SC2310 if file_exists /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 if ! command -v "${_cmd}" &>/dev/null; then _missing_cmds+=("${_cmd}") fi done # shellcheck disable=SC2310 if [[ "${#_missing_cmds[@]}" -gt 0 ]]; then log_error "Missing commands: ${_missing_cmds[*]}" _has_errors=1 fi # Detect reconfigure: our config marker + gns3 service running # # shellcheck disable=SC2310 if dir_exists "${_DEPLOY_DIR_}"; then log_info "Existing installation detected — running in reconfigure mode" systemctl stop gns3 2>/dev/null || true systemctl stop gns3-config-serve.service 2>/dev/null || true fi local -a _busy_ports=() for _port in "${REQUIRED_PORTS[@]}"; do if ss -lnH | 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() { if ! getent group "${1:-}" &>/dev/null; then groupadd --system "${1:-}" fi } # 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="" if command -v dig &>/dev/null; then _ip=$(dig @ns1.google.com -t txt o-o.myaddr.l.google.com +short -4 2>/dev/null | sed 's/"//g') || true if var_exists "${_ip}"; then echo "${_ip}" return fi fi _ip=$(curl -sf --max-time 5 https://icanhazip.com 2>/dev/null) # shellcheck disable=SC2310 if var_exists "${_ip}"; then echo "${_ip}" return fi _ip=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null) # shellcheck disable=SC2310 if var_exists "${_ip}"; then echo "${_ip}" return fi echo "UNKNOWN" } # Get LAN IP — primary interface address get_lan_ip() { local _ip _ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [[ -n "${_ip}" ]]; then echo "${_ip}" else echo "127.0.0.1" fi } # 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 enc -aes-256-cbc -pbkdf2 -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" local _key="" local _val="" : >"${_sysctl_file}" for _key in "${!_sysctls[@]}"; do _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}" # shellcheck disable=SC2310 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:-}" # shellcheck disable=SC2310 if dir_exists "${_path}"; then log_info "Directory exists: ${_path}" else mkdir -p "${_path}" log_info "Created directory: ${_path}" fi chown "${_owner}:${_owner}" "${_path}" # shellcheck disable=SC2310 if var_exists "${_mode}"; then chmod "${_mode}" "${_path}" fi } # 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 # shellcheck disable=SC2310 require_file "${_path}" "config" || return 1 log_info "Wrote config: ${_path} (mode ${_mode}, owner ${_owner})" } fetch_file() { local _name="${1:-}" local _script_dir _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" # shellcheck disable=SC2310 if file_exists "${_script_dir}/${_name}"; then cat "${_script_dir}/${_name}" elif curl -fsSL --max-time 10 "${_REPO_BASE_URL_}${_name}" 2>/dev/null; then : # curl already wrote to stdout else return 1 fi } # TODO # If we download file, I'd like to do checksum validation check_integrity() { : } render_template() { local _content="" local _kv="" local _key="" local _val="" _content="$(cat)" for _kv in "${@}"; do _key="${_kv%%=*}" _val="${_kv#*=}" _content="${_content//\{\{${_key}\}\}/${_val}}" _content="${_content//\$\{${_key}\}/${_val}}" done printf "%s\n" "${_content}" } json_escape() { local _value="${1:-}" _value="${_value//\\/\\\\}" _value="${_value//\"/\\\"}" _value="${_value//$'\n'/\\n}" printf "%s" "${_value}" } ### 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) # shellcheck disable=SC2310 if dir_exists "${_CONFIG_SERVE_DIR_}"; then rm -rf "${_CONFIG_SERVE_DIR_}" fi # Ensure deploy directory exists for secrets ensure_directory "${_DEPLOY_DIR_}" root 700 # Persist slug across re-runs so URL stays stable local _serve_slug # shellcheck disable=SC2310 if file_exists "${_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="${SERVER_PRIVATE_IP}" _public_ip="${SERVER_PUBLIC_IP}" _gns3_bin="/usr/bin/gns3server" if [[ -x "${_GNS3_VENV_}/bin/gns3server" ]]; then _gns3_bin="${_GNS3_VENV_}/bin/gns3server" fi # 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 if [[ -z "${_gns3_ver}" ]]; then _gns3_ver="unknown" fi # 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 # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 if file_exists /root/client.ovpn; then cp /root/client.ovpn "${_DEPLOY_DIR_}/client.ovpn" fi # shellcheck disable=SC2310 if file_exists "${_WG_CONF_DIR_}/client1.conf"; then cp "${_WG_CONF_DIR_}/client1.conf" "${_DEPLOY_DIR_}/wg-client1.conf" fi # ## Landing page ########################################################## log_info "Rendering UI template..." local _template_dst="${_CONFIG_SERVE_DIR_}/index.html" local _warnings_json="[]" local _w="" if [[ "${#WARN_MESSAGES[@]}" -gt 0 ]]; then _warnings_json="[" for _w in "${WARN_MESSAGES[@]}"; do _warnings_json+="\"$(json_escape "${_w}")\"," done _warnings_json="${_warnings_json%,}]" fi # shellcheck disable=SC2310 if fetch_file "${_REPO_LANDING_HTML_}" | render_template \ "LAN_IP=${_lan_ip}" \ "PUBLIC_IP=${_public_ip}" \ "HOSTNAME=${SERVER_HOSTNAME}" \ "GNS3_VERSION=${_gns3_ver}" \ "GNS3_PORT=${_GNS3_PORT_}" \ "WITH_OPENVPN=${WITH_OPENVPN}" \ "WITH_WIREGUARD=${WITH_WIREGUARD}" \ "WITH_DOCKER=${WITH_DOCKER}" \ "DISABLE_KVM=${DISABLE_KVM}" \ "UNSAFE_CONFIGS=${UNSAFE_CONFIGS}" \ "SERVE_SLUG=${_serve_slug}" \ "SERVE_HOURS=${_CONFIG_SERVE_HOURS_}" \ "_SERVE_PORT_=${_CONFIG_SERVE_PORT_}" \ "WARNINGS_JSON=${_warnings_json}" \ "_OVPN_PORT_=${_OVPN_PORT_}" \ "_WG_PORT_=${_WG_PORT_}" | ensure_config "${_template_dst}" root 644; then log_ok "Landing page generated" else log_error "Failed to obtain template. Using basic fallback UI." printf "%s\n" \ "GNS3 Config Server" \ "

GNS3 Config Server

" \ "

Template missing. Check console for details.

" | ensure_config "${_template_dst}" root 644 fi # ## Systemd units ########################################################## fetch_file "${_TEMPLATE_CONFIG_SERVE_SERVICE_}" | render_template \ "_CONFIG_SERVE_DIR_=${_CONFIG_SERVE_DIR_}" \ "_CONFIG_SERVE_PORT_=${_CONFIG_SERVE_PORT_}" | ensure_config /etc/systemd/system/gns3-config-serve.service root 644 fetch_file "${_TEMPLATE_CONFIG_SERVE_TIMER_}" | render_template \ "_CONFIG_SERVE_HOURS_=${_CONFIG_SERVE_HOURS_}" | ensure_config /etc/systemd/system/gns3-config-serve-stop.timer root 644 fetch_file "${_TEMPLATE_CONFIG_SERVE_STOP_SERVICE_}" | render_template \ "_CONFIG_SERVE_DIR_=${_CONFIG_SERVE_DIR_}" \ "_CONFIG_SERVE_PORT_=${_CONFIG_SERVE_PORT_}" | ensure_config /etc/systemd/system/gns3-config-serve-stop.service root 644 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 fetch_file "${_TEMPLATE_MOTD_}" | render_template \ "_lan_ip=${_lan_ip}" \ "_CONFIG_SERVE_PORT_=${_CONFIG_SERVE_PORT_}" | ensure_config /etc/update-motd.d/70-gns3-vpn root 755 } ### 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" # shellcheck disable=SC2310 if file_exists "${_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? Handles old deb and new deb822 URIs: format gns3_repo_present() { grep -rEq \ "^(deb|URIs:)[[:space:]].*ppa\.launchpadcontent\.net/gns3/${REPOSITORY}" \ /etc/apt/sources.list.d/ 2>/dev/null } # Add gns3 repo (if not already present) add_gns3_repository() { # shellcheck disable=SC2310 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() { # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 if ! fetch_file "${_REPO_DOCKER_GPG_}" > "${_DOCKER_KEYRING_}"; 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 # shellcheck disable=SC2310 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 # shellcheck disable=SC2310 fetch_file "${_TEMPLATE_GNS3_SERVER_CONF_}" | render_template \ "_DEPLOY_MARKER_=${_DEPLOY_MARKER_}" \ "_listen_host=${_listen_host}" \ "_GNS3_PORT_=${_GNS3_PORT_}" \ "_GNS3_HOME_=${_GNS3_HOME_}" \ "_hw_accel=${_hw_accel}" | ensure_config "${_GNS3_CONF_DIR_}/gns3_server.conf" "${_GNS3_USER_}" 600 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 fetch_file "${_TEMPLATE_GNS3_SERVICE_}" | render_template \ "_GNS3_USER_=${_GNS3_USER_}" \ "_gns3_bin=${_gns3_bin}" | ensure_config "${_GNS3_SERVICE_FILE_}" root 644 enable_and_start gns3 log_ok "GNS3 service installed and enabled" } ### OpenVPN setup ################################################ configure_openvpn() { log_info "Configuring OpenVPN..." local _public_ip="${SERVER_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)..." # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 require_file "${_OVPN_CONF_DIR_}/dh.pem" "OpenVPN DH params" || log_fatal "DH param generation failed" # shellcheck disable=SC2310 if file_exists "${_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)..." # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 require_file "${_OVPN_CONF_DIR_}/key.pem" "OpenVPN private key" || log_fatal "Key generation failed" # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 require_file "${_OVPN_CONF_DIR_}/csr.pem" "OpenVPN CSR" || log_fatal "CSR generation failed" # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2310 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 # shellcheck disable=SC2310 enable_and_start openvpn || true log_ok "OpenVPN configured" } ### WireGuard setup ############################################# configure_wireguard() { log_info "Configuring WireGuard..." local _public_ip="${SERVER_PUBLIC_IP}" if [[ "${_public_ip}" == "UNKNOWN" ]]; then log_fatal "Could not determine public IP for WireGuard configuration." fi # shellcheck disable=SC2310 ensure_directory "${_WG_CONF_DIR_}" root 700 # shellcheck disable=SC2310 if ! file_exists "${_WG_CONF_DIR_}/server.key"; then ( umask 077 wg genkey | tee "${_WG_CONF_DIR_}/server.key" | wg pubkey >"${_WG_CONF_DIR_}/server.pub" ) fi # shellcheck disable=SC2310 require_file "${_WG_CONF_DIR_}/server.key" "WireGuard server private key" || log_fatal "WG server keygen failed" # shellcheck disable=SC2310 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") # shellcheck disable=SC2310 if ! file_exists "${_WG_CONF_DIR_}/client1.key"; then ( umask 077 wg genkey | tee "${_WG_CONF_DIR_}/client1.key" | wg pubkey >"${_WG_CONF_DIR_}/client1.pub" ) fi # shellcheck disable=SC2310 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") local _default_iface _default_iface=$(ip route show default | awk '{print $5}' | head -1) if [[ -z "${_default_iface}" ]]; then _default_iface="eth0" fi # shellcheck disable=SC2310 fetch_file "${_TEMPLATE_WG_SERVER_}" | render_template \ "_WG_PORT_=${_WG_PORT_}" \ "_server_privkey=${_server_privkey}" \ "_default_iface=${_default_iface}" \ "_client_pubkey=${_client_pubkey}" | ensure_config "${_WG_CONF_DIR_}/wg0.conf" root 600 # shellcheck disable=SC2310 fetch_file "${_TEMPLATE_WG_CLIENT_}" | render_template \ "_client_privkey=${_client_privkey}" \ "_server_pubkey=${_server_pubkey}" \ "_public_ip=${_public_ip}" \ "_WG_PORT_=${_WG_PORT_}" | ensure_config "${_WG_CONF_DIR_}/client1.conf" root 600 # shellcheck disable=SC2310 enable_and_start wg-quick@wg0 || true log_ok "WireGuard configured" } ### Welcome setup ############################################################## configure_welcome() { log_info "Setting up GNS3-VM welcome console..." ensure_config "/etc/sudoers.d/${_GNS3_USER_}" root 440 <<_EOF ${_GNS3_USER_} ALL = (ALL) NOPASSWD: /usr/bin/apt-key ${_GNS3_USER_} ALL = (ALL) NOPASSWD: /usr/bin/apt-get ${_GNS3_USER_} ALL = (ALL) NOPASSWD: /usr/sbin/reboot _EOF if ! visudo -cf "/etc/sudoers.d/${_GNS3_USER_}" &>/dev/null; then log_warn_sticky "sudoers fragment failed syntax check — removing" rm -f "/etc/sudoers.d/${_GNS3_USER_}" fi # shellcheck disable=SC2310 if ! fetch_file "${_REPO_WELCOME_PY_}" > /usr/local/bin/welcome.py; then log_fatal "Could not obtain welcome.py from local resources or upstream." fi 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_USER_} --noclear %I \$TERM _EOF if ! grep -q 'welcome.py' "${_GNS3_HOME_}/.bashrc" 2>/dev/null; then echo "python3 /usr/local/bin/welcome.py" >>"${_GNS3_HOME_}/.bashrc" fi echo "${_GNS3_USER_}:gns3" | chpasswd usermod --shell /bin/bash "${_GNS3_USER_}" usermod -aG sudo "${_GNS3_USER_}" log_ok "Welcome console configured" } ### Start services ########################################################## start_services() { log_info "Verifying services..." 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" if [[ "${_proto}" == "udp" ]]; then _flag="-ulnH" fi 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="${SERVER_PRIVATE_IP}" sleep 3 # GNS3 API responding # shellcheck disable=SC2310 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 # shellcheck disable=SC2310 probe_port_listening "${_GNS3_PORT_}" "GNS3 server" || true # shellcheck disable=SC2310 probe_port_listening "${_CONFIG_SERVE_PORT_}" "Config server" || true # Config server landing page # shellcheck disable=SC2310 probe_http "http://${_lan_ip}:${_CONFIG_SERVE_PORT_}/" "Landing page" || true # VPN config downloadable through the slug path # shellcheck disable=SC2310 if file_exists "${_DEPLOY_DIR_}/serve_slug"; then _serve_slug=$(cat "${_DEPLOY_DIR_}/serve_slug") if [[ "${WITH_WIREGUARD}" -eq 1 ]]; then # shellcheck disable=SC2310 probe_http "http://${_lan_ip}:${_CONFIG_SERVE_PORT_}/${_serve_slug}/w" "WireGuard config download" || true fi if [[ "${WITH_OPENVPN}" -eq 1 ]]; then # shellcheck disable=SC2310 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 # shellcheck disable=SC2310 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 # shellcheck disable=SC2310 if file_exists /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 # shellcheck disable=SC2310 if dir_exists "${_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" if [[ -x "${_GNS3_VENV_}/bin/gns3server" ]]; then _gns3_bin="${_GNS3_VENV_}/bin/gns3server" fi 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 [[ "${#WARN_MESSAGES[@]}" -eq 0 ]]; then log_ok "All post-install checks passed" fi } ### Summary banner ########################################################## print_summary() { local _lan_ip _public_ip _lan_ip="${SERVER_PRIVATE_IP}" _public_ip="${SERVER_PUBLIC_IP}" echo "" printf "${_GREEN_}${_BOLD_} GNS3 Server Install Complete${_RST_}\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 # shellcheck disable=SC2310 if file_exists "${_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_}" # shellcheck disable=SC2059 printf " ${_YELLOW_}Expires in %sh${_RST_}\n\n" "${_CONFIG_SERVE_HOURS_}" fi # Encrypted config download commands # shellcheck disable=SC2310 if file_exists "${_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 # shellcheck disable=SC2059 printf " ${_CYAN_}WireGuard:${_RST_}\n" echo " curl -s http://${_lan_ip}:${_CONFIG_SERVE_PORT_}/${_serve_slug}/w | openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:${_pass} -a > wg.conf" echo "" fi if [[ "${WITH_OPENVPN}" -eq 1 ]]; then # shellcheck disable=SC2059 printf " ${_CYAN_}OpenVPN:${_RST_}\n" echo " curl -s http://${_lan_ip}:${_CONFIG_SERVE_PORT_}/${_serve_slug}/o | openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:${_pass} -a > conf.ovpn" echo "" fi fi if [[ "${DISABLE_KVM}" -eq 1 ]]; then # shellcheck disable=SC2059 printf " ${_YELLOW_}KVM: DISABLED${_RST_}\n" echo "" fi local _invoker _invoker=$(detect_invoking_user) # shellcheck disable=SC2310 if var_exists "${_invoker}"; then # shellcheck disable=SC2059 printf " ${_CYAN_}Log out/in as ${_invoker}${_RST_}" echo " for group changes" echo "" fi if [[ "${#WARN_MESSAGES[@]}" -gt 0 ]]; then # shellcheck disable=SC2059 printf " ${_YELLOW_}${_BOLD_}Action Required${_RST_}\n" echo " ##################################" for _warning in "${WARN_MESSAGES[@]}"; do # shellcheck disable=SC2059 printf " ${_YELLOW_}!${_RST_} %s\n" "${_warning}" done echo "" fi } ############################################################################## # MAIN ############################################################################## main() { export DEBIAN_FRONTEND="noninteractive" parse_args "${@}" apply_option_flags # shellcheck disable=SC2310 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 apt-get install -y -qq curl software-properties-common >/dev/null 2>&1 log_ok "Bootstrap complete" set -E trap 'on_error "${LINENO}" "${BASH_COMMAND}" "$?"' ERR trap 'log_warn "Interrupted by user."; exit 130' INT TERM # Initialize deploy directory for secrets and state ensure_directory "${_DEPLOY_DIR_}" root 700 # Log everything to file for post-mortem exec > >(tee -a "${_DEPLOY_DIR_}/install.log") 2>&1 preflight_checks readonly SERVER_PRIVATE_IP=$(get_lan_ip) readonly SERVER_PUBLIC_IP=$(get_public_ip) readonly SERVER_HOSTNAME=$(hostname) 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