#!/bin/bash
# OpenVPN Access Server Installation Script
# The script will automatically install OpenVPN Access Server based on your Linux distribution.
#
# Copyright 2026 OpenVPN Inc. All Rights Reserved.
#

set -eu

INSTALLATION_SCRIPT_VERSION="2.4"
ARCH=""
PLIST="openvpn-as"
DCO_NAME="ovpn-dkms"
AS_VERSION=""
APT_ALLOW_DOWNGRADES=""
IF_HELD=false
YES_MODE=false
WITHOUT_DCO=false
OVPN_INIT_MANUAL="${OVPN_INIT_MANUAL:-}"
LICENSE=""

abort() {
    echo "This $PRETTY_NAME $ARCH distribution is not officially supported. Aborted." >&2
    exit 1
}

repo_error() {
    echo
    echo "Unfortunately the package management program on your operating system is reporting a problem." >&2
    echo "Please refer to our online documentation or contact our support team for assistance." >&2
    exit 4
}

clones_warning() {
    echo "WARNING: This $PRETTY_NAME is a $1 clone and not officially supported," >&2
    echo "however in theory, it's compatible with $1 repositories which we do support." >&2
    echo "This should be compatible but there is no guarantee to function as expected." >&2
    echo
}

initialization() {
    if [ -f "/etc/os-release" ]; then
        . /etc/os-release
    else
        echo "Can not detect OS/distribution. Aborted." >&2
        exit 1
    fi

    case $(uname -m) in
        x86_64)  ARCH="amd64" ;;
        aarch64) ARCH="arm64" ;;
        *)       abort ;;
    esac

    if [[ "$ARCH" == "arm64" ]]; then
        DISTRO_ARM64_LIST="ubuntu linuxmint pop tuxedo zorin"
        if echo $DISTRO_ARM64_LIST |grep -v -q $ID ; then
            abort
        fi
    fi
    echo "Detected Linux distribution: $PRETTY_NAME $ARCH"
    echo

    if [ -n "$LICENSE" ] ; then
        export OVPN_INIT_MANUAL=true
    fi
}

install_packages() {
    initialization

    case $ID in
        ubuntu|debian)
            install_deb
            ;;
        rhel|centos|amzn)
            DCO_NAME="kmod-ovpn"
            install_rpm
            check_firewalld
            ;;
        linuxmint|pop|tuxedo|zorin)
            clones_warning "Ubuntu"
            install_deb
            ;;
        rocky|almalinux|ol)
            DCO_NAME="kmod-ovpn"
            clones_warning "RHEL"
            install_rpm
            check_firewalld
            ;;
        *)
            abort
            ;;
    esac
    check_selinux
}

install_deb() {
    if [[ "$ID" == "linuxmint" ]]; then
        VERSION_CODENAME=$UBUNTU_CODENAME
    fi
    DISTRO_LIST="buster bullseye bookworm trixie bionic focal jammy noble resolute"
    if echo $DISTRO_LIST |grep -q $VERSION_CODENAME ; then
        confirmation_prompt

        apt update -y -qq || repo_error
        DEBIAN_FRONTEND=noninteractive apt -y -qq install ca-certificates wget net-tools gnupg || repo_error
        mkdir -p /etc/apt/keyrings
        wget https://packages.openvpn.net/as-repo-public.asc -qO /etc/apt/keyrings/as-repository.asc
        echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/as-repository.asc] http://packages.openvpn.net/as/debian $VERSION_CODENAME main" > /etc/apt/sources.list.d/openvpn-as-repo.list
        apt update -y -qq || repo_error
        check_version "deb"
        if $IF_HELD ; then
            apt-mark unhold $PLIST $DCO_NAME
        fi
        DEBIAN_FRONTEND=noninteractive apt -y install "${PLIST}${AS_VERSION}" "$APT_ALLOW_DOWNGRADES" --autoremove || repo_error
        ovpn_init_manual_call
    else
        abort
    fi

    # DCO installation
    if echo $VERSION_CODENAME |grep -qv "buster\|bionic" ; then
        if install_dco "deb" ; then
            apt update -y -qq || repo_error
            DEBIAN_FRONTEND=noninteractive apt -y install $DCO_NAME || repo_error
            enable_dco
        fi
    fi

    if $IF_HELD ; then
        apt-mark hold $PLIST $DCO_NAME
    fi
}

