#!/usr/bin/ruby -w # $Id: httpd,v 1.11 2004/09/14 07:21:39 bubi Exp $ # # httpd daemon for local use, supports CGI # http://www.xs4all.nl/~hipster # # Copyright (C) 2000-2004 Michel van de Ven # Copyright (C) 2004 Patric Mueller # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # Changes from 1.6.1.6 # - removed Cache code # - slightly changed appearance of directory listing # - searches for index.html also in directories # - quits now also on SIGQUIT # - added VERBOSE_LOG # - 404 gets logged # - bugfix: crashed when cgi-file didn't exists # - bugfix: Connection by peer error(e.g. by nmap) let httpd crash (14.09.2004) # # ToDo: # - bug: http://domain/directory//page.html confuses relative links # - improve directory listing to make it look more like apache # - change CONSTANTS to @global_variables and enable config file # - implement HEAD require "socket" require "thread" REVISION = 1.8 ########################################################################### # modify this to suit your setup and needs # # "0.0.0.0" to serve everybody HOST = "localhost" PORT = 8080 # number of simultaneous handler threads THREADS = 4 # root of the html tree DOCROOT = File.expand_path("~/lib/html") # root of the cgi scripts, relative to DOCROOT CGIROOT = "/cgi" # location of the logfile; nil = don't log anything LOGFILE = File.expand_path("~/lib/html/httpd.log") # max number of log rotations; if <= 0: don't rotate # This value is ignored if LOGFILE is nil. LOGROTATIONS = 8 # if true, log all http headers VERBOSE_LOG = true # if true, detach process from terminal START_AS_DAEMON = true # change to userids USER_ID = 1000 GROUP_ID = 100 # XXX load configuration from file, temporarily removed #load(File.expand_path("~/src/ruby/httpd/httpd.rc")) ########################################################################### class MimeMap def initialize # XXX see also end of header parsing # could also be text/plain or text/html @map = Hash.new("*/*") File.foreach("/etc/mime.types") { |line| next if line =~ /^#|^\s*$/ type, extlist = line.chomp.split(/\t+/) if extlist extlist.split(/ /).each { |ext| @map[ext] = type } end } end def typeof ext @map[ext] end end ########################################################################### class Log if LOGFILE.nil? def Log.open(stream = nil) end def Log.puts(txt) end def Log.close() end else def Log.open stream = LOGFILE unless stream.is_a?(IO) if LOGROTATIONS > 0 if test(?f, LOGFILE) (LOGROTATIONS - 1).downto(0) { |n| target = "#{LOGFILE}.#{n}" if test(?f, target) File.rename(target, "#{LOGFILE}.#{n + 1}") end } culprit = "#{LOGFILE}.#{LOGROTATIONS}" if test(?f, culprit) File.delete(culprit) end File.rename(LOGFILE, "#{LOGFILE}.0") end end end begin @io = (stream.is_a? IO) ? stream : File.open(stream, "a") rescue $stderr.puts "httpd: cannot open stream for writing; logging to stderr" @io = $stderr end end def Log.close @io.close end def Log.puts txt @io.print txt.chomp + "\n" @io.flush end end end ########################################################################### class Request attr_reader :socket, :header, :method, :file, :mtype, :content, :fatal_error def initialize socket, mimemap @socket = socket @header = @method = @file = @content = @mtype = "" @fatal_error = false; contentlength = 0 Log.puts "" if VERBOSE_LOG begin # output from address and time if VERBOSE_LOG Log.puts "[" + @socket.peeraddr[2..3].join(", ") + "]" Log.puts Time.new.to_s end while (line = @socket.gets) != nil break if line == "\r\n" or line == "\n" @header << line Log.puts line if VERBOSE_LOG case line when /^(GET|POST) (.*) HTTP\/\d+\.\d+/ # XXX delay this to the handlers, so they can indicate failures? if not VERBOSE_LOG then Log.puts "[" + @socket.peeraddr[3] + "] " + line.chomp("\r\n") end @method = $1 @file = $2 when /^content-length: (\d+)/i contentlength = $1.to_i end end if contentlength != 0 @content = @socket.read contentlength end # default to index.html for root and directory requests @file = @file + "/index.html" if test(?f, DOCROOT + @file + "/index.html") # deduce mimetype from filename suffix if @file =~ /\.([a-z]+)$/ @mtype = mimemap.typeof($1) else @mtype = "text/plain" end rescue Exception @fatal_error = true; Log.puts $!.to_s + "("+$!.class.to_s+")" end end end ########################################################################### class Httpd HTTP_200 = "HTTP/1.0 200 OK\n" HTTP_404 = "HTTP/1.0 404 Not found\n" def initialize Log.open @hostname = HOST @port = PORT @mimemap = MimeMap.new @socket = TCPserver.new(@hostname, @port) change_ids @rqueue = Queue.new # request queue, handler threads query this @nrequest = 0 @cgi_mutex = Mutex.new ["SIGTERM", "SIGHUP", "SIGINT", "SIGQUIT"].each { |sig| trap sig, lambda { shutdown } } end def listen Log.puts "-" * 79 + "\nstart @ " + Time.now.to_s + "\nhttpd/#{REVISION}; " + "pid #{$$} with #{THREADS} threads listening on #{@hostname}:#{@port}" # start handler threads Thread.abort_on_exception = true # XXX debugging only? THREADS.times { Thread.new { handler } } # main accept loop loop do begin @rqueue.push @socket.accept @nrequest += 1 rescue Log.puts "listener: socket exception: " + $! end end end private def handler cgiroot_regex = Regexp.new("^#{CGIROOT}") loop do socket = @rqueue.pop req = Request.new socket, @mimemap if not req.fatal_error then if cgiroot_regex.match(req.file) handle_cgi req else if test(?f, DOCROOT + req.file) handle_file req elsif test(?d, DOCROOT + req.file) handle_dir req else handle_error req end end end socket.close end end def handle_cgi req script, param = req.file.split("?") if File.exists?("#{DOCROOT}#{script}") && File.stat("#{DOCROOT}#{script}").executable? begin @cgi_mutex.lock ENV["SERVER_SOFTWARE"] = "Ruby-httpd/#{REVISION}" ENV["REMOTE_HOST"] = ENV["HOSTNAME"] ENV["REQUEST_METHOD"] = req.method ENV["SCRIPT_NAME"] = script ENV["QUERY_STRING"] = param ENV["CONTENT_LENGTH"] = req.content.length != 0 ? req.content.length.to_s : nil cgi = IO.popen("#{DOCROOT}#{script}", "r+") if req.content.length != 0 cgi << req.content #cgi.flush # commented out, did hang with some shell scripts end result = cgi.read rescue result = header("text/html") + "

