9 Commits

Author SHA1 Message Date
9caa391601 Resolve issue with returning a lower number of servers, add auth to reload
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-04-02 14:35:46 -04:00
e5434a9a7b Add mirror status endpoint, timestamp on last activity
All checks were successful
continuous-integration/drone/push Build is passing
2022-04-02 04:50:12 -04:00
2f43f2d934 Strip leading slashes from requests to download map
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-04-02 01:21:42 -04:00
a571832239 Add support for region paths
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-04-01 00:04:27 -04:00
20ae76ff06 Fix distance sorting, add env variable for local IP to test
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-03-31 22:43:18 -04:00
b4ed1fc1a3 Check redirect path and add trailing slash if necessary
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-03-31 22:23:51 -04:00
aa8a187bda Mimic existing mirrors endpoint, add geoip
All checks were successful
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is passing
2022-03-31 22:04:19 -04:00
e06eced768 No more initial status messages = starting does nothing
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-03-31 01:04:04 -04:00
6c52d1a5b2 Rewrite to add weighting and caching
All checks were successful
continuous-integration/drone/push Build is passing
2022-03-31 00:57:21 -04:00
12 changed files with 639 additions and 187 deletions

1
assets/status-down.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="20" role="img" aria-label="status: down"><title>status: down</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="82" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="43" height="20" fill="#555"/><rect x="43" width="39" height="20" fill="#e05d44"/><rect width="82" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="225" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">status</text><text x="225" y="140" transform="scale(.1)" fill="#fff" textLength="330">status</text><text aria-hidden="true" x="615" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="290">down</text><text x="615" y="140" transform="scale(.1)" fill="#fff" textLength="290">down</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="104" height="20" role="img" aria-label="status: unknown"><title>status: unknown</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="104" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="43" height="20" fill="#555"/><rect x="43" width="61" height="20" fill="#9f9f9f"/><rect width="104" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="225" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">status</text><text x="225" y="140" transform="scale(.1)" fill="#fff" textLength="330">status</text><text aria-hidden="true" x="725" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">unknown</text><text x="725" y="140" transform="scale(.1)" fill="#fff" textLength="510">unknown</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
assets/status-up.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="66" height="20" role="img" aria-label="status: up"><title>status: up</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="66" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="43" height="20" fill="#555"/><rect x="43" width="23" height="20" fill="#4c1"/><rect width="66" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="225" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">status</text><text x="225" y="140" transform="scale(.1)" fill="#fff" textLength="330">status</text><text aria-hidden="true" x="535" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">up</text><text x="535" y="140" transform="scale(.1)" fill="#fff" textLength="130">up</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

233
config.go Normal file
View File

