Skip to content

Commit 778d0fe

Browse files
committed
Mark dropped clients as dropped which prevents a 'disconnected' message
Add a test for dropping a client which should not trigger a 'disconnected' message Replace `authenticated` flag with `ClientState` on `PathClient` Slightly more robust interaction between `Path` and `PathClient` More robust interaction between `Server` and `ServerProtocol` Update tests
1 parent dd40f8b commit 778d0fe

File tree

6 files changed

+190
-66
lines changed

6 files changed

+190
-66
lines changed

Diff for: saltyrtc/server/common.py

+32
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
'OverflowSentinel',
2222
'SubProtocol',
2323
'CloseCode',
24+
'ClientState',
2425
'AddressType',
2526
'MessageType',
2627
'available_slot_range',
@@ -89,6 +90,37 @@ def is_valid_drop_reason(self):
8990
return self.value in _drop_reasons
9091

9192

93+
@enum.unique
94+
class ClientState(enum.IntEnum):
95+
"""
96+
The state of a :class:`PathClient`.
97+
98+
.. important:: States MUST follow the exact order as enumerated
99+
below. A client cannot go back a state or skip
100+
a state in between. For example, a *dropped* client
101+
MUST have been formerly *authenticated*.
102+
"""
103+
# The client is connected but is not allowed to communicate
104+
# with another client.
105+
restricted = 1
106+
107+
# The client has been authenticated and may communicate with
108+
# other clients (of different type).
109+
authenticated = 2
110+
111+
# The client has been dropped by another client.
112+
dropped = 3
113+
114+
@property
115+
def next(self):
116+
"""
117+
Return the subsequent state.
118+
119+
Raises :exc:`ValueError` in case there is no subsequent state.
120+
"""
121+
return ClientState(self + 1)
122+
123+
92124
@enum.unique
93125
class AddressType(enum.IntEnum):
94126
server = 0x00

Diff for: saltyrtc/server/message.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
NONCE_FORMATTER,
1515
NONCE_LENGTH,
1616
AddressType,
17+
ClientState,
1718
CloseCode,
1819
MessageType,
1920
OverflowSentinel,
@@ -192,8 +193,8 @@ def pack(self, client):
192193

193194
# Encrypt payload if required
194195
if self.encrypted:
195-
if not client.authenticated:
196-
raise MessageFlowError('Cannot encrypt payload, no box available')
196+
if client.state != ClientState.authenticated:
197+
raise MessageFlowError('Cannot encrypt payload, not authenticated')
197198
payload = self._encrypt_payload(client, nonce, payload)
198199

199200
# Append payload and return as bytes
@@ -224,7 +225,7 @@ def unpack(cls, client, data):
224225
expect_type = None
225226
if destination_type == AddressType.server:
226227
data = data[NONCE_LENGTH:]
227-
if not client.authenticated and client.type is None:
228+
if client.state == ClientState.restricted and client.type is None:
228229
payload = None
229230

230231
# Try client-auth (encrypted)

Diff for: saltyrtc/server/protocol.py

+55-19
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
KEEP_ALIVE_TIMEOUT,
1616
KEY_LENGTH,
1717
AddressType,
18+
ClientState,
1819
OverflowSentinel,
1920
available_slot_range,
2021
is_initiator_id,
@@ -94,6 +95,9 @@ def set_initiator(self, initiator):
9495
Arguments:
9596
- `initiator`: A :class:`PathClient` instance.
9697
98+
Raises :exc:`ValueError` in case of a state violation on the
99+
:class:`PathClient`.
100+
97101
Return the previously set initiator or `None`.
98102
"""
99103
previous_initiator = self._slots.get(AddressType.initiator)
@@ -102,8 +106,7 @@ def set_initiator(self, initiator):
102106
# Update initiator's log name
103107
initiator.update_log_name(AddressType.initiator)
104108
# Authenticated, assign id
105-
initiator.authenticated = True
106-
initiator.id = AddressType.initiator
109+
initiator.authenticate(AddressType.initiator)
107110
# Return previous initiator
108111
return previous_initiator
109112

@@ -141,7 +144,10 @@ def add_responder(self, responder):
141144
Arguments:
142145
- `client`: A :class:`PathClient` instance.
143146
144-
Raises :exc:`SlotsFullError` if no free slot exists on the path.
147+
Raises:
148+
- :exc:`SlotsFullError` if no free slot exists on the path.
149+
- :exc:`ValueError` in case of a state violation on the
150+
:class:`PathClient`.
145151
146152
Return the assigned slot identifier.
147153
"""
@@ -152,8 +158,7 @@ def add_responder(self, responder):
152158
# Update responder's log name
153159
responder.update_log_name(id_)
154160
# Authenticated, set and return assigned slot id
155-
responder.authenticated = True
156-
responder.id = id_
161+
responder.authenticate(id_)
157162
return id_
158163
raise SlotsFullError('No free slots on path')
159164

@@ -172,8 +177,8 @@ def remove_client(self, client):
172177
Raises :exc:`KeyError` in case the client provided an
173178
invalid slot identifier.
174179
"""
175-
if not client.authenticated:
176-
# Client has not been authenticated. Nothing to do.
180+
if client.state == ClientState.restricted:
181+
# Client has never been authenticated. Nothing to do.
177182
return
178183
id_ = client.id
179184

@@ -245,6 +250,7 @@ def _ensure_future_or_none(coroutine_or_task, loop):
245250
class PathClient:
246251
__slots__ = (
247252
'_loop',
253+
'_state',
248254
'_connection',
249255
'_connection_closed_future',
250256
'_client_key',
@@ -262,7 +268,6 @@ class PathClient:
262268
'_keep_alive_interval',
263269
'log',
264270
'type',
265-
'authenticated',
266271
'keep_alive_timeout',
267272
'keep_alive_pings',
268273
'tasks',
@@ -275,6 +280,7 @@ def __init__(
275280
server_session_key=None, loop=None
276281
):
277282
self._loop = asyncio.get_event_loop() if loop is None else loop
283+
self._state = ClientState.restricted
278284
self._connection = connection
279285
connection_closed_future = asyncio.Future(loop=self._loop)
280286
self._connection_closed_future = connection_closed_future
@@ -291,7 +297,6 @@ def __init__(
291297
self._keep_alive_interval = KEEP_ALIVE_INTERVAL_DEFAULT
292298
self.log = util.get_logger('path.{}.client.{:x}'.format(path_number, id(self)))
293299
self.type = None
294-
self.authenticated = False
295300
self.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
296301
self.keep_alive_pings = 0
297302
self.tasks = None
@@ -312,6 +317,26 @@ def __str__(self):
312317
return 'PathClient(role=0x{:02x}, id={}, at={})'.format(
313318
type_, self._id, hex(id(self)))
314319

320+
@property
321+
def state(self):
322+
"""
323+
Return the current :class:`ClientState` of the client.
324+
"""
325+
return self._state
326+
327+
@state.setter
328+
def state(self, state):
329+
"""
330+
Update the :class:`ClientState` of the client.
331+
332+
Raises :exc:`ValueError` in case the state is not following
333+
the strict state order as defined by :class`ClientState`.
334+
"""
335+
if state != self.state.next:
336+
raise ValueError('State {} cannot be updated to {}'.format(self.state, state))
337+
self.log.debug('State {} -> {}', self._state.name, state.name)
338+
self._state = state
339+
315340
@property
316341
def connection_closed_future(self):
317342
"""
@@ -328,14 +353,6 @@ def id(self):
328353
"""
329354
return self._id
330355

331-
@id.setter
332-
def id(self, id_):
333-
"""
334-
Assign the id. Only :class:`Path` may set the id!
335-
"""
336-
self._id = id_
337-
self.log.debug('Assigned id: {}', id_)
338-
339356
@property
340357
def keep_alive_interval(self):
341358
"""
@@ -495,6 +512,19 @@ def set_client_key(self, public_key):
495512
self._box = libnacl.public.Box(self.server_key, public_key)
496513
self.log.debug('Client key updated')
497514

515+
def authenticate(self, id_):
516+
"""
517+
Authenticate the client and assign it an id.
518+
519+
.. important:: Only :class:`Path` may call this!
520+
521+
Raises :exc:`ValueError` in case the previous state was not
522+
:attr:`ClientState.restricted`.
523+
"""
524+
self.state = ClientState.authenticated
525+
self._id = id_
526+
self.log.debug('Assigned id: {}', id_)
527+
498528
def update_log_name(self, slot_id):
499529
"""
500530
Update the logger's name by the assigned slot identifier.
@@ -563,7 +593,7 @@ def p2p_allowed(self, destination_type):
563593
Return `True` if :class:`RawMessage` instances are allowed and
564594
can be sent to the requested :class:`AddressType`.
565595
"""
566-
return self.authenticated and self.type != destination_type
596+
return self.state == ClientState.authenticated and self.type != destination_type
567597

568598
@asyncio.coroutine
569599
def enqueue_task(self, coroutine_or_task, ignore_closed=False):
@@ -732,6 +762,11 @@ def receive(self):
732762
"""
733763
Disconnected
734764
"""
765+
# Safeguard
766+
# Note: This should never happen since the receive queue will
767+
# be stopped when a client is being dropped.
768+
assert self.state < ClientState.dropped
769+
735770
# Receive data
736771
try:
737772
data = yield from self._connection.recv()
@@ -825,7 +860,8 @@ def drop(self, code):
825860
self.log.debug('Cancelling all running tasks but the task loop')
826861
self.tasks.cancel_all_but_task_loop()
827862

828-
# Dropped
863+
# Mark as dropped
864+
self.state = ClientState.dropped
829865
self.log.debug('Client dropped, close code: {}', code)
830866
return enqueue_task
831867

0 commit comments

Comments
 (0)