mirror of
				https://github.com/retspen/webvirtcloud
				synced 2025-07-31 12:41:08 +00:00 
			
		
		
		
	replace tunnel.py with sshtunnel.py some small fixes
This commit is contained in:
		
							parent
							
								
									b6cb81c3bc
								
							
						
					
					
						commit
						0b86e34203
					
				
					 5 changed files with 14265 additions and 158 deletions
				
			
		| 
						 | 
				
			
			@ -15,16 +15,12 @@ if ROOT_PATH not in sys.path:
 | 
			
		|||
 | 
			
		||||
django.setup()
 | 
			
		||||
 | 
			
		||||
# VENV_PATH = ROOT_PATH + '/venv/lib/python3.6/site-packages'
 | 
			
		||||
# if VENV_PATH not in sys.path:
 | 
			
		||||
#     sys.path.append(VENV_PATH)
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import socket
 | 
			
		||||
from six.moves import http_cookies as Cookie
 | 
			
		||||
from webvirtcloud.settings import WS_PUBLIC_PORT, WS_HOST, WS_CERT
 | 
			
		||||
from vrtManager.connection import CONN_SSH, CONN_SOCKET
 | 
			
		||||
from console.tunnel import Tunnel
 | 
			
		||||
from console.sshtunnels import SSHTunnels
 | 
			
		||||
from optparse import OptionParser
 | 
			
		||||
 | 
			
		||||
parser = OptionParser()
 | 
			
		||||
| 
						 | 
				
			
			@ -119,7 +115,8 @@ def get_connection_infos(token):
 | 
			
		|||
        console_port = conn.get_console_port()
 | 
			
		||||
        console_socket = conn.get_console_socket()
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logging.error('Fail to retrieve console connection infos for token %s : %s' % (token, e))
 | 
			
		||||
        logging.error(
 | 
			
		||||
            'Fail to retrieve console connection infos for token %s : %s' % (token, e))
 | 
			
		||||
        raise
 | 
			
		||||
    return (connhost, connport, connuser, conntype, console_host,
 | 
			
		||||
            console_port, console_socket)
 | 
			
		||||
| 
						 | 
				
			
			@ -131,6 +128,7 @@ class CompatibilityMixIn(object):
 | 
			
		|||
        # from the request to a cookie header, we should check
 | 
			
		||||
        # also for this behavior
 | 
			
		||||
        hcookie = self.headers.get('cookie')
 | 
			
		||||
 | 
			
		||||
        if hcookie:
 | 
			
		||||
            cookie = Cookie.SimpleCookie()
 | 
			
		||||
            for hcookie_part in hcookie.split(';'):
 | 
			
		||||