@ -0,0 +1,233 @@
package main
import (
lru "github.com/hashicorp/golang-lru"
"github.com/oschwald/maxminddb-golang"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
"net"
"net/url"
"strings"
"sync"
)
func reloadConfig() {
log.Info("Loading configuration...")
err := viper.ReadInConfig() // Find and read the config file
if err != nil { // Handle errors reading the config file
log.WithError(err).Fatalln("Unable to load config file")
}
// db will never be reloaded.
if db == nil {
// Load maxmind database
db, err = maxminddb.Open(viper.GetString("geodb"))
if err != nil {
log.WithError(err).Fatalln("Unable to open database")
}
}
// Refresh server cache if size changed
if serverCache == nil {
serverCache, err = lru.New(viper.GetInt("cacheSize"))
} else {
serverCache.Resize(viper.GetInt("cacheSize"))
}
// Set top choice count
topChoices = viper.GetInt("topChoices")
// Reload map file
reloadMap()
// Reload server list
reloadServers()
// Create mirror map
mirrors := make(map[string][]*Server)
for _, server := range servers {
mirrors[server.Continent] = append(mirrors[server.Continent], server)
}
mirrors["default"] = append(mirrors["NA"], mirrors["EU"]...)
regionMap = mirrors
hosts := make(map[string]*Server)
for _, server := range servers {
hosts[server.Host] = server
}
hostMap = hosts
// Check top choices size
if topChoices > len(servers) {
topChoices = len(servers)
}
// Force check
go servers.Check()
}
func reloadServers() {
var serverList []ServerConfig
viper.UnmarshalKey("servers", &serverList)
var wg sync.WaitGroup
existing := make(map[string]int)
for i, server := range servers {
existing[server.Host] = i
}
hosts := make(map[string]bool)
for _, server := range serverList {
wg.Add(1)
var prefix string
if !strings.HasPrefix(server.Server, "http") {
prefix = "https://"
}
u, err := url.Parse(prefix + server.Server)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": server,
}).Warning("Server is invalid")
return
}
hosts[u.Host] = true
i := -1
if v, exists := existing[u.Host]; exists {
i = v
}
go func(i int, server ServerConfig, u *url.URL) {
defer wg.Done()
s := addServer(server, u)
if _, ok := existing[u.Host]; ok {
s.Redirects = servers[i].Redirects
servers[i] = s
} else {
s.Redirects = promauto.NewCounter(prometheus.CounterOpts{
Name: "armbian_router_redirects_" + metricReplacer.Replace(u.Host),
Help: "The number of redirects for server " + u.Host,
})
servers = append(servers, s)
log.WithFields(log.Fields{
"server": u.Host,
"path": u.Path,
"latitude": s.Latitude,
"longitude": s.Longitude,
}).Info("Added server")
}
}(i, server, u)
}
wg.Wait()
// Remove servers that no longer exist in the config
for i := len(servers) - 1; i >= 0; i-- {
if _, exists := hosts[servers[i].Host]; exists {
continue
}
log.WithFields(log.Fields{
"server": servers[i].Host,
}).Info("Removed server")
servers = append(servers[:i], servers[i+1:]...)
}
}
var metricReplacer = strings.NewReplacer(".", "_", "-", "_")
// addServer takes ServerConfig and constructs a server.
// This will create duplicate servers, but it will overwrite existing ones when changed.
func addServer(server ServerConfig, u *url.URL) *Server {
s := &Server{
Available: true,
Host: u.Host,
Path: u.Path,
Latitude: server.Latitude,
Longitude: server.Longitude,
Continent: server.Continent,
Weight: server.Weight,
}
// Defaults to 10 to allow servers to be set lower for lower priority
if s.Weight == 0 {
s.Weight = 10
}
ips, err := net.LookupIP(u.Host)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": s.Host,
}).Warning("Could not resolve address")
return nil
}
var city City
err = db.Lookup(ips[0], &city)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": s.Host,
"ip": ips[0],
}).Warning("Could not geolocate address")
return nil
}
if s.Continent == "" {
s.Continent = city.Continent.Code
}
if s.Latitude == 0 && s.Longitude == 0 {
s.Latitude = city.Location.Latitude
s.Longitude = city.Location.Longitude
}
return s
}
func reloadMap() {
mapFile := viper.GetString("dl_map")
if mapFile == "" {
return
}
log.WithField("file", mapFile).Info("Loading download map")
newMap, err := loadMap(mapFile)
if err != nil {
return
}
dlMap = newMap
}

View File

