vines 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. data/README +5 -9
  2. data/Rakefile +11 -9
  3. data/conf/config.rb +30 -4
  4. data/lib/vines/cluster/connection.rb +26 -0
  5. data/lib/vines/cluster/publisher.rb +55 -0
  6. data/lib/vines/cluster/pubsub.rb +92 -0
  7. data/lib/vines/cluster/sessions.rb +125 -0
  8. data/lib/vines/cluster/subscriber.rb +108 -0
  9. data/lib/vines/cluster.rb +246 -0
  10. data/lib/vines/command/init.rb +21 -24
  11. data/lib/vines/config/host.rb +48 -8
  12. data/lib/vines/config/port.rb +5 -0
  13. data/lib/vines/config/pubsub.rb +108 -0
  14. data/lib/vines/config.rb +74 -20
  15. data/lib/vines/jid.rb +14 -0
  16. data/lib/vines/router.rb +69 -55
  17. data/lib/vines/stanza/iq/disco_info.rb +22 -9
  18. data/lib/vines/stanza/iq/disco_items.rb +6 -3
  19. data/lib/vines/stanza/iq/ping.rb +1 -1
  20. data/lib/vines/stanza/iq/private_storage.rb +4 -8
  21. data/lib/vines/stanza/iq/roster.rb +6 -14
  22. data/lib/vines/stanza/iq/session.rb +2 -7
  23. data/lib/vines/stanza/iq/vcard.rb +4 -6
  24. data/lib/vines/stanza/iq/version.rb +1 -1
  25. data/lib/vines/stanza/iq.rb +8 -10
  26. data/lib/vines/stanza/presence/subscribe.rb +3 -11
  27. data/lib/vines/stanza/presence/subscribed.rb +16 -29
  28. data/lib/vines/stanza/presence/unsubscribe.rb +3 -15
  29. data/lib/vines/stanza/presence/unsubscribed.rb +3 -16
  30. data/lib/vines/stanza/presence.rb +30 -0
  31. data/lib/vines/stanza/pubsub/create.rb +39 -0
  32. data/lib/vines/stanza/pubsub/delete.rb +41 -0
  33. data/lib/vines/stanza/pubsub/publish.rb +66 -0
  34. data/lib/vines/stanza/pubsub/subscribe.rb +44 -0
  35. data/lib/vines/stanza/pubsub/unsubscribe.rb +30 -0
  36. data/lib/vines/stanza/pubsub.rb +22 -0
  37. data/lib/vines/stanza.rb +72 -22
  38. data/lib/vines/storage/couchdb.rb +46 -65
  39. data/lib/vines/storage/local.rb +20 -14
  40. data/lib/vines/storage/mongodb.rb +132 -0
  41. data/lib/vines/storage/null.rb +39 -0
  42. data/lib/vines/storage/redis.rb +61 -68
  43. data/lib/vines/storage/sql.rb +73 -69
  44. data/lib/vines/storage.rb +1 -1
  45. data/lib/vines/stream/client/bind.rb +2 -2
  46. data/lib/vines/stream/client/session.rb +71 -16
  47. data/lib/vines/stream/component/handshake.rb +1 -0
  48. data/lib/vines/stream/component/ready.rb +2 -2
  49. data/lib/vines/stream/http/session.rb +2 -0
  50. data/lib/vines/stream/http.rb +0 -6
  51. data/lib/vines/stream/server/final_restart.rb +1 -0
  52. data/lib/vines/stream/server/outbound/final_features.rb +1 -0
  53. data/lib/vines/stream/server/ready.rb +6 -2
  54. data/lib/vines/stream/server.rb +4 -3
  55. data/lib/vines/stream.rb +10 -6
  56. data/lib/vines/version.rb +1 -1
  57. data/lib/vines.rb +48 -22
  58. data/test/cluster/publisher_test.rb +45 -0
  59. data/test/cluster/sessions_test.rb +54 -0
  60. data/test/cluster/subscriber_test.rb +94 -0
  61. data/test/config/host_test.rb +100 -21
  62. data/test/config/pubsub_test.rb +181 -0
  63. data/test/config_test.rb +225 -43
  64. data/test/jid_test.rb +7 -0
  65. data/test/router_test.rb +181 -9
  66. data/test/stanza/iq/disco_info_test.rb +8 -6
  67. data/test/stanza/iq/disco_items_test.rb +3 -3
  68. data/test/stanza/iq/private_storage_test.rb +8 -19
  69. data/test/stanza/iq/roster_test.rb +1 -1
  70. data/test/stanza/iq/session_test.rb +3 -6
  71. data/test/stanza/iq/vcard_test.rb +6 -2
  72. data/test/stanza/iq/version_test.rb +3 -2
  73. data/test/stanza/iq_test.rb +5 -5
  74. data/test/stanza/message_test.rb +3 -2
  75. data/test/stanza/presence/probe_test.rb +2 -1
  76. data/test/stanza/pubsub/create_test.rb +138 -0
  77. data/test/stanza/pubsub/delete_test.rb +142 -0
  78. data/test/stanza/pubsub/publish_test.rb +373 -0
  79. data/test/stanza/pubsub/subscribe_test.rb +186 -0
  80. data/test/stanza/pubsub/unsubscribe_test.rb +179 -0
  81. data/test/stanza_test.rb +2 -1
  82. data/test/storage/local_test.rb +26 -25
  83. data/test/storage/mock_mongo.rb +40 -0
  84. data/test/storage/mock_redis.rb +98 -0
  85. data/test/storage/mongodb_test.rb +81 -0
  86. data/test/storage/null_test.rb +30 -0
  87. data/test/storage/redis_test.rb +3 -36
  88. data/test/stream/component/handshake_test.rb +4 -0
  89. data/test/stream/component/ready_test.rb +2 -1
  90. data/test/stream/server/ready_test.rb +7 -1
  91. data/web/404.html +5 -3
  92. data/web/chat/coffeescripts/chat.coffee +9 -5
  93. data/web/chat/javascripts/app.js +1 -1
  94. data/web/chat/javascripts/chat.js +14 -8
  95. data/web/chat/stylesheets/chat.css +4 -1
  96. data/web/lib/coffeescripts/button.coffee +9 -5
  97. data/web/lib/coffeescripts/filter.coffee +1 -1
  98. data/web/lib/coffeescripts/login.coffee +14 -1
  99. data/web/lib/coffeescripts/session.coffee +8 -11
  100. data/web/lib/images/dark-gray.png +0 -0
  101. data/web/lib/images/light-gray.png +0 -0
  102. data/web/lib/images/logo-large.png +0 -0
  103. data/web/lib/images/logo-small.png +0 -0
  104. data/web/lib/images/white.png +0 -0
  105. data/web/lib/javascripts/base.js +9 -8
  106. data/web/lib/javascripts/button.js +20 -12
  107. data/web/lib/javascripts/filter.js +1 -1
  108. data/web/lib/javascripts/icons.js +7 -1
  109. data/web/lib/javascripts/jquery.js +4 -4
  110. data/web/lib/javascripts/login.js +16 -2
  111. data/web/lib/javascripts/raphael.js +5 -7
  112. data/web/lib/javascripts/session.js +10 -14
  113. data/web/lib/stylesheets/base.css +7 -11
  114. data/web/lib/stylesheets/login.css +31 -27
  115. metadata +100 -34
