DynDNS互換サービスを自作する

筆者が使っている 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に送信されたクエリが入っているのでこれをパースしてhostnamemyipを受け取っているが、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_modulesperl_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__  
    

    1. 911は米国で言う110番のこと。

    2. 通常の用途ではMYIPとremote_addrは常に同一だろう。ルータを多段に組んだ場合でもなければ両者が異なるケースはまずない。

    3. ここでの実装は簡易的なので、ネームサーバ側の設定でこのサービスサーバのIPに直接update許可を与えている。キー認証を使用する場合、named.confのほうではallow-updateディレクティブにアクセス許可するキーIDを追記する。

    4. この実装のままnginx/perlに持って行くとfork負荷が問題になったのでNet::DNS実装に置き換えた。

    RECENT LINKS