Implement connectivity store services (wireguard-ext, openvpn-client, tor)
Unit Tests / test (push) Successful in 11m31s

- ConnectivityManager: move config dirs to data_dir/services/<id>/config so
  Docker can bind-mount them into store-service containers (Docker resolves
  bind-mount paths on the host, not inside the API container).  Add
  _migrate_legacy_configs to copy existing files from the old config_dir
  location on first boot.

- manifest_validator: add allow_host_network parameter to
  validate_rendered_compose.  When True, waives the external-network
  requirement, permits network_mode: host, and allows devices: — all needed
  by VPN/Tor containers that must share the host network namespace to create
  tun/wg interfaces.  Non-host services are unaffected.

- service_composer: read requires_host_network from the manifest and pass
  allow_host_network=True to validate_rendered_compose for connectivity
  services.

- Tests: update file-path assertions to new data_dir layout; add
  TestMigrateLegacyConfigs, TestValidateRenderedComposeHostNetwork, and
  two TestWriteCompose cases for the host-network path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:06:48 -04:00
parent 60601eb4af
commit 7d5c5421f1
6 changed files with 306 additions and 29 deletions
+42 -5
View File
@@ -80,19 +80,56 @@ class ConnectivityManager(BaseServiceManager):
self.config_manager = config_manager
self.peer_registry = peer_registry
# Config file directories
self.connectivity_config_dir = os.path.join(config_dir, 'connectivity')
self.wireguard_ext_dir = os.path.join(self.connectivity_config_dir, 'wireguard_ext')
self.openvpn_dir = os.path.join(self.connectivity_config_dir, 'openvpn')
# Connectivity configs live under the per-service data dir so that
# ${PIC_DATA_DIR}/services/<id>/config bind mounts in store compose
# templates can read them (Docker daemon resolves paths on the HOST,
# so they must be reachable via data_dir, not config_dir).
services_dir = os.path.join(data_dir, 'services')
self.wireguard_ext_dir = os.path.join(services_dir, 'wireguard-ext', 'config')
self.openvpn_dir = os.path.join(services_dir, 'openvpn-client', 'config')
for d in (self.connectivity_config_dir, self.wireguard_ext_dir, self.openvpn_dir):
for d in (self.wireguard_ext_dir, self.openvpn_dir):
self.safe_makedirs(d)
# One-shot migration from the legacy config_dir/connectivity/ location.
_legacy_base = os.path.join(config_dir, 'connectivity')
self._migrate_legacy_configs(_legacy_base)
# Subscribe to ServiceBus CONFIG_CHANGED events so routes are
# reapplied if the underlying network changes. Done lazily —
# service_bus is a singleton imported at app startup.
self._subscribe_to_events()
# ── Legacy migration ──────────────────────────────────────────────────
def _migrate_legacy_configs(self, legacy_base: str) -> None:
"""Copy files from the old config_dir/connectivity/ tree to the new data_dir locations.
The old layout stored WireGuard and OpenVPN configs under the API container's
config_dir, which Docker cannot bind-mount into store-service containers. Files
are copied (not moved) so the legacy location still works until the operator
removes it manually.
"""
import shutil
pairs = (
(os.path.join(legacy_base, 'wireguard_ext'), self.wireguard_ext_dir),
(os.path.join(legacy_base, 'openvpn'), self.openvpn_dir),
)
for src_dir, dst_dir in pairs:
if not os.path.isdir(src_dir):
continue
try:
for fname in os.listdir(src_dir):
src_file = os.path.join(src_dir, fname)
dst_file = os.path.join(dst_dir, fname)
if os.path.isfile(src_file) and not os.path.exists(dst_file):
shutil.copy2(src_file, dst_file)
os.chmod(dst_file, 0o600)
logger.info('connectivity: migrated %s%s', src_file, dst_file)
except OSError as e:
logger.warning('connectivity: migration from %s failed: %s', src_dir, e)
# ── Event wiring ──────────────────────────────────────────────────────
def _subscribe_to_events(self) -> None:
+32 -16
View File
@@ -158,7 +158,8 @@ def validate_manifest(manifest: dict) -> tuple:
return (len(errors) == 0, errors)
def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> tuple:
def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None,
allow_host_network: bool = False) -> tuple:
"""
Parse and security-validate a rendered docker-compose YAML string.
@@ -168,6 +169,12 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> t
allowed_data_dir: when set, absolute bind mounts under this prefix are
permitted — they come from ${PIC_DATA_DIR} substitution and land in the
designated service data directory.
allow_host_network: when True, the compose file is permitted to use
network_mode: host and devices: — required for connectivity services
(wireguard-ext, openvpn-client, tor) that must share the host network
namespace to create tun/wg interfaces. The external-network requirement
is also waived since host-network containers reach the cell network directly.
"""
errors = []
@@ -179,17 +186,19 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> t
if not isinstance(doc, dict):
return (False, ['compose file must be a YAML mapping'])
# At least one external network must exist so the container joins the cell network
# rather than an isolated bridge that would be invisible to Caddy and CoreDNS.
networks = doc.get('networks') or {}
has_external = any(
isinstance(v, dict) and v.get('external')
for v in networks.values()
)
if not has_external:
errors.append(
'compose file must declare at least one network with external: true'
# Regular (bridged) services must join the cell-network so Caddy and CoreDNS
# can reach them. Host-network services share the host namespace directly,
# so the external network declaration would be wrong and is omitted.
if not allow_host_network:
networks = doc.get('networks') or {}
has_external = any(
isinstance(v, dict) and v.get('external')
for v in networks.values()
)
if not has_external:
errors.append(
'compose file must declare at least one network with external: true'
)
for svc_name, svc in (doc.get('services') or {}).items():
if not isinstance(svc, dict):
@@ -204,10 +213,17 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> t
errors.append(f'{prefix}: privileged: true is not allowed')
net_mode = svc.get('network_mode')
if net_mode is not None and net_mode not in (None, 'bridge'):
errors.append(
f'{prefix}: network_mode {net_mode!r} is not allowed (only bridge)'
)
if allow_host_network:
if net_mode is not None and net_mode not in ('host',):
errors.append(
f'{prefix}: network_mode {net_mode!r} is not allowed '
'(connectivity services must use host)'
)
else:
if net_mode is not None and net_mode not in (None, 'bridge'):
errors.append(
f'{prefix}: network_mode {net_mode!r} is not allowed (only bridge)'
)
if svc.get('pid') == 'host':
errors.append(f'{prefix}: pid: host is not allowed')
@@ -238,7 +254,7 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> t
f'{prefix}: absolute host bind mount not allowed: {vol_str!r}'
)
if 'devices' in svc:
if 'devices' in svc and not allow_host_network:
errors.append(f'{prefix}: devices key is not allowed')
for opt in svc.get('security_opt') or []:
+6 -1
View File
@@ -158,8 +158,13 @@ class ServiceComposer:
# Validate before any file I/O so a bad template never touches disk.
# Pass the resolved data_dir so that bind mounts created by ${PIC_DATA_DIR}
# substitution are allowed; all other absolute paths are still rejected.
# Connectivity services (wireguard-ext, openvpn-client, tor) set
# requires_host_network: true in their manifest to opt into network_mode: host.
allow_host_network = bool(manifest.get('requires_host_network'))
ok, errs = validate_rendered_compose(
content, allowed_data_dir=str(Path(self.data_dir).resolve())
content,
allowed_data_dir=str(Path(self.data_dir).resolve()),
allow_host_network=allow_host_network,
)
if not ok:
raise ValueError(