#!/usr/bin/ruby

require 'etc'
require 'fileutils'
require 'openssl'
require 'puavo/conf'
require 'puavo/rest-client'
require 'syslog'
require 'time'

mode              = ARGV[0]
certchain_version = ARGV[1]

def rest_client()
  PuavoRestClient.new(:auth => :etc, :dns => :no)
end

class CertificatePaths
  attr_reader :certdir

  Certificates_path = '/state/etc/puavo/certificates'

  def initialize(certchain_version, suffix)
    @certchain_version = certchain_version
    @certdir = "#{ certchain_link }#{ suffix }"
  end

  def certchain_link
    "#{ Certificates_path }/#{ @certchain_version }"
  end

  def certificate    ; "#{ @certdir }/host.crt"           ; end
  def hostorgcabundle; "#{ @certdir }/hostorgcabundle.pem"; end
  def orgcabundle    ; "#{ @certdir }/orgcabundle.pem"    ; end
  def private_key    ; "#{ @certdir }/host.key"           ; end
  def rootca         ; "#{ @certdir }/rootca.pem"         ; end
end

class Certificates
  def initialize(certchain_version)
    @certchain_version = certchain_version

    unix_seconds = Time.now.to_i
    @paths       = CertificatePaths.new(certchain_version, ".#{ unix_seconds }")
  end

  def cert_ok?(textstr)
    textstr.kind_of?(String) && !textstr.empty?
  end

  def create()
    make_certificate_request
    sign_in_puavo
    write_to_filesystem
  end

  def update()
    current_cert_paths = CertificatePaths.new(@certchain_version, '')

    begin
      end_cert_marker = "-----END CERTIFICATE-----\n"
      all_certificates \
        = (File.read( current_cert_paths.certificate )    \
            + File.read( current_cert_paths.orgcabundle ) \
            + File.read( current_cert_paths.rootca ))     \
          .split(end_cert_marker) \
          .map { |pem| pem + end_cert_marker }
    rescue Errno::ENOENT
      Syslog.info('no certificate exists for certchain version %s, creating',
                  @certchain_version)
      create
      return
    end

    must_renew = false
    all_certificates.each do |cert_pem|
      cert = OpenSSL::X509::Certificate.new(cert_pem)
      if (Time.now + 60 * 60 * 24 * 180) >= cert.not_after then
        # update certificates in case any of the certificates will expire
        # in half a year
        must_renew = true
        break
      end
    end

    return unless must_renew

    Syslog.info('some certificate for chain version %s is expiring in half' \
                  + ' a year (or has already expired), updating',
                @certchain_version)
    create
  end

  def make_certificate_request
    @key = OpenSSL::PKey::RSA.new(4096)
    csr = OpenSSL::X509::Request.new
    csr.version = 0
    csr.public_key = @key.public_key
    csr.sign(@key, OpenSSL::Digest::SHA512.new)

    @csr = csr
  end

  def sign_in_puavo
    Syslog.notice('sending a new certificate signing request to Puavo for' \
                    + ' chain version %s',
                  @certchain_version)

    begin
      msgobj = {
                 'certchain_version'   => @certchain_version,
                 'certificate_request' => @csr.to_pem,
               }
      response = rest_client().post('/v3/hosts/certs/sign',
                                    :json => msgobj)
    rescue PuavoRestClient::BadStatusCode => e
      begin
        error = JSON.parse(e.response.body)['error']['message']
      rescue StandardError => e
        raise "can not parse error message: #{ e.message }"
      end
      raise "error returned by server: #{ error }"
    end

    begin
      certificates = response.parse
    rescue StandardError => e
      raise "could not parse certificate signing response: #{ e.message }"
    end

    raise 'no host certificate in the server response' \
      unless cert_ok?(certificates['certificate'])
    raise 'no organisation ca certificate bundle in the server response' \
      unless cert_ok?(certificates['org_ca_certificate_bundle'])
    raise 'no root ca certificate in the server response' \
      unless cert_ok?(certificates['root_ca_certificate'])

    Syslog.notice('got certificates from Puavo for chain version %s',
                  @certchain_version)

    @host_cert     = certificates['certificate']
    @org_ca_bundle = certificates['org_ca_certificate_bundle']
    @root_ca       = certificates['root_ca_certificate']
  end

  def write_to_filesystem
    Syslog.info('writing certificates for chain version %s to %s',
                @certchain_version, @paths.certdir)

    FileUtils.mkdir_p(@paths.certdir)

    File.open(@paths.certdir, 'r') do |certdir_fh|
      certdir_fh.flock(File::LOCK_EX)

      File.open(@paths.certificate, 'w', 0444) do |f|
        f.write(@host_cert)
      end
      File.open(@paths.private_key, 'w', 0440) do |f|
        f.print(@key.to_pem)
      end
      File.chown(nil, $puavo_update_gid, @paths.private_key)
      File.open(@paths.hostorgcabundle, 'w', 0444) do |f|
        f.print(@host_cert + @org_ca_bundle)
      end
      File.open(@paths.orgcabundle, 'w', 0444) do |f|
        f.print(@org_ca_bundle)
      end
      File.open(@paths.rootca, 'w', 0444) do |f|
        f.print(@root_ca)
      end

      tmp_link = "#{ @paths.certchain_link }.tmp"
      FileUtils.rm_f(tmp_link)
      FileUtils.symlink(File.basename(@paths.certdir), tmp_link)
      File.rename(tmp_link, @paths.certchain_link)

      Syslog.info('certificates written successfully for chain version %s',
                  @certchain_version)

      Dir.glob("#{ @paths.certchain_link }.*") do |dir|
        next if dir == @paths.certdir
        Syslog.info('removing %s', dir)
        FileUtils.remove_entry_secure(dir)
      end
    end
  end
end

def usage
  puts <<EOF
Usage:
        #{ File.basename($0) } create [certchain_version]
        #{ File.basename($0) } update [certchain_version]
EOF

end

Syslog.open(File.basename($0), Syslog::LOG_PERROR)

# the certificate private key should have group "puavo-update"
begin
  $puavo_update_gid = Etc.getgrnam('puavo-update').gid
rescue StandardError => e
  Syslog.err('could not lookup the "puavo-update" group: %s', e.message)
  exit 1
end

case mode
  when 'create'
    action_method = :create
  when 'update'
    action_method = :update
  when nil
    usage
    exit 0
  else
    Syslog.err('unsupported mode: %s', mode)
    usage
    exit 1
end

if certchain_version.nil? then
  puavoconf = Puavo::Conf.new
  certchain_version_list = puavoconf.get('puavo.admin.certs.versions').split
  puavoconf.close
else
  certchain_version_list = [ certchain_version ]
end

if certchain_version_list.empty? then
  Syslog.err('no certificates to update in "puavo.admin.certs.versions"')
  exit 1
end

status = 0

certchain_version_list.each do |certchain_version|
  begin
    raise 'certificate chain version is not made of digits' \
      unless certchain_version.match(/\A\d+\z/)
    certificates = Certificates.new(certchain_version)
    certificates.send(action_method)
  rescue StandardError => e
    Syslog.err('error with certificate chain version %s: %s',
               certchain_version, e.message)
    status = 1
  end
end

unless system('/etc/puavo-conf/scripts/setup_certs') then
  Syslog.err('error running /etc/puavo-conf/scripts/setup_certs')
  status = 1
end

Syslog.err('errors occurred!') unless status == 0

Syslog.close

exit(status)
