catflap 0.0.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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