04/24 Fixed keyring issues

This commit is contained in:
2026-04-24 17:33:20 -04:00
parent 5d6ca5a039
commit 71a53232a8
4 changed files with 56 additions and 156 deletions
+9 -1
View File
@@ -65,7 +65,15 @@ class App(tk.Tk):
SettingsView(self, on_save_callback=on_complete, first_run=True)
def _init_db(self):
"""Initialise database schema, then proceed to login."""
"""Initialise database schema, then proceed to login.
reload_db_config() is called first to ensure DB_CONFIG is populated
with the credentials just saved by the Settings dialog. Without this,
DB_CONFIG retains its placeholder values for the remainder of the
first-run session and every connection attempt fails.
"""
from config import reload_db_config
reload_db_config()
try:
initialize_database()
# Signal the DB log handler that the pool is ready so queued
+9 -6
View File
@@ -27,16 +27,16 @@ Output
After Building
--------------
1. Copy config.ini into the dist/WebsiteChecker/ folder before
distributing to users, OR let users complete the first-run
database setup dialog on first launch.
1. No config.ini required. DB credentials are stored in the OS keychain
(Windows Credential Manager on Windows, Keychain on macOS).
On first launch the Database Setup dialog will appear automatically.
2. app.log will be written to the same folder as the .exe at runtime.
3. The bundled app requires network access to the MySQL server
on the configured port (default 3306).
Prerequisites
-------------
pip install pyinstaller
pip install pyinstaller keyring
pip install -r requirements.txt
"""
@@ -102,6 +102,7 @@ def check_environment():
"bcrypt": "bcrypt",
"openpyxl": "openpyxl",
"cryptography": "cryptography",
"keyring": "keyring",
"matplotlib": "matplotlib",
"plyer": "plyer",
"reportlab": "reportlab",
@@ -255,8 +256,10 @@ def report(args, returncode):
print()
print(" Post-build checklist:")
print(" [ ] Copy config.ini into the output folder (or let users")
print(" complete the first-run setup dialog on launch)")
print(" [ ] NO config.ini required — DB credentials are stored in the")
print(" OS keychain (Windows Credential Manager / macOS Keychain).")
print(" On first launch the Database Setup dialog will appear.")
print(" [ ] Ensure keyring is installed: pip install keyring")
print(" [ ] Test the .exe on a clean machine without Python installed")
print(" [ ] Verify the MySQL connection works from the target machine")
print(" [ ] Confirm app.log is written next to the .exe at runtime")
-7
View File
@@ -1,7 +0,0 @@
[database]
host = 67.217.62.199
port = 3306
database = webchecker
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAZfPmuxpaSlrA7baq6FuVA3mxjxM4Kjk3n2c3ntJuEukAAAAADoAAAAACAAAgAAAApAOcDY8BjopNdccaEtymsKrh8pe9OCv5OOjrEURz8e8QAAAAynWoAwfUckM2KPojaAmLqkAAAADFRX+8SXg/SnhOad3blnMygOYi6RyTF+Ei3+ShbjHEgnLkgl1Sf3xruM7AcRmCeHcB3he0n8q5clagpKUYiEdI
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAL9rzNnENEcALpm87GOHhRkA6go80lhJXm8atP9sLtfkAAAAADoAAAAACAAAgAAAAF37pL18fWvsyYKJQaK9v9Xwq1bb2YPoQfcQKjTm9QTcQAAAADqJfCkgtQsUEZR12YnDALkAAAADl2cNUaxU40qObsA0mQ+XVuQ1qMOZytHhM5XpEHMTIwUmwvGuCRJrb6+D7EVdyLxS6PCoStrell6alDGg6UzRk
+38 -142
View File
@@ -225,7 +225,12 @@ def load_config() -> dict:
if data.get("host") and data.get("database") and data.get("user"):
return data
# One-time migration: pull from config.ini if it still exists
# One-time migration: pull from config.ini if it still exists.
# IMPORTANT: decrypt_value() uses Windows DPAPI which is machine+user-scoped.
# A config.ini created on a different machine cannot be decrypted here —
# we catch RuntimeError (and any other error) per-field and skip gracefully,
# letting the user re-enter credentials via the Settings dialog instead of
# crashing the application.
try:
import configparser as _cp
_cfg_file = "config.ini"
@@ -235,13 +240,27 @@ def load_config() -> dict:
cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_section("database"):
section = cfg["database"]
def _safe_decrypt(raw: str) -> str:
"""Decrypt a DPAPI value; return empty string on any failure."""
try:
return decrypt_value(raw)
except Exception as dec_err:
logger.warning(
f"config.ini migration: could not decrypt a value "
f"(DPAPI is machine-scoped — this is expected on a new "
f"machine). Skipping migration. Detail: {dec_err}"
)
return ""
migrated = {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
"user": _safe_decrypt(section.get("user", "")),
"password": _safe_decrypt(section.get("password", "")),
}
# Only migrate if we successfully decrypted a usable credential set
if migrated.get("host") and migrated.get("database") and migrated.get("user"):
_keyring_set(migrated)
logger.info(
@@ -249,6 +268,14 @@ def load_config() -> dict:
"config.ini [database] section is no longer needed."
)
return migrated
elif migrated.get("host"):
# File found but decryption failed (different machine) —
# log clearly and fall through to prompt the user
logger.warning(
"config.ini found but credentials could not be decrypted "
"(created on a different machine). "
"Please re-enter connection details in the Settings dialog."
)
except Exception as e:
logger.warning(f"config.ini migration attempt failed: {e}")
@@ -316,7 +343,14 @@ def reload_db_config():
# ─── Database Configuration ───────────────────────────────────────────────────
# Populated from the OS keychain at import time; falls back to placeholder
# strings so the module is importable even before first-run setup has completed.
_ini = load_config()
# The try/except ensures a corrupt or inaccessible keychain entry never crashes
# the import — the user will simply be shown the Settings dialog on first run.
try:
_ini = load_config()
except Exception as _load_err:
logger.warning(f"Could not load DB config at startup: {_load_err}")
_ini = {}
DB_CONFIG = {
"host": _ini.get("host", "your-mysql-host"),
"port": _ini.get("port", 3306),
@@ -819,144 +853,6 @@ logging.basicConfig(
)
logger = logging.getLogger("config")
# ─── Config File Load / Save ──────────────────────────────────────────────────
import configparser as _cp
import os as _os
CONFIG_FILE = "config.ini"
APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
# Sensitive fields that are DPAPI-encrypted in config.ini
_DB_SENSITIVE = {"user", "password"}
def load_config() -> dict:
"""
Load DB settings from config.ini.
Returns a dict with keys: host, port, database, user, password.
Sensitive fields (user, password) are decrypted transparently via DPAPI.
Returns empty dict if the file does not exist or is incomplete.
"""
from utils.config_crypto import decrypt_value
cfg = _cp.ConfigParser()
if not _os.path.exists(CONFIG_FILE):
return {}
cfg.read(CONFIG_FILE, encoding="utf-8")
if "database" not in cfg:
return {}
section = cfg["database"]
try:
return {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
}
except RuntimeError as exc:
logger.error(f"Failed to decrypt DB credentials: {exc}")
raise
def save_config(host: str, port: int, database: str, user: str, password: str):
"""Persist DB connection settings to config.ini (sensitive fields DPAPI-encrypted)."""
from utils.config_crypto import encrypt_value
# Preserve any existing non-database sections (email, crypto, groq)
cfg = _cp.ConfigParser()
if _os.path.exists(CONFIG_FILE):
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["database"] = {
"host": host,
"port": str(port),
"database": database,
"user": encrypt_value(user),
"password": encrypt_value(password),
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
def migrate_plaintext_config():
"""
One-time migration: if config.ini contains plain-text DB credentials
(no 'dpapi:' prefix) encrypt them in-place using Windows DPAPI.
Safe to call on every startup — is a no-op when already encrypted.
"""
from utils.config_crypto import encrypt_value, is_encrypted
if not _os.path.exists(CONFIG_FILE):
return
cfg = _cp.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
# DB section
for key in ("user", "password"):
if cfg.has_option("database", key):
raw = cfg.get("database", key)
if raw and not is_encrypted(raw):
cfg.set("database", key, encrypt_value(raw))
changed = True
# Email section
if cfg.has_option("email", "smtp_password"):
raw = cfg.get("email", "smtp_password")
if raw and not is_encrypted(raw):
cfg.set("email", "smtp_password", encrypt_value(raw))
changed = True
# Groq section
if cfg.has_option("groq", "api_key"):
raw = cfg.get("groq", "api_key")
if raw and not is_encrypted(raw):
cfg.set("groq", "api_key", encrypt_value(raw))
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("config.ini: plain-text credentials encrypted with Windows DPAPI.")
def config_exists() -> bool:
"""Return True if config.ini contains all required connection fields."""
ini = load_config()
return bool(ini.get("host") and ini.get("database") and ini.get("user"))
def reload_db_config():
"""
Re-read config.ini and update DB_CONFIG in place.
Also resets the connection pool so the next get_connection() uses new creds.
"""
global _pool, DB_CONFIG
ini = load_config()
DB_CONFIG.update({
"host": ini.get("host", DB_CONFIG["host"]),
"port": ini.get("port", DB_CONFIG["port"]),
"database": ini.get("database", DB_CONFIG["database"]),
"user": ini.get("user", DB_CONFIG["user"]),
"password": ini.get("password", DB_CONFIG["password"]),
})
_pool = None # force pool recreation on next connection
logger.info("DB_CONFIG reloaded from config.ini.")
# ─── Database Configuration ───────────────────────────────────────────────────
# Populated from config.ini at runtime; falls back to placeholder strings so
# the module is importable even before first-run setup has completed.
_ini = load_config()
DB_CONFIG = {
"host": _ini.get("host", "your-mysql-host"),
"port": _ini.get("port", 3306),
"database": _ini.get("database", "website_checker"),
"user": _ini.get("user", "your-db-user"),
"password": _ini.get("password", "your-db-password"),
"connection_timeout": 10,
}
# ─── Connection Pool ──────────────────────────────────────────────────────────
_pool = None