mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
CUT-22: add reverse-proxy support to firegex
This commit is contained in:
parent
f3ce593af5
commit
6fb08f41f6
10
README.md
10
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`.
|
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.
|
||||||
|
|||||||
@ -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
28
run.py
@ -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":
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user