Remote Service Provider: increase security by preventing a malicious client from...
[rbot] / lib / rbot / core / remote.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Remote service provider for rbot
5 #
6 # Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com)
7 # Copyright:: Copyright (c) 2006 Giuseppe Bilotta
8 # License:: GPLv2
9 #
10 # From an idea by halorgium <rbot@spork.in>.
11 #
12 # TODO find a way to manage session id (logging out, manually and/or
13 # automatically)
14 #
15
16 require 'drb/drb'
17
18 module ::Irc
19
20   module Auth
21
22     # We extend the BotUser class to handle remote logins
23     #
24     class BotUser
25
26       # A rather simple method to handle remote logins. Nothing special, just a
27       # password check.
28       #
29       def remote_login(password)
30         if password == @password
31           debug "remote login for #{self.inspect} succeeded"
32           return true
33         else
34           return false
35         end
36       end
37     end
38
39     # We extend the AuthManagerClass to handle remote logins
40     #
41     class AuthManagerClass
42
43       MAX_SESSION_ID = 2**128 - 1
44
45       # Creates a session id when the given password matches the given
46       # botusername
47       #
48       def remote_login(botusername, pwd)
49         @remote_users = Hash.new unless defined? @remote_users
50         n = BotUser.sanitize_username(botusername)
51         k = n.to_sym
52         raise "No such BotUser #{n}" unless include?(k)
53         bu = @allbotusers[k]
54         if bu.remote_login(pwd)
55           raise "ran out of session ids!" if @remote_users.length == MAX_SESSION_ID
56           session_id = rand(MAX_SESSION_ID)
57           while @remote_users.has_key?(session_id)
58             session_id = rand(MAX_SESSION_ID)
59           end
60           @remote_users[session_id] = bu
61           return session_id
62         end
63         return false
64       end
65
66       # Returns the botuser associated with the given session id
67       def remote_user(session_id)
68         return everyone unless session_id
69         return nil unless defined? @remote_users
70         if @remote_users.has_key?(session_id)
71           return @remote_users[session_id]
72         else
73           return nil
74         end
75       end
76     end
77
78   end
79
80
81   # A RemoteMessage is similar to a BasicUserMessage
82   #
83   class RemoteMessage
84     # associated bot
85     attr_reader :bot
86
87     # when the message was received
88     attr_reader :time
89
90     # remote client that originated the message
91     attr_reader :source
92
93     # contents of the message
94     attr_accessor :message
95
96     def initialize(bot, source, message)
97       @bot = bot
98       @source = source
99       @message = message
100       @time = Time.now
101     end
102
103     # The target of a RemoteMessage
104     def target
105       @bot
106     end
107
108     # Remote messages are always 'private'
109     def private?
110       true
111     end
112   end
113
114   # The RemoteDispatcher is a kind of MessageMapper, tuned to handle
115   # RemoteMessages
116   #
117   class RemoteDispatcher < MessageMapper
118
119     # It is initialized by passing it the bot instance
120     #
121     def initialize(bot)
122       super
123     end
124
125     # The map method for the RemoteDispatcher returns the index of the inserted
126     # template
127     #
128     def map(botmodule, *args)
129       super
130       return @templates.length - 1
131     end
132
133     # The unmap method for the RemoteDispatcher nils the template at the given index,
134     # therefore effectively removing the mapping
135     #
136     def unmap(botmodule, handle)
137       tmpl = @templates[handle]
138       raise "Botmodule #{botmodule.name} tried to unmap #{tmpl.inspect} that was handled by #{tmpl.botmodule}" unless tmpl.botmodule == botmodule.name
139       debug "Unmapping #{tmpl.inspect}"
140       @templates[handle] = nil
141       @templates.clear unless @templates.nitems > 0
142     end
143
144     # We redefine the handle() method from MessageMapper, taking into account
145     # that @parent is a bot, and that we don't handle fallbacks.
146     #
147     # On failure to dispatch anything, the method returns false. If dispatching
148     # is successfull, the method returns a Hash.
149     #
150     # Presently, the hash returned on success has only one key, :return, whose
151     # value is the actual return value of the successfull dispatch.
152     # 
153     # TODO this same kind of mechanism could actually be used in MessageMapper
154     # itself to be able to handle the case of multiple plugins having the same
155     # 'first word' ...
156     #
157     #
158     def handle(m)
159       return false if @templates.empty?
160       failures = []
161       @templates.each do |tmpl|
162         # Skip this element if it was unmapped
163         next unless tmpl
164         botmodule = @parent.plugins[tmpl.botmodule]
165         options, failure = tmpl.recognize(m)
166         if options.nil?
167           failures << [tmpl, failure]
168         else
169           action = tmpl.options[:action]
170           unless botmodule.respond_to?(action)
171             failures << [tmpl, "#{botmodule} does not respond to action #{action}"]
172             next
173           end
174           auth = tmpl.options[:full_auth_path]
175           debug "checking auth for #{auth}"
176           # We check for private permission
177           if m.bot.auth.allow?(auth, m.source, '?')
178             debug "template match found and auth'd: #{action.inspect} #{options.inspect}"
179             return :return => botmodule.send(action, m, options)
180           end
181           debug "auth failed for #{auth}"
182           # if it's just an auth failure but otherwise the match is good,
183           # don't try any more handlers
184           return false
185         end
186       end
187       failures.each {|f, r|
188         debug "#{f.inspect} => #{r}"
189       }
190       debug "no handler found"
191       return false
192     end
193
194   end
195
196   class IrcBot
197
198     # The Irc::IrcBot::RemoteObject class represents and object that will take care
199     # of interfacing with remote clients
200     #
201     # Example client session:
202     #
203     #   require 'drb'
204     #   rbot = DRbObject.new_with_uri('druby://localhost:7268')
205     #   id = rbot.delegate(nil, 'remote login someuser somepass')[:return]
206     #   rbot.delegate(id, 'some secret command')
207     #
208     # Of course, the remote login is only neede for commands which may not be available
209     # to everyone
210     #
211     class RemoteObject
212
213       # We don't want this object to be copied clientside, so we make it undumpable
214       include DRbUndumped
215
216       # Initialization is simple
217       def initialize(bot)
218         @bot = bot
219       end
220
221       # The delegate method. This is the main method used by remote clients to send
222       # commands to the bot. Most of the time, the method will be called with only
223       # two parameters (session id and a String), but we allow more parameters
224       # for future expansions.
225       #
226       # The session_id can be nil, meaning that the remote client wants to work as
227       # an anoynomus botuser.
228       #
229       def delegate(session_id, *pars)
230         warn "Ignoring extra parameters" if pars.length > 1
231         cmd = pars.first
232         client = @bot.auth.remote_user(session_id)
233         raise "No such session id #{session_id}" unless client
234         debug "Trying to dispatch command #{cmd.inspect} from #{client.inspect} authorized by #{session_id.inspect}"
235         m = RemoteMessage.new(@bot, client, cmd)
236         @bot.remote_dispatcher.handle(m)
237       end
238
239       private :instance_variables, :instance_variable_get, :instance_variable_set
240     end
241
242     # The bot also manages a single (for the moment) remote dispatcher. This method
243     # makes it accessible to the outside world, creating it if necessary.
244     #
245     def remote_dispatcher
246       if defined? @remote_dispatcher
247         @remote_dispatcher
248       else
249         @remote_dispatcher = RemoteDispatcher.new(self)
250       end
251     end
252
253     # The bot also manages a single (for the moment) remote object. This method
254     # makes it accessible to the outside world, creating it if necessary.
255     #
256     def remote_object
257       if defined? @remote_object
258         @remote_object
259       else
260         @remote_object = RemoteObject.new(self)
261       end
262     end
263
264   end
265
266   module Plugins
267
268     # We create a new Ruby module that can be included by BotModules that want to
269     # provide remote interfaces
270     #
271     module RemoteBotModule
272
273       # The remote_map acts just like the BotModule#map method, except that
274       # the map is registered to the @bot's remote_dispatcher. Also, the remote map handle
275       # is handled for the cleanup management
276       #
277       def remote_map(*args)
278         @remote_maps = Array.new unless defined? @remote_maps
279         @remote_maps << @bot.remote_dispatcher.map(self, *args)
280       end
281
282       # Unregister the remote maps.
283       #
284       def remote_cleanup
285         return unless defined? @remote_maps
286         @remote_maps.each { |h|
287           @bot.remote_dispatcher.unmap(self, h)
288         }
289         @remote_maps.clear
290       end
291
292       # Redefine the default cleanup method.
293       #
294       def cleanup
295         super
296         remote_cleanup
297       end
298     end
299
300     # And just because I like consistency:
301     #
302     module RemoteCoreBotModule
303       include RemoteBotModule
304     end
305
306     module RemotePlugin
307       include RemoteBotModule
308     end
309
310   end
311
312 end
313
314 class RemoteModule < CoreBotModule
315
316   include RemoteCoreBotModule
317
318   BotConfig.register BotConfigBooleanValue.new('remote.autostart',
319     :default => true,
320     :requires_rescan => true,
321     :desc => "Whether the remote service provider should be started automatically")
322
323   BotConfig.register BotConfigIntegerValue.new('remote.port',
324     :default => 7268, # that's 'rbot'
325     :requires_rescan => true,
326     :desc => "Port on which the remote interface will be presented")
327
328   BotConfig.register BotConfigStringValue.new('remote.host',
329     :default => '',
330     :requires_rescan => true,
331     :desc => "Port on which the remote interface will be presented")
332
333   def initialize
334     super
335     @port = @bot.config['remote.port']
336     @host = @bot.config['remote.host']
337     @drb = nil
338     begin
339       start_service if @bot.config['remote.autostart']
340     rescue => e
341       error "couldn't start remote service provider: #{e.inspect}"
342     end
343   end
344
345   def start_service
346     raise "Remote service provider already running" if @drb
347     @drb = DRb.start_service("druby://#{@host}:#{@port}", @bot.remote_object)
348   end
349
350   def stop_service
351     @drb.stop_service if @drb
352     @drb = nil
353   end
354
355   def cleanup
356     stop_service
357     super
358   end
359
360   def handle_start(m, params)
361     if @drb
362       rep = "remote service provider already running"
363       rep << " on port #{@port}" if m.private?
364     else
365       begin
366         start_service(@port)
367         rep = "remote service provider started"
368         rep << " on port #{@port}" if m.private?
369       rescue
370         rep = "couldn't start remote service provider"
371       end
372     end
373     m.reply rep
374   end
375
376   def remote_test(m, params)
377     @bot.say params[:channel], "This is a remote test"
378   end
379
380   def remote_login(m, params)
381     id = @bot.auth.remote_login(params[:botuser], params[:password])
382     raise "login failed" unless id
383     return id
384   end
385
386 end
387
388 remote = RemoteModule.new
389
390 remote.map "remote start",
391   :action => 'handle_start',
392   :auth_path => ':manage:'
393
394 remote.map "remote stop",
395   :action => 'handle_stop',
396   :auth_path => ':manage:'
397
398 remote.default_auth('*', false)
399
400 remote.remote_map "remote test :channel",
401   :action => 'remote_test'
402
403 remote.remote_map "remote login :botuser :password",
404   :action => 'remote_login'
405
406 remote.default_auth('login', true)