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