install_rpm() {
    RELEASE=$(echo $VERSION_ID |sed 's/\.[0-9]*//')
    DIST=$ID

    yum list updates 1>/dev/null || repo_error

    if [[ "$RELEASE" == "7" ]]; then
        if [[ "$ID" == "rhel" ]]; then
            confirmation_prompt
            subscription-manager repos --enable rhel-7-server-optional-rpms --enable rhel-server-rhscl-7-rpms || repo_error
        elif [[ "$ID" == "centos" ]]; then
            confirmation_prompt
            yum -y -q install centos-release-scl-rh || repo_error
        fi
        DIST="centos"
    elif [[ "$RELEASE" == "8" || "$RELEASE" == "9" || "$RELEASE" == "10" ]]; then
        confirmation_prompt
        DIST="rhel"
    elif [[ "$ID" == "amzn" && "$RELEASE" == "2" ]]; then
        confirmation_prompt
    else
        abort
    fi

    yum -y -q remove openvpn-as-yum || repo_error
    yum -y -q install "https://packages.openvpn.net/as-repo-${DIST}${RELEASE}.rpm" || repo_error
    check_version "rpm"
    if $IF_HELD ; then
        yum versionlock delete $PLIST $DCO_NAME
    fi
    yum -y install "${PLIST}${AS_VERSION}" || repo_error
    ovpn_init_manual_call

    # DCO installation
    if [[ "$RELEASE" == "8" || "$RELEASE" == "9" || "$RELEASE" == "10" ]]; then
        if install_dco "rpm" ; then
            if [[ "$ID" == "rocky" || "$ID" == "almalinux" ]]; then
                if [[ "$RELEASE" == "8" ]]; then
                    yum config-manager --set-enabled powertools || repo_error
                else
                    yum config-manager --set-enabled crb || repo_error
                fi
                yum -y -q install epel-release || repo_error
            elif [[ "$ID" == "rhel" ]]; then
                yum -y -q install https://dl.fedoraproject.org/pub/epel/epel-release-latest-"$RELEASE".noarch.rpm || repo_error
            fi
            yum -y install $DCO_NAME || repo_error
            enable_dco
        fi
    fi

    if $IF_HELD ; then
        yum versionlock add $PLIST $DCO_NAME
    fi
}

ovpn_init_manual_call() {
    if [ -n "$LICENSE" ]; then
        if [ ! -f "/usr/local/openvpn_as/etc/as.conf" ] && [ ! -f "/usr/local/openvpn_as/etc/db/certs.db" ]; then
            # It's a fresh install
            ovpn-init --batch --license "$LICENSE"
        fi
    fi
}

enable_dco() {
    # Retroactive bandaid fix for duplicated nftables rules on downgrade. For root cause fix, see related tasks: AS-6200
    # systemd restart/stop can SIGKILL or shutdown ungracefully AS processes,
    # cutting off their own cleanups and leaking state (eg. nftables rules) into the next start.
    # Running sacli stop first drives the server agent's full shutdown in-process, so cleanup completes correctly.
    # This issue is only observed on Debian-based distros, but we still do sacli stop on RHEL too for consistency.
    if systemctl is-active --quiet openvpnas; then
        sacli_enable_dco
        /usr/local/openvpn_as/scripts/sacli stop 1>/dev/null || exit 14
        systemctl restart openvpnas || exit 13
    else
        systemctl start openvpnas || exit 13
        sacli_enable_dco
        /usr/local/openvpn_as/scripts/sacli stop 1>/dev/null || exit 14
        systemctl stop openvpnas || exit 13
    fi
}

