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.
Files changed (96) hide show
  1. goosebit/__init__.py +56 -6
  2. goosebit/api/telemetry/metrics.py +1 -5
  3. goosebit/api/v1/devices/device/responses.py +1 -0
  4. goosebit/api/v1/devices/device/routes.py +8 -8
  5. goosebit/api/v1/devices/requests.py +20 -0
  6. goosebit/api/v1/devices/routes.py +83 -8
  7. goosebit/api/v1/download/routes.py +14 -3
  8. goosebit/api/v1/rollouts/routes.py +5 -4
  9. goosebit/api/v1/routes.py +2 -1
  10. goosebit/api/v1/settings/routes.py +14 -0
  11. goosebit/api/v1/settings/users/__init__.py +1 -0
  12. goosebit/api/v1/settings/users/requests.py +16 -0
  13. goosebit/api/v1/settings/users/responses.py +7 -0
  14. goosebit/api/v1/settings/users/routes.py +56 -0
  15. goosebit/api/v1/software/routes.py +18 -14
  16. goosebit/auth/__init__.py +54 -14
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  20. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  21. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  22. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  23. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  24. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  25. goosebit/db/models.py +22 -7
  26. goosebit/db/pg_ssl_context.py +51 -0
  27. goosebit/device_manager.py +262 -0
  28. goosebit/plugins/__init__.py +32 -0
  29. goosebit/schema/devices.py +9 -6
  30. goosebit/schema/plugins.py +67 -0
  31. goosebit/schema/updates.py +15 -0
  32. goosebit/schema/users.py +9 -0
  33. goosebit/settings/__init__.py +0 -3
  34. goosebit/settings/schema.py +62 -14
  35. goosebit/storage/__init__.py +62 -0
  36. goosebit/storage/base.py +14 -0
  37. goosebit/storage/filesystem.py +111 -0
  38. goosebit/storage/s3.py +104 -0
  39. goosebit/ui/bff/common/columns.py +50 -0
  40. goosebit/ui/bff/common/requests.py +3 -15
  41. goosebit/ui/bff/common/responses.py +17 -0
  42. goosebit/ui/bff/devices/device/__init__.py +1 -0
  43. goosebit/ui/bff/devices/device/routes.py +17 -0
  44. goosebit/ui/bff/devices/requests.py +1 -0
  45. goosebit/ui/bff/devices/responses.py +6 -2
  46. goosebit/ui/bff/devices/routes.py +71 -17
  47. goosebit/ui/bff/download/routes.py +14 -3
  48. goosebit/ui/bff/rollouts/responses.py +6 -2
  49. goosebit/ui/bff/rollouts/routes.py +32 -4
  50. goosebit/ui/bff/routes.py +6 -3
  51. goosebit/ui/bff/settings/__init__.py +1 -0
  52. goosebit/ui/bff/settings/routes.py +20 -0
  53. goosebit/ui/bff/settings/users/__init__.py +1 -0
  54. goosebit/ui/bff/settings/users/responses.py +33 -0
  55. goosebit/ui/bff/settings/users/routes.py +80 -0
  56. goosebit/ui/bff/software/responses.py +19 -9
  57. goosebit/ui/bff/software/routes.py +40 -12
  58. goosebit/ui/nav.py +12 -2
  59. goosebit/ui/routes.py +70 -26
  60. goosebit/ui/static/js/devices.js +72 -80
  61. goosebit/ui/static/js/login.js +21 -5
  62. goosebit/ui/static/js/logs.js +7 -22
  63. goosebit/ui/static/js/rollouts.js +39 -35
  64. goosebit/ui/static/js/settings.js +322 -0
  65. goosebit/ui/static/js/setup.js +28 -0
  66. goosebit/ui/static/js/software.js +127 -127
  67. goosebit/ui/static/js/util.js +45 -4
  68. goosebit/ui/templates/__init__.py +10 -1
  69. goosebit/ui/templates/devices.html.jinja +0 -20
  70. goosebit/ui/templates/login.html.jinja +5 -0
  71. goosebit/ui/templates/nav.html.jinja +26 -7
  72. goosebit/ui/templates/rollouts.html.jinja +4 -22
  73. goosebit/ui/templates/settings.html.jinja +88 -0
  74. goosebit/ui/templates/setup.html.jinja +71 -0
  75. goosebit/ui/templates/software.html.jinja +0 -11
  76. goosebit/updater/controller/v1/routes.py +120 -72
  77. goosebit/updater/routes.py +86 -7
  78. goosebit/updates/__init__.py +24 -31
  79. goosebit/updates/swdesc.py +15 -8
  80. goosebit/users/__init__.py +63 -0
  81. goosebit/util/__init__.py +0 -0
  82. goosebit/util/path.py +42 -0
  83. goosebit/util/version.py +92 -0
  84. goosebit-0.2.6.dist-info/METADATA +280 -0
  85. goosebit-0.2.6.dist-info/RECORD +133 -0
  86. {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  87. goosebit-0.2.6.dist-info/entry_points.txt +3 -0
  88. goosebit/realtime/logs.py +0 -42
  89. goosebit/realtime/routes.py +0 -13
  90. goosebit/ui/static/js/index.js +0 -155
  91. goosebit/ui/templates/index.html.jinja +0 -25
  92. goosebit/updater/manager.py +0 -357
  93. goosebit-0.2.4.dist-info/METADATA +0 -181
  94. goosebit-0.2.4.dist-info/RECORD +0 -98
  95. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  96. {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 FileResponse, RedirectResponse, Response
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.updater.manager import HandlingType, UpdateManager, get_update_manager
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, dev_id: str, updater: UpdateManager = Depends(get_update_manager)):
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 = config.poll_time_registration
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=dev_id,
46
+ dev_id=device.id,
42
47
  )
