#!/usr/bin/lua5.4

--[[
Alpine Wall
Copyright (C) 2012-2025 Kaarle Ritvanen
See LICENSE file for license details
]]--

get_opts = require('alt_getopt').get_opts
lpc = require('lpc')

posix = require('posix')
signal = posix.signal
stat = posix.stat

stringy = require('stringy')

-- Lua 5.1 compatibility
if not table.unpack then table.unpack = unpack end

function help()
	io.stderr:write([[
Alpine Wall
Copyright (C) 2012-2025 Kaarle Ritvanen
This is free software with ABSOLUTELY NO WARRANTY,
available under the terms of the GNU General Public License, version 2

Usage:

Translate policy files to firewall configuration files:
    awall translate [-o|--output <dir>] [-V|--verify]

    The --verify option makes awall verify the configuration using the
    test mode of iptables-restore before overwriting the old files.

    Specifying the output directory allows testing awall policies
    without overwriting the current iptables and ipset configuration
    files. By default, awall generates the configuration to
    /etc/iptables and /etc/ipset.d, which are read by the init
    scripts.

Run-time activation of new firewall configuration:
    awall activate [-f|--force]

    This command generates firewall configuration from the policy
    files, superseding the currently active configuration. If the user
    confirms the new configuration by hitting RETURN within 10 seconds
    or the --force option is used, the configuration is saved to the
    files. Otherwise, the old configuration is restored.

    Unless the --force option is used, the firewall must already be
    active when this command is run.

Flush firewall configuration:
    awall flush [-a|--all]

    Normally, this command deletes all firewall rules and configures
    it to drop all packets.

    If awall is configured to co-exist with other firewall management
    tools, this command flushes only the rules installed by awall.
    Specifying --all overrides this behavior and causes all rules to
    be flushed.

Enable/disable optional policies:
    awall {enable|disable} <policy>...

List optional policies:
    awall list [-a|--all]

    The 'enabled' status means that the policy has been enabled by the
    user. The 'disabled' status means that the policy is not in
    use. The 'required' status means that the policy has not been
    enabled by the user but is in use because it is required by
    another policy which is in use.

    Normally, the command lists only optional policies. Specifying
    --all makes it list all policies and more information about them.

Dump variable and zone definitions:
    awall dump [level]

    Verbosity level is an integer in range 0-5 and defaults to 0.

Show difference between modified and saved configurations:
    awall diff [-o|--output <dir>]

    Displays the difference in the input policy files and generated
    output files since the last 'translate' or 'activate' command.

    When the --output option is used, the updated configuration is
    compared to the generated files in the specified directory
    (generated by the equivalent 'translate' command).

]])
	os.exit(2)
end

if not arg[1] then help() end

if not stringy.startswith(arg[1], '-') then
	mode = arg[1]
	table.remove(arg, 1)
end

opts, opind = get_opts(
	arg,
	'afo:V',
	{all='a', force='f', ['output-dir']='o', verify='V'}
)
for switch, value in pairs(opts) do
	if switch == 'a' then all = true
	elseif switch == 'f' then force = true
	elseif switch == 'c' then verbose = true
	elseif switch == 'V' then verify = true
	elseif switch == 'o' then outputdir = value
	else assert(false) end
end

if not mode then
	mode = arg[opind]
	opind = opind + 1
end


dev_mode = stringy.endswith(arg[0], '/awall-cli')
if dev_mode then
	basedir = arg[0]:sub(1, -11)
	package.path = basedir..'/?/init.lua;'..basedir..'/?.lua;'..package.path
end

util = require('awall.util')
contains = util.contains
printmsg = util.printmsg

if not contains(
	{
		'translate',
		'activate',
		'fallback',
		'flush',
		'enable',
		'disable',
		'list',
		'dump',
		'diff'
	},
	mode
) then help() end

pol_paths = {}
for i, cls in ipairs{'mandatory', 'optional', 'private'} do
	path = os.getenv('AWALL_PATH_'..cls:upper())
	if path then pol_paths[cls] = util.split(path, ':') end
end

if dev_mode then
	util.setdefault(pol_paths, 'mandatory', {'/etc/awall'})
	table.insert(pol_paths.mandatory, basedir..'/mandatory')
end

uerror = require('awall.uerror')
call = uerror.call
raise = uerror.raise

