work on improving hostability
This commit is contained in:
33
.github/workflows/deploy.yml
vendored
Normal file
33
.github/workflows/deploy.yml
vendored
Normal 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
|
||||||
12
README.md
12
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
|
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
3
backend/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from backend.main import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
87
docs/self-hosting.md
Normal 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).
|
||||||
@@ -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*"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user