firegex/run.py
2026-09-18 23:01:11 +02:00

1486 lines
66 KiB
Python
Executable File

#!/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
import shlex
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)
#: What run.py sets for itself. An override here would either be silently overwritten or
#: silently win depending on which list is read first, so it is refused by name — the
#: option that owns the setting is the one to use.
MANAGED_ENV = {
"PORT", "HOST", "NTHREADS", "PSW_HASH_SET", "SOCKET_DIR", "FIREGEX_VERSION",
"ALLOWED_IPS", "PROXY_IP_HEADER", "UNSAFE_DISABLE_AUTH",
}
def merge_env(stored, given):
"""Fold `KEY=VALUE` arguments into what is already stored.
`KEY=VALUE` sets one and `KEY=` removes it; everything else is kept, because these
are settings an operator writes once and does not retype, and a bare `run.py start`
inherits the rest of its flags the same way.
"""
out = dict(stored or {})
for item in given or []:
if "=" not in item:
puts(f"Error: --env takes KEY=VALUE, not {item!r}", color=colors.red)
exit(1)
key, value = item.split("=", 1)
key = key.strip()
if not key:
puts("Error: --env needs a variable name before the '='", color=colors.red)
exit(1)
if key in MANAGED_ENV:
puts(f"Error: {key} is set by run.py itself — use its own option instead",
color=colors.red)
exit(1)
if value == "":
out.pop(key, None)
else:
out[key] = value
return out
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,
# Anything the engine or the backend reads straight out of the environment and
# run.py has no opinion about: the filter deadline, the QUIC ALPN list, the
# stream size cap. Kept here because run.py **rewrites** the compose file on
# every start, so a variable hand-edited into it is gone by the next one — which
# is what the documentation telling an operator to set FGEX_PROXY_QUIC_ALPN
# "in the container's environment" silently ran into.
"env": {},
}
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"]
if not isinstance(config.get("env"), dict):
config["env"] = {}
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('--env', '-e', required=False, action='append', metavar='KEY=VALUE', default=None, help='Extra environment variable for the firegex container, as KEY=VALUE (repeatable; KEY= removes one). For what the engine reads straight out of its environment, such as FGEX_PROXY_QUIC_ALPN. Stored, so later runs keep it.')
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('--env', '-e', required=False, action='append', metavar='KEY=VALUE', default=None, help='Extra environment variable for the firegex container, as KEY=VALUE (repeatable; KEY= removes one). For what the engine reads straight out of its environment, such as FGEX_PROXY_QUIC_ALPN. Stored, so later runs keep it.')
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('--env', '-e', required=False, action='append', metavar='KEY=VALUE', default=None, help='Extra environment variable for the firegex container, as KEY=VALUE (repeatable; KEY= removes one). For what the engine reads straight out of its environment, such as FGEX_PROXY_QUIC_ALPN. Stored, so later runs keep it.')
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 getattr(args, 'env', None):
merged = merge_env(config.get("env"), args.env)
if merged != config.get("env", {}):
config["env"] = merged
config_changed = True
# On every command, not only the ones that can change it: the compose file is written
# from `args`, and a `restart` that dropped these would be a restart that quietly
# unset them.
args.env_vars = dict(config.get("env") or {})
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 []),
# Whatever the operator asked to be here and run.py has no
# opinion about. Last, so it reads as the addition it is.
*(f"{k}={v}" for k, v in sorted(getattr(args, 'env_vars', {}).items())),
],
"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 []),
*(f"{k}={v}" for k, v in sorted(getattr(args, 'env_vars', {}).items())),
],
"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"))
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 <password>', 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")
# The same extras the container gets, because standalone is the same firegex without
# docker and an operator should not have to find that out.
for key, value in sorted(getattr(args, 'env_vars', {}).items()):
env_vars.append(shlex.quote(f"{key}={value}"))
# 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)
extra = config.get("env") or {}
puts(f"Extra environment: {', '.join(f'{k}={v}' for k, v in sorted(extra.items())) if extra else 'none'}",
color=colors.white)
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
# Folded in before this command was reached, like every other persisted flag; said
# out loud here, or `config --env ...` would report having changed nothing while
# having changed something.
if getattr(args, 'env', None):
extra = config.get("env") or {}
for item in args.env:
key = item.split("=", 1)[0].strip()
if key in extra:
puts(f"{key} set to: {extra[key]}", color=colors.green)
else:
puts(f"{key} removed", color=colors.green)
puts("It reaches the container on the next start or restart.", 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()