Skip to content

Commit 14efd4e

Browse files
committed
feat(ocf_www): add nginx reverse proxy in front of apache
Deploy nginx on test ports (8080/8443) proxying to apache as a slowloris mitigation layer. Includes rate limiting, request buffering, SSL termination, and dynamic user vhost generation.
1 parent e0865d4 commit 14efd4e

11 files changed

Lines changed: 454 additions & 95 deletions

File tree

modules/ocf_www/files/build-vhosts

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ from ocflib.vhost.web import get_vhosts
2222

2323
APACHE_SITE_CONFIG = '/etc/apache2/ocf-vhost.conf'
2424
NGINX_SITE_CONFIG = '/etc/nginx/sites-enabled/virtual'
25+
NGINX_WEB_SITE_CONFIG = '/etc/nginx/ocf-vhost.conf'
26+
27+
# Must match $backend_port in ocf_www::init
28+
BACKEND_PORT = 16767
2529

2630
LETS_ENCRYPT_SSL = Path('/services/http/ssl')
2731
SYSTEM_SSL = Path('/etc/ssl/private')
@@ -259,15 +263,15 @@ def build_config(src_vhosts, template, dev_config=False):
259263
))
260264

261265
return '\n\n'.join(
262-
template.render(vhost=vhost)
266+
template.render(vhost=vhost, backend_port=BACKEND_PORT)
263267
for vhost in sorted(
264268
vhosts,
265269
key=lambda vhost: (vhost.user, vhost.fqdn, bool(vhost.ssl)),
266270
)
267271
)
268272

269273

