#!/usr/bin/env python # -*- encoding: utf-8 -*- # # woof -- an ad-hoc single file webserver # Copyright (C) 2004-2009 Simon Budig <simon@budig.de> # # 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. # # A copy of the GNU General Public License is available at # http://www.fsf.org/licenses/gpl.txt, you can also write to the # Free Software Foundation, Inc., 59 Temple Place - Suite 330, # Boston, MA 02111-1307, USA. # Darwin support with the help from Mat Caughron, <mat@phpconsulting.com> # Solaris support by Colin Marquardt, <colin.marquardt@zmd.de> # FreeBSD support with the help from Andy Gimblett, <A.M.Gimblett@swansea.ac.uk> # Cygwin support by Stefan Reichör <stefan@xsteve.at> # tarfile usage suggested by Morgan Lefieux <comete@geekandfree.org> # File upload support loosely based on code from Stephen English <steve@secomputing.co.uk> import sys, os, errno, socket, getopt, commands, tempfile import cgi, urllib, BaseHTTPServer from SocketServer import ThreadingMixIn import ConfigParser import shutil, tarfile, zipfile import struct maxdownloads = 1 TM = object compressed = 'gz' upload = False class EvilZipStreamWrapper(TM): def __init__ (self, victim): self.victim_fd = victim self.position = 0 self.tells = [] self.in_file_data = 0 def tell (self): self.tells.append (self.position) return self.position def seek (self, offset, whence = 0): if offset != 0: if offset == self.tells[0] + 14: # the zipfile module tries to fix up the file header. # write Data descriptor header instead, # the next write from zipfile # is CRC, compressed_size and file_size (as required) self.write ("PK\007\010") elif offset == self.tells[1]: # the zipfile module goes to the end of the file. The next # data written definitely is infrastructure (in_file_data = 0) self.tells = [] self.in_file_data = 0 else: raise "unexpected seek for EvilZipStreamWrapper" def write (self, data): # only test for headers if we know that we're not writing # (potentially compressed) data. if self.in_file_data == 0: if data[:4] == zipfile.stringFileHeader: # fix the file header for extra Data descriptor hdr = list (struct.unpack (zipfile.structFileHeader, data[:30])) hdr[3] |= (1 << 3) data = struct.pack (zipfile.structFileHeader, *hdr) + data[30:] self.in_file_data = 1 elif data[:4] == zipfile.stringCentralDir: # fix the directory entry to match file header. hdr = list (struct.unpack (zipfile.structCentralDir, data[:46])) hdr[5] |= (1 << 3) data = struct.pack (zipfile.structCentralDir, *hdr) + data[46:] self.position += len (data) self.victim_fd.write (data) def __getattr__ (self, name): return getattr (self.victim_fd, name) # Utility function to guess the IP (as a string) where the server can be # reached from the outside. Quite nasty problem actually. def find_ip (): # we get a UDP-socket for the TEST-networks reserved by IANA. # It is highly unlikely, that there is special routing used # for these networks, hence the socket later should give us # the ip address of the default route. # We're doing multiple tests, to guard against the computer being # part of a test installation. candidates = [] for test_ip in ["192.0.2.0", "198.51.100.0", "203.0.113.0"]: s = socket.socket (socket.AF_INET, socket.SOCK_DGRAM) s.connect ((test_ip, 80)) ip_addr = s.getsockname ()[0] s.close () if ip_addr in candidates: return ip_addr candidates.append (ip_addr) return candidates[0] # Main class implementing an HTTP-Requesthandler, that serves just a single # file and redirects all other requests to this file (this passes the actual # filename to the client). # Currently it is impossible to serve different files with different # instances of this class. class FileServHTTPRequestHandler (BaseHTTPServer.BaseHTTPRequestHandler): server_version = "Simons FileServer" protocol_version = "HTTP/1.0" filename = "-" def log_request (self, code='-', size='-'): if code == 200: BaseHTTPServer.BaseHTTPRequestHandler.log_request (self, code, size) def do_POST (self): global maxdownloads, upload if not upload: self.send_error (501, "Unsupported method (POST)") return maxdownloads -= 1 if maxdownloads < 1: httpd.shutdown() # taken from # http://mail.python.org/pipermail/python-list/2006-September/402441.html ctype, pdict = cgi.parse_header (self.headers.getheader ('Content-Type')) form = cgi.FieldStorage (fp = self.rfile, headers = self.headers, environ = {'REQUEST_METHOD' : 'POST'}, keep_blank_values = 1, strict_parsing = 1) if not form.has_key ("upfile"): self.send_error (403, "No upload provided") return upfile = form["upfile"] if not upfile.file or not upfile.filename: self.send_error (403, "No upload provided") return upfilename = upfile.filename if "\\" in upfilename: upfilename = upfilename.split ("\\")[-1] upfilename = os.path.basename (upfile.filename) destfile = None for suffix in ["", ".1", ".2", ".3", ".4", ".5", ".6", ".7", ".8", ".9"]: destfilename = os.path.join (".", upfilename + suffix) try: destfile = os.open (destfilename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0644) break except OSError, e: if e.errno == errno.EEXIST: continue raise if not destfile: upfilename += "." destfile, destfilename = tempfile.mkstemp (prefix = upfilename, dir = ".") print >>sys.stderr, "accepting uploaded file: %s -> %s" % (upfilename, destfilename) shutil.copyfileobj (upfile.file, os.fdopen (destfile, "w")) if upfile.done == -1: self.send_error (408, "upload interrupted") txt = """\ <html> <head><title>Woof Upload</title></head> <body> <h1>Woof Upload complete</title></h1> <p>Thanks a lot!</p> </body> </html> """ self.send_response (200) self.send_header ("Content-Type", "text/html") self.send_header ("Content-Length", str (len (txt))) self.end_headers () self.wfile.write (txt) return def do_GET (self): global maxdownloads, compressed, upload # Form for uploading a file if upload: txt = """\ <html> <head><title>Woof Upload</title></head> <body> <h1>Woof Upload</title></h1> <form name="upload" method="POST" enctype="multipart/form-data"> <p><input type="file" name="upfile" /></p> <p><input type="submit" value="Upload!" /></p> </form> </body> </html> """ self.send_response (200) self.send_header ("Content-Type", "text/html") self.send_header ("Content-Length", str (len (txt))) self.end_headers () self.wfile.write (txt) return # Redirect any request to the filename of the file to serve. # This hands over the filename to the client. self.path = urllib.quote (urllib.unquote (self.path)) location = "/" + urllib.quote (os.path.basename (self.filename)) if os.path.isdir (self.filename): if compressed == 'gz': location += ".tar.gz" elif compressed == 'bz2': location += ".tar.bz2" elif compressed == 'zip': location += ".zip" else: location += ".tar" if self.path != location: txt = """\ <html> <head><title>302 Found</title></head> <body>302 Found <a href="%s">here</a>.</body> </html>\n""" % location self.send_response (302) self.send_header ("Location", location) self.send_header ("Content-Type", "text/html") self.send_header ("Content-Length", str (len (txt))) self.end_headers () self.wfile.write (txt) return maxdownloads -= 1 if maxdownloads < 1: httpd.shutdown() type = None if os.path.isfile (self.filename): type = "file" elif os.path.isdir (self.filename): type = "dir" elif self.filename == "-": type = "stdin" if not type: print >> sys.stderr, "can only serve files, directories or stdin. Aborting." sys.exit (1) self.send_response (200) self.send_header ("Content-Type", "application/octet-stream") if os.path.isfile (self.filename): self.send_header ("Content-Length", os.path.getsize (self.filename)) self.end_headers () try: if type == "file": datafile = file (self.filename) shutil.copyfileobj (datafile, self.wfile) datafile.close () elif type == "dir": if compressed == 'zip': ezfile = EvilZipStreamWrapper (self.wfile) zfile = zipfile.ZipFile (ezfile, 'w', zipfile.ZIP_DEFLATED) stripoff = os.path.dirname (self.filename) + os.sep for root, dirs, files in os.walk (self.filename): for f in files: filename = os.path.join (root, f) if filename[:len (stripoff)] != stripoff: raise RuntimeException, "invalid filename assumptions, please report!" zfile.write (filename, filename[len (stripoff):]) zfile.close () else: tfile = tarfile.open (mode=('w|' + compressed), fileobj=self.wfile) tfile.add (self.filename, arcname=os.path.basename(self.filename)) tfile.close () elif type == "stdin": datafile = sys.stdin shutil.copyfileobj (datafile, self.wfile) except Exception, e: print e print >>sys.stderr, "Connection broke. Aborting" class ThreadedHTTPServer(ThreadingMixIn, BaseHTTPServer.HTTPServer): """Handle requests in a separate thread""" def serve_files (filename, maxdown = 1, ip_addr = '', port = 8080): global maxdownloads, httpd maxdownloads = maxdown # We have to somehow push the filename of the file to serve to the # class handling the requests. This is an evil way to do this... FileServHTTPRequestHandler.filename = filename try: httpd = ThreadedHTTPServer ((ip_addr, port), FileServHTTPRequestHandler) except socket.error: print >>sys.stderr, "cannot bind to IP address '%s' port %d" % (ip_addr, port) sys.exit (1) if not ip_addr: ip_addr = find_ip () if ip_addr: print "Now serving on http://%s:%s/" % (ip_addr, httpd.server_port) httpd.serve_forever () def usage (defport, defmaxdown, errmsg = None): name = os.path.basename (sys.argv[0]) print >>sys.stderr, """ Usage: %s [-i <ip_addr>] [-p <port>] [-c <count>] [<file>] %s [-i <ip_addr>] [-p <port>] [-c <count>] [-z|-j|-Z|-u] <dir> %s [-i <ip_addr>] [-p <port>] [-c <count>] -s %s [-i <ip_addr>] [-p <port>] [-c <count>] -U Serves a single file <count> times via http on port <port> on IP address <ip_addr>. When no filename is specified, or set to '-', then stdin will be read. When a directory is specified, an tar archive gets served. By default it is gzip compressed. You can specify -z for gzip compression, -j for bzip2 compression, -Z for ZIP compression or -u for no compression. You can configure your default compression method in the configuration file described below. When -s is specified instead of a filename, %s distributes itself. When -U is specified, woof provides an upload form and allows uploading files. defaults: count = %d, port = %d You can specify different defaults in two locations: /etc/woofrc and ~/.woofrc can be INI-style config files containing the default port and the default count. The file in the home directory takes precedence. The compression methods are "off", "gz", "bz2" or "zip". Sample file: [main] port = 8008 count = 2 ip = 127.0.0.1 compressed = gz """ % (name, name, name, name, name, defmaxdown, defport) if errmsg: print >>sys.stderr, errmsg print >>sys.stderr sys.exit (1) def main (): global cpid, upload, compressed filename = '-' maxdown = 1 port = 8080 ip_addr = '' config = ConfigParser.ConfigParser() config.read (['/etc/woofrc', os.path.expanduser('~/.woofrc')]) if config.has_option ('main', 'port'): port = config.getint ('main', 'port') if config.has_option ('main', 'count'): maxdown = config.getint ('main', 'count') if config.has_option ('main', 'ip'): ip_addr = config.get ('main', 'ip') if config.has_option ('main', 'compressed'): formats = { 'gz' : 'gz', 'true' : 'gz', 'bz' : 'bz2', 'bz2' : 'bz2', 'zip' : 'zip', 'off' : '', 'false' : '' } compressed = config.get ('main', 'compressed') compressed = formats.get (compressed, 'gz') defaultport = port defaultmaxdown = maxdown try: options, filenames = getopt.getopt (sys.argv[1:], "hUszjZui:c:p:") except getopt.GetoptError, desc: usage (defaultport, defaultmaxdown, desc) for option, val in options: if option == '-c': try: maxdown = int (val) if maxdown <= 0: raise ValueError except ValueError: usage (defaultport, defaultmaxdown, "invalid download count: %r. " "Please specify an integer >= 0." % val) elif option == '-i': ip_addr = val elif option == '-p': try: port = int (val) except ValueError: usage (defaultport, defaultmaxdown, "invalid port number: %r. Please specify an integer" % val) elif option == '-s': filenames.append (__file__) elif option == '-h': usage (defaultport, defaultmaxdown) elif option == '-U': upload = True elif option == '-z': compressed = 'gz' elif option == '-j': compressed = 'bz2' elif option == '-Z': compressed = 'zip' elif option == '-u': compressed = '' else: usage (defaultport, defaultmaxdown, "Unknown option: %r" % option) if upload: if len (filenames) > 0: usage (defaultport, defaultmaxdown, "Conflicting usage: simultaneous up- and download not supported.") filename = None else: if len (filenames) == 1: if filenames[0] != "-": filename = os.path.abspath (filenames[0]) if not os.path.exists (filename): usage (defaultport, defaultmaxdown, "Can only serve single files/directories.") if not (os.path.isfile (filename) or os.path.isdir (filename)): usage (defaultport, defaultmaxdown, "%s: Neither file nor directory" % filenames[0]) else: filename = "-" serve_files (filename, maxdown, ip_addr, port) if __name__=='__main__': try: main () except KeyboardInterrupt: pass