init
This commit is contained in:
commit
cb2306889f
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
61
README.adoc
Normal 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
2
defaults/main.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
# defaults file for ssh-ca
|
328
files/ssh-ca.rb
Executable file
328
files/ssh-ca.rb
Executable 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
2
handlers/main.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
# handlers file for ssh-ca
|
32
meta/main.yml
Normal file
32
meta/main.yml
Normal 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
27
tasks/main.yml
Normal 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
2
tests/inventory
Normal file
|
@ -0,0 +1,2 @@
|
|||
localhost
|
||||
|
6
tests/test.yml
Normal file
6
tests/test.yml
Normal 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
5
vars/main.yml
Normal 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
|
Loading…
Reference in a new issue