270-
def test_and_overwrite_config(config_path, new_config, target):
274+
def test_and_overwrite_config(config_path, new_config, test_cmd):
271275
"""Diffs and tests the new config and overwrites the old config if
272276
the test passes.
273277
@@ -306,10 +310,7 @@ def test_and_overwrite_config(config_path, new_config, target):
306310
os.rename(new_path, config_path)
307311

308312
report('Performing config test.')
309-
if target == 'web':
310-
ret = subprocess.call(('apachectl', 'configtest'))
311-
else:
312-
ret = subprocess.call(('nginx', '-t'))
313+
ret = subprocess.call(test_cmd)
313314

314315
if ret != 0:
315316
report('Test failed!')
@@ -404,7 +405,6 @@ def main():
404405
changed |= process_app_vhosts()
405406

406407
if args.target == 'web':
407-
site_cfg = APACHE_SITE_CONFIG
408408
# Build app vhosts so that they can get proxied to apphost.o.b.e
409409
# Placed before regular vhosts so they take priority in domain matching
410410
# (sometimes hosts have entries in both vhost.conf and vhost-app.conf)
@@ -414,17 +414,58 @@ def main():
414414
for domain, conf in get_app_vhosts().items()
415415
if 'dev' not in conf['flags']
416416
}
417-
config = build_config(
417+
418+
web_vhosts = get_vhosts()
419+
420+
# Apache config (existing behavior)
421+
apache_config = build_config(
418422
prod_app_vhosts,
419423
jinja_env.get_template('vhost-web.jinja'),
420424
dev_config=args.dev,
421425
)
422-
config += '\n\n'
423-
config += build_config(
424-
get_vhosts(),
426+
apache_config += '\n\n'
427+
apache_config += build_config(
428+
web_vhosts,
425429
jinja_env.get_template('vhost-web.jinja'),
426430
dev_config=args.dev,
427431
)
432+
433+
# Nginx frontend config
434+
nginx_config = build_config(
435+
prod_app_vhosts,
436+
jinja_env.get_template('vhost-web-nginx.jinja'),
437+
dev_config=args.dev,
438+
)
439+
nginx_config += '\n\n'
440+
nginx_config += build_config(
441+
web_vhosts,
442+
jinja_env.get_template('vhost-web-nginx.jinja'),
443+
dev_config=args.dev,
444+
)
445+
446+
if args.dry_run:
447+
report('=== Apache config ===')
448+
report(apache_config)
449+
report('\n=== Nginx config ===')
450+
report(nginx_config)
451+
return 0
452+
453+
changed |= test_and_overwrite_config(
454+
APACHE_SITE_CONFIG, apache_config, ('apachectl', 'configtest'),
455+
)
456+
changed |= test_and_overwrite_config(
457+
NGINX_WEB_SITE_CONFIG, nginx_config, ('nginx', '-t'),
458+
)
459+
460+
if changed:
461+
if not args.no_reload:
462+
report('Things changed, reloading.')
463+
subprocess.check_call(('systemctl', 'reload', 'apache2'))
464+
subprocess.check_call(('systemctl', 'reload', 'nginx'))
465+
else:
466+
report('Not reloading, as you requested.')
467+
else:
468+
report('Nothing changed, not doing anything.')
428469
else:
429470
site_cfg = NGINX_SITE_CONFIG
430471
config = build_config(
@@ -433,22 +474,22 @@ def main():
433474
dev_config=args.dev,
434475
)
435476

436-
if args.dry_run:
437-
report(config)
438-
return 0
477+
if args.dry_run:
478+
report(config)
479+
return 0
439480

440-
changed |= test_and_overwrite_config(site_cfg, config, args.target)
441-
if changed:
442-
if not args.no_reload:
443-
report('Things changed, reloading.')
444-
if args.target == 'web':
445-
subprocess.check_call(('systemctl', 'reload', 'apache2'))
446-
else:
481+
changed |= test_and_overwrite_config(
482+
site_cfg, config, ('nginx', '-t'),
483+
)
484+
485+
if changed:
486+
if not args.no_reload:
487+
report('Things changed, reloading.')
447488
subprocess.check_call(('systemctl', 'reload', 'nginx'))
489+
else:
490+
report('Not reloading, as you requested.')
448491
else:
449-
report('Not reloading, as you requested.')
450-
else:
451-
report('Nothing changed, not doing anything.')
492+
report('Nothing changed, not doing anything.')
452493

453494

454495
if __name__ == '__main__':
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# {{vhost.comment}}
2+
# CR-soon oliverni: move to 80/443
3+
4+
{% if vhost.ssl %}
5+
server {
6+
listen 8443 ssl http2;
7+
listen [::]:8443 ssl http2;
8+
server_name "{{vhost.fqdn}}";
9+
10+
ssl_certificate {{vhost.ssl.bundle}};
11+
ssl_certificate_key {{vhost.ssl.key}};
12+
13+
add_header Strict-Transport-Security "max-age=31536000" always;
14+
15+
limit_req zone=web_ratelimit burst=20 nodelay;
16+
limit_conn web_connlimit 50;
17+
18+
{% for name, value in vhost.response_headers.items() %}
19+
add_header {{name}} "{{value}}" always;
20+
{% endfor %}
21+
22+
location /.well-known/ {
23+
alias /var/lib/lets-encrypt/.well-known/;
24+
}
25+
26+
location / {
27+
{% if vhost.is_redirect %}
28+
return {{vhost.redirect_type}} {{vhost.redirect_dest}}$request_uri;
29+
{% else %}
30+
proxy_pass http://127.0.0.1:{{backend_port}};
31+
proxy_set_header Host $host;
32+
proxy_set_header X-Forwarded-For $remote_addr;
33+
proxy_set_header X-Forwarded-Proto $scheme;
34+
proxy_set_header X-Real-IP $remote_addr;
35+
{% endif %}
36+
}
37+
38+
access_log /var/log/nginx/vhost-access.log vhost;
39+
}
40+
{% endif %}
41+
42+
{% if not vhost.ssl or vhost.is_redirect %}
43+
# HTTP (redirect or non-SSL)
44+
server {
45+
listen 8080;
46+
listen [::]:8080;
47+
server_name "{{vhost.fqdn}}";
48+
49+
location /.well-known/ {
50+
alias /var/lib/lets-encrypt/.well-known/;
51+
}
52+
53+
location / {
54+
{% if vhost.is_redirect %}
55+
return {{vhost.redirect_type}} {{vhost.redirect_dest}}$request_uri;
56+
{% elif vhost.ssl %}
57+
return 301 {{vhost.canonical_url}}$request_uri;
58+
{% else %}
59+
proxy_pass http://127.0.0.1:{{backend_port}};
60+
proxy_set_header Host $host;
61+
proxy_set_header X-Forwarded-For $remote_addr;
62+
proxy_set_header X-Real-IP $remote_addr;
63+
{% endif %}
64+
}
65+
}
66+
{% endif %}

modules/ocf_www/files/vhost-web.jinja

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# {{vhost.comment}}
2-
<VirtualHost *:{{vhost.port}}>
2+
{% set ports = [vhost.port, backend_port] if vhost.ssl else [vhost.port] %}
3+
{% for port in ports %}
4+
<VirtualHost *:{{port}}>
35
ServerName {{vhost.fqdn}}
46
ServerAdmin {{vhost.contact_email}}
57

6-
{% if vhost.ssl %}
8+
{% if vhost.ssl and port != backend_port %}
79
# SSL
810
SSLEngine on
911
SSLCertificateFile {{vhost.ssl.bundle}}
@@ -27,7 +29,7 @@
2729
# Proxy to the local "unavailable" vhost, which serves up a friendly
2830
# "your website is rekt" page.
2931
RequestHeader set Host unavailable.ocf.berkeley.edu
30-
ProxyPass / http://localhost/
32+
ProxyPass / http://127.0.0.1/
3133
{% else %}
3234
DocumentRoot {{vhost.docroot}}
3335

@@ -59,3 +61,4 @@
5961

6062
UserDir disabled
6163
</VirtualHost>
64+
{% endfor %}

modules/ocf_www/manifests/init.pp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
#
1111
# The interesting config is in ocf_www::site::www, which sets up
1212
# www.ocf.berkeley.edu, which is by far the most complicated domain.
13+
#
14+
# Nginx sits in front of Apache for slowloris protection.
15+
# CR-soon oliverni: swap nginx to 80/443, apache to 127.0.0.1:$backend_port only
1316
class ocf_www {
17+
# Port Apache listens on as nginx's backend (plain HTTP on localhost).
18+
# Must match BACKEND_PORT in build-vhosts.
19+
# Phase 2: make this the only Apache port and bind to 127.0.0.1.
20+
$backend_port = 16767
1421
include ocf::acct
1522
include ocf::extrapackages
1623
include ocf::firewall::allow_web
@@ -26,6 +33,9 @@
2633
web => false,
2734
}
2835

36+
# nginx reverse proxy (test ports for now)
37+
include ocf_www::nginx
38+
2939
class {
3040
'::apache':
3141
log_formats => {

0 commit comments

Comments
 (0)