vines 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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