prop/lib/microwaveprop/commercial/snmp_client.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

246 lines
7.5 KiB
Elixir

defmodule Microwaveprop.Commercial.SnmpClient do
@moduledoc false
require Logger
# UBNT-AirFIBER-MIB OIDs (base 1.3.6.1.4.1.41112.1.3)
@af_base "1.3.6.1.4.1.41112.1.3"
@af11x_oids %{
"#{@af_base}.2.1.11.1" => :rx_power_0,
"#{@af_base}.2.1.14.1" => :rx_power_1,
"#{@af_base}.1.1.9.1" => :tx_power,
"#{@af_base}.2.1.2.1" => :cur_tx_mod_rate,
"#{@af_base}.2.1.18.1" => :remote_tx_mod_rate,
"#{@af_base}.2.1.5.1" => :rx_capacity,
"#{@af_base}.2.1.6.1" => :tx_capacity,
"#{@af_base}.2.1.17.1" => :remote_tx_power,
"#{@af_base}.2.1.19.1" => :remote_rx_power_0,
"#{@af_base}.2.1.22.1" => :remote_rx_power_1,
"#{@af_base}.2.1.26.1" => :link_state,
"#{@af_base}.2.1.44.1" => :link_uptime,
"#{@af_base}.2.1.8.1" => :radio_temp_0_c,
"#{@af_base}.2.1.10.1" => :radio_temp_1_c,
"#{@af_base}.1.1.5.1" => :tx_freq_mhz,
"#{@af_base}.1.1.6.1" => :rx_freq_mhz,
"#{@af_base}.2.1.4.1" => :link_distance_m
}
# AF60-LR static OIDs
@af60_base "1.3.6.1.4.1.41112.1.11.1"
@af60_static_oids %{
"#{@af60_base}.2.5.1" => :radio_temp_0_c,
"#{@af60_base}.2.6.1" => :link_state,
"#{@af60_base}.1.2.1" => :tx_freq_mhz
}
# AF60 station table column -> field name + unit conversion
@af60_station_cols %{
3 => {:rx_power_0, :raw},
4 => {:tx_power, :raw},
5 => {:cur_tx_mod_rate, :raw},
6 => {:remote_tx_mod_rate, :raw},
7 => {:tx_capacity, :kbps_to_mbps},
8 => {:rx_capacity, :kbps_to_mbps},
15 => {:link_distance_m, :raw},
17 => {:link_uptime, :centiseconds_to_seconds},
18 => {:remote_rx_power_0, :raw},
19 => {:remote_tx_power, :raw}
}
@spec poll(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll(host, community, "af11x"), do: poll_af11x(host, community)
def poll(host, community, "af60"), do: poll_af60(host, community)
@spec poll_af11x(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af11x(host, community) do
with :ok <- validate_host(host),
:ok <- validate_community(community) do
do_poll_af11x(host, community)
end
end
defp do_poll_af11x(host, community) do
oids = Map.keys(@af11x_oids)
args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | oids]
case run_cmd("snmpget", args) do
{output, 0} ->
result = parse_snmpget_output(output, :af11x)
if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}
{output, _exit_code} ->
Logger.warning("snmpget failed for #{host}: #{String.trim(output)}")
{:error, :snmp_failed}
end
end
@spec poll_af60(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af60(host, community) do
with :ok <- validate_host(host),
:ok <- validate_community(community) do
do_poll_af60(host, community)
end
end
defp do_poll_af60(host, community) do
static_oids = Map.keys(@af60_static_oids)
static_args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | static_oids]
station_oids = Enum.map(Map.keys(@af60_station_cols), &"#{@af60_base}.3.1.#{&1}")
station_args = ["-v1", "-c", community, "-t", "5", "-r", "1", host | station_oids]
with {static_out, 0} <- run_cmd("snmpget", static_args),
{station_out, 0} <- run_cmd("snmpgetnext", station_args) do
result = parse_af60_output(static_out, station_out)
if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}
else
{output, _} ->
Logger.warning("snmp failed for AF60 #{host}: #{String.trim(output)}")
{:error, :snmp_failed}
end
end
@spec parse_snmpget_output(String.t(), :af11x) :: map()
def parse_snmpget_output(output, :af11x) do
output
|> parse_lines()
|> Enum.reduce(%{}, fn {oid, value}, acc ->
case find_af11x_field(oid) do
nil -> acc
field -> Map.put(acc, field, value)
end
end)
end
@spec parse_af60_output(String.t(), String.t()) :: map()
def parse_af60_output(static_output, station_output) do
static =
static_output
|> parse_lines()
|> Enum.reduce(%{}, fn {oid, value}, acc ->
case find_af60_static_field(oid) do
nil -> acc
field -> Map.put(acc, field, value)
end
end)
station =
station_output
|> parse_lines()
|> Enum.reduce(%{}, fn {oid, value}, acc ->
case find_af60_station_field(oid) do
nil -> acc
{field, conversion} -> Map.put(acc, field, convert_value(value, conversion))
end
end)
Map.merge(static, station)
end
# --- Private ---
# Reject community strings that start with "-" (prevents flag injection
# via System.cmd argv) and strings with non-printable characters.
defp validate_community(community) do
if String.starts_with?(community, "-") or
not String.valid?(community) or
community =~ ~r/[^[:print:]]/u do
{:error, :invalid_community}
else
:ok
end
end
# Reject host values that aren't a valid IP address or hostname.
# Prevents SSRF to unexpected destinations via crafted DB values.
@valid_host_pattern ~r/^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/
@valid_ip_pattern ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
defp validate_host(host) do
if host =~ @valid_host_pattern or host =~ @valid_ip_pattern do
:ok
else
{:error, :invalid_host}
end
end
defp parse_lines(output) do
output
|> String.split("\n", trim: true)
|> Enum.flat_map(fn line ->
case parse_snmp_line(line) do
{_oid, _value} = pair -> [pair]
nil -> []
end
end)
end
defp run_cmd(cmd, args) do
runner = Application.get_env(:microwaveprop, :snmp_runner, &default_run_cmd/2)
runner.(cmd, args)
end
defp default_run_cmd(cmd, args) do
System.cmd(cmd, args, env: %{}, stderr_to_stdout: true)
rescue
ErlangError -> {"command not found: #{cmd}", 127}
end
defp parse_snmp_line(line) do
line = String.trim(line)
with [oid_part, value_part] <- String.split(line, " = ", parts: 2),
false <- String.contains?(value_part, "No Such"),
oid = oid_part |> String.trim() |> normalize_oid(),
value when not is_nil(value) <- parse_snmp_value(value_part) do
{oid, value}
else
_ -> nil
end
end
defp normalize_oid(oid) do
oid
|> String.replace(~r/^SNMPv2-SMI::enterprises\./, "1.3.6.1.4.1.")
|> String.replace(~r/^iso\./, "1.")
|> String.replace(~r/^\./, "")
end
defp parse_snmp_value(value_str) do
case Regex.run(~r/:\s*(.+)$/, String.trim(value_str)) do
[_, raw] ->
raw = String.trim(raw, "\"")
case Integer.parse(raw) do
{val, _} -> val
:error -> nil
end
nil ->
nil
end
end
defp find_af11x_field(oid) do
Enum.find_value(@af11x_oids, fn {known_oid, field} ->
if oid == known_oid, do: field
end)
end
defp find_af60_static_field(oid) do
Enum.find_value(@af60_static_oids, fn {known_oid, field} ->
if oid == known_oid, do: field
end)
end
defp find_af60_station_field(oid) do
Enum.find_value(@af60_station_cols, fn {col, field_and_conv} ->
prefix = "#{@af60_base}.3.1.#{col}."
if String.starts_with?(oid, prefix), do: field_and_conv
end)
end
defp convert_value(value, :raw), do: value
defp convert_value(value, :kbps_to_mbps), do: div(value, 1000)
defp convert_value(value, :centiseconds_to_seconds), do: div(value, 100)
end