addDnsServer Bad Address: Android が実際に検証する内容
addDnsServer の Bad address エラーはローカルな形式チェックであり、DNS 障害ではありません。リテラルを解析し、loopback と wildcard を除外してから到達性を確認します。
値は文字列として VpnService.Builder.addDnsServer に届き、builder は何も確立される前にそこで拒否しました。この拒否はローカルです。resolver には一切問い合わせておらず、トンネルも存在せず、失敗したのは形式チェックであって DNS 障害ではありません。
これは重要です。一般的な VPN アプリには似た三つの DNS 入力があります。プロファイルに入力された値、サーバーが構成時に送る値、そしてシステム階層の Private DNS です。この呼び出しに届くのは最初の二つだけです。
準備オブジェクトはその場で引数を検査する
VpnService.Builder はトンネルではありません。アドレス、ルート、サーバーを集める準備オブジェクトであり、establish() だけがそれらのリストを生きた ParcelFileDescriptor とインターフェースに変えます。DNS エントリは addDnsServer を呼ぶ時点で検査され、確立時ではありません。
帰結は二つです。スタックトレースは短く意図的に情報を与えません。値は単なる文字列として届いたからです。そして catch を大雑把に全体へ付けると、アドレスとルートはあるが resolver のないトンネルが残ります。
builder は渡されたリストをそのまま保持します。検査を通ったサーバーの追加は、構築時点で文字列がアドレスのリテラルとして解析されたことしか証明しません。
引数として許されるもの
引数はアドレスであり、エンドポイントではありません。Builder リファレンスは IPv4 または IPv6 のリテラルを取り、その解析は数値だけです。android.net.InetAddresses.parseNumericAddress は API 29 以降のそのパーサーの公開された姿です。ここでホスト名は遅いアドレスではなく、解析できない文字列です。
実務的な対応は次のとおりです。
| Input | Result | Reason |
|---|---|---|
10.8.0.1 | accepted | IPv4 literal |
2606:4700:4700::1111 | accepted | IPv6 literal |
dns.example.com | rejected | hostname; numeric parsing does not resolve it |
10.8.0.1:53 | rejected | port belongs to your own config model |
https://dns.example.com/dns-query | rejected | DoH URL, a different configuration path |
127.0.0.1 | rejected | loopback is not a remote resolver |
0.0.0.0 or :: | accepted by the builder | parses fine, but names no destination; only your own validator rejects it |
バージョンが影響するのは検証の方法であり、builder が受け取る内容ではありません。InetAddresses は API 29 から利用できるため、Android 10 以降では解析は一回の呼び出しです。addDnsServer(InetAddress) が元の形で、String が新しい追加です。API 29 未満では InetAddress.getByName がブロックするネットワーク検索でホスト名を解決し、打ち間違いを遅い起動に変えます。
loopback はここで拒否され、wildcard は拒否されない
デスクトップの VPN クライアントは自身の stub resolver を 127.0.0.1:53 に向けることがよくあります。それを Android プロファイルに写すとローカルマシンを表す値が入りますが、ここにローカルマシンはありません。builder が拒否するのはこの場合だけです。null または loopback のアドレスは IllegalArgumentException を投げます。
wildcard は逆の場合で、このエラーの多くの読み方がここで誤ります。0.0.0.0 と :: は loopback ではないため builder は受け入れ、構築時に何も投げません。その後インターフェースは宛先を示さないエントリで上がります。それを見つけるのは検証側の仕事です。isAnyLocalAddress を無効な入力として扱ってください。
構成の境界で一度だけ解析する
安価な修正は、文字列がモデルに入る場所で検証することです。
import android.net.InetAddresses
import java.net.InetAddress
private fun dnsLiteralOrNull(raw: String?): InetAddress? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val address = try {
InetAddresses.parseNumericAddress(text)
} catch (e: IllegalArgumentException) {
return null
}
return address.takeUnless { it.isAnyLocalAddress || it.isLoopbackAddress }
}
null の結果は問題のテキストを伴うフィールド単位のエラーになり、サポートが対応できる唯一の形です。成功経路ではオブジェクトがそのまま builder.addDnsServer(address) に入り、IllegalArgumentException を明確に捕まえ、Exception は捕まえません。
目的の値が拒否されたときに公開 resolver で置き換えないでください。黙って公開 DNS に切り替えるプロファイルは接続しますが、内部名は解決できなくなります。
インターフェース生成後に到達性を確認する
builder の受け入れは最初の検査点であり、最後ではありません。インターフェースが上がった後、LinkProperties.getDnsServers() がネットワークが実際に受け取った内容を報告し、adb shell dumpsys connectivity が同じプロパティを外部から出力します。
Private DNS は別の制御で、この引数を通りません。DoT プロバイダーのホスト名はシステム設定に属し、IP フィールドに入れる値ではありません。
プロファイルのパーサーを再び変える前に、生の文字列とその出所を記録し、ホスト名ではなくリテラルであること、loopback でも wildcard でもないことを確認し、解析済みのオブジェクトを渡してください。