Files
tunnel/internal/client/check.go
T

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