diff --git a/backend/auth.py b/backend/auth.py index 6183ea8..907c917 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -13,6 +13,7 @@ import hmac import asyncio import hmac as _hmac_mod import hashlib as _hashlib_mod +import ipaddress import json as json_mod import logging import time @@ -235,10 +236,36 @@ def _is_local_or_docker(host: str) -> bool: return host in {"127.0.0.1", "::1", "localhost"} +def _docker_bridge_local_operator_enabled() -> bool: + return str(os.environ.get("SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _is_docker_bridge_host(host: str) -> bool: + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + # Docker Desktop and the default compose bridge normally sit inside + # 172.16.0.0/12. Keep this narrower than "any private IP" so a user who + # intentionally binds the backend to LAN does not silently trust LAN clients. + return ip in ipaddress.ip_network("172.16.0.0/12") + + +def _is_trusted_local_runtime_host(host: str) -> bool: + if _is_local_or_docker(host): + return True + return _docker_bridge_local_operator_enabled() and _is_docker_bridge_host(host) + + def require_local_operator(request: Request): """Allow local tooling on loopback / Docker internal network, or a valid admin key.""" host = (request.client.host or "").lower() if request.client else "" - if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"): + if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"): return admin_key = _current_admin_key() presented = str(request.headers.get("X-Admin-Key", "") or "").strip() @@ -362,8 +389,8 @@ async def require_openclaw_or_local(request: Request): """ host = (request.client.host or "").lower() if request.client else "" - # 1. Local loopback — always allowed - if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"): + # 1. Local runtime path — loopback, plus bundled Docker bridge when compose opts in + if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"): return # 2. Admin key — full trust diff --git a/backend/tests/test_p0_security.py b/backend/tests/test_p0_security.py index ba11673..21ee0ed 100644 --- a/backend/tests/test_p0_security.py +++ b/backend/tests/test_p0_security.py @@ -86,6 +86,18 @@ class TestRequireLocalOperator: def test_rfc1918_172_blocked_without_key(self): assert self._call_with_host("172.16.0.5") == 403 + def test_docker_bridge_blocked_without_compose_opt_in(self): + with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": ""}): + assert self._call_with_host("172.18.0.3") == 403 + + def test_docker_bridge_passes_with_compose_opt_in(self): + with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}): + assert self._call_with_host("172.18.0.3") == 200 + + def test_lan_ip_still_blocked_with_compose_opt_in(self): + with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}): + assert self._call_with_host("192.168.1.100") == 403 + def test_rfc1918_192168_blocked_without_key(self): assert self._call_with_host("192.168.1.100") == 403 diff --git a/docker-compose.yml b/docker-compose.yml index 3ca23be..4dc4566 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: - MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-} # Shared transport auth for operator peer push. Must be set to a unique secret per deployment. - MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET:-} + # The bundled Docker UI talks to the backend across Docker's private bridge. + # Treat that bridge as local operator access while ports remain bound to 127.0.0.1 by default. + - SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=${SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR:-1} volumes: - backend_data:/app/data restart: unless-stopped diff --git a/frontend/src/components/OnboardingModal.tsx b/frontend/src/components/OnboardingModal.tsx index dbc0383..3893be7 100644 --- a/frontend/src/components/OnboardingModal.tsx +++ b/frontend/src/components/OnboardingModal.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from 'lucide-react'; -const CURRENT_ONBOARDING_VERSION = '0.9.7'; +const CURRENT_ONBOARDING_VERSION = '0.9.7-docker-keys-1'; const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`; const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete'; @@ -19,7 +19,7 @@ const API_GUIDES = [ 'Create a free account at opensky-network.org', 'Go to Dashboard → OAuth → Create Client', 'Copy your Client ID and Client Secret', - 'Paste both into Settings → Aviation', + 'Set OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET in your .env file, then restart ShadowBroker', ], url: 'https://opensky-network.org/index.php?option=com_users&view=registration', color: 'cyan', @@ -33,7 +33,7 @@ const API_GUIDES = [ 'Register at aisstream.io', 'Navigate to your API Keys page', 'Generate a new API key', - 'Paste it into Settings → Maritime', + 'Set AIS_API_KEY in your .env file, then restart ShadowBroker', ], url: 'https://aisstream.io/authenticate', color: 'blue', @@ -218,8 +218,9 @@ const OnboardingModal = React.memo(function OnboardingModal({

OpenSky Network and AIS Stream are the free keys that make ShadowBroker - useful immediately: live aircraft and vessel tracking. Add these first; - trust modes and the no-key sources can come next. + useful immediately: live aircraft and vessel tracking. For Docker installs, + create or edit the .env file next to docker-compose.yml, then run docker + compose up -d. For local source installs, edit backend/.env and restart.