diff --git a/.document b/.document new file mode 100644 index 0000000..3d618dd --- /dev/null +++ b/.document @@ -0,0 +1,5 @@ +lib/**/*.rb +bin/* +- +features/**/*.feature +LICENSE.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757d622 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# rcov generated +coverage +coverage.data + +# rdoc generated +rdoc + +# yard generated +doc +.yardoc + +# bundler +.bundle + +# jeweler generated +pkg + +# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: +# +# * Create a file at ~/.gitignore +# * Include files you want ignored +# * Run: git config --global core.excludesfile ~/.gitignore +# +# After doing this, these files will be ignored in all your git projects, +# saving you from having to 'pollute' every project you touch with them +# +# Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) +# +# For MacOS: +# +.DS_Store + +# For TextMate +*.tmproj +tmtags + +# For emacs: +*~ +\#* +.\#* + +# For vim: +*.swp + +# For redcar: +.redcar + +# For rubinius: +*.rbc + +Gemfile.lock diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..9f96816 --- /dev/null +++ b/Gemfile @@ -0,0 +1,53 @@ +source 'https://rubygems.org' + +group :development do + gem "pry" + gem 'pry-doc' #, :git => 'https://github.com/pry/pry-doc.git' + gem 'pry-rails' #, :git => 'https://github.com/rweng/pry-rails.git' + gem 'pry-nav' #, :git => 'https://github.com/nixme/pry-nav.git' + gem 'pry-syntax-hacks' #, :git => 'https://github.com/ConradIrwin/pry-syntax-hacks.git' + gem 'pry-stack_explorer' #, :git => 'https://github.com/pry/pry-stack_explorer.git' + gem 'pry-exception_explorer' #, :git => 'https://github.com/pry/pry-exception_explorer.git' + gem "rdoc" #, "~> 3.12" + gem "bundler" #, "~> 1.0.0" + gem "jeweler" #, "~> 1.8.3" + gem "rspec" +end + +gem 'haml' + +# cli options parser +gem 'main', :git => 'git://github.com/ahoward/main.git' +gem 'highline' #, :git => 'https://github.com/JEG2/highline.git' + +#gem 'hashugar', :git => 'git://github.com/jsuchal/hashugar.git' +#gem 'rbcurse-core', :git => 'git://github.com/rkumar/rbcurse-core.git' +#gem 'rbcurse-extras', :git => 'git://github.com/rkumar/rbcurse-extras.git' + +# openssl wrappers +gem 'gibberish', :git => 'git://github.com/mdp/gibberish.git' +#gem 'cert_lib', :git => 'git://github.com/victorgrey/cert_lib.git' + + +# model stuff +# gem 'datamapper', "~> 1.2.0" +# gem 'dm-aggregates' +# gem 'dm-types', "~> 1.2.1" +# gem 'dm-observer' +# gem 'dm-migrations' +# gem 'dm-timestamps' +# gem 'dm-serializer', "~> 1.2.0" +# gem 'dm-validations' +# gem 'dm-mysql-adapter' + +# support libs +gem 'chronic' +gem 'ipaddr_extensions' +#, :git => 'git://github.com/jamesotron/IPAddrExtensions.git' +# gem 'rubyzip', :git => 'git://github.com/aussiegeek/rubyzip.git' + +# reporting +# gem 'ruport' +# gem 'ruport-util' + +gem 'ya_email_validator' diff --git a/README.rdoc b/README.rdoc index 3c761a1..4321e3b 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,8 +1,18 @@ +most of the code was stolen from here: http://github.com/pc/vpnmaker, thank you! +i made a gem, converted it to use haml, added bin/vpnmaker cli = VPNMaker -VPNMaker takes the teetering jankiness out of setting up and administering OpenVPN VPNs. +VPNMaker takes the teetering jankiness out of setting up and administering OpenVPN. -== Key management +It comes without any guarantees, the code seems to work for me, your mileage will invariably vary! +== Usage +* vpnmaker -h is your best friend +help format sucks, but it's better then using easy-rsa or doing openssl by hand +== Example +>>#vpnmaker init cli conf_name new_dir_path country province city organization organization_unit common_name key_name email + +== From the forked version: +=== Key management To set up your VPN, run: @@ -65,7 +75,7 @@ When Joe leaves the company, we can do: Which does the same revocation as in regenerate_user, but doesn't generate new keys. -== OpenVPN management +=== OpenVPN management To get OpenVPN set up, you should go back and edit foocorp.config.yaml, and add the following section: @@ -77,13 +87,17 @@ To get OpenVPN set up, you should go back and edit foocorp.config.yaml, :log: /var/log/openvpn.log :host: foocorp.com :port: 1194 - + You may want to modify some of the values. Then, head back to irb, and do something like: >> puts mgr.config_generator.server Which will output a config file that you can copy and paste into openvpn.conf on your server. You'll want make sure that the following files exist in /root/openvpn (or whatever your root directory is): ca.crt (so that the server can verify the validity of client certificates), dh.pem (for encryption of the connection), server.crt (the server's public key), server.key (the server's private key), ta.key (shared secret between server and clients), and crl.pem (so that the server will reject revoked certificates). -== OpenVPN client +=== OpenVPN client Each client will need: user.key, user.crt, ca.crt and ta.key. Make sure to enable tls-auth = 1. + +== Testing + +Tests are done with Rspec. To run the tests simply run `bundle exec rake test` or `bundle exec rake spec`. \ No newline at end of file diff --git a/Rakefile b/Rakefile index 342c26f..8223fef 100644 --- a/Rakefile +++ b/Rakefile @@ -1,44 +1,45 @@ -require 'highline/import' -require File.join(File.dirname(__FILE__), 'vpnmaker') +# encoding: utf-8 +require 'rubygems' +require 'bundler/gem_tasks' -def get_arg(argname, echo=true) - return ENV[argname] if ENV[argname] - ask("Value for #{argname}?") { |q| q.echo = false unless echo } +begin + Bundler.setup(:default, :development) +rescue Bundler::BundlerError => e + $stderr.puts e.message + $stderr.puts "Run `bundle install` to install missing gems" + exit e.status_code end +require 'rake' +require "rspec/core/rake_task" -namespace :config do - desc 'Generate server config' - task :server => :environment do - puts $manager.config_generator.server - end +RSpec::Core::RakeTask.new - desc 'Generate client config' - task :client => :environment do - username = get_arg('username') - puts $manager.config_generator.client($manager.user(username)) - end -end +task :test => :spec -namespace :user do - desc 'Create a new user' - task :create => :environment do - cn = get_arg('cn') - name = get_arg('name') - email = get_arg('email') - password = get_arg('password', false) - confirm_password = get_arg('confirm_password', false) - raise ArgumentError.new("Password mismatch") unless password == confirm_password - - if password.length > 0 - $manager.create_user(cn, name, email, password) - else - $manager.create_user(cn, name, email) - end +task :console do + begin + # use Pry if it exists + require 'pry' + require 'vpnmaker' + Pry.start + rescue LoadError + require 'irb' + require 'irb/completion' + require 'vpnmaker' + ARGV.clear + IRB.start end end -# Set up environment -task :environment do - vpndir = get_arg('vpndir') - $manager = VPNMaker::Manager.new(vpndir) +task :c => :console + + +require 'rdoc/task' +Rake::RDocTask.new do |rdoc| + version = File.exist?('VERSION') ? File.read('VERSION') : "" + + rdoc.rdoc_dir = 'rdoc' + rdoc.title = "vpnmaker #{version}" + rdoc.rdoc_files.include('README*') + rdoc.rdoc_files.include('lib/**/*.rb') end diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8684498 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.11 \ No newline at end of file diff --git a/bin/vpnmaker b/bin/vpnmaker new file mode 100755 index 0000000..7e13b2a --- /dev/null +++ b/bin/vpnmaker @@ -0,0 +1,297 @@ +#!/usr/bin/env ruby +require_relative '../lib/vpnmaker.rb' +#require 'micro-optparse' +#require 'highline' +require 'ya_email_validator' +require 'highline/import' +require 'main' + +# This validates email addresses +class String; include YaEmailValidator; end + +#TODO: use ~/.vpnmaker .vpnmaker and /etc/vpnmaker | maybe vpnmakerrc +module VPNMaker + module CLI + class Options + # main DSL + Main do + version File.read(File.expand_path("../..", __FILE__) + "/VERSION") + author 'Copyleft(cl) VoipScout - No rights reserved' + + mode('init') { + mode('cli') { + argument('country') { + required + cast :string + arity 1 + } + argument('province') { + required + cast :string + arity 1 + } + argument('city') { + required + cast :string + arity 1 + } + argument('organization') { + required + cast :string + arity 1 + } + argument('organization_unit') { + required + cast :string + arity 1 + } + argument('common_name') { + required + cast :string + arity 1 + } + argument('key_name') { + required + cast :string + arity 1 + } + argument('email') { + required + cast :string + arity 1 + validate {|e| e.email?} + } + + } #mode 'cli' + + argument('conf_name') { + required + cast :string + arity 1 + } + argument('new_dir_path') { + required + cast :string + arity 1 + validate {|dir| File.directory?(File.expand_path(dir))} + } + + def run + get_init_config + end + } + + mode('server') { + mode('build') { + def run + db.build_server + say('Please edit your config.yaml if you haven\'t done so yet') + end + } + mode('config') { + def run + puts db.config_generator.server + end + } + mode('install') { + description "this will make /etc/openvpn/[your server].ovpn.conf and crl.pem and some files to make NAT work, look into basedir" + def run + #FIXME: This needs to be cleaned up + iptables_nat_rules = < { + :country => params['country'].value, + :province => params['province'].value, + :city => params['city'].value, + :organization => params['organization'].value, + :organization_unit => params['organization_unit'].value, + :common_name => params['common_name'].value, + :name => params['key_name'].value, + :email => params['email'].value + }, + :site => { + :data_dir => data_dir.split('/').last, + :template_dir => template_dir.split('/').last, + :client_conf_dir => client_config_dir.split('/').last + } + } + + example_config = YAML.load_file(lib_dir + "/example_vpnmaker_site.config.yaml").to_yaml.gsub(/\n|---/, "\n#") + File.open((File.expand_path(dir) + "/" + name + ".vpn" + "/" + name + ".config.yaml"), 'w') {|f| f.write(initial_config.to_yaml + example_config)} + mgr = VPNMaker::Manager.new((File.expand_path(dir) + "/" + name + ".vpn")) + mgr.build_ca + say("Please edit files in #{template_dir} and #{dir}/#{name}.vpn/#{name}.config.yaml before proceeding further") + else + say('Time to mod yaml files') + end + end + + end # Main {} + end #class Options + end #module CLI +end #module VPNMaker + +VPNMaker::CLI::Options.new diff --git a/client.erb b/client.erb deleted file mode 100644 index 8e5e7ee..0000000 --- a/client.erb +++ /dev/null @@ -1,15 +0,0 @@ -# Auto-generated by vpnmaker on <%= @gen_host %> at <%= Time.now.to_s %> -# See http://github.com/pc/vpnmaker - -remote <%= @server[:host] %> <%= @server[:port] %> udp -persist-key -tls-client -tls-auth ta.key 1 -pull -ca ca.crt -dev tun -persist-tun -cert <%= @user %>-<%= (@revoked.max || -1) + 1 %>.crt -nobind -key <%= @user %>-<%= (@revoked.max || -1) + 1 %>.key -remote-cert-tls server diff --git a/lib/datastore.rb b/lib/datastore.rb new file mode 100644 index 0000000..d8d4b03 --- /dev/null +++ b/lib/datastore.rb @@ -0,0 +1,47 @@ +module VPNMaker + module DataStore + require 'dm-core' + require 'dm-do-adapter' + require 'dm-observer' + require 'dm-migrations' + require 'dm-timestamps' + require 'dm-serializer' + require 'dm-validations' + require 'dm-aggregates' + require 'dm-types' + #require 'do_mysql' + require 'dm-mysql-adapter' + + # require 'base64' + # require 'chronic' + # require 'phony' + + # require 'ansi/mixin' + # require 'ansi/progressbar' + # require 'ansi/table' + # require 'percentise' + # require 'stamp' + + # require 'munger' + # require 'ruport' + # require 'ruport/util' + + # require 'dm-sqlite-adapter' + # require 'do_sqlite3' + + # DataMapper::Logger.new($stdout, :debug) + + path = (File.dirname File.expand_path(__FILE__)) + "/" + autoload :User, "#{path}datastore/user" + autoload :CA, "#{path}datastore/ca" + + # DataMapper.setup(:default, { + # :host => '127.0.0.1', + # :port => 3306, + # :database => 'vpnmaker', + # :adapter => 'mysql', + # :username => 'root', + # :password => 'gavno.123!'}) + + end +end diff --git a/lib/datastore/ca.rb b/lib/datastore/ca.rb new file mode 100644 index 0000000..6956a74 --- /dev/null +++ b/lib/datastore/ca.rb @@ -0,0 +1,22 @@ +module VPNMaker + module DataStore + class CA + include DataMapper::Resource + + property :id, Serial + property :updated_at, DateTime + property :created_at, DateTime + + property :name, String + property :country, String + property :province, String + property :city, String + property :organization, String + property :email, String + + DataMapper::Model.raise_on_save_failure = true + DataMapper.finalize + DataMapper.auto_upgrade! + end + end +end diff --git a/lib/datastore/user.rb b/lib/datastore/user.rb new file mode 100644 index 0000000..63681a4 --- /dev/null +++ b/lib/datastore/user.rb @@ -0,0 +1,23 @@ +module VPNMaker + module DataStore + class User + include DataMapper::Resource + + property :id, Serial + property :updated_at, DateTime + property :created_at, DateTime + + property :cn, String + property :name, String + property :email, String + property :active_key, Integer + property :revoked, Object + + def user; cn; end; + + DataMapper::Model.raise_on_save_failure = true + DataMapper.finalize + DataMapper.auto_upgrade! + end + end +end diff --git a/lib/vpnmaker.rb b/lib/vpnmaker.rb new file mode 100644 index 0000000..bd3594f --- /dev/null +++ b/lib/vpnmaker.rb @@ -0,0 +1,63 @@ +require 'rubygems' + +require 'gibberish' +# require 'rubyzip' + +require 'fileutils' +require 'yaml' +require 'socket' + +require 'ipaddr' +require 'ipaddr_extensions' +require 'haml' + +require 'pry' + +class String + def path(p) + File.join(File.dirname(__FILE__), p) + end +end + +class HashBinding < Object + def self.from_hash(h) + hb = self.new + h.each do |k, v| + hb.instance_variable_set("@#{k}", v) + end + hb + end + + def binding; super; end # normally private +end + +module VPNMaker + + class BuildError < StandardError; end + + ROOT = File.dirname File.dirname __FILE__ + + autoload :DataStore, File.join(ROOT, "lib", "datastore") + autoload :ConfigGenerator, File.join(ROOT, "lib", "vpnmaker", "config_generator") + autoload :KeyDB, File.join(ROOT, "lib", "vpnmaker", "key_db") + autoload :KeyConfig, File.join(ROOT, "lib", "vpnmaker", "key_config") + autoload :KeyTracker, File.join(ROOT, "lib", "vpnmaker", "key_tracker") + autoload :Manager, File.join(ROOT, "lib", "vpnmaker", "manager") + autoload :KeyBuilder, File.join(ROOT, "lib", "vpnmaker", "key_builder") + + class << self + + def root + VPNMaker::ROOT + end + + def template_path(name=nil) + return "#{root}/templates" if name.nil? + "#{root}/templates/#{name}" + end + + def generate(*args) + KeyTracker.generate(args.first, args.last) + end + end +end diff --git a/lib/vpnmaker/config_generator.rb b/lib/vpnmaker/config_generator.rb new file mode 100644 index 0000000..d3663c9 --- /dev/null +++ b/lib/vpnmaker/config_generator.rb @@ -0,0 +1,61 @@ +module VPNMaker + class ConfigGenerator + def initialize(*args) + @mgr = args.shift.first + args.empty? ? (@runtime_cfg = default_template) : (@runtime_cfg = args.shift) + end + + def default_template + @dirname = (File.join(@mgr.data_dir)) + { + :type => :default, + :dh => File.read(File.join(@dirname, "dh.pem")), + :ca => File.read(File.join(@dirname, "ca.crt")), + :ta => File.read(File.join(@dirname, "ta.key")) + } + end + + def client_conf(client) + fname = client[:user] + '-' + ((client[:revoked].max || - 1) + 1).to_s + separator = '-----BEGIN CERTIFICATE-----' + cert = File.read(File.join(@dirname, "#{fname}.crt")).split(separator).last.insert(0, separator) + + { + :gen_host => Socket.gethostname, + :server => @mgr.config[:server], + :client => @mgr.config[:client] + }.merge(client).merge(:key => File.read(File.join(@dirname, "#{fname}.key")), + :cert => cert).merge(@runtime_cfg) + end + + def server_conf + separator = '-----BEGIN CERTIFICATE-----' + cert = File.read(File.join(@dirname, "server.crt")).split(separator).last.insert(0, separator) + { + :gen_host => Socket.gethostname, + :crl_path => @mgr.tracker.path + }.merge(@mgr.config[:server]).merge(@runtime_cfg).merge( + :key => File.read(File.join(@dirname, "server.key")), + :cert => cert, + :crl => File.read(File.join(@dirname, "crl.pem")) + ) + end + + def server + haml_vars = server_conf.dup + haml_vars[:base_ip] = ((a = IPAddr.new haml_vars[:base_ip]); {:net => a.to_s, :mask => a.subnet_mask.to_s}) + haml_vars[:bridgednets] ? (haml_vars[:bridgednets] = haml_vars[:bridgednets].map {|net| a = (IPAddr.new net); {:net => a.to_s, :mask => a.subnet_mask.to_s}}) : (haml_vars[:bridgednets] = Hash.new) + haml_vars[:subnets] ? (haml_vars[:subnets] = haml_vars[:subnets].map {|net| a = (IPAddr.new net); {:net => a.to_s, :mask => a.subnet_mask.to_s}}) : (haml_vars[:subnets] = Hash.new) + + template = File.read(VPNMaker.template_path 'server.haml') + Haml::Engine.new(template).render(Object.new, haml_vars) + end + + def client(client) + haml_vars = client_conf(client).dup + + template = File.read(VPNMaker.template_path 'client.haml') + Haml::Engine.new(template).render(Object.new, haml_vars) + end + end +end diff --git a/lib/vpnmaker/key_builder.rb b/lib/vpnmaker/key_builder.rb new file mode 100644 index 0000000..e16c938 --- /dev/null +++ b/lib/vpnmaker/key_builder.rb @@ -0,0 +1,159 @@ +module VPNMaker + class KeyBuilder + def initialize(tracker, config) + @tmpdir = File.join tracker.path, "tmp" + clean_tmpdir + @tracker = tracker + @config = config + end + + def clean_tmpdir + FileUtils.rm_rf(@tmpdir) + FileUtils.mkdir_p(@tmpdir) + end + + def cnfpath; File.join @tmpdir, "openssl-#{$$}.cnf"; end + + def opensslvars + { + :key_size => 1024, + :key_dir => @tmpdir, + :key_country => @config[:key_properties][:country], + :key_province => @config[:key_properties][:province], + :key_city => @config[:key_properties][:city], + :key_org => @config[:key_properties][:organization], + :key_email => @config[:key_properties][:email], + :key_org => @config[:key_properties][:organization], + :key_ou => @config[:key_properties][:organization_unit], + :key_cn => @config[:key_properties][:common_name], + :key_name => @config[:key_properties][:name] + } + end + + def init + `touch #{@dir}/index.txt` + `echo 01 > #{@dir}/serial` + end + + def opensslcnf(hash={}) + c = cnfpath + + template = File.read(VPNMaker.template_path('openssl.haml')) + haml = Haml::Engine.new(template) + config = haml.render(Object.new, opensslvars.merge(hash)) + + File.open(cnfpath, 'w') do |f| + f.write(config) + end + + c + end + + # Build Diffie-Hellman parameters for the server side of an SSL/TLS connection. + def build_dh_key(keysize=1024) + `openssl dhparam -out #{tmppath('dh.pem')} #{keysize}` + + raise BuildError, "DH key was empty" if tmpfile('dh.pem').empty? + + @tracker.set_dh(tmpfile('dh.pem')) + end + + def ca + @tracker[:ca] + end + + def gen_crl + `openssl ca -gencrl -crldays 3650 -keyfile #{tmppath('ca.key')} -cert #{tmppath('ca.crt')} -out #{tmppath('crl.pem')} -config #{opensslcnf}` + + raise BuildError, "CRL was empty" if tmpfile('crl.pem').empty? + end + + def build_ca + index = tmppath('index.txt') + + FileUtils.touch(index) + + `openssl req -batch -days 3650 -nodes -new -x509 -keyout #{@tmpdir}/ca.key -out #{@tmpdir}/ca.crt -config #{opensslcnf}` + + gen_crl + + raise BuildError, "CA certificate was empty" if tmpfile('ca.crt').empty? + raise BuildError, "CA key was empty" if tmpfile('ca.key').empty? + + @tracker.set_ca(tmpfile('ca.key'), tmpfile('ca.crt'), tmpfile('crl.pem'), tmpfile('index.txt'), "01\n") + end + + def build_server_key + place_file('ca.crt') + place_file('ca.key') + place_file('index.txt') + place_file('serial') + + `openssl req -batch -days 3650 -nodes -new -keyout #{tmppath('server.key')} -out #{tmppath('server.csr')} -extensions server -config #{opensslcnf}` + `openssl req -batch -days 3650 -out #{tmppath('server.crt')} -in #{tmppath('server.csr')} -extensions server -config #{opensslcnf}` + + raise BuildError, "Server certificate was empty" if tmpfile('server.crt').empty? + raise BuildError, "Server key was empty" if tmpfile('server.key').empty? + + @tracker.set_server_key(tmpfile('server.key'), tmpfile('server.crt'), tmpfile('index.txt'), tmpfile('serial')) + end + + def build_ta_key + `openvpn --genkey --secret #{tmppath('ta.key')}` + @tracker.set_ta_key(tmpfile('ta.key')) + end + + def place_file(name) + if data = @tracker.db.data(name) + File.open(File.join(@tmpdir, name), 'w') {|f| f.write(data)} + else + raise "No data for #{name}" + end + end + + def tmppath(f, extn=nil); File.join(@tmpdir, extn ? "#{f}.#{extn}" : f); end + def tmpfile(*args); File.read(tmppath(*args)); end + + def build_key(user, name, email, pass, delegate) + h = {:key_cn => user, :key_name => name, :key_email => email} + place_file('ca.crt') + place_file('ca.key') + place_file('index.txt') + place_file('serial') + if pass + pass_spec = "-passin 'pass:#{pass}' -passout 'pass:#{pass}'" + else + pass_spec = '-nodes' + end + `openssl req -batch -days 3650 -new -keyout #{tmppath(user, 'key')} -out #{tmppath(user, 'csr')} -config #{opensslcnf(h)} -nodes` + `openssl ca -batch -days 3650 -out #{tmppath(user, 'crt')} -in #{tmppath(user, 'csr')} -config #{opensslcnf(h)}` + # TODO: this still asks for the export password and we hack + # around it from bin/vpnmaker. This is actually something that + # should only be generated dynamically upon user request. + `openssl pkcs12 -export -clcerts -in #{tmppath(user, 'crt')} -inkey #{tmppath(user, 'key')} -out #{tmppath(user, 'p12')} #{pass_spec}` + @tracker.send(delegate, user, name, email, tmpfile(user, 'key'), tmpfile(user, 'crt'), tmpfile(user, 'p12'), tmpfile('index.txt'), tmpfile('serial')) + end + + def revoke_key(user, version) + h = {:key_cn => ""} + place_file('ca.crt') + place_file('ca.key') + place_file('crl.pem') + place_file('index.txt') + place_file('serial') + + user_crt = tmppath(user, 'crt') + rev_crt = tmppath('rev-test.crt') + File.open(user_crt, 'w') {|f| f.write(@tracker.key(user, version, 'crt'))} + `openssl ca -revoke #{user_crt} -keyfile #{tmppath('ca.key')} -cert #{tmppath('ca.crt')} -config #{opensslcnf(h)}` + gen_crl + + File.open(rev_crt, 'w') {|f| f.write(File.read(tmppath('ca.crt'))); f.write(File.read(tmppath('crl.pem')))} + if `openssl verify -CAfile #{rev_crt} -crl_check #{user_crt}` =~ /certificate revoked/ + @tracker.user_key_revoked(user, version, tmpfile('crl.pem'), tmpfile('index.txt')) + else + raise "Revocation verification failed: openssl isn't recognizing it" + end + end + end +end diff --git a/lib/vpnmaker/key_config.rb b/lib/vpnmaker/key_config.rb new file mode 100644 index 0000000..65227cd --- /dev/null +++ b/lib/vpnmaker/key_config.rb @@ -0,0 +1,9 @@ +module VPNMaker + class KeyConfig + def initialize(path) + @config = YAML.load_file(path) + end + + def [](k); @config[k]; end + end +end diff --git a/lib/vpnmaker/key_db.rb b/lib/vpnmaker/key_db.rb new file mode 100644 index 0000000..51383af --- /dev/null +++ b/lib/vpnmaker/key_db.rb @@ -0,0 +1,62 @@ +module VPNMaker + class KeyDB + def initialize(path) + @path = path + @db = File.exists?(path) ? YAML.load_file(path) : {} + @touched = false + end + + def [](k); @db[k]; end + + def []=(k, v) + @db[k] = v + @db[:modified] = Time.now + @touched = true + end + + def touched! + @touched = true + @db[:modified] = Time.now + end + + + def datadir; self[:datadir]; end + + def data_path(k) + File.join(File.dirname(@path), self.datadir, k) + end + + def dump(k, v, overwrite=false) + p = data_path(k) + raise "#{k} already exists" if File.exists?(p) && !overwrite + File.open(p, 'w') {|f| f.write(v)} + @touched = true + end + + def data(k) + File.exists?(data_path(k)) ? File.read(data_path(k)) : nil + end + + def disk_version + File.exists?(@path) ? YAML.load_file(@path)[:version] : 0 + end + + def sync + if disk_version == @db[:version] + if @touched + FileUtils.mkdir_p(File.dirname(@path) + "/" + self.datadir) + @db[:version] += 1 + File.open(@path, 'w') {|f| f.write(@db.to_yaml)} + true + else + false + end + else + raise "Disk version of #{@path} (#{disk_version}) != loaded version (#{@db[:version]}). " + \ + "Try reloading and making your changes again." + end + end + + def version; @db[:version]; end + end +end diff --git a/lib/vpnmaker/key_tracker.rb b/lib/vpnmaker/key_tracker.rb new file mode 100644 index 0000000..4660ad1 --- /dev/null +++ b/lib/vpnmaker/key_tracker.rb @@ -0,0 +1,159 @@ +module VPNMaker + class KeyTracker + attr_reader :builder + attr_reader :db + attr_reader :config + attr_reader :path + + def initialize(name, dir) + @path = dir + @db = KeyDB.new(File.join(dir, name + '.db.yaml')) + @config = KeyConfig.new(File.join(dir, name + '.config.yaml')) + @builder = KeyBuilder.new(self, @config) + end + + def self.generate(name, path=nil) + path ||= '/tmp' + dir = File.join(File.expand_path(path), name + '.vpn') + + FileUtils.mkdir_p(dir) + dbpath = File.join(dir, "#{name}.db.yaml") + + db = KeyDB.new(dbpath) + db[:version] = 0 + db[:modified] = Time.now + db[:users] = {} + db[:datadir] = "data" + db.sync + end + + def assert_user(user) + raise "User doesn't exist: #{user}" unless @db[:users][user] + end + + def ca; @db[:ca]; end + + def set_ca(key, crt, crl, index, serial) + raise "CA already set" if @db[:ca] + + @db[:ca] = {:modified => Time.now} + @db.dump('ca.key', key) + @db.dump('ca.crt', crt) + @db.dump('crl.pem', crl) + @db.dump('index.txt', index) + @db.dump('serial', serial) + @db.touched! + @db.sync + end + + def set_server_key(key, crt, index, serial) + raise "Server key already set" if @db[:server] + + @db[:server] = {:modified => Time.now} + @db.dump('server.key', key) + @db.dump('server.crt', crt) + @db.dump('index.txt', index, true) + @db.dump('serial', serial, true) + @db.touched! + @db.sync + end + + def set_ta_key(ta) + raise "TA key already set" if @db[:ta] + + @db[:ta] = {:modified => Time.now} + @db.dump('ta.key', ta) + @db.touched! + @db.sync + end + + def set_dh(dh) + raise "DH key already set" if @db[:dh] + + @db[:dh] = {:modified => Time.now} + @db.dump('dh.pem', dh) + @db.touched! + @db.sync + end + + def add_key(user, key, crt, p12, ver) + @db.dump("#{user}-#{ver}.key", key) + @db.dump("#{user}-#{ver}.crt", crt) + @db.dump("#{user}-#{ver}.p12", p12) + end + + def key(user, ver, type) + @db.data("#{user}-#{ver}.#{type}") + end + + def add_user(user, name, email, key, crt, p12, index, serial) + raise "User must be a non-empty string" unless user.is_a?(String) && user.size > 0 + raise "User already exists: #{user}" if @db[:users][user] + + @db[:users][user] = { + :user => user, + :name => name, + :email => email, + :active_key => 0, + :revoked => [], + :modified => Time.now + } + @db.dump('serial', serial, true) + @db.dump('index.txt', index, true) + add_key(user, key, crt, p12, 0) + @db.touched! + @db.sync + end + + def add_user_key(user, name, email, key, crt, p12, index, serial) + assert_user(user) + + u = @db[:users][user] + u[:modified] = Time.now + u[:active_key] += 1 + add_key(user, key, crt, p12, u[:active_key]) + + @db.dump('serial', serial, true) + @db.dump('index.txt', index, true) + + @db.touched! + @db.sync + end + + def user_key_revoked(user, version, crl, index) + assert_user(user) + + raise "Verison must be an int" unless version.kind_of?(Integer) + u = @db[:users][user] + u[:revoked] << version + u[:modified] = Time.now + @db.dump('index.txt', index, true) + @db.dump('crl.pem', crl, true) + @db.touched! + @db.sync + end + + def revoked?(user, version) + assert_user(user) + + @db[:users][user][:revoked].include?(version) + end + + def active_key_version(user) + assert_user(user) + + @db[:users][user][:active_key] + end + + def user(user) + assert_user(user) + @db[:users][user] + end + + def users; @db[:users]; end + end + + def self.generate(name, path) + KeyTracker.generate(name, path) + end +end diff --git a/lib/vpnmaker/manager.rb b/lib/vpnmaker/manager.rb new file mode 100644 index 0000000..eda9535 --- /dev/null +++ b/lib/vpnmaker/manager.rb @@ -0,0 +1,57 @@ +module VPNMaker + class Manager + attr_reader :tracker, :data_dir + + def self.vpn_name(dir); dir =~ /(^|\/)([^\/\.]+)\.vpn/ ? $2 : nil; end + + def initialize(dir) + name = self.class.vpn_name(File.expand_path(dir)) + @tracker = KeyTracker.new(name, File.expand_path(dir)) + @data_dir = File.join File.expand_path(dir), "data" + end + + def config; @tracker.config; end + + def build_ca; @tracker.builder.build_ca; end + + def build_server + @tracker.builder.build_server_key + @tracker.builder.build_ta_key + @tracker.builder.build_dh_key + end + + def create_user(user, name, email, pass=nil) + @tracker.builder.build_key(user, name, email, pass, :add_user) + end + + def revoke_all(user) + cur = @tracker.active_key_version(user) + while cur >= 0 + unless @tracker.revoked?(user, cur) + @tracker.builder.revoke_key(user, cur) + end + cur -= 1 + end + end + + def regenerate_user(user, pass=nil) + revoke_all(user) + u = @tracker.user(user) + @tracker.builder.build_key(user, u[:name], u[:email], pass, :add_user_key) + end + + def delete_user(user) + revoke_all(user) + end + + def users + @tracker.users.keys + end + + def user(user) + @tracker.user(user) + end + + def config_generator(*args); ConfigGenerator.new([self] + args); end + end +end diff --git a/lib/vpnmaker/version.rb b/lib/vpnmaker/version.rb new file mode 100644 index 0000000..993a6f6 --- /dev/null +++ b/lib/vpnmaker/version.rb @@ -0,0 +1,3 @@ +module VPNMaker + VERSION = "1.1" +end \ No newline at end of file diff --git a/openssl.erb b/openssl.erb deleted file mode 100644 index 0ff5916..0000000 --- a/openssl.erb +++ /dev/null @@ -1,290 +0,0 @@ -# For use with vpnmaker, originally from easy-rsa version 2.0 - -# -# OpenSSL example configuration file. -# This is mostly being used for generation of certificate requests. -# - -# This definition stops the following lines choking if HOME isn't -# defined. -HOME = . -RANDFILE = $ENV::HOME/.rnd -openssl_conf = openssl_init - -[ openssl_init ] -# Extra OBJECT IDENTIFIER info: -#oid_file = $ENV::HOME/.oid -oid_section = new_oids -engines = engine_section - -# To use this configuration file with the "-extfile" option of the -# "openssl x509" utility, name here the section containing the -# X.509v3 extensions to use: -# extensions = -# (Alternatively, use a configuration file that has only -# X.509v3 extensions in its main [= default] section.) - -[ new_oids ] - -# We can add new OIDs in here for use by 'ca' and 'req'. -# Add a simple OID like this: -# testoid1=1.2.3.4 -# Or use config file substitution like this: -# testoid2=${testoid1}.5.6 - -#################################################################### -[ ca ] -default_ca = CA_default # The default ca section - -#################################################################### -[ CA_default ] - -dir = <%= @key_dir %> # Where everything is kept -certs = $dir # Where the issued certs are kept -crl_dir = $dir # Where the issued crl are kept -database = $dir/index.txt # database index file. -new_certs_dir = $dir # default place for new certs. - -certificate = $dir/ca.crt # The CA certificate -serial = $dir/serial # The current serial number -crl = $dir/crl.pem # The current CRL -private_key = $dir/ca.key # The private key -RANDFILE = $dir/.rand # private random number file - -x509_extensions = usr_cert # The extentions to add to the cert - -# Extensions to add to a CRL. Note: Netscape communicator chokes on V2 CRLs -# so this is commented out by default to leave a V1 CRL. -# crl_extensions = crl_ext - -default_days = 3650 # how long to certify for -default_crl_days= 30 # how long before next CRL -default_md = md5 # which md to use. -preserve = no # keep passed DN ordering - -# A few difference way of specifying how similar the request should look -# For type CA, the listed attributes must be the same, and the optional -# and supplied fields are just that :-) -policy = policy_anything - -# For the CA policy -[ policy_match ] -countryName = match -stateOrProvinceName = match -organizationName = match -organizationalUnitName = optional -commonName = supplied -name = optional -emailAddress = optional - -# For the 'anything' policy -# At this point in time, you must list all acceptable 'object' -# types. -[ policy_anything ] -countryName = optional -stateOrProvinceName = optional -localityName = optional -organizationName = optional -organizationalUnitName = optional -commonName = supplied -name = optional -emailAddress = optional - -#################################################################### -[ req ] -default_bits = <%= @key_size %> -default_keyfile = privkey.pem -distinguished_name = req_distinguished_name -attributes = req_attributes -x509_extensions = v3_ca # The extentions to add to the self signed cert - -# Passwords for private keys if not present they will be prompted for -# input_password = secret -# output_password = secret - -# This sets a mask for permitted string types. There are several options. -# default: PrintableString, T61String, BMPString. -# pkix : PrintableString, BMPString. -# utf8only: only UTF8Strings. -# nombstr : PrintableString, T61String (no BMPStrings or UTF8Strings). -# MASK:XXXX a literal mask value. -# WARNING: current versions of Netscape crash on BMPStrings or UTF8Strings -# so use this option with caution! -string_mask = nombstr - -# req_extensions = v3_req # The extensions to add to a certificate request - -[ req_distinguished_name ] -countryName = Country Name (2 letter code) -countryName_default = <%= @key_country %> -countryName_min = 2 -countryName_max = 2 - -stateOrProvinceName = State or Province Name (full name) -stateOrProvinceName_default = <%= @key_province %> - -localityName = Locality Name (eg, city) -localityName_default = <%= @key_city %> - -0.organizationName = Organization Name (eg, company) -0.organizationName_default = <%= @key_org %> - -# we can do this but it is not needed normally :-) -#1.organizationName = Second Organization Name (eg, company) -#1.organizationName_default = World Wide Web Pty Ltd - -organizationalUnitName = Organizational Unit Name (eg, section) -#organizationalUnitName_default = - -commonName = Common Name (eg, your name or your server\'s hostname) -commonName_max = 64 - -name = Name -name_max = 64 - -emailAddress = Email Address -emailAddress_default = <%= @key_email %> -emailAddress_max = 40 - -# JY -- added for batch mode -organizationalUnitName_default = <%= @key_ou %> -commonName_default = <%= @key_cn %> -name_default = <%= @key_name %> - -# SET-ex3 = SET extension number 3 - -[ req_attributes ] -challengePassword = A challenge password -challengePassword_min = 4 -challengePassword_max = 20 - -unstructuredName = An optional company name - -[ usr_cert ] - -# These extensions are added when 'ca' signs a request. - -# This goes against PKIX guidelines but some CAs do it and some software -# requires this to avoid interpreting an end user certificate as a CA. - -basicConstraints=CA:FALSE - -# Here are some examples of the usage of nsCertType. If it is omitted -# the certificate can be used for anything *except* object signing. - -# This is OK for an SSL server. -# nsCertType = server - -# For an object signing certificate this would be used. -# nsCertType = objsign - -# For normal client use this is typical -# nsCertType = client, email - -# and for everything including object signing: -# nsCertType = client, email, objsign - -# This is typical in keyUsage for a client certificate. -# keyUsage = nonRepudiation, digitalSignature, keyEncipherment - -# This will be displayed in Netscape's comment listbox. -nsComment = "Easy-RSA Generated Certificate" - -# PKIX recommendations harmless if included in all certificates. -subjectKeyIdentifier=hash -authorityKeyIdentifier=keyid,issuer:always -extendedKeyUsage=clientAuth -keyUsage = digitalSignature - -# This stuff is for subjectAltName and issuerAltname. -# Import the email address. -# subjectAltName=email:copy - -# Copy subject details -# issuerAltName=issuer:copy - -#nsCaRevocationUrl = http://www.domain.dom/ca-crl.pem -#nsBaseUrl -#nsRevocationUrl -#nsRenewalUrl -#nsCaPolicyUrl -#nsSslServerName - -[ server ] - -# JY ADDED -- Make a cert with nsCertType set to "server" -basicConstraints=CA:FALSE -nsCertType = server -nsComment = "Easy-RSA Generated Server Certificate" -subjectKeyIdentifier=hash -authorityKeyIdentifier=keyid,issuer:always -extendedKeyUsage=serverAuth -keyUsage = digitalSignature, keyEncipherment - -[ v3_req ] - -# Extensions to add to a certificate request - -basicConstraints = CA:FALSE -keyUsage = nonRepudiation, digitalSignature, keyEncipherment - -[ v3_ca ] - - -# Extensions for a typical CA - - -# PKIX recommendation. - -subjectKeyIdentifier=hash - -authorityKeyIdentifier=keyid:always,issuer:always - -# This is what PKIX recommends but some broken software chokes on critical -# extensions. -#basicConstraints = critical,CA:true -# So we do this instead. -basicConstraints = CA:true - -# Key usage: this is typical for a CA certificate. However since it will -# prevent it being used as an test self-signed certificate it is best -# left out by default. -# keyUsage = cRLSign, keyCertSign - -# Some might want this also -# nsCertType = sslCA, emailCA - -# Include email address in subject alt name: another PKIX recommendation -# subjectAltName=email:copy -# Copy issuer details -# issuerAltName=issuer:copy - -# DER hex encoding of an extension: beware experts only! -# obj=DER:02:03 -# Where 'obj' is a standard or added object -# You can even override a supported extension: -# basicConstraints= critical, DER:30:03:01:01:FF - -[ crl_ext ] - -# CRL extensions. -# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL. - -# issuerAltName=issuer:copy -authorityKeyIdentifier=keyid:always,issuer:always - -[ engine_section ] -# -# If you are using PKCS#11 -# Install engine_pkcs11 of opensc (www.opensc.org) -# And uncomment the following -# verify that dynamic_path points to the correct location -# -# pkcs11 = pkcs11_section - -[ pkcs11_section ] -engine_id = pkcs11 -dynamic_path = /usr/lib/engines/engine_pkcs11.so -MODULE_PATH = blub # $ENV::PKCS11_MODULE_PATH -PIN = blub # $ENV::PKCS11_PIN -init = 0 \ No newline at end of file diff --git a/root b/root new file mode 100644 index 0000000..c25f980 --- /dev/null +++ b/root @@ -0,0 +1 @@ +/home/robert/src/vpnmaker \ No newline at end of file diff --git a/server.erb b/server.erb deleted file mode 100644 index edbbbd9..0000000 --- a/server.erb +++ /dev/null @@ -1,40 +0,0 @@ -# Auto-generated by vpnmaker on <%= @gen_host %> at <%= Time.now.to_s %> -# See http://github.com/pc/vpnmaker - -mode server -tls-server - -local <%= @host %> -port <%= @port %> -proto udp - -dev tun0 -server <%= @base_ip %> <%= @base_netmask %> -<% @subnets.each do |net| %> -route <%= net[:base_ip] %> <%= net[:netmask] %> -push "route <%= net[:base_ip] %> <%= net[:netmask] %>"<% end %> -<% @bridgednets.each do |net| %> -push "route <%= net[:base_ip] %> <%= net[:netmask] %>"<% end %> - -# Drop privileges to user/group nobody -user <%= @user %> -group <%= @group %> - -dh <%= @root %>/keys/dh.pem - -ca <%= @root %>/keys/ca.crt -cert <%= @root %>/keys/server.crt -key <%= @root %>/keys/server.key -crl-verify <%= @root %>/keys/crl.pem - -keepalive 10 120 # ping every 10 secs; no reply for 120 secs -> down - -log <%= @log %> - -# try to give same IP to client as before -persist-tun -persist-key - -tls-auth <%= @root %>/keys/ta.key 0 - -client-config-dir <%= @root %>/ccd diff --git a/spec/lib/vpnmaker/key_tracker_spec.rb b/spec/lib/vpnmaker/key_tracker_spec.rb new file mode 100644 index 0000000..e881cf2 --- /dev/null +++ b/spec/lib/vpnmaker/key_tracker_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe VPNMaker::KeyTracker do + + after(:each) do + FileUtils.rm_rf vpn_root + end + + it "should generate the config folders" do + VPNMaker::KeyTracker.generate("my", vpn_root) + expect(File.directory? vpn_root(:my)).to be_true + end +end \ No newline at end of file diff --git a/spec/lib/vpnmaker/manager_spec.rb b/spec/lib/vpnmaker/manager_spec.rb new file mode 100644 index 0000000..f83cd9f --- /dev/null +++ b/spec/lib/vpnmaker/manager_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' +require 'fileutils' + +describe VPNMaker::Manager do + subject(:manager) { VPNMaker::Manager.new( vpn_root(:my) ) } + + before(:each) do + FileUtils.mkdir_p "/tmp/keybuilder" + end + + after(:each) do + FileUtils.rm_rf vpn_root + end + + context "when there is no config file" do + it "should raise an error" do + expect { VPNMaker::Manager.new vpn_root(:my) }.to raise_error + end + end + + context "when there is a config file" do + before(:each) do + VPNMaker.generate "my", vpn_root + File.open("#{vpn_root(:my)}/my.config.yaml", "w") { |f| f.write key_props.to_yaml } + end + + it "should create an instance of manager" do + expect( manager ).to be_an_instance_of VPNMaker::Manager + end + + it "should build the intial keys and files" do + manager.build_ca + expect(File.exist? "#{vpn_data(:my)}/ca.crt").to be_true + expect(File.exist? "#{vpn_data(:my)}/ca.key").to be_true + expect(File.exist? "#{vpn_data(:my)}/crl.pem").to be_true + expect(File.exist? "#{vpn_data(:my)}/index.txt").to be_true + expect(File.exist? "#{vpn_data(:my)}/serial").to be_true + end + + it "should build the server keys" do + manager.build_ca + manager.build_server + expect(File.exist? "#{vpn_data(:my)}/server.crt").to be_true + expect(File.exist? "#{vpn_data(:my)}/server.key").to be_true + expect(File.exist? "#{vpn_data(:my)}/dh.pem").to be_true + expect(File.exist? "#{vpn_data(:my)}/ta.key").to be_true + end + + it "should create a new user" do + manager.build_ca + manager.build_server + manager.create_user 'joe', 'Joe Bloggs', 'joe.bloggs@example.com', 'password' + expect(manager.users).to include "joe" + end + + context "and a user has been created" do + before(:each) do + manager.build_ca + manager.build_server + manager.create_user 'joe', 'Joe Bloggs', 'joe.bloggs@example.com', 'password' + end + + it "should have user details" do + details = manager.user('joe') + expect(details).to be_a Hash + expect(details[:email]).to eq 'joe.bloggs@example.com' + end + + it "should have no revoked keys" do + details = manager.user('joe') + expect(details[:revoked]).to be_empty + expect(details[:active_key]).to eq 0 + end + + context "and a user has had a key revoked" do + it "should have a revoked key in the user details" do + manager.regenerate_user('joe', 'newpassword') + details = manager.user('joe') + expect(details[:revoked]).to eq [0] + expect(details[:active_key]).to eq 1 + end + end + end + + context "when there are no server configs" do + it "should raise an error" do + expect { + manager.config_generator.server + }.to raise_error + end + end + + context "when there are server configs" do + before(:each) do + c = YAML.load_file("#{vpn_root(:my)}/my.config.yaml") + c.merge! server_props + File.open("#{vpn_root(:my)}/my.config.yaml", "w") { |f| f.write c.to_yaml } + end + + it "should build the server config" do + manager.build_ca + manager.build_server + config = manager.config_generator.server + expect(config).to include "10.10.10.0" + expect(config).to include "example.com" + expect(config).to include "1194" + end + end + + end + +end \ No newline at end of file diff --git a/spec/lib/vpnmaker_spec.rb b/spec/lib/vpnmaker_spec.rb new file mode 100644 index 0000000..09214c6 --- /dev/null +++ b/spec/lib/vpnmaker_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe VPNMaker do + + after(:each) do + FileUtils.rm_rf vpn_root + end + + it "should generate a vpn" do + VPNMaker.generate("my", vpn_root) + expect(File.exist? vpn_root(:my)).to be_true + end + +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..db5073c --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,42 @@ +require 'vpnmaker' + +RSpec.configure do |config| +end + +def testfs + root = File.dirname File.dirname __FILE__ + "#{root}/tmp" +end + +def vpn_root(name=nil) + return "#{testfs}/vpns" if name.nil? + "#{testfs}/vpns/#{name}.vpn" +end + +def vpn_data(name) + "#{vpn_root(name)}/data" +end + +def key_props + { :key_properties => { + :country => "US", + :province => "CA", + :city => "San Francisco", + :organization => "VPNMaker", + :email => "test@example.com" + } + } +end + +def server_props + { :server => { + :base_ip => "10.10.10.0", + :user => "nouser", + :group => "nogroup", + :root => "/root/openvpn", + :log => "/var/log/openvpn.log", + :host => "example.com", + :port => "1194" + } + } +end \ No newline at end of file diff --git a/templates/client.haml b/templates/client.haml new file mode 100644 index 0000000..33984d1 --- /dev/null +++ b/templates/client.haml @@ -0,0 +1,55 @@ +client +dev tun +proto udp +remote #{server[:host]} #{server[:port]} udp +remote-random +resolv-retry infinite +nobind +persist-key +persist-tun +comp-lzo +\# +\#tls-remote must equal CN of ca in the hosts x509 +\# +tls-remote #{server[:host]} + +float +cipher AES-256-CBC +comp-lzo +verb 3 +ping 30 + +- if type == :default + + #{dh} + + + + #{ca} + + + + #{cert} + + + + #{key} + + + + #{ta} + + +- else + tls-client + tls-auth ta.key 1 + pull + ca ca.crt + dev tun + persist-tun + cert #{user}-#{(revoked.max || - 1) + 1}.crt + nobind + key #{user}-#{(revoked.max || - 1) + 1}.key + remote-cert-tls server + +:plain diff --git a/templates/openssl.haml b/templates/openssl.haml new file mode 100644 index 0000000..3d01705 --- /dev/null +++ b/templates/openssl.haml @@ -0,0 +1,144 @@ +HOME = . +RANDFILE = $ENV::HOME/.rnd +openssl_conf = openssl_init + +[ openssl_init ] +oid_section = new_oids +engines = engine_section + +[ new_oids ] +[ ca ] +default_ca = CA_default + +[CA_default ] + +dir = #{key_dir} +certs = $dir # Where the issued certs are kept +crl_dir = $dir # Where the issued crl are kept +database = $dir/index.txt # database index file. +new_certs_dir = $dir # default place for new certs. + +certificate = $dir/ca.crt # The CA certificate +serial = $dir/serial # The current serial number +crl = $dir/crl.pem # The current CRL +private_key = $dir/ca.key # The private key +RANDFILE = $dir/.rand # private random number file + +x509_extensions = usr_cert # The extentions to add to the cert + +default_days = 3650 # how long to certify for +default_crl_days= 30 # how long before next CRL +default_md = md5 # which md to use. +preserve = no # keep passed DN ordering + +policy = policy_anything + +[ policy_match ] +countryName = match +stateOrProvinceName = match +organizationName = match +organizationalUnitName = optional +commonName = supplied +name = optional +emailAddress = optional + +[ policy_anything ] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +name = optional +emailAddress = optional + +[ req ] +default_bits = #{key_size} +default_keyfile = privkey.pem +distinguished_name = req_distinguished_name +attributes = req_attributes +x509_extensions = v3_ca # The extentions to add to the self signed cert + +string_mask = nombstr + +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +countryName_default = #{key_country} +countryName_min = 2 +countryName_max = 2 + +stateOrProvinceName = State or Province Name (full name) +stateOrProvinceName_default = #{key_province} + +localityName = Locality Name (eg, city) +localityName_default = #{key_city} + +0.organizationName = Organization Name (eg, company) +0.organizationName_default = #{key_org} + +organizationalUnitName = Organizational Unit Name (eg, section) + +commonName = Common Name (eg, your name or your server\'s hostname) +commonName_max = 64 + +name = Name +name_max = 64 + +emailAddress = Email Address +emailAddress_default = #{key_email} +emailAddress_max = 40 + +organizationalUnitName_default = #{key_ou} +commonName_default = #{key_cn} +name_default = #{key_name} + +[ req_attributes ] +challengePassword = A challenge password +challengePassword_min = 4 +challengePassword_max = 20 + +unstructuredName = An optional company name + +[ usr_cert ] + +basicConstraints=CA:FALSE +nsComment = "Easy-RSA Generated Certificate" + +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid,issuer:always +extendedKeyUsage=clientAuth +keyUsage = digitalSignature + +[ server ] + +basicConstraints=CA:FALSE +nsCertType = server +nsComment = "Easy-RSA Generated Server Certificate" +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid,issuer:always +extendedKeyUsage=serverAuth +keyUsage = digitalSignature, keyEncipherment + +[ v3_req ] + +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment + +[ v3_ca ] +subjectKeyIdentifier=hash + +authorityKeyIdentifier=keyid:always,issuer:always +basicConstraints = CA:true + +[ crl_ext ] +authorityKeyIdentifier=keyid:always,issuer:always + +[ engine_section ] + +[ pkcs11_section ] +engine_id = pkcs11 +dynamic_path = /usr/lib/engines/engine_pkcs11.so +MODULE_PATH = blub # $ENV::PKCS11_MODULE_PATH +PIN = blub # $ENV::PKCS11_PIN +init = 0 +:plain diff --git a/templates/server.haml b/templates/server.haml new file mode 100644 index 0000000..c39cf93 --- /dev/null +++ b/templates/server.haml @@ -0,0 +1,64 @@ +\# Auto-generated by vpnmaker on #{gen_host} #{Time.now.to_s} +\# See http://github.com/pc/vpnmaker +mode server +dev tun0 +\#local #{host} +proto udp +port #{port} +server #{base_ip[:net]} #{base_ip[:mask]} +tls-server +comp-lzo +cipher AES-256-CBC +crl-verify /etc/openvpn/crl.pem + +- unless subnets.empty? + \# subnets.each do + - subnets.each do |net| + route #{net[:net]} #{net[:mask]} + push route #{net[:net]} #{net[:mask]} + +- unless bridgednets.empty? + \# bridgednets.each do + - bridgednets.each do |net| + push route #{net[:net]} #{net[:mask]} +\ +push "redirect-gateway" +client-to-client + +user #{user} +group #{group} +- if type == :default + + + #{dh} + + + + #{ca} + + + + #{cert} + + + + #{key} + + + + #{ta} + + +- else + dh #{root}/keys/dh.pem + ca #{root}/keys/ca.crt + cert #{root}/keys/server.crt + key #{root}/keys/server.key + crl-verify #{root}/keys/crl.pem + +keepalive 10 120 +\#log #{log} +persist-tun +persist-key + +:plain diff --git a/foocorp.config.yaml b/templates/vpn.config.yaml similarity index 66% rename from foocorp.config.yaml rename to templates/vpn.config.yaml index 5f28a0f..9d23119 100644 --- a/foocorp.config.yaml +++ b/templates/vpn.config.yaml @@ -1,24 +1,26 @@ +--- +:key_properties: + :country: US + :province: CA + :city: San Francisco + :organization: myorg + :email: security@my.org + :server: - :base_ip: 10.10.10.0 + :base_ip: 10.10.10.0/24 :bridgednets: # real networks to bridge via the VPN server - - 172.16.0.0 + - 172.16.0.0/24 :subnets: # subnets that exist only on the VPN - - 10.10.11.0 + - 10.10.11.0/8 + - 10.11.2.0/24 :user: nobody :group: nogroup - :root: /root/openvpn + :root: /etc/openvpn :log: /var/log/openvpn.log - :host: vpn.foocorp.com + :host: MY_HOST_FQDN :port: 1194 :client: :subnet: 172.16.0.0 :local_endpoint: 10.10.10.100 :remote_endpoint: 10.10.10.1 - -:key_properties: - :country: US - :province: CA - :city: San Francisco - :organization: FooCorp Inc - :email: sec@foocorp.com diff --git a/vpnmaker.gemspec b/vpnmaker.gemspec new file mode 100644 index 0000000..28e64bc --- /dev/null +++ b/vpnmaker.gemspec @@ -0,0 +1,80 @@ +lib = File.expand_path("../lib", __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'vpnmaker/version' + +Gem::Specification.new do |s| + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + + s.name = "vpnmaker" + s.version = VPNMaker::VERSION + s.authors = ["Voip Scout", "Robert McLeod"] + s.email = "voipscout@gmail.com" + s.date = "2012-05-12" + s.description = "haml templates and key tracking" + s.summary = "Makes it easy to manage OpenVPN" + s.homepage = "http://github.com/voipscout/vpnmaker" + s.licenses = ["MIT"] + + s.executables = ["vpnmaker"] + s.extra_rdoc_files = ["README.rdoc"] + s.files = `git ls-files`.split($/) + s.require_paths = ["lib"] + + if s.respond_to? :specification_version then + s.specification_version = 3 + + if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then + s.add_runtime_dependency(%q, [">= 0"]) + s.add_runtime_dependency(%q
, [">= 0"]) + s.add_runtime_dependency(%q, [">= 0"]) + s.add_runtime_dependency(%q, [">= 0"]) + s.add_runtime_dependency(%q, [">= 0"]) + s.add_runtime_dependency(%q, [">= 0"]) + s.add_runtime_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + s.add_development_dependency(%q, [">= 0"]) + else + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q
, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + end + else + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q
, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + s.add_dependency(%q, [">= 0"]) + end +end + diff --git a/vpnmaker.rb b/vpnmaker.rb deleted file mode 100644 index 7deeabf..0000000 --- a/vpnmaker.rb +++ /dev/null @@ -1,478 +0,0 @@ -require 'rubygems' - -require 'fileutils' -require 'yaml' -require 'erb' -require 'socket' - -class String - def path(p) - File.join(File.dirname(__FILE__), p) - end -end - -class HashBinding < Object - def self.from_hash(h) - hb = self.new - h.each do |k, v| - hb.instance_variable_set("@#{k}", v) - end - hb - end - - def binding; super; end # normally private -end - -module VPNMaker - class ConfigGenerator - def initialize(mgr) - @mgr = mgr - end - - def client_conf(client) - { - :gen_host => Socket.gethostname, - :server => @mgr.config[:server], - :client => @mgr.config[:client] - }.merge(client) - end - - def server_conf - { - :gen_host => Socket.gethostname - }.merge(@mgr.config[:server]) - end - - def apply_erb(name, cnf) - erb = File.read(__FILE__.path(name)) - ERB.new(erb).result(HashBinding.from_hash(cnf).binding) - end - - def server; apply_erb('server.erb', server_conf); end - - def client(client) - apply_erb('client.erb', client_conf(client)); - end - end - - class KeyDB - def initialize(path) - @path = path - @db = File.exists?(path) ? YAML.load_file(path) : {} - @touched = false - end - - def [](k); @db[k]; end - - def []=(k, v) - @db[k] = v - @db[:modified] = Time.now - @touched = true - end - - def touched! - @touched = true - @db[:modified] = Time.now - end - - - def datadir; self[:datadir]; end - - def data_path(k) - File.join(File.dirname(@path), self.datadir, k) - end - - def dump(k, v, overwrite=false) - p = data_path(k) - raise "#{k} already exists" if File.exists?(p) && !overwrite - File.open(p, 'w') {|f| f.write(v)} - @touched = true - end - - def data(k) - File.exists?(data_path(k)) ? File.read(data_path(k)) : nil - end - - def disk_version - File.exists?(@path) ? YAML.load_file(@path)[:version] : 0 - end - - def sync - if disk_version == @db[:version] - if @touched - FileUtils.mkdir_p(self.datadir) - @db[:version] += 1 - File.open(@path, 'w') {|f| f.write(@db.to_yaml)} - true - else - false - end - else - raise "Disk version of #{@path} (#{disk_version}) != loaded version (#{@db[:version]}). " + \ - "Try reloading and making your changes again." - end - end - - def version; @db[:version]; end - end - - class KeyConfig - def initialize(path) - @config = YAML.load_file(path) - end - - def [](k); @config[k]; end - end - - class KeyTracker - attr_reader :builder - attr_reader :db - attr_reader :config - - def self.generate(name, path=nil) - path ||= '/tmp' - dir = File.join(File.expand_path(path), name + '.vpn') - - FileUtils.mkdir_p(dir) - datadir = "#{name}_data" - dbpath = File.join(dir, "#{name}.db.yaml") - - d = KeyDB.new(dbpath) - d[:version] = 0 - d[:modified] = Time.now - d[:users] = {} - d[:datadir] = datadir - d.sync - end - - def assert_user(user) - raise "User doesn't exist: #{user}" unless @db[:users][user] - end - - def ca; @db[:ca]; end - - def set_ca(key, crt, crl, index, serial) - raise "CA already set" if @db[:ca] - - @db[:ca] = {:modified => Time.now} - @db.dump('ca.key', key) - @db.dump('ca.crt', crt) - @db.dump('crl.pem', crl) - @db.dump('index.txt', index) - @db.dump('serial', serial) - @db.touched! - @db.sync - end - - def set_server_key(key, crt, index, serial) - raise "Server key already set" if @db[:server] - - @db[:server] = {:modified => Time.now} - @db.dump('server.key', key) - @db.dump('server.crt', crt) - @db.dump('index.txt', index, true) - @db.dump('serial', serial, true) - @db.touched! - @db.sync - end - - def set_ta_key(ta) - raise "TA key already set" if @db[:ta] - - @db[:ta] = {:modified => Time.now} - @db.dump('ta.key', ta) - @db.touched! - @db.sync - end - - def set_dh(dh) - raise "DH key already set" if @db[:dh] - - @db[:dh] = {:modified => Time.now} - @db.dump('dh.pem', dh) - @db.touched! - @db.sync - end - - def add_key(user, key, crt, p12, ver) - @db.dump("#{user}-#{ver}.key", key) - @db.dump("#{user}-#{ver}.crt", crt) - @db.dump("#{user}-#{ver}.p12", p12) - end - - def key(user, ver, type) - @db.data("#{user}-#{ver}.#{type}") - end - - def add_user(user, name, email, key, crt, p12, index, serial) - raise "User must be a non-empty string" unless user.is_a?(String) && user.size > 0 - raise "User already exists: #{user}" if @db[:users][user] - - @db[:users][user] = { - :user => user, - :name => name, - :email => email, - :active_key => 0, - :revoked => [], - :modified => Time.now - } - @db.dump('serial', serial, true) - @db.dump('index.txt', index, true) - add_key(user, key, crt, p12, 0) - @db.touched! - @db.sync - end - - def add_user_key(user, name, email, key, crt, p12, index, serial) - assert_user(user) - - u = @db[:users][user] - u[:modified] = Time.now - u[:active_key] += 1 - add_key(user, key, crt, p12, u[:active_key]) - - @db.dump('serial', serial, true) - @db.dump('index.txt', index, true) - - @db.touched! - @db.sync - end - - def user_key_revoked(user, version, crl, index) - assert_user(user) - - raise "Verison must be an int" unless version.kind_of?(Integer) - u = @db[:users][user] - u[:revoked] << version - u[:modified] = Time.now - @db.dump('index.txt', index, true) - @db.dump('crl.pem', crl, true) - @db.touched! - @db.sync - end - - def revoked?(user, version) - assert_user(user) - - @db[:users][user][:revoked].include?(version) - end - - def active_key_version(user) - assert_user(user) - - @db[:users][user][:active_key] - end - - def user(user) - assert_user(user) - @db[:users][user] - end - - def users; @db[:users]; end - - def initialize(name, dir) - @db = KeyDB.new(File.join(dir, name + '.db.yaml')) - @config = KeyConfig.new(File.join(dir, name + '.config.yaml')) - @builder = KeyBuilder.new(self, @config) - end - end - - def self.generate(name, path) - KeyTracker.generate(name, path) - end - - class Manager - attr_reader :tracker - - def self.vpn_name(dir); dir =~ /(^|\/)([^\/\.]+)\.vpn/ ? $2 : nil; end - - def initialize(dir) - name = self.class.vpn_name(File.expand_path(dir)) - @tracker = KeyTracker.new(name, File.expand_path(dir)) - end - - def config; @tracker.config; end - - def build_ca; @tracker.builder.build_ca; end - def build_server - @tracker.builder.build_server_key - @tracker.builder.build_ta_key - @tracker.builder.build_dh_key - end - - def create_user(user, name, email, pass=nil) - @tracker.builder.build_key(user, name, email, pass, :add_user) - end - - def revoke_all(user) - cur = @tracker.active_key_version(user) - while cur >= 0 - unless @tracker.revoked?(user, cur) - @tracker.builder.revoke_key(user, cur) - end - cur -= 1 - end - end - - def regenerate_user(user, pass=nil) - revoke_all(user) - u = @tracker.user(user) - @tracker.builder.build_key(user, u[:name], u[:email], pass, :add_user_key) - end - - def delete_user(user) - revoke_all(user) - end - - def users - @tracker.users.keys - end - - def user(user) - @tracker.user(user) - end - - def config_generator; ConfigGenerator.new(self); end - end - - class KeyBuilder - def initialize(tracker, config) - @tmpdir = '/tmp/keybuilder' - clean_tmpdir - @tracker = tracker - @config = config - end - - def clean_tmpdir - FileUtils.rm_rf(@tmpdir) - FileUtils.mkdir_p(@tmpdir) - end - - def cnfpath; "/tmp/openssl-#{$$}.cnf"; end - - def opensslvars - { - :key_size => 1024, - :key_dir => @tmpdir, - :key_country => @config[:key_properties][:country], - :key_province => @config[:key_properties][:province], - :key_city => @config[:key_properties][:city], - :key_org => @config[:key_properties][:organization], - :key_email => @config[:key_properties][:email], - :key_org => @config[:key_properties][:organization], - :key_ou => 'Organization Unit', - :key_cn => 'Common Name', - :key_name => 'Name' - } - end - - def init - `touch #{@dir}/index.txt` - `echo 01 > #{@dir}/serial` - end - - def opensslcnf(hash={}) - c = cnfpath - - File.open(cnfpath, 'w') do |f| - f.write(ERB.new(File.read(__FILE__.path('openssl.erb'))).\ - result(HashBinding.from_hash(opensslvars.merge(hash)).binding)) - end - - c - end - - # Build Diffie-Hellman parameters for the server side of an SSL/TLS connection. - def build_dh_key(keysize=1024) - `openssl dhparam -out #{tmppath('dh.pem')} #{keysize}` - @tracker.set_dh(tmpfile('dh.pem')) - end - - def ca - @tracker[:ca] - end - - def gen_crl - `openssl ca -gencrl -crldays 3650 -keyfile #{tmppath('ca.key')} -cert #{tmppath('ca.crt')} -out #{tmppath('crl.pem')} -config #{opensslcnf}` - end - - def build_ca - index = tmppath('index.txt') - - FileUtils.touch(index) - - `openssl req -batch -days 3650 -nodes -new -x509 -keyout #{@tmpdir}/ca.key -out #{@tmpdir}/ca.crt -config #{opensslcnf}` - gen_crl - @tracker.set_ca(tmpfile('ca.key'), tmpfile('ca.crt'), tmpfile('crl.pem'), tmpfile('index.txt'), "01\n") - end - - def build_server_key - place_file('ca.crt') - place_file('ca.key') - place_file('index.txt') - place_file('serial') - - `openssl req -batch -days 3650 -nodes -new -keyout #{tmppath('server.key')} -out #{tmppath('server.csr')} -extensions server -config #{opensslcnf}` - `openssl ca -batch -days 3650 -out #{tmppath('server.crt')} -in #{tmppath('server.csr')} -extensions server -config #{opensslcnf}` - - @tracker.set_server_key(tmpfile('server.key'), tmpfile('server.crt'), tmpfile('index.txt'), tmpfile('serial')) - end - - def build_ta_key - `openvpn --genkey --secret #{tmppath('ta.key')}` - @tracker.set_ta_key(tmpfile('ta.key')) - end - - def place_file(name) - if data = @tracker.db.data(name) - File.open(File.join(@tmpdir, name), 'w') {|f| f.write(data)} - else - raise "No data for #{name}" - end - end - - def tmppath(f, extn=nil); File.join(@tmpdir, extn ? "#{f}.#{extn}" : f); end - def tmpfile(*args); File.read(tmppath(*args)); end - - def build_key(user, name, email, pass, delegate) - h = {:key_cn => user, :key_name => name, :key_email => email} - place_file('ca.crt') - place_file('ca.key') - place_file('index.txt') - place_file('serial') - if pass - pass_spec = "-passin 'pass:#{pass}' -passout 'pass:#{pass}'" - else - pass_spec = '-nodes' - end - - `openssl req -batch -days 3650 -new -keyout #{tmppath(user, 'key')} -out #{tmppath(user, 'csr')} -config #{opensslcnf(h)} #{pass_spec}` - `openssl ca -batch -days 3650 -out #{tmppath(user, 'crt')} -in #{tmppath(user, 'csr')} -config #{opensslcnf(h)}` - # TODO: this still asks for the export password - `openssl pkcs12 -export -clcerts -in #{tmppath(user, 'crt')} -inkey #{tmppath(user, 'key')} -out #{tmppath(user, 'p12')} #{pass_spec}` - @tracker.send(delegate, user, name, email, tmpfile(user, 'key'), tmpfile(user, 'crt'), tmpfile(user, 'p12'), tmpfile('index.txt'), tmpfile('serial')) - end - - def revoke_key(user, version) - h = {:key_cn => ""} - place_file('ca.crt') - place_file('ca.key') - place_file('crl.pem') - place_file('index.txt') - place_file('serial') - - user_crt = tmppath(user, 'crt') - rev_crt = tmppath('rev-test.crt') - File.open(user_crt, 'w') {|f| f.write(@tracker.key(user, version, 'crt'))} - `openssl ca -revoke #{user_crt} -keyfile #{tmppath('ca.key')} -cert #{tmppath('ca.crt')} -config #{opensslcnf(h)}` - gen_crl - - File.open(rev_crt, 'w') {|f| f.write(File.read(tmppath('ca.crt'))); f.write(File.read(tmppath('crl.pem')))} - if `openssl verify -CAfile #{rev_crt} -crl_check #{user_crt}` =~ /certificate revoked/ - @tracker.user_key_revoked(user, version, tmpfile('crl.pem'), tmpfile('index.txt')) - else - raise "Revocation verification failed: openssl isn't recognizing it" - end - end - end -end