]>
Raphaël G. Git Repositories - acme/blob - acme
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 # Copyright (C) 2016 - 2020 Raphaël Gertz <acme@rapsys.eu>
24 # # Allow use of print Dumper($this);
26 # # Prepend current directory in include array
27 # # XXX: this will load ./Acme.pm instead of an other version from system tree
31 # Add acl support to file tests
32 use filetest
qw(access);
35 use Carp
qw(carp confess);
36 use File
::Copy
qw(copy);
37 use File
::stat qw(stat);
38 use File
::Slurp
qw(read_file write_file);
39 use File
::Spec
qw(splitpath curdir);
40 use JSON
qw(decode_json);
42 use Net
::Domain
::TLD
qw(tld_exists);
47 use POSIX
qw(EXIT_SUCCESS EXIT_FAILURE);
60 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
61 tie
(%{$config}, 'Tie::IxHash');
66 # Init config file name
67 my $configFilename = Acme
::CONFIG
;
73 @ARGV = map { if ($_ eq '-v' or $_ eq '--verbose') { $verbose = 1; (); } else { $_; } } @ARGV;
76 @ARGV = map { if ($_ eq '-d' or $_ eq '--debian') { $debian = 1; (); } else { $_; } } @ARGV;
79 unless(defined $ARGV[0] and $ARGV[0] =~ /^(cert|cron|conf)$/) {
80 print "Usage: $0 (cert|cron|conf) [-(v|-verbose)] [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n";
89 # Process config and args
90 for (my $i = 0; $i <= $#ARGV; $i++) {
92 if ($ARGV[$i] =~ /^(?:(\-c|\-\-config)(?:=(.+))?)$/) {
93 # Extract -c=$2 or --config=$2 syntax
98 # Extract -c $ARGV[$i+1] or --config $ARGV[$i+1] writable status
99 } elsif (defined($ARGV[$i+1])) {
100 $configFilename = $ARGV[$i+1];
101 splice(@ARGV, $i, 2);
103 # Check if cert or cron action
104 } elsif ($action eq 'cert' or $action eq 'cron') {
105 print "Usage: $0 $action [-(c|-config)[=/etc/acme/config]] [example.com] [...]\n";
109 # Check if file don't exists
110 if (defined($configFilename) and ! -f
$configFilename) {
111 # Extract config directory and filename
112 my ($vol, $dir, $file) = File
::Spec-
>splitpath($configFilename);
116 # Set as current dir if empty
117 $dir = File
::Spec-
>curdir();
120 # Verify that directory exists
122 confess
('Config directory '.$dir.' must exists: '.$!);
125 # Check that directory is writable
127 confess
('Config directory '.$dir.' must be writable: '.$!);
133 # Check if conf action
134 if ($action eq 'conf') {
136 my $js = JSON-
>new->utf8->pretty(1)->space_before(0)->filter_json_object(
138 # Get source hash ref
141 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
142 my $r = tie
(my %r, 'Tie::IxHash');
145 # Insert key if present
146 $r{$_} = $x->{$_} if (defined($x->{$_}));
147 #XXX: Hash keys not present in this array will be dropped
148 #XXX: Hash keys will be inserted in tied hash in this order
152 'thumbprint', 'term', 'pending', 'certificates',
154 'cert', 'key', 'account', 'mail', 'domain', 'domains', 'prod'
156 # Return the ordered hash
161 # Check we have specified domains
162 unless (scalar(@ARGV) > 0) {
163 print "Usage: $0 $action [-(v|-verbose)] [-(d|-debian)] [-(c|-config)[=/etc/acme/config]] example.com[=www.example.com[,ftp.example.com]] [...]\n";
169 #XXX: use eval to workaround a fatal in decode_json
172 (-f
$configFilename) and
174 ($content = read_file
($configFilename)) and
176 ($config = $js->decode($content))
180 carp
('Config file '.$configFilename.' not readable or invalid: '.$!) if ($verbose);
182 # Create a default config
183 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
184 tie
(%{$config}, 'Tie::IxHash', 'thumbprint' => Acme
::THUMBPRINT
, 'term' => Acme
::TERM
, 'pending' => Acme
::PENDING
, 'certificates' => []);
186 # Fix root infos when missing
187 $config->{thumbprint
} = Acme
::THUMBPRINT
unless(defined($config->{thumbprint
}));
188 $config->{term
} = Acme
::TERM
unless(defined($config->{term
}));
189 $config->{pending
} = Acme
::PENDING
unless(defined($config->{pending
}));
190 $config->{certificates
} = [] unless(defined($config->{certificates
}) and ref($config->{certificates
}) eq 'ARRAY');
193 # Iterate on each certificates entry
194 for (my $i = 0; $i <= $#{$config->{certificates
}}; $i++) {
196 my $certificate = ${$config->{certificates
}}[$i];
198 # Drop the entry when missing domain key
199 unless (defined($certificate->{domain
})) {
200 splice(@{$config->{certificates
}}, $i, 1);
207 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
208 tie
(%{$replace}, 'Tie::IxHash', cert
=> Acme
::RH_CERTS
.'/'.$certificate->{domain
}.Acme
::RH_SUFFIX
, key
=> Acme
::RH_PRIVATE
.'/'.$certificate->{domain
}.Acme
::RH_SUFFIX
, account
=> Acme
::ACCOUNT
, mail
=> Acme
::MAIL
.'@'.$certificate->{domain
}, 'domain' => $certificate->{domain
}, 'domains' => [], prod
=> 0);
211 $replace->{cert
} = Acme
::DEB_CERTS
.'/'.$certificate->{domain
}.Acme
::DEB_CERTS_SUFFIX
;
212 $replace->{key
} = Acme
::DEB_PRIVATE
.'/'.$certificate->{domain
}.Acme
::DEB_PRIVATE_SUFFIX
;
216 $replace->{cert
} = $certificate->{cert
} if (defined($certificate->{cert
}));
219 $replace->{key
} = $certificate->{key
} if (defined($certificate->{key
}));
222 $replace->{account
} = $certificate->{account
} if (defined($certificate->{account
}));
225 $replace->{mail
} = $certificate->{mail
} if (defined($certificate->{mail
}));
228 $replace->{domains
} = $certificate->{domains
} if (defined($certificate->{domains
}) and ref($certificate->{domains
}) eq 'ARRAY');
231 $replace->{prod
} = $certificate->{prod
} if (defined($certificate->{prod
}));
233 # Replace certificate
234 ${$config->{certificates
}}[$i] = $replace;
238 # Check that domains are present in config
240 # Extract domain and domains
241 my ($domain, $domains) = split(/=/, $_);
244 my @domains = defined($domains) ? map { $_ ? $_ : (); } split(/,/, $domains) : ();
251 unless (($tld) = $_ =~ m/\.(\w+)$/) {
252 confess
('Extract '.$_.' tld failed');
255 # Check if tld exists
256 unless(Net
::Domain
::TLD
::tld_exists
($tld)) {
257 confess
('Extracted '.$_.' tld '.$tld.' do not exists');
261 my $a = Net
::DNS
::Resolver-
>new->search($_, 'A', 'IN');
264 my $aaaa = Net
::DNS
::Resolver-
>new->search($_, 'AAAA', 'IN');
266 # Trigger error for unresolvable domain
268 # Check if either has a A or AAAA record
270 ($_->type eq 'A' or $_->type eq 'AAAA') ? 1 : ();
274 (defined $a and defined $a->answer) ? $a->answer : (),
275 (defined $aaaa and defined $aaaa->answer) ? $aaaa->answer : ()
278 confess
('Resolve '.$_.' to an A or AAAA record failed');
280 } ($domain, @domains);
282 # Insert domain when missing
283 unless (scalar map { $_->{domain
} eq $domain ? 1 : (); } @{$config->{certificates
}}) {
285 my $certificate = undef;
288 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
289 tie
(%{$certificate}, 'Tie::IxHash', cert
=> undef, key
=> undef, account
=> Acme
::ACCOUNT
, mail
=> Acme
::MAIL
.'@'.$domain, 'domain' => $domain, 'domains' => [], prod
=> 0);
293 $certificate->{cert
} = Acme
::DEB_CERTS
.'/'.$domain.Acme
::DEB_CERTS_SUFFIX
;
294 $certificate->{key
} = Acme
::DEB_PRIVATE
.'/'.$domain.Acme
::DEB_PRIVATE_SUFFIX
;
297 $certificate->{cert
} = Acme
::RH_CERTS
.'/'.$domain.Acme
::RH_SUFFIX
;
298 $certificate->{key
} = Acme
::RH_PRIVATE
.'/'.$domain.Acme
::RH_SUFFIX
;
306 # Check if already present
307 unless (scalar map { $_ eq $subdomain ? 1 : (); } @{$certificate->{domains
}}) {
308 # Add when not already present
309 ${$certificate->{domains
}}[scalar @{$certificate->{domains
}}] = $_;
314 ${$config->{certificates
}}[scalar @{$config->{certificates
}}] = $certificate;
315 # Update domains when present
317 # Loop on all certificate
319 # Check that we are on the right domain
320 if ($_->{domain
} eq $domain) {
322 my $certificate = $_;
325 @{$certificate->{domains
}} = ();
332 # Check if already present
333 unless (scalar map { $_ eq $subdomain ? 1 : (); } @{$certificate->{domains
}}) {
334 # Add when not already present
335 ${$certificate->{domains
}}[scalar @{$certificate->{domains
}}] = $_;
339 } @{$config->{certificates
}};
343 # Extract config directory and filename
344 my ($vol, $dir, $file) = File
::Spec-
>splitpath($configFilename);
348 # Set as current dir if empty
349 $dir = File
::Spec-
>curdir();
352 # Backup old config if possible
353 if (-w
$dir and -f
$configFilename) {
354 my ($dt, $suffix) = undef;
356 # Extract datetime suffix
357 $suffix = ($dt = DateTime-
>from_epoch(epoch
=> stat($configFilename)->mtime))->ymd('').$dt->hms('');
360 unless(copy
($configFilename, $configFilename.'.'.$suffix)) {
361 carp
('Copy '.$configFilename.' to '.$configFilename.'.'.$suffix.' failed: '.$!);
363 # Check that directory is writable
364 } elsif (! -w
$dir and -f
$configFilename) {
365 confess
('Config directory '.$dir.' must be writable: '.$!);
368 # Encode config in json
369 #XXX: emulate a tab indent file by replacing 3 space indent with tab
370 ($content = $js->encode($config)) =~ s/(\G|^)\s{3}/\t/gm;
373 write_file
($configFilename, $content);
377 # Check if cert or cron action
378 } elsif ($action eq 'cert' or $action eq 'cron') {
381 #XXX: use eval to workaround a fatal in decode_json
384 (-f
$configFilename) and
386 ($content = read_file
($configFilename)) and
388 ($config = decode_json
($content)) and
389 # Check certificates presence
390 defined($config->{certificates
}) and
391 # Check certificates type
392 ref($config->{certificates
}) eq 'ARRAY' and
393 # Check thumbprint presence
394 defined($config->{thumbprint
}) and
395 # Check term presence
396 defined($config->{term
}) and
397 # Check pending presence
398 defined($config->{pending
}) and
399 # Check certificates array
402 defined($_->{cert
}) and
403 defined($_->{key
}) and
404 defined($_->{account
}) and
405 defined($_->{mail
}) and
406 defined($_->{domain
}) and
407 defined($_->{domains
}) and ref($_->{domains
}) eq 'ARRAY' and
414 } @{$config->{certificates
}}
417 confess
('Config file '.$configFilename.' not readable or invalid: '.$!);
421 #TODO: implement the new action
422 confess
('Unknown '.$action.' action');
425 # Deal with specified domains
426 if (scalar(@ARGV) > 0) {
427 # Check that domains are present in config
428 foreach my $domain (@ARGV) {
430 foreach my $certificate (@{$config->{certificates
}}) {
431 if ($certificate->{domain
} eq $domain) {
432 push(@domains, $certificate);
437 print 'Domain '.$domain.' not found in config file '.$configFilename."\n";
443 # Populate domains array with available ones
444 foreach my $certificate (@{$config->{certificates
}}) {
445 push(@domains, $certificate);
450 if (scalar(@domains) < 1) {
451 print "Usage: $0 conf [-(v|-verbose)] [-(d|-debian)] [-(c|-config)[=/etc/acme/config]] example.com[=www.example.com[,ftp.example.com]] [...]\n";
455 # Deal with each domain
456 foreach my $domain (@domains) {
457 # Skip certificate, in cron action, issued within the last 60 days
458 if ($action eq 'cron' and -f
$domain->{cert
} and stat($domain->{cert
})->mtime >= (time() - 60*24*3600)) {
459 carp
('Domain '.$domain->{domain
}.' certificate '.$domain->{cert
}.' skipped') if ($verbose);
463 my $acme = Acme-
>new($verbose, $domain, {thumbprint
=> $config->{thumbprint
}, pending
=> $config->{pending
}, term
=> $config->{term
}});
465 # Prepare environement
468 # Generate required keys