mirror of
https://github.com/ditatompel/xmr-remote-nodes.git
synced 2025-01-08 05:52:10 +07:00
Christian Ditaputratama
5e2ab83295
Please note that this naming service validation only validates simple length and allowed characters. Advanced validation such as internationalized domain name (IDN) is not implemented. To minimize abuse, I also set minimum length of submitted i2p naming service address to 5 characters. If someone have an address of 4 characters or less, let them open an issue or create a pull request.
409 lines
10 KiB
Go
409 lines
10 KiB
Go
package monero
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ditatompel/xmr-remote-nodes/internal/database"
|
|
"github.com/ditatompel/xmr-remote-nodes/internal/ip"
|
|
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
|
|
"github.com/jmoiron/sqlx/types"
|
|
)
|
|
|
|
type moneroRepo struct {
|
|
db *database.DB
|
|
}
|
|
|
|
func New() *moneroRepo {
|
|
return &moneroRepo{db: database.GetDB()}
|
|
}
|
|
|
|
// Node represents a single remote node
|
|
type Node struct {
|
|
ID uint `json:"id,omitempty" db:"id"`
|
|
Hostname string `json:"hostname" db:"hostname"`
|
|
IP string `json:"ip" db:"ip_addr"`
|
|
Port uint `json:"port" db:"port"`
|
|
Protocol string `json:"protocol" db:"protocol"`
|
|
IsTor bool `json:"is_tor" db:"is_tor"`
|
|
IsI2P bool `json:"is_i2p" db:"is_i2p"`
|
|
IsAvailable bool `json:"is_available" db:"is_available"`
|
|
Nettype string `json:"nettype" db:"nettype"`
|
|
Height uint `json:"height" db:"height"`
|
|
AdjustedTime uint `json:"adjusted_time" db:"adjusted_time"`
|
|
DatabaseSize uint `json:"database_size" db:"database_size"`
|
|
Difficulty uint `json:"difficulty" db:"difficulty"`
|
|
Version string `json:"version" db:"version"`
|
|
Status string `json:"status,omitempty"`
|
|
Uptime float64 `json:"uptime" db:"uptime"`
|
|
EstimateFee uint `json:"estimate_fee" db:"estimate_fee"`
|
|
ASN uint `json:"asn" db:"asn"`
|
|
ASNName string `json:"asn_name" db:"asn_name"`
|
|
CountryCode string `json:"cc" db:"country"`
|
|
CountryName string `json:"country_name" db:"country_name"`
|
|
City string `json:"city" db:"city"`
|
|
Latitude float64 `json:"latitude" db:"lat"`
|
|
Longitude float64 `json:"longitude" db:"lon"`
|
|
DateEntered uint `json:"date_entered,omitempty" db:"date_entered"`
|
|
LastChecked int64 `json:"last_checked" db:"last_checked"`
|
|
FailedCount uint `json:"failed_count,omitempty" db:"failed_count"`
|
|
LastCheckStatus types.JSONText `json:"last_check_statuses" db:"last_check_status"`
|
|
CORSCapable bool `json:"cors" db:"cors_capable"`
|
|
IPv6Only bool `json:"ipv6_only" db:"ipv6_only"`
|
|
IPAddresses string `json:"ip_addresses" db:"ip_addresses"`
|
|
}
|
|
|
|
// Get node from database by id
|
|
func (r *moneroRepo) Node(id int) (Node, error) {
|
|
var node Node
|
|
err := r.db.Get(&node, `SELECT * FROM tbl_node WHERE id = ?`, id)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
slog.Error(err.Error())
|
|
return node, errors.New("Can't get node information")
|
|
}
|
|
if err == sql.ErrNoRows {
|
|
return node, errors.New("Node not found")
|
|
}
|
|
return node, err
|
|
}
|
|
|
|
// QueryNodes represents database query parameters
|
|
type QueryNodes struct {
|
|
paging.Paging
|
|
Host string `url:"host,omitempty"`
|
|
Nettype string `url:"nettype,omitempty"` // Can be empty string, "any", mainnet, stagenet, testnet.
|
|
Protocol string `url:"protocol,omitempty"` // Can be "any", tor, http, https. Default: "any"
|
|
CC string `url:"cc,omitempty"` // 2 letter country code
|
|
Status int `url:"status"`
|
|
CORS string `url:"cors,omitempty"`
|
|
}
|
|
|
|
// toSQL generates SQL query from query parameters
|
|
func (q *QueryNodes) toSQL() (args []interface{}, where string) {
|
|
wq := []string{}
|
|
|
|
if q.Host != "" {
|
|
wq = append(wq, "(hostname LIKE ? OR ip_addr LIKE ?)")
|
|
args = append(args, "%"+q.Host+"%", "%"+q.Host+"%")
|
|
}
|
|
if slices.Contains([]string{"mainnet", "stagenet", "testnet"}, q.Nettype) {
|
|
wq = append(wq, "nettype = ?")
|
|
args = append(args, q.Nettype)
|
|
}
|
|
if q.Protocol != "any" && slices.Contains([]string{"tor", "i2p", "http", "https"}, q.Protocol) {
|
|
switch q.Protocol {
|
|
case "i2p":
|
|
wq = append(wq, "is_i2p = ?")
|
|
args = append(args, 1)
|
|
case "tor":
|
|
wq = append(wq, "is_tor = ?")
|
|
args = append(args, 1)
|
|
default:
|
|
wq = append(wq, "(protocol = ? AND is_tor = ? AND is_i2p = ?)")
|
|
args = append(args, q.Protocol, 0, 0)
|
|
}
|
|
}
|
|
if q.CC != "any" {
|
|
wq = append(wq, "country = ?")
|
|
if q.CC == "UNKNOWN" {
|
|
args = append(args, "")
|
|
} else {
|
|
args = append(args, q.CC)
|
|
}
|
|
}
|
|
if q.Status != -1 {
|
|
wq = append(wq, "is_available = ?")
|
|
args = append(args, q.Status)
|
|
}
|
|
if q.CORS == "on" || q.CORS == "1" { // DEPRECATED: CORS = int is deprecated, use CORS = on" instead
|
|
wq = append(wq, "cors_capable = ?")
|
|
args = append(args, 1)
|
|
}
|
|
|
|
if len(wq) > 0 {
|
|
where = "WHERE " + strings.Join(wq, " AND ")
|
|
}
|
|
|
|
if !slices.Contains([]string{"last_checked", "uptime"}, q.SortBy) {
|
|
q.SortBy = "last_checked"
|
|
}
|
|
|
|
if q.SortDirection != "asc" {
|
|
q.SortDirection = "DESC"
|
|
}
|
|
|
|
return args, where
|
|
}
|
|
|
|
// Nodes represents a list of nodes
|
|
type Nodes struct {
|
|
TotalRows int `json:"total_rows"`
|
|
TotalPages int `json:"total_pages"` // total pages
|
|
RowsPerPage int `json:"rows_per_page"`
|
|
Items []*Node `json:"items"`
|
|
}
|
|
|
|
// Get nodes from database
|
|
func (r *moneroRepo) Nodes(q QueryNodes) (Nodes, error) {
|
|
args, where := q.toSQL()
|
|
|
|
var nodes Nodes
|
|
|
|
nodes.RowsPerPage = q.Limit
|
|
|
|
qTotal := fmt.Sprintf(`
|
|
SELECT
|
|
COUNT(id) AS total_rows
|
|
FROM
|
|
tbl_node
|
|
%s`, where)
|
|
|
|
err := r.db.QueryRow(qTotal, args...).Scan(&nodes.TotalRows)
|
|
if err != nil {
|
|
return nodes, err
|
|
}
|
|
nodes.TotalPages = int(math.Ceil(float64(nodes.TotalRows) / float64(q.Limit)))
|
|
args = append(args, q.Limit, (q.Page-1)*q.Limit)
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
*
|
|
FROM
|
|
tbl_node
|
|
%s
|
|
ORDER BY
|
|
%s
|
|
%s
|
|
LIMIT ?
|
|
OFFSET ?`, where, q.SortBy, q.SortDirection)
|
|
err = r.db.Select(&nodes.Items, query, args...)
|
|
|
|
return nodes, err
|
|
}
|
|
|
|
func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
|
|
if protocol != "http" && protocol != "https" {
|
|
return errors.New("Invalid protocol, must one of or HTTP/HTTPS")
|
|
}
|
|
|
|
if port > 65535 || port < 1 {
|
|
return errors.New("Invalid port number")
|
|
}
|
|
|
|
is_tor := false
|
|
if strings.HasSuffix(hostname, ".onion") {
|
|
if !validTorHostname(hostname) {
|
|
return errors.New("Invalid TOR v3 .onion hostname")
|
|
}
|
|
is_tor = true
|
|
}
|
|
|
|
is_i2p := false
|
|
if strings.HasSuffix(hostname, ".i2p") {
|
|
if !validI2PHostname(hostname) {
|
|
return errors.New("Invalid I2P hostname")
|
|
}
|
|
is_i2p = true
|
|
}
|
|
|
|
ipAddr := ""
|
|
ips := ""
|
|
ipv6_only := false
|
|
|
|
if !is_tor && !is_i2p {
|
|
hostIps, err := net.LookupIP(hostname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ipv6_only = ip.IsIPv6Only(hostIps)
|
|
|
|
hostIp := hostIps[0]
|
|
if hostIp.IsPrivate() {
|
|
return errors.New("IP address is private")
|
|
}
|
|
if hostIp.IsLoopback() {
|
|
return errors.New("IP address is loopback address")
|
|
}
|
|
|
|
ipAddr = hostIp.String()
|
|
ips = ip.SliceToString(hostIps)
|
|
}
|
|
|
|
row, err := r.db.Query(`
|
|
SELECT
|
|
id
|
|
FROM
|
|
tbl_node
|
|
WHERE
|
|
protocol = ?
|
|
AND hostname = ?
|
|
AND port = ?
|
|
LIMIT 1`, protocol, hostname, port)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer row.Close()
|
|
|
|
if row.Next() {
|
|
return errors.New("Node already monitored")
|
|
}
|
|
statusDb, _ := json.Marshal([5]int{2, 2, 2, 2, 2})
|
|
_, err = r.db.Exec(`
|
|
INSERT INTO tbl_node (
|
|
protocol,
|
|
hostname,
|
|
port,
|
|
is_tor,
|
|
is_i2p,
|
|
nettype,
|
|
ip_addr,
|
|
lat,
|
|
lon,
|
|
date_entered,
|
|
last_checked,
|
|
last_check_status,
|
|
ip_addresses,
|
|
ipv6_only
|
|
) VALUES (
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?
|
|
)`,
|
|
protocol,
|
|
hostname,
|
|
port,
|
|
is_tor,
|
|
is_i2p,
|
|
"",
|
|
ipAddr,
|
|
0,
|
|
0,
|
|
time.Now().Unix(),
|
|
0,
|
|
string(statusDb),
|
|
ips,
|
|
ipv6_only)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validTorHostname shecks if a given hostname is a valid TOR v3 .onion address
|
|
// with optional subdomain
|
|
//
|
|
// TOR v3 .onion addresses are 56 characters of `base32` followed by ".onion"
|
|
func validTorHostname(hostname string) bool {
|
|
return regexp.MustCompile(`^([a-z0-9-]+\.)*[a-z2-7]{56}\.onion$`).MatchString(hostname)
|
|
}
|
|
|
|
// validI2PHostname checks if a given hostname is a valid b32 or naming service
|
|
// I2P address
|
|
//
|
|
// Old b32 addresses are always {52 chars}.b32.i2p and new ones are
|
|
// {56+ chars}.b32.i2p. Since I don't know if there is a length limit of new
|
|
// b32 addresses, this function allows up to 63 characters.
|
|
//
|
|
// For naming service, I2P addresses are up to 67 characters, including the
|
|
// '.i2p' part. Please note that this naming service validation only validates
|
|
// simple length and allowed characters. Advanced validation such as
|
|
// internationalized domain name (IDN) is not implemented.
|
|
//
|
|
// Ref: https://geti2p.net/spec/b32encrypted and https://geti2p.net/en/docs/naming
|
|
func validI2PHostname(hostname string) bool {
|
|
// To minimize abuse, I set minimum length of submitted i2p naming service
|
|
// address to 5 characters. If someone have an address of 4 characters or
|
|
// less, let them open an issue or create a pull request.
|
|
return regexp.MustCompile(`^([a-z2-7]{52,63}\.b32|[a-z0-9-]{5,63})\.i2p$`).MatchString(hostname)
|
|
}
|
|
|
|
func (r *moneroRepo) Delete(id uint) error {
|
|
if _, err := r.db.Exec(`DELETE FROM tbl_node WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := r.db.Exec(`DELETE FROM tbl_probe_log WHERE node_id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type NetFee struct {
|
|
Nettype string `json:"nettype" db:"nettype"`
|
|
EstimateFee uint `json:"estimate_fee" db:"estimate_fee"`
|
|
NodeCount int `json:"node_count" db:"node_count"`
|
|
}
|
|
|
|
// Get majority net fee from table tbl_fee
|
|
func (r *moneroRepo) NetFees() []NetFee {
|
|
var netFees []NetFee
|
|
err := r.db.Select(&netFees, `
|
|
SELECT
|
|
nettype,
|
|
estimate_fee,
|
|
node_count
|
|
FROM
|
|
tbl_fee
|
|
`)
|
|
if err != nil {
|
|
slog.Error(fmt.Sprintf("[MONERO] Failed to get net fees: %s", err))
|
|
}
|
|
return netFees
|
|
}
|
|
|
|
// Countries represents list of countries
|
|
type Countries struct {
|
|
TotalNodes int `json:"total_nodes" db:"total_nodes"`
|
|
CC string `json:"cc" db:"country"` // country code
|
|
Name string `json:"name" db:"country_name"`
|
|
}
|
|
|
|
// Get list of countries (count by nodes)
|
|
func (r *moneroRepo) Countries() ([]Countries, error) {
|
|
var c []Countries
|
|
err := r.db.Select(&c, `
|
|
SELECT
|
|
COUNT(id) AS total_nodes,
|
|
country,
|
|
country_name
|
|
FROM
|
|
tbl_node
|
|
GROUP BY
|
|
country
|
|
ORDER BY
|
|
country ASC`)
|
|
return c, err
|
|
}
|
|
|
|
// ParseNodeStatuses parses JSONText into [5]int
|
|
// Used this to parse last_check_status for templ engine
|
|
func ParseNodeStatuses(statuses types.JSONText) [5]int {
|
|
s := [5]int{}
|
|
if err := statuses.Unmarshal(&s); err != nil {
|
|
return [5]int{2, 2, 2, 2, 2}
|
|
}
|
|
|
|
return s
|
|
}
|