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.
- data/README +5 -9
- data/Rakefile +11 -9
- data/conf/config.rb +30 -4
- data/lib/vines/cluster/connection.rb +26 -0
- data/lib/vines/cluster/publisher.rb +55 -0
- data/lib/vines/cluster/pubsub.rb +92 -0
- data/lib/vines/cluster/sessions.rb +125 -0
- data/lib/vines/cluster/subscriber.rb +108 -0
- data/lib/vines/cluster.rb +246 -0
- data/lib/vines/command/init.rb +21 -24
- data/lib/vines/config/host.rb +48 -8
- data/lib/vines/config/port.rb +5 -0
- data/lib/vines/config/pubsub.rb +108 -0
- data/lib/vines/config.rb +74 -20
- data/lib/vines/jid.rb +14 -0
- data/lib/vines/router.rb +69 -55
- data/lib/vines/stanza/iq/disco_info.rb +22 -9
- data/lib/vines/stanza/iq/disco_items.rb +6 -3
- data/lib/vines/stanza/iq/ping.rb +1 -1
- data/lib/vines/stanza/iq/private_storage.rb +4 -8
- data/lib/vines/stanza/iq/roster.rb +6 -14
- data/lib/vines/stanza/iq/session.rb +2 -7
- data/lib/vines/stanza/iq/vcard.rb +4 -6
- data/lib/vines/stanza/iq/version.rb +1 -1
- data/lib/vines/stanza/iq.rb +8 -10
- data/lib/vines/stanza/presence/subscribe.rb +3 -11
- data/lib/vines/stanza/presence/subscribed.rb +16 -29
- data/lib/vines/stanza/presence/unsubscribe.rb +3 -15
- data/lib/vines/stanza/presence/unsubscribed.rb +3 -16
- data/lib/vines/stanza/presence.rb +30 -0
- data/lib/vines/stanza/pubsub/create.rb +39 -0
- data/lib/vines/stanza/pubsub/delete.rb +41 -0
- data/lib/vines/stanza/pubsub/publish.rb +66 -0
- data/lib/vines/stanza/pubsub/subscribe.rb +44 -0
- data/lib/vines/stanza/pubsub/unsubscribe.rb +30 -0
- data/lib/vines/stanza/pubsub.rb +22 -0
- data/lib/vines/stanza.rb +72 -22
- data/lib/vines/storage/couchdb.rb +46 -65
- data/lib/vines/storage/local.rb +20 -14
- data/lib/vines/storage/mongodb.rb +132 -0
- data/lib/vines/storage/null.rb +39 -0
- data/lib/vines/storage/redis.rb +61 -68
- data/lib/vines/storage/sql.rb +73 -69
- data/lib/vines/storage.rb +1 -1
- data/lib/vines/stream/client/bind.rb +2 -2
- data/lib/vines/stream/client/session.rb +71 -16
- data/lib/vines/stream/component/handshake.rb +1 -0
- data/lib/vines/stream/component/ready.rb +2 -2
- data/lib/vines/stream/http/session.rb +2 -0
- data/lib/vines/stream/http.rb +0 -6
- data/lib/vines/stream/server/final_restart.rb +1 -0
- data/lib/vines/stream/server/outbound/final_features.rb +1 -0
- data/lib/vines/stream/server/ready.rb +6 -2
- data/lib/vines/stream/server.rb +4 -3
- data/lib/vines/stream.rb +10 -6
- data/lib/vines/version.rb +1 -1
- data/lib/vines.rb +48 -22
- data/test/cluster/publisher_test.rb +45 -0
- data/test/cluster/sessions_test.rb +54 -0
- data/test/cluster/subscriber_test.rb +94 -0
- data/test/config/host_test.rb +100 -21
- data/test/config/pubsub_test.rb +181 -0
- data/test/config_test.rb +225 -43
- data/test/jid_test.rb +7 -0
- data/test/router_test.rb +181 -9
- data/test/stanza/iq/disco_info_test.rb +8 -6
- data/test/stanza/iq/disco_items_test.rb +3 -3
- data/test/stanza/iq/private_storage_test.rb +8 -19
- data/test/stanza/iq/roster_test.rb +1 -1
- data/test/stanza/iq/session_test.rb +3 -6
- data/test/stanza/iq/vcard_test.rb +6 -2
- data/test/stanza/iq/version_test.rb +3 -2
- data/test/stanza/iq_test.rb +5 -5
- data/test/stanza/message_test.rb +3 -2
- data/test/stanza/presence/probe_test.rb +2 -1
- data/test/stanza/pubsub/create_test.rb +138 -0
- data/test/stanza/pubsub/delete_test.rb +142 -0
- data/test/stanza/pubsub/publish_test.rb +373 -0
- data/test/stanza/pubsub/subscribe_test.rb +186 -0
- data/test/stanza/pubsub/unsubscribe_test.rb +179 -0
- data/test/stanza_test.rb +2 -1
- data/test/storage/local_test.rb +26 -25
- data/test/storage/mock_mongo.rb +40 -0
- data/test/storage/mock_redis.rb +98 -0
- data/test/storage/mongodb_test.rb +81 -0
- data/test/storage/null_test.rb +30 -0
- data/test/storage/redis_test.rb +3 -36
- data/test/stream/component/handshake_test.rb +4 -0
- data/test/stream/component/ready_test.rb +2 -1
- data/test/stream/server/ready_test.rb +7 -1
- data/web/404.html +5 -3
- data/web/chat/coffeescripts/chat.coffee +9 -5
- data/web/chat/javascripts/app.js +1 -1
- data/web/chat/javascripts/chat.js +14 -8
- data/web/chat/stylesheets/chat.css +4 -1
- data/web/lib/coffeescripts/button.coffee +9 -5
- data/web/lib/coffeescripts/filter.coffee +1 -1
- data/web/lib/coffeescripts/login.coffee +14 -1
- data/web/lib/coffeescripts/session.coffee +8 -11
- data/web/lib/images/dark-gray.png +0 -0
- data/web/lib/images/light-gray.png +0 -0
- data/web/lib/images/logo-large.png +0 -0
- data/web/lib/images/logo-small.png +0 -0
- data/web/lib/images/white.png +0 -0
- data/web/lib/javascripts/base.js +9 -8
- data/web/lib/javascripts/button.js +20 -12
- data/web/lib/javascripts/filter.js +1 -1
- data/web/lib/javascripts/icons.js +7 -1
- data/web/lib/javascripts/jquery.js +4 -4
- data/web/lib/javascripts/login.js +16 -2
- data/web/lib/javascripts/raphael.js +5 -7
- data/web/lib/javascripts/session.js +10 -14
- data/web/lib/stylesheets/base.css +7 -11
- data/web/lib/stylesheets/login.css +31 -27
- 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.
|
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
|
-
|
25
|
-
|
26
|
-
|
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
|
21
|
-
implementation that you provide. LDAP authentication can be used
|
22
|
-
and passwords aren't stored in the chat database. SSL encryption
|
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.
|
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
|
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.
|
36
|
-
s.add_dependency "em-
|
37
|
-
s.add_dependency "eventmachine", "
|
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
|
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
|
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
|
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
|
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
|