Skip to content

Commit 832f074

Browse files
committed
Release of version 1.4.0
1 parent 47363e9 commit 832f074

File tree

22 files changed

+1136
-135
lines changed

22 files changed

+1136
-135
lines changed

AWSIoTPythonSDK/MQTTLib.py

Lines changed: 449 additions & 98 deletions
Large diffs are not rendered by default.

AWSIoTPythonSDK/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
__version__ = "1.3.1"
1+
__version__ = "1.4.0"
22

33

AWSIoTPythonSDK/core/greengrass/discovery/providers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ class DiscoveryInfoProvider(object):
3939

4040
REQUEST_TYPE_PREFIX = "GET "
4141
PAYLOAD_PREFIX = "/greengrass/discover/thing/"
42-
PAYLOAD_SUFFIX = " HTTP/1.1\r\n\r\n" # Space in the front
42+
PAYLOAD_SUFFIX = " HTTP/1.1\r\n" # Space in the front
43+
HOST_PREFIX = "Host: "
44+
HOST_SUFFIX = "\r\n\r\n"
4345
HTTP_PROTOCOL = r"HTTP/1.1 "
4446
CONTENT_LENGTH = r"content-length: "
4547
CONTENT_LENGTH_PATTERN = CONTENT_LENGTH + r"([0-9]+)\r\n"
@@ -311,7 +313,10 @@ def _send_discovery_request(self, ssl_sock, thing_name):
311313
request = self.REQUEST_TYPE_PREFIX + \
312314
self.PAYLOAD_PREFIX + \
313315
thing_name + \
314-
self.PAYLOAD_SUFFIX
316+
self.PAYLOAD_SUFFIX + \
317+
self.HOST_PREFIX + \
318+
self._host + ":" + str(self._port) + \
319+
self.HOST_SUFFIX
315320
self._logger.debug("Sending discover request: " + request)
316321

317322
start_time = time.time()

AWSIoTPythonSDK/core/jobs/__init__.py

