#!/usr/bin/lua5.2

--[[
Certificate Authority tool for Dynamic Multipoint VPN

Copyright (c) 2014-2018 Kaarle Ritvanen
Copyright (c) 2015 Timo Teräs
Copyright (c) 2017 Natanael Copa

See LICENSE file for license details
]]--

conf_file = io.open(os.getenv('DMVPN_CA_CONF') or '/etc/dmvpn-ca.conf')
config = require('lyaml').load(conf_file:read('*a'))
conf_file:close()

asn1 = require('asn1')
rfc3779 = require('asn1.rfc3779')
rfc5280 = require('asn1.rfc5280')

dmvpn = require('dmvpn')

pkcs12 = require('openssl.pkcs12')
pkey = require('openssl.pkey')
x509 = require('openssl.x509')
x509an = require('openssl.x509.altname')
x509ch = require('openssl.x509.chain')
x509crl = require('openssl.x509.crl')
x509ext = require('openssl.x509.extension')
x509n = require('openssl.x509.name')

stat = require('posix.sys.stat')
unistd = require('posix.unistd')

stringy = require('stringy')

dev = io.open('/dev/urandom')
s = dev:read(4)
dev:close()
assert(s:len() == 4)
seed = 0
for i=1,4 do seed = seed * 256 + s:byte(i) end
math.randomseed(seed)


function set_config_defaults(config, defaults)
	for k, v in pairs(defaults) do
		if not config[k] then config[k] = v
		elseif type(v) == 'table' then
			set_config_defaults(config[k], v)
		end
	end
end

set_config_defaults(
	config,
	{
		db={file='/var/lib/misc/dmvpn-ca.sqlite3'},
		cert={
			lifetime=365 * 24 * 60 * 60,
			renewal=30 * 24 * 60 * 60,
			['hash-alg']='SHA256',
			key={type='EC', curve='secp384r1'}
		},
		ca={dn='O=DMVPN'},
		hub={
			dn='$ROOT,OU=Hubs,CN=$NAME',
			['default-name']='Hub-$ID',
			subnets={'10.0.0.0/8'}
		},
		spoke={
			dn='$ROOT,OU=$SITE,CN=$NAME',
			['default-name']='VPNc-$ID'
		},
		crl={},
		['password-length']=16,
		['default-asn']=65000
	}
)

for _, ct in ipairs{'ca', 'hub', 'spoke', 'crl'} do
	set_config_defaults(config[ct], config.cert)
end

if config.db['encrypt-keys'] == true then
	config.db['encrypt-keys'] = 'aes128'
end


now = os.time()

stat.umask(bit32.bor(stat.S_IRWXG, stat.S_IRWXO))


function connection()
	if not sql then
		sql = require('luasql.sqlite3').sqlite3()
		conn = sql:connect(config.db.file)
		conn:setautocommit(false)
	end
	return conn
end

function execute(statement)
	local res, err = connection():execute(statement)
	if res then return res end
	error(err)
end

function escape(s)
	if not s then return 'NULL' end
	return type(s) == 'string' and "'"..connection():escape(s).."'" or s
end

function value_list(values)
	local res = {}
	for k, v in pairs(values) do table.insert(res, k..' = '..escape(v)) end
	return res
end

function insert(tbl, values)
	local columns = {}
	local escaped = {}
	for k, v in pairs(values) do
		table.insert(columns, k)
		table.insert(escaped, escape(v))
	end
	execute(
		'INSERT INTO '..tbl..'('..table.concat(columns, ',')..
			') VALUES ('..table.concat(escaped, ',')..')'
	)
end

function where(filter)
	if not filter then return '' end
	if type(filter) == 'table' then
		filter = value_list(filter)
		if not next(filter) then return '' end
		filter = table.concat(filter, ' AND ')
	end
	return ' WHERE '..filter
end

function update(tbl, values, filter)
	execute(
		'UPDATE '..tbl..' SET '..
			table.concat(value_list(values), ', ')..where(filter)
	)
end

function delete(tbl, filter) execute('DELETE FROM '..tbl..where(filter)) end

