potluck-nginx 0.0.6 → 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,24 +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')
6
+ require_relative('nginx/version')
7
7
 
8
8
  module Potluck
9
- ##
10
- # A Ruby interface for configuring and controlling Nginx. Each instance of this class manages a separate
11
- # Nginx configuration file, which is loaded and unloaded from the base Nginx configuration when #start and
12
- # #stop are called, respectively. Any number of Ruby processes can thus each manage their own Nginx
13
- # configuration and control whether or not it is active without interfering with any other instances or
14
- # non-Ruby processes leveraging Nginx.
9
+ # Public: A Ruby interface for configuring and controlling Nginx.
15
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
16
31
  class Nginx < Service
17
32
  CONFIG_NAME_ACTIVE = 'nginx.conf'
18
33
  CONFIG_NAME_INACTIVE = 'nginx-stopped.conf'
19
- ACTIVE_CONFIG_PATTERN = File.join(DIR, '*', CONFIG_NAME_ACTIVE).freeze
34
+ ACTIVE_CONFIG_PATTERN = File.join(Potluck.config.dir, '*', CONFIG_NAME_ACTIVE).freeze
20
35
 
21
- TEST_CONFIG_REGEX = /nginx: configuration file (?<config>.+) test (failed|is successful)/.freeze
22
- 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?)( *)/
23
39
 
24
40
  NON_LAUNCHCTL_COMMANDS = {
25
41
  status: 'ps aux | grep \'[n]ginx: master process\'',
@@ -27,76 +43,148 @@ module Potluck
27
43
  stop: 'nginx -s stop',
28
44
  }.freeze
29
45
 
30
- ##
31
- # 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.
32
73
  #
33
- # * +hosts+ - One or more hosts.
34
- # * +port+ - Port that the upstream (Ruby web server) is running on.
35
- # * +subdomains+ - One or more subdomains (optional).
36
- # * +ssl+ - SSL configuration arguments to pass to SSL.new (optional).
37
- # * +one_host+ - True if URLs should be normalized to the first host in +hosts+ (optional, default:
38
- # false).
39
- # * +www+ - +true+ if URLs should be normalized to include 'www.' prefix, +false+ to exclude 'www.', and
40
- # +nil+ if either is acceptable (optional, default: +nil+).
41
- # * +multiple_slashes+ - +false+ if any occurrence of multiple slashes in the path portion of the URL
42
- # should be normalized to a single slash (optional, default: +nil+).
43
- # * +multiple_question_marks+ - +false+ if multiple question marks in the URL signifying the start of
44
- # the query string should be normalized to a single question mark (optional, default: +nil+).
45
- # * +trailing_slash+ - +true+ if URLs should be normalized to include a trailing slash at the end of the
46
- # path portion, +false+ to strip any trailing slash, and +nil+ if either is acceptable (optional,
47
- # default: +nil+).
48
- # * +trailing_question_mark+ - +true+ if URLs should be normalized to include a trailing question mark
49
- # when the query string is empty, +false+ to strip any trailing question mark, and +nil+ if either is
50
- # acceptable (optional, default: +nil+).
51
- # * +config+ - Nginx configuration hash; see #config (optional).
52
- # * +ensure_host_entries+ - True if +hosts+ should be added to system /etc/hosts file as mappings to
53
- # localhost (optional, default: false).
54
- # * +args+ - Arguments to pass to Potluck::Service.new (optional).
74
+ # message - String deprecation message.
55
75
  #
56
- def initialize(hosts, port, subdomains: nil, ssl: nil, one_host: false, www: nil, multiple_slashes: nil,
57
- multiple_question_marks: nil, trailing_slash: nil, trailing_question_mark: nil, config: {},
58
- ensure_host_entries: false, **args)
59
- if args[:manage] && !args[:manage].kind_of?(Hash) && !self.class.launchctl?
60
- 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
61
112
  end
62
113
 
63
- super(**args)
114
+ super(**kwargs)
64
115
 
65
- @hosts = Array(hosts).map { |h| h.sub(/^www\./, '') }.uniq
66
- @hosts += @hosts.map { |h| "www.#{h}" }
67
- @host = @hosts.first
68
- @port = port
116
+ if config
117
+ self.class.deprecated("Passing config: {...} to #{self.class.name}.new is deprecated: use a " \
118
+ 'block instead')
69
119
 
70
- @ensure_host_entries = ensure_host_entries
71
- @dir = File.join(DIR, @host)
72
- @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
73
134
 
74
- @scheme = @ssl ? 'https' : 'http'
75
- @other_scheme = @ssl ? 'http' : 'https'
76
- @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
77
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
78
149
  @www = www
79
150
  @multiple_slashes = multiple_slashes
