Skip to content

Commit f253c57

Browse files
committed
add /clients endpoint
1 parent 2f58933 commit f253c57

File tree

3 files changed

+133
-7
lines changed

3 files changed

+133
-7
lines changed

api/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DNSMASQ_LEASES = '/var/lib/misc/dnsmasq.leases'

api/main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,20 @@ async def get_ap(api_key: APIKey = Depends(auth.get_api_key)):
6969
'ignore_broadcast_ssid': ap.ignore_broadcast_ssid()
7070
}
7171

72+
@app.get("/clients", tags=["Clients"])
73+
async def get_clients(api_key: APIKey = Depends(auth.get_api_key)):
74+
return{
75+
'active_clients_amount': client.get_active_clients_amount(),
76+
'active_wireless_clients_amount': client.get_active_wireless_clients_amount(),
77+
'active_ethernet_clients_amount': client.get_active_ethernet_clients_amount(),
78+
'active_clients': json.loads(client.get_active_clients())
79+
}
80+
7281
@app.get("/clients/{wireless_interface}", tags=["Clients"])
7382
async def get_clients(wireless_interface, api_key: APIKey = Depends(auth.get_api_key)):
7483
return{
75-
'active_clients_amount': client.get_active_clients_amount(wireless_interface),
76-
'active_clients': json.loads(client.get_active_clients(wireless_interface))
84+
'active_clients_amount': client.get_active_clients_amount_by_interface(wireless_interface),
85+
'active_clients': json.loads(client.get_active_clients_by_interface(wireless_interface))
7786
}
7887

7988
@app.get("/dhcp", tags=["DHCP"])

api/modules/client.py

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,138 @@
11
import subprocess
2+
import re
3+
import os
24
import json
35

