scidsg / hushline

Hush Line connects whistleblowers with organizations and people who can help.
https://hushline.app
GNU Affero General Public License v3.0
76 stars 21 forks source link

Onion service is auto-redirecting to clearnet #640

Open micahflee opened 1 month ago

micahflee commented 1 month ago

Describe the bug The onion service for https://tips.hushline.app/ is http://hyewn4dvbedq7ooe3oxrhpceljd7ncfyeyts2c7nwsjp34i46smbzwid.onion/, however it seems that if you load the site using the onion service hostname it auto-redirects to SERVER_NAME, which is tips.hushline.app.

To Reproduce Steps to reproduce the behavior:

Open Tor Browser, load https://tips.hushline.app/, and click ".onion available". It loads the onion service, which redirects back to https://tips.hushline.app/.

Expected behavior You should be able to use the onion service.

brassy-endomorph commented 1 month ago

I have this snipped in another project and it would obviously need modifying, but it could be set up to a single app like this. It's a few years old and from the Flask 2.x release series, so idk if there's a better way to do it.

class DomainDispatcher:
    def __init__(self, domains):
        self.domains = domains

    def __call__(self, environ, start_response):
        request_host = environ['HTTP_HOST']
        for (domain, app) in self.domains:
            if request_host.endswith(domain):
                return app(environ, start_response)

        # TODO instead of aborting 503, replace with call to simple app that renders a
        # simple page "service not available" static page.
        # nginx should redirect to this too when the service is down
        abort(503)

def create_app(config: Config = None) -> DomainDispatcher:
    if config is None:
        config = Config()

    domains = []
    for domain in config.domains:
        # make a copy so we can fiddle with some of the options for this sub-app
        domains.append((domain, make_app(copy.deepcopy(config), domain)))

    return DomainDispatcher(domains)

def make_app(config: Config, domain: str = None) -> Flask:
    # normal app.route, app.config, etc. stuff here

The disadvantage of this is I had some dangerous mangling of the Config in make_app where I was doing things like if domain.endswith(".onion"): ... and then tweaking things.

I actually think the best way to deploy this would be using two containers each with fully separate (but in some cases copy/pasted) configs because for example in #626 there almost certainly would be a difference in the proxy config for clearnet vs. Tor which would require a totally different Config (and/or set of env vars), and no tricky mangling within make_app would be able to account for this without being highly brittle and tied to our particular setup.

brassy-endomorph commented 1 month ago

I started working on this and immediately ran into some problems. There are some config values that need to be different between the clearnet and Tor versions of the app if we use the above model, e.g., PREFERRED_URL_SCHEME. This one config could be mostly reliably detected by matching server_name.endswith('.onion') but also onion services are allowed to be HTTPS if they pay for verification. Granted, we don't support that yet and that's okay maybe, except in the next release we're already doing to have to make a breaking config change, so we might as well think carefully for a moment.

If we want to have the app be able to serve multiple domains, we need to do one of the following:

Env Var Prefixing

To support multiple domains, we first require an env var like DOMAIN_MAP structured like this:

DOMAIN_MAP="PREFIX_1:example.com ,  PREFIX_2:example.onion , etc."

We then parse this first, then per domain, parse configs like:

# prefix being e.g., `PREFIX_1` from the above example
def load_config(prefix: str) -> dict[str, Any]:
  cfg_value = os.environ.get(prefix + "_" + "ACTUAL_CONFIG_NAME", None)
  ... # etc.

An actual config file

Many of these values are related as in there's no reason multiple domains would need to set a different value for whether or not premium/stripe is set. So we have shared + overrides like:

# using TOML because it's easier to type, but we might use YAML idk

shared_config_1 = "foo"
shared_config_2 = 123

[domains."example.com"]
# override
shared_config_1 = "bar"

[domains."example.onion"]
some_other_config = false

Two apps

For the Debian package (hardware) or containers (DO/hosted), we just make two apps. Two systemd units, two containers, whatever. All the app code stays the same (modulo any bugs related to clearnet / onion, etc.).