feat(https): optional HTTPS front end via Nginx Proxy Manager

Adds an opt-in HTTPS layer without touching anything for existing installs.

The proxy is a service in docker-compose.yml behind the "https" Compose
profile, activated with COMPOSE_PROFILES=https in .env. That keeps the whole
setup inside files git owns while the switch lives in a file it ignores --
important because scripts/update.sh runs `git pull`, so any user edit to a
tracked compose file would break the next update with a merge conflict.
mcupdate needs no change: compose reads COMPOSE_PROFILES from .env itself.

Both services share the project's default network, so the proxy forwards to
http://mc-webui:5000 internally; MC_BIND_ADDRESS lets the plain-HTTP port be
restricted to loopback once HTTPS works, and MC_TRUST_PROXY turns on ProxyFix
so the app sees the real client address and scheme.

Also fixes copying over plain HTTP. navigator.clipboard only exists in a
secure context, and 9 call sites across 5 entry points used it with no
fallback, so copy buttons silently did nothing on a LAN address. They now
share clipboard-utils.js, loaded via _head_i18n.html (the one include every
entry point already has), which falls back to execCommand.

Verified locally against the real stack: self-signed cert uploaded to NPM,
proxy host created, app served over HTTPS with socket.io upgrading to a real
wss:// WebSocket (transport "websocket"), and the clipboard fallback checked
end-to-end by pasting back what it copied over http://.

Two findings from that run are documented in docs/https-setup.md:
NPM 2.15 dropped the default admin@example.com account for a setup wizard,
and its default 443 server now refuses connections without SNI -- which is
every connection made to a bare IP address. docker/npm-default-site.conf is
the opt-in way around that, mounted writable (a :ro mount aborts s6 init and
leaves nginx not listening at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-08-02 17:58:03 +02:00
parent a3fde55704
commit 05ec5fa13e
15 changed files with 502 additions and 51 deletions
+40
View File
@@ -72,6 +72,46 @@ FLASK_PORT=5000
# Debug mode (true/false) - use false in production
FLASK_DEBUG=false
# ============================================
# HTTPS (optional) - see docs/https-setup.md
# ============================================
# Off by default: mc-webui serves plain HTTP on FLASK_PORT and nothing else runs.
#
# Uncommenting the line below starts Nginx Proxy Manager alongside the app, which
# terminates HTTPS and forwards to it. You then configure the certificate in its
# web UI (http://<server>:81) - Let's Encrypt, or your own certificate file.
# HTTPS also unlocks browser features that require a secure context, such as
# installing mc-webui as an app on a phone.
# COMPOSE_PROFILES=https
# Ports published by the proxy. Change them if something else already uses 80/443
# on this host. Note that Let's Encrypt HTTP-01 validation needs the *public*
# port 80 to reach this container, so it only works with NPM_HTTP_PORT=80.
# NPM_HTTP_PORT=80
# NPM_HTTPS_PORT=443
# NPM_ADMIN_PORT=81
# Where the proxy keeps its configuration and issued certificates.
# Must NOT be a synced folder (Dropbox, Synology Drive) - it holds a SQLite database.
# NPM_DATA_DIR=./data/npm
# NPM_LETSENCRYPT_DIR=./data/letsencrypt
# Set to false only if your host has working IPv6.
# NPM_DISABLE_IPV6=true
# Pin the proxy image instead of following latest, e.g. jc21/nginx-proxy-manager:2.12.6
# NPM_IMAGE=jc21/nginx-proxy-manager:latest
# Let the app read the real client address and scheme from the proxy's
# X-Forwarded-* headers. Enable ONLY when reaching the app through the proxy;
# a client talking to FLASK_PORT directly can forge those headers.
# MC_TRUST_PROXY=true
# Restrict the plain-HTTP port to the machine itself, so the app is reachable
# only through HTTPS. Set this only AFTER the proxy works - otherwise you lock
# yourself out. Leave unset (0.0.0.0) to keep HTTP available as before.
# MC_BIND_ADDRESS=127.0.0.1
# ============================================
# System Configuration
# ============================================
+1
View File
@@ -461,6 +461,7 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
|----------|-------------|
| [User Guide](docs/user-guide.md) | Complete feature documentation |
| [Android App](docs/android-app.md) | Installing the Android companion app and connecting it to your instance |
| [HTTPS Setup](docs/https-setup.md) | Optional encrypted access via Nginx Proxy Manager (Let's Encrypt, self-signed, IP address) |
| [Architecture](docs/architecture.md) | Technical details, API reference |
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
| [Docker Installation](docs/docker-install.md) | How to install Docker on Debian/Ubuntu |
+5
View File
@@ -45,6 +45,11 @@ class Config:
MC_AUTO_RECONNECT = os.getenv('MC_AUTO_RECONNECT', 'true').lower() == 'true'
MC_LOG_LEVEL = os.getenv('MC_LOG_LEVEL', 'INFO')
# Reverse proxy: trust X-Forwarded-* headers (see docs/https-setup.md).
# Only enable when mc-webui is reachable exclusively through a proxy — a client
# talking to the port directly can forge those headers.
MC_TRUST_PROXY = os.getenv('MC_TRUST_PROXY', 'false').lower() == 'true'
# Flask server configuration
FLASK_HOST = os.getenv('FLASK_HOST', '0.0.0.0')
FLASK_PORT = int(os.getenv('FLASK_PORT', '5000'))
+9
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import Optional
from flask import Flask, request as flask_request
from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
from app import i18n
from app.config import config, runtime_config
from app.database import Database
@@ -228,6 +229,14 @@ def create_app():
app.config['DEBUG'] = config.FLASK_DEBUG
app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production'
# Behind a reverse proxy (Nginx Proxy Manager, see docs/https-setup.md) every
# request arrives from the proxy over plain HTTP, so without this the app sees
# the proxy's address as the client and thinks the scheme is http. One hop only:
# the proxy is the single trusted intermediary.
if config.MC_TRUST_PROXY:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
logger.info("Trusting X-Forwarded-* headers from one proxy hop (MC_TRUST_PROXY=true)")
# Inject version, branch, transport type, and UI language into all templates.
# This is the single injection point for i18n — it covers every render_template()
# in the app, including the six standalone pages loaded as fullscreen iframes.
+5 -13
View File
@@ -1870,7 +1870,7 @@ function showPathsPopup(element, encodedPaths, packetHash) {
copyBtn.title = t('common.copy_route');
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
copyTextToClipboard(commaRoute).then(() => {
copyBtn.className = 'bi bi-clipboard-check path-copy';
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
});
@@ -1889,7 +1889,7 @@ function showPathsPopup(element, encodedPaths, packetHash) {
entry.title = t('chat.copy_route_title');
entry.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
copyTextToClipboard(commaRoute).then(() => {
copyBtn.className = 'bi bi-clipboard-check path-copy';
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
});
@@ -1995,7 +1995,7 @@ function injectRawResendButtonsForVisibleMessages() {
*/
async function copyToClipboard(text, btnElement) {
try {
await navigator.clipboard.writeText(text);
await copyTextToClipboard(text);
const icon = btnElement.querySelector('i');
const originalClass = icon.className;
icon.className = 'bi bi-check';
@@ -5738,18 +5738,10 @@ async function shareChannel(index) {
async function copyChannelKey() {
const input = document.getElementById('shareChannelKey');
try {
// Use modern Clipboard API
await navigator.clipboard.writeText(input.value);
await copyTextToClipboard(input.value);
showNotification(t('channels.toast.key_copied'), 'success');
} catch (error) {
// Fallback for older browsers
input.select();
try {
document.execCommand('copy');
showNotification(t('channels.toast.key_copied'), 'success');
} catch (fallbackError) {
showNotification(t('channels.toast.copy_failed'), 'danger');
}
showNotification(t('channels.toast.copy_failed'), 'danger');
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Clipboard helper shared by every entry point.
*
* navigator.clipboard exists only in a secure context — HTTPS, or http://localhost.
* Opening mc-webui over plain HTTP on a LAN address (http://192.168.1.50:5000) is not
* one, so the API is simply undefined there and every copy button silently does
* nothing. This falls back to the pre-Clipboard-API textarea trick, which still works.
*
* Returns a Promise, so it is a drop-in replacement for navigator.clipboard.writeText().
*/
function copyTextToClipboard(text) {
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
// Even in a secure context this can reject (permissions, unfocused document),
// so keep the fallback on that path too.
return navigator.clipboard.writeText(text).catch(() => legacyClipboardWrite(text));
}
return legacyClipboardWrite(text);
}
/**
* Copy via a temporary textarea and document.execCommand('copy').
* Deprecated, but the only option outside a secure context.
*/
function legacyClipboardWrite(text) {
return new Promise((resolve, reject) => {
const textArea = document.createElement('textarea');
textArea.value = text;
// Off-screen but still selectable — display:none would make select() a no-op.
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.setAttribute('readonly', '');
document.body.appendChild(textArea);
try {
textArea.select();
textArea.setSelectionRange(0, textArea.value.length); // iOS needs the range
if (document.execCommand('copy')) {
resolve();
} else {
reject(new Error('execCommand("copy") returned false'));
}
} catch (err) {
reject(err);
} finally {
document.body.removeChild(textArea);
}
});
}
+6 -33
View File
@@ -1446,7 +1446,7 @@ async function approveContact(contact, index) {
}
function copyPublicKey(publicKey, buttonEl) {
navigator.clipboard.writeText(publicKey).then(() => {
copyTextToClipboard(publicKey).then(() => {
// Visual feedback
const originalHTML = buttonEl.innerHTML;
buttonEl.innerHTML = `<i class="bi bi-check"></i> ${tHtml('common.copied')}`;
@@ -2312,47 +2312,20 @@ function createExistingContactCard(contact, index) {
}
/**
* Copy text to clipboard with fallback for HTTP contexts.
* Copy text to clipboard with visual feedback on the given element.
* The HTTP fallback lives in clipboard-utils.js, shared by every page.
* @param {string} text - Text to copy
* @param {HTMLElement} element - Element for visual feedback
*/
function copyToClipboard(text, element) {
const originalText = element.textContent;
// Try modern clipboard API first (requires HTTPS)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showCopyFeedback(element, originalText);
}).catch(() => {
// Fallback to legacy method
legacyCopy(text, element, originalText);
});
} else {
// Fallback for HTTP contexts
legacyCopy(text, element, originalText);
}
}
/**
* Legacy copy method using execCommand (works on HTTP).
*/
function legacyCopy(text, element, originalText) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
copyTextToClipboard(text).then(() => {
showCopyFeedback(element, originalText);
} catch (err) {
}).catch((err) => {
console.error('Failed to copy:', err);
showToast(t('contacts.toast.copy_failed'), 'danger');
}
document.body.removeChild(textArea);
});
}
/**
+2 -2
View File
@@ -1058,7 +1058,7 @@ function populateContactInfoModal() {
keyDiv.title = t('dm.copy_pubkey_title');
keyDiv.onclick = () => {
const pk = contact.public_key || contact.public_key_prefix || '';
navigator.clipboard.writeText(pk).then(() => {
copyTextToClipboard(pk).then(() => {
showNotification(t('contacts.toast.pubkey_copied'), 'info');
}).catch(() => {});
};
@@ -1568,7 +1568,7 @@ function showDmRoutePopup(element, hexPath, hashSize) {
entry.title = t('chat.copy_route_title');
entry.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
copyTextToClipboard(commaRoute).then(() => {
const orig = entry.innerHTML;
entry.innerHTML = `<span style="opacity:0.8">${tHtml('common.copied')}</span>`;
setTimeout(() => { entry.innerHTML = orig; }, 1000);
+1 -1
View File
@@ -195,7 +195,7 @@ function paFormatTime(msg) {
}
function paCopyText(text, label) {
navigator.clipboard.writeText(text).then(
copyTextToClipboard(text).then(
() => showNotification(t('pa.toast.copied', { what: label }), 'success'),
() => showNotification(t('pa.toast.copy_failed', { what: label }), 'danger')
);
+1 -1
View File
@@ -1850,7 +1850,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('copyPubkeyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey);
await copyTextToClipboard(_repeater ? _repeater.public_key : _pubkey);
showNotification(t('rptmgmt.pubkey_copied'), 'info');
} catch (e) {
showNotification(t('common.copy_failed'), 'warning');
+1
View File
@@ -9,3 +9,4 @@
<script src="{{ i18n_catalog_url }}"></script>
<script src="{{ url_for('static', filename='js/i18n-runtime.js') }}"></script>
<script src="{{ url_for('static', filename='js/datetime-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/clipboard-utils.js') }}"></script>
+29 -1
View File
@@ -4,8 +4,10 @@ services:
build: .
container_name: mc-webui
restart: unless-stopped
# MC_BIND_ADDRESS=127.0.0.1 keeps the plain-HTTP port off the network once
# HTTPS is up (docs/https-setup.md). Default 0.0.0.0 = reachable as before.
ports:
- "${FLASK_PORT:-5000}:${FLASK_PORT:-5000}"
- "${MC_BIND_ADDRESS:-0.0.0.0}:${FLASK_PORT:-5000}:${FLASK_PORT:-5000}"
# Grant access to serial devices for auto-detection
# Major 188 = ttyUSB (CP2102, CH340), Major 166 = ttyACM (ESP32-S3)
device_cgroup_rules:
@@ -32,6 +34,7 @@ services:
- FLASK_HOST=${FLASK_HOST:-0.0.0.0}
- FLASK_PORT=${FLASK_PORT:-5000}
- FLASK_DEBUG=${FLASK_DEBUG:-false}
- MC_TRUST_PROXY=${MC_TRUST_PROXY:-false}
- TZ=${TZ:-UTC}
env_file:
- path: .env
@@ -42,3 +45,28 @@ services:
timeout: 10s
retries: 3
start_period: 15s
# Optional HTTPS front end — Nginx Proxy Manager.
# Inert unless the "https" profile is active, so existing installs are untouched.
# Enable it by putting COMPOSE_PROFILES=https in .env, then run the usual
# `docker compose up -d --build` (or mcupdate). Full guide: docs/https-setup.md
#
# Both services share this project's default network, so the proxy reaches the
# app at http://mc-webui:5000 without the app publishing any port at all.
npm:
image: ${NPM_IMAGE:-jc21/nginx-proxy-manager:latest}
container_name: mc-webui-proxy
profiles: ["https"]
restart: unless-stopped
depends_on:
- mc-webui
ports:
- "${NPM_HTTP_PORT:-80}:80" # HTTP — also the Let's Encrypt HTTP-01 challenge
- "${NPM_HTTPS_PORT:-443}:443" # HTTPS
- "${NPM_ADMIN_PORT:-81}:81" # Proxy Manager admin UI
volumes:
- "${NPM_DATA_DIR:-./data/npm}:/data"
- "${NPM_LETSENCRYPT_DIR:-./data/letsencrypt}:/etc/letsencrypt"
environment:
- TZ=${TZ:-UTC}
- DISABLE_IPV6=${NPM_DISABLE_IPV6:-true}
+40
View File
@@ -0,0 +1,40 @@
# Replacement for Nginx Proxy Manager's built-in /etc/nginx/conf.d/default.conf.
#
# Only needed to reach mc-webui by bare IP address over HTTPS — see the
# "Browsing by IP address" section of docs/https-setup.md. Not used unless you
# mount it yourself; the stock file is fine for every hostname-based setup.
#
# Why: browsers do not send SNI when you type an IP address, so nginx falls back
# to the default server for port 443. Since 2.15 that default is a block ending in
# `ssl_reject_handshake on;`, which refuses the connection outright. This copy of
# the file keeps the port 80 fallback and drops that 443 block, so the first (or
# only) proxy host becomes the default and answers with its own certificate —
# the behaviour older releases had.
#
# Mount it WITHOUT :ro — the container chowns its config at startup, and on a read-only
# mount that failure aborts s6 init, leaving nginx not listening at all.
#
# This shadows a file that ships inside the image. If a future Nginx Proxy Manager
# release changes it, this copy keeps winning: unmount it to go back to stock.
# "You are not configured" page, which is the default if another default doesn't exist
server {
listen 80;
#listen [::]:80;
set $forward_scheme "http";
set $server "127.0.0.1";
set $port "80";
server_name localhost-nginx-proxy-manager;
access_log /data/logs/fallback_http_access.log standard;
error_log /data/logs/fallback_http_error.log warn;
include conf.d/include/assets.conf;
include conf.d/include/block-exploits.conf;
include conf.d/include/letsencrypt-acme-challenge.conf;
location / {
index index.html;
root /var/www/html;
}
}
+312
View File
@@ -0,0 +1,312 @@
# HTTPS Setup
By default mc-webui speaks plain HTTP. This guide turns on an optional HTTPS front end
using [Nginx Proxy Manager](https://nginxproxymanager.com/) (NPM) — a small web UI where
you point-and-click your way to a certificate, instead of editing nginx config files.
Everything here is opt-in. If you skip this document, nothing about your installation
changes.
## Why bother
- **Encrypted traffic.** Nobody on your network reads your messages or your device
configuration in transit.
- **Copy buttons, and more, start working.** Browsers restrict a set of features to a
*secure context* — HTTPS, or `http://localhost`. Open mc-webui over plain HTTP at
`http://192.168.1.50:5000` and the modern clipboard API simply does not exist, so copy
buttons fall back to an older, less reliable method. The same applies to installing
mc-webui as an app on a phone, and to browser notifications.
- **A real hostname.** `https://mesh.example.com` instead of an IP and a port number.
- **Optional password protection.** NPM's *Access Lists* can put HTTP authentication in
front of mc-webui, which has no login of its own.
## How it fits together
```
browser ──HTTPS(443)──► nginx-proxy-manager ──HTTP──► mc-webui ──► MeshCore device
(container: mc-webui-proxy) (container: mc-webui)
```
Both containers belong to the same Compose project, so they share a private Docker
network. The proxy reaches the app as `http://mc-webui:5000` over that network — the app
does not need to publish a port at all, and the traffic between them never leaves the
host.
---
## Step 1 — Turn it on
Edit `.env` in your mc-webui directory (create it from `.env.example` if you have none)
and add:
```ini
COMPOSE_PROFILES=https
```
Then start it the usual way:
```bash
docker compose up -d --build
```
That is the whole installation. The proxy is defined in the project's `docker-compose.yml`
behind a Compose *profile*, so it stays completely inert until that line exists — and
`mcupdate` picks it up automatically from then on. **Do not edit `docker-compose.yml`
yourself**: it is tracked in git, and a local edit will make the next update fail with a
merge conflict.
Check that both containers are up:
```bash
docker compose ps
```
You should see `mc-webui` and `mc-webui-proxy`.
> **Port 80 or 443 already in use?** If something else on the host owns those ports,
> `docker compose up` fails with `address already in use`. Either stop the other service,
> or move the proxy's ports with `NPM_HTTP_PORT` / `NPM_HTTPS_PORT` in `.env`. Note that
> Let's Encrypt's HTTP validation requires the public port 80, so moving it rules that
> method out — use the DNS method (Option B) instead.
## Step 2 — Log in to the proxy admin UI
Open `http://<your-server>:81`. On first run you get a short setup form where you create
your own administrator account — name, email and password. There is no default login to
change afterwards.
Keep this panel on the local network and **do not forward port 81 through your router**:
it can route traffic anywhere inside your network, and it is served over plain HTTP.
> Older Nginx Proxy Manager releases (before 2.15) instead shipped a built-in
> `admin@example.com` / `changeme` account that you were expected to change on first
> login. If you pinned an older image with `NPM_IMAGE`, that is what you will see.
## Step 3 — Create the proxy host
Go to **Hosts → Proxy Hosts → Add Proxy Host** and fill in the **Details** tab:
| Field | Value |
|---|---|
| Domain Names | `mesh.example.com` — your hostname, or the server's IP address |
| Scheme | `http` |
| Forward Hostname / IP | `mc-webui` |
| Forward Port | `5000` |
| Cache Assets | off |
| Block Common Exploits | on |
| **Websockets Support** | **on — this one is not optional** |
> ### Websockets Support must be on
>
> mc-webui pushes new messages to open pages over a WebSocket. Without this switch, nginx
> does not forward the connection upgrade, the page silently drops back to long-polling,
> and a browser only allows six long-polling connections per address **across all tabs**.
> Two or three open tabs then exhaust that budget and the interface crawls — clicks take
> ten or twenty seconds while the server sits idle. It looks exactly like an overloaded
> server and it is not one. If you have seen that symptom before, this is the same bug
> coming back through the proxy.
Save. `http://mesh.example.com` should now show mc-webui. Certificates come next.
## Step 4 — Choose a certificate
Three routes, depending on whether you have a domain name and whether your server is
reachable from the internet.
### Option A — Let's Encrypt with a public domain (the easy case)
**Requires:** a domain name pointing at your public IP, and port 80 forwarded from your
router to this host.
Edit the proxy host → **SSL** tab → SSL Certificate: *Request a new SSL Certificate*
enable **Force SSL** and **HTTP/2 Support** → agree to the terms → Save.
That is it. The certificate is trusted by every browser and phone with no warnings, and
NPM renews it automatically.
### Option B — Let's Encrypt with a DNS challenge (best for LAN-only servers)
**This is the option most home installations want.** It gives you a fully trusted
certificate on a server that is *not* exposed to the internet at all — no port
forwarding, nothing reachable from outside.
The trick: Let's Encrypt proves you own the *domain* by having you write a token into its
DNS, and never connects to your server. Nothing stops that domain's A record from
pointing at a private address such as `192.168.1.50`.
**Requires:** a domain you own, hosted at a DNS provider NPM supports (Cloudflare,
deSEC, DuckDNS, Hetzner, OVH, and around a hundred others), and an API token from it.
1. In your DNS, point `mesh.example.com` at the server's LAN address, e.g. `192.168.1.50`.
2. Edit the proxy host → **SSL** tab → *Request a new SSL Certificate*.
3. Enable **Use a DNS Challenge**, pick your provider and paste its API credentials.
4. Enable **Force SSL**, save, and give it a minute or two for DNS to propagate.
Certificate renewal is automatic and equally invisible. Everyone on the LAN reaches
`https://mesh.example.com` with a green padlock, including phones and the Android app.
### Option C — Self-signed, or a private CA (no domain at all)
**Use when you only ever reach the server by IP address**, e.g. `https://192.168.1.50`.
First, the part that is worth being clear about: **no public certificate authority will
ever issue a certificate for a private address** like `192.168.1.50` — nobody can prove
they own an address that exists identically inside every home network on earth. Let's
Encrypt has recently begun issuing certificates for *public* IP addresses, but those are
short-lived and NPM's certificate integration does not support them. So for a LAN
address, the choice is a certificate you sign yourself.
NPM cannot generate one, so create it on the host and upload it. **The `subjectAltName`
line is what matters** — browsers ignore the old Common Name entirely, and a certificate
without a matching SAN is rejected outright:
```bash
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-keyout mc-webui.key -out mc-webui.crt \
-subj "/CN=mc-webui" \
-addext "subjectAltName=IP:192.168.1.50,DNS:mc-webui.local" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth"
```
Replace the IP with your server's. List every name and address you will actually type
into the browser — each one needs to be in that SAN list.
Then in NPM: **SSL Certificates → Add SSL Certificate → Custom**, upload `mc-webui.key`
as the key and `mc-webui.crt` as the certificate (leave the intermediate field empty),
and select it on the proxy host's SSL tab.
**What you get:** real encryption, and the secure-context browser features work. **What
you do not get:** a green padlock. Every browser shows an interstitial warning the first
time, which you click through once per browser.
If the warnings bother you, use **[mkcert](https://github.com/FiloSottile/mkcert)**
instead. It creates a small certificate authority of your own, and installs it into the
trust store of the machines you choose — then those machines see no warning at all:
```bash
mkcert -install # once, per machine that should trust it
mkcert 192.168.1.50 mc-webui.local # produces a .pem cert and key to upload
```
### Browsing by IP address needs one extra step
Put the IP straight into the proxy host's *Domain Names* field — NPM accepts it. That is
not quite enough on its own, though, and the reason is worth understanding.
When you type a hostname, the browser tells the server which name it is asking for, as
part of setting up the encrypted connection (SNI). When you type an IP address it sends
nothing — the standard forbids it. Nginx then falls back to a *default* server for
port 443, and since version 2.15 Nginx Proxy Manager ships one whose entire job is to
refuse such connections. The browser shows a connection error before it ever gets as far
as a certificate warning.
Two ways out.
**The clean one: give the server a name.** Add a DNS entry on your router (or Pi-hole,
or whatever serves DNS on your network) pointing e.g. `mesh.lan` at the server, and use
that. One entry covers every device, including phones — where you cannot edit a hosts
file. Everything then works as described above, and this is worth doing anyway.
**The direct one: let the proxy answer unnamed requests.** This project ships a
replacement for that default server at [`docker/npm-default-site.conf`](../docker/npm-default-site.conf)
— same file with the refusing block removed, so your proxy host answers instead and
presents its certificate. Enable it by creating `docker-compose.override.yml` next to
`docker-compose.yml`:
```yaml
services:
npm:
volumes:
- "./docker/npm-default-site.conf:/etc/nginx/conf.d/default.conf"
```
Then `docker compose up -d`. That filename is deliberately ignored by git, so it survives
updates untouched.
> **Do not add `:ro` to that line.** The proxy container adjusts ownership of its config
> files at startup; on a read-only mount that fails, and it aborts the whole startup —
> the container comes up but nginx never starts listening, so *everything* refuses
> connections. It is a confusing failure to debug. Mount it writable, as above.
## Step 5 — Tighten it up (optional)
Once HTTPS works, three optional steps make it the only way in.
**Force HTTPS.** On the proxy host's SSL tab, enable **Force SSL** so plain HTTP requests
redirect. Leave HSTS off until you are sure everything works — HSTS is remembered by
browsers for months and is awkward to undo.
**Let the app see the real client.** In `.env`:
```ini
MC_TRUST_PROXY=true
```
Without it, mc-webui sees every request as coming from the proxy container and believes
the connection is plain HTTP. With it, the log shows real client addresses. Enable it
**only** when the app is reached through the proxy — anyone able to talk to port 5000
directly can forge those headers.
**Close the plain HTTP port.** Also in `.env`:
```ini
MC_BIND_ADDRESS=127.0.0.1
```
Port 5000 then answers only on the machine itself, so the network sees nothing but HTTPS.
The proxy is unaffected — it reaches the app over the internal Docker network. Set this
**after** HTTPS works, not before, or you will lock yourself out. To undo it, delete the
line and run `docker compose up -d`.
Apply either change with `docker compose up -d`.
## The Android app
The [Android wrapper](android-app.md) accepts both `http://` and `https://` addresses, but
it **rejects certificates it does not trust** and shows an SSL error instead of the page.
In practice:
- Options A and B work perfectly — enter `https://mesh.example.com` and you are done.
- Option C (self-signed) does **not** work in the app. Android's WebView does not trust
certificates you added to the phone manually, and there is no way around that from
inside the app. Use the app over `http://` on the LAN, or move to Option B.
## Updating and removing
**Updating** needs nothing special — `mcupdate` (or `docker compose up -d --build`) pulls
both containers as usual. The proxy's configuration and certificates live in
`./data/npm` and `./data/letsencrypt` and survive rebuilds.
**Removing HTTPS:** delete or comment out `COMPOSE_PROFILES=https` in `.env`, then:
```bash
docker compose --profile https down
docker compose up -d
```
Also remove `MC_BIND_ADDRESS` and `MC_TRUST_PROXY` if you set them, or the app stays
unreachable from the network. The proxy's data directories are left in place; delete
`./data/npm` and `./data/letsencrypt` by hand if you want them gone.
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The interface crawls with 23 tabs open; clicks take 1020 s | **Websockets Support** is off on the proxy host. Turn it on (Step 3). |
| `502 Bad Gateway` | The app container is down or still starting — `docker compose ps`, then `docker compose logs mc-webui`. Also check Forward Hostname is `mc-webui` (the container name), not `localhost`: inside the proxy container, `localhost` is the proxy itself. |
| `address already in use` on startup | Something else on the host holds port 80 or 443. Stop it, or set `NPM_HTTP_PORT` / `NPM_HTTPS_PORT`. |
| Let's Encrypt fails with a connection or timeout error | HTTP-01 validation could not reach port 80 from the internet. Check the router forward, or switch to a DNS challenge (Option B). |
| Browser warns about the certificate even after installing a private CA | The address you typed is not in the certificate's `subjectAltName`. Reissue with every name and IP you use. |
| The Android app shows an SSL error | A self-signed certificate — the app cannot trust it. See the Android section above. |
| HTTPS works by hostname but not by IP address | The proxy refuses connections that carry no hostname. See "Browsing by IP address" above. |
| After mounting `npm-default-site.conf`, *nothing* answers — not even port 81 | The mount has `:ro`. Remove it and run `docker compose up -d`. |
| Locked out after setting `MC_BIND_ADDRESS` | On the server itself: remove the line from `.env` and run `docker compose up -d`. |
| Proxy container keeps restarting | Check `docker compose logs npm`. A common cause is `./data/npm` sitting on a synced folder (Synology Drive, Dropbox) — the sync client corrupts its SQLite database. Move it with `NPM_DATA_DIR`. |
## See also
- [Nginx Proxy Manager documentation](https://nginxproxymanager.com/guide/)
- [architecture.md](architecture.md) — how mc-webui itself is put together
- [android-app.md](android-app.md) — the Android wrapper
+2
View File
@@ -12,12 +12,14 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### Features
- **You can now reach mc-webui over HTTPS.** Until now the interface was served over plain HTTP only, which meant the address bar warned it was not secure, and — less obviously — that browsers quietly withheld a set of features they reserve for encrypted connections. There is now an optional HTTPS front end you switch on with a single line in `.env`; it starts [Nginx Proxy Manager](https://nginxproxymanager.com/) next to the app, and you pick your certificate in its web interface rather than editing configuration files. Three routes are covered: a free Let's Encrypt certificate for a public domain, a Let's Encrypt certificate via a DNS challenge — the one worth knowing about, because it gives a fully trusted certificate on a server that is not exposed to the internet at all, with no port forwarding — and a self-signed certificate for reaching the server by its IP address. Existing installations are untouched: nothing starts and nothing changes unless you ask for it, and updates keep working exactly as before. The guide is [https-setup.md](https-setup.md), including which switch to be careful about (Websockets Support — leave it off and several open tabs will crawl) and why a self-signed certificate cannot work with the Android app.
- **The interface can be translated, and Polish has started.** mc-webui was written English-only, with every label and message baked into the code. There is now a translation system behind it, and a **Language** setting at the top of Settings → Appearance. It covers the whole interface: the main window — menu, chat, message bubbles, its dialogs and **Settings** — every panel that opens in its own window (**System Log**, **Console**, **My Repeaters**, **Path Analyzer**, **Contacts**, **Direct Messages**), and the running commentary too: the small toasts after every action, the confirmation dialogs, the search results, the update flow and the notifications your phone shows when the app is in the background. Error text that comes back from the server itself is still English — that is the one part left, and it is a separate job. Your choice applies to the browser you set it in and also becomes the default for anyone else opening the server without a preference of their own.
- **You can add a language yourself, without waiting for a release.** A language is a single file. Copy `en.json` from `app/translations/`, translate the values, and drop it into a `translations` folder inside your config directory — the same place the database lives. Refresh the page and it appears in the Language list, named however you named it, with no rebuild and no restart. Anything you leave untranslated falls back to English, so a half-finished translation is perfectly usable. A file you drop in also overrides one that ships with the app, so you can correct the built-in Polish on your own server. English and Polish ship in the box; everything else is open to whoever wants to write it. See [translations.md](translations.md), which explains the format and — importantly — which words to leave alone: mesh terms like flood, hop, advert, RSSI and the repeater roles stay English in every language, because that is what the firmware, the CLI and the forums all use. The Console and the log lines themselves stay English for the same reason.
- **Times and dates behave the same in every language.** Only the words are translated — "Yesterday" becomes "Wczoraj", "5 min ago" becomes "5 min temu". The clock and the number formatting keep following your browser's own settings, so switching the menus to English will not suddenly turn your 24-hour clock into "9:53 AM". One display of large numbers on the repeater statistics page had been hard-coded to American thousands separators; it now follows your locale like everything else.
### Fixes
- **Copy buttons work over plain HTTP again.** Copying a public key, a channel key or a routing path did nothing on some pages when mc-webui was open over `http://` on a network address — no error, no text on the clipboard, just a button that appeared to do nothing. Browsers only offer the modern clipboard function to pages served over HTTPS (or opened on the machine itself), and several of those buttons had no fallback for anything else. They all share one now, so copying works over HTTP everywhere in the interface. Serving the interface over [HTTPS](https-setup.md) removes the restriction at the source.
- **The Android app no longer renders text larger than the browser does.** The app is a wrapper around the same page you open in Chrome, but Android's WebView quietly scales text by your phone's system font-size setting while Chrome ignores it and uses its own. If you had enlarged the system font even one step, everything in the app came out around 20% bigger than on the website — the title truncated to "mc-web…", longer messages wrapped earlier, and the connection status and refresh time at the bottom broke across two lines each. The app now pins text at the same size the browser uses. This is **app version 1.2** — install the new `.apk` over the one you have to pick it up; it is signed with the same key, so it goes in as an update and keeps your server address. See [android-app.md](android-app.md).
- **The bar at the bottom copes with text that does not fit.** The connection status, the region badge and the "Updated:" time sat in a row that could not wrap, so when they ran out of room each one broke apart internally instead — a green dot with "Connected" stranded underneath it, a time split from its own label. They now drop onto a second line as whole items. This mostly showed up in translated interfaces, where the labels are longer than the English ones they were laid out for ("Aktualizacja:" against "Updated:"), and on narrow phones.