From 46af86f8ace136dd1d1d94590d3423e6b12e3f7b Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Thu, 30 Dec 2021 10:16:41 +0100 Subject: Prepare for github --- .dockerignore | 1 + .github/workflows/docker-image.yml | 39 ++++++ .gitignore | 237 +++++++++++++++++++++++++++++++++++++ .python-version | 1 + Dockerfile | 24 ++++ LICENSE | 29 +++++ README.rst | 41 +++++++ requirements.txt | 2 + ttun_server/__init__.py | 12 ++ ttun_server/connections.py | 3 + ttun_server/endpoints.py | 98 +++++++++++++++ ttun_server/types.py | 25 ++++ 12 files changed, 512 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-image.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 requirements.txt create mode 100644 ttun_server/__init__.py create mode 100644 ttun_server/connections.py create mode 100644 ttun_server/endpoints.py create mode 100644 ttun_server/types.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61f2dc9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +**/__pycache__/ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..8945bfb --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - 'v*' + pull_request: + branches: + - 'main' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: ghcr.io/tomvanderlee/ttun-server + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6d8f21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,237 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..30291cb --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66b4b70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.10-alpine AS base + +RUN mkdir -p /app +WORKDIR /app + +FROM base AS build + +RUN mkdir /buildroot +RUN apk add gcc make musl-dev +RUN pip install --upgrade pip + +COPY requirements.txt . +RUN pip install -r requirements.txt --root /buildroot + +FROM base + +COPY --from=build /buildroot / +COPY . . + +ENV TUNNEL_DOMAIN= +ENV SECURE True +EXPOSE 8000 + +CMD ["uvicorn", "ttun_server:server", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a83ceb2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, Tom van der Lee +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8688b23 --- /dev/null +++ b/README.rst @@ -0,0 +1,41 @@ +=========== +TTUN Server +=========== + +|Release| + +.. |Release| image:: https://github.com/tomvanderlee/ttun-server/actions/workflows/docker-image.yml/badge.svg + :target: https://github.com/tomvanderlee/ttun-server/actions/workflows/docker-image.yml + +The self-hostable proxy tunnel. + +Running +------- + +Running:: + + docker run -e TUNNEL_DOMAIN= -e SECURE= ghcr.io/tomvanderlee/ttun-server:latest + + +Environment variables: + ++----------------+-----------------------------------------------------------------------------------------------------------------+--------------+ +| Variable | Description | Valid Value | ++================+=================================================================================================================+==============+ +| TUNNEL_DOMAIN | The domain your tunnel server is hosted on. Any individual tunnels will be hosted as a subdomain of this one. | FQDN | ++----------------+-----------------------------------------------------------------------------------------------------------------+--------------+ +| SECURE | Set this value to True if you are hosting the tunnel with SSL. If not leave this variable out | | ++----------------+-----------------------------------------------------------------------------------------------------------------+--------------+ + +Developing +---------- + +1. Create and activate a python 3.10 virtual environment + +2. Install requirements:: + + pip install -r requirements.txt + +3. Run:: + + uvicorn ttun_server:server --reload diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..95f7ad2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +starlette ~= 0.17 +uvicorn[standard] ~= 0.16 diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py new file mode 100644 index 0000000..b8fd114 --- /dev/null +++ b/ttun_server/__init__.py @@ -0,0 +1,12 @@ +from starlette.applications import Starlette +from starlette.routing import Route, WebSocketRoute + +from ttun_server.endpoints import Proxy, Tunnel + +server = Starlette( + debug=True, + routes=[ + Route('/{path:path}', Proxy), + WebSocketRoute('/tunnel/', Tunnel) + ] +) diff --git a/ttun_server/connections.py b/ttun_server/connections.py new file mode 100644 index 0000000..a8dabcf --- /dev/null +++ b/ttun_server/connections.py @@ -0,0 +1,3 @@ +from ttun_server.types import Connection + +connections: dict[str, Connection] = {} diff --git a/ttun_server/endpoints.py b/ttun_server/endpoints.py new file mode 100644 index 0000000..d59cb7c --- /dev/null +++ b/ttun_server/endpoints.py @@ -0,0 +1,98 @@ +import asyncio +import os +from asyncio import Queue +from base64 import b64decode, b64encode +from typing import Optional, Any +from uuid import uuid4 + +from starlette.endpoints import HTTPEndpoint, WebSocketEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Scope, Receive, Send +from starlette.websockets import WebSocket + +from ttun_server.types import Connection, RequestData, Config, ResponseData + +from ttun_server.connections import connections + + +class Proxy(HTTPEndpoint): + async def dispatch(self) -> None: + request = Request(self.scope, self.receive) + + [subdomain, *_] = request.headers['host'].split('.') + response = Response(content='Not Found', status_code=404) + + if subdomain in connections: + connection = connections[subdomain] + + await connection['requests'].put(RequestData( + method=request.method, + path=str(request.url).replace(str(request.base_url), '/'), + headers=dict(request.headers), + cookies=dict(request.cookies), + body=b64encode(await request.body()).decode() + )) + + _response = await connection['responses'].get() + response = Response( + status_code=_response['status'], + headers=_response['headers'], + content=b64decode(_response['body'].encode()) + ) + + await response(self.scope, self.receive, self.send) + + +class Tunnel(WebSocketEndpoint): + encoding = 'json' + + def __init__(self, scope: Scope, receive: Receive, send: Send): + super().__init__(scope, receive, send) + self.request_task = None + self.config: Optional[Config] = None + + @property + def requests(self) -> Queue[RequestData]: + return connections[self.config['subdomain']]['requests'] + + @property + def responses(self) -> Queue[ResponseData]: + return connections[self.config['subdomain']]['responses'] + + async def handle_requests(self, websocket: WebSocket): + while request := await self.requests.get(): + await websocket.send_json(request) + + async def on_connect(self, websocket: WebSocket) -> None: + await websocket.accept() + self.config = await websocket.receive_json() + + if self.config['subdomain'] is None \ + or self.config['subdomain'] in connections: + self.config['subdomain'] = uuid4().hex + + + connections[self.config['subdomain']] = Connection( + requests=Queue(), + responses=Queue(), + ) + + hostname = os.environ.get("TUNNEL_DOMAIN") + protocol = "https" if os.environ.get("SECURE", False) else "http" + + await websocket.send_json({ + 'url': f'{protocol}://{self.config["subdomain"]}.{hostname}' + }) + + self.request_task = asyncio.create_task(self.handle_requests(websocket)) + + async def on_receive(self, websocket: WebSocket, data: Any) -> None: + await self.responses.put(data) + + async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None: + if self.config is not None and self.config['subdomain'] in connections: + del connections[self.config['subdomain']] + + if self.request_task is not None: + self.request_task.cancel() diff --git a/ttun_server/types.py b/ttun_server/types.py new file mode 100644 index 0000000..0b2fb87 --- /dev/null +++ b/ttun_server/types.py @@ -0,0 +1,25 @@ +from asyncio import Queue +from typing import TypedDict, Optional + + +class Config(TypedDict): + subdomain: str + +class RequestData(TypedDict): + method: str + path: str + headers: dict + cookies: dict + body: Optional[str] + + +class ResponseData(TypedDict): + status: int + headers: dict + cookies: dict + body: Optional[str] + + +class Connection(TypedDict): + requests: Queue[RequestData] + responses: Queue[ResponseData] -- cgit v1.2.3