Compare commits

..

10 commits

Author SHA1 Message Date
b60a67c8cb
chore: Run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
Run and use your own node! YOU MUST run and use your own node!
2024-09-12 06:02:02 +07:00
9bd609e4dd
chore: Remove dev SQL statement 2024-09-12 05:27:54 +07:00
cdb0816bc3
chore(ui): Replace IPs comma separator with space in table
Also replace IPs comma with comma and space in logs detail
2024-09-12 03:24:18 +07:00
fc172a0bd0
chore(ui): Moving logs link under uptime 2024-09-12 03:16:45 +07:00
7553ad8b45
feat: Display node IP addresses #84 2024-09-12 03:11:17 +07:00
f6b048b017
feat: Record node ip addresses #84
For future use investigations about "suspicious" nodes. #105
2024-09-12 01:13:30 +07:00
0e3dc04af8
fix: Formatting IPv6 display #84
Wraps IPv6 host in square brackets, returns as-is for domain names
or IPv4 addresses.
2024-09-09 19:50:30 +07:00
61cc98e378
feat!: Added IPv6 only information to the table
The IP address information of the remote node is replaced with
information on whether the remote node only supports IPv6 or not.
2024-09-09 19:17:39 +07:00
c3f837e122
feat: Check IP-stack info everytime prober send report #84
This commit add IsIPv6Only function inside `internal/ip` package
and moving `geo` package from `internal/geo` to `internal/ip/geo`.

Although it increases server resource usage, checking hostname to IP is
required every time the prober sends a report so that the `ipv6_only`
record in the database is not up-to-date. Previously, this feature did
not exist.
2024-09-09 18:21:03 +07:00
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
19 changed files with 277 additions and 58 deletions

View file

@ -8,6 +8,7 @@ SERVER_ENDPOINT="http://127.0.0.1:18901"
API_KEY=
ACCEPT_TOR=false
TOR_SOCKS="127.0.0.1:9050"
IPV6_CAPABLE=false
# Server Config
# #############

View file

