筆者が使っている Allied Telesisの AR560S/AR550SにはダイナミックDNS機能があるが、対応サービスにはStandard DNS(Dyn.com)しか使えない。まあ企業用途なら一口25ドルくらいどうってことはないが、個人用途でこれはちょっと面白くないので、DynDNS互換サービスを自作してみた話。
DynDNSプロトコル
まず基本的な情報だが、ARルータ側の設定項目はこう定義されている。
SET DDNS [SERVER=server] [PORT=port] [USER=userid] [PASSWORD=password] [DYNAMICHOST=hostnames] [PRIMARYINT=ipinterface] [SECONDARYINT=ipinterface] [OFFLINE={YES|NO|ON|OFF}] [PERIODICUPDATE={1..60|ON|OFF}]
server: ダイナミックDNSサーバー名。(1~31文字。英数字)
port: HTTPポート番号。80(HTTP)または 8245(HTTP Bypass)
userid: ユーザーID。(1~15文字。英数字。大文字小文字を区別する)
password: パスワード(1~15文字。英数字。大文字小文字を区別する)
hostnames: ホスト名(URL形式。カンマ区切りで複数指定可能。カンマを含め128文字まで。)
ipinterface: インターフェース名。(ppp0など)
その結果、DNSサーバには次のようなリクエストが送信される。
GET /nic/update?system=dyndns&hostname=example.dyndns.org&myip=123.45.67.89&wildcard=OFF&offline=OFF HTTP/1.1
Host: members.dyndns.org
Authorization: Basic [BASE64-ENCODED-USERNAME:PASSWORD-PAIR]
User-Agent: Dyn DNS Client/CentreCOM AR560S version 2.9.2-09 21-Aug-2012
これに対してサーバレスポンスにgood <IP_ADDR>
が返るとそれは正常に受け付けられたことになり、show ddns
で確認できるステータス表示が更新される。
送信URIとして/nic/update
は固定、ユーザ認証はBASIC認証方式、パラメータとしてhostname
は必須。CGIインタフェースとして実装すべき内容はこれだけなのでそう難しいことではない。
ここでの実装では要件的には必要ないのだがエラーログを残すのにも使うので、good以外のレスポンス文字列も上げておく。1
|応答|状態|説明|
|good <IP_ADDR>|OK|リクエストは正常に受け付けた|
|nochg <IP_ADDR>|OK|リクエストは正常だが更新されるステータスはない|
|nohost|NG|hostnameは存在しない|
|badauth|NG|認証に失敗した|
|badagent|NG|この機器は許可されていない|
|!donator|NG|利用料が支払われていない|
|abuse|NG|ユーザはブロックされている・乱用|
|911|NG|重大なエラー|
nginx perl moduleでの実装
今回はnginxのperl module機能を用いて実装する。CentOS版のnginxはこれに対応しているが、公式サイトで配布しているバイナリは対応していないため要リビルドである。次のようにしてなにも表示されなければ、このビルドオプションが足りていない。
$ nginx -V 2>&1 | tr ' ' '\n' | grep perl
--with-http_perl_module
perlモジュールが有効なら、レスポンスハンドラをperlで記述することが出来る。そこで以下のコードを/etc/nginx/lib/dyndns.pm
に用意した。
#
# $Id: dyndns.pm 159 2015-05-15 04:18:23Z askn $
#
package dyndns;
use strict;
use utf8;
use 5.010;
use nginx;
use Net::DNS;
use MIME::Base64;
use Sys::Syslog;
sub update_handler {
my $r = shift;
$r->send_http_header("text/plain");
return OK if $r->header_only;
eval {
openlog("ddns_update", "cons,pid", "daemon");
die "unknown:nothing parameters\n" unless $r->args;
my $auth = $r->header_in("Authorization") // "";
die "badauth:bad authorization.\n"
unless $auth =~ m{^Basic ([0-9a-z\+\/\=]+)}i;
my $user = (split /:/, decode_base64($1))[0];
my $q = {};
foreach my $pair (split /&/, $r->args) {
my($key, $val) = split /=/, $pair;
next unless defined $key;
$val = "" unless defined $val;
$q->{$r->unescape($key)} = $r->unescape($val);
}
my $hostname = lc($q->{hostname} // "");
my $myip = $q->{myip} || $r->remote_addr;
# my $system = $q->{system} // "";
# my $wildcard = $q->{wildcard} // "";
# my $offline = $q->{offline} // "";
# my $mx = ls($q->{mx} // "");
syslog("info", "USER=".$user." HOSTNAME=".$hostname." MYIP=".$myip);
die "unknown:badformat myip.\n"
unless $myip =~ /^(\d{1,3}\.){3}\d{1,3}$/;
my $resolver = new Net::DNS::Resolver;
$resolver->nameservers("127.0.0.1");
my @fqdn = split /,/, $hostname;
die "numhosth:too many hostname.\n"
if 4 < scalar @fqdn;
foreach my $fqdn (@fqdn) {
# ZONE name resolv stage
defined $fqdn and $fqdn =~ s{(^\s+|\s+$)}{}g;
die "nohost:badformat hostname.\n"
unless defined $fqdn;
die "badfqdn:badformat hostname.\n"
unless $fqdn =~ /^([\w\-]+\.)*[\w\-]+$/;
my $zone = "";
my $ns = "";
my(@stack, @ns);
foreach my $dotted (reverse(split /\./, $fqdn)) {
$zone = $dotted.".".$zone;
unshift @stack, $zone;
}
foreach my $test (@stack) {
my $query = $resolver->query($test, "NS");
if ($query) {
foreach my $rr (grep {$_->type eq "NS"} $query->answer) {
push @ns, $rr->nsdname;
}
$zone = $test;
syslog("info", "ZONE=".$zone." NS=".join(",", @ns));
last;
}
}
die "dnserr:Undefined query nameservers.\n"
unless @ns;
$resolver->nameservers(@ns);
# Update stage
my $update = new Net::DNS::Update($zone);
$update->push(update => rr_del($fqdn.". IN A"));
$update->push(update => rr_add($fqdn.". 3600 IN A ".$myip));
my $reply = $resolver->send($update);
die "dnserr:reply resolver ".$resolver->errorstring."\n"
unless $reply;
die "dnserr:reply ".$reply->header->rcode."\n"
unless $reply->header->rcode eq "NOERROR";
}
$r->print("good " . $myip . "\n");
syslog("info", "update success.");
};
if ($@) {
chomp $@;
syslog("info", "ERROR: " . $@);
my $result = (split /:/, $@)[0];
$r->print($result."\n");
}
return OK;
}
1;
__END__
コード中の$r
はnginxから渡されるリファレンスオブジェクトで、mod_perlのそれと似た表現になっている。$r->args
に送信されたクエリが入っているのでこれをパースしてhostname
とmyip
を受け取っているが、myip
がなければ$r->remote_addr
を代わりに使うよう2にもしている。その他のパラメータについてはこのコードでは対応していない。なお処理過程のログに付いては(perlの)Sys::Syslog
モジュールを用いてsyslogのdaemonファシリティに送信するようにしている。
DNS Aレコード処理は(perlの)Net::DNS
モジュールで行う。まずlocalhost
ネームサーバにFQDNを問い合わせてNSレコード=当該ネームサーバIPを取得し(resolvステージ)そこに対して既存DNS Aレコードの削除および新規DNS Aレコードの登録(updateステージ)を行う。ここでは処理を端折っているが、本格的な汎用サービス目的で実装するなら、updateステージではアクセス認証キーの処理を追加する必要があるだろう。3
nginx.conf側の記述
nginx.confから要点だけを抜き書きすると次のようになる。
http {
perl_modules /etc/nginx/lib;
perl_require dyndns.pm;
server {
location /nic/update {
auth_basic "closed site";
auth_basic_user_file /etc/nginx/_htpasswd;
perl dyndns::update_handler;
}
}
}
まずグローバル設定内でperl_modules
とperl_require
ディレクティブを用いてperlコードをnginx本体にロードする。プロセス起動時にメモリ上に読み込まれるためコードを書き換えた場合は逐一nginxをreloadする必要がある。ここで読み込まれたperlコードのpackage名前空間名とハンドラ関数名をlocaltionブロック内で指定すると、URL(/nic/update
)とperlコードとを紐付けることができる。またこの時BASIC認証を行うので、認証用パスワードファイルをauth_basic_user_file
ディレクティブで指定する。
named.conf側の設定
named.confの設定では、FQDNが属すZONEを有しており、かつ上記のリクエストレコード送信元IPがallow-updateディレクティブで指定されていればよい。
zone "example.org" IN {
type master;
:
allow-update {
127.0.0.1;
192.168.0.0/24;
key host1;
}
}
参考:Perl-CGI版
nginx版の前に作成・使用していたPerl-CGIバージョンを以下に上げておく。こちらは単にnsupdate
コマンドを叩くだけのシンプルなものだ。4
#!/usr/bin/perl
use strict;
use CGI;
use Sys::Syslog;
use 5.010;
my($q, $hostname);
my $myip = '0.0.0.0';
eval {
openlog('ddns_update', 'cons,pid', 'daemon');
$q = new CGI;
$hostname = $q->param('hostname');
$myip = $q->param('myip') || $ENV{HTTP_X_FORWARDED_FOR} || $ENV{REMOTE_ADDR};
# $system = $q->param('system');
# $wildcard = $q->param('wildcard');
# $offline = $q->param('offline');
# $mx = $q->param('mx');
syslog('info', 'HOSTNAME='.$hostname.' MYIP='.$myip);
unless ($hostname =~ /^([\w\-]+\.)*[\w\-]+$/) {
die "nohost\n";
}
unless ($myip =~ /^(\d{1,3}\.){3}\d{1,3}$/) {
die "unknown\n";
}
open my $PIPE, '|-', '/usr/bin/nsupdate' or die "badcall nsupdate.";
print $PIPE <<__EOF;
server 127.0.0.1
update delete ${hostname}. IN A
update add ${hostname}. 3600 IN A ${myip}
send
__EOF
close $PIPE or die "break nsupdate.\n";
print 'Content-type: text/plain', "\r\n\r\n";
print 'good ', $myip, "\n";
syslog('info', 'Success.');
};
if ($@) {
print 'Content-type: text/plain', "\r\n\r\n";
print 'nochg ', $myip, "\n";
syslog('info', 'ERROR: '.$@);
}
1;
__END__