feat(updater): one-click updates for Docker Hub installations

The version check already worked for an installation running the published
image - CI freezes the version before building, so version_frozen.py ships
inside it - but "Update now" never appeared, because the webhook behind it
was installed from scripts/, a folder such an installation does not have.
And update.sh could not have served it anyway: it does git pull + rebuild.

- both installers accept being piped through bash (curl | sudo bash). They
  then download what they need into /opt/mc-webui-{updater,watchdog} and
  find the instance by looking for docker-compose.yml. A checkout install
  keeps running its scripts from the checkout, unchanged
- updater.py picks the update path per request: git checkout -> update.sh,
  bare docker-compose.yml -> the new update-image.sh (pull + recreate).
  Resolved per request, so cloning the repo later flips it without a restart
- both update scripts end with MC_UPDATE_RESULT=updated|no_change, which
  the webhook parses and /api/updater/status passes on. The UI used to
  watch only for the version to change and then claim it timed out after
  two minutes - the normal outcome right after a push, while the image for
  that commit is still building. It now says so as soon as the run ends,
  in a neutral state rather than a red cross
- the marker prints only when stdout is not a terminal, so mcupdate output
  stays as it was

Also corrects the claim, in the README and in updater.py's own docstring,
that the webhook listens on localhost. It binds 0.0.0.0 - the container
reaches it over the Docker bridge - and sends no token, so port 5050 is
worth keeping off untrusted networks.

Verified: update-image.sh reports updated then no_change against a real
docker compose project; the standalone installer installs, runs and
uninstalls cleanly under systemd (WSL), with the app reaching it through
host.docker.internal and a triggered run coming back changed=false; the
resulting modal checked in a browser in both en and pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-09-09 18:53:35 +02:00
1 parent 59ef0dd6be
commit 60faa44d37
13 files changed
+681 -56

No files matched your search

+38 -6
View File
@@ -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.
---
+25 -2
View File
@@ -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:
+61 -5
View File
@@ -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;
+2
View File
@@ -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",
+2
View File
@@ -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ę",
+4 -1
View File
@@ -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
```
+19 -1
View File
@@ -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:
+11
View File
@@ -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 '<', "<!DOCTYPE "... is not valid JSON`. It now says what actually happened, and what to do about it: that a login page answered and the page needs reloading and signing in again, or that something in front of the app answered, with the HTTP status quoted for anyone diagnosing it. Requests are read the same way everywhere now, so a genuine error from the app itself keeps its own message — a repeater that did not answer in time still says exactly that.
### Features
+25 -1
View File
@@ -40,6 +40,7 @@ echo ""
# Step 1: Git pull
info "Pulling latest changes from Git..."
BEFORE_HEAD="$(git rev-parse HEAD 2>/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
+132 -19
View File
@@ -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 <same url> | 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 <url> <destination>
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
+140
View File
@@ -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
+102 -13
View File
@@ -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)
+120 -8
View File
@@ -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 <same url> | 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 <url> <destination>
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"