├── .dockerignore ├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── basehandler.py ├── kvm_handler.py ├── login_handler.py ├── login_mixin.py ├── main.py ├── requirements.txt └── templates ├── index.tpl ├── index_instant.tpl └── not_authorized.tpl /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .git 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - deploy 3 | 4 | deploy-to-github: 5 | stage: deploy 6 | image: iffregistry.fz-juelich.de/docker-images/gr-build-images/deploy 7 | variables: 8 | GIT_STRATEGY: none 9 | only: 10 | - master@Scientific-IT-Systems/administration/nojava-ipmi-kvm-server 11 | - develop@Scientific-IT-Systems/administration/nojava-ipmi-kvm-server 12 | - tags@Scientific-IT-Systems/administration/nojava-ipmi-kvm-server 13 | script: 14 | - mkdir --mode=700 ~/.ssh/ 15 | - (umask 0377 && echo "${GITHUB_DEPLOY_KEY}" > ~/.ssh/id_rsa 16 | && echo "github.com ${GITHUB_HOST_KEY}" >> ~/.ssh/known_hosts) 17 | - git clone --mirror "${CI_REPOSITORY_URL}" "${CI_PROJECT_NAME}_mirror" 18 | - cd "${CI_PROJECT_NAME}_mirror"; 19 | git push --mirror "git@github.com:sciapp/${CI_PROJECT_NAME}.git"; 20 | cd .. 21 | 22 | deploy-to-dockerhub: 23 | image: docker:latest 24 | stage: deploy 25 | script: 26 | - docker login -u "${DOCKERHUB_USER}" -p "${DOCKERHUB_SECRET}" 27 | - docker build -t "${DOCKERHUB_NAMESPACE}/${CI_PROJECT_NAME}:${CI_COMMIT_TAG}" . 28 | - docker push "${DOCKERHUB_NAMESPACE}/${CI_PROJECT_NAME}:${CI_COMMIT_TAG}" 29 | - docker tag "${DOCKERHUB_NAMESPACE}/${CI_PROJECT_NAME}:${CI_COMMIT_TAG}" "${DOCKERHUB_NAMESPACE}/${CI_PROJECT_NAME}:latest" 30 | - docker push "${DOCKERHUB_NAMESPACE}/${CI_PROJECT_NAME}:latest" 31 | only: 32 | - tags@Scientific-IT-Systems/administration/nojava-ipmi-kvm-server 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-buster 2 | 3 | RUN apt-get update && apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common && \ 4 | curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - && \ 5 | add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" && \ 6 | apt-get update && apt-get install -y docker-ce-cli 7 | 8 | ADD requirements.txt / 9 | RUN pip3 install -r /requirements.txt && mkdir /code 10 | 11 | ADD . /code/ 12 | 13 | WORKDIR /code/ 14 | EXPOSE 5000 15 | CMD ["python3", "/code/main.py"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Forschungszentrum Jülich GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NoJava-IPMI-KVM-Server 2 | 3 | ## Introduction 4 | 5 | NoJava-IPMI-KVM-Server is a python-based server designed to centrally provide instances of [sciapp/nojava-ipmi-kvm](https://github.com/sciapp/nojava-ipmi-kvm) via the browser. This means that no installation of either Java or nojava-ipmi-kvm is required on the administrator's machine. 6 | A typical usage might be: 7 | 1. Optional login using oauth, optionally secured by ldap group membership 8 | 2. Selection of desired target machine, resolution and prompt for required kvm password 9 | 3. Start of a docker container using nojava-ipmi-kvm to provide console access via novnc's web interface/the kvm's html5 interface 10 | 4. The docker container is stopped and deleted after the administrator closes the tab/window. 11 | 12 | ## Installation 13 | 14 | NoJava-IPMI-KVM-Server can either be installed using Git or installed as a docker container. 15 | 16 | ### Standalone using git: 17 | - Install Python3 and pip 18 | - [Install Docker](https://www.docker.com/) on the server if not done already. 19 | - Ensure the user that shall run nojava-ipmi-kvm-server has permissions to access docker (usually being in the `docker` group suffices) 20 | - Clone the repository, f.e. `git clone https://github.com/sciapp/nojava-ipmi-kvm-server.git` into a directory of your choice 21 | - Install the dependencies, f.e. `pip install -r requirements.txt` 22 | - Write a configuration file for nojava-ipmi-kvm (in [this](https://github.com/sciapp/nojava-ipmi-kvm/blob/master/README.md#configuration-file) format) and place it somewhere accessible 23 | - Start the service using `python3 main.py` with the required env variables set. 24 | 25 | 26 | ### As a container: 27 | - Write a configuration file for nojava-ipmi-kvm (in [this](https://github.com/sciapp/nojava-ipmi-kvm/blob/master/README.md#configuration-file) format) and place it somewhere accessible 28 | - Start a docker container based on the `sciapp/nojava-ipmi-kvm-server` image with the required env variables set and the configuration file mounted into the container. Make sure to mount the docker socket into the container. 29 | 30 | Example: 31 | 32 | ```bash 33 | docker run -d \ 34 | --restart always \ 35 | --name nojava-ipmi-kvm-server \ 36 | --env-file ~/nojava-ipmi-kvm.envfile \ 37 | -v /var/run/docker.sock:/var/run/docker.sock \ 38 | -v ~/nojava-ipmi-kvmrc.yaml:/nojava-ipmi-kvmrc.yaml \ 39 | sciapp/nojava-ipmi-kvm-server:latest 40 | ``` 41 | 42 | ## Configuration 43 | 44 | ### Authentication 45 | Three authentication options are available: 46 | 1. No authentication. This requires that no `OAUTH_*` or `LDAP_*` option is set 47 | 2. OAUTH-only authentication. This requires that all `OAUTH_*` options are set while no `LDAP_*` option is set 48 | 3. OAUTH authentication + LDAP authorization. This requires that all `OAUTH_*` and all `LDAP_*` (except `LDAP_FALLBACK_SERVER`) options are set. This requires that signed in users are in a specific LDAP group. 49 | 50 | LDAP-Identification is performed using the email. 51 | 52 | ### Environment variables 53 | | Name | Description | Example | 54 | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | 55 | | `LOGLEVEL` | Log level to use (one of `DEBUG`, `INFO`, `WARNING` (default), `ERROR`, `CRITICAL`) | `INFO` | 56 | | `KVM_CONFIG_PATH` | Absolute path to a configuration file for nojava-ipmi-kvm | `/data/nojava-ipmi-kvmrc.yaml` | 57 | | `WEBAPP_BASE` | The final address the service is published | `https://nojava-ipmi-kvm.corporate.local` | 58 | | `WEBAPP_PORT` | The port to listen on | `8080` | 59 | | `EXTERNAL_WEB_DNS` | Custom address for checking availability and passed to `IFRAME_PATH_FORMAT`. Should be set to the external address of the docker host | `nojava-ipmi-kvm.corporate.local` | 60 | | `WEB_PORT_START` | The first port to be allocated to kvm containers | `8800` | 61 | | `WEB_PORT_END` | The first port outside the range | `8900` | 62 | | `JAVA_IFRAME_PATH_FORMAT` | This format specifies the iframe url used for java kvm hosts, useful if you have a reverse proxy | (see section [IFRAME_PATH_FORMAT](#IFRAME_PATH_FORMAT)) | 63 | | `HTML5_IFRAME_PATH_FORMAT` | This format specifies the iframe url used for html5 kvm hosts, useful if you have a reverse proxy | (see section [IFRAME_PATH_FORMAT](#IFRAME_PATH_FORMAT)) | 64 | | `HTML5_AUTHORIZATION` | Authorization cookie required to access html5 consoles. `generate`: auto-generated, `use_server`: Uses the `is_admin` cookie set by server, `[key]:[value]`: Manual | `kvm_authorization:abcdefgh` | 65 | | `HTML5_SUBDIR_FORMAT` | Format to generate `{subdirectoy}` value replaced in `rewrites` html5 host config; Parameters: `external_web_dns`, `port`, `hostname` | `/{port}` | 66 | | `OAUTH_HOST` | Base URL of the oauth provider | `https://oauth-provider` | 67 | | `OAUTH_CLIENT_ID` | Client ID of nojava-ipmi-kvm-server-app in oauth provider | `abcdef...` | 68 | | `OAUTH_CLIENT_SECRET` | Client secret to fit `OAUTH_CLIENT_ID` | `abcdef...` | 69 | | `LDAP_DEFAULT_SERVER` | If set, verifies the account authenticated using oauth is in a specific group | `ldap.corporate.local` | 70 | | `LDAP_FALLBACK_SERVER` | If set, defines a fallback server if the default server is undefined | `ldap-fallback.corporate.local` | 71 | | `LDAP_BASE_DN` | Base DN to search users in | `cn=users,dc=....` | 72 | | `LDAP_USER_DN` | The account used to login at the ldap server | `cn=ipmi-kvm,dc=...` | 73 | | `LDAP_PASSWORD` | The password for `LDAP_USER_DN` account | `abcdef...` | 74 | | `LDAP_GROUP_DN` | FQDN of group to search in the `memberOf` property of the authenticated user | `cn=kvm-access,dc=...` | 75 | 76 | ### IFRAME_PATH_FORMAT 77 | You can use the following placeholders within the format string: 78 | | Placeholder | Description | 79 | | ----------------------- | ------------------------------------------------------------------------------------------- | 80 | | `{url}` | Autogenerated url generated using `EXTERNAL_WEB_DNS` and the selected web port | 81 | | `{external_web_dns}` | The value of `EXTERNAL_WEB_DNS` | 82 | | `{port}` | The port selected for the started container | 83 | | Java-only Options: | | 84 | | `{password}` | Generated VNC password | 85 | | HTML5-only Options: | | 86 | | `{subdir}` | The subdirectory formatted using `HTML5_SUBDIR_FORMAT` | 87 | | `{authorization_key}` | The cookie-key used to authorize the user, might be unset/None if authorization is disabled | 88 | | `{authorization_value}` | The cookie-value used to authorize the user. | 89 | | `{html5_endpoint}` | HTML5-Endpoint configured in `.nojava-ipmi-kvm.yaml` | 90 | 91 | Some examples: 92 | - `{url}`: No special network configuration, `EXTERNAL_WEB_DNS` is set to the external address of the docker host 93 | - `https://outside-address/{port}/vnc.html?host={outside-address}&autoconnect=true&password={password}&path={port}/websockify`: Java Example using a reverse proxy proxying WebVNC Ports 94 | - `https://{external_web_dns}/{subdir}{html5_endpoint}`: HTML5 Example using a reverse proxy 95 | -------------------------------------------------------------------------------- /basehandler.py: -------------------------------------------------------------------------------- 1 | from tornado.web import RequestHandler 2 | from tornado.websocket import WebSocketHandler 3 | 4 | 5 | def authorized(func): 6 | def wrapper(self, *args): 7 | user = self.get_current_user() 8 | if user is None or not user["is_admin"]: 9 | return self.render("not_authorized.tpl", user=user) 10 | return func(self, *args) 11 | 12 | return wrapper 13 | 14 | 15 | class UserObject: 16 | def get_current_user(self): 17 | email = self.get_secure_cookie("user") 18 | name = self.get_secure_cookie("user_name") 19 | is_admin = self.get_secure_cookie("is_admin") 20 | 21 | if email is None or name is None or is_admin is None or not is_admin: 22 | return None 23 | ret = { 24 | "email": email.decode("utf-8"), 25 | "name": name.decode("utf-8"), 26 | "is_admin": is_admin.decode("utf-8") == "on", 27 | } 28 | return ret 29 | 30 | 31 | class BaseHandler(UserObject, RequestHandler): 32 | pass 33 | 34 | 35 | class BaseWSHandler(UserObject, WebSocketHandler): 36 | pass 37 | 38 | 39 | __all__ = ["authorized", "BaseHandler", "BaseWSHandler"] 40 | -------------------------------------------------------------------------------- /kvm_handler.py: -------------------------------------------------------------------------------- 1 | import json, logging, os 2 | 3 | try: 4 | from typing import List 5 | except ImportError: 6 | pass 7 | 8 | from nojava_ipmi_kvm.kvm import ( 9 | start_kvm_container, 10 | WebserverNotReachableError, 11 | DockerNotInstalledError, 12 | DockerNotCallableError, 13 | DockerPortNotReadableError, 14 | DockerTerminatedError, 15 | HTML5KvmViewer, 16 | JavaKvmViewer, 17 | ) 18 | from nojava_ipmi_kvm.config import config, HTML5HostConfig 19 | from nojava_ipmi_kvm import utils 20 | 21 | from basehandler import BaseWSHandler 22 | 23 | WEB_PORT_START = int(os.environ.get("WEB_PORT_START", 8800)) 24 | WEB_PORT_END = int(os.environ.get("WEB_PORT_END", 8900)) 25 | external_web_dns = os.environ.get("EXTERNAL_WEB_DNS", "localhost") 26 | HTML5_AUTHORIZATION = os.environ.get("HTML5_AUTHORIZATION", "disabled") 27 | JAVA_IFRAME_PATH_FORMAT = os.environ.get("JAVA_IFRAME_PATH_FORMAT", "{url}") 28 | HTML5_IFRAME_PATH_FORMAT = os.environ.get("HTML5_IFRAME_PATH_FORMAT", "{url}") 29 | HTML5_SUBDIR_FORMAT = os.environ.get("HTML5_SUBDIR_FORMAT", "") 30 | 31 | logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) 32 | 33 | used_ports: List[int] = [] 34 | 35 | 36 | class KVMHandler(BaseWSHandler): 37 | def open(self): 38 | self._current_session = None 39 | self._web_port = 0 40 | self._current_user = self.get_current_user() 41 | self._connecting = False 42 | self._is_closed = False 43 | 44 | if self._current_user is None or not self._current_user["is_admin"]: 45 | return self.close(code=401, reason="Unauthorized") 46 | 47 | logging.info("Websocket opened by %s", self._current_user["name"]) 48 | 49 | async def on_message(self, msg): 50 | logging.info("Websocket from %s said %s", self._current_user["name"], msg) 51 | 52 | try: 53 | msg = json.loads(msg) 54 | except json.decoder.JSONDecodeError: 55 | return self.write_message({"action": "notice", "message": "Invalid json received"}) 56 | 57 | if "action" in msg: 58 | if msg["action"] == "connect": 59 | if self._connecting: 60 | return self.write_message({"action": "notice", "message": "Already connected to a kvm!"}) 61 | self._connecting = True 62 | 63 | server = msg["server"] 64 | password = msg["password"] 65 | resolution = msg["resolution"] if "resolution" in msg else None 66 | logging.info("%s wants to connect to %s with res %s", self._current_user["name"], server, resolution) 67 | 68 | if server not in config.get_servers(): 69 | return self.write_message( 70 | {"action": "notice", "message": "The specified hostname is not valid.", "refresh": True} 71 | ) 72 | host_config = config[server] 73 | 74 | web_port = 1 75 | for p in range(WEB_PORT_START, WEB_PORT_END): 76 | if p not in used_ports: 77 | self._web_port = p 78 | web_port = p 79 | used_ports.append(p) 80 | break 81 | else: 82 | return self.write_message( 83 | { 84 | "action": "notice", 85 | "message": "No unused port available. Please notify admins.", 86 | "refresh": True, 87 | } 88 | ) 89 | 90 | def send_log_message(msg, *args, **kwargs): 91 | if self._is_closed: 92 | return 93 | self.write_message({"action": "log", "message": msg if len(args) == 0 else msg % args}) 94 | 95 | try: 96 | authorization_key = None 97 | authorization_value = None 98 | if isinstance(host_config, HTML5HostConfig): 99 | if HTML5_AUTHORIZATION == "generate": 100 | authorization_key = "kvm_auth_" + self._web_port 101 | authorization_value = utils.generate_temp_password(20) 102 | elif HTML5_AUTHORIZATION == "use_server": 103 | authorization_key = "is_admin" 104 | authorization_value = self.get_cookie("is_admin") 105 | elif ":" in HTML5_AUTHORIZATION: 106 | authorization_key = HTML5_AUTHORIZATION.split(":")[0] 107 | authorization_value = HTML5_AUTHORIZATION.split(":", 1)[1] 108 | 109 | sess = self._current_session = await start_kvm_container( 110 | host_config=host_config, 111 | login_password=password, 112 | external_vnc_dns=external_web_dns, 113 | docker_port=self._web_port, 114 | additional_logging=send_log_message, 115 | selected_resolution=resolution, 116 | authorization_key=authorization_key, 117 | authorization_value=authorization_value, 118 | subdir=HTML5_SUBDIR_FORMAT.format( 119 | external_web_dns=external_web_dns, port=self._web_port, hostname=host_config.full_hostname 120 | ), 121 | ) 122 | except ( 123 | WebserverNotReachableError, 124 | DockerNotInstalledError, 125 | DockerNotCallableError, 126 | IOError, 127 | DockerTerminatedError, 128 | DockerPortNotReadableError, 129 | ) as ex: 130 | logging.exception("Could not start KVM container") 131 | used_ports.remove(web_port) 132 | return self.write_message({"action": "error", "message": str(ex)}) 133 | 134 | if isinstance(sess, HTML5KvmViewer): 135 | return self.write_message( 136 | { 137 | "action": "connected", 138 | "url": HTML5_IFRAME_PATH_FORMAT.format( 139 | url=sess.url, 140 | external_web_dns=external_web_dns, 141 | port=sess.web_port, 142 | subdir=sess.subdir, 143 | authorization_key=sess.authorization_key, 144 | authorization_value=sess.authorization_value, 145 | html5_endpoint=sess.html5_endpoint, 146 | ), 147 | "authorization_key": sess.authorization_key, 148 | "authorization_value": sess.authorization_value, 149 | } 150 | ) 151 | else: 152 | return self.write_message( 153 | { 154 | "action": "connected", 155 | "url": JAVA_IFRAME_PATH_FORMAT.format( 156 | url=sess.url, 157 | external_web_dns=external_web_dns, 158 | port=sess.web_port, 159 | password=sess.vnc_password, 160 | ), 161 | } 162 | ) 163 | 164 | self.write_message( 165 | {"action": "notice", "message": "Invalid msg received", "source": msg, "user": self.get_current_user()} 166 | ) 167 | 168 | def on_close(self): 169 | logging.info("WS from %s closed", None if self._current_user is None else self._current_user["name"]) 170 | self._is_closed = True 171 | if self._current_session is not None: 172 | used_ports.remove(self._web_port) 173 | self._current_session.kill_process() 174 | 175 | 176 | __all__ = ["KVMHandler"] 177 | -------------------------------------------------------------------------------- /login_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import ldap3 4 | from login_mixin import OAuth2LoginMixin 5 | from basehandler import BaseHandler 6 | 7 | class OAuth2LoginHandler(BaseHandler, OAuth2LoginMixin): 8 | """ 9 | Handler for authorization for oauth servers 10 | """ 11 | 12 | def __init__(self, *args, **kwargs): 13 | BaseHandler.__init__(self, *args, **kwargs) 14 | OAuth2LoginMixin.__init__(self, *args, oauth_host=os.environ.get('OAUTH_HOST', ''), **kwargs) 15 | self.OAUTH_CLIENT_ID = os.environ.get('OAUTH_CLIENT_ID', None) 16 | self.OAUTH_CLIENT_SECRET = os.environ.get('OAUTH_CLIENT_SECRET', None) 17 | self.OAUTH_REDIRECT_URI = "{}/oauth/login".format(os.environ['WEBAPP_BASE']) 18 | 19 | 20 | async def get(self): 21 | """ 22 | Cheks wether the user is logged in or not and handles authentication 23 | """ 24 | if not self.OAUTH_CLIENT_ID: 25 | self.set_secure_cookie("user", "anonymous-email") 26 | self.set_secure_cookie("user_name", "anonymous") 27 | self.set_secure_cookie("is_admin", "on") 28 | return self.redirect("/dashboard") 29 | 30 | if self.get_argument("code", ""): 31 | user_data = await self.get_authenticated_user(self.get_argument("code", "")) 32 | # Use user_data to find the user in your user database or use their data directly 33 | email = user_data["email"] 34 | uid = user_data["username"] 35 | self.set_secure_cookie("user", email) 36 | self.set_secure_cookie("user_name", uid) 37 | 38 | # Skip LDAP authorization checking if not defined -> all users are allowed 39 | if "LDAP_DEFAULT_SERVER" not in os.environ: 40 | self.set_secure_cookie("is_admin", "on") 41 | else: 42 | # Connect to ldap to check access of group 43 | try: 44 | if 'LDAP_FALLBACK_SERVER' in os.environ: 45 | pool = [ 46 | ldap3.Server(os.environ['LDAP_DEFAULT_SERVER'], use_ssl=True, get_info=ldap3.ALL), 47 | ldap3.Server(os.environ['LDAP_FALLBACK_SERVER'], use_ssl=True, get_info=ldap3.ALL) 48 | ] 49 | else: 50 | pool = ldap3.Server(os.environ['LDAP_DEFAULT_SERVER'], use_ssl=True, get_info=ldap3.ALL) 51 | base_dn = os.environ['LDAP_BASE_DN'] 52 | user_dn = os.environ['LDAP_USER_DN'] 53 | password = os.environ['LDAP_PASSWORD'] 54 | connection = ldap3.Connection(pool, user=user_dn, password=password, auto_bind=True) 55 | reader = ldap3.Reader(connection, ldap3.ObjectDef('inetUser', connection), base_dn, 56 | '(&(objectClass=inetOrgPerson)(mail={email})(uid={uid}))'.format(email=email, uid=uid)) 57 | user_infos = reader.search() 58 | self.set_secure_cookie("is_admin", "off") 59 | if user_infos: 60 | for user in user_infos: 61 | if 'memberOf' in user: 62 | if os.environ['LDAP_GROUP_DN'] in user['memberOf']: 63 | self.set_secure_cookie("is_admin", "on") 64 | break 65 | else: 66 | print("Error") # TODO Error handling 67 | except Exception as e: 68 | print(e) 69 | return self.redirect("/dashboard") 70 | if self.get_argument("error", ""): 71 | self.write(self.get_argument("error", "")) 72 | else: 73 | return self.authorize_redirect( 74 | redirect_uri=self.OAUTH_REDIRECT_URI, 75 | client_id=self.OAUTH_CLIENT_ID, 76 | client_secret=self.OAUTH_CLIENT_SECRET, 77 | scope=["api"], 78 | response_type="code", 79 | ) 80 | -------------------------------------------------------------------------------- /login_mixin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from typing import Dict, Any 4 | import tornado 5 | import tornado.auth 6 | import tornado.escape 7 | import tornado.httputil 8 | import tornado.httpclient 9 | import tornado.ioloop 10 | import tornado.web 11 | import urllib 12 | try: 13 | import certifi 14 | ca_certs = certifi.where() 15 | except: 16 | ca_certs = None 17 | 18 | class OAuth2LoginMixin(tornado.auth.OAuth2Mixin): 19 | """ 20 | OAuth2Mixin for use with oauth authentication providers. 21 | Subclasses need to define the following variables: 22 | - OAUTH_CLIENT_ID 23 | - OAUTH_CLIENT_SECRET 24 | - OAUTH_REDIRECT_URI 25 | """ 26 | 27 | def __init__(self, *args, oauth_host="", **kwargs): 28 | super(OAuth2LoginMixin, self).__init__() 29 | self._OAUTH_AUTHORIZE_URL = oauth_host + "/oauth/authorize" 30 | self._OAUTH_ACCESS_TOKEN_URL = oauth_host + "/oauth/token" 31 | self._OAUTH_USER_INFO_URL = oauth_host + "/api/me" 32 | 33 | 34 | async def get_authenticated_user(self, code: str) -> Dict[str, Any]: 35 | """ 36 | 37 | Handles the login for the oauth user, returning the user info. 38 | """ 39 | http_client = self.get_auth_http_client() 40 | # get the access token for this code 41 | response = await http_client.fetch( 42 | self._OAUTH_ACCESS_TOKEN_URL, 43 | method="POST", 44 | headers={"Accept": "application/json"}, 45 | validate_cert=True, 46 | body=urllib.parse.urlencode([ 47 | ('client_id', self.OAUTH_CLIENT_ID), 48 | ('client_secret', self.OAUTH_CLIENT_SECRET), 49 | ('redirect_uri', self.OAUTH_REDIRECT_URI), 50 | ('grant_type', "authorization_code"), 51 | ('code', code) 52 | ]) 53 | ) 54 | 55 | response_data = json.loads(response.body.decode('utf8', 'replace')) 56 | access_token = response_data['access_token'] 57 | # get the user information for this access token 58 | request = tornado.httpclient.HTTPRequest( 59 | self._OAUTH_USER_INFO_URL, 60 | method="GET", 61 | validate_cert=True, 62 | headers={ 63 | "Accept": "application/json", 64 | "User-Agent": "OAuth2LoginHandler", 65 | "Authorization": "Bearer {}".format(access_token) 66 | }, 67 | ca_certs=ca_certs 68 | ) 69 | 70 | response = await http_client.fetch(request) 71 | response_data = json.loads(response.body.decode('utf8', 'replace')) 72 | return response_data 73 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | __version__ = "0.2.2" 4 | __author__ = "M. Heuwes " 5 | 6 | import os 7 | import json 8 | import logging 9 | 10 | from tornado.web import authenticated 11 | from tornado import web, ioloop 12 | 13 | from nojava_ipmi_kvm.config import config, DEFAULT_CONFIG_FILEPATH 14 | from nojava_ipmi_kvm import utils 15 | 16 | from login_handler import OAuth2LoginHandler 17 | from basehandler import BaseHandler, authorized 18 | from kvm_handler import KVMHandler 19 | 20 | WEBAPP_PORT = int(os.environ["WEBAPP_PORT"]) 21 | WEBAPP_BASE = os.environ["WEBAPP_BASE"] 22 | CONFIG_PATH = os.environ.get("KVM_CONFIG_PATH", DEFAULT_CONFIG_FILEPATH) 23 | 24 | logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) 25 | 26 | config.read_config(CONFIG_PATH) 27 | 28 | 29 | class MainHandler(BaseHandler): 30 | @authenticated 31 | @authorized 32 | def get(self): 33 | self.render( 34 | "index.tpl", 35 | title="Remote KVM", 36 | user=self.get_current_user(), 37 | servers=config.get_servers(), 38 | base_uri=WEBAPP_BASE, 39 | websocket_uri="ws" + WEBAPP_BASE[4:], 40 | ) 41 | 42 | @authenticated 43 | @authorized 44 | def post(self): 45 | self.render( 46 | "index_instant.tpl", 47 | title="Remote KVM", 48 | user=self.get_current_user(), 49 | servers=config.get_servers(), 50 | base_uri=WEBAPP_BASE, 51 | websocket_uri="ws" + WEBAPP_BASE[4:], 52 | server_name=json.dumps(self.get_body_argument("server_name")), 53 | password=json.dumps(self.get_body_argument("password")), 54 | resolution=json.dumps(self.get_body_argument("resolution")), 55 | ) 56 | 57 | 58 | def make_app(): 59 | """ 60 | returns a tornado.web.Application 61 | """ 62 | settings = { 63 | "template_path": "templates", 64 | "static_path": "static", 65 | "debug": True, 66 | "cookie_secret": utils.generate_temp_password(32), 67 | "login_url": "/oauth/login", 68 | "xsrf_cookies": True, 69 | "default_handler_class": MainHandler, 70 | "websocket_ping_interval": 10, 71 | } 72 | return web.Application( 73 | [web.url(r"/oauth/login", OAuth2LoginHandler), web.url(r"/", MainHandler), web.url(r"/kvm", KVMHandler)], 74 | **settings 75 | ) 76 | 77 | 78 | if __name__ == "__main__": 79 | APP = make_app() 80 | APP.listen(WEBAPP_PORT) 81 | print("Started app on port {}".format(WEBAPP_PORT)) 82 | ioloop.IOLoop.current().start() 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado>=6.0.0 2 | nojava-ipmi-kvm>=0.9.2 3 | ldap3>=2.4.1 4 | certifi>=2019.11.28 5 | -------------------------------------------------------------------------------- /templates/index.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ title }} 4 | 56 | 57 | 58 |
59 |

Hello {{ user['name'] }} ({{ user['email'] }})

60 | 61 |
62 | {% module xsrf_form_html() %} 63 | 64 | 65 | {% for server in servers %} 66 | 67 | {% end %} 68 | 69 | 70 |
71 | 72 | 73 | 74 |
75 | 76 | 77 | 83 |
84 | 85 | 88 | 91 | {% import os %} 92 | {% if 'OAUTH_HOST' in os.environ %} 93 | 94 | {% end %} 95 |
96 |
97 |
98 | 100 |
101 | 102 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /templates/index_instant.tpl: -------------------------------------------------------------------------------- 1 | {% extends 'index.tpl' %} 2 | {% block ws_onopen %} 3 | 4 | document.getElementById('kvm-server').value = {% raw server_name %}; 5 | document.getElementById('kvm-password').value = {% raw password %}; 6 | document.getElementById('kvm-resolution').value = {% raw resolution %}; 7 | 8 | start_kvm(); 9 | 10 | {% end %} 11 | -------------------------------------------------------------------------------- /templates/not_authorized.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | Not authorized 4 | 10 | 11 | 12 |
13 |

Hello {{ user['name'] }} ({{ user['email'] }})

14 |
You are not authorized to use this service. Please contact the responsible admin if you think that this is a mistake.
15 | {% import os %} 16 | {% if 'OAUTH_HOST' in os.environ %} 17 | 18 | {% end %} 19 |
20 | 32 | 33 | 34 | --------------------------------------------------------------------------------