From 2c0dbfbbb38e564c5fd3f52fbeefcf26af488c02 Mon Sep 17 00:00:00 2001 From: MadCamel Date: Wed, 13 May 2026 13:24:16 -0400 Subject: [PATCH 1/3] General code cleanups that should not affect functionality --- acmeproxy.pl | 111 ++++++++++++++++++++++----------------------------- 1 file changed, 48 insertions(+), 63 deletions(-) diff --git a/acmeproxy.pl b/acmeproxy.pl index 9051be2..d894b7a 100755 --- a/acmeproxy.pl +++ b/acmeproxy.pl @@ -36,13 +36,10 @@ use Cwd; use strict; -my $has_bcrypt = eval { - require Crypt::Bcrypt; - Crypt::Bcrypt->import(); - 1; -}; +my $has_bcrypt = eval { require Crypt::Bcrypt; 1 }; -die("$0: please install curl.\n") unless (-x `/usr/bin/which curl` =~ s/[\r\n]//r); +chomp(my $curl_path = qx(command -v curl)); +die("$0: please install curl.\n") unless -x $curl_path; # acme.sh uses this log format so we're sort of stuck with it sub logg ($in) { say strftime("[%a %b %e %I:%M:%S %p %Z %Y] ", localtime()) . $in }; @@ -51,33 +48,22 @@ logg('WARNING: acmeproxy.pl.conf is world-readable. Please chmod 0600 acmeproxy.pl.conf') if ((stat('acmeproxy.pl.conf'))[2] & 04); my $config = plugin 'Config' => {file => cwd().'/acmeproxy.pl.conf', format => 'perl'}; -# Backwards compatibility checks/updates -if (!exists($config->{acmesh_extra_params_install})) { - $config->{acmesh_extra_params_install} = []; -} -if (!exists($config->{acmesh_extra_params_install_cert})) { - $config->{acmesh_extra_params_install_cert} = []; -} -if (!exists($config->{acmesh_extra_params_issue})) { - $config->{acmesh_extra_params_issue} = []; -} -if ($has_bcrypt) { - foreach my $auth (@{$config->{auth}}) { - if (exists($auth->{pass})) { - logg "One or more users are defined with plaintext passwords. You should convert them to bcrypt hashes!"; - last; - } - } -} else { - foreach my $auth (@{$config->{auth}}) { - if (exists($auth->{hash})) { - die("One or more users are defined with bcrypt hashes, but Crypt::Bcrypt is not available. Either install Crypt::Bcrypt, or change these users to have a plaintext password!"); - } - } -} -if (!exists($config->{keypair_directory})) { - $config->{keypair_directory} = $acme_home; +# Backwards compatibility defaults +$config->{acmesh_extra_params_install} = [] unless exists $config->{acmesh_extra_params_install}; +$config->{acmesh_extra_params_install_cert} = [] unless exists $config->{acmesh_extra_params_install_cert}; +$config->{acmesh_extra_params_issue} = [] unless exists $config->{acmesh_extra_params_issue}; +$config->{keypair_directory} = $acme_home unless exists $config->{keypair_directory}; + +# Validate auth entries against bcrypt availability +my ($has_plaintext, $has_hash) = (0, 0); +foreach my $auth (@{$config->{auth}}) { + $has_plaintext ||= exists $auth->{pass}; + $has_hash ||= exists $auth->{hash}; } +die("One or more users are defined with bcrypt hashes, but Crypt::Bcrypt is not available. Either install Crypt::Bcrypt, or change these users to have a plaintext password!\n") + if ($has_hash && !$has_bcrypt); +logg "One or more users are defined with plaintext passwords. You should convert them to bcrypt hashes!" + if ($has_plaintext && $has_bcrypt); # Set environment variables from config foreach (keys %{$config->{env}}) { $ENV{$_} = $config->{env}->{$_}; } @@ -128,17 +114,14 @@ sub handle_request { # We used acme.sh to generate our TLS certificate so its cron job should update our cert regularly # Check the TLS certificate file for changes every second and reload our app if it's been modified -{ - my $watcher; - my $cert_mtime = (stat("$acmeproxy_crt_file"))[9]; - $watcher = Mojo::IOLoop->recurring(1 => sub { - if ((stat($acmeproxy_crt_file))[9] != $cert_mtime) { - $cert_mtime = (stat($acmeproxy_crt_file))[9]; - logg "$acmeproxy_crt_file modified. Reloading"; - exec($^X, $0, @ARGV) or logg "reload failed!"; # Just re-exec ourselves - } - }); -} +my $cert_mtime = (stat($acmeproxy_crt_file))[9]; +Mojo::IOLoop->recurring(1 => sub { + my $mtime = (stat($acmeproxy_crt_file))[9]; + return if $mtime == $cert_mtime; + $cert_mtime = $mtime; + logg "$acmeproxy_crt_file modified. Reloading"; + exec($^X, $0, @ARGV) or logg "reload failed!"; # Just re-exec ourselves +}); # Anchors aweigh! app->mode('production'); @@ -153,15 +136,19 @@ ($action, $fqdn, $value) return { text => "invalid characters in value", status => 400} unless ($value =~ /^[\w_\.-]+$/); $fqdn =~ s/\.+$//; # Some acme.sh plugins add an additional . to the end of the hostname - my $shellcmd = '/usr/bin/env bash -c "' . - "source $acme_home/acme.sh >/dev/null 2>&1; " . # Load all bash functions from acme.sh - "source $acme_home/dnsapi/$config->{dns_provider}.sh; " . # source ~/.acme.sh/dns_cf.sh - $config->{dns_provider}.'_'.$action.' \"'.$fqdn.'\" '.'\"'.$value.'\";"'; # dns_cf_add "sub.domain.com" "value123456" - logg "executing: $shellcmd"; + # Source acme.sh and the dnsapi provider, then call the provider's add/rm function. + # fqdn and value are passed as positional args ($1, $2) rather than interpolated into + # the shell string, so they can never be parsed as shell syntax. + my $func = $config->{dns_provider}.'_'.$action; + my $script = "source $acme_home/acme.sh >/dev/null 2>&1; " . + "source $acme_home/dnsapi/$config->{dns_provider}.sh; " . + '"$0" "$1" "$2"'; + logg "executing: $func \"$fqdn\" \"$value\""; # acme.sh/dnslib/dns_acmeproxy.sh explicitly looks for the quotes around $value to determine success # other clients expect full JSON and fqdn needs to end with "." - return { text => "{\"fqdn\": \"$fqdn.\", \"value\": \"$value\"}", status => 200} unless (system("$shellcmd")); + return { text => "{\"fqdn\": \"$fqdn.\", \"value\": \"$value\"}", status => 200} + unless (system('/usr/bin/env', 'bash', '-c', $script, $func, $fqdn, $value)); return { text => "failed. check acmeproxy.pl logs", status => 500}; } @@ -176,17 +163,15 @@ ($userpass, $fqdn) my ($user, $pass) = split(/:/, $userpass, 2); foreach my $auth (@{$config->{auth}}) { - my $auth_check = 0; - if (secure_compare($user, $auth->{user}) && $fqdn =~ /\.$auth->{host}\.?$/) { - if ($has_bcrypt && exists($auth->{hash})) { - $auth_check = Crypt::Bcrypt::bcrypt_check($pass, $auth->{hash}); - } else { - $auth_check = secure_compare($pass, $auth->{pass}); - } - if ($auth_check) { - logg "auth: $user successfully authenticated for $fqdn"; - return 1; - } + next unless secure_compare($user, $auth->{user}) && $fqdn =~ /\.$auth->{host}\.?$/; + + my $ok = ($has_bcrypt && exists $auth->{hash}) + ? Crypt::Bcrypt::bcrypt_check($pass, $auth->{hash}) + : secure_compare($pass, $auth->{pass}); + + if ($ok) { + logg "auth: $user successfully authenticated for $fqdn"; + return 1; } } @@ -198,7 +183,7 @@ ($userpass, $fqdn) sub acme_install { say "Installing acme.sh"; my $extra_params_install = join(' ', @{$config->{acmesh_extra_params_install}}); - system("curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sh -s -- --install-online -m $config->{email} $extra_params_install") && die("ouldn't install acme.sh\n"); + system("curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sh -s -- --install-online -m $config->{email} $extra_params_install") && die("Couldn't install acme.sh\n"); say "Completed"; } @@ -214,8 +199,8 @@ ($hn) die("Could not create TLS certificate for $hn") if ($ret != 0 && $ret >> 8 != 2); my $extra_params_install_cert = join(' ', @{$config->{acmesh_extra_params_install_cert}}); - $ret = system("$acme_home/acme.sh --log --install-cert $extra_params_install_cert $domain_list " . - "--key-file $acmeproxy_key_file --fullchain-file $acmeproxy_crt_file"); + $ret = system("$acme_home/acme.sh --log --install-cert $extra_params_install_cert $domain_list " . + "--key-file $acmeproxy_key_file --fullchain-file $acmeproxy_crt_file"); die("Could not install TLS certificate for $hn") if ($ret); } From 4f722357ec776d67dca43309512eee9a67cd8b2a Mon Sep 17 00:00:00 2001 From: MadCamel Date: Wed, 13 May 2026 15:46:46 -0400 Subject: [PATCH 2/3] Added LLM generated test suite to verify operation. Better than nothing.. --- acmeproxy.pl | 5 +- t/00-compile.t | 20 ++++++ t/10-auth.t | 92 ++++++++++++++++++++++++ t/20-acme_cmd.t | 73 +++++++++++++++++++ t/30-routes.t | 125 ++++++++++++++++++++++++++++++++ t/40-bootstrap.t | 157 +++++++++++++++++++++++++++++++++++++++++ t/Makefile | 10 +++ t/lib/AcmeProxyTest.pm | 104 +++++++++++++++++++++++++++ 8 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 t/00-compile.t create mode 100644 t/10-auth.t create mode 100644 t/20-acme_cmd.t create mode 100644 t/30-routes.t create mode 100644 t/40-bootstrap.t create mode 100644 t/Makefile create mode 100644 t/lib/AcmeProxyTest.pm diff --git a/acmeproxy.pl b/acmeproxy.pl index d894b7a..b56eeb2 100755 --- a/acmeproxy.pl +++ b/acmeproxy.pl @@ -38,7 +38,7 @@ my $has_bcrypt = eval { require Crypt::Bcrypt; 1 }; -chomp(my $curl_path = qx(command -v curl)); +chomp(my $curl_path = qx{command -v curl 2>/dev/null}); die("$0: please install curl.\n") unless -x $curl_path; # acme.sh uses this log format so we're sort of stuck with it @@ -125,7 +125,8 @@ sub handle_request { # Anchors aweigh! app->mode('production'); -app->start('daemon', '-l', "https://$config->{bind}?cert=$acmeproxy_crt_file&key=$acmeproxy_key_file"); +app->start('daemon', '-l', "https://$config->{bind}?cert=$acmeproxy_crt_file&key=$acmeproxy_key_file") + unless caller; # Add or remove a DNS record using the configured acme.sh DNS provider # Hijacks acme.sh to use it's dnsapi library. diff --git a/t/00-compile.t b/t/00-compile.t new file mode 100644 index 0000000..83e89e4 --- /dev/null +++ b/t/00-compile.t @@ -0,0 +1,20 @@ +use strict; +use warnings; +use Test::More; +use FindBin; +use lib "$FindBin::Bin/lib"; +use AcmeProxyTest qw(setup_testenv); + +setup_testenv(); +require "$FindBin::Bin/../acmeproxy.pl"; +AcmeProxyTest::silence_logg(); + +ok(defined &main::check_auth, 'check_auth defined in main::'); +ok(defined &main::acme_cmd, 'acme_cmd defined in main::'); +ok(main::app()->isa('Mojolicious'), 'app is a Mojolicious instance'); + +my @paths = sort map { $_->pattern->unparsed } @{main::app()->routes->children}; +is_deeply(\@paths, [sort '/*', '/cleanup', '/present'], 'expected routes registered') + or diag explain \@paths; + +done_testing(); diff --git a/t/10-auth.t b/t/10-auth.t new file mode 100644 index 0000000..bf85772 --- /dev/null +++ b/t/10-auth.t @@ -0,0 +1,92 @@ +use strict; +use warnings; +use Test::More; +use FindBin; +use lib "$FindBin::Bin/lib"; +use AcmeProxyTest qw(setup_testenv clear_log @LOG_LINES); + +setup_testenv(); +require "$FindBin::Bin/../acmeproxy.pl"; +AcmeProxyTest::silence_logg(); + +# --- negative paths -------------------------------------------------------- +ok(!main::check_auth(undef, 'foo.bob.example.com'), 'undef userinfo rejected'); +ok(!main::check_auth('', 'foo.bob.example.com'), 'empty userinfo rejected'); +ok(!main::check_auth('mallory:x', 'foo.bob.example.com'), 'unknown user rejected'); +ok(!main::check_auth('bob:wrong', 'foo.bob.example.com'), 'wrong password rejected'); + +# --- positive paths -------------------------------------------------------- +# acme.sh sends FQDNs like "_acme-challenge." — any subdomain of the +# configured host satisfies the /\.HOST\.?$/ regex +ok( main::check_auth('bob:dobbs', 'foo.bob.example.com'), + 'subdomain match for bob'); +ok( main::check_auth('bob:dobbs', '_acme-challenge.bob.example.com'), + 'acme-challenge prefix matches'); +ok( main::check_auth('bob:dobbs', '_acme-challenge.bob.example.com.'), + 'trailing-dot FQDN still matches'); + +# --- host scoping ---------------------------------------------------------- +ok(!main::check_auth('bob:dobbs', 'foo.alice.example.com'), + 'bob cannot issue for alice host'); +ok(!main::check_auth('bob:dobbs', 'evil.com'), + 'bob cannot issue for unrelated domain'); +ok(!main::check_auth('bob:dobbs', 'foobob.example.com'), + 'host match requires preceding dot (no substring sneak)'); +ok( main::check_auth('alice:rabbit', 'foo.alice.example.com'), + 'alice can issue for alice subdomain'); +ok(!main::check_auth('alice:rabbit', 'foo.bob.example.com'), + 'alice cannot issue for bob subdomain'); + +# --- multi-host single user ----------------------------------------------- +push @{ main::app()->config->{auth} }, + { user => 'bob', pass => 'dobbs', host => 'subgenius.example.com' }; +ok( main::check_auth('bob:dobbs', 'foo.subgenius.example.com'), + 'bob authorized for second host entry'); +ok( main::check_auth('bob:dobbs', 'foo.bob.example.com'), + 'bob still authorized for first host entry'); + +# --- audit log ------------------------------------------------------------- +clear_log(); +main::check_auth('bob:wrong', 'foo.bob.example.com'); +ok( (grep { /auth: Invalid credentials for user bob/ } @LOG_LINES), + 'invalid creds logged for audit') + or diag explain \@LOG_LINES; + +clear_log(); +main::check_auth('bob:dobbs', 'foo.bob.example.com'); +ok( (grep { /auth: bob successfully authenticated/ } @LOG_LINES), + 'successful auth logged for audit') + or diag explain \@LOG_LINES; + +clear_log(); +main::check_auth(undef, 'foo.bob.example.com'); +ok( (grep { /credentials not supplied/ } @LOG_LINES), + 'missing creds logged for audit') + or diag explain \@LOG_LINES; + +# --- bcrypt (skipped when Crypt::Bcrypt unavailable) ---------------------- +SKIP: { + skip 'Crypt::Bcrypt not installed', 3 + unless eval { require Crypt::Bcrypt; Crypt::Bcrypt->import('bcrypt'); 1 }; + + # Generate a real hash for password "carrot" at cost 4 (fast) + my $hash = Crypt::Bcrypt::bcrypt('carrot', '2b', 4, '0123456789012345'); + + # Add a charlie entry that has BOTH pass and hash. With Crypt::Bcrypt + # available, the hash path should win (per script's ternary at line 168). + push @{ main::app()->config->{auth} }, { + user => 'charlie', + pass => 'ignored-because-hash-wins', + hash => $hash, + host => 'charlie.example.com', + }; + + ok( main::check_auth('charlie:carrot', 'foo.charlie.example.com'), + 'bcrypt: correct password authorizes'); + ok(!main::check_auth('charlie:wrong', 'foo.charlie.example.com'), + 'bcrypt: wrong password rejected'); + ok(!main::check_auth('charlie:ignored-because-hash-wins', 'foo.charlie.example.com'), + 'bcrypt: plaintext field ignored when hash is present'); +} + +done_testing(); diff --git a/t/20-acme_cmd.t b/t/20-acme_cmd.t new file mode 100644 index 0000000..e794abf --- /dev/null +++ b/t/20-acme_cmd.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use Test::More; +use FindBin; +use lib "$FindBin::Bin/lib"; +use AcmeProxyTest qw(setup_testenv $TMPDIR); + +my $tmp = setup_testenv(); +require "$FindBin::Bin/../acmeproxy.pl"; +AcmeProxyTest::silence_logg(); + +# --- fqdn validation ------------------------------------------------------- +my @metachars = (';', '$', '`', ' ', '|', '&', '<', '>', "\n", '"', "'", '(', ')'); +for my $c (@metachars) { + my $label = $c eq "\n" ? 'newline' : "'$c'"; + my $res = main::acme_cmd('add', "foo${c}bar.example.com", 'tok'); + is($res->{status}, 400, "fqdn with $label rejected"); + like($res->{text}, qr/invalid characters in fqdn/, "fqdn $label error text"); +} + +# --- value validation ------------------------------------------------------ +for my $c (@metachars) { + my $label = $c eq "\n" ? 'newline' : "'$c'"; + my $res = main::acme_cmd('add', 'foo.example.com', "tok${c}bad"); + is($res->{status}, 400, "value with $label rejected"); + like($res->{text}, qr/invalid characters in value/, "value $label error text"); +} + +# --- happy path ------------------------------------------------------------ +my $res = main::acme_cmd('add', '_acme-challenge.bob.example.com', 'token123'); +is($res->{status}, 200, 'valid add returns 200'); +like($res->{text}, qr/"fqdn":\s*"_acme-challenge\.bob\.example\.com\."/, + 'response includes fqdn with trailing dot'); +like($res->{text}, qr/"value":\s*"token123"/, + 'response includes value'); + +# --- trailing-dot normalization ------------------------------------------- +$res = main::acme_cmd('add', '_acme-challenge.bob.example.com.', 'tok'); +like($res->{text}, qr/"fqdn":\s*"_acme-challenge\.bob\.example\.com\."/, + 'trailing dot stripped then re-added'); + +$res = main::acme_cmd('add', '_acme-challenge.bob.example.com...', 'tok'); +like($res->{text}, qr/"fqdn":\s*"_acme-challenge\.bob\.example\.com\."/, + 'multiple trailing dots collapsed to one'); + +# --- rm action ------------------------------------------------------------- +$res = main::acme_cmd('rm', '_acme-challenge.bob.example.com', 'token123'); +is($res->{status}, 200, 'rm returns 200'); + +# --- failure path ---------------------------------------------------------- +{ + local $ENV{ACMEPROXY_TEST_FAIL} = 1; + $res = main::acme_cmd('add', '_acme-challenge.bob.example.com', 'tok'); + is($res->{status}, 500, 'dnsapi failure returns 500'); + like($res->{text}, qr/check acmeproxy\.pl logs/, '500 error message text'); +} + +# --- call log inspection --------------------------------------------------- +# The stub dns_test.sh appends "add|rm " lines to calls.log. +# After the happy-path runs above, the log should contain at least one add +# and one rm. +my $log_path = "$tmp/.acme.sh/calls.log"; +ok(-f $log_path, 'calls.log exists'); +my $content = do { open my $fh, '<', $log_path or die $!; local $/; <$fh> }; +like($content, qr/^add /m, 'add invocation recorded'); +like($content, qr/^rm /m, 'rm invocation recorded'); + +# Arguments passed to the stub are positional ($1, $2) so they cannot +# be parsed as shell syntax. Confirm the FQDN ended up as a literal arg. +like($content, qr/_acme-challenge\.bob\.example\.com token123/, + 'fqdn and value reach dnsapi as positional args'); + +done_testing(); diff --git a/t/30-routes.t b/t/30-routes.t new file mode 100644 index 0000000..f8ad9f6 --- /dev/null +++ b/t/30-routes.t @@ -0,0 +1,125 @@ +use strict; +use warnings; +use Test::More; +use Test::Mojo; +use FindBin; +use lib "$FindBin::Bin/lib"; +use AcmeProxyTest qw(setup_testenv clear_log @LOG_LINES $TMPDIR); +use MIME::Base64 qw(encode_base64); + +my $tmp = setup_testenv(); +require "$FindBin::Bin/../acmeproxy.pl"; +AcmeProxyTest::silence_logg(); + +my $t = Test::Mojo->new(main::app()); + +sub basic { + my ($user, $pass) = @_; + return 'Basic ' . encode_base64("$user:$pass", ''); +} + +sub call_log { + my $path = "$TMPDIR/.acme.sh/calls.log"; + return '' unless -f $path; + open my $fh, '<', $path or die $!; + local $/; + return scalar <$fh>; +} + +sub clear_calls { + unlink "$TMPDIR/.acme.sh/calls.log"; +} + +# --- catch-all ------------------------------------------------------------- +# Note: the script uses any '/*' which requires at least one path segment; +# bare GET / does not match it (returns 404). +$t->get_ok('/anything')->status_is(200)->content_like(qr/not a teapot/); +$t->get_ok('/foo/bar/baz')->status_is(200)->content_like(qr/not a teapot/); +$t->get_ok('/')->status_is(404); + +# --- invalid JSON ---------------------------------------------------------- +$t->post_ok('/present', 'this is not json') + ->status_is(400) + ->content_like(qr/Invalid JSON/); + +# /present with valid JSON but no auth -> 401, WWW-Authenticate set +$t->post_ok('/present' => json => { fqdn => 'foo.bob.example.com', value => 'tok' }) + ->status_is(401) + ->content_like(qr/Invalid credentials/) + ->header_like('WWW-Authenticate', qr/Basic/); + +# --- bad credentials ------------------------------------------------------- +clear_log(); +$t->post_ok('/present' => { Authorization => basic('bob', 'wrong') } + => json => { fqdn => 'foo.bob.example.com', value => 'tok' }) + ->status_is(401); +ok((grep { /auth: Invalid credentials for user bob/ } @LOG_LINES), + 'bad creds audit log present') + or diag explain \@LOG_LINES; + +# Unknown user +$t->post_ok('/present' => { Authorization => basic('mallory', 'x') } + => json => { fqdn => 'foo.bob.example.com', value => 'tok' }) + ->status_is(401); + +# --- happy path: /present -------------------------------------------------- +clear_calls(); +clear_log(); +$t->post_ok('/present' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => '_acme-challenge.bob.example.com', value => 'tok' }) + ->status_is(200) + ->content_like(qr/"fqdn":\s*"_acme-challenge\.bob\.example\.com\."/) + ->content_like(qr/"value":\s*"tok"/); +ok((grep { /auth: bob successfully authenticated/ } @LOG_LINES), + 'success audit log present') + or diag explain \@LOG_LINES; + +# Confirms the script invokes dnsapi twice for /present: rm then add. +my @lines = grep { length } split /\n/, call_log(); +is(scalar @lines, 2, '/present invokes dnsapi twice (rm then add)') + or diag explain \@lines; +like($lines[0], qr/^rm /, 'first dnsapi call is rm (clear stale)'); +like($lines[1], qr/^add /, 'second dnsapi call is add'); + +# --- happy path: /cleanup -------------------------------------------------- +clear_calls(); +$t->post_ok('/cleanup' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => '_acme-challenge.bob.example.com', value => 'tok' }) + ->status_is(200) + ->content_like(qr/"fqdn":\s*"_acme-challenge\.bob\.example\.com\."/); + +@lines = grep { length } split /\n/, call_log(); +is(scalar @lines, 1, '/cleanup invokes dnsapi once (rm only)'); +like($lines[0], qr/^rm /, 'cleanup call is rm'); + +# --- host-scoped denial ---------------------------------------------------- +$t->post_ok('/present' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => 'foo.alice.example.com', value => 'tok' }) + ->status_is(401); + +# --- failure propagation -------------------------------------------------- +{ + local $ENV{ACMEPROXY_TEST_FAIL} = 1; + $t->post_ok('/present' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => '_acme-challenge.bob.example.com', value => 'tok' }) + ->status_is(500) + ->content_like(qr/check acmeproxy\.pl logs/); + $t->post_ok('/cleanup' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => '_acme-challenge.bob.example.com', value => 'tok' }) + ->status_is(500); +} + +# --- shell injection attempts --------------------------------------------- +# An authenticated user still cannot smuggle shell syntax through fqdn/value. +# The fqdn here ends with .bob.example.com so it passes auth's host regex, +# but acme_cmd's [\w_.-]+ check rejects the embedded ';'. +$t->post_ok('/present' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => 'foo;.bob.example.com', value => 'tok' }) + ->status_is(400) + ->content_like(qr/invalid characters in fqdn/); +$t->post_ok('/present' => { Authorization => basic('bob', 'dobbs') } + => json => { fqdn => 'foo.bob.example.com', value => 'tok`id`' }) + ->status_is(400) + ->content_like(qr/invalid characters in value/); + +done_testing(); diff --git a/t/40-bootstrap.t b/t/40-bootstrap.t new file mode 100644 index 0000000..7b30f2e --- /dev/null +++ b/t/40-bootstrap.t @@ -0,0 +1,157 @@ +use strict; +use warnings; +use Test::More; +use FindBin; +use lib "$FindBin::Bin/lib"; +use AcmeProxyTest qw(setup_testenv write_config_file); +use File::Temp qw(tempdir); +use File::Path qw(make_path); +use Cwd qw(cwd); + +my $script = "$FindBin::Bin/../acmeproxy.pl"; + +# =========================================================================== +# In-process: backcompat defaults +# =========================================================================== +# Test file's single require gets used here for the minimal-config case. +setup_testenv( + config => { + # Deliberately omit acmesh_extra_params_* and keypair_directory. + email => 'test@example.com', + dns_provider => 'dns_test', + env => {}, + hostname => 'test.example.com', + bind => '*:0', + auth => [{ user => 'bob', pass => 'dobbs', host => 'bob.example.com' }], + }, +); +require $script; +AcmeProxyTest::silence_logg(); + +my $cfg = main::app()->config; +is_deeply($cfg->{acmesh_extra_params_install}, [], 'acmesh_extra_params_install default []'); +is_deeply($cfg->{acmesh_extra_params_install_cert}, [], 'acmesh_extra_params_install_cert default []'); +is_deeply($cfg->{acmesh_extra_params_issue}, [], 'acmesh_extra_params_issue default []'); +is($cfg->{keypair_directory}, "$ENV{HOME}/.acme.sh", 'keypair_directory default is $HOME/.acme.sh'); + +# =========================================================================== +# Subprocess helpers +# =========================================================================== +sub run_script { + my (%args) = @_; + my $cwd = $args{cwd} or die "cwd required"; + my $envref = $args{env} || {}; + + my $original = cwd(); + my $pid = open my $fh, '-|'; + die "fork: $!" unless defined $pid; + if ($pid == 0) { + # child + chdir $cwd or die "chdir $cwd: $!"; + for my $k (keys %$envref) { $ENV{$k} = $envref->{$k} } + open STDERR, '>&', \*STDOUT or die "merge stderr: $!"; + exec $^X, $script; + die "exec failed: $!"; + } + my $out = do { local $/; <$fh> }; + close $fh; + my $exit = $?; + return ($out, $exit); +} + +sub stub_acme_home { + my $home = shift; + make_path("$home/.acme.sh/dnsapi"); + open my $fh, '>', "$home/.acme.sh/acme.sh"; close $fh; + open $fh, '>', "$home/.acme.sh/acmeproxy.pl.crt"; close $fh; + open $fh, '>', "$home/.acme.sh/acmeproxy.pl.key"; close $fh; + open $fh, '>', "$home/.acme.sh/dnsapi/dns_test.sh"; + print $fh "dns_test_add(){ return 0; }\ndns_test_rm(){ return 0; }\n"; + close $fh; +} + +# =========================================================================== +# Subprocess: missing config file -> writes example and dies +# =========================================================================== +{ + my $home = tempdir(CLEANUP => 1); + my $cwd = tempdir(CLEANUP => 1); + my ($out, $exit) = run_script(cwd => $cwd, env => { HOME => $home }); + isnt($exit, 0, 'script exits non-zero when config missing'); + like($out, qr/Example configuration file written/, 'die message printed'); + ok(-f "$cwd/acmeproxy.pl.conf", 'example config file written to cwd'); + my @stat = stat "$cwd/acmeproxy.pl.conf"; + is(($stat[2] & 07777), 0600, 'example config file mode is 0600'); +} + +# =========================================================================== +# Subprocess: DNS provider script missing -> dies +# =========================================================================== +{ + my $home = tempdir(CLEANUP => 1); + stub_acme_home($home); + # Remove the dns_test stub so dns_nope is the only configured provider + # and intentionally absent. + my $cwd = tempdir(CLEANUP => 1); + write_config_file($cwd, { + acmesh_extra_params_install => [], + acmesh_extra_params_install_cert => [], + acmesh_extra_params_issue => [], + email => 'test@example.com', + dns_provider => 'dns_nope', + env => {}, + hostname => 'test.example.com', + bind => '*:0', + auth => [{ user => 'bob', pass => 'dobbs', host => 'bob.example.com' }], + }); + + my ($out, $exit) = run_script(cwd => $cwd, env => { HOME => $home }); + isnt($exit, 0, 'script exits non-zero when dns provider missing'); + like($out, qr/acme dnslib provider not found: dns_nope/, + 'die message names the missing provider'); +} + +# =========================================================================== +# Subprocess: bcrypt hash configured but Crypt::Bcrypt unavailable -> dies +# =========================================================================== +# Two cases: +# - Crypt::Bcrypt IS available on this box: need Test::Without::Module to +# block it for the child process. Skip if that module isn't installed. +# - Crypt::Bcrypt is NOT available: the child will fail to load it +# naturally; no extra blocking needed. +SKIP: { + my $bcrypt_available = eval { require Crypt::Bcrypt; 1 }; + my $can_block_in_child = eval { require Test::Without::Module; 1 }; + + skip 'Crypt::Bcrypt is installed and Test::Without::Module is not — cannot block', 2 + if $bcrypt_available && !$can_block_in_child; + + my $home = tempdir(CLEANUP => 1); + stub_acme_home($home); + my $cwd = tempdir(CLEANUP => 1); + write_config_file($cwd, { + acmesh_extra_params_install => [], + acmesh_extra_params_install_cert => [], + acmesh_extra_params_issue => [], + email => 'test@example.com', + dns_provider => 'dns_test', + env => {}, + hostname => 'test.example.com', + bind => '*:0', + auth => [{ + user => 'bob', + hash => '$2b$12$ZkfzP1DVcFHSXyrtMRXJR.Ny2fpSixG00oLI2iMkT3yArpzs/921u', + host => 'bob.example.com', + }], + }); + + my %env = (HOME => $home); + $env{PERL5OPT} = '-MTest::Without::Module=Crypt::Bcrypt' if $bcrypt_available; + + my ($out, $exit) = run_script(cwd => $cwd, env => \%env); + isnt($exit, 0, 'script exits non-zero on bcrypt hash without Crypt::Bcrypt'); + like($out, qr/Crypt::Bcrypt is not available/, + 'die message names the missing module'); +} + +done_testing(); diff --git a/t/Makefile b/t/Makefile new file mode 100644 index 0000000..16dde83 --- /dev/null +++ b/t/Makefile @@ -0,0 +1,10 @@ +PROVE := prove + +.PHONY: all test verbose +all: test + +test: + $(PROVE) *.t + +verbose: + $(PROVE) -v *.t diff --git a/t/lib/AcmeProxyTest.pm b/t/lib/AcmeProxyTest.pm new file mode 100644 index 0000000..6d320c2 --- /dev/null +++ b/t/lib/AcmeProxyTest.pm @@ -0,0 +1,104 @@ +package AcmeProxyTest; +use strict; +use warnings; +use feature 'say'; + +use Exporter 'import'; +our @EXPORT_OK = qw( + setup_testenv + silence_logg + clear_log + write_config_file + $TMPDIR + @LOG_LINES +); + +use File::Temp qw(tempdir); +use File::Path qw(make_path); +use Cwd qw(cwd); +use Data::Dumper; + +our $TMPDIR; +our @LOG_LINES; + +sub _default_config { + return { + acmesh_extra_params_install => [], + acmesh_extra_params_install_cert => [], + acmesh_extra_params_issue => [], + email => 'test@example.com', + dns_provider => 'dns_test', + env => {}, + hostname => 'test.example.com', + bind => '*:0', + auth => [ + { user => 'bob', pass => 'dobbs', host => 'bob.example.com' }, + { user => 'alice', pass => 'rabbit', host => 'alice.example.com' }, + ], + }; +} + +# Stub dnsapi provider. Both add/rm honour $ACMEPROXY_TEST_FAIL: if set, return 1. +# Each invocation appends a line to $HOME/.acme.sh/calls.log so tests can assert +# on call count and arguments. +my $DNSAPI_STUB = <<'EOSH'; +dns_test_add() { + echo "add $1 $2" >> "$HOME/.acme.sh/calls.log" + [ -n "$ACMEPROXY_TEST_FAIL" ] && return 1 + return 0 +} +dns_test_rm() { + echo "rm $1 $2" >> "$HOME/.acme.sh/calls.log" + [ -n "$ACMEPROXY_TEST_FAIL" ] && return 1 + return 0 +} +EOSH + +sub _write_file { + my ($path, $content) = @_; + open my $fh, '>', $path or die "can't write $path: $!"; + print $fh $content; + close $fh; +} + +sub write_config_file { + my ($dir, $config) = @_; + my $dumped = Data::Dumper->new([$config])->Terse(1)->Sortkeys(1)->Indent(1)->Dump; + _write_file("$dir/acmeproxy.pl.conf", $dumped); + chmod 0600, "$dir/acmeproxy.pl.conf"; +} + +sub setup_testenv { + my %opts = @_; + + $TMPDIR = tempdir(CLEANUP => 1); + $ENV{HOME} = $TMPDIR; + + make_path("$TMPDIR/.acme.sh/dnsapi"); + _write_file("$TMPDIR/.acme.sh/acme.sh", "# stub\n"); + + unless (exists $opts{write_dnsapi} && !$opts{write_dnsapi}) { + _write_file("$TMPDIR/.acme.sh/dnsapi/dns_test.sh", $DNSAPI_STUB); + } + + _write_file("$TMPDIR/.acme.sh/acmeproxy.pl.crt", ""); + _write_file("$TMPDIR/.acme.sh/acmeproxy.pl.key", ""); + + my $config = exists $opts{config} ? $opts{config} : _default_config(); + write_config_file($TMPDIR, $config); + + chdir $TMPDIR or die "can't chdir $TMPDIR: $!"; + + return $TMPDIR; +} + +sub silence_logg { + no warnings 'redefine'; + *main::logg = sub { push @LOG_LINES, $_[0] }; +} + +sub clear_log { + @LOG_LINES = (); +} + +1; From f19471362c60fbdb179395d6106e79dc597d449f Mon Sep 17 00:00:00 2001 From: MadCamel Date: Wed, 13 May 2026 16:05:04 -0400 Subject: [PATCH 3/3] Run tests before publishing docker image; drop arm/v7 from build matrix. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/docker-publish.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3104df2..d4772b0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,16 +20,32 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Perl dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libmojolicious-perl \ + libcrypt-bcrypt-perl \ + libtest-without-module-perl \ + curl + + - name: Run test harness + run: make -C t test + build: + needs: test strategy: fail-fast: false matrix: platform: - docker: linux/amd64 s6arch: x86_64 - - docker: linux/arm/v7 - qemu: arm - s6arch: arm - docker: linux/arm64/v8 qemu: arm64 s6arch: aarch64