Compare commits

...

163 Commits
v0.24 ... v0.93

Author SHA1 Message Date
Moxie Marlinspike
c410348278 Bump version to 0.93
// FREEBIE
2016-03-06 13:59:36 -08:00
Moxie Marlinspike
d8a758211f Make push sender queue depth configurable or disable-able.
// FREEBIE
2016-03-06 13:58:30 -08:00
Moxie Marlinspike
33e60f2527 Bump version to 0.92
// FREEBIE
2016-02-02 15:31:10 -08:00
Moxie Marlinspike
fb705eee23 Switch PushSender queue depth metrics to gauge
// FREEBIE
2016-02-02 15:21:15 -08:00
Moxie Marlinspike
635e16e934 Bump version to 0.91
// FREEBIE
2016-02-02 14:43:34 -08:00
Moxie Marlinspike
1deb3ae67f Asynchronous processing queue for incoming messages
// FREEBIE
2016-02-02 14:42:47 -08:00
Moxie Marlinspike
16ff40f420 Bump version to 0.90
// FREEBIE
2016-01-20 18:41:22 -08:00
Moxie Marlinspike
a8b5cb23fe Reduce pending max queue size to 1000 messages
// FREEBIE
2016-01-20 18:41:01 -08:00
Moxie Marlinspike
82f88d04ad Fuzz GCM write timestmap by 10 seconds
// FREEBIE
2015-12-21 16:59:54 -08:00
Moxie Marlinspike
0e1091e0ea Bump version to 0.89
// FREEBIE
2015-12-21 12:36:07 -08:00
Moxie Marlinspike
7b48f10cc9 Filter message deletes by device
// FREEBIE
2015-12-21 12:35:45 -08:00
Moxie Marlinspike
0be34b1135 Bump version to 0.88
// FREEBIE
2015-12-04 12:59:22 -08:00
Moxie Marlinspike
fb5e0242d0 Adjust requeue message logic to avoid redis assumptions
// FREEBIE
2015-12-04 11:41:23 -08:00
Moxie Marlinspike
d376035557 Bump version to 0.87 2015-12-03 16:40:23 -08:00
Moxie Marlinspike
747b2dc7c5 Jedis sanity checks
// FREEBIE
2015-12-03 16:40:04 -08:00
Moxie Marlinspike
fac2f1bee3 Bump version to 0.86
// FREEBIE
2015-12-02 15:06:54 -08:00
Moxie Marlinspike
a211f6aed9 Delete pending messages for an unlinked device
// FREEBIE
2015-12-02 15:06:09 -08:00
Moxie Marlinspike
0bc494245d Fix for broken string format
// FREEBIE
2015-12-01 11:54:50 -08:00
Frederic Jacobs
b31a88043e Adding Signal SMS verification strings.
- Changes the voice verification string.
- Keeps the TextSecure SMS String for matching in Signal for Android.
- Changes TextSecure to Signal for iOS, adding tap to verify link.
- Added test for iOS query parameter.
2015-12-01 11:54:14 -08:00
Moxie Marlinspike
85509c6d8b Don't need to send payload GCM messages any longer.
// FREEBIE
2015-12-01 10:58:43 -08:00
Moxie Marlinspike
2dd131cf79 Bump verison to 0.85
// FREEBIE
2015-11-12 10:42:28 -08:00
Moxie Marlinspike
51990d0b33 Lower chunk size
// FREEBIE
2015-11-12 10:42:16 -08:00
Moxie Marlinspike
00a49afc30 Bump version to 0.84
// FREEBIE
2015-11-09 17:20:28 -08:00
Moxie Marlinspike
faa0630851 Fix up MX numbers for SMS delivery
// FREEBIE
2015-11-09 17:18:59 -08:00
Moxie Marlinspike
aac3fc68fc Bump version to 0.83
// FREEBIE
2015-11-06 11:24:53 -08:00
Moxie Marlinspike
9c08b96b50 Bump version to 0.82
// FREEBIE
2015-11-04 11:20:39 -08:00
Moxie Marlinspike
15ddde1df4 Adjust log levels on delivery receipt failure.
// FREEBIE
2015-11-04 11:20:09 -08:00
Moxie Marlinspike
f2a9de3ba8 Retry serializable transaction.
// FREEBIE
2015-11-04 11:19:54 -08:00
Moxie Marlinspike
fd725206e2 Bump version to 0.81
// FREEBIE
2015-11-03 08:12:31 -08:00
Moxie Marlinspike
6368b9383a Stripe SMS/Vox across multiple numbers
// FREEBIE
2015-11-03 08:12:18 -08:00
Moxie Marlinspike
2b8a11b001 Bump version to 0.80
// FREEBIE
2015-09-30 17:53:47 -07:00
Moxie Marlinspike
c9e0339a30 Specify media type on attributes put
// FREEBIE
2015-09-30 17:53:09 -07:00
Moxie Marlinspike
8d11595290 Bump version to 0.79 2015-09-21 14:09:28 -07:00
Moxie Marlinspike
2fe9f3effa Generate as well as consume auth tokens. Also user agents.
// FREEBIE
2015-09-21 14:09:03 -07:00
Moxie Marlinspike
ae122ff8a2 Bump version to 0.76
// FREEBIE
2015-08-18 11:11:58 -07:00
Moxie Marlinspike
8b941ddd33 Make the messagedb a bounded queue at 5000 msgs/device
// FREEBIE
2015-08-18 11:10:42 -07:00
Moxie Marlinspike
2902ea6689 Get rid of deprecated API
// FREEBIE
2015-08-18 11:10:34 -07:00
Moxie Marlinspike
5ccbf355bd Chunk sending pending message queues > a chunk size.
// FREEBIE
2015-08-17 17:12:36 -07:00
Moxie Marlinspike
62d8f635b0 Track voice support on TS server.
// FREEBIE
2015-08-13 11:43:49 -07:00
Moxie Marlinspike
4c3aae63d3 Trim old messages
// FREEBIE
2015-08-11 20:15:05 -07:00
Moxie Marlinspike
8f94aa0c0d Actually vacuum messages
// FREEBIE
2015-08-11 20:00:11 -07:00
Moxie Marlinspike
0370306bb6 Map 411 to 413
// FREEBIE
2015-08-01 10:09:15 -07:00
Moxie Marlinspike
c9176efe6f Bump version to 0.71
// FREEBIE
2015-07-30 17:31:19 -07:00
Moxie Marlinspike
a3fd08b7ef Add gauge for reporting number of open fds
// FREEBIE
2015-07-30 16:55:19 -07:00
Moxie Marlinspike
83a9e36ef1 Update logging levels.
// FREEBIE
2015-07-30 16:39:55 -07:00
Moxie Marlinspike
9668decc84 Bump version to 0.70
// FREEBIE
2015-07-30 14:17:24 -07:00
Moxie Marlinspike
328bb47d44 Only handle dead letters to addresses, not connection info.
// FREEBIE
2015-07-30 14:16:39 -07:00
Moxie Marlinspike
c74e0b9eab Bump version to 0.69
// FREEBIE
2015-07-30 13:25:40 -07:00
Moxie Marlinspike
20dc32413f Soften some logging.
// FREEBIE
2015-07-30 13:25:29 -07:00
Moxie Marlinspike
d4e618893c Make APN fallback behave well in multi-server environments.
// FREEBIE
2015-07-30 13:18:22 -07:00
Moxie Marlinspike
8d0d934249 Bump version to 0.68
// FREEBIE
2015-07-29 15:19:09 -07:00
Moxie Marlinspike
ef2441ad82 Don't pass response objects back from federated client.
// FREEBIE
2015-07-29 15:18:40 -07:00
Moxie Marlinspike
bb7859138c Bump version to 0.67
// FREEBIE
2015-07-29 14:23:05 -07:00
Moxie Marlinspike
ebc4570941 Fix federated client connection leak.
// FREEBIE
2015-07-29 12:36:03 -07:00
Moxie Marlinspike
d04baed38b Bump version to 0.65
// FREEBIE
2015-07-28 15:23:49 -07:00
Moxie Marlinspike
001c81f797 Try to make JerseyClient put() include a content-length of 0.
// FREEBIE
2015-07-28 15:23:21 -07:00
Moxie Marlinspike
3327bf4788 Add provisioning keepalive endpoint.
// FREEBIE
2015-07-28 15:22:51 -07:00
Moxie Marlinspike
e0b480e232 Bump version to 0.64
// FREEBIE
2015-07-27 22:47:16 -07:00
Moxie Marlinspike
b328d85230 Increase timeout on push service socket.
// FREEBIE
2015-07-27 22:46:38 -07:00
Moxie Marlinspike
3afaa5c1e6 Fix bug with federated delivery receipts.
// FREEBIE
2015-07-27 22:46:18 -07:00
Moxie Marlinspike
f2c8699823 Remove unused provider.
// FREEBIE
2015-07-27 17:58:38 -07:00
Moxie Marlinspike
4c11315a3c Bump version to 0.63
// FREEBIE
2015-07-27 17:04:07 -07:00
Moxie Marlinspike
0e3a347d6b Disable FAIL_ON_UNKNOWN_PROPERTIES for directory command.
// FREEBIE
2015-07-27 17:03:39 -07:00
Moxie Marlinspike
dc723fadaa Bump version to 0.62
// FREEBIE
2015-07-27 16:41:23 -07:00
Moxie Marlinspike
6396958a31 Bump up FederatedClient timeouts.
// FREEBIE
2015-07-27 16:40:42 -07:00
Moxie Marlinspike
1fe57e4841 Bump version to 0.61
// FREEBIE
2015-07-27 14:03:42 -07:00
Moxie Marlinspike
3885ae6337 Dropwizard 9 compatibility!
// FREEBIE
2015-07-27 14:02:44 -07:00
Moxie Marlinspike
39e3366b3b Bump version to 0.54
// FREEBIE
2015-06-25 11:02:00 -07:00
Moxie Marlinspike
a5ffd47935 Gotta stub out message field for delivery receipts w/ old clients
// FREEBIE
2015-06-25 11:00:59 -07:00
Moxie Marlinspike
18a96a445b Bump version to 0.53 2015-06-25 08:51:09 -07:00
Moxie Marlinspike
de366b976e Ignore unknown properties from federated responses.
// FREEBIE
2015-06-25 08:50:10 -07:00
Moxie Marlinspike
8f6aff3a7e Bump version to 0.52
// FREEBIE
2015-06-24 13:46:08 -07:00
Moxie Marlinspike
fb411b20cc Make adding and removing master device operations.
// FREEBIE
2015-06-22 11:01:08 -07:00
Moxie Marlinspike
52ce7d6935 Enhance device management API.
1. Put a limit on the number of registered devices per account.

2. Support removing devices.

3. Support device names and created dates.

4. Support enumerating devices.

// FREEBIE
2015-06-19 21:41:22 -07:00
Moxie Marlinspike
75ee398633 Remove server-side tracking of "supports SMS." Nobody does!
// FREEBIE
2015-06-17 16:45:23 -07:00
Moxie Marlinspike
53bdd946d6 Update TextSecure envelope protobuf.
Support envelope 'content' field.

// FREEBIE
2015-06-17 16:29:07 -07:00
Moxie Marlinspike
79f36664ef Bump version to 0.50
// FREEBIE
2015-06-06 21:04:53 -07:00
Moxie Marlinspike
83078a48ab Support for expiration on APN messages.
// FREEBIE
2015-06-06 21:04:08 -07:00
Moxie Marlinspike
6f67a812dc Make APN fallback 30 seconds.
// FREEBIE
2015-05-27 16:25:31 -07:00
Moxie Marlinspike
6ad705b40e Fall back straight to APN.
// FREEBIE
2015-05-27 16:19:56 -07:00
Moxie Marlinspike
4cb43415a1 Track APN fallback deliverability metrics.
// FREEBIE
2015-05-15 17:01:23 -07:00
Moxie Marlinspike
bbb09b558c Support for APN fallback retries.
// FREEBIE
2015-05-15 16:04:27 -07:00
Moxie Marlinspike
6363be81e0 Support for configured test devices with hardcoded verification.
Closes #40

// FREEBIE
2015-05-13 15:35:59 -07:00
Moxie Marlinspike
c6810d7460 Bump version to 0.49
// FREEBIE
2015-05-13 14:31:38 -07:00
Moxie Marlinspike
4c1e7e7c2f Rate limit messages on source+destination rather than just src.
// FREEBIE
2015-04-24 16:25:59 -07:00
Moxie Marlinspike
931081752a Updated sample config
// FREEBIE

Closes #38
2015-04-21 19:45:31 -07:00
Moxie Marlinspike
424e98e67e Bump version to 0.48
// FREEBIE
2015-04-21 19:44:20 -07:00
Moxie Marlinspike
7cfa93f5f8 Tone down websocket logging for bad federated responses.
// FREEBIE
2015-04-21 19:44:02 -07:00
Moxie Marlinspike
fd8e8d1475 Catch WebApplicationException inside WebsocketConnection.
// FREEBIE
2015-04-16 11:33:16 -07:00
Moxie Marlinspike
7ed5eb22ec Additional WebsocketConnection test.
// FREEBIE
2015-04-16 10:45:24 -07:00
Moxie Marlinspike
fa1c275904 Bump version to 0.46
// FREEBIE
2015-04-15 16:44:22 -07:00
Moxie Marlinspike
558c72bbb7 Make pending messages indexable by sender and timestamp.
Rather than just timestamp.

// FREEBIE
2015-04-15 16:43:44 -07:00
Moxie Marlinspike
37976455bc Bump version to 0.45
// FREEBIE
2015-04-15 16:20:25 -07:00
Moxie Marlinspike
db6ee8f687 Make stored messages REST accessible.
Add REST endpoints for retrieving and acknowledging pending
messges, beyond the WebSocket.

// FREEBIE
2015-04-15 16:19:07 -07:00
Moxie Marlinspike
53e7ffa311 Bump version to 0.44 2015-03-30 10:31:35 -07:00
Moxie Marlinspike
e0f7ff325a Add voip push support in communication with push server.
// FREEBIE
2015-03-25 15:59:52 -07:00
Moxie Marlinspike
1fcd1e33c5 Log keepalives for unsubscribed channels.
// FREEBIE
2015-03-24 14:13:32 -07:00
Moxie Marlinspike
843b16c1f0 Correctly serialize provisioning addresses.
// FREEBIE
2015-03-24 12:10:59 -07:00
Moxie Marlinspike
a58f3f0fe3 Check subscription status on websocket keepalive.
// FREEBIE
2015-03-24 10:48:14 -07:00
Moxie Marlinspike
e69e395b25 Support for receiving "canonical id" update events from pushserver.
// FREEBIE
2015-03-24 10:47:45 -07:00
Moxie Marlinspike
456164fc24 Support registering a 'voip' APN ID.
// FREEBIE
2015-03-24 10:47:20 -07:00
Moxie Marlinspike
9b7f61a09d Bump version to 0.43 2015-03-20 07:57:00 -07:00
Moxie Marlinspike
2de9adb7ae Make dispatch subscription/unsubscription synchronized. 2015-03-19 14:37:10 -07:00
Moxie Marlinspike
c7e0cc1158 Use a custom redis pubsub implementation rather than Jedis.
// FREEBIE
2015-03-17 13:30:51 -07:00
Moxie Marlinspike
e79861c30a Add connection duration stats.
// FREEBIE
2015-03-15 10:21:24 -07:00
Moxie Marlinspike
407f596b61 Bump version to 0.41
// FREEBIE
2015-03-13 14:38:39 -07:00
Moxie Marlinspike
41d30fc8dc Resubscribe listeners when subscription link breaks.
// FREEBIE
2015-03-13 10:56:48 -07:00
Moxie Marlinspike
2e429c5b35 Loop voice verification prompt 3 times.
Fixes #18
Closes #32

// FREEBIE
2015-03-13 10:09:40 -07:00
Moxie Marlinspike
28afe3470b Upgrade jedis to 2.6.2
// FREEBIE
2015-03-13 10:06:44 -07:00
Moxie Marlinspike
f623b24196 Fix string formatting.
// FREEBIE
2015-03-12 16:29:49 -07:00
Moxie Marlinspike
2016c17894 Bump version to 0.40 2015-03-10 18:29:14 -07:00
Moxie Marlinspike
2d28077010 Make idle timeout 90s
// FREEBIE
2015-03-10 17:27:43 -07:00
Moxie Marlinspike
7011f3c3c7 Fix 500 on validation error 2015-03-10 16:00:20 -07:00
Moxie Marlinspike
1403dbd5dd Handle pubsub callbacks from a cached thread pool.
...implement some belt and suspenders dead letter handling.

...implement some belt and suspenders redis pubsub queue handling.

// FREEBIE
2015-03-10 12:45:05 -07:00
Moxie Marlinspike
6ef3845a34 Don't consider empty relays present on receipt delivery. 2015-03-09 15:41:50 -07:00
Moxie Marlinspike
de2f0914f0 Reenable websocket notifications for Android.
// FREEBIE
2015-03-09 09:39:09 -07:00
Moxie Marlinspike
080ae0985f Support URL-safe Base64 encoding for directory tokens.
// FREEBIE
2015-03-09 09:34:02 -07:00
Moxie Marlinspike
887f49760f Bump version to 0.35
// FREEBIE
2015-03-07 09:25:24 -08:00
Moxie Marlinspike
be77f2291b Temporarily disable GCM websocket notifications.
Workaround for a client bug.

// FREEBIE
2015-03-07 09:24:55 -08:00
Moxie Marlinspike
b585b849a1 Bump version to 0.34 2015-03-05 11:59:41 -08:00
Moxie Marlinspike
0c94e3d994 Don't print the full stack trace for twilio exceptions. 2015-03-05 08:37:27 -08:00
Moxie Marlinspike
4a93658d0f Don't consider an empty string to be a possible relay.
// FREEBIE
2015-03-05 08:36:56 -08:00
Moxie Marlinspike
6da19c6254 Set registration id when newly provisioned device registers. 2015-03-05 08:36:30 -08:00
Moxie Marlinspike
289058be81 Bump version to 0.33 2015-02-23 21:26:27 -08:00
Moxie Marlinspike
864675ecde Return stored messages in order.
// FREEBIE
2015-02-23 12:14:41 -08:00
Moxie Marlinspike
c79d7e3e30 Close existing websocket connection for a device on new connect.
// FREEBIE
2015-02-23 12:11:07 -08:00
Moxie Marlinspike
549cc6f492 Bump version to 0.32
// FREEBIE
2015-02-23 11:27:25 -08:00
Moxie Marlinspike
aa84ab66af Support for GCM stored messages.
// FREEBIE
2015-02-04 14:19:50 -08:00
Moxie Marlinspike
1fef812c67 Bump version to 0.31
// FREEBIE
2015-02-02 09:37:50 -08:00
Moxie Marlinspike
9170f74887 Vacuum messages now too.
// FREEBIE
2015-02-02 08:59:32 -08:00
Moxie Marlinspike
c9bd700d31 Bump version to 0.30
// FREEBIE
2015-02-02 08:55:11 -08:00
Moxie Marlinspike
0928e4c035 Fix leaky bucket serialization.
// FREEBIE
2015-01-30 11:48:49 -08:00
Moxie Marlinspike
75aec0a8d4 Switch to Redis for all caching.
// FREEBIE
2015-01-29 15:37:28 -08:00
Moxie Marlinspike
1f5ee36a6b Switch to postgresql-backed message DB.
// FREEBIE
2015-01-29 13:25:33 -08:00
Moxie Marlinspike
45a0b74b89 Device provisioning fixes.
// FREEBIE
2015-01-21 15:15:40 -08:00
Moxie Marlinspike
f7132bdbbc Rearrange provisioning flow. Add needsMessageSync response.
// FREEBIE
2015-01-21 13:56:58 -08:00
Moxie Marlinspike
d2dbff173a Adjust encoding 2015-01-19 19:03:06 -08:00
Moxie Marlinspike
79f83babb3 Support for ephemeral provisioning communication channels.
// FREEBIE
2015-01-18 18:09:25 -08:00
Moxie Marlinspike
715181f830 Remove duplicate dependency.
// FREEBIE

Fixes #26
2015-01-03 20:26:11 -08:00
Moxie Marlinspike
5c1c80dad3 Bump version to 0.29
// FREEBIE
2015-01-03 19:43:46 -08:00
Moxie Marlinspike
32c0712715 Chunk local directory update queries.
// FREEBIE
2015-01-03 19:43:19 -08:00
Moxie Marlinspike
fa4e492d1c Get rid of GSON dependency.
// FREEBIE
2015-01-03 18:28:51 -08:00
Moxie Marlinspike
4711fa2a9a Bump version to 0.27
// FREEBIE
2015-01-03 17:34:44 -08:00
Moxie Marlinspike
08291502eb Expire in-memory queues after 30 days of inactivity.
// FREEBIE
2015-01-03 17:24:35 -08:00
Moxie Marlinspike
1f0acd0622 Don't warn on connection timeout exceptions.
// FREEBIE
2015-01-03 16:51:59 -08:00
Moxie Marlinspike
e88b732715 Add PaperTrail support.
// FREEBIE
2015-01-03 16:51:28 -08:00
Moxie Marlinspike
dafda85c36 Move JSON reporter to Dropwizard ReporterFactory structure. 2015-01-02 23:53:40 -08:00
Moxie Marlinspike
8441fa9687 Fix bugs associated with PubSub encoding.
// FREEBIE
2014-12-12 12:35:05 -08:00
Moxie Marlinspike
77800dfb01 Update websocket-resources.
// FREEBIE
2014-12-08 09:07:45 -08:00
Moxie Marlinspike
41d15b738b Refactor direct connect delivery pipeline and message store.
1) Make message store contents more memory efficient.

2) Make notification pipeline simpler and more memory efficient.

3) Don't b64 encode websocket message bodies.

// FREEBIE
2014-12-06 20:00:39 -08:00
Moxie Marlinspike
aa2a5ff929 Bump version to 0.26
// FREEBIE
2014-12-03 13:36:25 -08:00
Moxie Marlinspike
56d3c1e73f Turn down log levels.
// FREEBIE
2014-12-03 11:44:40 -08:00
Moxie Marlinspike
f401f9a674 Schedule at 1min instead of 10min.
// FREEBIE
2014-12-03 11:35:38 -08:00
Moxie Marlinspike
30933d792b Timestamp comparison should be the other way.
// FREEBIE
2014-12-03 11:33:34 -08:00
Moxie Marlinspike
905717977e Turn down logging on metrics reporter.
// FREEBIE
2014-12-03 11:09:37 -08:00
Moxie Marlinspike
b802994809 Do a timestamp comparison on unregister events.
// FREEBIE
2014-12-03 11:09:01 -08:00
Moxie Marlinspike
ac96f906b3 Bump version to 0.25
// FREEBIE
2014-12-02 15:37:40 -08:00
Moxie Marlinspike
cc395e914f Fix APN push payload.
// FREEBIE
2014-12-01 14:01:53 -08:00
Moxie Marlinspike
f8063f8faf Add feedback handler.
// FREEBIE
2014-12-01 13:27:06 -08:00
Moxie Marlinspike
958ada9110 Bump dropwizard version.
// FREEBIE
2014-12-01 12:10:14 -08:00
Moxie Marlinspike
3452ea29b8 Use push microservice instead of doing push directly.
// FREEBIE
2014-12-01 11:23:29 -08:00
Moxie Marlinspike
675b6f4b5e Update APN payload.
// FREEBIE
2014-11-27 18:20:23 -08:00
Moxie Marlinspike
4fab67b0f5 Switch to production APN endpoint.
// FREEBIE
2014-11-27 16:25:02 -08:00
152 changed files with 7524 additions and 3081 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ config/production.yml
config/federated.yml config/federated.yml
config/staging.yml config/staging.yml
.opsmanage .opsmanage
put.sh

View File

@@ -1,32 +1,16 @@
twilio: twilio: # Twilio SMS gateway configuration
accountId: accountId:
accountToken: accountToken:
number: number:
localDomain: # The domain Twilio can call back to. localDomain: # The domain Twilio can call back to.
international: # Boolean specifying Twilio for international delivery
# Optional. If specified, Nexmo will be used for non-US SMS and push: # GCM/APN push server configuration
# voice verification if twilio.international is false. Otherwise, host:
# Nexmo, if specified, Nexmo will only be used as a fallback port:
# for failed Twilio deliveries. username:
nexmo: password:
apiKey:
apiSecret:
number:
gcm: s3: # AWS S3 configuration
senderId:
apiKey:
# Optional. Only if iOS clients are supported.
apn:
# In PEM format.
certificate:
# In PEM format.
key:
s3:
accessKey: accessKey:
accessSecret: accessSecret:
@@ -35,13 +19,37 @@ s3:
# correct permissions. # correct permissions.
attachmentsBucket: attachmentsBucket:
memcache: directory: # Redis server configuration for TS directory
servers: url:
user:
password:
redis: cache: # Redis server configuration for general purpose caching
url: url:
websocket:
enabled: true
messageStore: # Postgres database configuration for message store
driverClass: org.postgresql.Driver
user:
password:
url:
database: # Postgres database configuration for account store
# the name of your JDBC driver
driverClass: org.postgresql.Driver
# the username
user:
# the password
password:
# the JDBC URL
url: jdbc:postgresql://somehost:somport/somedb
# any properties specific to your JDBC driver:
properties:
charSet: UTF-8
federation: federation:
name: name:
@@ -52,24 +60,3 @@ federation:
authenticationToken: foo authenticationToken: foo
certificate: in pem format certificate: in pem format
# Optional address of graphite server to report metrics
graphite:
host:
port:
database:
# the name of your JDBC driver
driverClass: org.postgresql.Driver
# the username
user:
# the password
password:
# the JDBC URL
url: jdbc:postgresql://somehost:somport/somedb
# any properties specific to your JDBC driver:
properties:
charSet: UTF-8

104
pom.xml
View File

