Mult Port + TCP Calibration (SSH DEAD)

This commit is contained in:
2026-08-17 17:08:57 -03:00
parent 7ea221a99c
commit b997294607
58 changed files with 6033 additions and 497 deletions
+2 -1
View File
@@ -369,7 +369,8 @@ func processBHTTPRequest(conn net.Conn, req bhttpRequest, ctx *bhttpServerContex
if err != nil {
return writeBHTTPError(conn, err.Error())
}
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, ctx.debug)
_, internalCarrier := lookupInternalTarget(host, port)
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, internalCarrier, ctx.debug)
session.mu.Lock()
if session.stream == nil {
session.stream = stream
+88 -22
View File
@@ -14,12 +14,13 @@ import (
)
type streamSession struct {
sid wire.SessionID
target net.Conn
targetName string
maxChunk int
maxBuffer int
debug *serverDebug
sid wire.SessionID
target net.Conn
targetName string
maxChunk int
maxBuffer int
debug *serverDebug
bulkCoalesce bool
mu sync.Mutex
notify chan struct{}
@@ -33,16 +34,17 @@ type streamSession struct {
expectedUp uint64
}
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, debug *serverDebug) *streamSession {
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, bulkCoalesce bool, debug *serverDebug) *streamSession {
s := &streamSession{
sid: sid,
target: target,
targetName: targetName,
maxChunk: maxChunk,
maxBuffer: maxBuffer,
debug: debug,
notify: make(chan struct{}),
lastSeen: time.Now(),
sid: sid,
target: target,
targetName: targetName,
maxChunk: maxChunk,
maxBuffer: maxBuffer,
debug: debug,
bulkCoalesce: bulkCoalesce,
notify: make(chan struct{}),
lastSeen: time.Now(),
}
go s.readTarget()
return s
@@ -155,14 +157,34 @@ func (s *streamSession) readAt(offset uint64, limit int, wait time.Duration) ([]
if firstDataAt.IsZero() {
firstDataAt = time.Now()
}
// Coalesce tiny target reads briefly. This prevents a 1-2 byte
// producer read from becoming a permanent tiny tunnel record.
if available < limit && !s.eof && wait > 0 && time.Since(firstDataAt) < 2*time.Millisecond {
// SSH packetization naturally feeds this stream in ~tens-of-KiB
// bursts. Returning the first burst turns a DragonTCP download into
// one SSH packet per WAN RTT. Internal carrier sessions therefore
// get a slightly wider coalescing window and can accumulate at least
// 512 KiB before the pull response is emitted. Ordinary destinations
// retain the original 2 ms latency-oriented behavior.
coalesceDelay := 2 * time.Millisecond
coalesceGoal := limit
if s.bulkCoalesce {
coalesceDelay = 25 * time.Millisecond
coalesceGoal = 512 * 1024
if coalesceGoal > limit {
coalesceGoal = limit
}
}
elapsed := time.Since(firstDataAt)
if available < limit && available < coalesceGoal && !s.eof && wait > 0 && elapsed < coalesceDelay {
ch := s.notify
remaining := coalesceDelay - elapsed
if untilDeadline := time.Until(deadline); untilDeadline < remaining {
remaining = untilDeadline
}
s.mu.Unlock()
select {
case <-ch:
case <-time.After(2 * time.Millisecond):
if remaining > 0 {
select {
case <-ch:
case <-time.After(remaining):
}
}
continue
}
@@ -339,6 +361,23 @@ func probePattern(n int) []byte {
return out
}
func validateIperfUploadPayload(payload []byte, token string, candidate int) bool {
base := 11 + len(token)
wantLen := candidate
if wantLen < base {
wantLen = base
}
if len(payload) != wantLen {
return false
}
for i := base; i < len(payload); i++ {
if payload[i] != byte((i*31+17)&0xff) {
return false
}
}
return true
}
func parseOpen(payload []byte) (token, host string, port int, err error) {
if len(payload) < 6 {
return "", "", 0, fmt.Errorf("bad OPEN payload")
@@ -395,6 +434,32 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
}
}
return nil
case wire.ProbeIperfUpload:
if value < 1 || value > maxChunk || len(req.Payload) > maxChunk {
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload chunk too large"))
}
if !validateIperfUploadPayload(req.Payload, supplied, value) {
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload validation failed"))
}
if debug != nil && debug.enabled {
debug.logf("CALIBRATION fake_iperf=upload peer=%s chunk=%d bytes=%d seq=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, len(req.Payload), req.Seq)
}
return wire.WriteResponse(conn, wire.StatusOK, nil)
case wire.ProbeIperfDownload:
if value < 1 || value > maxChunk {
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf download chunk too large"))
}
count := wire.ProbeBurstCount(value)
if debug != nil && debug.enabled {
debug.logf("CALIBRATION fake_iperf=download peer=%s chunk=%d records=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, count, value*count)
}
data := probePattern(value)
for i := 0; i < count; i++ {
if err := wire.WriteMaskedResponse(conn, wire.StatusData, data, req.Session, wire.ModeProbe, req.Seq+uint64(i)); err != nil {
return err
}
}
return nil
default:
return wire.WriteResponse(conn, wire.StatusError, []byte("unknown probe kind"))
}
@@ -418,7 +483,8 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
if err != nil {
return wire.WriteResponse(conn, wire.StatusError, []byte(err.Error()))
}
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, debug)
_, internalCarrier := lookupInternalTarget(host, port)
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, internalCarrier, debug)
_, created := manager.addOrGet(req.Session, session)
if created && debug != nil && debug.enabled {
debug.sessionsOpened.Add(1)
+176
View File
@@ -103,6 +103,77 @@ func TestXORProfileProbeEndToEnd(t *testing.T) {
}
}
func TestCoveredProfilesSupportEveryHeaderMask(t *testing.T) {
for n := 0; n < 256; n++ {
mask := byte(n)
for _, xor := range []bool{false, true} {
profile := cover.Profile{
Enabled: true,
ID: uint16(mask)<<8 | uint16(mask^0xa5),
Padding: 0,
HeaderMask: mask,
XOR: xor,
Clear: false,
}
server, client := net.Pipe()
clientResult := make(chan error, 1)
go func() {
defer client.Close()
if err := cover.WritePreface(client, profile); err != nil {
clientResult <- err
return
}
if xor {
if err := protocol.WriteRequestFrameProfile(client, 17, []byte("CPROBE -"), mask); err != nil {
clientResult <- err
return
}
id, payload, err := protocol.ReadResponseFrameProfile(client, mask)
if err == nil && (id != 17 || string(payload) != "PROBEOK") {
err = fmt.Errorf("id=%d payload=%q", id, payload)
}
clientResult <- err
return
}
var sid wire.SessionID
payload := make([]byte, 11)
copy(payload[:4], wire.ProbeMagic[:])
payload[4] = wire.ProbeKeepalive
if err := wire.WriteRequestProfile(client, wire.ModeProbe, sid, 17, payload, mask); err != nil {
clientResult <- err
return
}
status, _, err := wire.ReadResponseProfile(client, mask)
if err == nil && status != wire.StatusOK {
err = fmt.Errorf("status=%d", status)
}
clientResult <- err
}()
profiled, gotXOR, gotMask, err := sniffWire(server)
if err != nil || gotXOR != xor || gotMask != mask {
t.Fatalf("mask=%02x xor=%t sniff got xor=%t mask=%02x err=%v", mask, xor, gotXOR, gotMask, err)
}
if xor {
handleXOR(profiled, gotMask, "", false, nil, 0, nil, 1024, 8, time.Millisecond, nil)
} else {
req, readErr := wire.ReadRequestProfile(profiled, gotMask)
if readErr == nil {
readErr = processWireRequest(profiled, req, "", false, nil, 0, nil, 1024, 0, 0, nil)
}
if readErr != nil {
t.Fatalf("mask=%02x binary server: %v", mask, readErr)
}
}
if err := <-clientResult; err != nil {
t.Fatalf("mask=%02x xor=%t client: %v", mask, xor, err)
}
_ = server.Close()
}
}
}
func TestCoveredProfilesProbeEndToEnd(t *testing.T) {
for _, padding := range []uint16{0, 64, cover.MaxPadding} {
for _, xor := range []bool{false, true} {
@@ -201,3 +272,108 @@ func TestSniffWireRecognizesAllHeaderProfiles(t *testing.T) {
}
}
}
func TestStreamSessionBulkCoalescesSSHLikeBursts(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
var sid wire.SessionID
s := newStreamSession(sid, server, "dragontcp-ssh.internal:2222", 1024*1024, 4*1024*1024, true, nil)
defer s.close()
const packet = 32 * 1024
const packets = 16 // 512 KiB, matching the bulk coalescing goal.
go func() {
buf := make([]byte, packet)
for i := 0; i < packets; i++ {
for j := range buf {
buf[j] = byte(i)
}
if _, err := client.Write(buf); err != nil {
return
}
}
}()
data, status, err := s.readAt(0, 1024*1024, 100*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if status != wire.StatusData {
t.Fatalf("status=%d", status)
}
if len(data) < 512*1024 {
t.Fatalf("bulk carrier returned only %d bytes; want at least 512 KiB", len(data))
}
}
func TestFakeIperfProbeUploadAndDownload(t *testing.T) {
const token = "test-token"
const candidate = 512
var sid wire.SessionID
copy(sid[:], []byte("iperf-test-sid!!"))
makePayload := func(kind byte, total int) []byte {
base := 11 + len(token)
if total < base {
total = base
}
p := make([]byte, total)
copy(p[:4], wire.ProbeMagic[:])
p[4] = kind
binary.BigEndian.PutUint16(p[5:7], uint16(len(token)))
binary.BigEndian.PutUint32(p[7:11], candidate)
copy(p[11:base], token)
for i := base; i < len(p); i++ {
p[i] = byte((i*31 + 17) & 0xff)
}
return p
}
t.Run("upload", func(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
errCh := make(chan error, 1)
go func() {
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 10, Payload: makePayload(wire.ProbeIperfUpload, candidate)}
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
}()
status, body, err := wire.ReadResponse(client)
if err != nil {
t.Fatal(err)
}
if status != wire.StatusOK || len(body) != 0 {
t.Fatalf("upload status=%d body=%q", status, body)
}
if err := <-errCh; err != nil {
t.Fatal(err)
}
})
t.Run("download", func(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
errCh := make(chan error, 1)
go func() {
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 20, Payload: makePayload(wire.ProbeIperfDownload, 0)}
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
}()
want := probePattern(candidate)
for i := 0; i < wire.ProbeBurstCount(candidate); i++ {
status, body, err := wire.ReadResponse(client)
if err != nil {
t.Fatal(err)
}
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeProbe, 20+uint64(i))
if status != wire.StatusData || !bytes.Equal(body, want) {
t.Fatalf("download record=%d status=%d len=%d", i, status, len(body))
}
}
if err := <-errCh; err != nil {
t.Fatal(err)
}
})
}
+854
View File
@@ -0,0 +1,854 @@
package main
import (
"bufio"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"dragontcp/internal/protocol"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
const (
defaultSSHInternalHost = "dragontcp-ssh.internal"
defaultSSHListen = "127.0.0.1:2222"
)
type sshUserRecord struct {
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
type sshUserFile struct {
Version int `json:"version"`
Users []sshUserRecord `json:"users"`
}
type sshUserStore struct {
path string
mu sync.RWMutex
users map[string]sshUserRecord
modTime time.Time
}
func newSSHUserStore(path string) *sshUserStore {
return &sshUserStore{path: path, users: make(map[string]sshUserRecord)}
}
func normalizeSSHUsername(v string) string {
return strings.TrimSpace(v)
}
func validateSSHUsername(v string) error {
v = normalizeSSHUsername(v)
if v == "" {
return errors.New("SSH username is required")
}
if len(v) > 64 {
return errors.New("SSH username is too long")
}
for _, r := range v {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
continue
}
return fmt.Errorf("SSH username contains unsupported character %q", r)
}
return nil
}
func (s *sshUserStore) loadLocked() error {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.users = make(map[string]sshUserRecord)
s.modTime = time.Time{}
return nil
}
return err
}
var file sshUserFile
if err := json.Unmarshal(data, &file); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
users := make(map[string]sshUserRecord, len(file.Users))
for _, u := range file.Users {
u.Username = normalizeSSHUsername(u.Username)
if u.Username == "" || u.PasswordHash == "" {
continue
}
users[u.Username] = u
}
s.users = users
if st, err := os.Stat(s.path); err == nil {
s.modTime = st.ModTime()
}
return nil
}
func (s *sshUserStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadLocked()
}
func (s *sshUserStore) reloadIfChanged() error {
st, err := os.Stat(s.path)
if err != nil {
if os.IsNotExist(err) {
s.mu.RLock()
alreadyEmpty := len(s.users) == 0 && s.modTime.IsZero()
s.mu.RUnlock()
if alreadyEmpty {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
s.users = make(map[string]sshUserRecord)
s.modTime = time.Time{}
return nil
}
return err
}
s.mu.RLock()
unchanged := st.ModTime().Equal(s.modTime)
s.mu.RUnlock()
if unchanged {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if st2, err := os.Stat(s.path); err == nil && st2.ModTime().Equal(s.modTime) {
return nil
}
return s.loadLocked()
}
func (s *sshUserStore) snapshot() ([]sshUserRecord, error) {
if err := s.reloadIfChanged(); err != nil {
return nil, err
}
s.mu.RLock()
out := make([]sshUserRecord, 0, len(s.users))
for _, u := range s.users {
out = append(out, u)
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out, nil
}
func (s *sshUserStore) get(username string) (sshUserRecord, bool) {
_ = s.reloadIfChanged()
s.mu.RLock()
u, ok := s.users[normalizeSSHUsername(username)]
s.mu.RUnlock()
return u, ok
}
func (s *sshUserStore) authenticate(username string, password []byte) (sshUserRecord, error) {
if err := s.reloadIfChanged(); err != nil {
return sshUserRecord{}, err
}
s.mu.RLock()
u, ok := s.users[normalizeSSHUsername(username)]
s.mu.RUnlock()
if !ok || u.Disabled {
return sshUserRecord{}, errors.New("authentication failed")
}
if !u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt) {
return sshUserRecord{}, errors.New("account expired")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), password) != nil {
return sshUserRecord{}, errors.New("authentication failed")
}
return u, nil
}
func (s *sshUserStore) writeRecords(records []sshUserRecord) error {
sort.Slice(records, func(i, j int) bool { return records[i].Username < records[j].Username })
file := sshUserFile{Version: 1, Users: records}
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
dir := filepath.Dir(s.path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return err
}
return s.Load()
}
func (s *sshUserStore) upsert(username, password string, days, maxConnections int) error {
if err := validateSSHUsername(username); err != nil {
return err
}
if password == "" {
return errors.New("SSH password is required")
}
if days < 0 {
return errors.New("account lifetime days cannot be negative")
}
if maxConnections < 0 {
return errors.New("max connections cannot be negative")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
var expires time.Time
if days > 0 {
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
}
records, err := s.snapshot()
if err != nil {
return err
}
updated := false
for i := range records {
if records[i].Username == normalizeSSHUsername(username) {
records[i].PasswordHash = string(hash)
records[i].ExpiresAt = expires
records[i].MaxConnections = maxConnections
records[i].Disabled = false
updated = true
break
}
}
if !updated {
records = append(records, sshUserRecord{
Username: normalizeSSHUsername(username),
PasswordHash: string(hash),
ExpiresAt: expires,
MaxConnections: maxConnections,
})
}
return s.writeRecords(records)
}
func (s *sshUserStore) delete(username string) error {
username = normalizeSSHUsername(username)
records, err := s.snapshot()
if err != nil {
return err
}
out := records[:0]
found := false
for _, u := range records {
if u.Username == username {
found = true
continue
}
out = append(out, u)
}
if !found {
return fmt.Errorf("SSH user %q not found", username)
}
return s.writeRecords(out)
}
func (s *sshUserStore) setPassword(username, password string) error {
username = normalizeSSHUsername(username)
if err := validateSSHUsername(username); err != nil {
return err
}
if password == "" {
return errors.New("SSH password is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
records, err := s.snapshot()
if err != nil {
return err
}
for i := range records {
if records[i].Username == username {
records[i].PasswordHash = string(hash)
return s.writeRecords(records)
}
}
return fmt.Errorf("SSH user %q not found", username)
}
func (s *sshUserStore) updateSettings(username string, days, maxConnections int) error {
username = normalizeSSHUsername(username)
if err := validateSSHUsername(username); err != nil {
return err
}
if days < 0 {
return errors.New("account lifetime days cannot be negative")
}
if maxConnections < 0 {
return errors.New("max connections cannot be negative")
}
var expires time.Time
if days > 0 {
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
}
records, err := s.snapshot()
if err != nil {
return err
}
for i := range records {
if records[i].Username == username {
records[i].ExpiresAt = expires
records[i].MaxConnections = maxConnections
return s.writeRecords(records)
}
}
return fmt.Errorf("SSH user %q not found", username)
}
func generateSSHPassword() (string, error) {
buf := make([]byte, 18)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
type sshRuntime struct {
store *sshUserStore
mu sync.Mutex
conns map[string]int
}
func newSSHRuntime(store *sshUserStore) *sshRuntime {
return &sshRuntime{store: store, conns: make(map[string]int)}
}
func (r *sshRuntime) acquire(username string) (sshUserRecord, error) {
u, ok := r.store.get(username)
if !ok || u.Disabled || (!u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt)) {
return sshUserRecord{}, errors.New("account unavailable")
}
r.mu.Lock()
defer r.mu.Unlock()
if u.MaxConnections > 0 && r.conns[username] >= u.MaxConnections {
return sshUserRecord{}, fmt.Errorf("max connections reached (%d)", u.MaxConnections)
}
r.conns[username]++
return u, nil
}
func (r *sshRuntime) release(username string) {
r.mu.Lock()
if r.conns[username] <= 1 {
delete(r.conns, username)
} else {
r.conns[username]--
}
r.mu.Unlock()
}
func ensureSSHHostSigner(path string) (ssh.Signer, error) {
if data, err := os.ReadFile(path); err == nil {
return ssh.ParsePrivateKey(data)
} else if !os.IsNotExist(err) {
return nil, err
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
data := pem.EncodeToMemory(block)
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
}
if err := os.WriteFile(path, data, 0600); err != nil {
return nil, err
}
return ssh.ParsePrivateKey(data)
}
type sshDirectTCPIPRequest struct {
Host string
Port uint32
OriginHost string
OriginPort uint32
}
func handleSSHDirectTCPIP(newChan ssh.NewChannel, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
var req sshDirectTCPIPRequest
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil || req.Host == "" || req.Port == 0 || req.Port > 65535 {
_ = newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
var backend net.Conn
var err error
if internalAddr, ok := lookupSSHOnlyInternalTarget(req.Host, int(req.Port)); ok {
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
backend, err = d.DialContext(ctx, "tcp", internalAddr)
if err == nil {
protocol.TuneTCP(backend)
protocol.TuneTCPBuffer(backend, tcpBuffer)
}
} else {
backend, err = dialTarget(ctx, req.Host, int(req.Port), allowPrivate, cache, tcpBuffer)
}
if err != nil {
_ = newChan.Reject(ssh.ConnectionFailed, "connect failed")
return
}
ch, reqs, err := newChan.Accept()
if err != nil {
_ = backend.Close()
return
}
go ssh.DiscardRequests(reqs)
// Preserve TCP half-close semantics. A client may finish uploading while the
// destination is still sending a large response, so do not close both sides
// merely because one copy direction reached EOF.
var relayWG sync.WaitGroup
relayWG.Add(2)
go func() {
defer relayWG.Done()
_, _ = io.Copy(backend, ch)
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
go func() {
defer relayWG.Done()
_, _ = io.Copy(ch, backend)
if cw, ok := ch.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
relayWG.Wait()
_ = backend.Close()
_ = ch.Close()
}
func handleSSHDummySession(newChan ssh.NewChannel) {
ch, reqs, err := newChan.Accept()
if err != nil {
return
}
go func() {
defer ch.Close()
for req := range reqs {
if req.WantReply {
_ = req.Reply(false, nil)
}
}
}()
}
func serveSSHConn(conn net.Conn, cfg *ssh.ServerConfig, runtime *sshRuntime, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
return
}
_ = conn.SetDeadline(time.Time{})
username := sshConn.User()
user, err := runtime.acquire(username)
if err != nil {
log.Printf("fake-ssh rejected user=%q remote=%s: %v", username, sshConn.RemoteAddr(), err)
_ = sshConn.Close()
return
}
log.Printf("fake-ssh connected user=%q remote=%s mode=tunnel-only", username, sshConn.RemoteAddr())
defer func() {
runtime.release(username)
log.Printf("fake-ssh disconnected user=%q remote=%s", username, sshConn.RemoteAddr())
}()
defer sshConn.Close()
if !user.ExpiresAt.IsZero() {
remaining := time.Until(user.ExpiresAt)
if remaining <= 0 {
return
}
expiryTimer := time.AfterFunc(remaining, func() { _ = sshConn.Close() })
defer expiryTimer.Stop()
}
go ssh.DiscardRequests(reqs)
for newChan := range chans {
switch newChan.ChannelType() {
case "direct-tcpip":
go handleSSHDirectTCPIP(newChan, allowPrivate, cache, tcpBuffer)
case "session":
go handleSSHDummySession(newChan)
default:
_ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
}
}
}
func startFakeSSH(listenAddr, hostKeyPath string, store *sshUserStore, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Listener, string, error) {
if err := store.Load(); err != nil {
return nil, "", err
}
signer, err := ensureSSHHostSigner(hostKeyPath)
if err != nil {
return nil, "", err
}
runtime := newSSHRuntime(store)
cfg := &ssh.ServerConfig{
NoClientAuth: false,
PasswordCallback: func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if _, err := store.authenticate(meta.User(), password); err != nil {
log.Printf("fake-ssh auth failed user=%q remote=%s", meta.User(), meta.RemoteAddr())
return nil, err
}
return nil, nil
},
}
cfg.AddHostKey(signer)
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, "", err
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
log.Printf("fake-ssh accept: %v", err)
continue
}
go serveSSHConn(conn, cfg, runtime, allowPrivate, cache, tcpBuffer)
}
}()
return ln, ssh.FingerprintSHA256(signer.PublicKey()), nil
}
type sshCLIFlags struct {
usersPath *string
addUser *string
deleteUser *string
password *string
passwordEnv *string
days *int
maxConnections *int
listUsers *bool
menu *bool
}
func registerSSHCLIFlags() sshCLIFlags {
return sshCLIFlags{
usersPath: flag.String("ssh-users", "dragontcp-users.json", "fake SSH user database JSON path"),
addUser: flag.String("ssh-user-add", "", "create or update an SSH tunnel user, then exit"),
deleteUser: flag.String("ssh-user-delete", "", "delete an SSH tunnel user, then exit"),
password: flag.String("ssh-user-password", "", "password used with --ssh-user-add"),
passwordEnv: flag.String("ssh-user-password-env", "", "environment variable containing password for --ssh-user-add"),
days: flag.Int("ssh-user-days", 0, "account lifetime in days; 0 means no expiry"),
maxConnections: flag.Int("ssh-user-max-connections", 1, "maximum simultaneous SSH connections for the account; 0 means unlimited"),
listUsers: flag.Bool("ssh-user-list", false, "list SSH tunnel users, then exit"),
menu: flag.Bool("ssh-menu", false, "interactive SSH tunnel user management menu, then exit"),
}
}
func menuReadLine(reader *bufio.Reader, prompt string) (string, error) {
fmt.Print(prompt)
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return strings.TrimSpace(line), nil
}
func menuReadInt(reader *bufio.Reader, prompt string, defaultValue, minValue int) (int, error) {
for {
line, err := menuReadLine(reader, prompt)
if err != nil {
return 0, err
}
if line == "" {
return defaultValue, nil
}
v, err := strconv.Atoi(line)
if err != nil || v < minValue {
fmt.Printf("Enter a number >= %d.\n", minValue)
continue
}
return v, nil
}
}
func printSSHUserList(store *sshUserStore) error {
records, err := store.snapshot()
if err != nil {
return err
}
if len(records) == 0 {
fmt.Println("No SSH tunnel users.")
return nil
}
fmt.Printf("%-22s %-26s %-16s %s\n", "USERNAME", "EXPIRES", "MAX CONNECTIONS", "STATUS")
fmt.Printf("%-22s %-26s %-16s %s\n", strings.Repeat("-", 8), strings.Repeat("-", 7), strings.Repeat("-", 15), strings.Repeat("-", 6))
now := time.Now()
for _, u := range records {
expiry := "never"
status := "active"
if !u.ExpiresAt.IsZero() {
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
if now.After(u.ExpiresAt) {
status = "expired"
}
}
if u.Disabled {
status = "disabled"
}
max := "unlimited"
if u.MaxConnections > 0 {
max = strconv.Itoa(u.MaxConnections)
}
fmt.Printf("%-22s %-26s %-16s %s\n", u.Username, expiry, max, status)
}
return nil
}
func runSSHUserMenu(store *sshUserStore) error {
if err := store.Load(); err != nil {
return err
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println()
fmt.Println("========================================")
fmt.Println(" DragonTCP SSH Tunnel User Manager")
fmt.Println("========================================")
fmt.Printf("User database: %s\n\n", store.path)
fmt.Println(" 1) Create user (automatic password)")
fmt.Println(" 2) Delete user")
fmt.Println(" 3) List users")
fmt.Println(" 4) Reset user password (automatic)")
fmt.Println(" 5) Renew/edit expiry and connection limit")
fmt.Println(" 0) Exit")
choice, err := menuReadLine(reader, "\nSelect: ")
if err != nil {
return err
}
switch choice {
case "0", "q", "quit", "exit":
return nil
case "1":
username, err := menuReadLine(reader, "Username: ")
if err != nil {
return err
}
if err := validateSSHUsername(username); err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
if _, exists := store.get(username); exists {
fmt.Printf("User %q already exists. Use option 4 or 5 to change it.\n", username)
continue
}
days, err := menuReadInt(reader, "Days [30, 0 = never expires]: ", 30, 0)
if err != nil {
return err
}
maxConnections, err := menuReadInt(reader, "Max connections [1, 0 = unlimited]: ", 1, 0)
if err != nil {
return err
}
password, err := generateSSHPassword()
if err != nil {
fmt.Printf("Error generating password: %v\n", err)
continue
}
if err := store.upsert(username, password, days, maxConnections); err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
u, _ := store.get(username)
expiry := "never"
if !u.ExpiresAt.IsZero() {
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
}
fmt.Println("\nUser created successfully.")
fmt.Printf("Username: %s\n", u.Username)
fmt.Printf("Password: %s\n", password)
fmt.Printf("Expires: %s\n", expiry)
fmt.Printf("Max connections: %d\n", u.MaxConnections)
fmt.Println("Save the password now. DragonTCP stores only its bcrypt hash and cannot display it later.")
case "2":
username, err := menuReadLine(reader, "Username to delete: ")
if err != nil {
return err
}
if _, exists := store.get(username); !exists {
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
continue
}
confirm, err := menuReadLine(reader, fmt.Sprintf("Delete %q? [y/N]: ", normalizeSSHUsername(username)))
if err != nil {
return err
}
if !strings.EqualFold(confirm, "y") && !strings.EqualFold(confirm, "yes") {
fmt.Println("Delete cancelled.")
continue
}
if err := store.delete(username); err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("User %q deleted.\n", normalizeSSHUsername(username))
case "3":
if err := printSSHUserList(store); err != nil {
fmt.Printf("Error: %v\n", err)
}
case "4":
username, err := menuReadLine(reader, "Username: ")
if err != nil {
return err
}
if _, exists := store.get(username); !exists {
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
continue
}
password, err := generateSSHPassword()
if err != nil {
fmt.Printf("Error generating password: %v\n", err)
continue
}
if err := store.setPassword(username, password); err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("New password for %s: %s\n", normalizeSSHUsername(username), password)
fmt.Println("Save it now; only the bcrypt hash is stored.")
case "5":
username, err := menuReadLine(reader, "Username: ")
if err != nil {
return err
}
u, exists := store.get(username)
if !exists {
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
continue
}
days, err := menuReadInt(reader, "New lifetime from now in days [30, 0 = never expires]: ", 30, 0)
if err != nil {
return err
}
maxDefault := u.MaxConnections
maxConnections, err := menuReadInt(reader, fmt.Sprintf("Max connections [%d, 0 = unlimited]: ", maxDefault), maxDefault, 0)
if err != nil {
return err
}
if err := store.updateSettings(username, days, maxConnections); err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("User %q updated. Password was not changed.\n", normalizeSSHUsername(username))
default:
fmt.Println("Invalid selection.")
}
}
}
func handleSSHCLI(flags sshCLIFlags) (bool, error) {
store := newSSHUserStore(*flags.usersPath)
actions := 0
if strings.TrimSpace(*flags.addUser) != "" {
actions++
}
if strings.TrimSpace(*flags.deleteUser) != "" {
actions++
}
if *flags.listUsers {
actions++
}
if *flags.menu {
actions++
}
if actions == 0 {
return false, nil
}
if actions > 1 {
return true, errors.New("choose only one of --ssh-menu, --ssh-user-add, --ssh-user-delete, or --ssh-user-list")
}
if *flags.menu {
return true, runSSHUserMenu(store)
}
if strings.TrimSpace(*flags.addUser) != "" {
password := *flags.password
if *flags.passwordEnv != "" {
password = os.Getenv(*flags.passwordEnv)
}
if err := store.upsert(*flags.addUser, password, *flags.days, *flags.maxConnections); err != nil {
return true, err
}
u, _ := store.get(*flags.addUser)
expiry := "never"
if !u.ExpiresAt.IsZero() {
expiry = u.ExpiresAt.Format(time.RFC3339)
}
fmt.Printf("SSH user %s saved (expires=%s max_connections=%d)\n", u.Username, expiry, u.MaxConnections)
return true, nil
}
if strings.TrimSpace(*flags.deleteUser) != "" {
if err := store.delete(*flags.deleteUser); err != nil {
return true, err
}
fmt.Printf("SSH user %s deleted\n", normalizeSSHUsername(*flags.deleteUser))
return true, nil
}
records, err := store.snapshot()
if err != nil {
return true, err
}
for _, u := range records {
expiry := "never"
if !u.ExpiresAt.IsZero() {
expiry = u.ExpiresAt.Format(time.RFC3339)
}
fmt.Printf("%s expires=%s max_connections=%d disabled=%t\n", u.Username, expiry, u.MaxConnections, u.Disabled)
}
return true, nil
}
@@ -0,0 +1,73 @@
package main
import (
"path/filepath"
"testing"
"time"
)
func TestGeneratedSSHPassword(t *testing.T) {
p1, err := generateSSHPassword()
if err != nil {
t.Fatal(err)
}
p2, err := generateSSHPassword()
if err != nil {
t.Fatal(err)
}
if len(p1) != 24 || len(p2) != 24 {
t.Fatalf("generated password lengths = %d, %d; want 24", len(p1), len(p2))
}
if p1 == p2 {
t.Fatal("two generated passwords were identical")
}
}
func TestSSHUserMenuOperationsPreservePasswordWhenEditingSettings(t *testing.T) {
store := newSSHUserStore(filepath.Join(t.TempDir(), "users.json"))
if err := store.upsert("alice", "initial-password", 7, 2); err != nil {
t.Fatal(err)
}
before, ok := store.get("alice")
if !ok {
t.Fatal("user missing after create")
}
if err := store.updateSettings("alice", 30, 5); err != nil {
t.Fatal(err)
}
after, ok := store.get("alice")
if !ok {
t.Fatal("user missing after settings update")
}
if after.PasswordHash != before.PasswordHash {
t.Fatal("editing expiry/connection limit changed password hash")
}
if after.MaxConnections != 5 {
t.Fatalf("max connections = %d; want 5", after.MaxConnections)
}
if after.ExpiresAt.Before(time.Now().UTC().Add(29 * 24 * time.Hour)) {
t.Fatalf("expiry was not renewed: %v", after.ExpiresAt)
}
if err := store.setPassword("alice", "replacement-password"); err != nil {
t.Fatal(err)
}
reset, ok := store.get("alice")
if !ok {
t.Fatal("user missing after password reset")
}
if reset.PasswordHash == after.PasswordHash {
t.Fatal("password reset did not change password hash")
}
if reset.MaxConnections != after.MaxConnections || !reset.ExpiresAt.Equal(after.ExpiresAt) {
t.Fatal("password reset changed account limits")
}
if err := store.delete("alice"); err != nil {
t.Fatal(err)
}
if _, ok := store.get("alice"); ok {
t.Fatal("user still present after delete")
}
}
@@ -0,0 +1,59 @@
package main
import (
"net"
"strconv"
"strings"
"sync"
)
type internalTargetRegistry struct {
sync.RWMutex
m map[string]string
}
var dragonTCPInternalTargets = internalTargetRegistry{m: make(map[string]string)}
var sshOnlyInternalTargets = internalTargetRegistry{m: make(map[string]string)}
func internalTargetKey(host string, port int) string {
return strings.ToLower(strings.TrimSpace(host)) + ":" + strconv.Itoa(port)
}
func registerInternalTarget(host string, port int, dialAddr string) {
registerTarget(&dragonTCPInternalTargets, host, port, dialAddr)
}
func lookupInternalTarget(host string, port int) (string, bool) {
return lookupTarget(&dragonTCPInternalTargets, host, port)
}
// registerSSHOnlyInternalTarget creates a destination that is reachable only
// after SSH authentication. It is deliberately not exposed to raw DragonTCP
// clients, which prevents direct access to services such as the UDP gateway.
func registerSSHOnlyInternalTarget(host string, port int, dialAddr string) {
registerTarget(&sshOnlyInternalTargets, host, port, dialAddr)
}
func lookupSSHOnlyInternalTarget(host string, port int) (string, bool) {
return lookupTarget(&sshOnlyInternalTargets, host, port)
}
func registerTarget(registry *internalTargetRegistry, host string, port int, dialAddr string) {
if strings.TrimSpace(host) == "" || port < 1 || port > 65535 || strings.TrimSpace(dialAddr) == "" {
return
}
registry.Lock()
registry.m[internalTargetKey(host, port)] = dialAddr
registry.Unlock()
}
func lookupTarget(registry *internalTargetRegistry, host string, port int) (string, bool) {
registry.RLock()
addr, ok := registry.m[internalTargetKey(host, port)]
registry.RUnlock()
return addr, ok
}
func dialInternalTarget(dialer *net.Dialer, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
@@ -0,0 +1,16 @@
package main
import "testing"
func TestSSHOnlyInternalTargetIsNotPublicDragonTCPTarget(t *testing.T) {
const host = "test-udpgw.internal"
const port = 17400
registerSSHOnlyInternalTarget(host, port, "127.0.0.1:17400")
if _, ok := lookupInternalTarget(host, port); ok {
t.Fatal("SSH-only target leaked into raw DragonTCP internal target registry")
}
if got, ok := lookupSSHOnlyInternalTarget(host, port); !ok || got != "127.0.0.1:17400" {
t.Fatalf("SSH-only target lookup = %q, %v", got, ok)
}
}
+97 -2
View File
@@ -14,6 +14,7 @@ import (
"sync/atomic"
"time"
"dragontcp/internal/cover"
"dragontcp/internal/protocol"
)
@@ -135,6 +136,17 @@ func addressAllowed(addr netip.Addr, allowPrivate bool) bool {
}
func dialTarget(ctx context.Context, host string, port int, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Conn, error) {
if internalAddr, ok := lookupInternalTarget(host, port); ok {
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
conn, err := d.DialContext(ctx, "tcp", internalAddr)
if err != nil {
return nil, err
}
protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, tcpBuffer)
return conn, nil
}
ips, err := cache.resolve(ctx, host)
if err != nil {
return nil, err
@@ -215,11 +227,24 @@ func handle(
return
}
clearPayload := false
coverID := uint16(0)
covered := false
if profiled, ok := conn.(interface{ ClearPayload() bool }); ok {
clearPayload = profiled.ClearPayload()
}
if profiled, ok := conn.(interface{ CoverProfile() cover.Profile }); ok {
profile := profiled.CoverProfile()
if profile.Enabled {
covered = true
coverID = profile.ID
}
}
if debug != nil && debug.enabled {
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t", conn.RemoteAddr(), headerMask, clearPayload)
if covered {
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=%04x", conn.RemoteAddr(), headerMask, clearPayload, coverID)
} else {
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=direct", conn.RemoteAddr(), headerMask, clearPayload)
}
}
handleBinary(conn, headerMask, clearPayload, token, allowPrivate, cache, tcpBuffer, manager,
@@ -268,6 +293,7 @@ func acceptLoop(
}
func main() {
sshCLI := registerSSHCLIFlags()
var (
host = flag.String("host", "0.0.0.0", "listen host")
port = flag.Int("port", 53, "listen port")
@@ -285,9 +311,27 @@ func main() {
debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics")
debugChunks = flag.Bool("debug-chunks", false, "log every chunk protocol record; very verbose")
debugStats = flag.Duration("debug-stats-interval", 5*time.Second, "periodic debug statistics interval; 0 disables")
sshEnable = flag.Bool("ssh-enable", true, "enable the internal tunnel-only SSH service")
sshListen = flag.String("ssh-listen", defaultSSHListen, "internal fake SSH listen address")
sshInternalHost = flag.String("ssh-internal-host", defaultSSHInternalHost, "reserved DragonTCP target name used by clients for SSH")
sshHostKey = flag.String("ssh-host-key", "dragontcp_ssh_host_key", "SSH host private-key path; generated automatically if missing")
udpgwEnable = flag.Bool("udpgw-enable", true, "enable integrated BadVPN-compatible UDPGW")
udpgwListen = flag.String("udpgw-listen", "127.0.0.1:7400", "UDPGW listen address; loopback is recommended")
udpgwInternalHost = flag.String("udpgw-internal-host", "dragontcp-udpgw.internal", "reserved SSH direct-tcpip target name for UDPGW")
udpgwMaxClients = flag.Int("udpgw-max-clients", 10000, "maximum concurrent UDPGW TCP clients")
udpgwDebug = flag.Bool("udpgw-debug", false, "verbose UDPGW errors")
)
flag.Parse()
if handled, err := handleSSHCLI(sshCLI); handled {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
return
}
if *chunkMax < 32 || *chunkMax > protocol.MaxChunkPayload {
fmt.Fprintf(os.Stderr, "--chunk-max must be between 32 and %d\n", protocol.MaxChunkPayload)
os.Exit(2)
@@ -297,6 +341,58 @@ func main() {
os.Exit(2)
}
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
var udpServer *udpgwServer
if *udpgwEnable {
var err error
udpServer, err = startUDPGWServer(udpgwServerConfig{
Listen: *udpgwListen, MaxClients: *udpgwMaxClients, Debug: *udpgwDebug,
})
if err != nil {
fmt.Fprintf(os.Stderr, "UDPGW start failed: %v\n", err)
os.Exit(1)
}
defer udpServer.Close()
_, udpPortText, err := net.SplitHostPort(udpServer.ln.Addr().String())
if err != nil {
fmt.Fprintf(os.Stderr, "invalid UDPGW listener: %v\n", err)
os.Exit(2)
}
udpPort, err := strconv.Atoi(udpPortText)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid UDPGW port: %v\n", err)
os.Exit(2)
}
registerSSHOnlyInternalTarget(*udpgwInternalHost, udpPort, udpServer.ln.Addr().String())
fmt.Printf("udpgw=true listen=%s internal_target=%s:%d max_clients=%d\n", udpServer.ln.Addr(), *udpgwInternalHost, udpPort, *udpgwMaxClients)
}
var sshListener net.Listener
if *sshEnable {
sshStore := newSSHUserStore(*sshCLI.usersPath)
listener, fingerprint, err := startFakeSSH(*sshListen, *sshHostKey, sshStore, *allowPrivate, cache, *tcpBuffer)
if err != nil {
fmt.Fprintf(os.Stderr, "fake SSH start failed: %v\n", err)
os.Exit(1)
}
sshListener = listener
defer sshListener.Close()
sshBoundAddr := sshListener.Addr().String()
_, sshPortText, err := net.SplitHostPort(sshBoundAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid fake SSH listener: %v\n", err)
os.Exit(2)
}
sshPort, err := strconv.Atoi(sshPortText)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid fake SSH listener port: %v\n", err)
os.Exit(2)
}
registerInternalTarget(*sshInternalHost, sshPort, sshBoundAddr)
fmt.Printf("fake_ssh=true listen=%s internal_target=%s:%d hostkey=%s users=%s\n", sshBoundAddr, *sshInternalHost, sshPort, fingerprint, *sshCLI.usersPath)
}
listenAddr := net.JoinHostPort(*host, strconv.Itoa(*port))
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
@@ -324,7 +420,6 @@ func main() {
fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer)
slots := make(chan struct{}, *maxConnections)
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
debug := newServerDebug(*debugEnabled, *debugChunks, *debugStats)
bufferBytes := *chunkBuffered * 65536
if bufferBytes < 1024*1024 {
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"net"
"sync"
"time"
)
type udpgwServerConfig struct {
Listen string
MaxFrame int
MaxClients int
MaxClientConns int
MaxMapEntries int
MapTTL time.Duration
IdleTimeout time.Duration
Debug bool
}
type udpgwServer struct {
cfg udpgwServerConfig
ln net.Listener
slots chan struct{}
closeMu sync.Once
}
type udpDestKey struct {
ip [4]byte
port uint16
}
type udpMapVal struct {
connID uint16
x byte
exp time.Time
}
func startUDPGWServer(cfg udpgwServerConfig) (*udpgwServer, error) {
if cfg.Listen == "" {
cfg.Listen = "127.0.0.1:7400"
}
if cfg.MaxFrame <= 0 || cfg.MaxFrame > 65535 {
cfg.MaxFrame = 65535
}
if cfg.MaxClients <= 0 {
cfg.MaxClients = 10000
}
if cfg.MaxClientConns <= 0 {
cfg.MaxClientConns = 64
}
if cfg.MaxMapEntries <= 0 {
cfg.MaxMapEntries = 32768
}
if cfg.MapTTL <= 0 {
cfg.MapTTL = 90 * time.Second
}
if cfg.IdleTimeout <= 0 {
cfg.IdleTimeout = 2 * time.Minute
}
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
return nil, err
}
s := &udpgwServer{cfg: cfg, ln: ln, slots: make(chan struct{}, cfg.MaxClients)}
go s.acceptLoop()
return s, nil
}
func (s *udpgwServer) Close() error {
var err error
s.closeMu.Do(func() { err = s.ln.Close() })
return err
}
func (s *udpgwServer) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
log.Printf("udpgw accept: %v", err)
continue
}
select {
case s.slots <- struct{}{}:
go func() {
defer func() { <-s.slots }()
s.handleClient(conn)
}()
default:
_ = conn.Close()
}
}
}
func (s *udpgwServer) handleClient(conn net.Conn) {
defer conn.Close()
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
udpConn, err := net.ListenUDP("udp4", nil)
if err != nil {
return
}
defer udpConn.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
writeCh := make(chan []byte, 256)
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case frame := <-writeCh:
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
if _, err := conn.Write(frame); err != nil {
cancel()
_ = conn.Close()
return
}
}
}
}()
var mu sync.Mutex
mappings := make(map[udpDestKey]udpMapVal)
connSeen := make(map[uint16]time.Time)
go func() {
buf := make([]byte, 65535)
for {
n, from, err := udpConn.ReadFromUDP(buf)
if err != nil {
return
}
ip4 := from.IP.To4()
if ip4 == nil || n <= 0 {
continue
}
var ip [4]byte
copy(ip[:], ip4)
key := udpDestKey{ip: ip, port: uint16(from.Port)}
mu.Lock()
v, ok := mappings[key]
mu.Unlock()
if !ok || time.Now().After(v.exp) {
continue
}
frame := udpgwBuildFrame(v.connID, v.x, ip, uint16(from.Port), buf[:n])
select {
case writeCh <- frame:
default:
}
}
}()
reap := time.NewTicker(10 * time.Second)
defer reap.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case now := <-reap.C:
mu.Lock()
for k, v := range mappings {
if now.After(v.exp) {
delete(mappings, k)
}
}
for id, seen := range connSeen {
if now.Sub(seen) > s.cfg.MapTTL {
delete(connSeen, id)
}
}
mu.Unlock()
}
}
}()
br := bufio.NewReaderSize(conn, 32*1024)
for {
_ = conn.SetReadDeadline(time.Now().Add(s.cfg.IdleTimeout))
payload, err := udpgwReadPayload(br, s.cfg.MaxFrame)
if err != nil {
cancel()
_ = conn.Close()
<-done
return
}
if len(payload) < 9 {
continue
}
connID := binary.BigEndian.Uint16(payload[0:2])
x := payload[2]
var dstIP [4]byte
copy(dstIP[:], payload[3:7])
dstPort := binary.BigEndian.Uint16(payload[7:9])
data := payload[9:]
now := time.Now()
key := udpDestKey{ip: dstIP, port: dstPort}
mu.Lock()
for id, seen := range connSeen {
if now.Sub(seen) > s.cfg.MapTTL {
delete(connSeen, id)
}
}
if _, ok := connSeen[connID]; !ok && len(connSeen) >= s.cfg.MaxClientConns {
var oldestID uint16
var oldestTime time.Time
first := true
for id, seen := range connSeen {
if first || seen.Before(oldestTime) {
oldestID, oldestTime, first = id, seen, false
}
}
delete(connSeen, oldestID)
}
connSeen[connID] = now
if len(mappings) >= s.cfg.MaxMapEntries {
for k, v := range mappings {
if now.After(v.exp) {
delete(mappings, k)
}
}
if len(mappings) >= s.cfg.MaxMapEntries {
for k := range mappings {
delete(mappings, k)
break
}
}
}
mappings[key] = udpMapVal{connID: connID, x: x, exp: now.Add(s.cfg.MapTTL)}
mu.Unlock()
addr := &net.UDPAddr{IP: net.IPv4(dstIP[0], dstIP[1], dstIP[2], dstIP[3]), Port: int(dstPort)}
if _, err := udpConn.WriteToUDP(data, addr); err != nil && s.cfg.Debug {
log.Printf("udpgw write %s: %v", addr, err)
}
}
}
func udpgwReadPayload(r *bufio.Reader, max int) ([]byte, error) {
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
if n <= 0 || n > max {
return nil, fmt.Errorf("udpgw invalid frame length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}
func udpgwBuildFrame(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
payloadLen := 9 + len(data)
out := make([]byte, 2+payloadLen)
binary.LittleEndian.PutUint16(out[0:2], uint16(payloadLen))
binary.BigEndian.PutUint16(out[2:4], connID)
out[4] = x
copy(out[5:9], ip[:])
binary.BigEndian.PutUint16(out[9:11], port)
copy(out[11:], data)
return out
}
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"bufio"
"encoding/binary"
"io"
"net"
"testing"
"time"
)
func TestUDPGWRelaysIPv4Datagram(t *testing.T) {
echo, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer echo.Close()
go func() {
b := make([]byte, 2048)
n, addr, e := echo.ReadFromUDP(b)
if e == nil {
_, _ = echo.WriteToUDP(b[:n], addr)
}
}()
srv, err := startUDPGWServer(udpgwServerConfig{Listen: "127.0.0.1:0", MaxClients: 4})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
c, err := net.DialTimeout("tcp", srv.ln.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
port := echo.LocalAddr().(*net.UDPAddr).Port
data := []byte("udpgw-ok")
payloadLen := 9 + len(data)
frame := make([]byte, 2+payloadLen)
binary.LittleEndian.PutUint16(frame[:2], uint16(payloadLen))
binary.BigEndian.PutUint16(frame[2:4], 1)
frame[4] = 0
copy(frame[5:9], []byte{127, 0, 0, 1})
binary.BigEndian.PutUint16(frame[9:11], uint16(port))
copy(frame[11:], data)
if _, err := c.Write(frame); err != nil {
t.Fatal(err)
}
r := bufio.NewReader(c)
var lb [2]byte
if _, err := io.ReadFull(r, lb[:]); err != nil {
t.Fatal(err)
}
n := int(binary.LittleEndian.Uint16(lb[:]))
reply := make([]byte, n)
if _, err := io.ReadFull(r, reply); err != nil {
t.Fatal(err)
}
if n < 9 || string(reply[9:]) != string(data) {
t.Fatalf("bad reply n=%d data=%q", n, reply[9:])
}
}
+42
View File
@@ -357,6 +357,8 @@ func decodeWireToken(token string) string {
func isChunkCommand(payload []byte) bool {
return bytes.HasPrefix(payload, []byte("CPROBE ")) ||
bytes.HasPrefix(payload, []byte("CIPERFUP ")) ||
bytes.HasPrefix(payload, []byte("CIPERFDW ")) ||
bytes.HasPrefix(payload, []byte("COPEN ")) ||
bytes.HasPrefix(payload, []byte("CPUSH ")) ||
bytes.HasPrefix(payload, []byte("CPULL ")) ||
@@ -388,6 +390,46 @@ func processChunkCommand(
return protocol.WriteResponseFrame(conn, requestID, []byte("PROBEOK"))
}
if bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
parts := bytes.SplitN(payload, []byte(" "), 4)
if len(parts) != 4 {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFUP"))
}
if !tokenEqual(decodeWireToken(string(parts[1])), token) {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
}
size, err := strconv.Atoi(string(parts[2]))
if err != nil || size < 1 || size > maxChunk {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload chunk too large"))
}
data := parts[3]
if len(data) != size || !bytes.Equal(data, probePattern(size)) {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload validation failed"))
}
if debug != nil && debug.enabled {
debug.logf("CALIBRATION fake_iperf=upload wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, len(data))
}
return protocol.WriteResponseFrame(conn, requestID, []byte("IPERFOK"))
}
if bytes.HasPrefix(payload, []byte("CIPERFDW ")) {
parts := strings.Fields(string(payload))
if len(parts) != 3 {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFDW"))
}
if !tokenEqual(decodeWireToken(parts[1]), token) {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
}
size, err := strconv.Atoi(parts[2])
if err != nil || size < 1 || size > maxChunk {
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf download chunk too large"))
}
if debug != nil && debug.enabled {
debug.logf("CALIBRATION fake_iperf=download wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, size)
}
return protocol.WriteResponseFrame(conn, requestID, probePattern(size))
}
if bytes.HasPrefix(payload, []byte("COPEN ")) {
parts := strings.Fields(string(payload))
if len(parts) != 5 {