Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
275374e
Add Unix socket listener for Supervisor to Core communication
agners Feb 23, 2026
68d94ba
Use SUPERVISOR_CORE_API_SOCKET env var for unix socket path
agners Feb 23, 2026
c588908
Authenticate Unix socket requests as the Supervisor user
agners Feb 23, 2026
72db92b
Restrict Unix socket permissions before accepting connections
agners Feb 24, 2026
b6be7a1
Patch loop instance instead of private asyncio class in tests
agners Feb 24, 2026
c3be74c
Merge branch 'dev' into use-unix-socket-for-supervisor
agners Mar 9, 2026
95d76e8
Merge branch 'dev' into use-unix-socket-for-supervisor
agners Mar 11, 2026
f499a0b
Extend docs and add comments to Unix socket authentication logic
agners Mar 11, 2026
ea556d6
Improve removing Unix socket on shutdown
agners Mar 11, 2026
cccb252
Add comment about why we delay start serving
agners Mar 11, 2026
da29f06
Move potentially blocking I/O into executor
agners Mar 11, 2026
fdde931
Handle missing refresh token id gracefully
agners Mar 11, 2026
88b9e6c
Move Unix socket websocket auth bypass into AuthPhase
agners Mar 11, 2026
d93b45f
Create Unix socket only after hassio is loaded
agners Mar 11, 2026
58d8824
Fail hard if Supervisor user does not exist
agners Mar 11, 2026
f0c56d7
Use get_running_loop() in tests
agners Mar 11, 2026
03817cc
Check for Supervisor user existence before starting Unix socket
agners Mar 11, 2026
63bc456
Merge branch 'dev' into use-unix-socket-for-supervisor
agners Mar 17, 2026
0888dcc
Fix test_unix_socket_started_with_supervisor pytest
agners Mar 17, 2026
4ce712b
Rename unix_socket to supervisor_unix_socket for clarity
agners Mar 24, 2026
b975a89
Merge branch 'dev' into use-unix-socket-for-supervisor
agners Mar 24, 2026
2d67754
Add Supervisor to message strings
agners Mar 24, 2026
8f886ae
Move chmod inside of try/except block
agners Mar 24, 2026
4bd744f
Check for refresh token in async_sign_path
agners Mar 24, 2026
d4e71ab
Fix auth/delete_all_refresh_tokens endpoint for Supervisor
agners Mar 24, 2026
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
73 changes: 72 additions & 1 deletion homeassistant/components/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ipaddress import IPv4Network, IPv6Network, ip_network
import logging
import os
from pathlib import Path
import socket
import ssl
from tempfile import NamedTemporaryFile
Expand All @@ -33,6 +34,7 @@
from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
HASSIO_USER_NAME,
SERVER_PORT,
)
from homeassistant.core import Event, HomeAssistant, callback
Expand Down Expand Up @@ -69,7 +71,7 @@
from .request_context import setup_request_context
from .security_filter import setup_security_filter
from .static import CACHE_HEADERS, CachingStaticResource
from .web_runner import HomeAssistantTCPSite
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite

CONF_SERVER_HOST: Final = "server_host"
CONF_SERVER_PORT: Final = "server_port"
Expand Down Expand Up @@ -235,6 +237,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

source_ip_task = create_eager_task(async_get_source_ip(hass))

supervisor_unix_socket_path: Path | None = None
if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
socket_path = Path(socket_env)
if socket_path.is_absolute():
supervisor_unix_socket_path = socket_path
else:
_LOGGER.error(
"Invalid unix socket path %s: path must be absolute", socket_env
)

