diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 21d8cacb..ca7ce6ec 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -512,6 +512,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop } return t("vmLxc.appEditor.upstreamErrorNetwork", { detail }) } + if (lower.includes("github rate limited")) { + return t("vmLxc.appEditor.upstreamErrorGithubRateLimit") + } return msg } diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index a80de8da..a110b439 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" -import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react" +import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { NotificationSettings } from "./notification-settings" @@ -392,6 +392,17 @@ export function Settings() { const [loadingInterfaces, setLoadingInterfaces] = useState(true) const [savingInterface, setSavingInterface] = useState(null) + // Optional GitHub API authentication for app release/tag checks. The + // backend only returns whether a token exists; the secret itself never + // leaves the host after it has been saved. + const [githubTokenConfigured, setGithubTokenConfigured] = useState(false) + const [githubTokenLoading, setGithubTokenLoading] = useState(true) + const [githubTokenEditMode, setGithubTokenEditMode] = useState(false) + const [githubTokenDraft, setGithubTokenDraft] = useState("") + const [githubTokenSaving, setGithubTokenSaving] = useState(false) + const [githubTokenSaved, setGithubTokenSaved] = useState(false) + const [githubTokenError, setGithubTokenError] = useState("") + // Active Suppressions panel — lists every error currently dismissed // (time-limited or permanent) so the user can re-enable individual // alerts. Mirrors what /api/health/full returns under `dismissed`. @@ -451,6 +462,63 @@ export function Settings() { } } + const loadGithubTokenStatus = async () => { + setGithubTokenLoading(true) + try { + const data = await fetchApi<{ configured: boolean }>("/api/apps/github-token") + setGithubTokenConfigured(!!data.configured) + setGithubTokenError("") + } catch (err) { + console.error("Failed to load GitHub API token status:", err) + setGithubTokenError(t("settings.githubApi.loadFailed")) + } finally { + setGithubTokenLoading(false) + } + } + + const saveGithubToken = async () => { + const token = githubTokenDraft.trim() + if (!token) return + setGithubTokenSaving(true) + setGithubTokenError("") + try { + await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", { + method: "PUT", + body: JSON.stringify({ token }), + }) + setGithubTokenConfigured(true) + setGithubTokenDraft("") + setGithubTokenEditMode(false) + setGithubTokenSaved(true) + window.setTimeout(() => setGithubTokenSaved(false), 2500) + } catch (err) { + console.error("Failed to save GitHub API token:", err) + setGithubTokenError(t("settings.githubApi.saveFailed")) + } finally { + setGithubTokenSaving(false) + } + } + + const removeGithubToken = async () => { + setGithubTokenSaving(true) + setGithubTokenError("") + try { + await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", { + method: "DELETE", + }) + setGithubTokenConfigured(false) + setGithubTokenDraft("") + setGithubTokenEditMode(false) + setGithubTokenSaved(true) + window.setTimeout(() => setGithubTokenSaved(false), 2500) + } catch (err) { + console.error("Failed to remove GitHub API token:", err) + setGithubTokenError(t("settings.githubApi.removeFailed")) + } finally { + setGithubTokenSaving(false) + } + } + useEffect(() => { loadProxmenuxTools() getUnitsSettings() @@ -459,6 +527,7 @@ export function Settings() { loadActiveSuppressions() loadNetworkInterfaces() loadSnippetsStorage() + loadGithubTokenStatus() }, []) // Refresh the Active Suppressions list whenever: @@ -1803,6 +1872,113 @@ export function Settings() { is re-enabled). */} + {/* GitHub API — optional authentication for app upstream checks. */} + + +
+
+ + {t("settings.githubApi.title")} +
+ {!githubTokenLoading && ( +
+ {githubTokenSaved && ( + + + {t("status.saved")} + + )} + {githubTokenEditMode ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ {t("settings.githubApi.description")} +
+ + {githubTokenLoading ? ( +
+ +
+ ) : githubTokenEditMode ? ( +
+
+ + setGithubTokenDraft(event.target.value)} + placeholder={githubTokenConfigured ? "••••••••••••" : t("settings.githubApi.tokenPlaceholder")} + disabled={githubTokenSaving} + /> +

{t("settings.githubApi.tokenHelp")}

+
+ {githubTokenConfigured && ( + + )} +
+ ) : ( +
+ + + {githubTokenConfigured ? t("settings.githubApi.configured") : t("settings.githubApi.notConfigured")} + +
+ )} + {githubTokenError && ( +
+ + {githubTokenError} +
+ )} +
+
+ {/* Notification Settings */} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index ed595107..63424f11 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1527,6 +1527,7 @@ "editButton": "Bearbeiten", "upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams", "upstreamErrorNetwork": "Netzwerkfehler: {detail}", + "upstreamErrorGithubRateLimit": "Das GitHub-Anfragelimit wurde erreicht. Konfigurieren Sie unter Einstellungen → GitHub API ein optionales Token oder versuchen Sie es später erneut.", "upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}", "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken", "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken", @@ -1648,6 +1649,20 @@ "saveFailed": "Die Einstellung für die LXC-Update-Erkennung konnte nicht gespeichert werden.", "purgedMessage": "{count} LXC-Einträge aus der Registrierung entfernt. Durch erneutes Aktivieren der Erkennung werden sie beim nächsten Scan-Zyklus neu aufgefüllt." }, + "githubApi": { + "title": "GitHub API", + "description": "Optionale Authentifizierung für die Release- und Tag-Prüfungen registrierter Anwendungen.", + "configure": "Konfigurieren", + "tokenLabel": "Persönliches Zugriffstoken", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "Das Token wird verschlüsselt gespeichert. ProxMenux verwendet es ausschließlich für schreibgeschützte GitHub-API-Anfragen und zeigt es danach nicht erneut an.", + "configured": "Token konfiguriert", + "notConfigured": "Anonymes GitHub-Anfragekontingent wird verwendet", + "removeToken": "Token entfernen", + "loadFailed": "Die GitHub-API-Einstellung konnte nicht geladen werden.", + "saveFailed": "Das GitHub-API-Token konnte nicht gespeichert werden.", + "removeFailed": "Das GitHub-API-Token konnte nicht entfernt werden." + }, "notifications": { "title": "Benachrichtigungen", "description": "Konfigurieren Sie Benachrichtigungskanäle und Ereignisfilter. Erhalten Sie Benachrichtigungen per Telegram, Gotify, Discord oder E-Mail.", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index b12ac54e..6ee6ccc3 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1432,6 +1432,7 @@ "updateAvailableBadge": "Update available", "upstreamErrorTimeout": "Network timeout while contacting upstream", "upstreamErrorNetwork": "Network error: {detail}", + "upstreamErrorGithubRateLimit": "GitHub's request limit has been reached. Configure an optional token in Settings → GitHub API, or try again later.", "upstreamErrorGeneric": "Upstream check failed: {detail}", "portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)", "portPortPlaceholder": "port", @@ -1647,6 +1648,20 @@ "saveFailed": "Could not save the LXC update detection setting.", "purgedMessage": "{count} LXC entries removed from the registry. Re-enabling detection will repopulate them on the next scan cycle." }, + "githubApi": { + "title": "GitHub API", + "description": "Optional authentication for the release and tag checks used by registered applications.", + "configure": "Configure", + "tokenLabel": "Personal access token", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "The token is stored encrypted. ProxMenux uses it only for read-only GitHub API requests and never displays it again.", + "configured": "Token configured", + "notConfigured": "Using GitHub's anonymous request quota", + "removeToken": "Remove token", + "loadFailed": "Could not load the GitHub API setting.", + "saveFailed": "Could not save the GitHub API token.", + "removeFailed": "Could not remove the GitHub API token." + }, "notifications": { "title": "Notifications", "description": "Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 9a4b27d8..2390a7a5 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1432,6 +1432,7 @@ "upToDateBadge": "Actualizado", "upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen", "upstreamErrorNetwork": "Error de red: {detail}", + "upstreamErrorGithubRateLimit": "Se ha alcanzado el límite de solicitudes de GitHub. Configure un token opcional en Ajustes → API de GitHub o vuelva a intentarlo más tarde.", "upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}", "updateAvailableBadge": "Actualización disponible", "portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)", @@ -1648,6 +1649,20 @@ "saveFailed": "No se pudo guardar la configuración de detección de actualizaciones de LXC.", "purgedMessage": "{count} Entradas LXC eliminadas del registro. Al volver a habilitar la detección, se volverán a llenar en el siguiente ciclo de escaneo." }, + "githubApi": { + "title": "API de GitHub", + "description": "Autenticación opcional para comprobar los lanzamientos y las etiquetas de las aplicaciones registradas.", + "configure": "Configurar", + "tokenLabel": "Token de acceso personal", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "El token se guarda cifrado. ProxMenux solo lo utiliza para consultas de solo lectura a la API de GitHub y no vuelve a mostrarlo.", + "configured": "Token configurado", + "notConfigured": "Usando la cuota de solicitudes anónimas de GitHub", + "removeToken": "Eliminar token", + "loadFailed": "No se pudo cargar el ajuste de la API de GitHub.", + "saveFailed": "No se pudo guardar el token de la API de GitHub.", + "removeFailed": "No se pudo eliminar el token de la API de GitHub." + }, "notifications": { "title": "Notificaciones", "description": "Configure canales de notificación y filtros de eventos. Recibe alertas vía Telegram, Gotify, Discord o Email.", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 003a67ae..7d38db36 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1527,6 +1527,7 @@ "editButton": "Modifier", "upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont", "upstreamErrorNetwork": "Erreur réseau : {detail}", + "upstreamErrorGithubRateLimit": "La limite de requêtes GitHub a été atteinte. Configurez un jeton facultatif dans Paramètres → API GitHub ou réessayez plus tard.", "upstreamErrorGeneric": "Échec de la vérification en amont : {detail}", "notificationsEnabled": "Notifications de mise à jour en amont activées – cliquez pour désactiver le son", "notificationsMuted": "Notifications de mise à jour en amont MUTED – cliquez pour activer", @@ -1648,6 +1649,20 @@ "saveFailed": "Impossible d'enregistrer le paramètre de détection de mise à jour LXC.", "purgedMessage": "{count} Entrées LXC supprimées du registre. La réactivation de la détection les repeuplera lors du prochain cycle d'analyse." }, + "githubApi": { + "title": "API GitHub", + "description": "Authentification facultative pour vérifier les versions et les étiquettes des applications enregistrées.", + "configure": "Configurer", + "tokenLabel": "Jeton d'accès personnel", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "Le jeton est stocké sous forme chiffrée. ProxMenux l'utilise uniquement pour des requêtes en lecture seule vers l'API GitHub et ne l'affiche plus ensuite.", + "configured": "Jeton configuré", + "notConfigured": "Utilisation du quota de requêtes anonymes de GitHub", + "removeToken": "Supprimer le jeton", + "loadFailed": "Impossible de charger le paramètre de l'API GitHub.", + "saveFailed": "Impossible d'enregistrer le jeton de l'API GitHub.", + "removeFailed": "Impossible de supprimer le jeton de l'API GitHub." + }, "notifications": { "title": "Notifications", "description": "Configurez les canaux de notification et les filtres d'événements. Recevez des alertes via Telegram, Gotify, Discord ou Email.", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 31350dd1..0cc7d567 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1527,6 +1527,7 @@ "editButton": "Modificare", "upstreamErrorTimeout": "timeout della rete durante il contatto a monte", "upstreamErrorNetwork": "errore di rete: {detail}", + "upstreamErrorGithubRateLimit": "È stato raggiunto il limite di richieste GitHub. Configura un token facoltativo in Impostazioni → API GitHub oppure riprova più tardi.", "upstreamErrorGeneric": "controllo upstream non riuscito: {detail}", "notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio", "notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare", @@ -1648,6 +1649,20 @@ "saveFailed": "Impossibile salvare l'impostazione di rilevamento degli aggiornamenti LXC.", "purgedMessage": "{count} Voci LXC rimosse dal registro. La riattivazione del rilevamento li ripopolarà al ciclo di scansione successivo." }, + "githubApi": { + "title": "API GitHub", + "description": "Autenticazione facoltativa per controllare release e tag delle applicazioni registrate.", + "configure": "Configura", + "tokenLabel": "Token di accesso personale", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "Il token viene archiviato in forma cifrata. ProxMenux lo utilizza solo per richieste di sola lettura all'API GitHub e non lo mostra più dopo il salvataggio.", + "configured": "Token configurato", + "notConfigured": "Utilizzo della quota di richieste anonime di GitHub", + "removeToken": "Rimuovi token", + "loadFailed": "Impossibile caricare l'impostazione dell'API GitHub.", + "saveFailed": "Impossibile salvare il token dell'API GitHub.", + "removeFailed": "Impossibile rimuovere il token dell'API GitHub." + }, "notifications": { "title": "Notifiche", "description": "Configura canali di notifica e filtri eventi. Ricevi avvisi tramite Telegram, Gotify, Discord o e-mail.", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 075c2925..a77da096 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1527,6 +1527,7 @@ "editButton": "Editar", "upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream", "upstreamErrorNetwork": "Erro de rede: {detail}", + "upstreamErrorGithubRateLimit": "O limite de pedidos do GitHub foi atingido. Configure um token opcional em Definições → API do GitHub ou tente novamente mais tarde.", "upstreamErrorGeneric": "falha na verificação upstream: {detail}", "notificationsEnabled": "Notificações de atualização upstream ATIVADAS – clique para silenciar", "notificationsMuted": "notificações de atualização upstream silenciadas – clique para ativar", @@ -1648,6 +1649,20 @@ "saveFailed": "Não foi possível salvar a configuração de detecção de atualização LXC.", "purgedMessage": "{count} Entradas LXC removidas do registro. Reativar a detecção irá preenchê-los novamente no próximo ciclo de verificação." }, + "githubApi": { + "title": "API do GitHub", + "description": "Autenticação opcional para verificar lançamentos e etiquetas das aplicações registadas.", + "configure": "Configurar", + "tokenLabel": "Token de acesso pessoal", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "O token é armazenado de forma cifrada. O ProxMenux utiliza-o apenas para pedidos de leitura à API do GitHub e não volta a apresentá-lo.", + "configured": "Token configurado", + "notConfigured": "A utilizar a quota de pedidos anónimos do GitHub", + "removeToken": "Remover token", + "loadFailed": "Não foi possível carregar a definição da API do GitHub.", + "saveFailed": "Não foi possível guardar o token da API do GitHub.", + "removeFailed": "Não foi possível remover o token da API do GitHub." + }, "notifications": { "title": "Notificações", "description": "Configure canais de notificação e filtros de eventos. Receba alertas via Telegram, Gotify, Discord ou Email.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 02896f07..cb427e7a 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1526,6 +1526,7 @@ "editButton": "Upraviť", "upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream", "upstreamErrorNetwork": "Chyba siete: {detail}", + "upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.", "upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}", "notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte", "notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete", @@ -1647,6 +1648,20 @@ "saveFailed": "Nastavenie kontroly aktualizácií LXC sa nepodarilo uložiť.", "purgedMessage": "Z registra bolo odstránených {count} LXC záznamov. Po opätovnom zapnutí kontroly sa doplnia pri ďalšom skenovaní." }, + "githubApi": { + "title": "GitHub API", + "description": "Voliteľné overenie pre kontrolu vydaní a značiek registrovaných aplikácií.", + "configure": "Nastaviť", + "tokenLabel": "Osobný prístupový token", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "Token sa ukladá šifrovane. ProxMenux ho používa iba na požiadavky GitHub API určené na čítanie a po uložení ho už nezobrazí.", + "configured": "Token je nastavený", + "notConfigured": "Používa sa anonymná kvóta požiadaviek GitHubu", + "removeToken": "Odstrániť token", + "loadFailed": "Nastavenie GitHub API sa nepodarilo načítať.", + "saveFailed": "Token GitHub API sa nepodarilo uložiť.", + "removeFailed": "Token GitHub API sa nepodarilo odstrániť." + }, "notifications": { "title": "Notifikácie", "description": "Nastavte kanály a filtre udalostí. Upozornenia môžete dostávať cez Telegram, Gotify, Discord alebo e-mail.", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 0d3c6e25..1cb1a591 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1527,6 +1527,7 @@ "editButton": "Redigera", "upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms", "upstreamErrorNetwork": "Nätverksfel: {detail}", + "upstreamErrorGithubRateLimit": "GitHubs förfrågningsgräns har nåtts. Konfigurera en valfri token under Inställningar → GitHub API eller försök igen senare.", "upstreamErrorGeneric": "Uppströmskontroll misslyckades: {detail}", "notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ – klicka för att stänga av ljudet", "notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD – klicka för att aktivera", @@ -1648,6 +1649,20 @@ "saveFailed": "Det gick inte att spara inställningen för upptäckt av LXC-uppdatering.", "purgedMessage": "{count} LXC-poster har tagits bort från registret. Om detektering återaktiveras kommer de att fyllas på igen vid nästa skanningscykel." }, + "githubApi": { + "title": "GitHub API", + "description": "Valfri autentisering för kontroller av utgåvor och taggar för registrerade appar.", + "configure": "Konfigurera", + "tokenLabel": "Personlig åtkomsttoken", + "tokenPlaceholder": "github_pat_...", + "tokenHelp": "Token lagras krypterad. ProxMenux använder den endast för skrivskyddade anrop till GitHub API och visar den inte igen efter att den har sparats.", + "configured": "Token konfigurerad", + "notConfigured": "GitHubs anonyma förfrågningskvot används", + "removeToken": "Ta bort token", + "loadFailed": "Det gick inte att läsa in GitHub API-inställningen.", + "saveFailed": "Det gick inte att spara GitHub API-token.", + "removeFailed": "Det gick inte att ta bort GitHub API-token." + }, "notifications": { "title": "Aviseringar", "description": "Konfigurera aviseringskanaler och händelsefilter. Ta emot varningar via Telegram, Gotify, Discord eller e-post.", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 944e505f..ad0e2118 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -13747,6 +13747,54 @@ def api_apps_catalog(): return jsonify({'error': str(e)}), 500 +@app.route('/api/apps/github-token', methods=['GET']) +@require_auth +def api_apps_github_token_status(): + """Return whether the optional GitHub API token is configured. + + The token itself is deliberately never returned to the client. + """ + try: + if not notification_manager._config: + notification_manager._load_config() + value = notification_manager._config.get('github_pat', '') + return jsonify({'configured': bool(isinstance(value, str) and value.strip())}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/apps/github-token', methods=['PUT']) +@require_admin_scope +def api_apps_github_token_save(): + """Store the GitHub API token in the existing encrypted settings store.""" + payload = request.get_json(silent=True) or {} + token = payload.get('token') + if not isinstance(token, str): + return jsonify({'error': 'token must be a string'}), 400 + token = token.strip() + if not token: + return jsonify({'error': 'token is required'}), 400 + if len(token) > 512: + return jsonify({'error': 'token exceeds the 512 character limit'}), 400 + if any(ch.isspace() or ord(ch) < 33 or ord(ch) == 127 for ch in token): + return jsonify({'error': 'token contains whitespace or control characters'}), 400 + + result = notification_manager.save_settings({'github_pat': token}) + if not result.get('success'): + return jsonify({'error': result.get('error', 'failed to save token')}), 500 + return jsonify({'success': True, 'configured': True}) + + +@app.route('/api/apps/github-token', methods=['DELETE']) +@require_admin_scope +def api_apps_github_token_remove(): + """Clear the optional GitHub API token without exposing its old value.""" + result = notification_manager.save_settings({'github_pat': ''}) + if not result.get('success'): + return jsonify({'error': result.get('error', 'failed to remove token')}), 500 + return jsonify({'success': True, 'configured': False}) + + @app.route('/api/lxc-apps/dockerhub-tag-preview', methods=['POST']) @require_auth def api_lxc_apps_dockerhub_tag_preview(): diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index baa88e9f..f4531fb4 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -1285,16 +1285,13 @@ def _select_working_hint_detector(vmid, hint: dict) -> tuple[dict, Optional[str] def _github_pat() -> Optional[str]: try: from notification_manager import notification_manager - pat = notification_manager._config.get("github_pat") if notification_manager._config else None - if not pat: - return None - try: - from notification_manager import decrypt_sensitive_value - if isinstance(pat, str) and pat.startswith("encrypted:"): - return decrypt_sensitive_value(pat) - except Exception: - pass - return pat if isinstance(pat, str) else None + # The notification manager owns the shared encrypted settings store. + # During very early calls its runtime cache may not have been loaded + # yet, so initialise it before reading the optional GitHub token. + if not notification_manager._config: + notification_manager._load_config() + pat = notification_manager._config.get("github_pat") + return pat.strip() if isinstance(pat, str) and pat.strip() else None except Exception: return None @@ -1365,7 +1362,7 @@ def _fetch_github_latest_details(config: dict) -> tuple[Optional[str], Optional[ if e.code == 403: remaining = e.headers.get("X-RateLimit-Remaining", "1") if remaining == "0": - return None, "github rate limited — configure a PAT in Settings", None + return None, "github rate limited — configure a PAT in Settings → GitHub API", None return None, "github rejected the request (403)", None return None, f"github error {e.code}", None except (urllib.error.URLError, TimeoutError, OSError) as e: diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 859634ea..fe3c8755 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -4147,6 +4147,17 @@ class ProxmoxHookWatcher: 'job_id': pve_job_id, } + if pve_type == 'replication': + replication = self._extract_replication_context( + fields, title, message + ) + data.update(replication) + entity_id = ( + replication.get('job_id') + or replication.get('vmid') + or entity_id + ) + # `system_problem` is the generic fallback of `_classify_pve` for # unknown/empty pve_type. Without a populated `reason`, the template # renders "Reason: " (empty) and `_summarize_event` falls back to @@ -4274,6 +4285,73 @@ class ProxmoxHookWatcher: self._queue.put(event) return {'accepted': True, 'event_type': event_type, 'event_id': event.event_id} + + def _extract_replication_context(self, fields: dict, title: str, + message: str) -> dict: + """Map a native PVE replication notice to template fields.""" + raw_job_id = fields.get('job-id') or fields.get('job_id') or '' + job_id = str(raw_job_id).strip() + if not re.fullmatch(r'\d+(?:-\d+)?', job_id): + combined = f'{title or ""}\n{message or ""}' + match = re.search( + r'\breplication(?:\s+job)?(?:\s*:\s*|\s+)' + r'[\'\"]?(\d+(?:-\d+)?)\b', + combined, + re.IGNORECASE, + ) + if not match: + match = re.search(r'\b(\d+-\d+)\b', combined) + job_id = match.group(1) if match else '' + + vmid_match = re.fullmatch(r'(\d+)(?:-\d+)?', job_id) + vmid = vmid_match.group(1) if vmid_match else '' + vmname = self._resolve_replication_guest_name(vmid) + if not vmname and vmid: + vmname = 'VM/CT' + + target = str( + fields.get('job-target') or fields.get('target') or '' + ).strip() + if not target: + target_match = re.search( + r'\bwith\s+target\s+[\'\"]([^\'\"\n]+)[\'\"]', + message or '', + re.IGNORECASE, + ) + if target_match: + target = target_match.group(1).strip() + + reason_match = re.search( + r'^\s*Error:\s*(.*?)\s*\Z', + message or '', + re.IGNORECASE | re.MULTILINE | re.DOTALL, + ) + reason = reason_match.group(1).strip() if reason_match else '' + if not reason: + reason = (message or title or '').strip() + + return { + 'job_id': job_id, + 'vmid': vmid, + 'vmname': vmname, + 'target_node': target, + 'reason': reason, + } + + @staticmethod + def _resolve_replication_guest_name(vmid: str) -> str: + """Resolve the replicated guest name from the cluster config.""" + if not vmid or not vmid.isdigit(): + return '' + for base in ('/etc/pve/qemu-server', '/etc/pve/lxc'): + try: + with open(f'{base}/{vmid}.conf', encoding='utf-8') as config: + for line in config: + if line.startswith(('name:', 'hostname:')): + return line.split(':', 1)[1].strip() + except OSError: + continue + return '' def _classify_pve(self, pve_type: str, severity: str, title: str, message: str) -> tuple: diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index e60e70a1..f9e67bbd 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -64,6 +64,10 @@ ENCRYPTION_KEY_FILE = Path('/usr/local/share/proxmenux/.notification_key') # Keys that contain sensitive data and should be encrypted SENSITIVE_KEYS = { + # Optional GitHub API token used by the LXC app version tracker. + # It lives in the shared settings store so it benefits from the same + # ENC2 encryption and never needs a second secrets file. + 'github_pat', 'ai_api_key', # Legacy - kept for migration 'ai_api_key_groq', 'ai_api_key_gemini', diff --git a/AppImage/scripts/tests/test_replication_webhook.py b/AppImage/scripts/tests/test_replication_webhook.py new file mode 100644 index 00000000..24879e0a --- /dev/null +++ b/AppImage/scripts/tests/test_replication_webhook.py @@ -0,0 +1,106 @@ +import sys +import unittest +from pathlib import Path +from queue import Queue +from unittest import mock + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import notification_events # noqa: E402 +import notification_templates # noqa: E402 + + +class ReplicationWebhookTests(unittest.TestCase): + def _process(self, payload, guest_name='fileserver'): + watcher = notification_events.ProxmoxHookWatcher(Queue()) + with mock.patch.object( + watcher, + '_resolve_replication_guest_name', + return_value=guest_name, + ), mock.patch.object( + notification_events, + 'capture_journal_context', + return_value='', + ): + result = watcher.process_webhook(payload) + return result, watcher._queue.get_nowait() + + def test_structured_job_id_populates_template_fields(self): + reason = 'command zfs error: cannot open pool\nremote side unavailable' + result, event = self._process({ + 'title': "Replication Job: '100-0' failed", + 'message': ( + "Replication job '100-0' with target 'pve02' and schedule " + "'*/15' failed!\n\n" + "Last successful sync: 2026-09-02 15:00:00\n" + "Next sync try: 2026-09-02 15:30:00\n" + "Failure count: 1\n\n" + f"Error:\n{reason}" + ), + 'severity': 'error', + 'fields': { + 'type': 'replication', + 'hostname': 'pve01', + 'job-id': '100-0', + }, + }) + + self.assertTrue(result['accepted']) + self.assertEqual(event.event_type, 'replication_fail') + self.assertEqual(event.entity_id, '100-0') + self.assertEqual(event.data['job_id'], '100-0') + self.assertEqual(event.data['vmid'], '100') + self.assertEqual(event.data['vmname'], 'fileserver') + self.assertEqual(event.data['target_node'], 'pve02') + self.assertEqual(event.data['reason'], reason) + + rendered = notification_templates.render_template( + event.event_type, + event.data, + ) + self.assertIn('fileserver (100)', rendered['title']) + self.assertIn('ID: 100', rendered['body_text']) + self.assertIn(reason, rendered['body_text']) + + def test_title_and_message_are_used_when_job_id_field_is_missing(self): + _, event = self._process({ + 'title': "Replication Job: '212-3' failed", + 'message': ( + "Replication job '212-3' with target 'pve03' failed!\n\n" + "Error: storage 'replica-zfs' is not available" + ), + 'severity': 'error', + 'fields': {'type': 'replication', 'hostname': 'pve01'}, + }, guest_name='') + + self.assertEqual(event.entity_id, '212-3') + self.assertEqual(event.data['vmid'], '212') + self.assertEqual(event.data['vmname'], 'VM/CT') + self.assertEqual(event.data['target_node'], 'pve03') + self.assertEqual( + event.data['reason'], + "storage 'replica-zfs' is not available", + ) + + def test_missing_error_block_never_renders_an_empty_reason(self): + message = "Replication job '300-0' failed unexpectedly" + _, event = self._process({ + 'title': "Replication Job: '300-0' failed", + 'message': message, + 'severity': 'error', + 'fields': {'type': 'replication', 'hostname': 'pve01'}, + }) + + self.assertEqual(event.data['reason'], message) + rendered = notification_templates.render_template( + event.event_type, + event.data, + ) + self.assertIn(f'Reason: {message}', rendered['body_text']) + + +if __name__ == '__main__': + unittest.main()