tkellem 0.8.11 → 0.9.0.beta1

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/Gemfile CHANGED
@@ -1,4 +1,13 @@
1
1
  source "http://rubygems.org"
2
2
 
3
+ $:.push File.expand_path("../lib", __FILE__)
4
+
5
+ gem 'celluloid', :git => 'git://github.com/celluloid/celluloid'
6
+ gem 'celluloid-io', :git => 'https://github.com/celluloid/celluloid-io.git'
7
+ gem 'activesupport', '3.2.10'
8
+ gem 'sequel', '3.42.0'
9
+ gem 'sqlite3', '1.3.6'
10
+ gem 'rspec', '2.5'
11
+
3
12
  # Specify your gem's dependencies in tkellem.gemspec
4
13
  gemspec
@@ -1,17 +1,21 @@
1
1
  require 'active_support/core_ext/class/attribute_accessors'
2
+ require 'celluloid/io'
2
3
 
3
- require 'tkellem/irc_server'
4
4
  require 'tkellem/bouncer_connection'
5
5
 
6
6
  module Tkellem
7
7
 
8
8
  class Bouncer
9
+ include Celluloid::IO
9
10
  include Tkellem::EasyLogger
11
+ include Tkellem::CelluloidTools::LineReader
10
12
 
11
13
  attr_reader :user, :network, :nick, :network_user, :connected_at
12
14
  cattr_accessor :plugins
13
15
  self.plugins = []
14
16
 
17
+ class Room < Struct.new(:name, :topic, :topic_setter, :topic_time); end
18
+
15
19
  def initialize(network_user)
16
20
  @network_user = network_user
17
21
  @user = network_user.user
@@ -21,7 +25,7 @@ class Bouncer
21
25
  # maps { client_conn => state_hash }
22
26
  @active_conns = {}
23
27
  @welcomes = []
24
- @rooms = []
28
+ @rooms = {}
25
29
  # maps { client_conn => away_status_or_nil }
26
30
  @away = {}
27
31
  # plugin data
@@ -29,7 +33,7 @@ class Bouncer
29
33
  # clients waiting for us to connect to the irc server
30
34
  @waiting_clients = []
31
35
 
32
- connect!
36
+ async.connect
33
37
  end
34
38
 
35
39
  def data(key)
@@ -62,7 +66,7 @@ class Bouncer
62
66
  client.send_msg(":#{client.connecting_nick} NICK #{nick}") if client.connecting_nick != nick
63
67
  send_welcome(client)
64
68
  # make the client join all the rooms that we're in
65
- @rooms.each { |room| client.simulate_join(room) }
69
+ @rooms.each_value { |room| client.simulate_join(room) }
66
70
 
67
71
  plugins.each { |plugin| plugin.new_client_connected(self, client) }
68
72
  check_away_status
@@ -100,6 +104,13 @@ class Bouncer
100
104
  end
101
105
  end
102
106
 
107
+ def receive_line(line)
108
+ trace "from server: #{line}"
109
+ return if line.blank?
110
+ msg = IrcMessage.parse(line)
111
+ server_msg(msg)
112
+ end
113
+
103
114
  def server_msg(msg)
104
115
  return if plugins.any? do |plugin|
105
116
  !plugin.server_msg(self, msg)
@@ -112,12 +123,25 @@ class Bouncer
112
123
  false
113
124
  when 'JOIN'
114
125
  debug "#{msg.target_user} joined #{msg.args.first}"
115
- @rooms << msg.args.first if msg.target_user == @nick
126
+ @rooms[msg.args.first] = Room.new(msg.args.first) if msg.target_user == @nick
116
127
  true
117
128
  when 'PART'
118
129
  debug "#{msg.target_user} left #{msg.args.first}"
119
130
  @rooms.delete(msg.args.first) if msg.target_user == @nick
120
131
  true
