#!/usr/bin/env perl -w
########################################################################
# refresh-ros-certs - Automatically mint fresh TLS certificates for a
# set of RouterOS devices defined in %kRouters below.
#
# Calls on the services of a RouterOS device with the CNAME 'ca',
# which may or may not be in the defined router set.
#
# Copyright © 2025 by Warren Young. Licenced under CC-BY-SA 4.0.
########################################################################
use strict;
use v5.26;
use Readonly;
#### CONFIGURABLES #####################################################
Readonly::Scalar our $kValidDays => 35;
Readonly::Scalar our $kDomain => 'example.com';
Readonly::Scalar our $kCountry => 'ZZ';
Readonly::Scalar our $kState => 'AA';
Readonly::Scalar our $kLocality => 'Anytown';
Readonly::Scalar our $kOrganization => 'Networks R Us';
Readonly::Scalar our $kSignCert => $kOrganization . 'CA';
Readonly::Scalar our $kUnit => 'Network Core Ops';
Readonly::Scalar our $kBackupDir => '~/network-config';
Readonly::Hash our %kRouters = (
MyRouter => {
cnames => [
'gw',
'rb5009',
],
ipaddr => '192.168.88.1',
},
MySwitch => {
cnames => [
'core-switch',
'crs310',
],
ipaddr => '192.168.88.10',
},
);
#### WARRANTY VOID IF SEAL IS BROKEN ###################################
my ($sec, $min, $hour, $mday, $month, $year) = localtime();
my $ts = sprintf("%04d-%02d", $year + 1900, $month + 1);
Readonly::Array our @kPWAlphabet => ('A'..'Z','a'..'z',0..9);
Readonly::Scalar our $kPWLength => 16;
Readonly::Scalar our $kPassphrase =>
join '', map { $kPWAlphabet[rand @kPWAlphabet] } 1..$kPWLength;
print "Generating certificates under passphrase '$kPassphrase'…\n";
for my $r (keys %kRouters) {
my $ipaddr = $kRouters{$r}{ipaddr};
my @cnames = @{$kRouters{$r}{cnames}};
unshift @cnames, $r;
my $san = join(',', map { "DNS:$_.$kDomain" } @cnames) . ',';
$san .= join(',', map { "DNS:$_" } @cnames) . ',';
$san .= 'IP:' . $ipaddr;
my $name = $r . '-TLS-' . $ts;
my %attrs = (
'name' => $name,
'subject-alt-name' => $san,
'country' => $kCountry,
'state' => $kState,
'locality' => $kLocality,
'organization' => $kOrganization,
'unit' => $kUnit,
'trusted' => 'yes',
'key-usage' => 'tls-server',
'days-valid' => $kValidDays,
);
print "Minting certificate for $r…\n";
my $cmd = "/certificate/add " .
join(' ', map {
$_ . '=' . '"' . $attrs{$_} . '"'
} keys %attrs);
system("ssh ca '$cmd'") == 0 or die "Minting failed, CMD: $cmd!\n";
print "Signing minted certificate…\n";
$cmd = "/certificate/sign " . $name . ' ca="' . $kSignCert . '"';
system("ssh ca '$cmd'") == 0 or die "Signing failed, CMD: $cmd!\n";
%attrs = (
'export-passphrase' => $kPassphrase,
'file-name' => $name,
);
print "Exporting signed certificate…\n";
$cmd = "/certificate/export-certificate " . $name . ' ' .
join(' ', map {
$_ . '=' . '"' . $attrs{$_} . '"'
} keys %attrs);
system("ssh ca '$cmd'") == 0 or die "Export failed, CMD: $cmd!\n";
my $bdir = $kBackupDir . '/' . lc($r);
print "Copying signed cert halves to $bdir/tls.*…\n";
system("scp ca:$name.crt $bdir/tls.crt") == 0
or die "Failed to copy public certificate!\n";
system("scp ca:$name.key $bdir/tls.key") == 0
or die "Failed to copy private cert key!\n";
print "Copying refreshed cert halves to $r…\n";
system("scp $bdir/tls.* $r:") == 0 or die "Failed to copy cert halves to $r!\n";
%attrs = (
'file-name' => 'tls.crt',
'name' => $name,
'passphrase' => $kPassphrase,
'trusted' => 'yes',
);
print "Importing refreshed certificate on $r…\n";
$cmd = "/certificate/import " .
join(' ', map {
$_ . '=' . '"' . $attrs{$_} . '"'
} keys %attrs);
system("ssh $r '$cmd'") == 0 or die "Public cert import failed, CMD: $cmd!\n";
$cmd =~ s{\.crt}{.key}x;
system("ssh $r '$cmd'") == 0 or die "Private key import failed, CMD: $cmd!\n";
print "Switching services on $r to new certs…\n";
system("ssh $r '/ip/service/set api-ssl certificate=\"" . $name . '"\'"')
or die "Failed to switch REST API to new TLS cert!\n";
system("ssh $r '/ip/service/set www-ssl certificate=\"" . $name . '"\'"')
or die "Failed to switch WebFig to new TLS cert!\n";
}