data/README CHANGED
@@ -6,10 +6,10 @@ CouchDB, Redis, the file system, or a custom storage implementation that you pro
6
6
  LDAP authentication can be used so user names and passwords aren't stored in the chat
7
7
  database. SSL encryption is mandatory on all client and server connections.
8
8
 
9
- The Vines XMPP server includes a web chat client. The web application is available
9
+ The Vines XMPP server includes a web chat client. The web application is available
10
10
  immediately after starting the chat server at http://localhost:5280/chat/.
11
11
 
12
- Additional documentation can be found at www.getvines.com.
12
+ Additional documentation can be found at www.getvines.org.
13
13
 
14
14
  == Usage
15
15
 
@@ -21,13 +21,9 @@ Additional documentation can be found at www.getvines.com.
21
21
 
22
22
  == Dependencies
23
23
 
24
- * bcrypt-ruby >= 3.0.1
25
- * eventmachine >= 0.12.10
26
- * nokogiri >= 1.4.7
27
- * ruby >= 1.9.2
28
-
29
- == Ubuntu setup
30
- $ sudo apt-get install build-essential ruby1.9.1 ruby1.9.1-dev libxml2-dev libxslt-dev
24
+ Vines requires Ruby 1.9.2 or better. Instructions for installing the
25
+ needed OS packages, as well as Ruby itself, are available at
26
+ http://www.getvines.org/ruby.
31
27
 