132
+ when 'TOPIC'
133
+ if room = @rooms[msg.args.first]
134
+ room.topic = msg.args.last
135
+ end
136
+ when '332' # topic replay
137
+ if room = @rooms[msg.args[1]]
138
+ room.topic = msg.args.last
139
+ end
140
+ when '333' # topic timestamp
141
+ if room = @rooms[msg.args[1]]
142
+ room.topic_setter = msg.args[2]
143
+ room.topic_time = msg.args[3]
144
+ end
121
145
  when 'PING'
122
146
  send_msg("PONG tkellem!tkellem :#{msg.args.last}")
123
147
  false
@@ -165,22 +189,21 @@ class Bouncer
165
189
  alias_method :log_name, :name
166
190
 
167
191
  def send_msg(msg)
168
- return unless @conn
169
192
  trace "to server: #{msg}"
170
- @conn.send_data("#{msg}\r\n")
193
+ @socket.write("#{msg}\r\n")
171
194
  end
172
195
 
173
- def connection_established(conn)
174
- @conn = conn
196
+ def connection_established
197
+ debug "Connected"
175
198
  # TODO: support sending a real username, realname, etc
176
199
  send_msg("USER #{@user.username} somehost tkellem :#{@user.name}@tkellem")
177
200
  change_nick(@nick, true)
178
201
  @connected_at = Time.now
202
+ async.run
179
203
  end
180
204
 
181
205
  def disconnected!
182
206
  debug "OMG we got disconnected."
183
- @conn = nil
184
207
  @connected = false
185
208
  @connected_at = nil
186
209
  @active_conns.each { |c,s| c.close_connection }
@@ -199,14 +222,26 @@ class Bouncer
199
222
  @welcomes.each { |msg| msg.args[0] = nick; bouncer_conn.send_msg(msg) }
200
223
  end
201
224
 
202
- def connect!
203
- @connector ||= IrcServerConnection.connector(self, network)
204
- @connector.connect!
225
+ def connect
226
+ hosts = network.reload.hosts
227
+ # random rather than round-robin, not totally ideal
228
+ # Note: Celluloid::TCPSocket also is random rather than round-robin when
229
+ # picking the DNS IP record to connect to
230
+ host = hosts[rand(hosts.length)]
231
+ begin
232
+ info "Connecting to #{host}"
233
+ @socket = TCPSocket.new(host.address, host.port)
234
+ if host.ssl
235
+ @socket = SSLSocket.new(@socket)
236
+ @socket.connect
237
+ end
238
+ end
239
+ connection_established
205
240
  end
206
241
 
207
242
  def ready!
208
- @rooms.each do |room|
209
- send_msg("JOIN #{room}")
243
+ @rooms.each_key do |room|
244
+ send_msg("JOIN #{room.name}")
210
245
  end
211
246
 
212
247
  check_away_status
@@ -1,19 +1,19 @@
1
1
  require 'active_support/core_ext/object/blank'
2
+ require 'celluloid/io'
2
3
 
3
- require 'eventmachine'
4
4
  require 'tkellem/irc_message'
5
+ require 'tkellem/celluloid_tools'
5
6
 
6
7
  module Tkellem
7
8
 
8
- module BouncerConnection
9
- include EM::Protocols::LineText2
9
+ class BouncerConnection
10
+ include Celluloid::IO
10
11
  include Tkellem::EasyLogger
12
+ include Tkellem::CelluloidTools::LineReader
11
13
 
12
- def initialize(tkellem_server, do_ssl)
13
- set_delimiter "\r\n"
14
-
15
- @ssl = do_ssl
14
+ def initialize(tkellem_server, socket)
16
15
  @tkellem = tkellem_server
16
+ @socket = socket
17
17
 
18
18
  @state = :auth
19
19
  @name = 'new-conn'
@@ -31,24 +31,11 @@ module BouncerConnection
31
31
  @data[key] ||= {}
32
32
  end
33
33
 
34
- def post_init
35
- failsafe(:post_init) do
36
- if ssl
37
- start_tls :verify_peer => false
38
- else
39
- ssl_handshake_completed
40
- end
41
- end
42
- end
43
-
44
- def ssl_handshake_completed
45
- end
46
-
47
34
  def error!(msg)
48
35
  info("ERROR :#{msg}")