80
151
  @multiple_question_marks = multiple_question_marks
81
152
  @trailing_slash = trailing_slash
82
153
  @trailing_question_mark = trailing_question_mark
83
- @additional_config = config
84
-
85
- FileUtils.mkdir_p(@dir)
154
+ @ensure_host_entries = ensure_host_entries
86
155
 
156
+ @dir = File.join(Potluck.config.dir, @host).freeze
87
157
  @config_file_active = File.join(@dir, CONFIG_NAME_ACTIVE).freeze
88
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
89
176
  end
90
177
 
91
- ##
92
- # Ensures this instance's configuration file is active and starts Nginx if it's managed. If Nginx is
93
- # 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.
94
181
  #
182
+ # Returns nothing.
95
183
  def start
96
184
  return unless manage?
97
185
 
98
- @ssl&.ensure_files
99
- ensure_host_entries if @ensure_host_entries
186
+ ensure_ssl_files
187
+ ensure_host_entries
100
188
  ensure_include
101
189
 
102
190
  write_config
@@ -107,13 +195,13 @@ module Potluck
107
195
  status == :active ? reload : super
108
196
  end
109
197
 
110
- ##
111
- # Ensures this instance's configuration file is inactive and optionally stops the Nginx process if it's
112
- # managed.
198
+ # Public: Ensure this instance's configuration file is inactive and optionally stop Nginx. Does nothing
199
+ # if Nginx is not managed.
113
200
  #
114
- # * +hard+ - True if the Nginx process should be stopped, false to just inactivate this instance's
115
- # 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).
116
203
  #
204
+ # Returns nothing.
117
205
  def stop(hard = false)
118
206
  return unless manage?
119
207
 
@@ -122,261 +210,235 @@ module Potluck
122
210
  hard || status != :active ? super() : reload
123
211
  end
124
212
 
125
- ##
126
- # Reloads Nginx if it's managed.
213
+ # Public: Reload Nginx. Does nothing if Nginx is not managed.
127
214
  #
215
+ # Returns nothing.
128
216
  def reload
129
217
  return unless manage?
130
218
 
131
219
  run('nginx -s reload')
132
220
  end
133
221
 
134
- ##
135
- # Returns the content for the Nginx configuration file as a string.
222
+ # Public: Get or modify the config.
136
223
  #
137
- def config_file_content
138
- 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
139
229
  end
140
230
 
141
- ##
142
- # Content of the launchctl plist file.
231
+ private
232
+
233
+ # Internal: Add upstream directive to the config.
143
234
  #
144
- def self.plist
145
- super(
146
- <<~EOS
147
- <key>ProgramArguments</key>
148
- <array>
149
- <string>/usr/local/opt/nginx/bin/nginx</string>
150
- <string>-g</string>
151
- <string>daemon off;</string>
152
- </array>
153
- <key>StandardOutPath</key>
154
- <string>/usr/local/var/log/nginx/access.log</string>
155
- <key>StandardErrorPath</key>
156
- <string>/usr/local/var/log/nginx/error.log</string>
157
- EOS
158
- )
235
+ # Returns nothing.
236
+ def add_upstream_config
237
+ config.upstream(@host) do |c|
238
+ c.server("127.0.0.1:#{@port}")
239
+ end
159
240
  end
160
241
 
161
- ##
162
- # Converts a hash to an Nginx configuration file content string. Keys should be strings and values
163
- # either strings or hashes. A +nil+ value in a hash will result in that key-value pair being omitted.
164
- #
165
- # * +hash+ - Hash to convert to the string content of an Nginx configuration file.
166
- # * +indent+ - Number of spaces to indent; used when the method is called recursively and should not be
167
- # set explicitly (optional, default: 0).
168
- # * +repeat+ - Value to prepend to each entry of the hash; used when the method is called recursively
169
- # and should not be set explicitly (optional).
170
- #
171
- # Symbol keys in hashes are used as special directives. Including <tt>repeat: true</tt> will cause the
172
- # parent hash's key for the child hash to be prefixed to each line of the output. Example:
173
- #
174
- # {
175
- # # ...
242
+ # Internal: Add map directive for URL host normalization to the config.
176
243
  #
177
- # 'add_header' => {
178
- # repeat: true,
179
- # 'X-Frame-Options' => 'DENY',
180
- # 'X-Content-Type-Options' => 'nosniff',
181
- # }
182
- # }
183
- #
184
- # Result:
185
- #
186
- # # ...
187
- #
188
- # add_header X-Frame-Options DENY;
189
- # add_header X-Content-Type-Options nosniff;
190
- #
191
- # A hash containing <tt>raw: '...'</tt> can be used to include a raw chunk of text rather than key-value
192
- # pairs. Example:
193
- #
194
- # {
195
- # # ...
196
- #
197
- # 'location /' => {
198
- # raw: """
199
- # if ($scheme = https) { ... }
200
- # if ($host ~ ^www.) { ... }
201
- # """,
202
- # }
203
- # }
204
- #
205
- # 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.
206
266
  #