function select_many(what, from, filter, mode, order)
	local cur = execute(
		'SELECT '..what..' FROM '..from..where(filter)..
			(order and ' ORDER BY '..order or '')
	)
	return function()
		local row = cur:fetch(mode and {}, mode)
		if row == nil then cur:close() end
		return row
	end
end

function select_certs(filter, order)
	return select_many('*', 'certificate', filter, 'a', order)
end

function select_one(...)
	local res
	for row in select_many(...) do
		assert(res == nil)
		res = row
	end
	return res
end

function exists(tbl, filter, active_only)
	if active_only then filter.active = '1' end
	return select_one('0', tbl, filter) and true or false
end

function next_key(tbl, key, filter)
	return (select_one('MAX('..key..')', tbl, filter) or 0) + 1
end

function select_cert(serial)
	return select_one('*', 'certificate', {serial=serial}, 'a')
end


function toint(s, min, max, desc)
	local i = tonumber(s)
	if i and i == math.floor(i) and i >= min and (not max or i <= max) then
		return i
	end
	if desc then error('Invalid '..desc..': '..s) end
end

function toid(s) return toint(s, 1, nil, 'ID') end


function detect_afi(s)
	x509an.new():add('IP', s)
	return s:find(':') and 2 or 1
end

function detect_prefix_afi(s)
	local function fail() error('Invalid network address: '..s) end

	local comps = stringy.split(s, '/')
	if #comps ~= 2 then fail() end

	local afi = detect_afi(comps[1])
	if not (afi and toint(comps[2], 0, ({32, 128})[afi])) then fail() end

	return afi
end


passwords = {}
function get_password(new, id)
	if not id then id = 'default' end
	if not passwords[id] then passwords[id] = dmvpn.get_password(new) end
	return passwords[id]
end

function decrypt_key(key)
	return pkey.new(key, 'PEM', 'private', get_password)
end

function load_cert(row)
	return x509.new(row.data, 'PEM'), decrypt_key(row.privateKey)
end

function load_ca_cert()
	if not ca_cert then ca_cert, ca_key = load_cert(select_cert(0)) end
	return ca_cert, ca_key
end

function encrypt_key(key, new_pw, pw_id)
	return key:getPrivateKey(
		config.db['encrypt-keys'] or nil,
		function() return get_password(new_pw, pw_id) end
	)
end

function sign(object, hash_alg, cert, key)
	if not cert then cert, key = load_ca_cert() end
	object:setIssuer(cert:getSubject())

	object:addExtension(
		x509ext.new(
			'authorityKeyIdentifier',
			'DER',
			rfc5280.AuthorityKeyIdentifier.encode{
				keyIdentifier=cert:getPublicKeyDigest()
			}
		)
	)

	return object:sign(key, hash_alg)
end

function issue_cert(attrs, func)
	local key = pkey.new(attrs.params.key)

	local ca = attrs.usage and attrs.usage.keyCertSign
	attrs.serial = ca and 0 or next_key('certificate', 'serial')

	local cert = x509.new()
	cert:setVersion(3)
	cert:setSerial(attrs.serial)
	cert:setPublicKey(key)
	
	local dn = x509n.new()
	for _, rdn in ipairs(stringy.split(attrs.dn, ',')) do
		local attr, value = string.match(rdn, '^%s*(%a+)=(.*)$')
		if not attr then error('Invalid RDN: '..rdn) end
		dn:add(attr, value)
	end
	cert:setSubject(dn)

	cert:setBasicConstraints{CA=attrs.usage}
	cert:setBasicConstraintsCritical(true)

	local issued = cert:getLifetime()
	local expires = issued + attrs.params.lifetime
	cert:setLifetime(issued, expires)

	attrs.issued = issued
	attrs.expires = expires
	attrs.privateKey = (ca or not attrs.usage) and encrypt_key(key, ca) or
		key:getPrivateKey()

	cert:addExtension(
		x509ext.new(
			'subjectKeyIdentifier',
			'DER',
			rfc5280.KeyIdentifier.encode(cert:getPublicKeyDigest())
		)
	)

	if attrs.usage then
		cert:addExtension(
			x509ext.new(
				'keyUsage',
				'DER',
				rfc5280.KeyUsage.encode(attrs.usage)
			)
		)
		attrs.usage = nil
	end

	local crl_dp = config.crl['dist-point']
	if crl_dp then
		cert:addExtension(
			x509ext.new(
				'crlDistributionPoints',
				'DER',
				rfc5280.CRLDistributionPoints.encode{
					{
						distributionPoint={
							fullName={{uniformResourceIdentifier=crl_dp}}
						}
					}
				}
			)
		)
	end

	if func then func(cert, attrs) end

	sign(cert, attrs.params['hash-alg'], ca and cert, ca and key)

	attrs.data = tostring(cert)
	attrs.dn = tostring(dn)
	attrs.params = nil
	attrs.sname = nil
	attrs.vname = nil
	insert('certificate', attrs)
	return attrs
