#!/usr/bin/ruby -w # $Id: httpd,v 1.6.1.6 2002/04/01 15:52:20 hip Exp $ # # httpd daemon for local use, supports CGI # http://www.xs4all.nl/~hipster # # Copyright (C) 2000-2001 Michel van de Ven # 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 require "socket" require "thread" REVISION = /\d+(\.\d+)*/.match("$Revision: 1.6.1.6 $")[0] ########################################################################### # 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 # cache size in bytes CACHESIZE = 4 * 2 ** 20 # 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 def initialize socket, mimemap @socket = socket @header = @method = @file = @content = @mtype = "" contentlength = 0 while (line = @socket.gets) != nil break if line == "\r\n" or line == "\n" @header << line case line when /^(GET|POST) (.*) HTTP\/\d+\.\d+/ # XXX delay this to the handlers, so they can indicate failures? Log.puts "[" + @socket.peeraddr[3] + "] " + line.chomp("\r\n") @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 requests @file = "/index.html" if @file == "/" # deduce mimetype from filename suffix if @file =~ /\.([a-z]+)$/ @mtype = mimemap.typeof($1) else @mtype = "text/plain" 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) @rqueue = Queue.new # request queue, handler threads query this @nrequest = 0 @cgi_mutex = Mutex.new @cache = Cache.new(CACHESIZE) ["SIGTERM", "SIGHUP", "SIGINT"].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 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 socket.close end end def handle_cgi req script, param = req.file.split("?") if 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 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") + "

Directory listing of '#{req.file}'

\n" up = File.expand_path(req.file + "/..", DOCROOT) emit req.socket, %!up to higher level 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, "

" end def handle_file req # content = File.open(DOCROOT + req.file).read # don't use the cache content = @cache.lookup(DOCROOT + req.file) 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" # 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 shutdown @socket.close Log.puts @cache.statistics Log.puts "#{@nrequest} requests handled" Log.puts "shutdown @ #{Time.now.to_s}" Log.close exit end end ########################################################################### class Cache def initialize max_size @max_size = max_size @lookups = @hits = 0 @mutex = Mutex.new purge end def purge @mutex.lock @cache = Hash.new @inuse = 0 @mutex.unlock end def lookup filename # FIXME this big lock currently serialises all access, but that makes # e.g. the threading in httpd quite useless, because serialised. # Try to make the locking more fine grained, only on cache-modifying # operations -> factor out all modifying actions and put locks on those # functions: classic 'many readers, one writer' scenario, where readers # can come in together, readers can only read while nothing is written, # and writers can only write if nothing is read. @mutex.lock result = nil unless test(?f, filename) # is not a regular file, or it disappeared if @cache.has_key?(filename) remove_entry filename end return nil end @lookups += 1 if @cache.has_key?(filename) # file is in cache statinfo = File.stat(filename) if statinfo.mtime <= @cache[filename].file_mtime # cache contents are up to date, done @hits += 1 @cache[filename].update_last_access result = @cache[filename].contents else # cache entry is stale, reload remove_entry filename result = add_entry filename end else # file not in cache result = add_entry filename end @mutex.unlock result end def statistics "(size #{@max_size/1024} files #{@cache.size} inuse #{@inuse/1024} " + "free #{(@max_size - @inuse)/1024} " + "lookups #{@lookups} hits #{@hits} " + "ratio #{format('%.3f', @hits.to_f / @lookups)})" end private def remove_entry filename # TODO lock mutex here @inuse -= @cache[filename].file_size @cache.delete(filename) end def add_entry filename # TODO lock mutex here entry = Entry.new(filename) result = entry.contents if entry.file_size <= @max_size # file fits in cache if entry.file_size > (@max_size - @inuse) # make some room make_room entry.file_size end # add entry to cache @cache[filename] = entry @inuse += entry.file_size else # file doesn't fit in cache, just return the contents end result end def make_room size lru_list = @cache.values.sort while size > (@max_size - @inuse) lru = lru_list.shift remove_entry lru.filename end end class Entry attr_reader :filename, :contents, :file_mtime, :file_size, :last_access def initialize filename @filename = filename File.open(filename) { |f| @contents = f.read } statinfo = File.stat(filename) @file_mtime = statinfo.mtime @file_size = statinfo.size @last_access = Time.now end def update_last_access @last_access = Time.now end def <=>(other) @last_access <=> other.last_access end def hash @filename.hash end def eql?(other) hash == other.hash end def to_s "(#{@filename} #{@last_access})" end alias inspect to_s 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