-
-
Save arnehormann/9744964 to your computer and use it in GitHub Desktop.
| #!/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('') | |
| } |
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.
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
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 😀
@@prefixand@@parametersfollowed by creation of the accessors are sufficient)AppConfig.configfills 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 filesThe rest uses the instance variables available after creating an AppConfig with ConfigReader.