Files
pic/api/ip_utils.py
roof a43f9fbf0d fix: full security audit remediation — P0/P1/P2/P3 fixes + 1020 passing tests
P0 — Broken functionality:
- Fix 12+ endpoints with wrong manager method signatures (email/calendar/file/routing)
- Fix email_manager.delete_email_user() missing domain arg
- Fix cell-link DNS forwarding wiped on every peer change (generate_corefile now
  accepts cell_links param; add/remove_cell_dns_forward no longer clobber the file)
- Fix Flask SECRET_KEY regenerating on every restart (persisted to DATA_DIR)
- Fix _next_peer_ip exhaustion returning 500 instead of 409
- Fix ConfigManager Caddyfile path (/app/config-caddy/)
- Fix UI double-add and wrong-key peer bugs in Peers.jsx / WireGuard.jsx
- Remove hardcoded credentials from Dashboard.jsx

P1 — Security:
- CSRF token validation on all POST/PUT/DELETE/PATCH to /api/* (double-submit pattern)
- enforce_auth: 503 only when users file readable but empty; never bypass on IOError
- WireGuard add_cell_peer: validate pubkey, name, endpoint against strict regexes
- DNS add_cell_dns_forward: validate IP and domain; reject injection chars
- DNS zone write: realpath containment + record content validation
- iptables comment /32 suffix prevents substring match deleting wrong peer rules
- is_local_request() trusts only loopback + 172.16.0.0/12 (Docker bridge)
- POST /api/containers: volume allow-list prevents arbitrary host mounts
- file_manager: bcrypt ($2b→$2y) for WebDAV; realpath containment in delete_user
- email/calendar: stop persisting plaintext passwords in user records
- routing_manager: validate IPs, networks, and interface names
- peer_registry: write peers.json at mode 0o600
- vault_manager: Fernet key file at mode 0o600
- CORS: lock down to explicit origin list
- domain/cell_name validation: reject newline, brace, semicolon injection chars

P2 — Architecture:
- Peer add: rollback registry entry if firewall rules fail post-add
- restart_service(): base class now calls _restart_container(); email and calendar
  managers call cell-mail / cell-radicale respectively
- email/calendar managers sync user list (no passwords) to cell_config.json
- Pending-restart flag cleared only after helper subprocess exits with code 0
- docker-compose.yml: add config-caddy volume to API container

P3 — Tests (854 → 1020):
- Fill test_email_endpoints.py, test_calendar_endpoints.py,
  test_network_endpoints.py, test_routing_endpoints.py
- New: test_peer_management_update.py, test_peer_management_edge_cases.py,
  test_input_validation.py, test_enforce_auth_configured.py,
  test_cell_link_dns.py, test_logs_endpoints.py, test_cells_endpoints.py,
  test_is_local_request_per_endpoint.py, test_caddy_routing.py
- E2E conftest: skip WireGuard suite when wg-quick absent
- Update existing tests to match fixed signatures and comment formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:30:21 -04:00

250 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
IP utility functions for PIC — derive all container and virtual IPs from the
docker network subnet so that one ip_range setting drives everything.
The canonical source of IPs is the .env file at the project root.
docker-compose.yml uses ${VAR:-default} substitution to read from it.
"""
import ipaddress
import os
from typing import Dict, List, Optional
# Fixed host-number offsets within the subnet (e.g. 172.20.0.0/16 → 172.20.0.<offset>)
CONTAINER_OFFSETS: Dict[str, int] = {
'caddy': 2,
'dns': 3,
'dhcp': 4,
'ntp': 5,
'mail': 6,
'radicale': 7,
'webdav': 8,
'wireguard': 9,
'api': 10,
'webui': 11,
'rainloop': 12,
'filegator': 13,
# Caddy virtual IPs — each service gets its own IP so Caddy can route by dst addr
'vip_calendar': 21,
'vip_files': 22,
'vip_mail': 23,
'vip_webdav': 24,
}
# Mapping from service key → docker-compose env var name (static containers only)
ENV_VAR_NAMES: Dict[str, str] = {
'caddy': 'CADDY_IP',
'dns': 'DNS_IP',
'dhcp': 'DHCP_IP',
'ntp': 'NTP_IP',
'mail': 'MAIL_IP',
'radicale': 'RADICALE_IP',
'webdav': 'WEBDAV_IP',
'wireguard': 'WG_IP',
'api': 'API_IP',
'webui': 'WEBUI_IP',
'rainloop': 'RAINLOOP_IP',
'filegator': 'FILEGATOR_IP',
}
# Default host-port bindings for each service
PORT_DEFAULTS: Dict[str, int] = {
'dns_port': 53,
'dhcp_port': 67,
'ntp_port': 123,
'mail_smtp_port': 25,
'mail_submission_port': 587,
'mail_imap_port': 993,
'radicale_port': 5232,
'webdav_port': 8080,
'wg_port': 51820,
'api_port': 3000,
'webui_port': 8081,
'rainloop_port': 8888,
'filegator_port': 8082,
}
# Mapping from port key → docker-compose env var name
PORT_ENV_VAR_NAMES: Dict[str, str] = {
'dns_port': 'DNS_PORT',
'dhcp_port': 'DHCP_PORT',
'ntp_port': 'NTP_PORT',
'mail_smtp_port': 'MAIL_SMTP_PORT',
'mail_submission_port': 'MAIL_SUBMISSION_PORT',
'mail_imap_port': 'MAIL_IMAP_PORT',
'radicale_port': 'RADICALE_PORT',
'webdav_port': 'WEBDAV_PORT',
'wg_port': 'WG_PORT',
'api_port': 'API_PORT',
'webui_port': 'WEBUI_PORT',
'rainloop_port': 'RAINLOOP_PORT',
'filegator_port': 'FILEGATOR_PORT',
}
# Mapping from port key → docker-compose service name(s) that must restart on port change
PORT_TO_CONTAINERS: Dict[str, List[str]] = {
'dns_port': ['dns'],
'dhcp_port': ['dhcp'],
'ntp_port': ['ntp'],
'mail_smtp_port': ['mail'],
'mail_submission_port': ['mail'],
'mail_imap_port': ['mail'],
'radicale_port': ['radicale'],
'webdav_port': ['webdav'],
'wg_port': ['wireguard'],
'api_port': ['api'],
'webui_port': ['webui'],
'rainloop_port': ['rainloop'],
'filegator_port': ['filegator'],
}
def get_service_ips(ip_range: str) -> Dict[str, str]:
"""
Derive all container and virtual IPs from the docker network subnet.
Example: '172.20.0.0/16'{'caddy': '172.20.0.2', 'dns': '172.20.0.3', ...}
The offset of each service within the subnet is fixed (see CONTAINER_OFFSETS).
"""
network = ipaddress.IPv4Network(ip_range, strict=False)
base = int(network.network_address)
return {
name: str(ipaddress.IPv4Address(base + offset))
for name, offset in CONTAINER_OFFSETS.items()
}
def get_virtual_ips(ip_range: str) -> Dict[str, str]:
"""
Return only the four Caddy virtual IPs keyed by service name.
Used by firewall_manager to set per-service iptables rules.
"""
ips = get_service_ips(ip_range)
return {
'calendar': ips['vip_calendar'],
'files': ips['vip_files'],
'mail': ips['vip_mail'],
'webdav': ips['vip_webdav'],
}
def write_caddyfile(ip_range: str, cell_name: str, domain: str, path: str) -> bool:
"""
Generate the Caddy reverse-proxy config from the current ip_range, cell_name, and domain.
Must be called after any ip_range or domain change so Caddy routes correctly.
Container-internal ports are fixed by docker-compose and never change.
Returns True on success.
"""
try:
ips = get_service_ips(ip_range)
caddy_ip = ips['caddy']
vip_calendar = ips['vip_calendar']
vip_files = ips['vip_files']
vip_mail = ips['vip_mail']
vip_webdav = ips['vip_webdav']
content = f"""\
{{
auto_https off
}}
# Main cell domain — no service-IP restriction needed
http://{cell_name}.{domain}, http://{caddy_ip}:80 {{
handle /api/* {{
reverse_proxy cell-api:3000
}}
handle /calendar* {{
reverse_proxy cell-radicale:5232
}}
handle /files* {{
reverse_proxy cell-filegator:8080
}}
handle /webmail* {{
reverse_proxy cell-rainloop:8888
}}
handle {{
reverse_proxy cell-webui:80
}}
}}
# Per-service virtual IPs — each gets its own IP so iptables can target them
http://calendar.{domain}, http://{vip_calendar}:80 {{
reverse_proxy cell-radicale:5232
}}
http://files.{domain}, http://{vip_files}:80 {{
reverse_proxy cell-filegator:8080
}}
http://mail.{domain}, http://webmail.{domain}, http://{vip_mail}:80 {{
reverse_proxy cell-rainloop:8888
}}
http://webdav.{domain}, http://{vip_webdav}:80 {{
reverse_proxy cell-webdav:80
}}
http://api.{domain} {{
reverse_proxy cell-api:3000
}}
http://webui.{domain} {{
reverse_proxy cell-webui:80
}}
# Catch-all for direct IP / localhost
:80 {{
handle /api/* {{
reverse_proxy cell-api:3000
}}
handle {{
reverse_proxy cell-webui:80
}}
}}
"""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
# Write in-place (same inode) so Docker bind-mounted files see the update.
# os.replace() changes the inode which breaks file bind-mounts inside containers.
with open(path, 'w') as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
return True
except Exception:
return False
def write_env_file(ip_range: str, path: str, ports: Optional[Dict[str, int]] = None) -> bool:
"""
Write (or overwrite) the docker-compose .env file with IPs and ports.
docker-compose reads this file automatically at startup to substitute
${VAR:-default} placeholders in docker-compose.yml. Call this at setup
time and whenever ip_range or port values change.
ports: override specific port defaults (keys from PORT_DEFAULTS).
Returns True on success, False if the path is not writable.
"""
try:
ips = get_service_ips(ip_range)
merged_ports = dict(PORT_DEFAULTS)
if ports:
merged_ports.update(ports)
lines = [f'CELL_NETWORK={ip_range}\n']
for svc, var in ENV_VAR_NAMES.items():
lines.append(f'{var}={ips[svc]}\n')
for key, var in PORT_ENV_VAR_NAMES.items():
lines.append(f'{var}={merged_ports[key]}\n')
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
content = ''.join(lines)
# Write in-place (same inode) so Docker bind-mounted files see the update.
# os.replace() changes the inode which breaks file bind-mounts inside containers.
with open(path, 'w') as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
return True
except Exception:
return False