Mult Protocol

This commit is contained in:
2026-08-16 15:22:03 -03:00
parent 96ea761b72
commit 1fb431ccba
17 changed files with 1873 additions and 204 deletions
+44 -78
View File
@@ -571,41 +571,40 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
// 0 = persistent (CLI explicit)
// 1 = auto: persistent when the path probe succeeds, otherwise one request/connection
// N>=2 = force connection rotation after N logical requests
if reconnect == 1 {
if profile.persistent {
reconnect = 0
fmt.Printf("path probe: reconnect mode auto -> persistent\n")
} else {
fmt.Printf("path probe: reconnect mode auto -> every request\n")
}
// Resolved silently: this runs once per proxied flow, so it must never log.
if reconnect == 1 && profile.persistent {
reconnect = 0
}
sid, err := randomSessionID()
if err != nil {
return nil, err
}
control := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout)
// OPEN rides the upload lane instead of a throwaway connection. A dedicated
// control connection cost one extra dial per proxied flow, which shows up on
// the server as connection churn on top of the steady-state count.
uploadLane := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout)
payload, err := encodeOpen(token, targetHost, targetPort)
if err != nil {
control.Close()
uploadLane.Close()
return nil, err
}
status, body, err := control.single(wire.ModeOpen, sid, 0, payload)
status, body, err := uploadLane.single(wire.ModeOpen, sid, 0, payload)
if err != nil {
control.Close()
uploadLane.Close()
return nil, err
}
if status == wire.StatusError {
control.Close()
uploadLane.Close()
return nil, fmt.Errorf("%s", string(body))
}
if status != wire.StatusOK || len(body) != 4 {
control.Close()
uploadLane.Close()
return nil, fmt.Errorf("bad OPEN response")
}
serverMax := int(binary.BigEndian.Uint32(body))
control.Close()
if serverMax < opts.minSize {
uploadLane.Close()
return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize)
}
if opts.maxSize > serverMax {
@@ -624,13 +623,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
sid: sid,
opts: opts,
serverMax: serverMax,
uploadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
uploadLane: uploadLane,
downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
// Start at the user-configured ceiling. On transport failures the
// pipeline is halved; successful data responses grow it back by one,
// always staying inside minPipeline..maxPipeline. When the two bounds
// are equal the depth is pinned and never adapts, which is what paths
// that only work at one specific batch size need.
// Start at the configured ceiling. On transport failure the batch is
// halved but never below minPipeline; successful data grows it back by
// one. When min == max the depth is pinned and never adapts, which is
// what paths that only work at one specific batch size need.
pipeline: opts.maxPipeline,
minPipeline: opts.minPipeline,
maxPipeline: opts.maxPipeline,
@@ -640,53 +638,6 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
return c, nil
}
// pinnedBatch reports whether the batch depth is fixed. A pinned depth never
// grows or shrinks: some paths only deliver correctly at one specific number of
// records per request, so the adaptive controller must stay out of the way.
func (c *chunkConn) pinnedBatch() bool { return c.minPipeline >= c.maxPipeline }
// batchCount is how many records the next download request will ask for.
func (c *chunkConn) batchCount(chunk int) int {
count := c.pipeline
if count < c.minPipeline {
count = c.minPipeline
}
if count > c.maxPipeline {
count = c.maxPipeline
}
// Bound each batch to roughly 1 MiB of useful data, but never below the
// configured floor: a pinned depth is a path requirement, not a hint.
if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count {
count = maxInt(maxCount, c.minPipeline)
}
return count
}
// growPipeline widens the batch by one after a successful data response.
func (c *chunkConn) growPipeline() {
if c.pinnedBatch() {
return
}
if c.pipeline < c.maxPipeline {
c.pipeline++
}
}
// shrinkPipeline halves the batch after a transport failure. It reports the old
// and new depth, and whether anything actually changed; when it returns false
// the caller should shrink the record size instead.
func (c *chunkConn) shrinkPipeline() (int, int, bool) {
if c.pinnedBatch() || c.pipeline <= c.minPipeline {
return c.pipeline, c.pipeline, false
}
old := c.pipeline
c.pipeline /= 2
if c.pipeline < c.minPipeline {
c.pipeline = c.minPipeline
}
return old, c.pipeline, old != c.pipeline
}
func (c *chunkConn) fillReadBuffer() error {
if c.eof {
return io.EOF
@@ -694,7 +645,18 @@ func (c *chunkConn) fillReadBuffer() error {
minFailures := 0
for len(c.readBuf) == 0 && !c.eof {
chunk := c.downSizer.Current()
count := c.batchCount(chunk)
count := c.pipeline
if count < c.minPipeline {
count = c.minPipeline
}
if count > c.maxPipeline {
count = c.maxPipeline
}
// Bound each batch to roughly 1 MiB of useful data, but never below the
// configured floor: a pinned depth is a path requirement, not a hint.
if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count {
count = maxInt(maxCount, c.minPipeline)
}
data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count)
for _, part := range data {
@@ -703,16 +665,20 @@ func (c *chunkConn) fillReadBuffer() error {
}
if len(data) > 0 {
c.downSizer.Success(chunk)
c.growPipeline()
if c.pipeline < c.maxPipeline {
c.pipeline++
}
minFailures = 0
}
if err != nil {
// Shrink the batch first, then the record size. When the batch is
// pinned (min == max) the depth is left alone entirely and only the
// record size adapts.
if old, next, shrank := c.shrinkPipeline(); shrank {
if c.opts.adaptLog {
fmt.Printf("adaptive download batch: %d -> %d after transport failure\n", old, next)
if c.pipeline > c.minPipeline {
old := c.pipeline
c.pipeline /= 2
if c.pipeline < c.minPipeline {
c.pipeline = c.minPipeline
}
if c.opts.adaptLog && old != c.pipeline {
fmt.Printf("adaptive download pipeline: %d -> %d after transport failure\n", old, c.pipeline)
}
} else {
old, next := c.downSizer.Failure(chunk)
@@ -808,9 +774,9 @@ func (c *chunkConn) Write(p []byte) (int, error) {
func (c *chunkConn) Close() error {
c.closeOnce.Do(func() {
lane := newRequestLane(c.uploadLane.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout)
_, _, _ = lane.single(wire.ModeClose, c.sid, 0, nil)
lane.Close()
// Reuse the upload lane rather than dialling a connection just to say
// goodbye; that was a second wasted dial per flow.
_, _, _ = c.uploadLane.single(wire.ModeClose, c.sid, 0, nil)
c.uploadLane.Close()
c.downloadLane.Close()
})
-70
View File
@@ -23,76 +23,6 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
}
}
func newTestConn(min, max int) *chunkConn {
return &chunkConn{pipeline: max, minPipeline: min, maxPipeline: max}
}
func TestPinnedBatchNeverAdapts(t *testing.T) {
c := newTestConn(5, 5)
if got := c.batchCount(1400); got != 5 {
t.Fatalf("pinned batch should request 5 records, got %d", got)
}
for i := 0; i < 10; i++ {
if _, _, shrank := c.shrinkPipeline(); shrank {
t.Fatal("pinned batch shrank on transport failure")
}
c.growPipeline()
}
if c.pipeline != 5 {
t.Fatalf("pinned batch drifted to %d", c.pipeline)
}
if got := c.batchCount(1400); got != 5 {
t.Fatalf("pinned batch should still request 5 records, got %d", got)
}
}
func TestPinnedBatchSurvivesOneMiBCap(t *testing.T) {
// 8 x 1 MiB records exceed the ~1 MiB useful-data cap. A pinned depth must
// win anyway, otherwise a path that needs exactly 8 records is broken by
// an unrelated size heuristic.
c := newTestConn(8, 8)
if got := c.batchCount(1024 * 1024); got != 8 {
t.Fatalf("pinned batch should ignore the 1 MiB cap, got %d", got)
}
// An unpinned batch is still capped.
c = newTestConn(1, 8)
if got := c.batchCount(1024 * 1024); got != 1 {
t.Fatalf("unpinned batch should be capped to 1, got %d", got)
}
}
func TestAdaptiveBatchStopsAtFloor(t *testing.T) {
c := newTestConn(4, 32)
seen := map[int]bool{}
for i := 0; i < 12; i++ {
_, next, _ := c.shrinkPipeline()
seen[next] = true
}
if c.pipeline != 4 {
t.Fatalf("batch fell to %d, want the floor 4", c.pipeline)
}
if !seen[16] || !seen[8] {
t.Fatalf("expected halving through 16 and 8, saw %v", seen)
}
for i := 0; i < 100; i++ {
c.growPipeline()
}
if c.pipeline != 32 {
t.Fatalf("batch grew to %d, want the ceiling 32", c.pipeline)
}
}
func TestSingleBatchIsFixed(t *testing.T) {
c := newTestConn(1, 1)
if !c.pinnedBatch() {
t.Fatal("a 1..1 batch must be treated as pinned")
}
c.growPipeline()
if c.pipeline != 1 {
t.Fatalf("batch of 1 grew to %d", c.pipeline)
}
}
func TestReconnectZeroMeansPersistent(t *testing.T) {
lane := newRequestLane("127.0.0.1:1", 0, 0, 0)
if lane.reconnectEvery != 0 {
+55 -27
View File
@@ -13,6 +13,7 @@ import (
"time"
"dragontcp/internal/protocol"
"dragontcp/internal/xorchunk"
)
const maxHeader = 128 * 1024
@@ -251,7 +252,7 @@ func writeHTTPError(conn net.Conn, code int, reason, detail string) {
_, _ = conn.Write(body)
}
func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, chunkOpts chunkClientOptions, slots chan struct{}) {
func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, wires *wireSelector, slots chan struct{}) {
defer func() {
<-slots
_ = conn.Close()
@@ -285,7 +286,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
var remote net.Conn
if transport == "chunk" {
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
remote, err = wires.dial(host, port)
} else {
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
}
@@ -327,7 +328,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
var remote net.Conn
if transport == "chunk" {
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts)
remote, err = wires.dial(host, port)
} else {
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer)
}
@@ -358,27 +359,28 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
func main() {
var (
listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
serverHost = flag.String("server-host", "", "remote DragonTCP server host")
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
token = flag.String("token", "", "optional shared token")
maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
transport = flag.String("transport", "chunk", "transport: chunk (DragonTCP binary adaptive transport)")
tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning")
chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes")
chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes")
chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (up to 1 MiB)")
chunkAdaptive = flag.Bool("chunk-adaptive", true, "automatically shrink on failures and grow after stable success")
chunkSuccesses = flag.Int("chunk-grow-after", 16, "successful data records required before increasing chunk size")
chunkAdaptLog = flag.Bool("chunk-adapt-log", true, "print adaptive chunk size changes")
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)")
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth and disables batch adaptation")
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic")
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink")
listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
serverHost = flag.String("server-host", "", "remote DragonTCP server host")
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
token = flag.String("token", "", "optional shared token")
maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
transport = flag.String("transport", "chunk", "transport: chunk (DragonTCP binary adaptive transport)")
tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning")
chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes")
chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes")
chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (up to 1 MiB)")
chunkAdaptive = flag.Bool("chunk-adaptive", true, "automatically shrink on failures and grow after stable success")
chunkSuccesses = flag.Int("chunk-grow-after", 16, "successful data records required before increasing chunk size")
chunkAdaptLog = flag.Bool("chunk-adapt-log", true, "print adaptive chunk size changes")
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)")
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth")
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic")
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink")
wireMode = flag.String("wire", "auto", "wire mode: b, x, or auto (probe and pick)")
)
flag.Parse()
@@ -426,6 +428,17 @@ func main() {
fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency")
os.Exit(2)
}
*wireMode = strings.ToLower(strings.TrimSpace(*wireMode))
switch *wireMode {
case WireBinary, WireXOR, WireAuto:
case "binary":
*wireMode = WireBinary
case "xor":
*wireMode = WireXOR
default:
fmt.Fprintln(os.Stderr, "--wire must be b, x or auto")
os.Exit(2)
}
if *chunkReconnect < 0 {
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
os.Exit(2)
@@ -438,14 +451,19 @@ func main() {
adaptSuccesses: *chunkSuccesses,
adaptLog: *chunkAdaptLog,
pollers: *chunkPollers,
minPipeline: *chunkConcurrencyMin,
maxPipeline: *chunkConcurrency,
reconnectEvery: *chunkReconnect,
pollDelay: *chunkPollDelay,
txnTimeout: *chunkTimeout,
tcpBuffer: *tcpBuffer,
minPipeline: *chunkConcurrencyMin,
maxPipeline: *chunkConcurrency,
}
xorOpts := xorchunk.NewOptions(
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkAdaptLog,
*chunkPollers, *chunkReconnect, *chunkPollDelay, *chunkTimeout, *tcpBuffer,
)
listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
@@ -480,6 +498,16 @@ func main() {
)
}
wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts)
if *wireMode == WireAuto {
fmt.Printf("wire=auto probing %s\n", probeHost)
// Resolve in the background so startup is not blocked; a connection that
// arrives first simply waits for the same result.
go wires.mode()
} else {
fmt.Printf("wire=%s (manual)\n", *wireMode)
}
slots := make(chan struct{}, *maxConnections)
for {
@@ -491,7 +519,7 @@ func main() {
select {
case slots <- struct{}{}:
go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots)
go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, wires, slots)
default:
writeHTTPError(
conn,
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"net"
"strings"
"sync"
"time"
"dragontcp/internal/xorchunk"
)
// DragonTCP speaks two wires that are not interchangeable:
//
// b — compact binary records (29/5-byte headers, SHA-256 keystream mask)
// x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands
//
// Networks differ in which they pass, so the client can be pinned to either or
// left on auto, which decides by actually fetching a URL through each wire and
// keeping the first that answers.
const (
WireBinary = "b"
WireXOR = "x"
WireAuto = "auto"
)
// probeTarget is fetched through a candidate wire to decide whether it works.
// A plain HTTP host is used deliberately: it exercises OPEN, upload and
// download in one go, and a valid status line proves bytes survived intact.
const (
probeHost = "ip.dr2.site"
probePort = 80
probeTimeout = 8 * time.Second
)
type wireSelector struct {
mu sync.Mutex
configured string // b, x or auto
resolved string // b or x once decided
serverAddr string
token string
binOpts chunkClientOptions
xorOpts xorchunk.Options
}
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options) *wireSelector {
s := &wireSelector{
configured: configured,
serverAddr: serverAddr,
token: token,
binOpts: binOpts,
xorOpts: xorOpts,
}
if configured != WireAuto {
s.resolved = configured
}
return s
}
// dial opens a tunnel over the active wire, resolving the wire first if needed.
func (s *wireSelector) dial(host string, port int) (net.Conn, error) {
mode := s.mode()
if mode == WireXOR {
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts)
}
return openChunkTunnel(s.serverAddr, s.token, host, port, s.binOpts)
}
// mode returns the wire to use, running detection once if configured as auto.
// Detection failure is not cached, so a client that starts before the network
// is usable retries on the next connection instead of latching a bad guess.
func (s *wireSelector) mode() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.resolved != "" {
return s.resolved
}
if picked, ok := s.detectLocked(); ok {
s.resolved = picked
return picked
}
// Undecided: use the binary wire for this attempt without caching it.
return WireBinary
}
func (s *wireSelector) detectLocked() (string, bool) {
for _, candidate := range []string{WireBinary, WireXOR} {
if s.probe(candidate) {
fmt.Printf("wire probe: %s selected via %s\n", candidate, probeHost)
return candidate, true
}
fmt.Printf("wire probe: %s failed\n", candidate)
}
fmt.Printf("wire probe: neither wire reached %s; retrying later\n", probeHost)
return "", false
}
// probe fetches probeHost through one wire and reports whether a well-formed
// HTTP status line came back.
func (s *wireSelector) probe(mode string) bool {
type result struct{ ok bool }
done := make(chan result, 1)
go func() {
var (
conn net.Conn
err error
)
if mode == WireXOR {
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts)
} else {
conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, s.binOpts)
}
if err != nil {
done <- result{false}
return
}
defer conn.Close()
request := "GET / HTTP/1.1\r\nHost: " + probeHost + "\r\nUser-Agent: dragontcp\r\nConnection: close\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
done <- result{false}
return
}
buf := make([]byte, 64)
n, err := conn.Read(buf)
if n <= 0 || (err != nil && n == 0) {
done <- result{false}
return
}
done <- result{strings.HasPrefix(string(buf[:n]), "HTTP/")}
}()
select {
case r := <-done:
return r.ok
case <-time.After(probeTimeout):
// The tunnel goroutine is left to unwind on its own; the wire simply
// did not answer in time, which is all the caller needs to know.
return false
}
}