Поднимаем OpenConnect SSL VPN сервер (ocserv) в docker для внутренних проектов

Поднимаем OpenConnect SSL VPN сервер (ocserv) в docker для внутренних проектов

Обновлено 29.01.2025

Приветствую!

(Обновлено) Сегодня будем разворачивать свой VPN на базе OpenConnect сервера (ocserv), работающего поверх HTTPS и который совместим с Cisco Anyconnect. Все это добро мы упакуем в docker контейнер для простоты использования и лёгкой переносимости.

Данный сервер планируется использовать, как основной связующий элемент, с помощью которого будем настраивать сетевое взаимодействие будущих проектов и сервисов. Сама установка сервера выполняется всего в несколько команд. Будет интересно 😉

Присоединяйтесь к нашему каналу: t.me/r4ven_me и чату: t.me/r4ven_me_chat в Telegram .

Пожалуйста не пугайтесь “длинности” поста. В статье описано множество подробностей, но сама процедура развертывания очень проста и выполняется в несколько команд (загляните в TLDR). Настройка клиентов и то занимает больше времени.

TLDR

#############################################################
## при использовании домена для получения SSL сертификатов ## 
## расскоментируйте сервис certbot и параметр depends_on   ##
## у сервиса openconnect + укажите свои значения вместо    ##
## *example* в файле docker-compose.yml                    ##
#############################################################

### установка и настройка сервера с помощью готового образа ###

# создание директории проекта
mkdir /opt/openconnect && cd /opt/openconnect

# копирование файла docker-compose.yml
curl -sSLO https://raw.githubusercontent.com/r4ven-me/openconnect/main/docker-compose.yml

# запуск OpenConnect сервера
docker compose up -d && docker compose logs -f

# создание пользователя с id "exampleuser" и именем "Example User"
# файл сертификата .p12 будет создан в ./data/secrets
docker exec -it openconnect ocuser exampleuser 'Example User'

# настройка автозапуска с systemd
docker compose down
curl -fLo /etc/systemd/system/openconnect.service https://raw.githubusercontent.com/r4ven-me/openconnect/main/src/server/v1.3-sid/openconnect.service
systemctl daemon-reload
systemctl enable --now openconnect

### пример подключения клиента с помощью утилиты openconnect ###

# без домена
sudo openconnect -c /home/exampleuser/exampleuser.p12 12.345.67.89:43443 <<< $(echo "examplepassword"$'\n'yes$'\n')

# с доменом
sudo openconnect -c /home/exampleuser/exampleuser.p12 example.com:43443 <<< $(echo "examplepassword"$'\n')

Обращаю ваше внимание, что предоставленная в данной статье информация предназначена исключительно для образовательных целей. Любые действия, основанные на этой информации, осуществляются на ваш собственный риск и ответственность.

Содержание поста

Введение

Пару слов о том, в чем смысОл всего происходящего.

В корпоративном мире существует продукт Cisco AnyConnect, который представляет собой проприетарное ПО для организации виртуальных частных сетей (VPN), разработанное компанией Cisco.

Как это часто бывает в IT индустрии, если образуется весомый спрос на продукт, который имеет закрытый исходный код, появляется его “открытая” реализация. Самым популярным примером такого явления является Linux.

Как вы могли догадаться, рассматриваемый нами сегодня OpenConnect – это open source реализация ПО для организации VPN сервиса, совместимого с Cisco AnyConnect.

Вот что говорит зарубежная вики про OpenConnect:

OpenConnect is a free and open-source cross-platform multi-protocol virtual private network (VPN) client software which implement secure point-to-point connections.

Wikipedia

Проект OpenConnect также включает в себя сервер, который называется ocserv (OpenConnect server) и тем самым обеспечивает полноценное клиент-серверное VPN решение.

Основные преимущества такого решения:

  • Кроссплатформенность: клиенты под Linux, Windows, MacOS, Android, iOS, HarmonyOS;
  • Гибкость настройки, в т.ч. для каждого пользователя;
  • Шифрование соединения на основе SSL/TLS;
  • Простота настройки и использования;
  • Множество способов авторизации + двухфакторная и многое другое.

Предварительная подготовка

Для удобства и простоты использования мы с вами будем разворачивать OpenConnect сервер в docker контейнере, путем сборки образа и его запуска с помощью docker-compose. Обязательным условием является настроенный Linux сервер с установленным и запущенным в нем docker engine. А также наличие root привилегий (для моего примера) или группы docker у пользователя, если будете выполнять установку по своему.

Если с какой-то из пунктов отсутствует, возможно вам будут полезны следующие статьи:

В статье про установку docker’а также есть ссылки на полезные материалы, где доступным языком объясняется, что это такое, для чего нужен и из чего состоит. Если вы ранее не работали с данной технологией контейнерной виртуализации, то я настоятельно рекомендую их к прочтению.

Так, как ocserv работает поверх HTTPS c SSL шифрованием, рекомендованным (но необязательным) условиям является наличие валидного доменного имени. Конкретнее – DNS записи типа A, указывающую на внешний IP адрес вашего сервера. Добавление такой записи производится в настройках DNS вашего провайдера домена. В моём примере используется адрес: vpn.r4ven.me. При настройке OpenConnect адрес DNS необходим для бесплатного получения официальных SSL сертификатов от проекта Let’s Encrypt.

Если домена у вас нет, то при настройке сервера вместо DNS имени указывайте его IP адрес. При такой настройке на VPN клиентах при подключении будет выплывать уведомление о подключении к неподтвержденному источнику. В этом нет ничего страшного, просто это неудобно, и если честно, раздражает)

Схема проекта

Для визуального подкрепления, ниже представлена схема проекта будущего OpenConnect VPN сервера:

Сохранив картинку в голову, приступаем к установке и настройке.

Развертывание OpenConnect VPN сервера в docker

Действия из статьи выполнялись в среде дистрибутива:Debian 12
Образ docker контейнера с ocserv основан на:Debian sid
Версия ocserv внутри контейнера:1.3
Вводные данные

Для быстрого и удобного поднятия своего VPN сервера я подготовил небольшой проект с использованием docker, docker-compose и bash.

Весь процесс настройки сервера OpenConnect внутрни контейнера был автоматизирован.

Вот краткое описание проекта:

  • Сборка образа docker на основе Debian sid;
  • Установка в образ сервера ocserv и вспомогательных утилит;
  • Копирование в образ bash скрипта oscerv.sh;
  • При первом старте скрипт создает необходимые файлы и папки, в т.ч. основной конфиг ocserv.conf, скрипты подключения/отключения и файлы сертификатов;
  • Всё это сохраняется в docker volume, который монтируется в папочку ./data;
  • Файлы сервера можно с легкостью переносить на другие системы/платформы, где работает docker (просто перенесите папку /opt/openconnect);
  • Конфиг файлы сервера можно кастомизировать под свои нужды;
  • При старте контейнера скрипт oscserv.sh проверяет наличие файлов в директории ./data, если они есть, то просто запускается сервер, если нет, файлы создаются заново, и после запускается сервер;
  • Все необходимые значения для конфигов и сертификатов берутся из переменных окружения, определенные в файле .env (если не указать, будут использованы дефолтные значения);

Так, воды налил, теперь давайте приступать к установке и настройке нашего сервера.

Обновление системы

Первым делом рекомендуется обновить вашу систему до актуального состояния:

sudo -s

apt update && apt upgrade -y

Клонирование репозитория с исходными файлами проекта

Для клонирования git репозитория нам потребуется утилита командной строки, из состава пакета одноимённой системы контроля версий – git. Если эта система у вас не установлена, то выполняем:

apt install git

Теперь клонируем репозиторий с подготовленными файлами из моего GitHub, далее копируем директорию v1.3-sid по пути /opt/openconnect и переходим в неё:

git clone https://github.com/r4ven-me/openconnect /tmp/openconnect

cp -rv /tmp/openconnect/src/server/v1.3-sid /opt/openconnect && rm -rf /tmp/openconnect

cd /opt/openconnect

Директория /opt (optional) предназначена для установки “дополнительного” программного обеспечения, которое не является стандартным для данной ОС.

Файлы проекта имеет следующую структуру:

Где:

Клик ЛКМ для просмотра содержимого файла

.env - файл с переменными

TZ="Europe/Moscow"
SRV_PORT="43443"
SRV_CN="example.com"
SRV_CA="Example CA"
USER_EMAIL="mail@example.com"
#OTP_ENABLE="true"
#OTP_SEND_BY_EMAIL="true"
#MSMTP_HOST="smtp.example.com"
#MSMTP_PORT="465"
#MSMTP_USER="mail@example.com"
#MSMTP_PASSWORD="SuPeRsEcReTpAsSwOrD"
#MSMTP_FROM="mail@example.com"
#OTP_SEND_BY_TELEGRAM="true"
#TG_TOKEN="1234567890:QWERTYuio-PA1DFGHJ2_KlzxcVBNmqWEr3t"

