# 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()