lygneo-vines 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (174) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE +19 -0
  4. data/README.md +7 -0
  5. data/Rakefile +23 -0
  6. data/bin/vines +4 -0
  7. data/conf/certs/README +39 -0
  8. data/conf/certs/ca-bundle.crt +3895 -0
  9. data/conf/config.rb +42 -0
  10. data/lib/vines/cli.rb +132 -0
  11. data/lib/vines/cluster/connection.rb +26 -0
  12. data/lib/vines/cluster/publisher.rb +55 -0
  13. data/lib/vines/cluster/pubsub.rb +92 -0
  14. data/lib/vines/cluster/sessions.rb +125 -0
  15. data/lib/vines/cluster/subscriber.rb +108 -0
  16. data/lib/vines/cluster.rb +246 -0
  17. data/lib/vines/command/bcrypt.rb +12 -0
  18. data/lib/vines/command/cert.rb +50 -0
  19. data/lib/vines/command/init.rb +68 -0
  20. data/lib/vines/command/ldap.rb +38 -0
  21. data/lib/vines/command/restart.rb +12 -0
  22. data/lib/vines/command/schema.rb +24 -0
  23. data/lib/vines/command/start.rb +28 -0
  24. data/lib/vines/command/stop.rb +18 -0
  25. data/lib/vines/config/host.rb +125 -0
  26. data/lib/vines/config/port.rb +132 -0
  27. data/lib/vines/config/pubsub.rb +108 -0
  28. data/lib/vines/config.rb +223 -0
  29. data/lib/vines/daemon.rb +78 -0
  30. data/lib/vines/error.rb +150 -0
  31. data/lib/vines/follower.rb +111 -0
  32. data/lib/vines/jid.rb +95 -0
  33. data/lib/vines/kit.rb +23 -0
  34. data/lib/vines/log.rb +24 -0
  35. data/lib/vines/router.rb +179 -0
  36. data/lib/vines/stanza/iq/auth.rb +18 -0
  37. data/lib/vines/stanza/iq/disco_info.rb +45 -0
  38. data/lib/vines/stanza/iq/disco_items.rb +29 -0
  39. data/lib/vines/stanza/iq/error.rb +16 -0
  40. data/lib/vines/stanza/iq/ping.rb +16 -0
  41. data/lib/vines/stanza/iq/private_storage.rb +83 -0
  42. data/lib/vines/stanza/iq/query.rb +10 -0
  43. data/lib/vines/stanza/iq/result.rb +16 -0
  44. data/lib/vines/stanza/iq/roster.rb +140 -0
  45. data/lib/vines/stanza/iq/session.rb +17 -0
  46. data/lib/vines/stanza/iq/vcard.rb +56 -0
  47. data/lib/vines/stanza/iq/version.rb +25 -0
  48. data/lib/vines/stanza/iq.rb +48 -0
  49. data/lib/vines/stanza/message.rb +40 -0
  50. data/lib/vines/stanza/presence/error.rb +23 -0
  51. data/lib/vines/stanza/presence/probe.rb +37 -0
  52. data/lib/vines/stanza/presence/subscribe.rb +42 -0
  53. data/lib/vines/stanza/presence/subscribed.rb +51 -0
  54. data/lib/vines/stanza/presence/unavailable.rb +15 -0
  55. data/lib/vines/stanza/presence/unsubscribe.rb +38 -0
  56. data/lib/vines/stanza/presence/unsubscribed.rb +38 -0
  57. data/lib/vines/stanza/presence.rb +141 -0
  58. data/lib/vines/stanza/pubsub/create.rb +39 -0
  59. data/lib/vines/stanza/pubsub/delete.rb +41 -0
  60. data/lib/vines/stanza/pubsub/publish.rb +66 -0
  61. data/lib/vines/stanza/pubsub/subscribe.rb +44 -0
  62. data/lib/vines/stanza/pubsub/unsubscribe.rb +30 -0
  63. data/lib/vines/stanza/pubsub.rb +22 -0
  64. data/lib/vines/stanza.rb +175 -0
  65. data/lib/vines/storage/ldap.rb +71 -0
  66. data/lib/vines/storage/local.rb +139 -0
  67. data/lib/vines/storage/null.rb +39 -0
  68. data/lib/vines/storage/sql.rb +138 -0
  69. data/lib/vines/storage.rb +239 -0
  70. data/lib/vines/store.rb +110 -0
  71. data/lib/vines/stream/client/auth.rb +74 -0
  72. data/lib/vines/stream/client/auth_restart.rb +29 -0
  73. data/lib/vines/stream/client/bind.rb +72 -0
  74. data/lib/vines/stream/client/bind_restart.rb +24 -0
  75. data/lib/vines/stream/client/closed.rb +13 -0
  76. data/lib/vines/stream/client/ready.rb +17 -0
  77. data/lib/vines/stream/client/session.rb +210 -0
  78. data/lib/vines/stream/client/start.rb +27 -0
  79. data/lib/vines/stream/client/tls.rb +38 -0
  80. data/lib/vines/stream/client.rb +84 -0
  81. data/lib/vines/stream/component/handshake.rb +26 -0
  82. data/lib/vines/stream/component/ready.rb +23 -0
  83. data/lib/vines/stream/component/start.rb +19 -0
  84. data/lib/vines/stream/component.rb +58 -0
  85. data/lib/vines/stream/http/auth.rb +22 -0
  86. data/lib/vines/stream/http/bind.rb +32 -0
  87. data/lib/vines/stream/http/bind_restart.rb +37 -0
  88. data/lib/vines/stream/http/ready.rb +29 -0
  89. data/lib/vines/stream/http/request.rb +172 -0
  90. data/lib/vines/stream/http/session.rb +120 -0
  91. data/lib/vines/stream/http/sessions.rb +65 -0
  92. data/lib/vines/stream/http/start.rb +23 -0
  93. data/lib/vines/stream/http.rb +157 -0
  94. data/lib/vines/stream/parser.rb +79 -0
  95. data/lib/vines/stream/sasl.rb +128 -0
  96. data/lib/vines/stream/server/auth.rb +13 -0
  97. data/lib/vines/stream/server/auth_restart.rb +13 -0
  98. data/lib/vines/stream/server/final_restart.rb +21 -0
  99. data/lib/vines/stream/server/outbound/auth.rb +31 -0
  100. data/lib/vines/stream/server/outbound/auth_restart.rb +20 -0
  101. data/lib/vines/stream/server/outbound/auth_result.rb +32 -0
  102. data/lib/vines/stream/server/outbound/final_features.rb +28 -0
  103. data/lib/vines/stream/server/outbound/final_restart.rb +20 -0
  104. data/lib/vines/stream/server/outbound/start.rb +20 -0
  105. data/lib/vines/stream/server/outbound/tls.rb +30 -0
  106. data/lib/vines/stream/server/outbound/tls_result.rb +34 -0
  107. data/lib/vines/stream/server/ready.rb +24 -0
  108. data/lib/vines/stream/server/start.rb +13 -0
  109. data/lib/vines/stream/server/tls.rb +13 -0
  110. data/lib/vines/stream/server.rb +150 -0
  111. data/lib/vines/stream/state.rb +60 -0
  112. data/lib/vines/stream.rb +247 -0
  113. data/lib/vines/token_bucket.rb +55 -0
  114. data/lib/vines/user.rb +123 -0
  115. data/lib/vines/version.rb +6 -0
  116. data/lib/vines/xmpp_server.rb +25 -0
  117. data/lib/vines.rb +203 -0
  118. data/test/cluster/publisher_test.rb +57 -0
  119. data/test/cluster/sessions_test.rb +47 -0
  120. data/test/cluster/subscriber_test.rb +109 -0
  121. data/test/config/host_test.rb +369 -0
  122. data/test/config/pubsub_test.rb +187 -0
  123. data/test/config_test.rb +732 -0
  124. data/test/error_test.rb +58 -0
  125. data/test/ext/nokogiri.rb +14 -0
  126. data/test/follower_test.rb +102 -0
  127. data/test/jid_test.rb +147 -0
  128. data/test/kit_test.rb +31 -0
  129. data/test/router_test.rb +243 -0
  130. data/test/stanza/iq/disco_info_test.rb +78 -0
  131. data/test/stanza/iq/disco_items_test.rb +49 -0
  132. data/test/stanza/iq/private_storage_test.rb +184 -0
  133. data/test/stanza/iq/roster_test.rb +229 -0
  134. data/test/stanza/iq/session_test.rb +25 -0
  135. data/test/stanza/iq/vcard_test.rb +146 -0
  136. data/test/stanza/iq/version_test.rb +64 -0
  137. data/test/stanza/iq_test.rb +70 -0
  138. data/test/stanza/message_test.rb +126 -0
  139. data/test/stanza/presence/probe_test.rb +50 -0
  140. data/test/stanza/presence/subscribe_test.rb +83 -0
  141. data/test/stanza/pubsub/create_test.rb +116 -0
  142. data/test/stanza/pubsub/delete_test.rb +169 -0
  143. data/test/stanza/pubsub/publish_test.rb +309 -0
  144. data/test/stanza/pubsub/subscribe_test.rb +205 -0
  145. data/test/stanza/pubsub/unsubscribe_test.rb +148 -0
  146. data/test/stanza_test.rb +85 -0
  147. data/test/storage/ldap_test.rb +201 -0
  148. data/test/storage/local_test.rb +59 -0
  149. data/test/storage/mock_redis.rb +97 -0
  150. data/test/storage/null_test.rb +29 -0
  151. data/test/storage/storage_tests.rb +182 -0
  152. data/test/storage_test.rb +85 -0
  153. data/test/store_test.rb +130 -0
  154. data/test/stream/client/auth_test.rb +137 -0
  155. data/test/stream/client/ready_test.rb +47 -0
  156. data/test/stream/client/session_test.rb +27 -0
  157. data/test/stream/component/handshake_test.rb +52 -0
  158. data/test/stream/component/ready_test.rb +103 -0
  159. data/test/stream/component/start_test.rb +39 -0
  160. data/test/stream/http/auth_test.rb +70 -0
  161. data/test/stream/http/ready_test.rb +86 -0
  162. data/test/stream/http/request_test.rb +209 -0
  163. data/test/stream/http/sessions_test.rb +49 -0
  164. data/test/stream/http/start_test.rb +50 -0
  165. data/test/stream/parser_test.rb +122 -0
  166. data/test/stream/sasl_test.rb +195 -0
  167. data/test/stream/server/auth_test.rb +61 -0
  168. data/test/stream/server/outbound/auth_test.rb +75 -0
  169. data/test/stream/server/ready_test.rb +98 -0
  170. data/test/test_helper.rb +42 -0
  171. data/test/token_bucket_test.rb +44 -0
  172. data/test/user_test.rb +96 -0
  173. data/vines.gemspec +30 -0
  174. metadata +387 -0