@ -1,16 +1,47 @@
geodb: GeoIP2-City.mmdb # GeoIP Database Path
geodb: GeoLite2-City.mmdb
dl_map: userdata.csv
# LRU Cache Size (in items)
cacheSize: 1024
# Server definition
# Weights are just like nginx, where if it's > 1 it'll be chosen x out of x + total times
# By default, the top 3 servers are used for choosing the best.
# server = full url or host+path
# weight = int
# optional: latitude, longitude (float)
servers: servers:
- https://mirrors.tuna.tsinghua.edu.cn/armbian-releases/ - server: armbian.12z.eu/apt/
- https://mirrors.bfsu.edu.cn/armbian-releases/ - server: armbian.chi.auroradev.org/apt/
- https://mirrors.nju.edu.cn/armbian-releases/ weight: 15
- https://mirrors.ustc.edu.cn/armbian-dl/ latitude: 41.8879
- https://mirror.12z.eu/pub/linux/armbian/dl/ longitude: -88.1995
- https://armbian.tnahosting.net/dl/ - server: armbian.hosthatch.com/apt/
- https://stpete-mirror.armbian.com/dl/ - server: armbian.lv.auroradev.org/apt/
- https://mirror.armbian.de/dl/ weight: 15
- https://mirrors.netix.net/armbian/dl/ - server: armbian.site-meganet.com/apt/
- https://mirrors.dotsrc.org/armbian-dl/ - server: armbian.systemonachip.net/apt/
- https://armbian.hosthatch.com/dl/ - server: armbian.tnahosting.net/apt/
- https://xogium.performanceservers.nl/dl/ weight: 15
- https://github.com/armbian/mirror/releases/download/ - server: au-mirror.bret.dk/armbian/apt/
- server: es-mirror.bret.dk/armbian/apt/
- server: imola.armbian.com/apt/
- server: mirror.iscas.ac.cn/armbian/
- server: mirror.sjtu.edu.cn/armbian/
- server: mirrors.aliyun.com/armbian/
continent: AS
- server: mirrors.bfsu.edu.cn/armbian/
- server: mirrors.dotsrc.org/armbian-apt/
weight: 15
- server: mirrors.netix.net/armbian/apt/
- server: mirrors.nju.edu.cn/armbian/
- server: mirrors.sustech.edu.cn/armbian/
- server: mirrors.tuna.tsinghua.edu.cn/armbian/
- server: mirrors.ustc.edu.cn/armbian/
- server: mirrors.xtom.de/armbian/
- server: sg-mirror.bret.dk/armbian/apt/
- server: stpete-mirror.armbian.com/apt/
- server: xogium.performanceservers.nl/apt/
- server: github.com/armbian/mirror/releases/download/
continent: GITHUB

2
go.mod
View File

@ -5,6 +5,8 @@ go 1.17
require ( require (
github.com/chi-middleware/logrus-logger v0.2.0 github.com/chi-middleware/logrus-logger v0.2.0
github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/chi/v5 v5.0.7
github.com/hashicorp/golang-lru v0.5.4
github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff
github.com/oschwald/maxminddb-golang v1.8.0 github.com/oschwald/maxminddb-golang v1.8.0
github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_golang v1.11.0
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1

3
go.sum
View File

@ -224,6 +224,7 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
@ -234,6 +235,8 @@ github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpT
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff h1:6NvhExg4omUC9NfA+l4Oq3ibNNeJUdiAF3iBVB0PlDk=
github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff/go.mod h1:ddfPX8Z28YMjiqoaJhNBzWHapTHXejnB5cDCUWDwriw=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

112
http.go
View File

@ -3,21 +3,19 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/jmcvetta/randutil"
"github.com/spf13/viper"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"strings" "strings"
) )
func statusHandler(w http.ResponseWriter, r *http.Request) { func statusHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} w.Write([]byte("OK"))
func mirrorsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(servers)
} }
func redirectHandler(w http.ResponseWriter, r *http.Request) { func redirectHandler(w http.ResponseWriter, r *http.Request) {
@ -30,16 +28,57 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
// TODO: This is temporary to allow testing on private addresses. if ip.IsLoopback() || ip.IsPrivate() {
if ip.IsPrivate() { overrideIP := os.Getenv("OVERRIDE_IP")
ip = net.ParseIP("1.1.1.1")
if overrideIP == "" {
overrideIP = "1.1.1.1"
}
ip = net.ParseIP(overrideIP)
} }
server, distance, err := servers.Closest(ip) var server *Server
var distance float64
if err != nil { if strings.HasPrefix(r.URL.Path, "/region") {
http.Error(w, err.Error(), http.StatusInternalServerError) parts := strings.Split(r.URL.Path, "/")
return
// region = parts[2]
if mirrors, ok := regionMap[parts[2]]; ok {
choices := make([]randutil.Choice, len(mirrors))
for i, item := range mirrors {
if !item.Available {
continue
}
choices[i] = randutil.Choice{
Weight: item.Weight,
Item: item,
}
}
choice, err := randutil.WeightedChoice(choices)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
server = choice.Item.(*Server)
r.URL.Path = strings.Join(parts[3:], "/")
}
}
if server == nil {
server, distance, err = servers.Closest(ip)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} }
scheme := r.URL.Scheme scheme := r.URL.Scheme
@ -57,6 +96,10 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
if strings.HasSuffix(r.URL.Path, "/") && !strings.HasSuffix(redirectPath, "/") {
redirectPath += "/"
}
u := &url.URL{ u := &url.URL{
Scheme: scheme, Scheme: scheme,
Host: server.Host, Host: server.Host,
@ -66,13 +109,30 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
server.Redirects.Inc() server.Redirects.Inc()
redirectsServed.Inc() redirectsServed.Inc()
w.Header().Set("X-Geo-Distance", fmt.Sprintf("%f", distance)) if distance > 0 {
w.Header().Set("X-Geo-Distance", fmt.Sprintf("%f", distance))
}
w.Header().Set("Location", u.String()) w.Header().Set("Location", u.String())
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
} }
func reloadHandler(w http.ResponseWriter, r *http.Request) { func reloadHandler(w http.ResponseWriter, r *http.Request) {
reloadMap() token := r.Header.Get("Authorization")
if token == "" || !strings.HasPrefix(token, "Bearer") || !strings.Contains(token, " ") {
w.WriteHeader(http.StatusUnauthorized)
return
}
token = token[strings.Index(token, " ")+1:]
if token != viper.GetString("reloadToken") {
w.WriteHeader(http.StatusUnauthorized)
return
}
reloadConfig()
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) w.Write([]byte("OK"))
@ -88,3 +148,25 @@ func dlMapHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(dlMap) json.NewEncoder(w).Encode(dlMap)
} }
func geoIPHandler(w http.ResponseWriter, r *http.Request) {
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ip := net.ParseIP(ipStr)
var city City
err = db.Lookup(ip, &city)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(city)
}

