diaspora-vines 0.1.2

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 (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/contact.rb +111 -0
  30. data/lib/vines/daemon.rb +78 -0
  31. data/lib/vines/error.rb +150 -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/contact_test.rb +102 -0
  125. data/test/error_test.rb +58 -0
  126. data/test/ext/nokogiri.rb +14 -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 'diaspora' 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