init
This commit is contained in:
commit
e5a7448c5f
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
*~
|
||||||
|
.*\.sw[opn]
|
||||||
|
|
||||||
|
ca
|
||||||
|
id_*
|
||||||
|
*.pub
|
45
Makefile
Normal file
45
Makefile
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
USER := sshca
|
||||||
|
HOME := /srv/sshca
|
||||||
|
BINDIR := $(HOME)/bin
|
||||||
|
DATADIR := $(HOME)/.local/ssh-ca
|
||||||
|
PKIDIR := $(DATADIR)/pubs
|
||||||
|
CONFDIR := $(HOME)/.config/ssh-ca
|
||||||
|
CONFFILE := $(CONFDIR)/ssh-ca.conf
|
||||||
|
|
||||||
|
all: ssh-ca.conf help
|
||||||
|
help:
|
||||||
|
@echo "Depends on ruby"
|
||||||
|
@echo "Run `make install` for installation."
|
||||||
|
@echo "If you want to define some default, delete ssh-ca.conf and run `make USER=sshca HOME=/srv/sshca`"
|
||||||
|
@echo "You have to install the user manually:"
|
||||||
|
@echo " useradd -H /srv/sshca sshca"
|
||||||
|
@echo "And needed gems:"
|
||||||
|
@echo " gem install activesupport"
|
||||||
|
|
||||||
|
install: $(BINDIR)/ssh-ca $(CONFFILE) $(PKIDIR) $(HOME)/.ssh/authorized_keys $(DATADIR)/serial
|
||||||
|
|
||||||
|
$(HOME):
|
||||||
|
useradd --system --no-user-group --shell /bin/sh --create-home --home-dir /srv/sshca sshca
|
||||||
|
|
||||||
|
$(BINDIR): $(HOME)
|
||||||
|
install -o $(USER) -m 0755 -d $@
|
||||||
|
|
||||||
|
$(BINDIR)/ssh-ca: ssh-ca $(BINDIR)
|
||||||
|
install -o $(USER) -m 0755 $^ $@
|
||||||
|
|
||||||
|
$(CONFFILE): ssh-ca.conf $(CONFDIR)
|
||||||
|
if ! test -f $@; then install -o $(USER) -m 0600 $^ $@; fi
|
||||||
|
|
||||||
|
$(CONFDIR) $(DATADIR) $(HOME)/.ssh: $(HOME)
|
||||||
|
install -o $(USER) -m 0700 -d $@
|
||||||
|
|
||||||
|
$(HOME)/.ssh/authorized_keys: $(HOME)/.ssh
|
||||||
|
umask 0177; touch $@; chmod 0600 $@; chown $(USER) $@
|
||||||
|
|
||||||
|
$(DATADIR)/serial: $(DATADIR)
|
||||||
|
if ! test -f $@; then echo '0' > $@; chown $(USER) $@; chmod 0600 $@; fi
|
||||||
|
|
||||||
|
$(DATADIR)/ca: $(DATADIR)
|
||||||
|
if ! test -f $@; then ssh-keygen -t ed25519 -C "CA" -N '' -f $@; chown $(USER) $@ $@.pub; chmod 0400 $@ $@.pub; fi
|
||||||
|
|
||||||
|
.PHONY: all help install
|
42
README.adoc
Normal file
42
README.adoc
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
Manual installation
|
||||||
|
===================
|
||||||
|
|
||||||
|
* Create user sshca:
|
||||||
|
+
|
||||||
|
----
|
||||||
|
useradd --system --no-user-group --shell /bin/sh --create-home --home-dir /srv/sshca sshca
|
||||||
|
----
|
||||||
|
|
||||||
|
* Create directories:
|
||||||
|
+
|
||||||
|
----
|
||||||
|
install -o sshca -m 0700 -d ~sshca/bin ~sshca/.local ~sshca/.local/sshca ~sshca/.local/sshca/pubs
|
||||||
|
----
|
||||||
|
|
||||||
|
* Copy `ssh-ca` script:
|
||||||
|
+
|
||||||
|
----
|
||||||
|
install -o sshca -m 0700 -t ~sshca/bin ssh-ca
|
||||||
|
----
|
||||||
|
|
||||||
|
* Create `authorized_keys`:
|
||||||
|
+
|
||||||
|
----
|
||||||
|
touch emptyfile
|
||||||
|
install -o sshca -m 0700 emptyfile ~sshca/.ssh/authorized_keys
|
||||||
|
rm emptyfile
|
||||||
|
----
|
||||||
|
|
||||||
|
* Create serial-file:
|
||||||
|
+
|
||||||
|
----
|
||||||
|
echo 0 > serial
|
||||||
|
install -o sshca -m 0600 serial ~sshca/.local/sshca
|
||||||
|
rm serial
|
||||||
|
----
|
||||||
|
|
||||||
|
* Create CA (no password):
|
||||||
|
+
|
||||||
|
----
|
||||||
|
ssh-keygen -t ed25519 -C "CA" -N '' -f ~sshca/.local/sshca/ca
|
||||||
|
----
|
616
ssh-ca
Executable file
616
ssh-ca
Executable file
|
@ -0,0 +1,616 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require 'time'
|
||||||
|
require 'pathname'
|
||||||
|
require 'shellwords'
|
||||||
|
require 'getoptlong'
|
||||||
|
require 'active_support/time'
|
||||||
|
require 'json'
|
||||||
|
require 'fcntl'
|
||||||
|
|
||||||
|
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 to_s() @file.to_s end
|
||||||
|
def basename() @file.basename end
|
||||||
|
def dirname() @file.dirname end
|
||||||
|
alias :to_path :file
|
||||||
|
|
||||||
|
def open *a, **o, &e
|
||||||
|
if block_given?
|
||||||
|
@file.open *a, **o, &e
|
||||||
|
else
|
||||||
|
@file.open *a, **o
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class PublicKey
|
||||||
|
Types = %w[sk-ecdsa-sha2-nistp256@openssh.com ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 sk-ssh-ed25519@openssh.com ssh-ed25519 ssh-dss ssh-rsa]
|
||||||
|
RE = %r{
|
||||||
|
(?<type> (?:#{Types.map{|t|Regexp.quote t}.join '|'}))
|
||||||
|
\ +
|
||||||
|
(?<pubkey> [a-zA-Z0-9/+]+ ) # base64 encoded
|
||||||
|
\ +
|
||||||
|
(?<comment> .*? ) # we use it as ident
|
||||||
|
\ *
|
||||||
|
}x
|
||||||
|
Re = /\A#{RE}\z/
|
||||||
|
|
||||||
|
attr :type, :key
|
||||||
|
attr_accessor :comment
|
||||||
|
|
||||||
|
def self.parse line
|
||||||
|
m = Re.match line
|
||||||
|
return nil if m.nil?
|
||||||
|
self.new m[:type], m[:pubkey], m[:comment]
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize type, key, comment
|
||||||
|
die "Unknown PubKey type <#{type}>" unless Types.include?( type)
|
||||||
|
die "Not a (base64-encoded) PubKey <#{key}>" if %r<[^a-zA-Z0-9/+]> =~ key
|
||||||
|
@type, @key, @comment = type, key, comment
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
"#{@type} #{@key} #{@comment}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def == other_pub
|
||||||
|
other_pub.kind_of?( PublicKey) and @type == other_pub.type and @key == other_pub.key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class AuthorizedKeys
|
||||||
|
LINERE = %r{
|
||||||
|
(?:(?<options>
|
||||||
|
(?:[a-z-]+ (?:=\"[^"]*\")?) # one option
|
||||||
|
(?:, (?:[a-z-]+ (?: = \"[^"]*\")?) )* # , more options
|
||||||
|
)\ )?
|
||||||
|
#{PublicKey::RE}
|
||||||
|
}x
|
||||||
|
LineRe = /\A#{LINERE}\z/
|
||||||
|
attr_reader :file
|
||||||
|
|
||||||
|
def self.parse line
|
||||||
|
if m = LineRe.match( line)
|
||||||
|
return PublicKey.new( m[:type], m[:pubkey], m[:comment]), m[:options]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize file
|
||||||
|
@file = file
|
||||||
|
end
|
||||||
|
|
||||||
|
def each_line &block
|
||||||
|
@file.each_line &block
|
||||||
|
end
|
||||||
|
|
||||||
|
def each_parsed_line &block
|
||||||
|
return to_enum(__method__) unless block_given?
|
||||||
|
@file.each_line do |l|
|
||||||
|
case l.chomp!
|
||||||
|
when /\A\s*# *(.*)\z/
|
||||||
|
yield :comment, l, $1
|
||||||
|
when /\A\s*$/
|
||||||
|
yield :empty, l
|
||||||
|
else
|
||||||
|
pk, o = self.class.parse( l)
|
||||||
|
if pk
|
||||||
|
yield :pub, l, o, pk
|
||||||
|
else
|
||||||
|
yield :unknown, l
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def each &block
|
||||||
|
each_parsed_line {|t, *a| yield *a if :pub == t }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get ident
|
||||||
|
each do |line, opts, pk|
|
||||||
|
return opts, pk if ident === pk.comment
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
alias :[] :get
|
||||||
|
|
||||||
|
def add! options, pub
|
||||||
|
@file.open 'a+' do |f|
|
||||||
|
f.puts "#{options} #{pub}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete! ident
|
||||||
|
tmp = Pathname.new "#{@file}.tmp.#{Process.pid}"
|
||||||
|
ret = 0
|
||||||
|
tmp.open 'w' do |f|
|
||||||
|
self.each_parsed_line do |t, l, *a|
|
||||||
|
if :pub == t and a[1].comment == ident
|
||||||
|
ret += 1
|
||||||
|
else
|
||||||
|
f.puts l
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if 0 == ret
|
||||||
|
tmp.unlink
|
||||||
|
else
|
||||||
|
@file.unlink
|
||||||
|
tmp.rename @file
|
||||||
|
end
|
||||||
|
ret
|
||||||
|
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, ident
|
||||||
|
opts, entry = authkeysfile[ident]
|
||||||
|
die "PubKey not found in authorized_keys: #{ident}" unless entry
|
||||||
|
if exist?
|
||||||
|
entry == @file.read.chomp
|
||||||
|
else
|
||||||
|
@file.open( 'a+') {|f| f.puts entry }
|
||||||
|
entry == PublicKey.parse( @file.read.chomp)
|
||||||
|
end
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
die "PubKey unusable"
|
||||||
|
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:, pubsdir:
|
||||||
|
#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 = pubsdir + '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
|
||||||
|
|
||||||
|
class Lock
|
||||||
|
attr_reader :path, :file
|
||||||
|
LOCK_T = [Fcntl::F_WRLCK, IO::SEEK_SET, 0, 0, 0].pack('s_s_q_q_q_')
|
||||||
|
UNLOCK_T = [Fcntl::F_UNLCK, IO::SEEK_SET, 0, 0, 0].pack('s_s_q_q_q_')
|
||||||
|
|
||||||
|
def initialize path
|
||||||
|
@path = path
|
||||||
|
@file = @path.open File::CREAT|File::RDWR, 0600
|
||||||
|
end
|
||||||
|
|
||||||
|
def exclusive!() die "Unable to get lock." unless @file.fcntl Fcntl::F_SETLKW, LOCK_T end
|
||||||
|
def release!() die "Unable to release lock." unless @file.fcntl Fcntl::F_SETLKW, UNLOCK_T end
|
||||||
|
|
||||||
|
def exclusive &block
|
||||||
|
exclusive!
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
release!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module REs
|
||||||
|
HOSTNAME = /[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?/
|
||||||
|
Hostname = /\A#{HOSTNAME}\z/
|
||||||
|
DOMAINNAME = /#{HOSTNAME}(?:\.#{HOSTNAME})*\.?/
|
||||||
|
Domainname = /\A#{DOMAINNAME}\z/
|
||||||
|
USERNAME = /[a-zA-Z0-9_]+/
|
||||||
|
Username = /\A#{USERNAME}\z/
|
||||||
|
USERIDENT = /#{USERNAME}@#{DOMAINNAME}/
|
||||||
|
Userident = /\A#{USERIDENT}\z/
|
||||||
|
|
||||||
|
# https://stackoverflow.com/a/17871737
|
||||||
|
OCTET = /1?[0-9]?[0-9] | 2[0-4][0-9] | 25[0-5]/x
|
||||||
|
IPV4ADDR = /#{OCTET}\.#{OCTET}\.#{OCTET}\.#{OCTET}/
|
||||||
|
IPv4Addr = /\A#{IPV4ADDR}\z/
|
||||||
|
|
||||||
|
IPV6SEG = /[0-9a-fA-Z]{1,4}/
|
||||||
|
IPV6ADDR = %r<
|
||||||
|
(?:#{IPV6SEG}:){7,7}#{IPV6SEG}| # 1:2:3:4:5:6:7:8
|
||||||
|
(?:#{IPV6SEG}:){1,7}:| # 1:: 1:2:3:4:5:6:7::
|
||||||
|
(?:#{IPV6SEG}:){1,6}:#{IPV6SEG}| # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8
|
||||||
|
(?:#{IPV6SEG}:){1,5}(?::#{IPV6SEG}){1,2}| # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8
|
||||||
|
(?:#{IPV6SEG}:){1,4}(?::#{IPV6SEG}){1,3}| # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8
|
||||||
|
(?:#{IPV6SEG}:){1,3}(?::#{IPV6SEG}){1,4}| # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8
|
||||||
|
(?:#{IPV6SEG}:){1,2}(?::#{IPV6SEG}){1,5}| # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8
|
||||||
|
#{IPV6SEG}:(?:(?::#{IPV6SEG}){1,6})| # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8
|
||||||
|
:(?:(?::#{IPV6SEG}){1,7}|:)| # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::
|
||||||
|
fe80:(?::#{IPV6SEG}){0,4}%[0-9a-zA-Z]{1,}| # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index)
|
||||||
|
::(?:ffff(?::0{1,4}){0,1}:){0,1}IPV4ADDR| # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses)
|
||||||
|
(?:#{IPV6SEG}:){1,4}:IPV4ADDR # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address)
|
||||||
|
>x
|
||||||
|
IPv6Addr = /\A#{IPV6ADDR}\z/
|
||||||
|
|
||||||
|
IPADDR = /#{IPV6ADDR}|#{IPV4ADDR}/
|
||||||
|
IPAddr = /\A#{IPADDR}\z/
|
||||||
|
end
|
||||||
|
|
||||||
|
class Main
|
||||||
|
attr_reader :admin, :type, :ident, :principals, :info, :expire_in, :gracetime
|
||||||
|
alias :admin? :admin
|
||||||
|
|
||||||
|
def initialize *argv
|
||||||
|
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]
|
||||||
|
@admin, @type, @expire_in, @gracetime = false, type.to_sym, 12.weeks, 1.week
|
||||||
|
prepare
|
||||||
|
files
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare
|
||||||
|
case @type
|
||||||
|
when :host
|
||||||
|
die "Invalid ident: #{@ident}" unless REs::Domainname =~ @ident
|
||||||
|
@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 REs::Userident =~ @ident
|
||||||
|
@info ||= "user: #{@ident}"
|
||||||
|
@principals = @principals.split ','
|
||||||
|
|
||||||
|
when :admin
|
||||||
|
die "Invalid ident: #{@ident}" unless REs::Userident =~ @ident
|
||||||
|
@info ||= "admin: #{@ident}"
|
||||||
|
@type, @admin = :user, true
|
||||||
|
@principals = @principals.split ','
|
||||||
|
|
||||||
|
else
|
||||||
|
die "Invalid type: #{@type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :lock, :pubsdir, :pubfile, :certfile, :cafile, :capubfile, :serialfile, :authkeysfile
|
||||||
|
def files
|
||||||
|
@config =
|
||||||
|
begin
|
||||||
|
JSON.parse Pathname.new( "~/.config/ssh-ca.conf").read
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
{}
|
||||||
|
rescue JSON::JSONError
|
||||||
|
die "500, Internal server error"
|
||||||
|
end
|
||||||
|
@lock = Lock.new Pathname.new( @config[:lockfile] || "/run/lock/ssh-ca.lock")
|
||||||
|
@storage = Pathname.new( @config[:storage] || "~/.local/sshca")
|
||||||
|
@pubsdir =
|
||||||
|
if @config[:pubsdir] then Pathname.new @config[:pubsdir]
|
||||||
|
else @storage + 'pubs'
|
||||||
|
end.expand_path
|
||||||
|
@pubfile = PublicKeyFile.new @pubsdir + "#{@type}-#{@ident}.pub", @type, @ident
|
||||||
|
@certfile = CertifcateFile.new @pubsdir + "#{@type}-#{@ident}-cert.pub"
|
||||||
|
@cafile = @storage + "ca"
|
||||||
|
@capubfile = @storage + "ca.pub"
|
||||||
|
@serialfile = SerialFile.new @storage + "serial"
|
||||||
|
@authkeysfile = AuthorizedKeys.new Pathname.new( @config[:authorized_keys_file] || "~/.ssh/authorized_keys").expand_path
|
||||||
|
@binfile = @config[:command] || __FILE__
|
||||||
|
end
|
||||||
|
|
||||||
|
def help msg=nil
|
||||||
|
STDERR.puts msg if msg
|
||||||
|
conn = "#{ENV['LOGNAME']}@#{`hostname -f`}".chomp
|
||||||
|
STDERR.puts <<-EOHELP.gsub( /^\s*#(.*)$/, admin? ? ":\\1" : "").sub(/\n+/, "\n").gsub( /^\s*:/, '')
|
||||||
|
:Usage: ssh #{conn} renew [force] [show]
|
||||||
|
: ssh #{conn} show
|
||||||
|
: ssh #{conn} help
|
||||||
|
: ssh #{conn} jsonl
|
||||||
|
# ssh #{conn} keylist
|
||||||
|
# ssh #{conn} keyimport host|user|admin IDENT PRINCIPALS*
|
||||||
|
# ssh #{conn} keydelete IDENT
|
||||||
|
:
|
||||||
|
:[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.
|
||||||
|
#keylist Lists installed keys
|
||||||
|
#keyimport host|user|admin IDENT PRINCIPALS*
|
||||||
|
# Import new key as host (a host-key) or user|admin (a user-key)
|
||||||
|
# Users and hosts are allowed to renew there keys.
|
||||||
|
# Admins are allowed to administrate keys (import/delete).
|
||||||
|
#keydelete IDENT
|
||||||
|
# Deletes key of this IDENT
|
||||||
|
:
|
||||||
|
: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
|
||||||
|
|
||||||
|
def show argv
|
||||||
|
die "Unexpected addition parameters: #{argv.shelljoin}" unless 1 == argv.length
|
||||||
|
die "No certfile, yet: #{@certfile.basename}" unless @certfile.exist?
|
||||||
|
lock.exclusive do
|
||||||
|
puts @certfile.read
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def renew argv
|
||||||
|
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?
|
||||||
|
|
||||||
|
lock.exclusive do
|
||||||
|
@pubfile.prepare @authkeysfile, ident
|
||||||
|
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, pubsdir: @pubsdir
|
||||||
|
puts @certfile.read if show
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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]
|
||||||
|
|
||||||
|
lock.exclusive do
|
||||||
|
@pubfile.prepare authkeyfile, ident
|
||||||
|
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, pubsdir: @pubsdir,
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
when :get
|
||||||
|
lock.exclusive do
|
||||||
|
STDOUT.puts [200, @certfile.metadata, @certfile.read].to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
else
|
||||||
|
die "Unknown command."
|
||||||
|
end
|
||||||
|
rescue Object
|
||||||
|
STDOUT.puts [500, $!.class.name, $!.to_s].to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def keyimport argv
|
||||||
|
help "Unknown command: #{argv[0]}" unless admin
|
||||||
|
type, ident, *principals = argv[1..-1].map(&:downcase)
|
||||||
|
case type
|
||||||
|
when 'host'
|
||||||
|
die "Ident must be a domainname / hostname" unless REs::Domainname =~ ident
|
||||||
|
principals.each do |princ|
|
||||||
|
case princ
|
||||||
|
when REs::Domainname then # ok
|
||||||
|
when REs::IPv6Addr then # ok
|
||||||
|
when REs::IPv4Addr then # ok
|
||||||
|
else die "Principals must be domainnames or IP-addresses, not <#{princ}>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when 'user', 'admin'
|
||||||
|
die "Ident must be user@hostname (lower cased)" unless REs::Userident =~ ident
|
||||||
|
else
|
||||||
|
die "Type of key expected (host, user, admin) instead of <#{type}>."
|
||||||
|
end
|
||||||
|
|
||||||
|
str = STDIN.readline.chomp
|
||||||
|
pk, _ = PublicKey.parse( str)
|
||||||
|
die "Cannot parse public key <#{str}>." unless pk
|
||||||
|
pk.comment = ident
|
||||||
|
lock.exclusive do
|
||||||
|
@authkeysfile.add! "restrict,command=\"#{__FILE__} #{type} #{ident} #{principals.join ','}\"", pk
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def keydelete argv
|
||||||
|
help "Unknown command: #{argv[0]}" unless admin
|
||||||
|
ident = argv[1]
|
||||||
|
lock.exclusive do
|
||||||
|
@authkeysfile.delete! ident
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def keylist argv
|
||||||
|
help "Unknown command: #{argv[0]}" unless admin
|
||||||
|
lock.exclusive do
|
||||||
|
@authkeysfile.each_line {|l| STDOUT.print l }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def exec cmd
|
||||||
|
help unless cmd
|
||||||
|
die "Unallowed char in arguments [\\0]" if cmd.include? 0.chr
|
||||||
|
argv = cmd.shellsplit
|
||||||
|
case argv[0]
|
||||||
|
when *%w[h help] then help
|
||||||
|
when *%w[s show] then show argv
|
||||||
|
when *%w[r renew] then renew argv
|
||||||
|
when 'jsonl' then jsonl argv
|
||||||
|
when 'keyimport' then keyimport argv
|
||||||
|
when 'keydelete' then keydelete argv
|
||||||
|
when 'keylist' then keylist argv
|
||||||
|
else help "Unknown command: #{argv[0]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
#################################################
|
||||||
|
begin
|
||||||
|
|
||||||
|
main = Main.new *ARGV
|
||||||
|
main.exec ENV['SSH_ORIGINAL_COMMAND']
|
||||||
|
|
||||||
|
rescue SystemExit
|
||||||
|
raise
|
||||||
|
|
||||||
|
rescue Die
|
||||||
|
STDERR.puts $!
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
rescue Object
|
||||||
|
STDERR.puts "#$! (#{$!.class})", *$!.backtrace
|
||||||
|
exit 2
|
||||||
|
end
|
16
ssh-ca.conf
Normal file
16
ssh-ca.conf
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"!": [
|
||||||
|
"comments are keys beginning with #, ! are for documentation.",
|
||||||
|
"configs are letters-only like \"lockfile\": \"/run/lock/ssh-ca.lock\""
|
||||||
|
],
|
||||||
|
|
||||||
|
"!lockfile": [
|
||||||
|
"ssh-ca manipulates your authorized_keys and other files.",
|
||||||
|
"To prevent concurrent access and damages to your file, it uses a lock."
|
||||||
|
],
|
||||||
|
"#lockfile": "/run/lock/ssh-ca.lock",
|
||||||
|
"#storage": "~/.local/sshca",
|
||||||
|
"#pubsdir": "~/.local/sshca/pub",
|
||||||
|
"#authorized_keys_file": "~/.ssh/authorized_keys",
|
||||||
|
"#command": "~/bin/ssh-ca"
|
||||||
|
}
|
Loading…
Reference in a new issue