sacli_enable_dco() {
    /usr/local/openvpn_as/scripts/sacli --key "vpn.server.daemon.ovpndco" --value "true" configput || exit 12
}

install_dco() {
    if $WITHOUT_DCO; then
        return 103
    fi
    echo
    echo
    echo "Access Server 2.12 and newer supports OpenVPN Data Channel Offload (DCO)."
    echo "You can benefit from performance improvements when you enable DCO for your VPN server and clients."
    echo
    echo "Your running kernel version is '$(uname -r)'"
    echo
    echo "DCO needs Linux kernel headers to be installed."
    echo "If the Linux kernel headers are not present, they will be installed automatically."
    echo
    if ! $YES_MODE; then
        read -p "Would you like to install OpenVPN Data Channel Offload? (Y/n): " resp
        if [[ "$resp" == "N" || "$resp" == "n" ]]; then
            return 102
        fi
    fi
    echo
    echo "Checking and installing Linux kernel headers, please wait..."
    echo
    if check_install_headers "$1" ; then
        echo
        echo "Linux kernel headers are installed. Proceeding with DCO installation."
        echo
        echo "Keep in mind that if newer kernel versions are available,"
        echo "there is a possibility that DCO installation could fail."
        return 0
    else
        echo
        echo "WARNING: The actual kernel headers could not be located and installed." >&2
        echo "For further guidance, please refer to our online documentation or contact our support team." >&2
        echo "https://openvpn.net/as-docs/openvpn-dco.html" >&2
        echo
        echo "DCO can not be installed. Skipped." >&2
    fi
    return 101
}

check_install_headers() {
    if [[ "$1" == "rpm" ]]; then
        if rpm -q kernel-headers-$(uname -r) ; then
            return 0
        else
            if yum -y -q install kernel-headers-$(uname -r) kernel-devel-$(uname -r) ; then
                return 0
            fi
        fi
    elif [[ "$1" == "deb" ]]; then
        if dpkg -l |grep linux-headers-$(uname -r) ; then
            return 0
        else
            apt update -y -qq || repo_error
            if apt -y -qq install linux-headers-$(uname -r) ; then
                return 0
            fi
        fi
    fi
    return 100
}

