diff --git a/custom_components/opnsense/coordinator.py b/custom_components/opnsense/coordinator.py index 57685a0..b59144f 100644 --- a/custom_components/opnsense/coordinator.py +++ b/custom_components/opnsense/coordinator.py @@ -193,7 +193,10 @@ async def _async_update_data(self) -> Mapping[str, Any]: interface[new_property] = value for vpn_type in ["openvpn", "wireguard"]: - for clients_servers in ["clients", "servers"]: + cs = ["servers"] + if vpn_type == "wireguard": + cs = ["clients", "servers"] + for clients_servers in cs: for instance_name in dict_get( self._state, f"{vpn_type}.{clients_servers}", {} ): diff --git a/custom_components/opnsense/pyopnsense/__init__.py b/custom_components/opnsense/pyopnsense/__init__.py index 9c875df..9f490c2 100644 --- a/custom_components/opnsense/pyopnsense/__init__.py +++ b/custom_components/opnsense/pyopnsense/__init__.py @@ -24,6 +24,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +def wireguard_is_connected(past_time: datetime) -> bool: + return datetime.now().astimezone() - past_time <= timedelta(minutes=3) + + def get_ip_key(item) -> tuple: address = item.get("address", None) @@ -1321,22 +1325,24 @@ async def _get_telemetry_filesystems(self) -> list: @_log_errors async def get_openvpn(self) -> Mapping[str, Any]: - openvpn_info: Mapping[str, Any] | list = await self._get( - "/api/openvpn/export/providers" - ) - connection_info: Mapping[str, Any] = await self._get( + sessions_info: Mapping[str, Any] = await self._get( "/api/openvpn/service/searchSessions" ) + + providers_info: Mapping[str, Any] | list = await self._get( + "/api/openvpn/export/providers" + ) + instances_info: Mapping[str, Any] = await self._get( "/api/openvpn/instances/search" ) - # _LOGGER.debug(f"[get_openvpn] openvpn_info: {openvpn_info}") - # _LOGGER.debug(f"[get_openvpn] connection_info: {connection_info}") + # _LOGGER.debug(f"[get_openvpn] providers_info: {providers_info}") + # _LOGGER.debug(f"[get_openvpn] sessions_info: {sessions_info}") # _LOGGER.debug(f"[get_openvpn] instances_info: {instances_info}") - if not isinstance(openvpn_info, Mapping): - openvpn_info = {} - if not isinstance(connection_info, Mapping): - connection_info = {} + if not isinstance(providers_info, Mapping): + providers_info = {} + if not isinstance(sessions_info, Mapping): + sessions_info = {} if not isinstance(instances_info, Mapping): instances_info = {} @@ -1358,54 +1364,81 @@ async def get_openvpn(self) -> Mapping[str, Any]: openvpn["servers"][instance.get("uuid")] = { "uuid": instance.get("uuid"), "name": instance.get("description"), + "connected_clients": 0, + "enabled": bool(instance.get("enabled", "0") == "1"), + "dev_type": instance.get("dev_type", None), } - if instance.get("dev_type", None): - openvpn["servers"][instance.get("uuid")]["dev_type"] = instance.get( - "dev_type", None - ) - if instance.get("enabled", None): - openvpn["servers"][instance.get("uuid")]["enabled"] = bool( - instance.get("enabled", "0") == "1" - ) - for uuid, vpn_info in openvpn_info.items(): + for uuid, vpn_info in providers_info.items(): if not uuid or not isinstance(vpn_info, Mapping): continue if uuid not in openvpn["servers"]: openvpn["servers"][uuid] = { "uuid": uuid, "name": vpn_info.get("name"), + "connected_clients": 0, } if vpn_info.get("hostname", None) and vpn_info.get("local_port", None): openvpn["servers"][uuid][ "endpoint" ] = f"{vpn_info.get('hostname')}:{vpn_info.get('local_port')}" - for connect in connection_info.get("rows", []): - if not isinstance(connect, Mapping) or not connect: + for session in sessions_info.get("rows", []): + if session.get("type", None) != "server": continue - id: str | int | None = connect.get("id", None) - if isinstance(id, str) and "_" in id: - id = id.split("_")[0] - if id and id in openvpn["servers"]: - if connect.get("description", None): - openvpn["servers"][id]["name"] = connect.get("description") - openvpn["servers"][id]["status"] = connect.get("status", "down") - if openvpn["servers"][id]["status"] == "ok": - openvpn["servers"][id]["status"] = "up" - - openvpn["servers"][id]["total_bytes_recv"] = self._try_to_int( - connect.get("bytes_received", 0), 0 - ) - openvpn["servers"][id]["total_bytes_sent"] = self._try_to_int( - connect.get("bytes_sent", 0), 0 - ) + server_id = str(session["id"]).split("_")[0] + + if server_id not in openvpn["servers"]: + openvpn["servers"][server_id] = { + "uuid": server_id, + "connected_clients": 0, + } + openvpn["servers"][server_id].update( + { + "name": session.get("description", ""), + "status": ( + "up" + if session.get("status", None) == "ok" + else session.get("status", "down") + ), + "clients": [], + } + ) + + if not session.get("is_client", False): + openvpn["servers"][server_id]["clients"] = [] + else: + openvpn["servers"][server_id]["connected_clients"] += 1 + client = { + "common_name": session.get("common_name", None), + "endpoint": session.get("real_address", None), + "bytes_recv": self._try_to_int(session.get("bytes_received", 0), 0), + "bytes_sent": self._try_to_int(session.get("bytes_sent", 0), 0), + } + tunnel_addresses: list = [] + for addr in ["virtual_address", "virtual_ipv6_address"]: + if session.get(addr, None): + tunnel_addresses.append(session.get(addr)) + client.update({"tunnel_addresses": tunnel_addresses}) + if session.get("connected_since__time_t_", None): + client.update( + { + "latest_handshake": datetime.fromtimestamp( + int(session.get("connected_since__time_t_")), + tz=datetime.now().astimezone().tzinfo, + ) + } + ) + openvpn["servers"][server_id]["clients"].append(client) for uuid, server in openvpn["servers"].items(): - if server.get("total_bytes_sent", None) is None: - server["total_bytes_sent"] = 0 - if server.get("total_bytes_recv", None) is None: - server["total_bytes_recv"] = 0 + server["total_bytes_sent"] = 0 + server["total_bytes_recv"] = 0 + if isinstance(server.get("clients", None), list): + for client in server.get("clients", []): + server["total_bytes_sent"] += client.get("bytes_sent", 0) + server["total_bytes_recv"] += client.get("bytes_recv", 0) + details_info: Mapping[str, Any] = await self._get( f"/api/openvpn/instances/get/{uuid}" ) @@ -1427,31 +1460,31 @@ async def get_openvpn(self) -> Mapping[str, Any]: or instance.get("role", "").lower() != "client" ): continue - client: Mapping[str, Any] = {} - client["name"] = instance.get("description", None) - client["uuid"] = instance.get("uuid", None) - client["enabled"] = instance.get("enabled", "0") == "1" - openvpn["clients"][client["uuid"]] = client - - for connect in connection_info.get("rows", []): - if not isinstance(connect, Mapping) or not connect: - continue - id: str | int | None = connect.get("id", None) - if isinstance(id, str) and "_" in id: - id = id.split("_")[0] - if id and id in openvpn["clients"]: - openvpn["clients"][id]["total_bytes_recv"] = self._try_to_int( - connect.get("bytes_received", 0), 0 - ) - openvpn["clients"][id]["total_bytes_sent"] = self._try_to_int( - connect.get("bytes_sent", 0), 0 - ) + openvpn["clients"][client["uuid"]] = { + "name": instance.get("description", None), + "uuid": instance.get("uuid", None), + "enabled": bool(instance.get("enabled", "0") == "1"), + } - for uuid, client in openvpn["clients"].items(): - if client.get("total_bytes_sent", None) is None: - client["total_bytes_sent"] = 0 - if client.get("total_bytes_recv", None) is None: - client["total_bytes_recv"] = 0 + # for connect in sessions_info.get("rows", []): + # if not isinstance(connect, Mapping) or not connect: + # continue + # id: str | int | None = connect.get("id", None) + # if isinstance(id, str) and "_" in id: + # id = id.split("_")[0] + # if id and id in openvpn["clients"]: + # openvpn["clients"][id]["total_bytes_recv"] = self._try_to_int( + # connect.get("bytes_received", 0), 0 + # ) + # openvpn["clients"][id]["total_bytes_sent"] = self._try_to_int( + # connect.get("bytes_sent", 0), 0 + # ) + + # for uuid, client in openvpn["clients"].items(): + # if client.get("total_bytes_sent", None) is None: + # client["total_bytes_sent"] = 0 + # if client.get("total_bytes_recv", None) is None: + # client["total_bytes_recv"] = 0 _LOGGER.debug(f"[get_openvpn] openvpn: {openvpn}") return openvpn @@ -1753,10 +1786,17 @@ async def get_wireguard(self) -> Mapping[str, Any]: for addr in srv.get("tunneladdress", {}).values(): if addr.get("selected", 0) == 1 and addr.get("value", None): server["tunnel_addresses"].append(addr.get("value")) - server["clients"] = {} + server["clients"] = [] for peer_id, peer in srv.get("peers", {}).items(): if peer.get("selected", 0) == 1 and peer.get("value", None): - server["clients"][peer_id] = {"name": peer.get("value")} + server["clients"].append( + { + "name": peer.get("value"), + "uuid": peer_id, + "connected": False, + } + ) + server["connected_clients"] = 0 server["total_bytes_recv"] = 0 server["total_bytes_sent"] = 0 servers[uid] = server @@ -1774,25 +1814,41 @@ async def get_wireguard(self) -> Mapping[str, Any]: for addr in clnt.get("tunneladdress", {}).values(): if addr.get("selected", 0) == 1 and addr.get("value", None): client["tunnel_addresses"].append(addr.get("value")) - client["servers"] = {} + client["servers"] = [] for srv_id, srv in clnt.get("servers", {}).items(): if srv.get("selected", 0) == 1 and srv.get("value", None): if servers.get(srv_id, None): - client["servers"][srv_id] = {} - for attr in ["name", "pubkey", "interface", "tunnel_addresses"]: + add_srv: Mapping[str, Any] = { + "name": servers[srv_id]["name"], + "uuid": srv_id, + "connected": False, + } + for attr in ["pubkey", "interface", "tunnel_addresses"]: if servers.get(srv_id, {}).get(attr, None): - client["servers"][srv_id][attr] = servers[srv_id][attr] + add_srv[attr] = servers[srv_id][attr] + client["servers"].append(add_srv) else: - client["servers"][srv_id] = {"name": peer.get("value")} + client["servers"].append( + { + "name": peer.get("value"), + "uuid": srv_id, + "connected": False, + } + ) for server in servers.values(): - if ( - isinstance(server, Mapping) - and isinstance(server.get("clients", None), Mapping) - and uid in server.get("clients") + if isinstance(server, Mapping) and isinstance( + server.get("clients", None), list ): - for attr in ["name", "enabled", "pubkey", "tunnel_addresses"]: - if client.get(attr, None): - server["clients"][uid][attr] = client.get(attr) + match_cl: Mapping[str, Any] = {} + for cl in server.get("clients"): + if isinstance(cl, Mapping) and cl.get("uuid", None) == uid: + match_cl = cl + break + if match_cl: + for attr in ["name", "enabled", "pubkey", "tunnel_addresses"]: + if client.get(attr, None): + match_cl[attr] = client.get(attr) + client["connected_servers"] = 0 client["total_bytes_recv"] = 0 client["total_bytes_sent"] = 0 clients[uid] = client @@ -1811,9 +1867,10 @@ async def get_wireguard(self) -> Mapping[str, Any]: if ( isinstance(client, Mapping) and client.get("pubkey", "") == entry.get("public-key", "-") - and isinstance(client.get("servers", None), Mapping) + and isinstance(client.get("servers", None), list) ): - for srv in client.get("servers").values(): + client["connected_servers"] = 0 + for srv in client.get("servers"): if isinstance(srv, Mapping) and srv.get( "interface", "" ) == entry.get("if", "-"): @@ -1833,28 +1890,35 @@ async def get_wireguard(self) -> Mapping[str, Any]: client.get("total_bytes_sent", 0) ) + int(entry.get("transfer-tx")) if entry.get("latest-handshake", None): - srv["latest-handshake"] = datetime.fromtimestamp( + srv["latest_handshake"] = datetime.fromtimestamp( int(entry.get("latest-handshake")), tz=datetime.now().astimezone().tzinfo, ) + srv["connected"] = wireguard_is_connected( + srv.get("latest_handshake") + ) + if srv["connected"]: + client["connected_servers"] += 1 if client.get( - "latest-handshake", None + "latest_handshake", None ) is None or client.get( - "latest-handshake" + "latest_handshake" ) < srv.get( - "latest-handshake" + "latest_handshake" ): - client["latest-handshake"] = srv.get( - "latest-handshake" + client["latest_handshake"] = srv.get( + "latest_handshake" ) + else: + srv["connected"] = False for server in servers.values(): if ( isinstance(server, Mapping) and server.get("interface", "") == entry.get("if", "-") - and isinstance(server.get("clients", None), Mapping) + and isinstance(server.get("clients", None), list) ): - for clnt in server.get("clients").values(): + for clnt in server.get("clients"): if isinstance(clnt, Mapping) and clnt.get( "pubkey", "" ) == entry.get("public-key", "-"): @@ -1874,24 +1938,31 @@ async def get_wireguard(self) -> Mapping[str, Any]: server.get("total_bytes_sent", 0) ) + int(entry.get("transfer-tx")) if entry.get("latest-handshake", None): - clnt["latest-handshake"] = datetime.fromtimestamp( + clnt["latest_handshake"] = datetime.fromtimestamp( int(entry.get("latest-handshake")), tz=datetime.now().astimezone().tzinfo, ) + clnt["connected"] = wireguard_is_connected( + clnt.get("latest_handshake") + ) + if clnt["connected"]: + server["connected_clients"] += 1 if server.get( - "latest-handshake", None + "latest_handshake", None ) is None or server.get( - "latest-handshake" + "latest_handshake" ) < clnt.get( - "latest-handshake" + "latest_handshake" ): - server["latest-handshake"] = clnt.get( - "latest-handshake" + server["latest_handshake"] = clnt.get( + "latest_handshake" ) + else: + clnt["connected"] = False - _LOGGER.debug(f"[get_wireguard] servers: {servers}") - _LOGGER.debug(f"[get_wireguard] clients: {clients}") - return {"servers": servers, "clients": clients} + wireguard: Mapping[str, Any] = {"servers": servers, "clients": clients} + _LOGGER.debug(f"[get_wireguard] wireguard: {wireguard}") + return wireguard async def toggle_vpn_instance( self, vpn_type: str, clients_servers: str, uuid: str diff --git a/custom_components/opnsense/sensor.py b/custom_components/opnsense/sensor.py index dd6c315..7189a2f 100644 --- a/custom_components/opnsense/sensor.py +++ b/custom_components/opnsense/sensor.py @@ -366,7 +366,10 @@ async def _compile_vpn_sensors( entities: list = [] for vpn_type in ["openvpn", "wireguard"]: - for clients_servers in ["clients", "servers"]: + cs = ["servers"] + if vpn_type == "wireguard": + cs = ["clients", "servers"] + for clients_servers in cs: for uuid, instance in dict_get( state, f"{vpn_type}.{clients_servers}", {} ).items(): @@ -380,6 +383,9 @@ async def _compile_vpn_sensors( ] if clients_servers == "servers": properties.append("status") + properties.append("connected_clients") + if vpn_type == "wireguard" and clients_servers == "clients": + properties.append("connected_servers") for prop_name in properties: state_class = None native_unit_of_measurement = None @@ -400,12 +406,19 @@ async def _compile_vpn_sensors( suggested_display_precision = 1 suggested_unit_of_measurement = UnitOfInformation.MEGABYTES + if prop_name in ["connected_clients", "connected_servers"]: + state_class = SensorStateClass.MEASUREMENT + # icon if "bytes" in prop_name: icon = "mdi:server-network" elif prop_name == "status": icon = "mdi:check-network" enabled_default = True + elif prop_name == "connected_servers": + icon = "mdi:router-network" + elif prop_name == "connected_clients": + icon = "mdi:account-network" else: icon = "mdi:gauge" @@ -414,7 +427,7 @@ async def _compile_vpn_sensors( coordinator=coordinator, entity_description=SensorEntityDescription( key=f"{vpn_type}.{clients_servers}.{uuid}.{prop_name}", - name=f"{"OpenVPN" if vpn_type == "openvpn" else vpn_type.title()} {clients_servers.title().rstrip('s')} {instance['name']} {prop_name}", + name=f"{'OpenVPN' if vpn_type == 'openvpn' else vpn_type.title()} {clients_servers.title().rstrip('s')} {instance['name']} {prop_name}", native_unit_of_measurement=native_unit_of_measurement, device_class=device_class, icon=icon, @@ -782,40 +795,53 @@ def _handle_coordinator_update(self) -> None: self._available = True self._attr_extra_state_attributes = {} - if ( - vpn_type == "wireguard" - and clients_servers == "servers" - and prop_name == "status" - ): + if clients_servers == "servers" and prop_name == "status": properties: list = [ "uuid", "name", "enabled", + "connected_clients", "endpoint", "interface", + "dev_type", "pubkey", "tunnel_addresses", "dns_servers", + "latest_handshake", "clients", ] - elif ( - vpn_type == "openvpn" - and clients_servers == "servers" - and prop_name == "status" - ): + elif prop_name == "connected_clients": properties: list = [ "uuid", "name", + "status", "enabled", "endpoint", + "interface", "dev_type", + "pubkey", "tunnel_addresses", "dns_servers", + "latest_handshake", + "clients", + ] + elif prop_name == "connected_servers": + properties: list = [ + "uuid", + "name", + "enabled", + "connected_servers", + "endpoint", + "iterface", + "pubkey", + "tunnel_addresses", + "latest_handshake", + "servers", ] else: properties: list = ["uuid", "name"] for attr in properties: - if instance.get(attr, None): + if instance.get(attr, None) is not None: self._attr_extra_state_attributes[attr] = instance.get(attr) self.async_write_ha_state() diff --git a/custom_components/opnsense/switch.py b/custom_components/opnsense/switch.py index cfa224a..1e5abf1 100644 --- a/custom_components/opnsense/switch.py +++ b/custom_components/opnsense/switch.py @@ -622,25 +622,32 @@ def _handle_coordinator_update(self) -> None: return self._available = True self._attr_extra_state_attributes = {} - if self._vpn_type == "wireguard" and self._clients_servers == "servers": + if self._clients_servers == "servers": properties: list = [ "uuid", "name", + "status", + "connected_clients", "endpoint", "interface", + "dev_type", "pubkey", "tunnel_addresses", "dns_servers", + "latest_handshake", "clients", ] - elif self._vpn_type == "openvpn" and self._clients_servers == "servers": + elif self._clients_servers == "clients": properties: list = [ "uuid", "name", + "connected_servers", "endpoint", - "dev_type", + "iterface", + "pubkey", "tunnel_addresses", - "dns_servers", + "latest_handshake", + "servers", ] else: properties: list = ["uuid", "name"]