From c99193041ae3a2a52a6c03a0fafad6e77cbd71a0 Mon Sep 17 00:00:00 2001 From: lolwuttav Date: Mon, 1 Jun 2026 21:27:04 -0600 Subject: [PATCH] fix(cookbook): default Ollama serve to loopback (#872) --- routes/cookbook_helpers.py | 32 +++++++++++++++++++++++++++ routes/cookbook_routes.py | 20 +++++++++++------ static/js/admin.js | 2 +- static/js/cookbook.js | 3 ++- tests/test_cookbook_helpers.py | 35 ++++++++++++++++++++++++++++++ tests/test_security_regressions.py | 12 ++++++++++ 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index c646864..fcfb25a 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -318,6 +318,38 @@ _SERVE_CMD_ALLOWLIST = { _GGUF_PRELUDE_RE = re.compile( r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*' ) +_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)") +_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$") +_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$") + + +def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]: + """Return the Ollama bind host/port requested by a serve command. + + Plain local `ollama serve` defaults to loopback. Remote callers can pass a + wider default host so the resulting API is reachable by Odysseus. + """ + if not cmd: + return default_host, "11434" + match = _OLLAMA_HOST_ASSIGNMENT_RE.search(cmd) + if not match: + return default_host, "11434" + value = match.group(1).strip("'\"") + bind_match = _OLLAMA_BIND_RE.match(value) + if not bind_match: + return "127.0.0.1", "11434" + bracketed_host = bind_match.group(1) + host = bracketed_host or bind_match.group(3) or "127.0.0.1" + port = bind_match.group(2) or bind_match.group(4) or "11434" + if not _OLLAMA_BIND_HOST_RE.match(host): + return "127.0.0.1", "11434" + try: + port_num = int(port, 10) + except ValueError: + return "127.0.0.1", "11434" + if port_num < 1 or port_num > 65535: + return "127.0.0.1", "11434" + return f"[{host}]" if bracketed_host else host, port def _check_serve_binary(seg: str) -> None: diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index a6f03d5..364fa0e 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -37,7 +37,7 @@ from routes.cookbook_helpers import ( _validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, - _append_serve_exit_code_lines, _cached_model_scan_script, + _append_serve_exit_code_lines, _cached_model_scan_script, _ollama_bind_from_cmd, _pip_install_fallback_chain, ModelDownloadRequest, ServeRequest, ) @@ -999,13 +999,15 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append('fi') elif "ollama" in req.cmd: handled_ollama_serve = True - _ollama_port = "11434" - _ollama_match = re.search(r"OLLAMA_HOST=[^\s:]+:(\d+)", req.cmd) - if _ollama_match: - _ollama_port = _ollama_match.group(1) + _ollama_default_host = "0.0.0.0" if remote else "127.0.0.1" + _ollama_host, _ollama_port = _ollama_bind_from_cmd( + req.cmd, + default_host=_ollama_default_host, + ) # Ollama can be a host binary, a system service, or a Docker # container. If the HTTP API is already reachable, the model is # already served and we should not require a host `ollama` CLI. + runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}') runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"') runner_lines.append('ODYSSEUS_OLLAMA_URL=""') runner_lines.append('for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do') @@ -1034,8 +1036,12 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' echo "=== Process exited with code 127 ==="') runner_lines.append(' exec bash -i') runner_lines.append('fi') - runner_lines.append('echo "Starting ollama server on 0.0.0.0:${ODYSSEUS_OLLAMA_PORT}..."') - runner_lines.append('OLLAMA_HOST="0.0.0.0:${ODYSSEUS_OLLAMA_PORT}" ollama serve') + runner_lines.append('ODYSSEUS_OLLAMA_URL="http://${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}"') + if remote and _ollama_host in ("0.0.0.0", "::"): + runner_lines.append('echo "[odysseus] WARNING: remote Ollama will bind to ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT} so Odysseus can reach it from this host."') + runner_lines.append('echo "[odysseus] Ollama has no built-in authentication; expose this only on a trusted LAN/VPN or provide an explicit OLLAMA_HOST with your own access controls."') + runner_lines.append('echo "Starting ollama server on ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}..."') + runner_lines.append('OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve') runner_lines.append('_ody_exit=$?') runner_lines.append('echo') runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') diff --git a/static/js/admin.js b/static/js/admin.js index 4d15a4f..a551df7 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -968,7 +968,7 @@ function initEndpointForm() { const data = await res.json(); const items = data.items || []; if (!items.length) { - msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.'; + msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.'; msg.className = 'admin-error'; } else { // Auto-add each discovered endpoint. Server dedupes on base_url diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 3443156..af8d911 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -426,7 +426,8 @@ export function _buildServeCmd(f, modelName, backend) { } } else if (backend === 'ollama') { const ollamaPort = f.port || '11434'; - const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=0.0.0.0:${ollamaPort} ` : ''; + const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1'; + const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : ''; cmd = `${hostEnv}ollama serve`; } else if (backend === 'diffusers') { const gpuStr = f.gpus?.trim(); diff --git a/tests/test_cookbook_helpers.py b/tests/test_cookbook_helpers.py index 5ce6650..278e428 100644 --- a/tests/test_cookbook_helpers.py +++ b/tests/test_cookbook_helpers.py @@ -11,6 +11,7 @@ from routes.cookbook_helpers import ( _append_serve_preflight_exit_lines, _local_tooling_path_export, _pip_install_fallback_chain, + _ollama_bind_from_cmd, _safe_env_prefix, _validate_gpus, _validate_repo_id, @@ -130,6 +131,40 @@ def test_serve_runner_preserves_command_exit_code(): assert 'echo "=== Process exited with code $? ==="' not in script +def test_ollama_serve_defaults_to_loopback_bind(): + assert _ollama_bind_from_cmd("ollama serve") == ("127.0.0.1", "11434") + assert _ollama_bind_from_cmd("ollama run qwen2.5:0.5b") == ("127.0.0.1", "11434") + + +def test_ollama_serve_accepts_remote_reachable_default_bind(): + assert ( + _ollama_bind_from_cmd("ollama serve", default_host="0.0.0.0") + == ("0.0.0.0", "11434") + ) + + +def test_ollama_serve_preserves_explicit_bind_opt_in(): + assert ( + _ollama_bind_from_cmd("OLLAMA_HOST=0.0.0.0:12345 ollama serve") + == ("0.0.0.0", "12345") + ) + assert ( + _ollama_bind_from_cmd("OLLAMA_HOST=[::1]:11435 ollama serve") + == ("[::1]", "11435") + ) + + +def test_ollama_serve_rejects_unsafe_bind_values(): + assert ( + _ollama_bind_from_cmd("OLLAMA_HOST='$HOST:11434' ollama serve") + == ("127.0.0.1", "11434") + ) + assert ( + _ollama_bind_from_cmd("OLLAMA_HOST=127.0.0.1:99999 ollama serve") + == ("127.0.0.1", "11434") + ) + + def test_cached_model_scan_reports_plain_dir_gguf(tmp_path): """Custom download dirs may sit inside the HF hub cache and contain plain per-model folders. They must show up in Serve and keep the GGUF signal.""" diff --git a/tests/test_security_regressions.py b/tests/test_security_regressions.py index 38a1c52..488c38f 100644 --- a/tests/test_security_regressions.py +++ b/tests/test_security_regressions.py @@ -125,6 +125,18 @@ def test_readme_native_quickstart_uses_loopback(): assert "Use `--host 0.0.0.0` only when you intentionally want" in readme +def test_ollama_cookbook_runner_does_not_force_public_bind(): + route = Path("routes/cookbook_routes.py").read_text(encoding="utf-8") + cookbook_js = Path("static/js/cookbook.js").read_text(encoding="utf-8") + assert 'OLLAMA_HOST="0.0.0.0:${ODYSSEUS_OLLAMA_PORT}" ollama serve' not in route + assert 'OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve' in route + assert '_ollama_default_host = "0.0.0.0" if remote else "127.0.0.1"' in route + assert "WARNING: remote Ollama will bind" in route + assert "OLLAMA_HOST=0.0.0.0:${ollamaPort}" not in cookbook_js + assert "const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';" in cookbook_js + assert "OLLAMA_HOST=${bindHost}:${ollamaPort}" in cookbook_js + + def _import_integrations(tmp_path, monkeypatch): """Import src.integrations with data + encryption key redirected to tmp.""" _import_secret_storage(tmp_path, monkeypatch)