#!/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

	local deleted = "Deleted "
	if not string.find(output, deleted, 1, true) then
		return nil, nil
	end

	for _, ent in ipairs(g_rlist) do
		local needle = deleted .. ent.matchstr
		if string.find(output, needle, 1, true) then
			return true, nil
		end

		local regex = "Deleted[^\r\n]+%s" .. ent.device .. "[\r\n%s]"
		if string.match(output, regex) then
			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 = 3600 end

		local recheck, term = read_ipmon()
		local now = invoker.uptime()
		if recheck or (now - last_check) >= inval then
			last_check = now
			if check_and_add() > 0 then itercnt = 0 end
		end
		if term then break end
		if (now - 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()
