Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7e01d4aa7 | |||
| 1bb8a5eb59 | |||
| fa746a3b30 |
+14
-2
@@ -310,7 +310,15 @@ class CaddyManager(BaseServiceManager):
|
|||||||
service_routes: str, core_routes: str,
|
service_routes: str, core_routes: str,
|
||||||
cert_path: str = _CADDY_INTERNAL_CERT,
|
cert_path: str = _CADDY_INTERNAL_CERT,
|
||||||
key_path: str = _CADDY_INTERNAL_KEY) -> str:
|
key_path: str = _CADDY_INTERNAL_KEY) -> str:
|
||||||
"""LAN mode: HTTP only + internal-CA TLS, no ACME."""
|
"""LAN mode: internal-CA TLS on 443, plain HTTP on 80, no ACME.
|
||||||
|
|
||||||
|
The same routes are served on both an HTTPS site (the internal-CA cert)
|
||||||
|
and an HTTP site. They must be SEPARATE site blocks: a `tls` directive on
|
||||||
|
an `http://` (port 80) address is rejected by Caddy ("server listening on
|
||||||
|
[:80] is HTTP, but attempts to configure TLS connection policies"). Both
|
||||||
|
are needed because the WireGuard server DNATs peer traffic to Caddy on
|
||||||
|
both 80 and 443.
|
||||||
|
"""
|
||||||
body = []
|
body = []
|
||||||
if service_routes:
|
if service_routes:
|
||||||
body.append(self._indent_routes(service_routes))
|
body.append(self._indent_routes(service_routes))
|
||||||
@@ -325,10 +333,14 @@ class CaddyManager(BaseServiceManager):
|
|||||||
" auto_https off\n"
|
" auto_https off\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"\n"
|
"\n"
|
||||||
f"http://{cell_name}.cell, http://172.20.0.2:80 {{\n"
|
f"https://{cell_name}.cell {{\n"
|
||||||
f" tls {cert_path} {key_path}\n"
|
f" tls {cert_path} {key_path}\n"
|
||||||
f"{inner}\n"
|
f"{inner}\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
|
"\n"
|
||||||
|
f"http://{cell_name}.cell, http://172.20.0.2:80 {{\n"
|
||||||
|
f"{inner}\n"
|
||||||
|
"}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _caddyfile_pic_ngo(self, cell_name: str,
|
def _caddyfile_pic_ngo(self, cell_name: str,
|
||||||
|
|||||||
+48
-18
@@ -30,6 +30,17 @@ _BACKOFF_BASE_S = 60
|
|||||||
_BACKOFF_MAX_S = 3600
|
_BACKOFF_MAX_S = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_api_url(dns_ip: Optional[str]) -> Optional[str]:
|
||||||
|
"""Base URL for a linked cell's API, reached over the WG tunnel.
|
||||||
|
|
||||||
|
Cross-cell peer-sync goes to the remote's Caddy on 443 (the WireGuard server
|
||||||
|
DNATs VPN-IP:443 → Caddy → API). The API's own :3000 is bound to 127.0.0.1
|
||||||
|
and is NOT reachable from another cell, so we must target HTTPS/443, not
|
||||||
|
http://<ip>:3000.
|
||||||
|
"""
|
||||||
|
return f"https://{dns_ip}" if dns_ip else None
|
||||||
|
|
||||||
|
|
||||||
def _compute_next_retry(attempts: int) -> str:
|
def _compute_next_retry(attempts: int) -> str:
|
||||||
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
|
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
|
||||||
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
|
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
|
||||||
@@ -66,10 +77,12 @@ class CellLinkManager:
|
|||||||
changed = True
|
changed = True
|
||||||
# Phase 1 migration: permission-sync tracking fields
|
# Phase 1 migration: permission-sync tracking fields
|
||||||
if 'remote_api_url' not in link:
|
if 'remote_api_url' not in link:
|
||||||
link['remote_api_url'] = (
|
link['remote_api_url'] = _remote_api_url(link.get('dns_ip'))
|
||||||
f"http://{link['dns_ip']}:3000"
|
changed = True
|
||||||
if link.get('dns_ip') else None
|
# Migrate legacy http://<ip>:3000 URLs (unreachable across
|
||||||
)
|
# cells) to the HTTPS/Caddy form.
|
||||||
|
elif str(link.get('remote_api_url', '')).startswith('http://'):
|
||||||
|
link['remote_api_url'] = _remote_api_url(link.get('dns_ip'))
|
||||||
changed = True
|
changed = True
|
||||||
if 'last_push_status' not in link:
|
if 'last_push_status' not in link:
|
||||||
link['last_push_status'] = 'never'
|
link['last_push_status'] = 'never'
|
||||||
@@ -193,7 +206,10 @@ class CellLinkManager:
|
|||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'docker', 'exec', 'cell-wireguard',
|
'docker', 'exec', 'cell-wireguard',
|
||||||
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
# -k: the request reaches Caddy by the remote's VPN IP over the
|
||||||
|
# encrypted WG tunnel, so the TLS cert (issued for the cell's domain)
|
||||||
|
# won't match the IP — the tunnel already authenticates the peer.
|
||||||
|
'curl', '-s', '-k', '-o', '/dev/null', '-w', '%{http_code}',
|
||||||
'-X', 'POST',
|
'-X', 'POST',
|
||||||
'-H', 'Content-Type: application/json',
|
'-H', 'Content-Type: application/json',
|
||||||
]
|
]
|
||||||
@@ -371,14 +387,24 @@ class CellLinkManager:
|
|||||||
# ── Public API ────────────────────────────────────────────────────────────
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
def generate_invite(self, cell_name: str, domain: str) -> Dict[str, Any]:
|
||||||
"""Return an invite package describing this cell for another cell to import."""
|
"""Return an invite package describing this cell for another cell to import.
|
||||||
|
|
||||||
|
The endpoint advertises the cell's public domain (when in a DDNS/ACME
|
||||||
|
mode) plus this cell's own WireGuard port, rather than a raw external IP —
|
||||||
|
so the remote cell reaches us by name and a NAT/router can forward each
|
||||||
|
cell's distinct WG port to the right host.
|
||||||
|
"""
|
||||||
keys = self.wireguard_manager.get_keys()
|
keys = self.wireguard_manager.get_keys()
|
||||||
srv = self.wireguard_manager.get_server_config()
|
|
||||||
server_vpn_ip = self.wireguard_manager._get_configured_address().split('/')[0]
|
server_vpn_ip = self.wireguard_manager._get_configured_address().split('/')[0]
|
||||||
|
try:
|
||||||
|
from app import config_manager as _cm
|
||||||
|
except Exception:
|
||||||
|
_cm = None
|
||||||
|
endpoint = self.wireguard_manager.get_advertised_endpoint(_cm)
|
||||||
return {
|
return {
|
||||||
'cell_name': cell_name,
|
'cell_name': cell_name,
|
||||||
'public_key': keys['public_key'],
|
'public_key': keys['public_key'],
|
||||||
'endpoint': srv.get('endpoint'),
|
'endpoint': endpoint,
|
||||||
'vpn_subnet': self.wireguard_manager._get_configured_network(),
|
'vpn_subnet': self.wireguard_manager._get_configured_network(),
|
||||||
'dns_ip': server_vpn_ip,
|
'dns_ip': server_vpn_ip,
|
||||||
'domain': domain,
|
'domain': domain,
|
||||||
@@ -448,15 +474,16 @@ class CellLinkManager:
|
|||||||
def _push_invite_to_remote(self, link: Dict[str, Any]) -> Dict[str, Any]:
|
def _push_invite_to_remote(self, link: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Send OUR invite to the remote cell so it can complete mutual WG pairing.
|
"""Send OUR invite to the remote cell so it can complete mutual WG pairing.
|
||||||
|
|
||||||
Called immediately after adding the remote as our WG peer. Uses the
|
Called immediately after adding the remote as our WG peer, before the WG
|
||||||
remote's endpoint IP (LAN-reachable before the WG tunnel is up) rather
|
tunnel is up. Reaches the remote over the PUBLIC path at its advertised
|
||||||
than the WG-internal dns_ip. Non-fatal — one-sided pairing degrades
|
endpoint host (a domain in DDNS/ACME modes) on Caddy/443 — the API's :3000
|
||||||
gracefully; the admin can pair from the other side manually.
|
is 127.0.0.1-only and not reachable across cells. Non-fatal — one-sided
|
||||||
|
pairing degrades gracefully; the admin can pair from the other side.
|
||||||
"""
|
"""
|
||||||
endpoint = link.get('endpoint') or ''
|
endpoint = link.get('endpoint') or ''
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return {'ok': False, 'error': 'no endpoint'}
|
return {'ok': False, 'error': 'no endpoint'}
|
||||||
# Parse LAN IP from endpoint (e.g. "192.168.31.52:51820" → "192.168.31.52")
|
# Host from endpoint (e.g. "alice.pic.ngo:51821" → "alice.pic.ngo").
|
||||||
try:
|
try:
|
||||||
host = endpoint.rsplit(':', 1)[0].strip('[]')
|
host = endpoint.rsplit(':', 1)[0].strip('[]')
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -471,11 +498,14 @@ class CellLinkManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'ok': False, 'error': f'could not build own invite: {e}'}
|
return {'ok': False, 'error': f'could not build own invite: {e}'}
|
||||||
|
|
||||||
url = f'http://{host}:3000/api/cells/peer-sync/accept-invite'
|
url = f'https://{host}/api/cells/peer-sync/accept-invite'
|
||||||
payload = json.dumps({'invite': own_invite})
|
payload = json.dumps({'invite': own_invite})
|
||||||
cmd = [
|
cmd = [
|
||||||
'docker', 'exec', 'cell-wireguard',
|
'docker', 'exec', 'cell-wireguard',
|
||||||
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
|
# -k: endpoint may be a bare IP (LAN/fallback) whose cert won't match;
|
||||||
|
# accept-invite carries only public keys and the WG handshake is the
|
||||||
|
# real authentication.
|
||||||
|
'curl', '-s', '-k', '-o', '/dev/null', '-w', '%{http_code}',
|
||||||
'-X', 'POST',
|
'-X', 'POST',
|
||||||
'-H', 'Content-Type: application/json',
|
'-H', 'Content-Type: application/json',
|
||||||
'-d', payload,
|
'-d', payload,
|
||||||
@@ -537,7 +567,7 @@ class CellLinkManager:
|
|||||||
old_domain = existing.get('domain', '')
|
old_domain = existing.get('domain', '')
|
||||||
existing['dns_ip'] = invite['dns_ip']
|
existing['dns_ip'] = invite['dns_ip']
|
||||||
existing['vpn_subnet'] = invite['vpn_subnet']
|
existing['vpn_subnet'] = invite['vpn_subnet']
|
||||||
existing['remote_api_url'] = f"http://{invite['dns_ip']}:3000"
|
existing['remote_api_url'] = _remote_api_url(invite['dns_ip'])
|
||||||
if invite.get('endpoint'):
|
if invite.get('endpoint'):
|
||||||
existing['endpoint'] = invite['endpoint']
|
existing['endpoint'] = invite['endpoint']
|
||||||
if domain_changed:
|
if domain_changed:
|
||||||
@@ -599,7 +629,7 @@ class CellLinkManager:
|
|||||||
'domain': invite['domain'],
|
'domain': invite['domain'],
|
||||||
'connected_at': datetime.utcnow().isoformat(),
|
'connected_at': datetime.utcnow().isoformat(),
|
||||||
'permissions': _default_perms(),
|
'permissions': _default_perms(),
|
||||||
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
'remote_api_url': _remote_api_url(invite['dns_ip']),
|
||||||
'last_push_status': 'never',
|
'last_push_status': 'never',
|
||||||
'last_push_at': None,
|
'last_push_at': None,
|
||||||
'last_push_error': None,
|
'last_push_error': None,
|
||||||
@@ -659,7 +689,7 @@ class CellLinkManager:
|
|||||||
'domain': invite['domain'],
|
'domain': invite['domain'],
|
||||||
'connected_at': datetime.utcnow().isoformat(),
|
'connected_at': datetime.utcnow().isoformat(),
|
||||||
'permissions': perms,
|
'permissions': perms,
|
||||||
'remote_api_url': f"http://{invite['dns_ip']}:3000",
|
'remote_api_url': _remote_api_url(invite['dns_ip']),
|
||||||
'last_push_status': 'never',
|
'last_push_status': 'never',
|
||||||
'last_push_at': None,
|
'last_push_at': None,
|
||||||
'last_push_error': None,
|
'last_push_error': None,
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ bp = Blueprint('wireguard', __name__)
|
|||||||
def _effective_endpoint(wireguard_manager, config_manager) -> str:
|
def _effective_endpoint(wireguard_manager, config_manager) -> str:
|
||||||
"""Return the WireGuard endpoint to embed in peer configs.
|
"""Return the WireGuard endpoint to embed in peer configs.
|
||||||
|
|
||||||
Uses wireguard_endpoint from identity config when set (admin override),
|
Prefers the cell's public domain (DDNS/ACME modes) or an admin override over
|
||||||
falling back to get_external_ip() detection.
|
the raw external IP, so a peer config points at a name that resolves to the
|
||||||
|
cell rather than a bare IP. See WireGuardManager.get_advertised_endpoint.
|
||||||
"""
|
"""
|
||||||
srv = wireguard_manager.get_server_config()
|
return wireguard_manager.get_advertised_endpoint(config_manager) or '<SERVER_IP>'
|
||||||
override = (config_manager.get_identity().get('wireguard_endpoint') or '').strip()
|
|
||||||
if override:
|
|
||||||
port = srv.get('port', 51820)
|
|
||||||
return override if ':' in override else f'{override}:{port}'
|
|
||||||
return srv.get('endpoint') or '<SERVER_IP>'
|
|
||||||
|
|
||||||
@bp.route('/api/wireguard/keys', methods=['GET'])
|
@bp.route('/api/wireguard/keys', methods=['GET'])
|
||||||
def get_wireguard_keys():
|
def get_wireguard_keys():
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ _SAFE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
|||||||
_DIGEST_RE = re.compile(r'@sha256:[0-9a-f]{64}$')
|
_DIGEST_RE = re.compile(r'@sha256:[0-9a-f]{64}$')
|
||||||
|
|
||||||
# Bundled cosign public key — shipped in the repo (config/cosign/cosign.pub) so
|
# Bundled cosign public key — shipped in the repo (config/cosign/cosign.pub) so
|
||||||
# every cell can verify store-service image signatures offline. install.sh keeps
|
# every cell can verify store-service image signatures offline. It is bind-mounted
|
||||||
# it at /opt/pic/config/cosign/cosign.pub; in the cell-api container it is
|
# into cell-api at /app/config/cosign/cosign.pub (see docker-compose.yml). Because
|
||||||
# COPYed to /app/config/cosign/cosign.pub.
|
# `make reinstall`/`uninstall` run `rm -rf config/`, setup_cell.ensure_cosign_pubkey()
|
||||||
|
# restores it from git on every setup so the mount is never empty.
|
||||||
_COSIGN_PUBKEY_PATH = os.environ.get(
|
_COSIGN_PUBKEY_PATH = os.environ.get(
|
||||||
'PIC_COSIGN_PUBKEY', '/app/config/cosign/cosign.pub'
|
'PIC_COSIGN_PUBKEY', '/app/config/cosign/cosign.pub'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1054,6 +1054,38 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
'vpn_network': self._get_configured_network(),
|
'vpn_network': self._get_configured_network(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Domain modes whose effective domain is a publicly-resolvable FQDN that the
|
||||||
|
# WireGuard endpoint should advertise instead of a raw IP. In these modes the
|
||||||
|
# domain resolves (via DDNS/ACME) to the cell's public IP, so peers and linked
|
||||||
|
# cells reach the cell by name — which survives IP changes and lets a NAT/router
|
||||||
|
# forward each cell's WG port to the right host.
|
||||||
|
PUBLIC_DOMAIN_MODES = ('pic_ngo', 'cloudflare', 'duckdns', 'http01')
|
||||||
|
|
||||||
|
def get_advertised_endpoint(self, config_manager=None) -> Optional[str]:
|
||||||
|
"""Return the WireGuard endpoint (host:port) to advertise to peers/cells.
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. an explicit admin override (`_identity.wireguard_endpoint`),
|
||||||
|
2. the cell's public domain in a DDNS/ACME mode (`<domain>:<port>`),
|
||||||
|
3. the detected external IP (`<ip>:<port>`) — LAN/fallback.
|
||||||
|
|
||||||
|
The port is always this cell's own configured WireGuard port, so a cell
|
||||||
|
on a non-default port advertises it correctly (the router forwards that
|
||||||
|
public port to this host).
|
||||||
|
"""
|
||||||
|
port = self._get_configured_port()
|
||||||
|
identity = config_manager.get_identity() if config_manager is not None else {}
|
||||||
|
override = (identity.get('wireguard_endpoint') or '').strip()
|
||||||
|
if override:
|
||||||
|
return override if ':' in override else f'{override}:{port}'
|
||||||
|
mode = identity.get('domain_mode', 'lan')
|
||||||
|
if mode in self.PUBLIC_DOMAIN_MODES and config_manager is not None:
|
||||||
|
host = (config_manager.get_effective_domain() or '').strip()
|
||||||
|
if host:
|
||||||
|
return f'{host}:{port}'
|
||||||
|
ext = self.get_external_ip()
|
||||||
|
return f'{ext}:{port}' if ext else None
|
||||||
|
|
||||||
def get_peer_status(self, public_key: str) -> Dict[str, Any]:
|
def get_peer_status(self, public_key: str) -> Dict[str, Any]:
|
||||||
"""Return live handshake + transfer stats for a peer from `wg show`."""
|
"""Return live handshake + transfer stats for a peer from `wg show`."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ def ensure_file(rel):
|
|||||||
print(f'[EXISTS] {rel}')
|
print(f'[EXISTS] {rel}')
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_cosign_pubkey():
|
||||||
|
"""Restore the tracked cosign public key if a config wipe removed it.
|
||||||
|
|
||||||
|
`config/cosign/cosign.pub` is a git-tracked asset bind-mounted into cell-api
|
||||||
|
and used to verify store-service image signatures. `make reinstall`/
|
||||||
|
`uninstall` run `rm -rf config/`, which deletes it from the working tree, and
|
||||||
|
nothing else recreates it — leaving every store install broken under the
|
||||||
|
default enforce mode. Restore it from HEAD here (setup runs on every
|
||||||
|
install/reinstall). Best-effort: if this is not a git checkout, warn rather
|
||||||
|
than fail — install.sh surfaces the same warning.
|
||||||
|
"""
|
||||||
|
rel = os.path.join('config', 'cosign', 'cosign.pub')
|
||||||
|
path = os.path.join(ROOT, rel)
|
||||||
|
if os.path.exists(path) and os.path.getsize(path) > 0:
|
||||||
|
print(f'[EXISTS] {rel}')
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
try:
|
||||||
|
blob = subprocess.run(
|
||||||
|
['git', '-C', ROOT, 'show', 'HEAD:config/cosign/cosign.pub'],
|
||||||
|
capture_output=True, check=True).stdout
|
||||||
|
if blob:
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(blob)
|
||||||
|
print(f'[RESTORED] {rel} (from git HEAD)')
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[WARN] could not restore {rel} from git: {e}')
|
||||||
|
print(f'[WARN] {rel} is missing — store-service image signature '
|
||||||
|
'verification will fail under enforce mode until it is provided')
|
||||||
|
|
||||||
|
|
||||||
def ensure_caddy_ca_cert():
|
def ensure_caddy_ca_cert():
|
||||||
cert_dir = os.path.join(ROOT, 'config', 'caddy', 'certs')
|
cert_dir = os.path.join(ROOT, 'config', 'caddy', 'certs')
|
||||||
ca_key = os.path.join(cert_dir, 'ca.key')
|
ca_key = os.path.join(cert_dir, 'ca.key')
|
||||||
@@ -402,6 +434,7 @@ def main():
|
|||||||
for f in REQUIRED_FILES:
|
for f in REQUIRED_FILES:
|
||||||
ensure_file(f)
|
ensure_file(f)
|
||||||
|
|
||||||
|
ensure_cosign_pubkey()
|
||||||
ensure_caddy_ca_cert()
|
ensure_caddy_ca_cert()
|
||||||
priv, _pub = generate_wg_keys()
|
priv, _pub = generate_wg_keys()
|
||||||
write_wg0_conf(priv, vpn_address, wg_port)
|
write_wg0_conf(priv, vpn_address, wg_port)
|
||||||
|
|||||||
@@ -48,12 +48,16 @@ class TestGenerateCaddyfileLan(unittest.TestCase):
|
|||||||
self.assertNotIn('acme_email', out)
|
self.assertNotIn('acme_email', out)
|
||||||
self.assertNotIn('dns pic_ngo', out)
|
self.assertNotIn('dns pic_ngo', out)
|
||||||
self.assertNotIn('dns cloudflare', out)
|
self.assertNotIn('dns cloudflare', out)
|
||||||
# Internal-CA TLS pair
|
# Internal-CA TLS pair, on an HTTPS (443) site — never on an http:// one.
|
||||||
self.assertIn('tls /etc/caddy/internal/cert.pem '
|
self.assertIn('tls /etc/caddy/internal/cert.pem '
|
||||||
'/etc/caddy/internal/key.pem', out)
|
'/etc/caddy/internal/key.pem', out)
|
||||||
# Cell hostname plus virtual IP listener
|
self.assertIn('https://mycell.cell {', out)
|
||||||
self.assertIn('http://mycell.cell', out)
|
# Cell hostname plus virtual IP listener on plain HTTP (80)
|
||||||
self.assertIn('http://172.20.0.2:80', out)
|
self.assertIn('http://mycell.cell, http://172.20.0.2:80 {', out)
|
||||||
|
# The HTTP (:80) block must NOT carry a tls directive — Caddy rejects
|
||||||
|
# "server listening on [:80] is HTTP, but attempts to configure TLS".
|
||||||
|
http_block = out.split('http://mycell.cell, http://172.20.0.2:80 {', 1)[1]
|
||||||
|
self.assertNotIn('tls ', http_block)
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateCaddyfilePicNgo(unittest.TestCase):
|
class TestGenerateCaddyfilePicNgo(unittest.TestCase):
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def _make_wg_mock():
|
|||||||
}
|
}
|
||||||
wg._get_configured_network.return_value = '10.0.0.0/24'
|
wg._get_configured_network.return_value = '10.0.0.0/24'
|
||||||
wg._get_configured_address.return_value = '10.0.0.1/24'
|
wg._get_configured_address.return_value = '10.0.0.1/24'
|
||||||
|
wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
|
||||||
wg.add_cell_peer.return_value = True
|
wg.add_cell_peer.return_value = True
|
||||||
wg.remove_peer.return_value = True
|
wg.remove_peer.return_value = True
|
||||||
return wg
|
return wg
|
||||||
@@ -82,6 +83,13 @@ class TestCellLinkManagerInvite(unittest.TestCase):
|
|||||||
self.assertEqual(invite['cell_name'], 'myhome')
|
self.assertEqual(invite['cell_name'], 'myhome')
|
||||||
self.assertEqual(invite['domain'], 'myhome.local')
|
self.assertEqual(invite['domain'], 'myhome.local')
|
||||||
|
|
||||||
|
def test_generate_invite_endpoint_from_advertised_endpoint(self):
|
||||||
|
"""The invite endpoint comes from get_advertised_endpoint (domain-aware),
|
||||||
|
not a raw external IP — so the remote cell reaches us by name + our port."""
|
||||||
|
self.wg.get_advertised_endpoint.return_value = 'myhome.pic.ngo:51821'
|
||||||
|
invite = self.mgr.generate_invite('myhome', 'myhome.pic.ngo')
|
||||||
|
self.assertEqual(invite['endpoint'], 'myhome.pic.ngo:51821')
|
||||||
|
|
||||||
|
|
||||||
class TestCellLinkManagerConnections(unittest.TestCase):
|
class TestCellLinkManagerConnections(unittest.TestCase):
|
||||||
|
|
||||||
@@ -182,7 +190,7 @@ class TestCellLinkManagerConnections(unittest.TestCase):
|
|||||||
result = self.mgr.accept_invite(updated_invite)
|
result = self.mgr.accept_invite(updated_invite)
|
||||||
|
|
||||||
self.assertEqual(result['dns_ip'], '10.1.0.2')
|
self.assertEqual(result['dns_ip'], '10.1.0.2')
|
||||||
self.assertEqual(result['remote_api_url'], 'http://10.1.0.2:3000')
|
self.assertEqual(result['remote_api_url'], 'https://10.1.0.2')
|
||||||
self.nm.remove_cell_dns_forward.assert_called()
|
self.nm.remove_cell_dns_forward.assert_called()
|
||||||
self.nm.add_cell_dns_forward.assert_called_with(
|
self.nm.add_cell_dns_forward.assert_called_with(
|
||||||
domain='office.cell', dns_ip='10.1.0.2')
|
domain='office.cell', dns_ip='10.1.0.2')
|
||||||
@@ -470,9 +478,10 @@ class TestPushInviteToRemote(unittest.TestCase):
|
|||||||
result = self.mgr._push_invite_to_remote(link)
|
result = self.mgr._push_invite_to_remote(link)
|
||||||
self.assertFalse(result['ok'])
|
self.assertFalse(result['ok'])
|
||||||
|
|
||||||
def test_push_invite_sends_to_correct_lan_host(self):
|
def test_push_invite_sends_to_endpoint_host_over_https(self):
|
||||||
"""The curl URL must use the LAN IP from the endpoint, not the WG dns_ip."""
|
"""The curl targets the endpoint host on Caddy/HTTPS (443), not the WG
|
||||||
link = self._make_link(endpoint='192.168.31.52:51820')
|
dns_ip and not the internal :3000 API port."""
|
||||||
|
link = self._make_link(endpoint='alice.pic.ngo:51821')
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_run(cmd, **kw):
|
def fake_run(cmd, **kw):
|
||||||
@@ -493,10 +502,11 @@ class TestPushInviteToRemote(unittest.TestCase):
|
|||||||
self.mgr._push_invite_to_remote(link)
|
self.mgr._push_invite_to_remote(link)
|
||||||
|
|
||||||
url_in_cmd = captured['cmd'][-1]
|
url_in_cmd = captured['cmd'][-1]
|
||||||
self.assertIn('192.168.31.52', url_in_cmd)
|
self.assertEqual(url_in_cmd,
|
||||||
self.assertIn('accept-invite', url_in_cmd)
|
'https://alice.pic.ngo/api/cells/peer-sync/accept-invite')
|
||||||
# Must NOT use the WG dns_ip (10.1.0.1)
|
self.assertNotIn(':3000', url_in_cmd)
|
||||||
self.assertNotIn('10.1.0.1', url_in_cmd)
|
self.assertNotIn('10.1.0.1', url_in_cmd) # not the WG dns_ip
|
||||||
|
self.assertIn('-k', captured['cmd']) # cert may not match a bare IP
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -605,7 +615,7 @@ class TestAcceptInviteNew(unittest.TestCase):
|
|||||||
with patch('firewall_manager.apply_cell_rules'):
|
with patch('firewall_manager.apply_cell_rules'):
|
||||||
result = self.mgr.accept_invite(updated)
|
result = self.mgr.accept_invite(updated)
|
||||||
self.assertEqual(result['dns_ip'], '10.1.0.5')
|
self.assertEqual(result['dns_ip'], '10.1.0.5')
|
||||||
self.assertEqual(result['remote_api_url'], 'http://10.1.0.5:3000')
|
self.assertEqual(result['remote_api_url'], 'https://10.1.0.5')
|
||||||
self.nm.remove_cell_dns_forward.assert_called()
|
self.nm.remove_cell_dns_forward.assert_called()
|
||||||
self.nm.add_cell_dns_forward.assert_called_with(
|
self.nm.add_cell_dns_forward.assert_called_with(
|
||||||
domain='office.cell', dns_ip='10.1.0.5')
|
domain='office.cell', dns_ip='10.1.0.5')
|
||||||
@@ -1047,7 +1057,8 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
|
|
||||||
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
|
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
|
||||||
link = self._add_office()
|
link = self._add_office()
|
||||||
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
|
# Cross-cell API is reached over the tunnel via Caddy/443, not :3000.
|
||||||
|
self.assertEqual(link['remote_api_url'], 'https://10.1.0.1')
|
||||||
|
|
||||||
def test_add_connection_triggers_push(self):
|
def test_add_connection_triggers_push(self):
|
||||||
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
push_mock = MagicMock(return_value={'ok': True, 'error': None})
|
||||||
@@ -1321,7 +1332,7 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
self.assertIn('last_push_status', link)
|
self.assertIn('last_push_status', link)
|
||||||
self.assertIn('last_push_at', link)
|
self.assertIn('last_push_at', link)
|
||||||
self.assertIn('last_remote_update_at', link)
|
self.assertIn('last_remote_update_at', link)
|
||||||
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
|
self.assertEqual(link['remote_api_url'], 'https://10.1.0.1')
|
||||||
self.assertTrue(link['pending_push']) # pre-existing → marked pending
|
self.assertTrue(link['pending_push']) # pre-existing → marked pending
|
||||||
self.assertEqual(link['last_push_status'], 'never')
|
self.assertEqual(link['last_push_status'], 'never')
|
||||||
|
|
||||||
@@ -1330,6 +1341,24 @@ class TestPermissionSync(unittest.TestCase):
|
|||||||
raw = json.load(f)
|
raw = json.load(f)
|
||||||
self.assertIn('pending_push', raw[0])
|
self.assertIn('pending_push', raw[0])
|
||||||
|
|
||||||
|
def test_load_migrates_legacy_http_3000_url_to_https(self):
|
||||||
|
"""An existing link with the old http://<ip>:3000 URL (unreachable across
|
||||||
|
cells) is rewritten to the HTTPS/Caddy form on load."""
|
||||||
|
legacy = [{
|
||||||
|
'cell_name': 'office',
|
||||||
|
'public_key': 'officepubkey=',
|
||||||
|
'vpn_subnet': '10.1.0.0/24',
|
||||||
|
'dns_ip': '10.1.0.9',
|
||||||
|
'domain': 'office.cell',
|
||||||
|
'permissions': {'inbound': {}, 'outbound': {}},
|
||||||
|
'remote_api_url': 'http://10.1.0.9:3000',
|
||||||
|
}]
|
||||||
|
links_file = os.path.join(self.test_dir, 'cell_links.json')
|
||||||
|
with open(links_file, 'w') as f:
|
||||||
|
json.dump(legacy, f)
|
||||||
|
link = self.mgr.list_connections()[0]
|
||||||
|
self.assertEqual(link['remote_api_url'], 'https://10.1.0.9')
|
||||||
|
|
||||||
|
|
||||||
class TestExitOffer(unittest.TestCase):
|
class TestExitOffer(unittest.TestCase):
|
||||||
"""Tests for Phase 2: exit-offer signaling."""
|
"""Tests for Phase 2: exit-offer signaling."""
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for scripts/setup_cell.py setup helpers."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))
|
||||||
|
import setup_cell # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureCosignPubkey(unittest.TestCase):
|
||||||
|
"""ensure_cosign_pubkey restores the tracked key after a `rm -rf config/`.
|
||||||
|
|
||||||
|
Regression: `make reinstall`/`uninstall` wipe config/, deleting the tracked
|
||||||
|
config/cosign/cosign.pub; without restore, enforce-mode store installs break.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KEY_REL = os.path.join('config', 'cosign', 'cosign.pub')
|
||||||
|
KEY_BODY = '-----BEGIN PUBLIC KEY-----\nTESTKEYDATA\n-----END PUBLIC KEY-----\n'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp()
|
||||||
|
env = {**os.environ, 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_SYSTEM': '/dev/null'}
|
||||||
|
subprocess.run(['git', 'init', '-q', self.tmp], check=True, env=env)
|
||||||
|
subprocess.run(['git', '-C', self.tmp, 'config', 'user.email', 't@t'], check=True)
|
||||||
|
subprocess.run(['git', '-C', self.tmp, 'config', 'user.name', 't'], check=True)
|
||||||
|
self.key = os.path.join(self.tmp, self.KEY_REL)
|
||||||
|
os.makedirs(os.path.dirname(self.key))
|
||||||
|
with open(self.key, 'w') as f:
|
||||||
|
f.write(self.KEY_BODY)
|
||||||
|
subprocess.run(['git', '-C', self.tmp, 'add', '-A'], check=True)
|
||||||
|
subprocess.run(['git', '-C', self.tmp, 'commit', '-qm', 'init'], check=True, env=env)
|
||||||
|
self._root = setup_cell.ROOT
|
||||||
|
setup_cell.ROOT = self.tmp
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
setup_cell.ROOT = self._root
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_restores_key_when_wiped(self):
|
||||||
|
os.remove(self.key)
|
||||||
|
shutil.rmtree(os.path.dirname(self.key)) # mimic `rm -rf config/`
|
||||||
|
self.assertFalse(os.path.exists(self.key))
|
||||||
|
setup_cell.ensure_cosign_pubkey()
|
||||||
|
self.assertTrue(os.path.exists(self.key))
|
||||||
|
self.assertEqual(open(self.key).read(), self.KEY_BODY)
|
||||||
|
|
||||||
|
def test_noop_when_key_present(self):
|
||||||
|
setup_cell.ensure_cosign_pubkey()
|
||||||
|
self.assertEqual(open(self.key).read(), self.KEY_BODY)
|
||||||
|
|
||||||
|
def test_warns_not_raises_outside_git(self):
|
||||||
|
# Not a git checkout and key missing → must warn, never raise.
|
||||||
|
non_git = tempfile.mkdtemp()
|
||||||
|
setup_cell.ROOT = non_git
|
||||||
|
try:
|
||||||
|
setup_cell.ensure_cosign_pubkey() # should not raise
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(non_git, self.KEY_REL)))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(non_git, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -90,11 +90,13 @@ class TestWireGuardEndpoints(unittest.TestCase):
|
|||||||
'endpoint': '1.2.3.4:51820',
|
'endpoint': '1.2.3.4:51820',
|
||||||
'port': 51820,
|
'port': 51820,
|
||||||
}
|
}
|
||||||
|
mock_wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
|
||||||
r = self.client.get('/api/wireguard/server-config')
|
r = self.client.get('/api/wireguard/server-config')
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
data = json.loads(r.data)
|
data = json.loads(r.data)
|
||||||
self.assertIn('public_key', data)
|
self.assertIn('public_key', data)
|
||||||
self.assertIn('endpoint', data)
|
self.assertIn('endpoint', data)
|
||||||
|
self.assertEqual(data.get('effective_endpoint'), '1.2.3.4:51820')
|
||||||
|
|
||||||
@patch('app.wireguard_manager')
|
@patch('app.wireguard_manager')
|
||||||
def test_server_config_returns_500_on_exception(self, mock_wg):
|
def test_server_config_returns_500_on_exception(self, mock_wg):
|
||||||
|
|||||||
@@ -885,5 +885,60 @@ class TestCellRoutes(unittest.TestCase):
|
|||||||
mock_route.assert_called_once_with('10.1.0.0/24')
|
mock_route.assert_called_once_with('10.1.0.0/24')
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCM:
|
||||||
|
"""Minimal config_manager stand-in for get_advertised_endpoint tests."""
|
||||||
|
def __init__(self, identity, effective_domain):
|
||||||
|
self._identity = identity
|
||||||
|
self._effective = effective_domain
|
||||||
|
|
||||||
|
def get_identity(self):
|
||||||
|
return self._identity
|
||||||
|
|
||||||
|
def get_effective_domain(self):
|
||||||
|
return self._effective
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdvertisedEndpoint(unittest.TestCase):
|
||||||
|
"""get_advertised_endpoint prefers domain/override over the raw external IP."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.wg = WireGuardManager(self.test_dir, self.test_dir)
|
||||||
|
# Pin the configured port and external IP for deterministic endpoints.
|
||||||
|
self.wg._get_configured_port = MagicMock(return_value=51821)
|
||||||
|
self.wg.get_external_ip = MagicMock(return_value='198.51.100.7')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_public_mode_uses_effective_domain_and_own_port(self):
|
||||||
|
cm = _FakeCM({'domain_mode': 'pic_ngo'}, 'alice.pic.ngo')
|
||||||
|
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'alice.pic.ngo:51821')
|
||||||
|
|
||||||
|
def test_lan_mode_falls_back_to_external_ip(self):
|
||||||
|
cm = _FakeCM({'domain_mode': 'lan'}, 'cell')
|
||||||
|
self.assertEqual(self.wg.get_advertised_endpoint(cm), '198.51.100.7:51821')
|
||||||
|
|
||||||
|
def test_admin_override_wins(self):
|
||||||
|
cm = _FakeCM({'domain_mode': 'pic_ngo', 'wireguard_endpoint': 'vpn.example.com'}, 'alice.pic.ngo')
|
||||||
|
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'vpn.example.com:51821')
|
||||||
|
|
||||||
|
def test_override_with_explicit_port_kept(self):
|
||||||
|
cm = _FakeCM({'domain_mode': 'lan', 'wireguard_endpoint': 'vpn.example.com:7777'}, 'cell')
|
||||||
|
self.assertEqual(self.wg.get_advertised_endpoint(cm), 'vpn.example.com:7777')
|
||||||
|
|
||||||
|
def test_none_when_no_domain_and_no_external_ip(self):
|
||||||
|
self.wg.get_external_ip = MagicMock(return_value=None)
|
||||||
|
cm = _FakeCM({'domain_mode': 'lan'}, 'cell')
|
||||||
|
self.assertIsNone(self.wg.get_advertised_endpoint(cm))
|
||||||
|
|
||||||
|
def test_public_mode_without_domain_falls_back_to_ip(self):
|
||||||
|
cm = _FakeCM({'domain_mode': 'cloudflare'}, '')
|
||||||
|
self.assertEqual(self.wg.get_advertised_endpoint(cm), '198.51.100.7:51821')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in New Issue
Block a user