@@ -9,12 +9,10 @@
<groupId>org.whispersystems.textsecure</groupId> <groupId>org.whispersystems.textsecure</groupId>
<artifactId>TextSecureServer</artifactId> <artifactId>TextSecureServer</artifactId>
<version>0.24</version> <version>0.93</version>
<properties> <properties>
<dropwizard.version>0.7.0</dropwizard.version> <dropwizard.version>0.9.0-rc3</dropwizard.version>
<jackson.api.version>2.3.3</jackson.api.version>
<commons-codec.version>1.6</commons-codec.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -53,103 +51,87 @@
<artifactId>dropwizard-metrics-graphite</artifactId> <artifactId>dropwizard-metrics-graphite</artifactId>
<version>${dropwizard.version}</version> <version>${dropwizard.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.sun.jersey</groupId> <groupId>com.dcsquare</groupId>
<artifactId>jersey-json</artifactId> <artifactId>dropwizard-papertrail</artifactId>
<version>1.18.1</version> <version>1.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.codahale.metrics</groupId> <groupId>org.bouncycastle</groupId>
<artifactId>metrics-graphite</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>websocket-server</artifactId>
<version>9.0.7.v20131107</version>
</dependency>
<dependency>
<groupId>bouncycastle</groupId>
<artifactId>bcprov-jdk16</artifactId> <artifactId>bcprov-jdk16</artifactId>
<version>140</version> <version>1.46</version>
</dependency>
<dependency>
<groupId>com.google.android.gcm</groupId>
<artifactId>gcm-server</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>net.spy</groupId>
<artifactId>spymemcached</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>com.notnoop.apns</groupId>
<artifactId>apns</artifactId>
<version>0.2.3</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.amazonaws</groupId> <groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk</artifactId> <artifactId>aws-java-sdk-s3</artifactId>
<version>1.4.1</version> <version>1.10.6</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.google.protobuf</groupId> <groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId> <artifactId>protobuf-java</artifactId>
<version>2.5.0</version> <version>2.6.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>redis.clients</groupId> <groupId>redis.clients</groupId>
<artifactId>jedis</artifactId> <artifactId>jedis</artifactId>
<version>2.2.1</version> <version>2.7.3</version>
<type>jar</type> <type>jar</type>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.twilio.sdk</groupId> <groupId>com.twilio.sdk</groupId>
<artifactId>twilio-java-sdk</artifactId> <artifactId>twilio-java-sdk</artifactId>
<version>3.4.5</version> <version>4.4.4</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<version>9.1-901.jdbc4</version> <version>9.4-1201-jdbc41</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.igniterealtime.smack</groupId> <groupId>org.whispersystems</groupId>
<artifactId>smack-tcp</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.whispersystems.websocket</groupId>
<artifactId>websocket-resources</artifactId> <artifactId>websocket-resources</artifactId>
<version>0.1-SNAPSHOT</version> <version>0.3.2</version>
</dependency>
<dependency>
<groupId>org.whispersystems</groupId>
<artifactId>dropwizard-simpleauth</artifactId>
<version>0.1.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
<version>2.19</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</exclusion>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>org.apache.httpcomponents</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>httpclient</artifactId>
<version>${jackson.api.version}</version> <version>4.4.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>commons-codec</groupId> <groupId>org.apache.httpcomponents</groupId>
<artifactId>commons-codec</artifactId> <artifactId>httpcore</artifactId>
<version>${commons-codec.version}</version> <version>4.4.1</version>
</dependency> </dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -1,3 +1,3 @@
all: all:
protoc --java_out=../src/main/java/ OutgoingMessageSignal.proto protoc --java_out=../src/main/java/ TextSecure.proto PubSubMessage.proto

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (C) 2013 Open WhisperSystems * Copyright (C) 2014 Open Whisper Systems
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
@@ -14,30 +14,21 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.whispersystems.textsecuregcm.configuration; package textsecure;
import com.fasterxml.jackson.annotation.JsonProperty; option java_package = "org.whispersystems.textsecuregcm.storage";
option java_outer_classname = "PubSubProtos";
public class NexmoConfiguration { message PubSubMessage {
enum Type {
@JsonProperty UNKNOWN = 0;
private String apiKey; QUERY_DB = 1;
DELIVER = 2;
@JsonProperty KEEPALIVE = 3;
private String apiSecret; CLOSE = 4;
CONNECTED = 5;
@JsonProperty
private String number;
public String getApiKey() {
return apiKey;
} }
public String getApiSecret() { optional Type type = 1;
return apiSecret; optional bytes content = 2;
}
public String getNumber() {
return number;
}
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (C) 2013 Open WhisperSystems * Copyright (C) 2014 Open Whisper Systems
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
@@ -14,33 +14,17 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.whispersystems.textsecuregcm.configuration; package textsecure;
import com.fasterxml.jackson.annotation.JsonProperty; option java_package = "org.whispersystems.textsecuregcm.storage";
import org.hibernate.validator.constraints.NotEmpty; option java_outer_classname = "StoredMessageProtos";
public class MemcacheConfiguration { message StoredMessage {
enum Type {
@NotEmpty UNKNOWN = 0;
@JsonProperty MESSAGE = 1;
private String servers;
@JsonProperty
private String user;
@JsonProperty
private String password;
public String getServers() {
return servers;
} }
public String getUser() { optional Type type = 1;
return user; optional bytes content = 2;
}
public String getPassword() {
return password;
}
} }

View File

@@ -1,5 +1,5 @@
/** /**
* Copyright (C) 2013 Open WhisperSystems * Copyright (C) 2013 - 2015 Open WhisperSystems
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
@@ -19,21 +19,24 @@ package textsecure;
option java_package = "org.whispersystems.textsecuregcm.entities"; option java_package = "org.whispersystems.textsecuregcm.entities";
option java_outer_classname = "MessageProtos"; option java_outer_classname = "MessageProtos";
message OutgoingMessageSignal { message Envelope {
enum Type { enum Type {
UNKNOWN = 0; UNKNOWN = 0;
CIPHERTEXT = 1; CIPHERTEXT = 1;
KEY_EXCHANGE = 2; KEY_EXCHANGE = 2;
PREKEY_BUNDLE = 3; PREKEY_BUNDLE = 3;
PLAINTEXT = 4;
RECEIPT = 5; RECEIPT = 5;
} }
optional uint32 type = 1; optional Type type = 1;
optional string source = 2; optional string source = 2;
optional uint32 sourceDevice = 7; optional uint32 sourceDevice = 7;
optional string relay = 3; optional string relay = 3;
// repeated string destinations = 4; optional uint64 timestamp = 5;
optional uint64 timestamp = 5; optional bytes legacyMessage = 6; // Contains an encrypted DataMessage XXX -- Remove after 10/01/15
optional bytes message = 6; optional bytes content = 8; // Contains an encrypted Content
}
message ProvisioningUuid {
optional string uuid = 1;
} }

View File

@@ -0,0 +1,7 @@
package org.whispersystems.dispatch;
public interface DispatchChannel {
public void onDispatchMessage(String channel, byte[] message);
public void onDispatchSubscribed(String channel);
public void onDispatchUnsubscribed(String channel);
}

View File

@@ -0,0 +1,172 @@
package org.whispersystems.dispatch;
import com.google.common.base.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.io.RedisPubSubConnectionFactory;
import org.whispersystems.dispatch.redis.PubSubConnection;
import org.whispersystems.dispatch.redis.PubSubReply;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class DispatchManager extends Thread {
private final Logger logger = LoggerFactory.getLogger(DispatchManager.class);
private final Executor executor = Executors.newCachedThreadPool();
private final Map<String, DispatchChannel> subscriptions = new ConcurrentHashMap<>();
private final Optional<DispatchChannel> deadLetterChannel;
private final RedisPubSubConnectionFactory redisPubSubConnectionFactory;
private PubSubConnection pubSubConnection;
private volatile boolean running;
public DispatchManager(RedisPubSubConnectionFactory redisPubSubConnectionFactory,
Optional<DispatchChannel> deadLetterChannel)
{
this.redisPubSubConnectionFactory = redisPubSubConnectionFactory;
this.deadLetterChannel = deadLetterChannel;
}
@Override
public void start() {
this.pubSubConnection = redisPubSubConnectionFactory.connect();
this.running = true;
super.start();
}
public void shutdown() {
this.running = false;
this.pubSubConnection.close();
}
public synchronized void subscribe(String name, DispatchChannel dispatchChannel) {
Optional<DispatchChannel> previous = Optional.fromNullable(subscriptions.get(name));
subscriptions.put(name, dispatchChannel);
try {
pubSubConnection.subscribe(name);
} catch (IOException e) {
logger.warn("Subscription error", e);
}
if (previous.isPresent()) {
dispatchUnsubscription(name, previous.get());
}
}
public synchronized void unsubscribe(String name, DispatchChannel channel) {
Optional<DispatchChannel> subscription = Optional.fromNullable(subscriptions.get(name));
if (subscription.isPresent() && subscription.get() == channel) {
subscriptions.remove(name);
try {
pubSubConnection.unsubscribe(name);
} catch (IOException e) {
logger.warn("Unsubscribe error", e);
}
dispatchUnsubscription(name, subscription.get());
}
}
public boolean hasSubscription(String name) {
return subscriptions.containsKey(name);
}
@Override
public void run() {
while (running) {
try {
PubSubReply reply = pubSubConnection.read();
switch (reply.getType()) {
case UNSUBSCRIBE: break;
case SUBSCRIBE: dispatchSubscribe(reply); break;
case MESSAGE: dispatchMessage(reply); break;
default: throw new AssertionError("Unknown pubsub reply type! " + reply.getType());
}
} catch (IOException e) {
logger.warn("***** PubSub Connection Error *****", e);
if (running) {
this.pubSubConnection.close();
this.pubSubConnection = redisPubSubConnectionFactory.connect();
resubscribeAll();
}
}
}
logger.warn("DispatchManager Shutting Down...");
}
private void dispatchSubscribe(final PubSubReply reply) {
Optional<DispatchChannel> subscription = Optional.fromNullable(subscriptions.get(reply.getChannel()));
if (subscription.isPresent()) {
dispatchSubscription(reply.getChannel(), subscription.get());
} else {
logger.info("Received subscribe event for non-existing channel: " + reply.getChannel());
}
}
private void dispatchMessage(PubSubReply reply) {
Optional<DispatchChannel> subscription = Optional.fromNullable(subscriptions.get(reply.getChannel()));
if (subscription.isPresent()) {
dispatchMessage(reply.getChannel(), subscription.get(), reply.getContent().get());
} else if (deadLetterChannel.isPresent()) {
dispatchMessage(reply.getChannel(), deadLetterChannel.get(), reply.getContent().get());
} else {
logger.warn("Received message for non-existing channel, with no dead letter handler: " + reply.getChannel());
}
}
private void resubscribeAll() {
new Thread() {
@Override
public void run() {
synchronized (DispatchManager.this) {
try {
for (String name : subscriptions.keySet()) {
pubSubConnection.subscribe(name);
}
} catch (IOException e) {
logger.warn("***** RESUBSCRIPTION ERROR *****", e);
}
}
}
}.start();
}
private void dispatchMessage(final String name, final DispatchChannel channel, final byte[] message) {
executor.execute(new Runnable() {
@Override
public void run() {
channel.onDispatchMessage(name, message);
}
});
}
private void dispatchSubscription(final String name, final DispatchChannel channel) {
executor.execute(new Runnable() {
@Override
public void run() {
channel.onDispatchSubscribed(name);
}
});
}
private void dispatchUnsubscription(final String name, final DispatchChannel channel) {
executor.execute(new Runnable() {
@Override
public void run() {
channel.onDispatchUnsubscribed(name);
}
});
}
}

View File

@@ -0,0 +1,64 @@
package org.whispersystems.dispatch.io;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
public class RedisInputStream {
private static final byte CR = 0x0D;
private static final byte LF = 0x0A;
private final InputStream inputStream;
public RedisInputStream(InputStream inputStream) {
this.inputStream = inputStream;
}
public String readLine() throws IOException {
ByteArrayOutputStream boas = new ByteArrayOutputStream();
boolean foundCr = false;
while (true) {
int character = inputStream.read();
if (character == -1) {
throw new IOException("Stream closed!");
}
boas.write(character);
if (foundCr && character == LF) break;
else if (character == CR) foundCr = true;
else if (foundCr) foundCr = false;
}
byte[] data = boas.toByteArray();
return new String(data, 0, data.length-2);
}
public byte[] readFully(int size) throws IOException {
byte[] result = new byte[size];
int offset = 0;
int remaining = result.length;
while (remaining > 0) {
int read = inputStream.read(result, offset, remaining);
if (read < 0) {
throw new IOException("Stream closed!");
}
offset += read;
remaining -= read;
}
return result;
}
public void close() throws IOException {
inputStream.close();
}
}

View File

@@ -0,0 +1,9 @@
package org.whispersystems.dispatch.io;
import org.whispersystems.dispatch.redis.PubSubConnection;
public interface RedisPubSubConnectionFactory {
public PubSubConnection connect();
}

View File

@@ -0,0 +1,119 @@
package org.whispersystems.dispatch.redis;
import com.google.common.base.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.io.RedisInputStream;
import org.whispersystems.dispatch.redis.protocol.ArrayReplyHeader;
import org.whispersystems.dispatch.redis.protocol.IntReply;
import org.whispersystems.dispatch.redis.protocol.StringReplyHeader;
import org.whispersystems.dispatch.util.Util;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicBoolean;
public class PubSubConnection {
private final Logger logger = LoggerFactory.getLogger(PubSubConnection.class);
private static final byte[] UNSUBSCRIBE_TYPE = {'u', 'n', 's', 'u', 'b', 's', 'c', 'r', 'i', 'b', 'e' };
private static final byte[] SUBSCRIBE_TYPE = {'s', 'u', 'b', 's', 'c', 'r', 'i', 'b', 'e' };
private static final byte[] MESSAGE_TYPE = {'m', 'e', 's', 's', 'a', 'g', 'e' };
private static final byte[] SUBSCRIBE_COMMAND = {'S', 'U', 'B', 'S', 'C', 'R', 'I', 'B', 'E', ' ' };
private static final byte[] UNSUBSCRIBE_COMMAND = {'U', 'N', 'S', 'U', 'B', 'S', 'C', 'R', 'I', 'B', 'E', ' '};
private static final byte[] CRLF = {'\r', '\n' };
private final OutputStream outputStream;
private final RedisInputStream inputStream;
private final Socket socket;
private final AtomicBoolean closed;
public PubSubConnection(Socket socket) throws IOException {
this.socket = socket;
this.outputStream = socket.getOutputStream();
this.inputStream = new RedisInputStream(new BufferedInputStream(socket.getInputStream()));
this.closed = new AtomicBoolean(false);
}
public void subscribe(String channelName) throws IOException {
if (closed.get()) throw new IOException("Connection closed!");
byte[] command = Util.combine(SUBSCRIBE_COMMAND, channelName.getBytes(), CRLF);
outputStream.write(command);
}
public void unsubscribe(String channelName) throws IOException {
if (closed.get()) throw new IOException("Connection closed!");
byte[] command = Util.combine(UNSUBSCRIBE_COMMAND, channelName.getBytes(), CRLF);
outputStream.write(command);
}
public PubSubReply read() throws IOException {
if (closed.get()) throw new IOException("Connection closed!");
ArrayReplyHeader replyHeader = new ArrayReplyHeader(inputStream.readLine());
if (replyHeader.getElementCount() != 3) {
throw new IOException("Received array reply header with strange count: " + replyHeader.getElementCount());
}
StringReplyHeader replyTypeHeader = new StringReplyHeader(inputStream.readLine());
byte[] replyType = inputStream.readFully(replyTypeHeader.getStringLength());
inputStream.readLine();
if (Arrays.equals(SUBSCRIBE_TYPE, replyType)) return readSubscribeReply();
else if (Arrays.equals(UNSUBSCRIBE_TYPE, replyType)) return readUnsubscribeReply();
else if (Arrays.equals(MESSAGE_TYPE, replyType)) return readMessageReply();
else throw new IOException("Unknown reply type: " + new String(replyType));
}
public void close() {
try {
this.closed.set(true);
this.inputStream.close();
this.outputStream.close();
this.socket.close();
} catch (IOException e) {
logger.warn("Exception while closing", e);
}
}
private PubSubReply readMessageReply() throws IOException {
StringReplyHeader channelNameHeader = new StringReplyHeader(inputStream.readLine());
byte[] channelName = inputStream.readFully(channelNameHeader.getStringLength());
inputStream.readLine();
StringReplyHeader messageHeader = new StringReplyHeader(inputStream.readLine());
byte[] message = inputStream.readFully(messageHeader.getStringLength());
inputStream.readLine();
return new PubSubReply(PubSubReply.Type.MESSAGE, new String(channelName), Optional.of(message));
}
private PubSubReply readUnsubscribeReply() throws IOException {
String channelName = readSubscriptionReply();
return new PubSubReply(PubSubReply.Type.UNSUBSCRIBE, channelName, Optional.<byte[]>absent());
}
private PubSubReply readSubscribeReply() throws IOException {
String channelName = readSubscriptionReply();
return new PubSubReply(PubSubReply.Type.SUBSCRIBE, channelName, Optional.<byte[]>absent());
}
private String readSubscriptionReply() throws IOException {
StringReplyHeader channelNameHeader = new StringReplyHeader(inputStream.readLine());
byte[] channelName = inputStream.readFully(channelNameHeader.getStringLength());
inputStream.readLine();
IntReply subscriptionCount = new IntReply(inputStream.readLine());
return new String(channelName);
}
}

View File

@@ -0,0 +1,35 @@
package org.whispersystems.dispatch.redis;
import com.google.common.base.Optional;
public class PubSubReply {
public enum Type {
MESSAGE,
SUBSCRIBE,
UNSUBSCRIBE
}
private final Type type;
private final String channel;
private final Optional<byte[]> content;
public PubSubReply(Type type, String channel, Optional<byte[]> content) {
this.type = type;
this.channel = channel;
this.content = content;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public Optional<byte[]> getContent() {
return content;
}
}

View File

@@ -0,0 +1,24 @@
package org.whispersystems.dispatch.redis.protocol;
import java.io.IOException;
public class ArrayReplyHeader {
private final int elementCount;
public ArrayReplyHeader(String header) throws IOException {
if (header == null || header.length() < 2 || header.charAt(0) != '*') {
throw new IOException("Invalid array reply header: " + header);
}
try {
this.elementCount = Integer.parseInt(header.substring(1));
} catch (NumberFormatException e) {
throw new IOException(e);
}
}
public int getElementCount() {
return elementCount;
}
}

View File

@@ -0,0 +1,24 @@
package org.whispersystems.dispatch.redis.protocol;
import java.io.IOException;
public class IntReply {
private final int value;
public IntReply(String reply) throws IOException {
if (reply == null || reply.length() < 2 || reply.charAt(0) != ':') {
throw new IOException("Invalid int reply: " + reply);
}
try {
this.value = Integer.parseInt(reply.substring(1));
} catch (NumberFormatException e) {
throw new IOException(e);
}
}
public int getValue() {
return value;
}
}

View File

@@ -0,0 +1,24 @@
package org.whispersystems.dispatch.redis.protocol;
import java.io.IOException;
public class StringReplyHeader {
private final int stringLength;
public StringReplyHeader(String header) throws IOException {
if (header == null || header.length() < 2 || header.charAt(0) != '$') {
throw new IOException("Invalid string reply header: " + header);
}
try {
this.stringLength = Integer.parseInt(header.substring(1));
} catch (NumberFormatException e) {
throw new IOException(e);
}
}
public int getStringLength() {
return stringLength;
}
}

View File

@@ -0,0 +1,36 @@
package org.whispersystems.dispatch.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class Util {
public static byte[] combine(byte[]... elements) {
try {
int sum = 0;
for (byte[] element : elements) {
sum += element.length;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream(sum);
for (byte[] element : elements) {
baos.write(element);
}
return baos.toByteArray();
} catch (IOException e) {
throw new AssertionError(e);
}
}
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new AssertionError(e);
}
}
}

View File

@@ -17,24 +17,26 @@
package org.whispersystems.textsecuregcm; package org.whispersystems.textsecuregcm;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
import org.whispersystems.textsecuregcm.configuration.FederationConfiguration; import org.whispersystems.textsecuregcm.configuration.FederationConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GraphiteConfiguration; import org.whispersystems.textsecuregcm.configuration.GraphiteConfiguration;
import org.whispersystems.textsecuregcm.configuration.MemcacheConfiguration; import org.whispersystems.textsecuregcm.configuration.PushConfiguration;
import org.whispersystems.textsecuregcm.configuration.MetricsConfiguration;
import org.whispersystems.textsecuregcm.configuration.NexmoConfiguration;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedPhoneConfiguration; import org.whispersystems.textsecuregcm.configuration.RedPhoneConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
import org.whispersystems.textsecuregcm.configuration.S3Configuration; import org.whispersystems.textsecuregcm.configuration.S3Configuration;
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration; import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
import org.whispersystems.textsecuregcm.configuration.WebsocketConfiguration; import org.whispersystems.textsecuregcm.configuration.WebsocketConfiguration;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import io.dropwizard.Configuration; import io.dropwizard.Configuration;
import io.dropwizard.client.JerseyClientConfiguration;
import io.dropwizard.db.DataSourceFactory; import io.dropwizard.db.DataSourceFactory;
public class WhisperServerConfiguration extends Configuration { public class WhisperServerConfiguration extends Configuration {
@@ -44,13 +46,10 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty @JsonProperty
private TwilioConfiguration twilio; private TwilioConfiguration twilio;
@JsonProperty
private NexmoConfiguration nexmo;
@NotNull @NotNull
@Valid @Valid
@JsonProperty @JsonProperty
private GcmConfiguration gcm; private PushConfiguration push;
@NotNull @NotNull
@Valid @Valid
@@ -60,15 +59,22 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull @NotNull
@Valid @Valid
@JsonProperty @JsonProperty
private MemcacheConfiguration memcache; private RedisConfiguration cache;
@NotNull @NotNull
@Valid @Valid
@JsonProperty @JsonProperty
private RedisConfiguration redis; private RedisConfiguration directory;
@Valid
@NotNull
@JsonProperty @JsonProperty
private ApnConfiguration apn = new ApnConfiguration(); private DataSourceFactory messageStore;
@Valid
@NotNull
@JsonProperty
private List<TestDeviceConfiguration> testDevices = new LinkedList<>();
@Valid @Valid
@JsonProperty @JsonProperty
@@ -88,10 +94,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty @JsonProperty
private GraphiteConfiguration graphite = new GraphiteConfiguration(); private GraphiteConfiguration graphite = new GraphiteConfiguration();
@Valid
@JsonProperty
private MetricsConfiguration viz = new MetricsConfiguration();
@Valid @Valid
@JsonProperty @JsonProperty
private WebsocketConfiguration websocket = new WebsocketConfiguration(); private WebsocketConfiguration websocket = new WebsocketConfiguration();
@@ -99,6 +101,12 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty @JsonProperty
private RedPhoneConfiguration redphone = new RedPhoneConfiguration(); private RedPhoneConfiguration redphone = new RedPhoneConfiguration();
@Valid
@NotNull
@JsonProperty
private JerseyClientConfiguration httpClient = new JerseyClientConfiguration();
public WebsocketConfiguration getWebsocketConfiguration() { public WebsocketConfiguration getWebsocketConfiguration() {
return websocket; return websocket;
} }
@@ -107,28 +115,28 @@ public class WhisperServerConfiguration extends Configuration {
return twilio; return twilio;
} }
public NexmoConfiguration getNexmoConfiguration() { public PushConfiguration getPushConfiguration() {
return nexmo; return push;
} }
public GcmConfiguration getGcmConfiguration() { public JerseyClientConfiguration getJerseyClientConfiguration() {
return gcm; return httpClient;
}
public ApnConfiguration getApnConfiguration() {
return apn;
} }
public S3Configuration getS3Configuration() { public S3Configuration getS3Configuration() {
return s3; return s3;
} }
public MemcacheConfiguration getMemcacheConfiguration() { public RedisConfiguration getCacheConfiguration() {
return memcache; return cache;
} }
public RedisConfiguration getRedisConfiguration() { public RedisConfiguration getDirectoryConfiguration() {
return redis; return directory;
}
public DataSourceFactory getMessageStoreConfiguration() {
return messageStore;
} }
public DataSourceFactory getDataSourceFactory() { public DataSourceFactory getDataSourceFactory() {
@@ -147,11 +155,18 @@ public class WhisperServerConfiguration extends Configuration {
return graphite; return graphite;
} }
public MetricsConfiguration getMetricsConfiguration() {
return viz;
}
public RedPhoneConfiguration getRedphoneConfiguration() { public RedPhoneConfiguration getRedphoneConfiguration() {
return redphone; return redphone;
} }
public Map<String, Integer> getTestDevices() {
Map<String, Integer> results = new HashMap<>();
for (TestDeviceConfiguration testDeviceConfiguration : testDevices) {
results.put(testDeviceConfiguration.getNumber(),
testDeviceConfiguration.getCode());
}
return results;
}
} }

View File

@@ -18,64 +18,77 @@ package org.whispersystems.textsecuregcm;
import com.codahale.metrics.SharedMetricRegistries; import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.graphite.GraphiteReporter; import com.codahale.metrics.graphite.GraphiteReporter;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import net.spy.memcached.MemcachedClient;
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.eclipse.jetty.servlets.CrossOriginFilter; import org.eclipse.jetty.servlets.CrossOriginFilter;
import org.glassfish.jersey.client.ClientProperties;
import org.skife.jdbi.v2.DBI; import org.skife.jdbi.v2.DBI;
import org.whispersystems.dispatch.DispatchChannel;
import org.whispersystems.dispatch.DispatchManager;
import org.whispersystems.dropwizard.simpleauth.AuthDynamicFeature;
import org.whispersystems.dropwizard.simpleauth.AuthValueFactoryProvider;
import org.whispersystems.dropwizard.simpleauth.BasicCredentialAuthFilter;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.FederatedPeerAuthenticator; import org.whispersystems.textsecuregcm.auth.FederatedPeerAuthenticator;
import org.whispersystems.textsecuregcm.auth.MultiBasicAuthProvider;
import org.whispersystems.textsecuregcm.configuration.NexmoConfiguration;
import org.whispersystems.textsecuregcm.controllers.AccountController; import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.AttachmentController; import org.whispersystems.textsecuregcm.controllers.AttachmentController;
import org.whispersystems.textsecuregcm.controllers.DeviceController; import org.whispersystems.textsecuregcm.controllers.DeviceController;
import org.whispersystems.textsecuregcm.controllers.DirectoryController; import org.whispersystems.textsecuregcm.controllers.DirectoryController;
import org.whispersystems.textsecuregcm.controllers.FederationControllerV1; import org.whispersystems.textsecuregcm.controllers.FederationControllerV1;
import org.whispersystems.textsecuregcm.controllers.FederationControllerV2; import org.whispersystems.textsecuregcm.controllers.FederationControllerV2;
import org.whispersystems.textsecuregcm.controllers.KeepAliveController;
import org.whispersystems.textsecuregcm.controllers.KeysControllerV1; import org.whispersystems.textsecuregcm.controllers.KeysControllerV1;
import org.whispersystems.textsecuregcm.controllers.KeysControllerV2; import org.whispersystems.textsecuregcm.controllers.KeysControllerV2;
import org.whispersystems.textsecuregcm.controllers.MessageController; import org.whispersystems.textsecuregcm.controllers.MessageController;
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
import org.whispersystems.textsecuregcm.controllers.ReceiptController; import org.whispersystems.textsecuregcm.controllers.ReceiptController;
import org.whispersystems.textsecuregcm.federation.FederatedClientManager; import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
import org.whispersystems.textsecuregcm.federation.FederatedPeer; import org.whispersystems.textsecuregcm.federation.FederatedPeer;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper; import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge; import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge;
import org.whispersystems.textsecuregcm.metrics.FileDescriptorGauge;
import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge; import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge;
import org.whispersystems.textsecuregcm.metrics.JsonMetricsReporter;
import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge; import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge;
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge; import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
import org.whispersystems.textsecuregcm.providers.MemcacheHealthCheck;
import org.whispersystems.textsecuregcm.providers.MemcachedClientFactory;
import org.whispersystems.textsecuregcm.providers.RedisClientFactory; import org.whispersystems.textsecuregcm.providers.RedisClientFactory;
import org.whispersystems.textsecuregcm.providers.RedisHealthCheck; import org.whispersystems.textsecuregcm.providers.RedisHealthCheck;
import org.whispersystems.textsecuregcm.providers.TimeProvider; import org.whispersystems.textsecuregcm.providers.TimeProvider;
import org.whispersystems.textsecuregcm.push.APNSender; import org.whispersystems.textsecuregcm.push.ApnFallbackManager;
import org.whispersystems.textsecuregcm.push.GCMSender; import org.whispersystems.textsecuregcm.push.FeedbackHandler;
import org.whispersystems.textsecuregcm.push.PushSender; import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.PushServiceClient;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.WebsocketSender; import org.whispersystems.textsecuregcm.push.WebsocketSender;
import org.whispersystems.textsecuregcm.sms.NexmoSmsSender;
import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.SmsSender;
import org.whispersystems.textsecuregcm.sms.TwilioSmsSender; import org.whispersystems.textsecuregcm.sms.TwilioSmsSender;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Accounts; import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DirectoryManager; import org.whispersystems.textsecuregcm.storage.DirectoryManager;
import org.whispersystems.textsecuregcm.storage.Keys; import org.whispersystems.textsecuregcm.storage.Keys;
import org.whispersystems.textsecuregcm.storage.Messages;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingAccounts; import org.whispersystems.textsecuregcm.storage.PendingAccounts;
import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
import org.whispersystems.textsecuregcm.storage.PendingDevices; import org.whispersystems.textsecuregcm.storage.PendingDevices;
import org.whispersystems.textsecuregcm.storage.PendingDevicesManager; import org.whispersystems.textsecuregcm.storage.PendingDevicesManager;
import org.whispersystems.textsecuregcm.storage.PubSubManager; import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.StoredMessages;
import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.UrlSigner; import org.whispersystems.textsecuregcm.util.UrlSigner;
import org.whispersystems.textsecuregcm.websocket.ConnectListener; import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
import org.whispersystems.textsecuregcm.websocket.DeadLetterHandler;
import org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener;
import org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator; import org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator;
import org.whispersystems.textsecuregcm.workers.DirectoryCommand; import org.whispersystems.textsecuregcm.workers.DirectoryCommand;
import org.whispersystems.textsecuregcm.workers.TrimMessagesCommand;
import org.whispersystems.textsecuregcm.workers.VacuumCommand; import org.whispersystems.textsecuregcm.workers.VacuumCommand;
import org.whispersystems.websocket.WebSocketResourceProviderFactory; import org.whispersystems.websocket.WebSocketResourceProviderFactory;
import org.whispersystems.websocket.setup.WebSocketEnvironment; import org.whispersystems.websocket.setup.WebSocketEnvironment;
@@ -83,16 +96,17 @@ import org.whispersystems.websocket.setup.WebSocketEnvironment;
import javax.servlet.DispatcherType; import javax.servlet.DispatcherType;
import javax.servlet.FilterRegistration; import javax.servlet.FilterRegistration;
import javax.servlet.ServletRegistration; import javax.servlet.ServletRegistration;
import javax.ws.rs.client.Client;
import java.security.Security; import java.security.Security;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.Application; import io.dropwizard.Application;
import io.dropwizard.client.JerseyClientBuilder;
import io.dropwizard.db.DataSourceFactory; import io.dropwizard.db.DataSourceFactory;
import io.dropwizard.jdbi.DBIFactory; import io.dropwizard.jdbi.DBIFactory;
import io.dropwizard.metrics.graphite.GraphiteReporterFactory; import io.dropwizard.metrics.graphite.GraphiteReporterFactory;
import io.dropwizard.migrations.MigrationsBundle;
import io.dropwizard.setup.Bootstrap; import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment; import io.dropwizard.setup.Environment;
import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPool;
@@ -107,12 +121,20 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
public void initialize(Bootstrap<WhisperServerConfiguration> bootstrap) { public void initialize(Bootstrap<WhisperServerConfiguration> bootstrap) {
bootstrap.addCommand(new DirectoryCommand()); bootstrap.addCommand(new DirectoryCommand());
bootstrap.addCommand(new VacuumCommand()); bootstrap.addCommand(new VacuumCommand());
bootstrap.addBundle(new MigrationsBundle<WhisperServerConfiguration>() { bootstrap.addCommand(new TrimMessagesCommand());
bootstrap.addBundle(new NameableMigrationsBundle<WhisperServerConfiguration>("accountdb", "accountsdb.xml") {
@Override @Override
public DataSourceFactory getDataSourceFactory(WhisperServerConfiguration configuration) { public DataSourceFactory getDataSourceFactory(WhisperServerConfiguration configuration) {
return configuration.getDataSourceFactory(); return configuration.getDataSourceFactory();
} }
}); });
bootstrap.addBundle(new NameableMigrationsBundle<WhisperServerConfiguration>("messagedb", "messagedb.xml") {
@Override
public DataSourceFactory getDataSourceFactory(WhisperServerConfiguration configuration) {
return configuration.getMessageStoreConfiguration();
}
});
} }
@Override @Override
@@ -126,100 +148,127 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
{ {
SharedMetricRegistries.add(Constants.METRICS_NAME, environment.metrics()); SharedMetricRegistries.add(Constants.METRICS_NAME, environment.metrics());
environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
DBIFactory dbiFactory = new DBIFactory(); DBIFactory dbiFactory = new DBIFactory();
DBI jdbi = dbiFactory.build(environment, config.getDataSourceFactory(), "postgresql"); DBI database = dbiFactory.build(environment, config.getDataSourceFactory(), "accountdb");
DBI messagedb = dbiFactory.build(environment, config.getMessageStoreConfiguration(), "messagedb");
Accounts accounts = jdbi.onDemand(Accounts.class); Accounts accounts = database.onDemand(Accounts.class);
PendingAccounts pendingAccounts = jdbi.onDemand(PendingAccounts.class); PendingAccounts pendingAccounts = database.onDemand(PendingAccounts.class);
PendingDevices pendingDevices = jdbi.onDemand(PendingDevices.class); PendingDevices pendingDevices = database.onDemand(PendingDevices.class);
Keys keys = jdbi.onDemand(Keys.class); Keys keys = database.onDemand(Keys.class);
Messages messages = messagedb.onDemand(Messages.class);
MemcachedClient memcachedClient = new MemcachedClientFactory(config.getMemcacheConfiguration()).getClient(); RedisClientFactory cacheClientFactory = new RedisClientFactory(config.getCacheConfiguration().getUrl());
JedisPool redisClient = new RedisClientFactory(config.getRedisConfiguration()).getRedisClientPool(); JedisPool cacheClient = cacheClientFactory.getRedisClientPool();
JedisPool directoryClient = new RedisClientFactory(config.getDirectoryConfiguration().getUrl()).getRedisClientPool();
Client httpClient = initializeHttpClient(environment, config);
DirectoryManager directory = new DirectoryManager(redisClient); DirectoryManager directory = new DirectoryManager(directoryClient);
PendingAccountsManager pendingAccountsManager = new PendingAccountsManager(pendingAccounts, memcachedClient); PendingAccountsManager pendingAccountsManager = new PendingAccountsManager(pendingAccounts, cacheClient);
PendingDevicesManager pendingDevicesManager = new PendingDevicesManager (pendingDevices, memcachedClient ); PendingDevicesManager pendingDevicesManager = new PendingDevicesManager (pendingDevices, cacheClient );
AccountsManager accountsManager = new AccountsManager(accounts, directory, memcachedClient); AccountsManager accountsManager = new AccountsManager(accounts, directory, cacheClient);
FederatedClientManager federatedClientManager = new FederatedClientManager(config.getFederationConfiguration()); FederatedClientManager federatedClientManager = new FederatedClientManager(environment, config.getJerseyClientConfiguration(), config.getFederationConfiguration());
StoredMessages storedMessages = new StoredMessages(redisClient); MessagesManager messagesManager = new MessagesManager(messages);
PubSubManager pubSubManager = new PubSubManager(redisClient); DeadLetterHandler deadLetterHandler = new DeadLetterHandler(messagesManager);
DispatchManager dispatchManager = new DispatchManager(cacheClientFactory, Optional.<DispatchChannel>of(deadLetterHandler));
APNSender apnSender = new APNSender(accountsManager, pubSubManager, storedMessages, memcachedClient, PubSubManager pubSubManager = new PubSubManager(cacheClient, dispatchManager);
config.getApnConfiguration().getCertificate(), PushServiceClient pushServiceClient = new PushServiceClient(httpClient, config.getPushConfiguration());
config.getApnConfiguration().getKey()); WebsocketSender websocketSender = new WebsocketSender(messagesManager, pubSubManager);
AccountAuthenticator deviceAuthenticator = new AccountAuthenticator(accountsManager );
GCMSender gcmSender = new GCMSender(accountsManager, FederatedPeerAuthenticator federatedPeerAuthenticator = new FederatedPeerAuthenticator(config.getFederationConfiguration());
config.getGcmConfiguration().getSenderId(), RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), cacheClient);
config.getGcmConfiguration().getApiKey());
WebsocketSender websocketSender = new WebsocketSender(storedMessages, pubSubManager);
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(gcmSender);
AccountAuthenticator deviceAuthenticator = new AccountAuthenticator(accountsManager);
RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), memcachedClient);
ApnFallbackManager apnFallbackManager = new ApnFallbackManager(pushServiceClient, pubSubManager);
TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration()); TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration());
Optional<NexmoSmsSender> nexmoSmsSender = initializeNexmoSmsSender(config.getNexmoConfiguration()); SmsSender smsSender = new SmsSender(twilioSmsSender);
SmsSender smsSender = new SmsSender(twilioSmsSender, nexmoSmsSender, config.getTwilioConfiguration().isInternational());
UrlSigner urlSigner = new UrlSigner(config.getS3Configuration()); UrlSigner urlSigner = new UrlSigner(config.getS3Configuration());
PushSender pushSender = new PushSender(gcmSender, apnSender, websocketSender); PushSender pushSender = new PushSender(apnFallbackManager, pushServiceClient, websocketSender, config.getPushConfiguration().getQueueSize());
ReceiptSender receiptSender = new ReceiptSender(accountsManager, pushSender, federatedClientManager);
FeedbackHandler feedbackHandler = new FeedbackHandler(pushServiceClient, accountsManager);
Optional<byte[]> authorizationKey = config.getRedphoneConfiguration().getAuthorizationKey(); Optional<byte[]> authorizationKey = config.getRedphoneConfiguration().getAuthorizationKey();
environment.lifecycle().manage(apnFallbackManager);
environment.lifecycle().manage(pubSubManager);
environment.lifecycle().manage(feedbackHandler);
environment.lifecycle().manage(pushSender);
AttachmentController attachmentController = new AttachmentController(rateLimiters, federatedClientManager, urlSigner); AttachmentController attachmentController = new AttachmentController(rateLimiters, federatedClientManager, urlSigner);
KeysControllerV1 keysControllerV1 = new KeysControllerV1(rateLimiters, keys, accountsManager, federatedClientManager); KeysControllerV1 keysControllerV1 = new KeysControllerV1(rateLimiters, keys, accountsManager, federatedClientManager);
KeysControllerV2 keysControllerV2 = new KeysControllerV2(rateLimiters, keys, accountsManager, federatedClientManager); KeysControllerV2 keysControllerV2 = new KeysControllerV2(rateLimiters, keys, accountsManager, federatedClientManager);
MessageController messageController = new MessageController(rateLimiters, pushSender, accountsManager, federatedClientManager); MessageController messageController = new MessageController(rateLimiters, pushSender, receiptSender, accountsManager, messagesManager, federatedClientManager);
environment.jersey().register(new MultiBasicAuthProvider<>(new FederatedPeerAuthenticator(config.getFederationConfiguration()), environment.jersey().register(new AuthDynamicFeature(new BasicCredentialAuthFilter.Builder<Account>()
FederatedPeer.class, .setAuthenticator(deviceAuthenticator)
deviceAuthenticator, .setPrincipal(Account.class)
Device.class, "WhisperServer")); .buildAuthFilter(),
new BasicCredentialAuthFilter.Builder<FederatedPeer>()
.setAuthenticator(federatedPeerAuthenticator)
.setPrincipal(FederatedPeer.class)
.buildAuthFilter()));
environment.jersey().register(new AuthValueFactoryProvider.Binder());
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, storedMessages, new TimeProvider(), authorizationKey)); environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, rateLimiters, smsSender, messagesManager, new TimeProvider(), authorizationKey, config.getTestDevices()));
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, rateLimiters)); environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, messagesManager, rateLimiters));
environment.jersey().register(new DirectoryController(rateLimiters, directory)); environment.jersey().register(new DirectoryController(rateLimiters, directory));
environment.jersey().register(new FederationControllerV1(accountsManager, attachmentController, messageController, keysControllerV1)); environment.jersey().register(new FederationControllerV1(accountsManager, attachmentController, messageController, keysControllerV1));
environment.jersey().register(new FederationControllerV2(accountsManager, attachmentController, messageController, keysControllerV2)); environment.jersey().register(new FederationControllerV2(accountsManager, attachmentController, messageController, keysControllerV2));
environment.jersey().register(new ReceiptController(accountsManager, federatedClientManager, pushSender)); environment.jersey().register(new ReceiptController(receiptSender));
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));
environment.jersey().register(attachmentController); environment.jersey().register(attachmentController);
environment.jersey().register(keysControllerV1); environment.jersey().register(keysControllerV1);
environment.jersey().register(keysControllerV2); environment.jersey().register(keysControllerV2);
environment.jersey().register(messageController); environment.jersey().register(messageController);
if (config.getWebsocketConfiguration().isEnabled()) { if (config.getWebsocketConfiguration().isEnabled()) {
WebSocketEnvironment webSocketEnvironment = new WebSocketEnvironment(environment); WebSocketEnvironment webSocketEnvironment = new WebSocketEnvironment(environment, config, 90000);
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(deviceAuthenticator)); webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(deviceAuthenticator));
webSocketEnvironment.setConnectListener(new ConnectListener(accountsManager, pushSender, storedMessages, pubSubManager)); webSocketEnvironment.setConnectListener(new AuthenticatedConnectListener(accountsManager, pushSender, receiptSender, messagesManager, pubSubManager, apnFallbackManager));
webSocketEnvironment.jersey().register(new KeepAliveController(pubSubManager));
WebSocketResourceProviderFactory servlet = new WebSocketResourceProviderFactory(webSocketEnvironment); WebSocketEnvironment provisioningEnvironment = new WebSocketEnvironment(environment, config);
provisioningEnvironment.setConnectListener(new ProvisioningConnectListener(pubSubManager));
provisioningEnvironment.jersey().register(new KeepAliveController(pubSubManager));
WebSocketResourceProviderFactory webSocketServlet = new WebSocketResourceProviderFactory(webSocketEnvironment );
WebSocketResourceProviderFactory provisioningServlet = new WebSocketResourceProviderFactory(provisioningEnvironment);
ServletRegistration.Dynamic websocket = environment.servlets().addServlet("WebSocket", servlet); ServletRegistration.Dynamic websocket = environment.servlets().addServlet("WebSocket", webSocketServlet );
websocket.addMapping("/v1/websocket/*"); ServletRegistration.Dynamic provisioning = environment.servlets().addServlet("Provisioning", provisioningServlet);
websocket.addMapping("/v1/websocket/");
websocket.setAsyncSupported(true); websocket.setAsyncSupported(true);
provisioning.addMapping("/v1/websocket/provisioning/");
provisioning.setAsyncSupported(true);
webSocketServlet.start();
provisioningServlet.start();
FilterRegistration.Dynamic filter = environment.servlets().addFilter("CORS", CrossOriginFilter.class); FilterRegistration.Dynamic filter = environment.servlets().addFilter("CORS", CrossOriginFilter.class);
filter.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), true, "/*"); filter.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), true, "/*");
filter.setInitParameter("allowedOrigins", "*"); filter.setInitParameter("allowedOrigins", "*");
filter.setInitParameter("allowedHeaders", "Content-Type,Authorization,X-Requested-With,Content-Length,Accept,Origin"); filter.setInitParameter("allowedHeaders", "Content-Type,Authorization,X-Requested-With,Content-Length,Accept,Origin,X-Signal-Agent");
filter.setInitParameter("allowedMethods", "GET,PUT,POST,DELETE,OPTIONS"); filter.setInitParameter("allowedMethods", "GET,PUT,POST,DELETE,OPTIONS");
filter.setInitParameter("preflightMaxAge", "5184000"); filter.setInitParameter("preflightMaxAge", "5184000");
filter.setInitParameter("allowCredentials", "true"); filter.setInitParameter("allowCredentials", "true");
} }
environment.healthChecks().register("redis", new RedisHealthCheck(redisClient)); environment.healthChecks().register("directory", new RedisHealthCheck(directoryClient));
environment.healthChecks().register("memcache", new MemcacheHealthCheck(memcachedClient)); environment.healthChecks().register("cache", new RedisHealthCheck(cacheClient));
environment.jersey().register(new IOExceptionMapper()); environment.jersey().register(new IOExceptionMapper());
environment.jersey().register(new RateLimitExceededExceptionMapper()); environment.jersey().register(new RateLimitExceededExceptionMapper());
environment.jersey().register(new InvalidWebsocketAddressExceptionMapper());
environment.jersey().register(new DeviceLimitExceededExceptionMapper());
environment.metrics().register(name(CpuUsageGauge.class, "cpu"), new CpuUsageGauge()); environment.metrics().register(name(CpuUsageGauge.class, "cpu"), new CpuUsageGauge());
environment.metrics().register(name(FreeMemoryGauge.class, "free_memory"), new FreeMemoryGauge()); environment.metrics().register(name(FreeMemoryGauge.class, "free_memory"), new FreeMemoryGauge());
environment.metrics().register(name(NetworkSentGauge.class, "bytes_sent"), new NetworkSentGauge()); environment.metrics().register(name(NetworkSentGauge.class, "bytes_sent"), new NetworkSentGauge());
environment.metrics().register(name(NetworkReceivedGauge.class, "bytes_received"), new NetworkReceivedGauge()); environment.metrics().register(name(NetworkReceivedGauge.class, "bytes_received"), new NetworkReceivedGauge());
environment.metrics().register(name(FileDescriptorGauge.class, "fd_count"), new FileDescriptorGauge());
if (config.getGraphiteConfiguration().isEnabled()) { if (config.getGraphiteConfiguration().isEnabled()) {
GraphiteReporterFactory graphiteReporterFactory = new GraphiteReporterFactory(); GraphiteReporterFactory graphiteReporterFactory = new GraphiteReporterFactory();
@@ -229,25 +278,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
GraphiteReporter graphiteReporter = (GraphiteReporter) graphiteReporterFactory.build(environment.metrics()); GraphiteReporter graphiteReporter = (GraphiteReporter) graphiteReporterFactory.build(environment.metrics());
graphiteReporter.start(15, TimeUnit.SECONDS); graphiteReporter.start(15, TimeUnit.SECONDS);
} }
if (config.getMetricsConfiguration().isEnabled()) {
new JsonMetricsReporter(environment.metrics(),
config.getMetricsConfiguration().getToken(),
config.getMetricsConfiguration().getHost())
.start(60, TimeUnit.SECONDS);
}
} }
private Optional<NexmoSmsSender> initializeNexmoSmsSender(NexmoConfiguration configuration) { private Client initializeHttpClient(Environment environment, WhisperServerConfiguration config) {
if (configuration == null) { Client httpClient = new JerseyClientBuilder(environment).using(config.getJerseyClientConfiguration())
return Optional.absent(); .build(getName());
} else {
return Optional.of(new NexmoSmsSender(configuration)); httpClient.property(ClientProperties.CONNECT_TIMEOUT, 1000);
} httpClient.property(ClientProperties.READ_TIMEOUT, 1000);
return httpClient;
} }
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
new WhisperServerService().run(args); new WhisperServerService().run(args);
} }
} }

View File

@@ -22,6 +22,7 @@ import com.codahale.metrics.SharedMetricRegistries;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.dropwizard.simpleauth.Authenticator;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
@@ -29,7 +30,6 @@ import org.whispersystems.textsecuregcm.util.Constants;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.auth.AuthenticationException; import io.dropwizard.auth.AuthenticationException;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials; import io.dropwizard.auth.basic.BasicCredentials;
public class AccountAuthenticator implements Authenticator<BasicCredentials, Account> { public class AccountAuthenticator implements Authenticator<BasicCredentials, Account> {

View File

@@ -1,6 +1,7 @@
package org.whispersystems.textsecuregcm.auth; package org.whispersystems.textsecuregcm.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -16,61 +17,13 @@ import java.util.concurrent.TimeUnit;
public class AuthorizationToken { public class AuthorizationToken {
private final Logger logger = LoggerFactory.getLogger(AuthorizationToken.class); @JsonProperty
private String token;
private final String token; public AuthorizationToken(String token) {
private final byte[] key;
public AuthorizationToken(String token, byte[] key) {
this.token = token; this.token = token;
this.key = key;
} }
public boolean isValid(String number, long currentTimeMillis) { public AuthorizationToken() {}
String[] parts = token.split(":");
if (parts.length != 3) {
return false;
}
if (!number.equals(parts[0])) {
return false;
}
if (!isValidTime(parts[1], currentTimeMillis)) {
return false;
}
return isValidSignature(parts[0] + ":" + parts[1], parts[2]);
}
private boolean isValidTime(String timeString, long currentTimeMillis) {
try {
long tokenTime = Long.parseLong(timeString);
long ourTime = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis);
return TimeUnit.SECONDS.toHours(Math.abs(ourTime - tokenTime)) < 24;
} catch (NumberFormatException e) {
logger.warn("Number Format", e);
return false;
}
}
private boolean isValidSignature(String prefix, String suffix) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] ourSuffix = Util.truncate(hmac.doFinal(prefix.getBytes()), 10);
byte[] theirSuffix = Hex.decodeHex(suffix.toCharArray());
return MessageDigest.isEqual(ourSuffix, theirSuffix);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
} catch (DecoderException e) {
logger.warn("Authorizationtoken", e);
return false;
}
}
} }

View File

@@ -0,0 +1,90 @@
package org.whispersystems.textsecuregcm.auth;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.Util;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.TimeUnit;
public class AuthorizationTokenGenerator {
private final Logger logger = LoggerFactory.getLogger(AuthorizationTokenGenerator.class);
private final byte[] key;
public AuthorizationTokenGenerator(byte[] key) {
this.key = key;
}
public AuthorizationToken generateFor(String number) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
long currentTimeSeconds = System.currentTimeMillis() / 1000;
String prefix = number + ":" + currentTimeSeconds;
mac.init(new SecretKeySpec(key, "HmacSHA256"));
String output = Hex.encodeHexString(Util.truncate(mac.doFinal(prefix.getBytes()), 10));
String token = prefix + ":" + output;
return new AuthorizationToken(token);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
}
}
public boolean isValid(String token, String number, long currentTimeMillis) {
String[] parts = token.split(":");
if (parts.length != 3) {
return false;
}
if (!number.equals(parts[0])) {
return false;
}
if (!isValidTime(parts[1], currentTimeMillis)) {
return false;
}
return isValidSignature(parts[0] + ":" + parts[1], parts[2]);
}
private boolean isValidTime(String timeString, long currentTimeMillis) {
try {
long tokenTime = Long.parseLong(timeString);
long ourTime = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis);
return TimeUnit.SECONDS.toHours(Math.abs(ourTime - tokenTime)) < 24;
} catch (NumberFormatException e) {
logger.warn("Number Format", e);
return false;
}
}
private boolean isValidSignature(String prefix, String suffix) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] ourSuffix = Util.truncate(hmac.doFinal(prefix.getBytes()), 10);
byte[] theirSuffix = Hex.decodeHex(suffix.toCharArray());
return MessageDigest.isEqual(ourSuffix, theirSuffix);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e);
} catch (DecoderException e) {
logger.warn("Authorizationtoken", e);
return false;
}
}
}

View File

@@ -22,6 +22,7 @@ import com.codahale.metrics.SharedMetricRegistries;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.dropwizard.simpleauth.Authenticator;
import org.whispersystems.textsecuregcm.configuration.FederationConfiguration; import org.whispersystems.textsecuregcm.configuration.FederationConfiguration;
import org.whispersystems.textsecuregcm.federation.FederatedPeer; import org.whispersystems.textsecuregcm.federation.FederatedPeer;
import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.Constants;
@@ -30,7 +31,6 @@ import java.util.List;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.auth.AuthenticationException; import io.dropwizard.auth.AuthenticationException;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials; import io.dropwizard.auth.basic.BasicCredentials;

View File

@@ -1,66 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.textsecuregcm.auth;
import com.sun.jersey.api.model.Parameter;
import com.sun.jersey.core.spi.component.ComponentContext;
import com.sun.jersey.core.spi.component.ComponentScope;
import com.sun.jersey.spi.inject.Injectable;
import com.sun.jersey.spi.inject.InjectableProvider;
import io.dropwizard.auth.Auth;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicAuthProvider;
import io.dropwizard.auth.basic.BasicCredentials;
public class MultiBasicAuthProvider<T1,T2> implements InjectableProvider<Auth, Parameter> {
private final BasicAuthProvider<T1> provider1;
private final BasicAuthProvider<T2> provider2;
private final Class<?> clazz1;
private final Class<?> clazz2;
public MultiBasicAuthProvider(Authenticator<BasicCredentials, T1> authenticator1,
Class<?> clazz1,
Authenticator<BasicCredentials, T2> authenticator2,
Class<?> clazz2,
String realm)
{
this.provider1 = new BasicAuthProvider<>(authenticator1, realm);
this.provider2 = new BasicAuthProvider<>(authenticator2, realm);
this.clazz1 = clazz1;
this.clazz2 = clazz2;
}
@Override
public ComponentScope getScope() {
return ComponentScope.PerRequest;
}
@Override
public Injectable<?> getInjectable(ComponentContext componentContext,
Auth auth, Parameter parameter)
{
if (parameter.getParameterClass().equals(clazz1)) {
return this.provider1.getInjectable(componentContext, auth, parameter);
} else {
return this.provider2.getInjectable(componentContext, auth, parameter);
}
}
}

View File

@@ -18,12 +18,8 @@ package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import org.whispersystems.textsecuregcm.federation.FederatedPeer; import org.whispersystems.textsecuregcm.federation.FederatedPeer;
import java.util.LinkedList;
import java.util.List; import java.util.List;
public class FederationConfiguration { public class FederationConfiguration {
@@ -34,31 +30,7 @@ public class FederationConfiguration {
@JsonProperty @JsonProperty
private String name; private String name;
@JsonProperty
private String herokuPeers;
public List<FederatedPeer> getPeers() { public List<FederatedPeer> getPeers() {
if (peers != null) {
return peers;
}
if (herokuPeers != null) {
List<FederatedPeer> peers = new LinkedList<>();
JsonElement root = new JsonParser().parse(herokuPeers);
JsonArray peerElements = root.getAsJsonArray();
for (JsonElement peer : peerElements) {
String name = peer.getAsJsonObject().get("name").getAsString();
String url = peer.getAsJsonObject().get("url").getAsString();
String authenticationToken = peer.getAsJsonObject().get("authenticationToken").getAsString();
String certificate = peer.getAsJsonObject().get("certificate").getAsString();
peers.add(new FederatedPeer(name, url, authenticationToken, certificate));
}
return peers;
}
return peers; return peers;
} }

View File

@@ -0,0 +1,14 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
public class MessageStoreConfiguration {
@JsonProperty
@NotEmpty
private String url;
public String getUrl() {
return url;
}
}

View File

@@ -1,27 +0,0 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
public class MetricsConfiguration {
@JsonProperty
private String token;
@JsonProperty
private String host;
@JsonProperty
private boolean enabled = false;
public String getHost() {
return host;
}
public String getToken() {
return token;
}
public boolean isEnabled() {
return enabled && token != null && host != null;
}
}

View File

@@ -0,0 +1,48 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.Min;
public class PushConfiguration {
@JsonProperty
@NotEmpty
private String host;
@JsonProperty
@Min(1)
private int port;
@JsonProperty
@NotEmpty
private String username;
@JsonProperty
@NotEmpty
private String password;
@JsonProperty
@Min(0)
private int queueSize = 200;
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public int getQueueSize() {
return queueSize;
}
}

View File

@@ -0,0 +1,25 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
public class TestDeviceConfiguration {
@JsonProperty
@NotEmpty
private String number;
@JsonProperty
@NotNull
private int code;
public String getNumber() {
return number;
}
public int getCode() {
return code;
}
}

View File

@@ -19,6 +19,9 @@ package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty; import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
public class TwilioConfiguration { public class TwilioConfiguration {
@NotEmpty @NotEmpty
@@ -29,17 +32,14 @@ public class TwilioConfiguration {
@JsonProperty @JsonProperty
private String accountToken; private String accountToken;
@NotEmpty @NotNull
@JsonProperty @JsonProperty
private String number; private List<String> numbers;
@NotEmpty @NotEmpty
@JsonProperty @JsonProperty
private String localDomain; private String localDomain;
@JsonProperty
private boolean international;
public String getAccountId() { public String getAccountId() {
return accountId; return accountId;
} }
@@ -48,15 +48,11 @@ public class TwilioConfiguration {
return accountToken; return accountToken;
} }
public String getNumber() { public List<String> getNumbers() {
return number; return numbers;
} }
public String getLocalDomain() { public String getLocalDomain() {
return localDomain; return localDomain;
} }
public boolean isInternational() {
return international;
}
} }

View File

@@ -23,11 +23,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials; import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.AuthorizationHeader; import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.AuthorizationToken;
import org.whispersystems.textsecuregcm.auth.AuthorizationTokenGenerator;
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException; import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.auth.AuthorizationToken;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.providers.TimeProvider; import org.whispersystems.textsecuregcm.providers.TimeProvider;
import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.SmsSender;
@@ -35,11 +36,10 @@ import org.whispersystems.textsecuregcm.sms.TwilioSmsSender;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
import org.whispersystems.textsecuregcm.storage.StoredMessages;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.VerificationCode; import org.whispersystems.textsecuregcm.util.VerificationCode;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import javax.validation.Valid; import javax.validation.Valid;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
@@ -51,12 +51,14 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; import java.io.IOException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Map;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
@@ -65,36 +67,45 @@ public class AccountController {
private final Logger logger = LoggerFactory.getLogger(AccountController.class); private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final PendingAccountsManager pendingAccounts; private final PendingAccountsManager pendingAccounts;
private final AccountsManager accounts; private final AccountsManager accounts;
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
private final SmsSender smsSender; private final SmsSender smsSender;
private final StoredMessages storedMessages; private final MessagesManager messagesManager;
private final TimeProvider timeProvider; private final TimeProvider timeProvider;
private final Optional<byte[]> authorizationKey; private final Optional<AuthorizationTokenGenerator> tokenGenerator;
private final Map<String, Integer> testDevices;
public AccountController(PendingAccountsManager pendingAccounts, public AccountController(PendingAccountsManager pendingAccounts,
AccountsManager accounts, AccountsManager accounts,
RateLimiters rateLimiters, RateLimiters rateLimiters,
SmsSender smsSenderFactory, SmsSender smsSenderFactory,
StoredMessages storedMessages, MessagesManager messagesManager,
TimeProvider timeProvider, TimeProvider timeProvider,
Optional<byte[]> authorizationKey) Optional<byte[]> authorizationKey,
Map<String, Integer> testDevices)
{ {
this.pendingAccounts = pendingAccounts; this.pendingAccounts = pendingAccounts;
this.accounts = accounts; this.accounts = accounts;
this.rateLimiters = rateLimiters; this.rateLimiters = rateLimiters;
this.smsSender = smsSenderFactory; this.smsSender = smsSenderFactory;
this.storedMessages = storedMessages; this.messagesManager = messagesManager;
this.timeProvider = timeProvider; this.timeProvider = timeProvider;
this.authorizationKey = authorizationKey; this.testDevices = testDevices;
if (authorizationKey.isPresent()) {
tokenGenerator = Optional.of(new AuthorizationTokenGenerator(authorizationKey.get()));
} else {
tokenGenerator = Optional.absent();
}
} }
@Timed @Timed
@GET @GET
@Path("/{transport}/code/{number}") @Path("/{transport}/code/{number}")
public Response createAccount(@PathParam("transport") String transport, public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number) @PathParam("number") String number,
@QueryParam("client") Optional<String> client)
throws IOException, RateLimitExceededException throws IOException, RateLimitExceededException
{ {
if (!Util.isValidNumber(number)) { if (!Util.isValidNumber(number)) {
@@ -113,11 +124,13 @@ public class AccountController {
throw new WebApplicationException(Response.status(422).build()); throw new WebApplicationException(Response.status(422).build());
} }
VerificationCode verificationCode = generateVerificationCode(); VerificationCode verificationCode = generateVerificationCode(number);
pendingAccounts.store(number, verificationCode.getVerificationCode()); pendingAccounts.store(number, verificationCode.getVerificationCode());
if (transport.equals("sms")) { if (testDevices.containsKey(number)) {
smsSender.deliverSmsVerification(number, verificationCode.getVerificationCodeDisplay()); // noop
} else if (transport.equals("sms")) {
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
} else if (transport.equals("voice")) { } else if (transport.equals("voice")) {
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCodeSpeech()); smsSender.deliverVoxVerification(number, verificationCode.getVerificationCodeSpeech());
} }
@@ -131,6 +144,7 @@ public class AccountController {
@Path("/code/{verification_code}") @Path("/code/{verification_code}")
public void verifyAccount(@PathParam("verification_code") String verificationCode, public void verifyAccount(@PathParam("verification_code") String verificationCode,
@HeaderParam("Authorization") String authorizationHeader, @HeaderParam("Authorization") String authorizationHeader,
@HeaderParam("X-Signal-Agent") String userAgent,
@Valid AccountAttributes accountAttributes) @Valid AccountAttributes accountAttributes)
throws RateLimitExceededException throws RateLimitExceededException
{ {
@@ -153,7 +167,7 @@ public class AccountController {
throw new WebApplicationException(Response.status(417).build()); throw new WebApplicationException(Response.status(417).build());
} }
createAccount(number, password, accountAttributes); createAccount(number, password, userAgent, accountAttributes);
} catch (InvalidAuthorizationHeaderException e) { } catch (InvalidAuthorizationHeaderException e) {
logger.info("Bad Authorization Header", e); logger.info("Bad Authorization Header", e);
throw new WebApplicationException(Response.status(401).build()); throw new WebApplicationException(Response.status(401).build());
@@ -166,6 +180,7 @@ public class AccountController {
@Path("/token/{verification_token}") @Path("/token/{verification_token}")
public void verifyToken(@PathParam("verification_token") String verificationToken, public void verifyToken(@PathParam("verification_token") String verificationToken,
@HeaderParam("Authorization") String authorizationHeader, @HeaderParam("Authorization") String authorizationHeader,
@HeaderParam("X-Signal-Agent") String userAgent,
@Valid AccountAttributes accountAttributes) @Valid AccountAttributes accountAttributes)
throws RateLimitExceededException throws RateLimitExceededException
{ {
@@ -176,32 +191,50 @@ public class AccountController {
rateLimiters.getVerifyLimiter().validate(number); rateLimiters.getVerifyLimiter().validate(number);
if (!authorizationKey.isPresent()) { if (!tokenGenerator.isPresent()) {
logger.debug("Attempt to authorize with key but not configured..."); logger.debug("Attempt to authorize with key but not configured...");
throw new WebApplicationException(Response.status(403).build()); throw new WebApplicationException(Response.status(403).build());
} }
AuthorizationToken token = new AuthorizationToken(verificationToken, authorizationKey.get()); if (!tokenGenerator.get().isValid(verificationToken, number, timeProvider.getCurrentTimeMillis())) {
if (!token.isValid(number, timeProvider.getCurrentTimeMillis())) {
throw new WebApplicationException(Response.status(403).build()); throw new WebApplicationException(Response.status(403).build());
} }
createAccount(number, password, accountAttributes); createAccount(number, password, userAgent, accountAttributes);
} catch (InvalidAuthorizationHeaderException e) { } catch (InvalidAuthorizationHeaderException e) {
logger.info("Bad authorization header", e); logger.info("Bad authorization header", e);
throw new WebApplicationException(Response.status(401).build()); throw new WebApplicationException(Response.status(401).build());
} }
} }
@Timed
@GET
@Path("/token/")
@Produces(MediaType.APPLICATION_JSON)
public AuthorizationToken verifyToken(@Auth Account account)
throws RateLimitExceededException
{
if (!tokenGenerator.isPresent()) {
logger.debug("Attempt to authorize with key but not configured...");
throw new WebApplicationException(Response.status(404).build());
}
return tokenGenerator.get().generateFor(account.getNumber());
}
@Timed @Timed
@PUT @PUT
@Path("/gcm/") @Path("/gcm/")
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public void setGcmRegistrationId(@Auth Account account, @Valid GcmRegistrationId registrationId) { public void setGcmRegistrationId(@Auth Account account, @Valid GcmRegistrationId registrationId) {
Device device = account.getAuthenticatedDevice().get(); Device device = account.getAuthenticatedDevice().get();
device.setApnId(null); device.setApnId(null);
device.setVoipApnId(null);
device.setGcmId(registrationId.getGcmRegistrationId()); device.setGcmId(registrationId.getGcmRegistrationId());
if (registrationId.isWebSocketChannel()) device.setFetchesMessages(true);
else device.setFetchesMessages(false);
accounts.update(account); accounts.update(account);
} }
@@ -211,6 +244,7 @@ public class AccountController {
public void deleteGcmRegistrationId(@Auth Account account) { public void deleteGcmRegistrationId(@Auth Account account) {
Device device = account.getAuthenticatedDevice().get(); Device device = account.getAuthenticatedDevice().get();
device.setGcmId(null); device.setGcmId(null);
device.setFetchesMessages(false);
accounts.update(account); accounts.update(account);
} }
@@ -221,7 +255,9 @@ public class AccountController {
public void setApnRegistrationId(@Auth Account account, @Valid ApnRegistrationId registrationId) { public void setApnRegistrationId(@Auth Account account, @Valid ApnRegistrationId registrationId) {
Device device = account.getAuthenticatedDevice().get(); Device device = account.getAuthenticatedDevice().get();
device.setApnId(registrationId.getApnRegistrationId()); device.setApnId(registrationId.getApnRegistrationId());
device.setVoipApnId(registrationId.getVoipRegistrationId());
device.setGcmId(null); device.setGcmId(null);
device.setFetchesMessages(true);
accounts.update(account); accounts.update(account);
} }
@@ -231,6 +267,28 @@ public class AccountController {
public void deleteApnRegistrationId(@Auth Account account) { public void deleteApnRegistrationId(@Auth Account account) {
Device device = account.getAuthenticatedDevice().get(); Device device = account.getAuthenticatedDevice().get();
device.setApnId(null); device.setApnId(null);
device.setFetchesMessages(false);
accounts.update(account);
}
@Timed
@PUT
@Path("/attributes/")
@Consumes(MediaType.APPLICATION_JSON)
public void setAccountAttributes(@Auth Account account,
@HeaderParam("X-Signal-Agent") String userAgent,
@Valid AccountAttributes attributes)
{
Device device = account.getAuthenticatedDevice().get();
device.setFetchesMessages(attributes.getFetchesMessages());
device.setName(attributes.getName());
device.setLastSeen(Util.todayInMillis());
device.setVoiceSupported(attributes.getVoice());
device.setRegistrationId(attributes.getRegistrationId());
device.setSignalingKey(attributes.getSignalingKey());
device.setUserAgent(userAgent);
accounts.update(account); accounts.update(account);
} }
@@ -243,28 +301,34 @@ public class AccountController {
encodedVerificationText)).build(); encodedVerificationText)).build();
} }
private void createAccount(String number, String password, AccountAttributes accountAttributes) { private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {
Device device = new Device(); Device device = new Device();
device.setId(Device.MASTER_ID); device.setId(Device.MASTER_ID);
device.setAuthenticationCredentials(new AuthenticationCredentials(password)); device.setAuthenticationCredentials(new AuthenticationCredentials(password));
device.setSignalingKey(accountAttributes.getSignalingKey()); device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages()); device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setRegistrationId(accountAttributes.getRegistrationId()); device.setRegistrationId(accountAttributes.getRegistrationId());
device.setName(accountAttributes.getName());
device.setVoiceSupported(accountAttributes.getVoice());
device.setCreated(System.currentTimeMillis());
device.setLastSeen(Util.todayInMillis());
device.setUserAgent(userAgent);
Account account = new Account(); Account account = new Account();
account.setNumber(number); account.setNumber(number);
account.setSupportsSms(accountAttributes.getSupportsSms());
account.addDevice(device); account.addDevice(device);
accounts.create(account); accounts.create(account);
storedMessages.clear(new WebsocketAddress(number, Device.MASTER_ID)); messagesManager.clear(number);
pendingAccounts.remove(number); pendingAccounts.remove(number);
logger.debug("Stored device...");
} }
@VisibleForTesting protected VerificationCode generateVerificationCode() { @VisibleForTesting protected VerificationCode generateVerificationCode(String number) {
try { try {
if (testDevices.containsKey(number)) {
return new VerificationCode(testDevices.get(number));
}
SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
int randomInt = 100000 + random.nextInt(900000); int randomInt = 100000 + random.nextInt(900000);
return new VerificationCode(randomInt); return new VerificationCode(randomInt);

View File

@@ -25,16 +25,21 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.AuthorizationHeader; import org.whispersystems.textsecuregcm.auth.AuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException; import org.whispersystems.textsecuregcm.auth.InvalidAuthorizationHeaderException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
import org.whispersystems.textsecuregcm.entities.DeviceInfoList;
import org.whispersystems.textsecuregcm.entities.DeviceResponse; import org.whispersystems.textsecuregcm.entities.DeviceResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingDevicesManager; import org.whispersystems.textsecuregcm.storage.PendingDevicesManager;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.VerificationCode; import org.whispersystems.textsecuregcm.util.VerificationCode;
import javax.validation.Valid; import javax.validation.Valid;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam; import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
@@ -44,8 +49,11 @@ import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.LinkedList;
import java.util.List;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
@@ -54,28 +62,68 @@ public class DeviceController {
private final Logger logger = LoggerFactory.getLogger(DeviceController.class); private final Logger logger = LoggerFactory.getLogger(DeviceController.class);
private static final int MAX_DEVICES = 3;
private final PendingDevicesManager pendingDevices; private final PendingDevicesManager pendingDevices;
private final AccountsManager accounts; private final AccountsManager accounts;
private final MessagesManager messages;
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
public DeviceController(PendingDevicesManager pendingDevices, public DeviceController(PendingDevicesManager pendingDevices,
AccountsManager accounts, AccountsManager accounts,
MessagesManager messages,
RateLimiters rateLimiters) RateLimiters rateLimiters)
{ {
this.pendingDevices = pendingDevices; this.pendingDevices = pendingDevices;
this.accounts = accounts; this.accounts = accounts;
this.messages = messages;
this.rateLimiters = rateLimiters; this.rateLimiters = rateLimiters;
} }
@Timed @Timed
@GET @GET
@Path("/provisioning_code") @Produces(MediaType.APPLICATION_JSON)
public DeviceInfoList getDevices(@Auth Account account) {
List<DeviceInfo> devices = new LinkedList<>();
for (Device device : account.getDevices()) {
devices.add(new DeviceInfo(device.getId(), device.getName(),
device.getLastSeen(), device.getCreated()));
}
return new DeviceInfoList(devices);
}
@Timed
@DELETE
@Path("/{device_id}")
public void removeDevice(@Auth Account account, @PathParam("device_id") long deviceId) {
if (account.getAuthenticatedDevice().get().getId() != Device.MASTER_ID) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
account.removeDevice(deviceId);
accounts.update(account);
messages.clear(account.getNumber(), deviceId);
}
@Timed
@GET
@Path("/provisioning/code")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public VerificationCode createDeviceToken(@Auth Account account) public VerificationCode createDeviceToken(@Auth Account account)
throws RateLimitExceededException throws RateLimitExceededException, DeviceLimitExceededException
{ {
rateLimiters.getAllocateDeviceLimiter().validate(account.getNumber()); rateLimiters.getAllocateDeviceLimiter().validate(account.getNumber());
if (account.getActiveDeviceCount() >= MAX_DEVICES) {
throw new DeviceLimitExceededException(account.getDevices().size(), MAX_DEVICES);
}
if (account.getAuthenticatedDevice().get().getId() != Device.MASTER_ID) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
VerificationCode verificationCode = generateVerificationCode(); VerificationCode verificationCode = generateVerificationCode();
pendingDevices.store(account.getNumber(), verificationCode.getVerificationCode()); pendingDevices.store(account.getNumber(), verificationCode.getVerificationCode());
@@ -90,7 +138,7 @@ public class DeviceController {
public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode, public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode,
@HeaderParam("Authorization") String authorizationHeader, @HeaderParam("Authorization") String authorizationHeader,
@Valid AccountAttributes accountAttributes) @Valid AccountAttributes accountAttributes)
throws RateLimitExceededException throws RateLimitExceededException, DeviceLimitExceededException
{ {
try { try {
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader); AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
@@ -102,7 +150,7 @@ public class DeviceController {
Optional<String> storedVerificationCode = pendingDevices.getCodeForNumber(number); Optional<String> storedVerificationCode = pendingDevices.getCodeForNumber(number);
if (!storedVerificationCode.isPresent() || if (!storedVerificationCode.isPresent() ||
!verificationCode.equals(storedVerificationCode.get())) !MessageDigest.isEqual(verificationCode.getBytes(), storedVerificationCode.get().getBytes()))
{ {
throw new WebApplicationException(Response.status(403).build()); throw new WebApplicationException(Response.status(403).build());
} }
@@ -113,11 +161,19 @@ public class DeviceController {
throw new WebApplicationException(Response.status(403).build()); throw new WebApplicationException(Response.status(403).build());
} }
if (account.get().getActiveDeviceCount() >= MAX_DEVICES) {
throw new DeviceLimitExceededException(account.get().getDevices().size(), MAX_DEVICES);
}
Device device = new Device(); Device device = new Device();
device.setName(accountAttributes.getName());
device.setAuthenticationCredentials(new AuthenticationCredentials(password)); device.setAuthenticationCredentials(new AuthenticationCredentials(password));
device.setSignalingKey(accountAttributes.getSignalingKey()); device.setSignalingKey(accountAttributes.getSignalingKey());
device.setFetchesMessages(accountAttributes.getFetchesMessages()); device.setFetchesMessages(accountAttributes.getFetchesMessages());
device.setId(account.get().getNextDeviceId()); device.setId(account.get().getNextDeviceId());
device.setRegistrationId(accountAttributes.getRegistrationId());
device.setLastSeen(Util.todayInMillis());
device.setCreated(System.currentTimeMillis());
account.get().addDevice(device); account.get().addDevice(device);
accounts.update(account.get()); accounts.update(account.get());

View File

@@ -0,0 +1,21 @@
package org.whispersystems.textsecuregcm.controllers;
public class DeviceLimitExceededException extends Exception {
private final int currentDevices;
private final int maxDevices;
public DeviceLimitExceededException(int currentDevices, int maxDevices) {
this.currentDevices = currentDevices;
this.maxDevices = maxDevices;
}
public int getCurrentDevices() {
return currentDevices;
}
public int getMaxDevices() {
return maxDevices;
}
}

View File

@@ -74,7 +74,7 @@ public class DirectoryController {
rateLimiters.getContactsLimiter().validate(account.getNumber()); rateLimiters.getContactsLimiter().validate(account.getNumber());
try { try {
Optional<ClientContact> contact = directory.get(Base64.decodeWithoutPadding(token)); Optional<ClientContact> contact = directory.get(decodeToken(token));
if (contact.isPresent()) return Response.ok().entity(contact.get()).build(); if (contact.isPresent()) return Response.ok().entity(contact.get()).build();
else return Response.status(404).build(); else return Response.status(404).build();
@@ -100,7 +100,7 @@ public class DirectoryController {
List<byte[]> tokens = new LinkedList<>(); List<byte[]> tokens = new LinkedList<>();
for (String encodedContact : contacts.getContacts()) { for (String encodedContact : contacts.getContacts()) {
tokens.add(Base64.decodeWithoutPadding(encodedContact)); tokens.add(decodeToken(encodedContact));
} }
List<ClientContact> intersection = directory.get(tokens); List<ClientContact> intersection = directory.get(tokens);
@@ -110,4 +110,8 @@ public class DirectoryController {
throw new WebApplicationException(Response.status(400).build()); throw new WebApplicationException(Response.status(400).build());
} }
} }
private byte[] decodeToken(String encoded) throws IOException {
return Base64.decodeWithoutPadding(encoded.replace('-', '+').replace('_', '/'));
}
} }

View File

@@ -150,7 +150,7 @@ public class FederationControllerV1 extends FederationController {
for (Account account : accountList) { for (Account account : accountList) {
byte[] token = Util.getContactToken(account.getNumber()); byte[] token = Util.getContactToken(account.getNumber());
ClientContact clientContact = new ClientContact(token, null, account.getSupportsSms()); ClientContact clientContact = new ClientContact(token, null, account.isVoiceSupported());
if (!account.isActive()) { if (!account.isActive()) {
clientContact.setInactive(true); clientContact.setInactive(true);

View File

@@ -0,0 +1,7 @@
package org.whispersystems.textsecuregcm.controllers;
public class InvalidDestinationException extends Exception {
public InvalidDestinationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,55 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import org.whispersystems.websocket.session.WebSocketSession;
import org.whispersystems.websocket.session.WebSocketSessionContext;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import io.dropwizard.auth.Auth;
@Path("/v1/keepalive")
public class KeepAliveController {
private final Logger logger = LoggerFactory.getLogger(KeepAliveController.class);
private final PubSubManager pubSubManager;
public KeepAliveController(PubSubManager pubSubManager) {
this.pubSubManager = pubSubManager;
}
@Timed
@GET
public Response getKeepAlive(@Auth Account account,
@WebSocketSession WebSocketSessionContext context)
{
if (account != null) {
WebsocketAddress address = new WebsocketAddress(account.getNumber(),
account.getAuthenticatedDevice().get().getId());
if (!pubSubManager.hasLocalSubscription(address)) {
logger.warn("***** No local subscription found for: " + address);
context.getClient().close(1000, "OK");
}
}
return Response.ok().build();
}
@Timed
@GET
@Path("/provisioning")
public Response getProvisioningKeepAlive() {
return Response.ok().build();
}
}

View File

@@ -16,8 +16,10 @@
*/ */
package org.whispersystems.textsecuregcm.controllers; package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import org.skife.jdbi.v2.exceptions.UnableToExecuteStatementException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PreKeyCount; import org.whispersystems.textsecuregcm.entities.PreKeyCount;
import org.whispersystems.textsecuregcm.federation.FederatedClientManager; import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
@@ -38,6 +40,8 @@ import io.dropwizard.auth.Auth;
public class KeysController { public class KeysController {
private static final Logger logger = LoggerFactory.getLogger(KeysController.class);
protected final RateLimiters rateLimiters; protected final RateLimiters rateLimiters;
protected final Keys keys; protected final Keys keys;
protected final AccountsManager accounts; protected final AccountsManager accounts;
@@ -52,7 +56,6 @@ public class KeysController {
this.federatedClientManager = federatedClientManager; this.federatedClientManager = federatedClientManager;
} }
@Timed
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public PreKeyCount getStatus(@Auth Account account) { public PreKeyCount getStatus(@Auth Account account) {
@@ -87,8 +90,16 @@ public class KeysController {
throw new NoSuchUserException("Target device is inactive."); throw new NoSuchUserException("Target device is inactive.");
} }
Optional<List<KeyRecord>> preKeys = keys.get(number, deviceId); for (int i=0;i<20;i++) {
return new TargetKeys(destination.get(), preKeys); try {
Optional<List<KeyRecord>> preKeys = keys.get(number, deviceId);
return new TargetKeys(destination.get(), preKeys);
} catch (UnableToExecuteStatementException e) {
logger.info(e.getMessage());
}
}
throw new WebApplicationException(Response.status(500).build());
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
throw new WebApplicationException(Response.status(422).build()); throw new WebApplicationException(Response.status(422).build());
} }

View File

@@ -23,9 +23,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.IncomingMessage; import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList; import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.MessageProtos.OutgoingMessageSignal; import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.MessageResponse;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices; import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices; import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.federation.FederatedClient; import org.whispersystems.textsecuregcm.federation.FederatedClient;
import org.whispersystems.textsecuregcm.federation.FederatedClientManager; import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
@@ -33,15 +35,19 @@ import org.whispersystems.textsecuregcm.federation.NoSuchPeerException;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.push.PushSender; import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.TransientPushFailureException; import org.whispersystems.textsecuregcm.push.TransientPushFailureException;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.util.Base64; import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.Util;
import javax.validation.Valid; import javax.validation.Valid;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.POST; import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
@@ -64,17 +70,23 @@ public class MessageController {
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
private final PushSender pushSender; private final PushSender pushSender;
private final ReceiptSender receiptSender;
private final FederatedClientManager federatedClientManager; private final FederatedClientManager federatedClientManager;
private final AccountsManager accountsManager; private final AccountsManager accountsManager;
private final MessagesManager messagesManager;
public MessageController(RateLimiters rateLimiters, public MessageController(RateLimiters rateLimiters,
PushSender pushSender, PushSender pushSender,
ReceiptSender receiptSender,
AccountsManager accountsManager, AccountsManager accountsManager,
MessagesManager messagesManager,
FederatedClientManager federatedClientManager) FederatedClientManager federatedClientManager)
{ {
this.rateLimiters = rateLimiters; this.rateLimiters = rateLimiters;
this.pushSender = pushSender; this.pushSender = pushSender;
this.receiptSender = receiptSender;
this.accountsManager = accountsManager; this.accountsManager = accountsManager;
this.messagesManager = messagesManager;
this.federatedClientManager = federatedClientManager; this.federatedClientManager = federatedClientManager;
} }
@@ -82,16 +94,21 @@ public class MessageController {
@Path("/{destination}") @Path("/{destination}")
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public void sendMessage(@Auth Account source, @Produces(MediaType.APPLICATION_JSON)
@PathParam("destination") String destinationName, public SendMessageResponse sendMessage(@Auth Account source,
@Valid IncomingMessageList messages) @PathParam("destination") String destinationName,
@Valid IncomingMessageList messages)
throws IOException, RateLimitExceededException throws IOException, RateLimitExceededException
{ {
rateLimiters.getMessagesLimiter().validate(source.getNumber()); rateLimiters.getMessagesLimiter().validate(source.getNumber() + "__" + destinationName);
try { try {
if (messages.getRelay() == null) sendLocalMessage(source, destinationName, messages); boolean isSyncMessage = source.getNumber().equals(destinationName);
else sendRelayMessage(source, destinationName, messages);
if (Util.isEmpty(messages.getRelay())) sendLocalMessage(source, destinationName, messages, isSyncMessage);
else sendRelayMessage(source, destinationName, messages, isSyncMessage);
return new SendMessageResponse(!isSyncMessage && source.getActiveDeviceCount() > 1);
} catch (NoSuchUserException e) { } catch (NoSuchUserException e) {
throw new WebApplicationException(Response.status(404).build()); throw new WebApplicationException(Response.status(404).build());
} catch (MismatchedDevicesException e) { } catch (MismatchedDevicesException e) {
@@ -105,37 +122,58 @@ public class MessageController {
.type(MediaType.APPLICATION_JSON) .type(MediaType.APPLICATION_JSON)
.entity(new StaleDevices(e.getStaleDevices())) .entity(new StaleDevices(e.getStaleDevices()))
.build()); .build());
} catch (InvalidDestinationException e) {
throw new WebApplicationException(Response.status(400).build());
} }
} }
@Timed @Timed
@POST @GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public MessageResponse sendMessageLegacy(@Auth Account source, @Valid IncomingMessageList messages) public OutgoingMessageEntityList getPendingMessages(@Auth Account account) {
throws IOException, RateLimitExceededException return messagesManager.getMessagesForDevice(account.getNumber(),
account.getAuthenticatedDevice().get().getId());
}
@Timed
@DELETE
@Path("/{source}/{timestamp}")
public void removePendingMessage(@Auth Account account,
@PathParam("source") String source,
@PathParam("timestamp") long timestamp)
throws IOException
{ {
try { try {
List<IncomingMessage> incomingMessages = messages.getMessages(); Optional<OutgoingMessageEntity> message = messagesManager.delete(account.getNumber(),
validateLegacyDestinations(incomingMessages); account.getAuthenticatedDevice().get().getId(),
source, timestamp);
messages.setRelay(incomingMessages.get(0).getRelay()); if (message.isPresent() && message.get().getType() != Envelope.Type.RECEIPT_VALUE) {
sendMessage(source, incomingMessages.get(0).getDestination(), messages); receiptSender.sendReceipt(account,
message.get().getSource(),
return new MessageResponse(new LinkedList<String>(), new LinkedList<String>()); message.get().getTimestamp(),
} catch (ValidationException e) { Optional.fromNullable(message.get().getRelay()));
throw new WebApplicationException(Response.status(422).build()); }
} catch (NotPushRegisteredException e) {
logger.info("User no longer push registered for delivery receipt: " + e.getMessage());
} catch (NoSuchUserException | TransientPushFailureException e) {
logger.warn("Sending delivery receipt", e);
} }
} }
private void sendLocalMessage(Account source, private void sendLocalMessage(Account source,
String destinationName, String destinationName,
IncomingMessageList messages) IncomingMessageList messages,
throws NoSuchUserException, MismatchedDevicesException, IOException, StaleDevicesException boolean isSyncMessage)
throws NoSuchUserException, MismatchedDevicesException, StaleDevicesException
{ {
Account destination = getDestinationAccount(destinationName); Account destination;
validateCompleteDeviceList(destination, messages.getMessages()); if (!isSyncMessage) destination = getDestinationAccount(destinationName);
else destination = source;
validateCompleteDeviceList(destination, messages.getMessages(), isSyncMessage);
validateRegistrationIds(destination, messages.getMessages()); validateRegistrationIds(destination, messages.getMessages());
for (IncomingMessage incomingMessage : messages.getMessages()) { for (IncomingMessage incomingMessage : messages.getMessages()) {
@@ -152,19 +190,24 @@ public class MessageController {
Device destinationDevice, Device destinationDevice,
long timestamp, long timestamp,
IncomingMessage incomingMessage) IncomingMessage incomingMessage)
throws NoSuchUserException, IOException throws NoSuchUserException
{ {
try { try {
Optional<byte[]> messageBody = getMessageBody(incomingMessage); Optional<byte[]> messageBody = getMessageBody(incomingMessage);
OutgoingMessageSignal.Builder messageBuilder = OutgoingMessageSignal.newBuilder(); Optional<byte[]> messageContent = getMessageContent(incomingMessage);
Envelope.Builder messageBuilder = Envelope.newBuilder();
messageBuilder.setType(incomingMessage.getType()) messageBuilder.setType(Envelope.Type.valueOf(incomingMessage.getType()))
.setSource(source.getNumber()) .setSource(source.getNumber())
.setTimestamp(timestamp == 0 ? System.currentTimeMillis() : timestamp) .setTimestamp(timestamp == 0 ? System.currentTimeMillis() : timestamp)
.setSourceDevice((int)source.getAuthenticatedDevice().get().getId()); .setSourceDevice((int) source.getAuthenticatedDevice().get().getId());
if (messageBody.isPresent()) { if (messageBody.isPresent()) {
messageBuilder.setMessage(ByteString.copyFrom(messageBody.get())); messageBuilder.setLegacyMessage(ByteString.copyFrom(messageBody.get()));
}
if (messageContent.isPresent()) {
messageBuilder.setContent(ByteString.copyFrom(messageContent.get()));
} }
if (source.getRelay().isPresent()) { if (source.getRelay().isPresent()) {
@@ -175,17 +218,17 @@ public class MessageController {
} catch (NotPushRegisteredException e) { } catch (NotPushRegisteredException e) {
if (destinationDevice.isMaster()) throw new NoSuchUserException(e); if (destinationDevice.isMaster()) throw new NoSuchUserException(e);
else logger.debug("Not registered", e); else logger.debug("Not registered", e);
} catch (TransientPushFailureException e) {
if (destinationDevice.isMaster()) throw new IOException(e);
else logger.debug("Transient failure", e);
} }
} }
private void sendRelayMessage(Account source, private void sendRelayMessage(Account source,
String destinationName, String destinationName,
IncomingMessageList messages) IncomingMessageList messages,
throws IOException, NoSuchUserException boolean isSyncMessage)
throws IOException, NoSuchUserException, InvalidDestinationException
{ {
if (isSyncMessage) throw new InvalidDestinationException("Transcript messages can't be relayed!");
try { try {
FederatedClient client = federatedClientManager.getClient(messages.getRelay()); FederatedClient client = federatedClientManager.getClient(messages.getRelay());
client.sendMessages(source.getNumber(), source.getAuthenticatedDevice().get().getId(), client.sendMessages(source.getNumber(), source.getAuthenticatedDevice().get().getId(),
@@ -228,7 +271,9 @@ public class MessageController {
} }
} }
private void validateCompleteDeviceList(Account account, List<IncomingMessage> messages) private void validateCompleteDeviceList(Account account,
List<IncomingMessage> messages,
boolean isSyncMessage)
throws MismatchedDevicesException throws MismatchedDevicesException
{ {
Set<Long> messageDeviceIds = new HashSet<>(); Set<Long> messageDeviceIds = new HashSet<>();
@@ -242,7 +287,9 @@ public class MessageController {
} }
for (Device device : account.getDevices()) { for (Device device : account.getDevices()) {
if (device.isActive()) { if (device.isActive() &&
!(isSyncMessage && device.getId() == account.getAuthenticatedDevice().get().getId()))
{
accountDeviceIds.add(device.getId()); accountDeviceIds.add(device.getId());
if (!messageDeviceIds.contains(device.getId())) { if (!messageDeviceIds.contains(device.getId())) {
@@ -262,23 +309,9 @@ public class MessageController {
} }
} }
private void validateLegacyDestinations(List<IncomingMessage> messages)
throws ValidationException
{
String destination = null;
for (IncomingMessage message : messages) {
if ((message.getDestination() == null) ||
(destination != null && !destination.equals(message.getDestination())))
{
throw new ValidationException("Multiple account destinations!");
}
destination = message.getDestination();
}
}
private Optional<byte[]> getMessageBody(IncomingMessage message) { private Optional<byte[]> getMessageBody(IncomingMessage message) {
if (Util.isEmpty(message.getBody())) return Optional.absent();
try { try {
return Optional.of(Base64.decode(message.getBody())); return Optional.of(Base64.decode(message.getBody()));
} catch (IOException ioe) { } catch (IOException ioe) {
@@ -286,4 +319,15 @@ public class MessageController {
return Optional.absent(); return Optional.absent();
} }
} }
private Optional<byte[]> getMessageContent(IncomingMessage message) {
if (Util.isEmpty(message.getContent())) return Optional.absent();
try {
return Optional.of(Base64.decode(message.getContent()));
} catch (IOException ioe) {
logger.debug("Bad B64", ioe);
return Optional.absent();
}
}
} }

View File

@@ -0,0 +1,55 @@
package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.WebsocketSender;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.websocket.InvalidWebsocketAddressException;
import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import io.dropwizard.auth.Auth;
@Path("/v1/provisioning")
public class ProvisioningController {
private final RateLimiters rateLimiters;
private final WebsocketSender websocketSender;
public ProvisioningController(RateLimiters rateLimiters, PushSender pushSender) {
this.rateLimiters = rateLimiters;
this.websocketSender = pushSender.getWebSocketSender();
}
@Timed
@Path("/{destination}")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void sendProvisioningMessage(@Auth Account source,
@PathParam("destination") String destinationName,
@Valid ProvisioningMessage message)
throws RateLimitExceededException, InvalidWebsocketAddressException, IOException
{
rateLimiters.getMessagesLimiter().validate(source.getNumber());
if (!websocketSender.sendProvisioningMessage(new ProvisioningAddress(destinationName, 0),
Base64.decode(message.getBody())))
{
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
}
}

View File

@@ -2,14 +2,10 @@ package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed; import com.codahale.metrics.annotation.Timed;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
import org.whispersystems.textsecuregcm.federation.NoSuchPeerException;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.push.PushSender; import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.TransientPushFailureException; import org.whispersystems.textsecuregcm.push.TransientPushFailureException;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@@ -18,25 +14,16 @@ import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
import static org.whispersystems.textsecuregcm.entities.MessageProtos.OutgoingMessageSignal;
@Path("/v1/receipt") @Path("/v1/receipt")
public class ReceiptController { public class ReceiptController {
private final AccountsManager accountManager; private final ReceiptSender receiptSender;
private final PushSender pushSender;
private final FederatedClientManager federatedClientManager;
public ReceiptController(AccountsManager accountManager, public ReceiptController(ReceiptSender receiptSender) {
FederatedClientManager federatedClientManager, this.receiptSender = receiptSender;
PushSender pushSender)
{
this.accountManager = accountManager;
this.federatedClientManager = federatedClientManager;
this.pushSender = pushSender;
} }
@Timed @Timed
@@ -49,8 +36,7 @@ public class ReceiptController {
throws IOException throws IOException
{ {
try { try {
if (relay.isPresent()) sendRelayedReceipt(source, destination, messageId, relay.get()); receiptSender.sendReceipt(source, destination, messageId, relay);
else sendDirectReceipt(source, destination, messageId);
} catch (NoSuchUserException | NotPushRegisteredException e) { } catch (NoSuchUserException | NotPushRegisteredException e) {
throw new WebApplicationException(Response.Status.NOT_FOUND); throw new WebApplicationException(Response.Status.NOT_FOUND);
} catch (TransientPushFailureException e) { } catch (TransientPushFailureException e) {
@@ -58,51 +44,4 @@ public class ReceiptController {
} }
} }
private void sendRelayedReceipt(Account source, String destination, long messageId, String relay)
throws NoSuchUserException, IOException
{
try {
federatedClientManager.getClient(relay)
.sendDeliveryReceipt(source.getNumber(),
source.getAuthenticatedDevice().get().getId(),
destination, messageId);
} catch (NoSuchPeerException e) {
throw new NoSuchUserException(e);
}
}
private void sendDirectReceipt(Account source, String destination, long messageId)
throws NotPushRegisteredException, TransientPushFailureException, NoSuchUserException
{
Account destinationAccount = getDestinationAccount(destination);
List<Device> destinationDevices = destinationAccount.getDevices();
OutgoingMessageSignal.Builder message =
OutgoingMessageSignal.newBuilder()
.setSource(source.getNumber())
.setSourceDevice((int) source.getAuthenticatedDevice().get().getId())
.setTimestamp(messageId)
.setType(OutgoingMessageSignal.Type.RECEIPT_VALUE);
if (source.getRelay().isPresent()) {
message.setRelay(source.getRelay().get());
}
for (Device destinationDevice : destinationDevices) {
pushSender.sendMessage(destinationAccount, destinationDevice, message.build());
}
}
private Account getDestinationAccount(String destination)
throws NoSuchUserException
{
Optional<Account> account = accountManager.get(destination);
if (!account.isPresent()) {
throw new NoSuchUserException(destination);
}
return account.get();
}
} }

View File

@@ -17,6 +17,8 @@
package org.whispersystems.textsecuregcm.entities; package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotEmpty; import org.hibernate.validator.constraints.NotEmpty;
public class AccountAttributes { public class AccountAttributes {
@@ -25,32 +27,39 @@ public class AccountAttributes {
@NotEmpty @NotEmpty
private String signalingKey; private String signalingKey;
@JsonProperty
private boolean supportsSms;
@JsonProperty @JsonProperty
private boolean fetchesMessages; private boolean fetchesMessages;
@JsonProperty @JsonProperty
private int registrationId; private int registrationId;
@JsonProperty
@Length(max = 50, message = "This field must be less than 50 characters")
private String name;
@JsonProperty
private boolean voice;
public AccountAttributes() {} public AccountAttributes() {}
public AccountAttributes(String signalingKey, boolean supportsSms, boolean fetchesMessages, int registrationId) { @VisibleForTesting
public AccountAttributes(String signalingKey, boolean fetchesMessages, int registrationId) {
this(signalingKey, fetchesMessages, registrationId, null, false);
}
@VisibleForTesting
public AccountAttributes(String signalingKey, boolean fetchesMessages, int registrationId, String name, boolean voice) {
this.signalingKey = signalingKey; this.signalingKey = signalingKey;
this.supportsSms = supportsSms;
this.fetchesMessages = fetchesMessages; this.fetchesMessages = fetchesMessages;
this.registrationId = registrationId; this.registrationId = registrationId;
this.name = name;
this.voice = voice;
} }
public String getSignalingKey() { public String getSignalingKey() {
return signalingKey; return signalingKey;
} }
public boolean getSupportsSms() {
return supportsSms;
}
public boolean getFetchesMessages() { public boolean getFetchesMessages() {
return fetchesMessages; return fetchesMessages;
} }
@@ -58,4 +67,13 @@ public class AccountAttributes {
public int getRegistrationId() { public int getRegistrationId() {
return registrationId; return registrationId;
} }
public String getName() {
return name;
}
public boolean getVoice() {
return voice;
}
} }

View File

@@ -0,0 +1,76 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
public class ApnMessage {
public static long MAX_EXPIRATION = Integer.MAX_VALUE * 1000L;
@JsonProperty
@NotEmpty
private String apnId;
@JsonProperty
@NotEmpty
private String number;
@JsonProperty
@Min(1)
private int deviceId;
@JsonProperty
@NotEmpty
private String message;
@JsonProperty
@NotNull
private boolean voip;
@JsonProperty
private long expirationTime;
public ApnMessage() {}
public ApnMessage(String apnId, String number, int deviceId, String message, boolean voip, long expirationTime) {
this.apnId = apnId;
this.number = number;
this.deviceId = deviceId;
this.message = message;
this.voip = voip;
this.expirationTime = expirationTime;
}
public ApnMessage(ApnMessage copy, String apnId, boolean voip, long expirationTime) {
this.apnId = apnId;
this.number = copy.number;
this.deviceId = copy.deviceId;
this.message = copy.message;
this.voip = voip;
this.expirationTime = expirationTime;
}
@VisibleForTesting
public String getApnId() {
return apnId;
}
@VisibleForTesting
public boolean isVoip() {
return voip;
}
@VisibleForTesting
public String getMessage() {
return message;
}
@VisibleForTesting
public long getExpirationTime() {
return expirationTime;
}
}

View File

@@ -25,7 +25,14 @@ public class ApnRegistrationId {
@NotEmpty @NotEmpty
private String apnRegistrationId; private String apnRegistrationId;
@JsonProperty
private String voipRegistrationId;
public String getApnRegistrationId() { public String getApnRegistrationId() {
return apnRegistrationId; return apnRegistrationId;
} }
public String getVoipRegistrationId() {
return voipRegistrationId;
}
} }

View File

@@ -18,15 +18,10 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.gson.Gson;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.util.Arrays; import java.util.Arrays;
@JsonInclude(JsonInclude.Include.NON_DEFAULT) @JsonInclude(JsonInclude.Include.NON_DEFAULT)
@@ -37,14 +32,16 @@ public class ClientContact {
@JsonProperty @JsonProperty
private byte[] token; private byte[] token;
@JsonProperty
private boolean voice;
private String relay; private String relay;
private boolean inactive; private boolean inactive;
private boolean supportsSms;
public ClientContact(byte[] token, String relay, boolean supportsSms) { public ClientContact(byte[] token, String relay, boolean voice) {
this.token = token; this.token = token;
this.relay = relay; this.relay = relay;
this.supportsSms = supportsSms; this.voice = voice;
} }
public ClientContact() {} public ClientContact() {}
@@ -61,10 +58,6 @@ public class ClientContact {
this.relay = relay; this.relay = relay;
} }
public boolean isSupportsSms() {
return supportsSms;
}
public boolean isInactive() { public boolean isInactive() {
return inactive; return inactive;
} }
@@ -73,8 +66,12 @@ public class ClientContact {
this.inactive = inactive; this.inactive = inactive;
} }
public String toString() { public boolean isVoice() {
return new Gson().toJson(this); return voice;
}
public void setVoice(boolean voice) {
this.voice = voice;
} }
@Override @Override
@@ -86,8 +83,8 @@ public class ClientContact {
return return
Arrays.equals(this.token, that.token) && Arrays.equals(this.token, that.token) &&
this.supportsSms == that.supportsSms &&
this.inactive == that.inactive && this.inactive == that.inactive &&
this.voice == that.voice &&
(this.relay == null ? (that.relay == null) : this.relay.equals(that.relay)); (this.relay == null ? (that.relay == null) : this.relay.equals(that.relay));
} }

View File

@@ -0,0 +1,24 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DeviceInfo {
@JsonProperty
private long id;
@JsonProperty
private String name;
@JsonProperty
private long lastSeen;
@JsonProperty
private long created;
public DeviceInfo(long id, String name, long lastSeen, long created) {
this.id = id;
this.name = name;
this.lastSeen = lastSeen;
this.created = created;
}
}

View File

@@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class DeviceInfoList {
@JsonProperty
private List<DeviceInfo> devices;
public DeviceInfoList(List<DeviceInfo> devices) {
this.devices = devices;
}
}

View File

@@ -18,7 +18,7 @@ package org.whispersystems.textsecuregcm.entities;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.MessageProtos.OutgoingMessageSignal; import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.util.Base64; import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@@ -41,21 +41,25 @@ public class EncryptedOutgoingMessage {
private static final int MAC_KEY_SIZE = 20; private static final int MAC_KEY_SIZE = 20;
private static final int MAC_SIZE = 10; private static final int MAC_SIZE = 10;
private final String serialized; private final byte[] serialized;
private final String serializedAndEncoded;
public EncryptedOutgoingMessage(OutgoingMessageSignal outgoingMessage, public EncryptedOutgoingMessage(Envelope outgoingMessage, String signalingKey)
String signalingKey)
throws CryptoEncodingException throws CryptoEncodingException
{ {
byte[] plaintext = outgoingMessage.toByteArray(); byte[] plaintext = outgoingMessage.toByteArray();
SecretKeySpec cipherKey = getCipherKey (signalingKey); SecretKeySpec cipherKey = getCipherKey (signalingKey);
SecretKeySpec macKey = getMacKey(signalingKey); SecretKeySpec macKey = getMacKey(signalingKey);
byte[] ciphertext = getCiphertext(plaintext, cipherKey, macKey);
this.serialized = Base64.encodeBytes(ciphertext); this.serialized = getCiphertext(plaintext, cipherKey, macKey);
this.serializedAndEncoded = Base64.encodeBytes(this.serialized);
} }
public String serialize() { public String toEncodedString() {
return serializedAndEncoded;
}
public byte[] toByteArray() {
return serialized; return serialized;
} }

View File

@@ -0,0 +1,42 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.Min;
public class GcmMessage {
@JsonProperty
@NotEmpty
private String gcmId;
@JsonProperty
@NotEmpty
private String number;
@JsonProperty
@Min(1)
private int deviceId;
@JsonProperty
private String message;
@JsonProperty
private boolean receipt;
@JsonProperty
private boolean notification;
public GcmMessage() {}
public GcmMessage(String gcmId, String number, int deviceId, String message, boolean receipt, boolean notification) {
this.gcmId = gcmId;
this.number = number;
this.deviceId = deviceId;
this.message = message;
this.receipt = receipt;
this.notification = notification;
}
}

View File

@@ -25,9 +25,15 @@ public class GcmRegistrationId {
@NotEmpty @NotEmpty
private String gcmRegistrationId; private String gcmRegistrationId;
@JsonProperty
private boolean webSocketChannel;
public String getGcmRegistrationId() { public String getGcmRegistrationId() {
return gcmRegistrationId; return gcmRegistrationId;
} }
public boolean isWebSocketChannel() {
return webSocketChannel;
}
} }

View File

@@ -34,9 +34,11 @@ public class IncomingMessage {
private int destinationRegistrationId; private int destinationRegistrationId;
@JsonProperty @JsonProperty
@NotEmpty
private String body; private String body;
@JsonProperty
private String content;
@JsonProperty @JsonProperty
private String relay; private String relay;
@@ -67,4 +69,8 @@ public class IncomingMessage {
public int getDestinationRegistrationId() { public int getDestinationRegistrationId() {
return destinationRegistrationId; return destinationRegistrationId;
} }
public String getContent() {
return content;
}
} }

View File

@@ -0,0 +1,80 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
public class OutgoingMessageEntity {
@JsonIgnore
private long id;
@JsonProperty
private int type;
@JsonProperty
private String relay;
@JsonProperty
private long timestamp;
@JsonProperty
private String source;
@JsonProperty
private int sourceDevice;
@JsonProperty
private byte[] message;
@JsonProperty
private byte[] content;
public OutgoingMessageEntity() {}
public OutgoingMessageEntity(long id, int type, String relay, long timestamp,
String source, int sourceDevice, byte[] message,
byte[] content)
{
this.id = id;
this.type = type;
this.relay = relay;
this.timestamp = timestamp;
this.source = source;
this.sourceDevice = sourceDevice;
this.message = message;
this.content = content;
}
public int getType() {
return type;
}
public String getRelay() {
return relay;
}
public long getTimestamp() {
return timestamp;
}
public String getSource() {
return source;
}
public int getSourceDevice() {
return sourceDevice;
}
public byte[] getMessage() {
return message;
}
public byte[] getContent() {
return content;
}
public long getId() {
return id;
}
}

View File

@@ -0,0 +1,30 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
public class OutgoingMessageEntityList {
@JsonProperty
private List<OutgoingMessageEntity> messages;
@JsonProperty
private boolean more;
public OutgoingMessageEntityList() {}
public OutgoingMessageEntityList(List<OutgoingMessageEntity> messages, boolean more) {
this.messages = messages;
this.more = more;
}
public List<OutgoingMessageEntity> getMessages() {
return messages;
}
public boolean hasMore() {
return more;
}
}

View File

@@ -1,60 +0,0 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class PendingMessage {
@JsonProperty
private String sender;
@JsonProperty
private long messageId;
@JsonProperty
private String encryptedOutgoingMessage;
@JsonProperty
private boolean receipt;
public PendingMessage() {}
public PendingMessage(String sender, long messageId, boolean receipt, String encryptedOutgoingMessage) {
this.sender = sender;
this.messageId = messageId;
this.receipt = receipt;
this.encryptedOutgoingMessage = encryptedOutgoingMessage;
}
public String getEncryptedOutgoingMessage() {
return encryptedOutgoingMessage;
}
public long getMessageId() {
return messageId;
}
public String getSender() {
return sender;
}
public boolean isReceipt() {
return receipt;
}
@Override
public boolean equals(Object other) {
if (other == null || !(other instanceof PendingMessage)) return false;
PendingMessage that = (PendingMessage)other;
return
this.sender.equals(that.sender) &&
this.messageId == that.messageId &&
this.receipt == that.receipt &&
this.encryptedOutgoingMessage.equals(that.encryptedOutgoingMessage);
}
@Override
public int hashCode() {
return this.sender.hashCode() ^ (int)this.messageId ^ this.encryptedOutgoingMessage.hashCode() ^ (receipt ? 1 : 0);
}
}

View File

@@ -16,6 +16,7 @@
*/ */
package org.whispersystems.textsecuregcm.entities; package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
@@ -42,7 +43,19 @@ public class PreKeyResponseV2 {
} }
@VisibleForTesting @VisibleForTesting
public List<PreKeyResponseItemV2> getDevices() { @JsonIgnore
return devices; public PreKeyResponseItemV2 getDevice(int deviceId) {
for (PreKeyResponseItemV2 device : devices) {
if (device.getDeviceId() == deviceId) return device;
}
return null;
} }
@VisibleForTesting
@JsonIgnore
public int getDevicesCount() {
return devices.size();
}
} }

View File

@@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
public class ProvisioningMessage {
@JsonProperty
@NotEmpty
private String body;
public String getBody() {
return body;
}
}

View File

@@ -0,0 +1,16 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class SendMessageResponse {
@JsonProperty
private boolean needsSync;
public SendMessageResponse() {}
public SendMessageResponse(boolean needsSync) {
this.needsSync = needsSync;
}
}

View File

@@ -0,0 +1,47 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.Min;
public class UnregisteredEvent {
@JsonProperty
@NotEmpty
private String registrationId;
@JsonProperty
private String canonicalId;
@JsonProperty
@NotEmpty
private String number;
@JsonProperty
@Min(1)
private int deviceId;
@JsonProperty
private long timestamp;
public String getRegistrationId() {
return registrationId;
}
public String getCanonicalId() {
return canonicalId;
}
public String getNumber() {
return number;
}
public int getDeviceId() {
return deviceId;
}
public long getTimestamp() {
return timestamp;
}
}

View File

@@ -0,0 +1,17 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.LinkedList;
import java.util.List;
public class UnregisteredEventList {
@JsonProperty
private List<UnregisteredEvent> devices;
public List<UnregisteredEvent> getDevices() {
if (devices == null) return new LinkedList<>();
else return devices;
}
}

View File

@@ -18,17 +18,15 @@ package org.whispersystems.textsecuregcm.federation;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import com.sun.jersey.api.client.Client; import org.apache.http.config.Registry;
import com.sun.jersey.api.client.ClientHandlerException; import org.apache.http.config.RegistryBuilder;
import com.sun.jersey.api.client.ClientResponse; import org.apache.http.conn.socket.ConnectionSocketFactory;
import com.sun.jersey.api.client.UniformInterfaceException; import org.apache.http.conn.ssl.DefaultHostnameVerifier;
import com.sun.jersey.api.client.WebResource; import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import com.sun.jersey.api.client.config.ClientConfig;
import com.sun.jersey.api.client.config.DefaultClientConfig;
import com.sun.jersey.api.json.JSONConfiguration;
import com.sun.jersey.client.urlconnection.HTTPSProperties;
import org.apache.http.conn.ssl.StrictHostnameVerifier;
import org.bouncycastle.openssl.PEMReader; import org.bouncycastle.openssl.PEMReader;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.RequestEntityProcessing;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.AccountCount; import org.whispersystems.textsecuregcm.entities.AccountCount;
@@ -38,11 +36,13 @@ import org.whispersystems.textsecuregcm.entities.ClientContacts;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList; import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.PreKeyResponseV1; import org.whispersystems.textsecuregcm.entities.PreKeyResponseV1;
import org.whispersystems.textsecuregcm.entities.PreKeyResponseV2; import org.whispersystems.textsecuregcm.entities.PreKeyResponseV2;
import org.whispersystems.textsecuregcm.util.Base64;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.TrustManagerFactory;
import javax.ws.rs.ProcessingException;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -57,7 +57,10 @@ import java.security.SecureRandom;
import java.security.cert.CertificateException; import java.security.cert.CertificateException;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.List; import java.util.List;
import java.util.Map;
import io.dropwizard.client.JerseyClientBuilder;
import io.dropwizard.client.JerseyClientConfiguration;
import io.dropwizard.setup.Environment;
public class FederatedClient { public class FederatedClient {
@@ -73,15 +76,14 @@ public class FederatedClient {
private final FederatedPeer peer; private final FederatedPeer peer;
private final Client client; private final Client client;
private final String authorizationHeader;
public FederatedClient(String federationName, FederatedPeer peer) public FederatedClient(Environment environment, JerseyClientConfiguration configuration,
String federationName, FederatedPeer peer)
throws IOException throws IOException
{ {
try { try {
this.client = Client.create(getClientConfig(peer)); this.client = createClient(environment, configuration, federationName, peer);
this.peer = peer; this.peer = peer;
this.authorizationHeader = getAuthorizationHeader(federationName, peer);
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
throw new AssertionError(e); throw new AssertionError(e);
} catch (KeyStoreException | KeyManagementException | CertificateException e) { } catch (KeyStoreException | KeyManagementException | CertificateException e) {
@@ -91,20 +93,14 @@ public class FederatedClient {
public URL getSignedAttachmentUri(long attachmentId) throws IOException { public URL getSignedAttachmentUri(long attachmentId) throws IOException {
try { try {
WebResource resource = client.resource(peer.getUrl()) AttachmentUri response = client.target(peer.getUrl())
.path(String.format(ATTACHMENT_URI_PATH, attachmentId)); .path(String.format(ATTACHMENT_URI_PATH, attachmentId))
.request()
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(AttachmentUri.class);
ClientResponse response = resource.accept(MediaType.APPLICATION_JSON) return response.getLocation();
.header("Authorization", authorizationHeader) } catch (ProcessingException e) {
.get(ClientResponse.class);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new WebApplicationException(clientResponseToResponse(response));
}
return response.getEntity(AttachmentUri.class).getLocation();
} catch (UniformInterfaceException | ClientHandlerException e) {
logger.warn("Bad URI", e); logger.warn("Bad URI", e);
throw new IOException(e); throw new IOException(e);
} }
@@ -112,19 +108,14 @@ public class FederatedClient {
public Optional<PreKeyResponseV1> getKeysV1(String destination, String device) { public Optional<PreKeyResponseV1> getKeysV1(String destination, String device) {
try { try {
WebResource resource = client.resource(peer.getUrl()).path(String.format(PREKEY_PATH_DEVICE_V1, destination, device)); PreKeyResponseV1 response = client.target(peer.getUrl())
.path(String.format(PREKEY_PATH_DEVICE_V1, destination, device))
.request()
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(PreKeyResponseV1.class);
ClientResponse response = resource.accept(MediaType.APPLICATION_JSON) return Optional.of(response);
.header("Authorization", authorizationHeader) } catch (ProcessingException e) {
.get(ClientResponse.class);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new WebApplicationException(clientResponseToResponse(response));
}
return Optional.of(response.getEntity(PreKeyResponseV1.class));
} catch (UniformInterfaceException | ClientHandlerException e) {
logger.warn("PreKey", e); logger.warn("PreKey", e);
return Optional.absent(); return Optional.absent();
} }
@@ -132,34 +123,29 @@ public class FederatedClient {
public Optional<PreKeyResponseV2> getKeysV2(String destination, String device) { public Optional<PreKeyResponseV2> getKeysV2(String destination, String device) {
try { try {
WebResource resource = client.resource(peer.getUrl()).path(String.format(PREKEY_PATH_DEVICE_V2, destination, device)); PreKeyResponseV2 response = client.target(peer.getUrl())
.path(String.format(PREKEY_PATH_DEVICE_V2, destination, device))
.request()
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(PreKeyResponseV2.class);
ClientResponse response = resource.accept(MediaType.APPLICATION_JSON) return Optional.of(response);
.header("Authorization", authorizationHeader) } catch (ProcessingException e) {
.get(ClientResponse.class);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new WebApplicationException(clientResponseToResponse(response));
}
return Optional.of(response.getEntity(PreKeyResponseV2.class));
} catch (UniformInterfaceException | ClientHandlerException e) {
logger.warn("PreKey", e); logger.warn("PreKey", e);
return Optional.absent(); return Optional.absent();
} }
} }
public int getUserCount() { public int getUserCount() {
try { try {
WebResource resource = client.resource(peer.getUrl()).path(USER_COUNT_PATH); AccountCount count = client.target(peer.getUrl())
AccountCount count = resource.accept(MediaType.APPLICATION_JSON) .path(USER_COUNT_PATH)
.header("Authorization", authorizationHeader) .request()
.get(AccountCount.class); .accept(MediaType.APPLICATION_JSON_TYPE)
.get(AccountCount.class);
return count.getCount(); return count.getCount();
} catch (UniformInterfaceException | ClientHandlerException e) { } catch (ProcessingException e) {
logger.warn("User Count", e); logger.warn("User Count", e);
return 0; return 0;
} }
@@ -167,13 +153,14 @@ public class FederatedClient {
public List<ClientContact> getUserTokens(int offset) { public List<ClientContact> getUserTokens(int offset) {
try { try {
WebResource resource = client.resource(peer.getUrl()).path(String.format(USER_TOKENS_PATH, offset)); ClientContacts contacts = client.target(peer.getUrl())
ClientContacts contacts = resource.accept(MediaType.APPLICATION_JSON) .path(String.format(USER_TOKENS_PATH, offset))
.header("Authorization", authorizationHeader) .request()
.get(ClientContacts.class); .accept(MediaType.APPLICATION_JSON_TYPE)
.get(ClientContacts.class);
return contacts.getContacts(); return contacts.getContacts();
} catch (UniformInterfaceException | ClientHandlerException e) { } catch (ProcessingException e) {
logger.warn("User Tokens", e); logger.warn("User Tokens", e);
return null; return null;
} }
@@ -182,46 +169,53 @@ public class FederatedClient {
public void sendMessages(String source, long sourceDeviceId, String destination, IncomingMessageList messages) public void sendMessages(String source, long sourceDeviceId, String destination, IncomingMessageList messages)
throws IOException throws IOException
{ {
Response response = null;
try { try {
WebResource resource = client.resource(peer.getUrl()).path(String.format(RELAY_MESSAGE_PATH, source, sourceDeviceId, destination)); response = client.target(peer.getUrl())
ClientResponse response = resource.type(MediaType.APPLICATION_JSON) .path(String.format(RELAY_MESSAGE_PATH, source, sourceDeviceId, destination))
.header("Authorization", authorizationHeader) .request()
.entity(messages) .put(Entity.json(messages));
.put(ClientResponse.class);
if (response.getStatus() != 200 && response.getStatus() != 204) { if (response.getStatus() != 200 && response.getStatus() != 204) {
throw new WebApplicationException(clientResponseToResponse(response)); if (response.getStatus() == 411) throw new WebApplicationException(Response.status(413).build());
else throw new WebApplicationException(Response.status(response.getStatusInfo()).build());
} }
} catch (UniformInterfaceException | ClientHandlerException e) {
} catch (ProcessingException e) {
logger.warn("sendMessage", e); logger.warn("sendMessage", e);
throw new IOException(e); throw new IOException(e);
} finally {
if (response != null) response.close();
} }
} }
public void sendDeliveryReceipt(String source, long sourceDeviceId, String destination, long messageId) public void sendDeliveryReceipt(String source, long sourceDeviceId, String destination, long messageId)
throws IOException throws IOException
{ {
Response response = null;
try { try {
String path = String.format(RECEIPT_PATH, source, sourceDeviceId, destination, messageId); response = client.target(peer.getUrl())
WebResource resource = client.resource(peer.getUrl()).path(path); .path(String.format(RECEIPT_PATH, source, sourceDeviceId, destination, messageId))
ClientResponse response = resource.type(MediaType.APPLICATION_JSON) .request()
.header("Authorization", authorizationHeader) .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true)
.put(ClientResponse.class); .put(Entity.entity("", MediaType.APPLICATION_JSON_TYPE));
if (response.getStatus() != 200 && response.getStatus() != 204) { if (response.getStatus() != 200 && response.getStatus() != 204) {
throw new WebApplicationException(clientResponseToResponse(response)); if (response.getStatus() == 411) throw new WebApplicationException(Response.status(413).build());
else throw new WebApplicationException(Response.status(response.getStatusInfo()).build());
} }
} catch (UniformInterfaceException | ClientHandlerException e) { } catch (ProcessingException e) {
logger.warn("sendMessage", e); logger.warn("sendMessage", e);
throw new IOException(e); throw new IOException(e);
} finally {
if (response != null) response.close();
} }
} }
private String getAuthorizationHeader(String federationName, FederatedPeer peer) { private Client createClient(Environment environment, JerseyClientConfiguration configuration,
return "Basic " + Base64.encodeBytes((federationName + ":" + peer.getAuthenticationToken()).getBytes()); String federationName, FederatedPeer peer)
}
private ClientConfig getClientConfig(FederatedPeer peer)
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, CertificateException throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, CertificateException
{ {
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509"); TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509");
@@ -230,12 +224,19 @@ public class FederatedClient {
SSLContext sslContext = SSLContext.getInstance("TLS"); SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), SecureRandom.getInstance("SHA1PRNG")); sslContext.init(null, trustManagerFactory.getTrustManagers(), SecureRandom.getInstance("SHA1PRNG"));
ClientConfig config = new DefaultClientConfig(); SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new DefaultHostnameVerifier());
config.getProperties().put(HTTPSProperties.PROPERTY_HTTPS_PROPERTIES, Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create().register("https", sslConnectionSocketFactory).build();
new HTTPSProperties(new StrictHostnameVerifier(), sslContext));
config.getFeatures().put(JSONConfiguration.FEATURE_POJO_MAPPING, Boolean.TRUE);
return config; Client client = new JerseyClientBuilder(environment).using(configuration)
.using(registry)
.build("FederatedClient");
client.property(ClientProperties.CONNECT_TIMEOUT, 5000);
client.property(ClientProperties.READ_TIMEOUT, 10000);
client.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED);
client.register(HttpAuthenticationFeature.basic(federationName, peer.getAuthenticationToken()));
return client;
} }
private KeyStore initializeTrustStore(String name, String pemCertificate) private KeyStore initializeTrustStore(String name, String pemCertificate)
@@ -261,19 +262,6 @@ public class FederatedClient {
} }
} }
private Response clientResponseToResponse(ClientResponse r) {
Response.ResponseBuilder rb = Response.status(r.getStatus());
for (Map.Entry<String, List<String>> entry : r.getHeaders().entrySet()) {
for (String value : entry.getValue()) {
rb.header(entry.getKey(), value);
}
}
rb.entity(r.getEntityInputStream());
return rb.build();
}
public String getPeerName() { public String getPeerName() {
return peer.getName(); return peer.getName();
} }

View File

@@ -25,13 +25,18 @@ import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import io.dropwizard.client.JerseyClientConfiguration;
import io.dropwizard.setup.Environment;
public class FederatedClientManager { public class FederatedClientManager {
private final Logger logger = LoggerFactory.getLogger(FederatedClientManager.class); private final Logger logger = LoggerFactory.getLogger(FederatedClientManager.class);
private final HashMap<String, FederatedClient> clients = new HashMap<>(); private final HashMap<String, FederatedClient> clients = new HashMap<>();
public FederatedClientManager(FederationConfiguration federationConfig) public FederatedClientManager(Environment environment,
JerseyClientConfiguration clientConfig,
FederationConfiguration federationConfig)
throws IOException throws IOException
{ {
List<FederatedPeer> peers = federationConfig.getPeers(); List<FederatedPeer> peers = federationConfig.getPeers();
@@ -40,7 +45,7 @@ public class FederatedClientManager {
if (peers != null) { if (peers != null) {
for (FederatedPeer peer : peers) { for (FederatedPeer peer : peers) {
logger.info("Adding peer: " + peer.getName()); logger.info("Adding peer: " + peer.getName());
clients.put(peer.getName(), new FederatedClient(identity, peer)); clients.put(peer.getName(), new FederatedClient(environment, clientConfig, identity, peer));
} }
} }
} }

View File

@@ -40,6 +40,6 @@ public class NonLimitedAccount extends Account {
@Override @Override
public Optional<Device> getAuthenticatedDevice() { public Optional<Device> getAuthenticatedDevice() {
return Optional.of(new Device(deviceId, null, null, null, null, null, false, 0, null)); return Optional.of(new Device(deviceId, null, null, null, null, null, null, null, false, 0, null, System.currentTimeMillis(), System.currentTimeMillis(), false, "NA"));
} }
} }

View File

@@ -16,9 +16,13 @@
*/ */
package org.whispersystems.textsecuregcm.limits; package org.whispersystems.textsecuregcm.limits;
import java.io.Serializable; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class LeakyBucket implements Serializable { import java.io.IOException;
public class LeakyBucket {
private final int bucketSize; private final int bucketSize;
private final double leakRatePerMillis; private final double leakRatePerMillis;
@@ -27,10 +31,14 @@ public class LeakyBucket implements Serializable {
private long lastUpdateTimeMillis; private long lastUpdateTimeMillis;
public LeakyBucket(int bucketSize, double leakRatePerMillis) { public LeakyBucket(int bucketSize, double leakRatePerMillis) {
this(bucketSize, leakRatePerMillis, bucketSize, System.currentTimeMillis());
}
private LeakyBucket(int bucketSize, double leakRatePerMillis, int spaceRemaining, long lastUpdateTimeMillis) {
this.bucketSize = bucketSize; this.bucketSize = bucketSize;
this.leakRatePerMillis = leakRatePerMillis; this.leakRatePerMillis = leakRatePerMillis;
this.spaceRemaining = bucketSize; this.spaceRemaining = spaceRemaining;
this.lastUpdateTimeMillis = System.currentTimeMillis(); this.lastUpdateTimeMillis = lastUpdateTimeMillis;
} }
public boolean add(int amount) { public boolean add(int amount) {
@@ -50,4 +58,40 @@ public class LeakyBucket implements Serializable {
return Math.min(this.bucketSize, return Math.min(this.bucketSize,
(int)Math.floor(this.spaceRemaining + (elapsedTime * this.leakRatePerMillis))); (int)Math.floor(this.spaceRemaining + (elapsedTime * this.leakRatePerMillis)));
} }
public String serialize(ObjectMapper mapper) throws JsonProcessingException {
return mapper.writeValueAsString(new LeakyBucketEntity(bucketSize, leakRatePerMillis, spaceRemaining, lastUpdateTimeMillis));
}
public static LeakyBucket fromSerialized(ObjectMapper mapper, String serialized) throws IOException {
LeakyBucketEntity entity = mapper.readValue(serialized, LeakyBucketEntity.class);
return new LeakyBucket(entity.bucketSize, entity.leakRatePerMillis,
entity.spaceRemaining, entity.lastUpdateTimeMillis);
}
private static class LeakyBucketEntity {
@JsonProperty
private int bucketSize;
@JsonProperty
private double leakRatePerMillis;
@JsonProperty
private int spaceRemaining;
@JsonProperty
private long lastUpdateTimeMillis;
public LeakyBucketEntity() {}
private LeakyBucketEntity(int bucketSize, double leakRatePerMillis,
int spaceRemaining, long lastUpdateTimeMillis)
{
this.bucketSize = bucketSize;
this.leakRatePerMillis = leakRatePerMillis;
this.spaceRemaining = spaceRemaining;
this.lastUpdateTimeMillis = lastUpdateTimeMillis;
}
}
} }

View File

@@ -19,27 +19,38 @@ package org.whispersystems.textsecuregcm.limits;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries; import com.codahale.metrics.SharedMetricRegistries;
import net.spy.memcached.MemcachedClient; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import java.io.IOException;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class RateLimiter { public class RateLimiter {
private final Meter meter; private final Logger logger = LoggerFactory.getLogger(RateLimiter.class);
private final MemcachedClient memcachedClient; private final ObjectMapper mapper = SystemMapper.getMapper();
private final String name;
private final int bucketSize;
private final double leakRatePerMillis;
public RateLimiter(MemcachedClient memcachedClient, String name, private final Meter meter;
private final JedisPool cacheClient;
private final String name;
private final int bucketSize;
private final double leakRatePerMillis;
public RateLimiter(JedisPool cacheClient, String name,
int bucketSize, double leakRatePerMinute) int bucketSize, double leakRatePerMinute)
{ {
MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
this.meter = metricRegistry.meter(name(getClass(), name, "exceeded")); this.meter = metricRegistry.meter(name(getClass(), name, "exceeded"));
this.memcachedClient = memcachedClient; this.cacheClient = cacheClient;
this.name = name; this.name = name;
this.bucketSize = bucketSize; this.bucketSize = bucketSize;
this.leakRatePerMillis = leakRatePerMinute / (60.0 * 1000.0); this.leakRatePerMillis = leakRatePerMinute / (60.0 * 1000.0);
@@ -61,21 +72,29 @@ public class RateLimiter {
} }
private void setBucket(String key, LeakyBucket bucket) { private void setBucket(String key, LeakyBucket bucket) {
memcachedClient.set(getBucketName(key), try (Jedis jedis = cacheClient.getResource()) {
(int)Math.ceil((bucketSize / leakRatePerMillis) / 1000), bucket); String serialized = bucket.serialize(mapper);
} jedis.setex(getBucketName(key), (int) Math.ceil((bucketSize / leakRatePerMillis) / 1000), serialized);
} catch (JsonProcessingException e) {
private LeakyBucket getBucket(String key) { throw new IllegalArgumentException(e);
LeakyBucket bucket = (LeakyBucket)memcachedClient.get(getBucketName(key));
if (bucket == null) {
return new LeakyBucket(bucketSize, leakRatePerMillis);
} else {
return bucket;
} }
} }
private LeakyBucket getBucket(String key) {
try (Jedis jedis = cacheClient.getResource()) {
String serialized = jedis.get(getBucketName(key));
if (serialized != null) {
return LeakyBucket.fromSerialized(mapper, serialized);
}
} catch (IOException e) {
logger.warn("Deserialization error", e);
}
return new LeakyBucket(bucketSize, leakRatePerMillis);
}
private String getBucketName(String key) { private String getBucketName(String key) {
return LeakyBucket.class.getSimpleName() + name + key; return "leaky_bucket::" + name + "::" + key;
} }
} }

View File

@@ -17,9 +17,10 @@
package org.whispersystems.textsecuregcm.limits; package org.whispersystems.textsecuregcm.limits;
import net.spy.memcached.MemcachedClient;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import redis.clients.jedis.JedisPool;
public class RateLimiters { public class RateLimiters {
private final RateLimiter smsDestinationLimiter; private final RateLimiter smsDestinationLimiter;
@@ -34,40 +35,40 @@ public class RateLimiters {
private final RateLimiter allocateDeviceLimiter; private final RateLimiter allocateDeviceLimiter;
private final RateLimiter verifyDeviceLimiter; private final RateLimiter verifyDeviceLimiter;
public RateLimiters(RateLimitsConfiguration config, MemcachedClient memcachedClient) { public RateLimiters(RateLimitsConfiguration config, JedisPool cacheClient) {
this.smsDestinationLimiter = new RateLimiter(memcachedClient, "smsDestination", this.smsDestinationLimiter = new RateLimiter(cacheClient, "smsDestination",
config.getSmsDestination().getBucketSize(), config.getSmsDestination().getBucketSize(),
config.getSmsDestination().getLeakRatePerMinute()); config.getSmsDestination().getLeakRatePerMinute());
this.voiceDestinationLimiter = new RateLimiter(memcachedClient, "voxDestination", this.voiceDestinationLimiter = new RateLimiter(cacheClient, "voxDestination",
config.getVoiceDestination().getBucketSize(), config.getVoiceDestination().getBucketSize(),
config.getVoiceDestination().getLeakRatePerMinute()); config.getVoiceDestination().getLeakRatePerMinute());
this.verifyLimiter = new RateLimiter(memcachedClient, "verify", this.verifyLimiter = new RateLimiter(cacheClient, "verify",
config.getVerifyNumber().getBucketSize(), config.getVerifyNumber().getBucketSize(),
config.getVerifyNumber().getLeakRatePerMinute()); config.getVerifyNumber().getLeakRatePerMinute());
this.attachmentLimiter = new RateLimiter(memcachedClient, "attachmentCreate", this.attachmentLimiter = new RateLimiter(cacheClient, "attachmentCreate",
config.getAttachments().getBucketSize(), config.getAttachments().getBucketSize(),
config.getAttachments().getLeakRatePerMinute()); config.getAttachments().getLeakRatePerMinute());
this.contactsLimiter = new RateLimiter(memcachedClient, "contactsQuery", this.contactsLimiter = new RateLimiter(cacheClient, "contactsQuery",
config.getContactQueries().getBucketSize(), config.getContactQueries().getBucketSize(),
config.getContactQueries().getLeakRatePerMinute()); config.getContactQueries().getLeakRatePerMinute());
this.preKeysLimiter = new RateLimiter(memcachedClient, "prekeys", this.preKeysLimiter = new RateLimiter(cacheClient, "prekeys",
config.getPreKeys().getBucketSize(), config.getPreKeys().getBucketSize(),
config.getPreKeys().getLeakRatePerMinute()); config.getPreKeys().getLeakRatePerMinute());
this.messagesLimiter = new RateLimiter(memcachedClient, "messages", this.messagesLimiter = new RateLimiter(cacheClient, "messages",
config.getMessages().getBucketSize(), config.getMessages().getBucketSize(),
config.getMessages().getLeakRatePerMinute()); config.getMessages().getLeakRatePerMinute());
this.allocateDeviceLimiter = new RateLimiter(memcachedClient, "allocateDevice", this.allocateDeviceLimiter = new RateLimiter(cacheClient, "allocateDevice",
config.getAllocateDevice().getBucketSize(), config.getAllocateDevice().getBucketSize(),
config.getAllocateDevice().getLeakRatePerMinute()); config.getAllocateDevice().getLeakRatePerMinute());
this.verifyDeviceLimiter = new RateLimiter(memcachedClient, "verifyDevice", this.verifyDeviceLimiter = new RateLimiter(cacheClient, "verifyDevice",
config.getVerifyDevice().getBucketSize(), config.getVerifyDevice().getBucketSize(),
config.getVerifyDevice().getLeakRatePerMinute()); config.getVerifyDevice().getLeakRatePerMinute());

View File

@@ -0,0 +1,63 @@
package org.whispersystems.textsecuregcm.liquibase;
import com.codahale.metrics.MetricRegistry;
import net.sourceforge.argparse4j.inf.Namespace;
import java.sql.SQLException;
import io.dropwizard.Configuration;
import io.dropwizard.cli.ConfiguredCommand;
import io.dropwizard.db.DatabaseConfiguration;
import io.dropwizard.db.ManagedDataSource;
import io.dropwizard.db.PooledDataSourceFactory;
import io.dropwizard.setup.Bootstrap;
import liquibase.Liquibase;
import liquibase.exception.LiquibaseException;
import liquibase.exception.ValidationFailedException;
public abstract class AbstractLiquibaseCommand<T extends Configuration> extends ConfiguredCommand<T> {
private final DatabaseConfiguration<T> strategy;
private final Class<T> configurationClass;
private final String migrations;
protected AbstractLiquibaseCommand(String name,
String description,
String migrations,
DatabaseConfiguration<T> strategy,
Class<T> configurationClass) {
super(name, description);
this.migrations = migrations;
this.strategy = strategy;
this.configurationClass = configurationClass;
}
@Override
protected Class<T> getConfigurationClass() {
return configurationClass;
}
@Override
@SuppressWarnings("UseOfSystemOutOrSystemErr")
protected void run(Bootstrap<T> bootstrap, Namespace namespace, T configuration) throws Exception {
final PooledDataSourceFactory dbConfig = strategy.getDataSourceFactory(configuration);
dbConfig.asSingleConnectionPool();
try (final CloseableLiquibase liquibase = openLiquibase(dbConfig, namespace)) {
run(namespace, liquibase);
} catch (ValidationFailedException e) {
e.printDescriptiveError(System.err);
throw e;
}
}
private CloseableLiquibase openLiquibase(final PooledDataSourceFactory dataSourceFactory, final Namespace namespace)
throws ClassNotFoundException, SQLException, LiquibaseException
{
final ManagedDataSource dataSource = dataSourceFactory.build(new MetricRegistry(), "liquibase");
return new CloseableLiquibase(dataSource, migrations);
}
protected abstract void run(Namespace namespace, Liquibase liquibase) throws Exception;
}

View File

@@ -0,0 +1,28 @@
package org.whispersystems.textsecuregcm.liquibase;
import java.sql.SQLException;
import io.dropwizard.db.ManagedDataSource;
import liquibase.Liquibase;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.LiquibaseException;
import liquibase.resource.ClassLoaderResourceAccessor;
public class CloseableLiquibase extends Liquibase implements AutoCloseable {
private final ManagedDataSource dataSource;
public CloseableLiquibase(ManagedDataSource dataSource, String migrations)
throws LiquibaseException, ClassNotFoundException, SQLException
{
super(migrations,
new ClassLoaderResourceAccessor(),
new JdbcConnection(dataSource.getConnection()));
this.dataSource = dataSource;
}
@Override
public void close() throws Exception {
dataSource.stop();
}
}

View File

@@ -0,0 +1,72 @@
package org.whispersystems.textsecuregcm.liquibase;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import java.io.OutputStreamWriter;
import java.util.List;
import io.dropwizard.Configuration;
import io.dropwizard.db.DatabaseConfiguration;
import liquibase.Liquibase;
public class DbMigrateCommand<T extends Configuration> extends AbstractLiquibaseCommand<T> {
public DbMigrateCommand(String migration, DatabaseConfiguration<T> strategy, Class<T> configurationClass) {
super("migrate", "Apply all pending change sets.", migration, strategy, configurationClass);
}
@Override
public void configure(Subparser subparser) {
super.configure(subparser);
subparser.addArgument("-n", "--dry-run")
.action(Arguments.storeTrue())
.dest("dry-run")
.setDefault(Boolean.FALSE)
.help("output the DDL to stdout, don't run it");
subparser.addArgument("-c", "--count")
.type(Integer.class)
.dest("count")
.help("only apply the next N change sets");
subparser.addArgument("-i", "--include")
.action(Arguments.append())
.dest("contexts")
.help("include change sets from the given context");
}
@Override
@SuppressWarnings("UseOfSystemOutOrSystemErr")
public void run(Namespace namespace, Liquibase liquibase) throws Exception {
final String context = getContext(namespace);
final Integer count = namespace.getInt("count");
final Boolean dryRun = namespace.getBoolean("dry-run");
if (count != null) {
if (dryRun) {
liquibase.update(count, context, new OutputStreamWriter(System.out, Charsets.UTF_8));
} else {
liquibase.update(count, context);
}
} else {
if (dryRun) {
liquibase.update(context, new OutputStreamWriter(System.out, Charsets.UTF_8));
} else {
liquibase.update(context);
}
}
}
private String getContext(Namespace namespace) {
final List<Object> contexts = namespace.getList("contexts");
if (contexts == null) {
return "";
}
return Joiner.on(',').join(contexts);
}
}

View File

@@ -0,0 +1,51 @@
package org.whispersystems.textsecuregcm.liquibase;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import java.io.OutputStreamWriter;
import java.util.List;
import io.dropwizard.Configuration;
import io.dropwizard.db.DatabaseConfiguration;
import liquibase.Liquibase;
public class DbStatusCommand <T extends Configuration> extends AbstractLiquibaseCommand<T> {
public DbStatusCommand(String migrations, DatabaseConfiguration<T> strategy, Class<T> configurationClass) {
super("status", "Check for pending change sets.", migrations, strategy, configurationClass);
}
@Override
public void configure(Subparser subparser) {
super.configure(subparser);
subparser.addArgument("-v", "--verbose")
.action(Arguments.storeTrue())
.dest("verbose")
.help("Output verbose information");
subparser.addArgument("-i", "--include")
.action(Arguments.append())
.dest("contexts")
.help("include change sets from the given context");
}
@Override
@SuppressWarnings("UseOfSystemOutOrSystemErr")
public void run(Namespace namespace, Liquibase liquibase) throws Exception {
liquibase.reportStatus(namespace.getBoolean("verbose"),
getContext(namespace),
new OutputStreamWriter(System.out, Charsets.UTF_8));
}
private String getContext(Namespace namespace) {
final List<Object> contexts = namespace.getList("contexts");
if (contexts == null) {
return "";
}
return Joiner.on(',').join(contexts);
}
}

View File

@@ -0,0 +1,44 @@
package org.whispersystems.textsecuregcm.liquibase;
import com.google.common.collect.Maps;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import java.util.SortedMap;
import io.dropwizard.Configuration;
import io.dropwizard.db.DatabaseConfiguration;
import liquibase.Liquibase;
public class NameableDbCommand<T extends Configuration> extends AbstractLiquibaseCommand<T> {
private static final String COMMAND_NAME_ATTR = "subcommand";
private final SortedMap<String, AbstractLiquibaseCommand<T>> subcommands;
public NameableDbCommand(String name, String migrations, DatabaseConfiguration<T> strategy, Class<T> configurationClass) {
super(name, "Run database migrations tasks", migrations, strategy, configurationClass);
this.subcommands = Maps.newTreeMap();
addSubcommand(new DbMigrateCommand<>(migrations, strategy, configurationClass));
addSubcommand(new DbStatusCommand<>(migrations, strategy, configurationClass));
}
private void addSubcommand(AbstractLiquibaseCommand<T> subcommand) {
subcommands.put(subcommand.getName(), subcommand);
}
@Override
public void configure(Subparser subparser) {
for (AbstractLiquibaseCommand<T> subcommand : subcommands.values()) {
final Subparser cmdParser = subparser.addSubparsers()
.addParser(subcommand.getName())
.setDefault(COMMAND_NAME_ATTR, subcommand.getName())
.description(subcommand.getDescription());
subcommand.configure(cmdParser);
}
}
@Override
public void run(Namespace namespace, Liquibase liquibase) throws Exception {
final AbstractLiquibaseCommand<T> subcommand = subcommands.get(namespace.getString(COMMAND_NAME_ATTR));
subcommand.run(namespace, liquibase);
}
}

View File

@@ -0,0 +1,27 @@
package org.whispersystems.textsecuregcm.liquibase;
import io.dropwizard.Bundle;
import io.dropwizard.Configuration;
import io.dropwizard.db.DatabaseConfiguration;
import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment;
import io.dropwizard.util.Generics;
public abstract class NameableMigrationsBundle<T extends Configuration> implements Bundle, DatabaseConfiguration<T> {
private final String name;
private final String migrations;
public NameableMigrationsBundle(String name, String migrations) {
this.name = name;
this.migrations = migrations;
}
public final void initialize(Bootstrap<?> bootstrap) {
Class klass = Generics.getTypeParameter(this.getClass(), Configuration.class);
bootstrap.addCommand(new NameableDbCommand(name, migrations, this, klass));
}
public final void run(Environment environment) {
}
}

View File

@@ -0,0 +1,33 @@
package org.whispersystems.textsecuregcm.mappers;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.textsecuregcm.controllers.DeviceLimitExceededException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
@Provider
public class DeviceLimitExceededExceptionMapper implements ExceptionMapper<DeviceLimitExceededException> {
@Override
public Response toResponse(DeviceLimitExceededException exception) {
return Response.status(411)
.entity(new DeviceLimitExceededDetails(exception.getCurrentDevices(),
exception.getMaxDevices()))
.build();
}
private static class DeviceLimitExceededDetails {
@JsonProperty
private int current;
@JsonProperty
private int max;
public DeviceLimitExceededDetails(int current, int max) {
this.current = current;
this.max = max;
}
}
}

View File

@@ -31,7 +31,9 @@ public class IOExceptionMapper implements ExceptionMapper<IOException> {
@Override @Override
public Response toResponse(IOException e) { public Response toResponse(IOException e) {
logger.warn("IOExceptionMapper", e); if (!(e.getCause() instanceof java.util.concurrent.TimeoutException)) {
logger.warn("IOExceptionMapper", e);
}
return Response.status(503).build(); return Response.status(503).build();
} }
} }

View File

@@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.mappers;
import org.whispersystems.textsecuregcm.websocket.InvalidWebsocketAddressException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
@Provider
public class InvalidWebsocketAddressExceptionMapper implements ExceptionMapper<InvalidWebsocketAddressException> {
@Override
public Response toResponse(InvalidWebsocketAddressException exception) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
}

View File

@@ -0,0 +1,19 @@
package org.whispersystems.textsecuregcm.metrics;
import com.codahale.metrics.Gauge;
import java.io.File;
public class FileDescriptorGauge implements Gauge<Integer> {
@Override
public Integer getValue() {
File file = new File("/proc/self/fd");
if (file.isDirectory() && file.exists()) {
return file.list().length;
}
return 0;
}
}

View File

@@ -27,25 +27,25 @@ import java.util.SortedMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/**
* Adapted from MetricsServlet.
*/
public class JsonMetricsReporter extends ScheduledReporter { public class JsonMetricsReporter extends ScheduledReporter {
private final Logger logger = LoggerFactory.getLogger(JsonMetricsReporter.class); private static final Pattern SIMPLE_NAMES = Pattern.compile("[^a-zA-Z0-9_.\\-~]");
private final Logger logger = LoggerFactory.getLogger(JsonMetricsReporter.class);
private final JsonFactory factory = new JsonFactory(); private final JsonFactory factory = new JsonFactory();
private final String table; private final String token;
private final String sunnylabsHost; private final String hostname;
private final String host; private final String host;
public JsonMetricsReporter(MetricRegistry registry, String token, String sunnylabsHost) public JsonMetricsReporter(MetricRegistry registry, String token, String hostname,
MetricFilter filter, TimeUnit rateUnit, TimeUnit durationUnit)
throws UnknownHostException throws UnknownHostException
{ {
super(registry, "jsonmetrics-reporter", MetricFilter.ALL, TimeUnit.SECONDS, TimeUnit.MILLISECONDS); super(registry, "json-reporter", filter, rateUnit, durationUnit);
this.table = token; this.token = token;
this.sunnylabsHost = sunnylabsHost; this.hostname = hostname;
this.host = InetAddress.getLocalHost().getHostName(); this.host = InetAddress.getLocalHost().getHostName();
} }
@Override @Override
@@ -56,8 +56,8 @@ public class JsonMetricsReporter extends ScheduledReporter {
SortedMap<String, Timer> stringTimerSortedMap) SortedMap<String, Timer> stringTimerSortedMap)
{ {
try { try {
logger.info("Reporting metrics..."); logger.debug("Reporting metrics...");
URL url = new URL("https", sunnylabsHost, 443, "/report/metrics?t=" + table + "&h=" + host); URL url = new URL("https", hostname, 443, String.format("/report/metrics?t=%s&h=%s", token, host));
HttpURLConnection connection = (HttpURLConnection) url.openConnection(); HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoOutput(true); connection.setDoOutput(true);
@@ -93,7 +93,7 @@ public class JsonMetricsReporter extends ScheduledReporter {
outputStream.close(); outputStream.close();
logger.info("Metrics server response: " + connection.getResponseCode()); logger.debug("Metrics server response: " + connection.getResponseCode());
} catch (IOException e) { } catch (IOException e) {
logger.warn("Error sending metrics", e); logger.warn("Error sending metrics", e);
} catch (Exception e) { } catch (Exception e) {
@@ -175,10 +175,66 @@ public class JsonMetricsReporter extends ScheduledReporter {
json.writeNumberField("m15", convertRate(meter.getFifteenMinuteRate())); json.writeNumberField("m15", convertRate(meter.getFifteenMinuteRate()));
} }
private static final Pattern SIMPLE_NAMES = Pattern.compile("[^a-zA-Z0-9_.\\-~]");
private String sanitize(String metricName) { private String sanitize(String metricName) {
return SIMPLE_NAMES.matcher(metricName).replaceAll("_"); return SIMPLE_NAMES.matcher(metricName).replaceAll("_");
} }
public static Builder forRegistry(MetricRegistry registry) {
return new Builder(registry);
}
public static class Builder {
private final MetricRegistry registry;
private MetricFilter filter = MetricFilter.ALL;
private TimeUnit rateUnit = TimeUnit.SECONDS;
private TimeUnit durationUnit = TimeUnit.MILLISECONDS;
private String token;
private String hostname;
private Builder(MetricRegistry registry) {
this.registry = registry;
this.rateUnit = TimeUnit.SECONDS;
this.durationUnit = TimeUnit.MILLISECONDS;
this.filter = MetricFilter.ALL;
}
public Builder convertRatesTo(TimeUnit rateUnit) {
this.rateUnit = rateUnit;
return this;
}
public Builder convertDurationsTo(TimeUnit durationUnit) {
this.durationUnit = durationUnit;
return this;
}
public Builder filter(MetricFilter filter) {
this.filter = filter;
return this;
}
public Builder withToken(String token) {
this.token = token;
return this;
}
public Builder withHostname(String hostname) {
this.hostname = hostname;
return this;
}
public JsonMetricsReporter build() throws UnknownHostException {
if (hostname == null) {
throw new IllegalArgumentException("No hostname specified!");
}
if (token == null) {
throw new IllegalArgumentException("No token specified!");
}
return new JsonMetricsReporter(registry, token, hostname, filter, rateUnit, durationUnit);
}
}
} }

View File

@@ -0,0 +1,38 @@
package org.whispersystems.textsecuregcm.metrics;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.ScheduledReporter;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import javax.validation.constraints.NotNull;
import java.net.UnknownHostException;
import io.dropwizard.metrics.BaseReporterFactory;
@JsonTypeName("json")
public class JsonMetricsReporterFactory extends BaseReporterFactory {
@JsonProperty
@NotNull
private String hostname;
@JsonProperty
@NotNull
private String token;
@Override
public ScheduledReporter build(MetricRegistry metricRegistry) {
try {
return JsonMetricsReporter.forRegistry(metricRegistry)
.withHostname(hostname)
.withToken(token)
.convertRatesTo(getRateUnit())
.convertDurationsTo(getDurationUnit())
.filter(getFilter())
.build();
} catch (UnknownHostException e) {
throw new IllegalArgumentException(e);
}
}
}

View File

@@ -1,53 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.textsecuregcm.providers;
import com.codahale.metrics.health.HealthCheck;
import net.spy.memcached.MemcachedClient;
import java.security.SecureRandom;
public class MemcacheHealthCheck extends HealthCheck {
private final MemcachedClient client;
public MemcacheHealthCheck(MemcachedClient client) {
this.client = client;
}
@Override
protected Result check() throws Exception {
if (client == null) {
return Result.unhealthy("not configured");
}
int random = SecureRandom.getInstance("SHA1PRNG").nextInt();
int value = SecureRandom.getInstance("SHA1PRNG").nextInt();
this.client.set("HEALTH" + random, 2000, String.valueOf(value));
String result = (String)this.client.get("HEALTH" + random);
if (result == null || Integer.parseInt(result) != value) {
return Result.unhealthy("Fetch failed");
}
this.client.delete("HEALTH" + random);
return Result.healthy();
}
}

View File

@@ -1,54 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.textsecuregcm.providers;
import net.spy.memcached.AddrUtil;
import net.spy.memcached.ConnectionFactoryBuilder;
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.auth.AuthDescriptor;
import net.spy.memcached.auth.PlainCallbackHandler;
import org.whispersystems.textsecuregcm.configuration.MemcacheConfiguration;
import org.whispersystems.textsecuregcm.util.Util;
import java.io.IOException;
public class MemcachedClientFactory {
private final MemcachedClient client;
public MemcachedClientFactory(MemcacheConfiguration config) throws IOException {
ConnectionFactoryBuilder builder = new ConnectionFactoryBuilder();
builder.setProtocol(ConnectionFactoryBuilder.Protocol.BINARY);
if (!Util.isEmpty(config.getUser())) {
AuthDescriptor ad = new AuthDescriptor(new String[] { "PLAIN" },
new PlainCallbackHandler(config.getUser(),
config.getPassword()));
builder.setAuthDescriptor(ad);
}
this.client = new MemcachedClient(builder.build(),
AddrUtil.getAddresses(config.getServers()));
}
public MemcachedClient getClient() {
return client;
}
}

View File

@@ -16,9 +16,14 @@
*/ */
package org.whispersystems.textsecuregcm.providers; package org.whispersystems.textsecuregcm.providers;
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.io.RedisPubSubConnectionFactory;
import org.whispersystems.dispatch.redis.PubSubConnection;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
import java.io.IOException;
import java.net.Socket;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
@@ -26,29 +31,40 @@ import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Protocol; import redis.clients.jedis.Protocol;
public class RedisClientFactory { public class RedisClientFactory implements RedisPubSubConnectionFactory {
private final Logger logger = LoggerFactory.getLogger(RedisClientFactory.class);
private final String host;
private final int port;
private final JedisPool jedisPool; private final JedisPool jedisPool;
public RedisClientFactory(RedisConfiguration redisConfig) throws URISyntaxException { public RedisClientFactory(String url) throws URISyntaxException {
JedisPoolConfig poolConfig = new JedisPoolConfig(); JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setTestOnBorrow(true); poolConfig.setTestOnBorrow(true);
URI redisURI = new URI(redisConfig.getUrl()); URI redisURI = new URI(url);
String redisHost = redisURI.getHost();
int redisPort = redisURI.getPort();
String redisPassword = null;
if (!Util.isEmpty(redisURI.getUserInfo())) { this.host = redisURI.getHost();
redisPassword = redisURI.getUserInfo().split(":",2)[1]; this.port = redisURI.getPort();
} this.jedisPool = new JedisPool(poolConfig, host, port,
Protocol.DEFAULT_TIMEOUT, null);
this.jedisPool = new JedisPool(poolConfig, redisHost, redisPort,
Protocol.DEFAULT_TIMEOUT, redisPassword);
} }
public JedisPool getRedisClientPool() { public JedisPool getRedisClientPool() {
return jedisPool; return jedisPool;
} }
@Override
public PubSubConnection connect() {
while (true) {
try {
Socket socket = new Socket(host, port);
return new PubSubConnection(socket);
} catch (IOException e) {
logger.warn("Error connecting", e);
Util.sleep(200);
}
}
}
} }

View File

@@ -31,9 +31,7 @@ public class RedisHealthCheck extends HealthCheck {
@Override @Override
protected Result check() throws Exception { protected Result check() throws Exception {
Jedis client = clientPool.getResource(); try (Jedis client = clientPool.getResource()) {
try {
client.set("HEALTH", "test"); client.set("HEALTH", "test");
if (!"test".equals(client.get("HEALTH"))) { if (!"test".equals(client.get("HEALTH"))) {
@@ -41,8 +39,6 @@ public class RedisHealthCheck extends HealthCheck {
} }
return Result.healthy(); return Result.healthy();
} finally {
clientPool.returnResource(client);
} }
} }
} }

View File

@@ -1,248 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.textsecuregcm.push;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Optional;
import com.notnoop.apns.APNS;
import com.notnoop.apns.ApnsService;
import com.notnoop.exceptions.NetworkIOException;
import net.spy.memcached.MemcachedClient;
import org.bouncycastle.openssl.PEMReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PendingMessage;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PubSubMessage;
import org.whispersystems.textsecuregcm.storage.StoredMessages;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.lifecycle.Managed;
public class APNSender implements Managed {
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter websocketMeter = metricRegistry.meter(name(getClass(), "websocket"));
private final Meter pushMeter = metricRegistry.meter(name(getClass(), "push"));
private final Meter failureMeter = metricRegistry.meter(name(getClass(), "failure"));
private final Logger logger = LoggerFactory.getLogger(APNSender.class);
private static final String MESSAGE_BODY = "m";
private static final ObjectMapper mapper = SystemMapper.getMapper();
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private final AccountsManager accounts;
private final PubSubManager pubSubManager;
private final StoredMessages storedMessages;
private final MemcachedClient memcachedClient;
private final String apnCertificate;
private final String apnKey;
private Optional<ApnsService> apnService;
public APNSender(AccountsManager accounts,
PubSubManager pubSubManager,
StoredMessages storedMessages,
MemcachedClient memcachedClient,
String apnCertificate, String apnKey)
{
this.accounts = accounts;
this.pubSubManager = pubSubManager;
this.storedMessages = storedMessages;
this.apnCertificate = apnCertificate;
this.apnKey = apnKey;
this.memcachedClient = memcachedClient;
}
public void sendMessage(Account account, Device device,
String registrationId, PendingMessage message)
throws TransientPushFailureException
{
try {
String serializedPendingMessage = mapper.writeValueAsString(message);
WebsocketAddress websocketAddress = new WebsocketAddress(account.getNumber(), device.getId());
if (pubSubManager.publish(websocketAddress, new PubSubMessage(PubSubMessage.TYPE_DELIVER,
serializedPendingMessage)))
{
websocketMeter.mark();
} else {
memcacheSet(registrationId, account.getNumber());
storedMessages.insert(websocketAddress, message);
if (!message.isReceipt()) {
sendPush(registrationId, serializedPendingMessage);
}
}
} catch (IOException e) {
throw new TransientPushFailureException(e);
}
}
private void sendPush(String registrationId, String message)
throws TransientPushFailureException
{
try {
if (!apnService.isPresent()) {
failureMeter.mark();
throw new TransientPushFailureException("APN access not configured!");
}
String payload = APNS.newPayload()
.alertBody("Message!")
.customField(MESSAGE_BODY, message)
.build();
logger.debug("APN Payload: " + payload);
apnService.get().push(registrationId, payload);
pushMeter.mark();
} catch (NetworkIOException nioe) {
logger.warn("Network Error", nioe);
failureMeter.mark();
throw new TransientPushFailureException(nioe);
}
}
private static byte[] initializeKeyStore(String pemCertificate, String pemKey)
throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException
{
PEMReader reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemCertificate.getBytes())));
X509Certificate certificate = (X509Certificate) reader.readObject();
Certificate[] certificateChain = {certificate};
reader = new PEMReader(new InputStreamReader(new ByteArrayInputStream(pemKey.getBytes())));
KeyPair keyPair = (KeyPair) reader.readObject();
KeyStore keyStore = KeyStore.getInstance("pkcs12");
keyStore.load(null);
keyStore.setEntry("apn",
new KeyStore.PrivateKeyEntry(keyPair.getPrivate(), certificateChain),
new KeyStore.PasswordProtection("insecure".toCharArray()));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
keyStore.store(baos, "insecure".toCharArray());
return baos.toByteArray();
}
@Override
public void start() throws Exception {
if (!Util.isEmpty(apnCertificate) && !Util.isEmpty(apnKey)) {
byte[] keyStore = initializeKeyStore(apnCertificate, apnKey);
this.apnService = Optional.of(APNS.newService()
.withCert(new ByteArrayInputStream(keyStore), "insecure")
.asQueued()
.withSandboxDestination().build());
this.executor.scheduleAtFixedRate(new FeedbackRunnable(), 0, 1, TimeUnit.HOURS);
} else {
this.apnService = Optional.absent();
}
}
@Override
public void stop() throws Exception {
if (apnService.isPresent()) {
apnService.get().stop();
}
}
private void memcacheSet(String registrationId, String number) {
if (memcachedClient != null) {
memcachedClient.set("APN-" + registrationId, 60 * 60 * 24, number);
}
}
private Optional<String> memcacheGet(String registrationId) {
if (memcachedClient != null) {
return Optional.fromNullable((String)memcachedClient.get("APN-" + registrationId));
} else {
return Optional.absent();
}
}
private class FeedbackRunnable implements Runnable {
private void updateAccount(Account account, String registrationId) {
boolean needsUpdate = false;
for (Device device : account.getDevices()) {
if (registrationId.equals(device.getApnId())) {
needsUpdate = true;
device.setApnId(null);
}
}
if (needsUpdate) {
accounts.update(account);
}
}
@Override
public void run() {
if (apnService.isPresent()) {
Map<String, Date> inactiveDevices = apnService.get().getInactiveDevices();
for (String registrationId : inactiveDevices.keySet()) {
Optional<String> number = memcacheGet(registrationId);
if (number.isPresent()) {
Optional<Account> account = accounts.get(number.get());
if (account.isPresent()) {
updateAccount(account.get(), registrationId);
}
} else {
logger.warn("APN unregister event received for uncached ID: " + registrationId);
}
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
package org.whispersystems.textsecuregcm.push;
import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.RatioGauge;
import com.codahale.metrics.SharedMetricRegistries;
import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.InvalidProtocolBufferException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.DispatchChannel;
import org.whispersystems.textsecuregcm.entities.ApnMessage;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PubSubProtos.PubSubMessage;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebSocketConnectionInfo;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map.Entry;
import java.util.concurrent.TimeUnit;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.lifecycle.Managed;
public class ApnFallbackManager implements Managed, Runnable, DispatchChannel {
private static final Logger logger = LoggerFactory.getLogger(ApnFallbackManager.class);
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private static final Meter voipOneSuccess = metricRegistry.meter(name(ApnFallbackManager.class, "voip_one_success"));
private static final Meter voipOneDelivery = metricRegistry.meter(name(ApnFallbackManager.class, "voip_one_failure"));
private static final Histogram voipOneSuccessHistogram = metricRegistry.histogram(name(ApnFallbackManager.class, "voip_one_success_histogram"));
static {
metricRegistry.register(name(ApnFallbackManager.class, "voip_one_success_ratio"), new VoipRatioGauge(voipOneSuccess, voipOneDelivery));
}
private final ApnFallbackTaskQueue taskQueue = new ApnFallbackTaskQueue();
private final PushServiceClient pushServiceClient;
private final PubSubManager pubSubManager;
public ApnFallbackManager(PushServiceClient pushServiceClient, PubSubManager pubSubManager) {
this.pushServiceClient = pushServiceClient;
this.pubSubManager = pubSubManager;
}
public void schedule(final WebsocketAddress address, ApnFallbackTask task) {
voipOneDelivery.mark();
if (taskQueue.put(address, task)) {
pubSubManager.subscribe(new WebSocketConnectionInfo(address), this);
}
}
private void cancel(WebsocketAddress address) {
ApnFallbackTask task = taskQueue.remove(address);
if (task != null) {
pubSubManager.unsubscribe(new WebSocketConnectionInfo(address), this);
voipOneSuccess.mark();
voipOneSuccessHistogram.update(System.currentTimeMillis() - task.getScheduledTime());
}
}
@Override
public void start() throws Exception {
new Thread(this).start();
}
@Override
public void stop() throws Exception {
}
@Override
public void run() {
while (true) {
try {
Entry<WebsocketAddress, ApnFallbackTask> taskEntry = taskQueue.get();
ApnFallbackTask task = taskEntry.getValue();
pubSubManager.unsubscribe(new WebSocketConnectionInfo(taskEntry.getKey()), this);
pushServiceClient.send(new ApnMessage(task.getMessage(), task.getApnId(),
false, ApnMessage.MAX_EXPIRATION));
} catch (Throwable e) {
logger.warn("ApnFallbackThread", e);
}
}
}
@Override
public void onDispatchMessage(String channel, byte[] message) {
try {
PubSubMessage notification = PubSubMessage.parseFrom(message);
if (notification.getType().getNumber() == PubSubMessage.Type.CONNECTED_VALUE) {
WebSocketConnectionInfo address = new WebSocketConnectionInfo(channel);
cancel(address.getWebsocketAddress());
} else {
logger.warn("Got strange pubsub type: " + notification.getType().getNumber());
}
} catch (WebSocketConnectionInfo.FormattingException e) {
logger.warn("Bad formatting?", e);
} catch (InvalidProtocolBufferException e) {
logger.warn("Bad protobuf", e);
}
}
@Override
public void onDispatchSubscribed(String channel) {}
@Override
public void onDispatchUnsubscribed(String channel) {}
public static class ApnFallbackTask {
private final long delay;
private final long scheduledTime;
private final String apnId;
private final ApnMessage message;
public ApnFallbackTask(String apnId, ApnMessage message) {
this(apnId, message, TimeUnit.SECONDS.toMillis(30));
}
@VisibleForTesting
public ApnFallbackTask(String apnId, ApnMessage message, long delay) {
this.scheduledTime = System.currentTimeMillis();
this.delay = delay;
this.apnId = apnId;
this.message = message;
}
public String getApnId() {
return apnId;
}
public ApnMessage getMessage() {
return message;
}
public long getScheduledTime() {
return scheduledTime;
}
public long getExecutionTime() {
return scheduledTime + delay;
}
public long getDelay() {
return delay;
}
}
@VisibleForTesting
public static class ApnFallbackTaskQueue {
private final LinkedHashMap<WebsocketAddress, ApnFallbackTask> tasks = new LinkedHashMap<>();
public Entry<WebsocketAddress, ApnFallbackTask> get() {
while (true) {
long timeDelta;
synchronized (tasks) {
while (tasks.isEmpty()) Util.wait(tasks);
Iterator<Entry<WebsocketAddress, ApnFallbackTask>> iterator = tasks.entrySet().iterator();
Entry<WebsocketAddress, ApnFallbackTask> nextTask = iterator.next();
timeDelta = nextTask.getValue().getExecutionTime() - System.currentTimeMillis();
if (timeDelta <= 0) {
iterator.remove();
return nextTask;
}
}
Util.sleep(timeDelta);
}
}
public boolean put(WebsocketAddress address, ApnFallbackTask task) {
synchronized (tasks) {
ApnFallbackTask previous = tasks.put(address, task);
tasks.notifyAll();
return previous == null;
}
}
public ApnFallbackTask remove(WebsocketAddress address) {
synchronized (tasks) {
return tasks.remove(address);
}
}
}
private static class VoipRatioGauge extends RatioGauge {
private final Meter success;
private final Meter attempts;
private VoipRatioGauge(Meter success, Meter attempts) {
this.success = success;
this.attempts = attempts;
}
@Override
protected Ratio getRatio() {
return Ratio.of(success.getFiveMinuteRate(), attempts.getFiveMinuteRate());
}
}
}

View File

@@ -0,0 +1,116 @@
package org.whispersystems.textsecuregcm.push;
import com.google.common.base.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.UnregisteredEvent;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import io.dropwizard.lifecycle.Managed;
public class FeedbackHandler implements Managed, Runnable {
private final Logger logger = LoggerFactory.getLogger(PushServiceClient.class);
private final PushServiceClient client;
private final AccountsManager accountsManager;
private ScheduledExecutorService executor;
public FeedbackHandler(PushServiceClient client, AccountsManager accountsManager) {
this.client = client;
this.accountsManager = accountsManager;
}
@Override
public void start() throws Exception {
this.executor = Executors.newSingleThreadScheduledExecutor();
this.executor.scheduleAtFixedRate(this, 0, 1, TimeUnit.MINUTES);
}
@Override
public void stop() throws Exception {
if (this.executor != null) {
this.executor.shutdown();
}
}
@Override
public void run() {
try {
List<UnregisteredEvent> gcmFeedback = client.getGcmFeedback();
List<UnregisteredEvent> apnFeedback = client.getApnFeedback();
for (UnregisteredEvent gcmEvent : gcmFeedback) {
handleGcmUnregistered(gcmEvent);
}
for (UnregisteredEvent apnEvent : apnFeedback) {
handleApnUnregistered(apnEvent);
}
} catch (IOException e) {
logger.warn("Error retrieving feedback: ", e);
}
}
private void handleGcmUnregistered(UnregisteredEvent event) {
logger.info("Got GCM Unregistered: " + event.getNumber() + "," + event.getDeviceId());
Optional<Account> account = accountsManager.get(event.getNumber());
if (account.isPresent()) {
Optional<Device> device = account.get().getDevice(event.getDeviceId());
if (device.isPresent()) {
if (event.getRegistrationId().equals(device.get().getGcmId())) {
logger.info("GCM Unregister GCM ID matches!");
if (device.get().getPushTimestamp() == 0 ||
event.getTimestamp() > (device.get().getPushTimestamp() + TimeUnit.SECONDS.toMillis(10)))
{
logger.info("GCM Unregister Timestamp matches!");
if (event.getCanonicalId() != null && !event.getCanonicalId().isEmpty()) {
logger.info("It's a canonical ID update...");
device.get().setGcmId(event.getCanonicalId());
} else {
device.get().setGcmId(null);
device.get().setFetchesMessages(false);
}
accountsManager.update(account.get());
}
}
}
}
}
private void handleApnUnregistered(UnregisteredEvent event) {
logger.info("Got APN Unregistered: " + event.getNumber() + "," + event.getDeviceId());
Optional<Account> account = accountsManager.get(event.getNumber());
if (account.isPresent()) {
Optional<Device> device = account.get().getDevice(event.getDeviceId());
if (device.isPresent()) {
if (event.getRegistrationId().equals(device.get().getApnId())) {
logger.info("APN Unregister APN ID matches!");
if (device.get().getPushTimestamp() == 0 ||
event.getTimestamp() > device.get().getPushTimestamp())
{
logger.info("APN Unregister timestamp matches!");
device.get().setApnId(null);
accountsManager.update(account.get());
}
}
}
}
}
}

View File

@@ -1,424 +0,0 @@
package org.whispersystems.textsecuregcm.push;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.google.common.base.Optional;
import org.jivesoftware.smack.ConnectionConfiguration;
import org.jivesoftware.smack.ConnectionListener;
import org.jivesoftware.smack.PacketListener;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.filter.PacketTypeFilter;
import org.jivesoftware.smack.packet.DefaultPacketExtension;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.Packet;
import org.jivesoftware.smack.packet.PacketExtension;
import org.jivesoftware.smack.provider.PacketExtensionProvider;
import org.jivesoftware.smack.provider.ProviderManager;
import org.jivesoftware.smack.tcp.XMPPTCPConnection;
import org.jivesoftware.smack.util.StringUtils;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PendingMessage;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.Util;
import org.xmlpull.v1.XmlPullParser;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.lifecycle.Managed;
public class GCMSender implements Managed, PacketListener {
private final Logger logger = LoggerFactory.getLogger(GCMSender.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(org.whispersystems.textsecuregcm.util.Constants.METRICS_NAME);
private final Meter success = metricRegistry.meter(name(getClass(), "sent", "success"));
private final Meter failure = metricRegistry.meter(name(getClass(), "sent", "failure"));
private final Meter unregistered = metricRegistry.meter(name(getClass(), "sent", "unregistered"));
private static final String GCM_SERVER = "gcm.googleapis.com";
private static final int GCM_PORT = 5235;
private static final String GCM_ELEMENT_NAME = "gcm";
private static final String GCM_NAMESPACE = "google:mobile:data";
private final Map<String, UnacknowledgedMessage> pendingMessages = new ConcurrentHashMap<>();
private final long senderId;
private final String apiKey;
private final AccountsManager accounts;
private XMPPTCPConnection connection;
public GCMSender(AccountsManager accounts, long senderId, String apiKey) {
this.accounts = accounts;
this.senderId = senderId;
this.apiKey = apiKey;
ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
new GcmPacketExtensionProvider());
}
public void sendMessage(String destinationNumber, long destinationDeviceId,
String registrationId, PendingMessage message)
{
String messageId = "m-" + UUID.randomUUID().toString();
UnacknowledgedMessage unacknowledgedMessage = new UnacknowledgedMessage(destinationNumber,
destinationDeviceId,
registrationId, message);
sendMessage(messageId, unacknowledgedMessage);
}
public void sendMessage(String messageId, UnacknowledgedMessage message) {
try {
boolean isReceipt = message.getPendingMessage().isReceipt();
Map<String, String> dataObject = new HashMap<>();
dataObject.put("type", "message");
dataObject.put(isReceipt ? "receipt" : "message", message.getPendingMessage().getEncryptedOutgoingMessage());
Map<String, Object> messageObject = new HashMap<>();
messageObject.put("to", message.getRegistrationId());
messageObject.put("message_id", messageId);
messageObject.put("data", dataObject);
String json = JSONObject.toJSONString(messageObject);
pendingMessages.put(messageId, message);
connection.sendPacket(new GcmPacketExtension(json).toPacket());
} catch (SmackException.NotConnectedException e) {
logger.warn("GCMClient", "No connection", e);
}
}
@Override
public void start() throws Exception {
this.connection = connect(senderId, apiKey);
}
@Override
public void stop() throws Exception {
this.connection.disconnect();
}
@Override
public void processPacket(Packet packet) throws SmackException.NotConnectedException {
Message incomingMessage = (Message) packet;
GcmPacketExtension gcmPacket = (GcmPacketExtension) incomingMessage.getExtension(GCM_NAMESPACE);
String json = gcmPacket.getJson();
try {
Map<String, Object> jsonObject = (Map<String, Object>) JSONValue.parseWithException(json);
Object messageType = jsonObject.get("message_type");
if (messageType == null) {
handleUpstreamMessage(jsonObject);
return;
}
switch (messageType.toString()) {
case "ack" : handleAckReceipt(jsonObject); break;
case "nack" : handleNackReceipt(jsonObject); break;
case "receipt" : handleDeliveryReceipt(jsonObject); break;
case "control" : handleControlMessage(jsonObject); break;
default:
logger.warn("Received unknown GCM message: " + messageType.toString());
}
} catch (ParseException e) {
logger.warn("GCMClient", "Received unparsable message", e);
} catch (Exception e) {
logger.warn("GCMClient", "Failed to process packet", e);
}
}
private void handleControlMessage(Map<String, Object> message) {
String controlType = (String) message.get("control_type");
if ("CONNECTION_DRAINING".equals(controlType)) {
logger.warn("GCM Connection is draining! Initiating reconnect...");
reconnect();
} else {
logger.warn("Received unknown GCM control message: " + controlType);
}
}
private void handleDeliveryReceipt(Map<String, Object> message) {
logger.warn("Got delivery receipt!");
}
private void handleNackReceipt(Map<String, Object> message) {
String messageId = (String) message.get("message_id");
String errorCode = (String) message.get("error");
if (errorCode == null) {
logger.warn("Null GCM error code!");
if (messageId != null) {
pendingMessages.remove(messageId);
}
return;
}
switch (errorCode) {
case "BAD_REGISTRATION" : handleBadRegistration(message); break;
case "DEVICE_UNREGISTERED" : handleBadRegistration(message); break;
case "INTERNAL_SERVER_ERROR" : handleServerFailure(message); break;
case "INVALID_JSON" : handleClientFailure(message); break;
case "QUOTA_EXCEEDED" : handleClientFailure(message); break;
case "SERVICE_UNAVAILABLE" : handleServerFailure(message); break;
}
}
private void handleAckReceipt(Map<String, Object> message) {
success.mark();
String messageId = (String) message.get("message_id");
if (messageId != null) {
pendingMessages.remove(messageId);
}
}
private void handleUpstreamMessage(Map<String, Object> message)
throws SmackException.NotConnectedException
{
logger.warn("Got upstream message from GCM Server!");
for (String key : message.keySet()) {
logger.warn(key + " : " + message.get(key));
}
Map<String, Object> ack = new HashMap<>();
message.put("message_type", "ack");
message.put("to", message.get("from"));
message.put("message_id", message.get("message_id"));
String json = JSONValue.toJSONString(ack);
Packet request = new GcmPacketExtension(json).toPacket();
connection.sendPacket(request);
}
private void handleBadRegistration(Map<String, Object> message) {
unregistered.mark();
String messageId = (String) message.get("message_id");
if (messageId != null) {
UnacknowledgedMessage unacknowledgedMessage = pendingMessages.remove(messageId);
if (unacknowledgedMessage != null) {
Optional<Account> account = accounts.get(unacknowledgedMessage.getDestinationNumber());
if (account.isPresent()) {
Optional<Device> device = account.get().getDevice(unacknowledgedMessage.getDestinationDeviceId());
if (device.isPresent()) {
device.get().setGcmId(null);
accounts.update(account.get());
}
}
}
}
}
private void handleServerFailure(Map<String, Object> message) {
failure.mark();
String messageId = (String)message.get("message_id");
if (messageId != null) {
UnacknowledgedMessage unacknowledgedMessage = pendingMessages.remove(messageId);
if (unacknowledgedMessage != null) {
sendMessage(messageId, unacknowledgedMessage);
}
}
}
private void handleClientFailure(Map<String, Object> message) {
failure.mark();
logger.warn("Unrecoverable error: " + message.get("error"));
String messageId = (String)message.get("message_id");
if (messageId != null) {
pendingMessages.remove(messageId);
}
}
private void reconnect() {
try {
this.connection.disconnect();
} catch (SmackException.NotConnectedException e) {
logger.warn("GCMClient", "Disconnect attempt", e);
}
while (true) {
try {
this.connection = connect(senderId, apiKey);
return;
} catch (XMPPException | IOException | SmackException e) {
logger.warn("GCMClient", "Reconnecting", e);
Util.sleep(1000);
}
}
}
private XMPPTCPConnection connect(long senderId, String apiKey)
throws XMPPException, IOException, SmackException
{
ConnectionConfiguration config = new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
config.setSecurityMode(ConnectionConfiguration.SecurityMode.enabled);
config.setReconnectionAllowed(true);
config.setRosterLoadedAtLogin(false);
config.setSendPresence(false);
config.setSocketFactory(SSLSocketFactory.getDefault());
XMPPTCPConnection connection = new XMPPTCPConnection(config);
connection.connect();
connection.addConnectionListener(new LoggingConnectionListener());
connection.addPacketListener(this, new PacketTypeFilter(Message.class));
connection.login(senderId + "@gcm.googleapis.com", apiKey);
return connection;
}
private static class GcmPacketExtensionProvider implements PacketExtensionProvider {
@Override
public PacketExtension parseExtension(XmlPullParser xmlPullParser) throws Exception {
String json = xmlPullParser.nextText();
return new GcmPacketExtension(json);
}
}
private static final class GcmPacketExtension extends DefaultPacketExtension {
private final String json;
public GcmPacketExtension(String json) {
super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
this.json = json;
}
public String getJson() {
return json;
}
@Override
public String toXML() {
return String.format("<%s xmlns=\"%s\">%s</%s>", GCM_ELEMENT_NAME, GCM_NAMESPACE,
StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
}
public Packet toPacket() {
Message message = new Message();
message.addExtension(this);
return message;
}
}
private class LoggingConnectionListener implements ConnectionListener {
@Override
public void connected(XMPPConnection xmppConnection) {
logger.warn("GCM XMPP Connected.");
}
@Override
public void authenticated(XMPPConnection xmppConnection) {
logger.warn("GCM XMPP Authenticated.");
}
@Override
public void reconnectionSuccessful() {
logger.warn("GCM XMPP Reconnecting..");
Iterator<Map.Entry<String, UnacknowledgedMessage>> iterator =
pendingMessages.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, UnacknowledgedMessage> entry = iterator.next();
iterator.remove();
sendMessage(entry.getKey(), entry.getValue());
}
}
@Override
public void reconnectionFailed(Exception e) {
logger.warn("GCM XMPP Reconnection failed!", e);
reconnect();
}
@Override
public void reconnectingIn(int seconds) {
logger.warn(String.format("GCM XMPP Reconnecting in %d secs", seconds));
}
@Override
public void connectionClosedOnError(Exception e) {
logger.warn("GCM XMPP Connection closed on error.");
}
@Override
public void connectionClosed() {
logger.warn("GCM XMPP Connection closed.");
reconnect();
}
}
private static class UnacknowledgedMessage {
private final String destinationNumber;
private final long destinationDeviceId;
private final String registrationId;
private final PendingMessage pendingMessage;
private UnacknowledgedMessage(String destinationNumber,
long destinationDeviceId,
String registrationId,
PendingMessage pendingMessage)
{
this.destinationNumber = destinationNumber;
this.destinationDeviceId = destinationDeviceId;
this.registrationId = registrationId;
this.pendingMessage = pendingMessage;
}
private String getRegistrationId() {
return registrationId;
}
private PendingMessage getPendingMessage() {
return pendingMessage;
}
public String getDestinationNumber() {
return destinationNumber;
}
public long getDestinationDeviceId() {
return destinationDeviceId;
}
}
}

View File

@@ -16,77 +16,159 @@
*/ */
package org.whispersystems.textsecuregcm.push; package org.whispersystems.textsecuregcm.push;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.SharedMetricRegistries;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.CryptoEncodingException; import org.whispersystems.textsecuregcm.entities.ApnMessage;
import org.whispersystems.textsecuregcm.entities.EncryptedOutgoingMessage; import org.whispersystems.textsecuregcm.entities.GcmMessage;
import org.whispersystems.textsecuregcm.entities.MessageProtos; import org.whispersystems.textsecuregcm.push.ApnFallbackManager.ApnFallbackTask;
import org.whispersystems.textsecuregcm.entities.PendingMessage; import org.whispersystems.textsecuregcm.push.WebsocketSender.DeliveryStatus;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.BlockingThreadPoolExecutor;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import static org.whispersystems.textsecuregcm.entities.MessageProtos.OutgoingMessageSignal; import java.util.concurrent.TimeUnit;
public class PushSender { import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.lifecycle.Managed;
import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
public class PushSender implements Managed {
private final Logger logger = LoggerFactory.getLogger(PushSender.class); private final Logger logger = LoggerFactory.getLogger(PushSender.class);
private final GCMSender gcmSender; private static final String APN_PAYLOAD = "{\"aps\":{\"sound\":\"default\",\"badge\":%d,\"alert\":{\"loc-key\":\"APN_Message\"}}}";
private final APNSender apnSender;
private final WebsocketSender webSocketSender;
public PushSender(GCMSender gcmClient, private final ApnFallbackManager apnFallbackManager;
APNSender apnSender, private final PushServiceClient pushServiceClient;
WebsocketSender websocketSender) private final WebsocketSender webSocketSender;
private final BlockingThreadPoolExecutor executor;
private final int queueSize;
public PushSender(ApnFallbackManager apnFallbackManager, PushServiceClient pushServiceClient,
WebsocketSender websocketSender, int queueSize)
{ {
this.gcmSender = gcmClient; this.apnFallbackManager = apnFallbackManager;
this.apnSender = apnSender; this.pushServiceClient = pushServiceClient;
this.webSocketSender = websocketSender; this.webSocketSender = websocketSender;
this.queueSize = queueSize;
this.executor = new BlockingThreadPoolExecutor(50, queueSize);
SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME)
.register(name(PushSender.class, "send_queue_depth"),
new Gauge<Integer>() {
@Override
public Integer getValue() {
return executor.getSize();
}
});
} }
public void sendMessage(Account account, Device device, OutgoingMessageSignal message) public void sendMessage(final Account account, final Device device, final Envelope message)
throws NotPushRegisteredException, TransientPushFailureException throws NotPushRegisteredException
{ {
try { if (device.getGcmId() == null && device.getApnId() == null && !device.getFetchesMessages()) {
boolean isReceipt = message.getType() == OutgoingMessageSignal.Type.RECEIPT_VALUE; throw new NotPushRegisteredException("No delivery possible!");
String signalingKey = device.getSignalingKey(); }
EncryptedOutgoingMessage encryptedMessage = new EncryptedOutgoingMessage(message, signalingKey);
PendingMessage pendingMessage = new PendingMessage(message.getSource(),
message.getTimestamp(),
isReceipt,
encryptedMessage.serialize());
sendMessage(account, device, pendingMessage); if (queueSize > 0) {
} catch (CryptoEncodingException e) { executor.execute(new Runnable() {
throw new NotPushRegisteredException(e); @Override
public void run() {
sendSynchronousMessage(account, device, message);
}
});
} else {
sendSynchronousMessage(account, device, message);
} }
} }
public void sendMessage(Account account, Device device, PendingMessage pendingMessage) public void sendQueuedNotification(Account account, Device device, int messageQueueDepth)
throws NotPushRegisteredException, TransientPushFailureException throws NotPushRegisteredException, TransientPushFailureException
{ {
if (device.getGcmId() != null) sendGcmMessage(account, device, pendingMessage); if (device.getGcmId() != null) sendGcmNotification(account, device);
else if (device.getApnId() != null) sendApnMessage(account, device, pendingMessage); else if (device.getApnId() != null) sendApnNotification(account, device, messageQueueDepth);
else if (device.getFetchesMessages()) sendWebSocketMessage(account, device, pendingMessage); else if (!device.getFetchesMessages()) throw new NotPushRegisteredException("No notification possible!");
else throw new NotPushRegisteredException("No delivery possible!");
} }
private void sendGcmMessage(Account account, Device device, PendingMessage pendingMessage) { public WebsocketSender getWebSocketSender() {
String number = account.getNumber(); return webSocketSender;
long deviceId = device.getId();
String registrationId = device.getGcmId();
gcmSender.sendMessage(number, deviceId, registrationId, pendingMessage);
} }
private void sendApnMessage(Account account, Device device, PendingMessage outgoingMessage) private void sendSynchronousMessage(Account account, Device device, Envelope message) {
throws TransientPushFailureException if (device.getGcmId() != null) sendGcmMessage(account, device, message);
else if (device.getApnId() != null) sendApnMessage(account, device, message);
else if (device.getFetchesMessages()) sendWebSocketMessage(account, device, message);
else throw new AssertionError();
}
private void sendGcmMessage(Account account, Device device, Envelope message) {
DeliveryStatus deliveryStatus = webSocketSender.sendMessage(account, device, message, WebsocketSender.Type.GCM);
if (!deliveryStatus.isDelivered()) {
sendGcmNotification(account, device);
}
}
private void sendGcmNotification(Account account, Device device) {
try {
GcmMessage gcmMessage = new GcmMessage(device.getGcmId(), account.getNumber(),
(int)device.getId(), "", false, true);
pushServiceClient.send(gcmMessage);
} catch (TransientPushFailureException e) {
logger.warn("SILENT PUSH LOSS", e);
}
}
private void sendApnMessage(Account account, Device device, Envelope outgoingMessage) {
DeliveryStatus deliveryStatus = webSocketSender.sendMessage(account, device, outgoingMessage, WebsocketSender.Type.APN);
if (!deliveryStatus.isDelivered() && outgoingMessage.getType() != Envelope.Type.RECEIPT) {
sendApnNotification(account, device, deliveryStatus.getMessageQueueDepth());
}
}
private void sendApnNotification(Account account, Device device, int messageQueueDepth) {
ApnMessage apnMessage;
if (!Util.isEmpty(device.getVoipApnId())) {
apnMessage = new ApnMessage(device.getVoipApnId(), account.getNumber(), (int)device.getId(),
String.format(APN_PAYLOAD, messageQueueDepth),
true, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30));
apnFallbackManager.schedule(new WebsocketAddress(account.getNumber(), device.getId()),
new ApnFallbackTask(device.getApnId(), apnMessage));
} else {
apnMessage = new ApnMessage(device.getApnId(), account.getNumber(), (int)device.getId(),
String.format(APN_PAYLOAD, messageQueueDepth),
false, ApnMessage.MAX_EXPIRATION);
}
try {
pushServiceClient.send(apnMessage);
} catch (TransientPushFailureException e) {
logger.warn("SILENT PUSH LOSS", e);
}
}
private void sendWebSocketMessage(Account account, Device device, Envelope outgoingMessage)
{ {
apnSender.sendMessage(account, device, device.getApnId(), outgoingMessage); webSocketSender.sendMessage(account, device, outgoingMessage, WebsocketSender.Type.WEB);
} }
private void sendWebSocketMessage(Account account, Device device, PendingMessage outgoingMessage) @Override
{ public void start() throws Exception {
webSocketSender.sendMessage(account, device, outgoingMessage);
}
@Override
public void stop() throws Exception {
executor.shutdown();
executor.awaitTermination(5, TimeUnit.MINUTES);
} }
} }

View File

@@ -0,0 +1,94 @@
package org.whispersystems.textsecuregcm.push;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.PushConfiguration;
import org.whispersystems.textsecuregcm.entities.ApnMessage;
import org.whispersystems.textsecuregcm.entities.GcmMessage;
import org.whispersystems.textsecuregcm.entities.UnregisteredEvent;
import org.whispersystems.textsecuregcm.entities.UnregisteredEventList;
import org.whispersystems.textsecuregcm.util.Base64;
import javax.ws.rs.ProcessingException;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.List;
public class PushServiceClient {
private static final String PUSH_GCM_PATH = "/api/v1/push/gcm";
private static final String PUSH_APN_PATH = "/api/v1/push/apn";
private static final String APN_FEEDBACK_PATH = "/api/v1/feedback/apn";
private static final String GCM_FEEDBACK_PATH = "/api/v1/feedback/gcm";
private final Logger logger = LoggerFactory.getLogger(PushServiceClient.class);
private final Client client;
private final String host;
private final int port;
private final String authorization;
public PushServiceClient(Client client, PushConfiguration config) {
this.client = client;
this.host = config.getHost();
this.port = config.getPort();
this.authorization = getAuthorizationHeader(config.getUsername(), config.getPassword());
}
public void send(GcmMessage message) throws TransientPushFailureException {
sendPush(PUSH_GCM_PATH, message);
}
public void send(ApnMessage message) throws TransientPushFailureException {
sendPush(PUSH_APN_PATH, message);
}
public List<UnregisteredEvent> getGcmFeedback() throws IOException {
return getFeedback(GCM_FEEDBACK_PATH);
}
public List<UnregisteredEvent> getApnFeedback() throws IOException {
return getFeedback(APN_FEEDBACK_PATH);
}
private void sendPush(String path, Object entity) throws TransientPushFailureException {
try {
Response response = client.target("http://" + host + ":" + port)
.path(path)
.request()
.header("Authorization", authorization)
.put(Entity.entity(entity, MediaType.APPLICATION_JSON_TYPE));
if (response.getStatus() != 204 && response.getStatus() != 200) {
logger.warn("PushServer response: " + response.getStatus() + " " + response.getStatusInfo().getReasonPhrase());
throw new TransientPushFailureException("Bad response: " + response.getStatus());
}
} catch (ProcessingException e) {
logger.warn("Push error: ", e);
throw new TransientPushFailureException(e);
}
}
private List<UnregisteredEvent> getFeedback(String path) throws IOException {
try {
UnregisteredEventList unregisteredEvents = client.target("http://" + host + ":" + port)
.path(path)
.request()
.header("Authorization", authorization)
.get(UnregisteredEventList.class);
return unregisteredEvents.getDevices();
} catch (ProcessingException e) {
logger.warn("Request error:", e);
throw new IOException(e);
}
}
private String getAuthorizationHeader(String username, String password) {
return "Basic " + Base64.encodeBytes((username + ":" + password).getBytes());
}
}

View File

@@ -0,0 +1,87 @@
package org.whispersystems.textsecuregcm.push;
import com.google.common.base.Optional;
import org.whispersystems.textsecuregcm.controllers.NoSuchUserException;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.federation.FederatedClientManager;
import org.whispersystems.textsecuregcm.federation.NoSuchPeerException;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import java.io.IOException;
import java.util.Set;
public class ReceiptSender {
private final PushSender pushSender;
private final FederatedClientManager federatedClientManager;
private final AccountsManager accountManager;
public ReceiptSender(AccountsManager accountManager,
PushSender pushSender,
FederatedClientManager federatedClientManager)
{
this.federatedClientManager = federatedClientManager;
this.accountManager = accountManager;
this.pushSender = pushSender;
}
public void sendReceipt(Account source, String destination,
long messageId, Optional<String> relay)
throws IOException, NoSuchUserException,
NotPushRegisteredException, TransientPushFailureException
{
if (relay.isPresent() && !relay.get().isEmpty()) {
sendRelayedReceipt(source, destination, messageId, relay.get());
} else {
sendDirectReceipt(source, destination, messageId);
}
}
private void sendRelayedReceipt(Account source, String destination, long messageId, String relay)
throws NoSuchUserException, IOException
{
try {
federatedClientManager.getClient(relay)
.sendDeliveryReceipt(source.getNumber(),
source.getAuthenticatedDevice().get().getId(),
destination, messageId);
} catch (NoSuchPeerException e) {
throw new NoSuchUserException(e);
}
}
private void sendDirectReceipt(Account source, String destination, long messageId)
throws NotPushRegisteredException, TransientPushFailureException, NoSuchUserException
{
Account destinationAccount = getDestinationAccount(destination);
Set<Device> destinationDevices = destinationAccount.getDevices();
Envelope.Builder message = Envelope.newBuilder()
.setSource(source.getNumber())
.setSourceDevice((int) source.getAuthenticatedDevice().get().getId())
.setTimestamp(messageId)
.setType(Envelope.Type.RECEIPT);
if (source.getRelay().isPresent()) {
message.setRelay(source.getRelay().get());
}
for (Device destinationDevice : destinationDevices) {
pushSender.sendMessage(destinationAccount, destinationDevice, message.build());
}
}
private Account getDestinationAccount(String destination)
throws NoSuchUserException
{
Optional<Account> account = accountManager.get(destination);
if (!account.isPresent()) {
throw new NoSuchUserException(destination);
}
return account.get();
}
}

View File

@@ -19,55 +19,121 @@ package org.whispersystems.textsecuregcm.push;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries; import com.codahale.metrics.SharedMetricRegistries;
import com.fasterxml.jackson.core.JsonProcessingException; import com.google.protobuf.ByteString;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PendingMessage;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PubSubManager; import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PubSubMessage;
import org.whispersystems.textsecuregcm.storage.StoredMessages;
import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.SystemMapper; import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress;
import org.whispersystems.textsecuregcm.websocket.WebsocketAddress; import org.whispersystems.textsecuregcm.websocket.WebsocketAddress;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import static org.whispersystems.textsecuregcm.storage.PubSubProtos.PubSubMessage;
public class WebsocketSender { public class WebsocketSender {
public static enum Type {
APN,
GCM,
WEB
}
private static final Logger logger = LoggerFactory.getLogger(WebsocketSender.class); private static final Logger logger = LoggerFactory.getLogger(WebsocketSender.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter onlineMeter = metricRegistry.meter(name(getClass(), "online"));
private final Meter offlineMeter = metricRegistry.meter(name(getClass(), "offline"));
private static final ObjectMapper mapper = SystemMapper.getMapper(); private final Meter websocketRequeueMeter = metricRegistry.meter(name(getClass(), "ws_requeue"));
private final Meter websocketOnlineMeter = metricRegistry.meter(name(getClass(), "ws_online" ));
private final Meter websocketOfflineMeter = metricRegistry.meter(name(getClass(), "ws_offline" ));
private final StoredMessages storedMessages; private final Meter apnOnlineMeter = metricRegistry.meter(name(getClass(), "apn_online" ));
private final PubSubManager pubSubManager; private final Meter apnOfflineMeter = metricRegistry.meter(name(getClass(), "apn_offline"));
public WebsocketSender(StoredMessages storedMessages, PubSubManager pubSubManager) { private final Meter gcmOnlineMeter = metricRegistry.meter(name(getClass(), "gcm_online" ));
this.storedMessages = storedMessages; private final Meter gcmOfflineMeter = metricRegistry.meter(name(getClass(), "gcm_offline"));
this.pubSubManager = pubSubManager;
private final Meter provisioningOnlineMeter = metricRegistry.meter(name(getClass(), "provisioning_online" ));
private final Meter provisioningOfflineMeter = metricRegistry.meter(name(getClass(), "provisioning_offline"));
private final MessagesManager messagesManager;
private final PubSubManager pubSubManager;
public WebsocketSender(MessagesManager messagesManager, PubSubManager pubSubManager) {
this.messagesManager = messagesManager;
this.pubSubManager = pubSubManager;
} }
public void sendMessage(Account account, Device device, PendingMessage pendingMessage) { public DeliveryStatus sendMessage(Account account, Device device, Envelope message, Type channel) {
try { WebsocketAddress address = new WebsocketAddress(account.getNumber(), device.getId());
String serialized = mapper.writeValueAsString(pendingMessage); PubSubMessage pubSubMessage = PubSubMessage.newBuilder()
WebsocketAddress address = new WebsocketAddress(account.getNumber(), device.getId()); .setType(PubSubMessage.Type.DELIVER)
PubSubMessage pubSubMessage = new PubSubMessage(PubSubMessage.TYPE_DELIVER, serialized); .setContent(message.toByteString())
.build();
if (pubSubManager.publish(address, pubSubMessage)) { if (pubSubManager.publish(address, pubSubMessage)) {
onlineMeter.mark(); if (channel == Type.APN) apnOnlineMeter.mark();
} else { else if (channel == Type.GCM) gcmOnlineMeter.mark();
offlineMeter.mark(); else websocketOnlineMeter.mark();
storedMessages.insert(address, pendingMessage);
pubSubManager.publish(address, new PubSubMessage(PubSubMessage.TYPE_QUERY_DB, null)); return new DeliveryStatus(true, 0);
} } else {
} catch (JsonProcessingException e) { if (channel == Type.APN) apnOfflineMeter.mark();
logger.warn("WebsocketSender", "Unable to serialize json", e); else if (channel == Type.GCM) gcmOfflineMeter.mark();
else websocketOfflineMeter.mark();
int queueDepth = queueMessage(account, device, message);
return new DeliveryStatus(false, queueDepth);
}
}
public int queueMessage(Account account, Device device, Envelope message) {
websocketRequeueMeter.mark();
WebsocketAddress address = new WebsocketAddress(account.getNumber(), device.getId());
int queueDepth = messagesManager.insert(account.getNumber(), device.getId(), message);
pubSubManager.publish(address, PubSubMessage.newBuilder()
.setType(PubSubMessage.Type.QUERY_DB)
.build());
return queueDepth;
}
public boolean sendProvisioningMessage(ProvisioningAddress address, byte[] body) {
PubSubMessage pubSubMessage = PubSubMessage.newBuilder()
.setType(PubSubMessage.Type.DELIVER)
.setContent(ByteString.copyFrom(body))
.build();
if (pubSubManager.publish(address, pubSubMessage)) {
provisioningOnlineMeter.mark();
return true;
} else {
provisioningOfflineMeter.mark();
return false;
}
}
public static class DeliveryStatus {
private final boolean delivered;
private final int messageQueueDepth;
public DeliveryStatus(boolean delivered, int messageQueueDepth) {
this.delivered = delivered;
this.messageQueueDepth = messageQueueDepth;
}
public boolean isDelivered() {
return delivered;
}
public int getMessageQueueDepth() {
return messageQueueDepth;
} }
} }
} }

View File

@@ -1,91 +0,0 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.whispersystems.textsecuregcm.sms;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.NexmoConfiguration;
import org.whispersystems.textsecuregcm.util.Constants;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import static com.codahale.metrics.MetricRegistry.name;
public class NexmoSmsSender {
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter smsMeter = metricRegistry.meter(name(getClass(), "sms", "delivered"));
private final Meter voxMeter = metricRegistry.meter(name(getClass(), "vox", "delivered"));
private final Logger logger = LoggerFactory.getLogger(NexmoSmsSender.class);
private static final String NEXMO_SMS_URL =
"https://rest.nexmo.com/sms/json?api_key=%s&api_secret=%s&from=%s&to=%s&text=%s";
private static final String NEXMO_VOX_URL =
"https://rest.nexmo.com/tts/json?api_key=%s&api_secret=%s&to=%s&text=%s";
private final String apiKey;
private final String apiSecret;
private final String number;
public NexmoSmsSender(NexmoConfiguration config) {
this.apiKey = config.getApiKey();
this.apiSecret = config.getApiSecret();
this.number = config.getNumber();
}
public void deliverSmsVerification(String destination, String verificationCode) throws IOException {
URL url = new URL(String.format(NEXMO_SMS_URL, apiKey, apiSecret, number, destination,
URLEncoder.encode(SmsSender.SMS_VERIFICATION_TEXT + verificationCode, "UTF-8")));
URLConnection connection = url.openConnection();
connection.setDoInput(true);
connection.connect();
BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
while (reader.readLine() != null) {}
reader.close();
smsMeter.mark();
}
public void deliverVoxVerification(String destination, String message) throws IOException {
URL url = new URL(String.format(NEXMO_VOX_URL, apiKey, apiSecret, destination,
URLEncoder.encode(SmsSender.VOX_VERIFICATION_TEXT + message, "UTF-8")));
URLConnection connection = url.openConnection();
connection.setDoInput(true);
connection.connect();
BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
logger.debug(line);
}
reader.close();
voxMeter.mark();
}
}

View File

@@ -26,59 +26,41 @@ import java.io.IOException;
public class SmsSender { public class SmsSender {
static final String SMS_VERIFICATION_TEXT = "Your TextSecure verification code: "; static final String SMS_IOS_VERIFICATION_TEXT = "Your Signal verification code: %s\n\nOr tap: sgnl://verify/%s";
static final String VOX_VERIFICATION_TEXT = "Your TextSecure verification code is: "; static final String SMS_VERIFICATION_TEXT = "Your TextSecure verification code: %s";
static final String VOX_VERIFICATION_TEXT = "Your Signal verification code is: ";
private final Logger logger = LoggerFactory.getLogger(SmsSender.class); private final Logger logger = LoggerFactory.getLogger(SmsSender.class);
private final TwilioSmsSender twilioSender; private final TwilioSmsSender twilioSender;
private final Optional<NexmoSmsSender> nexmoSender;
private final boolean isTwilioInternational;
public SmsSender(TwilioSmsSender twilioSender, public SmsSender(TwilioSmsSender twilioSender)
Optional<NexmoSmsSender> nexmoSender,
boolean isTwilioInternational)
{ {
this.isTwilioInternational = isTwilioInternational; this.twilioSender = twilioSender;
this.twilioSender = twilioSender;
this.nexmoSender = nexmoSender;
} }
public void deliverSmsVerification(String destination, String verificationCode) public void deliverSmsVerification(String destination, Optional<String> clientType, String verificationCode)
throws IOException throws IOException
{ {
if (!isTwilioDestination(destination) && nexmoSender.isPresent()) { // Fix up mexico numbers to 'mobile' format just for SMS delivery.
nexmoSender.get().deliverSmsVerification(destination, verificationCode); if (destination.startsWith("+42") && !destination.startsWith("+421")) {
} else { destination = "+421" + destination.substring(3);
try { }
twilioSender.deliverSmsVerification(destination, verificationCode);
} catch (TwilioRestException e) { try {
logger.info("Twilio SMS Fallback", e); twilioSender.deliverSmsVerification(destination, clientType, verificationCode);
if (nexmoSender.isPresent()) { } catch (TwilioRestException e) {
nexmoSender.get().deliverSmsVerification(destination, verificationCode); logger.info("Twilio SMS Failed: " + e.getErrorMessage());
}
}
} }
} }
public void deliverVoxVerification(String destination, String verificationCode) public void deliverVoxVerification(String destination, String verificationCode)
throws IOException throws IOException
{ {
if (!isTwilioDestination(destination) && nexmoSender.isPresent()) { try {
nexmoSender.get().deliverVoxVerification(destination, verificationCode); twilioSender.deliverVoxVerification(destination, verificationCode);
} else { } catch (TwilioRestException e) {
try { logger.info("Twilio Vox Failed: " + e.getErrorMessage());
twilioSender.deliverVoxVerification(destination, verificationCode);
} catch (TwilioRestException e) {
logger.info("Twilio Vox Fallback", e);
if (nexmoSender.isPresent()) {
nexmoSender.get().deliverVoxVerification(destination, verificationCode);
}
}
} }
} }
private boolean isTwilioDestination(String number) {
return isTwilioInternational || number.length() == 12 && number.startsWith("+1");
}
} }

View File

@@ -19,6 +19,7 @@ package org.whispersystems.textsecuregcm.sms;
import com.codahale.metrics.Meter; import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries; import com.codahale.metrics.SharedMetricRegistries;
import com.google.common.base.Optional;
import com.twilio.sdk.TwilioRestClient; import com.twilio.sdk.TwilioRestClient;
import com.twilio.sdk.TwilioRestException; import com.twilio.sdk.TwilioRestException;
import com.twilio.sdk.resource.factory.CallFactory; import com.twilio.sdk.resource.factory.CallFactory;
@@ -29,10 +30,12 @@ import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.Constants;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Random;
import static com.codahale.metrics.MetricRegistry.name; import static com.codahale.metrics.MetricRegistry.name;
@@ -40,35 +43,42 @@ public class TwilioSmsSender {
public static final String SAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + public static final String SAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Response>\n" + "<Response>\n" +
" <Say voice=\"woman\" language=\"en\">" + SmsSender.VOX_VERIFICATION_TEXT + "%s</Say>\n" + " <Say voice=\"woman\" language=\"en\" loop=\"3\">" + SmsSender.VOX_VERIFICATION_TEXT + "%s.</Say>\n" +
"</Response>"; "</Response>";
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter smsMeter = metricRegistry.meter(name(getClass(), "sms", "delivered")); private final Meter smsMeter = metricRegistry.meter(name(getClass(), "sms", "delivered"));
private final Meter voxMeter = metricRegistry.meter(name(getClass(), "vox", "delivered")); private final Meter voxMeter = metricRegistry.meter(name(getClass(), "vox", "delivered"));
private final String accountId; private final String accountId;
private final String accountToken; private final String accountToken;
private final String number; private final ArrayList<String> numbers;
private final String localDomain; private final String localDomain;
private final Random random;
public TwilioSmsSender(TwilioConfiguration config) { public TwilioSmsSender(TwilioConfiguration config) {
this.accountId = config.getAccountId(); this.accountId = config.getAccountId();
this.accountToken = config.getAccountToken(); this.accountToken = config.getAccountToken();
this.number = config.getNumber(); this.numbers = new ArrayList<>(config.getNumbers());
this.localDomain = config.getLocalDomain(); this.localDomain = config.getLocalDomain();
this.random = new Random(System.currentTimeMillis());
} }
public void deliverSmsVerification(String destination, String verificationCode) public void deliverSmsVerification(String destination, Optional<String> clientType, String verificationCode)
throws IOException, TwilioRestException throws IOException, TwilioRestException
{ {
TwilioRestClient client = new TwilioRestClient(accountId, accountToken); TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
MessageFactory messageFactory = client.getAccount().getMessageFactory(); MessageFactory messageFactory = client.getAccount().getMessageFactory();
List<NameValuePair> messageParams = new LinkedList<>(); List<NameValuePair> messageParams = new LinkedList<>();
messageParams.add(new BasicNameValuePair("To", destination)); messageParams.add(new BasicNameValuePair("To", destination));
messageParams.add(new BasicNameValuePair("From", number)); messageParams.add(new BasicNameValuePair("From", getRandom(random, numbers)));
messageParams.add(new BasicNameValuePair("Body", SmsSender.SMS_VERIFICATION_TEXT + verificationCode));
if ("ios".equals(clientType.orNull())) {
messageParams.add(new BasicNameValuePair("Body", String.format(SmsSender.SMS_IOS_VERIFICATION_TEXT, verificationCode, verificationCode)));
} else {
messageParams.add(new BasicNameValuePair("Body", String.format(SmsSender.SMS_VERIFICATION_TEXT, verificationCode)));
}
try { try {
messageFactory.create(messageParams); messageFactory.create(messageParams);
} catch (RuntimeException damnYouTwilio) { } catch (RuntimeException damnYouTwilio) {
@@ -85,7 +95,7 @@ public class TwilioSmsSender {
CallFactory callFactory = client.getAccount().getCallFactory(); CallFactory callFactory = client.getAccount().getCallFactory();
Map<String, String> callParams = new HashMap<>(); Map<String, String> callParams = new HashMap<>();
callParams.put("To", destination); callParams.put("To", destination);
callParams.put("From", number); callParams.put("From", getRandom(random, numbers));
callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode); callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode);
try { try {
@@ -96,4 +106,9 @@ public class TwilioSmsSender {
voxMeter.mark(); voxMeter.mark();
} }
private String getRandom(Random random, ArrayList<String> elements) {
return elements.get(random.nextInt(elements.size()));
}
} }

View File

@@ -22,8 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import java.util.LinkedList; import java.util.HashSet;
import java.util.List; import java.util.Set;
public class Account { public class Account {
@@ -33,32 +33,28 @@ public class Account {
private String number; private String number;
@JsonProperty @JsonProperty
private boolean supportsSms; private Set<Device> devices = new HashSet<>();
@JsonProperty
private List<Device> devices = new LinkedList<>();
@JsonProperty @JsonProperty
private String identityKey; private String identityKey;
@JsonIgnore @JsonIgnore
private Optional<Device> authenticatedDevice; private Device authenticatedDevice;
public Account() {} public Account() {}
@VisibleForTesting @VisibleForTesting
public Account(String number, boolean supportsSms, List<Device> devices) { public Account(String number, Set<Device> devices) {
this.number = number; this.number = number;
this.supportsSms = supportsSms; this.devices = devices;
this.devices = devices;
} }
public Optional<Device> getAuthenticatedDevice() { public Optional<Device> getAuthenticatedDevice() {
return authenticatedDevice; return Optional.fromNullable(authenticatedDevice);
} }
public void setAuthenticatedDevice(Device device) { public void setAuthenticatedDevice(Device device) {
this.authenticatedDevice = Optional.of(device); this.authenticatedDevice = device;
} }
public void setNumber(String number) { public void setNumber(String number) {
@@ -69,23 +65,16 @@ public class Account {
return number; return number;
} }
public boolean getSupportsSms() {
return supportsSms;
}
public void setSupportsSms(boolean supportsSms) {
this.supportsSms = supportsSms;
}
public void addDevice(Device device) { public void addDevice(Device device) {
this.devices.remove(device);
this.devices.add(device); this.devices.add(device);
} }
public void setDevices(List<Device> devices) { public void removeDevice(long deviceId) {
this.devices = devices; this.devices.remove(new Device(deviceId, null, null, null, null, null, null, null, false, 0, null, 0, 0, false, "NA"));
} }
public List<Device> getDevices() { public Set<Device> getDevices() {
return devices; return devices;
} }
@@ -103,6 +92,16 @@ public class Account {
return Optional.absent(); return Optional.absent();
} }
public boolean isVoiceSupported() {
for (Device device : devices) {
if (device.isActive() && device.isVoiceSupported()) {
return true;
}
}
return false;
}
public boolean isActive() { public boolean isActive() {
return return
getMasterDevice().isPresent() && getMasterDevice().isPresent() &&
@@ -113,7 +112,9 @@ public class Account {
long highestDevice = Device.MASTER_ID; long highestDevice = Device.MASTER_ID;
for (Device device : devices) { for (Device device : devices) {
if (device.getId() > highestDevice) { if (!device.isActive()) {
return device.getId();
} else if (device.getId() > highestDevice) {
highestDevice = device.getId(); highestDevice = device.getId();
} }
} }
@@ -121,6 +122,16 @@ public class Account {
return highestDevice + 1; return highestDevice + 1;
} }
public int getActiveDeviceCount() {
int count = 0;
for (Device device : devices) {
if (device.isActive()) count++;
}
return count;
}
public boolean isRateLimited() { public boolean isRateLimited() {
return true; return true;
} }

View File

@@ -16,8 +16,6 @@
*/ */
package org.whispersystems.textsecuregcm.storage; package org.whispersystems.textsecuregcm.storage;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.skife.jdbi.v2.SQLStatement; import org.skife.jdbi.v2.SQLStatement;

View File

@@ -17,12 +17,9 @@
package org.whispersystems.textsecuregcm.storage; package org.whispersystems.textsecuregcm.storage;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import net.spy.memcached.MemcachedClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.ClientContact; import org.whispersystems.textsecuregcm.entities.ClientContact;
@@ -33,23 +30,26 @@ import java.io.IOException;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class AccountsManager { public class AccountsManager {
private final Logger logger = LoggerFactory.getLogger(AccountsManager.class); private final Logger logger = LoggerFactory.getLogger(AccountsManager.class);
private final Accounts accounts; private final Accounts accounts;
private final MemcachedClient memcachedClient; private final JedisPool cacheClient;
private final DirectoryManager directory; private final DirectoryManager directory;
private final ObjectMapper mapper; private final ObjectMapper mapper;
public AccountsManager(Accounts accounts, public AccountsManager(Accounts accounts,
DirectoryManager directory, DirectoryManager directory,
MemcachedClient memcachedClient) JedisPool cacheClient)
{ {
this.accounts = accounts; this.accounts = accounts;
this.directory = directory; this.directory = directory;
this.memcachedClient = memcachedClient; this.cacheClient = cacheClient;
this.mapper = SystemMapper.getMapper(); this.mapper = SystemMapper.getMapper();
} }
public long getCount() { public long getCount() {
@@ -100,7 +100,7 @@ public class AccountsManager {
private void updateDirectory(Account account) { private void updateDirectory(Account account) {
if (account.isActive()) { if (account.isActive()) {
byte[] token = Util.getContactToken(account.getNumber()); byte[] token = Util.getContactToken(account.getNumber());
ClientContact clientContact = new ClientContact(token, null, account.getSupportsSms()); ClientContact clientContact = new ClientContact(token, null, account.isVoiceSupported());
directory.add(clientContact); directory.add(clientContact);
} else { } else {
directory.remove(account.getNumber()); directory.remove(account.getNumber());
@@ -112,25 +112,19 @@ public class AccountsManager {
} }
private void memcacheSet(String number, Account account) { private void memcacheSet(String number, Account account) {
if (memcachedClient != null) { try (Jedis jedis = cacheClient.getResource()) {
try { jedis.set(getKey(number), mapper.writeValueAsString(account));
String json = mapper.writeValueAsString(account); } catch (JsonProcessingException e) {
memcachedClient.set(getKey(number), 0, json); throw new IllegalArgumentException(e);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
} }
} }
private Optional<Account> memcacheGet(String number) { private Optional<Account> memcacheGet(String number) {
if (memcachedClient == null) return Optional.absent(); try (Jedis jedis = cacheClient.getResource()) {
String json = jedis.get(getKey(number));
try {
String json = (String)memcachedClient.get(getKey(number));
if (json != null) return Optional.of(mapper.readValue(json, Account.class)); if (json != null) return Optional.of(mapper.readValue(json, Account.class));
else return Optional.absent(); else return Optional.absent();
} catch (IOException e) { } catch (IOException e) {
logger.warn("AccountsManager", "Deserialization error", e); logger.warn("AccountsManager", "Deserialization error", e);
return Optional.absent(); return Optional.absent();

View File

@@ -22,7 +22,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.entities.SignedPreKey; import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
import java.io.Serializable; import java.util.concurrent.TimeUnit;
public class Device { public class Device {
@@ -31,6 +31,9 @@ public class Device {
@JsonProperty @JsonProperty
private long id; private long id;
@JsonProperty
private String name;
@JsonProperty @JsonProperty
private String authToken; private String authToken;
@@ -46,6 +49,12 @@ public class Device {
@JsonProperty @JsonProperty
private String apnId; private String apnId;
@JsonProperty
private String voipApnId;
@JsonProperty
private long pushTimestamp;
@JsonProperty @JsonProperty
private boolean fetchesMessages; private boolean fetchesMessages;
@@ -55,22 +64,42 @@ public class Device {
@JsonProperty @JsonProperty
private SignedPreKey signedPreKey; private SignedPreKey signedPreKey;
@JsonProperty
private long lastSeen;
@JsonProperty
private long created;
@JsonProperty
private boolean voice;
@JsonProperty
private String userAgent;
public Device() {} public Device() {}
public Device(long id, String authToken, String salt, public Device(long id, String name, String authToken, String salt,
String signalingKey, String gcmId, String apnId, String signalingKey, String gcmId, String apnId,
boolean fetchesMessages, int registrationId, String voipApnId, boolean fetchesMessages,
SignedPreKey signedPreKey) int registrationId, SignedPreKey signedPreKey,
long lastSeen, long created, boolean voice,
String userAgent)
{ {
this.id = id; this.id = id;
this.name = name;
this.authToken = authToken; this.authToken = authToken;
this.salt = salt; this.salt = salt;
this.signalingKey = signalingKey; this.signalingKey = signalingKey;
this.gcmId = gcmId; this.gcmId = gcmId;
this.apnId = apnId; this.apnId = apnId;
this.voipApnId = voipApnId;
this.fetchesMessages = fetchesMessages; this.fetchesMessages = fetchesMessages;
this.registrationId = registrationId; this.registrationId = registrationId;
this.signedPreKey = signedPreKey; this.signedPreKey = signedPreKey;
this.lastSeen = lastSeen;
this.created = created;
this.voice = voice;
this.userAgent = userAgent;
} }
public String getApnId() { public String getApnId() {
@@ -79,6 +108,34 @@ public class Device {
public void setApnId(String apnId) { public void setApnId(String apnId) {
this.apnId = apnId; this.apnId = apnId;
if (apnId != null) {
this.pushTimestamp = System.currentTimeMillis();
}
}
public String getVoipApnId() {
return voipApnId;
}
public void setVoipApnId(String voipApnId) {
this.voipApnId = voipApnId;
}
public void setLastSeen(long lastSeen) {
this.lastSeen = lastSeen;
}
public long getLastSeen() {
return lastSeen;
}
public void setCreated(long created) {
this.created = created;
}
public long getCreated() {
return this.created;
} }
public String getGcmId() { public String getGcmId() {
@@ -87,6 +144,10 @@ public class Device {
public void setGcmId(String gcmId) { public void setGcmId(String gcmId) {
this.gcmId = gcmId; this.gcmId = gcmId;
if (gcmId != null) {
this.pushTimestamp = System.currentTimeMillis();
}
} }
public long getId() { public long getId() {
@@ -97,6 +158,22 @@ public class Device {
this.id = id; this.id = id;
} }
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public boolean isVoiceSupported() {
return voice;
}
public void setVoiceSupported(boolean voice) {
this.voice = voice;
}
public void setAuthenticationCredentials(AuthenticationCredentials credentials) { public void setAuthenticationCredentials(AuthenticationCredentials credentials) {
this.authToken = credentials.getHashedAuthenticationToken(); this.authToken = credentials.getHashedAuthenticationToken();
this.salt = credentials.getSalt(); this.salt = credentials.getSalt();
@@ -115,7 +192,10 @@ public class Device {
} }
public boolean isActive() { public boolean isActive() {
return fetchesMessages || !Util.isEmpty(getApnId()) || !Util.isEmpty(getGcmId()); boolean hasChannel = fetchesMessages || !Util.isEmpty(getApnId()) || !Util.isEmpty(getGcmId());
return (id == MASTER_ID && hasChannel) ||
(id != MASTER_ID && hasChannel && signedPreKey != null && lastSeen > (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)));
} }
public boolean getFetchesMessages() { public boolean getFetchesMessages() {
@@ -145,4 +225,29 @@ public class Device {
public void setSignedPreKey(SignedPreKey signedPreKey) { public void setSignedPreKey(SignedPreKey signedPreKey) {
this.signedPreKey = signedPreKey; this.signedPreKey = signedPreKey;
} }
public long getPushTimestamp() {
return pushTimestamp;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public String getUserAgent() {
return this.userAgent;
}
@Override
public boolean equals(Object other) {
if (other == null || !(other instanceof Device)) return false;
Device that = (Device)other;
return this.id == that.id;
}
@Override
public int hashCode() {
return (int)this.id;
}
} }

View File

@@ -16,14 +16,19 @@
*/ */
package org.whispersystems.textsecuregcm.storage; package org.whispersystems.textsecuregcm.storage;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import com.google.gson.Gson; import org.slf4j.Logger;
import com.google.gson.annotations.SerializedName; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.ClientContact; import org.whispersystems.textsecuregcm.entities.ClientContact;
import org.whispersystems.textsecuregcm.util.IterablePair; import org.whispersystems.textsecuregcm.util.IterablePair;
import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
import java.io.IOException;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@@ -34,12 +39,17 @@ import redis.clients.jedis.Response;
public class DirectoryManager { public class DirectoryManager {
private final Logger logger = LoggerFactory.getLogger(DirectoryManager.class);
private static final byte[] DIRECTORY_KEY = {'d', 'i', 'r', 'e', 'c', 't', 'o', 'r', 'y'}; private static final byte[] DIRECTORY_KEY = {'d', 'i', 'r', 'e', 'c', 't', 'o', 'r', 'y'};
private final ObjectMapper objectMapper;
private final JedisPool redisPool; private final JedisPool redisPool;
public DirectoryManager(JedisPool redisPool) { public DirectoryManager(JedisPool redisPool) {
this.redisPool = redisPool; this.redisPool = redisPool;
this.objectMapper = new ObjectMapper();
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
} }
public void remove(String number) { public void remove(String number) {
@@ -51,9 +61,9 @@ public class DirectoryManager {
} }
public void remove(byte[] token) { public void remove(byte[] token) {
Jedis jedis = redisPool.getResource(); try (Jedis jedis = redisPool.getResource()) {
jedis.hdel(DIRECTORY_KEY, token); jedis.hdel(DIRECTORY_KEY, token);
redisPool.returnResource(jedis); }
} }
public void remove(BatchOperationHandle handle, byte[] token) { public void remove(BatchOperationHandle handle, byte[] token) {
@@ -62,46 +72,49 @@ public class DirectoryManager {
} }
public void add(ClientContact contact) { public void add(ClientContact contact) {
TokenValue tokenValue = new TokenValue(contact.getRelay(), contact.isSupportsSms()); TokenValue tokenValue = new TokenValue(contact.getRelay(), contact.isVoice());
Jedis jedis = redisPool.getResource();
jedis.hset(DIRECTORY_KEY, contact.getToken(), new Gson().toJson(tokenValue).getBytes()); try (Jedis jedis = redisPool.getResource()) {
redisPool.returnResource(jedis); jedis.hset(DIRECTORY_KEY, contact.getToken(), objectMapper.writeValueAsBytes(tokenValue));
} catch (JsonProcessingException e) {
logger.warn("JSON Serialization", e);
}
} }
public void add(BatchOperationHandle handle, ClientContact contact) { public void add(BatchOperationHandle handle, ClientContact contact) {
Pipeline pipeline = handle.pipeline; try {
TokenValue tokenValue = new TokenValue(contact.getRelay(), contact.isSupportsSms()); Pipeline pipeline = handle.pipeline;
TokenValue tokenValue = new TokenValue(contact.getRelay(), contact.isVoice());
pipeline.hset(DIRECTORY_KEY, contact.getToken(), new Gson().toJson(tokenValue).getBytes()); pipeline.hset(DIRECTORY_KEY, contact.getToken(), objectMapper.writeValueAsBytes(tokenValue));
} catch (JsonProcessingException e) {
logger.warn("JSON Serialization", e);
}
} }
public PendingClientContact get(BatchOperationHandle handle, byte[] token) { public PendingClientContact get(BatchOperationHandle handle, byte[] token) {
Pipeline pipeline = handle.pipeline; Pipeline pipeline = handle.pipeline;
return new PendingClientContact(token, pipeline.hget(DIRECTORY_KEY, token)); return new PendingClientContact(objectMapper, token, pipeline.hget(DIRECTORY_KEY, token));
} }
public Optional<ClientContact> get(byte[] token) { public Optional<ClientContact> get(byte[] token) {
Jedis jedis = redisPool.getResource(); try (Jedis jedis = redisPool.getResource()) {
try {
byte[] result = jedis.hget(DIRECTORY_KEY, token); byte[] result = jedis.hget(DIRECTORY_KEY, token);
if (result == null) { if (result == null) {
return Optional.absent(); return Optional.absent();
} }
TokenValue tokenValue = new Gson().fromJson(new String(result), TokenValue.class); TokenValue tokenValue = objectMapper.readValue(result, TokenValue.class);
return Optional.of(new ClientContact(token, tokenValue.relay, tokenValue.supportsSms)); return Optional.of(new ClientContact(token, tokenValue.relay, tokenValue.voice));
} finally { } catch (IOException e) {
redisPool.returnResource(jedis); logger.warn("JSON Error", e);
return Optional.absent();
} }
} }
public List<ClientContact> get(List<byte[]> tokens) { public List<ClientContact> get(List<byte[]> tokens) {
Jedis jedis = redisPool.getResource(); try (Jedis jedis = redisPool.getResource()) {
try {
Pipeline pipeline = jedis.pipelined(); Pipeline pipeline = jedis.pipelined();
List<Response<byte[]>> futures = new LinkedList<>(); List<Response<byte[]>> futures = new LinkedList<>();
List<ClientContact> results = new LinkedList<>(); List<ClientContact> results = new LinkedList<>();
@@ -117,17 +130,19 @@ public class DirectoryManager {
IterablePair<byte[], Response<byte[]>> lists = new IterablePair<>(tokens, futures); IterablePair<byte[], Response<byte[]>> lists = new IterablePair<>(tokens, futures);
for (Pair<byte[], Response<byte[]>> pair : lists) { for (Pair<byte[], Response<byte[]>> pair : lists) {
if (pair.second().get() != null) { try {
TokenValue tokenValue = new Gson().fromJson(new String(pair.second().get()), TokenValue.class); if (pair.second().get() != null) {
ClientContact clientContact = new ClientContact(pair.first(), tokenValue.relay, tokenValue.supportsSms); TokenValue tokenValue = objectMapper.readValue(pair.second().get(), TokenValue.class);
ClientContact clientContact = new ClientContact(pair.first(), tokenValue.relay, tokenValue.voice);
results.add(clientContact); results.add(clientContact);
}
} catch (IOException e) {
logger.warn("Deserialization Problem: ", e);
} }
} }
return results; return results;
} finally {
redisPool.returnResource(jedis);
} }
} }
@@ -156,36 +171,41 @@ public class DirectoryManager {
} }
private static class TokenValue { private static class TokenValue {
@SerializedName("r")
@JsonProperty(value = "r")
private String relay; private String relay;
@SerializedName("s") @JsonProperty(value = "v")
private boolean supportsSms; private boolean voice;
public TokenValue(String relay, boolean supportsSms) { public TokenValue() {}
this.relay = relay;
this.supportsSms = supportsSms; public TokenValue(String relay, boolean voice) {
this.relay = relay;
this.voice = voice;
} }
} }
public static class PendingClientContact { public static class PendingClientContact {
private final ObjectMapper objectMapper;
private final byte[] token; private final byte[] token;
private final Response<byte[]> response; private final Response<byte[]> response;
PendingClientContact(byte[] token, Response<byte[]> response) { PendingClientContact(ObjectMapper objectMapper, byte[] token, Response<byte[]> response) {
this.token = token; this.objectMapper = objectMapper;
this.response = response; this.token = token;
this.response = response;
} }
public Optional<ClientContact> get() { public Optional<ClientContact> get() throws IOException {
byte[] result = response.get(); byte[] result = response.get();
if (result == null) { if (result == null) {
return Optional.absent(); return Optional.absent();
} }
TokenValue tokenValue = new Gson().fromJson(new String(result), TokenValue.class); TokenValue tokenValue = objectMapper.readValue(result, TokenValue.class);
return Optional.of(new ClientContact(token, tokenValue.relay, tokenValue.supportsSms)); return Optional.of(new ClientContact(token, tokenValue.relay, tokenValue.voice));
} }
} }

View File

@@ -0,0 +1,127 @@
package org.whispersystems.textsecuregcm.storage;
import org.skife.jdbi.v2.SQLStatement;
import org.skife.jdbi.v2.StatementContext;
import org.skife.jdbi.v2.sqlobject.Bind;
import org.skife.jdbi.v2.sqlobject.Binder;
import org.skife.jdbi.v2.sqlobject.BinderFactory;
import org.skife.jdbi.v2.sqlobject.BindingAnnotation;
import org.skife.jdbi.v2.sqlobject.SqlQuery;
import org.skife.jdbi.v2.sqlobject.SqlUpdate;
import org.skife.jdbi.v2.sqlobject.customizers.Mapper;
import org.skife.jdbi.v2.tweak.ResultSetMapper;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import java.lang.annotation.Annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
public abstract class Messages {
public static final int RESULT_SET_CHUNK_SIZE = 100;
private static final String ID = "id";
private static final String TYPE = "type";
private static final String RELAY = "relay";
private static final String TIMESTAMP = "timestamp";
private static final String SOURCE = "source";
private static final String SOURCE_DEVICE = "source_device";
private static final String DESTINATION = "destination";
private static final String DESTINATION_DEVICE = "destination_device";
private static final String MESSAGE = "message";
private static final String CONTENT = "content";
@SqlQuery("INSERT INTO messages (" + TYPE + ", " + RELAY + ", " + TIMESTAMP + ", " + SOURCE + ", " + SOURCE_DEVICE + ", " + DESTINATION + ", " + DESTINATION_DEVICE + ", " + MESSAGE + ", " + CONTENT + ") " +
"VALUES (:type, :relay, :timestamp, :source, :source_device, :destination, :destination_device, :message, :content) " +
"RETURNING (SELECT COUNT(id) FROM messages WHERE " + DESTINATION + " = :destination AND " + DESTINATION_DEVICE + " = :destination_device AND " + TYPE + " != " + Envelope.Type.RECEIPT_VALUE + ")")
abstract int store(@MessageBinder Envelope message,
@Bind("destination") String destination,
@Bind("destination_device") long destinationDevice);
@Mapper(MessageMapper.class)
@SqlQuery("SELECT * FROM messages WHERE " + DESTINATION + " = :destination AND " + DESTINATION_DEVICE + " = :destination_device ORDER BY " + TIMESTAMP + " ASC LIMIT " + RESULT_SET_CHUNK_SIZE)
abstract List<OutgoingMessageEntity> load(@Bind("destination") String destination,
@Bind("destination_device") long destinationDevice);
@Mapper(MessageMapper.class)
@SqlQuery("DELETE FROM messages WHERE " + ID + " IN (SELECT " + ID + " FROM messages WHERE " + DESTINATION + " = :destination AND " + DESTINATION_DEVICE + " = :destination_device AND " + SOURCE + " = :source AND " + TIMESTAMP + " = :timestamp ORDER BY " + ID + " LIMIT 1) RETURNING *")
abstract OutgoingMessageEntity remove(@Bind("destination") String destination,
@Bind("destination_device") long destinationDevice,
@Bind("source") String source,
@Bind("timestamp") long timestamp);
@Mapper(MessageMapper.class)
@SqlUpdate("DELETE FROM messages WHERE " + ID + " = :id AND " + DESTINATION + " = :destination")
abstract void remove(@Bind("destination") String destination, @Bind("id") long id);
@SqlUpdate("DELETE FROM messages WHERE " + DESTINATION + " = :destination")
abstract void clear(@Bind("destination") String destination);
@SqlUpdate("DELETE FROM messages WHERE " + DESTINATION + " = :destination AND " + DESTINATION_DEVICE + " = :destination_device")
abstract void clear(@Bind("destination") String destination, @Bind("destination_device") long destinationDevice);
@SqlUpdate("DELETE FROM messages WHERE " + TIMESTAMP + " < :timestamp")
public abstract void removeOld(@Bind("timestamp") long timestamp);
@SqlUpdate("VACUUM messages")
public abstract void vacuum();
public static class MessageMapper implements ResultSetMapper<OutgoingMessageEntity> {
@Override
public OutgoingMessageEntity map(int i, ResultSet resultSet, StatementContext statementContext)
throws SQLException
{
int type = resultSet.getInt(TYPE);
byte[] legacyMessage = resultSet.getBytes(MESSAGE);
if (type == Envelope.Type.RECEIPT_VALUE && legacyMessage == null) {
/// XXX - REMOVE AFTER 10/01/15
legacyMessage = new byte[0];
}
return new OutgoingMessageEntity(resultSet.getLong(ID),
type,
resultSet.getString(RELAY),
resultSet.getLong(TIMESTAMP),
resultSet.getString(SOURCE),
resultSet.getInt(SOURCE_DEVICE),
legacyMessage,
resultSet.getBytes(CONTENT));
}
}
@BindingAnnotation(MessageBinder.AccountBinderFactory.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
public @interface MessageBinder {
public static class AccountBinderFactory implements BinderFactory {
@Override
public Binder build(Annotation annotation) {
return new Binder<MessageBinder, Envelope>() {
@Override
public void bind(SQLStatement<?> sql,
MessageBinder accountBinder,
Envelope message)
{
sql.bind(TYPE, message.getType().getNumber());
sql.bind(RELAY, message.getRelay());
sql.bind(TIMESTAMP, message.getTimestamp());
sql.bind(SOURCE, message.getSource());
sql.bind(SOURCE_DEVICE, message.getSourceDevice());
sql.bind(MESSAGE, message.hasLegacyMessage() ? message.getLegacyMessage().toByteArray() : null);
sql.bind(CONTENT, message.hasContent() ? message.getContent().toByteArray() : null);
}
};
}
}
}
}

View File

@@ -0,0 +1,44 @@
package org.whispersystems.textsecuregcm.storage;
import com.google.common.base.Optional;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import java.util.List;
public class MessagesManager {
private final Messages messages;
public MessagesManager(Messages messages) {
this.messages = messages;
}
public int insert(String destination, long destinationDevice, Envelope message) {
return this.messages.store(message, destination, destinationDevice) + 1;
}
public OutgoingMessageEntityList getMessagesForDevice(String destination, long destinationDevice) {
List<OutgoingMessageEntity> messages = this.messages.load(destination, destinationDevice);
return new OutgoingMessageEntityList(messages, messages.size() >= Messages.RESULT_SET_CHUNK_SIZE);
}
public void clear(String destination) {
this.messages.clear(destination);
}
public void clear(String destination, long deviceId) {
this.messages.clear(destination, deviceId);
}
public Optional<OutgoingMessageEntity> delete(String destination, long destinationDevice, String source, long timestamp)
{
return Optional.fromNullable(this.messages.remove(destination, destinationDevice, source, timestamp));
}
public void delete(String destination, long id) {
this.messages.remove(destination, id);
}
}

Some files were not shown because too many files have changed in this diff Show More