end

function issue_ca_cert(usage)
	return issue_cert{dn=config.ca.dn, params=config.ca, usage=usage}
end

function export_cert(cert)
	local password = {}
	for i=1,config['password-length'] do
		local r = math.random(0, 61)
		if r > 35 then r = r + 61
		elseif r > 9 then r = r + 55
		else r = r + 48 end
		password[i] = r
	end
	password = string.char(table.unpack(password))

	local chain = x509ch.new()
	chain:add(load_ca_cert())

	if config.db['encrypt-keys'] then chain:add(load_crl_cert()) end

	chain:add(x509.new(cert.data, 'PEM'))

	local file = io.open(
		('%s_%s.%s.pfx'):format(cert.site, cert.vpnc, password), 'w'
	)
	file:write(
		tostring(
			pkcs12.new{
				key=decrypt_key(cert.privateKey),
				certs=chain,
				password=password
			}
		)
	)
	file:close()
end


function add_header(tbl, columns)
	if tbl[1] then table.insert(tbl, 1, columns) end
	return tbl
end


function format_cert_info(certs)
	local res = {}

	local function format_ts(timestamp) return
		os.date('%x %X', timestamp)
	end
	
	for _, cert in ipairs(certs) do
		local exp_info
		if cert.revoked then
			exp_info = 'Revoked '..format_ts(cert.revoked)
		else
			exp_info = 'Expire'..
				(cert.expires < now and 'd' or 's')..' '..
				format_ts(cert.expires)
		end
		table.insert(
			res,
			{cert.serial, cert.dn, format_ts(cert.issued), exp_info}
		)
	end

	return add_header(res, {'serial', 'dn', 'issued', 'validity'})
end

function print_cert(cert)
	print(x509.new(cert.data, 'PEM'):text{'ext_parse'})
end

function is_valid(cert, margin)
	return not cert.revoked and now < cert.expires - (margin or 0)
end

function revoke(filter)
	local revoked = {}
	for cert in select_certs(filter) do
		if is_valid(cert) then
			update(
				'certificate',
				{revoked=now},
				{serial=cert.serial}
			)
			cert.revoked = now
			table.insert(revoked, cert)
		end
	end
	if #revoked > 0 then delete('crl') end
	return revoked
end


function scan_next(desc)
	if #arg == 0 then
		if desc then error('Missing '..desc) end
		return
	end
	local res = arg[1]
	table.remove(arg, 1)
	return res
end

function scan_addr()
	local addr = scan_next('IP address')
	detect_afi(addr)
	return addr
end

function scan_subnet()
	local addr = scan_next('network address')
	detect_prefix_afi(addr)
	return addr
end

function scan_choice(choices, desc)
	local token = scan_next(desc)
	if not token then return end
	for k, v in pairs(choices) do if token == k then return v end end
	error('Invalid '..desc..': '..token)
end

function scan_param(choices, desc, optional)
	if type(choices) == 'string' then choices = {choices} end

	local k = scan_next(not optional and desc)
	if not k then return end
	for _, v in ipairs(choices) do
		if k == v then return k, scan_next(k..' specification') end
	end
	error('Invalid '..desc..': '..k)
end


function scan_site_code() return scan_next('site code'):upper() end

