pat/internal/prehook/prehook.go
2026-04-19 17:33:10 +02:00

188 lines
4.6 KiB
Go

// Package prehook implements a connection prehook mechanism, to handle any
// pre-negotiation required by a remote node before the B2F protocol can
// commence (e.g. packet node traversal).
package prehook
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/la5nta/pat/internal/debug"
"github.com/la5nta/pat/internal/directories"
"golang.org/x/sync/errgroup"
)
var ErrConnNotWrapped = errors.New("connection not wrapped for prehook")
type Script struct {
File string
Dir string
Args []string
Env []string
}
// Execute executes the prehook script on a wrapped connection.
//
// ErrConnNotWrapped is returned if conn is not wrapped.
func (s Script) Execute(ctx context.Context, conn net.Conn) error {
if conn, ok := conn.(*Conn); ok {
return conn.Execute(ctx, s)
}
return ErrConnNotWrapped
}
type Conn struct {
net.Conn
br *bufio.Reader
}
// VerifyFile returns nil if the given script file is found and valid.
func (s Script) VerifyFile() error {
p, err := s.Path()
if err != nil {
return err
}
info, err := os.Stat(p)
if err != nil {
return err
}
if info.IsDir() {
return fmt.Errorf("%s: is a directory", s.File)
}
if _, err := exec.LookPath(p); err != nil {
return err
}
return nil
}
func (s Script) Path() (string, error) {
p := filepath.Join(s.Dir, s.File)
if !directories.IsInPath(s.Dir, p) {
return "", fmt.Errorf("%s: escapes base path", p)
}
return p, nil
}
// Wrap returns a wrapped connection with the ability to execute a prehook.
//
// The returned Conn implements the net.Conn interface, and should be used in
// place of the original throughout the lifetime of the connection once the
// prehook script is executed.
func Wrap(conn net.Conn) *Conn {
return &Conn{
Conn: conn,
br: bufio.NewReader(conn),
}
}
func (p *Conn) Read(b []byte) (int, error) { return p.br.Read(b) }
// Execute executes the prehook script, returning nil if the process
// terminated successfully (exit code 0).
func (p *Conn) Execute(ctx context.Context, script Script) error {
name, err := script.Path()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, name, script.Args...)
cmd.Env = script.Env
cmd.Stderr = os.Stderr
cmd.Stdout = p.Conn
cmdStdin, err := cmd.StdinPipe()
if err != nil {
return err
}
debugf("start cmd: %s", cmd)
if err := cmd.Start(); err != nil {
return err
}
g, ctx := errgroup.WithContext(ctx)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
g.Go(func() error { return forwardLines(ctx, cmdStdin, p.br) })
g.Go(func() error { defer cancel(); return cmd.Wait() })
return g.Wait()
}
// forwardLines forwards data from to the spawned process line by line.
//
// The line delimiter is CR or LF, but to facilitate scripting we forward
// each line with LF ending only.
func forwardLines(ctx context.Context, w io.Writer, r *bufio.Reader) error {
// Copy the lines to stdout so the user can see what's going on.
stdinBuffered := bufio.NewWriter(io.MultiWriter(w, os.Stdout))
defer stdinBuffered.Flush()
isDelimiter := func(b byte) bool { return b == '\n' || b == '\r' }
var isPrefix bool // true if we're in the middle of a line
for {
if !isPrefix {
// Peek until the next new line (discard empty lines).
debugf("wait next line")
switch peek, err := r.Peek(1); {
case err != nil:
// Connection lost.
debugf("connection lost while waiting for next line")
return err
case len(peek) > 0 && isDelimiter(peek[0]):
debugf("discard %q", peek)
r.Discard(1)
continue
case ctx.Err() != nil:
// Child process exited before the next line
// arrived. We're done.
debugf("cmd exited while waiting for next line")
return nil
default:
debugf("at next line")
}
}
// Read and forward the byte.
// Replace CR with LF for convenience.
b, err := r.ReadByte()
if err != nil {
// Connection lost.
debugf("connection lost while reading next byte")
return err
}
if b == '\r' {
b = '\n'
}
stdinBuffered.WriteByte(b)
isPrefix = !isDelimiter(b)
if isPrefix {
// Keep going. We're in the middle of a line.
continue
}
// A line was just terminated.
// Flush and wait a bit to check if the process exits.
if err := stdinBuffered.Flush(); err != nil {
return fmt.Errorf("child process exited prematurely: %w", err)
}
select {
case <-time.After(100 * time.Millisecond):
// Child process is still alive. Keep going.
case <-ctx.Done():
// Child process exited. We're done.
return nil
}
}
}
func debugf(format string, args ...interface{}) {
debug.Printf("prehook: "+format, args...)
}