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