5 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
10 changed files with 260 additions and 73 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

View File

@ -14,6 +14,8 @@ import (
)
func reloadConfig() {
log.Info("Loading configuration...")
err := viper.ReadInConfig() // Find and read the config file
if err != nil { // Handle errors reading the config file
@ -46,6 +48,25 @@ func reloadConfig() {
// 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)

102
http.go
View File

@ -3,9 +3,12 @@ package main
import (
"encoding/json"
"fmt"
"github.com/jmcvetta/randutil"
"github.com/spf13/viper"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
)
@ -15,31 +18,6 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}
func legacyMirrorsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
mirrors := make(map[string][]string)
for _, server := range servers {
u := &url.URL{
Scheme: r.URL.Scheme,
Host: server.Host,
Path: server.Path,
}
mirrors[server.Continent] = append(mirrors[server.Continent], u.String())
}
mirrors["default"] = append(mirrors["NA"], mirrors["EU"]...)
json.NewEncoder(w).Encode(mirrors)
}
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) {
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
@ -50,18 +28,59 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
ip := net.ParseIP(ipStr)
// TODO: This is temporary to allow testing on private addresses.
if ip.IsPrivate() {
ip = net.ParseIP("1.1.1.1")
if ip.IsLoopback() || ip.IsPrivate() {
overrideIP := os.Getenv("OVERRIDE_IP")
if overrideIP == "" {
overrideIP = "1.1.1.1"
}
server, distance, err := servers.Closest(ip)
ip = net.ParseIP(overrideIP)
}
var server *Server
var distance float64
if strings.HasPrefix(r.URL.Path, "/region") {
parts := strings.Split(r.URL.Path, "/")
// 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
if scheme == "" {
@ -71,13 +90,7 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
redirectPath := path.Join(server.Path, r.URL.Path)
if dlMap != nil {
p := r.URL.Path
if p[0] != '/' {
p = "/" + p
}
if newPath, exists := dlMap[p]; exists {
if newPath, exists := dlMap[strings.TrimLeft(r.URL.Path, "/")]; exists {
downloadsMapped.Inc()
redirectPath = path.Join(server.Path, newPath)
}
@ -96,12 +109,29 @@ func redirectHandler(w http.ResponseWriter, r *http.Request) {
server.Redirects.Inc()
redirectsServed.Inc()
if distance > 0 {
w.Header().Set("X-Geo-Distance", fmt.Sprintf("%f", distance))
}
w.Header().Set("Location", u.String())
w.WriteHeader(http.StatusFound)
}
func reloadHandler(w http.ResponseWriter, r *http.Request) {
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)

14
main.go
View File

@ -20,8 +20,10 @@ import (
var (
db *maxminddb.Reader
servers ServerList
regionMap map[string][]*Server
hostMap map[string]*Server
dlMap map[string]string
topChoices int
redirectsServed = promauto.NewCounter(prometheus.CounterOpts{
Name: "armbian_router_redirects",
@ -34,8 +36,6 @@ var (
})
serverCache *lru.Cache
topChoices int
)
type LocationLookup struct {
@ -79,14 +79,20 @@ type ServerConfig struct {
var (
configFlag = flag.String("config", "", "configuration file path")
flagDebug = flag.Bool("debug", false, "Enable debug logging")
)
func main() {
flag.Parse()
if *flagDebug {
log.SetLevel(log.DebugLevel)
}
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.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
@ -110,8 +116,10 @@ func main() {
r.Use(RealIPMiddleware)
r.Use(logger.Logger("router", log.StandardLogger()))
r.Head("/status", statusHandler)
r.Get("/status", statusHandler)
r.Get("/mirrors", legacyMirrorsHandler)
r.Get("/mirrors/{server}.svg", mirrorStatusHandler)
r.Get("/mirrors.json", mirrorsHandler)
r.Post("/reload", reloadHandler)
r.Get("/dl_map", dlMapHandler)

3
map.go
View File

@ -4,6 +4,7 @@ import (
"encoding/csv"
"io"
"os"
"strings"
)
func loadMap(file string) (map[string]string, error) {
@ -32,7 +33,7 @@ func loadMap(file string) (map[string]string, error) {
return nil, err
}
m[row[0]] = row[1]
m[strings.TrimLeft(row[0], "/")] = strings.TrimLeft(row[1], "/")
}
return m, nil

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

@ -7,6 +7,7 @@ import (
"math"
"net"
"net/http"
"net/url"
"runtime"
"sort"
"strings"
@ -16,7 +17,7 @@ import (
var (
checkClient = &http.Client{
Timeout: 10 * time.Second,
Timeout: 20 * time.Second,
}
)
@ -29,10 +30,11 @@ type Server struct {
Weight int `json:"weight"`
Continent string `json:"continent"`
Redirects prometheus.Counter `json:"-"`
LastChange time.Time `json:"lastChange"`
}
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()+")")
@ -55,6 +57,7 @@ func (server *Server) checkStatus() {
}).Info("Server went offline")
server.Available = false
server.LastChange = time.Now()
} else {
log.WithFields(log.Fields{
"server": server.Host,
@ -70,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.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 {
server.Available = true
server.LastChange = time.Now()
log.WithFields(responseFields).Info("Server is online")
}
} else {
@ -80,6 +113,7 @@ func (server *Server) checkStatus() {
if server.Available {
log.WithFields(responseFields).Info("Server went offline")
server.Available = false
server.LastChange = time.Now()
}
}
}
@ -123,19 +157,6 @@ type ComputedDistance struct {
// DistanceList is a list of Computed Distances with an easy "Choices" func
type DistanceList []ComputedDistance
func (d DistanceList) Choices() []randutil.Choice {
c := make([]randutil.Choice, len(d))
for i, item := range d {
c[i] = randutil.Choice{
Weight: item.Server.Weight,
Item: item,
}
}
return c
}
// 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.
@ -157,18 +178,39 @@ func (s ServerList) Closest(ip net.IP) (*Server, float64, error) {
continue
}
distance := Distance(city.Location.Latitude, city.Location.Longitude, server.Latitude, server.Longitude)
c[i] = ComputedDistance{
Server: server,
Distance: Distance(city.Location.Latitude, city.Location.Longitude, server.Latitude, server.Longitude),
Distance: distance,
}
}
// Sort by distance
sort.Slice(s, func(i int, j int) bool {
sort.Slice(c, func(i int, j int) bool {
return c[i].Distance < c[j].Distance
})
choiceInterface = c[0:topChoices].Choices()
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)
}

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)
}