fix: get_live_service_vips uses config API, require CIDR prefix for ip_range

- tests/integration/conftest.py: get_live_service_vips() now reads from the
  config API's service_ips field instead of docker exec.  The docker exec approach
  spawns a fresh Python process that imports firewall_manager with its hardcoded
  initial SERVICE_IPS, ignoring any update_service_ips() calls made at runtime.
  The config API always computes VIPs from the current ip_range, so it matches what
  the running app actually uses when writing iptables rules.

- api/app.py: reject ip_range values without a CIDR prefix (e.g. '10.0.0.1')
  with a 400.  Bare IPs are parsed as /32 by ipaddress.ip_network(strict=False),
  which shifts all VIP offsets and produces unusable Docker subnet configs.

- tests/integration/test_config_api.py: update bare-ip test to expect 400 now
  that the API enforces the prefix requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 04:54:47 -04:00
parent 768571f2b7
commit 3ce45a8911
3 changed files with 15 additions and 18 deletions
+4 -1
View File
@@ -447,7 +447,10 @@ def update_config():
_ipa.ip_network('192.168.0.0/16'), _ipa.ip_network('192.168.0.0/16'),
] ]
try: try:
_net = _ipa.ip_network(identity_updates['ip_range'], strict=False) _raw = str(identity_updates['ip_range'])
if '/' not in _raw:
return jsonify({'error': 'ip_range must include a CIDR prefix (e.g. 172.20.0.0/16)'}), 400
_net = _ipa.ip_network(_raw, strict=False)
if not any(_net.subnet_of(r) for r in _rfc1918): if not any(_net.subnet_of(r) for r in _rfc1918):
return jsonify({'error': ( return jsonify({'error': (
'ip_range must be within an RFC-1918 private range ' 'ip_range must be within an RFC-1918 private range '
+7 -13
View File
@@ -73,20 +73,14 @@ def peer_rules(peer_ip: str) -> list[str]:
def get_live_service_vips() -> dict: def get_live_service_vips() -> dict:
""" """
Read SERVICE_IPS directly from the running API container. Read virtual IPs from the config API.
More reliable than the config API since SERVICE_IPS may not match ip_range
when the container was built before an ip_range change. The config API computes service_ips from the current ip_range at request time,
so it always matches what the running firewall_manager will use when applying
peer rules. Using docker exec on the API container is NOT reliable because
it spawns a fresh Python process that imports firewall_manager with its initial
hardcoded SERVICE_IPS, ignoring any update_service_ips() calls made at runtime.
""" """
import json
result = subprocess.run(
['docker', 'exec', 'cell-api', 'python3', '-c',
'import sys; sys.path.insert(0,"/app/api");'
' from firewall_manager import SERVICE_IPS; import json; print(json.dumps(SERVICE_IPS))'],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return json.loads(result.stdout)
# Fallback: derive from config API
cfg = requests.get(f"{API_BASE}/api/config").json() cfg = requests.get(f"{API_BASE}/api/config").json()
sips = cfg.get('service_ips', {}) sips = cfg.get('service_ips', {})
return { return {
+4 -4
View File
@@ -176,11 +176,11 @@ class TestPutConfigValidation:
r = put('/api/config', json={'ip_range': 'not-an-ip'}) r = put('/api/config', json={'ip_range': 'not-an-ip'})
assert r.status_code == 400 assert r.status_code == 400
def test_put_config_ip_range_bare_ip_behavior(self): def test_put_config_ip_range_bare_ip_returns_400(self):
# Bare IP is interpreted as /32 — the API may accept or reject it, # Bare IP without CIDR prefix must be rejected /32 networks are
# but it must not crash (no 500). # accepted by Python but useless as a Docker subnet.
r = put('/api/config', json={'ip_range': '10.0.0.1'}) r = put('/api/config', json={'ip_range': '10.0.0.1'})
assert r.status_code in (200, 400) assert r.status_code == 400
def test_put_config_calendar_port_zero_returns_400(self): def test_put_config_calendar_port_zero_returns_400(self):
r = put('/api/config', json={'calendar': {'port': 0}}) r = put('/api/config', json={'calendar': {'port': 0}})