diff --git a/src/index.json b/src/index.json index 76c3a3cf603..fe1bf8545a1 100644 --- a/src/index.json +++ b/src/index.json @@ -325,9 +325,9 @@ ], "webapp": [ { - "filename": "webapp-0.2.2-py2.py3-none-any.whl", - "sha256Digest": "db8bdba11e6814ceeff37063d0e7548dab42f9e33a64b4c4a5b7ffea0bc93884", - "downloadUrl": "https://github.com/panchagnula/azure-cli-extensions/raw/sisirap-extensions-whl/dist/webapp-0.2.2-py2.py3-none-any.whl", + "filename": "webapp-0.2.3-py2.py3-none-any.whl", + "sha256Digest": "c6e2c8fff7f3d88f9b7eb77327d67ab525ad9c8b8b27b3b004b565fac391c241", + "downloadUrl": "https://github.com/panchagnula/azure-cli-extensions/raw/sisirap-extensions-whl/dist/webapp-0.2.3-py2.py3-none-any.whl", "metadata": { "azext.isPreview": true, "azext.minCliCoreVersion": "2.0.24", @@ -349,7 +349,7 @@ "contacts": [ { "email": "sisirap@microsoft.com", - "name": "Sisira Panchagnula", + "name": "Sisira Panchagnula, Lukasz Stempniewicz", "role": "author" } ], @@ -366,7 +366,7 @@ "metadata_version": "2.0", "name": "webapp", "summary": "An Azure CLI Extension to manage appservice resources", - "version": "0.2.2" + "version": "0.2.3" } } ], diff --git a/src/webapp/azext_webapp/__init__.py b/src/webapp/azext_webapp/__init__.py index 468d7885dc6..ca5586e6aa2 100644 --- a/src/webapp/azext_webapp/__init__.py +++ b/src/webapp/azext_webapp/__init__.py @@ -23,6 +23,7 @@ def __init__(self, cli_ctx=None): def load_command_table(self, _): with self.command_group('webapp') as g: g.custom_command('up', 'create_deploy_webapp') + g.custom_command('remote-connection create', 'create_tunnel') g.custom_command('config snapshot list', 'list_webapp_snapshots') g.custom_command('config snapshot restore', 'restore_webapp_snapshot') return self.command_table @@ -33,6 +34,9 @@ def load_arguments(self, _): c.argument('dryrun', help="shows summary of the create and deploy operation instead of executing it", default=False, action='store_true') + with self.argument_context('webapp remote-connection create') as c: + c.argument('port', options_list=['--port', '-p'], help='Port for the remote connection', type=int) + c.argument('name', options_list=['--name', '-n'], help='Name of the webapp to connect to') with self.argument_context('webapp config snapshot list') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], help='Name of resource group.') c.argument('name', options_list=['--webapp-name', '-n'], help='Name of the webapp.') diff --git a/src/webapp/azext_webapp/_help.py b/src/webapp/azext_webapp/_help.py index 0d3a1d3e571..dbb591168b0 100644 --- a/src/webapp/azext_webapp/_help.py +++ b/src/webapp/azext_webapp/_help.py @@ -16,6 +16,15 @@ az webapp up -n MyUniqueAppName --dryrun \n az webapp up -n MyUniqueAppName -l locationName """ +helps['webapp remote-connection'] = """ + type: group + short-summary: Create a remote connection using a tcp tunnel to your web app +""" + +helps['webapp remote-connection create'] = """ + type: command + short-summary: Creates a remote connection using a tcp tunnel to your web app +""" helps['webapp config snapshot list'] = """ type: command diff --git a/src/webapp/azext_webapp/custom.py b/src/webapp/azext_webapp/custom.py index 3f076cbe59b..1f3397c8f9e 100644 --- a/src/webapp/azext_webapp/custom.py +++ b/src/webapp/azext_webapp/custom.py @@ -17,7 +17,9 @@ update_app_settings, _get_site_credential, _get_scm_url, - get_sku_name) + get_sku_name, + list_publish_profiles, + get_site_configs) from .create_util import ( zip_contents_from_dir, @@ -213,3 +215,35 @@ def restore_webapp_snapshot(cmd, resource_group, name, time, slot=None, restore_ return client.web_apps.recover_slot(resource_group, name, request, slot) else: return client.web_apps.recover(resource_group, name, request) + + +def _check_for_ready_tunnel(cmd, resource_group_name, name, remote_debugging, tunnel_server, slot=None): + from .tunnel import TunnelServer + default_port = tunnel_server.is_port_set_to_default() + if default_port is not remote_debugging: + return True + return False + + +def create_tunnel(cmd, resource_group_name, name, port, slot=None): + profiles = list_publish_profiles(cmd, resource_group_name, name, slot) + user_name = next(p['userName'] for p in profiles) + user_password = next(p['userPWD'] for p in profiles) + import time + import threading + from .tunnel import TunnelServer + tunnel_server = TunnelServer('', port, name, user_name, user_password) + config = get_site_configs(cmd, resource_group_name, name, slot) + + t = threading.Thread() + t.daemon = True + t.start() + if not _check_for_ready_tunnel(cmd, resource_group_name, name, config.remote_debugging_enabled, tunnel_server, slot): + logger.warning('Tunnel is not ready yet, please wait (may take up to 1 minute)') + while True: + time.sleep(1) + logger.warning('.') + if _check_for_ready_tunnel(cmd, resource_group_name, name, config.remote_debugging_enabled, tunnel_server, slot): + break + logger.warning('Tunnel is ready! Creating on port %s', port) + tunnel_server.start_server() diff --git a/src/webapp/azext_webapp/tunnel.py b/src/webapp/azext_webapp/tunnel.py new file mode 100644 index 00000000000..966d6e37839 --- /dev/null +++ b/src/webapp/azext_webapp/tunnel.py @@ -0,0 +1,186 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import sys +import ssl +import socket +import time +import traceback +import websocket +import logging as logs + +from contextlib import closing +from threading import Thread +from websocket import create_connection, WebSocket + +from knack.util import CLIError +from knack.log import get_logger +logger = get_logger(__name__) + + +class TunnelWebSocket(WebSocket): + def recv_frame(self): + frame = super(TunnelWebSocket, self).recv_frame() + logger.info('Received frame: %s', frame) + return frame + + def recv(self): + data = super(TunnelWebSocket, self).recv() + logger.info('Received websocket data: %s', data) + return data + + def send_binary(self, data): + super(TunnelWebSocket, self).send_binary(data) + + +class TunnelServer(object): + def __init__(self, local_addr, local_port, remote_addr, remote_user_name, remote_password): + self.local_addr = local_addr + self.local_port = local_port + if not self.is_port_open(): + raise CLIError('Defined port is currently unavailable') + self.remote_addr = remote_addr + self.remote_user_name = remote_user_name + self.remote_password = remote_password + logger.info('Creating a socket on port: %s', self.local_port) + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + logger.info('Setting socket options') + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + logger.info('Binding to socket on local address and port') + self.sock.bind((self.local_addr, self.local_port)) + logger.info('Finished initialization') + + def create_basic_auth(self): + from base64 import b64encode, b64decode + basic_auth_string = '{}:{}'.format(self.remote_user_name, self.remote_password).encode() + basic_auth_string = b64encode(basic_auth_string).decode('utf-8') + return basic_auth_string + + def is_port_open(self): + is_port_open = False + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + if sock.connect_ex(('', self.local_port)) == 0: + logger.info('Port %s is NOT open', self.local_port) + else: + logger.warning('Port %s is open', self.local_port) + is_port_open = True + return is_port_open + + def is_port_set_to_default(self): + import sys + import certifi + import urllib3 + try: + import urllib3.contrib.pyopenssl + urllib3.contrib.pyopenssl.inject_into_urllib3() + except ImportError: + pass + + http = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where()) + headers = urllib3.util.make_headers(basic_auth='{0}:{1}'.format(self.remote_user_name, self.remote_password)) + url = 'https://{}{}'.format(self.remote_addr, '.scm.azurewebsites.net/AppServiceTunnel/Tunnel.ashx?GetStatus') + r = http.request( + 'GET', + url, + headers=headers, + preload_content=False + ) + if r.status != 200: + raise CLIError("Failed to connect to '{}' with status code '{}' and reason '{}'".format(url, r.status, r.reason)) + msg = r.read().decode('utf-8') + logger.info('Status response message: %s', msg) + if 'FAIL' in msg.upper(): + logger.warning('WARNING - Remote debugging may not be setup properly. Reponse content: %s', msg) + if '2222' in msg: + return True + return False + + def listen(self): + self.sock.listen(100) + index = 0 + basic_auth_string = self.create_basic_auth() + while True: + self.client, address = self.sock.accept() + self.client.settimeout(60) + host = 'wss://{}{}'.format(self.remote_addr, '.scm.azurewebsites.net/AppServiceTunnel/Tunnel.ashx') + basic_auth_header = 'Authorization: Basic {}'.format(basic_auth_string) + cli_logger = get_logger() # get CLI logger which has the level set through command lines + is_verbose = any(handler.level <= logs.INFO for handler in cli_logger.handlers) + if is_verbose: + logger.info('Websocket tracing enabled') + websocket.enableTrace(True) + else: + logger.warning('Websocket tracing disabled, use --verbose flag to enable') + websocket.enableTrace(False) + self.ws = create_connection(host, + sockopt=((socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),), + class_=TunnelWebSocket, + header=[basic_auth_header], + sslopt={'cert_reqs': ssl.CERT_NONE}, + enable_multithread=True) + logger.info('Websocket, connected status: %s', self.ws.connected) + + index = index + 1 + logger.info('Got debugger connection... index: %s', index) + debugger_thread = Thread(target=self.listen_to_client, args=(self.client, self.ws, index)) + web_socket_thread = Thread(target=self.listen_to_web_socket, args=(self.client, self.ws, index)) + debugger_thread.start() + web_socket_thread.start() + logger.info('Both debugger and websocket threads started...') + logger.warning('Successfully started local server..') + debugger_thread.join() + web_socket_thread.join() + logger.info('Both debugger and websocket threads stopped...') + logger.warning('Stopped local server..') + + def listen_to_web_socket(self, client, ws_socket, index): + while True: + try: + logger.info('Waiting for websocket data, connection status: %s, index: %s', ws_socket.connected, index) + data = ws_socket.recv() + logger.info('Received websocket data: %s, index: %s', data, index) + if data: + # Set the response to echo back the recieved data + response = data + logger.info('Sending to debugger, response: %s, index: %s', response, index) + client.sendall(response) + logger.info('Done sending to debugger, index: %s', index) + else: + logger.info('Client disconnected!, index: %s', index) + client.close() + ws_socket.close() + break + except: + traceback.print_exc(file=sys.stdout) + client.close() + ws_socket.close() + return False + + def listen_to_client(self, client, ws_socket, index): + while True: + try: + logger.info('Waiting for debugger data, index: %s', index) + buf = bytearray(4096) + nbytes = client.recv_into(buf, 4096) + logger.info('Received debugger data, nbytes: %s, index: %s', nbytes, index) + if nbytes > 0: + responseData = buf[0:nbytes] + logger.info('Sending to websocket, response data: %s, index: %s', responseData, index) + ws_socket.send_binary(responseData) + logger.info('Done sending to websocket, index: %s', index) + else: + logger.warn('Client disconnected %s', index) + client.close() + ws_socket.close() + break + except: + traceback.print_exc(file=sys.stdout) + client.close() + ws_socket.close() + return False + + def start_server(self): + logger.warning('Starting local server..') + self.listen() diff --git a/src/webapp/setup.py b/src/webapp/setup.py index a5c94a56fb2..78727e9507d 100644 --- a/src/webapp/setup.py +++ b/src/webapp/setup.py @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = "0.2.2" +VERSION = "0.2.3" CLASSIFIERS = [ 'Development Status :: 4 - Beta', @@ -32,7 +32,7 @@ description='An Azure CLI Extension to manage appservice resources', long_description='An Azure CLI Extension to manage appservice resources', license='MIT', - author='Sisira Panchagnula', + author='Sisira Panchagnula, Lukasz Stempniewicz', author_email='sisirap@microsoft.com', url='https://github.com/Azure/azure-cli-extensions', classifiers=CLASSIFIERS,