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