commit f0f9a23813a447fb845591f5306000606fe9d63f Author: nullcat Date: Sun May 17 18:56:39 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f9bbbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.exe +./.idea diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..d8a15c1 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,28 @@ +[core] +auth_key = "" # required +control_url = "https://controlplane.tailscale.com" # or your headscale instance +hostname = "" # leave blank to use machine name +ephemeral = true +accept_routes = true + +[[forward.web]] # you -> others +protocol = "tcp" +tailscale_port = 8080 +local_addr = "127.0.0.1:9090" + +[[connect.web]] # others -> you +protocol = "tcp" +local_port = 9000 +dst_addr = "any-client-in.ts.net:8080" + +[[connect.minecraft]] +protocol = "minecraft" +local_port = 25565 +dst_addr = "any-client-in.ts.net:25566" +lan_enable = true +lan_motd = "Minecraft via Tailscale" + +[[connect.udp_example]] +protocol = "udp" +local_port = 24454 +dst_addr = "any-client-in.ts.net:24454" \ No newline at end of file diff --git a/core/config.go b/core/config.go new file mode 100644 index 0000000..7976b5c --- /dev/null +++ b/core/config.go @@ -0,0 +1,81 @@ +package core + +import ( + "bytes" + "os" + + "github.com/BurntSushi/toml" +) + +type ForwardRule struct { + Protocol string `toml:"protocol"` + TailscalePort int `toml:"tailscale_port"` + LocalAddr string `toml:"local_addr"` +} + +type ConnectRule struct { + Protocol string `toml:"protocol"` + LocalPort int `toml:"local_port"` + LocalAddr string `toml:"local_addr"` + DstAddr string `toml:"dst_addr"` + LanEnable *bool `toml:"lan_enable"` + LanMotd string `toml:"lan_motd"` +} + +type Core struct { + AuthKey string `toml:"auth_key"` + ControlURL string `toml:"control_url"` + Hostname string `toml:"hostname"` + Ephemeral bool `toml:"ephemeral"` + AcceptRoutes bool `toml:"accept_routes"` +} + +func (r ConnectRule) LANEnabled() bool { + if r.LanEnable != nil { + return *r.LanEnable + } + return r.Protocol == "minecraft" +} + +func (r ConnectRule) LANMotdOr(def string) string { + if r.LanMotd != "" { + return r.LanMotd + } + return def +} + +type Config struct { + Core Core `toml:"core"` + Forward map[string][]ForwardRule `toml:"forward"` + Connect map[string][]ConnectRule `toml:"connect"` +} + +func LoadConfig(path string) (*Config, error) { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + cfg := &Config{ + Core: Core{ + Hostname: hostname, + Ephemeral: true, + AcceptRoutes: true, + }, + Forward: make(map[string][]ForwardRule), + Connect: make(map[string][]ConnectRule), + } + if _, err = os.Stat(path); err != nil { + // write a basic config + buf := new(bytes.Buffer) + err = toml.NewEncoder(buf).Encode(cfg) + if err != nil { + return nil, err + } + err = os.WriteFile(path, buf.Bytes(), 0644) + } + _, err = toml.DecodeFile(path, cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/core/forwarder.go b/core/forwarder.go new file mode 100644 index 0000000..e119ed1 --- /dev/null +++ b/core/forwarder.go @@ -0,0 +1,429 @@ +package core + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "sync" + "time" + + "tailscale.com/tsnet" +) + +func StartForwarders(ctx context.Context, srv *tsnet.Server, rules map[string][]ForwardRule) { + for tag, rrs := range rules { + for _, rule := range rrs { + rule := rule + tag := tag + slog.Debug("starting forwarder", + slog.String("tag", tag), + slog.String("protocol", rule.Protocol), + slog.Int("tailscale_port", rule.TailscalePort), + slog.String("local_addr", rule.LocalAddr), + ) + go runForwarder(ctx, srv, rule, tag) + } + } +} + +func StartConnectors(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule) { + for tag, rrs := range rules { + for _, rule := range rrs { + rule := rule + tag := tag + args := []any{ + slog.String("tag", tag), + slog.String("protocol", rule.Protocol), + slog.Int("local_port", rule.LocalPort), + slog.String("dst_addr", rule.DstAddr), + } + if rule.LocalAddr != "" { + args = append(args, slog.String("local_addr", rule.LocalAddr)) + } + slog.Debug("starting connector", args...) + go runConnector(ctx, srv, rule, tag) + } + } +} + +func RuleLogger(rule any, tag string) *slog.Logger { + var args []any + switch r := rule.(type) { + case ForwardRule: + args = []any{ + slog.String("protocol", r.Protocol), + slog.Int("tailscale_port", r.TailscalePort), + slog.String("local_addr", r.LocalAddr), + } + case ConnectRule: + args = []any{ + slog.String("protocol", r.Protocol), + slog.Int("local_port", r.LocalPort), + slog.String("dst_addr", r.DstAddr), + } + if r.LocalAddr != "" { + args = append(args, slog.String("local_addr", r.LocalAddr)) + } + } + if tag != "" { + args = append(args, slog.String("tag", tag)) + } + return slog.With(args...) +} + +func runForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, tag string) { + logger := RuleLogger(rule, tag) + + switch rule.Protocol { + case "tcp": + runTCPForwarder(ctx, srv, rule, logger) + case "udp": + runUDPForwarder(ctx, srv, rule, logger) + default: + logger.Error("unsupported protocol, expected tcp or udp") + } +} + +func runTCPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) { + ip4, ip6 := srv.TailscaleIPs() + ip := ip4 + if !ip.IsValid() { + ip = ip6 + } + ln, err := srv.Listen("tcp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort)) + if err != nil { + logger.Error("failed to listen", "error", err) + return + } + logger.Info("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort))) + + go func() { + <-ctx.Done() + ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + logger.Error("accept error", "error", err) + continue + } + go handleTCPForward(ctx, srv, conn, rule, logger) + } +} + +func handleTCPForward(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ForwardRule, logger *slog.Logger) { + remoteAddrStr := conn.RemoteAddr().String() + clog := logger.With(slog.String("remote", remoteAddrStr)) + + lc, err := srv.LocalClient() + if err == nil { + who, err := lc.WhoIs(ctx, remoteAddrStr) + if err == nil { + clog = clog.With(slog.String("user", who.UserProfile.LoginName)) + } + } + + connType := getConnType(ctx, srv, remoteAddrStr) + clog.Info("accepted connection", + slog.String("conn_type", connType), + slog.String("local_addr", rule.LocalAddr), + ) + + defer conn.Close() + + localConn, err := net.Dial("tcp", rule.LocalAddr) + if err != nil { + clog.Error("failed to dial local", "error", err) + return + } + defer localConn.Close() + + toTs, toLocal := pipeConns(conn, localConn) + clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs)) +} + +func getConnType(ctx context.Context, srv *tsnet.Server, remoteAddrStr string) string { + lc, err := srv.LocalClient() + if err != nil { + return "unknown" + } + status, err := lc.Status(ctx) + if err != nil { + return "unknown" + } + + remoteHost, _, err := net.SplitHostPort(remoteAddrStr) + if err != nil { + return "unknown" + } + + for _, peer := range status.Peer { + for _, addr := range peer.TailscaleIPs { + if addr.String() == remoteHost { + if peer.Relay != "" { + return fmt.Sprintf("derp(%s)", peer.Relay) + } + return "direct" + } + } + } + return "unknown" +} + +func runUDPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) { + ip4, ip6 := srv.TailscaleIPs() + ip := ip4 + if !ip.IsValid() { + ip = ip6 + } + pc, err := srv.ListenPacket("udp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort)) + if err != nil { + logger.Error("failed to listen", "error", err) + return + } + logger.Info("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort))) + + relay := &udpRelay{ + listenConn: pc, + dialAddr: rule.LocalAddr, + logger: logger, + direction: "local", + sessions: make(map[string]*udpSession), + } + relay.run(ctx) +} + +type udpSession struct { + conn net.Conn + remote net.Addr + lastUse time.Time +} + +type udpRelay struct { + listenConn net.PacketConn + dialAddr string + logger *slog.Logger + direction string + + mu sync.Mutex + sessions map[string]*udpSession +} + +func (r *udpRelay) run(ctx context.Context) { + go func() { + <-ctx.Done() + r.listenConn.Close() + }() + + go func() { + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.cleanup() + } + } + }() + + buf := make([]byte, 65535) + for { + select { + case <-ctx.Done(): + return + default: + } + + n, from, err := r.listenConn.ReadFrom(buf) + if err != nil { + if ctx.Err() != nil { + return + } + r.logger.Error("udp read error", "error", err) + return + } + + key := from.String() + r.mu.Lock() + sess, exists := r.sessions[key] + if !exists { + dialed, err := net.Dial("udp", r.dialAddr) + if err != nil { + r.mu.Unlock() + r.logger.Error("failed to dial", "error", err) + continue + } + sess = &udpSession{conn: dialed, remote: from, lastUse: time.Now()} + r.sessions[key] = sess + r.mu.Unlock() + + r.logger.Debug("new udp session", slog.String("remote", key), slog.String("direction", r.direction)) + go r.readSession(key, sess) + } else { + sess.lastUse = time.Now() + r.mu.Unlock() + } + + if _, err := sess.conn.Write(buf[:n]); err != nil { + r.logger.Error("failed to write", "error", err) + r.removeSession(key) + } + } +} + +func (r *udpRelay) readSession(key string, sess *udpSession) { + buf := make([]byte, 65535) + for { + n, err := sess.conn.Read(buf) + if err != nil { + r.removeSession(key) + return + } + if _, err := r.listenConn.WriteTo(buf[:n], sess.remote); err != nil { + r.logger.Error("failed to write back", "error", err) + r.removeSession(key) + return + } + } +} + +func (r *udpRelay) removeSession(key string) { + r.mu.Lock() + defer r.mu.Unlock() + if s, ok := r.sessions[key]; ok { + s.conn.Close() + delete(r.sessions, key) + r.logger.Debug("udp session closed", slog.String("remote", key)) + } +} + +func (r *udpRelay) cleanup() { + r.mu.Lock() + defer r.mu.Unlock() + threshold := time.Now().Add(-5 * time.Minute) + for key, s := range r.sessions { + if s.lastUse.Before(threshold) { + s.conn.Close() + delete(r.sessions, key) + r.logger.Debug("udp session cleaned up", slog.String("remote", key)) + } + } +} + +func runConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, tag string) { + logger := RuleLogger(rule, tag) + + switch rule.Protocol { + case "tcp", "minecraft": + runTCPConnector(ctx, srv, rule, logger) + case "udp": + runUDPConnector(ctx, srv, rule, logger) + default: + logger.Error("unsupported protocol, expected tcp or udp") + } +} + +func runTCPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) { + bindIP := rule.LocalAddr + if bindIP == "" { + bindIP = "0.0.0.0" + } + if rule.LANEnabled() && bindIP != "0.0.0.0" { + logger.Warn("lan_enable forces local_addr to 0.0.0.0, overriding") + bindIP = "0.0.0.0" + } + addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + logger.Error("failed to listen locally", "error", err) + return + } + logger.Info("listening", slog.String("on", addr)) + + go func() { + <-ctx.Done() + ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + logger.Error("accept error", "error", err) + continue + } + go handleTCPConnect(ctx, srv, conn, rule, logger) + } +} + +func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ConnectRule, logger *slog.Logger) { + clog := logger.With(slog.String("local_client", conn.RemoteAddr().String())) + defer conn.Close() + + tsConn, err := srv.Dial(ctx, "tcp", rule.DstAddr) + if err != nil { + clog.Error("failed to dial tailscale", "error", err) + return + } + defer tsConn.Close() + + clog.Info("accepted connection", slog.String("dst_addr", rule.DstAddr)) + toConn, toTs := pipeConns(conn, tsConn) + clog.Info("connection closed", slog.Int64("ts_rx_bytes", toTs), slog.Int64("ts_tx_bytes", toConn)) +} + +func runUDPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) { + bindIP := rule.LocalAddr + if bindIP == "" { + bindIP = "0.0.0.0" + } + addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort) + addrUDP, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + logger.Error("failed to resolve local addr", "error", err) + return + } + + pc, err := net.ListenUDP("udp", addrUDP) + if err != nil { + logger.Error("failed to listen locally", "error", err) + return + } + logger.Info("listening", slog.String("on", addr)) + + relay := &udpRelay{ + listenConn: pc, + dialAddr: rule.DstAddr, + logger: logger, + direction: "tailscale", + sessions: make(map[string]*udpSession), + } + relay.run(ctx) +} + +func pipeConns(a, b net.Conn) (toA, toB int64) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + n, _ := io.Copy(a, b) + toA = n + }() + go func() { + defer wg.Done() + n, _ := io.Copy(b, a) + toB = n + }() + wg.Wait() + return +} diff --git a/core/lan.go b/core/lan.go new file mode 100644 index 0000000..edad2f3 --- /dev/null +++ b/core/lan.go @@ -0,0 +1,94 @@ +package core + +import ( + "context" + "fmt" + "net" + "time" + + "log/slog" +) + +type LanEntry struct { + Motd string + Port int +} + +func LanDiscoverService(ctx context.Context, entryList []LanEntry, logger *slog.Logger) { + mcastAddrs := []string{ + "224.0.2.60:4445", + "[ff75:230::60]:4445", + } + + var fdList []*net.UDPConn + for _, addrStr := range mcastAddrs { + addr, err := net.ResolveUDPAddr("udp", addrStr) + if err != nil { + logger.With(slog.String("error", err.Error())).Error("failed to resolve udp address") + continue + } + fd, err := net.DialUDP("udp", nil, addr) + if err != nil { + logger.With(slog.String("error", err.Error())).Error("failed to dial udp server") + continue + } + fdList = append(fdList, fd) + } + + if len(fdList) == 0 { + // init fail + logger.Warn("all multicast binding failed, service discovery is disabled") + return + } + + for _, entry := range entryList { + // debug log to print entry detail + logger.With( + slog.Int("port", entry.Port), + slog.String("motd", entry.Motd), + ).Debug("discover service: %s on %d", entry.Motd, entry.Port) + } + + ticker := time.NewTicker(1500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + logger.Debug("shutting down lan discovery service") + for _, fd := range fdList { + fd.Close() + } + return + case <-ticker.C: + for _, e := range entryList { + msg := fmt.Sprintf("[MOTD]%s[/MOTD][AD]%d[/AD]", e.Motd, e.Port) + for _, c := range fdList { + _, err := c.Write([]byte(msg)) + if err != nil { + logger.With(slog.String("error", err.Error())).Error("failed to write to udp server") + return + } + } + } + } + } +} + +func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) { + var lanEntries []LanEntry + for tag, rs := range rules { + for _, rule := range rs { + if !rule.LANEnabled() { + continue + } + motd := rule.LANMotdOr(tag) + lanEntries = append(lanEntries, LanEntry{ + Motd: motd, + Port: rule.LocalPort, + }) + } + } + + go LanDiscoverService(ctx, lanEntries, logger) +} diff --git a/core/logs.go b/core/logs.go new file mode 100644 index 0000000..af11410 --- /dev/null +++ b/core/logs.go @@ -0,0 +1,44 @@ +package core + +import ( + "os" + "strings" + "time" + + "log/slog" + + "github.com/lmittmann/tint" + "github.com/mattn/go-colorable" +) + +func parseLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func NewLogger(level string, useJsonFormat bool) *slog.Logger { + w := os.Stdout + var logger *slog.Logger + if !useJsonFormat { + logger = slog.New(tint.NewHandler(colorable.NewColorable(w), &tint.Options{ + Level: parseLevel(level), + TimeFormat: time.DateTime, + //NoColor: !isatty.IsTerminal(w.Fd()), + })) + } else { + logger = slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: parseLevel(level), + })) + } + + slog.SetDefault(logger) + return logger +} diff --git a/core/tsnet.go b/core/tsnet.go new file mode 100644 index 0000000..ea99b5d --- /dev/null +++ b/core/tsnet.go @@ -0,0 +1,65 @@ +package core + +import ( + "context" + fmt2 "fmt" + "log/slog" + + "tailscale.com/ipn" + + "tailscale.com/tsnet" +) + +func InitTsNet(ctx context.Context, cfg *Core, logger *slog.Logger) (*tsnet.Server, error) { + srv := &tsnet.Server{ + Hostname: "tslink-" + cfg.Hostname, + AuthKey: cfg.AuthKey, + Ephemeral: cfg.Ephemeral, + Logf: func(fmt string, args ...interface{}) { + logger.With(slog.String("from", "tsnet")).Debug(fmt2.Sprintf(fmt, args...)) + }, + UserLogf: func(fmt string, args ...interface{}) { + logger.With(slog.String("from", "tsnet")).Info(fmt2.Sprintf(fmt, args...)) + }, + RunWebClient: true, + } + + if cfg.ControlURL != "" { + srv.ControlURL = cfg.ControlURL + } + + logger.Debug("starting tsnet server") + if err := srv.Start(); err != nil { + logger.With(slog.String("error", err.Error())).Error("starting tsnet server failed") + return nil, err + } + + status, err := srv.Up(ctx) + if err != nil { + logger.With(slog.String("error", err.Error())).Error("bring up tsnet server failed") + return nil, err + } + + for _, ip := range status.TailscaleIPs { + logger.With(slog.String("ip", ip.String())).Info("ip got from tsnet") + } + + if cfg.AcceptRoutes { + lc, err := srv.LocalClient() + if err != nil { + logger.With(slog.String("error", err.Error())).Error("error from getting local client") + } else { + _, err = lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{RouteAll: true}, + RouteAllSet: true, + }) + if err != nil { + logger.With(slog.String("error", err.Error())).Error("error from editing prefs") + } else { + logger.Debug("subnet route accepted") + } + } + } + + return srv, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57a9117 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module tslink + +go 1.26.3 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/lmittmann/tint v1.1.3 + github.com/mattn/go-colorable v0.1.13 + tailscale.com v1.98.2 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/creachadair/msync v0.7.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..0f29f5d --- /dev/null +++ b/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + "tslink/core" +) + +func main() { + useJsonFormatLogger := flag.Bool("json-format", false, "use json format logger") + logLevel := flag.String("level", "info", "log level (DEBUG|INFO|WARN|ERROR)") + configPath := flag.String("c", "config.toml", "path to config file") + flag.Parse() + + logger := core.NewLogger(*logLevel, *useJsonFormatLogger) + + logger.Info("Starting tslink server", "level", *logLevel) + cfg, err := core.LoadConfig(*configPath) + if err != nil { + logger.With( + slog.String("error", err.Error()), + ).Error("Error loading config") + os.Exit(1) + } + + ctx, cancel := context.WithCancel(context.Background()) + logger.Info("initializing tsnet server") + srv, err := core.InitTsNet(ctx, &cfg.Core, logger) + if err != nil { + logger.With( + slog.String("error", err.Error())).Error("Error initializing tsnet") + os.Exit(1) + } + logger.Info("tsnet server initialized") + + core.StartForwarders(ctx, srv, cfg.Forward) + core.StartConnectors(ctx, srv, cfg.Connect) + + core.RunLanDiscoverService(ctx, cfg.Connect, logger) + + sigHandler := make(chan os.Signal, 1) + signal.Notify(sigHandler, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-sigHandler: + logger.Warn("Shutting down") + cancel() + time.Sleep(time.Second) + return + } + } +}