mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
ac3fc12077
* fix(ports): refuse an inbound on a port an AmneziaWG peer forwards checkForwardedPortsConflict only ever ran from the AmneziaWG save path, and only in one direction: an AmneziaWG client's forwardedPorts were checked against the ports other inbounds already hold, while the reverse -- an ordinary inbound saved onto a port some peer forwards -- had no guard at all. The forward listener binds that port on every interface in both directions (amneziawgnet/portfwd.go's attachTCP/attachUDP), so the two listeners want the same socket: the loser either leaves the peer's forward silently dead or fails the inbound's listen. checkPortConflictTx now resolves that owner the same way the relay-slot checks do -- same host, peers derived from the stored settings with the shared InstanceFromInbound -- and names the peer in the refusal. Sitting inside checkPortConflictTx covers both the save and the enable path added in #6549. TestAddInboundRefusesAPortAnAmneziaWGPeerForwards fails without this -- watched red, the create is allowed -- and its node-row companion pins the scoping that keeps a node row legal on a locally forwarded port. * fix(ports): name only a peer that binds as the owner of a forwarded port The owner lookup read instance.Peers and ForwardedPortsInclude directly, so a peer the forward supervisor skips (no email, or no address the tunnel routes to) was reported as holding a port nothing binds -- refusing a create that is legal with a message naming a row whose own port is its WireGuard one. It also repeated the candidate's listen address as the forward's location, though the forward binds :port on every interface. Share the supervisor's own gate through amneziawgnet.ForwardedPortOwner, report the wildcard bind, and propagate a failed owner query instead of reading it as "no conflict", matching the sibling checks in the same file. * style(ports): keep the forwarded-key doc block within the 2-line cap The reworded desiredPortForwardKeys doc ran to three lines, against the rule this repo sets for committed Go comments.
360 lines
13 KiB
Go
360 lines
13 KiB
Go
// Phase 3.6: per-client port-forwarding. A real Go listener bound to each
|
|
// forwarded external port relays into the peer's own tunnel-internal
|
|
// address via a direct gonet dial -- the mirror image of
|
|
// AttachTCPForwarder/AttachUDPHandler (which relay FROM the tunnel TO the
|
|
// real world), and this path's replacement for the retired kernel-module
|
|
// architecture's PostUp/PostDown iptables DNAT rules: there's no real OS
|
|
// network interface here for DNAT to rewrite packets on, the same root
|
|
// reason Phase 3.5's IPv6 alias mechanism couldn't reuse NDP-proxy either.
|
|
//
|
|
// Deliberately dials straight into the gVisor stack rather than relaying
|
|
// through Xray's own SOCKS5 inbound the way the outbound direction does
|
|
// (relay.go): Xray runs as a genuinely separate OS process
|
|
// (internal/xray/process.go), so it has no visibility into this process's
|
|
// private, in-memory netstack at all -- a tunnel-internal address like
|
|
// 10.8.1.5:8080 has no route from Xray's own freedom outbound; only code
|
|
// holding the actual *stack.Stack can reach it. Accepted consequence:
|
|
// forwarded-port bytes don't appear in Xray's per-email stats/quota
|
|
// counters. This undercounts, it doesn't bypass enforcement -- a
|
|
// depleted/disabled client's peer is dropped from the interface's peer list
|
|
// entirely by DesiredAmneziaWGInstances, which tears its forwards down too
|
|
// as a side effect of Reconcile's own diff below.
|
|
package amneziawgnet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"sync"
|
|
"time"
|
|
|
|
"gvisor.dev/gvisor/pkg/tcpip"
|
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
|
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
|
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
|
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// portForwardProto distinguishes the two sockets a single forwarded port
|
|
// needs -- ForwardedPorts has no per-port protocol selector (matches the
|
|
// retired DNAT implementation's own unconditional-TCP+UDP contract), so
|
|
// every port gets both.
|
|
type portForwardProto uint8
|
|
|
|
const (
|
|
tcpForward portForwardProto = iota
|
|
udpForward
|
|
)
|
|
|
|
// portForwardKey identifies one listener: a specific peer's specific port on
|
|
// a specific protocol. Two different peers (even on the same inbound)
|
|
// forwarding the same port number get two independent listeners under two
|
|
// independent keys -- a same-port collision surfaces as an ordinary bind
|
|
// failure on whichever one opens second, not something actively prevented
|
|
// here (see the migration plan's Phase 3.6 notes).
|
|
type portForwardKey struct {
|
|
email string
|
|
port int
|
|
proto portForwardProto
|
|
}
|
|
|
|
// portForwardTargetFunc resolves a peer's current tunnel-internal target
|
|
// address by email, re-checked on every new connection/session rather than
|
|
// captured once at listen time -- so a peer re-IP takes effect for the next
|
|
// connection with zero listener churn (see Reconcile's own comment on
|
|
// this). false means the peer has no resolvable target right now (removed,
|
|
// or its AllowedIPs/ForwardedPorts changed): the caller drops the
|
|
// connection/packet, and Reconcile will close the now-undesired listener
|
|
// shortly after, if it hasn't already.
|
|
type portForwardTargetFunc func(email string) (netip.Addr, bool)
|
|
|
|
// portForwardListener is the common handle both listenPortForwardTCP and
|
|
// listenPortForwardUDP return, so PortForwardSet can hold either behind one
|
|
// map value type without a type switch.
|
|
type portForwardListener interface {
|
|
Close()
|
|
}
|
|
|
|
// PortForwardSet owns every open port-forward listener for one embedded
|
|
// AmneziaWG interface (one per amneziawgnet managed entry -- see
|
|
// manager.go). Unlike v6alias.go's stateless desired/diff/apply functions,
|
|
// this holds live Go resources (net.Listener/net.PacketConn) that must be
|
|
// explicitly closed -- there's no OS-level idempotent recreate the way
|
|
// `ip addr add` has -- so Reconcile diffs against its own live listeners
|
|
// map directly instead of a remembered prior Instance.
|
|
type PortForwardSet struct {
|
|
gstack *stack.Stack
|
|
inboundID int
|
|
|
|
mu sync.Mutex
|
|
peerTargets map[string]netip.Addr
|
|
listeners map[portForwardKey]portForwardListener
|
|
}
|
|
|
|
// NewPortForwardSet creates an empty supervisor for one embedded interface's
|
|
// stack. Call Reconcile to actually open any listeners.
|
|
func NewPortForwardSet(gstack *stack.Stack, inboundID int) *PortForwardSet {
|
|
return &PortForwardSet{
|
|
gstack: gstack,
|
|
inboundID: inboundID,
|
|
peerTargets: map[string]netip.Addr{},
|
|
listeners: map[portForwardKey]portForwardListener{},
|
|
}
|
|
}
|
|
|
|
// desiredPeerTargets resolves each peer's tunnel-internal target address:
|
|
// the first IPv4 AllowedIPs entry, falling back to the first IPv6 entry only
|
|
// when no v4 entry exists and the instance has IPv6 enabled (mirrors
|
|
// desiredV6Aliases' own gating in v6alias.go -- no v6 route exists on the
|
|
// stack otherwise). A peer with no resolvable address at all (neither
|
|
// family, or an unparseable entry) is simply absent from the result.
|
|
func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
|
|
out := map[string]netip.Addr{}
|
|
for _, p := range inst.Peers {
|
|
if p.Email == "" {
|
|
continue
|
|
}
|
|
raw := amneziawg.FirstIPv4(p.AllowedIPs)
|
|
if raw == "" && inst.IPv6Enabled {
|
|
raw = amneziawg.FirstIPv6(p.AllowedIPs)
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
addr, err := netip.ParseAddr(raw)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out[p.Email] = addr
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardingPeers is the one gate a host listener comes from: no email, port
|
|
// spec and resolvable target (see desiredPeerTargets), no socket.
|
|
func forwardingPeers(inst amneziawg.Instance) []amneziawg.Peer {
|
|
targets := desiredPeerTargets(inst)
|
|
out := make([]amneziawg.Peer, 0, len(inst.Peers))
|
|
for _, p := range inst.Peers {
|
|
if p.Email == "" || p.ForwardedPorts == "" {
|
|
continue
|
|
}
|
|
if _, ok := targets[p.Email]; !ok {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// desiredPortForwardKeys returns every listener key inst wants right now: one
|
|
// tcpForward and one udpForward per forwarded port of the forwarding peers.
|
|
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
|
|
out := map[portForwardKey]struct{}{}
|
|
for _, p := range forwardingPeers(inst) {
|
|
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
|
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
|
|
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ForwardedPortOwner names the peer Reconcile opens a listener on port for --
|
|
// the same peers and expansion as desiredPortForwardKeys, never a silent one.
|
|
func ForwardedPortOwner(inst amneziawg.Instance, port int) (string, bool) {
|
|
for _, p := range forwardingPeers(inst) {
|
|
for _, candidate := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
|
|
if candidate == port {
|
|
return p.Email, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// Reconcile brings the supervisor's open listeners in line with what inst
|
|
// currently wants: closes anything no longer desired, opens anything newly
|
|
// desired, leaves everything else untouched. Never returns an error --
|
|
// matches applyV6Aliases' contract exactly: one listener failing to bind
|
|
// only narrows that specific forward, never a reason to fail the whole
|
|
// reconcile.
|
|
func (s *PortForwardSet) Reconcile(inst amneziawg.Instance) {
|
|
wantTargets := desiredPeerTargets(inst)
|
|
wantKeys := desiredPortForwardKeys(inst)
|
|
|
|
s.mu.Lock()
|
|
s.peerTargets = wantTargets
|
|
|
|
var toClose []portForwardListener
|
|
for key, ln := range s.listeners {
|
|
if _, ok := wantKeys[key]; ok {
|
|
continue
|
|
}
|
|
toClose = append(toClose, ln)
|
|
delete(s.listeners, key)
|
|
}
|
|
var toOpen []portForwardKey
|
|
for key := range wantKeys {
|
|
if _, ok := s.listeners[key]; ok {
|
|
continue
|
|
}
|
|
toOpen = append(toOpen, key)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Outside the lock: closing/opening real sockets shouldn't block a
|
|
// concurrent targetFor lookup from an in-flight connection on some
|
|
// other, unaffected listener.
|
|
for _, ln := range toClose {
|
|
ln.Close()
|
|
}
|
|
for _, key := range toOpen {
|
|
ln := openPortForwardListener(s.gstack, s.inboundID, key, s.targetFor)
|
|
if ln == nil {
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
s.listeners[key] = ln
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// targetFor implements portForwardTargetFunc against the supervisor's
|
|
// current peerTargets snapshot.
|
|
func (s *PortForwardSet) targetFor(email string) (netip.Addr, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
addr, ok := s.peerTargets[email]
|
|
return addr, ok
|
|
}
|
|
|
|
// Close tears down every open listener. Call when the owning Device is
|
|
// closed (or rebuilt -- see manager.go's ensureLocked, which always
|
|
// constructs a fresh PortForwardSet alongside a fresh Device.Stack, the
|
|
// same reason it also rebuilds udpRelay from scratch rather than reusing
|
|
// one bound to a discarded stack).
|
|
func (s *PortForwardSet) Close() {
|
|
s.mu.Lock()
|
|
listeners := s.listeners
|
|
s.listeners = map[portForwardKey]portForwardListener{}
|
|
s.mu.Unlock()
|
|
for _, ln := range listeners {
|
|
ln.Close()
|
|
}
|
|
}
|
|
|
|
// openPortForwardListener dispatches to the protocol-specific opener and
|
|
// normalizes its result to a real nil interface value on failure -- a
|
|
// (*tcpForwardListener)(nil) (or *udpForwardListener(nil)) wrapped directly
|
|
// into the portForwardListener interface would be a non-nil interface
|
|
// holding a nil pointer, Go's classic trap, so the concrete pointer is
|
|
// checked before it's ever assigned into the interface-typed return.
|
|
func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) portForwardListener {
|
|
switch key.proto {
|
|
case tcpForward:
|
|
if ln := listenPortForwardTCP(gstack, inboundID, key, target); ln != nil {
|
|
return ln
|
|
}
|
|
case udpForward:
|
|
if ln := listenPortForwardUDP(gstack, inboundID, key, target); ln != nil {
|
|
return ln
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const portForwardDialTimeout = 10 * time.Second
|
|
|
|
// tunnelNetwork returns the gVisor network protocol number matching addr's
|
|
// address family, for dialing toward it inside the embedded stack.
|
|
func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber {
|
|
if addr.Is4() {
|
|
return ipv4.ProtocolNumber
|
|
}
|
|
return ipv6.ProtocolNumber
|
|
}
|
|
|
|
// tunnelFullAddress builds the tcpip.FullAddress a gonet dial needs to
|
|
// reach addr:port inside the embedded stack -- NIC 1, matching
|
|
// createNetTUNWithStack's own CreateNIC(1, ...) (this package's stack only
|
|
// ever registers one NIC, and WriteUDPReply's WriteRawPacket already
|
|
// addresses it explicitly the same way elsewhere in this package, rather
|
|
// than relying on NIC 0's route-table auto-selection).
|
|
func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress {
|
|
return tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(addr.AsSlice()), Port: uint16(port)}
|
|
}
|
|
|
|
// tcpForwardListener is one open host-facing TCP listener for a single
|
|
// portForwardKey.
|
|
type tcpForwardListener struct {
|
|
ln net.Listener
|
|
closing chan struct{}
|
|
}
|
|
|
|
// listenPortForwardTCP opens a host-facing TCP listener on key.port and
|
|
// starts relaying accepted connections into the tunnel toward
|
|
// target(key.email). A bind failure (most commonly EADDRINUSE, whether from
|
|
// an unrelated process or another AmneziaWG peer/inbound that already
|
|
// claimed the same port) is logged and returns nil; Reconcile treats a nil
|
|
// result as "not open this round" and retries on every future Reconcile
|
|
// call for as long as the key stays desired.
|
|
func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *tcpForwardListener {
|
|
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf(":%d", key.port))
|
|
if err != nil {
|
|
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
|
|
return nil
|
|
}
|
|
l := &tcpForwardListener{ln: ln, closing: make(chan struct{})}
|
|
logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
|
|
go l.acceptLoop(gstack, inboundID, key, target)
|
|
return l
|
|
}
|
|
|
|
func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
|
|
for {
|
|
conn, err := l.ln.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-l.closing:
|
|
return // intentional shutdown, not a real accept error
|
|
default:
|
|
}
|
|
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err)
|
|
return
|
|
}
|
|
go relayTCPForward(gstack, conn, inboundID, key, target)
|
|
}
|
|
}
|
|
|
|
func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
|
|
defer conn.Close()
|
|
addr, ok := target(key.email)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
|
|
defer cancel()
|
|
tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
|
|
if err != nil {
|
|
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
|
|
return
|
|
}
|
|
defer tunnelConn.Close()
|
|
|
|
pipeBothWays(conn, tunnelConn)
|
|
}
|
|
|
|
// Close stops accepting new connections. Already-relaying connections are
|
|
// left to finish on their own -- there's no shared state to tear down early
|
|
// for, and an abrupt cut would just look like a network error to whichever
|
|
// external client was mid-transfer.
|
|
func (l *tcpForwardListener) Close() {
|
|
close(l.closing)
|
|
l.ln.Close()
|
|
}
|