def update_record(self, zone_id, record): """ Update a record. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return The DNS record (DNSRecord) """ if record.id is None: raise DNSAPIError('Need record ID to update record!') self._announce('update record') command = self._prepare() command.add_simple_command('updateRecord', recordId=record.id, recorddata=_encode_record(record, include_id=False)) try: return _create_record_from_encoding( self._execute(command, 'updateRecordResponse', dict)) except WSDLError as exc: raise_from( DNSAPIError('Error while updating record: {0}'.format( to_native(exc))), exc) except WSDLNetworkError as exc: raise_from( DNSAPIError('Network error while updating record: {0}'.format( to_native(exc))), exc)
def add_record(self, zone_id, record): """ Adds a new record to an existing zone. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return The created DNS record (DNSRecord) """ self._announce('add record') command = self._prepare() command.add_simple_command('addRecord', search=str(zone_id), recorddata=_encode_record(record, include_id=False)) try: return _create_record_from_encoding( self._execute(command, 'addRecordResponse', dict)) except WSDLError as exc: raise_from( DNSAPIError('Error while adding record: {0}'.format( to_native(exc))), exc) except WSDLNetworkError as exc: raise_from( DNSAPIError('Network error while adding record: {0}'.format( to_native(exc))), exc)
def get_zone_with_records_by_name(self, name, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): """ Given a zone name, return the zone contents with records if found. @param name: The zone name (string) @param prefix: The prefix to filter for, if provided. Since None is a valid value, the special constant NOT_PROVIDED indicates that we are not filtering. @param record_type: The record type to filter for, if provided @return The zone information with records (DNSZoneWithRecords), or None if not found """ self._announce('get zone') command = self._prepare() command.add_simple_command('getZone', sZoneName=name) try: return _create_zone_from_encoding(self._execute( command, 'getZoneResponse', dict), prefix=prefix, record_type=record_type) except WSDLError as exc: if exc.error_origin == 'server' and exc.error_message == 'zone not found': return None raise_from( DNSAPIError('Error while getting zone: {0}'.format( to_native(exc))), exc) except WSDLNetworkError as exc: raise_from( DNSAPIError('Network error while getting zone: {0}'.format( to_native(exc))), exc)
def create_hosttech_api(option_provider, http_helper): username = option_provider.get_option('hosttech_username') password = option_provider.get_option('hosttech_password') if username is not None and password is not None: if not HAS_LXML_ETREE: raise DNSAPIError('Needs lxml Python module (pip install lxml)') return HostTechWSDLAPI(http_helper, username, password, debug=False) token = option_provider.get_option('hosttech_token') if token is not None: return HostTechJSONAPI(http_helper, token) raise DNSAPIError('One of hosttech_token or both hosttech_username and hosttech_password must be provided!')
def _validate(self, result=None, info=None, expected=None, method='GET'): if info is None: raise DNSAPIError('Internal error: info needs to be provided') status = info['status'] url = info['url'] # Check expected status error_code = ERROR_CODES.get(status, UNKNOWN_ERROR) if expected is not None: if status not in expected: more = self._extract_error_message(result) raise DNSAPIError( 'Expected HTTP status {0} for {1} {2}, but got HTTP status {3} ({4}){5}' .format(', '.join(['{0}'.format(e) for e in expected]), method, url, status, error_code, more)) else: if status < 200 or status >= 300: more = self._extract_error_message(result) raise DNSAPIError( 'Expected successful HTTP status for {0} {1}, but got HTTP status {2} ({3}){4}' .format(method, url, status, error_code, more))
def delete_record(self, zone_id, record): """ Delete a record. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return True in case of success (boolean) """ if record.id is None: raise DNSAPIError('Need record ID to delete record!') dummy, info = self._delete('v1/records/{id}'.format(id=record.id), must_have_content=False, expected=[200, 404]) return info['status'] == 200
def update_record(self, zone_id, record): """ Update a record. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return The DNS record (DNSRecord) """ if record.id is None: raise DNSAPIError('Need record ID to update record!') data = _record_to_json(record, zone_id=zone_id) result, info = self._put('v1/records/{id}'.format(id=record.id), data=data, expected=[200, 422]) if info['status'] == 422: raise DNSAPIError( 'The updated {type} record with value "{target}" and TTL {ttl} has not been accepted by the server{message}'.format( type=record.type, target=record.target, ttl=record.ttl, message=self._extract_only_error_message(result), ) ) return _create_record_from_json(result['record'])
def delete_record(self, zone_id, record): """ Delete a record. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return True in case of success (boolean) """ if record.id is None: raise DNSAPIError('Need record ID to delete record!') self._announce('delete record') command = self._prepare() command.add_simple_command('deleteRecord', recordId=record.id) try: return self._execute(command, 'deleteRecordResponse', bool) except WSDLError as exc: raise_from( DNSAPIError('Error while deleting record: {0}'.format( to_native(exc))), exc) except WSDLNetworkError as exc: raise_from( DNSAPIError('Network error while deleting record: {0}'.format( to_native(exc))), exc)
def _validate(self, result=None, info=None, expected=None, method='GET'): super(HetznerAPI, self)._validate(result=result, info=info, expected=expected, method=method) if isinstance(result, dict): error = result.get('error') if isinstance(error, dict): status = error.get('code') if status is None: return url = info['url'] if expected is not None and status in expected: return error_code = ERROR_CODES.get(status, UNKNOWN_ERROR) more = self._extract_error_message(result) raise DNSAPIError( '{0} {1} resulted in API error {2} ({3}){4}'.format(method, url, status, error_code, more))
def add_records(self, records_per_zone_id, stop_early_on_errors=True): """ Add new records to an existing zone. @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. This might only work on some APIs. @return A dictionary mapping zone IDs to lists of tuples ``(record, created, failed)``. Here ``created`` indicates whether the record was created (``True``) or not (``False``). If it was created, ``record`` contains the record ID and ``failed`` is ``None``. If it was not created, ``failed`` should be a ``DNSAPIError`` instance indicating why it was not created. It is possible that the API only creates records if all succeed, in that case ``failed`` can be ``None`` even though ``created`` is ``False``. """ json_records = [] for zone_id, records in records_per_zone_id.items(): for record in records: json_records.append(_record_to_json(record, zone_id=zone_id)) data = {'records': json_records} # Error 422 means that at least one of the records was not valid result, info = self._post('v1/records/bulk', data=data, expected=[200, 422]) results_per_zone_id = {} # This is the list of invalid records that was detected before accepting the whole set for json_record in result.get('invalid_records') or []: record = _create_record_from_json(json_record, has_id=False) zone_id = json_record['zone_id'] self._append(results_per_zone_id, zone_id, (record, False, DNSAPIError( 'Creating {type} record "{target}" with TTL {ttl} for zone {zoneID} failed with unknown reason'.format( type=record.type, target=record.target, ttl=record.ttl, zoneID=zone_id)))) # This is the list of valid records that were not processed for json_record in result.get('valid_records') or []: record = _create_record_from_json(json_record, has_id=False) zone_id = json_record['zone_id'] self._append(results_per_zone_id, zone_id, (record, False, None)) # This is the list of correctly processed records for json_record in result.get('records') or []: record = _create_record_from_json(json_record) zone_id = json_record['zone_id'] self._append(results_per_zone_id, zone_id, (record, True, None)) return results_per_zone_id
def update_records(self, records_per_zone_id, stop_early_on_errors=True): """ Update multiple records. @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. This might only work on some APIs. @return A dictionary mapping zone IDs to lists of tuples ``(record, updated, failed)``. Here ``updated`` indicates whether the record was updated (``True``) or not (``False``). If it was not updated, ``failed`` should be a ``DNSAPIError`` instance. If it was updated, ``failed`` should be ``None``. It is possible that the API only updates records if all succeed, in that case ``failed`` can be ``None`` even though ``updated`` is ``False``. """ # Currently Hetzner's bulk update API seems to be broken, it always returns the error message # "An invalid response was received from the upstream server". That's why for now, we always # fall back to the default implementation. if True: # pylint: disable=using-constant-test return super(HetznerAPI, self).update_records(records_per_zone_id, stop_early_on_errors=stop_early_on_errors) json_records = [] for zone_id, records in records_per_zone_id.items(): for record in records: json_records.append(_record_to_json(record, zone_id=zone_id)) data = {'records': json_records} result, dummy = self._put('v1/records/bulk', data=data, expected=[200]) results_per_zone_id = {} for json_record in result.get('failed_records') or []: record = _create_record_from_json(json_record) zone_id = json_record['zone_id'] self._append(results_per_zone_id, zone_id, (record, False, DNSAPIError( 'Updating {type} record #{id} "{target}" with TTL {ttl} for zone {zoneID} failed with unknown reason'.format( type=record.type, id=record.id, target=record.target, ttl=record.ttl, zoneID=zone_id)))) for json_record in result.get('records') or []: record = _create_record_from_json(json_record) zone_id = json_record['zone_id'] self._append(results_per_zone_id, zone_id, (record, True, None)) return results_per_zone_id
def get_prefix(normalized_zone, provider_information, normalized_record=None, prefix=None): # If normalized_record is not specified, use prefix if normalized_record is None: if prefix is not None: prefix = provider_information.normalize_prefix( normalize_dns_name(prefix)) return (prefix + '.' + normalized_zone) if prefix else normalized_zone, prefix # Convert record to prefix if not normalized_record.endswith( '.' + normalized_zone) and normalized_record != normalized_zone: raise DNSAPIError('Record must be in zone') if normalized_record == normalized_zone: return normalized_record, None else: return normalized_record, normalized_record[:len(normalized_record) - len(normalized_zone) - 1]
def add_record(self, zone_id, record): """ Adds a new record to an existing zone. @param zone_id: The zone ID @param record: The DNS record (DNSRecord) @return The created DNS record (DNSRecord) """ data = _record_to_json(record, zone_id=zone_id) result, info = self._post('v1/records', data=data, expected=[200, 422]) if info['status'] == 422: raise DNSAPIError( 'The new {type} record with value "{target}" and TTL {ttl} has not been accepted by the server{message}'.format( type=record.type, target=record.target, ttl=record.ttl, message=self._extract_only_error_message(result), ) ) return _create_record_from_json(result['record'])
def _encode_record(record, include_id=False): result = { 'type': record.type, 'prefix': record.prefix, 'target': record.target, 'ttl': record.ttl, } if record.type in ('PTR', 'MX'): try: priority, target = record.target.split(' ', 1) result['priority'] = int(priority) result['target'] = target except Exception as e: raise DNSAPIError( 'Cannot split {0} record "{1}" into integer priority and target: {2}' .format(record.type, record.target, e)) else: result['priority'] = None if include_id: result['id'] = record.id return result
def _request(self, url, **kwargs): """Execute a HTTP request and handle common things like rate limiting.""" number_retries = 10 countdown = number_retries + 1 while True: content, info = self._http_helper.fetch_url(url, **kwargs) countdown -= 1 if info['status'] == 429: if countdown <= 0: break try: retry_after = max( min(float(_get_header_value(info, 'retry-after')), 60), 1) except (ValueError, TypeError): retry_after = 10 time.sleep(retry_after) continue return content, info raise DNSAPIError( 'Stopping after {0} failed retries with 429 Too Many Attempts'. format(number_retries))
def _execute(self, command, result_name, acceptable_types): if self._debug: pass # q.q('Request: {0}'.format(command)) try: result = command.execute(debug=self._debug) except WSDLError as e: if e.error_code == '998': raise DNSAPIAuthenticationError( 'Error on authentication ({0})'.format(e.error_message)) raise res = result.get_result(result_name) if isinstance(res, acceptable_types): if self._debug: pass # q.q('Extracted result: {0} (type {1})'.format(res, type(res))) return res if self._debug: pass # q.q('Result: {0}; extracted type {1}'.format(result, type(res))) raise DNSAPIError( 'Result has unexpected type {0} (expecting {1})!'.format( type(res), acceptable_types))
def _process_json_result(self, content, info, must_have_content=True, method='GET', expected=None): if isinstance(must_have_content, (list, tuple)): must_have_content = info['status'] in must_have_content # Check for unauthenticated if info['status'] == 401: message = 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' try: body = json.loads(content.decode('utf8')) if body['message']: message = '{0}: {1}'.format(message, body['message']) except Exception: pass raise DNSAPIAuthenticationError(message) if info['status'] == 403: message = 'Forbidden: you do not have access to this resource (HTTP status 403)' try: body = json.loads(content.decode('utf8')) if body['message']: message = '{0}: {1}'.format(message, body['message']) except Exception: pass raise DNSAPIAuthenticationError(message) # Check Content-Type header content_type = _get_header_value(info, 'content-type') if content_type != 'application/json' and ( content_type is None or not content_type.startswith('application/json;')): if must_have_content: raise DNSAPIError( '{0} {1} did not yield JSON data, but HTTP status code {2} with Content-Type "{3}" and data: {4}' .format(method, info['url'], info['status'], content_type, to_native(content))) self._validate(result=content, info=info, expected=expected, method=method) return None, info # Decode content as JSON try: result = json.loads(content.decode('utf8')) except Exception: if must_have_content: raise DNSAPIError( '{0} {1} did not yield JSON data, but HTTP status code {2} with data: {3}' .format(method, info['url'], info['status'], to_native(content))) self._validate(result=content, info=info, expected=expected, method=method) return None, info self._validate(result=result, info=info, expected=expected, method=method) return result, info