mirror of
https://github.com/ditatompel/xmr-remote-nodes.git
synced 2024-12-23 12:09:47 +00:00
4c7d53547b
This make me easier to test the module with external services.
360 lines
8.5 KiB
Go
360 lines
8.5 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/ditatompel/xmr-remote-nodes/internal/config"
|
|
"github.com/ditatompel/xmr-remote-nodes/internal/monero"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
const RPCUserAgent = "ditatombot/0.0.1 (Monero RPC Monitoring; https://github.com/ditatompel/xmr-remote-nodes)"
|
|
|
|
const (
|
|
errNoEndpoint = errProber("no SERVER_ENDPOINT was provided")
|
|
errNoTorSocks = errProber("no TOR_SOCKS was provided")
|
|
errNoAPIKey = errProber("no API_KEY was provided")
|
|
errInvalidCredentials = errProber("invalid API_KEY credentials")
|
|
)
|
|
|
|
type errProber string
|
|
|
|
func (err errProber) Error() string {
|
|
return string(err)
|
|
}
|
|
|
|
type proberClient struct {
|
|
endpoint string // server endpoint
|
|
apiKey string // prober api key
|
|
acceptTor bool // accept tor
|
|
torSOCKS string // IP:Port of tor socks
|
|
message string // message to include when reporting back to server
|
|
}
|
|
|
|
func newProber() *proberClient {
|
|
cfg := config.AppCfg()
|
|
return &proberClient{
|
|
endpoint: cfg.ServerEndpoint,
|
|
apiKey: cfg.APIKey,
|
|
acceptTor: cfg.AcceptTor,
|
|
torSOCKS: cfg.TorSOCKS,
|
|
}
|
|
}
|
|
|
|
var ProbeCmd = &cobra.Command{
|
|
Use: "probe",
|
|
Short: "Probe remote nodes",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
prober := newProber()
|
|
if e, _ := cmd.Flags().GetString("endpoint"); e != "" {
|
|
prober.SetEndpoint(e)
|
|
}
|
|
if t, _ := cmd.Flags().GetBool("no-tor"); t {
|
|
prober.SetAcceptTor(false)
|
|
}
|
|
|
|
if err := prober.Run(); err != nil {
|
|
switch err.(type) {
|
|
case errProber:
|
|
slog.Error(fmt.Sprintf("[PROBE] %s", err.Error()))
|
|
os.Exit(1)
|
|
default:
|
|
slog.Warn(fmt.Sprintf("[PROBE] %s", err.Error()))
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
func (p *proberClient) SetEndpoint(endpoint string) {
|
|
p.endpoint = endpoint
|
|
}
|
|
|
|
func (p *proberClient) SetAcceptTor(acceptTor bool) {
|
|
p.acceptTor = acceptTor
|
|
}
|
|
|
|
// Fetch a new job from the server, fetches node info, and sends it to the server
|
|
func (p *proberClient) Run() error {
|
|
if err := p.validateConfig(); err != nil {
|
|
return err
|
|
}
|
|
|
|
node, err := p.fetchJob()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fetchNode, err := p.fetchNode(node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.Debug(fmt.Sprintf("[PROBE] fetchNode: %s", prettyPrint(fetchNode)))
|
|
return nil
|
|
}
|
|
|
|
// checks if all required environment variables are set
|
|
func (p *proberClient) validateConfig() error {
|
|
if p.endpoint == "" {
|
|
return errNoEndpoint
|
|
}
|
|
if p.apiKey == "" {
|
|
return errNoAPIKey
|
|
}
|
|
if p.acceptTor && p.torSOCKS == "" {
|
|
return errNoTorSocks
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get monero node info to fetch from the server
|
|
func (p *proberClient) fetchJob() (monero.Node, error) {
|
|
queryParams := ""
|
|
if p.acceptTor {
|
|
queryParams = "?accept_tor=1"
|
|
}
|
|
|
|
var node monero.Node
|
|
|
|
uri := fmt.Sprintf("%s/api/v1/job%s", p.endpoint, queryParams)
|
|
slog.Info(fmt.Sprintf("[PROBE] Getting node from %s", uri))
|
|
|
|
req, err := http.NewRequest(http.MethodGet, uri, nil)
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
req.Header.Add(monero.ProberAPIKey, p.apiKey)
|
|
req.Header.Set("User-Agent", RPCUserAgent)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case 200:
|
|
break
|
|
case 401:
|
|
return node, errInvalidCredentials
|
|
default:
|
|
return node, fmt.Errorf("status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
response := struct {
|
|
Data monero.Node `json:"data"`
|
|
}{}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
|
|
node = response.Data
|
|
slog.Info(fmt.Sprintf("[PROBE] Got node: %s://%s:%d", node.Protocol, node.Hostname, node.Port))
|
|
|
|
return node, nil
|
|
}
|
|
|
|
func (p *proberClient) fetchNode(node monero.Node) (monero.Node, error) {
|
|
startTime := time.Now()
|
|
endpoint := fmt.Sprintf("%s://%s:%d/json_rpc", node.Protocol, node.Hostname, node.Port)
|
|
rpcParam := []byte(`{"jsonrpc": "2.0","id": "0","method": "get_info"}`)
|
|
slog.Info(fmt.Sprintf("[PROBE] Fetching node info from %s", endpoint))
|
|
slog.Debug(fmt.Sprintf("[PROBE] RPC param: %s", string(rpcParam)))
|
|
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(rpcParam))
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
|
req.Header.Set("User-Agent", RPCUserAgent)
|
|
req.Header.Set("Origin", "https://xmr.ditatompel.com")
|
|
|
|
var client http.Client
|
|
if p.acceptTor && node.IsTor {
|
|
dialer, err := proxy.SOCKS5("tcp", p.torSOCKS, nil, proxy.Direct)
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return dialer.Dial(network, addr)
|
|
}
|
|
transport := &http.Transport{
|
|
DialContext: dialContext,
|
|
DisableKeepAlives: true,
|
|
}
|
|
client.Transport = transport
|
|
client.Timeout = 60 * time.Second
|
|
}
|
|
|
|
// reset the default node struct
|
|
node.IsAvailable = false
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
p.message = err.Error()
|
|
if err := p.reportResult(node, time.Since(startTime).Seconds()); err != nil {
|
|
return node, err
|
|
}
|
|
return node, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
p.message = fmt.Sprintf("status code: %d", resp.StatusCode)
|
|
if err := p.reportResult(node, time.Since(startTime).Seconds()); err != nil {
|
|
return node, err
|
|
}
|
|
return node, errors.New(p.message)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
p.message = err.Error()
|
|
if err := p.reportResult(node, time.Since(startTime).Seconds()); err != nil {
|
|
return node, err
|
|
}
|
|
return node, err
|
|
}
|
|
|
|
reportNode := struct {
|
|
monero.Node `json:"result"`
|
|
}{}
|
|
|
|
if err := json.Unmarshal(body, &reportNode); err != nil {
|
|
p.message = err.Error()
|
|
if err := p.reportResult(node, time.Since(startTime).Seconds()); err != nil {
|
|
return node, err
|
|
}
|
|
}
|
|
if reportNode.Status == "OK" {
|
|
node.IsAvailable = true
|
|
}
|
|
node.Nettype = reportNode.Nettype
|
|
node.AdjustedTime = reportNode.AdjustedTime
|
|
node.DatabaseSize = reportNode.DatabaseSize
|
|
node.Difficulty = reportNode.Difficulty
|
|
node.Height = reportNode.Height
|
|
node.Version = reportNode.Version
|
|
|
|
if resp.Header.Get("Access-Control-Allow-Origin") == "*" || resp.Header.Get("Access-Control-Allow-Origin") == "https://xmr.ditatompel.com" {
|
|
node.CORSCapable = true
|
|
}
|
|
|
|
if !node.IsTor {
|
|
hostIp, err := net.LookupIP(node.Hostname)
|
|
if err != nil {
|
|
fmt.Println("Warning: Could not resolve hostname: " + node.Hostname)
|
|
} else {
|
|
node.IP = hostIp[0].String()
|
|
}
|
|
}
|
|
|
|
// Sleeping 1 second to avoid too many request on host behind CloudFlare
|
|
// time.Sleep(1 * time.Second)
|
|
|
|
// check fee
|
|
fee, err := p.fetchFee(client, endpoint)
|
|
if err != nil {
|
|
return node, err
|
|
}
|
|
node.EstimateFee = fee
|
|
|
|
tookTime := time.Since(startTime).Seconds()
|
|
|
|
slog.Info(fmt.Sprintf("[PROBE] Took %f seconds", tookTime))
|
|
if err := p.reportResult(node, tookTime); err != nil {
|
|
return node, err
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
// get estimate fee from remote node
|
|
func (p *proberClient) fetchFee(client http.Client, endpoint string) (uint, error) {
|
|
rpcParam := []byte(`{"jsonrpc": "2.0","id": "0","method": "get_fee_estimate"}`)
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(rpcParam))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
|
req.Header.Set("User-Agent", RPCUserAgent)
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != 200 {
|
|
return 0, fmt.Errorf("status code: %d", res.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
f := struct {
|
|
Result struct {
|
|
Fee uint `json:"fee"`
|
|
} `json:"result"`
|
|
}{}
|
|
|
|
if err := json.Unmarshal(body, &f); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return f.Result.Fee, nil
|
|
}
|
|
|
|
func (p *proberClient) reportResult(node monero.Node, tookTime float64) error {
|
|
jsonData, err := json.Marshal(monero.ProbeReport{
|
|
TookTime: tookTime,
|
|
Message: p.message,
|
|
Node: node,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/api/v1/job", p.endpoint)
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Add(monero.ProberAPIKey, p.apiKey)
|
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
|
req.Header.Set("User-Agent", RPCUserAgent)
|
|
|
|
client := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("status code: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// for debug purposes
|
|
func prettyPrint(i interface{}) string {
|
|
s, _ := json.MarshalIndent(i, "", "\t")
|
|
return string(s)
|
|
}
|