From 2d7ce00f5bdfdc460e9699fd1ae877c52c424902 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Fri, 17 Apr 2026 14:56:05 +0000 Subject: [PATCH 01/10] Stop and drain the timer before Reset in sendLoop. Before go1.23, calling Stop, and draining the channel if the timer did not already fire, is necessary before calling Reset: https://pkg.go.dev/time@go1.22.12#Timer.Reset This changed in go1.23: now Reset automatically effectively drains the channel, and calling Stop is no longer necessary. https://pkg.go.dev/time@go1.23.9#Timer.Reset However, the changes in go1.23 only take effect if go.mod specifies 1.23 or later. We currently specify 1.21. https://go.dev/doc/go1.23#timer-changes For compatibility, do the Stop/drain procedure before calling Reset. We were already doing this for pollTimer in DNSPacketConn) sendLoop in dnstt-client. This change may not have any observable effect. The duration we Reset the timer to was 0, so if there had been a stale value in the channel because of a failure to drain it, the effect would be the same as waiting 0 seconds. We were already calling Stop when finished with the timer, so it would have been garbage-collectable even before go1.23. --- dnstt-server/main.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dnstt-server/main.go b/dnstt-server/main.go index 047683c..19c8fdc 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -592,6 +592,7 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- // overflow the capacity of the DNS response, we stash // to be bundled into a future response. timer := time.NewTimer(maxResponseDelay) + timerExpired := false for { var p []byte unstash := ttConn.Unstash(rec.ClientID) @@ -612,6 +613,7 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- case p = <-unstash: case p = <-outgoing: case <-timer.C: + timerExpired = true case nextRec = <-ch: } } @@ -620,7 +622,11 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- // only. The second and later packets must be // immediately available or they will be omitted // from this bundle. + if !timerExpired && !timer.Stop() { + <-timer.C + } timer.Reset(0) + timerExpired = false if len(p) == 0 { // timer expired or receive on ch, we @@ -646,7 +652,9 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- binary.Write(&payload, binary.BigEndian, uint16(len(p))) payload.Write(p) } - timer.Stop() + if !timerExpired && !timer.Stop() { + <-timer.C + } rec.Resp.Answer[0].Data = dns.EncodeRDataTXT(payload.Bytes()) } From 37129955dea9d2ab3c8e1a91a16a10321120c858 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Fri, 17 Apr 2026 15:25:49 +0000 Subject: [PATCH 02/10] Let the program end if sendLoop happens to end before recvLoop. Without sendLoop, the program can no longer make progress. Treat sendLoop and recvLoop as peer goroutines, instead of having recvLoop on the main class stack and sendLoop as a goroutine. --- dnstt-server/main.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/dnstt-server/main.go b/dnstt-server/main.go index 19c8fdc..9797eba 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -813,17 +813,36 @@ func run(privkey []byte, domain dns.Name, upstream string, dnsConn net.PacketCon ch := make(chan *record, 100) defer close(ch) + // We will run sendLoop and recvLoop at the same time. The first one to + // finish closes the done channel. + doneChan := make(chan struct{}) + var doneOnce sync.Once + done := func() { doneOnce.Do(func() { close(doneChan) }) } + // We could run multiple copies of sendLoop; that would allow more time // for each response to collect downstream data before being evicted by // another response that needs to be sent. go func() { + defer done() err := sendLoop(dnsConn, ttConn, ch, maxEncodedPayload) if err != nil { log.Printf("sendLoop: %v", err) } }() - return recvLoop(domain, dnsConn, ttConn, ch) + go func() { + defer done() + err := recvLoop(domain, dnsConn, ttConn, ch) + if err != nil { + log.Printf("recvLoop: %v", err) + } + }() + + // Wait for either sendLoop or recvLoop to return. In normal operation, + // we don't expect either to return. + <-doneChan + + return nil } func main() { From a786303c106b7dc87b84f784153fa97a27913533 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Fri, 17 Apr 2026 15:50:23 +0000 Subject: [PATCH 03/10] Let termination of acceptSessions end the program as well. --- dnstt-server/main.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/dnstt-server/main.go b/dnstt-server/main.go index 9797eba..948c11f 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -803,7 +803,15 @@ func run(privkey []byte, domain dns.Name, upstream string, dnsConn net.PacketCon return fmt.Errorf("opening KCP listener: %v", err) } defer ln.Close() + + // We will run acceptSessions, sendLoop, and recvLoop concurrently. The + // first one to finish closes the done channel. + doneChan := make(chan struct{}) + var doneOnce sync.Once + done := func() { doneOnce.Do(func() { close(doneChan) }) } + go func() { + defer done() err := acceptSessions(ln, privkey, mtu, upstream) if err != nil { log.Printf("acceptSessions: %v", err) @@ -813,12 +821,6 @@ func run(privkey []byte, domain dns.Name, upstream string, dnsConn net.PacketCon ch := make(chan *record, 100) defer close(ch) - // We will run sendLoop and recvLoop at the same time. The first one to - // finish closes the done channel. - doneChan := make(chan struct{}) - var doneOnce sync.Once - done := func() { doneOnce.Do(func() { close(doneChan) }) } - // We could run multiple copies of sendLoop; that would allow more time // for each response to collect downstream data before being evicted by // another response that needs to be sent. @@ -838,8 +840,8 @@ func run(privkey []byte, domain dns.Name, upstream string, dnsConn net.PacketCon } }() - // Wait for either sendLoop or recvLoop to return. In normal operation, - // we don't expect either to return. + // Wait for any of acceptSessions, sendLoop, or recvLoop to return. In + // normal operation, we don't expect any of these to return. <-doneChan return nil From 65880742b9f96540912aae89e9195bef515a3c0a Mon Sep 17 00:00:00 2001 From: David Fifield Date: Tue, 21 Apr 2026 01:00:13 +0000 Subject: [PATCH 04/10] Add Dante configuration example. --- README | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README b/README index 31ca56b..3f77ae4 100644 --- a/README +++ b/README @@ -176,6 +176,43 @@ tunnel-client$ curl --proxy http://127.0.0.1:7000/ https://wtfismyip.com/text ``` +### Dante SOCKS proxy + +With the Dante SOCKS proxy (https://www.inet.no/dante/), a minimal +configuration (/etc/danted.conf) may look like the following: + +``` +internal: lo port = 1080 +external: eth0 + +clientmethod: none +socksmethod: none + +user.privileged: proxy +user.unprivileged: nobody + +client pass { + from: lo to: 0/0 +} + +socks block { + from: 0.0.0.0/0 to: lo +} + +socks block { + from: ::/0 to: lo +} + +socks pass { + from: lo to: 0.0.0.0/0 + command: connect +} +``` + +See https://www.inet.no/dante/doc/latest/config/server.html for more +examples. + + ### SSH SOCKS proxy OpenSSH has a built-in SOCKS proxy, which makes it easy to add a SOCKS From 0b5b9b10f0b29e34073884299c36c8527902740e Mon Sep 17 00:00:00 2001 From: David Fifield Date: Tue, 21 Apr 2026 01:06:16 +0000 Subject: [PATCH 05/10] Use port 8000 in Dante example to match the others. --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index 3f77ae4..e62f2f3 100644 --- a/README +++ b/README @@ -182,7 +182,7 @@ With the Dante SOCKS proxy (https://www.inet.no/dante/), a minimal configuration (/etc/danted.conf) may look like the following: ``` -internal: lo port = 1080 +internal: lo port = 8000 external: eth0 clientmethod: none From a3210833f15d6ff4a4f0f4db787f5f1ce0f9eff2 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Tue, 21 Apr 2026 01:06:38 +0000 Subject: [PATCH 06/10] Reorganize README. --- README | 74 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/README b/README index e62f2f3..0d19662 100644 --- a/README +++ b/README @@ -176,43 +176,6 @@ tunnel-client$ curl --proxy http://127.0.0.1:7000/ https://wtfismyip.com/text ``` -### Dante SOCKS proxy - -With the Dante SOCKS proxy (https://www.inet.no/dante/), a minimal -configuration (/etc/danted.conf) may look like the following: - -``` -internal: lo port = 8000 -external: eth0 - -clientmethod: none -socksmethod: none - -user.privileged: proxy -user.unprivileged: nobody - -client pass { - from: lo to: 0/0 -} - -socks block { - from: 0.0.0.0/0 to: lo -} - -socks block { - from: ::/0 to: lo -} - -socks pass { - from: lo to: 0.0.0.0/0 - command: connect -} -``` - -See https://www.inet.no/dante/doc/latest/config/server.html for more -examples. - - ### SSH SOCKS proxy OpenSSH has a built-in SOCKS proxy, which makes it easy to add a SOCKS @@ -261,6 +224,43 @@ tunnel-client$ curl --proxy socks5h://127.0.0.1:7000/ https://wtfismyip.com/text ``` +### Dante SOCKS proxy + +With the Dante SOCKS proxy (https://www.inet.no/dante/), a minimal +configuration (/etc/danted.conf) may look like the following: + +``` +internal: lo port = 8000 +external: eth0 + +clientmethod: none +socksmethod: none + +user.privileged: proxy +user.unprivileged: nobody + +client pass { + from: lo to: 0/0 +} + +socks block { + from: 0.0.0.0/0 to: lo +} + +socks block { + from: ::/0 to: lo +} + +socks pass { + from: lo to: 0.0.0.0/0 + command: connect +} +``` + +See https://www.inet.no/dante/doc/latest/config/server.html for more +examples. + + ### Tor bridge You can run a Tor bridge on the tunnel server and tunnel the connection From a7d8773259ad16d3a5759a56682e6c563b1372e6 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Mon, 27 Apr 2026 18:10:38 +0000 Subject: [PATCH 07/10] Don't let a WriteTo error terminate sendLoop, except net.ErrClosed. This error check was meant to terminate sendLoop and cause it to return with the error from WriteTo. (Except for the special case where the error is a net.Error that is also Temporary(), in which case we merely logged the error and continued running sendLoop.) Errors from WriteTo (whether Temporary() or not) were rare. I managed to get one line this after several days' uptime on a server with heavy use: sendLoop: write udp [::]:5300->X.X.X.X:YYYYY: sendto: operation not permitted The above dnstt-server error was accompanied by a Linux kernel log message: nf_conntrack: nf_conntrack: table full, dropping packet What happened is the conntrack table filled and failed to track the state of some UDP exchanges. A UDP 4-tuple lost the RELATED state and and outbound packet was blocked by the local firewall ("operation not permitted"). This may not be the only way a non-Temporary() WriteTo error could happen. But in any case, when one did happen, it would cause sendLoop to return and the server to stop processing traffic. (Before 37129955dea9d2ab3c8e1a91a16a10321120c858, this was especially bad, because the return of sendLoop would not terminate the program: it would keep running and receiving queries, but never send any responses. Now, at least, the program terminates, so the failure is immediately detectable.) Now we simply log errors from WriteTo, as if they were always temporary. The only exception is net.ErrClosed, which causes sendLoop to terminate as before. Background on the net.Error Temporary() pattern: * "Use net.Error to distinguish temporary Accept errors." https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib/-/commit/3030f080eecf72b0e896236fca5fabd245c00bdb * "Don't report errors that are not caused by Accept in AcceptSocks." https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib/-/commit/50b39b746c6ff34bf31977b658848d876ee84fbf * https://go.dev/blog/error-handling-and-go#the-error-type net.Error.Temporary was deprecated in go1.18: * "net: deprecate Temporary error status" https://github.com/golang/go/issues/45729 * https://go.dev/doc/go1.18#netpkgnet See also: * "net/http: server.Serve() uses deprecated net.Error.Temporary()" https://github.com/golang/go/issues/66208 * "proposal: net: add ErrRetryableAcceptError" https://github.com/golang/go/issues/66252 For now, though, even though I'm removing the Temporary() check on WriteTo errors, I'm keeping it for KCP AcceptKCP and AcceptStream. It may still be the right thing for an accept loop; cf. https://groups.google.com/g/golang-nuts/c/-JcZzOkyqYI/m/wp_5G8LmAwAJ: While the whole suite of Temporary errors isn't really coherent, the issue is that a small subset of Temporary is still useful for Accept loops and it doesn't have a non-deprecated replacement. As a case in point, I presume that http.Server is going to keep using Temporary indefinitely. --- dnstt-server/main.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dnstt-server/main.go b/dnstt-server/main.go index 948c11f..b91eba0 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -675,11 +675,14 @@ func sendLoop(dnsConn net.PacketConn, ttConn *turbotunnel.QueuePacketConn, ch <- // Now we actually send the message as a UDP packet. _, err = dnsConn.WriteTo(buf, rec.Addr) if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - log.Printf("WriteTo temporary error: %v", err) - continue + // net.ErrClosed means we'll never be able to send on + // dnsConn, so terminate the loop. Treat all other + // errors as temporary and simply log them. + if errors.Is(err, net.ErrClosed) { + return err } - return err + log.Printf("WriteTo error: %v", err) + continue } } return nil From b79c43667188588d68cafe4344ff77ff628b873d Mon Sep 17 00:00:00 2001 From: David Fifield Date: Mon, 27 Apr 2026 18:59:33 +0000 Subject: [PATCH 08/10] Log temporary errors from AcceptKCP and AcceptStream. --- dnstt-server/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dnstt-server/main.go b/dnstt-server/main.go index b91eba0..1ae8bbd 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -257,6 +257,7 @@ func acceptStreams(conn *kcp.UDPSession, privkey []byte, upstream string) error stream, err := sess.AcceptStream() if err != nil { if err, ok := err.(net.Error); ok && err.Temporary() { + log.Printf("AcceptStream temporary error: %v", err) continue } return err @@ -282,6 +283,7 @@ func acceptSessions(ln *kcp.Listener, privkey []byte, mtu int, upstream string) conn, err := ln.AcceptKCP() if err != nil { if err, ok := err.(net.Error); ok && err.Temporary() { + log.Printf("AcceptKCP temporary error: %v", err) continue } return err From 4d61987592285c4335af0bc47c763b84055b3ed0 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Mon, 27 Apr 2026 18:59:44 +0000 Subject: [PATCH 09/10] Remove the Temporary() check from ReadFrom calls. net.Error.Temporary is deprecated since go1.18: https://github.com/golang/go/issues/45729 https://go.dev/doc/go1.18#netpkgnet We don't set a deadline on these reads, so we don't expect errors ever to be Timeout(). Maybe we can get away with simply terminating the program on any error. --- dnstt-client/dns.go | 4 ---- dnstt-server/main.go | 4 ---- 2 files changed, 8 deletions(-) diff --git a/dnstt-client/dns.go b/dnstt-client/dns.go index 67115c1..0479d9d 100644 --- a/dnstt-client/dns.go +++ b/dnstt-client/dns.go @@ -187,10 +187,6 @@ func (c *DNSPacketConn) recvLoop(transport net.PacketConn) error { var buf [4096]byte n, addr, err := transport.ReadFrom(buf[:]) if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - log.Printf("ReadFrom temporary error: %v", err) - continue - } return err } diff --git a/dnstt-server/main.go b/dnstt-server/main.go index 1ae8bbd..a0146a6 100644 --- a/dnstt-server/main.go +++ b/dnstt-server/main.go @@ -505,10 +505,6 @@ func recvLoop(domain dns.Name, dnsConn net.PacketConn, ttConn *turbotunnel.Queue var buf [4096]byte n, addr, err := dnsConn.ReadFrom(buf[:]) if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - log.Printf("ReadFrom temporary error: %v", err) - continue - } return err } From 0c5c52a57d899c05428c116898941761a2ed83c2 Mon Sep 17 00:00:00 2001 From: David Fifield Date: Fri, 1 May 2026 01:13:58 +0000 Subject: [PATCH 10/10] Update CHANGELOG to v1.20260501.0. --- CHANGELOG | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index c638625..1c23fd4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,24 @@ +## v1.20260501.0 + +Fixed an error in dnstt-server that could cause the server to stop +processing traffic, while still running. Rarely, a sendto system call in +the sendLoop function would cause sendLoop to return with an error. +Meanwhile, recvLoop would keep running and processing incoming queries, +but as sendLoop had finished, the server would cease to send back +responses. In live testing, one possible cause of a sendto error was the +Linux conntrack table becoming full from heavy traffic and the outgoing +packet being blocked by a default DENY rule in the local firewall; other +causes may have been possible. This release contains two changes to fix +the problem: (1) sendto errors, other than net.ErrClosed, are now only +logged and do not cause sendLoop to terminate; (2) if sendLoop (or any +other top-level goroutine) does return, it causes the whole process to +exit, which will make any failure noticeable and avoid the "running but +not working" failure mode. + +## v1.20241021.0 + +Added a CC0 COPYING file. + ## v1.20240513.0 Updated utls to v1.6.6. Added a "random" fingerprint ID that maps to