あんどきゅめんてっどTonicDNS。

私にもかつてPowerDNS用のAPIを、管理ツールとして以前導入したPowerDNS GUIを拡張して作ろうとしたことがありました [1] 。ですが、pdns guiは現状ではapplication/x-www-form-urlencoded形式でしかデータを受け付けないこと、特定のゾーンのレコードだけの登録、更新、削除などができないという仕様です [2] 。ですので、JSONで受け付けてやるようにして、特定のレコードだけ登録、更新、削除できるようにしようかとも考えました。が、pdns-guiはフレームワークとしてsymfony1.0を使っています。独自に拡張するとsymfony1.4もしくは2.0にバージョンアップした場合に追従するのがめんどいという問題があります [3] 。一方、自分でsymfony1.0から1.1→1.2→1.3→1.4、もしくはさらに2.0とアップデートするのは結構ハードルが高いので、いずれにしても微妙だなぁと思いつつも、PowerDNS GUIを拡張してAPIを作ることにしようかとtweetしたところ、 TonicDNSは見た? と教えてもらったので試してみることにしました [4]

TonicDNSとは。

Github で公開されている、PowerDNS用のRESTful APIです。ロゴが結構かっこいいですね!TonicDNS自体は、 tonic というRESTfulなWebアプリを書くためのPHPのライブラリ&フレームワークを使っているようです [5] 。導入自体は、README.markdownのQuick Install Guideのとおりにすればできます。この導入途中で、初めて mpm-itk モジュールなんてあるのを知りました。DebianやUbuntuではapache2-mpm-itkパッケージを導入すれば使えます。

さて使ってみよう…、アレ?

上記のとおり、導入自体はあっさりできました。が、肝心の使い方については一切ドキュメントがありません!APIなのに使い方が分からんのでは使えんやないの…。ただし、ソースコードには結構コメントが書いてありますし、結構読みやすい&分かりやすいコードです。ソース嫁ということなのでしょう。なので、実際にソースコードを読んで、使い方を調べてみました。

ユーザを作成する。

TonicDNSにはユーザ認証の仕組みがあります。しかしユーザ登録の仕組みはありません。ユーザ用のテーブルスキーマは下記のようになっていますが、

mysql> desc users;
+-------------+---------------+------+-----+---------+----------------+
| Field       | Type          | Null | Key | Default | Extra          |
+-------------+---------------+------+-----+---------+----------------+
| id          | int(11)       | NO   | PRI | NULL    | auto_increment |
| username    | varchar(16)   | NO   |     | NULL    |                |
| password    | varchar(34)   | NO   |     | NULL    |                |
| fullname    | varchar(255)  | NO   |     | NULL    |                |
| email       | varchar(255)  | NO   |     | NULL    |                |
| description | varchar(1024) | NO   |     | NULL    |                |
| perm_templ  | tinyint(4)    | NO   |     | 0       |                |
| active      | tinyint(4)    | NO   |     | 0       |                |
+-------------+---------------+------+-----+---------+----------------+
8 rows in set (0.00 sec)

passwordのフォーマットがなんなのか分からないので困ってしまいます。lib/pdo_token_backend.phpを見ると、