function validate_site(code, retired)
	code = code:upper()
	if exists('site', {code=code}, not retired) then return code end
	error('Invalid site code: '..code)
end

function scan_site(retired) return validate_site(scan_site_code(), retired) end

function scan_site_selector(options)
	options = options or {}
	local _, code = scan_param(
		options.param or 'site', 'selector', options.required == false
	)
	if code then return validate_site(code, options.retired) end
end


function scan_site_filter(options)
	options = options or {}
	options.required = false

	local code = scan_site_selector(options)
	if code then return {code=code} end
end

function site_filter(filter, column)
	if not filter then return end
	if not column then column = 'site' end
	return {[column]=filter.code}
end


function scan_vpnc(options)
	options = options or {}

	if options.multiple and arg[1] == 'hubs' then return {site=''} end

	local k, v = scan_param({'hub', 'site'}, 'selector', options.multiple)
	if not k then return end

	local res, obj

	if k == 'hub' then
		res = {site='', id=v}
		obj = 'hub'
	else
		local _, vpnc = scan_param(
			options.id_attr or 'vpnc', 'VPNc number', true
		)
		if not (vpnc or options.multiple) then vpnc = 1 end

		res = {site=validate_site(v, options.retired), id=vpnc}
		obj = 'VPNc'
	end

	if res.id and not exists('vpnc', res, not options.retired) then
		error('Invalid '..obj..' number: '..res.id)
	end

	return res
end

function vpnc_filter(filter, site_column, id_column)
	if not filter then return end
	if not site_column then site_column = 'site' end
	if not id_column then id_column = 'vpnc' end
	return {[site_column]=filter.site, [id_column]=filter.id}
end


function scan_finished()
	if #arg > 0 then error('Invalid parameter: '..scan_next()) end
end


function display_active(value) return ({['0']='no', ['1']='yes'})[value] end

function show(tbl, columns, filter, mangle)
	local output = {columns}

	for row in select_many(table.concat(columns, ','), tbl, filter, 'a') do
		if row.active then row.active = display_active(row.active) end
		if mangle then mangle(row) end

		local line = {}
		for _, column in ipairs(columns) do
			table.insert(line, row[column] or '')
		end
		table.insert(output, line)
	end

	return output
end

function show_site(filter)
	return show('site', {'code', 'name', 'asn', 'active'}, filter)
end

function show_vpnc(site, vpnc)
	local filter = site and site_filter(site, 'v.site') or "v.site > ''"
	if vpnc then filter['v.id'] = vpnc end

	local columns = {
		'v.id', 'v.name', 'v.active', 'a1.address', 'a2.address'
	}
	local titles = {'vpnc', 'name', 'active','ipv4Address', 'ipv6Address'}
	local i = 2
	if not site then
		table.insert(columns, 1, 'v.site')
		table.insert(titles, 1, 'site')
		i = 3
	elseif site.code == '' then titles[1] = 'hub' end

	local output = {}

	for row in select_many(
		table.concat(columns, ', '),
		[[
			vpnc v
				LEFT JOIN greAddress a1
					ON v.site = a1.site AND
						v.id = a1.vpnc AND a1.family = 1
				LEFT JOIN greAddress a2
					ON v.site = a2.site AND
						v.id = a2.vpnc AND a2.family = 2
		]],
		filter,
		'n'
	) do
		if not row[i] then row[i] = '' end
		row[i + 1] = display_active(row[i + 1])
		table.insert(output, row)
	end

	return add_header(output, titles)
end


function add_subnet(site, addr)
	insert(
		'subnet',
		{site=site, family=detect_prefix_afi(addr), address=addr}
	)
end


function retire_vpnc(site, id)
	local filter = {site=site, id=id}
	local vf = vpnc_filter(filter)
	local revoked = revoke(vf)
	delete('greAddress', vf)
	update('vpnc', {active='0'}, filter)
	return format_cert_info(revoked)
end


function add_site(attrs)
	if not attrs.asn then attrs.asn = config['default-asn'] end
	insert('site', attrs)
end


