├── .circleci └── config.yml ├── .gitignore ├── Aptfile ├── LICENSE.md ├── Procfile ├── README.markdown ├── certs.sh ├── dotenv.go ├── dotenv_test.go ├── go.mod ├── go.sum └── proxy.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Right now tests don't work on CircleCI. I'm not sure why, but other people 2 | # have the same issue: https://github.com/CircleCI-Public/go-orb/issues/69. 3 | # 4 | # If the above gets fixed, try adding this project to CircleCI again. 5 | 6 | version: '2.1' 7 | orbs: 8 | go: circleci/go@1 9 | jobs: 10 | build: 11 | executor: 12 | name: go/default 13 | tag: '1.20.1' 14 | steps: 15 | - checkout 16 | - go/load-cache 17 | - go/mod-download 18 | - go/save-cache 19 | - go/test: 20 | covermode: atomic 21 | failfast: true 22 | race: true 23 | workflows: 24 | main: 25 | jobs: 26 | - build 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | .env 3 | __debug_bin 4 | -------------------------------------------------------------------------------- /Aptfile: -------------------------------------------------------------------------------- 1 | jq 2 | libjq1 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Recurse Center. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | 8 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 9 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/proxy 2 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Proxy 2 | 3 | Proxy is an HTTP reverse proxy. It was written to be the backend for the Recurse Center custom subdomain service, which lets RC alumni register a recurse.com subdomain for their webapp or website. 4 | 5 | Proxy handles requests for subdomains of a single domain. It periodically loads a JSON endpoint continaing mappings from subdomain to URL, and then proxies requests to each subdomain to the appropriate URL. 6 | 7 | Proxy can be run almost anywhere, including Heroku. 8 | 9 | For simplicity, Proxy handles HTTP requests only. You should deploy it behind a TLS-terminating load balancer on a secure network like a VPC. There is an included certs.sh script that can provision a wildcard certificate from Let's Encrypt and install it on Heroku. See the source of certs.sh for detailed setup instructions. 10 | 11 | ## Dependencies 12 | 13 | - Go 14 | - curl, jq, openssl, awk, sed, and bash (for certs.sh) 15 | 16 | ## Setup 17 | 18 | Proxy gets its configuration from environmental variables: 19 | 20 | | Variable | Example | Description | Required | Default | 21 | | --- | --- | --- | --- | --- | 22 | | `DOMAIN` | example.com | The domain to handle requests for. | **Yes** | | 23 | | `ENDPOINT` | https://www.example.com/domains.json | The URL of the JSON endpoint containing mappings from subdomain to URL. | **Yes** | | 24 | | `PORT` | 8080 | The port that Proxy should listen on. | No | 80 | 25 | | `FORCE_TLS` | true | If true, Proxy will redirect all HTTP requests to HTTPS. | No | false | 26 | | `READ_TIMEOUT` | 10 | Maximum number of seconds Proxy waits to read a request from a client. | No | 5 | 27 | | `WRITE_TIMEOUT` | 15 | Maximum number of seconds Proxy will spend writing a response to the client before timing out. This includes time spend proxying the request. | No | 10 | 28 | | `SHUTDOWN_TIMEOUT` | 20 | Maximum number of seconds Proxy will wait for in-flight requests to complete while shutting down. After this duration has expired, Proxy will kill all requests that are still running. | No | 10 | 29 | | `REFRESH_INTERVAL` | 10 | Proxy fetches `ENDPOINT` every `REFRESH_INTERVAL` seconds. | No | 5 | 30 | 31 | ## Running 32 | 33 | Proxy runs in the foreground and logs to STDOUT. All error messages contain the string "error:". When it receives a SIGINT or a SIGTERM, Proxy shuts down. 34 | 35 | If present, Proxy will read its configuration out of a `.env` file. Here's a starting point for development: 36 | 37 | ```dotenv 38 | DOMAIN=example.com 39 | ENDPOINT=https://www.example.com/domains.json 40 | ``` 41 | 42 | To start Proxy locally, run: 43 | 44 | ```shell 45 | $ go run . 46 | ``` 47 | 48 | You can use `curl` to make a request to a mapped subdomain: 49 | 50 | ```shell 51 | $ curl --header 'Host: foo.example.com' http://localhost 52 | ``` 53 | 54 | ## Subdomain mappings endpoint 55 | 56 | In order to use Proxy, you need a publicly accessible HTTP endpoint that returns a set of mappings from subdomain to URL. The endpoint must return JSON data in the following format: 57 | 58 | ``` 59 | [ 60 | ["subdomain1", "https://www.example.com/foo"], 61 | ["subdomain2", "https://www.example.net"], 62 | ["subdomain3", "http://www.example.org"] 63 | ] 64 | ``` 65 | 66 | ## Copyright 67 | 68 | Copyright Recurse Center. 69 | 70 | ## License 71 | 72 | This project is licensed under the terms of the BSD 2-clause "Simplified" license. See LICENSE.md for full terms. 73 | -------------------------------------------------------------------------------- /certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Wildcard certificates from Let's Encrypt for Heroku apps 4 | # 5 | # How it works: 6 | # 7 | # `certs.sh issue` generates or renews a certificate for *.$DOMAIN, and 8 | # registers it with Heroku. 9 | # 10 | # Certs.sh uses acme.sh with the dns_aws dnsapi provider to satisfy the DNS-01 challenge. 11 | # 12 | # The --staging flag can be used to target Let's Encrypt's staging server. Use this if 13 | # you're testing changes to this script. 14 | # 15 | # Acme.sh state is cached encrypted in S3. This means you can run `certs.sh issue` 16 | # as many times as you want. The certificate will only be renewed if it's nearing 17 | # its expiration. Use Heroku Scheduler to run `certs.sh issue` daily. 18 | # 19 | # Acme.sh state is cached by app name ($HEROKU_APP_NAME.tar.gz), so multiple apps can 20 | # use the same bucket to cache their state. Staging state is cached 21 | # separately ($HEROKU_APP_NAME.staging.tar.gz). 22 | # 23 | # `certs.sh clear-cache` deletes the acme.sh state from S3. Remember to use --staging 24 | # if you want to clear the staging cache while you're modifying this script. 25 | # 26 | # External services: 27 | # - Heroku 28 | # - Amazon S3 29 | # - Amazon Route 53 30 | # - Let's Encrypt 31 | # 32 | # Dependencies: 33 | # - curl (comes pre-installed on Heroku) 34 | # - openssl (ditto) 35 | # - awk (ditto) 36 | # - sed (ditto) 37 | # - bash (ditto) 38 | # - jq and libjq1 (use the heroku-community/apt buildpack with "jq" in your Aptfile) 39 | # - acme.sh (installed and managed by this script) 40 | # 41 | # Heroku-buildpack-apt notes: 42 | # 43 | # The jq package depends on libjq1, but if your Aptfile contains the only the former, 44 | # the latter won't be installed. I'm not sure why this is, but adding libjq1 to the 45 | # Aptfile explicitly fixed the problem. 46 | # 47 | # Environmental variables: 48 | # - DOMAIN 49 | # - HEROKU_APP_NAME 50 | # - HEROKU_API_KEY 51 | # - AWS_ACCESS_KEY_ID 52 | # - AWS_SECRET_ACCESS_KEY 53 | # - AWS_DEFAULT_REGION 54 | # - LETS_ENCRYPT_EMAIL 55 | # - CERTS_BUCKET 56 | # 57 | # Required AWS IAM permissions: 58 | # - s3:PutObject 59 | # - s3:GetObject 60 | # - s3:DeleteObject 61 | # - route53:GetHostedZone 62 | # - route53:ListResourceRecordSets 63 | # - route53:ChangeResourceRecordSets 64 | # - route53:ListHostedZones 65 | # - route53:GetHostedZoneCount 66 | # - route53:ListHostedZonesByName 67 | # 68 | # Setup: 69 | # - Generate a Heroku API key with read/write access, and set it as HEROKU_API_KEY: 70 | # heroku config:set HEROKU_API_KEY=$(heroku authorizations:create --short --scope=read,write --description="...") 71 | # 72 | # - Enable runtime-dyno-metadata to automatically set HEROKU_APP_NAME: 73 | # heroku labs:enable runtime-dyno-metadata 74 | # 75 | # - Create a bucket on S3 with no public permissions, and set CERTS_BUCKET to its name. Make sure 76 | # it uses server-side encryption with S3 managed keys (SSE-S3). This is the default as of March 2023. 77 | # 78 | # - Create a Route 53 IAM polcy (substitute $ZONE_ID): 79 | # { 80 | # "Version": "2012-10-17", 81 | # "Statement": [ 82 | # { 83 | # "Sid": "VisualEditor0", 84 | # "Effect": "Allow", 85 | # "Action": [ 86 | # "route53:GetHostedZone", 87 | # "route53:ChangeResourceRecordSets", 88 | # "route53:ListResourceRecordSets" 89 | # ], 90 | # "Resource": "arn:aws:route53:::hostedzone/$ZONE_ID" 91 | # }, 92 | # { 93 | # "Sid": "VisualEditor1", 94 | # "Effect": "Allow", 95 | # "Action": [ 96 | # "route53:ListHostedZones", 97 | # "route53:GetHostedZoneCount", 98 | # "route53:ListHostedZonesByName" 99 | # ], 100 | # "Resource": "*" 101 | # } 102 | # ] 103 | # } 104 | # 105 | # - Create a S3 IAM policy (substitute $BUCKET_NAME): 106 | # { 107 | # "Version": "2012-10-17", 108 | # "Statement": [ 109 | # { 110 | # "Sid": "VisualEditor0", 111 | # "Effect": "Allow", 112 | # "Action": [ 113 | # "s3:PutObject", 114 | # "s3:GetObject", 115 | # "s3:DeleteObject" 116 | # ], 117 | # "Resource": "arn:aws:s3:::$BUCKET_NAME/*" 118 | # } 119 | # ] 120 | # } 121 | # 122 | # - Create an IAM user with the above policies, and set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. 123 | # - Set AWS_DEFAULT_REGION to the region of your S3 bucket. 124 | # - Set LETS_ENCRYPT_EMAIL to the address you want to receive Let's Encrypt emails. 125 | # - Set DOMAIN to the domain you want to issue a certificate for (e.g. "example.com") 126 | # - Deploy the app 127 | # - Run `heroku run ./certs.sh issue` to generate a certificate. 128 | # - Set up Heroku Scheduler to run `./certs.sh renew` daily. 129 | 130 | set -e # Exit immediately if a command fails 131 | set -E # Trigger the ERR trap when a command fails 132 | set -u # Treat unset variables as an error 133 | set -f # Disable file globbing 134 | set -o pipefail # Fail a pipe if any subcommand fails 135 | 136 | ## S3 137 | 138 | # AWS specific url-encoding rules 139 | 140 | function urlencode() { 141 | local string="$1" 142 | local strlen encoded pos c 143 | strlen=${#string} 144 | encoded="" 145 | 146 | for (( pos=0 ; pos /dev/null 372 | ./acme.sh --install --force --nocron --accountemail "$email" 373 | popd > /dev/null 374 | 375 | "$HOME/.acme.sh/acme.sh" --set-default-ca --server letsencrypt 376 | } 377 | 378 | function upgrade_acme_sh() { 379 | "$HOME/.acme.sh/acme.sh" --upgrade 380 | } 381 | 382 | function issue_certificate() { 383 | local certfile="$1" 384 | local keyfile="$2" 385 | local flags="$3" 386 | local domain="$4" 387 | 388 | "$HOME/.acme.sh/acme.sh" --issue --dns dns_aws --keylength ec-256 --key-file "$keyfile" --fullchain-file "$certfile" --log "$flags" --domain "$domain" 389 | } 390 | 391 | function renew_certificates_if_necessary() { 392 | local flags="$1" 393 | 394 | "$HOME/.acme.sh/acme.sh" --cron --log "$flags" 395 | } 396 | 397 | function save_cache() { 398 | local bucket="$1" 399 | local cachefile="$2" 400 | local cachedir="$3" 401 | 402 | echo "Saving cache to s3://$bucket/$cachefile" 403 | tar -czf "$cachefile" -C "$(dirname "$cachedir")" "$(basename "$cachedir")" 404 | s3_put_object "$bucket" "$cachefile" 405 | } 406 | 407 | function restore_cache() { 408 | local bucket="$1" 409 | local cachefile="$2" 410 | local cachedir="$3" 411 | 412 | echo "Restoring cache from s3://$bucket/$cachefile" 413 | if ! s3_get_object "$bucket" "$cachefile"; then 414 | echo "Cache not found. Starting fresh." 415 | return 416 | fi 417 | 418 | tar -xzf "$cachefile" -C "$(dirname "$cachedir")" 419 | } 420 | 421 | function usage() { 422 | echo "usage: $0 [--staging] issue [--force-install]" 423 | echo " $0 [--staging] clear-cache" 424 | } 425 | 426 | if [ "$#" -lt 1 ]; then 427 | usage 428 | exit 1 429 | fi 430 | 431 | if [ "$1" = "--help" ] || [ "$1" == "help" ]; then 432 | usage 433 | exit 0 434 | fi 435 | 436 | staging=false 437 | 438 | if [ "$1" = "--staging" ]; then 439 | staging=true 440 | shift 441 | fi 442 | 443 | checkenv DOMAIN 444 | checkenv AWS_ACCESS_KEY_ID 445 | checkenv AWS_SECRET_ACCESS_KEY 446 | checkenv AWS_DEFAULT_REGION 447 | checkenv CERTS_BUCKET 448 | checkenv LETS_ENCRYPT_EMAIL 449 | checkenv HEROKU_APP_NAME 450 | checkenv HEROKU_API_KEY 451 | 452 | cachedir="$HOME/.acme.sh" 453 | 454 | if [ "$staging" = true ]; then 455 | acmeflags="--staging" 456 | cachefile="${HEROKU_APP_NAME}.staging.tar.gz" 457 | else 458 | acmeflags="" 459 | cachefile="${HEROKU_APP_NAME}.tar.gz" 460 | fi 461 | 462 | trap "echo 'error: \"$0 $1\" failed'" ERR 463 | 464 | case "$1" in 465 | issue) 466 | force_install=false 467 | if [ "$#" -gt 1 ] && [ "$2" = "--force-install" ]; then 468 | force_install=true 469 | fi 470 | 471 | wildcard="*.$DOMAIN" 472 | 473 | keyfile="/tmp/$wildcard.key" 474 | certfile="/tmp/$wildcard.crt" 475 | 476 | # We test for the existence of the certificate file to determine if the certificates 477 | # needed to be installed. Delete them here so that we don't accidentally think the certs 478 | # have been renewed and need to be installed when they haven't. This only matters in 479 | # development – on Heroku, the filesystem is ephemeral, so this is a no-op. 480 | rm -f "$keyfile" "$certfile" 481 | 482 | restore_cache "$CERTS_BUCKET" "$cachefile" "$cachedir" 483 | 484 | if [ -d "$cachedir" ]; then 485 | upgrade_acme_sh 486 | renew_certificates_if_necessary "$acmeflags" 487 | else 488 | install_acme_sh "$LETS_ENCRYPT_EMAIL" 489 | issue_certificate "$certfile" "$keyfile" "$acmeflags" "$wildcard" 490 | fi 491 | 492 | save_cache "$CERTS_BUCKET" "$cachefile" "$cachedir" 493 | 494 | # If renew_certificates_if_necessary didn't renew the certificates, they won't be installed into /tmp. 495 | # If you want to test the Heroku API calls, use --force-install to force the certificates to be be put 496 | # in /tmp so that certs.sh won't exit early with "Nothing to install." 497 | if [ "$force_install" = true ]; then 498 | "$HOME/.acme.sh/acme.sh" --install-cert --domain "$wildcard" --key-file "$keyfile" --fullchain-file "$certfile" 499 | fi 500 | 501 | # If the certificate file doesn't exist, then the certificates didn't need to be installed on Heroku. 502 | if [ ! -f "$certfile" ]; then 503 | echo "Certificates are up to date. Nothing to install." 504 | exit 0 505 | fi 506 | 507 | endpoint_id=$(endpoint_id_to_update "$wildcard") 508 | 509 | if [ -n "$endpoint_id" ]; then 510 | echo "Updating certificate for $wildcard on Heroku" 511 | update_sni_endpoint "$endpoint_id" "$certfile" "$keyfile" > /dev/null 512 | else 513 | echo "Installing new certificate for $wildcard on Heroku" 514 | endpoint_id=$(create_sni_endpoint "$certfile" "$keyfile" | jq --raw-output '.id') 515 | attach_sni_endpoint "$endpoint_id" "$wildcard" > /dev/null 516 | fi 517 | ;; 518 | clear-cache) 519 | s3_delete_object "$CERTS_BUCKET" "$cachefile" 520 | ;; 521 | *) 522 | usage 523 | exit 1 524 | ;; 525 | esac 526 | -------------------------------------------------------------------------------- /dotenv.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | lineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)=(\S.*)$`) 14 | 15 | // bare value (no white space) followed by optional whitespace and optional comment 16 | bareRe = regexp.MustCompile(`^(\S+)\s*(:?#.*)?$`) 17 | 18 | // quoted strings followed by optional whitespace and optional comment 19 | singleRe = regexp.MustCompile(`^'([^']*)'\s*(:?#.*)?$`) 20 | doubleRe = regexp.MustCompile(`^"([^"]*)"\s*(:?#.*)?$`) 21 | ) 22 | 23 | func parseDotenv(s string) (map[string]string, error) { 24 | env := make(map[string]string) 25 | 26 | s = strings.ReplaceAll(s, "\r\n", "\n") 27 | s = strings.ReplaceAll(s, "\r", "\n") 28 | 29 | for _, line := range strings.Split(s, "\n") { 30 | line = strings.TrimSpace(line) 31 | if line == "" || line[0] == '#' { 32 | continue 33 | } 34 | 35 | m := lineRe.FindStringSubmatch(line) 36 | if m == nil { 37 | return nil, fmt.Errorf("invalid line: %q", line) 38 | } 39 | 40 | name := m[1] 41 | value := m[2] 42 | 43 | if m := singleRe.FindStringSubmatch(value); m != nil { 44 | value = m[1] 45 | } else if m := doubleRe.FindStringSubmatch(value); m != nil { 46 | value = m[1] 47 | } else if m := bareRe.FindStringSubmatch(value); m != nil { 48 | value = m[1] 49 | } else { 50 | return nil, fmt.Errorf("invalid line: %q", line) 51 | } 52 | 53 | env[name] = value 54 | } 55 | 56 | return env, nil 57 | } 58 | 59 | func loadDotenv() error { 60 | data, err := os.ReadFile(".env") 61 | if errors.Is(err, fs.ErrNotExist) { 62 | return nil 63 | } else if err != nil { 64 | return err 65 | } 66 | 67 | env, err := parseDotenv(string(data)) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | for key, value := range env { 73 | if err := os.Setenv(key, value); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /dotenv_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSimple(t *testing.T) { 8 | s := "FOO=bar" 9 | 10 | env, err := parseDotenv(s) 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | 15 | if len(env) != 1 { 16 | t.Fatalf("expected 1 env var, got %d", len(env)) 17 | } 18 | 19 | if env["FOO"] != "bar" { 20 | t.Fatalf("expected FOO=bar, got %s", env["FOO"]) 21 | } 22 | } 23 | 24 | func TestMulti(t *testing.T) { 25 | s := "FOO=bar\nBAZ=qux" 26 | 27 | env, err := parseDotenv(s) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | if len(env) != 2 { 33 | t.Fatalf("expected 2 env vars, got %d", len(env)) 34 | } 35 | 36 | if env["FOO"] != "bar" { 37 | t.Fatalf("expected FOO=bar, got %s", env["FOO"]) 38 | } 39 | 40 | if env["BAZ"] != "qux" { 41 | t.Fatalf("expected BAZ=qux, got %s", env["BAZ"]) 42 | } 43 | } 44 | 45 | func TestEmpty(t *testing.T) { 46 | s := "" 47 | 48 | env, err := parseDotenv(s) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if len(env) != 0 { 54 | t.Fatalf("expected 0 env vars, got %d", len(env)) 55 | } 56 | } 57 | 58 | func TestWhiteSpace(t *testing.T) { 59 | s := " \n FOO=bar \n" 60 | 61 | env, err := parseDotenv(s) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | if len(env) != 1 { 67 | t.Fatalf("expected 1 env var, got %d", len(env)) 68 | } 69 | 70 | if env["FOO"] != "bar" { 71 | t.Fatalf("expected FOO=bar, got %s", env["FOO"]) 72 | } 73 | } 74 | 75 | func TestQuote(t *testing.T) { 76 | s := "ONE='two \" three' \n FOUR=\"five ' six\"" 77 | 78 | env, err := parseDotenv(s) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | if len(env) != 2 { 84 | t.Fatalf("expected 2 env vars, got %d", len(env)) 85 | } 86 | 87 | if env["ONE"] != "two \" three" { 88 | t.Fatalf("expected ONE='two \" three', got %s", env["ONE"]) 89 | } 90 | 91 | if env["FOUR"] != "five ' six" { 92 | t.Fatalf("expected FOUR=\"five ' six\", got %s", env["FOUR"]) 93 | } 94 | } 95 | 96 | func TestComment(t *testing.T) { 97 | s := "# FOO=bar" 98 | 99 | env, err := parseDotenv(s) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | if len(env) != 0 { 105 | t.Fatalf("expected 0 env vars, got %d", len(env)) 106 | } 107 | 108 | s = "FOO=bar # set FOO to bar" 109 | 110 | env, err = parseDotenv(s) 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | 115 | if len(env) != 1 { 116 | t.Fatalf("expected 1 env var, got %d", len(env)) 117 | } 118 | 119 | if env["FOO"] != "bar" { 120 | t.Fatalf("expected FOO=bar, got %s", env["FOO"]) 121 | } 122 | 123 | s = "FOO=bar # set FOO to bar\nBAZ=qux\n" 124 | 125 | env, err = parseDotenv(s) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | if len(env) != 2 { 131 | t.Fatalf("expected 2 env vars, got %d", len(env)) 132 | } 133 | 134 | if env["FOO"] != "bar" { 135 | t.Fatalf("expected FOO=bar, got %s", env["FOO"]) 136 | } 137 | 138 | if env["BAZ"] != "qux" { 139 | t.Fatalf("expected BAZ=qux, got %s", env["BAZ"]) 140 | } 141 | } 142 | 143 | func TestBad(t *testing.T) { 144 | s := "FOO" 145 | 146 | _, err := parseDotenv(s) 147 | if err == nil { 148 | t.Fatal("expected error") 149 | } 150 | 151 | s = "FOO=bar\nBAZ" 152 | _, err = parseDotenv(s) 153 | if err == nil { 154 | t.Fatal("expected error") 155 | } 156 | 157 | s = "1FOO=bar" 158 | _, err = parseDotenv(s) 159 | if err == nil { 160 | t.Fatal("expected error") 161 | } 162 | 163 | s = "FOO=bar baz\nBAZ=qux" 164 | _, err = parseDotenv(s) 165 | if err == nil { 166 | t.Fatal("expected error") 167 | } 168 | 169 | s = "FOO BAR=baz qux" 170 | _, err = parseDotenv(s) 171 | if err == nil { 172 | t.Fatal("expected error") 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/recursecenter/proxy 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/google/uuid v1.3.0 7 | golang.org/x/sync v0.1.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 2 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 4 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 5 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "os" 13 | "os/signal" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/google/uuid" 21 | "golang.org/x/sync/errgroup" 22 | ) 23 | 24 | type syncMap struct { 25 | m map[string]string 26 | mutex sync.Mutex 27 | } 28 | 29 | func (sm *syncMap) lookup(key string) (string, bool) { 30 | sm.mutex.Lock() 31 | defer sm.mutex.Unlock() 32 | 33 | if sm.m == nil { 34 | return "", false 35 | } 36 | 37 | s, ok := sm.m[key] 38 | return s, ok 39 | } 40 | 41 | func (sm *syncMap) replace(m map[string]string) { 42 | sm.mutex.Lock() 43 | defer sm.mutex.Unlock() 44 | 45 | sm.m = m 46 | } 47 | 48 | func fetchDomains(ctx context.Context, url string) (map[string]string, error) { 49 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | client := http.Client{} 55 | 56 | resp, err := client.Do(req) 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer resp.Body.Close() 61 | 62 | if resp.StatusCode != http.StatusOK { 63 | return nil, fmt.Errorf("received %d when fetching %s", resp.StatusCode, url) 64 | } 65 | 66 | body, err := io.ReadAll(resp.Body) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // parse body as json. The schema is an array of arrays, each inner array has 2 elements, both of them are strings 72 | var domains [][]string 73 | err = json.Unmarshal(body, &domains) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | mapping := make(map[string]string) 79 | for _, domain := range domains { 80 | mapping[domain[0]] = domain[1] 81 | } 82 | 83 | return mapping, nil 84 | } 85 | 86 | func proxy(w http.ResponseWriter, r *http.Request, mapping *syncMap, domain string, forceTLS bool) { 87 | requestID := r.Header.Get("X-Request-ID") 88 | if requestID == "" { 89 | id, err := uuid.NewRandom() 90 | if err == nil { 91 | requestID = id.String() 92 | } else { 93 | requestID = "unknown" 94 | } 95 | } 96 | 97 | // r.TLS is always nil right now because Proxy doesn't support TLS natively. 98 | // Here for future-proofing only. 99 | isTLS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" 100 | var scheme string 101 | if isTLS { 102 | scheme = "https://" 103 | } else { 104 | scheme = "http://" 105 | } 106 | 107 | originalURL := scheme + r.Host + r.URL.String() 108 | 109 | if forceTLS && !isTLS { 110 | log.Printf("request_id=%s %s %s; 301 Moved Permanently; redirect http -> https", requestID, r.Method, originalURL) 111 | http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently) 112 | return 113 | } 114 | 115 | subdomain := strings.Split(r.Host, ".")[0] 116 | 117 | // If domain is example.com, then we want to proxy requests to 118 | // foo.example.com, but not foo.bar.example.com. 119 | if r.Host != subdomain+"."+domain { 120 | log.Printf("request_id=%s %s %s; 502 Bad Gateway; error: invalid host: %q must be a subdomain of %q", requestID, r.Method, originalURL, r.Host, domain) 121 | w.WriteHeader(http.StatusBadGateway) 122 | if _, err := w.Write([]byte("502 Bad Gateway\n")); err != nil { 123 | log.Printf("request_id=%s %s %s; error: failed to write 502 response (invalid subdomain): %v", requestID, r.Method, originalURL, err) 124 | } 125 | return 126 | } 127 | 128 | target, ok := mapping.lookup(subdomain) 129 | if !ok { 130 | log.Printf("request_id=%s %s %s; 404 Not Found; warning: unknown host: %s", requestID, r.Method, originalURL, r.Host) 131 | w.WriteHeader(http.StatusNotFound) 132 | if _, err := w.Write([]byte("404 Not Found\n")); err != nil { 133 | log.Printf("request_id=%s %s %s; error: failed to write 404 response: %v", requestID, r.Method, originalURL, err) 134 | } 135 | return 136 | } 137 | 138 | u, err := url.Parse(target) 139 | if err != nil { 140 | log.Printf("request_id=%s %s %s; 502 Bad Gateway; error: invalid url: %v", requestID, r.Method, originalURL, err) 141 | w.WriteHeader(http.StatusBadGateway) 142 | if _, err := w.Write([]byte("502 Bad Gateway\n")); err != nil { 143 | log.Printf("request_id=%s %s %s; error: failed to write 502 response (invalid url): %v", requestID, r.Method, originalURL, err) 144 | } 145 | return 146 | } 147 | 148 | // Probably not great to make a new ReverseProxy for every request 149 | // but it means we don't have to do the check of the host header 150 | // twice just to be able to respond with errors in the way that we 151 | // want to. 152 | proxy := &httputil.ReverseProxy{ 153 | Rewrite: func(req *httputil.ProxyRequest) { 154 | req.SetURL(u) 155 | req.SetXForwarded() 156 | 157 | // Rewrite() docs say: 158 | // 159 | // Unparsable query parameters are removed from the 160 | // outbound request before Rewrite is called. 161 | // The Rewrite function may copy the inbound URL's 162 | // RawQuery to the outbound URL to preserve the original 163 | // parameter string. Note that this can lead to security 164 | // issues if the proxy's interpretation of query parameters 165 | // does not match that of the downstream server. 166 | // 167 | // We don't interpret query parameters at all, so let's just pass them 168 | // on umodified for maximum compatibility. This is the security issue: 169 | // https://www.oxeye.io/blog/golang-parameter-smuggling-attack. I don't 170 | // believe it applies to our usecase. 171 | req.Out.URL.RawQuery = req.In.URL.RawQuery 172 | }, 173 | ModifyResponse: func(resp *http.Response) error { 174 | resp.Header.Set("Server", "Proxy/2.0") 175 | log.Printf("request_id=%s %s %s -> %s; %s", requestID, r.Method, originalURL, resp.Request.URL, resp.Status) 176 | return nil 177 | }, 178 | } 179 | 180 | proxy.ServeHTTP(w, r) 181 | } 182 | 183 | func getenv(key, fallback string) string { 184 | value, ok := os.LookupEnv(key) 185 | if !ok { 186 | return fallback 187 | } 188 | 189 | return value 190 | } 191 | 192 | func mustGetenv(key string) string { 193 | value, ok := os.LookupEnv(key) 194 | if !ok { 195 | log.Fatalf("error: %s not set", key) 196 | } 197 | 198 | return value 199 | } 200 | 201 | // Returns the value of the environment variable as a bool. 202 | // Panics if the environment variable is set but fails to parse. 203 | func mustGetenvBool(key string, fallback bool) bool { 204 | value, ok := os.LookupEnv(key) 205 | if !ok { 206 | return fallback 207 | } 208 | 209 | b, err := strconv.ParseBool(value) 210 | if err != nil { 211 | log.Fatalf("error: %s must be a boolean: %v", key, err) 212 | } 213 | 214 | return b 215 | } 216 | 217 | // Returns the value of the environment variable as a time.Duration in seconds. 218 | // Panics if the environment variable is set but fails to parse. 219 | func mustGetenvDuration(key string, fallback time.Duration) time.Duration { 220 | value, ok := os.LookupEnv(key) 221 | if !ok { 222 | return fallback 223 | } 224 | 225 | i, err := strconv.Atoi(value) 226 | if err != nil { 227 | log.Fatalf("error: %s must be an integer: %v", key, err) 228 | } 229 | 230 | return time.Duration(i) * time.Second 231 | } 232 | 233 | func main() { 234 | log.Printf("Proxy starting...") 235 | 236 | // Only fails if the file fails to parse, not if it doesn't exist. 237 | if err := loadDotenv(); err != nil { 238 | log.Fatalf("error: can't reading .env: %v", err) 239 | } 240 | 241 | addr := ":" + getenv("PORT", "80") 242 | domain := mustGetenv("DOMAIN") 243 | endpoint := mustGetenv("ENDPOINT") 244 | forceTLS := mustGetenvBool("FORCE_TLS", false) 245 | readTimeout := mustGetenvDuration("READ_TIMEOUT", 5*time.Second) 246 | writeTimeout := mustGetenvDuration("WRITE_TIMEOUT", 10*time.Second) 247 | shutdownTimeout := mustGetenvDuration("SHUTDOWN_TIMEOUT", 10*time.Second) 248 | refreshInterval := mustGetenvDuration("REFRESH_INTERVAL", 5*time.Second) 249 | 250 | if readTimeout < 1*time.Second { 251 | log.Fatalf("error: read timeout must be at least 1 second") 252 | } else if writeTimeout < 1*time.Second { 253 | log.Fatalf("error: write timeout must be at least 1 second") 254 | } else if shutdownTimeout < 1*time.Second { 255 | log.Fatalf("error: shutdown timeout must be at least 1 second") 256 | } else if refreshInterval < 1*time.Second { 257 | log.Fatalf("error: refresh interval must be at least 1 second") 258 | } 259 | 260 | log.Printf("* read timeout: %s", readTimeout) 261 | log.Printf("* write timeout: %s", writeTimeout) 262 | log.Printf("* shutdown timeout: %s", shutdownTimeout) 263 | log.Printf("* refresh interval: %s", refreshInterval) 264 | log.Printf("* domain: %s", domain) 265 | log.Printf("* endpoint: %s", endpoint) 266 | log.Printf("* force tls: %t", forceTLS) 267 | log.Printf("* Listening on http://0.0.0.0%s", addr) 268 | log.Printf("* Listening on http://[::]%s", addr) 269 | 270 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 271 | g, ctx := errgroup.WithContext(ctx) 272 | 273 | mapping := &syncMap{} 274 | 275 | mux := http.NewServeMux() 276 | 277 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 278 | proxy(w, r, mapping, domain, forceTLS) 279 | }) 280 | 281 | server := &http.Server{ 282 | Addr: addr, 283 | ReadTimeout: readTimeout, 284 | WriteTimeout: writeTimeout, 285 | Handler: mux, 286 | } 287 | 288 | // Fetch the domain every refereshInterval 289 | g.Go(func() error { 290 | for { 291 | m, err := fetchDomains(ctx, endpoint) 292 | if err != nil { 293 | log.Printf("error: couldn't fetch domains endpoint: %v", err) 294 | } else { 295 | mapping.replace(m) 296 | } 297 | 298 | select { 299 | case <-ctx.Done(): 300 | return nil 301 | case <-time.After(refreshInterval): 302 | } 303 | } 304 | }) 305 | 306 | // Shutdown the server when we receive a SIGINT or SIGTERM 307 | g.Go(func() error { 308 | <-ctx.Done() 309 | 310 | log.Println("Shutting down...") 311 | 312 | shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) 313 | defer cancel() 314 | return server.Shutdown(shutdownCtx) 315 | }) 316 | 317 | // Start the server 318 | g.Go(func() error { 319 | err := server.ListenAndServe() 320 | if err != nil && err != http.ErrServerClosed { 321 | return err 322 | } 323 | 324 | return nil 325 | }) 326 | 327 | if err := g.Wait(); err != nil { 328 | log.Fatalf("error: %v", err) 329 | } 330 | 331 | log.Println("Proxy stopped") 332 | } 333 | --------------------------------------------------------------------------------