· 6 years ago · Jan 13, 2020, 03:16 PM
1#!/usr/bin/lua
2-- Disconnect WiFi STA (clients) based on their signal levels.
3--
4-- This is designed to work with openwrt's libiwinfo-lua, libuci-lua
5-- and ubus services: network.wireless and hostapd.
6
7local ubus = require("ubus")
8local nixio = require("nixio")
9local iwinfo = require("iwinfo")
10local uci = require("uci")
11
12local remember_gone = 60 -- remember gone STA for this amount of seconds
13
14local openlog_args = {"wifi-disconnect-low-signal"}
15local loglevel = 0
16local loglevels = {"info", "debug"}
17local loglevel_enabled = {}
18
19-- cmdline arguments:
20-- * --stderr: print log to stderr as well as syslog
21-- * -v --verbose: increase verbosity
22for i, a in ipairs(arg) do
23 if i > 0 then
24 if a == "--stderr" then
25 openlog_args[#openlog_args + 1] = "perror"
26 elseif a == "-v" or a == "--verbose" then
27 loglevel = loglevel + 1
28 end
29 end
30end
31
32for i, level in ipairs(loglevels) do
33 loglevel_enabled[level] = i <= loglevel
34end
35
36nixio.openlog(unpack(openlog_args))
37
38function dbg(fmt, ...)
39 if not loglevel_enabled["debug"] then return end
40 local message = fmt:format(unpack(arg))
41 nixio.syslog("debug", message)
42end
43
44function inf(fmt, ...)
45 if not loglevel_enabled["info"] then return end
46 local message = fmt:format(unpack(arg))
47 nixio.syslog("info", message)
48end
49
50function crit(fmt, ...)
51 local message = fmt:format(unpack(arg))
52 nixio.syslog("crit", message)
53end
54
55function gettime()
56 local sec, usec
57 sec, usec = nixio.gettimeofday()
58 return sec
59end
60
61local cursor = uci.cursor()
62if not cursor then
63 crit("Failed to open UCI cursor")
64 os.exit(1)
65end
66
67local conn = ubus.connect()
68if not conn then
69 crit("Failed to connect to ubus")
70 os.exit(1)
71end
72
73local STA = {}
74STA.__index = STA
75
76function STA.new(addr)
77 local self = setmetatable({}, STA)
78 self.addr = addr
79 self.subject = addr:gsub(":", ""):upper()
80 self.signal = 0
81 self.noise = 0
82 self.strikes = strikes or 0
83 self.is_new = true
84 self.disconnected_at = nil
85 self.gone_at = nil
86 return self
87end
88
89function STA:__tostring()
90 local now = gettime()
91 local disconnected, gone
92 if self.disconnected_at == nil then
93 disconnected = ""
94 else
95 disconnected = string.format(
96 ", disconnected=%ds", now - self.disconnected_at)
97 end
98 if self.gone_at == nil then
99 gone = ""
100 else
101 gone = string.format(
102 ", gone=%ds", now - self.gone_at)
103 end
104 return string.format(
105 "%s {" ..
106 "signal=%ddBm, " ..
107 "noise=%ddBm, " ..
108 "snr=%ddBm, " ..
109 "strikes=%d, "..
110 "is_new=%s"..
111 "%s%s}",
112 self.addr,
113 self.signal,
114 self.noise,
115 self.snr,
116 self.strikes,
117 tostring(self.is_new),
118 disconnected,
119 gone)
120end
121
122function STA:update(info)
123 self.signal = info.signal
124 self.noise = info.noise
125 self.snr = self.signal - self.noise
126end
127
128function STA:matches(matcher)
129 local len = matcher.max_len
130 while len >= matcher.min_len do
131 local subject = self.subject:sub(1, len)
132 if matcher[subject] then
133 return true
134 end
135 len = len - 1
136 end
137 return false
138end
139
140-- creates a table with [prefix] = true and sets the prefix length
141-- range as max_len and min_len. Then the matcher should use
142-- string.sub(1, len) for len in closed range min_len to max_len,
143-- checking if the prefix exists in the matcher.
144function prefix_list_matcher(str)
145 if str == nil or str == "" then
146 return nil
147 end
148
149 local matcher = {}
150 local max_len = 0
151 local min_len = 12
152 for p in str:gmatch("([^,]+)") do
153 if p ~= "" then
154 matcher[p] = true
155
156 local plen = p:len()
157 if max_len < plen then
158 max_len = plen
159 end
160 if min_len > plen then
161 min_len = plen
162 end
163 end
164 end
165 if max_len < 1 then
166 return nil
167 end
168
169 matcher.max_len = max_len
170 matcher.min_len = min_len
171
172 return matcher
173end
174
175-- defines an uci config option given its name, how to convert it to
176-- native type and the default value
177function DeviceOpt(name, type, default)
178 return { name = name, type = type, default = default }
179end
180
181local Device = {}
182Device.__index = Device
183Device.conf_options = {
184 signal_connect = DeviceOpt("signal_connect", tonumber, -128),
185 signal_stay = DeviceOpt("signal_stay", tonumber, -128),
186 snr_connect = DeviceOpt("signal_snr_connect", tonumber, 0),
187 snr_stay = DeviceOpt("signal_snr_stay", tonumber, 0),
188 poll_time = DeviceOpt("signal_poll_time", tonumber, 3),
189 strikes = DeviceOpt("signal_strikes", tonumber, 3),
190 drop_reason = DeviceOpt("signal_drop_reason", tonumber, 3),
191 whitelist = DeviceOpt("signal_whitelist", prefix_list_matcher, nil),
192 blacklist = DeviceOpt("signal_blacklist", prefix_list_matcher, nil),
193}
194
195function Device.new(ifname, conf_section)
196 local self = setmetatable({}, Device)
197 self.ifname = ifname
198 local api = iwinfo.type(self.ifname)
199 self.iw = iwinfo[api]
200
201 for key, opt in pairs(Device.conf_options) do
202 local conf = cursor:get("wireless", conf_section, opt.name)
203 self[key] = opt.type(conf) or opt.default
204 end
205
206 self.stas = {} -- addr -> STA
207 self.updated_at = nil
208 return self
209end
210
211function Device:__tostring()
212 local connect, stay
213
214 if self.snr_connect > 0 then
215 connect = string.format("snr_connect=%ddBm", self.snr_connect)
216 else
217 connect = string.format("signal_connect=%ddBm", self.signal_connect)
218 end
219 if self.snr_stay > 0 then
220 stay = string.format("snr_stay=%ddBm", self.snr_stay)
221 else
222 stay = string.format("signal_stay=%ddBm", self.signal_stay)
223 end
224
225 return string.format(
226 "%s {" ..
227 "%s, " ..
228 "%s, " ..
229 "poll_time=%ds, " ..
230 "strikes=%d, " ..
231 "drop_reason=%d}",
232 self.ifname,
233 connect,
234 stay,
235 self.poll_time,
236 self.strikes,
237 self.drop_reason)
238end
239
240function Device:get_sta(addr)
241 local sta = self.stas[addr]
242
243 if sta == nil then
244 sta = STA.new(addr)
245
246 if self.whitelist ~= nil and not sta:matches(self.whitelist) then
247 dbg("%s: ignored %s (whitelist)", self.ifname, addr)
248 return nil
249 end
250
251 if self.blacklist ~= nil and sta:matches(self.blacklist) then
252 dbg("%s: ignored %s (blacklist)", self.ifname, addr)
253 return nil
254 end
255
256 self.stas[addr] = sta
257 elseif sta.is_new then
258 sta.is_new = false
259 end
260
261 return sta
262end
263
264function Device:next_poll()
265 return self.updated_at + self.poll_time
266end
267
268function Device:update_stas(now)
269 if self.updated_at ~= nil and now - self.updated_at < self.poll_time then
270 return nil
271 end
272
273 local stas = {
274 connected = {},
275 disconnected = {},
276 }
277 self.updated_at = now
278
279 local assoclist = self.iw.assoclist(self.ifname)
280 if not assoclist or next(assoclist) == nil then
281 dbg("%s: no associated STAs", self.ifname)
282 self:cleanup_stas(stas.connected)
283 return stas
284 end
285
286 for addr, info in pairs(assoclist) do
287 local sta = self:get_sta(addr)
288 if sta ~= nil and sta.disconnected_at == nil then
289 sta:update(info)
290
291 if self:should_disconnect_sta(sta) then
292 self:disconnect_sta(sta)
293 sta.disconnected_at = now
294 sta.gone_at = now
295 stas.disconnected[#stas.disconnected + 1] = sta
296 stas.disconnected[addr] = sta
297 else
298 sta.gone_at = nil
299 stas.connected[#stas.connected + 1] = sta
300 stas.connected[addr] = sta
301 end
302 end
303 end
304
305 dbg("%s: signal poll: %i STAs, %i disconnected",
306 self.ifname, #stas.connected + #stas.disconnected,
307 #stas.disconnected)
308
309 self:cleanup_stas(stas.connected)
310 return stas
311end
312
313function Device:cleanup_stas(connected)
314 local to_remove = {}
315 local now = self.updated_at
316 for addr, sta in pairs(self.stas) do
317 if connected[addr] == nil then
318 if sta.gone_at == nil then
319 dbg("%s: %s is gone", self.ifname, addr)
320 sta.gone_at = now
321 elseif now - sta.gone_at > remember_gone then
322 to_remove[#to_remove + 1] = addr
323 end
324 end
325 end
326 for _, addr in ipairs(to_remove) do
327 dbg("%s: forget %s", self.ifname, addr)
328 self.stas[addr] = nil
329 end
330end
331
332function Device:should_disconnect_sta(sta)
333 local label, ref_strikes, value
334 if sta.is_new then
335 if self.snr_connect > 0 then
336 label = "snr_connect"
337 else
338 label = "signal_connect"
339 end
340 ref_strikes = 0
341 else
342 if self.snr_stay > 0 then
343 label = "snr_stay"
344 else
345 label = "signal_stay"
346 end
347 ref_strikes = self.strikes
348 end
349
350 local ref = self[label]
351 local value = sta[label:gsub("_%a+$", "")]
352
353 function msg(status)
354 dbg("%s: %s signal=%ddBm snr=%ddBm strikes=%d " ..
355 "(threshold: %s=%d strikes=%d): %s",
356 self.ifname, sta.addr, sta.signal, sta.snr, sta.strikes,
357 label, ref, ref_strikes, status)
358 end
359
360 if ref <= value then
361 msg("good signal")
362 sta.strikes = 0
363 return false
364 end
365
366 sta.strikes = sta.strikes + 1
367 if ref_strikes > sta.strikes then
368 msg("bad signal")
369 return false
370 end
371
372 msg("should disconnect")
373 return true
374end
375
376function Device:disconnect_sta(sta)
377 local endpoint = "hostapd." .. self.ifname
378 conn:call(endpoint, "del_client", {
379 addr = sta.addr,
380 reason = self.drop_reason,
381 deauth = false,
382 ban_time = 0,
383 })
384 inf("%s: disconnected %s", self.ifname, tostring(sta))
385end
386
387-- returns an array of all enabled devices as instances of Device.
388function get_wifi_devices()
389 local wireless_status = conn:call("network.wireless", "status", {})
390 local devices = {}
391 for name, radio in pairs(wireless_status) do
392 if radio.disabled then
393 dbg("%s: disabled by config file", name)
394 else
395 for _, iface in ipairs(radio.interfaces) do
396 local dev = Device.new(iface.ifname, iface.section)
397 if dev ~= nil then
398 devices[#devices + 1] = dev
399 dbg("%s: %s", name, tostring(dev))
400 end
401 end
402 end
403 end
404 return devices
405end
406
407function update_devices_and_sleep(devices)
408 local now = gettime()
409 local next_poll_time = now + 60
410 for _, dev in ipairs(devices) do
411 dev:update_stas(now)
412 local dev_next_poll_time = dev:next_poll()
413 if next_poll_time > dev_next_poll_time then
414 next_poll_time = dev_next_poll_time
415 end
416 end
417
418 local seconds = next_poll_time - now
419 if seconds > 0 then
420 nixio.nanosleep(seconds)
421 end
422end
423
424nixio.signal(3, "dfl")
425nixio.signal(15, "dfl")
426
427local devices = {}
428while #devices == 0 do
429 devices = get_wifi_devices()
430 dbg("found %d enabled WiFi devices", #devices)
431end
432
433while true do
434 local _, err = pcall(function () update_devices_and_sleep(devices) end)
435 if err ~= nil then
436 dbg("quit: %s", tostring(err))
437 break
438 end
439end
440
441nixio.closelog()
442conn:close()