Compare commits

...

137 Commits
106 ... 131

Author SHA1 Message Date
Pascal Vizeli
ccff0f5b9e Merge pull request #706 from home-assistant/dev
Release 131
2018-09-18 21:19:33 +02:00
Pascal Vizeli
9f8ad05471 Add API role system (#703)
* Add API role system

* Finish

* Simplify

* Fix lint

* Fix rights

* Fix lint

* Fix spell

* Fix log
2018-09-18 20:39:58 +02:00
Fabian Affolter
c2299ef8da Fix typos (#704) 2018-09-18 18:17:20 +02:00
Franck Nijhof
f5845564db 👕 Fixes a typo in method name (#702) 2018-09-17 23:11:53 +02:00
Franck Nijhof
17904d70d8 🚀 Adds venv to .dockerignore (#701) 2018-09-17 21:03:14 +02:00
Franck Nijhof
622e99e04c Adds host PID mode support for add-ons (#700)
*  Adds host PID mode support for add-ons.

* 🔒 Disables host PID mode when in protected mode

* 🚦 Adds more negative rating weight to host PID mode
2018-09-17 21:02:28 +02:00
Pascal Vizeli
061420f279 Make Label handling more robust (#696)
* Make Label handling more robust

* Update interface.py

* Update interface.py

* Update interface.py
2018-09-15 22:07:05 +02:00
Franck Nijhof
3d459f1b8b Adds support for SYS_PTRACE add-on privileges (#697) 2018-09-15 22:05:50 +02:00
Pascal Vizeli
5f3dd6190a Bump version 130 2018-09-10 00:02:27 +02:00
Pascal Vizeli
ac824d3af6 Merge pull request #691 from home-assistant/dev
Release 130
2018-09-10 00:00:56 +02:00
Pascal Vizeli
dd25c29544 Bugfix Proxy with new token (#690)
* Update proxy.py

* Update security.py
2018-09-09 23:47:35 +02:00
Pascal Vizeli
5cbdbffbb2 Bump version to 130 2018-09-08 00:17:05 +02:00
Pascal Vizeli
bb81f14c2c Merge pull request #688 from home-assistant/dev
Release 129
2018-09-08 00:16:17 +02:00
Pascal Vizeli
cecefd6972 Change access to API (#686)
* Update API.md

* Update API.md

* Update API.md

* Update addons.py

* Update addons.py

* Update addons.py

* Update addons.py

* Update __init__.py

* Update security.py

* Update security.py

* Update const.py

* Update validate.py

* Update __init__.py

* Update validate.py

* Update homeassistant.py

* Update homeassistant.py

* Update homeassistant.py

* Update addon.py

* Update addon.py

* Update homeassistant.py

* Fix lint

* Fix lint

* Backward combatibility

* Make token more robust

* Fix bug

* Logic error

* Fix access

* fix valid
2018-09-07 22:59:31 +02:00
Pascal Vizeli
ff7f6a0b4c Bump version 129 2018-08-29 10:16:04 +02:00
Pascal Vizeli
1dc9f35e12 Merge pull request #674 from home-assistant/dev
Release 128
2018-08-29 10:13:57 +02:00
Pascal Vizeli
051b63c7cc Fix access token property (#673)
* Fix access token property

* revert
2018-08-28 17:04:39 +02:00
Pascal Vizeli
aac4b9b24a Snapshot/Restore Home-Assistant token (#672)
* Snapshot/Restore Home-Assistant token

* Encrypt token & check api

* fix lint
2018-08-28 16:32:17 +02:00
Paulus Schoutsen
1a208a20b6 Handle access token expiration (#671) 2018-08-28 12:14:40 +02:00
Pascal Vizeli
b1e8722ead Update: pycryptodome to 3.6.6 (#670) 2018-08-28 12:04:32 +02:00
Pascal Vizeli
a66af6e903 Update aiohttp to 3.4.0 (#668)
Update: aiohttp to 3.4.0
2018-08-28 01:18:38 +02:00
Pascal Vizeli
0c345fc615 Bump version 128 2018-08-19 22:05:42 +02:00
Pascal Vizeli
087b082a6b Merge pull request #660 from home-assistant/dev
Release 127
2018-08-19 22:03:49 +02:00
Pascal Vizeli
0b85209eae Detect running record migration (#659)
* Detect running record migration

* Fix order

* Change order second one
2018-08-19 21:58:19 +02:00
Pascal Vizeli
d81bc7de46 Change rating 1-6 (#658) 2018-08-19 18:17:14 +02:00
Pascal Vizeli
e3a99b9f89 Fix /share inside whitelist (#657) 2018-08-18 15:05:18 +02:00
Pascal Vizeli
5d319b37ea Bump verison 127 2018-08-16 23:38:57 +02:00
Pascal Vizeli
9f25606986 Merge pull request #653 from home-assistant/dev
Release 126
2018-08-16 23:38:24 +02:00
Pascal Vizeli
ecd12732ee New generation of security and access (#652)
* New generation of security and access

* Update const.py

* Update validate.py

* Update addon.py

* Update validate.py

* Fix name

* Allow access

* Fix

* add logs

* change message

* add rating

* fix lint

* fix lint

* fix

* Fix
2018-08-16 22:49:08 +02:00
Pascal Vizeli
85fbde8e36 Fix Dockerfile 2018-08-16 01:42:56 +02:00
Pascal Vizeli
6e6c2c3efb Change timezone handling (#641)
* Change timezone handling

* Update dt.py

* Update homeassistant.py

* fix

* Use new timezone

* fix handling

* fix regex

* fix regex

* Rename old config

* fix lint

* simplify

* fix regex

* fix

* cleanup

* cleanup

* fix

* fix find

* mm
2018-08-16 01:40:20 +02:00
Pascal Vizeli
0d4a808449 Improve docker build cache for supervisor (#651) 2018-08-15 23:52:52 +02:00
Pascal Vizeli
087f746647 update docker API to 3.5.0 (#650) 2018-08-15 22:05:13 +02:00
Pascal Vizeli
640d66ad1a Update uvloop 0.11.2 (#648) 2018-08-15 21:38:57 +02:00
Pascal Vizeli
f5f5ed83af Bump version 126 2018-08-09 14:38:34 +02:00
Pascal Vizeli
95f01a1161 Merge pull request #640 from home-assistant/dev
Release 125
2018-08-09 14:37:56 +02:00
Pascal Vizeli
b84e7e7d94 Allow to reset token (#639)
* Allow to reset token

* Update homeassistant.py
2018-08-09 14:37:00 +02:00
Pascal Vizeli
5d7018f3f0 Bump version 125 2018-08-09 01:05:21 +02:00
Pascal Vizeli
d87a85ceb5 Merge pull request #636 from home-assistant/dev
Release 124
2018-08-09 01:03:47 +02:00
Pascal Vizeli
9ab6e80b6f Cleanup logging (#637)
* Cleanup logging

* simplify
2018-08-09 01:03:00 +02:00
Pascal Vizeli
78e91e859e Add add-on support for docker sock ro (#635)
* Add add-on support for docker sock ro

* fix
2018-08-09 00:42:33 +02:00
Pascal Vizeli
9eee8eade6 Fix gpio mapping on amd64 systems (#634) 2018-08-09 00:29:20 +02:00
Pascal Vizeli
124ce0b8b7 Update voluptuous 0.11.5 (#622) 2018-08-09 00:06:49 +02:00
Pascal Vizeli
00e7d96472 Fix new auth system (#633)
* Fix new auth system

* Update exceptions.py

* Update exceptions.py

* Update homeassistant.py

* Update homeassistant.py

* Update homeassistant.py

* Fix some API Errors

* fix lint
2018-08-09 00:05:08 +02:00
Pascal Vizeli
398815efd8 Bump version to 124 2018-08-08 19:22:12 +02:00
Pascal Vizeli
bdc2bdcf56 Merge pull request #631 from ndarilek/dev
Add SYS_RESOURCE to list of valid privileges
2018-08-07 17:06:15 +02:00
Nolan Darilek
68eafb0a7d Add SYS_RESOURCE to list of valid privileges 2018-08-07 03:21:21 +00:00
Pascal Vizeli
7ca2fd7193 Merge pull request #618 from home-assistant/dev
Release 123
2018-08-04 01:25:55 +02:00
Pascal Vizeli
ec823edd8f Cleanup docker image (#617) 2018-08-04 00:41:14 +02:00
Pascal Vizeli
858c7a1fa7 Bump version 123 2018-08-02 23:40:52 +02:00
Pascal Vizeli
6ac45a24fc Merge pull request #615 from home-assistant/dev
Release 122
2018-08-02 23:39:43 +02:00
Pascal Vizeli
9430b39042 Update uvloop version 0.11.1 (#614) 2018-08-02 23:18:40 +02:00
Pascal Vizeli
ae7466ccfe Fix UnicodeDecodeError with read json file (#613)
* Update json.py

* Update data.py
2018-08-02 21:48:50 +02:00
Simon Holzmayer
2c17fe5da8 Adapt regex validation to allow docker images from other registries (#608)
* Adapt regex validation to allow images from other registries than dockerhub

Issue #564

* Update validate.py
2018-07-30 12:34:42 +02:00
Pascal Vizeli
a0fb91af29 Use requirements.txt (#607)
* Create requirements.txt

* Update setup.py

* Update Dockerfile

* Update Dockerfile

* Update requirements.txt

* Update requirements.txt

* Update Dockerfile

* Update tox.ini
2018-07-27 16:34:47 +02:00
Pascal Vizeli
f626e31fd3 Bump version 122 2018-07-25 01:52:24 +02:00
Pascal Vizeli
0151a149fd Merge pull request #604 from home-assistant/dev
Release 121
2018-07-25 01:47:36 +02:00
Pascal Vizeli
9dea93142b Timeout shutdown (#603)
* Don't wait too long for shutdown

* Update log message

* Fix timeout

* Fast shudown
2018-07-25 01:46:54 +02:00
Pascal Vizeli
7f878bfac0 Bump version to 121 2018-07-25 01:39:33 +02:00
Pascal Vizeli
ebe9ae2341 Merge pull request #600 from home-assistant/dev
Release 120
2018-07-24 22:11:10 +02:00
Pascal Vizeli
e777bbd024 Fix bug with proxy (#599) 2018-07-24 22:00:46 +02:00
Pascal Vizeli
2116d56124 Bump version 120 2018-07-24 16:32:46 +02:00
Pascal Vizeli
0b6a82b018 Merge pull request #598 from home-assistant/dev
Release 119
2018-07-24 16:21:06 +02:00
Pascal Vizeli
b4ea28af4e Update uvloop to 0.11.0 (#597) 2018-07-24 16:16:26 +02:00
Pascal Vizeli
22f59712df Bump version 119 2018-07-23 13:01:51 +02:00
Pascal Vizeli
efe95f7bab Merge pull request #593 from home-assistant/dev
Release 118
2018-07-23 12:59:15 +02:00
Pascal Vizeli
200c68f67f Fix proxy data passthrougth (#592)
* Fix proxy data passthrougth

* Update homeassistant.py
2018-07-23 12:53:32 +02:00
Pascal Vizeli
dcefec7b99 Cleanup old stuff (#589) 2018-07-22 01:51:45 +02:00
Pascal Vizeli
5db798bcf8 Fix API for home-assistant (#588)
* Fix API for home-assistant

* Update API.md
2018-07-22 00:42:45 +02:00
Pascal Vizeli
70005296cc Bump version 118 2018-07-21 20:25:12 +02:00
Pascal Vizeli
f2bf8dea93 Merge pull request #585 from home-assistant/dev
Release 117
2018-07-21 20:21:18 +02:00
Pascal Vizeli
fee858c956 Fix exception is HomeAssistant allready running (#587) 2018-07-21 20:13:13 +02:00
Pascal Vizeli
e3ae48c8ff Remove geo ip (#586)
* Remove geo ip

* Update core.py

* Update dt.py
2018-07-21 19:45:11 +02:00
Pascal Vizeli
fa9e20385e Bugfix passwrod (#584) 2018-07-21 19:07:22 +02:00
Pascal Vizeli
f51c9704e0 Fix timeout on freegeoip (#581)
* Fix timeout on freegeoip

* Update updater.py

* Update supervisor.py

* Update dt.py

* Update hassos.py

* Update core.py

* Update hassos.py

* Update supervisor.py

* Update updater.py
2018-07-21 19:01:20 +02:00
Pascal Vizeli
57c58d81c0 Bump version 117 2018-07-21 00:06:34 +02:00
Pascal Vizeli
1ec1082068 Merge pull request #580 from home-assistant/dev
Release 116
2018-07-21 00:05:56 +02:00
Pascal Vizeli
35b7c2269c Support control of hassos-cli (#555)
* Support control of hassos-cli

* Update const.py

* Update validate.py

* Update supervisor.py

* Create hassos_cli.py

* Update hassos_cli.py

* Update hassos_cli.py

* Update hassos.py

* Update tasks.py

* Update hassos.py

* Update API.md

* Update API.md

* Update const.py

* Update hassos.py

* Update __init__.py

* Fix lint

* fix

* Fix logging

* change order

* Fix download
2018-07-20 23:45:36 +02:00
Pascal Vizeli
cc3e6ec6fd Fix stream error with aiohttp >= 3 (#579)
* Fix stream error with aiohttp >= 3

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py

* Update proxy.py
2018-07-20 22:28:56 +02:00
Paulus Schoutsen
4df42e054d Leverage access and refresh tokens if available (#575)
* Leverage access and refresh tokens if available

* Update homeassistant.py

* Update homeassistant.py

* Update proxy.py

* Migrate HomeAssistant to new exception layout

* Fix build for 3.7

* Cleanups

* Fix style

* fix log strings

* Fix new style

* Fix travis build

* python 3.7

* next try

* fix

* fix lint

* Fix lint p2

* Add logging

* Fix logging

* fix access

* Fix spell

* fix return

* Fix runtime

* Add to hass config
2018-07-20 16:55:48 +02:00
Pascal Vizeli
1b481e0b37 Fix small bugs (python37) (#577)
* Fix small bugs (python37)

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py
2018-07-19 21:22:26 +02:00
Pascal Vizeli
3aa4cdf540 Fix remove data inside executor (#576) 2018-07-19 20:25:58 +02:00
Pascal Vizeli
029f277945 Reset readonly on remove data (#569)
* Reset readonly on remove data

* Update addon.py

* Update utils.py

* Fix lint

* Update utils.py

* Update utils.py

* Update utils.py

* Update utils.py

* Update addon.py
2018-07-19 12:44:16 +02:00
Pascal Vizeli
e7e0b9adda Fix-python7 compatibility (#573) 2018-07-19 01:18:43 +02:00
Pascal Vizeli
5fbff75da8 Support new base images (#571)
* Support new base images

* Update Dockerfile

* Update setup.py
2018-07-17 23:32:50 +02:00
Paulus Schoutsen
58299a0389 Add release drafter 2018-07-10 10:38:45 +02:00
Pascal Vizeli
1151d7e17b Bump version to 116 2018-07-06 13:10:16 +02:00
Pascal Vizeli
b56ed547e3 Merge pull request #559 from home-assistant/dev
Release 115
2018-07-06 13:09:32 +02:00
Pascal Vizeli
a71ebba940 Bugfix rollback if the hass instant is complete corrupt (#558) 2018-07-06 13:08:57 +02:00
Pascal Vizeli
4fcb516c75 Bump version to 115 2018-07-06 01:38:18 +02:00
Pascal Vizeli
22142d32d2 Merge remote-tracking branch 'origin/dev'
Release 114
2018-07-06 01:37:10 +02:00
Pascal Vizeli
21194f1411 Add hostname to UI (#557)
* Add hostname to UI

* Fix dbus call

* support boolean

* support types

* revert

* test

* test

* log

* fixup

* fix bug
2018-07-06 01:36:28 +02:00
Pascal Vizeli
09df046fa8 Fix problem with Repositories (#552)
* Fix problem with Repositories

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* Update git.py

* fix lint

* fix

* reset origin

* Git cleanup
2018-07-05 23:21:54 +02:00
Pascal Vizeli
63d3889d5c Fix problem with options / hostname (#554) 2018-07-05 13:01:48 +02:00
Pascal Vizeli
0ffc0559e2 Map devicetree 2018-07-04 01:12:58 +02:00
Pascal Vizeli
78118a502c Map devicetree 2018-07-04 01:12:25 +02:00
Pascal Vizeli
946cc3d618 Bump version to 114 2018-07-04 00:53:19 +02:00
Pascal Vizeli
c40a3f18e9 Merge remote-tracking branch 'origin/dev'
Release 113
2018-07-04 00:51:36 +02:00
Pascal Vizeli
f01945bf8c Update addon.py (#550) 2018-07-04 00:51:01 +02:00
Pascal Vizeli
0f72db45f9 Bump version to 113 2018-07-03 23:02:21 +02:00
Pascal Vizeli
83510341b6 Merge remote-tracking branch 'origin/dev'
Release 112
2018-07-03 22:43:05 +02:00
Pascal Vizeli
70dd6593e4 Rollback homeassistant on failover (#549)
* Rollback homeassistant on failover

* Check running system
2018-07-03 22:41:50 +02:00
Pascal Vizeli
60ba2db561 Bump version to 112 2018-07-03 20:44:06 +02:00
Pascal Vizeli
5820d16419 Fix wrong mount options for devicetree (#548) 2018-07-03 18:44:45 +02:00
Pascal Vizeli
9f9ff0d1ad Merge remote-tracking branch 'origin/dev'
Release 111
2018-07-03 00:14:28 +02:00
Pascal Vizeli
806161e3ac Use machine-id from filesystem (#546)
* Use machine-id from filesystem

* Update security.py

* Update security.py

* fix lint
2018-07-01 22:28:32 +02:00
Pascal Vizeli
44ae9c7b63 Don't try to shutdown a not running API (#543) 2018-06-30 22:06:18 +02:00
Pascal Vizeli
75d24ba534 Bump version to 111 2018-06-30 22:03:21 +02:00
Pascal Vizeli
13243cd02c Merge remote-tracking branch 'origin/dev'
Release 110
2018-06-30 02:10:48 +02:00
Pascal Vizeli
411fad8a45 Fix scroll bugs (#542) 2018-06-30 01:59:33 +02:00
Pascal Vizeli
5fe9d63c79 Add HassOS OTA support on Hass.io (#536)
* Add HassOS OTA support on Hass.io

* Update dt.py

* Update updater.py

* add rauc dbus / initial dbus signal handling

* Update gdbus.py

* Update hassos.py

* Update const.py

* Update hassos.py

* Update exceptions.py

* Update hassos.py

* Update rauc.py

* Update rauc.py

* Update rauc.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update __init__.py

* Update hassos.py

* Update hassos.py

* Update updater.py

* Update updater.py

* Update exceptions.py

* Update exceptions.py

* Update hassos.py

* Update dt.py

* fix lint

* Fix update

* fix property

* tmp disabled

* fix path

* fix rauc

* info

* More details

* cleanup signal hadnling

* fix

* Fix lint
2018-06-30 01:48:58 +02:00
Pascal Vizeli
33095f8792 Bump version to 110 2018-06-29 22:23:00 +02:00
Pascal Vizeli
0253722369 Remove update config with allready installed one (#533)
* Remove update config with allready installed one

* fix lint
2018-06-28 12:22:27 +02:00
Pascal Vizeli
495c45564a Update libuv (#529) 2018-06-26 00:21:49 +02:00
Pascal Vizeli
8517b43e85 Merge pull request #526 from home-assistant/dev
Release 109
2018-06-23 00:58:29 +02:00
Pascal Vizeli
033ea4e7dc Panel with HassOS support (#525) 2018-06-23 00:52:19 +02:00
Pascal Vizeli
a0c9e5ad26 HassOS support (#522)
* Add support for hassos

* Name command

* Update host.py

* Create hassos.py

* Update const.py

* Update host.py

* Update API.md

* Update const.py

* Update __init__.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update hassos.py

* Update const.py

* Update API.md

* Update hassos.py

* Update hassos.py

* Update API.md

* Update const.py

* Update hassos.py

* Update __init__.py

* fix lint

* Fix lint v2

* remove old function

* fix attribute error

* inittialize hassos

* Fix link

* fix error handling

* Fix handling
2018-06-22 22:54:03 +02:00
Pascal Vizeli
408d6eafcc Bump version to 109 2018-06-21 12:21:59 +02:00
Pascal Vizeli
054e357483 Add support to map devicetree into add-on (#519)
* Add support to map devicetree into add-on

* Update const.py

* Update validate.py

* Update addon.py

* Update addons.py

* Update API.md
2018-06-21 12:19:14 +02:00
Pascal Vizeli
cb520bff23 Merge pull request #518 from home-assistant/fix-device-configs
Bugfix home-assistant config with devices
2018-06-21 12:18:20 +02:00
Pascal Vizeli
024ebe0026 Update homeassistant.py 2018-06-21 12:00:13 +02:00
Pascal Vizeli
7b62e2f07b Bugfix home-assistant config with devices 2018-06-21 11:51:04 +02:00
Pascal Vizeli
7d52b3ba01 Merge pull request #517 from home-assistant/dev
Release 108
2018-06-21 11:33:09 +02:00
Pascal Vizeli
46caa23319 Update version.json 2018-06-21 11:27:19 +02:00
Pascal Vizeli
9aa5eda2c8 Some bugfix (#516)
* Some bugfix

* Update apparmor.py

* Update apparmor.py

* Update apparmor.py

* Update apparmor.py
2018-06-20 23:25:08 +02:00
Pascal Vizeli
f48182a69c Add lost panel files (#515)
* Update Panel for 0.72

* Add new panel files
2018-06-20 21:07:33 +02:00
Pascal Vizeli
788f883490 Update Panel for 0.72 (#514) 2018-06-20 20:37:26 +02:00
Pascal Vizeli
e84e82d018 Fix aiohttp handling (#512) 2018-06-20 15:32:55 +02:00
Pascal Vizeli
20e73796b8 Change aiohttp handling for server (#511) 2018-06-20 12:17:33 +02:00
Pascal Vizeli
7769d6fff1 Cleanup and fixup Apparmor implementation (#509)
* Cleanup and fixup Apparmor implementation

* Update addon.py

* Update validate.py

* Create apparmor.py

* Update exceptions.py

* Update apparmor.py

* Create apparmor.py

* Update const.py

* Update bootstrap.py

* Update const.py

* Update config.py

* Update addons.py

* Update apparmor.py

* Add support for host AppArmor

* Update apparmor.py

* Update apparmor.py

* Update apparmor.py

* Update apparmor.py

* Update apparmor.py

* Update addon.py

* Update apparmor.py

* Update addon.py

* Update addon.py

* Update addon.py

* Update addon.py

* Update const.py

* Update supervisor.py

* Update supervisor.py

* Update supervisor.py

* Add snapshot support

* some cleanup

* Cleanup v2

* Update aiohttp

* fix lint

* fix bugs

* Add info logs
2018-06-20 00:09:18 +02:00
Pascal Vizeli
561e80c2be Extend Systemd Support / Apparmor (#506)
* Update systemd.py

* Update control.py

* Update control.py

* Create service.py

* Update info.py

* Rename hassio/host/asound.tmpl to hassio/host/data/asound.tmpl

* Rename hassio/host/audiodb.json to hassio/host/data/audiodb.json

* Update alsa.py

* Update alsa.py

* Update control.py

* Fix

* Enable call

* fix

* fix args

* Fix gdbus

* parse service data

* Change handling

* Fix states

* Fix parser for tuples

* Fix parser v2

* Fix tuple handling

* Fix regex string handling

* Faster tuple finder

* fix empty detector

* wrong order

* Finish

* fix lint

* better filtering

* fix match

* Fix mode string
2018-06-17 02:07:12 +02:00
Pascal Vizeli
96f47a4c32 Bump version to 108 2018-06-16 01:20:10 +02:00
Pascal Vizeli
7482d6dd45 Update last panel version (#508) 2018-06-15 22:55:57 +02:00
Pascal Vizeli
aea31ee6dd Support host tokens (#507)
* Update coresys.py

* Update bootstrap.py

* Update security.py

* fix lint

* Update bootstrap.py
2018-06-15 22:10:51 +02:00
Pascal Vizeli
de43965ecb Merge pull request #503 from home-assistant/dev
Release 107
2018-06-09 00:01:11 +02:00
Pascal Vizeli
baa61c6aa0 Bump version to 107 2018-06-08 23:48:43 +02:00
Pascal Vizeli
cb22dafb3c Fix bug with 0.70 (#502)
Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch>
2018-06-08 23:47:59 +02:00
91 changed files with 2399 additions and 684 deletions

View File

@@ -7,3 +7,7 @@
# Temporary files
**/__pycache__
# virtualenv
venv/
ENV/

4
.github/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
template: |
## What's Changed
$CHANGES

View File

@@ -1,12 +1,6 @@
sudo: false
matrix:
fast_finish: true
include:
- python: "3.6"
cache:
directories:
- $HOME/.cache/pip
sudo: true
dist: xenial
install: pip install -U tox
language: python
python: 3.7
script: tox

93
API.md
View File

@@ -1,4 +1,4 @@
# Hass.io Server
# Hass.io
## Hass.io RESTful API
@@ -27,6 +27,9 @@ For access to API you need set the `X-HASSIO-KEY` they will be available for Add
### Hass.io
- GET `/supervisor/ping`
This API call don't need a token.
- GET `/supervisor/info`
The addons from `addons` are only installed one.
@@ -227,14 +230,12 @@ return:
```json
{
"hostname": "hostname|null",
"features": ["shutdown", "reboot", "update", "hostname"],
"operating_system": "Hass.io-OS XY|Ubuntu 16.4|null",
"features": ["shutdown", "reboot", "hostname", "services", "hassos"],
"operating_system": "HassOS XY|Ubuntu 16.4|null",
"kernel": "4.15.7|null",
"chassis": "specific|null",
"type": "Hass.io-OS Type|null",
"deployment": "stable|beta|dev|null",
"version": "xy|null",
"last_version": "xy|null",
"cpe": "xy|null",
}
```
@@ -246,18 +247,59 @@ return:
}
```
- POST `/host/reload`
- POST `/host/update`
Optional:
#### Services
- GET `/host/services`
```json
{
"version": "VERSION"
"services": [
{
"name": "xy.service",
"description": "XY ...",
"state": "active|"
}
]
}
```
- POST `/host/reload`
- POST `/host/service/{unit}/stop`
- POST `/host/service/{unit}/start`
- POST `/host/service/{unit}/reload`
### HassOS
- GET `/hassos/info`
```json
{
"version": "2.3",
"version_cli": "7",
"version_latest": "2.4",
"version_cli_latest": "8",
"board": "ova|rpi"
}
```
- POST `/hassos/update`
```json
{
"version": "optional"
}
```
- POST `/hassos/update/cli`
```json
{
"version": "optional"
}
```
- POST `/hassos/config/sync`
Load host configs from a USB stick.
### Hardware
@@ -342,6 +384,7 @@ Output is the raw Docker log.
"port": "port for access hass",
"ssl": "bool",
"password": "",
"refresh_token": "",
"watchdog": "bool",
"startup_time": 600
}
@@ -372,6 +415,8 @@ Proxy to real websocket instance.
### RESTful for API addons
If a add-on will call itself, you can use `/addons/self/...`.
- GET `/addons`
Get all available addons.
@@ -427,10 +472,10 @@ Get all available addons.
"options": "{}",
"network": "{}|null",
"host_network": "bool",
"host_pid": "bool",
"host_ipc": "bool",
"host_dbus": "bool",
"privileged": ["NET_ADMIN", "SYS_ADMIN"],
"seccomp": "disable|default|profile",
"apparmor": "disable|default|profile",
"devices": ["/dev/xy"],
"auto_uart": "bool",
@@ -438,10 +483,16 @@ Get all available addons.
"logo": "bool",
"changelog": "bool",
"hassio_api": "bool",
"hassio_role": "default|homeassistant|manager|admin",
"homeassistant_api": "bool",
"full_access": "bool",
"protected": "bool",
"rating": "1-6",
"stdin": "bool",
"webui": "null|http(s)://[HOST]:port/xy/zx",
"gpio": "bool",
"devicetree": "bool",
"docker_api": "bool",
"audio": "bool",
"audio_input": "null|0,0",
"audio_output": "null|0,0",
@@ -473,6 +524,16 @@ Get all available addons.
Reset custom network/audio/options, set it `null`.
- POST `/addons/{addon}/security`
This function is not callable by itself.
```json
{
"protected": "bool",
}
```
- POST `/addons/{addon}/start`
- POST `/addons/{addon}/stop`
@@ -569,14 +630,6 @@ return:
}
```
- GET `/services/xy`
```json
{
"available": "bool",
"xy": {}
}
```
#### MQTT
This service performs an auto discovery to Home-Assistant.

View File

@@ -1,26 +1,22 @@
ARG BUILD_FROM
FROM $BUILD_FROM
# Add env
ENV LANG C.UTF-8
# Setup base
# Install base
RUN apk add --no-cache \
python3 \
git \
socat \
glib \
libstdc++ \
eudev-libs \
&& apk add --no-cache --virtual .build-dependencies \
git \
socat \
glib \
libstdc++ \
eudev-libs
# Install requirements
COPY requirements.txt /usr/src/
RUN apk add --no-cache --virtual .build-dependencies \
make \
python3-dev \
g++ \
&& pip3 install --no-cache-dir \
uvloop==0.9.1 \
cchardet==2.1.1 \
pycryptodome==3.4.11 \
&& apk del .build-dependencies
&& pip3 install --no-cache-dir -r /usr/src/requirements.txt \
&& apk del .build-dependencies \
&& rm -f /usr/src/requirements.txt
# Install HassIO
COPY . /usr/src/hassio

View File

@@ -1 +1 @@
"""Init file for HassIO."""
"""Init file for Hass.io."""

View File

@@ -1,10 +1,10 @@
"""Main file for HassIO."""
"""Main file for Hass.io."""
import asyncio
from concurrent.futures import ThreadPoolExecutor
import logging
import sys
import hassio.bootstrap as bootstrap
from hassio import bootstrap
_LOGGER = logging.getLogger(__name__)
@@ -31,7 +31,7 @@ if __name__ == "__main__":
executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker")
loop.set_default_executor(executor)
_LOGGER.info("Initialize Hassio setup")
_LOGGER.info("Initialize Hass.io setup")
coresys = bootstrap.initialize_coresys(loop)
bootstrap.migrate_system_env(coresys)
@@ -43,13 +43,13 @@ if __name__ == "__main__":
loop.call_soon_threadsafe(bootstrap.reg_signal, loop)
try:
_LOGGER.info("Run HassIO")
_LOGGER.info("Run Hass.io")
loop.run_forever()
finally:
_LOGGER.info("Stopping HassIO")
_LOGGER.info("Stopping Hass.io")
loop.run_until_complete(coresys.core.stop())
executor.shutdown(wait=False)
loop.close()
_LOGGER.info("Close Hassio")
_LOGGER.info("Close Hass.io")
sys.exit(0)

View File

@@ -50,6 +50,13 @@ class AddonManager(CoreSysAttributes):
return addon
return None
def from_token(self, token):
"""Return an add-on from hassio token."""
for addon in self.list_addons:
if addon.is_installed and token == addon.hassio_token:
return addon
return None
async def load(self):
"""Startup addon management."""
self.data.reload()

View File

@@ -14,7 +14,7 @@ from voluptuous.humanize import humanize_error
from .validate import (
validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME, RE_SERVICE)
from .utils import check_installed
from .utils import check_installed, remove_data
from ..const import (
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP,
ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY,
@@ -25,11 +25,15 @@ from ..const import (
ATTR_HASSIO_API, ATTR_AUDIO, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC,
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES,
ATTR_SECCOMP, ATTR_APPARMOR, SECURITY_PROFILE, SECURITY_DISABLE,
SECURITY_DEFAULT)
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS,
ATTR_PROTECTED, ATTR_ACCESS_TOKEN, ATTR_HOST_PID, ATTR_HASSIO_ROLE,
SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT)
from ..coresys import CoreSysAttributes
from ..docker.addon import DockerAddon
from ..utils import create_token
from ..utils.json import write_json_file, read_json_file
from ..utils.apparmor import adjust_profile
from ..exceptions import HostAppArmorError
_LOGGER = logging.getLogger(__name__)
@@ -169,6 +173,13 @@ class Addon(CoreSysAttributes):
return self._data.user[self._id][ATTR_UUID]
return None
@property
def hassio_token(self):
"""Return access token for hass.io API."""
if self.is_installed:
return self._data.user[self._id].get(ATTR_ACCESS_TOKEN)
return None
@property
def description(self):
"""Return description of addon."""
@@ -199,6 +210,18 @@ class Addon(CoreSysAttributes):
return self._data.cache[self._id][ATTR_VERSION]
return self.version_installed
@property
def protected(self):
"""Return if addon is in protected mode."""
if self.is_installed:
return self._data.user[self._id][ATTR_PROTECTED]
return True
@protected.setter
def protected(self, value):
"""Set addon in protected mode."""
self._data.user[self._id][ATTR_PROTECTED] = value
@property
def startup(self):
"""Return startup type of addon."""
@@ -284,6 +307,11 @@ class Addon(CoreSysAttributes):
"""Return True if addon run on host network."""
return self._mesh[ATTR_HOST_NETWORK]
@property
def host_pid(self):
"""Return True if addon run on host PID namespace."""
return self._mesh[ATTR_HOST_PID]
@property
def host_ipc(self):
"""Return True if addon run on host IPC namespace."""
@@ -319,21 +347,12 @@ class Addon(CoreSysAttributes):
"""Return list of privilege."""
return self._mesh.get(ATTR_PRIVILEGED)
@property
def seccomp(self):
"""Return True if seccomp is enabled."""
if not self._mesh.get(ATTR_SECCOMP):
return SECURITY_DISABLE
elif self.path_seccomp.exists():
return SECURITY_PROFILE
return SECURITY_DEFAULT
@property
def apparmor(self):
"""Return True if seccomp is enabled."""
"""Return True if apparmor is enabled."""
if not self._mesh.get(ATTR_APPARMOR):
return SECURITY_DISABLE
elif self.path_apparmor.exists():
elif self.sys_host.apparmor.exists(self.slug):
return SECURITY_PROFILE
return SECURITY_DEFAULT
@@ -342,6 +361,11 @@ class Addon(CoreSysAttributes):
"""Return if the add-on don't support hass labels."""
return self._mesh.get(ATTR_LEGACY)
@property
def access_docker_api(self):
"""Return if the add-on need read-only docker API access."""
return self._mesh.get(ATTR_DOCKER_API)
@property
def access_hassio_api(self):
"""Return True if the add-on access to hassio api."""
@@ -352,6 +376,11 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access to Home-Assistant api proxy."""
return self._mesh[ATTR_HOMEASSISTANT_API]
@property
def hassio_role(self):
"""Return Hass.io role for API."""
return self._mesh[ATTR_HASSIO_ROLE]
@property
def with_stdin(self):
"""Return True if the add-on access use stdin input."""
@@ -362,6 +391,16 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access to gpio interface."""
return self._mesh[ATTR_GPIO]
@property
def with_full_access(self):
"""Return True if the add-on want full access to hardware."""
return self._mesh[ATTR_FULL_ACCESS]
@property
def with_devicetree(self):
"""Return True if the add-on read access to devicetree."""
return self._mesh[ATTR_DEVICETREE]
@property
def with_audio(self):
"""Return True if the add-on access to audio."""
@@ -493,15 +532,10 @@ class Addon(CoreSysAttributes):
"""Return path to addon changelog."""
return Path(self.path_location, 'CHANGELOG.md')
@property
def path_seccomp(self):
"""Return path to custom seccomp profile."""
return Path(self.path_location, 'seccomp.json')
@property
def path_apparmor(self):
"""Return path to custom AppArmor profile."""
return Path(self.path_location, 'apparmor')
return Path(self.path_location, 'apparmor.txt')
@property
def path_asound(self):
@@ -549,6 +583,27 @@ class Addon(CoreSysAttributes):
return True
async def _install_apparmor(self):
"""Install or Update AppArmor profile for Add-on."""
exists_local = self.sys_host.apparmor.exists(self.slug)
exists_addon = self.path_apparmor.exists()
# Nothing to do
if not exists_local and not exists_addon:
return
# Need removed
if exists_local and not exists_addon:
await self.sys_host.apparmor.remove_profile(self.slug)
return
# Need install/update
with TemporaryDirectory(dir=self.sys_config.path_tmp) as tmp_folder:
profile_file = Path(tmp_folder, 'apparmor.txt')
adjust_profile(self.slug, self.path_apparmor, profile_file)
await self.sys_host.apparmor.load_profile(self.slug, profile_file)
@property
def schema(self):
"""Create a schema for addon options."""
@@ -558,7 +613,7 @@ class Addon(CoreSysAttributes):
return vol.Schema(dict)
return vol.Schema(vol.All(dict, validate_options(raw_schema)))
def test_udpate_schema(self):
def test_update_schema(self):
"""Check if the exists config valid after update."""
if not self.is_installed or self.is_detached:
return True
@@ -604,6 +659,9 @@ class Addon(CoreSysAttributes):
"Create Home-Assistant addon data folder %s", self.path_data)
self.path_data.mkdir()
# Setup/Fix AppArmor profile
await self._install_apparmor()
if not await self.instance.install(self.last_version):
return False
@@ -619,13 +677,18 @@ class Addon(CoreSysAttributes):
if self.path_data.is_dir():
_LOGGER.info(
"Remove Home-Assistant addon data folder %s", self.path_data)
shutil.rmtree(str(self.path_data))
await remove_data(self.path_data)
# Cleanup audio settings
if self.path_asound.exists():
with suppress(OSError):
self.path_asound.unlink()
# Cleanup apparmor profile
if self.sys_host.apparmor.exists(self.slug):
with suppress(HostAppArmorError):
await self.sys_host.apparmor.remove_profile(self.slug)
self._set_uninstall()
return True
@@ -641,6 +704,14 @@ class Addon(CoreSysAttributes):
@check_installed
async def start(self):
"""Set options and start addon."""
if await self.instance.is_running():
_LOGGER.warning("%s allready running!", self.slug)
return
# Access Token
self._data.user[self._id][ATTR_ACCESS_TOKEN] = create_token()
self._data.save_data()
# Options
if not self.write_options():
return False
@@ -672,6 +743,9 @@ class Addon(CoreSysAttributes):
return False
self._set_update(self.last_version)
# Setup/Fix AppArmor profile
await self._install_apparmor()
# restore state
if last_state == STATE_STARTED:
await self.start()
@@ -738,7 +812,7 @@ class Addon(CoreSysAttributes):
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp:
# store local image
if self.need_build and not await \
self.instance.export_image(Path(temp, "image.tar")):
self.instance.export_image(Path(temp, 'image.tar')):
return False
data = {
@@ -750,11 +824,20 @@ class Addon(CoreSysAttributes):
# store local configs/state
try:
write_json_file(Path(temp, "addon.json"), data)
write_json_file(Path(temp, 'addon.json'), data)
except (OSError, json.JSONDecodeError) as err:
_LOGGER.error("Can't save meta for %s: %s", self._id, err)
return False
# Store AppArmor Profile
if self.sys_host.apparmor.exists(self.slug):
profile = Path(temp, 'apparmor.txt')
try:
self.sys_host.apparmor.backup_profile(self.slug, profile)
except HostAppArmorError:
_LOGGER.error("Can't backup AppArmor profile")
return False
# write into tarfile
def _write_tarfile():
"""Write tar inside loop."""
@@ -789,7 +872,7 @@ class Addon(CoreSysAttributes):
# read snapshot data
try:
data = read_json_file(Path(temp, "addon.json"))
data = read_json_file(Path(temp, 'addon.json'))
except (OSError, json.JSONDecodeError) as err:
_LOGGER.error("Can't read addon.json: %s", err)
@@ -810,7 +893,7 @@ class Addon(CoreSysAttributes):
if not await self.instance.exists():
_LOGGER.info("Restore image for addon %s", self._id)
image_file = Path(temp, "image.tar")
image_file = Path(temp, 'image.tar')
if image_file.is_file():
await self.instance.import_image(image_file, version)
else:
@@ -822,17 +905,27 @@ class Addon(CoreSysAttributes):
# restore data
def _restore_data():
"""Restore data."""
if self.path_data.is_dir():
shutil.rmtree(str(self.path_data), ignore_errors=True)
shutil.copytree(str(Path(temp, "data")), str(self.path_data))
_LOGGER.info("Restore data for addon %s", self._id)
if self.path_data.is_dir():
await remove_data(self.path_data)
try:
_LOGGER.info("Restore data for addon %s", self._id)
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
_LOGGER.error("Can't restore origin data: %s", err)
return False
# Restore AppArmor
profile_file = Path(temp, 'apparmor.txt')
if profile_file.exists():
try:
await self.sys_host.apparmor.load_profile(
self.slug, profile_file)
except HostAppArmorError:
_LOGGER.error("Can't restore AppArmor profile")
return False
# run addon
if data[ATTR_STATE] == STATE_STARTED:
return await self.start()

View File

@@ -1,5 +1,4 @@
"""Init file for HassIO addons."""
import copy
import logging
import json
from pathlib import Path
@@ -11,7 +10,7 @@ from .utils import extract_hash_from_path
from .validate import (
SCHEMA_ADDON_CONFIG, SCHEMA_ADDONS_FILE, SCHEMA_REPOSITORY_CONFIG)
from ..const import (
FILE_HASSIO_ADDONS, ATTR_VERSION, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON,
FILE_HASSIO_ADDONS, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON,
REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_USER, ATTR_SYSTEM)
from ..coresys import CoreSysAttributes
from ..utils.json import JsonConfig, read_json_file
@@ -70,9 +69,6 @@ class AddonsData(JsonConfig, CoreSysAttributes):
if repository_element.is_dir():
self._read_git_repository(repository_element)
# update local data
self._merge_config()
def _read_git_repository(self, path):
"""Process a custom repository folder."""
slug = extract_hash_from_path(path)
@@ -84,7 +80,7 @@ class AddonsData(JsonConfig, CoreSysAttributes):
read_json_file(repository_file)
)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
_LOGGER.warning("Can't read repository information from %s",
repository_file)
return
@@ -138,25 +134,3 @@ class AddonsData(JsonConfig, CoreSysAttributes):
# local repository
self._repositories[REPOSITORY_LOCAL] = \
builtin_data[REPOSITORY_LOCAL]
def _merge_config(self):
"""Update local config if they have update.
It need to be the same version as the local version is for merge.
"""
have_change = False
for addon in set(self.system):
# detached
if addon not in self._cache:
continue
cache = self._cache[addon]
data = self.system[addon]
if data[ATTR_VERSION] == cache[ATTR_VERSION]:
if data != cache:
self.system[addon] = copy.deepcopy(cache)
have_change = True
if have_change:
self.save_data()

View File

@@ -45,12 +45,13 @@ class GitRepo(CoreSysAttributes):
async with self.lock:
try:
_LOGGER.info("Load addon %s repository", self.path)
self.repo = await self.sys_loop.run_in_executor(
None, git.Repo, str(self.path))
self.repo = await self.sys_run_in_executor(
git.Repo, str(self.path))
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err:
_LOGGER.error("Can't load %s repo: %s.", self.path, err)
self._remove()
return False
return True
@@ -62,7 +63,9 @@ class GitRepo(CoreSysAttributes):
attribute: value
for attribute, value in (
('recursive', True),
('branch', self.branch)
('branch', self.branch),
('depth', 1),
('shallow-submodules', True)
) if value is not None
}
@@ -76,6 +79,7 @@ class GitRepo(CoreSysAttributes):
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err:
_LOGGER.error("Can't clone %s repo: %s.", self.url, err)
self._remove()
return False
return True
@@ -87,18 +91,43 @@ class GitRepo(CoreSysAttributes):
return False
async with self.lock:
_LOGGER.info("Update addon %s repository", self.url)
branch = self.repo.active_branch.name
try:
_LOGGER.info("Pull addon %s repository", self.url)
await self.sys_loop.run_in_executor(
None, self.repo.remotes.origin.pull)
# Download data
await self.sys_run_in_executor(ft.partial(
self.repo.remotes.origin.fetch, **{
'update-shallow': True,
'depth': 1,
}))
# Jump on top of that
await self.sys_run_in_executor(ft.partial(
self.repo.git.reset, f"origin/{branch}", hard=True))
# Cleanup old data
await self.sys_run_in_executor(ft.partial(
self.repo.git.clean, "-xdf"))
except (git.InvalidGitRepositoryError, git.NoSuchPathError,
git.GitCommandError) as err:
_LOGGER.error("Can't pull %s repo: %s.", self.url, err)
_LOGGER.error("Can't update %s repo: %s.", self.url, err)
return False
return True
def _remove(self):
"""Remove a repository."""
if not self.path.is_dir():
return
def log_err(funct, path, _):
"""Log error."""
_LOGGER.warning("Can't remove %s", path)
shutil.rmtree(str(self.path), onerror=log_err)
class GitRepoHassIO(GitRepo):
"""HassIO addons repository."""
@@ -121,12 +150,6 @@ class GitRepoCustom(GitRepo):
super().__init__(coresys, path, url)
def remove(self):
"""Remove a custom addon."""
if self.path.is_dir():
_LOGGER.info("Remove custom addon repository %s", self.url)
def log_err(funct, path, _):
"""Log error."""
_LOGGER.warning("Can't remove %s", path)
shutil.rmtree(str(self.path), onerror=log_err)
"""Remove a custom repository."""
_LOGGER.info("Remove custom addon repository %s", self.url)
self._remove()

View File

@@ -1,13 +1,67 @@
"""Util addons functions."""
import asyncio
import hashlib
import logging
import re
from ..const import (
SECURITY_DISABLE, SECURITY_PROFILE, PRIVILEGED_NET_ADMIN,
PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO, PRIVILEGED_SYS_PTRACE,
ROLE_ADMIN, ROLE_MANAGER)
RE_SHA1 = re.compile(r"[a-f0-9]{8}")
_LOGGER = logging.getLogger(__name__)
def rating_security(addon):
"""Return 1-5 for security rating.
1 = not secure
5 = high secure
"""
rating = 5
# AppArmor
if addon.apparmor == SECURITY_DISABLE:
rating += -1
elif addon.apparmor == SECURITY_PROFILE:
rating += 1
# API Access
if addon.access_hassio_api or addon.access_homeassistant_api:
rating += -1
# Privileged options
if addon.privileged in (PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN,
PRIVILEGED_SYS_RAWIO, PRIVILEGED_SYS_PTRACE):
rating += -1
# API Hass.io role
if addon.hassio_role == ROLE_MANAGER:
rating += -1
elif addon.hassio_role == ROLE_ADMIN:
rating += -2
# Not secure Networking
if addon.host_network:
rating += -1
# Insecure PID namespace
if addon.host_pid:
rating += -2
# Full Access
if addon.with_full_access:
rating += -2
# Docker Access
if addon.access_docker_api:
rating = 1
return max(min(6, rating), 1)
def get_hash_from_repository(name):
"""Generate a hash from repository."""
key = name.lower().encode()
@@ -33,3 +87,20 @@ def check_installed(method):
return await method(addon, *args, **kwargs)
return wrap_check
async def remove_data(folder):
"""Remove folder and reset privileged."""
try:
proc = await asyncio.create_subprocess_exec(
"rm", "-rf", str(folder),
stdout=asyncio.subprocess.DEVNULL
)
_, error_msg = await proc.communicate()
except OSError as err:
error_msg = str(err)
if proc.returncode == 0:
return
_LOGGER.error("Can't remove Add-on Data: %s", error_msg)

View File

@@ -18,7 +18,12 @@ from ..const import (
ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH,
ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY,
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY,
ATTR_SECCOMP, ATTR_APPARMOR)
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED,
ATTR_FULL_ACCESS, ATTR_ACCESS_TOKEN, ATTR_HOST_PID, ATTR_HASSIO_ROLE,
PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO,
PRIVILEGED_IPC_LOCK, PRIVILEGED_SYS_TIME, PRIVILEGED_SYS_NICE,
PRIVILEGED_SYS_RESOURCE, PRIVILEGED_SYS_PTRACE,
ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_MANAGER, ROLE_ADMIN)
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE
_LOGGER = logging.getLogger(__name__)
@@ -58,12 +63,21 @@ STARTUP_ALL = [
]
PRIVILEGED_ALL = [
"NET_ADMIN",
"SYS_ADMIN",
"SYS_RAWIO",
"IPC_LOCK",
"SYS_TIME",
"SYS_NICE"
PRIVILEGED_NET_ADMIN,
PRIVILEGED_SYS_ADMIN,
PRIVILEGED_SYS_RAWIO,
PRIVILEGED_IPC_LOCK,
PRIVILEGED_SYS_TIME,
PRIVILEGED_SYS_NICE,
PRIVILEGED_SYS_RESOURCE,
PRIVILEGED_SYS_PTRACE,
]
ROLE_ALL = [
ROLE_DEFAULT,
ROLE_HOMEASSISTANT,
ROLE_MANAGER,
ROLE_ADMIN,
]
BASE_IMAGE = {
@@ -99,6 +113,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Optional(ATTR_WEBUI):
vol.Match(r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"),
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICES): [vol.Match(r"^(.*):(.*):([rwm]{1,3})$")],
@@ -108,14 +123,17 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)],
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
vol.Optional(ATTR_SECCOMP, default=True): vol.Boolean(),
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(),
vol.Optional(ATTR_HASSIO_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL),
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
vol.Optional(ATTR_STDIN, default=False): vol.Boolean(),
vol.Optional(ATTR_LEGACY, default=False): vol.Boolean(),
vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(),
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
vol.Optional(ATTR_DISCOVERY): [vol.Match(RE_DISCOVERY)],
vol.Required(ATTR_OPTIONS): dict,
@@ -129,7 +147,8 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])
}))
}), False),
vol.Optional(ATTR_IMAGE): vol.Match(r"^[\w{}]+/[\-\w{}]+$"),
vol.Optional(ATTR_IMAGE):
vol.Match(r"^([a-zA-Z.:\d{}]+/)*?([\w{}]+)/([\-\w{}]+)$"),
vol.Optional(ATTR_TIMEOUT, default=10):
vol.All(vol.Coerce(int), vol.Range(min=10, max=120)),
}, extra=vol.REMOVE_EXTRA)
@@ -160,6 +179,7 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
vol.Match(r"^[0-9a-f]{32}$"),
vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"),
vol.Optional(ATTR_OPTIONS, default=dict): dict,
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
vol.Optional(ATTR_BOOT):
@@ -167,6 +187,7 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Optional(ATTR_NETWORK): DOCKER_PORTS,
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
}, extra=vol.REMOVE_EXTRA)

View File

@@ -9,6 +9,7 @@ from .discovery import APIDiscovery
from .homeassistant import APIHomeAssistant
from .hardware import APIHardware
from .host import APIHost
from .hassos import APIHassOS
from .proxy import APIProxy
from .supervisor import APISupervisor
from .snapshots import APISnapshots
@@ -30,13 +31,14 @@ class RestAPI(CoreSysAttributes):
middlewares=[self.security.token_validation], loop=coresys.loop)
# service stuff
self._handler = None
self.server = None
self._runner = web.AppRunner(self.webapp)
self._site = None
async def load(self):
"""Register REST API Calls."""
self._register_supervisor()
self._register_host()
self._register_hassos()
self._register_hardware()
self._register_homeassistant()
self._register_proxy()
@@ -55,8 +57,27 @@ class RestAPI(CoreSysAttributes):
web.get('/host/info', api_host.info),
web.post('/host/reboot', api_host.reboot),
web.post('/host/shutdown', api_host.shutdown),
web.post('/host/update', api_host.update),
web.post('/host/reload', api_host.reload),
web.post('/host/options', api_host.options),
web.get('/host/services', api_host.services),
web.post('/host/services/{service}/stop', api_host.service_stop),
web.post('/host/services/{service}/start', api_host.service_start),
web.post(
'/host/services/{service}/restart', api_host.service_restart),
web.post(
'/host/services/{service}/reload', api_host.service_reload),
])
def _register_hassos(self):
"""Register hassos function."""
api_hassos = APIHassOS()
api_hassos.coresys = self.coresys
self.webapp.add_routes([
web.get('/hassos/info', api_hassos.info),
web.post('/hassos/update', api_hassos.update),
web.post('/hassos/update/cli', api_hassos.update_cli),
web.post('/hassos/config/sync', api_hassos.config_sync),
])
def _register_hardware(self):
@@ -137,6 +158,7 @@ class RestAPI(CoreSysAttributes):
web.get('/addons/{addon}/logo', api_addons.logo),
web.get('/addons/{addon}/changelog', api_addons.changelog),
web.post('/addons/{addon}/stdin', api_addons.stdin),
web.post('/addons/{addon}/security', api_addons.security),
web.get('/addons/{addon}/stats', api_addons.stats),
])
@@ -213,22 +235,25 @@ class RestAPI(CoreSysAttributes):
async def start(self):
"""Run rest api webserver."""
self._handler = self.webapp.make_handler()
await self._runner.setup()
self._site = web.TCPSite(
self._runner, host="0.0.0.0", port=80, shutdown_timeout=5)
try:
self.server = await self.sys_loop.create_server(
self._handler, "0.0.0.0", "80")
await self._site.start()
except OSError as err:
_LOGGER.fatal(
"Failed to create HTTP server at 0.0.0.0:80 -> %s", err)
else:
_LOGGER.info("Start API on %s", self.sys_docker.network.supervisor)
async def stop(self):
"""Stop rest api webserver."""
if self.server:
self.server.close()
await self.server.wait_closed()
await self.webapp.shutdown()
if not self._site:
return
if self._handler:
await self._handler.shutdown(60)
await self.webapp.cleanup()
# Shutdown running API
await self._site.stop()
await self._runner.cleanup()
_LOGGER.info("Stop API on %s", self.sys_docker.network.supervisor)

View File

@@ -6,6 +6,7 @@ import voluptuous as vol
from voluptuous.humanize import humanize_error
from .utils import api_process, api_process_raw, api_validate
from ..addons.utils import rating_security
from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS,
ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY,
@@ -17,10 +18,13 @@ from ..const import (
ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION,
ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX,
ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES,
ATTR_DISCOVERY, ATTR_SECCOMP, ATTR_APPARMOR,
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT)
ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API,
ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING, ATTR_HOST_PID,
ATTR_HASSIO_ROLE,
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM)
from ..coresys import CoreSysAttributes
from ..validate import DOCKER_PORTS, ALSA_DEVICE
from ..exceptions import APINotSupportedError
_LOGGER = logging.getLogger(__name__)
@@ -37,13 +41,24 @@ SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
})
# pylint: disable=no-value-for-parameter
SCHEMA_SECURITY = vol.Schema({
vol.Optional(ATTR_PROTECTED): vol.Boolean(),
})
class APIAddons(CoreSysAttributes):
"""Handle rest api for addons functions."""
def _extract_addon(self, request, check_installed=True):
"""Return addon, throw an exception it it doesn't exist."""
addon = self.sys_addons.get(request.match_info.get('addon'))
addon_slug = request.match_info.get('addon')
# Lookup itself
if addon_slug == 'self':
addon_slug = request.get(REQUEST_FROM)
addon = self.sys_addons.get(addon_slug)
if not addon:
raise RuntimeError("Addon does not exist")
@@ -116,6 +131,8 @@ class APIAddons(CoreSysAttributes):
ATTR_REPOSITORY: addon.repository,
ATTR_LAST_VERSION: addon.last_version,
ATTR_STATE: await addon.state(),
ATTR_PROTECTED: addon.protected,
ATTR_RATING: rating_security(addon),
ATTR_BOOT: addon.boot,
ATTR_OPTIONS: addon.options,
ATTR_URL: addon.url,
@@ -123,10 +140,11 @@ class APIAddons(CoreSysAttributes):
ATTR_BUILD: addon.need_build,
ATTR_NETWORK: addon.ports,
ATTR_HOST_NETWORK: addon.host_network,
ATTR_HOST_PID: addon.host_pid,
ATTR_HOST_IPC: addon.host_ipc,
ATTR_HOST_DBUS: addon.host_dbus,
ATTR_PRIVILEGED: addon.privileged,
ATTR_SECCOMP: addon.seccomp,
ATTR_FULL_ACCESS: addon.with_full_access,
ATTR_APPARMOR: addon.apparmor,
ATTR_DEVICES: self._pretty_devices(addon),
ATTR_ICON: addon.with_icon,
@@ -135,8 +153,11 @@ class APIAddons(CoreSysAttributes):
ATTR_WEBUI: addon.webui,
ATTR_STDIN: addon.with_stdin,
ATTR_HASSIO_API: addon.access_hassio_api,
ATTR_HASSIO_ROLE: addon.hassio_role,
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
ATTR_GPIO: addon.with_gpio,
ATTR_DEVICETREE: addon.with_devicetree,
ATTR_DOCKER_API: addon.access_docker_api,
ATTR_AUDIO: addon.with_audio,
ATTR_AUDIO_INPUT: addon.audio_input,
ATTR_AUDIO_OUTPUT: addon.audio_output,
@@ -171,6 +192,26 @@ class APIAddons(CoreSysAttributes):
addon.save_data()
return True
@api_process
async def security(self, request):
"""Store security options for addon."""
addon = self._extract_addon(request)
# Have Access
# REMOVE: don't needed anymore
if addon.slug == request[REQUEST_FROM]:
_LOGGER.error("Can't self modify his security!")
raise APINotSupportedError()
body = await api_validate(SCHEMA_SECURITY, request)
if ATTR_PROTECTED in body:
_LOGGER.warning("Protected flag changing for %s!", addon.slug)
addon.protected = body[ATTR_PROTECTED]
addon.save_data()
return True
@api_process
async def stats(self, request):
"""Return resource information."""

53
hassio/api/hassos.py Normal file
View File

@@ -0,0 +1,53 @@
"""Init file for Hass.io hassos rest api."""
import asyncio
import logging
import voluptuous as vol
from .utils import api_process, api_validate
from ..const import (
ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST, ATTR_VERSION_CLI,
ATTR_VERSION_CLI_LATEST)
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({
vol.Optional(ATTR_VERSION): vol.Coerce(str),
})
class APIHassOS(CoreSysAttributes):
"""Handle rest api for hassos functions."""
@api_process
async def info(self, request):
"""Return hassos information."""
return {
ATTR_VERSION: self.sys_hassos.version,
ATTR_VERSION_CLI: self.sys_hassos.version_cli,
ATTR_VERSION_LATEST: self.sys_hassos.version_latest,
ATTR_VERSION_CLI_LATEST: self.sys_hassos.version_cli_latest,
ATTR_BOARD: self.sys_hassos.board,
}
@api_process
async def update(self, request):
"""Update HassOS."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_latest)
await asyncio.shield(self.sys_hassos.update(version))
@api_process
async def update_cli(self, request):
"""Update HassOS CLI."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest)
await asyncio.shield(self.sys_hassos.update_cli(version))
@api_process
def config_sync(self, request):
"""Trigger config reload on HassOS."""
return asyncio.shield(self.sys_hassos.config_sync())

View File

@@ -10,7 +10,7 @@ from ..const import (
ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX,
ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, ATTR_MACHINE,
CONTENT_TYPE_BINARY)
ATTR_REFRESH_TOKEN, CONTENT_TYPE_BINARY)
from ..coresys import CoreSysAttributes
from ..validate import NETWORK_PORT, DOCKER_IMAGE
@@ -21,15 +21,16 @@ _LOGGER = logging.getLogger(__name__)
SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_BOOT): vol.Boolean(),
vol.Inclusive(ATTR_IMAGE, 'custom_hass'):
vol.Any(None, vol.Coerce(str)),
vol.Maybe(vol.Coerce(str)),
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
vol.Any(None, DOCKER_IMAGE),
vol.Optional(ATTR_PORT): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_SSL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT):
vol.All(vol.Coerce(int), vol.Range(min=60)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
})
SCHEMA_VERSION = vol.Schema({
@@ -83,8 +84,10 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WAIT_BOOT in body:
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
if ATTR_REFRESH_TOKEN in body:
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
self.sys_homeassistant.save_data()
return True
@api_process
async def stats(self, request):
@@ -109,11 +112,7 @@ class APIHomeAssistant(CoreSysAttributes):
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version)
if version == self.sys_homeassistant.version:
raise RuntimeError("Version {} is already in use".format(version))
return await asyncio.shield(
self.sys_homeassistant.update(version))
await asyncio.shield(self.sys_homeassistant.update(version))
@api_process
def stop(self, request):

View File

@@ -6,15 +6,14 @@ import voluptuous as vol
from .utils import api_process, api_validate
from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_HOSTNAME, ATTR_FEATURES, ATTR_KERNEL,
ATTR_TYPE, ATTR_OPERATING_SYSTEM, ATTR_CHASSIS, ATTR_DEPLOYMENT)
ATTR_HOSTNAME, ATTR_FEATURES, ATTR_KERNEL, ATTR_OPERATING_SYSTEM,
ATTR_CHASSIS, ATTR_DEPLOYMENT, ATTR_STATE, ATTR_NAME, ATTR_DESCRIPTON,
ATTR_SERVICES, ATTR_CPE)
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({
vol.Optional(ATTR_VERSION): vol.Coerce(str),
})
SERVICE = 'service'
SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_HOSTNAME): vol.Coerce(str),
@@ -29,9 +28,7 @@ class APIHost(CoreSysAttributes):
"""Return host information."""
return {
ATTR_CHASSIS: self.sys_host.info.chassis,
ATTR_VERSION: None,
ATTR_LAST_VERSION: None,
ATTR_TYPE: None,
ATTR_CPE: self.sys_host.info.cpe,
ATTR_FEATURES: self.sys_host.supperted_features,
ATTR_HOSTNAME: self.sys_host.info.hostname,
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
@@ -65,8 +62,40 @@ class APIHost(CoreSysAttributes):
return asyncio.shield(self.sys_host.reload())
@api_process
async def update(self, request):
"""Update host OS."""
pass
# body = await api_validate(SCHEMA_VERSION, request)
# version = body.get(ATTR_VERSION, self.sys_host.last_version)
async def services(self, request):
"""Return list of available services."""
services = []
for unit in self.sys_host.services:
services.append({
ATTR_NAME: unit.name,
ATTR_DESCRIPTON: unit.description,
ATTR_STATE: unit.state,
})
return {
ATTR_SERVICES: services
}
@api_process
def service_start(self, request):
"""Start a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.start(unit))
@api_process
def service_stop(self, request):
"""Stop a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.stop(unit))
@api_process
def service_reload(self, request):
"""Reload a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.reload(unit))
@api_process
def service_restart(self, request):
"""Restart a service."""
unit = request.match_info.get(SERVICE)
return asyncio.shield(self.sys_host.services.restart(unit))

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1 +1 @@
!function(e){function n(n){for(var t,o,a=n[0],i=n[1],u=0,f=[];u<a.length;u++)o=a[u],r[o]&&f.push(r[o][0]),r[o]=0;for(t in i)Object.prototype.hasOwnProperty.call(i,t)&&(e[t]=i[t]);for(c&&c(n);f.length;)f.shift()()}var t={},r={6:0};function o(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,o),r.l=!0,r.exports}o.e=function(e){var n=[],t=r[e];if(0!==t)if(t)n.push(t[2]);else{var a=new Promise(function(n,o){t=r[e]=[n,o]});n.push(t[2]=a);var i=document.getElementsByTagName("head")[0],u=document.createElement("script");u.charset="utf-8",u.timeout=120,o.nc&&u.setAttribute("nonce",o.nc),u.src=function(e){return o.p+"chunk."+{0:"311036a0f4514f345e53",1:"a8e86d80be46b3b6e16d",2:"e4eb9811aad7204f14c4",3:"4ac8b99259327a51f355",4:"87cffadba6f33daa568c",5:"c93f37c558ff32991708"}[e]+".js"}(e);var c=setTimeout(function(){f({type:"timeout",target:u})},12e4);function f(n){u.onerror=u.onload=null,clearTimeout(c);var t=r[e];if(0!==t){if(t){var o=n&&("load"===n.type?"missing":n.type),a=n&&n.target&&n.target.src,i=new Error("Loading chunk "+e+" failed.\n("+o+": "+a+")");i.type=o,i.request=a,t[1](i)}r[e]=void 0}}u.onerror=u.onload=f,i.appendChild(u)}return Promise.all(n)},o.m=e,o.c=t,o.d=function(e,n,t){o.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:t})},o.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,"a",n),n},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.p="/api/hassio/app/",o.oe=function(e){throw console.error(e),e};var a=window.webpackJsonp=window.webpackJsonp||[],i=a.push.bind(a);a.push=n,a=a.slice();for(var u=0;u<a.length;u++)n(a[u]);var c=i;o(o.s=0)}([function(e,n,t){window.loadES5Adapter().then(function(){Promise.all([t.e(0),t.e(3)]).then(t.bind(null,1)),Promise.all([t.e(0),t.e(1),t.e(2)]).then(t.bind(null,2))})}]);
!function(e){function n(n){for(var t,o,i=n[0],u=n[1],a=0,l=[];a<i.length;a++)o=i[a],r[o]&&l.push(r[o][0]),r[o]=0;for(t in u)Object.prototype.hasOwnProperty.call(u,t)&&(e[t]=u[t]);for(f&&f(n);l.length;)l.shift()()}var t={},r={6:0};function o(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,o),r.l=!0,r.exports}o.e=function(e){var n=[],t=r[e];if(0!==t)if(t)n.push(t[2]);else{var i=new Promise(function(n,o){t=r[e]=[n,o]});n.push(t[2]=i);var u,a=document.getElementsByTagName("head")[0],f=document.createElement("script");f.charset="utf-8",f.timeout=120,o.nc&&f.setAttribute("nonce",o.nc),f.src=function(e){return o.p+"chunk."+{0:"f3880aa331d3ef2ddf32",1:"a8e86d80be46b3b6e16d",2:"0ef4ef1053fe3d5107b5",3:"ff92199b0d422767d108",4:"c77b56beea1d4547ff5f",5:"c93f37c558ff32991708"}[e]+".js"}(e),u=function(n){f.onerror=f.onload=null,clearTimeout(l);var t=r[e];if(0!==t){if(t){var o=n&&("load"===n.type?"missing":n.type),i=n&&n.target&&n.target.src,u=new Error("Loading chunk "+e+" failed.\n("+o+": "+i+")");u.type=o,u.request=i,t[1](u)}r[e]=void 0}};var l=setTimeout(function(){u({type:"timeout",target:f})},12e4);f.onerror=f.onload=u,a.appendChild(f)}return Promise.all(n)},o.m=e,o.c=t,o.d=function(e,n,t){o.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:t})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,n){if(1&n&&(e=o(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(o.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var r in e)o.d(t,r,function(n){return e[n]}.bind(null,r));return t},o.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(n,"a",n),n},o.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},o.p="/api/hassio/app/",o.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],u=i.push.bind(i);i.push=n,i=i.slice();for(var a=0;a<i.length;a++)n(i[a]);var f=u;o(o.s=0)}([function(e,n,t){window.loadES5Adapter().then(function(){Promise.all([t.e(0),t.e(3)]).then(t.bind(null,1)),Promise.all([t.e(0),t.e(1),t.e(2)]).then(t.bind(null,2))})}]);

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -11,7 +11,7 @@
padding: 0;
}
</style>
<script src='/static/custom-elements-es5-adapter.js'></script>
<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>
</head>
<body>
<hassio-app></hassio-app>

Binary file not shown.

View File

@@ -1,15 +1,18 @@
"""Utils for HomeAssistant Proxy."""
import asyncio
from contextlib import asynccontextmanager
import logging
import aiohttp
from aiohttp import web
from aiohttp.web_exceptions import HTTPBadGateway, HTTPInternalServerError
from aiohttp.web_exceptions import (
HTTPBadGateway, HTTPInternalServerError, HTTPUnauthorized)
from aiohttp.hdrs import CONTENT_TYPE
import async_timeout
from ..const import HEADER_HA_ACCESS
from ..coresys import CoreSysAttributes
from ..exceptions import HomeAssistantAuthError, HomeAssistantAPIError
_LOGGER = logging.getLogger(__name__)
@@ -20,52 +23,52 @@ class APIProxy(CoreSysAttributes):
def _check_access(self, request):
"""Check the Hass.io token."""
hassio_token = request.headers.get(HEADER_HA_ACCESS)
addon = self.sys_addons.from_uuid(hassio_token)
addon = self.sys_addons.from_token(hassio_token)
# REMOVE 132
if not addon:
addon = self.sys_addons.from_uuid(hassio_token)
if not addon:
_LOGGER.warning("Unknown Home-Assistant API access!")
_LOGGER.warning("Unknown HomeAssistant API access!")
elif not addon.access_homeassistant_api:
_LOGGER.warning("Not permitted API access: %s", addon.slug)
else:
_LOGGER.info("%s access from %s", request.path, addon.slug)
return
raise HTTPUnauthorized()
@asynccontextmanager
async def _api_client(self, request, path, timeout=300):
"""Return a client request with proxy origin for Home-Assistant."""
url = f"{self.sys_homeassistant.api_url}/api/{path}"
try:
data = None
headers = {}
method = getattr(self.sys_websession_ssl, request.method.lower())
params = request.query or None
# read data
with async_timeout.timeout(30):
data = await request.read()
if data:
headers.update({CONTENT_TYPE: request.content_type})
content_type = request.content_type
else:
content_type = None
# need api password?
if self.sys_homeassistant.api_password:
headers = {
HEADER_HA_ACCESS: self.sys_homeassistant.api_password,
}
# reset headers
if not headers:
headers = None
client = await method(
url, data=data, headers=headers, timeout=timeout,
params=params
)
return client
async with self.sys_homeassistant.make_request(
request.method.lower(), f'api/{path}',
content_type=content_type,
data=data,
timeout=timeout,
) as resp:
yield resp
return
except HomeAssistantAuthError:
_LOGGER.error("Authenticate error on API for request %s", path)
except HomeAssistantAPIError:
_LOGGER.error("Error on API for request %s", path)
except aiohttp.ClientError as err:
_LOGGER.error("Client error on API %s request %s.", path, err)
_LOGGER.error("Client error on API %s request %s", path, err)
except asyncio.TimeoutError:
_LOGGER.error("Client timeout error on API request %s.", path)
_LOGGER.error("Client timeout error on API request %s", path)
raise HTTPBadGateway()
@@ -74,30 +77,25 @@ class APIProxy(CoreSysAttributes):
self._check_access(request)
_LOGGER.info("Home-Assistant EventStream start")
client = await self._api_client(request, 'stream', timeout=None)
async with self._api_client(request, 'stream', timeout=None) as client:
response = web.StreamResponse()
response.content_type = request.headers.get(CONTENT_TYPE)
try:
await response.prepare(request)
while True:
data = await client.content.read(10)
if not data:
break
await response.write(data)
response = web.StreamResponse()
response.content_type = request.headers.get(CONTENT_TYPE)
try:
await response.prepare(request)
while True:
data = await client.content.read(10)
if not data:
await response.write_eof()
break
await response.write(data)
except aiohttp.ClientError:
pass
except aiohttp.ClientError:
await response.write_eof()
finally:
client.close()
_LOGGER.info("Home-Assistant EventStream close")
except asyncio.CancelledError:
pass
finally:
client.close()
_LOGGER.info("Home-Assistant EventStream close")
return response
return response
async def api(self, request):
"""Proxy HomeAssistant API Requests."""
@@ -105,14 +103,13 @@ class APIProxy(CoreSysAttributes):
# Normal request
path = request.match_info.get('path', '')
client = await self._api_client(request, path)
data = await client.read()
return web.Response(
body=data,
status=client.status,
content_type=client.content_type
)
async with self._api_client(request, path) as client:
data = await client.read()
return web.Response(
body=data,
status=client.status,
content_type=client.content_type
)
async def _websocket_client(self):
"""Initialize a websocket api connection."""
@@ -123,20 +120,46 @@ class APIProxy(CoreSysAttributes):
url, heartbeat=60, verify_ssl=False)
# handle authentication
for _ in range(2):
data = await client.receive_json()
if data.get('type') == 'auth_ok':
return client
elif data.get('type') == 'auth_required':
await client.send_json({
'type': 'auth',
'api_password': self.sys_homeassistant.api_password,
})
data = await client.receive_json()
_LOGGER.error("Authentication to Home-Assistant websocket")
if data.get('type') == 'auth_ok':
return client
except (aiohttp.ClientError, RuntimeError) as err:
if data.get('type') != 'auth_required':
# Invalid protocol
_LOGGER.error(
'Got unexpected response from HA websocket: %s', data)
raise HTTPBadGateway()
if self.sys_homeassistant.refresh_token:
await self.sys_homeassistant.ensure_access_token()
await client.send_json({
'type': 'auth',
'access_token': self.sys_homeassistant.access_token,
})
else:
await client.send_json({
'type': 'auth',
'api_password': self.sys_homeassistant.api_password,
})
data = await client.receive_json()
if data.get('type') == 'auth_ok':
return client
# Renew the Token is invalid
if (data.get('type') == 'invalid_auth' and
self.sys_homeassistant.refresh_token):
self.sys_homeassistant.access_token = None
return await self._websocket_client()
raise HomeAssistantAuthError()
except (RuntimeError, ValueError) as err:
_LOGGER.error("Client error on websocket API %s.", err)
except HomeAssistantAuthError as err:
_LOGGER.error("Failed authentication to HomeAssistant websocket")
raise HTTPBadGateway()
@@ -157,13 +180,23 @@ class APIProxy(CoreSysAttributes):
# Check API access
response = await server.receive_json()
hassio_token = response.get('api_password')
addon = self.sys_addons.from_uuid(hassio_token)
hassio_token = (response.get('api_password') or
response.get('access_token'))
addon = self.sys_addons.from_token(hassio_token)
# REMOVE 132
if not addon:
addon = self.sys_addons.from_uuid(hassio_token)
if not addon or not addon.access_homeassistant_api:
_LOGGER.warning("Unauthorized websocket access!")
else:
_LOGGER.info("Websocket access from %s", addon.slug)
await server.send_json({
'type': 'auth_invalid',
'message': 'Invalid access',
})
return server
_LOGGER.info("Websocket access from %s", addon.slug)
await server.send_json({
'type': 'auth_ok',

View File

@@ -3,18 +3,62 @@ import logging
import re
from aiohttp.web import middleware
from aiohttp.web_exceptions import HTTPUnauthorized
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden
from ..const import HEADER_TOKEN, REQUEST_FROM
from ..const import (
HEADER_TOKEN, REQUEST_FROM, ROLE_ADMIN, ROLE_DEFAULT, ROLE_HOMEASSISTANT,
ROLE_MANAGER)
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
NO_SECURITY_CHECK = set((
re.compile(r"^/homeassistant/api/.*$"),
re.compile(r"^/homeassistant/websocket$"),
re.compile(r"^/supervisor/ping$"),
))
# Free to call or have own security concepts
NO_SECURITY_CHECK = re.compile(
r"^(?:"
r"|/homeassistant/api/.*"
r"|/homeassistant/websocket"
r"|/supervisor/ping"
r"|/services.*"
r")$"
)
# Can called by every add-on
ADDONS_API_BYPASS = re.compile(
r"^(?:"
r"|/homeassistant/info"
r"|/supervisor/info"
r"|/addons(?:/self/(?!security)[^/]+)?"
r")$"
)
# Policy role add-on API access
ADDONS_ROLE_ACCESS = {
ROLE_DEFAULT: re.compile(
r"^(?:"
r"|/[^/]+/info"
r")$"
),
ROLE_HOMEASSISTANT: re.compile(
r"^(?:"
r"|/homeassistant/.+"
r")$"
),
ROLE_MANAGER: re.compile(
r"^(?:"
r"|/homeassistant/.+"
r"|/host/.+"
r"|/hardware/.+"
r"|/hassos/.+"
r"|/supervisor/.+"
r"|/addons/.+/(?!security|options).+"
r"|/addons(?:/self/(?!security).+)"
r"|/snapshots.*"
r")$"
),
ROLE_ADMIN: re.compile(
r".+"
),
}
class SecurityMiddleware(CoreSysAttributes):
@@ -27,30 +71,55 @@ class SecurityMiddleware(CoreSysAttributes):
@middleware
async def token_validation(self, request, handler):
"""Check security access of this layer."""
request_from = None
hassio_token = request.headers.get(HEADER_TOKEN)
# Ignore security check
for rule in NO_SECURITY_CHECK:
if rule.match(request.path):
_LOGGER.debug("Passthrough %s", request.path)
return await handler(request)
if NO_SECURITY_CHECK.match(request.path):
_LOGGER.debug("Passthrough %s", request.path)
return await handler(request)
# Unknown API access
# Not token
if not hassio_token:
_LOGGER.warning("Invalid token for access %s", request.path)
_LOGGER.warning("No API token provided for %s", request.path)
raise HTTPUnauthorized()
# Home-Assistant
if hassio_token == self.sys_homeassistant.uuid:
# UUID check need removed with 131
if hassio_token in (self.sys_homeassistant.uuid,
self.sys_homeassistant.hassio_token):
_LOGGER.debug("%s access from Home-Assistant", request.path)
request[REQUEST_FROM] = 'homeassistant'
return await handler(request)
request_from = 'homeassistant'
# Host
if hassio_token == self.sys_machine_id:
_LOGGER.debug("%s access from Host", request.path)
request_from = 'host'
# Add-on
addon = self.sys_addons.from_uuid(hassio_token)
if addon:
_LOGGER.info("%s access from %s", request.path, addon.slug)
request[REQUEST_FROM] = addon.slug
addon = None
if hassio_token and not request_from:
addon = self.sys_addons.from_token(hassio_token)
# REMOVE 132
if not addon:
addon = self.sys_addons.from_uuid(hassio_token)
# Check Add-on API access
if addon and ADDONS_API_BYPASS.match(request.path):
_LOGGER.debug("Passthrough %s from %s", request.path, addon.slug)
request_from = addon.slug
elif addon and addon.access_hassio_api:
# Check Role
if ADDONS_ROLE_ACCESS[addon.hassio_role].match(request.path):
_LOGGER.info("%s access from %s", request.path, addon.slug)
request_from = addon.slug
else:
_LOGGER.warning("%s no role for %s", request.path, addon.slug)
request_from = addon.slug # REMOVE: 132
if request_from:
request[REQUEST_FROM] = request_from
return await handler(request)
raise HTTPUnauthorized()
_LOGGER.warning("Invalid token for access %s", request.path)
raise HTTPForbidden()

View File

@@ -1,6 +1,5 @@
"""Init file for HassIO util for rest api."""
import json
import hashlib
import logging
from aiohttp import web
@@ -31,11 +30,10 @@ def api_process(method):
"""Return api information."""
try:
answer = await method(api, *args, **kwargs)
except HassioError:
return api_return_error()
except RuntimeError as err:
return api_return_error(message=str(err))
except HassioError:
_LOGGER.exception("Hassio error")
return api_return_error()
if isinstance(answer, dict):
return api_return_ok(data=answer)
@@ -95,9 +93,3 @@ async def api_validate(schema, request):
raise RuntimeError(humanize_error(data, ex)) from None
return data
def hash_password(password):
"""Hash and salt our passwords."""
key = ")*()*SALT_HASSIO2123{}6554547485HSKA!!*JSLAfdasda$".format(password)
return hashlib.sha256(key.encode()).hexdigest()

View File

@@ -1,4 +1,4 @@
"""Bootstrap HassIO."""
"""Bootstrap Hass.io."""
import logging
import os
import signal
@@ -21,9 +21,16 @@ from .services import ServiceManager
from .services import Discovery
from .host import HostManager
from .dbus import DBusManager
from .hassos import HassOS
_LOGGER = logging.getLogger(__name__)
ENV_SHARE = 'SUPERVISOR_SHARE'
ENV_NAME = 'SUPERVISOR_NAME'
ENV_REPO = 'HOMEASSISTANT_REPOSITORY'
MACHINE_ID = Path('/etc/machine-id')
def initialize_coresys(loop):
"""Initialize HassIO coresys/objects."""
@@ -42,59 +49,70 @@ def initialize_coresys(loop):
coresys.services = ServiceManager(coresys)
coresys.discovery = Discovery(coresys)
coresys.dbus = DBusManager(coresys)
coresys.hassos = HassOS(coresys)
# bootstrap config
initialize_system_data(coresys)
# Set Machine/Host ID
if MACHINE_ID.exists():
coresys.machine_id = MACHINE_ID.read_text().strip()
return coresys
def initialize_system_data(coresys):
"""Setup default config and create folders."""
"""Set up the default configuration and create folders."""
config = coresys.config
# homeassistant config folder
if not config.path_config.is_dir():
# Home Assistant configuration folder
if not config.path_homeassistant.is_dir():
_LOGGER.info(
"Create Home-Assistant config folder %s", config.path_config)
config.path_config.mkdir()
"Create Home Assistant configuration folder %s",
config.path_homeassistant)
config.path_homeassistant.mkdir()
# hassio ssl folder
if not config.path_ssl.is_dir():
_LOGGER.info("Create hassio ssl folder %s", config.path_ssl)
_LOGGER.info("Create Hass.io SSL/TLS folder %s", config.path_ssl)
config.path_ssl.mkdir()
# hassio addon data folder
if not config.path_addons_data.is_dir():
_LOGGER.info(
"Create hassio addon data folder %s", config.path_addons_data)
"Create Hass.io Add-on data folder %s", config.path_addons_data)
config.path_addons_data.mkdir(parents=True)
if not config.path_addons_local.is_dir():
_LOGGER.info("Create hassio addon local repository folder %s",
_LOGGER.info("Create Hass.io Add-on local repository folder %s",
config.path_addons_local)
config.path_addons_local.mkdir(parents=True)
if not config.path_addons_git.is_dir():
_LOGGER.info("Create hassio addon git repositories folder %s",
_LOGGER.info("Create Hass.io Add-on git repositories folder %s",
config.path_addons_git)
config.path_addons_git.mkdir(parents=True)
# hassio tmp folder
if not config.path_tmp.is_dir():
_LOGGER.info("Create hassio temp folder %s", config.path_tmp)
_LOGGER.info("Create Hass.io temp folder %s", config.path_tmp)
config.path_tmp.mkdir(parents=True)
# hassio backup folder
if not config.path_backup.is_dir():
_LOGGER.info("Create hassio backup folder %s", config.path_backup)
_LOGGER.info("Create Hass.io backup folder %s", config.path_backup)
config.path_backup.mkdir()
# share folder
if not config.path_share.is_dir():
_LOGGER.info("Create hassio share folder %s", config.path_share)
_LOGGER.info("Create Hass.io share folder %s", config.path_share)
config.path_share.mkdir()
# apparmor folder
if not config.path_apparmor.is_dir():
_LOGGER.info("Create Hass.io Apparmor folder %s", config.path_apparmor)
config.path_apparmor.mkdir()
return config
@@ -108,7 +126,7 @@ def migrate_system_env(coresys):
try:
old_build.rmdir()
except OSError:
_LOGGER.warning("Can't cleanup old addons build dir.")
_LOGGER.warning("Can't cleanup old Add-on build directory")
def initialize_logging():
@@ -139,8 +157,7 @@ def initialize_logging():
def check_environment():
"""Check if all environment are exists."""
# check environment variables
for key in ('SUPERVISOR_SHARE', 'SUPERVISOR_NAME',
'HOMEASSISTANT_REPOSITORY'):
for key in (ENV_SHARE, ENV_NAME, ENV_REPO):
try:
os.environ[key]
except KeyError:
@@ -149,24 +166,24 @@ def check_environment():
# check docker socket
if not SOCKET_DOCKER.is_socket():
_LOGGER.fatal("Can't find docker socket!")
_LOGGER.fatal("Can't find Docker socket!")
return False
# check socat exec
if not shutil.which('socat'):
_LOGGER.fatal("Can't find socat program!")
_LOGGER.fatal("Can't find socat!")
return False
# check socat exec
if not shutil.which('gdbus'):
_LOGGER.fatal("Can't find gdbus program!")
_LOGGER.fatal("Can't find gdbus!")
return False
return True
def reg_signal(loop):
"""Register SIGTERM, SIGKILL to stop system."""
"""Register SIGTERM and SIGKILL to stop system."""
try:
loop.add_signal_handler(
signal.SIGTERM, lambda: loop.call_soon(loop.stop))

View File

@@ -1,9 +1,12 @@
"""Bootstrap HassIO."""
"""Bootstrap Hass.io."""
from datetime import datetime
import logging
import os
import re
from pathlib import Path, PurePath
import pytz
from .const import (
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
ATTR_LAST_BOOT, ATTR_WAIT_BOOT)
@@ -13,7 +16,7 @@ from .validate import SCHEMA_HASSIO_CONFIG
_LOGGER = logging.getLogger(__name__)
HOMEASSISTANT_CONFIG = PurePath("homeassistant")
HOMEASSISTANT_CONFIG = PurePath('homeassistant')
HASSIO_SSL = PurePath("ssl")
@@ -25,9 +28,12 @@ ADDONS_DATA = PurePath("addons/data")
BACKUP_DATA = PurePath("backup")
SHARE_DATA = PurePath("share")
TMP_DATA = PurePath("tmp")
APPARMOR_DATA = PurePath("apparmor")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
RE_TIMEZONE = re.compile(r"time_zone: (?P<timezone>[\w/\-+]+)")
class CoreConfig(JsonConfig):
"""Hold all core config data."""
@@ -39,7 +45,21 @@ class CoreConfig(JsonConfig):
@property
def timezone(self):
"""Return system timezone."""
return self._data[ATTR_TIMEZONE]
config_file = Path(self.path_homeassistant, 'configuration.yaml')
try:
assert config_file.exists()
configuration = config_file.read_text()
data = RE_TIMEZONE.search(configuration)
assert data
timezone = data.group('timezone')
pytz.timezone(timezone)
except (pytz.exceptions.UnknownTimeZoneError, OSError, AssertionError):
_LOGGER.debug("Can't parse Home Assistant timezone")
return self._data[ATTR_TIMEZONE]
return timezone
@timezone.setter
def timezone(self, value):
@@ -73,27 +93,27 @@ class CoreConfig(JsonConfig):
@property
def path_hassio(self):
"""Return hassio data path."""
"""Return Hass.io data path."""
return HASSIO_DATA
@property
def path_extern_hassio(self):
"""Return hassio data path extern for docker."""
"""Return Hass.io data path external for Docker."""
return PurePath(os.environ['SUPERVISOR_SHARE'])
@property
def path_extern_config(self):
"""Return config path extern for docker."""
def path_extern_homeassistant(self):
"""Return config path external for Docker."""
return str(PurePath(self.path_extern_hassio, HOMEASSISTANT_CONFIG))
@property
def path_config(self):
def path_homeassistant(self):
"""Return config path inside supervisor."""
return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG)
@property
def path_extern_ssl(self):
"""Return SSL path extern for docker."""
"""Return SSL path external for Docker."""
return str(PurePath(self.path_extern_hassio, HASSIO_SSL))
@property
@@ -103,42 +123,42 @@ class CoreConfig(JsonConfig):
@property
def path_addons_core(self):
"""Return git path for core addons."""
"""Return git path for core Add-ons."""
return Path(HASSIO_DATA, ADDONS_CORE)
@property
def path_addons_git(self):
"""Return path for git addons."""
"""Return path for Git Add-on."""
return Path(HASSIO_DATA, ADDONS_GIT)
@property
def path_addons_local(self):
"""Return path for customs addons."""
"""Return path for custom Add-ons."""
return Path(HASSIO_DATA, ADDONS_LOCAL)
@property
def path_extern_addons_local(self):
"""Return path for customs addons."""
"""Return path for custom Add-ons."""
return PurePath(self.path_extern_hassio, ADDONS_LOCAL)
@property
def path_addons_data(self):
"""Return root addon data folder."""
"""Return root Add-on data folder."""
return Path(HASSIO_DATA, ADDONS_DATA)
@property
def path_extern_addons_data(self):
"""Return root addon data folder extern for docker."""
"""Return root add-on data folder external for Docker."""
return PurePath(self.path_extern_hassio, ADDONS_DATA)
@property
def path_tmp(self):
"""Return hass.io temp folder."""
"""Return Hass.io temp folder."""
return Path(HASSIO_DATA, TMP_DATA)
@property
def path_extern_tmp(self):
"""Return hass.io temp folder for docker."""
"""Return Hass.io temp folder for Docker."""
return PurePath(self.path_extern_hassio, TMP_DATA)
@property
@@ -148,7 +168,7 @@ class CoreConfig(JsonConfig):
@property
def path_extern_backup(self):
"""Return root backup data folder extern for docker."""
"""Return root backup data folder external for Docker."""
return PurePath(self.path_extern_hassio, BACKUP_DATA)
@property
@@ -156,14 +176,19 @@ class CoreConfig(JsonConfig):
"""Return root share data folder."""
return Path(HASSIO_DATA, SHARE_DATA)
@property
def path_apparmor(self):
"""Return root Apparmor profile folder."""
return Path(HASSIO_DATA, APPARMOR_DATA)
@property
def path_extern_share(self):
"""Return root share data folder extern for docker."""
"""Return root share data folder external for Docker."""
return PurePath(self.path_extern_hassio, SHARE_DATA)
@property
def addons_repositories(self):
"""Return list of addons custom repositories."""
"""Return list of custom Add-on repositories."""
return self._data[ATTR_ADDONS_CUSTOM_LIST]
def add_addon_repository(self, repo):

View File

@@ -1,12 +1,18 @@
"""Const file for HassIO."""
"""Constants file for Hass.io."""
from pathlib import Path
from ipaddress import ip_network
HASSIO_VERSION = '106'
HASSIO_VERSION = '131'
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
URL_HASSIO_VERSION = \
"https://s3.amazonaws.com/hassio-version/{channel}.json"
"https://s3.amazonaws.com/hassio-version/{channel}.json"
URL_HASSIO_APPARMOR = \
"https://s3.amazonaws.com/hassio-version/apparmor.txt"
URL_HASSOS_OTA = (
"https://github.com/home-assistant/hassos/releases/download/"
"{version}/hassos_{board}-{version}.raucb")
HASSIO_DATA = Path("/data")
@@ -44,7 +50,7 @@ CONTENT_TYPE_JSON = 'application/json'
CONTENT_TYPE_TEXT = 'text/plain'
CONTENT_TYPE_TAR = 'application/tar'
HEADER_HA_ACCESS = 'x-ha-access'
HEADER_TOKEN = 'X-HASSIO-KEY'
HEADER_TOKEN = 'x-hassio-key'
ENV_TOKEN = 'HASSIO_TOKEN'
ENV_TIME = 'TZ'
@@ -69,6 +75,7 @@ ATTR_SOURCE = 'source'
ATTR_FEATURES = 'features'
ATTR_ADDONS = 'addons'
ATTR_VERSION = 'version'
ATTR_VERSION_LATEST = 'version_latest'
ATTR_AUTO_UART = 'auto_uart'
ATTR_LAST_BOOT = 'last_boot'
ATTR_LAST_VERSION = 'last_version'
@@ -107,6 +114,7 @@ ATTR_BUILD = 'build'
ATTR_DEVICES = 'devices'
ATTR_ENVIRONMENT = 'environment'
ATTR_HOST_NETWORK = 'host_network'
ATTR_HOST_PID = 'host_pid'
ATTR_HOST_IPC = 'host_ipc'
ATTR_HOST_DBUS = 'host_dbus'
ATTR_NETWORK = 'network'
@@ -162,8 +170,21 @@ ATTR_PROTECTED = 'protected'
ATTR_CRYPTO = 'crypto'
ATTR_BRANCH = 'branch'
ATTR_KERNEL = 'kernel'
ATTR_SECCOMP = 'seccomp'
ATTR_APPARMOR = 'apparmor'
ATTR_DEVICETREE = 'devicetree'
ATTR_CPE = 'cpe'
ATTR_BOARD = 'board'
ATTR_HASSOS = 'hassos'
ATTR_HASSOS_CLI = 'hassos_cli'
ATTR_VERSION_CLI = 'version_cli'
ATTR_VERSION_CLI_LATEST = 'version_cli_latest'
ATTR_REFRESH_TOKEN = 'refresh_token'
ATTR_ACCESS_TOKEN = 'access_token'
ATTR_DOCKER_API = 'docker_api'
ATTR_FULL_ACCESS = 'full_access'
ATTR_PROTECTED = 'protected'
ATTR_RATING = 'rating'
ATTR_HASSIO_ROLE = 'hassio_role'
SERVICE_MQTT = 'mqtt'
@@ -212,7 +233,22 @@ SECURITY_PROFILE = 'profile'
SECURITY_DEFAULT = 'default'
SECURITY_DISABLE = 'disable'
PRIVILEGED_NET_ADMIN = 'NET_ADMIN'
PRIVILEGED_SYS_ADMIN = 'SYS_ADMIN'
PRIVILEGED_SYS_RAWIO = 'SYS_RAWIO'
PRIVILEGED_IPC_LOCK = 'IPC_LOCK'
PRIVILEGED_SYS_TIME = 'SYS_TIME'
PRIVILEGED_SYS_NICE = 'SYS_NICE'
PRIVILEGED_SYS_RESOURCE = 'SYS_RESOURCE'
PRIVILEGED_SYS_PTRACE = 'SYS_PTRACE'
FEATURES_SHUTDOWN = 'shutdown'
FEATURES_REBOOT = 'reboot'
FEATURES_UPDATE = 'update'
FEATURES_HASSOS = 'hassos'
FEATURES_HOSTNAME = 'hostname'
FEATURES_SERVICES = 'services'
ROLE_DEFAULT = 'default'
ROLE_HOMEASSISTANT = 'homeassistant'
ROLE_MANAGER = 'manager'
ROLE_ADMIN = 'admin'

View File

@@ -1,30 +1,29 @@
"""Main file for HassIO."""
"""Main file for Hass.io."""
from contextlib import suppress
import asyncio
import logging
import async_timeout
from .coresys import CoreSysAttributes
from .const import (
STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE)
from .exceptions import HassioError
from .utils.dt import fetch_timezone
from .exceptions import HassioError, HomeAssistantError
_LOGGER = logging.getLogger(__name__)
class HassIO(CoreSysAttributes):
"""Main object of hassio."""
"""Main object of Hass.io."""
def __init__(self, coresys):
"""Initialize hassio object."""
"""Initialize Hass.io object."""
self.coresys = coresys
async def setup(self):
"""Setup HassIO orchestration."""
# update timezone
if self.sys_config.timezone == 'UTC':
self.sys_config.timezone = \
await fetch_timezone(self.sys_websession)
# Load Supervisor
await self.sys_supervisor.load()
# Load DBus
await self.sys_dbus.load()
@@ -32,8 +31,8 @@ class HassIO(CoreSysAttributes):
# Load Host
await self.sys_host.load()
# Load Supervisor
await self.sys_supervisor.load()
# Load HassOS
await self.sys_hassos.load()
# Load Home Assistant
await self.sys_homeassistant.load()
@@ -57,7 +56,7 @@ class HassIO(CoreSysAttributes):
self.sys_create_task(self.sys_dns.start())
async def start(self):
"""Start HassIO orchestration."""
"""Start Hass.io orchestration."""
# on release channel, try update itself
# on dev mode, only read new versions
if not self.sys_dev and self.sys_supervisor.need_update:
@@ -68,7 +67,6 @@ class HassIO(CoreSysAttributes):
# start api
await self.sys_api.start()
_LOGGER.info("Start API on %s", self.sys_docker.network.supervisor)
# start addon mark as initialize
await self.sys_addons.boot(STARTUP_INITIALIZE)
@@ -90,7 +88,8 @@ class HassIO(CoreSysAttributes):
# run HomeAssistant
if self.sys_homeassistant.boot:
await self.sys_homeassistant.start()
with suppress(HomeAssistantError):
await self.sys_homeassistant.start()
# start addon mark as application
await self.sys_addons.boot(STARTUP_APPLICATION)
@@ -115,12 +114,18 @@ class HassIO(CoreSysAttributes):
self.sys_scheduler.suspend = True
# process async stop tasks
await asyncio.wait([
self.sys_api.stop(),
self.sys_dns.stop(),
self.sys_websession.close(),
self.sys_websession_ssl.close()
])
try:
with async_timeout.timeout(10):
await asyncio.wait([
self.sys_api.stop(),
self.sys_dns.stop(),
self.sys_websession.close(),
self.sys_websession_ssl.close()
])
except asyncio.TimeoutError:
_LOGGER.warning("Force Shutdown!")
_LOGGER.info("Hass.io is down")
async def shutdown(self):
"""Shutdown all running containers in correct order."""

View File

@@ -1,5 +1,4 @@
"""Handle core shared data."""
import aiohttp
from .const import CHANNEL_DEV
@@ -17,6 +16,7 @@ class CoreSys:
"""Initialize coresys."""
# Static attributes
self.exit_code = 0
self.machine_id = None
# External objects
self._loop = loop
@@ -42,28 +42,34 @@ class CoreSys:
self._tasks = None
self._host = None
self._dbus = None
self._hassos = None
self._services = None
self._discovery = None
@property
def arch(self):
"""Return running arch of hass.io system."""
"""Return running arch of the Hass.io system."""
if self._supervisor:
return self._supervisor.arch
return None
@property
def machine(self):
"""Return running machine type of hass.io system."""
"""Return running machine type of the Hass.io system."""
if self._homeassistant:
return self._homeassistant.machine
return None
@property
def dev(self):
"""Return True if we run dev modus."""
"""Return True if we run dev mode."""
return self._updater.channel == CHANNEL_DEV
@property
def timezone(self):
"""Return timezone."""
return self._config.timezone
@property
def loop(self):
"""Return loop object."""
@@ -111,21 +117,21 @@ class CoreSys:
@core.setter
def core(self, value):
"""Set a HassIO object."""
"""Set a Hass.io object."""
if self._core:
raise RuntimeError("HassIO already set!")
raise RuntimeError("Hass.io already set!")
self._core = value
@property
def homeassistant(self):
"""Return HomeAssistant object."""
"""Return Home Assistant object."""
return self._homeassistant
@homeassistant.setter
def homeassistant(self, value):
"""Set a HomeAssistant object."""
if self._homeassistant:
raise RuntimeError("HomeAssistant already set!")
raise RuntimeError("Home Assistant already set!")
self._homeassistant = value
@property
@@ -248,6 +254,18 @@ class CoreSys:
raise RuntimeError("HostManager already set!")
self._host = value
@property
def hassos(self):
"""Return HassOS object."""
return self._hassos
@hassos.setter
def hassos(self, value):
"""Set a HassOS object."""
if self._hassos:
raise RuntimeError("HassOS already set!")
self._hassos = value
def run_in_executor(self, funct, *args):
"""Wrapper for executor pool."""
return self._loop.run_in_executor(None, funct, *args)
@@ -266,4 +284,4 @@ class CoreSysAttributes:
"""Mapping to coresys."""
if name.startswith("sys_") and hasattr(self.coresys, name[4:]):
return getattr(self.coresys, name[4:])
raise AttributeError()
raise AttributeError(f"Can't resolve {name} on {self}")

View File

@@ -1,30 +1,39 @@
"""DBus interface objects."""
"""D-Bus interface objects."""
from .systemd import Systemd
from .hostname import Hostname
from .rauc import Rauc
from ..coresys import CoreSysAttributes
class DBusManager(CoreSysAttributes):
"""DBus Interface handler."""
"""A DBus Interface handler."""
def __init__(self, coresys):
"""Initialize DBus Interface."""
"""Initialize D-Bus interface."""
self.coresys = coresys
self._systemd = Systemd()
self._hostname = Hostname()
self._rauc = Rauc()
@property
def systemd(self):
"""Return Systemd Interface."""
"""Return the systemd interface."""
return self._systemd
@property
def hostname(self):
"""Return hostname Interface."""
"""Return the hostname interface."""
return self._hostname
@property
def rauc(self):
"""Return the rauc interface."""
return self._rauc
async def load(self):
"""Connect interfaces to dbus."""
"""Connect interfaces to D-Bus."""
await self.systemd.connect()
await self.hostname.connect()
await self.rauc.connect()

View File

@@ -1,4 +1,4 @@
"""DBus interface for hostname."""
"""D-Bus interface for hostname."""
import logging
from .interface import DBusInterface
@@ -13,10 +13,10 @@ DBUS_OBJECT = '/org/freedesktop/hostname1'
class Hostname(DBusInterface):
"""Handle DBus interface for hostname/system."""
"""Handle D-Bus interface for hostname/system."""
async def connect(self):
"""Connect do bus."""
"""Connect to system's D-Bus."""
try:
self.dbus = await DBus.connect(DBUS_NAME, DBUS_OBJECT)
except DBusError:
@@ -28,7 +28,7 @@ class Hostname(DBusInterface):
Return a coroutine.
"""
return self.dbus.SetStaticHostname(hostname)
return self.dbus.SetStaticHostname(hostname, False)
@dbus_connected
def get_properties(self):

View File

@@ -1,8 +1,8 @@
"""Interface class for dbus wrappers."""
"""Interface class for D-Bus wrappers."""
class DBusInterface:
"""Handle DBus interface for hostname/system."""
"""Handle D-Bus interface for hostname/system."""
def __init__(self):
"""Initialize systemd."""
@@ -10,9 +10,9 @@ class DBusInterface:
@property
def is_connected(self):
"""Return True, if they is connected to dbus."""
"""Return True, if they is connected to D-Bus."""
return self.dbus is not None
async def connect(self):
"""Connect do bus."""
"""Connect to D-Bus."""
raise NotImplementedError()

55
hassio/dbus/rauc.py Normal file
View File

@@ -0,0 +1,55 @@
"""D-Bus interface for rauc."""
import logging
from .interface import DBusInterface
from .utils import dbus_connected
from ..exceptions import DBusError
from ..utils.gdbus import DBus
_LOGGER = logging.getLogger(__name__)
DBUS_NAME = 'de.pengutronix.rauc'
DBUS_OBJECT = '/'
class Rauc(DBusInterface):
"""Handle D-Bus interface for rauc."""
async def connect(self):
"""Connect to D-Bus."""
try:
self.dbus = await DBus.connect(DBUS_NAME, DBUS_OBJECT)
except DBusError:
_LOGGER.warning("Can't connect to rauc")
@dbus_connected
def install(self, raucb_file):
"""Install rauc bundle file.
Return a coroutine.
"""
return self.dbus.Installer.Install(raucb_file)
@dbus_connected
def get_slot_status(self):
"""Get slot status.
Return a coroutine.
"""
return self.dbus.Installer.GetSlotStatus()
@dbus_connected
def get_properties(self):
"""Return rauc informations.
Return a coroutine.
"""
return self.dbus.get_properties(f"{DBUS_NAME}.Installer")
@dbus_connected
def signal_completed(self):
"""Return a signal wrapper for completed signal.
Return a coroutine.
"""
return self.dbus.wait_signal(f"{DBUS_NAME}.Installer.Completed")

View File

@@ -1,4 +1,4 @@
"""Interface to Systemd over dbus."""
"""Interface to Systemd over D-Bus."""
import logging
from .interface import DBusInterface
@@ -37,3 +37,43 @@ class Systemd(DBusInterface):
Return a coroutine.
"""
return self.dbus.Manager.PowerOff()
@dbus_connected
def start_unit(self, unit, mode):
"""Start a systemd service unit.
Return a coroutine.
"""
return self.dbus.Manager.StartUnit(unit, mode)
@dbus_connected
def stop_unit(self, unit, mode):
"""Stop a systemd service unit.
Return a coroutine.
"""
return self.dbus.Manager.StopUnit(unit, mode)
@dbus_connected
def reload_unit(self, unit, mode):
"""Reload a systemd service unit.
Return a coroutine.
"""
return self.dbus.Manager.ReloadOrRestartUnit(unit, mode)
@dbus_connected
def restart_unit(self, unit, mode):
"""Restart a systemd service unit.
Return a coroutine.
"""
return self.dbus.Manager.RestartUnit(unit, mode)
@dbus_connected
def list_units(self):
"""Return a list of available systemd services.
Return a coroutine.
"""
return self.dbus.Manager.ListUnits()

View File

@@ -1,12 +1,12 @@
"""Utils for dbus."""
"""Utils for D-Bus."""
from ..exceptions import DBusNotConnectedError
def dbus_connected(method):
"""Wrapper for check if dbus is connected."""
"""Wrapper for check if D-Bus is connected."""
def wrap_dbus(api, *args, **kwargs):
"""Check if dbus is connected before call a method."""
"""Check if D-Bus is connected before call a method."""
if api.dbus is None:
raise DBusNotConnectedError()
return method(api, *args, **kwargs)

View File

@@ -1,6 +1,7 @@
"""Init file for HassIO addon docker object."""
import logging
import os
from pathlib import Path
import docker
import requests
@@ -66,6 +67,11 @@ class DockerAddon(DockerInterface):
return 'host'
return None
@property
def full_access(self):
"""Return True if full access is enabled."""
return not self.addon.protected and self.addon.with_full_access
@property
def hostname(self):
"""Return slug/id of addon."""
@@ -85,8 +91,8 @@ class DockerAddon(DockerInterface):
return {
**addon_env,
ENV_TIME: self.sys_config.timezone,
ENV_TOKEN: self.addon.uuid,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.addon.hassio_token,
}
@property
@@ -124,18 +130,17 @@ class DockerAddon(DockerInterface):
security = []
# AppArmor
if self.addon.apparmor == SECURITY_DISABLE:
apparmor = self.sys_host.apparmor.available
if not apparmor or self.addon.apparmor == SECURITY_DISABLE:
security.append("apparmor:unconfined")
elif self.addon.apparmor == SECURITY_PROFILE:
security.append(f"apparmor={self.addon.slug}")
# Seccomp
if self.addon.seccomp == SECURITY_DISABLE:
security.append("seccomp=unconfined")
elif self.addon.seccomp == SECURITY_PROFILE:
security.append(f"seccomp={self.addon.path_seccomp}")
# Disable Seccomp / We don't support it official and it
# make troubles on some kind of host systems.
security.append("seccomp=unconfined")
return security or None
return security
@property
def tmpfs(self):
@@ -160,6 +165,13 @@ class DockerAddon(DockerInterface):
return 'host'
return None
@property
def pid_mode(self):
"""Return PID mode for addon."""
if not self.addon.protected and self.addon.host_pid:
return 'host'
return None
@property
def volumes(self):
"""Generate volumes for mappings."""
@@ -173,7 +185,7 @@ class DockerAddon(DockerInterface):
# setup config mappings
if MAP_CONFIG in addon_mapping:
volumes.update({
str(self.sys_config.path_extern_config): {
str(self.sys_config.path_extern_homeassistant): {
'bind': "/config", 'mode': addon_mapping[MAP_CONFIG]
}})
@@ -202,13 +214,31 @@ class DockerAddon(DockerInterface):
}})
# Init other hardware mappings
# GPIO support
if self.addon.with_gpio:
for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"):
if not Path(gpio_path).exists():
continue
volumes.update({
gpio_path: {
'bind': gpio_path, 'mode': 'rw'
},
})
# DeviceTree support
if self.addon.with_devicetree:
volumes.update({
"/sys/class/gpio": {
'bind': "/sys/class/gpio", 'mode': 'rw'
"/sys/firmware/devicetree/base": {
'bind': "/device-tree", 'mode': 'ro'
},
"/sys/devices/platform/soc": {
'bind': "/sys/devices/platform/soc", 'mode': 'rw'
})
# Docker API support
if not self.addon.protected and self.addon.access_docker_api:
volumes.update({
"/var/run/docker.sock": {
'bind': "/var/run/docker.sock", 'mode': 'ro'
},
})
@@ -236,6 +266,11 @@ class DockerAddon(DockerInterface):
if self._is_running():
return True
# Security check
if not self.addon.protected:
_LOGGER.warning(
"%s run with disabled proteced mode!", self.addon.name)
# cleanup
self._stop()
@@ -245,9 +280,11 @@ class DockerAddon(DockerInterface):
hostname=self.hostname,
detach=True,
init=True,
privileged=self.full_access,
ipc_mode=self.ipc,
stdin_open=self.addon.with_stdin,
network_mode=self.network_mode,
pid_mode=self.pid_mode,
ports=self.ports,
extra_hosts=self.network_mapping,
devices=self.devices,

View File

@@ -0,0 +1,37 @@
"""HassOS Cli docker object."""
import logging
import docker
from .interface import DockerInterface
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
class DockerHassOSCli(DockerInterface, CoreSysAttributes):
"""Docker hassio wrapper for HassOS Cli."""
@property
def image(self):
"""Return name of HassOS cli image."""
return f"homeassistant/{self.sys_arch}-hassio-cli"
def _stop(self):
"""Don't need stop."""
return True
def _attach(self):
"""Attach to running docker container.
Need run inside executor.
"""
try:
image = self.sys_docker.images.get(self.image)
except docker.errors.DockerException:
_LOGGER.warning("Can't find a HassOS cli %s", self.image)
else:
self._meta = image.attrs
_LOGGER.info("Found HassOS cli %s with version %s",
self.image, self.version)

View File

@@ -61,11 +61,11 @@ class DockerHomeAssistant(DockerInterface):
network_mode='host',
environment={
'HASSIO': self.sys_docker.network.supervisor,
ENV_TIME: self.sys_config.timezone,
ENV_TOKEN: self.sys_homeassistant.uuid,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_homeassistant.hassio_token,
},
volumes={
str(self.sys_config.path_extern_config):
str(self.sys_config.path_extern_homeassistant):
{'bind': '/config', 'mode': 'rw'},
str(self.sys_config.path_extern_ssl):
{'bind': '/ssl', 'mode': 'ro'},
@@ -88,17 +88,22 @@ class DockerHomeAssistant(DockerInterface):
return self.sys_docker.run_command(
self.image,
command,
privileged=True,
init=True,
devices=self.devices,
detach=True,
stdout=True,
stderr=True,
environment={
ENV_TIME: self.sys_config.timezone,
ENV_TIME: self.sys_timezone,
},
volumes={
str(self.sys_config.path_extern_config):
{'bind': '/config', 'mode': 'ro'},
str(self.sys_config.path_extern_homeassistant):
{'bind': '/config', 'mode': 'rw'},
str(self.sys_config.path_extern_ssl):
{'bind': '/ssl', 'mode': 'ro'},
str(self.sys_config.path_extern_share):
{'bind': '/share', 'mode': 'ro'},
}
)

View File

@@ -32,26 +32,32 @@ class DockerInterface(CoreSysAttributes):
"""Return name of docker container."""
return None
@property
def meta_config(self):
"""Return meta data of config for container/image."""
if not self._meta:
return {}
return self._meta.get('Config', {})
@property
def meta_labels(self):
"""Return meta data of labels for container/image."""
return self.meta_config.get('Labels', {})
@property
def image(self):
"""Return name of docker image."""
if not self._meta:
return None
return self._meta['Config']['Image']
return self.meta_config.get('Image')
@property
def version(self):
"""Return version of docker image."""
if self._meta and LABEL_VERSION in self._meta['Config']['Labels']:
return self._meta['Config']['Labels'][LABEL_VERSION]
return None
return self.meta_labels.get(LABEL_VERSION)
@property
def arch(self):
"""Return arch of docker image."""
if self._meta and LABEL_ARCH in self._meta['Config']['Labels']:
return self._meta['Config']['Labels'][LABEL_ARCH]
return None
return self.meta_labels.get(LABEL_ARCH)
@property
def in_progress(self):

View File

@@ -11,7 +11,7 @@ _LOGGER = logging.getLogger(__name__)
class DockerSupervisor(DockerInterface, CoreSysAttributes):
"""Docker hassio wrapper for HomeAssistant."""
"""Docker hassio wrapper for Supervisor."""
@property
def name(self):

View File

@@ -6,13 +6,54 @@ class HassioError(Exception):
pass
class HassioInternalError(HassioError):
"""Internal Hass.io error they can't handle."""
class HassioNotSupportedError(HassioError):
"""Function is not supported."""
pass
class HassioNotSupportedError(HassioError):
"""Function is not supported."""
# HomeAssistant
class HomeAssistantError(HassioError):
"""Home Assistant exception."""
pass
class HomeAssistantUpdateError(HomeAssistantError):
"""Error on update of a Home Assistant."""
pass
class HomeAssistantAPIError(HomeAssistantError):
"""Home Assistant API exception."""
pass
class HomeAssistantAuthError(HomeAssistantAPIError):
"""Home Assistant Auth API exception."""
pass
# HassOS
class HassOSError(HassioError):
"""HassOS exception."""
pass
class HassOSUpdateError(HassOSError):
"""Error on update of a HassOS."""
pass
class HassOSNotSupportedError(HassioNotSupportedError):
"""Function not supported by HassOS."""
pass
# Updater
class HassioUpdaterError(HassioError):
"""Error on Updater."""
pass
@@ -28,6 +69,28 @@ class HostNotSupportedError(HassioNotSupportedError):
pass
class HostServiceError(HostError):
"""Host service functions fails."""
pass
class HostAppArmorError(HostError):
"""Host apparmor functions fails."""
pass
# API
class APIError(HassioError):
"""API errors."""
pass
class APINotSupportedError(HassioNotSupportedError):
"""API not supported error."""
pass
# utils/gdbus
class DBusError(HassioError):
@@ -47,3 +110,20 @@ class DBusFatalError(DBusError):
class DBusParseError(DBusError):
"""DBus parse error."""
pass
# util/apparmor
class AppArmorError(HostAppArmorError):
"""General AppArmor error."""
pass
class AppArmorFileError(AppArmorError):
"""AppArmor profile file error."""
pass
class AppArmorInvalidError(AppArmorError):
"""AppArmor profile validate error."""
pass

186
hassio/hassos.py Normal file
View File

@@ -0,0 +1,186 @@
"""HassOS support on supervisor."""
import asyncio
import logging
from pathlib import Path
import aiohttp
from cpe import CPE
from .coresys import CoreSysAttributes
from .const import URL_HASSOS_OTA
from .docker.hassos_cli import DockerHassOSCli
from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError
_LOGGER = logging.getLogger(__name__)
class HassOS(CoreSysAttributes):
"""HassOS interface inside HassIO."""
def __init__(self, coresys):
"""Initialize HassOS handler."""
self.coresys = coresys
self.instance = DockerHassOSCli(coresys)
self._available = False
self._version = None
self._board = None
@property
def available(self):
"""Return True, if HassOS on host."""
return self._available
@property
def version(self):
"""Return version of HassOS."""
return self._version
@property
def version_cli(self):
"""Return version of HassOS cli."""
return self.instance.version
@property
def version_latest(self):
"""Return version of HassOS."""
return self.sys_updater.version_hassos
@property
def version_cli_latest(self):
"""Return version of HassOS."""
return self.sys_updater.version_hassos_cli
@property
def need_update(self):
"""Return true if a HassOS update is available."""
return self.version != self.version_latest
@property
def need_cli_update(self):
"""Return true if a HassOS cli update is available."""
return self.version_cli != self.version_cli_latest
@property
def board(self):
"""Return board name."""
return self._board
def _check_host(self):
"""Check if HassOS is availabe."""
if not self.available:
_LOGGER.error("No HassOS available")
raise HassOSNotSupportedError()
async def _download_raucb(self, version):
"""Download rauc bundle (OTA) from github."""
url = URL_HASSOS_OTA.format(version=version, board=self.board)
raucb = Path(self.sys_config.path_tmp, f"hassos-{version}.raucb")
try:
_LOGGER.info("Fetch OTA update from %s", url)
async with self.sys_websession.get(url) as request:
if request.status != 200:
raise HassOSUpdateError()
# Download RAUCB file
with raucb.open('wb') as ota_file:
while True:
chunk = await request.content.read(1048576)
if not chunk:
break
ota_file.write(chunk)
_LOGGER.info("OTA update is downloaded on %s", raucb)
return raucb
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch versions from %s: %s", url, err)
except OSError as err:
_LOGGER.error("Can't write OTA file: %s", err)
raise HassOSUpdateError()
async def load(self):
"""Load HassOS data."""
try:
# Check needed host functions
assert self.sys_dbus.rauc.is_connected
assert self.sys_dbus.systemd.is_connected
assert self.sys_dbus.hostname.is_connected
assert self.sys_host.info.cpe is not None
cpe = CPE(self.sys_host.info.cpe)
assert cpe.get_product()[0] == 'hassos'
except (AssertionError, NotImplementedError):
_LOGGER.debug("Found no HassOS")
return
# Store meta data
self._available = True
self._version = cpe.get_version()[0]
self._board = cpe.get_target_hardware()[0]
_LOGGER.info("Detect HassOS %s on host system", self.version)
await self.instance.attach()
def config_sync(self):
"""Trigger a host config reload from usb.
Return a coroutine.
"""
self._check_host()
_LOGGER.info("Syncing configuration from USB with HassOS.")
return self.sys_host.services.restart('hassos-config.service')
async def update(self, version=None):
"""Update HassOS system."""
version = version or self.version_latest
# Check installed version
self._check_host()
if version == self.version:
_LOGGER.warning("Version %s is already installed", version)
raise HassOSUpdateError()
# Fetch files from internet
int_ota = await self._download_raucb(version)
ext_ota = Path(self.sys_config.path_extern_tmp, int_ota.name)
try:
await self.sys_dbus.rauc.install(ext_ota)
completed = await self.sys_dbus.rauc.signal_completed()
except DBusError:
_LOGGER.error("Rauc communication error")
raise HassOSUpdateError() from None
finally:
int_ota.unlink()
# Update success
if 0 in completed:
_LOGGER.info("Install HassOS %s success", version)
self.sys_create_task(self.sys_host.control.reboot())
return
# Update fails
rauc_status = await self.sys_dbus.get_properties()
_LOGGER.error(
"HassOS update fails with: %s", rauc_status.get('LastError'))
raise HassOSUpdateError()
async def update_cli(self, version=None):
"""Update local HassOS cli."""
version = version or self.version_cli_latest
if version == self.version_cli:
_LOGGER.warning("Version %s is already installed for CLI", version)
raise HassOSUpdateError()
if await self.instance.update(version):
return
_LOGGER.error("HassOS CLI update fails")
raise HassOSUpdateError()

View File

@@ -1,22 +1,29 @@
"""HomeAssistant control object."""
"""Home Assistant control object."""
import asyncio
from contextlib import asynccontextmanager, suppress
from datetime import datetime, timedelta
import logging
import os
import re
from pathlib import Path
import socket
import time
import aiohttp
from aiohttp.hdrs import CONTENT_TYPE
from aiohttp import hdrs
import attr
from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, HEADER_HA_ACCESS, CONTENT_TYPE_JSON)
ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, ATTR_ACCESS_TOKEN,
HEADER_HA_ACCESS)
from .coresys import CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant
from .utils import convert_to_ascii, process_lock
from .exceptions import (
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
HomeAssistantAuthError)
from .utils import convert_to_ascii, process_lock, create_token
from .utils.json import JsonConfig
from .validate import SCHEMA_HASS_CONFIG
@@ -25,109 +32,118 @@ _LOGGER = logging.getLogger(__name__)
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
# pylint: disable=invalid-name
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'])
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'], frozen=True)
class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Hass core object for handle it."""
"""Home Assistant core object for handle it."""
def __init__(self, coresys):
"""Initialize hass object."""
"""Initialize Home Assistant object."""
super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG)
self.coresys = coresys
self.instance = DockerHomeAssistant(coresys)
self.lock = asyncio.Lock(loop=coresys.loop)
self._error_state = False
# We don't persist access tokens. Instead we fetch new ones when needed
self.access_token = None
self._access_token_expires = None
async def load(self):
"""Prepare HomeAssistant object."""
"""Prepare Home Assistant object."""
if await self.instance.attach():
return
_LOGGER.info("No HomeAssistant docker %s found.", self.image)
_LOGGER.info("No Home Assistant Docker image %s found.", self.image)
await self.install_landingpage()
@property
def machine(self):
"""Return System Machines."""
"""Return the system machines."""
return self.instance.machine
@property
def error_state(self):
"""Return True if system is in error."""
return self._error_state
@property
def api_ip(self):
"""Return IP of HomeAssistant instance."""
"""Return IP of Home Assistant instance."""
return self.sys_docker.network.gateway
@property
def api_port(self):
"""Return network port to home-assistant instance."""
"""Return network port to Home Assistant instance."""
return self._data[ATTR_PORT]
@api_port.setter
def api_port(self, value):
"""Set network port for home-assistant instance."""
"""Set network port for Home Assistant instance."""
self._data[ATTR_PORT] = value
@property
def api_password(self):
"""Return password for home-assistant instance."""
"""Return password for Home Assistant instance."""
return self._data.get(ATTR_PASSWORD)
@api_password.setter
def api_password(self, value):
"""Set password for home-assistant instance."""
"""Set password for Home Assistant instance."""
self._data[ATTR_PASSWORD] = value
@property
def api_ssl(self):
"""Return if we need ssl to home-assistant instance."""
"""Return if we need ssl to Home Assistant instance."""
return self._data[ATTR_SSL]
@api_ssl.setter
def api_ssl(self, value):
"""Set SSL for home-assistant instance."""
"""Set SSL for Home Assistant instance."""
self._data[ATTR_SSL] = value
@property
def api_url(self):
"""Return API url to Home-Assistant."""
"""Return API url to Home Assistant."""
return "{}://{}:{}".format(
'https' if self.api_ssl else 'http', self.api_ip, self.api_port
)
@property
def watchdog(self):
"""Return True if the watchdog should protect Home-Assistant."""
"""Return True if the watchdog should protect Home Assistant."""
return self._data[ATTR_WATCHDOG]
@watchdog.setter
def watchdog(self, value):
"""Return True if the watchdog should protect Home-Assistant."""
"""Return True if the watchdog should protect Home Assistant."""
self._data[ATTR_WATCHDOG] = value
@property
def wait_boot(self):
"""Return time to wait for Home-Assistant startup."""
"""Return time to wait for Home Assistant startup."""
return self._data[ATTR_WAIT_BOOT]
@wait_boot.setter
def wait_boot(self, value):
"""Set time to wait for Home-Assistant startup."""
"""Set time to wait for Home Assistant startup."""
self._data[ATTR_WAIT_BOOT] = value
@property
def version(self):
"""Return version of running homeassistant."""
"""Return version of running Home Assistant."""
return self.instance.version
@property
def last_version(self):
"""Return last available version of homeassistant."""
"""Return last available version of Home Assistant."""
if self.is_custom_image:
return self._data.get(ATTR_LAST_VERSION)
return self.sys_updater.version_homeassistant
@last_version.setter
def last_version(self, value):
"""Set last available version of homeassistant."""
"""Set last available version of Home Assistant."""
if value:
self._data[ATTR_LAST_VERSION] = value
else:
@@ -135,14 +151,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
@property
def image(self):
"""Return image name of hass containter."""
"""Return image name of the Home Assistant container."""
if self._data.get(ATTR_IMAGE):
return self._data[ATTR_IMAGE]
return os.environ['HOMEASSISTANT_REPOSITORY']
@image.setter
def image(self, value):
"""Set image name of hass containter."""
"""Set image name of Home Assistant container."""
if value:
self._data[ATTR_IMAGE] = value
else:
@@ -156,22 +172,37 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
@property
def boot(self):
"""Return True if home-assistant boot is enabled."""
"""Return True if Home Assistant boot is enabled."""
return self._data[ATTR_BOOT]
@boot.setter
def boot(self, value):
"""Set home-assistant boot options."""
"""Set Home Assistant boot options."""
self._data[ATTR_BOOT] = value
@property
def uuid(self):
"""Return a UUID of this HomeAssistant."""
"""Return a UUID of this Home Assistant instance."""
return self._data[ATTR_UUID]
@property
def hassio_token(self):
"""Return a access token for the Hass.io API."""
return self._data.get(ATTR_ACCESS_TOKEN)
@property
def refresh_token(self):
"""Return the refresh token to authenticate with Home Assistant."""
return self._data.get(ATTR_REFRESH_TOKEN)
@refresh_token.setter
def refresh_token(self, value):
"""Set Home Assistant refresh_token."""
self._data[ATTR_REFRESH_TOKEN] = value
@process_lock
async def install_landingpage(self):
"""Install a landingpage."""
"""Install a landing page."""
_LOGGER.info("Setup HomeAssistant landingpage")
while True:
if await self.instance.install('landingpage'):
@@ -180,12 +211,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
await asyncio.sleep(60)
# Run landingpage after installation
await self._start()
_LOGGER.info("Start landing page")
try:
await self._start()
except HomeAssistantError:
_LOGGER.warning("Can't start landing page")
@process_lock
async def install(self):
"""Install a landingpage."""
_LOGGER.info("Setup HomeAssistant")
"""Install a landing page."""
_LOGGER.info("Setup Home Assistant")
while True:
# read homeassistant tag and install it
if not self.last_version:
@@ -194,41 +229,74 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
tag = self.last_version
if tag and await self.instance.install(tag):
break
_LOGGER.warning("Error on install HomeAssistant. Retry in 60sec")
_LOGGER.warning("Error on install Home Assistant. Retry in 60sec")
await asyncio.sleep(60)
# finishing
_LOGGER.info("HomeAssistant docker now installed")
if self.boot:
_LOGGER.info("Home Assistant docker now installed")
try:
if not self.boot:
return
_LOGGER.info("Start Home Assistant")
await self._start()
await self.instance.cleanup()
except HomeAssistantError:
_LOGGER.error("Can't start Home Assistant!")
finally:
await self.instance.cleanup()
@process_lock
async def update(self, version=None):
"""Update HomeAssistant version."""
version = version or self.last_version
rollback = self.version if not self.error_state else None
running = await self.instance.is_running()
exists = await self.instance.exists()
if exists and version == self.instance.version:
_LOGGER.info("Version %s is already installed", version)
return False
_LOGGER.warning("Version %s is already installed", version)
return HomeAssistantUpdateError()
try:
return await self.instance.update(version)
finally:
if running:
await self._start()
# process a update
async def _update(to_version):
"""Run Home Assistant update."""
try:
_LOGGER.info("Update Home Assistant to version %s", to_version)
if not await self.instance.update(to_version):
raise HomeAssistantUpdateError()
finally:
if running:
await self._start()
_LOGGER.info("Successful run Home Assistant %s", to_version)
# Update Home Assistant
with suppress(HomeAssistantError):
await _update(version)
return
# Update going wrong, revert it
if self.error_state and rollback:
_LOGGER.fatal("HomeAssistant update fails -> rollback!")
await _update(rollback)
else:
raise HomeAssistantUpdateError()
async def _start(self):
"""Start HomeAssistant docker & wait."""
"""Start Home Assistant Docker & wait."""
if await self.instance.is_running():
_LOGGER.warning("Home Assistant is already running!")
return
# Create new API token
self._data[ATTR_ACCESS_TOKEN] = create_token()
self.save_data()
if not await self.instance.run():
return False
return await self._block_till_run()
raise HomeAssistantError()
await self._block_till_run()
@process_lock
def start(self):
"""Run HomeAssistant docker.
"""Run Home Assistant docker.
Return a coroutine.
"""
@@ -236,7 +304,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
@process_lock
def stop(self):
"""Stop HomeAssistant docker.
"""Stop Home Assistant Docker.
Return a coroutine.
"""
@@ -244,9 +312,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
@process_lock
async def restart(self):
"""Restart HomeAssistant docker."""
"""Restart Home Assistant Docker."""
await self.instance.stop()
return await self._start()
await self._start()
def logs(self):
"""Get HomeAssistant docker logs.
@@ -256,21 +324,21 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return self.instance.logs()
def stats(self):
"""Return stats of HomeAssistant.
"""Return stats of Home Assistant.
Return a coroutine.
"""
return self.instance.stats()
def is_running(self):
"""Return True if docker container is running.
"""Return True if Docker container is running.
Return a coroutine.
"""
return self.instance.is_running()
def is_initialize(self):
"""Return True if a docker container is exists.
"""Return True if a Docker container is exists.
Return a coroutine.
"""
@@ -282,14 +350,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return self.instance.in_progress or self.lock.locked()
async def check_config(self):
"""Run homeassistant config check."""
"""Run Home Assistant config check."""
result = await self.instance.execute_command(
"python3 -m homeassistant -c /config --script check_config"
)
# if not valid
if result.exit_code is None:
return ConfigResult(False, "")
raise HomeAssistantError()
# parse output
log = convert_to_ascii(result.output)
@@ -297,55 +365,99 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return ConfigResult(False, log)
return ConfigResult(True, log)
async def check_api_state(self):
"""Check if Home-Assistant up and running."""
url = f"{self.api_url}/api/"
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
async def ensure_access_token(self):
"""Ensures there is an access token."""
if (self.access_token is not None and
self._access_token_expires > datetime.utcnow()):
return
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
async with self.sys_websession_ssl.post(
f"{self.api_url}/auth/token",
timeout=30,
data={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token
}
) as resp:
if resp.status != 200:
_LOGGER.error("Can't update Home Assistant access token!")
raise HomeAssistantAuthError()
_LOGGER.info("Updated Home Assistant API token")
tokens = await resp.json()
self.access_token = tokens['access_token']
self._access_token_expires = \
datetime.utcnow() + timedelta(seconds=tokens['expires_in'])
@asynccontextmanager
async def make_request(self, method, path, json=None, content_type=None,
data=None, timeout=30):
"""Async context manager to make a request with right auth."""
url = f"{self.api_url}/{path}"
headers = {}
# Passthrough content type
if content_type is not None:
headers[hdrs.CONTENT_TYPE] = content_type
# Set old API Password
if self.api_password:
header.update({HEADER_HA_ACCESS: self.api_password})
headers[HEADER_HA_ACCESS] = self.api_password
try:
# pylint: disable=bad-continuation
async with self.sys_websession_ssl.get(
url, headers=header, timeout=30) as request:
status = request.status
for _ in (1, 2):
# Prepare Access token
if self.refresh_token:
await self.ensure_access_token()
headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}'
except (asyncio.TimeoutError, aiohttp.ClientError):
return False
try:
async with getattr(self.sys_websession_ssl, method)(
url, data=data, timeout=timeout, json=json,
headers=headers
) as resp:
# Access token expired
if resp.status == 401 and self.refresh_token:
self.access_token = None
continue
yield resp
return
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Error on call %s: %s", url, err)
break
if status not in (200, 201):
_LOGGER.warning("Home-Assistant API config missmatch")
return True
raise HomeAssistantAPIError()
async def check_api_state(self):
"""Return True if Home Assistant up and running."""
with suppress(HomeAssistantAPIError):
async with self.make_request('get', 'api/') as resp:
if resp.status in (200, 201):
return True
err = resp.status
_LOGGER.warning("Home Assistant API config mismatch: %d", err)
return False
async def send_event(self, event_type, event_data=None):
"""Send event to Home-Assistant."""
url = f"{self.api_url}/api/events/{event_type}"
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
with suppress(HomeAssistantAPIError):
async with self.make_request(
'get', f'api/events/{event_type}'
) as resp:
if resp.status in (200, 201):
return
err = resp.status
if self.api_password:
header.update({HEADER_HA_ACCESS: self.api_password})
try:
# pylint: disable=bad-continuation
async with self.sys_websession_ssl.post(
url, headers=header, timeout=30,
json=event_data) as request:
status = request.status
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.warning(
"Home-Assistant event %s fails: %s", event_type, err)
return False
if status not in (200, 201):
_LOGGER.warning("Home-Assistant event %s fails", event_type)
return False
return True
_LOGGER.warning("Home Assistant event %s fails: %s", event_type, err)
return HomeAssistantError()
async def _block_till_run(self):
"""Block until Home-Assistant is booting up or startup timeout."""
start_time = time.monotonic()
migration_progress = False
migration_file = Path(
self.sys_config.path_homeassistant, '.migration_progress')
def check_port():
"""Check if port is mapped."""
@@ -354,17 +466,47 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
result = sock.connect_ex((str(self.api_ip), self.api_port))
sock.close()
# Check if the port is available
if result == 0:
return True
return False
except OSError:
pass
return False
while time.monotonic() - start_time < self.wait_boot:
if await self.sys_run_in_executor(check_port):
_LOGGER.info("Detect a running Home-Assistant instance")
return True
while True:
await asyncio.sleep(10)
_LOGGER.warning("Don't wait anymore of Home-Assistant startup!")
return False
# 1
# Check if Container is is_running
if not await self.instance.is_running():
_LOGGER.error("Home Assistant has crashed!")
break
# 2
# Check if API response
if await self.sys_run_in_executor(check_port):
_LOGGER.info("Detect a running Home Assistant instance")
self._error_state = False
return
# 3
# Running DB Migration
if migration_file.exists():
if not migration_progress:
migration_progress = True
_LOGGER.info("Home Assistant record migration in progress")
continue
elif migration_progress:
migration_progress = False # Reset start time
start_time = time.monotonic()
_LOGGER.info("Home Assistant record migration done")
# 4
# Timeout
if time.monotonic() - start_time > self.wait_boot:
_LOGGER.warning(
"Don't wait anymore of Home Assistant startup!")
break
self._error_state = True
raise HomeAssistantError()

View File

@@ -1,10 +1,19 @@
"""Host function like audio/dbus/systemd."""
from contextlib import suppress
import logging
from .alsa import AlsaAudio
from .apparmor import AppArmorControl
from .control import SystemControl
from .info import InfoCenter
from ..const import FEATURES_REBOOT, FEATURES_SHUTDOWN, FEATURES_HOSTNAME
from .services import ServiceManager
from ..const import (
FEATURES_REBOOT, FEATURES_SHUTDOWN, FEATURES_HOSTNAME, FEATURES_SERVICES,
FEATURES_HASSOS)
from ..coresys import CoreSysAttributes
from ..exceptions import HassioError
_LOGGER = logging.getLogger(__name__)
class HostManager(CoreSysAttributes):
@@ -14,14 +23,21 @@ class HostManager(CoreSysAttributes):
"""Initialize Host manager."""
self.coresys = coresys
self._alsa = AlsaAudio(coresys)
self._apparmor = AppArmorControl(coresys)
self._control = SystemControl(coresys)
self._info = InfoCenter(coresys)
self._services = ServiceManager(coresys)
@property
def alsa(self):
"""Return host ALSA handler."""
return self._alsa
@property
def apparmor(self):
"""Return host apparmor handler."""
return self._apparmor
@property
def control(self):
"""Return host control handler."""
@@ -32,6 +48,11 @@ class HostManager(CoreSysAttributes):
"""Return host info handler."""
return self._info
@property
def services(self):
"""Return host services handler."""
return self._services
@property
def supperted_features(self):
"""Return a list of supported host features."""
@@ -41,18 +62,32 @@ class HostManager(CoreSysAttributes):
features.extend([
FEATURES_REBOOT,
FEATURES_SHUTDOWN,
FEATURES_SERVICES,
])
if self.sys_dbus.hostname.is_connected:
features.append(FEATURES_HOSTNAME)
if self.sys_hassos.available:
features.append(FEATURES_HASSOS)
return features
async def load(self):
"""Load host functions."""
async def reload(self):
"""Reload host functions."""
if self.sys_dbus.hostname.is_connected:
await self.info.update()
def reload(self):
"""Reload host information."""
return self.load()
if self.sys_dbus.systemd.is_connected:
await self.services.update()
async def load(self):
"""Load host information."""
with suppress(HassioError):
await self.reload()
# Load profile data
try:
await self.apparmor.load()
except HassioError as err:
_LOGGER.waring("Load host AppArmor on start fails: %s", err)

View File

@@ -81,7 +81,7 @@ class AlsaAudio(CoreSysAttributes):
@staticmethod
def _audio_database():
"""Read local json audio data into dict."""
json_file = Path(__file__).parent.joinpath('audiodb.json')
json_file = Path(__file__).parent.joinpath("data/audiodb.json")
try:
# pylint: disable=no-member
@@ -121,7 +121,7 @@ class AlsaAudio(CoreSysAttributes):
alsa_output = alsa_output or self.default.output
# Read Template
asound_file = Path(__file__).parent.joinpath('asound.tmpl')
asound_file = Path(__file__).parent.joinpath("data/asound.tmpl")
try:
# pylint: disable=no-member
with asound_file.open('r') as asound:

121
hassio/host/apparmor.py Normal file
View File

@@ -0,0 +1,121 @@
"""AppArmor control for host."""
import logging
import shutil
from pathlib import Path
from ..coresys import CoreSysAttributes
from ..exceptions import DBusError, HostAppArmorError
from ..utils.apparmor import validate_profile
_LOGGER = logging.getLogger(__name__)
SYSTEMD_SERVICES = {'hassos-apparmor.service', 'hassio-apparmor.service'}
class AppArmorControl(CoreSysAttributes):
"""Handle host apparmor controls."""
def __init__(self, coresys):
"""Initialize host power handling."""
self.coresys = coresys
self._profiles = set()
self._service = None
@property
def available(self):
"""Return True if AppArmor is availabe on host."""
return self._service is not None
def exists(self, profile):
"""Return True if a profile exists."""
return profile in self._profiles
async def _reload_service(self):
"""Reload internal service."""
try:
await self.sys_host.services.reload(self._service)
except DBusError as err:
_LOGGER.error("Can't reload %s: %s", self._service, err)
def _get_profile(self, profile_name):
"""Get a profile from AppArmor store."""
if profile_name not in self._profiles:
_LOGGER.error("Can't find %s for removing", profile_name)
raise HostAppArmorError()
return Path(self.sys_config.path_apparmor, profile_name)
async def load(self):
"""Load available profiles."""
for content in self.sys_config.path_apparmor.iterdir():
if not content.is_file():
continue
self._profiles.add(content.name)
# Is connected with systemd?
_LOGGER.info("Load AppArmor Profiles: %s", self._profiles)
for service in SYSTEMD_SERVICES:
if not self.sys_host.services.exists(service):
continue
self._service = service
# Load profiles
if self.available:
await self._reload_service()
else:
_LOGGER.info("AppArmor is not enabled on Host")
async def load_profile(self, profile_name, profile_file):
"""Load/Update a new/exists profile into AppArmor."""
if not validate_profile(profile_name, profile_file):
_LOGGER.error("profile is not valid with name %s", profile_name)
raise HostAppArmorError()
# Copy to AppArmor folder
dest_profile = Path(self.sys_config.path_apparmor, profile_name)
try:
shutil.copy(profile_file, dest_profile)
except OSError as err:
_LOGGER.error("Can't copy %s: %s", profile_file, err)
raise HostAppArmorError() from None
# Load profiles
_LOGGER.info("Add or Update AppArmor profile: %s", profile_name)
self._profiles.add(profile_name)
if self.available:
await self._reload_service()
async def remove_profile(self, profile_name):
"""Remove a AppArmor profile."""
profile_file = self._get_profile(profile_name)
# Only remove file
if not self.available:
try:
profile_file.unlink()
except OSError as err:
_LOGGER.error("Can't remove profile: %s", err)
raise HostAppArmorError()
return
# Marks als remove and start host process
remove_profile = Path(
self.sys_config.path_apparmor, 'remove', profile_name)
try:
profile_file.rename(remove_profile)
except OSError as err:
_LOGGER.error("Can't mark profile as remove: %s", err)
raise HostAppArmorError()
_LOGGER.info("Remove AppArmor profile: %s", profile_name)
self._profiles.remove(profile_name)
await self._reload_service()
def backup_profile(self, profile_name, backup_file):
"""Backup A profile into a new file."""
profile_file = self._get_profile(profile_name)
try:
shutil.copy(profile_file, backup_file)
except OSError as err:
_LOGGER.error("Can't backup profile %s: %s", profile_name, err)
raise HostAppArmorError()

View File

@@ -6,6 +6,9 @@ from ..exceptions import HostNotSupportedError
_LOGGER = logging.getLogger(__name__)
MANAGER = 'manager'
HOSTNAME = 'hostname'
class SystemControl(CoreSysAttributes):
"""Handle host power controls."""
@@ -14,15 +17,19 @@ class SystemControl(CoreSysAttributes):
"""Initialize host power handling."""
self.coresys = coresys
def _check_systemd(self):
def _check_dbus(self, flag):
"""Check if systemd is connect or raise error."""
if not self.sys_dbus.systemd.is_connected:
_LOGGER.error("No systemd dbus connection available")
raise HostNotSupportedError()
if flag == MANAGER and self.sys_dbus.systemd.is_connected:
return
if flag == HOSTNAME and self.sys_dbus.hostname.is_connected:
return
_LOGGER.error("No %s dbus connection available", flag)
raise HostNotSupportedError()
async def reboot(self):
"""Reboot host system."""
self._check_systemd()
self._check_dbus(MANAGER)
_LOGGER.info("Initialize host reboot over systemd")
try:
@@ -32,7 +39,7 @@ class SystemControl(CoreSysAttributes):
async def shutdown(self):
"""Shutdown host system."""
self._check_systemd()
self._check_dbus(MANAGER)
_LOGGER.info("Initialize host power off over systemd")
try:
@@ -42,9 +49,7 @@ class SystemControl(CoreSysAttributes):
async def set_hostname(self, hostname):
"""Set local a new Hostname."""
if not self.sys_dbus.systemd.is_connected:
_LOGGER.error("No hostname dbus connection available")
raise HostNotSupportedError()
self._check_dbus(HOSTNAME)
_LOGGER.info("Set Hostname %s", hostname)
await self.sys_dbus.hostname.set_static_hostname(hostname)

View File

@@ -1,4 +1,4 @@
"""Power control for host."""
"""Info control for host."""
import logging
from ..coresys import CoreSysAttributes
@@ -47,7 +47,7 @@ class InfoCenter(CoreSysAttributes):
async def update(self):
"""Update properties over dbus."""
if not self.sys_dbus.systemd.is_connected:
if not self.sys_dbus.hostname.is_connected:
_LOGGER.error("No hostname dbus connection available")
raise HostNotSupportedError()

99
hassio/host/services.py Normal file
View File

@@ -0,0 +1,99 @@
"""Service control for host."""
import logging
import attr
from ..coresys import CoreSysAttributes
from ..exceptions import HassioError, HostNotSupportedError, HostServiceError
_LOGGER = logging.getLogger(__name__)
MOD_REPLACE = 'replace'
class ServiceManager(CoreSysAttributes):
"""Handle local service information controls."""
def __init__(self, coresys):
"""Initialize system center handling."""
self.coresys = coresys
self._services = set()
def __iter__(self):
"""Iterator trought services."""
return iter(self._services)
def _check_dbus(self, unit=None):
"""Check available dbus connection."""
if not self.sys_dbus.systemd.is_connected:
_LOGGER.error("No systemd dbus connection available")
raise HostNotSupportedError()
if unit and not self.exists(unit):
_LOGGER.error("Unit '%s' not found", unit)
raise HostServiceError()
def start(self, unit):
"""Start a service on host."""
self._check_dbus(unit)
_LOGGER.info("Start local service %s", unit)
return self.sys_dbus.systemd.start_unit(unit, MOD_REPLACE)
def stop(self, unit):
"""Stop a service on host."""
self._check_dbus(unit)
_LOGGER.info("Stop local service %s", unit)
return self.sys_dbus.systemd.stop_unit(unit, MOD_REPLACE)
def reload(self, unit):
"""Reload a service on host."""
self._check_dbus(unit)
_LOGGER.info("Reload local service %s", unit)
return self.sys_dbus.systemd.reload_unit(unit, MOD_REPLACE)
def restart(self, unit):
"""Restart a service on host."""
self._check_dbus(unit)
_LOGGER.info("Restart local service %s", unit)
return self.sys_dbus.systemd.restart_unit(unit, MOD_REPLACE)
def exists(self, unit):
"""Check if a unit exists and return True."""
for service in self._services:
if unit == service.name:
return True
return False
async def update(self):
"""Update properties over dbus."""
self._check_dbus()
_LOGGER.info("Update service information")
self._services.clear()
try:
systemd_units = await self.sys_dbus.systemd.list_units()
for service_data in systemd_units[0]:
if not service_data[0].endswith(".service") or \
service_data[2] != 'loaded':
continue
self._services.add(ServiceInfo.read_from(service_data))
except (HassioError, IndexError):
_LOGGER.warning("Can't update host service information!")
@attr.s(frozen=True)
class ServiceInfo:
"""Represent a single Service."""
name = attr.ib(type=str)
description = attr.ib(type=str)
state = attr.ib(type=str)
@staticmethod
def read_from(unit):
"""Parse data from dbus into this object."""
return ServiceInfo(unit[0], unit[1], unit[3])

View File

@@ -3,6 +3,8 @@ import asyncio
import logging
import shlex
import async_timeout
_LOGGER = logging.getLogger(__name__)
COMMAND = "socat UDP-RECVFROM:53,fork UDP-SENDTO:127.0.0.11:53"
@@ -38,5 +40,10 @@ class DNSForward:
return
self.proc.kill()
await self.proc.wait()
try:
with async_timeout.timeout(5):
await self.proc.wait()
except asyncio.TimeoutError:
_LOGGER.warning("Stop waiting for DNS shutdown")
_LOGGER.info("Stop DNS forwarding")

View File

@@ -293,6 +293,7 @@ class SnapshotManager(CoreSysAttributes):
# Stop Home-Assistant if they will be restored later
if homeassistant and FOLDER_HOMEASSISTANT in folders:
await self.sys_homeassistant.stop()
snapshot.restore_homeassistant()
# Process folders
if folders:
@@ -304,7 +305,6 @@ class SnapshotManager(CoreSysAttributes):
if homeassistant:
_LOGGER.info("Restore %s run Home-Assistant",
snapshot.slug)
snapshot.restore_homeassistant()
task_hass = self.sys_create_task(
self.sys_homeassistant.update(
snapshot.homeassistant_version))
@@ -322,12 +322,20 @@ class SnapshotManager(CoreSysAttributes):
_LOGGER.info("Restore %s old add-ons", snapshot.slug)
await snapshot.restore_addons(addon_list)
# make sure homeassistant run agen
# Make sure homeassistant run agen
if task_hass:
_LOGGER.info("Restore %s wait for Home-Assistant",
snapshot.slug)
await task_hass
await self.sys_homeassistant.start()
# Do we need start HomeAssistant?
if not await self.sys_homeassistant.is_running():
await self.sys_homeassistant.start()
# Check If we can access to API / otherwise restart
if not await self.sys_homeassistant.check_api_state():
_LOGGER.warning("Need restart HomeAssistant for API")
await self.sys_homeassistant.restart()
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Restore %s error", snapshot.slug)

View File

@@ -20,7 +20,7 @@ from ..const import (
ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE,
ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_CRYPTO,
ATTR_LAST_VERSION, ATTR_PROTECTED, ATTR_WAIT_BOOT, ATTR_SIZE,
CRYPTO_AES128)
ATTR_REFRESH_TOKEN, CRYPTO_AES128)
from ..coresys import CoreSysAttributes
from ..utils.json import write_json_file
from ..utils.tar import SecureTarFile
@@ -387,6 +387,8 @@ class Snapshot(CoreSysAttributes):
# API/Proxy
self.homeassistant[ATTR_PORT] = self.sys_homeassistant.api_port
self.homeassistant[ATTR_SSL] = self.sys_homeassistant.api_ssl
self.homeassistant[ATTR_REFRESH_TOKEN] = \
self._encrypt_data(self.sys_homeassistant.refresh_token)
self.homeassistant[ATTR_PASSWORD] = \
self._encrypt_data(self.sys_homeassistant.api_password)
@@ -405,6 +407,8 @@ class Snapshot(CoreSysAttributes):
# API/Proxy
self.sys_homeassistant.api_port = self.homeassistant[ATTR_PORT]
self.sys_homeassistant.api_ssl = self.homeassistant[ATTR_SSL]
self.sys_homeassistant.refresh_token = \
self._decrypt_data(self.homeassistant[ATTR_REFRESH_TOKEN])
self.sys_homeassistant.api_password = \
self._decrypt_data(self.homeassistant[ATTR_PASSWORD])

View File

@@ -7,6 +7,7 @@ from ..const import (
ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE,
ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_SIZE,
ATTR_LAST_VERSION, ATTR_WAIT_BOOT, ATTR_PROTECTED, ATTR_CRYPTO,
ATTR_REFRESH_TOKEN,
FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL,
SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CRYPTO_AES128)
from ..validate import NETWORK_PORT, REPOSITORIES, DOCKER_IMAGE
@@ -16,7 +17,7 @@ ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL]
def unique_addons(addons_list):
"""Validate that an add-on is unique."""
single = set([addon[ATTR_SLUG] for addon in addons_list])
single = set(addon[ATTR_SLUG] for addon in addons_list)
if len(single) != len(addons_list):
raise vol.Invalid("Invalid addon list on snapshot!")
@@ -39,7 +40,8 @@ SCHEMA_SNAPSHOT = vol.Schema({
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(),
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT, default=600):
vol.All(vol.Coerce(int), vol.Range(min=60)),

View File

@@ -1,14 +1,21 @@
"""HomeAssistant control object."""
"""Home Assistant control object."""
import asyncio
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import aiohttp
from .coresys import CoreSysAttributes
from .docker.supervisor import DockerSupervisor
from .const import URL_HASSIO_APPARMOR
from .exceptions import HostAppArmorError
_LOGGER = logging.getLogger(__name__)
class Supervisor(CoreSysAttributes):
"""Hass core object for handle it."""
"""Home Assistant core object for handle it."""
def __init__(self, coresys):
"""Initialize hass object."""
@@ -16,9 +23,9 @@ class Supervisor(CoreSysAttributes):
self.instance = DockerSupervisor(coresys)
async def load(self):
"""Prepare HomeAssistant object."""
"""Prepare Home Assistant object."""
if not await self.instance.attach():
_LOGGER.fatal("Can't setup supervisor docker container!")
_LOGGER.fatal("Can't setup Supervisor Docker container!")
await self.instance.cleanup()
@property
@@ -28,38 +35,64 @@ class Supervisor(CoreSysAttributes):
@property
def version(self):
"""Return version of running homeassistant."""
"""Return version of running Home Assistant."""
return self.instance.version
@property
def last_version(self):
"""Return last available version of homeassistant."""
"""Return last available version of Home Assistant."""
return self.sys_updater.version_hassio
@property
def image(self):
"""Return image name of hass containter."""
"""Return image name of Home Assistant container."""
return self.instance.image
@property
def arch(self):
"""Return arch of hass.io containter."""
"""Return arch of the Hass.io container."""
return self.instance.arch
async def update_apparmor(self):
"""Fetch last version and update profile."""
url = URL_HASSIO_APPARMOR
try:
_LOGGER.info("Fetch AppArmor profile %s", url)
async with self.sys_websession.get(url, timeout=10) as request:
data = await request.text()
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch AppArmor profile: %s", err)
return
with TemporaryDirectory(dir=self.sys_config.path_tmp) as tmp_dir:
profile_file = Path(tmp_dir, 'apparmor.txt')
try:
profile_file.write_text(data)
except OSError as err:
_LOGGER.error("Can't write temporary profile: %s", err)
return
try:
await self.sys_host.apparmor.load_profile(
"hassio-supervisor", profile_file)
except HostAppArmorError:
_LOGGER.error("Can't update AppArmor profile!")
async def update(self, version=None):
"""Update HomeAssistant version."""
"""Update Home Assistant version."""
version = version or self.last_version
if version == self.sys_supervisor.version:
_LOGGER.warning("Version %s is already installed", version)
return
_LOGGER.info("Update supervisor to version %s", version)
_LOGGER.info("Update Supervisor to version %s", version)
if await self.instance.install(version):
await self.update_apparmor()
self.sys_loop.call_later(1, self.sys_loop.stop)
return True
_LOGGER.error("Update of hass.io fails!")
_LOGGER.error("Update of Hass.io fails!")
return False
@property

View File

@@ -1,4 +1,4 @@
"""Multible tasks."""
"""A collection of tasks."""
import asyncio
import logging
@@ -6,54 +6,59 @@ from .coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
HASS_WATCHDOG_API = 'HASS_WATCHDOG_API'
RUN_UPDATE_SUPERVISOR = 29100
RUN_UPDATE_ADDONS = 57600
RUN_UPDATE_HASSOSCLI = 29100
RUN_RELOAD_ADDONS = 21600
RUN_RELOAD_SNAPSHOTS = 72000
RUN_RELOAD_HOST = 72000
RUN_RELOAD_UPDATER = 21600
RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300
class Tasks(CoreSysAttributes):
"""Handle Tasks inside HassIO."""
RUN_UPDATE_SUPERVISOR = 29100
RUN_UPDATE_ADDONS = 57600
RUN_RELOAD_ADDONS = 21600
RUN_RELOAD_SNAPSHOTS = 72000
RUN_RELOAD_HOST = 72000
RUN_RELOAD_UPDATER = 21600
RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300
"""Handle Tasks inside Hass.io."""
def __init__(self, coresys):
"""Initialize Tasks."""
self.coresys = coresys
self.jobs = set()
self._data = {}
self._cache = {}
async def load(self):
"""Add Tasks to scheduler."""
self.jobs.add(self.sys_scheduler.register_task(
self._update_addons, self.RUN_UPDATE_ADDONS))
self._update_addons, RUN_UPDATE_ADDONS))
self.jobs.add(self.sys_scheduler.register_task(
self._update_supervisor, self.RUN_UPDATE_SUPERVISOR))
self._update_supervisor, RUN_UPDATE_SUPERVISOR))
self.jobs.add(self.sys_scheduler.register_task(
self._update_hassos_cli, RUN_UPDATE_HASSOSCLI))
self.jobs.add(self.sys_scheduler.register_task(
self.sys_addons.reload, self.RUN_RELOAD_ADDONS))
self.sys_addons.reload, RUN_RELOAD_ADDONS))
self.jobs.add(self.sys_scheduler.register_task(
self.sys_updater.reload, self.RUN_RELOAD_UPDATER))
self.sys_updater.reload, RUN_RELOAD_UPDATER))
self.jobs.add(self.sys_scheduler.register_task(
self.sys_snapshots.reload, self.RUN_RELOAD_SNAPSHOTS))
self.sys_snapshots.reload, RUN_RELOAD_SNAPSHOTS))
self.jobs.add(self.sys_scheduler.register_task(
self.sys_host.load, self.RUN_RELOAD_HOST))
self.sys_host.reload, RUN_RELOAD_HOST))
self.jobs.add(self.sys_scheduler.register_task(
self._watchdog_homeassistant_docker,
self.RUN_WATCHDOG_HOMEASSISTANT_DOCKER))
RUN_WATCHDOG_HOMEASSISTANT_DOCKER))
self.jobs.add(self.sys_scheduler.register_task(
self._watchdog_homeassistant_api,
self.RUN_WATCHDOG_HOMEASSISTANT_API))
RUN_WATCHDOG_HOMEASSISTANT_API))
_LOGGER.info("All core tasks are scheduled")
async def _update_addons(self):
"""Check if an update is available for an addon and update it."""
"""Check if an update is available for an Add-on and update it."""
tasks = []
for addon in self.sys_addons.list_addons:
if not addon.is_installed or not addon.auto_update:
@@ -62,22 +67,22 @@ class Tasks(CoreSysAttributes):
if addon.version_installed == addon.last_version:
continue
if addon.test_udpate_schema():
if addon.test_update_schema():
tasks.append(addon.update())
else:
_LOGGER.warning(
"Addon %s will be ignore, schema tests fails", addon.slug)
"Add-on %s will be ignore, schema tests fails", addon.slug)
if tasks:
_LOGGER.info("Addon auto update process %d tasks", len(tasks))
_LOGGER.info("Add-on auto update process %d tasks", len(tasks))
await asyncio.wait(tasks)
async def _update_supervisor(self):
"""Check and run update of supervisor hassio."""
"""Check and run update of Supervisor Hass.io."""
if not self.sys_supervisor.need_update:
return
# don't perform an update on beta/dev channel
# don't perform an update on dev channel
if self.sys_dev:
_LOGGER.warning("Ignore Hass.io update on dev channel!")
return
@@ -86,33 +91,36 @@ class Tasks(CoreSysAttributes):
await self.sys_supervisor.update()
async def _watchdog_homeassistant_docker(self):
"""Check running state of docker and start if they is close."""
# if Home-Assistant is active
"""Check running state of Docker and start if they is close."""
# if Home Assistant is active
if not await self.sys_homeassistant.is_initialize() or \
not self.sys_homeassistant.watchdog:
not self.sys_homeassistant.watchdog or \
self.sys_homeassistant.error_state:
return
# if Home-Assistant is running
# if Home Assistant is running
if self.sys_homeassistant.in_progress or \
await self.sys_homeassistant.is_running():
return
_LOGGER.warning("Watchdog found a problem with Home-Assistant docker!")
_LOGGER.warning("Watchdog found a problem with Home Assistant Docker!")
await self.sys_homeassistant.start()
async def _watchdog_homeassistant_api(self):
"""Create scheduler task for montoring running state of API.
"""Create scheduler task for monitoring running state of API.
Try 2 times to call API before we restart Home-Assistant. Maybe we had
a delay in our system.
"""
retry_scan = self._data.get('HASS_WATCHDOG_API', 0)
# If Home-Assistant is active
if not await self.sys_homeassistant.is_initialize() or \
not self.sys_homeassistant.watchdog:
not self.sys_homeassistant.watchdog or \
self.sys_homeassistant.error_state:
return
# Init cache data
retry_scan = self._cache.get(HASS_WATCHDOG_API, 0)
# If Home-Assistant API is up
if self.sys_homeassistant.in_progress or \
await self.sys_homeassistant.check_api_state():
@@ -121,10 +129,25 @@ class Tasks(CoreSysAttributes):
# Look like we run into a problem
retry_scan += 1
if retry_scan == 1:
self._data['HASS_WATCHDOG_API'] = retry_scan
_LOGGER.warning("Watchdog miss API response from Home-Assistant")
self._cache[HASS_WATCHDOG_API] = retry_scan
_LOGGER.warning("Watchdog miss API response from Home Assistant")
return
_LOGGER.error("Watchdog found a problem with Home-Assistant API!")
await self.sys_homeassistant.restart()
self._data['HASS_WATCHDOG_API'] = 0
_LOGGER.error("Watchdog found a problem with Home Assistant API!")
try:
await self.sys_homeassistant.restart()
finally:
self._cache[HASS_WATCHDOG_API] = 0
async def _update_hassos_cli(self):
"""Check and run update of HassOS CLI."""
if not self.sys_hassos.need_cli_update:
return
# don't perform an update on dev channel
if self.sys_dev:
_LOGGER.warning("Ignore HassOS CLI update on dev channel!")
return
_LOGGER.info("Found new HassOS CLI version")
await self.sys_hassos.update_cli()

View File

@@ -9,11 +9,12 @@ import aiohttp
from .const import (
URL_HASSIO_VERSION, FILE_HASSIO_UPDATER, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_CHANNEL)
ATTR_CHANNEL, ATTR_HASSOS, ATTR_HASSOS_CLI)
from .coresys import CoreSysAttributes
from .utils import AsyncThrottle
from .utils.json import JsonConfig
from .validate import SCHEMA_UPDATER_CONFIG
from .exceptions import HassioUpdaterError
_LOGGER = logging.getLogger(__name__)
@@ -26,26 +27,39 @@ class Updater(JsonConfig, CoreSysAttributes):
super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG)
self.coresys = coresys
def load(self):
"""Update internal data.
async def load(self):
"""Update internal data."""
with suppress(HassioUpdaterError):
await self.fetch_data()
Return a coroutine.
"""
return self.reload()
async def reload(self):
"""Update internal data."""
with suppress(HassioUpdaterError):
await self.fetch_data()
@property
def version_homeassistant(self):
"""Return last version of homeassistant."""
"""Return last version of Home Assistant."""
return self._data.get(ATTR_HOMEASSISTANT)
@property
def version_hassio(self):
"""Return last version of hassio."""
"""Return last version of Hass.io."""
return self._data.get(ATTR_HASSIO)
@property
def version_hassos(self):
"""Return last version of HassOS."""
return self._data.get(ATTR_HASSOS)
@property
def version_hassos_cli(self):
"""Return last version of HassOS cli."""
return self._data.get(ATTR_HASSOS_CLI)
@property
def channel(self):
"""Return upstream channel of hassio instance."""
"""Return upstream channel of Hass.io instance."""
return self._data[ATTR_CHANNEL]
@channel.setter
@@ -54,38 +68,48 @@ class Updater(JsonConfig, CoreSysAttributes):
self._data[ATTR_CHANNEL] = value
@AsyncThrottle(timedelta(seconds=60))
async def reload(self):
"""Fetch current versions from github.
async def fetch_data(self):
"""Fetch current versions from Github.
Is a coroutine.
"""
url = URL_HASSIO_VERSION.format(channel=self.channel)
machine = self.sys_machine or 'default'
board = self.sys_hassos.board
try:
_LOGGER.info("Fetch update data from %s", url)
async with self.sys_websession.get(url, timeout=10) as request:
data = await request.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, KeyError) as err:
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch versions from %s: %s", url, err)
return
raise HassioUpdaterError() from None
except json.JSONDecodeError as err:
_LOGGER.warning("Can't parse versions from %s: %s", url, err)
return
raise HassioUpdaterError() from None
# data valid?
if not data or data.get(ATTR_CHANNEL) != self.channel:
_LOGGER.warning("Invalid data from %s", url)
return
raise HassioUpdaterError() from None
# update supervisor versions
with suppress(KeyError):
try:
# update supervisor version
self._data[ATTR_HASSIO] = data['supervisor']
# update Home Assistant version
machine = self.sys_machine or 'default'
with suppress(KeyError):
self._data[ATTR_HOMEASSISTANT] = \
data['homeassistant'][machine]
# update Home Assistant version
self._data[ATTR_HOMEASSISTANT] = data['homeassistant'][machine]
self.save_data()
# update hassos version
if self.sys_hassos.available and board:
self._data[ATTR_HASSOS] = data['hassos'][board]
self._data[ATTR_HASSOS_CLI] = data['hassos-cli']
except KeyError as err:
_LOGGER.warning("Can't process version data: %s", err)
raise HassioUpdaterError() from None
else:
self.save_data()

View File

@@ -1,7 +1,9 @@
"""Tools file for HassIO."""
"""Tools file for Hass.io."""
from datetime import datetime
import hashlib
import logging
import re
import uuid
_LOGGER = logging.getLogger(__name__)
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
@@ -12,13 +14,19 @@ def convert_to_ascii(raw):
return RE_STRING.sub("", raw.decode())
def create_token():
"""Create token for API access."""
return hashlib.sha256(uuid.uuid4().bytes).hexdigest()
def process_lock(method):
"""Wrap function with only run once."""
async def wrap_api(api, *args, **kwargs):
"""Return api wrapper."""
if api.lock.locked():
_LOGGER.error(
"Can't excute %s while a task is in progress", method.__name__)
"Can't execute %s while a task is in progress",
method.__name__)
return False
async with api.lock:

66
hassio/utils/apparmor.py Normal file
View File

@@ -0,0 +1,66 @@
"""Some functions around AppArmor profiles."""
import logging
import re
from ..exceptions import AppArmorFileError, AppArmorInvalidError
_LOGGER = logging.getLogger(__name__)
RE_PROFILE = re.compile(r"^profile ([^ ]+).*$")
def get_profile_name(profile_file):
"""Read the profile name from file."""
profiles = set()
try:
with profile_file.open('r') as profile_data:
for line in profile_data:
match = RE_PROFILE.match(line)
if not match:
continue
profiles.add(match.group(1))
except OSError as err:
_LOGGER.error("Can't read AppArmor profile: %s", err)
raise AppArmorFileError()
if len(profiles) != 1:
_LOGGER.error("To many profiles inside file: %s", profiles)
raise AppArmorInvalidError()
return profiles.pop()
def validate_profile(profile_name, profile_file):
"""Check if profile from file is valid with profile name."""
if profile_name == get_profile_name(profile_file):
return True
return False
def adjust_profile(profile_name, profile_file, profile_new):
"""Fix the profile name."""
org_profile = get_profile_name(profile_file)
profile_data = []
# Process old data
try:
with profile_file.open('r') as profile:
for line in profile:
match = RE_PROFILE.match(line)
if not match:
profile_data.append(line)
else:
profile_data.append(
line.replace(org_profile, profile_name))
except OSError as err:
_LOGGER.error("Can't adjust origin profile: %s", err)
raise AppArmorFileError()
# Write into new file
try:
with profile_new.open('w') as profile:
profile.writelines(profile_data)
except OSError as err:
_LOGGER.error("Can't write new profile: %s", err)
raise AppArmorFileError()

View File

@@ -1,18 +1,14 @@
"""Tools file for HassIO."""
import asyncio
"""Tools file for Hass.io."""
from datetime import datetime, timedelta, timezone
import logging
import re
import aiohttp
import async_timeout
import pytz
UTC = pytz.utc
_LOGGER = logging.getLogger(__name__)
FREEGEOIP_URL = "https://freegeoip.net/json/"
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
@@ -25,23 +21,6 @@ DATETIME_RE = re.compile(
)
async def fetch_timezone(websession):
"""Read timezone from freegeoip."""
data = {}
try:
with async_timeout.timeout(10):
async with websession.get(FREEGEOIP_URL) as request:
data = await request.json()
except (aiohttp.ClientError, asyncio.TimeoutError, KeyError) as err:
_LOGGER.warning("Can't fetch freegeoip data: %s", err)
except ValueError as err:
_LOGGER.warning("Error on parse freegeoip data: %s", err)
return data.get('time_zone', 'UTC')
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
# https://github.com/django/django/blob/master/LICENSE
@@ -79,5 +58,5 @@ def parse_datetime(dt_str):
def utcnow():
"""Returns current timestamp including timezone."""
"""Return the current timestamp including timezone."""
return datetime.now(UTC)

View File

@@ -4,6 +4,7 @@ import logging
import json
import shlex
import re
from signal import SIGINT
import xml.etree.ElementTree as ET
from ..exceptions import DBusFatalError, DBusParseError
@@ -14,16 +15,20 @@ _LOGGER = logging.getLogger(__name__)
RE_GVARIANT_TYPE = re.compile(
r"(?:boolean|byte|int16|uint16|int32|uint32|handle|int64|uint64|double|"
r"string|objectpath|signature) ")
RE_GVARIANT_TULPE = re.compile(r"^\((.*),\)$")
RE_GVARIANT_VARIANT = re.compile(
r"(?<=(?: |{|\[))<((?:'|\").*?(?:'|\")|\d+(?:\.\d+)?)>(?=(?:|]|}|,))")
RE_GVARIANT_STRING = re.compile(r"(?<=(?: |{|\[))'(.*?)'(?=(?:|]|}|,))")
RE_GVARIANT_STRING = re.compile(r"(?<=(?: |{|\[|\())'(.*?)'(?=(?:|]|}|,|\)))")
RE_GVARIANT_TUPLE_O = re.compile(r"\"[^\"]*?\"|(\()")
RE_GVARIANT_TUPLE_C = re.compile(r"\"[^\"]*?\"|(,?\))")
RE_MONITOR_OUTPUT = re.compile(r".+?: (?P<signal>[^ ].+) (?P<data>.*)")
# Commands for dbus
INTROSPECT = ("gdbus introspect --system --dest {bus} "
"--object-path {object} --xml")
CALL = ("gdbus call --system --dest {bus} --object-path {object} "
"--method {method} {args}")
MONITOR = ("gdbus monitor --system --dest {bus}")
DBUS_METHOD_GETALL = 'org.freedesktop.DBus.Properties.GetAll'
@@ -36,6 +41,7 @@ class DBus:
self.bus_name = bus_name
self.object_path = object_path
self.methods = set()
self.signals = set()
@staticmethod
async def connect(bus_name, object_path):
@@ -68,21 +74,31 @@ class DBus:
_LOGGER.debug("data: %s", data)
for interface in xml.findall("./interface"):
interface_name = interface.get('name')
# Methods
for method in interface.findall("./method"):
method_name = method.get('name')
self.methods.add(f"{interface_name}.{method_name}")
# Signals
for signal in interface.findall("./signal"):
signal_name = signal.get('name')
self.signals.add(f"{interface_name}.{signal_name}")
@staticmethod
def _gvariant(raw):
def parse_gvariant(raw):
"""Parse GVariant input to python."""
raw = RE_GVARIANT_TYPE.sub("", raw)
raw = RE_GVARIANT_TULPE.sub(r"[\1]", raw)
raw = RE_GVARIANT_VARIANT.sub(r"\1", raw)
raw = RE_GVARIANT_STRING.sub(r'"\1"', raw)
raw = RE_GVARIANT_TUPLE_O.sub(
lambda x: x.group(0) if not x.group(1) else"[", raw)
raw = RE_GVARIANT_TUPLE_C.sub(
lambda x: x.group(0) if not x.group(1) else"]", raw)
# No data
if raw.startswith("()"):
return {}
if raw.startswith("[]"):
return []
try:
return json.loads(raw)
@@ -90,13 +106,29 @@ class DBus:
_LOGGER.error("Can't parse '%s': %s", raw, err)
raise DBusParseError() from None
@staticmethod
def gvariant_args(args):
"""Convert args into gvariant."""
gvariant = ""
for arg in args:
if isinstance(arg, bool):
gvariant += " {}".format(str(arg).lower())
elif isinstance(arg, (int, float)):
gvariant += f" {arg}"
elif isinstance(arg, str):
gvariant += f" \"{arg}\""
else:
gvariant += " {}".format(str(arg))
return gvariant.lstrip()
async def call_dbus(self, method, *args):
"""Call a dbus method."""
command = shlex.split(CALL.format(
bus=self.bus_name,
object=self.object_path,
method=method,
args=" ".join(map(str, args))
args=self.gvariant_args(args)
))
# Run command
@@ -104,7 +136,7 @@ class DBus:
data = await self._send(command)
# Parse and return data
return self._gvariant(data)
return self.parse_gvariant(data)
async def get_properties(self, interface):
"""Read all properties from interface."""
@@ -139,6 +171,17 @@ class DBus:
# End
return data.decode()
def attach_signals(self, filters=None):
"""Generate a signals wrapper."""
return DBusSignalWrapper(self, filters)
async def wait_signal(self, signal):
"""Wait for single event."""
monitor = DBusSignalWrapper(self, [signal])
async with monitor as signals:
async for signal in signals:
return signal
def __getattr__(self, name):
"""Mapping to dbus method."""
return getattr(DBusCallWrapper(self, self.bus_name), name)
@@ -172,3 +215,71 @@ class DBusCallWrapper:
return self.dbus.call_dbus(interface, *args)
return _method_wrapper
class DBusSignalWrapper:
"""Process Signals."""
def __init__(self, dbus, signals=None):
"""Initialize dbus signal wrapper."""
self.dbus = dbus
self._signals = signals
self._proc = None
async def __aenter__(self):
"""Start monitor events."""
_LOGGER.info("Start dbus monitor on %s", self.dbus.bus_name)
command = shlex.split(MONITOR.format(
bus=self.dbus.bus_name
))
self._proc = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
return self
async def __aexit__(self, exception_type, exception_value, traceback):
"""Stop monitor events."""
_LOGGER.info("Stop dbus monitor on %s", self.dbus.bus_name)
self._proc.send_signal(SIGINT)
await self._proc.communicate()
def __aiter__(self):
"""Start Iteratation."""
return self
async def __anext__(self):
"""Get next data."""
if not self._proc:
raise StopAsyncIteration()
# Read signals
while True:
try:
data = await self._proc.stdout.readline()
except asyncio.TimeoutError:
raise StopAsyncIteration() from None
# Program close
if not data:
raise StopAsyncIteration()
# Extract metadata
match = RE_MONITOR_OUTPUT.match(data.decode())
if not match:
continue
signal = match.group('signal')
data = match.group('data')
# Filter signals?
if self._signals and signal not in self._signals:
_LOGGER.debug("Skip event %s - %s", signal, data)
continue
try:
return self.dbus.parse_gvariant(data)
except DBusParseError:
raise StopAsyncIteration() from None

View File

@@ -1,4 +1,4 @@
"""Tools file for HassIO."""
"""Tools file for Hass.io."""
import json
import logging
@@ -9,14 +9,14 @@ _LOGGER = logging.getLogger(__name__)
def write_json_file(jsonfile, data):
"""Write a json file."""
"""Write a JSON file."""
json_str = json.dumps(data, indent=2)
with jsonfile.open('w') as conf_file:
conf_file.write(json_str)
def read_json_file(jsonfile):
"""Read a json file and return a dict."""
"""Read a JSON file and return a dict."""
with jsonfile.open('r') as cfile:
return json.loads(cfile.read())
@@ -33,7 +33,7 @@ class JsonConfig:
self.read_data()
def reset_data(self):
"""Reset json file to default."""
"""Reset JSON file to default."""
try:
self._data = self._schema({})
except vol.Invalid as ex:
@@ -41,11 +41,11 @@ class JsonConfig:
self._file, humanize_error(self._data, ex))
def read_data(self):
"""Read json file & validate."""
"""Read JSON file & validate."""
if self._file.is_file():
try:
self._data = read_json_file(self._file)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
_LOGGER.warning("Can't read %s", self._file)
self._data = {}
@@ -61,7 +61,7 @@ class JsonConfig:
self._data = self._schema({})
def save_data(self):
"""Store data to config file."""
"""Store data to configuration file."""
# Validate
try:
self._data = self._schema(self._data)
@@ -78,4 +78,5 @@ class JsonConfig:
try:
write_json_file(self._file, self._data)
except (OSError, json.JSONDecodeError) as err:
_LOGGER.error("Can't store config in %s: %s", self._file, err)
_LOGGER.error(
"Can't store configuration in %s: %s", self._file, err)

View File

@@ -6,10 +6,12 @@ import voluptuous as vol
import pytz
from .const import (
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE,
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS,
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI,
ATTR_ACCESS_TOKEN,
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
@@ -17,12 +19,12 @@ RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
WAIT_BOOT = vol.All(vol.Coerce(int), vol.Range(min=1, max=60))
DOCKER_IMAGE = vol.Match(r"^[\w{}]+/[\-\w{}]+$")
ALSA_DEVICE = vol.Any(None, vol.Match(r"\d+,\d+"))
ALSA_DEVICE = vol.Maybe(vol.Match(r"\d+,\d+"))
CHANNELS = vol.In([CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV])
def validate_repository(repository):
"""Validate a valide repository."""
"""Validate a valid repository."""
data = RE_REPOSITORY.match(repository)
if not data:
raise vol.Invalid("No valid repository format!")
@@ -53,7 +55,7 @@ def validate_timezone(timezone):
# pylint: disable=inconsistent-return-statements
def convert_to_docker_ports(data):
"""Convert data into docker port list."""
"""Convert data into Docker port list."""
# dynamic ports
if data is None:
return None
@@ -70,7 +72,7 @@ def convert_to_docker_ports(data):
if isinstance(data, list) and len(data) == 2:
return (vol.Coerce(str)(data[0]), NETWORK_PORT(data[1]))
raise vol.Invalid("Can't validate docker host settings")
raise vol.Invalid("Can't validate Docker host settings")
DOCKER_PORTS = vol.Schema({
@@ -83,11 +85,13 @@ DOCKER_PORTS = vol.Schema({
SCHEMA_HASS_CONFIG = vol.Schema({
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
vol.Match(r"^[0-9a-f]{32}$"),
vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"),
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(),
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE,
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT, default=600):
@@ -99,6 +103,8 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({
vol.Optional(ATTR_CHANNEL, default=CHANNEL_STABLE): CHANNELS,
vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str),
vol.Optional(ATTR_HASSIO): vol.Coerce(str),
vol.Optional(ATTR_HASSOS): vol.Coerce(str),
vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str),
}, extra=vol.REMOVE_EXTRA)

View File

@@ -21,7 +21,6 @@ disable=
abstract-class-little-used,
abstract-class-not-used,
unused-argument,
global-statement,
redefined-variable-type,
too-many-arguments,
too-many-branches,
@@ -32,7 +31,10 @@ disable=
too-many-statements,
too-many-lines,
too-few-public-methods,
abstract-method
abstract-method,
no-else-return,
useless-return,
not-async-context-manager
[EXCEPTIONS]
overgeneral-exceptions=Exception,HomeAssistantError

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
attr==0.3.1
async_timeout==3.0.0
aiohttp==3.4.0
docker==3.5.0
colorlog==3.1.2
voluptuous==0.11.5
gitpython==2.1.10
pytz==2018.4
pyudev==0.21.0
pycryptodome==3.6.6
cpe==1.2.1
uvloop==0.11.2
cchardet==2.1.1

View File

@@ -38,17 +38,5 @@ setup(
'hassio.utils',
'hassio.snapshots'
],
include_package_data=True,
install_requires=[
'attr==0.3.1',
'async_timeout==3.0.0',
'aiohttp==3.2.1',
'docker==3.3.0',
'colorlog==3.1.2',
'voluptuous==0.11.1',
'gitpython==2.1.10',
'pytz==2018.4',
'pyudev==0.21.0',
'pycryptodome==3.4.11'
]
include_package_data=True
)

View File

@@ -3,8 +3,9 @@ envlist = lint
[testenv]
deps =
flake8
pylint
flake8==3.5.0
pylint==2.1.1
-r{toxinidir}/requirements.txt
[testenv:lint]
basepython = python3

View File

@@ -1,4 +1,4 @@
{
"hassio": "105",
"hassio": "108",
"homeassistant": "0.70.0"
}