#!/opt/lnxall_app/bin/lua

-- Created by jiaqiang.ye@lnxall.com
-- Simple Dynamic NAT for LAN(br-lan)
-- 2025/11/17

local uci        = require "uci"
local bit32      = require "bit32"
local invoker    = require "invoker"
local gfmt       = string.format

local g_appname    = "DYNNAT"
local g_etccfg     = "/etc/config"
local g_pidfile    = "/tmp/.dynnat.pid"
local g_iptables   = "iptables"

local g_iptcmd     = {}
local g_has_docker = nil

local function dynnat_init()
	invoker.chdir("/")
	invoker.setname(g_appname)
	invoker.setenv("PATH", "/opt/lnxall_app/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin/bin")

	-- decide which to use
	local legacy = { [1] = "/usr/sbin/iptables-legacy", [2] = "/usr/bin/iptables-legacy",
		[3] = "/sbin/iptables-legacy", [4] = "/bin/iptables-legacy", }
	for _, ipt in ipairs(legacy) do
		if invoker.statfile(ipt) == "regular" then
			g_iptables = "iptables-legacy"
			io.stdout:write(gfmt("Warning, using iptables-legacy instead of iptables.\n"))
			io.stdout:flush()
			break
		end
	end

	local pid = invoker.readlink("/proc/self")
	if not pid or #pid == 0 then
		io.stderr:write("Error, PID not found!\n")
		io.stderr:flush()
		return false
	end

	local locker = invoker.waitlock(g_pidfile)
	if not locker then
		io.stderr:write(gfmt("Error, failed to lock file: %s\n", g_pidfile))
		io.stderr:flush()
		return false
	end

	-- check for existing running instance
	local oldpid = invoker.readfile(g_pidfile, invoker.TRIMEND)
	if oldpid then oldpid = tonumber(oldpid) end
	if type(oldpid) == "number" and oldpid > 0 then
		local name = invoker.readfile(gfmt("/proc/%d/comm", oldpid), invoker.TRIMEND)
		if name == g_appname then
			io.stderr:write(gfmt("Error, application already running, PID: %d\n", oldpid))
			io.stderr:flush()
			invoker.closefd(locker)
			return false
		end
	end

	local fh = io.open(g_pidfile, "wb")
	if fh then
		fh:write(gfmt("%s\n", pid))
		fh:close(); fh = nil
	end
	invoker.closefd(locker)

	fh = io.open("/proc/sys/net/ipv4/ip_forward", "wb")
	if fh then
		fh:write("1\n")
		fh:close(); fh = nil
	end
	return true
end

local function get_brblan_dev()
	local xuci = uci.cursor(g_etccfg)
	if not xuci then
		io.stderr:write(gfmt("Error, uci.cursor(%s) has failed.\n", g_etccfg))
		io.stderr:flush()
		return nil
	end

	local ndev = xuci:get("network", "lan", "device")
	xuci:close(); xuci = nil

	if type(ndev) == "string" and #ndev > 0 then
		return ndev
	end
	return nil
end

local function ipv4_maskit(ipaddr, mask)
	local a, b, c, d = string.match(ipaddr, "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
	if a and b and c and d then
		a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
	end

	if not (a and b and c and d) then
		io.stderr:write(gfmt("Error, invalid ipv4 address: %s\n", ipaddr))
		io.stderr:flush()
		return nil
	end

	a = bit32.lshift(a, 24)
	b = bit32.lshift(b, 16)
	c = bit32.lshift(c, 8)
	d = bit32.bor(a, b, c, d)
	mask = bit32.lshift(0x1, 32 - mask)
	mask = bit32.bxor(mask - 1, 0xffffffff)
	d = bit32.band(mask, d)
	a = bit32.band(bit32.rshift(d, 24), 0xff)
	b = bit32.band(bit32.rshift(d, 16), 0xff)
	c = bit32.band(bit32.rshift(d,  8), 0xff)
	d = bit32.band(d, 0xff)
	return gfmt("%d.%d.%d.%d", a, b, c, d)
end

local function brlan_nat_clear()
	for _, iptcmd in ipairs(g_iptcmd) do
		if type(iptcmd) == "table" and type(iptcmd.dcmd) == "table" then
			invoker.invoke(0, iptcmd.dcmd)
			invoker.invoke(0, iptcmd.dcmd)
			iptcmd.ecmd, iptcmd.dcmd = nil, nil
			iptcmd.isnat, iptcmd.chkc = nil, nil
		end
	end
	g_iptcmd[0], g_iptcmd[1] = nil, nil
	g_iptcmd[2], g_iptcmd[3] = nil, nil
	g_iptcmd[4], g_iptcmd[5] = nil, nil
end

local function iptables_S(isnat)
	local output = nil
	local iflags = bit32.bor(invoker.OUTPUT, invoker.BUFSIZ, 0x200000)
	if isnat then
		_, output = invoker.invoke(iflags, g_iptables, "-t", "nat", "-S")
	else
		_, output = invoker.invoke(iflags, g_iptables, "-S")
	end

	local list = {}
	if type(output) ~= "string" then output = "" end
	if string.find(output, "DOCKER-USER", 1, true) then
		g_has_docker = true
	end

	for line in string.gmatch(output, "[^\r\n]+") do
		list[#list + 1] = line
	end
	return list
end

local function ipt_contains(iptlist, what)
	for _, iptline in ipairs(iptlist) do
		if what == iptline then
			return true
		end
	end
	return false
end

local function check_existing(cmdtab)
	local found, total = 0, 0
	local info, info_nat = nil, nil
	for _, cmd in ipairs(cmdtab) do
		total = total + 1
		if cmd.isnat then
			if info_nat == nil then info_nat = iptables_S(true) end
			if cmd.chkc and ipt_contains(info_nat, cmd.chkc) then
				found = found + 1
			end
		else
			if info == nil then info = iptables_S(false) end
			if cmd.chkc and ipt_contains(info, cmd.chkc) then
				found = found + 1
			end
		end
	end

	if info == nil then iptables_S(false) end
	if g_has_docker == nil then g_has_docker = false end
	return found == total
end

local function brlan_nat(ndev, verbose)
	local okay, output = invoker.invoke(invoker.OUTPUT + invoker.NOSTDIO,
		"ip", "addr", "show", "dev", ndev)
	if okay ~= 0 or type(output) ~= "string" then
		if verbose then
			io.stderr:write(gfmt("Error, IP address not found for %s\n", ndev))
			io.stderr:flush()
		end
		return false
	end

	local ipv4, nmask = string.match(output, "inet%s+([%d%.]+)/(%d+)%s+")
	if nmask then nmask = tonumber(nmask) end
	if not invoker.isipv4(ipv4) or type(nmask) ~= "number" or nmask <= 0 or nmask >= 32 then
		if verbose then
			io.stderr:write(gfmt("Error, ipv4 address not found for %s\n", ndev))
			io.stderr:flush()
		end
		return false
	end

	local ipv4m = ipv4_maskit(ipv4, nmask)
	if not ipv4m then
		io.stderr:write(gfmt("Error, invalid ipv4 address: %s\n", ipv4))
		io.stderr:flush()
		return false
	end

	ipv4m = gfmt("%s/%d", ipv4m, nmask)
	-- io.stderr:write(gfmt("INFO: Translated ipv4 address: %s/%d => %s\n", ipv4, nmask, ipv4m))
	-- io.stderr:flush()

	local newcmd, ecmd, dcmd, chkc = {}, nil, nil, nil
	if g_has_docker == nil or g_has_docker then
		ecmd = {             "-A", "DOCKER-USER", "-i", ndev, "-j", "ACCEPT" }
		dcmd = { g_iptables, "-D", "DOCKER-USER", "-i", ndev, "-j", "ACCEPT" }
		chkc = table.concat(ecmd, " ")
		ecmd[1] = "-I"; table.insert(ecmd, 1, g_iptables)
		newcmd[#newcmd + 1] = { ["ecmd"] = ecmd, ["dcmd"] = dcmd, ["chkc"] = chkc }

		ecmd = {             "-A", "DOCKER-USER", "-o", ndev, "-m", "state",
			"--state", "RELATED,ESTABLISHED", "-j", "ACCEPT" }
		dcmd = { g_iptables, "-D", "DOCKER-USER", "-o", ndev, "-m", "state",
			"--state", "RELATED,ESTABLISHED", "-j", "ACCEPT" }
		chkc = table.concat(ecmd, " ")
		ecmd[1] = "-I"; table.insert(ecmd, 1, g_iptables)
		newcmd[#newcmd + 1] = { ["ecmd"] = ecmd, ["dcmd"] = dcmd, ["chkc"] = chkc}
	end

	ecmd = {                          "-A", "POSTROUTING", "-s", ipv4m, "-j", "MASQUERADE" }
	dcmd = { g_iptables, "-t", "nat", "-D", "POSTROUTING", "-s", ipv4m, "-j", "MASQUERADE" }
	chkc = table.concat(ecmd, " ")
	ecmd[1] = "-I"; table.insert(ecmd, 1, "nat")
	table.insert(ecmd, 1, "-t"); table.insert(ecmd, 1, g_iptables)
	newcmd[#newcmd + 1] = { ["ecmd"] = ecmd, ["dcmd"] = dcmd, ["chkc"] = chkc, ["isnat"] = true }

	if check_existing(newcmd) then
		io.stderr:write(gfmt("%s: INFO: no need to update iptables.\n", os.date()))
		io.stderr:flush()
		if #g_iptcmd == 0 then g_iptcmd = newcmd end
		return true
	end

	brlan_nat_clear()
	for _, cmd in ipairs(newcmd) do
		invoker.invoke(invoker.NOSTDIO, cmd.dcmd)
		invoker.invoke(invoker.NOSTDIO, cmd.dcmd)
		local okay = invoker.invoke(0, cmd.ecmd)
		if type(okay) ~= "number" then okay = -1 end
		io.stdout:write(gfmt("Command executed => %d\n\t%s\n", okay, table.concat(cmd.ecmd, " ")))
		io.stdout:flush()
	end

	g_iptcmd = newcmd
	return true
end

local function get_internval(delta)
	if delta < 60 then return 15000 end
	if delta < 600 then return 60000 end
	return 1200000
end

local function mainfunc()
	local netdev = nil
	while true do
		netdev = get_brblan_dev()
		if netdev and brlan_nat(netdev, false) then
			break
		end
		invoker.waitsec(invoker.uptime() <= 300 and 5 or 60)
	end

	local wflags = bit32.bor(invoker.IN_CLOSE_WRITE,
		invoker.IN_MODIFY, invoker.IN_DELETE, invoker.IN_CREATE,
		invoker.IN_MOVED_FROM, invoker.IN_MOVED_TO)

	local last = invoker.uptime()
	while true do
		local etccfg = invoker.realpath(g_etccfg)
		if not etccfg then etccfg = g_etccfg end

		local now = invoker.uptime()
		local files = invoker.watchfile(wflags, get_internval(now - last), nil, etccfg)
		if not files then
			if not invoker.statfile(g_etccfg) then
				invoker.waitsec(25)
			end
			io.stderr:write(gfmt("%s: Warning, watchfile has failed.\n", os.date()))
			io.stderr:flush()
		elseif files <= 0 then
			io.stderr:write(gfmt("%s: Warning, watchfile has returned nothing.\n", os.date()))
			io.stderr:flush()
		else
			last = invoker.uptime()
			io.stdout:write(gfmt("%s: Warning, files changed in directory: %s\n", os.date(), etccfg))
			io.stdout:flush()
		end

		invoker.waitsec(5)
		netdev = get_brblan_dev()
		if netdev and brlan_nat(netdev, true) then
			last = invoker.uptime()
		end
	end
end

if not dynnat_init() then
	os.exit(1)
end
mainfunc()
