From d3e4cfc1dbd20f121b15e7f9cc07cdbb39bd4eb3 Mon Sep 17 00:00:00 2001 From: samosvalishe Date: Tue, 22 Sep 2026 00:03:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth)!:=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20Client=20ID?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 44 ++++---- internal/clientsdb/db.go | 119 ++++++++++++++++---- internal/clientsdb/db_test.go | 168 ++++++++++++++++++++-------- internal/proxy/tcprelay/tcprelay.go | 2 +- internal/proxy/udprelay/loop.go | 2 +- 5 files changed, 245 insertions(+), 90 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index edea1e6..29674e3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -202,40 +202,38 @@ func handleAccepted(ctx context.Context, logger logx.Logger, db *clientsdb.DB, c } logger.Debugf("Handshake done") - // Wire-контракт: клиент всегда передаёт Client ID первой app-record. - clientID, clientMode, err := clientsdb.ReadClientID(dtlsConn) + clientID, data, err := clientsdb.AcceptClientID(dtlsConn, func(id string, mode byte) error { + return admitClient(cfg, db, id, mode) + }) if err != nil { - logger.Warnf("Read Client ID failed: %v", err) + logger.Warnf("Client ID from %s rejected: %v", conn.RemoteAddr(), err) return } - if want := wireMode(cfg.Proxy.Mode); clientMode != clientsdb.ModeUnset && clientMode != want { - logger.Warnf("Mode mismatch from %s: клиент %s, сервер %s - трафик не пойдёт, приведите -mode к одному значению", - conn.RemoteAddr(), modeName(clientMode), cfg.Proxy.Mode) - return - } - if clientMode == clientsdb.ModeUnset && cfg.Proxy.Mode == config.ProxyModeTCP { - logger.Warnf("Mode mismatch from %s: клиент без тега режима (udp), сервер tcp", conn.RemoteAddr()) - return - } - if db != nil { - if !db.IsAuthorized(clientID) { - logger.Warnf("Unauthorized Client ID: %s. Dropping connection.", clientID) - return - } - logger.Debugf("Client %s authorized", clientID) - } else { - logger.Debugf("Client ID received (no allowlist): %s", clientID) - } logger.Infof("Session up: client=%s from=%s", clientID, conn.RemoteAddr()) if cfg.Proxy.Mode == config.ProxyModeTCP { - tcpserver.Handle(ctx, logger, dtlsConn, cfg.Proxy.Connect, cfg.KCP.Profile) + tcpserver.Handle(ctx, logger, data, cfg.Proxy.Connect, cfg.KCP.Profile) } else { - udpserver.Handle(ctx, logger, conn, cfg.Proxy.Connect) + udpserver.Handle(ctx, logger, data, cfg.Proxy.Connect) } logger.Infof("Session down: client=%s from=%s", clientID, conn.RemoteAddr()) } +// admitClient: режим клиента обязан совпасть с сервером, ID - быть в allowlist (если он есть). +func admitClient(cfg *config.Server, db *clientsdb.DB, id string, mode byte) error { + if want := wireMode(cfg.Proxy.Mode); mode != clientsdb.ModeUnset && mode != want { + return fmt.Errorf("mode mismatch: клиент %s, сервер %s - приведите -mode к одному значению", + modeName(mode), cfg.Proxy.Mode) + } + if mode == clientsdb.ModeUnset && cfg.Proxy.Mode == config.ProxyModeTCP { + return errors.New("mode mismatch: клиент без тега режима (udp), сервер tcp") + } + if db != nil && !db.IsAuthorized(id) { + return fmt.Errorf("unauthorized client ID %s", id) + } + return nil +} + func handleClientsCommand(args []string) { if len(args) == 0 { fmt.Println("Usage: server clients [args...]") diff --git a/internal/clientsdb/db.go b/internal/clientsdb/db.go index 8f0ca46..fd96e25 100644 --- a/internal/clientsdb/db.go +++ b/internal/clientsdb/db.go @@ -1,8 +1,10 @@ package clientsdb import ( + "bytes" "context" "encoding/json" + "errors" "fmt" "io" "maps" @@ -187,50 +189,125 @@ func syncDir(dir string) { _ = d.Close() } -// Тег режима едет хвостом той же записи: клиент до этого поля его не писал, а читатель -// брал ровно 1+len байт - лишний байт старый сервер молча пропускает. const ( ModeUnset byte = 0 ModeUDP byte = 1 ModeTCP byte = 2 + + // idVersionAck - клиент ждёт подтверждения ID. + idVersionAck byte = 2 + idAck byte = 0x06 + + idReadTimeout = 5 * time.Second ) -// WriteClientID отправляет Client ID (1 байт длины + строка + 1 байт режима). -func WriteClientID(conn net.Conn, clientID string, mode byte) error { +// ErrNoIDAck - сервер не подтвердил Client ID: он старше протокола подтверждения или +// канал теряет всё подряд. +var ErrNoIDAck = errors.New("clientsdb: server did not acknowledge client ID (server outdated?)") + +// idRetransmit - таймеры повтора ID (RFC 6347 §4.2.4: старт 1 с, удвоение). +var idRetransmit = []time.Duration{time.Second, 2 * time.Second, 4 * time.Second} + +func idRecord(clientID string, mode byte) []byte { b := []byte(clientID) if len(b) > 255 { b = b[:255] } - buf := make([]byte, 1+len(b)+1) - buf[0] = byte(len(b)) //nolint:gosec // len(b) усечён до ≤255 выше - copy(buf[1:], b) - buf[1+len(b)] = mode - _, err := conn.Write(buf) - return err + buf := make([]byte, 0, len(b)+3) + buf = append(buf, byte(len(b))) //nolint:gosec // len(b) усечён до ≤255 выше + buf = append(buf, b...) + return append(buf, mode, idVersionAck) } -// ReadClientID читает Client ID из первой DTLS-записи. Режим ModeUnset - клиент старше -// тега, режим у него всегда udp. -func ReadClientID(conn net.Conn) (string, byte, error) { - _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) +func WriteClientID(ctx context.Context, conn net.Conn, clientID string, mode byte) error { + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + rec := idRecord(clientID, mode) + var buf [16]byte + for _, wait := range idRetransmit { + if _, err := conn.Write(rec); err != nil { + return fmt.Errorf("send client ID: %w", err) + } + if err := conn.SetReadDeadline(time.Now().Add(wait)); err != nil { + return fmt.Errorf("client ID deadline: %w", err) + } + for { + n, err := conn.Read(buf[:]) + if err == nil && n == 1 && buf[0] == idAck { + return nil + } + if err != nil { + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + return fmt.Errorf("await client ID ack: %w", err) + } + break + } + } + if ctx.Err() != nil { + return ctx.Err() + } + } + return ErrNoIDAck +} + +func readClientID(conn net.Conn) (string, byte, byte, error) { + _ = conn.SetReadDeadline(time.Now().Add(idReadTimeout)) defer func() { _ = conn.SetReadDeadline(time.Time{}) }() - buf := make([]byte, 257) + buf := make([]byte, 258) n, err := conn.Read(buf) if err != nil { - return "", ModeUnset, err + return "", ModeUnset, 0, err } if n == 0 { - return "", ModeUnset, nil + return "", ModeUnset, 0, io.ErrUnexpectedEOF } - l := int(buf[0]) if n < 1+l { - return "", ModeUnset, io.ErrUnexpectedEOF + return "", ModeUnset, 0, io.ErrUnexpectedEOF } - mode := ModeUnset + var mode, ver byte if n > 1+l { mode = buf[1+l] } - return string(buf[1 : 1+l]), mode, nil + if n > 2+l { + ver = buf[2+l] + } + return string(buf[1 : 1+l]), mode, ver, nil +} + +func AcceptClientID(conn net.Conn, authorize func(id string, mode byte) error) (string, net.Conn, error) { + id, mode, ver, err := readClientID(conn) + if err != nil { + return "", nil, err + } + if err := authorize(id, mode); err != nil { + return id, nil, err + } + if ver < idVersionAck { + return id, conn, nil + } + if _, err := conn.Write([]byte{idAck}); err != nil { + return id, nil, fmt.Errorf("ack client ID: %w", err) + } + return id, &ackedConn{Conn: conn, rec: idRecord(id, mode)}, nil +} + +type ackedConn struct { + net.Conn + rec []byte + data bool +} + +func (c *ackedConn) Read(b []byte) (int, error) { + for { + n, err := c.Conn.Read(b) + if err != nil || c.data || !bytes.Equal(b[:n], c.rec) { + c.data = c.data || err == nil + return n, err + } + if _, err := c.Write([]byte{idAck}); err != nil { + return 0, err + } + } } diff --git a/internal/clientsdb/db_test.go b/internal/clientsdb/db_test.go index 2a5b36a..0e965d9 100644 --- a/internal/clientsdb/db_test.go +++ b/internal/clientsdb/db_test.go @@ -1,11 +1,14 @@ package clientsdb import ( + "errors" "net" "os" "path/filepath" "testing" "time" + + "github.com/cbeuw/connutil" ) func TestFailedSavePreservesAuthorization(t *testing.T) { @@ -103,65 +106,142 @@ func TestClientsDB(t *testing.T) { } } +// dropWrites теряет первые n записей - модель потерь датаграмм. +type dropWrites struct { + net.Conn + n int +} + +func (d *dropWrites) Write(b []byte) (int, error) { + if d.n > 0 { + d.n-- + return len(b), nil + } + return d.Conn.Write(b) +} + +const testID = "e8030ba252344e536664d96f4544d64d" + +func accept(t *testing.T, conn net.Conn) (string, byte, net.Conn, error) { + t.Helper() + var gotMode byte + id, data, err := AcceptClientID(conn, func(_ string, mode byte) error { + gotMode = mode + return nil + }) + return id, gotMode, data, err +} + func TestClientIDRoundTrip(t *testing.T) { - addr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0} - serverConn, err := net.ListenUDP("udp", addr) - if err != nil { - t.Fatalf("ListenUDP: %v", err) - } - defer func() { _ = serverConn.Close() }() + client, server := pipe() + errc := make(chan error, 1) + go func() { errc <- WriteClientID(t.Context(), client, testID, ModeTCP) }() - udpAddr, ok := serverConn.LocalAddr().(*net.UDPAddr) - if !ok { - t.Fatalf("LocalAddr is not *net.UDPAddr") + id, mode, _, err := accept(t, server) + if err != nil || id != testID || mode != ModeTCP { + t.Fatalf("accept = %q/%d/%v, want %q/%d", id, mode, err, testID, ModeTCP) } - clientConn, err := net.DialUDP("udp", nil, udpAddr) - if err != nil { - t.Fatalf("DialUDP: %v", err) + if err := <-errc; err != nil { + t.Fatalf("WriteClientID: %v", err) } - defer func() { _ = clientConn.Close() }() +} - expectedID := "client-test-uuid-123" +// Потеря первой ID-записи: клиент повторяет по таймеру, сессия поднимается. +func TestClientIDSurvivesLostRecord(t *testing.T) { + client, server := pipe() + errc := make(chan error, 1) + go func() { errc <- WriteClientID(t.Context(), &dropWrites{Conn: client, n: 1}, testID, ModeUDP) }() + if id, _, _, err := accept(t, server); err != nil || id != testID { + t.Fatalf("accept = %q/%v", id, err) + } + if err := <-errc; err != nil { + t.Fatalf("WriteClientID: %v", err) + } +} + +func TestClientIDSurvivesLostAck(t *testing.T) { + client, server := pipe() + errc := make(chan error, 1) go func() { - if werr := WriteClientID(clientConn, expectedID, ModeTCP); werr != nil { - t.Errorf("WriteClientID failed: %v", werr) + if err := WriteClientID(t.Context(), client, testID, ModeUDP); err != nil { + errc <- err + return } + _, err := client.Write([]byte("payload")) + errc <- err }() - readID, mode, err := ReadClientID(serverConn) + _, _, data, err := accept(t, &dropWrites{Conn: server, n: 1}) if err != nil { - t.Fatalf("ReadClientID: %v", err) + t.Fatalf("accept: %v", err) } - - if readID != expectedID { - t.Errorf("expected %q, got %q", expectedID, readID) + buf := make([]byte, 64) + n, err := data.Read(buf) + if err != nil || string(buf[:n]) != "payload" { + t.Fatalf("data = %q/%v, want payload", buf[:n], err) } - if mode != ModeTCP { - t.Errorf("mode = %d, want %d", mode, ModeTCP) + if err := <-errc; err != nil { + t.Fatalf("client: %v", err) } } -// Клиент старше тега режима слал ровно 1+len байт - такую запись читатель обязан принять. -func TestReadClientIDLegacyRecordHasNoMode(t *testing.T) { - clientConn, serverConn := net.Pipe() - defer func() { _ = clientConn.Close() }() - defer func() { _ = serverConn.Close() }() - - const id = "legacy-client" - go func() { - buf := append([]byte{byte(len(id))}, id...) //nolint:gosec // длина константы заведомо ≤255 - _, _ = clientConn.Write(buf) - }() - - readID, mode, err := ReadClientID(serverConn) - if err != nil { - t.Fatalf("ReadClientID: %v", err) - } - if readID != id { - t.Errorf("id = %q, want %q", readID, id) - } - if mode != ModeUnset { - t.Errorf("mode = %d, want ModeUnset", mode) +// Клиенты старше подтверждения: без режима ([len][id]) и с режимом ([len][id][mode]). +// Им сервер не отвечает и отдаёт соединение как есть - поведение до подтверждения. +func TestAcceptLegacyRecordNoAck(t *testing.T) { + head := append([]byte{byte(len(testID))}, testID...) + for _, tc := range []struct { + name string + rec []byte + mode byte + }{ + {"no mode", head, ModeUnset}, + {"with mode", append(append([]byte{}, head...), ModeUDP), ModeUDP}, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := pipe() + if _, err := client.Write(tc.rec); err != nil { + t.Fatal(err) + } + id, mode, data, err := accept(t, server) + if err != nil || id != testID || mode != tc.mode || data != server { + t.Fatalf("accept = %q/%d/%v, conn untouched=%v", id, mode, err, data == server) + } + _ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + if n, err := client.Read(make([]byte, 4)); err == nil { + t.Fatalf("legacy client got %d bytes of ack", n) + } + }) } } + +func TestAcceptRejectsWithoutAck(t *testing.T) { + client, server := pipe() + if _, err := client.Write(idRecord(testID, ModeUDP)); err != nil { + t.Fatal(err) + } + denied := errors.New("denied") + if _, _, err := AcceptClientID(server, func(string, byte) error { return denied }); !errors.Is(err, denied) { + t.Fatalf("err = %v, want denied", err) + } + _ = client.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + if n, err := client.Read(make([]byte, 4)); err == nil { + t.Fatalf("rejected client got %d bytes of ack", n) + } +} + +// pipe - датаграммная пара; таймаут отдаётся как у DTLS (net.Error), а не своей ошибкой connutil. +func pipe() (net.Conn, net.Conn) { + a, b := connutil.AsyncPacketPipe() + return &netTimeout{a}, &netTimeout{b} +} + +type netTimeout struct{ net.Conn } + +func (c *netTimeout) Read(b []byte) (int, error) { + n, err := c.Conn.Read(b) + if errors.Is(err, connutil.ErrTimeout) { + err = os.ErrDeadlineExceeded + } + return n, err +} diff --git a/internal/proxy/tcprelay/tcprelay.go b/internal/proxy/tcprelay/tcprelay.go index 58e6373..795c2a0 100644 --- a/internal/proxy/tcprelay/tcprelay.go +++ b/internal/proxy/tcprelay/tcprelay.go @@ -394,7 +394,7 @@ func createSession(ctx context.Context, deps *Deps, params *Params, peer *net.UD closers = append(closers, func() { _ = dtlsConn.Close() }) // Wire-контракт: Client ID первой app-record, до KCP. - if err = clientsdb.WriteClientID(dtlsConn, params.ClientID, clientsdb.ModeTCP); err != nil { + if err = clientsdb.WriteClientID(ctx, dtlsConn, params.ClientID, clientsdb.ModeTCP); err != nil { cleanup() return nil, fmt.Errorf("send client ID: %w", err) } diff --git a/internal/proxy/udprelay/loop.go b/internal/proxy/udprelay/loop.go index 3b63802..d0077a3 100644 --- a/internal/proxy/udprelay/loop.go +++ b/internal/proxy/udprelay/loop.go @@ -176,7 +176,7 @@ func dtlsSession(dtlsctx context.Context, dtlscancel context.CancelFunc, deps *D }() deps.log().Debugf("[STREAM %d] Established DTLS connection", streamID) - if err := clientsdb.WriteClientID(dtlsConn, params.ClientID, clientsdb.ModeUDP); err != nil { + if err := clientsdb.WriteClientID(dtlsctx, dtlsConn, params.ClientID, clientsdb.ModeUDP); err != nil { return fmt.Errorf("failed to write client ID: %w", err) } // Аллокация без DTLS ещё не может передавать пользовательский трафик.