if not call(
	function()

		local awall = require('awall')
		local printtabular = util.printtabular
		local sortedkeys = util.sortedkeys

		local policyset = awall.PolicySet(pol_paths)

		if mode == 'list' then
			local active = policyset:active()
			local data = {}

			for i, name in sortedkeys(policyset.policies) do
				local policy = policyset.policies[name]

				if all or policy.type == 'optional' then
					if policy.enabled then status = 'enabled'
					elseif contains(active, name) then status = 'required'
					else status = 'disabled' end

					local polinfo = {name, status, policy:load().description}

					if all then
						table.insert(polinfo, 2, policy.type)
						table.insert(polinfo, 4, policy.path)
					end

					table.insert(data, polinfo)
				end
			end

			printtabular(data)
			os.exit()
		end

		if contains({'disable', 'enable'}, mode) then
			if opind > #arg then help() end
			repeat
				local name = arg[opind]
				local policy = policyset.policies[name]
				if not policy then raise('No such policy: '..name) end
				policy[mode](policy)
				opind = opind + 1
			until opind > #arg
			os.exit()
		end


		if mode == 'fallback' then

			for _, sig in ipairs{'HUP', 'INT', 'PIPE'} do
				signal(posix['SIG'..sig], 'SIG_IGN')
			end

			posix.sleep(10)
			printmsg('\nTimeout, reverting to the old configuration')

			local families = {}
			while opind <= #arg do
				table.insert(families, arg[opind])
				opind = opind + 1
			end
			require('awall.iptables').IPTables(families, false):revert()

			os.exit()
		end


		if mode == 'dump' then level = 0 + (arg[opind] or 0) end

		local input = policyset:load(basedir)
		local config = (mode ~= 'dump' or level > 3) and awall.Config(
			input, not force
		)


		local function dump(level)
			local json = require('cjson').new()
			json.encode_sort_keys(true)

			local expinput = input:expand()

			local function capitalize(cls)
				return cls:sub(1, 1):upper()..cls:sub(2, -1)
			end

			for _, cls in sortedkeys(input.data) do
				if level > 2 or (level == 2 and cls ~= 'service') or
					contains({'variable', 'zone'}, cls) then

					if level == 0 then io.write(capitalize(cls)..'s:\n') end

					local clsdata = input.data[cls]

					if next(clsdata) then
						local items = {}

						for _, key in sortedkeys(clsdata) do
							local exp = expinput[cls][key]
							local expj = json.encode(exp)
							local src = input.source[cls][key]

							if level == 0 then
								table.insert(items, {key, expj, src})

							else
								local value = clsdata[key]
								local data = {
									{
										capitalize(cls)..' '..key,
										json.encode(value)
									},
									{
										'('..src..')',
										util.compare(exp, value) and '' or
											'-> '..expj
									}
								}

								if level > 3 then
									local obj = config.objects[cls][key]
									if type(obj) == 'table' and obj.info then
										util.extend(data, obj:info())
									end
								end

								table.insert(items, {key, data})
							end
						end
						table.sort(items, function(a, b) return a[1] < b[1] end)

						if level == 0 then printtabular(items)
						else
							util.printtabulars(
								util.map(items, function(x) return x[2] end)
							)
							io.write('\n')
						end
					end
				end
			end

			if level > 4 then config:print() end
		end

		local function filedump(file)
			io.output(file)
			dump(5)
		end

		local sysdumpfile = '/etc/iptables/awall-save'
		local dumpfile = outputdir and outputdir..'/dump' or sysdumpfile


		if mode == 'dump' then dump(level)

		elseif mode == 'diff' then
			if not stat(dumpfile) then
				printmsg('Please translate or activate first')
				os.exit(2)
			end

			local pid, stdin, stdout = lpc.run(
				'diff', '-w', '-U3', '--', dumpfile, '/proc/self/fd/0'
			)

			filedump(stdin)
			stdin:close()

			local data
			repeat
				-- Lua 5.2 compatibility: prefix with *
				data = stdout:read('*a')

				io.stdout:write(data)
			until data == ''
			stdout:close()

			os.exit(lpc.wait(pid) / 256)

		elseif mode == 'translate' then
			if verify then config:test() end
			config:dump(outputdir)
			filedump(dumpfile)

		elseif mode == 'activate' then

			local function translate()
				config:dump()
				filedump(sysdumpfile)
			end

			if not force then
				for _, sig in ipairs{'INT', 'TERM'} do
					signal(posix['SIG'..sig], function() io.stdin:close() end)
				end

				if not config:fwenabled() then
					local INIT = '/usr/libexec/awall-init'
					if not stat(INIT) then
						raise('Firewall not enabled in kernel')
					end

					printmsg('Firewall is not active')
					io.stderr:write(
						'Press RETURN to perform initial configuration and activation: '
					)
					if not io.read() then
						printmsg('\nCanceled')
						os.exit(2)
					end

					translate()
					for _, family in ipairs(config:usablefamilies()) do
						os.execute(
							INIT..' '..
							({inet='iptables', inet6='ip6tables'})[family]
						)
					end
					os.exit(0)
				end
			end

			config:backup()

			local pid

			if not force then
				signal(
					posix.SIGCHLD,
					function()
						if pid and lpc.wait(pid, 1) then os.exit(1) end
					end
				)
				pid = util.run(
					arg[0], 'fallback', table.unpack(config:actfamilies())
				)
			end

			local function kill()
				signal(posix.SIGCHLD, 'SIG_DFL')
				posix.kill(pid, posix.SIGTERM)
				lpc.wait(pid)
			end

			local function revert()
				config:revert()
				os.exit(2)
			end

			if call(config.activate, config) then

				if not force then
					printmsg('New firewall configuration activated')
					io.stderr:write(
						'Press RETURN to commit changes permanently: '
					)
					local commit = io.read()

					kill()

					if not commit then
						printmsg(
							'\nActivation canceled, reverting to the old configuration'
						)
						revert()
					end
				end

				translate()

			else
				if not force then kill() end
				revert()
			end


		elseif mode == 'flush' then
			if all then config:flushall()
			else config:flush() end

		else assert(false) end

	end
) then os.exit(2) end

-- vim: ts=4
