1# -------------------------------------------------------------------------------------------- 2# Copyright (c) Microsoft Corporation. All rights reserved. 3# Licensed under the MIT License. See License.txt in the project root for license information. 4# -------------------------------------------------------------------------------------------- 5 6from azure.cli.core.azclierror import ValidationError 7from azure.cli.core.commands import DeploymentOutputLongRunningOperation 8from azure.cli.core.commands.client_factory import get_mgmt_service_client 9from azure.cli.core.profiles import ResourceType 10from azure.cli.core.util import sdk_no_wait, random_string 11from azure.mgmt.web.models import NameIdentifier 12from knack.util import CLIError 13from knack.log import get_logger 14 15from ._client_factory import web_client_factory 16 17logger = get_logger(__name__) 18 19 20def create_domain(cmd, resource_group_name, hostname, contact_info, privacy=True, auto_renew=True, # pylint: disable=too-many-locals 21 accept_terms=False, tags=None, dryrun=False, no_wait=False): 22 from azure.cli.core.commands.arm import ArmTemplateBuilder 23 from azure.cli.command_modules.appservice._template_builder import (build_dns_zone, build_domain) 24 from datetime import datetime 25 import socket 26 import json 27 28 tags = tags or {} 29 30 if not accept_terms and not dryrun: 31 raise CLIError("To purchase and create your custom domain '{}', you must view the terms and conditions " 32 "using the command `az appservice domain show-terms`, and accept these terms and " 33 "conditions using the --accept-terms flag".format(hostname)) 34 35 try: 36 contact_info = json.loads(contact_info) 37 except Exception: 38 raise CLIError('Unable to load contact info. Please verify the path to your contact info file, ' 39 'and that the format matches the sample found at the following link: ' 40 'https://github.com/AzureAppServiceCLI/appservice_domains_templates' 41 '/blob/master/contact_info.json') 42 contact_info = verify_contact_info_and_format(contact_info) 43 44 current_time = str(datetime.utcnow()).replace('+00:00', 'Z') 45 local_ip_address = '' 46 try: 47 local_ip_address = socket.gethostbyname(socket.gethostname()) 48 except: 49 raise CLIError("Unable to get IP address") 50 51 web_client = web_client_factory(cmd.cli_ctx) 52 hostname_availability = web_client.domains.check_availability(NameIdentifier(name=hostname)) 53 54 if not hostname_availability.available: 55 raise ValidationError("Custom domain name '{}' is not available. Please try again " 56 "with a new hostname.".format(hostname)) 57 58 tld = '.'.join(hostname.split('.')[1:]) 59 TopLevelDomainAgreementOption = cmd.get_models('TopLevelDomainAgreementOption') 60 domain_agreement_option = TopLevelDomainAgreementOption(include_privacy=bool(privacy), for_transfer=False) 61 agreements = web_client.top_level_domains.list_agreements(name=tld, agreement_option=domain_agreement_option) 62 agreement_keys = [agreement.agreement_key for agreement in agreements] 63 64 if dryrun: 65 logger.warning("Custom domain will be purchased with the below configuration. Re-run command " 66 "without the --dryrun flag to purchase & create the custom domain") 67 dry_run_params = contact_info.copy() 68 dry_run_params.update({ 69 "hostname": hostname, 70 "resource_group_name": resource_group_name, 71 "privacy": bool(privacy), 72 "auto_renew": bool(auto_renew), 73 "agreement_keys": agreement_keys, 74 "accept_terms": bool(accept_terms), 75 "hostname_available": bool(hostname_availability.available), 76 "price": "$11.99 USD" if hostname_availability.available else "N/A" 77 }) 78 dry_run_str = r""" { 79 "hostname" : "%(hostname)s", 80 "resource_group" : "%(resource_group_name)s", 81 "contact_info": { 82 "address1": "%(address1)s", 83 "address2": "%(address2)s", 84 "city": "%(city)s", 85 "country": "%(country)s", 86 "postal_code": "%(postal_code)s", 87 "state": "%(state)s", 88 "email": "%(email)s", 89 "fax": "%(fax)s", 90 "job_title": "%(job_title)s", 91 "name_first": "%(name_first)s", 92 "name_last": "%(name_last)s", 93 "name_middle": "%(name_middle)s", 94 "organization": "%(organization)s", 95 "phone": "%(phone)s" 96 }, 97 "privacy": "%(privacy)s", 98 "auto_renew": "%(auto_renew)s", 99 "accepted_hostname_purchase_terms": "%(accept_terms)s", 100 "agreement_keys": "%(agreement_keys)s", 101 "hostname_available": "%(hostname_available)s", 102 "price": "%(price)s" 103 } 104 """ % dry_run_params 105 return json.loads(dry_run_str) 106 107 dns_zone_id = "[resourceId('Microsoft.Network/dnszones', '{}')]".format(hostname) 108 109 master_template = ArmTemplateBuilder() 110 dns_zone_resource = build_dns_zone(hostname) 111 domain_resource = build_domain(domain_name=hostname, 112 local_ip_address=local_ip_address, 113 current_time=current_time, 114 address1=contact_info['address1'], 115 address2=contact_info['address2'], 116 city=contact_info['city'], 117 country=contact_info['country'], 118 postal_code=contact_info['postal_code'], 119 state=contact_info['state'], 120 email=contact_info['email'], 121 fax=contact_info['fax'], 122 job_title=contact_info['job_title'], 123 name_first=contact_info['name_first'], 124 name_last=contact_info['name_last'], 125 name_middle=contact_info['name_middle'], 126 organization=contact_info['organization'], 127 phone=contact_info['phone'], 128 dns_zone_id=dns_zone_id, 129 privacy=privacy, 130 auto_renew=auto_renew, 131 agreement_keys=agreement_keys, 132 tags=tags, 133 dependencies=[dns_zone_id]) 134 135 master_template.add_resource(dns_zone_resource) 136 master_template.add_resource(domain_resource) 137 138 template = master_template.build() 139 140 # deploy ARM template 141 deployment_name = 'domain_deploy_' + random_string(32) 142 client = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES).deployments 143 DeploymentProperties = cmd.get_models('DeploymentProperties', resource_type=ResourceType.MGMT_RESOURCE_RESOURCES) 144 properties = DeploymentProperties(template=template, parameters={}, mode='incremental') 145 Deployment = cmd.get_models('Deployment', resource_type=ResourceType.MGMT_RESOURCE_RESOURCES) 146 deployment = Deployment(properties=properties) 147 deployment_result = DeploymentOutputLongRunningOperation(cmd.cli_ctx)( 148 sdk_no_wait(no_wait, client.begin_create_or_update, resource_group_name, deployment_name, deployment)) 149 150 return deployment_result 151 152 153def show_domain_purchase_terms(cmd, hostname): 154 from azure.mgmt.web.models import TopLevelDomainAgreementOption 155 domain_identifier = NameIdentifier(name=hostname) 156 web_client = web_client_factory(cmd.cli_ctx) 157 hostname_availability = web_client.domains.check_availability(domain_identifier) 158 if not hostname_availability.available: # api returns false 159 raise CLIError(" hostname: '{}' in not available. Please enter a valid hostname.".format(hostname)) 160 161 tld = '.'.join(hostname.split('.')[1:]) 162 domain_agreement_option = TopLevelDomainAgreementOption(include_privacy=True, for_transfer=True) 163 agreements = web_client.top_level_domains.list_agreements(name=tld, agreement_option=domain_agreement_option) 164 165 terms = { 166 "hostname": hostname, 167 "hostname_available": hostname_availability.available, 168 "hostname_purchase_price": "$11.99 USD" if hostname_availability.available else None, 169 "legal_terms": 170 "https://storedomainslegalterms.blob.core.windows.net/domain-purchase-legal-terms/legal_terms.txt", 171 "GoDaddy_domain_registration_and_customer_service_agreement": 172 "https://www.godaddy.com/legal/agreements/domain-name-registration-agreement", 173 "ICANN_rights_and_responsibilities_policy": 174 "https://www.icann.org/resources/pages/responsibilities-2014-03-14-en" 175 } 176 177 for agreement in agreements: 178 terms['_'.join(agreement.title.lower().split(' '))] = agreement.url 179 180 return terms 181 182 183def verify_contact_info_and_format(contact_info): 184 # pylint: disable=too-many-statements, too-many-branches 185 return_contact_info = {} 186 required_keys = ['name_first', 'name_last', 'email', 'phone', 'address1', 'country', 'state', 'city', 'postal_code'] 187 for required_key in required_keys: 188 if not (required_key in contact_info and 'value' in contact_info[required_key] and contact_info[required_key]['value']): # pylint: disable=line-too-long 189 raise CLIError("Missing value in contact info: {}".format(required_key)) 190 191 import re 192 193 # GoDaddy regex 194 _phone_regex = r"^\+([0-9]){1,3}\.([0-9]\ ?){5,14}$" 195 _person_name_regex = r"^[a-zA-Z0-9\-.,\(\)\\\@&' ]*$" 196 _email_regex = ( 197 r"^(?:[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+\.)*[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+@(?:(?:(?:" 198 r"[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!\.)){0,61}[a-zA-Z0-9]?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!$)){0,61}[a-zA-Z0-9]" 199 r"?)|(?:\[(?:(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\]))$" 200 ) 201 _city_regex = r"^[a-zA-Z0-9\-.,' ]+$" 202 _address_regex = r"^[a-zA-Z0-9\-.,'#*@/& ]+$" 203 _postal_code_regex = r"^[a-zA-Z0-9 .\\-]+$" 204 205 # Validate required values 206 if not re.match(_phone_regex, contact_info['phone']['value']): 207 raise CLIError('Invalid value: phone number must match pattern +areacode.phonenumber, ' 208 'for example "+1.0000000000"') 209 return_contact_info['phone'] = contact_info['phone']['value'] 210 211 if not re.match(_person_name_regex, contact_info['name_first']['value']): 212 raise CLIError('Invalid value: first name') 213 if len(contact_info['name_first']['value']) > 30: 214 raise CLIError('Invalid value: first name must have a length of at most 30') 215 return_contact_info['name_first'] = contact_info['name_first']['value'] 216 217 if not re.match(_person_name_regex, contact_info['name_last']['value']): 218 raise CLIError('Invalid value: last name') 219 if len(contact_info['name_last']['value']) > 30: 220 raise CLIError('Invalid value: last name must have a length of at most 30') 221 return_contact_info['name_last'] = contact_info['name_last']['value'] 222 223 if not re.match(_email_regex, contact_info['email']['value']): 224 raise CLIError('Invalid value: email') 225 return_contact_info['email'] = contact_info['email']['value'] 226 227 if not re.match(_address_regex, contact_info['address1']['value']): 228 raise CLIError('Invalid value: address1') 229 if len(contact_info['address1']['value']) > 41: 230 raise CLIError('Invalid value: address1 must have a length of at most 41') 231 return_contact_info['address1'] = contact_info['address1']['value'] 232 233 allowed_countries = [ 234 "AC", "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", 235 "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", 236 "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CV", 237 "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", 238 "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", 239 "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", 240 "IM", "IN", "IO", "IQ", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", 241 "KV", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", 242 "MD", "ME", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", 243 "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", 244 "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", 245 "RU", "RW", "SA", "SB", "SC", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "ST", 246 "SV", "SX", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TP", "TR", "TT", 247 "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", 248 "WS", "YE", "YT", "ZA", "ZM", "ZW" 249 ] 250 if contact_info['country']['value'] not in allowed_countries: 251 raise CLIError('Invalid value: country is not one of the following values: {}'.format(allowed_countries)) 252 return_contact_info['country'] = contact_info['country']['value'] 253 254 if not 2 <= len(contact_info['state']['value']) <= 30: 255 raise CLIError('Invalid value: state must have a length between 2 and 30') 256 return_contact_info['state'] = contact_info['state']['value'] 257 258 if not re.match(_city_regex, contact_info['city']['value']): 259 raise CLIError('Invalid value: city') 260 if len(contact_info['city']['value']) > 30: 261 raise CLIError('Invalid value: city must have a length of at most 30') 262 return_contact_info['city'] = contact_info['city']['value'] 263 264 if not re.match(_postal_code_regex, contact_info['postal_code']['value']): 265 raise CLIError('Invalid value: postal code') 266 if not 2 <= len(contact_info['postal_code']['value']) <= 10: 267 raise CLIError('Invalid value: postal code must have a length between 2 and 10') 268 return_contact_info['postal_code'] = contact_info['postal_code']['value'] 269 270 # Validate optional params 271 if 'fax' in contact_info and 'value' in contact_info['fax'] and contact_info['fax']['value']: 272 if not re.match(_phone_regex, contact_info['fax']['value']): 273 raise CLIError('Invalid value: fax number must match pattern +areacode.phonenumber, ' 274 'for example "+1.0000000000"') 275 return_contact_info['fax'] = contact_info['fax']['value'] 276 else: 277 return_contact_info['fax'] = '' 278 279 if 'job_title' in contact_info and 'value' in contact_info['job_title'] and contact_info['job_title']['value']: 280 if len(contact_info['job_title']['value']) > 41: 281 raise CLIError('Invalid value: job title must have a length of at most 41') 282 return_contact_info['job_title'] = contact_info['job_title']['value'] 283 else: 284 return_contact_info['job_title'] = '' 285 286 if ('name_middle' in contact_info and 'value' in contact_info['name_middle'] and contact_info['name_middle']['value']): # pylint: disable=line-too-long 287 if not re.match(_person_name_regex, contact_info['name_middle']['value']): 288 raise CLIError('Invalid value: middle name') 289 if len(contact_info['name_middle']['value']) > 30: 290 raise CLIError('Invalid value: middle name must have a length of at most 30') 291 return_contact_info['name_middle'] = contact_info['name_middle']['value'] 292 else: 293 return_contact_info['name_middle'] = '' 294 295 if ('organization' in contact_info and 'value' in contact_info['organization'] and contact_info['organization']['value']): # pylint: disable=line-too-long 296 if len(contact_info['organization']['value']) > 41: 297 raise CLIError('Invalid value: organization must have a length of at most 41') 298 return_contact_info['organization'] = contact_info['organization']['value'] 299 else: 300 return_contact_info['organization'] = '' 301 302 if 'address2' in contact_info and 'value' in contact_info['address2'] and contact_info['address2']['value']: 303 if not re.match(_address_regex, contact_info['address2']['value']): 304 raise CLIError('Invalid value: address2') 305 if len(contact_info['address2']['value']) > 41: 306 raise CLIError('Invalid value: address2 must have a length of at most 41') 307 return_contact_info['address2'] = contact_info['address2']['value'] 308 else: 309 return_contact_info['address2'] = '' 310 311 return return_contact_info 312