mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-02-27 12:47:31 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb188f734 | ||
|
|
e7e3882013 | ||
|
|
caa2b8b486 | ||
|
|
3bf5ea4a05 | ||
|
|
7f6327e94e | ||
|
|
9f00b6e34f | ||
|
|
7a0b2e474a | ||
|
|
b74277ced0 | ||
|
|
c9a874b352 | ||
|
|
3de2deaf02 | ||
|
|
c79e58d584 | ||
|
|
6070d54860 | ||
|
|
03e110cb86 | ||
|
|
4a1c816b92 | ||
|
|
b70f44bf1f | ||
|
|
c981b3b4c2 | ||
|
|
f2d0ceab33 | ||
|
|
3147d080a2 | ||
|
|
09a4e9d5a2 | ||
|
|
d93e728918 | ||
|
|
27c6af4b4b | ||
|
|
00f2578d61 |
50
.github/release-drafter.yml
vendored
50
.github/release-drafter.yml
vendored
@@ -5,45 +5,53 @@ categories:
|
||||
- title: ":boom: Breaking Changes"
|
||||
label: "breaking-change"
|
||||
|
||||
- title: ":wrench: Build"
|
||||
label: "build"
|
||||
|
||||
- title: ":boar: Chore"
|
||||
label: "chore"
|
||||
|
||||
- title: ":sparkles: New Features"
|
||||
label: "new-feature"
|
||||
|
||||
- title: ":zap: Performance"
|
||||
label: "performance"
|
||||
|
||||
- title: ":recycle: Refactor"
|
||||
label: "refactor"
|
||||
|
||||
- title: ":green_heart: CI"
|
||||
label: "ci"
|
||||
|
||||
- title: ":bug: Bug Fixes"
|
||||
label: "bugfix"
|
||||
|
||||
- title: ":white_check_mark: Test"
|
||||
- title: ":gem: Style"
|
||||
label: "style"
|
||||
|
||||
- title: ":package: Refactor"
|
||||
label: "refactor"
|
||||
|
||||
- title: ":rocket: Performance"
|
||||
label: "performance"
|
||||
|
||||
- title: ":rotating_light: Test"
|
||||
label: "test"
|
||||
|
||||
- title: ":hammer_and_wrench: Build"
|
||||
label: "build"
|
||||
|
||||
- title: ":gear: CI"
|
||||
label: "ci"
|
||||
|
||||
- title: ":recycle: Chore"
|
||||
label: "chore"
|
||||
|
||||
- title: ":wastebasket: Revert"
|
||||
label: "revert"
|
||||
|
||||
- title: ":arrow_up: Dependency Updates"
|
||||
label: "dependencies"
|
||||
collapse-after: 1
|
||||
|
||||
include-labels:
|
||||
- "breaking-change"
|
||||
- "build"
|
||||
- "chore"
|
||||
- "performance"
|
||||
- "refactor"
|
||||
- "new-feature"
|
||||
- "bugfix"
|
||||
- "dependencies"
|
||||
- "style"
|
||||
- "refactor"
|
||||
- "performance"
|
||||
- "test"
|
||||
- "build"
|
||||
- "ci"
|
||||
- "chore"
|
||||
- "revert"
|
||||
- "dependencies"
|
||||
|
||||
template: |
|
||||
|
||||
|
||||
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
- name: Upload local wheels artifact
|
||||
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: wheels-${{ matrix.arch }}
|
||||
path: wheels
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build supervisor
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Download local wheels artifact
|
||||
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'false'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: wheels-amd64
|
||||
path: wheels
|
||||
@@ -259,7 +259,7 @@ jobs:
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build the Supervisor
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@2026.02.1
|
||||
with:
|
||||
args: |
|
||||
--test \
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -386,7 +386,7 @@ jobs:
|
||||
-o console_output_style=count \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coverage
|
||||
path: .coverage
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
echo "Failed to restore Python virtual environment from cache"
|
||||
exit 1
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
aiodns==4.0.0
|
||||
aiodocker==0.25.0
|
||||
aiodocker==0.26.0
|
||||
aiohttp==3.13.3
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==25.4.0
|
||||
@@ -14,7 +14,6 @@ cryptography==46.0.5
|
||||
debugpy==1.8.20
|
||||
deepmerge==2.0
|
||||
dirhash==0.5.0
|
||||
docker==7.1.0
|
||||
faust-cchardet==2.1.19
|
||||
gitpython==3.1.46
|
||||
jinja2==3.1.6
|
||||
@@ -24,8 +23,8 @@ pulsectl==24.12.0
|
||||
pyudev==0.24.4
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
securetar==2025.12.0
|
||||
sentry-sdk==2.52.0
|
||||
securetar==2026.2.0
|
||||
sentry-sdk==2.53.0
|
||||
setuptools==82.0.0
|
||||
voluptuous==0.16.0
|
||||
dbus-fast==4.0.0
|
||||
|
||||
@@ -2,15 +2,14 @@ astroid==4.0.3
|
||||
coverage==7.13.4
|
||||
mypy==1.19.1
|
||||
pre-commit==4.5.1
|
||||
pylint==4.0.4
|
||||
pylint==4.0.5
|
||||
pytest-aiohttp==1.1.0
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest==9.0.2
|
||||
ruff==0.15.1
|
||||
ruff==0.15.4
|
||||
time-machine==3.2.0
|
||||
types-docker==7.1.0.20260109
|
||||
types-pyyaml==6.0.12.20250915
|
||||
types-requests==2.32.4.20260107
|
||||
urllib3==2.6.3
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any, Final, cast
|
||||
import aiohttp
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
from deepmerge import Merger
|
||||
from securetar import AddFileError, SecureTarFile, atomic_contents_add, secure_path
|
||||
from securetar import AddFileError, SecureTarFile, atomic_contents_add
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
@@ -76,6 +76,7 @@ from ..exceptions import (
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonUnknownError,
|
||||
BackupInvalidError,
|
||||
BackupRestoreUnknownError,
|
||||
ConfigurationFileError,
|
||||
DockerBuildError,
|
||||
@@ -190,18 +191,18 @@ class Addon(AddonModel):
|
||||
self._startup_event.set()
|
||||
|
||||
# Dismiss boot failed issue if present and we started
|
||||
if (
|
||||
new_state == AddonState.STARTED
|
||||
and self.boot_failed_issue in self.sys_resolution.issues
|
||||
if new_state == AddonState.STARTED and (
|
||||
issue := self.sys_resolution.get_issue_if_present(self.boot_failed_issue)
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(self.boot_failed_issue)
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
# Dismiss device access missing issue if present and we stopped
|
||||
if (
|
||||
new_state == AddonState.STOPPED
|
||||
and self.device_access_missing_issue in self.sys_resolution.issues
|
||||
if new_state == AddonState.STOPPED and (
|
||||
issue := self.sys_resolution.get_issue_if_present(
|
||||
self.device_access_missing_issue
|
||||
)
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(self.device_access_missing_issue)
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
self.sys_homeassistant.websocket.supervisor_event_custom(
|
||||
WSEvent.ADDON,
|
||||
@@ -362,11 +363,10 @@ class Addon(AddonModel):
|
||||
self.persist[ATTR_BOOT] = value
|
||||
|
||||
# Dismiss boot failed issue if present and boot at start disabled
|
||||
if (
|
||||
value == AddonBoot.MANUAL
|
||||
and self._boot_failed_issue in self.sys_resolution.issues
|
||||
if value == AddonBoot.MANUAL and (
|
||||
issue := self.sys_resolution.get_issue_if_present(self._boot_failed_issue)
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(self._boot_failed_issue)
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
@property
|
||||
def auto_update(self) -> bool:
|
||||
@@ -1444,10 +1444,11 @@ class Addon(AddonModel):
|
||||
tmp = TemporaryDirectory(dir=self.sys_config.path_tmp)
|
||||
try:
|
||||
with tar_file as backup:
|
||||
# The tar filter rejects path traversal and absolute names,
|
||||
# aborting restore of malicious backups with such exploits.
|
||||
backup.extractall(
|
||||
path=tmp.name,
|
||||
members=secure_path(backup),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
|
||||
data = read_json_file(Path(tmp.name, "addon.json"))
|
||||
@@ -1459,8 +1460,12 @@ class Addon(AddonModel):
|
||||
|
||||
try:
|
||||
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
|
||||
except tarfile.FilterError as err:
|
||||
raise BackupInvalidError(
|
||||
f"Can't extract backup tarfile for {self.slug}: {err}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
except tarfile.TarError as err:
|
||||
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err)
|
||||
raise BackupRestoreUnknownError() from err
|
||||
except ConfigurationFileError as err:
|
||||
raise AddonUnknownError(addon=self.slug) from err
|
||||
|
||||
@@ -127,14 +127,14 @@ class APIAuth(CoreSysAttributes):
|
||||
return {
|
||||
ATTR_USERS: [
|
||||
{
|
||||
ATTR_USERNAME: user[ATTR_USERNAME],
|
||||
ATTR_NAME: user[ATTR_NAME],
|
||||
ATTR_IS_OWNER: user[ATTR_IS_OWNER],
|
||||
ATTR_IS_ACTIVE: user[ATTR_IS_ACTIVE],
|
||||
ATTR_LOCAL_ONLY: user[ATTR_LOCAL_ONLY],
|
||||
ATTR_GROUP_IDS: user[ATTR_GROUP_IDS],
|
||||
ATTR_USERNAME: user.username,
|
||||
ATTR_NAME: user.name,
|
||||
ATTR_IS_OWNER: user.is_owner,
|
||||
ATTR_IS_ACTIVE: user.is_active,
|
||||
ATTR_LOCAL_ONLY: user.local_only,
|
||||
ATTR_GROUP_IDS: user.group_ids,
|
||||
}
|
||||
for user in await self.sys_auth.list_users()
|
||||
if user[ATTR_USERNAME]
|
||||
if user.username
|
||||
]
|
||||
}
|
||||
|
||||
@@ -240,7 +240,9 @@ class APIHost(CoreSysAttributes):
|
||||
f"Cannot determine CONTAINER_LOG_EPOCH of {identifier}, latest logs not available."
|
||||
) from err
|
||||
|
||||
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
|
||||
accept_header = request.headers.get(ACCEPT)
|
||||
|
||||
if accept_header and accept_header not in [
|
||||
CONTENT_TYPE_TEXT,
|
||||
CONTENT_TYPE_X_LOG,
|
||||
"*/*",
|
||||
@@ -250,7 +252,7 @@ class APIHost(CoreSysAttributes):
|
||||
"supported for now."
|
||||
)
|
||||
|
||||
if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG:
|
||||
if "verbose" in request.query or accept_header == CONTENT_TYPE_X_LOG:
|
||||
log_formatter = LogFormatter.VERBOSE
|
||||
|
||||
if "no_colors" in request.query:
|
||||
|
||||
@@ -29,8 +29,8 @@ from ..const import (
|
||||
HEADER_REMOTE_USER_NAME,
|
||||
HEADER_TOKEN,
|
||||
HEADER_TOKEN_OLD,
|
||||
HomeAssistantUser,
|
||||
IngressSessionData,
|
||||
IngressSessionDataUser,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import HomeAssistantAPIError
|
||||
@@ -75,12 +75,6 @@ def status_code_must_be_empty_body(code: int) -> bool:
|
||||
class APIIngress(CoreSysAttributes):
|
||||
"""Ingress view to handle add-on webui routing."""
|
||||
|
||||
_list_of_users: list[IngressSessionDataUser]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize APIIngress."""
|
||||
self._list_of_users = []
|
||||
|
||||
def _extract_addon(self, request: web.Request) -> Addon:
|
||||
"""Return addon, throw an exception it it doesn't exist."""
|
||||
token = request.match_info["token"]
|
||||
@@ -306,20 +300,15 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
return response
|
||||
|
||||
async def _find_user_by_id(self, user_id: str) -> IngressSessionDataUser | None:
|
||||
async def _find_user_by_id(self, user_id: str) -> HomeAssistantUser | None:
|
||||
"""Find user object by the user's ID."""
|
||||
try:
|
||||
list_of_users = await self.sys_homeassistant.get_users()
|
||||
except (HomeAssistantAPIError, TypeError) as err:
|
||||
_LOGGER.error(
|
||||
"%s error occurred while requesting list of users: %s", type(err), err
|
||||
)
|
||||
users = await self.sys_homeassistant.list_users()
|
||||
except HomeAssistantAPIError as err:
|
||||
_LOGGER.warning("Could not fetch list of users: %s", err)
|
||||
return None
|
||||
|
||||
if list_of_users is not None:
|
||||
self._list_of_users = list_of_users
|
||||
|
||||
return next((user for user in self._list_of_users if user.id == user_id), None)
|
||||
return next((user for user in users if user.id == user_id), None)
|
||||
|
||||
|
||||
def _init_header(
|
||||
@@ -332,8 +321,8 @@ def _init_header(
|
||||
headers[HEADER_REMOTE_USER_ID] = session_data.user.id
|
||||
if session_data.user.username is not None:
|
||||
headers[HEADER_REMOTE_USER_NAME] = session_data.user.username
|
||||
if session_data.user.display_name is not None:
|
||||
headers[HEADER_REMOTE_USER_DISPLAY_NAME] = session_data.user.display_name
|
||||
if session_data.user.name is not None:
|
||||
headers[HEADER_REMOTE_USER_DISPLAY_NAME] = session_data.user.name
|
||||
|
||||
# filter flags
|
||||
for name, value in request.headers.items():
|
||||
|
||||
@@ -19,7 +19,6 @@ from ..const import (
|
||||
ATTR_UNSUPPORTED,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APINotFound, ResolutionNotFound
|
||||
from ..resolution.checks.base import CheckBase
|
||||
from ..resolution.data import Issue, Suggestion
|
||||
from .utils import api_process, api_validate
|
||||
@@ -32,24 +31,17 @@ class APIResoulution(CoreSysAttributes):
|
||||
|
||||
def _extract_issue(self, request: web.Request) -> Issue:
|
||||
"""Extract issue from request or raise."""
|
||||
try:
|
||||
return self.sys_resolution.get_issue(request.match_info["issue"])
|
||||
except ResolutionNotFound:
|
||||
raise APINotFound("The supplied UUID is not a valid issue") from None
|
||||
return self.sys_resolution.get_issue_by_id(request.match_info["issue"])
|
||||
|
||||
def _extract_suggestion(self, request: web.Request) -> Suggestion:
|
||||
"""Extract suggestion from request or raise."""
|
||||
try:
|
||||
return self.sys_resolution.get_suggestion(request.match_info["suggestion"])
|
||||
except ResolutionNotFound:
|
||||
raise APINotFound("The supplied UUID is not a valid suggestion") from None
|
||||
return self.sys_resolution.get_suggestion_by_id(
|
||||
request.match_info["suggestion"]
|
||||
)
|
||||
|
||||
def _extract_check(self, request: web.Request) -> CheckBase:
|
||||
"""Extract check from request or raise."""
|
||||
try:
|
||||
return self.sys_resolution.check.get(request.match_info["check"])
|
||||
except ResolutionNotFound:
|
||||
raise APINotFound("The supplied check slug is not available") from None
|
||||
return self.sys_resolution.check.get(request.match_info["check"])
|
||||
|
||||
def _generate_suggestion_information(self, suggestion: Suggestion):
|
||||
"""Generate suggestion information for response."""
|
||||
|
||||
@@ -6,13 +6,12 @@ import logging
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from .addons.addon import Addon
|
||||
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
|
||||
from .const import ATTR_PASSWORD, ATTR_USERNAME, FILE_HASSIO_AUTH, HomeAssistantUser
|
||||
from .coresys import CoreSys, CoreSysAttributes
|
||||
from .exceptions import (
|
||||
AuthHomeAssistantAPIValidationError,
|
||||
AuthInvalidNonStringValueError,
|
||||
AuthListUsersError,
|
||||
AuthListUsersNoneResponseError,
|
||||
AuthPasswordResetError,
|
||||
HomeAssistantAPIError,
|
||||
HomeAssistantWSError,
|
||||
@@ -157,22 +156,14 @@ class Auth(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
raise AuthPasswordResetError(user=username)
|
||||
|
||||
async def list_users(self) -> list[dict[str, Any]]:
|
||||
async def list_users(self) -> list[HomeAssistantUser]:
|
||||
"""List users on the Home Assistant instance."""
|
||||
try:
|
||||
users: (
|
||||
list[dict[str, Any]] | None
|
||||
) = await self.sys_homeassistant.websocket.async_send_command(
|
||||
{ATTR_TYPE: "config/auth/list"}
|
||||
)
|
||||
return await self.sys_homeassistant.list_users()
|
||||
except HomeAssistantWSError as err:
|
||||
_LOGGER.error("Can't request listing users on Home Assistant: %s", err)
|
||||
raise AuthListUsersError() from err
|
||||
|
||||
if users is not None:
|
||||
return users
|
||||
raise AuthListUsersNoneResponseError(_LOGGER.error)
|
||||
|
||||
@staticmethod
|
||||
def _rehash(value: str, salt2: str = "") -> str:
|
||||
"""Rehash a value."""
|
||||
|
||||
@@ -12,13 +12,19 @@ import json
|
||||
import logging
|
||||
from pathlib import Path, PurePath
|
||||
import tarfile
|
||||
from tarfile import TarFile
|
||||
from tempfile import TemporaryDirectory
|
||||
import time
|
||||
from typing import Any, Self, cast
|
||||
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
from securetar import AddFileError, SecureTarFile, atomic_contents_add, secure_path
|
||||
from securetar import (
|
||||
AddFileError,
|
||||
InvalidPasswordError,
|
||||
SecureTarArchive,
|
||||
SecureTarFile,
|
||||
SecureTarReadError,
|
||||
atomic_contents_add,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
@@ -59,7 +65,7 @@ from ..utils import remove_folder
|
||||
from ..utils.dt import parse_datetime, utcnow
|
||||
from ..utils.json import json_bytes
|
||||
from ..utils.sentinel import DEFAULT
|
||||
from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, BackupType
|
||||
from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, SECURETAR_CREATE_VERSION, BackupType
|
||||
from .validate import SCHEMA_BACKUP
|
||||
|
||||
IGNORED_COMPARISON_FIELDS = {ATTR_PROTECTED, ATTR_CRYPTO, ATTR_DOCKER}
|
||||
@@ -99,7 +105,7 @@ class Backup(JobGroup):
|
||||
)
|
||||
self._data: dict[str, Any] = data or {ATTR_SLUG: slug}
|
||||
self._tmp: TemporaryDirectory | None = None
|
||||
self._outer_secure_tarfile: SecureTarFile | None = None
|
||||
self._outer_secure_tarfile: SecureTarArchive | None = None
|
||||
self._password: str | None = None
|
||||
self._locations: dict[str | None, BackupLocation] = {
|
||||
location: BackupLocation(
|
||||
@@ -198,16 +204,6 @@ class Backup(JobGroup):
|
||||
"""Get extra metadata added by client."""
|
||||
return self._data[ATTR_EXTRA]
|
||||
|
||||
@property
|
||||
def docker(self) -> dict[str, Any]:
|
||||
"""Return backup Docker config data."""
|
||||
return self._data.get(ATTR_DOCKER, {})
|
||||
|
||||
@docker.setter
|
||||
def docker(self, value: dict[str, Any]) -> None:
|
||||
"""Set the Docker config data."""
|
||||
self._data[ATTR_DOCKER] = value
|
||||
|
||||
@property
|
||||
def location(self) -> str | None:
|
||||
"""Return the location of the backup."""
|
||||
@@ -364,15 +360,17 @@ class Backup(JobGroup):
|
||||
test_tar_file = backup.extractfile(test_tar_name)
|
||||
try:
|
||||
with SecureTarFile(
|
||||
ending, # Not used
|
||||
gzip=self.compressed,
|
||||
mode="r",
|
||||
fileobj=test_tar_file,
|
||||
password=self._password,
|
||||
):
|
||||
# If we can read the tar file, the password is correct
|
||||
return
|
||||
except tarfile.ReadError as ex:
|
||||
except (
|
||||
tarfile.ReadError,
|
||||
SecureTarReadError,
|
||||
InvalidPasswordError,
|
||||
) as ex:
|
||||
raise BackupInvalidError(
|
||||
f"Invalid password for backup {self.slug}", _LOGGER.error
|
||||
) from ex
|
||||
@@ -441,7 +439,7 @@ class Backup(JobGroup):
|
||||
async def create(self) -> AsyncGenerator[None]:
|
||||
"""Create new backup file."""
|
||||
|
||||
def _open_outer_tarfile() -> tuple[SecureTarFile, tarfile.TarFile]:
|
||||
def _open_outer_tarfile() -> SecureTarArchive:
|
||||
"""Create and open outer tarfile."""
|
||||
if self.tarfile.is_file():
|
||||
raise BackupFileExistError(
|
||||
@@ -449,14 +447,15 @@ class Backup(JobGroup):
|
||||
_LOGGER.error,
|
||||
)
|
||||
|
||||
_outer_secure_tarfile = SecureTarFile(
|
||||
_outer_secure_tarfile = SecureTarArchive(
|
||||
self.tarfile,
|
||||
"w",
|
||||
gzip=False,
|
||||
bufsize=BUF_SIZE,
|
||||
create_version=SECURETAR_CREATE_VERSION,
|
||||
password=self._password,
|
||||
)
|
||||
try:
|
||||
_outer_tarfile = _outer_secure_tarfile.open()
|
||||
_outer_secure_tarfile.open()
|
||||
except PermissionError as ex:
|
||||
raise BackupPermissionError(
|
||||
f"Cannot open backup file {self.tarfile.as_posix()}, permission error!",
|
||||
@@ -468,11 +467,9 @@ class Backup(JobGroup):
|
||||
_LOGGER.error,
|
||||
) from ex
|
||||
|
||||
return _outer_secure_tarfile, _outer_tarfile
|
||||
return _outer_secure_tarfile
|
||||
|
||||
outer_secure_tarfile, outer_tarfile = await self.sys_run_in_executor(
|
||||
_open_outer_tarfile
|
||||
)
|
||||
outer_secure_tarfile = await self.sys_run_in_executor(_open_outer_tarfile)
|
||||
self._outer_secure_tarfile = outer_secure_tarfile
|
||||
|
||||
def _close_outer_tarfile() -> int:
|
||||
@@ -483,7 +480,7 @@ class Backup(JobGroup):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await self._create_cleanup(outer_tarfile)
|
||||
await self._create_finalize(outer_secure_tarfile)
|
||||
size_bytes = await self.sys_run_in_executor(_close_outer_tarfile)
|
||||
self._locations[self.location].size_bytes = size_bytes
|
||||
self._outer_secure_tarfile = None
|
||||
@@ -512,12 +509,24 @@ class Backup(JobGroup):
|
||||
)
|
||||
tmp = TemporaryDirectory(dir=str(backup_tarfile.parent))
|
||||
|
||||
with tarfile.open(backup_tarfile, "r:") as tar:
|
||||
tar.extractall(
|
||||
path=tmp.name,
|
||||
members=secure_path(tar),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
try:
|
||||
with tarfile.open(backup_tarfile, "r:") as tar:
|
||||
# The tar filter rejects path traversal and absolute names,
|
||||
# aborting restore of potentially crafted backups.
|
||||
tar.extractall(
|
||||
path=tmp.name,
|
||||
filter="tar",
|
||||
)
|
||||
except tarfile.FilterError as err:
|
||||
raise BackupInvalidError(
|
||||
f"Can't read backup tarfile {backup_tarfile.as_posix()}: {err}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
except tarfile.TarError as err:
|
||||
raise BackupError(
|
||||
f"Can't read backup tarfile {backup_tarfile.as_posix()}: {err}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
return tmp
|
||||
|
||||
@@ -531,11 +540,11 @@ class Backup(JobGroup):
|
||||
if self._tmp:
|
||||
await self.sys_run_in_executor(self._tmp.cleanup)
|
||||
|
||||
async def _create_cleanup(self, outer_tarfile: TarFile) -> None:
|
||||
"""Cleanup after backup creation.
|
||||
async def _create_finalize(self, outer_archive: SecureTarArchive) -> None:
|
||||
"""Finalize backup creation.
|
||||
|
||||
Separate method to be called from create to ensure
|
||||
that cleanup is always performed, even if an exception is raised.
|
||||
Separate method to be called from create to ensure that the backup is
|
||||
finalized.
|
||||
"""
|
||||
# validate data
|
||||
try:
|
||||
@@ -554,7 +563,7 @@ class Backup(JobGroup):
|
||||
tar_info = tarfile.TarInfo(name="./backup.json")
|
||||
tar_info.size = len(raw_bytes)
|
||||
tar_info.mtime = int(time.time())
|
||||
outer_tarfile.addfile(tar_info, fileobj=fileobj)
|
||||
outer_archive.tar.addfile(tar_info, fileobj=fileobj)
|
||||
|
||||
try:
|
||||
await self.sys_run_in_executor(_add_backup_json)
|
||||
@@ -581,10 +590,9 @@ class Backup(JobGroup):
|
||||
|
||||
tar_name = f"{slug}.tar{'.gz' if self.compressed else ''}"
|
||||
|
||||
addon_file = self._outer_secure_tarfile.create_inner_tar(
|
||||
addon_file = self._outer_secure_tarfile.create_tar(
|
||||
f"./{tar_name}",
|
||||
gzip=self.compressed,
|
||||
password=self._password,
|
||||
)
|
||||
# Take backup
|
||||
try:
|
||||
@@ -634,7 +642,6 @@ class Backup(JobGroup):
|
||||
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
|
||||
addon_file = SecureTarFile(
|
||||
Path(self._tmp.name, tar_name),
|
||||
"r",
|
||||
gzip=self.compressed,
|
||||
bufsize=BUF_SIZE,
|
||||
password=self._password,
|
||||
@@ -730,10 +737,9 @@ class Backup(JobGroup):
|
||||
|
||||
return False
|
||||
|
||||
with outer_secure_tarfile.create_inner_tar(
|
||||
with outer_secure_tarfile.create_tar(
|
||||
f"./{tar_name}",
|
||||
gzip=self.compressed,
|
||||
password=self._password,
|
||||
) as tar_file:
|
||||
atomic_contents_add(
|
||||
tar_file,
|
||||
@@ -793,15 +799,21 @@ class Backup(JobGroup):
|
||||
_LOGGER.info("Restore folder %s", name)
|
||||
with SecureTarFile(
|
||||
tar_name,
|
||||
"r",
|
||||
gzip=self.compressed,
|
||||
bufsize=BUF_SIZE,
|
||||
password=self._password,
|
||||
) as tar_file:
|
||||
# The tar filter rejects path traversal and absolute names,
|
||||
# aborting restore of potentially crafted backups.
|
||||
tar_file.extractall(
|
||||
path=origin_dir, members=tar_file, filter="fully_trusted"
|
||||
path=origin_dir,
|
||||
filter="tar",
|
||||
)
|
||||
_LOGGER.info("Restore folder %s done", name)
|
||||
except tarfile.FilterError as err:
|
||||
raise BackupInvalidError(
|
||||
f"Can't restore folder {name}: {err}", _LOGGER.warning
|
||||
) from err
|
||||
except (tarfile.TarError, OSError) as err:
|
||||
raise BackupError(
|
||||
f"Can't restore folder {name}: {err}", _LOGGER.warning
|
||||
@@ -854,10 +866,9 @@ class Backup(JobGroup):
|
||||
|
||||
tar_name = f"homeassistant.tar{'.gz' if self.compressed else ''}"
|
||||
# Backup Home Assistant Core config directory
|
||||
homeassistant_file = self._outer_secure_tarfile.create_inner_tar(
|
||||
homeassistant_file = self._outer_secure_tarfile.create_tar(
|
||||
f"./{tar_name}",
|
||||
gzip=self.compressed,
|
||||
password=self._password,
|
||||
)
|
||||
|
||||
await self.sys_homeassistant.backup(homeassistant_file, exclude_database)
|
||||
@@ -881,7 +892,6 @@ class Backup(JobGroup):
|
||||
)
|
||||
homeassistant_file = SecureTarFile(
|
||||
tar_name,
|
||||
"r",
|
||||
gzip=self.compressed,
|
||||
bufsize=BUF_SIZE,
|
||||
password=self._password,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Literal
|
||||
from ..mounts.mount import Mount
|
||||
|
||||
BUF_SIZE = 2**20 * 4 # 4MB
|
||||
SECURETAR_CREATE_VERSION = 2
|
||||
DEFAULT_FREEZE_TIMEOUT = 600
|
||||
LOCATION_CLOUD_BACKUP = ".cloud_backup"
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from ..const import (
|
||||
ATTR_CRYPTO,
|
||||
ATTR_DATE,
|
||||
ATTR_DAYS_UNTIL_STALE,
|
||||
ATTR_DOCKER,
|
||||
ATTR_EXCLUDE_DATABASE,
|
||||
ATTR_EXTRA,
|
||||
ATTR_FOLDERS,
|
||||
@@ -35,7 +34,7 @@ from ..const import (
|
||||
FOLDER_SSL,
|
||||
)
|
||||
from ..store.validate import repositories
|
||||
from ..validate import SCHEMA_DOCKER_CONFIG, version_tag
|
||||
from ..validate import version_tag
|
||||
|
||||
ALL_FOLDERS = [
|
||||
FOLDER_SHARE,
|
||||
@@ -114,7 +113,6 @@ SCHEMA_BACKUP = vol.Schema(
|
||||
)
|
||||
),
|
||||
),
|
||||
vol.Optional(ATTR_DOCKER, default=dict): SCHEMA_DOCKER_CONFIG,
|
||||
vol.Optional(ATTR_FOLDERS, default=list): vol.All(
|
||||
v1_folderlist, [vol.In(ALL_FOLDERS)], vol.Unique()
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Constants file for Supervisor."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from ipaddress import IPv4Network, IPv6Network
|
||||
from pathlib import Path
|
||||
from sys import version_info as systemversion
|
||||
from typing import NotRequired, Self, TypedDict
|
||||
from typing import Any, NotRequired, Self, TypedDict
|
||||
|
||||
from aiohttp import __version__ as aiohttpversion
|
||||
|
||||
@@ -536,60 +537,77 @@ class CpuArch(StrEnum):
|
||||
AMD64 = "amd64"
|
||||
|
||||
|
||||
class IngressSessionDataUserDict(TypedDict):
|
||||
"""Response object for ingress session user."""
|
||||
|
||||
id: str
|
||||
username: NotRequired[str | None]
|
||||
# Name is an alias for displayname, only one should be used
|
||||
displayname: NotRequired[str | None]
|
||||
name: NotRequired[str | None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngressSessionDataUser:
|
||||
"""Format of an IngressSessionDataUser object."""
|
||||
class HomeAssistantUser:
|
||||
"""A Home Assistant Core user.
|
||||
|
||||
Incomplete model — Core's User object has additional fields
|
||||
(credentials, refresh_tokens, etc.) that are not represented here.
|
||||
Only fields used by the Supervisor are included.
|
||||
"""
|
||||
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
def to_dict(self) -> IngressSessionDataUserDict:
|
||||
"""Get dictionary representation."""
|
||||
return IngressSessionDataUserDict(
|
||||
id=self.id, displayname=self.display_name, username=self.username
|
||||
)
|
||||
name: str | None = None
|
||||
is_owner: bool = False
|
||||
is_active: bool = False
|
||||
local_only: bool = False
|
||||
system_generated: bool = False
|
||||
group_ids: list[str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: IngressSessionDataUserDict) -> Self:
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> Self:
|
||||
"""Return object from dictionary representation."""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
display_name=data.get("displayname") or data.get("name"),
|
||||
username=data.get("username"),
|
||||
# "displayname" is a legacy key from old ingress session data
|
||||
name=data.get("name") or data.get("displayname"),
|
||||
is_owner=data.get("is_owner", False),
|
||||
is_active=data.get("is_active", False),
|
||||
local_only=data.get("local_only", False),
|
||||
system_generated=data.get("system_generated", False),
|
||||
group_ids=data.get("group_ids"),
|
||||
)
|
||||
|
||||
|
||||
class IngressSessionDataUserDict(TypedDict):
|
||||
"""Serialization format for user data stored in ingress sessions.
|
||||
|
||||
Legacy data may contain "displayname" instead of "name".
|
||||
"""
|
||||
|
||||
id: str
|
||||
username: NotRequired[str | None]
|
||||
name: NotRequired[str | None]
|
||||
|
||||
|
||||
class IngressSessionDataDict(TypedDict):
|
||||
"""Response object for ingress session data."""
|
||||
"""Serialization format for ingress session data."""
|
||||
|
||||
user: IngressSessionDataUserDict
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngressSessionData:
|
||||
"""Format of an IngressSessionData object."""
|
||||
"""Ingress session data attached to a session token."""
|
||||
|
||||
user: IngressSessionDataUser
|
||||
user: HomeAssistantUser
|
||||
|
||||
def to_dict(self) -> IngressSessionDataDict:
|
||||
"""Get dictionary representation."""
|
||||
return IngressSessionDataDict(user=self.user.to_dict())
|
||||
return IngressSessionDataDict(
|
||||
user=IngressSessionDataUserDict(
|
||||
id=self.user.id,
|
||||
name=self.user.name,
|
||||
username=self.user.username,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: IngressSessionDataDict) -> Self:
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> Self:
|
||||
"""Return object from dictionary representation."""
|
||||
return cls(user=IngressSessionDataUser.from_dict(data["user"]))
|
||||
return cls(user=HomeAssistantUser.from_dict(data["user"]))
|
||||
|
||||
|
||||
STARTING_STATES = [
|
||||
|
||||
@@ -304,12 +304,38 @@ class DeviceType(DBusIntEnum):
|
||||
UNKNOWN = 0
|
||||
ETHERNET = 1
|
||||
WIRELESS = 2
|
||||
UNUSED1 = 3
|
||||
UNUSED2 = 4
|
||||
BLUETOOTH = 5
|
||||
OLPC_MESH = 6
|
||||
WIMAX = 7
|
||||
MODEM = 8
|
||||
INFINIBAND = 9
|
||||
BOND = 10
|
||||
VLAN = 11
|
||||
ADSL = 12
|
||||
BRIDGE = 13
|
||||
GENERIC = 14
|
||||
TEAM = 15
|
||||
TUN = 16
|
||||
IP_TUNNEL = 17
|
||||
MAC_VLAN = 18
|
||||
VXLAN = 19
|
||||
VETH = 20
|
||||
MACSEC = 21
|
||||
DUMMY = 22
|
||||
PPP = 23
|
||||
OVS_INTERFACE = 24
|
||||
OVS_PORT = 25
|
||||
OVS_BRIDGE = 26
|
||||
WPAN = 27
|
||||
LOWPAN6 = 28
|
||||
WIREGUARD = 29
|
||||
WIFI_P2P = 30
|
||||
VRF = 31
|
||||
LOOPBACK = 32
|
||||
HSR = 33
|
||||
IPVLAN = 34
|
||||
|
||||
|
||||
class WirelessMethodType(DBusIntEnum):
|
||||
|
||||
@@ -874,11 +874,12 @@ class DockerAddon(DockerInterface):
|
||||
await super().stop(remove_container)
|
||||
|
||||
# If there is a device access issue and the container is removed, clear it
|
||||
if (
|
||||
remove_container
|
||||
and self.addon.device_access_missing_issue in self.sys_resolution.issues
|
||||
if remove_container and (
|
||||
issue := self.sys_resolution.get_issue_if_present(
|
||||
self.addon.device_access_missing_issue
|
||||
)
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(self.addon.device_access_missing_issue)
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
@Job(
|
||||
name="docker_addon_hardware_events",
|
||||
|
||||
@@ -7,7 +7,6 @@ from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
from io import BufferedReader, BufferedWriter
|
||||
from ipaddress import IPv4Address
|
||||
@@ -25,8 +24,6 @@ from aiodocker.stream import Stream
|
||||
from aiodocker.types import JSONObject
|
||||
from aiohttp import ClientTimeout, UnixConnector
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||
from docker import errors as docker_errors
|
||||
from docker.client import DockerClient
|
||||
import requests
|
||||
|
||||
from ..const import (
|
||||
@@ -270,8 +267,6 @@ class DockerAPI(CoreSysAttributes):
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys = coresys
|
||||
# We keep both until we can fully refactor to aiodocker
|
||||
self._dockerpy: DockerClient | None = None
|
||||
self.docker: aiodocker.Docker = aiodocker.Docker(
|
||||
url="unix://localhost", # dummy hostname for URL composition
|
||||
connector=UnixConnector(SOCKET_DOCKER.as_posix()),
|
||||
@@ -289,15 +284,6 @@ class DockerAPI(CoreSysAttributes):
|
||||
|
||||
async def post_init(self) -> Self:
|
||||
"""Post init actions that must be done in event loop."""
|
||||
self._dockerpy = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
DockerClient,
|
||||
base_url=f"unix:/{SOCKET_DOCKER.as_posix()}",
|
||||
version="auto",
|
||||
timeout=900,
|
||||
),
|
||||
)
|
||||
self._info = await DockerInfo.new(await self.docker.system.info())
|
||||
await self.config.read_data()
|
||||
self._network = await DockerNetwork(self.docker).post_init(
|
||||
@@ -305,13 +291,6 @@ class DockerAPI(CoreSysAttributes):
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def dockerpy(self) -> DockerClient:
|
||||
"""Get docker API client."""
|
||||
if not self._dockerpy:
|
||||
raise RuntimeError("Docker API Client not initialized!")
|
||||
return self._dockerpy
|
||||
|
||||
@property
|
||||
def network(self) -> DockerNetwork:
|
||||
"""Get Docker network."""
|
||||
@@ -725,43 +704,40 @@ class DockerAPI(CoreSysAttributes):
|
||||
async def repair(self) -> None:
|
||||
"""Repair local docker overlayfs2 issues."""
|
||||
|
||||
def repair_docker_blocking():
|
||||
_LOGGER.info("Prune stale containers")
|
||||
try:
|
||||
output = self.dockerpy.api.prune_containers()
|
||||
_LOGGER.debug("Containers prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for containers prune: %s", err)
|
||||
_LOGGER.info("Prune stale containers")
|
||||
try:
|
||||
output = await self.docker.containers.prune()
|
||||
_LOGGER.debug("Containers prune: %s", output)
|
||||
except aiodocker.DockerError as err:
|
||||
_LOGGER.warning("Error for containers prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale images")
|
||||
try:
|
||||
output = self.dockerpy.api.prune_images(filters={"dangling": False})
|
||||
_LOGGER.debug("Images prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for images prune: %s", err)
|
||||
_LOGGER.info("Prune stale images")
|
||||
try:
|
||||
output = await self.images.prune(filters={"dangling": "false"})
|
||||
_LOGGER.debug("Images prune: %s", output)
|
||||
except aiodocker.DockerError as err:
|
||||
_LOGGER.warning("Error for images prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale builds")
|
||||
try:
|
||||
output = self.dockerpy.api.prune_builds()
|
||||
_LOGGER.debug("Builds prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for builds prune: %s", err)
|
||||
_LOGGER.info("Prune stale builds")
|
||||
try:
|
||||
output = await self.images.prune_builds()
|
||||
_LOGGER.debug("Builds prune: %s", output)
|
||||
except aiodocker.DockerError as err:
|
||||
_LOGGER.warning("Error for builds prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale volumes")
|
||||
try:
|
||||
output = self.dockerpy.api.prune_volumes()
|
||||
_LOGGER.debug("Volumes prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for volumes prune: %s", err)
|
||||
_LOGGER.info("Prune stale volumes")
|
||||
try:
|
||||
output = await self.docker.volumes.prune()
|
||||
_LOGGER.debug("Volumes prune: %s", output)
|
||||
except aiodocker.DockerError as err:
|
||||
_LOGGER.warning("Error for volumes prune: %s", err)
|
||||
|
||||
_LOGGER.info("Prune stale networks")
|
||||
try:
|
||||
output = self.dockerpy.api.prune_networks()
|
||||
_LOGGER.debug("Networks prune: %s", output)
|
||||
except docker_errors.APIError as err:
|
||||
_LOGGER.warning("Error for networks prune: %s", err)
|
||||
|
||||
await self.sys_run_in_executor(repair_docker_blocking)
|
||||
_LOGGER.info("Prune stale networks")
|
||||
try:
|
||||
output = await self.docker.networks.prune()
|
||||
_LOGGER.debug("Networks prune: %s", output)
|
||||
except aiodocker.DockerError as err:
|
||||
_LOGGER.warning("Error for networks prune: %s", err)
|
||||
|
||||
_LOGGER.info("Fix stale container on hassio network")
|
||||
try:
|
||||
|
||||
@@ -46,7 +46,7 @@ class HassioNotSupportedError(HassioError):
|
||||
# API
|
||||
|
||||
|
||||
class APIError(HassioError, RuntimeError):
|
||||
class APIError(HassioError):
|
||||
"""API errors."""
|
||||
|
||||
status = 400
|
||||
@@ -620,18 +620,6 @@ class AuthListUsersError(AuthError, APIUnknownSupervisorError):
|
||||
message_template = "Can't request listing users on Home Assistant"
|
||||
|
||||
|
||||
class AuthListUsersNoneResponseError(AuthError, APIInternalServerError):
|
||||
"""Auth error if listing users returned invalid None response."""
|
||||
|
||||
error_key = "auth_list_users_none_response_error"
|
||||
message_template = "Home Assistant returned invalid response of `{none}` instead of a list of users. Check Home Assistant logs for details (check with `{logs_command}`)"
|
||||
extra_fields = {"none": "None", "logs_command": "ha core logs"}
|
||||
|
||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
||||
"""Initialize exception."""
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
class AuthInvalidNonStringValueError(AuthError, APIUnauthorized):
|
||||
"""Auth error if something besides a string provided as username or password."""
|
||||
|
||||
@@ -976,6 +964,44 @@ class ResolutionFixupJobError(ResolutionFixupError, JobException):
|
||||
"""Raise on job error."""
|
||||
|
||||
|
||||
class ResolutionCheckNotFound(ResolutionNotFound, APINotFound): # pylint: disable=too-many-ancestors
|
||||
"""Raise if check does not exist."""
|
||||
|
||||
error_key = "resolution_check_not_found_error"
|
||||
message_template = "Check '{check}' does not exist"
|
||||
|
||||
def __init__(
|
||||
self, logger: Callable[..., None] | None = None, *, check: str
|
||||
) -> None:
|
||||
"""Initialize exception."""
|
||||
self.extra_fields = {"check": check}
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
class ResolutionIssueNotFound(ResolutionNotFound, APINotFound): # pylint: disable=too-many-ancestors
|
||||
"""Raise if issue does not exist."""
|
||||
|
||||
error_key = "resolution_issue_not_found_error"
|
||||
message_template = "Issue {uuid} does not exist"
|
||||
|
||||
def __init__(self, logger: Callable[..., None] | None = None, *, uuid: str) -> None:
|
||||
"""Initialize exception."""
|
||||
self.extra_fields = {"uuid": uuid}
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
class ResolutionSuggestionNotFound(ResolutionNotFound, APINotFound): # pylint: disable=too-many-ancestors
|
||||
"""Raise if suggestion does not exist."""
|
||||
|
||||
error_key = "resolution_suggestion_not_found_error"
|
||||
message_template = "Suggestion {uuid} does not exist"
|
||||
|
||||
def __init__(self, logger: Callable[..., None] | None = None, *, uuid: str) -> None:
|
||||
"""Initialize exception."""
|
||||
self.extra_fields = {"uuid": uuid}
|
||||
super().__init__(None, logger)
|
||||
|
||||
|
||||
# Store
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Home Assistant control object."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import errno
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
@@ -13,7 +12,7 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
from securetar import AddFileError, SecureTarFile, atomic_contents_add, secure_path
|
||||
from securetar import AddFileError, SecureTarFile, atomic_contents_add
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
@@ -35,11 +34,11 @@ from ..const import (
|
||||
ATTR_WATCHDOG,
|
||||
FILE_HASSIO_HOMEASSISTANT,
|
||||
BusEvent,
|
||||
IngressSessionDataUser,
|
||||
IngressSessionDataUserDict,
|
||||
HomeAssistantUser,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import (
|
||||
BackupInvalidError,
|
||||
ConfigurationFileError,
|
||||
HomeAssistantBackupError,
|
||||
HomeAssistantError,
|
||||
@@ -47,7 +46,6 @@ from ..exceptions import (
|
||||
)
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..hardware.data import Device
|
||||
from ..jobs.const import JobConcurrency, JobThrottle
|
||||
from ..jobs.decorator import Job
|
||||
from ..resolution.const import UnhealthyReason
|
||||
from ..utils import remove_folder, remove_folder_with_excludes
|
||||
@@ -495,11 +493,16 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
||||
# extract backup
|
||||
try:
|
||||
with tar_file as backup:
|
||||
# The tar filter rejects path traversal and absolute names,
|
||||
# aborting restore of potentially crafted backups.
|
||||
backup.extractall(
|
||||
path=temp_path,
|
||||
members=secure_path(backup),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
except tarfile.FilterError as err:
|
||||
raise BackupInvalidError(
|
||||
f"Invalid tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
except tarfile.TarError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
|
||||
@@ -570,21 +573,12 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
|
||||
if attr in data:
|
||||
self._data[attr] = data[attr]
|
||||
|
||||
@Job(
|
||||
name="home_assistant_get_users",
|
||||
throttle_period=timedelta(minutes=5),
|
||||
internal=True,
|
||||
concurrency=JobConcurrency.QUEUE,
|
||||
throttle=JobThrottle.THROTTLE,
|
||||
)
|
||||
async def get_users(self) -> list[IngressSessionDataUser]:
|
||||
"""Get list of all configured users."""
|
||||
list_of_users: (
|
||||
list[IngressSessionDataUserDict] | None
|
||||
) = await self.sys_homeassistant.websocket.async_send_command(
|
||||
async def list_users(self) -> list[HomeAssistantUser]:
|
||||
"""Fetch list of all users from Home Assistant Core via WebSocket.
|
||||
|
||||
Raises HomeAssistantWSError on WebSocket connection/communication failure.
|
||||
"""
|
||||
raw: list[dict[str, Any]] = await self.websocket.async_send_command(
|
||||
{ATTR_TYPE: "config/auth/list"}
|
||||
)
|
||||
|
||||
if list_of_users:
|
||||
return [IngressSessionDataUser.from_dict(data) for data in list_of_users]
|
||||
return []
|
||||
return [HomeAssistantUser.from_dict(data) for data in raw]
|
||||
|
||||
@@ -65,7 +65,7 @@ class WSClient:
|
||||
if not self._client.closed:
|
||||
await self._client.close()
|
||||
|
||||
async def async_send_command(self, message: dict[str, Any]) -> T | None:
|
||||
async def async_send_command(self, message: dict[str, Any]) -> T:
|
||||
"""Send a websocket message, and return the response."""
|
||||
self._message_id += 1
|
||||
message["id"] = self._message_id
|
||||
@@ -146,7 +146,7 @@ class WSClient:
|
||||
try:
|
||||
client = await session.ws_connect(url, ssl=False)
|
||||
except aiohttp.client_exceptions.ClientConnectorError:
|
||||
raise HomeAssistantWSError("Can't connect") from None
|
||||
raise HomeAssistantWSConnectionError("Can't connect") from None
|
||||
|
||||
hello_message = await client.receive_json()
|
||||
|
||||
@@ -200,10 +200,11 @@ class HomeAssistantWebSocket(CoreSysAttributes):
|
||||
async def _ensure_connected(self) -> None:
|
||||
"""Ensure WebSocket connection is ready.
|
||||
|
||||
Raises HomeAssistantWSError if unable to connect.
|
||||
Raises HomeAssistantWSConnectionError if unable to connect.
|
||||
Raises HomeAssistantAuthError if authentication with Core fails.
|
||||
"""
|
||||
if self.sys_core.state in CLOSING_STATES:
|
||||
raise HomeAssistantWSError(
|
||||
raise HomeAssistantWSConnectionError(
|
||||
"WebSocket not available, system is shutting down"
|
||||
)
|
||||
|
||||
@@ -211,7 +212,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
|
||||
# If we are already connected, we can avoid the check_api_state call
|
||||
# since it makes a new socket connection and we already have one.
|
||||
if not connected and not await self.sys_homeassistant.api.check_api_state():
|
||||
raise HomeAssistantWSError(
|
||||
raise HomeAssistantWSConnectionError(
|
||||
"Can't connect to Home Assistant Core WebSocket, the API is not reachable"
|
||||
)
|
||||
|
||||
@@ -251,10 +252,10 @@ class HomeAssistantWebSocket(CoreSysAttributes):
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
|
||||
async def async_send_command(self, message: dict[str, Any]) -> T | None:
|
||||
async def async_send_command(self, message: dict[str, Any]) -> T:
|
||||
"""Send a command and return the response.
|
||||
|
||||
Raises HomeAssistantWSError if unable to connect to Home Assistant Core.
|
||||
Raises HomeAssistantWSError on WebSocket connection or communication failure.
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
# _ensure_connected guarantees self._client is set
|
||||
|
||||
@@ -215,10 +215,10 @@ class Mount(CoreSysAttributes, ABC):
|
||||
await self._update_state(unit)
|
||||
|
||||
# If active, dismiss corresponding failed mount issue if found
|
||||
if (
|
||||
mounted := await self.is_mounted()
|
||||
) and self.failed_issue in self.sys_resolution.issues:
|
||||
self.sys_resolution.dismiss_issue(self.failed_issue)
|
||||
if (mounted := await self.is_mounted()) and (
|
||||
issue := self.sys_resolution.get_issue_if_present(self.failed_issue)
|
||||
):
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
return mounted
|
||||
|
||||
@@ -361,8 +361,8 @@ class Mount(CoreSysAttributes, ABC):
|
||||
await self._restart()
|
||||
|
||||
# If it is mounted now, dismiss corresponding issue if present
|
||||
if self.failed_issue in self.sys_resolution.issues:
|
||||
self.sys_resolution.dismiss_issue(self.failed_issue)
|
||||
if issue := self.sys_resolution.get_issue_if_present(self.failed_issue):
|
||||
self.sys_resolution.dismiss_issue(issue)
|
||||
|
||||
async def _restart(self) -> None:
|
||||
"""Restart mount unit to re-mount."""
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from ..const import ATTR_CHECKS
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import ResolutionNotFound
|
||||
from ..exceptions import ResolutionCheckNotFound
|
||||
from ..utils.sentry import async_capture_exception
|
||||
from .checks.base import CheckBase
|
||||
from .validate import get_valid_modules
|
||||
@@ -50,7 +50,7 @@ class ResolutionCheck(CoreSysAttributes):
|
||||
if slug in self._checks:
|
||||
return self._checks[slug]
|
||||
|
||||
raise ResolutionNotFound(f"Check with slug {slug} not found!")
|
||||
raise ResolutionCheckNotFound(check=slug)
|
||||
|
||||
async def check_system(self) -> None:
|
||||
"""Check the system."""
|
||||
|
||||
@@ -7,7 +7,11 @@ import attr
|
||||
|
||||
from ..bus import EventListener
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import ResolutionError, ResolutionNotFound
|
||||
from ..exceptions import (
|
||||
ResolutionError,
|
||||
ResolutionIssueNotFound,
|
||||
ResolutionSuggestionNotFound,
|
||||
)
|
||||
from ..homeassistant.const import WSEvent
|
||||
from ..utils.common import FileConfiguration
|
||||
from .check import ResolutionCheck
|
||||
@@ -165,21 +169,37 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
|
||||
]
|
||||
}
|
||||
|
||||
def get_suggestion(self, uuid: str) -> Suggestion:
|
||||
def get_suggestion_by_id(self, uuid: str) -> Suggestion:
|
||||
"""Return suggestion with uuid."""
|
||||
for suggestion in self._suggestions:
|
||||
if suggestion.uuid != uuid:
|
||||
continue
|
||||
return suggestion
|
||||
raise ResolutionNotFound()
|
||||
raise ResolutionSuggestionNotFound(uuid=uuid)
|
||||
|
||||
def get_issue(self, uuid: str) -> Issue:
|
||||
def get_suggestion_if_present(self, suggestion: Suggestion) -> Suggestion | None:
|
||||
"""Get suggestion matching provided one if it exists in resolution manager."""
|
||||
for s in self._suggestions:
|
||||
if s != suggestion:
|
||||
continue
|
||||
return s
|
||||
return None
|
||||
|
||||
def get_issue_by_id(self, uuid: str) -> Issue:
|
||||
"""Return issue with uuid."""
|
||||
for issue in self._issues:
|
||||
if issue.uuid != uuid:
|
||||
continue
|
||||
return issue
|
||||
raise ResolutionNotFound()
|
||||
raise ResolutionIssueNotFound(uuid=uuid)
|
||||
|
||||
def get_issue_if_present(self, issue: Issue) -> Issue | None:
|
||||
"""Get issue matching provided one if it exists in resolution manager."""
|
||||
for i in self._issues:
|
||||
if i != issue:
|
||||
continue
|
||||
return i
|
||||
return None
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
@@ -234,20 +254,13 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
async def apply_suggestion(self, suggestion: Suggestion) -> None:
|
||||
"""Apply suggested action."""
|
||||
if suggestion not in self._suggestions:
|
||||
raise ResolutionError(
|
||||
f"Suggestion {suggestion.uuid} is not valid", _LOGGER.warning
|
||||
)
|
||||
|
||||
suggestion = self.get_suggestion_by_id(suggestion.uuid)
|
||||
await self.fixup.apply_fixup(suggestion)
|
||||
await self.healthcheck()
|
||||
|
||||
def dismiss_suggestion(self, suggestion: Suggestion) -> None:
|
||||
"""Dismiss suggested action."""
|
||||
if suggestion not in self._suggestions:
|
||||
raise ResolutionError(
|
||||
f"The UUID {suggestion.uuid} is not valid suggestion", _LOGGER.warning
|
||||
)
|
||||
suggestion = self.get_suggestion_by_id(suggestion.uuid)
|
||||
self._suggestions.remove(suggestion)
|
||||
|
||||
# Remove event listeners if present
|
||||
@@ -263,10 +276,7 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
|
||||
|
||||
def dismiss_issue(self, issue: Issue) -> None:
|
||||
"""Dismiss suggested action."""
|
||||
if issue not in self._issues:
|
||||
raise ResolutionError(
|
||||
f"The UUID {issue.uuid} is not a valid issue", _LOGGER.warning
|
||||
)
|
||||
issue = self.get_issue_by_id(issue.uuid)
|
||||
self._issues.remove(issue)
|
||||
|
||||
# Event on issue removal
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"local": {
|
||||
"name": "Local add-ons",
|
||||
"name": "Local apps",
|
||||
"url": "https://home-assistant.io/hassio",
|
||||
"maintainer": "you"
|
||||
},
|
||||
"core": {
|
||||
"name": "Official add-ons",
|
||||
"name": "Official apps",
|
||||
"url": "https://home-assistant.io/addons",
|
||||
"maintainer": "Home Assistant"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ from .const import (
|
||||
ATTR_LOGGING,
|
||||
ATTR_MTU,
|
||||
ATTR_MULTICAST,
|
||||
ATTR_NAME,
|
||||
ATTR_OBSERVER,
|
||||
ATTR_OTA,
|
||||
ATTR_PASSWORD,
|
||||
@@ -206,7 +207,9 @@ SCHEMA_SESSION_DATA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ID): str,
|
||||
vol.Required(ATTR_USERNAME, default=None): vol.Maybe(str),
|
||||
vol.Required(ATTR_DISPLAYNAME, default=None): vol.Maybe(str),
|
||||
vol.Required(ATTR_NAME, default=None): vol.Maybe(str),
|
||||
# Legacy key, replaced by ATTR_NAME
|
||||
vol.Optional(ATTR_DISPLAYNAME): vol.Maybe(str),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import aiodocker
|
||||
from aiodocker.containers import DockerContainer
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
from securetar import SecureTarFile
|
||||
from securetar import SecureTarArchive, SecureTarFile
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.addons.const import AddonBackupMode
|
||||
@@ -34,6 +34,8 @@ from supervisor.exceptions import (
|
||||
)
|
||||
from supervisor.hardware.helper import HwHelper
|
||||
from supervisor.ingress import Ingress
|
||||
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
||||
from supervisor.resolution.data import Issue
|
||||
from supervisor.utils.dt import utcnow
|
||||
|
||||
from .test_manager import BOOT_FAIL_ISSUE, BOOT_FAIL_SUGGESTIONS
|
||||
@@ -436,8 +438,11 @@ async def test_backup(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
assert await install_addon_ssh.backup(tarfile) is None
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
assert await install_addon_ssh.backup(tar_file) is None
|
||||
archive.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["running", "stopped"])
|
||||
@@ -457,8 +462,11 @@ async def test_backup_no_config(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
assert await install_addon_ssh.backup(tarfile) is None
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
assert await install_addon_ssh.backup(tar_file) is None
|
||||
archive.close()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
|
||||
@@ -473,14 +481,17 @@ async def test_backup_with_pre_post_command(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
with (
|
||||
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
|
||||
patch.object(
|
||||
Addon, "backup_post", new=PropertyMock(return_value="backup_post")
|
||||
),
|
||||
):
|
||||
assert await install_addon_ssh.backup(tarfile) is None
|
||||
assert await install_addon_ssh.backup(tar_file) is None
|
||||
archive.close()
|
||||
|
||||
assert container.exec.call_count == 2
|
||||
assert container.exec.call_args_list[0].args[0] == "backup_pre"
|
||||
@@ -543,15 +554,18 @@ async def test_backup_with_pre_command_error(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
with (
|
||||
patch.object(DockerAddon, "is_running", return_value=True),
|
||||
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
|
||||
pytest.raises(exc_type_raised),
|
||||
):
|
||||
assert await install_addon_ssh.backup(tarfile) is None
|
||||
assert await install_addon_ssh.backup(tar_file) is None
|
||||
|
||||
assert not tarfile.path.exists()
|
||||
assert not tar_file.path.exists()
|
||||
archive.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["running", "stopped"])
|
||||
@@ -568,7 +582,9 @@ async def test_backup_cold_mode(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
with (
|
||||
patch.object(
|
||||
AddonModel,
|
||||
@@ -579,7 +595,8 @@ async def test_backup_cold_mode(
|
||||
DockerAddon, "is_running", side_effect=[status == "running", False, False]
|
||||
),
|
||||
):
|
||||
start_task = await install_addon_ssh.backup(tarfile)
|
||||
start_task = await install_addon_ssh.backup(tar_file)
|
||||
archive.close()
|
||||
|
||||
assert bool(start_task) is (status == "running")
|
||||
|
||||
@@ -607,7 +624,9 @@ async def test_backup_cold_mode_with_watchdog(
|
||||
|
||||
# Patching out the normal end of backup process leaves the container in a stopped state
|
||||
# Watchdog should still not try to restart it though, it should remain this way
|
||||
tarfile = SecureTarFile(coresys.config.path_tmp / "test.tar.gz", "w")
|
||||
archive = SecureTarArchive(coresys.config.path_tmp / "test.tar", "w")
|
||||
archive.open()
|
||||
tar_file = archive.create_tar("./test.tar.gz")
|
||||
with (
|
||||
patch.object(Addon, "start") as start,
|
||||
patch.object(Addon, "restart") as restart,
|
||||
@@ -619,10 +638,11 @@ async def test_backup_cold_mode_with_watchdog(
|
||||
new=PropertyMock(return_value=AddonBackupMode.COLD),
|
||||
),
|
||||
):
|
||||
await install_addon_ssh.backup(tarfile)
|
||||
await install_addon_ssh.backup(tar_file)
|
||||
await asyncio.sleep(0)
|
||||
start.assert_not_called()
|
||||
restart.assert_not_called()
|
||||
archive.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["running", "stopped"])
|
||||
@@ -635,7 +655,7 @@ async def test_restore(coresys: CoreSys, install_addon_ssh: Addon, status: str)
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(get_fixture_path(f"backup_local_ssh_{status}.tar.gz"), "r")
|
||||
tarfile = SecureTarFile(get_fixture_path(f"backup_local_ssh_{status}.tar.gz"))
|
||||
with patch.object(DockerAddon, "is_running", return_value=False):
|
||||
start_task = await coresys.addons.restore(TEST_ADDON_SLUG, tarfile)
|
||||
|
||||
@@ -655,7 +675,7 @@ async def test_restore_while_running(
|
||||
install_addon_ssh.path_data.mkdir()
|
||||
await install_addon_ssh.load()
|
||||
|
||||
tarfile = SecureTarFile(get_fixture_path("backup_local_ssh_stopped.tar.gz"), "r")
|
||||
tarfile = SecureTarFile(get_fixture_path("backup_local_ssh_stopped.tar.gz"))
|
||||
with (
|
||||
patch.object(DockerAddon, "is_running", return_value=True),
|
||||
patch.object(Ingress, "update_hass_panel"),
|
||||
@@ -688,7 +708,7 @@ async def test_restore_while_running_with_watchdog(
|
||||
|
||||
# We restore a stopped backup so restore will not restart it
|
||||
# Watchdog will see it stop and should not attempt reanimation either
|
||||
tarfile = SecureTarFile(get_fixture_path("backup_local_ssh_stopped.tar.gz"), "r")
|
||||
tarfile = SecureTarFile(get_fixture_path("backup_local_ssh_stopped.tar.gz"))
|
||||
with (
|
||||
patch.object(Addon, "start") as start,
|
||||
patch.object(Addon, "restart") as restart,
|
||||
@@ -976,16 +996,40 @@ async def test_addon_manual_only_boot(install_addon_example: Addon):
|
||||
assert install_addon_example.boot == "manual"
|
||||
|
||||
|
||||
async def test_addon_start_dismisses_boot_fail(
|
||||
coresys: CoreSys, install_addon_ssh: Addon
|
||||
@pytest.mark.parametrize(
|
||||
("initial_state", "target_state", "issue", "suggestions"),
|
||||
[
|
||||
(
|
||||
AddonState.ERROR,
|
||||
AddonState.STARTED,
|
||||
BOOT_FAIL_ISSUE,
|
||||
[suggestion.type for suggestion in BOOT_FAIL_SUGGESTIONS],
|
||||
),
|
||||
(
|
||||
AddonState.STARTED,
|
||||
AddonState.STOPPED,
|
||||
Issue(
|
||||
IssueType.DEVICE_ACCESS_MISSING,
|
||||
ContextType.ADDON,
|
||||
reference=TEST_ADDON_SLUG,
|
||||
),
|
||||
[SuggestionType.EXECUTE_RESTART],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_addon_state_dismisses_issue(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
initial_state: AddonState,
|
||||
target_state: AddonState,
|
||||
issue: Issue,
|
||||
suggestions: list[SuggestionType],
|
||||
):
|
||||
"""Test a successful start dismisses the boot fail issue."""
|
||||
install_addon_ssh.state = AddonState.ERROR
|
||||
coresys.resolution.add_issue(
|
||||
BOOT_FAIL_ISSUE, [suggestion.type for suggestion in BOOT_FAIL_SUGGESTIONS]
|
||||
)
|
||||
"""Test an addon state change dismisses the issues."""
|
||||
install_addon_ssh.state = initial_state
|
||||
coresys.resolution.add_issue(issue, suggestions)
|
||||
|
||||
install_addon_ssh.state = AddonState.STARTED
|
||||
install_addon_ssh.state = target_state
|
||||
assert coresys.resolution.issues == []
|
||||
assert coresys.resolution.suggestions == []
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test auth API."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohttp.hdrs import WWW_AUTHENTICATE
|
||||
@@ -169,46 +168,25 @@ async def test_list_users(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("send_command_mock", "error_response", "expected_log"),
|
||||
[
|
||||
(
|
||||
AsyncMock(return_value=None),
|
||||
{
|
||||
"result": "error",
|
||||
"message": "Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
|
||||
"error_key": "auth_list_users_none_response_error",
|
||||
"extra_fields": {"none": "None", "logs_command": "ha core logs"},
|
||||
},
|
||||
"Home Assistant returned invalid response of `None` instead of a list of users. Check Home Assistant logs for details (check with `ha core logs`)",
|
||||
),
|
||||
(
|
||||
AsyncMock(side_effect=HomeAssistantWSError("fail")),
|
||||
{
|
||||
"result": "error",
|
||||
"message": "Can't request listing users on Home Assistant. Check supervisor logs for details (check with 'ha supervisor logs')",
|
||||
"error_key": "auth_list_users_error",
|
||||
"extra_fields": {"logs_command": "ha supervisor logs"},
|
||||
},
|
||||
"Can't request listing users on Home Assistant: fail",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_list_users_failure(
|
||||
async def test_list_users_ws_error(
|
||||
api_client: TestClient,
|
||||
ha_ws_client: AsyncMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
send_command_mock: AsyncMock,
|
||||
error_response: dict[str, Any],
|
||||
expected_log: str,
|
||||
):
|
||||
"""Test failure listing users via API."""
|
||||
ha_ws_client.async_send_command = send_command_mock
|
||||
"""Test WS error when listing users via API."""
|
||||
ha_ws_client.async_send_command = AsyncMock(
|
||||
side_effect=HomeAssistantWSError("fail")
|
||||
)
|
||||
resp = await api_client.get("/auth/list")
|
||||
assert resp.status == 500
|
||||
result = await resp.json()
|
||||
assert result == error_response
|
||||
assert expected_log in caplog.text
|
||||
assert result == {
|
||||
"result": "error",
|
||||
"message": "Can't request listing users on Home Assistant. Check supervisor logs for details (check with 'ha supervisor logs')",
|
||||
"error_key": "auth_list_users_error",
|
||||
"extra_fields": {"logs_command": "ha supervisor logs"},
|
||||
}
|
||||
assert "Can't request listing users on Home Assistant: fail" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -374,6 +374,11 @@ async def test_advanced_logs_formatters(
|
||||
await api_client.get("/host/logs/identifiers/test", headers=headers)
|
||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
|
||||
|
||||
journal_logs_reader.reset_mock()
|
||||
|
||||
await api_client.get("/host/logs/identifiers/test", skip_auto_headers={"Accept"})
|
||||
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.PLAIN, False)
|
||||
|
||||
|
||||
async def test_advanced_logs_errors(coresys: CoreSys, api_client: TestClient):
|
||||
"""Test advanced logging API errors."""
|
||||
|
||||
@@ -99,9 +99,7 @@ async def test_validate_session_with_user_id(
|
||||
assert session in coresys.ingress.sessions_data
|
||||
assert coresys.ingress.get_session_data(session).user.id == "some-id"
|
||||
assert coresys.ingress.get_session_data(session).user.username == "sn"
|
||||
assert (
|
||||
coresys.ingress.get_session_data(session).user.display_name == "Some Name"
|
||||
)
|
||||
assert coresys.ingress.get_session_data(session).user.name == "Some Name"
|
||||
|
||||
|
||||
async def test_ingress_proxy_no_content_type_for_empty_body_responses(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Resolution API."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
@@ -46,7 +47,7 @@ async def test_api_resolution_base(coresys: CoreSys, api_client: TestClient):
|
||||
async def test_api_resolution_dismiss_suggestion(
|
||||
coresys: CoreSys, api_client: TestClient
|
||||
):
|
||||
"""Test resolution manager suggestion apply api."""
|
||||
"""Test resolution manager dismiss suggestion api."""
|
||||
coresys.resolution.add_suggestion(
|
||||
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
||||
)
|
||||
@@ -189,7 +190,9 @@ async def test_issue_not_found(api_client: TestClient, method: str, url: str):
|
||||
resp = await api_client.request(method, url)
|
||||
assert resp.status == 404
|
||||
body = await resp.json()
|
||||
assert body["message"] == "The supplied UUID is not a valid issue"
|
||||
assert body["message"] == "Issue bad does not exist"
|
||||
assert body["error_key"] == "resolution_issue_not_found_error"
|
||||
assert body["extra_fields"] == {"uuid": "bad"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -201,7 +204,9 @@ async def test_suggestion_not_found(api_client: TestClient, method: str, url: st
|
||||
resp = await api_client.request(method, url)
|
||||
assert resp.status == 404
|
||||
body = await resp.json()
|
||||
assert body["message"] == "The supplied UUID is not a valid suggestion"
|
||||
assert body["message"] == "Suggestion bad does not exist"
|
||||
assert body["error_key"] == "resolution_suggestion_not_found_error"
|
||||
assert body["extra_fields"] == {"uuid": "bad"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -211,6 +216,8 @@ async def test_suggestion_not_found(api_client: TestClient, method: str, url: st
|
||||
async def test_check_not_found(api_client: TestClient, method: str, url: str):
|
||||
"""Test check not found error."""
|
||||
resp = await api_client.request(method, url)
|
||||
assert resp.status == 404
|
||||
assert resp.status == HTTPStatus.NOT_FOUND
|
||||
body = await resp.json()
|
||||
assert body["message"] == "The supplied check slug is not available"
|
||||
assert body["message"] == "Check 'bad' does not exist"
|
||||
assert body["error_key"] == "resolution_check_not_found_error"
|
||||
assert body["extra_fields"] == {"check": "bad"}
|
||||
|
||||
@@ -8,7 +8,7 @@ import tarfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from securetar import AddFileError
|
||||
from securetar import AddFileError, InvalidPasswordError, SecureTarReadError
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.backups.backup import Backup, BackupLocation
|
||||
@@ -234,7 +234,21 @@ async def test_consolidate_failure(coresys: CoreSys, tmp_path: Path):
|
||||
pytest.raises(
|
||||
BackupInvalidError, match="Invalid password for backup 93b462f8"
|
||||
),
|
||||
), # Invalid password
|
||||
), # Invalid password (legacy securetar exception)
|
||||
(
|
||||
None,
|
||||
SecureTarReadError,
|
||||
pytest.raises(
|
||||
BackupInvalidError, match="Invalid password for backup 93b462f8"
|
||||
),
|
||||
), # Invalid password (securetar >= 2026.2.0 raises SecureTarReadError)
|
||||
(
|
||||
None,
|
||||
InvalidPasswordError,
|
||||
pytest.raises(
|
||||
BackupInvalidError, match="Invalid password for backup 93b462f8"
|
||||
),
|
||||
), # Invalid password (securetar >= 2026.2.0 with v3 backup raises InvalidPasswordError)
|
||||
],
|
||||
)
|
||||
async def test_validate_backup(
|
||||
@@ -244,7 +258,12 @@ async def test_validate_backup(
|
||||
securetar_side_effect: type[Exception] | None,
|
||||
expected_exception: AbstractContextManager,
|
||||
):
|
||||
"""Parameterized test for validate_backup."""
|
||||
"""Parameterized test for validate_backup.
|
||||
|
||||
Note that it is paramount that BackupInvalidError is raised for invalid password
|
||||
cases, as this is used by the Core to determine if a backup password is invalid
|
||||
and offer a input field to the user to input the correct password.
|
||||
"""
|
||||
enc_tar = Path(copy(get_fixture_path("backup_example_enc.tar"), tmp_path))
|
||||
enc_backup = Backup(coresys, enc_tar, "test", None)
|
||||
await enc_backup.load()
|
||||
@@ -273,3 +292,44 @@ async def test_validate_backup(
|
||||
expected_exception,
|
||||
):
|
||||
await enc_backup.validate_backup(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("password", "expected_exception"),
|
||||
[
|
||||
("supervisor", does_not_raise()),
|
||||
(
|
||||
"wrong_password",
|
||||
pytest.raises(
|
||||
BackupInvalidError, match="Invalid password for backup f92f0339"
|
||||
),
|
||||
),
|
||||
(
|
||||
None,
|
||||
pytest.raises(
|
||||
BackupInvalidError, match="Invalid password for backup f92f0339"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_validate_backup_v3(
|
||||
coresys: CoreSys,
|
||||
tmp_path: Path,
|
||||
password: str | None,
|
||||
expected_exception: AbstractContextManager,
|
||||
):
|
||||
"""Test validate_backup with a real SecureTar v3 encrypted backup.
|
||||
|
||||
SecureTar v3 uses Argon2id key derivation and raises InvalidPasswordError
|
||||
on wrong passwords. It is paramount that BackupInvalidError is raised for
|
||||
invalid password cases, as this is used by the Core to determine if a backup
|
||||
password is invalid and offer a dialog to the user to input the correct
|
||||
password.
|
||||
"""
|
||||
v3_tar = Path(copy(get_fixture_path("backup_example_sec_v3.tar"), tmp_path))
|
||||
v3_backup = Backup(coresys, v3_tar, "test", None)
|
||||
await v3_backup.load()
|
||||
v3_backup.set_password(password)
|
||||
|
||||
with expected_exception:
|
||||
await v3_backup.validate_backup(None)
|
||||
|
||||
257
tests/backups/test_backup_security.py
Normal file
257
tests/backups/test_backup_security.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Security tests for backup tar extraction with tar filter."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
from securetar import SecureTarFile
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.backups.backup import Backup
|
||||
from supervisor.backups.const import BackupType
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.exceptions import BackupInvalidError
|
||||
|
||||
|
||||
def _create_tar_gz(
|
||||
path: Path,
|
||||
members: list[tarfile.TarInfo],
|
||||
file_data: dict[str, bytes] | None = None,
|
||||
) -> None:
|
||||
"""Create a tar.gz file with specified members."""
|
||||
if file_data is None:
|
||||
file_data = {}
|
||||
with tarfile.open(path, "w:gz") as tar:
|
||||
for info in members:
|
||||
data = file_data.get(info.name)
|
||||
if data is not None:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
else:
|
||||
tar.addfile(info)
|
||||
|
||||
|
||||
def test_path_traversal_rejected(tmp_path: Path):
|
||||
"""Test that path traversal in member names is rejected."""
|
||||
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
||||
traversal_info.size = 9
|
||||
tar_path = tmp_path / "test.tar.gz"
|
||||
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with (
|
||||
tarfile.open(tar_path, "r:gz") as tar,
|
||||
pytest.raises(tarfile.OutsideDestinationError),
|
||||
):
|
||||
tar.extractall(path=dest, filter="tar")
|
||||
|
||||
|
||||
def test_symlink_write_through_rejected(tmp_path: Path):
|
||||
"""Test that writing through a symlink to outside destination is rejected.
|
||||
|
||||
The tar filter's realpath check follows already-extracted symlinks on disk,
|
||||
catching write-through attacks even without explicit link target validation.
|
||||
"""
|
||||
# Symlink pointing outside, then a file entry writing through it
|
||||
link_info = tarfile.TarInfo(name="escape")
|
||||
link_info.type = tarfile.SYMTYPE
|
||||
link_info.linkname = "../outside"
|
||||
file_info = tarfile.TarInfo(name="escape/evil.py")
|
||||
file_info.size = 9
|
||||
tar_path = tmp_path / "test.tar.gz"
|
||||
_create_tar_gz(
|
||||
tar_path,
|
||||
[link_info, file_info],
|
||||
{"escape/evil.py": b"malicious"},
|
||||
)
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with (
|
||||
tarfile.open(tar_path, "r:gz") as tar,
|
||||
pytest.raises(tarfile.OutsideDestinationError),
|
||||
):
|
||||
tar.extractall(path=dest, filter="tar")
|
||||
|
||||
# The evil file must not exist outside the destination
|
||||
assert not (tmp_path / "outside" / "evil.py").exists()
|
||||
|
||||
|
||||
def test_absolute_name_stripped_and_extracted(tmp_path: Path):
|
||||
"""Test that absolute member names have leading / stripped and extract safely."""
|
||||
info = tarfile.TarInfo(name="/etc/test.conf")
|
||||
info.size = 5
|
||||
tar_path = tmp_path / "test.tar.gz"
|
||||
_create_tar_gz(tar_path, [info], {"/etc/test.conf": b"hello"})
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with tarfile.open(tar_path, "r:gz") as tar:
|
||||
tar.extractall(path=dest, filter="tar")
|
||||
|
||||
# Extracted inside destination with leading / stripped
|
||||
assert (dest / "etc" / "test.conf").read_text() == "hello"
|
||||
|
||||
|
||||
def test_valid_backup_with_internal_symlinks(tmp_path: Path):
|
||||
"""Test that valid backups with internal relative symlinks extract correctly."""
|
||||
dir_info = tarfile.TarInfo(name="subdir")
|
||||
dir_info.type = tarfile.DIRTYPE
|
||||
dir_info.mode = 0o755
|
||||
|
||||
file_info = tarfile.TarInfo(name="subdir/config.yaml")
|
||||
file_info.size = 11
|
||||
|
||||
link_info = tarfile.TarInfo(name="config_link")
|
||||
link_info.type = tarfile.SYMTYPE
|
||||
link_info.linkname = "subdir/config.yaml"
|
||||
|
||||
tar_path = tmp_path / "test.tar.gz"
|
||||
_create_tar_gz(
|
||||
tar_path,
|
||||
[dir_info, file_info, link_info],
|
||||
{"subdir/config.yaml": b"key: value\n"},
|
||||
)
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with tarfile.open(tar_path, "r:gz") as tar:
|
||||
tar.extractall(path=dest, filter="tar")
|
||||
|
||||
assert (dest / "subdir" / "config.yaml").read_text() == "key: value\n"
|
||||
assert (dest / "config_link").is_symlink()
|
||||
assert (dest / "config_link").read_text() == "key: value\n"
|
||||
|
||||
|
||||
def test_uid_gid_preserved(tmp_path: Path):
|
||||
"""Test that tar filter preserves file ownership."""
|
||||
info = tarfile.TarInfo(name="owned_file.txt")
|
||||
info.size = 5
|
||||
info.uid = 1000
|
||||
info.gid = 1000
|
||||
tar_path = tmp_path / "test.tar.gz"
|
||||
_create_tar_gz(tar_path, [info], {"owned_file.txt": b"hello"})
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with tarfile.open(tar_path, "r:gz") as tar:
|
||||
# Extract member via filter only (don't actually extract, just check
|
||||
# the filter preserves uid/gid)
|
||||
for member in tar:
|
||||
filtered = tarfile.tar_filter(member, str(dest))
|
||||
assert filtered.uid == 1000
|
||||
assert filtered.gid == 1000
|
||||
|
||||
|
||||
async def test_backup_open_rejects_path_traversal(coresys: CoreSys, tmp_path: Path):
|
||||
"""Test that Backup.open() raises BackupInvalidError for path traversal."""
|
||||
tar_path = tmp_path / "malicious.tar"
|
||||
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
||||
traversal_info.size = 9
|
||||
with tarfile.open(tar_path, "w:") as tar:
|
||||
tar.addfile(traversal_info, io.BytesIO(b"malicious"))
|
||||
|
||||
backup = Backup(coresys, tar_path, "test", None)
|
||||
with pytest.raises(BackupInvalidError):
|
||||
async with backup.open(None):
|
||||
pass
|
||||
|
||||
|
||||
async def test_homeassistant_restore_rejects_path_traversal(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test that Home Assistant restore raises BackupInvalidError for path traversal."""
|
||||
tar_path = tmp_supervisor_data / "homeassistant.tar.gz"
|
||||
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
||||
traversal_info.size = 9
|
||||
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
||||
|
||||
tar_file = SecureTarFile(tar_path, gzip=True)
|
||||
with pytest.raises(BackupInvalidError):
|
||||
await coresys.homeassistant.restore(tar_file)
|
||||
|
||||
|
||||
async def test_addon_restore_rejects_path_traversal(
|
||||
coresys: CoreSys, install_addon_ssh: Addon, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test that add-on restore raises BackupInvalidError for path traversal."""
|
||||
tar_path = tmp_supervisor_data / "addon.tar.gz"
|
||||
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
||||
traversal_info.size = 9
|
||||
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
||||
|
||||
tar_file = SecureTarFile(tar_path, gzip=True)
|
||||
with pytest.raises(BackupInvalidError):
|
||||
await install_addon_ssh.restore(tar_file)
|
||||
|
||||
|
||||
async def test_addon_restore_rejects_symlink_escape(
|
||||
coresys: CoreSys, install_addon_ssh: Addon, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test that add-on restore raises BackupInvalidError for symlink escape."""
|
||||
link_info = tarfile.TarInfo(name="escape")
|
||||
link_info.type = tarfile.SYMTYPE
|
||||
link_info.linkname = "../outside"
|
||||
file_info = tarfile.TarInfo(name="escape/evil.py")
|
||||
file_info.size = 9
|
||||
|
||||
tar_path = tmp_supervisor_data / "addon.tar.gz"
|
||||
_create_tar_gz(
|
||||
tar_path,
|
||||
[link_info, file_info],
|
||||
{"escape/evil.py": b"malicious"},
|
||||
)
|
||||
|
||||
tar_file = SecureTarFile(tar_path, gzip=True)
|
||||
with pytest.raises(BackupInvalidError):
|
||||
await install_addon_ssh.restore(tar_file)
|
||||
|
||||
|
||||
async def test_folder_restore_rejects_path_traversal(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test that folder restore rejects path traversal in backup tar."""
|
||||
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
||||
traversal_info.size = 9
|
||||
|
||||
# Create backup with a malicious share folder tar inside
|
||||
backup_tar_path = tmp_supervisor_data / "backup.tar"
|
||||
with tarfile.open(backup_tar_path, "w:") as outer_tar:
|
||||
share_tar_path = tmp_supervisor_data / "share.tar.gz"
|
||||
_create_tar_gz(
|
||||
share_tar_path, [traversal_info], {"../../etc/passwd": b"malicious"}
|
||||
)
|
||||
outer_tar.add(share_tar_path, arcname="./share.tar.gz")
|
||||
|
||||
backup = Backup(coresys, backup_tar_path, "test", None)
|
||||
backup.new("test", "2025-01-01", BackupType.PARTIAL, compressed=True)
|
||||
async with backup.open(None):
|
||||
assert await backup.restore_folders(["share"]) is False
|
||||
|
||||
|
||||
async def test_folder_restore_rejects_symlink_escape(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test that folder restore rejects symlink escape in backup tar."""
|
||||
link_info = tarfile.TarInfo(name="escape")
|
||||
link_info.type = tarfile.SYMTYPE
|
||||
link_info.linkname = "../outside"
|
||||
file_info = tarfile.TarInfo(name="escape/evil.py")
|
||||
file_info.size = 9
|
||||
|
||||
# Create backup with a malicious share folder tar inside
|
||||
backup_tar_path = tmp_supervisor_data / "backup.tar"
|
||||
with tarfile.open(backup_tar_path, "w:") as outer_tar:
|
||||
share_tar_path = tmp_supervisor_data / "share.tar.gz"
|
||||
_create_tar_gz(
|
||||
share_tar_path,
|
||||
[link_info, file_info],
|
||||
{"escape/evil.py": b"malicious"},
|
||||
)
|
||||
outer_tar.add(share_tar_path, arcname="./share.tar.gz")
|
||||
|
||||
backup = Backup(coresys, backup_tar_path, "test", None)
|
||||
backup.new("test", "2025-01-01", BackupType.PARTIAL, compressed=True)
|
||||
async with backup.open(None):
|
||||
assert await backup.restore_folders(["share"]) is False
|
||||
@@ -15,6 +15,7 @@ from aiodocker.events import DockerEvents
|
||||
from aiodocker.execs import Exec
|
||||
from aiodocker.networks import DockerNetwork, DockerNetworks
|
||||
from aiodocker.system import DockerSystem
|
||||
from aiodocker.volumes import DockerVolumes
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.test_utils import TestClient
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -22,7 +23,7 @@ from blockbuster import BlockBuster, BlockBusterFunction
|
||||
from dbus_fast import BusType
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
import pytest
|
||||
from securetar import SecureTarFile
|
||||
from securetar import SecureTarArchive
|
||||
|
||||
from supervisor import config as su_config
|
||||
from supervisor.addons.addon import Addon
|
||||
@@ -160,7 +161,6 @@ async def docker() -> DockerAPI:
|
||||
}
|
||||
|
||||
with (
|
||||
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.aiodocker.Docker",
|
||||
return_value=(
|
||||
@@ -170,6 +170,7 @@ async def docker() -> DockerAPI:
|
||||
containers=(docker_containers := MagicMock(spec=DockerContainers)),
|
||||
events=(docker_events := MagicMock(spec=DockerEvents)),
|
||||
system=(docker_system := MagicMock(spec=DockerSystem)),
|
||||
volumes=MagicMock(spec=DockerVolumes),
|
||||
)
|
||||
),
|
||||
),
|
||||
@@ -847,7 +848,7 @@ async def backups(
|
||||
for i in range(request.param if hasattr(request, "param") else 5):
|
||||
slug = f"sn{i + 1}"
|
||||
temp_tar = Path(tmp_path, f"{slug}.tar")
|
||||
with SecureTarFile(temp_tar, "w"):
|
||||
with SecureTarArchive(temp_tar, "w"):
|
||||
pass
|
||||
backup = Backup(coresys, temp_tar, slug, None)
|
||||
backup._data = { # pylint: disable=protected-access
|
||||
|
||||
@@ -9,7 +9,6 @@ import aiodocker
|
||||
from aiodocker.containers import DockerContainer
|
||||
from aiodocker.networks import DockerNetwork
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import APIError
|
||||
import pytest
|
||||
|
||||
from supervisor.const import DNS_SUFFIX, ENV_SUPERVISOR_CPU_RT
|
||||
@@ -184,14 +183,6 @@ async def test_run_command_custom_stdout_stderr(
|
||||
|
||||
async def test_run_command_with_mounts(docker: DockerAPI):
|
||||
"""Test command execution with mounts are correctly converted."""
|
||||
# Mock container and its methods
|
||||
mock_container = MagicMock()
|
||||
mock_container.wait.return_value = {"StatusCode": 0}
|
||||
mock_container.logs.return_value = ["output"]
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Create test mounts
|
||||
mounts = [
|
||||
DockerMount(
|
||||
@@ -456,13 +447,13 @@ async def test_repair(
|
||||
|
||||
await coresys.docker.repair()
|
||||
|
||||
coresys.docker.dockerpy.api.prune_containers.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_images.assert_called_once_with(
|
||||
filters={"dangling": False}
|
||||
coresys.docker.docker.containers.prune.assert_called_once()
|
||||
coresys.docker.docker.images.prune.assert_called_once_with(
|
||||
filters={"dangling": "false"}
|
||||
)
|
||||
coresys.docker.dockerpy.api.prune_builds.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_volumes.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_networks.assert_called_once()
|
||||
coresys.docker.docker.images.prune_builds.assert_called_once()
|
||||
coresys.docker.docker.volumes.prune.assert_called_once()
|
||||
coresys.docker.docker.networks.prune.assert_called_once()
|
||||
hassio.disconnect.assert_called_once_with({"Container": "corrupt", "Force": True})
|
||||
host.disconnect.assert_not_called()
|
||||
assert "Docker fatal error on container fail on hassio" in caplog.text
|
||||
@@ -470,24 +461,27 @@ async def test_repair(
|
||||
|
||||
async def test_repair_failures(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test repair proceeds best it can through failures."""
|
||||
coresys.docker.dockerpy.api.prune_containers.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_images.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_builds.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_volumes.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_networks.side_effect = APIError("fail")
|
||||
coresys.docker.docker.networks.get.side_effect = err = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
fail_err = aiodocker.DockerError(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}
|
||||
)
|
||||
coresys.docker.docker.containers.prune.side_effect = fail_err
|
||||
coresys.docker.docker.images.prune.side_effect = fail_err
|
||||
coresys.docker.docker.images.prune_builds.side_effect = fail_err
|
||||
coresys.docker.docker.volumes.prune.side_effect = fail_err
|
||||
coresys.docker.docker.networks.prune.side_effect = fail_err
|
||||
coresys.docker.docker.networks.get.side_effect = missing_err = (
|
||||
aiodocker.DockerError(HTTPStatus.NOT_FOUND, {"message": "missing"})
|
||||
)
|
||||
|
||||
await coresys.docker.repair()
|
||||
|
||||
assert "Error for containers prune: fail" in caplog.text
|
||||
assert "Error for images prune: fail" in caplog.text
|
||||
assert "Error for builds prune: fail" in caplog.text
|
||||
assert "Error for volumes prune: fail" in caplog.text
|
||||
assert "Error for networks prune: fail" in caplog.text
|
||||
assert f"Error for networks hassio prune: {err!s}" in caplog.text
|
||||
assert f"Error for networks host prune: {err!s}" in caplog.text
|
||||
assert f"Error for containers prune: {fail_err!s}" in caplog.text
|
||||
assert f"Error for images prune: {fail_err!s}" in caplog.text
|
||||
assert f"Error for builds prune: {fail_err!s}" in caplog.text
|
||||
assert f"Error for volumes prune: {fail_err!s}" in caplog.text
|
||||
assert f"Error for networks prune: {fail_err!s}" in caplog.text
|
||||
assert f"Error for networks hassio prune: {missing_err!s}" in caplog.text
|
||||
assert f"Error for networks host prune: {missing_err!s}" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_starter", [("Loaded image ID"), ("Loaded image")])
|
||||
|
||||
BIN
tests/fixtures/backup_example_sec_v3.tar
vendored
Normal file
BIN
tests/fixtures/backup_example_sec_v3.tar
vendored
Normal file
Binary file not shown.
@@ -58,12 +58,11 @@ async def test_load(
|
||||
assert ha_ws_client.async_send_command.call_args_list[0][0][0] == {"lorem": "ipsum"}
|
||||
|
||||
|
||||
async def test_get_users_none(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||
"""Test get users returning none does not fail."""
|
||||
async def test_list_users_none(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||
"""Test list users raises on unexpected None response from Core."""
|
||||
ha_ws_client.async_send_command.return_value = None
|
||||
assert (
|
||||
await coresys.homeassistant.get_users.__wrapped__(coresys.homeassistant) == []
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
await coresys.homeassistant.list_users()
|
||||
|
||||
|
||||
async def test_write_pulse_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
|
||||
from supervisor.const import CoreState
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.exceptions import HomeAssistantWSError
|
||||
from supervisor.exceptions import HomeAssistantWSConnectionError
|
||||
from supervisor.homeassistant.const import WSEvent, WSType
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ async def test_send_command_core_not_reachable(
|
||||
ha_ws_client.connected = False
|
||||
with (
|
||||
patch.object(coresys.homeassistant.api, "check_api_state", return_value=False),
|
||||
pytest.raises(HomeAssistantWSError, match="not reachable"),
|
||||
pytest.raises(HomeAssistantWSConnectionError, match="not reachable"),
|
||||
):
|
||||
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
|
||||
|
||||
@@ -102,7 +102,7 @@ async def test_fire_and_forget_core_not_reachable(
|
||||
async def test_send_command_during_shutdown(coresys: CoreSys, ha_ws_client: AsyncMock):
|
||||
"""Test async_send_command raises during shutdown."""
|
||||
await coresys.core.set_state(CoreState.SHUTDOWN)
|
||||
with pytest.raises(HomeAssistantWSError, match="shutting down"):
|
||||
with pytest.raises(HomeAssistantWSConnectionError, match="shutting down"):
|
||||
await coresys.homeassistant.websocket.async_send_command({"type": "test"})
|
||||
|
||||
ha_ws_client.async_send_command.assert_not_called()
|
||||
|
||||
@@ -43,7 +43,9 @@ async def test_reading_addon_files_error(coresys: CoreSys):
|
||||
assert reset_repo in coresys.resolution.suggestions
|
||||
assert coresys.core.healthy is True
|
||||
|
||||
coresys.resolution.dismiss_issue(corrupt_repo)
|
||||
coresys.resolution.dismiss_issue(
|
||||
coresys.resolution.get_issue_if_present(corrupt_repo)
|
||||
)
|
||||
err.errno = errno.EBADMSG
|
||||
assert (await coresys.store.data._find_addon_configs(Path("test"), {})) is None
|
||||
assert corrupt_repo in coresys.resolution.issues
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Test ingress."""
|
||||
|
||||
from datetime import timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from supervisor.const import IngressSessionData, IngressSessionDataUser
|
||||
from supervisor.const import HomeAssistantUser, IngressSessionData
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.ingress import Ingress
|
||||
from supervisor.utils.dt import utc_from_timestamp
|
||||
@@ -34,7 +35,7 @@ def test_session_handling(coresys: CoreSys):
|
||||
def test_session_handling_with_session_data(coresys: CoreSys):
|
||||
"""Create and test session."""
|
||||
session = coresys.ingress.create_session(
|
||||
IngressSessionData(IngressSessionDataUser("some-id"))
|
||||
IngressSessionData(HomeAssistantUser("some-id"))
|
||||
)
|
||||
|
||||
assert session
|
||||
@@ -76,7 +77,7 @@ async def test_ingress_save_data(coresys: CoreSys, tmp_supervisor_data: Path):
|
||||
with patch("supervisor.ingress.FILE_HASSIO_INGRESS", new=config_file):
|
||||
ingress = await Ingress(coresys).load_config()
|
||||
session = ingress.create_session(
|
||||
IngressSessionData(IngressSessionDataUser("123", "Test", "test"))
|
||||
IngressSessionData(HomeAssistantUser("123", name="Test", username="test"))
|
||||
)
|
||||
await ingress.save_data()
|
||||
|
||||
@@ -87,12 +88,47 @@ async def test_ingress_save_data(coresys: CoreSys, tmp_supervisor_data: Path):
|
||||
assert await coresys.run_in_executor(get_config) == {
|
||||
"session": {session: ANY},
|
||||
"session_data": {
|
||||
session: {"user": {"id": "123", "displayname": "Test", "username": "test"}}
|
||||
session: {"user": {"id": "123", "name": "Test", "username": "test"}}
|
||||
},
|
||||
"ports": {},
|
||||
}
|
||||
|
||||
|
||||
async def test_ingress_load_legacy_displayname(
|
||||
coresys: CoreSys, tmp_supervisor_data: Path
|
||||
):
|
||||
"""Test loading session data with legacy 'displayname' key."""
|
||||
config_file = tmp_supervisor_data / "ingress.json"
|
||||
session_token = "a" * 128
|
||||
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session": {session_token: 9999999999.0},
|
||||
"session_data": {
|
||||
session_token: {
|
||||
"user": {
|
||||
"id": "456",
|
||||
"displayname": "Legacy Name",
|
||||
"username": "legacy",
|
||||
}
|
||||
}
|
||||
},
|
||||
"ports": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch("supervisor.ingress.FILE_HASSIO_INGRESS", new=config_file):
|
||||
ingress = await Ingress(coresys).load_config()
|
||||
|
||||
session_data = ingress.get_session_data(session_token)
|
||||
assert session_data is not None
|
||||
assert session_data.user.id == "456"
|
||||
assert session_data.user.name == "Legacy Name"
|
||||
assert session_data.user.username == "legacy"
|
||||
|
||||
|
||||
async def test_ingress_reload_ignore_none_data(coresys: CoreSys):
|
||||
"""Test reloading ingress does not add None for session data and create errors."""
|
||||
session = coresys.ingress.create_session()
|
||||
|
||||
Reference in New Issue
Block a user