Blame view

dyndns.pl 14.7 KB
Frederik Lindenaar authored
1
2
3
4
5
#!/usr/bin/perl

#
# dyndns.pl - CGI-BIN script to handle Dynamic DNS updates through http
#
Frederik Lindenaar authored
6
# Version 1.1, latest version, documentation and bugtracker available at:
Frederik Lindenaar authored
7
#		https://gitlab.lindenaar.net/scripts/dyndns
Frederik Lindenaar authored
8
#
Frederik Lindenaar authored
9
# Copyright (c) 2013 - 2019 Frederik Lindenaar
Frederik Lindenaar authored
10
#
Frederik Lindenaar authored
11
12
13
# This script is free software: you can redistribute and/or modify it under the
# terms of version 3 of the GNU General Public License as published by the Free
# Software Foundation, or (at your option) any later version of the license.
Frederik Lindenaar authored
14
#
Frederik Lindenaar authored
15
16
17
# This script is distributed in the hope that it will be useful but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
Frederik Lindenaar authored
18
19
#
# You should have received a copy of the GNU General Public License along with
Frederik Lindenaar authored
20
# this program.  If not, visit <http://www.gnu.org/licenses/> to download it.
Frederik Lindenaar authored
21
22
23
24
25
26
27
28
#

use strict;
use CGI;
use POSIX;
use Net::DNS;
use feature 'state';
Frederik Lindenaar authored
29
my $ConfigFile = 'optional';    # hardcoded, either optional, required or ignore
Frederik Lindenaar authored
30
31

##############################
Frederik Lindenaar authored
32
# Configuration section (defaults, can be set in config file)
Frederik Lindenaar authored
33
34
my $DNSServer = '192.168.1.1';	# DNS Server to communicate with (use IP!)
my $ExpandCNAMEs = 1;		# CNAME levels to expand (0 to disable)
Frederik Lindenaar authored
35
my $AllowDebugKey = 'off';	# Debuging, 'off' to disable, '' for always on
Frederik Lindenaar authored
36
37
38
39
40
41
42
43
				# and other values to enable with debug= param.
my $AuthMode = 'remote';	# either 'static', 'remote' or 'both'
my $StaticSigner = '';		# required for AuthMode 'static' or 'both'
my $StaticKey = '';		# required for AuthMode 'static' or 'both'
my $RequireRR = '';		# Require existing record of this type for upd.
my $ExpireAfter = '1w';		# Expire time for registrations in minutes,
				# hours, weeks or seconds. format: [0-9]+[mhws]?
my @ReplaceRR = ('A', 'AAAA', 'TXT');	# Records types to replace in update
Frederik Lindenaar authored
44
45
46
my $RecordTTL = '1h';           # TTL for created records, format: [0-9]+[mhws]?
my $UpdateTXT = 'Last DynDNS update on'; # if set add TXT with this+date on update
my $DeleteTXT = 'DynDNS cleared on'; # if set add TXT with this+date on delete
Frederik Lindenaar authored
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64


##############################
# Support functions
my %months = (Jan=> 0, Feb=> 1, Mar=> 2, Apr=> 3, May=>4, Jun=> 5, Jul=> 6,
                Aug=> 7, Sep=> 8, Sep=> 8, Oct=> 9, Nov=> 10, Dec=> 11);
# Parse the date and return time for:    Sun May 26 21:22:15 2013
sub parse_localtime($) {
  my ($day, $month, $mday, $time, $year)=split(' ', $_[0]);
  return mktime(reverse(split(':',$time)),$mday,$months{$month},$year-1900);
}

sub is_ipv4 { return $_[0]=~/^((25[0-5]|(2[0-4]|[01]?[0-9]?)[0-9])(\.|$)){4}/; }
sub is_ipv6 { return $_[0]=~/^([0-9a-fA-F]{0,4}(:|$)){3,8}/i; }

sub periodSeconds($) {
  my ($number, $units) = ($_[0]=~/^(\d+)([smhdw])?$/);
  if($number && $units && $units cmp 's') {
Frederik Lindenaar authored
65
66
67
68
      $number *= ($units eq 'm') ? 60		# Seconds per minute
		: ($units cmp 'h') ? 3600	# Seconds per hour
		: ($units cmp 'd') ? 86400	# Seconds per day
		: 604800;			# Seconds per week
Frederik Lindenaar authored
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
  }
  return $number;
}

