work on improving hostability

This commit is contained in:
2026-04-03 20:25:30 -07:00
parent 7ecc225f8c
commit e79ffea14a
7 changed files with 186 additions and 4 deletions

33
.github/workflows/deploy.yml vendored Normal file
View File

@@ -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

View File

@@ -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 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 ## Docs
- [Building](docs/building.md) — setup, dev mode, web deployment, and native desktop builds for macOS, Linux, and Windows - [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 - [Plugins](docs/plugins.md) — writing and uploading custom node plugins
- [Testing](docs/testing.md) — running tests and writing new ones - [Testing](docs/testing.md) — running tests and writing new ones

3
backend/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from backend.main import main
main()

View File

@@ -10,6 +10,7 @@ from the tono/ directory.
import asyncio import asyncio
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -25,8 +26,11 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
HOST = "127.0.0.1" HOST = os.getenv("TONO_HOST", "127.0.0.1")
PORT = 8188 try:
PORT = int(os.getenv("TONO_PORT", "8188"))
except ValueError:
sys.exit("TONO_PORT must be a valid integer")
def main() -> None: def main() -> None:

View File

@@ -32,8 +32,10 @@ import asyncio
import json import json
import logging import logging
import math import math
import os
import re import re
import secrets import secrets
import shutil
import sys import sys
import time import time
from collections import defaultdict from collections import defaultdict
@@ -53,6 +55,7 @@ from backend.session_runtime import (
resolve_client_path, resolve_client_path,
server_path_to_client_path, server_path_to_client_path,
session_input_dir, session_input_dir,
session_root_dir,
session_upload_uri, session_upload_uri,
validate_session_id, validate_session_id,
) )
@@ -145,7 +148,9 @@ def create_app(
pending_downloads: dict[str, Path] = {} pending_downloads: dict[str, Path] = {}
_last_download_token: dict[str, str] = {} # session_id → token (limit one per session) _last_download_token: dict[str, str] = {} # session_id → token (limit one per session)
_prompt_last_time: dict[str, float] = {} # session_id → monotonic timestamp _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 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: def _is_link(value) -> bool:
return ( return (
@@ -155,6 +160,9 @@ def create_app(
and isinstance(value[1], int) 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: def require_session_id(request: web.Request) -> str:
raw_session = request.headers.get(SESSION_HEADER) or request.query.get(SESSION_QUERY) raw_session = request.headers.get(SESSION_HEADER) or request.query.get(SESSION_QUERY)
if not raw_session: if not raw_session:
@@ -436,6 +444,11 @@ def create_app(
This endpoint is intentionally unrestricted because tono is a This endpoint is intentionally unrestricted because tono is a
local-first application; do not expose it on a public network. 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() reader = await request.multipart()
filename = "" filename = ""
file_bytes = None file_bytes = None
@@ -601,11 +614,31 @@ def create_app(
content_type="application/json", 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: async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
session_id = require_session_id(request) session_id = require_session_id(request)
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(request) await ws.prepare(request)
session_websockets[session_id].add(ws) 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( log.info(
"WebSocket client connected for session %s (%d total in session)", "WebSocket client connected for session %s (%d total in session)",
session_id, session_id,
@@ -621,6 +654,11 @@ def create_app(
session_websockets[session_id].discard(ws) session_websockets[session_id].discard(ws)
if not session_websockets[session_id]: if not session_websockets[session_id]:
session_websockets.pop(session_id, None) 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( log.info(
"WebSocket client disconnected for session %s (%d remaining in session)", "WebSocket client disconnected for session %s (%d remaining in session)",
session_id, session_id,
@@ -632,6 +670,8 @@ def create_app(
import aiohttp as _aiohttp import aiohttp as _aiohttp
current = _get_app_version() 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" url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
try: try:
async with _aiohttp.ClientSession() as session: async with _aiohttp.ClientSession() as session:
@@ -655,13 +695,13 @@ def create_app(
app = web.Application(client_max_size=100 * 1024 * 1024) # 100 MB upload cap app = web.Application(client_max_size=100 * 1024 * 1024) # 100 MB upload cap
app["allow_local_filesystem"] = allow_local_filesystem app["allow_local_filesystem"] = allow_local_filesystem
app.router.add_get("/health", health_check)
app.router.add_get("/", index) app.router.add_get("/", index)
app.router.add_get("/nodes", get_nodes) app.router.add_get("/nodes", get_nodes)
app.router.add_get("/files", list_files) app.router.add_get("/files", list_files)
app.router.add_get("/folder-files", get_folder_files) app.router.add_get("/folder-files", get_folder_files)
app.router.add_post("/upload-folder", create_upload_folder) app.router.add_post("/upload-folder", create_upload_folder)
app.router.add_post("/upload", upload_file) 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("/download", download_file)
app.router.add_post("/save-workflow-png", save_workflow_png) app.router.add_post("/save-workflow-png", save_workflow_png)

87
docs/self-hosting.md Normal file
View File

@@ -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://<your-server-ip>: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).

View File

@@ -35,6 +35,9 @@ icons = [
"reportlab>=4.4.0", "reportlab>=4.4.0",
] ]
[project.scripts]
tono = "backend.main:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["backend*"] include = ["backend*"]