#!/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 os
import pty
import select
import signal
import struct
import sys
import termios

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 console.tokens import authorize  # noqa: E402

CONSOLECALLBACK = os.path.join(ROOT_PATH, "conf", "daemon", "consolecallback")
MAX_SESSIONS = 20

sio = socketio.Server(async_mode="threading")  # the same origin only
sessions = {}  # sid -> {"pid", "fd", "vm"}


@sio.event
def connect(sid, environ, auth):
    if not settings.SERIAL_CONSOLE_ENABLED:
        return False
    try:
        instance = authorize((auth or {}).get("token"))
    except PermissionError:
        return False
    finally:
        db.close_old_connections()
    if len(sessions) >= MAX_SESSIONS or any(s["vm"] == instance.pk for s in sessions.values()):
        return False
    pid, fd = pty.fork()
    if pid == 0:
        try:
            os.execv(sys.executable, [sys.executable, CONSOLECALLBACK, str(instance.pk)])
        finally:
            os._exit(1)
    sessions[sid] = session = {"pid": pid, "fd": fd, "vm": instance.pk}
    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 sessions.get(sid) is session:
        if not select.select([session["fd"]], [], [], 0.1)[0]:
            continue
        try:
            output = os.read(session["fd"], 20480)
        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."""
    session = sessions.pop(sid, None)
    if session is None:
        return
    try:
        os.close(session["fd"])
    except OSError:
        pass
    try:
        os.kill(session["pid"], signal.SIGKILL)
    except ProcessLookupError:
        pass
    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):
        try:
            os.write(session["fd"], message["input"].encode())
        except OSError:
            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)):
        fcntl.ioctl(session["fd"], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))


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


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 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()
    Server(options.host, options.port).run()
