diff --git a/.gitignore b/.gitignore index f8166b97a..38b5a999c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ db.sqlite3 config.py migrations/ *.log +host_rsa_key diff --git a/apps/common/utils.py b/apps/common/utils.py index 3546f610f..da83cfca7 100644 --- a/apps/common/utils.py +++ b/apps/common/utils.py @@ -30,11 +30,17 @@ def get_object_or_none(model, **kwargs): def encrypt(*args, **kwargs): - return signing.dumps(*args, **kwargs) + try: + return signing.dumps(*args, **kwargs) + except signing.BadSignature: + return '' def decrypt(*args, **kwargs): - return signing.loads(*args, **kwargs) + try: + return signing.loads(*args, **kwargs) + except signing.BadSignature: + return '' def date_expired_default(): diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index cb6f7670c..b57a1f07a 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -54,7 +54,7 @@ INSTALLED_APPS = [ 'users.apps.UsersConfig', 'assets.apps.AssetsConfig', 'perms.apps.PermsConfig', - 'webterminal.apps.WebterminalConfig', + # 'terminal.apps.TerminalConfig', 'ops.apps.OpsConfig', 'audits.apps.AuditsConfig', 'common.apps.CommonConfig', @@ -274,36 +274,36 @@ REST_FRAMEWORK = { } # This setting is required to override the Django's main loop, when running in # development mode, such as ./manage runserver -WSGI_APPLICATION = 'ws4redis.django_runserver.application' +# WSGI_APPLICATION = 'ws4redis.django_runserver.application' # URL that distinguishes websocket connections from normal requests -WEBSOCKET_URL = '/ws/' +# WEBSOCKET_URL = '/ws/' # WebSocket Redis -WS4REDIS_CONNECTION = { - 'host': CONFIG.REDIS_HOST or '127.0.0.1', - 'port': CONFIG.REDIS_PORT or 6379, - 'db': 2, -} +# WS4REDIS_CONNECTION = { +# 'host': CONFIG.REDIS_HOST or '127.0.0.1', +# 'port': CONFIG.REDIS_PORT or 6379, +# 'db': 2, +# } # Set the number of seconds each message shall persisted -WS4REDIS_EXPIRE = 3600 +# WS4REDIS_EXPIRE = 3600 -WS4REDIS_HEARTBEAT = 'love you' +# WS4REDIS_HEARTBEAT = 'love you' -WS4REDIS_PREFIX = 'demo' +# WS4REDIS_PREFIX = 'demo' -SESSION_ENGINE = 'redis_sessions.session' +# SESSION_ENGINE = 'redis_sessions.session' -SESSION_REDIS_PREFIX = 'session' +# SESSION_REDIS_PREFIX = 'session' -SESSION_REDIS_HOST = CONFIG.REDIS_HOST +# SESSION_REDIS_HOST = CONFIG.REDIS_HOST -SESSION_REDIS_PORT = CONFIG.REDIS_PORT +# SESSION_REDIS_PORT = CONFIG.REDIS_PORT -SESSION_REDIS_PASSWORD = CONFIG.REDIS_PASSWORD +# SESSION_REDIS_PASSWORD = CONFIG.REDIS_PASSWORD -SESSION_REDIS_DB = CONFIG.REDIS_DB +# SESSION_REDIS_DB = CONFIG.REDIS_DB # Custom User Auth model diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index e593e60a8..7a1c3ae95 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -25,7 +25,6 @@ urlpatterns = [ url(r'^(api/)?users/', include('users.urls')), url(r'^assets/', include('assets.urls')), url(r'^perms/', include('perms.urls')), - url(r'^terminal/', include('webterminal.urls')), ] diff --git a/apps/perms/migrations/__init__.py b/apps/perms/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/terminal/migrations/__init__.py b/apps/terminal/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/users/models.py b/apps/users/models.py index 229b70ed8..ebe45afaa 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -112,6 +112,12 @@ class User(AbstractUser): else: return True + @property + def is_valid(self): + if self.is_active and not self.is_expired: + return True + return False + @property def private_key(self): return decrypt(self._private_key) diff --git a/apps/users/utils.py b/apps/users/utils.py index 198276190..2ec463e7e 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -12,7 +12,8 @@ from django.utils.translation import ugettext as _ from paramiko.rsakey import RSAKey from common.tasks import send_mail_async -from common.utils import reverse +from common.utils import reverse, get_object_or_none +from .models import User try: @@ -147,3 +148,75 @@ def send_reset_ssh_key_mail(user): logger.debug(message) send_mail_async.delay(subject, message, recipient_list, html_message=message) + + +def validate_ssh_pk(text): + """ + Expects a SSH private key as string. + Returns a boolean and a error message. + If the text is parsed as private key successfully, + (True,'') is returned. Otherwise, + (False, ) is returned. + + from https://github.com/githubnemo/SSH-private-key-validator/blob/master/validate.py + + """ + + if not text: + return False, 'No text given' + + startPattern = re.compile("^-----BEGIN [A-Z]+ PRIVATE KEY-----") + optionPattern = re.compile("^.+: .+") + contentPattern = re.compile("^([a-zA-Z0-9+/]{64}|[a-zA-Z0-9+/]{1,64}[=]{0,2})$") + endPattern = re.compile("^-----END [A-Z]+ PRIVATE KEY-----") + + def contentState(text): + for i in range(0, len(text)): + line = text[i] + + if endPattern.match(line): + if i == len(text) - 1 or len(text[i + 1]) == 0: + return True, '' + else: + return False, 'At end but content coming' + + elif not contentPattern.match(line): + return False, 'Wrong string in content section' + + return False, 'No content or missing end line' + + def optionState(text): + for i in range(0, len(text)): + line = text[i] + + if line[-1:] == '\\': + return optionState(text[i + 2:]) + + if not optionPattern.match(line): + return contentState(text[i + 1:]) + + return False, 'Expected option, found nothing' + + def startState(text): + if len(text) == 0 or not startPattern.match(text[0]): + return False, 'Header is wrong' + return optionState(text[1:]) + + return startState([n.strip() for n in text.splitlines()]) + + +def check_user_is_valid(**kwargs): + password = kwargs.pop('password', None) + public_key = kwargs.pop('public_key', None) + user = get_object_or_none(User, **kwargs) + + if password and not user.check_password(password): + user = None + + if public_key and not user.public_key == public_key: + user = None + + if user and user.is_valid: + return user + + return None diff --git a/apps/webterminal/__init__.py b/apps/webterminal/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/webterminal/admin.py b/apps/webterminal/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/apps/webterminal/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/webterminal/apps.py b/apps/webterminal/apps.py deleted file mode 100644 index 0e6932f9a..000000000 --- a/apps/webterminal/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import unicode_literals - -from django.apps import AppConfig - - -class WebterminalConfig(AppConfig): - name = 'webterminal' diff --git a/apps/webterminal/models.py b/apps/webterminal/models.py deleted file mode 100644 index bd4b2abe9..000000000 --- a/apps/webterminal/models.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import unicode_literals - -from django.db import models - -# Create your models here. diff --git a/apps/webterminal/templates/main.html b/apps/webterminal/templates/main.html deleted file mode 100644 index 0c9dd8e17..000000000 --- a/apps/webterminal/templates/main.html +++ /dev/null @@ -1,176 +0,0 @@ -{% extends 'base.html' %} -{% block content %} - -
-
-
-
-
- - - -
-{% endblock %} - -{% block custom_foot_js %} - - - - -{% endblock %} diff --git a/apps/webterminal/tests.py b/apps/webterminal/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/apps/webterminal/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/webterminal/urls.py b/apps/webterminal/urls.py deleted file mode 100644 index b8c6af98a..000000000 --- a/apps/webterminal/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -# coding:utf-8 -from django.conf.urls import url -from .views import * -from django.contrib import admin -admin.autodiscover() - -app_name = 'webterminal' - -urlpatterns = [ - url(r'^$', TerminalView.as_view(), name='webterminal'), -] \ No newline at end of file diff --git a/apps/webterminal/views.py b/apps/webterminal/views.py deleted file mode 100644 index 0f9be9384..000000000 --- a/apps/webterminal/views.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.shortcuts import render -from django.urls import reverse_lazy -from django.db.models import Q -from django.views.generic.list import ListView -from django.views.generic.edit import CreateView, DeleteView, UpdateView -from django.views.generic.detail import DetailView -from django.views.generic.base import TemplateView -from django.views import View -from django.http import HttpResponse -from ws4redis.redis_store import RedisMessage -from ws4redis.publisher import RedisPublisher -from django.conf import settings - - -# Create your views here. -class TerminalView(TemplateView): - template_name = 'main.html' - - def get(self, request, *args, **kwargs): - welcome = RedisMessage('Hello everybody') # create a welcome message to be sent to everybody - RedisPublisher(facility='foobar', broadcast=True).publish_message(welcome) - return super(TerminalView, self).get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - redis_publisher = RedisPublisher(facility='foobar', groups=[request.POST.get('group')]) - message = RedisMessage(request.POST.get('message')) - redis_publisher.publish_message(message) - return HttpResponse('OK') diff --git a/config-example.py b/config_example.py similarity index 96% rename from config-example.py rename to config_example.py index a1087a1aa..9bae73181 100644 --- a/config-example.py +++ b/config_example.py @@ -70,6 +70,10 @@ class Config: # EMAIL_USE_TLS = False # If port is 587, set True # EMAIL_SUBJECT_PREFIX = '[Jumpserver] ' + # SSH use password or public key for auth + SSH_PASSWORD_AUTH = False + SSH_PUBLIC_KEY_AUTH = True + def __init__(self): pass diff --git a/requirements.txt b/requirements.txt index ac60097ab..ddf45a598 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,6 @@ django-simple-captcha==0.5.2 django-formtools==1.0 sshpubkeys==2.2.0 djangorestframework-bulk==0.2.1 +python-gssapi==0.6.4 +tornado==4.4.2 + diff --git a/terminal/__init__.py b/terminal/__init__.py new file mode 100644 index 000000000..f93d0bec7 --- /dev/null +++ b/terminal/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# + + +if __name__ == '__main__': + pass diff --git a/apps/assets/migrations/__init__.py b/terminal/logs/.gitkeep similarity index 100% rename from apps/assets/migrations/__init__.py rename to terminal/logs/.gitkeep diff --git a/terminal/ssh_config.py b/terminal/ssh_config.py new file mode 100644 index 000000000..910f7d70d --- /dev/null +++ b/terminal/ssh_config.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# + +import logging +import os + + +BASE_DIR = os.path.dirname(os.path.abspath(__name__)) + + +class Config: + SSH_HOST = '' + SSH_PORT = 2200 + LOG_LEVEL = 'INFO' + LOG_DIR = os.path.join(BASE_DIR, 'logs') + LOG_FILENAME = 'ssh_server.log' + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' + }, + 'main': { + 'datefmt': '%Y-%m-%d %H:%M:%S', + 'format': '%(asctime)s [%(module)s %(levelname)s] %(message)s', + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + }, + 'handlers': { + 'null': { + 'level': 'DEBUG', + 'class': 'logging.NullHandler', + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'main', + 'stream': 'ext://sys.stdout', + }, + 'file': { + 'level': 'DEBUG', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'formatter': 'main', + 'filename': os.path.join(LOG_DIR, LOG_FILENAME), + 'when': 'D', + 'backupCount': 10, + }, + }, + 'loggers': { + 'jumpserver': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + 'propagate': True, + }, + 'jumpserver.web_ssh_server': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + 'propagate': True, + }, + 'jumpserver.ssh_server': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + 'propagate': True, + } + } + } + + def __init__(self): + pass + + def __getattr__(self, item): + return None + + +class DevelopmentConfig(Config): + pass + + +class ProductionConfig(Config): + pass + + +class TestingConfig(Config): + pass + + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig, +} + +env = 'default' + diff --git a/terminal/ssh_config_example.py b/terminal/ssh_config_example.py new file mode 100644 index 000000000..238304fcd --- /dev/null +++ b/terminal/ssh_config_example.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# + +import logging +import os + + +BASE_DIR = os.path.dirname(os.path.abspath(__name__)) + + +class Config: + LOG_LEVEL = 'INFO' + LOG_DIR = os.path.join(BASE_DIR, 'logs') + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' + }, + 'main': { + 'datefmt': '%Y-%m-%d %H:%M:%S', + 'format': '%(asctime)s [%(module)s %(levelname)s] %(message)s', + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + }, + 'handlers': { + 'null': { + 'level': 'DEBUG', + 'class': 'logging.NullHandler', + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'main' + }, + 'file': { + 'level': 'DEBUG', + 'class': 'logging.FileHandler', + 'formatter': 'main', + 'filename': LOG_DIR, + }, + }, + 'loggers': { + 'jumpserver': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + }, + 'jumpserver.web_ssh_server': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + }, + 'jumpserver.ssh_server': { + 'handlers': ['console', 'file'], + # 'level': LOG_LEVEL_CHOICES.get(LOG_LEVEL, None) or LOG_LEVEL_CHOICES.get('info') + 'level': LOG_LEVEL, + } + } + } + + def __init__(self): + pass + + def __getattr__(self, item): + return None + + +class DevelopmentConfig(Config): + pass + + +class ProductionConfig(Config): + pass + + +class TestingConfig(Config): + pass + + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig, +} + +env = 'default' + + +if __name__ == '__main__': + pass diff --git a/terminal/ssh_server.py b/terminal/ssh_server.py new file mode 100644 index 000000000..f8c649f13 --- /dev/null +++ b/terminal/ssh_server.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# + +__version__ = '0.3.3' + +import sys +import os +import base64 +import time +from binascii import hexlify +import sys +import threading +from multiprocessing.process import Process +import traceback +import tty +import termios +import struct +import fcntl +import signal +import socket +import select +import errno +import paramiko +import django + +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +APP_DIR = os.path.join(os.path.dirname(BASE_DIR), 'apps') +sys.path.append(APP_DIR) +os.environ['DJANGO_SETTINGS_MODULE'] = 'jumpserver.settings' + +try: + django.setup() +except IndexError: + pass + +from django.conf import settings +from users.utils import ssh_key_gen, check_user_is_valid +from utils import get_logger, SSHServerException, control_char + + +logger = get_logger(__name__) + +paramiko.util.log_to_file(os.path.join(BASE_DIR, 'logs', 'paramiko.log')) + + +class SSHServer(paramiko.ServerInterface): + host_key_path = os.path.join(BASE_DIR, 'host_rsa_key') + channel_pools = [] + + def __init__(self, client, addr): + self.event = threading.Event() + self.change_window_size_event = threading.Event() + self.client = client + self.addr = addr + self.username = None + self.user = None + self.channel_width = None + self.channel_height = None + + @classmethod + def host_key(cls): + return cls.get_host_key() + + @classmethod + def get_host_key(cls): + logger.debug("Get ssh server host key") + if not os.path.isfile(cls.host_key_path): + cls.host_key_gen() + return paramiko.RSAKey(filename=cls.host_key_path) + + @classmethod + def host_key_gen(cls): + logger.debug("Generate ssh server host key") + ssh_key, ssh_pub_key = ssh_key_gen() + with open(cls.host_key_path, 'w') as f: + f.write(ssh_key) + + def check_channel_request(self, kind, chanid): + if kind == 'session': + return paramiko.OPEN_SUCCEEDED + return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + def check_auth_password(self, username, password): + self.user = user = check_user_is_valid(username=username, password=password) + if self.user: + self.username = username = user.username + logger.info('Accepted password for %(username)s from %(host)s port %(port)s ' % { + 'username': username, + 'host': self.addr[0], + 'port': self.addr[1], + }) + return paramiko.AUTH_SUCCESSFUL + else: + logger.info('Authentication password failed for %(username)s from %(host)s port %(port)s ' % { + 'username': username, + 'host': self.addr[0], + 'port': self.addr[1], + }) + return paramiko.AUTH_FAILED + + def check_auth_publickey(self, username, public_key): + self.user = user = check_user_is_valid(username=username, public_key=public_key) + + if self.user: + self.username = username = user.username + logger.info('Accepted public key for %(username)s from %(host)s port %(port)s ' % { + 'username': username, + 'host': self.addr[0], + 'port': self.addr[1], + }) + return paramiko.AUTH_SUCCESSFUL + else: + logger.info('Authentication public key failed for %(username)s from %(host)s port %(port)s ' % { + 'username': username, + 'host': self.addr[0], + 'port': self.addr[1], + }) + return paramiko.AUTH_FAILED + + def get_allowed_auths(self, username): + auth_method_list = [] + if settings.CONFIG.SSH_PASSWORD_AUTH: + auth_method_list.append('password') + if settings.CONFIG.SSH_PUBLICK_KEY_AUTH: + auth_method_list.append('publickey') + return ','.join(auth_method_list) + + def check_channel_shell_request(self, channel): + self.event.set() + self.__class__.channel_pools.append(channel) + channel.username = self.username + channel.addr = self.addr + return True + + def check_channel_pty_request(self, channel, term, width, height, pixelwidth, + pixelheight, modes): + channel.change_window_size_event = threading.Event() + channel.width = width + channel.height = height + return True + + def check_channel_window_change_request(self, channel, width, height, pixelwidth, pixelheight): + channel.change_window_size_event.set() + channel.width = width + channel.height = height + return True + + +class BackendServer: + def __init__(self, host, port, username): + self.host = host + self.port = port + self.username = username + self.ssh = None + self.channel = None + + def connect(self, term='xterm', width=80, height=24, timeout=10): + self.ssh = ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + ssh.connect(hostname=self.host, port=self.port, username=self.username, password=self.host_password, + pkey=self.host_private_key, look_for_keys=False, allow_agent=True, compress=True, timeout=timeout) + except Exception: + logger.warning('Connect backend server %s failed' % self.host) + return None + + self.channel = channel = ssh.invoke_shell(term=term, width=width, height=height) + logger.info('Connect backend server %(username)s@%(host)s:%(port)s successfully' % { + 'username': self.username, + 'host': self.host, + 'port': self.port, + }) + channel.settimeout(100) + channel.host = self.host + channel.port = self.port + channel.username = self.username + return channel + + @property + def host_password(self): + return 'redhat' + + @property + def host_private_key(self): + return None + + +class Navigation: + def __init__(self, username, client_channel): + self.username = username + self.client_channel = client_channel + + def display_banner(self): + client_channel = self.client_channel + client_channel.send(control_char.clear) + client_channel.send('\r\n\r\n\t\tWelcome to use Jumpserver open source system !\r\n\r\n') + client_channel.send('If you find some bug please contact us \r\n') + client_channel.send('See more at https://www.jumpserver.org\r\n') + # client_channel.send(self.username) + + def display(self): + self.display_banner() + + def return_to_connect(self): + pass + + +class ProxyChannel: + ENTER_CHAR = ['\r', '\n', '\r\n'] + input_data = [] + output_data = [] + + def __init__(self, client_channel, backend_channel, client_addr): + self.client_channel = client_channel + self.backend_channel = backend_channel + self.client_addr = client_addr + self.in_input_mode = True + + def stream_flow(self, input_=None, output_=None): + if input_: + self.in_input_mode = True + if input_ in ['\r', '\n', '\r\n']: + self.in_input_mode = False + + if output_: + print(''.join(self.__class__.output_data)) + if not self.in_input_mode: + command = ''.join(self.__class__.output_data) + del self.__class__.output_data + self.__class__.output_data = [] + self.__class__.output_data.append(output_) + + def proxy(self): + client_channel = self.client_channel + backend_channel = self.backend_channel + client_addr = self.client_addr + + while True: + r, w, x = select.select([client_channel, backend_channel], [], []) + + if client_channel.change_window_size_event.is_set(): + backend_channel.resize_pty(width=client_channel.width, height=client_channel.height) + + if client_channel in r: + self.in_input_mode = True + client_data = client_channel.recv(1024) + + if client_data in self.__class__.ENTER_CHAR: + self.in_input_mode = False + command = ''.join(self.__class__.output_data) + print('########### command ##########') + print(command) + print('########### end command ##########') + del self.__class__.output_data + self.__class__.output_data = [] + backend_channel.send(client_data) + output = ''.join(self.__class__.output_data) + print('>>>>>>>>>>> output <<<<<<<<<<') + print(output) + print('>>>>>>>>>>> end output <<<<<<<<<<') + continue + + if len(client_data) == 0: + logger.info('Logout from ssh server %(host)s: %(username)s' % { + 'host': client_addr[0], + 'username': client_channel.username, + }) + break + backend_channel.send(client_data) + + if backend_channel in r: + backend_data = backend_channel.recv(1024) + if len(backend_data) == 0: + client_channel.send('Disconnect from %s \r\n' % backend_channel.host) + client_channel.close() + logger.info('Logout from backend server %(host)s: %(username)s' % { + 'host': backend_channel.host, + 'username': backend_channel.username, + }) + break + self.__class__.output_data.append(backend_data) + client_channel.send(backend_data) + + +class JumpServer: + backend_server_pools = [] + backend_channel_pools = [] + client_channel_pools = [] + + CONTROL_CHAR = { + 'clear': '' + } + + def __init__(self): + self.listen_host = '0.0.0.0' + self.listen_port = 2222 + + def display_navigation(self, username, client_channel): + nav = Navigation(username, client_channel) + nav.display() + return 'j', 22, 'root' + + def get_client_channel(self, client, addr): + transport = paramiko.Transport(client, gss_kex=False) + transport.set_gss_host(socket.getfqdn("")) + try: + transport.load_server_moduli() + except: + logger.warning('Failed to load moduli -- gex will be unsupported.') + raise + + transport.add_server_key(SSHServer.get_host_key()) + ssh_server = SSHServer(client, addr) + + try: + transport.start_server(server=ssh_server) + except paramiko.SSHException: + logger.warning('SSH negotiation failed.') + + client_channel = transport.accept(20) + if client_channel is None: + logger.warning('No ssh channel get.') + return None + + self.__class__.client_channel_pools.append(client_channel) + if not ssh_server.event.is_set(): + logger.warning('Client never asked for a shell.') + return client_channel + + def get_backend_channel(self, host, port, username, term='xterm', width=80, height=24): + backend_server = BackendServer(host, port, username) + backend_channel = backend_server.connect(term=term, width=width, height=height) + + if backend_channel is None: + logger.warning('Connect %(username)s@%(host)s:%(port)s failed' % { + 'username': username, + 'host': host, + 'port': port, + }) + return None + + self.__class__.backend_server_pools.append(backend_server) + self.__class__.backend_channel_pools.append(backend_channel) + + return backend_channel + + def command_flow(self, input_=None, output_=None): + pass + + def handle_ssh_request(self, client, addr): + logger.info("Get ssh request from %(host)s:%(port)s" % { + 'host': addr[0], + 'port': addr[1], + }) + + try: + client_channel = self.get_client_channel(client, addr) + if client_channel is None: + client.close() + return + + host, port, username = self.display_navigation('root', client_channel) + backend_channel = self.get_backend_channel(host, port, username, + width=client_channel.width, + height=client_channel.height) + if backend_channel is None: + client.shutdown() + client.close() + client.send('Close') + return + + proxy_channel = ProxyChannel(client_channel, backend_channel, addr) + proxy_channel.proxy() + + # Todo: catch other exception + except IndexError: + logger.info('Close with server %s from %s' % (addr[0], addr[1])) + sys.exit(100) + + def listen(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((self.listen_host, self.listen_port)) + sock.listen(5) + + print(time.ctime()) + print('Jumpserver version %s, more see https://www.jumpserver.org' % __version__) + print('Starting ssh server at %(host)s:%(port)s' % {'host': self.listen_host, 'port': self.listen_port}) + print('Quit the server with CONTROL-C.') + + while True: + try: + client, addr = sock.accept() + thread = threading.Thread(target=self.handle_ssh_request, args=(client, addr)) + thread.daemon = True + thread.start() + except Exception as e: + logger.error('Bind failed: ' + str(e)) + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + server = JumpServer() + try: + server.listen() + except KeyboardInterrupt: + sys.exit(1) + diff --git a/terminal/utils.py b/terminal/utils.py new file mode 100644 index 000000000..c9c4f82cf --- /dev/null +++ b/terminal/utils.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# + +import logging +from logging.config import dictConfig +from ssh_config import config, env + + +CONFIG_SSH_SERVER = config.get(env) + + +def get_logger(name): + dictConfig(CONFIG_SSH_SERVER.LOGGING) + return logging.getLogger('jumpserver.%s' % name) + + +class ControlChar: + CHARS = { + 'clear': '\x1b[H\x1b[2J', + } + + def __init__(self): + pass + + def __getattr__(self, item): + return self.__class__.CHARS.get(item, '') + + +class SSHServerException(Exception): + pass + + +control_char = ControlChar() diff --git a/terminal/web_ssh_server.py b/terminal/web_ssh_server.py new file mode 100644 index 000000000..3d98261b1 --- /dev/null +++ b/terminal/web_ssh_server.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# +