32
28
  == Contact
33
29
 
data/Rakefile CHANGED
@@ -17,25 +17,27 @@ spec = Gem::Specification.new do |s|
17
17
  s.summary = "Vines is an XMPP chat server that's easy to install and run."
18
18
  s.description = "Vines is an XMPP chat server that supports thousands of
19
19
  simultaneous connections by using EventMachine for asynchronous IO. User data
20
- is stored in a SQL database, CouchDB, Redis, the file system, or a custom storage
21
- implementation that you provide. LDAP authentication can be used so user names
22
- and passwords aren't stored in the chat database. SSL encryption is mandatory on
23
- all client and server connections."
20
+ is stored in a SQL database, CouchDB, MongoDB, Redis, the file system, or a
21
+ custom storage implementation that you provide. LDAP authentication can be used
22
+ so user names and passwords aren't stored in the chat database. SSL encryption
23
+ is mandatory on all client and server connections."
24
24
 
25
25
  s.authors = ["David Graham"]
26
26
  s.email = %w[david@negativecode.com]
27
- s.homepage = "http://www.getvines.com"
27
+ s.homepage = "http://www.getvines.org"
28
28
 
29
29
  s.test_files = FileList["test/**/*"]
30
30
  s.executables = %w[vines]
31
31
  s.require_path = "lib"
32
32
 
33
- s.add_dependency "activerecord", "~> 3.1.0"
33
+ s.add_dependency "activerecord", "~> 3.2.1"
34
34
  s.add_dependency "bcrypt-ruby", "~> 3.0.1"
35
- s.add_dependency "em-http-request", "~> 0.3.0"
36
- s.add_dependency "em-redis", "~> 0.3.0"
37
- s.add_dependency "eventmachine", "~> 0.12.10"
35
+ s.add_dependency "em-http-request", "~> 1.0.1"
36
+ s.add_dependency "em-hiredis", "~> 0.1.0"
37
+ s.add_dependency "eventmachine", ">= 0.12.10"
38
38
  s.add_dependency "http_parser.rb", "~> 0.5.3"
39
+ s.add_dependency "mongo", "~> 1.5.2"
40
+ s.add_dependency "bson_ext", "~> 1.5.2"
39
41
  s.add_dependency "net-ldap", "~> 0.2.2"
40
42
  s.add_dependency "nokogiri", "~> 1.4.7"
41
43
 
data/conf/config.rb CHANGED
@@ -19,25 +19,32 @@ Vines::Config.configure do
19
19
  # The private_storage attribute allows clients to store XML fragments
20
20
  # on the server, using the XEP-0049 Private XML Storage feature.
21
21
  #
22
+ # The pubsub attribute defines the XEP-0060 Publish-Subscribe services hosted
23
+ # at these virtual host domains. In the example below, pubsub services are
24
+ # available at games.wonderland.lit and scores.wonderland.lit as well as
25
+ # games.verona.lit and scores.verona.lit.
26
+ #
22
27
  # Shared storage example:
23
28
  # host 'verona.lit', 'wonderland.lit' do
24
29
  # private_storage false
25
30
  # cross_domain_messages false
26
31
  # storage 'fs' do
27
- # dir 'data/users'
32
+ # dir 'data'
28
33
  # end
29
34
  # components 'tea' => 'secr3t',
30
35
  # 'cake' => 'passw0rd'
36
+ # pubsub 'games', 'scores'
31
37
  # end
32
38
 
33
39
  host 'wonderland.lit' do
34
40
  cross_domain_messages false
35
41
  private_storage false
36
42
  storage 'fs' do
37
- dir 'data/users'
43
+ dir 'data'
38
44
  end
39
45
  # components 'tea' => 'secr3t',
40
46
  # 'cake' => 'passw0rd'