207
- # location / {
208
- # if ($scheme = https) { ... }
209
- # if ($host ~ ^www.) { ... }
210
- # }
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.
211
294
  #
212
- def self.to_nginx_config(hash, indent: 0, repeat: nil)
213
- hash.each_with_object(+'') do |(k, v), config|
214
- next if v.nil?
215
- next if k == :repeat
216
-
217
- config << (
218
- if v.kind_of?(Hash)
219
- if v[:repeat]
220
- to_nginx_config(v, indent: indent, repeat: k)
221
- else
222
- "#{' ' * indent}#{k} {\n#{to_nginx_config(v, indent: indent + 2)}#{' ' * indent}}\n"
223
- end
224
- elsif k == :raw
225
- "#{v.gsub(/^(?=.)/, ' ' * indent)}\n\n"
226
- else
227
- "#{' ' * indent}#{"#{repeat} " if repeat}#{k}#{" #{v}" unless v == true};\n"
228
- end
229
- )
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
+ }
230
303
  end
231
304
  end
232
305
 
233
- 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
316
+
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
234
331
 
235
- ##
236
- # Returns a hash representation of the Nginx configuration file content. Any configuration passed to
237
- # Nginx.new is deep-merged into a base configuration hash, meaning nested hashes are merged rather than
238
- # overwritten (see Util.deep_merge).
332
+ # Internal: Add base/default server definition to the config.
239
333
  #
240
- def config
241
- host_subdomains_regex = ([@host] + @subdomains).join('|')
242
- hosts_subdomains_regex = (@hosts + @subdomains).join('|')
243
-
244
- config = {
245
- "upstream #{@host}" => {
246
- 'server' => "127.0.0.1:#{@port}",
247
- },
248
-
249
- 'server' => Util.deep_merge(
250
- {
251
- 'charset' => 'UTF-8',
252
- 'access_log' => File.join(@dir, 'nginx-access.log'),
253
- 'error_log' => File.join(@dir, 'nginx-error.log'),
254
-
255
- 'listen' => {
256
- repeat: true,
257
- '8080' => true,
258
- '[::]:8080' => true,
259
- '4433 ssl http2' => @ssl ? true : nil,
260
- '[::]:4433 ssl http2' => @ssl ? true : nil,
261
- },
262
- 'server_name' => (@hosts + @subdomains).join(' '),
263
-
264
- 'gzip' => 'on',
265
- 'gzip_types' => 'application/javascript application/json application/xml text/css '\
266
- 'text/javascript text/plain',
267
-
268
- 'add_header' => {
269
- repeat: true,
270
- 'Referrer-Policy' => '\'same-origin\' always',
271
- 'X-Frame-Options' => '\'DENY\' always',
272
- 'X-XSS-Protection' => '\'1; mode=block\' always',
273
- 'X-Content-Type-Options' => '\'nosniff\' always',
274
- },
275
- },
276
-
277
- @ssl ? @ssl.config : {},
278
-
279
- {
280
- 'location /' => {
281
- raw: """
282
- if ($host !~ ^#{hosts_subdomains_regex}$) { return 404; }
283
-
284
- set $r 0;
285
- set $s $scheme;
286
- set $h $host;
287
- set $port #{@ssl ? '443' : '80'};
288
- set $p '';
289
- set $u '';
290
- set $q '';
291
-
292
- #{if @www.nil? && @one_host == false
293
- nil
294
- elsif @www.nil? && @one_host == true
295
- "if ($host !~ ^(www.)?#{host_subdomains_regex}$) { set $h $1#{@host}; set $r 1; }"
296
- elsif @www == false && @one_host == false
297
- "if ($host ~ ^www.(.+)$) { set $h $1; set $r 1; }"
298
- elsif @www == false && @one_host == true
299
- "if ($host !~ ^#{host_subdomains_regex}$) { set $h #{@host}; set $r 1; }"
300
- elsif @www == true && @one_host == false
301
- "if ($host !~ ^www.(.+)$) { set $h $1; set $r 1; }"
302
- elsif @www == true && @one_host == true
303
- "if ($host !~ ^www.#{host_subdomains_regex}$) { set $h www.#{@host}; set $r 1; }"
304
- end}
305
-
306
- if ($scheme = #{@other_scheme}) { set $s #{@scheme}; set $r 1; }
307
- if ($http_host ~ :([0-9]+)$) { set $p :$1; set $port $1; }
308
- if ($request_uri ~ ^([^\\?]+)(\\?+.*)?$) { set $u $1; set $q $2; }
309
-
310
- #{'if ($u ~ //) { set $u $uri; set $r 1; }' if @multiple_slashes == false}
311
- #{'if ($q ~ ^\?\?+(.*)$) { set $q ?$1; set $r 1; }' if @multiple_question_marks == false}
312
-
313
- #{if @trailing_question_mark == false
314
- 'if ($q ~ \?+$) { set $q \'\'; set $r 1; }'
315
- elsif @trailing_question_mark == true
316
- 'if ($q !~ .) { set $q ?; set $r 1; }'
317
- end}
318
- #{if @trailing_slash == false
319
- 'if ($u ~ (.+?)/+$) { set $u $1; set $r 1; }'
320
- elsif @trailing_slash == true
321
- 'if ($u ~ [^/]$) { set $u $u/; set $r 1; }'
322
- end}
323
-
324
- set $mr $request_method$r;
325
-
326
- if ($mr ~ ^(GET|HEAD)1$) { return 301 $s://$h$p$u$q; }
327
- if ($mr ~ 1$) { return 308 $s://$h$p$u$q; }
328
- """.strip.gsub(/^ +/, '').gsub(/\n{3,}/, "\n\n"),
329
-
330
- 'proxy_pass' => "http://#{@host}",
331
- 'proxy_redirect' => 'off',
332
- 'proxy_set_header' => {
333
- repeat: true,
334
- 'Host' => '$http_host',
335
- 'X-Real-IP' => '$remote_addr',
336
- 'X-Forwarded-For' => '$proxy_add_x_forwarded_for',
337
- 'X-Forwarded-Proto' => @ssl ? 'https' : 'http',
338
- 'X-Forwarded-Port' => '$port',
339
- },
340
- },
341
- },
342
-
343
- @additional_config,
344
- )
345
- }
346
-
347
- 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
348
409
  end
