3 Commits

Author SHA1 Message Date
roof c7e01d4aa7 fix: LAN Caddyfile serves TLS on an https:// site, not an http:// one
Unit Tests / test (push) Successful in 9m46s
_caddyfile_lan emitted the internal-CA `tls` directive inside an
`http://<cell>.cell, http://172.20.0.2:80` block. Caddy rejects a tls
directive on a port-80 (HTTP) listener ("server listening on [:80] is HTTP,
but attempts to configure TLS connection policies"), so cell-caddy crash-looped
in LAN mode. Split into a `https://<cell>.cell` site (internal-CA tls) plus a
separate plain-HTTP block for :80 — both needed because the WireGuard server
DNATs peer traffic to Caddy on 80 and 443.

Note: LAN mode still needs the internal serving cert wired to the mounted certs
dir (a separate gap) before cell-caddy comes fully up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 07:26:15 -04:00
roof 1bb8a5eb59 fix: advertise WireGuard endpoint by domain, and reach linked cells over HTTPS
Unit Tests / test (push) Successful in 9m50s
Three related cell-link/peer-config fixes (the peer and cell endpoints were
showing the raw external IP, which confused public-vs-internal addressing):

1. Peer WireGuard configs now embed the cell's effective domain (DDNS/ACME
   modes) instead of the detected external IP, via the new
   WireGuardManager.get_advertised_endpoint(). A name that resolves to the
   public IP survives IP changes and lets the datacenter forward each cell's
   WG port to the right host. LAN mode still falls back to the IP; an admin
   wireguard_endpoint override still wins.

2. Cell invites advertise <effective-domain>:<this cell's WG port> (was the
   external IP + a default/possibly-wrong port), so a remote cell pairs to the
   right host and port over the public path.

3. Cross-cell peer-sync no longer targets http://<ip>:3000 (the API binds
   127.0.0.1 and is unreachable across cells). It targets the remote's Caddy on
   HTTPS/443 — which the WireGuard server already DNATs over the tunnel — and the
   initial pre-tunnel invite push goes to https://<endpoint-host>/... ; legacy
   http://<ip>:3000 link URLs migrate to https on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 04:21:16 -04:00