if (($result = $stat1->execute(array(":username" => $token->username, ":password" => md5($token->password)))) !== false) {

となっているので、パスワード文字列をmd5()処理してやります。なので、パスワードをdummypasswordとする場合には、

<?php
  printf("%s\n", md5("dummypassword"));
?>

としてやり、phpコマンドで実行した結果である

$ php md5pw.php
60da11eb799d6a8da47e5cd6e4aa2273

をパスワード文字列としてusersテーブルにinsertしてやります。

mysql> insert into users values (null,'testuser','60da11eb799d6a8da47e5cd6e4aa2273','test user','testuser@example.org','test user',0,0);
Query OK, 1 row affected (0.00 sec)

このあと、Tokenを作ります。Tokenの作成方法は、classes/AuthenticationResource.class.phpを見ると分かります。

/**
 * Corresponds to login.
 *
 * Request:
 *
 * {
 *      "username": <username>,
 *      "password": <password>,
 *      "local_user": <username>
 * }
 *
 * Response:
 *
 * {
 *      "username": <string>,
 *      "valid_until": <int>,
 *      "hash": <string>,
 *      "token": <string>
 * }
 *
 * Errors:
 *
 *   500 - Invalid request or missing username/password.
 *   403 - Username/password incorrect.
 *
 * @access public
 * @param mixed $request Request parameters
 * @return Response Authentication Token if successful, error message if false.
 */
public function put($request) {
//(snip)
        $token = new Token();
        $token->username = $data->username;
        $token->password = $data->password;

        $token = $this->backend->createToken($token);

        if ($token == null) {
                $response->code = Response::FORBIDDEN;
                $response->error = "Username and/or password was invalid.";
                return $response;
        }

        $response->code = Response::OK;
        $response->body = $token->toArray();
        $response->log_message = "Token was successfully created.";

        return $response;
}

まず、上記のコメントにある形式でJSONファイルを作成します。

{
        "username": "testuser",
        "password": "dummypassword",
        "local_user": "testuser"
}

これを/authenticateにPUTメソッドで送信します。

$ curl -k -X PUT https://localhost/authenticate -d @./testuser.json
{"username":"testuser","valid_until":1327146727,"hash":"5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1","token":"5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1"}

コメントの期待値のレスポンスが返りましたね。これでTokenの登録ができました。なお、このTokenはしばらくすると無効になるので、リクエスト前に必ず実行するようにすると良いでしょう [6]

tokenの使い方。

tokenの使い方はコメントには一切書いていませんが、lib/tonic.phpの下記の部分を見ると分かります。

// get HTTP request type
$raw_headers = array();
if (function_exists("apache_request_headers")) {
        $raw_headers = apache_request_headers();
} else if (function_exists("nsapi_request_headers")) {
        $raw_headers = nsapi_request_headers();
}
foreach ($raw_headers as $k => $h) {
        switch (strtolower($k)) {
        case "content-type":
                $this->requestType = $h;
                break;
        case "x-authentication-token":
                $this->requestToken = $h;
                break;
        }
}

curlコマンドを使う場合は、 -H “x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1” とすれば、Tokenを渡す事ができます。

ゾーンの参照。

ゾーンの取得は、/zone/:identifierでGETメソッドで取得します。:itentifierにはドメインを指定します。test.localドメインが既に登録されている場合、下記のように実行します。

$ curl -s -k -H 'x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1' -X GET https://localhost/zone/test.local | sed '
s/\[{/\[\n{/g
s/},{/},\n{/g
'
{"name":"test.local","type":"MASTER","notified_serial":"2012011801","records":[
{"name":"ns.test.local","type":"A","content":"192.168.0.10","ttl":"86400","priority":null},
{"name":"ns2.test.local","type":"A","content":"192.168.0.11","ttl":"86400","priority":null},
{"name":"test.local","type":"SOA","content":"ns.test.local hostmaster.test.local 2012011801","ttl":"86400","priority":null},
{"name":"test.local","type":"NS","content":"ns.test.local","ttl":"86400","priority":null},
{"name":"test.local","type":"NS","content":"ns2.test.local","ttl":"86400","priority":null},
{"name":"test.local","type":"MX","content":"mx.test.local","ttl":"86400","priority":"0"},
{"name":"test.local","type":"MX","content":"mx2.test.local","ttl":"86400","priority":"10"},
{"name":"www.test.local","type":"A","content":"192.168.0.1","ttl":"86400","priority":null}]}

レコードの登録。

すでに登録済みのゾーンに対しレコードを登録する場合には、下記のようなJSONファイルを用意します。

{"records": [
{ "name": "mx.test.local", "type": "A", "content": "11.11.11.11" },
{ "name": "mx2.test.local", "type": "A", "content": "11.11.11.12" },
{ "name": "test.local", "type": "MX", "content": "mx3.test.local", "priority": 30 },
{ "name": "mx3.test.local", "type": "A", "content": "11.11.11.13" }]}

これを/zone/:identifierに対しPUTメソッドで送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X PUT https://localhost/zone/test.local -d @./add_record.json
true

レコード情報を取得すると登録されていることが分かります。

{"name":"test.local","type":"MASTER","notified_serial":"2012011801","records":[
{"name":"mx.test.local","type":"A","content":"11.11.11.11","ttl":"86400","priority":"0","change_date":"1327755951"},
{"name":"mx2.test.local","type":"A","content":"11.11.11.12","ttl":"86400","priority":"0","change_date":"1327755951"},
{"name":"mx3.test.local","type":"A","content":"11.11.11.13","ttl":"86400","priority":"0","change_date":"1327755951"},
{"name":"ns.test.local","type":"A","content":"192.168.0.10","ttl":"86400","priority":null},
{"name":"ns2.test.local","type":"A","content":"192.168.0.11","ttl":"86400","priority":null},
{"name":"test.local","type":"SOA","content":"ns.test.local hostmaster.test.local 2012011801","ttl":"86400","priority":null},
{"name":"test.local","type":"NS","content":"ns.test.local","ttl":"86400","priority":null},
{"name":"test.local","type":"NS","content":"ns2.test.local","ttl":"86400","priority":null},
{"name":"test.local","type":"MX","content":"mx.test.local","ttl":"86400","priority":"0"},
{"name":"test.local","type":"MX","content":"mx2.test.local","ttl":"86400","priority":"10"},
{"name":"test.local","type":"MX","content":"mx3.test.local","ttl":"86400","priority":"30","change_date":"1327755951"},
{"name":"www.test.local","type":"A","content":"192.168.0.1","ttl":"86400","priority":null}]}

MXとSRVレコード以外はpriorityは必要ありませんが、上記のように指定しなかった場合、conf/database.conf.phpでconst DNS_DEFAULT_RECORD_PRIORITYにデフォルト値として設定されている0が登録されます。0ではなく、nullを設定しておくとprirityはnullになります。が、これはまた現時点ではこうしてしまうと次に説明するレコードの削除のときに問題になります。

レコードの削除。

test.localゾーンのレコードの削除を行うためには、次のようなJSONを用意します。

{ "name": "test.local", "records": [
{ "name": "test.local", "type": "MX", "content": "mx3.test.local", "priority": 30 },
{ "name": "mx.test.local", "type": "A", "content": "11.11.11.11" },
{ "name": "mx2.test.local", "type": "A", "content": "11.11.11.12" },
{ "name": "mx3.test.local", "type": "A", "content": "11.11.11.13"} ]}

これを/zone/に対しDELETEメソッドで送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X DELETE https://localhost/zone/ -d @./delete_record.json
true

この実行結果はtrueが返ってきます。ところが、上記で削除できるのは一番最初のMXレコードだけです。他の3つは、priorityを指定していないため、レコードの削除ができないのです。

public function delete_records($response, $identifier, $data, &$out = null) {
//(snip)
        $statement = $connection->prepare(sprintf(
                "DELETE FROM `%s` WHERE name = :name AND type = :type AND prio = :priority AND content = :content;", PowerDNSConfig::DB_RECORD_TABLE
        ));

        $statement->bindParam(":name", $r_name);
        $statement->bindParam(":type", $r_type);
        $statement->bindParam(":content", $r_content);
        $statement->bindParam(":priority", $r_prio);

        foreach ($data->records as $record) {
                if (!isset($record->name) || !isset($record->type) || !isset($record->priority) || !isset($record->content)) {
                        continue;
                }

                $r_name = $record->name;
                $r_type = $record->type;
                $r_content = $record->content;
                $r_prio = $record->priority;

                if ($statement->execute() === false) {
                        $response->code = Response::INTERNALSERVERERROR;
                        $response->error = sprintf("Rolling back transaction, failed to delete zone record - name: '%s', type: '%s', prio: '%s'", $r_name, $r_type, $r_prio);

                        $connection->rollback();
                        $out = false;

                        return $response;
                }
        }

上記のとおり、レコード単位ではpriorityが設定されていない場合には処理がスキップされるだけでエラーにはならないためです。TonicDNSだけでPowerDNSを使うのなら問題ないかもしれませんが、他の管理ツールと一緒に使う場合は、ここは不整合が生じるのでパッチを作成中です [7]

レコードの更新。

残念ながら現時点でレコードの更新は未実装のためできません。

テンプレートの作成。

ゾーンの登録と行きたいところですが、ゾーンの作成には元にするテンプレートが必要です。テンプレートの作成には、下記のようなJSONを用意します。

{
     "identifier": "sample1",
     "description": "sample template",
     "entries": [ {
           "name": "test2.local",
           "type": "NS",
           "content": "ns.test2.local",
           "ttl": 86400,
           "priority": 0
     },{
           "name": "ns.test2.local",
           "type": "A",
           "content": "10.10.10.1",
           "ttl": 86400,
           "priority": 0
     }
]
}

これを/template/:identifierにPUTメソッドで送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X PUT https://localhost/template/sample1 -d @./create_template.json
true

テンプレートの参照。

テンプレートの参照は、/template/にGETメソッドでアクセスします。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X GET https://localhost/template/
[
{"identifier":"sample1","entries":[
{"name":"test2.local","type":"NS","content":"ns.test2.local","ttl":"86400","priority":"0"},
{"name":"ns.test2.local","type":"A","content":"10.10.10.1","ttl":"86400","priority":"0"}],"description":"sample template"}]

複数ある場合は列挙されます。

特定のテンプレートだけを表示する場合には、/template/:identifierをGETメソッドでアクセスします。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X GET https://localhost/template/sample1
{"identifier":"sample1","entries":[
{"name":"test2.local","type":"NS","content":"ns.test2.local","ttl":"86400","priority":"0"},
{"name":"ns.test2.local","type":"A","content":"10.10.10.1","ttl":"86400","priority":"0"}],"description":"sample template"}

テンプレートの更新。

先ほどの作成したテンプレートを更新してみましょう。まず、下記のような一部変更したJSONを用意します。

{
     "identifier": "sample1",
     "description": "sample template",
     "entries": [ {
           "name": "test2.local",
           "type": "NS",
           "content": "ns.test2.local",
           "ttl": 86400,
           "priority": 0
     },{
           "name": "ns.test2.local",
           "type": "A",
           "content": "10.10.10.2",
           "ttl": 86400,
           "priority": 0
     },{
           "name": "test2.local",
           "type": "A",
           "content": "10.10.10.1",
           "ttl": 86400,
           "priority": 0
     },{
           "name": "test2.local",
           "type": "SOA",
           "content": "ns.test2.local hostmaster.test2.local 2012012901 10800 3600 604800 3600",
           "ttl": 86400,
           "priority": 0
     }
]
}

これを/template/:identifierにPOSTメソッドで送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X POST https://localhost/template/sample1 -d @./update_template.json
true

テンプレートを参照しなおしてみると、更新できていることが確認できます。

{"identifier":"sample1","entries":[
{"name":"test2.local","type":"NS","content":"ns.test2.local","ttl":"86400","priority":"0"},
{"name":"ns.test2.local","type":"A","content":"10.10.10.2","ttl":"86400","priority":"0"},
{"name":"test2.local","type":"A","content":"10.10.10.1","ttl":"86400","priority":"0"},
{"name":"test2.local","type":"SOA","content":"ns.test2.local hostmaster.test2.local 2012012901 10800 3600 604800 3600","ttl":"86400","priority":"0"}],"description":"sample template"}

テンプレートの削除。

これは/template/:identifierにDELETEメソッドを送信するだけです。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X DELETE https://localhost/template/sample1
true

ゾーンの登録。

さて、テンプレートが用意できたので、ゾーンを登録してみます。まず、次にようなJSONを用意します。

{
"name": "test2.local",
"type": "MASTER",
"master": null,
"templates": [{
        "identifier": "sample1"
}],
"records": [{
"name": "moge.test2.local",
"type": "A",
"content": "11.11.11.11"
}]
}

これを/zone/にPUTメソッドで送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X PUT https://localhost/zone/ -d@./create_zone.json
true

ゾーンを参照してみると、登録できていることが確認できます。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X GET https://localhost/zone/test2.local
{"name":"test2.local","type":"MASTER","notified_serial":"2012012901","records":[
{"name":"moge.test2.local","type":"A","content":"11.11.11.11","ttl":"86400","priority":null,"change_date":"1327768827"},
{"name":"ns.test2.local","type":"A","content":"10.10.10.2","ttl":"86400","priority":"0","change_date":"1327768827"},
{"name":"test2.local","type":"SOA","content":"ns.test2.local hostmaster.test2.local 2012012901 10800 3600 604800 3600","ttl":"86400","priority":"0","change_date":"1327768827"},
{"name":"test2.local","type":"NS","content":"ns.test2.local","ttl":"86400","priority":"0","change_date":"1327768827"},
{"name":"test2.local","type":"A","content":"10.10.10.1","ttl":"86400","priority":"0","change_date":"1327768827"}]}

ゾーンの更新。

ゾーンの更新は、MASTER, SLAVE, NATIVEへの変更ができます。SLAVEに変更するときは、PowerDNSの仕様として、masterにmasterサーバのIPアドレスを指定する必要があります。変更するためには、

{
   "name": "test2.local",
   "type": "SLAVE",
   "master": "10.10.10.1"
}

という感じのJSONを用意し、/zone/:identifierにPOSTメソッドで送信すれば良いはずです。ただし、PowerDNS自体の設定にも依存するので、PowerDNSの設定がmasterサーバなのにゾーンはSLAVEにする、という処理は失敗します [8]

ゾーンの削除。

ゾーンの削除は/zone/:identifierにDELETEメソッドを送信します。

$ curl -s -k -H "x-authentication-token: 5790245d3bcd19c055b2c83d56f25f8a1ceeb9e1" -X DELETE https://localhost/zone/test.local
true

まとめ。

とりあえず、現状ではレコードの登録、参照、削除ができるので、最低限やりたいことはできそうです。ですが、

  • 使い方のドキュメントが無い
  • レコードの更新ができない
  • MX, SRVレコード以外のレコード登録にpriorityが設定されるのはイケてない
  • ユーザ作成ができない
  • レコード更新してもSOAレコードのserialが更新されない

といった問題は不便なので、パッチ書いて git format-patch で送付しようと思います。 [9]

あとは独自要件として、PowerDNS GUIとの整合性を取るためにautitテーブルの更新も行う必要があるので、その辺のパッチも作らなくてはですね。

[1]私にも、というかワシだけだろう…
[2]ゾーンに登録されているレコードを全部変更する、というのは可能です
[3]ただ、upstreamでは開発止まっているんじゃないかなぁ…。
[4]pdns-gui自体は、APIが無いことを除けば、現状必要な管理ツールとしての要件としては満たしているので変更したくない、ということも試してみようかと思った理由の一つです。
[5]なので、TonicDNS自体もまたPHPで書かれています…。まぁ、ええわ。
[6]無効になるタイミングは、ユーザ作成時のパラメータに依ります。
[7]ちなみに、以前ブログでも書いたPowerDNS GUIの場合は、MX, SRVレコード以外ではpriorityはnullになります。
[8]それだけ確認済み
[9]Githubだからpull requestとか使うんかな。まぁformat-patchでええやろ。