From 0fc09c9011f82d1e0aba23ac8118eb22a22181cd Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Sat, 2 May 2026 21:53:35 -0600 Subject: [PATCH] Fix Docker Infonet and Wormhole startup --- backend/services/config.py | 1 + backend/services/mesh/mesh_secure_storage.py | 35 ++++++++++++- .../mesh/test_secure_storage_passphrase.py | 52 +++++++++++++++---- frontend/src/components/MeshTerminal.tsx | 10 +++- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/backend/services/config.py b/backend/services/config.py index 80f3f59..49ceb64 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -210,6 +210,7 @@ class Settings(BaseSettings): MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK: bool = False MESH_ACK_RAW_FALLBACK_AT_OWN_RISK: bool = False MESH_SECURE_STORAGE_SECRET: str = "" + MESH_SECURE_STORAGE_SECRET_FILE: str = "" MESH_PRIVATE_LOG_TTL_S: int = 900 # Sprint 1 rollout: restored DM boot probes stay disabled by default until # the architect reviews false positives from the observe-only path. diff --git a/backend/services/mesh/mesh_secure_storage.py b/backend/services/mesh/mesh_secure_storage.py index 0bab1e1..f1feb92 100644 --- a/backend/services/mesh/mesh_secure_storage.py +++ b/backend/services/mesh/mesh_secure_storage.py @@ -230,11 +230,16 @@ def _raw_fallback_allowed() -> bool: return False +def _generated_secret_file() -> Path: + return DATA_DIR / "secure_storage_secret.key" + + def _get_storage_secret() -> str | None: - """Return the operator-supplied secure storage secret, or None.""" + """Return the operator-supplied or local generated secure storage secret.""" secret = os.environ.get("MESH_SECURE_STORAGE_SECRET", "").strip() if secret: return secret + secret_file_override = os.environ.get("MESH_SECURE_STORAGE_SECRET_FILE", "").strip() try: from services.config import get_settings @@ -242,8 +247,36 @@ def _get_storage_secret() -> str | None: secret = str(getattr(settings, "MESH_SECURE_STORAGE_SECRET", "") or "").strip() if secret: return secret + secret_file_override = ( + secret_file_override + or str(getattr(settings, "MESH_SECURE_STORAGE_SECRET_FILE", "") or "").strip() + ) except Exception: pass + if not _is_windows(): + if _raw_fallback_allowed(): + return None + secret_file = Path(secret_file_override or _generated_secret_file()) + try: + if secret_file.exists(): + secret = secret_file.read_text(encoding="utf-8").strip() + if secret: + return secret + secret_file.parent.mkdir(parents=True, exist_ok=True) + secret = _b64(os.urandom(48)) + _atomic_write_text(secret_file, secret + "\n", encoding="utf-8") + try: + os.chmod(secret_file, 0o600) + except OSError: + pass + logger.info("Generated local secure storage secret at %s", secret_file) + return secret + except Exception as exc: + logger.warning( + "Failed to load or generate local secure storage secret at %s: %s", + secret_file, + exc, + ) return None diff --git a/backend/tests/mesh/test_secure_storage_passphrase.py b/backend/tests/mesh/test_secure_storage_passphrase.py index 0d9a0b3..4b27740 100644 --- a/backend/tests/mesh/test_secure_storage_passphrase.py +++ b/backend/tests/mesh/test_secure_storage_passphrase.py @@ -2,7 +2,7 @@ Tests prove: - Docker no longer auto-allows raw fallback -- Non-Windows with no secure secret and no raw opt-in fails closed +- Non-Windows with no secure secret generates a local passphrase file - Non-Windows with MESH_SECURE_STORAGE_SECRET works (passphrase provider) - Passphrase-protected envelopes round-trip correctly (master + domain) - Raw-to-passphrase migration works when secret is supplied @@ -64,10 +64,10 @@ class TestDockerNoAutoRawFallback: assert mesh_secure_storage._raw_fallback_allowed() is True -class TestFailClosedWithoutSecret: - """Non-Windows with no secret and no raw opt-in must fail closed.""" +class TestGeneratedLocalSecretWithoutOperatorSecret: + """Non-Windows with no supplied secret generates a local passphrase file.""" - def test_master_key_creation_fails_closed(self, tmp_path, monkeypatch): + def test_master_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch): from services.mesh import mesh_secure_storage from services import config as config_mod @@ -76,6 +76,7 @@ class TestFailClosedWithoutSecret: monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False) + monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False) monkeypatch.setattr( config_mod, "get_settings", @@ -86,10 +87,14 @@ class TestFailClosedWithoutSecret: ) _reset(mesh_secure_storage) - with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"): - mesh_secure_storage._load_master_key() + key = mesh_secure_storage._load_master_key() + assert len(key) == 32 + assert (tmp_path / "secure_storage_secret.key").exists() + envelope = json.loads((tmp_path / "master.key").read_text(encoding="utf-8")) + assert envelope["provider"] == "passphrase" + assert "key" not in envelope - def test_domain_key_creation_fails_closed(self, tmp_path, monkeypatch): + def test_domain_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch): from services.mesh import mesh_secure_storage from services import config as config_mod @@ -98,6 +103,7 @@ class TestFailClosedWithoutSecret: monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False) + monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False) monkeypatch.setattr( config_mod, "get_settings", @@ -108,8 +114,12 @@ class TestFailClosedWithoutSecret: ) _reset(mesh_secure_storage) - with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"): - mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path) + key = mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path) + assert len(key) == 32 + assert (tmp_path / "secure_storage_secret.key").exists() + envelope = json.loads((tmp_path / "_domain_keys" / "test_domain.key").read_text(encoding="utf-8")) + assert envelope["provider"] == "passphrase" + assert "key" not in envelope class TestPassphraseProvider: @@ -311,7 +321,7 @@ class TestWrongPassphraseFails: ), ) - with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET is not set"): + with pytest.raises(mesh_secure_storage.SecureStorageError, match="Failed to unwrap"): mesh_secure_storage._load_master_key() @@ -517,6 +527,7 @@ class TestGetStorageSecret: from services import config as config_mod monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False) + monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: True) monkeypatch.setattr( config_mod, "get_settings", @@ -524,6 +535,27 @@ class TestGetStorageSecret: ) assert mesh_secure_storage._get_storage_secret() is None + def test_generates_local_secret_file_on_non_windows(self, tmp_path, monkeypatch): + from services.mesh import mesh_secure_storage + from services import config as config_mod + + secret_file = tmp_path / "generated_secret.key" + monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False) + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + monkeypatch.setenv("MESH_SECURE_STORAGE_SECRET_FILE", str(secret_file)) + monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False) + monkeypatch.setattr( + config_mod, + "get_settings", + lambda: SimpleNamespace(MESH_SECURE_STORAGE_SECRET=""), + ) + + first = mesh_secure_storage._get_storage_secret() + second = mesh_secure_storage._get_storage_secret() + assert first + assert second == first + assert secret_file.read_text(encoding="utf-8").strip() == first + def test_falls_back_to_config(self, monkeypatch): from services.mesh import mesh_secure_storage from services import config as config_mod diff --git a/frontend/src/components/MeshTerminal.tsx b/frontend/src/components/MeshTerminal.tsx index a05c6ea..391ca2c 100644 --- a/frontend/src/components/MeshTerminal.tsx +++ b/frontend/src/components/MeshTerminal.tsx @@ -364,6 +364,7 @@ function summarizeNodePeer(peerUrl?: string): string { } function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): string { + if (snapshot && !snapshot.node_enabled) return 'READY / DISABLED'; const bootstrap = snapshot?.bootstrap; if (!bootstrap) return 'LOCAL ONLY'; if (bootstrap.manifest_loaded) { @@ -376,6 +377,7 @@ function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): st } function describeSyncOutcome(snapshot?: InfonetNodeStatusSnapshot | null): string { + if (snapshot && !snapshot.node_enabled) return 'OFF - click NODE to activate'; const sync = snapshot?.sync_runtime; if (!sync) return 'IDLE'; const outcome = String(sync.last_outcome || 'idle').trim().toLowerCase(); @@ -433,6 +435,12 @@ function buildNodeRuntimeLines(snapshot: InfonetNodeStatusSnapshot): TermLine[] type: 'error', }); } + if (!snapshot.node_enabled) { + lines.push({ + text: ' Activate: click the NODE button in the top-right controls to join the public testnet seed', + type: 'dim', + }); + } lines.push({ text: '', type: 'dim' }); return lines; } @@ -5945,7 +5953,7 @@ export default function MeshTerminal({ isOpen, launchToken = 0, onClose, onDmCou PARTICIPANT NODE
- Automatic bootstrap and sync now live on the backend lane. This node can keep a local chain even with Wormhole off. + Backend bootstrap is configured; activate the participant node to sync the public testnet seed without Wormhole.