check_version() {
    echo
    AVAILABLE_VERSION=""
    INSTALLED_VERSION=""
    if [[ "$1" == "rpm" ]]; then
        if [[ -n "$AS_VERSION" ]]; then
            AVAILABLE_VERSION=$(yum -y --showduplicates list available ${PLIST} |grep -F ${AS_VERSION} |tail -1 |awk -F' ' '{print $2}')
            if [[ -z "$AVAILABLE_VERSION" ]]; then
                as_version_not_found
            fi
            AS_VERSION="-${AVAILABLE_VERSION}"
        fi
        if rpm -q --quiet $PLIST ; then
            INSTALLED_VERSION=$(rpm -q --qf "%{VERSION}" $PLIST)
        fi
        if yum versionlock list 2>/dev/null |grep $PLIST |grep -q -v "yum\|bundled"; then
            IF_HELD=true
            held_message
        fi
    elif [[ "$1" == "deb" ]]; then
        if [[ -n "$AS_VERSION" ]]; then
            AVAILABLE_VERSION=$(apt-cache madison ${PLIST} |grep -F ${AS_VERSION} |head -1 |awk -F' ' '{print $3}')
            if [[ -z "$AVAILABLE_VERSION" ]]; then
                as_version_not_found
            fi
            AS_VERSION="=${AVAILABLE_VERSION}"
        fi
        if dpkg -s $PLIST 2>/dev/null |grep Status |grep -q "ok installed" ; then
            INSTALLED_VERSION=$(dpkg-query -W -f='${Version}\n' $PLIST)
        fi
        if apt-mark showhold |grep -q $PLIST ; then
            IF_HELD=true
            held_message
        fi
    else
        echo "Package type '$1' is not supported." >&2
        exit 200
    fi
    if [[ -n "$AS_VERSION" ]]; then
        if [[ -n "$INSTALLED_VERSION" ]]; then
            if echo "$AVAILABLE_VERSION" |grep -q "$INSTALLED_VERSION" ; then
                echo "The OpenVPN Access Server '$INSTALLED_VERSION' package is already installed."
                echo "Nothing to do."
                exit 0
            fi
            if echo -e "$INSTALLED_VERSION\n$AVAILABLE_VERSION" | sort --version-sort -C ; then
                upgrade_message "$INSTALLED_VERSION" "$AVAILABLE_VERSION"
            else
                downgrade_message "$INSTALLED_VERSION" "$AVAILABLE_VERSION"
            fi
        fi

        # Old DCO packages for AS < 3.2.0
        if echo -e "$AVAILABLE_VERSION\n3.1.9" | sort --version-sort -C ; then
            if [[ "$1" == "rpm" ]]; then
                DCO_NAME="kmod-ovpn-dco"
            elif [[ "$1" == "deb" ]]; then
                DCO_NAME="openvpn-dco-dkms"
            fi
        fi
    else
        if [[ -n "$INSTALLED_VERSION" ]]; then
            upgrade_message "$INSTALLED_VERSION"
        fi
    fi
}

