Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Geo forwarder for tfgrid validators #56

Open
Mik-TF opened this issue Jun 25, 2024 · 21 comments
Open

Geo forwarder for tfgrid validators #56

Mik-TF opened this issue Jun 25, 2024 · 21 comments
Assignees
Labels
type_feature New feature or request
Milestone

Comments

@Mik-TF
Copy link
Contributor

Mik-TF commented Jun 25, 2024

Situation

We want to develop a load balancer that will redirect users from dashboard.grid.tf to the closest validator stack. Below is a recap the project to make sure the load balancer part is clear.

For example, we will have 16 validators running their full grid stack at dashboard.01.grid.tf, dashboard.02.grid.tf, ... dashboard.16.grid.tf

Then a user can go to dashboard.grid.tf and the load balancer points to any of the working validator stack URL (e.g. dashboard.04.grid.tf)

Users can also decide to simply go directly to a given validator URL (e.g. dashboard.04.grid.tf)

Full TFGrid Validator Stack Deployment Phase 1:

Here is a recap of the first phase of the project:

The first phase of the project Full TFGrid Validator Stack Deployment is to make it possible for anyone to run the grid independently, this means the full grid stack with tfhub and tfbootstrap.

Phase 2:

Once we have phase 1 ready, validators will be able to deploy the full grid stack and this grid stack will be available at some given URLs, e.g. dashboard.03.grid.tf, dashboard.04.grid.tf, etc.

We will be able to share this list of URLs on our websites (e.g. github, threefold.io, etc.).
Users will be able to join these URLs to connect to specific grid instances, that are all independent from one another. This will make sure the grid is decentralized.

Once we have this, we will need to set a load balancer with all those URLs, so when a user goes to dashboard.grid.tf, the load balancer points the user to the closest grid instance (e.g. dashboard.grid.tf points to dashboard.03.grid.tf, if 03 goes down, it will point to 04, etc.)

Todo

For this issue, we want to develop a load balancer that will redirect from dashboard.grid.tf to the other validator grid stack (e.g. dashboard.05.grid.tf)

References and Suggestions

@coesensbert let me know if this is clear! I know you have some suggestions for this load balancer, please write your ideas on this issue.

@LeeSmet
Copy link

LeeSmet commented Jun 27, 2024

Reading this issue, I'm a bit confused as to what the goal is here, or what problem is being solved. If I understand correctly, the idea is to have multiple independent stacks, which are served both under a unique domain each, and a globally shared domain. This shared domain would host this load balancer/proxy, and proxy to individual stacks, potentially based on geolocation.

There are some problems with the concept as laid out above. First, this setup is considered decentralized because each stack is hosted individually, and by someone else. However if we put a centralized proxy in front of it, this removes the achieved decentralization (making the proxy the single point of failure in the process). Secondly, it makes no sense to proxy based on geolocation. At this level, the user already connected to the proxy, and from here on out al that matters is the latency between proxy and actual backend. Proxying to a physically close backend stack from the users perspective would give adverse effects if the user is far away from the proxy.

Imo a better solution to this would be to solve this on the DNS level, by running geo aware authoritative DNS servers, where the DNS reply returns the IP(s) of the nearest backend stack if this is what we want. That way there is no additional (potentially high) latency cost to a centralized proxy first, and the SPOF of the proxy is removed. For this reason it can also be considered more decentralized. Note that the TLS certificate will need to be present on every backend stack (for the shared domain), which will require some mechanism to either distribute a certificate from a central location, or some orchestration so these backends can all acquire and renew the certificate on their own.

@Mik-TF
Copy link
Contributor Author

Mik-TF commented Jun 28, 2024

Thanks for this great feedback. Always highly appreciated.

Indeed I realized that the proxy was a SPOF. I thought worst case is that if it goes down, users will need to manually go to other URLs. It's not ideal for sure.

The idea you bring here looks way better and you seem to say it's feasible.
I think perhaps @coesensbert told me something along those lines lately.

I think we could maybe close this issue and create a new one with what you suggest here.

@coesensbert
Copy link
Contributor

So, after trying again we have been instructed no DNS infra of any kind for now and also no POC setups. Thanks @LeeSmet for helping in trying to convince them.

If we still have to continue with this geo load balancer, the next "best" approach (not) could be a 3 node geo forwarder. If:

  • we can distribute the cert (like https://dashboard.geo.grid.tf) via an S3 endpoint (garage cluster)
  • get nginx or caddy config working
  • if this can be a redirect for any first request on dashboard.geo.grid.tf, not following up requests (like to graphql or relay ..): once the user is forwarded to a stack, all future queries should be send to that stack (and not the geo forwarder)

In that case a user visiting dashboard.geo.grid.tf will get 3 ip's, round-robin will be used to choose one. Once the request is send, any of the 3 geo forwarders forward you to the closest grid stack. From that point on, you loose connection to dashboard.geo.grid.tf and continue on the forwarded grid backend stack.
Best scenario: if you are in EU, the EU geo forwarder is chosen randomly based on round robin and your forwarded to an EU grid stack
Worst scenario: if you are in the EU, the US geo forrwarder is chosen randomly based on round robin and your forwarded to the EU grid stack

Having a hard time just to get a single instance working, both with Nginx and Caddy. Have some poc running here: http://geo.ninja.tf/
Once this works, will look into distributing the cert via an S3.

@Mik-TF
Copy link
Contributor Author

Mik-TF commented Aug 27, 2024

Looks good as a starter with round-robin. At least we do get the load balancing functionality!

Thanks for the update.

@Mik-TF
Copy link
Contributor Author

Mik-TF commented Sep 2, 2024

@coesensbert can you give a status of this? Thanks!

@coesensbert coesensbert changed the title Load balancer for tfgrid validators Geo forwarder for tfgrid validators Sep 6, 2024
@coesensbert
Copy link
Contributor

Changed this to 'Geo forwarder' since a load balancer would not improve the situation (see above). We can do some load balancing for example on an pre-defined eu_upstream config since we have several stacks in the EU.

Abandoned the path with Caddy because I was not able to find/make config logic that we require for this.

Nginx + GeoIP2 seems to be the best approach for this. Tested a few different configs but always got stuck on several things.

One approach forwards you to http://default_upstream. So two issues here:

  • $geoip2_data_continent_name does not seem to supply Europe (in my case)
  • the upstream logic does seem to stop at the mapping, not at the defined upstreams
    image
    nginx.conf:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}


