From 60cf223293bc51dc9363b2597d0bec2c8a3c8e3b Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Wed, 22 Apr 2026 16:15:58 -0400 Subject: [PATCH 1/5] 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, From 323729e1abc6e46cad9b1a0d882ad81f6e6939c6 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 24 Apr 2026 00:33:30 -0400 Subject: [PATCH 2/5] feat: validate ip_range must be within RFC-1918 on save API: rejects ip_range outside 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16 with a 400 error before saving to config. UI: isRFC1918Cidr() validates on every keystroke; error message shown inline below the field; Save Identity button disabled while the value is invalid. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 19 ++++++++++++++++ webui/src/pages/Settings.jsx | 44 ++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/api/app.py b/api/app.py index 31a3e08..62bcbe5 100644 --- a/api/app.py +++ b/api/app.py @@ -435,6 +435,25 @@ def update_config(): # Handle identity fields (cell_name, domain, ip_range, wireguard_port) identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'} identity_updates = {k: v for k, v in data.items() if k in identity_keys} + + # Validate ip_range — must be a valid CIDR within an RFC-1918 range + if 'ip_range' in identity_updates: + import ipaddress as _ipa + _rfc1918 = [ + _ipa.ip_network('10.0.0.0/8'), + _ipa.ip_network('172.16.0.0/12'), + _ipa.ip_network('192.168.0.0/16'), + ] + try: + _net = _ipa.ip_network(identity_updates['ip_range'], strict=False) + if not any(_net.subnet_of(r) for r in _rfc1918): + return jsonify({'error': ( + 'ip_range must be within an RFC-1918 private range ' + '(10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16)' + )}), 400 + except ValueError as _e: + return jsonify({'error': f'Invalid ip_range: {_e}'}), 400 + # Capture old identity and service configs BEFORE saving, for change detection old_identity = dict(config_manager.configs.get('_identity', {})) old_svc_configs = { diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index 7223b9f..acef2e3 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -69,14 +69,39 @@ function Section({ icon: Icon, title, children, collapsible = false, defaultOpen ); } +// ── RFC-1918 validation ─────────────────────────────────────────────────────── + +function isRFC1918Cidr(cidr) { + if (!cidr || !cidr.trim()) return false; + const m = cidr.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/); + if (!m) return false; + const [, a, b, c, d, p] = m.map(Number); + if ([a, b, c, d].some(n => n < 0 || n > 255) || p < 0 || p > 32) return false; + const ip = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; + const ranges = [ + { net: 0x0a000000, prefix: 8 }, // 10.0.0.0/8 + { net: 0xac100000, prefix: 12 }, // 172.16.0.0/12 + { net: 0xc0a80000, prefix: 16 }, // 192.168.0.0/16 + ]; + for (const { net, prefix } of ranges) { + if (p < prefix) continue; + const mask = (0xffffffff << (32 - prefix)) >>> 0; + if ((ip & mask) >>> 0 === (net & mask) >>> 0) return true; + } + return false; +} + // ── Field components ────────────────────────────────────────────────────────── -function Field({ label, children, hint }) { +function Field({ label, children, hint, error }) { return ( -
- -
{children}
- {hint && {hint}} +
+ +
+ {children} + {error &&

{error}

} +
+ {hint && !error && {hint}}
); } @@ -338,7 +363,12 @@ function Settings() { }; // identity save + const ipRangeError = identity.ip_range && !isRFC1918Cidr(identity.ip_range) + ? 'Must be within an RFC-1918 range: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16' + : null; + const saveIdentity = async () => { + if (ipRangeError) return; setIdentitySaving(true); try { const res = await cellAPI.updateConfig(identity); @@ -475,7 +505,7 @@ function Settings() { placeholder="cell.local" /> - + { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }} @@ -486,7 +516,7 @@ function Settings() {