diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8a5e58d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,57 @@ +name: Build + +on: + push: + branches: + - master + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26.3' + cache: true + + - name: Install dependencies + run: go mod download + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + BINARY_NAME="tslink" + if [ "${{ matrix.goos }}" = "windows" ]; then + BINARY_NAME="${BINARY_NAME}.exe" + fi + go build -v -trimpath -buildvcs=false -o "release/${BINARY_NAME}" -ldflags="-s -w" . + + - name: Compress with UPX + if: matrix.goos != 'darwin' && !(matrix.goos == 'windows' && matrix.goarch == 'arm64') + uses: crazy-max/ghaction-upx@v4 + with: + files: | + release/tslink* + args: "-9" + + - name: Get current date + id: date + run: echo "date=$(date +'%y%m%d')" >> $GITHUB_OUTPUT + + - name: Upload Artifact + uses: actions/upload-artifact@v5 + with: + name: tslink-dev${{ steps.date.outputs.date }}-${{ matrix.goos }}_${{ matrix.goarch }} + path: release/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1d12a9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nullcat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5667ec0 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# tsnet_pure 使用说明 + +`tsnet_pure` 是一个基于 `tailscale.com/tsnet` 的轻量转发工具,支持: + +- `forward`:将 **Tailscale 入站** 请求转发到本机服务(你 -> 其他人)。 +- `connect`:将 **本机/LAN 入站** 请求转发到 Tailscale 目标(其他人 -> 你)。 + +支持 `tcp`、`udp`,并对 `minecraft` 协议提供局域网发现能力。 + +## 1. 环境要求 + +- Go 1.26+ +- 可用的 Tailscale `auth_key`(或 Headscale 对应密钥) + +## 2. 安装与构建 + +在项目根目录执行: + +```powershell +go mod tidy +go build -o tslink.exe . +``` + +如果你已有编译产物,也可以直接运行现成的 `tslink.exe`。 + +## 3. 配置文件 + +默认使用根目录 `config.toml`,可通过参数 `-c` 指定其他路径。 + +项目已提供 `config.example.toml`,可复制后修改: + +```powershell +Copy-Item .\config.example.toml .\config.toml +``` + +### 3.1 `[core]` 配置 + +```toml +[core] +auth_key = "" # 必填 +control_url = "https://controlplane.tailscale.com" # 官方控制面或 Headscale +hostname = "" # 留空时自动使用机器名 +ephemeral = true +accept_routes = true +``` + +- `auth_key`:Tailscale/Headscale 授权密钥。 +- `control_url`:默认是官方控制面,使用 Headscale 时改为你的实例地址。 +- `hostname`:节点名,为空则自动取系统主机名。 +- `ephemeral`:是否使用临时节点。 +- `accept_routes`:是否自动启用路由接收(`RouteAll`)。 + +### 3.2 `[[forward.]]` 规则(你 -> 其他人) + +```toml +[[forward.web]] +protocol = "tcp" +tailscale_port = 8080 +local_addr = "127.0.0.1:9090" +``` + +含义:监听本机 Tailscale IP 的 `8080`,转发到本地 `127.0.0.1:9090`。 + +字段说明: + +- `protocol`:`tcp` 或 `udp` +- `tailscale_port`:对 Tailnet 暴露端口 +- `local_addr`:本地目标地址(`host:port`) + +### 3.3 `[[connect.]]` 规则(其他人 -> 你) + +```toml +[[connect.web]] +protocol = "tcp" +local_port = 9000 +dst_addr = "any-client-in.ts.net:8080" +``` + +含义:监听本机 `9000`,流量转发到 Tailscale 目标 `any-client-in.ts.net:8080`。 + +字段说明: + +- `protocol`:`tcp`、`udp` 或 `minecraft` +- `local_port`:本地监听端口 +- `local_addr`:本地监听地址(可选,默认 `0.0.0.0`) +- `dst_addr`:Tailscale 目标地址(`host:port`) +- `lan_enable`:仅 `minecraft` 场景常用;不填时 `minecraft` 默认启用 +- `lan_motd`:Minecraft 局域网广播提示文案 + +Minecraft 示例: + +```toml +[[connect.minecraft]] +protocol = "minecraft" +local_port = 25565 +dst_addr = "any-client-in.ts.net:25566" +lan_enable = true +lan_motd = "Minecraft via Tailscale" +``` + +## 4. 启动方式 + +```powershell +.\tslink.exe -c config.toml +``` + +常用参数: + +- `-c`:配置文件路径,默认 `config.toml` +- `-level`:日志级别,默认 `info`(可用:`debug|info|warn|error`) +- `-json-format`:输出 JSON 日志 +- `-diagnose`:启用 tsnet debug 日志 + +示例: + +```powershell +.\tslink.exe -c .\config.toml -level debug -diagnose +``` + +## 5. 运行与退出 + +- 启动后程序会初始化 tsnet 并按配置启动所有 `forward/connect` 规则。 +- 按 `Ctrl + C` 可优雅退出。 +- 内置 watchdog 可能在异常时自动触发重启逻辑。 + +## 6. 常见问题 + +### 6.1 首次启动失败/无法入网 + +- 检查 `auth_key` 是否正确、是否过期。 +- 若使用 Headscale,确认 `control_url` 可访问且 TLS/证书配置正确。 + +### 6.2 端口无法访问 + +- 检查本地防火墙与目标服务是否真的在 `local_addr` 监听。 +- 确认 `dst_addr` 可在 Tailnet 内解析并连通。 +- 核对 `protocol` 是否与目标服务一致(`tcp/udp` 不可混用)。 + +### 6.3 日志排查建议 + +- 使用 `-level debug` 查看更详细转发日志。 +- 需要 tsnet 内部信息时加 `-diagnose`。 diff --git a/core/forwarder.go b/core/forwarder.go index e119ed1..e6667be 100644 --- a/core/forwarder.go +++ b/core/forwarder.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net" + "net/netip" "sync" "time" @@ -17,7 +18,7 @@ func StartForwarders(ctx context.Context, srv *tsnet.Server, rules map[string][] for _, rule := range rrs { rule := rule tag := tag - slog.Debug("starting forwarder", + slog.Info("starting forwarder", slog.String("tag", tag), slog.String("protocol", rule.Protocol), slog.Int("tailscale_port", rule.TailscalePort), @@ -42,7 +43,7 @@ func StartConnectors(ctx context.Context, srv *tsnet.Server, rules map[string][] if rule.LocalAddr != "" { args = append(args, slog.String("local_addr", rule.LocalAddr)) } - slog.Debug("starting connector", args...) + slog.Info("starting connector", args...) go runConnector(ctx, srv, rule, tag) } } @@ -87,17 +88,13 @@ func runForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, tag } func runTCPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) { - ip4, ip6 := srv.TailscaleIPs() - ip := ip4 - if !ip.IsValid() { - ip = ip6 - } + ip := getSelfTsnetAddr(srv) 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))) + logger.Debug("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort))) go func() { <-ctx.Done() @@ -177,32 +174,115 @@ func getConnType(ctx context.Context, srv *tsnet.Server, remoteAddrStr string) s } 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)) + ip := getSelfTsnetAddr(srv) + ln, err := srv.Listen("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), + 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 handleUDPForward(ctx, srv, conn, rule, logger) } - relay.run(ctx) +} + +func handleUDPForward(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("udp", rule.LocalAddr) + if err != nil { + clog.Error("failed to dial local", "error", err) + return + } + defer localConn.Close() + + remoteIP, _, _ := net.SplitHostPort(remoteAddrStr) + + var toTs, toLocal int64 + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + buf := make([]byte, 65535) + for { + n, err := conn.Read(buf) + if err != nil { + return + } + toLocal += int64(n) + clog.Debug("inbound udp packet", + slog.String("from_ip", remoteIP), + slog.String("to_ip", rule.LocalAddr), + slog.Int("pkg_size", n), + ) + if _, err := localConn.Write(buf[:n]); err != nil { + return + } + } + }() + + go func() { + defer wg.Done() + buf := make([]byte, 65535) + for { + n, err := localConn.Read(buf) + if err != nil { + return + } + toTs += int64(n) + localIP, _, _ := net.SplitHostPort(localConn.RemoteAddr().String()) + clog.Debug("outbound udp packet", + slog.String("from_ip", localIP), + slog.String("to_ip", remoteIP), + slog.Int("pkg_size", n), + ) + if _, err := conn.Write(buf[:n]); err != nil { + return + } + } + }() + + wg.Wait() + clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs)) } type udpSession struct { - conn net.Conn - remote net.Addr - lastUse time.Time + conn net.Conn + remote net.Addr + inTailscale bool + lastUse time.Time } type udpRelay struct { @@ -210,6 +290,8 @@ type udpRelay struct { dialAddr string logger *slog.Logger direction string + srv *tsnet.Server + ctx context.Context mu sync.Mutex sessions map[string]*udpSession @@ -255,23 +337,43 @@ func (r *udpRelay) run(ctx context.Context) { r.mu.Lock() sess, exists := r.sessions[key] if !exists { - dialed, err := net.Dial("udp", r.dialAddr) + inTsnet := false + if host, _, err := net.SplitHostPort(r.dialAddr); err == nil { + if dialIP, err := netip.ParseAddr(host); err == nil { + tsnetCIDR := netip.MustParsePrefix("100.64.0.0/10") + inTsnet = tsnetCIDR.Contains(dialIP) + } + } + + var dialed net.Conn + if inTsnet { + dialed, err = r.srv.Dial(r.ctx, "udp", r.dialAddr) + } else { + 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()} + sess = &udpSession{conn: dialed, remote: from, lastUse: time.Now(), inTailscale: inTsnet} r.sessions[key] = sess r.mu.Unlock() - r.logger.Debug("new udp session", slog.String("remote", key), slog.String("direction", r.direction)) + r.logger.Info("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() } + fromIP, _, _ := net.SplitHostPort(from.String()) + toIP, _, _ := net.SplitHostPort(sess.conn.RemoteAddr().String()) + r.logger.Debug("outbound udp packet", + slog.String("from_ip", fromIP), + slog.String("to_ip", toIP), + slog.Int("pkg_size", n), + ) if _, err := sess.conn.Write(buf[:n]); err != nil { r.logger.Error("failed to write", "error", err) r.removeSession(key) @@ -287,6 +389,13 @@ func (r *udpRelay) readSession(key string, sess *udpSession) { r.removeSession(key) return } + fromIP, _, _ := net.SplitHostPort(sess.conn.RemoteAddr().String()) + toIP, _, _ := net.SplitHostPort(sess.remote.String()) + r.logger.Info("udp packet", + slog.String("from_ip", fromIP), + slog.String("to_ip", toIP), + slog.Int("pkg_size", n), + ) if _, err := r.listenConn.WriteTo(buf[:n], sess.remote); err != nil { r.logger.Error("failed to write back", "error", err) r.removeSession(key) @@ -406,6 +515,8 @@ func runUDPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, l dialAddr: rule.DstAddr, logger: logger, direction: "tailscale", + srv: srv, + ctx: ctx, sessions: make(map[string]*udpSession), } relay.run(ctx) diff --git a/core/tsnet.go b/core/tsnet.go index ea99b5d..3e391b1 100644 --- a/core/tsnet.go +++ b/core/tsnet.go @@ -4,20 +4,27 @@ import ( "context" fmt2 "fmt" "log/slog" + "time" "tailscale.com/ipn" "tailscale.com/tsnet" ) -func InitTsNet(ctx context.Context, cfg *Core, logger *slog.Logger) (*tsnet.Server, error) { +func InitTsNet(ctx context.Context, cfg *Core, logger *slog.Logger, withDebugLog bool) (*tsnet.Server, error) { + dbgLogger := func(fmt string, args ...interface{}) {} + if withDebugLog { + logger.Warn("Tsnet debug log activated") + dbgLogger = func(fmt string, args ...interface{}) { + logger.With(slog.String("from", "tsnet")).Debug(fmt2.Sprintf(fmt, args...)) + } + } + 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...)) - }, + Logf: dbgLogger, UserLogf: func(fmt string, args ...interface{}) { logger.With(slog.String("from", "tsnet")).Info(fmt2.Sprintf(fmt, args...)) }, @@ -34,7 +41,9 @@ func InitTsNet(ctx context.Context, cfg *Core, logger *slog.Logger) (*tsnet.Serv return nil, err } - status, err := srv.Up(ctx) + cnclCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + status, err := srv.Up(cnclCtx) if err != nil { logger.With(slog.String("error", err.Error())).Error("bring up tsnet server failed") return nil, err diff --git a/core/utils.go b/core/utils.go index 7841e5a..7f84bab 100644 --- a/core/utils.go +++ b/core/utils.go @@ -11,6 +11,7 @@ import ( "time" "tailscale.com/client/local" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -86,7 +87,6 @@ func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][ ap, _, err := net.SplitHostPort(rule.DstAddr) if err != nil { logger.Debug("error parsing rule", "tag", tag, "dst", rule.DstAddr, "err", err) - // err log continue } addr, err := resolveAddr(ctx, srv, ap) @@ -109,19 +109,39 @@ func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][ func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers []netip.Addr, logger *slog.Logger) { for _, peer := range relativePeers { - ping, err := lc.Ping(ctx, peer, tailcfg.PingDisco) + loLog := logger.With("peer", peer) + + ping, err := func() (*ipnstate.PingResult, error) { + cnclCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + ping, err := lc.Ping(cnclCtx, peer, tailcfg.PingDisco) + return ping, err + }() + if err != nil { - logger.Debug("connectivity: failed to ping peer", "peer", peer, "err", err) - return + if errors.Is(err, context.DeadlineExceeded) { + loLog.Warn("connectivity: peer ping timeout") + } else { + loLog.Warn("connectivity: failed to ping peer", "err", err) + } + continue } + + peerInfo, err := lc.WhoIs(ctx, peer.String()) + if err != nil { + loLog.Warn("failed to get peer info", "err", err) + } else { + loLog = loLog.With("name", peerInfo.Node.ComputedName) + } + var connect string if ping.DERPRegionCode == "" { connect = "direct" } else { connect = ping.DERPRegionCode } - logger.Info("connectivity: peer pinged", - "peer", peer, + loLog.Info("connectivity: peer pinged", "latency", fmt.Sprintf("%.2fms", ping.LatencySeconds*1000), "connect", connect, ) @@ -160,3 +180,12 @@ func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, } }() } + +func getSelfTsnetAddr(srv *tsnet.Server) netip.Addr { + ip4, ip6 := srv.TailscaleIPs() + ip := ip4 + if !ip.IsValid() { + ip = ip6 + } + return ip +} diff --git a/main.go b/main.go index 9873827..b737bfd 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "log/slog" "os" @@ -11,7 +12,7 @@ import ( "tslink/core" ) -func serviceLogic(configPath, configURL string, logger *slog.Logger) bool { +func serviceLogic(configPath string, isTsnetDebug bool, configURL string, logger *slog.Logger) bool { // Determine config source: URL takes priority, then file configSource := configPath if configURL != "" { @@ -28,9 +29,14 @@ func serviceLogic(configPath, configURL string, logger *slog.Logger) bool { } ctx, cancelAll := context.WithCancel(context.Background()) + defer cancelAll() logger.Info("initializing tsnet server") - srv, err := core.InitTsNet(ctx, &cfg.Core, logger) + srv, err := core.InitTsNet(ctx, &cfg.Core, logger, isTsnetDebug) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + logger.Debug("tsnet initialization timed out, retrying") + return false + } logger.With( slog.String("error", err.Error())).Error("Error initializing tsnet") os.Exit(1) @@ -52,7 +58,7 @@ func serviceLogic(configPath, configURL string, logger *slog.Logger) bool { go func() { <-ctx.Done() logger.Debug("stopping tsnet service") - srv.Close() + _ = srv.Close() // just ignore it }() for { @@ -73,6 +79,7 @@ func serviceLogic(configPath, configURL string, logger *slog.Logger) bool { func main() { useJsonFormatLogger := flag.Bool("json-format", false, "use json format logger") + showTsnetDebugLog := flag.Bool("diagnose", false, "show tsnet debug log on level=debug") logLevel := flag.String("level", "info", "log level (DEBUG|INFO|WARN|ERROR)") configPath := flag.String("c", "config.toml", "path to config file") configURL := flag.String("config-url", core.DefaultConfigURL, "URL to fetch config from (default from build ldflags)") @@ -83,7 +90,7 @@ func main() { logger.Info("Starting tslink server", "level", *logLevel, "configPath", *configPath) for { - isStopped := serviceLogic(*configPath, *configURL, logger) + isStopped := serviceLogic(*configPath, *showTsnetDebugLog, *configURL, logger) if !isStopped { logger.Warn("tslink server restart") } else {