#!/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 } # # HELPER: Make sure config and key files have been fully initialized. # clientmgmt_check_init_done(){ [[ -f "${DATA_SERVER_DIR}/server.conf" ]] && return 0 log_error "Server is still initializing config and key files; try again in a few seconds" } ## MAIN ## case ${1:-""} in add) clientmgmt_check_init_done clientmgmt_add exit 0 ;; list) clientmgmt_check_init_done clientmgmt_list exit 0 ;; revoke) clientmgmt_check_init_done 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