diff --git a/README.md b/README.md index c6b6f3b..d911040 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,9 @@ To switch between stable and dev, change the image tag in `docker-compose.yml`: - Stable: `image: mawoj/mc-webui:latest` - Development: `image: mawoj/mc-webui:dev` +You can also update from the web interface instead of the terminal — see +[Remote updates from web GUI](#remote-updates-from-web-gui-optional) below. + ### From Source: Using the update script (recommended) The easiest way to update mc-webui: @@ -398,20 +401,42 @@ git checkout main ### Remote updates from web GUI (optional) -You can enable one-click updates directly from the mc-webui menu. This requires installing a small webhook service on the host machine. +You can enable one-click updates directly from the mc-webui menu. This requires installing a small webhook service on the host machine. It works with either installation option. -**Install the updater service:** +**Docker Hub installation** (no repository) — run this from the folder holding your `docker-compose.yml`: + +```bash +cd ~/mc-webui +curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/updater/install.sh | sudo bash +``` + +It downloads what it needs into `/opt/mc-webui-updater`. Add `MCWEBUI_DIR=/path/to/mc-webui` in front of `sudo` if your instance lives somewhere it cannot guess. + +**From Source installation:** ```bash cd ~/mc-webui sudo ./scripts/updater/install.sh ``` -The installer will: +Either way the installer will: - Create a systemd service `mc-webui-updater` -- Start a webhook server on port 5050 (localhost only) +- Start a webhook server on port 5050 - Enable automatic startup on boot +The update it performs matches the installation it finds, and it decides that at +the moment you press the button: + +| Installation | What "Update" runs | +|---|---| +| Docker Hub image | `docker compose pull` + `docker compose up -d` | +| Git checkout | `git pull`, freeze version, `docker compose up -d --build` | + +An image is published a few minutes after the commit it is built from, so +shortly after a release the button can report that the change is on GitHub but +its image is still being built. That is not an error — try again in a few +minutes. + **Usage:** 1. Click the refresh button (↻) next to the version in the menu 2. If an update is available, an "Update" button appears @@ -427,11 +452,18 @@ systemctl status mc-webui-updater # View logs journalctl -u mc-webui-updater -f -# Uninstall +# Uninstall (From Source) sudo ~/mc-webui/scripts/updater/install.sh --uninstall + +# Uninstall (Docker Hub installation) +curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/updater/install.sh | sudo bash -s -- --uninstall ``` -**Security note:** The webhook listens only on localhost. The Docker container connects to it via the Docker bridge network. +**Security note:** The webhook listens on port 5050 on all interfaces — the +Docker container reaches it over the Docker bridge, which rules out binding to +localhost only — and it has no authentication. Anyone who can reach port 5050 +can trigger a rebuild. Install it only on a trusted network, or block the port +at your firewall. --- diff --git a/app/routes/api.py b/app/routes/api.py index 518935e..9c9b98b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -5581,8 +5581,16 @@ def updater_status(): "success": true, "available": true, "url": "http://172.17.0.1:5050", - "update_in_progress": false + "update_in_progress": false, + "mode": "image", + "last_update_result": {"success": true, "changed": false}, + "last_update_time": "2026-09-09 11:20:03" } + + 'mode' is how the host installation updates itself - 'source' for a git + checkout, 'image' for a docker-compose.yml running the published image. + 'last_update_result' lets the caller tell "the update ran and there was + nothing newer" from "the update never finished". """ try: url = get_updater_url() @@ -5598,12 +5606,26 @@ def updater_status(): response = requests.get(f"{url}/health", timeout=5) data = response.json() + # Only the fields the UI acts on: the webhook also carries up to 2 KB + # of script output, which has no business in a poll that runs every + # two seconds. The full output stays in journalctl on the host. + last_result = data.get('last_update_result') or None + if isinstance(last_result, dict): + last_result = { + k: last_result.get(k) + for k in ('success', 'changed', 'mode', 'error', 'returncode') + if k in last_result + } + return jsonify({ 'success': True, 'available': True, 'url': url, 'update_in_progress': data.get('update_in_progress', False), - 'mcwebui_dir': data.get('mcwebui_dir', '') + 'mcwebui_dir': data.get('mcwebui_dir', ''), + 'mode': data.get('mode', 'unknown'), + 'last_update_result': last_result, + 'last_update_time': data.get('last_update_time') }), 200 except Exception as e: @@ -5648,6 +5670,7 @@ def updater_trigger(): if response.status_code == 200 and data.get('success'): return jsonify({ 'success': True, + 'mode': data.get('mode', 'unknown'), 'message': 'Update started. Server will restart shortly.' }), 200 else: diff --git a/app/static/js/app.js b/app/static/js/app.js index a7bbfc3..971a256 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -5704,6 +5704,20 @@ async function performRemoteUpdate() { document.getElementById('updateProgressMessage').textContent = t('update.starting'); try { + // Baseline before triggering: last_update_time is what tells this run + // apart from an earlier one, and the mode decides how "nothing to do" + // gets worded - a stale image and an unchanged commit are not the + // same story to tell. + let mode = 'unknown'; + let previousResultTime = null; + try { + const before = await fetch('/api/updater/status').then(r => r.json()); + mode = before.mode || 'unknown'; + previousResultTime = before.last_update_time || null; + } catch (e) { + // Not fatal - watching the version still works on its own + } + // Trigger update const data = await fetchJson('/api/updater/trigger', { method: 'POST' }); @@ -5711,6 +5725,7 @@ async function performRemoteUpdate() { showUpdateResult(false, data.error || t('update.start_failed')); return; } + if (data.mode && data.mode !== 'unknown') mode = data.mode; document.getElementById('updateProgressMessage').textContent = t('update.waiting'); @@ -5738,6 +5753,39 @@ async function performRemoteUpdate() { return; } } + + // The app answered, so it is up: the update run has either + // finished or never restarted anything. Ask the webhook which + // - otherwise a run that had nothing to install sits out the + // full two minutes and then claims it timed out. That is the + // normal case right after a push, while the image for that + // commit is still being built. + const status = await fetch('/api/updater/status') + .then(r => r.json()) + .catch(() => null); + + const finished = status && status.available + && !status.update_in_progress + && status.last_update_time + && status.last_update_time !== previousResultTime; + + if (finished) { + const result = status.last_update_result || {}; + if (result.success === false) { + showUpdateResult(false, result.error || t('update.start_failed')); + return; + } + // changed === null means an update script older than the + // result marker: fall through and keep watching the version. + if (result.changed === false) { + showUpdateResult( + false, + mode === 'image' ? t('update.no_new_image') : t('update.no_new_commit'), + { neutral: true } + ); + return; + } + } } catch (e) { // Server not responding yet - this is expected during restart document.getElementById('updateProgressMessage').textContent = @@ -5761,9 +5809,12 @@ async function performRemoteUpdate() { } /** - * Show update result in modal + * Show update result in modal. + * + * options.neutral marks an outcome that is neither: the update ran fine and + * found nothing to install. A red cross would read as a failure it isn't. */ -function showUpdateResult(success, message) { +function showUpdateResult(success, message, options = {}) { document.getElementById('updateProgress').classList.add('d-none'); document.getElementById('updateResult').classList.remove('d-none'); @@ -5775,10 +5826,15 @@ function showUpdateResult(success, message) { msg.className = 'mb-0 text-success'; document.getElementById('updateReloadBtn').classList.remove('d-none'); } else { - icon.className = 'bi bi-x-circle-fill text-danger fs-1 mb-3 d-block'; - msg.className = 'mb-0 text-danger'; + if (options.neutral) { + icon.className = 'bi bi-info-circle-fill text-secondary fs-1 mb-3 d-block'; + msg.className = 'mb-0 text-secondary'; + } else { + icon.className = 'bi bi-x-circle-fill text-danger fs-1 mb-3 d-block'; + msg.className = 'mb-0 text-danger'; + } document.getElementById('updateCancelBtn').classList.remove('d-none'); - document.getElementById('updateCancelBtn').textContent = 'Close'; + document.getElementById('updateCancelBtn').textContent = t('common.close'); } msg.textContent = message; diff --git a/app/translations/en.json b/app/translations/en.json index 788bce3..0f4f88f 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -1487,6 +1487,8 @@ "update.link_now_title": "Click to update", "update.network_error": "Network error during update", "update.new_version": "New version available: {version}", + "update.no_new_commit": "Already on the newest commit - nothing was pulled.", + "update.no_new_image": "The change is already on GitHub, but its Docker image is still being built. Try again in a few minutes.", "update.now": "Update Now", "update.rebuilding": "Rebuilding containers... ({attempt}/{max})", "update.reload": "Reload Page", diff --git a/app/translations/pl.json b/app/translations/pl.json index 5efa60a..8762241 100644 --- a/app/translations/pl.json +++ b/app/translations/pl.json @@ -1563,6 +1563,8 @@ "update.link_now_title": "Kliknij, aby zaktualizować", "update.network_error": "Błąd sieci podczas aktualizacji", "update.new_version": "Dostępna nowa wersja: {version}", + "update.no_new_commit": "Jesteś już na najnowszym commicie - nie było czego pobrać.", + "update.no_new_image": "Zmiana jest już na GitHubie, ale jej obraz Dockera wciąż się buduje. Spróbuj ponownie za kilka minut.", "update.now": "Aktualizuj teraz", "update.rebuilding": "Przebudowa kontenerów... ({attempt}/{max})", "update.reload": "Przeładuj stronę", diff --git a/docs/architecture.md b/docs/architecture.md index 1de662a..b1506d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -208,9 +208,12 @@ mc-webui/ ├── docs/ # Documentation ├── scripts/ │ ├── diag_report.py # Offline analyser for a diagnostic capture -│ ├── update.sh # Automated update script +│ ├── update.sh # Update a git checkout (pull + rebuild) │ ├── docker-entrypoint.sh # Container startup (BLE cleanup) │ ├── updater/ # Remote update webhook service +│ │ ├── updater.py # Host webhook; picks the update path per install +│ │ ├── update-image.sh # Update a Docker Hub install (pull + recreate) +│ │ └── install.sh # systemd installer; also runs from curl | sudo bash │ └── watchdog/ # Container health monitor └── README.md ``` diff --git a/docs/watchdog.md b/docs/watchdog.md index 1b9fedf..fa3064c 100644 --- a/docs/watchdog.md +++ b/docs/watchdog.md @@ -29,12 +29,26 @@ In parallel, the app exposes [`/health/strict`](architecture.md#health-endpoints ## Installation +**From Source installation** (a git checkout): + ```bash cd ~/mc-webui sudo ./scripts/watchdog/install.sh ``` -The installer will: +**Docker Hub installation** (no repository) — run this from the folder holding +your `docker-compose.yml`; it downloads what it needs into +`/opt/mc-webui-watchdog`: + +```bash +cd ~/mc-webui +curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/watchdog/install.sh | sudo bash +``` + +Add `MCWEBUI_DIR=/path/to/mc-webui` in front of `sudo` if your instance lives +somewhere the installer cannot guess. + +Either way the installer will: - Create a systemd service `mc-webui-watchdog` - Start monitoring the container immediately - Enable automatic startup on boot @@ -115,7 +129,11 @@ Environment=AUTO_START=false ## Uninstall ```bash +# From Source installation sudo ~/mc-webui/scripts/watchdog/install.sh --uninstall + +# Docker Hub installation +curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/watchdog/install.sh | sudo bash -s -- --uninstall ``` Note: The log file is preserved after uninstall. Remove manually if needed: diff --git a/docs/whatsnew.md b/docs/whatsnew.md index 392e058..e4642dd 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -12,10 +12,21 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g ### Features +- **One-click updates now work without cloning the repository.** mc-webui can be installed two ways: by pulling the ready-made Docker image, or by cloning this repository and building it. The ↻ button next to the version in the menu has always told both kinds whether a newer version exists — but **Update now** only ever appeared for a repository installation, because the small service behind it was installed from the `scripts/` folder, which an image installation does not have. That folder is no longer needed: one command installs the service and downloads what it needs, and the whole thing can be removed the same way. + + ```bash + cd ~/mc-webui # the folder with your docker-compose.yml + curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/updater/install.sh | sudo bash + ``` + + The update it then performs matches the installation it found: an image installation pulls the newly published image and recreates the container, a repository installation pulls the commits and rebuilds, exactly as before. Nothing changes for anyone who already has the updater installed from a checkout. The **container watchdog** — which restarts the container when it stops responding, and power-cycles a frozen USB device — installs the same way now, from `scripts/watchdog/install.sh` at the same address. + - **The line under an incoming channel message now names the region it was sent in, and no longer spells out the route.** A MeshCore sender can stamp a packet with a *region* — a flood scope such as `pl` that only repeaters allowing it pass on (see the previous release). The stamp is not the name but a 16-bit code derived from the region's key, so the interface can recognise it only for regions you have added under **Settings → Channels**: a message stamped with one of them now ends its line with **Region: pl**, while an unstamped message, or one from a region you have not configured, shows nothing extra. The route part changed too. It used to print the first route inline (`SNR: -5.5 dB | Hops: 4 | Route (3): 142C→1122→271C→EE6C`), which with 2- or 3-byte hashes crowded everything else off a phone screen. It now only says how many copies of the message were heard and how wide their per-hop hashes are — `(3) 2B`, three copies with 2-byte hashes — with the size coloured **red**, **orange** or **green** for 1, 2 or 3 bytes, and tapping it opens the same popup as before. The words *Hops*, *Route* and *Region* are gone as well, replaced by a small antenna, signpost and map-pin icon, each naming itself when hovered or long-pressed: spelled out, the line took two rows on a phone and pushed the messages apart. The SNR and hop count stay where they were: they describe the copy your node decoded, arrive with the message itself, and so are there even when no repeat was heard. The popup is now complete: it lists every copy in the order heard, including one that came **Direct** from the sender with no repeater in between (previously left out, which made "Hops: 0" under a message look wrong next to popup entries with one hop), and marks the copy your node actually decoded with **✓ received**. The whole line is also a size larger and darker, since it was hard to read on a big screen. The region stamp is kept from this version on, so messages received before the update have no region to show. ### Fixes +- **An update that had nothing to install no longer claims it timed out.** Pressing **Update now** watched for the version to change and gave up after two minutes if it did not — so an update run that legitimately found nothing new ended in *"Update timed out. Please check server manually."* That was the normal outcome shortly after a release, while the Docker image for the newest commit was still being built. The update now reports what it did, and the screen says so plainly: that the change is on GitHub but its image is still being built, or, for an installation built from source, that it was already on the newest commit. It appears the moment the run finishes, instead of two minutes later, and no longer wears a red cross. + - **A failed request no longer shows a programmer's error message.** When something in front of the app answered instead of the app — the login page of a reverse proxy or a service like Cloudflare Access after the session had expired, or a gateway error page — the interface tried to read that web page as data and showed what the attempt threw: `SyntaxError: Unexpected token '<', "/dev/null || echo unknown)" # Force HTTP/1.1 for the pull. GitHub's HTTP/2 edge rejects the anonymous # git-upload-pack POST sent by older git/libcurl (e.g. Debian bookworm's # git 2.39 + libcurl 7.88) with a 401, which git reports as a credential @@ -51,6 +52,13 @@ if GIT_TERMINAL_PROMPT=0 git -c http.version=HTTP/1.1 pull; then else error "Git pull failed" fi +AFTER_HEAD="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +if [ "$BEFORE_HEAD" == "$AFTER_HEAD" ]; then + CHANGED=false + warn "Already on the newest commit - rebuilding anyway to be sure." +else + CHANGED=true +fi echo "" # Step 2: Freeze version @@ -88,4 +96,20 @@ if command -v curl &> /dev/null; then fi echo "" -echo -e "${GREEN}Update complete!${NC}" +if [ "$CHANGED" == "true" ]; then + echo -e "${GREEN}Update complete!${NC}" +else + echo -e "${YELLOW}Nothing new was pulled - already on the newest commit.${NC}" +fi + +# Machine-readable result for the updater webhook - keep this the last line. +# Only when stdout is not a terminal: the webhook captures output through a +# pipe, while a human running this by hand has already read the same thing in +# plain words just above and does not need the marker in their terminal. +if [ ! -t 1 ]; then + if [ "$CHANGED" == "true" ]; then + echo "MC_UPDATE_RESULT=updated" + else + echo "MC_UPDATE_RESULT=no_change" + fi +fi diff --git a/scripts/updater/install.sh b/scripts/updater/install.sh index 365f054..81d4ea8 100755 --- a/scripts/updater/install.sh +++ b/scripts/updater/install.sh @@ -5,7 +5,23 @@ # This script installs the update webhook service that allows # remote updates from the mc-webui GUI. # -# Usage: sudo ./install.sh [--uninstall] +# Two ways to run it: +# +# From a git checkout: +# sudo ./install.sh +# +# Without the repository (Docker Hub installations) - run this from the +# folder holding your docker-compose.yml, it downloads what it needs: +# curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/updater/install.sh | sudo bash +# +# Uninstall: +# sudo ./install.sh --uninstall +# curl -fsSL | sudo bash -s -- --uninstall +# +# Environment: +# MCWEBUI_DIR - your mc-webui folder, if it cannot be found automatically +# MC_BRANCH - branch to download from in standalone mode (default: main) +# MC_RAW_BASE - download from somewhere else entirely (fork or mirror) # set -e @@ -25,16 +41,13 @@ if [ "$EUID" -ne 0 ]; then error "Please run as root: sudo $0" fi -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" - -# Detect the user who owns mc-webui directory -MCWEBUI_USER=$(stat -c '%U' "$MCWEBUI_DIR") -MCWEBUI_GROUP=$(stat -c '%G' "$MCWEBUI_DIR") - SERVICE_NAME="mc-webui-updater" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +STANDALONE_DIR="/opt/mc-webui-updater" +MC_BRANCH="${MC_BRANCH:-main}" +# MC_RAW_BASE lets a fork, a mirror, or a test run point the download +# somewhere other than this repository. +RAW_BASE="${MC_RAW_BASE:-https://raw.githubusercontent.com/MarekWo/mc-webui/${MC_BRANCH}/scripts/updater}" # Uninstall if [ "$1" == "--uninstall" ]; then @@ -56,28 +69,118 @@ if [ "$1" == "--uninstall" ]; then info "Service file removed" fi + # Only ever created by a standalone install, so it is safe to remove. + # A repository install keeps its scripts where the checkout has them. + if [ -d "$STANDALONE_DIR" ]; then + rm -rf "$STANDALONE_DIR" + info "Removed $STANDALONE_DIR" + fi + echo -e "${GREEN}Uninstallation complete!${NC}" exit 0 fi +# --------------------------------------------------------------------------- +# Where are we running from, and what are we updating? +# --------------------------------------------------------------------------- + +# Piped through bash (curl | sudo bash) leaves BASH_SOURCE pointing at stdin, +# so the presence of updater.py next to us is what tells the two apart. +SELF_DIR="" +if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then + SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +# Find the folder holding docker-compose.yml - that is the mc-webui instance +find_instance_dir() { + local candidates=("$@") + local d + for d in "${candidates[@]}"; do + [ -n "$d" ] || continue + if [ -f "$d/docker-compose.yml" ] || [ -f "$d/docker-compose.yaml" ]; then + (cd "$d" && pwd) + return 0 + fi + done + return 1 +} + +fetch() { # fetch + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + error "Neither curl nor wget is available - cannot download $1" + fi +} + +if [ -n "$SELF_DIR" ] && [ -f "$SELF_DIR/updater.py" ]; then + # Repository install: run the scripts straight out of the checkout, so + # they stay in step with the rest of the code on every git pull. + INSTALL_MODE="repository" + SCRIPT_DIR="$SELF_DIR" + MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +else + INSTALL_MODE="standalone" + SCRIPT_DIR="$STANDALONE_DIR" + + # $HOME is root's under sudo, so the invoking user's home is the one worth + # looking in - that is where the README tells people to put mc-webui. + SUDO_HOME="" + if [ -n "${SUDO_USER:-}" ]; then + SUDO_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)" + fi + + MCWEBUI_DIR="$(find_instance_dir \ + "${MCWEBUI_DIR:-}" \ + "$(pwd)" \ + "${SUDO_HOME:+$SUDO_HOME/mc-webui}" \ + "$HOME/mc-webui" \ + "/opt/mc-webui")" || error \ + "Cannot find your mc-webui folder (no docker-compose.yml). Run this from that folder, or set MCWEBUI_DIR=/path/to/mc-webui." +fi + # Install info "Installing ${SERVICE_NAME}..." +info " Install mode: $INSTALL_MODE" info " mc-webui directory: $MCWEBUI_DIR" -info " mc-webui user: $MCWEBUI_USER" + +if [ "$INSTALL_MODE" == "standalone" ]; then + info " Downloading from branch: $MC_BRANCH" + mkdir -p "$SCRIPT_DIR" + + # Into a temp file first, so a failed download cannot truncate a working + # install of the service that is running right now. + for f in updater.py update-image.sh; do + tmp="$(mktemp)" + fetch "$RAW_BASE/$f" "$tmp" || error "Failed to download $f from $RAW_BASE" + [ -s "$tmp" ] || error "Downloaded $f is empty" + mv "$tmp" "$SCRIPT_DIR/$f" + chmod 644 "$SCRIPT_DIR/$f" + info " Downloaded $f" + done + chmod +x "$SCRIPT_DIR/update-image.sh" +fi # Check if updater.py exists if [ ! -f "$SCRIPT_DIR/updater.py" ]; then error "updater.py not found in $SCRIPT_DIR" fi -# Check if update.sh exists -if [ ! -f "$MCWEBUI_DIR/scripts/update.sh" ]; then - error "update.sh not found in $MCWEBUI_DIR/scripts/" +# Sanity-check that there is something here to update at all +if [ ! -d "$MCWEBUI_DIR/.git" ] \ + && [ ! -f "$MCWEBUI_DIR/docker-compose.yml" ] \ + && [ ! -f "$MCWEBUI_DIR/docker-compose.yaml" ]; then + error "$MCWEBUI_DIR is neither a git checkout nor a docker-compose folder" fi -# Configure git safe.directory for root (required since service runs as root) -info "Configuring git safe.directory..." -git config --global --add safe.directory "$MCWEBUI_DIR" 2>/dev/null || true +# Configure git safe.directory for root (required since service runs as root). +# Only relevant to a checkout - a Docker Hub install has no repository. +if [ -d "$MCWEBUI_DIR/.git" ]; then + info "Configuring git safe.directory..." + git config --global --add safe.directory "$MCWEBUI_DIR" 2>/dev/null || true +fi # Create service file with correct paths info "Creating systemd service file..." @@ -106,8 +209,10 @@ systemctl daemon-reload info "Enabling service..." systemctl enable "$SERVICE_NAME" +# restart, not start: re-running the installer is how you upgrade a +# standalone install, and a running service would otherwise keep the old code info "Starting service..." -systemctl start "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" # Wait a moment for service to start sleep 2 @@ -119,8 +224,12 @@ if systemctl is-active --quiet "$SERVICE_NAME"; then # Test health endpoint if command -v curl &> /dev/null; then HEALTH=$(curl -s http://127.0.0.1:5050/health 2>/dev/null || echo "") - if echo "$HEALTH" | grep -q '"status":"ok"'; then + if echo "$HEALTH" | grep -q '"status": *"ok"'; then info "Health check passed!" + # Which update path did it settle on? Worth showing - it is the + # difference between "git pull + rebuild" and "docker compose pull". + DETECTED=$(echo "$HEALTH" | grep -o '"mode": *"[^"]*"' | cut -d'"' -f4) + [ -n "$DETECTED" ] && info "Detected update mode: $DETECTED" else warn "Health check failed - service may still be starting" fi @@ -138,4 +247,8 @@ echo "" echo "Useful commands:" echo " systemctl status $SERVICE_NAME # Check status" echo " sudo journalctl -u $SERVICE_NAME -f # View logs" -echo " sudo $0 --uninstall # Uninstall" +if [ "$INSTALL_MODE" == "standalone" ]; then + echo " curl -fsSL $RAW_BASE/install.sh | sudo bash -s -- --uninstall" +else + echo " sudo $0 --uninstall # Uninstall" +fi diff --git a/scripts/updater/update-image.sh b/scripts/updater/update-image.sh new file mode 100755 index 0000000..5f38f31 --- /dev/null +++ b/scripts/updater/update-image.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# +# mc-webui update script - Docker Hub / GHCR image installations +# +# Counterpart to scripts/update.sh: that one rebuilds a git checkout, this one +# pulls the published image and recreates the container. Used by installations +# that never cloned the repository (README "Option A: Docker Hub"), where there +# is nothing to git pull and nothing to build. +# +# Usage: +# ./update-image.sh # run from the mc-webui directory +# MCWEBUI_DIR=~/mc-webui ./update-image.sh +# +# Prints MC_UPDATE_RESULT=updated|no_change on the last line so the updater +# webhook can tell "container recreated" from "the registry had nothing new". +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } + +# Determine mc-webui directory - the one holding docker-compose.yml +find_compose_dir() { + local candidates=("$@") + local d + for d in "${candidates[@]}"; do + [ -n "$d" ] || continue + if [ -f "$d/docker-compose.yml" ] || [ -f "$d/docker-compose.yaml" ]; then + (cd "$d" && pwd) + return 0 + fi + done + return 1 +} + +MCWEBUI_DIR="$(find_compose_dir "$MCWEBUI_DIR" "$(pwd)" "$HOME/mc-webui")" \ + || error "Cannot find docker-compose.yml. Run from the mc-webui folder or set MCWEBUI_DIR." +cd "$MCWEBUI_DIR" + +# Compose v2 (docker compose) with a fallback to the standalone v1 binary +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + error "Neither 'docker compose' nor 'docker-compose' is available" +fi + +info "Updating mc-webui in: $MCWEBUI_DIR" +echo "" + +# Step 1: Remember which images we are running right now. +# Image IDs, not tags - the tag stays 'latest' across every update, so the ID +# is the only thing that tells us whether the pull actually brought anything. +images_fingerprint() { + local ref + for ref in $("${COMPOSE[@]}" config --images 2>/dev/null); do + echo "$ref=$(docker image inspect -f '{{.Id}}' "$ref" 2>/dev/null || echo missing)" + done +} + +BEFORE="$(images_fingerprint)" + +# Step 2: Pull the published image +info "Pulling the latest image..." +if "${COMPOSE[@]}" pull; then + success "Pull completed" +else + error "Image pull failed" +fi +echo "" + +AFTER="$(images_fingerprint)" + +if [ "$BEFORE" == "$AFTER" ]; then + CHANGED=false + warn "The registry has no newer image than the one already running." + warn "A commit can be on GitHub for a while before its image finishes building." +else + CHANGED=true + success "A newer image was downloaded" +fi +echo "" + +# Step 3: Recreate the container. +# Run this even when nothing was pulled: it is also what brings a stopped or +# crash-looping container back up, which is half the reason people press Update. +info "Recreating containers..." +if "${COMPOSE[@]}" up -d; then + success "Containers started" +else + error "Docker compose failed" +fi +echo "" + +# Step 4: Show status +info "Container status:" +"${COMPOSE[@]}" ps +echo "" + +# Step 5: Show version +if command -v curl &> /dev/null; then + sleep 2 # Wait for container to start + PORT="${FLASK_PORT:-5000}" + VERSION=$(curl -s "http://localhost:${PORT}/api/version" 2>/dev/null | grep -o '"version":"[^"]*"' | cut -d'"' -f4) + if [ -n "$VERSION" ]; then + success "mc-webui is running version: $VERSION" + else + warn "Could not fetch version (container may still be starting)" + fi +fi + +echo "" +if [ "$CHANGED" == "true" ]; then + echo -e "${GREEN}Update complete!${NC}" +else + echo -e "${YELLOW}Nothing to update - already on the newest published image.${NC}" +fi + +# Machine-readable result for the updater webhook - keep this the last line. +# Only when stdout is not a terminal: the webhook captures output through a +# pipe, while a human running this by hand has already read the same thing in +# plain words just above and does not need the marker in their terminal. +if [ ! -t 1 ]; then + if [ "$CHANGED" == "true" ]; then + echo "MC_UPDATE_RESULT=updated" + else + echo "MC_UPDATE_RESULT=no_change" + fi +fi diff --git a/scripts/updater/updater.py b/scripts/updater/updater.py index eff7b35..2c0fb47 100755 --- a/scripts/updater/updater.py +++ b/scripts/updater/updater.py @@ -5,9 +5,17 @@ mc-webui Update Webhook Server A simple HTTP server that listens for update requests and executes the update script. Designed to run as a systemd service on the host. +Two kinds of installation are supported, told apart at request time: +- source - a git checkout: runs scripts/update.sh (git pull + rebuild) +- image - a bare docker-compose.yml using the published Docker Hub image: + runs update-image.sh (docker compose pull + up -d) + Security: -- Listens only on localhost (127.0.0.1) -- Simple token-based authentication (optional) +- Listens on all interfaces: the mc-webui container reaches this over the + Docker bridge, so binding to loopback would put it out of reach +- Token authentication is available via UPDATER_TOKEN but off by default, + and the web UI does not send one - anyone who can reach port 5050 can + trigger an update, so keep the port off untrusted networks Endpoints: - GET /health - Check if webhook is running @@ -28,7 +36,9 @@ from urllib.parse import urlparse, parse_qs HOST = '0.0.0.0' # Listen on all interfaces (Docker needs this) PORT = 5050 MCWEBUI_DIR = os.environ.get('MCWEBUI_DIR', os.path.expanduser('~/mc-webui')) -UPDATE_SCRIPT = os.path.join(MCWEBUI_DIR, 'scripts', 'update.sh') +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SOURCE_UPDATE_SCRIPT = os.path.join(MCWEBUI_DIR, 'scripts', 'update.sh') +IMAGE_UPDATE_SCRIPT = os.path.join(SCRIPT_DIR, 'update-image.sh') AUTH_TOKEN = os.environ.get('UPDATER_TOKEN', '') # Optional token # Global state @@ -37,6 +47,36 @@ last_update_result = None last_update_time = None +def detect_mode(): + """Decide how this installation updates itself. + + Resolved per request rather than cached at startup, so cloning the + repository into what was an image-only installation flips the mode + without anyone remembering to restart this service. + + Returns 'source', 'image', or 'unknown'. + """ + if (os.path.isdir(os.path.join(MCWEBUI_DIR, '.git')) + and os.path.exists(SOURCE_UPDATE_SCRIPT)): + return 'source' + + for name in ('docker-compose.yml', 'docker-compose.yaml'): + if os.path.exists(os.path.join(MCWEBUI_DIR, name)): + return 'image' + + return 'unknown' + + +def resolve_update_script(): + """Return (mode, script_path); script_path is None when there is none.""" + mode = detect_mode() + if mode == 'source': + return mode, SOURCE_UPDATE_SCRIPT + if mode == 'image': + return mode, IMAGE_UPDATE_SCRIPT + return mode, None + + class UpdateHandler(BaseHTTPRequestHandler): """HTTP request handler for update webhook.""" @@ -100,11 +140,19 @@ class UpdateHandler(BaseHTTPRequestHandler): def handle_health(self): """Health check endpoint.""" + mode, script = resolve_update_script() self.send_json({ 'status': 'ok', 'service': 'mc-webui-updater', 'update_in_progress': update_in_progress, - 'mcwebui_dir': MCWEBUI_DIR + 'mcwebui_dir': MCWEBUI_DIR, + 'mode': mode, + 'update_script': script, + 'update_script_present': bool(script and os.path.exists(script)), + # Carried here as well so the web UI can read the outcome of the + # run it just triggered without a second round trip. + 'last_update_result': last_update_result, + 'last_update_time': last_update_time }) def handle_status(self): @@ -130,43 +178,76 @@ class UpdateHandler(BaseHTTPRequestHandler): }, 409) return - if not os.path.exists(UPDATE_SCRIPT): + mode, script = resolve_update_script() + + if mode == 'unknown': self.send_json({ 'success': False, - 'error': f'Update script not found: {UPDATE_SCRIPT}' + 'error': (f'No mc-webui installation found in {MCWEBUI_DIR}: ' + 'neither a git checkout nor a docker-compose.yml.') + }, 500) + return + + if not script or not os.path.exists(script): + self.send_json({ + 'success': False, + 'error': f'Update script not found: {script}' }, 500) return # Start update in background thread update_in_progress = True - thread = threading.Thread(target=run_update, daemon=True) + thread = threading.Thread( + target=run_update, args=(mode, script), daemon=True + ) thread.start() self.send_json({ 'success': True, + 'mode': mode, 'message': 'Update started', 'note': 'Server will restart. Poll /health to detect completion.' }) -def run_update(): +def parse_result_marker(stdout): + """Read the MC_UPDATE_RESULT=... line the update scripts print last. + + It separates "something new was installed" from "the remote had nothing + newer". Without it the web UI can only watch for the version to change, + and then sits through its whole timeout whenever there was legitimately + nothing to change - which is the normal case while a pushed commit is + still being built into an image. None means a script older than the + marker, and the caller should fall back to watching the version. + """ + for line in reversed((stdout or '').strip().splitlines()): + line = line.strip() + if line.startswith('MC_UPDATE_RESULT='): + return line.split('=', 1)[1].strip() + return None + + +def run_update(mode, script): """Run update script in background.""" global update_in_progress, last_update_result, last_update_time try: - print(f"[UPDATE] Starting update from {UPDATE_SCRIPT}") + print(f"[UPDATE] Starting {mode} update from {script}") # Run the update script result = subprocess.run( - ['/bin/bash', UPDATE_SCRIPT], + ['/bin/bash', script], cwd=MCWEBUI_DIR, capture_output=True, text=True, timeout=300 # 5 minute timeout ) + marker = parse_result_marker(result.stdout) last_update_result = { 'success': result.returncode == 0, + 'mode': mode, + 'changed': None if marker is None else marker == 'updated', 'returncode': result.returncode, 'stdout': result.stdout[-2000:] if result.stdout else '', # Last 2000 chars 'stderr': result.stderr[-500:] if result.stderr else '' @@ -182,6 +263,7 @@ def run_update(): except subprocess.TimeoutExpired: last_update_result = { 'success': False, + 'mode': mode, 'error': 'Update timed out after 5 minutes' } last_update_time = time.strftime('%Y-%m-%d %H:%M:%S') @@ -190,6 +272,7 @@ def run_update(): except Exception as e: last_update_result = { 'success': False, + 'mode': mode, 'error': str(e) } last_update_time = time.strftime('%Y-%m-%d %H:%M:%S') @@ -201,18 +284,24 @@ def run_update(): def main(): """Main entry point.""" + mode, script = resolve_update_script() + print(f"mc-webui Update Webhook Server") print(f" Listening on: {HOST}:{PORT}") print(f" mc-webui dir: {MCWEBUI_DIR}") - print(f" Update script: {UPDATE_SCRIPT}") + print(f" Install mode: {mode}") + print(f" Update script: {script or '(none)'}") print(f" Auth token: {'configured' if AUTH_TOKEN else 'disabled'}") print() if not os.path.exists(MCWEBUI_DIR): print(f"WARNING: mc-webui directory not found: {MCWEBUI_DIR}") - if not os.path.exists(UPDATE_SCRIPT): - print(f"WARNING: Update script not found: {UPDATE_SCRIPT}") + if mode == 'unknown': + print(f"WARNING: {MCWEBUI_DIR} holds neither a git checkout nor a " + "docker-compose.yml - nothing to update") + elif not script or not os.path.exists(script): + print(f"WARNING: Update script not found: {script}") server = HTTPServer((HOST, PORT), UpdateHandler) diff --git a/scripts/watchdog/install.sh b/scripts/watchdog/install.sh index baa38e9..084d6ef 100755 --- a/scripts/watchdog/install.sh +++ b/scripts/watchdog/install.sh @@ -5,7 +5,23 @@ # This script installs the watchdog service that monitors Docker containers # and automatically restarts unhealthy ones. # -# Usage: sudo ./install.sh [--uninstall] +# Two ways to run it: +# +# From a git checkout: +# sudo ./install.sh +# +# Without the repository (Docker Hub installations) - run this from the +# folder holding your docker-compose.yml, it downloads what it needs: +# curl -fsSL https://raw.githubusercontent.com/MarekWo/mc-webui/main/scripts/watchdog/install.sh | sudo bash +# +# Uninstall: +# sudo ./install.sh --uninstall +# curl -fsSL | sudo bash -s -- --uninstall +# +# Environment: +# MCWEBUI_DIR - your mc-webui folder, if it cannot be found automatically +# MC_BRANCH - branch to download from in standalone mode (default: main) +# MC_RAW_BASE - download from somewhere else entirely (fork or mirror) # set -e @@ -25,13 +41,14 @@ if [ "$EUID" -ne 0 ]; then error "Please run as root: sudo $0" fi -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" - SERVICE_NAME="mc-webui-watchdog" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" LOG_FILE="/var/log/mc-webui-watchdog.log" +STANDALONE_DIR="/opt/mc-webui-watchdog" +MC_BRANCH="${MC_BRANCH:-main}" +# MC_RAW_BASE lets a fork, a mirror, or a test run point the download +# somewhere other than this repository. +RAW_BASE="${MC_RAW_BASE:-https://raw.githubusercontent.com/MarekWo/mc-webui/${MC_BRANCH}/scripts/watchdog}" # Uninstall if [ "$1" == "--uninstall" ]; then @@ -53,6 +70,13 @@ if [ "$1" == "--uninstall" ]; then info "Service file removed" fi + # Only ever created by a standalone install, so it is safe to remove. + # A repository install keeps its scripts where the checkout has them. + if [ -d "$STANDALONE_DIR" ]; then + rm -rf "$STANDALONE_DIR" + info "Removed $STANDALONE_DIR" + fi + echo -e "${GREEN}Uninstallation complete!${NC}" echo "" echo "Note: Log file preserved at: $LOG_FILE" @@ -60,10 +84,86 @@ if [ "$1" == "--uninstall" ]; then exit 0 fi +# --------------------------------------------------------------------------- +# Where are we running from, and what are we watching? +# --------------------------------------------------------------------------- + +# Piped through bash (curl | sudo bash) leaves BASH_SOURCE pointing at stdin, +# so the presence of watchdog.py next to us is what tells the two apart. +SELF_DIR="" +if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then + SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +# Find the folder holding docker-compose.yml - that is the mc-webui instance +find_instance_dir() { + local candidates=("$@") + local d + for d in "${candidates[@]}"; do + [ -n "$d" ] || continue + if [ -f "$d/docker-compose.yml" ] || [ -f "$d/docker-compose.yaml" ]; then + (cd "$d" && pwd) + return 0 + fi + done + return 1 +} + +fetch() { # fetch + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + error "Neither curl nor wget is available - cannot download $1" + fi +} + +if [ -n "$SELF_DIR" ] && [ -f "$SELF_DIR/watchdog.py" ]; then + # Repository install: run the script straight out of the checkout, so it + # stays in step with the rest of the code on every git pull. + INSTALL_MODE="repository" + SCRIPT_DIR="$SELF_DIR" + MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +else + INSTALL_MODE="standalone" + SCRIPT_DIR="$STANDALONE_DIR" + + # $HOME is root's under sudo, so the invoking user's home is the one worth + # looking in - that is where the README tells people to put mc-webui. + SUDO_HOME="" + if [ -n "${SUDO_USER:-}" ]; then + SUDO_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)" + fi + + MCWEBUI_DIR="$(find_instance_dir \ + "${MCWEBUI_DIR:-}" \ + "$(pwd)" \ + "${SUDO_HOME:+$SUDO_HOME/mc-webui}" \ + "$HOME/mc-webui" \ + "/opt/mc-webui")" || error \ + "Cannot find your mc-webui folder (no docker-compose.yml). Run this from that folder, or set MCWEBUI_DIR=/path/to/mc-webui." +fi + # Install info "Installing ${SERVICE_NAME}..." +info " Install mode: $INSTALL_MODE" info " mc-webui directory: $MCWEBUI_DIR" +if [ "$INSTALL_MODE" == "standalone" ]; then + info " Downloading from branch: $MC_BRANCH" + mkdir -p "$SCRIPT_DIR" + + # Into a temp file first, so a failed download cannot truncate a working + # install of the service that is running right now. + tmp="$(mktemp)" + fetch "$RAW_BASE/watchdog.py" "$tmp" || error "Failed to download watchdog.py from $RAW_BASE" + [ -s "$tmp" ] || error "Downloaded watchdog.py is empty" + mv "$tmp" "$SCRIPT_DIR/watchdog.py" + chmod 644 "$SCRIPT_DIR/watchdog.py" + info " Downloaded watchdog.py" +fi + # Check if watchdog.py exists if [ ! -f "$SCRIPT_DIR/watchdog.py" ]; then error "watchdog.py not found in $SCRIPT_DIR" @@ -74,6 +174,12 @@ if ! command -v docker &> /dev/null; then error "Docker is not installed or not in PATH" fi +# The watchdog drives 'docker compose' inside this folder, so a compose file +# has to be there - a git checkout without one is no use to it either. +if [ ! -f "$MCWEBUI_DIR/docker-compose.yml" ] && [ ! -f "$MCWEBUI_DIR/docker-compose.yaml" ]; then + error "No docker-compose.yml in $MCWEBUI_DIR - set MCWEBUI_DIR to your mc-webui folder" +fi + # Create log file with proper permissions if [ ! -f "$LOG_FILE" ]; then touch "$LOG_FILE" @@ -113,8 +219,10 @@ systemctl daemon-reload info "Enabling service..." systemctl enable "$SERVICE_NAME" +# restart, not start: re-running the installer is how you upgrade a +# standalone install, and a running service would otherwise keep the old code info "Starting service..." -systemctl start "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" # Wait a moment for service to start sleep 3 @@ -126,7 +234,7 @@ if systemctl is-active --quiet "$SERVICE_NAME"; then # Test health endpoint if command -v curl &> /dev/null; then HEALTH=$(curl -s http://127.0.0.1:5051/health 2>/dev/null || echo "") - if echo "$HEALTH" | grep -q '"status":"ok"'; then + if echo "$HEALTH" | grep -q '"status": *"ok"'; then info "Health check passed!" else warn "Health check failed - service may still be starting" @@ -153,6 +261,10 @@ echo " sudo journalctl -u $SERVICE_NAME -f # View service logs" echo " tail -f $LOG_FILE # View watchdog logs" echo " curl http://localhost:5051/status # Check container status" echo " curl http://localhost:5051/history # View restart history" -echo " sudo $0 --uninstall # Uninstall" +if [ "$INSTALL_MODE" == "standalone" ]; then + echo " curl -fsSL $RAW_BASE/install.sh | sudo bash -s -- --uninstall" +else + echo " sudo $0 --uninstall # Uninstall" +fi echo "" echo "Diagnostic files are saved to /tmp/mc-webui-watchdog-*.log"