http {

    include       mime.types;
    include       /etc/nginx/conf.d/*.conf;
    include       /etc/nginx/sites-enabled/*;
    default_type  application/octet-stream;

    sendfile        on;

    keepalive_timeout  65;

    geoip2 GeoLite2-City.mmdb {
       $geoip2_data_continent_name $upstream;
    }

    map $geoip2_data_continent_name $upstream {
        'Europe' eu_upstream;
        'North America' us_upstream;
        'South America' us_upstream;
        'default' default_upstream;
    }

    geo $subnet {
        default         dashboard.fin.grid.tf;
    }


    upstream eu_upstream {
        server dashboard.be.grid.tf;
#        server dashboard.grid.tf max_fails=3 fail_timeout=600s;
#        server dashboard.be.grid.tf backup max_fails=3 fail_timeout=600s;
#        server dashboard.fin.grid.tf backup max_fails=3 fail_timeout=600s;
    }

    upstream us_upstream {
        server dashboard.us.grid.tf;
#        server dashboard.us.grid.tf max_fails=3 fail_timeout=600s;
#        server dashboard.grid.tf backup max_fails=3 fail_timeout=600s;
    }

    upstream default_upstream {
        server dashboard.grid.tf;
    }
}

default enabled site:

server {
#    listen 443 ssl;
    server_name  geo.ninja.tf;
    return 301 http://$upstream;

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/geo.ninja.tf/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/geo.ninja.tf/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot


}

server {
    if ($host = geo.ninja.tf) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80 default_server;
    server_name  geo.ninja.tf;
    return 404; # managed by Certbot


}

Another approach does not catch the if logic inside the default enabled site

nginx.conf

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
        worker_connections 768;
        # multi_accept on;
}

http {

        ##
        # Basic Settings
        ##

        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        # server_tokens off;

        # server_names_hash_bucket_size 64;
        # server_name_in_redirect off;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        ##
        # SSL Settings
        ##

        ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
        ssl_prefer_server_ciphers on;


        ## Geo stuff
        geoip2 GeoLite2-City.mmdb {
          $geoip2_data_continent_name;
        }

        ##
        # Logging Settings
        ##

        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        ##
        # Gzip Settings
        ##

        gzip on;

        # gzip_vary on;
        # gzip_proxied any;
        # gzip_comp_level 6;
        # gzip_buffers 16 8k;
        # gzip_http_version 1.1;
        # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

        ##
        # Virtual Host Configs
        ##

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

default enabled site:

server {
#    listen 443 ssl;
    server_name  geo.ninja.tf;
    location / {
      if ($geoip2_data_continent_name != "North America") {
       return 301 http://dashboard.us.grid.tf;
       }
      if ($geoip2_data_continent_name != "Europe") {
       return 301 http://dashboard.be.grid.tf;
       }
       return 301 http://dashboard.grid.tf;
    }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/geo.ninja.tf/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/geo.ninja.tf/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot


}

server {
    if ($host = geo.ninja.tf) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80 default_server;
    server_name  geo.ninja.tf;
    return 404; # managed by Certbot


}

@xmonader xmonader added the type_feature New feature or request label Sep 19, 2024
@xmonader xmonader added this to the 1.0.0 milestone Sep 19, 2024
@coesensbert
Copy link
Contributor

Got further on this but stuck at nginx not mapping to a defined upstream.

The following works, but doing this based on country name will make for a long list of pre-defined mappings. Which would have to be manually maintained based on where you want regions to go. Doable but not a practical approach. Mapping based on geo seem to work from basic vpn tests.

nginx.conf

    geoip_country /usr/share/GeoIP/GeoIP.dat;
    geoip_city /usr/share/GeoIP/GeoIPCity.dat;

    map $geoip_country_code $preferred_upstream {
      default dashboard.grid.tf;
      BE dashboard.be.grid.tf;
      DE dashboard.grid.tf;
      US dashboard.us.grid.tf;
      AS dashboard.sg.grid.tf;
      ...

default website

server {
    listen 80 default_server;
    server_name  geo.ninja.tf;

    location / {
    if ($http_cookie !~ "country=set") {
             add_header Set-Cookie "country=set;Max-Age=31536000";
             rewrite ^ $scheme://$preferred_upstream break;
             }
    }
}

Instead working with Continent names is much more in line with hour our current grid backend stacks are distributed (and will be in the future). Here we encounter the issue that for europe we have 3 stacks, while for the US one and for Asia also one. As this will grow organically, we need multiple options per continent thus we can introduce some basic load balancing if a given continent (or upstream) is chosen.

The following config is currently exposed at http://geo.ninja.tf ,can be tested but is broken as explained below.

nginx.conf

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}


http {

    include       mime.types;
    include       /etc/nginx/conf.d/*.conf;
    include       /etc/nginx/sites-enabled/*;
    default_type  application/octet-stream;

    sendfile        on;

    keepalive_timeout  65;

    geoip2 GeoLite2-City.mmdb {
       $geoip2_data_continent_name source=$remote_addr continent names en;
    }

    map $geoip2_data_continent_name $preferred_upstream {
        'Europe' eu_upstream;
        'North America' us_upstream;
        'South America' us_upstream;
        'Asia' as_upstream;
        'Africa' as_upstream;
        'default' default_upstream;
    }

    upstream eu_upstream {
        server dashboard.grid.tf max_fails=3 fail_timeout=600s;
        server dashboard.be.grid.tf max_fails=3 fail_timeout=600s;
        server dashboard.fin.grid.tf backup max_fails=3 fail_timeout=600s;
    }

    upstream us_upstream {
        server dashboard.us.grid.tf max_fails=3 fail_timeout=600s;
        server dashboard.grid.tf max_fails=3 fail_timeout=600s;
    }

    upstream as_upstream {
        server dashboard.sg.grid.tf max_fails=3 fail_timeout=600s;
        server dashboard.grid.tf max_fails=3 fail_timeout=600s;
    }

    upstream default_upstream {
        server dashboard.grid.tf;
    }
}

default website

server {
    listen 80 default_server;
    server_name  geo.ninja.tf;

    location / {
      return 301 http://$preferred_upstream$request_uri;
     }
}

The problem here is that the mapping happens, but is not interpreted as an upstream config. If you visit http://geo.ninja.tf you will be redirected to the name of the correct upstream, but not to what is configured inside the upstream config.

@xmonader
Copy link

Hi @coesensbert can you try replacing that redirect with a proxy_pass instead ? As you were saying it seems the you can't resolve that in a redirect, but may work in the proxy_pass

    location / {
        proxy_pass http://$preferred_upstream;
        // maybe punch of proxy set header directives 
    }

@PeterNashaat
Copy link
Member

  • In your current config, you are using return 301 to redirect to $preferred_upstream. To properly utilize the upstream servers, you sholud use proxy_pass instead of a redirect, here is the config should be and some headers passing config from the client to the upstream server.
server {
    listen 80 default_server;
    server_name geo.ninja.tf;

    location / {
        proxy_pass http://$preferred_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

@coesensbert
Copy link
Contributor

@xmonader @PeterNashaat thanks for the suggestions. We can't use proxy since that defeats the purpose of doing this. It would mean every request is first taken by the geo forwarder (well proxy in that case) and proxied to the closest stack. So if your in the us, every request would go to the eu, defeating what it's for and making the situation even worse then before.
The purpose of this is to catch and redirect the initial request to the correct location and keep the client there. So return 301 and rewrite are the only options. Before any query is made to a dashboard, graphql, .. etc we want the client to be at the correct stack for his location. We do not want to proxy every single query to graphql, gridproxy, .. But correct me if I'm wrong.

Using rewrite in the default website produces the same issue as with return 301

  server_name  geo.ninja.tf;
    rewrite ^/(.*)$ http://$preferred_upstream/$1 permanent;

Even tried it like this, but this logic is not picked up at all while nginx approves the config

server {
#    listen 443 ssl;
    server_name  geo.ninja.tf;
    location / {
      if ($geoip2_data_continent_name != "United States") {
#      rewrite ^/(.*)$ http://dashboard.us.grid.tf/$1 permanent;
       return 301 http://dashboard.us.grid.tf;
       }
      if ($geoip2_data_continent_name != "Europe") {
#      rewrite ^/(.*)$ http://dashboard.be.grid.tf/$1 permanent;
       return 301 http://dashboard.be.grid.tf;
       }
#      rewrite ^/(.*)$ http://dashboard.grid.tf/$1 permanent;
       return 301 http://dashboard.grid.tf;
    }

can show you about 4 different configs I also tried, same issue always comes up. So I'm missing something somewhere.

@PeterNashaat
Copy link
Member

  • Maybe $geoip2_data_continent_name != "United States" should be North America ? and you are using the if statement as continent_name is not equal (!=) to the specified continent (e.g., "United States")
  • So it should be something like this
server {
    listen 80 default_server;
    server_name geo.ninja.tf;

    location / {
        # Redirect based on the geoip2 continent name
        if ($geoip2_data_continent_name = "Europe") {
            return 301 http://dashboard.be.grid.tf$request_uri;
        }

        if ($geoip2_data_continent_name = "North America") {
            return 301 http://dashboard.us.grid.tf$request_uri;
        }

        if ($geoip2_data_continent_name = "Asia") {
            return 301 http://dashboard.sg.grid.tf$request_uri;
        }

        # goes back to global if no match
        return 301 http://dashboard.grid.tf$request_uri;
    }
}

@coesensbert
Copy link
Contributor

  • Maybe $geoip2_data_continent_name != "United States" should be North America ? and you are using the if statement as continent_name is not equal (!=) to the specified continent (e.g., "United States")
  • So it should be something like this
server {
    listen 80 default_server;
    server_name geo.ninja.tf;

    location / {
        # Redirect based on the geoip2 continent name
        if ($geoip2_data_continent_name = "Europe") {
            return 301 http://dashboard.be.grid.tf$request_uri;
        }

        if ($geoip2_data_continent_name = "North America") {
            return 301 http://dashboard.us.grid.tf$request_uri;
        }

        if ($geoip2_data_continent_name = "Asia") {
            return 301 http://dashboard.sg.grid.tf$request_uri;
        }

        # goes back to global if no match
        return 301 http://dashboard.grid.tf$request_uri;
    }
}

your right, i solved that during testing, pasted the wrong config (have so many by now). Quickly tested it again and the logic is accepted by nginx but not used when a request comes in. I'm always just forwarded tot http://dashboard.grid.tf while I also tested the geoip2 db works.
Either way, same issue with this config applies so I abandoned it:

Here we encounter the issue that for europe we have 3 stacks, while for the US one and for Asia also one. As this will grow organically, we need multiple options per continent thus we can introduce some basic load balancing if a given continent (or upstream) is chosen.

@coesensbert
Copy link
Contributor

got a cool AI suggestion from @Mik-TF to use this javascript thing

function balanceServers(r) {
    var upstream = r.variables.preferred_upstream;
    var servers = {
        'eu_upstream': ['dashboard.grid.tf', 'dashboard.be.grid.tf', 'dashboard.fin.grid.tf'],
        'us_upstream': ['dashboard.us.grid.tf', 'dashboard.grid.tf'],
        'as_upstream': ['dashboard.sg.grid.tf', 'dashboard.grid.tf'],
        'default_upstream': ['dashboard.grid.tf']
    };

    var selectedServers = servers[upstream] || servers['default_upstream'];
    var index = Math.floor(Math.random() * selectedServers.length);
    return selectedServers[index];
}

export default {balanceServers};

here I run into the issue that we need a nginx js module, which only comes from the original nginx repo's. Installed those, then a conflict occurs with the geoip2 module. Seems the nginx repo's only support geoip, not geoip2

root@geo-poc:~# apt install libnginx-mod-http-geoip2 libnginx-mod-stream-geoip2 nginx-module-njs
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Some packages could not be installed. This may mean that you have
requested an impossible situation or if you are using the unstable
distribution that some required packages have not yet been created
or been moved out of Incoming.
The following information may help to resolve the situation:

The following packages have unmet dependencies:
 nginx : Conflicts: nginx-common but 1.18.0-6ubuntu14.5 is to be installed
E: Unable to correct problems, you have held broken packages.

at this point, I have no clue nor any ideas anymore. Every path we make up, ends up dead.

@scottyeager
Copy link

Here's a solution based on a bit different approach. The idea is to serve the user some Javascript that will take care of the redirect step inside the browser:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard</title>
</head>
<body>
    <p>Redirecting...</p>
    <script>
        // Thanks to https://www.movable-type.co.uk/scripts/latlong.html (MIT license)
        function getDistanceFromLatLonInKm(lat1,lon1,lat2,lon2) {
          var R = 6371; // Radius of the earth in km
          var dLat = deg2rad(lat2-lat1);  // deg2rad below
          var dLon = deg2rad(lon2-lon1); 
          var a = 
            Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
            Math.sin(dLon/2) * Math.sin(dLon/2)
            ;
          var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
          var d = R * c; // Distance in km
          return d;
        }

        function deg2rad(deg) {
          return deg * (Math.PI/180)
        }

        function redirectClosest(geoip) {
          var closest = null;
          for (const backend of backends) {
            if (!closest) {
              closest = {url: backend.url, distance: getDistanceFromLatLonInKm(geoip.lat, geoip.lon, backend.lat, backend.lon)};
            } else {
              distance = getDistanceFromLatLonInKm(geoip.lat, geoip.lon, backend.lat, backend.lon);
              if (distance < closest.distance) {
                closest = {url: backend.url, distance: distance};
              }
            }
          }
          window.location.replace(closest.url);
        }

        backends = [
          {url: 'https://dashboard.grid.tf', lat: 50.4777, lon: 12.3649},
          {url: 'https://dashboard.us.grid.tf', lat: 40.7862, lon: -74.0743},
          {url: 'https://dashboard.be.grid.tf', lat: 51.0923, lon: 3.82025},
          {url: 'https://dashboard.sg.grid.tf', lat: 1.35208, lon: 103.82},
          {url: 'https://dashboard.fin.grid.tf', lat: 60.1797, lon: 24.9344}
        ];

        window.onload = function() {
            fetch('http://ip-api.com/json')
            .then(response => response.json())
            .then(data => redirectClosest(data));
        };
    </script>
</body>
</html>

The backends are hardcoded with the lat/lon values to save some API requests. Distance is calculated using Haversine formula, based on the lat/lon values for the user pulled from ip-api service. We could also use geoip.grid.tf for this, but it would require adding a CORS header (simple enough).

I did a couple quick tests with VPN and it seems to work fine. If we want to use this, it should at least be improved to fall back to a default if the geoip API can't be reached. Probably best to have an experienced JS dev check it over too 🙂

This keeps thing operationally very simple, as it's just a static file to serve and the browser takes care of the rest. Another approach would be to do something similar server side and serve up an actual redirect, via a simple custom application.

@Mik-TF
Copy link
Contributor Author

Mik-TF commented Sep 25, 2024

@scottyeager it works perfectly on my end, testing with different locations via VPN. Clever suggestion!

Here's a rewrite with some picocss to enhance the UI:

image

EDIT: As suggested by Scott, I set the same colors as the dashboard loading page colors.

<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard Redirect</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
    <style>
        :root {
            --pico-background-color: #212020;
            --pico-primary: #1aa18f;
            --pico-primary-background: #1aa18f;
            --pico-primary-hover: #158f7f;
            --pico-primary-hover-background: #158f7f;
        }
        body > main {
            display: flex;
            flex-direction: column;
            justify-content: center;
            min-height: 100vh;
            padding: 1rem 0;
        }
        article {
            background-color: #212020;
            border-radius: 8px;
            padding: 2rem;
        }
        article header, article footer {
            background-color: #212020;
            margin: -2rem -2rem 2rem -2rem;
            padding: 2rem;
        }
        article footer {
            margin: 1rem -1rem -1rem -1rem;
        }
        progress {
            border-radius: 10px;
            height: 10px;
        }
        progress::-webkit-progress-bar {
            background-color: rgba(26, 161, 143, 0.2);
            border-radius: 10px;
        }
        progress::-webkit-progress-value {
            background-color: #1aa18f;
            border-radius: 10px;
        }
        progress::-moz-progress-bar {
            background-color: #1aa18f;
            border-radius: 10px;
        }
    </style>
</head>
<body>
    <main class="container">
        <article>
            <header>
                <h1>ThreeFold Dashboard</h1>
                <p>We're finding the best route for you...</p>
            </header>
            <progress id="progress" value="0" max="100"></progress>
            <footer>
                <p>You will be redirected to the nearest ThreeFold Dashboard shortly.</p>
            </footer>
        </article>
    </main>

    <script>
        // Thanks to https://www.movable-type.co.uk/scripts/latlong.html (MIT license)
        function getDistanceFromLatLonInKm(lat1,lon1,lat2,lon2) {
          var R = 6371; // Radius of the earth in km
          var dLat = deg2rad(lat2-lat1);  // deg2rad below
          var dLon = deg2rad(lon2-lon1); 
          var a = 
            Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
            Math.sin(dLon/2) * Math.sin(dLon/2)
            ; 
          var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
          var d = R * c; // Distance in km
          return d;
        }

        function deg2rad(deg) {
          return deg * (Math.PI/180)
        }

        function redirectClosest(geoip) {
          var closest = null;
          for (const backend of backends) {
            if (!closest) {
              closest = {url: backend.url, distance: getDistanceFromLatLonInKm(geoip.latitude, geoip.longitude, backend.lat, backend.lon)};
            } else {
              distance = getDistanceFromLatLonInKm(geoip.latitude, geoip.longitude, backend.lat, backend.lon);
              if (distance < closest.distance) {
                closest = {url: backend.url, distance: distance};
              }
            }
          }
          window.location.replace(closest.url);
        }

        const backends = [
          {url: 'https://dashboard.grid.tf', lat: 50.4777, lon: 12.3649},
          {url: 'https://dashboard.us.grid.tf', lat: 40.7862, lon: -74.0743},
          {url: 'https://dashboard.be.grid.tf', lat: 51.0923, lon: 3.82025},
          {url: 'https://dashboard.sg.grid.tf', lat: 1.35208, lon: 103.82},
          {url: 'https://dashboard.fin.grid.tf', lat: 60.1797, lon: 24.9344}
        ];

        window.onload = function() {
            const progress = document.getElementById('progress');
            let progressValue = 0;
            const progressInterval = setInterval(() => {
                progressValue += 10;
                progress.value = progressValue;
                if (progressValue >= 100) {
                    clearInterval(progressInterval);
                }
            }, 200);

            fetch('https://ipapi.co/json/')
                .then(response => response.json())
                .then(data => {
                    clearInterval(progressInterval);
                    progress.value = 100;
                    setTimeout(() => redirectClosest(data), 500);
                })
                .catch(error => {
                    console.error('Error:', error);
                    document.querySelector('article footer p').textContent = 'An error occurred. Please try again later.';
                });
        };
    </script>
</body>
</html>

@scottyeager
Copy link

scottyeager commented Sep 25, 2024

Here's an alternative version with a couple of changes. First, it just uses a plain background that's the same color as the Dashboard loading page. This is pretty seamless so it's a nice experience without needing extra stuff.

The other change is that this one makes a request against each backend from the list and redirects to the one that responds first. This is simpler and more robust without any real downside.

<!DOCTYPE html>
<html lang="en">
<body style="background-color:#212020;">
<head>
    <meta charset="UTF-8">
    <meta name="" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard</title>
</head>
<body>
    <script>
        backends = [
          'https://dashboard.grid.tf',
          'https://dashboard.us.grid.tf',
          'https://dashboard.be.grid.tf',
          'https://dashboard.sg.grid.tf',
          'https://dashboard.fin.grid.tf'
        ];

        async function wasFetched(url) {
          await fetch(url, {mode: 'no-cors'});
          return url;
        }

        window.onload = function() {
          Promise.race(backends.map(url => wasFetched(url)))
            .then(url => window.location.replace(url))
            .catch(error => console.error('Error:', error));
        };
    </script>
</body>
</html>

The only thing to add here would be some handling for the case where no backend responds before some timeout. Most likely it would be the user's connection to blame in that case, since the likelihood of simultaneous failure of all these sites should be next to zero.

@scottyeager
Copy link

I wanted to add a quick additional note on the second approach. The use of no-cors means that we get an "opaque" response that doesn't actually contain the contents of the site. This is fast and works well enough for our purposes of discovering which site can respond the fastest.

However, it might be appropriate to add health check path, such as described here but with a Access-Control-Allow-Origin header added to allow Javascript running in the browser to access it. That can be done entirely in the web server, no need to modify application code at all.

@coesensbert
Copy link
Contributor

Thanks for the suggestions guys, everything is setup below. If it does not seem to work, make sure to clear cache or use a new incognito tab.

http://dashboard.geo.ninja.tf/ -->

user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
    worker_connections  1024;
}


http {

    include       mime.types;
    include       /etc/nginx/conf.d/*.conf;
    include       /etc/nginx/sites-enabled/*;
    default_type  application/octet-stream;

    sendfile        on;

    keepalive_timeout  65;

    geoip2 GeoLite2-City.mmdb {
       $geoip2_country_code source=$remote_addr country iso_code;
    }

    map $geoip2_country_code $primary_backend {
        default dashboard.grid.tf;

        # North America
        US dashboard.us.grid.tf;
        CA dashboard.us.grid.tf;
        MX dashboard.us.grid.tf;
        GL dashboard.us.grid.tf;
        PM dashboard.us.grid.tf;

        # Central America and Caribbean
        BZ dashboard.us.grid.tf;
        CR dashboard.us.grid.tf;
        SV dashboard.us.grid.tf;
        GT dashboard.us.grid.tf;
        HN dashboard.us.grid.tf;
        NI dashboard.us.grid.tf;
        PA dashboard.us.grid.tf;
        CU dashboard.us.grid.tf;
        DO dashboard.us.grid.tf;
        HT dashboard.us.grid.tf;
        JM dashboard.us.grid.tf;
        PR dashboard.us.grid.tf;
        VI dashboard.us.grid.tf;
        AG dashboard.us.grid.tf;
        BS dashboard.us.grid.tf;
        BB dashboard.us.grid.tf;
        DM dashboard.us.grid.tf;
        GD dashboard.us.grid.tf;
        KN dashboard.us.grid.tf;
        LC dashboard.us.grid.tf;
        VC dashboard.us.grid.tf;
        TT dashboard.us.grid.tf;
        AI dashboard.us.grid.tf;
        BM dashboard.us.grid.tf;
        VG dashboard.us.grid.tf;
        KY dashboard.us.grid.tf;
        MS dashboard.us.grid.tf;
        TC dashboard.us.grid.tf;
        SX dashboard.us.grid.tf;
        MF dashboard.us.grid.tf;
        BL dashboard.us.grid.tf;
        GP dashboard.us.grid.tf;
        MQ dashboard.us.grid.tf;
        CW dashboard.us.grid.tf;
        AW dashboard.us.grid.tf;
        BQ dashboard.us.grid.tf;

        # South America
        AR dashboard.us.grid.tf;
        BO dashboard.us.grid.tf;
        BR dashboard.us.grid.tf;
        CL dashboard.us.grid.tf;
        CO dashboard.us.grid.tf;
        EC dashboard.us.grid.tf;
        FK dashboard.us.grid.tf;
        GF dashboard.us.grid.tf;
        GY dashboard.us.grid.tf;
        PE dashboard.us.grid.tf;
        PY dashboard.us.grid.tf;
        SR dashboard.us.grid.tf;
        UY dashboard.us.grid.tf;
        VE dashboard.us.grid.tf;

        # Western Europe
        DE dashboard.grid.tf;
        AT dashboard.grid.tf;
        CH dashboard.grid.tf;
        LI dashboard.grid.tf;
        NL dashboard.be.grid.tf;
        BE dashboard.be.grid.tf;
        LU dashboard.be.grid.tf;
        FR dashboard.be.grid.tf;
        GB dashboard.be.grid.tf;
        IE dashboard.be.grid.tf;
        IS dashboard.be.grid.tf;
        PT dashboard.be.grid.tf;
        ES dashboard.be.grid.tf;
        AD dashboard.be.grid.tf;
        MC dashboard.be.grid.tf;
        IT dashboard.be.grid.tf;
        SM dashboard.be.grid.tf;
        VA dashboard.be.grid.tf;
        MT dashboard.be.grid.tf;

        # Northern and Eastern Europe
        FI dashboard.fin.grid.tf;
        SE dashboard.fin.grid.tf;
        NO dashboard.fin.grid.tf;
        DK dashboard.fin.grid.tf;
        EE dashboard.fin.grid.tf;
        LV dashboard.fin.grid.tf;
        LT dashboard.fin.grid.tf;
        PL dashboard.fin.grid.tf;
        CZ dashboard.fin.grid.tf;
        SK dashboard.fin.grid.tf;
        HU dashboard.fin.grid.tf;
        RO dashboard.fin.grid.tf;
        BG dashboard.fin.grid.tf;
        SI dashboard.fin.grid.tf;
        HR dashboard.fin.grid.tf;
        BA dashboard.fin.grid.tf;
        RS dashboard.fin.grid.tf;
        ME dashboard.fin.grid.tf;
        MK dashboard.fin.grid.tf;
        AL dashboard.fin.grid.tf;
        GR dashboard.fin.grid.tf;
        CY dashboard.fin.grid.tf;
        RU dashboard.fin.grid.tf;
        BY dashboard.fin.grid.tf;
        UA dashboard.fin.grid.tf;
        MD dashboard.fin.grid.tf;
        TR dashboard.fin.grid.tf;

        # Africa
        MA dashboard.be.grid.tf;
        DZ dashboard.be.grid.tf;
        TN dashboard.be.grid.tf;
        LY dashboard.be.grid.tf;
        EG dashboard.be.grid.tf;
        SD dashboard.be.grid.tf;
        TD dashboard.be.grid.tf;
        ER dashboard.be.grid.tf;
        DJ dashboard.be.grid.tf;
        ET dashboard.be.grid.tf;
        SO dashboard.be.grid.tf;
        KE dashboard.be.grid.tf;
        UG dashboard.be.grid.tf;
        RW dashboard.be.grid.tf;
        BI dashboard.be.grid.tf;
        TZ dashboard.be.grid.tf;
        MZ dashboard.be.grid.tf;
        ZW dashboard.be.grid.tf;
        ZM dashboard.be.grid.tf;
        MW dashboard.be.grid.tf;
        AO dashboard.be.grid.tf;
        NA dashboard.be.grid.tf;
        BW dashboard.be.grid.tf;
        ZA dashboard.be.grid.tf;
        SZ dashboard.be.grid.tf;
        LS dashboard.be.grid.tf;
        MG dashboard.be.grid.tf;
        KM dashboard.be.grid.tf;
        MU dashboard.be.grid.tf;
        RE dashboard.be.grid.tf;
        SC dashboard.be.grid.tf;
        YT dashboard.be.grid.tf;
        EH dashboard.be.grid.tf;
        MR dashboard.be.grid.tf;
        ML dashboard.be.grid.tf;
        NE dashboard.be.grid.tf;
        NG dashboard.be.grid.tf;
        BF dashboard.be.grid.tf;
        BJ dashboard.be.grid.tf;
        TG dashboard.be.grid.tf;
        GH dashboard.be.grid.tf;
        CI dashboard.be.grid.tf;
        LR dashboard.be.grid.tf;
        SL dashboard.be.grid.tf;
        GN dashboard.be.grid.tf;
        GW dashboard.be.grid.tf;
        SN dashboard.be.grid.tf;
        GM dashboard.be.grid.tf;
        CV dashboard.be.grid.tf;
        ST dashboard.be.grid.tf;
        GQ dashboard.be.grid.tf;
        GA dashboard.be.grid.tf;
        CG dashboard.be.grid.tf;
        CD dashboard.be.grid.tf;
        CF dashboard.be.grid.tf;
        CM dashboard.be.grid.tf;
        SS dashboard.be.grid.tf;

        # Middle East
        IL dashboard.be.grid.tf;
        PS dashboard.be.grid.tf;
        LB dashboard.be.grid.tf;
        SY dashboard.be.grid.tf;
        IQ dashboard.be.grid.tf;
        IR dashboard.sg.grid.tf;
        JO dashboard.be.grid.tf;
        SA dashboard.sg.grid.tf;
        YE dashboard.sg.grid.tf;
        OM dashboard.sg.grid.tf;
        AE dashboard.sg.grid.tf;
        QA dashboard.sg.grid.tf;
        BH dashboard.sg.grid.tf;
        KW dashboard.sg.grid.tf;

        # Asia
        GE dashboard.fin.grid.tf;
        AM dashboard.fin.grid.tf;
        AZ dashboard.fin.grid.tf;
        KZ dashboard.sg.grid.tf;
        UZ dashboard.sg.grid.tf;
        TM dashboard.sg.grid.tf;
        AF dashboard.sg.grid.tf;
        PK dashboard.sg.grid.tf;
        IN dashboard.sg.grid.tf;
        NP dashboard.sg.grid.tf;
        BT dashboard.sg.grid.tf;
        BD dashboard.sg.grid.tf;
        LK dashboard.sg.grid.tf;
        MV dashboard.sg.grid.tf;
        KG dashboard.sg.grid.tf;
        TJ dashboard.sg.grid.tf;
        MN dashboard.sg.grid.tf;
        CN dashboard.sg.grid.tf;
        HK dashboard.sg.grid.tf;
        MO dashboard.sg.grid.tf;
        TW dashboard.sg.grid.tf;
        KP dashboard.sg.grid.tf;
        KR dashboard.sg.grid.tf;
        JP dashboard.sg.grid.tf;
        VN dashboard.sg.grid.tf;
        LA dashboard.sg.grid.tf;
        KH dashboard.sg.grid.tf;
        TH dashboard.sg.grid.tf;
        MM dashboard.sg.grid.tf;
        MY dashboard.sg.grid.tf;
        SG dashboard.sg.grid.tf;
        ID dashboard.sg.grid.tf;
        TL dashboard.sg.grid.tf;
        BN dashboard.sg.grid.tf;
        PH dashboard.sg.grid.tf;

        # Oceania
        AU dashboard.sg.grid.tf;
        NZ dashboard.sg.grid.tf;
        NC dashboard.sg.grid.tf;
        VU dashboard.sg.grid.tf;
        SB dashboard.sg.grid.tf;
        FJ dashboard.sg.grid.tf;
        PG dashboard.sg.grid.tf;
        FM dashboard.sg.grid.tf;
        GU dashboard.sg.grid.tf;
        MP dashboard.sg.grid.tf;
        PW dashboard.sg.grid.tf;
        MH dashboard.sg.grid.tf;
        WS dashboard.sg.grid.tf;
        TO dashboard.sg.grid.tf;
        TV dashboard.sg.grid.tf;
        KI dashboard.sg.grid.tf;
        NR dashboard.sg.grid.tf;
        CK dashboard.sg.grid.tf;
        NU dashboard.sg.grid.tf;
        PF dashboard.sg.grid.tf;
        WF dashboard.sg.grid.tf;
        AS dashboard.sg.grid.tf;
        TK dashboard.sg.grid.tf;

        # Antarctica
        AQ dashboard.us.grid.tf;
        BV dashboard.us.grid.tf;
        GS dashboard.us.grid.tf;
        HM dashboard.us.grid.tf;
        TF dashboard.us.grid.tf;
    }

    map $primary_backend $secondary_backend {
        dashboard.grid.tf   dashboard.be.grid.tf;
        dashboard.fin.grid.tf dashboard.grid.tf;
        dashboard.be.grid.tf  dashboard.grid.tf;
        dashboard.us.grid.tf  dashboard.sg.grid.tf;
        dashboard.sg.grid.tf  dashboard.us.grid.tf;
    }

    server {
        listen 80;
        listen [::]:80;
        server_name dashboard.geo.ninja.tf;

        location / {
            set $backend $primary_backend;

            # Check if primary backend is down
            if ($backend = "") {
                set $backend $secondary_backend;
            }

            return 301 $scheme://$backend$request_uri;
        }

        error_page 500 502 503 504 = @fallback;

        location @fallback {
            return 301 $scheme://$secondary_backend$request_uri;
        }
    }

http://mik.geo.ninja.tf/ -->

<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard Redirect</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
    <style>
        :root {
            --pico-background-color: #212020;
            --pico-primary: #1aa18f;
            --pico-primary-background: #1aa18f;
            --pico-primary-hover: #158f7f;
            --pico-primary-hover-background: #158f7f;
        }
        body > main {
            display: flex;
            flex-direction: column;
            justify-content: center;
            min-height: 100vh;
            padding: 1rem 0;
        }
        article {
            background-color: #212020;
            border-radius: 8px;
            padding: 2rem;
        }
        article header, article footer {
            background-color: #212020;
            margin: -2rem -2rem 2rem -2rem;
            padding: 2rem;
        }
        article footer {
            margin: 1rem -1rem -1rem -1rem;
        }
        progress {
            border-radius: 10px;
            height: 10px;
        }
        progress::-webkit-progress-bar {
            background-color: rgba(26, 161, 143, 0.2);
            border-radius: 10px;
        }
        progress::-webkit-progress-value {
            background-color: #1aa18f;
            border-radius: 10px;
        }
        progress::-moz-progress-bar {
            background-color: #1aa18f;
            border-radius: 10px;
        }
    </style>
</head>
<body>
    <main class="container">
        <article>
            <header>
                <h1>ThreeFold Dashboard</h1>
                <p>We're finding the best route for you...</p>
            </header>
            <progress id="progress" value="0" max="100"></progress>
            <footer>
                <p>You will be redirected to the nearest ThreeFold Dashboard shortly.</p>
            </footer>
        </article>
    </main>

    <script>
        // Thanks to https://www.movable-type.co.uk/scripts/latlong.html (MIT license)
        function getDistanceFromLatLonInKm(lat1,lon1,lat2,lon2) {
          var R = 6371; // Radius of the earth in km
          var dLat = deg2rad(lat2-lat1);  // deg2rad below
          var dLon = deg2rad(lon2-lon1); 
          var a = 
            Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
            Math.sin(dLon/2) * Math.sin(dLon/2)
            ; 
          var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
          var d = R * c; // Distance in km
          return d;
        }

        function deg2rad(deg) {
          return deg * (Math.PI/180)
        }

        function redirectClosest(geoip) {
          var closest = null;
          for (const backend of backends) {
            if (!closest) {
              closest = {url: backend.url, distance: getDistanceFromLatLonInKm(geoip.latitude, geoip.longitude, backend.lat, backend.lon)};
            } else {
              distance = getDistanceFromLatLonInKm(geoip.latitude, geoip.longitude, backend.lat, backend.lon);
              if (distance < closest.distance) {
                closest = {url: backend.url, distance: distance};
              }
            }
          }
          window.location.replace(closest.url);
        }

        const backends = [
          {url: 'https://dashboard.grid.tf', lat: 50.4777, lon: 12.3649},
          {url: 'https://dashboard.us.grid.tf', lat: 40.7862, lon: -74.0743},
          {url: 'https://dashboard.be.grid.tf', lat: 51.0923, lon: 3.82025},
          {url: 'https://dashboard.sg.grid.tf', lat: 1.35208, lon: 103.82},
          {url: 'https://dashboard.fin.grid.tf', lat: 60.1797, lon: 24.9344}
        ];

        window.onload = function() {
            const progress = document.getElementById('progress');
            let progressValue = 0;
            const progressInterval = setInterval(() => {
                progressValue += 10;
                progress.value = progressValue;
                if (progressValue >= 100) {
                    clearInterval(progressInterval);
                }
            }, 200);

            fetch('https://ipapi.co/json/')
                .then(response => response.json())
                .then(data => {
                    clearInterval(progressInterval);
                    progress.value = 100;
                    setTimeout(() => redirectClosest(data), 500);
                })
                .catch(error => {
                    console.error('Error:', error);
                    document.querySelector('article footer p').textContent = 'An error occurred. Please try again later.';
                });
        };
    </script>
</body>
</html>

http://scott.geo.ninja.tf/ -->

<!DOCTYPE html>
<html lang="en">
<body style="background-color:#212020;">
<head>
    <meta charset="UTF-8">
    <meta name="" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard</title>
</head>
<body>
    <script>
        backends = [
          'https://dashboard.grid.tf',
          'https://dashboard.us.grid.tf',
          'https://dashboard.be.grid.tf',
          'https://dashboard.sg.grid.tf',
          'https://dashboard.fin.grid.tf'
        ];

        async function wasFetched(url) {
          await fetch(url, {mode: 'no-cors'});
          return url;
        }

        window.onload = function() {
          Promise.race(backends.map(url => wasFetched(url)))
            .then(url => window.location.replace(url))
            .catch(error => console.error('Error:', error));
        };
    </script>
</body>
</html>

@Mik-TF
Copy link
Contributor Author

Mik-TF commented Sep 27, 2024

Tested the 3 methods on vpn (toronto, france and india). All worked well!!

  • 3 is faster than 2

@coesensbert
Copy link
Contributor

the plan for now is to setup a multi node caddy poc that serves Scott's latency based forwarder. If one wants to improve this, send in some suggestions. We ditch nginx at this point, we only use caddy everywhere anyway.

see: https://github.com/threefoldtech/tf_operations/issues/2803

<!DOCTYPE html>
<html lang="en">
<body style="background-color:#212020;">
<head>
    <meta charset="UTF-8">
    <meta name="" content="width=device-width, initial-scale=1.0">
    <title>ThreeFold Dashboard</title>
</head>
<body>
    <script>
        backends = [
          'https://dashboard.grid.tf',
          'https://dashboard.us.grid.tf',
          'https://dashboard.be.grid.tf',
          'https://dashboard.sg.grid.tf',
          'https://dashboard.fin.grid.tf'
        ];

        async function wasFetched(url) {
          await fetch(url, {mode: 'no-cors'});
          return url;
        }

        window.onload = function() {
          Promise.race(backends.map(url => wasFetched(url)))
            .then(url => window.location.replace(url))
            .catch(error => console.error('Error:', error));
        };
    </script>
</body>
</html>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type_feature New feature or request
Projects
Status: In Progress
Status: In Progress
Development

No branches or pull requests

6 participants