goosebit 0.2.5__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 (88) hide show
  1. goosebit/__init__.py +41 -7
  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 +68 -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 +49 -13
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  20. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  21. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  22. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  23. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  24. goosebit/db/models.py +19 -8
  25. goosebit/db/pg_ssl_context.py +51 -0
  26. goosebit/device_manager.py +262 -0
  27. goosebit/plugins/__init__.py +32 -0
  28. goosebit/schema/devices.py +8 -5
  29. goosebit/schema/plugins.py +67 -0
  30. goosebit/schema/updates.py +15 -0
  31. goosebit/schema/users.py +9 -0
  32. goosebit/settings/__init__.py +0 -3
  33. goosebit/settings/schema.py +60 -14
  34. goosebit/storage/__init__.py +62 -0
  35. goosebit/storage/base.py +14 -0
  36. goosebit/storage/filesystem.py +111 -0
  37. goosebit/storage/s3.py +104 -0
  38. goosebit/ui/bff/common/columns.py +50 -0
  39. goosebit/ui/bff/common/responses.py +1 -0
  40. goosebit/ui/bff/devices/device/__init__.py +1 -0
  41. goosebit/ui/bff/devices/device/routes.py +17 -0
  42. goosebit/ui/bff/devices/requests.py +1 -0
  43. goosebit/ui/bff/devices/routes.py +49 -46
  44. goosebit/ui/bff/download/routes.py +14 -3
  45. goosebit/ui/bff/rollouts/routes.py +32 -4
  46. goosebit/ui/bff/routes.py +2 -1
  47. goosebit/ui/bff/settings/__init__.py +1 -0
  48. goosebit/ui/bff/settings/routes.py +20 -0
  49. goosebit/ui/bff/settings/users/__init__.py +1 -0
  50. goosebit/ui/bff/settings/users/responses.py +33 -0
  51. goosebit/ui/bff/settings/users/routes.py +80 -0
  52. goosebit/ui/bff/software/routes.py +40 -12
  53. goosebit/ui/nav.py +12 -2
  54. goosebit/ui/routes.py +66 -13
  55. goosebit/ui/static/js/devices.js +32 -24
  56. goosebit/ui/static/js/login.js +21 -5
  57. goosebit/ui/static/js/logs.js +7 -22
  58. goosebit/ui/static/js/rollouts.js +31 -30
  59. goosebit/ui/static/js/settings.js +322 -0
  60. goosebit/ui/static/js/setup.js +28 -0
  61. goosebit/ui/static/js/software.js +127 -121
  62. goosebit/ui/static/js/util.js +25 -4
  63. goosebit/ui/templates/__init__.py +10 -1
  64. goosebit/ui/templates/login.html.jinja +5 -0
  65. goosebit/ui/templates/nav.html.jinja +13 -5
  66. goosebit/ui/templates/rollouts.html.jinja +4 -22
  67. goosebit/ui/templates/settings.html.jinja +88 -0
  68. goosebit/ui/templates/setup.html.jinja +71 -0
  69. goosebit/ui/templates/software.html.jinja +0 -11
  70. goosebit/updater/controller/v1/routes.py +119 -77
  71. goosebit/updater/routes.py +83 -8
  72. goosebit/updates/__init__.py +24 -31
  73. goosebit/updates/swdesc.py +15 -8
  74. goosebit/users/__init__.py +63 -0
  75. goosebit/util/__init__.py +0 -0
  76. goosebit/util/path.py +42 -0
  77. goosebit/util/version.py +92 -0
  78. goosebit-0.2.6.dist-info/METADATA +280 -0
  79. goosebit-0.2.6.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  81. goosebit/realtime/logs.py +0 -42
  82. goosebit/realtime/routes.py +0 -13
  83. goosebit/updater/manager.py +0 -325
  84. goosebit-0.2.5.dist-info/METADATA +0 -189
  85. goosebit-0.2.5.dist-info/RECORD +0 -99
  86. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  87. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
  88. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/entry_points.txt +0 -0
@@ -1,25 +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
6
8
  from goosebit.settings import config
9
+ from goosebit.settings.schema import DeviceAuthMode, ExternalAuthMode
7
10
 
