From d1ca7c669254135d6067b7e591030365c3aa5850 Mon Sep 17 00:00:00 2001 From: lark Date: Thu, 28 May 2026 06:56:46 -0700 Subject: [PATCH] gns3 deploy initial repo --- .DS_Store | Bin 0 -> 6148 bytes README.md | 49 + TODO.md | 142 ++ gns3-remote-install.sh | 1678 +++++++++++++++++++++ remote-install.sh | 1724 ++++++++++++++++++++++ resources/docker-gpg | 62 + resources/welcome.py | 577 ++++++++ template.html | 213 +++ templates/70-gns3-vpn.temp | 6 + templates/client1.conf.temp | 10 + templates/gns3-config-serve-stop.service | 10 + templates/gns3-config-serve.service.temp | 12 + templates/gns3-config-serve.timer.temp | 10 + templates/gns3.service.temp | 21 + templates/gns3_server.conf.temp | 13 + templates/template.html | 114 ++ templates/wg0.conf.temp | 11 + 17 files changed, 4652 insertions(+) create mode 100644 .DS_Store create mode 100644 README.md create mode 100644 TODO.md create mode 100644 gns3-remote-install.sh create mode 100644 remote-install.sh create mode 100644 resources/docker-gpg create mode 100644 resources/welcome.py create mode 100644 template.html create mode 100644 templates/70-gns3-vpn.temp create mode 100644 templates/client1.conf.temp create mode 100644 templates/gns3-config-serve-stop.service create mode 100644 templates/gns3-config-serve.service.temp create mode 100644 templates/gns3-config-serve.timer.temp create mode 100644 templates/gns3.service.temp create mode 100644 templates/gns3_server.conf.temp create mode 100644 templates/template.html create mode 100644 templates/wg0.conf.temp diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..35e010cf2a69cc421ec84916b9606b6e002b940b GIT binary patch literal 6148 zcmeHKyG{c!5S)b+iD*(%`WN_vQxv{{9{>`%Lx%|Iugh;U`v4N%(NdsUX+8FO$CjtK zy#-*Kaq?kmzVAMUoN>fEc6h;If0$&?2b{YiiyRC%EIA80CB-^80XPT5Ss^xUE!F>2+fj8OsZ9jVM%AaRbE#(CMF#gH}jmj*{VaaxSjD9 z>98(QqZE(=mkM0xa_#m1p8iY!e@W6x3P^!}rGPEA58Dl&RJC>TIIp#h{z(5a=0-Y) lD8`WARN_MESSAGES` array +- [x] `ensure_directory` / `ensure_config` / `require_file` helpers +- [x] `fetch_file` (stdout-based, composable) +- [x] `render_template` (stdin-based, `{{KEY}}` substitution) +- [x] `enable_and_start` with retry loop +- [x] Predicates: `file_exists`, `dir_exists`, `var_exists`, `port_open` +- [x] `if/then/fi` instead of `[[ ]] &&` shorthand (most converted) +- [x] `encrypted_copy` uses `openssl enc` with `-pbkdf2` +- [x] Decrypt commands match encryption params +- [x] `gns3_repo_present` handles deb822 format +- [x] Sudoers lines have no leading whitespace +- [x] Stray box-drawing chars removed from section headers +- [x] `fetch_resource` removed `fetch_file` used everywhere +- [x] IP lookups cached as `SERVER_PUBLIC_IP` / `SERVER_PRIVATE_IP` / `SERVER_HOSTNAME` +- [x] No redundant IP re-checks mid-script +- [x] `.shellcheckrc` with justified exclusions +- [-] Remaining `[[ ]] && / ||` patterns most converted, grep to find stragglers + +--- + +## Phase 2: Structural Alignment + +- [ ] Replace `on_error()` + separate `INT/TERM` trap with single `cleanup()` on `EXIT` + - Convention: `trap cleanup EXIT` as first line of `main()` + - `cleanup()` preserves exit code, disarms traps, `set +e` + - Restart GNS3 on failure (current `on_error` behavior) moves into cleanup + - INT/TERM: set context flag and `exit` - cleanup runs via EXIT trap +- [ ] Add `setup_tempdir()` - centralized `TEMP_DIR` used by `apt_retry` and `ensure_config` + - Currently both use ad-hoc `mktemp` + - `ensure_config` should use `mktemp "${TEMP_DIR}/..."` instead of `mktemp "${_path}.XXXXXX"` +- [ ] Add tracking arrays wired to helpers: + - `DIRECTORIES_CREATED` ← `ensure_directory` appends + - `SERVICES_ENABLED` ← `enable_and_start` appends + - `PACKAGES_INSTALLED` ← `install_packages` populates (optional) + - Summary reads from these arrays +- [ ] Add `ROLLBACK_SERVICES` / `ROLLBACK_DIRS` typed registries + - `cleanup()` iterates in reverse on failure +- [ ] Replace `flock` lockfile with `mkdir`-based lock + stale PID detection (skeleton pattern) +- [ ] Remove `WARNINGS_OCCURRED` or use it - currently set but never read +- [ ] Add `require_args` to helper functions (from skeleton) + +--- + +## Phase 3: Naming & Organization + +- [ ] Standardize template placeholder names + - Current: mixed `_DEPLOY_MARKER_` / `_listen_host` / `GNS3_PORT` / `_hw_accel` + - Target: consistent `SCREAMING_SNAKE` for all placeholders (they're config values, not locals) +- [ ] Rename phase functions to convention verbs: + - `preflight_checks`->`preflight` + - `setup_config_server`->`config_server` + - `configure_firewall`->`firewall` + - `apply_sysctl_hardening`->`hardening` + - `start_services`->(merge into per-service phases or remove) + - `print_summary`->`summary` +- [ ] Rename sub-functions with phase prefix: + - `setup_groups`->`acct_create_groups` + - `setup_gns3_user`->`acct_create_gns3_user` + - `propagate_groups_to_invoker`->`acct_add_invoker` + - `add_gns3_repository`->`repo_add_gns3` + - `add_docker_repository`->`repo_add_docker` + - VPN functions: `ovpn_*` and `wg_*` prefixes +- [ ] Reorder function definitions to match execution order +- [ ] Add INDEX section to header mapping functions to sections +- [ ] Add `VERSION` constant, print in `--help` and summary +- [ ] Cache `_SCRIPT_DIR_` as readonly global (currently computed multiple times in `fetch_file`) + +--- + +## Phase 4: Macro Refactor + +- [ ] Two-tier `main()` structure: + ``` + main->bootstrap->preflight->repositories->packages → + accounts->gns3->docker->openvpn->wireguard → + welcome->firewall->hardening->config_server → + validate->summary + ``` +- [ ] Each phase is a noun function containing only sub-function calls +- [ ] Sub-functions use `phase_verb_noun` naming +- [ ] Phase banner comments with purpose description +- [ ] Flag gating in `main()` with `if/then`: + ```bash + if [[ "${WITH_DOCKER}" -eq 1 ]]; then docker; fi + if [[ "${WITH_OPENVPN}" -eq 1 ]]; then openvpn; fi + ``` +- [ ] Add predicate functions for recurring checks: + - `service_active` / `user_exists` / `interface_up` / `repo_present` / `packages_installed` +- [ ] Move `apply_option_flags` (rollup) into its own named phase or call from `main()` before bootstrap + +--- + +## Phase 5: Multi-File Split (future, when script exceeds ~1000 LOC of logic) + +- [ ] `conf/` - readonly vars/arrays per feature +- [ ] `lib/` source-only helpers, no `main()` +- [ ] `mod/` executable modules with numeric prefix ordering +- [ ] `bootstrap.sh` top-level orchestrator, executes `mod/*.sh` +- [ ] Source guards: `[[ -n "${_LIB_X_LOADED:-}" ]] && return 0` +- [ ] Direct-execution guards on sourced files + +--- + +## Ongoing / Per-Commit + +- [ ] `shfmt -i 2 -ci` before commit +- [ ] `shellcheck` zero warnings (with `.shellcheckrc` exclusions) +- [ ] Update `Changed:` date in header on substantive edits +- [ ] Update INDEX when function structure changes +- [ ] Convention: Check / Do / Verify for every action +- [ ] Convention: secrets never as function arguments; use env/files/stdin +- [ ] Convention: `backup_file` before modifying existing configs (not yet implemented) + +--- + +## Known Deferred Items + +- [ ] `check_integrity()` SHA256 validation for downloaded resources +- [ ] `--with-optimization` phase TCP/KVM tuning (BBR, swappiness, etc.) +- [ ] 26.04 codename fallback check GNS3 PPA Packages.xz for actual content +- [ ] OpenVPN client.ovpn still uses heredoc (PEM blocks don't work with `render_template`) +- [ ] `docker.list` still uses heredoc (inline `$(dpkg --print-architecture)` call) +- [ ] Validate function could check `ufw status numbered` rule count matches expectations +- [ ] Landing page: version-matched GNS3 client download URLs via JS +- [ ] GPG-signed bootstrap at `lrk.cx/gns3` + +--- diff --git a/gns3-remote-install.sh b/gns3-remote-install.sh new file mode 100644 index 0000000..6e68d25 --- /dev/null +++ b/gns3-remote-install.sh @@ -0,0 +1,1678 @@ +#!/usr/bin/env bash + +############################################################ +# Filename : gns3-remote-install.sh # +# Author : Shane Sexton # +# Created : April 18 2026 # +# Last edit : April 22 2026 # +# Purpose : Enhancement to remote-install.sh # +# Reference : GNS3:gns3-server - remote-install.sh # +# Depends : Ubuntu Server 22.04, 24.04, 26.04 LTS # +# To do : Consistency, readability, & structure need # +# improvements at all magnification levels # +############################################################ + +############################################################ +# PLEASE SEE README FOR FEATURE COMPARISON # +############################################################ + +######################################################################## +# Usage: sudo ./gns3-remote-install-redux.sh [OPTIONS] # +# # +# --with-openvpn Install & configure OpenVPN # +# --with-wireguard Install & configure WireGuard # +# --with-welcome Install GNS3-VM welcome.py console UI # +# --without-kvm Disable KVM (hurts Qemu performance) # +# --without-docker Skip Docker installation # +# --without-firewall Skip automatic UFW rule configuration # +# --without-system-upgrade Skip apt upgrade # +# --unsafe-configs Serve VPN configs unencrypted (less safe) # +# --unstable Use GNS3 unstable PPA # +# --custom-repository REPO Use custom GNS3 PPA name # +# -h, --help Show this help # +######################################################################## + +######################################################################## +# Conventions # +# # +# Variables: # +# Globals: ROARING_SNEK_CASE # +# Local and iters: _snake_case (prefix) # +# Var expansion: "${VAR}" (not "$VAR") # +# # +# Functions: # +# Use lowercase_snake_case names # +# Start with `local` declaration block for all locals # +# Return 0 - success # +# 1 - error; carrying on # +# log_fatal - panic # +# # +# Logic: # +# Avoid shorthand [[ condition ]] &&... use if/else/fi # +# # +# File writes: # +# ensure_config() for anything that needs specific perms # +# ensure_directory() before writing to a new path # +# require_file() after any action that should produce a file # +# # +# Logging: # +# log_info(): what we're about to do (or did, non-critical) # +# log_ok(): confirmation that something worked # +# log_warn(): non-fatal issue, not actionable # +# log_warn_sticky(): non-fatal but user informed at summary time # +# log_error(): something failed but we're continuing # +# log_fatal(): cannot proceed; exits # +# # +# Services: # +# enable_and_start() — not systemctl enable/start/restart # +# # +# External resources: # +# fetch_resource() with ordered fallbacks (local→canonical→mirror) # +# # +# Idempotency: # +# Every function should be safe to re-run # +# Key writes: [[ -f key ]] || generate (never overwrite keys) # +# Config writes: always overwrite existing configs # +# Groups/users: ensure_group / id checks # +######################################################################## + +### Constants ################################################################ +# This repo +readonly REPO_BASE_URL="https://raw.githubusercontent.com/lark-cx/gns3-server-deploy/refs/heads/main/" +readonly REPO_LANDING_HTML="template.html" +readonly REPO_DOCKER_GPG="docker-gpg" +# Docker +readonly DOCKER_BASE_URL="https://download.docker.com/linux/ubuntu" +readonly DOCKER_KEYRING="/etc/apt/keyrings/docker.asc" +# GNS3 configurations +readonly GNS3_USER="gns3" +readonly GNS3_HOME="/opt/gns3" +readonly GNS3_CONF_DIR="/etc/gns3" +readonly GNS3_PORT=3080 +readonly GNS3_SERVICE_FILE="/lib/systemd/system/gns3.service" +readonly GNS3_VENV="/usr/share/gns3/gns3-server" +# Config deployment server +readonly CONFIG_SERVE_PORT=8003 +readonly CONFIG_SERVE_DIR="/var/lib/gns3-config-serve" +readonly CONFIG_SERVE_HOURS=2 +readonly DEPLOY_MARKER="# deployed by gns3-remote-install-redux" +readonly DEPLOY_DIR="/root/.gns3-deploy" + +### VPN Constants ############################################################# +readonly OVPN_CONF_DIR="/etc/openvpn" +readonly OVPN_PORT=1194 +readonly WG_CONF_DIR="/etc/wireguard" +readonly WG_PORT=51820 + +### Colors #################################################################### +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly CYAN='\033[0;36m' +readonly BOLD='\033[1m' +readonly NC='\033[0m' + +### Logging ################################################################## +log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$1" >&2; } +log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$1" >&2; } +log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$1" >&2; } +log_error() { printf "${RED}[FAIL]${NC} %s\n" "$1" >&2; } +log_fatal() { + printf "${RED}[FATAL]${NC} %s\n" "$1" >&2 + exit 1 +} + + +on_error() { + local _line_no="$1" + local _cmd="$2" + local _exit_code="$3" + + log_error "Failed at line ${_line_no}: ${_cmd} (exit ${_exit_code})" + + # Only try to revive GNS3 if systemd knows about it + if systemctl list-unit-files gns3.service >/dev/null 2>&1; then + systemctl daemon-reload >/dev/null 2>&1 || true + sleep 2 + systemctl restart gns3 >/dev/null 2>&1 || systemctl start gns3 >/dev/null 2>&1 || true + fi + + exit "${_exit_code}" +} + +# Testability wrapper — EUID is readonly in bash, so tests override this function +is_root() { [[ ${EUID} -eq 0 ]]; } + +# Sticky warnings — collected throughout execution, displayed before summary +WARNINGS=() +log_warn_sticky() { + log_warn "$1" + WARNINGS+=("$1") +} + +### Mutable arrays (built up by option flags) ##############################─ +REQUIRED_CMDS=(apt apt-add-repository dpkg chown chmod useradd usermod lsmod systemctl ss openssl) +REQUIRED_PORTS=("${GNS3_PORT}") +REQUIRED_GROUPS=(kvm ubridge) +REQUIRED_MODS=(kvm) + +REQUIRED_PKGS=( + software-properties-common + ca-certificates + curl + gns3-server + dynamips + vpcs + python3 + python3-pip + python3-setuptools + qemu-system-x86 + qemu-utils +) + +readonly PKGS_OPENVPN=(openvpn dnsutils) +readonly PKGS_WIREGUARD=(wireguard-tools) +readonly PKGS_DOCKER=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin) +readonly PKGS_WELCOME=(net-tools dialog python3-dialog) + +### Option defaults ########################################################## + +WITH_OPENVPN=0 +WITH_WIREGUARD=0 +WITH_DOCKER=1 +WITH_WELCOME=0 +DISABLE_KVM=0 +DISABLE_FIREWALL=0 +NO_SYSTEM_UPGRADE=0 +UNSAFE_CONFIGS=0 +USE_LEGACY_RSA=0 +REPOSITORY="ppa" + +### Help ####################################################################─ + +show_help() { + cat >&2 <<_EOF_HELP +${BOLD}gns3-remote-install-redux.sh${NC} — GNS3 remote server installer + +${BOLD}Usage:${NC} sudo $0 [OPTIONS] + +${BOLD}Options:${NC} + --with-openvpn Install and configure OpenVPN + --with-wireguard Install and configure WireGuard + --with-welcome Install GNS3-VM welcome.py console UI + --without-kvm Disable KVM hardware acceleration + --without-docker Skip Docker CE installation + --without-firewall Skip automatic UFW rule configuration + --without-system-upgrade Skip apt upgrade step + --unsafe-configs Serve VPN configs unencrypted + --unstable Use the GNS3 unstable PPA + --custom-repository REPO Use a custom GNS3 PPA name + -h, --help Show this help +_EOF_HELP +} + +### Argument parsing ########################################################## + +TEMP=$(getopt -o h --long with-openvpn,with-wireguard,with-welcome,without-kvm,without-docker,without-firewall,without-system-upgrade,unsafe-configs,legacy-rsa,unstable,custom-repository:,help -n "$0" -- "$@") || { + show_help + exit 1 +} +eval set -- "${TEMP}" + +while true; do + case "$1" in + --with-openvpn) + WITH_OPENVPN=1 + shift + ;; + --with-wireguard) + WITH_WIREGUARD=1 + shift + ;; + --with-welcome) + WITH_WELCOME=1 + shift + ;; + --without-kvm) + DISABLE_KVM=1 + shift + ;; + --without-docker) + WITH_DOCKER=0 + shift + ;; + --without-firewall) + DISABLE_FIREWALL=1 + shift + ;; + --without-system-upgrade) + NO_SYSTEM_UPGRADE=1 + shift + ;; + --unsafe-configs) + UNSAFE_CONFIGS=1 + shift + ;; + --legacy-rsa) + USE_LEGACY_RSA=1 + shift + ;; + --unstable) + REPOSITORY="unstable" + shift + ;; + --custom-repository) + REPOSITORY="$2" + shift 2 + ;; + -h | --help) + show_help + exit 0 + ;; + --) + shift + break + ;; + *) log_fatal "Unknown option: $1" ;; + esac +done + +### Roll up arrays based on flags ##########################################─ + +if [[ ${WITH_OPENVPN} -eq 1 ]]; then + REQUIRED_PORTS+=("${OVPN_PORT}") + REQUIRED_PKGS+=("${PKGS_OPENVPN[@]}") +fi + +if [[ ${WITH_WIREGUARD} -eq 1 ]]; then + REQUIRED_PORTS+=("${WG_PORT}") + REQUIRED_MODS+=(wireguard) + REQUIRED_PKGS+=("${PKGS_WIREGUARD[@]}") +fi + +# Config server port always needed (landing page is default-on) +REQUIRED_PORTS+=("${CONFIG_SERVE_PORT}") + +if [[ ${WITH_DOCKER} -eq 1 ]]; then + REQUIRED_PKGS+=("${PKGS_DOCKER[@]}") + REQUIRED_GROUPS+=(docker) +fi + +if [[ ${WITH_WELCOME} -eq 1 ]]; then + REQUIRED_PKGS+=("${PKGS_WELCOME[@]}") +fi + +### Preflight checks ######################################################## + +preflight_checks() { + local _has_errors=0 + + if [[ ${OSTYPE} != linux-gnu* ]]; then + log_fatal "This script requires Linux (detected: ${OSTYPE})." + fi + + if [[ -f /etc/os-release ]]; then + source /etc/os-release + else + log_fatal "/etc/os-release not found. Is this Ubuntu?" + fi + + if [[ ${ID:-} != "ubuntu" ]]; then + log_fatal "This script requires Ubuntu (detected: ${ID:-unknown})." + fi + + local -a _missing_cmds=() + for _cmd in "${REQUIRED_CMDS[@]}"; do + command -v "${_cmd}" &>/dev/null || _missing_cmds+=("${_cmd}") + done + if [[ ${#_missing_cmds[@]} -gt 0 ]]; then + log_error "Missing commands: ${_missing_cmds[*]}" + _has_errors=1 + fi + + # Detect reconfigure: our config marker + gns3 service running + if [[ -f "${GNS3_CONF_DIR}/gns3_server.conf" ]] && + grep -q "${DEPLOY_MARKER}" "${GNS3_CONF_DIR}/gns3_server.conf" 2>/dev/null && + systemctl is-active --quiet gns3 2>/dev/null; then + log_info "Existing installation detected — running in reconfigure mode" + systemctl stop gns3 + systemctl stop gns3-config-serve.service 2>/dev/null || true + fi + + local -a _busy_ports=() + for _port in "${REQUIRED_PORTS[@]}"; do + if ss -tlnH | grep -q ":${_port} "; then + _busy_ports+=("${_port}") + fi + done + if [[ ${#_busy_ports[@]} -gt 0 ]]; then + log_error "Port(s) already in use: ${_busy_ports[*]}" + _has_errors=1 + fi + + # Ensure time synchronization is active for VPN certificate validity + if command -v timedatectl &>/dev/null; then + timedatectl set-ntp true || log_warn_sticky "Couldn't start NTP. Ensure accurate time for certs." + fi + + # Load missing kernel modules automatically unless --without-kvm + local -a _missing_mods=() + for _mod in "${REQUIRED_MODS[@]}"; do + if ! lsmod | grep -wq "${_mod}" 2>/dev/null; then + if [[ ${_mod} == "kvm" && ${DISABLE_KVM} -eq 1 ]]; then + continue + fi + log_info "Loading kernel module: ${_mod}" + if modprobe "${_mod}" 2>/dev/null; then + log_ok "Loaded ${_mod}" + if ! grep -qx "${_mod}" /etc/modules-load.d/gns3.conf 2>/dev/null; then + echo "${_mod}" >>/etc/modules-load.d/gns3.conf + fi + else + _missing_mods+=("${_mod}") + fi + fi + done + if [[ ${#_missing_mods[@]} -gt 0 ]]; then + log_warn_sticky "Kernel module(s) could not be loaded: ${_missing_mods[*]}" + log_warn_sticky " Manual fix: modprobe ${_missing_mods[*]}" + fi + + if [[ ${DISABLE_KVM} -eq 0 ]] && [[ $(grep -Ec '(vmx|svm)' /proc/cpuinfo) -eq 0 ]]; then + log_warn_sticky "CPU virtualization extensions not detected. KVM will not function." + log_warn_sticky " If running in a VM without nested virt, pass --without-kvm" + fi + + if [[ ${REPOSITORY} == "ppa-v3" ]]; then + if ! python3 -c 'import sys; assert sys.version_info >= (3,9)' &>/dev/null; then + log_error "GNS3 v3+ requires Python >= 3.9" + _has_errors=1 + fi + fi + + if [[ ${_has_errors} -eq 1 ]]; then + log_fatal "Preflight failed. Fix the above and re-run." + fi + + log_ok "Preflight checks passed" +} + +### Helpers ################################################################## + +# Retry apt operation 3 times, quietly. +# Return final lines from apt on failure. +apt_retry() { + local _attempts=3 _i _apt_log + _apt_log=$(mktemp) + for ((_i = 1; _i <= _attempts; _i++)); do + if apt-get "$@" -qq >"${_apt_log}" 2>&1; then + rm -f "${_apt_log}" + return 0 + fi + log_warn "apt failed (attempt ${_i}/${_attempts}), retrying in 5s..." + tail -5 "${_apt_log}" >&2 + sleep 5 + done + log_error "apt output:" + tail -10 "${_apt_log}" >&2 + rm -f "${_apt_log}" + log_fatal "apt failed after ${_attempts} attempts: apt-get $*" +} + +# Groups exists or is created +ensure_group() { + getent group "$1" &>/dev/null || groupadd --system "$1" +} + +# Who invoked the script with sudo? +detect_invoking_user() { + if [[ -n ${SUDO_USER:-} && ${SUDO_USER} != "root" ]]; then + echo "${SUDO_USER}" + fi +} + +# Get public IP with fallback chain (dig → curl → UNKNOWN) +get_public_ip() { + local _ip + _ip=$(dig @ns1.google.com -t txt o-o.myaddr.l.google.com +short -4 2>/dev/null | sed 's/"//g') + if [[ -n ${_ip} ]]; then + echo "${_ip}" + return + fi + _ip=$(curl -sf --max-time 5 https://icanhazip.com 2>/dev/null) + if [[ -n ${_ip} ]]; then + echo "${_ip}" + return + fi + _ip=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null) + if [[ -n ${_ip} ]]; then + echo "${_ip}" + return + fi + echo "UNKNOWN" +} + +# Get LAN IP — primary interface address +get_lan_ip() { + hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1" +} + +# Generate a readable one-time passphrase: XXXX-XXXX-#### +generate_passphrase() { + local _hex + _hex=$(openssl rand -hex 4) + printf "%s-%s" "${_hex:0:4}" "${_hex:4:4}" | tr 'a-f' 'A-F' +} + +# Encrypt a file with a passphrase, output .enc alongside original +encrypted_copy() { + local _src="$1" _dst="$2" _pass="$3" + openssl aes-256-cbc -pbkdf2 -iter 10000 -pass "pass:${_pass}" -a -in "${_src}" -out "${_dst}" +} + +# Enable IPv4 forwarding, if needed +enable_ip_forwarding() { + log_info "Enabling IPv4 forwarding..." + sysctl -w net.ipv4.ip_forward=1 >/dev/null + if ! grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf 2>/dev/null; then + echo "net.ipv4.ip_forward=1" >>/etc/sysctl.conf + fi + log_ok "IPv4 forwarding enabled (persistent)" +} + +# Apply baseline sysctl hardening — safe, standard CIS recommendations +apply_sysctl_hardening() { + log_info "Applying sysctl hardening..." + + local -A _sysctls=( + ["net.ipv4.conf.all.rp_filter"]="2" + ["net.ipv4.conf.default.rp_filter"]="2" + ["net.ipv4.tcp_syncookies"]="1" + ["net.ipv4.conf.all.accept_redirects"]="0" + ["net.ipv4.conf.default.accept_redirects"]="0" + ["net.ipv4.conf.all.send_redirects"]="0" + ["net.ipv4.conf.default.send_redirects"]="0" + ["net.ipv4.conf.all.accept_source_route"]="0" + ["net.ipv4.conf.default.accept_source_route"]="0" + ["net.ipv6.conf.all.accept_redirects"]="0" + ["net.ipv6.conf.default.accept_redirects"]="0" + ) + + local _sysctl_file="/etc/sysctl.d/90-gns3-hardening.conf" + : >"${_sysctl_file}" + + for _key in "${!_sysctls[@]}"; do + local _val="${_sysctls[${_key}]}" + echo "${_key}=${_val}" >>"${_sysctl_file}" + sysctl -w "${_key}=${_val}" >/dev/null 2>&1 + done + + log_ok "Sysctl hardening applied (${_sysctl_file})" +} + +# Safe and quiet service (re)start - with mulligan +enable_and_start() { + local _svc="$1" + local _attempt _max=3 + + log_info "Enabling and starting service: ${_svc}" + systemctl daemon-reload + systemctl enable "${_svc}" >/dev/null 2>&1 || true + + for ((_attempt=1; _attempt<=_max; _attempt++)); do + if systemctl restart "${_svc}" >/dev/null 2>&1 || systemctl start "${_svc}" >/dev/null 2>&1; then + log_ok "Service running: ${_svc}" + return 0 + fi + + if [[ ${_attempt} -lt ${_max} ]]; then + log_warn "Service start failed for ${_svc} (attempt ${_attempt}/${_max}); retrying in 1s..." + sleep 1 + systemctl daemon-reload + fi + done + + log_warn_sticky "Service failed after ${_max} attempts: ${_svc}" + log_warn_sticky " Check: journalctl -u ${_svc} --no-pager -n 30" + return 1 +} + +# Verify a file exists and is non-empty. +# Returns 1 and logs sticky warning on failure. +require_file() { + local _path="$1" _desc="${2:-file}" + if [[ ! -s ${_path} ]]; then + log_warn_sticky "${_desc} missing or empty: ${_path}" + return 1 + fi + return 0 +} + +# Idempotent directory creation with ownership and mode. Creates if missing, +# applies chown/chmod either way (in case perms drifted). +ensure_directory() { + local _path="$1" _owner="${2:-root}" _mode="${3:-}" + if [[ -d ${_path} ]]; then + log_info "Directory exists: ${_path}" + else + mkdir -p "${_path}" + log_info "Created directory: ${_path}" + fi + chown "${_owner}:${_owner}" "${_path}" + [[ -n ${_mode} ]] && chmod "${_mode}" "${_path}" +} + +# Atomically write a config file with the right perms from start. +# Content from stdin -> tempfile in target dir -> perms -> rename +# +# Usage: +# ensure_config /etc/wireguard/wg0.conf root 600 <"${_tmp}") + + chown "${_owner}:${_owner}" "${_tmp}" + chmod "${_mode}" "${_tmp}" + mv "${_tmp}" "${_path}" + + # Self-verify: confirm the file actually made it and is non-empty + require_file "${_path}" "config" || return 1 + log_info "Wrote config: ${_path} (mode ${_mode}, owner ${_owner})" +} + +# Try sequential sources until one succeeds. First arg is +# destination, remaining args are sources, tried in order. +# Local paths detected by absence of http(s):// prefix. +# Returns 1 if all sources fail. +# +# Usage: +# fetch_resource /etc/apt/keyrings/docker.asc \ +# "${_script_dir}/docker-gpg" \ +# "https://download.docker.com/linux/ubuntu/gpg" \ +# "${REPO_BASE_URL}docker-gpg" +fetch_resource() { + local _dst="$1" + shift + local _src + for _src in "$@"; do + if [[ ${_src} =~ ^https?:// ]]; then + if curl -fsSL --max-time 10 "${_src}" -o "${_dst}" 2>/dev/null; then + log_info "Fetched from remote: ${_src}" + return 0 + fi + log_warn "Remote fetch failed: ${_src}" + elif [[ -f ${_src} ]]; then + if cp "${_src}" "${_dst}" 2>/dev/null; then + log_info "Copied from local: ${_src}" + return 0 + fi + log_warn "Local copy failed: ${_src}" + fi + done + log_warn_sticky "All sources failed for: ${_dst}" + return 1 +} + +### Ephemeral config file server ############################################## + +setup_config_server() { + log_info "Config server starting (port ${CONFIG_SERVE_PORT}, ${CONFIG_SERVE_HOURS}h TTL)..." + + # Stop existing server if running from a previous install + if systemctl is-active --quiet gns3-config-serve.service 2>/dev/null; then + systemctl stop gns3-config-serve.service 2>/dev/null || true + systemctl stop gns3-config-serve-stop.timer 2>/dev/null || true + log_info "Stopped previous config server" + fi + + # Clean previous serve directory (public-facing only) + [[ -d ${CONFIG_SERVE_DIR} ]] && rm -rf "${CONFIG_SERVE_DIR}" + + # Ensure deploy directory exists for secrets + mkdir -p "${DEPLOY_DIR}" + chmod 700 "${DEPLOY_DIR}" + + # Persist slug across re-runs so URL stays stable + local _serve_slug + if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then + _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") + log_info "Reusing existing serve slug: ${_serve_slug}" + else + _serve_slug=$(openssl rand -hex 3) + echo "${_serve_slug}" >"${DEPLOY_DIR}/serve_slug" + chmod 600 "${DEPLOY_DIR}/serve_slug" + fi + + local _serve_path="${CONFIG_SERVE_DIR}/${_serve_slug}" + mkdir -p "${_serve_path}" + log_info "Config serve path: ${_serve_path}" + + local _lan_ip _public_ip _gns3_bin _gns3_ver + _lan_ip=$(get_lan_ip) + _public_ip=$(get_public_ip) + _gns3_bin="/usr/bin/gns3server" + [[ -x "${GNS3_VENV}/bin/gns3server" ]] && _gns3_bin="${GNS3_VENV}/bin/gns3server" + + # Extract exact semantic version, fallback to "unknown" + _gns3_ver=$("${_gns3_bin}" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) || true + [[ -z ${_gns3_ver} ]] && _gns3_ver="unknown" + + # VPN config files (encrypted by default) + local _conf_passphrase="" + + if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then + # Fatal if we can't determine public IP for VPN configs + if [[ ${_public_ip} == "UNKNOWN" ]]; then + log_fatal "Couldn't find public IP. VPN configs need a reachable IP." + fi + + if [[ ${UNSAFE_CONFIGS} -eq 0 ]]; then + # Persist passphrase across re-runs + if [[ -f "${DEPLOY_DIR}/passphrase" ]]; then + _conf_passphrase=$(cat "${DEPLOY_DIR}/passphrase") + log_info "Reusing existing config passphrase" + else + _conf_passphrase=$(generate_passphrase) + echo "${_conf_passphrase}" >"${DEPLOY_DIR}/passphrase" + chmod 600 "${DEPLOY_DIR}/passphrase" + fi + fi + fi + + if [[ ${WITH_OPENVPN} -eq 1 && -f /root/client.ovpn ]]; then + if [[ ${UNSAFE_CONFIGS} -eq 1 ]]; then + cp /root/client.ovpn "${_serve_path}/o" + else + encrypted_copy /root/client.ovpn "${_serve_path}/o" "${_conf_passphrase}" + fi + fi + + if [[ ${WITH_WIREGUARD} -eq 1 && -f "${WG_CONF_DIR}/client1.conf" ]]; then + if [[ ${UNSAFE_CONFIGS} -eq 1 ]]; then + cp "${WG_CONF_DIR}/client1.conf" "${_serve_path}/w" + else + encrypted_copy "${WG_CONF_DIR}/client1.conf" "${_serve_path}/w" "${_conf_passphrase}" + fi + fi + + # Also stash client configs in deploy dir for safekeeping + [[ -f /root/client.ovpn ]] && cp /root/client.ovpn "${DEPLOY_DIR}/client.ovpn" + [[ -f "${WG_CONF_DIR}/client1.conf" ]] && cp "${WG_CONF_DIR}/client1.conf" "${DEPLOY_DIR}/wg-client1.conf" + + # ## Landing page ########################################################## + + log_info "Fetching UI template..." + + local _script_dir _template_src _template_dst _template_ok + _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + _template_src="${_script_dir}/${REPO_LANDING_HTML}" + _template_dst="${CONFIG_SERVE_DIR}/index.html" + _template_ok=0 + + if fetch_resource "${_template_dst}" \ + "${_template_src}" \ + "${REPO_BASE_URL}${REPO_LANDING_HTML}"; then + _template_ok=1 + else + log_error "Failed to obtain template. Using basic fallback UI." + echo "Error" >"${_template_dst}" + echo "

GNS3 Config Server

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

" >>"${_template_dst}" + fi + + # Only run sed injections if we got the template (from either local or curl) + if [[ ${_template_ok} -eq 1 ]]; then + log_info "Injecting server data into UI template..." + + # Build warnings JSON array for injection + local _warnings_json="[]" + if [[ ${#WARNINGS[@]} -gt 0 ]]; then + _warnings_json="[" + for _w in "${WARNINGS[@]}"; do + _warnings_json+="\"$(echo "${_w}" | sed 's/"/\\"/g')\"," + done + _warnings_json="${_warnings_json%,}]" + fi + + # Use | as the sed delimiter to avoid conflicts with slashes in URLs/paths + sed -i "s|{{LAN_IP}}|${_lan_ip}|g" "${_template_dst}" + sed -i "s|{{PUBLIC_IP}}|${_public_ip}|g" "${_template_dst}" + sed -i "s|{{HOSTNAME}}|$(hostname)|g" "${_template_dst}" + sed -i "s|{{GNS3_VERSION}}|${_gns3_ver}|g" "${_template_dst}" + sed -i "s|{{GNS3_PORT}}|${GNS3_PORT}|g" "${_template_dst}" + sed -i "s|{{WITH_OPENVPN}}|${WITH_OPENVPN}|g" "${_template_dst}" + sed -i "s|{{WITH_WIREGUARD}}|${WITH_WIREGUARD}|g" "${_template_dst}" + sed -i "s|{{WITH_DOCKER}}|${WITH_DOCKER}|g" "${_template_dst}" + sed -i "s|{{DISABLE_KVM}}|${DISABLE_KVM}|g" "${_template_dst}" + sed -i "s|{{UNSAFE_CONFIGS}}|${UNSAFE_CONFIGS}|g" "${_template_dst}" + sed -i "s|{{SERVE_SLUG}}|${_serve_slug}|g" "${_template_dst}" + sed -i "s|{{SERVE_HOURS}}|${CONFIG_SERVE_HOURS}|g" "${_template_dst}" + sed -i "s|{{SERVE_PORT}}|${CONFIG_SERVE_PORT}|g" "${_template_dst}" + sed -i "s|{{WARNINGS_JSON}}|${_warnings_json}|g" "${_template_dst}" + sed -i "s|{{OVPN_PORT}}|${OVPN_PORT}|g" "${_template_dst}" + sed -i "s|{{WG_PORT}}|${WG_PORT}|g" "${_template_dst}" + + log_ok "Landing page generated" + fi + + # ## Systemd units ########################################################## + + ensure_config /lib/systemd/system/gns3-config-serve.service root 644 <<_EOF_HTTP_SVC +[Unit] +Description=GNS3 ephemeral config server +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=${CONFIG_SERVE_DIR} +ExecStart=/usr/bin/python3 -m http.server ${CONFIG_SERVE_PORT} --bind 0.0.0.0 +Restart=no + +[Install] +WantedBy=multi-user.target +_EOF_HTTP_SVC + + ensure_config /lib/systemd/system/gns3-config-serve-stop.timer root 644 <<_EOF_CONF_TMR +[Unit] +Description=Stop GNS3 config server after ${CONFIG_SERVE_HOURS} hours + +[Timer] +OnActiveSec=${CONFIG_SERVE_HOURS}h +AccuracySec=1min +Unit=gns3-config-serve-stop.service + +[Install] +WantedBy=timers.target +_EOF_CONF_TMR + + ensure_config /lib/systemd/system/gns3-config-serve-stop.service root 644 <<_EOF_CONF_SVC +[Unit] +Description=Stop and clean up GNS3 config server + +[Service] +Type=oneshot +ExecStart=/bin/systemctl stop gns3-config-serve.service +ExecStart=/bin/systemctl disable gns3-config-serve.service +ExecStart=/bin/rm -rf ${CONFIG_SERVE_DIR} +ExecStart=/bin/systemctl disable gns3-config-serve-stop.timer +ExecStart=/usr/sbin/ufw delete allow ${CONFIG_SERVE_PORT}/tcp +_EOF_CONF_SVC + + enable_and_start gns3-config-serve.service + enable_and_start gns3-config-serve-stop.timer + + log_ok "Config server live on port ${CONFIG_SERVE_PORT} (auto-stops in ${CONFIG_SERVE_HOURS}h)" + + # MOTD + ensure_config /etc/update-motd.d/70-gns3-vpn root 755 <<_EOF_MOTD +#!/bin/sh +if systemctl is-active --quiet gns3-config-serve.service 2>/dev/null; then + echo "" + echo " GNS3 config: http://${_lan_ip}:${CONFIG_SERVE_PORT}/" + echo " (auto-expires — download configs now)" +fi +_EOF_MOTD +} + +### Firewall (ufw) ########################################################## + +configure_firewall() { + if [[ ${DISABLE_FIREWALL} -eq 1 ]]; then + log_info "Skipping firewall configuration (--without-firewall)" + return + fi + + if ! command -v ufw &>/dev/null; then + log_warn "No ufw detected. Ensure these ports are open in your firewall:" + log_warn " ${REQUIRED_PORTS[*]}" + return + fi + + if ! ufw status | grep -q "Status: active"; then + log_warn "ufw is installed but inactive. Skipping rule creation." + log_warn " If you enable ufw later, allow ports: ${REQUIRED_PORTS[*]}" + return + fi + + log_info "Configuring ufw rules..." + + # Detect SSH source IP and local subnets for scoped rules + local _remote_ssh_ip="" + _remote_ssh_ip=$(echo "${SSH_CONNECTION:-}" | awk '{print $1}') + + local -a _local_subnets=() + readarray -t _local_subnets < <(ip -o -f inet addr show | + grep -E '(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | + awk '{print $4}') + + if [[ -n ${_remote_ssh_ip} ]]; then + log_info "SSH connection from ${_remote_ssh_ip}" + else + log_warn_sticky "No SSH session detected. Allow your IP in ufw or use VPN for remote access." + fi + + if [[ ${#_local_subnets[@]} -gt 0 ]]; then + log_info "RFC1918 subnets detected: ${_local_subnets[*]}" + fi + + for _port in "${REQUIRED_PORTS[@]}"; do + local _status="PERM" + + case "${_port}" in + "${OVPN_PORT}" | "${WG_PORT}") + # VPN ports: open from anywhere, both TCP and UDP + ufw allow "${_port}"/tcp comment "GNS3 - ${_status} (VPN)" >/dev/null 2>&1 + ufw allow "${_port}"/udp comment "GNS3 - ${_status} (VPN)" >/dev/null 2>&1 + ;; + "${CONFIG_SERVE_PORT}") + _status="CLEAR" + ufw allow "${_port}"/tcp comment "GNS3 - ${_status} (ephem. webserver)" >/dev/null 2>&1 + ;; + *) + # Non-VPN ports: scope to SSH source and local subnets + if [[ -n "${_remote_ssh_ip}" ]]; then + ufw allow from "${_remote_ssh_ip}" to any port "${_port}" proto tcp \ + comment "GNS3 - ${_status}" >/dev/null 2>&1 + fi + for _subnet in "${_local_subnets[@]}"; do + ufw allow from "${_subnet}" to any port "${_port}" proto tcp \ + comment "GNS3 - ${_status}" >/dev/null 2>&1 + done + ;; + esac + done + + # Enable forwarding in ufw if any VPN is configured + if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then + local _ufw_default="/etc/default/ufw" + if [[ -f ${_ufw_default} ]]; then + if grep -q '^DEFAULT_FORWARD_POLICY="DROP"' "${_ufw_default}"; then + sed -i 's/^DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' "${_ufw_default}" + log_info "Set UFW DEFAULT_FORWARD_POLICY=ACCEPT" + fi + fi + ufw reload >/dev/null 2>&1 + fi + + log_ok "ufw rules applied for ports: ${REQUIRED_PORTS[*]}" +} + +### Core setup functions ################################################# + +setup_groups() { + log_info "Creating required system groups..." + for _grp in "${REQUIRED_GROUPS[@]}"; do + ensure_group "${_grp}" + done + log_ok "Groups: ${REQUIRED_GROUPS[*]}" +} + +setup_gns3_user() { + log_info "Setting up GNS3 service user..." + mkdir -p "${GNS3_HOME}"/{images,projects,appliances,configs} + + if ! id "${GNS3_USER}" &>/dev/null; then + local _groups_csv + printf -v _groups_csv '%s,' "${REQUIRED_GROUPS[@]}" + _groups_csv="${_groups_csv%,}" + + useradd --system \ + --home-dir "${GNS3_HOME}" \ + --no-create-home \ + --comment "GNS3 server" \ + --groups "${_groups_csv}" \ + --shell /usr/sbin/nologin \ + "${GNS3_USER}" + log_ok "Created user ${GNS3_USER}" + else + for _grp in "${REQUIRED_GROUPS[@]}"; do + usermod -aG "${_grp}" "${GNS3_USER}" + done + log_ok "User ${GNS3_USER} already exists — updated groups" + fi + + chown -R "${GNS3_USER}:${GNS3_USER}" "${GNS3_HOME}" +} + +propagate_groups_to_invoker() { + local _invoker + _invoker=$(detect_invoking_user) + if [[ -n ${_invoker} ]]; then + log_info "Adding ${_invoker} to groups: ${REQUIRED_GROUPS[*]}" + for _grp in "${REQUIRED_GROUPS[@]}"; do + usermod -aG "${_grp}" "${_invoker}" + done + log_ok "Group membership updated for ${_invoker} (log out/in to take effect)" + fi +} + +# Is repo present? +gns3_repo_present() { + grep -rEq "^.*ppa.launchpadcontent.net/gns3/ppa/ubuntu/" \ + /etc/apt/sources.list.d/ 2>/dev/null +} + +# Add gns3 repo (if not already present) +add_gns3_repository() { + if gns3_repo_present; then + log_info "GNS3 PPA already configured" + return + fi + log_info "Adding GNS3 PPA: ppa:gns3/${REPOSITORY}" + apt-add-repository -y "ppa:gns3/${REPOSITORY}" >/dev/null + log_ok "GNS3 repository added" +} + +# Add Docker repo +add_docker_repository() { + if [[ -f ${DOCKER_KEYRING} ]]; then + log_info "Docker GPG key already present — skipping download" + else + log_info "Adding Docker CE repository..." + install -m 0755 -d /etc/apt/keyrings + + local _script_dir + _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + + # Try local GPG file, their URL, my URL, then fail + if ! fetch_resource "${DOCKER_KEYRING}" \ + "${_script_dir}/${REPO_DOCKER_GPG}" \ + "${DOCKER_BASE_URL}/gpg" \ + "${REPO_BASE_URL}${REPO_DOCKER_GPG}"; then + log_fatal "Could not obtain Docker GPG key from any source." + fi + + # Sanity check — make sure we got an actual key, not an error page + if ! file "${DOCKER_KEYRING}" 2>/dev/null | grep -qiE 'pgp|openpgp|gpg'; then + log_warn_sticky "Docker keyring file may not be a valid GPG key. Check: ${DOCKER_KEYRING}" + fi + + chmod a+r "${DOCKER_KEYRING}" + fi + + cat >/etc/apt/sources.list.d/docker.list </dev/null | grep -q "install ok installed"; then + return 0 # at least one package needs install + fi + done + return 1 # everything installed +} + +# Updates, ugprades, and installs - quietly +install_packages() { + apt_retry update -qq + + if [[ "${NO_SYSTEM_UPGRADE}" -eq 0 ]]; then + log_info "Upgrading system packages..." + apt_retry upgrade -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" + log_ok "System upgraded" + else + log_info "Skipping system upgrade" + fi + + if needs_install; then + log_info "Installing required packages..." + NEEDRESTART_MODE=a apt_retry install -y "${REQUIRED_PKGS[@]}" + log_ok "All packages installed" + else + log_ok "All required packages already installed" + fi +} + +### GNS3 server configuration ##################################### + +configure_gns3() { + log_info "Writing GNS3 server configuration..." + + local _listen_host="0.0.0.0" + + local _hw_accel="True" + if [[ ${DISABLE_KVM} -eq 1 ]]; then + _hw_accel="False" + log_warn "KVM disabled — Qemu performance will be degraded" + fi + + ensure_directory "${GNS3_CONF_DIR}" "${GNS3_USER}" 700 + + ensure_config "${GNS3_CONF_DIR}/gns3_server.conf" "${GNS3_USER}" 600 <<_EOF_GNS3_CONF +${DEPLOY_MARKER} +[Server] +host = ${_listen_host} +port = ${GNS3_PORT} +images_path = ${GNS3_HOME}/images +projects_path = ${GNS3_HOME}/projects +appliances_path = ${GNS3_HOME}/appliances +configs_path = ${GNS3_HOME}/configs +report_errors = True + +[Qemu] +enable_hardware_acceleration = ${_hw_accel} +require_hardware_acceleration = ${_hw_accel} +_EOF_GNS3_CONF + + log_ok "GNS3 configuration written" +} + +### Systemd service ############################################# + +install_gns3_service() { + log_info "Installing GNS3 systemd service..." + + local _gns3_bin="/usr/bin/gns3server" + if [[ -x "${GNS3_VENV}/bin/gns3server" ]]; then + _gns3_bin="${GNS3_VENV}/bin/gns3server" + log_info "Using venv binary: ${_gns3_bin}" + fi + + ensure_config "${GNS3_SERVICE_FILE}" root 644 <<_EOF_GNS3_SVC +[Unit] +Description=GNS3 server +After=network-online.target +Wants=network-online.target +Conflicts=shutdown.target + +[Service] +User=gns3 +Group=gns3 +PermissionsStartOnly=true +EnvironmentFile=/etc/environment +ExecStartPre=/bin/mkdir -p /var/log/gns3 /var/run/gns3 +ExecStartPre=/bin/chown -R gns3:gns3 /var/log/gns3 /var/run/gns3 +ExecStart=${_gns3_bin} --log /var/log/gns3/gns3.log +ExecReload=/bin/kill -s HUP \$MAINPID +Restart=on-failure +RestartSec=5 +LimitNOFILE=16384 + +[Install] +WantedBy=multi-user.target +_EOF_GNS3_SVC + + enable_and_start gns3 + log_ok "GNS3 service installed and enabled" +} + +### OpenVPN setup ################################################ + +configure_openvpn() { + log_info "Configuring OpenVPN..." + + local _public_ip + _public_ip=$(get_public_ip) + if [[ ${_public_ip} == "UNKNOWN" ]]; then + log_fatal "Could not determine public IP for OpenVPN configuration." + fi + log_info "Public IP detected: ${_public_ip}" + + log_info "Generating OpenVPN keys..." + ensure_directory "${OVPN_CONF_DIR}" root 755 + + # If using *RSA* + if [[ ${USE_LEGACY_RSA} -eq 1 ]]; then + log_info "Using legacy RSA crypto (DH params may take a minute)..." + if [[ -f "${OVPN_CONF_DIR}/dh.pem" ]]; then + log_info "Already have DH params. Skipping." + else + openssl dhparam -quiet 2048 | ensure_config "${OVPN_CONF_DIR}/dh.pem" root 600 + fi + require_file "${OVPN_CONF_DIR}/dh.pem" "OpenVPN DH params" || log_fatal "DH param generation failed" + + if [[ -f "${OVPN_CONF_DIR}/key.pem" ]]; then + log_info "Already have OpenVPN key. Skipping." + else + openssl genrsa 2048 | ensure_config "${OVPN_CONF_DIR}/key.pem" root 600 + fi + + # If using *ECC* + else + log_info "Using elliptic curve crypto (P-384)..." + if [[ -f "${OVPN_CONF_DIR}/key.pem" ]]; then + log_info "Already have OpenVPN key. Skipping." + else + openssl ecparam -name secp384r1 -genkey -noout | ensure_config "${OVPN_CONF_DIR}/key.pem" root 600 + fi + + fi + require_file "${OVPN_CONF_DIR}/key.pem" "OpenVPN private key" || log_fatal "Key generation failed" + + if [[ -f "${OVPN_CONF_DIR}/csr.pem" ]]; then + log_info "csr.pem already present." + else + openssl req -new -key "${OVPN_CONF_DIR}/key.pem" -out "${OVPN_CONF_DIR}/csr.pem" \ + -subj /CN=OpenVPN/ 2>/dev/null + fi + require_file "${OVPN_CONF_DIR}/csr.pem" "OpenVPN CSR" || log_fatal "CSR generation failed" + + if [[ -f "${OVPN_CONF_DIR}/cert.pem" ]]; then + log_info "cert.pem already present." + else + openssl x509 -req -in "${OVPN_CONF_DIR}/csr.pem" -out "${OVPN_CONF_DIR}/cert.pem" \ + -signkey "${OVPN_CONF_DIR}/key.pem" -days 3650 &>/dev/null + fi + require_file "${OVPN_CONF_DIR}/cert.pem" "OpenVPN certificate" || log_fatal "Certificate signing failed" + + local _dh_client_block="" + local _dh_server_line="dh none" + if [[ ${USE_LEGACY_RSA} -eq 1 ]]; then + _dh_client_block="$(cat "${OVPN_CONF_DIR}/dh.pem")" + _dh_server_line="dh dh.pem" + fi + + ensure_config /root/client.ovpn root 600 <<_EOF_OVPN_CLI +client +nobind +dev tun + +$(cat "${OVPN_CONF_DIR}/key.pem") + + +$(cat "${OVPN_CONF_DIR}/cert.pem") + + +$(cat "${OVPN_CONF_DIR}/cert.pem") + +${_dh_client_block} + +remote ${_public_ip} ${OVPN_PORT} udp + +_EOF_OVPN_CLI + + ensure_config "${OVPN_CONF_DIR}/udp${OVPN_PORT}.conf" root 644 <<_EOF_OVPN +server 172.16.253.0 255.255.255.0 +verb 3 +duplicate-cn +key key.pem +ca cert.pem +cert cert.pem +${_dh_server_line} +keepalive 10 60 +persist-key +persist-tun +proto udp +port ${OVPN_PORT} +dev tun${OVPN_PORT} +status openvpn-status-${OVPN_PORT}.log +log-append /var/log/openvpn-udp${OVPN_PORT}.log +_EOF_OVPN + + enable_and_start openvpn + log_ok "OpenVPN configured" +} + +### WireGuard setup #############################################─ + +configure_wireguard() { + log_info "Configuring WireGuard..." + + local _public_ip + _public_ip=$(get_public_ip) + if [[ ${_public_ip} == "UNKNOWN" ]]; then + log_fatal "Could not determine public IP for WireGuard configuration." + fi + + ensure_directory "${WG_CONF_DIR}" root 700 + + if [[ ! -f "${WG_CONF_DIR}/server.key" ]]; then + ( + umask 077 + wg genkey | tee "${WG_CONF_DIR}/server.key" | wg pubkey >"${WG_CONF_DIR}/server.pub" + ) + fi + require_file "${WG_CONF_DIR}/server.key" "WireGuard server private key" || log_fatal "WG server keygen failed" + require_file "${WG_CONF_DIR}/server.pub" "WireGuard server public key" || log_fatal "WG server keygen failed" + + local _server_privkey _server_pubkey + _server_privkey=$(cat "${WG_CONF_DIR}/server.key") + _server_pubkey=$(cat "${WG_CONF_DIR}/server.pub") + + if [[ ! -f "${WG_CONF_DIR}/client1.key" ]]; then + ( + umask 077 + wg genkey | tee "${WG_CONF_DIR}/client1.key" | wg pubkey >"${WG_CONF_DIR}/client1.pub" + ) + fi + require_file "${WG_CONF_DIR}/client1.key" "WireGuard client private key" || log_fatal "WG client keygen failed" + + local _client_privkey _client_pubkey + _client_privkey=$(cat "${WG_CONF_DIR}/client1.key") + _client_pubkey=$(cat "${WG_CONF_DIR}/client1.pub") + + ensure_config "${WG_CONF_DIR}/wg0.conf" root 600 <<_EOF_WG_SRV +[Interface] +Address = 172.16.254.1/24 +ListenPort = ${WG_PORT} +PrivateKey = ${_server_privkey} +PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +# client1 +PublicKey = ${_client_pubkey} +AllowedIPs = 172.16.254.2/32 +_EOF_WG_SRV + + ensure_config "${WG_CONF_DIR}/client1.conf" root 600 <<_EOF_WG_CLI +[Interface] +Address = 172.16.254.2/24 +PrivateKey = ${_client_privkey} +DNS = 1.1.1.1 + +[Peer] +PublicKey = ${_server_pubkey} +Endpoint = ${_public_ip}:${WG_PORT} +AllowedIPs = 172.16.253.0/24, 172.16.254.0/24 +PersistentKeepalive = 25 +_EOF_WG_CLI + + enable_and_start wg-quick@wg0 + log_ok "WireGuard configured" +} + +### Welcome setup ##############################################################─ + +configure_welcome() { + log_info "Setting up GNS3-VM welcome console..." + + ensure_config "/etc/sudoers.d/gns3" root 440 <<_EOF +gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-key +gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-get +gns3 ALL = (ALL) NOPASSWD: /usr/sbin/reboot +_EOF + + if ! visudo -cf /etc/sudoers.d/gns3 &>/dev/null; then + log_warn_sticky "sudoers fragment failed syntax check — removing" + rm -f /etc/sudoers.d/gns3 + fi + + curl -fsSL https://raw.githubusercontent.com/GNS3/gns3-server/master/scripts/welcome.py \ + -o /usr/local/bin/welcome.py + chmod 755 /usr/local/bin/welcome.py + chown "${GNS3_USER}:${GNS3_USER}" /usr/local/bin/welcome.py + + mkdir -p /etc/systemd/system/getty@tty1.service.d + ensure_config "/etc/systemd/system/getty@tty1.service.d/override.conf" root 644 <<'_EOF' +[Service] +ExecStart= +ExecStart=-/sbin/agetty -a gns3 --noclear %I $TERM +_EOF + + grep -q 'welcome.py' "${GNS3_HOME}/.bashrc" 2>/dev/null || + echo "python3 /usr/local/bin/welcome.py" >>"${GNS3_HOME}/.bashrc" + + echo "gns3:gns3" | chpasswd + usermod --shell /bin/bash "${GNS3_USER}" + usermod -aG sudo "${GNS3_USER}" + + log_ok "Welcome console configured" +} + +### Start services ########################################################## + +start_services() { + log_info "Starting GNS3 service..." + systemctl restart gns3 + sleep 2 + + if systemctl is-active --quiet gns3; then + log_ok "GNS3 service running" + else + log_warn_sticky "GNS3 service failed to start" + log_warn_sticky " Check logs: journalctl -u gns3 --no-pager -n 20" + fi + + if [[ ${WITH_DOCKER} -eq 1 ]]; then + enable_and_start docker + if systemctl is-active --quiet docker; then + log_ok "Docker service running" + else + log_warn_sticky "Docker service failed to start" + fi + fi +} + +### Post-install validation ################################################## + +# Helper: curl a URL and report success/failure. Returns 0/1 for chaining. +probe_http() { + local _url="$1" _desc="$2" + if curl -sf --max-time 5 "${_url}" &>/dev/null; then + log_ok "${_desc} responding: ${_url}" + return 0 + fi + log_warn_sticky "${_desc} not responding: ${_url}" + return 1 +} + +# Helper: check if a TCP (default) or UDP port is listening +probe_port_listening() { + local _port="$1" _desc="$2" _proto="${3:-tcp}" + local _flag="-tlnH" + [[ "${_proto}" == "udp" ]] && _flag="-ulnH" + if ss ${_flag} 2>/dev/null | grep -q ":${_port} "; then + log_ok "${_desc} listening on ${_proto} port ${_port}" + return 0 + fi + log_warn_sticky "${_desc} not listening on ${_proto} port ${_port}" + return 1 +} + +validate() { + log_info "Running post-install validation..." + local _lan_ip _serve_slug + _lan_ip=$(get_lan_ip) + + sleep 3 + + # GNS3 API responding + if ! probe_http "http://${_lan_ip}:${GNS3_PORT}/v2/version" "GNS3 API"; then + probe_http "http://localhost:${GNS3_PORT}/v2/version" "GNS3 API (localhost fallback)" || + log_warn_sticky " Check: journalctl -u gns3 --no-pager -n 20" + fi + + # Port listening checks + probe_port_listening "${GNS3_PORT}" "GNS3 server" || true + probe_port_listening "${CONFIG_SERVE_PORT}" "Config server" || true + + # Config server landing page + probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/" "Landing page" || true + + # VPN config downloadable through the slug path + if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then + _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") + if [[ ${WITH_WIREGUARD} -eq 1 ]]; then + probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/w" "WireGuard config download" || true + fi + if [[ ${WITH_OPENVPN} -eq 1 ]]; then + probe_http "http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/o" "OpenVPN config download" || true + fi + fi + + # VPN tunnel interfaces + if [[ ${WITH_WIREGUARD} -eq 1 ]]; then + probe_port_listening "${WG_PORT}" "WireGuard" udp || true + if ip link show wg0 &>/dev/null; then + log_ok "WireGuard interface wg0 is up" + else + log_warn_sticky "WireGuard interface wg0 not found" + fi + fi + + if [[ ${WITH_OPENVPN} -eq 1 ]]; then + if ip link show "tun${OVPN_PORT}" &>/dev/null; then + log_ok "OpenVPN interface tun${OVPN_PORT} is up" + else + log_warn_sticky "OpenVPN interface tun${OVPN_PORT} not found" + fi + fi + + # Config server auto-stop timer armed + if systemctl list-timers --all 2>/dev/null | grep -q "gns3-config-serve-stop"; then + log_ok "Config server auto-stop timer armed" + else + log_warn_sticky "Config server auto-stop timer NOT armed — server will not expire on schedule" + fi + + # Sysctl hardening applied + if [[ -f /etc/sysctl.d/90-gns3-hardening.conf ]]; then + log_ok "Sysctl hardening file present" + else + log_warn_sticky "Sysctl hardening file missing" + fi + + # IP forwarding enabled when VPN configured + if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then + if [[ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" == "1" ]]; then + log_ok "IPv4 forwarding enabled" + else + log_warn_sticky "IPv4 forwarding NOT enabled — VPN routing will fail" + fi + fi + + # Deploy dir perms — secrets must not be world-readable + if [[ -d ${DEPLOY_DIR} ]]; then + local _mode + _mode=$(stat -c %a "${DEPLOY_DIR}" 2>/dev/null) + if [[ ${_mode} == "700" ]]; then + log_ok "Deploy directory permissions: 700" + else + log_warn_sticky "Deploy directory permissions are ${_mode}, expected 700" + fi + fi + + # Docker + if [[ ${WITH_DOCKER} -eq 1 ]]; then + if docker info &>/dev/null; then + log_ok "Docker engine responding" + else + log_warn_sticky "Docker installed but not responding" + fi + fi + + # GNS3 binary sanity + local _gns3_bin="/usr/bin/gns3server" + [[ -x "${GNS3_VENV}/bin/gns3server" ]] && _gns3_bin="${GNS3_VENV}/bin/gns3server" + if "${_gns3_bin}" --version &>/dev/null; then + log_ok "gns3server binary: $("${_gns3_bin}" --version 2>&1 | head -1)" + else + log_warn_sticky "gns3server binary cannot execute — possible Python venv issue" + log_warn_sticky " Binary: ${_gns3_bin}" + log_warn_sticky " Check: ${_gns3_bin} --version" + fi + + # UFW active when firewall configuration was attempted + if [[ ${DISABLE_FIREWALL} -eq 0 ]] && command -v ufw &>/dev/null; then + if ufw status 2>/dev/null | grep -q "Status: active"; then + log_ok "UFW active" + else + log_warn_sticky "UFW is installed but not active — no firewall rules enforced" + fi + fi + + if [[ ${#WARNINGS[@]} -eq 0 ]]; then + log_ok "All post-install checks passed" + fi +} + +### Summary banner ########################################################## + +print_summary() { + local _lan_ip _public_ip + _lan_ip=$(get_lan_ip) + _public_ip=$(get_public_ip) + + echo "" + printf "${GREEN}${BOLD} GNS3 Server Install Complete${NC}\n" + printf " ##################################\n\n" + printf " Server: http://%s:%s\n" "${_lan_ip}" "${GNS3_PORT}" + printf " Config: %s/gns3_server.conf\n" "${GNS3_CONF_DIR}" + printf " Data: %s/\n" "${GNS3_HOME}" + echo " Logs: /var/log/gns3/gns3.log" + echo " Status: systemctl status gns3" + echo "" + + if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then + echo " VPN" + echo " ##################################" + + if [[ ${WITH_WIREGUARD} -eq 1 ]]; then + printf " WireGuard: %s:%s\n" "${_public_ip}" "${WG_PORT}" + printf " Tunnel: 172.16.254.1:%s\n" "${GNS3_PORT}" + fi + + if [[ ${WITH_OPENVPN} -eq 1 ]]; then + printf " OpenVPN: %s:%s/udp\n" "${_public_ip}" "${OVPN_PORT}" + printf " Tunnel: 172.16.253.1:%s\n" "${GNS3_PORT}" + fi + echo "" + fi + + # Config server info + if [[ -f "${DEPLOY_DIR}/serve_slug" ]]; then + local _serve_slug + _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") + printf " Landing page:\n" + printf " http://%s:%s/\n" "${_lan_ip}" "${CONFIG_SERVE_PORT}" + printf " ${YELLOW}Expires in %sh${NC}\n\n" "${CONFIG_SERVE_HOURS}" + fi + + # Encrypted config download commands + if [[ -f "${DEPLOY_DIR}/passphrase" ]]; then + local _pass _serve_slug + _pass=$(cat "${DEPLOY_DIR}/passphrase") + _serve_slug=$(cat "${DEPLOY_DIR}/serve_slug") + + echo " Secure config download" + echo " ##################################" + + if [[ ${WITH_WIREGUARD} -eq 1 ]]; then + printf " ${CYAN}WireGuard:${NC}\n" + echo " curl -s http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/w | openssl aes-256-cbc -d -pbkdf2 -pass pass:${_pass} -a > wg.conf" + echo "" + fi + + if [[ ${WITH_OPENVPN} -eq 1 ]]; then + printf " ${CYAN}OpenVPN:${NC}\n" + echo " curl -s http://${_lan_ip}:${CONFIG_SERVE_PORT}/${_serve_slug}/o | openssl aes-256-cbc -d -pbkdf2 -pass pass:${_pass} -a > conf.ovpn" + echo "" + fi + fi + + if [[ ${DISABLE_KVM} -eq 1 ]]; then + printf " ${YELLOW}KVM: DISABLED${NC}\n" + echo "" + fi + + local _invoker + _invoker=$(detect_invoking_user) + if [[ -n ${_invoker} ]]; then + printf " ${CYAN}Log out/in as ${_invoker}${NC}" + echo " for group changes" + echo "" + fi + + if [[ ${#WARNINGS[@]} -gt 0 ]]; then + printf " ${YELLOW}${BOLD}Action Required${NC}\n" + echo " ##################################" + for _warning in "${WARNINGS[@]}"; do + printf " ${YELLOW}!${NC} %s\n" "${_warning}" + done + echo "" + fi +} + +############################################################################## +# MAIN +############################################################################## + +main() { + export DEBIAN_FRONTEND="noninteractive" + + if ! is_root; then + log_fatal "Must run as root. Try: sudo $0" + fi + + # Ensure exclusive execution + exec 9>/var/lock/gns3-install.lock + if ! flock -n 9; then + log_fatal "Another instance of this script is already running." + fi + + log_info "Bootstrapping essential packages..." + apt-get update -qq >/dev/null 2>&1 && log_info "Update done" + apt-get install -y -qq curl software-properties-common >/dev/null 2>&1 && log_ok "Bootstrap complete" + + set -Eeuo pipefail + + trap 'on_error "${LINENO}" "${BASH_COMMAND}" "$?"' ERR + trap 'log_warn "Interrupted by user."; exit 130' INT TERM + + # Initialize deploy directory for secrets and state + mkdir -p "${DEPLOY_DIR}" + chmod 700 "${DEPLOY_DIR}" + + # Log everything to file for post-mortem + exec > >(tee -a "${DEPLOY_DIR}/install.log") 2>&1 + + preflight_checks + + readonly OS_CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME}}" + + # # Phase 1: Repositories ################################################## + + add_gns3_repository + if [[ "${WITH_DOCKER}" -eq 1 ]]; then add_docker_repository; fi + + # # Phase 2: Packages (single apt pass) #################################### + + install_packages + + # # Phase 3: Users and groups ############################################## + + setup_groups + setup_gns3_user + propagate_groups_to_invoker + + # # Phase 4: Configuration ################################################─ + + configure_gns3 + install_gns3_service + + if [[ ${WITH_OPENVPN} -eq 1 ]]; then configure_openvpn; fi + if [[ ${WITH_WIREGUARD} -eq 1 ]]; then configure_wireguard; fi + if [[ ${WITH_WELCOME} -eq 1 ]]; then configure_welcome; fi + if [[ ${WITH_OPENVPN} -eq 1 || ${WITH_WIREGUARD} -eq 1 ]]; then + enable_ip_forwarding + fi + + setup_config_server + configure_firewall + apply_sysctl_hardening + + # ## Phase 5: Start ########################################################## + + start_services + + # ## Phase 6: Validate ###################################################### + + validate + + # ## Phase 7: Welcome post-install repair #################################### + + if [[ ${WITH_WELCOME} -eq 1 ]]; then + python3 -c 'import sys; sys.path.append("/usr/local/bin/"); import welcome; ws = welcome.Welcome_dialog(); ws.repair_remote_install()' || true + fi + + # ## Done #################################################################### + + print_summary +} + +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + main "$@" +fi diff --git a/remote-install.sh b/remote-install.sh new file mode 100644 index 0000000..dd51876 --- /dev/null +++ b/remote-install.sh @@ -0,0 +1,1724 @@ +#!/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 diff --git a/resources/docker-gpg b/resources/docker-gpg new file mode 100644 index 0000000..ee7872e --- /dev/null +++ b/resources/docker-gpg @@ -0,0 +1,62 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFit2ioBEADhWpZ8/wvZ6hUTiXOwQHXMAlaFHcPH9hAtr4F1y2+OYdbtMuth +lqqwp028AqyY+PRfVMtSYMbjuQuu5byyKR01BbqYhuS3jtqQmljZ/bJvXqnmiVXh +38UuLa+z077PxyxQhu5BbqntTPQMfiyqEiU+BKbq2WmANUKQf+1AmZY/IruOXbnq +L4C1+gJ8vfmXQt99npCaxEjaNRVYfOS8QcixNzHUYnb6emjlANyEVlZzeqo7XKl7 +UrwV5inawTSzWNvtjEjj4nJL8NsLwscpLPQUhTQ+7BbQXAwAmeHCUTQIvvWXqw0N +cmhh4HgeQscQHYgOJjjDVfoY5MucvglbIgCqfzAHW9jxmRL4qbMZj+b1XoePEtht +ku4bIQN1X5P07fNWzlgaRL5Z4POXDDZTlIQ/El58j9kp4bnWRCJW0lya+f8ocodo +vZZ+Doi+fy4D5ZGrL4XEcIQP/Lv5uFyf+kQtl/94VFYVJOleAv8W92KdgDkhTcTD +G7c0tIkVEKNUq48b3aQ64NOZQW7fVjfoKwEZdOqPE72Pa45jrZzvUFxSpdiNk2tZ +XYukHjlxxEgBdC/J3cMMNRE1F4NCA3ApfV1Y7/hTeOnmDuDYwr9/obA8t016Yljj +q5rdkywPf4JF8mXUW5eCN1vAFHxeg9ZWemhBtQmGxXnw9M+z6hWwc6ahmwARAQAB +tCtEb2NrZXIgUmVsZWFzZSAoQ0UgZGViKSA8ZG9ja2VyQGRvY2tlci5jb20+iQI3 +BBMBCgAhBQJYrefAAhsvBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEI2BgDwO +v82IsskP/iQZo68flDQmNvn8X5XTd6RRaUH33kXYXquT6NkHJciS7E2gTJmqvMqd +tI4mNYHCSEYxI5qrcYV5YqX9P6+Ko+vozo4nseUQLPH/ATQ4qL0Zok+1jkag3Lgk +jonyUf9bwtWxFp05HC3GMHPhhcUSexCxQLQvnFWXD2sWLKivHp2fT8QbRGeZ+d3m +6fqcd5Fu7pxsqm0EUDK5NL+nPIgYhN+auTrhgzhK1CShfGccM/wfRlei9Utz6p9P +XRKIlWnXtT4qNGZNTN0tR+NLG/6Bqd8OYBaFAUcue/w1VW6JQ2VGYZHnZu9S8LMc +FYBa5Ig9PxwGQOgq6RDKDbV+PqTQT5EFMeR1mrjckk4DQJjbxeMZbiNMG5kGECA8 +g383P3elhn03WGbEEa4MNc3Z4+7c236QI3xWJfNPdUbXRaAwhy/6rTSFbzwKB0Jm +ebwzQfwjQY6f55MiI/RqDCyuPj3r3jyVRkK86pQKBAJwFHyqj9KaKXMZjfVnowLh +9svIGfNbGHpucATqREvUHuQbNnqkCx8VVhtYkhDb9fEP2xBu5VvHbR+3nfVhMut5 +G34Ct5RS7Jt6LIfFdtcn8CaSas/l1HbiGeRgc70X/9aYx/V/CEJv0lIe8gP6uDoW +FPIZ7d6vH+Vro6xuWEGiuMaiznap2KhZmpkgfupyFmplh0s6knymuQINBFit2ioB +EADneL9S9m4vhU3blaRjVUUyJ7b/qTjcSylvCH5XUE6R2k+ckEZjfAMZPLpO+/tF +M2JIJMD4SifKuS3xck9KtZGCufGmcwiLQRzeHF7vJUKrLD5RTkNi23ydvWZgPjtx +Q+DTT1Zcn7BrQFY6FgnRoUVIxwtdw1bMY/89rsFgS5wwuMESd3Q2RYgb7EOFOpnu +w6da7WakWf4IhnF5nsNYGDVaIHzpiqCl+uTbf1epCjrOlIzkZ3Z3Yk5CM/TiFzPk +z2lLz89cpD8U+NtCsfagWWfjd2U3jDapgH+7nQnCEWpROtzaKHG6lA3pXdix5zG8 +eRc6/0IbUSWvfjKxLLPfNeCS2pCL3IeEI5nothEEYdQH6szpLog79xB9dVnJyKJb +VfxXnseoYqVrRz2VVbUI5Blwm6B40E3eGVfUQWiux54DspyVMMk41Mx7QJ3iynIa +1N4ZAqVMAEruyXTRTxc9XW0tYhDMA/1GYvz0EmFpm8LzTHA6sFVtPm/ZlNCX6P1X +zJwrv7DSQKD6GGlBQUX+OeEJ8tTkkf8QTJSPUdh8P8YxDFS5EOGAvhhpMBYD42kQ +pqXjEC+XcycTvGI7impgv9PDY1RCC1zkBjKPa120rNhv/hkVk/YhuGoajoHyy4h7 +ZQopdcMtpN2dgmhEegny9JCSwxfQmQ0zK0g7m6SHiKMwjwARAQABiQQ+BBgBCAAJ +BQJYrdoqAhsCAikJEI2BgDwOv82IwV0gBBkBCAAGBQJYrdoqAAoJEH6gqcPyc/zY +1WAP/2wJ+R0gE6qsce3rjaIz58PJmc8goKrir5hnElWhPgbq7cYIsW5qiFyLhkdp +YcMmhD9mRiPpQn6Ya2w3e3B8zfIVKipbMBnke/ytZ9M7qHmDCcjoiSmwEXN3wKYI +mD9VHONsl/CG1rU9Isw1jtB5g1YxuBA7M/m36XN6x2u+NtNMDB9P56yc4gfsZVES +KA9v+yY2/l45L8d/WUkUi0YXomn6hyBGI7JrBLq0CX37GEYP6O9rrKipfz73XfO7 +JIGzOKZlljb/D9RX/g7nRbCn+3EtH7xnk+TK/50euEKw8SMUg147sJTcpQmv6UzZ +cM4JgL0HbHVCojV4C/plELwMddALOFeYQzTif6sMRPf+3DSj8frbInjChC3yOLy0 +6br92KFom17EIj2CAcoeq7UPhi2oouYBwPxh5ytdehJkoo+sN7RIWua6P2WSmon5 +U888cSylXC0+ADFdgLX9K2zrDVYUG1vo8CX0vzxFBaHwN6Px26fhIT1/hYUHQR1z +VfNDcyQmXqkOnZvvoMfz/Q0s9BhFJ/zU6AgQbIZE/hm1spsfgvtsD1frZfygXJ9f +irP+MSAI80xHSf91qSRZOj4Pl3ZJNbq4yYxv0b1pkMqeGdjdCYhLU+LZ4wbQmpCk +SVe2prlLureigXtmZfkqevRz7FrIZiu9ky8wnCAPwC7/zmS18rgP/17bOtL4/iIz +QhxAAoAMWVrGyJivSkjhSGx1uCojsWfsTAm11P7jsruIL61ZzMUVE2aM3Pmj5G+W +9AcZ58Em+1WsVnAXdUR//bMmhyr8wL/G1YO1V3JEJTRdxsSxdYa4deGBBY/Adpsw +24jxhOJR+lsJpqIUeb999+R8euDhRHG9eFO7DRu6weatUJ6suupoDTRWtr/4yGqe +dKxV3qQhNLSnaAzqW/1nA3iUB4k7kCaKZxhdhDbClf9P37qaRW467BLCVO/coL3y +Vm50dwdrNtKpMBh3ZpbB1uJvgi9mXtyBOMJ3v8RZeDzFiG8HdCtg9RvIt/AIFoHR +H3S+U79NT6i0KPzLImDfs8T7RlpyuMc4Ufs8ggyg9v3Ae6cN3eQyxcK3w0cbBwsh +/nQNfsA6uu+9H7NhbehBMhYnpNZyrHzCmzyXkauwRAqoCbGCNykTRwsur9gS41TQ +M8ssD1jFheOJf3hODnkKU+HKjvMROl1DK7zdmLdNzA1cvtZH/nCC9KPj1z8QC47S +xx+dTZSx4ONAhwbS/LN3PoKtn8LPjY9NP9uDWI+TWYquS2U+KHDrBDlsgozDbs/O +jCxcpDzNmXpWQHEtHU7649OXHP7UeNST1mCUCH5qdank0V1iejF6/CfTFU4MfcrG +YT90qFF93M3v01BbxP+EIY2/9tiIPbrd +=0YYh +-----END PGP PUBLIC KEY BLOCK----- diff --git a/resources/welcome.py b/resources/welcome.py new file mode 100644 index 0000000..50026ed --- /dev/null +++ b/resources/welcome.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import configparser +import locale +import os +import re +import subprocess +import sys +import time +import urllib.request +from json import loads as convert + +from dialog import Dialog, PythonDialogBug + + +class Welcome_dialog: + def __init__(self): + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + # Not supported via SSH + pass + self.display = Dialog(dialog="dialog", autowidgetsize=True) + if self.gns3_version() is None: + self.display.set_background_title("GNS3") + else: + self.display.set_background_title("GNS3 {}".format(self.gns3_version())) + + def get_ip(self): + """ + Return the active IP + """ + # request 'ip addr' data in JSON format from shell + ip_addr_response = subprocess.run(["ip", "--json", "addr"], capture_output=True) + + # process response, decode and use json.loads to convert the string to a dict + ip_addr_data = convert(ip_addr_response.stdout.decode("utf-8")) + + # search ip_addr_data for the first ip adress that is not under a virtual bridge or loopback interface + for i in ip_addr_data: + if ("virbr" in i["ifname"]) or ("lo" in i["ifname"]): + continue + try: + if "UP" in i["flags"]: + ip_addr = i["addr_info"][0]["local"] + break + except: + continue + ip_addr = None + + return ip_addr + + def repair_remote_install(self): + """ + This method is only called by remote-install.sh during setup to ensure it is setting the same IP as shown by Dialog + """ + ip_addr = self.get_ip() + subprocess.run( + [ + "sed", + "-i", + f"s/host = 0.0.0.0/host = {ip_addr}/", + "/etc/gns3/gns3_server.conf", + ], + capture_output=False, + ) + subprocess.run(["service", "gns3", "stop"], capture_output=False) + subprocess.run(["service", "gns3", "start"], capture_output=False) + + def get_config(self): + """ + Read the config + """ + config = configparser.RawConfigParser() + path = os.path.expanduser("~/.config/GNS3/gns3_server.conf") + config.read([path], encoding="utf-8") + return config + + def write_config(self, config): + """ + Write the config file + """ + + with open(os.path.expanduser("~/.config/GNS3/gns3_server.conf"), "w") as f: + config.write(f) + + def gns3_major_version(self): + """ + Returns the GNS3 major server version + """ + + version = self.gns3_version() + if version: + match = re.search(r"\d+.\d+", version) + return match.group(0) + return "" + + def gns3_version(self): + """ + Return the GNS3 server version + """ + try: + return subprocess.check_output(["gns3server", "--version"]).strip().decode() + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + def gns3vm_version(self): + """ + Return the GNS3 VM version + """ + try: + with open("/home/gns3/.config/GNS3/gns3vm_version") as f: + return f.read().strip() + except FileNotFoundError: + return "Remote Install" + + def mode(self): + if ( + self.display.yesno( + "This feature is for testers only. You may break your GNS3 installation. Are you REALLY sure you want to continue?", + yes_label="Exit (Safe option)", + no_label="Continue", + ) + == self.display.OK + ): + return + code, tag = self.display.menu( + "Select the GNS3 version", + choices=[ + ("2.1", "Stable release for this GNS3 VM (RECOMMENDED)"), + ("2.1dev", "Development version for stable release"), + ("2.2", "Latest stable release"), + ], + ) + self.display.clear() + if code == Dialog.OK: + os.makedirs(os.path.expanduser("~/.config/GNS3"), exist_ok=True) + with open(os.path.expanduser("~/.config/GNS3/gns3_release"), "w+") as f: + f.write(tag) + + self.update(force=True) + + def get_release(self): + try: + with open(os.path.expanduser("~/.config/GNS3/gns3_release")) as f: + content = f.read() + + # Support old VM versions + if content == "stable": + content = "1.5" + elif content == "testing": + content = "1.5" + elif content == "unstable": + content = "1.5dev" + + return content + except OSError: + return "1.5" + + def update(self, force=False): + if not force: + if ( + self.display.yesno( + "It is recommended to ensure all Nodes are shutdown before upgrading. Continue?" + ) + != self.display.OK + ): + return + code, option = self.display.menu( + "Select an option", + choices=[ + ("Upgrade GNS3", "Upgrades only the GNS3 pakage and dependences."), + ("Upgrade All", "Upgrades all avaiable packages"), + ( + "Dist Upgrade", + "Upgrades all avaiable packages and the Linux Kernel. Requires a reboot.", + ), + ], + ) + if code == Dialog.OK: + if option == "Upgrade GNS3": + ret = os.system( + "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys A2E3EF7B \ + && sudo apt-get update \ + && sudo apt-get install -y --only-upgrade gns3-server" + ) + elif option == "Upgrade All": + ret = os.system( + 'sudo apt-key adv --refresh-keys --keyserver keyserver.ubuntu.com \ + && sudo apt-get update \ + && sudo apt-get upgrade --yes --force-yes -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"' + ) + elif option == "Dist Upgrade": + ret = os.system( + 'sudo apt-key adv --refresh-keys --keyserver keyserver.ubuntu.com \ + && sudo apt-get update \ + && sudo apt-get dist-upgrade --yes --force-yes -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"' + ) + if ret != 0: + print( + "ERROR DURING UPGRADE PROCESS PLEASE TAKE A SCREENSHOT IF YOU NEED SUPPORT" + ) + time.sleep(15) + return + if option == "Dist Upgrade": + if self.display.yesno("Reboot now?") == self.display.OK: + os.system("sudo reboot now") + + def migrate(self): + """ + Migrate GNS3 VM data. + """ + + code, option = self.display.menu( + "Select an option", + choices=[ + ("Setup", "Configure this VM to send data to another GNS3 VM"), + ("Send", "Send images and projects to another GNS3 VM"), + ], + ) + self.display.clear() + if code == Dialog.OK: + (answer, destination) = self.display.inputbox( + "What is IP address or hostname of the other GNS3 VM?", + init="172.16.1.128", + ) + if answer != self.display.OK: + return + if destination == self.get_ip(): + self.display.msgbox( + "The destination cannot be the same as this VM IP address ({})".format( + destination + ) + ) + return + if option == "Send": + # first make sure they are no files belonging to root + os.system("sudo chown -R gns3:gns3 /opt/gns3") + # then rsync the data + command = r"rsync -az --progress -e 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /home/gns3/.ssh/gns3-vm-key' /opt/gns3 gns3@{}:/opt".format( + destination + ) + ret = os.system('bash -c "{}"'.format(command)) + time.sleep(10) + if ret != 0: + self.display.msgbox( + "Could not send data to the other GNS3 VM located at {}".format( + destination + ) + ) + else: + self.display.msgbox( + "Images and projects have been successfully sent to the other GNS3 VM located at {}".format( + destination + ) + ) + elif option == "Setup": + script = """ + if [ ! -f ~/.ssh/gns3-vm-key ] + then + ssh-keygen -f ~/.ssh/gns3-vm-key -N '' -C gns3@{} + fi + ssh-copy-id -i ~/.ssh/gns3-vm-key gns3@{} + """.format(self.get_ip(), destination) + ret = os.system('bash -c "{}"'.format(script)) + time.sleep(10) + if ret != 0: + self.display.msgbox("Error while setting up the migrate feature") + else: + self.display.msgbox( + "Configuration successful, you can now send data to the GNS3 VM located at {} without password".format( + destination + ) + ) + + def shrink_disk(self): + + ret = os.system("lspci | grep -i vmware") + if ret != 0: + self.display.msgbox( + "Shrinking the disk is only supported when running inside VMware" + ) + return + + if ( + self.display.yesno( + "Would you like to shrink the VM disk? The VM will reboot at the end of the process. Continue?" + ) + != self.display.OK + ): + return + + os.system("sudo service gns3 stop") + os.system("sudo service docker stop") + os.system("sudo vmware-toolbox-cmd disk shrink /opt") + os.system("sudo vmware-toolbox-cmd disk shrink /") + + self.display.msgbox("The GNS3 VM will reboot") + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + + def vm_information(self): + """ + Show IP, SSH settings.... + """ + + content = "Welcome to GNS3 appliance\n\n" + + version = self.gns3_version() + if version is None: + content += "GNS3 is not installed please install it with sudo pip3 install gns3-server. Or download a preinstalled VM.\n\n" + else: + content = "GNS3 version: {gns3_version}\nVM version: {gns3vm_version}\nKVM support available: {kvm}\n\n".format( + gns3vm_version=self.gns3vm_version(), + gns3_version=version, + kvm=self.kvm_support(), + ) + + ip = self.get_ip() + + if ip: + content += f""" +IP: {ip} +Web UI: http://{ip}:3080 + +To log in using SSH: +ssh gns3@{ip} +Password: gns3 + +Images and projects are located in /opt/gns3 +""".strip() + + else: + content += "eth0 is not configured. Please manually configure it via the Networking menu." + + content += "\n\nRelease channel: " + self.get_release() + + try: + self.display.msgbox(content) + # If it's an scp command or any bugs + except: + os.execvp("bash", ["/bin/bash"]) + + def check_internet_connectivity(self): + self.display.pause("Please wait...\n\n") + try: + response = urllib.request.urlopen("http://pypi.python.org/", timeout=5) + except urllib.request.URLError as err: + self.display.infobox( + "Can't connect to Internet (pypi.python.org): {}".format(str(err)) + ) + time.sleep(15) + return + self.display.infobox("Connection to Internet: OK") + time.sleep(2) + + def keyboard_configuration(): + """ + Allow user to change the keyboard layout + """ + os.system("/usr/bin/sudo dpkg-reconfigure keyboard-configuration") + + def set_security(self): + config = self.get_config() + if self.display.yesno("Enable server authentication?") == self.display.OK: + if not config.has_section("Server"): + config.add_section("Server") + config.set("Server", "auth", True) + (answer, text) = self.display.inputbox("Login?") + if answer != self.display.OK: + return + config.set("Server", "user", text) + (answer, text) = self.display.passwordbox("Password?") + if answer != self.display.OK: + return + config.set("Server", "password", text) + else: + config.set("Server", "auth", False) + + self.write_config(config) + + def log(self): + os.system("/usr/bin/sudo chmod 755 /var/log/upstart/gns3.log") + with open("/var/log/upstart/gns3.log") as f: + try: + while True: + line = f.readline() + sys.stdout.write(line) + except (KeyboardInterrupt, MemoryError): + return + + def edit_config(self): + """ + Edit GNS3 configuration file + """ + + major_version = self.gns3_major_version() + if major_version == "2.2": + os.system("nano ~/.config/GNS3/{}/gns3_server.conf".format(major_version)) + else: + os.system("nano ~/.config/GNS3/gns3_server.conf") + + def edit_network(self): + """ + Edit network configuration file + """ + if ( + self.display.yesno( + "The server will reboot at the end of the process. Continue?" + ) + != self.display.OK + ): + return + os.system("sudo nano /etc/network/interfaces") + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + + def edit_proxy(self): + """ + Configure proxy settings + """ + res, http_proxy = self.display.inputbox( + text="HTTP proxy string, for example http://:@:. Leave empty for no proxy." + ) + if res != self.display.OK: + return + res, https_proxy = self.display.inputbox( + text="HTTPS proxy string, for example http://:@:. Leave empty for no proxy." + ) + if res != self.display.OK: + return + + with open("/tmp/00proxy", "w+") as f: + f.write('Acquire::http::Proxy "' + http_proxy + '";') + os.system("sudo mv /tmp/00proxy /etc/apt/apt.conf.d/00proxy") + os.system("sudo chown root /etc/apt/apt.conf.d/00proxy") + os.system("sudo chmod 744 /etc/apt/apt.conf.d/00proxy") + + with open("/tmp/proxy.sh", "w+") as f: + f.write('export http_proxy="' + http_proxy + '"\n') + f.write('export https_proxy="' + https_proxy + '"\n') + f.write('export HTTP_PROXY="' + http_proxy + '"\n') + f.write('export HTTPS_PROXY="' + https_proxy + '"\n') + os.system("sudo mv /tmp/proxy.sh /etc/profile.d/proxy.sh") + os.system("sudo chown root /etc/profile.d/proxy.sh") + os.system("sudo chmod 744 /etc/profile.d/proxy.sh") + os.system("sudo cp /etc/profile.d/proxy.sh /etc/default/docker") + + self.display.msgbox("The GNS3 VM will reboot") + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + + def kvm_support(self): + """ + Returns true if KVM is available + """ + return subprocess.call("kvm-ok") == 0 + + def kvm_control(self): + """ + Check if KVM is correctly configured + """ + + kvm_ok = self.kvm_support() + config = self.get_config() + try: + if config.getboolean("Qemu", "enable_kvm") is True: + if kvm_ok is False: + if ( + self.display.yesno( + "KVM is not available!\n\nQemu VM will crash!!\n\nThe reason could be unsupported hardware or another virtualization solution is already running.\n\nDisable KVM and get lower performances?" + ) + == self.display.OK + ): + config.set("Qemu", "enable_kvm", False) + self.write_config(config) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + else: + if kvm_ok is True: + if ( + self.display.yesno( + "KVM is available on your computer.\n\nEnable KVM and get better performances?" + ) + == self.display.OK + ): + config.set("Qemu", "enable_kvm", True) + self.write_config(config) + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + except configparser.NoSectionError: + return + + def display_loop(self): + try: + while True: + code, tag = self.display.menu( + "GNS3 {}".format(self.gns3_version()), + choices=[ + ("Information", "Display VM information"), + ("Upgrade", "Upgrade GNS3"), + ("Migrate", "Migrate data to another GNS3 VM"), + ("Shell", "Open a console"), + ("Security", "Configure authentication"), + ("Keyboard", "Change keyboard layout"), + ( + "Configure", + "Edit server configuration (advanced users ONLY)", + ), + ("Proxy", "Configure proxy settings"), + ("Networking", "Configure networking settings"), + ("Log", "Show server log"), + ("Test", "Check internet connection"), + ("Shrink", "Shrink the VM disk"), + ("Version", "Select the GNS3 version"), + ("Restore", "Restore the VM (if you have trouble for upgrade)"), + ("Reboot", "Reboot the VM"), + ("Shutdown", "Shutdown the VM"), + ], + ) + self.display.clear() + if code == Dialog.OK: + if tag == "Shell": + print("Type: 'welcome.py' to get back to the dialog menu.") + sys.exit(0) + elif tag == "Version": + self.mode() + elif tag == "Restore": + os.execvp( + "sudo", ["/usr/bin/sudo", "/usr/local/bin/gns3restore"] + ) + elif tag == "Reboot": + os.execvp("sudo", ["/usr/bin/sudo", "reboot"]) + elif tag == "Shutdown": + os.execvp("sudo", ["/usr/bin/sudo", "poweroff"]) + elif tag == "Upgrade": + self.update() + elif tag == "Information": + self.vm_information() + elif tag == "Log": + self.log() + elif tag == "Migrate": + self.migrate() + elif tag == "Configure": + self.edit_config() + elif tag == "Networking": + self.edit_network() + elif tag == "Security": + self.set_security() + elif tag == "Keyboard": + self.keyboard_configuration() + elif tag == "Test": + self.check_internet_connectivity() + elif tag == "Proxy": + self.edit_proxy() + elif tag == "Shrink": + self.shrink_disk() + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + ws = Welcome_dialog() + ws.vm_information() + ws.kvm_control() + ws.display_loop() diff --git a/template.html b/template.html new file mode 100644 index 0000000..446bc63 --- /dev/null +++ b/template.html @@ -0,0 +1,213 @@ + + + + + + GNS3 Server + + + +

GNS3 SERVER

+ +
+ + + + diff --git a/templates/70-gns3-vpn.temp b/templates/70-gns3-vpn.temp new file mode 100644 index 0000000..d22546b --- /dev/null +++ b/templates/70-gns3-vpn.temp @@ -0,0 +1,6 @@ +#!/bin/sh +if systemctl is-active --quiet gns3-config-serve.service 2>/dev/null; then + echo "" + echo " GNS3 config: http://${_lan_ip}:${_CONFIG_SERVE_PORT_}/" + echo " (auto-expires — download configs now)" +fi diff --git a/templates/client1.conf.temp b/templates/client1.conf.temp new file mode 100644 index 0000000..6f328ce --- /dev/null +++ b/templates/client1.conf.temp @@ -0,0 +1,10 @@ +[Interface] +Address = 172.16.254.2/24 +PrivateKey = ${_client_privkey} +DNS = 1.1.1.1 + +[Peer] +PublicKey = ${_server_pubkey} +Endpoint = ${_public_ip}:${_WG_PORT_} +AllowedIPs = 172.16.253.0/24, 172.16.254.0/24 +PersistentKeepalive = 25 diff --git a/templates/gns3-config-serve-stop.service b/templates/gns3-config-serve-stop.service new file mode 100644 index 0000000..99540b2 --- /dev/null +++ b/templates/gns3-config-serve-stop.service @@ -0,0 +1,10 @@ +[Unit] +Description=Stop and clean up GNS3 config server + +[Service] +Type=oneshot +ExecStart=/bin/systemctl stop gns3-config-serve.service +ExecStart=/bin/systemctl disable gns3-config-serve.service +ExecStart=/bin/rm -rf ${_CONFIG_SERVE_DIR_} +ExecStart=/bin/systemctl disable gns3-config-serve-stop.timer +ExecStart=-/usr/sbin/ufw delete allow ${_CONFIG_SERVE_PORT_}/tcp diff --git a/templates/gns3-config-serve.service.temp b/templates/gns3-config-serve.service.temp new file mode 100644 index 0000000..50b8f1b --- /dev/null +++ b/templates/gns3-config-serve.service.temp @@ -0,0 +1,12 @@ +[Unit] +Description=GNS3 ephemeral config server +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=${_CONFIG_SERVE_DIR_} +ExecStart=/usr/bin/python3 -m http.server ${_CONFIG_SERVE_PORT_} --bind 0.0.0.0 +Restart=no + +[Install] +WantedBy=multi-user.target diff --git a/templates/gns3-config-serve.timer.temp b/templates/gns3-config-serve.timer.temp new file mode 100644 index 0000000..ffc4e3e --- /dev/null +++ b/templates/gns3-config-serve.timer.temp @@ -0,0 +1,10 @@ +[Unit] +Description=Stop GNS3 config server after ${_CONFIG_SERVE_HOURS_} hours + +[Timer] +OnActiveSec=${_CONFIG_SERVE_HOURS_}h +AccuracySec=1min +Unit=gns3-config-serve-stop.service + +[Install] +WantedBy=timers.target diff --git a/templates/gns3.service.temp b/templates/gns3.service.temp new file mode 100644 index 0000000..b030262 --- /dev/null +++ b/templates/gns3.service.temp @@ -0,0 +1,21 @@ +[Unit] +Description=GNS3 server +After=network-online.target +Wants=network-online.target +Conflicts=shutdown.target + +[Service] +User=${_GNS3_USER_} +Group=${_GNS3_USER_} +PermissionsStartOnly=true +EnvironmentFile=/etc/environment +ExecStartPre=/bin/mkdir -p /var/log/gns3 /var/run/gns3 +ExecStartPre=/bin/chown -R ${_GNS3_USER_}:${_GNS3_USER_} /var/log/gns3 /var/run/gns3 +ExecStart=${_gns3_bin} --log /var/log/gns3/gns3.log +ExecReload=/bin/kill -s HUP $MAINPID +Restart=on-failure +RestartSec=5 +LimitNOFILE=16384 + +[Install] +WantedBy=multi-user.target diff --git a/templates/gns3_server.conf.temp b/templates/gns3_server.conf.temp new file mode 100644 index 0000000..dfd2f70 --- /dev/null +++ b/templates/gns3_server.conf.temp @@ -0,0 +1,13 @@ +${_DEPLOY_MARKER_} +[Server] +host = ${_listen_host} +port = ${_GNS3_PORT_} +images_path = ${_GNS3_HOME_}/images +projects_path = ${_GNS3_HOME_}/projects +appliances_path = ${_GNS3_HOME_}/appliances +configs_path = ${_GNS3_HOME_}/configs +report_errors = True + +[Qemu] +enable_hardware_acceleration = ${_hw_accel} +require_hardware_acceleration = ${_hw_accel} diff --git a/templates/template.html b/templates/template.html new file mode 100644 index 0000000..a0b8dce --- /dev/null +++ b/templates/template.html @@ -0,0 +1,114 @@ + + + + + + + GNS3 Server + + + +

GNS3 SERVER

+ +
+ + + diff --git a/templates/wg0.conf.temp b/templates/wg0.conf.temp new file mode 100644 index 0000000..0a055cd --- /dev/null +++ b/templates/wg0.conf.temp @@ -0,0 +1,11 @@ +[Interface] +Address = 172.16.254.1/24 +ListenPort = ${_WG_PORT_} +PrivateKey = ${_server_privkey} +PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o ${_default_iface} -j MASQUERADE +PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o ${_default_iface} -j MASQUERADE + +[Peer] +# client1 +PublicKey = ${_client_pubkey} +AllowedIPs = 172.16.254.2/32