potluck-nginx 0.0.7 → 0.0.8

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.
data/lib/potluck/nginx.rb CHANGED
@@ -2,25 +2,40 @@
2
2
 
3
3
  require('fileutils')
4
4
  require('potluck')
5
- require_relative('nginx/ssl')
6
- require_relative('nginx/util')
5
+ require_relative('nginx/nginx_config')
7
6
  require_relative('nginx/version')
8
7
 
9
8
  module Potluck
10
- ##
11
- # A Ruby interface for configuring and controlling Nginx. Each instance of this class manages a separate
12
- # Nginx configuration file, which is loaded and unloaded from the base Nginx configuration when #start and
13
- # #stop are called, respectively. Any number of Ruby processes can thus each manage their own Nginx
14
- # configuration and control whether or not it is active without interfering with any other instances or
15
- # non-Ruby processes leveraging Nginx.
9
+ # Public: A Ruby interface for configuring and controlling Nginx.
16
10
  #
11
+ # Each instance of this class manages a separate Nginx configuration file, which is loaded and unloaded
12
+ # from the base Nginx configuration when #start and #stop are called, respectively. Any number of Ruby
13
+ # processes can thus each manage their own Nginx configuration and control whether or not it is active
14
+ # without interfering with any other instances or non-Ruby processes leveraging Nginx.
15
+ #
16
+ # A standard set of Nginx config directives are generated automatically, and a flexible DSL can be used to
17
+ # modify and/or embellish them.
18
+ #
19
+ # Examples
20
+ #
21
+ # Nginx.new('hello.world', 1234) do |c|
22
+ # c.server do
23
+ # c.access_log('/path/to/access.log')
24
+ # c.gzip('off')
25
+ #
26
+ # c.location('/hello') do
27
+ # c.try_files('world.html')
28
+ # end
29
+ # end
30
+ # end
17
31
  class Nginx < Service
18
32
  CONFIG_NAME_ACTIVE = 'nginx.conf'
19
33
  CONFIG_NAME_INACTIVE = 'nginx-stopped.conf'
20
- ACTIVE_CONFIG_PATTERN = File.join(DIR, '*', CONFIG_NAME_ACTIVE).freeze
34
+ ACTIVE_CONFIG_PATTERN = File.join(Potluck.config.dir, '*', CONFIG_NAME_ACTIVE).freeze
21
35
 