@ -38,14 +38,14 @@ To build the executable binaries, you need:
- MySQL/MariaDB
- [GeoIP Database][geoip_doc] (optional). Place it to `./assets/geoip`,
see [./internal/geo/ip.go](./internal/geo/ip.go).
see [./internal/ip/geo/geoip.go](./internal/ip/geo/geoip.go).
## Installation
### For initial server setup:
1. Download [GeoIP Database][geoip_doc] and place it to `./assets/geoip`.
(see [./internal/geo/ip.go](./internal/geo/ip.go)).
(see [./internal/ip/geo/geoip.go](./internal/ip/geo/geoip.go)).
2. Pepare your MySQL/MariaDB.
3. Copy `.env.example` to `.env` and edit it to match with server environment.
4. Build the binary with `make server` (or `make build` to build both

View file

@ -14,6 +14,7 @@ import (
"time"
"github.com/ditatompel/xmr-remote-nodes/internal/config"
"github.com/ditatompel/xmr-remote-nodes/internal/ip"
"github.com/ditatompel/xmr-remote-nodes/internal/monero"
"github.com/spf13/cobra"
@ -36,20 +37,22 @@ func (err errProber) Error() string {
}
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
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,
endpoint: cfg.ServerEndpoint,
apiKey: cfg.APIKey,
acceptTor: cfg.AcceptTor,
torSOCKS: cfg.TorSOCKS,
acceptIPv6: cfg.IPv6Capable,
}
}
@ -85,6 +88,10 @@ 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 {
@ -121,20 +128,26 @@ func (p *proberClient) validateConfig() error {
// Get monero node info to fetch from the server
func (p *proberClient) fetchJob() (monero.Node, error) {
queryParams := ""
acceptTor := 0
if p.acceptTor {
queryParams = "?accept_tor=1"
acceptTor = 1
}
acceptIPv6 := 0
if p.acceptIPv6 {
acceptIPv6 = 1
}
var node monero.Node
uri := fmt.Sprintf("%s/api/v1/job%s", p.endpoint, queryParams)
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)
@ -322,6 +335,13 @@ func (p *proberClient) fetchFee(client http.Client, endpoint string) (uint, erro
}
func (p *proberClient) reportResult(node monero.Node, tookTime float64) error {
if !node.IsTor {
if hostIps, err := net.LookupIP(node.Hostname); err == nil {
node.IPv6Only = ip.IsIPv6Only(hostIps)
node.IPAddresses = ip.SliceToString(hostIps)
}
}
jsonData, err := json.Marshal(monero.ProbeReport{
TookTime: tookTime,
Message: p.message,

View file

@ -21,6 +21,7 @@ declare global {
is_tor: boolean;
is_available: boolean;
nettype: string;
ip_addresses: string;
}
interface ApiResponse {

View file

@ -1,19 +1,23 @@
<script>
import { getModalStore } from '@skeletonlabs/skeleton';
import { formatHostname } from '$lib/utils/strings';
const modalStore = getModalStore();
/** @type {string} */
export let ip;
/** @type {boolean} */
export let is_tor;
/** @type {string} */
export let hostname;
/** @type {number} */
export let port;
// if (is_tor) {
// hostname = hostname.substring(0, 8) + '[...].onion';
// }
/**
* @type {{
* is_tor: boolean,
* hostname: string,
* port: number,
* ipv6_only: boolean
* }}
*/
export let is_tor;
export let hostname;
export let port;
export let ipv6_only;
/** @type {string} */
export let ip_addresses;
/**
* @param {string} onionAddr
@ -33,15 +37,20 @@
{#if is_tor}
<button
class="max-w-32 truncate text-orange-800 dark:text-orange-300"
class="max-w-40 truncate text-orange-800 dark:text-orange-300"
on:click={() => modalAlert(hostname, port)}
>
👁 {hostname}
</button><br />.onion:<span class="text-indigo-800 dark:text-indigo-400">{port}</span>
<span class="text-gray-700 dark:text-gray-400">(TOR)</span>
{:else}
{hostname}:<span class="text-indigo-800 dark:text-indigo-400">{port}</span>
{#if ip !== ''}
<br /><span class="text-gray-700 dark:text-gray-400">{ip}</span>
{/if}
{formatHostname(hostname)}:<span class="text-indigo-800 dark:text-indigo-400">{port}</span><br />
<div class="max-w-40 text-ellipsis overflow-x-auto md:overflow-hidden hover:overflow-visible">
<span class="whitespace-break-spaces text-gray-700 dark:text-gray-400"
>{ip_addresses.replace(/,/g, ' ')}</span
>
{#if ipv6_only}
<span class="text-rose-800 dark:text-rose-400">(IPv6 only)</span>
{/if}
</div>
{/if}

View file

@ -1,3 +1,26 @@
/**
* Modifies the input string based on whether it is an IPv6 address.
* If the input is an IPv6 address, it wraps it in square brackets `[ ]`.
* Otherwise, it returns the input string as-is (for domain names or
* IPv4 addresses). AND I'M SORRY USING REGEX FOR THIS!
*
* @param {string} hostname
* @returns {string} - The modified string, IPv6 addresses wrapped in `[ ]`.
*/
export const formatHostname = (hostname) => {
// const ipv6Pattern = /^(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}$/; // full
// pattern for both full and compressed IPv6 addresses.
// source: https://regex101.com/library/cP9mH9?filterFlavors=dotnet&filterFlavors=javascript&orderBy=RELEVANCE&search=ip
// This may be incorrect, but let's assume it's correct. xD
const ipv6Pattern =
/^(([0-9A-Fa-f]{1,4}:){7})([0-9A-Fa-f]{1,4})$|(([0-9A-Fa-f]{1,4}:){1,6}:)(([0-9A-Fa-f]{1,4}:){0,4})([0-9A-Fa-f]{1,4})$/;
if (ipv6Pattern.test(hostname)) {
return `[${hostname}]`;
}
return hostname;
};
/**
* @param {number} bytes
* @param {number} decimals

View file

@ -4,7 +4,7 @@ export async function load() {
meta: {
title: 'Monero Remote Node',
description:
'A website that helps you monitor your favourite Monero remote nodes, a device on the internet running the Monero software with copy of the Monero blockchain.',
'A website that helps you monitor your favourite Monero remote nodes, but YOU BETTER RUN AND USE YOUR OWN NODE.',
keywords:
'monero,monero,xmr,monero node,xmrnode,cryptocurrency,monero remote node,monero testnet,monero stagenet'
},

View file

@ -42,7 +42,7 @@
<section id="form-add-monero-node">
<div class="section-container text-center">
<p>Enter your Monero node information below (IPv4 host only):</p>
<p>Enter your Monero node information below (IPv6 host check is experimental):</p>
<form
class="mx-auto w-full max-w-3xl py-2"

View file

@ -4,7 +4,7 @@ export async function load() {
// prettier-ignore
meta: {
title: 'Public Monero Remote Nodes List',
description: 'List of public Monero remote nodes that you can use with your favourite Monero wallet. You can filter by country, protocol, or CORS capable nodes.',
description: "Although it's possible to use these existing public Monero nodes, you're MUST RUN AND USE YOUR OWN NODE!",
keywords: 'monero remote nodes,public monero nodes,monero public nodes,monero wallet,tor monero node,monero cors rpc'
}
};

View file

@ -79,7 +79,7 @@
<section id="introduction ">
<div class="section-container text-center !max-w-4xl">
<p>Remote node can be used by people who, for their own reasons (usually because of hardware requirements, disk space, or technical abilities), cannot/don't want to run their own node and prefer to relay on one publicly available on the Monero network.</p>
<p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to always run your own node</strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
<p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to <span class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
</div>
</section>
@ -211,12 +211,12 @@
<tr>
<td
><HostPortCell
ip={row.ip}
ip_addresses={row.ip_addresses}
is_tor={row.is_tor}
hostname={row.hostname}
port={row.port}
ipv6_only={row.ipv6_only}
/>
<a class="anchor" href="/remote-nodes/logs/?node_id={row.id}">[Logs]</a>
</td>
<td><NetTypeCell nettype={row.nettype} height={row.height} /></td>
<td><ProtocolCell protocol={row.protocol} cors={row.cors} /></td>
@ -241,7 +241,13 @@
majority_fee={majorityFee[row.nettype]}
/>
</td>
<td><UptimeCell uptime={row.uptime} /></td>
<td
><UptimeCell uptime={row.uptime} /><br />
<a
class="anchor !text-purple-800 dark:!text-purple-400"
href="/remote-nodes/logs/?node_id={row.id}">[Logs]</a
>
</td>
<td>
{format(row.last_checked * 1000, 'PP HH:mm')}<br />
{formatDistance(row.last_checked * 1000, new Date(), { addSuffix: true })}
@ -278,7 +284,12 @@
rel="noopener">still can return high fee only if you about to create a transactions</a
>.
</li>
<li><strong>The best and safest way is running your own node</strong>!</li>
<li>
<strong
class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500"
>The best and safest way is running your own node!</strong
>
</li>
<li>
Nodes with 0% uptime within 1 month with more than 300 check attempt will be removed. You
can always add your node again latter.

View file

@ -3,7 +3,7 @@
import { format, formatDistance } from 'date-fns';
import { loadData, loadNodeInfo } from './api-handler';
import { onMount } from 'svelte';
import { formatHashes, formatBytes } from '$lib/utils/strings';
import { formatHostname, formatHashes, formatBytes } from '$lib/utils/strings';
import {
DtSrRowsPerPage,
DtSrThSort,
@ -71,11 +71,11 @@
<tbody>
<tr>
<td class="font-bold">Hostname:Port</td>
<td>{nodeInfo?.hostname}:{nodeInfo?.port}</td>
<td>{formatHostname(nodeInfo?.hostname)}:{nodeInfo?.port}</td>
</tr>
<tr>
<td class="font-bold">Public IP</td>
<td>{nodeInfo?.ip}</td>
<td>{nodeInfo?.ip_addresses.replace(/,/g, ', ')}</td>
</tr>
<tr>
<td class="font-bold">Net Type</td>

View file

@ -24,6 +24,7 @@ type App struct {
APIKey string
AcceptTor bool
TorSOCKS string
IPv6Capable bool
}
func init() {
@ -65,4 +66,5 @@ func LoadApp() {
app.APIKey = os.Getenv("API_KEY")
app.AcceptTor, _ = strconv.ParseBool(os.Getenv("ACCEPT_TOR"))
app.TorSOCKS = os.Getenv("TOR_SOCKS")
app.IPv6Capable, _ = strconv.ParseBool(os.Getenv("IPV6_CAPABLE"))
}

View file

@ -7,7 +7,7 @@ import (
type migrateFn func(*DB) error
var dbMigrate = [...]migrateFn{v1, v2}
var dbMigrate = [...]migrateFn{v1, v2, v3}
func MigrateDb(db *DB) error {
version := getSchemaVersion(db)
@ -256,3 +256,19 @@ func v2(db *DB) error {
return nil
}
func v3(db *DB) error {
slog.Debug("[DB] Migrating database schema version 3")
// table: tbl_node
slog.Debug("[DB] Adding additional columns to tbl_node")
_, err := db.Exec(`
ALTER TABLE tbl_node
ADD COLUMN ipv6_only TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER cors_capable,
ADD COLUMN ip_addresses TEXT NOT NULL DEFAULT '' AFTER cors_capable;`)
if err != nil {
return err
}
return nil
}

View file

@ -171,9 +171,10 @@ func Countries(c *fiber.Ctx) error {
// This handler should protected by `CheckProber` middleware.
func GiveJob(c *fiber.Ctx) error {
acceptTor := c.QueryInt("accept_tor", 0)
acceptIPv6 := c.QueryInt("accept_ipv6", 0)
moneroRepo := monero.New()
node, err := moneroRepo.GiveJob(acceptTor)
node, err := moneroRepo.GiveJob(acceptTor, acceptIPv6)
if err != nil {
return c.JSON(fiber.Map{
"status": "error",

28
internal/ip/ip.go Normal file
View file

@ -0,0 +1,28 @@
// Package ip provides IP address related functions
package ip
import (
"net"
"strings"
)
// IsIPv6Only returns true if all given IPs are IPv6
func IsIPv6Only(ips []net.IP) bool {
for _, ip := range ips {
if ip.To4() != nil {
return false
}
}
return true
}
// SliceToString converts []net.IP to a string separated by comma.
// If the separator is empty, it defaults to ",".
func SliceToString(ips []net.IP) string {
r := make([]string, len(ips))
for i, j := range ips {
r[i] = j.String()
}
return strings.Join(r, ",")
}

86
internal/ip/ip_test.go Normal file
View file

@ -0,0 +1,86 @@
package ip
import (
"net"
"testing"
)
// Single test: go test ./internal/ip -bench TestIsIPv6Only -benchmem -run=^$ -v
func TestIsIPv6Only(t *testing.T) {
tests := []struct {
name string
ips []net.IP
want bool
}{
{
name: "IPv4",
ips: []net.IP{
net.ParseIP("1.1.1.1"),
},
want: false,
},
{
name: "IPv6",
ips: []net.IP{
net.ParseIP("2606:4700::6810:85e5"),
},
want: true,
},
{
name: "IPv6 and IPv4",
ips: []net.IP{
net.ParseIP("1.1.1.1"),
net.ParseIP("2606:4700::6810:84e5"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsIPv6Only(tt.ips); got != tt.want {
t.Errorf("IsIPv6Only() = %v, want %v", got, tt.want)
}
})
}
}
// Single test: go test ./internal/ip -bench TestSliceToString -benchmem -run=^$ -v
func TestSliceToString(t *testing.T) {
tests := []struct {
name string
ips []net.IP
want string
}{
{
name: "IPv4",
ips: []net.IP{
net.ParseIP("1.1.1.1"),
},
want: "1.1.1.1",
},
{
name: "IPv6",
ips: []net.IP{
net.ParseIP("2606:4700::6810:85e5"),
},
want: "2606:4700::6810:85e5",
},
{
name: "IPv6 and IPv4",
ips: []net.IP{
net.ParseIP("1.1.1.1"),
net.ParseIP("2606:4700::6810:85e5"),
},
want: "1.1.1.1,2606:4700::6810:85e5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SliceToString(tt.ips); got != tt.want {
t.Errorf("SliceToString() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -12,7 +12,7 @@ import (
"time"
"github.com/ditatompel/xmr-remote-nodes/internal/database"
"github.com/ditatompel/xmr-remote-nodes/internal/ip"
"github.com/jmoiron/sqlx/types"
)
@ -54,6 +54,8 @@ type Node struct {
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
@ -195,7 +197,10 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
if strings.HasSuffix(hostname, ".onion") {
is_tor = true
}
ip := ""
ipAddr := ""
ips := ""
ipv6_only := false
if !is_tor {
hostIps, err := net.LookupIP(hostname)
@ -203,10 +208,9 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
return err
}
hostIp := hostIps[0].To4()
if hostIp == nil {
return errors.New("Host IP is not IPv4")
}
ipv6_only = ip.IsIPv6Only(hostIps)
hostIp := hostIps[0]
if hostIp.IsPrivate() {
return errors.New("IP address is private")
}
@ -214,7 +218,8 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
return errors.New("IP address is loopback address")
}
ip = hostIp.String()
ipAddr = hostIp.String()
ips = ip.SliceToString(hostIps)
}
row, err := r.db.Query(`
@ -248,7 +253,9 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
lon,
date_entered,
last_checked,
last_check_status
last_check_status,
ip_addresses,
ipv6_only
) VALUES (
?,
?,
@ -260,6 +267,8 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
?,
?,
?,
?,
?,
?
)`,
protocol,
@ -267,12 +276,14 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
port,
is_tor,
"",
ip,
ipAddr,
0,
0,
time.Now().Unix(),
0,
string(statusDb))
string(statusDb),
ips,
ipv6_only)
if err != nil {
return err
}

View file

@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/ditatompel/xmr-remote-nodes/internal/geo"
"github.com/ditatompel/xmr-remote-nodes/internal/ip/geo"
)
type QueryLogs struct {
@ -108,7 +108,7 @@ func (r *moneroRepo) Logs(q QueryLogs) (FetchLogs, error) {
}
// GiveJob returns node that should be probed for the next time
func (r *moneroRepo) GiveJob(acceptTor int) (Node, error) {
func (r *moneroRepo) GiveJob(acceptTor, acceptIPv6 int) (Node, error) {
args := []interface{}{}
wq := []string{}
where := ""
@ -117,6 +117,10 @@ func (r *moneroRepo) GiveJob(acceptTor int) (Node, error) {
wq = append(wq, "is_tor = ?")
args = append(args, 0)
}
if acceptIPv6 != 1 {
wq = append(wq, "ipv6_only = ?")
args = append(args, 0)
}
if len(wq) > 0 {
where = "WHERE " + strings.Join(wq, " AND ")
@ -304,7 +308,9 @@ func (r *moneroRepo) ProcessJob(report ProbeReport, proberId int64) error {
city = ?,
last_checked = ?,
last_check_status = ?,
cors_capable = ?
cors_capable = ?,
ip_addresses = ?,
ipv6_only = ?
WHERE
id = ?`
_, err := r.db.Exec(update,
@ -326,6 +332,8 @@ func (r *moneroRepo) ProcessJob(report ProbeReport, proberId int64) error {
now.Unix(),
statuses,
report.Node.CORSCapable,
report.Node.IPAddresses,
report.Node.IPv6Only,
report.Node.ID)
if err != nil {
slog.Warn(err.Error())
@ -337,10 +345,12 @@ func (r *moneroRepo) ProcessJob(report ProbeReport, proberId int64) error {
is_available = ?,
uptime = ?,
last_checked = ?,
last_check_status = ?
last_check_status = ?,
ip_addresses = ?,
ipv6_only = ?
WHERE
id = ?`
if _, err := r.db.Exec(u, 0, report.Node.Uptime, now.Unix(), statuses, report.Node.ID); err != nil {
if _, err := r.db.Exec(u, 0, report.Node.Uptime, now.Unix(), statuses, report.Node.IPAddresses, report.Node.IPv6Only, report.Node.ID); err != nil {
slog.Warn(err.Error())
}
}