feat(post-install): rebuild Log2RAM with the journal and task logs kept on disk

This commit is contained in:
MacRimi
2026-10-27 19:48:26 +01:00
parent ac9b8c2705
commit 4cefc0484b
57 changed files with 992 additions and 1065 deletions
@@ -7,6 +7,16 @@ from typing import Optional
from .base import AIProvider, AIProviderError
def ollama_headers(api_key: str = "") -> dict:
"""Authenticate protected Ollama gateways without requiring a key locally."""
if not isinstance(api_key, str) or len(api_key) > 512 or '\r' in api_key or '\n' in api_key:
raise AIProviderError("Invalid Ollama API key")
headers = {'Content-Type': 'application/json'}
if api_key.strip():
headers['Authorization'] = f'Bearer {api_key.strip()}'
return headers
class OllamaProvider(AIProvider):
"""Ollama provider for local AI execution."""
@@ -18,7 +28,7 @@ class OllamaProvider(AIProvider):
"""Initialize Ollama provider.
Args:
api_key: Not used for Ollama (local execution)
api_key: Optional Bearer token for an authenticated Ollama gateway
model: Model name (user must select from loaded models)
base_url: Ollama server URL (default: http://localhost:11434)
"""
@@ -57,9 +67,7 @@ class OllamaProvider(AIProvider):
}
}
headers = {
'Content-Type': 'application/json',
}
headers = ollama_headers(self.api_key)
# Cloud models (e.g., kimi-k2.5:cloud, minimax-m2.7:cloud) need longer timeout
# because requests go through: ProxMenux -> Ollama -> Cloud Provider -> back
@@ -96,7 +104,8 @@ class OllamaProvider(AIProvider):
# First check if server is running
try:
url = f"{self.base_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
headers = {'User-Agent': 'ProxMenux/1.0', **ollama_headers(self.api_key)}
req = urllib.request.Request(url, method='GET', headers=headers)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode('utf-8'))
@@ -122,6 +131,13 @@ class OllamaProvider(AIProvider):
'message': f"Model '{self.model}' not found. Available: {', '.join(display_models)}{'...' if len(full_model_names) > 5 else ''}",
'model': self.model
}
except urllib.error.HTTPError as error:
message = (
f"HTTP {error.code}: Ollama authentication failed. Check the optional API key."
if error.code in (401, 403)
else f"HTTP {error.code}: Ollama request failed"
)
return {'success': False, 'message': message, 'model': self.model}
except urllib.error.URLError:
return {
'success': False,
@@ -446,7 +446,7 @@ def get_provider_models():
Request body:
{
"provider": "gemini|groq|openai|openrouter|ollama|anthropic",
"api_key": "your-api-key", // Not needed for ollama
"api_key": "your-api-key", // Optional for protected Ollama gateways
"ollama_url": "http://localhost:11434", // Only for ollama
"openai_base_url": "https://custom.endpoint/v1" // Optional for openai
}
@@ -498,9 +498,10 @@ def get_provider_models():
if provider == 'ollama':
import urllib.request
import urllib.error
from ai_providers.ollama_provider import ollama_headers
url = f"{ollama_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET')
req = urllib.request.Request(url, method='GET', headers=ollama_headers(api_key))
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
with urllib.request.urlopen(req, timeout=10) as resp:
+3 -1
View File
@@ -86,6 +86,7 @@ SENSITIVE_KEYS = {
'ai_api_key_anthropic',
'ai_api_key_openai',
'ai_api_key_openrouter',
'ai_api_key_ollama',
# `telegram.bot_token` — was previously listed as `telegram.token`,
# which never matched the real config_key (`bot_token` in
# CHANNEL_TYPES). The mismatch silently sent Telegram bot tokens
@@ -3019,6 +3020,7 @@ class NotificationManager:
current_provider = self._config.get('ai_provider', 'groq')
_raw_keys = {
'groq': self._config.get('ai_api_key_groq', ''),
'ollama': self._config.get('ai_api_key_ollama', ''),
'gemini': self._config.get('ai_api_key_gemini', ''),
'anthropic': self._config.get('ai_api_key_anthropic', ''),
'openai': self._config.get('ai_api_key_openai', ''),
@@ -3026,7 +3028,7 @@ class NotificationManager:
}
ai_api_keys = {
'groq': _mask_if_set(_raw_keys['groq']),
'ollama': '', # Ollama doesn't need API key
'ollama': _mask_if_set(_raw_keys['ollama']),
'gemini': _mask_if_set(_raw_keys['gemini']),
'anthropic': _mask_if_set(_raw_keys['anthropic']),
'openai': _mask_if_set(_raw_keys['openai']),
+189
View File
@@ -0,0 +1,189 @@
"""Optional Ollama gateway authentication, with no real keys or network calls."""
import ast
import io
import json
from pathlib import Path
import sqlite3
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
import urllib.error
SCRIPTS = Path(__file__).resolve().parents[1]
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from ai_providers.ollama_provider import OllamaProvider, ollama_headers
from ai_providers.base import AIProviderError
import notification_manager as manager_module
TOKEN = "test-only-ollama-token"
MODEL = "test-model:latest"
def routes(manager, payload):
"""Use the actual handlers without importing Flask or starting a server."""
source = SCRIPTS / "flask_notification_routes.py"
names = {"_resolve_masked_api_key", "get_provider_models",
"test_ai_connection", "reveal_notification_secret"}
functions = []
for node in ast.parse(source.read_text()).body:
if isinstance(node, ast.FunctionDef) and node.name in names:
node.decorator_list = []
functions.append(node)
scope = {
"__file__": str(source), "json": json,
"request": SimpleNamespace(get_json=lambda **kwargs: payload),
"jsonify": lambda value: value,
"notification_manager": manager,
"SENSITIVE_PLACEHOLDER": manager_module.SENSITIVE_PLACEHOLDER,
"validate_external_url": manager_module.validate_external_url,
"load_verified_models": lambda: {},
}
module = ast.fix_missing_locations(ast.Module(body=functions, type_ignores=[]))
exec(compile(module, str(source), "exec"), scope)
return scope
class OllamaAuthenticationTests(unittest.TestCase):
def setUp(self):
self.requests = []
self.required_token = TOKEN
patcher = mock.patch("urllib.request.urlopen", side_effect=self.respond)
patcher.start()
self.addCleanup(patcher.stop)
def respond(self, request, timeout=None):
self.requests.append(request)
if self.required_token and request.get_header("Authorization") != "Bearer " + self.required_token:
raise urllib.error.HTTPError(request.full_url, 401, "Unauthorized", {},
io.BytesIO(b"sensitive response body"))
if request.full_url.endswith("/api/tags"):
data = {"models": [{"name": MODEL}]}
elif request.full_url.endswith("/api/chat"):
data = {"message": {"content": "CONNECTION_OK"}}
else:
raise AssertionError("Unexpected endpoint: " + request.full_url)
return io.BytesIO(json.dumps(data).encode())
def test_key_is_optional_and_no_authorization_header_is_sent_when_empty(self):
self.required_token = ""
for key in ("", " "):
provider = OllamaProvider(api_key=key, model=MODEL)
self.assertTrue(provider.test_connection()["success"])
self.assertTrue(self.requests)
self.assertTrue(all(r.get_header("Authorization") is None for r in self.requests))
self.assertFalse(OllamaProvider.REQUIRES_API_KEY)
def test_connection_authenticates_both_model_lookup_and_generation(self):
result = OllamaProvider(api_key=TOKEN, model=MODEL).test_connection()
self.assertTrue(result["success"], result)
self.assertEqual([r.get_method() for r in self.requests], ["GET", "POST"])
self.assertTrue(all(r.get_header("Authorization") == "Bearer " + TOKEN for r in self.requests))
def test_authentication_failure_is_not_reported_as_an_unreachable_server(self):
result = OllamaProvider(api_key="wrong-test-token", model=MODEL).test_connection()
self.assertFalse(result["success"])
self.assertIn("HTTP 401", result["message"])
self.assertNotIn("Cannot connect", result["message"])
self.assertNotIn("wrong-test-token", json.dumps(result))
self.assertNotIn("sensitive response", json.dumps(result))
def test_invalid_header_values_are_rejected_without_echoing_them(self):
for value in ("test-secret\r\nInjected: header", "x" * 513, None):
with self.subTest(value_type=type(value).__name__):
with self.assertRaisesRegex(AIProviderError, "^Invalid Ollama API key$"):
ollama_headers(value)
self.assertEqual(self.requests, [])
def test_model_loading_uses_the_saved_key_when_the_browser_sends_a_mask(self):
manager = SimpleNamespace(_config={"ai_api_key_ollama": TOKEN})
handlers = routes(manager, {"provider": "ollama", "api_key": manager_module.SENSITIVE_PLACEHOLDER,
"ollama_url": "http://localhost:11434"})
result = handlers["get_provider_models"]()
self.assertTrue(result["success"], result)
self.assertEqual(result["models"], [MODEL])
self.assertEqual(self.requests[0].get_header("Authorization"), "Bearer " + TOKEN)
self.assertNotIn(TOKEN, json.dumps(result))
def test_model_loading_without_a_key_still_works_for_native_ollama(self):
self.required_token = ""
handlers = routes(SimpleNamespace(_config={}), {
"provider": "ollama", "ollama_url": "http://localhost:11434"})
result = handlers["get_provider_models"]()
self.assertTrue(result["success"], result)
self.assertIsNone(self.requests[0].get_header("Authorization"))
def test_test_ai_route_reuses_the_saved_key(self):
handlers = routes(SimpleNamespace(_config={"ai_api_key_ollama": TOKEN}), {
"provider": "ollama", "model": MODEL,
"api_key": manager_module.SENSITIVE_PLACEHOLDER,
"ollama_url": "http://localhost:11434"})
result = handlers["test_ai_connection"]()
self.assertTrue(result["success"], result)
self.assertTrue(all(r.get_header("Authorization") == "Bearer " + TOKEN for r in self.requests))
def test_reveal_endpoint_recognizes_the_ollama_secret(self):
handlers = routes(SimpleNamespace(_config={"ai_api_key_ollama": TOKEN}), {"ai_provider": "ollama"})
self.assertEqual(handlers["reveal_notification_secret"](), ({"value": TOKEN}, 200))
def test_real_notification_provider_receives_the_ollama_key(self):
manager = manager_module.NotificationManager()
manager._config = {"ai_enabled": "true", "ai_provider": "ollama",
"ai_api_key_ollama": TOKEN, "ai_model_ollama": MODEL,
"ai_ollama_url": "http://localhost:11434"}
from notification_templates import AIEnhancer
rewriter = AIEnhancer(manager._build_ai_config())
self.assertIsInstance(rewriter._provider, OllamaProvider)
self.assertEqual(rewriter._provider.generate("system", "user"), "CONNECTION_OK")
self.assertEqual(self.requests[0].get_header("Authorization"), "Bearer " + TOKEN)
class OllamaKeyStorageTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.db = Path(directory.name) / "settings.db"
with sqlite3.connect(str(self.db)) as conn:
conn.execute("CREATE TABLE user_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, updated_at TEXT)")
for name, value in (("DB_PATH", self.db), ("ENCRYPTION_KEY_FILE", Path(directory.name) / "key")):
patcher = mock.patch.object(manager_module, name, value)
patcher.start()
self.addCleanup(patcher.stop)
self.manager = manager_module.NotificationManager()
self.manager._rebuild_channels = mock.Mock()
def stored_value(self):
with sqlite3.connect(str(self.db)) as conn:
return conn.execute("SELECT setting_value FROM user_settings WHERE setting_key = ?",
("notification.ai_api_key_ollama",)).fetchone()[0]
def test_key_is_encrypted_masked_and_loaded_for_runtime_use(self):
result = self.manager.save_settings({"ai_provider": "ollama", "ai_api_key_ollama": TOKEN})
self.assertTrue(result["success"], result)
stored = self.stored_value()
self.assertTrue(stored.startswith("ENC2:"))
self.assertNotIn(TOKEN, stored)
self.assertEqual(manager_module.decrypt_sensitive_value(stored), TOKEN)
self.assertEqual(self.manager.get_settings()["config"]["ai_api_keys"]["ollama"],
manager_module.SENSITIVE_PLACEHOLDER)
fresh = manager_module.NotificationManager()
fresh._load_config()
self.assertEqual(fresh._build_ai_config()["ai_api_key"], TOKEN)
def test_masked_round_trip_keeps_the_key_and_an_empty_value_removes_it(self):
self.assertTrue(self.manager.save_settings({"ai_api_key_ollama": TOKEN})["success"])
stored = self.stored_value()
self.assertTrue(self.manager.save_settings({"ai_api_key_ollama": manager_module.SENSITIVE_PLACEHOLDER})["success"])
self.assertEqual(self.stored_value(), stored)
self.assertTrue(self.manager.save_settings({"ai_api_key_ollama": ""})["success"])
self.assertEqual(self.stored_value(), "")
self.assertEqual(self.manager.get_settings()["config"]["ai_api_keys"]["ollama"], "")
if __name__ == "__main__":
unittest.main()