Files

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