data/conf/config.rb ADDED
@@ -0,0 +1,42 @@
1
+ Vines::Config.configure do
2
+ # Set the logging level to debug, info, warn, error, or fatal. The debug
3
+ # level logs all XML sent and received by the server.
4
+ log :info
5
+
6
+ # Set the directory in which to look for virtual hosts' TLS certificates.
7
+ # This is optional and defaults to the conf/certs directory created during
8
+ # `vines init`.
9
+ #certs 'config/vines'
10
+
11
+ # Setup a pepper to generate the encrypted password.
12
+ pepper "065eb8798b181ff0ea2c5c16aee0ff8b70e04e2ee6bd6e08b49da46924223e39127d5335e466207d42bf2a045c12be5f90e92012a4f05f7fc6d9f3c875f4c95b"
13
+
14
+ host 'lygneo' do
15
+ storage 'sql'
16
+ end
17
+
18
+ # Configure the client-to-server port. The max_resources_per_account attribute
19
+ # limits how many concurrent connections one user can have to the server.
20
+ client '0.0.0.0', 5222 do
21
+ max_stanza_size 65536
22
+ max_resources_per_account 5
23
+ end
24
+
25
+ # Configure the server-to-server port. The max_stanza_size attribute should be
26
+ # much larger than the setting for client-to-server.
27
+ server '0.0.0.0', 5269 do
28
+ max_stanza_size 131072
29
+ hosts []
30
+ end
31
+
32
+ # Configure the built-in HTTP server that serves static files and responds to
33
+ # XEP-0124 BOSH requests. This allows HTTP clients to connect to
34
+ # the XMPP server.
35
+ http '0.0.0.0', 5280 do
36
+ bind '/xmpp'
37
+ max_stanza_size 65536
38
+ max_resources_per_account 5
39
+ root 'public'
40
+ vroute ''
41
+ end
42
+ end
data/lib/vines/cli.rb ADDED
@@ -0,0 +1,132 @@
1
+ module Vines
2
+ # The command line application that's invoked by the `vines` binary included
3
+ # in the gem. Parses the command line arguments to create a new server
4
+ # directory, and starts and stops the server.
5
+ class CLI
6
+ COMMANDS = %w[start stop restart init bcrypt cert ldap schema]
7
+
8
+ def self.start
9
+ self.new.start
10
+ end
11
+
12
+ # Run the command line application to parse arguments and run sub-commands.
13
+ # Exits the process with a non-zero return code to indicate failure.
14
+ #
15
+ # Returns nothing.
16
+ def start
17
+ register_storage
18
+ opts = parse(ARGV)
19
+ check_config(opts)
20
+ command = Command.const_get(opts[:command].capitalize).new
21
+ begin
22
+ command.run(opts)
23
+ rescue SystemExit
24
+ # do nothing
25
+ rescue Exception => e
26
+ puts e.message
27
+ exit(1)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Try to load various storage backends provided by vines-* gems and register
34
+ # them with the storage system for the config file to use.
35
+ #
36
+ # Returns nothing.
37
+ def register_storage
38
+ %w[couchdb mongodb redis sql].each do |backend|
39
+ begin
40
+ require 'vines/storage/%s' % backend
41
+ rescue LoadError
42
+ # do nothing
43
+ end
44
+ end
45
+ end
46
+
47
+ # Parse the command line arguments and run the matching sub-command
48
+ # (e.g. init, start, stop, etc).
49
+ #
50
+ # args - The ARGV array provided by the command line.
51
+ #
52
+ # Returns nothing.
53
+ def parse(args)
54
+ options = {}
55
+ parser = OptionParser.new do |opts|
56
+ opts.banner = "Usage: vines [options] #{COMMANDS.join('|')}"
57
+
58
+ opts.separator ""
59
+ opts.separator "Daemon options:"
60
+
61
+ opts.on('-d', '--daemonize', 'Run daemonized in the background') do |daemonize|
62
+ options[:daemonize] = daemonize
63
+ end
64
+
65
+ options[:log] = 'log/vines.log'
66
+ opts.on('-l', '--log FILE', 'File to redirect output (default: log/vines.log)') do |log|
67
+ options[:log] = log
68
+ end
69
+
70
+ options[:pid] = 'pid/vines.pid'
71
+ opts.on('-P', '--pid FILE', 'File to store PID (default: pid/vines.pid)') do |pid|
72
+ options[:pid] = pid
73
+ end
74
+
75
+ opts.separator ""
76
+ opts.separator "Common options:"
77
+
78
+ opts.on('-h', '--help', 'Show this message') do |help|
79
+ options[:help] = help
80
+ end
81
+
82
+ opts.on('-v', '--version', 'Show version') do |version|
83
+ options[:version] = version
84
+ end
85
+ end
86
+
87
+ begin
88
+ parser.parse!(args)
89
+ rescue
90
+ puts parser
91
+ exit(1)
92
+ end
93
+
94
+ if options[:version]
95
+ puts Vines::VERSION
96
+ exit
97
+ end
98
+
99
+ if options[:help]
100
+ puts parser
101
+ exit
102
+ end
103
+
104
+ command = args.shift
105
+ unless COMMANDS.include?(command)
106
+ puts parser
107
+ exit(1)
108
+ end
109
+
110
+ options.tap do |opts|
111
+ opts[:args] = args
112
+ opts[:command] = command
113
+ opts[:config] = File.expand_path("#{Dir.pwd}/config/vines.rb") || File.expand_path("conf/config.rb")
114
+ opts[:pid] = File.expand_path(opts[:pid])
115
+ opts[:log] = File.expand_path(opts[:log])
116
+ end
117
+ end
118
+
119
+ # Many commands must be run in the context of a vines server directory
120
+ # created with `vines init`. If the command can't find the server's config
121
+ # file, print an error message and exit.
122
+ #
123
+ # Returns nothing.
124
+ def check_config(opts)
125
+ return if %w[bcrypt init].include?(opts[:command])
126
+ unless File.exists?(opts[:config])
127
+ puts "No config file found at #{opts[:config]}"
128
+ exit(1)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -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, @host, @port, @database, @password = nil, nil, nil, nil, 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 => 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