1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(instances): the address search finds a VM by its MAC address too

The "Find IP" dialog takes a MAC address (aa:bb:..., aa-bb-... or
aabb...) as well as an IP address or a network. A MAC finds the VMs with
a network card of that MAC, shut-off ones too (from their definitions),
with that card's addresses when the VM runs: for a MAC seen on a switch
port or in an external DHCP server's log. A MAC on more than one VM is
marked as a possible conflict. Only the VMs with that MAC are read, and
the card's link-local address, which comes from the MAC, is left out.
This commit is contained in:
catborise 2026-10-24 22:38:52 +03:00
parent a80d093c94
commit ecb850739f
6 changed files with 112 additions and 19 deletions

View file

@ -6,6 +6,7 @@ guest agent. An address counts only on one of the VM's own NICs (by MAC), so
interfaces inside the guest such as docker0 never match.
"""
import re
import threading
import time
from concurrent import futures
@ -58,6 +59,18 @@ def parse_query(text):
return ip_network(text.strip(), strict=False)
MAC = re.compile(r"[0-9a-f]{2}([:-]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}")
def parse_mac(text):
"""A MAC address (aa:bb:..., aa-bb-..., aabb...) in lower-case colon form; None otherwise."""
text = text.strip().lower()
if not MAC.fullmatch(text):
return None
digits = text.replace(":", "").replace("-", "")
return ":".join(digits[i:i + 2] for i in range(0, 12, 2))
def nic_macs(xml):
return {mac.lower() for mac in etree.fromstring(xml).xpath("/domain/devices/interface/mac/@address")}
@ -135,14 +148,15 @@ def _read_os(dom):
return info.get("os.pretty-name") or info.get("os.name") or info["os.id"], os_family(info["os.id"])
def _read_host(target, network, everything):
def _read_host(target, network, everything, mac):
"""The cheap first phase on one compute: leases and ARP of each running
VM in scope (with everything, the shut-off ones are listed too). Returns
the VMs' records, the observations, the (domain, vm, MACs) whose agent is
to be asked, the number of running VMs read and of those without a
connected agent; None when the compute cannot be reached (connecting may
take the connect timeout, so this too runs on the compute's thread). A
VM that stops or leaves meanwhile is skipped."""
VM that stops or leaves meanwhile is skipped. With a mac, only the VMs
with a NIC of that MAC, and only that NIC's addresses."""
if target.compute.status is not True:
return None
records, found, agents, checked, no_agent = [], [], [], 0, 0
@ -159,6 +173,10 @@ def _read_host(target, network, everything):
except libvirtError:
continue
macs = nic_macs(xml)
if mac:
if mac not in macs:
continue
macs = {mac}
os, family = defined_os(xml)
records.append({"vm": vm, "state": state, "vcpus": vcpus, "memory": memory // 1024,
"macs": sorted(macs), "os": os, "os_family": family})
@ -189,7 +207,7 @@ def _rows(observations):
return rows
def _survey(targets, network, deadline, everything):
def _survey(targets, network, deadline, everything, with_os=False, mac=None):
"""
Read the targets by the deadline (seconds): first each compute's VMs,
leases and ARP, then each VM's guest agent on its own thread, so a hung
@ -205,7 +223,7 @@ def _survey(targets, network, deadline, everything):
checked = no_agent = unread = 0
pool = futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
hosts = {pool.submit(_counted, _read_host, target, network, everything): target.compute
hosts = {pool.submit(_counted, _read_host, target, network, everything, mac): target.compute
for target in targets}
waiting = set(hosts)
try:
@ -227,7 +245,7 @@ def _survey(targets, network, deadline, everything):
for dom, vm, macs in agents:
agent_reads.append(pool.submit(_counted, _matching, dom, vm,
VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, macs, network))
if everything:
if with_os:
os_reads[pool.submit(_counted, _read_os, dom)] = vm
except futures.TimeoutError:
pass
@ -255,10 +273,29 @@ def find(targets, network, deadline=5.0):
return {"matches": _rows(observations), **counts}
def find_mac(targets, mac, deadline=5.0):
"""The VMs of the targets with a NIC of the MAC, shut-off ones too, one
row per VM and address of that NIC (one row without an address if it
has none). One MAC on more than one VM is a conflict."""
records, observations, _, counts = _survey(targets, None, deadline, everything=True, mac=mac)
found = {}
for row in _rows(observations):
if not ip_address(row["address"]).is_link_local: # made from the MAC: not a finding
found.setdefault((row["compute_id"], row["uuid"]), []).append(row)
conflict = len({record["vm"][:2] for record in records}) > 1
matches = []
for record in sorted(records, key=lambda record: (record["vm"][2], record["vm"][0])):
compute_id, uuid, name = record["vm"]
rows = found.get((compute_id, uuid)) or [
{"compute_id": compute_id, "uuid": uuid, "name": name, "mac": mac, "address": "", "sources": []}]
matches += [{**row, "conflict": conflict} for row in rows]
return {"matches": matches, **counts}
def inventory(targets, deadline=20.0):
"""Every VM of the targets, shut-off ones too, with its addresses and OS
(the agent's, else the definition's libosinfo id), sorted by name."""
records, observations, systems, counts = _survey(targets, None, deadline, everything=True)
records, observations, systems, counts = _survey(targets, None, deadline, everything=True, with_os=True)
addresses = {}
for row in _rows(observations):
vm = (row["compute_id"], row["uuid"], row["name"])

View file

@ -50,6 +50,7 @@
<div class="modal fade" id="find-address" tabindex="-1" aria-labelledby="find-address-title" aria-hidden="true"
data-url="{% url 'instances:find_address' %}" data-failed="{% trans "The search did not go through. Try again." %}"
data-none="{% trans "No running VM reports this address." %}"
data-none-mac="{% trans "No VM has a network card with this MAC address." %}"
data-unregistered="{% trans "(not registered in the panel)" %}" data-conflict="{% trans "possible conflict" %}"
data-checked="{% trans "Running VMs checked: %(n)s." %}"
data-no-agent="{% trans "%(n)s had no connected guest agent; a static address on them cannot be seen." %}"
@ -61,14 +62,14 @@
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="find-address-title">{% trans "Find the VMs using an IP address" %}</h2>
<h2 class="modal-title h5" id="find-address-title">{% trans "Find the VMs using an IP or MAC address" %}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<form id="find-address-form" class="d-flex gap-2 mb-3">
<label class="visually-hidden" for="find-address-q">{% trans "IP address or network" %}</label>
<label class="visually-hidden" for="find-address-q">{% trans "IP address, network or MAC address" %}</label>
<input type="text" class="form-control" name="q" id="find-address-q" required
placeholder="192.0.2.10 {% trans "or" %} 192.0.2.0/24">
placeholder="192.0.2.10, 192.0.2.0/24 {% trans "or" %} 52:54:00:12:34:56">
<button type="submit" class="btn btn-primary">{% trans "Find" %}</button>
</form>
<div class="alert alert-danger d-none" role="alert" id="find-address-error"></div>

View file

@ -102,6 +102,14 @@ class ReadAndMatchTests(unittest.TestCase):
with self.subTest(text=text), self.assertRaises(ValueError):
addresses.parse_query(text)
def test_a_mac_is_read_in_its_usual_spellings(self):
for text in ("52:54:00:AA:bb:01", " 52-54-00-aa-bb-01 ", "525400aabb01"):
with self.subTest(text=text):
self.assertEqual(addresses.parse_mac(text), "52:54:00:aa:bb:01")
for text in ("", "52:54:00:aa:bb", "192.0.2.1", "fe80::1", "52:54:00:aa:bb:zz"):
with self.subTest(text=text):
self.assertIsNone(addresses.parse_mac(text))
def test_the_nics_and_the_agent_come_from_the_definition(self):
xml = domain_xml(MAC1, MAC2.upper(), agent="connected")
self.assertEqual(addresses.nic_macs(xml), {MAC1, MAC2})
@ -304,3 +312,32 @@ class InventoryTests(unittest.TestCase):
self.assertEqual(result["unread"], 0)
finally:
hung.set()
class FindMacTests(unittest.TestCase):
"""A MAC finds the VMs with a NIC of that MAC, shut-off ones too, with that NIC's addresses."""
def test_a_running_vm_comes_with_the_nics_addresses(self):
# the NIC's link-local address comes from its MAC: not a finding
dom = FakeDomain({ARP: {"e": iface(MAC1, (0, "192.0.2.10", 24))},
AGENT: {"eth0": iface(MAC1, (0, "192.0.2.10", 24), (1, "fe80::5054:ff:fe00:1", 64)),
"eth1": iface(MAC2, (0, "198.51.100.2", 24))}},
xml=domain_xml(MAC1, MAC2, agent="connected"))
result = addresses.find_mac([addresses.Target(compute("c1", dom), None)], MAC1)
self.assertEqual([(m["mac"], m["address"], m["sources"]) for m in result["matches"]],
[(MAC1, "192.0.2.10", ["ARP", "agent"])])
def test_a_shut_off_vm_is_found_by_its_definition(self):
off = FakeDomain({}, uuid="off", name="vm-off", state=5, xml=domain_xml(MAC2))
(match,) = addresses.find_mac([addresses.Target(compute("c1", off), None)], MAC2)["matches"]
self.assertEqual((match["name"], match["address"], match["sources"], match["conflict"]), ("vm-off", "", [], False))
def test_one_mac_on_two_vms_is_a_conflict(self):
c = compute("c1", vm("a", MAC1, "192.0.2.10"), FakeDomain({}, uuid="b", name="vm-b", state=5, xml=domain_xml(MAC1)))
matches = addresses.find_mac([addresses.Target(c, None)], MAC1)["matches"]
self.assertEqual(sorted((m["uuid"], m["conflict"]) for m in matches), [("a", True), ("b", True)])
def test_only_vms_with_the_mac_are_read(self):
other = FakeDomain({AGENT: libvirtError("must not be asked")}, uuid="other", xml=domain_xml(MAC2, agent="connected"))
result = addresses.find_mac([addresses.Target(compute("c1", vm("a", MAC1, "192.0.2.10"), other), None)], MAC1)
self.assertEqual(([m["uuid"] for m in result["matches"]], result["unread"]), (["a"], 0))

View file

@ -82,12 +82,23 @@ class FindAddressTests(TestCase):
self.assertEqual([m["url"] for m in response.json()["matches"]],
[reverse("instances:instance", args=[i.id]) + "#network" for i in (self.a, twin)])
def test_a_mac_is_looked_up_by_its_nics(self):
self.client.force_login(self.admin)
answer = {"matches": [{**row(UUID_A, "vm-a", self.c1.id), "address": "", "sources": []}],
"checked": 1, "no_agent": 0, "unread": 0, "hosts_down": [], "hosts_unfinished": []}
with patch("instances.addresses.find_mac", return_value=answer) as find_mac, \
patch("instances.addresses.find") as find:
response = self.client.get(reverse("instances:find_address"), {"q": "52-54-00-00-00-01"})
self.assertEqual(find_mac.call_args.args[1], "52:54:00:00:00:01")
find.assert_not_called()
self.assertEqual([(m["vm"], m["address"]) for m in response.json()["matches"]], [("vm-a", "")])
def test_a_bad_query_is_refused_without_libvirt(self):
for q in ("", "abc", "192.0.2.300"):
with self.subTest(q=q):
response, find = self.search(self.admin, q=q)
self.assertEqual(response.status_code, 400)
self.assertIn("192.0.2.0/24", response.json()["error"])
self.assertIn("MAC", response.json()["error"])
find.assert_not_called()
def test_too_many_searches_answer_409(self):

View file

@ -114,19 +114,24 @@ def _address_scope(user):
def find_address(request):
"""
The running VMs using an IP address or an address in a network
The running VMs using an IP address or an address in a network, or the
VMs with a NIC of a MAC address, shut-off ones too
(instances/addresses.py), as JSON for the Instances page's dialog.
Administrators search every compute, VMs the panel has not registered
included, and see the hosts; other users only their own VMs, without
hosts or host errors.
"""
try:
network = addresses.parse_query(request.GET.get("q", ""))
except ValueError:
return JsonResponse({"error": _("Enter an IP address or a network such as 192.0.2.0/24.")}, status=400)
query = request.GET.get("q", "")
mac = addresses.parse_mac(query)
if mac is None:
try:
network = addresses.parse_query(query)
except ValueError:
return JsonResponse({"error": _("Enter an IP address, a network such as 192.0.2.0/24, or a MAC address.")},
status=400)
show_hosts, known, targets = _address_scope(request.user)
try:
result = addresses.find(targets, network)
result = addresses.find_mac(targets, mac) if mac else addresses.find(targets, network)
except addresses.Busy:
return JsonResponse({"error": _("Too many searches are still running; try again shortly.")}, status=409)

View file

@ -9,6 +9,7 @@
var coverage = document.getElementById("find-address-coverage");
var button = form.querySelector("button[type=submit]");
var text = dialog.dataset;
var MAC = /^[0-9a-f]{2}([:-]?)[0-9a-f]{2}(\1[0-9a-f]{2}){4}$/i; // as instances/addresses.py reads one
function cell(row, content) {
var td = document.createElement("td");
@ -59,7 +60,7 @@
var matches = answer.matches || [];
matches.forEach(function (match) {
var row = body.insertRow();
var address = cell(row, match.address);
var address = cell(row, match.address || "\u2014"); // a NIC without an address
if (match.conflict) {
var badge = document.createElement("span");
badge.className = "badge bg-warning-subtle text-warning-emphasis ms-1";
@ -76,7 +77,8 @@
table.classList.toggle("d-none", matches.length === 0);
if (answer.coverage) {
var readAt = text.readAt.replace("%(time)s", new Date(answer.read_at).toLocaleTimeString());
coverage.textContent = (matches.length ? "" : text.none + " ") + coverageText(answer.coverage) + " " + readAt;
var none = MAC.test(form.elements.q.value.trim()) ? text.noneMac : text.none;
coverage.textContent = (matches.length ? "" : none + " ") + coverageText(answer.coverage) + " " + readAt;
} else {
coverage.textContent = "";
}
@ -85,7 +87,7 @@
// an address typed in the list's search box is what the dialog looks for
dialog.addEventListener("show.bs.modal", function () {
var typed = document.getElementById("filter").value.trim();
if (/^[0-9a-fA-F:.]+(\/[0-9]+)?$/.test(typed) && /[.:]/.test(typed)) {
if (MAC.test(typed) || (/^[0-9a-fA-F:.]+(\/[0-9]+)?$/.test(typed) && /[.:]/.test(typed))) {
form.elements.q.value = typed;
}
});