Commit b4c78ae9 authored by hujiebin's avatar hujiebin

feat

parents
Pipeline #1749 canceled with stages
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/hilo-speedtest.iml" filepath="$PROJECT_DIR$/.idea/hilo-speedtest.iml" />
</modules>
</component>
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
\ No newline at end of file
package config
import (
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
type Config struct {
BindAddress string `mapstructure:"bind_address"`
Port string `mapstructure:"listen_port"`
BaseURL string `mapstructure:"url_base"`
ProxyProtocolPort string `mapstructure:"proxyprotocol_port"`
ServerLat float64 `mapstructure:"server_lat"`
ServerLng float64 `mapstructure:"server_lng"`
IPInfoAPIKey string `mapstructure:"ipinfo_api_key"`
StatsPassword string `mapstructure:"statistics_password"`
RedactIP bool `mapstructure:"redact_ip_addresses"`
AssetsPath string `mapstructure:"assets_path"`
DatabaseType string `mapstructure:"database_type"`
DatabaseHostname string `mapstructure:"database_hostname"`
DatabaseName string `mapstructure:"database_name"`
DatabaseUsername string `mapstructure:"database_username"`
DatabasePassword string `mapstructure:"database_password"`
DatabaseFile string `mapstructure:"database_file"`
EnableHTTP2 bool `mapstructure:"enable_http2"`
EnableTLS bool `mapstructure:"enable_tls"`
TLSCertFile string `mapstructure:"tls_cert_file"`
TLSKeyFile string `mapstructure:"tls_key_file"`
}
var (
configFile string
loadedConfig *Config = nil
)
func init() {
viper.SetDefault("listen_port", "8989")
viper.SetDefault("url_base", "")
viper.SetDefault("proxyprotocol_port", "0")
viper.SetDefault("download_chunks", 4)
viper.SetDefault("distance_unit", "K")
viper.SetDefault("enable_cors", false)
viper.SetDefault("statistics_password", "PASSWORD")
viper.SetDefault("redact_ip_addresses", false)
viper.SetDefault("database_type", "postgresql")
viper.SetDefault("database_hostname", "localhost")
viper.SetDefault("database_name", "speedtest")
viper.SetDefault("database_username", "postgres")
viper.SetDefault("enable_tls", false)
viper.SetDefault("enable_http2", false)
viper.SetConfigName("settings")
viper.AddConfigPath(".")
}
func Load(configPath string) Config {
var conf Config
configFile = configPath
viper.SetConfigFile(configPath)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Warnf("No config file found in search paths, using default values")
} else {
log.Fatalf("Error reading config: %s", err)
}
}
if err := viper.Unmarshal(&conf); err != nil {
log.Fatalf("Error parsing config: %s", err)
}
loadedConfig = &conf
return conf
}
func LoadedConfig() *Config {
if loadedConfig == nil {
Load(configFile)
}
return loadedConfig
}
package bolt
import (
"encoding/json"
"errors"
"time"
"github.com/librespeed/speedtest/database/schema"
log "github.com/sirupsen/logrus"
"go.etcd.io/bbolt"
)
const (
bucketName = `speedtest`
)
type Bolt struct {
db *bbolt.DB
}
func Open(databaseFile string) *Bolt {
db, err := bbolt.Open(databaseFile, 0666, nil)
if err != nil {
log.Fatalf("Cannot open BoltDB database file: %s", err)
}
return &Bolt{db: db}
}
func (p *Bolt) Insert(data *schema.TelemetryData) error {
return p.db.Update(func(tx *bbolt.Tx) error {
data.Timestamp = time.Now()
b, _ := json.Marshal(data)
bucket, err := tx.CreateBucketIfNotExists([]byte(bucketName))
if err != nil {
return err
}
return bucket.Put([]byte(data.UUID), b)
})
}
func (p *Bolt) FetchByUUID(uuid string) (*schema.TelemetryData, error) {
var record schema.TelemetryData
err := p.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(bucketName))
if bucket == nil {
return errors.New("data bucket doesn't exist yet")
}
b := bucket.Get([]byte(uuid))
return json.Unmarshal(b, &record)
})
return &record, err
}
func (p *Bolt) FetchLast100() ([]schema.TelemetryData, error) {
var records []schema.TelemetryData
err := p.db.View(func(tx *bbolt.Tx) error {
var record schema.TelemetryData
bucket := tx.Bucket([]byte(bucketName))
if bucket == nil {
return errors.New("data bucket doesn't exist yet")
}
cursor := bucket.Cursor()
_, b := cursor.Last()
for len(records) < 100 {
if err := json.Unmarshal(b, &record); err != nil {
return err
}
records = append(records, record)
_, b = cursor.Prev()
if b == nil {
break
}
}
return nil
})
return records, err
}
package database
import (
"github.com/librespeed/speedtest/config"
"github.com/librespeed/speedtest/database/bolt"
"github.com/librespeed/speedtest/database/memory"
"github.com/librespeed/speedtest/database/mysql"
"github.com/librespeed/speedtest/database/none"
"github.com/librespeed/speedtest/database/postgresql"
"github.com/librespeed/speedtest/database/schema"
log "github.com/sirupsen/logrus"
)
var (
DB DataAccess
)
type DataAccess interface {
Insert(*schema.TelemetryData) error
FetchByUUID(string) (*schema.TelemetryData, error)
FetchLast100() ([]schema.TelemetryData, error)
}
func SetDBInfo(conf *config.Config) {
switch conf.DatabaseType {
case "postgresql":
DB = postgresql.Open(conf.DatabaseHostname, conf.DatabaseUsername, conf.DatabasePassword, conf.DatabaseName)
case "mysql":
DB = mysql.Open(conf.DatabaseHostname, conf.DatabaseUsername, conf.DatabasePassword, conf.DatabaseName)
case "bolt":
DB = bolt.Open(conf.DatabaseFile)
case "memory":
DB = memory.Open("")
case "none":
DB = none.Open("")
default:
log.Fatalf("Unsupported database type: %s", conf.DatabaseType)
}
}
package memory
import (
"errors"
"sync"
"time"
"github.com/librespeed/speedtest/database/schema"
)
const (
// just enough records to return for FetchLast100
maxRecords = 100
)
type Memory struct {
lock sync.RWMutex
records []schema.TelemetryData
}
func Open(_ string) *Memory {
return &Memory{}
}
func (mem *Memory) Insert(data *schema.TelemetryData) error {
mem.lock.Lock()
defer mem.lock.Unlock()
data.Timestamp = time.Now()
mem.records = append(mem.records, *data)
if len(mem.records) > maxRecords {
mem.records = mem.records[len(mem.records)-maxRecords:]
}
return nil
}
func (mem *Memory) FetchByUUID(uuid string) (*schema.TelemetryData, error) {
mem.lock.RLock()
defer mem.lock.RUnlock()
for _, record := range mem.records {
if record.UUID == uuid {
return &record, nil
}
}
return nil, errors.New("record not found")
}
func (mem *Memory) FetchLast100() ([]schema.TelemetryData, error) {
mem.lock.RLock()
defer mem.lock.RUnlock()
return mem.records, nil
}
package mysql
import (
"database/sql"
"fmt"
"github.com/librespeed/speedtest/database/schema"
_ "github.com/go-sql-driver/mysql"
log "github.com/sirupsen/logrus"
)
const (
connectionStringTemplate = `%s:%s@%s/%s?parseTime=true`
)
type MySQL struct {
db *sql.DB
}
func Open(hostname, username, password, database string) *MySQL {
connStr := fmt.Sprintf(connectionStringTemplate, username, password, hostname, database)
conn, err := sql.Open("mysql", connStr)
if err != nil {
log.Fatalf("Cannot open MySQL database: %s", err)
}
return &MySQL{db: conn}
}
func (p *MySQL) Insert(data *schema.TelemetryData) error {
stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
_, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID)
return err
}
func (p *MySQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) {
var record schema.TelemetryData
row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = ?`, uuid)
if row != nil {
var id string
if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil {
return nil, err
}
}
return &record, nil
}
func (p *MySQL) FetchLast100() ([]schema.TelemetryData, error) {
var records []schema.TelemetryData
rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`)
if err != nil {
return nil, err
}
if rows != nil {
var id string
for rows.Next() {
var record schema.TelemetryData
if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil {
return nil, err
}
records = append(records, record)
}
}
return records, nil
}
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET AUTOCOMMIT = 0;
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `speedtest_telemetry`
--
-- --------------------------------------------------------
--
-- Table structure for table `speedtest_users`
--
CREATE TABLE `speedtest_users` (
`id` int(11) NOT NULL,
`timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ip` text NOT NULL,
`ispinfo` text,
`extra` text,
`ua` text NOT NULL,
`lang` text NOT NULL,
`dl` text,
`ul` text,
`ping` text,
`jitter` text,
`log` longtext,
`uuid` text
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
--
-- Indexes for dumped tables
--
--
-- Indexes for table `speedtest_users`
--
ALTER TABLE `speedtest_users`
ADD PRIMARY KEY (`id`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `speedtest_users`
--
ALTER TABLE `speedtest_users`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
package none
import (
"github.com/librespeed/speedtest/database/schema"
)
type None struct{}
func Open(_ string) *None {
return &None{}
}
func (n *None) Insert(_ *schema.TelemetryData) error {
return nil
}
func (n *None) FetchByUUID(_ string) (*schema.TelemetryData, error) {
return &schema.TelemetryData{}, nil
}
func (n *None) FetchLast100() ([]schema.TelemetryData, error) {
return []schema.TelemetryData{}, nil
}
package postgresql
import (
"database/sql"
"fmt"
"github.com/librespeed/speedtest/database/schema"
_ "github.com/lib/pq"
log "github.com/sirupsen/logrus"
)
const (
connectionStringTemplate = `postgres://%s:%s@%s/%s?sslmode=disable`
)
type PostgreSQL struct {
db *sql.DB
}
func Open(hostname, username, password, database string) *PostgreSQL {
connStr := fmt.Sprintf(connectionStringTemplate, username, password, hostname, database)
conn, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("Cannot open PostgreSQL database: %s", err)
}
return &PostgreSQL{db: conn}
}
func (p *PostgreSQL) Insert(data *schema.TelemetryData) error {
stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id;`
_, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID)
return err
}
func (p *PostgreSQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) {
var record schema.TelemetryData
row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = $1`, uuid)
if row != nil {
var id string
if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil {
return nil, err
}
}
return &record, nil
}
func (p *PostgreSQL) FetchLast100() ([]schema.TelemetryData, error) {
var records []schema.TelemetryData
rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`)
if err != nil {
return nil, err
}
if rows != nil {
var id string
for rows.Next() {
var record schema.TelemetryData
if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil {
return nil, err
}
records = append(records, record)
}
}
return records, nil
}
--
-- PostgreSQL database dump
--
-- Dumped from database version 9.6.3
-- Dumped by pg_dump version 9.6.5
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SET check_function_bodies = false;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner:
--
CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog;
--
-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language';
SET search_path = public, pg_catalog;
SET default_tablespace = '';
SET default_with_oids = false;
--
-- Name: speedtest_users; Type: TABLE; Schema: public; Owner: speedtest
--
CREATE TABLE speedtest_users (
id integer NOT NULL,
"timestamp" timestamp without time zone DEFAULT now() NOT NULL,
ip text NOT NULL,
ispinfo text,
extra text,
ua text NOT NULL,
lang text NOT NULL,
dl text,
ul text,
ping text,
jitter text,
log text,
uuid text
);
-- Commented out the following line because it assumes the user of the speedtest server, @bplower
-- ALTER TABLE speedtest_users OWNER TO speedtest;
--
-- Name: speedtest_users_id_seq; Type: SEQUENCE; Schema: public; Owner: speedtest
--
CREATE SEQUENCE speedtest_users_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
-- Commented out the following line because it assumes the user of the speedtest server, @bplower
-- ALTER TABLE speedtest_users_id_seq OWNER TO speedtest;
--
-- Name: speedtest_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: speedtest
--
ALTER SEQUENCE speedtest_users_id_seq OWNED BY speedtest_users.id;
--
-- Name: speedtest_users id; Type: DEFAULT; Schema: public; Owner: speedtest
--
ALTER TABLE ONLY speedtest_users ALTER COLUMN id SET DEFAULT nextval('speedtest_users_id_seq'::regclass);
--
-- Data for Name: speedtest_users; Type: TABLE DATA; Schema: public; Owner: speedtest
--
COPY speedtest_users (id, "timestamp", ip, ua, lang, dl, ul, ping, jitter, log, uuid) FROM stdin;
\.
--
-- Name: speedtest_users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: speedtest
--
SELECT pg_catalog.setval('speedtest_users_id_seq', 1, true);
--
-- Name: speedtest_users speedtest_users_pkey; Type: CONSTRAINT; Schema: public; Owner: speedtest
--
ALTER TABLE ONLY speedtest_users
ADD CONSTRAINT speedtest_users_pkey PRIMARY KEY (id);
--
-- PostgreSQL database dump complete
--
package schema
import (
"time"
)
type TelemetryData struct {
Timestamp time.Time
IPAddress string
ISPInfo string
Extra string
UserAgent string
Language string
Download string
Upload string
Ping string
Jitter string
Log string
UUID string
}
module github.com/librespeed/speedtest
go 1.16
require (
github.com/breml/rootcerts v0.2.1
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.0
github.com/go-chi/render v1.0.1
github.com/go-sql-driver/mysql v1.6.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/lib/pq v1.10.4
github.com/oklog/ulid/v2 v2.0.2
github.com/pires/go-proxyproto v0.6.1
github.com/sirupsen/logrus v1.8.1
github.com/spf13/afero v1.8.0 // indirect
github.com/spf13/viper v1.10.1
github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26
go.etcd.io/bbolt v1.3.6
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
)
This diff is collapsed.
package main
import (
"flag"
_ "time/tzdata"
"github.com/librespeed/speedtest/config"
"github.com/librespeed/speedtest/database"
"github.com/librespeed/speedtest/results"
"github.com/librespeed/speedtest/web"
_ "github.com/breml/rootcerts"
log "github.com/sirupsen/logrus"
)
var (
optConfig = flag.String("c", "", "config file to be used, defaults to settings.toml in the same directory")
)
func main() {
flag.Parse()
conf := config.Load(*optConfig)
web.SetServerLocation(&conf)
results.Initialize(&conf)
database.SetDBInfo(&conf)
log.Fatal(web.ListenAndServe(&conf))
}
package results
import (
"html/template"
"net/http"
"github.com/go-chi/render"
log "github.com/sirupsen/logrus"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/librespeed/speedtest/config"
"github.com/librespeed/speedtest/database"
"github.com/librespeed/speedtest/database/schema"
)
type StatsData struct {
NoPassword bool
LoggedIn bool
Data []schema.TelemetryData
}
var (
key = []byte(securecookie.GenerateRandomKey(32))
store = sessions.NewCookieStore(key)
)
func init() {
store.Options = &sessions.Options{
Path: "/stats",
MaxAge: 3600 * 1, // 1 hour
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func Stats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t, err := template.New("template").Parse(htmlTemplate)
if err != nil {
log.Errorf("Failed to parse template: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
conf := config.LoadedConfig()
if conf.DatabaseType == "none" {
render.PlainText(w, r, "Statistics are disabled")
return
}
var data StatsData
if conf.StatsPassword == "PASSWORD" {
data.NoPassword = true
}
if !data.NoPassword {
op := r.FormValue("op")
session, _ := store.Get(r, "logged")
auth, ok := session.Values["authenticated"].(bool)
if auth && ok {
if op == "logout" {
session.Values["authenticated"] = false
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, conf.BaseURL+"/stats", http.StatusTemporaryRedirect)
} else {
data.LoggedIn = true
id := r.FormValue("id")
switch id {
case "L100":
stats, err := database.DB.FetchLast100()
if err != nil {
log.Errorf("Error fetching data from database: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
data.Data = stats
case "":
default:
stat, err := database.DB.FetchByUUID(id)
if err != nil {
log.Errorf("Error fetching data from database: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
data.Data = append(data.Data, *stat)
}
}
} else {
if op == "login" {
session, _ := store.Get(r, "logged")
password := r.FormValue("password")
if password == conf.StatsPassword {
session.Values["authenticated"] = true
session.Save(r, w)
http.Redirect(w, r, conf.BaseURL+"/stats", http.StatusTemporaryRedirect)
} else {
w.WriteHeader(http.StatusForbidden)
}
}
}
}
if err := t.Execute(w, data); err != nil {
log.Errorf("Error executing template: %s", err)
w.WriteHeader(http.StatusInternalServerError)
}
}
const htmlTemplate = `<!DOCTYPE html>
<html>
<head>
<title>LibreSpeed - Stats</title>
<style type="text/css">
html,body{
margin:0;
padding:0;
border:none;
width:100%; min-height:100%;
}
html{
background-color: hsl(198,72%,35%);
font-family: "Segoe UI","Roboto",sans-serif;
}
body{
background-color:#FFFFFF;
box-sizing:border-box;
width:100%;
max-width:70em;
margin:4em auto;
box-shadow:0 1em 6em #00000080;
padding:1em 1em 4em 1em;
border-radius:0.4em;
}
h1,h2,h3,h4,h5,h6{
font-weight:300;
margin-bottom: 0.1em;
}
h1{
text-align:center;
}
table{
margin:2em 0;
width:100%;
}
table, tr, th, td {
border: 1px solid #AAAAAA;
}
th {
width: 6em;
}
td {
word-break: break-all;
}
</style>
</head>
<body>
<h1>LibreSpeed - Stats</h1>
{{ if .NoPassword }}
Please set statistics_password in settings.toml to enable access.
{{ else if .LoggedIn }}
<form action="stats" method="GET"><input type="hidden" name="op" value="logout" /><input type="submit" value="Logout" /></form>
<form action="stats" method="GET">
<h3>Search test results</h6>
<input type="hidden" name="op" value="id" />
<input type="text" name="id" id="id" placeholder="Test ID" value=""/>
<input type="submit" value="Find" />
<input type="submit" onclick="document.getElementById('id').value='L100'" value="Show last 100 tests" />
</form>
{{ range $i, $v := .Data }}
<table>
<tr><th>Test ID</th><td>{{ $v.UUID }}</td></tr>
<tr><th>Date and time</th><td>{{ $v.Timestamp }}</td></tr>
<tr><th>IP and ISP Info</th><td>{{ $v.IPAddress }}<br/>{{ $v.ISPInfo }}</td></tr>
<tr><th>User agent and locale</th><td>{{ $v.UserAgent }}<br/>{{ $v.Language }}</td></tr>
<tr><th>Download speed</th><td>{{ $v.Download }}</td></tr>
<tr><th>Upload speed</th><td>{{ $v.Upload }}</td></tr>
<tr><th>Ping</th><td>{{ $v.Ping }}</td></tr>
<tr><th>Jitter</th><td>{{ $v.Jitter }}</td></tr>
<tr><th>Log</th><td>{{ $v.Log }}</td></tr>
<tr><th>Extra info</th><td>{{ $v.Extra }}</td></tr>
</table>
{{ end }}
{{ else }}
<form action="stats?op=login" method="POST">
<h3>Login</h3>
<input type="password" name="password" placeholder="Password" value=""/>
<input type="submit" value="Login" />
</form>
{{ end }}
</body>
</html>`
This diff is collapsed.
# bind address, use empty string to bind to all interfaces
bind_address=""
# backend listen port
listen_port=8989
# change the base URL
# url_base="/librespeed"
# proxy protocol port, use 0 to disable
proxyprotocol_port=0
# Server location
server_lat=1
server_lng=1
# ipinfo.io API key, if applicable
ipinfo_api_key=""
# assets directory path, defaults to `assets` in the same directory
assets_path="./web/assets"
# password for logging into statistics page
statistics_password="PASSWORD"
# redact IP addresses
redact_ip_addresses=false
# database type for statistics data, currently supports: none, memory, bolt, mysql, postgresql
# if none is specified, no telemetry/stats will be recorded, and no result PNG will be generated
database_type="memory"
database_hostname=""
database_name=""
database_username=""
database_password=""
# if you use `bolt` as database, set database_file to database file location
database_file="speedtest.db"
# TLS and HTTP/2 settings. TLS is required for HTTP/2
enable_tls=false
enable_http2=false
# if you use HTTP/2 or TLS, you need to prepare certificates and private keys
# tls_cert_file="cert.pem"
# tls_key_file="privkey.pem"
<!DOCTYPE html>
<html>
<head>
<link rel="shortcut icon" href="favicon.ico">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
<title>LibreSpeed Example</title>
<script type="text/javascript" src="speedtest.js"></script>
<script type="text/javascript">
//LIST OF TEST SERVERS. See documentation for details if needed
var SPEEDTEST_SERVERS=[
{ //this server doesn't actually exist, remove it
name:"hilo test apiv1", //user friendly name for the server
server:"//test.apiv1.faceline.live", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
dlURL:"backend/garbage.php", //path to download test on this server (garbage.php or replacement)
ulURL:"backend/empty.php", //path to upload test on this server (empty.php or replacement)
pingURL:"backend/empty.php", //path to ping/jitter test on this server (empty.php or replacement)
getIpURL:"backend/getIP.php" //path to getIP on this server (getIP.php or replacement)
},
// { //this server doesn't actually exist, remove it
// name:"Example Server 2", //user friendly name for the server
// server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
// dlURL:"garbage.php", //path to download test on this server (garbage.php or replacement)
// ulURL:"empty.php", //path to upload test on this server (empty.php or replacement)
// pingURL:"empty.php", //path to ping/jitter test on this server (empty.php or replacement)
// getIpURL:"getIP.php" //path to getIP on this server (getIP.php or replacement)
// }
//add other servers here, comma separated
];
//INITIALIZE SPEEDTEST
var s=new Speedtest(); //create speedtest object
s.onupdate=function(data){ //callback to update data in UI
I("ip").textContent=data.clientIp;
I("dlText").textContent=(data.testState==1&&data.dlStatus==0)?"...":data.dlStatus;
I("ulText").textContent=(data.testState==3&&data.ulStatus==0)?"...":data.ulStatus;
I("pingText").textContent=data.pingStatus;
I("jitText").textContent=data.jitterStatus;
}
s.onend=function(aborted){ //callback for test ended/aborted
I("startStopBtn").className=""; //show start button again
if(aborted){ //if the test was aborted, clear the UI and prepare for new test
initUI();
}
}
function selectServer(){ //called after loading server list
s.selectServer(function(server){ //run server selection. When the server has been selected, display it in the UI
console.log("jiebin",server)
if(server==null){
I("serverId").textContent="No servers available";
}else{
I("startStopBtn").style.display=""; //show start/stop button again
I("serverId").textContent=server.name; //show name of test server
}
});
}
function loadServers(){ //called when the page is fully loaded
I("startStopBtn").style.display="none"; //hide start/stop button during server selection
if(typeof SPEEDTEST_SERVERS === "string"){
//load servers from url
s.loadServerList(SPEEDTEST_SERVERS,function(servers){
//list loaded
SPEEDTEST_SERVERS=servers;
selectServer();
});
}else{
//hardcoded list of servers, already loaded
s.addTestPoints(SPEEDTEST_SERVERS);
console.log(SPEEDTEST_SERVERS)
selectServer();
}
}
function startStop(){ //start/stop button pressed
if(s.getState()==3){
//speedtest is running, abort
s.abort();
}else{
//test is not running, begin
s.start();
I("startStopBtn").className="running";
}
}
//function to (re)initialize UI
function initUI(){
I("dlText").textContent="";
I("ulText").textContent="";
I("pingText").textContent="";
I("jitText").textContent="";
I("ip").textContent="";
}
function I(id){return document.getElementById(id);}
</script>
<style type="text/css">
html,body{
border:none; padding:0; margin:0;
background:#FFFFFF;
color:#202020;
}
body{
text-align:center;
font-family:"Roboto",sans-serif;
}
h1{
color:#404040;
}
#startStopBtn{
display:inline-block;
margin:0 auto;
color:#6060AA;
background-color:rgba(0,0,0,0);
border:0.15em solid #6060FF;
border-radius:0.3em;
transition:all 0.3s;
box-sizing:border-box;
width:8em; height:3em;
line-height:2.7em;
cursor:pointer;
box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
}
#startStopBtn:hover{
box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
}
#startStopBtn.running{
background-color:#FF3030;
border-color:#FF6060;
color:#FFFFFF;
}
#startStopBtn:before{
content:"Start";
}
#startStopBtn.running:before{
content:"Abort";
}
#test{
margin-top:2em;
margin-bottom:12em;
}
div.testArea{
display:inline-block;
width:14em;
height:9em;
position:relative;
box-sizing:border-box;
}
div.testName{
position:absolute;
top:0.1em; left:0;
width:100%;
font-size:1.4em;
z-index:9;
}
div.meterText{
position:absolute;
bottom:1.5em; left:0;
width:100%;
font-size:2.5em;
z-index:9;
}
#dlText{
color:#6060AA;
}
#ulText{
color:#309030;
}
#pingText,#jitText{
color:#AA6060;
}
div.meterText:empty:before{
color:#505050 !important;
content:"0.00";
}
div.unit{
position:absolute;
bottom:2em; left:0;
width:100%;
z-index:9;
}
div.testGroup{
display:inline-block;
}
@media all and (max-width:65em){
body{
font-size:1.5vw;
}
}
@media all and (max-width:40em){
body{
font-size:0.8em;
}
div.testGroup{
display:block;
margin: 0 auto;
}
}
</style>
</head>
<body>
<h1>LibreSpeed Example</h1>
<div id="startStopBtn" onclick="startStop()"></div>
<div id="serverId">Selecting server...</div>
<div id="test">
<div class="testGroup">
<div class="testArea">
<div class="testName">Download</div>
<div id="dlText" class="meterText"></div>
<div class="unit">Mbps</div>
</div>
<div class="testArea">
<div class="testName">Upload</div>
<div id="ulText" class="meterText"></div>
<div class="unit">Mbps</div>
</div>
</div>
<div class="testGroup">
<div class="testArea">
<div class="testName">Ping</div>
<div id="pingText" class="meterText"></div>
<div class="unit">ms</div>
</div>
<div class="testArea">
<div class="testName">Jitter</div>
<div id="jitText" class="meterText"></div>
<div class="unit">ms</div>
</div>
</div>
<div id="ipArea">
IP Address: <span id="ip"></span>
</div>
</div>
<a href="https://github.com/librespeed/speedtest">Source code</a>
<script type="text/javascript">
initUI();
loadServers();
</script>
</body>
</html>
This diff is collapsed.
This diff is collapsed.
package web
import (
"io"
"net/http"
"os"
)
// Credit: https://stackoverflow.com/questions/49589685/good-way-to-disable-directory-listing-with-http-fileserver-in-go
type justFilesFilesystem struct {
fs http.FileSystem
// readDirBatchSize - configuration parameter for `Readdir` func
readDirBatchSize int
}
func (fs justFilesFilesystem) Open(name string) (http.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
return neuteredStatFile{File: f, readDirBatchSize: fs.readDirBatchSize}, nil
}
type neuteredStatFile struct {
http.File
readDirBatchSize int
}
func (e neuteredStatFile) Stat() (os.FileInfo, error) {
s, err := e.File.Stat()
if err != nil {
return nil, err
}
if s.IsDir() {
LOOP:
for {
fl, err := e.File.Readdir(e.readDirBatchSize)
switch err {
case io.EOF:
break LOOP
case nil:
for _, f := range fl {
if f.Name() == "index.html" {
return s, err
}
}
default:
return nil, err
}
}
return nil, os.ErrNotExist
}
return s, err
}
package web
import (
"crypto/rand"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/umahmood/haversine"
"github.com/librespeed/speedtest/config"
"github.com/librespeed/speedtest/results"
)
var (
serverCoord haversine.Coord
)
func getRandomData(length int) []byte {
data := make([]byte, length)
if _, err := rand.Read(data); err != nil {
log.Fatalf("Failed to generate random data: %s", err)
}
return data
}
func getIPInfoURL(address string) string {
apiKey := config.LoadedConfig().IPInfoAPIKey
ipInfoURL := `https://ipinfo.io/%s/json`
if address != "" {
ipInfoURL = fmt.Sprintf(ipInfoURL, address)
} else {
ipInfoURL = "https://ipinfo.io/json"
}
if apiKey != "" {
ipInfoURL += "?token=" + apiKey
}
return ipInfoURL
}
func getIPInfo(addr string) results.IPInfoResponse {
var ret results.IPInfoResponse
resp, err := http.DefaultClient.Get(getIPInfoURL(addr))
if err != nil {
log.Errorf("Error getting response from ipinfo.io: %s", err)
return ret
}
raw, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Errorf("Error reading response from ipinfo.io: %s", err)
return ret
}
defer resp.Body.Close()
if err := json.Unmarshal(raw, &ret); err != nil {
log.Errorf("Error parsing response from ipinfo.io: %s", err)
}
return ret
}
func SetServerLocation(conf *config.Config) {
if conf.ServerLat != 0 || conf.ServerLng != 0 {
log.Infof("Configured server coordinates: %.6f, %.6f", conf.ServerLat, conf.ServerLng)
serverCoord.Lat = conf.ServerLat
serverCoord.Lon = conf.ServerLng
return
}
var ret results.IPInfoResponse
resp, err := http.DefaultClient.Get(getIPInfoURL(""))
if err != nil {
log.Errorf("Error getting repsonse from ipinfo.io: %s", err)
return
}
raw, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Errorf("Error reading response from ipinfo.io: %s", err)
return
}
defer resp.Body.Close()
if err := json.Unmarshal(raw, &ret); err != nil {
log.Errorf("Error parsing response from ipinfo.io: %s", err)
return
}
if ret.Location != "" {
serverCoord, err = parseLocationString(ret.Location)
if err != nil {
log.Errorf("Cannot get server coordinates: %s", err)
return
}
}
log.Infof("Fetched server coordinates: %.6f, %.6f", serverCoord.Lat, serverCoord.Lon)
}
func parseLocationString(location string) (haversine.Coord, error) {
var coord haversine.Coord
parts := strings.Split(location, ",")
if len(parts) != 2 {
err := fmt.Errorf("unknown location format: %s", location)
log.Error(err)
return coord, err
}
lat, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
log.Errorf("Error parsing latitude: %s", parts[0])
return coord, err
}
lng, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
log.Errorf("Error parsing longitude: %s", parts[0])
return coord, err
}
coord.Lat = lat
coord.Lon = lng
return coord, nil
}
func calculateDistance(clientLocation string, unit string) string {
clientCoord, err := parseLocationString(clientLocation)
if err != nil {
log.Errorf("Error parsing client coordinates: %s", err)
return ""
}
dist, km := haversine.Distance(clientCoord, serverCoord)
unitString := " mi"
switch unit {
case "km":
dist = km
unitString = " km"
case "NM":
dist = km * 0.539957
unitString = " NM"
}
return fmt.Sprintf("%.2f%s", dist, unitString)
}
//go:build !linux
// +build !linux
package web
import (
"crypto/tls"
"github.com/go-chi/chi/v5"
"github.com/librespeed/speedtest/config"
log "github.com/sirupsen/logrus"
"net"
"net/http"
)
func startListener(conf *config.Config, r *chi.Mux) error {
var s error
addr := net.JoinHostPort(conf.BindAddress, conf.Port)
log.Infof("Starting backend server on %s", addr)
// TLS and HTTP/2.
if conf.EnableTLS {
log.Info("Use TLS connection.")
if !(conf.EnableHTTP2) {
srv := &http.Server{
Addr: addr,
Handler: r,
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
}
s = srv.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile)
} else {
s = http.ListenAndServeTLS(addr, conf.TLSCertFile, conf.TLSKeyFile, r)
}
} else {
if conf.EnableHTTP2 {
log.Errorf("TLS is mandatory for HTTP/2. Ignore settings that enable HTTP/2.")
}
s = http.ListenAndServe(addr, r)
}
return s
}
//go:build linux
// +build linux
package web
import (
"crypto/tls"
"github.com/coreos/go-systemd/activation"
"github.com/go-chi/chi/v5"
"github.com/librespeed/speedtest/config"
log "github.com/sirupsen/logrus"
"net"
"net/http"
)
func startListener(conf *config.Config, r *chi.Mux) error {
// See if systemd socket activation has been used when starting our process
listeners, err := activation.Listeners()
if err != nil {
log.Fatalf("Error whilst checking for systemd socket activation %s", err)
}
var s error
switch len(listeners) {
case 0:
addr := net.JoinHostPort(conf.BindAddress, conf.Port)
log.Infof("Starting backend server on %s", addr)
// TLS and HTTP/2.
if conf.EnableTLS {
log.Info("Use TLS connection.")
if !(conf.EnableHTTP2) {
srv := &http.Server{
Addr: addr,
Handler: r,
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
}
s = srv.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile)
} else {
s = http.ListenAndServeTLS(addr, conf.TLSCertFile, conf.TLSKeyFile, r)
}
} else {
if conf.EnableHTTP2 {
log.Errorf("TLS is mandatory for HTTP/2. Ignore settings that enable HTTP/2.")
}
s = http.ListenAndServe(addr, r)
}
case 1:
log.Info("Starting backend server on inherited file descriptor via systemd socket activation")
if conf.BindAddress != "" || conf.Port != "" {
log.Errorf("Both an address/port (%s:%s) has been specificed in the config AND externally configured socket activation has been detected", conf.BindAddress, conf.Port)
log.Fatal(`Please deconfigure socket activation (e.g. in systemd unit files), or set both 'bind_address' and 'listen_port' to ''`)
}
s = http.Serve(listeners[0], r)
default:
log.Fatalf("Asked to listen on %d sockets via systemd activation. Sorry we currently only support listening on 1 socket.", len(listeners))
}
return s
}
package web
import (
"embed"
"encoding/json"
"io"
"io/fs"
"io/ioutil"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/render"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus"
"github.com/librespeed/speedtest/config"
"github.com/librespeed/speedtest/results"
)
const (
// chunk size is 1 mib
chunkSize = 1048576
)
//go:embed assets
var defaultAssets embed.FS
var (
// generate random data for download test on start to minimize runtime overhead
randomData = getRandomData(chunkSize)
)
func ListenAndServe(conf *config.Config) error {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.GetHead)
cs := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS", "HEAD"},
AllowedHeaders: []string{"*"},
})
r.Use(cs.Handler)
r.Use(middleware.NoCache)
r.Use(middleware.Recoverer)
var assetFS http.FileSystem
if fi, err := os.Stat(conf.AssetsPath); os.IsNotExist(err) || !fi.IsDir() {
log.Warnf("Configured asset path %s does not exist or is not a directory, using default assets", conf.AssetsPath)
sub, err := fs.Sub(defaultAssets, "assets")
if err != nil {
log.Fatalf("Failed when processing default assets: %s", err)
}
assetFS = http.FS(sub)
} else {
assetFS = justFilesFilesystem{fs: http.Dir(conf.AssetsPath), readDirBatchSize: 2}
}
r.Get(conf.BaseURL+"/*", pages(assetFS, conf.BaseURL))
r.HandleFunc(conf.BaseURL+"/empty", empty)
r.HandleFunc(conf.BaseURL+"/backend/empty", empty)
r.Get(conf.BaseURL+"/garbage", garbage)
r.Get(conf.BaseURL+"/backend/garbage", garbage)
r.Get(conf.BaseURL+"/getIP", getIP)
r.Get(conf.BaseURL+"/backend/getIP", getIP)
r.Get(conf.BaseURL+"/results", results.DrawPNG)
r.Get(conf.BaseURL+"/results/", results.DrawPNG)
r.Get(conf.BaseURL+"/backend/results", results.DrawPNG)
r.Get(conf.BaseURL+"/backend/results/", results.DrawPNG)
r.Post(conf.BaseURL+"/results/telemetry", results.Record)
r.Post(conf.BaseURL+"/backend/results/telemetry", results.Record)
r.HandleFunc(conf.BaseURL+"/stats", results.Stats)
r.HandleFunc(conf.BaseURL+"/backend/stats", results.Stats)
// PHP frontend default values compatibility
r.HandleFunc(conf.BaseURL+"/empty.php", empty)
r.HandleFunc(conf.BaseURL+"/backend/empty.php", empty)
r.Get(conf.BaseURL+"/garbage.php", garbage)
r.Get(conf.BaseURL+"/backend/garbage.php", garbage)
r.Get(conf.BaseURL+"/getIP.php", getIP)
r.Get(conf.BaseURL+"/backend/getIP.php", getIP)
r.Post(conf.BaseURL+"/results/telemetry.php", results.Record)
r.Post(conf.BaseURL+"/backend/results/telemetry.php", results.Record)
r.HandleFunc(conf.BaseURL+"/stats.php", results.Stats)
r.HandleFunc(conf.BaseURL+"/backend/stats.php", results.Stats)
go listenProxyProtocol(conf, r)
return startListener(conf, r)
}
func listenProxyProtocol(conf *config.Config, r *chi.Mux) {
if conf.ProxyProtocolPort != "0" {
addr := net.JoinHostPort(conf.BindAddress, conf.ProxyProtocolPort)
l, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("Cannot listen on proxy protocol port %s: %s", conf.ProxyProtocolPort, err)
}
pl := &proxyproto.Listener{Listener: l}
defer pl.Close()
log.Infof("Starting proxy protocol listener on %s", addr)
log.Fatal(http.Serve(pl, r))
}
}
func pages(fs http.FileSystem, BaseURL string) http.HandlerFunc {
var removeBaseURL *regexp.Regexp
if BaseURL != "" {
removeBaseURL = regexp.MustCompile("^" + BaseURL + "/")
}
fn := func(w http.ResponseWriter, r *http.Request) {
if BaseURL != "" {
r.URL.Path = removeBaseURL.ReplaceAllString(r.URL.Path, "/")
}
if r.RequestURI == "/" {
r.RequestURI = "/index.html"
}
http.FileServer(fs).ServeHTTP(w, r)
}
return fn
}
func empty(w http.ResponseWriter, r *http.Request) {
_, err := io.Copy(ioutil.Discard, r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = r.Body.Close()
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
}
func garbage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Description", "File Transfer")
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=random.dat")
w.Header().Set("Content-Transfer-Encoding", "binary")
// chunk size set to 4 by default
chunks := 4
ckSize := r.FormValue("ckSize")
if ckSize != "" {
i, err := strconv.ParseInt(ckSize, 10, 64)
if err != nil {
log.Errorf("Invalid chunk size: %s", ckSize)
log.Warnf("Will use default value %d", chunks)
} else {
// limit max chunk size to 1024
if i > 1024 {
chunks = 1024
} else {
chunks = int(i)
}
}
}
for i := 0; i < chunks; i++ {
if _, err := w.Write(randomData); err != nil {
log.Errorf("Error writing back to client at chunk number %d: %s", i, err)
break
}
}
}
func getIP(w http.ResponseWriter, r *http.Request) {
var ret results.Result
clientIP := r.RemoteAddr
clientIP = strings.ReplaceAll(clientIP, "::ffff:", "")
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
clientIP = ip
}
isSpecialIP := true
switch {
case clientIP == "::1":
ret.ProcessedString = clientIP + " - localhost IPv6 access"
case strings.HasPrefix(clientIP, "fe80:"):
ret.ProcessedString = clientIP + " - link-local IPv6 access"
case strings.HasPrefix(clientIP, "127."):
ret.ProcessedString = clientIP + " - localhost IPv4 access"
case strings.HasPrefix(clientIP, "10."):
ret.ProcessedString = clientIP + " - private IPv4 access"
case regexp.MustCompile(`^172\.(1[6-9]|2\d|3[01])\.`).MatchString(clientIP):
ret.ProcessedString = clientIP + " - private IPv4 access"
case strings.HasPrefix(clientIP, "192.168"):
ret.ProcessedString = clientIP + " - private IPv4 access"
case strings.HasPrefix(clientIP, "169.254"):
ret.ProcessedString = clientIP + " - link-local IPv4 access"
case regexp.MustCompile(`^100\.([6-9][0-9]|1[0-2][0-7])\.`).MatchString(clientIP):
ret.ProcessedString = clientIP + " - CGNAT IPv4 access"
default:
isSpecialIP = false
}
if isSpecialIP {
b, _ := json.Marshal(&ret)
if _, err := w.Write(b); err != nil {
log.Errorf("Error writing to client: %s", err)
}
return
}
getISPInfo := r.FormValue("isp") == "true"
distanceUnit := r.FormValue("distance")
ret.ProcessedString = clientIP
if getISPInfo {
ispInfo := getIPInfo(clientIP)
ret.RawISPInfo = ispInfo
removeRegexp := regexp.MustCompile(`AS\d+\s`)
isp := removeRegexp.ReplaceAllString(ispInfo.Organization, "")
if isp == "" {
isp = "Unknown ISP"
}
if ispInfo.Country != "" {
isp += ", " + ispInfo.Country
}
if ispInfo.Location != "" {
isp += " (" + calculateDistance(ispInfo.Location, distanceUnit) + ")"
}
ret.ProcessedString += " - " + isp
}
render.JSON(w, r, ret)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment