Files
deploy-gns3-server/remote-install.sh
T
2026-05-28 06:56:46 -07:00

1725 lines
53 KiB
Bash

#!/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 <<EOF
# [Interface]
# ...
# EOF
#
# openssl ecparam -name secp384r1 -genkey -noout | \
# ensure_config /etc/openvpn/key.pem root 600
ensure_config() {
local _path="${1:-}" _owner="${2:-root}" _mode="${3:-644}"
local _dir _tmp
_dir="$(dirname "${_path}")"
# Make sure destination directory exists (don't override caller's perms on it)
if ! dir_exists "${_dir}"; then
mkdir -p "${_dir}"
fi
# Temp file in same directory so mv is atomic (same filesystem)
_tmp=$(mktemp "${_path}.XXXXXX")
# Restrictive umask during write — file never exists with open perms
(umask 077 && cat >"${_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" \
"<html><head><title>GNS3 Config Server</title></head>" \
"<body><h1>GNS3 Config Server</h1>" \
"<p>Template missing. Check console for details.</p></body></html>" |
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 <<EOF
deb [arch=$(dpkg --print-architecture) signed-by=${_DOCKER_KEYRING_}] ${_DOCKER_BASE_URL_} ${OS_CODENAME} stable
EOF
log_ok "Docker repository configured"
}
# Do we need to run install?
needs_install() {
local _pkg
for _pkg in "${REQUIRED_PKGS[@]}"; do
# dpkg-query is faster than apt-cache for this
# shellcheck disable=SC2310
if ! dpkg-query -W -f='${Status}' "${_pkg}" 2>/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="<dh>$(cat "${_OVPN_CONF_DIR_}/dh.pem")</dh>"
_dh_server_line="dh dh.pem"
fi
ensure_config /root/client.ovpn root 600 <<_EOF_OVPN_CLI
client
nobind
dev tun
<key>
$(cat "${_OVPN_CONF_DIR_}/key.pem")
</key>
<cert>
$(cat "${_OVPN_CONF_DIR_}/cert.pem")
</cert>
<ca>
$(cat "${_OVPN_CONF_DIR_}/cert.pem")
</ca>
${_dh_client_block}
<connection>
remote ${_public_ip} ${_OVPN_PORT_} udp
</connection>
_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