Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,38 @@ Two ways to make it work:
restriction entirely (handy when the Host varies per request
— but then operator-supplied auth becomes the only gate).

### Listening on a UNIX socket (`--socket`)

Instead of a TCP port, the public site can listen on a UNIX
socket — the usual choice when a reverse proxy runs on the
same host and you don't want the dashboard reachable over
TCP at all:

```bash
esphome-device-builder /config --socket /run/esphome/dashboard.sock
```

`--host` and `--port` are then ignored for binding but still
feed the dashboard's mDNS advertisement, so point them at
where the proxy ultimately listens.

The socket file is created with the default permissions your
process `umask` yields; the dashboard does not manage its
mode. **Put the socket in a directory readable only by the
dashboard and the proxy** (for example a dedicated
`/run/esphome/` owned by the dashboard user with the proxy's
user granted access via group or ACL) — directory permissions
are the reliable access control here, since socket-file mode
bits are not enforced on every platform. Anything that can
connect to the socket gets the same surface a TCP client
would, gated by the same auth and `Origin`/`Host` checks.

nginx upstream example:

```nginx
proxy_pass http://unix:/run/esphome/dashboard.sock;
```

### Subpath mounts (X-Forwarded-Prefix)

If you run the standalone dashboard behind an HTTP reverse proxy
Expand Down
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,12 @@ Match is case-insensitive and port-tolerant: `dashboard.example.com` accepts `Da

`--host`, `--ingress-host`, and `--remote-build-host` each accept either an IP literal (the usual `0.0.0.0` / `127.0.0.1` / a specific LAN IP) or a **local network interface name** (`eth0`, `wlan0`, `lo`, …). When the value matches an interface present on the host, the bind expands to every IPv4 / IPv6 address currently assigned to that interface.

### Binding to a UNIX socket

`--socket` allows the public site to be served on a UNIX socket instead of a TCP socket. Authentication and `Origin`/`Host` gates continue to be enforced as normal. The socket file is (re)created with default permissions based on the process `umask` and removed on shutdown.

When listening on a UNIX Socket, the configured `--host` and `--port` are ignored, except that they are still used for the dashboard's mDNS advertisement. When used in conjunction with a reverse proxy, they can be set to the proxy's bound IP and port.

## Discovery (mDNS)

Two mDNS surfaces ride the same `AsyncEsphomeZeroconf` instance the device state monitor already owns. Sharing one Zeroconf singleton matters: opening a second responder fights for the same multicast socket and silently drops half the packets.
Expand Down
15 changes: 15 additions & 0 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ These are explicitly *not* threats the dashboard defends against:
authenticated client.** They can already crash their own
dashboard; reliability is a quality bar, not a security
boundary.
- **The `--socket` UNIX socket file's permission bits.** The
socket is created with whatever mode the process `umask`
yields (see ARCHITECTURE.md "Binding to a UNIX socket"); the
dashboard deliberately does not `chmod` it. The legacy
dashboard's world-writable socket undermined the point of a
UNIX socket, and tightening after bind leaves a race window;
socket-file mode bits aren't portable enforcement anyway
(some platforms ignore them). The file mode was never the
boundary: local filesystem access to the socket is
local-shell trust (first bullet), and the auth and `Origin` /
`Host` gates apply to traffic arriving through it exactly as
they do on TCP. Operators who want OS-level access control
place the socket in a directory with appropriate permissions
or use default ACLs. A report that the socket's default mode
is too open (or too closed) is not a security bug.
- **A console reader taking the `--remote-build-only` first-pair
key.** The headless build server's bootstrap auto-approves the
first `pair_request` inside its operator-initiated, single-use,
Expand Down
3 changes: 3 additions & 0 deletions esphome_device_builder/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
"the container's LAN IP isn't known in advance"
),
)
parser.add_argument(
"--socket", help="Path of a UNIX socket to bind to. Host and port are ignored when set."
)
parser.add_argument(
"--username",
default="",
Expand Down
2 changes: 2 additions & 0 deletions esphome_device_builder/controllers/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class DashboardSettings:
log_level: str = "info"
port: int = 6052
host: str = "0.0.0.0"
unix_socket: str | None = None
ingress_port: int = DEFAULT_INGRESS_PORT
ingress_host: str = ""
# Plain-TCP port for the remote-build peer-link receiver site
Expand Down Expand Up @@ -201,6 +202,7 @@ def parse_args(self, args: Any) -> None:
self.log_level = getattr(args, "log_level", "info")
self.port = getattr(args, "port", 6052)
self.host = getattr(args, "host", "0.0.0.0")
self.unix_socket = getattr(args, "socket", None)
self.ingress_port = getattr(args, "ingress_port", DEFAULT_INGRESS_PORT)
self.ingress_host = getattr(args, "ingress_host", "") or ""
# ``--remote-build-port`` (or ``$ESPHOME_REMOTE_BUILD_PORT``).
Expand Down
13 changes: 8 additions & 5 deletions esphome_device_builder/device_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -891,16 +891,17 @@ def _warn_front_door_open(self) -> None:
_LOGGER.warning(
"\n%s\n"
" FRONT DOOR OPEN: external authentication is DISABLED.\n"
" The dashboard is serving on %s:%d with NO authentication.\n"
" The dashboard is serving on %s%s%s with NO authentication.\n"
" ANYONE on your network can flash firmware and run code on this host.\n"
' You enabled this with the add-on option "Disable external\n'
' authentication" (leave_front_door_open) plus a mapped port %d.\n'
" There is no password, no login, no protection of any kind.\n"
" Turn that option OFF (or unmap the port) for ingress-only access.\n"
"%s",
banner,
settings.host,
settings.port,
settings.unix_socket or settings.host,
"" if settings.unix_socket else ":",
"" if settings.unix_socket else settings.port,
settings.port,
banner,
)
Expand Down Expand Up @@ -939,12 +940,13 @@ def run(self) -> None:
app = self.create_app(trusted=False, peer_guard=False)
if self._startup_timer is not None:
self._startup_timer.mark("app")
hosts = resolve_bind_host(settings.host)
hosts = resolve_bind_host(settings.host) if settings.unix_socket is None else []
ensure_single_host_for_ephemeral_port(hosts, settings.port, "--port")
web.run_app(
app,
host=hosts,
port=settings.port,
path=settings.unix_socket,
shutdown_timeout=_SHUTDOWN_TIMEOUT_SECONDS,
handle_signals=False,
)
Expand Down Expand Up @@ -999,7 +1001,7 @@ def run(self) -> None:
app = self.create_app()
if self._startup_timer is not None:
self._startup_timer.mark("app")
hosts = resolve_bind_host(settings.host)
hosts = resolve_bind_host(settings.host) if settings.unix_socket is None else []
ensure_single_host_for_ephemeral_port(hosts, settings.port, "--port")
# ``handle_signals=False``: keep our ``__main__`` SIGTERM/SIGBREAK trap
# as the sole handler for the whole lifecycle. aiohttp's own
Expand All @@ -1013,6 +1015,7 @@ def run(self) -> None:
app,
host=hosts,
port=settings.port,
path=settings.unix_socket,
shutdown_timeout=_SHUTDOWN_TIMEOUT_SECONDS,
handle_signals=False,
)
Expand Down
53 changes: 42 additions & 11 deletions tests/test_ha_addon_failsafe.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def _make_db(
on_ha_addon: bool,
using_password: bool,
allow_public_port: bool = False,
unix_socket: str | None = None,
) -> DeviceBuilder:
"""Build a DeviceBuilder with the requested settings shape.

Expand All @@ -56,6 +57,7 @@ def _make_db(
settings.username = "admin"
settings.password_hash = b"x" * 32
settings.host = "0.0.0.0"
settings.unix_socket = unix_socket
settings.port = 6052
settings.ingress_port = 6053
settings.ingress_host = ""
Expand Down Expand Up @@ -135,10 +137,12 @@ def fake_run_app(
assert "standalone PyPI install" in warning


@pytest.mark.parametrize("unix_socket", [pytest.param(None, id="tcp_socket"), "unix_socket"])
def test_ha_addon_front_door_open_with_mapped_port_binds_public_unauthenticated(
make_settings: MakeSettingsFactory,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
unix_socket: str | None,
) -> None:
"""Both opt-ins set → public port bound with no auth, ingress kept for the sidebar.

Expand All @@ -151,15 +155,24 @@ def test_ha_addon_front_door_open_with_mapped_port_binds_public_unauthenticated(
survives.
"""
monkeypatch.setenv("DISABLE_HA_AUTHENTICATION", "true")
db = _make_db(make_settings, on_ha_addon=True, using_password=False, allow_public_port=True)
db = _make_db(
make_settings,
on_ha_addon=True,
using_password=False,
allow_public_port=True,
unix_socket=unix_socket,
)

captured: dict[str, object] = {}

def fake_run_app(app, *, host: list[str], port: int, **_: object) -> None:
def fake_run_app(
app, *, host: list[str], port: int, path: str | None = None, **_: object
) -> None:
captured["host"] = host
captured["port"] = port
captured["trusted_site"] = bool(app.get("trusted_site"))
captured["ingress_hook"] = db._start_ingress_site in app.on_startup
captured["path"] = path

with (
caplog.at_level("WARNING", logger="esphome_device_builder.device_builder"),
Expand All @@ -170,7 +183,8 @@ def fake_run_app(app, *, host: list[str], port: int, **_: object) -> None:

# Public port bound on all interfaces.
assert captured["port"] == 6052
assert captured["host"] == ["0.0.0.0"]
assert captured["host"] == ([] if unix_socket else ["0.0.0.0"])
assert captured["path"] == unix_socket
# Not a trusted site: the WS origin/Host gate stays active (auth is a no-op
# without a password), so a plain cross-origin drive-by is still rejected.
assert captured["trusted_site"] is False
Expand All @@ -184,7 +198,7 @@ def fake_run_app(app, *, host: list[str], port: int, **_: object) -> None:

banner = [r.getMessage() for r in caplog.records if "FRONT DOOR OPEN" in r.getMessage()]
assert banner, "expected the loud FRONT DOOR OPEN banner"
assert "0.0.0.0:6052" in banner[0]
assert (unix_socket or "0.0.0.0:6052") in banner[0]
assert "NO authentication" in banner[0]


Expand Down Expand Up @@ -287,55 +301,72 @@ def test_front_door_property_truth_table(
assert settings.create_ingress_site is ingress


@pytest.mark.parametrize("unix_socket", [pytest.param(None, id="tcp_socket"), "unix_socket"])
def test_ha_addon_with_password_binds_public_site_normally(
make_settings: MakeSettingsFactory,
monkeypatch: pytest.MonkeyPatch,
unix_socket: str | None,
) -> None:
"""Password set → normal public-site bind, ingress as a hook."""
monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False)
db = _make_db(make_settings, on_ha_addon=True, using_password=True)
db = _make_db(make_settings, on_ha_addon=True, using_password=True, unix_socket=unix_socket)

captured: dict[str, object] = {}

def fake_run_app(app, *, host: list[str], port: int, **_: object) -> None:
def fake_run_app(
app, *, host: list[str], port: int, path: str | None = None, **_: object
) -> None:
captured["host"] = host
captured["port"] = port
captured["trusted"] = bool(app.get("trusted_site"))
captured["path"] = path

with patch("esphome_device_builder.device_builder.web.run_app", fake_run_app):
db.run()

# Public port bound (auth gates it via using_password).
assert captured["port"] == 6052
assert captured["host"] == ["0.0.0.0"]
assert captured["host"] == ([] if unix_socket else ["0.0.0.0"])
assert captured["path"] == unix_socket
assert captured["trusted"] is False


def test_non_ha_addon_binds_public_site_normally(make_settings: MakeSettingsFactory) -> None:
@pytest.mark.parametrize("unix_socket", [pytest.param(None, id="tcp_socket"), "unix_socket"])
def test_non_ha_addon_binds_public_site_normally(
make_settings: MakeSettingsFactory, unix_socket: str | None
) -> None:
"""Standalone deployment is unaffected by the HA-add-on logic.

Doesn't need ``monkeypatch`` for ``DISABLE_HA_AUTHENTICATION``:
when ``on_ha_addon=False`` the property short-circuits and
returns ``False`` regardless of the env var.
"""
db = _make_db(make_settings, on_ha_addon=False, using_password=False)
db = _make_db(make_settings, on_ha_addon=False, using_password=False, unix_socket=unix_socket)

captured: dict[str, object] = {}

def fake_run_app(
app, *, host: list[str], port: int, handle_signals: bool = True, **_: object
app,
*,
host: list[str],
port: int,
path: str | None = None,
handle_signals: bool = True,
**_: object,
) -> None:
captured["host"] = host
captured["port"] = port
captured["handle_signals"] = handle_signals
captured["path"] = path

with patch("esphome_device_builder.device_builder.web.run_app", fake_run_app):
db.run()

# Public port bound — non-add-on deployments get the legacy
# default of "no auth required, user opts in via PASSWORD".
assert captured["port"] == 6052
assert captured["host"] == ["0.0.0.0"]
assert captured["host"] == ([] if unix_socket else ["0.0.0.0"])
assert captured["path"] == unix_socket
# We own the stop signal end-to-end (see DeviceBuilder.run); aiohttp
# must not install its own handler.
assert captured["handle_signals"] is False
Expand Down
Loading