47
+ # pubsub 'games', 'scores'
41
48
  end
42
49
 
43
50
  # Hosts can use LDAP authentication that overrides the authentication
@@ -49,7 +56,7 @@ Vines::Config.configure do
49
56
  # cross_domain_messages false
50
57
  # private_storage false
51
58
  # storage 'fs' do
52
- # dir 'data/users'
59
+ # dir 'data'
53
60
  # end
54
61
  # ldap 'ldap.wonderland.lit', 636 do
55
62
  # dn 'cn=Directory Manager'
@@ -98,12 +105,21 @@ Vines::Config.configure do
98
105
  component '0.0.0.0', 5347 do
99
106
  max_stanza_size 131072
100
107
  end
108
+
109
+ # Configure the redis connection used to form a cluster of server instances,
110
+ # serving the same chat domains across many different machines.
111
+ #cluster do
112
+ # host 'redis.wonderland.lit'
113
+ # port 6379
114
+ # database 0
115
+ # password ''
116
+ #end
101
117
  end
102
118
 
103
119
  # Available storage implementations:
104
120
 
105
121
  #storage 'fs' do
106
- # dir 'data/users'
122
+ # dir 'data'
107
123
  #end
108
124
 
109
125
  #storage 'couchdb' do
@@ -115,6 +131,16 @@ end
115
131
  # password ''
116
132
  #end
117
133
 
134
+ #storage 'mongodb' do
135
+ # host 'localhost', 27017
136
+ # host 'localhost', 27018 # optional, connects to replica set
137
+ # database 'xmpp'
138
+ # tls true
139
+ # username ''
140
+ # password ''
141
+ # pool 5
142
+ #end
143
+
118
144
  #storage 'redis' do
119
145
  # host 'localhost'
120
146
  # port 6379
