From 6fb08f41f65ddf269355c1bb0d86a1919334df16 Mon Sep 17 00:00:00 2001 From: Savva Serebriakov Date: Wed, 2 Sep 2026 19:08:51 +0300 Subject: [PATCH] CUT-22: add reverse-proxy support to firegex --- README.md | 10 ++++++++++ backend/app.py | 7 +++++-- run.py | 28 +++++++++++++++++++++++----- tests/ipfilter_test.py | 17 ++++++++++++++--- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index aebfe0d..8e650d2 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,16 @@ This is especially useful if you lost the current password: it's applied directl Firegex can also be restricted to accept connections only from a set of trusted CIDR ranges (`--allowed-ips`, optionally combined with `--proxy-ip-header` when running behind a reverse proxy) — see `python3 run.py start -h`. +### Reverse proxy without Firegex authentication + +To delegate access control entirely to a reverse proxy, start Firegex with `--disable-auth`: + +```bash +python3 run.py start --host 127.0.0.1 --disable-auth +``` + +This disables Firegex's password, JWT, and Socket.IO authentication, and it does not ask for an initial password. Requests forwarded by the proxy, including headers such as `X-Forwarded-For`, reach Firegex normally. This grants full administrative access to every request that reaches Firegex, so bind it to loopback, a Unix socket, or otherwise restrict direct access to the proxy. Use `--no-disable-auth` to turn the built-in authentication back on. + ## Documentation Each module above has its own markdown guide under [`docs/`](docs/), covering how to use it and how it works internally. The same files are rendered directly in the Firegex web interface (via the docs button on each page), so they're always in sync with what you see in the app. diff --git a/backend/app.py b/backend/app.py index b934522..26660f6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -39,6 +39,7 @@ async def lifespan(app): ALLOWED_NETWORKS = [ip_network(ip.strip(), strict=False) for ip in os.getenv("ALLOWED_IPS", "").split(",") if ip.strip()] PROXY_IP_HEADER = os.getenv("PROXY_IP_HEADER", "") +DISABLE_AUTH = os.getenv("DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"} class IPFilterMiddleware: def __init__(self, app): @@ -116,7 +117,7 @@ utils.socketio = socketio.AsyncServer( sio_app = socketio.ASGIApp(utils.socketio, socketio_path="/sock/socket.io", other_asgi_app=app) app.mount("/sock", sio_app) -def APP_STATUS(): return "init" if db.get("password") is None else "run" +def APP_STATUS(): return "run" if DISABLE_AUTH or db.get("password") is not None else "init" def JWT_SECRET(): return db.get("secret") def _hash_psw_sync(psw: str) -> str: @@ -150,6 +151,8 @@ async def refresh_frontend(additional:list[str]=[]): await socketio_emit([]+additional) async def check_login(token: str = Depends(oauth2_scheme)): + if DISABLE_AUTH: + return True if not token: return False try: @@ -161,7 +164,7 @@ async def check_login(token: str = Depends(oauth2_scheme)): @utils.socketio.on("connect") async def sio_connect(sid, environ, auth): - if not auth or not await check_login(auth.get("token")): + if not DISABLE_AUTH and (not auth or not await check_login(auth.get("token"))): raise ConnectionRefusedError("Unauthorized") utils.sid_list.add(sid) diff --git a/run.py b/run.py index e66dd19..10e2604 100755 --- a/run.py +++ b/run.py @@ -105,7 +105,8 @@ def load_config(): "host": "any", "socket_dir": None, "allowed_ips": None, - "proxy_ip_header": None + "proxy_ip_header": None, + "disable_auth": False, } if os.path.isfile(g.configfile): @@ -167,6 +168,7 @@ def gen_args(args_to_parse: list[str]|None = None): 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('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Disable Firegex password/JWT authentication (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) @@ -183,6 +185,7 @@ def gen_args(args_to_parse: list[str]|None = None): 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('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Disable Firegex password/JWT authentication (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) @@ -201,6 +204,7 @@ def gen_args(args_to_parse: list[str]|None = None): 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('--disable-auth', action=argparse.BooleanOptionalAction, default=config.get("disable_auth", False), help='Persist whether Firegex password/JWT authentication is disabled') parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False) args = parser.parse_args(args=args_to_parse) @@ -257,6 +261,9 @@ def gen_args(args_to_parse: list[str]|None = None): 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 hasattr(args, 'disable_auth') and args.disable_auth != config.get("disable_auth", False): + config["disable_auth"] = args.disable_auth + config_changed = True if config_changed: save_config(config) @@ -360,7 +367,8 @@ def write_compose(skip_password = True): *(["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 []) + *([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []), + *(["DISABLE_AUTH=1"] if getattr(args, 'disable_auth', False) else []) ], "volumes": [ "firegex_data:/execute/db", @@ -416,7 +424,8 @@ def write_compose(skip_password = True): *([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 []) + *([f"PROXY_IP_HEADER={args.proxy_ip_header}"] if getattr(args, 'proxy_ip_header', None) else []), + *(["DISABLE_AUTH=1"] if getattr(args, 'disable_auth', False) else []) ], "volumes": [ "firegex_data:/execute/db" @@ -432,6 +441,10 @@ def write_compose(skip_password = True): })) def get_password(): + # Authentication is delegated to the reverse proxy in this mode, so never + # prompt for or persist an unused Firegex password during initial startup. + if getattr(args, 'disable_auth', False): + return None 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: @@ -885,6 +898,8 @@ def run_standalone(): 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, 'disable_auth', False): + env_vars.append("DISABLE_AUTH=1") # Add password if set psw_set = get_password() @@ -974,6 +989,7 @@ def handle_config_command(args): 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('disable_auth', False)}", color=colors.white) puts(f"Config file: {g.configfile}", color=colors.white) return @@ -1183,8 +1199,10 @@ def main(): case "restart": if check_already_running(): write_compose() - puts("Running 'docker compose restart'\n", color=colors.green) - composecmd("restart", g.composefile) + # 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": diff --git a/tests/ipfilter_test.py b/tests/ipfilter_test.py index 53896b0..6f5578c 100644 --- a/tests/ipfilter_test.py +++ b/tests/ipfilter_test.py @@ -53,8 +53,8 @@ def wait_ready(timeout=90): return False -def restart_with(allowed_ips=None, proxy_ip_header=None): - print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}) ---") +def restart_with(allowed_ips=None, proxy_ip_header=None, disable_auth=None): + print(f"\n--- restarting firegex (allowed_ips={allowed_ips!r}, proxy_ip_header={proxy_ip_header!r}, disable_auth={disable_auth!r}) ---") run_py("stop") args = ["start", "-P", "testpassword"] # An empty string still needs to be passed explicitly to clear a previous value. @@ -62,6 +62,8 @@ def restart_with(allowed_ips=None, proxy_ip_header=None): args += ["--allowed-ips", allowed_ips] if proxy_ip_header is not None: args += ["--proxy-ip-header", proxy_ip_header] + if disable_auth is not None: + args += ["--disable-auth" if disable_auth else "--no-disable-auth"] run_py(*args) if not wait_ready(): print("FATAL: firegex didn't come back up after restart") @@ -135,9 +137,18 @@ if __name__ == "__main__": r = requests.get(BASE + "api/status", headers={"X-Forwarded-For": "not-an-ip"}) check("malformed PROXY_IP_HEADER value -> fails closed (403)", r.status_code == 403, f"got {r.status_code}") + # 6. Built-in authentication can be disabled for a reverse proxy that owns + # access control. Protected API routes and the status endpoint then work + # without a bearer token. + restart_with(allowed_ips="127.0.0.1/32", disable_auth=True) + r = requests.get(BASE + "api/status") + check("DISABLE_AUTH reports a logged-in running app", r.status_code == 200 and r.json().get("status") == "run" and r.json().get("loggined") is True, f"got {r.status_code}: {r.text}") + r = requests.get(BASE + "api/interfaces") + check("DISABLE_AUTH permits protected API without bearer token", r.status_code == 200, f"got {r.status_code}") + finally: if not args.skip_cleanup_restart: - restart_with(allowed_ips="") + restart_with(allowed_ips="", disable_auth=False) print() if failures: