qtoggleserver 0.28.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. qtoggleserver/commands/__init__.py +28 -0
  2. qtoggleserver/commands/server.py +18 -0
  3. qtoggleserver/commands/shell.py +21 -0
  4. qtoggleserver/conf/__init__.py +0 -0
  5. qtoggleserver/conf/settings.py +158 -0
  6. qtoggleserver/core/__init__.py +0 -0
  7. qtoggleserver/core/api/__init__.py +111 -0
  8. qtoggleserver/core/api/auth.py +105 -0
  9. qtoggleserver/core/api/funcs/__init__.py +0 -0
  10. qtoggleserver/core/api/funcs/backup.py +26 -0
  11. qtoggleserver/core/api/funcs/device.py +68 -0
  12. qtoggleserver/core/api/funcs/firmware.py +47 -0
  13. qtoggleserver/core/api/funcs/ports.py +471 -0
  14. qtoggleserver/core/api/funcs/reverse.py +31 -0
  15. qtoggleserver/core/api/funcs/various.py +99 -0
  16. qtoggleserver/core/api/funcs/webhooks.py +31 -0
  17. qtoggleserver/core/api/schema.py +172 -0
  18. qtoggleserver/core/device/__init__.py +93 -0
  19. qtoggleserver/core/device/attrs.py +736 -0
  20. qtoggleserver/core/device/events.py +5 -0
  21. qtoggleserver/core/events/__init__.py +35 -0
  22. qtoggleserver/core/events/base.py +86 -0
  23. qtoggleserver/core/events/device.py +29 -0
  24. qtoggleserver/core/events/handlers.py +76 -0
  25. qtoggleserver/core/events/port.py +64 -0
  26. qtoggleserver/core/expressions/__init__.py +87 -0
  27. qtoggleserver/core/expressions/aggregation.py +42 -0
  28. qtoggleserver/core/expressions/arithmetic.py +68 -0
  29. qtoggleserver/core/expressions/base.py +69 -0
  30. qtoggleserver/core/expressions/bitwise.py +64 -0
  31. qtoggleserver/core/expressions/comparison.py +64 -0
  32. qtoggleserver/core/expressions/date.py +334 -0
  33. qtoggleserver/core/expressions/exceptions.py +149 -0
  34. qtoggleserver/core/expressions/functions.py +150 -0
  35. qtoggleserver/core/expressions/literalvalues.py +58 -0
  36. qtoggleserver/core/expressions/logic.py +47 -0
  37. qtoggleserver/core/expressions/ports.py +112 -0
  38. qtoggleserver/core/expressions/rounding.py +40 -0
  39. qtoggleserver/core/expressions/sign.py +24 -0
  40. qtoggleserver/core/expressions/time.py +20 -0
  41. qtoggleserver/core/expressions/timeprocessing.py +280 -0
  42. qtoggleserver/core/expressions/various.py +330 -0
  43. qtoggleserver/core/history.py +245 -0
  44. qtoggleserver/core/main.py +243 -0
  45. qtoggleserver/core/ports.py +1256 -0
  46. qtoggleserver/core/responses.py +163 -0
  47. qtoggleserver/core/reverse.py +335 -0
  48. qtoggleserver/core/sequences.py +65 -0
  49. qtoggleserver/core/sessions.py +140 -0
  50. qtoggleserver/core/typing.py +15 -0
  51. qtoggleserver/core/vports.py +102 -0
  52. qtoggleserver/core/webhooks.py +254 -0
  53. qtoggleserver/drivers/__init__.py +0 -0
  54. qtoggleserver/drivers/events/__init__.py +0 -0
  55. qtoggleserver/drivers/events/dummy.py +79 -0
  56. qtoggleserver/drivers/fwupdate/__init__.py +4 -0
  57. qtoggleserver/drivers/fwupdate/dummy.py +57 -0
  58. qtoggleserver/drivers/persist/__init__.py +26 -0
  59. qtoggleserver/drivers/persist/json.py +306 -0
  60. qtoggleserver/drivers/persist/mongo.py +170 -0
  61. qtoggleserver/drivers/persist/postgresql.py +557 -0
  62. qtoggleserver/drivers/persist/redis.py +301 -0
  63. qtoggleserver/drivers/ports/__init__.py +0 -0
  64. qtoggleserver/drivers/ports/dummynumeric.py +54 -0
  65. qtoggleserver/drivers/ports/gpio/__init__.py +5 -0
  66. qtoggleserver/drivers/ports/gpio/dummy.py +54 -0
  67. qtoggleserver/drivers/ports/gpio/gpio.py +104 -0
  68. qtoggleserver/frontend/__init__.py +0 -0
  69. qtoggleserver/frontend/api/__init__.py +0 -0
  70. qtoggleserver/frontend/api/funcs.py +70 -0
  71. qtoggleserver/frontend/dist/font/dejavusans-bold.woff +0 -0
  72. qtoggleserver/frontend/dist/font/dejavusans-bolditalic.woff +0 -0
  73. qtoggleserver/frontend/dist/font/dejavusans-italic.woff +0 -0
  74. qtoggleserver/frontend/dist/font/dejavusans-regular.woff +0 -0
  75. qtoggleserver/frontend/dist/img/launcher-icon-144.png +0 -0
  76. qtoggleserver/frontend/dist/img/launcher-icon-16.png +0 -0
  77. qtoggleserver/frontend/dist/img/launcher-icon-192.png +0 -0
  78. qtoggleserver/frontend/dist/img/launcher-icon-256.png +0 -0
  79. qtoggleserver/frontend/dist/img/launcher-icon-32.png +0 -0
  80. qtoggleserver/frontend/dist/img/launcher-icon-36.png +0 -0
  81. qtoggleserver/frontend/dist/img/launcher-icon-384.png +0 -0
  82. qtoggleserver/frontend/dist/img/launcher-icon-48.png +0 -0
  83. qtoggleserver/frontend/dist/img/launcher-icon-512.png +0 -0
  84. qtoggleserver/frontend/dist/img/launcher-icon-64.png +0 -0
  85. qtoggleserver/frontend/dist/img/launcher-icon-72.png +0 -0
  86. qtoggleserver/frontend/dist/img/launcher-icon-96.png +0 -0
  87. qtoggleserver/frontend/dist/img/launcher-icon.svg +218 -0
  88. qtoggleserver/frontend/dist/img/qtoggle-icons.svg +1561 -0
  89. qtoggleserver/frontend/dist/img/qui-icons.svg +1937 -0
  90. qtoggleserver/frontend/dist/qtoggleserver-bundle-dark.css +1 -0
  91. qtoggleserver/frontend/dist/qtoggleserver-bundle-light.css +1 -0
  92. qtoggleserver/frontend/dist/qtoggleserver-bundle.js +3 -0
  93. qtoggleserver/frontend/dist/qtoggleserver-bundle.js.LICENSE.txt +259 -0
  94. qtoggleserver/frontend/dist/qtoggleserver-bundle.js.map +1 -0
  95. qtoggleserver/frontend/dist/templates/index.html +35 -0
  96. qtoggleserver/frontend/events.py +34 -0
  97. qtoggleserver/lib/__init__.py +0 -0
  98. qtoggleserver/lib/ble.py +370 -0
  99. qtoggleserver/lib/filtereventhandler.py +450 -0
  100. qtoggleserver/lib/onewire.py +96 -0
  101. qtoggleserver/lib/polled.py +182 -0
  102. qtoggleserver/lib/templatenotifications.py +260 -0
  103. qtoggleserver/peripherals/__init__.py +90 -0
  104. qtoggleserver/peripherals/api/__init__.py +0 -0
  105. qtoggleserver/peripherals/api/funcs.py +81 -0
  106. qtoggleserver/peripherals/api/schema.py +31 -0
  107. qtoggleserver/peripherals/exceptions.py +14 -0
  108. qtoggleserver/peripherals/peripheral.py +259 -0
  109. qtoggleserver/peripherals/peripheralport.py +44 -0
  110. qtoggleserver/persist/__init__.py +382 -0
  111. qtoggleserver/persist/base.py +196 -0
  112. qtoggleserver/persist/typing.py +7 -0
  113. qtoggleserver/slaves/__init__.py +36 -0
  114. qtoggleserver/slaves/api/__init__.py +0 -0
  115. qtoggleserver/slaves/api/funcs/__init__.py +0 -0
  116. qtoggleserver/slaves/api/funcs/devices.py +349 -0
  117. qtoggleserver/slaves/api/funcs/discovered.py +48 -0
  118. qtoggleserver/slaves/api/schema.py +41 -0
  119. qtoggleserver/slaves/devices.py +1671 -0
  120. qtoggleserver/slaves/discover/__init__.py +27 -0
  121. qtoggleserver/slaves/discover/apclients.py +344 -0
  122. qtoggleserver/slaves/discover/exceptions.py +6 -0
  123. qtoggleserver/slaves/events.py +49 -0
  124. qtoggleserver/slaves/exceptions.py +77 -0
  125. qtoggleserver/slaves/ports.py +428 -0
  126. qtoggleserver/slaves/utils.py +9 -0
  127. qtoggleserver/startup.py +369 -0
  128. qtoggleserver/system/__init__.py +66 -0
  129. qtoggleserver/system/ap/__init__.py +132 -0
  130. qtoggleserver/system/ap/client.py +72 -0
  131. qtoggleserver/system/ap/dnsmasq.py +192 -0
  132. qtoggleserver/system/ap/exceptions.py +2 -0
  133. qtoggleserver/system/ap/hostapd.py +155 -0
  134. qtoggleserver/system/api/__init__.py +0 -0
  135. qtoggleserver/system/api/funcs.py +21 -0
  136. qtoggleserver/system/battery.py +43 -0
  137. qtoggleserver/system/conf.py +51 -0
  138. qtoggleserver/system/date.py +55 -0
  139. qtoggleserver/system/dhcp.py +285 -0
  140. qtoggleserver/system/dns.py +54 -0
  141. qtoggleserver/system/fwupdate.py +125 -0
  142. qtoggleserver/system/net.py +123 -0
  143. qtoggleserver/system/storage.py +14 -0
  144. qtoggleserver/system/temperature.py +33 -0
  145. qtoggleserver/utils/__init__.py +0 -0
  146. qtoggleserver/utils/asyncio.py +164 -0
  147. qtoggleserver/utils/cmd.py +78 -0
  148. qtoggleserver/utils/conf.py +54 -0
  149. qtoggleserver/utils/dynload.py +20 -0
  150. qtoggleserver/utils/json.py +163 -0
  151. qtoggleserver/utils/logging.py +46 -0
  152. qtoggleserver/utils/timedset.py +35 -0
  153. qtoggleserver/version.py +2 -0
  154. qtoggleserver/web/__init__.py +42 -0
  155. qtoggleserver/web/base.py +191 -0
  156. qtoggleserver/web/handlers.py +243 -0
  157. qtoggleserver/web/server.py +177 -0
  158. qtoggleserver-0.28.0b1.dist-info/METADATA +37 -0
  159. qtoggleserver-0.28.0b1.dist-info/RECORD +163 -0
  160. qtoggleserver-0.28.0b1.dist-info/WHEEL +5 -0
  161. qtoggleserver-0.28.0b1.dist-info/entry_points.txt +3 -0
  162. qtoggleserver-0.28.0b1.dist-info/licenses/LICENSE.txt +177 -0
  163. qtoggleserver-0.28.0b1.dist-info/top_level.txt +3 -0
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ from qtoggleserver import startup
6
+
7
+
8
+ def execute(main_func: Callable[[], Awaitable[bool]]) -> None:
9
+ loop = asyncio.new_event_loop()
10
+
11
+ loop.run_until_complete(startup.init_loop())
12
+ loop.run_until_complete(startup.init())
13
+
14
+ try:
15
+ run_loop = loop.run_until_complete(main_func())
16
+ if run_loop:
17
+ loop.run_forever()
18
+ loop.run_until_complete(startup.cleanup())
19
+
20
+ finally:
21
+ try:
22
+ loop.run_until_complete(startup.cleanup_loop())
23
+ except asyncio.CancelledError:
24
+ pass # ignore any cancelled errors
25
+
26
+ loop.close()
27
+
28
+ startup.logger.info("bye!")
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python
2
+
3
+ from qtoggleserver import commands
4
+ from qtoggleserver.web import server as web_server
5
+
6
+
7
+ async def main() -> bool:
8
+ await web_server.init()
9
+
10
+ return True # run loop afterwards
11
+
12
+
13
+ def execute() -> None:
14
+ commands.execute(main)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ execute()
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python
2
+
3
+ import code
4
+
5
+ from qtoggleserver import commands
6
+
7
+
8
+ async def main() -> bool:
9
+ import qtoggleserver # noqa: F401 - Required for locals()
10
+
11
+ code.interact(local=locals())
12
+
13
+ return False # don't run loop afterwards
14
+
15
+
16
+ def execute() -> None:
17
+ commands.execute(main)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ execute()
File without changes
@@ -0,0 +1,158 @@
1
+ from typing import Any as _Any
2
+
3
+
4
+ source: str | None = None # full path to the configuration file, automatically set at startup
5
+
6
+ debug: bool = False
7
+
8
+ public_url: str | None = None
9
+
10
+
11
+ logging: dict[str, _Any] = {
12
+ "version": 1,
13
+ "memory_logs_buffer_len": 10000,
14
+ "formatters": {
15
+ "default": {"format": "%(asctime)s: %(levelname)7s: [%(name)s] %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S"}
16
+ },
17
+ "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "default"}},
18
+ "loggers": {
19
+ # Double quotes are necessary to avoid HOCON key split
20
+ '"asyncio"': {"level": "INFO"},
21
+ '"bleak"': {"level": "INFO"},
22
+ '"qtoggleserver.core.sessions"': {"level": "INFO"},
23
+ '"qtoggleserver.persist"': {"level": "INFO"},
24
+ '"qtoggleserver.drivers.persist"': {"level": "INFO"},
25
+ '"qtoggleserver.utils.cmd"': {"level": "INFO"},
26
+ },
27
+ "root": {"level": "DEBUG", "handlers": ["console"]},
28
+ }
29
+
30
+
31
+ class core:
32
+ class device_name:
33
+ get_cmd: str | None = None
34
+ set_cmd: str | None = None
35
+
36
+ class passwords:
37
+ set_cmd: str | None = None
38
+
39
+ tick_interval: int = 50
40
+ persist_interval: int = 2000
41
+ event_queue_size: int = 1024
42
+ max_client_time_skew: int = 300
43
+ backup_support: bool = True
44
+ history_support: bool = True
45
+ history_janitor_interval: int = 3600
46
+ listen_support: bool = True
47
+ sequences_support: bool = True
48
+ tls_support: bool = True
49
+ virtual_ports: int = 1024
50
+
51
+
52
+ class server:
53
+ addr: str = "0.0.0.0"
54
+ port: int = 8888
55
+ compress_response: bool = True
56
+
57
+ class https:
58
+ cert_file: str | None = None
59
+ key_file: str | None = None
60
+
61
+
62
+ class persist:
63
+ driver: str = "qtoggleserver.drivers.persist.JSONDriver"
64
+ file_path: str = "qtoggleserver-data.json"
65
+
66
+
67
+ class system:
68
+ setup_mode_cmd: str | None = None
69
+
70
+ class date:
71
+ set_cmd: str | None = None
72
+ set_format: str | None = "%Y-%m-%dT%H:%M:%SZ"
73
+
74
+ class timezone:
75
+ get_cmd: str | None = None
76
+ set_cmd: str | None = None
77
+
78
+ class net:
79
+ class wifi:
80
+ get_cmd: str | None = None
81
+ set_cmd: str | None = None
82
+
83
+ class ip:
84
+ get_cmd: str | None = None
85
+ set_cmd: str | None = None
86
+
87
+ class storage:
88
+ path: str | None = None
89
+
90
+ class temperature:
91
+ get_cmd: str | None = None
92
+ sensor_name: str | None = None
93
+ sensor_index: int = 0
94
+ min: int | None = 0
95
+ max: int | None = 100
96
+
97
+ class battery:
98
+ get_cmd: str | None = None
99
+
100
+ class fwupdate:
101
+ driver: str | None = None
102
+
103
+
104
+ class frontend:
105
+ enabled: bool = True
106
+ debug: bool = False
107
+ static_url: str | None = None
108
+ display_name: str = "qToggleServer"
109
+ display_short_name: str = "qToggleServer"
110
+ description: str = "An application to control qToggleServer" # TODO: i18n
111
+
112
+
113
+ class slaves:
114
+ enabled: bool = True
115
+ timeout: int = 10
116
+ long_timeout: int = 60
117
+ keepalive: int = 10
118
+ retry_interval: int = 5
119
+ retry_count: int = 3
120
+
121
+ class discover:
122
+ request_timeout: int = 5
123
+ dhcp_timeout: int = 10
124
+ dhcp_interface: str | None = None
125
+
126
+ class ap:
127
+ interface: str | None = None
128
+ interface_cmd: str | None = None
129
+ ssid: str = "qToggleSetup"
130
+ psk: str | None = None
131
+ own_ip: str = "192.168.43.1"
132
+ mask_len: int = 24
133
+ start_ip: str = "192.168.43.50"
134
+ stop_ip: str = "192.168.43.250"
135
+ hostapd_binary: str | None = None
136
+ hostapd_cli_binary: str | None = None
137
+ dnsmasq_binary: str | None = None
138
+ hostapd_log: str = "/tmp/hostapd.log"
139
+ dnsmasq_log: str = "/tmp/dnsmasq.log"
140
+ finish_timeout: int = 300
141
+
142
+
143
+ class webhooks:
144
+ enabled: bool = False
145
+
146
+
147
+ class reverse:
148
+ enabled: bool = False
149
+ retry_interval: int = 5
150
+
151
+
152
+ event_handlers: list[dict[str, _Any]] = []
153
+
154
+ peripherals: list[dict[str, _Any]] = []
155
+
156
+ ports: list[dict[str, _Any]] = []
157
+
158
+ port_mappings: dict[str, str] = {}
File without changes
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from qtoggleserver.core import responses as core_responses
10
+ from qtoggleserver.core.typing import GenericJSONDict
11
+ from qtoggleserver.web import APIHandler
12
+
13
+
14
+ API_VERSION = "1.1"
15
+
16
+ ACCESS_LEVEL_ADMIN = 30
17
+ ACCESS_LEVEL_NORMAL = 20
18
+ ACCESS_LEVEL_VIEWONLY = 10
19
+ ACCESS_LEVEL_NONE = 0
20
+
21
+ ACCESS_LEVEL_MAPPING = {
22
+ ACCESS_LEVEL_ADMIN: "admin",
23
+ ACCESS_LEVEL_NORMAL: "normal",
24
+ ACCESS_LEVEL_VIEWONLY: "viewonly",
25
+ ACCESS_LEVEL_NONE: "none",
26
+ "admin": ACCESS_LEVEL_ADMIN,
27
+ "normal": ACCESS_LEVEL_NORMAL,
28
+ "viewonly": ACCESS_LEVEL_VIEWONLY,
29
+ "none": ACCESS_LEVEL_NONE,
30
+ }
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class APIError(Exception):
36
+ def __init__(self, status: int, code: str, **params) -> None:
37
+ self.status: int = status
38
+ self.code: str = code
39
+ self.params: dict = params
40
+
41
+ super().__init__(code)
42
+
43
+ @staticmethod
44
+ def from_http_error(http_error: core_responses.HTTPError) -> APIError:
45
+ return APIError(http_error.status, http_error.code, **http_error.params)
46
+
47
+ def to_json(self) -> GenericJSONDict:
48
+ return dict(error=self.code, **self.params)
49
+
50
+
51
+ class APIAccepted(Exception):
52
+ def __init__(self, response: Any = None) -> None:
53
+ self.response: Any = response
54
+
55
+
56
+ class APIRequest:
57
+ def __init__(self, handler: APIHandler) -> None:
58
+ self.handler: APIHandler = handler
59
+
60
+ @property
61
+ def access_level(self) -> int:
62
+ return self.handler.access_level
63
+
64
+ @property
65
+ def username(self) -> str:
66
+ return self.handler.username
67
+
68
+ @property
69
+ def session_id(self) -> str | None:
70
+ return self.handler.request.headers.get("Session-Id")
71
+
72
+ @property
73
+ def method(self) -> str:
74
+ return self.handler.request.method
75
+
76
+ @property
77
+ def path(self) -> str:
78
+ return self.handler.request.path
79
+
80
+ @property
81
+ def query(self) -> dict[str, str]:
82
+ return {k: self.handler.decode_argument(v[0]) for k, v in self.handler.request.query_arguments.items()}
83
+
84
+ @property
85
+ def headers(self) -> dict[str, str]:
86
+ return self.handler.request.headers
87
+
88
+ @property
89
+ def body(self) -> bytes:
90
+ return self.handler.request.body
91
+
92
+
93
+ def api_call(access_level: int = ACCESS_LEVEL_NONE) -> Callable:
94
+ def decorator(func: Callable) -> Callable:
95
+ @functools.wraps(func)
96
+ def wrapper(request_handler: APIHandler, *args, **kwargs) -> Any:
97
+ logger.debug('executing API call "%s"', func.__name__)
98
+
99
+ if request_handler.access_level < access_level:
100
+ if request_handler.access_level == ACCESS_LEVEL_NONE: # indicates missing or invalid auth data
101
+ raise APIError(401, "authentication-required")
102
+ else:
103
+ raise APIError(403, "forbidden", required_level=ACCESS_LEVEL_MAPPING.get(access_level))
104
+
105
+ request = APIRequest(request_handler)
106
+
107
+ return func(request, *args, **kwargs)
108
+
109
+ return wrapper
110
+
111
+ return decorator
@@ -0,0 +1,105 @@
1
+ import hashlib
2
+ import logging
3
+ import re
4
+ import time
5
+
6
+ from collections.abc import Callable
7
+
8
+ import jwt
9
+
10
+ from qtoggleserver import system
11
+ from qtoggleserver.conf import settings
12
+ from qtoggleserver.core.device import attrs as core_device_attrs
13
+
14
+
15
+ JWT_ISS = "qToggle"
16
+ JWT_ALG = "HS256"
17
+
18
+ ORIGIN_DEVICE = "device"
19
+ ORIGIN_CONSUMER = "consumer"
20
+
21
+ EMPTY_PASSWORD_HASH = hashlib.sha256(b"").hexdigest()
22
+
23
+ _AUTH_TOKEN_RE = re.compile(r"^Bearer\s+([a-z0-9_.-]+)$", re.IGNORECASE)
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class AuthError(Exception):
29
+ pass
30
+
31
+
32
+ def make_auth_header(origin: str, username: str | None, password_hash: str) -> str:
33
+ claims: dict[str, str | int] = {"iss": JWT_ISS, "ori": origin}
34
+
35
+ if username:
36
+ claims["usr"] = username
37
+
38
+ if system.date.has_real_date_time():
39
+ claims["iat"] = int(time.time())
40
+
41
+ token = jwt.encode(claims, key=password_hash or "", algorithm=JWT_ALG)
42
+
43
+ return f"Bearer {token}"
44
+
45
+
46
+ def parse_auth_header(auth: str, origin: str, password_hash_func: Callable, require_usr: bool = True) -> str:
47
+ m = _AUTH_TOKEN_RE.match(auth)
48
+ if not m:
49
+ raise AuthError("Invalid authorization header")
50
+
51
+ # Decode but don't validate token yet
52
+ token = m.group(1)
53
+
54
+ try:
55
+ payload = jwt.decode(
56
+ token, algorithms=[JWT_ALG], options={"verify_signature": False}, leeway=settings.core.max_client_time_skew
57
+ )
58
+ except jwt.exceptions.InvalidTokenError as e:
59
+ raise AuthError(f"Invalid JWT: {e}") from e
60
+
61
+ # Validate claims
62
+ if payload.get("iss") != JWT_ISS:
63
+ raise AuthError("Missing or invalid iss claim in JWT")
64
+
65
+ if payload.get("ori") != origin:
66
+ raise AuthError("Missing or invalid ori claim in JWT")
67
+
68
+ iat = payload.get("iat")
69
+ if (iat is not None) and system.date.has_real_date_time():
70
+ delta = time.time() - iat
71
+ if abs(delta) > settings.core.max_client_time_skew:
72
+ raise AuthError("JWT too old or too much in the future")
73
+
74
+ usr = payload.get("usr")
75
+ if require_usr:
76
+ if not usr or not isinstance(usr, str):
77
+ raise AuthError("Missing or invalid usr claim in JWT")
78
+
79
+ # Validate username & signature
80
+ password_hash = password_hash_func(usr)
81
+ if not password_hash:
82
+ raise AuthError(f"Unknown usr in JWT: {usr}")
83
+
84
+ # Decode again to verify signature
85
+ try:
86
+ jwt.decode(
87
+ token, key=password_hash, algorithms=[JWT_ALG], verify=True, leeway=settings.core.max_client_time_skew
88
+ )
89
+ except jwt.exceptions.InvalidSignatureError as e:
90
+ raise AuthError("Invalid JWT signature") from e
91
+ except jwt.exceptions.InvalidTokenError as e:
92
+ raise AuthError(f"Invalid JWT: {e}") from e
93
+
94
+ return usr
95
+
96
+
97
+ def consumer_password_hash_func(usr: str) -> str | None:
98
+ if usr == "admin":
99
+ return core_device_attrs.admin_password_hash
100
+ elif usr == "normal":
101
+ return core_device_attrs.normal_password_hash
102
+ elif usr == "viewonly":
103
+ return core_device_attrs.viewonly_password_hash
104
+ else:
105
+ return None
File without changes
@@ -0,0 +1,26 @@
1
+ import logging
2
+
3
+ from qtoggleserver.conf import settings
4
+ from qtoggleserver.core import api as core_api
5
+ from qtoggleserver.core.typing import GenericJSONList
6
+ from qtoggleserver.system import conf as system_conf
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
13
+ async def get_backup_endpoints(request: core_api.APIRequest) -> GenericJSONList:
14
+ endpoints = [{"path": "/peripherals", "display_name": "Peripherals", "restore_method": "PUT", "order": 15}]
15
+
16
+ if system_conf.can_write_conf_file():
17
+ endpoints.append(
18
+ {"path": "/system", "display_name": "System Configuration", "restore_method": "PUT", "order": 5}
19
+ )
20
+
21
+ if settings.frontend.enabled:
22
+ endpoints.append(
23
+ {"path": "/frontend", "display_name": "App Configuration", "restore_method": "PUT", "order": 45}
24
+ )
25
+
26
+ return endpoints
@@ -0,0 +1,68 @@
1
+ from qtoggleserver import system
2
+ from qtoggleserver.core import api as core_api
3
+ from qtoggleserver.core import device as core_device
4
+ from qtoggleserver.core import main
5
+ from qtoggleserver.core.api import schema as core_api_schema
6
+ from qtoggleserver.core.device import attrs as core_device_attrs
7
+ from qtoggleserver.core.device import events as core_device_events
8
+ from qtoggleserver.core.typing import Attributes
9
+
10
+
11
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
12
+ async def get_device(request: core_api.APIRequest) -> Attributes:
13
+ return await core_device_attrs.to_json()
14
+
15
+
16
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
17
+ async def put_device(request: core_api.APIRequest, params: Attributes) -> None:
18
+ core_api_schema.validate(params, core_device_attrs.get_schema(loose=True))
19
+
20
+ # Password fields must explicitly be ignored, so we pop them from supplied data
21
+ for f in ("admin", "normal", "viewonly"):
22
+ params.pop(f"{f}_password", None)
23
+
24
+ # Ignore the date attribute
25
+ params.pop("date", None)
26
+
27
+ # Reset device attributes
28
+ await core_device.reset(preserve_attrs=["admin_password_hash", "normal_password_hash", "viewonly_password_hash"])
29
+ await core_device.load()
30
+
31
+ try:
32
+ await core_device_attrs.set_attrs(params, ignore_extra=True)
33
+ except core_device_attrs.DeviceAttributeError as e:
34
+ raise core_api.APIError(400, e.error, attribute=e.attribute)
35
+ except Exception as e:
36
+ raise core_api.APIError(500, "unexpected-error", message=str(e)) from e
37
+
38
+ await core_device.save()
39
+ await core_device_events.trigger_update()
40
+
41
+
42
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
43
+ async def patch_device(request: core_api.APIRequest, params: Attributes) -> None:
44
+ def unexpected_field_code(field: str) -> str:
45
+ if field in core_device_attrs.get_attrdefs():
46
+ return "attribute-not-modifiable"
47
+ else:
48
+ return "no-such-attribute"
49
+
50
+ core_api_schema.validate(
51
+ params,
52
+ core_device_attrs.get_schema(),
53
+ unexpected_field_code=unexpected_field_code,
54
+ unexpected_field_name="attribute",
55
+ )
56
+
57
+ try:
58
+ reboot_required = await core_device_attrs.set_attrs(params)
59
+ except core_device_attrs.DeviceAttributeError as e:
60
+ raise core_api.APIError(400, e.error, attribute=e.attribute)
61
+ except Exception as e:
62
+ raise core_api.APIError(500, "unexpected-error", message=str(e)) from e
63
+
64
+ await core_device.save()
65
+ await core_device_events.trigger_update()
66
+
67
+ if reboot_required:
68
+ main.loop.call_later(2, system.reboot)
@@ -0,0 +1,47 @@
1
+ import logging
2
+
3
+ from qtoggleserver.core import api as core_api
4
+ from qtoggleserver.core.api import schema as core_api_schema
5
+ from qtoggleserver.core.typing import GenericJSONDict
6
+ from qtoggleserver.system import fwupdate
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
13
+ async def get_firmware(request: core_api.APIRequest) -> GenericJSONDict:
14
+ current_version = await fwupdate.get_current_version()
15
+ status = await fwupdate.get_status()
16
+
17
+ if status in (fwupdate.STATUS_IDLE, fwupdate.STATUS_ERROR):
18
+ try:
19
+ latest_version, latest_date, latest_url = await fwupdate.get_latest()
20
+
21
+ return {
22
+ "version": current_version,
23
+ "latest_version": latest_version,
24
+ "latest_date": latest_date,
25
+ "latest_url": latest_url,
26
+ "status": status,
27
+ }
28
+ except Exception as e:
29
+ logger.error("get latest firmware failed: %s", e, exc_info=True)
30
+
31
+ return {"version": current_version, "status": status}
32
+ else:
33
+ return {"version": current_version, "status": status}
34
+
35
+
36
+ @core_api.api_call(core_api.ACCESS_LEVEL_ADMIN)
37
+ async def patch_firmware(request: core_api.APIRequest, params: GenericJSONDict) -> None:
38
+ core_api_schema.validate(params, core_api_schema.PATCH_FIRMWARE)
39
+
40
+ status = await fwupdate.get_status()
41
+ if status not in (fwupdate.STATUS_IDLE, fwupdate.STATUS_ERROR):
42
+ raise core_api.APIError(503, "busy")
43
+
44
+ if params.get("url"):
45
+ await fwupdate.update_to_url(params["url"])
46
+ else: # assuming params['version']
47
+ await fwupdate.update_to_version(params["version"])