#!/opt/lnxall_app/bin/lua -- Created by jiaqiang.ye@lnxall.com -- Simple static route monitor Service -- 2025/11/27 local bit32 = require "bit32" local cjson = require "cjson" local invoker = require "invoker" local gfmt = string.format local g_mcfg = "/app/config/mroute.json" local g_ipmap = {} -- ip-address map for duplication detection local g_runtime = 0x7FFFFFFF -- how-long (in seconds) the application should run local g_rlist = nil local g_pid = nil local g_outfd = nil local function nslookup(hostn) local iflags = bit32.bor(invoker.NOWAIT, invoker.NOSTDIO, invoker.OUTPUT) local pid, pfd = invoker.invoke(iflags, "busybox", "nslookup", hostn) if not pid then return nil end local swait = invoker.uptimsec() local nwait, exited = swait, false swait = swait + 12000 while nwait < swait do if invoker.waitpid(pid, true) then exited = true break end invoker.msleep(250) nwait = invoker.uptimsec() end if not exited then invoker.kill(pid, 9) invoker.waitpid(pid, false) end invoker.nonblock(pfd, true) local output = invoker.readfd(pfd, 8192) if type(pfd) == "number" then invoker.closefd(pfd); pfd = nil end if type(output) ~= "string" then return nil end local iptab, ipn, ans = {}, 0, false for line in string.gmatch(output, "[^\r\n]+") do if not ans then if string.find(line, "answer:", 1, true) then ans = true end else local ipv4 = string.match(line, "Address:%s+([%d%.]+)") if ipv4 and invoker.isipv4(ipv4) then ipn = ipn + 1 iptab[ipn] = ipv4 end end end if ipn == 0 then return nil end return iptab end local function is_ipv4_addr(w, how_many_secs) if type(w) ~= "string" or #w == 0 then return false end if g_ipmap[w] then return false end if invoker.isipv4(w) then g_ipmap[w] = true return w end local ipv4, mask = string.match(w, "^([%d%.]+)/(%d+)$") if invoker.isipv4(ipv4) and mask then g_ipmap[w] = true return w end local iplist = nslookup(w) if not iplist then g_runtime = how_many_secs io.stderr:write(gfmt("Error, failed to resolve hostname: %s, restart in %s seconds\n", w, g_runtime)) io.stderr:flush() return false end if invoker.isipv4(iplist[1]) then io.stdout:write(gfmt("INFO: hostname resolved: %s => %s\n", w, iplist[1])) io.stdout:flush() w = iplist[1] if g_ipmap[w] then return false end g_ipmap[w] = true return w end return false end local function is_netdevice(w) if type(w) ~= "string" then return false end return invoker.invoke(invoker.NOSTDIO, "ip", "link", "show", "dev", w) == 0 end local function default_gateway(device, output) if type(device) ~= "string" or #device == 0 then return nil end if type(output) ~= "string" then _, output = invoker.invoke(invoker.OUTPUT, "ip", "route", "show") if type(output) ~= "string" then output = "" end end local regex = "default%s+via%s+([%d%.]+)%s+dev%s+" .. device .. "%s+" local gwipaddr = string.match(output, regex) if invoker.isipv4(gwipaddr) then return gwipaddr end return nil end local function entry_clear(entry) local dcmd = entry["delcmd"] if type(dcmd) == "table" then invoker.invoke(invoker.NOSTDIO, dcmd) invoker.invoke(invoker.NOSTDIO, dcmd) end entry["gateway"] = nil entry["matchstr"] = nil entry["addcmd"] = nil entry["delcmd"] = nil end local function entry_refresh(entry, gateway) local addcmd = { entry.address, "via", gateway, "dev", entry.device, "proto", "static" } local mstr = table.concat(addcmd, " ") table.insert(addcmd, 1, "add") table.insert(addcmd, 1, "route") table.insert(addcmd, 1, "ip") io.stdout:write(gfmt("Static route => %s\n", table.concat(addcmd, " "))) io.stdout:flush() local delcmd = {} -- perform deep copy for `addcmd table for i, cmd in ipairs(addcmd) do delcmd[i] = cmd end delcmd[3] = "del" local dcmd = entry["delcmd"] if type(dcmd) == "table" then invoker.invoke(invoker.NOSTDIO, dcmd) invoker.invoke(invoker.NOSTDIO, dcmd) end entry["gateway"] = gateway entry["matchstr"] = mstr entry["addcmd"] = addcmd entry["delcmd"] = delcmd end local function entry_create(addr, gateway, dev) local isaddr = invoker.isipv4(gateway) if gateway and not isaddr then local gwstr = tostring(gateway) if type(gwstr) ~= "string" then gwstr = "WTF" end io.stderr:write(gfmt("Error, invalid gateway specified: %s\n", gwstr)) io.stderr:flush() return nil end local entry = { ["address"] = addr, ["device"] = dev, } if isaddr then entry["fixedgw"] = true entry_refresh(entry, gateway) else entry["fixedgw"] = false end return entry end local function mroute_init(rcfg, howlong) invoker.setenv("LANG", nil) invoker.setname("STATIC_ROUTE") invoker.setenv("PATH", "/opt/lnxall_app/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") if invoker.statfile(rcfg) ~= "regular" then rcfg = g_mcfg end local cfgd = invoker.readfile(rcfg, nil, 0x80000) if not cfgd or string.len(cfgd) == 0 then io.stderr:write(gfmt("Error, failed to read configuration file: %s\n", rcfg)) io.stderr:flush() return false end local okay, cfgj = pcall(cjson.decode, cfgd) if not okay or type(cfgj) ~= "table" then io.stderr:write(gfmt("Error, failed to decode as JSON: %s\n", rcfg)) io.stderr:flush() return false end if type(cfgj.static) ~= "table" then io.stderr:write(gfmt("Error, failed to find static list in %s\n", rcfg)) io.stderr:flush() return false end local howmsec = 600 if type(howlong) == "string" and #howlong > 0 then howlong = tonumber(howlong) if type(howlong) == "number" and howlong > 0 and howlong < 86400 then howmsec = howlong end end if invoker.uptime() <= 240 then howmsec = 30 end cfgj = cfgj.static local idx, empty = 0, {} local rlist, rnum = {}, 0 while idx < 256 do idx = idx + 1 local item = cfgj[idx] if type(item) == "table" then local ipaddr, isnet = nil, is_netdevice(item.device) if isnet then ipaddr = is_ipv4_addr(item.address, howmsec) end if ipaddr and isnet then local newent = entry_create(ipaddr, item.gateway, item.device) if newent then rnum = rnum + 1 rlist[rnum] = newent end else local buf = cjson.encode(item) io.stderr:write(gfmt("Error, invalid configuration at %d =>\n\t%s\n", idx, buf)) io.stderr:flush() end end end if rnum == 0 then io.stderr:write(gfmt("Error, no static route rules found in: %s\n", rcfg)) io.stderr:flush() return false end g_rlist = rlist return true end local function check_and_add() local _, output = invoker.invoke(invoker.OUTPUT, "ip", "route", "show") if type(output) ~= "string" then output = "" end local count = 0 for _, ent in ipairs(g_rlist) do if not ent.fixedgw then local gwip = default_gateway(ent.device, output) if not gwip then entry_clear(ent) count = count + 1 elseif gwip ~= ent.gateway then entry_refresh(ent, gwip) end end local found = true if ent.matchstr then found = string.find(output, ent.matchstr, 1, true) end if not found then io.stdout:write(gfmt("Adding static route:\n\t%s\n", ent.matchstr)) io.stdout:flush() invoker.invoke(invoker.NOSTDIO, ent.delcmd) invoker.invoke(invoker.NOSTDIO, ent.delcmd) invoker.invoke(0, ent.addcmd) count = count + 1 end end return count end local function start_ipmonitor() local iflags = bit32.bor(invoker.NOWAIT, invoker.OUTPUT) local pid, outfd = invoker.invoke(iflags, "ip", "-ts", "monitor") if type(pid) ~= "number" or pid <= 0 then io.stderr:write("Error, failed to start ip monitor process.\n") io.stderr:flush() return false end invoker.nonblock(outfd, true) g_pid, g_outfd = pid, outfd return true end local function read_ipmon() local outres = invoker.readfd(g_outfd, 16384) if not outres or #outres == 0 then local exited = invoker.waitpid(g_pid, true) if exited == nil or exited then io.stderr:write(gfmt("Warning, IP-monitor process has exited: %d\n", g_pid)) io.stderr:flush() return nil, true end return nil, nil end local output = {} while true do local more = invoker.readfd(g_outfd, 16384) if not more or #more == 0 then break end output[#output + 1] = more end if #output > 0 then output = outres .. table.concat(output) else output = outres end for _, ent in ipairs(g_rlist) do local mstr = ent.matchstr if mstr then local needle = "Deleted " .. mstr if string.find(output, needle, 1, true) then return true, nil end end local ndev = ent.device local regex = "Deleted[^\r\n]+%s+" .. ndev if string.match(output, regex) then io.stdout:write(gfmt("INFO: static route has been deleted for %s\n", ndev)) io.stdout:flush() return true, nil end regex = "default via[^\r\n]+dev%s+" .. ndev if string.match(output, regex) then io.stdout:write(gfmt("INFO: default gateway updated for %s\n", ndev)) io.stdout:flush() return true, nil end end return nil, nil end local function mainfunc() check_and_add() if not start_ipmonitor() then return false end local itercnt, inval = 0, 3600 local last_check = invoker.uptime() local start_time = last_check while true do itercnt = itercnt + 1 if itercnt <= 8 then inval = 120 else inval = 1800 end local recheck, term = read_ipmon() if recheck or (invoker.uptime() - last_check) >= inval then check_and_add() last_check = invoker.uptime() if recheck then itercnt = 0 end end if term then break end if (invoker.uptime() - start_time) >= g_runtime then break end invoker.waitsec(5) end end if not mroute_init(arg[1], arg[2]) then os.exit(1) end mainfunc()