Files
VoCat/internal/vowifi/ike/userspace_linux.go
2026-08-11 00:22:54 +08:00

693 lines
18 KiB
Go

//go:build linux
package ike
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
)
const userspaceTunnelMTU = 1380
const userspaceTunnelPollInterval = 100 * time.Millisecond
type linuxUserspaceInstaller struct {
ipCommand string
}
type linuxUserspaceHandle struct {
ipCommand string
config ChildSAConfig
tunnel *espTunnel
tun *os.File
tunFD int
relay NATTPacketRelay
runContext context.Context
cancel context.CancelFunc
wait sync.WaitGroup
cancelOnce sync.Once
closeOnce sync.Once
mu sync.Mutex
closed bool
terminalErr error
failures chan error
cleanup []ipCleanupCommand
}
type ipCleanupCommand struct {
operation string
arguments []string
}
func (*linuxUserspaceHandle) DataplaneMode() string { return "userspace" }
func (installer linuxUserspaceInstaller) Install(
ctx context.Context,
config ChildSAConfig,
) (ChildSAHandle, error) {
if ctx == nil {
ctx = context.Background()
}
if config.Relay == nil {
return nil, errors.New("ike: user-space ESP requires a NAT-T packet relay")
}
if !config.UDPEncapsulation {
return nil, errors.New("ike: user-space ESP relay requires negotiated UDP encapsulation")
}
if len(config.PCSCF) == 0 {
return nil, errors.New("ike: user-space ESP requires at least one negotiated P-CSCF address")
}
if err := validateUserspaceRoutes(config); err != nil {
return nil, err
}
command := strings.TrimSpace(installer.ipCommand)
if command == "" {
command = "ip"
}
if _, err := exec.LookPath(command); err != nil {
return nil, errors.New("Linux iproute2 is required to configure the user-space CHILD_SA")
}
tunnel, err := newESPTunnel(config, nil)
if err != nil {
return nil, err
}
tun, actualName, err := openLinuxTUN(config.Name)
if err != nil {
return nil, err
}
config.Name = actualName
runContext, cancel := context.WithCancel(context.Background())
handle := &linuxUserspaceHandle{
ipCommand: command,
config: cloneChildSAConfig(config),
tunnel: tunnel,
tun: tun,
tunFD: int(tun.Fd()),
relay: config.Relay,
runContext: runContext,
cancel: cancel,
failures: make(chan error, 1),
}
if err := handle.configure(ctx); err != nil {
cancel()
handle.cleanupNetwork(context.Background())
_ = tun.Close()
return nil, err
}
handle.wait.Add(2)
go handle.copyTUNToRelay()
go handle.copyRelayToTUN()
return handle, nil
}
func openLinuxTUN(name string) (*os.File, string, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, "", errors.New("ike: TUN interface name is required")
}
request, err := unix.NewIfreq(name)
if err != nil {
return nil, "", fmt.Errorf("ike: invalid TUN interface name: %w", err)
}
request.SetUint16(uint16(unix.IFF_TUN | unix.IFF_NO_PI))
descriptor, err := unix.Open("/dev/net/tun", unix.O_RDWR|unix.O_CLOEXEC, 0)
if err != nil {
return nil, "", fmt.Errorf("ike: open /dev/net/tun: %w", err)
}
if err := unix.IoctlIfreq(descriptor, unix.TUNSETIFF, request); err != nil {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: create TUN interface: %w", err)
}
// A blocking TUN read is not guaranteed to wake when another goroutine
// closes the descriptor on Linux. Keep the descriptor non-blocking and use
// poll below so cancellation can always drain the data-plane workers before
// the interface is released. Without this, a failed session can retain the
// TUN forever and every automatic reconnect fails with EBUSY.
if err := unix.SetNonblock(descriptor, true); err != nil {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: make TUN interface cancellable: %w", err)
}
file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name())
if file == nil {
_ = unix.Close(descriptor)
return nil, "", errors.New("ike: create TUN file handle")
}
return file, request.Name(), nil
}
func validateUserspaceRoutes(config ChildSAConfig) error {
if config.InnerLocalIPv4 == nil && config.InnerLocalIPv6 == nil {
return errors.New("ike: user-space ESP requires an assigned inner address")
}
validLocal := func(ip net.IP) bool {
return ip != nil &&
!ip.IsUnspecified() &&
!ip.IsMulticast() &&
ipAllowedBySelectors(ip, config.InitiatorSelectors)
}
if config.InnerLocalIPv4 != nil && !validLocal(config.InnerLocalIPv4) {
return errors.New("ike: assigned inner IPv4 address is outside initiator traffic selectors")
}
if config.InnerLocalIPv6 != nil && !validLocal(config.InnerLocalIPv6) {
return errors.New("ike: assigned inner IPv6 address is outside initiator traffic selectors")
}
matchingFamily := false
for _, pcscf := range config.PCSCF {
if pcscf == nil || pcscf.IsUnspecified() || pcscf.IsMulticast() {
return errors.New("ike: P-CSCF address is invalid")
}
if !ipAllowedBySelectors(pcscf, config.ResponderSelectors) {
return fmt.Errorf("ike: P-CSCF %s is outside responder traffic selectors", pcscf)
}
if (pcscf.To4() != nil && config.InnerLocalIPv4 != nil) ||
(pcscf.To4() == nil && pcscf.To16() != nil && config.InnerLocalIPv6 != nil) {
matchingFamily = true
}
}
if !matchingFamily {
return errors.New("ike: no P-CSCF address matches an assigned inner address family")
}
return nil
}
func ipAllowedBySelectors(ip net.IP, selectors []trafficSelector) bool {
for _, selector := range selectors {
if ipWithinRange(ip, selector.StartIP, selector.EndIP) {
return true
}
}
return false
}
func (handle *linuxUserspaceHandle) configure(ctx context.Context) error {
name := handle.config.Name
if handle.config.InnerLocalIPv4 != nil {
if err := handle.run(
ctx,
"assign TUN IPv4 address",
"-4", "address", "add",
handle.config.InnerLocalIPv4.String()+"/32",
"dev", name,
"noprefixroute",
); err != nil {
return err
}
}
if handle.config.InnerLocalIPv6 != nil {
prefix := handle.config.InnerIPv6Prefix
if prefix == 0 || prefix > 128 {
prefix = 128
}
if err := handle.run(
ctx,
"assign TUN IPv6 address",
"-6", "address", "add",
fmt.Sprintf("%s/%d", handle.config.InnerLocalIPv6.String(), prefix),
"dev", name,
"noprefixroute",
); err != nil {
return err
}
}
if err := handle.run(
ctx,
"enable TUN interface",
"link", "set", "dev", name,
"mtu", strconv.Itoa(userspaceTunnelMTU),
"up",
); err != nil {
return err
}
table, priority := userspaceRoutingIdentifiers(handle.config.InboundSPI)
if handle.config.InnerLocalIPv4 != nil {
if err := handle.configureFamily(
ctx,
"-4",
handle.config.InnerLocalIPv4,
handle.ipv4PCSCF(),
32,
table,
priority,
); err != nil {
return err
}
}
if handle.config.InnerLocalIPv6 != nil {
if err := handle.configureFamily(
ctx,
"-6",
handle.config.InnerLocalIPv6,
handle.ipv6PCSCF(),
128,
table,
priority,
); err != nil {
return err
}
}
return nil
}
func (handle *linuxUserspaceHandle) configureFamily(
ctx context.Context,
family string,
local net.IP,
pcscf []net.IP,
bits int,
table uint32,
priority uint32,
) error {
if len(pcscf) == 0 {
return nil
}
tableValue := strconv.FormatUint(uint64(table), 10)
priorityValue := strconv.FormatUint(uint64(priority), 10)
localPrefix := fmt.Sprintf("%s/%d", local.String(), bits)
if err := handle.requireUnusedRoutingSlot(
ctx,
family,
tableValue,
priorityValue,
); err != nil {
return err
}
ruleArguments := []string{
family, "rule", "add",
"priority", priorityValue,
"from", localPrefix,
"lookup", tableValue,
}
if err := handle.run(ctx, "install fail-closed source rule", ruleArguments...); err != nil {
return err
}
handle.recordCleanup(
"remove fail-closed source rule",
family, "rule", "delete",
"priority", priorityValue,
"from", localPrefix,
"lookup", tableValue,
)
unreachableArguments := []string{
family, "route", "add",
"table", tableValue,
"unreachable", "default",
}
if err := handle.run(ctx, "install fail-closed route", unreachableArguments...); err != nil {
return err
}
handle.recordCleanup(
"remove fail-closed route",
family, "route", "delete",
"table", tableValue,
"unreachable", "default",
)
for _, address := range pcscf {
hostPrefix := fmt.Sprintf("%s/%d", address.String(), bits)
routeArguments := []string{
family, "route", "add",
"table", tableValue,
hostPrefix,
"dev", handle.config.Name,
"src", local.String(),
}
if err := handle.run(ctx, "install P-CSCF host route", routeArguments...); err != nil {
return err
}
handle.recordCleanup(
"remove P-CSCF host route",
family, "route", "delete",
"table", tableValue,
hostPrefix,
"dev", handle.config.Name,
"src", local.String(),
)
}
return nil
}
func userspaceRoutingIdentifiers(spi uint32) (table uint32, priority uint32) {
table = spi
if table <= 255 {
table |= 0x80000000
}
// Linux evaluates policy rules from the lowest numeric priority upward.
// The built-in main/default rules are 32766/32767, so a full-width SPI
// used directly as the priority would usually run too late and leak the
// inner source through the host's default route. Keep a SPI-derived slot
// strictly ahead of main; requireUnusedRoutingSlot rejects collisions.
priority = 10000 + spi%20000
return table, priority
}
func (handle *linuxUserspaceHandle) requireUnusedRoutingSlot(
ctx context.Context,
family string,
table string,
priority string,
) error {
routeCommand := exec.CommandContext(
ctx,
handle.ipCommand,
family, "-j", "route", "show", "table", "all",
)
routeOutput, routeErr := routeCommand.CombinedOutput()
if routeErr != nil {
message := strings.TrimSpace(string(routeOutput))
if message == "" {
message = routeErr.Error()
}
return fmt.Errorf("ike: inspect routing table %s: %s", table, message)
}
var routes []map[string]any
if err := json.Unmarshal(routeOutput, &routes); err != nil {
return fmt.Errorf("ike: parse Linux routing table inventory: %w", err)
}
for _, route := range routes {
value, exists := route["table"]
if !exists {
continue
}
if routingTableValue(value) == table {
return fmt.Errorf("ike: routing table %s is already in use", table)
}
}
ruleCommand := exec.CommandContext(ctx, handle.ipCommand, family, "rule", "show")
ruleOutput, err := ruleCommand.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(ruleOutput))
if message == "" {
message = err.Error()
}
return fmt.Errorf("ike: inspect policy rules: %s", message)
}
prefix := priority + ":"
for _, line := range strings.Split(string(ruleOutput), "\n") {
fields := strings.Fields(line)
if strings.HasPrefix(strings.TrimSpace(line), prefix) ||
containsAdjacentFields(fields, "lookup", table) {
return fmt.Errorf("ike: policy rule priority %s is already in use", priority)
}
}
return nil
}
func routingTableValue(value any) string {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed <= float64(^uint32(0)) {
return strconv.FormatUint(uint64(typed), 10)
}
case string:
return typed
}
return ""
}
func containsAdjacentFields(fields []string, first string, second string) bool {
for index := 0; index+1 < len(fields); index++ {
if fields[index] == first && fields[index+1] == second {
return true
}
}
return false
}
func (handle *linuxUserspaceHandle) ipv4PCSCF() []net.IP {
var result []net.IP
seen := make(map[string]struct{})
for _, address := range handle.config.PCSCF {
if address.To4() != nil {
if _, duplicate := seen[address.String()]; duplicate {
continue
}
result = append(result, append(net.IP(nil), address...))
seen[address.String()] = struct{}{}
}
}
return result
}
func (handle *linuxUserspaceHandle) ipv6PCSCF() []net.IP {
var result []net.IP
seen := make(map[string]struct{})
for _, address := range handle.config.PCSCF {
if address.To4() == nil && address.To16() != nil {
if _, duplicate := seen[address.String()]; duplicate {
continue
}
result = append(result, append(net.IP(nil), address...))
seen[address.String()] = struct{}{}
}
}
return result
}
func (handle *linuxUserspaceHandle) run(
ctx context.Context,
operation string,
arguments ...string,
) error {
command := exec.CommandContext(ctx, handle.ipCommand, arguments...)
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf("ike: %s: %s", operation, message)
}
return nil
}
func (handle *linuxUserspaceHandle) recordCleanup(operation string, arguments ...string) {
handle.cleanup = append(handle.cleanup, ipCleanupCommand{
operation: operation,
arguments: append([]string(nil), arguments...),
})
}
func (handle *linuxUserspaceHandle) copyTUNToRelay() {
defer handle.wait.Done()
buffer := make([]byte, 65535)
for {
count, err := readTUNPacket(handle.runContext, handle.tunFD, buffer)
if err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: read TUN packet: %w", err))
}
return
}
protected, err := handle.tunnel.seal(buffer[:count])
if err != nil {
// The kernel may emit IPv6 DAD/link-local traffic when the TUN is
// brought up, and local processes may attempt unrelated routes.
// Traffic-selector enforcement is a filter, not a session failure.
if errors.Is(err, errESPPolicyDrop) {
continue
}
handle.fail(err)
return
}
if err := handle.relay.SendESP(handle.runContext, protected); err != nil {
if handle.runContext.Err() == nil {
handle.fail(fmt.Errorf("ike: relay outbound ESP: %w", err))
}
return
}
}
}
func (handle *linuxUserspaceHandle) copyRelayToTUN() {
defer handle.wait.Done()
buffer := make([]byte, 65535)
for {
count, err := handle.relay.ReceiveESP(handle.runContext, buffer)
if err != nil {
if handle.runContext.Err() == nil {
handle.fail(fmt.Errorf("ike: relay inbound ESP: %w", err))
}
return
}
cleartext, err := handle.tunnel.open(buffer[:count])
if err != nil {
// Invalid ICVs, replays, malformed padding, and packets outside the
// negotiated selectors are untrusted network input. Drop them
// without allowing a forged datagram to tear down the CHILD_SA.
continue
}
if err := writeTUNPacket(handle.runContext, handle.tunFD, cleartext); err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: write TUN packet: %w", err))
}
return
}
}
}
func readTUNPacket(ctx context.Context, descriptor int, buffer []byte) (int, error) {
for {
if err := ctx.Err(); err != nil {
return 0, err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLIN)
if err != nil {
return 0, err
}
if !ready {
continue
}
count, err := unix.Read(descriptor, buffer)
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
return count, err
}
}
func writeTUNPacket(ctx context.Context, descriptor int, packet []byte) error {
for written := 0; written < len(packet); {
if err := ctx.Err(); err != nil {
return err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLOUT)
if err != nil {
return err
}
if !ready {
continue
}
count, err := unix.Write(descriptor, packet[written:])
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
if err != nil {
return err
}
if count == 0 {
return errors.New("ike: zero-length TUN write")
}
written += count
}
return nil
}
func pollTUN(ctx context.Context, descriptor int, events int16) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
poll := []unix.PollFd{{Fd: int32(descriptor), Events: events}}
count, err := unix.Poll(poll, int(userspaceTunnelPollInterval/time.Millisecond))
if errors.Is(err, unix.EINTR) {
return false, nil
}
if err != nil {
return false, err
}
if count == 0 {
return false, nil
}
if poll[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, os.ErrClosed
}
return poll[0].Revents&events != 0, nil
}
func (handle *linuxUserspaceHandle) fail(err error) {
handle.mu.Lock()
notify := false
if handle.terminalErr == nil {
handle.terminalErr = err
notify = true
}
handle.mu.Unlock()
if notify {
select {
case handle.failures <- err:
default:
}
}
handle.cancelRun()
}
func (handle *linuxUserspaceHandle) Failures() <-chan error {
return handle.failures
}
func (handle *linuxUserspaceHandle) cancelRun() {
handle.cancelOnce.Do(func() {
handle.cancel()
})
}
func (handle *linuxUserspaceHandle) closeTUN() {
handle.closeOnce.Do(func() {
_ = handle.tun.Close()
})
}
func (handle *linuxUserspaceHandle) Close(ctx context.Context) error {
handle.mu.Lock()
if handle.closed {
handle.mu.Unlock()
return nil
}
handle.closed = true
handle.mu.Unlock()
handle.cancelRun()
// Workers use a non-blocking, polled TUN descriptor and therefore leave on
// cancellation without requiring a cross-goroutine close. Wait first so no
// blocked syscall can retain the interface after Close returns.
handle.wait.Wait()
cleanupErr := handle.cleanupNetwork(ctx)
handle.closeTUN()
// A terminal data-plane error is delivered exactly once through Failures.
// Close reports only teardown errors so the orchestrator does not record
// the same runtime cause again as a cleanup failure.
return cleanupErr
}
func (handle *linuxUserspaceHandle) cleanupNetwork(ctx context.Context) error {
if ctx == nil || ctx.Err() != nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var errs []error
for index := len(handle.cleanup) - 1; index >= 0; index-- {
item := handle.cleanup[index]
command := exec.CommandContext(ctx, handle.ipCommand, item.arguments...)
if output, err := command.CombinedOutput(); err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
errs = append(errs, fmt.Errorf("ike: %s: %s", item.operation, message))
}
}
handle.cleanup = nil
return errors.Join(errs...)
}
var _ ChildSAInstaller = linuxUserspaceInstaller{}
var _ ChildSAHandle = (*linuxUserspaceHandle)(nil)
var _ DataplaneEvidence = (*linuxUserspaceHandle)(nil)
var _ DataplaneFailureNotifier = (*linuxUserspaceHandle)(nil)