148 lines
4.2 KiB
Go
148 lines
4.2 KiB
Go
// Command tslink-gui is the desktop front-end for tslink.
|
|
//
|
|
// It runs the same service the headless binary does — see the root main.go —
|
|
// but supervises it in-process so the window can show tailnet peer health,
|
|
// latency history, the services tslink is forwarding, and a full network
|
|
// diagnostic run, plus a searchable log view that can be shared to a paste
|
|
// service for support.
|
|
//
|
|
// The UI is Gio: no webview, no bundled browser, one native binary per
|
|
// platform.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"log"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/pprof"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"gioui.org/app"
|
|
|
|
"tslink/core"
|
|
"tslink/gui"
|
|
)
|
|
|
|
// startPprof exposes net/http/pprof for diagnosing the GUI itself — frame-rate
|
|
// regressions in a GPU-accelerated UI are very hard to reason about without a
|
|
// profile.
|
|
//
|
|
// It refuses to bind anywhere but loopback: these handlers expose goroutine
|
|
// stacks and allow anyone who can reach them to trigger expensive profiles.
|
|
func startPprof(addr string, logger *slog.Logger) {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
logger.Error("invalid -pprof address, expected host:port", "addr", addr, "err", err)
|
|
return
|
|
}
|
|
if !isLoopbackHost(host) {
|
|
logger.Error("refusing to serve pprof on a non-loopback address", "addr", addr)
|
|
return
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
go func() {
|
|
logger.Warn("pprof endpoint enabled", "addr", addr)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Error("pprof server stopped", "err", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
if host == "localhost" || strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// Version is stamped at build time:
|
|
//
|
|
// go build -ldflags "-X main.Version=v1.2.3" ./cmd/tslink-gui
|
|
var Version = "dev"
|
|
|
|
func main() {
|
|
var (
|
|
configPath = flag.String("c", "config.toml", "path to config file")
|
|
configURL = flag.String("config-url", core.DefaultConfigURL, "URL to fetch config from")
|
|
logLevel = flag.String("level", "info", "console log level (DEBUG|INFO|WARN|ERROR)")
|
|
jsonFormat = flag.Bool("json-format", false, "use json format for the console logger")
|
|
tsnetDebug = flag.Bool("diagnose", false, "show tsnet debug log on level=debug")
|
|
ipinfoToken = flag.String("ipinfo-token", os.Getenv("IPINFO_TOKEN"),
|
|
"optional ipinfo.io token, raises the geolocation rate limit")
|
|
light = flag.Bool("light", false, "start in the light theme")
|
|
pprofA = flag.String("pprof", "", "serve net/http/pprof on this address, e.g. 127.0.0.1:6060 (loopback only)")
|
|
)
|
|
flag.Parse()
|
|
|
|
// The ring buffer captures at debug level regardless of what the console
|
|
// prints, so the log view and any shared bundle have the detail even when
|
|
// the user started without -level=debug.
|
|
logs := core.NewLogBuffer(core.DefaultLogCapacity)
|
|
logger := core.NewLoggerWithBuffer(*logLevel, *jsonFormat, logs)
|
|
logger.Info("starting tslink gui",
|
|
"version", Version,
|
|
"level", *logLevel,
|
|
"config", *configPath,
|
|
)
|
|
|
|
if *pprofA != "" {
|
|
startPprof(*pprofA, logger)
|
|
}
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
sup := core.NewSupervisor(core.SupervisorOptions{
|
|
ConfigPath: *configPath,
|
|
ConfigURL: *configURL,
|
|
TsnetDebug: *tsnetDebug,
|
|
Logger: logger,
|
|
})
|
|
go sup.Run(ctx)
|
|
|
|
ui := gui.New(gui.Options{
|
|
Version: Version,
|
|
ConfigPath: *configPath,
|
|
ConfigURL: *configURL,
|
|
Supervisor: sup,
|
|
Logs: logs,
|
|
Logger: logger,
|
|
IPInfoToken: *ipinfoToken,
|
|
StartDark: !*light,
|
|
})
|
|
|
|
go func() {
|
|
err := ui.Run(ctx)
|
|
// Closing the window shuts the service down: the GUI is the process.
|
|
cancel()
|
|
if err != nil {
|
|
logger.Error("gui exited", "err", err)
|
|
log.SetFlags(0)
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}()
|
|
|
|
app.Main()
|
|
}
|