Repository navigation
Expand file tree
/
Copy pathauth.lua
More file actions
299 lines (271 loc) · 14.9 KB
/
Copy pathauth.lua
File metadata and controls
299 lines (271 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
-- Обработчик auth-запросов (script.auth) - вызывается на каждый Access-Request,
-- должен определять function authorize(request) -> table.
--
-- ПОЛЯ request:
-- request.nas_ip - NAS-IP-Address (IP свитча/relay, приславшего запрос)
-- request.nas_name - NAS-Identifier
-- request.device_mac - мак абонентского устройства (User-Name)
-- request.dhcp_server_name - Called-Station-Id
-- request.dhcp_server_id - Calling-Station-Id
-- request.ip_address - Framed-IP-Address из запроса, если был
-- request.class_id - Class
-- request.option - таблица (пустая, если в пакете нет option82)
-- request.option.remote_id - мак свитча/OLT из option82 Agent-Remote-Id (AA:BB:CC:DD:EE:FF) или nil
-- request.option.circuit_id - option82 Agent-Circuit-Id, hex-строка без "0x", или nil
--
-- ВОЗВРАТ authorize():
-- ip_address - выдать конкретный IP (приоритетнее pool_name, если заданы оба)
-- pool_name - выдать имя пула (DHCP-сервер сам берёт IP из пула)
-- lease_time_sec - время аренды в секундах (Session-Timeout)
-- extra_attributes - таблица {["Mikrotik-Address-List"]="...", ...} с
-- дополнительными RADIUS-атрибутами в ответ (полное имя ->
-- значение, включая стандартный "Reply-Message" - см.
-- replyMessage()/attach() ниже), список поддерживаемых
-- имён см. attrTypes на стороне Go. Единственное поле,
-- которое применяется даже вместе с error/reject
-- error - ip_address/pool_name/lease_time_sec игнорируются. Значит
-- "этот конкретный запрос не может быть обслужен" (например
-- не распарсился circuit_id) - клиенту отправляется явный
-- Access-Reject
-- reject - как error, приоритетнее него, если заданы оба. Явное
-- бизнес-решение отказать этому устройству (например
-- заблокировано) - клиенту отправляется явный Access-Reject.
-- Текст сам в ответ не попадает - см. reject() ниже, который
-- дублирует его в Reply-Message
--
-- ГЛОБАЛЬНЫЕ ОБЪЕКТЫ:
-- db:getDeviceByMac(mac) -> table{ip, mac, parse_type} | nil
-- поиск в script.database.devices_url по мак-адресу свитча/OLT
-- db:getBind(source, mac, device_mac, port) -> array of table{ip, port, client_mac, device_mac}
-- source - имя источника из script.database.binds (например "clients"/"smart")
-- mac/device_mac/port - опциональны ("" или не передавать), но нужен либо mac,
-- либо пара device_mac+port - иначе вернётся пустой массив
-- log.debug(msg) / log.info(msg) / log.notice(msg) / log.warning(msg) / log.error(msg)
-- пишут в общий лог сервера с соответствующим уровнем
--
-- db обязателен при processor: script (см. script.database.devices_url в конфиге)
local CREDIT_HOUR = 6 -- час, после которого лиз считается "до утра"
local TIMEOUT_WIFI = 1800 -- незарегистрированный wifi-мак (66:99:...)
local TIMEOUT_FAKE = 120 -- совсем неизвестное устройство
-- лиз INET считается до ближайших CREDIT_HOUR по местному времени, минимум 120с
local function leaseTime()
local hour = tonumber(os.date("%H"))
local lease
if hour >= CREDIT_HOUR then
lease = (24 - hour + CREDIT_HOUR) * 3600
else
lease = (CREDIT_HOUR - hour) * 3600
end
if lease < 120 then
lease = 120
end
return lease
end
local function hexByte(hex, pos, len)
if pos + len - 1 > #hex then
return nil
end
return tonumber(hex:sub(pos, pos + len - 1), 16)
end
-- hex-строка -> «сырые» байты как текст (для ZTE, где значения зашиты ascii-строкой s=..;p=..)
local function hexToStr(hex)
if #hex % 2 == 1 then
hex = hex .. "0"
end
local chars = {}
for i = 1, #hex - 1, 2 do
chars[#chars + 1] = string.char(tonumber(hex:sub(i, i + 1), 16))
end
return table.concat(chars)
end
-- парсер возвращает vlan, stack, port. nil без причины - circuit_id не распарсился
-- (error), nil + 4-й параметр-причина - запрос заведомо не обслуживается (reject)
local circuitParsers = {}
circuitParsers["zte"] = function(circuit)
local raw = hexToStr((circuit:gsub("^0[xX]", "")))
-- текстовый circuit_id (s=..;p=..;o=..;v=..) OLT ставит только на ONU через
-- port-identification, с физического eth-порта (gei/xgei) приходит бинарный
-- option82 от dhcp relay
if not raw:find("v=%d") then
return nil, nil, nil, "request from eth port, only ONU allowed"
end
local vlan = tonumber(raw:match("v=(%d+)"))
if not vlan then
return nil
end
local stack = tonumber(raw:match("s=(%d+)")) or 0
local p = tonumber(raw:match("p=(%d+)")) or 0
local o = tonumber(raw:match("o=(%d+)")) or 0
return vlan, stack, stack * 100000 + p * 1000 + o
end
circuitParsers["edgecore"] = function(circuit)
local stack, port, vlan = hexByte(circuit, 1, 2), hexByte(circuit, 3, 2), hexByte(circuit, 5, 4)
if not vlan or not stack or not port then
return nil
end
return vlan, stack, port
end
circuitParsers["bdcom"] = function(circuit)
if #circuit == 8 then
local stack, port, vlan = hexByte(circuit, 1, 2), hexByte(circuit, 3, 2), hexByte(circuit, 5, 4)
if not vlan or not stack or not port then
return nil
end
return vlan, stack, port
end
local vlan, stack, portRaw = hexByte(circuit, 1, 4), hexByte(circuit, 7, 2), hexByte(circuit, 9, 2)
if not vlan or not stack or not portRaw then
return nil
end
return vlan, stack, stack * 1000 + portRaw
end
circuitParsers["cdata"] = function(circuit)
local vlan, stack, portRaw = hexByte(circuit, 1, 4), hexByte(circuit, 7, 2), hexByte(circuit, 9, 2)
if not vlan or not stack or not portRaw then
return nil
end
return vlan, stack, stack * 1000 + portRaw
end
circuitParsers["dlink"] = function(circuit)
local vlan, stack, port = hexByte(circuit, 5, 4), hexByte(circuit, 9, 2), hexByte(circuit, 11, 2)
if not vlan or not stack or not port then
return nil
end
return vlan, stack, port
end
local function circuitReader(circuit, parseType)
if not circuit or circuit == "" or not parseType or parseType == "" then
return nil
end
local parser = circuitParsers[parseType:lower()]
if not parser then
return nil
end
local vlan, stack, port, rejectReason = parser(circuit)
if not vlan then
return nil, nil, nil, rejectReason
end
return vlan, stack, port
end
-- IP-заглушки в binds - не настоящие адреса, а маркеры "весь порт отдан под
-- общий сервисный пул". 2.2.2.2/4.4.4.4/5.5.5.5 включают соответствующий флаг,
-- 1.1.1.1/3.3.3.3 зарезервированы под будущие сервисы - сейчас ни на что не
-- влияют, только исключаются из списка реальных привязок
local FLAG_BY_IP = { ["2.2.2.2"] = "iptv", ["4.4.4.4"] = "wifi", ["5.5.5.5"] = "youtube" }
local RESERVED_IPS = { ["1.1.1.1"] = true, ["3.3.3.3"] = true }
local function getPortBinds(macSw, port)
local all = db:getBind("clients", "", macSw, port)
local binds, flags = {}, {}
for _, b in ipairs(all) do
local flag = FLAG_BY_IP[b.ip]
if flag then
flags[flag] = true
elseif not RESERVED_IPS[b.ip] then
binds[#binds + 1] = b
end
end
return binds, flags
end
-- Reply-Message (через extra_attributes, см. attrTypes в radius/extra_attributes) -
-- диагностическая строка, формат идентичен legacy script.pl (authenticate():
-- $RAD_REPLY{'Reply-Message'}), чтобы её мог разобрать тот же парсер, что уже
-- читает Reply-Message прод-сервера (см. tools/pcapreplay) - не влияет на выдачу
-- IP/пула, только для сверки парсинга circuit_id. Отправляется клиенту на Accept
-- и на Reject одинаково (см. radius/handler.go)
local function replyMessage(vlan, stack, port, macSw, parseType)
local function s(v)
if v == nil then return "" end
return tostring(v)
end
return "vlan=" .. s(vlan) .. ";stack=" .. s(stack) .. ";port=" .. s(port) ..
";macSw=" .. s(macSw) .. ";parse-type=" .. s(parseType)
end
-- attach добавляет Reply-Message в extra_attributes возвращаемой таблицы, не
-- затирая уже выставленные там ключи (например Mikrotik-Address-List)
local function attach(rm, t)
t.extra_attributes = t.extra_attributes or {}
t.extra_attributes["Reply-Message"] = rm
return t
end
local function reject(msg)
return attach(msg:sub(1, 253), { reject = msg })
end
function authorize(request)
local macAbon = request.device_mac
local macSw = request.option.remote_id or ""
local circuitId = request.option.circuit_id or ""
local parseType
if macSw ~= "" then
local device = db:getDeviceByMac(macSw)
if not device then
log.warning("authorize: mac=" .. macAbon .. " mac_sw=" .. macSw .. " reject: device not found")
return reject("device not found: mac_sw=" .. macSw)
end
parseType = device.parse_type
else
log.warning("authorize: mac=" .. macAbon .. " mac_sw=<UNKNOWN> reject: no remote_id")
return reject("no remote_id: circuit_id=" .. circuitId)
end
local vlan, stack, port, rejectReason = circuitReader(circuitId, parseType)
if rejectReason then
log.warning("authorize: mac=" .. macAbon .. " mac_sw=" .. macSw .. " parse_type=" .. tostring(parseType) .. " reject: " .. rejectReason)
return reject(rejectReason .. ": mac_sw=" .. macSw .. " circuit_id=" .. circuitId)
end
log.debug("authorize: mac=" .. macAbon .. " mac_sw=" .. macSw ..
" parse_type=" .. tostring(parseType) .. " vlan=" .. tostring(vlan) .. " port=" .. tostring(port))
-- vlan=0 - тоже "не распарсилось": в legacy Perl-скрипте (script.pl:authenticate)
-- проверка "if(!$vlan)" ловит и undef, и 0 (в Perl 0 - falsy), а Lua 0 - truthy,
-- поэтому голым "if not vlan" эта ветка не отлавливалась - подтверждено сверкой
-- через tools/pcapreplay на реальном трафике: vlan=0 из circuit_id всегда даёт
-- Access-Reject на проде, мы же ошибочно выдавали Accept на бессмысленный
-- "INET-0-FAKE" пул
if not vlan or vlan == 0 then
log.warning("authorize: mac=" .. macAbon .. " mac_sw=" .. macSw .. " parse_type=" .. tostring(parseType) .. " vlan=" .. tostring(vlan) .. " reject: circuit_id parse failed")
return {
error = "circuit_id parse failed: mac_sw=" .. macSw .. " parse_type=" .. tostring(parseType) .. " circuit_id=" .. circuitId,
extra_attributes = { ["Reply-Message"] = replyMessage(vlan, stack, port, macSw, parseType) },
}
end
local rm = replyMessage(vlan, stack, port, macSw, parseType)
local leaseInet = leaseTime()
local portBinds, flags = getPortBinds(macSw, port)
-- portBinds содержит таблицу всех привязок на порту.
-- если на порту только 1 привязка - не смотрим на мак, сразу же устанавливаем IP и возвращаем клиенту
-- если привязок >1, то ищем точное совпадение по маку, иначе - ничего не выдаем
local ipAddress = nil
if #portBinds == 1 then
ipAddress = portBinds[1].ip
elseif #portBinds > 1 then
for _, b in ipairs(portBinds) do
if b.client_mac == macAbon then
ipAddress = b.ip
break
end
end
end
if ipAddress then
return attach(rm, { ip_address = ipAddress, lease_time_sec = leaseInet })
end
-- шаг 2: свою привязку не выдали - порт целиком под общим сервисным пулом?
-- порядок приоритета фиксирован: youtube, потом wifi, потом iptv
if flags.youtube then
return attach(rm, { pool_name = "YOUTUBE-" .. vlan, lease_time_sec = leaseInet, extra_attributes = { ["Mikrotik-Address-List"] = "Triolan.Youtube" } })
end
if flags.wifi then
return attach(rm, { pool_name = "INET-" .. vlan .. "-FAKE", lease_time_sec = leaseInet, extra_attributes = { ["Mikrotik-Address-List"] = "Triolan.Wifi" } })
end
if flags.iptv then
return attach(rm, { pool_name = "INET-" .. vlan .. "-FAKE", lease_time_sec = leaseInet, extra_attributes = { ["Mikrotik-Address-List"] = "Triolan.IPTV" } })
end
-- шаг 3: smart-устройство (абонентский wifi-роутер и т.п.) по мак абонента,
-- затем незарегистрированный wifi-мак (66:99:...), и в самом конце - совсем
-- неизвестное устройство
local smart = db:getBind("smart", macAbon)
if #smart > 0 then
return attach(rm, { ip_address = smart[1].ip, lease_time_sec = leaseInet })
end
if macAbon:match("^66:99:") then
return attach(rm, { pool_name = "INET-" .. vlan .. "-WIFI", lease_time_sec = TIMEOUT_WIFI })
end
return attach(rm, { pool_name = "INET-" .. vlan .. "-FAKE", lease_time_sec = TIMEOUT_FAKE })
end