catflap 0.0.2 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,56 @@
1
+ require 'open3'
2
+ require 'resolv'
3
+
4
+ # Mixin module to add functions to firewall drivers that need them.
5
+ #
6
+ # It is methods in this module that must run with root privileges and at some
7
+ # point it may form the basis for a micro-service that runs with system
8
+ # privileges, so that the webserver can be run as a non-privileged user.
9
+ #
10
+ # @author Nyk Cowham <nykcowham@gmail.com>
11
+ module Firewall
12
+ # http://blog.markhatton.co.uk/2011/03/15/regular-expressions-for-ip-addresses-cidr-ranges-and-hostnames/
13
+ CIDR_PATTERN = %r{
14
+ ^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}
15
+ ([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])
16
+ (\/([0-9]|[1-2][0-9]|3[0-2]))?$
17
+ }x
18
+
19
+ # Execute firewall commands in a forked process.
20
+ # @param [String] output firewall command string to be forked and executed.
21
+ # @return [String] any output returned by the forked process is returned.
22
+ # @raise StandardError when the forked process returns an error.
23
+
24
+ def execute(output)
25
+ puts output if @verbose
26
+ return if @noop
27
+
28
+ out, err, = Open3.capture3 output << ' 2>/dev/null'
29
+ raise err if err != ''
30
+ out
31
+ end
32
+
33
+ # Execute firewall command in forked process to see if it emits an error.
34
+ #
35
+ # This is used by the 'check' command to see if an IP rule is already in the
36
+ # firewall allow chain or not. It is to get around the quirkiness of the
37
+ # netfilter -C check rule.
38
+ # @param [String] output firewall command string to be forked and executed.
39
+ # @return [Boolean] true if no error was returned from the forked process.
40
+
41
+ def execute_true?(output)
42
+ _, err, = Open3.capture3 output << ' 2>/dev/null'
43
+ (err == '')
44
+ end
45
+
46
+ # Data validation method to ensure that user-submitted IP addresses can be
47
+ # resolved to a valid IP address. This prevents any attempt at a shell/command
48
+ # injection attack.
49
+ # @param [String] suspect the user-submitted address to be validated.
50
+ # @raise Resolve::ResolvError if string doesn't resolve to an IP address.
51
+
52
+ def assert_valid_ipaddr(suspect)
53
+ return suspect if suspect =~ CIDR_PATTERN
54
+ Resolv.getaddress(suspect) # raises Resolv::ResolvError on bad IP.
55
+ end
56
+ end
@@ -0,0 +1,288 @@
1
+ require 'catflap'
2
+ require 'webrick'
3
+ require 'json'
4
+ include WEBrick
5
+
6
+ ##
7
+ # Module to implement a WEBrick web server.
8
+ #
9
+ # @author Nyk Cowham <nykcowham@gmail.com>
10
+ module CfWebserver
11
+ # Add a mime type for *.rhtml files
12
+ HTTPUtils::DefaultMimeTypes.store('rhtml', 'text/html')
13
+
14
+ # Factory method to generate a new WEBrick server.
15
+ # @param [Catflap] cf a fully instantiated Catflap object.
16
+ # @param [Boolean] https set to true to generate an HTTPS server.
17
+ # @return void
18
+ def generate_server(cf, https = false)
19
+ port = (https == true) ? cf.https['port'] : cf.port
20
+
21
+ # Expand relative paths - particularly important for daemonizing.
22
+ docroot = File.expand_path cf.docroot
23
+
24
+ config = {
25
+ BindAddress: cf.listen_addr,
26
+ Port: port,
27
+ DocumentRoot: docroot,
28
+ StartCallback: lambda do
29
+ # Write the pid to file when the server starts.
30
+ if File.writable? cf.pid_path
31
+ File.open(get_pidfile(cf, https), 'w') do |file|
32
+ file.puts Process.pid.to_s
33
+ end
34
+ end
35
+ end,
36
+ StopCallback: lambda do
37
+ # Delete the pid file when the server shuts down.
38
+ pidfile = get_pidfile(cf, https)
39
+ File.delete pidfile if File.exist? pidfile
40
+ end
41
+ }
42
+
43
+ config[:ServerType] = Daemon if cf.daemonize
44
+
45
+ if https
46
+ require 'webrick/https'
47
+ require 'openssl'
48
+
49
+ if File.readable? cf.https['certificate']
50
+ cert = OpenSSL::X509::Certificate.new File.read cf.https['certificate']
51
+ end
52
+
53
+ if File.readable? cf.https['private_key']
54
+ pkey = OpenSSL::PKey::RSA.new File.read cf.https['private_key']
55
+ end
56
+
57
+ config[:SSLEnable] = true
58
+
59
+ if cert && pkey
60
+ config[:SSLCertificate] = cert
61
+ config[:SSLPrivateKey] = pkey
62
+ else
63
+ # We don't have a certificate so generate a new self-signed certificate.
64
+ config[:SSLCertName] = [%w(CN localhost)]
65
+ end
66
+
67
+ end
68
+
69
+ server = HTTPServer.new(config)
70
+ yield server if block_given?
71
+
72
+ %w(INT TERM).each do |signal|
73
+ trap(signal) { server.shutdown }
74
+ end
75
+
76
+ server.start
77
+ end
78
+
79
+ # Method to start the WEBrick web server.
80
+ # @param [Catflap] cf a fully instantiated Catflap object.
81
+ # @param [Boolean] https set to true to start an HTTPS server.
82
+ # @return void
83
+ def server_start(cf, https = false)
84
+ generate_server(cf, https) do |server|
85
+ server.mount cf.endpoint, CfApiServlet, cf
86
+
87
+ # Redirect HTTP to HTTPS if force option is set.
88
+ if !https && cf.https['force']
89
+ server.mount_proc '/' do |req, res|
90
+ res.set_redirect WEBrick::HTTPStatus::TemporaryRedirect,
91
+ 'https://' + req.server_name
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ # Method to stop the WEBrick web server.
98
+ # @param [Catflap] cf a fully instantiated Catflap object.
99
+ # @param [Boolean] https set to true to stop an HTTPS server.
100
+ # @return [Boolean] true if process termination was successful.
101
+ def server_stop(cf, https = false)
102
+ pid = get_pid(cf, https)
103
+ Process.kill('INT', pid) if pid > 0
104
+ end
105
+
106
+ # Method to get the status of the WEBrick web server and process id.
107
+ # @param [Catflap] cf a fully instantiated Catflap object.
108
+ # @param [Boolean] https set to true to get status of an HTTPS server.
109
+ # @return [Hash<Sym, Object>] the process id or 0 if there is no pid.
110
+ def server_status(cf, https = false)
111
+ {
112
+ pid: get_pid(cf, https),
113
+ address: cf.listen_addr,
114
+ port: https ? cf.https['port'] : cf.port
115
+ }
116
+ end
117
+
118
+ # Method to get the process id of the running process.
119
+ # @param [Catflap] cf a fully instantiated Catflap object.
120
+ # @param [Boolean] https set to true to get process id of an HTTPS server.
121
+ # @return [Integer] the process id or 0 if there is no pid.
122
+ def get_pid(cf, https = false)
123
+ pidfile = get_pidfile(cf, https)
124
+ pid = nil
125
+ if File.readable? pidfile
126
+ File.open(pidfile, 'r') do |file|
127
+ pid = file.readline
128
+ end
129
+ end
130
+ pid.to_i
131
+ end
132
+
133
+ # Method to get the pid file path
134
+ # @param [Catflap] cf a fully instantiated Catflap object.
135
+ # @param [Boolean] https set to true to get pidfile of an HTTPS server.
136
+ # @return [String] the file path to the pid file.
137
+ def get_pidfile(cf, https = false)
138
+ filename = https ? 'catflap-https.pid' : 'catflap-http.pid'
139
+ cf.pid_path + File::SEPARATOR + filename
140
+ end
141
+
142
+ ##
143
+ # A WEBrick servlet class to handle API requrests
144
+ # @author Nyk Cowham <nykcowham@gmail.com>
145
+ class CfApiServlet < HTTPServlet::AbstractServlet
146
+ # Initializer to construct a new CfApiServlet object.
147
+ # @param [HTTPServer] server a WEBrick HTTP server object.
148
+ # @param [Catflap] cf a fully instantiated Catflap object.
149
+ # @return void
150
+ def initialize(server, cf)
151
+ super server
152
+ @cf = cf
153
+ end
154
+
155
+ # Implementation of HTTPServlet::AbstractServlet method to handle GET
156
+ # method requests.
157
+ # @param [HTTPRequest] req a WEBrick::HTTPRequest object.
158
+ # @param [HTTPResponse] resp a WEBrick::HTTPResponse object.
159
+ # @return void
160
+ # rubocop:disable Style/MethodName
161
+ def do_POST(req, resp)
162
+ # Split the path into piece
163
+ path = req.path[1..-1].split('/')
164
+
165
+ # We don't want to cache catflap login page so set response headers.
166
+ # Chrome and FF respect the no-store, while IE respects no-cache.
167
+ resp['Cache-Control'] = 'no-cache, no-store'
168
+ resp['Pragma'] = 'no-cache' # Legacy
169
+ resp['Expires'] = '-1' # Microsoft advises this for older IE browsers.
170
+
171
+ response_class = CfRestService.const_get 'CfRestService'
172
+
173
+ raise "#{response_class} not a Class" unless response_class.is_a?(Class)
174
+
175
+ raise HTTPStatus::NotFound unless path[1]
176
+
177
+ response_method = path[1].to_sym
178
+ # Make sure the method exists in the class
179
+ raise HTTPStatus::NotFound unless response_class
180
+ .respond_to? response_method
181
+
182
+ if :sync == response_method
183
+ resp.body = response_class.send response_method, req, resp, @cf
184
+ end
185
+
186
+ if :knock == response_method
187
+ resp.body = response_class.send response_method, req, resp, @cf
188
+ end
189
+
190
+ # Remaining path segments get passed in as arguments to the method
191
+ if path.length > 2
192
+ resp.body = response_class.send response_method, req, resp,
193
+ @cf, path[1..-1]
194
+ else
195
+ resp.body = response_class.send response_method, req, resp, @cf
196
+ end
197
+ raise HTTPStatus::OK
198
+ end
199
+ end
200
+ end
201
+ # rubocop:enable Style/MethodName
202
+
203
+ ##
204
+ # REST service to handle REST requests from CfApiServlet.
205
+ # @author Nyk Cowham <nykcowham@gmail.com>
206
+ module CfRestService
207
+ # REST service handler Class
208
+ class CfRestService
209
+ # Numeric response code indicating that the token has expired.
210
+ STATUS_TOKEN_EXPIRED = 405
211
+ # Numeric response code indicating that the handshake was ok.
212
+ STATUS_SYNC_OK = 200
213
+ # Numeric response code indicating a failed authentication attempt.
214
+ STATUS_AUTH_FAIL = 401
215
+ # Numeric response code indicating a successful authentication attempt.
216
+ STATUS_AUTH_PASS = 200
217
+
218
+ # Handler method for handling sync/timestamp requests for handshaking.
219
+ #
220
+ # This is a handshake request from the browser for a timestamp to use
221
+ # to encrypt the pass phrase. This timestamp is passed back along with
222
+ # pass phrase. If the timestamp is older than the expiry then the token
223
+ # will be rejected. If the timestamp has not expired it will be used to
224
+ # generate a matching token for authentication.
225
+ # @return [Integer] an integer representation of a unix timestamp.
226
+
227
+ def self.sync(_req, _res, _cf)
228
+ result = {
229
+ Status: 'Handshake sync OK',
230
+ StatusCode: STATUS_SYNC_OK,
231
+ Timestamp: Time.new.to_i
232
+ }
233
+ JSON.generate(result)
234
+ end
235
+
236
+ # Handler method for handling knock requests for authentication.
237
+ # @param [WEBRick::HTTPRequest] req a WEBrick request object.
238
+ # @param [WEBrick::HTTPResponse] resp a WEBrick response object.
239
+ # @param [Catflap] cf a fully instantiated Catflap object.
240
+ # @return void
241
+
242
+ def self.knock(req, _resp, cf)
243
+ ip = req.peeraddr.pop
244
+ query = req.query
245
+ passkey = query['_key']
246
+
247
+ # Calculate difference between the timestamp sent to the client and
248
+ # the current timestamp.
249
+ ts_delta = Time.new.to_i - query['ts'].to_i
250
+
251
+ if ts_delta > cf.token_ttl
252
+ result = {
253
+ Status: 'Expired Token',
254
+ StatusCode: STATUS_TOKEN_EXPIRED
255
+ }
256
+ JSON.generate(result)
257
+ end
258
+
259
+ # If we have a matching key in the passfile then create a test token.
260
+ unless cf.passphrases[query['_key']].nil?
261
+ test_token = cf.generate_token(cf.passphrases[passkey], query['ts'])
262
+ end
263
+
264
+ # by default we tell the browser to reload the page, but we can configure
265
+ # catflap in the configuration file to redirect to some other URL.
266
+ redirect_url = cf.redirect_url ? cf.redirect_url : 'reload'
267
+
268
+ if test_token && test_token == query['token']
269
+ # The tokens matched and validated so we add the address and respond
270
+ # to the browser.
271
+ cf.firewall.add_address ip unless cf.firewall.check_address(ip)
272
+
273
+ result = {
274
+ Status: 'Authenticated',
275
+ StatusCode: STATUS_AUTH_PASS,
276
+ RedirectUrl: redirect_url
277
+ }
278
+
279
+ else
280
+ result = {
281
+ Status: 'Authentication failed',
282
+ StatusCode: STATUS_AUTH_FAIL
283
+ }
284
+ end
285
+ JSON.generate(result)
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,127 @@
1
+ require 'catflap/firewall'
2
+ include Firewall
3
+
4
+ # Mixin module to add rule handling functions to netfilter-based drivers.
5
+ #
6
+ # @author Nyk Cowham <nykcowham@gmail.com>
7
+ module NetfilterWriter
8
+ # Class providing a DSL for defining netfilter rules
9
+ # @author Nyk Cowham <nykcowham@gmail.com>
10
+ class Rules
11
+ attr_accessor :noop, :verbose, :match
12
+
13
+ def initialize(table, dports = nil)
14
+ @table = table
15
+ @dports = dports
16
+ @match = nil
17
+ @buffer = ''
18
+ end
19
+
20
+ # Chainable setter function: change the default table for rules.
21
+ # @param [String] table the name of the table (e.g. 'nat', 'filter', etc.)
22
+ # @return self
23
+ def table(table)
24
+ @table = table
25
+ self
26
+ end
27
+
28
+ # Chainable setter function: change the default destination ports for rules.
29
+ # @param [String] table the name of the table (e.g. 'nat', 'filter', etc.)
30
+ # @return self
31
+ def dports(dports)
32
+ @dports = dports
33
+ self
34
+ end
35
+
36
+ # Create, flush and delete chains and other iptable chain operations.
37
+ # @param [Symbol] cmd the operation to perform (:new, :delete, :flush)
38
+ # @param [String] chain name of the chain (e.g. INPUT, CATFLAP-DENY, etc.)
39
+ # @param [Hash] p parameters for specific iptables features.
40
+ # @return self
41
+ # @example
42
+ # Rules.new('nat').chain(:list, 'MY-CHAIN', numeric: true).flush
43
+ # => "iptables -t nat -n -L MY-CHAIN"
44
+ def chain(cmd, chain, p = {})
45
+ cmds = {
46
+ new: '-N', rename: '-E', delete: '-X', flush: '-F',
47
+ list_rules: '-S', list: '-L', zero: '-Z', policy: '-P'
48
+ }
49
+
50
+ @buffer << [
51
+ 'iptables',
52
+ option('-t', @table), cmds[cmd], option('-n', p[:numeric]), chain,
53
+ option(false, p[:rulenum]), option(false, p[:to])
54
+ ].compact.join(' ') << "\n"
55
+
56
+ self
57
+ end
58
+
59
+ # Create, flush and delete chains
60
+ # @param [String] cmd the operation to perform (add, delete, insert, etc.)
61
+ # @param [Hash] p parameters for specific iptables features.
62
+ # @param [Block] block will evaluate a block that will return true/false.
63
+ # @return self
64
+ def rule(cmd, p, &block)
65
+ # Evaluate a block expression and return early if it evaluates to false.
66
+ # If no block is passed it is equivalent to the block: { true }.
67
+ return self if block_given? && !instance_eval(&block)
68
+
69
+ raise ArgumentError, 'chain is a required argument' unless p[:chain]
70
+ assert_valid_ipaddr(p[:src]) if p[:src]
71
+ assert_valid_ipaddr(p[:dst]) if p[:dst]
72
+
73
+ # Map of commands for rules
74
+ cmds = {
75
+ add: '-A', delete: '-D', insert: '-I', replace: '-R', check: '-C'
76
+ }
77
+
78
+ @buffer << [
79
+ 'iptables', option('-t', @table), cmds[cmd], p[:chain],
80
+ option(false, p[:rulenum]), option('-f', p[:frag]),
81
+ option('-s', p[:src]), option('-d', p[:dst]),
82
+ option('-o', p[:out]), option('-i', p[:in]),
83
+ option('-p', p[:proto] || 'tcp'), option('-m', p[:match] || @match),
84
+ option('--sport', p[:sports] || @sports),
85
+ option('--dport', p[:dports] || @dports), p[:jump] || p[:goto],
86
+ option('--to-port', p[:to_port])
87
+ ].compact.join(' ') << "\n"
88
+
89
+ self
90
+ end
91
+ end
92
+
93
+ def option(flag, value)
94
+ return flag if value.is_a?(TrueClass)
95
+ return flag.insert(0, '!') if value.is_a?(FalseClass)
96
+ return value if flag.is_a?(FalseClass)
97
+ flag << ' ' << value.to_s if flag && value
98
+ end
99
+
100
+ # Add a raw text rule, (e.g.: iptables -t nat CATFLAP-ALLOW ...)
101
+ # @param [String] raw_rule custom raw iptables command.
102
+ # @return self
103
+ def raw(raw_rule)
104
+ @buffer = raw_rule
105
+ self
106
+ end
107
+
108
+ # Flush the rule buffer and output the resulting iptables commands.
109
+ # @return [String] rule text that can be sent iptables user-space client.
110
+ def flush
111
+ out = @buffer
112
+ @buffer = ''
113
+ out
114
+ end
115
+
116
+ # Flush the rule and execute in iptables user-space client.
117
+ # @return void
118
+ def do
119
+ execute flush
120
+ end
121
+
122
+ # Flush the rule and execute commands and return success/fail value.
123
+ # @return [Boolean] true if the execution was successful.
124
+ def do?
125
+ execute_true? flush
126
+ end
127
+ end