現在自分はRustを第二のメイン言語としようと絶賛勉強中である。勉強と実用を兼ねて、LetsEncryptのDNS認証をRustにやらせてみた。
全ソースコードはGithubに上げてある。ConoHa APIの仕様などについては、公式ドキュメントや以前書いた記事を参照。
ConoHa APIについて
ConoHaというのはVPSサービスである。このサイトもConoHaのVPSでホストされている。ConoHa APIというのは、ConoHaのサービス(VPS、DBサーバ、DNSなど)を制御するためのREST形式のAPIだ。
今回はこれを使ってDNSレコードを書き換え、LetsEncryptのDNS認証を自動化する。
RustのHTTPクライアント
RustでHTTPを喋るためのクレートはいくつかある。hyperやreqwestが最も人気を集めているようだが、自分はactixというWebフレームワークを愛用しているため、今回はそれと共に開発されているawc(actix web client)というクレートを利用してみることにした。
日本語圏には使ってみた情報がなく、公式のチュートリアルにもほとんど記述がなかったため、使い方の調査が難航した。
余談だが、かつては(actix バージョン3までは)「actix_web::client」としてactix本体に組み込まれており、バージョン4から分離したらしい。actixがRustではかなりメジャーなフレームワークなのにawcにあまり日が当たっていないのはこうした事情もあるのだと思われる。
この記事はそんな情報の少ないawcの試食レポという意味合いもある。(ただ、さすがにノイズが多すぎるので別の記事を出すかもしれない。)
awcでHTTPSを使えるようにする
awcはデフォルトではHTTPSに対応していない。featuresで"openssl"を有効化しておく。また、opensslクレートも必要になるので入れておく。
awc = { version = "3.0.0", features = ["openssl"] }
openssl = "0.10.40"
main()に#[actix_web::main]
を付けておく
どうやらawcを使う場合は、actixのサーバーとしての機能を使わない場合でもこの指定が必須らしい。
[actix_web::main]
async fn main() -> Result<(), Box<dyn error::Error>> {
// ...
}
これを付けないとエラーが出る。#[tokio::main]
でもなく#[actix_web::main]
を指定しなければならない。
HTTPクライアントの構築
こんな感じになる。
let mut ssl_connector_builder = openssl::ssl::SslConnector::builder(SslMethod::tls())?;
ssl_connector_builder.set_ca_file("cacert.pem")?;
let ssl_connector = ssl_connector_builder.build();
let connector = awc::Connector::new().openssl(ssl_connector);
let client = awc::Client::builder().connector(connector).finish();
SslConnectorBuilder→SslConnector→Connector→ClientBuilder→Clientという順序。httpsを求めないならSslConnectorの下りは要らないのだが、仮にもAPIアクセスなのでそうもいかない。
awc::Connectorはネットワーク接続機能の部分?を表すオブジェクトのようだ。これにSslConnectorを渡すことでhttps対応が出来る。この辺あまりよく理解していない。
cacert.pem(信頼済み認証局のリスト)はこのへんから適当に取ってくる。set_ca_file周りも情報が少なくて苦労した。正直これで合っているのかも若干自信がない。
JSONとオブジェクトの変換
ConoHa APIは情報を主にJSONでやりとりする。最近のWebは猫も杓子もJSONだ。javascript以外は肩身が狭くて仕方ない。
だが、Rustにはserdeという強力なクレートがある。こいつを使えばあらゆるオブジェクトとJSONの間のマッピングが出来るのだ。適切に構造体を定義しserdeの属性を付与すれば、立ちどころにJSONとの間の変換が定義される。プログラマは変換処理を意識する必要すらほぼない。強い、強すぎる。
ちなみにserdeは「JSONとの間の変換ライブラリ」ではなく「任意のオブジェクトをシリアライズ/デシリアライズするライブラリ」であり、適切に定義された構造体ならばJSONはおろかyaml、toml、xmlなどなんにでも変換できる。強い、強すぎる。
ConoHa APIの公式ドキュメントを参照しながら以下のようにレコードを表す構造体を定義した。
[derive(Serialize, Deserialize, Default)]
pub struct Record {
[serde(skip_serializing_if = "Option::is_none")]
pub domain_id: Option<uuid::Uuid>,
[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<uuid::Uuid>,
pub name: String,
[serde(rename = "type")]
pub record_type: String,
[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<NaiveDateTime>,
[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<NaiveDateTime>,
[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<i32>,
pub data: String,
[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
[serde(skip_serializing_if = "Option::is_none")]
pub gslb_region: Option<String>,
[serde(skip_serializing_if = "Option::is_none")]
pub gslb_weight: Option<i32>,
[serde(skip_serializing_if = "Option::is_none")]
pub gslb_check: Option<i32>,
}
「type」は予約語であるため、そこは#[serde(rename = "type")]
とした。このようにすると構造体のメンバ名と対応するjsonのキー名を異ならせることができる。
またserde_jsonのデフォルト動作では、Option<T>
がNoneの場合JSONの値としてはnullになるのだが、ConoHa APIではnullとするよりキー自体を存在しない形にした方が良いようなのでskip_serializing_ifを用いた。
こうして定義した構造体を用いて受信なり送信なりできる。
let validation_record = Record {
name: target_record_name.to_string(),
record_type: "TXT".to_string(),
data: validation_token.to_string(),
ttl: Some(60),
..Default::default()
};
let request = client
.request(Method::POST, url_endpoint)
.append_header(("X-Auth-Token", token));
let request = self.token_request(Method::GET, url)?;
let mut res = request.send_json(&validation_record).await?;
let record_res :Record = res.json().await?;
やってみた感想
やりたいことに対しては少し規模の大きいコードになってしまった気はするが、APIバインディング周りの実装は多少再利用性を意識して書いたので全体的にはまあこんなもんかなという気がする。いい勉強になった。RustはWeb系と結構相性が良い。
今回はserdeを使ってみたかったので構造体をちゃんと定義してみたが、必ずしもそういうことをしなくともjson!マクロなどを利用すればjsonデータを定義して送信に使えるし、serde_json::Valueに押し込めてしまえば受け取ることもできる。その辺はどのくらいの開発スピードを出したいか、コードの再利用性をどの程度求めるか、といったところで使い分ければいいのかなと思う。