docker-compose.yml - файл описания сервисов (контейнеров) openconnect и certbot (Let's Encrypt)

---

networks:

  vpn:
    ipam:
      driver: default
      config:
        - subnet: 172.22.22.0/24
          gateway: 172.22.22.1

services:

  certbot:
    image: certbot/certbot
    container_name: certbot
    hostname: certbot
    env_file:
      - .env
    volumes:
      - ./data/ssl:/etc/letsencrypt
    ports:
      - 80:80
    command: certonly --non-interactive --keep-until-expiring --standalone --preferred-challenges http --agree-tos --email ${USER_EMAIL} -d ${SRV_CN}

  openconnect:
    depends_on:
      certbot:
        condition: service_completed_successfully
    build: .
    image: openconnect
    #image: r4venme/openconnect:v1.3-sid
    container_name: openconnect
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 200M
    cap_add:
      - NET_ADMIN
    hostname: openconnect
    env_file:
      - .env
    volumes:
      - ./data:/etc/ocserv
      #- /etc/passwd:/etc/passwd:ro
      #- /etc/group:/etc/group:ro
      #- /etc/shadow:/etc/shadow:ro
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - ${SRV_PORT}:443/tcp
    networks:
      vpn:
        ipv4_address: 172.22.22.22

Dockerfile - файл описания docker образа сервера openconnect для сборки

FROM debian:sid

LABEL maintainer="Ivan Cherniy <kar-kar@r4ven.me>"

ENV OCSERV_DIR="/etc/ocserv"
ENV CERTS_DIR="${OCSERV_DIR}/certs"
ENV SSL_DIR="${OCSERV_DIR}/ssl"
ENV SECRETS_DIR="${OCSERV_DIR}/secrets"
ENV SCRIPTS_DIR="${OCSERV_DIR}/scripts"
ENV PATH="${SCRIPTS_DIR}:${PATH}"
ENV SRV_CN="example.com" 
ENV SRV_CA="Example CA"
ENV OTP_ENABLE="false"
ENV OTP_SEND_BY_EMAIL="false"
ENV OTP_SEND_BY_TELEGRAM="false"
ENV MSMTP_HOST="smtp.example.com"
ENV MSMTP_PORT="465"
ENV MSMTP_USER="mail@example.com"
ENV MSMTP_PASSWORD="PaSsw0rD"
ENV MSMTP_FROM="mail@example.com"
ENV TG_TOKEN="1234567890:QWERTYuio-PA1DFGHJ2_KlzxcVBNmqWEr3t"

WORKDIR $OCSERV_DIR

ARG DEBIAN_FRONTEND=noninteractive

RUN apt update && \
    apt install --yes --no-install-recommends \
        tini \
        ocserv \
        gnutls-bin \
        iptables \
        iproute2 \
        iputils-ping \
        less \
        ca-certificates \
        xxd \
        libpam-oath \
        oathtool \
        qrencode \
        curl \
        jq \
        msmtp && \
    apt autoremove --yes && \
    apt clean --yes && \
    rm -rf /var/lib/{apt,dpkg,cache,log}/*

COPY ./ocserv.sh /

ENTRYPOINT ["/ocserv.sh"]

CMD ["/usr/bin/tini", "--", "/usr/sbin/ocserv", "--config", "/etc/ocserv/ocserv.conf", "--foreground"]

HEALTHCHECK --interval=5m --timeout=3s \
    CMD curl -k https://localhost:443/ || exit 1

ocserv.sh - bash скрипт, запускаемый при старте контейнера, который выполняет начальную конфигурацию и инициализацию процесса ocserv

#!/bin/bash

# Some protection
set -Eeuo pipefail

# Define default server vars if they are not set
SRV_CN="${SRV_CN:=example.com}" 
SRV_CA="${SRV_CA:=Example CA}"
OTP_ENABLE="${OTP_ENABLE:=false}"
OTP_SEND_BY_EMAIL="${OTP_SEND_BY_EMAIL:=false}"
OTP_SEND_BY_TELEGRAM="${OTP_SEND_BY_TELEGRAM:=false}"
MSMTP_HOST="${MSMTP_HOST:=smtp.example.com}"
MSMTP_PORT="${MSMTP_PORT:=465}"
MSMTP_USER="${MSMTP_USER:=mail@example.com}"
MSMTP_PASSWORD="${MSMTP_PASSWORD:=PaSsw0rD}"
MSMTP_FROM="${MSMTP_FROM:=mail@example.com}"
TG_TOKEN="${TG_TOKEN:=1234567890:QWERTYuio-PA1DFGHJ2_KlzxcVBNmqWEr3t}"

# Ocserv vars (do not modify)
OCSERV_DIR="/etc/ocserv"
CERTS_DIR="${OCSERV_DIR}/certs"
SSL_DIR="${OCSERV_DIR}/ssl"
SECRETS_DIR="${OCSERV_DIR}/secrets"
SCRIPTS_DIR="${OCSERV_DIR}/scripts"

# Create certs dirs
for sub_dir in "${OCSERV_DIR}"/{"ssl/live/${SRV_CN}","certs","secrets","scripts"}; do
    if [[ ! -d "$sub_dir" ]]; then
        mkdir -p "$sub_dir"
    fi
done

if [[ -r /usr/share/doc/ocserv/sample.config && ! -e "${OCSERV_DIR}"/sample.config ]]; then
    cp /usr/share/doc/ocserv/sample.config "${OCSERV_DIR}"/
fi

# Create ocserv config file
if [[ ! -e "${OCSERV_DIR}"/ocserv.conf ]]; then
cat << _EOF_ > "${OCSERV_DIR}"/ocserv.conf
auth = "certificate"
#auth = "plain[passwd=${OCSERV_DIR}/ocpasswd]"
#auth = "plain[passwd=/etc/ocserv/ocpasswd,otp=/etc/ocserv/secrets/users.oath]"
#enable-auth = "certificate"
#enable-auth = "pam"
tcp-port = 443
socket-file = /run/ocserv-socket
server-cert = ${SSL_DIR}/live/${SRV_CN}/fullchain.pem
server-key = ${SSL_DIR}/live/${SRV_CN}/privkey.pem
ca-cert = ${CERTS_DIR}/ca-cert.pem
isolate-workers = true
max-clients = 20
max-same-clients = 2
rate-limit-ms = 200
server-stats-reset-time = 604800
keepalive = 10
dpd = 120
mobile-dpd = 1800
switch-to-tcp-timeout = 25
try-mtu-discovery = false
cert-user-oid = 0.9.2342.19200300.100.1.1
tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-TLS1.3"
auth-timeout = 1000
min-reauth-time = 300
max-ban-score = 100
ban-reset-time = 1200
cookie-timeout = 600
deny-roaming = false
rekey-time = 172800
rekey-method = ssl
connect-script = ${SCRIPTS_DIR}/connect
disconnect-script = ${SCRIPTS_DIR}/disconnect
use-occtl = true
pid-file = /run/ocserv.pid
log-level = 1
device = vpns
predictable-ips = true
default-domain = $SRV_CN
ipv4-network = 10.10.10.0
ipv4-netmask = 255.255.255.0
tunnel-all-dns = true
dns = 8.8.8.8
ping-leases = false
config-per-user = ${OCSERV_DIR}/config-per-user/
cisco-client-compat = true
dtls-legacy = true
client-bypass-protocol = false
crl = /etc/ocserv/certs/crl.pem
#camouflage = true
#camouflage_secret = "secretword"
#camouflage_realm = "Welcome to admin panel"
_EOF_
fi

# Create template for CA SSL cert
if [[ ! -e "${CERTS_DIR}"/ca.tmpl ]]; then
cat << _EOF_ > "${CERTS_DIR}"/ca.tmpl
organization = $SRV_CN
cn = $SRV_CA
serial = 001
expiration_days = -1
ca
signing_key
cert_signing_key
crl_signing_key
_EOF_
fi

# Create template for users SSL certs
if [[ ! -e "${CERTS_DIR}"/users.cfg ]]; then
cat << _EOF_ > "${CERTS_DIR}"/users.cfg
organization = $SRV_CN
cn = Example User
uid = exampleuser
expiration_days = -1
tls_www_client
signing_key
encryption_key
_EOF_
fi

# Create template for server self-signed SSL cert
if [[ ! -e "${SSL_DIR}"/server.tmpl ]]; then
cat << _EOF_ > "${SSL_DIR}"/server.tmpl
cn = $SRV_CA
dns_name = $SRV_CN
organization = $SRV_CN
expiration_days = -1
signing_key
encryption_key #only if the generated key is an RSA one
tls_www_server
_EOF_
fi

# Generate empty revoke file
if [[ ! -e "${CERTS_DIR}"/crl.tmpl ]]; then
cat << _EOF_ > "${CERTS_DIR}"/crl.tmpl
crl_next_update = 365
crl_number = 1
_EOF_
fi

# Create connect script which runs for every user connection
if [[ ! -e "${SCRIPTS_DIR}"/connect ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/connect && chmod +x "${SCRIPTS_DIR}"/connect
#!/bin/bash

set -Eeuo pipefail

echo "\$(date) User \${USERNAME} Connected - Server: \${IP_REAL_LOCAL} VPN IP: \${IP_REMOTE}  Remote IP: \${IP_REAL} Device:\${DEVICE}"
echo "Running iptables MASQUERADE for User \${USERNAME} connected with VPN IP \${IP_REMOTE}"
iptables -t nat -A POSTROUTING -s "\${IP_REMOTE}"/32 -o eth0 -j MASQUERADE
_EOF_
fi

# Create disconnect script which runs for every user disconnection
if [[ ! -e "${SCRIPTS_DIR}"/disconnect ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/disconnect && chmod +x "${SCRIPTS_DIR}"/disconnect
#!/bin/bash

set -Eeuo pipefail

echo "\$(date) User \${USERNAME} Disconnected - Bytes In: \${STATS_BYTES_IN} Bytes Out: \${STATS_BYTES_OUT} Duration:\${STATS_DURATION}"
iptables -t nat -D POSTROUTING -s "\${IP_REMOTE}"/32 -o eth0 -j MASQUERADE
_EOF_
fi

# Create script to create new users
if [[ ! -e "${SCRIPTS_DIR}"/ocuser ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/ocuser && chmod +x "${SCRIPTS_DIR}"/ocuser
#!/bin/bash

set -Eeuo pipefail

# Check and set script params
if [[ \$# -eq 2 ]]; then
    USER_UID="\$1"
    USER_CN="\$2"
elif [[ \$# -eq 3 ]]; then
	if [[ "\$1" == "-A" ]]; then
    		USER_UID="\$2"
    		USER_CN="\$3"
	else
		echo "Use -A key as a first param to generate cert for IOS devices" >&2
        exit 1
	fi
else
    echo "Please run script with two params: username and 'Common Username'" >&2
    echo "Example: ocuser john 'John Doe'" >&2
    echo "For IOS or HarmonyOS devices add -A key as first param in command" >&2
    echo "Example: ocuser -A steve 'Steve Jobs'" >&2
    exit 1
fi

# Modify user cert template and generate user key, cert and protected .p12 file
sed -i -e "s/^organization.*/organization = \$SRV_CN/" -e "s/^cn.*/cn = \$USER_CN/" -e "s/^uid.*/uid = \$USER_UID/g" "\${CERTS_DIR}"/users.cfg
echo "\$(tr -cd "[:alnum:]" < /dev/urandom | head -c 60)" | ocpasswd -c "\${OCSERV_DIR}"/ocpasswd "\$USER_UID"
certtool --generate-privkey --outfile "\${CERTS_DIR}"/"\${USER_UID}"-privkey.pem
certtool --generate-certificate --load-privkey "\${CERTS_DIR}"/"\${USER_UID}"-privkey.pem --load-ca-certificate "\${CERTS_DIR}"/ca-cert.pem --load-ca-privkey "\${CERTS_DIR}"/ca-key.pem --template "\${CERTS_DIR}"/users.cfg --outfile "\${CERTS_DIR}"/"\${USER_UID}"-cert.pem
if [[ "\$1" == "-A" ]]; then
	sleep 1 && certtool --to-p12 --load-certificate "\${CERTS_DIR}"/"\${USER_UID}"-cert.pem --load-privkey "\${CERTS_DIR}"/"\${USER_UID}"-privkey.pem --pkcs-cipher 3des-pkcs12 --hash SHA1 --outder --outfile "\${SECRETS_DIR}"/"\${USER_UID}".p12
else
	sleep 1 && certtool --load-certificate "\${CERTS_DIR}"/"\${USER_UID}"-cert.pem --load-privkey "\${CERTS_DIR}"/"\${USER_UID}"-privkey.pem --pkcs-cipher aes-256 --to-p12 --outder --outfile "\${SECRETS_DIR}"/"\${USER_UID}".p12
fi
_EOF_
fi

# Add revoke script
if [[ ! -e "${SCRIPTS_DIR}"/ocrevoke ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/ocrevoke && chmod +x "${SCRIPTS_DIR}"/ocrevoke
#!/bin/bash

set -Eeuo pipefail

if [[ ! -e "\${CERTS_DIR}"/crl.tmpl ]]; then
cat << __EOF__ > "\${CERTS_DIR}"/crl.tmpl
crl_next_update = 365
crl_number = 1
__EOF__
fi

if [[ \$# -eq 1 ]]; then
    if [[ "\$1" == "HELP" ]]; then
        echo "Usage:
        CMD to revoke cert of some user: ocrevoke <exist_user> 
        CMD to apply current revoked.pem: ocrevoke RELOAD
        CMD to reset all revokes: ocrevoke RESET
        CMD to print this help: ocrevoke HELP"
    elif [[ "\$1" == "RESET" ]]; then
        certtool --generate-crl --load-ca-privkey "\${CERTS_DIR}"/ca-key.pem --load-ca-certificate "\${CERTS_DIR}"/ca-cert.pem --template "\${CERTS_DIR}"/crl.tmpl --outfile "\${CERTS_DIR}"/crl.pem
        occtl reload
    elif [[ "\$1" == "RELOAD" ]]; then
        certtool --generate-crl --load-ca-privkey "\${CERTS_DIR}"/ca-key.pem --load-ca-certificate "\${CERTS_DIR}"/ca-cert.pem --load-certificate "\${CERTS_DIR}"/revoked.pem --template "\${CERTS_DIR}"/crl.tmpl --outfile "\${CERTS_DIR}"/crl.pem
    else
        USER_UID="\$1"
        cat "\${CERTS_DIR}"/"\${USER_UID}"-cert.pem >> "\${CERTS_DIR}"/revoked.pem
        certtool --generate-crl --load-ca-privkey "\${CERTS_DIR}"/ca-key.pem --load-ca-certificate "\${CERTS_DIR}"/ca-cert.pem --load-certificate "\${CERTS_DIR}"/revoked.pem --template "\${CERTS_DIR}"/crl.tmpl --outfile "\${CERTS_DIR}"/crl.pem
        occtl reload
    fi
else
    echo "Usage:
    CMD to revoke cert of some user: ocrevoke <exist_user> 
    CMD to apply current revoked.pem: ocrevoke RELOAD
    CMD to reset all revokes: ocrevoke RESET
    CMD to print this help: ocrevoke HELP"
fi
_EOF_
fi

# Add ocuser2fa script
if [[ "$OTP_ENABLE" == "true" && ! -e "${SCRIPTS_DIR}"/ocuser2fa ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/ocuser2fa && chmod +x "${SCRIPTS_DIR}"/ocuser2fa
#!/bin/bash

set -Eeuo pipefail

if [[ \$# -eq 1 ]]; then
    USER_ID="\$1"
    OTP_SECRET="\$(head -c 16 /dev/urandom | xxd -c 256 -ps)"
    OTP_SECRET_BASE32="\$(echo 0x"\${OTP_SECRET}" | xxd -r -c 256 | base32)"
    OTP_SECRET_QR="otpauth://totp/\$USER_ID?secret=\$OTP_SECRET_BASE32&issuer=$SRV_CA&algorithm=SHA1&digits=6&period=30"

    if [[ ! -e "\${SECRETS_DIR}"/users.oath ]] || ! grep -qP "(?<!\\S)\${USER_ID}(?!\\S)" "\${SECRETS_DIR}"/users.oath; then
        echo "HOTP/T30 \$USER_ID - \$OTP_SECRET" >> "\${SECRETS_DIR}"/users.oath
        echo "OTP secret for \$USER_ID: \$OTP_SECRET"
        echo "OTP secret in base32: \$OTP_SECRET_BASE32"
        echo "OTP secret in QR code:"
        qrencode -t ANSIUTF8 "\$OTP_SECRET_QR"
        qrencode "\$OTP_SECRET_QR" -s 10 -o "\${SECRETS_DIR}"/otp_"\${USER_ID}".png
        echo "TOTP secret in png image saved at: \${SECRETS_DIR}/otp_\${USER_ID}.png"

        send_qr_by_email() {
            EMAIL_REGEX="^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"

            if [[ \$USER_ID =~ \$EMAIL_REGEX ]]; then
                cat << EOF | msmtp --file="\${SCRIPTS_DIR}"/msmtprc "\$USER_ID"
Subject: TOTP QR code for OpenConnect auth
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="boundary"

--boundary
Content-Type: text/plain

TOTP secret for OpenConnect (base32):
\$OTP_SECRET_BASE32

--boundary
Content-Type: image/png; name="file.png"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="file.png"

\$(base64 "\${SECRETS_DIR}"/otp_"\${USER_ID}".png)
--boundary--
EOF
                echo "[\$(date '+%F %T')] - TOTP secret and QR code successfully sent to \$USER_ID via Email" | tee -a "\${OCSERV_DIR}"/pam.log
            else
                return 0
            fi
        }

        if [[ "\$OTP_SEND_BY_EMAIL" == "true" ]]; then send_qr_by_email; fi

        send_qr_by_telegram() {
            TG_REGEX="^[a-zA-Z][a-zA-Z0-9_]{4,31}\$"

            if [[ \$USER_ID =~ \$TG_REGEX ]]; then
                TG_MESSAGE="TOTP secret for OpenConnect (base32):
\$OTP_SECRET_BASE32"
                TG_USER_FILE="\${SCRIPTS_DIR}/tg_users.txt"
                
                if grep -qP "(?<!\\S)\${USER_ID}(?!\\S)" "\$TG_USER_FILE" 2> /dev/null; then
                    TG_CHAT_ID=\$(grep -P "(?<!\\S)\${USER_ID}(?!\\S)" "\$TG_USER_FILE" | awk '{print \$1}')
                else
                    TG_RESPONSE="\$(curl -s "https://api.telegram.org/bot\$TG_TOKEN/getUpdates")"
                    TG_CHAT_ID=\$(echo "\$TG_RESPONSE" | jq -r --arg USERNAME "\$USER_ID" '.result[] | select(.message.from.username == \$USERNAME) | .message.chat.id')

                    if [[ -z "\$TG_CHAT_ID" ]]; then
                        echo "[\$(date '+%F %T')] - User was not found or did not interact with the bot" >> "\${OCSERV_DIR}"/pam.log
                        return 0
                    fi
                    echo "\$TG_CHAT_ID \$USER_ID" >> "\$TG_USER_FILE"
                fi

                curl -s -X POST "https://api.telegram.org/bot\$TG_TOKEN/sendPhoto" \\
                    -H "Content-Type: multipart/form-data" \\
                    -F "chat_id=\$TG_CHAT_ID" \\
                    -F "photo=@\${SECRETS_DIR}/otp_\${USER_ID}.png" \\
                    -F "caption=\$TG_MESSAGE" > /dev/null 2>> "\${OCSERV_DIR}"/pam.log

                echo "[\$(date '+%F %T')] - TOTP secret and QR code successfully sent to \$USER_ID via Telegram" | tee -a "\${OCSERV_DIR}"/pam.log
            fi
        }

        if [[ "\$OTP_SEND_BY_TELEGRAM" == "true" ]]; then send_qr_by_telegram; fi

    else
        echo "OTP token already exists for \$USER_ID in \${SECRETS_DIR}/users.oath"
        exit 1
    fi
else
    echo "Usage: \$(basename "\$0") <user_id>"
    exit 1
fi
_EOF_
fi

if [[ "$OTP_ENABLE" == "true" && ! -e "${SCRIPTS_DIR}"/otp_sender ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/otp_sender && chmod +x "${SCRIPTS_DIR}"/otp_sender
#!/bin/bash

set -Eeuo pipefail

OCSERV_DIR="$OCSERV_DIR"
SECRETS_DIR="$SECRETS_DIR"
SCRIPTS_DIR="$SCRIPTS_DIR"
OTP_SEND_BY_EMAIL="$OTP_SEND_BY_EMAIL"
OTP_SEND_BY_TELEGRAM="$OTP_SEND_BY_TELEGRAM"
TG_TOKEN="$TG_TOKEN"

echo "[\$(date '+%F %T')] - PAM user \$PAM_USER is trying to connect to ocserv" >> "\${OCSERV_DIR}"/pam.log

otp_sender_by_email() {
    EMAIL_REGEX="^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
    if [[ \$PAM_USER =~ \$EMAIL_REGEX ]]; then true; else return 0; fi

    if [[ -e "\${SECRETS_DIR}"/users.oath ]] && grep -qP "(?<!\\S)\${PAM_USER}(?!\\S)" "\${SECRETS_DIR}"/users.oath; then
        OTP_TOKEN="\$(oathtool --totp=SHA1 --time-step-size=30 --digits=6 \$(grep -P "(?<!\\S)\${PAM_USER}(?!\\S)" \${SECRETS_DIR}/users.oath | awk '{print \$4}'))"

        echo -e "Subject: TOTP token for OpenConnect\n\n\${OTP_TOKEN}" | msmtp --file="\${SCRIPTS_DIR}"/msmtprc "\$PAM_USER"
        echo "[\$(date '+%F %T')] - TOTP token successfully sent to \$PAM_USER" >> "\${OCSERV_DIR}"/pam.log
    fi
}

otp_sender_by_telegram() {
    TG_REGEX="^[a-zA-Z][a-zA-Z0-9_]{4,31}\$"
    if [[ \$PAM_USER =~ \$TG_REGEX ]]; then true; else return 0; fi

    if grep -qP "(?<!\\S)\${PAM_USER}(?!\\S)" "\${SECRETS_DIR}"/users.oath 2> /dev/null; then
        OTP_TOKEN="\$(oathtool --totp=SHA1 --time-step-size=30 --digits=6 \$(grep -P "(?<!\\S)\${PAM_USER}(?!\\S)" \${SECRETS_DIR}/users.oath | awk '{print \$4}'))"
        TG_MESSAGE="TOTP token for OpenConnect: \$OTP_TOKEN"
        TG_USER_FILE="\${SCRIPTS_DIR}/tg_users.txt"
        
        if grep -qP "(?<!\\S)\$PAM_USER(?!\\S)" "\$TG_USER_FILE"; then
            TG_CHAT_ID=\$(grep -P "(?<!\\S)\${PAM_USER}(?!\\S)" "\$TG_USER_FILE" | awk '{print \$1}')
        else
            TG_RESPONSE="\$(curl -s "https://api.telegram.org/bot\$TG_TOKEN/getUpdates")"
            TG_CHAT_ID=\$(echo "\$TG_RESPONSE" | jq -r --arg USERNAME "\$PAM_USER" '.result[] | select(.message.from.username == \$USERNAME) | .message.chat.id')
    
            if [[ -z "\$TG_CHAT_ID" ]]; then
                echo "[\$(date '+%F %T')] - User was not found or did not interact with the bot" >> "\${OCSERV_DIR}"/pam.log
                return 0
            fi
            echo "\$TG_CHAT_ID \$PAM_USER" >> "\$TG_USER_FILE"
        fi  
        
        curl -s -X POST "https://api.telegram.org/bot\$TG_TOKEN/sendMessage" -d "chat_id=\$TG_CHAT_ID" -d "text=\$TG_MESSAGE" 2>> "\${OCSERV_DIR}"/pam.log
        echo "[\$(date '+%F %T')] - TOTP token successfully sent to \$PAM_USER" >> "\${OCSERV_DIR}"/pam.log
    fi
}

if [[ "\$OTP_SEND_BY_EMAIL" == "true" ]]; then otp_sender_by_email; fi &

if [[ "\$OTP_SEND_BY_TELEGRAM" == "true" ]]; then otp_sender_by_telegram; fi &
_EOF_
elif [[ "$OTP_ENABLE" == "true" && -e "${SCRIPTS_DIR}"/otp_sender ]]; then
    sed -i "s|OCSERV_DIR=.*|OCSERV_DIR=\"$OCSERV_DIR\"|" "${SCRIPTS_DIR}"/otp_sender
    sed -i "s|SECRETS_DIR=.*|SECRETS_DIR=\"$SECRETS_DIR\"|" "${SCRIPTS_DIR}"/otp_sender
    sed -i "s|SCRIPTS_DIR=.*|SCRIPTS_DIR=\"$SCRIPTS_DIR\"|" "${SCRIPTS_DIR}"/otp_sender
    sed -i "s|OTP_SEND_BY_EMAIL=.*|OTP_SEND_BY_EMAIL=\"$OTP_SEND_BY_EMAIL\"|" "${SCRIPTS_DIR}"/otp_sender
    sed -i "s|OTP_SEND_BY_TELEGRAM=.*|OTP_SEND_BY_TELEGRAM=\"$OTP_SEND_BY_TELEGRAM\"|" "${SCRIPTS_DIR}"/otp_sender
    sed -i "s|TG_TOKEN=.*|TG_TOKEN=\"$TG_TOKEN\"|" "${SCRIPTS_DIR}"/otp_sender
fi

# Add msmtprc config
if [[ "$OTP_ENABLE" == "true" && "$OTP_SEND_BY_EMAIL" == "true" && ! -e "${OCSERV_DIR}"/msmtprc ]]; then
cat << _EOF_ > "${SCRIPTS_DIR}"/msmtprc && chmod 400 "${SCRIPTS_DIR}"/msmtprc
account default
host $MSMTP_HOST
port $MSMTP_PORT
auth on
user $MSMTP_USER
password $MSMTP_PASSWORD
from $MSMTP_FROM
tls on
tls_starttls off
logfile $OCSERV_DIR/pam.log
_EOF_
fi

# Config OTP with PAM
pam_otp() {
    if [[ $OTP_ENABLE == "true" ]]; then
        until [[ -e /etc/pam.d/ocserv ]]; do sleep 5; done
        if grep -q 'otp_sender' /etc/pam.d/ocserv && grep -q 'users.oath' /etc/pam.d/ocserv; then return 0; fi
        sleep 3
        echo "auth optional pam_exec.so ${SCRIPTS_DIR}/otp_sender" >> /etc/pam.d/ocserv
        echo "auth requisite pam_oath.so debug usersfile=${SECRETS_DIR}/users.oath window=20" >> /etc/pam.d/ocserv
    fi
}

# Start ocserv service
if [[ -e "${SSL_DIR}"/live/"${SRV_CN}"/privkey.pem && -e "${SSL_DIR}"/live/"${SRV_CN}"/fullchain.pem && -e "${CERTS_DIR}"/ca-key.pem && -e "${CERTS_DIR}"/ca-cert.pem ]]; then
    pam_otp &
    echo "Starting OpenConnect Server"
    exec "$@" || { echo "Starting failed" >&2; exit 1; }
else
    # Server certificates generation
    certtool --generate-privkey --outfile "${CERTS_DIR}"/ca-key.pem
    certtool --generate-self-signed --load-privkey "${CERTS_DIR}"/ca-key.pem --template "${CERTS_DIR}"/ca.tmpl --outfile "${CERTS_DIR}"/ca-cert.pem
    certtool --generate-crl --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --template "${CERTS_DIR}"/crl.tmpl --outfile "${CERTS_DIR}"/crl.pem
    if [[ ! -e "${SSL_DIR}"/live/"${SRV_CN}"/privkey.pem && ! -e "${SSL_DIR}"/live/"${SRV_CN}"/fullchain.pem ]]; then
        certtool --generate-privkey --outfile "${SSL_DIR}"/live/"${SRV_CN}"/privkey.pem
        certtool --generate-certificate --load-privkey "${SSL_DIR}"/live/"${SRV_CN}"/privkey.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --template "${SSL_DIR}"/server.tmpl --outfile "${SSL_DIR}"/live/"${SRV_CN}"/fullchain.pem
    fi
    
    pam_otp &
    echo "Starting OpenConnect Server"
    exec "$@" || { echo "Starting failed" >&2; exit 1; }
fi

openconnect.service - файл unit'а systemd для автозапуска

[Unit]
Description=OpenConnect VPN service
Requires=docker.service
After=docker.service

[Service]
Restart=on-failure
RestartSec=5
WorkingDirectory=/opt/openconnect
ExecStart=/usr/bin/docker compose up
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target

ssl_update.sh - bash скрипт обновления SSL сертификатов от Let's Encrypt

#!/usr/bin/env bash

set -e

WORK_DIR="/opt/openconnect"
CONTAINER_NAME="openconnect"

to_log () {
    local text="$1"
    echo "[$(date '+%F %T')] ${text}"
}

cd "$WORK_DIR" || exit 1

if [[ -r ./docker-compose.yml ]]; then
    to_log "Run certbot service container"
    docker compose up certbot
    sleep 3
    to_log "Reload ocserv config"
    docker exec "$CONTAINER_NAME" occtl reload
    to_log "Delete all unused docker images"
    docker system prune -af
fi

Переопределение переменных: домен, имя сервера, email и пр.

Теперь необходимо задать переменные окружения, которые будут использованы для автоматической подстановки при сборке и запуске контейнера. Для этого открываем любым редактором файл .env, лежащий в директории с файлами проекта:

Файл .env обычно используется для определения переменных окружения, которые считываются утилитой docker-compose, при обращении к ним в файле описания сервисов – docker-compose.yml. Также эти переменные мы передадим внутрь контейнера openconnect.

vim .env

Всего 5 переменных:

  • TZ – временная зона;
  • SRV_PORT – сетевой порт подключения к серверу;
  • SRV_CN – тут задаём доменное имя, которое указывает на Linux сервер (если домена нет, то указываем произвольное имя);
  • SRV_CA – названия центра сертификации для самоподписанных сертификатов (произвольное, задать обязательно);
  • USER_EMAIL – адрес электронной почты, если используется доменное имя. Данный параметр обязателен для получения SSL сертификатов от letsencrypt.

В комментариях подсказали, что в случае подключения к ocserv роутером Keenetic, “то порт в .env нужно ставить дефолтный 443 (который не нужно указывать в строке подключения)“. Иные порты, данный роутер, игнорирует.

Сохраняем файл и выходим:

:wq

Понравился мой конфиг Neovim? Можете с легкостью создать аналогичный по статье: Neovim – Установка и настройка редактора кода с элементами IDE всего в несколько команд.

Сборка образа и первый запуск контейнера с помощью docker-compose

Если у вас нет домена, переходите к шагам внутри спойлера ниже: “Клик сюда, если домена нет”.

Теперь просто запускаем команду сборки образа с последующим запуском контейнера в фоновом режиме и после подключаемся к стандартному выводу docker-compose:

docker compose up -d && docker compose logs -f

Обратите внимание, что сервис openconnect, описанный в файле docker-compose.yml намеренно ограничен в аппаратных ресурсах на использование cpus: '0.50' и memory: 200M, т.е максимально разрешенное использование CPU составляет 50% одного ядра и 200 мб RAM. При необходимости скорректируйте данные параметры в соответствии со своими потребностями.

Подробнее про лимиты ресурсов сервисов при использовании docker-compose читайте тут.

В docker-compose.yml параметром depends-on указан порядок запуска сервисов. Сперва отрабатывает certbot (Let’s Encrypt), который запрашивает и получает валидные SSL сертификаты, и только затем стартует контейнер с VPN сервером:

Подробную документацию по использованию certbot смотрите тут.

В случае успеха вывод будет, как на скрине выше. Просмотр вывода можно прервать комбинацией Ctrl+C.

Чтобы убедится, что контейнер запущен выполняем:

docker compose ps

ls -l ./data

ls -l ./data/ssl/live/vpn.r4ven.me/

Должна создаться папка ./data с файлами сервера + сертификаты, полученные контейнером cerbot.

ЛКМ для просмотра описания содержимого папки data, которое создаёт скрипт ocserv.sh при первом старте

  • ocserv.conf – файл конфигурации нашего VPN сервера. Также можете модифицировать под свои нужды. К слову, полный конфиг ocserv с описанием доступных параметров можно изучить на официальном GitLab проекта по ссылке;
auth = "certificate"
#auth = "plain[passwd=/etc/ocserv/ocpasswd]"
#auth = "plain[passwd=/etc/ocserv/ocpasswd,otp=/etc/ocserv/secrets/users.oath]"
#enable-auth = "certificate"
#enable-auth = "pam"
tcp-port = 443
socket-file = /run/ocserv-socket
server-cert = /etc/ocserv/ssl/live/example.com/fullchain.pem
server-key = /etc/ocserv/ssl/live/example.com/privkey.pem
ca-cert = /etc/ocserv/certs/ca-cert.pem
isolate-workers = true
max-clients = 20
max-same-clients = 2
rate-limit-ms = 200
server-stats-reset-time = 604800
keepalive = 10
dpd = 120
mobile-dpd = 1800
switch-to-tcp-timeout = 25
try-mtu-discovery = false
cert-user-oid = 0.9.2342.19200300.100.1.1
tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-TLS1.3"
auth-timeout = 1000
min-reauth-time = 300
max-ban-score = 100
ban-reset-time = 1200
cookie-timeout = 600
deny-roaming = false
rekey-time = 172800
rekey-method = ssl
connect-script = /etc/ocserv/scripts/connect
disconnect-script = /etc/ocserv/scripts/disconnect
use-occtl = true
pid-file = /run/ocserv.pid
log-level = 1
device = vpns
predictable-ips = true
default-domain = example.com
ipv4-network = 10.10.10.0
ipv4-netmask = 255.255.255.0
tunnel-all-dns = true
dns = 8.8.8.8
ping-leases = false
config-per-user = /etc/ocserv/config-per-user/
cisco-client-compat = true
dtls-legacy = true
client-bypass-protocol = false
crl = /etc/ocserv/certs/crl.pem
#camouflage = true
#camouflage_secret = "secretword"
#camouflage_realm = "Welcome to admin panel"
  • bin – директория с внутренними скриптами/командами контейнера openconnect:
  • connect – bash скрипт, который выполняется для каждого клиента при подключении к нашему VPN серверу. Его можно расширять и кастоимизировать под любые ваши нужды. Главное не нарушьте инзначальную функциональность);
#!/bin/bash

set -Eeuo pipefail

echo "$(date) User ${USERNAME} Connected - Server: ${IP_REAL_LOCAL} VPN IP: ${IP_REMOTE}  Remote IP: ${IP_REAL} Device:${DEVICE}"
echo "Running iptables MASQUERADE for User ${USERNAME} connected with VPN IP ${IP_REMOTE}"
iptables -t nat -A POSTROUTING -s ${IP_REMOTE}/32 -o eth0 -j MASQUERADE
  • disconnect – bash скрипт, который выполняется при отключении пользователя;
#!/bin/bash

set -Eeuo pipefail

echo "$(date) User ${USERNAME} Disconnected - Bytes In: ${STATS_BYTES_IN} Bytes Out: ${STATS_BYTES_OUT} Duration:${STATS_DURATION}"
iptables -t nat -D POSTROUTING -s "${IP_REMOTE}"/32 -o eth0 -j MASQUERADE
  • ocuser – bash скрипт для создания новых пользователей и сертификатов подключения .p12;
#!/bin/bash

set -Eeuo pipefail

# Check and set script params
if [[ $# -eq 2 ]]; then
    USER_UID="$1"
    USER_CN="$2"
elif [[ $# -eq 3 ]]; then
	if [[ "$1" == "-A" ]]; then
    		USER_UID="$2"
    		USER_CN="$3"
	else
		echo "Use -A key as a first param to generate cert for IOS devices" >&2
        exit 1
	fi
else
    echo "Please run script with two params: username and 'Common Username'" >&2
    echo "Example: ocuser john 'John Doe'" >&2
    echo "For IOS or HarmonyOS devices add -A key as first param in command" >&2
    echo "Example: ocuser -A steve 'Steve Jobs'" >&2
    exit 1
fi

# Modify user cert template and generate user key, cert and protected .p12 file
sed -i -e "s/^organization.*/organization = $SRV_CN/" -e "s/^cn.*/cn = $USER_CN/" -e "s/^uid.*/uid = $USER_UID/g" "${CERTS_DIR}"/users.cfg
echo "$(tr -cd "[:alnum:]" < /dev/urandom | head -c 60)" | ocpasswd -c "${OCSERV_DIR}"/ocpasswd "$USER_UID"
certtool --generate-privkey --outfile "${CERTS_DIR}"/"${USER_UID}"-privkey.pem
certtool --generate-certificate --load-privkey "${CERTS_DIR}"/"${USER_UID}"-privkey.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --template "${CERTS_DIR}"/users.cfg --outfile "${CERTS_DIR}"/"${USER_UID}"-cert.pem
if [[ "$1" == "-A" ]]; then
	sleep 1 && certtool --to-p12 --load-certificate "${CERTS_DIR}"/"${USER_UID}"-cert.pem --load-privkey "${CERTS_DIR}"/"${USER_UID}"-privkey.pem --pkcs-cipher 3des-pkcs12 --hash SHA1 --outder --outfile "${SECRETS_DIR}"/"${USER_UID}".p12
else
	sleep 1 && certtool --load-certificate "${CERTS_DIR}"/"${USER_UID}"-cert.pem --load-privkey "${CERTS_DIR}"/"${USER_UID}"-privkey.pem --pkcs-cipher aes-256 --to-p12 --outder --outfile "${SECRETS_DIR}"/"${USER_UID}".p12
fi
  • ocuser2fa – bash скрипт для генерации секретов TOTP при использовании двухфакторной авторизации;
#!/bin/bash

set -Eeuo pipefail

if [[ $# -eq 1 ]]; then
    USER_ID="$1"
    OTP_SECRET="$(head -c 16 /dev/urandom | xxd -c 256 -ps)"
    OTP_SECRET_BASE32="$(echo 0x"${OTP_SECRET}" | xxd -r -c 256 | base32)"
    OTP_SECRET_QR="otpauth://totp/$USER_ID?secret=$OTP_SECRET_BASE32&issuer=Example CA&algorithm=SHA1&digits=6&period=30"

    if [[ ! -e "${SECRETS_DIR}"/users.oath ]] || ! grep -qP "(?<!\S)${USER_ID}(?!\S)" "${SECRETS_DIR}"/users.oath; then
        echo "HOTP/T30 $USER_ID - $OTP_SECRET" >> "${SECRETS_DIR}"/users.oath
        echo "OTP secret for $USER_ID: $OTP_SECRET"
        echo "OTP secret in base32: $OTP_SECRET_BASE32"
        echo "OTP secret in QR code:"
        qrencode -t ANSIUTF8 "$OTP_SECRET_QR"
        qrencode "$OTP_SECRET_QR" -s 10 -o "${SECRETS_DIR}"/otp_"${USER_ID}".png
        echo "TOTP secret in png image saved at: ${SECRETS_DIR}/otp_${USER_ID}.png"

        send_qr_by_email() {
            EMAIL_REGEX="^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"

            if [[ $USER_ID =~ $EMAIL_REGEX ]]; then
                cat << EOF | msmtp --file="${SCRIPTS_DIR}"/msmtprc "$USER_ID"
Subject: TOTP QR code for OpenConnect auth
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="boundary"

--boundary
Content-Type: text/plain

TOTP secret for OpenConnect (base32):
$OTP_SECRET_BASE32

--boundary
Content-Type: image/png; name="file.png"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="file.png"

$(base64 "${SECRETS_DIR}"/otp_"${USER_ID}".png)
--boundary--
EOF
                echo "[$(date '+%F %T')] - TOTP secret and QR code successfully sent to $USER_ID via Email" | tee -a "${OCSERV_DIR}"/pam.log
            else
                return 0
            fi
        }

        if [[ "$OTP_SEND_BY_EMAIL" == "true" ]]; then send_qr_by_email; fi

        send_qr_by_telegram() {
            TG_REGEX="^[a-zA-Z][a-zA-Z0-9_]{4,31}$"

            if [[ $USER_ID =~ $TG_REGEX ]]; then
                TG_MESSAGE="TOTP secret for OpenConnect (base32):
$OTP_SECRET_BASE32"
                TG_USER_FILE="${SCRIPTS_DIR}/tg_users.txt"
                
                if grep -qP "(?<!\S)${USER_ID}(?!\S)" "$TG_USER_FILE" 2> /dev/null; then
                    TG_CHAT_ID=$(grep -P "(?<!\S)${USER_ID}(?!\S)" "$TG_USER_FILE" | awk '{print $1}')
                else
                    TG_RESPONSE="$(curl -s "https://api.telegram.org/bot$TG_TOKEN/getUpdates")"
                    TG_CHAT_ID=$(echo "$TG_RESPONSE" | jq -r --arg USERNAME "$USER_ID" '.result[] | select(.message.from.username == $USERNAME) | .message.chat.id')

                    if [[ -z "$TG_CHAT_ID" ]]; then
                        echo "[$(date '+%F %T')] - User was not found or did not interact with the bot" >> "${OCSERV_DIR}"/pam.log
                        return 0
                    fi
                    echo "$TG_CHAT_ID $USER_ID" >> "$TG_USER_FILE"
                fi

                curl -s -X POST "https://api.telegram.org/bot$TG_TOKEN/sendPhoto" \
                    -H "Content-Type: multipart/form-data" \
                    -F "chat_id=$TG_CHAT_ID" \
                    -F "photo=@${SECRETS_DIR}/otp_${USER_ID}.png" \
                    -F "caption=$TG_MESSAGE" > /dev/null 2>> "${OCSERV_DIR}"/pam.log

                echo "[$(date '+%F %T')] - TOTP secret and QR code successfully sent to $USER_ID via Telegram" | tee -a "${OCSERV_DIR}"/pam.log
            fi
        }

        if [[ "$OTP_SEND_BY_TELEGRAM" == "true" ]]; then send_qr_by_telegram; fi

    else
        echo "OTP token already exists for $USER_ID in ${SECRETS_DIR}/users.oath"
        exit 1
    fi
else
    echo "Usage: $(basename "$0") <user_id>"
    exit 1
fi
  • otp_sender – bash скрипт для отправки TOTP токенов с помощью Email и Telegram при использовании PAM авторизации и включенном втором факторе (2FA);
#!/bin/bash

set -Eeuo pipefail

OCSERV_DIR="/etc/ocserv"
SECRETS_DIR="/etc/ocserv/secrets"
SCRIPTS_DIR="/etc/ocserv/scripts"
OTP_SEND_BY_EMAIL="true"
OTP_SEND_BY_TELEGRAM="true"
TG_TOKEN="1234567890:QWERTYuio-PA1DFGHJ2_KlzxcVBNmqWEr3t"

echo "[$(date '+%F %T')] - PAM user $PAM_USER is trying to connect to ocserv" >> "${OCSERV_DIR}"/pam.log

otp_sender_by_email() {
    EMAIL_REGEX="^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
    if [[ $PAM_USER =~ $EMAIL_REGEX ]]; then true; else return 0; fi

    if [[ -e "${SECRETS_DIR}"/users.oath ]] && grep -qP "(?<!\S)${PAM_USER}(?!\S)" "${SECRETS_DIR}"/users.oath; then
        OTP_TOKEN="$(oathtool --totp=SHA1 --time-step-size=30 --digits=6 $(grep -P "(?<!\S)${PAM_USER}(?!\S)" ${SECRETS_DIR}/users.oath | awk '{print $4}'))"

        echo -e "Subject: TOTP token for OpenConnect\n\n${OTP_TOKEN}" | msmtp --file="${SCRIPTS_DIR}"/msmtprc "$PAM_USER"
        echo "[$(date '+%F %T')] - TOTP token successfully sent to $PAM_USER" >> "${OCSERV_DIR}"/pam.log
    fi
}

otp_sender_by_telegram() {
    TG_REGEX="^[a-zA-Z][a-zA-Z0-9_]{4,31}$"
    if [[ $PAM_USER =~ $TG_REGEX ]]; then true; else return 0; fi

    if grep -qP "(?<!\S)${PAM_USER}(?!\S)" "${SECRETS_DIR}"/users.oath 2> /dev/null; then
        OTP_TOKEN="$(oathtool --totp=SHA1 --time-step-size=30 --digits=6 $(grep -P "(?<!\S)${PAM_USER}(?!\S)" ${SECRETS_DIR}/users.oath | awk '{print $4}'))"
        TG_MESSAGE="TOTP token for OpenConnect: $OTP_TOKEN"
        TG_USER_FILE="${SCRIPTS_DIR}/tg_users.txt"
        
        if grep -qP "(?<!\S)$PAM_USER(?!\S)" "$TG_USER_FILE"; then
            TG_CHAT_ID=$(grep -P "(?<!\S)${PAM_USER}(?!\S)" "$TG_USER_FILE" | awk '{print $1}')
        else
            TG_RESPONSE="$(curl -s "https://api.telegram.org/bot$TG_TOKEN/getUpdates")"
            TG_CHAT_ID=$(echo "$TG_RESPONSE" | jq -r --arg USERNAME "$PAM_USER" '.result[] | select(.message.from.username == $USERNAME) | .message.chat.id')
    
            if [[ -z "$TG_CHAT_ID" ]]; then
                echo "[$(date '+%F %T')] - User was not found or did not interact with the bot" >> "${OCSERV_DIR}"/pam.log
                return 0
            fi
            echo "$TG_CHAT_ID $PAM_USER" >> "$TG_USER_FILE"
        fi  
        
        curl -s -X POST "https://api.telegram.org/bot$TG_TOKEN/sendMessage" -d "chat_id=$TG_CHAT_ID" -d "text=$TG_MESSAGE" 2>> "${OCSERV_DIR}"/pam.log
        echo "[$(date '+%F %T')] - TOTP token successfully sent to $PAM_USER" >> "${OCSERV_DIR}"/pam.log
    fi
}

if [[ "$OTP_SEND_BY_EMAIL" == "true" ]]; then otp_sender_by_email; fi &

if [[ "$OTP_SEND_BY_TELEGRAM" == "true" ]]; then otp_sender_by_telegram; fi &
  • ocrevoke – bash скрипт для отзыва сертификатов пользователей;
#!/bin/bash

set -Eeuo pipefail

if [[ ! -e "${CERTS_DIR}"/crl.tmpl ]]; then
cat << __EOF__ > "${CERTS_DIR}"/crl.tmpl
crl_next_update = 365
crl_number = 1
__EOF__
fi

if [[ $# -eq 1 ]]; then
    if [[ "$1" == "HELP" ]]; then
        echo "Usage:
        CMD to revoke cert of some user: ocrevoke <exist_user>
        CMD to apply current revoked.pem: ocrevoke RELOAD
        CMD to reset all revokes: ocrevoke RESET
        CMD to print this help: ocrevoke HELP"
    elif [[ "$1" == "RESET" ]]; then
        certtool --generate-crl --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --template "${CERTS_DIR}"/crl.tmpl --outfile "${CERTS_DIR}"/crl.pem
        occtl reload
    elif [[ "$1" == "RELOAD" ]]; then
        certtool --generate-crl --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --load-certificate "${CERTS_DIR}"/revoked.pem --template "${CERTS_DIR}"/crl.tmpl --outfile "${CERTS_DIR}"/crl.pem
    else
        USER_UID="$1"
        cat "${CERTS_DIR}"/"${USER_UID}"-cert.pem >> "${CERTS_DIR}"/revoked.pem
        certtool --generate-crl --load-ca-privkey "${CERTS_DIR}"/ca-key.pem --load-ca-certificate "${CERTS_DIR}"/ca-cert.pem --load-certificate "${CERTS_DIR}"/revoked.pem --template "${CERTS_DIR}"/crl.tmpl --outfile "${CERTS_DIR}"/crl.pem
        occtl reload
    fi
else
    echo "Usage:
    CMD to revoke cert of some user: ocrevoke <exist_user>
    CMD to apply current revoked.pem: ocrevoke RELOAD
    CMD to reset all revokes: ocrevoke RESET
    CMD to print this help: ocrevoke HELP"
fi
  • certs – директория с SSL файлами нашего “центра сертификации”;
  • secrets – директория, в которой хранятся сертификаты (.p12) пользователей после их создания;
  • ssl – SSL сертификаты openconnect сервера, самоподписанные или полученные от проекта Let’s Encrypt.

Также проверяем прослушиваемый и проброшенный в хост контейнером TCP порт, такой командой:

ss -tnap | grep -E '43443'

Всё, сервер запущен и работает. Осталось создать пользователей и настроить клиентское подключение.

Клик сюда, если домена нет

В таком случае правим файл описания сервисов docker-compose.yml:

vim docker-compose.yml

В этом файле необходимо закоментировать сервис certbot и директиву depends_on у сервиса openconnect, как показано на скриншоте:

После чего запускаем сборку и смотрим вывод:

docker compose up -d && docker compose logs -f

Если все отработало корректно, выходим из режима просмотра логов клавишей Ctrl+C и проверяем запущенный сервис:

docker compose ps

ss -tnap | grep -E '43443'

Также проверяем наличие системных файлов ocserv:

ls -l ./data

Отлично, идём дальше.

Создание пользователей

Пару слов про способы авторизации на OpenConnect сервере.

Т.к. ocserv популярен в корпоративном мире, он поддерживает множество способов авторизации пользователей. От банального логина/пароля, до интеграции с централизованными инструментами, такими, как LDAP/Active Directory, PAM, Radius и пр. Из коробки даже имеется поддержка двухфакторной аутентификации на основе токенов – этот функционал + возможность отправки токенов TOTP по почте и в Telegram добавлен в контейнер, описание приведу позже в этой статье.

В качестве надежного и в тоже время удобного способа авторизации я выбрал подключение на основе пользовательского сертификата, который упаковывается в шифрованный (при указании пароля) файл с расширением .p12. В результате со стороны клиента необходимо будет лишь указать путь до файла сертификата и пароль для его расшифровки. Обычно клиенты способны запоминать пароли и вводить их более не требуется.

Стоит отметить, что для создания пользователя ему все таки необходимо задать пароль, хоть и такой способ авторизации отключен в конфиг файле сервера. Если вы проявили любопытство и заглянули в содержимое скрипта создания пользователей ocuser, то могли заметить команду генерации псевдослучайной строки: tr -cd “[:alnum:]” < /dev/urandom | head -c 60

Вывод которой задается в качестве пароля для пользователя при его создании. Небольшая подстраховка)

Создание пользователей для Linux/Windows/Android

И так, создаём пользователя такой командой:

docker exec -it openconnect ocuser ivan 'Ivan Cherniy'

Где ivan – это имя и идентификатор пользователя в ocserv (старайтесь указывать идентификатор без пробелов, дабы избежать ошибок), а ‘Ivan Cherniy’ это полное имя пользователя, которое будет отображено в метаданных пользовательского сертификата.

После ввода команды необходимо будет в интерактивном режиме указать имя файла сертификата и пароль для его шифрования.

Если команда создания отработала успешно, для указанного пользователя будет создан файл сертификата .p12 в директории secrets:

ls -l ./data/secrets

Список пользователей можно посмотреть в файле ./data/ocpasswd.

Создание пользователей для HarmonyOS/iOS

При создании пользователей для мобильных ОС от Huawei и Apple команде ocuser необходимо передать ключ -A. В таком случае будет использован другой алгоритм шифрования сертификата.

Пример команды:

docker exec -it openconnect ocuser -A steve 'Steve Jobs'

Проверяем созданный сертификат:

ls -l ./data/secrets

Если для устройств под управлением HarmonyOS и iOS сгенерировать сертификаты без ключа -A, то при настройке клиентского подключения, вы будете получать ошибку на этапе импорта сертификата.

Note that the Ciso AnyConnect app on iOS doesn’t support AES-256 cipher. It will refuse to import the client certificate. If the user is using iOS device, then you can choose the 3des-pkcs12cipher.

linuxbabe.com

Скачивание файла p12

Скачиваем созданный файл сертификата пользователя удобным для вас способом. Например:

cp ./data/secrets/ivan.p12 /tmp

А на клиентской машине:

scp vpn.r4ven.me:/tmp/ivan.p12 .
ls -l ivan.p12

На этом этапе уже можно переходить к настройке клиентов. Но если вам нужны персональные настройки каждого пользователя, такие как IP адрес клиента, маршруты, DNS, а также настройка автозапуска сервиса OpenConnect и автопродление SSL сертификатов, то идём последовательно.

Опционально: индивидуальные настройки ocserv для каждого пользователя

Создаём системную директорию config-per-user и отдельный файл под каждого пользователя, с таким же именем, какое указывали при создании (если забыли, посмотрите в файле ./data/ocpasswd):

mkdir ./data/config-per-user

vim ./data/config-per-user/ivan

Название параметров в файле конфига пользователя в большинстве своем идентичны основному конфигу сервера:

explicit-ipv4 = 10.10.10.5
route = 10.10.10.50/32
route = 64.233.164.139/32
dns = 1.1.1.1

В примере выше я задаю для пользователя ivan конкретный IP (из подсети, что указана в основном конфиге), отдельные маршруты и адрес DNS сервера.

Upd. Если вы предпочитаете повысить уровень конфиденциальности, то при тунелировании трафика желательно иметь собственный DNS сервер. Вот инструкция по его настройке и интеграции с OpenConnect:

Поднимаем свой DNS сервер Unbound и блокировщик рекламы Pihole в docker

При такой конфигурации данный пользователь будет обращаться к указанным ресурсам через VPN сервер, а остальной трафик будет идти через его шлюз по умолчанию (или нет, в зависимости от настроек сети).

После внесения изменений в конфиг необходимо выполнить перечитывание файла конфигурации процессом ocserv внутри контейнера. Делается такой командой:

docker exec -it openconnect occtl reload

Автозапуск сервера OpenConnect с помощью systemd

Теперь настроим автоматический запуск/перезапуск нашего VPN сервера при помощи системы инициализации systemd.

Из директории с файлами проекта копируем файл openconnect.service в системную директорию systemd, перезагружаем конфигурацию systemd и останавливаем наши запущенные контейнеры:

cp ./openconnect.service /etc/systemd/system/

systemctl daemon-reload

docker compose down

Теперь активируем автозапуск и пробуем выполнить старт контейнера с OpenConnect сервером как сервис systemd:

systemctl enable --now openconnect

systemctl status openconnect

docker compose ps

Все отлично.

Настройка автоматического обновления SSL сертификатов (если есть домен)

Особенностью сертификатов от центра сертификации Let’s Encrypt является то, что они выдаются на 3 месяца. В такой ситуации предусмотрительно будет настроить автоматическое продление. Для этого добавляем в системный планировщик заданий cron запуск скрипта ssl_update.sh раз в неделю, например, в воскресенье в 3 часа утра:

{ crontab -l; echo "0 3 * * 0 /opt/openconnect/ssl_update.sh"; } | crontab -

crontab -l

Синтаксис фигурных скобок в bash { command1; command2 } позволяет объединить несколько команд в одну. Тут мы с его помощью первой командой выводим список заданий cron текущего пользователя, второй командой выводим строку с новым заданием и потом с помощью механизма перенаправления передаем весь вывод команде crontab -. Если команде crontab - передать только одну задачу, она затрет все предыдущие. Так делать не стоит)

Хоть и в моём примере нет других заданий в планировщике, команда специально составлена с “защитой от дурака”)

Для проверки работоспособности скрипта продления сертификатов, можем выполнить его вручную:

UPD 16.05.2024

В команде обновления certbot заменил параметр --force-renewal на --keep-until-expiring, который выполняет проверку срока действия сертификата, и если он не близок к завершению, то обновление сертификатов не происходит.

/opt/openconnect/ssl_update.sh

Скрипт также почистит дисковое пространство от неиспользуемых образов docker:

Проверяем время обновления SSL файлов:

ls -l ./data/ssl/live/vpn.r4ven.me/

Так, вроде все хорошо)

Обратите внимание, что если слишком часто обновлять сертификаты, то certbot (letsencrypt) будет выпадать в ошибку по этой причине.

Настройка подключения клиентов

Настройка OpenConnect клиента для Linux

Способ №1: менеджер сетевых соединений – NetworkManager

Если вы пользователь популярных дестопных дистрибутивов, то вероятнее всего вашей сетевой подсистемой управлет NetworkManager.

Сразу отмечу, что данный способ предпочтителен для десктопных систем, т.к. при использовании NM для подключения к ocserv не происходит конфликтов системы DNS.

В случае deb based систем, выполняем установку специального пакета-плагина NetworkManager для подключения к ocserv.

По традиции, продемонстрирую на примере Linux Mint 21 Cinnamon (Ubuntu 22.04).

Команда установки плагина:

sudo apt update && sudo apt install network-manager-openconnect-gnome

После установки:

  1. Заходим в “Сетевые соединения” через трей или любым удобным вам способом;
  2. Затем нажимаем кнопку добавления нового соединения, в списке выбираем “Cisco AntConnect or openconnect (OpenConnect)” и нажимаем “Создать“;
  3. В открвшемся окошке указываем
    • Имя соединения” – произвольное
    • Шлюз” – DNS имя нашего сервера или его IP адрес (если без домена) + порт подключения. Пример: vpn.r4ven.me:43443
  4. В разделе “Аутентификации по сертификату” в параметре “Сертификат пользователя” выбираем наш .p12 файл, который мы сгнерировали на этапе создания пользователя. В старых версиях nmapplet’а есть баг, когда при выборе сертификата он не видит файлы с расширенияем .p12. В таком случае просто перетащите (drag-and-drop) файл с файлового менеджера в поле данного параметра, как показано на скриншоте ниже;
  5. После нажимаем “Сохранить”.

Если все сделано корректно, пробуем подключиться к нашему серверу. Кликаем на апплет сетевых соединений, затем на переключатель “VPN подключения“. Должно появиться окошко, где нас любезно попросят ввести пароль от файла-сертификата (если вы его задавали), который мы устанавливали на этапе создания пользователей ocserv. Ставим галочку “Сохранить пароль” и затем “Подключиться“. При успешном соединении на рабочем столе появится соответствующее уведомление:

Также новое подключение для NetworkManager можно создать с помощью консольной утилиты nmcli. Вот пример:

nmcli connection add \
    type vpn \
    con-name "vpn.r4ven.me" \
    ifname '*' \
    vpn-type openconnect \
    vpn.data "gateway=vpn.r4ven.me:43443, usercert=/home/ivan/ivan.p12"

Пробуем подключиться:

nmcli connection up vpn.r4ven.me

Успешно.

Посмотреть сетевые параметры нашего VPN подключения можно с помощью команд:

ip -c address
nmcli

Узнать внешний IP в консоли можно такой командой:

curl ifconfig.me

В выводе команды будет одна строка с вашим внешним IP адресом.

Способ №2: утилита командной строки – openconnect

Другой способ клиентского подключения в Linux, где отсутствует NetworkManager – это одноименная консольная утилита openconnect. Такой способ подключения чаще всего используется для клиентов без GUI, т.е. на Linux серверах.

Пример подключения все также для deb based систем.

Выполняем установку клиентского пакета openconnect:

sudo apt update && sudo apt install openconnect

Подключится к серверу OpenConnect можно такой командой:

# если с доменом
sudo openconnect -c /home/ivan/ivan.p12 vpn.r4ven.me:43443 <<< $(echo "password"$'\n')

# если без домена (с доп. подтверждением самоподписанного сертификата)
sudo openconnect -c /home/ivan/ivan.p12 12.345.67.89:43443 <<< $(echo "password"$'\n'yes$'\n')

Где ключу -c передается путь до файла сертификата .p12 а с помощью механизма here string (<<<) и подстановки передается вывод команды echo "password"$'\n', которая выводит текстовую строку и выполняет перевод строки, в данном случае, это имитация ввода Enter, чтобы не вводить вручную.

Замените password на ваш пароль от сертификата.

Для автоматизации процесса подключения/переподключения с помощью утилиты openconnect я написал небольшой скрипт. Изучить его можно в статье: Пишем bash скрипт для подключения к OpenConnect VPN серверу.

Результат команды подключения должен быть примерно таким:

Если вы используете подключение с помощью утилиты openconnect на Linux сервере, и планируете туннелировать весь его трафик, то необходимо добавить специальные правила маршрутизации, без которых вы потеряете доступ до своего сервера, в т.ч. и по SSH:

ip rule add table 128 from <public-ip>
ip route add table 128 to <public_ip_subnet> dev <ineteface_name>
ip route add table 128 default via <gateway>

Где:

  • 128 – имя новой таблицы маршрутизации;
  • <piblic-ip> – основной IP вашего сервера;
  • <public_ip_subnet> – подсеть основного IP сервера;
  • <interface_name> – имя физического интерфейса, к которому подключен основной IP;
  • <gateway> – шлюз сети, через который ходит основной IP.

Настройка OpenConnect клиента для Windows/MacOS

Для настройки клиента на Windows и MacOS необходимо скачать графическую программу-клиент с официального сайта https://gui.openconnect-vpn.net/download/ или на странице релизов в GitLab: https://gitlab.com/openconnect/openconnect-gui/-/releasesGitLab:

Далее устанавливаем программу на рабочий стол Windows обычными кликами “Далее, далее..”. А вот как установить клиента на MacOS я вам не подскажу. Тут уже вы сами изучите вопрос, ссылки на все материалы будут внизу.

После установки и запуска программы OpenConnectVPN выполняем настройку, как показано на скриншотах ниже. Отмечу лишь один момент: обязательно активируйте параметр “Disable UDP“, т.к. в нашей конфигурации сервера его использование отключено:

Проверяем сетевые параметры:

Все работает.

Настройка OpenConnect клиента для Android/HarmonyOS

Для мобильных ОС необходимо установить приложение Cisco Secure Client-AnyConnect.

Пожалуйста, обратите внимание, что данное мобильное приложение не имеет открытый исходный код. При его использовании придется довериться разработчику.

Существует open source мобильный клиент, но его разработка прекратилась n-е количество лет назад и у меня оно работало некорректно.

После открытия приложения необходимо создать подключение, указать адрес шлюза + порт и импортировать сертификат пользователя. Пошаговая инструкция показана на скриншотах ниже:

Настройка OpenConnect клиента для iOS

Инструкция любезно предоставлена подписчиком в Вороньем чате

Скачиваешь файл на телефон. Открываешь файлы и зажимаешь этот значок (просто при тычке он будет писать техническое сообщение о том, что профиль загружен). Выбираешь пункт поделиться – Еще – Листаешь в самый низ, там будет Any Connect. Открывается приложение и запрашивает пароль. Вводишь и тыкаешь импорт.
И дальше уже по твоей схеме в настройках туннеля выбираешь сертификат.

Ну вот, вроде бы всё.

Блокировка пользователей путём отзыва сертификата

При необходимости заблокировать какого-то пользователя, сделать это можно с помощью отзыва его SSL сертификата.

Если вы только что подняли ocserv по этой инструкции, просто воспользуйтесь командой ocrevoke внутри контейнера:

# отозвать сертификат пользователя ivan
docker exec -it openconnect ocrevoke ivan

# перечитать черный список пользователей
docker exec -it openconnect ocrevoke RELOAD

# удалить всех из черного списка
docker exec -it openconnect ocrevoke RESET

Где вместо ivan подставьте имя пользователя, доступ по сертификату которого нужно заблокировать.

При следующем подключении пользователь получит ошибку:

Если же ваш сервер был поднят по этой инструкции некоторое время назад, придется выполнить немного ручной работы.

Сперва в конец конфига ./data/ocserv.conf добавляем параметр:

crl = /etc/ocserv/certs/crl.pem

Затем выполняем команды:

cat ./data/certs/ivan-cert.pem >> ./data/certs/revoked.pem

docker exec -it openconnect certtool --generate-crl --load-ca-privkey /etc/ocserv/certs/ca-key.pem --load-ca-certificate /etc/ocserv/certs/ca-cert.pem --load-certificate /etc/ocserv/certs/revoked.pem --template /etc/ocserv/certs/crl.tmpl --outfile /etc/ocserv/certs/crl.pem

docker exec -it openconnect occtl reload

Где вместо ivan в первой команде подставьте имя пользователя, доступ по сертификату которого нужно заблокировать.

Как вы догадались, ключевым тут является файл ./data/certs/revoked.pem, куда добавляются публичные сертификаты пользователей <user_name>-cert.pem.

Для очистки всего черного списка выполните:

docker exec -it openconnect certtool --generate-crl --load-ca-privkey /etc/ocserv/certs/ca-key.pem --load-ca-certificate /etc/ocserv/certs/ca-cert.pem --template /etc/ocserv/certs/crl.tmpl --outfile /etc/ocserv/certs/crl.pem

docker exec -it openconnect occtl reload

При отсутствии необходимости данного функционала, просто закомментируйте строку crl = /etc/ocserv/certs/crl.pem в конфиге ocserv.

Авторизация по логину/паролю

В некоторых случаях может понадобиться включение возможности авторизации по логину и паролю. Например при настройке openconnect на роутерах Keenetic, который не поддерживает подключение на основе сертификата.

В файле конфигурации ocserv – /opt/openconnect/data/ocserv.conf разрешаем авторизацию по логину/паролю, путем добавления такого параметра:

enable-auth = "plain[passwd=/etc/ocserv/ocpasswd]"

Стоит отметить, что при такой конфигурации авторизация по сертификату останется.

Затем перезапускаем сервер:

docker compose restart openconnect

Далее создаём нового пользователя:

docker exec -it openconnect ocpasswd exampleuser

И задаем ему пароль.

После чего можно подключаться с помощью пары логин/пароль.
Обязательно указывайте только длинные и сложные пароли.

Настройка двухфактороной аутентификации (2FA/TOTP) с помощью ocpasswd и PAM

Ocserv из коробки поддерживает двухфакторную аутентификацию двух типов: HOTP, TOTP. В моей конфигурации используется TOTP, как более распространенный вариант.

Настроить 2FA в ocserv можно для двух способов авторизации на сервере: для внутренних пользователей в файле ocpasswd и внешних пользователей хоста с помощью модуля PAM.

Обратите, внимание, что в ocserv одновременно может использоваться только один вариант парольной аутентификации. Комментарий из оф. документации:

Note that authentication methods utilizing passwords cannot be combined (e.g., the plain, pam or radius methods).

Источник

Стоит отметить, что PAM аутентификация предоставляет более гибкий подход к настройке аутентификации пользователей на сервере. Именно с помощью PAM внутри контейнера реализован функционал отправки секретов и токенов TOTP по Email и в Telegram (об этом ниже).

Для активации 2FA внутри контейнера необходимо отредактировать файлы .env и ocserv.conf.

Правка .env

Добавить/изменить параметр:

OTP_ENABLE="true"

Правка ocserv.conf

Выбрать один из способов аутентификации, для которой будет применяться второй фактор:

  • 1) 2FA с помощью ocpasswd:
auth = "plain[passwd=/etc/ocserv/ocpasswd,otp=/etc/ocserv/secrets/users.oath]"

При работе такой аутентификации необходимо создать пользователя стандартным способом и задать ему пароль. Пример:

docker exec -it openconnect ocpasswd ivan
  • 2) 2FA с помощью PAM:
auth = "pam"

В случае PAM, вам также необходимо прокинуть файлы со списком локальных пользователей хостовой ОС в контейнер в файле docker-compose.yml:

    volumes:
      - ./data:/etc/ocserv
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /etc/shadow:/etc/shadow:ro

Обратите внимание, что при каждом добавлении нового пользователя в хостовой системе для синхронизации примонтированных файлов необходимо перезапускать контейнер.

Пример создания локального пользователя Linux без домашней директории и без возможности входа в сеанс оболочки ОС:

adduser --no-create-home --allow-bad-names --quiet --shell /bin/false --comment "Ocserv user" ivan@r4ven.me

Перезапуск сервиса

Теперь пересоздаем контейнер:

systemctl restart openconnect

Двухфакторная аутентификация активирована, осталось настроить ее для существующих пользователей. Для этого воспользуйтесь специальной командой/скриптом ocuser2fa. Пример для пользователя ivan:

docker exec -it openconnect ocuser2fa ivan

Команда выведет в консоль секрет, сгенерированный для указанного пользователя, а также этот секрет в формате base32 и в виде QR кода для сканирования-сохранения его в специальном приложении аутентификаторе на смартфоне, например: FreeOTP, Google authenticator, Yandex ключ и др.

Также для удобства QR код сохраняется в виде png файла в директории /opt/openconnect/data/secrets/otp_<user_id>.png.

Чаще всего при сканировании QR проблем с генерацией корректных TOTP не возникает, но с добавлением секрета вручную есть нюансы. Вот правильные параметры TOTP, поддерживаемые ocserv при ручном добавлении секрета на примере приложения FreeOTP:

  • Username — произвольное значение
  • Company — произвольное значение
  • Secret — секрет в кодировке base32
  • Type — totp
  • Digits — 6
  • Algorithm — SHA1
  • Interval — 30

Получать одноразовые пароли можно также в консоли Linux с помощью утилиты oathtool:

apt install -y oathtool

oathtool --base32 --totp=SHA1 --time-step-size=30 --digits=6 <totp_base32_secret>

Замените <totp_base32_secret> на сгенерированный командой ocuser2fa секрет.

Каждый одноразовый токен доступен для применения в течении 20 “окон”, т.е. 20 * 30 сек = 10 мин.

(Опиционально) Отправка TOTP секрета + QR код и одноразовых паролей по Email и в Telegram

В контейнере реализован функционал отправки секрета TOTP в виде строки base32 и QR кода в виде png файла на Email адрес/Telegram чат пользователя во время работы команды ocuser2fa.

А также, при наличии соответствующей переменной, активируется возможность отправки по указанным каналам одноразовых паролей при каждом подключении к VPN серверу – работает только при использовании PAM авторизации, т.к. сам ocserv не умеет запускать кастомные скрипты конкретно в процессе аутентификации пользователя.

Отправка по Email

Отправка по почте осуществляется через внешний почтовый сервис с помощью консольного SMTP клиента – msmtp. Для его работы необходимо задать соответствующие параметры через переменные среды в файле .env. Вот пример:

MSMTP_HOST="smtp.example.com"
MSMTP_PORT="465"
MSMTP_USER="email@example.com"
MSMTP_PASSWORD="supersecretpassword"
MSMTP_FROM="email@example.com"
# для отправки одноразовых паролей при каждой авторизации
OTP_SEND_BY_EMAIL="true"

При необходимости скорректируйте параметры msmtp в файле /opt/openconnect/data/scrits/msmtprc после пересоздания контейнера.

В качестве пароля рекомендуется использовать “пароль приложения”, а не полноценный пароль от почтового аккаунта. Реквизиты SMTP серверов всех популярных провайдеров легко гуглятся.

Данные отправляются по Email, только если в качестве имени пользователя ocserv используется email адрес в стандартном формате: username@example.com.

Отправка в Telegram

Отправка сообщений в телегу осуществляется путем отправки HTTP запросов с помощью curl на api.telegram.org. Для работы этой функции требуется:

1) токен телеграм бота (инструкция по получению из оф. доки или более понятно тут), который необходимо задать в файле .env, пример:

TG_TOKEN="1234567890:QWERTYqwerty-QWERTY123_QwErTy123qWeRtY123"
# для отправки одноразовых паролей при каждой авторизации
OTP_SEND_BY_TELEGRAM="true"

2) пользователю, которому будут отправляться секрет с QR кодом и одноразовые токены необходимо написать вашему боту в чат любое сообщение в течение последних 24 часов (требуется единожды)

3) имя пользователя ocserv должно четко совпадать с nickname пользователя в Telegram

Перезапуск сервиса

Для применения изменений перезапускаем сервис:

systemctl restart openconnect

Проверка

Настраиваем 2FA для существующего пользователя с именем ввиде email адреса:

docker exec -it openconnect ocuser2fa ivan@r4ven.me

На почту должны приходить подобные письма:

Для пользователя с именем, совпадающим с именем телеграм:

docker exec -it openconnect ocuser2fa ivan

В чате с ботом будет подобное:

Защита от автоматизированного доступа – Camouflage

С версии ocserv >= 1.23 доступен дополнительный слой защиты от сканирования интернет ботами, которые могут в автоматическом режиме подобирать резкизиты для доступа к внутренней сети. Для активации защиты внесите следующие параметры в ваш ocserv.conf:

# включение защиты от active probing
camouflage = true
# дополнительная часть url для подключения
camouflage_secret = "secretword"
# активация ложного окна авторизации, которое отклоняет любые попытки входа
#camouflage_realm = "My admin panel"

И перезапустите сервер:

systemctl restart openconnect

Теперь для подключения к серверу на клиенте необходимо указывать адрес с дополнительным сабурлом, указанного в конфиге. Пример:

https://vpn.r4ven.me:43443/?secretword

А все запросы к базовому урлу: https://vpn.r4ven.me:43443/ будут завершаться ошибкой клиента – 404. А если активен параметр camouflage_realm = "My admin panel", то запросы будут упираться в ложное окно авторизации.

Полезные команды systemctl/docker/docker-compose/ocserv

Небольшой список команд, который может вам пригодится при настройке и обслуживании OpenConnect сервера.

# управление сервисом openconnect с помощью systemd
systemctl stop openconnect
systemctl start openconnect
systemctl restart openconnect

# показывает список всех доступных образов docker
docker image ls

# показывает список всех контейнеров docker, включая работающие и остановленные
docker container ls -a

# удаляет все ненужные ресурсы docker, такие как неиспользуемые контейнеры, образы, сети и тома, без запроса подтверждения
docker system prune -af

# показывает статус и информацию о контейнерах, запущенных с помощью docker-compose
docker compose ps

# запускает контейнеры из файла docker-compose.yml в фоновом режиме
docker compose up -d

# останавливает и удаляет все контейнеры, сети, запущенные с помощью docker-compose
docker compose down

# отображает стандартный вывод контейнеров, запущенных с помощью docker-compose
docker compose logs -f

# перезапуск контейнера с именем "openconnect"
docker compose restart openconnect

# запускает интерактивную оболочку bash внутри контейнера "openconnect"
docker exec -it openconnect bash

# выводит справку по команде утилиты управления VPN сервером
docker exec -it openconnect occtl --help

# перечитывает файл конфигурации ocserv
docker exec -it openconnect occtl reload

# показывает статус ocserv
docker exec -it openconnect occtl show status

# показывает список пользователей ocserv
docker exec -it openconnect occtl show users

# показывает список активных сеансов ocserv
docker exec -it openconnect occtl show sessions all

# создает пользователя с id "ivan" и именем "Ivan Cherniy" для ocserv
docker exec -it openconnect ocuser ivan 'Ivan Cherniy'

Заключение

Фух! Создание данного материала заняло приличное количество времени и сил, но они было потрачены не зря. В процессе подготовки я узнал много нового и про работу сети в Linux, и множество нюансов bash при написании скриптов проекта и многое другое.

В будущем буду писать статьи по развертыванию различных персональных сервисов, и для доступа к ним будет использоваться VPN подключения на базе OpenConnect.

Если у вас возникли трудности с настройкой или остались вопросы, то смело оставляйте комментарии к данной статье или в нашем чате телеги: @r4ven_me_chat.

Также не забудьте подписаться на наш телеграм канал:@r4ven_me. Ссылки на все новые статьи появляются там в момент публикации.

Спасибо, что вместе со мной осилили данную статью. Успехов вам!

Используемые материалы

Подписаться
Уведомить о
31 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Valentin
Valentin
09.09.2024 07:43

camouflage поддерживает эта версия ?

Kirill Tarashev
Kirill Tarashev
11.09.2024 21:23

В статье есть упоминание про подключение к серверу с Keenetic… Так вот в последней текущей бете (а может и во всех), Keenetic не умеет сохранять адрес с портом… он его просто удаляет.. Ну и не коннектится, соответственно.
Имеет смысл добавить комментарий, что если планируется подсоединяться через эти роутеры, то порт в .env нужно ставить дефолтный 443 (который не нужно указывать в строке подключения)

Vladimir
Vladimir
07.10.2024 10:49

Добрый день.
А есть какие-то требования по настройке ufw и iptables?
Я второй день мучаюсь этими настройками.

survland
survland
26.10.2024 15:07

У меня скорость на Download 0.70 а на Upload 27 мбит, как исправить скорость?

Mashrooms Peter
Mashrooms Peter
01.11.2024 13:27

При коннекте получаю такую ошибку,хотя ссл рукопожатие таки проходит.

Got inappropriate HTTP CONNECT response: HTTP/1.1 401 Cookie is not acceptable

Error establishing the CSTP channel

disconnect

траффик проксирую в контейнер через нжинкс,но пробовал и хапрокси.результат аналогичный.Не могу понять в чем дело

Mashrooms Peter
Mashrooms Peter
01.11.2024 17:21
Ответ на  Иван Чёрный

конфиг хапрокси

frontend https_frontend
  bind haproxy:8443
  mode tcp
	tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }
	
    acl host_vpn hdr(host) -i example.com
    use_backend ocserv if { req_ssl_sni -i example.com }

backend ocserv
  mode tcp
  option ssl-hello-chk
  server ocserv openconnect:9443 send-proxy-v2

слушаю 8443 в контейнере хапрокси,отправляю на 9443 ocerv

напрямую без прокси
| 198c | Failed to open HTTPS connection to example.com
| 198c | Authentication error; cannot obtain cookie
| 2d98 | Disconnected

с хапрокси вот такое. Смущает signer not found,но похоже это пол беды

Server certificate verify failed: signer not found
| 4088 | Connected to HTTPS on example.com with ciphersuite (TLS1.3)-(ECDHE-SECP256R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
| 4088 | Got HTTP response: HTTP/1.1 200 OK
| 4088 | Connection: Keep-Alive
| 4088 | Content-Type: text/xml
| 4088 | Content-Length: 189
| 4088 | X-Transcend-Version: 1
| 4088 | Set-Cookie: webvpncontext=AEG244K5UOM9lu9jQ4qa69GXypuR9ybC0YNRdqif4EI=; Secure; HttpOnly
| 4088 | Set-Cookie: webvpn=<elided>; Secure; HttpOnly
| 4088 | Set-Cookie: webvpnc=; expires=Thu, 01 Jan 1970 22:00:00 GMT; path=/; Secure; HttpOnly
| 4088 | Set-Cookie: webvpnc=bu:/&p:t&iu:1/&sh:F050A2A4D159D3086B9A5353CC5EDCD6C62F5515; path=/; Secure; HttpOnly
| 4088 | HTTP body length:  (189)
| 4088 | XML POST enabled
| 4088 | SSL negotiation with example.com
| 4088 | Server certificate verify failed: signer not found
| 4088 | Connected to HTTPS on example.com with ciphersuite (TLS1.3)-(ECDHE-SECP256R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
| 4088 | TCP_MAXSEG 1440
| 4088 | Got inappropriate HTTP CONNECT response: HTTP/1.1 401 Cookie is not acceptable
| 4088 | Error establishing the CSTP channel
| 2d98 | Disconnected
Татьяна Германова
Татьяна Германова
19.11.2024 23:16

Добрый день.
А если порты 80 и 443 уже заняты, то как быть?

Денис Тутельян
Денис Тутельян
05.01.2025 15:31

Здравствуйте. Подскажите, с чем связана эта ошибка? Происходит на стадии сборки образа.
ERROR: The Compose file ‘./docker-compose.yml’ is invalid because:
services.openconnect.ports is invalid: Invalid port “”43443″:443/tcp”, should be [[remote_ip:]remote_port[-remote_port]:]port[/protocol]
networks.vpn.ipam.config value Additional properties are not allowed (‘gateway’ was unexpected)
services.openconnect.depends_on contains an invalid type, it should be an array

Денис Тутельян
Денис Тутельян
06.01.2025 11:50
Ответ на  Иван Чёрный

Синтаксис проверял, прочел, что это связано с новым docker-compose, в нем не поддерживается поле gateway. Образ собрался с командой docker compose, а не docker-compose

Денис Тутельян
Денис Тутельян
06.01.2025 16:28
Ответ на  Иван Чёрный

Engine 27.4.1, а вот compose 1.2.5, так что ошибся, не новый compose, а наоборот слишком старый в debian 11

Kirill NS
Kirill NS
16.01.2025 00:12

Доброго.
Пока не ставил, интересуюсь заранее. Такое подключение позволят получать доступ к ресурсам сети где этот linux сервер находится? В статье не нашёл явного описания.

Kirill NS
Kirill NS
22.01.2025 22:46
Ответ на  Иван Чёрный

Понятно, спасибо.

Родион Солошенко
Родион Солошенко
31.01.2025 20:40

Аутентификация через LDAP (AD) не рассматривалась? Подскажите пожалуйста, где можно посмотреть настройку? Что бы не сильно отличалась от вашей конфигурации ocserv. Иначе, придётся всё переделывать заново.

Егоров Иван
Егоров Иван
25.02.2025 13:54

Есть у кого рабочий, не могу запустить , в статье нет пояснений нашёл га хабре ))

[Unit]
Description=OpenConnect SSL VPN server
Documentation=man:ocserv(8)
After=network-online.target

[Service]
PrivateTmp=true
PIDFile=/run/ocserv.pid
Type=simple
ExecStart=/usr/sbin/ocserv –foreground –pid-file /run/ocserv.pid –config /etc/ocserv/ocserv.conf
ExecReload=/bin/kill -HUP $MAINPID

[Install]
WantedBy=multi-user.target

Егоров Иван
Егоров Иван
28.02.2025 13:29
Ответ на  Иван Чёрный

service unit occlient.service: вот что там есть , как я понял это клиент )) А нужен сервер.