From e8c252932a0ec219b7062a3a2e7192273b4a0997 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 22 Apr 2026 09:48:55 -0500 Subject: [PATCH] fix(ionosphere): disable TLS verify for GIRO to unbreak ionosonde fetch lgdc.uml.edu's TLS handshake doesn't include the InCommon RSA Server CA 2 intermediate, and Erlang/OTP's chain decode trips on an ASN.1 table-constraint mismatch even when the intermediate is merged into the trust store. The data is public, read-only, unauthenticated ionosonde measurements, so verify_none is scoped to this one client rather than let the pipeline stay dark. --- lib/microwaveprop/ionosphere/giro_client.ex | 25 ++++++++++++++++++- .../ionosphere/giro_client_test.exs | 20 +++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/microwaveprop/ionosphere/giro_client.ex b/lib/microwaveprop/ionosphere/giro_client.ex index c7ecbbeb..770a6874 100644 --- a/lib/microwaveprop/ionosphere/giro_client.ex +++ b/lib/microwaveprop/ionosphere/giro_client.ex @@ -56,7 +56,7 @@ defmodule Microwaveprop.Ionosphere.GiroClient do Microwaveprop.Instrument.span([:giro, :fetch], %{station: ursi_code}, fn -> url = build_url(ursi_code, from_dt, to_dt) - case Req.get(url, [receive_timeout: @default_timeout_ms] ++ req_options()) do + case Req.get(url, [receive_timeout: @default_timeout_ms] ++ merged_req_options()) do {:ok, %{status: 200, body: body}} when is_binary(body) -> parse_tabular(body) @@ -192,4 +192,27 @@ defmodule Microwaveprop.Ionosphere.GiroClient do defp req_options do Application.get_env(:microwaveprop, :giro_req_options, []) end + + # lgdc.uml.edu doesn't bundle the "InCommon RSA Server CA 2" + # intermediate in its handshake. Adding the intermediate to our trust + # store moves the error from unknown_ca to an Erlang/OTP asn1 table + # constraint mismatch during chain decode — :ssl trips on an + # extension shape it doesn't accept. Scope verify_none here is + # acceptable: GIRO data is public, read-only, unauthenticated + # ionosonde measurements, and the endpoint has no alternative. When + # the test config overrides :giro_req_options with a Req.Test plug, + # that override wins — :plug short-circuits before any TLS work, so + # verify_none never runs in tests. + defp merged_req_options do + case req_options() do + [] -> default_req_options() + overrides -> overrides + end + end + + @doc false + @spec default_req_options() :: keyword() + def default_req_options do + [connect_options: [transport_opts: [verify: :verify_none]]] + end end diff --git a/test/microwaveprop/ionosphere/giro_client_test.exs b/test/microwaveprop/ionosphere/giro_client_test.exs index 9b45d675..87756185 100644 --- a/test/microwaveprop/ionosphere/giro_client_test.exs +++ b/test/microwaveprop/ionosphere/giro_client_test.exs @@ -66,4 +66,24 @@ defmodule Microwaveprop.Ionosphere.GiroClientTest do assert row.valid_time == ~U[2026-04-15 18:07:30Z] end end + + describe "default_req_options/0" do + # lgdc.uml.edu's server doesn't bundle the "InCommon RSA Server CA 2" + # intermediate, and Erlang/OTP's chain-build path trips over an + # ASN.1 table-constraint mismatch even when the intermediate is + # merged into cacerts. The data is public read-only ionosonde + # measurements, so we accept verify_none for this specific endpoint + # rather than keep the pipeline dark. Scope is limited to the GIRO + # client — other HTTPS callers still verify normally. + test "returns transport_opts with verify disabled for the GIRO endpoint" do + opts = GiroClient.default_req_options() + + transport_opts = + opts + |> Keyword.fetch!(:connect_options) + |> Keyword.fetch!(:transport_opts) + + assert Keyword.get(transport_opts, :verify) == :verify_none + end + end end