11
+ from ..db import Device
8
12
  from . import controller
9
- from .manager import get_update_manager
10
13
 
11
14
 
12
15
  async def log_last_connection(request: Request, dev_id: str):
13
- updater = await get_update_manager(dev_id)
14
- if config.track_device_ip:
15
- await updater.update_last_connection(round(time.time()), request.client.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)
16
23
  else:
17
- await updater.update_last_connection(round(time.time()))
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)}")
18
93
 
19
94
 
20
95
  router = APIRouter(
21
- prefix="/ddi",
22
- dependencies=[Depends(log_last_connection)],
96
+ prefix=f"/{config.tenant}",
97
+ dependencies=[Depends(log_last_connection), Depends(validate_device_token)],
23
98
  tags=["ddi"],
24
99
  )
25
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"], optional_minor_and_patch=True)
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
@@ -0,0 +1,92 @@
1
+ from functools import total_ordering
2
+
3
+ from semver import Version as SemVersion
4
+
5
+
6
+ # Class that replicates version handling of swupdate. For reference see
7
+ # * https://sbabic.github.io/swupdate/sw-description.html#versioning-schemas-in-swupdate
8
+ # * https://github.com/sbabic/swupdate/blob/60322d5ad668e5603341c5f42b3c51d0a1c60226/core/artifacts_versions.c#L158
9
+ # * https://github.com/sbabic/swupdate/blob/60322d5ad668e5603341c5f42b3c51d0a1c60226/core/artifacts_versions.c#L217
10
+ @total_ordering
11
+ class Version:
12
+ version_str: str
13
+ default_version: int | None
14
+ sem_version: SemVersion
15
+
16
+ def __init__(self, version_str: str, default_version: int | None = None, sem_version: SemVersion = None):
17
+ self.version_str = version_str
18
+ self.default_version = default_version
19
+ self.sem_version = sem_version
20
+
21
+ @staticmethod
22
+ def parse(version_str: str):
23
+ default_version = Version._default_version_to_number(version_str)
24
+ sem_version = None
25
+ try:
26
+ sem_version = SemVersion.parse(version_str, optional_minor_and_patch=True)
27
+ except (TypeError, ValueError):
28
+ pass
29
+
30
+ if not default_version and not sem_version:
31
+ raise ValueError(f"{version_str} is not valid swupdate version")
32
+
33
+ return Version(version_str, default_version, sem_version)
34
+
35
+ def __str__(self):
36
+ return self.version_str
37
+
38
+ def __eq__(self, other):
39
+ # support comparison with strings as a convenience
40
+ if isinstance(other, str):
41
+ try:
42
+ other = Version.parse(other)
43
+ except ValueError:
44
+ return False
45
+
46
+ if not isinstance(other, Version):
47
+ return NotImplemented
48
+
49
+ if self.default_version and other.default_version:
50
+ return self.default_version == other.default_version
51
+
52
+ if self.sem_version and other.sem_version:
53
+ return self.sem_version == other.sem_version
54
+
55
+ # fallback to lexical comparison of no of the same type
56
+ return self.version_str == other.version_str
57
+
58
+ def __lt__(self, other):
59
+ if not isinstance(other, Version):
60
+ return NotImplemented
61
+
62
+ if self.default_version and other.default_version:
63
+ return self.default_version < other.default_version
64
+
65
+ if self.sem_version and other.sem_version:
66
+ return self.sem_version < other.sem_version
67
+
68
+ # fallback to lexical comparison of no of the same type
69
+ return self.version_str < other.version_str
70
+
71
+ @staticmethod
72
+ def _default_version_to_number(version_string: str) -> int | None:
73
+ parts = version_string.split(".")
74
+ count = min(len(parts), 4)
75
+ version = 0
76
+
77
+ for i in range(count):
78
+ try:
79
+ fld = int(parts[i])
80
+ except ValueError:
81
+ return None
82
+
83
+ if fld > 0xFFFF:
84
+ print(f"Version {version_string} had an element > 65535, falling back to semver")
85
+ return None
86
+
87
+ version = (version << 16) | fld
88
+
89
+ if count < 4:
90
+ version <<= 16 * (4 - count)
91
+
92
+ return version