189
main.go
View File

@ -4,27 +4,26 @@ import (
"flag" "flag"
"github.com/chi-middleware/logrus-logger" "github.com/chi-middleware/logrus-logger"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
lru "github.com/hashicorp/golang-lru"
"github.com/oschwald/maxminddb-golang" "github.com/oschwald/maxminddb-golang"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/viper" "github.com/spf13/viper"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"strings"
"sync"
"syscall" "syscall"
) )
var ( var (
db *maxminddb.Reader db *maxminddb.Reader
servers ServerList servers ServerList
regionMap map[string][]*Server
dlMap map[string]string hostMap map[string]*Server
dlMap map[string]string
topChoices int
redirectsServed = promauto.NewCounter(prometheus.CounterOpts{ redirectsServed = promauto.NewCounter(prometheus.CounterOpts{
Name: "armbian_router_redirects", Name: "armbian_router_redirects",
@ -35,24 +34,65 @@ var (
Name: "armbian_router_download_maps", Name: "armbian_router_download_maps",
Help: "The total number of mapped download paths", Help: "The total number of mapped download paths",
}) })
serverCache *lru.Cache
) )
// City represents a MaxmindDB city type LocationLookup struct {
type City struct {
Location struct { Location struct {
Latitude float64 `maxminddb:"latitude"` Latitude float64 `maxminddb:"latitude"`
Longitude float64 `maxminddb:"longitude"` Longitude float64 `maxminddb:"longitude"`
} `maxminddb:"location"` } `maxminddb:"location"`
} }
// City represents a MaxmindDB city
type City struct {
Continent struct {
Code string `maxminddb:"code" json:"code"`
GeoNameID uint `maxminddb:"geoname_id" json:"geoname_id"`
Names map[string]string `maxminddb:"names" json:"names"`
} `maxminddb:"continent" json:"continent"`
Country struct {
GeoNameID uint `maxminddb:"geoname_id" json:"geoname_id"`
IsoCode string `maxminddb:"iso_code" json:"iso_code"`
Names map[string]string `maxminddb:"names" json:"names"`
} `maxminddb:"country" json:"country"`
Location struct {
AccuracyRadius uint16 `maxminddb:"accuracy_radius" json:'accuracy_radius'`
Latitude float64 `maxminddb:"latitude" json:"latitude"`
Longitude float64 `maxminddb:"longitude" json:"longitude"`
} `maxminddb:"location"`
RegisteredCountry struct {
GeoNameID uint `maxminddb:"geoname_id" json:"geoname_id"`
IsoCode string `maxminddb:"iso_code" json:"iso_code"`
Names map[string]string `maxminddb:"names" json:"names"`
} `maxminddb:"registered_country" json:"registered_country"`
}
type ServerConfig struct {
Server string `mapstructure:"server" yaml:"server"`
Latitude float64 `mapstructure:"latitude" yaml:"latitude"`
Longitude float64 `mapstructure:"longitude" yaml:"longitude"`
Continent string `mapstructure:"continent"`
Weight int `mapstructure:"weight" yaml:"weight"`
}
var ( var (
configFlag = flag.String("config", "", "configuration file path") configFlag = flag.String("config", "", "configuration file path")
flagDebug = flag.Bool("debug", false, "Enable debug logging")
) )
func main() { func main() {
flag.Parse() flag.Parse()
if *flagDebug {
log.SetLevel(log.DebugLevel)
}
viper.SetDefault("bind", ":8080") viper.SetDefault("bind", ":8080")
viper.SetDefault("cacheSize", 1024)
viper.SetDefault("topChoices", 3)
viper.SetDefault("reloadKey", randSeq(32))
viper.SetConfigName("dlrouter") // name of config file (without extension) viper.SetConfigName("dlrouter") // name of config file (without extension)
viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
@ -64,45 +104,7 @@ func main() {
viper.SetConfigFile(*configFlag) viper.SetConfigFile(*configFlag)
} }
err := viper.ReadInConfig() // Find and read the config file reloadConfig()
if err != nil { // Handle errors reading the config file
log.WithError(err).Fatalln("Unable to load config file")
}
db, err = maxminddb.Open(viper.GetString("geodb"))
if err != nil {
log.WithError(err).Fatalln("Unable to open database")
}
if mapFile := viper.GetString("dl_map"); mapFile != "" {
log.WithField("file", mapFile).Info("Loading download map")
dlMap, err = loadMap(mapFile)
if err != nil {
log.WithError(err).Fatalln("Unable to load download map")
}
}
serverList := viper.GetStringSlice("servers")
var wg sync.WaitGroup
for _, server := range serverList {
wg.Add(1)
go func(server string) {
defer wg.Done()
addServer(server)
}(server)
}
wg.Wait()
log.Info("Servers added, checking statuses")
// Start check loop // Start check loop
go servers.checkLoop() go servers.checkLoop()
@ -114,16 +116,22 @@ func main() {
r.Use(RealIPMiddleware) r.Use(RealIPMiddleware)
r.Use(logger.Logger("router", log.StandardLogger())) r.Use(logger.Logger("router", log.StandardLogger()))
r.Head("/status", statusHandler)
r.Get("/status", statusHandler) r.Get("/status", statusHandler)
r.Get("/mirrors", mirrorsHandler) r.Get("/mirrors", legacyMirrorsHandler)
r.Get("/mirrors/{server}.svg", mirrorStatusHandler)
r.Get("/mirrors.json", mirrorsHandler)
r.Post("/reload", reloadHandler) r.Post("/reload", reloadHandler)
r.Get("/dl_map", dlMapHandler) r.Get("/dl_map", dlMapHandler)
r.Get("/geoip", geoIPHandler)
r.Get("/metrics", promhttp.Handler().ServeHTTP) r.Get("/metrics", promhttp.Handler().ServeHTTP)
r.NotFound(redirectHandler) r.NotFound(redirectHandler)
go http.ListenAndServe(viper.GetString("bind"), r) go http.ListenAndServe(viper.GetString("bind"), r)
log.Info("Ready")
c := make(chan os.Signal) c := make(chan os.Signal)
signal.Notify(c, syscall.SIGKILL, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(c, syscall.SIGKILL, syscall.SIGTERM, syscall.SIGHUP)
@ -135,85 +143,6 @@ func main() {
break break
} }
reloadMap() reloadConfig()
} }
} }
var metricReplacer = strings.NewReplacer(".", "_", "-", "_")
func addServer(server string) {
var prefix string
if !strings.HasPrefix(server, "http") {
prefix = "https://"
}
u, err := url.Parse(prefix + server)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": server,
}).Warning("Server is invalid")
return
}
ips, err := net.LookupIP(u.Host)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": server,
}).Warning("Could not resolve address")
return
}
var city City
err = db.Lookup(ips[0], &city)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"server": server,
}).Warning("Could not geolocate address")
return
}
redirects := promauto.NewCounter(prometheus.CounterOpts{
Name: "armbian_router_redirects_" + metricReplacer.Replace(u.Host),
Help: "The number of redirects for server " + u.Host,
})
servers = append(servers, &Server{
Host: u.Host,
Path: u.Path,
Latitude: city.Location.Latitude,
Longitude: city.Location.Longitude,
Redirects: redirects,
})
log.WithFields(log.Fields{
"server": u.Host,
"path": u.Path,
"latitude": city.Location.Latitude,
"longitude": city.Location.Longitude,
}).Info("Added server")
}
func reloadMap() {
mapFile := viper.GetString("dl_map")
if mapFile == "" {
return
}
log.WithField("file", mapFile).Info("Loading download map")
newMap, err := loadMap(mapFile)
if err != nil {
return
}
dlMap = newMap
}