Error executing script '#{script}'

" ensure cgi.close @cgi_mutex.unlock end emit req.socket, HTTP_200 + result else handle_error req end end def handle_dir req emit req.socket, HTTP_200 + header("text/html") + "\n\n\nIndex of #{req.file}\n\n\n\n

Index of #{req.file}

\n" up = File.expand_path(req.file + "/..", DOCROOT) emit req.socket, %!

Parent Directory\n! begin dir = Dir.open(DOCROOT + req.file) dir.sort.each { |entry| next if entry == "." or entry == ".." link = entry link += "/" if test(?d, DOCROOT + req.file + "/" + link) emit req.socket, %! #{link}\n! } ensure dir.close end emit req.socket, "

" emit req.socket, "Ruby-httpd #{REVISION} at #@hostname:#@port" end def handle_file req # read file and send it to client content = File.open(DOCROOT + req.file).read emit req.socket, HTTP_200 + header(req.mtype) emit req.socket, content end def handle_error req emit req.socket, HTTP_404 + header("text/html") + "

404 Not Found

\n" + "Request header:
#{req.header}
" + "
Ruby-httpd #{REVISION} at #@hostname:#@port" Log.puts "404 not found: " + req.file + "\n\n" # XXX OR # if req.mtype =~ /^text\// # emit req.socket, "HTTP/1.0 404 Not found\n" + # "Content-type: text/html\n\n" + # "

Object not found

\n" + # "Request header:
#{req.header}
" # else # emit req.socket, "HTTP/1.0 404 Not found\n" # end end def header mimetype "Content-type: #{mimetype}\n\n" # XXX "Content-type: #{mimetype}\nConnection: close\n\n" end def emit socket, data begin socket.write data rescue Log.puts "emit: socket exception: " + $! end end def change_ids Process.uid = USER_ID Process.gid = GROUP_ID Process.euid = USER_ID Process.egid = GROUP_ID end def shutdown @socket.close Log.puts "#{@nrequest} requests handled" Log.puts "shutdown @ #{Time.now.to_s}" Log.close exit end end ########################################################################### # TODO move this into httpd. Provide both a background and foreground func. def daemon if fork == nil Process.setsid # create new session, disconnect from tty [$stdin, $stdout, $stderr].each { |stream| stream.reopen "/dev/null" } Httpd.new.listen end end # XXX debug stuff if false LOGFILE = $stderr Httpd.new.listen else daemon end