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.
- goosebit-0.1.1/PKG-INFO +73 -0
- goosebit-0.1.1/README.md +50 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/__init__.py +3 -3
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/devices.py +6 -2
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/firmware.py +2 -15
- goosebit-0.1.1/goosebit/api/rollouts.py +36 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/routes.py +2 -1
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/auth/__init__.py +28 -20
- goosebit-0.1.1/goosebit/models.py +40 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/permissions.py +20 -6
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/logs.py +2 -1
- goosebit-0.1.1/goosebit/settings.py +66 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/routes.py +13 -9
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/devices.js +17 -2
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/firmware.js +10 -1
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/index.js +11 -1
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/js/logs.js +4 -0
- goosebit-0.1.1/goosebit/ui/static/js/rollouts.js +56 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/devices.html +9 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/firmware.html +2 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/index.html +3 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/logs.html +5 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/nav.html +4 -2
- goosebit-0.1.1/goosebit/ui/templates/rollouts.html +53 -0
- goosebit-0.1.1/goosebit/updater/controller/v1/routes.py +162 -0
- goosebit-0.1.1/goosebit/updater/download/v1/routes.py +13 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/manager.py +76 -35
- goosebit-0.1.1/goosebit/updater/misc.py +57 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/routes.py +3 -1
- goosebit-0.1.1/goosebit/updates/__init__.py +0 -0
- goosebit-0.1.0/goosebit/updater/updates.py → goosebit-0.1.1/goosebit/updates/artifacts.py +10 -14
- goosebit-0.1.1/goosebit/updates/version.py +38 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/pyproject.toml +11 -4
- goosebit-0.1.0/PKG-INFO +0 -37
- goosebit-0.1.0/README.md +0 -16
- goosebit-0.1.0/goosebit/models.py +0 -21
- goosebit-0.1.0/goosebit/settings.py +0 -55
- goosebit-0.1.0/goosebit/updater/controller/v1/routes.py +0 -92
- goosebit-0.1.0/goosebit/updater/download/v1/routes.py +0 -26
- goosebit-0.1.0/goosebit/updater/misc.py +0 -69
- {goosebit-0.1.0 → goosebit-0.1.1}/LICENSE +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/api/download.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/db.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/realtime/routes.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/favicon.ico +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/favicon.svg +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/ui/templates/login.html +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/routes.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/controller/v1/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/__init__.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/routes.py +0 -0
- {goosebit-0.1.0 → goosebit-0.1.1}/goosebit/updater/download/v1/__init__.py +0 -0
goosebit-0.1.1/PKG-INFO
ADDED
@@ -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.
|
goosebit-0.1.1/README.md
ADDED
@@ -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("
|
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("
|
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":
|
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
|
8
|
-
from goosebit.
|
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
|
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(
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
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
|
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
|
-
|
82
|
-
|
83
|
-
|
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
|
26
|
-
READ = "
|
27
|
-
WRITE = "
|
28
|
-
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
|
-
|
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.
|
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
|
-
"/
|
88
|
+
"/rollouts",
|
85
89
|
dependencies=[
|
86
|
-
Security(validate_user_permissions, scopes=[Permissions.
|
90
|
+
Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])
|
87
91
|
],
|
88
92
|
)
|
89
|
-
async def
|
93
|
+
async def rollouts_ui(request: Request):
|
90
94
|
return templates.TemplateResponse(
|
91
|
-
"
|
95
|
+
"rollouts.html", context={"request": request, "title": "Rollouts"}
|
92
96
|
)
|
93
97
|
|
94
98
|
|
95
99
|
@router.get(
|
96
|
-
"/
|
100
|
+
"/logs/{dev_id}",
|
97
101
|
dependencies=[
|
98
|
-
Security(validate_user_permissions, scopes=[Permissions.
|
102
|
+
Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])
|
99
103
|
],
|
100
104
|
)
|
101
|
-
async def
|
105
|
+
async def logs_ui(request: Request, dev_id: str):
|
102
106
|
return templates.TemplateResponse(
|
103
|
-
"
|
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["
|
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")
|