aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-09-04 17:09:24 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-09-04 17:09:24 +0200
commit6b6f20a032eb31d747366c8bc270ca81731eaf6a (patch)
treeada48080495267bebde4ee1251bbbe77d629745f
parent2d22158dd95f76399f5716d556be60c77da04a46 (diff)
downloadserver-v3.tar.gz
server-v3.tar.bz2
server-v3.zip
Fixed pre-commit issuesv3
-rw-r--r--.gitignore112
-rw-r--r--.pre-commit-config.yaml16
-rw-r--r--authentication/authlib.py77
-rw-r--r--authentication/backend.py49
-rw-r--r--authentication/endpoints.py177
-rw-r--r--authentication/mixins.py12
-rw-r--r--authentication/utils.py10
-rw-r--r--conf/__init__.py6
-rw-r--r--conf/local.py7
-rw-r--r--db/migrations/README2
-rw-r--r--db/migrations/env.py10
-rw-r--r--db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py33
-rw-r--r--db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py160
-rw-r--r--db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py21
-rw-r--r--db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py113
-rw-r--r--db/models.py21
-rw-r--r--db/session.py17
-rwxr-xr-xdev.sh3
-rw-r--r--management/endpoints.py33
-rw-r--r--proxy/__init__.py6
-rw-r--r--proxy/endpoints.py25
-rw-r--r--proxy/queue.py63
-rw-r--r--proxy/utils.py6
-rw-r--r--proxy/websockets.py48
-rw-r--r--pyproject.toml5
-rw-r--r--ttun_server/__init__.py19
-rw-r--r--ttun_server/endpoints.py2
-rw-r--r--ttun_server/redis.py4
-rw-r--r--ttun_server/types.py32
-rw-r--r--ttun_server/websockets.py60
-rw-r--r--uv.lock105
31 files changed, 812 insertions, 442 deletions
diff --git a/.gitignore b/.gitignore
index a9d7b09..69d3fbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,37 @@
1# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,vim
2# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,macos,vim
1 3
2# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all 4### macOS ###
3# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all 5# General
6.DS_Store
7.AppleDouble
8.LSOverride
9
10# Icon must end with two \r
11Icon
12
13# Thumbnails
14._*
15
16# Files that might appear in the root of a volume
17.DocumentRevisions-V100
18.fseventsd
19.Spotlight-V100
20.TemporaryItems
21.Trashes
22.VolumeIcon.icns
23.com.apple.timemachine.donotpresent
24
25# Directories potentially created on remote AFP share
26.AppleDB
27.AppleDesktop
28Network Trash Folder
29Temporary Items
30.apdisk
31
32### macOS Patch ###
33# iCloud generated files
34*.icloud
4 35
5### PyCharm+all ### 36### PyCharm+all ###
6# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 37# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
@@ -66,6 +97,9 @@ atlassian-ide-plugin.xml
66# Cursive Clojure plugin 97# Cursive Clojure plugin
67.idea/replstate.xml 98.idea/replstate.xml
68 99
100# SonarLint plugin
101.idea/sonarlint/
102
69# Crashlytics plugin (for Android Studio and IntelliJ) 103# Crashlytics plugin (for Android Studio and IntelliJ)
70com_crashlytics_export_strings.xml 104com_crashlytics_export_strings.xml
71crashlytics.properties 105crashlytics.properties
@@ -79,20 +113,13 @@ fabric.properties
79.idea/caches/build_file_checksums.ser 113.idea/caches/build_file_checksums.ser
80 114
81### PyCharm+all Patch ### 115### PyCharm+all Patch ###
82# Ignores the whole .idea folder and all .iml files 116# Ignore everything but code style settings and run configurations
83# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 117# that are supposed to be shared within teams.
84
85.idea/
86
87# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
88 118
89*.iml 119.idea/*
90modules.xml
91.idea/misc.xml
92*.ipr
93 120
94# Sonarlint plugin 121!.idea/codeStyles
95.idea/sonarlint 122!.idea/runConfigurations
96 123
97### Python ### 124### Python ###
98# Byte-compiled / optimized / DLL files 125# Byte-compiled / optimized / DLL files
@@ -191,7 +218,22 @@ ipython_config.py
191# install all needed dependencies. 218# install all needed dependencies.
192#Pipfile.lock 219#Pipfile.lock
193 220
194# PEP 582; used by e.g. github.com/David-OConnor/pyflow 221# poetry
222# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
223# This is especially recommended for binary packages to ensure reproducibility, and is more
224# commonly ignored for libraries.
225# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
226#poetry.lock
227
228# pdm
229# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
230#pdm.lock
231# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
232# in version control.
233# https://pdm.fming.dev/#use-with-ide
234.pdm.toml
235
236# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
195__pypackages__/ 237__pypackages__/
196 238
197# Celery stuff 239# Celery stuff
@@ -234,6 +276,42 @@ dmypy.json
234# Cython debug symbols 276# Cython debug symbols
235cython_debug/ 277cython_debug/
236 278
237# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all 279# PyCharm
280# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
281# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
282# and can be added to the global gitignore or merged into this file. For a more nuclear
283# option (not recommended) you can uncomment the following to ignore the entire idea folder.
284#.idea/
238 285
239.DS_Store 286### Python Patch ###
287# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
288poetry.toml
289
290# ruff
291.ruff_cache/
292
293# LSP config files
294pyrightconfig.json
295
296### Vim ###
297# Swap
298[._]*.s[a-v][a-z]
299!*.svg # comment out if you don't need vector files
300[._]*.sw[a-p]
301[._]s[a-rt-v][a-z]
302[._]ss[a-gi-z]
303[._]sw[a-p]
304
305# Session
306Session.vim
307Sessionx.vim
308
309# Temporary
310.netrwhist
311*~
312# Auto-generated tag files
313tags
314# Persistent undo
315[._]*.un~
316
317# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,vim
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..25e095a
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,16 @@
1repos:
2 - repo: https://github.com/pre-commit/pre-commit-hooks
3 rev: v6.0.0
4 hooks:
5 - id: check-yaml
6 - id: end-of-file-fixer
7 - id: trailing-whitespace
8 - repo: https://github.com/astral-sh/ruff-pre-commit
9 # Ruff version.
10 rev: v0.16.6
11 hooks:
12 # Run the linter.
13 - id: ruff-check
14 args: [ --fix ]
15 # Run the formatter.
16 - id: ruff-format
diff --git a/authentication/authlib.py b/authentication/authlib.py
index 8472062..a37d68f 100644
--- a/authentication/authlib.py
+++ b/authentication/authlib.py
@@ -1,31 +1,37 @@
1import json 1import json
2from collections import defaultdict 2from collections import defaultdict
3from typing import ClassVar
3from unittest.mock import patch 4from unittest.mock import patch
4 5
5from authlib.common.security import generate_token as generate_random_token 6from authlib.common.security import generate_token as generate_random_token
6from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request 7from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer
7from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \ 8from authlib.oauth2 import OAuth2Request
8 RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload 9from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant
10from authlib.oauth2.rfc6749 import OAuth2Payload
11from authlib.oauth2.rfc6749 import RefreshTokenGrant as BaseRefreshTokenGrant
9from authlib.oauth2.rfc6750 import BearerTokenGenerator 12from authlib.oauth2.rfc6750 import BearerTokenGenerator
10from authlib.oauth2.rfc7636 import CodeChallenge 13from authlib.oauth2.rfc7636 import CodeChallenge
14from fastapi import Request, Response
11from sqlalchemy.exc import NoResultFound 15from sqlalchemy.exc import NoResultFound
12
13from sqlmodel import select 16from sqlmodel import select
14from fastapi import Request, Response
15 17
16from db.models import ClientApplication, AuthToken, AuthCode, User 18from db.models import AuthCode, AuthToken, ClientApplication, User
17from db.session import get_session_context 19from db.session import get_session_context
18 20
19 21
20class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): 22class AuthorizationCodeGrant(BaseAuthorizationCodeGrant):
21 TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] 23 TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = [
24 "client_secret_basic",
25 "client_secret_post",
26 "none",
27 ]
22 28
23 def save_authorization_code(self, code: str, request: OAuth2Request): 29 def save_authorization_code(self, code: str, request: OAuth2Request):
24 with get_session_context() as db_session: 30 with get_session_context() as db_session:
25 db_session.add(request.user) 31 db_session.add(request.user)
26 32
27 code_challenge = request.payload.data.get('code_challenge') 33 code_challenge = request.payload.data.get("code_challenge")
28 code_challenge_method = request.payload.data.get('code_challenge_method') 34 code_challenge_method = request.payload.data.get("code_challenge_method")
29 35
30 auth_code = AuthCode( 36 auth_code = AuthCode(
31 code=code, 37 code=code,
@@ -41,10 +47,16 @@ class AuthorizationCodeGrant(BaseAuthorizationCodeGrant):
41 47
42 return auth_code 48 return auth_code
43 49
44 def query_authorization_code(self, code: str, client: ClientApplication) -> AuthCode | None: 50 def query_authorization_code(
51 self, code: str, client: ClientApplication
52 ) -> AuthCode | None:
45 try: 53 try:
46 with get_session_context() as db_session: 54 with get_session_context() as db_session:
47 auth_code = db_session.exec(select(AuthCode).where(AuthCode.code == code, AuthCode.client_id == client.client_id)).one() 55 auth_code = db_session.exec(
56 select(AuthCode).where(
57 AuthCode.code == code, AuthCode.client_id == client.client_id
58 )
59 ).one()
48 except NoResultFound: 60 except NoResultFound:
49 return None 61 return None
50 62
@@ -65,7 +77,9 @@ class RefreshTokenGrant(BaseRefreshTokenGrant):
65 def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: 77 def authenticate_refresh_token(self, refresh_token) -> AuthToken | None:
66 try: 78 try:
67 with get_session_context() as db_session: 79 with get_session_context() as db_session:
68 token = db_session.exec(select(AuthToken).where(AuthToken.refresh_token == refresh_token)).one() 80 token = db_session.exec(
81 select(AuthToken).where(AuthToken.refresh_token == refresh_token)
82 ).one()
69 except NoResultFound: 83 except NoResultFound:
70 return None 84 return None
71 85
@@ -82,6 +96,7 @@ class RefreshTokenGrant(BaseRefreshTokenGrant):
82 credential.revoked = True 96 credential.revoked = True
83 db_session.add(credential) 97 db_session.add(credential)
84 98
99
85async def prepare_oauth_request(request: Request) -> Request: 100async def prepare_oauth_request(request: Request) -> Request:
86 async with request.form() as form: 101 async with request.form() as form:
87 request.state.form_data = dict(form) 102 request.state.form_data = dict(form)
@@ -96,7 +111,7 @@ class FastApiOAuth2Payload(OAuth2Payload):
96 def data(self): 111 def data(self):
97 return { 112 return {
98 **self._request.query_params, 113 **self._request.query_params,
99 **getattr(self._request.state, 'form_data', {}), 114 **getattr(self._request.state, "form_data", {}),
100 } 115 }
101 116
102 @property 117 @property
@@ -106,9 +121,10 @@ class FastApiOAuth2Payload(OAuth2Payload):
106 values[k].extend([self.data[k]]) 121 values[k].extend([self.data[k]])
107 return values 122 return values
108 123
124
109class FastApiOAuth2Request(OAuth2Request): 125class FastApiOAuth2Request(OAuth2Request):
110 def __init__(self, request: Request): 126 def __init__(self, request: Request):
111 with patch('authlib.oauth2.rfc6749.errors.InsecureTransportError'): 127 with patch("authlib.oauth2.rfc6749.errors.InsecureTransportError"):
112 super().__init__( 128 super().__init__(
113 method=request.method, 129 method=request.method,
114 uri=str(request.url), 130 uri=str(request.url),
@@ -128,7 +144,7 @@ class FastApiOAuth2Request(OAuth2Request):
128 144
129 @property 145 @property
130 def form(self): 146 def form(self):
131 return getattr(self._request.state, 'form_data', {}) 147 return getattr(self._request.state, "form_data", {})
132 148
133 149
134class AuthorizationServer(BaseAuthorizationServer): 150class AuthorizationServer(BaseAuthorizationServer):
@@ -136,22 +152,33 @@ class AuthorizationServer(BaseAuthorizationServer):
136 super().__init__(*args, **kwargs) 152 super().__init__(*args, **kwargs)
137 self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) 153 self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()])
138 self.register_grant(RefreshTokenGrant) 154 self.register_grant(RefreshTokenGrant)
139 self.register_token_generator("default", BearerTokenGenerator( 155 self.register_token_generator(
140 access_token_generator=lambda *args, **kwargs: generate_random_token(42), 156 "default",
141 refresh_token_generator=lambda *args, **kwargs: generate_random_token(48), 157 BearerTokenGenerator(
142 )) 158 access_token_generator=lambda *args, **kwargs: generate_random_token(
159 42
160 ),
161 refresh_token_generator=lambda *args, **kwargs: generate_random_token(
162 48
163 ),
164 ),
165 )
143 166
144 def query_client(self, client_id: str) -> ClientApplication: 167 def query_client(self, client_id: str) -> ClientApplication:
145 with get_session_context() as db_session: 168 with get_session_context() as db_session:
146 return db_session.exec(select(ClientApplication).where(ClientApplication.client_id == client_id)).one() 169 return db_session.exec(
170 select(ClientApplication).where(
171 ClientApplication.client_id == client_id
172 )
173 ).one()
147 174
148 def save_token(self, token: dict, request: OAuth2Request): 175 def save_token(self, token: dict, request: OAuth2Request):
149 with get_session_context() as db_session: 176 with get_session_context() as db_session:
150 db_session.add(AuthToken( 177 db_session.add(
151 user_id=request.user.id, 178 AuthToken(
152 client_id=request.client.client_id, 179 user_id=request.user.id, client_id=request.client.client_id, **token
153 **token 180 )
154 )) 181 )
155 182
156 def create_oauth2_request(self, request: Request) -> OAuth2Request: 183 def create_oauth2_request(self, request: Request) -> OAuth2Request:
157 return FastApiOAuth2Request(request) 184 return FastApiOAuth2Request(request)
diff --git a/authentication/backend.py b/authentication/backend.py
index 9434906..58842de 100644
--- a/authentication/backend.py
+++ b/authentication/backend.py
@@ -1,52 +1,49 @@
1import re 1import re
2from typing import Optional
3 2
4from sqlalchemy import update 3from sqlalchemy import update
5from sqlmodel import select 4from sqlmodel import select
6from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser 5from starlette.authentication import (
6 AuthCredentials,
7 AuthenticationBackend,
8 BaseUser,
9 UnauthenticatedUser,
10)
7from starlette.requests import HTTPConnection 11from starlette.requests import HTTPConnection
8 12
9from db.models import Session, User, AuthToken 13from db.models import AuthToken, Session, User
10from db.session import get_session_context 14from db.session import get_session_context
11 15
12 16
13class SessionAuthBackend(AuthenticationBackend): 17class SessionAuthBackend(AuthenticationBackend):
14 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: 18 async def authenticate(
19 self, request: HTTPConnection
20 ) -> tuple[AuthCredentials, BaseUser] | None:
15 if "id" not in request.session: 21 if "id" not in request.session:
16 return None 22 return None
17 23
18 query = ( 24 query = select(Session).join(User).where(Session.id == request.session["id"])
19 select(Session)
20 .join(User)
21 .where(
22 Session.id == request.session['id']
23 )
24 )
25
26 25
27 with get_session_context() as db_session: 26 with get_session_context() as db_session:
28 session: Session = db_session.exec(query).first() 27 session: Session = db_session.exec(query).first()
29 28
30 if session is not None: 29 if session is not None:
31 db_session.execute( 30 db_session.execute(update(Session).where(Session.id == session.id))
32 update(Session).where(Session.id == session.id)
33 )
34 db_session.commit() 31 db_session.commit()
35 32
36
37 return ( 33 return (
38 AuthCredentials( 34 AuthCredentials(
39 ["authenticated", "session"] 35 ["authenticated", "session"] if session is not None else []
40 if session is not None
41 else []
42 ), 36 ),
43 session.user if session is not None else UnauthenticatedUser() 37 session.user if session is not None else UnauthenticatedUser(),
44 ) 38 )
45 39
46 40
47class BearerTokenAuthBackend(AuthenticationBackend): 41class BearerTokenAuthBackend(AuthenticationBackend):
48 regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$") 42 regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$")
49 async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: 43
44 async def authenticate(
45 self, request: HTTPConnection
46 ) -> tuple[AuthCredentials, BaseUser] | None:
50 token = None 47 token = None
51 if "Authorization" in request.headers: 48 if "Authorization" in request.headers:
52 match = self.regex.match(request.headers["Authorization"]) 49 match = self.regex.match(request.headers["Authorization"])
@@ -54,17 +51,13 @@ class BearerTokenAuthBackend(AuthenticationBackend):
54 query = ( 51 query = (
55 select(AuthToken) 52 select(AuthToken)
56 .join(User) 53 .join(User)
57 .where(AuthToken.access_token == match.group('token')) 54 .where(AuthToken.access_token == match.group("token"))
58 ) 55 )
59 56
60 with get_session_context() as db_session: 57 with get_session_context() as db_session:
61 token: AuthToken = db_session.exec(query).first() 58 token: AuthToken = db_session.exec(query).first()
62 59
63 return ( 60 return (
64 AuthCredentials( 61 AuthCredentials(["authenticated", "token"] if token is not None else []),
65 ["authenticated", "token"] 62 token.user if token is not None else UnauthenticatedUser(),
66 if token is not None
67 else []
68 ),
69 token.user if token is not None else UnauthenticatedUser()
70 ) 63 )
diff --git a/authentication/endpoints.py b/authentication/endpoints.py
index 3af49fc..17d49d7 100644
--- a/authentication/endpoints.py
+++ b/authentication/endpoints.py
@@ -4,8 +4,8 @@ from functools import partial
4from typing import Annotated, Self 4from typing import Annotated, Self
5from uuid import uuid7 5from uuid import uuid7
6 6
7from fastapi import FastAPI, Request, Form, Query 7from fastapi import FastAPI, Form, Query, Request
8from pydantic import BaseModel, ValidationError, model_validator, EmailStr 8from pydantic import BaseModel, EmailStr, ValidationError, model_validator
9from sqlalchemy import func 9from sqlalchemy import func
10from sqlmodel import select 10from sqlmodel import select
11from starlette.authentication import requires 11from starlette.authentication import requires
@@ -15,10 +15,14 @@ from starlette.responses import HTMLResponse, RedirectResponse
15from starlette.templating import Jinja2Templates 15from starlette.templating import Jinja2Templates
16 16
17import conf 17import conf
18from authentication.authlib import AuthorizationServer, AuthorizationCodeGrant, prepare_oauth_request 18from authentication.authlib import (
19 AuthorizationCodeGrant,
20 AuthorizationServer,
21 prepare_oauth_request,
22)
19from authentication.backend import SessionAuthBackend 23from authentication.backend import SessionAuthBackend
20from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query 24from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query
21from db.models import User, Session, ClientApplication 25from db.models import ClientApplication, Session, User
22from db.session import get_session_context 26from db.session import get_session_context
23 27
24authentication = FastAPI() 28authentication = FastAPI()
@@ -31,10 +35,7 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
31auth_server = AuthorizationServer() 35auth_server = AuthorizationServer()
32code_grant = partial(AuthorizationCodeGrant, server=auth_server) 36code_grant = partial(AuthorizationCodeGrant, server=auth_server)
33 37
34templates = Jinja2Templates(directory=[ 38templates = Jinja2Templates(directory=["templates", "authentication/templates"])
35 'templates',
36 'authentication/templates'
37])
38 39
39 40
40class LoginForm(BaseModel): 41class LoginForm(BaseModel):
@@ -42,57 +43,75 @@ class LoginForm(BaseModel):
42 password: str 43 password: str
43 44
44 45
45async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): 46async def base_login(
46 return templates.TemplateResponse(request, 'login.html', context={ 47 request: Request,
47 'form': form, 48 form: LoginForm | None = None,
48 'errors': errors, 49 errors: dict[str, str] | None = None,
49 }) 50):
51 return templates.TemplateResponse(
52 request,
53 "login.html",
54 context={
55 "form": form,
56 "errors": errors,
57 },
58 )
50 59
51 60
52@authentication.get('/login/', response_class=HTMLResponse) 61@authentication.get("/login/", response_class=HTMLResponse)
53async def get_login(request: Request, next: Annotated[str, Query()] = None): 62async def get_login(request: Request, next: Annotated[str | None, Query()] = None):
54 return await base_login(request) 63 return await base_login(request)
55 64
56 65
57@authentication.post('/login/', response_class=HTMLResponse) 66@authentication.post("/login/", response_class=HTMLResponse)
58async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', 67async def post_login(
59 next: Annotated[str, Query()] = None): 68 request: Request,
69 username: Annotated[str, Form()] = "",
70 password: Annotated[str, Form()] = "",
71 next: Annotated[str | None, Query()] = None,
72):
60 form = None 73 form = None
61 errors = {} 74 errors = {}
62 75
63 try: 76 try:
64 form = LoginForm.model_validate({ 77 form = LoginForm.model_validate({"username": username, "password": password})
65 'username': username,
66 'password': password
67 })
68 78
69 with get_session_context() as db_session: 79 with get_session_context() as db_session:
70 user = db_session.query(User).filter(User.username == form.username).first() 80 user = db_session.query(User).filter(User.username == form.username).first()
71 81
72 if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: 82 if (
73 errors.update({'password': 'Invalid username or password'}) 83 not bcrypt_sha256_verify(
84 password, user.password if user is not None else ""
85 )
86 or user is None
87 ):
88 errors.update({"password": "Invalid username or password"})
74 else: 89 else:
75 with get_session_context() as db_session: 90 with get_session_context() as db_session:
76 session = Session(id=uuid7(), user=user) 91 session = Session(id=uuid7(), user=user)
77 db_session.add(session) 92 db_session.add(session)
78 93
79 request.session.update({'id': str(session.id)}) 94 request.session.update({"id": str(session.id)})
80 95
81 if next is not None: 96 if next is not None:
82 return RedirectResponse(next, status_code=303) 97 return RedirectResponse(next, status_code=303)
83 except ValidationError as exc: 98 except ValidationError as exc:
84 errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) 99 errors.update({e["loc"][0]: e["msg"] for e in exc.errors()})
85 100
86 return await base_login(request, form, errors) 101 return await base_login(request, form, errors)
87 102
88 103
89@authentication.get('/logout/') 104@authentication.get("/logout/")
90def logout(request: Request): 105def logout(request: Request):
91 with get_session_context() as db_session: 106 with get_session_context() as db_session:
92 db_session.query(Session).filter(Session.id == request.session.get('id')).delete() 107 db_session.query(Session).filter(
108 Session.id == request.session.get("id")
109 ).delete()
93 110
94 request.session.clear() 111 request.session.clear()
95 return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303) 112 return RedirectResponse(
113 request.headers.get("Referer", "/management/dashboard/"), status_code=303
114 )
96 115
97 116
98class RegisterForm(BaseModel): 117class RegisterForm(BaseModel):
@@ -100,10 +119,10 @@ class RegisterForm(BaseModel):
100 password: str 119 password: str
101 verify_password: str 120 verify_password: str
102 121
103 @model_validator(mode='after') 122 @model_validator(mode="after")
104 def check_passwords_match(self) -> Self: 123 def check_passwords_match(self) -> Self:
105 if self.password != self.verify_password: 124 if self.password != self.verify_password:
106 raise ValueError('Passwords do not match') 125 raise ValueError("Passwords do not match")
107 return self 126 return self
108 127
109 128
@@ -113,31 +132,42 @@ def is_first_run() -> bool:
113 return user_count.first() == 0 132 return user_count.first() == 0
114 133
115 134
116async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): 135async def base_register(
117 return templates.TemplateResponse(request, 'register.html', context={ 136 request: Request,
118 'form': form, 137 form: RegisterForm | None = None,
119 'errors': errors, 138 errors: dict[str, str] | None = None,
120 'is_first_run': is_first_run(), 139):
121 }) 140 return templates.TemplateResponse(
141 request,
142 "register.html",
143 context={
144 "form": form,
145 "errors": errors,
146 "is_first_run": is_first_run(),
147 },
148 )
122 149
123 150
124@authentication.get('/register/', response_class=HTMLResponse) 151@authentication.get("/register/", response_class=HTMLResponse)
125async def get_register(request: Request, next: Annotated[str, Query()] = None): 152async def get_register(request: Request, next: Annotated[str | None, Query()] = None):
126 return await base_register(request) 153 return await base_register(request)
127 154
128 155
129@authentication.post('/register/', response_class=HTMLResponse) 156@authentication.post("/register/", response_class=HTMLResponse)
130async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', 157async def post_register(
131 verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): 158 request: Request,
159 email: Annotated[str, Form()] = "",
160 password: Annotated[str, Form()] = "",
161 verify_password: Annotated[str, Form()] = "",
162 next: Annotated[str | None, Query()] = None,
163):
132 form = None 164 form = None
133 errors = {} 165 errors = {}
134 166
135 try: 167 try:
136 form = RegisterForm.model_validate({ 168 form = RegisterForm.model_validate(
137 'email': email, 169 {"email": email, "password": password, "verify_password": verify_password}
138 'password': password, 170 )
139 'verify_password': verify_password
140 })
141 171
142 with get_session_context() as db_session: 172 with get_session_context() as db_session:
143 user = User(username=email, email=email) 173 user = User(username=email, email=email)
@@ -147,13 +177,18 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa
147 session = Session(id=uuid7(), user=user) 177 session = Session(id=uuid7(), user=user)
148 db_session.add(session) 178 db_session.add(session)
149 179
150 request.session.update({'id': str(session.id)}) 180 request.session.update({"id": str(session.id)})
151 181
152 if next is not None: 182 if next is not None:
153 return RedirectResponse(next, status_code=303) 183 return RedirectResponse(next, status_code=303)
154 184
155 except ValidationError as exc: 185 except ValidationError as exc:
156 errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) 186 errors.update(
187 {
188 (e["loc"][0] if e["loc"] else "verify_password"): e["msg"]
189 for e in exc.errors()
190 }
191 )
157 192
158 return await base_register(request, form, errors) 193 return await base_register(request, form, errors)
159 194
@@ -162,49 +197,59 @@ async def base_authorize(request: Request, user: User | None = None):
162 return auth_server.create_authorization_response(request, request.user, grant=None) 197 return auth_server.create_authorization_response(request, request.user, grant=None)
163 198
164 199
165@oauth.get('/authorize/') 200@oauth.get("/authorize/")
166@requires(['authenticated', 'session'], redirect='get_login') 201@requires(["authenticated", "session"], redirect="get_login")
167async def get_authorize(request: Request): 202async def get_authorize(request: Request):
168 return await base_authorize(request, request.user) 203 return await base_authorize(request, request.user)
169 204
170 205
171@oauth.post('/authorize/') 206@oauth.post("/authorize/")
172@requires(['authenticated', 'session'], redirect='get_login') 207@requires(["authenticated", "session"], redirect="get_login")
173async def post_authorize(request: Request): 208async def post_authorize(request: Request):
174 return await base_authorize(request, request.user) 209 return await base_authorize(request, request.user)
175 210
176 211
177@oauth.post('/token/') 212@oauth.post("/token/")
178async def post_token(request: Request): 213async def post_token(request: Request):
179 await prepare_oauth_request(request) 214 await prepare_oauth_request(request)
180 return auth_server.create_token_response(request) 215 return auth_server.create_token_response(request)
181 216
182 217
183@authentication.get('/connect/') 218@authentication.get("/connect/")
184async def connect(request: Request): 219async def connect(request: Request):
185 origin = request.url.scheme + '://' + request.url.netloc 220 origin = request.url.scheme + "://" + request.url.netloc
186 221
187 with get_session_context() as session: 222 with get_session_context() as session:
188 app = session.exec(select(ClientApplication)).first() 223 app = session.exec(select(ClientApplication)).first()
189 224
190 return RedirectResponse( 225 return RedirectResponse(
191 '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { 226 "/oauth"
192 'client_id': app.client_id, 227 + get_url_path_with_query(
193 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), 228 oauth,
194 'response_type': 'code', 229 "get_authorize",
195 'state': b64encode(json.dumps({ 230 {
196 'client_id': app.client_id, 231 "client_id": app.client_id,
197 }).encode()).decode(), 232 "redirect_uri": f"{origin}/oauth"
198 }), 233 + get_url_path_with_query(oauth, "callback"),
234 "response_type": "code",
235 "state": b64encode(
236 json.dumps(
237 {
238 "client_id": app.client_id,
239 }
240 ).encode()
241 ).decode(),
242 },
243 ),
199 status_code=303, 244 status_code=303,
200 ) 245 )
201 246
202 247
203@oauth.get('/config/') 248@oauth.get("/config/")
204async def get_config(request: Request): 249async def get_config(request: Request):
205 with get_session_context() as session: 250 with get_session_context() as session:
206 app = session.exec(select(ClientApplication)).first() 251 app = session.exec(select(ClientApplication)).first()
207 252
208 return { 253 return {
209 'client_id': app.client_id, 254 "client_id": app.client_id,
210 } 255 }
diff --git a/authentication/mixins.py b/authentication/mixins.py
index 3d6a91f..70816a1 100644
--- a/authentication/mixins.py
+++ b/authentication/mixins.py
@@ -1,7 +1,11 @@
1import json 1import json
2import time 2import time
3 3
4from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin, OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, OAuth2TokenMixin as BaseOAuth2TokenMixin 4from authlib.integrations.sqla_oauth2 import (
5 OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin,
6)
7from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin
8from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin as BaseOAuth2TokenMixin
5from sqlalchemy.sql.sqltypes import Text 9from sqlalchemy.sql.sqltypes import Text
6from sqlmodel import Field 10from sqlmodel import Field
7 11
@@ -11,9 +15,9 @@ class OAuth2ClientMixin(BaseOAuth2ClientMixin):
11 client_secret: str = Field(max_length=120, nullable=True) 15 client_secret: str = Field(max_length=120, nullable=True)
12 client_id_issued_at: int = Field(nullable=False, default=0) 16 client_id_issued_at: int = Field(nullable=False, default=0)
13 client_secret_expires_at: int = Field(nullable=False, default=0) 17 client_secret_expires_at: int = Field(nullable=False, default=0)
14 client_metadata_field: str = Field(sa_type=Text, sa_column_kwargs={ 18 client_metadata_field: str = Field(
15 'name': 'client_metadata' 19 sa_type=Text, sa_column_kwargs={"name": "client_metadata"}, nullable=True
16 }, nullable=True) 20 )
17 21
18 _client_metadata = None 22 _client_metadata = None
19 23
diff --git a/authentication/utils.py b/authentication/utils.py
index 4f19bc3..b0c9989 100644
--- a/authentication/utils.py
+++ b/authentication/utils.py
@@ -15,11 +15,11 @@ def bcrypt_sha256_hash(password: str) -> str:
15def bcrypt_sha256_verify(password: str, hashed: str) -> bool: 15def bcrypt_sha256_verify(password: str, hashed: str) -> bool:
16 digest = hashlib.sha256(password.encode("utf-8")).digest() 16 digest = hashlib.sha256(password.encode("utf-8")).digest()
17 encoded = base64.b64encode(digest) 17 encoded = base64.b64encode(digest)
18 return bcrypt.checkpw(encoded, hashed.encode('utf-8')) 18 return bcrypt.checkpw(encoded, hashed.encode("utf-8"))
19 19
20 20
21def get_url_path_with_query(app: FastAPI, name: str, params: dict | None = None, /, **path_params: str): 21def get_url_path_with_query(
22 app: FastAPI, name: str, params: dict | None = None, /, **path_params: str
23):
22 base = app.url_path_for(name, **path_params) 24 base = app.url_path_for(name, **path_params)
23 return base + '?' + urlencode(params) if params is not None else base 25 return base + "?" + urlencode(params) if params is not None else base
24
25
diff --git a/conf/__init__.py b/conf/__init__.py
index 744747f..213ff07 100644
--- a/conf/__init__.py
+++ b/conf/__init__.py
@@ -7,6 +7,6 @@ try:
7except ImportError: 7except ImportError:
8 pass 8 pass
9 9
10DATABASE_URL = os.environ.get('DATABASE_URL') 10DATABASE_URL = os.environ.get("DATABASE_URL")
11SECRET_KEY = os.environ.get('SECRET_KEY') 11SECRET_KEY = os.environ.get("SECRET_KEY")
12ENABLE_AUTH = os.environ.get('ENABLE_AUTH', 'false') == 'true' 12ENABLE_AUTH = os.environ.get("ENABLE_AUTH", "false") == "true"
diff --git a/conf/local.py b/conf/local.py
index 23f4005..8350831 100644
--- a/conf/local.py
+++ b/conf/local.py
@@ -2,6 +2,7 @@ import os
2 2
3IS_LOCAL = True 3IS_LOCAL = True
4 4
5os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun') 5os.environ.setdefault("TUNNEL_DOMAIN", "localhost")
6os.environ.setdefault('SECRET_KEY', 'secret') 6os.environ.setdefault("DATABASE_URL", "postgresql:///ttun")
7os.environ.setdefault('ENABLE_AUTH', 'true') 7os.environ.setdefault("SECRET_KEY", "secret")
8os.environ.setdefault("ENABLE_AUTH", "true")
diff --git a/db/migrations/README b/db/migrations/README
index 98e4f9c..2500aa1 100644
--- a/db/migrations/README
+++ b/db/migrations/README
@@ -1 +1 @@
Generic single-database configuration. \ No newline at end of file Generic single-database configuration.
diff --git a/db/migrations/env.py b/db/migrations/env.py
index f1b3156..9c901da 100644
--- a/db/migrations/env.py
+++ b/db/migrations/env.py
@@ -1,9 +1,7 @@
1from logging.config import fileConfig 1from logging.config import fileConfig
2 2
3from sqlalchemy import engine_from_config
4from sqlalchemy import pool
5
6from alembic import context 3from alembic import context
4from sqlalchemy import engine_from_config, pool
7 5
8from conf import DATABASE_URL 6from conf import DATABASE_URL
9from db.models import * 7from db.models import *
@@ -27,7 +25,7 @@ target_metadata = SQLModel.metadata
27# can be acquired: 25# can be acquired:
28# my_important_option = config.get_main_option("my_important_option") 26# my_important_option = config.get_main_option("my_important_option")
29# ... etc. 27# ... etc.
30config.set_main_option('sqlalchemy.url', DATABASE_URL) 28config.set_main_option("sqlalchemy.url", DATABASE_URL)
31 29
32 30
33def run_migrations_offline() -> None: 31def run_migrations_offline() -> None:
@@ -68,9 +66,7 @@ def run_migrations_online() -> None:
68 ) 66 )
69 67
70 with connectable.connect() as connection: 68 with connectable.connect() as connection:
71 context.configure( 69 context.configure(connection=connection, target_metadata=target_metadata)
72 connection=connection, target_metadata=target_metadata
73 )
74 70
75 with context.begin_transaction(): 71 with context.begin_transaction():
76 context.run_migrations() 72 context.run_migrations()
diff --git a/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py b/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py
index 3ccb92d..0bb3076 100644
--- a/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py
+++ b/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py
@@ -1,40 +1,41 @@
1"""Added user model 1"""Added user model
2 2
3Revision ID: 115caa5a76d0 3Revision ID: 115caa5a76d0
4Revises: 4Revises:
5Create Date: 2026-07-02 17:19:07.082841 5Create Date: 2026-07-02 17:19:07.082841
6 6
7""" 7"""
8from typing import Sequence, Union
9 8
10from alembic import op 9from collections.abc import Sequence
10
11import sqlalchemy as sa 11import sqlalchemy as sa
12import sqlmodel 12import sqlmodel
13 13from alembic import op
14 14
15# revision identifiers, used by Alembic. 15# revision identifiers, used by Alembic.
16revision: str = '115caa5a76d0' 16revision: str = "115caa5a76d0"
17down_revision: Union[str, Sequence[str], None] = None 17down_revision: str | Sequence[str] | None = None
18branch_labels: Union[str, Sequence[str], None] = None 18branch_labels: str | Sequence[str] | None = None
19depends_on: Union[str, Sequence[str], None] = None 19depends_on: str | Sequence[str] | None = None
20 20
21 21
22def upgrade() -> None: 22def upgrade() -> None:
23 """Upgrade schema.""" 23 """Upgrade schema."""
24 # ### commands auto generated by Alembic - please adjust! ### 24 # ### commands auto generated by Alembic - please adjust! ###
25 op.create_table('user', 25 op.create_table(
26 sa.Column('id', sa.Uuid(), nullable=False), 26 "user",
27 sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 27 sa.Column("id", sa.Uuid(), nullable=False),
28 sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 28 sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
29 sa.PrimaryKeyConstraint('id') 29 sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
30 sa.PrimaryKeyConstraint("id"),
30 ) 31 )
31 op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) 32 op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True)
32 # ### end Alembic commands ### 33 # ### end Alembic commands ###
33 34
34 35
35def downgrade() -> None: 36def downgrade() -> None:
36 """Downgrade schema.""" 37 """Downgrade schema."""
37 # ### commands auto generated by Alembic - please adjust! ### 38 # ### commands auto generated by Alembic - please adjust! ###
38 op.drop_index(op.f('ix_user_username'), table_name='user') 39 op.drop_index(op.f("ix_user_username"), table_name="user")
39 op.drop_table('user') 40 op.drop_table("user")
40 # ### end Alembic commands ### 41 # ### end Alembic commands ###
diff --git a/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py b/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py
index 5498291..b94ffb8 100644
--- a/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py
+++ b/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py
@@ -5,100 +5,116 @@ Revises: 115caa5a76d0
5Create Date: 2026-07-02 17:40:58.968553 5Create Date: 2026-07-02 17:40:58.968553
6 6
7""" 7"""
8
8import json 9import json
9import os 10import os
10from datetime import datetime, UTC 11from collections.abc import Sequence
12from datetime import UTC, datetime
11from secrets import token_hex 13from secrets import token_hex
12from random import randbytes
13from typing import Sequence, Union
14from uuid import uuid4, uuid7 14from uuid import uuid4, uuid7
15 15
16from alembic import op
17import sqlalchemy as sa 16import sqlalchemy as sa
18import sqlmodel 17import sqlmodel
19from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin 18from alembic import op
20 19
21# revision identifiers, used by Alembic. 20# revision identifiers, used by Alembic.
22revision: str = 'fc6608d3f9cc' 21revision: str = "fc6608d3f9cc"
23down_revision: Union[str, Sequence[str], None] = '115caa5a76d0' 22down_revision: str | Sequence[str] | None = "115caa5a76d0"
24branch_labels: Union[str, Sequence[str], None] = None 23branch_labels: str | Sequence[str] | None = None
25depends_on: Union[str, Sequence[str], None] = None 24depends_on: str | Sequence[str] | None = None
26 25
27 26
28def upgrade() -> None: 27def upgrade() -> None:
29 """Upgrade schema.""" 28 """Upgrade schema."""
30 # ### commands auto generated by Alembic - please adjust! ### 29 # ### commands auto generated by Alembic - please adjust! ###
31 client_application = op.create_table('clientapplication', 30 client_application = op.create_table(
32 sa.Column('id', sa.Uuid(), nullable=False), 31 "clientapplication",
33 sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 32 sa.Column("id", sa.Uuid(), nullable=False),
34 sa.Column('client_id', sa.String(length=48), nullable=True), 33 sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
35 sa.Column('client_secret', sa.String(length=120), nullable=True), 34 sa.Column("client_id", sa.String(length=48), nullable=True),
36 sa.Column('client_id_issued_at', sa.Integer(), nullable=False), 35 sa.Column("client_secret", sa.String(length=120), nullable=True),
37 sa.Column('client_secret_expires_at', sa.Integer(), nullable=False), 36 sa.Column("client_id_issued_at", sa.Integer(), nullable=False),
38 sa.Column('client_metadata', sa.Text(), nullable=True), 37 sa.Column("client_secret_expires_at", sa.Integer(), nullable=False),
39 sa.PrimaryKeyConstraint('id') 38 sa.Column("client_metadata", sa.Text(), nullable=True),
39 sa.PrimaryKeyConstraint("id"),
40 )
41 op.create_index(
42 op.f("ix_clientapplication_client_id"),
43 "clientapplication",
44 ["client_id"],
45 unique=False,
40 ) 46 )
41 op.create_index(op.f('ix_clientapplication_client_id'), 'clientapplication', ['client_id'], unique=False) 47 op.create_table(
42 op.create_table('authcode', 48 "authcode",
43 sa.Column('id', sa.Uuid(), nullable=False), 49 sa.Column("id", sa.Uuid(), nullable=False),
44 sa.Column('user_id', sa.Uuid(), nullable=False), 50 sa.Column("user_id", sa.Uuid(), nullable=False),
45 sa.Column('code', sa.String(length=120), nullable=False), 51 sa.Column("code", sa.String(length=120), nullable=False),
46 sa.Column('client_id', sa.String(length=48), nullable=True), 52 sa.Column("client_id", sa.String(length=48), nullable=True),
47 sa.Column('redirect_uri', sa.Text(), nullable=True), 53 sa.Column("redirect_uri", sa.Text(), nullable=True),
48 sa.Column('response_type', sa.Text(), nullable=True), 54 sa.Column("response_type", sa.Text(), nullable=True),
49 sa.Column('scope', sa.Text(), nullable=True), 55 sa.Column("scope", sa.Text(), nullable=True),
50 sa.Column('nonce', sa.Text(), nullable=True), 56 sa.Column("nonce", sa.Text(), nullable=True),
51 sa.Column('auth_time', sa.Integer(), nullable=False), 57 sa.Column("auth_time", sa.Integer(), nullable=False),
52 sa.Column('acr', sa.Text(), nullable=True), 58 sa.Column("acr", sa.Text(), nullable=True),
53 sa.Column('amr', sa.Text(), nullable=True), 59 sa.Column("amr", sa.Text(), nullable=True),
54 sa.Column('code_challenge', sa.Text(), nullable=True), 60 sa.Column("code_challenge", sa.Text(), nullable=True),
55 sa.Column('code_challenge_method', sa.String(length=48), nullable=True), 61 sa.Column("code_challenge_method", sa.String(length=48), nullable=True),
56 sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), 62 sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
57 sa.PrimaryKeyConstraint('id'), 63 sa.PrimaryKeyConstraint("id"),
58 sa.UniqueConstraint('code') 64 sa.UniqueConstraint("code"),
59 ) 65 )
60 op.create_table('authtoken', 66 op.create_table(
61 sa.Column('id', sa.Uuid(), nullable=False), 67 "authtoken",
62 sa.Column('user_id', sa.Uuid(), nullable=False), 68 sa.Column("id", sa.Uuid(), nullable=False),
63 sa.Column('client_id', sa.String(length=48), nullable=True), 69 sa.Column("user_id", sa.Uuid(), nullable=False),
64 sa.Column('token_type', sa.String(length=40), nullable=True), 70 sa.Column("client_id", sa.String(length=48), nullable=True),
65 sa.Column('access_token', sa.String(length=255), nullable=False), 71 sa.Column("token_type", sa.String(length=40), nullable=True),
66 sa.Column('refresh_token', sa.String(length=255), nullable=True), 72 sa.Column("access_token", sa.String(length=255), nullable=False),
67 sa.Column('scope', sa.Text(), nullable=True), 73 sa.Column("refresh_token", sa.String(length=255), nullable=True),
68 sa.Column('issued_at', sa.Integer(), nullable=False), 74 sa.Column("scope", sa.Text(), nullable=True),
69 sa.Column('access_token_revoked_at', sa.Integer(), nullable=False), 75 sa.Column("issued_at", sa.Integer(), nullable=False),
70 sa.Column('refresh_token_revoked_at', sa.Integer(), nullable=False), 76 sa.Column("access_token_revoked_at", sa.Integer(), nullable=False),
71 sa.Column('expires_in', sa.Integer(), nullable=False), 77 sa.Column("refresh_token_revoked_at", sa.Integer(), nullable=False),
72 sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), 78 sa.Column("expires_in", sa.Integer(), nullable=False),
73 sa.PrimaryKeyConstraint('id'), 79 sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
74 sa.UniqueConstraint('access_token') 80 sa.PrimaryKeyConstraint("id"),
81 sa.UniqueConstraint("access_token"),
82 )
83 op.create_index(
84 op.f("ix_authtoken_refresh_token"), "authtoken", ["refresh_token"], unique=False
75 ) 85 )
76 op.create_index(op.f('ix_authtoken_refresh_token'), 'authtoken', ['refresh_token'], unique=False)
77 86
78 op.bulk_insert(client_application, [ 87 op.bulk_insert(
79 { 88 client_application,
80 'id': uuid7(), 89 [
81 'name': 'TTUN', 90 {
82 'client_id': uuid4().hex, 91 "id": uuid7(),
83 'client_secret': token_hex(32), 92 "name": "TTUN",
84 'client_id_issued_at': datetime.now(tz=UTC).timestamp(), 93 "client_id": uuid4().hex,
85 'client_secret_expires_at': datetime.now(tz=UTC).timestamp() + 3600, 94 "client_secret": token_hex(32),
86 'client_metadata': json.dumps({ 95 "client_id_issued_at": datetime.now(tz=UTC).timestamp(),
87 'redirect_uris': [ 96 "client_secret_expires_at": datetime.now(tz=UTC).timestamp() + 3600,
88 f'//{os.environ['TUNNEL_DOMAIN']}/oauth/callback' 97 "client_metadata": json.dumps(
89 ], 98 {
90 }) 99 "redirect_uris": [
91 } 100 f"//{os.environ['TUNNEL_DOMAIN']}/oauth/callback"
92 ]) 101 ],
102 }
103 ),
104 }
105 ],
106 )
93 # ### end Alembic commands ### 107 # ### end Alembic commands ###
94 108
95 109
96def downgrade() -> None: 110def downgrade() -> None:
97 """Downgrade schema.""" 111 """Downgrade schema."""
98 # ### commands auto generated by Alembic - please adjust! ### 112 # ### commands auto generated by Alembic - please adjust! ###
99 op.drop_index(op.f('ix_authtoken_refresh_token'), table_name='authtoken') 113 op.drop_index(op.f("ix_authtoken_refresh_token"), table_name="authtoken")
100 op.drop_table('authtoken') 114 op.drop_table("authtoken")
101 op.drop_table('authcode') 115 op.drop_table("authcode")
102 op.drop_index(op.f('ix_clientapplication_client_id'), table_name='clientapplication') 116 op.drop_index(
103 op.drop_table('clientapplication') 117 op.f("ix_clientapplication_client_id"), table_name="clientapplication"
118 )
119 op.drop_table("clientapplication")
104 # ### end Alembic commands ### 120 # ### end Alembic commands ###
diff --git a/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py b/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py
index 116d27e..5ad10c9 100644
--- a/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py
+++ b/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py
@@ -5,29 +5,32 @@ Revises: fc6608d3f9cc
5Create Date: 2026-07-02 18:03:17.190700 5Create Date: 2026-07-02 18:03:17.190700
6 6
7""" 7"""
8from typing import Sequence, Union
9 8
10from alembic import op 9from collections.abc import Sequence
10
11import sqlalchemy as sa 11import sqlalchemy as sa
12import sqlmodel 12import sqlmodel
13 13from alembic import op
14 14
15# revision identifiers, used by Alembic. 15# revision identifiers, used by Alembic.
16revision: str = 'ac4fbf1fea31' 16revision: str = "ac4fbf1fea31"
17down_revision: Union[str, Sequence[str], None] = 'fc6608d3f9cc' 17down_revision: str | Sequence[str] | None = "fc6608d3f9cc"
18branch_labels: Union[str, Sequence[str], None] = None 18branch_labels: str | Sequence[str] | None = None
19depends_on: Union[str, Sequence[str], None] = None 19depends_on: str | Sequence[str] | None = None
20 20
21 21
22def upgrade() -> None: 22def upgrade() -> None:
23 """Upgrade schema.""" 23 """Upgrade schema."""
24 # ### commands auto generated by Alembic - please adjust! ### 24 # ### commands auto generated by Alembic - please adjust! ###
25 op.add_column('user', sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) 25 op.add_column(
26 "user",
27 sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
28 )
26 # ### end Alembic commands ### 29 # ### end Alembic commands ###
27 30
28 31
29def downgrade() -> None: 32def downgrade() -> None:
30 """Downgrade schema.""" 33 """Downgrade schema."""
31 # ### commands auto generated by Alembic - please adjust! ### 34 # ### commands auto generated by Alembic - please adjust! ###
32 op.drop_column('user', 'password') 35 op.drop_column("user", "password")
33 # ### end Alembic commands ### 36 # ### end Alembic commands ###
diff --git a/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py
index ec5031b..40b8a70 100644
--- a/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py
+++ b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py
@@ -5,52 +5,101 @@ Revises: ac4fbf1fea31
5Create Date: 2026-07-03 14:40:55.628816 5Create Date: 2026-07-03 14:40:55.628816
6 6
7""" 7"""
8from typing import Sequence, Union
9 8
10from alembic import op 9from collections.abc import Sequence
11import sqlalchemy as sa
12import sqlmodel
13 10
11import sqlalchemy as sa
12from alembic import op
14 13
15# revision identifiers, used by Alembic. 14# revision identifiers, used by Alembic.
16revision: str = '69d8b4938a12' 15revision: str = "69d8b4938a12"
17down_revision: Union[str, Sequence[str], None] = 'ac4fbf1fea31' 16down_revision: str | Sequence[str] | None = "ac4fbf1fea31"
18branch_labels: Union[str, Sequence[str], None] = None 17branch_labels: str | Sequence[str] | None = None
19depends_on: Union[str, Sequence[str], None] = None 18depends_on: str | Sequence[str] | None = None
20 19
21 20
22def upgrade() -> None: 21def upgrade() -> None:
23 """Upgrade schema.""" 22 """Upgrade schema."""
24 # ### commands auto generated by Alembic - please adjust! ### 23 # ### commands auto generated by Alembic - please adjust! ###
25 op.create_table('session', 24 op.create_table(
26 sa.Column('id', sa.Uuid(), nullable=False), 25 "session",
27 sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), 26 sa.Column("id", sa.Uuid(), nullable=False),
28 sa.Column('updated', sa.DateTime(timezone=True), nullable=True), 27 sa.Column(
29 sa.Column('user_id', sa.Uuid(), nullable=False), 28 "created",
30 sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 29 sa.DateTime(timezone=True),
31 sa.PrimaryKeyConstraint('id') 30 nullable=False,
31 server_default=sa.text("now()"),
32 ),
33 sa.Column("updated", sa.DateTime(timezone=True), nullable=True),
34 sa.Column("user_id", sa.Uuid(), nullable=False),
35 sa.ForeignKeyConstraint(
36 ["user_id"],
37 ["user.id"],
38 ),
39 sa.PrimaryKeyConstraint("id"),
40 )
41 op.add_column(
42 "authcode",
43 sa.Column(
44 "created",
45 sa.DateTime(timezone=True),
46 nullable=False,
47 server_default=sa.text("now()"),
48 ),
49 )
50 op.add_column(
51 "authcode", sa.Column("updated", sa.DateTime(timezone=True), nullable=True)
52 )
53 op.add_column(
54 "authtoken",
55 sa.Column(
56 "created",
57 sa.DateTime(timezone=True),
58 nullable=False,
59 server_default=sa.text("now()"),
60 ),
61 )
62 op.add_column(
63 "authtoken", sa.Column("updated", sa.DateTime(timezone=True), nullable=True)
64 )
65 op.add_column(
66 "clientapplication",
67 sa.Column(
68 "created",
69 sa.DateTime(timezone=True),
70 nullable=False,
71 server_default=sa.text("now()"),
72 ),
73 )
74 op.add_column(
75 "clientapplication",
76 sa.Column("updated", sa.DateTime(timezone=True), nullable=True),
77 )
78 op.add_column(
79 "user",
80 sa.Column(
81 "created",
82 sa.DateTime(timezone=True),
83 nullable=False,
84 server_default=sa.text("now()"),
85 ),
86 )
87 op.add_column(
88 "user", sa.Column("updated", sa.DateTime(timezone=True), nullable=True)
32 ) 89 )
33 op.add_column('authcode', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
34 op.add_column('authcode', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
35 op.add_column('authtoken', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
36 op.add_column('authtoken', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
37 op.add_column('clientapplication', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
38 op.add_column('clientapplication', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
39 op.add_column('user', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')))
40 op.add_column('user', sa.Column('updated', sa.DateTime(timezone=True), nullable=True))
41 # ### end Alembic commands ### 90 # ### end Alembic commands ###
42 91
43 92
44def downgrade() -> None: 93def downgrade() -> None:
45 """Downgrade schema.""" 94 """Downgrade schema."""
46 # ### commands auto generated by Alembic - please adjust! ### 95 # ### commands auto generated by Alembic - please adjust! ###
47 op.drop_column('user', 'updated') 96 op.drop_column("user", "updated")
48 op.drop_column('user', 'created') 97 op.drop_column("user", "created")
49 op.drop_column('clientapplication', 'updated') 98 op.drop_column("clientapplication", "updated")
50 op.drop_column('clientapplication', 'created') 99 op.drop_column("clientapplication", "created")
51 op.drop_column('authtoken', 'updated') 100 op.drop_column("authtoken", "updated")
52 op.drop_column('authtoken', 'created') 101 op.drop_column("authtoken", "created")
53 op.drop_column('authcode', 'updated') 102 op.drop_column("authcode", "updated")
54 op.drop_column('authcode', 'created') 103 op.drop_column("authcode", "created")
55 op.drop_table('session') 104 op.drop_table("session")
56 # ### end Alembic commands ### 105 # ### end Alembic commands ###
diff --git a/db/models.py b/db/models.py
index 8a01c8c..8946bd2 100644
--- a/db/models.py
+++ b/db/models.py
@@ -1,13 +1,16 @@
1import re 1import re
2from datetime import datetime, UTC 2from datetime import UTC, datetime
3from urllib.parse import urlsplit 3from uuid import UUID, uuid7
4from uuid import uuid4, UUID, uuid7
5 4
6from sqlalchemy import DateTime 5from sqlalchemy import DateTime
7from sqlmodel import SQLModel, Field, Relationship 6from sqlmodel import Field, Relationship, SQLModel
8from starlette.authentication import BaseUser 7from starlette.authentication import BaseUser
9 8
10from authentication.mixins import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin 9from authentication.mixins import (
10 OAuth2AuthorizationCodeMixin,
11 OAuth2ClientMixin,
12 OAuth2TokenMixin,
13)
11from authentication.utils import bcrypt_sha256_hash 14from authentication.utils import bcrypt_sha256_hash
12 15
13 16
@@ -21,7 +24,7 @@ class BaseModel(SQLModel):
21 default=None, 24 default=None,
22 nullable=True, 25 nullable=True,
23 sa_column_kwargs={ 26 sa_column_kwargs={
24 'onupdate': lambda: datetime.now(tz=UTC), 27 "onupdate": lambda: datetime.now(tz=UTC),
25 }, 28 },
26 sa_type=DateTime(timezone=True), 29 sa_type=DateTime(timezone=True),
27 ) 30 )
@@ -39,10 +42,12 @@ class User(BaseModel, BaseUser, table=True):
39 def is_authenticated(self) -> bool: 42 def is_authenticated(self) -> bool:
40 return True 43 return True
41 44
45
42class Session(BaseModel, table=True): 46class Session(BaseModel, table=True):
43 user_id: UUID = Field(foreign_key='user.id', exclude=True) 47 user_id: UUID = Field(foreign_key="user.id", exclude=True)
44 user: User = Relationship() 48 user: User = Relationship()
45 49
50
46class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): 51class ClientApplication(BaseModel, OAuth2ClientMixin, table=True):
47 name: str 52 name: str
48 53
@@ -52,10 +57,12 @@ class ClientApplication(BaseModel, OAuth2ClientMixin, table=True):
52 for pattern in self.redirect_uris 57 for pattern in self.redirect_uris
53 ) 58 )
54 59
60
55class AuthCode(BaseModel, OAuth2AuthorizationCodeMixin, table=True): 61class AuthCode(BaseModel, OAuth2AuthorizationCodeMixin, table=True):
56 user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") 62 user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE")
57 user: User = Relationship() 63 user: User = Relationship()
58 64
65
59class AuthToken(BaseModel, OAuth2TokenMixin, table=True): 66class AuthToken(BaseModel, OAuth2TokenMixin, table=True):
60 user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") 67 user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE")
61 user: User = Relationship() 68 user: User = Relationship()
diff --git a/db/session.py b/db/session.py
index 82c0211..48ef3b6 100644
--- a/db/session.py
+++ b/db/session.py
@@ -1,21 +1,28 @@
1from contextlib import contextmanager 1from contextlib import contextmanager
2 2
3from sqlalchemy.orm import sessionmaker, scoped_session 3from sqlalchemy.orm import scoped_session, sessionmaker
4from sqlmodel import create_engine 4from sqlmodel import Session, create_engine
5from sqlmodel import Session
6 5
7from conf import DATABASE_URL 6from conf import DATABASE_URL
8from utils.json import json_serializer 7from utils.json import json_serializer
9 8
10 9engine = create_engine(
11engine = create_engine(DATABASE_URL, echo=True, future=True, json_serializer=json_serializer, pool_size=100, pool_recycle=3600) 10 DATABASE_URL,
11 echo=True,
12 future=True,
13 json_serializer=json_serializer,
14 pool_size=100,
15 pool_recycle=3600,
16)
12session_factory = sessionmaker(engine, class_=Session) 17session_factory = sessionmaker(engine, class_=Session)
13ScopedSession = scoped_session(session_factory=session_factory) 18ScopedSession = scoped_session(session_factory=session_factory)
14 19
20
15def get_session(): 21def get_session():
16 with ScopedSession() as session: 22 with ScopedSession() as session:
17 yield session 23 yield session
18 24
25
19@contextmanager 26@contextmanager
20def get_session_context(autocommit=True): 27def get_session_context(autocommit=True):
21 session = next(get_session()) 28 session = next(get_session())
diff --git a/dev.sh b/dev.sh
new file mode 100755
index 0000000..1795bf7
--- /dev/null
+++ b/dev.sh
@@ -0,0 +1,3 @@
1#!/bin/sh
2
3uv run fastapi dev ttun_server
diff --git a/management/endpoints.py b/management/endpoints.py
index ef44114..2dc77d4 100644
--- a/management/endpoints.py
+++ b/management/endpoints.py
@@ -18,10 +18,7 @@ management = FastAPI()
18management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend()) 18management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend())
19management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) 19management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY)
20 20
21templates = Jinja2Templates(directory=[ 21templates = Jinja2Templates(directory=["templates", "management/templates"])
22 'templates',
23 'management/templates'
24])
25 22
26 23
27def requires_session(func): 24def requires_session(func):
@@ -29,38 +26,42 @@ def requires_session(func):
29 which resolves the redirect target via `request.url_for()` against whichever router is 26 which resolves the redirect target via `request.url_for()` against whichever router is
30 bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when 27 bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when
31 `management` happens to be mounted under a shared root app.""" 28 `management` happens to be mounted under a shared root app."""
29
32 @wraps(func) 30 @wraps(func)
33 async def wrapper(request: Request, *args, **kwargs): 31 async def wrapper(request: Request, *args, **kwargs):
34 if not {'authenticated', 'session'}.issubset(request.auth.scopes): 32 if not {"authenticated", "session"}.issubset(request.auth.scopes):
35 next_param = urlencode({'next': str(request.url)}) 33 next_param = urlencode({"next": str(request.url)})
36 return RedirectResponse(f'/auth/login/?{next_param}', status_code=303) 34 return RedirectResponse(f"/auth/login/?{next_param}", status_code=303)
37 return await func(request, *args, **kwargs) 35 return await func(request, *args, **kwargs)
36
38 return wrapper 37 return wrapper
39 38
40@management.get('/') 39
40@management.get("/")
41async def index(request: Request): 41async def index(request: Request):
42 with get_session_context() as session: 42 with get_session_context() as session:
43 query = select(func.count(User.id)) 43 query = select(func.count(User.id))
44 user_count = session.exec(query) 44 user_count = session.exec(query)
45 45
46 if user_count.first() == 0: 46 if user_count.first() == 0:
47 return RedirectResponse('/auth/register/?next=/management/dashboard/', status_code=303) 47 return RedirectResponse(
48 "/auth/register/?next=/management/dashboard/", status_code=303
49 )
48 50
49 return RedirectResponse('/management/dashboard/', status_code=303) 51 return RedirectResponse("/management/dashboard/", status_code=303)
50 52
51 53
52@management.get('/dashboard/') 54@management.get("/dashboard/")
53@requires_session 55@requires_session
54async def dashboard(request: Request): 56async def dashboard(request: Request):
55 return templates.TemplateResponse(request, 'dashboard.html') 57 return templates.TemplateResponse(request, "dashboard.html")
58
56 59
57@management.get('/users/') 60@management.get("/users/")
58@requires_session 61@requires_session
59async def users(request: Request): 62async def users(request: Request):
60 with get_session_context() as session: 63 with get_session_context() as session:
61 query = select(User) 64 query = select(User)
62 users = session.exec(query).all() 65 users = session.exec(query).all()
63 66
64 return templates.TemplateResponse(request, 'users.html', context={ 67 return templates.TemplateResponse(request, "users.html", context={"users": users})
65 'users': users
66 })
diff --git a/proxy/__init__.py b/proxy/__init__.py
index fcdb23b..aba8ad2 100644
--- a/proxy/__init__.py
+++ b/proxy/__init__.py
@@ -7,7 +7,7 @@ from proxy.websockets import WebsocketProxy
7app = Starlette( 7app = Starlette(
8 debug=True, 8 debug=True,
9 routes=[ 9 routes=[
10 Route('/{path:path}', Proxy), 10 Route("/{path:path}", Proxy),
11 WebSocketRoute('/{path:path}', WebsocketProxy), 11 WebSocketRoute("/{path:path}", WebsocketProxy),
12 ] 12 ],
13) 13)
diff --git a/proxy/endpoints.py b/proxy/endpoints.py
index 0a23e7b..f514226 100644
--- a/proxy/endpoints.py
+++ b/proxy/endpoints.py
@@ -1,13 +1,13 @@
1import logging 1import logging
2from base64 import b64encode, b64decode 2from base64 import b64decode, b64encode
3from uuid import uuid4 3from uuid import uuid4
4 4
5from starlette.endpoints import HTTPEndpoint 5from starlette.endpoints import HTTPEndpoint
6from starlette.requests import Request 6from starlette.requests import Request
7from starlette.responses import Response 7from starlette.responses import Response
8 8
9from proxy.utils import get_path_with_query_string
10from proxy.queue import ProxyQueue 9from proxy.queue import ProxyQueue
10from proxy.utils import get_path_with_query_string
11from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData 11from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData
12 12
13logger = logging.getLogger(__name__) 13logger = logging.getLogger(__name__)
@@ -18,16 +18,15 @@ class HeaderMapping:
18 self._headers = headers 18 self._headers = headers
19 19
20 def items(self): 20 def items(self):
21 for header in self._headers: 21 yield from self._headers
22 yield header
23 22
24 23
25class Proxy(HTTPEndpoint): 24class Proxy(HTTPEndpoint):
26 async def dispatch(self) -> None: 25 async def dispatch(self) -> None:
27 request = Request(self.scope, self.receive) 26 request = Request(self.scope, self.receive)
28 27
29 subdomain = request.path_params['subdomain'] 28 subdomain = request.path_params["subdomain"]
30 response = Response(content='Not Found', status_code=404) 29 response = Response(content="Not Found", status_code=404)
31 30
32 identifier = str(uuid4()) 31 identifier = str(uuid4())
33 response_queue = await ProxyQueue.create_for_identifier(identifier) 32 response_queue = await ProxyQueue.create_for_identifier(identifier)
@@ -35,7 +34,7 @@ class Proxy(HTTPEndpoint):
35 try: 34 try:
36 request_queue = await ProxyQueue.get_for_identifier(subdomain) 35 request_queue = await ProxyQueue.get_for_identifier(subdomain)
37 36
38 logger.debug('PROXY %s%s ', subdomain, request.url) 37 logger.debug("PROXY %s%s ", subdomain, request.url)
39 await request_queue.enqueue( 38 await request_queue.enqueue(
40 HttpMessage( 39 HttpMessage(
41 type=HttpMessageType.request.value, 40 type=HttpMessageType.request.value,
@@ -44,17 +43,17 @@ class Proxy(HTTPEndpoint):
44 method=request.method, 43 method=request.method,
45 path=get_path_with_query_string(request), 44 path=get_path_with_query_string(request),
46 headers=list(request.headers.items()), 45 headers=list(request.headers.items()),
47 body=b64encode(await request.body()).decode() 46 body=b64encode(await request.body()).decode(),
48 ) 47 ),
49 ) 48 )
50 ) 49 )
51 50
52 _response = await response_queue.dequeue() 51 _response = await response_queue.dequeue()
53 payload = _response['payload'] 52 payload = _response["payload"]
54 response = Response( 53 response = Response(
55 status_code=payload['status'], 54 status_code=payload["status"],
56 headers=HeaderMapping(payload['headers']), 55 headers=HeaderMapping(payload["headers"]),
57 content=b64decode(payload['body'].encode()) 56 content=b64decode(payload["body"].encode()),
58 ) 57 )
59 except AssertionError: 58 except AssertionError:
60 pass 59 pass
diff --git a/proxy/queue.py b/proxy/queue.py
index cfa0f3c..70fe3d0 100644
--- a/proxy/queue.py
+++ b/proxy/queue.py
@@ -2,8 +2,7 @@ import asyncio
2import json 2import json
3import logging 3import logging
4import os 4import os
5import traceback 5from typing import ClassVar, Self
6from typing import Type
7 6
8from ttun_server.redis import RedisConnectionPool 7from ttun_server.redis import RedisConnectionPool
9from ttun_server.types import Message 8from ttun_server.types import Message
@@ -16,37 +15,37 @@ class BaseProxyQueue:
16 self.identifier = identifier 15 self.identifier = identifier
17 16
18 @classmethod 17 @classmethod
19 async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': 18 async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue:
20 raise NotImplementedError(f'Please implement create_for_identifier') 19 raise NotImplementedError("Please implement create_for_identifier")
21 20
22 @classmethod 21 @classmethod
23 async def get_for_identifier(cls, identifier: str) -> Type['self']: 22 async def get_for_identifier(cls, identifier: str) -> Self:
24 assert await cls.has_connection(identifier) 23 assert await cls.has_connection(identifier)
25 return cls(identifier) 24 return cls(identifier)
26 25
27 @classmethod 26 @classmethod
28 async def has_connection(cls, identifier) -> bool: 27 async def has_connection(cls, identifier) -> bool:
29 raise NotImplementedError(f'Please implement has_connection') 28 raise NotImplementedError("Please implement has_connection")
30 29
31 async def enqueue(self, message: Message): 30 async def enqueue(self, message: Message):
32 raise NotImplementedError(f'Please implement send_request') 31 raise NotImplementedError("Please implement send_request")
33 32
34 async def dequeue(self) -> Message: 33 async def dequeue(self) -> Message:
35 raise NotImplementedError(f'Please implement handle_requests') 34 raise NotImplementedError("Please implement handle_requests")
36 35
37 async def delete(self): 36 async def delete(self):
38 raise NotImplementedError(f'Please implement delete') 37 raise NotImplementedError("Please implement delete")
39 38
40 39
41class MemoryProxyQueue(BaseProxyQueue): 40class MemoryProxyQueue(BaseProxyQueue):
42 connections: dict[str, asyncio.Queue] = {} 41 connections: ClassVar[dict[str, asyncio.Queue]] = {}
43 42
44 @classmethod 43 @classmethod
45 async def has_connection(cls, identifier) -> bool: 44 async def has_connection(cls, identifier) -> bool:
46 return identifier in cls.connections 45 return identifier in cls.connections
47 46
48 @classmethod 47 @classmethod
49 async def create_for_identifier(cls, identifier: str) -> 'MemoryProxyQueue': 48 async def create_for_identifier(cls, identifier: str) -> MemoryProxyQueue:
50 instance = cls(identifier) 49 instance = cls(identifier)
51 cls.connections[identifier] = asyncio.Queue() 50 cls.connections[identifier] = asyncio.Queue()
52 51
@@ -66,67 +65,59 @@ class RedisProxyQueue(BaseProxyQueue):
66 def __init__(self, identifier): 65 def __init__(self, identifier):
67 super().__init__(identifier) 66 super().__init__(identifier)
68 67
69 self.pubsub = RedisConnectionPool()\ 68 self.pubsub = RedisConnectionPool().get_connection().pubsub()
70 .get_connection()\
71 .pubsub()
72 69
73 self.subscription_queue = asyncio.Queue() 70 self.subscription_queue = asyncio.Queue()
74 71
75 @classmethod 72 @classmethod
76 async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': 73 async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue:
77 instance = cls(identifier) 74 instance = cls(identifier)
78 75
79 await instance.pubsub.subscribe(f'request_{identifier}') 76 await instance.pubsub.subscribe(f"request_{identifier}")
80 return instance 77 return instance
81 78
82 @classmethod 79 @classmethod
83 async def get_for_identifier(cls, identifier: str) -> 'RedisProxyQueue': 80 async def get_for_identifier(cls, identifier: str) -> RedisProxyQueue:
84 instance: 'RedisProxyQueue' = await super().get_for_identifier(identifier) 81 instance: RedisProxyQueue = await super().get_for_identifier(identifier)
85 82
86 await instance.pubsub.subscribe(f'response_{identifier}') 83 await instance.pubsub.subscribe(f"response_{identifier}")
87 84
88 return instance 85 return instance
89 86
90 @classmethod 87 @classmethod
91 async def has_connection(cls, identifier) -> bool: 88 async def has_connection(cls, identifier) -> bool:
92 logger.debug(await RedisConnectionPool.get_connection().pubsub_channels()) 89 logger.debug(await RedisConnectionPool.get_connection().pubsub_channels())
93 return f'request_{identifier}' in { 90 return f"request_{identifier}" in {
94 channel.decode() 91 channel.decode()
95 for channel 92 for channel in await RedisConnectionPool.get_connection().pubsub_channels()
96 in await RedisConnectionPool \
97 .get_connection() \
98 .pubsub_channels()
99 } 93 }
100 94
101 async def wait_for_message(self): 95 async def wait_for_message(self):
102 async for message in self.pubsub.listen(): 96 async for message in self.pubsub.listen():
103 match message['type']: 97 match message["type"]:
104 case 'subscribe': 98 case "subscribe":
105 continue 99 continue
106 case _: 100 case _:
107 return message['data'] 101 return message["data"]
108 102
109 async def enqueue(self, message: Message): 103 async def enqueue(self, message: Message):
110 await RedisConnectionPool \ 104 await RedisConnectionPool.get_connection().publish(
111 .get_connection() \ 105 f"request_{self.identifier}", json.dumps(message)
112 .publish(f'request_{self.identifier}', json.dumps(message)) 106 )
113 107
114 async def dequeue(self) -> Message: 108 async def dequeue(self) -> Message:
115 message = await self.wait_for_message() 109 message = await self.wait_for_message()
116 return json.loads(message) 110 return json.loads(message)
117 111
118 async def delete(self): 112 async def delete(self):
119 await self.pubsub.unsubscribe(f'request_{self.identifier}') 113 await self.pubsub.unsubscribe(f"request_{self.identifier}")
120 114
121 await RedisConnectionPool.get_connection()\ 115 await RedisConnectionPool.get_connection().srem("connections", self.identifier)
122 .srem('connections', self.identifier)
123 116
124 117
125class ProxyQueueMeta(type): 118class ProxyQueueMeta(type):
126 def __new__(cls, name, superclasses, attributes): 119 def __new__(cls, name, superclasses, attributes):
127 return RedisProxyQueue \ 120 return RedisProxyQueue if "REDIS_URL" in os.environ else MemoryProxyQueue
128 if 'REDIS_URL' in os.environ \
129 else MemoryProxyQueue
130 121
131 122
132class ProxyQueue(BaseProxyQueue, metaclass=ProxyQueueMeta): 123class ProxyQueue(BaseProxyQueue, metaclass=ProxyQueueMeta):
diff --git a/proxy/utils.py b/proxy/utils.py
index 2b80f43..3fff388 100644
--- a/proxy/utils.py
+++ b/proxy/utils.py
@@ -3,5 +3,9 @@ from starlette.requests import HTTPConnection
3 3
4def get_path_with_query_string(connection: HTTPConnection) -> str: 4def get_path_with_query_string(connection: HTTPConnection) -> str:
5 path = connection.url.path 5 path = connection.url.path
6 query_string = '?' + connection.scope['query_string'].decode() if connection.scope['query_string'] else '' 6 query_string = (
7 "?" + connection.scope["query_string"].decode()
8 if connection.scope["query_string"]
9 else ""
10 )
7 return f"{path}{query_string}" 11 return f"{path}{query_string}"
diff --git a/proxy/websockets.py b/proxy/websockets.py
index ec65e6d..a09e261 100644
--- a/proxy/websockets.py
+++ b/proxy/websockets.py
@@ -9,17 +9,22 @@ from uuid import uuid4
9from starlette.endpoints import WebSocketEndpoint 9from starlette.endpoints import WebSocketEndpoint
10from starlette.websockets import WebSocket 10from starlette.websockets import WebSocket
11 11
12from proxy.utils import get_path_with_query_string
13from proxy.queue import ProxyQueue 12from proxy.queue import ProxyQueue
14from ttun_server.types import WebsocketMessage, WebsocketMessageType, WebsocketConnectData, WebsocketMessageData, \ 13from proxy.utils import get_path_with_query_string
15 WebsocketDisconnectData 14from ttun_server.types import (
15 WebsocketConnectData,
16 WebsocketDisconnectData,
17 WebsocketMessage,
18 WebsocketMessageData,
19 WebsocketMessageType,
20)
16 21
17logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
18logger.setLevel('DEBUG') 23logger.setLevel("DEBUG")
19 24
20 25
21class WebsocketProxy(WebSocketEndpoint): 26class WebsocketProxy(WebSocketEndpoint):
22 encoding = 'json' 27 encoding = "json"
23 websocket_listen_task = None 28 websocket_listen_task = None
24 29
25 def __init__(self, *args, **kwargs): 30 def __init__(self, *args, **kwargs):
@@ -28,16 +33,20 @@ class WebsocketProxy(WebSocketEndpoint):
28 33
29 @asynccontextmanager 34 @asynccontextmanager
30 async def proxy(self, websocket: WebSocket, message: WebsocketMessage): 35 async def proxy(self, websocket: WebSocket, message: WebsocketMessage):
31 [subdomain, *_] = websocket.url.hostname.split('.') 36 [subdomain, *_] = websocket.url.hostname.split(".")
32 37
33 expect_ack = WebsocketMessageType(message['type']) == WebsocketMessageType.connect 38 expect_ack = (
39 WebsocketMessageType(message["type"]) == WebsocketMessageType.connect
40 )
34 41
35 try: 42 try:
36 request_queue = await ProxyQueue.get_for_identifier(subdomain) 43 request_queue = await ProxyQueue.get_for_identifier(subdomain)
37 await request_queue.enqueue(message) 44 await request_queue.enqueue(message)
38 45
39 if expect_ack: 46 if expect_ack:
40 response_queue = await ProxyQueue.create_for_identifier(message["identifier"]) 47 response_queue = await ProxyQueue.create_for_identifier(
48 message["identifier"]
49 )
41 yield await response_queue.dequeue() 50 yield await response_queue.dequeue()
42 await response_queue.delete() 51 await response_queue.delete()
43 else: 52 else:
@@ -51,7 +60,9 @@ class WebsocketProxy(WebSocketEndpoint):
51 while True: 60 while True:
52 message: WebsocketMessage = await response_queue.dequeue() 61 message: WebsocketMessage = await response_queue.dequeue()
53 logger.debug(message) 62 logger.debug(message)
54 await websocket.send_text(b64decode(message['payload']['body'].encode()).decode()) 63 await websocket.send_text(
64 b64decode(message["payload"]["body"].encode()).decode()
65 )
55 66
56 async def on_connect(self, websocket: WebSocket) -> None: 67 async def on_connect(self, websocket: WebSocket) -> None:
57 message = WebsocketMessage( 68 message = WebsocketMessage(
@@ -60,18 +71,21 @@ class WebsocketProxy(WebSocketEndpoint):
60 payload=WebsocketConnectData( 71 payload=WebsocketConnectData(
61 path=get_path_with_query_string(websocket), 72 path=get_path_with_query_string(websocket),
62 headers=[ 73 headers=[
63 (k.decode(), v.decode()) 74 (k.decode(), v.decode()) for k, v in websocket.scope["headers"]
64 for k, v
65 in websocket.scope['headers']
66 ], 75 ],
67 ) 76 ),
68 ) 77 )
69 78
70 async with self.proxy(websocket, message) as m: 79 async with self.proxy(websocket, message) as m:
71 if m is not None and WebsocketMessageType(m['type']) == WebsocketMessageType.ack: 80 if (
81 m is not None
82 and WebsocketMessageType(m["type"]) == WebsocketMessageType.ack
83 ):
72 await super().on_connect(websocket) 84 await super().on_connect(websocket)
73 85
74 self.websocket_listen_task = asyncio.create_task(self.listen_for_messages(websocket)) 86 self.websocket_listen_task = asyncio.create_task(
87 self.listen_for_messages(websocket)
88 )
75 89
76 def callback(*args, **kwargs): 90 def callback(*args, **kwargs):
77 self.websocket_listen_task = None 91 self.websocket_listen_task = None
@@ -92,7 +106,7 @@ class WebsocketProxy(WebSocketEndpoint):
92 identifier=self.id, 106 identifier=self.id,
93 payload=WebsocketMessageData( 107 payload=WebsocketMessageData(
94 body=b64encode(data_bytes).decode(), 108 body=b64encode(data_bytes).decode(),
95 ) 109 ),
96 ) 110 )
97 111
98 async with self.proxy(websocket, message): 112 async with self.proxy(websocket, message):
@@ -104,7 +118,7 @@ class WebsocketProxy(WebSocketEndpoint):
104 identifier=self.id, 118 identifier=self.id,
105 payload=WebsocketDisconnectData( 119 payload=WebsocketDisconnectData(
106 close_code=close_code, 120 close_code=close_code,
107 ) 121 ),
108 ) 122 )
109 123
110 async with self.proxy(websocket, message): 124 async with self.proxy(websocket, message):
diff --git a/pyproject.toml b/pyproject.toml
index 6a3117f..0a21d3a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,3 +17,8 @@ dependencies = [
17 "sqlmodel>=0.0.39", 17 "sqlmodel>=0.0.39",
18 "uvicorn[standard]~=0.44.0", 18 "uvicorn[standard]~=0.44.0",
19] 19]
20
21[dependency-groups]
22dev = [
23 "pre-commit>=4.6.2",
24]
diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py
index 388dd9e..6e1eabf 100644
--- a/ttun_server/__init__.py
+++ b/ttun_server/__init__.py
@@ -3,7 +3,7 @@ import os
3 3
4from fastapi import FastAPI 4from fastapi import FastAPI
5from starlette.middleware.authentication import AuthenticationMiddleware 5from starlette.middleware.authentication import AuthenticationMiddleware
6from starlette.routing import Host, Route, WebSocketRoute, Mount 6from starlette.routing import Host, Mount, Route, WebSocketRoute
7 7
8from authentication.backend import BearerTokenAuthBackend 8from authentication.backend import BearerTokenAuthBackend
9from authentication.endpoints import authentication, oauth 9from authentication.endpoints import authentication, oauth
@@ -12,20 +12,20 @@ from proxy import app as proxy_app
12from ttun_server.endpoints import health 12from ttun_server.endpoints import health
13from ttun_server.websockets import tunnel 13from ttun_server.websockets import tunnel
14 14
15logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) 15logging.basicConfig(level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO")))
16 16
17app = FastAPI( 17app = FastAPI(
18 debug=True, 18 debug=True,
19 routes=[ 19 routes=[
20 Mount('/management/', app=management), 20 Mount("/management/", app=management),
21 Mount('/auth/', app=authentication), 21 Mount("/auth/", app=authentication),
22 Mount('/oauth/', app=oauth), 22 Mount("/oauth/", app=oauth),
23 WebSocketRoute( 23 WebSocketRoute(
24 '/tunnel/', 24 "/tunnel/",
25 endpoint=tunnel, 25 endpoint=tunnel,
26 ), 26 ),
27 Route('/health/', endpoint=health), 27 Route("/health/", endpoint=health),
28 Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) 28 Host(f"{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}", app=proxy_app),
29 ], 29 ],
30) 30)
31 31
@@ -33,6 +33,7 @@ app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend())
33 33
34try: 34try:
35 from ._version import version 35 from ._version import version
36
36 __version__ = version 37 __version__ = version
37except ImportError: 38except ImportError:
38 __version__ = 'development' 39 __version__ = "development"
diff --git a/ttun_server/endpoints.py b/ttun_server/endpoints.py
index 61e3f21..e058009 100644
--- a/ttun_server/endpoints.py
+++ b/ttun_server/endpoints.py
@@ -1,2 +1,2 @@
1async def health(): 1async def health():
2 return 'OK' 2 return "OK"
diff --git a/ttun_server/redis.py b/ttun_server/redis.py
index fb9ff81..dabe940 100644
--- a/ttun_server/redis.py
+++ b/ttun_server/redis.py
@@ -9,5 +9,5 @@ class RedisConnectionPool:
9 @classmethod 9 @classmethod
10 def get_connection(cls) -> Redis: 10 def get_connection(cls) -> Redis:
11 if cls._pool is None: 11 if cls._pool is None:
12 cls._pool = ConnectionPool.from_url(os.environ.get('REDIS_URL')) 12 cls._pool = ConnectionPool.from_url(os.environ.get("REDIS_URL"))
13 return Redis(connection_pool=cls._pool) \ No newline at end of file 13 return Redis(connection_pool=cls._pool)
diff --git a/ttun_server/types.py b/ttun_server/types.py
index a0c22a3..33095ff 100644
--- a/ttun_server/types.py
+++ b/ttun_server/types.py
@@ -1,11 +1,11 @@
1from asyncio import Queue 1from asyncio import Queue
2from enum import Enum 2from enum import Enum
3from typing import TypedDict, Optional 3from typing import TypedDict
4 4
5 5
6class HttpMessageType(Enum): 6class HttpMessageType(Enum):
7 request = 'request' 7 request = "request"
8 response = 'response' 8 response = "response"
9 9
10 10
11class Config(TypedDict): 11class Config(TypedDict):
@@ -18,13 +18,13 @@ class HttpRequestData(TypedDict):
18 method: str 18 method: str
19 path: str 19 path: str
20 headers: list[tuple[str, str]] 20 headers: list[tuple[str, str]]
21 body: Optional[str] 21 body: str | None
22 22
23 23
24class HttpResponseData(TypedDict): 24class HttpResponseData(TypedDict):
25 status: int 25 status: int
26 headers: list[tuple[str, str]] 26 headers: list[tuple[str, str]]
27 body: Optional[str] 27 body: str | None
28 28
29 29
30class HttpMessage(TypedDict): 30class HttpMessage(TypedDict):
@@ -34,10 +34,10 @@ class HttpMessage(TypedDict):
34 34
35 35
36class WebsocketMessageType(Enum): 36class WebsocketMessageType(Enum):
37 connect = 'connect' 37 connect = "connect"
38 disconnect = 'disconnect' 38 disconnect = "disconnect"
39 message = 'message' 39 message = "message"
40 ack = 'ack' 40 ack = "ack"
41 41
42 42
43class WebsocketConnectData(TypedDict): 43class WebsocketConnectData(TypedDict):
@@ -50,7 +50,7 @@ class WebsocketDisconnectData(TypedDict):
50 50
51 51
52class WebsocketMessageData(TypedDict): 52class WebsocketMessageData(TypedDict):
53 body: Optional[str] 53 body: str | None
54 54
55 55
56class WebsocketMessage(TypedDict): 56class WebsocketMessage(TypedDict):
@@ -60,13 +60,13 @@ class WebsocketMessage(TypedDict):
60 60
61 61
62class MessageType(Enum): 62class MessageType(Enum):
63 request = 'request' 63 request = "request"
64 response = 'response' 64 response = "response"
65 65
66 ws_connect = 'connect' 66 ws_connect = "connect"
67 ws_disconnect = 'disconnect' 67 ws_disconnect = "disconnect"
68 ws_message = 'message' 68 ws_message = "message"
69 ws_ack = 'ack' 69 ws_ack = "ack"
70 70
71 71
72Message = HttpMessage | WebsocketMessage 72Message = HttpMessage | WebsocketMessage
diff --git a/ttun_server/websockets.py b/ttun_server/websockets.py
index 88d6cac..a3cbc5f 100644
--- a/ttun_server/websockets.py
+++ b/ttun_server/websockets.py
@@ -16,24 +16,25 @@ from ttun_server.types import (
16) 16)
17 17
18logger = logging.getLogger(__name__) 18logger = logging.getLogger(__name__)
19logger.setLevel('DEBUG') 19logger.setLevel("DEBUG")
20
20 21
21async def assert_compatible_version(websocket: WebSocket, config: Config) -> None: 22async def assert_compatible_version(websocket: WebSocket, config: Config) -> None:
22 client_version = config.get('version', '1.0.0') 23 client_version = config.get("version", "1.0.0")
23 logger.debug('client_version %s', client_version) 24 logger.debug("client_version %s", client_version)
24 25
25 if 'git' not in client_version and ttun_server.__version__ != 'development': 26 if "git" not in client_version and ttun_server.__version__ != "development":
26 [client_major, *_] = [int(i) for i in client_version.split('.')[:3]] 27 [client_major, *_] = [int(i) for i in client_version.split(".")[:3]]
27 [server_major, *_] = [int(i) for i in ttun_server.__version__.split('.')] 28 [server_major, *_] = [int(i) for i in ttun_server.__version__.split(".")]
28 29
29 if client_major < server_major: 30 if client_major < server_major:
30 await websocket.close(4000, 'Your client is too old') 31 await websocket.close(4000, "Your client is too old")
31 32
32 if client_major > server_major: 33 if client_major > server_major:
33 await websocket.close(4001, 'Your client is too new') 34 await websocket.close(4001, "Your client is too new")
34 35
35 36
36@requires(['authenticated', 'token'] if conf.ENABLE_AUTH else []) 37@requires(["authenticated", "token"] if conf.ENABLE_AUTH else [])
37async def tunnel(websocket: WebSocket) -> None: 38async def tunnel(websocket: WebSocket) -> None:
38 request_tasks: dict[str, asyncio.Task] = {} 39 request_tasks: dict[str, asyncio.Task] = {}
39 proxy_queues: dict[str, ProxyQueue] = {} 40 proxy_queues: dict[str, ProxyQueue] = {}
@@ -43,36 +44,39 @@ async def tunnel(websocket: WebSocket) -> None:
43 44
44 await assert_compatible_version(websocket, config) 45 await assert_compatible_version(websocket, config)
45 46
46 if 'subdomains' not in config: 47 if "subdomains" not in config:
47 config['subdomains'] = [config['subdomain']] 48 config["subdomains"] = [config["subdomain"]]
48 elif config['subdomains'] is None: 49 elif config["subdomains"] is None:
49 config['subdomains'] = [None] 50 config["subdomains"] = [None]
50 51
51 for i, subdomain in enumerate(config['subdomains']): 52 for i, subdomain in enumerate(config["subdomains"]):
52 if subdomain is None or await ProxyQueue.has_connection(subdomain): 53 if subdomain is None or await ProxyQueue.has_connection(subdomain):
53 config['subdomains'][i] = uuid4().hex 54 config["subdomains"][i] = uuid4().hex
54 55
55 for subdomain in config['subdomains']: 56 for subdomain in config["subdomains"]:
56 proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain) 57 proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain)
57 58
58 hostname = os.environ.get('TUNNEL_DOMAIN') 59 hostname = os.environ.get("TUNNEL_DOMAIN")
59 protocol = 'https' if os.environ.get('SECURE', False) else 'http' 60 protocol = "https" if os.environ.get("SECURE") else "http"
60 61
61 urls = [ 62 urls = [
62 f'{protocol}://{subdomain}.{hostname}' 63 f"{protocol}://{subdomain}.{hostname}" for subdomain in config["subdomains"]
63 for subdomain in config['subdomains']
64 ] 64 ]
65 65
66 await websocket.send_json({ 66 await websocket.send_json(
67 'url': urls[0], 67 {
68 'urls': urls, 68 "url": urls[0],
69 }) 69 "urls": urls,
70 }
71 )
70 72
71 async def handle_requests(subdomain: str) -> None: 73 async def handle_requests(subdomain: str) -> None:
72 while request := await proxy_queues[subdomain].dequeue(): 74 while request := await proxy_queues[subdomain].dequeue():
73 asyncio.create_task(websocket.send_json(request), name=request['identifier']) 75 asyncio.create_task(
76 websocket.send_json(request), name=request["identifier"]
77 )
74 78
75 for subdomain in config['subdomains']: 79 for subdomain in config["subdomains"]:
76 request_tasks[subdomain] = asyncio.create_task( 80 request_tasks[subdomain] = asyncio.create_task(
77 handle_requests(subdomain), name=subdomain 81 handle_requests(subdomain), name=subdomain
78 ) 82 )
@@ -81,8 +85,8 @@ async def tunnel(websocket: WebSocket) -> None:
81 while True: 85 while True:
82 data: Message = await websocket.receive_json() 86 data: Message = await websocket.receive_json()
83 try: 87 try:
84 data['type'] = MessageType(data['type']).value 88 data["type"] = MessageType(data["type"]).value
85 response_queue = await ProxyQueue.get_for_identifier(data['identifier']) 89 response_queue = await ProxyQueue.get_for_identifier(data["identifier"])
86 await response_queue.enqueue(data) 90 await response_queue.enqueue(data)
87 except AssertionError: 91 except AssertionError:
88 pass 92 pass
diff --git a/uv.lock b/uv.lock
index d63779e..eff487f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -162,6 +162,15 @@ wheels = [
162] 162]
163 163
164[[package]] 164[[package]]
165name = "cfgv"
166version = "3.5.0"
167source = { registry = "https://pypi.org/simple" }
168sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
169wheels = [
170 { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
171]
172
173[[package]]
165name = "click" 174name = "click"
166version = "8.3.2" 175version = "8.3.2"
167source = { registry = "https://pypi.org/simple" } 176source = { registry = "https://pypi.org/simple" }
@@ -253,6 +262,15 @@ wheels = [
253] 262]
254 263
255[[package]] 264[[package]]
265name = "distlib"
266version = "0.4.3"
267source = { registry = "https://pypi.org/simple" }
268sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
269wheels = [
270 { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
271]
272
273[[package]]
256name = "dnspython" 274name = "dnspython"
257version = "2.8.0" 275version = "2.8.0"
258source = { registry = "https://pypi.org/simple" } 276source = { registry = "https://pypi.org/simple" }
@@ -384,6 +402,15 @@ wheels = [
384] 402]
385 403
386[[package]] 404[[package]]
405name = "filelock"
406version = "3.32.5"
407source = { registry = "https://pypi.org/simple" }
408sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
409wheels = [
410 { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
411]
412
413[[package]]
387name = "greenlet" 414name = "greenlet"
388version = "3.5.3" 415version = "3.5.3"
389source = { registry = "https://pypi.org/simple" } 416source = { registry = "https://pypi.org/simple" }
@@ -509,6 +536,15 @@ wheels = [
509] 536]
510 537
511[[package]] 538[[package]]
539name = "identify"
540version = "2.6.19"
541source = { registry = "https://pypi.org/simple" }
542sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
543wheels = [
544 { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
545]
546
547[[package]]
512name = "idna" 548name = "idna"
513version = "3.11" 549version = "3.11"
514source = { registry = "https://pypi.org/simple" } 550source = { registry = "https://pypi.org/simple" }
@@ -623,6 +659,15 @@ wheels = [
623] 659]
624 660
625[[package]] 661[[package]]
662name = "nodeenv"
663version = "1.10.0"
664source = { registry = "https://pypi.org/simple" }
665sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
666wheels = [
667 { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
668]
669
670[[package]]
626name = "passlib" 671name = "passlib"
627version = "1.7.4" 672version = "1.7.4"
628source = { registry = "https://pypi.org/simple" } 673source = { registry = "https://pypi.org/simple" }
@@ -644,6 +689,31 @@ wheels = [
644] 689]
645 690
646[[package]] 691[[package]]
692name = "platformdirs"
693version = "4.11.7"
694source = { registry = "https://pypi.org/simple" }
695sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
696wheels = [
697 { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
698]
699
700[[package]]
701name = "pre-commit"
702version = "4.6.2"
703source = { registry = "https://pypi.org/simple" }
704dependencies = [
705 { name = "cfgv" },
706 { name = "identify" },
707 { name = "nodeenv" },
708 { name = "pyyaml" },
709 { name = "virtualenv" },
710]
711sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
712wheels = [
713 { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
714]
715
716[[package]]
647name = "psycopg2" 717name = "psycopg2"
648version = "2.9.12" 718version = "2.9.12"
649source = { registry = "https://pypi.org/simple" } 719source = { registry = "https://pypi.org/simple" }
@@ -759,6 +829,18 @@ wheels = [
759] 829]
760 830
761[[package]] 831[[package]]
832name = "python-discovery"
833version = "1.6.0"
834source = { registry = "https://pypi.org/simple" }
835dependencies = [
836 { name = "filelock" },
837]
838sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" }
839wheels = [
840 { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" },
841]
842
843[[package]]
762name = "python-dotenv" 844name = "python-dotenv"
763version = "1.2.2" 845version = "1.2.2"
764source = { registry = "https://pypi.org/simple" } 846source = { registry = "https://pypi.org/simple" }
@@ -985,6 +1067,11 @@ dependencies = [
985 { name = "uvicorn", extra = ["standard"] }, 1067 { name = "uvicorn", extra = ["standard"] },
986] 1068]
987 1069
1070[package.dev-dependencies]
1071dev = [
1072 { name = "pre-commit" },
1073]
1074
988[package.metadata] 1075[package.metadata]
989requires-dist = [ 1076requires-dist = [
990 { name = "alembic", specifier = ">=1.18.5" }, 1077 { name = "alembic", specifier = ">=1.18.5" },
@@ -1002,6 +1089,9 @@ requires-dist = [
1002 { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, 1089 { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" },
1003] 1090]
1004 1091
1092[package.metadata.requires-dev]
1093dev = [{ name = "pre-commit", specifier = ">=4.6.2" }]
1094
1005[[package]] 1095[[package]]
1006name = "typer" 1096name = "typer"
1007version = "0.26.8" 1097version = "0.26.8"
@@ -1092,6 +1182,21 @@ wheels = [
1092] 1182]
1093 1183
1094[[package]] 1184[[package]]
1185name = "virtualenv"
1186version = "21.7.8"
1187source = { registry = "https://pypi.org/simple" }
1188dependencies = [
1189 { name = "distlib" },
1190 { name = "filelock" },
1191 { name = "platformdirs" },
1192 { name = "python-discovery" },
1193]
1194sdist = { url = "https://files.pythonhosted.org/packages/08/1c/69faa2e6a83484e2a8227bce5cfaa183941c5720f99c48f204931d286b07/virtualenv-21.7.8.tar.gz", hash = "sha256:1dc49c790072a9072cb1803f9bd62aa69cd583077cada32390f75505cdc64c9b", size = 5347580, upload-time = "2026-09-01T13:36:13.894Z" }
1195wheels = [
1196 { url = "https://files.pythonhosted.org/packages/9f/34/88d507d4a4030fa559788de9c690a214f9a4053aa1d91cfb60e9b36127c2/virtualenv-21.7.8-py3-none-any.whl", hash = "sha256:3040eb3cbf5d32b10ffd57d167e6a162237ad82ba7d8cf1400a1efed593d85ac", size = 5324617, upload-time = "2026-09-01T13:36:11.248Z" },
1197]
1198
1199[[package]]
1095name = "watchfiles" 1200name = "watchfiles"
1096version = "1.1.1" 1201version = "1.1.1"
1097source = { registry = "https://pypi.org/simple" } 1202source = { registry = "https://pypi.org/simple" }