# Provide a resolver object for further use, re-using the initially created one
sub getResolver() {
    state $resolver;
    unless($resolver) {
        $resolver = Net::DNS::Resolver->new(
		nameservers => [ $DNSServer ],
		recurse => 0,
        );
    }
    return $resolver;
}

my %DNS_label = (
	A	=> 'IPv4 Address',
	AAAA	=> 'IPv6 Address',
	CNAME	=> 'Alias',
	TXT	=> 'Additional Information',
	MX	=> 'Mail Server',
	NS	=> 'Name Server',
	HINFO	=> 'Host Information',
);
sub DNS_decode_rr($) {
    my ($rr) = @_;
    return $rr->address if($rr->type eq 'A');
    return $rr->address if($rr->type eq 'AAAA');
    return lc($rr->cname) if($rr->type eq 'CNAME');
    return $rr->txtdata if($rr->type eq 'TXT');
    return $rr->nsdname if($rr->type eq 'NS');
    return $rr->exchange.' (priority '.$rr->preference.')' if($rr->type eq 'MX');
    return $rr->cpu.', '.$rr->os if($rr->type eq 'HINFO');
Frederik Lindenaar authored
103
    die "No support for $rr " . $rr->type . " in DNS_decode_rr()!, aborted!";
Frederik Lindenaar authored
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
}

# Retrieve a single value from the DNS server of a given type or everything
sub DNS_get($;$) {
    my ($host, $type) = @_;
    if (my $response = getResolver()->send($host, $type || 'ANY')) {
	return $response unless($type);
	foreach my $rr ($response->answer) {
	    next unless $rr->type eq $type;
	    return DNS_decode_rr($rr);
	}
    } else {
	die 'DNS query failed: ', getResolver()->errorstring, "\n";
    }
}