49
36
  say_as_tkellem(msg)
50
37
  send_msg("ERROR :#{msg}")
51
- close_connection(true)
38
+ close_connection
52
39
  end
53
40
 
54
41
  def connect_to_irc_server
@@ -90,40 +77,38 @@ module BouncerConnection
90
77
  end
91
78
 
92
79
  def receive_line(line)
93
- failsafe("message: {#{line}}") do
94
- trace "from client: #{line}"
95
- return if line.blank?
96
- msg = IrcMessage.parse(line)
97
-
98
- command = msg.command
99
- if @state != :auth && command == 'PRIVMSG' && msg.args.first == '-tkellem'
100
- msg_tkellem(IrcMessage.new(nil, 'TKELLEM', [msg.args.last]))
101
- elsif command == 'TKELLEM' || command == 'TK'
102
- msg_tkellem(msg)
103
- elsif command == 'CAP'
104
- # TODO: full support for CAP -- this just gets mobile colloquy connecting
105
- if msg.args.first =~ /req/i
106
- send_msg("CAP NAK")
107
- end
108
- elsif command == 'PASS' && @state == :auth
109
- @password = msg.args.first
110
- elsif command == 'NICK' && @state == :auth
111
- @connecting_nick = msg.args.first
112
- maybe_connect
113
- elsif command == 'QUIT'
114
- close_connection
115
- elsif command == 'USER' && @state == :auth
116
- unless @username
117
- @username, @conn_info = msg.args.first.strip.split('@', 2).map { |a| a.downcase }
118
- end
119
- maybe_connect
120
- elsif @state == :auth
121
- error!("Protocol error. You must authenticate first.")
122
- elsif @state == :connected
123
- @bouncer.client_msg(self, msg)
124
- else
125
- say_as_tkellem("You must connect to an irc network to do that.")
80
+ trace "from client: #{line}"
81
+ return if line.blank?
82
+ msg = IrcMessage.parse(line)
83
+
84
+ command = msg.command
85
+ if @state != :auth && command == 'PRIVMSG' && msg.args.first == '-tkellem'
86
+ msg_tkellem(IrcMessage.new(nil, 'TKELLEM', [msg.args.last]))
87
+ elsif command == 'TKELLEM' || command == 'TK'
88
+ msg_tkellem(msg)
89
+ elsif command == 'CAP'
90
+ # TODO: full support for CAP -- this just gets mobile colloquy connecting
91
+ if msg.args.first =~ /req/i
92
+ send_msg("CAP NAK")
126
93
  end
94
+ elsif command == 'PASS' && @state == :auth
95
+ @password = msg.args.first
96
+ elsif command == 'NICK' && @state == :auth
97
+ @connecting_nick = msg.args.first
98
+ maybe_connect
99
+ elsif command == 'QUIT'
100
+ close_connection
101
+ elsif command == 'USER' && @state == :auth
102
+ unless @username
103
+ @username, @conn_info = msg.args.first.strip.split('@', 2).map { |a| a.downcase }
104
+ end
105
+ maybe_connect
106
+ elsif @state == :auth
107
+ error!("Protocol error. You must authenticate first.")
108
+ elsif @state == :connected
109
+ @bouncer.client_msg(self, msg)
110
+ else
111
+ say_as_tkellem("You must connect to an irc network to do that.")
127
112
  end
128
113
  end
129
114
 
@@ -188,21 +173,22 @@ module BouncerConnection
188
173
  end
189
174
 
190
175
  def simulate_join(room)
191
- send_msg(":#{nick} JOIN #{room}")
176
+ send_msg(":#{nick} JOIN #{room.name}")
192
177
  # TODO: intercept the NAMES response so that only this bouncer gets it
193
178
  # Otherwise other clients might show an "in this room" line.
194
- @bouncer.send_msg("NAMES #{room}\r\n")
179
+ @bouncer.send_msg("NAMES #{room.name}\r\n")
180
+ send_msg(IrcMessage.new(":tkellem", "332", [nick, room.name, room.topic])) if room.topic
181
+ send_msg(IrcMessage.new(":tkellem", "333", [nick, room.name, room.topic_setter, room.topic_time])) if room.topic_setter && room.topic_time
195
182
  end
