use cloudflared, bypass nginx

fixes #1093
This commit is contained in:
Changaco 2021-05-21 12:22:25 +02:00
parent 68bc6cf294
commit f382da89da
18 changed files with 181 additions and 172 deletions

View File

@ -1,12 +0,0 @@
files:
"/etc/profile.d/appenv.sh":
mode: "000644"
owner: root
group: root
content: |
#!/bin/sh
export PGHOST=$(/opt/elasticbeanstalk/bin/get-config environment -k PGHOST)
export PGPORT=$(/opt/elasticbeanstalk/bin/get-config environment -k PGPORT)
export PGDATABASE=$(/opt/elasticbeanstalk/bin/get-config environment -k PGDATABASE)
export PGUSER=$(/opt/elasticbeanstalk/bin/get-config environment -k PGUSER)
export PGPASSWORD=$(/opt/elasticbeanstalk/bin/get-config environment -k PGPASSWORD)

View File

@ -1,3 +0,0 @@
packages:
yum:
libffi-devel: [] # for misaka

1
.platform/confighooks Symbolic link
View File

@ -0,0 +1 @@
hooks

View File

@ -0,0 +1,5 @@
tunnel: liberapay-prod
credentials-file: /etc/cloudflared/liberapay-prod.json
ingress:
- service: unix:/var/app/current/socket

View File

@ -0,0 +1,43 @@
[Unit]
Description=Cloudflare Tunnel daemon for web app #%i
After=network.target webapp@%i.service
Wants=network.target
BindsTo=webapp@%i.service
[Service]
Type=notify
ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel --config /etc/cloudflared/cloudflared.conf run
User=cloudflared
Group=cloudflared
Restart=on-failure
RestartSec=5s
CapabilityBoundingSet=
AmbientCapabilities=
PrivateUsers=true
NoNewPrivileges=true
LimitNOFILE=1048576
UMask=0077
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectHostname=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallArchitectures=native

7
.platform/files/pgenv.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
export PGHOST=$(/opt/elasticbeanstalk/bin/get-config environment -k PGHOST)
export PGPORT=$(/opt/elasticbeanstalk/bin/get-config environment -k PGPORT)
export PGDATABASE=$(/opt/elasticbeanstalk/bin/get-config environment -k PGDATABASE)
export PGUSER=$(/opt/elasticbeanstalk/bin/get-config environment -k PGUSER)
export PGPASSWORD=$(/opt/elasticbeanstalk/bin/get-config environment -k PGPASSWORD)

View File

@ -0,0 +1,21 @@
[Unit]
Description=Web application daemon #%i
Requires=webapp@%i.socket
[Service]
User=webapp
Group=webapp
Type=notify
WorkingDirectory=/var/app/current/
EnvironmentFile=/opt/elasticbeanstalk/deployment/env
Sockets=webapp@%i.socket
ExecStart=/var/app/venv/staging-LQM1lest/bin/python app.py --bind fd:3
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=web
# When stopping, send the initial SIGTERM to the main process only.
KillMode=mixed

View File

@ -0,0 +1,9 @@
[Unit]
Description=Socket of web application daemon #%i
[Socket]
ListenStream=/var/app/%i/socket
Service=webapp@%i.service
SocketUser=webapp
SocketGroup=cloudflared
SocketMode=0660

View File

@ -0,0 +1,37 @@
#!/bin/bash
# Tell bash to be strict and log everything
set -eux
# Compute the deployment ID
deploy_id=$(($(cat /var/app/_deploy_id 2>/dev/null || echo 0) + 1))
max_deploy_id=$((deploy_id + 99))
while systemctl is-active --quiet webapp@$deploy_id.service; do
let deploy_id++
if [ $deploy_id -gt $max_deploy_id ]; then
echo "this script appears to be stuck in an infinite loop, exiting"
exit 1
fi
done
# Rename the app directory
app_dir=$(pwd)
rm -rf /var/app/$deploy_id
mv $app_dir /var/app/$deploy_id
ln -s /var/app/$deploy_id $app_dir
# Start the new instance and its proxy
systemctl start webapp@$deploy_id.service cloudflared@$deploy_id.service
# Save the new deployment ID
echo $deploy_id >/var/app/_deploy_id
# Stop the old instance(s) and their proxies
let i=1
while [ $i -lt $deploy_id ]; do
systemctl stop cloudflared@$i.service
systemctl stop webapp@$i.service
systemctl stop webapp@$i.socket
rm -rf /var/app/$i
let i++
done

View File

@ -0,0 +1,44 @@
#!/bin/bash
# Tell bash to be strict and log everything
set -eux
# Install libffi-devel for misaka, and htop for when I want to look at what's going on
yum install -y libffi-devel htop
# Install PostgreSQL client tools and libraries
amazon-linux-extras install -y postgresql11
# Automatically set the PG* environment variables so that `psql` connects to the liberapay database by default
install -m 644 -o root -g root -t /etc/profile.d .platform/files/pgenv.sh
# Install the systemd service files for the webapp and cloudflared
install -m 644 -o root -g root -t /etc/systemd/system .platform/files/cloudflared@.service
install -m 644 -o root -g root -t /etc/systemd/system .platform/files/webapp@.service
install -m 644 -o root -g root -t /etc/systemd/system .platform/files/webapp@.socket
systemctl daemon-reload
# Install cloudflared, directly from GitHub
if ! which cloudflared 2>/dev/null || [ $(cloudflared version) != "cloudflared version 2021.5.8 "* ]; then
wget https://github.com/cloudflare/cloudflared/releases/download/2021.5.8/cloudflared-linux-amd64
hash=$(sha256sum cloudflared-linux-amd64 | cut -d' ' -f1)
expected_hash=224cd850cb042a5da1d15432063ed04bf8764241de769338e65c44639ed6c28e
if [ $hash != $expected_hash ]; then
echo "cloudflared binary downloaded from GitHub doesn't match expected hash: $hash != $expected_hash"
exit 1
fi
install -m 755 -o root -g root cloudflared-linux-amd64 /usr/local/bin/cloudflared
rm cloudflared-linux-amd64
fi
# Create the cloudflared system user and group
groupadd -r cloudflared || true
useradd -r -g cloudflared cloudflared || true
# Install the Cloudflare Tunnel configuration and credentials files
install -o cloudflared -g cloudflared -m 755 -d /etc/cloudflared
install -o cloudflared -g cloudflared -m 644 -t /etc/cloudflared .platform/files/cloudflared.conf
if ! [ -f /etc/cloudflared/liberapay-prod.json ]; then
aws s3 cp s3://serverfiles.liberapay.org/liberapay-prod.json liberapay-prod.json
install -o cloudflared -g cloudflared -m 644 -t /etc/cloudflared liberapay-prod.json
rm liberapay-prod.json
fi

View File

@ -1,3 +0,0 @@
#!/bin/bash -eu
amazon-linux-extras install -y postgresql11

View File

@ -1 +1 @@
web: python app.py
web: sleep 7d

View File

