diff --git a/README.md b/README.md index ef4361b..2faf835 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ An interactive X.509 Certificate Authority (CA) management tool for issuing, rev - Package certificates for end-user distribution (ZIP, OpenVPN inline config) - Generate Diffie-Hellman parameters - Available in both Perl (original) and Python 3 (rewrite with bug fixes) implementations +- Full batch/non-interactive CLI mode for scripting and automation ## Requirements @@ -110,7 +111,8 @@ KEY_DIR/ ```sh ssl-admin # Launch interactive menu -ssl-admin crl # Non-interactive: regenerate CRL and exit +ssl-admin crl # Non-interactive: regenerate CRL and exit (legacy) +ssl-admin [options] # Batch mode ``` ### Main Menu @@ -133,6 +135,86 @@ ssl-admin crl # Non-interactive: regenerate CRL and exit | `C` | Regenerate the Certificate Revocation List | | `q` | Quit | +## Batch Mode + +ssl-admin supports non-interactive operation for use in scripts, cron jobs, and automation pipelines. Pass a subcommand and options directly on the command line; the tool runs that one operation and exits. + +```sh +ssl-admin [options] +``` + +The CA must already be initialized (run interactively at least once) before using batch mode. + +### Subcommands + +| Subcommand | Description | +|-----------|-------------| +| `create-csr` | Create a new Certificate Signing Request | +| `sign` | Sign an existing CSR | +| `create-sign` | One-step: create and sign a certificate | +| `revoke` | Revoke a certificate | +| `renew` | Renew/re-sign a previously issued certificate | +| `view-crl` | Display the Certificate Revocation List | +| `index` | Show the index entry for a certificate | +| `inline` | Generate an OpenVPN inline config | +| `zip` | Package certificate files into a ZIP | +| `dh` | Generate Diffie-Hellman parameters | +| `new-ca` | Create a new self-signed CA certificate | +| `server` | One-step: create and sign a server certificate | +| `options` | Set runtime options (days, key size, intermediate CA) | +| `gen-crl` | Regenerate the Certificate Revocation List | +| `crl` | Alias for `gen-crl` (legacy compatibility) | + +### Common Options + +| Option | Applies to | Description | +|--------|-----------|-------------| +| `--cn NAME` | most commands | Certificate Common Name (required for cert operations) | +| `--password` | `create-csr`, `create-sign`, `server`, `new-ca` | Password-protect the private key | +| `--overwrite` | `create-csr`, `create-sign`, `server` | Overwrite an existing certificate (default: error) | +| `--archive-csr` / `--no-archive-csr` | `sign`, `create-sign`, `renew`, `server` | Control CSR archiving after signing | +| `--openvpn` / `--no-openvpn` | `zip` | Include OpenVPN client config in ZIP | +| `--days N` | `options`, `new-ca` | Certificate validity in days | +| `--size N` | `options`, `new-ca` | RSA key size in bits (max 4096) | +| `--intermediate` | `options` | Enable intermediate CA certificate signing | + +Run `ssl-admin --help` or `ssl-admin --help` for full option details. + +### Examples + +```sh +# Issue a client certificate +ssl-admin create-sign --cn jdoe + +# Issue a server certificate +ssl-admin server --cn webserver.example.com + +# Revoke a certificate and update the CRL +ssl-admin revoke --cn jdoe + +# Renew a certificate +ssl-admin renew --cn jdoe + +# Regenerate the CRL (e.g. from cron) +ssl-admin gen-crl + +# Package a certificate for distribution +ssl-admin zip --cn jdoe --openvpn + +# Overwrite an existing certificate +ssl-admin create-sign --cn jdoe --overwrite + +# Create a CA cert with custom validity and key size +ssl-admin new-ca --cn "My Root CA" --days 3650 --size 4096 +``` + +### Cron Example + +```cron +# Regenerate CRL weekly +0 3 * * 0 /usr/local/bin/ssl-admin gen-crl +``` + ## Running Tests The test suite uses `pytest` and validates both the Python implementation and behavioral parity between the Perl and Python versions. diff --git a/perl/ssl-admin b/perl/ssl-admin index 277e43e..a1f6a13 100755 --- a/perl/ssl-admin +++ b/perl/ssl-admin @@ -4,17 +4,17 @@ # # All rights reserved. # -# Redistribution and use in source and binary forms, with or -# without modification, are permitted provided that the following +# Redistribution and use in source and binary forms, with or +# without modification, are permitted provided that the following # conditions are met: # -# Redistributions of source code must retain the above copyright +# Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. -# -# Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in +# +# Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the distribution. -# +# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR @@ -33,6 +33,7 @@ use strict; use warnings; use File::Copy; +use Getopt::Long qw(:config pass_through); ## Read config file and die if there's a syntax error. my $config_file = "~~~ETCDIR~~~/ssl-admin/ssl-admin.conf"; @@ -63,15 +64,44 @@ my $intermediate = "NO"; my $key_size = $ENV{'KEY_SIZE'}; my $serial; -# Gather project information and run-time variables +### Batch mode state ### +my $batch_mode = 0; +my %batch_opts = (); + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- sub update_serial { chomp($curr_serial = `cat $working_dir/prog/serial`); } +# yn_prompt_or_default($prompt, $batch_key, $batch_default) +# In batch mode: return batch_opts{batch_key} if set, else batch_default. +# In interactive mode: prompt the user for y/n. +sub yn_prompt_or_default { + my ($prompt, $batch_key, $batch_default) = @_; + if ($batch_mode) { + my $val = $batch_opts{$batch_key}; + return defined($val) ? $val : $batch_default; + } + my $yn; + do { + print $prompt; + chomp($yn = <>); + } until $yn =~ m/^[yn]$/; + return $yn; +} + sub common_name { my $cn_regex = '^[\w\s\-\.]+$'; my $cn_new; + if ($batch_mode && $batch_opts{cn}) { + $cn_literal = $batch_opts{cn}; + ($cn = $cn_literal) =~ s/\s/_/g; + $ENV{'KEY_CN'} = $cn_literal; + return; + } do { print "Please enter certificate owner's name or ID.\nUsual format is first initial-last name (jdoe) or\n"; print "hostname of server which will use this certificate.\nAll lower case, numbers OK.\nOwner [$cn]: "; @@ -89,6 +119,17 @@ sub project_info { my $key_days_new; my $intermediate_new; if ($new_runtime == 1){ + if ($batch_mode) { + if (defined $batch_opts{days} && $batch_opts{days} ne '') { + $key_days = $batch_opts{days}; + $ENV{'KEY_DAYS'} = $key_days; + } + if (defined $batch_opts{size} && $batch_opts{size} ne '') { + $key_size = $batch_opts{size}; + } + $intermediate = $batch_opts{intermediate} ? "YES" : "NO"; + return; + } print "Number of days key is valid for [$key_days]: "; chomp($key_days_new = <>); if (($key_days_new ne "$ENV{'KEY_DAYS'}") and ($key_days_new ne "")){ @@ -106,39 +147,42 @@ sub project_info { print "\nTurn on Intermediate CA certificate signing? (y/n): "; chomp($intermediate_new = <>); } until $intermediate_new =~ m/^[yn]+$/; - if ($intermediate_new eq "y"){ - $intermediate = "YES"; + if ($intermediate_new eq "y"){ + $intermediate = "YES"; print "\nYou have enabled intermediate certificate signing! This means\n"; print "any certicates you sign this session will be authorized to sign\n"; print "new certificates which will be trusted by you and anyone who\n"; print "trusts you. This option is generally not going to be used.\n"; } - elsif ($intermediate_new eq "n"){ - $intermediate = "NO"; + elsif ($intermediate_new eq "n"){ + $intermediate = "NO"; print "Intermediate CA certificate signing is disabled."; } } } -# Things are kinda crazy, so we're going to functionalize some things. +# --------------------------------------------------------------------------- +# Core operations +# --------------------------------------------------------------------------- sub create_csr { my $yn = ""; common_name(); if ( -e "$working_dir/active/$cn.crt"){ print "$cn already has a key. Creating another one will overwrite the existing key.\n"; - do { - print "$cn already has an active key. Do you want to overwrite? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + $yn = yn_prompt_or_default( + "$cn already has an active key. Do you want to overwrite? (y/n): ", + 'overwrite', 'n'); if ($yn eq "n"){ + if ($batch_mode) { + die "Error: certificate for '$cn' already exists. Use --overwrite to replace it.\n"; + } common_name(); } } - do { - print "Would you like to password protect the private key (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + $yn = yn_prompt_or_default( + "Would you like to password protect the private key (y/n): ", + 'password', 'n'); if ($yn eq "y") { system("cd $working_dir && openssl req -new -keyout $cn.key -out $cn.csr -config $key_config -batch -extensions v3_req"); } elsif ($yn eq "n") { @@ -163,28 +207,28 @@ sub sign_csr { system("cp $working_dir/$cn.key $working_dir/csr/"); system("mv $working_dir/$cn.key $working_dir/active/"); system("mv $working_dir/active/$curr_serial.pem $working_dir/active/$cn.pem"); - my $yn = ""; - do { - print "Can I move signing request ($cn.csr) to the csr directory for archiving? (y/n): "; - if ($menu_item != 4){ - chomp($yn = <>); - } else { - $yn = 'y'; - } - } until $yn =~ m/^[yn]$/; + + my $yn; + if ($menu_item == 4 || $batch_mode) { + $yn = 'y'; + } else { + $yn = yn_prompt_or_default( + "Can I move signing request ($cn.csr) to the csr directory for archiving? (y/n): ", + 'archive_csr', 'y'); + } if ($yn eq "y"){ `mv $working_dir/$cn.csr $working_dir/csr/`; print "===> $cn.csr moved.\n" } else { print "You will need to move $working_dir/$cn.csr to $working_dir/csr/$cn.csr manually!"; } } + sub new_ca { my $yn; common_name(); print "\n\n===> Creating private key with $key_size bits and generating request.\n"; - do { - print "Do you want to password protect your CA private key? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + $yn = yn_prompt_or_default( + "Do you want to password protect your CA private key? (y/n): ", + 'password', 'n'); if ($yn eq "y") { system("cd $working_dir && openssl genrsa -des3 -out $cn.key $key_size"); } else { @@ -203,47 +247,48 @@ sub create_server { common_name(); if ( -e "$working_dir/active/$cn.crt"){ print "$cn already has a key. Creating another one will overwrite the existing key.\n"; - do { - print "$cn already has an active key. Do you want to overwrite? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + $yn = yn_prompt_or_default( + "$cn already has an active key. Do you want to overwrite? (y/n): ", + 'overwrite', 'n'); if ($yn eq "n") { + if ($batch_mode) { + die "Error: certificate for '$cn' already exists. Use --overwrite to replace it.\n"; + } $cn_o = 1; project_info(); } } - do { - print "Would you like to password protect the private key (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + $yn = yn_prompt_or_default( + "Would you like to password protect the private key (y/n): ", + 'password', 'n'); if ($yn eq "y") { system("cd $working_dir && openssl req -extensions server -new -keyout $cn.key -out $cn.csr -config $key_config -batch"); } elsif ($yn eq "n"){ system("cd $working_dir && openssl req -extensions server -nodes -new -keyout $cn.key -out $cn.csr -config $key_config -batch"); } } + sub sign_server { update_serial(); print "===> Serial Number = $curr_serial\n"; - `cd $working_dir && openssl ca -config $key_config -extensions server -days $key_days -out $cn.crt -in $cn.csr -batch`; + `cd $working_dir && openssl ca -config $key_config -extensions server -days $key_days -out $cn.crt -in $cn.csr -batch`; if ($? != 0){ die "There was an error during openssl execution. Please look for error messages above."; } print "=========> Moving certificates and keys to $working_dir/active for production.\n"; system("mv $working_dir/$cn.crt $working_dir/active/"); system("cp $working_dir/$cn.key $working_dir/csr/"); system("mv $working_dir/$cn.key $working_dir/active/"); system("mv $working_dir/active/$curr_serial.pem $working_dir/active/$cn.pem"); - my $yn = ""; - do { - print "Can I move signing request ($cn.csr) to the csr directory for archiving? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; + + my $yn = yn_prompt_or_default( + "Can I move signing request ($cn.csr) to the csr directory for archiving? (y/n): ", + 'archive_csr', 'y'); if ($yn eq "y"){ `mv $working_dir/$cn.csr $working_dir/csr/`; print "===> $cn.csr moved.\n" - } else { print "You will need to move $working_dir/$cn.csr to $working_dir/$cn.csr manually!\n"; + } else { print "You will need to move $working_dir/$cn.csr to $working_dir/$cn.csr manually!\n"; print "Remember that if you do not keep your server .csr you will need to build a new CA\n"; print "if your server cert gets comprimised.\n"; - } + } } sub gen_dh { @@ -253,7 +298,9 @@ sub gen_dh { print "Your Diffie Hellman parameters have been created."; } -# System Menu +# --------------------------------------------------------------------------- +# Menu +# --------------------------------------------------------------------------- sub main_menu { update_serial(); @@ -273,7 +320,7 @@ sub main_menu { print "6) Renew/Re-sign a past Certificate Request\n"; print "7) View current Certificate Revokation List\n"; print "8) View index information for certificate.\n"; - print "i) Generate a user config with in-line certifcates and keys.\n"; + print "i) Generate a user config with in-line certifcates and keys.\n"; print "z) Zip files for end user.\n"; print "dh) Generate Diffie Hellman parameters.\n"; print "CA) Create new Self-Signed CA certificate.\n"; @@ -285,7 +332,9 @@ sub main_menu { menu_handler(); } -# Menu Handler +# --------------------------------------------------------------------------- +# Menu handler +# --------------------------------------------------------------------------- sub menu_handler { if ($menu_item eq "1"){ @@ -293,7 +342,7 @@ sub menu_handler { project_info(); print "Run-time options reconfigured.\n\n\n"; $new_runtime = 0; - main_menu(); + main_menu() unless $batch_mode; ### CREATE CERT MENU @@ -316,15 +365,17 @@ sub menu_handler { } elsif ($menu_item eq "5"){ common_name(); - my $yn = ""; + my $yn; print "=========> Revoking Certificate for $cn\n"; - do { - print "We're going to REVOKE an SSL certificate. Are you sure? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; - if ($yn eq "n"){ main_menu(); } + $yn = yn_prompt_or_default( + "We're going to REVOKE an SSL certificate. Are you sure? (y/n): ", + 'yes', 'y'); + if ($yn eq "n"){ + main_menu() unless $batch_mode; + return; + } my $revoke = `openssl x509 -noout -text -in $working_dir/active/$cn.crt | grep Serial`; - $revoke =~ m/Serial Number: ([A-Fa-f0-9]+)/; + $revoke =~ m/Serial Number:\s*([A-Fa-f0-9]+)/; $revoke = $1 or warn("Certificate doesn't seem valid."); print "\n \$revoke = $revoke\n"; `cd $working_dir/active && openssl ca -revoke $working_dir/active/$cn.crt -config $key_config -batch`; @@ -333,7 +384,7 @@ sub menu_handler { print "=========> Verifying Revokation: "; my $check_revoke = `openssl crl -noout -text -in $crl | grep Serial`; my $check_status = 0; - while ($check_revoke =~ m/Serial Number: ([A-Fa-f0-9]+)/g){ + while ($check_revoke =~ m/Serial Number:\s*([A-Fa-f0-9]+)/g){ if (hex $revoke == hex $1){ $check_status = 1; } @@ -352,9 +403,9 @@ sub menu_handler { unlink "$working_dir/packages/$cn.ovpn", "$working_dir/packages/$cn.zip"; print "DONE\n"; print "=========> CSR for all users is in $working_dir/csr\n"; - print "===============> Changing file name for $cn\'s request to *.revoked"; - move("$working_dir/csr/$cn.csr", "$working_dir/csr/$cn.csr.revoked"); - sleep 3; + print "===============> Changing file name for $cn\'s request to *.revoked\n"; + move("$working_dir/csr/$cn.csr", "$working_dir/csr/$cn.csr.revoked") if -e "$working_dir/csr/$cn.csr"; + sleep 3 unless $batch_mode; ### RE-SIGN/RENEW MENU @@ -363,35 +414,35 @@ sub menu_handler { common_name(); my $yn; if ( -e "$working_dir/csr/$cn.csr"){ - print "======> Moving archived request to working directory."; + print "======> Moving archived request to working directory.\n"; system("mv $working_dir/csr/$cn.csr $working_dir"); - system("mv $working_dir/csr/$cn.key $working_dir"); + system("mv $working_dir/csr/$cn.key $working_dir") if -e "$working_dir/csr/$cn.key"; sign_csr(); } elsif ( -e "$working_dir/csr/$cn.csr.revoked"){ print "\n\nThe certificate you're trying to renew has been revoked!\n"; - do { - print "Are you sure you want to re-sign/renew this certficate? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; - if ($yn eq "n") { main_menu(); } - else { - system("mv $working_dir/csr/$cn.csr.revoked $working_dir/$cn.csr"); - system("mv $working_dir/csr/$cn.key $working_dir/$cn.key"); - sign_csr(); + $yn = yn_prompt_or_default( + "Are you sure you want to re-sign/renew this certficate? (y/n): ", + 'yes', 'y'); + if ($yn eq "n") { + main_menu() unless $batch_mode; + return; } - } else { - print "There is no request in the archive for $cn.\n"; + system("mv $working_dir/csr/$cn.csr.revoked $working_dir/$cn.csr"); + system("mv $working_dir/csr/$cn.key $working_dir/$cn.key") if -e "$working_dir/csr/$cn.key"; + sign_csr(); + } else { + print "There is no request in the archive for $cn.\n"; } - sleep 2; + sleep 2 unless $batch_mode; ### View Current CRL MENU } elsif ($menu_item eq "7"){ - if (! -e $crl){ + if (! -e $crl){ system("cd $working_dir/active && openssl ca -gencrl -config $key_config -out $crl -batch"); } system("openssl crl -text -noout -in $crl"); - sleep 3; + sleep 3 unless $batch_mode; ### Read INDEX Menu @@ -399,14 +450,11 @@ sub menu_handler { } elsif ($menu_item eq "8"){ common_name(); system("grep \Q$cn\E $working_dir/prog/index.txt"); - sleep 3; + sleep 3 unless $batch_mode; -### Create a config file with inline certifcates and keys. If the config has -### options for the inline files, remove them +### Create a config file with inline certifcates and keys. } elsif ($menu_item eq "i"){ - - my $yn; common_name(); print "========> Creating in-line configuration for $cn in $working_dir/packages\n"; open TEMPLATECONF, "<", "$working_dir/packages/client.ovpn" or die $!; @@ -462,20 +510,18 @@ sub menu_handler { ### Create ZIP FILE Menu } elsif ($menu_item eq "z"){ - my $yn; common_name(); print "=========> Creating .zip file for $cn in $working_dir/packages\n"; print "=================> Moving $cn.crt\n"; `cp $working_dir/active/$cn.crt $working_dir/packages/client.crt`; print "=================> Moving $cn.key\n"; `cp $working_dir/active/$cn.key $working_dir/packages/client.key`; - do { - print "Is this certificate for an OpenVPN client install? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; - if ($yn eq "n"){ + my $yn = yn_prompt_or_default( + "Is this certificate for an OpenVPN client install? (y/n): ", + 'openvpn', 'n'); + if ($yn eq "n"){ $zip_cmd = "cd $working_dir/packages/ && zip $cn.zip client.crt client.key ca.crt"; - } else { + } else { $zip_cmd = "cd $working_dir/packages/ && zip $cn.zip client.crt client.key ca.crt client.ovpn"; } print "=================> Zipping File\n"; @@ -486,40 +532,47 @@ sub menu_handler { `rm $working_dir/packages/client.key`; print "client.key.\n"; print "\nYou may distribute $working_dir/packages/$cn.zip to the end user.\n"; - sleep 3; + sleep 3 unless $batch_mode; } elsif ($menu_item eq "dh"){ gen_dh(); + ### CREATE NEW SELF-SIGNED CA CERTIFICATE } elsif ($menu_item eq "CA"){ new_ca(); + ### CREATE NEW SIGNED SERVER CERTIFICATE } elsif ($menu_item eq "S"){ create_server(); sign_server(); + ### GENERATE CRL MENU } elsif ($menu_item eq "C"){ - my $yn = ""; print "=========> Generating new CRL\n"; - do { - print "We're going to generate a new Certificate Revocation List. Are you sure? (y/n): "; - chomp($yn = <>); - } until $yn =~ m/^[yn]$/; - if ($yn eq "n"){ main_menu(); } + my $yn = yn_prompt_or_default( + "We're going to generate a new Certificate Revocation List. Are you sure? (y/n): ", + 'yes', 'y'); + if ($yn eq "n"){ + main_menu() unless $batch_mode; + return; + } print "=========> Generating new Certificate Revokation List $crl\n"; `cd $working_dir/active && openssl ca -gencrl -out $crl -config $key_config`; print "=========> Verifying Revokation: "; print "DONE\n"; print "=========> CSR for all users is in $working_dir/csr\n"; - sleep 3; + sleep 3 unless $batch_mode; } elsif ($menu_item eq "q"){ exit 0; } } -# Software header/introduction -# + +# --------------------------------------------------------------------------- +# Startup / first-run checks +# --------------------------------------------------------------------------- + if ( ! -e "$working_dir"){ print "$working_dir doesn't exist. Is the variable set correctly?\n"; exit 1; @@ -532,6 +585,97 @@ if ($> != 0){ } } +# --------------------------------------------------------------------------- +# Batch mode: parse arguments +# --------------------------------------------------------------------------- + +if (@ARGV) { + # Extract the command (first non-option argument) + my $command = ''; + for my $i (0 .. $#ARGV) { + unless ($ARGV[$i] =~ /^-/) { + $command = splice(@ARGV, $i, 1); + last; + } + } + + GetOptions( + 'cn=s' => \$batch_opts{cn}, + 'password' => sub { $batch_opts{password} = 'y' }, + 'no-password' => sub { $batch_opts{password} = 'n' }, + 'overwrite' => sub { $batch_opts{overwrite} = 'y' }, + 'archive-csr' => sub { $batch_opts{archive_csr} = 'y' }, + 'no-archive-csr' => sub { $batch_opts{archive_csr} = 'n' }, + 'openvpn' => sub { $batch_opts{openvpn} = 'y' }, + 'no-openvpn' => sub { $batch_opts{openvpn} = 'n' }, + 'yes' => sub { $batch_opts{yes} = 'y' }, + 'days=s' => \$batch_opts{days}, + 'size=s' => \$batch_opts{size}, + 'intermediate' => sub { $batch_opts{intermediate} = 1 }, + ) or die "Invalid options. Run without arguments for interactive help.\n"; + + if ($command eq 'crl') { + # Legacy alias: regenerate CRL silently + if ( ! -e "$working_dir/prog/install"){ + die "ssl-admin is not initialized. Run interactively first to complete setup.\n"; + } + `cd $working_dir/active && openssl ca -gencrl -out $crl -config $key_config 2>&1 > /dev/null`; + exit 0; + } + + if ($command ne '') { + $batch_mode = 1; + + if ( ! -e "$working_dir/prog/install"){ + die "ssl-admin is not initialized. Run interactively first to complete setup.\n"; + } + + # Apply runtime overrides from flags + if (defined $batch_opts{days} && $batch_opts{days} ne '') { + $key_days = $batch_opts{days}; + $ENV{'KEY_DAYS'} = $key_days; + } + if (defined $batch_opts{size} && $batch_opts{size} ne '') { + $key_size = $batch_opts{size}; + } + + # Ensure CRL exists + unless (-e $crl) { + system("cd $working_dir/active && openssl ca -gencrl -batch -config $key_config -out $crl 2>/dev/null"); + } + + my %cmd_to_menu = ( + 'options' => '1', + 'create-csr' => '2', + 'sign' => '3', + 'create-sign' => '4', + 'revoke' => '5', + 'renew' => '6', + 'view-crl' => '7', + 'index' => '8', + 'inline' => 'i', + 'zip' => 'z', + 'dh' => 'dh', + 'new-ca' => 'CA', + 'server' => 'S', + 'gen-crl' => 'C', + ); + + die "Unknown command: $command\nAvailable commands: " . + join(', ', sort keys %cmd_to_menu) . ", crl\n" + unless exists $cmd_to_menu{$command}; + + $menu_item = $cmd_to_menu{$command}; + $new_runtime = 1 if $menu_item eq '1'; + menu_handler(); + exit 0; + } +} + +# --------------------------------------------------------------------------- +# Interactive mode: first-run wizard +# --------------------------------------------------------------------------- + if ( ! -e "$working_dir/prog/install"){ if ( ! -e "$working_dir/active"){ @@ -570,7 +714,7 @@ if ( ! -e "$working_dir/prog/install"){ } } else { - if ( ! -e "$working_dir/active/ca.crt"){ + if ( ! -e "$working_dir/active/ca.crt"){ my $ca_cert; print "I need your ca.crt file. Please enter path and filename so I can have a copy: \n"; print "Location: "; @@ -617,19 +761,12 @@ if ( ! -e "$working_dir/prog/install"){ } system("echo `date` > $working_dir/prog/install"); } + if (! -e $crl){ print "===> Creating initial CRL."; system("cd $working_dir/active && openssl ca -gencrl -batch -config $key_config -out $crl"); } -if ($#ARGV >= 0){ - if ($ARGV[0] eq "crl"){ - # generate a CRL and exit - `cd $working_dir/active && openssl ca -gencrl -out $crl -config $key_config > /dev/null 2>&1`; - exit 0; - } -} - my $install_date = `cat $working_dir/prog/install`; print "ssl-admin installed $install_date"; update_serial(); diff --git a/python/ssl-admin b/python/ssl-admin index 7338bab..2e9c0d9 100755 --- a/python/ssl-admin +++ b/python/ssl-admin @@ -29,6 +29,7 @@ # # VERSION: ~~~VERSION~~~ +import argparse import os import re import sys @@ -76,6 +77,8 @@ intermediate: str = "NO" new_runtime: int = 0 menu_item: str = "" curr_serial: str = "" +batch_mode: bool = False +batch_opts: dict = {} # --------------------------------------------------------------------------- @@ -97,6 +100,17 @@ def yn_prompt(prompt: str) -> str: return answer +def yn_prompt_or_default(prompt: str, batch_key: str, batch_default: str) -> str: + """In batch mode return batch_opts[batch_key] if set, else batch_default. + In interactive mode prompt the user.""" + if batch_mode: + val = batch_opts.get(batch_key) + if val is not None: + return val + return batch_default + return yn_prompt(prompt) + + def update_serial(): global curr_serial curr_serial = (working_dir / "prog" / "serial").read_text().strip() @@ -108,6 +122,11 @@ def update_serial(): def common_name(): global cn, cn_literal + if batch_mode and batch_opts.get('cn'): + cn_literal = batch_opts['cn'] + cn = cn_literal.replace(' ', '_') + os.environ['KEY_CN'] = cn_literal + return cn_regex = re.compile(r'^[\w\s\-\.]+$') while True: print("Please enter certificate owner's name or ID.") @@ -129,6 +148,15 @@ def project_info(): if new_runtime != 1: return + if batch_mode: + if batch_opts.get('days'): + key_days = batch_opts['days'] + os.environ['KEY_DAYS'] = key_days + if batch_opts.get('size'): + key_size = batch_opts['size'] + intermediate = "YES" if batch_opts.get('intermediate') else "NO" + return + key_days_new = input(f"Number of days key is valid for [{key_days}]: ").strip() if key_days_new and key_days_new != os.environ.get('KEY_DAYS', ''): os.environ['KEY_DAYS'] = key_days_new @@ -161,10 +189,16 @@ def create_csr(): common_name() if (working_dir / "active" / f"{cn}.crt").exists(): print(f"{cn} already has a key. Creating another one will overwrite the existing key.") - if yn_prompt(f"{cn} already has an active key. Do you want to overwrite? (y/n): ") == 'n': + if yn_prompt_or_default( + f"{cn} already has an active key. Do you want to overwrite? (y/n): ", + 'overwrite', 'n') == 'n': + if batch_mode: + sys.exit(f"Error: certificate for '{cn}' already exists. Use --overwrite to replace it.") common_name() - nodes = "" if yn_prompt("Would you like to password protect the private key (y/n): ") == 'y' else "-nodes " + nodes = "" if yn_prompt_or_default( + "Would you like to password protect the private key (y/n): ", + 'password', 'n') == 'y' else "-nodes " run(f"cd {working_dir} && openssl req {nodes}-new -keyout {cn}.key -out {cn}.csr " f"-config {key_config} -batch -extensions v3_req") @@ -193,8 +227,10 @@ def sign_csr(): shutil.move(str(working_dir / "active" / f"{curr_serial}.pem"), str(working_dir / "active" / f"{cn}.pem")) - yn = 'y' if menu_item == '4' else yn_prompt( - f"Can I move signing request ({cn}.csr) to the csr directory for archiving? (y/n): ") + # Auto-archive CSR when called from create-sign (menu '4') or any batch command + yn = 'y' if (menu_item == '4' or batch_mode) else yn_prompt_or_default( + f"Can I move signing request ({cn}.csr) to the csr directory for archiving? (y/n): ", + 'archive_csr', 'y') if yn == 'y': shutil.move(str(working_dir / f"{cn}.csr"), str(working_dir / "csr")) print(f"===> {cn}.csr moved.") @@ -206,7 +242,9 @@ def new_ca(): common_name() print(f"\n\n===> Creating private key with {key_size} bits and generating request.") - des3 = "-des3 " if yn_prompt("Do you want to password protect your CA private key? (y/n): ") == 'y' else "" + des3 = "-des3 " if yn_prompt_or_default( + "Do you want to password protect your CA private key? (y/n): ", + 'password', 'n') == 'y' else "" run(f"cd {working_dir} && openssl genrsa {des3}-out {cn}.key {key_size}") print("===> Self-Signing request.") @@ -225,10 +263,16 @@ def create_server(): common_name() if (working_dir / "active" / f"{cn}.crt").exists(): print(f"{cn} already has a key. Creating another one will overwrite the existing key.") - if yn_prompt(f"{cn} already has an active key. Do you want to overwrite? (y/n): ") == 'n': + if yn_prompt_or_default( + f"{cn} already has an active key. Do you want to overwrite? (y/n): ", + 'overwrite', 'n') == 'n': + if batch_mode: + sys.exit(f"Error: certificate for '{cn}' already exists. Use --overwrite to replace it.") project_info() - nodes = "" if yn_prompt("Would you like to password protect the private key (y/n): ") == 'y' else "-nodes " + nodes = "" if yn_prompt_or_default( + "Would you like to password protect the private key (y/n): ", + 'password', 'n') == 'y' else "-nodes " run(f"cd {working_dir} && openssl req -extensions server {nodes}-new " f"-keyout {cn}.key -out {cn}.csr -config {key_config} -batch") @@ -248,7 +292,10 @@ def sign_server(): shutil.move(str(working_dir / "active" / f"{curr_serial}.pem"), str(working_dir / "active" / f"{cn}.pem")) - if yn_prompt(f"Can I move signing request ({cn}.csr) to the csr directory for archiving? (y/n): ") == 'y': + yn = 'y' if batch_mode else yn_prompt_or_default( + f"Can I move signing request ({cn}.csr) to the csr directory for archiving? (y/n): ", + 'archive_csr', 'y') + if yn == 'y': shutil.move(str(working_dir / f"{cn}.csr"), str(working_dir / "csr")) print(f"===> {cn}.csr moved.") else: @@ -304,7 +351,8 @@ def menu_handler(): project_info() print("Run-time options reconfigured.\n\n") new_runtime = 0 - main_menu() + if not batch_mode: + main_menu() elif menu_item == '2': create_csr() @@ -320,8 +368,11 @@ def menu_handler(): elif menu_item == '5': common_name() print(f"=========> Revoking Certificate for {cn}") - if yn_prompt("We're going to REVOKE an SSL certificate. Are you sure? (y/n): ") == 'n': - main_menu() + if yn_prompt_or_default( + "We're going to REVOKE an SSL certificate. Are you sure? (y/n): ", + 'yes', 'y') == 'n': + if not batch_mode: + main_menu() return revoke_out = run_out( @@ -365,7 +416,8 @@ def menu_handler(): csr_path = working_dir / "csr" / f"{cn}.csr" if csr_path.exists(): shutil.move(str(csr_path), str(csr_path.parent / f"{cn}.csr.revoked")) - time.sleep(3) + if not batch_mode: + time.sleep(3) elif menu_item == '6': common_name() @@ -381,8 +433,11 @@ def menu_handler(): sign_csr() elif csr_revoked.exists(): print(f"\n\nThe certificate you're trying to renew has been revoked!") - if yn_prompt("Are you sure you want to re-sign/renew this certificate? (y/n): ") == 'n': - main_menu() + if yn_prompt_or_default( + "Are you sure you want to re-sign/renew this certificate? (y/n): ", + 'yes', 'y') == 'n': + if not batch_mode: + main_menu() return shutil.move(str(csr_revoked), str(working_dir / f"{cn}.csr")) if key_path.exists(): @@ -390,13 +445,15 @@ def menu_handler(): sign_csr() else: print(f"There is no request in the archive for {cn}.") - time.sleep(2) + if not batch_mode: + time.sleep(2) elif menu_item == '7': if not crl.exists(): run(f"cd {working_dir}/active && openssl ca -gencrl -config {key_config} -out {crl} -batch") run(f"openssl crl -text -noout -in {crl}") - time.sleep(3) + if not batch_mode: + time.sleep(3) elif menu_item == '8': common_name() @@ -404,7 +461,8 @@ def menu_handler(): for line in index_text.splitlines(): if cn in line: print(line) - time.sleep(3) + if not batch_mode: + time.sleep(3) elif menu_item == 'i': common_name() @@ -442,14 +500,16 @@ def menu_handler(): shutil.copy2(str(working_dir / "active" / f"{cn}.crt"), str(pkg_dir / "client.crt")) shutil.copy2(str(working_dir / "active" / f"{cn}.key"), str(pkg_dir / "client.key")) - extras = "client.ovpn" if yn_prompt( - "Is this certificate for an OpenVPN client install? (y/n): ") == 'y' else "" + extras = "client.ovpn" if yn_prompt_or_default( + "Is this certificate for an OpenVPN client install? (y/n): ", + 'openvpn', 'n') == 'y' else "" run(f"cd {pkg_dir} && zip {cn}.zip client.crt client.key ca.crt {extras}".rstrip()) (pkg_dir / "client.crt").unlink(missing_ok=True) (pkg_dir / "client.key").unlink(missing_ok=True) print(f"\nYou may distribute {pkg_dir}/{cn}.zip to the end user.") - time.sleep(3) + if not batch_mode: + time.sleep(3) elif menu_item == 'dh': gen_dh() @@ -463,14 +523,18 @@ def menu_handler(): elif menu_item == 'C': print("=========> Generating new CRL") - if yn_prompt("We're going to generate a new Certificate Revocation List. Are you sure? (y/n): ") == 'n': - main_menu() + if yn_prompt_or_default( + "We're going to generate a new Certificate Revocation List. Are you sure? (y/n): ", + 'yes', 'y') == 'n': + if not batch_mode: + main_menu() return print(f"=========> Generating new Certificate Revocation List {crl}") run(f"cd {working_dir}/active && openssl ca -gencrl -out {crl} -config {key_config}") print("=========> Verifying Revocation: DONE") print(f"=========> CSR for all users is in {working_dir}/csr") - time.sleep(3) + if not batch_mode: + time.sleep(3) elif menu_item == 'q': sys.exit(0) @@ -491,6 +555,9 @@ def _ensure_prog_files(): def do_install(): + if batch_mode: + sys.exit("Error: ssl-admin is not initialized. Run interactively first to complete setup.") + if not (working_dir / "active").exists(): print("This program will walk you through requesting, signing,") print("organizing and revoking SSL certificates.\n") @@ -553,12 +620,156 @@ def do_install(): ) +# --------------------------------------------------------------------------- +# Argument parser (batch mode) +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROGRAM_NAME, + description="SSL/TLS Certificate Authority manager", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Batch mode runs a single operation non-interactively and exits.\n" + "Run without arguments to enter the interactive menu.\n\n" + "Common options:\n" + " --cn NAME Certificate owner / Common Name\n" + " --password Password-protect the private key (default: no)\n" + " --overwrite Overwrite an existing certificate (default: error)\n" + " --archive-csr Archive CSR after signing (default: yes)\n" + " --no-archive-csr Do not archive CSR after signing\n" + ), + ) + sub = parser.add_subparsers(dest='command', metavar='COMMAND') + + def add_cn(p): + p.add_argument('--cn', metavar='NAME', required=True, + help='Certificate owner common name') + + def add_pw(p): + p.add_argument('--password', action='store_const', const='y', default='n', + help='Password-protect the private key') + + def add_ow(p): + p.add_argument('--overwrite', action='store_const', const='y', default='n', + help='Overwrite existing certificate without prompting') + + def add_archive(p): + g = p.add_mutually_exclusive_group() + g.add_argument('--archive-csr', dest='archive_csr', + action='store_const', const='y', + help='Archive CSR after signing (default)') + g.add_argument('--no-archive-csr', dest='archive_csr', + action='store_const', const='n', + help='Do not archive CSR after signing') + + # options + p = sub.add_parser('options', help='Set runtime options (key days, size, intermediate CA)') + p.add_argument('--days', metavar='N', help='Certificate validity in days') + p.add_argument('--size', metavar='N', help='RSA key size in bits (max 4096)') + p.add_argument('--intermediate', action='store_true', default=False, + help='Enable intermediate CA certificate signing') + + # create-csr + p = sub.add_parser('create-csr', help='Create a new certificate signing request') + add_cn(p); add_pw(p); add_ow(p) + + # sign + p = sub.add_parser('sign', help='Sign an existing certificate signing request') + add_cn(p); add_archive(p) + + # create-sign + p = sub.add_parser('create-sign', help='One-step: create and sign a certificate') + add_cn(p); add_pw(p); add_ow(p) + + # revoke + p = sub.add_parser('revoke', help='Revoke a certificate') + add_cn(p) + p.add_argument('--yes', dest='yes', action='store_const', const='y', default='y', + help='Confirm revocation (implied in batch mode)') + + # renew + p = sub.add_parser('renew', help='Renew/re-sign a past certificate request') + add_cn(p); add_archive(p) + p.add_argument('--yes', dest='yes', action='store_const', const='y', default='y', + help='Confirm re-signing of a revoked certificate') + + # view-crl + sub.add_parser('view-crl', help='Display the Certificate Revocation List') + + # index + p = sub.add_parser('index', help='Show index entry for a certificate') + add_cn(p) + + # inline + p = sub.add_parser('inline', help='Generate OpenVPN inline config with embedded certs/keys') + add_cn(p) + + # zip + p = sub.add_parser('zip', help='Package certificate files as a ZIP for end-user distribution') + add_cn(p) + g = p.add_mutually_exclusive_group() + g.add_argument('--openvpn', dest='openvpn', action='store_const', const='y', + help='Include OpenVPN client config in the ZIP') + g.add_argument('--no-openvpn', dest='openvpn', action='store_const', const='n', + help='Do not include OpenVPN client config (default)') + + # dh + sub.add_parser('dh', help='Generate Diffie-Hellman parameters') + + # new-ca + p = sub.add_parser('new-ca', help='Create a new self-signed CA certificate') + add_cn(p); add_pw(p) + p.add_argument('--days', metavar='N', help='CA certificate validity in days') + p.add_argument('--size', metavar='N', help='CA key size in bits (max 4096)') + + # server + p = sub.add_parser('server', help='Create and sign a server certificate') + add_cn(p); add_pw(p); add_ow(p); add_archive(p) + + # gen-crl + p = sub.add_parser('gen-crl', help='Generate a new Certificate Revocation List') + p.add_argument('--yes', dest='yes', action='store_const', const='y', default='y', + help='Confirm CRL generation (implied in batch mode)') + + # crl (backward-compatible alias) + sub.add_parser('crl', help='Regenerate CRL silently and exit (legacy alias for gen-crl)') + + return parser + + +# Subcommand to menu_item mapping +_CMD_TO_MENU = { + 'options': '1', + 'create-csr': '2', + 'sign': '3', + 'create-sign': '4', + 'revoke': '5', + 'renew': '6', + 'view-crl': '7', + 'index': '8', + 'inline': 'i', + 'zip': 'z', + 'dh': 'dh', + 'new-ca': 'CA', + 'server': 'S', + 'gen-crl': 'C', +} + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): global working_dir, key_config, crl, key_days, key_size + global batch_mode, batch_opts, menu_item, new_runtime + + # Build parser once; --help/-h causes argparse to print and exit immediately + parser = build_parser() + if len(sys.argv) > 1 and ('--help' in sys.argv or '-h' in sys.argv): + parser.parse_args() + sys.exit(0) if not CONFIG_FILE.exists(): sys.exit(f"{CONFIG_FILE} doesn't exist. Did you copy the sample from {CONFIG_FILE}.default?") @@ -583,6 +794,39 @@ def main(): else: sys.exit("Sorry, but I need to be run as the root user.\n") + # --- Batch mode: parse subcommand if arguments are present --- + if len(sys.argv) > 1: + args = parser.parse_args() + if args.command: + batch_mode = True + batch_opts = {k: v for k, v in vars(args).items() if k != 'command'} + + # Apply per-command runtime overrides + if batch_opts.get('days'): + key_days = batch_opts['days'] + os.environ['KEY_DAYS'] = key_days + if batch_opts.get('size'): + key_size = batch_opts['size'] + + if not (working_dir / "prog" / "install").exists(): + do_install() # fast-fails in batch mode + + if not crl.exists(): + run(f"cd {working_dir}/active && openssl ca -gencrl -batch " + f"-config {key_config} -out {crl} 2>/dev/null") + + if args.command == 'crl': + run(f"cd {working_dir}/active && openssl ca -gencrl -out {crl} " + f"-config {key_config} >/dev/null 2>&1") + sys.exit(0) + + menu_item = _CMD_TO_MENU[args.command] + if args.command == 'options': + new_runtime = 1 + menu_handler() + sys.exit(0) + + # --- Interactive mode --- if not (working_dir / "prog" / "install").exists(): do_install() @@ -590,11 +834,6 @@ def main(): print("===> Creating initial CRL.") run(f"cd {working_dir}/active && openssl ca -gencrl -batch -config {key_config} -out {crl}") - if len(sys.argv) > 1 and sys.argv[1] == 'crl': - run(f"cd {working_dir}/active && openssl ca -gencrl -out {crl} -config {key_config} " - f"> /dev/null 2>&1") - sys.exit(0) - install_date = (working_dir / "prog" / "install").read_text().strip() print(f"ssl-admin installed {install_date}") update_serial()