Merge remote-tracking branch 'origin/master' into sf

This commit is contained in:
iceBear67
2026-05-31 15:48:07 +08:00
7 changed files with 418 additions and 42 deletions
+57
View File
@@ -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/
+21
View File
@@ -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.
+142
View File
@@ -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.<tag>]]` 规则(你 -> 其他人)
```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.<tag>]]` 规则(其他人 -> 你)
```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`
+138 -27
View File
@@ -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)
+14 -5
View File
@@ -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
+35 -6
View File
@@ -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
}
+11 -4
View File
@@ -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 {