Temporary working note. Not linked from CONTENTS. Contains the post-review versions of CertHub::Client and cert_request.pl, plus a record of deferred improvements.
Pre-review backups: CertHub/Client_pre_review.pm and cert_request_pre_review.pl. Full improvement log: CertHub/IMPROVEMENTS.md.
# Required
BASE_URL=https://uat-certhub.example.com \
USR=api_user \
PW=api_pass \
HOST=myserver.critchley.biz \
DOWNLOAD_PW=keystorepassword \
NID=45306-1 \
EMAIL=you@db.com \
perl cert_request.pl
# Optional overrides (defaults shown)
CERT_TYPE=gcp-ssl-client-server # certificate profile
POLL_INTERVAL=5 # seconds between status polls
POLL_TIMEOUT=300 # give up after N seconds
INSECURE=1 # disable TLS verification (UAT only)
package CertHub::Client;
# Data::Dumper and JSON are retained for debug use only.
# They are loaded at runtime when DEBUG is set to a non-empty, non-zero value
# so that normal production runs incur no cost from these imports.
if ($ENV{DEBUG}) {
require Data::Dumper;
require JSON;
}
use strict;
use warnings;
# Moo gives us a small, modern OO layer without pulling in full Moose.
use Moo;
# Carp is used for caller-friendly exceptions.
use Carp qw(croak);
# Mojo::UserAgent is the required HTTP client for this module.
use Mojo::UserAgent;
# Mojo::URL is useful for safe URL/query manipulation.
use Mojo::URL;
# Mojo::Util provides URL escaping and base64 helpers for HTTP Basic auth.
use Mojo::Util qw(url_escape b64_encode);
# MIME::Base64 is used for decoding downloaded certificate/keystore content.
use MIME::Base64 qw(decode_base64);
# Time::HiRes allows fractional sleeps and reliable timeout calculations.
use Time::HiRes qw(time sleep);
# POSIX is used for a compact UTC timestamp in generated clientRef values.
use POSIX qw(strftime);
# Bytes from /dev/urandom are used for uniqueness in generated clientRef values.
use Fcntl qw(:DEFAULT);
# Needed for old-Mojo insecure fallback via IO::Socket::SSL.
use IO::Socket::SSL qw(SSL_VERIFY_NONE);
our $VERSION = '0.01';
my $OLD_MOJO_INSECURE_HACK_INSTALLED;
# -------------------------------------------------------------------------
# Constructor attributes: connection/authentication.
# -------------------------------------------------------------------------
has base_url => (
is => 'ro',
required => 1,
);
has token_url => (
is => 'ro',
);
has username => (
is => 'ro',
);
has password => (
is => 'ro',
);
has access_token => (
is => 'rw',
predicate => 'has_access_token',
);
has auth_mode => (
is => 'ro',
default => sub { 'oauth' }, # 'oauth' or 'basic'
);
has ca_file => (
is => 'ro',
);
has insecure => (
is => 'ro',
default => sub { 0 },
);
has connect_timeout => (
is => 'ro',
default => sub { 30 },
);
has request_timeout => (
is => 'ro',
default => sub { 120 },
);
# -------------------------------------------------------------------------
# Constructor attributes: useful payload defaults.
#
# These values are not required for the low-level request methods, but they
# make the helper payload builders much easier to use from small scripts.
# -------------------------------------------------------------------------
has nar_id => (
is => 'ro',
);
has platform => (
is => 'ro',
default => sub { 'DAP' },
);
has email_contact => (
is => 'ro',
);
has change_id => (
is => 'ro',
);
has cert_type => (
is => 'ro',
default => sub { 'gcp-ssl-client-server' },
);
has organisation => (
is => 'ro',
default => sub { 'Deutsche Bank AG' },
);
has org_unit => (
is => 'ro',
default => sub { 'PKI' },
);
# -------------------------------------------------------------------------
# Lifecycle attributes: populated after onboard_newcert() is called.
# These let callers (and polling loops) refer back to the submitted request
# without needing to parse the response themselves.
# -------------------------------------------------------------------------
has last_client_ref => (
is => 'rw',
);
has onboard_task_id => (
is => 'rw',
);
has provide_results_task_id => (
is => 'rw',
);
# -------------------------------------------------------------------------
# Old Mojo insecure compatibility.
#
# According to old_Mojo::UserAgent.txt, very old Mojo::UserAgent has neither
# the modern "insecure" attribute nor "tls_options". According to
# IO::Socket::SSL.txt, disabling verification can be forced with:
#
# SSL_verify_mode => SSL_VERIFY_NONE
# SSL_verifycn_scheme => 'none'
#
# set_args_filter_hack is process-global, so this is only an emergency
# fallback for old Mojo installs.
# -------------------------------------------------------------------------
sub _install_old_mojo_insecure_hack {
return if $OLD_MOJO_INSECURE_HACK_INSTALLED++;
IO::Socket::SSL::set_args_filter_hack(sub {
my ($is_server, $args) = @_;
# Only weaken client-side TLS.
return if $is_server;
# curl -k equivalent: disable chain and hostname verification.
$args->{SSL_verify_mode} = SSL_VERIFY_NONE;
$args->{SSL_verifycn_scheme} = 'none';
# Remove other verification-related settings that could interfere.
delete @{$args}{qw(
SSL_ca
SSL_ca_file
SSL_ca_path
SSL_verify_callback
SSL_fingerprint
SSL_force_fingerprint
)};
});
return;
}
# -------------------------------------------------------------------------
# Lazy Mojo::UserAgent construction.
#
# This centralizes TLS and timeout configuration. In modern Mojo,
# Mojo::UserAgent.txt documents "insecure" and "tls_options". In old Mojo,
# old_Mojo::UserAgent.txt shows neither, so we must fall back to
# IO::Socket::SSL::set_args_filter_hack as documented in IO::Socket::SSL.txt.
# -------------------------------------------------------------------------
has ua => (
is => 'lazy',
builder => '_build_ua',
);
sub _build_ua {
my ($self) = @_;
my $ua = Mojo::UserAgent->new;
$ua->connect_timeout($self->connect_timeout);
$ua->request_timeout($self->request_timeout);
if ($self->insecure) {
if ($ua->can('insecure')) {
# Modern Mojo::UserAgent, per Mojo::UserAgent.txt
$ua->insecure(1);
}
elsif ($ua->can('tls_options')) {
# Intermediate Mojo with tls_options support
$ua->tls_options({
SSL_verify_mode => SSL_VERIFY_NONE,
SSL_verifycn_scheme => 'none',
});
}
else {
# Very old Mojo::UserAgent, per old_Mojo::UserAgent.txt
_install_old_mojo_insecure_hack();
}
}
elsif (defined $self->ca_file && length $self->ca_file) {
# In old_Mojo::UserAgent.txt, "ca" is used for peer verification and
# also activates hostname verification, so only set it when not using
# insecure mode.
$ua->ca($self->ca_file);
}
return $ua;
}
# -------------------------------------------------------------------------
# URL helper.
#
# certomato.py configures a base URL ending in /certhub/v1/ and then appends
# endpoint paths such as certhub/api/application/onboard/newcert.
#
# This helper keeps that convention and avoids accidental double slashes.
# -------------------------------------------------------------------------
sub _url_for {
my ($self, $path) = @_;
croak 'path is required' unless defined $path && length $path;
my $base = $self->base_url;
$base =~ s{/+\z}{};
$path =~ s{\A/+}{};
return "$base/$path";
}
# -------------------------------------------------------------------------
# Response helper.
#
# old_Mojo::UserAgent.txt prominently shows transaction-level error handling
# using $tx->success / $tx->error. Modern Mojo::UserAgent.txt uses $tx->result
# more heavily. This helper stays friendly to older Mojo by checking
# transaction-level errors first, then returning a response object.
# -------------------------------------------------------------------------
sub _checked_res {
my ($self, $tx, $context) = @_;
croak "$context failed: no transaction returned"
unless defined $tx;
# Modern Mojo: $tx->result croaks on connection errors and returns the
# response (even 4xx/5xx) otherwise. Use it when available.
if ($tx->can('result')) {
my $res = $tx->result;
if (my $err = $res->error) {
my $status = $err->{code} // 'transport';
my $msg = $err->{message} // 'unknown error';
croak "$context failed: status=$status message=$msg body=" . ($res->body // '');
}
return $res;
}
# Old Mojo: $tx->success returns undef on any error.
if ($tx->can('success')) {
if (my $res = $tx->success) {
return $res;
}
my $err = $tx->error || {};
my $status = $err->{code} // 'transport';
my $msg = $err->{message} // 'unknown error';
my $body = ($tx->can('res') && $tx->res) ? ($tx->res->body // '') : '';
croak "$context failed: status=$status message=$msg body=$body";
}
croak "$context failed: unsupported Mojo::Transaction object";
}
# -------------------------------------------------------------------------
# OAuth2 client-credentials authentication.
#
# If the caller supplied access_token, use it directly. Otherwise request a
# token using the same basic idea as certomato.py:
#
# POST token_url
# HTTP Basic auth username:password
# form grant_type=client_credentials
#
# The token is retained on the object for subsequent API calls.
# -------------------------------------------------------------------------
sub authenticate {
my ($self) = @_;
return $self->access_token if $self->has_access_token && length $self->access_token;
for my $field (qw(token_url username password)) {
my $value = $self->$field;
croak "Cannot authenticate: $field is required unless access_token is supplied"
unless defined $value && length $value;
}
my $basic = b64_encode($self->username . ':' . $self->password, '');
my $tx = $self->ua->post(
$self->token_url
=> {
Authorization => "Basic $basic",
Accept => 'application/json',
}
=> form => {
grant_type => 'client_credentials',
}
);
my $res = $self->_checked_res($tx, 'Token request');
my $json = $res->json;
croak 'Token response was not valid JSON'
unless defined $json && ref $json eq 'HASH';
my $token = $json->{access_token};
croak 'Token response did not contain access_token'
unless defined $token && length $token;
$self->access_token($token);
return $token;
}
# -------------------------------------------------------------------------
# Authentication header helper.
#
# Selects Basic or Bearer auth based on the auth_mode attribute.
# auth_mode 'basic' uses the username/password attributes directly.
# auth_mode 'oauth' (default) calls authenticate() to obtain a bearer token.
# -------------------------------------------------------------------------
sub _auth_header {
my ($self) = @_;
my $mode = lc($self->auth_mode // 'oauth');
if ($mode eq 'basic') {
for my $field (qw(username password)) {
croak "Basic auth requires $field"
unless defined $self->$field && length $self->$field;
}
my $credentials = b64_encode($self->username . ':' . $self->password, '');
return "Basic $credentials";
}
if ($mode eq 'oauth') {
my $token = $self->authenticate;
return "Bearer $token";
}
croak "Unsupported auth_mode: $mode";
}
# -------------------------------------------------------------------------
# Generic JSON request helper.
#
# All normal CertHub calls go through this method. It:
#
# * selects auth via _auth_header (Basic or OAuth Bearer);
# * constructs the full URL;
# * sends JSON for POST requests;
# * sends query parameters for GET requests if payload is supplied;
# * throws detailed errors including the response body;
# * returns decoded JSON.
# -------------------------------------------------------------------------
sub request_json {
my ($self, $method, $path, $payload) = @_;
croak 'HTTP method is required' unless defined $method && length $method;
croak 'path is required' unless defined $path && length $path;
$method = uc $method;
$payload = {} unless defined $payload;
my $url = $self->_url_for($path);
my $headers = {
Accept => 'application/json',
Authorization => $self->_auth_header,
};
my $tx;
if ($method eq 'POST') {
$tx = $self->ua->post($url => $headers => json => $payload);
}
elsif ($method eq 'GET') {
my $u = Mojo::URL->new($url);
if (ref $payload eq 'HASH' && keys %{$payload}) {
$u->query($payload);
}
$tx = $self->ua->get($u => $headers);
}
else {
croak "Unsupported HTTP method: $method";
}
my $res = $self->_checked_res($tx, "$method $url");
my $json = $res->json;
croak "$method $url returned non-JSON response: " . ($res->body // '')
unless defined $json;
return $json;
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/validate/certrequest
#
# This maps to CertRequestValidationReq in openapi.json.
# -------------------------------------------------------------------------
sub validate_cert_request {
my ($self, $payload) = @_;
croak 'validate_cert_request requires a payload hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/validate/certrequest',
$payload,
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/application/onboard/newcert
#
# This maps to OnboardingReqNewCert in openapi.json and to create_certificate()
# in certomato.py.
# -------------------------------------------------------------------------
sub onboard_newcert {
my ($self, $payload) = @_;
croak 'onboard_newcert requires a payload hashref'
unless ref $payload eq 'HASH';
# Save clientRef from the payload before the HTTP call so it is available
# for recovery even if the request times out after the server accepted it.
$self->last_client_ref($payload->{clientRef})
if defined $payload->{clientRef} && length $payload->{clientRef};
# Clear stale lifecycle state from any previous request on this object.
$self->onboard_task_id(undef);
$self->provide_results_task_id(undef);
my $response = $self->request_json(
POST => 'certhub/api/application/onboard/newcert',
$payload,
);
# Update from response (authoritative); payload value is the fallback.
$self->last_client_ref($response->{clientRef})
if defined $response->{clientRef} && length $response->{clientRef};
$self->onboard_task_id($self->find_onboard_task_id($response));
$self->provide_results_task_id($self->find_provide_results_task_id($response));
return $response;
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# GET /certhub/task/result/{cmsRef}
#
# In the intended workflow, cmsRef is the id of the "onboard cert request"
# task. Poll this task to know when the certificate has been issued.
# The "provide results" task is a later notification task (~5 min) and is
# not needed to confirm certificate issuance.
# -------------------------------------------------------------------------
sub task_result {
my ($self, $cms_ref) = @_;
croak 'task_result requires cms_ref'
unless defined $cms_ref && length $cms_ref;
return $self->request_json(
GET => 'certhub/task/result/' . url_escape($cms_ref),
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# GET /certhub/task/resultByClientRef/{clientRef}
#
# This is useful for recovery/debugging if a caller has clientRef but not the
# task id.
# -------------------------------------------------------------------------
sub task_result_by_client_ref {
my ($self, $client_ref) = @_;
croak 'task_result_by_client_ref requires client_ref'
unless defined $client_ref && length $client_ref;
return $self->request_json(
GET => 'certhub/task/resultByClientRef/' . url_escape($client_ref),
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/request/download
#
# This endpoint is documented in openapi.json and discussed in download_cert.ans
# and download_cert2.ans. certomato.py does not currently wrap it.
# -------------------------------------------------------------------------
sub download {
my ($self, $payload) = @_;
croak 'download requires a payload hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/request/download',
$payload,
);
}
# -------------------------------------------------------------------------
# Useful extra endpoint:
#
# POST /certhub/api/validate/domain
#
# certomato.py has this, and what_I_need_to_do.ans discusses it as a useful
# pre-check.
# -------------------------------------------------------------------------
sub validate_domain {
my ($self, $nar_id, $domains) = @_;
croak 'validate_domain requires nar_id'
unless defined $nar_id && length $nar_id;
croak 'validate_domain requires domains arrayref'
unless ref $domains eq 'ARRAY';
return $self->request_json(
POST => 'certhub/api/validate/domain',
{
narId => $nar_id,
domains => $domains,
},
);
}
# -------------------------------------------------------------------------
# Useful extra endpoint:
#
# POST /certhub/api/application/certificates
#
# This mirrors get_certificates() in certomato.py at a low level.
# -------------------------------------------------------------------------
sub query_certificates {
my ($self, $payload) = @_;
$payload = {} unless defined $payload;
croak 'query_certificates payload must be a hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/application/certificates',
$payload,
);
}
# -------------------------------------------------------------------------
# Metadata builder.
#
# This returns a MetaData structure as defined in openapi.json. Undefined
# values are omitted, because some fields such as changeId may not always be
# required in all environments.
# -------------------------------------------------------------------------
sub build_metadata {
my ($self, %args) = @_;
my %md;
my $nar_id = exists $args{nar_id} ? $args{nar_id} : $self->nar_id;
my $email_contact = exists $args{email_contact} ? $args{email_contact} : $self->email_contact;
my $platform = exists $args{platform} ? $args{platform} : $self->platform;
$md{narId} = $nar_id if defined $nar_id && length $nar_id;
$md{emailContact} = $email_contact if defined $email_contact && length $email_contact;
$md{platform} = $platform if defined $platform && length $platform;
# changeId is a top-level payload field, not a metaData field. See
# build_newcert_payload. Do not add it here.
return \%md;
}
# -------------------------------------------------------------------------
# Validation payload builder.
#
# openapi.json documents CertRequestValidationReq as:
#
# {
# certType: string,
# parameters: map<string,array<string>>,
# metaData: MetaData
# }
#
# The exact parameters can be profile-specific, so callers can pass their own
# parameters hashref. If not supplied, this helper creates a sensible minimal
# payload with commonName and dns values.
# -------------------------------------------------------------------------
sub build_validation_payload {
my ($self, %args) = @_;
my $host = $args{host};
croak 'build_validation_payload requires host'
unless defined $host && length $host;
my $cert_type = $args{cert_type} // $self->cert_type;
my $cn = $args{common_name} // $host;
my $sans = $args{sans} // [$host];
croak 'sans must be an arrayref'
unless ref $sans eq 'ARRAY';
my $parameters = $args{parameters};
if (defined $parameters) {
croak 'parameters must be a hashref'
unless ref $parameters eq 'HASH';
}
else {
$parameters = {
commonName => [$cn],
dns => $sans,
};
}
return {
certType => $cert_type,
parameters => $parameters,
metaData => $self->build_metadata(%args),
};
}
# -------------------------------------------------------------------------
# New certificate onboarding payload builder.
#
# This builds the payload for:
#
# POST /certhub/api/application/onboard/newcert
#
# It is oriented toward the download-only flow requested in what_I_need_to_do.ask:
# the generated key/cert should later be downloaded via API, not pushed by SSH.
#
# Therefore deploymentLocations is omitted unless explicitly supplied.
# -------------------------------------------------------------------------
sub build_newcert_payload {
my ($self, %args) = @_;
my $host = $args{host};
croak 'build_newcert_payload requires host'
unless defined $host && length $host;
my $download_password = $args{download_password};
croak 'build_newcert_payload requires download_password'
unless defined $download_password && length $download_password;
my $client_ref = $args{client_ref} // $self->generate_client_ref($host);
my $cert_type = $args{cert_type} // $self->cert_type;
my $cn = $args{common_name} // $host;
my $sans = $args{sans} // [$host];
croak 'sans must be an arrayref'
unless ref $sans eq 'ARRAY';
my $cert_request = {
certType => $cert_type,
commonName => $cn,
o => $args{organisation} // $self->organisation,
ou => $args{org_unit} // $self->org_unit,
sans => {
dns => $sans,
},
metaData => $self->build_metadata(%args),
downloadPassword => $download_password,
};
if (defined $args{task_ref} && length $args{task_ref}) {
$cert_request->{taskRef} = $args{task_ref};
}
if (exists $args{deployment_locations}) {
croak 'deployment_locations must be an arrayref'
unless ref $args{deployment_locations} eq 'ARRAY';
$cert_request->{deploymentLocations} = $args{deployment_locations};
}
my $payload = {
clientRef => $client_ref,
platform => $args{platform} // $self->platform,
onboardingCertRequests => [$cert_request],
};
my $change_id = exists $args{change_id} ? $args{change_id} : $self->change_id;
if (defined $change_id && length $change_id) {
$payload->{changeId} = $change_id;
}
return $payload;
}
# -------------------------------------------------------------------------
# Client reference generator.
#
# certomato.py generates unique clientRef strings using a prefix, date, and
# random bytes. This does the same kind of thing in Perl.
# -------------------------------------------------------------------------
sub generate_client_ref {
my ($self, $host) = @_;
$host //= 'unknown-host';
$host =~ s/[^A-Za-z0-9_.:-]+/-/g;
my $ts = strftime('%Y%m%d%H%M%S', gmtime);
my $rnd = _random_hex(4);
return "cert-request-$host-$ts-$rnd";
}
# -------------------------------------------------------------------------
# Small internal random hex helper.
# -------------------------------------------------------------------------
sub _random_hex {
my ($nbytes) = @_;
$nbytes ||= 4;
my $buf = '';
if (sysopen my $fh, '/dev/urandom', O_RDONLY) {
my $read = sysread $fh, $buf, $nbytes;
close $fh;
if (defined $read && $read == $nbytes) {
return unpack('H*', $buf);
}
}
# /dev/urandom was unavailable or returned short read. This should never
# happen on any normal Linux/macOS system. Falling back to rand(), which
# is weaker but sufficient for clientRef uniqueness. Log so this is
# visible if it ever occurs in production.
warn "CertHub::Client: _random_hex: /dev/urandom unavailable, falling back to rand()\n";
return sprintf '%08x', int(rand(0xffffffff));
}
# -------------------------------------------------------------------------
# Validation response helper.
#
# CertHub validation responses contain validationResults, a map of arrays of
# FieldValidationError objects. This helper returns the ones that are marked
# as errors.
# -------------------------------------------------------------------------
sub validation_errors {
my ($self, $validation_response) = @_;
return [] unless ref $validation_response eq 'HASH';
my $vr = $validation_response->{validationResults};
return [] unless ref $vr eq 'HASH';
my @errors;
for my $field (sort keys %{$vr}) {
my $items = $vr->{$field};
next unless ref $items eq 'ARRAY';
for my $item (@{$items}) {
next unless ref $item eq 'HASH';
if ($item->{error}) {
push @errors, {
field => $field,
code => $item->{code},
message => $item->{message},
raw => $item,
};
}
}
}
return \@errors;
}
# -------------------------------------------------------------------------
# Boolean validation helper.
# -------------------------------------------------------------------------
sub has_validation_errors {
my ($self, $validation_response) = @_;
return @{ $self->validation_errors($validation_response) } ? 1 : 0;
}
# -------------------------------------------------------------------------
# Task helper: find a CMS task by action regex.
#
# The newcert response is a SnowResp according to openapi.json, containing
# cmsTasks. design specifically says to find the id of the task whose action
# is "provide results".
# -------------------------------------------------------------------------
sub find_task_id_by_action {
my ($self, $snow_response, $regex) = @_;
croak 'find_task_id_by_action requires a response hashref'
unless ref $snow_response eq 'HASH';
croak 'find_task_id_by_action requires a regex'
unless ref $regex eq 'Regexp';
my $tasks = $snow_response->{cmsTasks} // [];
croak 'cmsTasks must be an arrayref'
unless ref $tasks eq 'ARRAY';
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
my $action = $task->{action} // '';
if ($action =~ $regex) {
return $task->{id};
}
}
return undef;
}
# -------------------------------------------------------------------------
# Task helper: find the "onboard cert request" task id.
# -------------------------------------------------------------------------
sub find_onboard_task_id {
my ($self, $snow_response) = @_;
return $self->find_task_id_by_action(
$snow_response,
qr/\bonboard\s+cert\s+request\b/i,
);
}
# -------------------------------------------------------------------------
# Task helper: find the "provide results" task id.
# -------------------------------------------------------------------------
sub find_provide_results_task_id {
my ($self, $snow_response) = @_;
return $self->find_task_id_by_action(
$snow_response,
qr/\bprovide\s+results\b/i,
);
}
# -------------------------------------------------------------------------
# Task helper: return normalized task state information.
#
# This is intentionally simple so monitor_cert_generation can print it easily.
# -------------------------------------------------------------------------
sub task_states {
my ($self, $snow_response) = @_;
return [] unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks} // [];
return [] unless ref $tasks eq 'ARRAY';
my @states;
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
push @states, {
id => $task->{id},
action => $task->{action},
state => $task->{state},
};
}
return \@states;
}
# -------------------------------------------------------------------------
# Task helper: get state for a specific task id.
#
# If the response only contains one task, this method falls back to that task.
# That makes it tolerant of both detailed and narrowed task-result responses.
# -------------------------------------------------------------------------
sub state_for_task {
my ($self, $snow_response, $task_id) = @_;
return undef unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks} // [];
return undef unless ref $tasks eq 'ARRAY';
if (defined $task_id) {
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
return $task->{state}
if defined $task->{id} && "$task->{id}" eq "$task_id";
}
}
if (@{$tasks} == 1 && ref $tasks->[0] eq 'HASH') {
return $tasks->[0]{state};
}
return undef;
}
# -------------------------------------------------------------------------
# Poll task until final state.
#
# This implements the monitoring behaviour requested in design:
# keep checking state until it is no longer init, then return success/failure.
#
# Callers can pass on_state => sub { my ($state, $response) = @_; ... }
# to print progress while polling.
# -------------------------------------------------------------------------
sub poll_task_until_final {
my ($self, %args) = @_;
my $cms_ref = $args{cms_ref};
croak 'poll_task_until_final requires cms_ref'
unless defined $cms_ref && length $cms_ref;
my $task_id = $args{task_id};
my $interval = $args{interval} // 30;
my $timeout = $args{timeout} // 1800;
my $on_state = $args{on_state};
croak 'on_state must be a coderef if supplied'
if defined $on_state && ref $on_state ne 'CODE';
my %init_states = map { lc($_) => 1 } @{ $args{init_states} // [qw(init running)] };
my %success_states = map { lc($_) => 1 } @{ $args{success_states} // ['done'] };
my %failure_states = map { lc($_) => 1 } @{ $args{failure_states} // [qw(failed failure error cancelled canceled)] };
my $deadline = time + $timeout;
while (1) {
my $response = $self->task_result($cms_ref);
my $state = $self->state_for_task($response, $task_id);
$state = 'unknown' unless defined $state && length $state;
$on_state->($state, $response) if $on_state;
my $norm = lc $state;
if ($success_states{$norm}) {
return {
success => 1,
state => $state,
response => $response,
};
}
if ($failure_states{$norm}) {
return {
success => 0,
state => $state,
response => $response,
};
}
if (!$init_states{$norm}) {
return {
success => 0,
state => $state,
response => $response,
};
}
my $remaining = $deadline - time;
croak "Timed out after $timeout seconds waiting for task $cms_ref"
if $remaining <= 0;
sleep($interval < $remaining ? $interval : $remaining);
}
}
# -------------------------------------------------------------------------
# Task helper: extract cmsActions from a task result response.
#
# Returns an arrayref of hashrefs with keys: action, state, result.
# If task_id is supplied, only actions for that task are returned.
# -------------------------------------------------------------------------
sub task_action_results {
my ($self, $snow_response, $task_id) = @_;
return [] unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks};
return [] unless ref $tasks eq 'ARRAY';
my @results;
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
if (defined $task_id) {
next unless defined $task->{id} && "$task->{id}" eq "$task_id";
}
my $actions = $task->{cmsActions};
next unless ref $actions eq 'ARRAY';
for my $action (@{$actions}) {
next unless ref $action eq 'HASH';
push @results, {
action => $action->{action},
state => $action->{state},
result => $action->{result},
};
}
}
return \@results;
}
# -------------------------------------------------------------------------
# Task helper: build a human-readable failure summary from cmsActions.
#
# Returns a string like "validate_metaData[failed]: ERROR:[Unknown NAR-ID]"
# suitable for appending to an error message.
# -------------------------------------------------------------------------
sub task_failure_summary {
my ($self, $snow_response, $task_id) = @_;
my $results = $self->task_action_results($snow_response, $task_id);
my @lines = map {
sprintf '%s[%s]: %s',
$_->{action} // '(unknown)',
$_->{state} // '(unknown)',
$_->{result} // ''
} @{$results};
return join '; ', grep { length } @lines;
}
# -------------------------------------------------------------------------
# Build CertDownloadReq.
#
# This is the JSON clarified in download_cert2.ans:
#
# {
# clientRef: "...",
# cmsTaskId: 123456,
# password: "...",
# keystoreFormat: "JKS",
# alias: "hostname"
# }
# -------------------------------------------------------------------------
sub build_download_payload {
my ($self, %args) = @_;
for my $field (qw(client_ref cms_task_id password)) {
croak "build_download_payload requires $field"
unless defined $args{$field} && length $args{$field};
}
croak 'cms_task_id must be a positive integer'
unless $args{cms_task_id} =~ /\A[1-9]\d*\z/;
my $format = uc($args{keystore_format} // 'JKS');
croak "Unsupported keystore_format: $format"
unless $format =~ /\A(?:JKS|PKCS12|PEM)\z/;
my $payload = {
clientRef => $args{client_ref},
cmsTaskId => int($args{cms_task_id}),
password => $args{password},
keystoreFormat => $format,
};
if (exists $args{aliases}) {
croak 'aliases must be an arrayref'
unless ref $args{aliases} eq 'ARRAY';
if (@{ $args{aliases} }) {
$payload->{alias} = @{ $args{aliases} } == 1
? $args{aliases}[0]
: $args{aliases};
}
}
elsif (defined $args{alias} && length $args{alias}) {
$payload->{alias} = $args{alias};
}
return $payload;
}
# -------------------------------------------------------------------------
# Convenience method: build download payload and call the endpoint.
# -------------------------------------------------------------------------
sub download_keystore {
my ($self, %args) = @_;
my $payload = $self->build_download_payload(%args);
return $self->download($payload);
}
# -------------------------------------------------------------------------
# Extract base64 text from a CertDownloadResp.
#
# openapi.json says the response can contain certificateContent,
# pkcs12Content, or keystoreContent. Which one is populated depends on
# requested format and backend behaviour.
# -------------------------------------------------------------------------
sub _download_base64_text {
my ($self, $response, $format) = @_;
croak 'download response must be a hashref'
unless ref $response eq 'HASH';
$format = uc($format // 'JKS');
my $text;
if ($format eq 'JKS') {
$text = $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
elsif ($format eq 'PKCS12') {
$text = $response->{pkcs12Content}{pkcs12}
if ref $response->{pkcs12Content} eq 'HASH';
$text //= $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
elsif ($format eq 'PEM') {
$text = $response->{certificateContent}{certificate}
if ref $response->{certificateContent} eq 'HASH';
$text //= $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
else {
croak "Unsupported keystore format: $format";
}
croak "Download response did not contain content for format $format"
unless defined $text && length $text;
return $text;
}
# -------------------------------------------------------------------------
# Decode downloaded content.
#
# The API returns base64 strings for binary content. This helper is tolerant
# of PEM-style armor as well:
#
# -----BEGIN CERTIFICATE-----
# ...
# -----END CERTIFICATE-----
#
# It strips BEGIN/END lines and whitespace before base64 decoding.
# -------------------------------------------------------------------------
sub extract_download_bytes {
my ($self, $response, $format) = @_;
my $text = $self->_download_base64_text($response, $format);
$text =~ s/^-----BEGIN [^-]+-----\s*//mg;
$text =~ s/^-----END [^-]+-----\s*//mg;
$text =~ s/\s+//g;
croak 'No base64 content remained after stripping armor/whitespace'
unless length $text;
my $bytes = decode_base64($text);
croak 'Base64 decode produced no bytes'
unless defined $bytes && length $bytes;
return $bytes;
}
# -------------------------------------------------------------------------
# Download and save decoded binary content to disk.
#
# For JKS and PKCS12 this writes binary keystore data.
# For PEM, this currently writes decoded bytes as requested: "base64 armour
# removed". If callers want textual PEM output later, add a separate method
# that preserves PEM armor.
#
# The output file is created with mode 0600 because it contains private key
# material.
# -------------------------------------------------------------------------
sub download_and_save {
my ($self, %args) = @_;
my $output_file = $args{output_file};
croak 'download_and_save requires output_file'
unless defined $output_file && length $output_file;
my $format = uc($args{keystore_format} // 'JKS');
my $response = $self->download_keystore(%args);
my $bytes = $self->extract_download_bytes($response, $format);
sysopen my $fh, $output_file, O_WRONLY | O_CREAT | O_TRUNC, 0600
or croak "Cannot open $output_file for writing: $!";
binmode $fh, ':raw'
or croak "Cannot set raw mode on $output_file: $!";
print {$fh} $bytes
or croak "Cannot write $output_file: $!";
close $fh
or croak "Cannot close $output_file: $!";
return {
output_file => $output_file,
bytes_written => length($bytes),
response => $response,
};
}
1;
__END__
=head1 NAME
CertHub::Client - Small Moo/Mojo::UserAgent client for CertHub certificate requests
=head1 SYNOPSIS
use CertHub::Client;
# Basic auth (development/UAT): pass auth_mode => 'basic' with username/password.
# OAuth (production): pass auth_mode => 'oauth' with token_url/username/password.
my $ch = CertHub::Client->new(
base_url => 'https://certhub.example.com',
auth_mode => 'basic',
username => $api_user,
password => $api_pass,
ca_file => '/etc/pki/tls/certs/ca-bundle.pem',
nar_id => '000000-0',
platform => 'DAP',
email_contact => 'CertificateManagement@example.com',
);
my $validation_payload = $ch->build_validation_payload(
host => 'myhost.example.com',
);
my $validation = $ch->validate_cert_request($validation_payload);
die "Validation failed\n" if $ch->has_validation_errors($validation);
my $newcert_payload = $ch->build_newcert_payload(
host => 'myhost.example.com',
download_password => $download_password,
);
my $newcert = $ch->onboard_newcert($newcert_payload);
# Poll the "onboard cert request" task — this is the one that transitions
# to "done" when the certificate is issued (~10-15 seconds).
# Do NOT poll the "provide results" task; that is a later notification task.
my $cms_task_id = $ch->onboard_task_id
or die "No onboard cert request task found\n";
my $poll = $ch->poll_task_until_final(
cms_ref => $cms_task_id,
task_id => $cms_task_id,
interval => 5,
timeout => 300,
on_state => sub {
my ($state, $response) = @_;
print "Current state: $state\n";
},
);
unless ($poll->{success}) {
my $detail = $ch->task_failure_summary($poll->{response}, $cms_task_id);
die "Certificate generation failed ($poll->{state}): $detail\n";
}
$ch->download_and_save(
client_ref => $ch->last_client_ref,
cms_task_id => $cms_task_id,
password => $download_password,
keystore_format => 'JKS',
alias => 'myhost.example.com',
output_file => 'myhost.example.com.jks',
);
=head1 DESCRIPTION
This module implements the CertHub flow described by the supplied design:
validate cert request
onboard new cert
poll task result
download JKS/PKCS12/PEM content
base64-decode and save
It uses Moo for object orientation and Mojo::UserAgent for HTTP.
For TLS behavior:
- modern per-UA insecure support is described in F<Mojo::UserAgent.txt>
- old Mojo limitations are described in F<old_Mojo::UserAgent.txt>
- the fallback TLS-disable mechanism is based on F<IO::Socket::SSL.txt>
The compatibility guidance and bug notes applied here are the ones called
out in F<out.txt>, and this file updates the original implementation from
F<CertHub/Client.pm> accordingly.
=cut
package CertHub::Client;
use Data::Dumper;
use JSON;
use strict;
use warnings;
# Moo gives us a small, modern OO layer without pulling in full Moose.
use Moo;
# Carp is used for caller-friendly exceptions.
use Carp qw(croak);
# Mojo::UserAgent is the required HTTP client for this module.
use Mojo::UserAgent;
# Mojo::URL is useful for safe URL/query manipulation.
use Mojo::URL;
# Mojo::Util provides URL escaping and base64 helpers for HTTP Basic auth.
use Mojo::Util qw(url_escape b64_encode);
# MIME::Base64 is used for decoding downloaded certificate/keystore content.
use MIME::Base64 qw(decode_base64);
# Time::HiRes allows fractional sleeps and reliable timeout calculations.
use Time::HiRes qw(time sleep);
# POSIX is used for a compact UTC timestamp in generated clientRef values.
use POSIX qw(strftime);
# Bytes from /dev/urandom are used for uniqueness in generated clientRef values.
use Fcntl qw(:DEFAULT);
# Needed for old-Mojo insecure fallback via IO::Socket::SSL.
use IO::Socket::SSL qw(SSL_VERIFY_NONE);
our $VERSION = '0.01';
my $OLD_MOJO_INSECURE_HACK_INSTALLED;
# -------------------------------------------------------------------------
# Constructor attributes: connection/authentication.
# -------------------------------------------------------------------------
has base_url => (
is => 'ro',
required => 1,
);
has token_url => (
is => 'ro',
);
has username => (
is => 'ro',
);
has password => (
is => 'ro',
);
has access_token => (
is => 'rw',
predicate => 'has_access_token',
);
has auth_mode => (
is => 'ro',
default => sub { 'oauth' }, # 'oauth' or 'basic'
);
has ca_file => (
is => 'ro',
);
has insecure => (
is => 'ro',
default => sub { 0 },
);
has connect_timeout => (
is => 'ro',
default => sub { 30 },
);
has request_timeout => (
is => 'ro',
default => sub { 120 },
);
# -------------------------------------------------------------------------
# Constructor attributes: useful payload defaults.
#
# These values are not required for the low-level request methods, but they
# make the helper payload builders much easier to use from small scripts.
# -------------------------------------------------------------------------
has nar_id => (
is => 'ro',
);
has platform => (
is => 'ro',
default => sub { 'DAP' },
);
has email_contact => (
is => 'ro',
);
has change_id => (
is => 'ro',
);
has cert_type => (
is => 'ro',
default => sub { 'gcp-ssl-client-server' },
);
has organisation => (
is => 'ro',
default => sub { 'Deutsche Bank AG' },
);
has org_unit => (
is => 'ro',
default => sub { 'PKI' },
);
# -------------------------------------------------------------------------
# Lifecycle attributes: populated after onboard_newcert() is called.
# These let callers (and polling loops) refer back to the submitted request
# without needing to parse the response themselves.
# -------------------------------------------------------------------------
has last_client_ref => (
is => 'rw',
);
has onboard_task_id => (
is => 'rw',
);
has provide_results_task_id => (
is => 'rw',
);
# -------------------------------------------------------------------------
# Old Mojo insecure compatibility.
#
# According to old_Mojo::UserAgent.txt, very old Mojo::UserAgent has neither
# the modern "insecure" attribute nor "tls_options". According to
# IO::Socket::SSL.txt, disabling verification can be forced with:
#
# SSL_verify_mode => SSL_VERIFY_NONE
# SSL_verifycn_scheme => 'none'
#
# set_args_filter_hack is process-global, so this is only an emergency
# fallback for old Mojo installs.
# -------------------------------------------------------------------------
sub _install_old_mojo_insecure_hack {
return if $OLD_MOJO_INSECURE_HACK_INSTALLED++;
IO::Socket::SSL::set_args_filter_hack(sub {
my ($is_server, $args) = @_;
# Only weaken client-side TLS.
return if $is_server;
# curl -k equivalent: disable chain and hostname verification.
$args->{SSL_verify_mode} = SSL_VERIFY_NONE;
$args->{SSL_verifycn_scheme} = 'none';
# Remove other verification-related settings that could interfere.
delete @{$args}{qw(
SSL_ca
SSL_ca_file
SSL_ca_path
SSL_verify_callback
SSL_fingerprint
SSL_force_fingerprint
)};
});
return;
}
# -------------------------------------------------------------------------
# Lazy Mojo::UserAgent construction.
#
# This centralizes TLS and timeout configuration. In modern Mojo,
# Mojo::UserAgent.txt documents "insecure" and "tls_options". In old Mojo,
# old_Mojo::UserAgent.txt shows neither, so we must fall back to
# IO::Socket::SSL::set_args_filter_hack as documented in IO::Socket::SSL.txt.
# -------------------------------------------------------------------------
has ua => (
is => 'lazy',
builder => '_build_ua',
);
sub _build_ua {
my ($self) = @_;
my $ua = Mojo::UserAgent->new;
$ua->connect_timeout($self->connect_timeout);
$ua->request_timeout($self->request_timeout);
if ($self->insecure) {
if ($ua->can('insecure')) {
# Modern Mojo::UserAgent, per Mojo::UserAgent.txt
$ua->insecure(1);
}
elsif ($ua->can('tls_options')) {
# Intermediate Mojo with tls_options support
$ua->tls_options({
SSL_verify_mode => SSL_VERIFY_NONE,
SSL_verifycn_scheme => 'none',
});
}
else {
# Very old Mojo::UserAgent, per old_Mojo::UserAgent.txt
_install_old_mojo_insecure_hack();
}
}
elsif (defined $self->ca_file && length $self->ca_file) {
# In old_Mojo::UserAgent.txt, "ca" is used for peer verification and
# also activates hostname verification, so only set it when not using
# insecure mode.
$ua->ca($self->ca_file);
}
return $ua;
}
# -------------------------------------------------------------------------
# URL helper.
#
# certomato.py configures a base URL ending in /certhub/v1/ and then appends
# endpoint paths such as certhub/api/application/onboard/newcert.
#
# This helper keeps that convention and avoids accidental double slashes.
# -------------------------------------------------------------------------
sub _url_for {
my ($self, $path) = @_;
croak 'path is required' unless defined $path && length $path;
my $base = $self->base_url;
$base =~ s{/+\z}{};
$path =~ s{\A/+}{};
return "$base/$path";
}
# -------------------------------------------------------------------------
# Response helper.
#
# old_Mojo::UserAgent.txt prominently shows transaction-level error handling
# using $tx->success / $tx->error. Modern Mojo::UserAgent.txt uses $tx->result
# more heavily. This helper stays friendly to older Mojo by checking
# transaction-level errors first, then returning a response object.
# -------------------------------------------------------------------------
sub _checked_res {
my ($self, $tx, $context) = @_;
croak "$context failed: no transaction returned"
unless defined $tx;
# Modern Mojo: $tx->result croaks on connection errors and returns the
# response (even 4xx/5xx) otherwise. Use it when available.
if ($tx->can('result')) {
my $res = $tx->result;
if (my $err = $res->error) {
my $status = $err->{code} // 'transport';
my $msg = $err->{message} // 'unknown error';
croak "$context failed: status=$status message=$msg body=" . ($res->body // '');
}
return $res;
}
# Old Mojo: $tx->success returns undef on any error.
if ($tx->can('success')) {
if (my $res = $tx->success) {
return $res;
}
my $err = $tx->error || {};
my $status = $err->{code} // 'transport';
my $msg = $err->{message} // 'unknown error';
my $body = ($tx->can('res') && $tx->res) ? ($tx->res->body // '') : '';
croak "$context failed: status=$status message=$msg body=$body";
}
croak "$context failed: unsupported Mojo::Transaction object";
}
# -------------------------------------------------------------------------
# OAuth2 client-credentials authentication.
#
# If the caller supplied access_token, use it directly. Otherwise request a
# token using the same basic idea as certomato.py:
#
# POST token_url
# HTTP Basic auth username:password
# form grant_type=client_credentials
#
# The token is retained on the object for subsequent API calls.
# -------------------------------------------------------------------------
sub authenticate {
my ($self) = @_;
return $self->access_token if $self->has_access_token && length $self->access_token;
for my $field (qw(token_url username password)) {
my $value = $self->$field;
croak "Cannot authenticate: $field is required unless access_token is supplied"
unless defined $value && length $value;
}
my $basic = b64_encode($self->username . ':' . $self->password, '');
my $tx = $self->ua->post(
$self->token_url
=> {
Authorization => "Basic $basic",
Accept => 'application/json',
}
=> form => {
grant_type => 'client_credentials',
}
);
my $res = $self->_checked_res($tx, 'Token request');
my $json = $res->json;
croak 'Token response was not valid JSON'
unless defined $json && ref $json eq 'HASH';
my $token = $json->{access_token};
croak 'Token response did not contain access_token'
unless defined $token && length $token;
$self->access_token($token);
return $token;
}
# -------------------------------------------------------------------------
# Authentication header helper.
#
# Selects Basic or Bearer auth based on the auth_mode attribute.
# auth_mode 'basic' uses the username/password attributes directly.
# auth_mode 'oauth' (default) calls authenticate() to obtain a bearer token.
# -------------------------------------------------------------------------
sub _auth_header {
my ($self) = @_;
my $mode = lc($self->auth_mode // 'oauth');
if ($mode eq 'basic') {
for my $field (qw(username password)) {
croak "Basic auth requires $field"
unless defined $self->$field && length $self->$field;
}
my $credentials = b64_encode($self->username . ':' . $self->password, '');
return "Basic $credentials";
}
if ($mode eq 'oauth') {
my $token = $self->authenticate;
return "Bearer $token";
}
croak "Unsupported auth_mode: $mode";
}
# -------------------------------------------------------------------------
# Generic JSON request helper.
#
# All normal CertHub calls go through this method. It:
#
# * selects auth via _auth_header (Basic or OAuth Bearer);
# * constructs the full URL;
# * sends JSON for POST requests;
# * sends query parameters for GET requests if payload is supplied;
# * throws detailed errors including the response body;
# * returns decoded JSON.
# -------------------------------------------------------------------------
sub request_json {
my ($self, $method, $path, $payload) = @_;
croak 'HTTP method is required' unless defined $method && length $method;
croak 'path is required' unless defined $path && length $path;
$method = uc $method;
$payload = {} unless defined $payload;
my $url = $self->_url_for($path);
my $headers = {
Accept => 'application/json',
Authorization => $self->_auth_header,
};
my $tx;
if ($method eq 'POST') {
$tx = $self->ua->post($url => $headers => json => $payload);
}
elsif ($method eq 'GET') {
my $u = Mojo::URL->new($url);
if (ref $payload eq 'HASH' && keys %{$payload}) {
$u->query($payload);
}
$tx = $self->ua->get($u => $headers);
}
else {
croak "Unsupported HTTP method: $method";
}
my $res = $self->_checked_res($tx, "$method $url");
my $json = $res->json;
croak "$method $url returned non-JSON response: " . ($res->body // '')
unless defined $json;
return $json;
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/validate/certrequest
#
# This maps to CertRequestValidationReq in openapi.json.
# -------------------------------------------------------------------------
sub validate_cert_request {
my ($self, $payload) = @_;
croak 'validate_cert_request requires a payload hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/validate/certrequest',
$payload,
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/application/onboard/newcert
#
# This maps to OnboardingReqNewCert in openapi.json and to create_certificate()
# in certomato.py.
# -------------------------------------------------------------------------
sub onboard_newcert {
my ($self, $payload) = @_;
croak 'onboard_newcert requires a payload hashref'
unless ref $payload eq 'HASH';
# Save clientRef from the payload before the HTTP call so it is available
# for recovery even if the request times out after the server accepted it.
$self->last_client_ref($payload->{clientRef})
if defined $payload->{clientRef} && length $payload->{clientRef};
# Clear stale lifecycle state from any previous request on this object.
$self->onboard_task_id(undef);
$self->provide_results_task_id(undef);
my $response = $self->request_json(
POST => 'certhub/api/application/onboard/newcert',
$payload,
);
# Update from response (authoritative); payload value is the fallback.
$self->last_client_ref($response->{clientRef})
if defined $response->{clientRef} && length $response->{clientRef};
$self->onboard_task_id($self->find_onboard_task_id($response));
$self->provide_results_task_id($self->find_provide_results_task_id($response));
return $response;
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# GET /certhub/task/result/{cmsRef}
#
# In the intended workflow, cmsRef is the id of the "onboard cert request"
# task. Poll this task to know when the certificate has been issued.
# The "provide results" task is a later notification task (~5 min) and is
# not needed to confirm certificate issuance.
# -------------------------------------------------------------------------
sub task_result {
my ($self, $cms_ref) = @_;
croak 'task_result requires cms_ref'
unless defined $cms_ref && length $cms_ref;
return $self->request_json(
GET => 'certhub/task/result/' . url_escape($cms_ref),
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# GET /certhub/task/resultByClientRef/{clientRef}
#
# This is useful for recovery/debugging if a caller has clientRef but not the
# task id.
# -------------------------------------------------------------------------
sub task_result_by_client_ref {
my ($self, $client_ref) = @_;
croak 'task_result_by_client_ref requires client_ref'
unless defined $client_ref && length $client_ref;
return $self->request_json(
GET => 'certhub/task/resultByClientRef/' . url_escape($client_ref),
);
}
# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
# POST /certhub/api/request/download
#
# This endpoint is documented in openapi.json and discussed in download_cert.ans
# and download_cert2.ans. certomato.py does not currently wrap it.
# -------------------------------------------------------------------------
sub download {
my ($self, $payload) = @_;
croak 'download requires a payload hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/request/download',
$payload,
);
}
# -------------------------------------------------------------------------
# Useful extra endpoint:
#
# POST /certhub/api/validate/domain
#
# certomato.py has this, and what_I_need_to_do.ans discusses it as a useful
# pre-check.
# -------------------------------------------------------------------------
sub validate_domain {
my ($self, $nar_id, $domains) = @_;
croak 'validate_domain requires nar_id'
unless defined $nar_id && length $nar_id;
croak 'validate_domain requires domains arrayref'
unless ref $domains eq 'ARRAY';
return $self->request_json(
POST => 'certhub/api/validate/domain',
{
narId => $nar_id,
domains => $domains,
},
);
}
# -------------------------------------------------------------------------
# Useful extra endpoint:
#
# POST /certhub/api/application/certificates
#
# This mirrors get_certificates() in certomato.py at a low level.
# -------------------------------------------------------------------------
sub query_certificates {
my ($self, $payload) = @_;
$payload = {} unless defined $payload;
croak 'query_certificates payload must be a hashref'
unless ref $payload eq 'HASH';
return $self->request_json(
POST => 'certhub/api/application/certificates',
$payload,
);
}
# -------------------------------------------------------------------------
# Metadata builder.
#
# This returns a MetaData structure as defined in openapi.json. Undefined
# values are omitted, because some fields such as changeId may not always be
# required in all environments.
# -------------------------------------------------------------------------
sub build_metadata {
my ($self, %args) = @_;
my %md;
my $nar_id = exists $args{nar_id} ? $args{nar_id} : $self->nar_id;
my $email_contact = exists $args{email_contact} ? $args{email_contact} : $self->email_contact;
my $platform = exists $args{platform} ? $args{platform} : $self->platform;
$md{narId} = $nar_id if defined $nar_id && length $nar_id;
$md{emailContact} = $email_contact if defined $email_contact && length $email_contact;
$md{platform} = $platform if defined $platform && length $platform;
# changeId is a top-level payload field, not a metaData field. See
# build_newcert_payload. Do not add it here.
return \%md;
}
# -------------------------------------------------------------------------
# Validation payload builder.
#
# openapi.json documents CertRequestValidationReq as:
#
# {
# certType: string,
# parameters: map<string,array<string>>,
# metaData: MetaData
# }
#
# The exact parameters can be profile-specific, so callers can pass their own
# parameters hashref. If not supplied, this helper creates a sensible minimal
# payload with commonName and dns values.
# -------------------------------------------------------------------------
sub build_validation_payload {
my ($self, %args) = @_;
my $host = $args{host};
croak 'build_validation_payload requires host'
unless defined $host && length $host;
my $cert_type = $args{cert_type} // $self->cert_type;
my $cn = $args{common_name} // $host;
my $sans = $args{sans} // [$host];
croak 'sans must be an arrayref'
unless ref $sans eq 'ARRAY';
my $parameters = $args{parameters};
if (defined $parameters) {
croak 'parameters must be a hashref'
unless ref $parameters eq 'HASH';
}
else {
$parameters = {
commonName => [$cn],
dns => $sans,
};
}
return {
certType => $cert_type,
parameters => $parameters,
metaData => $self->build_metadata(%args),
};
}
# -------------------------------------------------------------------------
# New certificate onboarding payload builder.
#
# This builds the payload for:
#
# POST /certhub/api/application/onboard/newcert
#
# It is oriented toward the download-only flow requested in what_I_need_to_do.ask:
# the generated key/cert should later be downloaded via API, not pushed by SSH.
#
# Therefore deploymentLocations is omitted unless explicitly supplied.
# -------------------------------------------------------------------------
sub build_newcert_payload {
my ($self, %args) = @_;
my $host = $args{host};
croak 'build_newcert_payload requires host'
unless defined $host && length $host;
my $download_password = $args{download_password};
croak 'build_newcert_payload requires download_password'
unless defined $download_password && length $download_password;
my $client_ref = $args{client_ref} // $self->generate_client_ref($host);
my $cert_type = $args{cert_type} // $self->cert_type;
my $cn = $args{common_name} // $host;
my $sans = $args{sans} // [$host];
croak 'sans must be an arrayref'
unless ref $sans eq 'ARRAY';
my $cert_request = {
certType => $cert_type,
commonName => $cn,
o => $args{organisation} // $self->organisation,
ou => $args{org_unit} // $self->org_unit,
sans => {
dns => $sans,
},
metaData => $self->build_metadata(%args),
downloadPassword => $download_password,
};
if (defined $args{task_ref} && length $args{task_ref}) {
$cert_request->{taskRef} = $args{task_ref};
}
if (exists $args{deployment_locations}) {
croak 'deployment_locations must be an arrayref'
unless ref $args{deployment_locations} eq 'ARRAY';
$cert_request->{deploymentLocations} = $args{deployment_locations};
}
my $payload = {
clientRef => $client_ref,
platform => $args{platform} // $self->platform,
onboardingCertRequests => [$cert_request],
};
my $change_id = exists $args{change_id} ? $args{change_id} : $self->change_id;
if (defined $change_id && length $change_id) {
$payload->{changeId} = $change_id;
}
return $payload;
}
# -------------------------------------------------------------------------
# Client reference generator.
#
# certomato.py generates unique clientRef strings using a prefix, date, and
# random bytes. This does the same kind of thing in Perl.
# -------------------------------------------------------------------------
sub generate_client_ref {
my ($self, $host) = @_;
$host //= 'unknown-host';
$host =~ s/[^A-Za-z0-9_.:-]+/-/g;
my $ts = strftime('%Y%m%d%H%M%S', gmtime);
my $rnd = _random_hex(4);
return "cert-request-$host-$ts-$rnd";
}
# -------------------------------------------------------------------------
# Small internal random hex helper.
# -------------------------------------------------------------------------
sub _random_hex {
my ($nbytes) = @_;
$nbytes ||= 4;
my $buf = '';
if (sysopen my $fh, '/dev/urandom', O_RDONLY) {
my $read = sysread $fh, $buf, $nbytes;
close $fh;
if (defined $read && $read == $nbytes) {
return unpack('H*', $buf);
}
}
# Fallback for unusual systems without /dev/urandom.
return sprintf '%08x', int(rand(0xffffffff));
}
# -------------------------------------------------------------------------
# Validation response helper.
#
# CertHub validation responses contain validationResults, a map of arrays of
# FieldValidationError objects. This helper returns the ones that are marked
# as errors.
# -------------------------------------------------------------------------
sub validation_errors {
my ($self, $validation_response) = @_;
return [] unless ref $validation_response eq 'HASH';
my $vr = $validation_response->{validationResults};
return [] unless ref $vr eq 'HASH';
my @errors;
for my $field (sort keys %{$vr}) {
my $items = $vr->{$field};
next unless ref $items eq 'ARRAY';
for my $item (@{$items}) {
next unless ref $item eq 'HASH';
if ($item->{error}) {
push @errors, {
field => $field,
code => $item->{code},
message => $item->{message},
raw => $item,
};
}
}
}
return \@errors;
}
# -------------------------------------------------------------------------
# Boolean validation helper.
# -------------------------------------------------------------------------
sub has_validation_errors {
my ($self, $validation_response) = @_;
return @{ $self->validation_errors($validation_response) } ? 1 : 0;
}
# -------------------------------------------------------------------------
# Task helper: find a CMS task by action regex.
#
# The newcert response is a SnowResp according to openapi.json, containing
# cmsTasks. design specifically says to find the id of the task whose action
# is "provide results".
# -------------------------------------------------------------------------
sub find_task_id_by_action {
my ($self, $snow_response, $regex) = @_;
croak 'find_task_id_by_action requires a response hashref'
unless ref $snow_response eq 'HASH';
croak 'find_task_id_by_action requires a regex'
unless ref $regex eq 'Regexp';
my $tasks = $snow_response->{cmsTasks} // [];
croak 'cmsTasks must be an arrayref'
unless ref $tasks eq 'ARRAY';
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
my $action = $task->{action} // '';
if ($action =~ $regex) {
return $task->{id};
}
}
return undef;
}
# -------------------------------------------------------------------------
# Task helper: find the "onboard cert request" task id.
# -------------------------------------------------------------------------
sub find_onboard_task_id {
my ($self, $snow_response) = @_;
return $self->find_task_id_by_action(
$snow_response,
qr/\bonboard\s+cert\s+request\b/i,
);
}
# -------------------------------------------------------------------------
# Task helper: find the "provide results" task id.
# -------------------------------------------------------------------------
sub find_provide_results_task_id {
my ($self, $snow_response) = @_;
return $self->find_task_id_by_action(
$snow_response,
qr/\bprovide\s+results\b/i,
);
}
# -------------------------------------------------------------------------
# Task helper: return normalized task state information.
#
# This is intentionally simple so monitor_cert_generation can print it easily.
# -------------------------------------------------------------------------
sub task_states {
my ($self, $snow_response) = @_;
return [] unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks} // [];
return [] unless ref $tasks eq 'ARRAY';
my @states;
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
push @states, {
id => $task->{id},
action => $task->{action},
state => $task->{state},
};
}
return \@states;
}
# -------------------------------------------------------------------------
# Task helper: get state for a specific task id.
#
# If the response only contains one task, this method falls back to that task.
# That makes it tolerant of both detailed and narrowed task-result responses.
# -------------------------------------------------------------------------
sub state_for_task {
my ($self, $snow_response, $task_id) = @_;
return undef unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks} // [];
return undef unless ref $tasks eq 'ARRAY';
if (defined $task_id) {
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
return $task->{state}
if defined $task->{id} && "$task->{id}" eq "$task_id";
}
}
if (@{$tasks} == 1 && ref $tasks->[0] eq 'HASH') {
return $tasks->[0]{state};
}
return undef;
}
# -------------------------------------------------------------------------
# Poll task until final state.
#
# This implements the monitoring behaviour requested in design:
# keep checking state until it is no longer init, then return success/failure.
#
# Callers can pass on_state => sub { my ($state, $response) = @_; ... }
# to print progress while polling.
# -------------------------------------------------------------------------
sub poll_task_until_final {
my ($self, %args) = @_;
my $cms_ref = $args{cms_ref};
croak 'poll_task_until_final requires cms_ref'
unless defined $cms_ref && length $cms_ref;
my $task_id = $args{task_id};
my $interval = $args{interval} // 30;
my $timeout = $args{timeout} // 1800;
my $on_state = $args{on_state};
croak 'on_state must be a coderef if supplied'
if defined $on_state && ref $on_state ne 'CODE';
my %init_states = map { lc($_) => 1 } @{ $args{init_states} // [qw(init running)] };
my %success_states = map { lc($_) => 1 } @{ $args{success_states} // ['done'] };
my %failure_states = map { lc($_) => 1 } @{ $args{failure_states} // [qw(failed failure error cancelled canceled)] };
my $deadline = time + $timeout;
while (1) {
my $response = $self->task_result($cms_ref);
my $state = $self->state_for_task($response, $task_id);
$state = 'unknown' unless defined $state && length $state;
$on_state->($state, $response) if $on_state;
my $norm = lc $state;
if ($success_states{$norm}) {
return {
success => 1,
state => $state,
response => $response,
};
}
if ($failure_states{$norm}) {
return {
success => 0,
state => $state,
response => $response,
};
}
if (!$init_states{$norm}) {
return {
success => 0,
state => $state,
response => $response,
};
}
my $remaining = $deadline - time;
croak "Timed out after $timeout seconds waiting for task $cms_ref"
if $remaining <= 0;
sleep($interval < $remaining ? $interval : $remaining);
}
}
# -------------------------------------------------------------------------
# Task helper: extract cmsActions from a task result response.
#
# Returns an arrayref of hashrefs with keys: action, state, result.
# If task_id is supplied, only actions for that task are returned.
# -------------------------------------------------------------------------
sub task_action_results {
my ($self, $snow_response, $task_id) = @_;
return [] unless ref $snow_response eq 'HASH';
my $tasks = $snow_response->{cmsTasks};
return [] unless ref $tasks eq 'ARRAY';
my @results;
for my $task (@{$tasks}) {
next unless ref $task eq 'HASH';
if (defined $task_id) {
next unless defined $task->{id} && "$task->{id}" eq "$task_id";
}
my $actions = $task->{cmsActions};
next unless ref $actions eq 'ARRAY';
for my $action (@{$actions}) {
next unless ref $action eq 'HASH';
push @results, {
action => $action->{action},
state => $action->{state},
result => $action->{result},
};
}
}
return \@results;
}
# -------------------------------------------------------------------------
# Task helper: build a human-readable failure summary from cmsActions.
#
# Returns a string like "validate_metaData[failed]: ERROR:[Unknown NAR-ID]"
# suitable for appending to an error message.
# -------------------------------------------------------------------------
sub task_failure_summary {
my ($self, $snow_response, $task_id) = @_;
my $results = $self->task_action_results($snow_response, $task_id);
my @lines = map {
sprintf '%s[%s]: %s',
$_->{action} // '(unknown)',
$_->{state} // '(unknown)',
$_->{result} // ''
} @{$results};
return join '; ', grep { length } @lines;
}
# -------------------------------------------------------------------------
# Build CertDownloadReq.
#
# This is the JSON clarified in download_cert2.ans:
#
# {
# clientRef: "...",
# cmsTaskId: 123456,
# password: "...",
# keystoreFormat: "JKS",
# alias: "hostname"
# }
# -------------------------------------------------------------------------
sub build_download_payload {
my ($self, %args) = @_;
for my $field (qw(client_ref cms_task_id password)) {
croak "build_download_payload requires $field"
unless defined $args{$field} && length $args{$field};
}
croak 'cms_task_id must be a positive integer'
unless $args{cms_task_id} =~ /\A[1-9]\d*\z/;
my $format = uc($args{keystore_format} // 'JKS');
croak "Unsupported keystore_format: $format"
unless $format =~ /\A(?:JKS|PKCS12|PEM)\z/;
my $payload = {
clientRef => $args{client_ref},
cmsTaskId => int($args{cms_task_id}),
password => $args{password},
keystoreFormat => $format,
};
if (exists $args{aliases}) {
croak 'aliases must be an arrayref'
unless ref $args{aliases} eq 'ARRAY';
if (@{ $args{aliases} }) {
$payload->{alias} = @{ $args{aliases} } == 1
? $args{aliases}[0]
: $args{aliases};
}
}
elsif (defined $args{alias} && length $args{alias}) {
$payload->{alias} = $args{alias};
}
return $payload;
}
# -------------------------------------------------------------------------
# Convenience method: build download payload and call the endpoint.
# -------------------------------------------------------------------------
sub download_keystore {
my ($self, %args) = @_;
my $payload = $self->build_download_payload(%args);
return $self->download($payload);
}
# -------------------------------------------------------------------------
# Extract base64 text from a CertDownloadResp.
#
# openapi.json says the response can contain certificateContent,
# pkcs12Content, or keystoreContent. Which one is populated depends on
# requested format and backend behaviour.
# -------------------------------------------------------------------------
sub _download_base64_text {
my ($self, $response, $format) = @_;
croak 'download response must be a hashref'
unless ref $response eq 'HASH';
$format = uc($format // 'JKS');
my $text;
if ($format eq 'JKS') {
$text = $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
elsif ($format eq 'PKCS12') {
$text = $response->{pkcs12Content}{pkcs12}
if ref $response->{pkcs12Content} eq 'HASH';
$text //= $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
elsif ($format eq 'PEM') {
$text = $response->{certificateContent}{certificate}
if ref $response->{certificateContent} eq 'HASH';
$text //= $response->{keystoreContent}{keystore}
if ref $response->{keystoreContent} eq 'HASH';
}
else {
croak "Unsupported keystore format: $format";
}
croak "Download response did not contain content for format $format"
unless defined $text && length $text;
return $text;
}
# -------------------------------------------------------------------------
# Decode downloaded content.
#
# The API returns base64 strings for binary content. This helper is tolerant
# of PEM-style armor as well:
#
# -----BEGIN CERTIFICATE-----
# ...
# -----END CERTIFICATE-----
#
# It strips BEGIN/END lines and whitespace before base64 decoding.
# -------------------------------------------------------------------------
sub extract_download_bytes {
my ($self, $response, $format) = @_;
my $text = $self->_download_base64_text($response, $format);
$text =~ s/^-----BEGIN [^-]+-----\s*//mg;
$text =~ s/^-----END [^-]+-----\s*//mg;
$text =~ s/\s+//g;
croak 'No base64 content remained after stripping armor/whitespace'
unless length $text;
my $bytes = decode_base64($text);
croak 'Base64 decode produced no bytes'
unless defined $bytes && length $bytes;
return $bytes;
}
# -------------------------------------------------------------------------
# Download and save decoded binary content to disk.
#
# For JKS and PKCS12 this writes binary keystore data.
# For PEM, this currently writes decoded bytes as requested: "base64 armour
# removed". If callers want textual PEM output later, add a separate method
# that preserves PEM armor.
#
# The output file is created with mode 0600 because it contains private key
# material.
# -------------------------------------------------------------------------
sub download_and_save {
my ($self, %args) = @_;
my $output_file = $args{output_file};
croak 'download_and_save requires output_file'
unless defined $output_file && length $output_file;
my $format = uc($args{keystore_format} // 'JKS');
my $response = $self->download_keystore(%args);
my $bytes = $self->extract_download_bytes($response, $format);
sysopen my $fh, $output_file, O_WRONLY | O_CREAT | O_TRUNC, 0600
or croak "Cannot open $output_file for writing: $!";
binmode $fh, ':raw'
or croak "Cannot set raw mode on $output_file: $!";
print {$fh} $bytes
or croak "Cannot write $output_file: $!";
close $fh
or croak "Cannot close $output_file: $!";
return {
output_file => $output_file,
bytes_written => length($bytes),
response => $response,
};
}
1;
__END__
=head1 NAME
CertHub::Client - Small Moo/Mojo::UserAgent client for CertHub certificate requests
=head1 SYNOPSIS
use CertHub::Client;
# Basic auth (development/UAT): pass auth_mode => 'basic' with username/password.
# OAuth (production): pass auth_mode => 'oauth' with token_url/username/password.
my $ch = CertHub::Client->new(
base_url => 'https://certhub.example.com',
auth_mode => 'basic',
username => $api_user,
password => $api_pass,
ca_file => '/etc/pki/tls/certs/ca-bundle.pem',
nar_id => '000000-0',
platform => 'DAP',
email_contact => 'CertificateManagement@example.com',
);
my $validation_payload = $ch->build_validation_payload(
host => 'myhost.example.com',
);
my $validation = $ch->validate_cert_request($validation_payload);
die "Validation failed\n" if $ch->has_validation_errors($validation);
my $newcert_payload = $ch->build_newcert_payload(
host => 'myhost.example.com',
download_password => $download_password,
);
my $newcert = $ch->onboard_newcert($newcert_payload);
# Poll the "onboard cert request" task — this is the one that transitions
# to "done" when the certificate is issued (~10-15 seconds).
# Do NOT poll the "provide results" task; that is a later notification task.
my $cms_task_id = $ch->onboard_task_id
or die "No onboard cert request task found\n";
my $poll = $ch->poll_task_until_final(
cms_ref => $cms_task_id,
task_id => $cms_task_id,
interval => 5,
timeout => 300,
on_state => sub {
my ($state, $response) = @_;
print "Current state: $state\n";
},
);
unless ($poll->{success}) {
my $detail = $ch->task_failure_summary($poll->{response}, $cms_task_id);
die "Certificate generation failed ($poll->{state}): $detail\n";
}
$ch->download_and_save(
client_ref => $ch->last_client_ref,
cms_task_id => $cms_task_id,
password => $download_password,
keystore_format => 'JKS',
alias => 'myhost.example.com',
output_file => 'myhost.example.com.jks',
);
=head1 DESCRIPTION
This module implements the CertHub flow described by the supplied design:
validate cert request
onboard new cert
poll task result
download JKS/PKCS12/PEM content
base64-decode and save
It uses Moo for object orientation and Mojo::UserAgent for HTTP.
For TLS behavior:
- modern per-UA insecure support is described in F<Mojo::UserAgent.txt>
- old Mojo limitations are described in F<old_Mojo::UserAgent.txt>
- the fallback TLS-disable mechanism is based on F<IO::Socket::SSL.txt>
The compatibility guidance and bug notes applied here are the ones called
out in F<out.txt>, and this file updates the original implementation from
F<CertHub/Client.pm> accordingly.
=cut
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib $FindBin::Dir;
use CertHub::Client;
sub fail {
my ($msg) = @_;
chomp $msg;
warn "ERROR: $msg\n";
exit 1;
}
sub required_env {
my ($name) = @_;
my $value = $ENV{$name};
fail("Missing $name") unless defined $value && length $value;
return $value;
}
my $VERSION = '1.1.0';
eval { run() };
fail($@) if $@;
sub run {
print "cert_request.pl v$VERSION\n";
my $base_url = required_env('BASE_URL');
my $user = required_env('USR');
my $pass = required_env('PW');
my $host = required_env('HOST');
my $download_pw = required_env('DOWNLOAD_PW');
my $nar_id = required_env('NAR_ID');
my $email = required_env('EMAIL');
my $cert_type = $ENV{CERT_TYPE} // 'gcp-ssl-client-server';
my $poll_interval = $ENV{POLL_INTERVAL} // 5;
my $poll_timeout = $ENV{POLL_TIMEOUT} // 300;
my $insecure = ($ENV{INSECURE} ? 1 : 0);
my $keystore_format = uc($ENV{KEYSTORE_FORMAT} // 'JKS');
$keystore_format = 'PKCS12' if $keystore_format eq 'P12';
fail('KEYSTORE_FORMAT must be one of JKS, PKCS12, PEM')
unless $keystore_format =~ /\A(?:JKS|PKCS12|PEM)\z/;
my %ext_map = (JKS => 'jks', PKCS12 => 'p12', PEM => 'pem');
my $output_file = $ENV{OUTPUT_FILE} // ($host . '.' . $ext_map{$keystore_format});
# Build the SAN list: HOST is always first; SANS adds extra hostnames.
# Trim each entry and de-duplicate case-insensitively.
my @extra_sans = map { s/\A\s+//; s/\s+\z//; $_ }
split /,/, ($ENV{SANS} // '');
my %seen;
my @sans = grep { length $_ && !$seen{lc $_}++ } ($host, @extra_sans);
my $sans = \@sans;
my $ch = CertHub::Client->new(
base_url => $base_url,
auth_mode => 'basic',
username => $user,
password => $pass,
insecure => $insecure,
nar_id => $nar_id,
email_contact => $email,
cert_type => $cert_type,
);
printf "Submitting cert request for %s ...\n", join(', ', @$sans);
my $payload = $ch->build_newcert_payload(
host => $host,
sans => $sans,
download_password => $download_pw,
);
my $response = $ch->onboard_newcert($payload);
my $task_id = $ch->onboard_task_id
or die "Response contained no 'onboard cert request' task\n";
printf "Submitted. clientRef=%s onboard_task_id=%s\n",
$ch->last_client_ref // '(unknown)',
$task_id;
print "Polling for completion...\n";
my $poll = $ch->poll_task_until_final(
cms_ref => $task_id,
task_id => $task_id,
interval => $poll_interval,
timeout => $poll_timeout,
on_state => sub {
my ($state) = @_;
print " state: $state\n";
},
);
unless ($poll->{success}) {
my $detail = $ch->task_failure_summary($poll->{response}, $task_id);
my $msg = "Certificate generation ended with state '$poll->{state}'";
$msg .= ": $detail" if length $detail;
die "$msg\n";
}
print "Certificate generated. Downloading...\n";
my $saved = $ch->download_and_save(
client_ref => $ch->last_client_ref,
cms_task_id => $task_id,
password => $download_pw,
keystore_format => $keystore_format,
alias => $host,
output_file => $output_file,
);
printf "Saved %d bytes to %s\n", $saved->{bytes_written}, $saved->{output_file};
}
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib $FindBin::Dir;
use CertHub::Client;
sub fail {
my ($msg) = @_;
chomp $msg;
warn "ERROR: $msg\n";
exit 1;
}
sub required_env {
my ($name) = @_;
my $value = $ENV{$name};
fail("Missing $name") unless defined $value && length $value;
return $value;
}
my $VERSION = '1.1.0';
# eval/fail is a top-level catch-and-die. $@ is printed to stderr, which is
# the appropriate log channel for a CLI tool. Note that Carp::longmess or
# Devel::StackTrace could be used here if stack traces become necessary for
# diagnosis; for now the propagated croak message is sufficient.
eval { run() };
fail($@) if $@;
sub run {
print "cert_request.pl v$VERSION\n";
my $base_url = required_env('BASE_URL');
my $user = required_env('USR');
my $pass = required_env('PW');
my $host = required_env('HOST');
my $download_pw = required_env('DOWNLOAD_PW');
my $nar_id = required_env('NAR_ID');
my $email = required_env('EMAIL');
my $cert_type = $ENV{CERT_TYPE} // 'gcp-ssl-client-server';
my $poll_interval = $ENV{POLL_INTERVAL} // 5;
my $poll_timeout = $ENV{POLL_TIMEOUT} // 300;
my $insecure = ($ENV{INSECURE} ? 1 : 0);
my $keystore_format = uc($ENV{KEYSTORE_FORMAT} // 'JKS');
$keystore_format = 'PKCS12' if $keystore_format eq 'P12';
fail('KEYSTORE_FORMAT must be one of JKS, PKCS12, PEM')
unless $keystore_format =~ /\A(?:JKS|PKCS12|PEM)\z/;
my %ext_map = (JKS => 'jks', PKCS12 => 'p12', PEM => 'pem');
my $output_file = $ENV{OUTPUT_FILE} // ($host . '.' . $ext_map{$keystore_format});
# Build the SAN list: HOST is always first; SANS adds extra hostnames.
# Trim each entry and de-duplicate case-insensitively.
my @extra_sans = map { s/\A\s+//; s/\s+\z//; $_ }
split /,/, ($ENV{SANS} // '');
my %seen;
my @sans = grep { length $_ && !$seen{lc $_}++ } ($host, @extra_sans);
my $sans = \@sans;
my $ch = CertHub::Client->new(
base_url => $base_url,
auth_mode => 'basic',
username => $user,
password => $pass,
insecure => $insecure,
nar_id => $nar_id,
email_contact => $email,
cert_type => $cert_type,
);
printf "Submitting cert request for %s ...\n", join(', ', @$sans);
my $payload = $ch->build_newcert_payload(
host => $host,
sans => $sans,
download_password => $download_pw,
);
my $response = $ch->onboard_newcert($payload);
my $task_id = $ch->onboard_task_id
or die "Response contained no 'onboard cert request' task\n";
printf "Submitted. clientRef=%s onboard_task_id=%s\n",
$ch->last_client_ref // '(unknown)',
$task_id;
print "Polling for completion...\n";
my $poll = $ch->poll_task_until_final(
cms_ref => $task_id,
task_id => $task_id,
interval => $poll_interval,
timeout => $poll_timeout,
on_state => sub {
my ($state) = @_;
print " state: $state\n";
},
);
unless ($poll->{success}) {
my $detail = $ch->task_failure_summary($poll->{response}, $task_id);
my $msg = "Certificate generation ended with state '$poll->{state}'";
$msg .= ": $detail" if length $detail;
die "$msg\n";
}
print "Certificate generated. Downloading...\n";
my $saved = $ch->download_and_save(
client_ref => $ch->last_client_ref,
cms_task_id => $task_id,
password => $download_pw,
keystore_format => $keystore_format,
alias => $host,
output_file => $output_file,
);
printf "Saved %d bytes to %s\n", $saved->{bytes_written}, $saved->{output_file};
}
Closed. required_env() already checks both defined and length, so empty strings are correctly rejected. The deferred note was wrong.
BASE_URL= passes the // check. A proper env_required() helper that also rejects empty strings was not added; deferred as usage is controlled.
Client.pm supports OAuth (auth_mode => oauth, token_url, username, password) but cert_request.pl has no TOKEN_URL / AUTH_MODE env-var path. Deferred until OAuth credentials are available for testing. When implementing: read AUTH_MODE env var (default basic); for oauth read TOKEN_URL, CLIENT_ID, CLIENT_SECRET; pass auth_mode/token_url/username/password to CertHub::Client->new.
A single Client object cannot safely manage two concurrent cert requests because onboard_task_id etc. are overwritten on each call. Suggested fix: add parse_onboard_response() helper returning structured data without mutating the object. Deferred — current usage is single-request per script invocation.
download_and_save always base64-decodes. For PEM callers usually expect textual -----BEGIN CERTIFICATE----- output. Suggested: add preserve_pem_text => 1 option to extract_download_content(). Deferred until PEM format is actually needed.