310 lines
9.9 KiB
Go
310 lines
9.9 KiB
Go
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
|
|
}
|