package cluster import ( "context" "database/sql" "errors" "linkport/internal/model" "linkport/internal/protocol/amqpprobe" "linkport/internal/protocol/transport" "net" "strconv" "strings" "time" "github.com/go-mysql-org/go-mysql/client" "github.com/jackc/pgx/v5/pgconn" mssql "github.com/microsoft/go-mssqldb" "github.com/microsoft/go-mssqldb/msdsn" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) func probeMongo(ctx context.Context, p model.Proxy, dial model.DialFunc) (*model.ClusterInfo, error) { tls, err := transport.TLSConfig(p) if err != nil { return nil, err } authDB := p.AuthSource if authDB == "" { authDB = "admin" } opts := options.Client().SetHosts([]string{node(p).Address()}).SetDirect(true).SetDialer(contextDialer{dial}).SetTLSConfig(tls).SetServerSelectionTimeout(3 * time.Second).SetConnectTimeout(3 * time.Second) if p.TargetUser != "" { opts.SetAuth(options.Credential{AuthSource: authDB, Username: p.TargetUser, Password: p.TargetPassword}) } c, err := mongo.Connect(opts) if err != nil { return nil, err } defer func() { closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() c.Disconnect(closeCtx) }() var hello bson.M err = c.Database("admin").RunCommand(ctx, bson.D{{Key: "hello", Value: 1}}).Decode(&hello) if mongoHelloUnsupported(err) { hello = nil err = c.Database("admin").RunCommand(ctx, bson.D{{Key: "isMaster", Value: 1}}).Decode(&hello) } if err != nil { return nil, err } info := singleInfo(p, "standalone", "standalone") if hello["msg"] == "isdbgrid" { info.Type = "mongodb-sharded" info.Selected.Role = "router" info.Nodes[0].Role = "router" return info, nil } if name, ok := hello["setName"].(string); ok { info.Type, info.Name = "mongodb-replica-set", name info.Selected.Role = "replica" if hello["isWritablePrimary"] == true || hello["ismaster"] == true { info.Selected.Role = "primary" } info.Nodes[0].Role = info.Selected.Role for _, key := range []string{"hosts", "passives"} { if hosts, ok := hello[key].(bson.A); ok { for _, host := range hosts { if address, ok := host.(string); ok { if n, ok := endpoint(address, 27017); ok { if address == hello["primary"] { n.Role = "primary" } info.Nodes = addNodes(info.Nodes, n) } } } } } } return info, nil } func mongoHelloUnsupported(err error) bool { var command mongo.CommandError return errors.As(err, &command) && (command.Code == 59 || command.Name == "CommandNotFound") } // go-mysql GetString aliases pooled result bytes. Topology outlives Result.Close // and subsequent queries, so retain owned strings rather than borrowed views. func mysqlTopologyNode(host string, port int, role string) model.ClusterNode { return model.ClusterNode{Host: strings.Clone(host), Port: port, Role: strings.ToLower(strings.Clone(role))} } func probeMySQL(ctx context.Context, p model.Proxy, dial model.DialFunc) (*model.ClusterInfo, error) { tls, err := transport.TLSConfig(p) if err != nil { return nil, err } var raw net.Conn wrapped := func(ctx context.Context, network, address string) (net.Conn, error) { c, e := dial(ctx, network, address) if e == nil { raw = c deadline := time.Now().Add(3 * time.Second) if d, ok := ctx.Deadline(); ok { deadline = d } c.SetDeadline(deadline) } return c, e } c, err := client.ConnectWithDialer(ctx, "tcp", node(p).Address(), p.TargetUser, p.TargetPassword, "", wrapped, func(c *client.Conn) error { c.SetTLSConfig(tls); return nil }) if err != nil { if raw != nil { raw.Close() } return nil, err } defer c.Close() stop := context.AfterFunc(ctx, func() { c.Close() }) defer stop() info := singleInfo(p, "unknown", "unknown") if r, e := c.Execute("SELECT @@global.read_only"); e == nil { readonly, _ := r.GetInt(0, 0) info.Selected.Role = "primary" if readonly != 0 { info.Selected.Role = "replica" } info.Nodes[0].Role = info.Selected.Role r.Close() } if r, e := c.Execute("SELECT MEMBER_HOST, MEMBER_PORT, MEMBER_ROLE, MEMBER_STATE FROM performance_schema.replication_group_members"); e == nil { if r.RowNumber() > 0 { info.Type = "mysql-group-replication" } for row := 0; row < r.RowNumber(); row++ { host, _ := r.GetString(row, 0) port, _ := r.GetInt(row, 1) role, _ := r.GetString(row, 2) state, _ := r.GetString(row, 3) if state == "ONLINE" { info.Nodes = addNodes(info.Nodes, mysqlTopologyNode(host, int(port), role)) } } r.Close() if info.Type != "unknown" { return info, nil } } r, e := c.Execute("SHOW REPLICA STATUS") if e != nil { r, e = c.Execute("SHOW SLAVE STATUS") } if e == nil { if r.RowNumber() > 0 { info.Type = "mysql-replication" host, _ := r.GetStringByName(0, "Source_Host") port, _ := r.GetIntByName(0, "Source_Port") if host == "" { host, _ = r.GetStringByName(0, "Master_Host") port, _ = r.GetIntByName(0, "Master_Port") } info.Nodes = addNodes(info.Nodes, mysqlTopologyNode(host, int(port), "primary")) } r.Close() } if info.Type == "unknown" { if r, e := c.Execute("SHOW REPLICAS"); e == nil { if r.RowNumber() > 0 { info.Type = "mysql-replication" } r.Close() } } if info.Type == "unknown" { info.Warning = "可确定节点只读状态,但无法完整读取复制拓扑;单机与暂无副本的主节点无法可靠区分。" } return info, nil } func probePostgres(ctx context.Context, p model.Proxy, dial model.DialFunc) (*model.ClusterInfo, error) { cfg, err := pgconn.ParseConfig("postgres://localhost/postgres?sslmode=disable") if err != nil { return nil, err } cfg.Host, cfg.Port, cfg.User, cfg.Password = p.TargetHost, uint16(p.TargetPort), p.TargetUser, p.TargetPassword if p.AuthSource != "" && p.AuthSource != "admin" { cfg.Database = p.AuthSource } cfg.TLSConfig, err = transport.TLSConfig(p) if err != nil { return nil, err } cfg.Fallbacks = nil cfg.DialFunc = pgconn.DialFunc(dial) cfg.LookupFunc = func(_ context.Context, host string) ([]string, error) { return []string{host}, nil } cfg.ConnectTimeout = 3 * time.Second c, err := pgconn.ConnectConfig(ctx, cfg) if err != nil { return nil, err } defer c.Close(context.Background()) r, err := c.Exec(ctx, "SELECT pg_is_in_recovery()").ReadAll() if err != nil || len(r) == 0 || len(r[0].Rows) == 0 { return nil, errors.New("PostgreSQL role unavailable") } info := singleInfo(p, "unknown", "primary") if string(r[0].Rows[0][0]) == "t" { info.Type = "pgsql-replication" info.Selected.Role = "replica" info.Nodes[0].Role = "replica" rows, e := c.Exec(ctx, "SELECT sender_host, sender_port FROM pg_stat_wal_receiver").ReadAll() if e == nil && len(rows) > 0 { for _, row := range rows[0].Rows { if len(row) < 2 { continue } port, _ := strconv.Atoi(string(row[1])) info.Nodes = addNodes(info.Nodes, model.ClusterNode{Host: string(row[0]), Port: port, Role: "primary"}) } } } else { rows, e := c.Exec(ctx, "SELECT count(*) FROM pg_stat_replication").ReadAll() if e == nil && len(rows) > 0 && len(rows[0].Rows) > 0 && string(rows[0].Rows[0][0]) != "0" { info.Type = "pgsql-replication" } } if info.Type == "unknown" { info.Warning = "当前节点可写;没有可见复制连接时,无法区分单机与暂无副本的主节点。" } return info, nil } type sqlDialer struct { contextDialer host string } func (d sqlDialer) HostName() string { return d.host } func probeSQLServer(ctx context.Context, p model.Proxy, dial model.DialFunc) (*model.ClusterInfo, error) { tls, err := transport.TLSConfig(p) if err != nil { return nil, err } encryption := msdsn.Encryption(msdsn.EncryptionDisabled) if p.TargetTLS { encryption = msdsn.EncryptionRequired } c := mssql.NewConnectorConfig(msdsn.Config{Host: p.TargetHost, Port: uint64(p.TargetPort), User: p.TargetUser, Password: p.TargetPassword, Database: p.AuthSource, Encryption: encryption, TLSConfig: tls, Protocols: []string{"tcp"}, DisableRetry: true}) c.Dialer = sqlDialer{contextDialer{dial}, p.TargetHost} db := sql.OpenDB(c) defer db.Close() var hadr, fc int var primary sql.NullInt64 err = db.QueryRowContext(ctx, "SELECT CAST(SERVERPROPERTY('IsHadrEnabled') AS int), CAST(SERVERPROPERTY('IsClustered') AS int), sys.fn_hadr_is_primary_replica(DB_NAME())").Scan(&hadr, &fc, &primary) if err != nil { if err = db.PingContext(ctx); err != nil { return nil, err } return singleInfo(p, "unknown", "unknown"), nil } info := singleInfo(p, "standalone", "standalone") if fc == 1 { info.Type = "sqlserver-fci" info.Selected.Role = "primary" } if primary.Valid { info.Type = "sqlserver-ag" info.Selected.Role = "replica" if primary.Int64 == 1 { info.Selected.Role = "primary" } rows, e := db.QueryContext(ctx, "SELECT dns_name, port FROM sys.availability_group_listeners WHERE group_id IN (SELECT group_id FROM sys.availability_databases_cluster WHERE database_name = DB_NAME())") if e == nil { defer rows.Close() for rows.Next() { var host string var port int if rows.Scan(&host, &port) == nil { info.Nodes = addNodes(info.Nodes, model.ClusterNode{Host: host, Port: port, Role: "primary"}) } } } } if hadr == 1 && !primary.Valid && fc == 0 { info.Type = "unknown" info.Warning = "已启用 Always On,但当前数据库不在可识别的可用性组中。" } info.Nodes[0].Role = info.Selected.Role return info, nil } func probeRabbit(ctx context.Context, p model.Proxy, dial model.DialFunc) (*model.ClusterInfo, error) { result, err := amqpprobe.Probe(ctx, p, dial) if err != nil { return nil, err } info := singleInfo(p, "unknown", "broker") info.Name, info.Version = result.ClusterName, result.Version info.Warning = "AMQP 不公布集群成员列表;请配置其他集群节点以支持新连接故障切换。" if p.Cluster.Mode == "rabbitmq-cluster" { info.Type = "rabbitmq-cluster" } return info, nil }