xmr-remote-nodes/cmd/client/probe.go
Christian Ditaputratama 518d4b4335
feat: Added IPv6 nodes support (alpha) #84
This commit accept IPv6 nodes submission.

When user submit new public node, the server will check IP addresses
from given hostname. If host IP addresses doesn't have IPv4, it will
be recorded as "IPv6 only" node.

Probers that support IPv6 may add `IPV6_CAPABLE=true` to the `.env`
file.

Please note that this feature still experimental and may not being
merged to the main branch.
2024-09-06 00:08:59 +07:00

372 lines
8.8 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
acceptIPv6 bool // accept ipv6
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,
acceptIPv6: cfg.IPv6Capable,
}
}
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
}
func (p *proberClient) SetAcceptIPv6(acceptIPv6 bool) {
p.acceptIPv6 = acceptIPv6
}
// 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) {
acceptTor := 0
if p.acceptTor {
acceptTor = 1
}
acceptIPv6 := 0
if p.acceptIPv6 {
acceptIPv6 = 1
}
var node monero.Node
uri := fmt.Sprintf("%s/api/v1/job?accept_tor=%d&accept_ipv6=%d", p.endpoint, acceptTor, acceptIPv6)
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)
}