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:
@@ -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.
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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,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">
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user