Phase 4: service store — manifest validation, install/remove, Store UI

- ServiceStoreManager: manifest allowlist (git.pic.ngo/roof/*), volume
  denylist, ACCEPT-only iptables rules, ${SERVICE_IP}-only dest_ip
- IP allocator: pool 172.20.0.20-254, skips CONTAINER_OFFSETS VIPs
- Compose overlay: docker-compose.services.yml auto-included via DCF
- Flask blueprint at /api/store: list, install, remove, refresh
- Store.jsx: full install/remove UI with spinners and toast notifications
- 95 new unit tests for ServiceStoreManager (all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:19:39 -04:00
parent f77d7fabcd
commit 0a21f22076
14 changed files with 2190 additions and 12 deletions
+49
View File
@@ -804,3 +804,52 @@ def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFI
if ok:
reload_coredns()
return ok
# ---------------------------------------------------------------------------
# Service store firewall rules
# ---------------------------------------------------------------------------
def _service_tag(service_id: str) -> str:
safe = re.sub(r'[^a-z0-9]', '-', service_id.lower())
return f'pic-svc-{safe}'
def apply_service_rules(service_id: str, service_ip: str, rules: list) -> bool:
"""Apply manifest-declared ACCEPT rules for an installed service."""
tag = _service_tag(service_id)
clear_service_rules(service_id)
for r in rules:
if r.get('type') != 'ACCEPT':
continue
dest_ip = r['dest_ip'].replace('${SERVICE_IP}', service_ip)
dport = str(r['dest_port'])
proto = r.get('proto', 'tcp')
_iptables(['-I', 'FORWARD',
'-d', dest_ip, '-p', proto, '--dport', dport,
'-m', 'comment', '--comment', tag,
'-j', 'ACCEPT'])
return True
def clear_service_rules(service_id: str) -> None:
"""Remove all iptables rules tagged for this service using save/restore."""
tag = _service_tag(service_id)
comment_re = re.compile(rf'--comment\s+["\']?{re.escape(tag)}["\']?(\s|$)')
try:
save = _wg_exec(['iptables-save'])
if save.returncode != 0:
return
lines = save.stdout.splitlines()
filtered = [l for l in lines if not comment_re.search(l)]
if len(filtered) == len(lines):
return
restore_input = '\n'.join(filtered) + '\n'
restore = subprocess.run(
['docker', 'exec', '-i', WIREGUARD_CONTAINER, 'iptables-restore'],
input=restore_input, capture_output=True, text=True, timeout=10
)
if restore.returncode != 0:
logger.warning(f'clear_service_rules iptables-restore failed: {restore.stderr.strip()}')
except Exception as e:
logger.error(f'clear_service_rules({service_id}): {e}')