196
183
 
197
184
  def send_msg(msg)
185
+ return if @socket.closed?
198
186
  trace "to client: #{msg}"
199
- send_data("#{msg}\r\n")
187
+ @socket.write("#{msg}\r\n")
200
188
  end
201
189
 
202
190
  def unbind
203
- failsafe(:unbind) do
204
- @bouncer.disconnect_client(self) if @bouncer
205
- end
191
+ @bouncer.try(:disconnect_client, self)
206
192
  end
207
193
  end
208
194
 
@@ -0,0 +1,131 @@
1
+ require 'openssl'
2
+ require 'celluloid/io'
3
+
4
+ module Tkellem
5
+ module CelluloidTools
6
+
7
+ # Generates a new SSL context with a new cert and key
8
+ # Great for easily getting up and running, but not necessarily a good idea for
9
+ # production use
10
+ def self.generate_ssl_ctx
11
+ key = OpenSSL::PKey::RSA.new(2048)
12
+
13
+ dn = OpenSSL::X509::Name.parse("/CN=tkellem-auto")
14
+ cert = OpenSSL::X509::Certificate.new
15
+ cert.version = 2
16
+ cert.serial = 1
17
+ cert.subject = dn
18
+ cert.issuer = dn
19
+ cert.public_key = key.public_key
20
+ cert.not_before = Time.now
21
+ cert.not_after = Time.now + 94670777 # 3 years
22
+ cert.sign(key, OpenSSL::Digest::SHA1.new)
23
+
24
+ ctx = OpenSSL::SSL::SSLContext.new
25
+ ctx.key = key
26
+ ctx.cert = cert
27
+
28
+ ctx
29
+ end
30
+
31
+ class BackoffSupervisor < ::Celluloid::SupervisionGroup
32
+ attr_reader :registry
33
+
34
+ def restart_actor(actor, reason)
35
+ member = @members.find do |member|
36
+ member.actor == actor
37
+ end
38
+ raise "a group member went missing. This shouldn't be!" unless member
39
+
40
+ if reason
41
+ end
42
+
43
+ member.restart(reason)
44
+ end
45
+ end
46
+
47
+ class Listener < Struct.new(:server, :callback)
48
+ include Celluloid::IO
49
+
50
+ def self.start(*args, &callback)
51
+ listener = self.new(*args)
52
+ listener.callback = callback
53
+ listener.async.run
54
+ listener
55
+ end
56
+
57
+ def run
58
+ loop { handle_connection(server.accept) }
59
+ end
60
+
61
+ def handle_connection(socket)
62
+ callback.(socket)
63
+ end
64
+
65
+ finalizer :close
66
+
67
+ def close
68
+ server.try(:close) unless server.try(:closed?)
69
+ end
70
+ end
71
+
72
+ class TCPListener < Listener
73
+ def initialize(host, port)
74
+ self.server = TCPServer.new(host, port)
75
+ end
76
+ end
77
+
78
+ class SSLListener < Listener
79
+ def initialize(host, port)
80
+ self.server = SSLServer.new(TCPServer.new(host, port), CelluloidTools.generate_ssl_ctx)
81
+ end
82
+ end
83
+
84
+ class UnixListener < Listener
85
+ def initialize(socket_path)
86
+ self.server = UNIXServer.new(socket_path)
87
+ end
88
+ end
89
+
90
+ module LineReader
91
+ def self.included(k)
92
+ k.send :finalizer, :close_connection
93
+ end
94
+
95
+ def readline
96
+ @delimiter ||= "\r\n"
97
+ @readline_buffer ||= ''
98
+ loop do
99
+ if idx = @readline_buffer.index(@delimiter)
100
+ postidx = idx + @delimiter.size
101
+ line = @readline_buffer[0, postidx]
102
+ @readline_buffer = @readline_buffer[postidx..-1]
103
+ return line
104
+ else
105
+ @socket.readpartial(4096, @readline_buffer)
106
+ end
107
+ end
108
+ end
109
+
110
+ def close_connection
111
+ @socket.close if @socket && !@socket.closed?
112
+ end
113
+
114
+ def run
115
+ loop do
116
+ line = readline
117
+ receive_line(line)
118
+ end
119
+ rescue EOFError, IOError, OpenSSL::SSL::SSLError
120
+ unbind
121
+ end
122
+
123
+ def receive_line(line)
124
+ end
125
+
126
+ def unbind
127
+ end
128
+ end
129
+
130
+ end
131
+ end
@@ -2,7 +2,6 @@ require 'fileutils'
2
2
  require 'optparse'
