From 3a92d8367db0ac772111605c191ef821361438da Mon Sep 17 00:00:00 2001 From: "Jason T. Wong" Date: Mon, 18 May 2026 20:17:25 -0400 Subject: [PATCH 1/3] Bind firmware downloads to active network interface Binary.Downloader was opening Mint connections with no interface binding, so downloads routed through the OS default (often ppp0 at metric 0) even after NetworkMonitor switched to a higher-priority interface like eth0. When ppp0 lost internet the download stalled; retries went back to ppp0. On each connect attempt, ask NetworkMonitor for the current bound interface and pass bind_to_device to Mint.HTTP.connect/4. Retries after an idle timeout now bind to whichever interface is active at that moment. Guards against NetworkMonitor not being started (test environment). --- lib/peridiod/binary/downloader.ex | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/peridiod/binary/downloader.ex b/lib/peridiod/binary/downloader.ex index d1eaa17..98cc570 100644 --- a/lib/peridiod/binary/downloader.ex +++ b/lib/peridiod/binary/downloader.ex @@ -24,6 +24,7 @@ defmodule Peridiod.Binary.Downloader do Downloader.VerifyConfig } + alias Peridiod.Cloud.NetworkMonitor alias Peridiod.LogSanitizer require Logger @@ -612,7 +613,9 @@ defmodule Peridiod.Binary.Downloader do # like this. There may be a better way to do this.. path = if query, do: "#{path}?#{query}", else: path - with {:ok, conn} <- Mint.HTTP.connect(String.to_existing_atom(scheme), host, port), + transport_opts = bound_interface_transport_opts() + + with {:ok, conn} <- Mint.HTTP.connect(String.to_existing_atom(scheme), host, port, transport_opts: transport_opts), {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "GET", path, request_headers, nil) do {:ok, %Downloader{ @@ -657,4 +660,15 @@ defmodule Peridiod.Binary.Downloader do defp add_user_agent_header(headers, _), do: [{"User-Agent", "Peridiod/#{Application.spec(:peridiod)[:vsn]}"} | headers] + + defp bound_interface_transport_opts do + case Process.whereis(NetworkMonitor) do + nil -> [] + _pid -> + case NetworkMonitor.get_bound_interface() do + nil -> [] + ifname -> [bind_to_device: ifname] + end + end + end end From 065348f5d58e292da23a9339048a25de69ef557e Mon Sep 17 00:00:00 2001 From: "Jason T. Wong" Date: Mon, 18 May 2026 20:22:19 -0400 Subject: [PATCH 2/3] Add tests for interface binding in Binary.Downloader Two tests covering the bound_interface_transport_opts/0 logic: - returns empty opts when NetworkMonitor has no bound interface (default test environment state) - returns [bind_to_device: ifname] when an interface is bound, verified by temporarily updating NetworkMonitor state via :sys.replace_state --- lib/peridiod/binary/downloader.ex | 12 +++++++++--- test/peridiod/binary/downloader_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/peridiod/binary/downloader.ex b/lib/peridiod/binary/downloader.ex index 98cc570..b5a99db 100644 --- a/lib/peridiod/binary/downloader.ex +++ b/lib/peridiod/binary/downloader.ex @@ -615,7 +615,10 @@ defmodule Peridiod.Binary.Downloader do transport_opts = bound_interface_transport_opts() - with {:ok, conn} <- Mint.HTTP.connect(String.to_existing_atom(scheme), host, port, transport_opts: transport_opts), + with {:ok, conn} <- + Mint.HTTP.connect(String.to_existing_atom(scheme), host, port, + transport_opts: transport_opts + ), {:ok, conn, request_ref} <- Mint.HTTP.request(conn, "GET", path, request_headers, nil) do {:ok, %Downloader{ @@ -661,9 +664,12 @@ defmodule Peridiod.Binary.Downloader do defp add_user_agent_header(headers, _), do: [{"User-Agent", "Peridiod/#{Application.spec(:peridiod)[:vsn]}"} | headers] - defp bound_interface_transport_opts do + @doc false + def bound_interface_transport_opts do case Process.whereis(NetworkMonitor) do - nil -> [] + nil -> + [] + _pid -> case NetworkMonitor.get_bound_interface() do nil -> [] diff --git a/test/peridiod/binary/downloader_test.exs b/test/peridiod/binary/downloader_test.exs index 0048753..46074c0 100644 --- a/test/peridiod/binary/downloader_test.exs +++ b/test/peridiod/binary/downloader_test.exs @@ -3,6 +3,7 @@ defmodule Peridiod.Binary.DownloaderTest do alias Peridiod.Binary.Downloader alias Peridiod.Binary.Downloader.RetryConfig alias Peridiod.Binary.Downloader.VerifyConfig + alias Peridiod.Cloud.NetworkMonitor # SHA-256 of test/fixtures/binaries/1M.bin @bin_1m_hash Base.decode16!("a073ad730e540107fbb92ee48baab97c9bc16105333a42b15a53bcc183f6f5c2", @@ -280,4 +281,26 @@ defmodule Peridiod.Binary.DownloaderTest do assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 2000 end end + + describe "interface binding" do + test "returns empty transport opts when no interface is bound" do + # NetworkMonitor starts with bound_interface: nil in the test environment + assert NetworkMonitor.get_bound_interface() == nil + assert Downloader.bound_interface_transport_opts() == [] + end + + test "returns bind_to_device transport opt for the currently bound interface" do + original = :sys.get_state(NetworkMonitor) + + on_exit(fn -> + :sys.replace_state(NetworkMonitor, fn _ -> original end) + end) + + :sys.replace_state(NetworkMonitor, fn state -> + %{state | bound_interface: {"eth0", %NetworkMonitor.InterfaceInfo{}}} + end) + + assert Downloader.bound_interface_transport_opts() == [bind_to_device: "eth0"] + end + end end From 72b4b4f6eff7d767426e6976d35817adcb66c117 Mon Sep 17 00:00:00 2001 From: "Jason T. Wong" Date: Mon, 18 May 2026 20:29:08 -0400 Subject: [PATCH 3/3] Fix TOCTOU race in bound_interface_transport_opts Replace Process.whereis + GenServer.call with a try/catch :exit so the downloader returns [] gracefully if NetworkMonitor dies or restarts between the existence check and the call. Adds a test that kills the process mid-flight to verify the exit-safety path. --- lib/peridiod/binary/downloader.ex | 16 +++++++--------- test/peridiod/binary/downloader_test.exs | 9 +++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/peridiod/binary/downloader.ex b/lib/peridiod/binary/downloader.ex index b5a99db..9e6707f 100644 --- a/lib/peridiod/binary/downloader.ex +++ b/lib/peridiod/binary/downloader.ex @@ -666,15 +666,13 @@ defmodule Peridiod.Binary.Downloader do @doc false def bound_interface_transport_opts do - case Process.whereis(NetworkMonitor) do - nil -> - [] - - _pid -> - case NetworkMonitor.get_bound_interface() do - nil -> [] - ifname -> [bind_to_device: ifname] - end + try do + case NetworkMonitor.get_bound_interface() do + nil -> [] + ifname -> [bind_to_device: ifname] + end + catch + :exit, _ -> [] end end end diff --git a/test/peridiod/binary/downloader_test.exs b/test/peridiod/binary/downloader_test.exs index 46074c0..5ad2166 100644 --- a/test/peridiod/binary/downloader_test.exs +++ b/test/peridiod/binary/downloader_test.exs @@ -302,5 +302,14 @@ defmodule Peridiod.Binary.DownloaderTest do assert Downloader.bound_interface_transport_opts() == [bind_to_device: "eth0"] end + + test "returns empty transport opts when NetworkMonitor is not running" do + pid = Process.whereis(NetworkMonitor) + ref = Process.monitor(pid) + Process.exit(pid, :kill) + assert_receive {:DOWN, ^ref, :process, ^pid, :killed} + + assert Downloader.bound_interface_transport_opts() == [] + end end end