Whitespace-only changes.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# /*
2+
# * Copyright 2010-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
# *
4+
# * Licensed under the Apache License, Version 2.0 (the "License").
5+
# * You may not use this file except in compliance with the License.
6+
# * A copy of the License is located at
7+
# *
8+
# * http://aws.amazon.com/apache2.0
9+
# *
10+
# * or in the "license" file accompanying this file. This file is distributed
11+
# * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
# * express or implied. See the License for the specific language governing
13+
# * permissions and limitations under the License.
14+
# */
15+
16+
import json
17+
18+
_BASE_THINGS_TOPIC = "$aws/things/"
19+
_NOTIFY_OPERATION = "notify"
20+
_NOTIFY_NEXT_OPERATION = "notify-next"
21+
_GET_OPERATION = "get"
22+
_START_NEXT_OPERATION = "start-next"
23+
_WILDCARD_OPERATION = "+"
24+
_UPDATE_OPERATION = "update"
25+
_ACCEPTED_REPLY = "accepted"
26+
_REJECTED_REPLY = "rejected"
27+
_WILDCARD_REPLY = "#"
28+
29+
#Members of this enum are tuples
30+
_JOB_ID_REQUIRED_INDEX = 1
31+
_JOB_OPERATION_INDEX = 2
32+
33+
_STATUS_KEY = 'status'
34+
_STATUS_DETAILS_KEY = 'statusDetails'
35+
_EXPECTED_VERSION_KEY = 'expectedVersion'
36+
_EXEXCUTION_NUMBER_KEY = 'executionNumber'
37+
_INCLUDE_JOB_EXECUTION_STATE_KEY = 'includeJobExecutionState'
38+
_INCLUDE_JOB_DOCUMENT_KEY = 'includeJobDocument'
39+
_CLIENT_TOKEN_KEY = 'clientToken'
40+
41+
#The type of job topic.
42+
class jobExecutionTopicType(object):
43+
JOB_UNRECOGNIZED_TOPIC = (0, False, '')
44+
JOB_GET_PENDING_TOPIC = (1, False, _GET_OPERATION)
45+
JOB_START_NEXT_TOPIC = (2, False, _START_NEXT_OPERATION)
46+
JOB_DESCRIBE_TOPIC = (3, True, _GET_OPERATION)
47+
JOB_UPDATE_TOPIC = (4, True, _UPDATE_OPERATION)
48+
JOB_NOTIFY_TOPIC = (5, False, _NOTIFY_OPERATION)
49+
JOB_NOTIFY_NEXT_TOPIC = (6, False, _NOTIFY_NEXT_OPERATION)
50+
JOB_WILDCARD_TOPIC = (7, False, _WILDCARD_OPERATION)
51+
52+
#Members of this enum are tuples
53+
_JOB_SUFFIX_INDEX = 1
54+
#The type of reply topic, or #JOB_REQUEST_TYPE for topics that are not replies.
55+
class jobExecutionTopicReplyType(object):
56+
JOB_UNRECOGNIZED_TOPIC_TYPE = (0, '')
57+
JOB_REQUEST_TYPE = (1, '')
58+
JOB_ACCEPTED_REPLY_TYPE = (2, '/' + _ACCEPTED_REPLY)
59+
JOB_REJECTED_REPLY_TYPE = (3, '/' + _REJECTED_REPLY)
60+
JOB_WILDCARD_REPLY_TYPE = (4, '/' + _WILDCARD_REPLY)
61+
62+
_JOB_STATUS_INDEX = 1
63+
class jobExecutionStatus(object):
64+
JOB_EXECUTION_STATUS_NOT_SET = (0, None)
65+
JOB_EXECUTION_QUEUED = (1, 'QUEUED')
66+
JOB_EXECUTION_IN_PROGRESS = (2, 'IN_PROGRESS')
67+
JOB_EXECUTION_FAILED = (3, 'FAILED')
68+
JOB_EXECUTION_SUCCEEDED = (4, 'SUCCEEDED')
69+
JOB_EXECUTION_CANCELED = (5, 'CANCELED')
70+
JOB_EXECUTION_REJECTED = (6, 'REJECTED')
71+
JOB_EXECUTION_UNKNOWN_STATUS = (99, None)
72+
73+
def _getExecutionStatus(jobStatus):
74+
try:
75+
return jobStatus[_JOB_STATUS_INDEX]
76+
except KeyError:
77+
return None
78+
79+
def _isWithoutJobIdTopicType(srcJobExecTopicType):
80+
return (srcJobExecTopicType == jobExecutionTopicType.JOB_GET_PENDING_TOPIC or srcJobExecTopicType == jobExecutionTopicType.JOB_START_NEXT_TOPIC
81+
or srcJobExecTopicType == jobExecutionTopicType.JOB_NOTIFY_TOPIC or srcJobExecTopicType == jobExecutionTopicType.JOB_NOTIFY_NEXT_TOPIC)
82+
83+
class thingJobManager:
84+
def __init__(self, thingName, clientToken = None):
85+
self._thingName = thingName
86+
self._clientToken = clientToken
87+
88+
def getJobTopic(self, srcJobExecTopicType, srcJobExecTopicReplyType=jobExecutionTopicReplyType.JOB_REQUEST_TYPE, jobId=None):
89+
if self._thingName is None:
90+
return None
91+
92+
#Verify topics that only support request type, actually have request type specified for reply
93+
if (srcJobExecTopicType == jobExecutionTopicType.JOB_NOTIFY_TOPIC or srcJobExecTopicType == jobExecutionTopicType.JOB_NOTIFY_NEXT_TOPIC) and srcJobExecTopicReplyType != jobExecutionTopicReplyType.JOB_REQUEST_TYPE:
94+
return None
95+
96+
#Verify topics that explicitly do not want a job ID do not have one specified
97+
if (jobId is not None and _isWithoutJobIdTopicType(srcJobExecTopicType)):
98+
return None
99+
100+
#Verify job ID is present if the topic requires one
101+
if jobId is None and srcJobExecTopicType[_JOB_ID_REQUIRED_INDEX]:
102+
return None
103+
104+
#Ensure the job operation is a non-empty string
105+
if srcJobExecTopicType[_JOB_OPERATION_INDEX] == '':
106+
return None
107+
108+
if srcJobExecTopicType[_JOB_ID_REQUIRED_INDEX]:
109+
return '{0}{1}/jobs/{2}/{3}{4}'.format(_BASE_THINGS_TOPIC, self._thingName, str(jobId), srcJobExecTopicType[_JOB_OPERATION_INDEX], srcJobExecTopicReplyType[_JOB_SUFFIX_INDEX])
110+
elif srcJobExecTopicType == jobExecutionTopicType.JOB_WILDCARD_TOPIC:
111+
return '{0}{1}/jobs/#'.format(_BASE_THINGS_TOPIC, self._thingName)
112+
else:
113+
return '{0}{1}/jobs/{2}{3}'.format(_BASE_THINGS_TOPIC, self._thingName, srcJobExecTopicType[_JOB_OPERATION_INDEX], srcJobExecTopicReplyType[_JOB_SUFFIX_INDEX])
114+
115+
def serializeJobExecutionUpdatePayload(self, status, statusDetails=None, expectedVersion=0, executionNumber=0, includeJobExecutionState=False, includeJobDocument=False):
116+
executionStatus = _getExecutionStatus(status)
117+
if executionStatus is None:
118+
return None
119+
payload = {_STATUS_KEY: executionStatus}
120+
if statusDetails:
121+
payload[_STATUS_DETAILS_KEY] = statusDetails
122+
if expectedVersion > 0:
123+
payload[_EXPECTED_VERSION_KEY] = str(expectedVersion)
124+
if executionNumber > 0:
125+
payload[_EXEXCUTION_NUMBER_KEY] = str(executionNumber)
126+
if includeJobExecutionState:
127+
payload[_INCLUDE_JOB_EXECUTION_STATE_KEY] = True
128+
if includeJobDocument:
129+
payload[_INCLUDE_JOB_DOCUMENT_KEY] = True
130+
if self._clientToken is not None:
131+
payload[_CLIENT_TOKEN_KEY] = self._clientToken
132+
return json.dumps(payload)
133+
134+
def serializeDescribeJobExecutionPayload(self, executionNumber=0, includeJobDocument=True):
135+
payload = {_INCLUDE_JOB_DOCUMENT_KEY: includeJobDocument}
136+
if executionNumber > 0:
137+
payload[_EXEXCUTION_NUMBER_KEY] = executionNumber
138+
if self._clientToken is not None:
139+
payload[_CLIENT_TOKEN_KEY] = self._clientToken
140+
return json.dumps(payload)
141+
142+
def serializeStartNextPendingJobExecutionPayload(self, statusDetails=None):
143+
payload = {}
144+
if self._clientToken is not None:
145+
payload[_CLIENT_TOKEN_KEY] = self._clientToken
146+
if statusDetails is not None:
147+
payload[_STATUS_DETAILS_KEY] = statusDetails
148+
return json.dumps(payload)
149+
150+
def serializeClientTokenPayload(self):
151+
return json.dumps({_CLIENT_TOKEN_KEY: self._clientToken}) if self._clientToken is not None else '{}'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# /*
2+
# * Copyright 2010-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
# *
4+
# * Licensed under the Apache License, Version 2.0 (the "License").
5+
# * You may not use this file except in compliance with the License.
6+
# * A copy of the License is located at
7+
# *
8+
# * http://aws.amazon.com/apache2.0
9+
# *
10+
# * or in the "license" file accompanying this file. This file is distributed
11+
# * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
# * express or implied. See the License for the specific language governing
13+
# * permissions and limitations under the License.
14+
# */
15+
16+
17+
try:
18+
import ssl
19+
except:
20+
ssl = None
21+
22+
23+
class SSLContextBuilder(object):
24+
25+
def __init__(self):
26+
self.check_supportability()
27+
self._ssl_context = ssl.create_default_context()
28+
29+
def check_supportability(self):
30+
if ssl is None:
31+
raise RuntimeError("This platform has no SSL/TLS.")
32+
if not hasattr(ssl, "SSLContext"):
33+
raise NotImplementedError("This platform does not support SSLContext. Python 2.7.10+/3.5+ is required.")
34+
if not hasattr(ssl.SSLContext, "set_alpn_protocols"):
35+
raise NotImplementedError("This platform does not support ALPN as TLS extensions. Python 2.7.10+/3.5+ is required.")
36+
37+
def with_protocol(self, protocol):
38+
self._ssl_context.protocol = protocol
39+
return self
40+
41+
def with_ca_certs(self, ca_certs):
42+
self._ssl_context.load_verify_locations(ca_certs)
43+
return self
44+
45+
def with_cert_key_pair(self, cert_file, key_file):
46+
self._ssl_context.load_cert_chain(cert_file, key_file)
47+
return self
48+
49+
def with_cert_reqs(self, cert_reqs):
50+
self._ssl_context.verify_mode = cert_reqs
51+
return self
52+
53+
def with_check_hostname(self, check_hostname):
54+
self._ssl_context.check_hostname = check_hostname
55+
return self
56+
57+
def with_ciphers(self, ciphers):
58+
if ciphers is not None:
59+
self._ssl_context.set_ciphers(ciphers) # set_ciphers() does not allow None input. Use default (do nothing) if None
60+
return self
61+
62+
def with_alpn_protocols(self, alpn_protocols):
63+
self._ssl_context.set_alpn_protocols(alpn_protocols)
64+
return self
65+
66+
def build(self):
67+
return self._ssl_context

AWSIoTPythonSDK/core/protocol/connection/cores.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
# when to increase it and when to reset it.
1919

2020

21+
import re
2122
import sys
2223
import ssl
24+
import errno
2325
import struct
2426
import socket
2527
import base64
@@ -30,6 +32,7 @@
3032
from datetime import datetime
3133
import hashlib
3234
import hmac
35+
from AWSIoTPythonSDK.exception.AWSIoTExceptions import ClientError
3336
from AWSIoTPythonSDK.exception.AWSIoTExceptions import wssNoKeyInEnvironmentError
3437
from AWSIoTPythonSDK.exception.AWSIoTExceptions import wssHandShakeError
3538
from AWSIoTPythonSDK.core.protocol.internal.defaults import DEFAULT_CONNECT_DISCONNECT_TIMEOUT_SEC
@@ -240,12 +243,13 @@ def createWebsocketEndpoint(self, host, port, region, method, awsServiceName, pa
240243
amazonDateSimple = amazonDate[0] # Unicode in 3.x
241244
amazonDateComplex = amazonDate[1] # Unicode in 3.x
242245
allKeys = self._checkIAMCredentials() # Unicode in 3.x
243-
hasCredentialsNecessaryForWebsocket = "aws_access_key_id" in allKeys.keys() and "aws_secret_access_key" in allKeys.keys()
244-
if not hasCredentialsNecessaryForWebsocket:
245-
return ""
246+
if not self._hasCredentialsNecessaryForWebsocket(allKeys):
247+
raise wssNoKeyInEnvironmentError()
246248
else:
249+
# Because of self._hasCredentialsNecessaryForWebsocket(...), keyID and secretKey should not be None from here
247250
keyID = allKeys["aws_access_key_id"]
248251
secretKey = allKeys["aws_secret_access_key"]
252+
# amazonDateSimple and amazonDateComplex are guaranteed not to be None
249253
queryParameters = "X-Amz-Algorithm=AWS4-HMAC-SHA256" + \
250254
"&X-Amz-Credential=" + keyID + "%2F" + amazonDateSimple + "%2F" + region + "%2F" + awsServiceName + "%2Faws4_request" + \
251255
"&X-Amz-Date=" + amazonDateComplex + \
@@ -264,12 +268,23 @@ def createWebsocketEndpoint(self, host, port, region, method, awsServiceName, pa
264268
# generate url
265269
url = "wss://" + host + ":" + str(port) + path + '?' + queryParameters + "&X-Amz-Signature=" + signature
266270
# See if we have STS token, if we do, add it
267-
if "aws_session_token" in allKeys.keys():
271+
awsSessionTokenCandidate = allKeys.get("aws_session_token")
272+
if awsSessionTokenCandidate is not None and len(awsSessionTokenCandidate) != 0:
268273
aws_session_token = allKeys["aws_session_token"]
269274
url += "&X-Amz-Security-Token=" + quote(aws_session_token.encode("utf-8")) # Unicode in 3.x
270275
self._logger.debug("createWebsocketEndpoint: Websocket URL: " + url)
271276
return url
272277

278+
def _hasCredentialsNecessaryForWebsocket(self, allKeys):
279+
awsAccessKeyIdCandidate = allKeys.get("aws_access_key_id")
280+
awsSecretAccessKeyCandidate = allKeys.get("aws_secret_access_key")
281+
# None value is NOT considered as valid entries
282+
validEntries = awsAccessKeyIdCandidate is not None and awsAccessKeyIdCandidate is not None
283+
if validEntries:
284+
# Empty value is NOT considered as valid entries
285+
validEntries &= (len(awsAccessKeyIdCandidate) != 0 and len(awsSecretAccessKeyCandidate) != 0)
286+
return validEntries
287+
273288

274289
# This is an internal class that buffers the incoming bytes into an
275290
# internal buffer until it gets the full desired length of bytes.
@@ -305,6 +320,10 @@ def read(self, numberOfBytesToBeBuffered):
305320
while self._remainedLength > 0: # Read in a loop, always try to read in the remained length
306321
# If the data is temporarily not available, socket.error will be raised and catched by paho
307322
dataChunk = self._sslSocket.read(self._remainedLength)
323+
# There is a chance where the server terminates the connection without closing the socket.
324+
# If that happens, let's raise an exception and enter the reconnect flow.
325+
if not dataChunk:
326+
raise socket.error(errno.ECONNABORTED, 0)
308327
self._internalBuffer.extend(dataChunk) # Buffer the data
309328
self._remainedLength -= len(dataChunk) # Update the remained length
310329

@@ -411,6 +430,8 @@ def __init__(self, socket, hostAddress, portNumber, AWSAccessKeyID="", AWSSecret
411430
raise ValueError("No Access Key/KeyID Error")
412431
except wssHandShakeError:
413432
raise ValueError("Websocket Handshake Error")
433+
except ClientError as e:
434+
raise ValueError(e.message)
414435
# Now we have a socket with secured websocket...
415436
self._bufferedReader = _BufferedReader(self._sslSocket)
416437
self._bufferedWriter = _BufferedWriter(self._sslSocket)
@@ -461,11 +482,12 @@ def _verifyWSSAcceptKey(self, srcAcceptKey, clientKey):
461482

462483
def _handShake(self, hostAddress, portNumber):
463484
CRLF = "\r\n"
464-
hostAddressChunks = hostAddress.split('.') # <randomString>.iot.<region>.amazonaws.com
465-
region = hostAddressChunks[2] # XXXX.<region>.beta
485+
IOT_ENDPOINT_PATTERN = r"^[0-9a-zA-Z]+\.iot\.(.*)\.amazonaws\..*"
486+
matched = re.compile(IOT_ENDPOINT_PATTERN).match(hostAddress)
487+
if not matched:
488+
raise ClientError("Invalid endpoint pattern for wss: %s" % hostAddress)
489+
region = matched.group(1)
466490
signedURL = self._sigV4Handler.createWebsocketEndpoint(hostAddress, portNumber, region, "GET", "iotdata", "/mqtt")
467-
if signedURL == "":
468-
raise wssNoKeyInEnvironmentError()
469491
# Now we got a signedURL
470492
path = signedURL[signedURL.index("/mqtt"):]
471493
# Assemble HTTP request headers
@@ -667,6 +689,9 @@ def close(self):
667689
self._sslSocket.close()
668690
self._sslSocket = None
669691

692+
def getpeercert(self):
693+
return self._sslSocket.getpeercert()
694+
670695
def getSSLSocket(self):
671696
if self._connectStatus != self._WebsocketDisconnected:
672697
return self._sslSocket

AWSIoTPythonSDK/core/protocol/internal/clients.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def set_endpoint_provider(self, endpoint_provider):
9494
def configure_last_will(self, topic, payload, qos, retain=False):
9595
self._paho_client.will_set(topic, payload, qos, retain)
9696

97+
def configure_alpn_protocols(self, alpn_protocols):
98+
self._paho_client.config_alpn_protocols(alpn_protocols)
99+
97100
def clear_last_will(self):
98101
self._paho_client.will_clear()
99102

AWSIoTPythonSDK/core/protocol/internal/defaults.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@
1616
DEFAULT_CONNECT_DISCONNECT_TIMEOUT_SEC = 30
1717
DEFAULT_OPERATION_TIMEOUT_SEC = 5
1818
DEFAULT_DRAINING_INTERNAL_SEC = 0.5
19-
METRICS_PREFIX = "?SDK=Python&Version="
19+
METRICS_PREFIX = "?SDK=Python&Version="
20+
ALPN_PROTCOLS = "x-amzn-mqtt-ca"

0 commit comments

Comments
 (0)