116 lines
3.0 KiB
Go
116 lines
3.0 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// RouteCheck is the result of checking one route's local target and its
|
|
// public TLS path.
|
|
type RouteCheck struct {
|
|
Route string
|
|
LocalUp bool
|
|
PublicUp bool
|
|
LocalError string
|
|
PublicError string
|
|
Checked time.Time
|
|
}
|
|
|
|
// CheckLocal dials the route target's TCP port.
|
|
func CheckLocal(ctx context.Context, target string) (bool, string) {
|
|
dialer := net.Dialer{Timeout: 3 * time.Second}
|
|
conn, err := dialer.DialContext(ctx, "tcp", target)
|
|
if err != nil {
|
|
return false, err.Error()
|
|
}
|
|
conn.Close()
|
|
return true, ""
|
|
}
|
|
|
|
// CheckPublic performs a TLS handshake against the public route hostname. It
|
|
// goes through DNS, the public proxy/relay, the bridge and the client's TLS
|
|
// certificate, so success means the external path is up. It intentionally
|
|
// does not send application data, so it works for non-HTTP routes too.
|
|
func CheckPublic(ctx context.Context, publicURL string) (bool, string) {
|
|
u, err := url.Parse(publicURL)
|
|
if err != nil || u.Hostname() == "" {
|
|
return false, "no public hostname"
|
|
}
|
|
host := u.Hostname()
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
raw, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
|
|
if err != nil {
|
|
return false, err.Error()
|
|
}
|
|
defer raw.Close()
|
|
// A TCP connect can succeed while the TLS handshake stalls (for example
|
|
// when the tunnel has no bridge); bound it independently of the dialer.
|
|
_ = raw.SetDeadline(time.Now().Add(5 * time.Second))
|
|
tlsConn := tls.Client(raw, &tls.Config{
|
|
ServerName: host,
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
return false, err.Error()
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// CheckRoute checks one route.
|
|
func CheckRoute(ctx context.Context, cfg *Config, name string) RouteCheck {
|
|
result := RouteCheck{Route: name, Checked: time.Now()}
|
|
route, ok := cfg.Routes[name]
|
|
if !ok {
|
|
result.LocalError = "route not found"
|
|
result.PublicError = "route not found"
|
|
return result
|
|
}
|
|
result.LocalUp, result.LocalError = CheckLocal(ctx, route.Target)
|
|
result.PublicUp, result.PublicError = CheckPublic(ctx, cfg.RouteURL(name))
|
|
return result
|
|
}
|
|
|
|
// CheckAll checks every route concurrently.
|
|
func CheckAll(ctx context.Context, cfg *Config) []RouteCheck {
|
|
names := make([]string, 0, len(cfg.Routes))
|
|
for name := range cfg.Routes {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
results := make([]RouteCheck, len(names))
|
|
var wg sync.WaitGroup
|
|
for i, name := range names {
|
|
wg.Add(1)
|
|
go func(i int, name string) {
|
|
defer wg.Done()
|
|
results[i] = CheckRoute(ctx, cfg, name)
|
|
}(i, name)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
// CheckSummary formats the counts for a UI.
|
|
func CheckSummary(results []RouteCheck) string {
|
|
local, public := 0, 0
|
|
for _, result := range results {
|
|
if result.LocalUp {
|
|
local++
|
|
}
|
|
if result.PublicUp {
|
|
public++
|
|
}
|
|
}
|
|
return fmt.Sprintf("local %d/%d up · public %d/%d up", local, len(results), public, len(results))
|
|
}
|