server = HomeAssistantHTTP(
hass,
server_host=server_host,
Expand All @@ -244,6 +256,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
ssl_key=ssl_key,
trusted_proxies=trusted_proxies,
ssl_profile=ssl_profile,
supervisor_unix_socket_path=supervisor_unix_socket_path,
)
await server.async_initialize(
cors_origins=cors_origins,
Expand All @@ -267,6 +280,21 @@ async def start_server(*_: Any) -> None:

async_when_setup_or_start(hass, "frontend", start_server)

if server.supervisor_unix_socket_path is not None:

async def start_supervisor_unix_socket(*_: Any) -> None:
"""Start the Unix socket after the Supervisor user is available."""
if any(
user
for user in await hass.auth.async_get_users()
if user.system_generated and user.name == HASSIO_USER_NAME
):
await server.async_start_supervisor_unix_socket()
else:
_LOGGER.error("Supervisor user not found; not starting Unix socket")

async_when_setup_or_start(hass, "hassio", start_supervisor_unix_socket)

hass.http = server

local_ip = await source_ip_task
Expand Down Expand Up @@ -366,6 +394,7 @@ def __init__(
server_port: int,
trusted_proxies: list[IPv4Network | IPv6Network],
ssl_profile: str,
supervisor_unix_socket_path: Path | None = None,
) -> None:
"""Initialize the HTTP Home Assistant server."""
self.app = HomeAssistantApplication(
Expand All @@ -384,8 +413,10 @@ def __init__(
self.server_port = server_port
self.trusted_proxies = trusted_proxies
self.ssl_profile = ssl_profile
self.supervisor_unix_socket_path = supervisor_unix_socket_path
self.runner: web.AppRunner | None = None
self.site: HomeAssistantTCPSite | None = None
self.supervisor_site: HomeAssistantUnixSite | None = None
self.context: ssl.SSLContext | None = None

async def async_initialize(
Expand Down Expand Up @@ -610,6 +641,33 @@ def _create_emergency_ssl_context(self) -> ssl.SSLContext:
context.load_cert_chain(cert_pem.name, key_pem.name)
return context

async def async_start_supervisor_unix_socket(self) -> None:
"""Start listening on the Unix socket.

This is called separately from start() to delay serving the Unix
socket until the Supervisor user exists (created by the hassio
integration). Without this delay, Supervisor could connect before
its user is available and receive 401 responses it won't retry.
"""
if self.supervisor_unix_socket_path is None or self.runner is None:
return
self.supervisor_site = HomeAssistantUnixSite(
self.runner, self.supervisor_unix_socket_path
)
try:
await self.supervisor_site.start()
except OSError as error:
_LOGGER.error(
"Failed to create HTTP server on unix socket %s: %s",
self.supervisor_unix_socket_path,
error,
)
self.supervisor_site = None
else:
_LOGGER.info(
"Now listening on unix socket %s", self.supervisor_unix_socket_path
)

async def start(self) -> None:
"""Start the aiohttp server."""
# Aiohttp freezes apps after start so that no changes can be made.
Expand Down Expand Up @@ -637,6 +695,19 @@ async def start(self) -> None:

async def stop(self) -> None:
"""Stop the aiohttp server."""
if self.supervisor_site is not None:
await self.supervisor_site.stop()
if self.supervisor_unix_socket_path is not None:
try:
await self.hass.async_add_executor_job(
self.supervisor_unix_socket_path.unlink, True
)
except OSError as err:
_LOGGER.warning(
"Could not remove unix socket %s: %s",
self.supervisor_unix_socket_path,
err,
)
if self.site is not None:
await self.site.stop()
if self.runner is not None:
Expand Down
61 changes: 56 additions & 5 deletions homeassistant/components/http/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
from typing import Any, Final

from aiohttp import hdrs
from aiohttp.web import Application, Request, StreamResponse, middleware
from aiohttp.web import (
Application,
HTTPInternalServerError,
Request,
StreamResponse,
middleware,
)
import jwt
from jwt import api_jws
from yarl import URL
Expand All @@ -20,14 +26,20 @@
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local

from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
from .const import (
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
KEY_HASS_USER,
is_supervisor_unix_socket_request,
)

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -117,7 +129,7 @@ def async_user_not_allowed_do_auth(
return "User cannot authenticate remotely"


async def async_setup_auth(
async def async_setup_auth( # noqa: C901
hass: HomeAssistant,
app: Application,
) -> None:
Expand Down Expand Up @@ -207,14 +219,53 @@ def async_validate_signed_request(request: Request) -> bool:
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True

supervisor_user_id: str | None = None

async def async_authenticate_supervisor_unix_socket(request: Request) -> bool:
"""Authenticate a request from a Unix socket as the Supervisor user.

The Unix Socket is dedicated and only available to Supervisor. To
avoid the extra overhead and round trips for the authentication and
refresh tokens, we directly authenticate requests from the socket as
the Supervisor user.
"""
nonlocal supervisor_user_id

# Fast path: use cached user ID
if supervisor_user_id is not None:
if user := await hass.auth.async_get_user(supervisor_user_id):
request[KEY_HASS_USER] = user
return True
supervisor_user_id = None
Comment on lines +237 to +241
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unix-socket authentication currently accepts the cached Supervisor user even if it is inactive. Other authentication paths explicitly reject inactive users, so this could allow an unexpectedly disabled Supervisor user to still access the API over the unix socket. Consider verifying user.is_active in both the cached and lookup paths (and failing the request if inactive) to keep behavior consistent with the rest of the auth system.

Copilot uses AI. Check for mistakes.

# Slow path: find the Supervisor user by name
for user in await hass.auth.async_get_users():
if user.system_generated and user.name == HASSIO_USER_NAME:
supervisor_user_id = user.id
# Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user
# doesn't use refresh tokens.
request[KEY_HASS_USER] = user
Comment on lines +225 to +249
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unix-socket authentication sets request[KEY_HASS_USER] but never sets request[KEY_HASS_REFRESH_TOKEN_ID]. Some authenticated endpoints assume a refresh token ID is always present (for example, onboarding uses request[KEY_HASS_REFRESH_TOKEN_ID] directly), which can lead to runtime errors or incorrect signing/audit behavior for unix-socket requests. Consider associating the Supervisor system user with a refresh token ID (creating one if missing) and setting KEY_HASS_REFRESH_TOKEN_ID alongside KEY_HASS_USER for this auth path.

Suggested change
async def async_authenticate_unix_socket(request: Request) -> bool:
"""Authenticate a request from a Unix socket as the Supervisor user."""
nonlocal supervisor_user_id
# Fast path: use cached user ID
if supervisor_user_id is not None:
if user := await hass.auth.async_get_user(supervisor_user_id):
request[KEY_HASS_USER] = user
return True
supervisor_user_id = None
# Slow path: find the Supervisor user by name
for user in await hass.auth.async_get_users():
if user.system_generated and user.name == HASSIO_USER_NAME:
supervisor_user_id = user.id
request[KEY_HASS_USER] = user
supervisor_refresh_token_id: str | None = None
async def async_authenticate_unix_socket(request: Request) -> bool:
"""Authenticate a request from a Unix socket as the Supervisor user."""
nonlocal supervisor_user_id, supervisor_refresh_token_id
# Fast path: use cached user and refresh token IDs
if supervisor_user_id is not None and supervisor_refresh_token_id is not None:
user = await hass.auth.async_get_user(supervisor_user_id)
refresh_token = hass.auth.async_get_refresh_token(
supervisor_refresh_token_id
)
if user is not None and refresh_token is not None:
request[KEY_HASS_USER] = user
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True
supervisor_user_id = None
supervisor_refresh_token_id = None
# Slow path: find the Supervisor user by name and associate a refresh token
for user in await hass.auth.async_get_users():
if user.system_generated and user.name == HASSIO_USER_NAME:
# Reuse an existing refresh token for this user if available
refresh_token = next(iter(user.refresh_tokens.values()), None)
if refresh_token is None:
# Without a refresh token ID, we cannot safely authenticate
return False
supervisor_user_id = user.id
supervisor_refresh_token_id = refresh_token.id
request[KEY_HASS_USER] = user
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if we can avoid setting KEY_HASS_REFRESH_TOKEN_ID. From what I can tell, the two possible locations seem safe for the Supervisor case:

  1. onboarding/views.py:286 — IntegrationOnboardingView.post() — requires auth, only called by the frontend during onboarding. Would KeyError on a Unix socket request, but Supervisor has no reason to call this.
  2. auth.py:71 — async_sign_path() — uses KEY_HASS_REFRESH_TOKEN_ID in request (with an in check, not direct access), so it gracefully falls back to the content user if the key isn't present. Safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason we want to avoid setting KEY_HASS_REFRESH_TOKEN_ID?
I'd suggest to add a comment here explaining we intentionally don't set KEY_HASS_REFRESH_TOKEN_ID.

WRT to the IntegrationOnboardingView it seems there's some missing error handling there and it should check that there's a KEY_HASS_REFRESH_TOKEN_ID in the request? Could it be added to the schema, or is that wrong?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason we want to avoid setting KEY_HASS_REFRESH_TOKEN_ID?

Really only to avoid having to maintain a "fake" authentication and refresh token. I've added a comment.

WRT to the IntegrationOnboardingView it seems there's some missing error handling there and it should check that there's a KEY_HASS_REFRESH_TOKEN_ID in the request? Could it be added to the schema, or is that wrong?

The schema is for payload, but KEY_HASS_REFRESH_TOKEN_ID is data added to the request by our authentication system. So no, we can't add this to schema. The Supervisor should never use this view though, and the view is only active during onboarding. I've added a check for completeness.

return True

# The Unix socket should not be serving before the hassio integration
# has created the Supervisor user. If we get here, something is wrong.
_LOGGER.error(
"Supervisor user not found; cannot authenticate Unix socket request"
)
raise HTTPInternalServerError

@middleware
async def auth_middleware(
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""Authenticate as middleware."""
authenticated = False

if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
if is_supervisor_unix_socket_request(request):
authenticated = await async_authenticate_supervisor_unix_socket(request)
auth_type = "unix socket"

elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
request
):
authenticated = True
Expand All @@ -233,7 +284,7 @@ async def auth_middleware(
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug(
"Authenticated %s for %s using %s",
request.remote,
request.remote or "unknown",
request.path,
auth_type,
)
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/http/ban.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio
from homeassistant.util import dt as dt_util, yaml as yaml_util

from .const import KEY_HASS
from .const import KEY_HASS, is_supervisor_unix_socket_request
from .view import HomeAssistantView

_LOGGER: Final = logging.getLogger(__name__)
Expand Down Expand Up @@ -72,6 +72,10 @@ async def ban_middleware(
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
"""IP Ban middleware."""
# Unix socket connections are trusted, skip ban checks
if is_supervisor_unix_socket_request(request):
return await handler(request)

if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None:
_LOGGER.error("IP Ban middleware loaded but banned IPs not loaded")
return await handler(request)
Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/http/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@

from typing import Final

from aiohttp.web import Request

from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401

DOMAIN: Final = "http"

KEY_HASS_USER: Final = "hass_user"
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"


def is_supervisor_unix_socket_request(request: Request) -> bool:
"""Check if request arrived over the Supervisor Unix socket."""
if (transport := request.transport) is None:
return False
if (http := request.app[KEY_HASS].http) is None or (
supervisor_path := http.supervisor_unix_socket_path
) is None:
return False
sockname: str | None = transport.get_extra_info("sockname")
return sockname == str(supervisor_path)
61 changes: 61 additions & 0 deletions homeassistant/components/http/web_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import asyncio
from pathlib import Path
import socket
from ssl import SSLContext

from aiohttp import web
Expand Down Expand Up @@ -68,3 +70,62 @@ async def start(self) -> None:
reuse_address=self._reuse_address,
reuse_port=self._reuse_port,
)


class HomeAssistantUnixSite(web.BaseSite):
"""HomeAssistant specific aiohttp UnixSite.

Listens on a Unix socket for local inter-process communication,
used for Supervisor to Core communication.
"""

__slots__ = ("_path",)

def __init__(
self,
runner: web.BaseRunner,
path: Path,
*,
backlog: int = 128,
) -> None:
"""Initialize HomeAssistantUnixSite."""
super().__init__(
runner,
backlog=backlog,
)
self._path = path

@property
def name(self) -> str:
"""Return server URL."""
return f"http://unix:{self._path}:"

def _create_unix_socket(self) -> socket.socket:
"""Create and bind a Unix domain socket.

Performs blocking filesystem I/O (mkdir, unlink, chmod) and is
intended to be run in an executor. Permissions are set after bind
but before the socket is handed to the event loop, so no
connections can arrive on an unrestricted socket.
"""
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.unlink(missing_ok=True)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.bind(str(self._path))
except OSError:
sock.close()
raise
self._path.chmod(0o600)
return sock

async def start(self) -> None:
"""Start server."""
await super().start()
loop = asyncio.get_running_loop()
sock = await loop.run_in_executor(None, self._create_unix_socket)
server = self._runner.server
assert server is not None
self._server = await loop.create_unix_server(
server, sock=sock, backlog=self._backlog
)
Comment on lines +129 to +131
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If loop.create_unix_server(...) raises after _create_unix_socket() successfully bound the path, the socket FD and filesystem entry can be leaked (and the stale socket path may prevent later restarts). Wrap the create_unix_server call in error handling that closes the created socket and unlinks the path on failure.

Suggested change
self._server = await loop.create_unix_server(
server, sock=sock, backlog=self._backlog
)
try:
self._server = await loop.create_unix_server(
server, sock=sock, backlog=self._backlog
)
except Exception:
sock.close()
self._path.unlink(missing_ok=True)
raise

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_create_unix_socket() handles stale entries gracefully. This seems unnecessary to me.

5 changes: 4 additions & 1 deletion homeassistant/components/onboarding/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ class IntegrationOnboardingView(_BaseOnboardingStepView):
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle token creation."""
hass = request.app[KEY_HASS]
refresh_token_id = request[KEY_HASS_REFRESH_TOKEN_ID]
if not (refresh_token_id := request.get(KEY_HASS_REFRESH_TOKEN_ID)):
return self.json_message(
"Refresh token not available", HTTPStatus.FORBIDDEN
)

async with self._lock:
if self._async_is_done():
Expand Down
Loading
Loading