Mult Port + TCP Calibration (SSH DEAD)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user