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)
|
SettingsView(self, on_save_callback=on_complete, first_run=True)
|
||||||
|
|
||||||
def _init_db(self):
|
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:
|
try:
|
||||||
initialize_database()
|
initialize_database()
|
||||||
# Signal the DB log handler that the pool is ready so queued
|
# Signal the DB log handler that the pool is ready so queued
|
||||||
|
|||||||
+9
-6
@@ -27,16 +27,16 @@ Output
|
|||||||
|
|
||||||
After Building
|
After Building
|
||||||
--------------
|
--------------
|
||||||
1. Copy config.ini into the dist/WebsiteChecker/ folder before
|
1. No config.ini required. DB credentials are stored in the OS keychain
|
||||||
distributing to users, OR let users complete the first-run
|
(Windows Credential Manager on Windows, Keychain on macOS).
|
||||||
database setup dialog on first launch.
|
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.
|
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
|
3. The bundled app requires network access to the MySQL server
|
||||||
on the configured port (default 3306).
|
on the configured port (default 3306).
|
||||||
|
|
||||||
Prerequisites
|
Prerequisites
|
||||||
-------------
|
-------------
|
||||||
pip install pyinstaller
|
pip install pyinstaller keyring
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -102,6 +102,7 @@ def check_environment():
|
|||||||
"bcrypt": "bcrypt",
|
"bcrypt": "bcrypt",
|
||||||
"openpyxl": "openpyxl",
|
"openpyxl": "openpyxl",
|
||||||
"cryptography": "cryptography",
|
"cryptography": "cryptography",
|
||||||
|
"keyring": "keyring",
|
||||||
"matplotlib": "matplotlib",
|
"matplotlib": "matplotlib",
|
||||||
"plyer": "plyer",
|
"plyer": "plyer",
|
||||||
"reportlab": "reportlab",
|
"reportlab": "reportlab",
|
||||||
@@ -255,8 +256,10 @@ def report(args, returncode):
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print(" Post-build checklist:")
|
print(" Post-build checklist:")
|
||||||
print(" [ ] Copy config.ini into the output folder (or let users")
|
print(" [ ] NO config.ini required — DB credentials are stored in the")
|
||||||
print(" complete the first-run setup dialog on launch)")
|
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(" [ ] Test the .exe on a clean machine without Python installed")
|
||||||
print(" [ ] Verify the MySQL connection works from the target machine")
|
print(" [ ] Verify the MySQL connection works from the target machine")
|
||||||
print(" [ ] Confirm app.log is written next to the .exe at runtime")
|
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"):
|
if data.get("host") and data.get("database") and data.get("user"):
|
||||||
return data
|
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:
|
try:
|
||||||
import configparser as _cp
|
import configparser as _cp
|
||||||
_cfg_file = "config.ini"
|
_cfg_file = "config.ini"
|
||||||
@@ -235,13 +240,27 @@ def load_config() -> dict:
|
|||||||
cfg.read(_cfg_file, encoding="utf-8")
|
cfg.read(_cfg_file, encoding="utf-8")
|
||||||
if cfg.has_section("database"):
|
if cfg.has_section("database"):
|
||||||
section = cfg["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 = {
|
migrated = {
|
||||||
"host": section.get("host", ""),
|
"host": section.get("host", ""),
|
||||||
"port": section.getint("port", 3306),
|
"port": section.getint("port", 3306),
|
||||||
"database": section.get("database", ""),
|
"database": section.get("database", ""),
|
||||||
"user": decrypt_value(section.get("user", "")),
|
"user": _safe_decrypt(section.get("user", "")),
|
||||||
"password": decrypt_value(section.get("password", "")),
|
"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"):
|
if migrated.get("host") and migrated.get("database") and migrated.get("user"):
|
||||||
_keyring_set(migrated)
|
_keyring_set(migrated)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -249,6 +268,14 @@ def load_config() -> dict:
|
|||||||
"config.ini [database] section is no longer needed."
|
"config.ini [database] section is no longer needed."
|
||||||
)
|
)
|
||||||
return migrated
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"config.ini migration attempt failed: {e}")
|
logger.warning(f"config.ini migration attempt failed: {e}")
|
||||||
|
|
||||||
@@ -316,7 +343,14 @@ def reload_db_config():
|
|||||||
# ─── Database Configuration ───────────────────────────────────────────────────
|
# ─── Database Configuration ───────────────────────────────────────────────────
|
||||||
# Populated from the OS keychain at import time; falls back to placeholder
|
# 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.
|
# 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 = {
|
DB_CONFIG = {
|
||||||
"host": _ini.get("host", "your-mysql-host"),
|
"host": _ini.get("host", "your-mysql-host"),
|
||||||
"port": _ini.get("port", 3306),
|
"port": _ini.get("port", 3306),
|
||||||
@@ -819,144 +853,6 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("config")
|
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 ──────────────────────────────────────────────────────────
|
# ─── Connection Pool ──────────────────────────────────────────────────────────
|
||||||
_pool = None
|
_pool = None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user