xmr-remote-nodes/internal/monero/monero.go
Christian Ditaputratama 5e2ab83295
feat: Accept i2p naming service hostname #148
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.
2024-11-08 00:23:07 +07:00

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
}