Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/webapp/azext_webapp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.')
Expand Down
9 changes: 9 additions & 0 deletions src/webapp/azext_webapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 app
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both have the same help message. How is this different from create?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work for function app as well? if not clarify in the help & i also recommend handling this (no support for function app) in the command with a useful error message

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first help definition corresponds to the help for the group, while the 'create' command help is the help only for the command itself. It just so happened to be the same for both. Should I change this?

"""

helps['webapp remote-connection create'] = """
type: command
short-summary: Create a remote connection using a tcp tunnel to your app
"""

helps['webapp config snapshot list'] = """
type: command
Expand Down
39 changes: 38 additions & 1 deletion src/webapp/azext_webapp/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -213,3 +215,38 @@ 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)

if not _check_for_ready_tunnel(cmd, resource_group_name, name, config.remote_debugging_enabled, tunnel_server, slot):
print('Tunnel is not ready yet, please wait (may take up to 1 minute)')

t = threading.Thread()
t.daemon = True
t.start()

while True:
time.sleep(1)
print('.')
if _check_for_ready_tunnel(cmd, resource_group_name, name, config.remote_debugging_enabled, slot):
break
print('Tunnel is ready! Creating on port {}'.format(port))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use logger.warning for such messages. Same as above.

tunnel_server.start_server()
176 changes: 176 additions & 0 deletions src/webapp/azext_webapp/tunnel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# --------------------------------------------------------------------------------------------
# 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

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 '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)
websocket.enableTrace(True)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either remove this or have a --verbose flag that will print this.

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...')
print('Successfully started local server..')
debugger_thread.join()
web_socket_thread.join()
logger.info('Both debugger and websocket threads stopped...')
print('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):
print('Starting local server..')
self.listen()
2 changes: 1 addition & 1 deletion src/webapp/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is causing CI build break. Update the contacts under index.json as well.

author_email='[email protected]',
url='https://github.com/Azure/azure-cli-extensions',
classifiers=CLASSIFIERS,
Expand Down