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
+13 -10
View File
@@ -17,6 +17,9 @@
# Detect docker compose command (v2 plugin preferred, fallback to v1 standalone)
DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
# Full compose command: includes docker-compose.services.yml when it exists
DCF = $(DC) $(if $(wildcard docker-compose.services.yml),-f docker-compose.yml -f docker-compose.services.yml,-f docker-compose.yml)
# Default target
help:
@echo "Personal Internet Cell - Management Commands"
@@ -93,12 +96,12 @@ init-peers:
start:
@echo "Starting Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build
@echo "Services started. Check status with 'make status'"
stop:
@echo "Stopping Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down
@echo "Services stopped."
restart:
@@ -109,16 +112,16 @@ restart:
status:
@echo "Personal Internet Cell Status:"
@echo "================================"
$(DC) ps
$(DCF) ps
@echo ""
@echo "API Status:"
@curl -s http://localhost:3000/health || echo "API not responding"
logs:
$(DC) logs -f
$(DCF) logs -f
logs-%:
$(DC) logs -f $*
$(DCF) logs -f $*
shell-%:
docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh
@@ -135,12 +138,12 @@ update:
$(MAKE) setup; \
fi
@echo "Rebuilding and restarting services..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full up -d --build
@echo "Update complete. Run 'make status' to verify."
reinstall:
@echo "Reinstalling Personal Internet Cell from scratch..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v 2>/dev/null || true
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v 2>/dev/null || true
@sudo rm -rf config/ data/
@$(MAKE) setup
@$(MAKE) start
@@ -169,14 +172,14 @@ uninstall:
case "$$ans" in \
y|Y) \
echo "Stopping containers and removing images..."; \
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v --rmi all 2>/dev/null || true; \
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down -v --rmi all 2>/dev/null || true; \
echo "Deleting config/ and data/..."; \
sudo rm -rf config/ data/; \
echo "Uninstall complete. Git repo and scripts remain."; \
;; \
n|N|"") \
echo "Stopping and removing containers (keeping images and data)..."; \
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down 2>/dev/null || true; \
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile full down 2>/dev/null || true; \
echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \
;; \
*) \
@@ -208,7 +211,7 @@ build-webui:
start-core:
@echo "Starting core services (caddy, dns, wireguard, api, webui)..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile core up -d --build
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build
@echo "Core services started. Run 'make start' to also bring up optional services."
start-dns:
+9 -1
View File
@@ -42,7 +42,7 @@ from managers import (
routing_manager, vault_manager, container_manager,
cell_link_manager, auth_manager, setup_manager,
caddy_manager,
ddns_manager,
ddns_manager, service_store_manager,
firewall_manager, EventType,
)
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
@@ -374,6 +374,11 @@ def _apply_startup_enforcement():
sync_summary = cell_link_manager.replay_pending_pushes()
if sync_summary.get('attempted'):
logger.info(f"Startup permission sync: {sync_summary}")
# Service store: re-apply firewall/caddy rules for installed services
try:
service_store_manager.reapply_on_startup()
except Exception as _sse:
logger.warning(f"service_store reapply_on_startup failed (non-fatal): {_sse}")
except Exception as e:
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
@@ -465,6 +470,9 @@ app.register_blueprint(_services_bp)
app.register_blueprint(_peer_dashboard_bp)
app.register_blueprint(_config_bp)
from routes.service_store import store_bp
app.register_blueprint(store_bp)
# Re-export config helpers so existing test imports/patches keep working
from routes.config import (
_set_pending_restart, _clear_pending_restart,
+6
View File
@@ -373,6 +373,12 @@ class CaddyManager(BaseServiceManager):
# ── certificate status ────────────────────────────────────────────────
def regenerate_with_installed(self, installed_services: list) -> bool:
"""Regenerate Caddyfile with installed services and reload."""
identity = self.config_manager.get_identity()
content = self.generate_caddyfile(identity, installed_services)
return self.write_caddyfile(content)
def get_cert_status(self) -> Dict[str, Any]:
"""Return TLS cert status from identity['tls'] if present."""
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
+14
View File
@@ -474,6 +474,20 @@ class ConfigManager:
self.configs['_identity'][key] = value
self._save_all_configs()
def get_installed_services(self) -> dict:
return self.configs.get('_identity', {}).get('installed_services', {})
def set_installed_service(self, service_id: str, record: dict):
ident = self.configs.setdefault('_identity', {})
ident.setdefault('installed_services', {})[service_id] = record
self._save_all_configs()
def remove_installed_service(self, service_id: str):
ident = self.configs.setdefault('_identity', {})
ident.setdefault('installed_services', {}).pop(service_id, None)
ident.setdefault('service_ips', {}).pop(service_id, None)
self._save_all_configs()
def get_all_configs(self) -> Dict[str, Dict]:
"""Get all service configurations"""
return self.configs.copy()
+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}')
+10 -1
View File
@@ -60,6 +60,15 @@ setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_ma
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
from service_store_manager import ServiceStoreManager
service_store_manager = ServiceStoreManager(
config_manager=config_manager,
caddy_manager=caddy_manager,
container_manager=container_manager,
data_dir=DATA_DIR,
config_dir=CONFIG_DIR,
)
# Service logger configuration
_service_log_configs = {
'network': {'level': 'INFO', 'formatter': 'json', 'console': False},
@@ -93,7 +102,7 @@ __all__ = [
'email_manager', 'calendar_manager', 'file_manager',
'routing_manager', 'vault_manager', 'container_manager',
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
'ddns_manager',
'ddns_manager', 'service_store_manager',
'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR',
]
+105
View File
@@ -0,0 +1,105 @@
"""
Service Store Blueprint — /api/store
Provides routes to browse, install, and remove services from the PIC
service store. Authentication is enforced by the global before_request
hook in app.py (admin session required for all /api/* routes except
/api/auth/*).
"""
import logging
from flask import Blueprint, request, jsonify
import requests as _requests
from service_store_manager import MANIFEST_URL_TPL
logger = logging.getLogger('picell')
store_bp = Blueprint('service_store', __name__, url_prefix='/api/store')
def _ssm():
"""Lazy import of service_store_manager to avoid circular import at module load."""
from app import service_store_manager
return service_store_manager
def _cfg():
from app import config_manager
return config_manager
@store_bp.route('/services', methods=['GET'])
def list_store_services():
"""Return available and installed services."""
try:
return jsonify(_ssm().list_services())
except Exception as e:
logger.error(f'list_store_services: {e}')
return jsonify({'error': str(e)}), 500
@store_bp.route('/services/<service_id>/manifest', methods=['GET'])
def get_manifest(service_id: str):
"""Fetch and return the manifest for a specific service."""
try:
url = MANIFEST_URL_TPL.format(id=service_id)
resp = _requests.get(url, timeout=10)
resp.raise_for_status()
return jsonify(resp.json())
except _requests.HTTPError as e:
return jsonify({'error': f'Manifest not found: {e}'}), 404
except Exception as e:
logger.error(f'get_manifest({service_id}): {e}')
return jsonify({'error': str(e)}), 500
@store_bp.route('/services/<service_id>/install', methods=['POST'])
def install_service(service_id: str):
"""Install a service from the store."""
try:
result = _ssm().install(service_id)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.error(f'install_service({service_id}): {e}')
return jsonify({'error': str(e)}), 500
@store_bp.route('/services/<service_id>', methods=['DELETE'])
def remove_service(service_id: str):
"""Remove an installed service."""
try:
purge = request.args.get('purge') == 'true'
result = _ssm().remove(service_id, purge_data=purge)
if result.get('ok'):
return jsonify(result)
return jsonify(result), 404
except Exception as e:
logger.error(f'remove_service({service_id}): {e}')
return jsonify({'error': str(e)}), 500
@store_bp.route('/installed', methods=['GET'])
def get_installed():
"""Return all currently installed services."""
try:
return jsonify({'installed': _cfg().get_installed_services()})
except Exception as e:
logger.error(f'get_installed: {e}')
return jsonify({'error': str(e)}), 500
@store_bp.route('/refresh', methods=['POST'])
def refresh_index():
"""Invalidate the index cache and return a fresh service list."""
try:
ssm = _ssm()
ssm._index_cache = None
ssm._index_cache_time = 0
return jsonify(ssm.list_services())
except Exception as e:
logger.error(f'refresh_index: {e}')
return jsonify({'error': str(e)}), 500
+526
View File
@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
Service Store Manager for Personal Internet Cell.
Manages installation, removal, and lifecycle of third-party services from the
PIC service store index. Each installed service runs as a Docker container
declared in a compose override file and has:
- An allocated IP in the service pool (172.20.0.20254 by default)
- Optional iptables FORWARD rules declared in its manifest
- Optional Caddy reverse-proxy route declared in its manifest
"""
import logging
import os
import re
import threading
import subprocess
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import requests
import yaml
from base_service_manager import BaseServiceManager
from ip_utils import CONTAINER_OFFSETS
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SERVICE_POOL_START = 20
SERVICE_POOL_END = 254
INDEX_URL_DEFAULT = (
'https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json'
)
MANIFEST_URL_TPL = (
'https://git.pic.ngo/roof/pic-services/raw/branch/main/services/{id}/manifest.json'
)
IMAGE_ALLOWLIST_RE = re.compile(
r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?$'
)
FORBIDDEN_MOUNTS = frozenset([
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
])
RESERVED_SUBDOMAINS = frozenset([
'api', 'webui', 'admin', 'www', 'mail', 'ns1', 'ns2',
'git', 'registry', 'install',
])
ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-= ]*$')
# ---------------------------------------------------------------------------
# ServiceStoreManager
# ---------------------------------------------------------------------------
class ServiceStoreManager(BaseServiceManager):
"""Manages service store: install, remove, and list available/installed services."""
def __init__(self, config_manager, caddy_manager, container_manager,
data_dir: str = '', config_dir: str = ''):
super().__init__('service_store', data_dir, config_dir)
self.config_manager = config_manager
self.caddy_manager = caddy_manager
self.container_manager = container_manager
self.compose_override = os.environ.get(
'COMPOSE_SERVICES_PATH', '/app/docker-compose.services.yml'
)
self.index_url = os.environ.get('PIC_STORE_INDEX_URL', INDEX_URL_DEFAULT)
self._lock = threading.Lock()
self._index_cache: Optional[list] = None
self._index_cache_time: float = 0
self._cache_ttl: int = 300 # 5 min
# ── BaseServiceManager required ───────────────────────────────────────
def get_status(self) -> Dict[str, Any]:
installed = self.config_manager.get_installed_services()
return {
'service': self.service_name,
'running': True,
'installed_count': len(installed),
}
def test_connectivity(self) -> Dict[str, Any]:
try:
resp = requests.get(self.index_url, timeout=5)
return {'success': resp.status_code == 200}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Manifest validation ───────────────────────────────────────────────
@staticmethod
def _validate_manifest(m: dict) -> Tuple[bool, List[str]]:
"""Validate a service manifest. Returns (ok, [errors])."""
errors: List[str] = []
# Required top-level fields
for field in ('id', 'name', 'version', 'author', 'image', 'container_name'):
if not m.get(field):
errors.append(f'Missing required field: {field}')
# Image allowlist
image = m.get('image', '')
if image and not IMAGE_ALLOWLIST_RE.match(image):
errors.append(
f'image must match git.pic.ngo/roof/* pattern, got: {image}'
)
# Volume mount safety
for vol in m.get('volumes', []):
mount = vol.get('mount', '')
if mount in FORBIDDEN_MOUNTS:
errors.append(f'Forbidden volume mount: {mount}')
elif mount.startswith('/home/roof/pic'):
errors.append(f'Volume mount cannot be a prefix of /home/roof/pic: {mount}')
# iptables rules
for rule in m.get('iptables_rules', []):
if rule.get('type') != 'ACCEPT':
errors.append(
f'iptables_rules[].type must be ACCEPT, got: {rule.get("type")}'
)
if rule.get('dest_ip') != '${SERVICE_IP}':
errors.append(
f'iptables_rules[].dest_ip must be exactly ${{SERVICE_IP}}, '
f'got: {rule.get("dest_ip")}'
)
port = rule.get('dest_port')
if not isinstance(port, int) or not (1 <= port <= 65535):
errors.append(
f'iptables_rules[].dest_port must be an integer 1-65535, got: {port}'
)
proto = rule.get('proto', 'tcp')
if proto not in ('tcp', 'udp'):
errors.append(
f'iptables_rules[].proto must be tcp or udp, got: {proto}'
)
# Caddy route subdomain
caddy_route = m.get('caddy_route') or {}
if isinstance(caddy_route, dict):
subdomain = caddy_route.get('subdomain', '')
else:
subdomain = ''
if subdomain:
if subdomain in RESERVED_SUBDOMAINS:
errors.append(f'caddy_route.subdomain is reserved: {subdomain}')
elif not re.match(r'^[a-z][a-z0-9-]{0,30}$', subdomain):
errors.append(
f'caddy_route.subdomain must match ^[a-z][a-z0-9-]{{0,30}}$, '
f'got: {subdomain}'
)
# Env value safety
for env_entry in m.get('env', []):
val = str(env_entry.get('value', ''))
if not ENV_VALUE_RE.match(val):
errors.append(
f'env[].value contains disallowed characters: {val!r}'
)
return (len(errors) == 0, errors)
# ── IP allocation ─────────────────────────────────────────────────────
def _allocate_service_ip(self, service_id: str) -> str:
"""Allocate the next free IP from the service pool."""
identity = self.config_manager.get_identity()
ip_range = identity.get('ip_range', '172.20.0.0/16')
import ipaddress
network = ipaddress.IPv4Network(ip_range, strict=False)
base = int(network.network_address)
# IPs already assigned to named containers
reserved_offsets = set(CONTAINER_OFFSETS.values())
# IPs already assigned to installed services
service_ips: Dict[str, str] = identity.get('service_ips', {})
taken_ips = set(service_ips.values())
for offset in range(SERVICE_POOL_START, SERVICE_POOL_END + 1):
if offset in reserved_offsets:
continue
candidate = str(ipaddress.IPv4Address(base + offset))
if candidate not in taken_ips:
return candidate
raise RuntimeError('Service IP pool exhausted (offsets 20-254 all taken)')
# ── Compose override ──────────────────────────────────────────────────
def _render_compose_override(self, installed_records: dict) -> str:
"""Generate docker-compose YAML override for all installed services."""
services: Dict[str, Any] = {}
for svc_id, record in installed_records.items():
manifest = record.get('manifest', {})
container_name = record.get('container_name', svc_id)
image = manifest.get('image', record.get('image', ''))
service_ip = record.get('service_ip', '')
# Volumes
volumes = []
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
mount = vol.get('mount', '')
if vol_name and mount:
volumes.append(f'{vol_name}:{mount}')
# Environment
environment: Dict[str, str] = {}
for env_entry in manifest.get('env', []):
k = env_entry.get('key', '')
v = str(env_entry.get('value', ''))
if k:
environment[k] = v
svc_def: Dict[str, Any] = {
'image': image,
'container_name': container_name,
'restart': 'unless-stopped',
'logging': {
'driver': 'json-file',
'options': {
'max-size': '10m',
'max-file': '5',
},
},
'networks': {
'cell-network': {
'ipv4_address': service_ip,
}
},
}
if volumes:
svc_def['volumes'] = volumes
if environment:
svc_def['environment'] = environment
services[container_name] = svc_def
# Collect named volumes
named_volumes: Dict[str, Any] = {}
for svc_id, record in installed_records.items():
manifest = record.get('manifest', {})
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
if vol_name:
named_volumes[vol_name] = None # Docker default driver
doc: Dict[str, Any] = {
'version': '3.8',
'services': services,
'networks': {
'cell-network': {
'external': True,
}
},
}
if named_volumes:
doc['volumes'] = named_volumes
return yaml.dump(doc, default_flow_style=False, allow_unicode=True)
def _write_compose_override(self, content: str) -> None:
"""Atomic write of the compose override file."""
tmp_path = self.compose_override + '.tmp'
try:
os.makedirs(os.path.dirname(os.path.abspath(self.compose_override)),
exist_ok=True)
except (PermissionError, OSError):
pass
with open(tmp_path, 'w') as f:
f.write(content)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
os.replace(tmp_path, self.compose_override)
# ── Index / manifest fetching ─────────────────────────────────────────
def fetch_index(self) -> list:
"""Fetch and cache the service index."""
import time
now = time.time()
if self._index_cache is not None and (now - self._index_cache_time) < self._cache_ttl:
return self._index_cache
try:
resp = requests.get(self.index_url, timeout=10)
resp.raise_for_status()
data = resp.json()
self._index_cache = data if isinstance(data, list) else data.get('services', [])
self._index_cache_time = now
return self._index_cache
except Exception as e:
logger.warning(f'fetch_index failed: {e}')
return self._index_cache or []
def _fetch_manifest(self, service_id: str) -> dict:
"""Fetch a service manifest by ID."""
url = MANIFEST_URL_TPL.format(id=service_id)
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
# ── Core operations ───────────────────────────────────────────────────
def install(self, service_id: str) -> dict:
"""Install a service from the store."""
from firewall_manager import apply_service_rules
with self._lock:
# Already installed?
installed = self.config_manager.get_installed_services()
if service_id in installed:
return {'ok': True, 'already_installed': True}
# Fetch and validate manifest
try:
manifest = self._fetch_manifest(service_id)
except Exception as e:
return {'ok': False, 'error': f'Failed to fetch manifest: {e}'}
ok, errs = self._validate_manifest(manifest)
if not ok:
return {'ok': False, 'errors': errs}
# Allocate IP
try:
ip = self._allocate_service_ip(service_id)
except RuntimeError as e:
return {'ok': False, 'error': str(e)}
# Build install record
record = {
'id': service_id,
'name': manifest.get('name', service_id),
'container_name': manifest['container_name'],
'image': manifest.get('image', ''),
'service_ip': ip,
'caddy_route': manifest.get('caddy_route'),
'iptables_rules': manifest.get('iptables_rules', []),
'manifest': manifest,
'installed_at': datetime.utcnow().isoformat(),
}
# Persist to config
self.config_manager.set_installed_service(service_id, record)
identity = self.config_manager.get_identity()
service_ips = dict(identity.get('service_ips', {}))
service_ips[service_id] = ip
self.config_manager.set_identity_field('service_ips', service_ips)
# Write compose override
all_installed = self.config_manager.get_installed_services()
try:
content = self._render_compose_override(all_installed)
self._write_compose_override(content)
except Exception as e:
logger.error(f'Failed to write compose override: {e}')
# Apply iptables rules (best-effort)
try:
apply_service_rules(service_id, ip, manifest.get('iptables_rules', []))
except Exception as e:
logger.warning(f'apply_service_rules for {service_id} failed (non-fatal): {e}')
# Regenerate Caddyfile
try:
caddy_routes = [
r.get('caddy_route')
for r in all_installed.values()
if r.get('caddy_route')
]
self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e:
logger.warning(f'caddy regenerate for {service_id} failed (non-fatal): {e}')
# Start the container via docker compose
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
try:
result = subprocess.run(
['docker', 'compose',
'-f', base_compose,
'-f', self.compose_override,
'up', '-d', manifest['container_name']],
capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
logger.warning(
f'docker compose up for {service_id} failed: {result.stderr.strip()}'
)
except Exception as e:
logger.warning(f'docker compose up for {service_id} failed (non-fatal): {e}')
return {
'ok': True,
'service_ip': ip,
'container_name': manifest['container_name'],
}
def remove(self, service_id: str, purge_data: bool = False) -> dict:
"""Remove an installed service."""
from firewall_manager import clear_service_rules
with self._lock:
installed = self.config_manager.get_installed_services()
record = installed.get(service_id)
if not record:
return {'ok': False, 'error': f'Service {service_id} is not installed'}
container_name = record.get('container_name', service_id)
manifest = record.get('manifest', {})
base_compose = os.environ.get('COMPOSE_FILE', '/app/docker-compose.yml')
# Stop and remove container
try:
subprocess.run(
['docker', 'compose',
'-f', base_compose,
'-f', self.compose_override,
'stop', container_name],
capture_output=True, text=True, timeout=60,
)
except Exception as e:
logger.warning(f'docker compose stop for {service_id} failed (non-fatal): {e}')
try:
subprocess.run(
['docker', 'rm', '-f', container_name],
capture_output=True, text=True, timeout=30,
)
except Exception as e:
logger.warning(f'docker rm for {service_id} failed (non-fatal): {e}')
# Clear iptables rules
try:
clear_service_rules(service_id)
except Exception as e:
logger.warning(f'clear_service_rules for {service_id} failed (non-fatal): {e}')
# Remove from config, regenerate compose + caddy
self.config_manager.remove_installed_service(service_id)
remaining = self.config_manager.get_installed_services()
try:
content = self._render_compose_override(remaining)
self._write_compose_override(content)
except Exception as e:
logger.error(f'Failed to write compose override after remove: {e}')
try:
caddy_routes = [
r.get('caddy_route')
for r in remaining.values()
if r.get('caddy_route')
]
self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e:
logger.warning(f'caddy regenerate after remove failed (non-fatal): {e}')
# Purge named volumes if requested
if purge_data:
for vol in manifest.get('volumes', []):
vol_name = vol.get('name', '')
if vol_name:
try:
subprocess.run(
['docker', 'volume', 'rm', vol_name],
capture_output=True, text=True, timeout=30,
)
except Exception as e:
logger.warning(
f'docker volume rm {vol_name} failed (non-fatal): {e}'
)
return {'ok': True}
def list_services(self) -> dict:
"""Return available (from index) and installed services."""
available = self.fetch_index()
installed = self.config_manager.get_installed_services()
return {'available': available, 'installed': installed}
def reapply_on_startup(self) -> None:
"""Re-apply firewall and Caddy rules for all installed services on startup."""
from firewall_manager import apply_service_rules
installed = self.config_manager.get_installed_services()
if not installed:
return
# Regenerate compose override in case it was deleted
try:
content = self._render_compose_override(installed)
self._write_compose_override(content)
except Exception as e:
logger.warning(f'reapply_on_startup: compose override write failed: {e}')
# Re-apply iptables rules
for svc_id, record in installed.items():
ip = record.get('service_ip', '')
rules = record.get('iptables_rules', [])
try:
apply_service_rules(svc_id, ip, rules)
except Exception as e:
logger.warning(f'reapply_on_startup: apply_service_rules({svc_id}) failed: {e}')
# Regenerate Caddyfile
try:
caddy_routes = [
r.get('caddy_route')
for r in installed.values()
if r.get('caddy_route')
]
self.caddy_manager.regenerate_with_installed(caddy_routes)
except Exception as e:
logger.warning(f'reapply_on_startup: caddy regenerate failed: {e}')
+6
View File
@@ -0,0 +1,6 @@
version: '3.3'
services: {}
networks:
cell-network:
external: true
name: pic_cell-network
+1
View File
@@ -215,6 +215,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env.compose
- ./docker-compose.yml:/app/docker-compose.yml:ro
- ./docker-compose.services.yml:/app/docker-compose.services.yml
- ./scripts:/app/scripts:ro
pid: host
restart: unless-stopped
File diff suppressed because it is too large Load Diff
+4
View File
@@ -12,6 +12,7 @@ import {
Wifi,
Server,
Key,
Package,
Package2,
Settings as SettingsIcon,
Link2,
@@ -42,6 +43,7 @@ import Login from './pages/Login';
import AccountSettings from './pages/AccountSettings';
import PeerDashboard from './pages/PeerDashboard';
import MyServices from './pages/MyServices';
import Store from './pages/Store';
import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard';
@@ -238,6 +240,7 @@ function AppCore() {
{ name: 'Routing', href: '/routing', icon: Wifi },
{ name: 'Vault', href: '/vault', icon: Key },
{ name: 'Containers', href: '/containers', icon: Package2 },
{ name: 'Store', href: '/store', icon: Package },
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
@@ -343,6 +346,7 @@ function AppCore() {
<Route path="/routing" element={<PrivateRoute requireRole="admin"><Routing /></PrivateRoute>} />
<Route path="/vault" element={<PrivateRoute requireRole="admin"><Vault /></PrivateRoute>} />
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
<Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} />
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
+429
View File
@@ -0,0 +1,429 @@
import { useState, useEffect, useCallback } from 'react';
import {
Package,
Download,
Trash2,
RefreshCw,
CheckCircle,
AlertCircle,
} from 'lucide-react';
import { storeAPI } from '../services/api';
// ── Toast helpers (same pattern as Settings.jsx) ─────────────────────────────
function toastEvent(msg, type = 'success') {
window.dispatchEvent(new CustomEvent('store-toast', { detail: { msg, type } }));
}
function Toast({ toasts }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
}`}
>
{t.type === 'success' ? (
<CheckCircle className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
{t.msg}
</div>
))}
</div>
);
}
function useToasts() {
const [toasts, setToasts] = useState([]);
useEffect(() => {
const handler = (e) => {
const id = Date.now();
setToasts((prev) => [...prev, { ...e.detail, id }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
};
window.addEventListener('store-toast', handler);
return () => window.removeEventListener('store-toast', handler);
}, []);
return toasts;
}
// ── Skeleton card ─────────────────────────────────────────────────────────────
function SkeletonCard() {
return (
<div className="card animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-gray-100 rounded w-3/4 mb-1" />
<div className="h-3 bg-gray-100 rounded w-1/2 mb-4" />
<div className="flex justify-between items-center mt-auto">
<div className="h-3 bg-gray-100 rounded w-1/4" />
<div className="h-8 bg-gray-200 rounded w-20" />
</div>
</div>
);
}
// ── Confirm remove dialog ─────────────────────────────────────────────────────
function ConfirmRemoveDialog({ service, onConfirm, onCancel }) {
const [purge, setPurge] = useState(false);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-96 mx-4">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold text-gray-900">Remove {service.name}?</h3>
<p className="text-sm text-gray-500 mt-1">
The service will be stopped and uninstalled. By default, data is kept on disk.
</p>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none mb-5">
<input
type="checkbox"
checked={purge}
onChange={(e) => setPurge(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-400"
/>
<span className="text-sm text-gray-700">
Also delete service data (cannot be undone)
</span>
</label>
<div className="flex gap-2 justify-end">
<button
onClick={onCancel}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => onConfirm(purge)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
>
{purge ? 'Remove and Delete Data' : 'Remove Service'}
</button>
</div>
</div>
</div>
);
}
// ── Service card ──────────────────────────────────────────────────────────────
function ServiceCard({ service, isInstalled, installedInfo, onInstall, onRemove, installing, removing }) {
return (
<div className="card flex flex-col gap-3">
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="h-5 w-5 text-primary-500 shrink-0" />
<span className="font-semibold text-gray-900 truncate">{service.name}</span>
</div>
{isInstalled && (
<span className="flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5 shrink-0">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-gray-500 flex-1">
{service.description || 'No description available.'}
</p>
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
{service.version && (
<span>v{service.version}</span>
)}
{service.author && (
<span>by {service.author}</span>
)}
{isInstalled && installedInfo?.installed_at && (
<span>Installed {new Date(installedInfo.installed_at).toLocaleDateString()}</span>
)}
</div>
{/* Action */}
<div className="flex justify-end pt-1 border-t border-gray-100">
{isInstalled ? (
<button
onClick={() => onRemove(service)}
disabled={removing}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
aria-label={`Remove ${service.name}`}
>
{removing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{removing ? 'Removing…' : 'Remove'}
</button>
) : (
<button
onClick={() => onInstall(service)}
disabled={installing}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
aria-label={`Install ${service.name}`}
>
{installing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{installing ? 'Installing…' : 'Install'}
</button>
)}
</div>
</div>
);
}
// ── Main Store component ──────────────────────────────────────────────────────
function Store() {
const toasts = useToasts();
const [services, setServices] = useState([]); // available services array
const [installed, setInstalled] = useState({}); // map of id -> installed info
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
// Per-service operation state: { [id]: 'installing' | 'removing' | null }
const [opState, setOpState] = useState({});
// Pending remove confirmation dialog
const [removeTarget, setRemoveTarget] = useState(null); // service object or null
const loadStore = useCallback(async () => {
setLoadError(null);
try {
const res = await storeAPI.listServices();
const data = res.data || {};
setServices(Array.isArray(data.available) ? data.available : []);
setInstalled(data.installed && typeof data.installed === 'object' ? data.installed : {});
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Could not load the service store. Check that the API is reachable.';
setLoadError(msg);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadStore();
}, [loadStore]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await storeAPI.refreshIndex();
toastEvent('Store index refreshed');
await loadStore();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to refresh store index';
toastEvent(msg, 'error');
} finally {
setRefreshing(false);
}
};
const handleInstall = async (service) => {
setOpState((s) => ({ ...s, [service.id]: 'installing' }));
try {
await storeAPI.installService(service.id);
toastEvent(`${service.name} installed successfully`);
await loadStore();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
`Failed to install ${service.name}`;
toastEvent(msg, 'error');
} finally {
setOpState((s) => ({ ...s, [service.id]: null }));
}
};
const handleRemoveClick = (service) => {
setRemoveTarget(service);
};
const handleRemoveConfirm = async (purge) => {
const service = removeTarget;
setRemoveTarget(null);
setOpState((s) => ({ ...s, [service.id]: 'removing' }));
try {
await storeAPI.removeService(service.id, purge);
toastEvent(`${service.name} removed`);
await loadStore();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
`Failed to remove ${service.name}`;
toastEvent(msg, 'error');
} finally {
setOpState((s) => ({ ...s, [service.id]: null }));
}
};
// ── Render ────────────────────────────────────────────────────────────────
const installedServices = services.filter((s) => installed[s.id]);
const availableServices = services.filter((s) => !installed[s.id]);
return (
<div>
<Toast toasts={toasts} />
{/* Page header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Service Store</h1>
<p className="mt-1 text-sm text-gray-500">
Browse and install optional services for your Personal Internet Cell
</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing || isLoading}
className="btn-secondary flex items-center gap-2 text-sm shrink-0"
aria-label="Refresh store index"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? 'Refreshing…' : 'Refresh Store'}
</button>
</div>
{/* Loading state */}
{isLoading && (
<div>
<div className="h-4 bg-gray-200 rounded w-40 mb-4 animate-pulse" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((n) => (
<SkeletonCard key={n} />
))}
</div>
</div>
)}
{/* Error state */}
{!isLoading && loadError && (
<div className="card border border-red-200 bg-red-50">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">Failed to load store</p>
<p className="text-sm text-red-600 mt-1">{loadError}</p>
</div>
<button
onClick={() => { setIsLoading(true); loadStore(); }}
className="btn-secondary text-sm shrink-0"
>
Retry
</button>
</div>
</div>
)}
{/* Content */}
{!isLoading && !loadError && (
<>
{/* Installed services section */}
{installedServices.length > 0 && (
<section className="mb-8">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Installed ({installedServices.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{installedServices.map((svc) => (
<ServiceCard
key={svc.id}
service={svc}
isInstalled={true}
installedInfo={installed[svc.id]}
onInstall={handleInstall}
onRemove={handleRemoveClick}
installing={opState[svc.id] === 'installing'}
removing={opState[svc.id] === 'removing'}
/>
))}
</div>
</section>
)}
{/* Available services section */}
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{installedServices.length > 0 ? 'Available to Install' : 'Available Services'}
{availableServices.length > 0 && ` (${availableServices.length})`}
</h2>
{availableServices.length === 0 && installedServices.length === 0 && (
<div className="card border border-gray-100 text-center py-12">
<Package className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-medium text-gray-500">No services in the store yet</p>
<p className="text-xs text-gray-400 mt-1">
Click "Refresh Store" to check for available services.
</p>
</div>
)}
{availableServices.length === 0 && installedServices.length > 0 && (
<div className="card border border-gray-100 text-center py-8">
<CheckCircle className="h-8 w-8 text-green-400 mx-auto mb-2" />
<p className="text-sm text-gray-500">All available services are installed.</p>
</div>
)}
{availableServices.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableServices.map((svc) => (
<ServiceCard
key={svc.id}
service={svc}
isInstalled={false}
installedInfo={null}
onInstall={handleInstall}
onRemove={handleRemoveClick}
installing={opState[svc.id] === 'installing'}
removing={opState[svc.id] === 'removing'}
/>
))}
</div>
)}
</section>
</>
)}
{/* Remove confirmation dialog */}
{removeTarget && (
<ConfirmRemoveDialog
service={removeTarget}
onConfirm={handleRemoveConfirm}
onCancel={() => setRemoveTarget(null)}
/>
)}
</div>
);
}
export default Store;
+10
View File
@@ -288,6 +288,16 @@ export const cellLinkAPI = {
getServices: () => api.get('/api/cells/services'),
};
// Service Store API
export const storeAPI = {
listServices: () => api.get('/api/store/services'),
getManifest: (id) => api.get(`/api/store/services/${id}/manifest`),
installService: (id) => api.post(`/api/store/services/${id}/install`),
removeService: (id, purge = false) => api.delete(`/api/store/services/${id}`, { params: { purge } }),
listInstalled: () => api.get('/api/store/installed'),
refreshIndex: () => api.post('/api/store/refresh'),
};
// Health check
export const healthAPI = {
check: () => api.get('/health'),