From 439feb994237c1f9e52c52b4fe59ed4ec92b7c05 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 17 Mar 2026 21:08:37 -0400 Subject: [PATCH] Feat: Update settings tab implemented. Auto-updates now configurable (URL, source (gitea or private server), log activity for auto updates. fix: Update now hardened to prevent installation of corrupt or incorrect .exe files. (security to be hardened in the future) --- CHANGELOG.md | 13 ++++ README.md | 11 +++- build.bat | 14 +++- config-template.ini | 5 ++ series3_tray.py | 156 +++++++++++++++++++++++++++++++++++++++----- series3_watcher.py | 8 ++- settings_dialog.py | 75 ++++++++++++++++++++- 7 files changed, 259 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cf50f..baf7b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.4.3] - 2026-03-17 + +### Added +- Auto-updater now logs all activity to the watcher log file (`[updater]` prefix) — silent failures are now visible. +- Configurable update source: `UPDATE_SOURCE = gitea` (default), `url`, or `disabled`. In `url` mode the watcher fetches `version.txt` and the `.exe` from a custom base URL (e.g. terra-view) instead of the Gitea API — enables updates on isolated networks that cannot reach Gitea. `disabled` turns off automatic checks while keeping the remote push path (from terra-view) functional. +- New **Updates** tab in the Settings dialog to configure `UPDATE_SOURCE` and `UPDATE_URL`. + +### Fixed +- Downloaded `.exe` is now validated before applying: absolute size floor (100 KB), relative size floor (50% of current exe), and MZ magic bytes check. A corrupt or truncated download is now rejected and logged rather than silently overwriting the live exe. +- Swap `.bat` now backs up the current exe as `.old` before overwriting, providing a manual rollback copy if needed. +- Swap `.bat` retry loop is now capped at 5 attempts — was previously infinite if the file remained locked. +- Swap `.bat` now cleans up the temp download file on both success and failure. + ## [1.4.2] - 2026-03-17 ### Changed diff --git a/README.md b/README.md index 5cf606e..0456d2d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Series 3 Watcher v1.4.2 +# Series 3 Watcher v1.4.3 Monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. Runs as a **system tray app** that starts automatically on login, reports heartbeats to terra-view, and self-updates from Gitea. @@ -83,6 +83,13 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here | `ENABLE_LOGGING` | `true` / `false` | | `LOG_RETENTION_DAYS` | Auto-clear log after this many days (default `30`) | +### Auto-Updater + +| Key | Description | +|-----|-------------| +| `UPDATE_SOURCE` | `gitea` (default) or `url` — where to check for updates | +| `UPDATE_URL` | Base URL of the update server when `UPDATE_SOURCE = url` (e.g. terra-view URL). The watcher fetches `/api/updates/series3-watcher/version.txt` and `/api/updates/series3-watcher/series3-watcher.exe` from this base. | + --- ## Tray Icon @@ -116,7 +123,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**. ## Versioning -Follows **Semantic Versioning**. Current release: **v1.4.2**. +Follows **Semantic Versioning**. Current release: **v1.4.3**. See `CHANGELOG.md` for full history. --- diff --git a/build.bat b/build.bat index 17a0472..f2474ac 100644 --- a/build.bat +++ b/build.bat @@ -2,19 +2,27 @@ echo Building series3-watcher.exe... pip install pyinstaller pystray Pillow +REM Extract version from series3_watcher.py (looks for: VERSION = "1.4.2") +for /f "tokens=3 delims= " %%V in ('findstr /C:"VERSION = " series3_watcher.py') do set RAW_VER=%%V +set VERSION=%RAW_VER:"=% +set EXE_NAME=series3-watcher-%VERSION% + +echo Version: %VERSION% +echo Output: dist\%EXE_NAME%.exe + REM Check whether icon.ico exists alongside this script. REM If it does, embed it as the .exe icon AND bundle it as a data file REM so the tray overlay can load it at runtime. if exist "%~dp0icon.ico" ( - pyinstaller --onefile --windowed --name series3-watcher ^ + pyinstaller --onefile --windowed --name "%EXE_NAME%" ^ --icon="%~dp0icon.ico" ^ --add-data "%~dp0icon.ico;." ^ series3_tray.py ) else ( echo [INFO] icon.ico not found -- building without custom icon. - pyinstaller --onefile --windowed --name series3-watcher series3_tray.py + pyinstaller --onefile --windowed --name "%EXE_NAME%" series3_tray.py ) echo. -echo Done. Check dist\series3-watcher.exe +echo Done. Check dist\%EXE_NAME%.exe pause diff --git a/config-template.ini b/config-template.ini index 86c5677..a2e52a6 100644 --- a/config-template.ini +++ b/config-template.ini @@ -29,3 +29,8 @@ MLG_HEADER_BYTES = 2048 ; used for unit-id extraction DEEP_SNIFF = True ; toggle deep sniff on/off SNIFF_BYTES = 65536 ; max bytes to scan for Notes/Cal +# Auto-updater source: gitea (default) or url +UPDATE_SOURCE = gitea +# If UPDATE_SOURCE = url, set UPDATE_URL to the base URL of the update server (e.g. terra-view) +UPDATE_URL = + diff --git a/series3_tray.py b/series3_tray.py index 9f49daf..75044cc 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — System Tray Launcher v1.4.2 +Series 3 Watcher — System Tray Launcher v1.4.3 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw series3_tray.py (no console window) @@ -16,6 +16,7 @@ import sys import subprocess import tempfile import threading +import configparser import urllib.request import urllib.error from datetime import datetime @@ -52,11 +53,24 @@ def _version_tuple(v): return tuple(parts) -def check_for_update(): - """ - Query Gitea for the latest release. - Returns (tag, download_url) if an update is available, else (None, None). - """ +def _update_log(msg): + """Append a timestamped line to the watcher log for update events.""" + try: + log_path = os.path.join( + os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "", + "Series3Watcher", "agent_logs", "series3_watcher.log" + ) + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "a") as f: + f.write("[{}] [updater] {}\n".format( + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg + )) + except Exception: + pass + + +def _check_for_update_gitea(): + """Query Gitea API for latest release. Returns (tag, download_url) or (None, None).""" import json as _json try: req = urllib.request.Request( @@ -71,21 +85,78 @@ def check_for_update(): tag = latest.get("tag_name", "") if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): return None, None - # Find the .exe asset assets = latest.get("assets", []) for asset in assets: - name = asset.get("name", "") - if name.lower().endswith(".exe"): + name = asset.get("name", "").lower() + if name.endswith(".exe") and "setup" not in name: return tag, asset.get("browser_download_url") + _update_log("Newer release {} found but no valid .exe asset".format(tag)) return tag, None - except Exception: + except Exception as e: + _update_log("check_for_update (gitea) failed: {}".format(e)) return None, None +def _check_for_update_url(base_url): + """Query a custom URL server for latest version. Returns (tag, download_url) or (None, None).""" + if not base_url: + _update_log("UPDATE_SOURCE=url but UPDATE_URL is empty — skipping") + return None, None + try: + ver_url = base_url.rstrip("/") + "/api/updates/series3-watcher/version.txt" + req = urllib.request.Request( + ver_url, + headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)}, + ) + with urllib.request.urlopen(req, timeout=8) as resp: + tag = resp.read().decode("utf-8").strip() + if not tag: + return None, None + if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): + return None, None + exe_url = base_url.rstrip("/") + "/api/updates/series3-watcher/series3-watcher.exe" + return tag, exe_url + except Exception as e: + _update_log("check_for_update (url mode) failed: {}".format(e)) + return None, None + + +def check_for_update(): + """ + Check for an update using the configured source (gitea, url, or disabled). + Reads UPDATE_SOURCE and UPDATE_URL from config.ini at check time. + Returns (tag, download_url) if an update is available, else (None, None). + Returns (None, None) immediately if UPDATE_SOURCE = disabled. + """ + try: + cp = configparser.ConfigParser(inline_comment_prefixes=(";", "#")) + cp.optionxform = str + cp.read(CONFIG_PATH, encoding="utf-8") + section = cp["agent"] if cp.has_section("agent") else {} + update_source = section.get("UPDATE_SOURCE", "gitea").strip().lower() + update_url = section.get("UPDATE_URL", "").strip() + except Exception: + update_source = "gitea" + update_url = "" + + if update_source == "disabled": + return None, None + + _update_log("Checking for update (source={}, version={})".format( + update_source, _CURRENT_VERSION + )) + + if update_source == "url": + return _check_for_update_url(update_url) + else: + return _check_for_update_gitea() + + def apply_update(download_url): """ - Download new .exe to a temp file, write a swap .bat, launch it, exit. - The bat waits for us to exit, then swaps the files and relaunches. + Download new .exe to a temp file, validate it, write a swap .bat, launch it, exit. + The bat backs up the old exe, retries the copy up to 5 times if locked, then relaunches. + The .exe.old backup is left in place as a rollback copy. """ exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0]) @@ -93,6 +164,8 @@ def apply_update(download_url): tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="s3w_update_") os.close(tmp_fd) + _update_log("Downloading update from: {}".format(download_url)) + req = urllib.request.Request( download_url, headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)}, @@ -101,26 +174,79 @@ def apply_update(download_url): with open(tmp_path, "wb") as f: f.write(resp.read()) + # Three-layer validation before touching the live exe + try: + dl_size = os.path.getsize(tmp_path) + current_size = os.path.getsize(exe_path) + _update_log("Download complete ({} bytes), validating...".format(dl_size)) + + if dl_size < 100 * 1024: + _update_log("Validation failed: too small ({} bytes) — aborting".format(dl_size)) + os.remove(tmp_path) + return False + + if current_size > 0 and dl_size < current_size * 0.5: + _update_log("Validation failed: suspiciously small ({} bytes vs current {} bytes) — aborting".format( + dl_size, current_size + )) + os.remove(tmp_path) + return False + + with open(tmp_path, "rb") as _f: + magic = _f.read(2) + if magic != b"MZ": + _update_log("Validation failed: not a valid Windows exe (bad magic bytes) — aborting") + os.remove(tmp_path) + return False + + _update_log("Validation passed ({} bytes, MZ ok)".format(dl_size)) + + except Exception as e: + _update_log("Validation error: {} — aborting".format(e)) + try: + os.remove(tmp_path) + except Exception: + pass + return False + bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="s3w_swap_") os.close(bat_fd) bat_content = ( "@echo off\r\n" "ping 127.0.0.1 -n 4 > nul\r\n" + "copy /Y \"{exe}\" \"{exe}.old\"\r\n" + "set RETRIES=0\r\n" + ":retry\r\n" "copy /Y \"{new}\" \"{exe}\"\r\n" + "if errorlevel 1 (\r\n" + " set /a RETRIES+=1\r\n" + " if %RETRIES% GEQ 5 goto fail\r\n" + " ping 127.0.0.1 -n 3 > nul\r\n" + " goto retry\r\n" + ")\r\n" "start \"\" \"{exe}\"\r\n" + "del \"{new}\"\r\n" "del \"%~f0\"\r\n" + "exit /b 0\r\n" + ":fail\r\n" + "del \"{new}\"\r\n" + "del \"%~f0\"\r\n" + "exit /b 1\r\n" ).format(new=tmp_path, exe=exe_path) with open(bat_path, "w") as f: f.write(bat_content) + _update_log("Launching swap bat — exiting for update") + subprocess.Popen( ["cmd", "/C", bat_path], creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, ) return True - except Exception: + except Exception as e: + _update_log("apply_update failed: {}".format(e)) return False @@ -352,7 +478,7 @@ class WatcherTray: self._do_update() return # exit loop; swap bat will relaunch - # Periodic Gitea update check (every ~5 min) + # Periodic update check (every ~5 min) update_check_counter += 1 if update_check_counter >= 30: update_check_counter = 0 @@ -379,7 +505,7 @@ class WatcherTray: self.stop_event.set() if self._icon is not None: self._icon.stop() - # If update failed, just keep running silently + # If update failed, keep running silently — error is in the log # --- Entry point --- diff --git a/series3_watcher.py b/series3_watcher.py index 543eb89..2c7da2e 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — v1.4.2 +Series 3 Watcher — v1.4.3 Environment: - Python 3.8 (Windows 7 compatible) @@ -78,6 +78,10 @@ def load_config(path: str) -> Dict[str, Any]: "API_INTERVAL_SECONDS": get_int("API_INTERVAL_SECONDS", 300), "SOURCE_ID": get_str("SOURCE_ID", gethostname()), "SOURCE_TYPE": get_str("SOURCE_TYPE", "series3_watcher"), + + # Auto-updater source + "UPDATE_SOURCE": get_str("UPDATE_SOURCE", "gitea"), + "UPDATE_URL": get_str("UPDATE_URL", ""), } @@ -215,7 +219,7 @@ def scan_latest( # --- API heartbeat / SFM telemetry helpers --- -VERSION = "1.4.2" +VERSION = "1.4.3" def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: diff --git a/settings_dialog.py b/settings_dialog.py index 8232f9f..b65af66 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — Settings Dialog v1.4.2 +Series 3 Watcher — Settings Dialog v1.4.3 Provides a Tkinter settings dialog that doubles as a first-run wizard. @@ -39,6 +39,10 @@ DEFAULTS = { "Series3Watcher", "agent_logs", "series3_watcher.log" ), "LOG_RETENTION_DAYS": "30", + + # Auto-updater + "UPDATE_SOURCE": "gitea", + "UPDATE_URL": "", } @@ -233,6 +237,10 @@ class SettingsDialog: self.var_enable_logging = tk.BooleanVar(value=v["ENABLE_LOGGING"].lower() in ("1","true","yes","on")) self.var_log_retention_days = tk.StringVar(value=v["LOG_RETENTION_DAYS"]) + # Updates + self.var_update_source = tk.StringVar(value=v["UPDATE_SOURCE"].lower() if v["UPDATE_SOURCE"].lower() in ("gitea", "url", "disabled") else "gitea") + self.var_update_url = tk.StringVar(value=v["UPDATE_URL"]) + # --- UI construction --- def _build_ui(self): @@ -259,6 +267,7 @@ class SettingsDialog: self._build_tab_paths(nb) self._build_tab_scanning(nb) self._build_tab_logging(nb) + self._build_tab_updates(nb) # Buttons btn_frame = tk.Frame(outer) @@ -398,6 +407,68 @@ class SettingsDialog: _add_label_check(f, 0, "Enable Logging", self.var_enable_logging) _add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365) + def _build_tab_updates(self, nb): + f = self._tab_frame(nb, "Updates") + + tk.Label( + f, + text="Auto-Update Source", + anchor="w", + ).grid(row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 2)) + + radio_frame = tk.Frame(f) + radio_frame.grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 2)) + + rb_gitea = ttk.Radiobutton( + radio_frame, text="Gitea (default)", + variable=self.var_update_source, value="gitea", + command=self._on_update_source_change, + ) + rb_gitea.grid(row=0, column=0, sticky="w", padx=(0, 12)) + + rb_url = ttk.Radiobutton( + radio_frame, text="Custom URL", + variable=self.var_update_source, value="url", + command=self._on_update_source_change, + ) + rb_url.grid(row=0, column=1, sticky="w", padx=(0, 12)) + + rb_disabled = ttk.Radiobutton( + radio_frame, text="Disabled", + variable=self.var_update_source, value="disabled", + command=self._on_update_source_change, + ) + rb_disabled.grid(row=0, column=2, sticky="w") + + tk.Label(f, text="Update Server URL", anchor="w").grid( + row=1, column=0, sticky="w", padx=(8, 4), pady=4 + ) + self._update_url_entry = ttk.Entry(f, textvariable=self.var_update_url, width=42) + self._update_url_entry.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4) + + hint_text = ( + "Gitea: checks the Gitea release page automatically every 5 minutes.\n" + "Custom URL: fetches version.txt and series3-watcher.exe from a web\n" + "server — use when Gitea is not reachable (e.g. terra-view URL).\n" + "Disabled: no automatic update checks. Remote push from terra-view\n" + "still works when disabled." + ) + tk.Label(f, text=hint_text, justify="left", fg="#555555", + wraplength=380).grid( + row=2, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(4, 8) + ) + + # Set initial state of URL entry + self._on_update_source_change() + + def _on_update_source_change(self): + """Enable/disable the URL entry based on selected update source.""" + if self.var_update_source.get() == "url": + self._update_url_entry.config(state="normal") + else: + self._update_url_entry.config(state="disabled") + + # --- Validation helpers --- def _get_int_var(self, var, name, min_val, max_val, default): @@ -467,6 +538,8 @@ class SettingsDialog: "ENABLE_LOGGING": "true" if self.var_enable_logging.get() else "false", "LOG_FILE": self.var_log_file.get().strip(), "LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]), + "UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea", + "UPDATE_URL": self.var_update_url.get().strip(), } try: -- 2.49.1