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)
246 lines
7.5 KiB
Elixir
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
|