22
- TEST_CONFIG_REGEX = /nginx: configuration file (?<config>.+) test (failed|is successful)/.freeze
23
- INCLUDE_REGEX = /^ *include +#{Regexp.escape(ACTIVE_CONFIG_PATTERN)} *;/.freeze
36
+ TEST_CONFIG_REGEX = /nginx: configuration file (?<config>.+) test (?:failed|is successful)/
37
+ INCLUDE_REGEX = /^ *include +#{Regexp.escape(ACTIVE_CONFIG_PATTERN)} *;/
38
+ HTTP_BLOCK_REGEX = /^( *http *{)( *\n?)( *)/
24
39
 
25
40
  NON_LAUNCHCTL_COMMANDS = {
26
41
  status: 'ps aux | grep \'[n]ginx: master process\'',
@@ -28,76 +43,148 @@ module Potluck
28
43
  stop: 'nginx -s stop',
29
44
  }.freeze
30
45
 
31
- ##
32
- # Creates a new instance.
46
+ DEFAULT_HTTP_PORT = 8080
47
+ DEFAULT_HTTPS_PORT = 4433
48
+
49
+ SSL_CERT_DAYS = 365
50
+ SSL_CERT_RENEW_DAYS = 14
51
+
52
+ # Public: Get the content of the launchctl plist file.
53
+ #
54
+ # Returns the String content.
55
+ def self.plist
56
+ super(
57
+ <<~XML
58
+ <key>ProgramArguments</key>
59
+ <array>
60
+ <string>#{Potluck.config.homebrew_prefix}/opt/nginx/bin/nginx</string>
61
+ <string>-g</string>
62
+ <string>daemon off;</string>
63
+ </array>
64
+ <key>StandardOutPath</key>
65
+ <string>#{Potluck.config.homebrew_prefix}/var/log/nginx/access.log</string>
66
+ <key>StandardErrorPath</key>
67
+ <string>#{Potluck.config.homebrew_prefix}/var/log/nginx/error.log</string>
68
+ XML
69
+ )
70
+ end
71
+
72
+ # Internal: Print a warning with the file and line number of a deprecated call.
33
73
  #
34
- # * +hosts+ - One or more hosts.
35
- # * +port+ - Port that the upstream (Ruby web server) is running on.
36
- # * +subdomains+ - One or more subdomains (optional).
37
- # * +ssl+ - SSL configuration arguments to pass to SSL.new (optional).
38
- # * +one_host+ - True if URLs should be normalized to the first host in +hosts+ (optional, default:
39
- # false).
40
- # * +www+ - +true+ if URLs should be normalized to include 'www.' prefix, +false+ to exclude 'www.', and
41
- # +nil+ if either is acceptable (optional, default: +nil+).
42
- # * +multiple_slashes+ - +false+ if any occurrence of multiple slashes in the path portion of the URL
43
- # should be normalized to a single slash (optional, default: +nil+).
44
- # * +multiple_question_marks+ - +false+ if multiple question marks in the URL signifying the start of
45
- # the query string should be normalized to a single question mark (optional, default: +nil+).
46
- # * +trailing_slash+ - +true+ if URLs should be normalized to include a trailing slash at the end of the
47
- # path portion, +false+ to strip any trailing slash, and +nil+ if either is acceptable (optional,
48
- # default: +nil+).
49
- # * +trailing_question_mark+ - +true+ if URLs should be normalized to include a trailing question mark
50
- # when the query string is empty, +false+ to strip any trailing question mark, and +nil+ if either is
51
- # acceptable (optional, default: +nil+).
52
- # * +config+ - Nginx configuration hash; see #config (optional).
53
- # * +ensure_host_entries+ - True if +hosts+ should be added to system /etc/hosts file as mappings to
54
- # localhost (optional, default: false).
55
- # * +args+ - Arguments to pass to Potluck::Service.new (optional).
74
+ # message - String deprecation message.
56
75
  #
57
- def initialize(hosts, port, subdomains: nil, ssl: nil, one_host: false, www: nil, multiple_slashes: nil,
58
- multiple_question_marks: nil, trailing_slash: nil, trailing_question_mark: nil, config: {},
59
- ensure_host_entries: false, **args)
60
- if args[:manage] && !args[:manage].kind_of?(Hash) && !self.class.launchctl?
61
- args[:manage] = NON_LAUNCHCTL_COMMANDS
76
+ # Returns nothing.
77
+ def self.deprecated(message)
78
+ location = caller_locations(2, 1).first
79
+
80
+ warn("#{location.path}:#{location.lineno}: #{message}")
81
+ end
82
+
83
+ # Public: Create a new instance.
84
+ #
85
+ # hosts - String or Array of String hosts.
86
+ # port - Integer port that the upstream (Ruby web server) is running on.
87
+ # subdomains: - String or Array of String fully qualified subdomains (e.g. 'sub.hello.com',
88
+ # not 'sub.').
89
+ # ssl: - Boolean indicating if SSL should be used. If true and SSL files are not
90
+ # configured, a self-signed certificate will be generated.
91
+ # one_host: - Boolean specifying if URLs should be normalized to the first item in hosts.
92
+ # www: - Boolean specifying if URLs should be normalized to include 'www.' (true),
93
+ # exclude it (false), or allow either (nil).
94
+ # multiple_slashes: - Boolean specifying if URLs should be normalized to reduce multiple
95
+ # slashes in a row to a single one (false) or leave them alone (true or nil).
96
+ # multiple_question_marks: - Boolean specifying if URLs should be normalized to reduce multiple question
97
+ # marks in a row to a single one (false) or leave them alone (true or nil).
98
+ # trailing_slash: - Boolean specifying if URLs should be normalized to include a trailing slash
99
+ # (true), exclude it (false), or allow either (nil).
100
+ # trailing_question_mark: - Boolean specifying if URLs should be normalized to include a trailing
101
+ # question mark (true), exclude it (false), or allow either (nil).
102
+ # config: - Deprecated: Nginx server block configuration Hash. Use a block instead.
103
+ # ensure_host_entries: - Boolean specifying if hosts should be added to system /etc/hosts file as
104
+ # mappings to localhost.
105
+ # kwargs - Hash of keyword arguments to pass to Service.new.
106
+ # block - Block for making modifications to the Nginx config. See NginxConfig#modify.
107
+ def initialize(hosts, port, subdomains: nil, ssl: false, one_host: false, www: nil,
108
+ multiple_slashes: nil, multiple_question_marks: nil, trailing_slash: nil,
109
+ trailing_question_mark: nil, config: nil, ensure_host_entries: false, **kwargs, &block)
110
+ if kwargs[:manage] && !kwargs[:manage].kind_of?(Hash) && !self.class.launchctl?
111
+ kwargs[:manage] = NON_LAUNCHCTL_COMMANDS
62
112
  end
63
113
 
64
- super(**args)
114
+ super(**kwargs)
65
115
 
66
- @hosts = Array(hosts).map { |h| h.sub(/^www\./, '') }.uniq
67
- @hosts += @hosts.map { |h| "www.#{h}" }
68
- @host = @hosts.first
69
- @port = port
116
+ if config
117
+ self.class.deprecated("Passing config: {...} to #{self.class.name}.new is deprecated: use a " \
118
+ 'block instead')
70
119
 
71
- @ensure_host_entries = ensure_host_entries
72
- @dir = File.join(DIR, @host)
73
- @ssl = SSL.new(self, @dir, @host, **ssl) if ssl
120
+ @deprecated_additional_config = config
121
+ end
122
+
123
+ if ssl.kind_of?(Hash)
124
+ self.class.deprecated("Passing ssl: {...} to #{self.class.name}.new is deprecated: pass ssl: " \
125
+ 'true and use a block to configure SSL instead')
126
+
127
+ @deprecated_ssl_crt_file = ssl[:crt_file]
128
+ @deprecated_ssl_key_file = ssl[:key_file]
129
+ @deprecated_ssl_dhparam_file = ssl[:dhparam_file]
130
+ @deprecated_ssl_config = ssl[:config]
131
+
132
+ all_given = @deprecated_ssl_crt_file && @deprecated_ssl_key_file && @deprecated_ssl_dhparam_file
133
+ none_given = !@deprecated_ssl_crt_file && !@deprecated_ssl_key_file && !@deprecated_ssl_dhparam_file
74
134
 
75
- @scheme = @ssl ? 'https' : 'http'
76
- @other_scheme = @ssl ? 'http' : 'https'
77
- @one_host = !!one_host
135
+ unless all_given || none_given
136
+ raise(ArgumentError, 'Must supply values for all SSL files or none: crt_file, key_file, ' \
137
+ 'dhparam_file')
138
+ end
139
+ end
140
+
141
+ @hosts = Array(hosts).map { |h| h.sub(/^www\./, '') }.uniq
78
142
  @subdomains = Array(subdomains)
143
+ @host = @hosts.first || @subdomains.first
144
+ @server_names = @hosts + @hosts.map { |h| "www.#{h}" } + @subdomains
145
+ @var_prefix = "$#{@host.downcase.gsub(/[^a-z0-9]/, '_')}"
146
+ @port = port
147
+ @ssl = ssl
148
+ @one_host = one_host
79
149
  @www = www
80
150
  @multiple_slashes = multiple_slashes
81
151
  @multiple_question_marks = multiple_question_marks
82
152
  @trailing_slash = trailing_slash
83
153
  @trailing_question_mark = trailing_question_mark
84
- @additional_config = config
85
-
86
- FileUtils.mkdir_p(@dir)
154
+ @ensure_host_entries = ensure_host_entries
87
155
 
156
+ @dir = File.join(Potluck.config.dir, @host).freeze
88
157
  @config_file_active = File.join(@dir, CONFIG_NAME_ACTIVE).freeze
89
158
  @config_file_inactive = File.join(@dir, CONFIG_NAME_INACTIVE).freeze
159
+
160
+ @default_ssl_certificate = File.join(@dir, "#{@host}.crt")
161
+ @default_ssl_certificate_key = File.join(@dir, "#{@host}.key")
162
+ @default_ssl_dhparam = File.join(@dir, 'dhparam.pem')
163
+
164
+ FileUtils.mkdir_p(@dir)
165
+
166
+ @config = NginxConfig.new
167
+
168
+ add_upstream_config
169
+ add_host_map_config
170
+ add_port_map_config
171
+ add_path_map_config
172
+ add_query_map_config
173
+ add_server_config
174
+
175
+ config(&block) if block
90
176
  end
91
177
 
92
- ##
93
- # Ensures this instance's configuration file is active and starts Nginx if it's managed. If Nginx is
94
- # already running, a reload signal is sent to the process after activating the configuration file.
178
+ # Public: Ensure this instance's configuration file is active and start Nginx. If Nginx is already
179
+ # running, send a reload signal to the process after activating the configuration file. Does nothing if
180
+ # Nginx is not managed.
95
181
  #
182
+ # Returns nothing.
96
183
  def start
97
184
  return unless manage?
98
185
 
99
- @ssl&.ensure_files
100
- ensure_host_entries if @ensure_host_entries
186
+ ensure_ssl_files
187
+ ensure_host_entries
101
188
  ensure_include
102
189
 
103
190
  write_config
@@ -108,13 +195,13 @@ module Potluck
108
195
  status == :active ? reload : super
109
196
  end
110
197
 
111
- ##
112
- # Ensures this instance's configuration file is inactive and optionally stops the Nginx process if it's
113
- # managed.
198
+ # Public: Ensure this instance's configuration file is inactive and optionally stop Nginx. Does nothing
199
+ # if Nginx is not managed.
114
200
  #
115
- # * +hard+ - True if the Nginx process should be stopped, false to just inactivate this instance's
116
- # configuration file and leave Nginx running (optional, default: false).
201
+ # hard - Boolean specifying if the Nginx process should be stopped (true) or this instance's
202
+ # configuration file simply inactivated (false).
117
203
  #
204
+ # Returns nothing.
118
205
  def stop(hard = false)
119
206
  return unless manage?
120
207
 
@@ -123,259 +210,235 @@ module Potluck
123
210
  hard || status != :active ? super() : reload
124
211
  end
125
212
 
126
- ##
127
- # Reloads Nginx if it's managed.
213
+ # Public: Reload Nginx. Does nothing if Nginx is not managed.
128
214
  #
215
+ # Returns nothing.
129
216
  def reload
130
217
  return unless manage?
131
218
 
132
219
  run('nginx -s reload')
133
220
  end
134
221
 
135
- ##
136
- # Returns the content for the Nginx configuration file as a string.
222
+ # Public: Get or modify the config.
137
223
  #
138
- def config_file_content
139
- self.class.to_nginx_config(config)
224
+ # block - Block for making modifications. See NginxConfig#modify.
225
+ #
226
+ # Returns the NginxConfig instance.
227
+ def config(&block)
228
+ block ? @config.modify(&block) : @config
140
229
  end
141
230
 
142
- ##
143
- # Content of the launchctl plist file.
231
+ private
232
+
233
+ # Internal: Add upstream directive to the config.
144
234
  #
145
- def self.plist
146
- super(
147
- <<~EOS
148
- <key>ProgramArguments</key>
149
- <array>
150
- <string>#{HOMEBREW_PREFIX}/opt/nginx/bin/nginx</string>
151
- <string>-g</string>
152
- <string>daemon off;</string>
153
- </array>
154
- <key>StandardOutPath</key>
155
- <string>#{HOMEBREW_PREFIX}/var/log/nginx/access.log</string>
156
- <key>StandardErrorPath</key>
157
- <string>#{HOMEBREW_PREFIX}/var/log/nginx/error.log</string>
158
- EOS
159
- )
235
+ # Returns nothing.
236
+ def add_upstream_config
237
+ config.upstream(@host) do |c|
238
+ c.server("127.0.0.1:#{@port}")
239
+ end
160
240
  end
161
241
 
162
- ##
163
- # Converts a hash to an Nginx configuration file content string. Keys should be strings and values
164
- # either strings or hashes. A +nil+ value in a hash will result in that key-value pair being omitted.
165
- #
166
- # * +hash+ - Hash to convert to the string content of an Nginx configuration file.
167
- # * +indent+ - Number of spaces to indent; used when the method is called recursively and should not be
168
- # set explicitly (optional, default: 0).
169
- # * +repeat+ - Value to prepend to each entry of the hash; used when the method is called recursively
170
- # and should not be set explicitly (optional).
171
- #
172
- # Symbol keys in hashes are used as special directives. Including <tt>repeat: true</tt> will cause the
173
- # parent hash's key for the child hash to be prefixed to each line of the output. Example:
174
- #
175
- # {
176
- # # ...
177
- #
178
- # 'add_header' => {
179
- # repeat: true,
180
- # 'X-Frame-Options' => 'DENY',
181
- # 'X-Content-Type-Options' => 'nosniff',
182
- # }
183
- # }
184
- #
185
- # Result:
186
- #
187
- # # ...
188
- #
189
- # add_header X-Frame-Options DENY;
190
- # add_header X-Content-Type-Options nosniff;
191
- #
192
- # A hash containing <tt>raw: '...'</tt> can be used to include a raw chunk of text rather than key-value
193
- # pairs. Example:
194
- #
195
- # {
196
- # # ...
197
- #
198
- # 'location /' => {
199
- # raw: """
200
- # if ($scheme = https) { ... }
201
- # if ($host ~ ^www.) { ... }
202
- # """,
203
- # }
204
- # }
242
+ # Internal: Add map directive for URL host normalization to the config.
205
243
  #
206
- # Result:
244
+ # Returns nothing.
245
+ def add_host_map_config
246
+ host_map = @hosts.each.with_object({}) do |host, map|
247
+ www_key = "www.#{host}"
248
+ www_value = "#{'www.' unless @www == false}#{@one_host ? @host : host}"
249
+
250
+ non_www_key = host
251
+ non_www_value = "#{'www.' if @www}#{@one_host ? @host : host}"
252
+
253
+ map[www_key] = www_value unless www_key == www_value
254
+ map[non_www_key] = non_www_value unless non_www_key == non_www_value
255
+ end
256
+
257
+ config.map("$host #{@var_prefix}_host") do |c|
258
+ c << {
259
+ 'default' => '$host',
260
+ **host_map,
261
+ }
262
+ end
263
+ end
264
+
265
+ # Internal: Add map directives for URL port normalization to the config.
207
266
  #
208
- # location / {
209
- # if ($scheme = https) { ... }
210
- # if ($host ~ ^www.) { ... }
211
- # }
267
+ # Returns nothing.
268
+ def add_port_map_config
269
+ normalized_port = @ssl ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT
270
+
271
+ config.map("$http_host #{@var_prefix}_request_port") do |c|
272
+ c << {
273
+ 'default' => "''",
274
+ '~(:[0-9]+)$' => '$1',
275
+ }
276
+ end
277
+
278
+ config.map("$http_host #{@var_prefix}_port") do |c|
279
+ c << {
280
+ 'default' => "''",
281
+ '~:[0-9]+$' => ":#{normalized_port}",
282
+ }
283
+ end
284
+
285
+ config.map("$http_host #{@var_prefix}_x_forwarded_port") do |c|
286
+ c << {
287
+ 'default' => normalized_port,
288
+ '~:([0-9]+)$' => '$1',
289
+ }
290
+ end
291
+ end
292
+
293
+ # Internal: Add map directive for URL path normalization to the config.
212
294
  #
213
- def self.to_nginx_config(hash, indent: 0, repeat: nil)
214
- hash.each_with_object(+'') do |(k, v), config|
215
- next if v.nil?
216
- next if k == :repeat
217
-
218
- config << (
219
- if v.kind_of?(Hash)
220
- if v[:repeat]
221
- to_nginx_config(v, indent: indent, repeat: k)
222
- else
223
- "#{' ' * indent}#{k} {\n#{to_nginx_config(v, indent: indent + 2)}#{' ' * indent}}\n"
224
- end
225
- elsif k == :raw
226
- "#{v.gsub(/^(?=.)/, ' ' * indent)}\n\n"
227
- else
228
- "#{' ' * indent}#{"#{repeat} " if repeat}#{k}#{" #{v}" unless v == true};\n"
229
- end
230
- )
295
+ # Returns nothing.
296
+ def add_path_map_config
297
+ config.map("$uri #{@var_prefix}_uri") do |c|
298
+ c << {
299
+ 'default' => '$uri',
300
+ '~^(.*/[^/.]+)/+$' => ('$1' if @trailing_slash == false),
301
+ '~^(.*/[^/.]+)$' => ('$1/' if @trailing_slash),
302
+ }
231
303
  end
232
304
  end
233
305
 
234
- private
306
+ # Internal: Add map directives for URL query string normalization to the config.
307
+ #
308
+ # Returns nothing.
309
+ def add_query_map_config
310
+ config.map("$request_uri #{@var_prefix}_q") do |c|
311
+ c << {
312
+ 'default' => "''",
313
+ '~\\?' => '?',
314
+ }
315
+ end
235
316
 
236
- ##
237
- # Returns a hash representation of the Nginx configuration file content. Any configuration passed to
238
- # Nginx.new is deep-merged into a base configuration hash, meaning nested hashes are merged rather than
239
- # overwritten (see Util.deep_merge).
317
+ config.map("#{@var_prefix}_q$query_string #{@var_prefix}_query") do |c|
318
+ c << {
319
+ 'default' => "#{@var_prefix}_q$query_string",
320
+ '~^\\?+([^\\?].*)$' => ('?$1' if @multiple_question_marks == false),
321
+ '~^(\\?+)$' =>
322
+ if @trailing_question_mark == false
323
+ "''"
324
+ elsif @multiple_question_marks == false
325
+ '?'
326
+ end,
327
+ "''" => ('?' if @trailing_question_mark),
328
+ }
329
+ end
330
+ end
331
+
332
+ # Internal: Add base/default server definition to the config.
240
333
  #
241
- def config
242
- host_subdomains_regex = ([@host] + @subdomains).join('|')
243
- hosts_subdomains_regex = (@hosts + @subdomains).join('|')
244
-
245
- config = {
246
- "upstream #{@host}" => {
247
- 'server' => "127.0.0.1:#{@port}",
248
- },
249
-
250
- 'server' => Util.deep_merge(
251
- {
252
- 'charset' => 'UTF-8',
253
- 'access_log' => File.join(@dir, 'nginx-access.log'),
254
- 'error_log' => File.join(@dir, 'nginx-error.log'),
255
-
256
- 'listen' => {
257
- repeat: true,
258
- '8080' => true,
259
- '[::]:8080' => true,
260
- '4433 ssl http2' => @ssl ? true : nil,
261
- '[::]:4433 ssl http2' => @ssl ? true : nil,
262
- },
263
- 'server_name' => (@hosts + @subdomains).join(' '),
264
-
265
- 'gzip' => 'on',
266
- 'gzip_types' => 'application/javascript application/json application/xml text/css '\
267
- 'text/javascript text/plain',
268
-
269
- 'add_header' => {
270
- repeat: true,
271
- 'Referrer-Policy' => '\'same-origin\' always',
272
- 'X-Frame-Options' => '\'DENY\' always',
273
- 'X-XSS-Protection' => '\'1; mode=block\' always',
274
- 'X-Content-Type-Options' => '\'nosniff\' always',
275
- },
276
- },
277
-
278
- @ssl ? @ssl.config : {},
279
-
280
- {
281
- 'location /' => {
282
- raw: """
283
- if ($host !~ ^#{hosts_subdomains_regex}$) { return 404; }
284
-
285
- set $r 0;
286
- set $s $scheme;
287
- set $h $host;
288
- set $port #{@ssl ? '443' : '80'};
289
- set $p '';
290
- set $u '';
291
- set $q '';
292
-
293
- #{if @www.nil? && @one_host == false
294
- nil
295
- elsif @www.nil? && @one_host == true
296
- "if ($host !~ ^(www.)?#{host_subdomains_regex}$) { set $h $1#{@host}; set $r 1; }"
297
- elsif @www == false && @one_host == false
298
- "if ($host ~ ^www.(.+)$) { set $h $1; set $r 1; }"
299
- elsif @www == false && @one_host == true
300
- "if ($host !~ ^#{host_subdomains_regex}$) { set $h #{@host}; set $r 1; }"
301
- elsif @www == true && @one_host == false
302
- "if ($host !~ ^www.(.+)$) { set $h $1; set $r 1; }"
303
- elsif @www == true && @one_host == true
304
- "if ($host !~ ^www.#{host_subdomains_regex}$) { set $h www.#{@host}; set $r 1; }"
305
- end}
306
-
307
- if ($scheme = #{@other_scheme}) { set $s #{@scheme}; set $r 1; }
308
- if ($http_host ~ :([0-9]+)$) { set $p :$1; set $port $1; }
309
- if ($request_uri ~ ^([^\\?]+)(\\?+.*)?$) { set $u $1; set $q $2; }
310
-
311
- #{'if ($u ~ //) { set $u $uri; set $r 1; }' if @multiple_slashes == false}
312
- #{'if ($q ~ ^\?\?+(.*)$) { set $q ?$1; set $r 1; }' if @multiple_question_marks == false}
313
-
314
- #{if @trailing_question_mark == false
315
- 'if ($q ~ \?+$) { set $q \'\'; set $r 1; }'
316
- elsif @trailing_question_mark == true
317
- 'if ($q !~ .) { set $q ?; set $r 1; }'
318
- end}
319
- #{if @trailing_slash == false
320
- 'if ($u ~ (.+?)/+$) { set $u $1; set $r 1; }'
321
- elsif @trailing_slash == true
322
- 'if ($u ~ [^/]$) { set $u $u/; set $r 1; }'
323
- end}
324
-
325
- set $mr $request_method$r;
326
-
327
- if ($mr ~ ^(GET|HEAD)1$) { return 301 $s://$h$p$u$q; }
328
- if ($mr ~ 1$) { return 308 $s://$h$p$u$q; }
329
- """.strip.gsub(/^ +/, '').gsub(/\n{3,}/, "\n\n"),
330
-
331
- 'proxy_pass' => "http://#{@host}",
332
- 'proxy_redirect' => 'off',
333
- 'proxy_set_header' => {
334
- repeat: true,
335
- 'Host' => '$http_host',
336
- 'X-Real-IP' => '$remote_addr',
337
- 'X-Forwarded-For' => '$proxy_add_x_forwarded_for',
338
- 'X-Forwarded-Proto' => @ssl ? 'https' : 'http',
339
- 'X-Forwarded-Port' => '$port',
340
- },
341
- },
342
- },
343
-
344
- @additional_config,
345
- )
346
- }
347
-
348
- config
334
+ # Returns nothing.
335
+ def add_server_config
336
+ config.server do |c|
337
+ c.server_name(@server_names)
338
+
339
+ c.listen(DEFAULT_HTTP_PORT, soft: true)
340
+ c.listen("[::]:#{DEFAULT_HTTP_PORT}", soft: true)
341
+
342
+ if @ssl
343
+ c.listen(DEFAULT_HTTPS_PORT, 'ssl', soft: true)
344
+ c.listen("[::]:#{DEFAULT_HTTPS_PORT}", 'ssl', soft: true)
345
+ c.http2('on', soft: true)
346
+
347
+ if @deprecated_ssl_crt_file
348
+ c.ssl_certificate(@deprecated_ssl_crt_file)
349
+ c.ssl_certificate_key(@deprecated_ssl_key_file)
350
+ c.ssl_dhparam(@deprecated_ssl_dhparam_file)
351
+ else
352
+ c.ssl_certificate(@default_ssl_certificate, soft: true)
353
+ c.ssl_certificate_key(@default_ssl_certificate_key, soft: true)
354
+ c.ssl_dhparam(@default_ssl_dhparam, soft: true)
355
+ end
356
+
357
+ c.ssl_ciphers('ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-' \
358
+ 'GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-' \
359
+ 'POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384', soft: true)
360
+ c.ssl_prefer_server_ciphers('off', soft: true)
361
+ c.ssl_protocols('TLSv1.2 TLSv1.3', soft: true)
362
+ c.ssl_session_cache('shared:SSL:10m', soft: true)
363
+ c.ssl_session_tickets('off', soft: true)
364
+ c.ssl_session_timeout('1d', soft: true)
365
+ c.ssl_stapling('on', soft: true)
366
+ c.ssl_stapling_verify('on', soft: true)
367
+
368
+ add_deprecated_config(@deprecated_ssl_config)
369
+ end
370
+
371
+ c.charset('UTF-8', soft: true)
372
+
373
+ c.access_log(File.join(@dir, 'nginx-access.log'), soft: true)
374
+ c.error_log(File.join(@dir, 'nginx-error.log'), soft: true)
375
+
376
+ c.merge_slashes(@multiple_slashes == false ? 'on' : 'off', soft: true)
377
+
378
+ c.gzip('on', soft: true)
379
+ c.gzip_types('application/javascript application/json application/xml text/css text/javascript ' \
380
+ 'text/plain', soft: true)
381
+
382
+ c.add_header('Referrer-Policy', "'same-origin' always")
383
+ c.add_header('Strict-Transport-Security', "'max-age=31536000; includeSubDomains' always") if @ssl
384
+ c.add_header('X-Content-Type-Options', "'nosniff' always")
385
+ c.add_header('X-Frame-Options', "'DENY' always")
386
+ c.add_header('X-XSS-Protection', "'1; mode=block' always")
387
+
388
+ c.set('$normalized', "http#{'s' if @ssl}://#{@var_prefix}_host#{@var_prefix}_port" \
389
+ "#{@var_prefix}_uri#{@var_prefix}_query")
390
+
391
+ c << <<~CONFIG
392
+ if ($normalized != '$scheme://$host#{@var_prefix}_request_port$request_uri') {
393
+ return 308 $normalized;
394
+ }
395
+ CONFIG
396
+
397
+ c.location('/') do
398
+ c.proxy_pass("http://#{@host}")
399
+ c.proxy_redirect('off')
400
+ c.proxy_set_header('Host', '$http_host')
401
+ c.proxy_set_header('X-Real-IP', '$remote_addr')
402
+ c.proxy_set_header('X-Forwarded-For', '$proxy_add_x_forwarded_for')
403
+ c.proxy_set_header('X-Forwarded-Proto', @ssl ? 'https' : 'http')
404
+ c.proxy_set_header('X-Forwarded-Port', "#{@var_prefix}_x_forwarded_port")
405
+ end
406
+
407
+ add_deprecated_config(@deprecated_additional_config)
408
+ end
349
409
  end
350
410
 
351
- ##
352
- # Writes the Nginx configuration to the (inactive) configuration file.
411
+ # Internal: Write the Nginx configuration to the (inactive) configuration file.
353
412
  #
413
+ # Returns nothing.
354
414
  def write_config
355
- File.write(@config_file_inactive, config_file_content)
415
+ File.write(@config_file_inactive, config.to_s)
356
416
  end
357
417
 
358
- ##
359
- # Renames the inactive Nginx configuration file to its active name.
418
+ # Internal: Rename the inactive Nginx configuration file to its active name.
360
419
  #
420
+ # Returns nothing.
361
421
  def activate_config
362
422
  FileUtils.mv(@config_file_inactive, @config_file_active)
363
423
  end
364
424
 
365
- ##
366
- # Renames the active Nginx configuration file to its inactive name.
425
+ # Internal: Rename the active Nginx configuration file to its inactive name.
367
426
  #
427
+ # Returns nothing.
368
428
  def deactivate_config
369
429
  FileUtils.mv(@config_file_active, @config_file_inactive) if File.exist?(@config_file_active)
370
430
  end
371
431
 
372
- ##
373
- # Ensures hosts are mapped to localhost in the system /etc/hosts file. Useful in development. Uses sudo
374
- # to perform the write, which will prompt for the system user's password.
432
+ # Internal: Ensure hosts are mapped to localhost in the system /etc/hosts file if ensure_host_entries is
433
+ # enabled. Useful in development. Uses sudo to perform the write, which will prompt for the system
434
+ # user's password.
375
435
  #
436
+ # Returns nothing.
376
437
  def ensure_host_entries
438
+ return unless @ensure_host_entries
439
+
377
440
  content = File.read('/etc/hosts')
378
- missing_entries = (@hosts + @subdomains).each_with_object([]) do |h, a|
441
+ missing_entries = @server_names.each_with_object([]) do |h, a|
379
442
  a << h unless content.include?(" #{h}\n")
380
443
  end
381
444
 
@@ -386,24 +449,161 @@ module Potluck
386
449
  run(
387
450
  <<~CMD
388
451
  sudo sh -c 'printf "
389
- #{missing_entries.map { |h| "127.0.0.1 #{h}\n::1 #{h}"}.join("\n")}
452
+ #{missing_entries.map { |h| "127.0.0.1 #{h}\n::1 #{h}" }.join("\n")}
390
453
  " >> /etc/hosts'
391
454
  CMD
392
455
  )
393
456
  end
394
457
 
395
- ##
396
- # Ensures Nginx's base configuration file contains an include statement for Potluck's Nginx
458
+ # Internal: Ensure Nginx's base configuration file contains an include statement for Potluck's Nginx
397
459
  # configuration files. Sudo is not used, so Nginx's base configuration file must be writable by the
398
460
  # system user running this Ruby process.
399
461
  #
462
+ # Returns nothing.
400
463
  def ensure_include
401
464
  config_file = `nginx -t 2>&1`[TEST_CONFIG_REGEX, :config]
402
465
  config_content = File.read(config_file)
403
466
 
404
- if config_content !~ INCLUDE_REGEX
405
- File.write(config_file, config_content.sub(/^( *http *{)( *\n?)( *)/,
406
- "\\1\\2\\3include #{ACTIVE_CONFIG_PATTERN};\n\n\\3"))
467
+ unless config_content.match?(INCLUDE_REGEX)
468
+ config_content.sub!(HTTP_BLOCK_REGEX, "\\1\\2\\3include #{ACTIVE_CONFIG_PATTERN};\n\n\\3")
469
+
470
+ File.write(config_file, config_content)
471
+ end
472
+ end
473
+
474
+ # Internal: Generate self-signed certificate files if SSL is enabled, custom certificate files are not
475
+ # configured, and self-signed certificate files don't exist, will expire soon, or already did expire.
476
+ #
477
+ # Returns nothing.
478
+ def ensure_ssl_files
479
+ return unless @ssl
480
+
481
+ csr = File.join(@dir, "#{@host}.csr")
482
+ ssl_certificate, ssl_certificate_key, ssl_dhparam, auto_generated = ssl_config_values
483
+
484
+ if auto_generated
485
+ config.server(0) do |c|
486
+ c.ssl_stapling('off')
487
+ c.ssl_stapling_verify('off')
488
+ end
489
+ end
490
+
491
+ return if !auto_generated || (
492
+ csr && File.exist?(csr) &&
493
+ ssl_certificate && File.exist?(ssl_certificate) &&
494
+ ssl_certificate_key && File.exist?(ssl_certificate_key) &&
495
+ ssl_dhparam && File.exist?(ssl_dhparam) && (
496
+ Time.parse(run("openssl x509 -enddate -noout -in #{ssl_certificate}").sub('notAfter=', '')) -
497
+ Time.now
498
+ ) >= SSL_CERT_RENEW_DAYS * 24 * 60 * 60
499
+ )
500
+
501
+ log('Generating SSL files...')
502
+
503
+ run("openssl genrsa -out #{ssl_certificate_key} 4096", capture_stderr: false)
504
+ run("openssl req -out #{csr} -key #{ssl_certificate_key} -new -sha256 -config /dev/stdin <<< " \
505
+ "'#{openssl_config}'", capture_stderr: false)
506
+ run("openssl x509 -in #{csr} -out #{ssl_certificate} -signkey #{ssl_certificate_key} -days " \
507
+ "#{SSL_CERT_DAYS} -req -sha256 -extensions req_ext -extfile /dev/stdin <<< '#{openssl_config}'",
508
+ capture_stderr: false)
509
+ run("openssl dhparam -out #{ssl_dhparam} 2048", capture_stderr: false)
510
+
511
+ add_cert_to_keychain(ssl_certificate)
512
+ end
513
+
514
+ # Internal: Add a self-signed SSL certificate file to the system keychain if running on macOS. Uses sudo
515
+ # to perform the write, which will prompt for the system user's password.
516
+ #
517
+ # ssl_certificate - String path to the SSL certificate file.
518
+ #
519
+ # Returns nothing.
520
+ def add_cert_to_keychain(ssl_certificate)
521
+ return unless RUBY_PLATFORM[/darwin/]
522
+
523
+ log('Adding cert to keychain...')
524
+
525
+ run(
526
+ "sudo security delete-certificate -t -c #{@host} 2>&1 || " \
527
+ "sudo security delete-certificate -c #{@host} 2>&1 || :"
528
+ )
529
+
530
+ run('sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ' \
531
+ "#{ssl_certificate}")
532
+ end
533
+
534
+ # Internal: Get and validate the current configured SSL certificate files.
535
+ #
536
+ # Returns an Array of String paths to the SSL certificate, key, and DH parameter files, plus a boolean
537
+ # indicating if the files are auto-generated.
538
+ def ssl_config_values
539
+ ssl_certificate = config.dig('server', 0, 'ssl_certificate', 0)
540
+ ssl_certificate_key = config.dig('server', 0, 'ssl_certificate_key', 0)
541
+ ssl_dhparam = config.dig('server', 0, 'ssl_dhparam', 0)
542
+
543
+ auto_generated = ssl_certificate == @default_ssl_certificate &&
544
+ ssl_certificate_key == @default_ssl_certificate_key &&
545
+ ssl_dhparam == @default_ssl_dhparam
546
+
547
+ if !auto_generated && (
548
+ ssl_certificate == @default_ssl_certificate ||
549
+ ssl_certificate.nil? ||
550
+ ssl_certificate_key == @default_ssl_certificate_key ||
551
+ ssl_certificate_key.nil? ||
552
+ ssl_dhparam == @default_ssl_dhparam ||
553
+ ssl_dhparam.nil?
554
+ )
555
+ raise('Nginx configuration must provide all three SSL file directives (or none): ' \
556
+ 'ssl_certificate, ssl_certificate_key, ssl_dhparam')
557
+ end
558
+
559
+ [ssl_certificate, ssl_certificate_key, ssl_dhparam, auto_generated]
560
+ end
561
+
562
+ # Internal: Get the OpenSSL configuration content used when auto-generating an SSL certificate.
563
+ #
564
+ # Returns the String configuration.
565
+ def openssl_config
566
+ <<~OPENSSL
567
+ [ req ]
568
+ prompt = no
569
+ default_bits = 4096
570
+ distinguished_name = req_distinguished_name
571
+ req_extensions = req_ext
572
+
573
+ [ req_distinguished_name ]
574
+ commonName = #{@host}
575
+
576
+ [ req_ext ]
577
+ subjectAltName = @alt_names
578
+
579
+ [alt_names]
580
+ DNS.1 = #{@host}
581
+ DNS.2 = *.#{@host}
582
+ OPENSSL
583
+ end
584
+
585
+ # Internal: Iterate over a hash of config directives in the old deprecated style and add them.
586
+ #
587
+ # hash - Hash of config directives.
588
+ #
589
+ # Returns nothing.
590
+ def add_deprecated_config(hash)
591
+ return unless hash
592
+
593
+ config do |c|
594
+ hash.each do |directive, value|
595
+ if !value.kind_of?(Hash)
596
+ c.send(directive, value)
597
+ elsif value[:repeat]
598
+ value.each do |k, v|
599
+ c.send(directive, k, v) unless k == :repeat
600
+ end
601
+ else
602
+ c.send(directive) do
603
+ add_deprecated_config(value)
604
+ end
605
+ end
606
+ end
407
607
  end
408
608
  end
409
609
  end