# Check whether the hostname provided is actually a CNAME, returns the host
# it points to in case of a CNAME or the original hostname if it is not.
sub expand_CNAME($;$) {
    my ($host, @found) = @_;
    if(my $cname=DNS_get($host, 'CNAME')) {
        push(@found, $host);
	die $found[0]."has > $ExpandCNAMEs level deep CNAME chain, aborting!\n"
			unless($#found < $ExpandCNAMEs);
	foreach my $r (@found) {
	    die "found CNAME loop for $host, aborting!\n" if($cname eq $r);
        }

        return &expand_CNAME($cname, @found);
    }
    return $host;
}
Frederik Lindenaar authored
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# Get signer and key CGI paramseters, abort with HTTP 400 error if not present
sub get_authinfo($$) {
  my ($cgi, $host) = @_;

  # Get signer and key parameters
  my $signer   = ($AuthMode eq 'static') ? $StaticSigner : ($cgi->param('user')
		 || (($AuthMode eq 'both') ? $StaticSigner : $host));
  my $key      = ($AuthMode eq 'static') ? $StaticKey : ($cgi->param('secret')
		 || (($AuthMode eq 'both') ? $StaticSigner : undef));

  # Ensure we have a value for signer and key, otherwise abort the processing
  if($signer eq '' or $key eq '') {
    print $cgi->header(-status=>400, -type=>'text/plain'),
	  "ERROR - No/incomplete authentication information provided\n";
    exit
  }

  # and return the values
  return($signer, $key);
}

# Perform an DNS Update for a single host
Frederik Lindenaar authored
159
160
161
sub DNS_Update($$$$$$$) {
  my ($dnsdomain, $dnshost, $ipv4, $ipv6, $signer, $key, $debug) = @_;
  my $dnsupdate = Net::DNS::Update->new($dnsdomain);
Frederik Lindenaar authored
162
  my $ttl = periodSeconds($RecordTTL);
Frederik Lindenaar authored
163
164
165
166
167
168
169
170
171
172

  # If $RequireRR is set, ensure an records of specified type exist for the name
  $dnsupdate->push(pre => yxrrset("$dnshost. $RequireRR")) if($RequireRR);

  # Replace any existing A, AAAA and TXT entries (update was requested)
  foreach my $rrtype (@ReplaceRR) {
    $dnsupdate->push(update=>rr_del("$dnshost. $rrtype"));
  }

  # Add new A and AAAA record based on whether ipv4 and ipv6 address provided
Frederik Lindenaar authored
173
174
  $dnsupdate->push(update=>rr_add("$dnshost. $ttl A $ipv4")) if($ipv4);
  $dnsupdate->push(update=>rr_add("$dnshost. $ttl AAAA $ipv6")) if($ipv6);
Frederik Lindenaar authored
175
Frederik Lindenaar authored
176
177
178
179
180
  # Add a TXT record with the timestamp of the last update, if required
  if (my $txt = ($ipv4 or $ipv6) ? $UpdateTXT : $DeleteTXT) {
      my $timestamp = localtime();
      $dnsupdate->push(update=>rr_add("$dnshost. $ttl TXT \"$txt $timestamp\""));
  }
Frederik Lindenaar authored
181
182
183
184
185
186
187
188
189
190
191

  # Sign the request with the signer and key
  $dnsupdate->sign_tsig($signer, $key);

  my $debugmessage = ($debug)
	? "\n\n\n========================================================\n" .
		$dnsupdate->string . "\n"
	: "\n";

  if(my $response = getResolver()->send($dnsupdate)) {
    $debugmessage .= $response->string . "\n"	if($debug);
Frederik Lindenaar authored
192
Frederik Lindenaar authored
193
    if ($response->header->rcode eq 'NOERROR') {
Frederik Lindenaar authored
194
	return (200, "OK - DNS update for $dnshost succeeded: " .
Frederik Lindenaar authored
195
196
197
		$response->header->rcode . $debugmessage);
    } else {
	# REFUSED, FORMERR
Frederik Lindenaar authored
198
	return (400, "ERROR - DNS update for $dnshost failed: " .
Frederik Lindenaar authored
199
200
201
202
203
204
205
206
207
208
209
210
211
		$response->header->rcode . $debugmessage);
    }
  } else {
    return (503, 'ERROR - DNS update for $dnshost failed: '.
			getResolver()->errorstring. $debugmessage);
  }
}


##############################
# Handlers for the different requests
sub handle_update($$$$$$) {
  my ($cgi, $mode, $host, $dnshost, $dnsdomain, $debug) = @_;
Frederik Lindenaar authored
212
  my ($signer, $key) = get_authinfo($cgi, $host);
Frederik Lindenaar authored
213
214
215
216
217
218
219
220
221

  # perform the action
  my ($statuscode, $statusmessage);
  if($mode eq 'delete') {
    ($statuscode, $statusmessage) =
	DNS_Update($dnsdomain, $dnshost, undef, undef, $signer, $key, $debug)
  } else {
    # Get ipv4, and ipv6 parameters
    my $remote_addr = $cgi->remote_addr;
Frederik Lindenaar authored
222
223
224
225
226
227
228
229
    my $ipv4addr = $cgi->param('ipv4addr') || $cgi->param('ip');
    if ($ipv4addr == 'auto') {
        $ipv4addr = is_ipv4($remote_addr) ? $remote_addr : undef;
    }
    my $ipv6addr = $cgi->param('ipv6addr') || $cgi->param('ipv6');
    if ($ipv6addr == 'auto') {
        $ipv6addr = is_ipv6($remote_addr) ? $remote_addr : undef;
    }
Frederik Lindenaar authored
230
231
232
233
234
235
236
    ($statuscode, $statusmessage) =
	DNS_Update($dnsdomain, $dnshost, $ipv4addr, $ipv6addr, $signer, $key, $debug);
  }

  # And report back the status
  print $cgi->header(-status=>$statuscode, -type=>'text/plain'), $statusmessage;
Frederik Lindenaar authored
237
}
Frederik Lindenaar authored
238
239
240

sub handle_expire($$$$$$) {
  my ($cgi, $mode, $host, $dnshost, $dnsdomain, $debug) = @_;
Frederik Lindenaar authored
241
  my ($signer, $key) = get_authinfo($cgi, $host);
Frederik Lindenaar authored
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268

  my $debugmsg = ($debug) ? "\n" : '';

  # perform the action
  if(my $period = periodSeconds($ExpireAfter)) {
    my $validafter = time - $period;
    if($debug) {
      $debugmsg .= "ExpireAfter $ExpireAfter, expiring all entries older than ";
      $debugmsg .= localtime($validafter) . "\n";
    }
    foreach my $rr (getResolver->axfr($dnsdomain)) {
      next if($rr->name eq $dnsdomain);
      if($rr->type eq 'TXT' &&
		$rr->txtdata=~/$UpdateTXT(.*\d\d:\d\d:\d\d \d{4})$/){
	if(my $lastupdate = parse_localtime($1)) {
	  DNS_Update($dnsdomain, $dnshost, undef, undef, $signer, $key, $debug)
		unless($lastupdate > $validafter);
	  if($debug) {
	    $debugmsg .= ($lastupdate > $validafter) ? 'Keeping ' : 'Expiring ';
	    $debugmsg .= $rr->name . " last update ($1)\n";
	  }
	} elsif($debug) {
	  $debugmsg .= 'Skipping '. $rr->name ." TXT: '". $rr->txtdata ."'\n";
	}
      }
    }
  # And report back the status
Frederik Lindenaar authored
269
  print $cgi->header(-status=>200, -type=>'text/plain'),
Frederik Lindenaar authored
270
271
272
273
	"OK - DNS expiry for $dnsdomain succeeded\n" . $debugmsg;
  }
}
Frederik Lindenaar authored
274
sub handle_view($$$$$$) {
Frederik Lindenaar authored
275
276
277
278
279
280
281
282
283
284
285
286
287
288
  my ($cgi, $mode, $host, $dnshost, $dnsdomain, $debug) = @_;
  my $title = "DynDNS Updater - $host";
  print $cgi->header(-status=>200),
	$cgi->start_html(-title => $title),
	$cgi->h1($title);

  print $cgi->p("$host resolves to $dnshost")	if($host cmp $dnshost);

  print $cgi->p("Current DNS information for $dnshost:"),
	$cgi->start_table,
	$cgi->Tr({-align=>'LEFT',-valign=>'TOP'}, [
		$cgi->th(['Field', 'Value'])
	]);
  foreach my $rr (DNS_get($dnshost)->answer) {
Frederik Lindenaar authored
289
290
291
    if(my $label = $DNS_label{$rr->type}) {
        print $cgi->Tr([ $cgi->td([$label, DNS_decode_rr($rr)]) ]);
    }
Frederik Lindenaar authored
292
293
294
295
296
297
  }
  print $cgi->end_table();

  print $cgi->end_html();
}
Frederik Lindenaar authored
298
sub handle_list($$$$$$) {
Frederik Lindenaar authored
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
  my ($cgi, $mode, $host, $dnshost, $dnsdomain, $debug) = @_;
  my $title = "DynDNS Updater - $dnsdomain";

  print $cgi->header(-status=>200),
	$cgi->start_html(-title => $title),
	$cgi->h1($title);

  print $cgi->p("Current DNS information for $dnsdomain:"),
	$cgi->start_table,
	$cgi->Tr({-align=>'LEFT',-valign=>'TOP'}, [
		$cgi->th(['Name', 'Field', 'Value'])
	]);
  my $lastname = '';
  foreach my $rr (getResolver->axfr($dnsdomain)) {
    next if($rr->name eq $dnsdomain);
Frederik Lindenaar authored
314
315
316
317
318
    if(my $label = $DNS_label{$rr->type}) {
        print $cgi->Tr([ $cgi->td([ ($lastname cmp $rr->name) ? $rr->name : '',
						$label, DNS_decode_rr($rr)]) ]);
        $lastname = $rr->name;
    }
Frederik Lindenaar authored
319
320
321
322
323
324
325
326
  }
  print $cgi->end_table();

  print $cgi->end_html();
}

my $cgi = CGI->new;
Frederik Lindenaar authored
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
##############################
# Load configuration, if desired
if ($ConfigFile cmp 'ignore') {
    my $CFGFile = $0;
    $CFGFile =~ s/(\.pl)?$/.cfg/;
    if (open (CONFIG, $CFGFile)) {
        my %CONFIG = (
            allow_debug_key => \$AllowDebugKey, dns_server      => \$DNSServer,
            expand_cnames   => \$ExpandCNAMEs,  auth_mode       => \$AuthMode,
            static_signer   => \$StaticSigner,  static_key      => \$StaticKey,
            require_rr      => \$RequireRR,     replace_rr      => \@ReplaceRR,
            update_txt      => \$UpdateTXT,     delete_txt      => \$DeleteTXT,
            expire_after    => \$ExpireAfter,   record_ttl      => \$RecordTTL,
        );

        while (<CONFIG>) {
            chomp; s/^\s+//; s/\s*(#.*)?$//;        # trim whitespace & comments
            next unless length;                               # skip empty lines
            my ($key, $value) = split(/\s*=\s*/, $_, 2);   # split key and value
            my $dst = $CONFIG{$key};                 # get destination for value
            if (ref $dst eq 'SCALAR') { $$dst = $value; }   # store scalar value
            elsif (ref $dst eq 'CODE') { &$dst($value); } # call setter function
            elsif (ref $dst cmp 'ARRAY') { die "Invalid $key in $CFGFile\n"; }
            else  { @$dst = split(/\s*,\s*/, $value); }  # split and store array
        }
        close CONFIG;
    } elsif ($ConfigFile eq 'required') {
       die "unable to load configuration from $CFGFile: $!, aborting\n"
    }
}
Frederik Lindenaar authored
358
359
360
##############################
# Validate Configuration
my $CE = 'Configuration Error:';
Frederik Lindenaar authored
361
362
363
die "$CE ConfigFile must be optional, required or ignore, not '$ConfigFile'\n"
	unless $ConfigFile=~/optional|required|ignore/;
die "$CE AuthMode '$AuthMode' is unsupported must be remote, static or both\n"
Frederik Lindenaar authored
364
	unless $AuthMode=~/remote|static|both/;
Frederik Lindenaar authored
365
die "$CE StaticSigner must be set for \$AuthMode '$AuthMode'\n"
Frederik Lindenaar authored
366
	unless ($StaticSigner or $AuthMode eq 'remote');
Frederik Lindenaar authored
367
die "$CE StaticKey must be set for \$AuthMode '$AuthMode'\n"
Frederik Lindenaar authored
368
	unless ($StaticKey or $AuthMode eq 'remote');
Frederik Lindenaar authored
369
die "$CE RequireRR is set to unsupported type '$RequireRR'\n"
Frederik Lindenaar authored
370
	if ($RequireRR and not $DNS_label{$RequireRR});
Frederik Lindenaar authored
371
372
373
374
375
die "$CE RecordTTL '$RecordTTL' is not supported\n"
	unless ($RecordTTL=~/^\d+[smhw]?$/);
die "$CE ExpireAfter '$ExpireAfter' is not supported\n"
	unless ($ExpireAfter=~/^\d+[smhw]?$/);
die "$CE UpdateTXT must be set when \$ExpireAfter is set\n"
Frederik Lindenaar authored
376
377
	if($ExpireAfter and not $UpdateTXT);
foreach my $rrtype (@ReplaceRR) {
Frederik Lindenaar authored
378
379
  die "$CE ReplaceRR contains unsupported type '$rrtype'\n"
	unless exists $DNS_label{$rrtype};
Frederik Lindenaar authored
380
381
382
383
384
}


##############################
# Determine what to do and fetch the input parameters
Frederik Lindenaar authored
385
my $mode = $cgi->path_info || $cgi->param('mode') || 'view';
Frederik Lindenaar authored
386
387
388
389
390
391
392
393
$mode=~s/^\/([^\/]+)(\/(.*))?/$1/;
my $host = $cgi->param('host') || $3;
my $debug = ($AllowDebugKey eq 'off') ? 0 : ($AllowDebugKey eq ($cgi->param('debug') || ''));


##############################
# Dispatch the request to the correct handler
my %handlers = (
Frederik Lindenaar authored
394
  view		=> \&handle_view,
Frederik Lindenaar authored
395
396
  update	=> \&handle_update,
  delete	=> \&handle_update,
Frederik Lindenaar authored
397
  list		=> \&handle_list,
Frederik Lindenaar authored
398
399
  expire	=> \&handle_expire,
);
Frederik Lindenaar authored
400
if($host eq '' and $mode cmp 'list' and $mode cmp 'expire') {
Frederik Lindenaar authored
401
402
403
404
405
406
407
408
409
410
411
412
  print $cgi->header(-status=>400, -type=>'text/plain'),
	  "ERROR - No host name to act on specified\n";
} elsif(my $handler = $handlers{$mode}) {
  # Replace provided host with that of a CNAME it points to and determine domain
  my $dnshost = ($host) ? expand_CNAME($host) : undef;
  my $dnsdomain = $cgi->param('domain') || ($dnshost=~/\.(.*)$/)[0];
  $handler->($cgi, $mode, $host, $dnshost, $dnsdomain, $debug);
} else {
  print $cgi->header(-status=>($cgi->path_info) ? 404 : 400,
			-type=>'text/plain'),
	  "ERROR - File Not Found / Invalid Mode '$mode' specified\n";
}
Frederik Lindenaar authored
413