4-
def get_active_clients_amount(interface):
6+
import modules.ap as ap
7+
import config
8+
9+
def get_active_wireless_clients_mac(interface):
10+
station_dump = subprocess.run(["iw", "dev", interface, "station", "dump"], capture_output=True, text=True)
11+
12+
macs = []
13+
for line in station_dump.stdout.splitlines():
14+
if line.startswith("Station "):
15+
# Typical format: Station aa:bb:cc:dd:ee:ff (on wlan0)
16+
parts = line.split()
17+
if len(parts) >= 2:
18+
mac = parts[1].strip()
19+
# Optional: basic validation
20+
if len(mac) == 17 and mac.count(":") == 5:
21+
macs.append(mac.upper())
22+
23+
return macs
24+
25+
def get_active_wireless_clients_amount():
26+
ap_interface = ap.interface()
27+
macs = get_active_wireless_clients_mac(ap_interface)
28+
29+
return len(macs)
30+
31+
def get_active_ethernet_clients_mac():
32+
arp_macs = []
33+
34+
arp_output = subprocess.run(['ip', 'neight', 'show'], capture_output=True, text=True)
35+
if arp_output.stdout:
36+
for line in arp_output.stdout.splitlines():
37+
line = line.strip()
38+
if not line:
39+
continue
40+
41+
# Matches lines like:
42+
# 192.168.100.45 dev enp3s0 lladdr 3c:97:0e:12:34:56 REACHABLE
43+
# 192.168.1.120 dev eth0 lladdr 00:1a:2b:3c:4d:5e DELAY
44+
match = re.match(
45+
r'^(\S+)\s+dev\s+(eth[0-9]+|en\w+)\s+lladdr\s+(\S+)\s+(REACHABLE|DELAY|PROBE)',
46+
line
47+
)
48+
if match:
49+
mac = match.group(3).upper()
50+
arp_macs.append(mac)
51+
52+
lease_macs = []
53+
54+
if os.path.isfile(config.DNSMASQ_LEASES):
55+
try:
56+
with open(config.DNSMASQ_LEASES, encoding="utf-8", errors="ignore") as f:
57+
for line in f:
58+
line = line.strip()
59+
if not line or line.startswith("#"):
60+
continue
61+
fields = line.split()
62+
if len(fields) >= 3:
63+
# format: expiry MAC IP hostname ...
64+
mac = fields[1].upper()
65+
lease_macs.append(mac)
66+
except Exception:
67+
pass
68+
69+
active_ethernet_macs = []
70+
for mac in arp_macs:
71+
if mac in lease_macs and mac not in active_ethernet_macs:
72+
active_ethernet_macs.append(mac)
73+
74+
75+
return active_ethernet_macs
76+
77+
def get_active_ethernet_clients_amount():
78+
eth_macs = get_active_ethernet_clients_mac()
79+
return len(eth_macs)
80+
81+
def get_active_clients_amount():
82+
wireless_clients_count = get_active_wireless_clients_amount()
83+
ethernet_clients_count = get_active_ethernet_clients_amount()
84+
85+
return wireless_clients_count + ethernet_clients_count
86+
87+
def get_active_clients():
88+
ap_interface = ap.interface()
89+
wireless_macs = get_active_wireless_clients_mac(ap_interface)
90+
ethernet_macs = get_active_ethernet_clients_mac()
91+
92+
arp_output = subprocess.run(['arp', '-i', ap_interface], capture_output=True, text=True)
93+
arp_mac_addresses = set(line.split()[2] for line in arp_output.stdout.splitlines()[1:])
94+
95+
dnsmasq_output = subprocess.run(['cat', config.DNSMASQ_LEASES], capture_output=True, text=True)
96+
active_clients = []
97+
98+
for line in dnsmasq_output.stdout.splitlines():
99+
fields = line.split()
100+
mac_address = fields[1]
101+
102+
if mac_address in arp_mac_addresses:
103+
normalized_mac = mac_address.upper()
104+
is_wireless = True if normalized_mac in wireless_macs else False
105+
is_ethernet = True if normalized_mac in ethernet_macs else False
106+
107+
client_data = {
108+
"timestamp": int(fields[0]),
109+
"mac_address": fields[1],
110+
"ip_address": fields[2],
111+
"hostname": fields[3],
112+
"client_id": fields[4],
113+
"connection_type": 'wireless' if is_wireless else ('ethernet' if is_ethernet else 'unknown')
114+
}
115+
active_clients.append(client_data)
116+
117+
json_output = json.dumps(active_clients, indent=2)
118+
return json_output
119+
120+
def get_active_clients_amount_by_interface(interface):
5121
arp_output = subprocess.run(['arp', '-i', interface], capture_output=True, text=True)
6-
mac_addresses = mac_addresses = set(line.split()[2] for line in arp_output.stdout.splitlines()[1:])
122+
mac_addresses = set(line.split()[2] for line in arp_output.stdout.splitlines()[1:])
7123

8124
if mac_addresses:
9125
grep_pattern = '|'.join(mac_addresses)
10-
output = subprocess.run(['grep', '-iwE', grep_pattern, '/var/lib/misc/dnsmasq.leases'], capture_output=True, text=True)
126+
output = subprocess.run(['grep', '-iwE', grep_pattern, config.DNSMASQ_LEASES], capture_output=True, text=True)
11127
return len(output.stdout.splitlines())
12128
else:
13129
return 0
14130

15-
def get_active_clients(interface):
131+
def get_active_clients_by_interface(interface):
16132
arp_output = subprocess.run(['arp', '-i', interface], capture_output=True, text=True)
17133
arp_mac_addresses = set(line.split()[2] for line in arp_output.stdout.splitlines()[1:])
18134

19-
dnsmasq_output = subprocess.run(['cat', '/var/lib/misc/dnsmasq.leases'], capture_output=True, text=True)
135+
dnsmasq_output = subprocess.run(['cat', config.DNSMASQ_LEASES], capture_output=True, text=True)
20136
active_clients = []
21137

22138
for line in dnsmasq_output.stdout.splitlines():

0 commit comments

Comments
 (0)