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
+19 -6
View File
@@ -874,7 +874,7 @@ export function NotificationSettings() {
// Flatten per-provider API keys
if (cfg.ai_api_keys) {
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
if (key) {
if (key || provider === "ollama") {
flat[`ai_api_key_${provider}`] = key
}
}
@@ -2305,6 +2305,12 @@ export function NotificationSettings() {
// Restore previously saved model for the new provider (if any)
const savedModel = config.ai_models?.[v] || ''
setShowSecrets(p => ({ ...p, ai_key: false }))
setRevealedSecrets(p => {
const next = { ...p }
delete next.ai_key
return next
})
updateConfig(p => ({
...p,
@@ -2364,11 +2370,13 @@ export function NotificationSettings() {
</div>
)}
{/* API Key (not shown for Ollama) */}
{config.ai_provider !== "ollama" && (
{/* Ollama gateways may require a key; native servers do not. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
<Label htmlFor="notification-ai-api-key" className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
{config.ai_provider === "ollama" && (
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
)}
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
<a
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
@@ -2382,9 +2390,10 @@ export function NotificationSettings() {
</div>
<div className="flex items-center gap-2">
<Input
id="notification-ai-api-key"
type={showSecrets["ai_key"] ? "text" : "password"}
className="h-9 text-sm font-mono"
placeholder="sk-..."
placeholder={config.ai_provider === "ollama" ? "" : "sk-..."}
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
onChange={e => updateConfig(p => ({
...p,
@@ -2402,8 +2411,12 @@ export function NotificationSettings() {
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{config.ai_provider === "ollama" && (
<p className="text-xs text-muted-foreground">
{t("settings.notifications.ai.ollamaApiKeyHint")}
</p>
)}
</div>
)}
{/* Model - selector with Load button for all providers */}
<div className="space-y-2">
+1
View File
@@ -2312,6 +2312,7 @@
"testing": "Testen…",
"testConnection": "Testverbindung",
"ollamaUrl": "Ollama-URL",
"ollamaApiKeyHint": "Bei einem normalen Ollama-Server leer lassen. Wenn dein Proxy oder Gateway einen Schlüssel verlangt, wird er als Authorization: Bearer gesendet. Dieses Feld aktiviert keine Authentifizierung auf dem Server.",
"getKey": "Holen Sie sich den Schlüssel",
"loadModelsPlaceholder": "Klicken Sie auf „Laden“, um Modelle abzurufen",
"selectModel": "Modell auswählen",
+1
View File
@@ -2439,6 +2439,7 @@
"testing": "Testing…",
"testConnection": "Test connection",
"ollamaUrl": "Ollama URL",
"ollamaApiKeyHint": "Leave empty for a standard Ollama server. If your proxy or gateway requires a key, it is sent as Authorization: Bearer. This field does not enable authentication on the server.",
"getKey": "Get key",
"loadModelsPlaceholder": "Click Load to fetch models",
"selectModel": "Select model",
+1
View File
@@ -2465,6 +2465,7 @@
"testing": "Pruebas…",
"testConnection": "Conexión de prueba",
"ollamaUrl": "URL de Ollama",
"ollamaApiKeyHint": "Déjala vacía para un servidor Ollama estándar. Si tu proxy o pasarela requiere una clave, se envía como Authorization: Bearer. Este campo no activa la autenticación en el servidor.",
"getKey": "Obtener clave",
"loadModelsPlaceholder": "Haz clic en Cargar para buscar modelos",
"selectModel": "Seleccionar modelo",
+1
View File
@@ -2312,6 +2312,7 @@
"testing": "Essai…",
"testConnection": "Tester la connexion",
"ollamaUrl": "URL d'Ollama",
"ollamaApiKeyHint": "Laissez ce champ vide pour un serveur Ollama standard. Si votre proxy ou passerelle exige une clé, elle est envoyée dans Authorization: Bearer. Ce champ n'active pas l'authentification sur le serveur.",
"getKey": "Obtenir la clé",
"loadModelsPlaceholder": "Cliquez sur Charger pour récupérer les modèles",
"selectModel": "Sélectionnez le modèle",
+1
View File
@@ -2312,6 +2312,7 @@
"testing": "Test in corso…",
"testConnection": "Prova connessione",
"ollamaUrl": "URL Ollama",
"ollamaApiKeyHint": "Lascia vuoto per un server Ollama standard. Se il proxy o gateway richiede una chiave, viene inviata come Authorization: Bearer. Questo campo non abilita l'autenticazione sul server.",
"getKey": "Ottieni chiave",
"loadModelsPlaceholder": "Fai clic su Carica per recuperare i modelli",
"selectModel": "Seleziona modello",
+1
View File
@@ -2312,6 +2312,7 @@
"testing": "Testando…",
"testConnection": "Conexão de teste",
"ollamaUrl": "URL de Ollama",
"ollamaApiKeyHint": "Deixa vazio para um servidor Ollama padrão. Se o proxy ou gateway exigir uma chave, esta é enviada como Authorization: Bearer. Este campo não ativa a autenticação no servidor.",
"getKey": "Obter chave",
"loadModelsPlaceholder": "Clique em Carregar para buscar modelos",
"selectModel": "Selecione o modelo",
+1
View File
@@ -2311,6 +2311,7 @@
"testing": "Testujem…",
"testConnection": "Otestovať pripojenie",
"ollamaUrl": "URL služby Ollama",
"ollamaApiKeyHint": "Pre bežný server Ollama nechajte prázdne. Ak proxy alebo brána vyžaduje kľúč, odosiela sa ako Authorization: Bearer. Toto pole nezapína autentifikáciu na serveri.",
"getKey": "Získať kľúč",
"loadModelsPlaceholder": "Kliknutím na Načítať získate modely",
"selectModel": "Vyberte model",
+1
View File
@@ -2312,6 +2312,7 @@
"testing": "Testning…",
"testConnection": "Testa anslutningen",
"ollamaUrl": "Ollama URL",
"ollamaApiKeyHint": "Lämna tomt för en vanlig Ollama-server. Om din proxy eller gateway kräver en nyckel skickas den som Authorization: Bearer. Fältet aktiverar inte autentisering på servern.",
"getKey": "Få nyckel",
"loadModelsPlaceholder": "Klicka på Ladda för att hämta modeller",
"selectModel": "Välj modell",
@@ -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()