#!/usr/bin/env python3 from __future__ import annotations import argparse import sys import platform import os import multiprocessing import subprocess import getpass import tarfile import hashlib import secrets pref = "\033[" reset = f"{pref}0m" class g: composefile = ".firegex-compose.yml" configfile = ".firegex-conf.json" build = False standalone_mode = False rootfs_path = "./firegexfs" pid_file = "./.firegex-standalone.pid" docker_sudo = False os.chdir(os.path.dirname(os.path.realpath(__file__))) if os.path.isfile("./Dockerfile"): with open("./Dockerfile", "rt") as dockerfile: if "cf1795af-3284-4183-a888-81ad3590ad84" in dockerfile.read(): g.build = True #Terminal colors class colors: black = "30m" red = "31m" green = "32m" yellow = "33m" blue = "34m" magenta = "35m" cyan = "36m" white = "37m" def hash_psw(psw: str): salt = secrets.token_hex(32) return hashlib.pbkdf2_hmac("sha256", psw.encode(), salt.encode(), 500_000).hex()+"-"+salt def puts(text, *args, color=colors.white, is_bold=False, **kwargs): print(f'{pref}{1 if is_bold else 0};{color}' + text + reset, *args, **kwargs) def sep(): puts("-----------------------------------", is_bold=True) def dict_to_yaml(data, indent_spaces:int=4, base_indent:int=0, additional_spaces:int=0, add_text_on_dict:str|None=None): yaml = '' spaces = ' '*((indent_spaces*base_indent)+additional_spaces) if isinstance(data, dict): for key, value in data.items(): if add_text_on_dict is not None: spaces_len = len(spaces)-len(add_text_on_dict) spaces = (' '*max(spaces_len, 0))+add_text_on_dict add_text_on_dict = None if isinstance(value, dict) or isinstance(value, list): yaml += f"{spaces}{key}:\n" yaml += dict_to_yaml(value, indent_spaces=indent_spaces, base_indent=base_indent+1, additional_spaces=additional_spaces) else: yaml += f"{spaces}{key}: {value}\n" spaces = ' '*((indent_spaces*base_indent)+additional_spaces) elif isinstance(data, list): for item in data: if isinstance(item, dict): yaml += dict_to_yaml(item, indent_spaces=indent_spaces, base_indent=base_indent, additional_spaces=additional_spaces+2, add_text_on_dict="- ") elif isinstance(item, list): yaml += dict_to_yaml(item, indent_spaces=indent_spaces, base_indent=base_indent+1, additional_spaces=additional_spaces) else: yaml += f"{spaces}- {item}\n" else: yaml += f"{data}\n" return yaml def cmd_check(program, get_output=False, print_output=False, no_stderr=False): if get_output: return subprocess.getoutput(program) if print_output: return subprocess.call(program, shell=True) == 0 return subprocess.call(program, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL if no_stderr else subprocess.STDOUT, shell=True) == 0 def composecmd(cmd, composefile=None): if composefile: cmd = f"-f {composefile} {cmd}" sudo = "sudo " if g.docker_sudo else "" if cmd_check(f"{sudo}docker compose --version"): return subprocess.run(f"{sudo}docker compose -p firegex {cmd}", shell=True) elif cmd_check(f"{sudo}docker-compose --version"): return subprocess.run(f"{sudo}docker-compose -p firegex {cmd}", shell=True) else: puts("Docker compose not found! please install docker compose!", color=colors.red) def check_already_running(): sudo = "sudo " if g.docker_sudo else "" return "firegex" in cmd_check(f'{sudo}docker ps --filter "name=^firegex$"', get_output=True) def load_config(): """Load configuration from .firegex-conf.json""" import json default_config = { "port": 4444, # any allow to bind service also on ipv6 (see the main of backend to understand why) "host": "any", "socket_dir": None, "allowed_ips": None, "proxy_ip_header": None, "unsafe_disable_auth": False, } if os.path.isfile(g.configfile): try: with open(g.configfile, 'r') as f: config = json.load(f) # Ensure all required keys exist for key, value in default_config.items(): if key not in config: config[key] = value # port/host always need a concrete value: a stale/explicit null in the # config file (unlike socket_dir/allowed_ips/proxy_ip_header, which are # legitimately optional) must fall back to the default, not stay None. if config.get("port") is None: config["port"] = default_config["port"] if config.get("host") is None: config["host"] = default_config["host"] return config except (json.JSONDecodeError, IOError) as e: puts(f"Warning: Failed to load config file {g.configfile}: {e}", color=colors.yellow) puts("Using default configuration", color=colors.yellow) return default_config def save_config(config): """Save configuration to .firegex-conf.json""" import json try: with open(g.configfile, 'w') as f: json.dump(config, f, indent=2) return True except IOError as e: puts(f"Warning: Failed to save config file {g.configfile}: {e}", color=colors.yellow) return False def gen_args(args_to_parse: list[str]|None = None): # Load configuration config = load_config() #Main parser parser = argparse.ArgumentParser(description="Firegex Manager") parser.add_argument('--clear', dest="bef_clear", required=False, action="store_true", help='Delete docker volume associated to firegex resetting all the settings', default=False) parser.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) subcommands = parser.add_subparsers(dest="command", help="Command to execute [Default start if not running]") #Compose Command parser_compose = subcommands.add_parser('compose', help='Run docker compose command') parser_compose.add_argument('compose_args', nargs=argparse.REMAINDER, help='Arguments to pass to docker compose', default=[]) #Start Command parser_start = subcommands.add_parser('start', help='Start the firewall') parser_start.add_argument('--threads', "-t", type=int, required=False, help='Number of threads started for each service/utility', default=-1) parser_start.add_argument('--startup-psw','-P', required=False, help='Insert password in the startup screen of firegex', type=str, default=None) parser_start.add_argument('--psw-on-web', required=False, help='Setup firegex password on the web interface', action="store_true", default=False) parser_start.add_argument('--port', "-p", type=int, required=False, help=f'Port where open the web service of the firewall (default from config: {config["port"]})', default=config["port"]) parser_start.add_argument('--host', required=False, help=f'Host IP address to bind the service to (default from config: {config["host"]})', default=config["host"]) parser_start.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"]) parser_start.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips")) parser_start.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header")) parser_start.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=config.get("unsafe_disable_auth", False), help='UNSAFE: disable Firegex password/JWT authentication, making every request that reaches firegex a full administrator (for a trusted reverse proxy only)') parser_start.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False) parser_start.add_argument('--version', '-v', required=False, type=str , help='Version of the firegex image to use', default=None) parser_start.add_argument('--prebuilt', required=False, action="store_true", help='Use prebuilt docker image', default=False) parser_start.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) #Stop Command parser_stop = subcommands.add_parser('stop', help='Stop the firewall') parser_stop.add_argument('--clear', required=False, action="store_true", help='Delete docker volume associated to firegex resetting all the settings', default=False) parser_stop.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) parser_restart = subcommands.add_parser('restart', help='Restart the firewall') parser_restart.add_argument('--port', "-p", type=int, required=False, help=f'Port where open the web service of the firewall (default from config: {config["port"]})', default=config["port"]) parser_restart.add_argument('--host', required=False, help=f'Host IP address to bind the service to (default from config: {config["host"]})', default=config["host"]) parser_restart.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"]) parser_restart.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips")) parser_restart.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header")) parser_restart.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=config.get("unsafe_disable_auth", False), help='UNSAFE: disable Firegex password/JWT authentication, making every request that reaches firegex a full administrator (for a trusted reverse proxy only)') parser_restart.add_argument('--logs', required=False, action="store_true", help='Show firegex logs', default=False) parser_restart.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) #Status Command parser_status = subcommands.add_parser('status', help='Show firewall status') parser_status.add_argument('--port', "-p", type=int, required=False, help=f'Port where open the web service of the firewall (default from config: {config["port"]})', default=config["port"]) parser_status.add_argument('--host', required=False, help=f'Host IP address to bind the service to (default from config: {config["host"]})', default=config["host"]) parser_status.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"]) parser_status.add_argument('--standalone', required=False, action="store_true", help='Force standalone mode', default=False) #Config Command parser_config = subcommands.add_parser('config', help='Manage configuration settings') parser_config.add_argument('--port', "-p", type=int, required=False, help='Set default port for web service') parser_config.add_argument('--host', required=False, help='Set default host IP address to bind the service to') parser_config.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"]) parser_config.add_argument('--password', required=False, type=str, nargs='?', const='', help='Change the password of the firewall (omit the value to be prompted for it interactively)') parser_config.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips")) parser_config.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header")) parser_config.add_argument('--unsafe-disable-auth', action=argparse.BooleanOptionalAction, default=None, help='UNSAFE: turn Firegex password/JWT authentication off or back on, on the running instance and for the next start') parser_config.add_argument('--keep-auth-disabled', required=False, action="store_true", default=False, help='With --password: store the new password without turning authentication back on (for an instance kept open behind a trusted reverse proxy)') parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False) args = parser.parse_args(args=args_to_parse) if "version" in args and args.version and g.build: puts("The version argument is not used when the image is built from the Dockerfile", color=colors.yellow) puts("The version will be ignored", color=colors.yellow) if "version" not in args or not args.version: args.version = "latest" if "prebuilt" in args and args.prebuilt: g.build = False if "psw_on_web" not in args: args.psw_on_web = False if "startup_psw" not in args: args.startup_psw = None if "clear" not in args: args.clear = False if "standalone" not in args: args.standalone = False if "threads" not in args or args.threads < 1: args.threads = multiprocessing.cpu_count() # Use config values as fallback, but allow command line to override if "command" in args and args.command != "config": if "port" not in args or args.port is None or args.port < 1: args.port = config["port"] if "host" not in args or args.host is None: args.host = config["host"] if "socket_dir" not in args or args.socket_dir is None: args.socket_dir = config["socket_dir"] # Save configuration if values were specified via command line and differ from config config_changed = False if hasattr(args, 'port') and args.port is not None and args.port != config["port"]: config["port"] = args.port config_changed = True if hasattr(args, 'host') and args.host is not None and args.host != config["host"]: config["host"] = args.host config_changed = True if hasattr(args, 'socket_dir') and args.socket_dir != config["socket_dir"]: config["socket_dir"] = args.socket_dir config_changed = True if hasattr(args, 'allowed_ips') and args.allowed_ips != config.get("allowed_ips"): config["allowed_ips"] = args.allowed_ips config_changed = True if hasattr(args, 'proxy_ip_header') and args.proxy_ip_header != config.get("proxy_ip_header"): config["proxy_ip_header"] = args.proxy_ip_header config_changed = True if getattr(args, 'unsafe_disable_auth', None) is not None and args.unsafe_disable_auth != config.get("unsafe_disable_auth", False): config["unsafe_disable_auth"] = args.unsafe_disable_auth config_changed = True if config_changed: save_config(config) if args.command is None: if not args.clear: return gen_args(["start", *sys.argv[1:]]) args.clear = args.bef_clear or args.clear return args args = gen_args() def get_git_version() -> str | None: import os import re def parse_version(v): v = v.lstrip("v") parts = re.split(r'[^0-9]+', v) return tuple(int(p) if p.isdigit() else 0 for p in parts if p) def find_git_dir(start_path): current = start_path while current and current != "/" and current != "": gd = os.path.join(current, ".git") if os.path.isdir(gd): return gd parent = os.path.dirname(current) if parent == current: break current = parent return None try: git_dir = find_git_dir(os.path.abspath(os.path.dirname(__file__))) if not git_dir: return None branch = None with open(os.path.join(git_dir, "HEAD"), "r") as f: head = f.read().strip() if head.startswith("ref: refs/heads/"): branch = head.split("ref: refs/heads/")[1] if branch != "main": return None tags = [] try: with open(os.path.join(git_dir, "packed-refs"), "r") as f: for line in f: if " refs/tags/" in line: tags.append(line.strip().split(" refs/tags/")[1]) except Exception: pass tags_dir = os.path.join(git_dir, "refs", "tags") try: for root, _, files in os.walk(tags_dir): for file in files: tags.append(os.path.relpath(os.path.join(root, file), tags_dir).replace(os.path.sep, "/")) except Exception: pass version_tags = [t for t in set(tags) if any(c.isdigit() for c in t)] if version_tags: return max(version_tags, key=parse_version) except Exception: pass return None def is_linux(): return "linux" in sys.platform and 'microsoft-standard' not in platform.uname().release def get_web_interface_url(): if args.socket_dir: return os.path.join(args.socket_dir, "firegex.sock") display_host = "localhost" if args.host == "0.0.0.0" else args.host return f"http://{display_host}:{args.port}" def write_compose(skip_password = True): psw_set = get_password() if not skip_password else None with open(g.composefile,"wt") as compose: if is_linux(): #Check if not is a wsl also compose.write(dict_to_yaml({ "services": { "firewall": { "restart": "unless-stopped", "container_name": "firegex", "build" if g.build else "image": "." if g.build else f"ghcr.io/pwnzer0tt1/firegex:{args.version}", "network_mode": "host", "environment": [ f"PORT={args.port}", f"HOST={args.host}", f"NTHREADS={args.threads}", *([f"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []), *(["SOCKET_DIR=/run/firegex"] if args.socket_dir else []), *([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() else []), *([f"ALLOWED_IPS={args.allowed_ips}"] if getattr(args, 'allowed_ips', None) else []), *([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []), *(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_disable_auth', False) else []) ], "volumes": [ "firegex_data:/execute/db", { "type": "bind", "source": "/proc/sys/net/ipv4/conf/all/route_localnet", "target": "/sys_host/net.ipv4.conf.all.route_localnet" }, { "type": "bind", "source": "/proc/sys/net/ipv4/ip_forward", "target": "/sys_host/net.ipv4.ip_forward" }, { "type": "bind", "source": "/proc/sys/net/ipv4/conf/all/forwarding", "target": "/sys_host/net.ipv4.conf.all.forwarding" }, { "type": "bind", "source": "/proc/sys/net/ipv6/conf/all/forwarding", "target": "/sys_host/net.ipv6.conf.all.forwarding" }, *([{ "type": "bind", "source": args.socket_dir, "target": "/run/firegex" }] if args.socket_dir else []) ], "cap_add": [ "NET_ADMIN", # What the proxy engine opens its capture socket with, to # write a TLS service's decrypted traffic to `firegex0`. # Named rather than relied on: it is in Docker's default set # today, and a default is not a promise. "NET_RAW", "SYS_NICE" ] } }, "volumes": { "firegex_data": "" } })) else: compose.write(dict_to_yaml({ "services": { "firewall": { "restart": "unless-stopped", "container_name": "firegex", "build" if g.build else "image": "." if g.build else f"ghcr.io/pwnzer0tt1/firegex:{args.version}", "ports": [ f"{'' if args.host == 'any' else args.host+':'}{args.port}:{args.port}" ], "environment": [ f"PORT={args.port}", f"NTHREADS={args.threads}", *([f"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []), *([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() else []), *([f"ALLOWED_IPS={args.allowed_ips}"] if getattr(args, 'allowed_ips', None) else []), *([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []), *(["UNSAFE_DISABLE_AUTH=1"] if getattr(args, 'unsafe_disable_auth', False) else []) ], "volumes": [ "firegex_data:/execute/db" ], "cap_add": [ "NET_ADMIN" ] } }, "volumes": { "firegex_data": "" } })) def get_password(): if volume_exists() or args.psw_on_web or (g.standalone_mode and os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db"))): return None if args.startup_psw: return args.startup_psw # Authentication is delegated to the reverse proxy in this mode, so don't block # startup on an interactive prompt for a password nothing will check. An explicit # --startup-psw is still honored above: without a password in the database, turning # authentication back on would leave the instance in the "set the initial password" # state, where anyone able to reach it can claim it. if getattr(args, 'unsafe_disable_auth', False): return None psw_set = None while True: while True: puts("Insert a password for firegex: ", end="" , color=colors.yellow, is_bold=True, flush=True) psw_set = getpass.getpass("") if (len(psw_set) < 8): puts("The password has to be at least 8 char long", color=colors.red, is_bold=True, flush=True) else: break puts("Confirm the password: ", end="" , color=colors.yellow, is_bold=True, flush=True) check = getpass.getpass("") if check != psw_set: puts("Passwords don't match!" , color=colors.red, is_bold=True, flush=True) else: break return psw_set def volume_exists(): sudo = "sudo " if g.docker_sudo else "" return "firegex_firegex_data" in cmd_check(f'{sudo}docker volume ls --filter "name=^firegex_firegex_data$"', get_output=True) def firegex_db_exists(): """Whether a database (and therefore a possibly already set password) is around.""" if g.standalone_mode: return os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db")) else: return volume_exists() def delete_volume(): sudo = "sudo " if g.docker_sudo else "" return cmd_check(f"{sudo}docker volume rm firegex_firegex_data") def warn_if_auth_disabled(): if not getattr(args, 'unsafe_disable_auth', False): return sep() puts("--- AUTHENTICATION DISABLED ---", color=colors.yellow, is_bold=True) puts("Firegex will treat every request that reaches it as a full administrator.", color=colors.red) puts("Only expose it behind a reverse proxy that performs the access control itself.", color=colors.red) puts(f"This setting is stored in {g.configfile} and stays active on the next start:", color=colors.red) puts("pass --no-unsafe-disable-auth to turn the built-in authentication back on.", color=colors.red) if not firegex_db_exists() and not args.startup_psw: puts("No password is set: if you re-enable authentication later, firegex will start in", color=colors.yellow) puts("its initial-setup state, where anyone able to reach it can choose the password.", color=colors.yellow) puts("Set one now with '-P ', or later with 'python3 run.py config --password'.", color=colors.yellow) sep() def nfqueue_exists(): import socket import fcntl import os import time NETLINK_NETFILTER = 12 SOL_NETLINK = 270 NETLINK_EXT_ACK = 11 try: nfsock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_NETFILTER) fcntl.fcntl(nfsock, fcntl.F_SETFL, os.O_RDONLY|os.O_NONBLOCK) nfsock.setsockopt(SOL_NETLINK, NETLINK_EXT_ACK, 1) except Exception: return False for rev in [3,2,1,0]: timestamp = int(time.time()).to_bytes(4, byteorder='big') rev = rev.to_bytes(4, byteorder='big') #Prepared payload to check if the nfqueue module is loaded (from iptables code "nft_compatible_revision") payload = b"\x30\x00\x00\x00\x00\x0b\x05\x00"+timestamp+b"\x00\x00\x00\x00\x02\x00\x00\x00\x0c\x00\x01\x00\x4e\x46\x51\x55\x45\x55\x45\x00\x08\x00\x02\x00"+rev+b"\x08\x00\x03\x00\x00\x00\x00\x01" nfsock.send(payload) data = nfsock.recv(1024) is_error = data[4] == 2 if not is_error: return True # The module exists and we have permission to use it error_code = int.from_bytes(data[16:16+4], signed=True, byteorder='little') if error_code == -1: return True # EPERM (the user is not root, but the module exists) if error_code == -2: pass # ENOENT (the module does not exist) else: puts("Error while trying to check if the nfqueue module is loaded, this check will be skipped!", color=colors.yellow) return True return False def write_pid_file(pid): """Write PID to file""" try: with open(g.pid_file, 'w') as f: f.write(str(pid)) return True except Exception as e: puts(f"Failed to write PID file: {e}", color=colors.red) return False def read_pid_file(): """Read PID from file""" try: if os.path.exists(g.pid_file): with open(g.pid_file, 'r') as f: return int(f.read().strip()) return None except Exception: return None def remove_pid_file(): """Remove PID file""" try: if os.path.exists(g.pid_file): os.remove(g.pid_file) except Exception: pass def is_process_running(pid): """Check if process with given PID is running""" if pid is None: return False try: # Send signal 0 to check if process exists os.kill(pid, 0) return True except (OSError, ProcessLookupError): return False def is_standalone_running(): """Check if standalone Firegex is already running""" pid = read_pid_file() if pid and is_process_running(pid): return True else: # Clean up stale PID file remove_pid_file() return False def stop_standalone_process(): """Stop the standalone Firegex process""" pid = read_pid_file() if pid and is_process_running(pid): try: puts(f"Stopping Firegex process (PID: {pid})...", color=colors.yellow) os.kill(pid, 15) # SIGTERM # Wait a bit for graceful shutdown import time for _ in range(10): if not is_process_running(pid): break time.sleep(0.5) # Force kill if still running if is_process_running(pid): puts("Process didn't stop gracefully, forcing termination...", color=colors.yellow) os.kill(pid, 9) # SIGKILL time.sleep(1) if not is_process_running(pid): puts("Firegex process stopped", color=colors.green) return True else: puts("Failed to stop Firegex process", color=colors.red) return False except Exception as e: puts(f"Error stopping process: {e}", color=colors.red) return False else: puts("No running Firegex process found", color=colors.yellow) return True def is_docker_rootless(): """Check if Docker is running in rootless mode""" try: sudo = "sudo " if g.docker_sudo else "" output = cmd_check(f'{sudo}docker info -f "{{{{println .SecurityOptions}}}}"', get_output=True) return "rootless" in output.lower() except Exception: return False def should_use_standalone(): """Determine if standalone mode should be used""" # Check if standalone mode is forced if args.standalone: return True if is_standalone_running(): return True # Check if Docker exists if not cmd_check("docker --version"): return True # Check if Docker Compose exists if not cmd_check("docker-compose --version") and not cmd_check("docker compose --version"): return True # Check if Docker is accessible if not cmd_check("docker ps"): if cmd_check("sudo docker ps"): g.docker_sudo = True else: return True # Check if Docker is in rootless mode if is_docker_rootless(): return True return False def is_root(): """Check if running as root""" return os.geteuid() == 0 def get_sudo_prefix(): """Get sudo prefix if needed, empty string if already root""" return "" if is_root() else "sudo " def run_privileged_commands(commands, description="operations"): """Run a batch of privileged commands efficiently""" if not commands: return True if is_root(): # If already root, run commands directly for cmd in commands: result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode != 0: puts(f"Command failed: {cmd}", color=colors.red) puts(f"Error: {result.stderr}", color=colors.red) return False return True else: # If not root, create a script and run it with sudo once import tempfile with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False) as script_file: script_file.write("#!/bin/sh\nset -e\n") for cmd in commands: script_file.write(f"{cmd}\n") script_path = script_file.name try: os.chmod(script_path, 0o755) result = subprocess.run(f"sudo sh {script_path}", shell=True, capture_output=True, text=True) if result.returncode != 0: puts(f"Failed to execute {description}", color=colors.red) puts(f"Error: {result.stderr}", color=colors.red) return False return True finally: os.unlink(script_path) def safe_run_command(cmd, check_result=True, use_sudo=False): """Run a command safely with proper error handling""" if use_sudo: cmd = f"{get_sudo_prefix()}{cmd}" try: result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if check_result and result.returncode != 0: puts(f"Command failed: {cmd}", color=colors.red) puts(f"Error: {result.stderr}", color=colors.red) return False return result.returncode == 0 except Exception as e: puts(f"Error running command: {cmd}", color=colors.red) puts(f"Exception: {e}", color=colors.red) return False def cleanup_standalone_mounts(): """Cleanup any existing mounts for standalone mode""" mount_points = [ f"{g.rootfs_path}/dev", f"{g.rootfs_path}/proc", f"{g.rootfs_path}/sys_host/net.ipv4.conf.all.route_localnet", f"{g.rootfs_path}/sys_host/net.ipv4.ip_forward", f"{g.rootfs_path}/sys_host/net.ipv4.conf.all.forwarding", f"{g.rootfs_path}/sys_host/net.ipv6.conf.all.forwarding" ] # Add socket directory mount point if configured if args.socket_dir: mount_points.append(f"{g.rootfs_path}/run/firegex") # Create umount commands (with || true to ignore errors) umount_commands = [f"umount -l {mount_point} || true" for mount_point in mount_points] # Run all umount commands in one batch run_privileged_commands(umount_commands, "cleanup mounts") def get_latest_release_tag(): """Get the latest release tag from GitHub API""" import urllib.request import json try: url = "https://api.github.com/repos/Pwnzer0tt1/firegex/releases/latest" with urllib.request.urlopen(url) as response: data = json.loads(response.read().decode()) return data.get('tag_name') except Exception as e: puts(f"Failed to get latest release tag: {e}", color=colors.red) return None def get_architecture(): """Get current architecture (amd64 or arm64)""" import platform arch = platform.machine().lower() if arch in ['x86_64', 'amd64']: return 'amd64' elif arch in ['aarch64', 'arm64']: return 'arm64' else: puts(f"Unsupported architecture: {arch}", color=colors.red) return None def download_file(url, filename): """Download a file using urllib with progress bar""" import urllib.request import sys def progress_hook(block_num, block_size, total_size): if total_size > 0: percent = min(100, (block_num * block_size * 100) // total_size) sys.stdout.write(f"\rDownloading... {percent}%") sys.stdout.flush() else: sys.stdout.write(f"\rDownloading... {block_num * block_size} bytes") sys.stdout.flush() try: puts(f"Downloading {filename}...", color=colors.green) urllib.request.urlretrieve(url, filename, reporthook=progress_hook) print() # New line after progress return True except Exception as e: print() # New line after progress puts(f"Failed to download {filename}: {e}", color=colors.red) return False def setup_standalone_rootfs(): """Set up the standalone rootfs""" puts("Setting up standalone mode...", color=colors.green) # Remove and recreate rootfs directory if os.path.exists(g.rootfs_path): puts("Rootfs already exists, skipping download...", color=colors.yellow) # Clean up any existing mounts cleanup_standalone_mounts() return True puts("Creating rootfs directory...", color=colors.green) try: os.makedirs(g.rootfs_path, exist_ok=True) except Exception as e: puts(f"Failed to create rootfs directory: {e}", color=colors.red) return False # Get latest release tag release_tag = get_latest_release_tag() if not release_tag: puts("Failed to get latest release tag", color=colors.red) return False # Get current architecture arch = get_architecture() if not arch: return False # Download rootfs from GitHub releases puts(f"Downloading rootfs for {arch} architecture from GitHub releases...", color=colors.green) # Construct download URL rootfs_filename = f"firegex-rootfs-{arch}.tar.gz" download_url = f"https://github.com/Pwnzer0tt1/firegex/releases/download/{release_tag}/{rootfs_filename}" tar_path = os.path.join(g.rootfs_path, rootfs_filename) # Download the rootfs archive if not download_file(download_url, tar_path): return False try: # Extract tar.gz file puts("Extracting rootfs...", color=colors.green) with tarfile.open(tar_path, 'r:gz') as tar: # Extract all files with tar filter (allows safe symbolic links) tar.extractall(path=g.rootfs_path, filter='tar') # Remove tar.gz file os.remove(tar_path) # Create necessary directories os.makedirs(os.path.join(g.rootfs_path, "dev"), exist_ok=True) os.makedirs(os.path.join(g.rootfs_path, "proc"), exist_ok=True) os.makedirs(os.path.join(g.rootfs_path, "sys_host"), exist_ok=True) puts("Rootfs setup completed", color=colors.green) return True except Exception as e: puts(f"Failed to extract rootfs: {e}", color=colors.red) # Clean up partial extraction if os.path.exists(tar_path): os.remove(tar_path) return False def setup_standalone_mounts(): """Set up bind mounts for standalone mode""" puts("Setting up bind mounts...", color=colors.green) # Create mount point files mount_files = [ "net.ipv4.conf.all.route_localnet", "net.ipv4.ip_forward", "net.ipv4.conf.all.forwarding", "net.ipv6.conf.all.forwarding" ] sys_host_dir = os.path.join(g.rootfs_path, "sys_host") # Prepare all privileged commands privileged_commands = [] # Touch commands for mount point files for mount_file in mount_files: file_path = os.path.join(sys_host_dir, mount_file) privileged_commands.append(f"touch {file_path}") # Mount commands privileged_commands.extend([ f"mount --bind /dev {g.rootfs_path}/dev", f"mount --bind /proc {g.rootfs_path}/proc", f"mount --bind /proc/sys/net/ipv4/conf/all/route_localnet {g.rootfs_path}/sys_host/net.ipv4.conf.all.route_localnet", f"mount --bind /proc/sys/net/ipv4/ip_forward {g.rootfs_path}/sys_host/net.ipv4.ip_forward", f"mount --bind /proc/sys/net/ipv4/conf/all/forwarding {g.rootfs_path}/sys_host/net.ipv4.conf.all.forwarding", f"mount --bind /proc/sys/net/ipv6/conf/all/forwarding {g.rootfs_path}/sys_host/net.ipv6.conf.all.forwarding" ]) # Add socket directory bind mount if configured if args.socket_dir: # Create socket directory on host if it doesn't exist # Create mount point in rootfs and bind mount the socket directory privileged_commands.extend([ f"mkdir -p {args.socket_dir}", f"chmod 755 {args.socket_dir}", f"mkdir -p {g.rootfs_path}/run/firegex", f"chmod 755 {g.rootfs_path}/run/firegex", f"mount --bind {args.socket_dir} {g.rootfs_path}/run/firegex" ]) # Run all privileged commands in one batch if not run_privileged_commands(privileged_commands, "setup bind mounts"): puts("Failed to set up bind mounts", color=colors.red) return False return True def run_standalone(): """Run Firegex in standalone mode as a daemon""" puts("Starting Firegex in standalone mode...", color=colors.green) # Check if already running if is_standalone_running(): puts("Firegex is already running in standalone mode!", color=colors.yellow) pid = read_pid_file() puts(f"Process PID: {pid}", color=colors.cyan) return warn_if_auth_disabled() # Set up environment variables env_vars = [ f"PORT={args.port}", f"HOST={args.host}", f"NTHREADS={args.threads}", ] if getattr(args, 'allowed_ips', None): env_vars.append(f"ALLOWED_IPS={args.allowed_ips}") if getattr(args, 'proxy_ip_header', None): env_vars.append(f"PROXY_IP_HEADER={args.proxy_ip_header}") if getattr(args, 'unsafe_disable_auth', False): env_vars.append("UNSAFE_DISABLE_AUTH=1") # Add password if set psw_set = get_password() if psw_set: env_vars.append(f"PSW_HASH_SET={hash_psw(psw_set)}") # Add socket dir if set (use path inside chroot) if args.socket_dir: env_vars.append("SOCKET_DIR=/run/firegex") # Prepare environment string for chroot env_string = " ".join([f"{var}" for var in env_vars]) # Run chroot command in background chroot_cmd = f"{get_sudo_prefix()}env {env_string} chroot --userspec=root:root {g.rootfs_path} /bin/python3 /execute/app.py DOCKER" puts(f"Running: {chroot_cmd}", color=colors.cyan) puts("Starting as daemon...", color=colors.green) try: out_file = open('/tmp/app.log', 'w') process = subprocess.Popen( chroot_cmd, shell=True, stdout=out_file, stderr=out_file, stdin=subprocess.DEVNULL, preexec_fn=os.setsid # Create new session ) # Write PID to file if write_pid_file(process.pid): puts(f"Firegex started successfully (PID: {process.pid})", color=colors.green) if is_process_running(process.pid): puts(f"Web interface should be available at: {get_web_interface_url()}", color=colors.cyan) else: puts("Firegex process failed to start", color=colors.red) remove_pid_file() cleanup_standalone_mounts() else: puts("Failed to save PID file", color=colors.red) process.terminate() cleanup_standalone_mounts() except Exception as e: puts(f"Failed to start Firegex: {e}", color=colors.red) cleanup_standalone_mounts() def stop_standalone(): """Stop standalone mode by stopping the process and cleaning up mounts""" puts("Stopping standalone mode...", color=colors.green) # Stop the process if stop_standalone_process(): # Clean up mounts cleanup_standalone_mounts() # Remove PID file remove_pid_file() puts("Standalone mode stopped", color=colors.green) else: # Clean up anyway cleanup_standalone_mounts() remove_pid_file() puts("Cleanup completed", color=colors.yellow) def clear_standalone(): """Clear standalone rootfs""" puts("Clearing standalone rootfs...", color=colors.green) cleanup_standalone_mounts() if os.path.exists(g.rootfs_path): # If permission denied, use privileged command if run_privileged_commands([f"chmod ugo+rw -R {g.rootfs_path}", f"rm -rf {g.rootfs_path}"], "remove rootfs"): puts("Standalone rootfs cleared", color=colors.green) else: puts("Failed to clear standalone rootfs", color=colors.red) else: puts("Standalone rootfs not found", color=colors.yellow) #: Written into the running instance's own database. It reads them per request, so a #: change lands on the next one instead of on the next restart. _KV_WRITE = ( "import sqlite3, sys\n" "conn = sqlite3.connect(sys.argv[1])\n" "for key, value in zip(sys.argv[2::2], sys.argv[3::2]):\n" " if conn.execute('UPDATE keys_values SET value = ? WHERE key = ?', (value, key)).rowcount == 0:\n" " conn.execute('INSERT INTO keys_values (key, value) VALUES (?, ?)', (key, value))\n" "conn.commit()\n" "conn.close()\n" ) #: The same, the other way round. `None` for a key that is not there. _KV_READ = ( "import sqlite3, sys\n" "conn = sqlite3.connect(sys.argv[1])\n" "row = conn.execute('SELECT value FROM keys_values WHERE key = ?', (sys.argv[2],)).fetchone()\n" "print(row[0] if row else '')\n" ) DB_IN_CONTAINER = "/execute/db/firegex.db" def _standalone_db(): path = os.path.join(g.rootfs_path, "execute/db/firegex.db") return path if os.path.isfile(path) else None def put_runtime_settings(pairs: dict) -> bool: """Write settings straight into the running instance's database. Two ways in, because there are two ways firegex runs; one function, because a setting that applies under Docker and quietly does nothing standalone is a setting nobody can rely on. UPDATE first and INSERT only if it changed nothing: the other order relies on catching an integrity error, which leaves the caller unable to tell a key that was already there from a database that was not writable. Returns False when there is nothing running or stored to write to. """ flat = [item for pair in pairs.items() for item in pair] standalone = _standalone_db() if g.standalone_mode and standalone: import sqlite3 conn = sqlite3.connect(standalone) try: for key, value in pairs.items(): if conn.execute("UPDATE keys_values SET value = ? WHERE key = ?", (value, key)).rowcount == 0: conn.execute("INSERT INTO keys_values (key, value) VALUES (?, ?)", (key, value)) conn.commit() finally: conn.close() return True if volume_exists(): try: cmd = ["sudo", "docker"] if getattr(g, "docker_sudo", False) else ["docker"] subprocess.run( cmd + ["exec", "firegex", "python3", "-c", _KV_WRITE, DB_IN_CONTAINER, *flat], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False return False def get_runtime_setting(key: str): """What the running instance currently holds for `key`, or None if it cannot be asked.""" standalone = _standalone_db() if g.standalone_mode and standalone: import sqlite3 conn = sqlite3.connect(standalone) try: row = conn.execute("SELECT value FROM keys_values WHERE key = ?", (key,)).fetchone() return row[0] if row else None except Exception: return None finally: conn.close() if volume_exists(): try: cmd = ["sudo", "docker"] if getattr(g, "docker_sudo", False) else ["docker"] out = subprocess.run( cmd + ["exec", "firegex", "python3", "-c", _KV_READ, DB_IN_CONTAINER, key], check=True, capture_output=True, text=True, ).stdout.strip() return out or None except (subprocess.CalledProcessError, FileNotFoundError): return None return None def handle_config_command(args): """Handle config command""" config = load_config() config_changed = False if args.show: puts("Current configuration:", color=colors.cyan, is_bold=True) puts(f"Port: {config['port']}", color=colors.white) puts(f"Host: {config['host']}", color=colors.white) puts(f"Socket dir: {config['socket_dir']}", color=colors.white) puts(f"Authentication disabled: {config.get('unsafe_disable_auth', False)}", color=colors.white) # What the next start will do, and what the running one is actually doing: they # part company the moment authentication is turned off from the interface, which # lasts only as long as the process. live = get_runtime_setting("auth_disabled") if live is not None and (live == "1") != bool(config.get("unsafe_disable_auth", False)): puts(f" (the running instance has it {'disabled' if live == '1' else 'enabled'} " f"right now — that lasts until it restarts)", color=colors.yellow) puts(f"Config file: {g.configfile}", color=colors.white) return if hasattr(args, 'port') and args.port is not None: if args.port < 1 or args.port > 65535: puts("Error: Port must be between 1 and 65535", color=colors.red) exit(1) config["port"] = args.port config_changed = True puts(f"Port set to: {args.port}", color=colors.green) if hasattr(args, 'host') and args.host is not None: config["host"] = args.host config_changed = True puts(f"Host set to: {args.host}", color=colors.green) if hasattr(args, 'socket_dir') and args.socket_dir is not None: config["socket_dir"] = args.socket_dir config_changed = True puts(f"Socket dir set to: {args.socket_dir}", color=colors.green) if getattr(args, 'unsafe_disable_auth', None) is not None: config["unsafe_disable_auth"] = args.unsafe_disable_auth config_changed = True if args.unsafe_disable_auth: puts("Authentication disabled: every request reaching firegex becomes a full administrator", color=colors.red, is_bold=True) else: puts("Authentication enabled", color=colors.green) # Both halves, or the two disagree until the next restart: the database is what # the running process reads, the config file is what the next one comes up with. applied = put_runtime_settings({"auth_disabled": "1" if args.unsafe_disable_auth else "0"}) if applied: puts("Applied to the running instance immediately, and kept for the next start.", color=colors.green) else: puts("Firegex is not running: the change is kept for the next start.", color=colors.yellow) # Only when nothing on this same command line is about to set one: the password # block runs after this one, so warning here about a password that is two lines # away from existing would be a warning about nothing. setting_one_now = getattr(args, 'password', None) is not None if not args.unsafe_disable_auth and not setting_one_now \ and get_runtime_setting("password") is None: # Otherwise the interface answers with the "choose a password" screen and the # operator has no idea which of the two things they did caused it. puts("There is no password set, so firegex will ask for one to be chosen.", color=colors.yellow) puts("Set it here instead with 'python3 run.py config --password'.", color=colors.yellow) if hasattr(args, 'password') and args.password is not None: new_password = args.password if new_password == "": # No value was passed on the command line: prompt for it interactively # (masked input), so the new password never lands in shell history or `ps`. while True: puts("Insert a new password for firegex: ", end="", color=colors.yellow, is_bold=True, flush=True) new_password = getpass.getpass("") if len(new_password) < 8: puts("The password has to be at least 8 char long", color=colors.red, is_bold=True, flush=True) else: break puts("Confirm the password: ", end="", color=colors.yellow, is_bold=True, flush=True) if getpass.getpass("") != new_password: puts("Passwords don't match!", color=colors.red, is_bold=True, flush=True) exit(1) elif len(new_password) < 8: puts("Error: The password has to be at least 8 char long", color=colors.red) exit(1) # The hash travels as a plain argv item rather than interpolated into source, so # it never needs shell or Python-string escaping. if not put_runtime_settings({"password": hash_psw(new_password)}): puts("Error: Firegex is not running and no standalone data found.", color=colors.red) exit(1) puts("Password changed successfully! It will take effect immediately.", color=colors.green) # Setting a password is asking for one to be asked for. A password that is stored # and not in force is worse than no password, because it reads as one — so when # authentication is off, this turns it back on rather than reporting success and # leaving every caller an administrator. Both halves, or the two would disagree # until the next restart: the database is what the running process reads, the # config file is what the next one comes up with. # # Not when the same command line also said otherwise. `--unsafe-disable-auth` # passed here is an explicit instruction and wins; `--keep-auth-disabled` is for # the one case where storing a password without putting it in force is the point # — an instance held open behind a proxy that authenticates, with a password kept # ready for the day it is not. asked_otherwise = (getattr(args, 'unsafe_disable_auth', None) is not None or getattr(args, 'keep_auth_disabled', False)) if get_runtime_setting("auth_disabled") == "1": if asked_otherwise: puts("Authentication stays disabled, so nothing asks for it yet.", color=colors.yellow) puts("Put it in force with 'python3 run.py config --no-unsafe-disable-auth'.", color=colors.yellow) else: config["unsafe_disable_auth"] = False put_runtime_settings({"auth_disabled": "0"}) puts("Authentication was disabled on this instance: turned back on, so the new " "password is asked for.", color=colors.green, is_bold=True) puts("Pass --keep-auth-disabled to store a password without putting it in force.", color=colors.yellow) config_changed = True if config_changed: if save_config(config): puts(f"Configuration saved to {g.configfile}", color=colors.green) else: puts("Failed to save configuration", color=colors.red) exit(1) else: puts("No configuration changes specified. Use --show to view current configuration.", color=colors.yellow) def status_standalone(): """Show standalone mode status""" puts("Standalone mode status:", color=colors.cyan, is_bold=True) # Check if running if is_standalone_running(): pid = read_pid_file() puts(f"Status: Running (PID: {pid})", color=colors.green) puts(f"Web interface: {get_web_interface_url()}", color=colors.cyan) else: puts("Status: Not running", color=colors.red) if os.path.exists(g.rootfs_path): puts(f"Rootfs: Available ({g.rootfs_path})", color=colors.white) else: puts("Rootfs: Not available", color=colors.yellow) def main(): # Check if we should use standalone mode if should_use_standalone(): if not is_linux(): puts("Standalone mode only works on Linux!", color=colors.red) puts("Please install Docker and Docker Compose.", color=colors.red) exit(1) g.standalone_mode = True if args.standalone: puts("Standalone mode forced by --standalone option", color=colors.cyan) elif is_standalone_running(): puts("Standalone mode already running, using it", color=colors.cyan) else: puts("Docker not available or in rootless mode, using standalone mode", color=colors.yellow) # Ensure we have root privileges for standalone mode operations if not is_root(): puts("Standalone mode requires root privileges. 'sudo' will be used.", color=colors.yellow) if args.command == "start" or args.command is None: # Check if already running if is_standalone_running(): pid = read_pid_file() puts(f"Firegex is already running in standalone mode! (PID: {pid})", color=colors.yellow) puts(f"Web interface available at: {get_web_interface_url()}", color=colors.cyan) return if not setup_standalone_rootfs(): exit(1) if not setup_standalone_mounts(): exit(1) run_standalone() elif args.command == "stop": stop_standalone() elif args.command == "status": status_standalone() elif args.command == "restart": stop_standalone() if not setup_standalone_mounts(): exit(1) run_standalone() elif args.command == "config": handle_config_command(args) else: puts("Command not supported in standalone mode", color=colors.red) exit(1) # Handle clear option for standalone mode if args.clear: clear_standalone() return # Original Docker-based logic sudo = "sudo " if g.docker_sudo else "" if not cmd_check(f"{sudo}docker --version"): puts("Docker not found! please install docker and docker compose!", color=colors.red) exit() elif not cmd_check(f"{sudo}docker-compose --version") and not cmd_check(f"{sudo}docker compose --version"): puts("Docker compose not found! please install docker compose!", color=colors.red) exit() if not cmd_check(f"{sudo}docker ps"): puts("Cannot use docker, the user hasn't the permission or docker isn't running", color=colors.red) exit() if not is_linux(): sep() puts("--- WARNING ---", color=colors.yellow) puts("You are not in a linux machine, the firewall will not work in this machine.", color=colors.red) sep() elif not nfqueue_exists(): sep() puts("--- WARNING ---", color=colors.yellow) puts("The nfqueue kernel module seems not loaded, some features of firegex may not work.", color=colors.red) sep() if args.command: match args.command: case "start": if check_already_running(): puts("Firegex is already running! use --help to see options useful to manage firegex execution", color=colors.yellow) else: puts("Firegex", color=colors.yellow, end="") puts(" will start on port ", end="") puts(f"{args.port}", color=colors.cyan) warn_if_auth_disabled() write_compose(skip_password=False) if not g.build: puts(f"Downloading docker image from github packages '{sudo}docker pull ghcr.io/pwnzer0tt1/firegex'", color=colors.green) cmd_check(f"{sudo}docker pull ghcr.io/pwnzer0tt1/firegex:{args.version}", print_output=True) puts("Running 'docker compose up -d --build'\n", color=colors.green) composecmd("up -d --build", g.composefile) case "compose": write_compose() compose_cmd = " ".join(args.compose_args) puts(f"Running 'docker compose {compose_cmd}'\n", color=colors.green) composecmd(compose_cmd, g.composefile) case "restart": if check_already_running(): warn_if_auth_disabled() write_compose() # A plain `restart` preserves the old container environment, so # changes to access-control options would not take effect. puts("Running 'docker compose up -d --force-recreate'\n", color=colors.green) composecmd("up -d --force-recreate", g.composefile) else: puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True) case "stop": if check_already_running(): write_compose() puts("Running 'docker compose down'\n", color=colors.green) composecmd("down", g.composefile) else: puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True) case "status": if check_already_running(): puts("Firegex is running in Docker mode", color=colors.green) puts(f"Web interface: {get_web_interface_url()}", color=colors.cyan) else: puts("Firegex is not running", color=colors.red) case "config": handle_config_command(args) if args.command not in ("config", "status"): write_compose() if args.clear: if volume_exists(): delete_volume() else: puts("Firegex volume not found!", color=colors.red) if "logs" in args and args.logs: composecmd("logs -f") if __name__ == "__main__": try: main() except KeyboardInterrupt: print()