@ -60,7 +60,6 @@ from liberapay.utils.state_chain import (
merge_responses,
overwrite_status_code_of_gateway_errors,
raise_response_to_OPTIONS_request,
reject_requests_bypassing_proxy,
return_500_for_exception,
set_output_to_None,
turn_socket_error_into_50X,
@ -198,8 +197,6 @@ algorithm.functions = [
raise_response_to_OPTIONS_request,
set_output_to_None,
reject_requests_bypassing_proxy,
canonize,
algorithm['extract_accept_header'],
set_default_security_headers,
@ -325,46 +322,19 @@ aspen.http.request.Querystring.serialize = _Querystring_serialize
if hasattr(pando.http.request.Request, 'source'):
raise Warning('pando.http.request.Request.source already exists')
def _source(self):
def f():
addr = self.environ.get('REMOTE_ADDR') or self.environ[b'REMOTE_ADDR']
addr = ip_address(addr.decode('ascii') if type(addr) is bytes else addr)
trusted_proxies = getattr(self.website, 'trusted_proxies', None)
forwarded_for = self.headers.get(b'X-Forwarded-For')
self.__dict__['bypasses_proxy'] = bool(trusted_proxies)
if not trusted_proxies or not forwarded_for:
return addr
for networks in trusted_proxies:
is_trusted = False
for network in networks:
is_trusted = addr.is_private if network == 'private' else addr in network
if is_trusted:
break
if not is_trusted:
return addr
i = forwarded_for.rfind(b',')
try:
addr = ip_address(forwarded_for[i+1:].decode('ascii').strip())
except (UnicodeDecodeError, ValueError):
return addr
if i == -1:
if networks is trusted_proxies[-1]:
break
return addr
forwarded_for = forwarded_for[:i]
self.__dict__['bypasses_proxy'] = False
return addr
r = f()
self.__dict__['source'] = r
return r
if 'source' not in self.__dict__:
addr = (
self.headers.get(b'Cf-Connecting-Ip') or
self.environ.get(b'REMOTE_ADDR') or
self.environ.get('REMOTE_ADDR') or
'0.0.0.0'
)
if isinstance(addr, bytes):
addr = addr.decode()
self.__dict__['source'] = ip_address(addr)
return self.__dict__['source']
pando.http.request.Request.source = property(_source)
if hasattr(pando.http.request.Request, 'bypasses_proxy'):
raise Warning('pando.http.request.Request.bypasses_proxy already exists')
def _bypasses_proxy(self):
self.source
return self.__dict__['bypasses_proxy']
pando.http.request.Request.bypasses_proxy = property(_bypasses_proxy)
if hasattr(pando.http.request.Request, 'find_input_name'):
raise Warning('pando.http.request.Request.find_input_name already exists')
def _find_input_name(self, value):

View File

@ -37,13 +37,6 @@ def raise_response_to_OPTIONS_request(request, response):
raise response
def reject_requests_bypassing_proxy(request, response):
"""Reject requests that bypass Cloudflare, except health checks.
"""
if request.bypasses_proxy and request.path.raw != '/callbacks/health':
raise response.error(403, "The request bypassed a proxy.")
def canonize(request, website):
"""Enforce a certain scheme and hostname.

View File

@ -1,13 +1,10 @@
from decimal import Decimal
from ipaddress import ip_network
import json
import logging
from operator import itemgetter
import os
import re
import socket
from tempfile import mkstemp
from time import time
import traceback
import babel.localedata
@ -20,7 +17,6 @@ from postgres.cursors import SimpleRowCursor
import psycopg2
from psycopg2.extensions import adapt, AsIs, new_type, register_adapter, register_type
from psycopg2_pool import PoolError
import requests
import sass
import sentry_sdk
from state_chain import StateChain
@ -43,7 +39,7 @@ from liberapay.models.payin import Payin
from liberapay.models.repository import Repository
from liberapay.models.tip import Tip
from liberapay.security.crypto import Cryptograph
from liberapay.utils import find_files, markdown, mkdir_p, resolve, urlquote
from liberapay.utils import find_files, markdown, resolve
from liberapay.utils.emails import compile_email_spt
from liberapay.utils.http_caching import asset_etag
from liberapay.utils.query_cache import QueryCache
@ -269,7 +265,6 @@ class AppConf:
stripe_connect_id=str,
stripe_publishable_key=str,
stripe_secret_key=str,
trusted_proxies=list,
twitch_id=str,
twitch_secret=str,
twitter_callback=str,
@ -315,40 +310,6 @@ def app_conf(db):
return {'app_conf': app_conf}
def trusted_proxies(app_conf, env, tell_sentry):
if not app_conf:
return {'trusted_proxies': []}
def parse_network(net):
if net == 'private':
return [net]
elif net.startswith('https://'):
d = env.log_dir + '/trusted_proxies/'
mkdir_p(d)
filename = d + urlquote(net, '')
skip_download = (
os.path.exists(filename) and
os.stat(filename).st_size > 0 and
os.stat(filename).st_mtime > time() - 60*60*24*7
)
if not skip_download:
tmpfd, tmp_path = mkstemp(dir=d)
with open(tmpfd, 'w') as f:
f.write(requests.get(net).text)
os.rename(tmp_path, filename)
with open(filename, 'rb') as f:
return [ip_network(x) for x in f.read().decode('ascii').strip().split()]
else:
return [ip_network(net)]
try:
return {'trusted_proxies': [
sum((parse_network(net) for net in networks), [])
for networks in (app_conf.trusted_proxies or ())
]}
except Exception as e:
tell_sentry(e, {})
return {'trusted_proxies': []}
def mail(app_conf, env, project_root='.'):
if not app_conf:
return
@ -873,7 +834,6 @@ full_chain = StateChain(
accounts_elsewhere,
load_scss_variables,
s3,
trusted_proxies,
currency_exchange_rates,
)

View File

@ -52,7 +52,6 @@ INSERT INTO app_conf (key, value) VALUES
('stripe_connect_id', '"ca_DEYxiYHBHZtGj32l9uczcsunbQOcRq8H"'::jsonb),
('stripe_publishable_key', '"pk_test_rGZY3Q7ba61df50X0h70iHeZ"'::jsonb),
('stripe_secret_key', '"sk_test_QTUa8AqWXyU2feC32glNgDQd"'::jsonb),
('trusted_proxies', '[]'::jsonb),
('twitch_id', '"9ro3g4slh0de5yijy6rqb2p0jgd7hi"'::jsonb),
('twitch_secret', '"o090sc7828d7gljtrqc5n4vcpx3bfx"'::jsonb),
('twitter_callback', '"http://127.0.0.1:8339/on/twitter/associate"'::jsonb),

1
sql/branch.sql Normal file
View File

@ -0,0 +1 @@
DELETE FROM app_conf WHERE key = 'trusted_proxies';

View File

@ -1,63 +0,0 @@
from ipaddress import IPv4Network
from liberapay.testing import Harness
class Tests(Harness):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.website._trusted_proxies = getattr(cls.website, 'trusted_proxies', None)
cls.website.trusted_proxies = [
[IPv4Network('10.0.0.0/8')],
[IPv4Network('141.101.64.0/18')],
]
@classmethod
def tearDownClass(cls):
cls.website.trusted_proxies = cls.website._trusted_proxies
super().tearDownClass()
def request(self, forwarded_for, source, **kw):
kw['HTTP_X_FORWARDED_FOR'] = forwarded_for
kw['REMOTE_ADDR'] = source
kw.setdefault('return_after', 'attach_environ_to_request')
kw.setdefault('want', 'request')
return self.client.GET('/', **kw)
def test_request_source_with_invalid_header_from_trusted_proxy(self):
r = self.request(b'f\xc3\xa9e, \t bar', b'10.0.0.1')
assert str(r.source) == '10.0.0.1'
assert r.bypasses_proxy is True
def test_request_source_with_invalid_header_from_untrusted_proxy(self):
r = self.request(b'f\xc3\xa9e, \tbar', b'8.8.8.8')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is True
def test_request_source_with_valid_headers_from_trusted_proxies(self):
r = self.request(b'8.8.8.8,141.101.69.139', b'10.0.0.1')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is False
r = self.request(b'8.8.8.8', b'10.0.0.2')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is True
def test_request_source_with_valid_headers_from_untrusted_proxies(self):
# 8.8.8.8 claims that the request came from 0.0.0.0, but we don't trust 8.8.8.8
r = self.request(b'0.0.0.0, 8.8.8.8,141.101.69.140', b'10.0.0.1')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is False
r = self.request(b'0.0.0.0, 8.8.8.8', b'10.0.0.1')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is True
def test_request_source_with_forged_headers_from_untrusted_client(self):
# 8.8.8.8 claims that the request came from a trusted proxy, but we don't trust 8.8.8.8
r = self.request(b'0.0.0.0,141.101.69.141, 8.8.8.8,141.101.69.142', b'10.0.0.1')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is False
r = self.request(b'0.0.0.0, 141.101.69.143, 8.8.8.8', b'10.0.0.1')
assert str(r.source) == '8.8.8.8'
assert r.bypasses_proxy is True