├── .gitignore ├── LICENSE ├── README.rst ├── setup.py └── src ├── rproxy ├── __init__.py └── _version.py └── twisted └── plugins └── rproxy.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.o 3 | *.py[co] 4 | *.so 5 | _trial_temp*/ 6 | build/ 7 | dropin.cache 8 | doc/ 9 | docs/_build/ 10 | dist/ 11 | venv/ 12 | htmlcov/ 13 | .coverage 14 | *~ 15 | *.lock 16 | apidocs/ 17 | *.log 18 | *.DS_Store 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2018 2 | Amber Brown 3 | Mark R. Williams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | rproxy 2 | ====== 3 | 4 | A super simple HTTP/1.1 proxy, with TLS and Let's Encrypt support. 5 | 6 | rproxy takes care of your Let's Encrypt certificates, automatically renewing them. 7 | This is done by the excellent `txacme `_ library. 8 | 9 | Install from PyPI: 10 | 11 | .. code:: 12 | 13 | $ pip install rproxy 14 | 15 | Make a directory to store your certificates: 16 | 17 | .. code:: 18 | 19 | $ mkdir my-certs 20 | 21 | Make a ``rproxy.ini``: 22 | 23 | .. code:: 24 | 25 | [rproxy] 26 | certificates=my-certs 27 | http_ports=80 28 | https_ports=443 29 | 30 | [hosts] 31 | mysite.com_port=8080 32 | 33 | Then run it: 34 | 35 | .. code:: 36 | 37 | sudo twistd -u nobody -g nobody -n rproxy 38 | 39 | 40 | This will start the server, drop permissions (setting the effective uid/guid to nobody), and will proxy incoming requests to ``mysite.com`` to ``localhost:8080``. 41 | You can configure it further: 42 | 43 | .. code:: 44 | 45 | [rproxy] 46 | certificates=my-certs 47 | http_ports=80,8080 48 | https_ports=443 49 | clacks=true 50 | 51 | [hosts] 52 | mysite.com_port=8080 53 | mysite.com_host=otherserver 54 | mysite.com_onlysecure=True 55 | mysite.com_proxysecure=True 56 | 57 | myothersite.net_port=8081 58 | 59 | 60 | This config will: 61 | 62 | - connect to ``https://otherserver:8080`` as the proxied server for ``mysite.com``, and only allow HTTPS connections to the proxy for this site 63 | - connect to ``http://localhost:8081`` as the proxied server for ``myothersite.net``, and allow HTTP or HTTPS connections. 64 | 65 | 66 | General Config 67 | -------------- 68 | 69 | - ``http_ports`` -- comma-separated list of numerical ports to listen on for HTTP connections. 70 | - ``https_ports`` -- comma-separated list of numerical ports to listen on for HTTPS connections. 71 | - ``certificates`` -- directory where certificates are kept. 72 | - ``clacks`` -- Enable ``X-Clacks-Overhead`` for requests. 73 | 74 | 75 | Hosts Config 76 | ------------ 77 | 78 | - ``_onlysecure`` -- enforce HTTPS connections. If not set, or set to False, it will allow HTTP and HTTPS connections. 79 | - ``_proxysecure`` -- connect to the proxied server by HTTPS. If not set, or set to False, it will connect over HTTP. 80 | - ``_port`` -- The port of the proxied server that this proxy should connect to. 81 | - ``_host`` -- the hostname/IP of the server to proxy to. 82 | - ``_sendhsts`` -- send HSTS headers on HTTPS connections. 83 | - ``_wwwtoo`` -- match ``www`` too. 84 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='rproxy', 5 | description='A super simple reverse proxy.', 6 | long_description=open("README.rst").read(), 7 | author='Amber Brown', 8 | author_email='hawkowl@atleastfornow.net', 9 | packages=['rproxy', 'twisted.plugins'], 10 | package_dir={"": "src"}, 11 | install_requires=[ 12 | 'twisted[tls] >= 18.4.0', 13 | 'pyopenssl', 14 | 'txsni', 15 | 'txacme', 16 | 'incremental', 17 | ], 18 | zip_safe=False, 19 | setup_requires=["incremental"], 20 | use_incremental=True, 21 | ) 22 | -------------------------------------------------------------------------------- /src/rproxy/__init__.py: -------------------------------------------------------------------------------- 1 | # (C) Amber Brown. See LICENSE for details. 2 | 3 | from __future__ import absolute_import, division 4 | 5 | from six.moves import configparser 6 | 7 | from zope.interface import implementer 8 | 9 | from six.moves.urllib.parse import urlencode, urlparse 10 | 11 | from twisted.python.url import URL 12 | 13 | from twisted.application import service, strports 14 | from twisted.application.service import Service 15 | from twisted.internet import reactor 16 | from twisted.internet.defer import Deferred, succeed 17 | from twisted.internet.protocol import Protocol 18 | from twisted.python import usage 19 | from twisted.python.filepath import FilePath 20 | from twisted.web import server, http, static 21 | from twisted.web.client import Agent, HTTPConnectionPool, ContentDecoderAgent, GzipDecoder 22 | from twisted.web.iweb import IBodyProducer 23 | from twisted.web.resource import Resource, EncodingResourceWrapper 24 | 25 | from josepy.jwa import RS256 26 | 27 | from txacme.challenges import HTTP01Responder 28 | from txacme.client import Client 29 | from txacme.endpoint import load_or_create_client_key 30 | from txacme.service import AcmeIssuingService 31 | from txacme.store import DirectoryStore 32 | from txacme.urls import LETSENCRYPT_DIRECTORY 33 | 34 | from ._version import __version__ 35 | 36 | 37 | 38 | class Downloader(Protocol): 39 | def __init__(self, finished, write): 40 | self.finished = finished 41 | self._write = write 42 | 43 | def dataReceived(self, bytes): 44 | self._write(bytes) 45 | 46 | def connectionLost(self, reason): 47 | self.finished.callback(None) 48 | 49 | 50 | @implementer(IBodyProducer) 51 | class StringProducer(object): 52 | 53 | def __init__(self, body): 54 | self.body = body.read() 55 | self.length = len(self.body) 56 | 57 | def startProducing(self, consumer): 58 | consumer.write(self.body) 59 | return succeed(None) 60 | 61 | def pauseProducing(self): 62 | pass 63 | 64 | def stopProducing(self): 65 | pass 66 | 67 | def movedTo(request, url): 68 | """ 69 | Permanently redirect C{request} to C{url}. 70 | 71 | @param request: The L{twisted.web.server.Reqeuest} to redirect. 72 | 73 | @param url: The new URL to which to redirect the request. 74 | @type url: L{bytes} 75 | 76 | @return: The redirect HTML page. 77 | @rtype: L{bytes} 78 | """ 79 | request.setResponseCode(http.MOVED_PERMANENTLY) 80 | request.setHeader(b"location", url) 81 | return (""" 82 | 83 | 84 | 85 | 86 | 87 | click here 88 | 89 | 90 | """ % {'url': url}).encode('ascii') 91 | 92 | class RespondToHTTP01AndRedirectToHTTPS(Resource): 93 | """ 94 | Allow an L{HTTP01Responder} to handle requests for 95 | C{.well_known/acme-challenges} only. Redirect any other requests 96 | to their HTTPS equivalent. 97 | """ 98 | def __init__(self, responderResource): 99 | Resource.__init__(self) 100 | wellKnown = Resource() 101 | wellKnown.putChild(b'acme-challenge', responderResource) 102 | self.putChild(b'.well-known', wellKnown) 103 | self.putChild(b'check', static.Data(b'OK', b'text/plain')) 104 | 105 | def render(self, request): 106 | # request.args can include URL encoded bodies, so extract the 107 | # query from request.uri 108 | _, _, query = request.uri.partition(b'?') 109 | # Assume HTTPS is served over 443 110 | httpsURL = URL( 111 | scheme=u'https', 112 | # I'm sure ASCII will be fine. 113 | host=request.getRequestHostname().decode('ascii'), 114 | path=tuple(segment.decode('ascii') 115 | for segment in request.prepath + request.postpath), 116 | 117 | ) 118 | httpsLocation = httpsURL.asText().encode('ascii') 119 | if query: 120 | httpsLocation += (b'?' + query) 121 | return movedTo(request, httpsLocation) 122 | 123 | def getChild(self, path, request): 124 | return self 125 | 126 | 127 | class EnsureHTTPS(Resource): 128 | """ 129 | Wrap a resource so that all requests that are not over HTTPS are 130 | redirected to HTTPS. 131 | """ 132 | def __init__(self, wrappedResource, responderResource): 133 | """ 134 | Initialize L{EnsureHTTPS}. 135 | 136 | @param wrappedResource: A resource representing the root of a web site. 137 | @type wrappedResource: L{twisted.web.resource.Resource} 138 | """ 139 | self._wrappedResource = wrappedResource 140 | self._httpResource = RespondToHTTP01AndRedirectToHTTPS( 141 | responderResource) 142 | 143 | def getChildWithDefault(self, path, request): 144 | if request.isSecure(): 145 | return self._wrappedResource 146 | else: 147 | return self._httpResource.getChildWithDefault(path, request) 148 | 149 | 150 | class RProxyResource(Resource): 151 | 152 | isLeaf = True 153 | 154 | def __init__(self, hosts, clacks, pool, reactor, extraHeaders, anonymous): 155 | self._clacks = clacks 156 | self._hosts = hosts 157 | self._agent = ContentDecoderAgent(Agent(reactor, pool=pool), [('gzip', GzipDecoder)]) 158 | self._extraHeaders = extraHeaders 159 | self._anonymous = anonymous 160 | 161 | def render(self, request): 162 | 163 | host = self._hosts.get(request.getRequestHostname().lower()) 164 | 165 | if not host and request.getRequestHostname().lower().startswith("www."): 166 | host = self._hosts.get(request.getRequestHostname().lower()[4:]) 167 | 168 | # The non-www host doesn't want to match to www. 169 | if not host["wwwtoo"]: 170 | host = None 171 | 172 | if not host: 173 | request.code = 404 174 | request.responseHeaders.setRawHeaders("Server", 175 | [__version__.package + " " + __version__.base()]) 176 | return b"I can't seem to find a domain by that name. Look behind the couch?" 177 | 178 | url = "{}://{}:{}/{}".format( 179 | "https" if host["proxysecure"] else "http", 180 | host["host"], host["port"], request.path[1:]) 181 | 182 | urlFragments = urlparse(request.uri) 183 | 184 | if urlFragments.query: 185 | url += "?" + urlFragments.query 186 | 187 | for x in [b'content-length', b'connection', b'keep-alive', b'te', 188 | b'trailers', b'transfer-encoding', b'upgrade', 189 | b'proxy-connection']: 190 | request.requestHeaders.removeHeader(x) 191 | 192 | prod = StringProducer(request.content) 193 | 194 | d = self._agent.request(request.method, url, 195 | request.requestHeaders, prod) 196 | 197 | def write(res): 198 | 199 | request.code = res.code 200 | old_headers = request.responseHeaders 201 | request.responseHeaders = res.headers 202 | request.responseHeaders.setRawHeaders( 203 | 'content-encoding', 204 | old_headers.getRawHeaders('content-encoding', [])) 205 | if not self._anonymous: 206 | request.responseHeaders.addRawHeader("X-Proxied-By", 207 | __version__.package + " " + __version__.base()) 208 | 209 | if request.isSecure() and host["sendhsts"]: 210 | request.responseHeaders.setRawHeaders("Strict-Transport-Security", 211 | ["max-age=31536000"]) 212 | 213 | if self._clacks: 214 | request.responseHeaders.addRawHeader("X-Clacks-Overhead", 215 | "GNU Terry Pratchett") 216 | 217 | for name, values in self._extraHeaders: 218 | request.responseHeaders.setRawHeaders(name, values) 219 | 220 | f = Deferred() 221 | res.deliverBody(Downloader(f, request.write)) 222 | f.addCallback(lambda _: request.finish()) 223 | return f 224 | 225 | def failed(res): 226 | request.setResponseCode(http.INTERNAL_SERVER_ERROR) 227 | for name, values in self._extraHeaders: 228 | request.responseHeaders.setRawHeaders(name, values) 229 | request.write(str(res)) 230 | request.finish() 231 | return res 232 | 233 | d.addCallback(write) 234 | d.addErrback(failed) 235 | 236 | return server.NOT_DONE_YET 237 | 238 | 239 | class Options(usage.Options): 240 | optParameters = [ 241 | ['config', None, 'rproxy.ini', 'Config.'] 242 | ] 243 | 244 | 245 | def makeService(config): 246 | 247 | ini = configparser.RawConfigParser() 248 | ini.read(config['config']) 249 | 250 | configPath = FilePath(config['config']).parent() 251 | 252 | rproxyConf = dict(ini.items("rproxy")) 253 | hostsConf = dict(ini.items("hosts")) 254 | 255 | hosts = {} 256 | 257 | for k, v in hostsConf.items(): 258 | 259 | k = k.lower() 260 | hostname, part = k.rsplit("_", 1) 261 | 262 | if hostname not in hosts: 263 | hosts[hostname] = {} 264 | 265 | hosts[hostname][part] = v 266 | 267 | if not hosts: 268 | raise ValueError("No hosts configured.") 269 | 270 | for i in hosts: 271 | 272 | if "port" not in hosts[i]: 273 | raise ValueError("All hosts need a port.") 274 | 275 | if "host" not in hosts[i]: 276 | print("%s does not have a host, making localhost" % (i,)) 277 | hosts[i]["host"] = "localhost" 278 | 279 | if "wwwtoo" not in hosts[i]: 280 | print("%s does not have an wwwtoo setting, making True" % (i,)) 281 | hosts[i]["wwwtoo"] = "True" 282 | 283 | if "proxysecure" not in hosts[i]: 284 | print("%s does not have an proxysecure setting, making False" % (i,)) 285 | hosts[i]["proxysecure"] = False 286 | 287 | hosts[i]["wwwtoo"] = True if hosts[i]["wwwtoo"]=="True" else False 288 | hosts[i]["proxysecure"] = True if hosts[i]["proxysecure"]=="True" else False 289 | hosts[i]["sendhsts"] = True if hosts[i].get("sendhsts")=="True" else False 290 | 291 | 292 | from twisted.internet import reactor 293 | pool = HTTPConnectionPool(reactor) 294 | 295 | resource = EncodingResourceWrapper( 296 | RProxyResource(hosts, rproxyConf.get("clacks"), pool, reactor, {}, False), 297 | [server.GzipEncoderFactory()]) 298 | 299 | responder = HTTP01Responder() 300 | site = server.Site(EnsureHTTPS(resource, responder.resource),) 301 | multiService = service.MultiService() 302 | certificates = rproxyConf.get("certificates", None) 303 | 304 | if certificates: 305 | try: 306 | configPath.child(certificates).makedirs() 307 | except: 308 | pass 309 | 310 | certificates = configPath.child(certificates).path 311 | for i in rproxyConf.get("https_ports").split(","): 312 | print("Starting HTTPS on port " + i) 313 | multiService.addService(strports.service('txsni:' + certificates + ':tcp:' + i, site)) 314 | 315 | for host in hosts.keys(): 316 | with open(FilePath(certificates).child(host + ".pem").path, 'r+'): 317 | # Open it so that txacme can find it 318 | pass 319 | if hosts[host]["wwwtoo"]: 320 | with open(FilePath(certificates).child("www." + host + ".pem").path, 'r+'): 321 | # Open it so that txacme can find it 322 | pass 323 | 324 | for i in rproxyConf.get("http_ports", "").split(","): 325 | print("Starting HTTP on port " + i) 326 | multiService.addService(strports.service('tcp:' + i, site)) 327 | 328 | issuingService = AcmeIssuingService( 329 | cert_store=DirectoryStore(FilePath(certificates)), 330 | client_creator=(lambda: Client.from_url( 331 | reactor=reactor, 332 | url=LETSENCRYPT_DIRECTORY, 333 | key=load_or_create_client_key(FilePath(certificates)), 334 | alg=RS256, 335 | )), 336 | clock=reactor, 337 | responders=[responder], 338 | ) 339 | 340 | issuingService.setServiceParent(multiService) 341 | 342 | return multiService 343 | 344 | 345 | __all__ = ["__version__", "makeService"] 346 | -------------------------------------------------------------------------------- /src/rproxy/_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides rproxy version information. 3 | """ 4 | 5 | # This file is auto-generated! Do not edit! 6 | # Use `python -m incremental.update rproxy` to change this file. 7 | 8 | from incremental import Version 9 | 10 | __version__ = Version('rproxy', 18, 6, 0) 11 | __all__ = ["__version__"] 12 | -------------------------------------------------------------------------------- /src/twisted/plugins/rproxy.py: -------------------------------------------------------------------------------- 1 | from twisted.application.service import ServiceMaker 2 | 3 | rproxy = ServiceMaker( 4 | "rproxy", 5 | "rproxy", 6 | ("HTTP/1.1 reverse proxy, with TLS support."), 7 | "rproxy") 8 | --------------------------------------------------------------------------------