From d56054b61785e350af385666edd55a169fcd3b89 Mon Sep 17 00:00:00 2001 From: DrMaxNix Date: Sat, 24 Aug 2024 21:05:07 +0200 Subject: [PATCH] :tada: initial codebase --- Dockerfile | 8 + clientmgmt | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++ entrypoint.sh | 204 ++++++++++++++++++++++++++ 3 files changed, 609 insertions(+) create mode 100644 Dockerfile create mode 100755 clientmgmt create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c20777f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM alpine:latest +RUN apk --update add bash openvpn iptables easy-rsa ncurses && rm -rf /var/cache/apk/* + +RUN ln -s /usr/share/easy-rsa/easyrsa /usr/bin/easyrsa +COPY ./entrypoint.sh /entrypoint.sh +COPY ./clientmgmt /usr/bin/clientmgmt + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/clientmgmt b/clientmgmt new file mode 100755 index 0000000..6637aa3 --- /dev/null +++ b/clientmgmt @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +set -euo pipefail + + +## CONSTANTS ## +readonly REGEX_IP_ADDRESS="^((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])){3})|(([0-9a-f]{1,4}:){7,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,7}:|([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:((:[0-9a-f]{1,4}){1,6})|:((:[0-9a-f]{1,4}){1,7}|:)|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-f]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])))$" +readonly DATA_DIR="/etc/openvpn-data" +readonly EASYRSA_DIR="${DATA_DIR}/easyrsa" +readonly DATA_SERVER_DIR="${DATA_DIR}/server" +readonly DATA_CLIENT_DIR="${DATA_DIR}/client" +readonly ARG_LIST=("$@") + + +## LIB: COLOR ## +color_bold="" +color_underline="" +color_standout="" +color_normal="" +color_black="" +color_red="" +color_green="" +color_yellow="" +color_blue="" +color_magenta="" +color_cyan="" +color_white="" +if test -t 1; then + # check count of supported colors + color_count=$(tput colors) + if [[ ! -z "$color_count" && "$color_count" -ge 8 ]]; then + color_bold="$(tput bold)" + color_underline="$(tput smul)" + color_standout="$(tput smso)" + color_normal="$(tput sgr0)" + color_black="$(tput setaf 0)" + color_red="$(tput setaf 1)" + color_green="$(tput setaf 2)" + color_yellow="$(tput setaf 3)" + color_blue="$(tput setaf 4)" + color_magenta="$(tput setaf 5)" + color_cyan="$(tput setaf 6)" + color_white="$(tput setaf 7)" + fi +fi + + +## LIB: LOG ## +log_debug(){ + echo -ne "${color_green}[DEBUG]${color_normal} " 1>&2 + echo "$1" 1>&2 +} +log_info(){ + echo -ne "${color_blue}[INFO ]${color_normal} " 1>&2 + echo "$1" 1>&2 +} +log_warn(){ + echo -ne "${color_yellow}[WARN ]${color_normal} " 1>&2 + echo "$1" 1>&2 +} +log_error(){ + echo -ne "${color_red}[ERROR]${color_normal} " 1>&2 + echo "$1" 1>&2 + exit 1 +} + + + +# +# Explain usage of script. +# +clientmgmt_help(){ + echo "Usage: $0 [...args]" + echo + echo "Commands:" + echo " add [name] - Generate new client keys and output its config" + echo " list - List registered client keys" + echo " revoke [name] - Revoke a client's keys" + echo + echo " help - Display this help message and exit" +} + + + +# +# Run command `add`. +# +clientmgmt_add(){ + ## GET NAME ## + local exit=0 + local name + name=$(clientmgmt_askname noexist) || exit=$? + [[ "$exit" -gt 0 ]] && exit 1 + + + ## GET PUBLIC IP ## + # ask whether auto-detection should be used + local answer="x" + local first=0 + until [[ -z "$answer" || "$answer" =~ ^Y|y|N|n$ ]]; do + [[ "$first" -le 0 ]] && first=1 || echo "Invalid answer '$answer'" + read -ep "Auto-detect public IP-Address using icanhazip.com? [Y/n]: " answer + done + + # maybe do auto-detection + local public_ip="" + if [[ ! "$answer" =~ ^N|n$ ]]; then + local exit=0 + local icanhazip + icanhazip=$(wget -O - -q icanhazip.com) || exit=$? + + if [[ "$exit" -le 0 && "$icanhazip" =~ $REGEX_IP_ADDRESS ]]; then + public_ip="$icanhazip" + log_info "Found public IP-Address: '$public_ip'" + + else + log_warn "Failed to auto-detect public IP-Address" + fi + fi + + # use ask as fallback + local first=0 + until [[ "$public_ip" =~ $REGEX_IP_ADDRESS ]]; do + [[ "$first" -le 0 ]] && first=1 || echo "Invalid IP-Address '$public_ip'" + read -ep "Public IP-Address: " public_ip + done + + + ## AUTO-DETECT PROTO AND PORT ## + local proto + local port + + # stream server config + while IFS= read -r line; do + # trim whitespaces + line=$(echo "$line" | sed -E "s/^\s*//g" | sed -E "s/\s*$//g") + + # ignore blank lines and comments + [[ "$line" == "" || "$line" =~ ^# ]] && continue + + # match proto + if [[ "$line" =~ ^proto[[:blank:]] ]]; then + if [[ ! "$line" =~ ^proto[[:blank:]]+(tcp|udp)$ ]]; then + log_warn "Unexpected config line format for key 'proto': '$line'" + continue + fi + proto=$(echo "$line" | sed -E "s/^proto[[:blank:]]+(tcp|udp)$/\1/g") + fi + + # match port + if [[ "$line" =~ ^port[[:blank:]] ]]; then + if [[ ! "$line" =~ ^port[[:blank:]]+([0-9]+)$ ]]; then + log_warn "Unexpected config line format for key 'port': '$line'" + continue + fi + local parsed_port=$(echo "$line" | sed -E "s/^port[[:blank:]]+([0-9]+)$/\1/g") + if [[ "$parsed_port" -le 0 || "$parsed_port" -gt 65535 ]]; then + log_warn "Configured port number out of range: '$parsed_port'" + continue + fi + port="$parsed_port" + fi + done < "${DATA_SERVER_DIR}/server.conf" + + # validate + if [[ -z "${proto:+x}" ]]; then + proto="udp" + log_warn "Unable to auto-detect proto from server config file; using fallback: '$proto'" + fi + if [[ -z "${port:+x}" ]]; then + port="1194" + log_warn "Unable to auto-detect port from server config file; using fallback: '$port'" + fi + + + ## GENERATE CLIENT KEYS ## + local prev_pwd="$PWD" && cd "$EASYRSA_DIR" + log_info "Generating client keys" + /usr/bin/easyrsa --batch --days=7300 build-client-full "$name" nopass + cd "$prev_pwd" + + + ## BUILD CLIENT CONFIG ## + # header + echo + echo -e "${color_magenta}Client Config (${name}.ovpn):${color_normal}" + echo -e "${color_magenta}----------------------------------------------------------------${color_normal}" + + # static content + echo "client +dev tun +proto $proto +remote $public_ip $port +resolv-retry infinite +nobind +persist-key +persist-tun +remote-cert-tls server +auth SHA512 +ignore-unknown-option block-outside-dns +verb 3" + echo + + # ca cert + echo "" + cat "${EASYRSA_DIR}/pki/ca.crt" + echo "" + + # client cert + echo "" + sed -ne '/BEGIN CERTIFICATE/,$ p' "${EASYRSA_DIR}/pki/issued/${name}.crt" + echo "" + + # client key + echo "" + cat "${EASYRSA_DIR}/pki/private/${name}.key" + echo "" + + # tls-crypt key + echo "" + sed -ne '/BEGIN OpenVPN Static key/,$ p' "${DATA_SERVER_DIR}/tls-crypt.key" + echo "" + + # footer + echo -e "${color_magenta}----------------------------------------------------------------${color_normal}" +} + + + +# +# Run command `revoke`. +# +clientmgmt_revoke(){ + ## GET NAME ## + local exit=0 + local name + name=$(clientmgmt_askname exist) || exit=$? + [[ "$exit" -gt 0 ]] && exit 1 + + + ## REVOKE CLIENT KEYS ## + local prev_pwd="$PWD" && cd "$EASYRSA_DIR" + log_info "Revoking client keys" + + # revoke keys + /usr/bin/easyrsa --batch revoke "$name" + + # update revoke list + /usr/bin/easyrsa --batch --days=7300 gen-crl + chown nobody:nobody pki/crl.pem + + # restore pwd + cd "$prev_pwd" + + # print info + log_info "Client revoked: '$name'" +} + + + +# +# Run command `list`. +# +clientmgmt_list(){ + ## GET LIST ## + list=$(clientmgmt_clientlist) + + + ## PRINT ## + # warn if there are no clients + if [[ "$list" == "" ]]; then + log_warn "No clients registered" + exit 0 + fi + + # list + echo "Clients:" + for one_client_name in $list; do + echo " $one_client_name" + done +} + + + +# +# HELPER: Parse name parameter or ask if not provided. +# +# @param $1 noexist: Expect this name to be unused +# exist: Expect this name to exist already. +# +# @return Name entered. +# @exit 0: Returned name is valid +# 1: Returned name is invalid. +# +clientmgmt_askname(){ + ## ASK ## + local answer="" + local first=0 + if [[ "${ARG_LIST[1]:+x}" ]]; then + answer="${ARG_LIST[1]}" + first=1 + fi + until [[ "$answer" =~ ^[abcdefghijklmnopqrstuvwxyz0123456789-]{2,64}$ ]]; do + [[ "$first" -le 0 ]] && first=1 || echo "Invalid client name '$answer'" 1>&2 + read -ep "Client name: " answer + done + + + ## VALIDATE ## + # check for reserved names + if [[ "$answer" =~ ^ca|server$ ]]; then + log_error "Name is reserved for internal use: '$answer'" + fi + + # make sure name is exists or does not exist + case "$1" in + noexist) + if [[ -f "${EASYRSA_DIR}/pki/issued/${answer}.crt" ]]; then + log_error "Name already in use: '$answer'" + fi + ;; + + exist) + if [[ ! -f "${EASYRSA_DIR}/pki/issued/${answer}.crt" ]]; then + log_error "Client does not exist: '$answer'" + fi + ;; + + *) log_error "Unknown exist goal: '$1'";; + esac + + + ## RETURN ## + echo -n "$answer" + return 0 +} + + + +# +# GETTER: Get list of registered client names. +# +# @return Space separated list of registered client names. +# +clientmgmt_clientlist(){ + ## GET LIST ## + # get from directory + local rawlist=$(echo -n ${EASYRSA_DIR}/pki/issued/*.crt) + + # build list + local list="" + for one_cert in $rawlist; do + filename=$(basename $one_cert) + [[ "$filename" == "server.crt" ]] && continue + list="$list $(echo ${filename} | sed -E 's/\.crt$//g')" + done + list="$(echo $list | xargs)" + + + ## RETURN ## + echo -n "$list" + return 0 +} + + + +## MAIN ## +case ${1:-""} in + add) + clientmgmt_add + exit 0 + ;; + + list) + clientmgmt_list + exit 0 + ;; + + revoke) + clientmgmt_revoke + exit 0 + ;; + + help) + clientmgmt_help + exit 0 + ;; + + "") + log_error "No command given; See '$0 help' for usage information" + exit 1 + ;; + + *) + log_error "Invalid command '$1'; See '$0 help' for usage information" + exit 1 + ;; +esac diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..f009034 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -euo pipefail + + +## CONSTANTS ## +readonly DATA_DIR="/etc/openvpn-data" +readonly EASYRSA_DIR="${DATA_DIR}/easyrsa" +readonly DATA_SERVER_DIR="${DATA_DIR}/server" +readonly DATA_CLIENT_DIR="${DATA_DIR}/client" + + +## HANDLE EXIT SIGNALS ## +trap trap_exit EXIT SIGINT SIGTERM +trap_exit(){ + if [[ "${openvpn_pid:+x}" ]]; then + kill -- $openvpn_pid 2> /dev/null || true + q=0 + while proc_running $openvpn_pid; do + q=$(( q + 1 )) + snore 1 + if [[ "$q" -ge 15 ]]; then + log_warn "Sending kill to OpenVPN" + kill -s KILL -- $openvpn_pid 2> /dev/null || true + break + fi + done + fi + exit 0 +} + + + +# +# LIB: Efficient sleep (does not create a new process). +# +snore(){ + local IFS + [[ -n "${_snore_fd:-}" ]] || exec {_snore_fd}<> <(:) + read ${1:+-t "$1"} -u $_snore_fd || true +} + + + +# +# HELPER: Check whether given pid is running. +# +# @param $1 Process ID. +# +# @exit 0: Process is running +# 1: Process is not running. +# +proc_running(){ + # try reading state + local state_path="/proc/$1/stat" + [[ ! -f "$state_path" ]] && return 1 + local state=$(cat "$state_path" | cut -d ' ' -f3) + + # parse state + case "$state" in + R|S|D|Z|W|W|P|I) return 0;; + *) return 1;; + esac +} + + + +# +# HELPER: Initialize iptables rules. +# +iptables_init(){ + /sbin/iptables -t nat -I POSTROUTING -s 192.168.35.0/24 ! -d 192.168.35.0/24 -j MASQUERADE + /sbin/iptables -I INPUT -i tun0 -j ACCEPT + /sbin/iptables -I INPUT -p udp --dport 1194 -j ACCEPT + /sbin/iptables -I INPUT -p tcp --dport 1194 -j ACCEPT + /sbin/iptables -I FORWARD -s 192.168.35.0/24 -j ACCEPT + /sbin/iptables -I FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT +} + + + +# +# HELPER: Set up easyrsa. +# +easyrsa_init(){ + ## MAYBE CREATE VARS FILE ## + if [[ ! -f "${EASYRSA_DIR}/vars" ]]; then + touch "${EASYRSA_DIR}/vars" + fi +} + + + +# +# HELPER: Set up server keys. +# +easyrsa_server_keys_create(){ + ## IGNORE IF EXISTING ## + [[ -d "${EASYRSA_DIR}/pki" ]] && return 0 + + + ## SET UP CA AND SERVER KEYS (EASYRSA) ## + local prev_pwd="$PWD" && cd "$EASYRSA_DIR" + + # new pki + /usr/bin/easyrsa --batch init-pki + + # create ca + /usr/bin/easyrsa --batch --days=7300 build-ca nopass + + # generate server keys + /usr/bin/easyrsa --batch --days=7300 build-server-full server nopass + + # generate crl + /usr/bin/easyrsa --batch --days=7300 gen-crl + chown nobody:nobody pki/crl.pem + + # restore pwd + cd "$prev_pwd" + + # create symlinks + ln -s ${EASYRSA_DIR}/pki/{ca.crt,private/ca.key,issued/server.crt,private/server.key,crl.pem} "${DATA_SERVER_DIR}" + + + ## OTHER KEYS ## + # dh parameters + openssl dhparam -out "${DATA_SERVER_DIR}/dh2048.pem" 2048 + + # tls-crypt key + openvpn --genkey secret "${DATA_SERVER_DIR}/tls-crypt.key" +} + + + +# +# HELPER: Set up openvpn server config file. +# +openvpn_config_init(){ + ## IGNORE IF EXISTING ## + [[ -f "${DATA_SERVER_DIR}/server.conf" ]] && return 0 + + + ## WRITE SERVER CONFIG ## + echo "port 1194 +proto udp +server 192.168.35.0 255.255.255.0 +dev tun +user nobody +group nogroup +topology subnet + +ca ca.crt +cert server.crt +key server.key +dh dh2048.pem +crl-verify crl.pem +tls-crypt tls-crypt.key +auth SHA512 + +client-config-dir ${DATA_CLIENT_DIR} +ifconfig-pool-persist ipp.txt +keepalive 600 720 +persist-key +persist-tun + +push \"dhcp-option DNS 1.1.1.1\" +push \"dhcp-option DNS 1.0.0.1\" +push \"redirect-gateway def1 bypass-dhcp\" +push \"block-outside-dns\" + +verb 3" > "${DATA_SERVER_DIR}/server.conf" +} + + + +## SET UP USED DIRECTORIES ## +mkdir -p "$DATA_DIR" +mkdir -p "$EASYRSA_DIR" +mkdir -p "$DATA_SERVER_DIR" +mkdir -p "$DATA_CLIENT_DIR" + + +## SET UP TUN DEVICE ## +mkdir -p /dev/net +[[ ! -c /dev/net/tun ]] && mknod /dev/net/tun c 10 200 + + +## SET UP FW AND ROUTING ## +iptables_init + + +## INITIALIZE CONFIG ## +# set up easyrsa +easyrsa_init + +# server keys +easyrsa_server_keys_create + +# server config +openvpn_config_init + + +## RUN OPENVPN SERVER ## +/usr/sbin/openvpn --cd "${DATA_SERVER_DIR}" --config "${DATA_SERVER_DIR}/server.conf" & openvpn_pid=$! +wait $openvpn_pid