69
mirrors.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
_ "embed"
"encoding/json"
"github.com/go-chi/chi/v5"
"net/http"
"strings"
)
func legacyMirrorsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
mirrorOutput := make(map[string][]string)
for region, mirrors := range regionMap {
list := make([]string, len(mirrors))
for i, mirror := range mirrors {
list[i] = r.URL.Scheme + "://" + mirror.Host + "/" + strings.TrimLeft(mirror.Path, "/")
}
mirrorOutput[region] = list
}
json.NewEncoder(w).Encode(mirrorOutput)
}
func mirrorsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(servers)
}
var (
//go:embed assets/status-up.svg
statusUp []byte
//go:embed assets/status-down.svg
statusDown []byte
//go:embed assets/status-unknown.svg
statusUnknown []byte
)
func mirrorStatusHandler(w http.ResponseWriter, r *http.Request) {
serverHost := chi.URLParam(r, "server")
w.Header().Set("Content-Type", "image/svg+xml;charset=utf-8")
if serverHost == "" {
w.Write(statusUnknown)
return
}
serverHost = strings.Replace(serverHost, "_", ".", -1)
server, ok := hostMap[serverHost]
if !ok {
w.Write(statusUnknown)
return
}
if server.Available {
w.Write(statusUp)
} else {
w.Write(statusDown)
}
}

