04/24 Fixed keyring issues
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
# 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user