349
410
 
350
- ##
351
- # Writes the Nginx configuration to the (inactive) configuration file.
411
+ # Internal: Write the Nginx configuration to the (inactive) configuration file.
352
412
  #
413
+ # Returns nothing.
353
414
  def write_config
354
- File.open(@config_file_inactive, 'w') do |file|
355
- file.write(config_file_content)
356
- end
415
+ File.write(@config_file_inactive, config.to_s)
357
416
  end
358
417
 
359
- ##
360
- # Renames the inactive Nginx configuration file to its active name.
418
+ # Internal: Rename the inactive Nginx configuration file to its active name.
361
419
  #
420
+ # Returns nothing.
362
421
  def activate_config
363
422
  FileUtils.mv(@config_file_inactive, @config_file_active)
364
423
  end
365
424
 
366
- ##
367
- # Renames the active Nginx configuration file to its inactive name.
425
+ # Internal: Rename the active Nginx configuration file to its inactive name.
368
426
  #
427
+ # Returns nothing.
369
428
  def deactivate_config
370
- FileUtils.mv(@config_file_active, @config_file_inactive) if File.exists?(@config_file_active)
429
+ FileUtils.mv(@config_file_active, @config_file_inactive) if File.exist?(@config_file_active)
371
430
  end
372
431
 
373
- ##
374
- # Ensures hosts are mapped to localhost in the system /etc/hosts file. Useful in development. Uses sudo
375
- # 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.
376
435
  #
436
+ # Returns nothing.
377
437
  def ensure_host_entries
438
+ return unless @ensure_host_entries
439
+
378
440
  content = File.read('/etc/hosts')
379
- missing_entries = (@hosts + @subdomains).each_with_object([]) do |h, a|
441
+ missing_entries = @server_names.each_with_object([]) do |h, a|
380
442
  a << h unless content.include?(" #{h}\n")
381
443
  end
382
444
 
@@ -387,24 +449,161 @@ module Potluck
387
449
  run(
388
450
  <<~CMD
389
451
  sudo sh -c 'printf "
390
- #{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")}
391
453
  " >> /etc/hosts'
392
454
  CMD
393
455
  )
394
456
  end
395
457
 
396
- ##
397
- # 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
398
459
  # configuration files. Sudo is not used, so Nginx's base configuration file must be writable by the
399
460
  # system user running this Ruby process.
400
461
  #
462
+ # Returns nothing.
401
463
  def ensure_include
402
464
  config_file = `nginx -t 2>&1`[TEST_CONFIG_REGEX, :config]
403
465
  config_content = File.read(config_file)
404
466
 
405
- if config_content !~ INCLUDE_REGEX
406
- File.write(config_file, config_content.sub(/^( *http *{)( *\n?)( *)/,
407
- "\\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
408
607
  end
409
608
  end
410
609
  end