neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Let interfaces be taken out of the sweep
7a73ed3 · Gabriel Morell · 2026-09-17 00:26
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/lambent/app/data/LambentSocket.kt
+14
−0
@@ -42,6 +42,10 @@ object LambentSocket {
| 42 | 42 | private val _services = MutableStateFlow<Map<String, List<Service>>>(emptyMap()) |
| 43 | 43 | val services: StateFlow<Map<String, List<Service>>> = _services.asStateFlow() |
| 44 | 44 | |
| 45 | + /** which interfaces the sweep runs on */ | |
| 46 | + private val _interfaces = MutableStateFlow<List<Interface>>(emptyList()) | |
| 47 | + val interfaces: StateFlow<List<Interface>> = _interfaces.asStateFlow() | |
| 48 | + | |
| 45 | 49 | /** machine name to its preview strip, 32 pixels of 0xRRGGBB */ |
| 46 | 50 | private val _previews = MutableStateFlow<Map<String, List<Int>>>(emptyMap()) |
| 47 | 51 | val previews: StateFlow<Map<String, List<Int>>> = _previews.asStateFlow() |
@@ -124,6 +128,7 @@ object LambentSocket {
| 124 | 128 | _placeClamps.value = emptyMap() |
| 125 | 129 | _globalClamp.value = Clamp(null, Clamp.FULL, Clamp.FULL, 0) |
| 126 | 130 | _machineTypes.value = emptyList() |
| 131 | + _interfaces.value = emptyList() | |
| 127 | 132 | } |
| 128 | 133 | |
| 129 | 134 | // ----- joins ----- |
@@ -141,6 +146,12 @@ object LambentSocket {
| 141 | 146 | } |
| 142 | 147 | } |
| 143 | 148 | |
| 149 | + ch.on("interfaces") { message -> | |
| 150 | + _interfaces.value = message.payload["interfaces"].asList().map { | |
| 151 | + Interface.from(it.asMap()) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 144 | 155 | ch.on("probe_result") { message -> |
| 145 | 156 | val ip = message.payload["ip"].asStr() ?: "?" |
| 146 | 157 | val notice = when (message.payload["status"].asStr()) { |
@@ -273,6 +284,9 @@ object LambentSocket {
| 273 | 284 | fun poke(mac: String, mode: String = "auto") = push("devices", "poke", mapOf("id" to mac, "mode" to mode)) |
| 274 | 285 | fun probe(ip: String) = push("devices", "probe", mapOf("ip" to ip)) |
| 275 | 286 | |
| 287 | + fun sweepInterface(name: String, enabled: Boolean) = | |
| 288 | + push("devices", "interface", mapOf("name" to name, "enabled" to enabled)) | |
| 289 | + | |
| 276 | 290 | // machines |
| 277 | 291 | fun machineCommand(cmd: String, name: String) = push("machines", cmd, mapOf("id" to name)) |
| 278 | 292 | fun libraryAdd(cls: String, entry: String) = |
modified
android/app/src/main/java/org/neiam/lambent/app/data/Models.kt
+15
−0
@@ -99,6 +99,21 @@ data class Link(
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | +/** | |
| 103 | + * A network interface the sweep can run on. The scanner filters out the | |
| 104 | + * obvious ones (loopback, docker, tunnels) before this ever reaches us; these | |
| 105 | + * are the ones left, which can still be told to stay out of the sweep. | |
| 106 | + */ | |
| 107 | +data class Interface(val name: String, val addr: String?, val enabled: Boolean) { | |
| 108 | + companion object { | |
| 109 | + fun from(raw: Map<String, Any?>) = Interface( | |
| 110 | + name = raw["name"].asStr() ?: "", | |
| 111 | + addr = raw["addr"].asStr(), | |
| 112 | + enabled = raw["enabled"] == true, | |
| 113 | + ) | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 102 | 117 | /** |
| 103 | 118 | * A brightness clamp: where it is, where it is headed, and how long the walk |
| 104 | 119 | * between the two takes. The global clamp has no place. |
modified
android/app/src/main/java/org/neiam/lambent/app/ui/DevicesScreen.kt
+32
−0
@@ -1,6 +1,8 @@
| 1 | 1 | package org.neiam.lambent.app.ui |
| 2 | 2 | |
| 3 | 3 | import androidx.compose.foundation.layout.Arrangement |
| 4 | +import androidx.compose.foundation.layout.ExperimentalLayoutApi | |
| 5 | +import androidx.compose.foundation.layout.FlowRow | |
| 4 | 6 | import androidx.compose.foundation.layout.Column |
| 5 | 7 | import androidx.compose.foundation.layout.Row |
| 6 | 8 | import androidx.compose.foundation.layout.Spacer |
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.Search
| 15 | 17 | import androidx.compose.material.icons.filled.WbIridescent |
| 16 | 18 | import androidx.compose.material3.AssistChip |
| 17 | 19 | import androidx.compose.material3.Card |
| 20 | +import androidx.compose.material3.Checkbox | |
| 18 | 21 | import androidx.compose.material3.DropdownMenu |
| 19 | 22 | import androidx.compose.material3.DropdownMenuItem |
| 20 | 23 | import androidx.compose.material3.FilledTonalIconButton |
@@ -34,12 +37,14 @@ import androidx.compose.ui.Modifier
| 34 | 37 | import androidx.compose.ui.unit.dp |
| 35 | 38 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
| 36 | 39 | import org.neiam.lambent.app.data.Device |
| 40 | +import org.neiam.lambent.app.data.Interface | |
| 37 | 41 | import org.neiam.lambent.app.data.LambentSocket |
| 38 | 42 | import org.neiam.lambent.app.data.ORDERS |
| 39 | 43 | |
| 40 | 44 | @Composable |
| 41 | 45 | fun DevicesScreen() { |
| 42 | 46 | val devices by LambentSocket.devices.collectAsStateWithLifecycle() |
| 47 | + val interfaces by LambentSocket.interfaces.collectAsStateWithLifecycle() | |
| 43 | 48 | var probeIp by remember { mutableStateOf("") } |
| 44 | 49 | |
| 45 | 50 | LazyColumn( |
@@ -71,6 +76,10 @@ fun DevicesScreen() {
| 71 | 76 | } |
| 72 | 77 | } |
| 73 | 78 | |
| 79 | + if (interfaces.isNotEmpty()) { | |
| 80 | + item { SweepInterfaces(interfaces) } | |
| 81 | + } | |
| 82 | + | |
| 74 | 83 | if (devices.isEmpty()) { |
| 75 | 84 | item { Text("No devices detected, yet.", style = MaterialTheme.typography.bodyMedium) } |
| 76 | 85 | } |
@@ -81,6 +90,29 @@ fun DevicesScreen() {
| 81 | 90 | } |
| 82 | 91 | } |
| 83 | 92 | |
| 93 | +/** which interfaces the sweep runs on, beyond the ones filtered out by name */ | |
| 94 | +@OptIn(ExperimentalLayoutApi::class) | |
| 95 | +@Composable | |
| 96 | +private fun SweepInterfaces(interfaces: List<Interface>) { | |
| 97 | + Column { | |
| 98 | + Text("Sweep", style = MaterialTheme.typography.labelMedium) | |
| 99 | + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 100 | + interfaces.forEach { iface -> | |
| 101 | + Row(verticalAlignment = Alignment.CenterVertically) { | |
| 102 | + Checkbox( | |
| 103 | + checked = iface.enabled, | |
| 104 | + onCheckedChange = { LambentSocket.sweepInterface(iface.name, it) }, | |
| 105 | + ) | |
| 106 | + Text( | |
| 107 | + listOfNotNull(iface.name, iface.addr).joinToString(" "), | |
| 108 | + style = MaterialTheme.typography.labelSmall, | |
| 109 | + ) | |
| 110 | + } | |
| 111 | + } | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 84 | 116 | @Composable |
| 85 | 117 | private fun DeviceCard(device: Device) { |
| 86 | 118 | var editing by remember { mutableStateOf<String?>(null) } |
modified
lib/lambent_ex/meta.ex
+16
−0
@@ -87,6 +87,22 @@ defmodule LambentEx.Meta do
| 87 | 87 | :dets.insert(@table, {"place-#{key}", value}) |
| 88 | 88 | end |
| 89 | 89 | |
| 90 | + # interfaces the sweep has been told to leave alone, beyond the prefixes the | |
| 91 | + # scanner filters out on its own | |
| 92 | + def get_skipped_ifaces do | |
| 93 | + case :dets.lookup(@table, "skip-ifaces") do | |
| 94 | + [] -> | |
| 95 | + [] | |
| 96 | + | |
| 97 | + [{_key, value}] -> | |
| 98 | + value | |
| 99 | + end | |
| 100 | + end | |
| 101 | + | |
| 102 | + def put_skipped_ifaces(value) do | |
| 103 | + :dets.insert(@table, {"skip-ifaces", value}) | |
| 104 | + end | |
| 105 | + | |
| 90 | 106 | # brightness clamps. one row, since it is a single small map and the walk |
| 91 | 107 | # targets change together often enough that separate keys buy nothing |
| 92 | 108 | def get_brightness do |
modified
lib/lambent_ex/scan/nmap.ex
+132
−27
@@ -17,7 +17,7 @@ defmodule LambentEx.Scan.NMAP.Runner do
| 17 | 17 | def handle_info({pid, :data, :out, data}, state) do |
| 18 | 18 | previous = state.previous |
| 19 | 19 | |
| 20 | −# IO.inspect("Received data: #{data}") | |
| 20 | + # IO.inspect("Received data: #{data}") | |
| 21 | 21 | |
| 22 | 22 | lines = data |> String.trim_trailing("\n") |> String.split("\n") |
| 23 | 23 | f = lines |> List.first() |
@@ -60,11 +60,12 @@ defmodule LambentEx.Scan.NMAP.Runner do
| 60 | 60 | %{latency: latency, seen: DateTime.utc_now()} |
| 61 | 61 | } |
| 62 | 62 | end) |
| 63 | − |> Map.new | |
| 64 | −# |> IO.inspect | |
| 63 | + |> Map.new() | |
| 65 | 64 | |
| 66 | − curr = state.results | |
| 67 | − updated = Map.merge(curr, new_struct) | |
| 65 | + # |> IO.inspect | |
| 66 | + | |
| 67 | + curr = state.results | |
| 68 | + updated = Map.merge(curr, new_struct) | |
| 68 | 69 | |
| 69 | 70 | {:noreply, %{state | previous: leftover, results: updated}} |
| 70 | 71 | end |
@@ -119,6 +120,9 @@ defmodule LambentEx.Scan.NMAP do
| 119 | 120 | @s "ꙭ📡" |
| 120 | 121 | @filtered_prefixes ["br", "veth", "docker", "lo", "cni", "flannel", "tun", "tailscale"] |
| 121 | 122 | @pubsub_name LambentEx.PubSub |
| 123 | + @pubsub_ifaces "scan-ifaces" | |
| 124 | + | |
| 125 | + alias LambentEx.Meta, as: M | |
| 122 | 126 | |
| 123 | 127 | def start_link(opts) do |
| 124 | 128 | Parent.GenServer.start_link(__MODULE__, opts, name: via_tuple("nmap")) |
@@ -128,47 +132,148 @@ defmodule LambentEx.Scan.NMAP do
| 128 | 132 | via_tuple("nmap") |> GenServer.call(:retrieve) |
| 129 | 133 | end |
| 130 | 134 | |
| 135 | + @doc """ | |
| 136 | + Every interface worth offering, with its address and whether the sweep is | |
| 137 | + running on it. The prefixes filtered out at startup never appear here — this | |
| 138 | + is for saying no to the ones that are left. | |
| 139 | + """ | |
| 140 | + def interfaces() do | |
| 141 | + via_tuple("nmap") |> GenServer.call(:interfaces) | |
| 142 | + end | |
| 143 | + | |
| 144 | + @doc "Start or stop sweeping one interface. Remembered across restarts." | |
| 145 | + def set_enabled(iface, enabled) when is_boolean(enabled) do | |
| 146 | + via_tuple("nmap") |> GenServer.cast({:set_enabled, Kernel.to_string(iface), enabled}) | |
| 147 | + end | |
| 148 | + | |
| 149 | + @impl true | |
| 131 | 150 | def init(_opts) do |
| 132 | − # Process.send_after(self(), :scan, :timer.seconds(3)) | |
| 133 | 151 | interfaces = get_valid_interfaces() |
| 152 | + skipped = M.get_skipped_ifaces() |> MapSet.new() | |
| 134 | 153 | |
| 135 | 154 | sov = %{ |
| 136 | 155 | ~c"enp38s0" => ~i"172.16.13.0/19" |
| 137 | 156 | } |
| 138 | 157 | |
| 139 | − pids = | |
| 158 | + state = %{ | |
| 159 | + interfaces: interfaces, | |
| 160 | + runners: %{}, | |
| 161 | + skipped: skipped, | |
| 162 | + subnet_override: sov, | |
| 163 | + task: nil | |
| 164 | + } | |
| 165 | + | |
| 166 | + state = | |
| 140 | 167 | interfaces |
| 141 | − |> Enum.map(fn {k, ifd} -> | |
| 142 | − Parent.child_spec( | |
| 143 | − {LambentEx.Scan.NMAP.Runner, %{iface: k, ifd: ifd, subnet_override: sov |> Map.get(k)}}, | |
| 144 | − id: k | |
| 145 | − ) | |
| 146 | − end) | |
| 147 | − |> Enum.map(fn x -> Parent.start_child(x) end) | |
| 148 | − |> Enum.filter(fn {status, pid} -> status == :ok end) | |
| 149 | − |> Enum.map(fn {:ok, v} -> v end) | |
| 168 | + |> Map.keys() | |
| 169 | + |> Enum.reduce(state, fn iface, state -> start_runner(iface, state) end) | |
| 150 | 170 | |
| 151 | −# IO.inspect(pids) | |
| 171 | + if MapSet.size(skipped) > 0 do | |
| 172 | + Logger.notice("#{@s} Not sweeping #{Enum.join(skipped, ", ")}") | |
| 173 | + end | |
| 152 | 174 | |
| 153 | − {:ok, | |
| 154 | − %{ | |
| 155 | − interfaces: interfaces, | |
| 156 | − pids: pids, | |
| 157 | − subnet_override: %{ | |
| 158 | − ~c"enp38s0" => ~i"172.16.13.0/19" | |
| 159 | − }, | |
| 160 | − task: nil | |
| 161 | − }} | |
| 175 | + {:ok, state} | |
| 176 | + end | |
| 177 | + | |
| 178 | + # a skipped interface simply has no runner, so nothing is nmapped there and | |
| 179 | + # nothing it might have found earlier is still on offer | |
| 180 | + defp start_runner(iface, state) do | |
| 181 | + cond do | |
| 182 | + MapSet.member?(state.skipped, Kernel.to_string(iface)) -> | |
| 183 | + state | |
| 184 | + | |
| 185 | + Map.has_key?(state.runners, iface) -> | |
| 186 | + state | |
| 187 | + | |
| 188 | + true -> | |
| 189 | + spec = | |
| 190 | + Parent.child_spec( | |
| 191 | + {LambentEx.Scan.NMAP.Runner, | |
| 192 | + %{ | |
| 193 | + iface: iface, | |
| 194 | + ifd: state.interfaces[iface], | |
| 195 | + subnet_override: state.subnet_override |> Map.get(iface) | |
| 196 | + }}, | |
| 197 | + id: iface | |
| 198 | + ) | |
| 199 | + | |
| 200 | + case Parent.start_child(spec) do | |
| 201 | + {:ok, pid} -> %{state | runners: Map.put(state.runners, iface, pid)} | |
| 202 | + _otherwise -> state | |
| 203 | + end | |
| 204 | + end | |
| 205 | + end | |
| 206 | + | |
| 207 | + defp stop_runner(iface, state) do | |
| 208 | + case Map.has_key?(state.runners, iface) do | |
| 209 | + false -> | |
| 210 | + state | |
| 211 | + | |
| 212 | + true -> | |
| 213 | + Parent.shutdown_child(iface) | |
| 214 | + %{state | runners: Map.delete(state.runners, iface)} | |
| 215 | + end | |
| 162 | 216 | end |
| 163 | 217 | |
| 164 | 218 | defp via_tuple(name), do: {:via, Registry, {@registry, name}} |
| 165 | 219 | |
| 166 | 220 | @impl true |
| 167 | 221 | def handle_call(:retrieve, _from, state) do |
| 168 | − res = state.pids |> Enum.map(fn pid -> GenServer.call(pid, :retrieve) end) |> Enum.reduce(&Map.merge/2) # |> IO.inspect | |
| 222 | + res = | |
| 223 | + state.runners | |
| 224 | + |> Map.values() | |
| 225 | + |> Enum.map(fn pid -> GenServer.call(pid, :retrieve) end) | |
| 226 | + |> Enum.reduce(%{}, &Map.merge/2) | |
| 227 | + | |
| 169 | 228 | {:reply, res, state} |
| 170 | 229 | end |
| 171 | 230 | |
| 231 | + @impl true | |
| 232 | + def handle_call(:interfaces, _from, state) do | |
| 233 | + {:reply, listing(state), state} | |
| 234 | + end | |
| 235 | + | |
| 236 | + @impl true | |
| 237 | + def handle_cast({:set_enabled, iface, enabled}, state) do | |
| 238 | + skipped = | |
| 239 | + case enabled do | |
| 240 | + true -> MapSet.delete(state.skipped, iface) | |
| 241 | + false -> MapSet.put(state.skipped, iface) | |
| 242 | + end | |
| 243 | + | |
| 244 | + state = %{state | skipped: skipped} | |
| 245 | + M.put_skipped_ifaces(MapSet.to_list(skipped)) | |
| 246 | + | |
| 247 | + key = Enum.find(Map.keys(state.interfaces), fn k -> Kernel.to_string(k) == iface end) | |
| 248 | + | |
| 249 | + state = | |
| 250 | + case {key, enabled} do | |
| 251 | + {nil, _enabled} -> state | |
| 252 | + {key, true} -> start_runner(key, state) | |
| 253 | + {key, false} -> stop_runner(key, state) | |
| 254 | + end | |
| 255 | + | |
| 256 | + Logger.notice("#{@s} #{if enabled, do: "Sweeping", else: "No longer sweeping"} #{iface}") | |
| 257 | + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_ifaces, {:interfaces, listing(state)}) | |
| 258 | + | |
| 259 | + {:noreply, state} | |
| 260 | + end | |
| 261 | + | |
| 262 | + defp listing(state) do | |
| 263 | + state.interfaces | |
| 264 | + |> Enum.map(fn {iface, ifd} -> | |
| 265 | + %{ | |
| 266 | + name: Kernel.to_string(iface), | |
| 267 | + addr: ifd |> Keyword.get(:addr) |> address(), | |
| 268 | + enabled: not MapSet.member?(state.skipped, Kernel.to_string(iface)) | |
| 269 | + } | |
| 270 | + end) | |
| 271 | + |> Enum.sort_by(& &1.name) | |
| 272 | + end | |
| 273 | + | |
| 274 | + defp address(nil), do: nil | |
| 275 | + defp address(addr), do: addr |> Tuple.to_list() |> Enum.join(".") | |
| 276 | + | |
| 172 | 277 | defp get_valid_interfaces() do |
| 173 | 278 | {:ok, addrs} = :inet.getifaddrs() |
| 174 | 279 |
modified
lib/lambent_ex_web/channels/devices_channel.ex
+19
−0
@@ -7,6 +7,8 @@ defmodule LambentExWeb.DevicesChannel do
| 7 | 7 | * `"devices"` — `%{devices: %{mac => device}}`, a snapshot on join and a |
| 8 | 8 | delta on every publish. Merge them by mac, the way the liveview does. |
| 9 | 9 | * `"probe_result"` — `%{ip: ip, status: "lambent" | "not_lambent" | "no_iface", sox: n}` |
| 10 | + * `"interfaces"` — `[%{name:, addr:, enabled:}]`, which interfaces the | |
| 11 | + sweep runs on, on join and whenever one is turned off or back on. | |
| 10 | 12 | """ |
| 11 | 13 | |
| 12 | 14 | use Phoenix.Channel |
@@ -21,6 +23,7 @@ defmodule LambentExWeb.DevicesChannel do
| 21 | 23 | def join("devices", _params, socket) do |
| 22 | 24 | Phoenix.PubSub.subscribe(@pubsub, "scan-82667777") |
| 23 | 25 | Phoenix.PubSub.subscribe(@pubsub, "scan-manual") |
| 26 | + Phoenix.PubSub.subscribe(@pubsub, "scan-ifaces") | |
| 24 | 27 | send(self(), :snapshot) |
| 25 | 28 | {:ok, assign(socket, :devices, %{})} |
| 26 | 29 | end |
@@ -31,9 +34,16 @@ defmodule LambentExWeb.DevicesChannel do
| 31 | 34 | def handle_info(:snapshot, socket) do |
| 32 | 35 | devices = S.tagged_devices() |
| 33 | 36 | push(socket, "devices", %{devices: Payload.devices(devices)}) |
| 37 | + push(socket, "interfaces", %{interfaces: LambentEx.Scan.NMAP.interfaces()}) | |
| 34 | 38 | {:noreply, assign(socket, :devices, devices)} |
| 35 | 39 | end |
| 36 | 40 | |
| 41 | + @impl true | |
| 42 | + def handle_info({:interfaces, interfaces}, socket) do | |
| 43 | + push(socket, "interfaces", %{interfaces: interfaces}) | |
| 44 | + {:noreply, socket} | |
| 45 | + end | |
| 46 | + | |
| 37 | 47 | @impl true |
| 38 | 48 | def handle_info({:devices_pub, devices}, socket) do |
| 39 | 49 | push(socket, "devices", %{devices: Payload.devices(devices)}) |
@@ -99,6 +109,15 @@ defmodule LambentExWeb.DevicesChannel do
| 99 | 109 | {:reply, :ok, socket} |
| 100 | 110 | end |
| 101 | 111 | |
| 112 | + # beyond the prefixes the scanner filters out on its own, an interface can be | |
| 113 | + # told to stay out of the sweep | |
| 114 | + @impl true | |
| 115 | + def handle_in("interface", %{"name" => name, "enabled" => enabled}, socket) | |
| 116 | + when is_boolean(enabled) do | |
| 117 | + LambentEx.Scan.NMAP.set_enabled(name, enabled) | |
| 118 | + {:reply, :ok, socket} | |
| 119 | + end | |
| 120 | + | |
| 102 | 121 | @impl true |
| 103 | 122 | def handle_in("probe", %{"ip" => ip}, socket) do |
| 104 | 123 | ip = String.trim(ip) |
modified
lib/lambent_ex_web/live/devices/index.ex
+25
−1
@@ -11,7 +11,13 @@ defmodule LambentExWeb.DevicesLive.Index do
| 11 | 11 | def mount(_params, _session, socket) do |
| 12 | 12 | Phoenix.PubSub.subscribe(@pubsub_name, "scan-82667777") |
| 13 | 13 | Phoenix.PubSub.subscribe(@pubsub_name, "scan-manual") |
| 14 | − {:ok, socket |> assign(:devices, %{}) |> assign(:nonew, true)} | |
| 14 | + Phoenix.PubSub.subscribe(@pubsub_name, "scan-ifaces") | |
| 15 | + | |
| 16 | + {:ok, | |
| 17 | + socket | |
| 18 | + |> assign(:devices, %{}) | |
| 19 | + |> assign(:interfaces, LambentEx.Scan.NMAP.interfaces()) | |
| 20 | + |> assign(:nonew, true)} | |
| 15 | 21 | end |
| 16 | 22 | |
| 17 | 23 | @impl true |
@@ -32,6 +38,11 @@ defmodule LambentExWeb.DevicesLive.Index do
| 32 | 38 | {:noreply, socket |> assign(:devices, n)} |
| 33 | 39 | end |
| 34 | 40 | |
| 41 | + @impl true | |
| 42 | + def handle_info({:interfaces, interfaces}, socket) do | |
| 43 | + {:noreply, socket |> assign(:interfaces, interfaces)} | |
| 44 | + end | |
| 45 | + | |
| 35 | 46 | @impl true |
| 36 | 47 | def handle_info({:rename, data}, socket) do |
| 37 | 48 | id = data["id"] |> String.trim_trailing("name") |> String.trim_trailing("namem") |
@@ -118,6 +129,19 @@ defmodule LambentExWeb.DevicesLive.Index do
| 118 | 129 | {:noreply, socket} |
| 119 | 130 | end |
| 120 | 131 | |
| 132 | + # the sweep runs on every interface that is not filtered out by prefix; this | |
| 133 | + # is for the ones that are left but still not worth scanning | |
| 134 | + @impl true | |
| 135 | + def handle_event("toggle_iface", %{"name" => name}, socket) do | |
| 136 | + enabled = | |
| 137 | + socket.assigns.interfaces | |
| 138 | + |> Enum.find(%{}, fn iface -> iface.name == name end) | |
| 139 | + |> Map.get(:enabled, true) | |
| 140 | + | |
| 141 | + LambentEx.Scan.NMAP.set_enabled(name, !enabled) | |
| 142 | + {:noreply, socket} | |
| 143 | + end | |
| 144 | + | |
| 121 | 145 | @impl true |
| 122 | 146 | def handle_event("manual", %{"ip" => ip}, socket) do |
| 123 | 147 | ip = String.trim(ip) |
modified
lib/lambent_ex_web/live/devices/index.html.heex
+25
−0
@@ -19,6 +19,31 @@
| 19 | 19 | <i class="fa-solid fa-code-branch"></i> Versions |
| 20 | 20 | </.link> |
| 21 | 21 | </form> |
| 22 | + <div class="my-2 flex flex-wrap items-center gap-4"> | |
| 23 | + <span class="text-sm opacity-60"> | |
| 24 | + <i class="fa-solid fa-network-wired fa-fw"></i> Sweep | |
| 25 | + </span> | |
| 26 | + <%= for iface <- @interfaces do %> | |
| 27 | + <label class="label cursor-pointer gap-2 p-0"> | |
| 28 | + <input | |
| 29 | + type="checkbox" | |
| 30 | + class="checkbox checkbox-sm" | |
| 31 | + checked={iface.enabled} | |
| 32 | + phx-click="toggle_iface" | |
| 33 | + phx-value-name={iface.name} | |
| 34 | + /> | |
| 35 | + <span class="label-text"> | |
| 36 | + <%= iface.name %> | |
| 37 | + <%= if iface.addr do %> | |
| 38 | + <span class="opacity-60"><%= iface.addr %></span> | |
| 39 | + <% end %> | |
| 40 | + </span> | |
| 41 | + </label> | |
| 42 | + <% end %> | |
| 43 | + <%= if @interfaces == [] do %> | |
| 44 | + <span class="text-sm opacity-60">No interfaces to scan.</span> | |
| 45 | + <% end %> | |
| 46 | + </div> | |
| 22 | 47 | <div class="hidden lg:block"> |
| 23 | 48 | <table class="table table-zebra w-full"> |
| 24 | 49 | <!-- head --> |
modified
test/lambent_ex_web/channels/channels_test.exs
+35
−0
@@ -51,6 +51,41 @@ defmodule LambentExWeb.ChannelsTest do
| 51 | 51 | end |
| 52 | 52 | end |
| 53 | 53 | |
| 54 | + describe "choosing what gets swept" do | |
| 55 | + setup do | |
| 56 | + on_exit(fn -> LambentEx.Meta.put_skipped_ifaces([]) end) | |
| 57 | + :ok | |
| 58 | + end | |
| 59 | + | |
| 60 | + test "the devices channel says which interfaces the sweep runs on" do | |
| 61 | + connect_to("devices") | |
| 62 | + | |
| 63 | + assert_push "interfaces", %{interfaces: interfaces} | |
| 64 | + assert is_list(interfaces) | |
| 65 | + assert {:ok, _json} = Jason.encode(interfaces) | |
| 66 | + | |
| 67 | + # never the ones filtered out by prefix | |
| 68 | + refute Enum.any?(interfaces, fn iface -> String.starts_with?(iface.name, "lo") end) | |
| 69 | + refute Enum.any?(interfaces, fn iface -> String.starts_with?(iface.name, "docker") end) | |
| 70 | + end | |
| 71 | + | |
| 72 | + test "turning one off is remembered and pushed back out" do | |
| 73 | + socket = connect_to("devices") | |
| 74 | + assert_push "interfaces", %{interfaces: _first} | |
| 75 | + | |
| 76 | + ref = push(socket, "interface", %{"name" => "test-iface0", "enabled" => false}) | |
| 77 | + assert_reply ref, :ok | |
| 78 | + | |
| 79 | + assert_push "interfaces", %{interfaces: _updated} | |
| 80 | + assert "test-iface0" in LambentEx.Meta.get_skipped_ifaces() | |
| 81 | + | |
| 82 | + ref = push(socket, "interface", %{"name" => "test-iface0", "enabled" => true}) | |
| 83 | + assert_reply ref, :ok | |
| 84 | + assert_push "interfaces", %{interfaces: _again} | |
| 85 | + refute "test-iface0" in LambentEx.Meta.get_skipped_ifaces() | |
| 86 | + end | |
| 87 | + end | |
| 88 | + | |
| 54 | 89 | describe "the machine form over the machines channel" do |
| 55 | 90 | test "describes what it can build and what each type carries" do |
| 56 | 91 | connect_to("machines", %{"previews" => false}) |
added
test/lambent_ex_web/live/interfaces_test.exs
+64
−0
@@ -0,0 +1,64 @@
| 1 | +defmodule LambentExWeb.InterfacesTest do | |
| 2 | + use LambentExWeb.ConnCase | |
| 3 | + | |
| 4 | + import Phoenix.LiveViewTest | |
| 5 | + | |
| 6 | + alias LambentEx.Meta | |
| 7 | + alias LambentEx.Scan.NMAP | |
| 8 | + | |
| 9 | + setup do | |
| 10 | + on_exit(fn -> Meta.put_skipped_ifaces([]) end) | |
| 11 | + :ok | |
| 12 | + end | |
| 13 | + | |
| 14 | + test "the devices page lists the interfaces the sweep runs on", %{conn: conn} do | |
| 15 | + {:ok, _live, html} = live(conn, "/cfg/devices") | |
| 16 | + | |
| 17 | + assert html =~ "Sweep" | |
| 18 | + assert html =~ "toggle_iface" | |
| 19 | + | |
| 20 | + # whatever this machine has, minus the prefixes the scanner drops | |
| 21 | + for iface <- NMAP.interfaces() do | |
| 22 | + assert html =~ iface.name | |
| 23 | + refute String.starts_with?(iface.name, "docker") | |
| 24 | + end | |
| 25 | + end | |
| 26 | + | |
| 27 | + test "unchecking one is remembered", %{conn: conn} do | |
| 28 | + {:ok, live, _html} = live(conn, "/cfg/devices") | |
| 29 | + | |
| 30 | + # an interface this machine does not have exercises the plumbing without | |
| 31 | + # stopping a sweep the test host is actually running | |
| 32 | + NMAP.set_enabled("test-iface1", false) | |
| 33 | + NMAP.interfaces() | |
| 34 | + assert render(live) =~ "toggle_iface" | |
| 35 | + assert "test-iface1" in Meta.get_skipped_ifaces() | |
| 36 | + | |
| 37 | + NMAP.set_enabled("test-iface1", true) | |
| 38 | + NMAP.interfaces() | |
| 39 | + refute "test-iface1" in Meta.get_skipped_ifaces() | |
| 40 | + end | |
| 41 | + | |
| 42 | + test "a real interface can be toggled from the page and comes back", %{conn: conn} do | |
| 43 | + case NMAP.interfaces() do | |
| 44 | + [] -> | |
| 45 | + :ok | |
| 46 | + | |
| 47 | + [iface | _rest] -> | |
| 48 | + {:ok, live, _html} = live(conn, "/cfg/devices") | |
| 49 | + | |
| 50 | + live | |
| 51 | + |> element(~s([phx-click="toggle_iface"][phx-value-name="#{iface.name}"])) | |
| 52 | + |> render_click() | |
| 53 | + | |
| 54 | + refute NMAP.interfaces() |> Enum.find(&(&1.name == iface.name)) |> Map.get(:enabled) | |
| 55 | + assert iface.name in Meta.get_skipped_ifaces() | |
| 56 | + | |
| 57 | + live | |
| 58 | + |> element(~s([phx-click="toggle_iface"][phx-value-name="#{iface.name}"])) | |
| 59 | + |> render_click() | |
| 60 | + | |
| 61 | + assert NMAP.interfaces() |> Enum.find(&(&1.name == iface.name)) |> Map.get(:enabled) | |
| 62 | + end | |
| 63 | + end | |
| 64 | +end |
Parents: 453cc82