function get_cert_by_serial(serial)
	scan_finished()

	serial = toint(serial, 0, nil, 'serial number')
	local cert = select_cert(serial)

	if not cert then error('Invalid serial number') end
	return cert
end

function get_certs()
	if arg[1] == 'serial' then
		local _, serial = scan_param('serial', 'serial number')
		return function(cert, v)
			return not v and cert or nil
		end, get_cert_by_serial(serial)
	end

	if arg[1] == 'crl' then
		return select_certs('site IS NULL AND serial > 0')
	end

	return select_certs(
		vpnc_filter(scan_vpnc{multiple=true, retired=true}) or
			'serial > 0'
	)
end


function try_load_crl_cert()
	if not config.db['encrypt-keys'] then
		return table.unpack{load_ca_cert()}
	end

	if not crl_cert then
		for row in select_certs(
			'site IS NULL and serial > 0', 'serial DESC'
		) do
			if not crl_cert then
				local cert, key = load_cert(row)
				if is_valid(row) then
					crl_cert = cert
					crl_key = key
				end
			end
		end
	end
	return crl_cert, crl_key
end

function load_crl_cert()
	local cert, key = try_load_crl_cert()
	if not cert then error('No valid CRL signing key found') end
	return cert, key
end

function generate_crl()
	local crl = x509crl.new()
	crl:setVersion(2)

	local old_serial = select_one('serial', 'crl')
	local new_serial = (old_serial or 0) + 1

	crl:addExtension(
		x509ext.new(
			'crlNumber', 'DER', rfc5280.CRLNumber.encode(new_serial)
		)
	)

	local timestamp = crl:getLastUpdate()
	local expires = timestamp + config.crl.lifetime
	crl:setNextUpdate(expires)

	for cert in select_certs() do
		if cert.expires > timestamp and cert.revoked then
			crl:add(cert.serial, cert.revoked)
		end
	end

	sign(crl, config.crl['hash-alg'], table.unpack{load_crl_cert()})

	insert('crl', {serial=new_serial, expires=expires, data=tostring(crl)})
	if old_serial then delete('crl', {serial=old_serial}) end

	return crl
end

function get_crl()
	local row = select_one('expires, data', 'crl', nil, 'n')
	return row and now < row[1] - config.crl.renewal and x509crl.new(row[2])
		or generate_crl()
end


function print_table(tbl)
	local colwidth = {}
	for _, row in ipairs(tbl) do
		for i, col in ipairs(row) do
			colwidth[i] = math.max(
				colwidth[i] or 0, string.len(col)
			)
		end
	end
	for _, row in ipairs(tbl) do
		for i = 1,#row do
			if i > 1 then io.write('  ') end
			io.write(row[i])
			if i < #row then
				for _ = 1,colwidth[i] - string.len(row[i]) do
					io.write(' ')
				end
			end
		end
		io.write('\n')
	end
end

function simple_confirm(func)
	if not unistd.isatty(0) then return end
	func()
	io.write('\nContinue (y/n)? ')
	io.stdout:flush()
	local input = io.read()
	if not input or input:lower() ~= 'y' then os.exit() end
	io.write('\n')
end

function confirm(object, action, tbl)
	if #tbl == 0 then return end
	simple_confirm(
		function()
			io.write('The following '..object..' will be '..action..':\n\n')
			print_table(tbl)
		end
	)
end