3
3
 
4
4
  require 'tkellem'
5
- require 'tkellem/socket_server'
6
5
 
7
6
  class Tkellem::Daemon
8
7
  attr_reader :options
@@ -86,11 +85,8 @@ class Tkellem::Daemon
86
85
  end
87
86
 
88
87
  def start
89
- trap("INT") { EM.stop }
90
- EM.run do
91
- @admin = EM.start_unix_domain_server(socket_file, Tkellem::SocketServer)
92
- Tkellem::TkellemServer.new
93
- end
88
+ remove_files
89
+ Tkellem::TkellemServer.new(options).run
94
90
  ensure
95
91
  remove_files
96
92
  end
@@ -138,7 +134,7 @@ class Tkellem::Daemon
138
134
  end
139
135
 
140
136
  def remove_files
141
- FileUtils.rm(socket_file)
137
+ FileUtils.rm(socket_file) if File.exist?(socket_file)
142
138
  return unless @daemon
143
139
  pid = File.read(pid_file) if File.file?(pid_file)
144
140
  if pid.to_i == Process.pid
@@ -20,7 +20,7 @@ class IrcMessage < Struct.new(:prefix, :command, :args, :ctcp)
20
20
 
21
21
  msg = self.new(prefix, command, args)
22
22
 
23
- if args.last.try(:match, %r{#{"\x01"}([^ ]+)([^\1]*)#{"\x01"}})
23
+ if args.last && args.last.match(%r{#{"\x01"}([^ ]+)([^\1]*)#{"\x01"}})
24
24
  msg.ctcp = $1.upcase
25
25
  msg.args[-1] = $2.strip
26
26
  end
@@ -1,33 +1,50 @@
1
- class InitDb < ActiveRecord::Migration
2
- def self.up
3
- create_table 'listen_addresses' do |t|
4
- t.string 'address', :null => false
5
- t.integer 'port', :null => false
6
- t.boolean 'ssl', :null => false, :default => false
1
+ Sequel.migration do
2
+ up do
3
+ if self.table_exists?(:schema_migrations) && self[:schema_migrations].first(:version => '1')
4
+ next
7
5
  end
8
6
 
9
- create_table 'users' do |t|
10
- t.string 'username', :null => false
11
- t.string 'password'
12
- t.string 'role', :null => false, :default => 'user'
7
+ create_table 'listen_addresses' do
8
+ primary_key :id
9
+ String 'address', :null => false
10
+ Integer 'port', :null => false
11
+ boolean 'ssl', :null => false, :default => false
13
12
  end
14
13
 
15
- create_table 'networks' do |t|
16
- t.belongs_to 'user'
17
- t.string 'name', :null => false
14
+ create_table 'users' do
15
+ primary_key :id
16
+ String 'username', :null => false
17
+ String 'password'
18
+ String 'role', :null => false, :default => 'user'
18
19
  end
19
20
 
20
- create_table 'hosts' do |t|
21
- t.belongs_to 'network'
22
- t.string 'address', :null => false
23
- t.integer 'port', :null => false
24
- t.boolean 'ssl', :null => false, :default => false
21
+ create_table 'networks' do
22
+ primary_key :id
23
+ foreign_key :user_id, :users
24
+ String 'name', :null => false
25
25
  end
26
26
 
27
- create_table 'network_users' do |t|
28
- t.belongs_to 'user'
29
- t.belongs_to 'network'
30
- t.string 'nick'
27
+ create_table 'hosts' do
28
+ primary_key :id
29
+ foreign_key :network_id, :networks
30
+ String 'address', :null => false
31
+ Integer 'port', :null => false
32
+ boolean 'ssl', :null => false, :default => false
31
33
  end
34
+
35
+ create_table 'network_users' do
36
+ primary_key :id
37
+ foreign_key :user_id, :users
38
+ foreign_key :network_id, :networks
39
+ String 'nick'
40
+ end
41
+ end
42
+
43
+ down do
44
+ drop_table 'network_users'
45
+ drop_table 'hosts'
46
+ drop_table 'networks'
47
+ drop_table 'users'
48
+ drop_table 'listen_addresses'
32
49
  end
33
50
  end
@@ -1,7 +1,24 @@
1
- class AtConnectColumns < ActiveRecord::Migration
2
- def self.up
3
- add_column 'networks', 'at_connect', 'text'
4
- add_column 'network_users', 'at_connect', 'text'
1
+ Sequel.migration do
2
+ up do
3
+ if self.table_exists?(:schema_migrations) && self[:schema_migrations].first(:version => '2')
4
+ next
5
+ end
6
+
7
+ alter_table 'networks' do
8
+ add_column 'at_connect', String, :text => true
9
+ end
10
+ alter_table 'network_users' do
11
+ add_column 'at_connect', String, :text => true
12
+ end
13
+ end
14
+
15
+ down do
16
+ alter_table 'network_users' do
17
+ drop_column 'at_connect'
18
+ end
19
+ alter_table 'networks' do
20
+ drop_column 'at_connect'
21
+ end
5
22
  end
6
23
  end
7
24
 
@@ -1,13 +1,22 @@
1
- class Settings < ActiveRecord::Migration
2
- def self.up
3
- create_table 'settings' do |t|
4
- t.string :name, :null => false
5
- t.string :value, :null => false
6
- t.boolean :unchanged, :null => false, :default => true
1
+ Sequel.migration do
2
+ up do
3
+ if self.table_exists?(:schema_migrations) && self[:schema_migrations].first(:version => '3')
4
+ next
7
5
  end
8
6
 
9
- Tkellem::Setting.make_new('user_registration', 'closed')
10
- Tkellem::Setting.make_new('recaptcha_api_key', '')
11
- Tkellem::Setting.make_new('allow_user_networks', 'false')
7
+ create_table 'settings' do
8
+ primary_key :id
9
+ String :name, :null => false
10
+ String :value, :null => false
11
+ boolean :unchanged, :null => false, :default => true
12
+ end
13
+
14
+ self[:settings].insert(:name => 'user_registration', :value => 'closed')
15
+ self[:settings].insert(:name => 'recaptcha_api_key', :value => '')
16
+ self[:settings].insert(:name => 'allow_user_networks', :value => 'false')
17
+ end
18
+
19
+ down do
20
+ drop_table 'settings'
12
21
  end
13
22
  end
@@ -1,7 +1,7 @@
1
1
  module Tkellem
2
2
 
3
- class Host < ActiveRecord::Base
4
- belongs_to :network
3
+ class Host < Sequel::Model
4
+ many_to_one :network
5
5
 
6
6
  def to_s
7
7
  self.class.address_string(address, port, ssl)
@@ -1,12 +1,24 @@
1
1
  module Tkellem
2
2
 
3
- class ListenAddress < ActiveRecord::Base
3
+ class ListenAddress < Sequel::Model
4
+ plugin :validation_class_methods
5
+
4
6
  validates_uniqueness_of :port, :scope => [:address]
5
7
  validates_presence_of :address, :port
6
8
 
7
9
  def to_s
8
10
  "#{ssl ? 'ircs' : 'irc'}://#{address}:#{port}"
9
11
  end
12
+
13
+ def after_create
14
+ super
15
+ $tkellem_server.try(:after_create, self)
16
+ end
17
+
18
+ def after_destroy
19
+ super
20
+ $tkellem_server.try(:after_destroy, self)
21
+ end
10
22
  end
11
23
 
12
24
  end