@@ -0,0 +1,26 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Cluster
5
+ # Create and cache a redis database connection.
6
+ class Connection
7
+ attr_accessor :host, :port, :database, :password
8
+
9
+ def initialize
10
+ @redis = nil
11
+ end
12
+
13
+ # Return a shared redis connection.
14
+ def connect
15
+ @redis ||= create
16
+ end
17
+
18
+ # Return a new redis connection.
19
+ def create
20
+ conn = EM::Hiredis::Client.new(@host, @port, @password, @database)
21
+ conn.connect
22
+ conn
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Cluster
5
+ # Broadcast messages to other cluster nodes via redis pubsub channels. All
6
+ # members subscribe to a channel for heartbeats, online, and offline
7
+ # messages from other nodes. This allows new nodes to be added to the
8
+ # cluster dynamically, without configuring all other nodes.
9
+ class Publisher
10
+ include Vines::Log
11
+
12
+ ALL, STANZA, USER = %w[cluster:nodes:all stanza user].map {|s| s.freeze }
13
+
14
+ def initialize(cluster)
15
+ @cluster = cluster
16
+ end
17
+
18
+ # Publish a :heartbeat, :online, or :offline message to the nodes:all
19
+ # broadcast channel.
20
+ def broadcast(type)
21
+ redis.publish(ALL, {
22
+ from: @cluster.id,
23
+ type: type,
24
+ time: Time.now.to_i
25
+ }.to_json)
26
+ end
27
+
28
+ # Send the stanza to the node hosting the user's session. The stanza is
29
+ # published to the channel to which the remote node is listening for
30
+ # messages.
31
+ def route(stanza, node)
32
+ log.debug { "Sent cluster stanza: %s -> %s\n%s\n" % [@cluster.id, node, stanza] }
33
+ redis.publish("cluster:nodes:#{node}", {
34
+ from: @cluster.id,
35
+ type: STANZA,
36
+ stanza: stanza.to_s
37
+ }.to_json)
38
+ end
39
+
40
+ # Notify the remote node that the user's roster has changed and it should
41
+ # reload the user from storage.
42
+ def update_user(jid, node)
43
+ redis.publish("cluster:nodes:#{node}", {
44
+ from: @cluster.id,
45
+ type: USER,
46
+ jid: jid.to_s
47
+ }.to_json)
48
+ end
49
+
50
+ def redis
51
+ @cluster.connection
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,92 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Cluster
5
+ # Manages the pubsub topic list and subscribers stored in redis. When a
6
+ # message is published to a topic, the receiving cluster node broadcasts
7
+ # the message to all subscribers at all other cluster nodes.
8
+ class PubSub
9
+ def initialize(cluster)
10
+ @cluster = cluster
11
+ end
12
+
13
+ # Create a pubsub topic (a.k.a. node), in the given domain, to which
14
+ # messages may be published. The domain argument will be one of the
15
+ # configured pubsub subdomains in conf/config.rb (e.g. games.wonderland.lit,
16
+ # topics.wonderland.lit, etc).
17
+ def add_node(domain, node)
18
+ redis.sadd("pubsub:#{domain}:nodes", node)
19
+ end
20
+
21
+ # Remove a pubsub topic so messages may no longer be broadcast to it.
22
+ def delete_node(domain, node)
23
+ redis.smembers("pubsub:#{domain}:subscribers_#{node}") do |subscribers|
24
+ redis.multi
25
+ subscribers.each do |jid|
26
+ redis.srem("pubsub:#{domain}:subscriptions_#{jid}", node)
27
+ end
28
+ redis.del("pubsub:#{domain}:subscribers_#{node}")
29
+ redis.srem("pubsub:#{domain}:nodes", node)
30
+ redis.exec
31
+ end
32
+ end
33
+
34
+ # Subscribe the JID to the pubsub topic so it will receive any messages
35
+ # published to it.
36
+ def subscribe(domain, node, jid)
37
+ jid = JID.new(jid)
38
+ redis.multi
39
+ redis.sadd("pubsub:#{domain}:subscribers_#{node}", jid.to_s)
40
+ redis.sadd("pubsub:#{domain}:subscriptions_#{jid}", node)
41
+ redis.exec
42
+ end
43
+
44
+ # Unsubscribe the JID from the pubsub topic, deregistering its interest
45
+ # in receiving any messages published to it.
46
+ def unsubscribe(domain, node, jid)
47
+ jid = JID.new(jid)
48
+ redis.multi
49
+ redis.srem("pubsub:#{domain}:subscribers_#{node}", jid.to_s)
50
+ redis.srem("pubsub:#{domain}:subscriptions_#{jid}", node)
51
+ redis.exec
52
+ redis.scard("pubsub:#{domain}:subscribers_#{node}") do |count|
53
+ delete_node(domain, node) if count == 0
54
+ end
55
+ end
56
+
57
+ # Unsubscribe the JID from all pubsub topics. This is useful when the
58
+ # JID's session ends by logout or disconnect.
59
+ def unsubscribe_all(domain, jid)
60
+ jid = JID.new(jid)
61
+ redis.smembers("pubsub:#{domain}:subscriptions_#{jid}") do |nodes|
62
+ nodes.each do |node|
63
+ unsubscribe(domain, node, jid)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Return true if the pubsub topic exists and messages may be published to it.
69
+ def node?(domain, node)
70
+ @cluster.query(:sismember, "pubsub:#{domain}:nodes", node) == 1
71
+ end
72
+
73
+ # Return true if the JID is a registered subscriber to the pubsub topic and
74
+ # messages published to it should be routed to the JID.
75
+ def subscribed?(domain, node, jid)
76
+ jid = JID.new(jid)
77
+ @cluster.query(:sismember, "pubsub:#{domain}:subscribers_#{node}", jid.to_s) == 1
78
+ end
79
+
80
+ # Return a list of JIDs subscribed to the pubsub topic.
81
+ def subscribers(domain, node)
82
+ @cluster.query(:smembers, "pubsub:#{domain}:subscribers_#{node}")
83
+ end
84
+
85
+ private
86
+
87
+ def redis
88
+ @cluster.connection
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,125 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Cluster
5
+ # Manages the cluster node list and user session routing table stored in
6
+ # redis. All cluster nodes share this in-memory database to quickly discover
7
+ # the node hosting a particular user session. Once a session is located,
8
+ # stanzas can be routed to that node via the +Publisher+.
9
+ class Sessions
10
+ include Vines::Log
11
+
12
+ NODES = 'cluster:nodes'.freeze
13
+
14
+ def initialize(cluster)
15
+ @cluster, @nodes = cluster, {}
16
+ end
17
+
18
+ # Return the sessions for these JIDs. If a bare JID is used, all sessions
19
+ # for that user will be returned. If a full JID is used, the session for
20
+ # that single connected stream is returned.
21
+ def find(*jids)
22
+ jids.flatten.map do |jid|
23
+ jid = JID.new(jid)
24
+ jid.bare? ? user_sessions(jid) : user_session(jid)
25
+ end.compact.flatten
26
+ end
27
+
28
+ # Persist the user's session to the shared redis cache so that other cluster
29
+ # nodes can locate the node hosting this user's connection and route messages
30
+ # to them.
31
+ def save(jid, attrs)
32
+ jid = JID.new(jid)
33
+ session = {node: @cluster.id}.merge(attrs)
34
+ redis.multi
35
+ redis.hset("sessions:#{jid.bare}", jid.resource, session.to_json)
36
+ redis.sadd("cluster:nodes:#{@cluster.id}", jid.to_s)
37
+ redis.exec
38
+ end
39
+
40
+ # Remove this user from the cluster routing table so that no further stanzas
41
+ # may be routed to them. This must be called when the user's session is
42
+ # terminated, either by logout or stream disconnect.
43
+ def delete(jid)
44
+ jid = JID.new(jid)
45
+ redis.hget("sessions:#{jid.bare}", jid.resource) do |response|
46
+ if doc = JSON.parse(response) rescue nil
47
+ redis.multi
48
+ redis.hdel("sessions:#{jid.bare}", jid.resource)
49
+ redis.srem("cluster:nodes:#{doc['node']}", jid.to_s)
50
+ redis.exec
51
+ end
52
+ end
53
+ end
54
+
55
+ # Remove all user sessions from the routing table associated with the
56
+ # given node ID. Cluster nodes call this themselves during normal shutdown.
57
+ # However, if a node dies without being properly shutdown, the other nodes
58
+ # will cleanup its sessions when they detect the node is offline.
59
+ def delete_all(node)
60
+ @nodes.delete(node)
61
+ redis.smembers("cluster:nodes:#{node}") do |jids|
62
+ redis.multi
63
+ redis.del("cluster:nodes:#{node}")
64
+ redis.hdel(NODES, node)
65
+ jids.each do |jid|
66
+ jid = JID.new(jid)
67
+ redis.hdel("sessions:#{jid.bare}", jid.resource)
68
+ end
69
+ redis.exec
70
+ end
71
+ end
72
+
73
+ # Cluster nodes broadcast a heartbeat to other members every second. If we
74
+ # haven't heard from a node in five seconds, assume it's offline and cleanup
75
+ # its session cache for it. Nodes may die abrubtly, without a chance to clear
76
+ # their sessions, so other members cleanup for them.
77
+ def expire
78
+ redis.hset(NODES, @cluster.id, Time.now.to_i)
79
+ redis.hgetall(NODES) do |response|
80
+ now = Time.now
81
+ expired = Hash[*response].select do |node, active|
82
+ offset = @nodes[node] || 0
83
+ (now - offset) - Time.at(active.to_i) > 5
84
+ end.keys
85
+ expired.each {|node| delete_all(node) }
86
+ end
87
+ end
88
+
89
+ # Notify the session store that this node is still alive. The node
90
+ # broadcasts its current time, so all cluster members' clocks don't
91
+ # necessarily need to be in sync.
92
+ def poke(node, time)
93
+ offset = Time.now.to_i - time
94
+ @nodes[node] = offset
95
+ end
96
+
97
+ private
98
+
99
+ # Return all remote sessions for this user's bare JID.
100
+ def user_sessions(jid)
101
+ response = @cluster.query(:hgetall, "sessions:#{jid.bare}") || []
102
+ Hash[*response].map do |resource, json|
103
+ if session = JSON.parse(json) rescue nil
104
+ session['jid'] = JID.new(jid.node, jid.domain, resource).to_s
105
+ end
106
+ session
107
+ end.compact.reject {|session| session['node'] == @cluster.id }
108
+ end
109
+
110
+ # Return the remote session for this full JID or nil if not found.
111
+ def user_session(jid)
112
+ response = @cluster.query(:hget, "sessions:#{jid.bare}", jid.resource)
113
+ return unless response
114
+ session = JSON.parse(response) rescue nil
115
+ return if session.nil? || session['node'] == @cluster.id
116
+ session['jid'] = jid.to_s
117
+ session
118
+ end
119
+
120
+ def redis
121
+ @cluster.connection
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,108 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Cluster
5
+ # Subscribes to the redis nodes:all broadcast channel to listen for
6
+ # heartbeats from other cluster members. Also subscribes to a channel
7
+ # exclusively for this particular node, listening for stanzas routed to us
8
+ # from other nodes.
9
+ class Subscriber
10
+ include Vines::Log
11
+
12
+ ALL, FROM, HEARTBEAT, OFFLINE, ONLINE, STANZA, TIME, TO, TYPE, USER =
13
+ %w[cluster:nodes:all from heartbeat offline online stanza time to type user].map {|s| s.freeze }
14
+
15
+ def initialize(cluster)
16
+ @cluster = cluster
17
+ @channel = "cluster:nodes:#{@cluster.id}"
18
+ @messages = EM::Queue.new
19
+ process_messages
20
+ end
21
+
22
+ # Create a new redis connection and subscribe to the nodes:all broadcast
23
+ # channel as well as the channel for this cluster node. Redis connections
24
+ # in subscribe mode cannot be used for other key/value operations.
25
+ def subscribe
26
+ conn = @cluster.connect
27
+ conn.subscribe(ALL)
28
+ conn.subscribe(@channel)
29
+ conn.on(:message) do |channel, message|
30
+ @messages.push([channel, message])
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Recursively process incoming messages from the queue, guaranteeing they
37
+ # are processed in the order they are received.
38
+ def process_messages
39
+ @messages.pop do |channel, message|
40
+ Fiber.new do
41
+ on_message(channel, message)
42
+ process_messages
43
+ end.resume
44
+ end
45
+ end
46
+
47
+ # Process messages as they arrive on the pubsub channels to which we're
48
+ # subscribed.
49
+ def on_message(channel, message)
50
+ doc = JSON.parse(message)
51
+ case channel
52
+ when ALL then to_all(doc)
53
+ when @channel then to_node(doc)
54
+ end
55
+ rescue Exception => e
56
+ log.error("Cluster subscription message failed: #{e}")
57
+ end
58
+
59
+ # Process a message sent to the nodes:all broadcast channel. In the case
60
+ # of node heartbeats, we update the last time we heard from this node so
61
+ # we can cleanup its session if it goes offline.
62
+ def to_all(message)
63
+ case message[TYPE]
64
+ when ONLINE, HEARTBEAT
65
+ @cluster.poke(message[FROM], message[TIME])
66
+ when OFFLINE
67
+ @cluster.delete_sessions(message[FROM])
68
+ end
69
+ end
70
+
71
+ # Process a message published to this node's channel. Messages sent to
72
+ # this channel are stanzas that need to be routed to connections attached
73
+ # to this node.
74
+ def to_node(message)
75
+ case message[TYPE]
76
+ when STANZA then route_stanza(message)
77
+ when USER then update_user(message)
78
+ end
79
+ end
80
+
81
+ # Send the stanza, from a remote cluster node, to locally connected
82
+ # streams for the destination user.
83
+ def route_stanza(message)
84
+ node = Nokogiri::XML(message[STANZA]).root rescue nil
85
+ return unless node
86
+ log.debug { "Received cluster stanza: %s -> %s\n%s\n" % [message[FROM], @cluster.id, node] }
87
+ if node[TO]
88
+ @cluster.connected_resources(node[TO]).each do |recipient|
89
+ recipient.write(node)
90
+ end
91
+ else
92
+ log.warn("Cluster stanza missing address:\n#{node}")
93
+ end
94
+ end
95
+
96
+ # Update the roster information, that's cached in locally connected
97
+ # streams, for this user.
98
+ def update_user(message)
99
+ jid = JID.new(message['jid']).bare
100
+ if user = @cluster.storage(jid.domain).find_user(jid)
101
+ @cluster.connected_resources(jid).each do |stream|
102
+ stream.user.update_from(user)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end