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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 21:57:16 +02:00
co-authored by Claude Opus 5
parent 6213bcf23f
commit 6d132f53ff
6 changed files with 79 additions and 7 deletions
+29
View File
@@ -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 `<Location>` 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 `<pi-ip>: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.
+12 -2
View File
@@ -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__":
+31
View File
@@ -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 <pi-ip>:5005.
# This block also takes /heos away from the filesystem, so the source in
# /var/www/html/heos stops being reachable as static files.
<Location /heos>
# 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.
<RequireAny>
Require ip 192.168.0.0/24
Require ip 127.0.0.1
Require ip ::1
</RequireAny>
# 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
</Location>
+3 -1
View File
@@ -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}`);
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}">
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
+3 -3
View File
@@ -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" }
]
}