Skip to content

Instantly share code, notes, and snippets.

@arnehormann
Last active June 21, 2022 19:29
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save arnehormann/9744964 to your computer and use it in GitHub Desktop.
Save arnehormann/9744964 to your computer and use it in GitHub Desktop.
Configuration file generator for OpenVPN which also sets up a ca and generates keys.
#!/usr/bin/env ruby
# Just call this without arguments. It will show a friendly help text.
# For xterm-256color, it will even use colors for some commands!
class AppConfig
@@default_remote = 'vpn.example.com'
@@default_networks = '10.0.0.0/24>192.168.1.0/24'
@@default_subject = '/C=US/ST=CA/L=San Francisco/O=Example/OU=/CN={{name}}'
# If you want to use eliptic curve crypto, change the code in Crypto below.
# As is, it only uses RSA, AES in CBC mode and SHA
# lookup with: openvpn --show-ciphers
@@default_cipher = 'AES-256-CBC'
# lookup with: openvpn --show-tls
@@default_tls_ciphers = %w(
TLS-DHE-RSA-WITH-AES-256-GCM-SHA384
TLS-DHE-RSA-WITH-AES-256-CBC-SHA256
TLS-DHE-RSA-WITH-AES-128-GCM-SHA256
TLS-DHE-RSA-WITH-AES-128-CBC-SHA256
TLS-DHE-RSA-WITH-AES-256-CBC-SHA
TLS-DHE-RSA-WITH-AES-128-CBC-SHA
).join(':')
### REQUIRED CLASS VARIABLES
# environment variable prefix for this app
@@prefix = 'VPN_'
# [field, default, description]
# Cases for default:
# nil - not set, optional
# empty Array - not set, mandatory
# Symbol - default value of another field
# String, Fixnum - that value
@@parameters = [
# values mainly used in config file generation
[:devName, 'tun', 'name of the server\'s tunnel device'],
[:devNode, nil, 'name of the device node for Windows TUN/TAP driver'],
[:protocol, 'udp', 'openvpn protocol'],
[:port, 1194, 'external openvpn port on server'],
[:portIntra, :port, 'internal openvpn port on server'],
[:serverDev, 'eth0', 'server network device name'],
[:user, 'openvpn', 'openvpn user for privilege separation'],
[:group, :user, 'openvpn group for privilege separation'],
[:remote, @@default_remote, 'reachable address of vpn server'],
[:networks, @@default_networks, 'from network-ip/range to network-ip/range'],
[:cipher, @@default_cipher, 'used encryption cipher'],
[:tlsCipher, @@default_tls_ciphers, 'encryption ciphers used for tls'],
[:maxClients, 16, 'maximum number of simultaneous clients (server)'],
# values used in creation of certificates
[:bits, 2048, 'bits per key or parameter'],
[:bitsDh, :bits, 'diffie hellman parameter bits'],
[:bitsCa, :bits, 'certificate authority rsa key bits'],
[:bitsServer, :bits, 'server rsa key bits'],
[:bitsClient, :bits, 'client rsa key bits'],
[:certSubject, @@default_subject, 'certificate subject, "{{name}}" can use argument'],
[:certValid, 3650, 'validity of certificate in days'],
[:certValidCa, :certValid, 'validity of ca certificate in days'],
[:fileSerial, nil, 'file to add serial and subject to (needed for crl)']
]
# all options defined above are injected with ConfigReader
# but are read-only
attr_reader *(@@parameters.collect{|a| a[0]})
# unparsed arguments
attr_reader :unparsed
attr_reader :logVerbosity,
:fromNet,
:toNet
attr_reader :fileTa,
:fileDh,
:fileCrl,
:fileCaKey,
:fileCaCert,
:fileKey,
:fileCert
attr_accessor :mode, :name
attr_accessor :blobTa,
:blobDh,
:blobCa,
:blobServer,
:blobKey,
:blobCert
def build()
# fill in synthetic values
@logVerbosity = 3
@fromNet, @toNet = @networks.split('>')
@fileTa = 'ta.key'
@fileDh = "dh#{@bitsDh}.pem"
@fileCrl = 'crl.pem'
@fileCaKey = 'ca.key'
@fileCaCert = 'ca.pem'
return self
end
def use()
# @mode is set after the call to build()
@fileKey = "#{@name || @mode}.key"
@fileCert = "#{@name || @name}.pem"
return self
end
def server?
return @mode == 'server'
end
def net_format(what, format)
return case format
when :first
what.sub(/\.[^\.]*$/, '.1/32')
when :netmask
ip, range = what.split('/')
nm = 0xffffffff ^ (0xffffffff >> range.to_i)
netmask = (0..3).collect{ |i| ((nm >> (3-i)*8) & 0xff) }.join('.')
"#{ip} #{netmask}"
else
what
end
end
def linkPem(pemType)
key, file, blob = {
ta: ['tls-auth', @fileTa, @blobTa],
dh: ['dh', @fileDh, @blobDh],
ca: ['ca', @fileCaCert, @blobCa],
cert: ['cert', @fileCert, @blobCert],
key: ['key', @fileKey, @blobKey]
}[pemType]
if blob
blob = blob.gsub(/^[^a-zA-Z0-9=+\/\-].*/, '')
blob.strip!()
"<#{key}>\n#{blob}\n</#{key}>\n"
elsif file
"#{key} #{file}\n"
else
""
end
end
def config()
# everything after __END__ marker is the template
template = DATA.read
if server?
# is in server mode, remove server annotations
template.gsub!(/##S:[ \t]*/, '')
else
# is in client mode, remove client annotations
template.gsub!(/##C:[ \t]*/, '')
end
# remove remaining annotations including their content
template.gsub!(/##[SC]:[^\n]*\n?/, '')
# evaluate the template in context of this instance
out = instance_eval("<<CONFIG_END\n" + template + "\nCONFIG_END")
# fix whitespace: none at the front, just \n at the end, no more than
# one blank line inside
out.strip!
out.gsub!(/([\t ]*\n){3,}/, "\n\n")
return out + "\n"
end
end
module ConfigReader
# ARGV as a map including all parameters in the form
# -KEY=VALUE
# and the rest in an Array with the key :unparsed
@@argMap = ARGV.inject({unparsed: []}) do |a, v|
begin
key, val = v.scan(/^(-[^=]*)=?(.*)$/)[0]
a[key] = val.empty? ? true : val
rescue
a[:unparsed] << v
end
a
end
# read order for configuration parameters.
# Per default:
# 1. lookup in ARGV (command line arguments)
# 2. lookup in ENV (environment variables)
# 3. use default value
@@readOrder = [
[:arg, @@argMap],
[:env, ENV],
:default
]
# take the array of usable parameters in the format
# [[NAME:symbol, DEFAULT, DESCRIPTION], ...]
# and the environment variable prefix and
# return a map {NAME => CONFIGURATION_PARAMETER, ...}
# where CONFIGURATION_PARAMETER is
# {
# symbol: NAME,
# arg: COMMAND_LINE_VARIABLE_PREFIX,
# env: ENVIRONMENT_VARIABLE_NAME,
# default: DEFAULT_VALUE,
# desc: DESCRIPTION
# }
def self.seed(parameters, envPrefix)
envPrefix ||= ''
data = {}
parameters.each do |p|
symbol, defval, description = p
# key in command line arguments:
# capital letters in symbol are replaced by '-' and lower case
key = symbol.to_s().gsub(/([A-Z])/, '-\1').downcase()
argkey = '-' + key
# key in environment variables:
# add envPrefix in front and use upper casse version of
# command line key with '-' replaced with '_'
envkey = envPrefix + key.gsub('-', '_').upcase()
data[symbol] = {
symbol: symbol,
arg: argkey,
env: envkey,
default: defval,
desc: description
}
end
data
end
# fetch a configuration parameter with its description from seed.
# returns [value, is_set]
def self.value(parameter)
@@readOrder.each do |source|
if source == :default
return [parameter[:default], false]
end
pk, values = source
key = parameter[pk]
if values.has_key? key
return [values[key], true]
end
end
end
def self.fill(config)
missing = []
config.each do |key, cfgparam|
v, s = value(cfgparam)
if s || v.class != Symbol
cfgparam[:value] = v
cfgparam[:is_set] = s
else
missing << [key, v]
end
end
until missing.empty?
still_missing = []
missing.each do |link|
key, ref = link
reference = config[ref]
if reference.has_key? :value
cfgparam = config[key]
cfgparam[:value] = reference[:value]
cfgparam[:is_set] = false
else
still_missing.push([key, ref])
end
end
if missing.length == still_missing.length
raise Exception.new("unpaired config defaults for #{missing.inspect}")
end
missing = still_missing
end
return config
end
def self.create(cfgClass)
envPrefix = cfgClass.class_variable_get(:@@prefix)
parameters = cfgClass.class_variable_get(:@@parameters)
config = seed(parameters, envPrefix)
cfgClass.class_variable_set(:@@expandedParameters, config)
instance = cfgClass.new
instance.instance_variable_set("@unparsed", @@argMap[:unparsed])
fill({}.merge(config)).each do |k, v|
instance.instance_variable_set("@#{k}", v[:value])
end
instance.build()
end
end
module CliHelp
def self.colorizer()
if ENV['TERM'] == 'xterm-256color'
# See http://stackoverflow.com/questions/1489183/colorized-ruby-output
# and http://ascii-table.com/ansi-escape-sequences.php
return Proc.new do |str, col|
case col
when :key # bold gray
"\033[37;1m#{str}\033[0m"
when :argkey # bold gray, bg-cyan
"\033[46;37;1m#{str}\033[0m"
when :envkey # bold gray, bg-blue
"\033[44;37;1m#{str}\033[0m"
when :value # bold brown
"\033[33;1m#{str}\033[0m"
when :default # brown
"\033[33m#{str}\033[0m"
when :warn # red
"\033[31m#{str}\033[0m"
else
str.to_s()
end
end
end
return Proc.new do |str, col|
str.to_s()
end
end
def self.help(app)
color = colorizer()
lines = []
cfg = app.class.class_variable_get(:@@expandedParameters)
cfg.each do |k, parameter|
value = app.send(k)
argkey = parameter[:arg]
envkey = parameter[:env]
defval = parameter[:default]
desc = parameter[:desc]
dvtxt = case defval
when String
': "' + color[defval, :default] + '"'
when Symbol
" is the value of #{color[cfg[defval][:arg], :argkey]}"
else
if defval == nil
" does not exist"
else
": #{color[defval, :default]}"
end
end
printval = case
when !parameter[:is_set]
if defval == []
color["not set", :warn] + ','
else
'not set,'
end
when value == defval
'the'
else
"\"#{color[value, :value]}\","
end
lines << [
"#{color[argkey, :argkey]} / #{color[envkey, :envkey]}",
" #{desc};",
" Is #{printval} default value#{dvtxt}"
].join("\n")
end
return (
[
"Configuration parameters as #{color['argument', :argkey]} " +
"/ #{color['environment', :envkey]} variables"
] + lines.sort() +
[
"unknown arguments:",
app.unparsed
]
).join("\n")
end
def self.example(app, all)
cfg = app.class.class_variable_get(:@@expandedParameters)
vars = cfg.keys.sort.inject('') do |a, k|
arg = cfg[k]
value = app.send(k)
if all || arg[:is_set] && value != arg[:default]
a += "#{arg[:env]}='#{value}' "
end
a
end
return vars + __FILE__ + ' ...'
end
end
module Crypto
require 'openssl'
@@DIGEST = OpenSSL::Digest::SHA256
@@SERIAL_DIGEST = @@DIGEST
@@SIGN_DIGEST = @@DIGEST
def self.serial(*certs)
hash = @@SERIAL_DIGEST.new
certs.each { |cert| hash << cert }
# serial number binary length is max. 20 bytes:
# use last 20 bytes from hash
return OpenSSL::BN.new(hash.digest[-20, 20], 2)
end
def self.asName(value, defaultValue)
return case value
when NilClass
defaultValue
when String
OpenSSL::X509::Name.parse(value)
when OpenSSL::X509::Name
value
when OpenSSL::X509::Certificate
value.subject
when Array
OpenSSL::X509::Name.new(value)
else
raise Exception.new("asName: could not [#{value}], unhandled class #{value.class}")
end
end
def self.getKey(filename, pkClass, *generate_args)
if filename != nil && (File.exists? filename)
return pkClass.new(File.read(filename))
end
return pkClass.generate(*generate_args)
end
def self.storePem(filename, pemable)
return if File.exists? filename
mode = 0644
if pemable.respond_to?(:private?) && pemable.private?
mode = 0600
end
File.open(filename, 'w', mode) do |file|
file.write(pemable.to_pem)
end
end
def self.addExt(issuer, extable, extensions)
factory = OpenSSL::X509::ExtensionFactory
ef = case extable
when OpenSSL::X509::Certificate
factory.new(issuer, extable)
when OpenSSL::X509::Request
factory.new(issuer, nil, extable)
when OpenSSL::X509::CRL
factory.new(issuer, nil, nil, extable)
else
raise Exception.new("type #{extable.class} can handle extensions")
end
extensions.each do |k, v|
extable.add_extension(ef.create_extension(k, v))
end
return extable
end
def self.newCert(info)
cert = OpenSSL::X509::Certificate.new()
cert.version = 2
serial = info[:serial]
cert.serial = case serial
when NilClass
1
when Fixnum, OpenSSL::BN
serial
when Array
serial(*serial)
else
raise Exception.new("invalid serial [#{info[:serial]}]")
end
subject = asName(info[:subject], nil)
if subject == nil
raise Exception.new("invalid subject [#{info[:subject]}]")
end
cert.subject = subject
cert.issuer = asName(info[:issuerCert], subject)
key = info[:key]
cert.public_key = key.public_key
cert.not_before = info[:starts]
cert.not_after = info[:expires]
cert = addExt(info[:issuerCert] || cert, cert, info[:extension])
cert.sign(info[:signKey] || key, @@SIGN_DIGEST.new)
end
def self.loadAs(filename, cls)
return nil unless filename != nil && (File.exists? filename)
cert = File.read(filename)
return cls.new(cert)
end
##
## general use functions, the ones above are rough and internal
##
def self.rsaPrivKey(filename, bits)
# use public exponent of 3 (RSA_3) instead of 65537 (RSA_F4)
# to speed up operations. Claims of security improvements by
# using larger exponents are appearently misguided:
# https://www.imperialviolet.org/2012/03/16/rsae.html (Three should be fine)
getKey(filename, OpenSSL::PKey::RSA, bits, 3)
end
def self.loadCert(filename)
return self.loadAs(filename, OpenSSL::X509::Certificate)
end
def self.loadRSAKey(filename)
return self.loadAs(filename, OpenSSL::PKey::RSA)
end
def self.ta(app)
return File.read(app.fileTa) if File.exists? app.fileTa
# The code below is an alternative for
# `openvpn --genkey --secret #{app.fileTa}`
# but returns the ta key pem as a String
header, footer = ['BEGIN', 'END'].collect do |s|
"-----#{s} OpenVPN Static key V1-----"
end
num_bytes = 4 * 64 # 2 keys with 64 bytes for hmac and cipher
body = OpenSSL::Random.
random_bytes(num_bytes).
unpack("h32" * (num_bytes / 16))
return ([header] + body + [footer, '']).join("\n")
end
def self.dh(app)
# use default generator 2 , but make it explicit
getKey(app.fileDh, OpenSSL::PKey::DH, app.bitsDh, 2)
end
def self.ca(app, key)
cert = loadCert(app.fileCaCert)
if cert
return cert
end
starts = Time.now
expires = starts + app.certValidCa.to_i * 24 * 60 * 60
subject = app.certSubject.gsub('{{name}}', app.name || app.mode)
return newCert({
key: key,
subject: subject,
starts: starts,
expires: expires,
extension: {
'basicConstraints' => 'CA:TRUE,pathlen:1',
'subjectKeyIdentifier' => 'hash',
# NOTE:
# Removing issuer from authorityKeyIdentifier would
# allow for certificate regeneration from the same
# key which keeps all signed certificates valid.
# As is, everything is invalidated when the CA
# certificate expires.
'authorityKeyIdentifier' => 'keyid,issuer:always',
'keyUsage' => 'cRLSign,keyCertSign'
}
})
end
def self.cert(app, key, caKey, caCert)
# NOTE:
# For a CRL, the required info can be extracted from the
# configuration by piping the configuration file input with
# its embedded certificate into
# awk 'BEGIN{PRINT=0}{if($0=="<cert>"){PRINT=1}else if($0=="</cert>"){PRINT=0}else if(PRINT==1){print $0}}'\
# | openssl x509 -serial -subject -noout
cert = loadCert(app.fileCert)
if cert
return cert
end
starts = Time.now
expires = starts + app.certValidCa.to_i * 24 * 60 * 60
subject = app.certSubject.gsub('{{name}}', app.name || app.mode)
ext = {
'basicConstraints' => 'CA:FALSE',
'subjectKeyIdentifier' => 'hash',
'authorityKeyIdentifier' => 'keyid,issuer:always',
}
if app.server?
ext = ext.merge({
'extendedKeyUsage' => 'serverAuth',
'keyUsage' => 'digitalSignature,keyEncipherment'
})
else
ext = ext.merge({
'extendedKeyUsage' => 'clientAuth',
'keyUsage' => 'digitalSignature'
})
end
return newCert({
key: key,
subject: subject,
issuerCert: caCert,
starts: starts,
expires: expires,
signKey: caKey,
serial: [caCert.to_pem(), key.public_key.to_pem()],
extension: ext
})
end
def self.crl(lastCrl, caKey, caCert, *revokeSerials)
crl = case lastCrl
when OpenSSL::X509::CRL
lastCrl
when String, File
OpenSSL::X509::CRL.new(lastCrl)
when NilClass
new_crl = OpenSSL::X509::CRL.new
new_crl.issuer = caCert.subject
new_crl.version = 0
cert = addExt(caCert, new_crl, {
'authorityKeyIdentifier' => 'keyid:always,issuer:always'
})
new_crl
else
raise Exception.new("invalid CRL")
end
now = Time.now
crl.last_update = now
crl.next_update = now + 60 * 60 # in one hour
crl.version += 1
revokeSerials.each do |serial|
rev = OpenSSL::X509::Revoked.new
rev.time = now
rev.serial = case serial
when OpenSSL::BN
serial
when String
OpenSSL::BN.new(serial)
else
raise Exception.new("unsupported serial type #{serial.class} for '#{serial}'")
end
crl.add_revoked rev
end
crl.sign(caKey, @@SIGN_DIGEST.new)
return crl
end
# TODO still missing encryption of keys
# IDEA generate template of client string to get rid of ta.key and ca files
end
# TODO handling of key en-/decryption passwords
# TODO certificate generation with external private key - public key as argument instead
if __FILE__ == $0
app = ConfigReader.create(AppConfig)
mode = app.unparsed.shift
case mode
when 'parameters'
puts CliHelp.help(app.use())
when 'config', 'config-full'
puts CliHelp.example(app.use(), mode == 'config-full')
when 'setup'
app.mode = mode
app.name = app.unparsed[0] || 'CA'
app.use()
caKey = Crypto.rsaPrivKey(app.fileCaKey, app.bitsCa)
Crypto.storePem(app.fileCaKey, caKey)
cert = Crypto.ca(app, caKey)
Crypto.storePem(app.fileCaCert, cert)
crl = Crypto.crl(nil, caKey, cert)
Crypto.storePem(app.fileCrl, crl)
when 'server', 'client'
app.mode = mode
app.name = app.unparsed[0] || mode
app.use()
if app.server?
app.blobTa = Crypto.ta(app)
unless File.exists?(app.fileTa)
File.open(app.fileTa, 'w', 0644) do |file|
file.write(app.blobTa)
end
end
# dh parameters are server only
app.blobDh = Crypto.dh(app).to_pem()
end
app.blobTa ||= File.read(app.fileTa)
caKey = Crypto.loadRSAKey(app.fileCaKey)
caCert = Crypto.loadCert(app.fileCaCert)
app.blobCa ||= caCert.to_pem()
key = Crypto.rsaPrivKey(
nil, # do not store on HDD
app.server? ? app.bitsServer : app.bitsClient
)
app.blobKey = key.to_pem()
cert = Crypto.cert(app, key, caKey, caCert)
if app.fileSerial
File.open(app.fileSerial, 'a', 0600) do |file|
values = [:serial, :not_before, :subject].collect{|f| cert.send(f)}
file.puts values.join("\t")
end
end
app.blobCert = cert.to_pem()
puts app.config()
when 'revoke'
puts Crypto.crl(
File.read(app.fileCrl),
Crypto.loadRSAKey(app.fileCaKey),
Crypto.loadCert(app.fileCaCert),
*app.unparsed
).to_pem
else
program = __FILE__
puts <<HELP_END
#{program} sets up and manages OpenVPN.
It can generate a new certificate authority and create client and server
configuration. To run OpenVPN, please read the configuration files, the
comment at the beginning of the server configuration file provides
the instructions.
How to call #{program} (best start with parameters and config-full):
$ #{program} help this help text
$ #{program} parameters lists available configuration parameters
$ #{program} config-full example for a call with full current configuration
$ #{program} config like config-full without defaults
$ #{program} setup [CANAME] create a CA and a CRL
$ #{program} server [SRVNAME] print a server config file
$ #{program} client NAME print a client config file
$ #{program} revoke SERIAL* print crl with added revoked serials
HELP_END
end
end
# TODO: reduce "hand-window"
__END__
##S: # Per installation, run these on the server (assuming Linux):
##S: # $: useradd -M '#{@user}'
##S: # Also run these and consider adding them to /etc/rc.local so they are preserved on reboot
##S: # $: sysctl -w net.ipv4.ip_forward=1
##S: # $: ip link set dev '#{@serverDev}' promisc on
##S: # $: iptables -A FORWARD -i '#{@serverDev}' -o '#{@devName == 'tun' ? 'tun0' : @devName}' -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
##S: # $: iptables -A FORWARD -s '#{@fromNet}' -o '#{@serverDev}' -j ACCEPT
##S: # $: iptables -t nat -A POSTROUTING -s '#{@fromNet}' '!' -d '#{net_format(@fromNet, :first)}' -o '#{@serverDev}' -j MASQUERADE
##S:
##S: # For a management console:
##S: # connect with
##S: # telnet 127.0.0.1 20000
##S: # and uncomment below to enable
##S: # management 127.0.0.1 20000
##C: client
##S: server #{net_format(@fromNet, :netmask)}
proto udp
##C: remote #{@remote} #{@port}
##S: port #{@portIntra}
##S: push "route #{net_format(@toNet, :netmask)}"
dev-type tun
dev #{@devName || 'tun'}
#{@devNode ? "dev-node #{@devNode}" : ""}
# remove this if there are Windows clients with OpenVPN < 2.1
topology subnet
comp-lzo adaptive
cipher #{@cipher}
##S: # see https://community.openvpn.net/openvpn/wiki/Hardening
##S: tls-cipher #{@tlsCipher}
##S: remote-cert-tls client
##C: remote-cert-tls server
##S:
##S: persist-key
##S: persist-tun
##S: persist-local-ip
##S: persist-remote-ip
##S: push "persist-key"
##S: push "persist-tun"
##C:
##C: nobind
##S: max-clients #{@maxClients || 32}
##S: keepalive 4 20
##S:
##S: #{@user ? "user #{@user}" : ''}
##S: #{@group ? "group #{@group}" : ''}
verb #{@logVerbosity || 3}
##S: mute 5
##S: # status logging, ip pool persist
##S: ifconfig-pool-persist ipp.txt
##S: status openvpn-status.log
##S: #{@fileCrl ? "crl-verify #{@fileCrl}" : ''}
key-direction #{server? ? '0' : '1'}
#{
((server? ? [:dh] : []) + [:ta, :ca, :cert, :key]).
collect{ |pem| linkPem(pem) }.join('')
}
@arnehormann
Copy link
Author

Usage

Example use on the ca-server (server creation takes a while due to dh parameter generation).
Adapt it to your own needs (esp. the values of VPN_NETWORKS, VPN_CERT_SUBJECT and the users for client config generation below).

# To get help, call it without arguments (or with 'help'):
#    vpngen.rb
# All environment variables can also be specified as arguments.
# If you do so, change
#     export VPN_CERT_SUBJECT='/CN={{name}}'
# to an argument like this
#     -cert-subject='/CN={{name}}'
# Call this for a list of available variables with their defaults:
#     vpngen.rb parameters

# Set variables needed for the specific installation.
export VPN_REMOTE='mydomain.dyndns.org'
export VPN_NETWORKS='10.10.10.0/24>192.168.1.0/24'
export VPN_CERT_SUBJECT='/C=DE/ST=Hessen/L=Frankfurt/O=COMPANY/OU=VPN/CN={{name}}'
export VPN_FILE_SERIAL='serials.txt'

# for user access only
umask 177

# generate ca and crl (here, the {{name}} of the ca in the subject is VPNCA)
vpngen.rb setup VPNCA

# generate server configuration and write it into myvpn.conf ({{name}} for server is VPNSERVER)
# Note: for a Windows server, you may have to specify the device node here, too.
# See the client section below.
vpngen.rb server VPNSERVER > myvpn.conf

# change umask - next up is a directory, allow access to it
umask 077
mkdir -p clients

# stricter umask again
umask 177

# generate configurations for non-windows users in clients/...
for user in user1.uinx user2.unix user3.unix; do
  vpngen.rb client "${user}" > clients/"${user}.ovpn"
done

# assume a tun/tap device named "vpnnet" for the Windows clients (needed - at least for older versions)
export VPN_DEV_NODE="vpnnet"

# generate their configurations in clients/...
for user in user1.windows user2.windows; do
  vpngen.rb client "${user}" > clients/"${user}.ovpn"
done

Using the first column from serials.txt, one can add certificates to the crl by specifying their serial number.

vpngen.rb revoke SERIAL_1 SERIAL_2 ... SERIAL_N > newcrl.pem
cp newcrl.pem crl.pem

Note: Never directly overwrite your crl, create a new one and overwrite the old one with it afterwards.

@arnehormann
Copy link
Author

Technicalities

To avoid the overhead of maintaining index.txt for the serial, I use a 20-byte number created from the postfix of the sha-sum of the ca certificate pem and the public key of the new certificate. That should avoid collisions but still keep everything pretty deterministic with minimal bookkeeping.

If changing the ca certificate using the same key is required, that should be changed for continuity:
the ca public key should be used instead of the pem.
In that case, the NOTE in Crypto.ca should also be read and followed.

@arnehormann
Copy link
Author

Fun Facts

This uses a fun way to create single file service controllers - uses a nifty pattern for configuration: inject everything into a prepared class. Very flexible 😀

  • AppConfig uses pretty minimal information to describe available configuration options (@@prefix and @@parameters followed by creation of the accessors are sufficient)
  • ConfigReader creates an AppConfig instance and initialises it with command line arguments, environment variables or default values.
  • CliHelp can provide a help text with all available configuration options or a sample of how a call would look

AppConfig.config fills the template at the bottom of the file.
As you probably guessed,

  • ##S: lines are only printed for server configuration files
  • ##C: lines are only printed for client configuration files

The rest uses the instance variables available after creating an AppConfig with ConfigReader.

@arnehormann
Copy link
Author

Extension Suggestions

See https://community.openvpn.net/openvpn/wiki/Openvpn23ManPage
consider adding this to the configuration template:

ping 5
hand-window 10

Search for TODO, MAYBE, IDEA and NOTE in the source file above for some additional remarks.

@arnehormann
Copy link
Author

Troubleshooting

This script somehow fails when it's called from Windows. I didn't have Windows on hand myself and couldn't debug it. The configurations work, though.

When OpenVPN is installed on a Raspberry Pi with Raspbian, the tls cipher list is borked.
First, update OpenSSL so you are heartbleed safe: openssl version -a should show a build date of April 20th 2014 or more recent.
Call openvpn --show-tls and copy the list.
Remove all entries containing RC4, DES, 3DES, NULL and ECDH.
Proofread what's left, I don't know what OpenVPN can handle in which version on which hardware.
Put the rest into your configuration:

  • ":" separated in the server configuration: tls-cipher
  • in the generator: AppConfig.@@default_tls_ciphers

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment