This commit is contained in:
Denis Knauf 2020-09-18 22:05:44 +02:00
commit cb2306889f
10 changed files with 489 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# ---> Vim
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
# ---> Ansible
*.retry

61
README.adoc Normal file
View file

@ -0,0 +1,61 @@
Role Name
=========
A brief description of the role goes here.
Requirements
------------
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
Role Variables
--------------
ssh_ca_name::
Name for your CA - will be stored as comment.
If CA already exists, it will not be changed.
Mandatory.
ssh_ca_user::
User for CA.
Must match with `ssh_cert`-role.
Default: `sshca`
ssh_ca_home::
Default: `/var/lib/sshca`
ssh_ca_base_dir::
Where to store the certs and CA.
Must match with `ssh_cert`-role.
Default: `~/.ssh-ca`
**Do not change!**
ssh_ca_force_regeneration::
Forces to regenerate the CA.
*The old will be deleted!**
Dependencies
------------
Use ssh-cert to use ssh-ca-server for re-/newal hosts and users certificates.
Example Playbook
----------------
.example playbook
----
- name: SSH-CA
hosts: ssh_ca_server
roles:
- role: ssh-ca
----
License
-------
AGPLv3
Author Information
------------------
Denis Knauf - https://git.denkn.at/deac/ansible-role-ssh-cert

2
defaults/main.yml Normal file
View file

@ -0,0 +1,2 @@
---
# defaults file for ssh-ca

328
files/ssh-ca.rb Executable file
View file

@ -0,0 +1,328 @@
#!/usr/bin/env ruby
require 'time'
require 'pathname'
require 'shellwords'
require 'getoptlong'
require 'active_support/time'
require 'json'
def help msg=nil
STDERR.puts msg if msg
STDERR.puts <<-EOHELP.gsub( /^\s*:/, '')
:Usage: ssh .... renew [force] [show]
: ssh .... show
: ssh .... help
: ssh .... jsonl
:
:[h]elp
:[r]enew Renews (if expires in graceperiod) or creates (if not existing) certificate
: [f]orce forces renewal, also if valid for graceperiod
:[s]how Shows current certificate
:jsonl Expect commands via STDIN as JSONL and responses via STDOUT in JSONL
: Request format: [0, CMD, ARGS...]
: Response format: [SCODE, BODY]
: SCODEs are like HTTP.
:
:possible CMD:
:<renew[, {<force: true|false>}]> => <RENEWED, DATA, CONTENT>
:<get> => <DATA, CONTENT>
:
: RENEWED indicates if file was renewed (true|false).
: CONTENT is the content of the certfile (String).
: DATA is the parsed file (ssh-keygen -L) (Hash).
EOHELP
exit 1
end
class Die <Exception
end
module Kernel
def popen *a, **o, &e
a = a.flatten.compact.map &:to_s
IO.popen a, **o, &e
end
def die str
raise Die, str
end
end
module FileHelper
attr_reader :file
def exist?
@file.file?
end
def read
@file.read
end
def open *a, **o, &e
if block_given?
@file.open *a, **o, &e
else
@file.open *a, **o
end
end
def to_s
@file.to_s
end
end
class PublicKeyFile
include FileHelper
attr_reader :type, :ident
def initialize file, type, ident
@file, @type, @ident = file, type, ident
end
def prepare authkeysfile
return true if exist?
authkeysfile.each_line do |l|
case l.chomp
when /\A\s*#/ # ignore - comment
when /\A\s*$/ # ignore - empty line
when /\Assh-/ # ignore - blank keys without command
when / (ssh-(?:rsa|ed25519) +[^ ]+ +#{Regexp.quote @ident}) *\z/
pub = $1
@file.open( 'a+') {|f| f.puts pub }
end
end
exist?
end
end
class CertifcateFile
include FileHelper
attr_reader :metadata
def initialize file
@file = file
end
def parse
r = {}
popen %w[ssh-keygen -L -f], @file do |f|
mode = nil
f.each_line do |l|
case l.chomp
when /^ Valid: from ([^ ][^ ]*) to ([^ ][^ ]*)$/
r[:valid] = Time.parse( $1) ... Time.parse( $2)
when /^ Type: ([^ ]+) ([^ ]+) certificate$/
r[:type] = [$1, $2]
when /^ Public key: ([^ ]+) (.*)$/
r[:pubkey]= [$1, $2]
when /^ Signing CA: ([^ ]+) (.*)$/
r[:signca]= [$1, $2]
when /^ Key ID: (.*)$/
r[:keyid] = $1.sub /\A"?(.*?)"?\z/, '\1'
when /^ Serial: (\d+)$/
r[:serial]= $1.to_i
when /^ Principals: \(node\)$/
r[:principals] = []
when /^ Principals:\s*$/
mode = :principals
r[:principals] = []
when /^ Extensions: \(none\)$/
r[:extensions] = []
when /^ Extensions:\s*$/
mode = :extensions
r[:extensions] = []
when /^ Critical Options: \(none\)$/
r[:critopts] = []
when /^ Critical Options:\s*$/
mode = :critopts
r[:critopts] = []
when /^ ([^ ].*)$/
r[mode].push $1
else
end
end
end
@metadata = r
end
def [] key
(@metadata || parse)[key]
end
alias to_hash []
def valid_at? ts
exist? and self[:valid].include?( ts)
end
def create pubfile, type:, valid:, serial:, cafile:, info:, principals:, sshcadir:
#sign_user_pub opts
err =
popen( %w[ssh-keygen -V], "#{valid.begin.strftime '%Y%m%d'}:#{valid.end.strftime '%Y%m%d'}",
(:host == type ? '-h' : nil), '-s', cafile,
'-P', '', '-z', serial, '-I', info, '-n', principals.join(','), pubfile,
err: %i[child out]
) {|l| l.read }
die "Creating certificate failed [#{$?.inspect}]: #{err}" unless 0 == $?.exitstatus
serialkeyfile = sshcadir + 'serials' + serial.to_s
serialkeyfile.open( 'a+') {|f| f.write self.read }
self
end
end
class SerialFile
include FileHelper
def initialize file
@file = file
end
def read
open 'a+' do |fh|
fh.read.to_i
end
end
alias to_i read
alias get read
def increment!
open 'a+' do |fh|
(1+fh.read.to_i).tap do |val|
fh.rewind
fh.truncate 0
fh.print val
end
end
end
alias inc! increment!
end
#################################################
begin
die "2..4 arguments (type ident [principals [info]]) expected. Provided count of arguments: #{ARGV.length}" unless (2..4) === ARGV.length
type, ident, principals, info = ARGV[0..3]
expire_in, gracetime = 12.weeks, 1.week
type = type.to_sym
case type
when :host
die "Invalid ident: #{ident}" unless ident =~ /\A[a-z][a-z_0-9]*\z/i
expire_in, gracetime = 360.days, 30.days
info ||= "host: #{ident}"
principals =
([ident] +
principals.split( ',').map do |e|
if /:/ =~ e or /\A[0-9.]+\z/ =~ e or /\A[^.]+\z/ =~ e
e
else
e = e.gsub /\.?\z/, ''
[e, "#{e}."]
end
end
).flatten.compact.map do |e|
if /\A\[/ =~ e
e
else
[e, "[#{e}]:19"]
end
end.flatten.compact.uniq
when :user
die "Invalid ident: #{ident}" unless ident =~ /\A[a-z][a-z_0-9]*@[a-z][a-z_0-9]*\z/i
info ||= "user: #{ident}"
principals = principals.split ','
else
die "Invalid type: #{type}"
end
sshcadir = Pathname.new( "~/.ssh-ca").expand_path
pubfile = PublicKeyFile.new sshcadir + "#{type}-#{ident}.pub", type, ident
certfile = CertifcateFile.new sshcadir + "#{type}-#{ident}-cert.pub"
cafile = sshcadir + "ca"
capubfile = sshcadir + "ca.pub"
serialfile = SerialFile.new sshcadir + "serial"
authkeysfile = Pathname.new( "~/.ssh/authorized_keys").expand_path
help unless ENV['SSH_ORIGINAL_COMMAND']
die "Unallowed char in arguments [\\0]" if ENV['SSH_ORIGINAL_COMMAND'].include? 0.chr
ARGV.replace ENV['SSH_ORIGINAL_COMMAND'].shellsplit
case ARGV[0]
when *%w[h help]
help
when *%w[s show]
die "Unexpected addition parameters: #{ARGV.shelljoin}" unless 1 == ARGV.length
die "No certfile, yet: #{certfile.basename}" unless certfile.exist?
puts certfile.read
when *%w[r renew]
begin
unexpected = ARGV[1..-1] - %w[s show f force]
die "Unknown argument: #{unexpected.shelljoin}" unless unexpected.empty?
end
show = ! (ARGV[1..-1] & %w[s show]).empty?
force = ! (ARGV[1..-1] & %w[f force]).empty?
pubfile.prepare authkeysfile
die "Pubfile or pub in authorized_keys for #{ident} not found." unless pubfile.exist?
if !force && certfile.exist? && certfile.valid_at?( gracetime.from_now) && certfile[:principals].sort == principals.sort
STDERR.print "Certificate [#{certfile[:serial]}|#{certfile[:keyid]}] valid between #{certfile[:valid]} as #{certfile[:principals].sort.join ', '}\n"
STDERR.flush
STDOUT.print "#{certfile.read.chomp}\n" if show
exit 0
end
STDERR.puts "Renew certificate for #{ident} as #{principals.sort.join ', '}"
certfile.create pubfile, type: type, valid: -5.minutes.ago..expire_in.from_now,
serial: serialfile.increment!, cafile: cafile, info: info, principals: principals, sshcadir: sshcadir
puts certfile.read if show
when 'jsonl'
STDIN.each_line do |line|
begin
line = JSON.parse line, symbolize_names: true
cmd, args = line[0].to_sym, line[1..-1]
case cmd
when :renew
die "renew expects no or a Hash as args." unless (0..1).include?( args.length) && args.kind_of?( Hash)
args ||= {}
args[:force] = ! ! args[:force]
pubfile.prepare
die "Pubfile or pub in authorized_keys for #{ident} not found." unless pubfile.exist?
if !args[:force] && certfile.valid_at?( gracetime.from_now)
STDOUT.puts [200, false, certfile.metadata, certfile.read].to_json
end
certfile.create pubfile, type: type, sshcadir: sshcadir,
valid: -5.minutes.ago..expire_in.from_now, serial: serialfile.increment!,
cafile: cafile, info: info, principals: principals
STDOUT.puts [200, true, certfile.metadata, certfile.read].to_json
when :get
STDOUT.puts [200, certfile.metadata, certfile.read].to_json
else
die "Unknown command."
end
rescue Object
STDOUT.puts [500, $!.class.name, $!.to_s].to_json
end
end
else
help "Unknown command: #{ARGV[0]}"
end
rescue SystemExit
raise
rescue Die
STDERR.puts $!
exit 1
rescue Object
STDERR.puts "#$! (#{$!.class})", *$!.backtrace
exit 2
end

2
handlers/main.yml Normal file
View file

@ -0,0 +1,2 @@
---
# handlers file for ssh-ca

32
meta/main.yml Normal file
View file

@ -0,0 +1,32 @@
galaxy_info:
author: Denis Knauf
description: Provides a SSH-CA renewal server
# issue_tracker_url: http://example.com/issue/tracker
license: AGPL-3.0-or-later
min_ansible_version: 2.9
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
platforms:
- name: Debian
versions:
- 10
- name: Ubuntu
versions:
- 18.04
- 20.04
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

27
tasks/main.yml Normal file
View file

@ -0,0 +1,27 @@
---
# vim: set expandtab tabstop=2 shiftwidth=2:
- name: create sshca-user
user:
name: '{{ssh_ca_user}}'
comment: SSH-CA
shell: /bin/sh
createhome: yes
home: '{{ssh_ca_home}}'
move_home: no
skeleton: no
- name: install ssh-ca
copy:
src: ssh-ca.rb
dest: '{{ssh_ca_home}}/ssh-ca'
- name: base-dir
file:
path: '{{ssh_ca_base_dir}}'
owner: '{{ssh_ca_user}}'
mode: 0700
- name: CA
openssh_keypair:
path: '{{ssh_ca_base_dir}}/ca'
type: ed25519
owner: '{{ssh_ca_user}}'
comment: '{{ssh_ca_name|mandatory}}'
force: '{{ssh_ca_force_regeneration|default(false)}}'

2
tests/inventory Normal file
View file

@ -0,0 +1,2 @@
localhost

6
tests/test.yml Normal file
View file

@ -0,0 +1,6 @@
---
# vim: set et sw=2 ts=2 sts=2:
- hosts: localhost
remote_user: root
roles:
- ssh-ca

5
vars/main.yml Normal file
View file

@ -0,0 +1,5 @@
---
# vim: set et sw=2 ts=2 sws=2:
ssh_ca_user: sshca
ssh_ca_home: /var/lib/sshca
ssh_ca_base_dir: ~/.ssh-ca