]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/webservice.rb
web service: better integration in rbot
[user/henk/code/ruby/rbot.git] / lib / rbot / core / webservice.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Web service for bot
5 #
6 # Author:: Matthias Hecker (apoc@geekosphere.org)
7 #
8 # HTTP(S)/json based web service for remote controlling the bot,
9 # similar to remote but much more portable.
10 #
11 # For more info/documentation:
12 # https://github.com/4poc/rbot/wiki/Web-Service
13 #
14
15 require 'webrick'
16 require 'webrick/https'
17 require 'openssl'
18 require 'cgi'
19 require 'json'
20
21 module ::Irc
22 class Bot
23     # A WebMessage is a web request and response object combined with helper methods.
24     #
25     class WebMessage
26       attr_reader :bot, :method, :bot, :req, :res, :post, :client, :path, :source
27       def initialize(bot, req, res)
28         @bot = bot
29         @req = req
30         @res = res
31
32         @method = req.request_method
33         if req.body and not req.body.empty?
34           @post = CGI::parse(req.body)
35         end
36         @client = req.peeraddr[3]
37
38         # login a botuser with http authentication
39         WEBrick::HTTPAuth.basic_auth(req, res, 'RBotAuth') { |username, password|
40           if username
41             botuser = @bot.auth.get_botuser(Auth::BotUser.sanitize_username(username))
42             if botuser and botuser.password == password
43               @source = botuser
44               true
45             end
46             false
47           else
48             true # no need to request auth at this point
49           end
50         }
51
52         @path = req.path
53         debug '@path = ' + @path.inspect
54       end
55
56       # The target of a RemoteMessage
57       def target
58         @bot
59       end
60
61       # Remote messages are always 'private'
62       def private?
63         true
64       end
65
66       # Sends a plaintext response
67       def send_plaintext(body, status=200)
68         @res.status = status
69         @res['Content-Type'] = 'text/plain'
70         @res.body = body
71       end
72     end
73
74     # works similar to a message mapper but for url paths
75     class WebDispatcher
76       class WebTemplate
77         attr_reader :botmodule, :pattern, :options
78         def initialize(botmodule, pattern, options={})
79           @botmodule = botmodule
80           @pattern = pattern
81           @options = options
82           set_auth_path(@options)
83         end
84
85         def recognize(m)
86           message_route = m.path[1..-1].split('/')
87           template_route = @pattern[1..-1].split('/')
88           params = {}
89
90           debug 'web mapping path %s <-> %s' % [message_route.inspect, template_route.inspect]
91
92           message_route.each do |part|
93             tmpl = template_route.shift
94             return false if not tmpl
95
96             if tmpl[0] == ':'
97               # push part as url path parameter
98               params[tmpl[1..-1].to_sym] = part
99             elsif tmpl == part
100               next
101             else
102               return false
103             end
104           end
105
106           debug 'web mapping params is %s' % [params.inspect]
107
108           params
109         end
110
111         def set_auth_path(hash)
112           if hash.has_key?(:full_auth_path)
113             warning "Web route #{@pattern.inspect} in #{@botmodule} sets :full_auth_path, please don't do this"
114           else
115             pre = @botmodule
116             words = @pattern[1..-1].split('/').reject{ |x|
117               x == pre || x =~ /^:/ || x =~ /\[|\]/
118             }
119             if words.empty?
120               post = nil
121             else
122               post = words.first
123             end
124             if hash.has_key?(:auth_path)
125               extra = hash[:auth_path]
126               if extra.sub!(/^:/, "")
127                 pre += "::" + post
128                 post = nil
129               end
130               if extra.sub!(/:$/, "")
131                 if words.length > 1
132                   post = [post,words[1]].compact.join("::")
133                 end
134               end
135               pre = nil if extra.sub!(/^!/, "")
136               post = nil if extra.sub!(/!$/, "")
137               extra = nil if extra.empty?
138             else
139               extra = nil
140             end
141             hash[:full_auth_path] = [pre,extra,post].compact.join("::")
142             debug "Web route #{@pattern} in #{botmodule} will use authPath #{hash[:full_auth_path]}"
143           end
144         end
145       end
146
147       def initialize(bot)
148         @bot = bot
149         @templates = []
150       end
151
152       def map(botmodule, pattern, options={})
153         @templates << WebTemplate.new(botmodule.to_s, pattern, options)
154         debug 'template route: ' + @templates[-1].inspect
155         return @templates.length - 1
156       end
157
158       # The unmap method for the RemoteDispatcher nils the template at the given index,
159       # therefore effectively removing the mapping
160       #
161       def unmap(botmodule, index)
162         tmpl = @templates[index]
163         raise "Botmodule #{botmodule.name} tried to unmap #{tmpl.inspect} that was handled by #{tmpl.botmodule}" unless tmpl.botmodule == botmodule.name
164         debug "Unmapping #{tmpl.inspect}"
165         @templates[handle] = nil
166         @templates.clear unless @templates.compact.size > 0
167       end
168
169       # Handle a web service request, find matching mapping and dispatch.
170       #
171       # In case authentication fails, sends a 401 Not Authorized response.
172       #
173       def handle(m)
174         if @templates.empty?
175           m.send_plaintext('no routes!', 404)
176           return false if @templates.empty?
177         end
178         failures = []
179         @templates.each do |tmpl|
180           # Skip this element if it was unmapped
181           next unless tmpl
182           botmodule = @bot.plugins[tmpl.botmodule]
183           params = tmpl.recognize(m)
184           if params
185             action = tmpl.options[:action]
186             unless botmodule.respond_to?(action)
187               failures << NoActionFailure.new(tmpl, m)
188               next
189             end
190             # check http method:
191             unless not tmpl.options.has_key? :method or tmpl.options[:method] == m.method
192               debug 'request method missmatch'
193               next
194             end
195             auth = tmpl.options[:full_auth_path]
196             debug "checking auth for #{auth.inspect}"
197             # We check for private permission
198             if m.bot.auth.permit?(m.source || Auth::defaultbotuser, auth, '?')
199               debug "template match found and auth'd: #{action.inspect} #{params.inspect}"
200               response = botmodule.send(action, m, params)
201               if m.res.sent_size == 0
202                 m.send_plaintext(response.to_json)
203               end
204               return true
205             end
206             debug "auth failed for #{auth}"
207             # if it's just an auth failure but otherwise the match is good,
208             # don't try any more handlers
209             m.send_plaintext('Authentication Required!', 401)
210             return false
211           end
212         end
213         failures.each {|r|
214           debug "#{r.template.inspect} => #{r}"
215         }
216         debug "no handler found"
217         m.send_plaintext('No Handler Found!', 404)
218         return false
219       end
220     end
221
222     # Static web dispatcher instance used internally.
223     def web_dispatcher
224       if defined? @web_dispatcher
225         @web_dispatcher
226       else
227         @web_dispatcher = WebDispatcher.new(self)
228       end
229     end
230
231     module Plugins
232       # Mixin for plugins that want to provide a web interface of some sort.
233       #
234       # Plugins include the module and can then use web_map
235       # to register a url to handle.
236       #
237       module WebBotModule
238         # The remote_map acts just like the BotModule#map method, except that
239         # the map is registered to the @bot's remote_dispatcher. Also, the remote map handle
240         # is handled for the cleanup management
241         #
242         def web_map(*args)
243           # stores the handles/indexes for cleanup:
244           @web_maps = Array.new unless defined? @web_maps
245           @web_maps << @bot.web_dispatcher.map(self, *args)
246         end
247
248         # Unregister the remote maps.
249         #
250         def web_cleanup
251           return unless defined? @web_maps
252           @web_maps.each { |h|
253             @bot.web_dispatcher.unmap(self, h)
254           }
255           @web_maps.clear
256         end
257
258         # Redefine the default cleanup method.
259         #
260         def cleanup
261           super
262           web_cleanup
263         end
264       end
265     end
266 end # Bot
267 end # Irc
268
269 class ::WebServiceUser < Irc::User
270   def initialize(str, botuser, opts={})
271     super(str, opts)
272     @botuser = botuser
273     @response = []
274   end
275   attr_reader :botuser
276   attr_accessor :response
277 end
278
279 class DispatchServlet < WEBrick::HTTPServlet::AbstractServlet
280   def initialize(server, bot)
281     super server
282     @bot = bot
283   end
284
285   def dispatch(req, res)
286     res['Server'] = 'RBot Web Service (http://ruby-rbot.org/)'
287     begin
288       m = WebMessage.new(@bot, req, res)
289       @bot.web_dispatcher.handle m
290     rescue
291       res.status = 500
292       res['Content-Type'] = 'text/plain'
293       res.body = "Error: %s\n" % [$!.to_s]
294       error 'web dispatch error: ' + $!.to_s
295       error $@.join("\n")
296     end
297   end
298
299   def do_GET(req, res)
300     dispatch(req, res)
301   end
302
303   def do_POST(req, res)
304     dispatch(req, res)
305   end
306 end
307
308 class WebServiceModule < CoreBotModule
309
310   include WebBotModule
311
312   Config.register Config::BooleanValue.new('webservice.autostart',
313     :default => false,
314     :requires_rescan => true,
315     :desc => 'Whether the web service should be started automatically')
316
317   Config.register Config::IntegerValue.new('webservice.port',
318     :default => 7268,
319     :requires_rescan => true,
320     :desc => 'Port on which the web service will listen')
321
322   Config.register Config::StringValue.new('webservice.host',
323     :default => '127.0.0.1',
324     :requires_rescan => true,
325     :desc => 'Host the web service will bind on')
326
327   Config.register Config::BooleanValue.new('webservice.ssl',
328     :default => false,
329     :requires_rescan => true,
330     :desc => 'Whether the web server should use SSL (recommended!)')
331
332   Config.register Config::StringValue.new('webservice.ssl_key',
333     :default => '~/.rbot/wskey.pem',
334     :requires_rescan => true,
335     :desc => 'Private key file to use for SSL')
336
337   Config.register Config::StringValue.new('webservice.ssl_cert',
338     :default => '~/.rbot/wscert.pem',
339     :requires_rescan => true,
340     :desc => 'Certificate file to use for SSL')
341
342   Config.register Config::BooleanValue.new('webservice.allow_dispatch',
343     :default => true,
344     :desc => 'Dispatch normal bot commands, just as a user would through the web service, requires auth for certain commands just like a irc user.')
345
346   def initialize
347     super
348     @port = @bot.config['webservice.port']
349     @host = @bot.config['webservice.host']
350     @server = nil
351     @bot.webservice = self
352     begin
353       start_service if @bot.config['webservice.autostart']
354     rescue => e
355       error "couldn't start web service provider: #{e.inspect}"
356     end
357   end
358
359   def start_service
360     raise "Remote service provider already running" if @server
361     opts = {:BindAddress => @host, :Port => @port}
362     if @bot.config['webservice.ssl']
363       opts.merge! :SSLEnable => true
364       cert = File.expand_path @bot.config['webservice.ssl_cert']
365       key = File.expand_path @bot.config['webservice.ssl_key']
366       if File.exists? cert and File.exists? key
367         debug 'using ssl certificate files'
368         opts.merge!({
369           :SSLCertificate => OpenSSL::X509::Certificate.new(File.read(cert)),
370           :SSLPrivateKey => OpenSSL::PKey::RSA.new(File.read(key))
371         })
372       else
373         debug 'using on-the-fly generated ssl certs'
374         opts.merge! :SSLCertName => [ %w[CN localhost] ]
375         # the problem with this is that it will always use the same
376         # serial number which makes this feature pretty much useless.
377       end
378     end
379     # Logging to file in ~/.rbot
380     logfile = File.open(@bot.path('webservice.log'), 'a+')
381     opts.merge!({
382       :Logger => WEBrick::Log.new(logfile),
383       :AccessLog => [[logfile, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
384     })
385     @server = WEBrick::HTTPServer.new(opts)
386     debug 'webservice started: ' + opts.inspect
387     @server.mount('/', DispatchServlet, @bot)
388     Thread.new { @server.start }
389   end
390
391   def stop_service
392     @server.shutdown if @server
393     @server = nil
394   end
395
396   def cleanup
397     stop_service
398     super
399   end
400
401   def handle_start(m, params)
402     if @server
403       m.reply 'web service already running'
404     else
405       begin
406         start_service
407         m.reply 'web service started'
408       rescue
409         m.reply 'unable to start web service, error: ' + $!.to_s
410       end
411     end
412   end
413
414   def handle_stop(m, params)
415     if @server
416       stop_service
417       m.reply 'web service stopped'
418     else
419       m.reply 'web service not running'
420     end
421   end
422
423   def handle_ping(m, params)
424     m.send_plaintext("pong\n")
425   end
426
427   def handle_dispatch(m, params)
428     if not @bot.config['webservice.allow_dispatch']
429       m.send_plaintext('dispatch forbidden by configuration', 403)
430       return
431     end
432
433     command = m.post['command'][0]
434     if not m.source
435       botuser = Auth::defaultbotuser
436     else
437       botuser = m.source.botuser
438     end
439     netmask = '%s!%s@%s' % [botuser.username, botuser.username, m.client]
440
441     user = WebServiceUser.new(netmask, botuser)
442     message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command)
443
444     res = @bot.plugins.irc_delegate('privmsg', message)
445
446     { :reply => user.response }
447   end
448
449 end
450
451 webservice = WebServiceModule.new
452
453 webservice.map 'webservice start',
454   :action => 'handle_start',
455   :auth_path => ':manage:'
456
457 webservice.map 'webservice stop',
458   :action => 'handle_stop',
459   :auth_path => ':manage:'
460
461 webservice.web_map '/ping',
462   :action => :handle_ping,
463   :auth_path => 'public'
464
465 # executes arbitary bot commands
466 webservice.web_map '/dispatch',
467   :action => :handle_dispatch,
468   :method => 'POST',
469   :auth_path => 'public'
470
471 webservice.default_auth('*', false)
472 webservice.default_auth('public', true)
473