commit
cb2306889f
10 changed files with 489 additions and 0 deletions
@ -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 |
||||
|
@ -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 |
@ -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 |
@ -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. |
@ -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)}}' |
@ -0,0 +1,6 @@
|
||||
--- |
||||
# vim: set et sw=2 ts=2 sts=2: |
||||
- hosts: localhost |
||||
remote_user: root |
||||
roles: |
||||
- ssh-ca |
Loading…
Reference in new issue