From e79ffea14ada0605e0b6ae4834d0062ca358ade7 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Fri, 3 Apr 2026 20:25:30 -0700 Subject: [PATCH] work on improving hostability --- .github/workflows/deploy.yml | 33 ++++++++++++++ README.md | 12 +++++ backend/__main__.py | 3 ++ backend/main.py | 8 +++- backend/server.py | 44 +++++++++++++++++- docs/self-hosting.md | 87 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 backend/__main__.py create mode 100644 docs/self-hosting.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..51c6b57 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + uses: ./.github/workflows/tests.yml + + deploy: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy to server + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + HOST: ${{ secrets.DEPLOY_HOST }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + echo "$SSH_KEY" > key && chmod 600 key + ssh -o StrictHostKeyChecking=no -i key "${USER}@${HOST}" bash -s <<'REMOTE' + set -e + cd /opt/tono + git pull --ff-only + .venv/bin/pip install -e . --quiet + cd frontend && npm ci --ignore-scripts && npm run build && cd .. + sudo systemctl restart tono + REMOTE + rm key diff --git a/README.md b/README.md index 8c81a87..989ac88 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,21 @@ npm run backend # terminal 1 — Python server at http://127.0.0.1:8188 npm run dev # terminal 2 — Vite dev server, open the URL it prints ``` +## Self-hosting + +```bash +git clone https://github.com/VIPQualityPost/tono.git && cd tono +pip install -e . +cd frontend && npm ci && npm run build && cd .. +TONO_HOST=0.0.0.0 tono +``` + +See [Self-Hosting](docs/self-hosting.md) for reverse proxy setup, environment variables, and configuration. + ## Docs - [Building](docs/building.md) — setup, dev mode, web deployment, and native desktop builds for macOS, Linux, and Windows +- [Self-Hosting](docs/self-hosting.md) — deploying tono on a server - [Plugins](docs/plugins.md) — writing and uploading custom node plugins - [Testing](docs/testing.md) — running tests and writing new ones diff --git a/backend/__main__.py b/backend/__main__.py new file mode 100644 index 0000000..80c2fdd --- /dev/null +++ b/backend/__main__.py @@ -0,0 +1,3 @@ +from backend.main import main + +main() diff --git a/backend/main.py b/backend/main.py index ffdc0a1..1ec4685 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from the tono/ directory. import asyncio import logging +import os import sys from pathlib import Path @@ -25,8 +26,11 @@ logging.basicConfig( ) log = logging.getLogger(__name__) -HOST = "127.0.0.1" -PORT = 8188 +HOST = os.getenv("TONO_HOST", "127.0.0.1") +try: + PORT = int(os.getenv("TONO_PORT", "8188")) +except ValueError: + sys.exit("TONO_PORT must be a valid integer") def main() -> None: diff --git a/backend/server.py b/backend/server.py index a4a857f..5cff300 100644 --- a/backend/server.py +++ b/backend/server.py @@ -32,8 +32,10 @@ import asyncio import json import logging import math +import os import re import secrets +import shutil import sys import time from collections import defaultdict @@ -53,6 +55,7 @@ from backend.session_runtime import ( resolve_client_path, server_path_to_client_path, session_input_dir, + session_root_dir, session_upload_uri, validate_session_id, ) @@ -145,7 +148,9 @@ def create_app( pending_downloads: dict[str, Path] = {} _last_download_token: dict[str, str] = {} # session_id → token (limit one per session) _prompt_last_time: dict[str, float] = {} # session_id → monotonic timestamp + _pending_cleanups: dict[str, asyncio.TimerHandle] = {} # session_id → scheduled cleanup PROMPT_MIN_INTERVAL = 0.5 # seconds between /prompt submissions per session + SESSION_TTL = int(os.getenv("TONO_SESSION_TTL", "60")) # seconds after last WS disconnect def _is_link(value) -> bool: return ( @@ -155,6 +160,9 @@ def create_app( and isinstance(value[1], int) ) + async def health_check(_request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + def require_session_id(request: web.Request) -> str: raw_session = request.headers.get(SESSION_HEADER) or request.query.get(SESSION_QUERY) if not raw_session: @@ -436,6 +444,11 @@ def create_app( This endpoint is intentionally unrestricted because tono is a local-first application; do not expose it on a public network. """ + if not _plugins_on: + raise web.HTTPForbidden( + reason="Plugin upload is disabled. " + "Set TONO_PLUGINS=1 to enable (allows arbitrary code execution).", + ) reader = await request.multipart() filename = "" file_bytes = None @@ -601,11 +614,31 @@ def create_app( content_type="application/json", ) + def _cleanup_session(session_id: str) -> None: + """Evict all server-side state for a session after its grace period.""" + _pending_cleanups.pop(session_id, None) + # If the session reconnected during the grace period, abort cleanup + if session_websockets.get(session_id): + return + session_engines.pop(session_id, None) + _prompt_last_time.pop(session_id, None) + prev_token = _last_download_token.pop(session_id, None) + if prev_token: + pending_downloads.pop(prev_token, None) + session_dir = session_root_dir(session_id) + if session_dir.exists(): + shutil.rmtree(session_dir, ignore_errors=True) + log.info("Cleaned up session %s", session_id) + async def websocket_handler(request: web.Request) -> web.WebSocketResponse: session_id = require_session_id(request) ws = web.WebSocketResponse() await ws.prepare(request) session_websockets[session_id].add(ws) + # Cancel any pending cleanup for this session (user reconnected) + handle = _pending_cleanups.pop(session_id, None) + if handle is not None: + handle.cancel() log.info( "WebSocket client connected for session %s (%d total in session)", session_id, @@ -621,6 +654,11 @@ def create_app( session_websockets[session_id].discard(ws) if not session_websockets[session_id]: session_websockets.pop(session_id, None) + # Schedule cleanup after grace period + if SESSION_TTL > 0: + _pending_cleanups[session_id] = loop.call_later( + SESSION_TTL, _cleanup_session, session_id, + ) log.info( "WebSocket client disconnected for session %s (%d remaining in session)", session_id, @@ -632,6 +670,8 @@ def create_app( import aiohttp as _aiohttp current = _get_app_version() + if os.getenv("TONO_UPDATE_CHECK", "").strip().lower() in ("off", "0", "false", "no"): + return web.json_response({"current": current, "latest": None, "update_available": False}) url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" try: async with _aiohttp.ClientSession() as session: @@ -655,14 +695,14 @@ def create_app( app = web.Application(client_max_size=100 * 1024 * 1024) # 100 MB upload cap app["allow_local_filesystem"] = allow_local_filesystem + app.router.add_get("/health", health_check) app.router.add_get("/", index) app.router.add_get("/nodes", get_nodes) app.router.add_get("/files", list_files) app.router.add_get("/folder-files", get_folder_files) app.router.add_post("/upload-folder", create_upload_folder) app.router.add_post("/upload", upload_file) - if _plugins_on: - app.router.add_post("/upload-plugin", upload_plugin) + app.router.add_post("/upload-plugin", upload_plugin) app.router.add_post("/download", download_file) app.router.add_post("/save-workflow-png", save_workflow_png) app.router.add_get("/channels", get_channels) diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..f5e6b83 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,87 @@ +# Self-Hosting + +tono can be self-hosted on any server with Python 3.10+ and Node.js 18+. + +## Quick start + +```bash +git clone https://github.com/VIPQualityPost/tono.git && cd tono +python -m venv .venv && source .venv/bin/activate +pip install -e . +cd frontend && npm ci && npm run build && cd .. +TONO_HOST=0.0.0.0 tono +``` + +The server will be available at `http://:8188`. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `TONO_HOST` | `127.0.0.1` | Bind address. Set to `0.0.0.0` for remote access. | +| `TONO_PORT` | `8188` | Listen port. | +| `TONO_APPDATA` | Platform-dependent | Data directory for sessions and uploads. | +| `TONO_PLUGINS` | `0` (web mode) | Set to `1` to enable the plugin system. **Warning:** plugins execute arbitrary Python code. | +| `TONO_SESSION_TTL` | `60` | Seconds to wait after a user disconnects before cleaning up their session data. Set to `0` to disable cleanup. | +| `TONO_UPDATE_CHECK` | (enabled) | Set to `off` to disable the GitHub release update checker. | + +## Reverse proxy + +You will almost certainly want to run tono behind a reverse proxy for TLS termination. The key requirement is proxying WebSocket connections on `/ws`. + +### nginx + +```nginx +server { + listen 443 ssl; + server_name tono.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:8188; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + client_max_body_size 100M; + } +} +``` + +### Caddy + +``` +tono.example.com { + reverse_proxy localhost:8188 +} +``` + +Caddy handles TLS, WebSocket upgrades, and headers automatically. + +## Health check + +`GET /health` returns `{"status": "ok"}` and can be used by load balancers or monitoring tools. + +## Authentication + +tono does not include built-in authentication. For access control, use your reverse proxy: + +- **nginx**: HTTP basic auth (`auth_basic`) or integrate with an auth provider +- **Caddy**: `basicauth` directive or forward auth +- **Cloudflare Access**, **Authelia**, **Authentik**: external identity-aware proxies + +## Session lifecycle + +Each browser tab creates an isolated session with its own uploaded files, execution engine, and cache. When a user closes their tab (WebSocket disconnects) and does not reconnect within `TONO_SESSION_TTL` seconds, the server automatically cleans up: + +- Execution engine and cached results +- Uploaded files on disk +- Pending downloads and rate limit state + +Set `TONO_SESSION_TTL=0` to disable automatic cleanup (useful for single-user deployments). diff --git a/pyproject.toml b/pyproject.toml index ef83353..30b66d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ icons = [ "reportlab>=4.4.0", ] +[project.scripts] +tono = "backend.main:main" + [tool.setuptools.packages.find] include = ["backend*"]