Commit b025aa56 authored by Michal Horejsek's avatar Michal Horejsek

Merge branch 'balancer' into 'master'

Load balancing between multiple honeypots.

See merge request !5
parents d2e7f4e2 9c27119a
# pylint: disable=missing-docstring
import traceback
import cachetools
import requests
from haas_proxy import constants
class Balancer():
"""
Handles "load-balancing" of proxies between multiple running honeypots.
We call HTTP GET where we receive randomly assigned honeypot for 1H.
"""
api_url = None
# Expiring cache for API result, expires in 1h.
cache = cachetools.TTLCache(1, constants.DEFAULT_BALANCER_CHECK_INTERVAL)
CACHE_KEY = 'API_RESP'
def __init__(self, api_url):
self.api_url = api_url
def load_api(self):
"""
Returns cached API response or get's data from API.
"""
cached_resp = self.cache.get(self.CACHE_KEY)
if cached_resp is None:
try:
resp = requests.api.get(self.api_url)
# pylint: disable=broad-except
except Exception:
traceback.print_exc()
return None
if resp.status_code != 200:
print("API returned invalid response: {}".format(resp.text))
return None
self.cache[self.CACHE_KEY] = cached_resp = resp.json()
return cached_resp
@property
def host(self):
"""
Returns host of honeypot.
"""
api_resp = self.load_api()
# load_api() may return None if there was error loading the API.
if api_resp is None:
return constants.DEFAULT_HONEYPOT_HOST
# in case someone breaks our balancer API to return wrong JSON.
api_host = api_resp.get('host')
if api_host is None:
return constants.DEFAULT_HONEYPOT_HOST
return api_host
@property
def port(self):
"""
Returns port of honeypot.
"""
api_resp = self.load_api()
# load_api() may return None if there was error loading the API.
if api_resp is None:
return constants.DEFAULT_HONEYPOT_PORT
# in case someone breaks our balancer API to return wrong JSON.
api_port = api_resp.get('port')
if api_port is None:
return constants.DEFAULT_HONEYPOT_PORT
return api_port
......@@ -5,6 +5,8 @@ Constants to use for default run of application.
DEFAULT_PORT = 2222
DEFAULT_HONEYPOT_HOST = 'haas-app.nic.cz'
DEFAULT_HONEYPOT_PORT = 10000
DEFAULT_BALANCER_CHECK_INTERVAL = 3600
DEFAULT_BALANCER_ADDRESS = 'https://haas.nic.cz/api/honeypot-loadbalancer'
# pylint: disable=line-too-long
DEFAULT_PUBLIC_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC2jdAE4EAAKikW6W/dDmWS/0lQ1jWM6c6Ef+KpGr+jW83/XIR2reWXeeDTIEluL20JV/P2+2bvVShNr4w8SWitcYKTpwkSgGYHo2vAQvXArx/CsRnTAP6NwrxuZoLNO52fMXQWSrqs0tEvkzYXR3PcR6Cq07RN7QkYNWctCYJxdw=='
......
......@@ -15,6 +15,7 @@ from twisted.conch.unix import SSHSessionForUnixConchUser
from twisted.internet import reactor, defer
from twisted.python import components
from haas_proxy.balancer import Balancer
from haas_proxy.utils import force_text
......@@ -29,7 +30,8 @@ class ProxyService(service.Service):
def startService(self):
# pylint: disable=no-member
self._port = reactor.listenTCP(self.args.port, ProxySSHFactory(self.args))
self._port = reactor.listenTCP(
self.args.port, ProxySSHFactory(self.args))
def stopService(self):
return self._port.stopListening()
......@@ -50,9 +52,12 @@ class ProxySSHFactory(factory.SSHFactory):
b'ssh-userauth': userauth.SSHUserAuthServer,
b'ssh-connection': connection.SSHConnection,
}
self.portal = cred.portal.Portal(ProxySSHRealm(), checkers=[ProxyPasswordChecker()])
self.portal = cred.portal.Portal(
ProxySSHRealm(), checkers=[ProxyPasswordChecker()])
ProxySSHSession.cmd_args = cmd_args
components.registerAdapter(ProxySSHSession, ProxySSHUser, session.ISession)
ProxySSHSession.balancer = Balancer(cmd_args.balancer_address)
components.registerAdapter(
ProxySSHSession, ProxySSHUser, session.ISession)
class ProxyPasswordChecker:
......@@ -100,13 +105,25 @@ class ProxySSHUser(ConchUser):
self.password = password
self.channelLookup.update({b'session': session.SSHSession})
# pylint: disable=invalid-name
def getUserGroupID(self):
# # pylint: disable=invalid-name
def getUserGroupId(self):
"""
Returns tuple with user and group ID.
Method needed by `SSHSessionForUnixConchUser`.
Method needed by `SSHSessionForUnixConchUser.openShell`.
"""
return 0, 0
def getHomeDir(self):
"""
return None, None
Method needed by `SSHSessionForUnixConchUser.openShell`.
"""
return "/root"
def getShell(self):
"""
Method needed by `SSHSessionForUnixConchUser.openShell`.
"""
return "/bin/bash"
class ProxySSHSession(SSHSessionForUnixConchUser):
......@@ -114,13 +131,15 @@ class ProxySSHSession(SSHSessionForUnixConchUser):
Main function of SSH proxy - connects to honeypot and change password
to JSON with more information needed to tag activity with user's account.
"""
cmd_args = None # Will inject ProxySSHFactory.
balancer = None # Injected from ProxySSHFactory.
cmd_args = None # Injected from ProxySSHFactory.
# pylint: disable=invalid-name
def openShell(self, proto):
"""
Custom implementation of shell - proxy to real SSH to honeypot.
This method handles interactive SSH sessions from the user. It requires
ProxySSHUser to have `getUserGroupId`, `getHomeDir` and `getShell` implemented.
"""
# pylint: disable=no-member
self.pty = reactor.spawnProcess(
......@@ -134,9 +153,28 @@ class ProxySSHSession(SSHSessionForUnixConchUser):
usePTY=self.ptyTuple,
)
if self.ptyTuple:
fcntl.ioctl(self.pty.fileno(), tty.TIOCSWINSZ, struct.pack('4H', *self.winSize))
fcntl.ioctl(self.pty.fileno(), tty.TIOCSWINSZ,
struct.pack('4H', *self.winSize))
self.avatar.conn.transport.transport.setTcpNoDelay(1)
def execCommand(self, proto, cmd):
"""
Custom implementation of exec - proxy to real SSH to honeypot.
This function handles executing of commands from SSH:
`ssh root@honeypot "cmd"`
"""
# pylint: disable=no-member
self.pty = reactor.spawnProcess(
proto,
executable='/usr/bin/sshpass',
args=self.honeypot_ssh_arguments + [cmd],
env=self.environ,
path='/',
uid=None,
gid=None,
usePTY=self.ptyTuple,
)
@property
def honeypot_ssh_arguments(self):
"""
......@@ -149,9 +187,11 @@ class ProxySSHSession(SSHSessionForUnixConchUser):
'ssh',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'StrictHostKeyChecking=no',
'-o', 'LogLevel=error', # Ignore warning of permanently added host to list of known hosts.
'-p', str(self.cmd_args.honeypot_port),
'{}@{}'.format(force_text(self.avatar.username), self.cmd_args.honeypot_host),
# Ignore warning of permanently added host to list of known hosts.
'-o', 'LogLevel=error',
'-p', str(self.balancer.port),
'{}@{}'.format(force_text(self.avatar.username),
self.balancer.host),
]
@property
......
......@@ -18,15 +18,15 @@ def read_key(filename, default):
try:
return open(filename, 'rb').read()
except Exception as exc:
raise usage.UsageError('Problem to read the key {}: {}'.format(filename, exc))
raise usage.UsageError(
'Problem reading the key {}: {}'.format(filename, exc))
class Options(usage.Options):
optParameters = [
['device-token', 'd', None, 'Your ID at honeypot.labs.nic.cz. If you don\'t have one, sign up first.'],
['port', 'p', constants.DEFAULT_PORT, 'Port to listen to.', int],
['honeypot-host', None, constants.DEFAULT_HONEYPOT_HOST],
['honeypot-port', None, constants.DEFAULT_HONEYPOT_PORT],
['balancer-address', None, constants.DEFAULT_BALANCER_ADDRESS],
['public-key'],
['private-key'],
['log-file', 'l', None, 'Turn on Python logging to this file. It\' wise to disable Twisted logging.'],
......@@ -42,12 +42,8 @@ class Options(usage.Options):
return self['port']
@property
def honeypot_host(self):
return self['honeypot-host']
@property
def honeypot_port(self):
return self['honeypot-port']
def balancer_address(self):
return self['balancer-address']
@property
def public_key(self):
......@@ -68,8 +64,10 @@ class Options(usage.Options):
def postOptions(self):
if not self['device-token']:
raise usage.UsageError('Device token is required')
self['public-key'] = read_key(self['public-key'], constants.DEFAULT_PUBLIC_KEY)
self['private-key'] = read_key(self['private-key'], constants.DEFAULT_PRIVATE_KEY)
self['public-key'] = read_key(self['public-key'],
constants.DEFAULT_PUBLIC_KEY)
self['private-key'] = read_key(self['private-key'],
constants.DEFAULT_PRIVATE_KEY)
if self['log-file']:
init_python_logging(self['log-file'], self['log-level'])
......
......@@ -29,6 +29,8 @@ setup(
install_requires=[
'twisted[conch]>={}'.format(TWISTED_VERSION),
'requests',
'cachetools',
],
extras_require={
'test': EXTRA_TEST_REQUIRE,
......
......@@ -30,7 +30,8 @@ def avatar(request):
username=request.param[0],
password=request.param[1],
)
avatar.conn.transport.transport.getPeer.return_value = mock.Mock(host='hacker', port=12345)
avatar.conn.transport.transport.getPeer.return_value = mock.Mock(
host='hacker', port=12345)
return avatar
......@@ -38,6 +39,7 @@ def avatar(request):
def proxy_ssh_session(cmd_args, avatar):
session = ProxySSHSession(avatar)
session.cmd_args = cmd_args
session.balancer = mock.Mock(host="localhost", port=2222)
return session
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment