|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | + |
| 3 | +defmodule Vordr.Temporal.PortSwarm do |
| 4 | + @moduledoc """ |
| 5 | + BEAM Port Interceptor Swarm |
| 6 | +
|
| 7 | + Spawns one lightweight BEAM process per port (up to 65,535). |
| 8 | + Each process listens on a single port and logs all connection attempts. |
| 9 | + Pre-spawned before the target process starts, so the target sees |
| 10 | + services already listening — indistinguishable from normal deployment. |
| 11 | +
|
| 12 | + ## Why BEAM? |
| 13 | +
|
| 14 | + BEAM processes are ~300 bytes each. 65,535 interceptors consume |
| 15 | + approximately 20MB of memory — negligible for a monitoring system. |
| 16 | + BEAM spawns processes in microseconds, so the entire swarm is ready |
| 17 | + before the target code executes its first instruction. |
| 18 | +
|
| 19 | + ## Detection resistance |
| 20 | +
|
| 21 | + In any real deployment, most ports have something listening |
| 22 | + (web servers, databases, message brokers, health checks). |
| 23 | + Port interceptors look like normal services — they are |
| 24 | + environmental noise, not monitoring artefacts. |
| 25 | +
|
| 26 | + ## Integration |
| 27 | +
|
| 28 | + Port swarm events feed into the eBPF event consumer via |
| 29 | + the selur IPC bridge. The Rust eBPF monitor receives |
| 30 | + `NetworkEvent` structs for correlation with syscall events. |
| 31 | + """ |
| 32 | + |
| 33 | + use GenServer |
| 34 | + |
| 35 | + require Logger |
| 36 | + |
| 37 | + @default_port_range 1..65_535 |
| 38 | + @listen_backlog 1 |
| 39 | + |
| 40 | + # ── Types ────────────────────────────────────────────── |
| 41 | + |
| 42 | + @type interceptor_id :: pos_integer() |
| 43 | + @type connection_log :: %{ |
| 44 | + port: pos_integer(), |
| 45 | + source_ip: String.t(), |
| 46 | + source_port: pos_integer(), |
| 47 | + timestamp: DateTime.t(), |
| 48 | + data_preview: binary() |
| 49 | + } |
| 50 | + |
| 51 | + # ── Public API ───────────────────────────────────────── |
| 52 | + |
| 53 | + @doc """ |
| 54 | + Start the port swarm supervisor. |
| 55 | +
|
| 56 | + ## Options |
| 57 | +
|
| 58 | + - `:port_range` — Range of ports to intercept (default: 1..65_535) |
| 59 | + - `:exclude_ports` — Ports to skip (e.g. SSH on 22, or the BEAM node port) |
| 60 | + - `:collector_pid` — PID to receive connection events |
| 61 | + - `:log_data` — Whether to capture initial bytes of data (default: false) |
| 62 | + """ |
| 63 | + @spec start_link(keyword()) :: GenServer.on_start() |
| 64 | + def start_link(opts \\ []) do |
| 65 | + GenServer.start_link(__MODULE__, opts, name: __MODULE__) |
| 66 | + end |
| 67 | + |
| 68 | + @doc """ |
| 69 | + Spawn interceptors across the port range. |
| 70 | +
|
| 71 | + Returns the count of successfully spawned interceptors. |
| 72 | + Ports that are already in use are silently skipped (they |
| 73 | + already have a legitimate service — no need to intercept). |
| 74 | + """ |
| 75 | + @spec spawn_swarm() :: {:ok, non_neg_integer()} |
| 76 | + def spawn_swarm do |
| 77 | + GenServer.call(__MODULE__, :spawn_swarm, :infinity) |
| 78 | + end |
| 79 | + |
| 80 | + @doc """ |
| 81 | + Stop all interceptors and release ports. |
| 82 | + """ |
| 83 | + @spec stop_swarm() :: :ok |
| 84 | + def stop_swarm do |
| 85 | + GenServer.call(__MODULE__, :stop_swarm) |
| 86 | + end |
| 87 | + |
| 88 | + @doc """ |
| 89 | + Get all connection events logged so far. |
| 90 | + """ |
| 91 | + @spec get_connections() :: [connection_log()] |
| 92 | + def get_connections do |
| 93 | + GenServer.call(__MODULE__, :get_connections) |
| 94 | + end |
| 95 | + |
| 96 | + @doc """ |
| 97 | + Get count of active interceptors. |
| 98 | + """ |
| 99 | + @spec active_count() :: non_neg_integer() |
| 100 | + def active_count do |
| 101 | + GenServer.call(__MODULE__, :active_count) |
| 102 | + end |
| 103 | + |
| 104 | + @doc """ |
| 105 | + Get connection count per port (only ports that received connections). |
| 106 | + """ |
| 107 | + @spec connection_summary() :: %{pos_integer() => non_neg_integer()} |
| 108 | + def connection_summary do |
| 109 | + GenServer.call(__MODULE__, :connection_summary) |
| 110 | + end |
| 111 | + |
| 112 | + # ── GenServer callbacks ──────────────────────────────── |
| 113 | + |
| 114 | + @impl true |
| 115 | + def init(opts) do |
| 116 | + port_range = Keyword.get(opts, :port_range, @default_port_range) |
| 117 | + exclude_ports = Keyword.get(opts, :exclude_ports, []) |
| 118 | + collector_pid = Keyword.get(opts, :collector_pid, self()) |
| 119 | + log_data = Keyword.get(opts, :log_data, false) |
| 120 | + |
| 121 | + state = %{ |
| 122 | + port_range: port_range, |
| 123 | + exclude_ports: MapSet.new(exclude_ports), |
| 124 | + collector_pid: collector_pid, |
| 125 | + log_data: log_data, |
| 126 | + interceptors: %{}, |
| 127 | + connections: [], |
| 128 | + spawned_count: 0 |
| 129 | + } |
| 130 | + |
| 131 | + {:ok, state} |
| 132 | + end |
| 133 | + |
| 134 | + @impl true |
| 135 | + def handle_call(:spawn_swarm, _from, state) do |
| 136 | + Logger.info("Spawning port interceptor swarm across #{inspect(state.port_range)}") |
| 137 | + |
| 138 | + {interceptors, count} = spawn_interceptors(state) |
| 139 | + |
| 140 | + Logger.info("Swarm ready: #{count} interceptors active (#{map_size(state.exclude_ports)} ports excluded)") |
| 141 | + |
| 142 | + new_state = %{state | interceptors: interceptors, spawned_count: count} |
| 143 | + {:reply, {:ok, count}, new_state} |
| 144 | + end |
| 145 | + |
| 146 | + @impl true |
| 147 | + def handle_call(:stop_swarm, _from, state) do |
| 148 | + Logger.info("Stopping port interceptor swarm (#{map_size(state.interceptors)} active)") |
| 149 | + |
| 150 | + Enum.each(state.interceptors, fn {_port, {socket, _pid}} -> |
| 151 | + :gen_tcp.close(socket) |
| 152 | + end) |
| 153 | + |
| 154 | + {:reply, :ok, %{state | interceptors: %{}, spawned_count: 0}} |
| 155 | + end |
| 156 | + |
| 157 | + @impl true |
| 158 | + def handle_call(:get_connections, _from, state) do |
| 159 | + {:reply, Enum.reverse(state.connections), state} |
| 160 | + end |
| 161 | + |
| 162 | + @impl true |
| 163 | + def handle_call(:active_count, _from, state) do |
| 164 | + {:reply, map_size(state.interceptors), state} |
| 165 | + end |
| 166 | + |
| 167 | + @impl true |
| 168 | + def handle_call(:connection_summary, _from, state) do |
| 169 | + summary = |
| 170 | + state.connections |
| 171 | + |> Enum.group_by(& &1.port) |
| 172 | + |> Enum.map(fn {port, conns} -> {port, length(conns)} end) |
| 173 | + |> Map.new() |
| 174 | + |
| 175 | + {:reply, summary, state} |
| 176 | + end |
| 177 | + |
| 178 | + @impl true |
| 179 | + def handle_info({:connection, port, source_ip, source_port, data_preview}, state) do |
| 180 | + log_entry = %{ |
| 181 | + port: port, |
| 182 | + source_ip: source_ip, |
| 183 | + source_port: source_port, |
| 184 | + timestamp: DateTime.utc_now(), |
| 185 | + data_preview: data_preview |
| 186 | + } |
| 187 | + |
| 188 | + Logger.debug("Connection intercepted on port #{port} from #{source_ip}:#{source_port}") |
| 189 | + |
| 190 | + # Forward to collector (e.g. eBPF event consumer) |
| 191 | + if state.collector_pid != self() do |
| 192 | + send(state.collector_pid, {:intercepted_connection, log_entry}) |
| 193 | + end |
| 194 | + |
| 195 | + {:noreply, %{state | connections: [log_entry | state.connections]}} |
| 196 | + end |
| 197 | + |
| 198 | + @impl true |
| 199 | + def handle_info(_msg, state) do |
| 200 | + {:noreply, state} |
| 201 | + end |
| 202 | + |
| 203 | + # ── Private ──────────────────────────────────────────── |
| 204 | + |
| 205 | + defp spawn_interceptors(state) do |
| 206 | + state.port_range |
| 207 | + |> Enum.reject(&MapSet.member?(state.exclude_ports, &1)) |
| 208 | + |> Enum.reduce({%{}, 0}, fn port, {interceptors, count} -> |
| 209 | + case listen_on_port(port, state) do |
| 210 | + {:ok, socket, acceptor_pid} -> |
| 211 | + {Map.put(interceptors, port, {socket, acceptor_pid}), count + 1} |
| 212 | + |
| 213 | + {:error, :eaddrinuse} -> |
| 214 | + # Port already in use — legitimate service, skip silently |
| 215 | + {interceptors, count} |
| 216 | + |
| 217 | + {:error, :eacces} -> |
| 218 | + # Permission denied (ports < 1024 need root), skip |
| 219 | + {interceptors, count} |
| 220 | + |
| 221 | + {:error, reason} -> |
| 222 | + Logger.debug("Could not bind port #{port}: #{inspect(reason)}") |
| 223 | + {interceptors, count} |
| 224 | + end |
| 225 | + end) |
| 226 | + end |
| 227 | + |
| 228 | + defp listen_on_port(port, state) do |
| 229 | + opts = [ |
| 230 | + :binary, |
| 231 | + active: false, |
| 232 | + reuseaddr: true, |
| 233 | + backlog: @listen_backlog |
| 234 | + ] |
| 235 | + |
| 236 | + case :gen_tcp.listen(port, opts) do |
| 237 | + {:ok, socket} -> |
| 238 | + # Spawn acceptor process for this port |
| 239 | + parent = self() |
| 240 | + log_data = state.log_data |
| 241 | + |
| 242 | + acceptor_pid = |
| 243 | + spawn_link(fn -> |
| 244 | + accept_loop(socket, port, parent, log_data) |
| 245 | + end) |
| 246 | + |
| 247 | + {:ok, socket, acceptor_pid} |
| 248 | + |
| 249 | + {:error, reason} -> |
| 250 | + {:error, reason} |
| 251 | + end |
| 252 | + end |
| 253 | + |
| 254 | + defp accept_loop(listen_socket, port, parent, log_data) do |
| 255 | + case :gen_tcp.accept(listen_socket, 5000) do |
| 256 | + {:ok, client_socket} -> |
| 257 | + # Get peer info |
| 258 | + {source_ip, source_port} = |
| 259 | + case :inet.peername(client_socket) do |
| 260 | + {:ok, {ip, p}} -> {format_ip(ip), p} |
| 261 | + _ -> {"unknown", 0} |
| 262 | + end |
| 263 | + |
| 264 | + # Optionally capture initial data |
| 265 | + data_preview = |
| 266 | + if log_data do |
| 267 | + case :gen_tcp.recv(client_socket, 0, 1000) do |
| 268 | + {:ok, data} -> data |
| 269 | + _ -> <<>> |
| 270 | + end |
| 271 | + else |
| 272 | + <<>> |
| 273 | + end |
| 274 | + |
| 275 | + # Close the connection (we only needed to observe it) |
| 276 | + :gen_tcp.close(client_socket) |
| 277 | + |
| 278 | + # Report to parent |
| 279 | + send(parent, {:connection, port, source_ip, source_port, data_preview}) |
| 280 | + |
| 281 | + # Continue accepting |
| 282 | + accept_loop(listen_socket, port, parent, log_data) |
| 283 | + |
| 284 | + {:error, :timeout} -> |
| 285 | + # No connection within timeout, loop back |
| 286 | + accept_loop(listen_socket, port, parent, log_data) |
| 287 | + |
| 288 | + {:error, :closed} -> |
| 289 | + # Socket closed, swarm is stopping |
| 290 | + :ok |
| 291 | + |
| 292 | + {:error, _reason} -> |
| 293 | + # Other error, stop this acceptor |
| 294 | + :ok |
| 295 | + end |
| 296 | + end |
| 297 | + |
| 298 | + defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" |
| 299 | + |
| 300 | + defp format_ip({a, b, c, d, e, f, g, h}), |
| 301 | + do: "#{Integer.to_string(a, 16)}:#{Integer.to_string(b, 16)}:#{Integer.to_string(c, 16)}:#{Integer.to_string(d, 16)}:#{Integer.to_string(e, 16)}:#{Integer.to_string(f, 16)}:#{Integer.to_string(g, 16)}:#{Integer.to_string(h, 16)}" |
| 302 | +end |
0 commit comments