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:
parent
a80d093c94
commit
ecb850739f
6 changed files with 112 additions and 19 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue