#!/usr/bin/env python3
# The serial console daemon. The page connects with its signed console token
# (Socket.IO auth); each accepted connection gets its own console worker
# (conf/daemon/consolecallback) in a pty, which opens the VM's console with the
# compute's credentials. This process never opens libvirt. Connections are
# refused while SERIAL_CONSOLE_ENABLED is off.
import argparse
import codecs
import fcntl
import logging
import os
import pty
import select
import signal
import struct
import sys
import termios
import threading
import tty

import django

ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
if ROOT_PATH not in sys.path:
    sys.path.append(ROOT_PATH)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webvirtcloud.settings")
django.setup()

import socketio  # noqa: E402
from gunicorn.app.base import BaseApplication  # noqa: E402
from django import db  # noqa: E402
from django.conf import settings  # noqa: E402
from django.http import HttpRequest  # noqa: E402
from django.http.cookie import parse_cookie  # noqa: E402
from django.utils import translation  # noqa: E402

from console import refusals  # noqa: E402
from console.tokens import authorize, session_user_id  # noqa: E402

CONSOLECALLBACK = os.path.join(ROOT_PATH, "conf", "daemon", "consolecallback")
LOG_FD = os.dup(2)  # this daemon's stderr: a worker logs there on its fd 3
MAX_SESSIONS = 20

logger = logging.getLogger("socketiod")
sio = socketio.Server(async_mode="threading")  # the same origin only
# Handlers run on their own threads. sessions (sid -> {"pid", "fd", "vm",
# "lock", "open"}) changes only under sessions_lock; a session's fd is used
# and closed only under its own lock, while it is open, so a closed fd's
# number, reused by a new session, never reaches another console. The fd
# is non-blocking: no one waits on it while holding the lock (a paste and
# the guest's echo of it would wait on each other).
sessions = {}
sessions_lock = threading.Lock()


def refused(environ, reason):
    """The refusal the page shows, in its language (LocaleMiddleware's choice:
    the language cookie, else the browser's); it is logged too, in English."""
    logger.info("serial console refused: %s", reason)
    request = HttpRequest()
    request.COOKIES, request.META = parse_cookie(environ.get("HTTP_COOKIE", "")), environ
    with translation.override(translation.get_language_from_request(request)):
        return socketio.exceptions.ConnectionRefusedError(translation.gettext(reason))


@sio.event
def connect(sid, environ, auth):
    # the page shows the reason
    if not settings.SERIAL_CONSOLE_ENABLED:
        raise refused(environ, refusals.TURNED_OFF)
    try:
        instance, user = authorize((auth or {}).get("token"), "pty")
    except PermissionError:
        raise refused(environ, refusals.NOT_ALLOWED)
    finally:
        db.close_old_connections()
    taken = None
    with sessions_lock:
        open_sid = next((s for s, session in sessions.items() if session["vm"] == instance.pk), None)
        if open_sid is not None and not user.is_superuser:
            raise refused(environ, refusals.OPEN_ELSEWHERE)
        if open_sid is None and len(sessions) >= MAX_SESSIONS:
            raise refused(environ, refusals.TOO_MANY)
        if open_sid is not None:  # a superuser takes over a console left open
            taken = open_sid, sessions.pop(open_sid)
            # the page tells its user who took the console; first, as its
            # forward ends the connection as soon as the worker is gone
            sio.emit("taken_over", {"by": user.username}, to=open_sid)
            # gone before the new worker starts: libvirt gives a console to one at a time
            end(taken[1], wait=True)
        pid, fd = pty.fork()
        if pid == 0:
            try:
                # pty.fork's master is inheritable: without this each worker would
                # hold the other sessions' consoles open
                os.dup2(LOG_FD, 3)
                os.closerange(4, os.sysconf("SC_OPEN_MAX"))
                os.environ["CONSOLECALLBACK_LOG_FD"] = "3"
                os.execv(sys.executable, [sys.executable, CONSOLECALLBACK, str(instance.pk), str(user.pk)])
            finally:
                os._exit(1)
        os.set_blocking(fd, False)
        # raw at once, not when the worker gets to it: until then the terminal
        # keeps at most one 4 KiB line of a paste
        tty.setraw(fd)
        sessions[sid] = session = {"pid": pid, "fd": fd, "vm": instance.pk, "lock": threading.Lock(), "open": True}
    logger.info("serial console of VM %s opened by %s%s", instance.name, user.username,
                " (taken over)" if taken else "")
    if taken:
        sio.disconnect(taken[0])
    sio.start_background_task(forward, sid, session)


def forward(sid, session):
    """The worker's output to its client, until the worker ends."""
    decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")  # a character may be split
    while session["open"]:
        try:
            # a fd closed meanwhile may wake this up: the session is closed then
            ready = select.select([session["fd"]], [], [], 0.1)[0]
        except (OSError, ValueError):
            ready = True
        if not ready:
            continue
        with session["lock"]:
            if not session["open"]:
                return
            try:
                output = os.read(session["fd"], 20480)
            except BlockingIOError:  # nothing after all
                continue
            except OSError:  # EIO: the worker ended
                output = b""
        if not output:
            sio.emit("pty_output", {"output": decoder.decode(b"", final=True) + "\r\n[console closed]\r\n"}, to=sid)
            stop(sid)
            sio.disconnect(sid)
            return
        sio.emit("pty_output", {"output": decoder.decode(output)}, to=sid)


def stop(sid):
    """End the sid's worker; once, whoever calls it first."""
    with sessions_lock:
        session = sessions.pop(sid, None)
    if session is not None:
        end(session)


def end(session, wait=False):
    """End a session taken out of sessions; with wait, once its worker is gone."""
    try:
        os.kill(session["pid"], signal.SIGKILL)  # first: a write blocked on its terminal ends
    except ProcessLookupError:
        pass
    with session["lock"]:
        session["open"] = False
        try:
            os.close(session["fd"])
        except OSError:
            pass
    if wait:
        try:
            os.waitpid(session["pid"], 0)
        except ChildProcessError:  # reaped already
            pass
    else:
        sio.start_background_task(os.waitpid, session["pid"], 0)


@sio.event
def pty_input(sid, message):
    session = sessions.get(sid)
    if session and isinstance(message, dict) and isinstance(message.get("input"), str):
        data = memoryview(message["input"].encode(errors="replace"))  # a lone surrogate from the browser
        while data:
            with session["lock"]:
                if not session["open"]:
                    return
                try:
                    data = data[os.write(session["fd"], data):]
                    continue
                except BlockingIOError:  # the worker has not read the rest yet
                    pass
                except OSError:
                    return
            try:
                select.select([], [session["fd"]], [], 0.1)  # outside the lock: output goes on meanwhile
            except (OSError, ValueError):  # closed meanwhile: the loop sees it
                pass


@sio.event
def resize(sid, message):
    session = sessions.get(sid)
    if not (session and isinstance(message, dict)):
        return
    rows, cols = message.get("rows"), message.get("cols")
    if all(isinstance(v, int) and 1 <= v <= 500 for v in (rows, cols)):
        with session["lock"]:
            if session["open"]:
                try:
                    fcntl.ioctl(session["fd"], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
                except OSError:  # the worker has just ended
                    pass


@sio.event
def disconnect(sid, reason=None):
    stop(sid)


def signed_in_only(app):
    """Engine.IO holds a connection, and one of the threads, from its first
    request; Socket.IO's connect() checks the token only after that. A request
    without a signed-in session is refused before it takes a thread: without
    this, anyone could hold every thread and no console would open."""
    def wsgi(environ, start_response):
        try:
            signed_in = session_user_id(environ.get("HTTP_COOKIE")) is not None
        finally:
            db.close_old_connections()
        if not signed_in:
            start_response("403 Forbidden", [("Content-Type", "text/plain")])
            return [b"Sign in to open a serial console\n"]
        return app(environ, start_response)
    return wsgi


class Server(BaseApplication):
    """gunicorn with one worker (the sessions live in it); a thread per connection."""

    def __init__(self, host, port):
        self.bind = "%s:%d" % (host, port)
        super().__init__()

    def load_config(self):
        self.cfg.set("bind", self.bind)
        self.cfg.set("worker_class", "gthread")
        self.cfg.set("threads", 2 * MAX_SESSIONS)
        self.cfg.set("graceful_timeout", 1)  # open consoles never finish; their workers end with the pty
        self.cfg.set("control_socket_disable", True)

    def load(self):
        return signed_in_only(socketio.WSGIApp(sio))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="WebVirtCloud serial console daemon")
    parser.add_argument("-H", "--host", default=settings.SOCKETIO_HOST, help="Listen host")
    parser.add_argument("-p", "--port", type=int, default=settings.SOCKETIO_PORT, help="Listen port")
    options = parser.parse_args()
    # to stderr (the journal, docker logs), not the panel's own log file:
    # each process rotating that file would lose lines
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s", force=True)
    Server(options.host, options.port).run()
