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.
- goosebit/__init__.py +41 -7
- goosebit/api/telemetry/metrics.py +1 -5
- goosebit/api/v1/devices/device/responses.py +1 -0
- goosebit/api/v1/devices/device/routes.py +8 -8
- goosebit/api/v1/devices/requests.py +20 -0
- goosebit/api/v1/devices/routes.py +68 -8
- goosebit/api/v1/download/routes.py +14 -3
- goosebit/api/v1/rollouts/routes.py +5 -4
- goosebit/api/v1/routes.py +2 -1
- goosebit/api/v1/settings/routes.py +14 -0
- goosebit/api/v1/settings/users/__init__.py +1 -0
- goosebit/api/v1/settings/users/requests.py +16 -0
- goosebit/api/v1/settings/users/responses.py +7 -0
- goosebit/api/v1/settings/users/routes.py +56 -0
- goosebit/api/v1/software/routes.py +18 -14
- goosebit/auth/__init__.py +49 -13
- goosebit/auth/permissions.py +80 -0
- goosebit/db/config.py +57 -1
- goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
- goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
- goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
- goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
- goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
- goosebit/db/models.py +19 -8
- goosebit/db/pg_ssl_context.py +51 -0
- goosebit/device_manager.py +262 -0
- goosebit/plugins/__init__.py +32 -0
- goosebit/schema/devices.py +8 -5
- goosebit/schema/plugins.py +67 -0
- goosebit/schema/updates.py +15 -0
- goosebit/schema/users.py +9 -0
- goosebit/settings/__init__.py +0 -3
- goosebit/settings/schema.py +60 -14
- goosebit/storage/__init__.py +62 -0
- goosebit/storage/base.py +14 -0
- goosebit/storage/filesystem.py +111 -0
- goosebit/storage/s3.py +104 -0
- goosebit/ui/bff/common/columns.py +50 -0
- goosebit/ui/bff/common/responses.py +1 -0
- goosebit/ui/bff/devices/device/__init__.py +1 -0
- goosebit/ui/bff/devices/device/routes.py +17 -0
- goosebit/ui/bff/devices/requests.py +1 -0
- goosebit/ui/bff/devices/routes.py +49 -46
- goosebit/ui/bff/download/routes.py +14 -3
- goosebit/ui/bff/rollouts/routes.py +32 -4
- goosebit/ui/bff/routes.py +2 -1
- goosebit/ui/bff/settings/__init__.py +1 -0
- goosebit/ui/bff/settings/routes.py +20 -0
- goosebit/ui/bff/settings/users/__init__.py +1 -0
- goosebit/ui/bff/settings/users/responses.py +33 -0
- goosebit/ui/bff/settings/users/routes.py +80 -0
- goosebit/ui/bff/software/routes.py +40 -12
- goosebit/ui/nav.py +12 -2
- goosebit/ui/routes.py +66 -13
- goosebit/ui/static/js/devices.js +32 -24
- goosebit/ui/static/js/login.js +21 -5
- goosebit/ui/static/js/logs.js +7 -22
- goosebit/ui/static/js/rollouts.js +31 -30
- goosebit/ui/static/js/settings.js +322 -0
- goosebit/ui/static/js/setup.js +28 -0
- goosebit/ui/static/js/software.js +127 -121
- goosebit/ui/static/js/util.js +25 -4
- goosebit/ui/templates/__init__.py +10 -1
- goosebit/ui/templates/login.html.jinja +5 -0
- goosebit/ui/templates/nav.html.jinja +13 -5
- goosebit/ui/templates/rollouts.html.jinja +4 -22
- goosebit/ui/templates/settings.html.jinja +88 -0
- goosebit/ui/templates/setup.html.jinja +71 -0
- goosebit/ui/templates/software.html.jinja +0 -11
- goosebit/updater/controller/v1/routes.py +119 -77
- goosebit/updater/routes.py +83 -8
- goosebit/updates/__init__.py +24 -31
- goosebit/updates/swdesc.py +15 -8
- goosebit/users/__init__.py +63 -0
- goosebit/util/__init__.py +0 -0
- goosebit/util/path.py +42 -0
- goosebit/util/version.py +92 -0
- goosebit-0.2.6.dist-info/METADATA +280 -0
- goosebit-0.2.6.dist-info/RECORD +133 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
- goosebit/realtime/logs.py +0 -42
- goosebit/realtime/routes.py +0 -13
- goosebit/updater/manager.py +0 -325
- goosebit-0.2.5.dist-info/METADATA +0 -189
- goosebit-0.2.5.dist-info/RECORD +0 -99
- /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/entry_points.txt +0 -0
goosebit/auth/__init__.py
CHANGED
@@ -10,8 +10,9 @@ from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
|
10
10
|
from joserfc import jwt
|
11
11
|
from joserfc.errors import BadSignatureError
|
12
12
|
|
13
|
-
from goosebit.
|
14
|
-
from goosebit.settings
|
13
|
+
from goosebit.db.models import User
|
14
|
+
from goosebit.settings import PWD_CXT, config
|
15
|
+
from goosebit.users import UserManager
|
15
16
|
|
16
17
|
logger = logging.getLogger(__name__)
|
17
18
|
|
@@ -31,25 +32,31 @@ def create_token(username: str) -> str:
|
|
31
32
|
return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
|
32
33
|
|
33
34
|
|
34
|
-
def get_user_from_token(token: str | None) -> User | None:
|
35
|
+
async def get_user_from_token(token: str | None) -> User | None:
|
35
36
|
if token is None:
|
36
37
|
return None
|
37
38
|
try:
|
38
39
|
token_data = jwt.decode(token, config.secret_key)
|
39
40
|
username = token_data.claims["username"]
|
40
|
-
return
|
41
|
+
return await UserManager.get_user(username)
|
41
42
|
except (BadSignatureError, LookupError, ValueError):
|
42
43
|
return None
|
43
44
|
|
44
45
|
|
45
|
-
def login_user(username: str, password: str) -> str:
|
46
|
-
user =
|
46
|
+
async def login_user(username: str, password: str) -> str:
|
47
|
+
user = await UserManager.get_user(username)
|
47
48
|
if user is None:
|
48
49
|
raise HTTPException(
|
49
50
|
status_code=401,
|
50
51
|
detail="Invalid username or password",
|
51
52
|
headers={"WWW-Authenticate": "Bearer"},
|
52
53
|
)
|
54
|
+
if not user.enabled:
|
55
|
+
raise HTTPException(
|
56
|
+
status_code=401,
|
57
|
+
detail="User has been disabled, please contact your administrator",
|
58
|
+
headers={"WWW-Authenticate": "Bearer"},
|
59
|
+
)
|
53
60
|
try:
|
54
61
|
PWD_CXT.verify(user.hashed_pwd, password)
|
55
62
|
except VerifyMismatchError:
|
@@ -61,12 +68,12 @@ def login_user(username: str, password: str) -> str:
|
|
61
68
|
return create_token(user.username)
|
62
69
|
|
63
70
|
|
64
|
-
def get_current_user(
|
71
|
+
async def get_current_user(
|
65
72
|
session_token: Annotated[str | None, Depends(session_auth)] = None,
|
66
73
|
oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
|
67
74
|
) -> User | None:
|
68
|
-
session_user = get_user_from_token(session_token)
|
69
|
-
oauth2_user = get_user_from_token(oauth2_token)
|
75
|
+
session_user = await get_user_from_token(session_token)
|
76
|
+
oauth2_user = await get_user_from_token(oauth2_token)
|
70
77
|
user = session_user or oauth2_user
|
71
78
|
return user
|
72
79
|
|
@@ -74,34 +81,63 @@ def get_current_user(
|
|
74
81
|
# using | Request because oauth2_auth.__call__ expects is
|
75
82
|
async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
|
76
83
|
token = await session_auth(connection) or await oauth2_auth(connection)
|
77
|
-
return get_user_from_token(token)
|
84
|
+
return await get_user_from_token(token)
|
78
85
|
|
79
86
|
|
80
|
-
def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
|
87
|
+
async def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
|
81
88
|
if user is None:
|
82
89
|
raise HTTPException(
|
83
90
|
status_code=302,
|
84
91
|
headers={"location": str(connection.url_for("login_get"))},
|
85
92
|
detail="Invalid username",
|
86
93
|
)
|
94
|
+
if not user.enabled:
|
95
|
+
raise HTTPException(
|
96
|
+
status_code=302,
|
97
|
+
headers={"location": str(connection.url_for("login_get"))},
|
98
|
+
detail="Disabled user",
|
99
|
+
)
|
87
100
|
|
88
101
|
|
89
|
-
def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
|
102
|
+
async def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
|
90
103
|
if user is not None:
|
104
|
+
if not user.enabled:
|
105
|
+
return
|
91
106
|
raise HTTPException(
|
92
107
|
status_code=302,
|
93
108
|
headers={"location": str(connection.url_for("ui_root"))},
|
94
109
|
detail="Already logged in",
|
95
110
|
)
|
111
|
+
if await User.all().count() == 0:
|
112
|
+
raise HTTPException(
|
113
|
+
status_code=302,
|
114
|
+
headers={"location": str(connection.url_for("setup_get"))},
|
115
|
+
detail="No users set up",
|
116
|
+
)
|
96
117
|
|
97
118
|
|
98
|
-
def
|
119
|
+
async def redirect_if_users_exist(connection: HTTPConnection):
|
120
|
+
if await User.all().count() > 0:
|
121
|
+
raise HTTPException(
|
122
|
+
status_code=302,
|
123
|
+
headers={"location": str(connection.url_for("login_get"))},
|
124
|
+
detail="An admin user already exists",
|
125
|
+
)
|
126
|
+
|
127
|
+
|
128
|
+
async def validate_current_user(user: Annotated[User, Depends(get_current_user)]):
|
99
129
|
if user is None:
|
100
130
|
raise HTTPException(
|
101
131
|
status_code=401,
|
102
132
|
detail="Not authenticated",
|
103
133
|
headers={"WWW-Authenticate": "Bearer"},
|
104
134
|
)
|
135
|
+
if not user.enabled:
|
136
|
+
raise HTTPException(
|
137
|
+
status_code=401,
|
138
|
+
detail="Disabled user",
|
139
|
+
headers={"WWW-Authenticate": "Bearer"},
|
140
|
+
)
|
105
141
|
return user
|
106
142
|
|
107
143
|
|
@@ -0,0 +1,80 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
from pydantic import BaseModel, Field, computed_field
|
4
|
+
|
5
|
+
|
6
|
+
class Permission(BaseModel):
|
7
|
+
def model_post_init(self, ctx):
|
8
|
+
if self.sub_permissions is None:
|
9
|
+
return
|
10
|
+
for permission in self.sub_permissions:
|
11
|
+
permission.parent_permission = self
|
12
|
+
|
13
|
+
def __call__(self, *args, **kwargs) -> str:
|
14
|
+
if self.parent_permission is None:
|
15
|
+
return self.name
|
16
|
+
return ".".join([self.parent_permission(), self.name])
|
17
|
+
|
18
|
+
def __getitem__(self, item):
|
19
|
+
return self.sub_permissions_by_name[item]
|
20
|
+
|
21
|
+
@property
|
22
|
+
def sub_permissions_by_name(self) -> dict[str, "Permission"]:
|
23
|
+
if self.sub_permissions is None:
|
24
|
+
return {}
|
25
|
+
return {item.name: item for item in self.sub_permissions}
|
26
|
+
|
27
|
+
@computed_field # type: ignore[misc]
|
28
|
+
@property
|
29
|
+
def value(self) -> str:
|
30
|
+
return self()
|
31
|
+
|
32
|
+
@computed_field # type: ignore[misc]
|
33
|
+
@property
|
34
|
+
def parent(self) -> str | None:
|
35
|
+
if self.parent_permission is not None:
|
36
|
+
return self.parent_permission()
|
37
|
+
return None
|
38
|
+
|
39
|
+
name: str
|
40
|
+
description: str
|
41
|
+
|
42
|
+
parent_permission: Optional["Permission"] = Field(exclude=True, default=None)
|
43
|
+
sub_permissions: list["Permission"] | None = None
|
44
|
+
|
45
|
+
|
46
|
+
READ_PERMISSION = Permission(name="read", description="Read access")
|
47
|
+
WRITE_PERMISSION = Permission(name="write", description="Write access")
|
48
|
+
DELETE_PERMISSION = Permission(name="delete", description="Delete access")
|
49
|
+
|
50
|
+
DEVICE_PERMISSIONS = Permission(
|
51
|
+
name="device",
|
52
|
+
description="Access to devices",
|
53
|
+
sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
|
54
|
+
)
|
55
|
+
SOFTWARE_PERMISSIONS = Permission(
|
56
|
+
name="software",
|
57
|
+
description="Access to software",
|
58
|
+
sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
|
59
|
+
)
|
60
|
+
ROLLOUT_PERMISSIONS = Permission(
|
61
|
+
name="rollout",
|
62
|
+
description="Access to rollouts",
|
63
|
+
sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
|
64
|
+
)
|
65
|
+
SETTINGS_USERS_PERMISSIONS = Permission(
|
66
|
+
name="users",
|
67
|
+
description="Access to user control",
|
68
|
+
sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
|
69
|
+
)
|
70
|
+
SETTING_PERMISSIONS = Permission(
|
71
|
+
name="settings",
|
72
|
+
description="Access to settings",
|
73
|
+
sub_permissions=[SETTINGS_USERS_PERMISSIONS],
|
74
|
+
)
|
75
|
+
|
76
|
+
GOOSEBIT_PERMISSIONS = Permission(
|
77
|
+
name="goosebit",
|
78
|
+
description="Full access to GooseBit",
|
79
|
+
sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS],
|
80
|
+
)
|
goosebit/db/config.py
CHANGED
@@ -1,10 +1,66 @@
|
|
1
|
+
import logging
|
2
|
+
from urllib.parse import parse_qs, urlparse
|
3
|
+
|
4
|
+
from goosebit.db.pg_ssl_context import PostgresSSLContext
|
1
5
|
from goosebit.settings import config
|
2
6
|
|
7
|
+
|
8
|
+
def add_models(models_path: str):
|
9
|
+
models.append(models_path)
|
10
|
+
|
11
|
+
|
12
|
+
models = ["goosebit.db.models", "aerich.models"]
|
13
|
+
|
3
14
|
TORTOISE_CONF = {
|
4
15
|
"connections": {"default": config.db_uri},
|
5
16
|
"apps": {
|
6
17
|
"models": {
|
7
|
-
"models":
|
18
|
+
"models": models,
|
8
19
|
},
|
9
20
|
},
|
10
21
|
}
|
22
|
+
|
23
|
+
if config.db_ssl_crt is not None:
|
24
|
+
# config.db_uri must have the following style:
|
25
|
+
# postgres://<user>:<password>@<host>:<port>/<database>?sslmode=<sslmode>
|
26
|
+
parsed = urlparse(config.db_uri)
|
27
|
+
if parsed.scheme != "postgres":
|
28
|
+
logging.error("database scheme must be postgres!")
|
29
|
+
exit(1)
|
30
|
+
|
31
|
+
# parse parameters
|
32
|
+
params = parse_qs(parsed.query)
|
33
|
+
|
34
|
+
# create ssl context for postgres
|
35
|
+
ssl_context = PostgresSSLContext()
|
36
|
+
|
37
|
+
# set certificate file
|
38
|
+
ssl_context.load_verify_locations(config.db_ssl_crt)
|
39
|
+
|
40
|
+
# parse and set verify-flags
|
41
|
+
if params.get("verifyflags") is not None:
|
42
|
+
ssl_context.parse_verify_flags(params["verifyflags"][0])
|
43
|
+
|
44
|
+
# parse and set ssl verify-mode
|
45
|
+
if params.get("sslmode") is not None:
|
46
|
+
ssl_context.parse_ssl_mode(params["sslmode"][0])
|
47
|
+
|
48
|
+
# update database configuration
|
49
|
+
TORTOISE_CONF = {
|
50
|
+
"connections": {
|
51
|
+
"default": {
|
52
|
+
"engine": "tortoise.backends.asyncpg",
|
53
|
+
"credentials": {
|
54
|
+
"host": parsed.hostname,
|
55
|
+
"port": parsed.port,
|
56
|
+
"user": parsed.username,
|
57
|
+
"password": parsed.password,
|
58
|
+
"database": parsed.path.lstrip("/"),
|
59
|
+
"ssl": ssl_context.context,
|
60
|
+
},
|
61
|
+
},
|
62
|
+
},
|
63
|
+
"apps": {
|
64
|
+
"models": {"models": ["goosebit.db.models", "aerich.models"], "default_connection": "default"},
|
65
|
+
},
|
66
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
return """
|
6
|
+
CREATE INDEX "idx_rollout_software_3614e1" ON "rollout" ("software_id");"""
|
7
|
+
|
8
|
+
|
9
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
10
|
+
return """
|
11
|
+
DROP INDEX "idx_rollout_software_3614e1";"""
|
@@ -0,0 +1,11 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
return """
|
6
|
+
ALTER TABLE "device" ADD "auth_token" VARCHAR(32);"""
|
7
|
+
|
8
|
+
|
9
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
10
|
+
return """
|
11
|
+
ALTER TABLE "device" DROP COLUMN "auth_token";"""
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
return """
|
6
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
7
|
+
"username" VARCHAR(255) NOT NULL PRIMARY KEY,
|
8
|
+
"hashed_pwd" VARCHAR(255) NOT NULL,
|
9
|
+
"permissions" JSON NOT NULL,
|
10
|
+
"enabled" INT NOT NULL DEFAULT 1
|
11
|
+
);"""
|
12
|
+
|
13
|
+
|
14
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
15
|
+
return """
|
16
|
+
DROP TABLE IF EXISTS "user";"""
|
@@ -0,0 +1,11 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
return """
|
6
|
+
ALTER TABLE "device" RENAME COLUMN "uuid" TO "id";"""
|
7
|
+
|
8
|
+
|
9
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
10
|
+
return """
|
11
|
+
ALTER TABLE "device" RENAME COLUMN "id" TO "uuid";"""
|
@@ -0,0 +1,83 @@
|
|
1
|
+
from tortoise import BaseDBAsyncClient
|
2
|
+
|
3
|
+
|
4
|
+
async def upgrade(db: BaseDBAsyncClient) -> str:
|
5
|
+
dialect = db.schema_generator.DIALECT
|
6
|
+
|
7
|
+
if dialect == "postgres":
|
8
|
+
return """
|
9
|
+
ALTER TABLE "device" ALTER COLUMN "feed" DROP NOT NULL;"""
|
10
|
+
|
11
|
+
return """PRAGMA foreign_keys=off;
|
12
|
+
|
13
|
+
CREATE TABLE "device_new" (
|
14
|
+
"id" CHAR(255) NOT NULL PRIMARY KEY,
|
15
|
+
"name" CHAR(255),
|
16
|
+
"assigned_software_id" INT,
|
17
|
+
"force_update" INT NOT NULL DEFAULT 0,
|
18
|
+
"sw_version" CHAR(255),
|
19
|
+
"hardware_id" INT NOT NULL,
|
20
|
+
"feed" CHAR(255) DEFAULT 'default', -- NULL allowed here
|
21
|
+
"update_mode" INT NOT NULL DEFAULT 0,
|
22
|
+
"last_state" INT NOT NULL DEFAULT 0,
|
23
|
+
"progress" INT,
|
24
|
+
"last_log" TEXT,
|
25
|
+
"last_seen" BIGINT,
|
26
|
+
"last_ip" CHAR(15),
|
27
|
+
"last_ipv6" CHAR(40),
|
28
|
+
"auth_token" CHAR(32)
|
29
|
+
);
|
30
|
+
|
31
|
+
INSERT INTO "device_new" SELECT
|
32
|
+
id, name, assigned_software_id, force_update, sw_version,
|
33
|
+
hardware_id, feed, update_mode, last_state, progress,
|
34
|
+
last_log, last_seen, last_ip, last_ipv6, auth_token
|
35
|
+
FROM "device";
|
36
|
+
|
37
|
+
DROP TABLE "device";
|
38
|
+
|
39
|
+
ALTER TABLE "device_new" RENAME TO "device";
|
40
|
+
|
41
|
+
PRAGMA foreign_keys=on;
|
42
|
+
"""
|
43
|
+
|
44
|
+
|
45
|
+
async def downgrade(db: BaseDBAsyncClient) -> str:
|
46
|
+
dialect = db.schema_generator.DIALECT
|
47
|
+
|
48
|
+
if dialect == "postgres":
|
49
|
+
return """
|
50
|
+
ALTER TABLE "device" ALTER COLUMN "feed" SET NOT NULL;"""
|
51
|
+
|
52
|
+
return """PRAGMA foreign_keys=off;
|
53
|
+
|
54
|
+
CREATE TABLE "device_old" (
|
55
|
+
"id" CHAR(255) NOT NULL PRIMARY KEY,
|
56
|
+
"name" CHAR(255),
|
57
|
+
"assigned_software_id" INT,
|
58
|
+
"force_update" INT NOT NULL DEFAULT 0,
|
59
|
+
"sw_version" CHAR(255),
|
60
|
+
"hardware_id" INT NOT NULL,
|
61
|
+
"feed" CHAR(255) NOT NULL DEFAULT 'default', -- NOT NULL again
|
62
|
+
"update_mode" INT NOT NULL DEFAULT 0,
|
63
|
+
"last_state" INT NOT NULL DEFAULT 0,
|
64
|
+
"progress" INT,
|
65
|
+
"last_log" TEXT,
|
66
|
+
"last_seen" BIGINT,
|
67
|
+
"last_ip" CHAR(15),
|
68
|
+
"last_ipv6" CHAR(40),
|
69
|
+
"auth_token" CHAR(32)
|
70
|
+
);
|
71
|
+
|
72
|
+
INSERT INTO "device_old" SELECT
|
73
|
+
id, name, assigned_software_id, force_update, sw_version,
|
74
|
+
hardware_id, feed, update_mode, last_state, progress,
|
75
|
+
last_log, last_seen, last_ip, last_ipv6, auth_token
|
76
|
+
FROM "device";
|
77
|
+
|
78
|
+
DROP TABLE "device";
|
79
|
+
|
80
|
+
ALTER TABLE "device_old" RENAME TO "device";
|
81
|
+
|
82
|
+
PRAGMA foreign_keys=on;
|
83
|
+
"""
|
goosebit/db/models.py
CHANGED
@@ -5,13 +5,12 @@ from typing import Self
|
|
5
5
|
from urllib.parse import unquote, urlparse
|
6
6
|
from urllib.request import url2pathname
|
7
7
|
|
8
|
-
import semver
|
9
8
|
from anyio import Path
|
10
|
-
from semver import Version
|
11
9
|
from tortoise import Model, fields
|
12
10
|
from tortoise.exceptions import ValidationError
|
13
11
|
|
14
12
|
from goosebit.api.telemetry.metrics import devices_count
|
13
|
+
from goosebit.util.version import Version
|
15
14
|
|
16
15
|
|
17
16
|
class UpdateModeEnum(IntEnum):
|
@@ -57,7 +56,7 @@ class Tag(Model):
|
|
57
56
|
|
58
57
|
|
59
58
|
class Device(Model):
|
60
|
-
|
59
|
+
id = fields.CharField(max_length=255, primary_key=True)
|
61
60
|
name = fields.CharField(max_length=255, null=True)
|
62
61
|
assigned_software = fields.ForeignKeyField(
|
63
62
|
"models.Software", related_name="assigned_devices", null=True, on_delete=fields.SET_NULL
|
@@ -65,7 +64,7 @@ class Device(Model):
|
|
65
64
|
force_update = fields.BooleanField(default=False)
|
66
65
|
sw_version = fields.CharField(max_length=255, null=True)
|
67
66
|
hardware = fields.ForeignKeyField("models.Hardware", related_name="devices")
|
68
|
-
feed = fields.CharField(max_length=255, default="default")
|
67
|
+
feed = fields.CharField(max_length=255, default="default", null=True)
|
69
68
|
update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
|
70
69
|
last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
|
71
70
|
progress = fields.IntField(null=True)
|
@@ -73,9 +72,14 @@ class Device(Model):
|
|
73
72
|
last_seen = fields.BigIntField(null=True)
|
74
73
|
last_ip = fields.CharField(max_length=15, null=True)
|
75
74
|
last_ipv6 = fields.CharField(max_length=40, null=True)
|
75
|
+
auth_token = fields.CharField(max_length=32, null=True)
|
76
76
|
tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
|
77
77
|
|
78
78
|
async def save(self, *args, **kwargs):
|
79
|
+
# ensure if using rollout that feed is set
|
80
|
+
if self.update_mode == UpdateModeEnum.ROLLOUT:
|
81
|
+
if self.feed is None:
|
82
|
+
raise ValidationError("Feed must be set in order to use rollout.")
|
79
83
|
# Check if the software is compatible with the hardware before saving
|
80
84
|
if self.assigned_software and self.hardware:
|
81
85
|
# Check if the assigned software is compatible with the hardware
|
@@ -107,7 +111,7 @@ class Rollout(Model):
|
|
107
111
|
created_at = fields.DatetimeField(auto_now_add=True)
|
108
112
|
name = fields.CharField(max_length=255, null=True)
|
109
113
|
feed = fields.CharField(max_length=255, default="default")
|
110
|
-
software = fields.ForeignKeyField("models.Software", related_name="rollouts")
|
114
|
+
software = fields.ForeignKeyField("models.Software", related_name="rollouts", db_index=True)
|
111
115
|
paused = fields.BooleanField(default=False)
|
112
116
|
success_count = fields.IntField(default=0)
|
113
117
|
failure_count = fields.IntField(default=0)
|
@@ -133,12 +137,12 @@ class Software(Model):
|
|
133
137
|
|
134
138
|
@classmethod
|
135
139
|
async def latest(cls, device: Device) -> Self | None:
|
136
|
-
updates = await cls.filter(
|
140
|
+
updates = await cls.filter(compatibility__devices__id=device.id)
|
137
141
|
if len(updates) == 0:
|
138
142
|
return None
|
139
143
|
return sorted(
|
140
144
|
updates,
|
141
|
-
key=lambda x:
|
145
|
+
key=lambda x: Version.parse(x.version),
|
142
146
|
reverse=True,
|
143
147
|
)[0]
|
144
148
|
|
@@ -159,4 +163,11 @@ class Software(Model):
|
|
159
163
|
|
160
164
|
@property
|
161
165
|
def parsed_version(self) -> Version:
|
162
|
-
return
|
166
|
+
return Version.parse(self.version)
|
167
|
+
|
168
|
+
|
169
|
+
class User(Model):
|
170
|
+
username = fields.CharField(max_length=255, primary_key=True, null=False)
|
171
|
+
hashed_pwd = fields.CharField(max_length=255, null=False)
|
172
|
+
permissions: fields.JSONField[list[str]] = fields.JSONField(default=[])
|
173
|
+
enabled = fields.BooleanField(default=True)
|
@@ -0,0 +1,51 @@
|
|
1
|
+
import logging
|
2
|
+
import ssl
|
3
|
+
|
4
|
+
|
5
|
+
class PostgresSSLContext:
|
6
|
+
context: ssl.SSLContext
|
7
|
+
|
8
|
+
def __init__(self):
|
9
|
+
# create ssl context in server-auth mode: this sets verify_mode = required and check_hostname = True
|
10
|
+
self.context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
|
11
|
+
|
12
|
+
def parse_ssl_mode(self, sslmode: str):
|
13
|
+
match sslmode:
|
14
|
+
case "none":
|
15
|
+
self.context.check_hostname = False
|
16
|
+
self.context.verify_mode = ssl.CERT_NONE
|
17
|
+
case "optional":
|
18
|
+
self.context.verify_mode = ssl.CERT_OPTIONAL
|
19
|
+
case "require":
|
20
|
+
self.context.verify_mode = ssl.CERT_REQUIRED
|
21
|
+
case _:
|
22
|
+
logging.error("sslmode must be either: none, optional or required!")
|
23
|
+
exit(1)
|
24
|
+
|
25
|
+
# parse and set verify-flags according to postgres string attributes
|
26
|
+
# default as defined in python3.13 lib/ssl.py: https://github.com/python/cpython/blob/3.13/Lib/ssl.py#L713)
|
27
|
+
# is the following: (ssl.VERIFY_X509_PARTIAL_CHAIN | ssl.VERIFY_X509_STRICT)
|
28
|
+
def parse_verify_flags(self, verifyflags: str):
|
29
|
+
self.context.verify_flags = ssl.VerifyFlags(0)
|
30
|
+
for f in verifyflags.split("|"):
|
31
|
+
match f:
|
32
|
+
case "default":
|
33
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_DEFAULT
|
34
|
+
case "crl_check_leaf":
|
35
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_CRL_CHECK_LEAF
|
36
|
+
case "crl_check_chain":
|
37
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN
|
38
|
+
case "x509_strict":
|
39
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_STRICT
|
40
|
+
case "allow_proxy_certs":
|
41
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_ALLOW_PROXY_CERTS
|
42
|
+
case "x509_trusted_first":
|
43
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_TRUSTED_FIRST
|
44
|
+
case "x509_partial_chain":
|
45
|
+
self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_PARTIAL_CHAIN
|
46
|
+
case _:
|
47
|
+
logging.error(f"verify-flag is undefined: {f}")
|
48
|
+
exit(1)
|
49
|
+
|
50
|
+
def load_verify_locations(self, file):
|
51
|
+
self.context.load_verify_locations(file)
|