From 60cf223293bc51dc9363b2597d0bec2c8a3c8e3b Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Wed, 22 Apr 2026 16:15:58 -0400 Subject: [PATCH] fix: is_local_request rejects non-RFC1918 cell subnets; helper image hardcoded Two bugs triggered when ip_range is set to a subnet outside 172.16.0.0/12 (e.g. 172.0.0.0/24): 1. is_local_request() used ip.is_private which returns False for 172.0.x.x, causing Caddy reverse-proxy requests to get 403 on the containers endpoint. Fix: also accept IPs in the configured cell-network subnet. 2. apply_pending_config() hardcoded 'pic_api:latest' as the helper container image. docker-compose v1 builds pic_api:latest (underscore) but compose v2+ builds pic-api:latest (hyphen). On a v2 install the helper would fail to start silently, leaving the network unreconstructed after an ip_range change. Fix: read the actual image tag from cell-api's own container metadata. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 56 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/api/app.py b/api/app.py index 18a0520..31a3e08 100644 --- a/api/app.py +++ b/api/app.py @@ -320,31 +320,35 @@ health_monitor_thread = threading.Thread(target=health_monitor_loop, daemon=True health_monitor_thread.start() def is_local_request(): - # Allow requests from localhost, Docker networks, and internal IPs remote_addr = request.remote_addr forwarded_for = request.headers.get('X-Forwarded-For', '') - - # Check direct remote address - if remote_addr in ('127.0.0.1', '::1', 'localhost'): - return True - - # Check forwarded address (for reverse proxy scenarios) - if forwarded_for: - forwarded_ips = [ip.strip() for ip in forwarded_for.split(',')] - for ip in forwarded_ips: - if ip in ('127.0.0.1', '::1', 'localhost'): - return True - - # Allow Docker internal networks (172.x.x.x, 192.168.x.x, 10.x.x.x) - if remote_addr: + + def _allowed(addr): + if not addr: + return False + if addr in ('127.0.0.1', '::1', 'localhost'): + return True try: - import ipaddress - ip = ipaddress.ip_address(remote_addr) + import ipaddress as _ipa + ip = _ipa.ip_address(addr) if ip.is_private or ip.is_loopback: return True - except: + # Also allow IPs in the configured cell-network, which may fall outside + # RFC-1918 (e.g. 172.0.0.0/24 is not in 172.16.0.0/12). + cell_net = config_manager.configs.get('_identity', {}).get( + 'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')) + if ip in _ipa.ip_network(cell_net, strict=False): + return True + except Exception: pass - + return False + + if _allowed(remote_addr): + return True + if forwarded_for: + for addr in forwarded_for.split(','): + if _allowed(addr.strip()): + return True return False @app.route('/health', methods=['GET']) @@ -692,13 +696,19 @@ def apply_pending_config(): if not pending.get('needs_restart'): return jsonify({'message': 'No pending changes to apply'}) - # Get project working dir from our own container labels (set by docker-compose) + # Get project working dir and image name from our own container labels project_dir = '/home/roof/pic' + api_image = 'pic_api:latest' # fallback (docker-compose v1 naming) try: import docker as _docker_sdk _client = _docker_sdk.from_env() _self = _client.containers.get('cell-api') project_dir = _self.labels.get('com.docker.compose.project.working_dir', project_dir) + # Use the actual image tag so the helper works regardless of compose version + # (docker-compose v1 builds pic_api:latest, compose v2+ builds pic-api:latest) + tags = _self.image.tags + if tags: + api_image = tags[0] except Exception: pass @@ -717,8 +727,8 @@ def apply_pending_config(): if '*' in containers: # All-services restart: `docker compose down` or `up -d` may stop/recreate the # API container itself, killing this background thread mid-operation. - # Spawn an independent helper container using pic_api:latest that has docker CLI - # and survives cell-api being stopped/recreated. + # Spawn an independent helper container (same image as cell-api) that has docker + # CLI and survives cell-api being stopped/recreated. if needs_network_recreate: helper_script = ( f'sleep 2' @@ -741,7 +751,7 @@ def apply_pending_config(): '-v', '/var/run/docker.sock:/var/run/docker.sock', '-v', f'{project_dir}:{project_dir}', '--entrypoint', 'sh', - 'pic_api:latest', + api_image, '-c', helper_script], close_fds=True, stdout=_subprocess.DEVNULL,