| 
						 | 
				
			
			@ -145,6 +143,7 @@ class CompatibilityMixIn(object):
 | 
			
		|||
                    if 'token' in cookie:
 | 
			
		||||
                        token = cookie['token'].value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        (connhost, connport, connuser, conntype, console_host, console_port,
 | 
			
		||||
         console_socket) = get_connection_infos(token)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -177,9 +176,10 @@ class CompatibilityMixIn(object):
 | 
			
		|||
                error_msg += "(or socket %s)"
 | 
			
		||||
                self.msg(error_msg % (connuser, connhost, connport,
 | 
			
		||||
                                      console_host, console_port, console_socket))
 | 
			
		||||
                tunnel = Tunnel()
 | 
			
		||||
                fd = tunnel.open(connhost, connuser, connport,
 | 
			
		||||
                tunnel = SSHTunnels(connhost, connuser, connport,
 | 
			
		||||
                                 console_host, console_port, console_socket)
 | 
			
		||||
                fd = tunnel.open_new()
 | 
			
		||||
                tunnel.unlock()
 | 
			
		||||
                tsock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                self.msg("Fail to open tunnel : %s" % e)
 | 
			
		||||
| 
						 | 
				
			
			@ -198,18 +198,17 @@ class CompatibilityMixIn(object):
 | 
			
		|||
        try:
 | 
			
		||||
            self.msg("Start proxying")
 | 
			
		||||
            self.do_proxy(tsock)
 | 
			
		||||
        except:
 | 
			
		||||
        except Exception:
 | 
			
		||||
            if tunnel:
 | 
			
		||||
                self.vmsg(
 | 
			
		||||
                    "%s:%s (via %s@%s:%s) : Target closed" %
 | 
			
		||||
                    "%s:%s (via %s@%s:%s) : Websocket client or Target closed" %
 | 
			
		||||
                    (console_host, console_port, connuser, connhost, connport))
 | 
			
		||||
                if tsock:
 | 
			
		||||
                    tsock.shutdown(socket.SHUT_RDWR)
 | 
			
		||||
                    tsock.close()
 | 
			
		||||
                tunnel.close()
 | 
			
		||||
                tunnel.close_all()
 | 
			
		||||
            raise
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if USE_HANDLER:
 | 
			
		||||
    class NovaProxyRequestHandler(ProxyRequestHandler, CompatibilityMixIn):
 | 
			
		||||
        def msg(self, *args, **kwargs):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										209
									
								
								console/sshtunnels.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								console/sshtunnels.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,209 @@
 | 
			
		|||
# Copyright (C) 2014, 2015 Red Hat, Inc.
 | 
			
		||||
#
 | 
			
		||||
# This work is licensed under the GNU GPLv2 or later.
 | 
			
		||||
# See the COPYING file in the top-level directory.
 | 
			
		||||
 | 
			
		||||
import functools
 | 
			
		||||
import os
 | 
			
		||||
import queue
 | 
			
		||||
import socket
 | 
			
		||||
import signal
 | 
			
		||||
import threading
 | 
			
		||||
 | 
			
		||||
import logging as log
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class _TunnelScheduler(object):
 | 
			
		||||
    """
 | 
			
		||||
    If the user is using Spice + SSH URI + no SSH keys, we need to
 | 
			
		||||
    serialize connection opening otherwise ssh-askpass gets all angry.
 | 
			
		||||
    This handles the locking and scheduling.
 | 
			
		||||
    It's only instantiated once for the whole app, because we serialize
 | 
			
		||||
    independent of connection, vm, etc.
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self._thread = None
 | 
			
		||||
        self._queue = queue.Queue()
 | 
			
		||||
        self._lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
    def _handle_queue(self):
 | 
			
		||||
        while True:
 | 
			
		||||
            lock_cb, cb, args, = self._queue.get()
 | 
			
		||||
            lock_cb()
 | 
			
		||||
            cb(*args)
 | 
			
		||||
 | 
			
		||||
    def schedule(self, lock_cb, cb, *args):
 | 
			
		||||
        if not self._thread:
 | 
			
		||||
            self._thread = threading.Thread(name="Tunnel thread",
 | 
			
		||||
                                            target=self._handle_queue,
 | 
			
		||||
                                            args=())
 | 
			
		||||
            self._thread.daemon = True
 | 
			
		||||
        if not self._thread.is_alive():
 | 
			
		||||
            self._thread.start()
 | 
			
		||||
        self._queue.put((lock_cb, cb, args))
 | 
			
		||||
 | 
			
		||||
    def lock(self):
 | 
			
		||||
        self._lock.acquire()
 | 
			
		||||
    def unlock(self):
 | 
			
		||||
        self._lock.release()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
_tunnel_scheduler = _TunnelScheduler()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class _Tunnel(object):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self._pid = None
 | 
			
		||||
        self._closed = False
 | 
			
		||||
        self._errfd = None
 | 
			
		||||
 | 
			
		||||
    def close(self):
 | 
			
		||||
        if self._closed:
 | 
			
		||||
            return
 | 
			
		||||
        self._closed = True
 | 
			
		||||
 | 
			
		||||
        log.debug("Close tunnel PID=%s ERRFD=%s",
 | 
			
		||||
                      self._pid, self._errfd and self._errfd.fileno() or None)
 | 
			
		||||
 | 
			
		||||
        # Since this is a socket object, the file descriptor is closed
 | 
			
		||||
        # when it's garbage collected.
 | 
			
		||||
        self._errfd = None
 | 
			
		||||
 | 
			
		||||
        if self._pid:
 | 
			
		||||
            os.kill(self._pid, signal.SIGKILL)
 | 
			
		||||
            os.waitpid(self._pid, 0)
 | 
			
		||||
        self._pid = None
 | 
			
		||||
 | 
			
		||||
    def get_err_output(self):
 | 
			
		||||
        errout = ""
 | 
			
		||||
        while True:
 | 
			
		||||
            try:
 | 
			
		||||
                new = self._errfd.recv(1024)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
            if not new:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
            errout += new.decode()
 | 
			
		||||
 | 
			
		||||
        return errout
 | 
			
		||||
 | 
			
		||||
    def open(self, argv, sshfd):
 | 
			
		||||
        if self._closed:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        errfds = socket.socketpair()
 | 
			
		||||
        pid = os.fork()
 | 
			
		||||
        if pid == 0:
 | 
			
		||||
            errfds[0].close()
 | 
			
		||||
 | 
			
		||||
            os.dup2(sshfd.fileno(), 0)
 | 
			
		||||
            os.dup2(sshfd.fileno(), 1)
 | 
			
		||||
            os.dup2(errfds[1].fileno(), 2)
 | 
			
		||||
            os.execlp(*argv)
 | 
			
		||||
            os._exit(1)  # pylint: disable=protected-access
 | 
			
		||||
 | 
			
		||||
        sshfd.close()
 | 
			
		||||
        errfds[1].close()
 | 
			
		||||
 | 
			
		||||
        self._errfd = errfds[0]
 | 
			
		||||
        self._errfd.setblocking(0)
 | 
			
		||||
        log.debug("Opened tunnel PID=%d ERRFD=%d",
 | 
			
		||||
                      pid, self._errfd.fileno())
 | 
			
		||||
 | 
			
		||||
        self._pid = pid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _make_ssh_command(connhost, connuser, connport, gaddr, gport, gsocket):
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # Build SSH cmd
 | 
			
		||||
    argv = ["ssh", "ssh"]
 | 
			
		||||
    if connport:
 | 
			
		||||
        argv += ["-p", str(connport)]
 | 
			
		||||
 | 
			
		||||
    if connuser:
 | 
			
		||||
        argv += ['-l', connuser]
 | 
			
		||||
 | 
			
		||||
    argv += [connhost]
 | 
			
		||||
 | 
			
		||||
    # Build 'nc' command run on the remote host
 | 
			
		||||
    #
 | 
			
		||||
    # This ugly thing is a shell script to detect availability of
 | 
			
		||||
    # the -q option for 'nc': debian and suse based distros need this
 | 
			
		||||
    # flag to ensure the remote nc will exit on EOF, so it will go away
 | 
			
		||||
    # when we close the VNC tunnel. If it doesn't go away, subsequent
 | 
			
		||||
    # VNC connection attempts will hang.
 | 
			
		||||
    #
 | 
			
		||||
    # Fedora's 'nc' doesn't have this option, and apparently defaults
 | 
			
		||||
    # to the desired behavior.
 | 
			
		||||
    #
 | 
			
		||||
    if gsocket:
 | 
			
		||||
        nc_params = "-U %s" % gsocket
 | 
			
		||||
    else:
 | 
			
		||||
        nc_params = "%s %s" % (gaddr, gport)
 | 
			
		||||
 | 
			
		||||
    nc_cmd = (
 | 
			
		||||
        """nc -q 2>&1 | grep "requires an argument" >/dev/null;"""
 | 
			
		||||
        """if [ $? -eq 0 ] ; then"""
 | 
			
		||||
        """   CMD="nc -q 0 %(nc_params)s";"""
 | 
			
		||||
        """else"""
 | 
			
		||||
        """   CMD="nc %(nc_params)s";"""
 | 
			
		||||
        """fi;"""
 | 
			
		||||
        """eval "$CMD";""" %
 | 
			
		||||
        {'nc_params': nc_params})
 | 
			
		||||
 | 
			
		||||
    argv.append("sh -c")
 | 
			
		||||
    argv.append("'%s'" % nc_cmd)
 | 
			
		||||
 | 
			
		||||
    argv_str = functools.reduce(lambda x, y: x + " " + y, argv[1:])
 | 
			
		||||
    log.debug("Pre-generated ssh command for info: %s", argv_str)
 | 
			
		||||
    return argv
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SSHTunnels(object):
 | 
			
		||||
    def __init__(self, connhost, connuser, connport, gaddr, gport, gsocket):
 | 
			
		||||
        self._tunnels = []
 | 
			
		||||
        self._sshcommand = _make_ssh_command(connhost, connuser, connport, gaddr, gport, gsocket)
 | 
			
		||||
        self._locked = False
 | 
			
		||||
 | 
			
		||||
    def open_new(self):
 | 
			
		||||
        t = _Tunnel()
 | 
			
		||||
        self._tunnels.append(t)
 | 
			
		||||
 | 
			
		||||
        # socket FDs are closed when the object is garbage collected. This
 | 
			
		||||
        # can close an FD behind spice/vnc's back which causes crashes.
 | 
			
		||||
        #
 | 
			
		||||
        # Dup a bare FD for the viewer side of things, but keep the high
 | 
			
		||||
        # level socket object for the SSH side, since it simplifies things
 | 
			
		||||
        # in that area.
 | 
			
		||||
        viewerfd, sshfd = socket.socketpair()
 | 
			
		||||
        _tunnel_scheduler.schedule(self._lock, t.open, self._sshcommand, sshfd)
 | 
			
		||||
 | 
			
		||||
        retfd = os.dup(viewerfd.fileno())
 | 
			
		||||
        log.debug("Generated tunnel fd=%s for viewer", retfd)
 | 
			
		||||
        return retfd
 | 
			
		||||
 | 
			
		||||
    def close_all(self):
 | 
			
		||||
        for l in self._tunnels:
 | 
			
		||||
            l.close()
 | 
			
		||||
        self._tunnels = []
 | 
			
		||||
        self.unlock()
 | 
			
		||||
 | 
			
		||||
    def get_err_output(self):
 | 
			
		||||
        errstrings = []
 | 
			
		||||
        for l in self._tunnels:
 | 
			
		||||
            e = l.get_err_output().strip()
 | 
			
		||||
            if e and e not in errstrings:
 | 
			
		||||
                errstrings.append(e)
 | 
			
		||||
        return "\n".join(errstrings)
 | 
			
		||||
 | 
			
		||||
    def _lock(self):
 | 
			
		||||
        _tunnel_scheduler.lock()
 | 
			
		||||
        self._locked = True
 | 
			
		||||
 | 
			
		||||
    def unlock(self, *args, **kwargs):
 | 
			
		||||
        if self._locked:
 | 
			
		||||
            _tunnel_scheduler.unlock(*args, **kwargs)
 | 
			
		||||
            self._locked = False
 | 
			
		||||
| 
						 | 
				
			
			@ -111,7 +111,7 @@
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        // uri = scheme + host + ":" + port;
 | 
			
		||||
        uri = scheme + "{{ ws_host }}:{{ ws_port }}{{ ws_path }}";
 | 
			
		||||
        uri = scheme + "{{ ws_host }}:{{ ws_port }}";
 | 
			
		||||
 | 
			
		||||
        if (path) {
 | 
			
		||||
            uri += path[0] == '/' ? path : ('/' + path);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,144 +0,0 @@
 | 
			
		|||
#
 | 
			
		||||
# This class provide from VirtManager project, from console.py
 | 
			
		||||
# file.
 | 
			
		||||
#
 | 
			
		||||
# Copyright (C) 2006-2008 Red Hat, Inc.
 | 
			
		||||
# Copyright (C) 2006 Daniel P. Berrange <berrange@redhat.com>
 | 
			
		||||
# Copyright (C) 2010 Marc-André Lureau <marcandre.lureau@redhat.com>
 | 
			
		||||
#
 | 
			
		||||
# This program is free software; you can redistribute it and/or modify
 | 
			
		||||
# it under the terms of the GNU General Public License as published by
 | 
			
		||||
# the Free Software Foundation; either version 2 of the License, or
 | 
			
		||||
# (at your option) any later version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful,
 | 
			
		||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
# GNU General Public License for more details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License
 | 
			
		||||
# along with this program; if not, write to the Free Software
 | 
			
		||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 | 
			
		||||
# MA 02110-1301 USA.
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
import socket
 | 
			
		||||
import signal
 | 
			
		||||
import logging
 | 
			
		||||
from functools import reduce
 | 
			
		||||
 | 
			
		||||
class Tunnel(object):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.outfd = None
 | 
			
		||||
        self.errfd = None
 | 
			
		||||
        self.pid = None
 | 
			
		||||
 | 
			
		||||
    def open(self, connhost, connuser, connport, gaddr, gport, gsocket):
 | 
			
		||||
        if self.outfd is not None:
 | 
			
		||||
            return -1
 | 
			
		||||
 | 
			
		||||
        # Build SSH cmd
 | 
			
		||||
        argv = ["ssh", "ssh"]
 | 
			
		||||
        if connport:
 | 
			
		||||
            argv += ["-p", str(connport)]
 | 
			
		||||
 | 
			
		||||
        if connuser:
 | 
			
		||||
            argv += ['-l', connuser]
 | 
			
		||||
 | 
			
		||||
        argv += [connhost]
 | 
			
		||||
 | 
			
		||||
        # Build 'nc' command run on the remote host
 | 
			
		||||
        #
 | 
			
		||||
        # This ugly thing is a shell script to detect availability of
 | 
			
		||||
        # the -q option for 'nc': debian and suse based distros need this
 | 
			
		||||
        # flag to ensure the remote nc will exit on EOF, so it will go away
 | 
			
		||||
        # when we close the VNC tunnel. If it doesn't go away, subsequent
 | 
			
		||||
        # VNC connection attempts will hang.
 | 
			
		||||
        #
 | 
			
		||||
        # Fedora's 'nc' doesn't have this option, and apparently defaults
 | 
			
		||||
        # to the desired behavior.
 | 
			
		||||
        #
 | 
			
		||||
        if gsocket:
 | 
			
		||||
            nc_params = "-U %s" % gsocket
 | 
			
		||||
        else:
 | 
			
		||||
            nc_params = "%s %s" % (gaddr, gport)
 | 
			
		||||
 | 
			
		||||
        nc_cmd = (
 | 
			
		||||
            """nc -q 2>&1 | grep "requires an argument" >/dev/null;"""
 | 
			
		||||
            """if [ $? -eq 0 ] ; then"""
 | 
			
		||||
            """   CMD="nc -q 0 %(nc_params)s";"""
 | 
			
		||||
            """else"""
 | 
			
		||||
            """   CMD="nc %(nc_params)s";"""
 | 
			
		||||
            """fi;"""
 | 
			
		||||
            """eval "$CMD";""" %
 | 
			
		||||
            {'nc_params': nc_params})
 | 
			
		||||
 | 
			
		||||
        argv.append("sh -c")
 | 
			
		||||
        argv.append("'%s'" % nc_cmd)
 | 
			
		||||
 | 
			
		||||
        argv_str = reduce(lambda x, y: x + " " + y, argv[1:])
 | 
			
		||||
        logging.debug("Creating SSH tunnel: %s", argv_str)
 | 
			
		||||
 | 
			
		||||
        fds = socket.socketpair()
 | 
			
		||||
        errorfds = socket.socketpair()
 | 
			
		||||
 | 
			
		||||
        pid = os.fork()
 | 
			
		||||
        if pid == 0:
 | 
			
		||||
            fds[0].close()
 | 
			
		||||
            errorfds[0].close()
 | 
			
		||||
 | 
			
		||||
            os.close(0)
 | 
			
		||||
            os.close(1)
 | 
			
		||||
            os.close(2)
 | 
			
		||||
            os.dup2(fds[1].fileno(), 0)
 | 
			
		||||
            os.dup2(fds[1].fileno(), 1)
 | 
			
		||||
            os.dup2(errorfds[1].fileno(), 2)
 | 
			
		||||
            os.execlp(*argv)
 | 
			
		||||
            os._exit(1)
 | 
			
		||||
        else:
 | 
			
		||||
            fds[1].close()
 | 
			
		||||
            errorfds[1].close()
 | 
			
		||||
 | 
			
		||||
        logging.debug("Tunnel PID=%d OUTFD=%d ERRFD=%d",
 | 
			
		||||
                      pid, fds[0].fileno(), errorfds[0].fileno())
 | 
			
		||||
        errorfds[0].setblocking(False)
 | 
			
		||||
 | 
			
		||||
        self.outfd = fds[0]
 | 
			
		||||
        self.errfd = errorfds[0]
 | 
			
		||||
        self.pid = pid
 | 
			
		||||
 | 
			
		||||
        fd = fds[0].fileno()
 | 
			
		||||
        if fd < 0:
 | 
			
		||||
            raise SystemError("can't open a new tunnel: fd=%d" % fd)
 | 
			
		||||
        return fd
 | 
			
		||||
 | 
			
		||||
    def close(self):
 | 
			
		||||
        if self.outfd is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        logging.debug("Shutting down tunnel PID=%d OUTFD=%d ERRFD=%d",
 | 
			
		||||
                      self.pid, self.outfd.fileno(),
 | 
			
		||||
                      self.errfd.fileno())
 | 
			
		||||
        self.outfd.close()
 | 
			
		||||
        self.outfd = None
 | 
			
		||||
        self.errfd.close()
 | 
			
		||||
        self.errfd = None
 | 
			
		||||
 | 
			
		||||
        os.kill(self.pid, signal.SIGKILL)
 | 
			
		||||
        self.pid = None
 | 
			
		||||
 | 
			
		||||
    def get_err_output(self):
 | 
			
		||||
        errout = ""
 | 
			
		||||
        while True:
 | 
			
		||||
            try:
 | 
			
		||||
                new = self.errfd.recv(1024)
 | 
			
		||||
            except:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
            if not new:
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
            errout += new
 | 
			
		||||
 | 
			
		||||
        return errout
 | 
			
		||||
							
								
								
									
										14043
									
								
								static/js/novnc/vendor/sinon.js
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14043
									
								
								static/js/novnc/vendor/sinon.js
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue