Merge lp:~free.ekanayaka/landscape-client/exchange-token into lp:~landscape/landscape-client/trunk

Proposed by Free Ekanayaka
Status: Merged
Approved by: Free Ekanayaka
Approved revision: 660
Merged at revision: 655
Proposed branch: lp:~free.ekanayaka/landscape-client/exchange-token
Merge into: lp:~landscape/landscape-client/trunk
Diff against target: 355 lines (+131/-14)
9 files modified
landscape/__init__.py (+4/-1)
landscape/broker/exchange.py (+29/-3)
landscape/broker/registration.py (+16/-2)
landscape/broker/store.py (+8/-0)
landscape/broker/tests/test_exchange.py (+30/-3)
landscape/broker/tests/test_registration.py (+12/-0)
landscape/broker/tests/test_store.py (+13/-0)
landscape/broker/tests/test_transport.py (+3/-1)
landscape/broker/transport.py (+16/-4)
To merge this branch: bzr merge lp:~free.ekanayaka/landscape-client/exchange-token
Reviewer Review Type Date Requested Status
Chris Glass (community) Approve
Alberto Donato (community) Approve
Review via email: mp+158131@code.launchpad.net

Commit message

This branch implements the client-side part of the changes needed to detect cloned computers. The idea is that at each exchange the server will give the client a token that the client will be required to give back at the following exchange to prove that it's still the same client. If two clients with the same secure ID try to perform an exchange using the same token, only the first one that completes the exchange will succeed, because the second will not be authenticated since the token has changed in the meantime (after the first exchange completed).

Description of the change

This branch implements the client-side part of the changes needed to detect cloned computers. The idea is that at each exchange the server will give the client a token that the client will be required to give back at the following exchange to prove that it's still the same client. If two clients with the same secure ID try to perform an exchange using the same token, only the first one that completes the exchange will succeed, because the second will not be authenticated since the token has changed in the meantime (after the first exchange completed).

To post a comment you must log in.
655. By Free Ekanayaka

Better comments

656. By Free Ekanayaka

Rephrase

657. By Free Ekanayaka

Extract exchange-token logic into a separate method

658. By Free Ekanayaka

Move private method at the bottom

659. By Free Ekanayaka

Change the computer title for cloned clients

Revision history for this message
Alberto Donato (ack) wrote :

Looks good! +1

#1:
Not really related to your changes, but I see MessageStore hs a lot of getter/setters for parameters that are just passed to the persist. Maybe we could reduce duplication using __getattr__/__seattr__, perhaps with defaults for some parameters. Just an idea, not something for this branch anyway.

review: Approve
660. By Free Ekanayaka

Nicer title

Revision history for this message
Chris Glass (tribaal) wrote :

+1 Nice!

review: Approve
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

#1 Yeah maybe it'd be an interesting cleanup for a follow-up branch.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'landscape/__init__.py'
2--- landscape/__init__.py 2013-03-12 21:47:27 +0000
3+++ landscape/__init__.py 2013-04-11 13:21:20 +0000
4@@ -35,8 +35,11 @@
5 # 3.4:
6 # * Add "hold" field to "change-packages"
7 # * Add "remove-hold" field to "change-packages"
8+#
9+# 3.5:
10+# * Support per-exchange authentication tokens
11
12-CLIENT_API = "3.4"
13+CLIENT_API = "3.5"
14
15 from twisted.python import util
16
17
18=== modified file 'landscape/broker/exchange.py'
19--- landscape/broker/exchange.py 2013-04-10 09:54:13 +0000
20+++ landscape/broker/exchange.py 2013-04-11 13:21:20 +0000
21@@ -395,6 +395,7 @@
22 self._reactor.call_in_thread(handle_result, None,
23 self._transport.exchange, payload,
24 self._registration_info.secure_id,
25+ self._get_exchange_token(),
26 payload.get("server-api"))
27 return deferred
28
29@@ -443,6 +444,26 @@
30 self._exchange_id = self._reactor.call_later(interval,
31 self.exchange)
32
33+ def _get_exchange_token(self):
34+ """Get the token given us by the server at the last exchange.
35+
36+ It will be C{None} if we are not fully registered yet or if something
37+ bad happened during the last exchange and we could not get the token
38+ that the server had given us.
39+ """
40+ exchange_token = self._message_store.get_exchange_token()
41+
42+ # Before starting the exchange set the saved token to None. This will
43+ # prevent us from locking ourselves out if the exchange fails or if we
44+ # crash badly, while the server has saved a new token that we couldn't
45+ # receive or persist (this works because if the token is None the
46+ # server will be forgiving and will authenticate us based only on the
47+ # secure ID we provide).
48+ self._message_store.set_exchange_token(None)
49+ self._message_store.commit()
50+
51+ return exchange_token
52+
53 def _notify_impending_exchange(self):
54 self._reactor.fire("impending-exchange")
55
56@@ -485,8 +506,7 @@
57 "accepted-types": accepted_types_digest,
58 "messages": messages,
59 "total-messages": total_messages,
60- "next-expected-sequence": store.get_server_sequence(),
61- }
62+ "next-expected-sequence": store.get_server_sequence()}
63 accepted_client_types = self.get_client_accepted_message_types()
64 accepted_client_types_hash = self._hash_types(accepted_client_types)
65 if accepted_client_types_hash != self._client_accepted_types_hash:
66@@ -524,7 +544,6 @@
67 next_expected += len(payload["messages"])
68
69 message_store_state = got_next_expected(message_store, next_expected)
70- message_store.commit()
71 if message_store_state == ANCIENT:
72 # The server has probably lost some data we sent it. The
73 # slate has been wiped clean (by got_next_expected), now
74@@ -536,6 +555,11 @@
75 self.send({"type": "resynchronize"})
76 self._reactor.fire("resynchronize-clients")
77
78+ # Save the exchange token that the server has sent us. We will provide
79+ # it at the next exchange to prove that we're still the same client.
80+ # See also landscape.broker.transport.
81+ message_store.set_exchange_token(result.get("next-exchange-token"))
82+
83 old_uuid = message_store.get_server_uuid()
84 new_uuid = result.get("server-uuid")
85 if new_uuid != old_uuid:
86@@ -544,6 +568,8 @@
87 self._reactor.fire("server-uuid-changed", old_uuid, new_uuid)
88 message_store.set_server_uuid(new_uuid)
89
90+ message_store.commit()
91+
92 sequence = message_store.get_server_sequence()
93 for message in result.get("messages", ()):
94 self.handle_message(message)
95
96=== modified file 'landscape/broker/registration.py'
97--- landscape/broker/registration.py 2013-04-09 12:18:50 +0000
98+++ landscape/broker/registration.py 2013-04-11 13:21:20 +0000
99@@ -337,8 +337,22 @@
100
101 def _handle_unknown_id(self, message):
102 id = self._identity
103- logging.info("Client has unknown secure-id for account %s."
104- % id.account_name)
105+ clone = message.get("clone-of")
106+ if clone is None:
107+ logging.info("Client has unknown secure-id for account %s."
108+ % id.account_name)
109+ else:
110+ logging.info("Client is clone of computer %s" % clone)
111+ # Set a new computer title so when a registration request will be
112+ # made, the pending computer UI will indicate that this is a clone
113+ # of another computer. There's no need to persist the changes since
114+ # a new registration will be requested immediately.
115+ if clone == self._config.computer_title:
116+ title = "%s (clone)" % self._config.computer_title
117+ else:
118+ title = "%s (clone of %s)" % (self._config.computer_title,
119+ clone)
120+ self._config.computer_title = title
121 id.secure_id = None
122 id.insecure_id = None
123
124
125=== modified file 'landscape/broker/store.py'
126--- landscape/broker/store.py 2013-04-03 08:57:30 +0000
127+++ landscape/broker/store.py 2013-04-11 13:21:20 +0000
128@@ -200,6 +200,14 @@
129 """Change the known UUID from the server we're communicating to."""
130 self._persist.set("server_uuid", uuid)
131
132+ def get_exchange_token(self):
133+ """Get the authentication token to use for the next exchange."""
134+ return self._persist.get("exchange_token")
135+
136+ def set_exchange_token(self, token):
137+ """Set the authentication token to use for the next exchange."""
138+ self._persist.set("exchange_token", token)
139+
140 def get_pending_offset(self):
141 """Get the current pending offset."""
142 return self._persist.get("pending_offset", 0)
143
144=== modified file 'landscape/broker/tests/test_exchange.py'
145--- landscape/broker/tests/test_exchange.py 2013-03-31 22:44:46 +0000
146+++ landscape/broker/tests/test_exchange.py 2013-04-11 13:21:20 +0000
147@@ -242,7 +242,7 @@
148 self.mstore.add({"type": "data", "data": 3})
149
150 # next one, server will respond with 1!
151- def desynched_send_data(payload, computer_id=None, message_api=None):
152+ def desynched_send_data(*args, **kwargs):
153 self.transport.next_expected_sequence = 1
154 return {"next-expected-sequence": 1}
155
156@@ -381,7 +381,7 @@
157 self.mstore.set_accepted_types(["data"])
158 self.mstore.add({"type": "data", "data": 0})
159
160- def desynched_send_data(payload, computer_id=None, message_api=None):
161+ def desynched_send_data(*args, **kwargs):
162 self.transport.next_expected_sequence = 0
163 return {"next-expected-sequence": 0}
164
165@@ -476,6 +476,33 @@
166 self.assertEqual(payload.get("server-api"), "2.0")
167 self.assertEqual(self.transport.message_api, "2.0")
168
169+ def test_exchange_token(self):
170+ """
171+ When sending messages to the server, the exchanger provides the
172+ token that the server itself gave it during the former exchange.
173+ """
174+ self.exchanger.exchange()
175+ self.assertIs(None, self.transport.exchange_token)
176+ exchange_token = self.mstore.get_exchange_token()
177+ self.assertIsNot(None, exchange_token)
178+ self.exchanger.exchange()
179+ self.assertEqual(exchange_token, self.transport.exchange_token)
180+
181+ def test_reset_exchange_token_on_failure(self):
182+ """
183+ If an exchange fails we set the value of the next exchange token to
184+ C{None}, so we can authenticate ourselves even if we couldn't receive
185+ a valid token.
186+ """
187+ self.mstore.set_exchange_token("abcd-efgh")
188+ self.mstore.commit()
189+ self.transport.exchange = lambda *args, **kwargs: None
190+ self.exchanger.exchange()
191+ # Check that the change was persisted
192+ persist = Persist(filename=self.persist_filename)
193+ store = MessageStore(persist, self.config.message_store_path)
194+ self.assertIs(None, store.get_exchange_token())
195+
196 def test_include_total_messages_none(self):
197 """
198 The payload includes the total number of messages that the client has
199@@ -611,7 +638,7 @@
200 exchanger raises an exception.
201 """
202
203- def failed_send_data(payload, computer_id=None, message_api=None):
204+ def failed_send_data(*args, **kwargs):
205 return None
206
207 self.transport.exchange = failed_send_data
208
209=== modified file 'landscape/broker/tests/test_registration.py'
210--- landscape/broker/tests/test_registration.py 2013-01-17 08:52:48 +0000
211+++ landscape/broker/tests/test_registration.py 2013-04-11 13:21:20 +0000
212@@ -113,6 +113,18 @@
213 self.assertEqual(self.identity.secure_id, None)
214 self.assertEqual(self.identity.insecure_id, None)
215
216+ def test_unknown_id_with_clone(self):
217+ """
218+ If the server reports us that we are a clone of another computer, then
219+ set our computer's title accordingly.
220+ """
221+ self.config.computer_title = "Wu"
222+ self.mstore.set_accepted_types(["register"])
223+ self.exchanger.handle_message({"type": "unknown-id", "clone-of": "Wu"})
224+ self.assertEqual("Wu (clone)", self.config.computer_title)
225+ self.assertIn("Client is clone of computer Wu",
226+ self.logfile.getvalue())
227+
228 def test_should_register(self):
229 self.mstore.set_accepted_types(["register"])
230 self.config.computer_title = "Computer Title"
231
232=== modified file 'landscape/broker/tests/test_store.py'
233--- landscape/broker/tests/test_store.py 2012-03-07 08:28:31 +0000
234+++ landscape/broker/tests/test_store.py 2013-04-11 13:21:20 +0000
235@@ -71,6 +71,19 @@
236 store = self.create_store()
237 self.assertEqual(store.get_server_uuid(), "abcd-efgh")
238
239+ def test_get_set_exchange_token(self):
240+ """
241+ The next-exchange-token value can be persisted and retrieved.
242+ """
243+ self.assertEqual(self.store.get_exchange_token(), None)
244+ self.store.set_exchange_token("abcd-efgh")
245+ self.assertEqual(self.store.get_exchange_token(), "abcd-efgh")
246+
247+ # Ensure it's actually saved.
248+ self.store.commit()
249+ store = self.create_store()
250+ self.assertEqual(store.get_exchange_token(), "abcd-efgh")
251+
252 def test_get_pending_offset(self):
253 self.assertEqual(self.store.get_pending_offset(), 0)
254 self.store.set_pending_offset(3)
255
256=== modified file 'landscape/broker/tests/test_transport.py'
257--- landscape/broker/tests/test_transport.py 2012-03-07 08:25:39 +0000
258+++ landscape/broker/tests/test_transport.py 2013-04-11 13:21:20 +0000
259@@ -79,11 +79,13 @@
260 transport = HTTPTransport(
261 None, "http://localhost:%d/" % (port.getHost().port,))
262 result = deferToThread(transport.exchange, "HI", computer_id="34",
263- message_api="X.Y")
264+ exchange_token="abcd-efgh", message_api="X.Y")
265
266 def got_result(ignored):
267 self.assertEqual(r.request.received_headers["x-computer-id"],
268 "34")
269+ self.assertEqual(r.request.received_headers["x-exchange-token"],
270+ "abcd-efgh")
271 self.assertEqual(r.request.received_headers["user-agent"],
272 "landscape-client/%s" % (VERSION,))
273 self.assertEqual(r.request.received_headers["x-message-api"],
274
275=== modified file 'landscape/broker/transport.py'
276--- landscape/broker/transport.py 2012-03-07 08:25:39 +0000
277+++ landscape/broker/transport.py 2013-04-11 13:21:20 +0000
278@@ -2,6 +2,7 @@
279 import time
280 import logging
281 import pprint
282+import uuid
283
284 import pycurl
285
286@@ -31,22 +32,28 @@
287 """Set the URL of the remote message system."""
288 self._url = url
289
290- def _curl(self, payload, computer_id, message_api):
291+ def _curl(self, payload, computer_id, exchange_token, message_api):
292 headers = {"X-Message-API": message_api,
293 "User-Agent": "landscape-client/%s" % VERSION,
294 "Content-Type": "application/octet-stream"}
295 if computer_id:
296 headers["X-Computer-ID"] = computer_id
297+ if exchange_token:
298+ headers["X-Exchange-Token"] = str(exchange_token)
299 curl = pycurl.Curl()
300 return (curl, fetch(self._url, post=True, data=payload,
301 headers=headers, cainfo=self._pubkey, curl=curl))
302
303- def exchange(self, payload, computer_id=None, message_api=SERVER_API):
304+ def exchange(self, payload, computer_id=None, exchange_token=None,
305+ message_api=SERVER_API):
306 """Exchange message data with the server.
307
308 @param payload: The object to send, it must be L{bpickle}-compatible.
309 @param computer_id: The computer ID to send the message as (see
310 also L{Identity}).
311+ @param exchange_token: The token that the server has given us at the
312+ last exchange. It's used to prove that we are still the same
313+ client.
314
315 @type: C{dict}
316 @return: The server's response to sent message or C{None} in case
317@@ -60,7 +67,8 @@
318 start_time = time.time()
319 if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
320 logging.debug("Sending payload:\n%s", pprint.pformat(payload))
321- curly, data = self._curl(spayload, computer_id, message_api)
322+ curly, data = self._curl(spayload, computer_id, exchange_token,
323+ message_api)
324 logging.info("Sent %d bytes and received %d bytes in %s.",
325 len(spayload), len(data),
326 format_delta(time.time() - start_time))
327@@ -95,6 +103,7 @@
328 self._current_response = 0
329 self.next_expected_sequence = 0
330 self.computer_id = None
331+ self.exchange_token = None
332 self.message_api = None
333 self.extra = {}
334 self._url = url
335@@ -106,9 +115,11 @@
336 def set_url(self, url):
337 self._url = url
338
339- def exchange(self, payload, computer_id=None, message_api=SERVER_API):
340+ def exchange(self, payload, computer_id=None, exchange_token=None,
341+ message_api=SERVER_API):
342 self.payloads.append(payload)
343 self.computer_id = computer_id
344+ self.exchange_token = exchange_token
345 self.message_api = message_api
346 self.next_expected_sequence += len(payload.get("messages", []))
347
348@@ -119,6 +130,7 @@
349 response = []
350
351 result = {"next-expected-sequence": self.next_expected_sequence,
352+ "next-exchange-token": unicode(uuid.uuid4()),
353 "messages": response}
354 result.update(self.extra)
355 return result

Subscribers

People subscribed via source and target branches

to all changes: