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