From 6d132f53fff819b742aba83545e5af0eb15c1737 Mon Sep 17 00:00:00 2001 From: Franzz Date: Mon, 14 Sep 2026 21:57:16 +0200 Subject: [PATCH] Serve the panel from a sub-path, and an Apache conf for /heos The app assumed it lived at the server root: its links were /static/... and its fetches /api/..., which behind a proxy at /heos resolve to the wrong place, so the page would load and nothing on it would work. It now takes the prefix from X-Forwarded-Prefix, and everything it generates -- stylesheet, icons, the manifest's start_url, every fetch -- follows. Nothing changes when it is served from its own port. deploy/heos.conf is the Apache side, restricted to the local network by default, since this controls the speakers and the vhost it hangs off has a public certificate. Co-Authored-By: Claude Opus 5 --- README.md | 29 +++++++++++++++++++++++++++++ app.py | 14 ++++++++++++-- deploy/heos.conf | 31 +++++++++++++++++++++++++++++++ static/app.js | 4 +++- templates/index.html | 2 +- templates/manifest.webmanifest | 6 +++--- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 deploy/heos.conf diff --git a/README.md b/README.md index 93725be..c3ec34c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,35 @@ WantedBy=multi-user.target sudo systemctl enable --now heos-panel ``` +## Behind Apache, at /heos + +`deploy/heos.conf` reverse-proxies `/heos` to the panel: + +```bash +sudo a2enmod proxy proxy_http headers +sudo cp deploy/heos.conf /etc/apache2/conf-available/heos.conf +sudo a2enconf heos +sudo apachectl configtest && sudo systemctl reload apache2 +``` + +It ships restricted to the local network — it controls the speakers, and +it usually hangs off a vhost with a public certificate. Delete the +`RequireAny` block to open it up. + +The app works at either address without being told which. Apache sends +`X-Forwarded-Prefix: /heos`, and every URL the app generates — stylesheet, +icons, the manifest's `start_url`, every `fetch` — picks up that prefix. +Serve it straight from port 5005 and the same URLs come out as `/...`. +That header is what the `headers` module is for; without it the page loads +and nothing on it works. + +Two things worth knowing: + +- The `` block takes `/heos` away from the filesystem, so the + source under `/var/www/html/heos` stops being served as static files. +- The panel still answers directly on `:5005`. Start it with + `--host 127.0.0.1` if you want Apache to be the only way in. + ## How the grouping actually works Worth knowing, because HEOS makes two things easy to get wrong. diff --git a/app.py b/app.py index 2b318be..4714d6d 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ import argparse from functools import wraps from flask import Flask, jsonify, render_template, request +from werkzeug.middleware.proxy_fix import ProxyFix import config from avr import AvrError @@ -20,6 +21,13 @@ from controller import Controller, TargetError from heos import HeosError app = Flask(__name__) + +# Served straight from port 5005 this changes nothing. Behind a reverse +# proxy that mounts us on a sub-path (Apache at /heos, say) it reads the +# X-Forwarded-Prefix that proxy sets, so every URL the app generates is +# /heos/... instead of /..., and the page works either way. +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + controller: Controller = None @@ -320,6 +328,9 @@ def main(): global controller parser = argparse.ArgumentParser(description="HEOS panel") parser.add_argument("--port", type=int, default=config.WEB_PORT) + parser.add_argument("--host", default="0.0.0.0", + help="0.0.0.0 so your phone can reach it directly; " + "127.0.0.1 to allow only a local reverse proxy") parser.add_argument("--demo", action="store_true", help="run with fake speakers, for working on the UI away from the kit") args = parser.parse_args() @@ -330,8 +341,7 @@ def main(): else: controller = Controller(config) - # 0.0.0.0 so your phone can reach it over the LAN. - app.run(host="0.0.0.0", port=args.port, threaded=True) + app.run(host=args.host, port=args.port, threaded=True) if __name__ == "__main__": diff --git a/deploy/heos.conf b/deploy/heos.conf new file mode 100644 index 0000000..a05162e --- /dev/null +++ b/deploy/heos.conf @@ -0,0 +1,31 @@ +# HEOS panel behind Apache, at /heos +# +# sudo a2enmod proxy proxy_http headers +# sudo cp /var/www/html/heos/deploy/heos.conf /etc/apache2/conf-available/heos.conf +# sudo a2enconf heos +# sudo apachectl configtest && sudo systemctl reload apache2 +# +# Apache reaches the panel on port 5005 (WEB_PORT in config.py). If you +# want it reachable ONLY through Apache, start it with --host 127.0.0.1; +# by default it also answers directly on the LAN at :5005. +# This block also takes /heos away from the filesystem, so the source in +# /var/www/html/heos stops being reachable as static files. + + + # The panel controls the speakers, and it hangs off a vhost with a + # public certificate. Keep it to the house unless you mean otherwise: + # delete this RequireAny block to let it answer from anywhere. + + Require ip 192.168.0.0/24 + Require ip 127.0.0.1 + Require ip ::1 + + + # Tells the app it is mounted on a sub-path, so every link, icon and + # fetch it generates is /heos/... rather than /... Without this the + # page loads and nothing on it works. + RequestHeader set X-Forwarded-Prefix /heos + + ProxyPass http://127.0.0.1:5005 + ProxyPassReverse http://127.0.0.1:5005 + diff --git a/static/app.js b/static/app.js index 45c7f4e..298f252 100644 --- a/static/app.js +++ b/static/app.js @@ -4,6 +4,8 @@ const STEP = Number(document.documentElement.dataset.step) || 2; const HOST_LABEL = document.documentElement.dataset.host || 'the AVR'; +// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. +const BASE = document.documentElement.dataset.base || '/'; const POLL_MS = 5000; const el = (sel, root = document) => root.querySelector(sel); @@ -32,7 +34,7 @@ async function api(path, body) { const options = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; - const response = await fetch(path, options); + const response = await fetch(BASE + path.replace(/^\//, ''), options); let data = {}; try { data = await response.json(); } catch (_) { /* empty or not JSON */ } if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`); diff --git a/templates/index.html b/templates/index.html index 0d6aea6..99a4712 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ - + diff --git a/templates/manifest.webmanifest b/templates/manifest.webmanifest index 2050a53..34624da 100644 --- a/templates/manifest.webmanifest +++ b/templates/manifest.webmanifest @@ -1,12 +1,12 @@ { "name": "{{ app_name }}", "short_name": "{{ app_name }}", - "start_url": "/", + "start_url": "{{ url_for('index') }}", "display": "standalone", "background_color": "#0a0d14", "theme_color": "#0a0d14", "icons": [ - { "src": "/static/icon-180.png", "sizes": "180x180", "type": "image/png" }, - { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + { "src": "{{ url_for('static', filename='icon-180.png') }}", "sizes": "180x180", "type": "image/png" }, + { "src": "{{ url_for('static', filename='icon-512.png') }}", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] }