goosebit 0.2.4__py3-none-any.whl → 0.2.6__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.
- goosebit/__init__.py +56 -6
- goosebit/api/telemetry/metrics.py +1 -5
- goosebit/api/v1/devices/device/responses.py +1 -0
- goosebit/api/v1/devices/device/routes.py +8 -8
- goosebit/api/v1/devices/requests.py +20 -0
- goosebit/api/v1/devices/routes.py +83 -8
- goosebit/api/v1/download/routes.py +14 -3
- goosebit/api/v1/rollouts/routes.py +5 -4
- goosebit/api/v1/routes.py +2 -1
- goosebit/api/v1/settings/routes.py +14 -0
- goosebit/api/v1/settings/users/__init__.py +1 -0
- goosebit/api/v1/settings/users/requests.py +16 -0
- goosebit/api/v1/settings/users/responses.py +7 -0
- goosebit/api/v1/settings/users/routes.py +56 -0
- goosebit/api/v1/software/routes.py +18 -14
- goosebit/auth/__init__.py +54 -14
- goosebit/auth/permissions.py +80 -0
- goosebit/db/config.py +57 -1
- goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
- goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
- goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
- goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
- goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
- goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
- goosebit/db/models.py +22 -7
- goosebit/db/pg_ssl_context.py +51 -0
- goosebit/device_manager.py +262 -0
- goosebit/plugins/__init__.py +32 -0
- goosebit/schema/devices.py +9 -6
- goosebit/schema/plugins.py +67 -0
- goosebit/schema/updates.py +15 -0
- goosebit/schema/users.py +9 -0
- goosebit/settings/__init__.py +0 -3
- goosebit/settings/schema.py +62 -14
- goosebit/storage/__init__.py +62 -0
- goosebit/storage/base.py +14 -0
- goosebit/storage/filesystem.py +111 -0
- goosebit/storage/s3.py +104 -0
- goosebit/ui/bff/common/columns.py +50 -0
- goosebit/ui/bff/common/requests.py +3 -15
- goosebit/ui/bff/common/responses.py +17 -0
- goosebit/ui/bff/devices/device/__init__.py +1 -0
- goosebit/ui/bff/devices/device/routes.py +17 -0
- goosebit/ui/bff/devices/requests.py +1 -0
- goosebit/ui/bff/devices/responses.py +6 -2
- goosebit/ui/bff/devices/routes.py +71 -17
- goosebit/ui/bff/download/routes.py +14 -3
- goosebit/ui/bff/rollouts/responses.py +6 -2
- goosebit/ui/bff/rollouts/routes.py +32 -4
- goosebit/ui/bff/routes.py +6 -3
- goosebit/ui/bff/settings/__init__.py +1 -0
- goosebit/ui/bff/settings/routes.py +20 -0
- goosebit/ui/bff/settings/users/__init__.py +1 -0
- goosebit/ui/bff/settings/users/responses.py +33 -0
- goosebit/ui/bff/settings/users/routes.py +80 -0
- goosebit/ui/bff/software/responses.py +19 -9
- goosebit/ui/bff/software/routes.py +40 -12
- goosebit/ui/nav.py +12 -2
- goosebit/ui/routes.py +70 -26
- goosebit/ui/static/js/devices.js +72 -80
- goosebit/ui/static/js/login.js +21 -5
- goosebit/ui/static/js/logs.js +7 -22
- goosebit/ui/static/js/rollouts.js +39 -35
- goosebit/ui/static/js/settings.js +322 -0
- goosebit/ui/static/js/setup.js +28 -0
- goosebit/ui/static/js/software.js +127 -127
- goosebit/ui/static/js/util.js +45 -4
- goosebit/ui/templates/__init__.py +10 -1
- goosebit/ui/templates/devices.html.jinja +0 -20
- goosebit/ui/templates/login.html.jinja +5 -0
- goosebit/ui/templates/nav.html.jinja +26 -7
- goosebit/ui/templates/rollouts.html.jinja +4 -22
- goosebit/ui/templates/settings.html.jinja +88 -0
- goosebit/ui/templates/setup.html.jinja +71 -0
- goosebit/ui/templates/software.html.jinja +0 -11
- goosebit/updater/controller/v1/routes.py +120 -72
- goosebit/updater/routes.py +86 -7
- goosebit/updates/__init__.py +24 -31
- goosebit/updates/swdesc.py +15 -8
- goosebit/users/__init__.py +63 -0
- goosebit/util/__init__.py +0 -0
- goosebit/util/path.py +42 -0
- goosebit/util/version.py +92 -0
- goosebit-0.2.6.dist-info/METADATA +280 -0
- goosebit-0.2.6.dist-info/RECORD +133 -0
- {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
- goosebit-0.2.6.dist-info/entry_points.txt +3 -0
- goosebit/realtime/logs.py +0 -42
- goosebit/realtime/routes.py +0 -13
- goosebit/ui/static/js/index.js +0 -155
- goosebit/ui/templates/index.html.jinja +0 -25
- goosebit/updater/manager.py +0 -357
- goosebit-0.2.4.dist-info/METADATA +0 -181
- goosebit-0.2.4.dist-info/RECORD +0 -98
- /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
- {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
@@ -2,11 +2,17 @@ import logging
|
|
2
2
|
|
3
3
|
from fastapi import APIRouter, Depends, HTTPException
|
4
4
|
from fastapi.requests import Request
|
5
|
-
from fastapi.responses import
|
5
|
+
from fastapi.responses import (
|
6
|
+
FileResponse,
|
7
|
+
RedirectResponse,
|
8
|
+
Response,
|
9
|
+
StreamingResponse,
|
10
|
+
)
|
6
11
|
|
7
|
-
from goosebit.db.models import Software, UpdateStateEnum
|
12
|
+
from goosebit.db.models import Device, Software, UpdateStateEnum
|
13
|
+
from goosebit.device_manager import DeviceManager, HandlingType, get_device
|
8
14
|
from goosebit.settings import config
|
9
|
-
from goosebit.
|
15
|
+
from goosebit.storage import storage
|
10
16
|
from goosebit.updates import generate_chunk
|
11
17
|
|
12
18
|
from .schema import (
|
@@ -22,48 +28,64 @@ router = APIRouter(prefix="/v1")
|
|
22
28
|
|
23
29
|
|
24
30
|
@router.get("/{dev_id}")
|
25
|
-
async def polling(request: Request,
|
31
|
+
async def polling(request: Request, device: Device = Depends(get_device)):
|
26
32
|
links: dict[str, dict[str, str]] = {}
|
27
33
|
|
28
|
-
sleep = updater.poll_time
|
29
|
-
device = await updater.get_device()
|
30
|
-
|
31
34
|
if device is None:
|
32
35
|
raise HTTPException(404)
|
33
36
|
|
37
|
+
sleep = config.poll_time
|
38
|
+
|
34
39
|
if device.last_state == UpdateStateEnum.UNKNOWN:
|
35
|
-
# device registration
|
36
|
-
sleep =
|
40
|
+
# device registration: force device to poll again in 10s. After registration, an update might be available
|
41
|
+
sleep = "00:00:10"
|
37
42
|
links["configData"] = {
|
38
43
|
"href": str(
|
39
44
|
request.url_for(
|
40
45
|
"config_data",
|
41
|
-
dev_id=
|
46
|
+
dev_id=device.id,
|
42
47
|
)
|
43
48
|
)
|
44
49
|
}
|
45
|
-
logger.info(f"Skip: registration required, device={
|
50
|
+
logger.info(f"Skip: registration required, device={device.id}")
|
46
51
|
|
47
52
|
elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
|
48
|
-
logger.
|
49
|
-
pass
|
53
|
+
logger.info(f"Skip: device in error state, device={device.id}")
|
50
54
|
|
51
55
|
else:
|
52
56
|
# provide update if available. Note: this is also required while in state "running", otherwise swupdate
|
53
57
|
# won't confirm a successful testing (might be a bug/problem in swupdate)
|
54
|
-
handling_type, software = await
|
58
|
+
handling_type, software = await DeviceManager.get_update(device)
|
55
59
|
if handling_type != HandlingType.SKIP and software is not None:
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
60
|
+
number_of_running = await Device.filter(last_state=UpdateStateEnum.RUNNING).count()
|
61
|
+
if number_of_running < config.max_concurrent_updates or device.last_state == UpdateStateEnum.RUNNING:
|
62
|
+
links["deploymentBase"] = {
|
63
|
+
"href": str(
|
64
|
+
request.url_for(
|
65
|
+
"deployment_base",
|
66
|
+
dev_id=device.id,
|
67
|
+
action_id=software.id,
|
68
|
+
)
|
62
69
|
)
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
70
|
+
}
|
71
|
+
logger.info(f"Forced: update available, device={device.id}")
|
72
|
+
else:
|
73
|
+
number_of_running = await Device.filter(last_state=UpdateStateEnum.RUNNING).count()
|
74
|
+
if number_of_running < config.max_concurrent_updates or device.last_state == UpdateStateEnum.RUNNING:
|
75
|
+
plugin_sources = await DeviceManager.get_alt_src_updates(request, device)
|
76
|
+
for handling_type, _ in plugin_sources:
|
77
|
+
if handling_type == HandlingType.SKIP:
|
78
|
+
continue
|
79
|
+
links["deploymentBase"] = {
|
80
|
+
"href": str(
|
81
|
+
request.url_for(
|
82
|
+
"deployment_base",
|
83
|
+
dev_id=device.id,
|
84
|
+
action_id=-1, # custom plugin
|
85
|
+
)
|
86
|
+
)
|
87
|
+
}
|
88
|
+
break
|
67
89
|
return {
|
68
90
|
"config": {"polling": {"sleep": sleep}},
|
69
91
|
"_links": links,
|
@@ -71,9 +93,9 @@ async def polling(request: Request, dev_id: str, updater: UpdateManager = Depend
|
|
71
93
|
|
72
94
|
|
73
95
|
@router.put("/{dev_id}/configData")
|
74
|
-
async def config_data(_: Request, cfg: ConfigDataSchema,
|
75
|
-
await
|
76
|
-
logger.info(f"Updating config data, device={
|
96
|
+
async def config_data(_: Request, cfg: ConfigDataSchema, device: Device = Depends(get_device)):
|
97
|
+
await DeviceManager.update_config_data(device, **cfg.data)
|
98
|
+
logger.info(f"Updating config data, device={device.id}")
|
77
99
|
return {"success": True, "message": "Updated swupdate data."}
|
78
100
|
|
79
101
|
|
@@ -81,91 +103,106 @@ async def config_data(_: Request, cfg: ConfigDataSchema, updater: UpdateManager
|
|
81
103
|
async def deployment_base(
|
82
104
|
request: Request,
|
83
105
|
action_id: int,
|
84
|
-
|
106
|
+
device: Device = Depends(get_device),
|
85
107
|
):
|
86
|
-
handling_type, software = await
|
87
|
-
|
88
|
-
logger.info(f"Request deployment base, device={
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
108
|
+
handling_type, software = await DeviceManager.get_update(device)
|
109
|
+
|
110
|
+
logger.info(f"Request deployment base, device={device.id}")
|
111
|
+
if not handling_type == HandlingType.SKIP:
|
112
|
+
return {
|
113
|
+
"id": str(action_id),
|
114
|
+
"deployment": {
|
115
|
+
"download": str(handling_type),
|
116
|
+
"update": str(handling_type),
|
117
|
+
"chunks": [chunk.model_dump(by_alias=True) for chunk in (await generate_chunk(request, device))],
|
118
|
+
},
|
119
|
+
}
|
120
|
+
else:
|
121
|
+
plugin_sources = await DeviceManager.get_alt_src_updates(request, device)
|
122
|
+
for handling_type, chunk in plugin_sources:
|
123
|
+
if handling_type == HandlingType.SKIP or chunk is None:
|
124
|
+
continue
|
125
|
+
return {
|
126
|
+
"id": str(action_id),
|
127
|
+
"deployment": {
|
128
|
+
"download": str(handling_type),
|
129
|
+
"update": str(handling_type),
|
130
|
+
"chunks": [chunk.model_dump(by_alias=True)],
|
131
|
+
},
|
132
|
+
}
|
98
133
|
|
99
134
|
|
100
135
|
@router.post("/{dev_id}/deploymentBase/{action_id}/feedback")
|
101
|
-
async def deployment_feedback(
|
102
|
-
_: Request, data: FeedbackSchema, action_id: int, updater: UpdateManager = Depends(get_update_manager)
|
103
|
-
):
|
136
|
+
async def deployment_feedback(_: Request, data: FeedbackSchema, action_id: int, device: Device = Depends(get_device)):
|
104
137
|
if data.status.execution == FeedbackStatusExecutionState.PROCEEDING:
|
105
|
-
|
106
|
-
|
138
|
+
if device and device.last_state != UpdateStateEnum.RUNNING:
|
139
|
+
await DeviceManager.deployment_action_start(device)
|
140
|
+
await DeviceManager.update_device_state(device, UpdateStateEnum.RUNNING)
|
141
|
+
|
142
|
+
logger.debug(f"Installation in progress, device={device.id}")
|
107
143
|
|
108
144
|
elif data.status.execution == FeedbackStatusExecutionState.CLOSED:
|
109
|
-
await
|
110
|
-
await updater.update_log_complete(True)
|
145
|
+
await DeviceManager.update_force_update(device, False)
|
111
146
|
|
112
147
|
reported_software = await Software.get_or_none(id=action_id)
|
113
148
|
|
114
149
|
# From hawkBit docu: DDI defines also a status NONE which will not be interpreted by the update server
|
115
150
|
# and handled like SUCCESS.
|
116
151
|
if data.status.result.finished in [FeedbackStatusResultFinished.SUCCESS, FeedbackStatusResultFinished.NONE]:
|
117
|
-
await
|
152
|
+
await DeviceManager.deployment_action_success(device)
|
153
|
+
await DeviceManager.update_device_state(device, UpdateStateEnum.FINISHED)
|
118
154
|
|
119
|
-
|
120
|
-
rollout = await updater.get_rollout()
|
155
|
+
rollout = await DeviceManager.get_rollout(device)
|
121
156
|
if rollout:
|
122
157
|
if rollout.software == reported_software:
|
123
158
|
rollout.success_count += 1
|
124
159
|
await rollout.save()
|
125
160
|
else:
|
161
|
+
# edge case where device update mode got changed while update was running
|
126
162
|
logging.warning(
|
127
|
-
f"Updating rollout success stats failed,
|
163
|
+
f"Updating rollout success stats failed, action_id={action_id}, device={device.id}"
|
164
|
+
# noqa: E501
|
128
165
|
)
|
129
166
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
logger.debug(f"Installation successful, software={
|
167
|
+
if reported_software:
|
168
|
+
await DeviceManager.update_sw_version(device, reported_software.version)
|
169
|
+
|
170
|
+
software_version = reported_software.version if reported_software else None
|
171
|
+
logger.debug(f"Installation successful, software={software_version}, device={device.id}")
|
135
172
|
|
136
173
|
elif data.status.result.finished == FeedbackStatusResultFinished.FAILURE:
|
137
|
-
await
|
174
|
+
await DeviceManager.update_device_state(device, UpdateStateEnum.ERROR)
|
138
175
|
|
139
|
-
|
140
|
-
rollout = await updater.get_rollout()
|
176
|
+
rollout = await DeviceManager.get_rollout(device)
|
141
177
|
if rollout:
|
142
178
|
if rollout.software == reported_software:
|
143
179
|
rollout.failure_count += 1
|
144
180
|
await rollout.save()
|
145
181
|
else:
|
182
|
+
# edge case where device update mode got changed while update was running
|
146
183
|
logging.warning(
|
147
|
-
f"Updating rollout failure stats failed,
|
184
|
+
f"Updating rollout failure stats failed, action_id={action_id}, device={device.id}"
|
185
|
+
# noqa: E501
|
148
186
|
)
|
149
187
|
|
150
|
-
|
188
|
+
software_version = reported_software.version if reported_software else None
|
189
|
+
logger.debug(f"Installation failed, software={software_version}, device={device.id}")
|
151
190
|
else:
|
152
|
-
logging.
|
153
|
-
f"Device reported unhandled execution state, state={data.status.execution}, device={updater.dev_id}"
|
154
|
-
)
|
191
|
+
logging.error(f"Device reported unhandled execution state, state={data.status.execution}, device={device.id}")
|
155
192
|
|
156
193
|
try:
|
157
194
|
log = data.status.details
|
158
195
|
if log is not None:
|
159
|
-
await
|
196
|
+
await DeviceManager.update_log(device, "\n".join(log))
|
160
197
|
except AttributeError:
|
161
|
-
logging.warning(f"No details to update device update log, device={
|
198
|
+
logging.warning(f"No details to update device update log, device={device.id}")
|
162
199
|
|
163
200
|
return {"id": str(action_id)}
|
164
201
|
|
165
202
|
|
166
203
|
@router.head("/{dev_id}/download")
|
167
|
-
async def download_artifact_head(_: Request,
|
168
|
-
_, software = await
|
204
|
+
async def download_artifact_head(_: Request, device: Device = Depends(get_device)):
|
205
|
+
_, software = await DeviceManager.get_update(device)
|
169
206
|
if software is None:
|
170
207
|
raise HTTPException(404)
|
171
208
|
|
@@ -175,15 +212,26 @@ async def download_artifact_head(_: Request, updater: UpdateManager = Depends(ge
|
|
175
212
|
|
176
213
|
|
177
214
|
@router.get("/{dev_id}/download")
|
178
|
-
async def download_artifact(_: Request,
|
179
|
-
_, software = await
|
215
|
+
async def download_artifact(_: Request, device: Device = Depends(get_device)):
|
216
|
+
_, software = await DeviceManager.get_update(device)
|
180
217
|
if software is None:
|
181
218
|
raise HTTPException(404)
|
219
|
+
|
182
220
|
if software.local:
|
183
221
|
return FileResponse(
|
184
222
|
software.path,
|
185
223
|
media_type="application/octet-stream",
|
186
224
|
filename=software.path.name,
|
187
225
|
)
|
188
|
-
|
189
|
-
|
226
|
+
|
227
|
+
try:
|
228
|
+
url = await storage.get_download_url(software.uri)
|
229
|
+
return RedirectResponse(url=url)
|
230
|
+
except ValueError:
|
231
|
+
# Fallback to streaming if redirect fails.
|
232
|
+
file_stream = storage.get_file_stream(software.uri)
|
233
|
+
return StreamingResponse(
|
234
|
+
file_stream,
|
235
|
+
media_type="application/octet-stream",
|
236
|
+
headers={"Content-Disposition": f"attachment; filename={software.path.name}"},
|
237
|
+
)
|
goosebit/updater/routes.py
CHANGED
@@ -1,21 +1,100 @@
|
|
1
1
|
import time
|
2
2
|
|
3
|
-
|
3
|
+
import httpx
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException
|
4
5
|
from fastapi.requests import Request
|
5
6
|
|
7
|
+
from goosebit.device_manager import DeviceManager, get_device_or_none
|
8
|
+
from goosebit.settings import config
|
9
|
+
from goosebit.settings.schema import DeviceAuthMode, ExternalAuthMode
|
10
|
+
|
11
|
+
from ..db import Device
|
6
12
|
from . import controller
|
7
|
-
from .manager import get_update_manager
|
8
13
|
|
9
14
|
|
10
15
|
async def log_last_connection(request: Request, dev_id: str):
|
11
|
-
|
12
|
-
|
13
|
-
|
16
|
+
device = await get_device_or_none(dev_id)
|
17
|
+
|
18
|
+
if not device:
|
19
|
+
return
|
20
|
+
|
21
|
+
if request.scope["config"].track_device_ip:
|
22
|
+
await DeviceManager.update_last_connection(device, round(time.time()), request.client.host)
|
23
|
+
else:
|
24
|
+
await DeviceManager.update_last_connection(device, round(time.time()))
|
25
|
+
|
26
|
+
|
27
|
+
async def validate_device_token(request: Request, dev_id: str):
|
28
|
+
if not request.scope["config"].device_auth.enable:
|
29
|
+
return
|
30
|
+
|
31
|
+
# parse device token, needs to be the `TargetToken`
|
32
|
+
device_token = request.headers.get("Authorization")
|
33
|
+
if device_token is not None:
|
34
|
+
if device_token.startswith("TargetToken"):
|
35
|
+
device_token = device_token.replace("TargetToken ", "")
|
36
|
+
else:
|
37
|
+
device_token = None
|
38
|
+
|
39
|
+
# setup mode should register devices and set up their auth token
|
40
|
+
if request.scope["config"].device_auth.mode == DeviceAuthMode.SETUP:
|
41
|
+
device = await DeviceManager.get_device(dev_id)
|
42
|
+
if device_token is None:
|
43
|
+
return
|
44
|
+
await DeviceManager.update_auth_token(device, device_token)
|
45
|
+
|
46
|
+
# lax mode should register devices and check their token if they have one, but not register their tokens
|
47
|
+
elif request.scope["config"].device_auth.mode == DeviceAuthMode.LAX:
|
48
|
+
device = await DeviceManager.get_device(dev_id)
|
49
|
+
# should not be possible
|
50
|
+
assert device is not None
|
51
|
+
|
52
|
+
if not device.auth_token == device_token:
|
53
|
+
raise HTTPException(401, "Device authentication token does not match.")
|
54
|
+
|
55
|
+
# strict mode should ensure all device are already set up and have a token, then check the token
|
56
|
+
elif request.scope["config"].device_auth.mode == DeviceAuthMode.STRICT:
|
57
|
+
if device_token is None:
|
58
|
+
raise HTTPException(401, "Device authentication token is required in strict mode.")
|
59
|
+
# do not create a device in strict mode
|
60
|
+
device = await Device.get_or_none(id=dev_id)
|
61
|
+
if device is None:
|
62
|
+
raise HTTPException(401, "Cannot register a new device in strict mode.")
|
63
|
+
if not device.auth_token == device_token:
|
64
|
+
raise HTTPException(401, "Device authentication token does not match.")
|
65
|
+
|
66
|
+
# external mode should check the token with an external service
|
67
|
+
elif request.scope["config"].device_auth.mode == DeviceAuthMode.EXTERNAL:
|
68
|
+
if device_token is None:
|
69
|
+
raise HTTPException(401, "Device authentication token is required in external mode.")
|
70
|
+
|
71
|
+
try:
|
72
|
+
async with httpx.AsyncClient() as client:
|
73
|
+
if request.scope["config"].device_auth.external_mode == ExternalAuthMode.BEARER:
|
74
|
+
response = await client.post(
|
75
|
+
request.scope["config"].device_auth.external_url,
|
76
|
+
headers={"Authorization": f"Bearer {device_token}"},
|
77
|
+
)
|
78
|
+
elif request.scope["config"].device_auth.external_mode == ExternalAuthMode.JSON:
|
79
|
+
json = {request.scope["config"].device_auth.external_json_key: device_token}
|
80
|
+
response = await client.post(
|
81
|
+
request.scope["config"].device_auth.external_url,
|
82
|
+
json=json,
|
83
|
+
)
|
84
|
+
|
85
|
+
if response.status_code != 200:
|
86
|
+
raise HTTPException(401, "Device authentication token is invalid.")
|
87
|
+
else:
|
88
|
+
await DeviceManager.get_device(dev_id)
|
89
|
+
except httpx.RequestError as e:
|
90
|
+
raise HTTPException(401, f"Error communicating with authentication service: {str(e)}")
|
91
|
+
except Exception as e:
|
92
|
+
raise HTTPException(401, f"Error: {str(e)}")
|
14
93
|
|
15
94
|
|
16
95
|
router = APIRouter(
|
17
|
-
prefix="/
|
18
|
-
dependencies=[Depends(log_last_connection)],
|
96
|
+
prefix=f"/{config.tenant}",
|
97
|
+
dependencies=[Depends(log_last_connection), Depends(validate_device_token)],
|
19
98
|
tags=["ddi"],
|
20
99
|
)
|
21
100
|
router.include_router(controller.router)
|
goosebit/updates/__init__.py
CHANGED
@@ -8,10 +8,11 @@ from fastapi import HTTPException
|
|
8
8
|
from fastapi.requests import Request
|
9
9
|
from tortoise.expressions import Q
|
10
10
|
|
11
|
-
from goosebit.db.models import Hardware, Software
|
12
|
-
from goosebit.
|
11
|
+
from goosebit.db.models import Device, Hardware, Software
|
12
|
+
from goosebit.device_manager import DeviceManager
|
13
|
+
from goosebit.schema.updates import UpdateChunk, UpdateChunkArtifact
|
14
|
+
from goosebit.storage import storage
|
13
15
|
|
14
|
-
from ..settings import config
|
15
16
|
from . import swdesc
|
16
17
|
|
17
18
|
|
@@ -48,11 +49,9 @@ async def create_software_update(uri: str, temp_file: Path | None) -> Software:
|
|
48
49
|
if temp_file is None:
|
49
50
|
raise HTTPException(500, "Temporary file missing, cannot parse file information")
|
50
51
|
filename = Path(url2pathname(unquote(parsed_uri.path))).name
|
51
|
-
|
52
|
-
|
53
|
-
await
|
54
|
-
absolute = await path.absolute()
|
55
|
-
uri = absolute.as_uri()
|
52
|
+
|
53
|
+
dest_path = Path(update_info["hash"]).joinpath(filename)
|
54
|
+
uri = await storage.store_file(temp_file, dest_path)
|
56
55
|
|
57
56
|
# create software
|
58
57
|
software = await Software.create(
|
@@ -92,31 +91,25 @@ async def _is_software_colliding(update_info):
|
|
92
91
|
return is_colliding
|
93
92
|
|
94
93
|
|
95
|
-
async def generate_chunk(request: Request,
|
96
|
-
_, software = await
|
94
|
+
async def generate_chunk(request: Request, device: Device) -> list[UpdateChunk]:
|
95
|
+
_, software = await DeviceManager.get_update(device)
|
97
96
|
if software is None:
|
98
97
|
return []
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
)
|
105
|
-
)
|
106
|
-
else:
|
107
|
-
href = software.uri
|
98
|
+
|
99
|
+
# Always use the download endpoint for consistency, the endpoint
|
100
|
+
# will handle both local and remote files appropriately.
|
101
|
+
href = str(request.url_for("download_artifact", dev_id=device.id))
|
102
|
+
|
108
103
|
return [
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
"
|
117
|
-
|
118
|
-
"_links": {"download": {"href": href}},
|
119
|
-
}
|
104
|
+
UpdateChunk(
|
105
|
+
name=software.path.name,
|
106
|
+
artifacts=[
|
107
|
+
UpdateChunkArtifact(
|
108
|
+
filename=software.path.name,
|
109
|
+
hashes={"sha1": software.hash},
|
110
|
+
size=software.size,
|
111
|
+
links={"download": {"href": href}},
|
112
|
+
)
|
120
113
|
],
|
121
|
-
|
114
|
+
)
|
122
115
|
]
|
goosebit/updates/swdesc.py
CHANGED
@@ -6,15 +6,17 @@ from typing import Any
|
|
6
6
|
|
7
7
|
import httpx
|
8
8
|
import libconf
|
9
|
-
import semver
|
10
9
|
from anyio import AsyncFile, Path, open_file
|
11
10
|
|
12
|
-
from goosebit.
|
11
|
+
from goosebit.storage import storage
|
12
|
+
from goosebit.util.version import Version
|
13
13
|
|
14
14
|
logger = logging.getLogger(__name__)
|
15
15
|
|
16
16
|
|
17
17
|
def _append_compatibility(boardname, value, compatibility):
|
18
|
+
if not isinstance(value, dict):
|
19
|
+
return
|
18
20
|
if "hardware-compatibility" in value:
|
19
21
|
for revision in value["hardware-compatibility"]:
|
20
22
|
compatibility.append({"hw_model": boardname, "hw_revision": revision})
|
@@ -23,7 +25,7 @@ def _append_compatibility(boardname, value, compatibility):
|
|
23
25
|
def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]):
|
24
26
|
swdesc_attrs = {}
|
25
27
|
try:
|
26
|
-
swdesc_attrs["version"] =
|
28
|
+
swdesc_attrs["version"] = Version.parse(swdesc["software"]["version"])
|
27
29
|
compatibility: list[dict[str, str]] = []
|
28
30
|
_append_compatibility("default", swdesc["software"], compatibility)
|
29
31
|
|
@@ -35,6 +37,10 @@ def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]):
|
|
35
37
|
for key2 in element:
|
36
38
|
_append_compatibility(key, element[key2], compatibility)
|
37
39
|
|
40
|
+
if len(compatibility) == 0:
|
41
|
+
# if nothing is specified, assume compatibility with default / default boards
|
42
|
+
compatibility.append({"hw_model": "default", "hw_revision": "default"})
|
43
|
+
|
38
44
|
swdesc_attrs["compatibility"] = compatibility
|
39
45
|
except KeyError as e:
|
40
46
|
logging.warning(f"Parsing swu descriptor failed, error={e}")
|
@@ -71,15 +77,16 @@ async def parse_file(file: Path):
|
|
71
77
|
async def parse_remote(url: str):
|
72
78
|
async with httpx.AsyncClient() as c:
|
73
79
|
file = await c.get(url)
|
74
|
-
|
75
|
-
tmp_file_path =
|
76
|
-
await tmp_file_path.parent.mkdir(parents=True, exist_ok=True)
|
80
|
+
temp_dir = Path(storage.get_temp_dir())
|
81
|
+
tmp_file_path = temp_dir.joinpath("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp")
|
77
82
|
try:
|
78
83
|
async with await open_file(tmp_file_path, "w+b") as f:
|
79
84
|
await f.write(file.content)
|
80
|
-
file_data = await parse_file(Path
|
85
|
+
file_data = await parse_file(tmp_file_path) # Use anyio.Path for parse_file
|
86
|
+
except Exception:
|
87
|
+
raise
|
81
88
|
finally:
|
82
|
-
await tmp_file_path.unlink()
|
89
|
+
await tmp_file_path.unlink(missing_ok=True)
|
83
90
|
return file_data
|
84
91
|
|
85
92
|
|
@@ -0,0 +1,63 @@
|
|
1
|
+
from aiocache import caches
|
2
|
+
|
3
|
+
from goosebit.api.telemetry.metrics import users_count
|
4
|
+
from goosebit.db.models import User
|
5
|
+
from goosebit.settings import PWD_CXT
|
6
|
+
|
7
|
+
|
8
|
+
async def create_user(username: str, password: str, permissions: list[str]) -> User:
|
9
|
+
return await UserManager.setup_user(username=username, hashed_pwd=PWD_CXT.hash(password), permissions=permissions)
|
10
|
+
|
11
|
+
|
12
|
+
async def create_initial_user(username: str, hashed_pwd: str) -> User:
|
13
|
+
return await UserManager.setup_user(username=username, hashed_pwd=hashed_pwd, permissions=["*"])
|
14
|
+
|
15
|
+
|
16
|
+
class UserManager:
|
17
|
+
@staticmethod
|
18
|
+
async def save_user(user: User, update_fields: list[str]) -> None:
|
19
|
+
await user.save(update_fields=update_fields)
|
20
|
+
|
21
|
+
# only update cache after a successful database save
|
22
|
+
result = await caches.get("default").set(user.username, user, ttl=600)
|
23
|
+
assert result, "user being cached"
|
24
|
+
|
25
|
+
@staticmethod
|
26
|
+
async def update_enabled(user: User, enabled: bool) -> None:
|
27
|
+
user.enabled = enabled
|
28
|
+
await UserManager.save_user(user, update_fields=["enabled"])
|
29
|
+
|
30
|
+
@classmethod
|
31
|
+
async def setup_user(cls, username: str, hashed_pwd: str, permissions: list[str]) -> User:
|
32
|
+
user = (
|
33
|
+
await User.get_or_create(
|
34
|
+
username=username,
|
35
|
+
defaults={
|
36
|
+
"hashed_pwd": hashed_pwd,
|
37
|
+
"permissions": permissions,
|
38
|
+
},
|
39
|
+
)
|
40
|
+
)[0]
|
41
|
+
users_count.set(await User.all().count())
|
42
|
+
return user
|
43
|
+
|
44
|
+
@staticmethod
|
45
|
+
async def get_user(username: str) -> User:
|
46
|
+
cache = caches.get("default")
|
47
|
+
user = await cache.get(username)
|
48
|
+
if user:
|
49
|
+
return user
|
50
|
+
|
51
|
+
user = await User.get_or_none(username=username)
|
52
|
+
if user is not None:
|
53
|
+
result = await cache.set(user.username, user, ttl=600)
|
54
|
+
assert result, "user being cached"
|
55
|
+
|
56
|
+
return user
|
57
|
+
|
58
|
+
@staticmethod
|
59
|
+
async def delete_users(usernames: list[str]):
|
60
|
+
await User.filter(username__in=usernames).delete()
|
61
|
+
for username in usernames:
|
62
|
+
await caches.get("default").delete(username)
|
63
|
+
users_count.set(await User.all().count())
|
File without changes
|
goosebit/util/path.py
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
from anyio import Path
|
2
|
+
|
3
|
+
|
4
|
+
async def validate_filename(filename: str, temp_dir: Path) -> Path:
|
5
|
+
if not filename or not isinstance(filename, str):
|
6
|
+
raise ValueError("Filename must be a non-empty string")
|
7
|
+
|
8
|
+
filename = filename.strip()
|
9
|
+
if not filename:
|
10
|
+
raise ValueError("Filename cannot be empty or whitespace only")
|
11
|
+
|
12
|
+
# Check for dangerous patterns that could indicate path traversal
|
13
|
+
# This includes both Unix (..) and Windows (..\) style traversal
|
14
|
+
dangerous_patterns = ["../", "..\\", "../", "..\\"]
|
15
|
+
if any(pattern in filename for pattern in dangerous_patterns):
|
16
|
+
raise ValueError("Filename contains invalid path traversal components")
|
17
|
+
|
18
|
+
# Check for Windows drive letters (C:, D:, etc.)
|
19
|
+
if len(filename) >= 2 and filename[1] == ":" and filename[0].isalpha():
|
20
|
+
raise ValueError("Filename cannot contain Windows drive letters")
|
21
|
+
|
22
|
+
# Create a path from the filename
|
23
|
+
filename_path = Path(filename)
|
24
|
+
|
25
|
+
# Check if it's an absolute path
|
26
|
+
if filename_path.is_absolute():
|
27
|
+
raise ValueError("Filename cannot be an absolute path")
|
28
|
+
|
29
|
+
# Construct the full path within temp directory
|
30
|
+
file_path = temp_dir / filename_path
|
31
|
+
|
32
|
+
# Resolve both paths to check for path traversal
|
33
|
+
resolved_file = await file_path.resolve()
|
34
|
+
resolved_temp = await temp_dir.resolve()
|
35
|
+
|
36
|
+
# Ensure the resolved file path is within the temp directory
|
37
|
+
try:
|
38
|
+
resolved_file.relative_to(resolved_temp)
|
39
|
+
except ValueError:
|
40
|
+
raise ValueError("Filename contains invalid path traversal components")
|
41
|
+
|
42
|
+
return file_path
|