feat(auth)!: подтверждение Client ID с повтором по таймеру

This commit is contained in:
samosvalishe
2026-09-22 00:03:12 +03:00
parent caa8fea5bf
commit d3e4cfc1db
5 changed files with 245 additions and 90 deletions
+21 -23
View File
@@ -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 <add|remove|list> [args...]")
+98 -21
View File
@@ -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
}
}
}
+124 -44
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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 ещё не может передавать пользовательский трафик.