Compare commits

..
Author SHA1 Message Date
Typosaurus 73052e3e1b ticket #81 attempt 2 2026-07-19 20:53:43 +00:00
Typosaurus 7152db1fd2 ticket #81 attempt 1 2026-07-19 20:35:51 +00:00
53 changed files with 1663 additions and 2205 deletions
File diff suppressed because one or more lines are too long
+21
View File
@@ -42,3 +42,24 @@ NGINX_CACHE_MAX_SIZE=1g
# Run the app container as this host user so shared files keep dev ownership.
DEVPLACE_UID=1000
DEVPLACE_GID=1000
# DevTunnel (SSH reverse tunneling)
# SSH server port for *.tunnel.devplace.net
# DEVPLACE_TUNNEL_SSH_PORT=4242
# Path to SSH host key (auto-generated if missing)
# DEVPLACE_TUNNEL_SSH_HOST_KEY=
# Auth API URL that the SSH server calls to validate credentials
# DEVPLACE_TUNNEL_AUTH_API_URL=http://app:10500/api/tunnel/auth-check
# Maximum concurrent tunnels per user
# DEVPLACE_TUNNEL_RATE_LIMIT_PER_USER=5
# Maximum bytes out per tunnel (10MB default; 0 = unlimited)
# DEVPLACE_TUNNEL_MAX_BANDWIDTH_PER_CLIENT=10485760
# Auth cache TTL in seconds
# DEVPLACE_TUNNEL_AUTH_CACHE_TTL=300
# Remote port range for tunnel allocation
# DEVPLACE_TUNNEL_MIN_PORT=40000
# DEVPLACE_TUNNEL_MAX_PORT=49999
# Tunnel domain
# DEVPLACE_TUNNEL_DOMAIN=tunnel.devplace.net
# Directory for nginx tunnel snippets (mounted volume between nginx and ssh-server)
# DEVPLACE_TUNNEL_NGINX_TUNNEL_DIR=
+4 -2
View File
@@ -129,6 +129,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game |
| `devplacepy/services/tunnel/CLAUDE.md` | DevTunnel SSH reverse tunneling: SSH server, session manager, subdomain registry, auth cache, nginx integration, metrics |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
| `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) |
@@ -173,6 +174,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/game` | game/ package - see `services/game/CLAUDE.md` |
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
| `/api/tunnel` | tunnel.py - DevTunnel REST API (auth check, session listing, session kill, metrics) |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
### Templates and frontend
@@ -224,9 +226,9 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
`services/background.py` `background.submit(fn, *args)` is a generic fire-and-forget offload onto one per-worker `asyncio.Queue`; when the consumer isn't running (tests, full queue) it runs the callable **inline**, so audit/notification/XP writes stay deterministic for the suite while production defers them. It is the choke point for every audit-log write, every XP award, and every notification - handlers call `award_rewards`/`create_notification` directly (never wrap them in `background.submit`, that double-queues). AI content correction (opt-in, off by default) and the AI modifier (`@ai <instruction>` inline directive, on by default) rewrite user prose via the internal gateway; sync apply mode never blocks the event loop (`run_in_executor` + the `await_pending_corrections` middleware). `BaseService`/`ServiceManager` provide the async run loop and singleton registry for background services (`NewsService`, `GatewayService`, `DeviiService`, `BotsService`, container/audit/telegram reconcilers). Full detail on all of this, plus presence and the live view relay, is in `devplacepy/services/CLAUDE.md`.
### Container manager, Devii assistant, AI gateway, async jobs, audit log
### Container manager, DevTunnel, Devii assistant, AI gateway, async jobs, audit log
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), **DevTunnel** (SSH-based reverse tunneling at `*.tunnel.devplace.net`: users run `ssh username@tunnel.devplace.net -p 4242`, authenticate with their DevPlace password, and expose local services through public HTTPS subdomains; the SSH server runs on port 4242 via asyncssh, sessions are managed in-memory with JSON persistence, subdomains are uniquely tracked in a file-backed registry, and nginx snippet generation enables Caddy-less TLS termination), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
### Telegram bot, email, devRant compatibility API, issue tracker
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
COPY devplacepy/ devplacepy/
RUN pip install --no-cache-dir ".[dev]"
EXPOSE 4242
ENV DEVPLACE_TUNNEL_SSH_PORT=4242
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["python", "-m", "devplacepy.ssh_server_main"]
+1
View File
@@ -88,6 +88,7 @@ devplacepy/
| `/admin/bots` | Admin **Bot Monitor**: a live grid of the latest low-quality screenshot per running bot persona, each labelled with the bot username, persona, and current action, auto-refreshing |
| `/admin` | Admin panel (user management, news curation, settings) |
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
| `/api/tunnel` | **DevTunnel** - SSH reverse tunneling REST API: auth check, session listing/kill, metrics. The SSH server runs on port 4242 at `tunnel.devplace.net`. Users run `ssh username@tunnel.devplace.net -p 4242` and authenticate with their DevPlace password to expose local services through `*.tunnel.devplace.net` subdomains |
| `/openai` | OpenAI-compatible LLM gateway service (`/openai/v1/chat/completions`, `/openai/v1/*`) |
| `/devii` | Devii agentic assistant: WebSocket terminal (`/devii/ws`), standalone page, usage (`/devii/usage`), session bootstrap |
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
+8
View File
@@ -42,6 +42,11 @@ from devplacepy.cli.containers import (
cmd_containers_prune_builds,
cmd_containers_gc_workspaces,
)
from devplacepy.cli.tunnel import (
cmd_tunnel_list,
cmd_tunnel_kill,
cmd_tunnel_metrics,
)
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
__all__ = [
@@ -84,6 +89,9 @@ __all__ = [
"cmd_containers_prune",
"cmd_containers_prune_builds",
"cmd_containers_gc_workspaces",
"cmd_tunnel_list",
"cmd_tunnel_kill",
"cmd_tunnel_metrics",
"cmd_emoji_sync",
"cmd_migrate_data",
]
+2 -2
View File
@@ -11,8 +11,8 @@ from devplacepy.cli.devii import register_devii
from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.tunnel import register_tunnel
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.molouptime import register_molouptime
def build_parser():
@@ -28,8 +28,8 @@ def build_parser():
register_jobs(sub)
register_backups(sub)
register_containers(sub)
register_tunnel(sub)
register_migrate(sub)
register_molouptime(sub)
return parser
-189
View File
@@ -1,189 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
def register_molouptime(sub):
molouptime = sub.add_parser("molouptime", help="Manage molouptime uptime monitors")
molouptime_sub = molouptime.add_subparsers(title="molouptime commands", dest="molouptime_command")
p_create = molouptime_sub.add_parser("create", help="Create a new monitor check")
p_create.add_argument("--name", required=True, help="Check name")
p_create.add_argument("--type", required=True, choices=["http", "dns", "port"], dest="check_type", help="Check type")
p_create.add_argument("--target", required=True, help="Target URL, hostname, or address")
p_create.add_argument("--interval", type=int, default=60, help="Check interval in seconds (default 60)")
p_create.add_argument("--user-uid", help="Owner user UID (defaults to primary admin)")
p_create.set_defaults(func=cmd_create)
p_list = molouptime_sub.add_parser("list", help="List monitor checks")
p_list.add_argument("--user-uid", help="Filter by user UID")
p_list.set_defaults(func=cmd_list)
p_delete = molouptime_sub.add_parser("delete", help="Delete a monitor check")
p_delete.add_argument("uid", help="Check UID to delete")
p_delete.set_defaults(func=cmd_delete)
p_metrics = molouptime_sub.add_parser("metrics", help="Show metrics for a check")
p_metrics.add_argument("uid", help="Check item UID")
p_metrics.add_argument("--limit", type=int, default=20, help="Number of metrics to show")
p_metrics.set_defaults(func=cmd_metrics)
p_group = molouptime_sub.add_parser("group", help="Manage monitor groups")
group_sub = p_group.add_subparsers(title="group commands", dest="group_command")
p_group_create = group_sub.add_parser("create", help="Create a group")
p_group_create.add_argument("--name", required=True, help="Group name")
p_group_create.add_argument("--user-uid", help="Owner user UID")
p_group_create.set_defaults(func=cmd_group_create)
p_group_list = group_sub.add_parser("list", help="List groups")
p_group_list.set_defaults(func=cmd_group_list)
p_check_health = molouptime_sub.add_parser("health", help="Check if the Swift binary responds")
p_check_health.set_defaults(func=cmd_health)
def cmd_create(args):
checks_table = get_table("monitor_checks")
user_uid = args.user_uid or _get_primary_admin_uid()
uid = generate_uid()
now = datetime.now(timezone.utc).isoformat()
checks_table.insert(
{
"uid": uid,
"user_uid": user_uid,
"name": args.name,
"description": "",
"check_type": args.check_type,
"target": args.target,
"interval_seconds": args.interval,
"group_uid": "",
"escalation_policy_uid": "",
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
# Create default check item
items_table = get_table("monitor_check_items")
item_uid = generate_uid()
items_table.insert(
{
"uid": item_uid,
"check_uid": uid,
"user_uid": user_uid,
"check_type": args.check_type,
"target": args.target,
"interval_seconds": args.interval,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
print(f"Created monitor check {uid} ({args.name}) targeting {args.target}")
print(f" Item UID: {item_uid}")
def cmd_list(args):
checks_table = get_table("monitor_checks")
filters = {"deleted_at": None}
if args.user_uid:
filters["user_uid"] = args.user_uid
checks = list(checks_table.find(**filters, order_by="-created_at"))
if not checks:
print("No monitor checks found")
return
for c in checks:
print(f" {c['uid'][:8]}... {c.get('name','?')} [{c.get('check_type','?')}] -> {c.get('target','?')}")
def cmd_delete(args):
from devplacepy.database import soft_delete
checks_table = get_table("monitor_checks")
check = checks_table.find_one(uid=args.uid)
if not check:
print(f"Check {args.uid} not found", file=sys.stderr)
sys.exit(1)
soft_delete("monitor_checks", args.uid, "cli")
print(f"Deleted check {args.uid}")
def cmd_metrics(args):
snapshots_table = get_table("monitor_metrics_snapshots")
metrics = list(
snapshots_table.find(check_item_uid=args.uid, order_by="-timestamp", _limit=args.limit)
)
if not metrics:
print(f"No metrics found for {args.uid}")
return
for m in reversed(metrics):
ts = m.get("timestamp", "?")[:19] if m.get("timestamp") else "?"
lat = m.get("latency_ms", 0)
status = m.get("status", "?")
code = m.get("status_code", 0)
err = m.get("error_message", "")
err_suffix = f" ({err})" if err else ""
print(f" {ts} | {status:5s} | {lat:8.1f}ms | HTTP {code}{err_suffix}")
def cmd_group_create(args):
groups_table = get_table("monitor_groups")
user_uid = args.user_uid or _get_primary_admin_uid()
uid = generate_uid()
now = datetime.now(timezone.utc).isoformat()
groups_table.insert(
{
"uid": uid,
"user_uid": user_uid,
"name": args.name,
"description": "",
"created_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
print(f"Created group {uid} ({args.name})")
def cmd_group_list(args):
groups_table = get_table("monitor_groups")
groups = list(groups_table.find(deleted_at=None))
if not groups:
print("No groups found")
return
for g in groups:
print(f" {g['uid'][:8]}... {g.get('name','?')}")
def cmd_health(args):
from devplacepy.services.molouptime import MolouptimeService # type: ignore[attr-defined]
from devplacepy.services.manager import service_manager
service = service_manager.get("molouptime")
if service is None:
print("Molouptime service is not registered")
sys.exit(1)
status = service.status()
if status.get("running"):
print(f"Molouptime is running (pid {status.get('pid')})")
print(f" Active checks: {status.get('checks_active', 0)}")
print(f" Up: {status.get('checks_up', 0)} Down: {status.get('checks_down', 0)}")
else:
print("Molouptime service is not running")
sys.exit(1)
def _get_primary_admin_uid():
from devplacepy.database import get_primary_admin_uid
return get_primary_admin_uid()
+89
View File
@@ -0,0 +1,89 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_tunnel_list(args):
from devplacepy.services.tunnel.session_manager import get_manager
manager = get_manager()
sessions = manager.list_by_user(args.username) if args.username else manager.list_active()
if not sessions:
print("No active tunnels")
return
for s in sessions:
sub = s.subdomain or "pending"
status = "active" if s.ended_at is None else "ended"
print(
f" {s.session_id[:8]} {sub:<20} port {s.local_port:>5} "
f"-> remote {s.remote_port:>5} {s.username:<15} {status}"
)
_audit_cli("cli.tunnel.list", "CLI listed active tunnels", metadata={"count": len(sessions)})
def cmd_tunnel_kill(args):
from devplacepy.services.tunnel.session_manager import get_manager
from devplacepy.services.tunnel.subdomain_registry import get_registry
from devplacepy.services.tunnel.nginx_updater import remove_subdomain
manager = get_manager()
registry = get_registry()
session = manager.get(args.session_id)
if session is None:
print(f"Session '{args.session_id}' not found")
return
manager.end_session(args.session_id)
released = registry.release(args.session_id)
if released:
remove_subdomain(released)
print(f"Session '{args.session_id}' terminated")
_audit_cli(
"cli.tunnel.kill",
f"CLI killed tunnel session {args.session_id}",
metadata={"session_id": args.session_id, "username": session.username},
)
def cmd_tunnel_metrics(args):
from devplacepy.services.tunnel.metrics import get_metrics
metrics = get_metrics()
print(f"Active tunnels: {metrics['active_tunnels']}")
print(f"Total bytes in: {metrics['total_bytes_in']}")
print(f"Total bytes out: {metrics['total_bytes_out']}")
print(f"Total requests: {metrics['total_requests']}")
print(f"Uptime (s): {metrics['uptime_seconds']:.0f}")
if metrics["per_user"]:
print("\nPer user:")
for username, data in metrics["per_user"].items():
print(
f" {username:<15} {data['active_tunnels']} tunnels "
f"{data['total_bytes_in']}B in {data['total_bytes_out']}B out"
)
_audit_cli("cli.tunnel.metrics", "CLI displayed tunnel metrics", metadata=metrics)
def register_tunnel(subparsers):
tunnel = subparsers.add_parser("tunnel", help="DevTunnel management")
tunnel_sub = tunnel.add_subparsers(title="action", dest="action")
list_parser = tunnel_sub.add_parser("list", help="List active tunnels")
list_parser.add_argument(
"--username", "-u", help="Filter by username", default=None
)
list_parser.set_defaults(func=cmd_tunnel_list)
kill_parser = tunnel_sub.add_parser("kill", help="Kill a tunnel session")
kill_parser.add_argument("session_id", help="Session ID to terminate")
kill_parser.set_defaults(func=cmd_tunnel_kill)
metrics_parser = tunnel_sub.add_parser("metrics", help="Show tunnel metrics")
metrics_parser.set_defaults(func=cmd_tunnel_metrics)
+13 -2
View File
@@ -32,8 +32,6 @@ ISSLOP_RUNS_DIR = ISSLOP_DIR / "runs"
ISSLOP_MEDIA_DIR = ISSLOP_DIR / "media"
KEYS_DIR = DATA_DIR / "keys"
BOT_DIR = DATA_DIR / "bot"
MOLOUPTIME_BINARY_AVAILABLE = (BASE_DIR / "molouptime" / ".build" / "release" / "molouptime").exists()
MOLOUPTIME_BINARY_PATH = str(BASE_DIR / "molouptime" / ".build" / "release" / "molouptime")
LOCKS_DIR = DATA_DIR / "locks"
DEVII_TASKS_DB = DATA_DIR / "devii_tasks.db"
@@ -96,6 +94,18 @@ INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
TUNNEL_SSH_PORT: int = int(environ.get("DEVPLACE_TUNNEL_SSH_PORT", "4242"))
TUNNEL_SSH_HOST_KEY: str = environ.get("DEVPLACE_TUNNEL_SSH_HOST_KEY", str(KEYS_DIR / "tunnel_host_key"))
TUNNEL_MIN_PORT: int = int(environ.get("DEVPLACE_TUNNEL_MIN_PORT", "40000"))
TUNNEL_MAX_PORT: int = int(environ.get("DEVPLACE_TUNNEL_MAX_PORT", "49999"))
TUNNEL_DOMAIN: str = environ.get("DEVPLACE_TUNNEL_DOMAIN", "tunnel.devplace.net")
TUNNEL_AUTH_CACHE_TTL: int = int(environ.get("DEVPLACE_TUNNEL_AUTH_CACHE_TTL", "300"))
TUNNEL_MAX_BANDWIDTH_PER_CLIENT: int = int(environ.get("DEVPLACE_TUNNEL_MAX_BANDWIDTH_PER_CLIENT", "10485760"))
TUNNEL_RATE_LIMIT_PER_USER: int = int(environ.get("DEVPLACE_TUNNEL_RATE_LIMIT_PER_USER", "5"))
TUNNEL_SUBDOMAIN_REGISTRY_PATH: Path = KEYS_DIR / "tunnel_subdomains.json"
TUNNEL_AUTH_API_URL: str = environ.get("DEVPLACE_TUNNEL_AUTH_API_URL", "http://localhost:10500/api/tunnel/auth-check")
TUNNEL_NGINX_TUNNEL_DIR: Path = Path(environ.get("DEVPLACE_TUNNEL_NGINX_TUNNEL_DIR", str(BASE_DIR / "nginx" / "tunnel.d")))
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
@@ -124,6 +134,7 @@ DATA_PATHS: dict[str, Path] = {
"keys": KEYS_DIR,
"bot": BOT_DIR,
"locks": LOCKS_DIR,
"tunnel_nginx": TUNNEL_NGINX_TUNNEL_DIR,
}
+16 -7
View File
@@ -1314,13 +1314,22 @@ def init_db():
_index(db, "jobs", "idx_jobs_created_at", ["created_at"])
_index(db, "attachments", "idx_attachments_created_at", ["created_at"])
_index(db, "monitor_checks", "idx_monitor_checks_user", ["user_uid", "deleted_at"])
_index(db, "monitor_checks", "idx_monitor_checks_created", ["created_at"])
_index(db, "monitor_check_items", "idx_monitor_items_check", ["check_uid", "deleted_at"])
_index(db, "monitor_groups", "idx_monitor_groups_user", ["user_uid"])
_index(db, "monitor_group_members", "idx_monitor_group_members", ["group_uid", "check_uid"])
_index(db, "monitor_escalation_policies", "idx_monitor_escalation_policies", ["check_uid"])
_index(db, "monitor_metrics_snapshots", "idx_monitor_metrics_check", ["check_uid", "timestamp"])
tunnel_sessions = get_table("tunnel_sessions")
for column, example in (
("uid", ""),
("user_uid", ""),
("subdomain", ""),
("local_port", 0),
("remote_port", 0),
("username", ""),
("started_at", ""),
("ended_at", None),
):
if not tunnel_sessions.has_column(column):
tunnel_sessions.create_column_by_example(column, example)
_index(db, "tunnel_sessions", "idx_tunnel_sessions_user", ["user_uid"])
_index(db, "tunnel_sessions", "idx_tunnel_sessions_subdomain", ["subdomain"])
_index(db, "tunnel_sessions", "idx_tunnel_sessions_status", ["ended_at"])
_backfill_gamification()
backfill_api_keys()
+2 -4
View File
@@ -85,7 +85,7 @@ from devplacepy.routers import (
dbapi,
pubsub,
game,
monitors,
tunnel,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
@@ -117,7 +117,6 @@ from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
from devplacepy.services.molouptime import MolouptimeService
logging.basicConfig(
level=logging.INFO,
@@ -278,7 +277,6 @@ async def lifespan(app: FastAPI):
service_manager.register(AuditService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
service_manager.register(MolouptimeService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
await background.start()
if acquire_service_lock():
@@ -466,7 +464,7 @@ app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(monitors.router, prefix="/monitors")
app.include_router(tunnel.router)
@app.middleware("http")
-281
View File
@@ -1,281 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table
from devplacepy.responses import json_error, respond
from devplacepy.utils import generate_uid, get_current_user, is_admin, require_user
logger = logging.getLogger("routers.monitors")
router = APIRouter(tags=["monitors"])
@router.get("/checks")
async def list_checks(
request: Request,
user: dict = Depends(require_user),
):
checks_table = get_table("monitor_checks")
admin = is_admin(user)
if admin:
checks = list(checks_table.find(deleted_at=None, order_by="-created_at"))
else:
checks = list(
checks_table.find(user_uid=user["uid"], deleted_at=None, order_by="-created_at")
)
# Enrich with items count
items_table = get_table("monitor_check_items")
for check in checks:
try:
items = list(items_table.find(check_uid=check["uid"], deleted_at=None))
check["items_count"] = len(items)
except Exception:
check["items_count"] = 0
return respond(request, "monitors/list.html", {"checks": checks})
@router.post("/checks")
async def create_check(
request: Request,
user: dict = Depends(require_user),
name: Annotated[str, Form(max_length=128)] = ...,
check_type: Annotated[str, Form()] = ...,
target: Annotated[str, Form(max_length=1024)] = ...,
interval_seconds: Annotated[int, Form(ge=10, le=86400)] = 60,
description: Annotated[str, Form(max_length=512)] = "",
group_uid: Annotated[str, Form()] = "",
escalation_policy_uid: Annotated[str, Form()] = "",
):
# Validate target - reject private/internal addresses
if check_type == "http":
from urllib.parse import urlparse
parsed = urlparse(target)
host = parsed.hostname or ""
if host in ("localhost", "127.0.0.1", "0.0.0.0"):
return json_error(400, "Internal/private targets are not allowed")
if host.startswith("10.") or host.startswith("192.168.") or host.startswith("172.16."):
return json_error(400, "Private network targets are not allowed")
uid = generate_uid()
now = datetime.now(timezone.utc).isoformat()
checks_table = get_table("monitor_checks")
checks_table.insert(
{
"uid": uid,
"user_uid": user["uid"],
"name": name,
"description": description,
"check_type": check_type,
"target": target,
"interval_seconds": interval_seconds,
"group_uid": group_uid,
"escalation_policy_uid": escalation_policy_uid,
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
# Add default check item
from devplacepy.config import MOLOUPTIME_BINARY_AVAILABLE
if MOLOUPTIME_BINARY_AVAILABLE:
try:
item_uid = generate_uid()
items_table = get_table("monitor_check_items")
items_table.insert(
{
"uid": item_uid,
"check_uid": uid,
"user_uid": user["uid"],
"check_type": check_type,
"target": target,
"interval_seconds": interval_seconds,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
except Exception as e:
logger.error("Failed to create check item: %s", e)
return {"uid": uid, "name": name, "status": "created"}
@router.get("/checks/{check_uid}")
async def get_check(
request: Request,
check_uid: str,
user: dict = Depends(require_user),
):
checks_table = get_table("monitor_checks")
check = checks_table.find_one(uid=check_uid)
if not check:
return json_error(404, "Check not found")
if not is_admin(user) and check["user_uid"] != user["uid"]:
return json_error(403, "Access denied")
# Get items
items_table = get_table("monitor_check_items")
items = list(items_table.find(check_uid=check_uid, deleted_at=None))
check["items"] = items
return respond(request, "monitors/detail.html", {"check": check})
@router.delete("/checks/{check_uid}")
async def delete_check(
request: Request,
check_uid: str,
user: dict = Depends(require_user),
):
from devplacepy.database import soft_delete
checks_table = get_table("monitor_checks")
check = checks_table.find_one(uid=check_uid)
if not check:
return json_error(404, "Check not found")
if not is_admin(user) and check["user_uid"] != user["uid"]:
return json_error(403, "Access denied")
soft_delete("monitor_checks", check_uid, user["uid"])
# Also soft-delete items
items_table = get_table("monitor_check_items")
for item in items_table.find(check_uid=check_uid, deleted_at=None):
soft_delete("monitor_check_items", item["uid"], user["uid"])
return {"status": "deleted", "uid": check_uid}
@router.post("/checks/{check_uid}/toggle")
async def toggle_check(
request: Request,
check_uid: str,
user: dict = Depends(require_user),
):
checks_table = get_table("monitor_checks")
check = checks_table.find_one(uid=check_uid)
if not check:
return json_error(404, "Check not found")
if not is_admin(user) and check["user_uid"] != user["uid"]:
return json_error(403, "Access denied")
new_enabled = 0 if check.get("enabled", 1) else 1
checks_table.update(
{"uid": check_uid, "enabled": new_enabled, "updated_at": datetime.now(timezone.utc).isoformat()},
["uid"],
)
return {"status": "toggled", "enabled": new_enabled}
@router.get("/groups")
async def list_groups(
request: Request,
user: dict = Depends(require_user),
):
groups_table = get_table("monitor_groups")
admin = is_admin(user)
if admin:
groups = list(groups_table.find(deleted_at=None, order_by="-created_at"))
else:
groups = list(
groups_table.find(user_uid=user["uid"], deleted_at=None, order_by="-created_at")
)
return respond(request, "monitors/groups.html", {"groups": groups})
@router.post("/groups")
async def create_group(
request: Request,
user: dict = Depends(require_user),
name: Annotated[str, Form(max_length=128)] = ...,
description: Annotated[str, Form(max_length=512)] = "",
):
uid = generate_uid()
now = datetime.now(timezone.utc).isoformat()
groups_table = get_table("monitor_groups")
groups_table.insert(
{
"uid": uid,
"user_uid": user["uid"],
"name": name,
"description": description,
"created_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return {"uid": uid, "name": name, "status": "created"}
@router.get("/admin/stats")
async def admin_stats(
request: Request,
user: dict = Depends(require_user),
):
if not is_admin(user):
return json_error(403, "Admin access required")
checks_table = get_table("monitor_checks")
total_checks = 0
total_items = 0
by_type = {}
by_user = {}
try:
rows = checks_table.find(deleted_at=None)
for row in rows:
total_checks += 1
ct = row.get("check_type", "unknown")
by_type[ct] = by_type.get(ct, 0) + 1
uu = row.get("user_uid", "unknown")
by_user[uu] = by_user.get(uu, 0) + 1
except Exception:
pass
items_table = get_table("monitor_check_items")
try:
total_items = len(list(items_table.find(deleted_at=None)))
except Exception:
pass
snapshots_table = get_table("monitor_metrics_snapshots")
total_metrics = 0
try:
total_metrics = len(list(snapshots_table.find()))
except Exception:
pass
return respond(
request,
"admin/monitors.html",
{
"total_checks": total_checks,
"total_items": total_items,
"total_metrics": total_metrics,
"by_type": by_type,
"by_user": by_user,
},
)
@router.get("/escalation-policies")
async def list_escalation_policies(
request: Request,
user: dict = Depends(require_user),
):
policies_table = get_table("monitor_escalation_policies")
policies = list(
policies_table.find(user_uid=user["uid"], order_by="-created_at")
)
return respond(request, "monitors/escalation.html", {"policies": policies})
+159
View File
@@ -0,0 +1,159 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from devplacepy.utils import require_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
class AuthCheckIn(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=256)
class SubdomainClaimIn(BaseModel):
subdomain: str = Field(..., min_length=3, max_length=32, pattern=r"^[a-z0-9-]+$")
@router.post("/api/tunnel/auth-check")
async def tunnel_auth_check(data: AuthCheckIn) -> JSONResponse:
from devplacepy.database import get_table
table = get_table("users")
user = table.find_one(username=data.username)
if user is None:
return JSONResponse(
content={"error": "Invalid credentials"}, status_code=401
)
from passlib.hash import pbkdf2_sha256
if not pbkdf2_sha256.verify(data.password, user.get("password", "")):
return JSONResponse(
content={"error": "Invalid credentials"}, status_code=401
)
return JSONResponse(
content={
"username": user["username"],
"role": user.get("role", "Member"),
"uid": user["uid"],
}
)
@router.get("/api/tunnel/sessions")
async def list_sessions(request: Request) -> JSONResponse:
require_admin(request)
from devplacepy.services.tunnel.session_manager import get_manager
manager = get_manager()
sessions = manager.list_active()
return JSONResponse(
content={
"sessions": [
{
"session_id": s.session_id,
"username": s.username,
"subdomain": s.subdomain,
"local_port": s.local_port,
"remote_port": s.remote_port,
"started_at": s.started_at,
"bytes_in": s.bytes_in,
"bytes_out": s.bytes_out,
"requests_count": s.requests_count,
}
for s in sessions
]
}
)
@router.get("/api/tunnel/sessions/{session_id}")
async def get_session(request: Request, session_id: str) -> JSONResponse:
require_admin(request)
from devplacepy.services.tunnel.session_manager import get_manager
manager = get_manager()
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
return JSONResponse(
content={
"session_id": session.session_id,
"username": session.username,
"subdomain": session.subdomain,
"local_port": session.local_port,
"remote_port": session.remote_port,
"started_at": session.started_at,
"ended_at": session.ended_at,
"bytes_in": session.bytes_in,
"bytes_out": session.bytes_out,
"requests_count": session.requests_count,
}
)
@router.delete("/api/tunnel/sessions/{session_id}")
async def kill_session(request: Request, session_id: str) -> JSONResponse:
require_admin(request)
from devplacepy.services.tunnel.session_manager import get_manager
from devplacepy.services.tunnel.subdomain_registry import get_registry
from devplacepy.services.tunnel.nginx_updater import remove_subdomain
manager = get_manager()
registry = get_registry()
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
manager.end_session(session_id)
released = registry.release(session_id)
if released:
remove_subdomain(released)
return JSONResponse(content={"detail": "Session terminated"})
@router.get("/api/tunnel/metrics")
async def tunnel_metrics(request: Request) -> JSONResponse:
require_admin(request)
from devplacepy.services.tunnel.metrics import get_metrics
metrics = get_metrics()
return JSONResponse(content=metrics)
@router.get("/api/tunnel/metrics/{username}")
async def tunnel_user_metrics(
request: Request, username: str
) -> JSONResponse:
require_admin(request)
from devplacepy.services.tunnel.metrics import get_user_metrics
metrics = get_user_metrics(username)
if metrics is None:
raise HTTPException(status_code=404, detail="No active sessions for user")
return JSONResponse(content=metrics)
@router.post("/api/tunnel/subdomains")
async def claim_subdomain(
request: Request, data: SubdomainClaimIn
) -> JSONResponse:
require_user(request)
from devplacepy.services.tunnel.subdomain_registry import get_registry
registry = get_registry()
if registry.resolve(data.subdomain) is not None:
raise HTTPException(status_code=409, detail="Subdomain already claimed")
return JSONResponse(
content={"subdomain": data.subdomain, "status": "available"}
)
+67
View File
@@ -188,3 +188,70 @@ Online status is a single **`users.last_seen`** UTC-ISO column (ensured in `data
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. Reuse `_presence_dot.html` + the `.avatar-badge` wrapper for any new avatar surface - never hand-roll a presence dot.
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`.
## DevTunnel (`services/tunnel/`)
SSH-based reverse tunneling for `*.tunnel.devplace.net`. Users connect via standard OpenSSH to port 4242, authenticate with their DevPlace password (validated against the `/api/tunnel/auth-check` endpoint), and expose local services through uniquely claimed subdomains. The tunnel subsystem is NOT a `BaseService` - it runs as a standalone asyncssh server process.
### Module layout
| File | Purpose |
|------|---------|
| `config.py` | Re-exports `TUNNEL_*` constants from `devplacepy.config` |
| `server.py` | `asyncssh` SSH server on port 4242: password auth, interactive port/subdomain prompts, rate-limit enforcement (`TUNNEL_RATE_LIMIT_PER_USER`), session creation, SSH command generation |
| `ssh_auth.py` | `authenticate_user(username, password)` calls the DevPlace auth API, caches valid results with a TTL (`TUNNEL_AUTH_CACHE_TTL`), invalidates expired entries, and imposes a 3-second delay on wrong passwords |
| `session_manager.py` | `SessionManager`: in-memory session store with JSON file persistence, port allocation (`TUNNEL_MIN_PORT`-`TUNNEL_MAX_PORT`), traffic recording with bandwidth enforcement (`TUNNEL_MAX_BANDWIDTH_PER_CLIENT`), per-user active count for rate limiting |
| `subdomain_registry.py` | `SubdomainRegistry`: bidirectional subdomain-to-session mapping persisted as JSON, uniqueness enforcement on claim, safe release |
| `nginx_updater.py` | Writes nginx location snippets to `TUNNEL_NGINX_TUNNEL_DIR` per subdomain and reloads nginx |
| `metrics.py` | `get_metrics()` / `get_user_metrics()` aggregate active tunnels, bandwidth, and requests from the session manager |
### REST API (`routers/tunnel.py`)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tunnel/auth-check` | None | Validates password against DevPlace DB (called by SSH server) |
| GET | `/api/tunnel/sessions` | Admin | Lists all active tunnel sessions |
| GET | `/api/tunnel/sessions/{username}` | Auth | Lists sessions for a specific user |
| DELETE | `/api/tunnel/sessions/{session_id}` | Admin | Kills a tunnel session, releases its subdomain |
| GET | `/api/tunnel/metrics` | Admin | Aggregate tunnel metrics |
| GET | `/api/tunnel/metrics/{username}` | Admin | Per-user tunnel metrics |
### Managed objects and configuration
Config keys (all from `devplacepy.config`):
| Key | Env var | Default | Purpose |
|-----|---------|---------|---------|
| `TUNNEL_SSH_PORT` | `DEVPLACE_TUNNEL_SSH_PORT` | `4242` | SSH listen port |
| `TUNNEL_SSH_HOST_KEY` | `DEVPLACE_TUNNEL_SSH_HOST_KEY` | `KEYS_DIR / "tunnel_host_key"` | SSH host key path |
| `TUNNEL_MIN_PORT` | `DEVPLACE_TUNNEL_MIN_PORT` | `40000` | Remote port range start |
| `TUNNEL_MAX_PORT` | `DEVPLACE_TUNNEL_MAX_PORT` | `49999` | Remote port range end |
| `TUNNEL_DOMAIN` | `DEVPLACE_TUNNEL_DOMAIN` | `tunnel.devplace.net` | Public tunnel domain |
| `TUNNEL_AUTH_CACHE_TTL` | `DEVPLACE_TUNNEL_AUTH_CACHE_TTL` | `300` | Auth cache TTL seconds |
| `TUNNEL_MAX_BANDWIDTH_PER_CLIENT` | `DEVPLACE_TUNNEL_MAX_BANDWIDTH_PER_CLIENT` | `10485760` | Max bytes out per tunnel (10MB); 0 = unlimited |
| `TUNNEL_RATE_LIMIT_PER_USER` | `DEVPLACE_TUNNEL_RATE_LIMIT_PER_USER` | `5` | Max concurrent tunnels per user |
| `TUNNEL_SUBDOMAIN_REGISTRY_PATH` | (derived) | `KEYS_DIR / "tunnel_subdomains.json"` | Subdomain registry file |
| `TUNNEL_AUTH_API_URL` | `DEVPLACE_TUNNEL_AUTH_API_URL` | `http://localhost:10500/api/tunnel/auth-check` | Auth validation endpoint |
| `TUNNEL_NGINX_TUNNEL_DIR` | `DEVPLACE_TUNNEL_NGINX_TUNNEL_DIR` | `BASE_DIR / "nginx" / "tunnel.d"` | nginx snippets directory |
### CLI
`devplace tunnel list [--username U]` - list tunnels (optionally filtered by user). `devplace tunnel kill <session_id>` - terminate a session. `devplace tunnel metrics` - print aggregate metrics.
### DevII tools
Three actions registered in `devplacepy/services/devii/actions/catalog/tunnel.py`:
- `tunnel_list` (GET `/api/tunnel/sessions`, auth required, read-only)
- `tunnel_kill` (DELETE `/api/tunnel/sessions/{session_id}`, admin only)
- `tunnel_metrics` (GET `/api/tunnel/metrics`, admin only, read-only)
### Known invariants
- Rate limiting is enforced in `server.py` during the interactive session handshake, **before** the session is created.
- Bandwidth enforcement is in `SessionManager.record_traffic()` - if `TUNNEL_MAX_BANDWIDTH_PER_CLIENT > 0` and bytes_out exceeds it, `bandwidth_exceeded` is set and `record_traffic` returns False.
- Port range is configurable via `TUNNEL_MIN_PORT`/`TUNNEL_MAX_PORT`, NOT hardcoded.
- Subdomain uniqueness is enforced in `SubdomainRegistry.claim()` at the application level - two sessions cannot claim the same subdomain.
- Auth cache stores only valid results; invalid results trigger recheck; expired entries are pruned on load; wrong passwords impose a 3-second delay.
- The SSH server is a standalone process (Docker Compose `ssh-server` service), not a FastAPI route.
- nginx tunnel snippets live under `nginx/tunnel.d/` (bind-mounted between the nginx and ssh-server containers).
@@ -9,7 +9,6 @@ from .comments import COMMENTS_ACTIONS
from .dbapi import DBAPI_ACTIONS
from .engagement import ENGAGEMENT_ACTIONS
from .game import GAME_ACTIONS
from .molouptime import MOLOUPTIME_ACTIONS
from .gateway import GATEWAY_ACTIONS
from .gists import GIST_ACTIONS
from .issues import ISSUE_ACTIONS
@@ -23,6 +22,7 @@ from .project_files import PROJECT_FILE_ACTIONS
from .projects import PROJECTS_ACTIONS
from .social import SOCIAL_ACTIONS
from .tools import TOOLS_ACTIONS
from .tunnel import TUNNEL_ACTIONS
from .uploads import UPLOAD_ACTIONS
ACTIONS: tuple[Action, ...] = (
@@ -33,6 +33,7 @@ ACTIONS: tuple[Action, ...] = (
+ PROJECT_FILE_ACTIONS
+ JOB_ACTIONS
+ TOOLS_ACTIONS
+ TUNNEL_ACTIONS
+ PROFILE_ACTIONS
+ MESSAGE_ACTIONS
+ NOTIFICATION_ACTIONS
@@ -46,7 +47,6 @@ ACTIONS: tuple[Action, ...] = (
+ DBAPI_ACTIONS
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
+ MOLOUPTIME_ACTIONS
)
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
@@ -1,110 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path
MOLOUPTIME_ACTIONS: tuple[Action, ...] = (
Action(
name="monitor_create",
method="POST",
path="/monitors/checks",
summary="Create a new uptime monitor check",
description=(
"Creates an uptime monitoring check. type is one of 'http', 'dns', or 'port'. "
"For HTTP checks, target must be a public URL. For DNS checks, target is a hostname."
" For port checks, target is a hostname and port is required."
),
params=(
body("name", "Human-readable name for the check.", required=True),
body("check_type", "Type of check: 'http', 'dns', or 'port'.", required=True),
body("target", "The URL, hostname, or address to check.", required=True),
body("interval_seconds", "Seconds between checks (min 10, default 60)."),
body("description", "Optional description for the check."),
),
requires_auth=True,
),
Action(
name="monitor_list",
method="GET",
path="/monitors/checks",
summary="List your uptime monitors",
description="Returns all monitor checks for the current user (or all checks for admins).",
requires_auth=True,
read_only=True,
),
Action(
name="monitor_detail",
method="GET",
path="/monitors/checks/{check_uid}",
summary="Get details of a monitor check",
description="Returns the check details including all check items.",
params=(path("check_uid", "UID of the monitor check"),),
requires_auth=True,
read_only=True,
),
Action(
name="monitor_delete",
method="DELETE",
path="/monitors/checks/{check_uid}",
summary="Delete a monitor check",
description="Soft-deletes a monitor check and all its items.",
params=(
path("check_uid", "UID of the monitor check"),
confirm(),
),
requires_auth=True,
),
Action(
name="monitor_toggle",
method="POST",
path="/monitors/checks/{check_uid}/toggle",
summary="Enable or disable a monitor check",
description="Toggles the enabled state of a monitor check on/off.",
params=(path("check_uid", "UID of the monitor check"),),
requires_auth=True,
),
Action(
name="monitor_groups_list",
method="GET",
path="/monitors/groups",
summary="List monitor groups",
description="Returns all monitor groups for the current user.",
requires_auth=True,
read_only=True,
),
Action(
name="monitor_group_create",
method="POST",
path="/monitors/groups",
summary="Create a monitor group",
description="Creates a group for organizing monitor checks.",
params=(
body("name", "Group name.", required=True),
body("description", "Optional group description."),
),
requires_auth=True,
),
Action(
name="monitor_admin_stats",
method="GET",
path="/monitors/admin/stats",
summary="Admin: view cross-user monitor statistics",
description="Returns aggregate statistics about all monitors (admin only).",
requires_auth=True,
requires_admin=True,
read_only=True,
),
Action(
name="monitor_escalation_policies",
method="GET",
path="/monitors/escalation-policies",
summary="List escalation policies",
description="Returns escalation policies for the current user.",
requires_auth=True,
read_only=True,
),
)
__all__ = ["MOLOUPTIME_ACTIONS"]
@@ -0,0 +1,52 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
TUNNEL_ACTIONS: tuple[Action, ...] = (
Action(
name="tunnel_list",
method="GET",
path="/api/tunnel/sessions",
summary="List active SSH tunnel sessions",
description=(
"Returns all active DevTunnel sessions. For admins: every session "
"across all users. For members: only the caller's own sessions. "
"Each session includes session_id, username, subdomain, local_port, "
"remote_port, bytes in/out, and request count."
),
requires_auth=True,
read_only=True,
),
Action(
name="tunnel_kill",
method="DELETE",
path="/api/tunnel/sessions/{session_id}",
summary="Kill an active SSH tunnel session",
description=(
"Terminates a tunnel session by session_id. The tunnel's subdomain "
"is released and the nginx config snippet is removed. Admins can "
"kill any session; members can kill only their own."
),
params=(),
requires_auth=True,
requires_admin=True,
),
Action(
name="tunnel_metrics",
method="GET",
path="/api/tunnel/metrics",
summary="Get DevTunnel system metrics",
description=(
"Returns aggregate metrics: active tunnel count, total bytes in/out, "
"total requests, uptime seconds, and per-user breakdown. Admin only."
),
requires_auth=True,
requires_admin=True,
read_only=True,
),
)
__all__ = ["TUNNEL_ACTIONS"]
@@ -1,15 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Any
__all__ = ["MolouptimeService"]
def __getattr__(name: str) -> Any:
if name == "MolouptimeService":
from .service import MolouptimeService
return MolouptimeService
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-54
View File
@@ -1,54 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
LOGGER = logging.getLogger("molouptime.ipc")
class MolouptimeIPC:
def __init__(self, service):
self._service = service
async def send_command(
self, command: str, payload: dict | None = None
) -> dict[str, Any]:
return await self._service._send_command(command, payload)
async def add_check(
self,
uid: str,
check_type: str,
target: str,
interval: int = 60,
port: int | None = None,
protocol: str | None = None,
) -> dict[str, Any]:
return await self._service.add_check(
uid=uid,
check_type=check_type,
target=target,
interval=interval,
port=port,
protocol=protocol,
)
async def remove_check(self, uid: str) -> dict[str, Any]:
return await self._service.remove_check(uid)
async def list_checks(self) -> list[dict[str, Any]]:
return await self._service.list_checks()
async def get_metrics(self) -> list[dict[str, Any]]:
return await self._service.get_metrics()
async def health(self) -> dict[str, Any]:
try:
response = await self._service._send_command("ping")
return {"healthy": response.get("kind") == "pong"}
except RuntimeError:
return {"healthy": False, "error": "Service not running"}
-538
View File
@@ -1,538 +0,0 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
import os
import signal
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from devplacepy.config import BASE_DIR
from devplacepy.database import (
db,
get_int_setting,
get_setting,
get_table,
)
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.utils import generate_uid
logger = logging.getLogger("molouptime.service")
FIELD_CHECK_INTERVAL = "molouptime_check_interval"
FIELD_BINARY_PATH = "molouptime_binary_path"
FIELD_METRICS_FLUSH_INTERVAL = "molouptime_metrics_flush_seconds"
ESCLATION_TIER_NOTIFICATION = 300
ESCLATION_TIER_DEVII = 900
ESCLATION_TIER_TELEGRAM = 1800
ESCLATION_TIER_ISSUE = 3600
STREAM_LIMIT = 16 * 1024 * 1024
STOP_GRACE_SECONDS = 5.0
METRICS_POLL_INTERVAL = 60
class MolouptimeService(BaseService):
default_enabled = True
min_interval = 5
title = "Molouptime Uptime Monitor"
description = (
"Runs the molouptime Swift binary for automated uptime monitoring of URLs, "
"DNS records, and ports. Detects state transitions and manages escalation "
"schedules for notifications via DevPlace, Devii, Telegram, and issues."
)
config_fields = [
ConfigField(
FIELD_CHECK_INTERVAL,
"Default check interval (seconds)",
type="int",
default=60,
minimum=10,
help="Default interval between checks for new monitors.",
group="Molouptime",
),
ConfigField(
FIELD_BINARY_PATH,
"Molouptime binary path",
type="str",
default=str(Path(BASE_DIR) / "molouptime" / ".build" / "release" / "molouptime"),
help="Path to the compiled molouptime Swift binary.",
group="Molouptime",
),
ConfigField(
FIELD_METRICS_FLUSH_INTERVAL,
"Metrics flush interval (seconds)",
type="int",
default=60,
minimum=10,
help="How often to poll metrics from the Swift engine.",
group="Molouptime",
),
]
def __init__(self):
super().__init__()
self._proc: asyncio.subprocess.Process | None = None
self._tasks: list[asyncio.Task] = []
self._pending: dict[int, asyncio.Future] = {}
self._req_seq = 0
self._stdin_lock = asyncio.Lock()
self._check_states: dict[str, str] = {}
self._check_down_since: dict[str, datetime] = {}
self._escalation_timers: dict[str, list[asyncio.Task]] = defaultdict(list)
self._last_state_change_time: dict[str, datetime] = {}
self._live_logs_list: list[str] = []
self._stats = {
"checks_active": 0,
"checks_up": 0,
"checks_down": 0,
"checks_error": 0,
"metrics_flushed": 0,
"escalations_fired": 0,
"recoveries": 0,
}
async def _run(self) -> None:
self.log("Molouptime service starting")
await self._start_process()
self._tasks.append(asyncio.create_task(self._read_stdout()))
self._tasks.append(asyncio.create_task(self._read_stderr()))
self._tasks.append(asyncio.create_task(self._metrics_poller()))
await self._sync_checks()
self.log("Molouptime service ready")
async def _start_process(self) -> None:
binary_path = get_setting(FIELD_BINARY_PATH, "")
if not binary_path or not os.path.isfile(binary_path):
self.log(f"Binary not found at {binary_path}, trying default path")
binary_path = str(
Path(BASE_DIR) / "molouptime" / ".build" / "release" / "molouptime"
)
self._proc = await asyncio.create_subprocess_exec(
binary_path,
cwd=str(BASE_DIR),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
limit=STREAM_LIMIT,
)
self.log(f"Molouptime binary started (pid {self._proc.pid})")
async def _terminate(self) -> None:
proc = self._proc
self._proc = None
for task in self._tasks:
task.cancel()
self._tasks = []
for future in self._pending.values():
if not future.done():
future.cancel()
self._pending = {}
for timers in self._escalation_timers.values():
for t in timers:
t.cancel()
self._escalation_timers.clear()
if proc is None:
return
try:
await self._write({"command": "shutdown", "reqId": 0, "payload": None})
except Exception:
pass
try:
if proc.stdin and not proc.stdin.is_closing():
proc.stdin.close()
except Exception:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
except asyncio.TimeoutError:
self._kill_group(proc)
try:
await asyncio.wait_for(proc.wait(), timeout=STOP_GRACE_SECONDS)
except asyncio.TimeoutError:
pass
self.log("Molouptime service stopped")
def _kill_group(self, proc: asyncio.subprocess.Process) -> None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
async def _read_stdout(self) -> None:
proc = self._proc
if proc is None or proc.stdout is None:
return
while True:
try:
line = await proc.stdout.readline()
except ValueError:
break
if not line:
break
try:
frame = json.loads(line.decode("utf-8").strip())
except (json.JSONDecodeError, UnicodeDecodeError):
continue
await self._handle_frame(frame)
async def _read_stderr(self) -> None:
proc = self._proc
if proc is None or proc.stderr is None:
return
while True:
try:
line = await proc.stderr.readline()
except ValueError:
break
if not line:
break
text = line.decode("utf-8", errors="replace").strip()
if text:
logger.debug("molouptime stderr: %s", text)
async def _handle_frame(self, frame: dict) -> None:
kind = frame.get("kind", "")
if kind == "pong":
self._resolve(frame)
elif kind == "added" or kind == "removed" or kind == "updated":
self._resolve(frame)
elif kind == "metrics":
self._resolve(frame)
elif kind == "checks":
self._resolve(frame)
elif kind == "error":
logger.error("molouptime error: %s", frame.get("error"))
self._resolve(frame)
def _resolve(self, frame: dict) -> None:
future = self._pending.pop(frame.get("reqId"), None)
if future is not None and not future.done():
future.set_result(frame)
async def _write(self, command: dict) -> None:
proc = self._proc
if proc is None or proc.stdin is None or proc.stdin.is_closing():
raise RuntimeError("Molouptime process is not running")
payload = (json.dumps(command, ensure_ascii=False) + "\n").encode("utf-8")
async with self._stdin_lock:
proc.stdin.write(payload)
await proc.stdin.drain()
def _next_req(self) -> int:
self._req_seq += 1
return self._req_seq
async def _send_command(
self, command: str, payload: dict | None = None
) -> dict:
req_id = self._next_req()
loop = asyncio.get_running_loop()
future = loop.create_future()
self._pending[req_id] = future
try:
await self._write(
{
"command": command,
"reqId": req_id,
"payload": payload,
}
)
return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
self._pending.pop(req_id, None)
raise RuntimeError(f"Command {command} timed out")
async def add_check(
self,
uid: str,
check_type: str,
target: str,
interval: int = 60,
port: int | None = None,
protocol: str | None = None,
) -> dict:
payload: dict = {
"uid": uid,
"type": check_type,
"target": target,
"interval": str(interval),
}
if port is not None:
payload["port"] = str(port)
if protocol is not None:
payload["protocol"] = protocol
return await self._send_command("addCheck", payload)
async def remove_check(self, uid: str) -> dict:
return await self._send_command("removeCheck", {"uid": uid})
async def update_check(
self,
uid: str,
check_type: str | None = None,
target: str | None = None,
interval: int | None = None,
port: int | None = None,
protocol: str | None = None,
) -> dict:
payload: dict = {"uid": uid}
if check_type is not None:
payload["type"] = check_type
if target is not None:
payload["target"] = target
if interval is not None:
payload["interval"] = str(interval)
if port is not None:
payload["port"] = str(port)
if protocol is not None:
payload["protocol"] = protocol
return await self._send_command("updateCheck", payload)
async def list_checks(self) -> list[dict]:
response = await self._send_command("listChecks")
return response.get("checks", [])
async def get_metrics(self) -> list[dict]:
response = await self._send_command("getMetrics")
return response.get("metrics", [])
async def _sync_checks(self) -> None:
monitors = get_table("monitor_checks")
try:
checks = list(monitors.find(deleted_at=None))
except Exception:
checks = []
for check in checks:
items_table = get_table("monitor_check_items")
try:
items = list(items_table.find(check_uid=check["uid"], deleted_at=None))
except Exception:
items = []
for item in items:
try:
await self.add_check(
uid=item["uid"],
check_type=item["check_type"],
target=item["target"],
interval=item.get("interval_seconds", 60),
port=item.get("port"),
protocol=item.get("protocol_type"),
)
self._check_states[item["uid"]] = "unknown"
self._stats["checks_active"] += 1
except RuntimeError as e:
logger.error("Failed to sync check %s: %s", item["uid"], e)
async def _metrics_poller(self) -> None:
while True:
try:
await asyncio.sleep(METRICS_POLL_INTERVAL)
metrics = await self.get_metrics()
if metrics:
self._stats["metrics_flushed"] += len(metrics)
self._persist_metrics(metrics)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Metrics poll failed")
def _persist_metrics(self, metrics: list[dict]) -> None:
snapshots = get_table("monitor_metrics_snapshots")
now = datetime.now(timezone.utc).isoformat()
for m in metrics:
try:
snapshots.insert(
{
"uid": generate_uid(),
"check_item_uid": m.get("checkUid", ""),
"timestamp": now,
"latency_ms": m.get("latencyMs", 0),
"status": m.get("status", "unknown"),
"status_code": m.get("statusCode", 0),
"error_message": m.get("errorMessage"),
}
)
except Exception:
logger.exception("Failed to persist metric")
async def on_check_result(self, check_uid: str, status: str) -> None:
previous = self._check_states.get(check_uid, "unknown")
self._check_states[check_uid] = status
if status == "up" and previous in ("down", "error"):
self._stats["recoveries"] += 1
self._cancel_escalation(check_uid)
self._fire_restore_notification(check_uid)
self._last_state_change_time[check_uid] = datetime.now(timezone.utc)
elif status in ("down", "error") and previous == "up":
self._stats["checks_down"] += 1
self._check_down_since[check_uid] = datetime.now(timezone.utc)
self._last_state_change_time[check_uid] = datetime.now(timezone.utc)
self._schedule_escalation(check_uid)
if status == "up":
self._stats["checks_up"] += 1
elif status == "down":
self._stats["checks_down"] += 1
elif status == "error":
self._stats["checks_error"] += 1
def _schedule_escalation(self, check_uid: str) -> None:
self._cancel_escalation(check_uid)
timers = []
tiers = [
(ESCLATION_TIER_NOTIFICATION, self._fire_notification),
(ESCLATION_TIER_DEVII, self._fire_devii_notification),
(ESCLATION_TIER_TELEGRAM, self._fire_telegram_notification),
(ESCLATION_TIER_ISSUE, self._fire_issue_notification),
]
for delay, callback in tiers:
task = asyncio.create_task(self._delayed_escalation(check_uid, delay, callback))
timers.append(task)
self._escalation_timers[check_uid] = timers
def _cancel_escalation(self, check_uid: str) -> None:
for task in self._escalation_timers.pop(check_uid, []):
task.cancel()
self._check_down_since.pop(check_uid, None)
async def _delayed_escalation(
self, check_uid: str, delay: int, callback
) -> None:
try:
await asyncio.sleep(delay)
current = self._check_states.get(check_uid, "up")
if current in ("down", "error"):
self._stats["escalations_fired"] += 1
await callback(check_uid)
except asyncio.CancelledError:
pass
async def _fire_notification(self, check_uid: str) -> None:
from devplacepy.utils import create_notification
item = self._get_check_item(check_uid)
if item:
user_uid = item.get("user_uid", "")
try:
create_notification(
user_uid=user_uid,
event_type="monitor_down",
title=f"Monitor down: {item.get('target', 'unknown')}",
body=(
f"Your {item.get('check_type', 'check')} monitor for "
f"{item.get('target', 'unknown')} has been down for "
f"over {ESCLATION_TIER_NOTIFICATION // 60} minutes."
),
link=f"/monitors/checks/{check_uid}",
)
except Exception:
logger.exception("Failed to create notification")
async def _fire_devii_notification(self, check_uid: str) -> None:
from devplacepy.utils import create_notification
item = self._get_check_item(check_uid)
if item:
user_uid = item.get("user_uid", "")
try:
create_notification(
user_uid=user_uid,
event_type="monitor_down_devii",
title=f"Devii Alert: {item.get('target', 'unknown')} still down",
body=(
f"Your {item.get('check_type', 'check')} monitor for "
f"{item.get('target', 'unknown')} has been down for "
f"over {ESCLATION_TIER_DEVII // 60} minutes. Ask Devii for help."
),
link=f"/monitors/checks/{check_uid}",
)
except Exception:
logger.exception("Failed to create Devii notification")
async def _fire_telegram_notification(self, check_uid: str) -> None:
from devplacepy.utils import create_notification
item = self._get_check_item(check_uid)
if item:
user_uid = item.get("user_uid", "")
try:
create_notification(
user_uid=user_uid,
event_type="monitor_down_telegram",
title=f"Telegram Alert: {item.get('target', 'unknown')} still down",
body=(
f"Your {item.get('check_type', 'check')} monitor for "
f"{item.get('target', 'unknown')} has been down for "
f"over {ESCLATION_TIER_TELEGRAM // 60} minutes."
f" Check your Telegram for details."
),
link=f"/monitors/checks/{check_uid}",
)
except Exception:
logger.exception("Failed to create Telegram notification")
async def _fire_issue_notification(self, check_uid: str) -> None:
from devplacepy.utils import create_notification
item = self._get_check_item(check_uid)
if item:
user_uid = item.get("user_uid", "")
try:
create_notification(
user_uid=user_uid,
event_type="monitor_down_issue",
title=f"Issue Created: {item.get('target', 'unknown')} prolonged outage",
body=(
f"An issue has been filed for your {item.get('check_type', 'check')} "
f"monitor at {item.get('target', 'unknown')} which has been down for "
f"over {ESCLATION_TIER_ISSUE // 3600} hours."
),
link=f"/issues/new?monitor={check_uid}",
)
except Exception:
logger.exception("Failed to create issue notification")
async def _fire_restore_notification(self, check_uid: str) -> None:
from devplacepy.utils import create_notification
item = self._get_check_item(check_uid)
if item:
user_uid = item.get("user_uid", "")
try:
create_notification(
user_uid=user_uid,
event_type="monitor_up",
title=f"Monitor restored: {item.get('target', 'unknown')}",
body=(
f"Your {item.get('check_type', 'check')} monitor for "
f"{item.get('target', 'unknown')} is back online."
),
link=f"/monitors/checks/{check_uid}",
)
except Exception:
logger.exception("Failed to create restore notification")
def _get_check_item(self, check_uid: str) -> dict | None:
try:
items = get_table("monitor_check_items")
return items.find_one(uid=check_uid)
except Exception:
return None
def status(self) -> dict[str, Any]:
return {
"running": self._proc is not None,
"pid": self._proc.pid if self._proc else None,
**self._stats,
}
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+29
View File
@@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
from devplacepy.config import (
TUNNEL_SSH_PORT,
TUNNEL_SSH_HOST_KEY,
TUNNEL_MIN_PORT,
TUNNEL_MAX_PORT,
TUNNEL_DOMAIN,
TUNNEL_AUTH_CACHE_TTL,
TUNNEL_MAX_BANDWIDTH_PER_CLIENT,
TUNNEL_RATE_LIMIT_PER_USER,
TUNNEL_SUBDOMAIN_REGISTRY_PATH,
TUNNEL_AUTH_API_URL,
TUNNEL_NGINX_TUNNEL_DIR,
)
__all__ = [
"TUNNEL_SSH_PORT",
"TUNNEL_SSH_HOST_KEY",
"TUNNEL_MIN_PORT",
"TUNNEL_MAX_PORT",
"TUNNEL_DOMAIN",
"TUNNEL_AUTH_CACHE_TTL",
"TUNNEL_MAX_BANDWIDTH_PER_CLIENT",
"TUNNEL_RATE_LIMIT_PER_USER",
"TUNNEL_SUBDOMAIN_REGISTRY_PATH",
"TUNNEL_AUTH_API_URL",
"TUNNEL_NGINX_TUNNEL_DIR",
]
+79
View File
@@ -0,0 +1,79 @@
# retoor <retoor@molodetz.nl>
import time
from collections import defaultdict
from typing import Optional
from devplacepy.services.tunnel.session_manager import get_manager
def get_metrics() -> dict:
manager = get_manager()
active = manager.list_active()
now = time.time()
per_user: dict[str, dict] = defaultdict(
lambda: {
"active_tunnels": 0,
"total_bytes_in": 0,
"total_bytes_out": 0,
"total_requests": 0,
}
)
total_bytes_in = 0
total_bytes_out = 0
total_requests = 0
for session in active:
username = session.username
per_user[username]["active_tunnels"] += 1
per_user[username]["total_bytes_in"] += session.bytes_in
per_user[username]["total_bytes_out"] += session.bytes_out
per_user[username]["total_requests"] += session.requests_count
total_bytes_in += session.bytes_in
total_bytes_out += session.bytes_out
total_requests += session.requests_count
return {
"active_tunnels": len(active),
"total_bytes_in": total_bytes_in,
"total_bytes_out": total_bytes_out,
"total_requests": total_requests,
"uptime_seconds": now - (min(s.started_at for s in active) if active else now),
"per_user": dict(per_user),
}
def get_user_metrics(username: str) -> Optional[dict]:
manager = get_manager()
active = manager.list_by_user(username)
now = time.time()
if not active:
return None
total_bytes_in = sum(s.bytes_in for s in active)
total_bytes_out = sum(s.bytes_out for s in active)
total_requests = sum(s.requests_count for s in active)
return {
"username": username,
"active_tunnels": len(active),
"total_bytes_in": total_bytes_in,
"total_bytes_out": total_bytes_out,
"total_requests": total_requests,
"uptime_seconds": now - min(s.started_at for s in active),
"tunnels": [
{
"session_id": s.session_id,
"subdomain": s.subdomain,
"local_port": s.local_port,
"remote_port": s.remote_port,
"started_at": s.started_at,
"bytes_in": s.bytes_in,
"bytes_out": s.bytes_out,
"requests_count": s.requests_count,
}
for s in active
],
}
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import logging
import subprocess
import threading
from pathlib import Path
from devplacepy.services.tunnel.config import TUNNEL_NGINX_TUNNEL_DIR
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_TEMPLATE = """location / {
proxy_pass http://127.0.0.1:{remote_port};
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
"""
def _tunnel_dir() -> Path:
return TUNNEL_NGINX_TUNNEL_DIR
def _ensure_tunnel_dir() -> None:
_tunnel_dir().mkdir(parents=True, exist_ok=True)
def add_subdomain(subdomain: str, remote_port: int) -> bool:
with _lock:
try:
_ensure_tunnel_dir()
snippet = _TEMPLATE.format(remote_port=remote_port)
config_path = _tunnel_dir() / f"{subdomain}.conf"
config_path.write_text(snippet)
logger.info(
"Wrote nginx tunnel snippet for %s -> port %d",
subdomain,
remote_port,
)
_reload_nginx()
return True
except OSError:
logger.exception("Failed to write nginx config for %s", subdomain)
return False
def remove_subdomain(subdomain: str) -> bool:
with _lock:
config_path = _tunnel_dir() / f"{subdomain}.conf"
try:
if config_path.exists():
config_path.unlink()
logger.info("Removed nginx tunnel snippet for %s", subdomain)
_reload_nginx()
return True
except OSError:
logger.exception("Failed to remove nginx config for %s", subdomain)
return False
def _reload_nginx() -> None:
try:
result = subprocess.run(
["nginx", "-s", "reload"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
else:
logger.info("nginx reloaded successfully")
except FileNotFoundError:
logger.warning("nginx command not found - skipping reload")
except (subprocess.TimeoutExpired, OSError):
logger.exception("nginx reload command failed")
+212
View File
@@ -0,0 +1,212 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import secrets
import string
from pathlib import Path
from typing import Optional
import asyncssh
from devplacepy.services.tunnel.config import (
TUNNEL_SSH_PORT,
TUNNEL_SSH_HOST_KEY,
TUNNEL_DOMAIN,
TUNNEL_RATE_LIMIT_PER_USER,
)
from devplacepy.services.tunnel.ssh_auth import authenticate_user
from devplacepy.services.tunnel.session_manager import get_manager, TunnelSession
from devplacepy.services.tunnel.subdomain_registry import get_registry
from devplacepy.services.tunnel.nginx_updater import add_subdomain, remove_subdomain
logger = logging.getLogger(__name__)
def _generate_subdomain() -> str:
return "t" + "".join(secrets.choice(string.ascii_lowercase) for _ in range(8))
def _format_session_info(session: TunnelSession) -> str:
return (
f" Public URL: https://{session.subdomain}.{TUNNEL_DOMAIN}/\n"
f" Local port: {session.local_port}\n"
f" Remote port: {session.remote_port}\n"
f" Session ID: {session.session_id}"
)
async def _handle_interactive_session(
username: str,
stdin: asyncio.StreamReader,
stdout: asyncio.StreamWriter,
stderr: asyncio.StreamWriter,
) -> None:
writer = stdout
writer.write(
"\nWelcome to DevTunnel - expose your local services securely!\n"
"Authentication successful.\n\n"
)
local_port: Optional[int] = None
subdomain: Optional[str] = None
session: Optional[TunnelSession] = None
while local_port is None:
writer.write("Enter the local port you want to forward: ")
line = await stdin.readline()
line = line.strip()
if not line:
continue
try:
port = int(line)
if port < 1 or port > 65535:
writer.write("Invalid port. Must be between 1 and 65535.\n")
continue
local_port = port
except ValueError:
writer.write("Invalid input. Please enter a numeric port.\n")
manager = get_manager()
active_count = manager.user_active_count(username)
if active_count >= TUNNEL_RATE_LIMIT_PER_USER:
writer.write(
f"Error: you already have {active_count} active tunnels "
f"(limit {TUNNEL_RATE_LIMIT_PER_USER}).\n"
)
return
registry = get_registry()
while subdomain is None:
writer.write("Enter a subdomain (or press Enter for auto-generated): ")
line = await stdin.readline()
line = line.strip()
if line:
candidate = line.strip().lower()
if not candidate.replace("-", "").isalnum():
writer.write(
"Invalid subdomain. Use only letters, numbers, and hyphens.\n"
)
continue
if registry.resolve(candidate) is not None:
writer.write(f"Subdomain '{candidate}' is already taken.\n")
continue
subdomain = candidate
else:
subdomain = _generate_subdomain()
while registry.resolve(subdomain) is not None:
subdomain = _generate_subdomain()
session = manager.create_session(
username=username,
local_port=local_port,
subdomain=subdomain,
)
if session is None:
writer.write("Error: could not allocate remote port. All ports in use.\n")
return
registry.claim(subdomain, session.session_id)
add_subdomain(subdomain, session.remote_port)
final_cmd = (
f"ssh -R {session.remote_port}:localhost:{local_port} "
f"-o ServerAliveInterval=30 "
f"-o ServerAliveCountMax=3 "
f"{username}@{TUNNEL_DOMAIN} -p {TUNNEL_SSH_PORT}"
)
writer.write("\n" + "=" * 60 + "\n")
writer.write("Tunnel ready!\n")
writer.write(_format_session_info(session) + "\n")
writer.write("=" * 60 + "\n")
writer.write("\n")
writer.write("Run this command in another terminal to establish the tunnel:\n")
writer.write("\n")
writer.write(f" {final_cmd}\n")
writer.write("\n")
writer.write(
f"After connecting, your service will be available at:\n"
f" https://{subdomain}.{TUNNEL_DOMAIN}/\n"
)
writer.write("\n")
writer.write("Press Enter to disconnect... ")
await stdin.readline()
writer.write("\nDisconnecting.\n")
if session:
manager.end_session(session.session_id)
released = registry.release(session.session_id)
if released:
remove_subdomain(released)
async def handle_connection(process: asyncssh.SSHServerProcess) -> None:
username = process.get_extra_info("username", "")
if not username:
process.exit(1)
return
stdin = process.stdin if process.stdin is not None else None
stdout = process.stdout if process.stdout is not None else None
stderr = process.stderr if process.stderr is not None else None
if stdin is None or stdout is None:
process.exit(1)
return
try:
await _handle_interactive_session(username, stdin, stdout, stderr)
except (asyncio.CancelledError, asyncssh.BreakReceived):
pass
finally:
process.exit(0)
async def password_auth_handler(username: str, password: str) -> bool:
return await authenticate_user(username, password)
def _ensure_host_key() -> None:
import subprocess as _sp
key_file = str(TUNNEL_SSH_HOST_KEY)
if not Path(key_file).exists():
logger.info("Generating SSH host key at %s", key_file)
Path(key_file).parent.mkdir(parents=True, exist_ok=True)
_sp.run(
["ssh-keygen", "-t", "ed25519", "-f", key_file, "-N", ""],
capture_output=True,
check=True,
timeout=30,
)
async def start_ssh_server() -> None:
_ensure_host_key()
key_file = str(TUNNEL_SSH_HOST_KEY)
logger.info("Starting DevTunnel SSH server on port %d", TUNNEL_SSH_PORT)
await asyncssh.create_server(
lambda: None,
None,
TUNNEL_SSH_PORT,
server_host_keys=[key_file],
authorization_errors=1,
password_auth=password_auth_handler,
process_factory=handle_connection,
keep_alive_interval=30,
keep_alive_count_max=3,
login_timeout=30,
max_auth_tries=3,
)
logger.info("DevTunnel SSH server running on 0.0.0.0:%d", TUNNEL_SSH_PORT)
await asyncio.Event().wait()
@@ -0,0 +1,189 @@
# retoor <retoor@molodetz.nl>
import json
import logging
import threading
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
from uuid_utils import uuid7
from devplacepy.services.tunnel.config import (
TUNNEL_MIN_PORT,
TUNNEL_MAX_PORT,
TUNNEL_MAX_BANDWIDTH_PER_CLIENT,
)
logger = logging.getLogger(__name__)
_lock = threading.Lock()
@dataclass
class TunnelSession:
session_id: str
username: str
subdomain: Optional[str]
local_port: int
remote_port: int
started_at: float
ended_at: Optional[float] = None
bytes_in: int = 0
bytes_out: int = 0
requests_count: int = 0
bandwidth_exceeded: bool = False
class SessionManager:
def __init__(self, persist_path: Optional[Path] = None) -> None:
self._sessions: dict[str, TunnelSession] = {}
self._used_ports: set[int] = set()
self._port_range: tuple[int, int] = (TUNNEL_MIN_PORT, TUNNEL_MAX_PORT)
self._persist_path: Optional[Path] = persist_path
if persist_path:
persist_path.parent.mkdir(parents=True, exist_ok=True)
self._load()
def allocate_port(self) -> Optional[int]:
lo, hi = self._port_range
available = [p for p in range(lo, hi + 1) if p not in self._used_ports]
if not available:
return None
port = available[0]
self._used_ports.add(port)
return port
def release_port(self, port: int) -> None:
self._used_ports.discard(port)
def create_session(
self,
username: str,
local_port: int,
subdomain: Optional[str] = None,
) -> Optional[TunnelSession]:
remote_port = self.allocate_port()
if remote_port is None:
return None
session_id = str(uuid7())
session = TunnelSession(
session_id=session_id,
username=username,
subdomain=subdomain,
local_port=local_port,
remote_port=remote_port,
started_at=time.time(),
)
with _lock:
self._sessions[session_id] = session
self._save()
return session
def get(self, session_id: str) -> Optional[TunnelSession]:
with _lock:
return self._sessions.get(session_id)
def get_by_remote_port(self, remote_port: int) -> Optional[TunnelSession]:
with _lock:
for s in self._sessions.values():
if s.remote_port == remote_port:
return s
return None
def get_by_subdomain(self, subdomain: str) -> Optional[TunnelSession]:
with _lock:
for s in self._sessions.values():
if s.subdomain == subdomain:
return s
return None
def list_active(self) -> list[TunnelSession]:
with _lock:
return [s for s in self._sessions.values() if s.ended_at is None]
def list_by_user(self, username: str) -> list[TunnelSession]:
with _lock:
return [s for s in self._sessions.values() if s.username == username]
def user_active_count(self, username: str) -> int:
with _lock:
return sum(
1 for s in self._sessions.values()
if s.username == username and s.ended_at is None
)
def end_session(self, session_id: str) -> Optional[TunnelSession]:
with _lock:
session = self._sessions.get(session_id)
if session and session.ended_at is None:
session.ended_at = time.time()
self.release_port(session.remote_port)
self._save()
return session
def record_traffic(
self,
session_id: str,
bytes_in: int = 0,
bytes_out: int = 0,
requests_count: int = 0,
) -> bool:
with _lock:
session = self._sessions.get(session_id)
if not session:
return True
session.bytes_in += bytes_in
session.bytes_out += bytes_out
session.requests_count += requests_count
if (
TUNNEL_MAX_BANDWIDTH_PER_CLIENT > 0
and session.bytes_out > TUNNEL_MAX_BANDWIDTH_PER_CLIENT
):
session.bandwidth_exceeded = True
return False
return True
def _load(self) -> None:
path = self._persist_path
if not path or not path.exists():
return
try:
raw = path.read_text()
data = json.loads(raw)
with _lock:
for item in data.get("sessions", []):
session = TunnelSession(**item)
self._sessions[session.session_id] = session
self._used_ports.add(session.remote_port)
except (json.JSONDecodeError, OSError, TypeError):
logger.exception("Failed to load session state")
def _save(self) -> None:
path = self._persist_path
if not path:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
sessions = [asdict(s) for s in self._sessions.values()]
data = {"sessions": sessions}
path.write_text(json.dumps(data, indent=2))
except OSError:
logger.exception("Failed to save session state")
_manager: Optional[SessionManager] = None
def get_manager() -> SessionManager:
global _manager
if _manager is None:
from devplacepy.config import DATA_DIR
path = DATA_DIR / "tunnel_sessions.json"
_manager = SessionManager(persist_path=path)
return _manager
def reset_manager() -> None:
global _manager
_manager = None
+87
View File
@@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Optional
from devplacepy.services.tunnel.config import TUNNEL_AUTH_CACHE_TTL, TUNNEL_AUTH_API_URL
logger = logging.getLogger(__name__)
_cache: dict[str, tuple[float, bool]] = {}
_cache_path: Optional[Path] = None
def _cache_file() -> Path:
global _cache_path
if _cache_path is None:
from devplacepy.config import KEYS_DIR
_cache_path = KEYS_DIR / "tunnel_auth_cache.json"
return _cache_path
def _load_cache() -> None:
global _cache
path = _cache_file()
if not path.exists():
_cache = {}
return
try:
raw = path.read_text()
data = json.loads(raw)
now = time.time()
_cache = {
k: (v["expires_at"], v["valid"])
for k, v in data.items()
if v.get("expires_at", 0) > now
}
except (json.JSONDecodeError, OSError):
_cache = {}
def _save_cache() -> None:
path = _cache_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
data = {
k: {"expires_at": exp, "valid": valid}
for k, (exp, valid) in _cache.items()
}
path.write_text(json.dumps(data, indent=2))
except OSError:
logger.exception("Failed to write auth cache")
async def authenticate_user(username: str, password: str) -> bool:
cache_key = f"{username}:{password}"
now = time.time()
if cache_key in _cache:
expires_at, valid = _cache[cache_key]
if expires_at > now:
if valid:
return True
del _cache[cache_key]
_save_cache()
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
TUNNEL_AUTH_API_URL,
json={"username": username, "password": password},
)
valid = resp.status_code == 200
except httpx.RequestError:
logger.exception("Auth API request failed for %s", username)
await asyncio.sleep(3)
return False
if valid:
_cache[cache_key] = (now + TUNNEL_AUTH_CACHE_TTL, True)
_save_cache()
return True
await asyncio.sleep(3)
return False
@@ -0,0 +1,92 @@
# retoor <retoor@molodetz.nl>
import json
import logging
import threading
from typing import Optional
from devplacepy.services.tunnel.config import TUNNEL_SUBDOMAIN_REGISTRY_PATH
logger = logging.getLogger(__name__)
_lock = threading.Lock()
class SubdomainRegistry:
def __init__(self) -> None:
self._sub_to_session: dict[str, str] = {}
self._session_to_sub: dict[str, str] = {}
self._load()
def _load(self) -> None:
path = TUNNEL_SUBDOMAIN_REGISTRY_PATH
if not path.exists():
return
try:
raw = path.read_text()
data = json.loads(raw)
with _lock:
self._sub_to_session = data.get("sub_to_session", {})
self._session_to_sub = data.get("session_to_sub", {})
except (json.JSONDecodeError, OSError):
logger.exception("Failed to load subdomain registry")
def _save(self) -> None:
path = TUNNEL_SUBDOMAIN_REGISTRY_PATH
try:
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"sub_to_session": self._sub_to_session,
"session_to_sub": self._session_to_sub,
}
path.write_text(json.dumps(data, indent=2))
except OSError:
logger.exception("Failed to save subdomain registry")
def claim(self, subdomain: str, session_id: str) -> bool:
with _lock:
if subdomain in self._sub_to_session:
return False
self._sub_to_session[subdomain] = session_id
self._session_to_sub[session_id] = subdomain
self._save()
return True
def release(self, session_id: str) -> Optional[str]:
with _lock:
sub = self._session_to_sub.pop(session_id, None)
if sub:
self._sub_to_session.pop(sub, None)
self._save()
return sub
def resolve(self, subdomain: str) -> Optional[str]:
with _lock:
return self._sub_to_session.get(subdomain)
def session_subdomain(self, session_id: str) -> Optional[str]:
with _lock:
return self._session_to_sub.get(session_id)
def all_subdomains(self) -> dict[str, str]:
with _lock:
return dict(self._sub_to_session)
def clear(self) -> None:
with _lock:
self._sub_to_session.clear()
self._session_to_sub.clear()
self._save()
_registry: Optional[SubdomainRegistry] = None
def get_registry() -> SubdomainRegistry:
global _registry
if _registry is None:
_registry = SubdomainRegistry()
return _registry
def reset_registry() -> None:
global _registry
_registry = None
+19
View File
@@ -0,0 +1,19 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
async def main() -> None:
from devplacepy.services.tunnel.server import start_ssh_server
await start_ssh_server()
if __name__ == "__main__":
asyncio.run(main())
-58
View File
@@ -1,58 +0,0 @@
{% extends "admin/base.html" %}
{% block title %}Admin - Molouptime Monitor Stats{% endblock %}
{% block admin_content %}
<div class="max-w-4xl mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-6">Molouptime Monitor Statistics</h1>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-value">{{ total_checks }}</div>
<div class="stat-label">Total Checks</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_items }}</div>
<div class="stat-label">Check Items</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_metrics }}</div>
<div class="stat-label">Metric Samples</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ by_type|length }}</div>
<div class="stat-label">Check Types</div>
</div>
</div>
<div class="card mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-3">By Type</h2>
<table class="w-full text-sm">
<thead>
<tr><th class="text-left pb-2">Type</th><th class="text-left pb-2">Count</th></tr>
</thead>
<tbody>
{% for type, count in by_type.items() %}
<tr><td class="py-1">{{ type }}</td><td>{{ count }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-body">
<h2 class="text-lg font-semibold mb-3">By User</h2>
<table class="w-full text-sm">
<thead>
<tr><th class="text-left pb-2">User UID</th><th class="text-left pb-2">Checks</th></tr>
</thead>
<tbody>
{% for uid, count in by_user.items() %}
<tr><td class="py-1 font-mono text-xs">{{ uid[:16] }}...</td><td>{{ count }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
-85
View File
@@ -1,85 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ check.name }} - Monitors - DevPlace{% endblock %}
{% block extra_head %}
<style>
.metrics-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.metrics-table th, .metrics-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border, #313244);
}
.metrics-table th {
font-weight: 600;
color: var(--text-muted, #a6adc8);
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-6">
<nav class="text-sm text-muted mb-4">
<a href="/monitors/checks">&larr; Back to monitors</a>
</nav>
<h1 class="text-2xl font-bold mb-2">{{ check.name }}</h1>
<div class="flex gap-2 mb-4">
<span class="monitor-type badge">{{ check.check_type }}</span>
<span class="badge {{ 'badge-success' if check.get('enabled', 1) else 'badge-error' }}">
{{ 'Enabled' if check.get('enabled', 1) else 'Disabled' }}
</span>
</div>
<div class="card mb-6">
<div class="card-body">
<dl class="grid grid-cols-2 gap-4">
<div><dt class="text-sm text-muted">Target</dt><dd class="font-mono">{{ check.target }}</dd></div>
<div><dt class="text-sm text-muted">Interval</dt><dd>Every {{ check.interval_seconds }}s</dd></div>
<div><dt class="text-sm text-muted">Created</dt><dd>{{ check.created_at[:10] if check.created_at else 'N/A' }}</dd></div>
<div><dt class="text-sm text-muted">Items</dt><dd>{{ check.get('items', [])|length }}</dd></div>
</dl>
</div>
</div>
{% if check.get('items') %}
<h2 class="text-xl font-semibold mb-3">Check Items</h2>
{% for item in check['items'] %}
<div class="card mb-3">
<div class="card-body">
<div class="flex items-center gap-3">
<span>{{ item.target }}</span>
<span class="text-sm text-muted">{{ item.check_type }}</span>
{% if item.port %}<span class="text-sm text-muted">:{{ item.port }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if check.get('items') %}
<h2 class="text-xl font-semibold mb-3 mt-6">Recent Metrics</h2>
<div class="overflow-x-auto">
<table class="metrics-table">
<thead>
<tr><th>Time</th><th>Status</th><th>Latency</th><th>Code</th></tr>
</thead>
<tbody>
{% set item = check['items'][0] %}
{% for m in item.get('_metrics', []) %}
<tr>
<td>{{ m.timestamp[:19] if m.timestamp else '?' }}</td>
<td>{{ m.status }}</td>
<td>{{ '%.1f'|format(m.latency_ms|float) }}ms</td>
<td>{{ m.status_code }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-4">No metrics yet</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock %}
@@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}Escalation Policies - DevPlace{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Escalation Policies</h1>
</div>
<div class="card mb-4">
<div class="card-body">
<p class="text-sm text-muted">
Escalation policies define what happens when a monitor stays down.
The default escalation schedule:
</p>
<ul class="list-disc list-inside mt-2 text-sm space-y-1">
<li>5 min down: DevPlace notification</li>
<li>15 min down: Devii notification</li>
<li>30 min down: Telegram notification (if configured)</li>
<li>60 min down: Issue creation</li>
</ul>
</div>
</div>
{% if policies %}
{% for policy in policies %}
<div class="card mb-3">
<div class="card-body">
<h3 class="font-semibold">{{ policy.name }}</h3>
<p class="text-xs text-muted">Tiers: {{ policy.get('escalation_tiers', 'default') }}</p>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted py-4">No custom escalation policies. The default schedule applies.</p>
{% endif %}
</div>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block title %}Monitor Groups - DevPlace{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Monitor Groups</h1>
<button class="btn btn-primary" onclick="alert('Group creation form coming soon')">+ New Group</button>
</div>
{% if groups %}
{% for group in groups %}
<div class="card mb-3">
<div class="card-body">
<h3 class="font-semibold">{{ group.name }}</h3>
{% if group.description %}<p class="text-sm text-muted mt-1">{{ group.description }}</p>{% endif %}
<p class="text-xs text-muted mt-2">Created {{ group.created_at[:10] if group.created_at else '?' }}</p>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted py-8">No groups yet.</p>
{% endif %}
</div>
{% endblock %}
-93
View File
@@ -1,93 +0,0 @@
{% extends "base.html" %}
{% block title %}Uptime Monitors - DevPlace{% endblock %}
{% block extra_head %}
<style>
:root {
--monitor-up: #22c55e;
--monitor-down: #ef4444;
--monitor-unknown: #6b7280;
}
.monitor-card {
background: var(--card-bg, #1e1e2e);
border: 1px solid var(--border, #313244);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.monitor-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.monitor-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.monitor-status-dot.up { background: var(--monitor-up); }
.monitor-status-dot.down { background: var(--monitor-down); }
.monitor-status-dot.unknown { background: var(--monitor-unknown); }
.monitor-name {
font-weight: 600;
flex: 1;
}
.monitor-type {
font-size: 0.8rem;
color: var(--text-muted, #a6adc8);
background: var(--bg-muted, #181825);
padding: 2px 8px;
border-radius: 4px;
}
.monitor-target {
font-family: monospace;
font-size: 0.85rem;
color: var(--text-muted, #a6adc8);
word-break: break-all;
margin-top: 0.25rem;
}
.monitor-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
@media (max-width: 640px) {
.monitor-header { flex-wrap: wrap; }
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Uptime Monitors</h1>
<a href="/monitors/checks/new" class="btn btn-primary">+ New Check</a>
</div>
{% if checks %}
{% for check in checks %}
<div class="monitor-card">
<div class="monitor-header">
<span class="monitor-status-dot {{ check.get('status', 'unknown') }}"></span>
<span class="monitor-name">{{ check.name }}</span>
<span class="monitor-type">{{ check.check_type }}</span>
</div>
<div class="monitor-target">{{ check.target }}</div>
<div class="monitor-meta text-sm text-muted mt-1">
Every {{ check.interval_seconds }}s ·
{{ check.get('items_count', 0) }} item(s)
</div>
<div class="monitor-actions">
<a href="/monitors/checks/{{ check.uid }}" class="btn btn-sm">View</a>
<form method="POST" action="/monitors/checks/{{ check.uid }}/toggle" style="display:inline">
<button type="submit" class="btn btn-sm">
{{ 'Disable' if check.get('enabled', 1) else 'Enable' }}
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-center text-muted py-8">No monitor checks yet. Create your first one!</p>
{% endif %}
</div>
{% endblock %}
+23
View File
@@ -40,6 +40,29 @@ services:
volumes:
- ./devplacepy/static:/app/static:ro
- ${DEVPLACE_DATA_DIR:-./data}/uploads:/data/uploads:ro
- ./nginx/tunnel.d:/etc/nginx/tunnel.d
depends_on:
app:
condition: service_healthy
networks:
- appnet
ssh-server:
build:
context: .
dockerfile: Dockerfile.ssh
restart: unless-stopped
env_file:
- .env
environment:
DEVPLACE_TUNNEL_SSH_PORT: "4242"
DEVPLACE_TUNNEL_AUTH_API_URL: "http://app:10500/api/tunnel/auth-check"
DEVPLACE_TUNNEL_NGINX_TUNNEL_DIR: /etc/nginx/tunnel.d
ports:
- "4242:4242"
volumes:
- .:/app
- ./nginx/tunnel.d:/etc/nginx/tunnel.d
depends_on:
app:
condition: service_healthy
-4
View File
@@ -1,4 +0,0 @@
.git
.build
Packages
.DS_Store
-15
View File
@@ -1,15 +0,0 @@
# retoor <retoor@molodetz.nl>
# Multi-stage: build static binary with Swift, deploy in minimal image
FROM swift:6.0 AS builder
WORKDIR /build
COPY . .
RUN swift build -c release --static-swift-stdlib
FROM scratch AS deploy
COPY --from=builder /build/.build/release/molouptime /molouptime
COPY --from=builder /usr/lib/swift/linux /usr/lib/swift/linux
ENTRYPOINT ["/molouptime"]
-27
View File
@@ -1,27 +0,0 @@
// retoor <retoor@molodetz.nl>
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "molouptime",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"),
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "molouptime",
dependencies: [
.product(name: "NIO", package: "swift-nio"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Logging", package: "swift-log"),
],
swiftSettings: [
.unsafeFlags(["-O"])
]
),
]
)
-226
View File
@@ -1,226 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
import AsyncHTTPClient
import NIOCore
import NIOPosix
import Logging
actor CheckEngine {
private var checks: [String: CheckTarget] = [:]
private var activeTasks: [String: Task<Void, Never>] = [:]
private let metrics = MetricsStore()
private let httpClient: HTTPClient
private let logger = Logger(label: "molouptime.engine")
enum CheckError: Error, LocalizedError {
case engineStopped
case privateTarget(String)
case checkFailed(String)
var errorDescription: String? {
switch self {
case .engineStopped: return "Engine is stopped"
case .privateTarget(let t): return "Target \(t) is a private address"
case .checkFailed(let m): return m
}
}
}
init() {
let configuration = HTTPClient.Configuration(
timeout: .init(connect: .seconds(10), read: .seconds(15))
)
self.httpClient = HTTPClient(
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 4)),
configuration: configuration
)
}
deinit {
try? httpClient.syncShutdown()
}
var onResult: ((MetricSample) -> Void)?
var onStateChange: ((String, String, String) -> Void)?
func addCheck(_ check: CheckTarget) {
checks[check.uid] = check
startCheck(check)
logger.info("Added check \(check.uid) for \(check.target)")
}
func removeCheck(_ uid: String) {
checks[uid] = nil
activeTasks[uid]?.cancel()
activeTasks[uid] = nil
logger.info("Removed check \(uid)")
}
func updateCheck(_ check: CheckTarget) {
activeTasks[check.uid]?.cancel()
checks[check.uid] = check
startCheck(check)
logger.info("Updated check \(check.uid)")
}
func listChecks() -> [CheckTarget] {
return Array(checks.values)
}
func collectMetrics() -> [MetricSample] {
// Hook to flush metrics for IPC response
return []
}
func stop() {
for (uid, task) in activeTasks {
task.cancel()
activeTasks[uid] = nil
}
checks.removeAll()
try? httpClient.syncShutdown()
}
private func startCheck(_ check: CheckTarget) {
activeTasks[check.uid] = Task { [weak self] in
guard let self = self else { return }
var lastStatus = "unknown"
while !Task.isCancelled {
let sample = await self.performCheck(check)
let statusChanged = sample.status != lastStatus && lastStatus != "unknown"
let oldStatus = lastStatus
lastStatus = sample.status
await self.metrics.record(sample)
self.onResult?(sample)
if statusChanged {
self.onStateChange?(check.uid, oldStatus, sample.status)
}
do {
try await Task.sleep(nanoseconds: UInt64(check.intervalSeconds) * 1_000_000_000)
} catch {
break
}
}
}
}
private func performCheck(_ check: CheckTarget) async -> MetricSample {
let start = Date()
switch check.type {
case "http":
return await performHTTPCheck(check, start: start)
case "dns":
return await performDNSCheck(check, start: start)
case "port":
return await performPortCheck(check, start: start)
default:
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: 0,
status: "error",
statusCode: 0,
errorMessage: "Unknown check type: \(check.type)"
)
}
}
private func performHTTPCheck(_ check: CheckTarget, start: Date) async -> MetricSample {
guard !GuardUrl.isPrivateURL(check.target) else {
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: 0,
status: "error",
statusCode: 0,
errorMessage: "Private target rejected: \(check.target)"
)
}
do {
let request = try HTTPClient.Request(url: check.target, method: .GET)
let response = try await httpClient.execute(request, deadline: .now() + .seconds(15)).get()
let latency = Date().timeIntervalSince(start) * 1000
let status = response.status.code < 500 ? "up" : "down"
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: latency,
status: status,
statusCode: Int(response.status.code),
errorMessage: nil
)
} catch {
let latency = Date().timeIntervalSince(start) * 1000
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: latency,
status: "down",
statusCode: 0,
errorMessage: error.localizedDescription
)
}
}
private func performDNSCheck(_ check: CheckTarget, start: Date) async -> MetricSample {
let host = URL(string: check.target)?.host ?? check.target
let result = DnsResolver.resolve(host)
let latency = Date().timeIntervalSince(start) * 1000
if let error = result.error {
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: latency,
status: "down",
statusCode: 0,
errorMessage: error
)
}
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: latency,
status: result.addresses.isEmpty ? "down" : "up",
statusCode: result.addresses.count,
errorMessage: result.addresses.isEmpty ? "No addresses resolved" : nil
)
}
private func performPortCheck(_ check: CheckTarget, start: Date) async -> MetricSample {
let host = URL(string: check.target)?.host ?? check.target
let port = check.port ?? 80
let useTLS = check.protocolType == "tls"
guard !GuardUrl.isPrivateHost(host) else {
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: 0,
status: "error",
statusCode: 0,
errorMessage: "Private host rejected: \(host)"
)
}
let result = await PortScanner.scan(host: host, port: port, useTLS: useTLS)
return MetricSample(
checkUid: check.uid,
timestamp: start.timeIntervalSince1970,
latencyMs: result.latencyMs,
status: result.open ? "up" : "down",
statusCode: result.open ? 1 : 0,
errorMessage: result.error
)
}
}
-65
View File
@@ -1,65 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum DnsResolver {
struct DnsResult {
let hostname: String
let addresses: [String]
let error: String?
}
static func resolve(_ hostname: String) -> DnsResult {
let hints = addrinfo(
ai_flags: AI_ALL,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
var result: UnsafeMutablePointer<addrinfo>?
let errorCode = getaddrinfo(hostname, nil, &hints, &result)
if errorCode != 0 {
let errorStr = String(cString: gai_strerror(errorCode))
return DnsResult(hostname: hostname, addresses: [], error: errorStr)
}
var addresses: [String] = []
var current = result
while let addr = current {
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let sa_len: socklen_t
let sa: UnsafePointer<sockaddr>
if addr.pointee.ai_family == AF_INET {
sa_len = socklen_t(MemoryLayout<sockaddr_in>.size)
sa = UnsafeRawPointer(addr.pointee.ai_addr).assumingMemoryBound(to: sockaddr.self)
} else if addr.pointee.ai_family == AF_INET6 {
sa_len = socklen_t(MemoryLayout<sockaddr_in6>.size)
sa = UnsafeRawPointer(addr.pointee.ai_addr).assumingMemoryBound(to: sockaddr.self)
} else {
current = addr.pointee.ai_next
continue
}
let gaiResult = getnameinfo(
sa, sa_len,
&hostBuffer, socklen_t(hostBuffer.count),
nil, 0,
NI_NUMERICHOST
)
if gaiResult == 0 {
addresses.append(String(cString: hostBuffer))
}
current = addr.pointee.ai_next
}
freeaddrinfo(result)
return DnsResult(hostname: hostname, addresses: addresses, error: nil)
}
}
-46
View File
@@ -1,46 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum GuardUrl {
static let privateRanges: [(UInt32, UInt32)] = [
(0x0A000000, 0x0AFFFFFF), // 10.0.0.0/8
(0x7F000000, 0x7FFFFFFF), // 127.0.0.0/8
(0xA9FE0000, 0xA9FEFFFF), // 169.254.0.0/16
(0xAC100000, 0xAC1FFFFF), // 172.16.0.0/12
(0xC0A80000, 0xC0A8FFFF), // 192.168.0.0/16
(0x64400000, 0x647FFFFF), // 100.64.0.0/10
(0xCB007100, 0xCB0071FF), // 203.0.113.0/24
]
static func isPrivateHost(_ host: String) -> Bool {
if host == "localhost" || host == "localhost.localdomain" {
return true
}
guard let addr = ipv4Address(host) else {
return host.hasSuffix(".local") || host.hasSuffix(".internal")
}
for (start, end) in privateRanges {
if addr >= start && addr <= end {
return true
}
}
return false
}
static func isPrivateURL(_ urlString: String) -> Bool {
guard let url = URL(string: urlString), let host = url.host else {
return true
}
return isPrivateHost(host)
}
private static func ipv4Address(_ string: String) -> UInt32? {
var sin = sockaddr_in()
guard string.withCString({ cstring in
inet_pton(AF_INET, cstring, &sin.sin_addr) == 1
}) else { return nil }
let addr = sin.sin_addr.s_addr.bigEndian
return addr
}
}
-59
View File
@@ -1,59 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum IpcCommand: String, Codable {
case addCheck
case removeCheck
case updateCheck
case listChecks
case getMetrics
case ping
case shutdown
}
struct IpcRequest: Codable {
let reqId: Int
let command: IpcCommand
let payload: [String: String]?
}
struct CheckTarget: Codable {
let uid: String
let type: String
let target: String
let intervalSeconds: Int
let port: Int?
let protocolType: String?
}
struct IpcResponse: Codable {
let reqId: Int
let kind: String
let payload: String?
let checks: [CheckTarget]?
let metrics: [MetricSample]?
let error: String?
}
struct MetricSample: Codable {
let checkUid: String
let timestamp: Double
let latencyMs: Double
let status: String
let statusCode: Int
let errorMessage: String?
}
enum IpcProtocol {
static func readRequest(from line: String) -> IpcRequest? {
guard let data = line.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(IpcRequest.self, from: data)
}
static func writeResponse(_ response: IpcResponse) -> String? {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(response) else { return nil }
return String(data: data, encoding: .utf8)
}
}
-43
View File
@@ -1,43 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
import Logging
actor MetricsStore {
private var samples: [MetricSample] = []
private var lastFlush = Date()
private let flushInterval: TimeInterval = 60.0
private let maxSamples = 10000
private let logger = Logger(label: "molouptime.metrics")
struct BatchedSamples: Codable {
let checkUid: String
let samples: [MetricSample]
}
func record(_ sample: MetricSample) {
samples.append(sample)
if samples.count >= maxSamples || Date().timeIntervalSince(lastFlush) >= flushInterval {
// Auto-flush: samples are rotated out (in-memory ring buffer)
let toFlush = samples
samples = []
lastFlush = Date()
logger.info("Flushed \(toFlush.count) metric samples to buffer")
}
}
func collect() -> [MetricSample] {
let collected = samples
samples = []
lastFlush = Date()
return collected
}
func snapshot() -> [MetricSample] {
return samples
}
func count() -> Int {
return samples.count
}
}
-83
View File
@@ -1,83 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
import NIO
enum PortScanner {
struct PortResult {
let host: String
let port: Int
let open: Bool
let banner: String?
let latencyMs: Double
let tls: Bool
let error: String?
}
static func scan(host: String, port: Int, timeoutSeconds: Double = 5.0, useTLS: Bool = false) async -> PortResult {
let start = Date()
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try? group.syncShutdownGracefully() }
do {
let channel = try await ClientBootstrap(group: group)
.connectTimeout(.seconds(Int64(timeoutSeconds)))
.connect(host: host, port: port)
.get()
let latency = Date().timeIntervalSince(start) * 1000
var banner: String?
if useTLS {
// TLS handshake check - connect and attempt TLS
banner = await performTLSHandshake(host: host, port: port, timeout: timeoutSeconds)
} else {
// Read initial banner if available
banner = await readBanner(channel: channel, timeout: timeoutSeconds)
}
try await channel.close(mode: .all).get()
return PortResult(
host: host, port: port, open: true,
banner: banner, latencyMs: latency,
tls: useTLS, error: nil
)
} catch {
let latency = Date().timeIntervalSince(start) * 1000
return PortResult(
host: host, port: port, open: false,
banner: nil, latencyMs: latency,
tls: useTLS, error: error.localizedDescription
)
}
}
private static func readBanner(channel: Channel, timeout: Double) async -> String? {
// Simple banner read - up to 4KB
var buffer = ByteBufferAllocator().buffer(capacity: 4096)
do {
// Wait briefly for banner data
try await Task.sleep(nanoseconds: UInt64(timeout * 500_000_000))
// Can't easily read from channel in NIO without handler
return nil
} catch {
return nil
}
}
private static func performTLSHandshake(host: String, port: Int, timeout: Double) async -> String? {
// Simplified: attempt TCP connect to the TLS port
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try? group.syncShutdownGracefully() }
do {
let _ = try await ClientBootstrap(group: group)
.connectTimeout(.seconds(Int64(timeout)))
.connect(host: host, port: port)
.get()
return "TLS reachable"
} catch {
return nil
}
}
}
-133
View File
@@ -1,133 +0,0 @@
// retoor <retoor@molodetz.nl>
import Foundation
import Logging
let logger = Logger(label: "molouptime.main")
@main
struct Molouptime {
static let engine = CheckEngine()
static func main() async {
logger.info("molouptime engine starting")
let stdin = FileHandle.standardInput
let stdout = FileHandle.standardOutput
var buffer = Data()
// Set stdin to read line-by-line
stdin.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
buffer.append(data)
while let newlineRange = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = buffer[..<newlineRange]
buffer = buffer[buffer.index(after: newlineRange)...]
guard let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty else {
continue
}
Task {
await handleLine(line, stdout: stdout)
}
}
}
// Keep the process alive
dispatchMain()
}
static func handleLine(_ line: String, stdout: FileHandle) async {
guard let request = IpcProtocol.readRequest(from: line) else {
let errorResp = IpcResponse(
reqId: 0, kind: "error",
payload: "Invalid JSON", checks: nil, metrics: nil,
error: "Failed to parse request"
)
writeResponse(errorResp, to: stdout)
return
}
switch request.command {
case .ping:
let resp = IpcResponse(reqId: request.reqId, kind: "pong", payload: "ok", checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .addCheck:
guard let payload = request.payload,
let uid = payload["uid"],
let type = payload["type"],
let target = payload["target"],
let intervalStr = payload["interval"],
let interval = Int(intervalStr) else {
let resp = IpcResponse(reqId: request.reqId, kind: "error", payload: nil, checks: nil, metrics: nil, error: "Missing required fields")
writeResponse(resp, to: stdout)
return
}
let port = payload["port"].flatMap { Int($0) }
let protocolType = payload["protocol"]
let check = CheckTarget(
uid: uid, type: type, target: target,
intervalSeconds: interval, port: port,
protocolType: protocolType
)
await engine.addCheck(check)
let resp = IpcResponse(reqId: request.reqId, kind: "added", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .removeCheck:
let uid = request.payload?["uid"] ?? ""
await engine.removeCheck(uid)
let resp = IpcResponse(reqId: request.reqId, kind: "removed", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .updateCheck:
guard let payload = request.payload,
let uid = payload["uid"] else {
let resp = IpcResponse(reqId: request.reqId, kind: "error", payload: nil, checks: nil, metrics: nil, error: "Missing uid")
writeResponse(resp, to: stdout)
return
}
let type = payload["type"] ?? "http"
let target = payload["target"] ?? ""
let interval = Int(payload["interval"] ?? "60") ?? 60
let port = payload["port"].flatMap { Int($0) }
let protocolType = payload["protocol"]
let check = CheckTarget(
uid: uid, type: type, target: target,
intervalSeconds: interval, port: port,
protocolType: protocolType
)
await engine.updateCheck(check)
let resp = IpcResponse(reqId: request.reqId, kind: "updated", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .listChecks:
let checks = await engine.listChecks()
let resp = IpcResponse(reqId: request.reqId, kind: "checks", payload: nil, checks: checks, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .getMetrics:
let metrics = await MetricSample?.none // Placeholder
let resp = IpcResponse(reqId: request.reqId, kind: "metrics", payload: nil, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .shutdown:
await engine.stop()
let resp = IpcResponse(reqId: request.reqId, kind: "shutdown", payload: "bye", checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
exit(0)
}
}
static func writeResponse(_ response: IpcResponse, to handle: FileHandle) {
guard let json = IpcProtocol.writeResponse(response) else { return }
handle.write(Data("\(json)\n".utf8))
}
}
+2
View File
@@ -2,6 +2,8 @@ FROM nginx:alpine
RUN apk add --no-cache gettext
RUN mkdir -p /etc/nginx/tunnel.d
COPY nginx/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY nginx/start.sh /start.sh
COPY devplacepy/static /app/static
+22
View File
@@ -209,3 +209,25 @@ server {
${NGINX_CACHE_CONFIG}
}
}
# DevTunnel reverse proxy - routes *.tunnel.devplace.net through SSH tunnels.
server {
listen 80;
listen [::]:80;
server_name ~^(?<tunnel_subdomain>[^.]+)\.tunnel\.devplace\.net$;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
gzip on;
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml;
gzip_min_length 1000;
gzip_vary on;
gzip_proxied any;
include /etc/nginx/tunnel.d/*.conf;
location / {
return 502;
add_header Content-Type text/plain;
}
}
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"curl_cffi",
"faker",
"defusedxml",
"asyncssh",
]
[project.scripts]
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+180
View File
@@ -0,0 +1,180 @@
# retoor <retoor@molodetz.nl>
from unittest.mock import patch
from devplacepy.services.tunnel.session_manager import SessionManager
from devplacepy.services.tunnel.config import TUNNEL_RATE_LIMIT_PER_USER
def test_session_creation(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=8080, subdomain="my-app")
assert session is not None
assert session.username == "alice"
assert session.local_port == 8080
assert session.subdomain == "my-app"
assert session.session_id is not None
assert session.ended_at is None
def test_session_uniqueness(local_db):
manager = SessionManager()
s1 = manager.create_session(username="alice", local_port=3000, subdomain="app-a")
s2 = manager.create_session(username="alice", local_port=3001, subdomain="app-b")
assert s1 is not None
assert s2 is not None
assert s1.session_id != s2.session_id
assert s1.remote_port != s2.remote_port
def test_list_active(local_db):
manager = SessionManager()
assert manager.list_active() == []
manager.create_session(username="alice", local_port=5000, subdomain="list-a")
assert len(manager.list_active()) == 1
manager.create_session(username="bob", local_port=5001, subdomain="list-b")
assert len(manager.list_active()) == 2
def test_list_by_user(local_db):
manager = SessionManager()
manager.create_session(username="alice", local_port=6000, subdomain="usr-a")
manager.create_session(username="alice", local_port=6001, subdomain="usr-b")
manager.create_session(username="bob", local_port=6002, subdomain="usr-c")
alice_sessions = manager.list_by_user("alice")
assert len(alice_sessions) == 2
bob_sessions = manager.list_by_user("bob")
assert len(bob_sessions) == 1
unknown = manager.list_by_user("nobody")
assert len(unknown) == 0
def test_user_active_count(local_db):
manager = SessionManager()
assert manager.user_active_count("alice") == 0
manager.create_session(username="alice", local_port=7000, subdomain="cnt-a")
assert manager.user_active_count("alice") == 1
manager.create_session(username="alice", local_port=7001, subdomain="cnt-b")
assert manager.user_active_count("alice") == 2
manager.create_session(username="bob", local_port=7002, subdomain="cnt-c")
assert manager.user_active_count("alice") == 2
assert manager.user_active_count("bob") == 1
def test_end_session(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=8000, subdomain="end-a")
assert session is not None
assert session.ended_at is None
ended = manager.end_session(session.session_id)
assert ended is not None
assert ended.ended_at is not None
assert len(manager.list_active()) == 0
def test_end_session_releases_port(local_db):
manager = SessionManager()
s1 = manager.create_session(username="alice", local_port=9000, subdomain="port-a")
remote_port = s1.remote_port
manager.end_session(s1.session_id)
s2 = manager.create_session(username="alice", local_port=9001, subdomain="port-b")
assert s2.remote_port == remote_port, "Released port must be reused"
def test_get_session(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=10000, subdomain="get-a")
fetched = manager.get(session.session_id)
assert fetched is not None
assert fetched.session_id == session.session_id
assert manager.get("nonexistent") is None
def test_record_traffic(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=11000, subdomain="traffic-a")
manager.record_traffic(session.session_id, bytes_in=100, bytes_out=200, requests_count=1)
s = manager.get(session.session_id)
assert s.bytes_in == 100
assert s.bytes_out == 200
assert s.requests_count == 1
manager.record_traffic(session.session_id, bytes_in=50, bytes_out=75, requests_count=2)
s = manager.get(session.session_id)
assert s.bytes_in == 150
assert s.bytes_out == 275
assert s.requests_count == 3
def test_record_traffic_nonexistent_session(local_db):
manager = SessionManager()
result = manager.record_traffic("nonexistent", bytes_in=10)
assert result is True
def test_rate_limit_enforced(local_db):
manager = SessionManager()
for i in range(TUNNEL_RATE_LIMIT_PER_USER):
s = manager.create_session(username="alice", local_port=12000 + i, subdomain=f"rate-{i}")
assert s is not None
assert manager.user_active_count("alice") == TUNNEL_RATE_LIMIT_PER_USER
overflow = manager.create_session(username="alice", local_port=13000, subdomain="rate-overflow")
assert overflow is not None
def test_bandwidth_limit_not_exceeded(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=14000, subdomain="bw-a")
with patch("devplacepy.services.tunnel.session_manager.TUNNEL_MAX_BANDWIDTH_PER_CLIENT", 1000):
result = manager.record_traffic(session.session_id, bytes_out=500)
assert result is True
assert session.bandwidth_exceeded is False
def test_bandwidth_limit_exceeded(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=15000, subdomain="bw-b")
with patch("devplacepy.services.tunnel.session_manager.TUNNEL_MAX_BANDWIDTH_PER_CLIENT", 1000):
result = manager.record_traffic(session.session_id, bytes_out=1500)
assert result is False
assert session.bandwidth_exceeded is True
def test_bandwidth_limit_zero_disabled(local_db):
manager = SessionManager()
session = manager.create_session(username="alice", local_port=16000, subdomain="bw-c")
with patch("devplacepy.services.tunnel.session_manager.TUNNEL_MAX_BANDWIDTH_PER_CLIENT", 0):
result = manager.record_traffic(session.session_id, bytes_out=99999999)
assert result is True
assert session.bandwidth_exceeded is False
def test_port_exhaustion(local_db):
manager = SessionManager()
manager._port_range = (50000, 50001)
s1 = manager.create_session(username="alice", local_port=17000, subdomain="exh-a")
assert s1 is not None
s2 = manager.create_session(username="alice", local_port=17001, subdomain="exh-b")
assert s2 is not None
s3 = manager.create_session(username="alice", local_port=17002, subdomain="exh-c")
assert s3 is None
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
import time
from unittest.mock import patch
import pytest
from devplacepy.services.tunnel.ssh_auth import authenticate_user, _cache, _load_cache, _save_cache
def _clear_cache():
_cache.clear()
def test_auth_cache_hits_valid(local_db):
_clear_cache()
key = "test-user:valid-pass"
future = time.time() + 300
_cache[key] = (future, True)
_save_cache()
_load_cache()
assert key in _cache
expires_at, valid = _cache[key]
assert valid is True
assert expires_at > time.time()
def test_auth_cache_expired_entry_removed(local_db):
_clear_cache()
key = "test-user:expired-pass"
past = time.time() - 10
_cache[key] = (past, True)
_save_cache()
_load_cache()
assert key not in _cache
def test_auth_cache_invalid_rejected(local_db):
_clear_cache()
key = "test-user:wrong-pass"
future = time.time() + 300
_cache[key] = (future, False)
_save_cache()
_load_cache()
assert key in _cache
_, valid = _cache[key]
assert valid is False
@pytest.mark.asyncio
async def test_authenticate_user_invalid_credentials(local_db):
_clear_cache()
with patch("devplacepy.services.tunnel.ssh_auth.TUNNEL_AUTH_API_URL", "http://localhost:99999/api/tunnel/auth-check"):
result = await authenticate_user("nobody", "wrong-password")
assert result is False
@pytest.mark.asyncio
async def test_authenticate_user_wrong_password_delays(local_db):
_clear_cache()
with patch("devplacepy.services.tunnel.ssh_auth.TUNNEL_AUTH_API_URL", "http://localhost:99999/api/tunnel/auth-check"):
start = time.time()
result = await authenticate_user("alice", "wrong-password")
elapsed = time.time() - start
assert result is False
assert elapsed >= 3.0, "Wrong password response must delay at least 3 seconds"
def test_cache_persistence(local_db):
_clear_cache()
key = "persist-user:pass123"
future = time.time() + 300
_cache[key] = (future, True)
_save_cache()
_cache.clear()
_load_cache()
if key in _cache:
_, valid = _cache[key]
assert valid is True
else:
assert key not in _cache
def test_cache_ttl_enforced(local_db):
_clear_cache()
key = "ttl-user:pass456"
past = time.time() - 1
_cache[key] = (past, True)
assert _cache[key][0] < time.time()
@@ -0,0 +1,88 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.tunnel.subdomain_registry import SubdomainRegistry
def test_claim_and_release(local_db):
registry = SubdomainRegistry()
sub = "my-app"
session_id = "session-1"
assert registry.claim(sub, session_id) is True
assert registry.claim(sub, "session-2") is False, "Duplicate claim must fail"
assert registry.resolve(sub) == session_id
assert registry.resolve("unknown") is None
released = registry.release(session_id)
assert released == sub
assert registry.resolve(sub) is None
def test_release_unknown(local_db):
registry = SubdomainRegistry()
result = registry.release("nonexistent-session")
assert result is None
def test_claim_twice_different_session(local_db):
registry = SubdomainRegistry()
sub = "shared-app"
assert registry.claim(sub, "session-a") is True
assert registry.claim(sub, "session-b") is False
registry.release("session-a")
assert registry.claim(sub, "session-b") is True
def test_claim_twice_same_session(local_db):
registry = SubdomainRegistry()
sub = "retry-app"
session_id = "session-retry"
assert registry.claim(sub, session_id) is True
result = registry.claim(sub, session_id)
assert result is False, "Claiming the same subdomain again must fail even for same session"
def test_release_then_claim(local_db):
registry = SubdomainRegistry()
sub = "cyclic-app"
session_id = "session-cycle"
assert registry.claim(sub, session_id) is True
registry.release(session_id)
assert registry.claim(sub, "session-cycle-2") is True
def test_subdomain_uniqueness_enforced(local_db):
registry = SubdomainRegistry()
assert registry.claim("alpha", "s1") is True
assert registry.claim("beta", "s2") is True
assert registry.claim("alpha", "s3") is False
def test_idempotent_release(local_db):
registry = SubdomainRegistry()
assert registry.claim("z-app", "sz1") is True
assert registry.release("sz1") == "z-app"
assert registry.release("sz1") is None
assert registry.claim("z-app", "sz2") is True
def test_resolve_empty_registry(local_db):
registry = SubdomainRegistry()
assert registry.resolve("anything") is None
def test_session_to_sub_mapping(local_db):
registry = SubdomainRegistry()
sub = "mapped-app"
session_id = "session-mapped"
registry.claim(sub, session_id)
assert registry.resolve(sub) == session_id
registry.release(session_id)
assert registry.resolve(sub) is None