diff --git a/.env.example b/.env.example index 5add859..ed4adf2 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,9 @@ SEARXNG_INSTANCE=http://localhost:8080 # Enable authentication (default: true) # AUTH_ENABLED=true -# Host port for the Odysseus web UI in Docker Compose. +# Host bind address and port for the Odysseus web UI in Docker Compose. +# Keep APP_BIND on loopback unless you intentionally want LAN/reverse-proxy access. +# APP_BIND=127.0.0.1 # Change this if another local service already uses 7000 (macOS AirPlay often does). # APP_PORT=7000 diff --git a/README.md b/README.md index 2f2da5b..64c54b5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ A full, hover-to-play tour lives on the landing page (`docs/index.html`). Defaults work out of the box: clone, run, then configure models/search/email inside **Settings**. Only edit `.env` for deployment-level overrides like -`APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password. +`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password. On first setup, Odysseus creates an admin account (`admin` unless `ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal. @@ -61,8 +61,10 @@ cd odysseus cp .env.example .env # optional, but recommended for explicit defaults docker compose up -d --build ``` -Open `http://localhost:7000` when the containers are healthy. If the port is -taken, set `APP_PORT=7001` in `.env` and recreate the container. +Open `http://localhost:7000` when the containers are healthy. Docker Compose +binds the web UI to `127.0.0.1` by default. If the port is taken, set +`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0` +only when you intentionally want LAN/reverse-proxy access. ### Native Linux / macOS ```bash @@ -72,10 +74,11 @@ python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python setup.py -python -m uvicorn app:app --host 0.0.0.0 --port 7000 +python -m uvicorn app:app --host 127.0.0.1 --port 7000 ``` Requirements: Python 3.11+. Cookbook also needs `tmux` for background model -downloads and serves. +downloads and serves. Use `--host 0.0.0.0` only when you intentionally want +LAN/reverse-proxy access. ### Apple Silicon Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an @@ -97,9 +100,9 @@ It launches at `http://127.0.0.1:7860`. To build a clickable app wrapper: Cookbook, GPU, Ollama, and troubleshooting notes **Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and -ntfy. ChromaDB/SearXNG/ntfy bind host ports to `127.0.0.1` by default, so they -are reachable from the host but not exposed to your LAN/public internet unless -you opt in. +ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so +they are reachable from the host but not exposed to your LAN/public internet +unless you opt in. **Cookbook storage in Docker.** Downloads live in `./data/huggingface` (`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and @@ -234,6 +237,8 @@ Key settings: | `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. | | `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. | | `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. | +| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. | +| `APP_PORT` | `7000` | Docker Compose host port for the web UI. | | `AUTH_ENABLED` | `true` | Enable/disable login | | `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | | `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string | diff --git a/docker-compose.yml b/docker-compose.yml index 8b48170..f91017b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: odysseus: build: . ports: - - "${APP_PORT:-7000}:7000" + - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" volumes: - ./data:/app/data - ./logs:/app/logs diff --git a/routes/auth_routes.py b/routes/auth_routes.py index dca14c3..e171f97 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -21,6 +21,7 @@ from src.integrations import ( update_integration, delete_integration, get_integration, + mask_integration_secret, execute_api_call, INTEGRATION_PRESETS, migrate_from_settings, @@ -431,12 +432,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(403, "Admin only") items = load_integrations() # Mask API keys for frontend display - safe = [] - for item in items: - copy = dict(item) - if copy.get("api_key"): - copy["api_key"] = copy["api_key"][:4] + "****" - safe.append(copy) + safe = [mask_integration_secret(item) for item in items] return {"integrations": safe} @router.get("/integrations/presets") @@ -452,7 +448,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(403, "Admin only") body = await request.json() item = add_integration(body) - return {"ok": True, "integration": item} + return {"ok": True, "integration": mask_integration_secret(item)} @router.put("/integrations/{integration_id}") async def update_integration_route(integration_id: str, request: Request): @@ -464,7 +460,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: item = update_integration(integration_id, body) if not item: raise HTTPException(404, "Integration not found") - return {"ok": True, "integration": item} + return {"ok": True, "integration": mask_integration_secret(item)} @router.delete("/integrations/{integration_id}") async def delete_integration_route(integration_id: str, request: Request): diff --git a/src/integrations.py b/src/integrations.py index 27e356e..45b3c6c 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -7,6 +7,10 @@ from typing import Dict, List, Optional, Any import httpx +from core.atomic_io import atomic_write_json +from core.platform_compat import safe_chmod +from src.secret_storage import decrypt, encrypt, is_encrypted + log = logging.getLogger(__name__) DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json") @@ -143,23 +147,69 @@ def _ensure_data_dir() -> None: os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) +def _encrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Return storage-safe copies with API keys encrypted at rest.""" + safe: List[Dict[str, Any]] = [] + for item in integrations: + copy = dict(item) + api_key = copy.get("api_key", "") + if api_key: + copy["api_key"] = encrypt(str(api_key)) + safe.append(copy) + return safe + + +def _decrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Return runtime copies with API keys decrypted for callers.""" + decoded: List[Dict[str, Any]] = [] + for item in integrations: + copy = dict(item) + api_key = copy.get("api_key", "") + if api_key: + copy["api_key"] = decrypt(str(api_key)) + decoded.append(copy) + return decoded + + +def _has_plaintext_api_key(integrations: List[Dict[str, Any]]) -> bool: + return any( + bool(item.get("api_key")) and not is_encrypted(str(item.get("api_key"))) + for item in integrations + ) + + +def mask_integration_secret(integration: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy safe for API responses.""" + safe = dict(integration) + api_key = safe.get("api_key", "") + if api_key: + safe["api_key"] = f"{str(api_key)[:4]}****" + return safe + + def load_integrations() -> List[Dict[str, Any]]: - """Load all integrations from disk.""" + """Load all integrations from disk with secrets decrypted for runtime use.""" if not os.path.exists(DATA_FILE): return [] try: with open(DATA_FILE, "r", encoding="utf-8") as f: - return json.load(f) + integrations = json.load(f) + if not isinstance(integrations, list): + log.error("Invalid integrations file shape: expected a list") + return [] + if _has_plaintext_api_key(integrations): + save_integrations(_decrypt_integration_secrets(integrations)) + return _decrypt_integration_secrets(integrations) except (json.JSONDecodeError, IOError) as exc: log.error("Failed to load integrations: %s", exc) return [] def save_integrations(integrations: List[Dict[str, Any]]) -> None: - """Persist integrations list to disk.""" + """Persist integrations list to disk with API keys encrypted at rest.""" _ensure_data_dir() - with open(DATA_FILE, "w", encoding="utf-8") as f: - json.dump(integrations, f, indent=2) + atomic_write_json(DATA_FILE, _encrypt_integration_secrets(integrations), indent=2) + safe_chmod(DATA_FILE, 0o600) def get_integration(integration_id: str) -> Optional[Dict[str, Any]]: diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 581d0e5..d1dbf7b 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -1058,56 +1058,53 @@ class TaskScheduler: except Exception as e: raw["notes_tasks"] = f"Error: {e}" - # Auto-discover API integrations (Miniflux RSS, etc.) from integrations.json + # Auto-discover API integrations (Miniflux RSS, etc.). try: import httpx - from pathlib import Path as _P - integrations_file = _P("data/integrations.json") - if integrations_file.exists(): - integrations = json.loads(integrations_file.read_text(encoding="utf-8")) - for integ in integrations: - if not integ.get("enabled"): - continue - preset = integ.get("preset", "") - base_url = integ.get("base_url", "").rstrip("/") - api_key = integ.get("api_key", "") - if not base_url: - continue + from src.integrations import load_integrations + for integ in load_integrations(): + if not integ.get("enabled"): + continue + preset = integ.get("preset", "") + base_url = integ.get("base_url", "").rstrip("/") + api_key = integ.get("api_key", "") + if not base_url: + continue - # Build auth headers - headers = {} - if integ.get("auth_type") == "header" and api_key: - headers[integ.get("auth_header", "X-Auth-Token")] = api_key - elif integ.get("auth_type") == "bearer" and api_key: - headers["Authorization"] = f"Bearer {api_key}" + # Build auth headers + headers = {} + if integ.get("auth_type") == "header" and api_key: + headers[integ.get("auth_header", "X-Auth-Token")] = api_key + elif integ.get("auth_type") == "bearer" and api_key: + headers["Authorization"] = f"Bearer {api_key}" - # Miniflux: fetch unread entries (cached 3 min across tasks) - if preset == "miniflux": - async def _fetch_miniflux(_base=base_url, _headers=dict(headers)): - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get( - f"{_base}/v1/entries", - params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"}, - headers=_headers, - ) - if resp.status_code != 200: - return None - entries = resp.json().get("entries", []) or [] - if not entries: - return None - lines = [] - for e in entries[:15]: - title = e.get("title", "?") - feed = (e.get("feed") or {}).get("title", "?") - url = e.get("url", "") - lines.append(f"- [{feed}] {title} — {url}") - return "\n".join(lines) - try: - val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux) - if val: - raw["rss_miniflux_unread"] = val - except Exception as e: - logger.warning(f"Miniflux fetch failed: {e}") + # Miniflux: fetch unread entries (cached 3 min across tasks) + if preset == "miniflux": + async def _fetch_miniflux(_base=base_url, _headers=dict(headers)): + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{_base}/v1/entries", + params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"}, + headers=_headers, + ) + if resp.status_code != 200: + return None + entries = resp.json().get("entries", []) or [] + if not entries: + return None + lines = [] + for e in entries[:15]: + title = e.get("title", "?") + feed = (e.get("feed") or {}).get("title", "?") + url = e.get("url", "") + lines.append(f"- [{feed}] {title} — {url}") + return "\n".join(lines) + try: + val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux) + if val: + raw["rss_miniflux_unread"] = val + except Exception as e: + logger.warning(f"Miniflux fetch failed: {e}") except Exception as e: logger.warning(f"Integrations discovery failed: {e}") diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py index 93ac0dc..08e1b96 100644 --- a/tests/test_security_regressions.py +++ b/tests/test_security_regressions.py @@ -111,6 +111,77 @@ def test_secret_storage_key_created_with_safe_mode(tmp_path, monkeypatch): assert mode == 0o600, f"expected 0o600, got 0o{mode:o}" +# ── secure-by-default deployment + integration storage ───────── + +def test_docker_compose_binds_web_ui_to_loopback_by_default(): + compose = Path("docker-compose.yml").read_text(encoding="utf-8") + assert "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" in compose + assert '"${APP_PORT:-7000}:7000"' not in compose + + +def test_readme_native_quickstart_uses_loopback(): + readme = Path("README.md").read_text(encoding="utf-8") + assert "python -m uvicorn app:app --host 127.0.0.1 --port 7000" in readme + assert "Use `--host 0.0.0.0` only when you intentionally want" in readme + + +def _import_integrations(tmp_path, monkeypatch): + """Import src.integrations with data + encryption key redirected to tmp.""" + _import_secret_storage(tmp_path, monkeypatch) + sys.modules.pop("src.integrations", None) + from src import integrations # noqa: WPS433 + monkeypatch.setattr(integrations, "DATA_FILE", str(tmp_path / "integrations.json")) + return integrations + + +def test_integrations_api_keys_are_encrypted_at_rest(tmp_path, monkeypatch): + integrations = _import_integrations(tmp_path, monkeypatch) + + integrations.save_integrations([ + { + "id": "miniflux", + "name": "Miniflux", + "base_url": "https://rss.example", + "auth_type": "bearer", + "api_key": "secret-token", + } + ]) + + raw_text = (tmp_path / "integrations.json").read_text(encoding="utf-8") + raw = json.loads(raw_text) + assert raw[0]["api_key"].startswith("enc:") + assert "secret-token" not in raw_text + + loaded = integrations.load_integrations() + assert loaded[0]["api_key"] == "secret-token" + assert integrations.mask_integration_secret(loaded[0])["api_key"] == "secr****" + + +def test_integrations_plaintext_keys_migrate_on_load(tmp_path, monkeypatch): + integrations = _import_integrations(tmp_path, monkeypatch) + data_file = tmp_path / "integrations.json" + data_file.write_text( + json.dumps([ + { + "id": "legacy", + "name": "Legacy API", + "base_url": "https://api.example", + "auth_type": "header", + "api_key": "legacy-secret", + } + ]), + encoding="utf-8", + ) + + loaded = integrations.load_integrations() + + assert loaded[0]["api_key"] == "legacy-secret" + migrated_text = data_file.read_text(encoding="utf-8") + migrated = json.loads(migrated_text) + assert migrated[0]["api_key"].startswith("enc:") + assert "legacy-secret" not in migrated_text + + # ── _q IMAP mailbox quoter ───────────────────────────────────── def _import_q():