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