641 lines
17 KiB
Go
641 lines
17 KiB
Go
package outbound
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"linkport/internal/model"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
singBoxInboundTag = "linkport-in"
|
|
singBoxOutboundTag = "linkport-out"
|
|
singBoxStartTimeout = 10 * time.Second
|
|
singBoxCheckTimeout = 10 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrSingBoxUnavailable = errors.New("sing-box executable is unavailable")
|
|
ErrSingBoxInvalid = errors.New("invalid sing-box outbound configuration")
|
|
ErrSingBoxCheck = errors.New("sing-box configuration check failed")
|
|
ErrSingBoxCheckTimeout = errors.New("sing-box configuration check timed out")
|
|
ErrSingBoxStart = errors.New("sing-box process failed to start")
|
|
ErrSingBoxConnect = errors.New("sing-box connection failed")
|
|
|
|
singBoxLookPath = exec.LookPath
|
|
singBoxCommandContext = exec.CommandContext
|
|
singBoxMakeTempDir = func() (string, error) {
|
|
return os.MkdirTemp("", "linkport-sing-box-")
|
|
}
|
|
)
|
|
|
|
type validatedSingBoxOutbound struct {
|
|
typ string
|
|
canonical []byte
|
|
}
|
|
|
|
// ValidateSingBoxOutbound uses the same validation as persistence. Runtime
|
|
// startup must not accept a capability that the stored model rejects.
|
|
func ValidateSingBoxOutbound(raw string) (string, error) {
|
|
outbound, err := parseSingBoxOutbound(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return outbound.typ, nil
|
|
}
|
|
|
|
func parseSingBoxOutbound(raw string) (validatedSingBoxOutbound, error) {
|
|
canonicalModel, typ, err := model.ValidateSingBoxOutbound(raw)
|
|
if err != nil {
|
|
return validatedSingBoxOutbound{}, fmt.Errorf("%w: %v", ErrSingBoxInvalid, err)
|
|
}
|
|
var object map[string]any
|
|
decoder := json.NewDecoder(strings.NewReader(canonicalModel))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&object); err != nil {
|
|
return validatedSingBoxOutbound{}, ErrSingBoxInvalid
|
|
}
|
|
object["tag"] = singBoxOutboundTag
|
|
canonical, err := json.Marshal(object)
|
|
if err != nil {
|
|
return validatedSingBoxOutbound{}, ErrSingBoxInvalid
|
|
}
|
|
return validatedSingBoxOutbound{typ: typ, canonical: canonical}, nil
|
|
}
|
|
|
|
type singBoxRuntimeConfig struct {
|
|
Log struct {
|
|
Disabled bool `json:"disabled"`
|
|
} `json:"log"`
|
|
Inbounds []singBoxInbound `json:"inbounds"`
|
|
Outbounds []json.RawMessage `json:"outbounds"`
|
|
Route struct {
|
|
Final string `json:"final"`
|
|
} `json:"route"`
|
|
}
|
|
|
|
type singBoxInbound struct {
|
|
Type string `json:"type"`
|
|
Tag string `json:"tag"`
|
|
Listen string `json:"listen"`
|
|
ListenPort int `json:"listen_port"`
|
|
Users []singBoxUser `json:"users"`
|
|
}
|
|
|
|
type singBoxUser struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func buildSingBoxConfig(outbound validatedSingBoxOutbound, port int) ([]byte, string, string, error) {
|
|
if port < 1 || port > 65535 {
|
|
return nil, "", "", ErrSingBoxStart
|
|
}
|
|
username, err := randomSingBoxCredential()
|
|
if err != nil {
|
|
return nil, "", "", ErrSingBoxStart
|
|
}
|
|
password, err := randomSingBoxCredential()
|
|
if err != nil {
|
|
return nil, "", "", ErrSingBoxStart
|
|
}
|
|
var config singBoxRuntimeConfig
|
|
config.Log.Disabled = true
|
|
config.Inbounds = []singBoxInbound{{
|
|
Type: "socks",
|
|
Tag: singBoxInboundTag,
|
|
Listen: "127.0.0.1",
|
|
ListenPort: port,
|
|
Users: []singBoxUser{{Username: username, Password: password}},
|
|
}}
|
|
config.Outbounds = []json.RawMessage{json.RawMessage(outbound.canonical)}
|
|
config.Route.Final = singBoxOutboundTag
|
|
content, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, "", "", ErrSingBoxStart
|
|
}
|
|
return content, username, password, nil
|
|
}
|
|
|
|
func randomSingBoxCredential() (string, error) {
|
|
var value [24]byte
|
|
if _, err := rand.Read(value[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(value[:]), nil
|
|
}
|
|
|
|
type privateSingBoxConfig struct {
|
|
directory string
|
|
path string
|
|
endpoint string
|
|
username string
|
|
password string
|
|
cleanup func()
|
|
}
|
|
|
|
func writePrivateSingBoxConfig(outbound validatedSingBoxOutbound, port int) (_ privateSingBoxConfig, resultErr error) {
|
|
content, username, password, err := buildSingBoxConfig(outbound, port)
|
|
if err != nil {
|
|
return privateSingBoxConfig{}, err
|
|
}
|
|
defer clear(content)
|
|
directory, err := singBoxMakeTempDir()
|
|
if err != nil {
|
|
return privateSingBoxConfig{}, ErrSingBoxStart
|
|
}
|
|
cleanup := func() { _ = os.RemoveAll(directory) }
|
|
defer func() {
|
|
if resultErr != nil {
|
|
cleanup()
|
|
}
|
|
}()
|
|
if err := os.Chmod(directory, 0o700); err != nil {
|
|
return privateSingBoxConfig{}, ErrSingBoxStart
|
|
}
|
|
path := filepath.Join(directory, "config.json")
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return privateSingBoxConfig{}, ErrSingBoxStart
|
|
}
|
|
_, writeErr := file.Write(content)
|
|
closeErr := file.Close()
|
|
if writeErr != nil || closeErr != nil {
|
|
return privateSingBoxConfig{}, ErrSingBoxStart
|
|
}
|
|
return privateSingBoxConfig{
|
|
directory: directory,
|
|
path: path,
|
|
endpoint: net.JoinHostPort("127.0.0.1", strconv.Itoa(port)),
|
|
username: username,
|
|
password: password,
|
|
cleanup: cleanup,
|
|
}, nil
|
|
}
|
|
|
|
func resolveSingBoxBinary() (string, error) {
|
|
binaryName := os.Getenv("LINKPORT_SING_BOX")
|
|
if binaryName == "" {
|
|
binaryName = "sing-box"
|
|
}
|
|
binaryPath, err := singBoxLookPath(binaryName)
|
|
if err != nil {
|
|
return "", ErrSingBoxUnavailable
|
|
}
|
|
return binaryPath, nil
|
|
}
|
|
|
|
// CheckSingBox validates with the installed sing-box binary without exposing
|
|
// its stderr, which may contain values copied from the outbound configuration.
|
|
func CheckSingBox(ctx context.Context, raw string) error {
|
|
outbound, err := parseSingBoxOutbound(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
binaryPath, err := resolveSingBoxBinary()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reservation, port, err := reserveSingBoxPort()
|
|
if err != nil {
|
|
return ErrSingBoxCheck
|
|
}
|
|
_ = reservation.Close()
|
|
config, err := writePrivateSingBoxConfig(outbound, port)
|
|
if err != nil {
|
|
return ErrSingBoxCheck
|
|
}
|
|
defer config.cleanup()
|
|
checkCtx, cancel := context.WithTimeout(ctx, singBoxCheckTimeout)
|
|
defer cancel()
|
|
cmd := singBoxCommandContext(checkCtx, binaryPath, "check", "-c", config.path, "--disable-color")
|
|
cmd.Dir = config.directory
|
|
cmd.Stdout = io.Discard
|
|
cmd.Stderr = io.Discard
|
|
configureSingBoxCommand(cmd)
|
|
if err := cmd.Run(); err != nil {
|
|
if errors.Is(checkCtx.Err(), context.DeadlineExceeded) {
|
|
return ErrSingBoxCheckTimeout
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return ErrSingBoxCheck
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type singBoxProcess struct {
|
|
cmd *exec.Cmd
|
|
config privateSingBoxConfig
|
|
done chan struct{}
|
|
stopOnce sync.Once
|
|
onExit func(*singBoxProcess)
|
|
}
|
|
|
|
func startSingBoxProcess(ctx context.Context, binaryPath string, outbound validatedSingBoxOutbound, onExit func(*singBoxProcess)) (*singBoxProcess, error) {
|
|
reservation, port, err := reserveSingBoxPort()
|
|
if err != nil {
|
|
return nil, ErrSingBoxStart
|
|
}
|
|
config, err := writePrivateSingBoxConfig(outbound, port)
|
|
if err != nil {
|
|
_ = reservation.Close()
|
|
return nil, ErrSingBoxStart
|
|
}
|
|
cmd := singBoxCommandContext(context.Background(), binaryPath, "run", "-c", config.path, "--disable-color")
|
|
cmd.Dir = config.directory
|
|
cmd.Stdout = io.Discard
|
|
cmd.Stderr = io.Discard
|
|
configureSingBoxCommand(cmd)
|
|
if err := reservation.Close(); err != nil {
|
|
config.cleanup()
|
|
return nil, ErrSingBoxStart
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
config.cleanup()
|
|
return nil, ErrSingBoxStart
|
|
}
|
|
process := &singBoxProcess{cmd: cmd, config: config, done: make(chan struct{}), onExit: onExit}
|
|
go process.wait()
|
|
startCtx, cancel := context.WithTimeout(ctx, singBoxStartTimeout)
|
|
defer cancel()
|
|
for {
|
|
probeCtx, probeCancel := context.WithTimeout(startCtx, 100*time.Millisecond)
|
|
connection, probeErr := (&net.Dialer{}).DialContext(probeCtx, "tcp", config.endpoint)
|
|
probeCancel()
|
|
if probeErr == nil {
|
|
_ = connection.Close()
|
|
return process, nil
|
|
}
|
|
select {
|
|
case <-process.done:
|
|
return nil, ErrSingBoxStart
|
|
case <-startCtx.Done():
|
|
process.stop()
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return nil, ErrSingBoxStart
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (process *singBoxProcess) wait() {
|
|
_ = process.cmd.Wait()
|
|
process.config.cleanup()
|
|
if process.onExit != nil {
|
|
process.onExit(process)
|
|
}
|
|
close(process.done)
|
|
}
|
|
|
|
func (process *singBoxProcess) stop() {
|
|
process.stopOnce.Do(func() {
|
|
if process.cmd.Process != nil {
|
|
_ = process.cmd.Process.Kill()
|
|
}
|
|
})
|
|
<-process.done
|
|
}
|
|
|
|
func reserveSingBoxPort() (net.Listener, int, error) {
|
|
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
return listener, port, nil
|
|
}
|
|
|
|
type singBoxProcessEntry struct {
|
|
ready chan struct{}
|
|
process *singBoxProcess
|
|
startErr error
|
|
refs int
|
|
exited bool
|
|
}
|
|
|
|
type singBoxProcessManager struct {
|
|
mu sync.Mutex
|
|
entries map[[32]byte]*singBoxProcessEntry
|
|
}
|
|
|
|
var sharedSingBoxProcesses = singBoxProcessManager{entries: make(map[[32]byte]*singBoxProcessEntry)}
|
|
|
|
type singBoxLease struct {
|
|
manager *singBoxProcessManager
|
|
key [32]byte
|
|
entry *singBoxProcessEntry
|
|
once sync.Once
|
|
}
|
|
|
|
func (manager *singBoxProcessManager) acquire(ctx context.Context, raw string) (*singBoxLease, error) {
|
|
outbound, err := parseSingBoxOutbound(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
binaryPath, err := resolveSingBoxBinary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyMaterial := make([]byte, 0, len(binaryPath)+1+len(outbound.canonical))
|
|
keyMaterial = append(keyMaterial, binaryPath...)
|
|
keyMaterial = append(keyMaterial, 0)
|
|
keyMaterial = append(keyMaterial, outbound.canonical...)
|
|
key := sha256.Sum256(keyMaterial)
|
|
clear(keyMaterial)
|
|
|
|
manager.mu.Lock()
|
|
entry := manager.entries[key]
|
|
starter := false
|
|
if entry == nil {
|
|
entry = &singBoxProcessEntry{ready: make(chan struct{})}
|
|
manager.entries[key] = entry
|
|
starter = true
|
|
}
|
|
entry.refs++
|
|
manager.mu.Unlock()
|
|
|
|
if starter {
|
|
process, startErr := startSingBoxProcess(ctx, binaryPath, outbound, func(process *singBoxProcess) {
|
|
manager.processExited(key, entry, process)
|
|
})
|
|
manager.mu.Lock()
|
|
if startErr == nil && !entry.exited {
|
|
entry.process = process
|
|
} else {
|
|
if startErr == nil {
|
|
startErr = ErrSingBoxStart
|
|
}
|
|
entry.startErr = startErr
|
|
}
|
|
close(entry.ready)
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
manager.release(key, entry)
|
|
return nil, ctx.Err()
|
|
case <-entry.ready:
|
|
}
|
|
if entry.startErr != nil {
|
|
manager.release(key, entry)
|
|
return nil, entry.startErr
|
|
}
|
|
select {
|
|
case <-entry.process.done:
|
|
manager.release(key, entry)
|
|
return nil, ErrSingBoxStart
|
|
default:
|
|
}
|
|
return &singBoxLease{manager: manager, key: key, entry: entry}, nil
|
|
}
|
|
|
|
func (manager *singBoxProcessManager) processExited(key [32]byte, entry *singBoxProcessEntry, process *singBoxProcess) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if manager.entries[key] != entry {
|
|
return
|
|
}
|
|
if entry.process == process {
|
|
delete(manager.entries, key)
|
|
return
|
|
}
|
|
entry.exited = true
|
|
}
|
|
|
|
func (manager *singBoxProcessManager) release(key [32]byte, entry *singBoxProcessEntry) {
|
|
var process *singBoxProcess
|
|
manager.mu.Lock()
|
|
if manager.entries[key] == entry {
|
|
entry.refs--
|
|
if entry.refs == 0 {
|
|
delete(manager.entries, key)
|
|
process = entry.process
|
|
}
|
|
}
|
|
manager.mu.Unlock()
|
|
if process != nil {
|
|
process.stop()
|
|
}
|
|
}
|
|
|
|
func (lease *singBoxLease) release() {
|
|
lease.once.Do(func() { lease.manager.release(lease.key, lease.entry) })
|
|
}
|
|
|
|
// dialSingBox obtains a shared sing-box process and opens one authenticated
|
|
// SOCKS5 stream. A failed proxy connection never falls back to direct dialing.
|
|
func dialSingBox(ctx context.Context, network, address, raw string, keepAlive time.Duration) (net.Conn, error) {
|
|
if network != "tcp" {
|
|
return nil, model.AtConnectionStage("target_connect", ErrSingBoxConnect)
|
|
}
|
|
startDone := Begin(ctx, "sing_box_start")
|
|
lease, err := sharedSingBoxProcesses.acquire(ctx, raw)
|
|
if err != nil {
|
|
err = model.AtConnectionStage("sing_box_start", err)
|
|
}
|
|
startDone(err)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
connection, err := dialSingBoxSOCKS(ctx, lease.entry.process.config.endpoint, address, lease.entry.process.config.username, lease.entry.process.config.password, keepAlive)
|
|
if err != nil {
|
|
lease.release()
|
|
return nil, err
|
|
}
|
|
return &singBoxConn{Conn: connection, release: lease.release}, nil
|
|
}
|
|
|
|
func dialSingBoxSOCKS(ctx context.Context, endpoint, address, username, password string, keepAlive time.Duration) (_ net.Conn, resultErr error) {
|
|
dialer := net.Dialer{KeepAlive: keepAlive}
|
|
connectDone := Begin(ctx, "sing_box_connect")
|
|
connection, err := dialer.DialContext(ctx, "tcp", endpoint)
|
|
if err != nil {
|
|
resultErr = model.AtConnectionStage("sing_box_connect", err)
|
|
connectDone(resultErr)
|
|
return nil, resultErr
|
|
}
|
|
connectDone(nil)
|
|
good := false
|
|
defer func() {
|
|
if !good {
|
|
_ = connection.Close()
|
|
}
|
|
}()
|
|
stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
|
|
defer stop()
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
_ = connection.SetDeadline(deadline)
|
|
}
|
|
negotiationDone := Begin(ctx, "sing_box_negotiation")
|
|
failNegotiation := func(cause error) (net.Conn, error) {
|
|
failure := model.AtConnectionStage("sing_box_negotiation", cause)
|
|
negotiationDone(failure)
|
|
return nil, failure
|
|
}
|
|
if err := writeSingBoxFrame(connection, []byte{5, 1, 2}); err != nil {
|
|
return failNegotiation(err)
|
|
}
|
|
response := make([]byte, 2)
|
|
if _, err := io.ReadFull(connection, response); err != nil || response[0] != 5 || response[1] != 2 {
|
|
if err == nil {
|
|
err = errors.New("sing-box SOCKS5 method rejected")
|
|
}
|
|
return failNegotiation(err)
|
|
}
|
|
negotiationDone(nil)
|
|
authenticationDone := Begin(ctx, "sing_box_authentication")
|
|
failAuthentication := func(cause error) (net.Conn, error) {
|
|
failure := model.AtConnectionStage("sing_box_authentication", cause)
|
|
authenticationDone(failure)
|
|
return nil, failure
|
|
}
|
|
if len(username) == 0 || len(username) > 255 || len(password) == 0 || len(password) > 255 {
|
|
return failAuthentication(ErrSingBoxConnect)
|
|
}
|
|
auth := make([]byte, 0, 3+len(username)+len(password))
|
|
auth = append(auth, 1, byte(len(username)))
|
|
auth = append(auth, username...)
|
|
auth = append(auth, byte(len(password)))
|
|
auth = append(auth, password...)
|
|
if err := writeSingBoxFrame(connection, auth); err != nil {
|
|
clear(auth)
|
|
return failAuthentication(err)
|
|
}
|
|
clear(auth)
|
|
if _, err := io.ReadFull(connection, response); err != nil || response[0] != 1 || response[1] != 0 {
|
|
if err == nil {
|
|
err = errors.New("sing-box SOCKS5 authentication rejected")
|
|
}
|
|
return failAuthentication(err)
|
|
}
|
|
authenticationDone(nil)
|
|
|
|
targetDone := Begin(ctx, "target_connect")
|
|
failTarget := func(cause error) (net.Conn, error) {
|
|
failure := model.AtConnectionStage("target_connect", cause)
|
|
targetDone(failure)
|
|
return nil, failure
|
|
}
|
|
host, portText, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return failTarget(err)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return failTarget(errors.New("invalid target port"))
|
|
}
|
|
request := []byte{5, 1, 0}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if ipv4 := ip.To4(); ipv4 != nil {
|
|
request = append(request, 1)
|
|
request = append(request, ipv4...)
|
|
} else {
|
|
request = append(request, 4)
|
|
request = append(request, ip.To16()...)
|
|
}
|
|
} else {
|
|
if len(host) == 0 || len(host) > 255 {
|
|
return failTarget(errors.New("invalid target hostname"))
|
|
}
|
|
request = append(request, 3, byte(len(host)))
|
|
request = append(request, host...)
|
|
}
|
|
request = binary.BigEndian.AppendUint16(request, uint16(port))
|
|
if err := writeSingBoxFrame(connection, request); err != nil {
|
|
return failTarget(err)
|
|
}
|
|
header := make([]byte, 4)
|
|
if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 0 || header[2] != 0 {
|
|
if err == nil {
|
|
err = fmt.Errorf("sing-box target connection rejected (code %d)", header[1])
|
|
}
|
|
return failTarget(err)
|
|
}
|
|
addressLength := 0
|
|
switch header[3] {
|
|
case 1:
|
|
addressLength = 4
|
|
case 4:
|
|
addressLength = 16
|
|
case 3:
|
|
var length [1]byte
|
|
if _, err := io.ReadFull(connection, length[:]); err != nil {
|
|
return failTarget(err)
|
|
}
|
|
addressLength = int(length[0])
|
|
default:
|
|
return failTarget(errors.New("invalid sing-box target address"))
|
|
}
|
|
if _, err := io.CopyN(io.Discard, connection, int64(addressLength+2)); err != nil {
|
|
return failTarget(err)
|
|
}
|
|
if !stop() || ctx.Err() != nil {
|
|
cause := ctx.Err()
|
|
if cause == nil {
|
|
cause = context.Canceled
|
|
}
|
|
failure := model.AtConnectionStage("target_connect", cause)
|
|
targetDone(failure)
|
|
if ctx.Err() != nil {
|
|
return nil, failure
|
|
}
|
|
return nil, failure
|
|
}
|
|
targetDone(nil)
|
|
_ = connection.SetDeadline(time.Time{})
|
|
good = true
|
|
return connection, nil
|
|
}
|
|
|
|
func writeSingBoxFrame(writer io.Writer, content []byte) error {
|
|
for len(content) > 0 {
|
|
written, err := writer.Write(content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if written == 0 {
|
|
return io.ErrShortWrite
|
|
}
|
|
content = content[written:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type singBoxConn struct {
|
|
net.Conn
|
|
releaseOnce sync.Once
|
|
release func()
|
|
}
|
|
|
|
func (connection *singBoxConn) Close() error {
|
|
err := connection.Conn.Close()
|
|
connection.releaseOnce.Do(connection.release)
|
|
return err
|
|
}
|
|
|
|
func (connection *singBoxConn) CloseWrite() error {
|
|
if half, ok := connection.Conn.(interface{ CloseWrite() error }); ok {
|
|
return half.CloseWrite()
|
|
}
|
|
return errors.New("sing-box stream does not support half-close")
|
|
}
|