mirror of
https://github.com/paradoxxxzero/butterfly.git
synced 2026-05-26 07:08:08 +00:00
Session security
This commit is contained in:
@@ -65,46 +65,47 @@ tornado.options.define("theme", default=None,
|
|||||||
if os.getuid() == 0:
|
if os.getuid() == 0:
|
||||||
conf_file = os.path.join(
|
conf_file = os.path.join(
|
||||||
os.path.abspath(os.sep), 'etc', 'butterfly', 'butterfly.conf')
|
os.path.abspath(os.sep), 'etc', 'butterfly', 'butterfly.conf')
|
||||||
|
ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl')
|
||||||
else:
|
else:
|
||||||
conf_file = os.path.join(
|
conf_file = os.path.join(
|
||||||
os.path.expanduser('~'), '.butterfly', 'butterfly.conf')
|
os.path.expanduser('~'), '.butterfly', 'butterfly.conf')
|
||||||
|
ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl')
|
||||||
|
|
||||||
tornado.options.define("conf", default=conf_file,
|
tornado.options.define("conf", default=conf_file,
|
||||||
help="Butterfly configuration file. "
|
help="Butterfly configuration file. "
|
||||||
"Contains the same options as command line.")
|
"Contains the same options as command line.")
|
||||||
|
|
||||||
if os.path.exists(conf_file):
|
tornado.options.define("ssl_dir", default=ssl_dir,
|
||||||
tornado.options.parse_config_file(conf_file)
|
help="Force SSL directory location")
|
||||||
|
|
||||||
tornado.options.parse_command_line()
|
tornado.options.parse_command_line()
|
||||||
|
|
||||||
|
if os.path.exists(tornado.options.options.conf):
|
||||||
|
tornado.options.parse_config_file(tornado.options.options.conf)
|
||||||
|
|
||||||
|
options = tornado.options.options
|
||||||
|
|
||||||
for logger in ('tornado.access', 'tornado.application',
|
for logger in ('tornado.access', 'tornado.application',
|
||||||
'tornado.general', 'butterfly'):
|
'tornado.general', 'butterfly'):
|
||||||
level = logging.WARNING
|
level = logging.WARNING
|
||||||
if tornado.options.options.debug:
|
if options.debug:
|
||||||
level = logging.INFO
|
level = logging.INFO
|
||||||
if tornado.options.options.more:
|
if options.more:
|
||||||
level = logging.DEBUG
|
level = logging.DEBUG
|
||||||
logging.getLogger(logger).setLevel(level)
|
logging.getLogger(logger).setLevel(level)
|
||||||
|
|
||||||
log = logging.getLogger('butterfly')
|
log = logging.getLogger('butterfly')
|
||||||
log.info('Starting server')
|
log.info('Starting server')
|
||||||
|
|
||||||
host = tornado.options.options.host
|
host = options.host
|
||||||
port = tornado.options.options.port
|
port = options.port
|
||||||
|
|
||||||
if os.getuid() == 0:
|
if not os.path.exists(options.ssl_dir):
|
||||||
ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl')
|
os.makedirs(options.ssl_dir)
|
||||||
else:
|
|
||||||
ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl')
|
|
||||||
|
|
||||||
if not os.path.exists(ssl_dir):
|
|
||||||
os.makedirs(ssl_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def to_abs(file):
|
def to_abs(file):
|
||||||
return os.path.join(ssl_dir, file)
|
return os.path.join(options.ssl_dir, file)
|
||||||
|
|
||||||
ca, ca_key, cert, cert_key, pkcs12 = map(to_abs, [
|
ca, ca_key, cert, cert_key, pkcs12 = map(to_abs, [
|
||||||
'butterfly_ca.crt', 'butterfly_ca.key',
|
'butterfly_ca.crt', 'butterfly_ca.key',
|
||||||
@@ -131,7 +132,7 @@ def read(file):
|
|||||||
with open(file, 'rb') as fd:
|
with open(file, 'rb') as fd:
|
||||||
return fd.read()
|
return fd.read()
|
||||||
|
|
||||||
if tornado.options.options.generate_certs:
|
if options.generate_certs:
|
||||||
from OpenSSL import crypto
|
from OpenSSL import crypto
|
||||||
print('Generating certificates for %s (change it with --host)\n' % host)
|
print('Generating certificates for %s (change it with --host)\n' % host)
|
||||||
|
|
||||||
@@ -180,8 +181,8 @@ if tornado.options.options.generate_certs:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if (tornado.options.options.generate_current_user_pkcs or
|
if (options.generate_current_user_pkcs or
|
||||||
tornado.options.options.generate_user_pkcs):
|
options.generate_user_pkcs):
|
||||||
from butterfly import utils
|
from butterfly import utils
|
||||||
try:
|
try:
|
||||||
current_user = utils.User()
|
current_user = utils.User()
|
||||||
@@ -193,10 +194,10 @@ if (tornado.options.options.generate_current_user_pkcs or
|
|||||||
print('Please generate certificates using --generate-certs before')
|
print('Please generate certificates using --generate-certs before')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if tornado.options.options.generate_current_user_pkcs:
|
if options.generate_current_user_pkcs:
|
||||||
user = current_user.name
|
user = current_user.name
|
||||||
else:
|
else:
|
||||||
user = tornado.options.options.generate_user_pkcs
|
user = options.generate_user_pkcs
|
||||||
|
|
||||||
if user != current_user.name and current_user.uid != 0:
|
if user != current_user.name and current_user.uid != 0:
|
||||||
print('Cannot create certificate for another user with '
|
print('Cannot create certificate for another user with '
|
||||||
@@ -239,7 +240,7 @@ if (tornado.options.options.generate_current_user_pkcs or
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if tornado.options.options.unsecure:
|
if options.unsecure:
|
||||||
ssl_opts = None
|
ssl_opts = None
|
||||||
else:
|
else:
|
||||||
if not all(map(os.path.exists, [cert % host, cert_key % host, ca])):
|
if not all(map(os.path.exists, [cert % host, cert_key % host, ca])):
|
||||||
@@ -260,15 +261,15 @@ else:
|
|||||||
'ca_certs': ca,
|
'ca_certs': ca,
|
||||||
'cert_reqs': ssl.CERT_REQUIRED
|
'cert_reqs': ssl.CERT_REQUIRED
|
||||||
}
|
}
|
||||||
if tornado.options.options.ssl_version is not None:
|
if options.ssl_version is not None:
|
||||||
if not hasattr(
|
if not hasattr(
|
||||||
ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version):
|
ssl, 'PROTOCOL_%s' % options.ssl_version):
|
||||||
print(
|
print(
|
||||||
"Unknown SSL protocol %s" %
|
"Unknown SSL protocol %s" %
|
||||||
tornado.options.options.ssl_version)
|
options.ssl_version)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ssl_opts['ssl_version'] = getattr(
|
ssl_opts['ssl_version'] = getattr(
|
||||||
ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version)
|
ssl, 'PROTOCOL_%s' % options.ssl_version)
|
||||||
|
|
||||||
from butterfly import application
|
from butterfly import application
|
||||||
|
|
||||||
@@ -285,6 +286,6 @@ log.info('Starting loop')
|
|||||||
ioloop = tornado.ioloop.IOLoop.instance()
|
ioloop = tornado.ioloop.IOLoop.instance()
|
||||||
|
|
||||||
url = "http%s://%s:%d/" % (
|
url = "http%s://%s:%d/" % (
|
||||||
"s" if not tornado.options.options.unsecure else "", host, port)
|
"s" if not options.unsecure else "", host, port)
|
||||||
log.warn('Butterfly is ready, open your browser to: %s' % url)
|
log.warn('Butterfly is ready, open your browser to: %s' % url)
|
||||||
ioloop.start()
|
ioloop.start()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import tornado.options
|
|||||||
import tornado.process
|
import tornado.process
|
||||||
import tornado.web
|
import tornado.web
|
||||||
import tornado.websocket
|
import tornado.websocket
|
||||||
|
from collections import defaultdict
|
||||||
from butterfly import url, Route, utils, __version__
|
from butterfly import url, Route, utils, __version__
|
||||||
from butterfly.terminal import Terminal
|
from butterfly.terminal import Terminal
|
||||||
|
|
||||||
@@ -98,11 +99,13 @@ class Font(Route):
|
|||||||
'(?:/wd/(?P<path>.+))?')
|
'(?:/wd/(?P<path>.+))?')
|
||||||
class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
||||||
session_history_size = 10000
|
session_history_size = 10000
|
||||||
# List of websockets per session
|
# List of websockets per session per user
|
||||||
sessions = {}
|
# dict: user -> dict: session -> [TermWebSocket]
|
||||||
|
sessions = defaultdict(dict)
|
||||||
|
|
||||||
# Terminal for session
|
# Terminal for session per user
|
||||||
terminals = {}
|
# dict: user -> dict: session -> Terminal
|
||||||
|
terminals = defaultdict(dict)
|
||||||
|
|
||||||
# All terminals sockets for systemd socket deactivation
|
# All terminals sockets for systemd socket deactivation
|
||||||
sockets = []
|
sockets = []
|
||||||
@@ -113,6 +116,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
def open(self, user, path, session):
|
def open(self, user, path, session):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
self.secure_user = None
|
||||||
|
|
||||||
# Prevent cross domain
|
# Prevent cross domain
|
||||||
if self.request.headers['Origin'] not in (
|
if self.request.headers['Origin'] not in (
|
||||||
@@ -130,46 +134,93 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
self.log.info('Websocket opened %r' % self)
|
self.log.info('Websocket opened %r' % self)
|
||||||
self.set_nodelay(True)
|
self.set_nodelay(True)
|
||||||
|
|
||||||
|
socket = utils.Socket(self.ws_connection.stream.socket)
|
||||||
|
opts = tornado.options.options
|
||||||
|
|
||||||
|
if not opts.unsecure:
|
||||||
|
user = utils.parse_cert(
|
||||||
|
self.ws_connection.stream.socket.getpeercert())
|
||||||
|
assert user, 'No user in certificate'
|
||||||
|
try:
|
||||||
|
user = utils.User(name=user)
|
||||||
|
except LookupError:
|
||||||
|
raise Exception('Invalid user in certificate')
|
||||||
|
|
||||||
|
# Certificate authed user
|
||||||
|
self.secure_user = user
|
||||||
|
|
||||||
|
elif socket.local and socket.user == utils.User():
|
||||||
|
# Local to local returning browser user
|
||||||
|
self.secure_user = socket.user
|
||||||
|
|
||||||
# Handling terminal session
|
# Handling terminal session
|
||||||
if session:
|
if session:
|
||||||
if session in TermWebSocket.sessions:
|
if session in self.user_sessions:
|
||||||
# Session already here, registering websocket
|
# Session already here, registering websocket
|
||||||
TermWebSocket.sessions[session].append(self)
|
self.user_sessions[session].append(self)
|
||||||
self.write_message(TermWebSocket.history[session])
|
self.write_message(TermWebSocket.history[session])
|
||||||
# And returning, we don't want another terminal
|
# And returning, we don't want another terminal
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# New session, opening terminal
|
# New session, opening terminal
|
||||||
TermWebSocket.sessions[session] = [self]
|
self.user_sessions[session] = [self]
|
||||||
TermWebSocket.history[session] = ''
|
TermWebSocket.history[session] = ''
|
||||||
|
|
||||||
terminal = Terminal(
|
terminal = Terminal(
|
||||||
user, path, session,
|
user, path, session, socket,
|
||||||
self.ws_connection.stream.socket,
|
self.request.headers['Host'], self.render_string, self.write)
|
||||||
self.request.headers['Host'],
|
|
||||||
self.render_string,
|
terminal.pty()
|
||||||
self.write)
|
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
TermWebSocket.terminals[session] = terminal
|
if not self.secure_user:
|
||||||
|
self.log.error(
|
||||||
|
'No terminal session without secure authenticated user'
|
||||||
|
'or local user.')
|
||||||
|
self._terminal = terminal
|
||||||
|
self.session = None
|
||||||
|
else:
|
||||||
|
self.log.info('Openning session %s for secure user %r' % (
|
||||||
|
session, self.secure_user))
|
||||||
|
self.user_terminals[session] = terminal
|
||||||
else:
|
else:
|
||||||
self._terminal = terminal
|
self._terminal = terminal
|
||||||
|
|
||||||
@classmethod
|
@property
|
||||||
def close_all(cls, session):
|
def user_sessions(self):
|
||||||
for inst in TermWebSocket.sessions[session][:]:
|
"""Return the dict session of socket lists"""
|
||||||
inst.on_close()
|
if not self.secure_user:
|
||||||
inst.close()
|
return {}
|
||||||
del TermWebSocket.sessions[session]
|
return TermWebSocket.sessions[self.secure_user.name]
|
||||||
del TermWebSocket.terminals[session]
|
|
||||||
|
@property
|
||||||
|
def user_terminals(self):
|
||||||
|
"""Return the dict session of terminal"""
|
||||||
|
if not self.secure_user:
|
||||||
|
return {}
|
||||||
|
return TermWebSocket.terminals[self.secure_user.name]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def broadcast(cls, session, message):
|
def close_all(cls, session, user):
|
||||||
|
sessions = TermWebSocket.sessions.get(user.name)
|
||||||
|
if sessions:
|
||||||
|
sockets = sessions[session]
|
||||||
|
for socket in sockets[:]:
|
||||||
|
socket.on_close()
|
||||||
|
socket.close()
|
||||||
|
del sessions[session]
|
||||||
|
|
||||||
|
terminals = TermWebSocket.terminals.get(user.name)
|
||||||
|
del terminals[session]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def broadcast(cls, session, message, user):
|
||||||
cls.history[session] += message
|
cls.history[session] += message
|
||||||
if len(cls.history) > cls.session_history_size:
|
if len(cls.history) > cls.session_history_size:
|
||||||
cls.history[session] = cls.history[session][
|
cls.history[session] = cls.history[session][
|
||||||
-cls.session_history_size:]
|
-cls.session_history_size:]
|
||||||
for session in cls.sessions[session][:]:
|
sessions = cls.sessions.get(user.name, [])
|
||||||
|
for session in sessions[session]:
|
||||||
try:
|
try:
|
||||||
session.write_message(message)
|
session.write_message(message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -178,9 +229,10 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
def write(self, message):
|
def write(self, message):
|
||||||
if self.session:
|
if self.session:
|
||||||
if message is None:
|
if message is None:
|
||||||
TermWebSocket.close_all(self.session)
|
TermWebSocket.close_all(self.session, self.secure_user)
|
||||||
else:
|
else:
|
||||||
TermWebSocket.broadcast(self.session, message)
|
TermWebSocket.broadcast(
|
||||||
|
self.session, message, self.secure_user)
|
||||||
else:
|
else:
|
||||||
if message is None:
|
if message is None:
|
||||||
self.on_close()
|
self.on_close()
|
||||||
@@ -189,8 +241,8 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
self.write_message(message)
|
self.write_message(message)
|
||||||
|
|
||||||
def on_message(self, message):
|
def on_message(self, message):
|
||||||
if self.session:
|
if self.session and self.secure_user:
|
||||||
term = TermWebSocket.terminals.get(self.session)
|
term = self.user_terminals.get(self.session)
|
||||||
term and term.write(message)
|
term and term.write(message)
|
||||||
else:
|
else:
|
||||||
self._terminal.write(message)
|
self._terminal.write(message)
|
||||||
@@ -202,7 +254,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
self.log.info('Websocket closed %r' % self)
|
self.log.info('Websocket closed %r' % self)
|
||||||
TermWebSocket.sockets.remove(self)
|
TermWebSocket.sockets.remove(self)
|
||||||
if self.session:
|
if self.session:
|
||||||
TermWebSocket.sessions[self.session].remove(self)
|
self.user_sessions[self.session].remove(self)
|
||||||
elif hasattr(self, '_terminal'):
|
elif hasattr(self, '_terminal'):
|
||||||
self._terminal.close()
|
self._terminal.close()
|
||||||
else:
|
else:
|
||||||
@@ -212,10 +264,18 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
@url(r'/list(?:user/(.+))?/?(?:wd/(.+))?')
|
@url(r'/sessions')
|
||||||
class List(Route):
|
class Sessions(Route):
|
||||||
"""List available terminals"""
|
"""List available sessions"""
|
||||||
def get(self, user, path):
|
def get(self):
|
||||||
if not tornado.options.options.unsecure and user:
|
if tornado.options.options.unsecure:
|
||||||
raise tornado.web.HTTPError(400)
|
raise tornado.web.HTTPError(403)
|
||||||
return self.render('list.html', sessions=TermWebSocket.sessions)
|
|
||||||
|
cert = self.request.get_ssl_certificate()
|
||||||
|
user = utils.parse_cert(cert)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise tornado.web.HTTPError(403)
|
||||||
|
|
||||||
|
return self.render(
|
||||||
|
'list.html', sessions=TermWebSocket.sessions.get(user, []))
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
<link rel="shortcut icon" href="{{ static_url('images/favicon.png') }}">
|
<link rel="shortcut icon" href="{{ static_url('images/favicon.png') }}">
|
||||||
|
|
||||||
<title>Butterfly</title>
|
<title>Butterfly</title>
|
||||||
{# <link href="/style.css" rel="stylesheet"> #}
|
<link href="/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<p>Currently open butterfly sessions :
|
<h1>Currently open butterfly sessions :</h1>
|
||||||
<ul>
|
<ul>
|
||||||
{% for session in sessions %}
|
{% for session in sessions %}
|
||||||
<li> <a target="_blank" href="/session/{{ session }}">{{ session }}</a></li>
|
<li><h2><a target="_blank" href="/session/{{ session }}">{{ session }}</a></h2></li>
|
||||||
{% end %}
|
{% end %}
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class Terminal(object):
|
|||||||
self.send = send
|
self.send = send
|
||||||
self.fd = None
|
self.fd = None
|
||||||
self.closed = False
|
self.closed = False
|
||||||
self.socket = utils.Socket(socket)
|
self.socket = socket
|
||||||
log.info('Terminal opening with session: %s and socket %r' % (
|
log.info('Terminal opening with session: %s and socket %r' % (
|
||||||
self.session, self.socket))
|
self.session, self.socket))
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -77,15 +77,10 @@ class Terminal(object):
|
|||||||
# user as the one who opened the socket ie: the one
|
# user as the one who opened the socket ie: the one
|
||||||
# openning a terminal in browser
|
# openning a terminal in browser
|
||||||
if not self.callee and not self.user and self.socket.local:
|
if not self.callee and not self.user and self.socket.local:
|
||||||
self.callee = self.caller
|
self.user = self.callee = self.caller
|
||||||
else:
|
else:
|
||||||
user = utils.parse_cert(socket.getpeercert())
|
# Authed user
|
||||||
assert user, 'No user in certificate'
|
self.callee = self.user
|
||||||
self.user = user
|
|
||||||
try:
|
|
||||||
self.callee = utils.User(name=self.user)
|
|
||||||
except LookupError:
|
|
||||||
raise Exception('Invalid user in certificate')
|
|
||||||
|
|
||||||
if tornado.options.options.motd != '':
|
if tornado.options.options.motd != '':
|
||||||
motd = (render_string(
|
motd = (render_string(
|
||||||
@@ -99,7 +94,7 @@ class Terminal(object):
|
|||||||
.replace('\n', '\r\n'))
|
.replace('\n', '\r\n'))
|
||||||
self.send(motd)
|
self.send(motd)
|
||||||
|
|
||||||
self.pty()
|
log.info('Forking pty for user %r' % self.user)
|
||||||
|
|
||||||
def pty(self):
|
def pty(self):
|
||||||
# Make a "unique" id in 4 bytes
|
# Make a "unique" id in 4 bytes
|
||||||
@@ -112,6 +107,8 @@ class Terminal(object):
|
|||||||
self.pid, self.fd = pty.fork()
|
self.pid, self.fd = pty.fork()
|
||||||
if self.pid == 0:
|
if self.pid == 0:
|
||||||
self.determine_user()
|
self.determine_user()
|
||||||
|
log.debug('Pty forked for user %r caller %r callee %r' % (
|
||||||
|
self.user, self.caller, self.callee))
|
||||||
self.shell()
|
self.shell()
|
||||||
else:
|
else:
|
||||||
self.communicate()
|
self.communicate()
|
||||||
@@ -126,7 +123,7 @@ class Terminal(object):
|
|||||||
try:
|
try:
|
||||||
user = input('login: ')
|
user = input('login: ')
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
log.debug("Errorin login input", exc_info=True)
|
log.debug("Error in login input", exc_info=True)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -201,6 +198,8 @@ class Terminal(object):
|
|||||||
os.initgroups(self.callee.name, self.callee.gid)
|
os.initgroups(self.callee.name, self.callee.gid)
|
||||||
os.setgid(self.callee.gid)
|
os.setgid(self.callee.gid)
|
||||||
os.setuid(self.callee.uid)
|
os.setuid(self.callee.uid)
|
||||||
|
# Apparently necessary for some cmd
|
||||||
|
env['LOGNAME'] = env['USER'] = self.callee.name
|
||||||
except Exception:
|
except Exception:
|
||||||
log.error(
|
log.error(
|
||||||
'The server must be run as root '
|
'The server must be run as root '
|
||||||
|
|||||||
Reference in New Issue
Block a user