CUT-22: add reverse-proxy support to firegex

This commit is contained in:
Savva Serebriakov 2026-09-02 19:08:51 +03:00
parent f3ce593af5
commit 6fb08f41f6
4 changed files with 52 additions and 10 deletions

View File

@ -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`. 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 ## 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. 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.

View File

@ -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()] 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", "") PROXY_IP_HEADER = os.getenv("PROXY_IP_HEADER", "")
DISABLE_AUTH = os.getenv("DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"}
class IPFilterMiddleware: class IPFilterMiddleware:
def __init__(self, app): 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) sio_app = socketio.ASGIApp(utils.socketio, socketio_path="/sock/socket.io", other_asgi_app=app)
app.mount("/sock", sio_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 JWT_SECRET(): return db.get("secret")
def _hash_psw_sync(psw: str) -> str: def _hash_psw_sync(psw: str) -> str:
@ -150,6 +151,8 @@ async def refresh_frontend(additional:list[str]=[]):
await socketio_emit([]+additional) await socketio_emit([]+additional)
async def check_login(token: str = Depends(oauth2_scheme)): async def check_login(token: str = Depends(oauth2_scheme)):
if DISABLE_AUTH:
return True
if not token: if not token:
return False return False
try: try:
@ -161,7 +164,7 @@ async def check_login(token: str = Depends(oauth2_scheme)):
@utils.socketio.on("connect") @utils.socketio.on("connect")
async def sio_connect(sid, environ, auth): 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") raise ConnectionRefusedError("Unauthorized")
utils.sid_list.add(sid) utils.sid_list.add(sid)

28
run.py
View File

@ -105,7 +105,8 @@ def load_config():
"host": "any", "host": "any",
"socket_dir": None, "socket_dir": None,
"allowed_ips": None, "allowed_ips": None,
"proxy_ip_header": None "proxy_ip_header": None,
"disable_auth": False,
} }
if os.path.isfile(g.configfile): 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('--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('--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('--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('--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('--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('--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('--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('--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('--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('--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) 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('--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('--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('--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) parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False)
args = parser.parse_args(args=args_to_parse) 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"): 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["proxy_ip_header"] = args.proxy_ip_header
config_changed = True 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: if config_changed:
save_config(config) save_config(config)
@ -360,7 +367,8 @@ def write_compose(skip_password = True):
*(["SOCKET_DIR=/run/firegex"] if args.socket_dir else []), *(["SOCKET_DIR=/run/firegex"] if args.socket_dir else []),
*([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() 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"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": [ "volumes": [
"firegex_data:/execute/db", "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"PSW_HASH_SET={hash_psw(psw_set)}"] if psw_set else []),
*([f"FIREGEX_VERSION={get_git_version()}"] if get_git_version() 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"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": [ "volumes": [
"firegex_data:/execute/db" "firegex_data:/execute/db"
@ -432,6 +441,10 @@ def write_compose(skip_password = True):
})) }))
def get_password(): 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"))): 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 return None
if args.startup_psw: if args.startup_psw:
@ -885,6 +898,8 @@ def run_standalone():
env_vars.append(f"ALLOWED_IPS={args.allowed_ips}") env_vars.append(f"ALLOWED_IPS={args.allowed_ips}")
if getattr(args, 'proxy_ip_header', None): if getattr(args, 'proxy_ip_header', None):
env_vars.append(f"PROXY_IP_HEADER={args.proxy_ip_header}") 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 # Add password if set
psw_set = get_password() psw_set = get_password()
@ -974,6 +989,7 @@ def handle_config_command(args):
puts(f"Port: {config['port']}", color=colors.white) puts(f"Port: {config['port']}", color=colors.white)
puts(f"Host: {config['host']}", color=colors.white) puts(f"Host: {config['host']}", color=colors.white)
puts(f"Socket dir: {config['socket_dir']}", 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) puts(f"Config file: {g.configfile}", color=colors.white)
return return
@ -1183,8 +1199,10 @@ def main():
case "restart": case "restart":
if check_already_running(): if check_already_running():
write_compose() write_compose()
puts("Running 'docker compose restart'\n", color=colors.green) # A plain `restart` preserves the old container environment, so
composecmd("restart", g.composefile) # 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: else:
puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True) puts("Firegex is not running!" , color=colors.red, is_bold=True, flush=True)
case "stop": case "stop":

View File

@ -53,8 +53,8 @@ def wait_ready(timeout=90):
return False return False
def restart_with(allowed_ips=None, proxy_ip_header=None): 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}) ---") 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") run_py("stop")
args = ["start", "-P", "testpassword"] args = ["start", "-P", "testpassword"]
# An empty string still needs to be passed explicitly to clear a previous value. # 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] args += ["--allowed-ips", allowed_ips]
if proxy_ip_header is not None: if proxy_ip_header is not None:
args += ["--proxy-ip-header", proxy_ip_header] 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) run_py(*args)
if not wait_ready(): if not wait_ready():
print("FATAL: firegex didn't come back up after restart") 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"}) 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}") 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: finally:
if not args.skip_cleanup_restart: if not args.skip_cleanup_restart:
restart_with(allowed_ips="") restart_with(allowed_ips="", disable_auth=False)
print() print()
if failures: if failures: