#!/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 -- check for docker command if invoker.invoke(invoker.NOSTDIO, "docker", "--version") ~= 0 then g_has_docker = false 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 if string.find(pid, "/", 1, true) then local newp = string.match(pid, "/(%d+)$") if newp then pid = newp end 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()