Files
pulse/pkg/tlsutil/subprocess_conn.go
T

178 lines
4.1 KiB
Go

package tlsutil
import (
"context"
"fmt"
"io"
"net"
"os/exec"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// subprocessConn wraps a subprocess (nc) as a net.Conn so that Go's HTTP/TLS
// stack can run transparently over a connection that bypasses VPN/NECP routing
// captures affecting the host process.
type subprocessConn struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
addr net.Addr
closeMu sync.Once
deadlineMu sync.Mutex
readTimer *time.Timer
writeTimer *time.Timer
}
func (c *subprocessConn) Read(p []byte) (int, error) { return c.stdout.Read(p) }
func (c *subprocessConn) Write(p []byte) (int, error) { return c.stdin.Write(p) }
func (c *subprocessConn) Close() error {
c.closeMu.Do(func() {
c.stdin.Close()
c.stdout.Close()
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
c.cancelTimers()
})
return nil
}
func (c *subprocessConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *subprocessConn) RemoteAddr() net.Addr { return c.addr }
func (c *subprocessConn) SetDeadline(t time.Time) error {
c.setReadDeadline(t)
c.setWriteDeadline(t)
return nil
}
func (c *subprocessConn) SetReadDeadline(t time.Time) error {
c.setReadDeadline(t)
return nil
}
func (c *subprocessConn) SetWriteDeadline(t time.Time) error {
c.setWriteDeadline(t)
return nil
}
func (c *subprocessConn) setReadDeadline(t time.Time) {
c.deadlineMu.Lock()
defer c.deadlineMu.Unlock()
if c.readTimer != nil {
c.readTimer.Stop()
c.readTimer = nil
}
if t.IsZero() {
return
}
d := time.Until(t)
if d <= 0 {
go c.Close()
return
}
c.readTimer = time.AfterFunc(d, func() { c.Close() })
}
func (c *subprocessConn) setWriteDeadline(t time.Time) {
c.deadlineMu.Lock()
defer c.deadlineMu.Unlock()
if c.writeTimer != nil {
c.writeTimer.Stop()
c.writeTimer = nil
}
if t.IsZero() {
return
}
d := time.Until(t)
if d <= 0 {
go c.Close()
return
}
c.writeTimer = time.AfterFunc(d, func() { c.Close() })
}
func (c *subprocessConn) cancelTimers() {
c.deadlineMu.Lock()
defer c.deadlineMu.Unlock()
if c.readTimer != nil {
c.readTimer.Stop()
c.readTimer = nil
}
if c.writeTimer != nil {
c.writeTimer.Stop()
c.writeTimer = nil
}
}
// dialViaSubprocess spawns nc to establish a TCP connection to address, then
// wraps its stdin/stdout as a net.Conn. The subprocess is not subject to the
// NECP routing policies that capture the host process's direct sockets, so it
// can reach RFC 1918 addresses on macOS even when a VPN system extension is
// active.
func dialViaSubprocess(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, "nc", "-w", "30", host, port)
cmd.Stderr = nil
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("nc stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return nil, fmt.Errorf("nc stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("nc start for %s: %w", address, err)
}
conn := &subprocessConn{
cmd: cmd,
stdin: stdin,
stdout: stdout,
addr: &net.TCPAddr{IP: net.ParseIP(host)},
}
// Detect immediate exit (connection refused, no route, etc.).
// If nc is still running after a brief grace period, the connection
// succeeded and data can flow through the pipes.
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case err := <-waitCh:
stdin.Close()
stdout.Close()
return nil, &net.OpError{
Op: "dial",
Net: network,
Addr: &net.TCPAddr{IP: net.ParseIP(host)},
Err: fmt.Errorf("nc exited immediately for %s: %w", address, err),
}
case <-time.After(50 * time.Millisecond):
log.Debug().Str("addr", address).Msg("subprocess TCP relay established")
go func() {
if err := <-waitCh; err != nil && !strings.Contains(err.Error(), "signal: killed") {
log.Debug().Str("addr", address).Err(err).Msg("subprocess TCP relay exited")
}
}()
return conn, nil
case <-ctx.Done():
conn.Close()
return nil, ctx.Err()
}
}