Files
2026-07-26 20:44:49 +08:00

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