output = scan_choice(
	scan_choice(
		{
			site={
				add=function()
					local site = scan_site_code()
					scan_finished()

					add_site{code=site}
				end,
				set=function()
					local k, v = scan_param(
						{'asn', 'name'}, 'attribute'
					)
					local site = scan_site()
					scan_finished()

					if k == 'asn' then
						v = toint(
							v,
							0,
							math.pow(2, 16) - 1,
							'AS number'
						)
					end

					update('site', {[k]=v}, {code=site})
				end,
				unset=function()
					local k = scan_choice(
						{name='name'}, 'attribute'
					)
					local site = scan_site()
					scan_finished()

					update('site', {[k]=false}, {code=site})
				end,
				show=function()
					local site = scan_site_filter{
						param='code', retired=true
					}
					scan_finished()

					return show_site(site or "code > ''")
				end,
				retire=function()
					local site = scan_site()
					scan_finished()
					confirm(
						'site',
						'retired',
						show_site{code=site}
					)

					delete('subnet', {site=site})
					local output = retire_vpnc(site)
					update(
						'site',
						{active='0'},
						{code=site}
					)
					return output
				end
			},
			subnet={
				add=function()
					local addr = scan_subnet()
					local site = scan_site_selector()
					scan_finished()

					add_subnet(site, addr)
				end,
				show=function()
					local site = scan_site_filter()
					scan_finished()

					return show(
						'subnet',
						{'site', 'address'},
						site and site_filter(site) or
							"site > ''"
					)
				end,
				del=function()
					local addr = scan_subnet()
					local filter = site_filter(
						scan_site_filter()
					)
					scan_finished()

					if not filter then filter = {} end
					filter.address = addr

					if not exists('subnet', filter) then
						error(
							'Address does not exist: '..
								addr
						)
					end

					delete('subnet', filter)
				end
			},
			vpnc={
				create=function()
					local site = scan_site_selector()
					local _, id = scan_param(
						'id', 'VPNc ID', true
					)
					scan_finished()

					insert(
						'vpnc',
						{
							site=site,
							id=id and toid(id) or
								next_key(
									'vpnc',
									'id',
									{site=site}
								)
						}
					)
				end,
				set=function()
					local _, name = scan_param(
						'name', 'attribute'
					)
					local vpnc = scan_vpnc{id_attr='id'}
					scan_finished()

					update('vpnc', {name=name}, vpnc)
				end,
				unset=function()
					scan_choice({name=true}, 'attribute')
					local vpnc = scan_vpnc{id_attr='id'}
					scan_finished()

					update('vpnc', {name=false}, vpnc)
				end,
				show=function()
					local site = scan_site_filter{
						retired=true
					}
					scan_finished()

					return show_vpnc(site)
				end,
				retire=function()
					local id = toid(
						scan_next('VPNc number')
					)
					local site = scan_site_selector()
					scan_finished()
					confirm(
						'VPNc',
						'retired',
						show_vpnc({code=site}, id)
					)

					return retire_vpnc(site, id)
				end
			},
			['gre-addr']={
				add=function()
					local addr = scan_addr()
					local vpnc = scan_vpnc()
					scan_finished()

					insert(
						'greAddress',
						{
							site=vpnc.site,
							vpnc=vpnc.id,
							family=detect_afi(addr),
							address=addr
						}
					)
				end,
				del=function()
					local addr = scan_addr()
					scan_finished()

					delete('greAddress', {address=addr})
				end
			},
			hub={
				create=function()
					local id = scan_next()
					scan_finished()

					insert(
						'vpnc',
						{
							site='',
							id=id and toid(id) or
								next_key(
									'vpnc',
									'id',
									{site=''}
								)
						}
					)
				end,
				set=function()
					local _, name = scan_param(
						'name', 'attribute'
					)
					local id = toid(scan_next('hub number'))
					scan_finished()

					update(
						'vpnc',
						{name=name},
						{site='', id=id}
					)
				end,
				unset=function()
					scan_choice({name=true}, 'attribute')
					local id = toid(scan_next('hub number'))
					scan_finished()

					update(
						'vpnc',
						{name=false},
						{site='', id=id}
					)
				end,
				show=function()
					scan_finished()

					return show_vpnc{code=''}
				end,
				retire=function()
					local id = toid(scan_next('hub number'))
					scan_finished()
					confirm(
						'hub',
						'retired',
						show_vpnc({code=''}, id)
					)

					return retire_vpnc('', id)
				end
			},
			['root-cert']={
				generate=function()
					scan_finished()

					if stat.stat(config.db.file) then
						simple_confirm(
							function()
								io.write('Current configuration and all keys will be lost.\n')
							end
						)
					end

					os.remove(config.db.file)
					for _, statement in ipairs(
						{
							[[
								CREATE TABLE site (
									code VARCHAR(16) NOT NULL PRIMARY KEY,
									asn INTEGER NOT NULL,
									name VARCHAR(32),
									active CHAR(1) DEFAULT '1'
								)
							]],
							[[
								CREATE TABLE subnet (
									site VARCHAR(16) NOT NULL REFERENCES site(code),
									family INTEGER NOT NULL,
									address CHAR(14) NOT NULL,
									PRIMARY KEY(site, family, address)
								)
							]],
							[[
								CREATE TABLE vpnc (
									site VARCHAR(16) NOT NULL REFERENCES site(code),
									id INTEGER NOT NULL,
									name VARCHAR(32),
									active CHAR(1) DEFAULT '1',
									PRIMARY KEY(site, id)
								)
							]],
							[[
								CREATE TABLE greAddress (
									site VARCHAR(16) NOT NULL,
									vpnc INTEGER NOT NULL,
									family INTEGER NOT NULL,
									address CHAR(14) NOT NULL,
									PRIMARY KEY(site, vpnc, family),
									FOREIGN KEY(site, vpnc) REFERENCES vpnc(site, id),
									UNIQUE(family, address)
								)
							]],
							[[
								CREATE TABLE certificate (
									serial INTEGER NOT NULL PRIMARY KEY,
									site VARCHAR(16),
									vpnc INTEGER,
									dn VARCHAR(128) NOT NULL,
									issued DATETIME NOT NULL,
									expires DATETIME NOT NULL,
									revoked DATETIME,
									privateKey TEXT NOT NULL,
									data TEXT NOT NULL,
									FOREIGN KEY(site, vpnc) REFERENCES vpnc(site, id)
								)
							]],
							[[
								CREATE TABLE crl (
									serial INTEGER NOT NULL PRIMARY KEY,
									expires DATETIME NOT NULL,
									data TEXT NOT NULL
								)
							]]
						}
					) do execute(statement) end

					add_site{code=''}
					for _, subnet in ipairs(
						config.hub.subnets
					) do
						add_subnet('', subnet)
					end

					issue_ca_cert{
						keyCertSign=true,
						cRLSign=not config.db['encrypt-keys']
					}
				end,
				show=function()
					print_cert(get_cert_by_serial(0))
				end
			},
			cert={
				generate=function()
					local crl = arg[1] == 'crl'
					local vpnc = not crl and vpnc_filter(
						scan_vpnc{multiple=true},
						's.code',
						'v.id'
					)

					local dns = {}
					local gen_crl_cert
					if not vpnc then
						gen_crl_cert = not try_load_crl_cert()
						if gen_crl_cert then
							table.insert(
								dns,
								{config.ca.dn}
							)
						end
					end

					local subjects = {}

					if not crl then
						local filter = vpnc or {}
						filter['s.active'] = '1'
						filter['v.active'] = '1'

						for row in select_many(
							's.code, v.id, s.asn, s.name, v.name',
							'site s INNER JOIN vpnc v ON s.code = v.site',
							filter,
							'n'
						) do
							local attrs = {
								site=row[1],
								vpnc=row[2],
								asn=row[3],
								sname=row[4],
								vname=row[5]
							}

							attrs.params = config[
								attrs.site == '' and
									'hub' or
									'spoke'
							]

							local function insert()
								attrs.dn = attrs.params.dn:gsub(
									'%$(%u+)',
									{
										ROOT=config.ca.dn,
										SITE=attrs.sname or
											attrs.site,
										NAME=attrs.vname or
											attrs.params[
												'default-name'
											]:gsub(
												'$ID',
												attrs.vpnc
											)
									}
								)

								table.insert(
									subjects,
									attrs
								)
								table.insert(
									dns,
									{tostring(attrs.dn)}
								)
							end

							if vpnc then insert()

							else
								local valid
								for cert in select_certs{
									site=row[1],
									vpnc=row[2]
								} do
									if is_valid(
										cert,
										attrs.params.renewal
									) then
										valid = true
									end
								end
								if not valid then
									insert()
								end
							end
						end
					end

					add_header(dns, {'dn'})
					confirm('certificates', 'issued', dns)

					local issued = {}

					if gen_crl_cert then
						table.insert(
							issued,
							issue_ca_cert{
								cRLSign=true
							}
						)
					end

					for _, attrs in ipairs(subjects) do
						local asn = attrs.asn
						attrs.asn = nil

						local cert = issue_cert(
							attrs,
							function(cert, attrs)

								local function get_subnets()
									return select_many(
										'family, address',
										'subnet',
										{site=attrs.site},
										'a'
									)
								end

								local function get_gre_addrs()
									return select_many(
										'family, address',
										'greAddress',
										{site=attrs.site, vpnc=attrs.vpnc},
										'a'
									)
								end

								cert:addExtension(
									x509ext.new(
										dmvpn.OID_IS_HUB,
										'critical,DER',
										asn1.boolean.encode(attrs.site == '')
									)
								)

								local hosts = config.hub.hosts
								if hosts then
									cert:addExtension(
										x509ext.new(
											dmvpn.OID_HUB_HOSTS,
											'DER',
											asn1.sequence_of(asn1.ia5string).encode(hosts)
										)
									)
								end

								local net_config = {}
								local pr_config = {}
								for subnet in get_subnets() do
									local f = subnet.family
									if not pr_config[f] then
										pr_config[f] = {}
										table.insert(
											net_config,
											{
												addressFamily={afi=f},
												ipAddressChoice={
													addressesOrRanges=pr_config[f]
												}
											}
										)
									end
									table.insert(
										pr_config[f],
										{addressPrefix=subnet.address}
									)
								end
								if net_config[1] then
									cert:addExtension(
										x509ext.new(
											'sbgp-ipAddrBlock',
											'critical,DER',
											rfc3779.IPAddrBlocks.encode(net_config)
										)
									)
								end

								local san
								for ga in get_gre_addrs() do
									if not san then
										san = x509an.new()
									end
									san:add(
										'IP',
										ga.address
									)
								end
								if san then
									cert:setSubjectAlt(san)
								end

								cert:addExtension(
									x509ext.new(
										'sbgp-autonomousSysNum',
										'critical,DER',
										rfc3779.ASIdentifiers.encode{
											asnum={asIdsOrRanges={{id=asn}}}
										}
									)
								)
							end
						)
						export_cert(cert)
						table.insert(issued, cert)
					end

					return format_cert_info(issued)
				end,
				list=function()
					local certs = {}
					for cert in get_certs() do
						table.insert(certs, cert)
					end
					return format_cert_info(certs)
				end,
				show=function()
					for cert in get_certs() do
						print_cert(cert)
					end
				end,
				revoke=function()
					local certs = {}
					for cert, selector in get_certs() do
						local valid = is_valid(cert)
						if selector == 'serial' and
							not valid then

							error('Certificate already expired or revoked')
						end
						if valid then
							table.insert(
								certs, cert
							)
						end
					end
					confirm(
						'certificates',
						'revoked',
						format_cert_info(certs)
					)

					local revoked = {}
					for _, cert in ipairs(certs) do
						table.insert(
							revoked,
							revoke{serial=cert.serial}[1]
						)
					end
					return format_cert_info(revoked)
				end,
				export=function()
					local _, serial = scan_param(
						'serial', 'selector'
					)
					local cert = get_cert_by_serial(serial)
					if not cert.site then
						error('Cannot export CA certificate')
					end
					export_cert(cert)
				end
			},
			crl={
				generate=function()
					scan_finished()
					io.write(tostring(generate_crl()))
				end,
				show=function()
					scan_finished()
					io.write(get_crl():text())
				end,
				export=function()
					scan_finished()
					io.write(tostring(get_crl()))
				end
			},
			password={
				set=function()
					for row in select_many(
						'serial, privateKey',
						'certificate',
						nil,
						'n'
					) do
						update(
							'certificate',
							{privateKey=encrypt_key(decrypt_key(row[2]), true, 'new')},
							{serial=row[1]}
						)
					end
				end
			}
		},
		'object type'
	),
	'action'
)()

if output then print_table(output) end

if sql then
	conn:commit()
	conn:close()
	sql:close()
end
