#!/usr/bin/env ruby require 'fuserofs' require 'socket' require 'openssl' require 'timeout' # # A ruby HTTP filesystem for fuse # # rhttpfs supports proxies, basic authentication, ssl # read requests uses http Range to get only required bytes, with a configurable temporary cache to allow seamless streaming # # Usage: # ruby [-v] [-d] rhttpfs.rb httpurl mountpoint # # httpurl exemples: # # http://www.site.com/ # # http://log:pass@www.site.com:8080/mydir/ # # http://virtualhost.undef@10.0.0.1/mydir/ # # http-proxy://l:p@proxy.com:2121/https://www.blabla.com:8000/ # # on the mounted filesystem, use : # * echo 1 > [mountdir]/.rhttpfs-ctrl/umount to unmount # * echo 1 > [mountdir]/.rhttpfs-ctrl/flush to empty the directory listing / file size cache # * echo > [mountdir]/.rhttpfs-ctrl/minreadsize to change the minimal size of http requests for file content # # Author:: Yoann Guillot (at ofjj.net) # Copyright:: Copyright (c) 2006 Yoann Guillot # Licence:: Redistributes under the terms of the GPL version 2, with specific permission to be linked to OpenSSL without invoking GPL clause 2 (b) # # # Changelog: # before: silent updates # 05/06/07: lol @ date # adopt great patch from Arnaud Cornet (allow HTTP/1.0 replies) # add entities decoder in dir listing # 25/12/11: add chunked transer-encoding support (dir listing) class HttpFS < FuseROFS def http_get(path, range=nil, method='GET') path = (@rootdir + path).gsub(/\/+/, '/') path = path.gsub(/[^a-zA-Z0-9_.\/-]/) { |c| '%' << c.unpack('H2').first } off, offend = range.split('-').map { |i| i.to_i } if range range = "Range: bytes=#{range}\r\n" if range retried = false begin info "#{method} #{path}" + (range ? " [#{off}, #{offend-off+1}]" : '') @socket.write "#{method} #{"http://#@host/" if @proxyh and not @use_ssl}#{path} HTTP/1.1\r\n" << "Host: #{@vhost}\r\n" << "#{@proxylp}" << "#{@loginpass}" << "#{range}" << "\r\n" buf = nil begin Timeout::timeout(20) { raise 'eof' if not buf = @socket.readpartial(4096) } rescue Timeout::Error return '' end raise "wtf #{buf.to_s[0, 64].inspect}" if buf !~ /^HTTP\/1.[01] (\d+) / case $1.to_i when 200, 206 # ok when 302 info '302 redirect to ' << buf[/^Location: (.*?)\r?$/, 1].inspect when 416 # bad range : beyond end of file @socket.close return '' else info 'unhandled http response:', buf raise 'unhandled http response:'+ buf end headlen = buf.index("\r\n\r\n") + 4 head = buf[0, headlen-4] len = $1.to_i if head =~ /^Content-length: (\d+)/i debug head if method == 'HEAD' # return file size len or 2**20 else # return body if len data = buf[headlen..-1] data << @socket.read(len - data.length) if len > data.length elsif head =~ /^Transfer-encoding: Chunked/i raw = buf[headlen..-1] data = '' loop do if not raw.empty? if not ll = raw.index("\r\n") raw << @socket.read(4096) next end line = raw[0, ll] raw[0, ll+2] = '' else line = @socket.gets end chunksize = line.to_i(16) break if chunksize == 0 if raw.length > 0 if raw.length < chunksize data << raw data << @socket.read(chunksize-raw.length) raw = '' @socket.read 2 else data << raw[0, chunksize] raw[0, chunksize] = '' end else data << @socket.read(chunksize) @socket.read 2 end end else data = buf[headlen..-1] data << @socket.read if buf.length >= 4096 end data end rescue info "exception: #{$!.class} #{$!.message}" raise if retried retried = true connect_socket retry end end def connect_socket debug 'connecting' if @proxyh @socket = TCPSocket.new @proxyh, @proxyp if @use_ssl @socket.puts "CONNECT #@host:#@port HTTP/1.1\r\n" << @proxylp << "\r\n" buf = @socket.gets raise "non http answer #{buf[1..100].inspect}" if buf !~ /^HTTP\/1.. (\d+) / raise "CONNECT bad response: #{buf.inspect}" if $1.to_i != 200 nil until @socket.gets.chomp.empty? end else @socket = TCPSocket.new @host, @port end if @use_ssl @socket = OpenSSL::SSL::SSLSocket.new(@socket, OpenSSL::SSL::SSLContext.new) @socket.sync_close = true @socket.connect end end def initialize(url) super() raise 'Unparsed url' unless md = %r{(?:http-proxy://(\w+:\w+@)?([\w.-]+)(:\d+)?/)?http(s)?://(\w+:\w+@)?([\w.-]+@)?([\w.-]+)(:\d+)?(/.*)}.match(url) @control_dir = '.rhttpfs-ctrl/' proxylp, @proxyh, proxyp, @use_ssl, loginpass, vhost, @host, port, @rootdir = md.captures @proxyp = proxyp ? proxyp[1..-1].to_i : 3128 @port = port ? port[1..-1].to_i : (@use_ssl ? 443 : 80) @proxylp = proxylp ? "Proxy-Authorization: Basic #{[proxylp.chop].pack('m').chomp}\r\n" : '' @loginpass = loginpass ? "Authorization: Basic #{[loginpass.chop].pack('m').chomp}\r\n" : '' @vhost = vhost ? vhost.chop : @host @socket = nil @cache_timeout = 2*3600 @minreadsize = 64*1024 file_accessor_int :minreadsize, :cache_timeout file_accessor :rootdir, :host, :port, :vhost, :proxyh, :proxyp connect_socket debug 'connected' end def get_file_content(path, off) http_get(path, "#{off}-#{off+@minreadsize-1}") end def directory_listing(path) connect_socket if path == '/' list = [] p = http_get(path) debug "dirlist #{path}" debug p p.scan(/]*href=(["'])(.*?)\1/i) { match = $2 match.gsub!(/%([a-fA-F0-9]{2})/) { $1.hex.chr } match.gsub!(/&(.*?);/) { |o| {'amp' => '&', 'quot' => '"', 'apos' => '\'', 'lt' => '<', 'gt' => '>'}.fetch($1, o) } next if match == '../' or match == '/' or match[0] == ?? or match[0..-2].index '/' list << match } list end def get_file_size(path) http_get(path, nil, 'HEAD') end end if $0 == __FILE__ begin Timeout::timeout(30, RuntimeError) { HttpFS.new(ARGV.shift) }.mount_under(ARGV.shift) rescue puts $! puts "Usage : #$0 [http-proxy://[login:pass@]host[:port]/]http[s]://[login:pass@][virtualhost@]host[:port]/[rootdir/] " end end