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