From 92db317e0658824df0db015f29728ce08a5e7880 Mon Sep 17 00:00:00 2001 From: battlmonstr <battlmonstr@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:02:12 +0200 Subject: [PATCH] Observer - P2P network crawler (#3928) Observer crawls the Ethereum network and collects information about the nodes. --- Makefile | 1 + cmd/observer/README.md | 35 + cmd/observer/database/db.go | 73 ++ cmd/observer/database/db_retrier.go | 240 +++++ cmd/observer/database/db_sqlite.go | 866 ++++++++++++++++++ cmd/observer/database/db_sqlite_test.go | 40 + cmd/observer/main.go | 108 +++ cmd/observer/observer/client_id.go | 133 +++ cmd/observer/observer/command.go | 233 +++++ cmd/observer/observer/crawler.go | 495 ++++++++++ cmd/observer/observer/diplomacy.go | 252 +++++ cmd/observer/observer/diplomat.go | 150 +++ cmd/observer/observer/handshake.go | 252 +++++ cmd/observer/observer/handshake_test.go | 35 + cmd/observer/observer/interrogation_error.go | 53 ++ cmd/observer/observer/interrogator.go | 230 +++++ cmd/observer/observer/keygen.go | 76 ++ cmd/observer/observer/keygen_test.go | 23 + cmd/observer/observer/node.go | 145 +++ cmd/observer/observer/server.go | 183 ++++ cmd/observer/observer/status_logger.go | 44 + .../reports/clients_estimate_report.go | 95 ++ cmd/observer/reports/clients_report.go | 97 ++ cmd/observer/reports/command.go | 92 ++ cmd/observer/reports/status_report.go | 40 + cmd/observer/utils/retry.go | 32 + cmd/observer/utils/sleep.go | 15 + cmd/observer/utils/task_queue.go | 51 ++ eth/protocols/eth/discovery.go | 13 + go.mod | 12 + go.sum | 120 +++ p2p/discover/v4_udp.go | 10 +- 32 files changed, 4242 insertions(+), 2 deletions(-) create mode 100644 cmd/observer/README.md create mode 100644 cmd/observer/database/db.go create mode 100644 cmd/observer/database/db_retrier.go create mode 100644 cmd/observer/database/db_sqlite.go create mode 100644 cmd/observer/database/db_sqlite_test.go create mode 100644 cmd/observer/main.go create mode 100644 cmd/observer/observer/client_id.go create mode 100644 cmd/observer/observer/command.go create mode 100644 cmd/observer/observer/crawler.go create mode 100644 cmd/observer/observer/diplomacy.go create mode 100644 cmd/observer/observer/diplomat.go create mode 100644 cmd/observer/observer/handshake.go create mode 100644 cmd/observer/observer/handshake_test.go create mode 100644 cmd/observer/observer/interrogation_error.go create mode 100644 cmd/observer/observer/interrogator.go create mode 100644 cmd/observer/observer/keygen.go create mode 100644 cmd/observer/observer/keygen_test.go create mode 100644 cmd/observer/observer/node.go create mode 100644 cmd/observer/observer/server.go create mode 100644 cmd/observer/observer/status_logger.go create mode 100644 cmd/observer/reports/clients_estimate_report.go create mode 100644 cmd/observer/reports/clients_report.go create mode 100644 cmd/observer/reports/command.go create mode 100644 cmd/observer/reports/status_report.go create mode 100644 cmd/observer/utils/retry.go create mode 100644 cmd/observer/utils/sleep.go create mode 100644 cmd/observer/utils/task_queue.go diff --git a/Makefile b/Makefile index b0fab7bdf5..4845587a9c 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,7 @@ COMMANDS += downloader COMMANDS += evm COMMANDS += hack COMMANDS += integration +COMMANDS += observer COMMANDS += pics COMMANDS += rpcdaemon COMMANDS += rpctest diff --git a/cmd/observer/README.md b/cmd/observer/README.md new file mode 100644 index 0000000000..d094f06b3b --- /dev/null +++ b/cmd/observer/README.md @@ -0,0 +1,35 @@ +# Observer - P2P network crawler + +Observer crawls the Ethereum network and collects information about the nodes. + +### Build + + make observer + +### Run + + observer --datadir ... --nat extip:<IP> --port <PORT> + +Where `IP` is your public IP, and `PORT` has to be open for incoming UDP traffic. + +See `observer --help` for available options. + +### Report + +To get the report about the currently known network state run: + + observer report --datadir ... + +## Description + +Observer uses [discv4](https://github.com/ethereum/devp2p/blob/master/discv4.md) protocol to discover new nodes. +Starting from a list of preconfigured "bootnodes" it uses FindNode +to obtain their "neighbor" nodes, and then recursively crawls neighbors of neighbors and so on. +Each found node is re-crawled again a few times. +If the node fails to be pinged after maximum attempts, it is considered "dead", but still re-crawled less often. + +A separate "diplomacy" process is doing "handshakes" to obtain information about the discovered nodes. +It tries to get [RLPx Hello](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00) +and [Eth Status](https://github.com/ethereum/devp2p/blob/master/caps/eth.md#status-0x00) +from each node. +The handshake repeats a few times according to the configured delays. diff --git a/cmd/observer/database/db.go b/cmd/observer/database/db.go new file mode 100644 index 0000000000..a647606f77 --- /dev/null +++ b/cmd/observer/database/db.go @@ -0,0 +1,73 @@ +package database + +import ( + "context" + "io" + "net" + "time" +) + +type NodeID string + +type NodeAddr1 struct { + IP net.IP + PortDisc uint16 + PortRLPx uint16 +} + +type NodeAddr struct { + NodeAddr1 + IPv6 NodeAddr1 +} + +type HandshakeError struct { + StringCode string + Time time.Time +} + +type DB interface { + io.Closer + + UpsertNodeAddr(ctx context.Context, id NodeID, addr NodeAddr) error + FindNodeAddr(ctx context.Context, id NodeID) (*NodeAddr, error) + + ResetPingError(ctx context.Context, id NodeID) error + UpdatePingError(ctx context.Context, id NodeID) error + CountPingErrors(ctx context.Context, id NodeID) (*uint, error) + + UpdateClientID(ctx context.Context, id NodeID, clientID string) error + UpdateNetworkID(ctx context.Context, id NodeID, networkID uint) error + UpdateEthVersion(ctx context.Context, id NodeID, ethVersion uint) error + UpdateHandshakeTransientError(ctx context.Context, id NodeID, hasTransientErr bool) error + InsertHandshakeError(ctx context.Context, id NodeID, handshakeErr string) error + DeleteHandshakeErrors(ctx context.Context, id NodeID) error + FindHandshakeLastErrors(ctx context.Context, id NodeID, limit uint) ([]HandshakeError, error) + UpdateHandshakeRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error + FindHandshakeRetryTime(ctx context.Context, id NodeID) (*time.Time, error) + CountHandshakeCandidates(ctx context.Context) (uint, error) + FindHandshakeCandidates(ctx context.Context, limit uint) ([]NodeID, error) + MarkTakenHandshakeCandidates(ctx context.Context, nodes []NodeID) error + // TakeHandshakeCandidates runs FindHandshakeCandidates + MarkTakenHandshakeCandidates in a transaction. + TakeHandshakeCandidates(ctx context.Context, limit uint) ([]NodeID, error) + + UpdateForkCompatibility(ctx context.Context, id NodeID, isCompatFork bool) error + + UpdateNeighborBucketKeys(ctx context.Context, id NodeID, keys []string) error + FindNeighborBucketKeys(ctx context.Context, id NodeID) ([]string, error) + + UpdateCrawlRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error + CountCandidates(ctx context.Context) (uint, error) + FindCandidates(ctx context.Context, limit uint) ([]NodeID, error) + MarkTakenNodes(ctx context.Context, nodes []NodeID) error + // TakeCandidates runs FindCandidates + MarkTakenNodes in a transaction. + TakeCandidates(ctx context.Context, limit uint) ([]NodeID, error) + + IsConflictError(err error) bool + + CountNodes(ctx context.Context, maxPingTries uint, networkID uint) (uint, error) + CountIPs(ctx context.Context, maxPingTries uint, networkID uint) (uint, error) + CountClients(ctx context.Context, clientIDPrefix string, maxPingTries uint, networkID uint) (uint, error) + CountClientsWithNetworkID(ctx context.Context, clientIDPrefix string, maxPingTries uint) (uint, error) + CountClientsWithHandshakeTransientError(ctx context.Context, clientIDPrefix string, maxPingTries uint) (uint, error) + EnumerateClientIDs(ctx context.Context, maxPingTries uint, networkID uint, enumFunc func(clientID *string)) error +} diff --git a/cmd/observer/database/db_retrier.go b/cmd/observer/database/db_retrier.go new file mode 100644 index 0000000000..87db46d68f --- /dev/null +++ b/cmd/observer/database/db_retrier.go @@ -0,0 +1,240 @@ +package database + +import ( + "context" + "github.com/ledgerwatch/erigon/cmd/observer/utils" + "github.com/ledgerwatch/log/v3" + "math/rand" + "time" +) + +type DBRetrier struct { + db DB + log log.Logger +} + +func NewDBRetrier(db DB, logger log.Logger) DBRetrier { + return DBRetrier{db, logger} +} + +func retryBackoffTime(attempt int) time.Duration { + if attempt <= 0 { + return 0 + } + jitter := rand.Int63n(30 * time.Millisecond.Nanoseconds() * int64(attempt)) + var ns int64 + if attempt <= 6 { + ns = ((50 * time.Millisecond.Nanoseconds()) << (attempt - 1)) + jitter + } else { + ns = 1600*time.Millisecond.Nanoseconds() + jitter + } + return time.Duration(ns) +} + +func (db DBRetrier) retry(ctx context.Context, opName string, op func(context.Context) (interface{}, error)) (interface{}, error) { + const retryCount = 40 + return utils.Retry(ctx, retryCount, retryBackoffTime, db.db.IsConflictError, db.log, opName, op) +} + +func (db DBRetrier) UpsertNodeAddr(ctx context.Context, id NodeID, addr NodeAddr) error { + _, err := db.retry(ctx, "UpsertNodeAddr", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpsertNodeAddr(ctx, id, addr) + }) + return err +} + +func (db DBRetrier) FindNodeAddr(ctx context.Context, id NodeID) (*NodeAddr, error) { + resultAny, err := db.retry(ctx, "FindNodeAddr", func(ctx context.Context) (interface{}, error) { + return db.db.FindNodeAddr(ctx, id) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.(*NodeAddr) + return result, err +} + +func (db DBRetrier) ResetPingError(ctx context.Context, id NodeID) error { + _, err := db.retry(ctx, "ResetPingError", func(ctx context.Context) (interface{}, error) { + return nil, db.db.ResetPingError(ctx, id) + }) + return err +} + +func (db DBRetrier) UpdatePingError(ctx context.Context, id NodeID) error { + _, err := db.retry(ctx, "UpdatePingError", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdatePingError(ctx, id) + }) + return err +} + +func (db DBRetrier) CountPingErrors(ctx context.Context, id NodeID) (*uint, error) { + resultAny, err := db.retry(ctx, "CountPingErrors", func(ctx context.Context) (interface{}, error) { + return db.db.CountPingErrors(ctx, id) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.(*uint) + return result, err +} + +func (db DBRetrier) UpdateClientID(ctx context.Context, id NodeID, clientID string) error { + _, err := db.retry(ctx, "UpdateClientID", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateClientID(ctx, id, clientID) + }) + return err +} + +func (db DBRetrier) UpdateNetworkID(ctx context.Context, id NodeID, networkID uint) error { + _, err := db.retry(ctx, "UpdateNetworkID", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateNetworkID(ctx, id, networkID) + }) + return err +} + +func (db DBRetrier) UpdateEthVersion(ctx context.Context, id NodeID, ethVersion uint) error { + _, err := db.retry(ctx, "UpdateEthVersion", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateEthVersion(ctx, id, ethVersion) + }) + return err +} + +func (db DBRetrier) UpdateHandshakeTransientError(ctx context.Context, id NodeID, hasTransientErr bool) error { + _, err := db.retry(ctx, "UpdateHandshakeTransientError", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateHandshakeTransientError(ctx, id, hasTransientErr) + }) + return err +} + +func (db DBRetrier) InsertHandshakeError(ctx context.Context, id NodeID, handshakeErr string) error { + _, err := db.retry(ctx, "InsertHandshakeError", func(ctx context.Context) (interface{}, error) { + return nil, db.db.InsertHandshakeError(ctx, id, handshakeErr) + }) + return err +} + +func (db DBRetrier) DeleteHandshakeErrors(ctx context.Context, id NodeID) error { + _, err := db.retry(ctx, "DeleteHandshakeErrors", func(ctx context.Context) (interface{}, error) { + return nil, db.db.DeleteHandshakeErrors(ctx, id) + }) + return err +} + +func (db DBRetrier) FindHandshakeLastErrors(ctx context.Context, id NodeID, limit uint) ([]HandshakeError, error) { + resultAny, err := db.retry(ctx, "FindHandshakeLastErrors", func(ctx context.Context) (interface{}, error) { + return db.db.FindHandshakeLastErrors(ctx, id, limit) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.([]HandshakeError) + return result, err +} + +func (db DBRetrier) UpdateHandshakeRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error { + _, err := db.retry(ctx, "UpdateHandshakeRetryTime", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateHandshakeRetryTime(ctx, id, retryTime) + }) + return err +} + +func (db DBRetrier) FindHandshakeRetryTime(ctx context.Context, id NodeID) (*time.Time, error) { + resultAny, err := db.retry(ctx, "FindHandshakeRetryTime", func(ctx context.Context) (interface{}, error) { + return db.db.FindHandshakeRetryTime(ctx, id) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.(*time.Time) + return result, err +} + +func (db DBRetrier) CountHandshakeCandidates(ctx context.Context) (uint, error) { + resultAny, err := db.retry(ctx, "CountHandshakeCandidates", func(ctx context.Context) (interface{}, error) { + return db.db.CountHandshakeCandidates(ctx) + }) + + if resultAny == nil { + return 0, err + } + result := resultAny.(uint) + return result, err +} + +func (db DBRetrier) TakeHandshakeCandidates(ctx context.Context, limit uint) ([]NodeID, error) { + resultAny, err := db.retry(ctx, "TakeHandshakeCandidates", func(ctx context.Context) (interface{}, error) { + return db.db.TakeHandshakeCandidates(ctx, limit) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.([]NodeID) + return result, err +} + +func (db DBRetrier) UpdateForkCompatibility(ctx context.Context, id NodeID, isCompatFork bool) error { + _, err := db.retry(ctx, "UpdateForkCompatibility", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateForkCompatibility(ctx, id, isCompatFork) + }) + return err +} + +func (db DBRetrier) UpdateNeighborBucketKeys(ctx context.Context, id NodeID, keys []string) error { + _, err := db.retry(ctx, "UpdateNeighborBucketKeys", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateNeighborBucketKeys(ctx, id, keys) + }) + return err +} + +func (db DBRetrier) FindNeighborBucketKeys(ctx context.Context, id NodeID) ([]string, error) { + resultAny, err := db.retry(ctx, "FindNeighborBucketKeys", func(ctx context.Context) (interface{}, error) { + return db.db.FindNeighborBucketKeys(ctx, id) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.([]string) + return result, err +} + +func (db DBRetrier) UpdateCrawlRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error { + _, err := db.retry(ctx, "UpdateCrawlRetryTime", func(ctx context.Context) (interface{}, error) { + return nil, db.db.UpdateCrawlRetryTime(ctx, id, retryTime) + }) + return err +} + +func (db DBRetrier) CountCandidates(ctx context.Context) (uint, error) { + resultAny, err := db.retry(ctx, "CountCandidates", func(ctx context.Context) (interface{}, error) { + return db.db.CountCandidates(ctx) + }) + + if resultAny == nil { + return 0, err + } + result := resultAny.(uint) + return result, err +} + +func (db DBRetrier) TakeCandidates(ctx context.Context, limit uint) ([]NodeID, error) { + resultAny, err := db.retry(ctx, "TakeCandidates", func(ctx context.Context) (interface{}, error) { + return db.db.TakeCandidates(ctx, limit) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.([]NodeID) + return result, err +} + +func (db DBRetrier) IsConflictError(err error) bool { + return db.db.IsConflictError(err) +} diff --git a/cmd/observer/database/db_sqlite.go b/cmd/observer/database/db_sqlite.go new file mode 100644 index 0000000000..9d27727eab --- /dev/null +++ b/cmd/observer/database/db_sqlite.go @@ -0,0 +1,866 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + _ "modernc.org/sqlite" + "net" + "strings" + "time" +) + +type DBSQLite struct { + db *sql.DB +} + +// language=SQL +const ( + sqlCreateSchema = ` +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + + ip TEXT, + port_disc INTEGER, + port_rlpx INTEGER, + ip_v6 TEXT, + ip_v6_port_disc INTEGER, + ip_v6_port_rlpx INTEGER, + addr_updated INTEGER NOT NULL, + + ping_try INTEGER NOT NULL DEFAULT 0, + + compat_fork INTEGER, + compat_fork_updated INTEGER, + + client_id TEXT, + network_id INTEGER, + eth_version INTEGER, + handshake_transient_err INTEGER NOT NULL DEFAULT 0, + handshake_updated INTEGER, + handshake_retry_time INTEGER, + + neighbor_keys TEXT, + + crawl_retry_time INTEGER +); + +CREATE TABLE IF NOT EXISTS handshake_errors ( + id TEXT NOT NULL, + err TEXT NOT NULL, + updated INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_nodes_crawl_retry_time ON nodes (crawl_retry_time); +CREATE INDEX IF NOT EXISTS idx_nodes_ip ON nodes (ip); +CREATE INDEX IF NOT EXISTS idx_nodes_ip_v6 ON nodes (ip_v6); +CREATE INDEX IF NOT EXISTS idx_nodes_ping_try ON nodes (ping_try); +CREATE INDEX IF NOT EXISTS idx_nodes_compat_fork ON nodes (compat_fork); +CREATE INDEX IF NOT EXISTS idx_nodes_network_id ON nodes (network_id); +CREATE INDEX IF NOT EXISTS idx_nodes_handshake_retry_time ON nodes (handshake_retry_time); +CREATE INDEX IF NOT EXISTS idx_handshake_errors_id ON handshake_errors (id); +` + + sqlUpsertNodeAddr = ` +INSERT INTO nodes( + id, + ip, + port_disc, + port_rlpx, + ip_v6, + ip_v6_port_disc, + ip_v6_port_rlpx, + addr_updated +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + ip = excluded.ip, + port_disc = excluded.port_disc, + port_rlpx = excluded.port_rlpx, + ip_v6 = excluded.ip_v6, + ip_v6_port_disc = excluded.ip_v6_port_disc, + ip_v6_port_rlpx = excluded.ip_v6_port_rlpx, + addr_updated = excluded.addr_updated +` + + sqlFindNodeAddr = ` +SELECT + ip, + port_disc, + port_rlpx, + ip_v6, + ip_v6_port_disc, + ip_v6_port_rlpx +FROM nodes +WHERE id = ? +` + + sqlResetPingError = ` +UPDATE nodes SET ping_try = 0 WHERE id = ? +` + + sqlUpdatePingError = ` +UPDATE nodes SET ping_try = nodes.ping_try + 1 WHERE id = ? +` + + sqlCountPingErrors = ` +SELECT ping_try FROM nodes WHERE id = ? +` + + sqlUpdateClientID = ` +UPDATE nodes SET + client_id = ?, + handshake_updated = ? +WHERE id = ? +` + + sqlUpdateNetworkID = ` +UPDATE nodes SET + network_id = ?, + handshake_updated = ? +WHERE id = ? +` + + sqlUpdateEthVersion = ` +UPDATE nodes SET + eth_version = ?, + handshake_updated = ? +WHERE id = ? +` + + sqlUpdateHandshakeTransientError = ` +UPDATE nodes SET + handshake_transient_err = ?, + handshake_updated = ? +WHERE id = ? +` + + sqlInsertHandshakeError = ` +INSERT INTO handshake_errors( + id, + err, + updated +) VALUES (?, ?, ?) +` + + sqlDeleteHandshakeErrors = ` +DELETE FROM handshake_errors WHERE id = ? +` + + sqlFindHandshakeLastErrors = ` +SELECT err, updated FROM handshake_errors +WHERE id = ? +ORDER BY updated DESC +LIMIT ? +` + + sqlUpdateHandshakeRetryTime = ` +UPDATE nodes SET handshake_retry_time = ? WHERE id = ? +` + + sqlFindHandshakeRetryTime = ` +SELECT handshake_retry_time FROM nodes WHERE id = ? +` + + sqlCountHandshakeCandidates = ` +SELECT COUNT(*) FROM nodes +WHERE ((handshake_retry_time IS NULL) OR (handshake_retry_time < ?)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +` + + sqlFindHandshakeCandidates = ` +SELECT id FROM nodes +WHERE ((handshake_retry_time IS NULL) OR (handshake_retry_time < ?)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +ORDER BY handshake_retry_time +LIMIT ? +` + + sqlMarkTakenHandshakeCandidates = ` +UPDATE nodes SET handshake_retry_time = ? WHERE id IN (123) +` + + sqlUpdateForkCompatibility = ` +UPDATE nodes SET compat_fork = ?, compat_fork_updated = ? WHERE id = ? +` + + sqlUpdateNeighborBucketKeys = ` +UPDATE nodes SET neighbor_keys = ? WHERE id = ? +` + + sqlFindNeighborBucketKeys = ` +SELECT neighbor_keys FROM nodes WHERE id = ? +` + + sqlUpdateCrawlRetryTime = ` +UPDATE nodes SET crawl_retry_time = ? WHERE id = ? +` + + sqlCountCandidates = ` +SELECT COUNT(*) FROM nodes +WHERE ((crawl_retry_time IS NULL) OR (crawl_retry_time < ?)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +` + + sqlFindCandidates = ` +SELECT id FROM nodes +WHERE ((crawl_retry_time IS NULL) OR (crawl_retry_time < ?)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +ORDER BY crawl_retry_time +LIMIT ? +` + + sqlMarkTakenNodes = ` +UPDATE nodes SET crawl_retry_time = ? WHERE id IN (123) +` + + sqlCountNodes = ` +SELECT COUNT(*) FROM nodes +WHERE (ping_try < ?) + AND ((network_id = ?) OR (network_id IS NULL)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +` + + sqlCountIPs = ` +SELECT COUNT(DISTINCT ip) FROM nodes +WHERE (ping_try < ?) + AND ((network_id = ?) OR (network_id IS NULL)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +` + + sqlCountClients = ` +SELECT COUNT(*) FROM nodes +WHERE (ping_try < ?) + AND (network_id = ?) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) + AND (client_id LIKE ?) +` + + sqlCountClientsWithNetworkID = ` +SELECT COUNT(*) FROM nodes +WHERE (ping_try < ?) + AND (network_id IS NOT NULL) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) + AND (client_id LIKE ?) +` + + sqlCountClientsWithHandshakeTransientError = ` +SELECT COUNT(*) FROM nodes +WHERE (ping_try < ?) + AND (handshake_transient_err = 1) + AND (network_id IS NULL) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) + AND (client_id LIKE ?) +` + + sqlEnumerateClientIDs = ` +SELECT client_id FROM nodes +WHERE (ping_try < ?) + AND ((network_id = ?) OR (network_id IS NULL)) + AND ((compat_fork == TRUE) OR (compat_fork IS NULL)) +` +) + +func NewDBSQLite(filePath string) (*DBSQLite, error) { + db, err := sql.Open("sqlite", filePath) + if err != nil { + return nil, fmt.Errorf("failed to open DB: %w", err) + } + + _, err = db.Exec(sqlCreateSchema) + if err != nil { + return nil, fmt.Errorf("failed to create the DB schema: %w", err) + } + + instance := DBSQLite{db} + return &instance, nil +} + +func (db *DBSQLite) Close() error { + return db.db.Close() +} + +func (db *DBSQLite) UpsertNodeAddr(ctx context.Context, id NodeID, addr NodeAddr) error { + var ip *string + if addr.IP != nil { + value := addr.IP.String() + ip = &value + } + + var ipV6 *string + if addr.IPv6.IP != nil { + value := addr.IPv6.IP.String() + ipV6 = &value + } + + var portDisc *int + if (ip != nil) && (addr.PortDisc != 0) { + value := int(addr.PortDisc) + portDisc = &value + } + + var ipV6PortDisc *int + if (ipV6 != nil) && (addr.IPv6.PortDisc != 0) { + value := int(addr.IPv6.PortDisc) + ipV6PortDisc = &value + } + + var portRLPx *int + if (ip != nil) && (addr.PortRLPx != 0) { + value := int(addr.PortRLPx) + portRLPx = &value + } + + var ipV6PortRLPx *int + if (ipV6 != nil) && (addr.IPv6.PortRLPx != 0) { + value := int(addr.IPv6.PortRLPx) + ipV6PortRLPx = &value + } + + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpsertNodeAddr, + id, + ip, portDisc, portRLPx, + ipV6, ipV6PortDisc, ipV6PortRLPx, + updated) + if err != nil { + return fmt.Errorf("failed to upsert a node address: %w", err) + } + return nil +} + +func (db *DBSQLite) FindNodeAddr(ctx context.Context, id NodeID) (*NodeAddr, error) { + row := db.db.QueryRowContext(ctx, sqlFindNodeAddr, id) + + var ip sql.NullString + var portDisc sql.NullInt32 + var portRLPx sql.NullInt32 + var ipV6 sql.NullString + var ipV6PortDisc sql.NullInt32 + var ipV6PortRLPx sql.NullInt32 + + err := row.Scan( + &ip, + &portDisc, + &portRLPx, + &ipV6, + &ipV6PortDisc, + &ipV6PortRLPx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("FindNodeAddr failed: %w", err) + } + + var addr NodeAddr + + if ip.Valid { + value := net.ParseIP(ip.String) + if value == nil { + return nil, errors.New("FindNodeAddr failed to parse IP") + } + addr.IP = value + } + if ipV6.Valid { + value := net.ParseIP(ipV6.String) + if value == nil { + return nil, errors.New("FindNodeAddr failed to parse IPv6") + } + addr.IPv6.IP = value + } + if portDisc.Valid { + value := uint16(portDisc.Int32) + addr.PortDisc = value + } + if portRLPx.Valid { + value := uint16(portRLPx.Int32) + addr.PortRLPx = value + } + if ipV6PortDisc.Valid { + value := uint16(ipV6PortDisc.Int32) + addr.IPv6.PortDisc = value + } + if ipV6PortRLPx.Valid { + value := uint16(ipV6PortRLPx.Int32) + addr.IPv6.PortRLPx = value + } + + return &addr, nil +} + +func (db *DBSQLite) ResetPingError(ctx context.Context, id NodeID) error { + _, err := db.db.ExecContext(ctx, sqlResetPingError, id) + if err != nil { + return fmt.Errorf("ResetPingError failed: %w", err) + } + return nil +} + +func (db *DBSQLite) UpdatePingError(ctx context.Context, id NodeID) error { + _, err := db.db.ExecContext(ctx, sqlUpdatePingError, id) + if err != nil { + return fmt.Errorf("UpdatePingError failed: %w", err) + } + return nil +} + +func (db *DBSQLite) CountPingErrors(ctx context.Context, id NodeID) (*uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountPingErrors, id) + var count uint + if err := row.Scan(&count); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("CountPingErrors failed: %w", err) + } + return &count, nil +} + +func (db *DBSQLite) UpdateClientID(ctx context.Context, id NodeID, clientID string) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpdateClientID, clientID, updated, id) + if err != nil { + return fmt.Errorf("UpdateClientID failed to update a node: %w", err) + } + return nil +} + +func (db *DBSQLite) UpdateNetworkID(ctx context.Context, id NodeID, networkID uint) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpdateNetworkID, networkID, updated, id) + if err != nil { + return fmt.Errorf("UpdateNetworkID failed: %w", err) + } + return nil +} + +func (db *DBSQLite) UpdateEthVersion(ctx context.Context, id NodeID, ethVersion uint) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpdateEthVersion, ethVersion, updated, id) + if err != nil { + return fmt.Errorf("UpdateEthVersion failed: %w", err) + } + return nil +} + +func (db *DBSQLite) UpdateHandshakeTransientError(ctx context.Context, id NodeID, hasTransientErr bool) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpdateHandshakeTransientError, hasTransientErr, updated, id) + if err != nil { + return fmt.Errorf("UpdateHandshakeTransientError failed: %w", err) + } + return nil +} + +func (db *DBSQLite) InsertHandshakeError(ctx context.Context, id NodeID, handshakeErr string) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlInsertHandshakeError, id, handshakeErr, updated) + if err != nil { + return fmt.Errorf("InsertHandshakeError failed: %w", err) + } + return nil +} + +func (db *DBSQLite) DeleteHandshakeErrors(ctx context.Context, id NodeID) error { + _, err := db.db.ExecContext(ctx, sqlDeleteHandshakeErrors, id) + if err != nil { + return fmt.Errorf("DeleteHandshakeErrors failed: %w", err) + } + return nil +} + +func (db *DBSQLite) FindHandshakeLastErrors(ctx context.Context, id NodeID, limit uint) ([]HandshakeError, error) { + cursor, err := db.db.QueryContext( + ctx, + sqlFindHandshakeLastErrors, + id, + limit) + if err != nil { + return nil, fmt.Errorf("FindHandshakeLastErrors failed to query: %w", err) + } + defer func() { + _ = cursor.Close() + }() + + var handshakeErrors []HandshakeError + for cursor.Next() { + var stringCode string + var updatedTimestamp int64 + err := cursor.Scan(&stringCode, &updatedTimestamp) + if err != nil { + return nil, fmt.Errorf("FindHandshakeLastErrors failed to read data: %w", err) + } + + handshakeError := HandshakeError{ + stringCode, + time.Unix(updatedTimestamp, 0), + } + + handshakeErrors = append(handshakeErrors, handshakeError) + } + + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("FindHandshakeLastErrors failed to iterate over rows: %w", err) + } + return handshakeErrors, nil +} + +func (db *DBSQLite) UpdateHandshakeRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error { + _, err := db.db.ExecContext(ctx, sqlUpdateHandshakeRetryTime, retryTime.Unix(), id) + if err != nil { + return fmt.Errorf("UpdateHandshakeRetryTime failed: %w", err) + } + return nil +} + +func (db *DBSQLite) FindHandshakeRetryTime(ctx context.Context, id NodeID) (*time.Time, error) { + row := db.db.QueryRowContext(ctx, sqlFindHandshakeRetryTime, id) + + var timestamp sql.NullInt64 + + if err := row.Scan(×tamp); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("FindHandshakeRetryTime failed: %w", err) + } + + // if never we tried to handshake then the time is NULL + if !timestamp.Valid { + return nil, nil + } + + retryTime := time.Unix(timestamp.Int64, 0) + return &retryTime, nil +} + +func (db *DBSQLite) CountHandshakeCandidates(ctx context.Context) (uint, error) { + retryTimeBefore := time.Now().Unix() + row := db.db.QueryRowContext(ctx, sqlCountHandshakeCandidates, retryTimeBefore) + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountHandshakeCandidates failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) FindHandshakeCandidates( + ctx context.Context, + limit uint, +) ([]NodeID, error) { + retryTimeBefore := time.Now().Unix() + cursor, err := db.db.QueryContext( + ctx, + sqlFindHandshakeCandidates, + retryTimeBefore, + limit) + if err != nil { + return nil, fmt.Errorf("FindHandshakeCandidates failed to query candidates: %w", err) + } + defer func() { + _ = cursor.Close() + }() + + var nodes []NodeID + for cursor.Next() { + var id string + err := cursor.Scan(&id) + if err != nil { + return nil, fmt.Errorf("FindHandshakeCandidates failed to read candidate data: %w", err) + } + + nodes = append(nodes, NodeID(id)) + } + + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("FindHandshakeCandidates failed to iterate over candidates: %w", err) + } + return nodes, nil +} + +func (db *DBSQLite) MarkTakenHandshakeCandidates(ctx context.Context, ids []NodeID) error { + if len(ids) == 0 { + return nil + } + + delayedRetryTime := time.Now().Add(time.Hour).Unix() + + idsPlaceholders := strings.TrimRight(strings.Repeat("?,", len(ids)), ",") + query := strings.Replace(sqlMarkTakenHandshakeCandidates, "123", idsPlaceholders, 1) + args := append([]interface{}{delayedRetryTime}, stringsToAny(ids)...) + + _, err := db.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to mark taken handshake candidates: %w", err) + } + return nil +} + +func (db *DBSQLite) TakeHandshakeCandidates( + ctx context.Context, + limit uint, +) ([]NodeID, error) { + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("TakeHandshakeCandidates failed to start transaction: %w", err) + } + + ids, err := db.FindHandshakeCandidates( + ctx, + limit) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + err = db.MarkTakenHandshakeCandidates(ctx, ids) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + err = tx.Commit() + if err != nil { + return nil, fmt.Errorf("TakeHandshakeCandidates failed to commit transaction: %w", err) + } + return ids, nil +} + +func (db *DBSQLite) UpdateForkCompatibility(ctx context.Context, id NodeID, isCompatFork bool) error { + updated := time.Now().Unix() + + _, err := db.db.ExecContext(ctx, sqlUpdateForkCompatibility, isCompatFork, updated, id) + if err != nil { + return fmt.Errorf("UpdateForkCompatibility failed to update a node: %w", err) + } + return nil +} + +func (db *DBSQLite) UpdateNeighborBucketKeys(ctx context.Context, id NodeID, keys []string) error { + keysStr := strings.Join(keys, ",") + + _, err := db.db.ExecContext(ctx, sqlUpdateNeighborBucketKeys, keysStr, id) + if err != nil { + return fmt.Errorf("UpdateNeighborBucketKeys failed to update a node: %w", err) + } + return nil +} + +func (db *DBSQLite) FindNeighborBucketKeys(ctx context.Context, id NodeID) ([]string, error) { + row := db.db.QueryRowContext(ctx, sqlFindNeighborBucketKeys, id) + + var keysStr sql.NullString + if err := row.Scan(&keysStr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("FindNeighborBucketKeys failed: %w", err) + } + + if !keysStr.Valid { + return nil, nil + } + return strings.Split(keysStr.String, ","), nil +} + +func (db *DBSQLite) UpdateCrawlRetryTime(ctx context.Context, id NodeID, retryTime time.Time) error { + _, err := db.db.ExecContext(ctx, sqlUpdateCrawlRetryTime, retryTime.Unix(), id) + if err != nil { + return fmt.Errorf("UpdateCrawlRetryTime failed: %w", err) + } + return nil +} + +func (db *DBSQLite) CountCandidates(ctx context.Context) (uint, error) { + retryTimeBefore := time.Now().Unix() + row := db.db.QueryRowContext(ctx, sqlCountCandidates, retryTimeBefore) + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountCandidates failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) FindCandidates( + ctx context.Context, + limit uint, +) ([]NodeID, error) { + retryTimeBefore := time.Now().Unix() + cursor, err := db.db.QueryContext( + ctx, + sqlFindCandidates, + retryTimeBefore, + limit) + if err != nil { + return nil, fmt.Errorf("FindCandidates failed to query candidates: %w", err) + } + defer func() { + _ = cursor.Close() + }() + + var nodes []NodeID + for cursor.Next() { + var id string + err := cursor.Scan(&id) + if err != nil { + return nil, fmt.Errorf("FindCandidates failed to read candidate data: %w", err) + } + + nodes = append(nodes, NodeID(id)) + } + + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("FindCandidates failed to iterate over candidates: %w", err) + } + return nodes, nil +} + +func (db *DBSQLite) MarkTakenNodes(ctx context.Context, ids []NodeID) error { + if len(ids) == 0 { + return nil + } + + delayedRetryTime := time.Now().Add(time.Hour).Unix() + + idsPlaceholders := strings.TrimRight(strings.Repeat("?,", len(ids)), ",") + query := strings.Replace(sqlMarkTakenNodes, "123", idsPlaceholders, 1) + args := append([]interface{}{delayedRetryTime}, stringsToAny(ids)...) + + _, err := db.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to mark taken nodes: %w", err) + } + return nil +} + +func (db *DBSQLite) TakeCandidates( + ctx context.Context, + limit uint, +) ([]NodeID, error) { + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("TakeCandidates failed to start transaction: %w", err) + } + + ids, err := db.FindCandidates( + ctx, + limit) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + err = db.MarkTakenNodes(ctx, ids) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + err = tx.Commit() + if err != nil { + return nil, fmt.Errorf("TakeCandidates failed to commit transaction: %w", err) + } + return ids, nil +} + +func (db *DBSQLite) IsConflictError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "SQLITE_BUSY") +} + +func (db *DBSQLite) CountNodes(ctx context.Context, maxPingTries uint, networkID uint) (uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountNodes, maxPingTries, networkID) + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountNodes failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) CountIPs(ctx context.Context, maxPingTries uint, networkID uint) (uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountIPs, maxPingTries, networkID) + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountIPs failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) CountClients(ctx context.Context, clientIDPrefix string, maxPingTries uint, networkID uint) (uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountClients, maxPingTries, networkID, clientIDPrefix+"%") + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountClients failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) CountClientsWithNetworkID(ctx context.Context, clientIDPrefix string, maxPingTries uint) (uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountClientsWithNetworkID, maxPingTries, clientIDPrefix+"%") + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountClientsWithNetworkID failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) CountClientsWithHandshakeTransientError(ctx context.Context, clientIDPrefix string, maxPingTries uint) (uint, error) { + row := db.db.QueryRowContext(ctx, sqlCountClientsWithHandshakeTransientError, maxPingTries, clientIDPrefix+"%") + var count uint + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("CountClientsWithHandshakeTransientError failed: %w", err) + } + return count, nil +} + +func (db *DBSQLite) EnumerateClientIDs( + ctx context.Context, + maxPingTries uint, + networkID uint, + enumFunc func(clientID *string), +) error { + cursor, err := db.db.QueryContext(ctx, sqlEnumerateClientIDs, maxPingTries, networkID) + if err != nil { + return fmt.Errorf("EnumerateClientIDs failed to query: %w", err) + } + defer func() { + _ = cursor.Close() + }() + + for cursor.Next() { + var clientID sql.NullString + err := cursor.Scan(&clientID) + if err != nil { + return fmt.Errorf("EnumerateClientIDs failed to read data: %w", err) + } + if clientID.Valid { + enumFunc(&clientID.String) + } else { + enumFunc(nil) + } + } + + if err := cursor.Err(); err != nil { + return fmt.Errorf("EnumerateClientIDs failed to iterate: %w", err) + } + return nil +} + +func stringsToAny(strValues []NodeID) []interface{} { + values := make([]interface{}, 0, len(strValues)) + for _, value := range strValues { + values = append(values, value) + } + return values +} diff --git a/cmd/observer/database/db_sqlite_test.go b/cmd/observer/database/db_sqlite_test.go new file mode 100644 index 0000000000..c43e39f15b --- /dev/null +++ b/cmd/observer/database/db_sqlite_test.go @@ -0,0 +1,40 @@ +package database + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net" + "path/filepath" + "testing" +) + +func TestDBSQLiteInsertAndFind(t *testing.T) { + ctx := context.Background() + db, err := NewDBSQLite(filepath.Join(t.TempDir(), "observer.sqlite")) + require.Nil(t, err) + defer func() { _ = db.Close() }() + + var id NodeID = "ba85011c70bcc5c04d8607d3a0ed29aa6179c092cbdda10d5d32684fb33ed01bd94f588ca8f91ac48318087dcb02eaf36773a7a453f0eedd6742af668097b29c" + var addr NodeAddr + addr.IP = net.ParseIP("10.0.1.16") + addr.PortRLPx = 30303 + addr.PortDisc = 30304 + + err = db.UpsertNodeAddr(ctx, id, addr) + require.Nil(t, err) + + candidates, err := db.FindCandidates(ctx, 1) + require.Nil(t, err) + require.Equal(t, 1, len(candidates)) + + candidateID := candidates[0] + assert.Equal(t, id, candidateID) + + candidate, err := db.FindNodeAddr(ctx, candidateID) + require.Nil(t, err) + + assert.Equal(t, addr.IP, candidate.IP) + assert.Equal(t, addr.PortDisc, candidate.PortDisc) + assert.Equal(t, addr.PortRLPx, candidate.PortRLPx) +} diff --git a/cmd/observer/main.go b/cmd/observer/main.go new file mode 100644 index 0000000000..c7fdad7d08 --- /dev/null +++ b/cmd/observer/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "errors" + "fmt" + "github.com/ledgerwatch/erigon-lib/common" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/cmd/observer/observer" + "github.com/ledgerwatch/erigon/cmd/observer/reports" + "github.com/ledgerwatch/erigon/cmd/utils" + "github.com/ledgerwatch/erigon/params" + "github.com/ledgerwatch/log/v3" + "path/filepath" +) + +func mainWithFlags(ctx context.Context, flags observer.CommandFlags) error { + server, err := observer.NewServer(flags) + if err != nil { + return err + } + + db, err := database.NewDBSQLite(filepath.Join(flags.DataDir, "observer.sqlite")) + if err != nil { + return err + } + defer func() { _ = db.Close() }() + + discV4, err := server.Listen(ctx) + if err != nil { + return err + } + + networkID := uint(params.NetworkIDByChainName(flags.Chain)) + go observer.StatusLoggerLoop(ctx, db, networkID, flags.StatusLogPeriod, log.Root()) + + crawlerConfig := observer.CrawlerConfig{ + Chain: flags.Chain, + Bootnodes: server.Bootnodes(), + PrivateKey: server.PrivateKey(), + ConcurrencyLimit: flags.CrawlerConcurrency, + RefreshTimeout: flags.RefreshTimeout, + MaxPingTries: flags.MaxPingTries, + StatusLogPeriod: flags.StatusLogPeriod, + + HandshakeRefreshTimeout: flags.HandshakeRefreshTimeout, + HandshakeRetryDelay: flags.HandshakeRetryDelay, + HandshakeMaxTries: flags.HandshakeMaxTries, + + KeygenTimeout: flags.KeygenTimeout, + KeygenConcurrency: flags.KeygenConcurrency, + } + + crawler, err := observer.NewCrawler(discV4, db, crawlerConfig, log.Root()) + if err != nil { + return err + } + + return crawler.Run(ctx) +} + +func reportWithFlags(ctx context.Context, flags reports.CommandFlags) error { + db, err := database.NewDBSQLite(filepath.Join(flags.DataDir, "observer.sqlite")) + if err != nil { + return err + } + defer func() { _ = db.Close() }() + + networkID := uint(params.NetworkIDByChainName(flags.Chain)) + + if flags.Estimate { + report, err := reports.CreateClientsEstimateReport(ctx, db, flags.ClientsLimit, flags.MaxPingTries, networkID) + if err != nil { + return err + } + fmt.Println(report) + return nil + } + + statusReport, err := reports.CreateStatusReport(ctx, db, flags.MaxPingTries, networkID) + if err != nil { + return err + } + clientsReport, err := reports.CreateClientsReport(ctx, db, flags.ClientsLimit, flags.MaxPingTries, networkID) + if err != nil { + return err + } + + fmt.Println(statusReport) + fmt.Println(clientsReport) + return nil +} + +func main() { + ctx, cancel := common.RootContext() + defer cancel() + + command := observer.NewCommand() + + reportCommand := reports.NewCommand() + reportCommand.OnRun(reportWithFlags) + command.AddSubCommand(reportCommand.RawCommand()) + + err := command.ExecuteContext(ctx, mainWithFlags) + if (err != nil) && !errors.Is(err, context.Canceled) { + utils.Fatalf("%v", err) + } +} diff --git a/cmd/observer/observer/client_id.go b/cmd/observer/observer/client_id.go new file mode 100644 index 0000000000..2dec521ac2 --- /dev/null +++ b/cmd/observer/observer/client_id.go @@ -0,0 +1,133 @@ +package observer + +import "strings" + +func clientNameBlacklist() []string { + return []string{ + // bor/v0.2.14-stable-9edb2836/linux-amd64/go1.17.7 + // https://polygon.technology + "bor", + + // Cypher/v1.9.24-unstable-a7d8c0f9/linux-amd64/go1.11 + // unknown, but it's likely outdated since almost all nodes are running on go 1.11 (2018) + "Cypher", + + // Ecoball/v1.0.2-stable-ac03aee-20211125/x86_64-linux-gnu/rustc1.52.1 + // https://ecoball.org + "Ecoball", + + // egem/v1.1.4-titanus-9b056f56-20210808/linux-amd64/go1.15.13 + // https://egem.io + "egem", + + // energi3/v3.1.1-stable/linux-amd64/go1.15.8 + // https://energi.world + "energi3", + + // Gdbix/v1.5.3-fluxdbix-f6911ea5/linux/go1.8.3 + // https://www.arabianchain.org + "Gdbix", + + // Gddm/v0.8.1-master-7155d9dd/linux-amd64/go1.9.2 + // unknown, but it's likely outdated since all nodes are running on go 1.9 (2017) + "Gddm", + + // Gero/v1.1.3-dev-f8efb930/linux-amd64/go1.13.4 + // https://sero.cash + "Gero", + + // Gesn/v0.3.13-stable-b6c12eb2/linux-amd64/go1.12.4 + // https://ethersocial.org + "Gesn", + + // Gexp/v1.10.8-stable-1eb55798/linux-amd64/go1.17 + // https://expanse.tech + "Gexp", + + // Gnekonium/v1.6.6-stable-820982d6/linux-amd64/go1.9.2 + // https://nekonium.github.io + "Gnekonium", + + // go-corex/v1.0.0-rc.1-6197c8bf-1638348709/linux-amd64/go1.17.3 + // https://www.corexchain.io + "go-corex", + + // go-opera/v1.1.0-rc.4-91951f74-1647353617/linux-amd64/go1.17.8 + // https://www.fantom.foundation + "go-opera", + + // go-photon/v1.0.2-rc.5-32a52936-1646808549/linux-amd64/go1.17.8 + // https://github.com/TechPay-io/go-photon + "go-photon", + + // GoChain/v4.0.2/linux-amd64/go1.17.3 + // https://gochain.io + "GoChain", + + // gqdc/v1.5.2-stable-53b6a36d/linux-amd64/go1.13.4 + // https://quadrans.io + "gqdc", + + // Gtsf/v1.2.1-stable-df201e7e/linux-amd64/go1.13.4 + // https://tsf-network.com + "Gtsf", + + // Gubiq/v7.0.0-monoceros-c9009e89/linux-amd64/go1.17.6 + // https://ubiqsmart.com + "Gubiq", + + // Gvns/v3.2.0-unstable/linux-amd64/go1.12.4 + // https://github.com/AMTcommunity/go-vnscoin + "Gvns", + + // Moac/v2.1.5-stable-af7bea47/linux-amd64/go1.13.4 + // https://www.moac.io + "Moac", + + // pchain/linux-amd64/go1.13.3 + // http://pchain.org + "pchain", + + // Pirl/v1.9.12-v7-masternode-premium-lion-ea07aebf-20200407/linux-amd64/go1.13.6 + // https://pirl.io + "Pirl", + + // Q-Client/v1.0.8-stable/Geth/v1.10.8-stable-850a0145/linux-amd64/go1.16.15 + // https://q.org + "Q-Client", + + // qk_node/v1.10.16-stable-75ceb6c6-20220308/linux-amd64/go1.17.8 + // https://quarkblockchain.medium.com + "qk_node", + + // Quai/v1.10.10-unstable-b1b52e79-20220226/linux-amd64/go1.17.7 + // https://quai.network + "Quai", + + // REOSC/v2.2.4-unstable-6bcba06-20190321/x86_64-linux-gnu/rustc1.37.0 + // https://www.reosc.io + "REOSC", + + // ronin/v2.3.0-stable-f07cd8d1/linux-amd64/go1.15.5 + // https://wallet.roninchain.com + "ronin", + } +} + +func IsClientIDBlacklisted(clientID string) bool { + // some unknown clients return an empty string + if clientID == "" { + return true + } + for _, clientName := range clientNameBlacklist() { + if strings.HasPrefix(clientID, clientName) { + return true + } + } + return false +} + +func NameFromClientID(clientID string) string { + parts := strings.SplitN(clientID, "/", 2) + return parts[0] +} diff --git a/cmd/observer/observer/command.go b/cmd/observer/observer/command.go new file mode 100644 index 0000000000..ac72355ccf --- /dev/null +++ b/cmd/observer/observer/command.go @@ -0,0 +1,233 @@ +package observer + +import ( + "context" + "errors" + "github.com/ledgerwatch/erigon/cmd/utils" + "github.com/ledgerwatch/erigon/internal/debug" + "github.com/spf13/cobra" + "github.com/urfave/cli" + "runtime" + "time" +) + +type CommandFlags struct { + DataDir string + StatusLogPeriod time.Duration + + Chain string + Bootnodes string + + ListenPort int + NATDesc string + NetRestrict string + + NodeKeyFile string + NodeKeyHex string + + CrawlerConcurrency uint + RefreshTimeout time.Duration + MaxPingTries uint + + KeygenTimeout time.Duration + KeygenConcurrency uint + + HandshakeRefreshTimeout time.Duration + HandshakeRetryDelay time.Duration + HandshakeMaxTries uint +} + +type Command struct { + command cobra.Command + flags CommandFlags +} + +func NewCommand() *Command { + command := cobra.Command{ + Short: "P2P network crawler", + } + + // debug flags + utils.CobraFlags(&command, append(debug.Flags, utils.MetricFlags...)) + + instance := Command{ + command: command, + } + + instance.withDatadir() + instance.withStatusLogPeriod() + + instance.withChain() + instance.withBootnodes() + + instance.withListenPort() + instance.withNAT() + instance.withNetRestrict() + + instance.withNodeKeyFile() + instance.withNodeKeyHex() + + instance.withCrawlerConcurrency() + instance.withRefreshTimeout() + instance.withMaxPingTries() + + instance.withKeygenTimeout() + instance.withKeygenConcurrency() + + instance.withHandshakeRefreshTimeout() + instance.withHandshakeRetryDelay() + instance.withHandshakeMaxTries() + + return &instance +} + +func (command *Command) withDatadir() { + flag := utils.DataDirFlag + command.command.Flags().StringVar(&command.flags.DataDir, flag.Name, flag.Value.String(), flag.Usage) + must(command.command.MarkFlagDirname(utils.DataDirFlag.Name)) +} + +func (command *Command) withStatusLogPeriod() { + flag := cli.DurationFlag{ + Name: "status-log-period", + Usage: "How often to log status summaries", + Value: 10 * time.Second, + } + command.command.Flags().DurationVar(&command.flags.StatusLogPeriod, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withChain() { + flag := utils.ChainFlag + command.command.Flags().StringVar(&command.flags.Chain, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withBootnodes() { + flag := utils.BootnodesFlag + command.command.Flags().StringVar(&command.flags.Bootnodes, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withListenPort() { + flag := utils.ListenPortFlag + command.command.Flags().IntVar(&command.flags.ListenPort, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withNAT() { + flag := utils.NATFlag + command.command.Flags().StringVar(&command.flags.NATDesc, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withNetRestrict() { + flag := utils.NetrestrictFlag + command.command.Flags().StringVar(&command.flags.NetRestrict, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withNodeKeyFile() { + flag := utils.NodeKeyFileFlag + command.command.Flags().StringVar(&command.flags.NodeKeyFile, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withNodeKeyHex() { + flag := utils.NodeKeyHexFlag + command.command.Flags().StringVar(&command.flags.NodeKeyHex, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withCrawlerConcurrency() { + flag := cli.UintFlag{ + Name: "crawler-concurrency", + Usage: "A number of maximum parallel node interrogations", + Value: uint(runtime.GOMAXPROCS(-1)) * 10, + } + command.command.Flags().UintVar(&command.flags.CrawlerConcurrency, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withRefreshTimeout() { + flag := cli.DurationFlag{ + Name: "refresh-timeout", + Usage: "A timeout to wait before considering to re-crawl a node", + Value: 2 * 24 * time.Hour, + } + command.command.Flags().DurationVar(&command.flags.RefreshTimeout, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withMaxPingTries() { + flag := cli.UintFlag{ + Name: "max-ping-tries", + Usage: "How many times to try PING before applying exponential back-off logic", + Value: 3, + } + command.command.Flags().UintVar(&command.flags.MaxPingTries, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withKeygenTimeout() { + flag := cli.DurationFlag{ + Name: "keygen-timeout", + Usage: "How much time can be used to generate node bucket keys", + Value: 2 * time.Second, + } + command.command.Flags().DurationVar(&command.flags.KeygenTimeout, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withKeygenConcurrency() { + flag := cli.UintFlag{ + Name: "keygen-concurrency", + Usage: "How many parallel goroutines can be used by the node bucket keys generator", + Value: 2, + } + command.command.Flags().UintVar(&command.flags.KeygenConcurrency, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withHandshakeRefreshTimeout() { + flag := cli.DurationFlag{ + Name: "handshake-refresh-timeout", + Usage: "When a node's handshake data is considered expired and needs to be re-crawled", + Value: 20 * 24 * time.Hour, + } + command.command.Flags().DurationVar(&command.flags.HandshakeRefreshTimeout, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withHandshakeRetryDelay() { + flag := cli.DurationFlag{ + Name: "handshake-retry-delay", + Usage: "How long to wait before retrying a failed handshake", + Value: 4 * time.Hour, + } + command.command.Flags().DurationVar(&command.flags.HandshakeRetryDelay, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withHandshakeMaxTries() { + flag := cli.UintFlag{ + Name: "handshake-max-tries", + Usage: "How many times to retry handshake before abandoning a candidate", + Value: 3, + } + command.command.Flags().UintVar(&command.flags.HandshakeMaxTries, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) ExecuteContext(ctx context.Context, runFunc func(ctx context.Context, flags CommandFlags) error) error { + command.command.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // apply debug flags + return utils.SetupCobra(cmd) + } + command.command.PersistentPostRun = func(cmd *cobra.Command, args []string) { + debug.Exit() + } + command.command.RunE = func(cmd *cobra.Command, args []string) error { + defer debug.Exit() + err := runFunc(cmd.Context(), command.flags) + if errors.Is(err, context.Canceled) { + return nil + } + return err + } + return command.command.ExecuteContext(ctx) +} + +func (command *Command) AddSubCommand(subCommand *cobra.Command) { + command.command.AddCommand(subCommand) +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/cmd/observer/observer/crawler.go b/cmd/observer/observer/crawler.go new file mode 100644 index 0000000000..07005dc368 --- /dev/null +++ b/cmd/observer/observer/crawler.go @@ -0,0 +1,495 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/cmd/observer/utils" + "github.com/ledgerwatch/erigon/core/forkid" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/erigon/params" + "github.com/ledgerwatch/log/v3" + "golang.org/x/sync/semaphore" + "sync/atomic" + "time" +) + +type Crawler struct { + transport DiscV4Transport + + db database.DBRetrier + saveQueue *utils.TaskQueue + + config CrawlerConfig + forkFilter forkid.Filter + + diplomacy *Diplomacy + + log log.Logger +} + +type CrawlerConfig struct { + Chain string + Bootnodes []*enode.Node + PrivateKey *ecdsa.PrivateKey + ConcurrencyLimit uint + RefreshTimeout time.Duration + MaxPingTries uint + StatusLogPeriod time.Duration + + HandshakeRefreshTimeout time.Duration + HandshakeRetryDelay time.Duration + HandshakeMaxTries uint + + KeygenTimeout time.Duration + KeygenConcurrency uint +} + +func NewCrawler( + transport DiscV4Transport, + db database.DB, + config CrawlerConfig, + logger log.Logger, +) (*Crawler, error) { + saveQueueLogFuncProvider := func(err error) func(msg string, ctx ...interface{}) { + if db.IsConflictError(err) { + return logger.Warn + } + return logger.Error + } + saveQueue := utils.NewTaskQueue("Crawler.saveQueue", config.ConcurrencyLimit*2, saveQueueLogFuncProvider) + + chain := config.Chain + chainConfig := params.ChainConfigByChainName(chain) + genesisHash := params.GenesisHashByChainName(chain) + if (chainConfig == nil) || (genesisHash == nil) { + return nil, fmt.Errorf("unknown chain %s", chain) + } + + forkFilter := forkid.NewStaticFilter(chainConfig, *genesisHash) + + diplomacy := NewDiplomacy( + database.NewDBRetrier(db, logger), + saveQueue, + config.PrivateKey, + config.ConcurrencyLimit, + config.HandshakeRefreshTimeout, + config.HandshakeRetryDelay, + config.HandshakeMaxTries, + config.StatusLogPeriod, + logger) + + instance := Crawler{ + transport, + database.NewDBRetrier(db, logger), + saveQueue, + config, + forkFilter, + diplomacy, + logger, + } + return &instance, nil +} + +func (crawler *Crawler) startSaveQueue(ctx context.Context) { + go crawler.saveQueue.Run(ctx) +} + +func (crawler *Crawler) startDiplomacy(ctx context.Context) { + go func() { + err := crawler.diplomacy.Run(ctx) + if (err != nil) && !errors.Is(err, context.Canceled) { + crawler.log.Error("Diplomacy has failed", "err", err) + } + }() +} + +type candidateNode struct { + id database.NodeID + node *enode.Node +} + +func (crawler *Crawler) startSelectCandidates(ctx context.Context) <-chan candidateNode { + nodes := make(chan candidateNode) + go func() { + err := crawler.selectCandidates(ctx, nodes) + if (err != nil) && !errors.Is(err, context.Canceled) { + crawler.log.Error("Failed to select candidates", "err", err) + } + close(nodes) + }() + return nodes +} + +func (crawler *Crawler) selectCandidates(ctx context.Context, nodes chan<- candidateNode) error { + for _, node := range crawler.config.Bootnodes { + id, err := nodeID(node) + if err != nil { + return fmt.Errorf("failed to get a bootnode ID: %w", err) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case nodes <- candidateNode{id, node}: + } + } + + for ctx.Err() == nil { + candidates, err := crawler.db.TakeCandidates( + ctx, + crawler.config.ConcurrencyLimit) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to take candidates", "err", err) + } else { + return err + } + } + + if len(candidates) == 0 { + utils.Sleep(ctx, 1*time.Second) + } + + for _, id := range candidates { + select { + case <-ctx.Done(): + return ctx.Err() + case nodes <- candidateNode{id, nil}: + } + } + } + + return ctx.Err() +} + +func (crawler *Crawler) Run(ctx context.Context) error { + crawler.startSaveQueue(ctx) + crawler.startDiplomacy(ctx) + + nodes := crawler.startSelectCandidates(ctx) + sem := semaphore.NewWeighted(int64(crawler.config.ConcurrencyLimit)) + // allow only 1 keygen at a time + keygenSem := semaphore.NewWeighted(int64(1)) + + crawledCount := 0 + crawledCountLogDate := time.Now() + foundPeersCountPtr := new(uint64) + + for candidate := range nodes { + if err := sem.Acquire(ctx, 1); err != nil { + if !errors.Is(err, context.Canceled) { + return fmt.Errorf("failed to acquire semaphore: %w", err) + } else { + break + } + } + + crawledCount++ + if time.Since(crawledCountLogDate) > crawler.config.StatusLogPeriod { + foundPeersCount := atomic.LoadUint64(foundPeersCountPtr) + + remainingCount, err := crawler.db.CountCandidates(ctx) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to count candidates", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to count candidates: %w", err) + } + + crawler.log.Info( + "Crawling", + "crawled", crawledCount, + "remaining", remainingCount, + "foundPeers", foundPeersCount, + ) + crawledCountLogDate = time.Now() + } + + id := candidate.id + node := candidate.node + + if node == nil { + nodeAddr, err := crawler.db.FindNodeAddr(ctx, id) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to get the node address", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get the node address: %w", err) + } + + node, err = makeNodeFromAddr(id, *nodeAddr) + if err != nil { + return fmt.Errorf("failed to make node from node address: %w", err) + } + } + + nodeDesc := node.URLv4() + logger := crawler.log.New("node", nodeDesc) + + prevPingTries, err := crawler.db.CountPingErrors(ctx, id) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to count ping errors", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to count ping errors: %w", err) + } + + handshakeNextRetryTime, err := crawler.db.FindHandshakeRetryTime(ctx, id) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to get handshake next retry time", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get handshake next retry time: %w", err) + } + + handshakeLastErrors, err := crawler.db.FindHandshakeLastErrors(ctx, id, crawler.config.HandshakeMaxTries) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to get handshake last errors", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get handshake last errors: %w", err) + } + + diplomat := NewDiplomat( + node, + crawler.config.PrivateKey, + handshakeLastErrors, + crawler.config.HandshakeRefreshTimeout, + crawler.config.HandshakeRetryDelay, + crawler.config.HandshakeMaxTries, + logger) + + keygenCachedHexKeys, err := crawler.db.FindNeighborBucketKeys(ctx, id) + if err != nil { + if crawler.db.IsConflictError(err) { + crawler.log.Warn("Failed to get neighbor bucket keys", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get neighbor bucket keys: %w", err) + } + keygenCachedKeys, err := parseHexPublicKeys(keygenCachedHexKeys) + if err != nil { + return fmt.Errorf("failed to parse cached neighbor bucket keys: %w", err) + } + + interrogator, err := NewInterrogator( + node, + crawler.transport, + crawler.forkFilter, + diplomat, + handshakeNextRetryTime, + crawler.config.KeygenTimeout, + crawler.config.KeygenConcurrency, + keygenSem, + keygenCachedKeys, + logger) + if err != nil { + return fmt.Errorf("failed to create Interrogator for node %s: %w", nodeDesc, err) + } + + go func() { + defer sem.Release(1) + + result, err := interrogator.Run(ctx) + + isPingError := (err != nil) && (err.id == InterrogationErrorPing) + nextRetryTime := crawler.nextRetryTime(isPingError, *prevPingTries) + + var isCompatFork *bool + if result != nil { + isCompatFork = result.IsCompatFork + } else if (err != nil) && + ((err.id == InterrogationErrorIncompatibleForkID) || + (err.id == InterrogationErrorBlacklistedClientID)) { + isCompatFork = new(bool) + *isCompatFork = false + } + + var clientID *string + var handshakeRetryTime *time.Time + if (result != nil) && (result.HandshakeResult != nil) { + clientID = result.HandshakeResult.ClientID + handshakeRetryTime = result.HandshakeRetryTime + } else if (err != nil) && (err.id == InterrogationErrorBlacklistedClientID) { + clientID = new(string) + *clientID = err.wrappedErr.Error() + handshakeRetryTime = new(time.Time) + *handshakeRetryTime = time.Now().Add(crawler.config.HandshakeRefreshTimeout) + } + + if err != nil { + if !errors.Is(err, context.Canceled) { + var logFunc func(msg string, ctx ...interface{}) + switch err.id { + case InterrogationErrorPing: + fallthrough + case InterrogationErrorIncompatibleForkID: + fallthrough + case InterrogationErrorBlacklistedClientID: + fallthrough + case InterrogationErrorFindNodeTimeout: + logFunc = logger.Debug + default: + logFunc = logger.Warn + } + logFunc("Failed to interrogate node", "err", err) + } + } + + if result != nil { + peers := result.Peers + logger.Debug(fmt.Sprintf("Got %d peers", len(peers))) + atomic.AddUint64(foundPeersCountPtr, uint64(len(peers))) + } + + crawler.saveQueue.EnqueueTask(ctx, func(ctx context.Context) error { + return crawler.saveInterrogationResult( + ctx, + id, + result, + isPingError, + isCompatFork, + clientID, + handshakeRetryTime, + nextRetryTime) + }) + }() + } + return nil +} + +func (crawler *Crawler) saveInterrogationResult( + ctx context.Context, + id database.NodeID, + result *InterrogationResult, + isPingError bool, + isCompatFork *bool, + clientID *string, + handshakeRetryTime *time.Time, + nextRetryTime time.Time, +) error { + var peers []*enode.Node + if result != nil { + peers = result.Peers + } + + for _, peer := range peers { + peerID, err := nodeID(peer) + if err != nil { + return fmt.Errorf("failed to get peer node ID: %w", err) + } + + dbErr := crawler.db.UpsertNodeAddr(ctx, peerID, makeNodeAddr(peer)) + if dbErr != nil { + return dbErr + } + } + + if (result != nil) && (len(result.KeygenKeys) >= 15) { + keygenHexKeys := hexEncodePublicKeys(result.KeygenKeys) + dbErr := crawler.db.UpdateNeighborBucketKeys(ctx, id, keygenHexKeys) + if dbErr != nil { + return dbErr + } + } + + if isPingError { + dbErr := crawler.db.UpdatePingError(ctx, id) + if dbErr != nil { + return dbErr + } + } else { + dbErr := crawler.db.ResetPingError(ctx, id) + if dbErr != nil { + return dbErr + } + } + + if isCompatFork != nil { + dbErr := crawler.db.UpdateForkCompatibility(ctx, id, *isCompatFork) + if dbErr != nil { + return dbErr + } + } + + if clientID != nil { + dbErr := crawler.db.UpdateClientID(ctx, id, *clientID) + if dbErr != nil { + return dbErr + } + + dbErr = crawler.db.DeleteHandshakeErrors(ctx, id) + if dbErr != nil { + return dbErr + } + } + + if (result != nil) && (result.HandshakeResult != nil) && (result.HandshakeResult.NetworkID != nil) { + dbErr := crawler.db.UpdateNetworkID(ctx, id, uint(*result.HandshakeResult.NetworkID)) + if dbErr != nil { + return dbErr + } + } + + if (result != nil) && (result.HandshakeResult != nil) && (result.HandshakeResult.EthVersion != nil) { + dbErr := crawler.db.UpdateEthVersion(ctx, id, uint(*result.HandshakeResult.EthVersion)) + if dbErr != nil { + return dbErr + } + } + + if (result != nil) && (result.HandshakeResult != nil) && (result.HandshakeResult.HandshakeErr != nil) { + dbErr := crawler.db.InsertHandshakeError(ctx, id, result.HandshakeResult.HandshakeErr.StringCode()) + if dbErr != nil { + return dbErr + } + } + + if (result != nil) && (result.HandshakeResult != nil) { + dbErr := crawler.db.UpdateHandshakeTransientError(ctx, id, result.HandshakeResult.HasTransientErr) + if dbErr != nil { + return dbErr + } + } + + if handshakeRetryTime != nil { + dbErr := crawler.db.UpdateHandshakeRetryTime(ctx, id, *handshakeRetryTime) + if dbErr != nil { + return dbErr + } + } + + return crawler.db.UpdateCrawlRetryTime(ctx, id, nextRetryTime) +} + +func (crawler *Crawler) nextRetryTime(isPingError bool, prevPingTries uint) time.Time { + return time.Now().Add(crawler.nextRetryDelay(isPingError, prevPingTries)) +} + +func (crawler *Crawler) nextRetryDelay(isPingError bool, prevPingTries uint) time.Duration { + if !isPingError { + return crawler.config.RefreshTimeout + } + + pingTries := prevPingTries + 1 + if pingTries < crawler.config.MaxPingTries { + return crawler.config.RefreshTimeout + } + + // back off: double for each next retry + return crawler.config.RefreshTimeout << (pingTries - crawler.config.MaxPingTries + 1) +} diff --git a/cmd/observer/observer/diplomacy.go b/cmd/observer/observer/diplomacy.go new file mode 100644 index 0000000000..35765732f5 --- /dev/null +++ b/cmd/observer/observer/diplomacy.go @@ -0,0 +1,252 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/cmd/observer/utils" + "github.com/ledgerwatch/log/v3" + "golang.org/x/sync/semaphore" + "sync/atomic" + "time" +) + +type Diplomacy struct { + db database.DBRetrier + saveQueue *utils.TaskQueue + + privateKey *ecdsa.PrivateKey + concurrencyLimit uint + refreshTimeout time.Duration + retryDelay time.Duration + maxHandshakeTries uint + + statusLogPeriod time.Duration + log log.Logger +} + +func NewDiplomacy( + db database.DBRetrier, + saveQueue *utils.TaskQueue, + privateKey *ecdsa.PrivateKey, + concurrencyLimit uint, + refreshTimeout time.Duration, + retryDelay time.Duration, + maxHandshakeTries uint, + statusLogPeriod time.Duration, + logger log.Logger, +) *Diplomacy { + instance := Diplomacy{ + db, + saveQueue, + privateKey, + concurrencyLimit, + refreshTimeout, + retryDelay, + maxHandshakeTries, + statusLogPeriod, + logger, + } + return &instance +} + +func (diplomacy *Diplomacy) startSelectCandidates(ctx context.Context) <-chan database.NodeID { + candidatesChan := make(chan database.NodeID) + go func() { + err := diplomacy.selectCandidates(ctx, candidatesChan) + if (err != nil) && !errors.Is(err, context.Canceled) { + diplomacy.log.Error("Failed to select handshake candidates", "err", err) + } + close(candidatesChan) + }() + return candidatesChan +} + +func (diplomacy *Diplomacy) selectCandidates(ctx context.Context, candidatesChan chan<- database.NodeID) error { + for ctx.Err() == nil { + candidates, err := diplomacy.db.TakeHandshakeCandidates( + ctx, + diplomacy.concurrencyLimit) + if err != nil { + if diplomacy.db.IsConflictError(err) { + diplomacy.log.Warn("Failed to take handshake candidates", "err", err) + } else { + return err + } + } + + if len(candidates) == 0 { + utils.Sleep(ctx, 1*time.Second) + } + + for _, id := range candidates { + select { + case <-ctx.Done(): + return ctx.Err() + case candidatesChan <- id: + } + } + } + + return ctx.Err() +} + +func (diplomacy *Diplomacy) Run(ctx context.Context) error { + candidatesChan := diplomacy.startSelectCandidates(ctx) + sem := semaphore.NewWeighted(int64(diplomacy.concurrencyLimit)) + + doneCount := 0 + statusLogDate := time.Now() + clientIDCountPtr := new(uint64) + + for id := range candidatesChan { + if err := sem.Acquire(ctx, 1); err != nil { + if !errors.Is(err, context.Canceled) { + return fmt.Errorf("failed to acquire semaphore: %w", err) + } else { + break + } + } + + doneCount++ + if time.Since(statusLogDate) > diplomacy.statusLogPeriod { + clientIDCount := atomic.LoadUint64(clientIDCountPtr) + + remainingCount, err := diplomacy.db.CountHandshakeCandidates(ctx) + if err != nil { + if diplomacy.db.IsConflictError(err) { + diplomacy.log.Warn("Failed to count handshake candidates", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to count handshake candidates: %w", err) + } + + diplomacy.log.Info( + "Handshaking", + "done", doneCount, + "remaining", remainingCount, + "clientIDs", clientIDCount, + ) + statusLogDate = time.Now() + } + + nodeAddr, err := diplomacy.db.FindNodeAddr(ctx, id) + if err != nil { + if diplomacy.db.IsConflictError(err) { + diplomacy.log.Warn("Failed to get the node address", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get the node address: %w", err) + } + + node, err := makeNodeFromAddr(id, *nodeAddr) + if err != nil { + return fmt.Errorf("failed to make node from node address: %w", err) + } + + nodeDesc := node.URLv4() + logger := diplomacy.log.New("node", nodeDesc) + + handshakeLastErrors, err := diplomacy.db.FindHandshakeLastErrors(ctx, id, diplomacy.maxHandshakeTries) + if err != nil { + if diplomacy.db.IsConflictError(err) { + diplomacy.log.Warn("Failed to get handshake last errors", "err", err) + sem.Release(1) + continue + } + return fmt.Errorf("failed to get handshake last errors: %w", err) + } + + diplomat := NewDiplomat( + node, + diplomacy.privateKey, + handshakeLastErrors, + diplomacy.refreshTimeout, + diplomacy.retryDelay, + diplomacy.maxHandshakeTries, + logger) + + go func(id database.NodeID) { + defer sem.Release(1) + + result := diplomat.Run(ctx) + clientID := result.ClientID + + if clientID != nil { + atomic.AddUint64(clientIDCountPtr, 1) + } + + var isCompatFork *bool + if (clientID != nil) && IsClientIDBlacklisted(*clientID) { + isCompatFork = new(bool) + *isCompatFork = false + } + + nextRetryTime := diplomat.NextRetryTime(result.HandshakeErr) + + diplomacy.saveQueue.EnqueueTask(ctx, func(ctx context.Context) error { + return diplomacy.saveDiplomatResult(ctx, id, result, isCompatFork, nextRetryTime) + }) + }(id) + } + return nil +} + +func (diplomacy *Diplomacy) saveDiplomatResult( + ctx context.Context, + id database.NodeID, + result DiplomatResult, + isCompatFork *bool, + nextRetryTime time.Time, +) error { + if result.ClientID != nil { + dbErr := diplomacy.db.UpdateClientID(ctx, id, *result.ClientID) + if dbErr != nil { + return dbErr + } + + dbErr = diplomacy.db.DeleteHandshakeErrors(ctx, id) + if dbErr != nil { + return dbErr + } + } + + if result.NetworkID != nil { + dbErr := diplomacy.db.UpdateNetworkID(ctx, id, uint(*result.NetworkID)) + if dbErr != nil { + return dbErr + } + } + + if result.EthVersion != nil { + dbErr := diplomacy.db.UpdateEthVersion(ctx, id, uint(*result.EthVersion)) + if dbErr != nil { + return dbErr + } + } + + if result.HandshakeErr != nil { + dbErr := diplomacy.db.InsertHandshakeError(ctx, id, result.HandshakeErr.StringCode()) + if dbErr != nil { + return dbErr + } + } + + dbErr := diplomacy.db.UpdateHandshakeTransientError(ctx, id, result.HasTransientErr) + if dbErr != nil { + return dbErr + } + + if isCompatFork != nil { + dbErr := diplomacy.db.UpdateForkCompatibility(ctx, id, *isCompatFork) + if dbErr != nil { + return dbErr + } + } + + return diplomacy.db.UpdateHandshakeRetryTime(ctx, id, nextRetryTime) +} diff --git a/cmd/observer/observer/diplomat.go b/cmd/observer/observer/diplomat.go new file mode 100644 index 0000000000..9812b3bc03 --- /dev/null +++ b/cmd/observer/observer/diplomat.go @@ -0,0 +1,150 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "errors" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/p2p" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/log/v3" + "time" +) + +type Diplomat struct { + node *enode.Node + privateKey *ecdsa.PrivateKey + + handshakeLastErrors []database.HandshakeError + handshakeRefreshTimeout time.Duration + handshakeRetryDelay time.Duration + handshakeMaxTries uint + + log log.Logger +} + +type DiplomatResult struct { + ClientID *string + NetworkID *uint64 + EthVersion *uint32 + HandshakeErr *HandshakeError + HasTransientErr bool +} + +func NewDiplomat( + node *enode.Node, + privateKey *ecdsa.PrivateKey, + handshakeLastErrors []database.HandshakeError, + handshakeRefreshTimeout time.Duration, + handshakeRetryDelay time.Duration, + handshakeMaxTries uint, + logger log.Logger, +) *Diplomat { + instance := Diplomat{ + node, + privateKey, + handshakeLastErrors, + handshakeRefreshTimeout, + handshakeRetryDelay, + handshakeMaxTries, + logger, + } + return &instance +} + +func (diplomat *Diplomat) handshake(ctx context.Context) (*HelloMessage, *StatusMessage, *HandshakeError) { + node := diplomat.node + return Handshake(ctx, node.IP(), node.TCP(), node.Pubkey(), diplomat.privateKey) +} + +func (diplomat *Diplomat) Run(ctx context.Context) DiplomatResult { + diplomat.log.Debug("Handshaking with a node") + hello, status, handshakeErr := diplomat.handshake(ctx) + + var result DiplomatResult + + if (handshakeErr != nil) && !errors.Is(handshakeErr, context.Canceled) { + result.HandshakeErr = handshakeErr + diplomat.log.Debug("Failed to handshake", "err", handshakeErr) + } + result.HasTransientErr = diplomat.hasRecentTransientError(handshakeErr) + + if hello != nil { + result.ClientID = &hello.ClientID + diplomat.log.Debug("Got client ID", "clientID", *result.ClientID) + } + + if status != nil { + result.NetworkID = &status.NetworkID + diplomat.log.Debug("Got network ID", "networkID", *result.NetworkID) + } + if status != nil { + result.EthVersion = &status.ProtocolVersion + diplomat.log.Debug("Got eth version", "ethVersion", *result.EthVersion) + } + + return result +} + +func (diplomat *Diplomat) NextRetryTime(handshakeErr *HandshakeError) time.Time { + return time.Now().Add(diplomat.NextRetryDelay(handshakeErr)) +} + +func (diplomat *Diplomat) NextRetryDelay(handshakeErr *HandshakeError) time.Duration { + if handshakeErr == nil { + return diplomat.handshakeRefreshTimeout + } + + dbHandshakeErr := database.HandshakeError{ + StringCode: handshakeErr.StringCode(), + Time: time.Now(), + } + + lastErrors := append([]database.HandshakeError{dbHandshakeErr}, diplomat.handshakeLastErrors...) + + if uint(len(lastErrors)) < diplomat.handshakeMaxTries { + return diplomat.handshakeRetryDelay + } + + if containsHandshakeError(diplomat.transientError(), lastErrors) { + return diplomat.handshakeRetryDelay + } + + if len(lastErrors) < 2 { + return 1000000 * time.Hour // never + } + + backOffDelay := 2 * lastErrors[0].Time.Sub(lastErrors[1].Time) + if backOffDelay < diplomat.handshakeRetryDelay { + return diplomat.handshakeRetryDelay + } + + return backOffDelay +} + +func (diplomat *Diplomat) transientError() *HandshakeError { + return NewHandshakeError(HandshakeErrorIDDisconnect, p2p.DiscTooManyPeers, uint64(p2p.DiscTooManyPeers)) +} + +func (diplomat *Diplomat) hasRecentTransientError(handshakeErr *HandshakeError) bool { + if handshakeErr == nil { + return false + } + + dbHandshakeErr := database.HandshakeError{ + StringCode: handshakeErr.StringCode(), + Time: time.Now(), + } + + lastErrors := append([]database.HandshakeError{dbHandshakeErr}, diplomat.handshakeLastErrors...) + return containsHandshakeError(diplomat.transientError(), lastErrors) +} + +func containsHandshakeError(target *HandshakeError, list []database.HandshakeError) bool { + for _, err := range list { + if err.StringCode == target.StringCode() { + return true + } + } + return false +} diff --git a/cmd/observer/observer/handshake.go b/cmd/observer/observer/handshake.go new file mode 100644 index 0000000000..6fdcd3414a --- /dev/null +++ b/cmd/observer/observer/handshake.go @@ -0,0 +1,252 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "fmt" + "github.com/ledgerwatch/erigon/common" + "github.com/ledgerwatch/erigon/core/forkid" + "github.com/ledgerwatch/erigon/crypto" + "github.com/ledgerwatch/erigon/eth/protocols/eth" + "github.com/ledgerwatch/erigon/p2p" + "github.com/ledgerwatch/erigon/p2p/rlpx" + "github.com/ledgerwatch/erigon/params" + "github.com/ledgerwatch/erigon/rlp" + "math/big" + "net" + "strings" + "time" +) + +// https://github.com/ethereum/devp2p/blob/master/rlpx.md#p2p-capability +const ( + RLPxMessageIDHello = 0 + RLPxMessageIDDisconnect = 1 + RLPxMessageIDPing = 2 + RLPxMessageIDPong = 3 +) + +// HelloMessage is the RLPx Hello message. +// (same as protoHandshake in p2p/peer.go) +// https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00 +type HelloMessage struct { + Version uint64 + ClientID string + Caps []p2p.Cap + ListenPort uint64 + Pubkey []byte // secp256k1 public key + + // Ignore additional fields (for forward compatibility). + Rest []rlp.RawValue `rlp:"tail"` +} + +// StatusMessage is the Ethereum Status message v63+. +// (same as StatusPacket in eth/protocols/eth/protocol.go) +// https://github.com/ethereum/devp2p/blob/master/caps/eth.md#status-0x00 +type StatusMessage struct { + ProtocolVersion uint32 + NetworkID uint64 + TD *big.Int + Head common.Hash + Genesis common.Hash + ForkID *forkid.ID `rlp:"-"` // parsed from Rest if exists in v64+ + Rest []rlp.RawValue `rlp:"tail"` +} + +type HandshakeErrorID string + +const ( + HandshakeErrorIDConnect HandshakeErrorID = "connect" + HandshakeErrorIDSetTimeout HandshakeErrorID = "set-timeout" + HandshakeErrorIDAuth HandshakeErrorID = "auth" + HandshakeErrorIDRead HandshakeErrorID = "read" + HandshakeErrorIDUnexpectedMessage HandshakeErrorID = "unexpected-message" + HandshakeErrorIDDisconnectDecode HandshakeErrorID = "disconnect-decode" + HandshakeErrorIDDisconnect HandshakeErrorID = "disconnect" + HandshakeErrorIDHelloEncode HandshakeErrorID = "hello-encode" + HandshakeErrorIDHelloDecode HandshakeErrorID = "hello-decode" + HandshakeErrorIDStatusDecode HandshakeErrorID = "status-decode" +) + +type HandshakeError struct { + id HandshakeErrorID + wrappedErr error + param uint64 +} + +func NewHandshakeError(id HandshakeErrorID, wrappedErr error, param uint64) *HandshakeError { + instance := HandshakeError{ + id, + wrappedErr, + param, + } + return &instance +} + +func (e *HandshakeError) Unwrap() error { + return e.wrappedErr +} + +func (e *HandshakeError) Error() string { + switch e.id { + case HandshakeErrorIDConnect: + return fmt.Sprintf("handshake failed to connect: %v", e.wrappedErr) + case HandshakeErrorIDSetTimeout: + return fmt.Sprintf("handshake failed to set timeout: %v", e.wrappedErr) + case HandshakeErrorIDAuth: + return fmt.Sprintf("handshake RLPx auth failed: %v", e.wrappedErr) + case HandshakeErrorIDRead: + return fmt.Sprintf("handshake RLPx read failed: %v", e.wrappedErr) + case HandshakeErrorIDUnexpectedMessage: + return fmt.Sprintf("handshake got unexpected message ID: %d", e.param) + case HandshakeErrorIDDisconnectDecode: + return fmt.Sprintf("handshake failed to parse disconnect reason: %v", e.wrappedErr) + case HandshakeErrorIDDisconnect: + return fmt.Sprintf("handshake got disconnected: %v", e.wrappedErr) + case HandshakeErrorIDHelloEncode: + return fmt.Sprintf("handshake failed to encode outgoing Hello message: %v", e.wrappedErr) + case HandshakeErrorIDHelloDecode: + return fmt.Sprintf("handshake failed to parse Hello message: %v", e.wrappedErr) + case HandshakeErrorIDStatusDecode: + return fmt.Sprintf("handshake failed to parse Status message: %v", e.wrappedErr) + default: + return "<unhandled HandshakeErrorID>" + } +} + +func (e *HandshakeError) StringCode() string { + switch e.id { + case HandshakeErrorIDUnexpectedMessage: + fallthrough + case HandshakeErrorIDDisconnect: + return fmt.Sprintf("%s-%d", e.id, e.param) + default: + return string(e.id) + } +} + +func Handshake( + ctx context.Context, + ip net.IP, + rlpxPort int, + pubkey *ecdsa.PublicKey, + myPrivateKey *ecdsa.PrivateKey, +) (*HelloMessage, *StatusMessage, *HandshakeError) { + connectTimeout := 10 * time.Second + dialer := net.Dialer{ + Timeout: connectTimeout, + } + addr := net.TCPAddr{IP: ip, Port: rlpxPort} + + tcpConn, err := dialer.DialContext(ctx, "tcp", addr.String()) + if err != nil { + return nil, nil, NewHandshakeError(HandshakeErrorIDConnect, err, 0) + } + + conn := rlpx.NewConn(tcpConn, pubkey) + defer func() { _ = conn.Close() }() + + handshakeTimeout := 5 * time.Second + handshakeDeadline := time.Now().Add(handshakeTimeout) + err = conn.SetDeadline(handshakeDeadline) + if err != nil { + return nil, nil, NewHandshakeError(HandshakeErrorIDSetTimeout, err, 0) + } + err = conn.SetWriteDeadline(handshakeDeadline) + if err != nil { + return nil, nil, NewHandshakeError(HandshakeErrorIDSetTimeout, err, 0) + } + + _, err = conn.Handshake(myPrivateKey) + if err != nil { + return nil, nil, NewHandshakeError(HandshakeErrorIDAuth, err, 0) + } + + ourHelloMessage := makeOurHelloMessage(myPrivateKey) + ourHelloData, err := rlp.EncodeToBytes(&ourHelloMessage) + if err != nil { + return nil, nil, NewHandshakeError(HandshakeErrorIDHelloEncode, err, 0) + } + go func() { _, _ = conn.Write(RLPxMessageIDHello, ourHelloData) }() + + var helloMessage HelloMessage + if err := readMessage(conn, RLPxMessageIDHello, HandshakeErrorIDHelloDecode, &helloMessage); err != nil { + return nil, nil, err + } + + // All messages following Hello are compressed using the Snappy algorithm. + if helloMessage.Version >= 5 { + conn.SetSnappy(true) + } + + var statusMessage StatusMessage + if err := readMessage(conn, 16+eth.StatusMsg, HandshakeErrorIDStatusDecode, &statusMessage); err != nil { + return &helloMessage, nil, err + } + + // parse fork ID + if (statusMessage.ProtocolVersion >= 64) && (len(statusMessage.Rest) > 0) { + var forkID forkid.ID + if err := rlp.DecodeBytes(statusMessage.Rest[0], &forkID); err != nil { + return &helloMessage, nil, NewHandshakeError(HandshakeErrorIDStatusDecode, err, 0) + } + statusMessage.ForkID = &forkID + } + + return &helloMessage, &statusMessage, nil +} + +func readMessage(conn *rlpx.Conn, expectedMessageID uint64, decodeError HandshakeErrorID, message interface{}) *HandshakeError { + messageID, data, _, err := conn.Read() + if err != nil { + return NewHandshakeError(HandshakeErrorIDRead, err, 0) + } + + if messageID == RLPxMessageIDPing { + pongData, _ := rlp.EncodeToBytes(make([]string, 0, 1)) + go func() { _, _ = conn.Write(RLPxMessageIDPong, pongData) }() + return readMessage(conn, expectedMessageID, decodeError, message) + } + if messageID == 16+eth.GetPooledTransactionsMsg { + return readMessage(conn, expectedMessageID, decodeError, message) + } + if messageID == RLPxMessageIDDisconnect { + var reason [1]p2p.DiscReason + err = rlp.DecodeBytes(data, &reason) + if (err != nil) && strings.Contains(err.Error(), "rlp: expected input list") { + err = rlp.DecodeBytes(data, &reason[0]) + } + if err != nil { + return NewHandshakeError(HandshakeErrorIDDisconnectDecode, err, 0) + } + return NewHandshakeError(HandshakeErrorIDDisconnect, reason[0], uint64(reason[0])) + } + if messageID != expectedMessageID { + return NewHandshakeError(HandshakeErrorIDUnexpectedMessage, nil, messageID) + } + + if err = rlp.DecodeBytes(data, message); err != nil { + return NewHandshakeError(decodeError, err, 0) + } + return nil +} + +func makeOurHelloMessage(myPrivateKey *ecdsa.PrivateKey) HelloMessage { + version := params.VersionWithCommit(params.GitCommit, "") + clientID := common.MakeName("observer", version) + + caps := []p2p.Cap{ + {Name: eth.ProtocolName, Version: 63}, + {Name: eth.ProtocolName, Version: 64}, + {Name: eth.ProtocolName, Version: 65}, + {Name: eth.ProtocolName, Version: eth.ETH66}, + } + + return HelloMessage{ + Version: 5, + ClientID: clientID, + Caps: caps, + ListenPort: 0, // not listening + Pubkey: crypto.MarshalPubkey(&myPrivateKey.PublicKey), + } +} diff --git a/cmd/observer/observer/handshake_test.go b/cmd/observer/observer/handshake_test.go new file mode 100644 index 0000000000..2691cd2400 --- /dev/null +++ b/cmd/observer/observer/handshake_test.go @@ -0,0 +1,35 @@ +package observer + +import ( + "context" + "github.com/ledgerwatch/erigon/crypto" + "github.com/ledgerwatch/erigon/eth/protocols/eth" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/erigon/params" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestHandshake(t *testing.T) { + t.Skip("only for dev") + + // grep 'self=enode' the log, and paste it here + // url := "enode://..." + url := params.MainnetBootnodes[0] + node := enode.MustParseV4(url) + myPrivateKey, _ := crypto.GenerateKey() + + ctx := context.Background() + hello, status, err := Handshake(ctx, node.IP(), node.TCP(), node.Pubkey(), myPrivateKey) + + require.Nil(t, err) + require.NotNil(t, hello) + assert.Equal(t, uint64(5), hello.Version) + assert.NotEmpty(t, hello.ClientID) + assert.Contains(t, hello.ClientID, "erigon") + + require.NotNil(t, status) + assert.Equal(t, uint32(eth.ETH66), status.ProtocolVersion) + assert.Equal(t, uint64(1), status.NetworkID) +} diff --git a/cmd/observer/observer/interrogation_error.go b/cmd/observer/observer/interrogation_error.go new file mode 100644 index 0000000000..bc99dd93a9 --- /dev/null +++ b/cmd/observer/observer/interrogation_error.go @@ -0,0 +1,53 @@ +package observer + +import "fmt" + +type InterrogationErrorID int + +const ( + InterrogationErrorPing InterrogationErrorID = iota + 1 + InterrogationErrorENRDecode + InterrogationErrorIncompatibleForkID + InterrogationErrorBlacklistedClientID + InterrogationErrorKeygen + InterrogationErrorFindNode + InterrogationErrorFindNodeTimeout +) + +type InterrogationError struct { + id InterrogationErrorID + wrappedErr error +} + +func NewInterrogationError(id InterrogationErrorID, wrappedErr error) *InterrogationError { + instance := InterrogationError{ + id, + wrappedErr, + } + return &instance +} + +func (e *InterrogationError) Unwrap() error { + return e.wrappedErr +} + +func (e *InterrogationError) Error() string { + switch e.id { + case InterrogationErrorPing: + return fmt.Sprintf("ping-pong failed: %v", e.wrappedErr) + case InterrogationErrorENRDecode: + return e.wrappedErr.Error() + case InterrogationErrorIncompatibleForkID: + return fmt.Sprintf("incompatible ENR fork ID %v", e.wrappedErr) + case InterrogationErrorBlacklistedClientID: + return fmt.Sprintf("incompatible client ID %v", e.wrappedErr) + case InterrogationErrorKeygen: + return fmt.Sprintf("keygen failed: %v", e.wrappedErr) + case InterrogationErrorFindNode: + return fmt.Sprintf("FindNode request failed: %v", e.wrappedErr) + case InterrogationErrorFindNodeTimeout: + return fmt.Sprintf("FindNode request timeout: %v", e.wrappedErr) + default: + return "<unhandled InterrogationErrorID>" + } +} diff --git a/cmd/observer/observer/interrogator.go b/cmd/observer/observer/interrogator.go new file mode 100644 index 0000000000..a66bd81160 --- /dev/null +++ b/cmd/observer/observer/interrogator.go @@ -0,0 +1,230 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/utils" + "github.com/ledgerwatch/erigon/core/forkid" + "github.com/ledgerwatch/erigon/eth/protocols/eth" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/log/v3" + "golang.org/x/sync/semaphore" + "strings" + "time" +) + +type DiscV4Transport interface { + RequestENR(*enode.Node) (*enode.Node, error) + Ping(*enode.Node) error + FindNode(toNode *enode.Node, targetKey *ecdsa.PublicKey) ([]*enode.Node, error) +} + +type Interrogator struct { + node *enode.Node + transport DiscV4Transport + forkFilter forkid.Filter + + diplomat *Diplomat + handshakeRetryTime *time.Time + + keygenTimeout time.Duration + keygenConcurrency uint + keygenSemaphore *semaphore.Weighted + keygenCachedKeys []*ecdsa.PublicKey + + log log.Logger +} + +type InterrogationResult struct { + Node *enode.Node + IsCompatFork *bool + HandshakeResult *DiplomatResult + HandshakeRetryTime *time.Time + KeygenKeys []*ecdsa.PublicKey + Peers []*enode.Node +} + +func NewInterrogator( + node *enode.Node, + transport DiscV4Transport, + forkFilter forkid.Filter, + diplomat *Diplomat, + handshakeRetryTime *time.Time, + keygenTimeout time.Duration, + keygenConcurrency uint, + keygenSemaphore *semaphore.Weighted, + keygenCachedKeys []*ecdsa.PublicKey, + logger log.Logger, +) (*Interrogator, error) { + instance := Interrogator{ + node, + transport, + forkFilter, + diplomat, + handshakeRetryTime, + keygenTimeout, + keygenConcurrency, + keygenSemaphore, + keygenCachedKeys, + logger, + } + return &instance, nil +} + +func (interrogator *Interrogator) Run(ctx context.Context) (*InterrogationResult, *InterrogationError) { + interrogator.log.Debug("Interrogating a node") + + err := interrogator.transport.Ping(interrogator.node) + if err != nil { + return nil, NewInterrogationError(InterrogationErrorPing, err) + } + + // The outgoing Ping above triggers an incoming Ping. + // We need to wait until Server sends a Pong reply to that. + // The remote side is waiting for this Pong no longer than v4_udp.respTimeout. + // If we don't wait, the ENRRequest/FindNode might fail due to errUnknownNode. + utils.Sleep(ctx, 500*time.Millisecond) + + // request client ID + var handshakeResult *DiplomatResult + var handshakeRetryTime *time.Time + if (interrogator.handshakeRetryTime == nil) || interrogator.handshakeRetryTime.Before(time.Now()) { + result := interrogator.diplomat.Run(ctx) + clientID := result.ClientID + if (clientID != nil) && IsClientIDBlacklisted(*clientID) { + return nil, NewInterrogationError(InterrogationErrorBlacklistedClientID, errors.New(*clientID)) + } + handshakeResult = &result + handshakeRetryTime = new(time.Time) + *handshakeRetryTime = interrogator.diplomat.NextRetryTime(result.HandshakeErr) + } + + // request ENR + var forkID *forkid.ID + var enr *enode.Node + if (handshakeResult == nil) || (handshakeResult.ClientID == nil) || isENRRequestSupportedByClientID(*handshakeResult.ClientID) { + enr, err = interrogator.transport.RequestENR(interrogator.node) + } + if err != nil { + interrogator.log.Debug("ENR request failed", "err", err) + } else if enr != nil { + interrogator.log.Debug("Got ENR", "enr", enr) + forkID, err = eth.LoadENRForkID(enr.Record()) + if err != nil { + return nil, NewInterrogationError(InterrogationErrorENRDecode, err) + } + if forkID == nil { + interrogator.log.Debug("Got ENR, but it doesn't contain a ForkID") + } + } + + // filter by fork ID + var isCompatFork *bool + if forkID != nil { + err := interrogator.forkFilter(*forkID) + isCompatFork = new(bool) + *isCompatFork = (err == nil) || !errors.Is(err, forkid.ErrLocalIncompatibleOrStale) + if !*isCompatFork { + return nil, NewInterrogationError(InterrogationErrorIncompatibleForkID, err) + } + } + + // keygen + keys, err := interrogator.keygen(ctx) + if err != nil { + return nil, NewInterrogationError(InterrogationErrorKeygen, err) + } + + // FindNode + peersByID := make(map[enode.ID]*enode.Node) + for _, key := range keys { + neighbors, err := interrogator.findNode(ctx, key) + if err != nil { + if isFindNodeTimeoutError(err) { + return nil, NewInterrogationError(InterrogationErrorFindNodeTimeout, err) + } + return nil, NewInterrogationError(InterrogationErrorFindNode, err) + } + + for _, node := range neighbors { + if node.Incomplete() { + continue + } + peersByID[node.ID()] = node + } + + utils.Sleep(ctx, 1*time.Second) + } + + peers := valuesOfIDToNodeMap(peersByID) + + result := InterrogationResult{ + interrogator.node, + isCompatFork, + handshakeResult, + handshakeRetryTime, + keys, + peers, + } + return &result, nil +} + +func (interrogator *Interrogator) keygen(ctx context.Context) ([]*ecdsa.PublicKey, error) { + if interrogator.keygenCachedKeys != nil { + return interrogator.keygenCachedKeys, nil + } + + if err := interrogator.keygenSemaphore.Acquire(ctx, 1); err != nil { + return nil, err + } + defer interrogator.keygenSemaphore.Release(1) + + keys := keygen( + ctx, + interrogator.node.Pubkey(), + interrogator.keygenTimeout, + interrogator.keygenConcurrency, + interrogator.log) + + interrogator.log.Trace(fmt.Sprintf("Generated %d keys", len(keys))) + if (len(keys) < 13) && (ctx.Err() == nil) { + msg := "Generated just %d keys within a given timeout and concurrency (expected 16-17). " + + "If this happens too often, try to increase keygen-timeout/keygen-concurrency parameters." + interrogator.log.Warn(fmt.Sprintf(msg, len(keys))) + } + return keys, ctx.Err() +} + +func (interrogator *Interrogator) findNode(ctx context.Context, targetKey *ecdsa.PublicKey) ([]*enode.Node, error) { + delayForAttempt := func(attempt int) time.Duration { return 2 * time.Second } + resultAny, err := utils.Retry(ctx, 2, delayForAttempt, isFindNodeTimeoutError, interrogator.log, "FindNode", func(ctx context.Context) (interface{}, error) { + return interrogator.transport.FindNode(interrogator.node, targetKey) + }) + + if resultAny == nil { + return nil, err + } + result := resultAny.([]*enode.Node) + return result, err +} + +func isFindNodeTimeoutError(err error) bool { + return (err != nil) && (err.Error() == "RPC timeout") +} + +func isENRRequestSupportedByClientID(clientID string) bool { + isUnsupported := strings.HasPrefix(clientID, "Parity-Ethereum") || + strings.HasPrefix(clientID, "OpenEthereum") || + strings.HasPrefix(clientID, "Nethermind") + return !isUnsupported +} + +func valuesOfIDToNodeMap(m map[enode.ID]*enode.Node) []*enode.Node { + values := make([]*enode.Node, 0, len(m)) + for _, value := range m { + values = append(values, value) + } + return values +} diff --git a/cmd/observer/observer/keygen.go b/cmd/observer/observer/keygen.go new file mode 100644 index 0000000000..5de9bc3f9c --- /dev/null +++ b/cmd/observer/observer/keygen.go @@ -0,0 +1,76 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "github.com/ledgerwatch/erigon/crypto" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/log/v3" + "time" +) + +func keygen( + parentContext context.Context, + targetKey *ecdsa.PublicKey, + timeout time.Duration, + concurrencyLimit uint, + logger log.Logger, +) []*ecdsa.PublicKey { + ctx, cancel := context.WithTimeout(parentContext, timeout) + defer cancel() + + targetID := enode.PubkeyToIDV4(targetKey) + cpus := concurrencyLimit + + type result struct { + key *ecdsa.PublicKey + distance int + } + + generatedKeys := make(chan result, cpus) + + for i := uint(0); i < cpus; i++ { + go func() { + for ctx.Err() == nil { + keyPair, err := crypto.GenerateKey() + if err != nil { + logger.Error("keygen has failed to generate a key", "err", err) + break + } + + key := &keyPair.PublicKey + id := enode.PubkeyToIDV4(key) + distance := enode.LogDist(targetID, id) + + select { + case generatedKeys <- result{key, distance}: + case <-ctx.Done(): + break + } + } + }() + } + + keysAtDist := make(map[int]*ecdsa.PublicKey) + + for ctx.Err() == nil { + select { + case res := <-generatedKeys: + keysAtDist[res.distance] = res.key + case <-ctx.Done(): + break + } + } + + keys := valuesOfIntToPubkeyMap(keysAtDist) + + return keys +} + +func valuesOfIntToPubkeyMap(m map[int]*ecdsa.PublicKey) []*ecdsa.PublicKey { + values := make([]*ecdsa.PublicKey, 0, len(m)) + for _, value := range m { + values = append(values, value) + } + return values +} diff --git a/cmd/observer/observer/keygen_test.go b/cmd/observer/observer/keygen_test.go new file mode 100644 index 0000000000..b57e56b0d2 --- /dev/null +++ b/cmd/observer/observer/keygen_test.go @@ -0,0 +1,23 @@ +package observer + +import ( + "context" + "github.com/ledgerwatch/erigon/crypto" + "github.com/ledgerwatch/log/v3" + "github.com/stretchr/testify/assert" + "runtime" + "testing" + "time" +) + +func TestKeygen(t *testing.T) { + targetKeyPair, err := crypto.GenerateKey() + assert.NotNil(t, targetKeyPair) + assert.Nil(t, err) + + targetKey := &targetKeyPair.PublicKey + keys := keygen(context.Background(), targetKey, 50*time.Millisecond, uint(runtime.GOMAXPROCS(-1)), log.Root()) + + assert.NotNil(t, keys) + assert.GreaterOrEqual(t, len(keys), 4) +} diff --git a/cmd/observer/observer/node.go b/cmd/observer/observer/node.go new file mode 100644 index 0000000000..184d9842dd --- /dev/null +++ b/cmd/observer/observer/node.go @@ -0,0 +1,145 @@ +package observer + +import ( + "crypto/ecdsa" + "encoding/hex" + "errors" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/crypto" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/erigon/p2p/enr" + "net" + "net/url" +) + +func nodeID(node *enode.Node) (database.NodeID, error) { + if node.Incomplete() { + return "", errors.New("nodeID not implemented for incomplete nodes") + } + nodeURL, err := url.Parse(node.URLv4()) + if err != nil { + return "", fmt.Errorf("failed to parse node URL: %w", err) + } + id := nodeURL.User.Username() + return database.NodeID(id), nil +} + +func makeNodeAddr(node *enode.Node) database.NodeAddr { + var addr database.NodeAddr + + var ipEntry enr.IPv4 + if node.Load(&ipEntry) == nil { + addr.IP = net.IP(ipEntry) + } + + var ipV6Entry enr.IPv6 + if node.Load(&ipV6Entry) == nil { + addr.IPv6.IP = net.IP(ipEntry) + } + + var portDiscEntry enr.UDP + if (addr.IP != nil) && (node.Load(&portDiscEntry) == nil) { + addr.PortDisc = uint16(portDiscEntry) + } + + var ipV6PortDiscEntry enr.UDP6 + if (addr.IPv6.IP != nil) && (node.Load(&ipV6PortDiscEntry) == nil) { + addr.IPv6.PortDisc = uint16(ipV6PortDiscEntry) + } + + var portRLPxEntry enr.TCP + if (addr.IP != nil) && (node.Load(&portRLPxEntry) == nil) { + addr.PortRLPx = uint16(portRLPxEntry) + } + + var ipV6PortRLPxEntry enr.TCP + if (addr.IPv6.IP != nil) && (node.Load(&ipV6PortRLPxEntry) == nil) { + addr.IPv6.PortRLPx = uint16(ipV6PortRLPxEntry) + } + + return addr +} + +func makeNodeFromAddr(id database.NodeID, addr database.NodeAddr) (*enode.Node, error) { + rec := new(enr.Record) + + pubkey, err := parseHexPublicKey(string(id)) + if err != nil { + return nil, err + } + rec.Set((*enode.Secp256k1)(pubkey)) + + if addr.IP != nil { + rec.Set(enr.IP(addr.IP)) + } + if addr.IPv6.IP != nil { + rec.Set(enr.IPv6(addr.IPv6.IP)) + } + if addr.PortDisc != 0 { + rec.Set(enr.UDP(addr.PortDisc)) + } + if addr.PortRLPx != 0 { + rec.Set(enr.TCP(addr.PortRLPx)) + } + if addr.IPv6.PortDisc != 0 { + rec.Set(enr.UDP6(addr.IPv6.PortDisc)) + } + if addr.IPv6.PortRLPx != 0 { + rec.Set(enr.TCP6(addr.IPv6.PortRLPx)) + } + + rec.Set(enr.ID("unsigned")) + node, err := enode.New(enr.SchemeMap{"unsigned": noSignatureIDScheme{}}, rec) + if err != nil { + return nil, fmt.Errorf("failed to make a node: %w", err) + } + return node, nil +} + +type noSignatureIDScheme struct { + enode.V4ID +} + +func (noSignatureIDScheme) Verify(_ *enr.Record, _ []byte) error { + return nil +} + +func parseHexPublicKey(keyStr string) (*ecdsa.PublicKey, error) { + nodeWithPubkey, err := enode.ParseV4("enode://" + keyStr) + if err != nil { + return nil, fmt.Errorf("failed to decode a public key: %w", err) + } + return nodeWithPubkey.Pubkey(), nil +} + +func parseHexPublicKeys(hexKeys []string) ([]*ecdsa.PublicKey, error) { + if hexKeys == nil { + return nil, nil + } + keys := make([]*ecdsa.PublicKey, 0, len(hexKeys)) + for _, keyStr := range hexKeys { + key, err := parseHexPublicKey(keyStr) + if err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, nil +} + +func hexEncodePublicKey(key *ecdsa.PublicKey) string { + return hex.EncodeToString(crypto.MarshalPubkey(key)) +} + +func hexEncodePublicKeys(keys []*ecdsa.PublicKey) []string { + if keys == nil { + return nil + } + hexKeys := make([]string, 0, len(keys)) + for _, key := range keys { + keyStr := hexEncodePublicKey(key) + hexKeys = append(hexKeys, keyStr) + } + return hexKeys +} diff --git a/cmd/observer/observer/server.go b/cmd/observer/observer/server.go new file mode 100644 index 0000000000..5aca0fa1a4 --- /dev/null +++ b/cmd/observer/observer/server.go @@ -0,0 +1,183 @@ +package observer + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + "github.com/ledgerwatch/erigon/cmd/utils" + "github.com/ledgerwatch/erigon/common/debug" + "github.com/ledgerwatch/erigon/core/forkid" + "github.com/ledgerwatch/erigon/eth/protocols/eth" + "github.com/ledgerwatch/erigon/p2p" + "github.com/ledgerwatch/erigon/p2p/discover" + "github.com/ledgerwatch/erigon/p2p/enode" + "github.com/ledgerwatch/erigon/p2p/enr" + "github.com/ledgerwatch/erigon/p2p/nat" + "github.com/ledgerwatch/erigon/p2p/netutil" + "github.com/ledgerwatch/erigon/params" + "github.com/ledgerwatch/log/v3" + "net" + "path/filepath" +) + +type Server struct { + localNode *enode.LocalNode + + listenAddr string + natInterface nat.Interface + discConfig discover.Config + + log log.Logger +} + +func NewServer(flags CommandFlags) (*Server, error) { + nodeDBPath := filepath.Join(flags.DataDir, "nodes", "eth66") + + nodeKeyConfig := p2p.NodeKeyConfig{} + privateKey, err := nodeKeyConfig.LoadOrParseOrGenerateAndSave(flags.NodeKeyFile, flags.NodeKeyHex, flags.DataDir) + if err != nil { + return nil, err + } + + localNode, err := makeLocalNode(nodeDBPath, privateKey, flags.Chain) + if err != nil { + return nil, err + } + + listenAddr := fmt.Sprintf(":%d", flags.ListenPort) + + natInterface, err := nat.Parse(flags.NATDesc) + if err != nil { + return nil, fmt.Errorf("NAT parse error: %w", err) + } + + var netRestrictList *netutil.Netlist + if flags.NetRestrict != "" { + netRestrictList, err = netutil.ParseNetlist(flags.NetRestrict) + if err != nil { + return nil, fmt.Errorf("net restrict parse error: %w", err) + } + } + + bootnodes, err := utils.GetBootnodesFromFlags(flags.Bootnodes, flags.Chain) + if err != nil { + return nil, fmt.Errorf("bootnodes parse error: %w", err) + } + + logger := log.New() + + discConfig := discover.Config{ + PrivateKey: privateKey, + NetRestrict: netRestrictList, + Bootnodes: bootnodes, + Log: logger.New(), + } + + instance := Server{ + localNode, + listenAddr, + natInterface, + discConfig, + logger, + } + return &instance, nil +} + +func makeLocalNode(nodeDBPath string, privateKey *ecdsa.PrivateKey, chain string) (*enode.LocalNode, error) { + db, err := enode.OpenDB(nodeDBPath) + if err != nil { + return nil, err + } + localNode := enode.NewLocalNode(db, privateKey) + localNode.SetFallbackIP(net.IP{127, 0, 0, 1}) + + forksEntry, err := makeForksENREntry(chain) + if err != nil { + return nil, err + } + localNode.Set(forksEntry) + + return localNode, nil +} + +func makeForksENREntry(chain string) (enr.Entry, error) { + chainConfig := params.ChainConfigByChainName(chain) + genesisHash := params.GenesisHashByChainName(chain) + if (chainConfig == nil) || (genesisHash == nil) { + return nil, fmt.Errorf("unknown chain %s", chain) + } + + forks := forkid.GatherForks(chainConfig) + return eth.CurrentENREntryFromForks(forks, *genesisHash, 0), nil +} + +func (server *Server) Bootnodes() []*enode.Node { + return server.discConfig.Bootnodes +} + +func (server *Server) PrivateKey() *ecdsa.PrivateKey { + return server.discConfig.PrivateKey +} + +func (server *Server) mapNATPort(ctx context.Context, realAddr *net.UDPAddr) { + if server.natInterface == nil { + return + } + if realAddr.IP.IsLoopback() { + return + } + if !server.natInterface.SupportsMapping() { + return + } + + go func() { + defer debug.LogPanic() + nat.Map(server.natInterface, ctx.Done(), "udp", realAddr.Port, realAddr.Port, "ethereum discovery") + }() +} + +func (server *Server) detectNATExternalIP() (net.IP, error) { + if server.natInterface == nil { + return nil, errors.New("no NAT flag configured") + } + if _, hasExtIP := server.natInterface.(nat.ExtIP); !hasExtIP { + server.log.Info("Detecting external IP...") + } + ip, err := server.natInterface.ExternalIP() + if err != nil { + return nil, fmt.Errorf("NAT ExternalIP error: %w", err) + } + server.log.Debug("External IP detected", "ip", ip) + return ip, nil +} + +func (server *Server) Listen(ctx context.Context) (*discover.UDPv4, error) { + if server.natInterface != nil { + ip, err := server.detectNATExternalIP() + if err != nil { + return nil, err + } + server.localNode.SetStaticIP(ip) + } + + addr, err := net.ResolveUDPAddr("udp", server.listenAddr) + if err != nil { + return nil, fmt.Errorf("ResolveUDPAddr error: %w", err) + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return nil, fmt.Errorf("ListenUDP error: %w", err) + } + + realAddr := conn.LocalAddr().(*net.UDPAddr) + server.localNode.SetFallbackUDP(realAddr.Port) + + if server.natInterface != nil { + server.mapNATPort(ctx, realAddr) + } + + server.log.Debug("Discovery UDP listener is up", "addr", realAddr) + + return discover.ListenV4(ctx, conn, server.localNode, server.discConfig) +} diff --git a/cmd/observer/observer/status_logger.go b/cmd/observer/observer/status_logger.go new file mode 100644 index 0000000000..2757ecd7ca --- /dev/null +++ b/cmd/observer/observer/status_logger.go @@ -0,0 +1,44 @@ +package observer + +import ( + "context" + "errors" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/cmd/observer/utils" + "github.com/ledgerwatch/log/v3" + "time" +) + +func StatusLoggerLoop(ctx context.Context, db database.DB, networkID uint, period time.Duration, logger log.Logger) { + var maxPingTries uint = 1000000 // unlimited (include dead nodes) + var prevTotalCount uint + var prevDistinctIPCount uint + + for ctx.Err() == nil { + utils.Sleep(ctx, period) + + totalCount, err := db.CountNodes(ctx, maxPingTries, networkID) + if err != nil { + if !errors.Is(err, context.Canceled) { + logger.Error("Failed to count nodes", "err", err) + } + continue + } + + distinctIPCount, err := db.CountIPs(ctx, maxPingTries, networkID) + if err != nil { + if !errors.Is(err, context.Canceled) { + logger.Error("Failed to count IPs", "err", err) + } + continue + } + + if (totalCount == prevTotalCount) && (distinctIPCount == prevDistinctIPCount) { + continue + } + + logger.Info("Status", "totalCount", totalCount, "distinctIPCount", distinctIPCount) + prevTotalCount = totalCount + prevDistinctIPCount = distinctIPCount + } +} diff --git a/cmd/observer/reports/clients_estimate_report.go b/cmd/observer/reports/clients_estimate_report.go new file mode 100644 index 0000000000..4ec7d149bf --- /dev/null +++ b/cmd/observer/reports/clients_estimate_report.go @@ -0,0 +1,95 @@ +package reports + +import ( + "context" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "math" + "strings" +) + +type ClientsEstimateReportEntry struct { + Name string + CountLow uint + CountHigh uint +} + +type ClientsEstimateReport struct { + Clients []ClientsEstimateReportEntry +} + +func CreateClientsEstimateReport( + ctx context.Context, + db database.DB, + limit uint, + maxPingTries uint, + networkID uint, +) (*ClientsEstimateReport, error) { + clientsReport, err := CreateClientsReport(ctx, db, limit, maxPingTries, networkID) + if err != nil { + return nil, err + } + + report := ClientsEstimateReport{} + + for i, topClient := range clientsReport.Clients { + if uint(i) >= limit { + break + } + clientName := topClient.Name + + sameNetworkCount, err := db.CountClients(ctx, clientName+"/", maxPingTries, networkID) + if err != nil { + return nil, err + } + if sameNetworkCount == 0 { + continue + } + + knownNetworkCount, err := db.CountClientsWithNetworkID(ctx, clientName+"/", maxPingTries) + if err != nil { + return nil, err + } + if knownNetworkCount == 0 { + continue + } + + // 1 - (1 - p)/2 percentile for 95% confidence + const z = 1.96 + intervalLow, intervalHigh := waldInterval(knownNetworkCount, sameNetworkCount, z) + + transientErrCount, err := db.CountClientsWithHandshakeTransientError(ctx, clientName+"/", maxPingTries) + if err != nil { + return nil, err + } + + countLow := sameNetworkCount + uint(math.Round(float64(transientErrCount)*intervalLow)) + countHigh := sameNetworkCount + uint(math.Round(float64(transientErrCount)*intervalHigh)) + + client := ClientsEstimateReportEntry{ + clientName, + countLow, + countHigh, + } + report.Clients = append(report.Clients, client) + } + + return &report, nil +} + +// https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Normal_approximation_interval_or_Wald_interval +func waldInterval(n uint, ns uint, z float64) (float64, float64) { + nf := n - ns + p := float64(ns) / float64(n) + interval := z * math.Sqrt(float64(ns*nf)) / (float64(n) * math.Sqrt(float64(n))) + return p - interval, p + interval +} + +func (report *ClientsEstimateReport) String() string { + var builder strings.Builder + for _, client := range report.Clients { + builder.WriteString(fmt.Sprintf("%6d - %-6d %s", client.CountLow, client.CountHigh, client.Name)) + builder.WriteRune('\n') + } + return builder.String() +} diff --git a/cmd/observer/reports/clients_report.go b/cmd/observer/reports/clients_report.go new file mode 100644 index 0000000000..e41b82e49d --- /dev/null +++ b/cmd/observer/reports/clients_report.go @@ -0,0 +1,97 @@ +package reports + +import ( + "context" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "github.com/ledgerwatch/erigon/cmd/observer/observer" + "strings" +) + +type ClientsReportEntry struct { + Name string + Count uint +} + +type ClientsReport struct { + Clients []ClientsReportEntry +} + +func CreateClientsReport(ctx context.Context, db database.DB, limit uint, maxPingTries uint, networkID uint) (*ClientsReport, error) { + groups := make(map[string]uint) + unknownCount := uint(0) + enumFunc := func(clientID *string) { + if clientID != nil { + if observer.IsClientIDBlacklisted(*clientID) { + return + } + clientName := observer.NameFromClientID(*clientID) + groups[clientName]++ + } else { + unknownCount++ + } + } + if err := db.EnumerateClientIDs(ctx, maxPingTries, networkID, enumFunc); err != nil { + return nil, err + } + + totalCount := sumMapValues(groups) + + report := ClientsReport{} + + for i := uint(0); i < limit; i++ { + clientName, count := takeMapMaxValue(groups) + if count == 0 { + break + } + + client := ClientsReportEntry{ + clientName, + count, + } + report.Clients = append(report.Clients, client) + } + + othersCount := sumMapValues(groups) + + report.Clients = append(report.Clients, + ClientsReportEntry{"...", othersCount}, + ClientsReportEntry{"total", totalCount}, + ClientsReportEntry{"unknown", unknownCount}) + + return &report, nil +} + +func (report *ClientsReport) String() string { + var builder strings.Builder + builder.WriteString("clients:") + builder.WriteRune('\n') + for _, client := range report.Clients { + builder.WriteString(fmt.Sprintf("%6d %s", client.Count, client.Name)) + builder.WriteRune('\n') + } + return builder.String() +} + +func takeMapMaxValue(m map[string]uint) (string, uint) { + maxKey := "" + maxValue := uint(0) + + for k, v := range m { + if v > maxValue { + maxKey = k + maxValue = v + } + } + + delete(m, maxKey) + return maxKey, maxValue +} + +func sumMapValues(m map[string]uint) uint { + sum := uint(0) + for _, v := range m { + sum += v + } + return sum +} diff --git a/cmd/observer/reports/command.go b/cmd/observer/reports/command.go new file mode 100644 index 0000000000..b3c4f13a71 --- /dev/null +++ b/cmd/observer/reports/command.go @@ -0,0 +1,92 @@ +package reports + +import ( + "context" + "github.com/ledgerwatch/erigon/cmd/utils" + "github.com/spf13/cobra" + "github.com/urfave/cli" +) + +type CommandFlags struct { + DataDir string + Chain string + ClientsLimit uint + MaxPingTries uint + Estimate bool +} + +type Command struct { + command cobra.Command + flags CommandFlags +} + +func NewCommand() *Command { + command := cobra.Command{ + Use: "report", + Short: "P2P network crawler database report", + } + + instance := Command{ + command: command, + } + instance.withDatadir() + instance.withChain() + instance.withClientsLimit() + instance.withMaxPingTries() + instance.withEstimate() + + return &instance +} + +func (command *Command) withDatadir() { + flag := utils.DataDirFlag + command.command.Flags().StringVar(&command.flags.DataDir, flag.Name, flag.Value.String(), flag.Usage) + must(command.command.MarkFlagDirname(utils.DataDirFlag.Name)) +} + +func (command *Command) withChain() { + flag := utils.ChainFlag + command.command.Flags().StringVar(&command.flags.Chain, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withClientsLimit() { + flag := cli.UintFlag{ + Name: "clients-limit", + Usage: "A number of top clients to show", + Value: uint(10), + } + command.command.Flags().UintVar(&command.flags.ClientsLimit, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withMaxPingTries() { + flag := cli.UintFlag{ + Name: "max-ping-tries", + Usage: "A number of PING failures for a node to be considered dead", + Value: 3, + } + command.command.Flags().UintVar(&command.flags.MaxPingTries, flag.Name, flag.Value, flag.Usage) +} + +func (command *Command) withEstimate() { + flag := cli.BoolFlag{ + Name: "estimate", + Usage: "Estimate totals including nodes that replied with 'too many peers'", + } + command.command.Flags().BoolVar(&command.flags.Estimate, flag.Name, false, flag.Usage) +} + +func (command *Command) RawCommand() *cobra.Command { + return &command.command +} + +func (command *Command) OnRun(runFunc func(ctx context.Context, flags CommandFlags) error) { + command.command.RunE = func(cmd *cobra.Command, args []string) error { + return runFunc(cmd.Context(), command.flags) + } +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/cmd/observer/reports/status_report.go b/cmd/observer/reports/status_report.go new file mode 100644 index 0000000000..c094e9b203 --- /dev/null +++ b/cmd/observer/reports/status_report.go @@ -0,0 +1,40 @@ +package reports + +import ( + "context" + "fmt" + "github.com/ledgerwatch/erigon/cmd/observer/database" + "strings" +) + +type StatusReport struct { + TotalCount uint + DistinctIPCount uint +} + +func CreateStatusReport(ctx context.Context, db database.DB, maxPingTries uint, networkID uint) (*StatusReport, error) { + totalCount, err := db.CountNodes(ctx, maxPingTries, networkID) + if err != nil { + return nil, err + } + + distinctIPCount, err := db.CountIPs(ctx, maxPingTries, networkID) + if err != nil { + return nil, err + } + + report := StatusReport{ + totalCount, + distinctIPCount, + } + return &report, nil +} + +func (report *StatusReport) String() string { + var builder strings.Builder + builder.WriteString(fmt.Sprintf("total: %d", report.TotalCount)) + builder.WriteRune('\n') + builder.WriteString(fmt.Sprintf("distinct IPs: %d", report.DistinctIPCount)) + builder.WriteRune('\n') + return builder.String() +} diff --git a/cmd/observer/utils/retry.go b/cmd/observer/utils/retry.go new file mode 100644 index 0000000000..ab745415a3 --- /dev/null +++ b/cmd/observer/utils/retry.go @@ -0,0 +1,32 @@ +package utils + +import ( + "context" + "github.com/ledgerwatch/log/v3" + "time" +) + +func Retry( + ctx context.Context, + retryCount int, + delayForAttempt func(attempt int) time.Duration, + isRecoverableError func(error) bool, + logger log.Logger, + opName string, + op func(context.Context) (interface{}, error), +) (interface{}, error) { + var result interface{} + var err error + + for i := 0; i <= retryCount; i++ { + if i > 0 { + logger.Trace("retrying", "op", opName, "attempt", i, "err", err) + Sleep(ctx, delayForAttempt(i)) + } + result, err = op(ctx) + if (err == nil) || !isRecoverableError(err) { + break + } + } + return result, err +} diff --git a/cmd/observer/utils/sleep.go b/cmd/observer/utils/sleep.go new file mode 100644 index 0000000000..336f531402 --- /dev/null +++ b/cmd/observer/utils/sleep.go @@ -0,0 +1,15 @@ +package utils + +import ( + "context" + "time" +) + +func Sleep(parentContext context.Context, timeout time.Duration) { + if timeout <= 0 { + return + } + ctx, cancel := context.WithTimeout(parentContext, timeout) + defer cancel() + <-ctx.Done() +} diff --git a/cmd/observer/utils/task_queue.go b/cmd/observer/utils/task_queue.go new file mode 100644 index 0000000000..b01af3c60b --- /dev/null +++ b/cmd/observer/utils/task_queue.go @@ -0,0 +1,51 @@ +package utils + +import ( + "context" + "errors" +) + +type TaskQueue struct { + name string + queue chan func(context.Context) error + + logFuncProvider func(err error) func(msg string, ctx ...interface{}) +} + +func NewTaskQueue( + name string, + capacity uint, + logFuncProvider func(err error) func(msg string, ctx ...interface{}), +) *TaskQueue { + queue := make(chan func(context.Context) error, capacity) + + instance := TaskQueue{ + name, + queue, + logFuncProvider, + } + return &instance +} + +func (queue *TaskQueue) Run(ctx context.Context) { + for ctx.Err() == nil { + select { + case <-ctx.Done(): + break + case op := <-queue.queue: + err := op(ctx) + if (err != nil) && !errors.Is(err, context.Canceled) { + logFunc := queue.logFuncProvider(err) + logFunc("Task failed", "queue", queue.name, "err", err) + } + } + } +} + +func (queue *TaskQueue) EnqueueTask(ctx context.Context, op func(context.Context) error) { + select { + case <-ctx.Done(): + break + case queue.queue <- op: + } +} diff --git a/eth/protocols/eth/discovery.go b/eth/protocols/eth/discovery.go index a6cf92b88d..4bd009da7e 100644 --- a/eth/protocols/eth/discovery.go +++ b/eth/protocols/eth/discovery.go @@ -17,8 +17,10 @@ package eth import ( + "fmt" "github.com/ledgerwatch/erigon/common" "github.com/ledgerwatch/erigon/core/forkid" + "github.com/ledgerwatch/erigon/p2p/enr" "github.com/ledgerwatch/erigon/rlp" ) @@ -41,3 +43,14 @@ func CurrentENREntryFromForks(forks []uint64, genesisHash common.Hash, headHeigh ForkID: forkid.NewIDFromForks(forks, genesisHash, headHeight), } } + +func LoadENRForkID(r *enr.Record) (*forkid.ID, error) { + var entry enrEntry + if err := r.Load(&entry); err != nil { + if enr.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to load fork ID from ENR: %w", err) + } + return &entry.ForkID, nil +} diff --git a/go.mod b/go.mod index 974371eb99..4fcddaea31 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( google.golang.org/protobuf v1.28.0 gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b gopkg.in/olebedev/go-duktape.v3 v3.0.0-20200619000410-60c24ae608a6 + modernc.org/sqlite v1.14.2-0.20211125151325-d4ed92c0a70f pgregory.net/rapid v0.4.7 ) @@ -107,6 +108,7 @@ require ( github.com/huandu/xstrings v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmhodges/levigo v1.0.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/text v0.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.6 // indirect @@ -142,6 +144,7 @@ require ( github.com/prometheus/common v0.30.0 // indirect github.com/prometheus/procfs v0.7.2 // indirect github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/rs/dnscache v0.0.0-20210201191234-295bba877686 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect @@ -161,4 +164,13 @@ require ( golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect + lukechampine.com/uint128 v1.1.1 // indirect + modernc.org/cc/v3 v3.35.18 // indirect + modernc.org/ccgo/v3 v3.12.73 // indirect + modernc.org/libc v1.11.82 // indirect + modernc.org/mathutil v1.4.1 // indirect + modernc.org/memory v1.0.5 // indirect + modernc.org/opt v0.1.1 // indirect + modernc.org/strutil v1.1.1 // indirect + modernc.org/token v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index b84717f45b..9a2c5b6a27 100644 --- a/go.sum +++ b/go.sum @@ -324,6 +324,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -430,6 +431,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/go-bindata v3.21.0+incompatible h1:baK7hwFJDlAHrOqmE9U3u8tow1Uc5ihN9E/b7djcK2g= github.com/kevinburke/go-bindata v3.21.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -469,9 +472,12 @@ github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHR github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= +github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -629,6 +635,8 @@ github.com/quasilyte/go-ruleguard/dsl v0.3.19 h1:5+KTKb2YREUYiqZFEIuifFyBxlcCUPW github.com/quasilyte/go-ruleguard/dsl v0.3.19/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -899,6 +907,7 @@ golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -917,6 +926,7 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -926,7 +936,9 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211030160813-b3129d9d1021/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -997,6 +1009,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= @@ -1136,6 +1149,113 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.4/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.5/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.7/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.8/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.10/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.15/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.16/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.17/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/cc/v3 v3.35.18 h1:rMZhRcWrba0y3nVmdiQ7kxAgOOSq2m2f2VzjHLgEs6U= +modernc.org/cc/v3 v3.35.18/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g= +modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60= +modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw= +modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI= +modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag= +modernc.org/ccgo/v3 v3.11.3/go.mod h1:0oHunRBMBiXOKdaglfMlRPBALQqsfrCKXgw9okQ3GEw= +modernc.org/ccgo/v3 v3.12.4/go.mod h1:Bk+m6m2tsooJchP/Yk5ji56cClmN6R1cqc9o/YtbgBQ= +modernc.org/ccgo/v3 v3.12.6/go.mod h1:0Ji3ruvpFPpz+yu+1m0wk68pdr/LENABhTrDkMDWH6c= +modernc.org/ccgo/v3 v3.12.8/go.mod h1:Hq9keM4ZfjCDuDXxaHptpv9N24JhgBZmUG5q60iLgUo= +modernc.org/ccgo/v3 v3.12.11/go.mod h1:0jVcmyDwDKDGWbcrzQ+xwJjbhZruHtouiBEvDfoIsdg= +modernc.org/ccgo/v3 v3.12.14/go.mod h1:GhTu1k0YCpJSuWwtRAEHAol5W7g1/RRfS4/9hc9vF5I= +modernc.org/ccgo/v3 v3.12.18/go.mod h1:jvg/xVdWWmZACSgOiAhpWpwHWylbJaSzayCqNOJKIhs= +modernc.org/ccgo/v3 v3.12.20/go.mod h1:aKEdssiu7gVgSy/jjMastnv/q6wWGRbszbheXgWRHc8= +modernc.org/ccgo/v3 v3.12.21/go.mod h1:ydgg2tEprnyMn159ZO/N4pLBqpL7NOkJ88GT5zNU2dE= +modernc.org/ccgo/v3 v3.12.22/go.mod h1:nyDVFMmMWhMsgQw+5JH6B6o4MnZ+UQNw1pp52XYFPRk= +modernc.org/ccgo/v3 v3.12.25/go.mod h1:UaLyWI26TwyIT4+ZFNjkyTbsPsY3plAEB6E7L/vZV3w= +modernc.org/ccgo/v3 v3.12.29/go.mod h1:FXVjG7YLf9FetsS2OOYcwNhcdOLGt8S9bQ48+OP75cE= +modernc.org/ccgo/v3 v3.12.36/go.mod h1:uP3/Fiezp/Ga8onfvMLpREq+KUjUmYMxXPO8tETHtA8= +modernc.org/ccgo/v3 v3.12.38/go.mod h1:93O0G7baRST1vNj4wnZ49b1kLxt0xCW5Hsa2qRaZPqc= +modernc.org/ccgo/v3 v3.12.43/go.mod h1:k+DqGXd3o7W+inNujK15S5ZYuPoWYLpF5PYougCmthU= +modernc.org/ccgo/v3 v3.12.46/go.mod h1:UZe6EvMSqOxaJ4sznY7b23/k13R8XNlyWsO5bAmSgOE= +modernc.org/ccgo/v3 v3.12.47/go.mod h1:m8d6p0zNps187fhBwzY/ii6gxfjob1VxWb919Nk1HUk= +modernc.org/ccgo/v3 v3.12.50/go.mod h1:bu9YIwtg+HXQxBhsRDE+cJjQRuINuT9PUK4orOco/JI= +modernc.org/ccgo/v3 v3.12.51/go.mod h1:gaIIlx4YpmGO2bLye04/yeblmvWEmE4BBBls4aJXFiE= +modernc.org/ccgo/v3 v3.12.53/go.mod h1:8xWGGTFkdFEWBEsUmi+DBjwu/WLy3SSOrqEmKUjMeEg= +modernc.org/ccgo/v3 v3.12.54/go.mod h1:yANKFTm9llTFVX1FqNKHE0aMcQb1fuPJx6p8AcUx+74= +modernc.org/ccgo/v3 v3.12.55/go.mod h1:rsXiIyJi9psOwiBkplOaHye5L4MOOaCjHg1Fxkj7IeU= +modernc.org/ccgo/v3 v3.12.56/go.mod h1:ljeFks3faDseCkr60JMpeDb2GSO3TKAmrzm7q9YOcMU= +modernc.org/ccgo/v3 v3.12.57/go.mod h1:hNSF4DNVgBl8wYHpMvPqQWDQx8luqxDnNGCMM4NFNMc= +modernc.org/ccgo/v3 v3.12.60/go.mod h1:k/Nn0zdO1xHVWjPYVshDeWKqbRWIfif5dtsIOCUVMqM= +modernc.org/ccgo/v3 v3.12.65/go.mod h1:D6hQtKxPNZiY6wDBtehSGKFKmyXn53F8nGTpH+POmS4= +modernc.org/ccgo/v3 v3.12.66/go.mod h1:jUuxlCFZTUZLMV08s7B1ekHX5+LIAurKTTaugUr/EhQ= +modernc.org/ccgo/v3 v3.12.67/go.mod h1:Bll3KwKvGROizP2Xj17GEGOTrlvB1XcVaBrC90ORO84= +modernc.org/ccgo/v3 v3.12.73 h1:AMk4wEpzWjpODXohKvvnlwLob4Xk8tq3we6CwYh88mA= +modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3cQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= +modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q= +modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg= +modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M= +modernc.org/libc v1.11.5/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU= +modernc.org/libc v1.11.6/go.mod h1:ddqmzR6p5i4jIGK1d/EiSw97LBcE3dK24QEwCFvgNgE= +modernc.org/libc v1.11.11/go.mod h1:lXEp9QOOk4qAYOtL3BmMve99S5Owz7Qyowzvg6LiZso= +modernc.org/libc v1.11.13/go.mod h1:ZYawJWlXIzXy2Pzghaf7YfM8OKacP3eZQI81PDLFdY8= +modernc.org/libc v1.11.16/go.mod h1:+DJquzYi+DMRUtWI1YNxrlQO6TcA5+dRRiq8HWBWRC8= +modernc.org/libc v1.11.19/go.mod h1:e0dgEame6mkydy19KKaVPBeEnyJB4LGNb0bBH1EtQ3I= +modernc.org/libc v1.11.24/go.mod h1:FOSzE0UwookyT1TtCJrRkvsOrX2k38HoInhw+cSCUGk= +modernc.org/libc v1.11.26/go.mod h1:SFjnYi9OSd2W7f4ct622o/PAYqk7KHv6GS8NZULIjKY= +modernc.org/libc v1.11.27/go.mod h1:zmWm6kcFXt/jpzeCgfvUNswM0qke8qVwxqZrnddlDiE= +modernc.org/libc v1.11.28/go.mod h1:Ii4V0fTFcbq3qrv3CNn+OGHAvzqMBvC7dBNyC4vHZlg= +modernc.org/libc v1.11.31/go.mod h1:FpBncUkEAtopRNJj8aRo29qUiyx5AvAlAxzlx9GNaVM= +modernc.org/libc v1.11.34/go.mod h1:+Tzc4hnb1iaX/SKAutJmfzES6awxfU1BPvrrJO0pYLg= +modernc.org/libc v1.11.37/go.mod h1:dCQebOwoO1046yTrfUE5nX1f3YpGZQKNcITUYWlrAWo= +modernc.org/libc v1.11.39/go.mod h1:mV8lJMo2S5A31uD0k1cMu7vrJbSA3J3waQJxpV4iqx8= +modernc.org/libc v1.11.42/go.mod h1:yzrLDU+sSjLE+D4bIhS7q1L5UwXDOw99PLSX0BlZvSQ= +modernc.org/libc v1.11.44/go.mod h1:KFq33jsma7F5WXiYelU8quMJasCCTnHK0mkri4yPHgA= +modernc.org/libc v1.11.45/go.mod h1:Y192orvfVQQYFzCNsn+Xt0Hxt4DiO4USpLNXBlXg/tM= +modernc.org/libc v1.11.47/go.mod h1:tPkE4PzCTW27E6AIKIR5IwHAQKCAtudEIeAV1/SiyBg= +modernc.org/libc v1.11.49/go.mod h1:9JrJuK5WTtoTWIFQ7QjX2Mb/bagYdZdscI3xrvHbXjE= +modernc.org/libc v1.11.51/go.mod h1:R9I8u9TS+meaWLdbfQhq2kFknTW0O3aw3kEMqDDxMaM= +modernc.org/libc v1.11.53/go.mod h1:5ip5vWYPAoMulkQ5XlSJTy12Sz5U6blOQiYasilVPsU= +modernc.org/libc v1.11.54/go.mod h1:S/FVnskbzVUrjfBqlGFIPA5m7UwB3n9fojHhCNfSsnw= +modernc.org/libc v1.11.55/go.mod h1:j2A5YBRm6HjNkoSs/fzZrSxCuwWqcMYTDPLNx0URn3M= +modernc.org/libc v1.11.56/go.mod h1:pakHkg5JdMLt2OgRadpPOTnyRXm/uzu+Yyg/LSLdi18= +modernc.org/libc v1.11.58/go.mod h1:ns94Rxv0OWyoQrDqMFfWwka2BcaF6/61CqJRK9LP7S8= +modernc.org/libc v1.11.70/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw= +modernc.org/libc v1.11.71/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw= +modernc.org/libc v1.11.75/go.mod h1:dGRVugT6edz361wmD9gk6ax1AbDSe0x5vji0dGJiPT0= +modernc.org/libc v1.11.82 h1:CSl/6n4odvPYWKKqBtFb8e0ZWVTjxDqwxTjaoee9V7E= +modernc.org/libc v1.11.82/go.mod h1:NF+Ek1BOl2jeC7lw3a7Jj5PWyHPwWD4aq3wVKxqV1fI= +modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc= +modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14= +modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM= +modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.14.2-0.20211125151325-d4ed92c0a70f h1:yQwkmqKCIgLzFIfjfPfZAAxLZernckpo7zGTv37Ahv0= +modernc.org/sqlite v1.14.2-0.20211125151325-d4ed92c0a70f/go.mod h1:YT5XFRKOueohjppHO4cHb54eQlnaUGsZMHoryaCpNo4= +modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/tcl v1.8.13 h1:V0sTNBw0Re86PvXZxuCub3oO9WrSTqALgrwNZNvLFGw= +modernc.org/tcl v1.8.13/go.mod h1:V+q/Ef0IJaNUSECieLU4o+8IScapxnMyFV6i/7uQlAY= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.2.19 h1:BGyRFWhDVn5LFS5OcX4Yd/MlpRTOc7hOPTdcIpCiUao= +modernc.org/z v1.2.19/go.mod h1:+ZpP0pc4zz97eukOzW3xagV/lS82IpPN9NGG5pNF9vY= pgregory.net/rapid v0.4.7 h1:MTNRktPuv5FNqOO151TM9mDTa+XHcX6ypYeISDVD14g= pgregory.net/rapid v0.4.7/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/p2p/discover/v4_udp.go b/p2p/discover/v4_udp.go index cdeec407d3..3e2614a098 100644 --- a/p2p/discover/v4_udp.go +++ b/p2p/discover/v4_udp.go @@ -301,8 +301,14 @@ func (t *UDPv4) newLookup(ctx context.Context, targetKey *ecdsa.PublicKey) *look return it } -// findnode sends a findnode request to the given node and waits until -// the node has sent up to k neighbors. +// FindNode sends a "FindNode" request to the given node and waits until +// the node has sent up to bucketSize neighbors or a respTimeout has passed. +func (t *UDPv4) FindNode(toNode *enode.Node, targetKey *ecdsa.PublicKey) ([]*enode.Node, error) { + targetKeyEnc := v4wire.EncodePubkey(targetKey) + nodes, err := t.findnode(toNode.ID(), wrapNode(toNode).addr(), targetKeyEnc) + return unwrapNodes(nodes), err +} + func (t *UDPv4) findnode(toid enode.ID, toaddr *net.UDPAddr, target v4wire.Pubkey) ([]*node, error) { t.ensureBond(toid, toaddr) -- GitLab