upgrade_message() {
    echo "The OpenVPN Access Server '$1' package is currently installed."
    if [[ $# -eq 1 ]]; then
        echo "Upon proceeding, the script will check for a newer version available for"
        echo "the operating system and upgrade the existing installation."
    elif [[ $# -eq 2 ]]; then
        echo "Upon proceeding, the script will upgrade OpenVPN Access Server to '$2' version."
    else
        echo "Too many arguments passed. Aborted." >&2
        exit 201
    fi
    echo
}

downgrade_message() {
    echo "WARNING:"
    echo "The OpenVPN Access Server version '$2' to install is older than currently installed '$1'."
    echo "This is a downgrade and may cause issues."
    echo
    if ! $YES_MODE; then
        read -p "Do you still want to proceed with the downgrade? (Y/n): " downgrade_resp
        if [[ "$downgrade_resp" == "N" || "$downgrade_resp" == "n" ]]; then
            echo "Downgrade aborted." >&2
            exit 0
        fi
    fi
    APT_ALLOW_DOWNGRADES="--allow-downgrades"
}

held_message() {
    echo "WARNING:"
    echo "The current installation is pinned to that version so normal upgrade actions don't inadvertently upgrade it."
    echo "However, if you proceed this will be temporarily ignored and upgraded anyway."
    echo
}

as_version_not_found() {
    echo "Unfortunately the specified Access Server '$AS_VERSION' version could not be found for this operating system." >&2
    echo "Please refer to our online documentation or contact our support team for assistance." >&2
    echo "https://openvpn.net/as-docs/release-notes.html" >&2
    exit 6
}

confirmation_prompt() {
    if ! $YES_MODE; then
        echo "If you're ready to install OpenVPN Access Server, you can continue below."
        echo
        read -p "Do you want to proceed with the installation? (Y/n): " response

        if [[ "$response" == "N" || "$response" == "n" ]]; then
            echo "Installation aborted." >&2
            exit 0
        fi
    fi
    echo
    echo "Configuring repository and fetching data, please wait..."
    echo
}

sudo_message() {
    echo "Run the script either as root, or using sudo to perform the installation."
    echo
    echo "    sudo bash install.sh --yes"
    echo "or"
    echo "    sudo bash -c 'bash <(curl -fsS https://packages.openvpn.net/as/install.sh) --yes'"
    echo
}

user_root_check() {
    user="$(id -un 2>/dev/null || true)"

    if [ "$user" != "root" ]; then
        echo "This installer needs to run as root." >&2
        sudo_message
        echo "Aborted." >&2
        exit 5
    fi
}

# Check if firewalld is installed
check_firewalld() {
    if rpm -q firewalld &>/dev/null; then
        echo
        echo "WARNING: firewalld has been detected, this may interfere with the functioning of access server."
        echo "See https://openvpn.net/static-links/documentation-firewalld for more information on how to resolve this."
        echo
    fi
}

# Check if SELinux is enabled
check_selinux() {
    if sestatus 2>/dev/null | grep -q "SELinux status:.*enabled"; then
        echo
        echo "WARNING: SELinux has been detected, this may interfere with the functioning of access server."
        echo "See https://openvpn.net/static-links/documentation-selinux for more information on how to resolve this."
        echo
    fi
}

usage() {
    echo "Usage: install.sh [-y|--yes] [--without-dco] [--as-version VERSION] [-h|--help] [-v|--version]"
    echo
    echo "OpenVPN Access Server installation script."
    echo "Version: $INSTALLATION_SCRIPT_VERSION"
    echo
    echo "-y, --yes"
    echo "    Automatic yes to prompts. Assume 'yes' as answer to all prompts and run non-interactively."
    echo "    If an undesirable situation occurs then installation will abort."
    echo
    echo "--without-dco"
    echo "    Disables Data Channel Offload (DCO) installation."
    echo
    echo "--as-version VERSION"
    echo "    Specify the version of OpenVPN Access Server to install."
    echo
    echo "-h, --help"
    echo "    Shows a short usage summary."
    echo
    echo "-v, --version"
    echo "    Shows this script version."
    echo

    sudo_message
    exit 0
}

# getopt exit code is captured here to print 'usage' additionally to 'invalid/unrecognized option' error
set +e
options=$(getopt -n install.sh -o yvh --long yes,without-dco,as-version:,version,help -- "$@")
exit_code=$?
set -e
if [ $exit_code -ne 0 ] ; then
    [ $exit_code -eq 1 ] && usage || exit $exit_code
fi

eval set -- "$options"
while :; do
    case "$1" in
        -y|--yes) YES_MODE=true; shift ;;
        --without-dco) WITHOUT_DCO=true; shift ;;
        --as-version) AS_VERSION="$2"; shift 2 ;;
        -v|--version) echo "$INSTALLATION_SCRIPT_VERSION"; exit 0 ;;
        -h|--help) usage ;;
        --) shift; break ;;
        *) echo "Unknown option passed: $1"; usage ;;
    esac
done

echo
echo
echo "Welcome to the OpenVPN Access Server Installation Script!"
echo "Version: $INSTALLATION_SCRIPT_VERSION"
echo
echo
echo "WARNING: Please verify if there are any available security"
echo "and kernel updates for your operating system. We recommend"
echo "installing and applying these updates before proceeding."
echo

user_root_check
install_packages


# 'sacli status' check for additional verification
# if openvpn-as service is up and running after installation.
#
# Do not check openvpn-as service status if 'ovpn init' is skipped.
echo
if [ "${OVPN_INIT_MANUAL:-false}" = true ] ; then
    echo "Installation successful!"
    exit 0
fi
echo -n "Checking status (can take up to 1 minute): ."
exit_counter=12
while [ $exit_counter -gt 0 ] ; do
    if ! /usr/local/openvpn_as/scripts/sacli status 2>&1 |grep -q -i "error:" ; then
        echo "OK"
        echo "Installation successful!"
        exit 0
    fi
    sleep 5
    echo -n "."
    exit_counter=$((exit_counter - 1))
done

echo
echo "Installation successful but 'openvpn-as' service failed to start."
echo "Please run 'sudo sacli status' for more details or contact our support team for assistance."
