diff --git a/.golangci.yml b/.golangci.yml index 6935a07..a6d85f0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -44,6 +44,11 @@ linters: ignored-numbers: - "2" + # テストのケース名・期待値はモード名と綴りが重なりがちだが定数化は不自然なので、 + # goconst はテストファイルを数えない (exclusions の test 除外と方針を合わせる)。 + goconst: + ignore-tests: true + staticcheck: checks: - all diff --git a/README.md b/README.md index 5f0f06b..98d05c4 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,16 @@ kame6 2001:200:dff:fff1:216:3eff:feb1:44d7 | netns | `name ADDR relay=NETNSNAME via=netns`(Linux・root) | | vrf | `name ADDR relay=VRFNAME via=vrf`(Linux・root) | | routeros | `name ADDR relay=ROS via=routeros_api username=U password=P method=https verify=false` | +| quic | `name ADDR via=quic [port=443] [sni=NAME] [verify=on]`(OS 非依存・外部コマンド不要) | | tcp/hping3 | `name ADDR tcp=dstport:80`(Linux・root) | | nexthop 強制 | `name ADDR nexthop=GWIP [source=eth0]`(直接 ICMP・Linux・root・IPv4/IPv6) | ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、リモートサーバ X.X.X.X 経由で対象へ ping します。 `user=USER` / `key=KEYPATH` で ssh のユーザ名と鍵を指定でき、`os` を省略すると deadman を実行している OS 名が使われます。 `os=` は中継先(Unix 系)の OS を表し、リモートで使う `ping`/`ping6` の選択や送信元フラグ(`-I`/`-S`)に影響します。Windows の中継先(`ping -n`)は未対応です。 + +`quic` モード(`via=quic`)は対象に QUIC(TLS1.3)ハンドシェイクを毎回張り、その**確立完了までの時間**を RTT として測ります(既定ポート 443・ALPN `h3`)。ICMP のような「任意 IP の死活」ではなく、QUIC/h3 を話すエンドポイント(ポート)の到達性を測る点で `tcp=` 接続プローブの仲間です。RTT は両端の TLS1.3 暗号処理を含むため ICMP よりやや高めに出ます。`port=` / `alpn=` / `sni=` / `verify=` で挙動を調整できます(「[属性](#属性)」参照)。外部コマンド不要・OS 非依存で、raw ソケット権限も要りません。 + 各モードの記述例は `deadman.conf` にもコメントとして含まれています。 中継モードが必要とする外部コマンドや権限は「[権限とプラットフォームに関する注意](#権限とプラットフォームに関する注意)」を参照してください。 @@ -98,11 +102,17 @@ ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、 値は送信元 IP アドレス、または `source=eth0` のようなネットワークインターフェース名です。 インターフェース名を指定できるのは直接 ICMP・`nexthop`(Linux)と **Linux 上**の ssh/netns/vrf 中継に限ります。 macOS/BSD の中継先では `ping -S` が送信元**アドレス**のみを受け付けるため、ソースには IP アドレスを指定してください(インターフェース名は構築時に拒否され、起動時に警告が表示されます)。 - snmp / routeros / tcp(hping3) モードは `source` を使いません(プローブは中継先で生成されるか hping3 が未対応のため)。指定しても無視され、起動時に警告が表示されます(対象の監視は継続します)。 + snmp / routeros / tcp(hping3) / quic モードは `source` を使いません(プローブは中継先で生成されるか、hping3 が未対応か、QUIC のダイヤルが `[::]:0` を bind するため)。指定しても無視され、起動時に警告が表示されます(対象の監視は継続します)。 + +- **`verify=on|off`**(routeros / quic モード)… TLS 証明書検証の有無を指定します。 + 綴りは両モード共通で、`on` / `true` / `yes` / `1` が検証あり、`off` / `false` / `no` / `0` が検証なし(大文字小文字を問わない)。 + ただし**既定値はモードで逆**です。 + - routeros … 既定**有効**(安全側)。`off` 等でのみ無効化でき(自己署名証明書のネットワーク機器向け)、認識できない値は検証有効として扱います。 + - quic … 既定**無効**(`InsecureSkipVerify`)。QUIC プローブは IP アドレスを直接対象にすることが多く、証明書/SAN 検査ではほぼ毎回失敗するためです。`on` 等で検証を有効化できます(認識できない値は無効のまま)。 -- **`verify=on|off`**(routeros モード)… RouterOS REST API の TLS 証明書検証の有無を指定します。 - 既定は**有効**で、`off` / `false` / `no` / `0`(大文字小文字を問わない)でのみ無効化できます(自己署名証明書のネットワーク機器向け)。 - `on` / `true` / `yes` / `1` や認識できない値は安全側(検証有効)として扱います。 +- **`port=` / `alpn=` / `sni=`**(quic モード)… QUIC プローブのダイヤル先ポート(既定 `443`)、ALPN(既定 `h3`)、TLS の SNI(`ServerName`)を指定します。 + `sni` を省略するとホスト名のアドレスがそのまま使われ、IP アドレスリテラルの場合は空になります。 + `InsecureSkipVerify`(検証無効)でも SNI は送出されるため、実在の h3 フロント(Cloudflare / Google など)を IP アドレスで対象にするときは `sni=` の指定が必要になることがあります。 - **`resolve_family=ipv4|ipv6`** … ホスト名の解決をそのアドレスファミリーに固定します(`ipv4` は A レコード、`ipv6` は AAAA レコード)。 dual-stack なホスト名を IPv4 と IPv6 で別々に監視したいときに使います。 @@ -112,11 +122,11 @@ ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、 web-v6 example.com resolve_family=ipv6 ``` - - **直接 ICMP に限り**有効で、`relay`/`via`/`tcp`/`nexthop` を併用した対象では無視されます(それらは中継先やゲートウェイ側でファミリーが決まるため)。 + - **直接 ICMP と `via=quic` で**有効です(どちらも対象名をローカルで解決するため)。その他のモード(`relay`/`via=snmp/netns/vrf/routeros_api`/`tcp(hping3)`/`nexthop`)を併用した対象では無視されます(中継先・ゲートウェイ側でファミリーが決まるか、そのモード自身のリゾルバ任せのため)。 - 値は `ipv4`/`ipv6` のみを認識し、それ以外(綴り違いなど)は指定なし(自動)として扱います。 - - 指定したファミリーに該当レコードが無い場合や、反対のファミリーの IP アドレスリテラルを指定した場合は、その対象は `X`(到達不能)として表示されます。 + - 指定したファミリーに該当レコードが無いホスト名は、その対象が `X`(到達不能)になります。直接 ICMP では反対のファミリーの IP アドレスリテラルを指定した場合も `X` になりますが、`via=quic` では IP リテラルはファミリーが確定済みのため `resolve_family` の対象外です(そのままダイヤルします)。 - 名前・アドレスが同じでも `resolve_family` が異なれば別エントリとして扱われ、リロード時の履歴も分かれて保持されます。 - VIA 列はどちらも `direct` になるため、上の例のように行名で区別すると見分けやすくなります。 + VIA 列は同じ表示(`direct` どうし/`quic PORT` どうし)になるため、上の例のように行名で区別すると見分けやすくなります。 - ホスト名が同じファミリー内に複数のアドレス(複数の A / AAAA)を持つ場合、probe するのは **RFC 6724 で並べ替えた先頭の 1 アドレスだけ**です(アドレス選択は Go / OS のリゾルバに委譲し、複数アドレスを並行して試す Happy Eyeballs 的な動作は行いません。これは `resolve_family` を指定しない場合も同じです)。 ラウンドロビン DNS では解決のたびに先頭が入れ替わりうるため、特定の 1 台を継続的に監視したい場合は IP アドレスを直接指定してください。 @@ -198,7 +208,7 @@ columns ADDRESS=off MIN=off MAX=off VIA=on 指定できるキーは`HOSTNAME ADDRESS VIA LOSS RTT AVG MIN MAX JIT SNT FAIL` です。 `RESULT`(結果バー)は deadman の看板のため常に表示され、隠せません。 -`VIA` 列は各対象の**取得方法**を表示します(`direct` / `nexthop GWIP` / `ssh HOST` / `snmp HOST` / `netns NAME` / `vrf NAME` / `routeros HOST` / `tcp dstport:PORT`)。 +`VIA` 列は各対象の**取得方法**を表示します(`direct` / `nexthop GWIP` / `ssh HOST` / `snmp HOST` / `netns NAME` / `vrf NAME` / `routeros HOST` / `tcp dstport:PORT` / `quic PORT`)。 同じアドレスを別経路で監視している場合などに、一目で区別できます。 表示は設定上の意図ではなく**実際に使われる経路**を反映するため、relay/via/tcp が優先されて nexthop が無視される対象では、その実際のモード(`ssh` など)が表示されます。 @@ -242,6 +252,7 @@ precision ms.1 統計値の表示精度(ms / ms.1 / ms.2 / ms.3 のいず `ssh`(ssh 中継)、`snmpping`(snmp)、`ip`(netns/vrf)、`hping3`(tcp)が該当します。 netns・vrf・hping3 は Linux + root が前提です。 RouterOS API モードは HTTP を使うためOS 非依存です。 +`quic` モード(`via=quic`)も in-process(quic-go による UDP ダイヤル)で動作し、外部コマンド不要・OS 非依存で、raw ソケット権限(root / `CAP_NET_RAW`)も要りません。 必要なコマンドが存在しない環境(たとえば Windows)では、その対象はクラッシュせず失敗(`X`)として表示されます。 `nexthop` 強制は AF_PACKET で L2 フレームを送り、ゲートウェイの MAC を IPv4 は ARP(`/proc/net/arp`)、IPv6 は NDP(netlink)で解決するため Linux + root/CAP_NET_RAW が必須で、他 OS のビルドではその対象は失敗(`X`)として表示されます。 diff --git a/deadman.conf b/deadman.conf index 653c6a2..0cc8758 100644 --- a/deadman.conf +++ b/deadman.conf @@ -49,6 +49,12 @@ kame6 2001:2f0:0:8800::1:1 # tcp/hping3 (Linux, root; may also work on macOS) -- TCP probe to a port. #wide-tcp80 203.178.136.59 tcp=dstport:80 +# quic (OS-independent, no external command) -- QUIC (TLS1.3) handshake RTT to a QUIC/h3 +# endpoint. Cert verification defaults OFF; "port" defaults 443, "alpn" h3. "sni" sets the +# TLS ServerName -- a bare IP target against a real h3 front-end usually needs it. +#cloudflare-quic 1.1.1.1 via=quic sni=cloudflare-dns.com +#google-quic dns.google via=quic + # nexthop=GWIP -- force a direct ICMP probe out a next-hop gateway (Linux, root/CAP_NET_RAW). # IPv4 (ARP) and IPv6 (NDP) both work; gateway and target must share the address family. # The gateway must be on-link; name the egress with source=IFNAME or source=SRCIP (IFNAME is diff --git a/go.mod b/go.mod index 4949fdf..5026ce4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,8 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 github.com/mattn/go-runewidth v0.0.24 github.com/prometheus-community/pro-bing v0.9.0 - golang.org/x/net v0.55.0 + github.com/quic-go/quic-go v0.60.0 + golang.org/x/net v0.56.0 golang.org/x/sys v0.46.0 ) @@ -29,6 +30,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 1084bd4..37690ec 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -34,16 +36,28 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.9.0 h1:M/zt1lL7cmbK+wm40RPS4waaiAN2EcYEeD4Xt0Lv4x0= github.com/prometheus-community/pro-bing v0.9.0/go.mod h1:IBeW2ScY7sAWv4mYjH0xDDigqfe7kXeVxNdbNKdBHWY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -51,3 +65,5 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index afb6362..83e0b88 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,11 +101,14 @@ var reSeparator = regexp.MustCompile(`^-+$`) // deliberately absent: the netns mode is selected by via=netns and reads the namespace // name from relay=, so a `netns=` token is a typo that should surface as an ignored // stray token (the warning every other unknown key gets), not be silently swallowed. +// "port"/"alpn"/"sni" are QUIC (via=quic) sub-attributes; like "method"/"verify" they +// are meaningful only to their mode and harmlessly ignored by the others. var relayKeys = map[string]bool{ "os": true, "relay": true, "via": true, "community": true, "user": true, "key": true, "method": true, "username": true, "password": true, "verify": true, "nexthop": true, "resolve_family": true, + "port": true, "alpn": true, "sni": true, } // ParseConfig reads a deadman config from r and returns the parsed targets plus diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e40f3ef..f5d9df2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -118,6 +118,34 @@ func TestParseConfigResolveFamily(t *testing.T) { } } +func TestParseConfigQUIC(t *testing.T) { + // The QUIC sub-attributes (port/alpn/sni) are relay keys, so they land in the + // relay map verbatim alongside via/verify; none is recorded in Dropped as unknown. + cfg, err := ParseConfig( + strings.NewReader("h 1.2.3.4 via=quic port=8443 alpn=h3 sni=x verify=on\n"), + ) + if err != nil { + t.Fatal(err) + } + + specs := cfg.Targets + if len(specs) != 1 { + t.Fatalf("got %d specs, want 1: %+v", len(specs), specs) + } + + for k, want := range map[string]string{ + "via": "quic", "port": "8443", "alpn": "h3", "sni": "x", "verify": "on", + } { + if specs[0].Relay[k] != want { + t.Errorf("Relay[%q] = %q, want %q", k, specs[0].Relay[k], want) + } + } + + if len(specs[0].Dropped) != 0 { + t.Errorf("Dropped = %v, want empty (quic attrs must not be dropped)", specs[0].Dropped) + } +} + func TestParseConfigDroppedTokens(t *testing.T) { // A name with spaces shifts real tokens past the address slot: this parses to // name="Cloudflare", address="via", with "MGMT" and "1.1.1.1" unroutable and diff --git a/internal/ping/method_test.go b/internal/ping/method_test.go index ea3144c..dea3ae8 100644 --- a/internal/ping/method_test.go +++ b/internal/ping/method_test.go @@ -30,9 +30,10 @@ func TestPingCommand(t *testing.T) { } } -// source= is meaningless for the snmp / tcp(hping3) / routeros modes (the probe -// originates at the relay, or hping3 has no per-probe source), so SourceUnsupported -// flags them for a TUI warning. The source-capable modes return false. +// source= is meaningless for the snmp / tcp(hping3) / routeros / quic modes (the +// probe originates at the relay, hping3 has no per-probe source, or the QUIC dial +// binds [::]:0), so SourceUnsupported flags them for a TUI warning. The +// source-capable modes return false. func TestSourceUnsupported(t *testing.T) { cases := []struct { name string @@ -63,6 +64,15 @@ func TestSourceUnsupported(t *testing.T) { }, true, }, + { + "quic", + Spec{ + Addr: "1.2.3.4", + Source: "10.0.0.1", + Relay: map[string]string{"via": "quic"}, + }, + true, + }, {"direct", Spec{Addr: "1.2.3.4", Source: "10.0.0.1"}, false}, { "nexthop", @@ -112,6 +122,7 @@ func TestSourceIgnoredNotRejected(t *testing.T) { "password": "p", }, }, + {Addr: "1.2.3.4", Source: "10.0.0.1", Relay: map[string]string{"via": "quic"}}, } for _, s := range specs { _, err := New(s) @@ -121,8 +132,20 @@ func TestSourceIgnoredNotRejected(t *testing.T) { } } +// The source-ignored warning's mode list comes from methodRegistry, so a new +// source-ignoring mode joins it automatically. Pin the set so the registry's +// sourceUnsupported flags cannot silently drift from the warning text. +func TestSourceModeNames(t *testing.T) { + if got := SourceUnsupportedModeNames(); !slices.Equal( + got, + []string{"quic", "routeros", "snmp", "tcp"}, + ) { + t.Errorf("SourceUnsupportedModeNames = %v, want [quic routeros snmp tcp]", got) + } +} + // selectMethod and Describe must agree with New's dispatch precedence: tcp > -// via=snmp/netns/vrf/routers_api > relay (ssh) > nexthop > direct. +// via=snmp/netns/vrf/routers_api/quic > relay (ssh) > nexthop > direct. func TestSelectMethodAndDescribe(t *testing.T) { cases := []struct { name string @@ -200,6 +223,22 @@ func TestSelectMethodAndDescribe(t *testing.T) { method: MethodRouterOS, describe: "routeros r", }, + { + name: "quic", + spec: Spec{Addr: "1.1.1.1", Relay: map[string]string{"via": "quic"}}, + method: MethodQUIC, + describe: "quic 443", + }, + { + // A non-default port= is reflected in the VIA label. + name: "quic_port", + spec: Spec{ + Addr: "1.1.1.1", + Relay: map[string]string{"via": "quic", "port": "8443"}, + }, + method: MethodQUIC, + describe: "quic 8443", + }, { name: "tcp", spec: Spec{Addr: "1.1.1.1", TCP: "dstport:80"}, diff --git a/internal/ping/ping.go b/internal/ping/ping.go index c607aa0..be81beb 100644 --- a/internal/ping/ping.go +++ b/internal/ping/ping.go @@ -1,12 +1,13 @@ // Package ping abstracts a single ICMP/relay reachability probe. // // Each probing mode (direct ICMP, SSH relay, SNMP, network namespace, VRF, -// RouterOS REST API, TCP/hping3) implements the Pinger interface. New selects an -// implementation from a Spec based on its "via" and other relay attributes. +// RouterOS REST API, TCP/hping3, QUIC) implements the Pinger interface. New selects +// an implementation from a Spec based on its "via" and other relay attributes. package ping import ( "context" + "slices" ) // ResultCode classifies a probe outcome. The monitor layer maps each code to its @@ -61,6 +62,7 @@ const ( MethodNetns // via=netns. MethodVRF // via=vrf. MethodRouterOS // via=routers_api. + MethodQUIC // via=quic : QUIC handshake probe. MethodSSH // relay= : ssh-wrapped remote ping. MethodNexthop // nexthop= : direct ICMP forced via a gateway. ) @@ -73,6 +75,7 @@ const ( viaVRF = "vrf" viaRouters = "routers_api" viaRouterOS = "routeros_api" + viaQUIC = "quic" ) // labelDirect is the VIA-column label for the native direct-ICMP path. It is also @@ -84,6 +87,7 @@ const labelDirect = "direct" const ( networkIPv4 = "ip4" networkIPv6 = "ip6" + networkAny = "ip" // let the resolver choose the family (resolve_family unset). ) // resolveFamilies maps each accepted resolve_family attribute value to the pro-bing @@ -95,13 +99,14 @@ var resolveFamilies = map[string]string{"ipv4": networkIPv4, "ipv6": networkIPv6 // resolveNetwork returns the network a target's resolve_family pins ("ip4"/"ip6"), or // "" to let the resolver choose (today's behavior) when the attribute is unset or // unrecognized. Only "ipv4"/"ipv6" are recognized; the lenient unknown->"" fallback -// matches deadman's other attribute handling. Honored only by the direct-ICMP path: -// relay/via/tcp/nexthop targets resolve their family elsewhere (gateway or remote). +// matches deadman's other attribute handling. Honored by the modes that resolve the +// target name locally — direct ICMP and via=quic; the others (relay/snmp/netns/vrf/ +// routeros/tcp/nexthop) resolve their family elsewhere (gateway or remote). func resolveNetwork(s Spec) string { return resolveFamilies[s.Relay["resolve_family"]] } // selectMethod resolves the probing method from a Spec. New switches on it to // build the Pinger and Describe to label it, so the two never drift. The -// precedence is: tcp > via=snmp/netns/vrf/routers_api > relay (ssh) > nexthop > +// precedence is: tcp > via=snmp/netns/vrf/routers_api/quic > relay (ssh) > nexthop > // direct. nexthop is consulted only on the default path, so relay/via/tcp take // precedence over it. func selectMethod(s Spec) Method { @@ -122,6 +127,8 @@ func selectMethod(s Spec) Method { // silently fell through to the SSH relay. Accepting both is additive and // breaks no existing config. return MethodRouterOS + case viaQUIC: + return MethodQUIC } switch { @@ -153,19 +160,36 @@ func UsesNexthop(s Spec) bool { } // SourceUnsupported reports whether the Spec sets a source= that its resolved mode -// cannot honor: snmp/routeros originate the probe at the relay and tcp(hping3) has no -// per-probe source, so the attribute is silently ignored by those modes. The TUI -// warns about these (rather than failing the target) so monitoring still runs with +// cannot honor: snmp/routeros originate the probe at the relay, tcp(hping3) has no +// per-probe source, and quic's fresh dial binds [::]:0 — so the attribute is silently +// ignored by those modes (their methodRegistry entry has sourceUnsupported=true). The +// TUI warns about these rather than failing the target, so monitoring still runs with // the source ignored. The source-capable modes (direct ICMP, nexthop, ssh/netns/vrf) -// return false. It shares selectMethod with New, so it cannot disagree with dispatch. +// return false. func SourceUnsupported(s Spec) bool { if s.Source == "" { return false } - m := selectMethod(s) + return methodRegistry[selectMethod(s)].sourceUnsupported +} + +// SourceUnsupportedModeNames returns the sorted short names of the modes that ignore +// source= (quic/routeros/snmp/tcp). The startup warning builds its mode list from this, +// so a new source-ignoring mode shows up in the warning automatically instead of the +// message having to be updated by hand (the drift this PR's review caught). +func SourceUnsupportedModeNames() []string { + var names []string + + for _, e := range methodRegistry { + if e.sourceUnsupported { + names = append(names, e.name) + } + } + + slices.Sort(names) - return m == MethodSNMP || m == MethodTCP || m == MethodRouterOS + return names } // Describe returns a short human label for how a target is probed: the method @@ -184,6 +208,8 @@ func Describe(s Spec) string { return viaVRF + " " + s.Relay["relay"] case MethodRouterOS: return "routeros " + s.Relay["relay"] + case MethodQUIC: + return quicLabel(s) case MethodSSH: return "ssh " + s.Relay["relay"] case MethodNexthop: @@ -203,32 +229,50 @@ func nexthopLabel(s Spec) string { return "nexthop " + s.Relay["nexthop"] } +// methodEntry is the per-method dispatch and capability data: how to build the Pinger, +// the short mode name shown in operator warnings, and whether the mode can honor a +// source=. methodRegistry is the single source for all three, so adding a mode (or +// changing its source capability) updates New, SourceUnsupported, and the startup +// warning together instead of in three hand-synced places. +type methodEntry struct { + build func(Spec) (Pinger, error) + name string // short mode name for the source-ignored warning; "" for source-capable modes. + sourceUnsupported bool +} + +// The subprocess-backed modes share one constructor, parameterized by mode; these +// thin wrappers let methodRegistry hold a uniform func(Spec) (Pinger, error). +func newNetnsPinger(s Spec) (Pinger, error) { return newSubprocessPinger(s, modeNetns) } +func newVRFPinger(s Spec) (Pinger, error) { return newSubprocessPinger(s, modeVRF) } +func newSSHPinger(s Spec) (Pinger, error) { return newSubprocessPinger(s, modeSSH) } + +// methodRegistry maps each Method to its entry. selectMethod picks the Method (the +// single source of dispatch precedence); New and the source-capability helpers read +// from here. Describe stays a separate switch because its label is Spec-dependent (it +// appends the relay host, tcp port, or gateway). +var methodRegistry = map[Method]methodEntry{ + MethodDirect: {build: newICMPPinger}, + MethodTCP: {build: newHPingPinger, name: "tcp", sourceUnsupported: true}, + MethodSNMP: {build: newSNMPPinger, name: viaSNMP, sourceUnsupported: true}, + MethodNetns: {build: newNetnsPinger}, + MethodVRF: {build: newVRFPinger}, + MethodRouterOS: {build: newRouterOSPinger, name: "routeros", sourceUnsupported: true}, + MethodQUIC: {build: newQUICPinger, name: viaQUIC, sourceUnsupported: true}, + MethodSSH: {build: newSSHPinger}, + MethodNexthop: {build: newNexthopPinger}, +} + // New builds the Pinger for a Spec, selecting the mode from the relay attributes. func New(s Spec) (Pinger, error) { if s.OSName == "" { s.OSName = DefaultOSName() } - switch selectMethod(s) { - case MethodTCP: - return newHPingPinger(s) - case MethodSNMP: - return newSNMPPinger(s) - case MethodNetns: - return newSubprocessPinger(s, modeNetns) - case MethodVRF: - return newSubprocessPinger(s, modeVRF) - case MethodRouterOS: - return newRouterOSPinger(s) - case MethodSSH: - return newSubprocessPinger(s, modeSSH) - case MethodNexthop: - return newNexthopPinger(s) - case MethodDirect: - return newICMPPinger(s) + if e, ok := methodRegistry[selectMethod(s)]; ok { + return e.build(s) } - // selectMethod only yields the methods handled above; direct ICMP is the - // catch-all for any future method added without its own New branch. + // selectMethod only yields registered methods; direct ICMP is the catch-all for any + // future method added without a registry entry. return newICMPPinger(s) } diff --git a/internal/ping/quic.go b/internal/ping/quic.go new file mode 100644 index 0000000..84ce69c --- /dev/null +++ b/internal/ping/quic.go @@ -0,0 +1,183 @@ +package ping + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "time" + + "github.com/quic-go/quic-go" +) + +const quicTimeout = 5 * time.Second + +const ( + defaultQUICPort = "443" + defaultQUICALPN = "h3" +) + +// quicPinger probes by performing a fresh QUIC (TLS 1.3) handshake on each Send and +// measuring the dial->handshake-complete wall-clock as the RTT. It is the in-process +// sibling of the tcp= connect-probe: it targets an endpoint known to speak QUIC on a +// port, not an arbitrary IP. The tls.Config is built once in the constructor (the +// routeros pattern). Unlike routeros, verify defaults OFF (insecure), because QUIC +// probes commonly target endpoints by IP, where a SAN/cert check would fail every probe. +// +// The RTT is the handshake-completion time, so it includes the TLS 1.3 crypto work on +// both ends and reads slightly higher than an ICMP or TCP-connect probe. No TTL is +// available from a QUIC handshake, so Result.TTL is always -1. +type quicPinger struct { + host string // hostname or IP literal, resolved per Send (no port). + port string // dial port, validated at construction. + network string // LookupNetIP network: "ip4"/"ip6" pins resolve_family, "ip" = either. + tlsConfig *tls.Config +} + +func newQUICPinger(s Spec) (Pinger, error) { + const ( + minPort = 1 + maxPort = 65535 + ) + + // Validate port= at construction so a typo (port=abc / 0 / 99999) surfaces as a + // startup warning and a permanent-failure row (model.go), like routeros rejecting a + // missing username — rather than a silent X the operator must debug by staring at it. + port := quicPort(s) + + n, err := strconv.Atoi(port) + if err != nil || n < minPort || n > maxPort { + return nil, fmt.Errorf( + "quic: invalid port %q for %s (want %d-%d)", + port, + s.Addr, + minPort, + maxPort, + ) + } + + alpn := s.Relay["alpn"] + if alpn == "" { + alpn = defaultQUICALPN + } + + // verify defaults OFF for QUIC (InsecureSkipVerify=true) — the opposite of the + // routeros default. Only an explicit truthy value turns verification on; an absent + // or unrecognized value stays insecure. + insecure := true + + switch strings.ToLower(s.Relay["verify"]) { + case "on", "true", "yes", "1": + insecure = false + default: + // "", off/false/no/0, or any unrecognized value: stay insecure. + } + + // SNI: an explicit sni= wins; otherwise the hostname Addr; otherwise "" for an IP + // literal. isIPLiteral uses netip.ParseAddr (not net.ParseIP) so a zoned IPv6 literal + // such as fe80::1%eth0 is recognized as a literal and its zone never leaks into the + // SNI. The SNI field is sent even with InsecureSkipVerify, and real h3 front-ends + // (Cloudflare/Google) require it — so a bare-IP target may need an explicit sni= to + // complete the handshake (documented in the README). + sni := s.Relay["sni"] + if sni == "" && !isIPLiteral(s.Addr) { + sni = s.Addr + } + + // #nosec G402 -- TLS verification is opt-in via verify=on; QUIC probes target + // endpoints (often by IP) where a SAN/cert check would otherwise fail every probe. + tlsConfig := &tls.Config{ + InsecureSkipVerify: insecure, + NextProtos: []string{alpn}, + ServerName: sni, + } + + // resolve_family pins a dual-stack hostname to A (ip4) or AAAA (ip6) records, like the + // direct-ICMP path; an unset/unrecognized value lets the resolver choose. It is + // applied only to hostname resolution — an IP literal already fixes its family. + network := resolveNetwork(s) + if network == "" { + network = networkAny + } + + return &quicPinger{host: s.Addr, port: port, network: network, tlsConfig: tlsConfig}, nil +} + +func (p *quicPinger) Send(ctx context.Context) Result { + ctx, cancel := context.WithTimeout(ctx, quicTimeout) + defer cancel() + + // Resolve the host OUTSIDE the RTT window so DNS latency/jitter does not inflate the + // handshake measurement (matching the direct-ICMP path). The lookup still honors the + // ctx timeout, so a hung resolver cannot overrun the probe. + addr, err := p.resolveAddr(ctx) + if err != nil { + return Result{Code: Failed, TTL: -1} + } + + // DialAddr blocks until the 1-RTT handshake completes; ctx cancellation aborts the + // dial. A nil quic.Config leaves the context as the single timeout authority. + start := time.Now() + + conn, err := quic.DialAddr(ctx, addr, p.tlsConfig, nil) + if err != nil { + return Result{Code: Failed, TTL: -1} + } + + // Capture the RTT before closing so the close path never contaminates it. + rtt := float64(time.Since(start).Microseconds()) / usPerMs + + // Each DialAddr spins up an internal Transport and ephemeral UDP socket; close it + // or leak a socket and goroutines on every probe. + _ = conn.CloseWithError(0, "") + + return Result{Success: true, Code: Success, RTT: rtt, TTL: -1} +} + +// resolveAddr returns the host:port to dial with the host already resolved to an IP, so +// quic.DialAddr performs no DNS inside the RTT window. An IP-literal host (including a +// zoned IPv6 literal) is returned unchanged; a hostname is resolved here under ctx. Like +// the rest of deadman, it probes the resolver's first address rather than racing several. +func (p *quicPinger) resolveAddr(ctx context.Context) (string, error) { + if isIPLiteral(p.host) { + return net.JoinHostPort(p.host, p.port), nil + } + + ips, err := net.DefaultResolver.LookupNetIP(ctx, p.network, p.host) + if err != nil { + return "", err + } + + if len(ips) == 0 { + return "", fmt.Errorf("quic: no addresses for %s", p.host) + } + + return net.JoinHostPort(ips[0].String(), p.port), nil +} + +// isIPLiteral reports whether host is an IP-address literal (v4, v6, or a zoned v6 such +// as fe80::1%eth0). It uses netip.ParseAddr rather than net.ParseIP precisely because +// net.ParseIP rejects the zone form, which would misclassify a zoned literal as a +// hostname (icmp_linux.go uses netip.ParseAddr for the same reason). +func isIPLiteral(host string) bool { + _, err := netip.ParseAddr(host) + + return err == nil +} + +// quicPort returns the dial port for a QUIC target: an explicit port= or the 443 default. +func quicPort(s Spec) string { + if p := s.Relay["port"]; p != "" { + return p + } + + return defaultQUICPort +} + +// quicLabel renders the VIA-column label for a QUIC target: "quic ". +func quicLabel(s Spec) string { + return viaQUIC + " " + quicPort(s) +} diff --git a/internal/ping/quic_manual_test.go b/internal/ping/quic_manual_test.go new file mode 100644 index 0000000..6d426a2 --- /dev/null +++ b/internal/ping/quic_manual_test.go @@ -0,0 +1,172 @@ +//go:build manual + +// These tests perform real QUIC handshakes over the network and are excluded from +// the default suite. Run them explicitly to verify the QUIC probe works on the host: +// +// go test -tags manual -run TestQUIC -v ./internal/ping +package ping + +import ( + "context" + "os" + "runtime" + "testing" + "time" +) + +// TestQUICReachable handshakes against public HTTP/3 endpoints and expects at least +// one to succeed (a single endpoint being down must not fail the sanity check). It +// exercises both the hostname-SNI path and the bare-IP-with-explicit-sni path. +func TestQUICReachable(t *testing.T) { + targets := []struct { + name string + addr string + relay map[string]string + }{ + {"dns.google", "dns.google", map[string]string{"via": "quic"}}, + {"cloudflare", "1.1.1.1", map[string]string{"via": "quic", "sni": "cloudflare-dns.com"}}, + } + + anyOK := false + + for _, tg := range targets { + p, err := newQUICPinger(Spec{Addr: tg.addr, Relay: tg.relay}) + if err != nil { + t.Fatalf("%s: newQUICPinger error: %v", tg.name, err) + } + + res := p.Send(context.Background()) + if res.Success { + anyOK = true + + t.Logf("%s: rtt=%.3fms", tg.name, res.RTT) + } else { + t.Logf( + "%s: unreachable (code=%d) — may be a local network restriction", + tg.name, + res.Code, + ) + } + } + + if !anyOK { + t.Error("no public QUIC endpoint reachable; check network/UDP 443 egress") + } +} + +// TestQUICReachableIPv6 confirms the dial works over IPv6. quic-go's DialAddr binds its +// UDP socket with net.ListenUDP("udp", {IP: net.IPv4zero}); because IPv4zero is the +// wildcard, Go opens a dual-stack [::] socket that reaches IPv6 too, so an IPv6 literal +// (or resolve_family=ipv6) target dials fine. Skips when the host has no IPv6 egress. +func TestQUICReachableIPv6(t *testing.T) { + // Cloudflare DNS over an IPv6 literal, with the SNI a real h3 front-end expects. + p, err := newQUICPinger(Spec{ + Addr: "2606:4700:4700::1111", + Relay: map[string]string{"via": "quic", "sni": "cloudflare-dns.com"}, + }) + if err != nil { + t.Fatal(err) + } + + res := p.Send(context.Background()) + if !res.Success { + t.Skipf( + "IPv6 QUIC endpoint unreachable (code=%d) — likely no IPv6 egress on this host", + res.Code, + ) + } + + t.Logf("IPv6 literal dial: rtt=%.3fms", res.RTT) +} + +// TestQUICUnreachable dials a TEST-NET-1 (RFC 5737) address that speaks no QUIC and +// expects a timeout failure bounded by quicTimeout — not a hang and not a success. +func TestQUICUnreachable(t *testing.T) { + p, err := newQUICPinger(Spec{Addr: "192.0.2.1", Relay: map[string]string{"via": "quic"}}) + if err != nil { + t.Fatal(err) + } + + start := time.Now() + res := p.Send(context.Background()) + elapsed := time.Since(start) + + if res.Success { + t.Fatalf("unreachable TEST-NET address reported up: %+v", res) + } + + t.Logf("unreachable failed as expected in %v (code=%d)", elapsed, res.Code) + + if elapsed > quicTimeout+2*time.Second { + t.Errorf("probe overran its timeout: %v (quicTimeout=%v)", elapsed, quicTimeout) + } +} + +// TestQUICNoLeak guards the design's central risk: Send dials a fresh quic.Transport +// and UDP socket on every probe, and deadman runs it on a loop forever. It verifies +// that CloseWithError reclaims the per-probe goroutines (and, on Linux, file +// descriptors) so continuous operation does not leak. The success path is the hot +// path, so the loop runs against a real endpoint. +func TestQUICNoLeak(t *testing.T) { + p, err := newQUICPinger( + Spec{Addr: "1.1.1.1", Relay: map[string]string{"via": "quic", "sni": "cloudflare-dns.com"}}, + ) + if err != nil { + t.Fatal(err) + } + + // The first successful dial initializes one-time runtime state, so the baseline is + // taken after it rather than before. + if res := p.Send(context.Background()); !res.Success { + t.Skip("endpoint unreachable; cannot run the success-path leak check") + } + + stable := func() (int, int) { + runtime.GC() + time.Sleep(100 * time.Millisecond) + + return runtime.NumGoroutine(), openFDs() + } + + baseG, baseFD := stable() + + const iterations = 30 + + for i := range iterations { + if res := p.Send(context.Background()); !res.Success { + t.Skipf("endpoint became unreachable at iter %d; leak check inconclusive", i) + } + } + + afterG, afterFD := stable() + + t.Logf( + "goroutines %d -> %d; fds %d -> %d over %d probes", + baseG, + afterG, + baseFD, + afterFD, + iterations, + ) + + // A per-probe leak would grow roughly linearly with iterations; allow small slack + // for runtime/GC scheduling jitter. + if afterG-baseG > 3 { + t.Errorf("goroutine leak: grew by %d over %d probes", afterG-baseG, iterations) + } + + if baseFD > 0 && afterFD-baseFD > 3 { + t.Errorf("fd leak: grew by %d over %d probes", afterFD-baseFD, iterations) + } +} + +// openFDs returns the number of open file descriptors on Linux (via /proc/self/fd), or +// 0 elsewhere — the caller then skips the fd assertion. +func openFDs() int { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return 0 + } + + return len(entries) +} diff --git a/internal/ping/quic_test.go b/internal/ping/quic_test.go new file mode 100644 index 0000000..4dcc134 --- /dev/null +++ b/internal/ping/quic_test.go @@ -0,0 +1,211 @@ +package ping + +import ( + "context" + "testing" +) + +// newQUICPinger's defaults are the contract's sharp edges: verify defaults OFF (the +// opposite of routeros, so copying that switch verbatim would be a bug), the port +// defaults to 443, the ALPN to h3, and the SNI falls back to a hostname Addr but not to +// an IP literal — including a zoned IPv6 literal, which net.ParseIP would misclassify as +// a hostname. This is a white-box check of the resolved tls.Config — no network — so the +// defaults cannot silently drift. +func TestNewQUICPinger(t *testing.T) { + cases := []struct { + name string + spec Spec + wantHost string + wantPort string + wantInsecure bool + wantALPN string + wantSNI string + }{ + { + name: "defaults_ip", + spec: Spec{Addr: "1.2.3.4", Relay: map[string]string{"via": "quic"}}, + wantHost: "1.2.3.4", + wantPort: "443", + wantInsecure: true, // verify defaults OFF. + wantALPN: "h3", + wantSNI: "", // a bare IP literal sends no SNI by default. + }, + { + name: "hostname_sni_fallback", + spec: Spec{Addr: "example.com", Relay: map[string]string{"via": "quic"}}, + wantHost: "example.com", + wantPort: "443", + wantInsecure: true, + wantALPN: "h3", + wantSNI: "example.com", // a hostname Addr becomes the SNI. + }, + { + // A zoned IPv6 literal is still a literal: no SNI, zone not leaked. + name: "zoned_ipv6_literal_no_sni", + spec: Spec{Addr: "fe80::1%eth0", Relay: map[string]string{"via": "quic"}}, + wantHost: "fe80::1%eth0", + wantPort: "443", + wantInsecure: true, + wantALPN: "h3", + wantSNI: "", + }, + { + name: "verify_on_secures", + spec: Spec{ + Addr: "1.2.3.4", + Relay: map[string]string{"via": "quic", "verify": "on"}, + }, + wantHost: "1.2.3.4", + wantPort: "443", + wantInsecure: false, // only an explicit truthy value verifies. + wantALPN: "h3", + wantSNI: "", + }, + { + name: "verify_off_stays_insecure", + spec: Spec{ + Addr: "1.2.3.4", + Relay: map[string]string{"via": "quic", "verify": "off"}, + }, + wantHost: "1.2.3.4", + wantPort: "443", + wantInsecure: true, + wantALPN: "h3", + wantSNI: "", + }, + { + name: "verify_garbage_stays_insecure", + spec: Spec{ + Addr: "1.2.3.4", + Relay: map[string]string{"via": "quic", "verify": "maybe"}, + }, + wantHost: "1.2.3.4", + wantPort: "443", + wantInsecure: true, // an unrecognized value must not accidentally secure. + wantALPN: "h3", + wantSNI: "", + }, + { + name: "explicit_port_alpn_sni", + spec: Spec{ + Addr: "1.2.3.4", + Relay: map[string]string{ + "via": "quic", "port": "8443", "alpn": "hq-interop", "sni": "host.internal", + }, + }, + wantHost: "1.2.3.4", + wantPort: "8443", + wantInsecure: true, + wantALPN: "hq-interop", + wantSNI: "host.internal", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pinger, err := newQUICPinger(c.spec) + if err != nil { + t.Fatalf("newQUICPinger error: %v", err) + } + + p, ok := pinger.(*quicPinger) + if !ok { + t.Fatalf("newQUICPinger returned %T, want *quicPinger", pinger) + } + + if p.host != c.wantHost || p.port != c.wantPort { + t.Errorf("host:port = %q:%q, want %q:%q", p.host, p.port, c.wantHost, c.wantPort) + } + + if p.tlsConfig.InsecureSkipVerify != c.wantInsecure { + t.Errorf( + "InsecureSkipVerify = %v, want %v", + p.tlsConfig.InsecureSkipVerify, + c.wantInsecure, + ) + } + + if len(p.tlsConfig.NextProtos) != 1 || p.tlsConfig.NextProtos[0] != c.wantALPN { + t.Errorf("NextProtos = %v, want [%q]", p.tlsConfig.NextProtos, c.wantALPN) + } + + if p.tlsConfig.ServerName != c.wantSNI { + t.Errorf("ServerName = %q, want %q", p.tlsConfig.ServerName, c.wantSNI) + } + }) + } +} + +// resolve_family pins the QUIC dial's hostname resolution to a family (like direct +// ICMP); an unset or unrecognized value lets the resolver choose ("ip"). +func TestNewQUICPingerResolveFamily(t *testing.T) { + cases := map[string]string{"": "ip", "ipv4": "ip4", "ipv6": "ip6", "bogus": "ip"} + + for rf, want := range cases { + relay := map[string]string{"via": "quic"} + if rf != "" { + relay["resolve_family"] = rf + } + + pinger, err := newQUICPinger(Spec{Addr: "example.com", Relay: relay}) + if err != nil { + t.Fatalf("resolve_family=%q: newQUICPinger error: %v", rf, err) + } + + qp, ok := pinger.(*quicPinger) + if !ok { + t.Fatalf("resolve_family=%q: returned %T, want *quicPinger", rf, pinger) + } + + if qp.network != want { + t.Errorf("resolve_family=%q: network = %q, want %q", rf, qp.network, want) + } + } +} + +// An invalid port= must fail at construction, so model.go turns it into a startup +// warning and a permanent-failure row rather than a silent, undebuggable X. +func TestNewQUICPingerInvalidPort(t *testing.T) { + for _, port := range []string{"abc", "0", "-1", "65536", "99999", " 443"} { + _, err := newQUICPinger( + Spec{Addr: "1.2.3.4", Relay: map[string]string{"via": "quic", "port": port}}, + ) + if err == nil { + t.Errorf("port=%q: expected a construction error, got nil", port) + } + } +} + +// resolveAddr returns an IP literal (including a zoned IPv6 literal) unchanged and adds +// the port, so a literal target performs no DNS inside the RTT window. Hostname +// resolution needs the network and is covered by the manual suite. +func TestQUICResolveAddrLiteral(t *testing.T) { + cases := map[string]string{ + "1.2.3.4": "1.2.3.4:443", + "2001:db8::1": "[2001:db8::1]:443", + "fe80::1%eth0": "[fe80::1%eth0]:443", + } + + for addr, want := range cases { + pinger, err := newQUICPinger(Spec{Addr: addr, Relay: map[string]string{"via": "quic"}}) + if err != nil { + t.Fatalf("%s: newQUICPinger error: %v", addr, err) + } + + qp, ok := pinger.(*quicPinger) + if !ok { + t.Fatalf("%s: newQUICPinger returned %T, want *quicPinger", addr, pinger) + } + + got, err := qp.resolveAddr(context.Background()) + if err != nil { + t.Errorf("%s: resolveAddr error: %v", addr, err) + + continue + } + + if got != want { + t.Errorf("%s: resolveAddr = %q, want %q", addr, got, want) + } + } +} diff --git a/internal/tui/parse_warn.go b/internal/tui/parse_warn.go index b4c9a68..14e59fe 100644 --- a/internal/tui/parse_warn.go +++ b/internal/tui/parse_warn.go @@ -36,9 +36,9 @@ func parseWarnings(specs []config.TargetSpec) []string { if ping.SourceUnsupported(specToPingSpec(s)) { warns = append(warns, fmt.Sprintf( - "%q: source=%q is ignored in this relay mode (snmp/routeros/tcp have no local "+ - "source) — remove it, or use a source-capable mode (direct/nexthop/ssh/netns/vrf)", - s.Name, s.Source, + "%q: source=%q is ignored in this mode (%s have no local source) — remove it, "+ + "or use a source-capable mode (direct/nexthop/ssh/netns/vrf)", + s.Name, s.Source, strings.Join(ping.SourceUnsupportedModeNames(), "/"), )) } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index dd0d1ff..edef6f0 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -255,8 +255,8 @@ func TestParseWarningShown(t *testing.T) { } } -// A source= on a mode that ignores it (snmp/routeros/tcp) surfaces a startup warning -// rather than failing the target, so monitoring keeps running with source ignored. +// A source= on a mode that ignores it (snmp/routeros/tcp/quic) surfaces a startup +// warning rather than failing the target, so monitoring keeps running with source ignored. func TestSourceUnsupportedWarningShown(t *testing.T) { specs := []config.TargetSpec{{ Name: "snmp-t", @@ -271,7 +271,7 @@ func TestSourceUnsupportedWarningShown(t *testing.T) { } _, out := drive(t, m, tea.WindowSizeMsg{Width: 160, Height: 40}) - for _, want := range []string{"source=", "ignored in this relay mode"} { + for _, want := range []string{"source=", "ignored in this mode", "quic"} { if !strings.Contains(out, want) { t.Errorf("view missing source-unsupported warning %q\n---\n%s", want, out) }