diff --git a/component/dns/dns.go b/component/dns/dns.go index 9800416d3b..2ecf1f48d8 100644 --- a/component/dns/dns.go +++ b/component/dns/dns.go @@ -67,7 +67,7 @@ func New(dns *config.Dns, opt *NewOption) (s *Dns, err error) { Network: opt.UpstreamResolverNetwork, FinishInitCallback: func(i int) func(raw *url.URL, upstream *Upstream) (err error) { return func(raw *url.URL, upstream *Upstream) (err error) { - if opt != nil && opt.UpstreamReadyCallback != nil { + if opt != nil && opt.UpstreamReadyCallback != nil && upstream.Scheme != UpstreamScheme_Fakeip { if err = opt.UpstreamReadyCallback(upstream); err != nil { return err } diff --git a/component/dns/upstream.go b/component/dns/upstream.go index d1fbf6c042..6ea6b0719c 100644 --- a/component/dns/upstream.go +++ b/component/dns/upstream.go @@ -19,9 +19,7 @@ import ( "github.com/daeuniverse/outbound/protocol/direct" ) -var ( - ErrFormat = fmt.Errorf("format error") -) +var ErrFormat = fmt.Errorf("format error") type UpstreamScheme string @@ -35,6 +33,7 @@ const ( UpstreamScheme_HTTPS UpstreamScheme = "https" upstreamScheme_H3_Alias UpstreamScheme = "http3" UpstreamScheme_H3 UpstreamScheme = "h3" + UpstreamScheme_Fakeip UpstreamScheme = "fakeip" ) func (s UpstreamScheme) ContainsTcp() bool { @@ -76,6 +75,11 @@ func ParseRawUpstream(raw *url.URL) (scheme UpstreamScheme, hostname string, por if __port == "" { __port = "853" } + case UpstreamScheme_Fakeip: + __port = raw.Port() + if __port == "" { + __port = "53" + } default: return "", "", 0, "", fmt.Errorf("unexpected scheme: %v", raw.Scheme) } @@ -139,7 +143,7 @@ func (u *Upstream) SupportedNetworks() (ipversions []consts.IpVersionStr, l4prot switch u.Scheme { case UpstreamScheme_TCP, UpstreamScheme_HTTPS, UpstreamScheme_TLS: l4protos = []consts.L4ProtoStr{consts.L4ProtoStr_TCP} - case UpstreamScheme_UDP, UpstreamScheme_QUIC, UpstreamScheme_H3: + case UpstreamScheme_UDP, UpstreamScheme_QUIC, UpstreamScheme_H3, UpstreamScheme_Fakeip: l4protos = []consts.L4ProtoStr{consts.L4ProtoStr_UDP} case UpstreamScheme_TCP_UDP: // UDP first. diff --git a/control/control_plane.go b/control/control_plane.go index 6c84a465be..014cf76d8f 100644 --- a/control/control_plane.go +++ b/control/control_plane.go @@ -147,7 +147,7 @@ func NewControlPlane( return nil, fmt.Errorf("failed to setup dae netns: %w", err) } pinPath := filepath.Join(consts.BpfPinRoot, consts.AppName) - if err = os.MkdirAll(pinPath, 0755); err != nil && !os.IsExist(err) { + if err = os.MkdirAll(pinPath, 0o755); err != nil && !os.IsExist(err) { if os.IsNotExist(err) { log.Warnln("Perhaps you are in a container environment (such as lxc). If so, please use higher virtualization (kvm/qemu).") } @@ -901,8 +901,6 @@ func (c *ControlPlane) chooseBestDnsDialer( req *udpRequest, dnsUpstream *dns.Upstream, ) (*dialArgument, error) { - /// Choose the best l4proto+ipversion dialer, and change taregt DNS to the best ipversion DNS upstream for DNS request. - // Get available ipversions and l4protos for DNS upstream. ipversions, l4protos := dnsUpstream.SupportedNetworks() var ( bestLatency time.Duration diff --git a/control/dns.go b/control/dns.go index 5d9818e92d..250b54e45c 100644 --- a/control/dns.go +++ b/control/dns.go @@ -1,7 +1,7 @@ /* * SPDX-License-Identifier: AGPL-3.0-only * Copyright (c) 2022-2025, daeuniverse Organization -*/ + */ package control @@ -55,6 +55,10 @@ func newDnsForwarder(upstream *dns.Upstream, dialArgument dialArgument) (DnsForw return &DoQ{Upstream: *upstream, Dialer: dialArgument.bestDialer, dialArgument: dialArgument}, nil case dns.UpstreamScheme_H3: return &DoH{Upstream: *upstream, Dialer: dialArgument.bestDialer, dialArgument: dialArgument, http3: true}, nil + case dns.UpstreamScheme_Fakeip: + return &DoFakeip{ + pool: GetGlobalFakeipPool(), + }, nil default: return nil, fmt.Errorf("unexpected scheme: %v", upstream.Scheme) } @@ -202,8 +206,8 @@ func (d *DoQ) ForwardDNS(ctx context.Context, data []byte) (*dnsmessage.Msg, err } return msg, nil } -func (d *DoQ) createConnection(ctx context.Context) (quic.EarlyConnection, error) { +func (d *DoQ) createConnection(ctx context.Context) (quic.EarlyConnection, error) { udpAddr := net.UDPAddrFromAddrPort(d.dialArgument.bestTarget) conn, err := d.dialArgument.bestDialer.DialContext( ctx, @@ -226,7 +230,6 @@ func (d *DoQ) createConnection(ctx context.Context) (quic.EarlyConnection, error return nil, err } return qc, nil - } func (d *DoQ) Close() error { @@ -440,3 +443,59 @@ func sendStreamDNS(stream io.ReadWriter, data []byte) (respMsg *dnsmessage.Msg, } return &msg, nil } + +type DoFakeip struct { + pool *fakeipPool +} + +func (d *DoFakeip) ForwardDNS(ctx context.Context, data []byte) (*dnsmessage.Msg, error) { + var query dnsmessage.Msg + if err := query.Unpack(data); err != nil { + return nil, fmt.Errorf("failed to unpack DNS query: %w", err) + } + + if len(query.Question) == 0 { + return nil, fmt.Errorf("no questions in DNS query") + } + + question := query.Question[0] + domain := question.Name + + resp := &dnsmessage.Msg{ + MsgHdr: dnsmessage.MsgHdr{ + Id: query.Id, + Response: true, + Opcode: query.Opcode, + Authoritative: false, + Truncated: false, + RecursionDesired: query.RecursionDesired, + RecursionAvailable: true, + Rcode: dnsmessage.RcodeSuccess, + }, + Question: query.Question, + } + + switch question.Qtype { + case dnsmessage.TypeA: + fakeIP := d.pool.allocate(domain) + resp.Answer = append(resp.Answer, &dnsmessage.A{ + Hdr: dnsmessage.RR_Header{ + Name: question.Name, + Rrtype: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + Ttl: 300, + }, + A: fakeIP, + }) + case dnsmessage.TypeAAAA: + resp.Rcode = dnsmessage.RcodeSuccess + default: + resp.Rcode = dnsmessage.RcodeNameError + } + + return resp, nil +} + +func (d *DoFakeip) Close() error { + return nil +} diff --git a/control/fakeip_pool.go b/control/fakeip_pool.go new file mode 100644 index 0000000000..3b811f23b2 --- /dev/null +++ b/control/fakeip_pool.go @@ -0,0 +1,193 @@ +/* +* SPDX-License-Identifier: AGPL-3.0-only +* Copyright (c) 2022-2025, daeuniverse Organization + */ + +package control + +import ( + "net" + "strings" + "sync" + "time" +) + +const ( + defaultOffset = 2 + // FakeIPTTL is the default TTL for fakeip entries (1 hour) + FakeIPTTL = 1 * time.Hour + // FakeIPCleanupInterval is the execution interval for cleanup tasks (10 minutes) + FakeIPCleanupInterval = 10 * time.Minute +) + +// fakeipEntry represents a fakeip entry +type fakeipEntry struct { + ip net.IP + domain string + expireTime time.Time +} + +// fakeipPool is a fakeip pool implementation with automatic cleanup +// Uses the 198.18.0.0/16 address range as the fakeip pool +type fakeipPool struct { + mu sync.RWMutex + domainToEntry map[string]*fakeipEntry // mapping from domain to entry + ipToDomain map[string]string // reverse mapping from IP to domain + baseIP net.IP // base IP address (198.18.0.0) + currentOffset uint32 // current allocation offset + maxOffset uint32 // maximum offset (65536) + ttl time.Duration // TTL for entries + stopChan chan struct{} // channel to stop cleanup goroutine + cleanupTicker *time.Ticker // cleanup ticker +} + +var ( + globalFakeipPool *fakeipPool + globalFakeipPoolOnce sync.Once +) + +// GetGlobalFakeipPool returns the global fakeip pool singleton +func GetGlobalFakeipPool() *fakeipPool { + globalFakeipPoolOnce.Do(func() { + globalFakeipPool = newFakeipPool() + globalFakeipPool.startCleanup() + }) + return globalFakeipPool +} + +// newFakeipPool creates a new fakeip pool +func newFakeipPool() *fakeipPool { + return &fakeipPool{ + domainToEntry: make(map[string]*fakeipEntry), + ipToDomain: make(map[string]string), + baseIP: net.ParseIP("198.18.0.0").To4(), + maxOffset: 65536, // 198.18.0.0/16 can allocate 65536 addresses + ttl: FakeIPTTL, + stopChan: make(chan struct{}), + currentOffset: defaultOffset, + } +} + +// startCleanup starts the automatic cleanup goroutine +func (p *fakeipPool) startCleanup() { + p.cleanupTicker = time.NewTicker(FakeIPCleanupInterval) + go p.cleanupLoop() +} + +// cleanupLoop runs the cleanup loop +func (p *fakeipPool) cleanupLoop() { + for { + select { + case <-p.cleanupTicker.C: + p.cleanup() + case <-p.stopChan: + p.cleanupTicker.Stop() + return + } + } +} + +// cleanup removes expired entries +func (p *fakeipPool) cleanup() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + expiredDomains := make([]string, 0) + + // find all expired domains + for domain, entry := range p.domainToEntry { + if now.After(entry.expireTime) { + expiredDomains = append(expiredDomains, domain) + } + } + + // delete expired entries + for _, domain := range expiredDomains { + if entry, exists := p.domainToEntry[domain]; exists { + delete(p.ipToDomain, entry.ip.String()) + delete(p.domainToEntry, domain) + } + } +} + +// Stop stops the cleanup goroutine +func (p *fakeipPool) Stop() { + close(p.stopChan) +} + +// allocate assigns a fake IP address to a domain +func (p *fakeipPool) allocate(domain string) net.IP { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + + // if the domain already has an IP, update expiration time and return + if entry, exists := p.domainToEntry[domain]; exists { + entry.expireTime = now.Add(p.ttl) + return entry.ip + } + + // allocate a new IP + offset := p.currentOffset + p.currentOffset++ + if p.currentOffset >= p.maxOffset { + p.currentOffset = defaultOffset + } + + // calculate IP address: 198.18.0.0 + offset + ip := make(net.IP, 4) + copy(ip, p.baseIP) + ip[2] = byte(offset >> 8) + ip[3] = byte(offset & 0xff) + + // create new entry + entry := &fakeipEntry{ + ip: ip, + domain: domain, + expireTime: now.Add(p.ttl), + } + + // if this IP is already occupied, clean up the old mapping first + ipStr := ip.String() + if oldDomain, exists := p.ipToDomain[ipStr]; exists { + delete(p.domainToEntry, oldDomain) + } + + // save mapping relationships + p.domainToEntry[domain] = entry + p.ipToDomain[ipStr] = domain + + return ip +} + +// lookup finds the corresponding domain for a fake IP +func (p *fakeipPool) lookup(ip net.IP) (string, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + + domain, exists := p.ipToDomain[ip.String()] + if !exists { + return "", false + } + + // check if expired + if entry, ok := p.domainToEntry[domain]; ok { + if time.Now().After(entry.expireTime) { + return "", false + } + } + if strings.HasSuffix(domain, ".") { + return domain[:len(domain)-1], true + } + + return domain, true +} + +// GetStats returns pool statistics +func (p *fakeipPool) GetStats() (total int, allocated int) { + p.mu.RLock() + defer p.mu.RUnlock() + return int(p.maxOffset), len(p.domainToEntry) +} diff --git a/control/tcp.go b/control/tcp.go index c9de230da5..66c71cdbb6 100644 --- a/control/tcp.go +++ b/control/tcp.go @@ -26,15 +26,6 @@ import ( func (c *ControlPlane) handleConn(lConn net.Conn) (err error) { defer lConn.Close() - // Sniff target domain. - sniffer := sniffing.NewConnSniffer(lConn, c.sniffingTimeout) - // ConnSniffer should be used later, so we cannot close it now. - defer sniffer.Close() - domain, err := sniffer.SniffTcp() - if err != nil && !sniffing.IsSniffingError(err) { - return err - } - // Get tuples and outbound. src := lConn.RemoteAddr().(*net.TCPAddr).AddrPort() dst := lConn.LocalAddr().(*net.TCPAddr).AddrPort() @@ -45,6 +36,25 @@ func (c *ControlPlane) handleConn(lConn net.Conn) (err error) { src = common.ConvergeAddrPort(src) dst = common.ConvergeAddrPort(dst) + var domain string + var sniffer *sniffing.ConnSniffer + newLConn := lConn + + realDomain, ok := GetGlobalFakeipPool().lookup(dst.Addr().AsSlice()) + if ok { + domain = realDomain + } else { + // Sniff target domain. + sniffer = sniffing.NewConnSniffer(lConn, c.sniffingTimeout) + // ConnSniffer should be used later, so we cannot close it now. + defer sniffer.Close() + domain, err = sniffer.SniffTcp() + if err != nil && !sniffing.IsSniffingError(err) { + return err + } + newLConn = sniffer + } + // Dial and relay. rConn, err := c.RouteDialTcp(&RouteDialParam{ Outbound: consts.OutboundIndex(routingResult.Outbound), @@ -61,7 +71,7 @@ func (c *ControlPlane) handleConn(lConn net.Conn) (err error) { } defer rConn.Close() - if err = RelayTCP(sniffer, rConn); err != nil { + if err = RelayTCP(newLConn, rConn); err != nil { switch { case strings.HasSuffix(err.Error(), "write: broken pipe"), strings.HasSuffix(err.Error(), "i/o timeout"), diff --git a/control/udp.go b/control/udp.go index 8344a7e038..1e74b2480d 100644 --- a/control/udp.go +++ b/control/udp.go @@ -9,7 +9,6 @@ import ( "fmt" "net" "net/netip" - "time" "github.com/daeuniverse/dae/common" @@ -22,9 +21,7 @@ import ( "github.com/sirupsen/logrus" ) -var ( - DefaultNatTimeout = 3 * time.Minute -) +var DefaultNatTimeout = 3 * time.Minute const ( DnsNatTimeout = 17 * time.Second // RFC 5452 @@ -44,7 +41,7 @@ func ChooseNatTimeout(data []byte, sniffDns bool) (dmsg *dnsmessage.Msg, timeout if sniffDns { var dnsmsg dnsmessage.Msg if err := dnsmsg.Unpack(data); err == nil { - //log.Printf("DEBUG: lookup %v", dnsmsg.Question[0].Name) + // log.Printf("DEBUG: lookup %v", dnsmsg.Question[0].Name) return &dnsmsg, DnsNatTimeout } } @@ -281,6 +278,12 @@ getNew: // It is used for showing. domain = ue.SniffedDomain } + fakeip, ok := GetGlobalFakeipPool().lookup(realDst.Addr().AsSlice()) + if ok { + dialTarget = fmt.Sprintf("%s:%d", fakeip, realDst.Port()) + domain = fakeip + dialIp = false + } _, err = ue.WriteTo(data, dialTarget) if err != nil { diff --git a/go.mod b/go.mod index 69db74165e..17f8f53c55 100644 --- a/go.mod +++ b/go.mod @@ -98,9 +98,9 @@ require ( google.golang.org/grpc v1.65.0 // indirect ) -// replace github.com/daeuniverse/outbound => ../outbound +replace github.com/daeuniverse/outbound => github.com/cubatic45/outbound v0.0.0-20250928110443-c9be2f221994 // replace github.com/daeuniverse/quic-go => ../quic-go //replace github.com/cilium/ebpf => /home/mzz/goProjects/ebpf -//replace github.com/daeuniverse/dae-config-dist/go/dae_config => /home/mzz/antlrProjects/dae-config/build/go/dae_config \ No newline at end of file +//replace github.com/daeuniverse/dae-config-dist/go/dae_config => /home/mzz/antlrProjects/dae-config/build/go/dae_config diff --git a/go.sum b/go.sum index 4944902374..7d0f71b69f 100644 --- a/go.sum +++ b/go.sum @@ -24,10 +24,10 @@ github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cubatic45/outbound v0.0.0-20250928110443-c9be2f221994 h1:urUCh6nQmPOLN1jg9KvXSgsQZeFioPkSFlGaGeIsOT8= +github.com/cubatic45/outbound v0.0.0-20250928110443-c9be2f221994/go.mod h1:fywFXIIfFeyG+oMat6h7MExY99CNtERbhrH0DYSr/6g= github.com/daeuniverse/dae-config-dist/go/dae_config v0.0.0-20230604120805-1c27619b592d h1:hnC39MjR7xt5kZjrKlef7DXKFDkiX8MIcDXYC/6Jf9Q= github.com/daeuniverse/dae-config-dist/go/dae_config v0.0.0-20230604120805-1c27619b592d/go.mod h1:VGWGgv7pCP5WGyHGUyb9+nq/gW0yBm+i/GfCNATOJ1M= -github.com/daeuniverse/outbound v0.0.0-20250720091307-9b4c31511d0f h1:o9tlps6Hy2F2OxsdfYxZzaL7AduqFFCM9iUV3a6VrO8= -github.com/daeuniverse/outbound v0.0.0-20250720091307-9b4c31511d0f/go.mod h1:fywFXIIfFeyG+oMat6h7MExY99CNtERbhrH0DYSr/6g= github.com/daeuniverse/quic-go v0.0.0-20250210145620-2083199a7851 h1:AK4qfFw5CcHdOJcEpZj443NqskjhTvc+2cLOB5Cvrmk= github.com/daeuniverse/quic-go v0.0.0-20250210145620-2083199a7851/go.mod h1:hykVjD1wT/nAFcAkagZpziNAnXLwJOOpn0Ozohtgmsw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -304,7 +304,3 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -github.com/daeuniverse/outbound v0.0.0-20250531131212-a58b4c6b39b2 h1:NUUI9tKUM+KZUUC51w0wu9Ci4myFaoTsTSA7sJS0rtc= -github.com/daeuniverse/outbound v0.0.0-20250531131212-a58b4c6b39b2/go.mod h1:fywFXIIfFeyG+oMat6h7MExY99CNtERbhrH0DYSr/6g= -github.com/daeuniverse/outbound v0.0.0-20250722064253-00c4fbb38759 h1:aklFtuD9AJ9toFveiPNfstY0o4owduvJ+iNpc61mhkU= -github.com/daeuniverse/outbound v0.0.0-20250722064253-00c4fbb38759/go.mod h1:fywFXIIfFeyG+oMat6h7MExY99CNtERbhrH0DYSr/6g= \ No newline at end of file