1#! @PATH_PERL@ -w
2# @configure_input
3
4# Copyright (C) 2015, 2017 Network Time Foundation
5# Author: Harlan Stenn
6#
7# General cleanup and https support: Paul McMath
8#
9# Original shell version:
10# Copyright (C) 2014 Timothe Litt litt at acm dot org
11#
12# This script may be freely copied, used and modified providing that
13# this notice and the copyright statement are included in all copies
14# and derivative works.  No warranty is offered, and use is entirely at
15# your own risk.  Bugfixes and improvements would be appreciated by the
16# author.
17
18######## BEGIN #########
19use strict;
20
21# Core modules
22use Digest::SHA qw(sha1_hex);
23use File::Basename;
24use File::Copy qw(move);
25use File::Temp qw(tempfile);
26use Getopt::Long qw(:config auto_help no_ignore_case bundling);
27use Sys::Syslog qw(:standard :macros);
28
29# External modules
30use HTTP::Tiny 0.056;
31use Net::SSLeay 1.49;
32use IO::Socket::SSL 1.56;
33
34my $VERSION = '1.004';
35
36my $RUN_DIR = '/tmp';
37my $RUN_UID = 0;
38my $TMP_FILE;
39my $TMP_FH;
40my $FILE_MODE = 0644;
41
42######## DEFAULT CONFIGURATION ##########
43# LEAP FILE SRC URIS
44#    HTTPS - (default)
45#    	https://www.ietf.org/timezones/data/leap-seconds
46#    HTTP - No TLS/SSL - (not recommended)
47#	http://www.ietf.org/timezones/data/leap-seconds.list
48
49my $LEAPSRC = 'https://www.ietf.org/timezones/data/leap-seconds.list';
50my $LEAPFILE;
51
52# How many times to try to download new file
53my $MAXTRIES = 6;
54my $INTERVAL = 10;
55
56my $NTPCONF='/etc/ntp.conf';
57
58# How long (in days) before expiration to get updated file
59my $PREFETCH = 60;
60my $EXPIRES;
61my $FORCE;
62
63# Output Flags
64my $QUIET;
65my $DEBUG;
66my $SYSLOG;
67my $TOTERM;
68my $LOGFAC = 'LOG_USER';
69
70######### PARSE/SET OPTIONS #########
71my %SSL_OPTS;
72my %SSL_ATTRS = (
73    verify_SSL => 1,  
74    SSL_options => \%SSL_OPTS,
75);
76
77our(%opt);
78
79GetOptions(\%opt,	
80	'C=s',
81	'D=s',
82	'e:60',
83	'F',
84	'f=s',
85	'h|help',
86	'i:10',
87	'L=s',
88	'l=s',
89	'q',
90	'r:6',
91	's',
92	't',
93	'u=s',
94	'v',
95	);
96
97$LOGFAC   = $opt{l} if defined $opt{l};
98$LEAPSRC  = $opt{u} if defined $opt{u};
99$LEAPFILE = $opt{L} if defined $opt{L};
100$PREFETCH = $opt{e} if defined $opt{e};
101$NTPCONF  = $opt{f} if defined $opt{f};
102$MAXTRIES = $opt{r} if defined $opt{r};
103$INTERVAL = $opt{i} if defined $opt{i};
104
105$FORCE   = 1 if defined $opt{F};
106$DEBUG	 = 1 if defined $opt{v};
107$QUIET   = 1 if defined $opt{q};
108$SYSLOG  = 1 if defined $opt{s};
109$TOTERM  = 1 if defined $opt{t};
110
111$SSL_OPTS{SSL_ca_file} = $opt{C} if (defined($opt{C}));
112$SSL_OPTS{SSL_ca_path} = $opt{D} if (defined($opt{D}));
113
114###############
115## START MAIN
116###############
117my $PROG = basename($0);
118
119# Logging - Default is to use syslog(3) if STDOUT isn't 
120# connected to a tty.
121if ($SYSLOG || !-t STDOUT) {
122    $SYSLOG = 1;
123    openlog($PROG, 'pid', $LOGFAC);
124} 
125else {
126    $TOTERM = 1;
127}
128
129if (defined $opt{q} && defined $opt{v}) {
130    log_fatal(LOG_ERR, '-q and -v options mutually exclusive');
131}
132
133if (defined $opt{L} && defined $opt{f}) {
134    log_fatal(LOG_ERR, '-L and -f options mutually exclusive');
135}
136
137$SIG{INT} = \&signal_catcher;
138$SIG{TERM} = \&signal_catcher;
139$SIG{QUIT} = \&signal_catcher;
140
141# Take some security precautions
142close STDIN;
143
144# Show help
145if (defined $opt{h}) {
146    show_help();
147    exit 0;
148}
149
150if ($< != $RUN_UID) {
151    log_fatal(LOG_ERR, 'User ' . getpwuid($<) . " (UID $<) tried to run $PROG");
152}
153
154chdir $RUN_DIR || log_fatal("Failed to change dir to $RUN_DIR");
155
156# Parse ntp.conf for path to leapfile if not set by user
157if (! $LEAPFILE) {
158
159    open my $LF, '<', $NTPCONF || log_fatal(LOG_ERR, "Can't open <$NTPCONF>: $!");
160
161    while (<$LF>) {
162	chomp;
163	$LEAPFILE = $1 if /^ *leapfile\s+"(\S+)"/;
164    }
165    close $LF;
166
167    if (! $LEAPFILE) {
168	log_fatal(LOG_ERR, "No leapfile directive in $NTPCONF; leapfile location not known"); 
169    }
170}
171
172-s $LEAPFILE || logger(LOG_DEBUG, "Leapfile $LEAPFILE is empty");
173
174# Download new file if:
175#   1. file doesn't exist
176#   2. invoked w/ force flag (-F)
177#   3. current file isn't valid
178#   4. current file expired or expires soon
179
180if ( !-e $LEAPFILE || $FORCE || ! verifySHA($LEAPFILE) || 
181	( $EXPIRES lt ( $PREFETCH * 86400 + time() ) )) {
182
183    for (my $try = 1; $try <= $MAXTRIES; $try++) {
184	logger(LOG_DEBUG, "Attempting download from $LEAPSRC, try $try..");
185
186	($TMP_FH, $TMP_FILE) = tempfile(UNLINK => 1, SUFFIX => '.list');
187
188	if (retrieve_file($TMP_FH)) {
189
190            if ( verifySHA($TMP_FILE) ) {
191		move_file($TMP_FILE, $LEAPFILE);
192		chmod $FILE_MODE, $LEAPFILE; 
193		logger(LOG_INFO, "Installed new $LEAPFILE from $LEAPSRC");
194	    }
195	    else {
196                logger(LOG_ERR, "Downloaded file $TMP_FILE rejected -- saved for diagnosis");
197		move_file($TMP_FILE, 'leap-seconds.list_corrupt');
198		exit 1;
199            }
200	    # Fall through
201            exit 0;
202	}
203
204	# Failure
205	unlink $TMP_FILE;
206	logger(LOG_INFO, "Download failed. Waiting $INTERVAL minutes before retrying...");
207        sleep $INTERVAL * 60 ;
208    }
209
210    # Failed and out of retries
211    log_fatal(LOG_ERR, "Download from $LEAPSRC failed after $MAXTRIES attempts");
212}
213
214logger(LOG_INFO, "Not time to replace $LEAPFILE");
215
216exit 0;
217
218######## SUB ROUTINES #########
219sub move_file {
220
221    (my $src, my $dst) = @_;
222
223    if ( move($src, $dst) ) {
224	logger(LOG_DEBUG, "Moved $src to $dst");
225    } 
226    else {
227	log_fatal(LOG_ERR, "Moving $src to $dst failed: $!");
228    }
229}
230
231# Removes temp file if terminating signal recv'd
232sub signal_catcher {
233    my $signame = shift;
234
235    close $TMP_FH;
236    unlink $TMP_FILE;
237    log_fatal(LOG_INFO, "Recv'd SIG${signame}. Terminating.");
238}	    
239
240sub log_fatal {
241    my ($p, $msg) = @_;
242    logger($p, $msg);
243    exit 1;
244}
245
246sub logger {
247    my ($p, $msg) = @_;
248
249    # Suppress LOG_DEBUG msgs unless $DEBUG set
250    return if (!$DEBUG && $p eq LOG_DEBUG);
251
252    # Suppress all but LOG_ERR msgs if $QUIET set
253    return if ($QUIET && $p ne LOG_ERR);
254
255    if ($TOTERM) {
256        if ($p eq LOG_ERR) {	# errors should go to STDERR
257	    print STDERR "$msg\n";
258	}
259	else {
260	    print STDOUT "$msg\n";
261	}
262    }
263
264    if ($SYSLOG) {
265	syslog($p, $msg)
266    }
267}
268
269#################################
270# Connect to server and retrieve file
271#
272# Since we make as many as $MAXTRIES attempts to connect to the remote
273# server to download the file, the network socket should be closed after
274# each attempt, rather than let it be reused (because it may be in some
275# unknown state).
276#
277# HTTP::Tiny doesn't export a method to explicitly close a connected
278# socket, therefore, we instantiate the lexically scoped $http object in
279# a function; when the function returns, the object goes out of scope
280# and is destroyed, closing the socket.
281sub retrieve_file {
282
283    my $fh = shift;
284    my $http;
285
286    if ($LEAPSRC =~ /^https\S+/) {
287	$http = HTTP::Tiny->new(%SSL_ATTRS);
288	(my $ok, my $why) = $http->can_ssl;
289	log_fatal(LOG_ERR, "TLS/SSL config error: $why") if ! $ok;
290    } 
291    else {
292	$http = HTTP::Tiny->new();
293    }
294
295    my $reply = $http->get($LEAPSRC);
296
297    if ($reply->{success}) {
298	logger(LOG_DEBUG, "Download of $LEAPSRC succeeded");
299	print $fh $reply->{content} || 
300	    log_fatal(LOG_ERR, "Couldn't write new file contents to temp file: $!");
301	close $fh;
302	return 1;
303    } 
304    else {
305	close $fh;
306	return 0;
307    }
308}
309
310########################
311# Validate a leap-seconds file checksum
312#
313# File format: (full description in file)
314# Pound sign (#) marks comments, EXCEPT:
315# 	#$ number : the NTP date of the last update
316# 	#@ number : the NTP date that the file expires
317# 	#h hex hex hex hex hex : the SHA-1 checksum of the data & dates, 
318#	   excluding whitespace w/o leading zeroes
319#
320# Date (seconds since 1900) leaps : leaps is the # of seconds to add
321#  for times >= Date 
322# Date lines have comments.
323#
324# Returns:
325#   0	Invalid Checksum/Expired
326#   1	File is valid
327
328sub verifySHA {
329
330    my $file = shift;
331    my $fh;
332    my $data;
333    my $FSHA;
334
335    open $fh, '<', $file || log_fatal(LOG_ERR, "Can't open $file: $!");
336
337    # Remove comments, except those that are markers for last update,
338    # expires and hash
339    while (<$fh>) {
340	if (/^#\$/) {
341	    s/^..//;
342	    $data .= $_;
343	}
344	elsif (/^#\@/) {
345	    s/^..//;
346	    $data .= $_;
347	    s/\s+//g;
348	    $EXPIRES = $_ - 2208988800;
349	}
350	elsif (/^#h\s+([[:xdigit:]]+)\s+([[:xdigit:]]+)\s+([[:xdigit:]]+)\s+([[:xdigit:]]+)\s+([[:xdigit:]]+)/) {
351	    chomp;
352	    $FSHA = sprintf("%08s%08s%08s%08s%08s", $1, $2, $3, $4, $5);
353	}
354	elsif (/^#/) {
355	    # ignore it
356	}
357	elsif (/^\d/) {
358	    s/#.*$//;
359	    $data .= $_;
360	} 
361	else {
362	    chomp;
363	    print "Unexpected line: <$_>\n";
364	}
365    }
366    close $fh;
367
368    if ( $EXPIRES < time() ) {
369        logger(LOG_DEBUG, 'File expired on ' . gmtime($EXPIRES));
370        return 0;
371    }
372
373    if (! $FSHA) {
374	logger(LOG_NOTICE, "no checksum record found in file");
375	return 0;
376    }
377
378    # Remove all white space
379    $data =~ s/\s//g;
380
381    # Compute the SHA hash of the data, removing the marker and filename
382    # Computed in binary mode, which shouldn't matter since whitespace has been removed
383    my $DSHA = sha1_hex($data);
384
385    if ($FSHA eq $DSHA) {
386	logger(LOG_DEBUG, "Checksum of $file validated");
387	return 1;
388    } 
389    else {
390        logger(LOG_NOTICE, "Checksum of $file is invalid EXPECTED: $FSHA COMPUTED: $DSHA");
391        return 0;
392    }
393}
394
395sub show_help {
396print <<EOF
397
398Usage: $PROG [options]
399
400Verifies and if necessary, updates leap-second definition file
401
402All arguments are optional:  Default (or current value) shown:
403    -C    Absolute path to CA Cert (see SSL/TLS Considerations)
404    -D    Path to a CAdir (see SSL/TLS Considerations)
405    -e    Specify how long (in days) before expiration the file is to be
406    	  refreshed.  Note that larger values imply more frequent refreshes.
407          $PREFETCH
408    -F    Force update even if current file is OK and not close to expiring.
409    -f    Absolute path ntp.conf file (default /etc/ntp.conf)
410          $NTPCONF
411    -h    show help
412    -i    Specify number of minutes between retries
413          $INTERVAL
414    -L    Absolute path to leapfile on the local system
415	  (overrides value in ntp.conf)
416    -l    Specify the syslog(3) facility for logging
417          $LOGFAC
418    -q    Only report errors (cannot be used with -v)
419    -r    Specify number of attempts to retrieve file
420          $MAXTRIES
421    -s    Send output to syslog(3) - implied if STDOUT has no tty or redirected
422    -t    Send output to terminal - implied if STDOUT attached to terminal
423    -u    Specify the URL of the master copy to download
424          $LEAPSRC
425    -v    Verbose - show debug messages (cannot be used with -q)
426
427The following options are not (yet) implemented in the perl version:
428    -4    Use only IPv4
429    -6    Use only IPv6
430    -c    Command to restart NTP after installing a new file
431          <none> - ntpd checks file daily
432    -p 4|6
433          Prefer IPv4 or IPv6 (as specified) addresses, but use either
434
435$PROG will validate the file currently on the local system.
436
437Ordinarily, the leapfile is found using the 'leapfile' directive in
438$NTPCONF.  However, an alternate location can be specified on the
439command line with the -L flag.
440
441If the leapfile does not exist, is not valid, has expired, or is
442expiring soon, a new copy will be downloaded.  If the new copy is
443valid, it is installed.
444
445If the current file is acceptable, no download or restart occurs.
446
447This can be run as a cron job.  As the file is rarely updated, and
448leap seconds are announced at least one month in advance (usually
449longer), it need not be run more frequently than about once every
450three weeks.
451
452SSL/TLS Considerations
453-----------------------
454The perl modules can usually locate the CA certificate used to verify
455the peer's identity.
456
457On BSDs, the default is typically the file /etc/ssl/certs.pem.  On
458Linux, the location is typically a path to a CAdir - a directory of
459symlinks named according to a hash of the certificates' subject names.
460
461The -C or -D options are available to pass in a location if no CA cert
462is found in the default location.
463
464External Dependencies
465---------------------
466The following perl modules are required:
467HTTP::Tiny 	- version >= 0.056
468IO::Socket::SSL - version >= 1.56
469NET::SSLeay 	- version >= 1.49
470
471Version: $VERSION
472
473EOF
474}
475
476