43
48
  )
44
49
  }
45
- logger.info(f"Skip: registration required, device={updater.dev_id}")
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.warning(f"Skip: device in error state, device={updater.dev_id}")
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 updater.get_update()
58
+ handling_type, software = await DeviceManager.get_update(device)
55
59
  if handling_type != HandlingType.SKIP and software is not None:
56
- links["deploymentBase"] = {
57
- "href": str(
58
- request.url_for(
59
- "deployment_base",
60
- dev_id=dev_id,
61
- action_id=software.id,
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
- logger.info(f"Forced: update available, device={updater.dev_id}")
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, updater: UpdateManager = Depends(get_update_manager)):
75
- await updater.update_config_data(**cfg.data)
76
- logger.info(f"Updating config data, device={updater.dev_id}")
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
- updater: UpdateManager = Depends(get_update_manager),
106
+ device: Device = Depends(get_device),
85
107
  ):
86
- handling_type, software = await updater.get_update()
87
-
88
- logger.info(f"Request deployment base, device={updater.dev_id}")
89
-
90
- return {
91
- "id": str(action_id),
92
- "deployment": {
93
- "download": str(handling_type),
94
- "update": str(handling_type),
95
- "chunks": await generate_chunk(request, updater),
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
- await updater.update_device_state(UpdateStateEnum.RUNNING)
106
- logger.debug(f"Installation in progress, device={updater.dev_id}")
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 updater.update_force_update(False)
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 updater.update_device_state(UpdateStateEnum.FINISHED)
152
+ await DeviceManager.deployment_action_success(device)
153
+ await DeviceManager.update_device_state(device, UpdateStateEnum.FINISHED)
118
154
 
119
- # not guaranteed to be the correct rollout - see next comment.
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, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
163
+ f"Updating rollout success stats failed, action_id={action_id}, device={device.id}"
164
+ # noqa: E501
128
165
  )
129
166
 
130
- # setting the currently installed version based on the current assigned software / existing rollouts
131
- # is problematic. Better to assign custom action_id for each update (rollout id? software id? new id?).
132
- # Alternatively - but requires customization on the gateway side - use version reported by the gateway.
133
- await updater.update_sw_version(reported_software.version)
134
- logger.debug(f"Installation successful, software={reported_software.version}, device={updater.dev_id}")
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 updater.update_device_state(UpdateStateEnum.ERROR)
174
+ await DeviceManager.update_device_state(device, UpdateStateEnum.ERROR)
138
175
 
139
- # not guaranteed to be the correct rollout - see comment above.
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, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
184
+ f"Updating rollout failure stats failed, action_id={action_id}, device={device.id}"
185
+ # noqa: E501
148
186
  )
149
187
 
150
- logger.debug(f"Installation failed, software={reported_software.version}, device={updater.dev_id}")
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.warning(
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 updater.update_log("\n".join(log))
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={updater.dev_id}")
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, updater: UpdateManager = Depends(get_update_manager)):
168
- _, software = await updater.get_update()
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, updater: UpdateManager = Depends(get_update_manager)):
179
- _, software = await updater.get_update()
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
- else:
189
- return RedirectResponse(url=software.uri)
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
+ )
@@ -1,21 +1,100 @@
1
1
  import time
2
2
 
3
- from fastapi import APIRouter, Depends
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
- host = request.client.host
12
- updater = await get_update_manager(dev_id)
13
- await updater.update_last_connection(round(time.time()), host)
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="/ddi",
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)
@@ -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.updater.manager import UpdateManager
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
- path = Path(config.artifacts_dir).joinpath(update_info["hash"], filename)
52
- await path.parent.mkdir(parents=True, exist_ok=True)
53
- await temp_file.replace(path)
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, updater: UpdateManager) -> list:
96
- _, software = await updater.get_update()
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
- if software.local:
100
- href = str(
101
- request.url_for(
102
- "download_artifact",
103
- dev_id=updater.dev_id,
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
- "part": "os",
111
- "version": "1",
112
- "name": software.path.name,
113
- "artifacts": [
114
- {
115
- "filename": software.path.name,
116
- "hashes": {"sha1": software.hash},
117
- "size": software.size,
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
  ]
@@ -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.settings import config
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"] = semver.Version.parse(swdesc["software"]["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
- artifacts_dir = Path(config.artifacts_dir)
75
- tmp_file_path = artifacts_dir.joinpath("tmp", ("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp"))
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(str(f.name)))
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