roof fa746a3b30 fix: restore cosign pubkey on setup so clean reinstall keeps image verification
Unit Tests / test (push) Successful in 9m50s
`make reinstall`/`uninstall` run `rm -rf config/`, which deletes the git-tracked
config/cosign/cosign.pub. Nothing recreated it, so after any clean reinstall the
bind-mounted key was missing and cosign verification failed for EVERY store
service under the default enforce mode ("loading public key: open
/app/config/cosign/cosign.pub: no such file or directory") — store installs were
completely broken on a fresh install. Found during clean-build pic1 verification.

setup_cell.ensure_cosign_pubkey() now restores the key from git HEAD on every
setup (best-effort; warns rather than fails outside a git checkout). Also fixes
the stale service_composer comment that claimed a Dockerfile COPY that never
existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:32:19 -04:00
11 changed files with 307 additions and 46 deletions
+14 -2
View File
@@ -310,7 +310,15 @@ class CaddyManager(BaseServiceManager):
service_routes: str, core_routes: str,
cert_path: str = _CADDY_INTERNAL_CERT,
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 = []
if service_routes:
body.append(self._indent_routes(service_routes))
@@ -325,10 +333,14 @@ class CaddyManager(BaseServiceManager):
" auto_https off\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"{inner}\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,
+48 -18
View File
@@ -30,6 +30,17 @@ _BACKOFF_BASE_S = 60
_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:
"""Return an ISO timestamp for the earliest next retry using capped exponential backoff."""
delay = min(_BACKOFF_BASE_S * (2 ** (attempts - 1)), _BACKOFF_MAX_S)
@@ -66,10 +77,12 @@ class CellLinkManager:
changed = True
# Phase 1 migration: permission-sync tracking fields
if 'remote_api_url' not in link:
link['remote_api_url'] = (
f"http://{link['dns_ip']}:3000"
if link.get('dns_ip') else None
)
link['remote_api_url'] = _remote_api_url(link.get('dns_ip'))
changed = True
# 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
if 'last_push_status' not in link:
link['last_push_status'] = 'never'
@@ -193,7 +206,10 @@ class CellLinkManager:
cmd = [
'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',
'-H', 'Content-Type: application/json',
]
@@ -371,14 +387,24 @@ class CellLinkManager:
# ── Public API ────────────────────────────────────────────────────────────
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()
srv = self.wireguard_manager.get_server_config()
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 {
'cell_name': cell_name,
'public_key': keys['public_key'],
'endpoint': srv.get('endpoint'),
'endpoint': endpoint,
'vpn_subnet': self.wireguard_manager._get_configured_network(),
'dns_ip': server_vpn_ip,
'domain': domain,
@@ -448,15 +474,16 @@ class CellLinkManager:
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.
Called immediately after adding the remote as our WG peer. Uses the
remote's endpoint IP (LAN-reachable before the WG tunnel is up) rather
than the WG-internal dns_ip. Non-fatal — one-sided pairing degrades
gracefully; the admin can pair from the other side manually.
Called immediately after adding the remote as our WG peer, before the WG
tunnel is up. Reaches the remote over the PUBLIC path at its advertised
endpoint host (a domain in DDNS/ACME modes) on Caddy/443 — the API's :3000
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 ''
if not 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:
host = endpoint.rsplit(':', 1)[0].strip('[]')
except Exception:
@@ -471,11 +498,14 @@ class CellLinkManager:
except Exception as 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})
cmd = [
'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',
'-H', 'Content-Type: application/json',
'-d', payload,
@@ -537,7 +567,7 @@ class CellLinkManager:
old_domain = existing.get('domain', '')
existing['dns_ip'] = invite['dns_ip']
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'):
existing['endpoint'] = invite['endpoint']
if domain_changed:
@@ -599,7 +629,7 @@ class CellLinkManager:
'domain': invite['domain'],
'connected_at': datetime.utcnow().isoformat(),
'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_at': None,
'last_push_error': None,
@@ -659,7 +689,7 @@ class CellLinkManager:
'domain': invite['domain'],
'connected_at': datetime.utcnow().isoformat(),
'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_at': None,
'last_push_error': None,
+4 -8
View File
@@ -8,15 +8,11 @@ bp = Blueprint('wireguard', __name__)
def _effective_endpoint(wireguard_manager, config_manager) -> str:
"""Return the WireGuard endpoint to embed in peer configs.
Uses wireguard_endpoint from identity config when set (admin override),
falling back to get_external_ip() detection.
Prefers the cell's public domain (DDNS/ACME modes) or an admin override over
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()
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>'
return wireguard_manager.get_advertised_endpoint(config_manager) or '<SERVER_IP>'
@bp.route('/api/wireguard/keys', methods=['GET'])
def get_wireguard_keys():
+4 -3
View File
@@ -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}$')
# 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
# it at /opt/pic/config/cosign/cosign.pub; in the cell-api container it is
# COPYed to /app/config/cosign/cosign.pub.
# every cell can verify store-service image signatures offline. It is bind-mounted
# into cell-api at /app/config/cosign/cosign.pub (see docker-compose.yml). Because
# `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(
'PIC_COSIGN_PUBKEY', '/app/config/cosign/cosign.pub'
)
+32
View File
@@ -1054,6 +1054,38 @@ class WireGuardManager(BaseServiceManager):
'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]:
"""Return live handshake + transfer stats for a peer from `wg show`."""
try:
+33
View File
@@ -62,6 +62,38 @@ def ensure_file(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():
cert_dir = os.path.join(ROOT, 'config', 'caddy', 'certs')
ca_key = os.path.join(cert_dir, 'ca.key')
@@ -402,6 +434,7 @@ def main():
for f in REQUIRED_FILES:
ensure_file(f)
ensure_cosign_pubkey()
ensure_caddy_ca_cert()
priv, _pub = generate_wg_keys()
write_wg0_conf(priv, vpn_address, wg_port)
+8 -4
View File
@@ -48,12 +48,16 @@ class TestGenerateCaddyfileLan(unittest.TestCase):
self.assertNotIn('acme_email', out)
self.assertNotIn('dns pic_ngo', 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 '
'/etc/caddy/internal/key.pem', out)
# Cell hostname plus virtual IP listener
self.assertIn('http://mycell.cell', out)
self.assertIn('http://172.20.0.2:80', out)
self.assertIn('https://mycell.cell {', out)
# Cell hostname plus virtual IP listener on plain HTTP (80)
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):
+40 -11
View File
@@ -26,6 +26,7 @@ def _make_wg_mock():
}
wg._get_configured_network.return_value = '10.0.0.0/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.remove_peer.return_value = True
return wg
@@ -82,6 +83,13 @@ class TestCellLinkManagerInvite(unittest.TestCase):
self.assertEqual(invite['cell_name'], 'myhome')
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):
@@ -182,7 +190,7 @@ class TestCellLinkManagerConnections(unittest.TestCase):
result = self.mgr.accept_invite(updated_invite)
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.add_cell_dns_forward.assert_called_with(
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)
self.assertFalse(result['ok'])
def test_push_invite_sends_to_correct_lan_host(self):
"""The curl URL must use the LAN IP from the endpoint, not the WG dns_ip."""
link = self._make_link(endpoint='192.168.31.52:51820')
def test_push_invite_sends_to_endpoint_host_over_https(self):
"""The curl targets the endpoint host on Caddy/HTTPS (443), not the WG
dns_ip and not the internal :3000 API port."""
link = self._make_link(endpoint='alice.pic.ngo:51821')
captured = {}
def fake_run(cmd, **kw):
@@ -493,10 +502,11 @@ class TestPushInviteToRemote(unittest.TestCase):
self.mgr._push_invite_to_remote(link)
url_in_cmd = captured['cmd'][-1]
self.assertIn('192.168.31.52', url_in_cmd)
self.assertIn('accept-invite', url_in_cmd)
# Must NOT use the WG dns_ip (10.1.0.1)
self.assertNotIn('10.1.0.1', url_in_cmd)
self.assertEqual(url_in_cmd,
'https://alice.pic.ngo/api/cells/peer-sync/accept-invite')
self.assertNotIn(':3000', 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'):
result = self.mgr.accept_invite(updated)
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.add_cell_dns_forward.assert_called_with(
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):
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):
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_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.assertEqual(link['last_push_status'], 'never')
@@ -1330,6 +1341,24 @@ class TestPermissionSync(unittest.TestCase):
raw = json.load(f)
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):
"""Tests for Phase 2: exit-offer signaling."""
+67
View File
@@ -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()
+2
View File
@@ -90,11 +90,13 @@ class TestWireGuardEndpoints(unittest.TestCase):
'endpoint': '1.2.3.4:51820',
'port': 51820,
}
mock_wg.get_advertised_endpoint.return_value = '1.2.3.4:51820'
r = self.client.get('/api/wireguard/server-config')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertIn('public_key', data)
self.assertIn('endpoint', data)
self.assertEqual(data.get('effective_endpoint'), '1.2.3.4:51820')
@patch('app.wireguard_manager')
def test_server_config_returns_500_on_exception(self, mock_wg):
+55
View File
@@ -885,5 +885,60 @@ class TestCellRoutes(unittest.TestCase):
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__':
unittest.main()