#!/usr/bin/perl # # htpdate time poller version 0.9.3 # Copyright (C) 2005 Eddy Vervest # Copyright (C) 2010-2011 Tails developers # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # http://www.gnu.org/copyleft/gpl.html use strict; use warnings; use version; our $VERSION = qv('0.9.3'); use Carp; use Cwd; use Data::Dumper; use DateTime; use DateTime::Format::DateParse; use English qw( -no_match_vars ); use File::Path qw(rmtree); use File::Spec::Functions; use File::Temp qw/tempdir/; use Getopt::Long::Descriptive; use List::Util qw( shuffle ); use open qw{:utf8 :std}; use POSIX qw( WIFEXITED ); use threads; use Try::Tiny; my $datecommand = '/bin/date'; # "date" command to set time my $dateparam = '-s'; # "date" parameter to set time my $maxadjust = 0; # maximum time step in seconds (0 means no max.) my $minadjust = 1; # minimum time step in seconds my ( $debug, $useragent, $log, $quiet, $set_date, $done_file, $res_file, $usage, $opt, $runas, $allowed_per_pool_failure_ratio, $proxy, @pools, ); sub done { if (defined $done_file) { $> = 0 if $runas; open my $f, '>', $done_file or print STDERR "Couldn't write done file: $done_file\n"; close $f; $> = getpwnam($runas) if $runas; } } $SIG{__DIE__} = sub { # Avoid the "done" file to be created by an catched exception. # When a eval block is being run, e.g. for exception catching, $^S is true. # It is false otherwise. done unless $^S; die(@_); }; sub message { my @msg = @_; if ($log) { open my $h, '>>', $log or die "Cannot open log file $log: $!"; print $h "@msg\n"; close $h; } else { print "@msg\n" unless $quiet; } } sub debug { message(@_) if $debug; } sub error { debug(@_); croak @_; } sub parseCommandLine () { # specify valid switches ($opt, $usage) = describe_options( 'htpdate %o', [ 'debug|d', "debug", { default => 0 } ], [ 'help', "print usage message and exit" ], [ 'quiet|q', "quiet", { default => 0 } ], [ 'user|u:s', "userid to run as" ], [ 'dont_set_date|x', "do not set the time (only show)", { default => 0 } ], [ 'user_agent|a:s', "http user agent to use", { default => "htpdate/$VERSION" } ], [ 'log_file|l:s', "log to this file rather than to STDOUT" ], [ 'done_file|D:s', "create this file after quitting in any way" ], [ 'success_file|T:s', "create this file after setting time successfully" ], [ 'pal_pool=s@', "distrusted hostnames" ], [ 'neutral_pool=s@', "neutral hostnames" ], [ 'foe_pool=s@', "distrusted hostnames" ], [ 'allowed_per_pool_failure_ratio:f', "ratio (0.0-1.0) of allowed per-pool failure", { default => 1.0 } ], [ 'proxy|p:s', "what to pass to curl's --socks5-hostname (if unset, environment variables may affect curl's behavior -- see curl(1) for details)" ], ); usage() if $opt->help; usage() unless $opt->pal_pool && $opt->neutral_pool && $opt->foe_pool; $runas = $opt->user if $opt->user; $> = getpwnam($runas) if $runas; $useragent = $opt->user_agent; $debug = $opt->debug; $log = $opt->log_file if $opt->log_file; $quiet = $opt->quiet; $set_date = ! $opt->dont_set_date; $done_file = $opt->done_file if $opt->done_file; $res_file = $opt->success_file if $opt->success_file; $allowed_per_pool_failure_ratio = $opt->allowed_per_pool_failure_ratio; $proxy = $opt->proxy if $opt->proxy; @pools = map { [ map { $_ = 'https://'.$_ unless $_ =~ /^http/i; } split(/,/, join(',', @{$_})) ] } ($opt->pal_pool, $opt->neutral_pool, $opt->foe_pool); } sub usage () { print STDERR $usage->text; exit; } sub newestDateHeader { my ($dir) = @_; my @files = grep { ! ( $_ =~ m|/?\.{1,2}$| ) } glob("$dir/.* $dir/*"); @files or error "No downloaded files can be found"; my $newestdt; foreach my $file (@files) { next if -l $file || -d _; my $date; open(my $file_h, '<', $file) or die "Can not read file $file: $!"; while (my $line = <$file_h>) { chomp $line; # empty line == we leave the headers to go into the content last if $line eq ''; last if ($date) = ($line =~ m/^\s*Date:\s+(.*)$/m); } close $file_h; if (defined $date) { # RFC 2616 (3.3.1) says Date headers MUST be represented in GMT my $dt = DateTime::Format::DateParse->parse_datetime( $date, 'GMT' ); if (! defined $newestdt || DateTime->compare($dt, $newestdt) > 0) { $newestdt = $dt; } } } return $newestdt; } =head2 random_first_with_allowed_failure_ratio Returns the result of the first successful application of $args->{code} on a random element of $args->{list}. Success is tested using the $args->{is_success} predicate, called on the value returned by $args->{code}. $args->{allowed_failure_ratio} caps the maximum failure ratio before giving up. $args->{code} is called with two arguments: the currently (randomly picked) considered element, and $args->{args}. Any exceptions thrown by $args->{code} is catched. =cut sub random_first_with_allowed_failure_ratio { my $args = shift; my %tried; $tried{$_} = 0 for (@{$args->{list}}); my $failures = 0; my $total = keys %tried; while ( $failures / $total <= $args->{allowed_failure_ratio} ) { my @randomized_left = shuffle grep { ! $tried{$_} } keys(%tried); my $picked = $randomized_left[0]; $tried{$picked}++; my $res; try { $res = $args->{code}->($picked, $args->{args}) }; return $res if $args->{is_success}->($res); $failures++; } return; } sub getPoolDateDiff { my $args = shift; random_first_with_allowed_failure_ratio({ list => $args->{urls}, code => \&getUrlDateDiff, is_success => sub { defined shift }, allowed_failure_ratio => $allowed_per_pool_failure_ratio, }); } sub getUrlDateDiff { my $url = shift; my $args = shift; defined $url or error "getUrlDateDiff must be passed an URL"; debug("getUrlDateDiff: $url"); my $tmpdir = tempdir("XXXXXXXXXX", TMPDIR => 1); my @curl_options = ( '--user-agent', $useragent, '--silent', '--proto', '=https', '--tlsv1', '--head', '--output', catfile($tmpdir, 'headers'), ); push @curl_options, ('--socks5-hostname', $proxy) if defined $proxy; my @cmdline = ('curl', @curl_options, $url); # fetch (the page and) referenced resources: # images, stylesheets, scripts, etc. my $before = DateTime->now->epoch(); WIFEXITED(system(@cmdline)) or error "Failed to fetch content from $url: $!"; my $local = DateTime->now->epoch(); my $newestdt; eval { $newestdt = newestDateHeader($tmpdir) }; if ($EVAL_ERROR =~ m/No downloaded files can be found/) { rmtree($tmpdir); error "No file could be downloaded from $url."; } rmtree($tmpdir); defined $newestdt or error "Could not get any Date header"; my $newest_epoch = $newestdt->epoch(); my $diff = $newest_epoch - $local; my $took = $local - $before; debug("$url (took ${took}s) => diff = $diff second(s)"); return $diff; } sub adjustDate { my ($diff) = @_; defined $diff or error "adjustDate was passed an undefined diff"; my $local = DateTime->now->epoch(); my $absdiff = abs($diff); debug("Median diff: $diff second(s)"); if ( $maxadjust && $absdiff gt $maxadjust ) { message("Not setting clock as diff ($diff seconds) is too large."); } elsif ( $absdiff lt $minadjust) { message("Not setting clock as diff ($diff seconds) is too small."); } else { my $newtime = DateTime->now->epoch + $diff; message("Setting time to $newtime..."); if ($set_date) { $> = 0 if $runas; open(my $fd, "-|", $datecommand, $dateparam, '@' . $newtime) or die "Cannot set run command $datecommand: $!"; if ( $? != 0 ) { my @output = <$fd>; error "An error occured setting the time\n@output"; } close($fd); $> = getpwnam($runas) if $runas; } } if (defined $res_file) { $> = 0 if $runas; open my $res_h, '>>', $res_file or die "Cannot open res file $res_file: $!"; print $res_h "$diff\n"; close $res_h; $> = getpwnam($runas) if $runas; } } sub median { my @a = sort {$a <=> $b} @_; return ($a[$#a/2] + $a[@a/2]) / 2; } parseCommandLine(); message("Running htpdate."); my @diffs = grep { defined $_ } map { my $diff = $_->join(); if (! defined $diff) { error('Aborting as one pool could not be reached'); } $diff; } map { threads->create(\&getPoolDateDiff, { urls => $_ }) } @pools or error "No Date header could be received."; adjustDate(median(@diffs)); done;