View File

@ -1,12 +1,15 @@
package main package main
import ( import (
"github.com/jmcvetta/randutil"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"math" "math"
"net" "net"
"net/http" "net/http"
"net/url"
"runtime" "runtime"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -14,21 +17,24 @@ import (
var ( var (
checkClient = &http.Client{ checkClient = &http.Client{
Timeout: 10 * time.Second, Timeout: 20 * time.Second,
} }
) )
type Server struct { type Server struct {
Available bool Available bool `json:"available"`
Host string Host string `json:"host"`
Path string Path string `json:"path"`
Latitude float64 Latitude float64 `json:"latitude"`
Longitude float64 Longitude float64 `json:"longitude"`
Redirects prometheus.Counter Weight int `json:"weight"`
Continent string `json:"continent"`
Redirects prometheus.Counter `json:"-"`
LastChange time.Time `json:"lastChange"`
} }
func (server *Server) checkStatus() { func (server *Server) checkStatus() {
req, err := http.NewRequest(http.MethodGet, "https://"+server.Host+"/"+strings.TrimLeft(server.Path, "/"), nil) req, err := http.NewRequest(http.MethodGet, "http://"+server.Host+"/"+strings.TrimLeft(server.Path, "/"), nil)
req.Header.Set("User-Agent", "ArmbianRouter/1.0 (Go "+runtime.Version()+")") req.Header.Set("User-Agent", "ArmbianRouter/1.0 (Go "+runtime.Version()+")")
@ -51,6 +57,7 @@ func (server *Server) checkStatus() {
}).Info("Server went offline") }).Info("Server went offline")
server.Available = false server.Available = false
server.LastChange = time.Now()
} else { } else {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"server": server.Host, "server": server.Host,
@ -66,8 +73,38 @@ func (server *Server) checkStatus() {
} }
if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusMovedPermanently || res.StatusCode == http.StatusFound || res.StatusCode == http.StatusNotFound { if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusMovedPermanently || res.StatusCode == http.StatusFound || res.StatusCode == http.StatusNotFound {
if res.StatusCode == http.StatusMovedPermanently || res.StatusCode == http.StatusFound {
location := res.Header.Get("Location")
responseFields["url"] = location
log.WithFields(responseFields).Debug("Server responded with redirect")
newUrl, err := url.Parse(location)
if err != nil {
if server.Available {
log.WithFields(responseFields).Warning("Server returned invalid url")
server.Available = false
server.LastChange = time.Now()
}
return
}
if newUrl.Scheme == "https" {
if server.Available {
responseFields["url"] = location
log.WithFields(responseFields).Warning("Server returned https url for http request")
server.Available = false
server.LastChange = time.Now()
}
return
}
}
if !server.Available { if !server.Available {
server.Available = true server.Available = true
server.LastChange = time.Now()
log.WithFields(responseFields).Info("Server is online") log.WithFields(responseFields).Info("Server is online")
} }
} else { } else {
@ -76,6 +113,7 @@ func (server *Server) checkStatus() {
if server.Available { if server.Available {
log.WithFields(responseFields).Info("Server went offline") log.WithFields(responseFields).Info("Server went offline")
server.Available = false server.Available = false
server.LastChange = time.Now()
} }
} }
} }
@ -86,8 +124,8 @@ func (s ServerList) checkLoop() {
t := time.NewTicker(60 * time.Second) t := time.NewTicker(60 * time.Second)
for { for {
s.Check()
<-t.C <-t.C
s.Check()
} }
} }
@ -110,33 +148,82 @@ func (s ServerList) Check() {
wg.Wait() wg.Wait()
} }
// Closest will use GeoIP on the IP provided and find the closest server. // ComputedDistance is a wrapper that contains a Server and Distance.
type ComputedDistance struct {
Server *Server
Distance float64
}
// DistanceList is a list of Computed Distances with an easy "Choices" func
type DistanceList []ComputedDistance
// Closest will use GeoIP on the IP provided and find the closest servers.
// When we have a list of x servers closest, we can choose a random or weighted one.
// Return values are the closest server, the distance, and if an error occurred. // Return values are the closest server, the distance, and if an error occurred.
func (s ServerList) Closest(ip net.IP) (*Server, float64, error) { func (s ServerList) Closest(ip net.IP) (*Server, float64, error) {
var city City choiceInterface, exists := serverCache.Get(ip.String())
err := db.Lookup(ip, &city)
if !exists {
var city LocationLookup
err := db.Lookup(ip, &city)
if err != nil {
return nil, -1, err
}
c := make(DistanceList, len(s))
for i, server := range s {
if !server.Available {
continue
}
distance := Distance(city.Location.Latitude, city.Location.Longitude, server.Latitude, server.Longitude)
c[i] = ComputedDistance{
Server: server,
Distance: distance,
}
}
// Sort by distance
sort.Slice(c, func(i int, j int) bool {
return c[i].Distance < c[j].Distance
})
choiceCount := topChoices
if len(c) < topChoices {
choiceCount = len(c)
}
choices := make([]randutil.Choice, choiceCount)
for i, item := range c[0:choiceCount] {
if item.Server == nil {
continue
}
choices[i] = randutil.Choice{
Weight: item.Server.Weight,
Item: item,
}
}
choiceInterface = choices
serverCache.Add(ip.String(), choiceInterface)
}
choice, err := randutil.WeightedChoice(choiceInterface.([]randutil.Choice))
if err != nil { if err != nil {
return nil, -1, err return nil, -1, err
} }
var closest *Server dist := choice.Item.(ComputedDistance)
var closestDistance float64 = -1
for _, server := range s { return dist.Server, dist.Distance, nil
if !server.Available {
continue
}
distance := Distance(city.Location.Latitude, city.Location.Longitude, server.Latitude, server.Longitude)
if closestDistance == -1 || distance < closestDistance {
closestDistance = distance
closest = server
}
}
return closest, closestDistance, nil
} }
// haversin(θ) function // haversin(θ) function

13
util.go Normal file
View File

@ -0,0 +1,13 @@
package main
import "math/rand"
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}