icaprb-server 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE +11 -0
- data/README.md +50 -0
- data/README.rdoc +34 -0
- data/Rakefile +17 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/start_server.rb +36 -0
- data/icaprb-server.gemspec +23 -0
- data/lib/icaprb/icapuri.rb +24 -0
- data/lib/icaprb/server/constants.rb +83 -0
- data/lib/icaprb/server/data_structures.rb +182 -0
- data/lib/icaprb/server/request_parser.rb +295 -0
- data/lib/icaprb/server/response.rb +168 -0
- data/lib/icaprb/server/services.rb +241 -0
- data/lib/icaprb/server/version.rb +6 -0
- data/lib/icaprb/server.rb +211 -0
- metadata +107 -0
@@ -0,0 +1,295 @@
|
|
1
|
+
require 'uri'
|
2
|
+
module ICAPrb
|
3
|
+
module Server
|
4
|
+
# The Parser module contains methods to parse ICAP or HTTP messages.
|
5
|
+
module Parser
|
6
|
+
|
7
|
+
# This class indicates an error while parsing the request
|
8
|
+
class ICAP_Parse_Error < RuntimeError
|
9
|
+
end
|
10
|
+
# This class indicates an error while parsing the request
|
11
|
+
class HTTP_Parse_Error < RuntimeError
|
12
|
+
end
|
13
|
+
|
14
|
+
# This module contains the Methods included in the request parser as well as in the Service.
|
15
|
+
# It is used to provide parsing methods which are used to read the full request.
|
16
|
+
module ChunkedEncodingHelper
|
17
|
+
# this method reads a chunk from an Object, which supports the method "gets" and it is usually a socket.
|
18
|
+
# it will return the data sent over the socket and return it. It will also return the information,
|
19
|
+
# if the chunk had an ieof flag set. ieof means there is no data available and the server must not ask
|
20
|
+
# to continue.
|
21
|
+
#
|
22
|
+
# params:
|
23
|
+
# +io+:: socket
|
24
|
+
#
|
25
|
+
# returns:
|
26
|
+
# [string data, ieof_set]
|
27
|
+
private
|
28
|
+
def read_chunk(io)
|
29
|
+
str = "\r\n"
|
30
|
+
until (str = io.gets) != "\r\n"
|
31
|
+
end
|
32
|
+
total_length, ieof = read_chunk_length(str)
|
33
|
+
result = ''
|
34
|
+
until result.length >= total_length
|
35
|
+
result += io.gets
|
36
|
+
end
|
37
|
+
if total_length == 0
|
38
|
+
return :eof,ieof
|
39
|
+
end
|
40
|
+
# cut the protocol overhead
|
41
|
+
return result[0...total_length], ieof
|
42
|
+
end
|
43
|
+
|
44
|
+
# returns the length of the chunk as first argument and if ieof is set as second
|
45
|
+
private
|
46
|
+
def read_chunk_length(line)
|
47
|
+
x = line.scan(/\A([A-Fa-f0-9]+)(; ieof)?\r\n/).first
|
48
|
+
[x[0].to_i(16), !x[1].nil?]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# parse header line and returns the parsed line as Array which has the name on index 0 and the value on index 1
|
53
|
+
def parse_header(line)
|
54
|
+
# remove newlines
|
55
|
+
line = line.gsub!("\r",'')
|
56
|
+
line = line.gsub!("\n",'')
|
57
|
+
line.split(':',2).map {|x| x.strip}
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
end
|
62
|
+
# This class is used to parse ICAP requests. It includes the Parser module.
|
63
|
+
class ICAPRequestParser
|
64
|
+
include Parser
|
65
|
+
|
66
|
+
# initializes a new ICAP request parser
|
67
|
+
# params:
|
68
|
+
# +io+:: the Socket
|
69
|
+
def initialize(io)
|
70
|
+
@io = io
|
71
|
+
end
|
72
|
+
|
73
|
+
# This method parses the request line and returns an Hash with the components
|
74
|
+
# * icap_method:: can be :req_mod, :resp_mod or :options
|
75
|
+
# * uri:: an URI
|
76
|
+
# * version:: the used version of ICAP
|
77
|
+
# @raise ICAP_Parse_Error if something is invalid
|
78
|
+
def parse_icap_request_line(line)
|
79
|
+
str_method, str_uri,_,_,_, str_version = line.scan(/(REQMOD|RESPMOD|OPTIONS) (((icap[s]?:)?[^\s]+)|(\*)) ICAP\/([\d\.]+)/i).first
|
80
|
+
raise ICAP_Parse_Error.new "invalid icap Method in RequestLine #{line}" if str_method.nil?
|
81
|
+
case str_method.upcase
|
82
|
+
when 'REQMOD'
|
83
|
+
icap_method = :request_mod
|
84
|
+
when 'RESPMOD'
|
85
|
+
icap_method = :response_mod
|
86
|
+
when 'OPTIONS'
|
87
|
+
icap_method = :options
|
88
|
+
else
|
89
|
+
raise ICAP_Parse_Error.new 'The request type is not known'
|
90
|
+
end
|
91
|
+
uri = URI(str_uri)
|
92
|
+
unless icap_method && uri && str_version
|
93
|
+
raise ICAP_Parse_Error.new 'The request line is not complete.'
|
94
|
+
end
|
95
|
+
{icap_method: icap_method, uri: uri, version: str_version}
|
96
|
+
end
|
97
|
+
|
98
|
+
# parse all headers
|
99
|
+
def parse
|
100
|
+
line = "\r\n"
|
101
|
+
while line == "\r\n"
|
102
|
+
line = @io.gets
|
103
|
+
end
|
104
|
+
return nil unless line
|
105
|
+
icap_req_line = parse_icap_request_line(line)
|
106
|
+
icap_headers = {}
|
107
|
+
until (line = @io.gets) == "\r\n"
|
108
|
+
parsed_header = parse_header(line)
|
109
|
+
icap_headers[parsed_header[0]] = parsed_header[1]
|
110
|
+
end
|
111
|
+
{request_line: icap_req_line, header: icap_headers}
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# parses HTTP Headers
|
116
|
+
class HTTPHeaderParser
|
117
|
+
include Parser
|
118
|
+
|
119
|
+
# initializes a new HTTPHeaderParser
|
120
|
+
# params:
|
121
|
+
# +io+:: the socket
|
122
|
+
# +is_request+:: value to say if it is an response or an request because the request line / status line
|
123
|
+
# look different
|
124
|
+
def initialize(io,is_request = true)
|
125
|
+
@io = io
|
126
|
+
@length_read = 0
|
127
|
+
@is_request = is_request
|
128
|
+
end
|
129
|
+
# This method parses the request line and returns an Hash with the components
|
130
|
+
# * http_method:: a string
|
131
|
+
# * uri:: an URI
|
132
|
+
# * version:: the used version of HTTP
|
133
|
+
# @raise HTTP_Parse_Error if something is invalid
|
134
|
+
def parse_http_request_line(line)
|
135
|
+
@length_read += line.length
|
136
|
+
str_method, str_uri, str_version = line.scan(/(GET|POST|PUT|DELETE|PATCH|OPTIONS|TRACE|HEAD|CONNECT) (\S+) HTTP\/([\d\.]+)/i).first
|
137
|
+
raise HTTP_Parse_Error.new 'invalid http Method' if str_method.nil?
|
138
|
+
uri = URI(str_uri)
|
139
|
+
unless str_method && uri && str_version
|
140
|
+
raise HTTP_Parse_Error.new 'The request line is not complete.'
|
141
|
+
end
|
142
|
+
{http_method: str_method, uri: uri, version: str_version}
|
143
|
+
end
|
144
|
+
# This method parses the response line and returns an Hash with the components
|
145
|
+
# * status:: an integer
|
146
|
+
# * version:: the used version of HTTP
|
147
|
+
# @raise HTTP_Parse_Error if something is invalid
|
148
|
+
def parse_http_response_line(line)
|
149
|
+
@length_read += line.length
|
150
|
+
str_version, str_code, _ = line.scan(/HTTP\/([\d\.]+) (\d+) ([A-Za-z0-9 \-]+)\r\n/i).first
|
151
|
+
raise HTTP_Parse_Error.new 'invalid Code' if str_code.nil?
|
152
|
+
code = str_code.to_i
|
153
|
+
unless code && str_version
|
154
|
+
raise HTTP_Parse_Error.new 'The request line is not complete.'
|
155
|
+
end
|
156
|
+
{code: code, version: str_version}
|
157
|
+
end
|
158
|
+
|
159
|
+
# parse all headers
|
160
|
+
def parse
|
161
|
+
if @is_request
|
162
|
+
http_req_line = parse_http_request_line(@io.gets)
|
163
|
+
header = RequestHeader.new(http_req_line[:http_method],http_req_line[:uri],http_req_line[:version])
|
164
|
+
else
|
165
|
+
http_response_line = parse_http_response_line(@io.gets)
|
166
|
+
header = ResponseHeader.new(http_response_line[:version],http_response_line[:code])
|
167
|
+
end
|
168
|
+
until (line = @io.gets) == "\r\n"
|
169
|
+
parsed_header = parse_header(line)
|
170
|
+
header[parsed_header[0]] = parsed_header[1]
|
171
|
+
end
|
172
|
+
header
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# The request parser uses the ICAP and HTTP parsers to parse the complete request.
|
177
|
+
# It is the main parser used by the server which gets the socket to read the +ICAP+
|
178
|
+
# headers which are sent by the client.
|
179
|
+
# Depending on the headers and the values we got, we will decide, which other parsers
|
180
|
+
# we need and how to read it. the parsed request will be returned and depending on the
|
181
|
+
# data, the server will decide what it will do with the request.
|
182
|
+
class RequestParser
|
183
|
+
|
184
|
+
# create a new instance of a +RequestParser+
|
185
|
+
# params:
|
186
|
+
# +io+:: a socket to communicate
|
187
|
+
# +ip+:: the peer ip address
|
188
|
+
# +server+:: the instance of the server which uses the parser to get the service names.
|
189
|
+
def initialize(io,ip,server)
|
190
|
+
@io = io
|
191
|
+
@ip = ip
|
192
|
+
@server = server
|
193
|
+
end
|
194
|
+
|
195
|
+
# Parses the complete request and returns the parsed result.
|
196
|
+
# It will return the parsed result.
|
197
|
+
#
|
198
|
+
# if an +Encapsulated+ header is set, it will also parse the encapsulated +HTTP+ header and the body if
|
199
|
+
# available
|
200
|
+
def parse
|
201
|
+
# parse ICAP headers
|
202
|
+
icap_parser = ICAPRequestParser.new(@io)
|
203
|
+
icap_data = icap_parser.parse
|
204
|
+
return nil unless icap_data
|
205
|
+
if icap_data[:header]['Encapsulated']
|
206
|
+
encapsulation = icap_data[:header]['Encapsulated']
|
207
|
+
encapsulated_parts = encapsulation.split(',').map do |part|
|
208
|
+
part.split('=').map(&:strip)
|
209
|
+
end
|
210
|
+
else
|
211
|
+
encapsulated_parts = []
|
212
|
+
end
|
213
|
+
parsed_data = {icap_data: icap_data, encapsulated: encapsulated_parts}
|
214
|
+
parts = []
|
215
|
+
service_name = icap_data[:request_line][:uri].path
|
216
|
+
service_name = service_name[1...service_name.length]
|
217
|
+
service = @server.services[service_name]
|
218
|
+
if service
|
219
|
+
disable_preview = !service.supports_preview?
|
220
|
+
preview_size = service.preview_size
|
221
|
+
else
|
222
|
+
disable_preview = true
|
223
|
+
preview_size = nil
|
224
|
+
end
|
225
|
+
encapsulated_parts.each do |ep|
|
226
|
+
parts << case ep[0]
|
227
|
+
when 'null-body'
|
228
|
+
NullBody.new
|
229
|
+
when 'req-hdr'
|
230
|
+
http_parser = HTTPHeaderParser.new(@io,true)
|
231
|
+
parsed_data[:http_request_header] = http_parser.parse
|
232
|
+
when 'res-hdr'
|
233
|
+
http_parser = HTTPHeaderParser.new(@io,false)
|
234
|
+
parsed_data[:http_response_header] = http_parser.parse
|
235
|
+
when 'req-body'
|
236
|
+
bp = BodyParser.new(@io, disable_preview, (icap_data[:header]['Preview'] || nil))
|
237
|
+
p_data = bp.parse
|
238
|
+
parsed_data[:http_request_body] = RequestBody.new(p_data[0],p_data[1])
|
239
|
+
when 'res-body'
|
240
|
+
bp = BodyParser.new(@io, disable_preview, (icap_data[:header]['Preview'] || nil))
|
241
|
+
p_data = bp.parse
|
242
|
+
parsed_data[:http_response_body] = ResponseBody.new(p_data[0],p_data[1])
|
243
|
+
else
|
244
|
+
nil
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
parsed_data
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# this class will parse an http body which has to be chunked encoded.
|
253
|
+
class BodyParser
|
254
|
+
# By default, it will try to receive all data if the service does not provide information,
|
255
|
+
# if it supports previews.
|
256
|
+
# params:
|
257
|
+
# +io+:: the Socket
|
258
|
+
# +read_everything+:: read all data before forwarding them to the service - true by default
|
259
|
+
# (set a preview size in the service to override)
|
260
|
+
# +preview_header+:: if a preview header is set, we can find out how long the preview will be.
|
261
|
+
# so we know how much data we can expect.
|
262
|
+
def initialize(io, read_everything = true, preview_header = nil)
|
263
|
+
@io = io
|
264
|
+
@read_everything = read_everything
|
265
|
+
if preview_header
|
266
|
+
@preview_size = preview_header.to_i
|
267
|
+
else
|
268
|
+
@preview_size = nil
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# parses all chunks and concatenates then until:
|
273
|
+
# * the end of the preview is reached and the service is not correctly configured
|
274
|
+
# * die end of the data is reached
|
275
|
+
# it will return the data it got and if the ieof has been set.
|
276
|
+
def parse
|
277
|
+
data = ''
|
278
|
+
ieof = false
|
279
|
+
until (line,ieof = read_chunk(@io); line) && line == :eof
|
280
|
+
data += line
|
281
|
+
end
|
282
|
+
if !ieof && @read_everything && !@preview_size.nil? && (@preview_size >= data.length)
|
283
|
+
Response.continue(@io)
|
284
|
+
until (line,ieof2 = read_chunk(@io); line) && line == :eof
|
285
|
+
data += line
|
286
|
+
ieof ||= ieof2
|
287
|
+
end
|
288
|
+
end
|
289
|
+
return data, ieof
|
290
|
+
end
|
291
|
+
|
292
|
+
include ::ICAPrb::Server::Parser::ChunkedEncodingHelper
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
require_relative './data_structures'
|
2
|
+
require_relative './constants'
|
3
|
+
require 'date'
|
4
|
+
require 'erb'
|
5
|
+
module ICAPrb
|
6
|
+
module Server
|
7
|
+
# The Response class creates a valid ICAP response to send over the socket.
|
8
|
+
class Response
|
9
|
+
# A +Hash+ containing the header of the ICAP response
|
10
|
+
attr_accessor :icap_header
|
11
|
+
# The ICAP Version - usually 1.0
|
12
|
+
attr_accessor :icap_version
|
13
|
+
# The ICAP status code like 200 for OK
|
14
|
+
attr_accessor :icap_status_code
|
15
|
+
# the parts of the ICAP response
|
16
|
+
attr_accessor :components
|
17
|
+
# creates a new instance of ICAPrb::Server::Response and initialises some headers
|
18
|
+
def initialize
|
19
|
+
# the ISTag is used to let the proxy know that the old response (probably cached)
|
20
|
+
# is invalid if this value changes
|
21
|
+
@icap_header = {'Date' => Time.now.gmtime, 'Server' => 'ICAP.rb', 'Connection' => 'Close', 'ISTag' => '"replace"'}
|
22
|
+
@icap_version = '1.0'
|
23
|
+
@icap_status_code = 200
|
24
|
+
@components = []
|
25
|
+
end
|
26
|
+
|
27
|
+
# creates the status line for the ICAP protocol
|
28
|
+
# Returns the status line as a +String+
|
29
|
+
def response_line
|
30
|
+
"ICAP/#{@icap_version} #{@icap_status_code} #{ICAP_STATUS_CODES[@icap_status_code]}\r\n"
|
31
|
+
end
|
32
|
+
|
33
|
+
# convert an hash of keys (header names) and values to a +String+
|
34
|
+
#
|
35
|
+
# Params:
|
36
|
+
# +value+:: the hash to convert
|
37
|
+
#
|
38
|
+
# Returns:: the ICAP headers as +String+
|
39
|
+
def hash_to_header
|
40
|
+
headers = []
|
41
|
+
# add Encapsulated Header if we have a body
|
42
|
+
encapsulated = encapsulated_header
|
43
|
+
value = @icap_header
|
44
|
+
value = @icap_header.merge(encapsulated) if encapsulated['Encapsulated'].length > 0
|
45
|
+
value.each do |key, value|
|
46
|
+
headers << "#{key}: #{value}"
|
47
|
+
end
|
48
|
+
headers.join("\r\n") + "\r\n\r\n"
|
49
|
+
end
|
50
|
+
|
51
|
+
# creates the encapsulated header from an array of components which it is for
|
52
|
+
# Params:
|
53
|
+
# +components+:: an array of the components of the ICAP response The components can be an instance of
|
54
|
+
# RequestHeader, RequestBody, ResponseHeader or ResponseBody
|
55
|
+
#
|
56
|
+
# Returns::
|
57
|
+
# A Hash containing only one entry with the key 'Encapsulated' which holds the offsets of the components
|
58
|
+
def encapsulated_header
|
59
|
+
encapsulated_hdr = 'Encapsulated'
|
60
|
+
encapsulated_hdr_list = []
|
61
|
+
offset = 0
|
62
|
+
@components.sort.each do |component|
|
63
|
+
case component
|
64
|
+
when RequestHeader
|
65
|
+
encapsulated_hdr_list << "req-hdr=#{offset}"
|
66
|
+
when ResponseHeader
|
67
|
+
encapsulated_hdr_list << "res-hdr=#{offset}"
|
68
|
+
when RequestBody
|
69
|
+
encapsulated_hdr_list << "req-body=#{offset}"
|
70
|
+
when ResponseBody
|
71
|
+
encapsulated_hdr_list << "res-body=#{offset}"
|
72
|
+
when NullBody
|
73
|
+
encapsulated_hdr_list << "null-body=#{offset}"
|
74
|
+
end
|
75
|
+
offset += component.to_s.length
|
76
|
+
end
|
77
|
+
{encapsulated_hdr => encapsulated_hdr_list.join(', ')}
|
78
|
+
end
|
79
|
+
# writes the headers into a string and returns them
|
80
|
+
# it raises an exception if the response would be incorrectly created (for example multiple headers)
|
81
|
+
# it will create the full ICAP + HTTP header (if available)
|
82
|
+
def write_headers
|
83
|
+
output = response_line
|
84
|
+
output += hash_to_header
|
85
|
+
s_comp = @components.sort
|
86
|
+
|
87
|
+
# add request header if it exists
|
88
|
+
request_header = s_comp.select {|component| component.class == RequestHeader}
|
89
|
+
raise 'The request header can be included only once' if request_header.count > 1
|
90
|
+
request_header.each do |rh|
|
91
|
+
output += rh.to_s
|
92
|
+
end
|
93
|
+
|
94
|
+
# add response header to the response if it exists
|
95
|
+
response_header = s_comp.select {|component| component.class == ResponseHeader}
|
96
|
+
raise 'The request header can be included only once' if response_header.count > 1
|
97
|
+
response_header.each do |rh|
|
98
|
+
output += rh.to_s
|
99
|
+
end
|
100
|
+
# return the output
|
101
|
+
output
|
102
|
+
end
|
103
|
+
|
104
|
+
# send headers to the client.
|
105
|
+
#
|
106
|
+
# Params:
|
107
|
+
# +io+:: Socket where the headers should be sent to
|
108
|
+
def write_headers_to_socket(io)
|
109
|
+
io.write write_headers
|
110
|
+
end
|
111
|
+
# basic template for a HTML error page
|
112
|
+
ERROR_TEMPLATE = ERB.new('<html><head><meta charset="utf-8" /><title>ICAP.rb<% unless params["title"].nil? %> ::'+
|
113
|
+
' <%= params["title"] %><% end %></title></head>'+
|
114
|
+
'<body><h1><%= params["title"] || "Error" %></h1><div>'+
|
115
|
+
'<%= params["content"] || "Content missing" %></div></body></html>')
|
116
|
+
# display an error page when something is not ok
|
117
|
+
# this is a server function because it is also required for errors which cannot be caught by a service
|
118
|
+
#
|
119
|
+
# Params
|
120
|
+
# +io+:: The object, where the response should be written to
|
121
|
+
# +status+:: ICAP status code to send
|
122
|
+
# +params+:: parameters for the template and the response as well
|
123
|
+
def self.display_error_page(io,status, params)
|
124
|
+
response = Response.new
|
125
|
+
response.icap_status_code = status
|
126
|
+
http_resp_header = ResponseHeader.new(params[:http_version],params[:http_status])
|
127
|
+
http_resp_header['Content-Type'] = 'text/html; charset=utf-8'
|
128
|
+
http_resp_body = ResponseBody.new(ERROR_TEMPLATE.result(binding), false)
|
129
|
+
http_resp_header['Content-Length'] = http_resp_body.length
|
130
|
+
response.components << http_resp_header
|
131
|
+
response.components << http_resp_body
|
132
|
+
response.write_headers_to_socket io
|
133
|
+
io.write(http_resp_body.to_chunk)
|
134
|
+
send_last_chunk(io,false)
|
135
|
+
io.close
|
136
|
+
end
|
137
|
+
|
138
|
+
# this method is an alternative to display_error_page. It does not send any http information to the client.
|
139
|
+
# instead it will send an ICAP header which will indicate an error.
|
140
|
+
def self.error_response(io)
|
141
|
+
response = Response.new
|
142
|
+
response.icap_status_code = 500
|
143
|
+
response.components << NullBody.new
|
144
|
+
response.write_headers_to_socket io
|
145
|
+
end
|
146
|
+
|
147
|
+
# sends the information to the client, that it should send the rest of the file. This method does not keep
|
148
|
+
# track of your connection and if you call it twice, your client may have trouble with your response. Use
|
149
|
+
# it only once and only in +preview+ mode.
|
150
|
+
def self.continue(io,icap_version = '1.0')
|
151
|
+
io.write "ICAP/#{icap_version} 100 Continue\r\n\r\n"
|
152
|
+
end
|
153
|
+
|
154
|
+
# this method sends the last (empty) chunk and it will add the ieof marker if it is requested.
|
155
|
+
# this empty chunk is used to indicate the end of the body encoded in chunked encondig.
|
156
|
+
def self.send_last_chunk(io,in_preview = false)
|
157
|
+
data = '0'
|
158
|
+
if in_preview
|
159
|
+
data += '; ieof'
|
160
|
+
end
|
161
|
+
data += "\r\n\r\n"
|
162
|
+
io.write(data)
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|