goosebit 0.1.0__tar.gz → 0.1.1__tar.gz

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 (60) hide show
  1. goosebit-0.1.1/PKG-INFO +73 -0
  2. goosebit-0.1.1/README.md +50 -0
  3. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/__init__.py +3 -3
  4. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/devices.py +6 -2
  5. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/firmware.py +2 -15
  6. goosebit-0.1.1/goosebit/api/rollouts.py +36 -0
  7. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/routes.py +2 -1
  8. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/auth/__init__.py +28 -20
  9. goosebit-0.1.1/goosebit/models.py +40 -0
  10. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/permissions.py +20 -6
  11. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/logs.py +2 -1
  12. goosebit-0.1.1/goosebit/settings.py +66 -0
  13. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/routes.py +13 -9
  14. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/devices.js +17 -2
  15. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/firmware.js +10 -1
  16. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/index.js +11 -1
  17. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/logs.js +4 -0
  18. goosebit-0.1.1/goosebit/ui/static/js/rollouts.js +56 -0
  19. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/devices.html +9 -0
  20. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/firmware.html +2 -0
  21. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/index.html +3 -0
  22. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/logs.html +5 -0
  23. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/nav.html +4 -2
  24. goosebit-0.1.1/goosebit/ui/templates/rollouts.html +53 -0
  25. goosebit-0.1.1/goosebit/updater/controller/v1/routes.py +162 -0
  26. goosebit-0.1.1/goosebit/updater/download/v1/routes.py +13 -0
  27. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/manager.py +76 -35
  28. goosebit-0.1.1/goosebit/updater/misc.py +57 -0
  29. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/routes.py +3 -1
  30. goosebit-0.1.1/goosebit/updates/__init__.py +0 -0
  31. goosebit-0.1.0/goosebit/updater/updates.py → goosebit-0.1.1/goosebit/updates/artifacts.py +10 -14
  32. goosebit-0.1.1/goosebit/updates/version.py +38 -0
  33. {goosebit-0.1.0 → goosebit-0.1.1}/pyproject.toml +11 -4
  34. goosebit-0.1.0/PKG-INFO +0 -37
  35. goosebit-0.1.0/README.md +0 -16
  36. goosebit-0.1.0/goosebit/models.py +0 -21
  37. goosebit-0.1.0/goosebit/settings.py +0 -55
  38. goosebit-0.1.0/goosebit/updater/controller/v1/routes.py +0 -92
  39. goosebit-0.1.0/goosebit/updater/download/v1/routes.py +0 -26
  40. goosebit-0.1.0/goosebit/updater/misc.py +0 -69
  41. {goosebit-0.1.0 → goosebit-0.1.1}/LICENSE +0 -0
  42. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/__init__.py +0 -0
  43. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/download.py +0 -0
  44. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/db.py +0 -0
  45. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/__init__.py +0 -0
  46. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/routes.py +0 -0
  47. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/__init__.py +0 -0
  48. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/__init__.py +0 -0
  49. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/favicon.ico +0 -0
  50. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/favicon.svg +0 -0
  51. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
  52. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/__init__.py +0 -0
  53. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/login.html +0 -0
  54. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/__init__.py +0 -0
  55. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/__init__.py +0 -0
  56. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/routes.py +0 -0
  57. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/v1/__init__.py +0 -0
  58. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/__init__.py +0 -0
  59. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/routes.py +0 -0
  60. {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/v1/__init__.py +0 -0
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.1
2
+ Name: goosebit
3
+ Version: 0.1.1
4
+ Summary:
5
+ Author: Upstream Data
6
+ Author-email: brett@upstreamdata.ca
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: aerich (>=0.7.2,<0.8.0)
12
+ Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
13
+ Requires-Dist: argon2-cffi (>=23.1.0,<24.0.0)
14
+ Requires-Dist: fastapi[uvicorn] (>=0.111.0,<0.112.0)
15
+ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
16
+ Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
17
+ Requires-Dist: joserfc (>=1.0.0,<2.0.0)
18
+ Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
19
+ Requires-Dist: semver (>=3.0.2,<4.0.0)
20
+ Requires-Dist: tortoise-orm (>=0.21.4,<0.22.0)
21
+ Requires-Dist: websockets (>=12.0,<13.0)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # gooseBit
25
+ <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
26
+
27
+ ---
28
+
29
+ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
30
+
31
+ ## Setup
32
+
33
+ To set up, install the dependencies in `pyproject.toml` with `poetry install`. Then you can run gooseBit by running `main.py`.
34
+
35
+ ## Initial Startup
36
+
37
+ The first time you start gooseBit, you should change the default username and password inside `settings.yaml`.
38
+ The default login credentials for testing are `admin@goosebit.local`, `admin`.
39
+
40
+ ## Assumptions
41
+ - [SWUpdate](https://swupdate.org) used on device side.
42
+
43
+ ## Current Feature Set
44
+
45
+ ### Firmware repository
46
+ Uploading firmware images through frontend. All files should follow the format `{model}_{revision}_{version}`, where
47
+ `version` is either a semantic version or a datetime version in the format `YYYYMMDD-HHmmSS`.
48
+
49
+ ### Automatic device registration
50
+ First time a new device connects, its configuration data is requested. `hw_model` and `hw_revision` are captured from
51
+ the configuration data (both fall back to `default` if not provided) which allows to distinguish different device
52
+ types and their revisions.
53
+
54
+ ### Automatically update device to newest firmware
55
+ Once a device is registered it will get the newest available firmware from the repository based on model and revision.
56
+
57
+ ### Manually update device to specific firmware
58
+ Frontend allows to assign specific firmware to be rolled out.
59
+
60
+ ### Firmware rollout
61
+ Rollouts allow a fine-grained assignment of firmwares to devices. The reported device model and revision is combined
62
+ with the manually set feed and flavor values on a device to determine a matching rollout.
63
+
64
+ The feed is meant to model either different environments (like: dev, qa, live) or update channels (like. candidate,
65
+ fast, stable).
66
+
67
+ The flavor can be used for different type of builds (like: debug, prod).
68
+
69
+ ### Pause updates
70
+ Device can be pinned to its current firmware.
71
+
72
+ ### Realtime update logs
73
+ While an update is running, the update logs are captured and visualized in the frontend.
@@ -0,0 +1,50 @@
1
+ # gooseBit
2
+ <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
3
+
4
+ ---
5
+
6
+ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
7
+
8
+ ## Setup
9
+
10
+ To set up, install the dependencies in `pyproject.toml` with `poetry install`. Then you can run gooseBit by running `main.py`.
11
+
12
+ ## Initial Startup
13
+
14
+ The first time you start gooseBit, you should change the default username and password inside `settings.yaml`.
15
+ The default login credentials for testing are `admin@goosebit.local`, `admin`.
16
+
17
+ ## Assumptions
18
+ - [SWUpdate](https://swupdate.org) used on device side.
19
+
20
+ ## Current Feature Set
21
+
22
+ ### Firmware repository
23
+ Uploading firmware images through frontend. All files should follow the format `{model}_{revision}_{version}`, where
24
+ `version` is either a semantic version or a datetime version in the format `YYYYMMDD-HHmmSS`.
25
+
26
+ ### Automatic device registration
27
+ First time a new device connects, its configuration data is requested. `hw_model` and `hw_revision` are captured from
28
+ the configuration data (both fall back to `default` if not provided) which allows to distinguish different device
29
+ types and their revisions.
30
+
31
+ ### Automatically update device to newest firmware
32
+ Once a device is registered it will get the newest available firmware from the repository based on model and revision.
33
+
34
+ ### Manually update device to specific firmware
35
+ Frontend allows to assign specific firmware to be rolled out.
36
+
37
+ ### Firmware rollout
38
+ Rollouts allow a fine-grained assignment of firmwares to devices. The reported device model and revision is combined
39
+ with the manually set feed and flavor values on a device to determine a matching rollout.
40
+
41
+ The feed is meant to model either different environments (like: dev, qa, live) or update channels (like. candidate,
42
+ fast, stable).
43
+
44
+ The flavor can be used for different type of builds (like: debug, prod).
45
+
46
+ ### Pause updates
47
+ Device can be pinned to its current firmware.
48
+
49
+ ### Realtime update logs
50
+ While an update is running, the update logs are captured and visualized in the frontend.
@@ -49,14 +49,14 @@ async def login_ui(request: Request):
49
49
 
50
50
 
51
51
  @app.post("/login", include_in_schema=False, dependencies=[Depends(authenticate_user)])
52
- async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
53
- resp = RedirectResponse("/ui/home", status_code=302)
52
+ async def login(request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
53
+ resp = RedirectResponse(request.url_for("ui_root"), status_code=302)
54
54
  resp.set_cookie(key="session_id", value=create_session(form_data.username))
55
55
  return resp
56
56
 
57
57
 
58
58
  @app.get("/logout", include_in_schema=False)
59
59
  async def logout(request: Request):
60
- resp = RedirectResponse("/login", status_code=302)
60
+ resp = RedirectResponse(request.url_for("login_ui"), status_code=302)
61
61
  resp.delete_cookie(key="session_id")
62
62
  return resp
@@ -30,15 +30,19 @@ async def devices_get_all() -> list[dict]:
30
30
  last_seen = round(time.time() - device.last_seen)
31
31
  return {
32
32
  "uuid": device.uuid,
33
- "web_pwd": device.web_pwd,
34
33
  "name": device.name,
35
34
  "fw": device.fw_version,
36
35
  "fw_file": device.fw_file,
36
+ "hw_model": device.hw_model,
37
+ "hw_revision": device.hw_revision,
38
+ "progress": device.progress,
37
39
  "state": device.last_state,
38
40
  "force_update": manager.force_update,
39
41
  "last_ip": device.last_ip,
40
42
  "last_seen": last_seen,
41
- "online": last_seen < 120 if last_seen is not None else None,
43
+ "online": (
44
+ last_seen < manager.poll_seconds if last_seen is not None else None
45
+ ),
42
46
  }
43
47
 
44
48
  return list(await asyncio.gather(*[parse(d) for d in devices]))
@@ -4,8 +4,8 @@ from fastapi.requests import Request
4
4
  from goosebit.auth import validate_user_permissions
5
5
  from goosebit.permissions import Permissions
6
6
  from goosebit.settings import UPDATES_DIR
7
- from goosebit.updater.misc import fw_sort_key, get_newest_fw
8
- from goosebit.updater.updates import FirmwareArtifact
7
+ from goosebit.updater.misc import fw_sort_key
8
+ from goosebit.updates.artifacts import FirmwareArtifact
9
9
 
10
10
  router = APIRouter(prefix="/firmware")
11
11
 
@@ -37,19 +37,6 @@ async def firmware_get_all() -> list[dict]:
37
37
  return firmware
38
38
 
39
39
 
40
- @router.get(
41
- "/latest",
42
- dependencies=[
43
- Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
44
- ],
45
- )
46
- async def firmware_get_latest() -> dict:
47
- UPDATES_DIR.mkdir(parents=True, exist_ok=True)
48
-
49
- file_data = UPDATES_DIR.joinpath(get_newest_fw())
50
- return {"name": file_data.name, "size": file_data.stat().st_size}
51
-
52
-
53
40
  @router.post(
54
41
  "/delete",
55
42
  dependencies=[
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Security
4
+
5
+ from goosebit.auth import validate_user_permissions
6
+ from goosebit.models import Rollout
7
+ from goosebit.permissions import Permissions
8
+
9
+ router = APIRouter(prefix="/rollouts")
10
+
11
+
12
+ @router.get(
13
+ "/all",
14
+ dependencies=[
15
+ Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])
16
+ ],
17
+ )
18
+ async def rollouts_get_all() -> list[dict]:
19
+ rollouts = await Rollout.all()
20
+
21
+ def parse(rollout: Rollout) -> dict:
22
+ return {
23
+ "id": rollout.id,
24
+ "created_at": rollout.created_at,
25
+ "name": rollout.name,
26
+ "hw_model": rollout.hw_model,
27
+ "hw_revision": rollout.hw_revision,
28
+ "feed": rollout.feed,
29
+ "flavor": rollout.flavor,
30
+ "fw_file": rollout.fw_file,
31
+ "paused": rollout.paused,
32
+ "success_count": rollout.success_count,
33
+ "failure_count": rollout.failure_count,
34
+ }
35
+
36
+ return [parse(r) for r in rollouts]
@@ -1,6 +1,6 @@
1
1
  from fastapi import APIRouter, Depends
2
2
 
3
- from goosebit.api import devices, download, firmware
3
+ from goosebit.api import devices, download, firmware, rollouts
4
4
  from goosebit.auth import authenticate_api_session
5
5
 
6
6
  router = APIRouter(
@@ -8,4 +8,5 @@ router = APIRouter(
8
8
  )
9
9
  router.include_router(firmware.router)
10
10
  router.include_router(devices.router)
11
+ router.include_router(rollouts.router)
11
12
  router.include_router(download.router)
@@ -4,7 +4,8 @@ from fastapi import Depends, HTTPException
4
4
  from fastapi.requests import Request
5
5
  from fastapi.security import SecurityScopes
6
6
  from fastapi.websockets import WebSocket
7
- from jose import jwt
7
+ from joserfc import jwt
8
+ from joserfc.errors import BadSignatureError
8
9
 
9
10
  from goosebit.settings import PWD_CXT, SECRET, USERS
10
11
 
@@ -20,7 +21,7 @@ async def authenticate_user(request: Request):
20
21
  headers={"location": str(request.url_for("login"))},
21
22
  detail="Invalid credentials",
22
23
  )
23
- if not PWD_CXT.verify(password, user.hashed_pwd):
24
+ if not PWD_CXT.verify(user.hashed_pwd, password):
24
25
  raise HTTPException(
25
26
  status_code=302,
26
27
  headers={"location": str(request.url_for("login"))},
@@ -29,46 +30,49 @@ async def authenticate_user(request: Request):
29
30
  return user
30
31
 
31
32
 
32
- sessions = {}
33
-
34
-
35
- def create_session(email: str) -> str:
36
- token = jwt.encode({"email": email}, SECRET)
37
- sessions[token] = email
38
- return token
33
+ def create_session(username: str) -> str:
34
+ return jwt.encode(
35
+ header={"alg": "HS256"}, claims={"username": username}, key=SECRET
36
+ )
39
37
 
40
38
 
41
39
  def authenticate_session(request: Request):
42
40
  session_id = request.cookies.get("session_id")
43
- if session_id is None or session_id not in sessions:
41
+ if session_id is None:
44
42
  raise HTTPException(
45
43
  status_code=302,
46
44
  headers={"location": str(request.url_for("login"))},
47
45
  detail="Invalid session ID",
48
46
  )
49
47
  user = get_user_from_session(session_id)
48
+ if user is None:
49
+ raise HTTPException(
50
+ status_code=302,
51
+ headers={"location": str(request.url_for("login"))},
52
+ detail="Invalid username",
53
+ )
50
54
  return user
51
55
 
52
56
 
53
57
  def authenticate_api_session(request: Request):
54
58
  session_id = request.cookies.get("session_id")
55
- if session_id is None or session_id not in sessions:
56
- raise HTTPException(status_code=401, detail="Not logged in")
57
59
  user = get_user_from_session(session_id)
60
+ if user is None:
61
+ raise HTTPException(status_code=401, detail="Not logged in")
58
62
  return user
59
63
 
60
64
 
61
65
  def authenticate_ws_session(websocket: WebSocket):
62
66
  session_id = websocket.cookies.get("session_id")
63
- if session_id is None or session_id not in sessions:
64
- raise HTTPException(status_code=401, detail="Not logged in")
65
67
  user = get_user_from_session(session_id)
68
+ if user is None:
69
+ raise HTTPException(status_code=401, detail="Not logged in")
66
70
  return user
67
71
 
68
72
 
69
73
  def auto_redirect(request: Request):
70
74
  session_id = request.cookies.get("session_id")
71
- if session_id is None or session_id not in sessions:
75
+ if get_user_from_session(session_id) is None:
72
76
  return request
73
77
  raise HTTPException(
74
78
  status_code=302,
@@ -78,16 +82,20 @@ def auto_redirect(request: Request):
78
82
 
79
83
 
80
84
  def get_user_from_session(session_id: str):
81
- for username in USERS:
82
- if username == sessions.get(session_id):
83
- return username
85
+ if session_id is None:
86
+ return
87
+ try:
88
+ session_data = jwt.decode(session_id, SECRET)
89
+ return session_data.claims["username"]
90
+ except (BadSignatureError, LookupError):
91
+ pass
84
92
 
85
93
 
86
94
  def get_current_user(request: Request):
87
95
  session_id = request.cookies.get("session_id")
88
- if session_id is None or session_id not in sessions:
89
- return None
90
96
  user = get_user_from_session(session_id)
97
+ if user is None:
98
+ return None
91
99
  return USERS[user]
92
100
 
93
101
 
@@ -0,0 +1,40 @@
1
+ from tortoise import Model, fields
2
+
3
+
4
+ class Tag(Model):
5
+ id = fields.IntField(primary_key=True)
6
+ name = fields.CharField(max_length=255)
7
+
8
+
9
+ class Device(Model):
10
+ uuid = fields.CharField(max_length=255, primary_key=True)
11
+ name = fields.CharField(max_length=255, null=True)
12
+ fw_file = fields.CharField(max_length=255, default="latest")
13
+ fw_version = fields.CharField(max_length=255, null=True)
14
+ hw_model = fields.CharField(max_length=255, null=True, default="default")
15
+ hw_revision = fields.CharField(max_length=255, null=True, default="default")
16
+ feed = fields.CharField(max_length=255, default="default")
17
+ flavor = fields.CharField(max_length=255, default="default")
18
+ last_state = fields.CharField(max_length=255, null=True, default="unknown")
19
+ progress = fields.IntField(null=True)
20
+ last_log = fields.TextField(null=True)
21
+ last_seen = fields.BigIntField(null=True)
22
+ last_ip = fields.CharField(max_length=15, null=True)
23
+ last_ipv6 = fields.CharField(max_length=40, null=True)
24
+ tags = fields.ManyToManyField(
25
+ "models.Tag", related_name="devices", through="device_tags"
26
+ )
27
+
28
+
29
+ class Rollout(Model):
30
+ id = fields.IntField(primary_key=True)
31
+ created_at = fields.DatetimeField(auto_now_add=True)
32
+ name = fields.CharField(max_length=255, null=True)
33
+ hw_model = fields.CharField(max_length=255, default="default")
34
+ hw_revision = fields.CharField(max_length=255, default="default")
35
+ feed = fields.CharField(max_length=255, default="default")
36
+ flavor = fields.CharField(max_length=255, default="default")
37
+ fw_file = fields.CharField(max_length=255)
38
+ paused = fields.BooleanField(default=False)
39
+ success_count = fields.IntField(default=0)
40
+ failure_count = fields.IntField(default=0)
@@ -22,10 +22,10 @@ class DevicePermissions(PermissionsBase):
22
22
  DELETE = "devices.delete"
23
23
 
24
24
 
25
- class TunnelPermissions(PermissionsBase):
26
- READ = "tunnels.read"
27
- WRITE = "tunnels.write"
28
- DELETE = "tunnels.delete"
25
+ class RolloutPermissions(PermissionsBase):
26
+ READ = "rollouts.read"
27
+ WRITE = "rollouts.write"
28
+ DELETE = "rollouts.delete"
29
29
 
30
30
 
31
31
  class HomePermissions(PermissionsBase):
@@ -36,15 +36,29 @@ class Permissions:
36
36
  HOME = HomePermissions
37
37
  FIRMWARE = FirmwarePermissions
38
38
  DEVICE = DevicePermissions
39
- TUNNEL = TunnelPermissions
39
+ ROLLOUT = RolloutPermissions
40
40
 
41
41
  @classmethod
42
42
  def full(cls):
43
43
  all_items = set()
44
- for item in [cls.HOME, cls.FIRMWARE, cls.DEVICE, cls.TUNNEL]:
44
+ for item in [cls.HOME, cls.FIRMWARE, cls.DEVICE, cls.ROLLOUT]:
45
45
  all_items.update(item.full())
46
46
  return list(all_items)
47
47
 
48
+ @classmethod
49
+ def from_str(cls, permission: str):
50
+ if permission == "*":
51
+ return cls.full()
52
+ permission_type = permission.split(".")[0]
53
+ if permission_type == "firmware":
54
+ return FirmwarePermissions(permission)
55
+ if permission_type == "devices":
56
+ return DevicePermissions(permission)
57
+ if permission_type == "rollouts":
58
+ return RolloutPermissions(permission)
59
+ if permission_type == "home":
60
+ return HomePermissions(permission)
61
+
48
62
 
49
63
  ADMIN = Permissions.full()
50
64
  MONITORING = [
@@ -14,6 +14,7 @@ router = APIRouter(prefix="/logs")
14
14
 
15
15
  class RealtimeLogModel(BaseModel):
16
16
  log: str | None
17
+ progress: int | None
17
18
  clear: bool = False
18
19
 
19
20
 
@@ -29,7 +30,7 @@ async def device_logs(websocket: WebSocket, dev_id: str):
29
30
  manager = await get_update_manager(dev_id)
30
31
 
31
32
  async def callback(log_update):
32
- data = RealtimeLogModel(log=log_update)
33
+ data = RealtimeLogModel(log=log_update, progress=manager.device.progress)
33
34
  if log_update is None:
34
35
  data.clear = True
35
36
  data.log = ""
@@ -0,0 +1,66 @@
1
+ import secrets
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from argon2 import PasswordHasher
7
+
8
+ from goosebit.permissions import Permissions
9
+ from goosebit.updates.version import UpdateVersionParser
10
+
11
+ BASE_DIR = Path(__file__).resolve().parent.parent
12
+ TOKEN_SWU_DIR = BASE_DIR.joinpath("swugen")
13
+ SWUPDATE_FILES_DIR = BASE_DIR.joinpath("swupdate")
14
+ UPDATES_DIR = BASE_DIR.joinpath("updates")
15
+ DB_MIGRATIONS_LOC = BASE_DIR.joinpath("migrations")
16
+
17
+ SECRET = secrets.token_hex(16)
18
+ PWD_CXT = PasswordHasher()
19
+
20
+ with open(BASE_DIR.joinpath("settings.yaml"), "r") as f:
21
+ config = yaml.safe_load(f.read())
22
+
23
+ TENANT = config.get("tenant", "DEFAULT")
24
+
25
+ POLL_TIME = config.get("poll_time_default", "00:01:00")
26
+ POLL_TIME_UPDATING = config.get("poll_time_updating", "00:00:05")
27
+ POLL_TIME_REGISTRATION = config.get("poll_time_registration", "00:00:10")
28
+
29
+ DB_LOC = BASE_DIR.joinpath(config.get("db_location", "db.sqlite3"))
30
+ DB_URI = f"sqlite:///{DB_LOC}"
31
+
32
+ UPDATE_VERSION_PARSER = UpdateVersionParser.create(
33
+ parse_mode=config.get("version_format", "datetime"),
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class User:
39
+ username: str
40
+ hashed_pwd: str
41
+ permissions: set
42
+
43
+ def get_json_permissions(self):
44
+ return [str(p) for p in self.permissions]
45
+
46
+
47
+ users: dict[str, User] = {}
48
+
49
+
50
+ def add_user(u: User):
51
+ users[u.username] = u
52
+
53
+
54
+ for user in config.get("users", []):
55
+ permissions = set()
56
+ for p in user["permissions"]:
57
+ permissions.update(Permissions.from_str(p))
58
+ add_user(
59
+ User(
60
+ username=user["email"],
61
+ hashed_pwd=PWD_CXT.hash(user["password"]),
62
+ permissions=permissions,
63
+ )
64
+ )
65
+
66
+ USERS = users
@@ -1,5 +1,5 @@
1
1
  import aiofiles
2
- from fastapi import APIRouter, Depends, Form, Security, UploadFile
2
+ from fastapi import APIRouter, Depends, Form, HTTPException, Security, UploadFile
3
3
  from fastapi.requests import Request
4
4
  from fastapi.responses import RedirectResponse
5
5
  from fastapi.security import OAuth2PasswordBearer
@@ -8,6 +8,7 @@ from goosebit.auth import authenticate_session, validate_user_permissions
8
8
  from goosebit.permissions import Permissions
9
9
  from goosebit.settings import UPDATES_DIR
10
10
  from goosebit.ui.templates import templates
11
+ from goosebit.updater.misc import validate_filename
11
12
 
12
13
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
13
14
 
@@ -46,6 +47,9 @@ async def upload_update(
46
47
  done: bool = Form(...),
47
48
  filename: str = Form(...),
48
49
  ):
50
+ if not validate_filename(filename):
51
+ raise HTTPException(400, detail="Could not parse file data, invalid filename.")
52
+
49
53
  file = UPDATES_DIR.joinpath(filename)
50
54
  tmpfile = file.with_suffix(".tmp")
51
55
  contents = await chunk.read()
@@ -81,24 +85,24 @@ async def devices_ui(request: Request):
81
85
 
82
86
 
83
87
  @router.get(
84
- "/logs/{dev_id}",
88
+ "/rollouts",
85
89
  dependencies=[
86
- Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])
90
+ Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])
87
91
  ],
88
92
  )
89
- async def logs_ui(request: Request, dev_id: str):
93
+ async def rollouts_ui(request: Request):
90
94
  return templates.TemplateResponse(
91
- "logs.html", context={"request": request, "title": "Log", "device": dev_id}
95
+ "rollouts.html", context={"request": request, "title": "Rollouts"}
92
96
  )
93
97
 
94
98
 
95
99
  @router.get(
96
- "/tunnels",
100
+ "/logs/{dev_id}",
97
101
  dependencies=[
98
- Security(validate_user_permissions, scopes=[Permissions.TUNNEL.READ])
102
+ Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])
99
103
  ],
100
104
  )
101
- async def tunnels_ui(request: Request):
105
+ async def logs_ui(request: Request, dev_id: str):
102
106
  return templates.TemplateResponse(
103
- "tunnels.html", context={"request": request, "title": "Tunnels"}
107
+ "logs.html", context={"request": request, "title": "Log", "device": dev_id}
104
108
  )
@@ -40,6 +40,8 @@ document.addEventListener("DOMContentLoaded", function() {
40
40
  }
41
41
  },
42
42
  { data: 'uuid' },
43
+ { data: 'hw_model' },
44
+ { data: 'hw_revision' },
43
45
  { data: 'fw' },
44
46
  {
45
47
  data: 'force_update',
@@ -56,6 +58,16 @@ document.addEventListener("DOMContentLoaded", function() {
56
58
  }
57
59
  },
58
60
  { data: 'fw_file' },
61
+ {
62
+ data: 'progress',
63
+ render: function(data, type, row) {
64
+ if ( type === 'display' || type === 'filter' ) {
65
+ return (data || "❓") + "%";
66
+ }
67
+ return data;
68
+ }
69
+
70
+ },
59
71
  { data: 'last_ip' },
60
72
  {
61
73
  data: 'last_seen',
@@ -210,17 +222,20 @@ function updateFirmwareSelection() {
210
222
  .then(data => {
211
223
  selectElem = document.getElementById("device-selected-fw");
212
224
 
225
+ optionElem = document.createElement("option");
226
+ optionElem.value = "none";
227
+ optionElem.textContent = "none";
228
+ selectElem.appendChild(optionElem);
229
+
213
230
  optionElem = document.createElement("option");
214
231
  optionElem.value = "latest";
215
232
  optionElem.textContent = "latest";
216
-
217
233
  selectElem.appendChild(optionElem);
218
234
 
219
235
  data.forEach(item => {
220
236
  optionElem = document.createElement("option");
221
237
  optionElem.value = item["name"];
222
238
  optionElem.textContent = item["name"];
223
-
224
239
  selectElem.appendChild(optionElem);
225
240
  });
226
241
  })
@@ -45,6 +45,15 @@ const sendFileChunks = async (file) => {
45
45
  const progress = (uploadedChunks / totalChunks) * 100;
46
46
  progressBar.style.width = `${progress}%`;
47
47
  progressBar.innerHTML = `${Math.round(progress)}%`;
48
+ } else {
49
+ if (response.status === 400) {
50
+ result = await response.json()
51
+ alerts = document.getElementById("upload-alerts");
52
+ alerts.innerHTML = `<div class="alert alert-warning alert-dismissible fade show" role="alert">
53
+ ${result["detail"]}
54
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
55
+ </div>`
56
+ }
48
57
  }
49
58
 
50
59
  start = end;
@@ -84,7 +93,7 @@ function updateFirmwareList() {
84
93
 
85
94
  data.forEach(item => {
86
95
  const listItem = document.createElement('li');
87
- listItem.textContent = item["version"];
96
+ listItem.textContent = `${item["name"]}, size: ${(item["size"] / 1024 / 1024).toFixed(2)} MB`;
88
97
  listItem.classList = ["list-group-item d-flex justify-content-between align-items-center"];
89
98
 
90
99
  const btnGroup = document.createElement("div")