Skip to content

Commit 58a59a7

Browse files
committed
networkmanager: Add Wi-Fi support
Update the model to collect Wi-Fi access points and provide a scan method. On a Wi-Fi device page, show a table of available networks with mode, signal strength, rate, and security status. The currently active connection (if any) is always at the top, followed by known networks (with a saved connection), then unknown networks sorted by descending signal strength (by default, the table can also be sorted by name), and finally the number of hidden networks. Allow the user to connect, disconnect, and forget networks, and re-scan. In the Networking overview, if there are any Wi-Fi devices, add a "Details" column which shows the number of available and the currently connected networks. We can test this with `mac80211_hwsim` and `hostapd`, to create the kinds of networks that we want to cover: one open (with two APs), two WPA (so that we can have one active and one inactive one), and one hidden network. https://issues.redhat.com/browse/COCKPIT-1751
1 parent 5237b39 commit 58a59a7

File tree

6 files changed

+1252
-19
lines changed

6 files changed

+1252
-19
lines changed

pkg/networkmanager/interfaces.js

Lines changed: 230 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { show_modal_dialog } from "cockpit-components-dialog.jsx";
1717

1818
const _ = cockpit.gettext;
1919

20-
function show_error_dialog(title, message) {
20+
export function show_error_dialog(title, message) {
2121
const props = {
2222
id: "error-popup",
2323
title,
@@ -332,7 +332,8 @@ export function NetworkManagerModel() {
332332

333333
if (signal == "PropertiesChanged") {
334334
push_refresh();
335-
set_object_properties(obj, remove_signatures(args[0]));
335+
const props = remove_signatures(args[0]);
336+
set_object_properties(obj, props);
336337
pop_refresh();
337338
} else if (type.signals && type.signals[signal])
338339
type.signals[signal](obj, args);
@@ -399,6 +400,21 @@ export function NetworkManagerModel() {
399400
Object.keys(interfaces).forEach(iface => {
400401
const props = interfaces[iface];
401402

403+
/* Capture connection failure reasons for devices
404+
NM transitions through PREPARE → CONFIG → NEED_AUTH → FAILED → DISCONNECTED very
405+
quickly, and React batches these updates, so the UI often misses the FAILED state.
406+
Store the failure reason here at the D-Bus event level so we can retrieve it later. */
407+
if (path.includes("/Devices/") && props?.StateReason) {
408+
const obj = peek_object(path);
409+
if (obj) {
410+
const [state, reason] = props.StateReason;
411+
if (state === 120 && reason !== 0) {
412+
utils.debug("Captured", obj.Interface, "failure, reason:", reason);
413+
priv(obj).lastFailureReason = reason;
414+
}
415+
}
416+
}
417+
402418
if (props)
403419
interface_properties(path, iface, props);
404420
else
@@ -610,6 +626,13 @@ export function NetworkManagerModel() {
610626
};
611627
}
612628

629+
if (settings["802-11-wireless"]) {
630+
result["802-11-wireless"] = {
631+
ssid: get("802-11-wireless", "ssid"),
632+
mode: get("802-11-wireless", "mode"),
633+
};
634+
}
635+
613636
return result;
614637
}
615638

@@ -784,6 +807,20 @@ export function NetworkManagerModel() {
784807
delete result.wireguard;
785808
}
786809

810+
if (settings["802-11-wireless"]) {
811+
set("802-11-wireless", "ssid", 'ay', settings["802-11-wireless"].ssid);
812+
set("802-11-wireless", "mode", 's', settings["802-11-wireless"].mode);
813+
} else {
814+
delete result["802-11-wireless"];
815+
}
816+
817+
if (settings["802-11-wireless-security"]) {
818+
set("802-11-wireless-security", "key-mgmt", 's', settings["802-11-wireless-security"]["key-mgmt"]);
819+
set("802-11-wireless-security", "psk", 's', settings["802-11-wireless-security"].psk);
820+
} else {
821+
delete result["802-11-wireless-security"];
822+
}
823+
787824
return result;
788825
}
789826

@@ -860,6 +897,21 @@ export function NetworkManagerModel() {
860897
}
861898
}
862899

900+
function access_point_mode_to_text(mode) {
901+
switch (mode) {
902+
// NM_802_11_MODE_ADHOC
903+
case 1: return _("Adhoc");
904+
// NM_802_11_MODE_INFRA
905+
case 2: return _("Infra");
906+
// NM_802_11_MODE_AP
907+
case 3: return _("AP");
908+
// NM_802_11_MODE_MESH
909+
case 4: return _("Mesh");
910+
// subsumes NM_802_11_MODE_UNKNOWN
911+
default: return _("Unknown");
912+
}
913+
}
914+
863915
const connections_by_uuid = { };
864916

865917
function set_settings(obj, settings) {
@@ -942,6 +994,37 @@ export function NetworkManagerModel() {
942994
}
943995
};
944996

997+
const type_AccessPoint = {
998+
interfaces: [
999+
"org.freedesktop.NetworkManager.AccessPoint"
1000+
],
1001+
1002+
props: {
1003+
Flags: { def: 0 },
1004+
WpaFlags: { def: 0 },
1005+
RsnFlags: { def: 0 },
1006+
Ssid: { conv: utils.ssid_from_nm, def: "" },
1007+
Frequency: { def: 0 }, // MHz
1008+
HwAddress: { def: "" },
1009+
Mode: { conv: access_point_mode_to_text, def: "" },
1010+
MaxBitrate: { def: 0 }, // Kbit/s
1011+
Bandwidth: { def: 0 }, // MHz
1012+
Strength: { def: 0 },
1013+
LastSeen: { def: -1 }, // CLOCK_BOOTTIME seconds, -1 if never seen
1014+
},
1015+
1016+
exporters: [
1017+
function (obj) {
1018+
// Find connection for this SSID (undefined if none exists)
1019+
obj.Connection = (self.get_settings()?.Connections || []).find(con => {
1020+
if (con.Settings?.["802-11-wireless"]?.ssid)
1021+
return utils.ssid_from_nm(con.Settings["802-11-wireless"].ssid) == obj.Ssid;
1022+
return false;
1023+
});
1024+
}
1025+
]
1026+
};
1027+
9451028
const type_Connection = {
9461029
interfaces: [
9471030
"org.freedesktop.NetworkManager.Settings.Connection"
@@ -1052,7 +1135,8 @@ export function NetworkManagerModel() {
10521135
props: {
10531136
Connection: { conv: conv_Object(type_Connection) },
10541137
Ip4Config: { conv: conv_Object(type_Ipv4Config) },
1055-
Ip6Config: { conv: conv_Object(type_Ipv6Config) }
1138+
Ip6Config: { conv: conv_Object(type_Ipv6Config) },
1139+
State: { def: 0 }
10561140
// See below for "Group"
10571141
},
10581142

@@ -1073,14 +1157,16 @@ export function NetworkManagerModel() {
10731157
"org.freedesktop.NetworkManager.Device.Bond",
10741158
"org.freedesktop.NetworkManager.Device.Team",
10751159
"org.freedesktop.NetworkManager.Device.Bridge",
1076-
"org.freedesktop.NetworkManager.Device.Vlan"
1160+
"org.freedesktop.NetworkManager.Device.Vlan",
1161+
"org.freedesktop.NetworkManager.Device.Wireless"
10771162
],
10781163

10791164
props: {
10801165
DeviceType: { conv: device_type_to_symbol },
10811166
Interface: { },
10821167
StateText: { prop: "State", conv: device_state_to_text, def: _("Unknown") },
10831168
State: { },
1169+
StateReason: { def: [0, 0] }, // [state, reason] tuple
10841170
HwAddress: { },
10851171
AvailableConnections: { conv: conv_Array(conv_Object(type_Connection)), def: [] },
10861172
ActiveConnection: { conv: conv_Object(type_ActiveConnection) },
@@ -1093,23 +1179,31 @@ export function NetworkManagerModel() {
10931179
Carrier: { def: true },
10941180
Speed: { },
10951181
Managed: { def: false },
1182+
// WiFi-specific properties
1183+
AccessPoints: { conv: conv_Array(conv_Object(type_AccessPoint)), def: [] },
1184+
ActiveAccessPoint: { conv: conv_Object(type_AccessPoint) },
10961185
// See below for "Members"
10971186
},
10981187

10991188
prototype: {
11001189
activate: function(connection, specific_object) {
1190+
priv(this).lastFailureReason = undefined; // Clear stale failure reason from previous attempts
11011191
return call_object_method(get_object("/org/freedesktop/NetworkManager", type_Manager),
11021192
"org.freedesktop.NetworkManager", "ActivateConnection",
11031193
objpath(connection), objpath(this), objpath(specific_object))
11041194
.then(([active_connection]) => active_connection);
11051195
},
11061196

11071197
activate_with_settings: function(settings, specific_object) {
1198+
priv(this).lastFailureReason = undefined; // Clear stale failure reason from previous attempts
11081199
try {
11091200
return call_object_method(get_object("/org/freedesktop/NetworkManager", type_Manager),
11101201
"org.freedesktop.NetworkManager", "AddAndActivateConnection",
11111202
settings_to_nm(settings), objpath(this), objpath(specific_object))
1112-
.then(([path, active_connection]) => active_connection);
1203+
.then(([path, active_connection_path]) => ({
1204+
connection: get_object(path, type_Connection),
1205+
active_connection: get_object(active_connection_path, type_ActiveConnection)
1206+
}));
11131207
} catch (e) {
11141208
return Promise.reject(e);
11151209
}
@@ -1118,8 +1212,136 @@ export function NetworkManagerModel() {
11181212
disconnect: function () {
11191213
return call_object_method(this, 'org.freedesktop.NetworkManager.Device', 'Disconnect')
11201214
.then(() => undefined);
1215+
},
1216+
1217+
// Request a WiFi scan to populate this.AccessPoints
1218+
request_scan: function() {
1219+
utils.debug("request_scan: requesting scan for", this.Interface);
1220+
call_object_method(this, 'org.freedesktop.NetworkManager.Device.Wireless', 'RequestScan', {})
1221+
.catch(error => {
1222+
// RequestScan can fail if a scan was recently done, that's OK
1223+
console.warn("request_scan: scan failed for", this.Interface + ":", error.toString());
1224+
});
1225+
},
1226+
1227+
// Get and clear the last connection failure reason
1228+
consume_failure_reason: function() {
1229+
const reason = priv(this).lastFailureReason;
1230+
priv(this).lastFailureReason = undefined;
1231+
return reason;
1232+
},
1233+
1234+
// Mark that a pending connection is being cancelled by the user
1235+
cancel_pending_connection: function() {
1236+
priv(this).connectionCancelled = true;
1237+
},
1238+
1239+
// Wait for a connection to complete
1240+
// For WiFi, pass expected_ssid to verify we connected to the right network
1241+
// Returns a Promise that resolves on success or cancel, rejects with {reason} on failure
1242+
wait_connection: function(expected_ssid) {
1243+
priv(this).connectionCancelled = false;
1244+
utils.debug("wait_connection: starting, iface:", this.Interface, "expected:", expected_ssid, "initial state:", this.State);
1245+
return new Promise((resolve, reject) => {
1246+
let activationStarted = false;
1247+
1248+
const cleanup = () => self.removeEventListener("changed", check);
1249+
1250+
const check = () => {
1251+
utils.debug("wait_connection check: state:", this.State, "ssid:", this.ActiveAccessPoint?.Ssid,
1252+
"activeConn:", !!this.ActiveConnection, "activationStarted:", activationStarted,
1253+
"lastFailureReason:", priv(this).lastFailureReason,
1254+
"connectionCancelled:", priv(this).connectionCancelled);
1255+
1256+
// captured a failure?
1257+
const reason = this.consume_failure_reason();
1258+
if (reason) {
1259+
cleanup();
1260+
console.warn("wait_connection: connection failed for", this.Interface, "reason:", reason);
1261+
const error = new Error("Connection failed");
1262+
error.reason = reason;
1263+
reject(error);
1264+
return;
1265+
}
1266+
1267+
// https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceState
1268+
switch (this.State) {
1269+
case 100: // NM_DEVICE_STATE_ACTIVATED
1270+
if (!expected_ssid || this.ActiveAccessPoint?.Ssid === expected_ssid) {
1271+
utils.debug("wait_connection: success");
1272+
cleanup();
1273+
resolve();
1274+
}
1275+
break;
1276+
1277+
case 30: // NM_DEVICE_STATE_DISCONNECTED; initial state, so wait for activation to start
1278+
case 120: // NM_DEVICE_STATE_FAILED
1279+
// Disconnected/failed after activation started means user cancelled or failure without reason
1280+
if (activationStarted && !this.ActiveConnection) {
1281+
cleanup();
1282+
if (priv(this).connectionCancelled) {
1283+
utils.debug("wait_connection: cancelled by user");
1284+
resolve();
1285+
} else {
1286+
console.warn("wait_connection: connection failed for", this.Interface, "without captured reason");
1287+
reject(new Error("Connection failed"));
1288+
}
1289+
}
1290+
break;
1291+
1292+
case 20: // NM_DEVICE_STATE_UNAVAILABLE
1293+
case 110: // NM_DEVICE_STATE_DEACTIVATING
1294+
break;
1295+
1296+
// any other state means we're activating
1297+
default:
1298+
if (!activationStarted) {
1299+
utils.debug("wait_connection: activation started");
1300+
activationStarted = true;
1301+
}
1302+
}
1303+
};
1304+
1305+
self.addEventListener("changed", check);
1306+
check(); // Check current state immediately in case already connected
1307+
});
11211308
}
1122-
}
1309+
},
1310+
1311+
exporters: [
1312+
function (obj) {
1313+
if (obj.DeviceType === '802-11-wireless') {
1314+
// When a hidden network (no SSID broadcast) has a saved connection, NetworkManager
1315+
// duplicates it in AccessPoints: once without SSID (from the beacon), and once with
1316+
// SSID (synthesized from the saved connection). Both have the same HwAddress (MAC).
1317+
// We deduplicate by MAC first, preferring the one with a known connection
1318+
const apByMac = new Map();
1319+
(obj.AccessPoints || []).forEach(ap => {
1320+
utils.debug(`AP: Ssid '${ap.Ssid}' HwAddress: ${ap.HwAddress} Strength: ${ap.Strength} hasConnection: ${!!ap.Connection}`);
1321+
if (!apByMac.get(ap.HwAddress) || ap.Connection)
1322+
apByMac.set(ap.HwAddress, ap);
1323+
});
1324+
1325+
// Deduplicate visible APs by SSID, keeping the strongest signal for each network.
1326+
// Count remaining hidden APs (those without SSID after MAC deduplication).
1327+
const apBySsid = new Map();
1328+
let hiddenCount = 0;
1329+
Array.from(apByMac.values()).forEach(ap => {
1330+
if (ap.Ssid) {
1331+
const existing = apBySsid.get(ap.Ssid);
1332+
if (!existing || ap.Strength > existing.Strength) {
1333+
apBySsid.set(ap.Ssid, ap);
1334+
}
1335+
} else {
1336+
hiddenCount++;
1337+
}
1338+
});
1339+
obj.visibleSsids = Array.from(apBySsid.values());
1340+
obj.hiddenAPCount = hiddenCount;
1341+
utils.debug("Device exporter:", obj.Interface, "has", obj.visibleSsids.length, "visible SSIDs and", obj.hiddenAPCount, "hidden APs");
1342+
}
1343+
}
1344+
]
11231345
};
11241346

11251347
// The 'Interface' type does not correspond to any NetworkManager
@@ -1362,7 +1584,8 @@ export function NetworkManagerModel() {
13621584
type_Ipv4Config,
13631585
type_Ipv6Config,
13641586
type_Connection,
1365-
type_ActiveConnection
1587+
type_ActiveConnection,
1588+
type_AccessPoint
13661589
]);
13671590

13681591
get_object("/org/freedesktop/NetworkManager", type_Manager);

0 commit comments

Comments
 (0)