├── VERSION
├── bun.lockb
├── .gitattributes
├── server.go
├── internal
├── handler
│ ├── views
│ │ ├── assets
│ │ │ ├── favicon.ico
│ │ │ └── img
│ │ │ │ ├── monerotip.png
│ │ │ │ ├── node-tx-fee.jpg
│ │ │ │ └── cf
│ │ │ │ ├── id.svg
│ │ │ │ ├── at.svg
│ │ │ │ ├── lv.svg
│ │ │ │ ├── mc.svg
│ │ │ │ ├── ng.svg
│ │ │ │ ├── ua.svg
│ │ │ │ ├── pl.svg
│ │ │ │ ├── pw.svg
│ │ │ │ ├── bd.svg
│ │ │ │ ├── de.svg
│ │ │ │ ├── jp.svg
│ │ │ │ ├── ye.svg
│ │ │ │ ├── ch.svg
│ │ │ │ ├── bs.svg
│ │ │ │ ├── bw.svg
│ │ │ │ ├── ee.svg
│ │ │ │ ├── be.svg
│ │ │ │ ├── bg.svg
│ │ │ │ ├── bl.svg
│ │ │ │ ├── bq.svg
│ │ │ │ ├── ci.svg
│ │ │ │ ├── fr.svg
│ │ │ │ ├── gf.svg
│ │ │ │ ├── gl.svg
│ │ │ │ ├── gp.svg
│ │ │ │ ├── hu.svg
│ │ │ │ ├── ie.svg
│ │ │ │ ├── it.svg
│ │ │ │ ├── mf.svg
│ │ │ │ ├── mg.svg
│ │ │ │ ├── mq.svg
│ │ │ │ ├── nc.svg
│ │ │ │ ├── nl.svg
│ │ │ │ ├── pm.svg
│ │ │ │ ├── re.svg
│ │ │ │ ├── ru.svg
│ │ │ │ ├── sl.svg
│ │ │ │ ├── yt.svg
│ │ │ │ ├── am.svg
│ │ │ │ ├── bj.svg
│ │ │ │ ├── cg.svg
│ │ │ │ ├── co.svg
│ │ │ │ ├── gb-eng.svg
│ │ │ │ ├── gn.svg
│ │ │ │ ├── lt.svg
│ │ │ │ ├── lu.svg
│ │ │ │ ├── ro.svg
│ │ │ │ ├── td.svg
│ │ │ │ ├── th.svg
│ │ │ │ ├── la.svg
│ │ │ │ ├── se.svg
│ │ │ │ ├── cz.svg
│ │ │ │ ├── dk.svg
│ │ │ │ ├── ga.svg
│ │ │ │ ├── ml.svg
│ │ │ │ ├── ae.svg
│ │ │ │ ├── tt.svg
│ │ │ │ ├── fi.svg
│ │ │ │ ├── ps.svg
│ │ │ │ ├── sd.svg
│ │ │ │ ├── to.svg
│ │ │ │ ├── gr.svg
│ │ │ │ ├── kw.svg
│ │ │ │ ├── ma.svg
│ │ │ │ ├── mu.svg
│ │ │ │ ├── bh.svg
│ │ │ │ ├── gm.svg
│ │ │ │ ├── is.svg
│ │ │ │ ├── ne.svg
│ │ │ │ ├── bv.svg
│ │ │ │ ├── fo.svg
│ │ │ │ ├── mv.svg
│ │ │ │ ├── no.svg
│ │ │ │ ├── sj.svg
│ │ │ │ ├── jm.svg
│ │ │ │ ├── ax.svg
│ │ │ │ ├── lc.svg
│ │ │ │ ├── dz.svg
│ │ │ │ ├── gh.svg
│ │ │ │ ├── tn.svg
│ │ │ │ ├── pr.svg
│ │ │ │ ├── gb-sct.svg
│ │ │ │ ├── tr.svg
│ │ │ │ ├── sc.svg
│ │ │ │ ├── cd.svg
│ │ │ │ ├── cu.svg
│ │ │ │ ├── tl.svg
│ │ │ │ ├── sr.svg
│ │ │ │ ├── gy.svg
│ │ │ │ ├── cf.svg
│ │ │ │ ├── dj.svg
│ │ │ │ ├── il.svg
│ │ │ │ ├── mr.svg
│ │ │ │ ├── aw.svg
│ │ │ │ ├── sy.svg
│ │ │ │ ├── wf.svg
│ │ │ │ ├── ss.svg
│ │ │ │ ├── pk.svg
│ │ │ │ ├── mk.svg
│ │ │ │ ├── qa.svg
│ │ │ │ ├── ly.svg
│ │ │ │ ├── tz.svg
│ │ │ │ ├── vc.svg
│ │ │ │ ├── gg.svg
│ │ │ │ ├── jo.svg
│ │ │ │ ├── vn.svg
│ │ │ │ ├── nr.svg
│ │ │ │ ├── eh.svg
│ │ │ │ ├── hn.svg
│ │ │ │ ├── tw.svg
│ │ │ │ ├── ba.svg
│ │ │ │ ├── cl.svg
│ │ │ │ ├── mm.svg
│ │ │ │ ├── so.svg
│ │ │ │ ├── az.svg
│ │ │ │ ├── gb.svg
│ │ │ │ ├── sh.svg
│ │ │ │ ├── za.svg
│ │ │ │ ├── cn.svg
│ │ │ │ ├── gb-nir.svg
│ │ │ │ ├── gw.svg
│ │ │ │ ├── pa.svg
│ │ │ │ ├── ag.svg
│ │ │ │ ├── bf.svg
│ │ │ │ ├── kr.svg
│ │ │ │ ├── cw.svg
│ │ │ │ ├── bb.svg
│ │ │ │ ├── cm.svg
│ │ │ │ ├── sn.svg
│ │ │ │ ├── ws.svg
│ │ │ │ ├── km.svg
│ │ │ │ ├── tg.svg
│ │ │ │ ├── my.svg
│ │ │ │ ├── ca.svg
│ │ │ │ ├── kp.svg
│ │ │ │ ├── st.svg
│ │ │ │ ├── lr.svg
│ │ │ │ ├── fm.svg
│ │ │ │ ├── na.svg
│ │ │ │ ├── rw.svg
│ │ │ │ ├── et.svg
│ │ │ │ ├── mw.svg
│ │ │ │ ├── mh.svg
│ │ │ │ ├── ge.svg
│ │ │ │ ├── um.svg
│ │ │ │ ├── us.svg
│ │ │ │ ├── in.svg
│ │ │ │ ├── sg.svg
│ │ │ │ ├── tf.svg
│ │ │ │ ├── ve.svg
│ │ │ │ ├── kn.svg
│ │ │ │ ├── sb.svg
│ │ │ │ ├── cv.svg
│ │ │ │ ├── bi.svg
│ │ │ │ ├── eu.svg
│ │ │ │ ├── hk.svg
│ │ │ │ ├── uz.svg
│ │ │ │ ├── ph.svg
│ │ │ │ ├── gd.svg
│ │ │ │ ├── nu.svg
│ │ │ │ ├── ke.svg
│ │ │ │ ├── ck.svg
│ │ │ │ ├── ir.svg
│ │ │ │ ├── tk.svg
│ │ │ │ ├── np.svg
│ │ │ │ ├── tv.svg
│ │ │ │ ├── au.svg
│ │ │ │ ├── hm.svg
│ │ │ │ ├── by.svg
│ │ │ │ ├── ls.svg
│ │ │ │ ├── tj.svg
│ │ │ │ ├── sk.svg
│ │ │ │ ├── nz.svg
│ │ │ │ ├── mo.svg
│ │ │ │ ├── mn.svg
│ │ │ │ ├── si.svg
│ │ │ │ ├── ao.svg
│ │ │ │ ├── iq.svg
│ │ │ │ ├── vu.svg
│ │ │ │ ├── pg.svg
│ │ │ │ ├── mz.svg
│ │ │ │ ├── cx.svg
│ │ │ │ ├── ai.svg
│ │ │ │ ├── ug.svg
│ │ │ │ ├── ar.svg
│ │ │ │ ├── ki.svg
│ │ │ │ ├── gi.svg
│ │ │ │ ├── br.svg
│ │ │ │ └── xk.svg
│ │ ├── embed.go
│ │ ├── src
│ │ │ ├── js
│ │ │ │ └── main.js
│ │ │ └── css
│ │ │ │ └── main.css
│ │ ├── vars.go
│ │ └── partial_navbar.templ
│ ├── middlewares.go
│ ├── server.go
│ └── routes.go
├── config
│ ├── config.go
│ ├── db.go
│ └── app.go
├── ip
│ ├── ip.go
│ ├── ip_test.go
│ └── geo
│ │ └── geoip.go
├── database
│ └── mysql.go
├── paging
│ └── paging.go
└── monero
│ ├── ban_list_test.go
│ ├── ban_list.go
│ └── prober.go
├── main.go
├── .gitignore
├── deployment
├── ansible
│ ├── inventory.example.ini
│ ├── deploy-server.example.yml
│ └── deploy-prober.example.yml
├── init
│ ├── xmr-nodes-prober.service
│ ├── xmr-nodes-server.service
│ └── xmr-nodes-prober.timer
└── nginx
│ └── vhost.conf
├── .github
├── dependabot.yml
└── workflows
│ ├── build.yml
│ ├── test.yml
│ └── release.yml
├── package.json
├── .editorconfig
├── tailwind.config.js
├── .golangci.yaml
├── cmd
├── server
│ ├── init.go
│ ├── cron.go
│ ├── node.go
│ └── serve.go
└── cmd.go
├── .air.toml
├── .env.example
├── go.mod
├── LICENSE
├── utils
└── human_readable.go
└── Makefile
/VERSION:
--------------------------------------------------------------------------------
1 | v0.2.4
2 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ditatompel/xmr-remote-nodes/HEAD/bun.lockb
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /deployment/nginx/**.conf linguist-language=Nginx
2 | /deployment/nginx/**.conf linguist-detectable=true
3 |
--------------------------------------------------------------------------------
/server.go:
--------------------------------------------------------------------------------
1 | //go:build server
2 |
3 | package main
4 |
5 | import _ "github.com/ditatompel/xmr-remote-nodes/cmd/server"
6 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ditatompel/xmr-remote-nodes/HEAD/internal/handler/views/assets/favicon.ico
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/monerotip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ditatompel/xmr-remote-nodes/HEAD/internal/handler/views/assets/img/monerotip.png
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/ditatompel/xmr-remote-nodes/cmd"
5 | )
6 |
7 | func main() {
8 | cmd.Execute()
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | /bin
3 | /node_modules
4 | /tmp
5 | /assets/geoip
6 | /internal/handler/views/assets/css/**/*
7 | /internal/handler/views/assets/js/**/*
8 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/node-tx-fee.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ditatompel/xmr-remote-nodes/HEAD/internal/handler/views/assets/img/node-tx-fee.jpg
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/id.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/at.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/lv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ng.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ua.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bd.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/de.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/jp.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ch.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ee.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/be.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bq.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ci.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/fr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gp.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/hu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ie.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/it.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mq.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/nc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/nl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/re.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ru.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/yt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/am.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bj.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/co.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gb-eng.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/lt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/lu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ro.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/td.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/th.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/la.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/se.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/dk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ga.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ml.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ae.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/fi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ps.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sd.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/to.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/kw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/deployment/ansible/inventory.example.ini:
--------------------------------------------------------------------------------
1 | [server]
2 | xmr-node-server ansible_host=192.168.0.2 ansible_user=xmr-nodes ansible_ssh_private_key_file=/path/to/ssh/private_key
3 |
4 | [prober]
5 | prober-1 ansible_host=192.168.0.3 ansible_user=xmr-nodes
6 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ma.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/is.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ne.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/fo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/no.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sj.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/jm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ax.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/lc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/dz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: gomod
4 | directory: "/"
5 | schedule:
6 | interval: weekly
7 | time: "05:00"
8 | open-pull-requests-limit: 5
9 | reviewers:
10 | - ditatompel
11 | assignees:
12 | - ditatompel
13 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gb-sct.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cd.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/deployment/init/xmr-nodes-prober.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=xmr-node prober service
3 | After=network.target
4 |
5 | [Service]
6 | Type=oneshot
7 | User=your_user
8 | WorkingDirectory=/path/to/project/dir
9 | ExecStart=/path/to/project/dir/bin/xmr-nodes-client probe
10 | TimeoutSec=90
11 |
12 | # vim: filetype=systemd
13 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/dj.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/il.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/aw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@preline/collapse": "^2.5.0",
4 | "@preline/overlay": "^2.5.0",
5 | "@tailwindcss/forms": "^0.5.9",
6 | "@tailwindcss/typography": "^0.5.15",
7 | "clipboard": "^2.0.11",
8 | "htmx.org": "^1.9.12",
9 | "preline": "^2.5.1",
10 | "tailwindcss": "^3.4.14"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/wf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ss.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/joho/godotenv"
7 | )
8 |
9 | // LoadAllConfigs set various configs
10 | func LoadAll(envFile string) {
11 | if err := godotenv.Load(envFile); err != nil {
12 | log.Fatalf("can't load environment file. error: %v", err)
13 | }
14 |
15 | LoadApp()
16 | LoadDBCfg()
17 | }
18 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/qa.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ly.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/vc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; https://editorconfig.org/
2 |
3 | root = true
4 |
5 | [*]
6 | trim_trailing_whitespace = true
7 | indent_style = space
8 | indent_size = 2
9 |
10 | [{Makefile,go.mod,go.sum,*.go}]
11 | indent_style = tab
12 | indent_size = 4
13 |
14 | [*.md]
15 | indent_size = 4
16 | trim_trailing_whitespace = false
17 |
18 |
19 | [{*.yml,*.yaml}]
20 | indent_style = space
21 | indent_size = 2
22 |
--------------------------------------------------------------------------------
/deployment/init/xmr-nodes-server.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=xmr-node server service
3 | After=network.target mariadb.service
4 |
5 | [Install]
6 | WantedBy=multi-user.target
7 |
8 | [Service]
9 | Type=simple
10 | User=your_user
11 | Restart=always
12 | WorkingDirectory=/path/to/project/dir
13 | ExecStart=/path/to/project/dir/bin/xmr-nodes-server serve
14 | SyslogIdentifier=xmr-node-server
15 |
16 | # vim: filetype=systemd
17 |
--------------------------------------------------------------------------------
/deployment/init/xmr-nodes-prober.timer:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Schedule fetch monero node info every 30 seconds
3 |
4 | [Timer]
5 | Persistent=true
6 | #Run 120 seconds after boot for the first time
7 | OnBootSec=120
8 | #Run every 30 seconds thereafter
9 | OnCalendar=*-*-* *:*:00,30
10 | #File describing job to execute
11 | Unit=xmr-nodes-prober.service
12 |
13 | [Install]
14 | WantedBy=timers.target
15 |
16 | # vim: filetype=systemd
17 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/jo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/embed.go:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "embed"
5 | "net/http"
6 |
7 | "github.com/gofiber/fiber/v2"
8 | "github.com/gofiber/fiber/v2/middleware/filesystem"
9 | )
10 |
11 | //go:embed assets/*
12 | var embedStatic embed.FS
13 |
14 | func EmbedAssets() fiber.Handler {
15 | return filesystem.New(filesystem.Config{
16 | Root: http.FS(embedStatic),
17 | PathPrefix: "assets",
18 | Browse: false,
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./internal/handler/views/*.templ",
5 | "node_modules/preline/dist/*.js",
6 | ],
7 | // enable dark mode via class strategy
8 | // darkMode: "class",
9 | theme: {
10 | extend: {},
11 | },
12 | plugins: [
13 | require("@tailwindcss/typography"),
14 | require("@tailwindcss/forms"),
15 | require("preline/plugin"),
16 | ],
17 | };
18 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/vn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/nr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/eh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/hn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ba.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cl.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/so.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/az.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gb.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/za.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gb-nir.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pa.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/kr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bb.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | exclusions:
4 | generated: lax
5 | presets:
6 | - comments
7 | - common-false-positives
8 | - legacy
9 | - std-error-handling
10 | rules:
11 | - path: frontend/embed.go
12 | text: 'pattern build/\*: no matching files found'
13 | - linters:
14 | - errcheck
15 | path: _test.go
16 | paths:
17 | - third_party$
18 | - builtin$
19 | - examples$
20 | formatters:
21 | exclusions:
22 | generated: lax
23 | paths:
24 | - third_party$
25 | - builtin$
26 | - examples$
27 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ws.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/km.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/my.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ca.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/kp.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/st.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/lr.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/fm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/config/db.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "strconv"
6 | )
7 |
8 | // DB holds the DB configuration
9 | type DB struct {
10 | Host string
11 | Port int
12 | Name string
13 | User string
14 | Password string
15 | }
16 |
17 | var db = &DB{}
18 |
19 | // DBCfg returns the default DB configuration
20 | func DBCfg() *DB {
21 | return db
22 | }
23 |
24 | // LoadDBCfg loads DB configuration
25 | func LoadDBCfg() {
26 | db.Host = os.Getenv("DB_HOST")
27 | db.Port, _ = strconv.Atoi(os.Getenv("DB_PORT"))
28 | db.User = os.Getenv("DB_USER")
29 | db.Password = os.Getenv("DB_PASSWORD")
30 | db.Name = os.Getenv("DB_NAME")
31 | }
32 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/na.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/rw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cmd/server/init.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import "github.com/ditatompel/xmr-remote-nodes/cmd"
4 |
5 | func init() {
6 | cmd.Root.AddCommand(serveCmd)
7 | cmd.Root.AddCommand(cronCmd)
8 | cmd.Root.AddCommand(probersCmd)
9 | probersCmd.AddCommand(listProbersCmd)
10 | probersCmd.AddCommand(addProbersCmd)
11 | probersCmd.AddCommand(editProbersCmd)
12 | probersCmd.AddCommand(deleteProbersCmd)
13 | listProbersCmd.Flags().StringP("sort-by", "s", "last_submit_ts", "Sort by column name, can be id or last_submit_ts")
14 | listProbersCmd.Flags().StringP("sort-dir", "d", "desc", "Sort direction, can be asc or desc")
15 | cmd.Root.AddCommand(nodeCmd)
16 | nodeCmd.AddCommand(deleteNodeCmd)
17 | }
18 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/et.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mw.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ge.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/um.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/us.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/in.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ve.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/middlewares.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "github.com/ditatompel/xmr-remote-nodes/internal/monero"
5 |
6 | "github.com/gofiber/fiber/v2"
7 | )
8 |
9 | // checkProberMW is a middleware to check prober API key
10 | func (s *fiberServer) checkProberMW(c *fiber.Ctx) error {
11 | key := c.Get(monero.ProberAPIKey)
12 | if key == "" {
13 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
14 | "status": "error",
15 | "message": "Unauthorized",
16 | "data": nil,
17 | })
18 | }
19 |
20 | prober, err := monero.NewProber().CheckAPI(key)
21 | if err != nil {
22 | return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
23 | "status": "error",
24 | "message": "No API key match",
25 | "data": nil,
26 | })
27 | }
28 |
29 | c.Locals("prober_id", prober.ID)
30 | return c.Next()
31 | }
32 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/kn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/server.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "github.com/ditatompel/xmr-remote-nodes/internal/config"
5 | "github.com/ditatompel/xmr-remote-nodes/internal/database"
6 | "github.com/gofiber/fiber/v2"
7 | )
8 |
9 | type fiberServer struct {
10 | *fiber.App
11 | db *database.DB
12 | url string
13 | secret string
14 | }
15 |
16 | // NewServer returns a new fiber server
17 | func NewServer() *fiberServer {
18 | if database.ConnectDB() != nil {
19 | panic("Failed to connect to database")
20 | }
21 | server := &fiberServer{
22 | App: fiber.New(fiber.Config{
23 | Prefork: config.AppCfg().Prefork,
24 | ProxyHeader: config.AppCfg().ProxyHeader,
25 | AppName: "XMR Nodes Aggregator " + config.Version,
26 | }),
27 | db: database.GetDB(),
28 | url: config.AppCfg().URL,
29 | }
30 |
31 | return server
32 | }
33 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sb.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/bi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/eu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/hk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/uz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ph.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gd.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/nu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ke.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ck.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/src/js/main.js:
--------------------------------------------------------------------------------
1 | import "@preline/collapse";
2 | import "@preline/overlay";
3 |
4 | window.addEventListener("load", () => {
5 | var clipboard = new ClipboardJS(".clipboard");
6 | clipboard.on("success", function (e) {
7 | let btnText = e.trigger.textContent;
8 | let successText = e.trigger.getAttribute("data-success-text");
9 | if (successText === null) {
10 | successText = "Copied 👍";
11 | }
12 | e.trigger.textContent = successText;
13 | e.trigger.disabled = true;
14 | setTimeout(function () {
15 | e.trigger.textContent = btnText;
16 | e.trigger.disabled = false;
17 | }, 1000);
18 | });
19 | clipboard.on("error", function (e) {
20 | console.error("Clipboard error", e.trigger);
21 | });
22 | });
23 |
24 | htmx.onLoad(function () {
25 | // Auto init preline JS, see https://preline.co/docs/preline-javascript.html
26 | // This need to be inside `htmx.onLoad` to be work together with hx-boost.
27 | HSCollapse.autoInit();
28 | HSOverlay.autoInit();
29 | });
30 |
--------------------------------------------------------------------------------
/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = "testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/main"
8 | cmd = "make dev"
9 | delay = 0
10 | exclude_dir = ["assets", "tmp", "testdata", "node_modules", "data", "bin", "internal/handler/views/assets"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go", ".*_templ.go"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = ""
16 | include_dir = []
17 | include_ext = ["go", "templ", "html", "css", "js"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | rerun = false
24 | rerun_delay = 500
25 | send_interrupt = false
26 | stop_on_error = false
27 |
28 | [color]
29 | app = ""
30 | build = "yellow"
31 | main = "magenta"
32 | runner = "green"
33 | watcher = "cyan"
34 |
35 | [log]
36 | main_only = false
37 | time = false
38 |
39 | [misc]
40 | clean_on_exit = false
41 |
42 | [screen]
43 | clear_on_rebuild = false
44 | keep_scroll = true
45 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 |
8 | jobs:
9 | build:
10 | name: build
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Check out source code
15 | uses: actions/checkout@v5
16 |
17 | - name: Setup bun
18 | uses: oven-sh/setup-bun@v2
19 |
20 | - name: Setup Go
21 | uses: actions/setup-go@v6
22 | with:
23 | go-version: 1.25.x
24 |
25 | - name: Setup templ
26 | run: go install github.com/a-h/templ/cmd/templ@v0.3.924
27 |
28 | - name: Prepare assets
29 | run: make prepare
30 |
31 | - name: Cache Go modules
32 | uses: actions/cache@v3
33 | with:
34 | path: ~/go/pkg/mod
35 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
36 | restore-keys: |
37 | ${{ runner.os }}-go-
38 |
39 | - name: Build Prober
40 | run: make client
41 |
42 | - name: Build Server
43 | run: make server
44 |
--------------------------------------------------------------------------------
/cmd/cmd.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/ditatompel/xmr-remote-nodes/cmd/client"
7 | "github.com/ditatompel/xmr-remote-nodes/internal/config"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | var configFile string
13 |
14 | var Root = &cobra.Command{
15 | Use: "xmr-nodes",
16 | Short: "XMR Nodes",
17 | Version: config.Version,
18 | }
19 |
20 | func Execute() {
21 | err := Root.Execute()
22 | if err != nil {
23 | os.Exit(1)
24 | }
25 | }
26 |
27 | func init() {
28 | cobra.OnInitialize(initConfig)
29 | Root.PersistentFlags().StringVarP(&configFile, "config-file", "c", "", "Default to .env")
30 | Root.AddCommand(client.ProbeCmd)
31 | client.ProbeCmd.Flags().StringP("endpoint", "e", "", "Server endpoint")
32 | client.ProbeCmd.Flags().Bool("no-tor", false, "Do not probe tor nodes")
33 | client.ProbeCmd.Flags().Bool("no-i2p", false, "Do not probe i2p nodes")
34 | }
35 |
36 | func initConfig() {
37 | if configFile != "" {
38 | config.LoadAll(configFile)
39 | return
40 | }
41 | config.LoadAll(".env")
42 | }
43 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ir.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/ip/ip.go:
--------------------------------------------------------------------------------
1 | // Package ip provides IP address related functions
2 | package ip
3 |
4 | import (
5 | "net"
6 | "strings"
7 | )
8 |
9 | // IsIPv6Only returns true if all given IPs are IPv6
10 | func IsIPv6Only(ips []net.IP) bool {
11 | for _, ip := range ips {
12 | if ip.To4() != nil {
13 | return false
14 | }
15 | }
16 | return true
17 | }
18 |
19 | // SliceToString converts []net.IP to a string separated by comma.
20 | // If the separator is empty, it defaults to ",".
21 | func SliceToString(ips []net.IP) string {
22 | r := make([]string, len(ips))
23 | for i, j := range ips {
24 | r[i] = j.String()
25 | }
26 |
27 | return strings.Join(r, ",")
28 | }
29 |
30 | // Add brackets based on whether the given string is IPv6 or not.
31 | // If the input is an IPv6 address, wraps it in square brackets `[ ]`.
32 | // Otherwise, it returns the input string as-is (for domain names or IPv4
33 | // addresses).
34 | func FormatHostname(hostname string) string {
35 | ip := net.ParseIP(hostname)
36 | if ip != nil && ip.To4() == nil {
37 | return "[" + hostname + "]"
38 | }
39 |
40 | return hostname
41 | }
42 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # General Config
2 | # ##############
3 | LOG_LEVEL=INFO # can be DEBUG, INFO, WARN, ERROR
4 |
5 | # Prober config
6 | # #############
7 | SERVER_ENDPOINT="http://127.0.0.1:18901"
8 | API_KEY=
9 | ACCEPT_TOR=false
10 | TOR_SOCKS="127.0.0.1:9050"
11 | ACCEPT_I2P=false
12 | I2P_SOCKS="127.0.0.1:4447"
13 | IPV6_CAPABLE=false
14 |
15 | # Server Config
16 | # #############
17 | APP_URL="https://xmr.ditatompel.com" # URL where user can access the web UI, don't put trailing slash
18 |
19 | # APP_SECRET is random 64-character hex string that give us 32 random bytes.
20 | # For now, this used for ip address salt, but may be useful for another feature
21 | # in the future. You can achieve this using `openssl rand -hex 32`.
22 | APP_SECRET=
23 |
24 | # Fiber Config
25 | APP_PREFORK=false
26 | APP_HOST="127.0.0.1"
27 | APP_PORT=18090
28 | APP_PROXY_HEADER="X-Real-Ip" # `CF-Connecting-IP` if using Cloudflare
29 | APP_ALLOW_ORIGIN="http://localhost:5173,http://127.0.0.1:5173,https://xmr.ditatompel.com"
30 |
31 | #DB settings:
32 | DB_HOST=127.0.0.1
33 | DB_PORT=3306
34 | DB_USER=root
35 | DB_PASSWORD=
36 | DB_NAME=xmr_nodes
37 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 | - htmx
6 | - i2p-support
7 |
8 | pull_request:
9 | name: Test
10 | jobs:
11 | test:
12 | name: test
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout repo
16 | uses: actions/checkout@v5
17 |
18 | - name: Setup bun
19 | uses: oven-sh/setup-bun@v2
20 |
21 | - name: Setup Go
22 | uses: actions/setup-go@v6
23 | with:
24 | go-version: 1.25.x
25 |
26 | - name: Setup templ
27 | run: go install github.com/a-h/templ/cmd/templ@v0.3.924
28 |
29 | - name: Cache Go modules
30 | uses: actions/cache@v3
31 | with:
32 | path: ~/go/pkg/mod
33 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
34 | restore-keys: |
35 | ${{ runner.os }}-go-
36 |
37 | - name: Prepare assets
38 | run: make prepare templ tailwind
39 |
40 | - name: Run lint
41 | uses: golangci/golangci-lint-action@v8
42 | with:
43 | version: v2.4
44 |
45 | - name: Run test
46 | run: make test
47 |
--------------------------------------------------------------------------------
/cmd/server/cron.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "text/tabwriter"
7 | "time"
8 |
9 | "github.com/ditatompel/xmr-remote-nodes/internal/cron"
10 | "github.com/ditatompel/xmr-remote-nodes/internal/database"
11 |
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | var cronCmd = &cobra.Command{
16 | Use: "cron",
17 | Short: "Print cron tasks",
18 | Long: `Print list of regular cron tasks running on the server.`,
19 | Run: func(cmd *cobra.Command, args []string) {
20 | if err := database.ConnectDB(); err != nil {
21 | panic(err)
22 | }
23 | crons, err := cron.New().Crons()
24 | if err != nil {
25 | fmt.Println(err)
26 | return
27 | }
28 | if len(crons) == 0 {
29 | fmt.Println("No crons found")
30 | return
31 | }
32 | w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
33 | fmt.Fprintf(w, "ID\t| Name\t| Run Every\t| Last Run\t| Took Time\n")
34 | for _, cron := range crons {
35 | fmt.Fprintf(w, "%d\t| %s\t| %ds\t| %s\t| %f\n",
36 | cron.ID,
37 | cron.Title,
38 | cron.RunEvery,
39 | time.Unix(cron.LastRun, 0).Format(time.RFC3339),
40 | cron.RunTime,
41 | )
42 | }
43 | w.Flush()
44 | },
45 | }
46 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/np.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/deployment/ansible/deploy-server.example.yml:
--------------------------------------------------------------------------------
1 | ---
2 | - name: Deploy and Restart Services
3 | hosts: all
4 | become: true
5 | tasks:
6 | - name: Stop server systemd daemon
7 | systemd:
8 | name: xmr-nodes-server.service
9 | state: stopped
10 | when: inventory_hostname in groups['server']
11 |
12 | - name: Upload binary file (AMD64)
13 | copy:
14 | src: ../../bin/xmr-nodes-server-linux-amd64
15 | dest: /path/to/remote/server/bin/xmr-nodes-server
16 | Owner: your_user
17 | Group: your_group
18 | mode: 0755
19 | when: inventory_hostname in groups['server'] and ansible_facts['architecture'] == 'x86_64'
20 |
21 | - name: Upload binary file (ARM64)
22 | copy:
23 | src: ../../bin/xmr-nodes-server-linux-arm64
24 | dest: /path/to/remote/server/bin/xmr-nodes-server
25 | Owner: your_user
26 | Group: your_group
27 | mode: 0755
28 | when: inventory_hostname in groups['server'] and ansible_facts['architecture'] == 'aarch64'
29 |
30 | - name: Start systemd daemon
31 | systemd:
32 | name: xmr-nodes-server.service
33 | state: started
34 | when: inventory_hostname in groups['server']
35 |
--------------------------------------------------------------------------------
/internal/handler/routes.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | func (s *fiberServer) Routes() {
4 | s.Get("/", s.homeHandler)
5 | s.Get("/robots.txt", s.robotsTxtHandler)
6 | s.Get("/remote-nodes", s.remoteNodesHandler)
7 | s.Get("/remote-nodes/id/:id", s.nodeHandler)
8 | s.Get("/remote-nodes/ban-list-enabled", s.banListEnabledHandler)
9 | s.Get("/add-node", s.addNodeHandler)
10 | s.Put("/add-node", s.addNodeHandler)
11 |
12 | // This is temporary route to redirect old path to new one. Once search
13 | // engine results updated to the new path, this route should be removed.
14 | s.Get("/remote-nodes/logs", s.redirectLogs)
15 |
16 | // V1 API routes
17 | v1 := s.Group("/api/v1")
18 |
19 | // these routes are public, they don't require a prober api key
20 | v1.Get("/nodes", s.nodesAPI)
21 | v1.Post("/nodes", s.addNodeAPI) // old add node form action endpoint. Deprecated: Use PUT /add-node instead
22 | v1.Get("/nodes/id/:id", s.nodeAPI)
23 | v1.Get("/nodes/logs", s.probeLogsAPI)
24 | v1.Get("/fees", s.netFeesAPI)
25 | v1.Get("/countries", s.countriesAPI)
26 |
27 | // these routes are for prober, they require a prober api key
28 | v1.Get("/job", s.checkProberMW, s.giveJobAPI)
29 | v1.Post("/job", s.checkProberMW, s.processJobAPI)
30 | }
31 |
--------------------------------------------------------------------------------
/deployment/ansible/deploy-prober.example.yml:
--------------------------------------------------------------------------------
1 | ---
2 | - name: Deploy and Restart Prober Timer
3 | hosts: all
4 | become: true
5 | tasks:
6 | - name: Stop prober systemd timer
7 | systemd:
8 | name: xmr-nodes-prober.timer
9 | state: stopped
10 | when: inventory_hostname in groups['prober']
11 |
12 | - name: Upload binary file (AMD64)
13 | copy:
14 | src: ../../bin/xmr-nodes-client-linux-amd64
15 | dest: /path/to/remote/xmr-nodes/bin/xmr-nodes-client
16 | owner: your_user
17 | group: your_group
18 | mode: 0755
19 | when: inventory_hostname in groups['prober'] and ansible_facts['architecture'] == 'x86_64'
20 |
21 | - name: Upload binary file (ARM64)
22 | copy:
23 | src: ../../bin/xmr-nodes-client-linux-arm64
24 | dest: /path/to/remote/xmr-nodes/bin/xmr-nodes-client
25 | owner: your_user
26 | group: your_group
27 | mode: 0755
28 | when: inventory_hostname in groups['prober'] and ansible_facts['architecture'] == 'aarch64'
29 |
30 |
31 | - name: Start systemd timer
32 | systemd:
33 | name: xmr-nodes-prober.timer
34 | state: started
35 | when: inventory_hostname in groups['prober']
36 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ditatompel/xmr-remote-nodes
2 |
3 | go 1.24.0
4 |
5 | toolchain go1.24.1
6 |
7 | require (
8 | github.com/a-h/templ v0.3.924
9 | github.com/go-sql-driver/mysql v1.9.3
10 | github.com/gofiber/fiber/v2 v2.52.9
11 | github.com/google/go-querystring v1.1.0
12 | github.com/google/uuid v1.6.0
13 | github.com/jmoiron/sqlx v1.4.0
14 | github.com/joho/godotenv v1.5.1
15 | github.com/oschwald/geoip2-golang v1.13.0
16 | github.com/spf13/cobra v1.10.1
17 | golang.org/x/net v0.46.0
18 | )
19 |
20 | require (
21 | filippo.io/edwards25519 v1.1.0 // indirect
22 | github.com/andybalholm/brotli v1.1.0 // indirect
23 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
24 | github.com/klauspost/compress v1.17.9 // indirect
25 | github.com/mattn/go-colorable v0.1.13 // indirect
26 | github.com/mattn/go-isatty v0.0.20 // indirect
27 | github.com/mattn/go-runewidth v0.0.16 // indirect
28 | github.com/oschwald/maxminddb-golang v1.13.0 // indirect
29 | github.com/rivo/uniseg v0.2.0 // indirect
30 | github.com/spf13/pflag v1.0.9 // indirect
31 | github.com/valyala/bytebufferpool v1.0.0 // indirect
32 | github.com/valyala/fasthttp v1.51.0 // indirect
33 | github.com/valyala/tcplisten v1.0.0 // indirect
34 | golang.org/x/sys v0.37.0 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/internal/database/mysql.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/ditatompel/xmr-remote-nodes/internal/config"
7 |
8 | _ "github.com/go-sql-driver/mysql"
9 | "github.com/jmoiron/sqlx"
10 | )
11 |
12 | // DB holds the database
13 | type DB struct{ *sqlx.DB }
14 |
15 | // database instance
16 | var defaultDB = &DB{}
17 |
18 | // connect sets the db client of database using configuration
19 | func (db *DB) connect(cfg *config.DB) (err error) {
20 | if defaultDB.DB != nil {
21 | return nil // reuse existing connection if available
22 | }
23 |
24 | dbURI := fmt.Sprintf("%s:%s@(%s:%d)/%s",
25 | cfg.User,
26 | cfg.Password,
27 | cfg.Host,
28 | cfg.Port,
29 | cfg.Name,
30 | )
31 |
32 | db.DB, err = sqlx.Connect("mysql", dbURI)
33 | if err != nil {
34 | return err
35 | }
36 |
37 | // Try to ping database.
38 | if err := db.Ping(); err != nil {
39 | defer db.Close() // close database connection
40 | return fmt.Errorf("can't sent ping to database, %w", err)
41 | }
42 |
43 | return nil
44 | }
45 |
46 | // GetDB returns db instance
47 | func GetDB() *DB {
48 | return defaultDB
49 | }
50 |
51 | // ConnectDB sets the db client of database using default configuration
52 | func ConnectDB() error {
53 | return defaultDB.connect(config.DBCfg())
54 | }
55 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/au.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/hm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/by.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ls.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/tj.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cmd/server/node.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "os"
7 | "strconv"
8 |
9 | "github.com/ditatompel/xmr-remote-nodes/internal/database"
10 | "github.com/ditatompel/xmr-remote-nodes/internal/monero"
11 |
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | var nodeCmd = &cobra.Command{
16 | Use: "node",
17 | Short: "[Server] Administer monitored nodes",
18 | Long: `Command to administer monitored nodes.
19 |
20 | This command should only be run on the server which directly connect to the MySQL database.
21 | `,
22 | Run: func(cmd *cobra.Command, _ []string) {
23 | if err := cmd.Help(); err != nil {
24 | slog.Error(err.Error())
25 | os.Exit(1)
26 | }
27 | },
28 | }
29 |
30 | var deleteNodeCmd = &cobra.Command{
31 | Use: "delete",
32 | Short: "Delete node",
33 | Long: `Delete node identified by ID.
34 |
35 | This command delete node and it's associated probe logs (if exists).
36 |
37 | To find out the node ID, visit frontend UI or from "/api/v1/nodes" endpoint.
38 | `,
39 | Run: func(_ *cobra.Command, _ []string) {
40 | if err := database.ConnectDB(); err != nil {
41 | fmt.Println(err)
42 | return
43 | }
44 | nodeID, err := strconv.Atoi(stringPrompt("Node ID:"))
45 | if err != nil {
46 | fmt.Println("Invalid ID:", err)
47 | return
48 | }
49 |
50 | moneroRepo := monero.New()
51 | err = moneroRepo.Delete(uint(nodeID))
52 | if err != nil {
53 | fmt.Println("Failed to delete node:", err)
54 | return
55 | }
56 |
57 | fmt.Printf("Node ID %d deleted\n", nodeID)
58 | },
59 | }
60 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024, Christian Ditaputratama
2 |
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its contributors
16 | may be used to endorse or promote products derived from this software without
17 | specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/sk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/nz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/vars.go:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | type link struct {
4 | Text string
5 | URI string
6 | }
7 |
8 | // Community reference links that are displayed on the home page
9 | var communityLinks = []link{
10 | {Text: "moneroworld.com", URI: "https://moneroworld.com"},
11 | {Text: "monero.how", URI: "https://www.monero.how"},
12 | {Text: "monero.observer", URI: "https://www.monero.observer"},
13 | {Text: "revuo-xmr.com", URI: "https://revuo-xmr.com"},
14 | {Text: "themonoeromoon.com", URI: "https://www.themoneromoon.com"},
15 | {Text: "monerotopia.com", URI: "https://monerotopia.com"},
16 | {Text: "sethforprivacy.com", URI: "https://sethforprivacy.com"},
17 | }
18 |
19 | type selectOpts struct {
20 | Code int
21 | Text string
22 | }
23 |
24 | // nodeStatuses is a list of status and their text representation in the UI
25 | //
26 | // The "Status" filter select option in the UI is populated from this list.
27 | var nodeStatuses = []selectOpts{
28 | {-1, "ANY"},
29 | {1, "Online"},
30 | {0, "Offline"},
31 | }
32 |
33 | // spyNodeOpts is a list of possible spy node representation in the UI
34 | //
35 | // The "Spy Node" filter select option in the UI is populated from this list.
36 | var spyNodeOpts = []selectOpts{
37 | {-1, "ANY"},
38 | {0, "NO"},
39 | {1, "YES"},
40 | {2, "N/A"},
41 | }
42 |
43 | // refreshIntevals, nettypes, and protocols are used to populate the refresh
44 | // interval, Monero network types, and protocols filter select options in the
45 | // UI
46 | var (
47 | refreshIntevals = []string{"5s", "10s", "30s", "1m"}
48 | nettypes = []string{"mainnet", "stagenet", "testnet"}
49 | protocols = []string{"tor", "i2p", "http", "https"}
50 | )
51 |
--------------------------------------------------------------------------------
/internal/paging/paging.go:
--------------------------------------------------------------------------------
1 | package paging
2 |
3 | import (
4 | "reflect"
5 |
6 | "github.com/google/go-querystring/query"
7 | )
8 |
9 | type Paging struct {
10 | Limit int `url:"limit,omitempty"` // rows per page
11 | Page int `url:"page"`
12 | SortBy string `url:"sort_by,omitempty"`
13 | SortDirection string `url:"sort_direction,omitempty"`
14 |
15 | // Refresh interval
16 | Refresh string `url:"refresh,omitempty"`
17 | }
18 |
19 | // a-h templ helpers
20 | func EncodedQuery(q interface{}, exclude interface{}) string {
21 | arr := reflect.ValueOf(exclude)
22 | v, _ := query.Values(q)
23 |
24 | for i := 0; i < arr.Len(); i++ {
25 | v.Del(arr.Index(i).String())
26 | }
27 |
28 | return v.Encode()
29 | }
30 |
31 | type Pagination struct {
32 | CurrentPage int
33 | TotalPages int
34 | Pages []int
35 | }
36 |
37 | func NewPagination(currentPage, totalPages int) Pagination {
38 | var pages []int
39 | const maxButtons = 5
40 |
41 | if totalPages <= maxButtons {
42 | for i := 1; i <= totalPages; i++ {
43 | pages = append(pages, i)
44 | }
45 | } else {
46 | start := max(1, currentPage-2)
47 | end := min(totalPages, currentPage+2)
48 |
49 | if currentPage <= 3 {
50 | end = maxButtons
51 | } else if currentPage > totalPages-3 {
52 | start = totalPages - (maxButtons - 1)
53 | }
54 |
55 | for i := start; i <= end; i++ {
56 | pages = append(pages, i)
57 | }
58 | if start > 1 {
59 | pages = append([]int{1, -1}, pages...) // -1 indicates ellipsis
60 | }
61 | if end < totalPages {
62 | pages = append(pages, -1, totalPages) // -1 indicates ellipsis
63 | }
64 | }
65 |
66 | return Pagination{
67 | CurrentPage: currentPage,
68 | TotalPages: totalPages,
69 | Pages: pages,
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/ip/ip_test.go:
--------------------------------------------------------------------------------
1 | package ip
2 |
3 | import (
4 | "net"
5 | "testing"
6 | )
7 |
8 | // Single test: go test ./internal/ip -bench TestIsIPv6Only -benchmem -run=^$ -v
9 | func TestIsIPv6Only(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | ips []net.IP
13 | want bool
14 | }{
15 | {
16 | name: "IPv4",
17 | ips: []net.IP{
18 | net.ParseIP("1.1.1.1"),
19 | },
20 | want: false,
21 | },
22 | {
23 | name: "IPv6",
24 | ips: []net.IP{
25 | net.ParseIP("2606:4700::6810:85e5"),
26 | },
27 | want: true,
28 | },
29 | {
30 | name: "IPv6 and IPv4",
31 | ips: []net.IP{
32 | net.ParseIP("1.1.1.1"),
33 | net.ParseIP("2606:4700::6810:84e5"),
34 | },
35 | want: false,
36 | },
37 | }
38 |
39 | for _, tt := range tests {
40 | t.Run(tt.name, func(t *testing.T) {
41 | if got := IsIPv6Only(tt.ips); got != tt.want {
42 | t.Errorf("IsIPv6Only() = %v, want %v", got, tt.want)
43 | }
44 | })
45 | }
46 | }
47 |
48 | // Single test: go test ./internal/ip -bench TestSliceToString -benchmem -run=^$ -v
49 | func TestSliceToString(t *testing.T) {
50 | tests := []struct {
51 | name string
52 | ips []net.IP
53 | want string
54 | }{
55 | {
56 | name: "IPv4",
57 | ips: []net.IP{
58 | net.ParseIP("1.1.1.1"),
59 | },
60 | want: "1.1.1.1",
61 | },
62 | {
63 | name: "IPv6",
64 | ips: []net.IP{
65 | net.ParseIP("2606:4700::6810:85e5"),
66 | },
67 | want: "2606:4700::6810:85e5",
68 | },
69 | {
70 | name: "IPv6 and IPv4",
71 | ips: []net.IP{
72 | net.ParseIP("1.1.1.1"),
73 | net.ParseIP("2606:4700::6810:85e5"),
74 | },
75 | want: "1.1.1.1,2606:4700::6810:85e5",
76 | },
77 | }
78 |
79 | for _, tt := range tests {
80 | t.Run(tt.name, func(t *testing.T) {
81 | if got := SliceToString(tt.ips); got != tt.want {
82 | t.Errorf("SliceToString() = %v, want %v", got, tt.want)
83 | }
84 | })
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: "Release Binaries"
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | build_binaries:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | goos: [linux]
13 | goarch: [amd64, arm64]
14 |
15 | steps:
16 | - name: Check out source code
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup bun
20 | uses: oven-sh/setup-bun@v2
21 |
22 | - name: Setup Go
23 | uses: actions/setup-go@v6
24 | with:
25 | go-version: 1.25.x
26 |
27 | - name: Setup templ
28 | run: go install github.com/a-h/templ/cmd/templ@v0.3.924
29 |
30 | # Need to build the UI here before build the server binary with go-release-action
31 | - name: Prepare assets
32 | run: make prepare templ tailwind
33 |
34 | - name: Build server binary
35 | uses: wangyoucao577/go-release-action@v1
36 | with:
37 | github_token: ${{ secrets.ACTION_TOKEN }}
38 | goos: ${{ matrix.goos }}
39 | goarch: ${{ matrix.goarch }}
40 | overwrite: true
41 | pre_command: export CGO_ENABLED=0
42 | ldflags: -s -w -X github.com/ditatompel/xmr-remote-nodes/internal/config.Version=${{github.ref_name}}
43 | build_flags: -tags server
44 | project_path: .
45 | binary_name: server
46 | extra_files: LICENSE README.md
47 |
48 | - name: Build client binary
49 | uses: wangyoucao577/go-release-action@v1
50 | with:
51 | github_token: ${{ secrets.ACTION_TOKEN }}
52 | goos: ${{ matrix.goos }}
53 | goarch: ${{ matrix.goarch }}
54 | overwrite: true
55 | pre_command: export CGO_ENABLED=0
56 | ldflags: -s -w -X github.com/ditatompel/xmr-remote-nodes/internal/config.Version=${{github.ref_name}}
57 | binary_name: client
58 | project_path: .
59 | extra_files: LICENSE README.md
60 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/si.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ao.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/config/app.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 | "strconv"
7 | )
8 |
9 | var Version string
10 |
11 | type App struct {
12 | // general config
13 | LogLevel string
14 |
15 | // configuration for server
16 | URL string // URL where user can access the web UI, don't put trailing slash
17 | Secret string // random 64-character hex string that give us 32 random bytes
18 |
19 | // fiber specific config
20 | Prefork bool
21 | Host string
22 | Port int
23 | ProxyHeader string
24 | AllowOrigin string
25 |
26 | // configuration for prober (client)
27 | ServerEndpoint string
28 | APIKey string
29 | AcceptTor bool
30 | TorSOCKS string
31 | AcceptI2P bool
32 | I2PSOCKS string
33 | IPv6Capable bool
34 | }
35 |
36 | func init() {
37 | if Version == "" {
38 | Version = "v0.0.0-alpha.0.000000.dev"
39 | }
40 | }
41 |
42 | var app = &App{}
43 |
44 | func AppCfg() *App {
45 | return app
46 | }
47 |
48 | // loads App configuration
49 | func LoadApp() {
50 | // general config
51 | app.LogLevel = os.Getenv("LOG_LEVEL")
52 | switch app.LogLevel {
53 | case "DEBUG":
54 | slog.SetLogLoggerLevel(slog.LevelDebug)
55 | case "ERROR":
56 | slog.SetLogLoggerLevel(slog.LevelError)
57 | case "WARN":
58 | slog.SetLogLoggerLevel(slog.LevelWarn)
59 | default:
60 | slog.SetLogLoggerLevel(slog.LevelInfo)
61 | }
62 |
63 | // server configuration
64 | app.URL = os.Getenv("APP_URL")
65 | app.Secret = os.Getenv("APP_SECRET")
66 |
67 | // fiber specific config
68 | app.Host = os.Getenv("APP_HOST")
69 | app.Port, _ = strconv.Atoi(os.Getenv("APP_PORT"))
70 | app.Prefork, _ = strconv.ParseBool(os.Getenv("APP_PREFORK"))
71 | app.ProxyHeader = os.Getenv("APP_PROXY_HEADER")
72 | app.AllowOrigin = os.Getenv("APP_ALLOW_ORIGIN")
73 |
74 | // prober configuration
75 | app.ServerEndpoint = os.Getenv("SERVER_ENDPOINT")
76 | app.APIKey = os.Getenv("API_KEY")
77 | app.AcceptTor, _ = strconv.ParseBool(os.Getenv("ACCEPT_TOR"))
78 | app.TorSOCKS = os.Getenv("TOR_SOCKS")
79 | app.AcceptI2P, _ = strconv.ParseBool(os.Getenv("ACCEPT_I2P"))
80 | app.I2PSOCKS = os.Getenv("I2P_SOCKS")
81 | app.IPv6Capable, _ = strconv.ParseBool(os.Getenv("IPV6_CAPABLE"))
82 | }
83 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/iq.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/vu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/pg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/monero/ban_list_test.go:
--------------------------------------------------------------------------------
1 | package monero
2 |
3 | import (
4 | "net"
5 | "testing"
6 | )
7 |
8 | // Create test for func isBannedIP(banList []string, ips []net.IP) bool
9 | // Single test:
10 | // go test -race ./internal/monero -run=TestIsBannedIP -v
11 | func TestIsBannedIP(t *testing.T) {
12 | tests := []struct {
13 | name string
14 | banList []string
15 | inputIPs []net.IP
16 | want bool
17 | }{
18 | {
19 | name: "Empty ban list",
20 | banList: []string{},
21 | inputIPs: []net.IP{net.ParseIP("192.168.1.123")},
22 | want: false,
23 | },
24 | {
25 | name: "Exact IP match",
26 | banList: []string{"192.168.1.123"},
27 | inputIPs: []net.IP{net.ParseIP("192.168.1.123")},
28 | want: true,
29 | },
30 | {
31 | name: "IP in CIDR",
32 | banList: []string{"10.0.0.0/8"},
33 | inputIPs: []net.IP{net.ParseIP("10.1.2.3")},
34 | want: true,
35 | },
36 | {
37 | name: "No match",
38 | banList: []string{"192.168.1.0/24", "10.0.0.0/8"},
39 | inputIPs: []net.IP{net.ParseIP("192.168.2.1")},
40 | want: false,
41 | },
42 | {
43 | name: "Multiple IPs, one match",
44 | banList: []string{"10.0.0.0/8", "172.16.0.0/12"},
45 | inputIPs: []net.IP{
46 | net.ParseIP("192.168.1.1"),
47 | net.ParseIP("8.8.8.8"),
48 | net.ParseIP("172.16.5.10"),
49 | },
50 | want: true,
51 | },
52 | {
53 | name: "IPv6 match",
54 | banList: []string{"2001:db8::/32"},
55 | inputIPs: []net.IP{net.ParseIP("2001:db8::1")},
56 | want: true,
57 | },
58 | {
59 | name: "IPv6 no match",
60 | banList: []string{"2001:db8::/32", "10.0.0.0/8", "172.16.0.0/12", "8.8.8.8"},
61 | inputIPs: []net.IP{net.ParseIP("2001:dead::1")},
62 | want: false,
63 | },
64 | }
65 |
66 | for _, tt := range tests {
67 | t.Run(tt.name, func(t *testing.T) {
68 | got := isBannedIP(tt.banList, tt.inputIPs)
69 | if got != tt.want {
70 | t.Errorf("isIPBanned() = %v, want %v", got, tt.want)
71 | }
72 | })
73 | }
74 | }
75 |
76 | // Single bench test:
77 | // go test ./internal/monero -bench IsBannedIP -benchmem -run=^$ -v
78 | func Benchmark_IsBannedIP(b *testing.B) {
79 | banList := []string{
80 | "192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12",
81 | }
82 |
83 | inputIPs := []net.IP{
84 | net.ParseIP("192.168.1.1"),
85 | net.ParseIP("10.0.0.1"),
86 | net.ParseIP("172.16.99.99"),
87 | net.ParseIP("8.8.8.8"),
88 | }
89 |
90 | for i := 0; i < b.N; i++ {
91 | _ = isBannedIP(banList, inputIPs)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/internal/ip/geo/geoip.go:
--------------------------------------------------------------------------------
1 | package geo
2 |
3 | import (
4 | "errors"
5 | "net"
6 |
7 | "github.com/oschwald/geoip2-golang"
8 | )
9 |
10 | // IPInfo represents IP address information from Maxmind's GeoLite2 database
11 | type IPInfo struct {
12 | IP string `json:"ip"`
13 | IsAnonymousProxy bool `json:"is_anonymous_proxy"`
14 | IsSatelliteProvider bool `json:"is_satellite_provider"`
15 | City string `json:"city"`
16 | ContinentName string `json:"continent_name"`
17 | ContinentCode string `json:"continent_code"`
18 | IsInEuropeanUnion bool `json:"is_in_european_union"`
19 | CountryName string `json:"country_name"`
20 | CountryCode string `json:"country_code"`
21 | TimeZone string `json:"timezone"`
22 | Latitude float64 `json:"latitude"`
23 | Longitude float64 `json:"longitude"`
24 | AccuracyRadius uint16 `json:"accuracy_radius"`
25 | ASNOrg string `json:"asn_org"`
26 | ASN uint `json:"asn"`
27 | }
28 |
29 | // Info returns GeoIP information from given IP address
30 | func Info(ipAddr string) (*IPInfo, error) {
31 | ip := net.ParseIP(ipAddr)
32 | if ip == nil {
33 | return nil, errors.New("invalid IP address")
34 | }
35 | dbCity, err := geoip2.Open("./assets/geoip/GeoLite2-City.mmdb")
36 | if err != nil {
37 | return nil, errors.New("cannot open GeoIP City database")
38 | }
39 | defer dbCity.Close()
40 |
41 | dbAsn, err := geoip2.Open("./assets/geoip/GeoLite2-ASN.mmdb")
42 | if err != nil {
43 | return nil, errors.New("cannot open GeoIP ASN database")
44 | }
45 | defer dbAsn.Close()
46 |
47 | city, err := dbCity.City(ip)
48 | if err != nil {
49 | return nil, errors.New("cannot read GeoIP City database")
50 | }
51 |
52 | asn, err := dbAsn.ASN(ip)
53 | if err != nil {
54 | return nil, errors.New("cannot read GeoIP ASN database")
55 | }
56 |
57 | qip := IPInfo{
58 | IP: ipAddr,
59 | IsAnonymousProxy: city.Traits.IsAnonymousProxy,
60 | IsSatelliteProvider: city.Traits.IsSatelliteProvider,
61 | City: city.City.Names["en"],
62 | ContinentName: city.Continent.Names["en"],
63 | ContinentCode: city.Continent.Code,
64 | IsInEuropeanUnion: city.Country.IsInEuropeanUnion,
65 | CountryName: city.Country.Names["en"],
66 | CountryCode: city.Country.IsoCode,
67 | TimeZone: city.Location.TimeZone,
68 | Latitude: city.Location.Latitude,
69 | Longitude: city.Location.Longitude,
70 | AccuracyRadius: city.Location.AccuracyRadius,
71 | ASNOrg: asn.AutonomousSystemOrganization,
72 | ASN: asn.AutonomousSystemNumber,
73 | }
74 |
75 | return &qip, nil
76 | }
77 |
--------------------------------------------------------------------------------
/cmd/server/serve.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "os"
7 | "os/signal"
8 | "syscall"
9 | "time"
10 |
11 | "github.com/ditatompel/xmr-remote-nodes/internal/config"
12 | "github.com/ditatompel/xmr-remote-nodes/internal/cron"
13 | "github.com/ditatompel/xmr-remote-nodes/internal/database"
14 | "github.com/ditatompel/xmr-remote-nodes/internal/handler"
15 | "github.com/ditatompel/xmr-remote-nodes/internal/handler/views"
16 |
17 | "github.com/gofiber/fiber/v2"
18 | "github.com/gofiber/fiber/v2/middleware/cors"
19 | "github.com/gofiber/fiber/v2/middleware/logger"
20 | "github.com/gofiber/fiber/v2/middleware/recover"
21 | "github.com/spf13/cobra"
22 | )
23 |
24 | var serveCmd = &cobra.Command{
25 | Use: "serve",
26 | Short: "Serve the WebUI and APIs",
27 | Long: `This command will run HTTP server for APIs and WebUI.`,
28 | Run: func(_ *cobra.Command, _ []string) {
29 | serve()
30 | },
31 | }
32 |
33 | func serve() {
34 | appCfg := config.AppCfg()
35 | if err := database.ConnectDB(); err != nil {
36 | slog.Error(fmt.Sprintf("[DB] %s", err.Error()))
37 | os.Exit(1)
38 | }
39 |
40 | // signal channel to capture system calls
41 | sigCh := make(chan os.Signal, 1)
42 | signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
43 |
44 | stopCron := make(chan struct{})
45 | if !fiber.IsChild() {
46 | // run db migrations
47 | if err := database.MigrateDb(database.GetDB()); err != nil {
48 | slog.Error(fmt.Sprintf("[DB] %s", err.Error()))
49 | os.Exit(1)
50 | }
51 |
52 | // run cron process
53 | cronRepo := cron.New()
54 | go cronRepo.RunCronProcess(stopCron)
55 | }
56 |
57 | // Define Fiber config & app.
58 | app := handler.NewServer()
59 |
60 | // recover
61 | app.Use(recover.New(recover.Config{EnableStackTrace: true}))
62 |
63 | // logger middleware
64 | if appCfg.LogLevel == "DEBUG" {
65 | app.Use(logger.New(logger.Config{
66 | Format: "${time} DEBUG [HTTP] ${status} - ${latency} ${method} ${path} ${queryParams} ${ip} ${ua}\n",
67 | TimeFormat: "2006/01/02 15:04:05",
68 | }))
69 | }
70 |
71 | app.Use(cors.New(cors.Config{
72 | AllowOrigins: appCfg.AllowOrigin,
73 | AllowHeaders: "Origin, Content-Type, Accept",
74 | AllowCredentials: true,
75 | }))
76 |
77 | app.Use("/assets", views.EmbedAssets())
78 | app.Routes()
79 |
80 | // go routine to capture system calls
81 | go func() {
82 | <-sigCh
83 | close(stopCron) // stop cron goroutine
84 | slog.Info("Shutting down HTTP server...")
85 | _ = app.Shutdown()
86 |
87 | // give time for graceful shutdown
88 | time.Sleep(1 * time.Second)
89 | }()
90 |
91 | // start http server
92 | serverAddr := fmt.Sprintf("%s:%d", appCfg.Host, appCfg.Port)
93 | if err := app.Listen(serverAddr); err != nil {
94 | slog.Error(fmt.Sprintf("[HTTP] Server is not running! error: %v", err))
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/deployment/nginx/vhost.conf:
--------------------------------------------------------------------------------
1 | # Example nginx virtual host config
2 | #
3 | # The directory structure for this Nginx configuration example is following
4 | # my `nginx-kickstart` project. For more information, please refer to
5 | # https://github.com/ditatompel/nginx-kickstart.
6 | #
7 | # NOTE: the `listen http2` directive is not set because it is deprecated since
8 | # Nginx v1.25.x.
9 | upstream xmr_remote_nodes_app {
10 | keepalive 8;
11 | server 127.0.0.1:18901;
12 | }
13 |
14 | server {
15 | if ($host = xmr.example.com) {
16 | return 301 https://$host$request_uri;
17 | } # managed by Certbot
18 |
19 | listen 80;
20 | server_name xmr.example.com;
21 | root /srv/http/default;
22 | access_log off;
23 | location /.well-known/acme-challenge/ { allow all; }
24 | location / { return 301 https://$host$request_uri; }
25 | }
26 |
27 | server {
28 | server_name xmr.example.com;
29 | listen 443 ssl;
30 |
31 | ssl_certificate /etc/nginx/certs/fullchain.pem;
32 | ssl_certificate_key /etc/nginx/certs/privkey.pem;
33 |
34 | # See https://github.com/ditatompel/nginx-kickstart/blob/main/etc/nginx/snippets/ssl-params.conf
35 | include /etc/nginx/snippets/ssl-params.conf;
36 |
37 | error_log /var/log/nginx/xmr.example.com.error.log;
38 |
39 | root /srv/http/default;
40 | index index.html;
41 |
42 | add_header X-Permitted-Cross-Domain-Policies none;
43 | add_header X-Content-Type-Options nosniff;
44 | add_header X-XSS-Protection "1; mode=block";
45 | add_header X-Download-Options noopen;
46 |
47 | # Add your onion URL here if you support it
48 | # add_header Onion-Location http://.onion$request_uri;
49 |
50 | location = /robots.txt {
51 | log_not_found off;
52 | access_log off;
53 | proxy_set_header Connection "";
54 | proxy_http_version 1.1;
55 | proxy_pass http://xmr_remote_nodes_app/robots.txt;
56 | }
57 |
58 | location ~* \.(?:ico|css|js|gif|jpe?g|png|webp|ttf|woff|woff2|svg|eot)$ {
59 | access_log off;
60 | expires max;
61 | add_header Pragma public;
62 | add_header Cache-Control "public";
63 | proxy_set_header Connection "";
64 | proxy_http_version 1.1;
65 | proxy_pass http://xmr_remote_nodes_app;
66 | }
67 |
68 | location / {
69 | proxy_set_header Host $http_host;
70 | proxy_set_header X-Real-IP $remote_addr;
71 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
72 | # For keepalive, the proxy_http_version directive should be set to “1.1”
73 | # and the “Connection” header field should be cleared.
74 | proxy_set_header Connection "";
75 | proxy_http_version 1.1;
76 | proxy_pass http://xmr_remote_nodes_app/;
77 | }
78 | }
79 |
80 | # vim: ft=nginx ts=4 sw=4 et
81 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/mz.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/monero/ban_list.go:
--------------------------------------------------------------------------------
1 | package monero
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "log/slog"
7 | "net"
8 | "net/http"
9 | "net/netip"
10 | )
11 |
12 | // Fetch and store IP addresses from Boog900's ban list to local db
13 | func (r *moneroRepo) FetchBoog900BanList() error {
14 | resp, err := http.Get("https://raw.githubusercontent.com/Boog900/monero-ban-list/main/ban_list.txt")
15 | if err != nil {
16 | slog.Error(fmt.Sprintf("[MRL] Failed to download Boog900's ban list: %s", err))
17 | return err
18 | }
19 | defer resp.Body.Close()
20 |
21 | if resp.StatusCode != 200 {
22 | return fmt.Errorf("[MRL] HTTP request return with status code: %d ", resp.StatusCode)
23 | }
24 |
25 | // turncate tbl_ban_list table
26 | if _, err := r.db.Exec("TRUNCATE TABLE tbl_ban_list"); err != nil {
27 | return err
28 | }
29 |
30 | scanner := bufio.NewScanner(resp.Body)
31 | for scanner.Scan() {
32 | ip := scanner.Text()
33 | _, err := r.db.Exec(`INSERT INTO tbl_ban_list (ip_addr) VALUES (?)`, ip)
34 | if err != nil {
35 | slog.Error(fmt.Sprintf("[MRL] Failed to insert ip: %s", err))
36 | }
37 | }
38 |
39 | if err := scanner.Err(); err != nil {
40 | return err
41 | }
42 |
43 | return nil
44 | }
45 |
46 | // Get list of IP addresses (may contain subnets) from local database
47 | func (r *moneroRepo) banList() ([]string, error) {
48 | var ips []string
49 | rows, err := r.db.Query("SELECT ip_addr FROM tbl_ban_list")
50 | if err != nil {
51 | return ips, err
52 | }
53 | defer rows.Close()
54 |
55 | for rows.Next() {
56 | var ip string
57 | if err := rows.Scan(&ip); err != nil {
58 | return ips, err
59 | }
60 | ips = append(ips, ip)
61 | }
62 |
63 | if err := rows.Err(); err != nil {
64 | slog.Warn(fmt.Sprintf("[MRL] Ban list Iteration error: %s", err))
65 | }
66 |
67 | return ips, err
68 | }
69 |
70 | // Check if the given IP address is on the blacklist
71 | //
72 | // TODO: Use `netip.Addr` for ips from net/netip package instead of `net.IP`.
73 | func isBannedIP(banList []string, ips []net.IP) bool {
74 | var prefixes []netip.Prefix
75 |
76 | for _, entry := range banList {
77 | // Try parsing as prefix first
78 | if prefix, err := netip.ParsePrefix(entry); err == nil {
79 | prefixes = append(prefixes, prefix)
80 | continue
81 | }
82 |
83 | if addr, err := netip.ParseAddr(entry); err == nil {
84 | prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen()))
85 | }
86 | }
87 |
88 | for _, ip := range ips {
89 | // Convert net.IP to netip.Addr
90 | var parsed netip.Addr
91 | if ip4 := ip.To4(); ip4 != nil {
92 | var ip4Arr [4]byte
93 | copy(ip4Arr[:], ip4)
94 | parsed = netip.AddrFrom4(ip4Arr)
95 | } else if ip16 := ip.To16(); ip16 != nil {
96 | var ip16Arr [16]byte
97 | copy(ip16Arr[:], ip16)
98 | parsed = netip.AddrFrom16(ip16Arr)
99 | } else {
100 | continue // skip malformed
101 | }
102 |
103 | for _, prefix := range prefixes {
104 | if prefix.Contains(parsed) {
105 | return true
106 | }
107 | }
108 | }
109 |
110 | return false
111 | }
112 |
--------------------------------------------------------------------------------
/internal/handler/views/partial_navbar.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | templ navbar(pageIdentifier string) {
4 |
54 | }
55 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/cx.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ai.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/src/css/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .link {
6 | @apply text-orange-400 hover:brightness-125;
7 | }
8 | a.external {
9 | @apply link after:content-['_↗'];
10 | }
11 |
12 | /* badge */
13 | .badge {
14 | @apply inline-flex items-center gap-x-1.5 px-2 rounded-md text-sm font-bold text-white;
15 | }
16 |
17 | /* main navbar */
18 | #main-navbar div a {
19 | @apply py-0.5 md:py-3 px-4 md:px-1 border-s-2 md:border-s-0 md:border-b-2 border-transparent text-neutral-400 hover:text-neutral-200 focus:outline-none;
20 | }
21 | #main-navbar div a.active {
22 | @apply py-0.5 md:py-3 px-4 md:px-1 border-s-2 md:border-s-0 md:border-b-2 border-orange-400 font-medium text-neutral-200 focus:outline-none;
23 | }
24 |
25 | /** home page **/
26 | a.btn-link {
27 | @apply py-1 px-3 mt-2 inline-flex items-center gap-x-1 text-sm font-medium rounded-lg border border-orange-500 bg-orange-600 text-white shadow-sm hover:brightness-125;
28 | }
29 | /* my nodes copy input button */
30 | button.copy-input {
31 | @apply px-2 shrink-0 inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-e-md border border-transparent bg-orange-600 text-white hover:brightness-125 focus:outline-none focus:bg-orange-700 disabled:opacity-50 disabled:pointer-events-none;
32 | }
33 |
34 | /* forms */
35 | input.frameless,
36 | select.frameless {
37 | @apply block w-full text-neutral-400 placeholder-neutral-500 bg-transparent border-t-transparent border-b-2 border-x-transparent border-b-neutral-700 text-sm focus:border-t-transparent focus:border-x-transparent focus:border-b-orange-400 focus:ring-0 focus:ring-orange-400 disabled:opacity-50 disabled:pointer-events-none;
38 | }
39 |
40 | /* table */
41 | table.dt {
42 | @apply min-w-full divide-y divide-neutral-700;
43 | }
44 | table.dt thead {
45 | @apply bg-neutral-800;
46 | }
47 | table.dt thead tr th {
48 | @apply px-3 py-3 text-start text-xs font-semibold uppercase text-neutral-200;
49 | }
50 | table.dt tbody {
51 | @apply divide-y divide-neutral-700;
52 | }
53 | table.dt tbody tr {
54 | @apply odd:bg-neutral-900 even:bg-neutral-800;
55 | }
56 | table.dt tbody tr th,
57 | table.dt tbody tr td {
58 | @apply px-3 py-2;
59 | }
60 |
61 | /* pagination */
62 | nav.pagination button.active {
63 | @apply py-1.5 px-2 inline-flex items-center gap-x-2 text-sm font-bold rounded-lg border border-orange-500 bg-orange-500 text-white shadow-sm hover:brightness-125 disabled:opacity-90 disabled:pointer-events-none;
64 | }
65 | nav.pagination button {
66 | @apply py-1.5 px-2 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg bg-neutral-800 border border-neutral-700 text-white shadow-sm hover:brightness-125 disabled:opacity-50 disabled:pointer-events-none;
67 | }
68 |
69 | /* modal */
70 | .modal-container {
71 | @apply w-full max-h-full overflow-hidden flex flex-col border shadow-sm rounded-xl pointer-events-auto bg-neutral-800 border-neutral-700 shadow-neutral-700/70;
72 | }
73 | .modal-container .modal-header {
74 | @apply flex justify-between items-center py-3 px-4 border-b border-neutral-700;
75 | }
76 | .modal-header button.btn-close {
77 | @apply flex justify-center items-center size-7 text-sm font-semibold rounded-full border border-transparent text-white hover:bg-neutral-700;
78 | }
79 | .modal-container .modal-body {
80 | @apply p-4 overflow-y-auto;
81 | }
82 | .modal-container .modal-footer {
83 | @apply flex justify-end items-center gap-x-2 py-3 px-4 border-t border-neutral-700;
84 | }
85 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ug.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/utils/human_readable.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "strconv"
7 | "time"
8 | )
9 |
10 | // TimeSince converts an int64 timestamp to a relative time string
11 | func TimeSince(timestamp int64) string {
12 | var duration time.Duration
13 | var suffix string
14 |
15 | t := time.Unix(timestamp, 0)
16 |
17 | if t.After(time.Now()) {
18 | duration = time.Until(t)
19 | suffix = "from now"
20 | } else {
21 | duration = time.Since(t)
22 | suffix = "ago"
23 | }
24 |
25 | switch {
26 | case duration < time.Minute:
27 | return fmt.Sprintf("%ds %s", int(duration.Seconds()), suffix)
28 | case duration < time.Hour:
29 | return fmt.Sprintf("%dm %s", int(duration.Minutes()), suffix)
30 | case duration < time.Hour*24:
31 | return fmt.Sprintf("%dh %s", int(duration.Hours()), suffix)
32 | case duration < time.Hour*24*7:
33 | return fmt.Sprintf("%dd %s", int(duration.Hours()/24), suffix)
34 | case duration < time.Hour*24*30:
35 | return fmt.Sprintf("%dw %s", int(duration.Hours()/(24*7)), suffix)
36 | default:
37 | months := int(duration.Hours() / (24 * 30))
38 | if months == 1 {
39 | return fmt.Sprintf("1 month %s", suffix)
40 | }
41 | return fmt.Sprintf("%d months %s", months, suffix)
42 | }
43 | }
44 |
45 | // Convert the float to a string, trimming unnecessary zeros
46 | func FormatFloat(f float64) string {
47 | return strconv.FormatFloat(f, 'f', -1, 64)
48 | }
49 |
50 | // Formats bytes as a human-readable string with the specified number of decimal places.
51 | func FormatBytes(bytes, decimals int) string {
52 | if bytes == 0 {
53 | return "0 Bytes"
54 | }
55 |
56 | const k float64 = 1024
57 | sizes := []string{"Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
58 |
59 | i := int(math.Floor(math.Log(float64(bytes)) / math.Log(k)))
60 | dm := decimals
61 | if dm < 0 {
62 | dm = 0
63 | }
64 |
65 | value := float64(bytes) / math.Pow(k, float64(i))
66 | return fmt.Sprintf("%.*f %s", dm, value, sizes[i])
67 | }
68 |
69 | // Formats a hash value (h) into human readable format.
70 | //
71 | // This function was adapted from jtgrassie/monero-pool project.
72 | // Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
73 | //
74 | // Copyright (c) 2018, The Monero Project
75 | func FormatHashes(h float64) string {
76 | switch {
77 | case h < 1e-12:
78 | return "0 H"
79 | case h < 1e-9:
80 | return fmt.Sprintf("%.0f pH", maxPrecision(h*1e12, 0))
81 | case h < 1e-6:
82 | return fmt.Sprintf("%.0f nH", maxPrecision(h*1e9, 0))
83 | case h < 1e-3:
84 | return fmt.Sprintf("%.0f μH", maxPrecision(h*1e6, 0))
85 | case h < 1:
86 | return fmt.Sprintf("%.0f mH", maxPrecision(h*1e3, 0))
87 | case h < 1e3:
88 | return fmt.Sprintf("%.0f H", h)
89 | case h < 1e6:
90 | return fmt.Sprintf("%.2f KH", maxPrecision(h*1e-3, 2))
91 | case h < 1e9:
92 | return fmt.Sprintf("%.2f MH", maxPrecision(h*1e-6, 2))
93 | default:
94 | return fmt.Sprintf("%.2f GH", maxPrecision(h*1e-9, 2))
95 | }
96 | }
97 |
98 | // Returns a number with a maximum precision.
99 | //
100 | // This function was adapted from jtgrassie/monero-pool project.
101 | // Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
102 | //
103 | // Copyright (c) 2018, The Monero Project
104 | func maxPrecision(n float64, p int) float64 {
105 | format := "%." + strconv.Itoa(p) + "f"
106 | result, _ := strconv.ParseFloat(fmt.Sprintf(format, n), 64)
107 | return result
108 | }
109 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/ki.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/gi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/br.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/internal/monero/prober.go:
--------------------------------------------------------------------------------
1 | package monero
2 |
3 | import (
4 | "fmt"
5 | "slices"
6 | "strings"
7 |
8 | "github.com/ditatompel/xmr-remote-nodes/internal/database"
9 |
10 | "github.com/google/uuid"
11 | )
12 |
13 | const ProberAPIKey = "X-Prober-Api-Key" // HTTP header key
14 |
15 | type proberRepo struct {
16 | db *database.DB
17 | }
18 |
19 | type Prober struct {
20 | ID int64 `json:"id" db:"id"`
21 | Name string `json:"name" db:"name"`
22 | APIKey uuid.UUID `json:"api_key" db:"api_key"`
23 | LastSubmitTS int64 `json:"last_submit_ts" db:"last_submit_ts"`
24 | }
25 |
26 | // Initializes a new ProberRepository
27 | //
28 | // NOTE: This "prober" is different with "probe" which is used to fetch a new job
29 | func NewProber() *proberRepo {
30 | return &proberRepo{db: database.GetDB()}
31 | }
32 |
33 | // Add a new prober machine
34 | func (r *proberRepo) Add(name string) (Prober, error) {
35 | apiKey := uuid.New()
36 | query := `
37 | INSERT INTO tbl_prober (
38 | name,
39 | api_key,
40 | last_submit_ts
41 | ) VALUES (
42 | ?,
43 | ?,
44 | ?
45 | )`
46 | _, err := r.db.Exec(query, name, apiKey, 0)
47 | if err != nil {
48 | return Prober{}, err
49 | }
50 | return Prober{Name: name, APIKey: apiKey}, nil
51 | }
52 |
53 | // Edit an existing prober
54 | func (r *proberRepo) Edit(id int, name string) error {
55 | query := `UPDATE tbl_prober SET name = ? WHERE id = ?`
56 | res, err := r.db.Exec(query, name, id)
57 | if err != nil {
58 | return err
59 | }
60 | row, err := res.RowsAffected()
61 | if err != nil {
62 | return err
63 | }
64 | if row == 0 {
65 | return fmt.Errorf("no rows affected")
66 | }
67 | return err
68 | }
69 |
70 | // Delete an existing prober
71 | func (r *proberRepo) Delete(id int) error {
72 | res, err := r.db.Exec(`DELETE FROM tbl_prober WHERE id = ?`, id)
73 | if err != nil {
74 | return err
75 | }
76 | row, err := res.RowsAffected()
77 | if err != nil {
78 | return err
79 | }
80 | if row == 0 {
81 | return fmt.Errorf("no rows affected")
82 | }
83 | return err
84 | }
85 |
86 | type QueryProbers struct {
87 | Search string
88 | SortBy string
89 | SortDirection string
90 | }
91 |
92 | func (q QueryProbers) toSQL() (args []interface{}, where, sortBy, sortDirection string) {
93 | wq := []string{}
94 | if q.Search != "" {
95 | wq = append(wq, "(name LIKE ? OR api_key LIKE ?)")
96 | args = append(args, "%"+q.Search+"%", "%"+q.Search+"%")
97 | }
98 | if len(wq) > 0 {
99 | where = "WHERE " + strings.Join(wq, " AND ")
100 | }
101 |
102 | as := []string{"id", "last_submit_ts"}
103 | sortBy = "last_submit_ts"
104 | if slices.Contains(as, q.SortBy) {
105 | sortBy = q.SortBy
106 | }
107 | sortDirection = "DESC"
108 | if q.SortDirection == "asc" {
109 | sortDirection = "ASC"
110 | }
111 |
112 | return args, where, sortBy, sortDirection
113 | }
114 |
115 | func (r *proberRepo) Probers(q QueryProbers) ([]Prober, error) {
116 | args, where, sortBy, sortDirection := q.toSQL()
117 |
118 | var probers []Prober
119 |
120 | query := fmt.Sprintf(`
121 | SELECT
122 | id,
123 | name,
124 | api_key,
125 | last_submit_ts
126 | FROM
127 | tbl_prober
128 | %s -- where clause if any
129 | ORDER BY %s %s`, where, sortBy, sortDirection)
130 |
131 | row, err := r.db.Query(query, args...)
132 | if err != nil {
133 | return probers, err
134 | }
135 | defer row.Close()
136 |
137 | for row.Next() {
138 | var p Prober
139 | err = row.Scan(&p.ID, &p.Name, &p.APIKey, &p.LastSubmitTS)
140 | if err != nil {
141 | return probers, err
142 | }
143 | probers = append(probers, p)
144 | }
145 | return probers, nil
146 | }
147 |
148 | func (r *proberRepo) CheckAPI(key string) (Prober, error) {
149 | var p Prober
150 | query := `
151 | SELECT
152 | id,
153 | name,
154 | api_key,
155 | last_submit_ts
156 | FROM
157 | tbl_prober
158 | WHERE
159 | api_key = ?
160 | LIMIT 1`
161 | err := r.db.QueryRow(query, key).Scan(&p.ID, &p.Name, &p.APIKey, &p.LastSubmitTS)
162 | return p, err
163 | }
164 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | BINARY_NAME = xmr-nodes
2 |
3 | # These build are modified version of rclone's Makefile
4 | # https://github.com/rclone/rclone/blob/master/Makefile
5 | VERSION := $(shell cat VERSION)
6 | BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
7 | # Last tag on this branch (eg. v1.0.0)
8 | LAST_TAG := $(shell git describe --tags --abbrev=0)
9 | # Tag of the current commit, if any. If this is not "" then we are building a release
10 | RELEASE_TAG := $(shell git tag -l --points-at HEAD)
11 | # If we are working on a release, override branch to main
12 | ifdef RELEASE_TAG
13 | BRANCH := main
14 | LAST_TAG := $(shell git describe --abbrev=0 --tags $(VERSION)^)
15 | endif
16 | # Make version suffix -beta.NNNN.CCCCCCCC (N=Commit number, C=Commit)
17 | VERSION_SUFFIX := -beta.$(shell git rev-list --count HEAD).$(shell git show --no-patch --no-notes --pretty='%h' HEAD)
18 | TAG_BRANCH := .$(BRANCH)
19 | # If building HEAD or master then unset TAG_BRANCH
20 | ifeq ($(subst HEAD,,$(subst main,,$(BRANCH))),)
21 | TAG_BRANCH :=
22 | endif
23 | # TAG is current version + commit number + commit + branch
24 | TAG := $(VERSION)$(VERSION_SUFFIX)$(TAG_BRANCH)
25 | ifdef RELEASE_TAG
26 | TAG := $(RELEASE_TAG)
27 | endif
28 | # end modified rclone's Makefile
29 |
30 | BUILD_LDFLAGS := -s -w -X github.com/ditatompel/xmr-remote-nodes/internal/config.Version=$(TAG)
31 |
32 | # This called from air cmd (see .air.toml)
33 | .PHONY: dev
34 | dev: templ tailwind
35 | go build -ldflags="$(BUILD_LDFLAGS)" -tags server -o ./tmp/main .
36 |
37 | .PHONY: build
38 | build: client server
39 |
40 | .PHONY: client
41 | client:
42 | CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build \
43 | -ldflags="$(BUILD_LDFLAGS)" \
44 | -o bin/${BINARY_NAME}-client-linux-amd64
45 | CGO_ENABLED=0 GOARCH=arm64 GOOS=linux go build \
46 | -ldflags="$(BUILD_LDFLAGS)" \
47 | -o bin/${BINARY_NAME}-client-linux-arm64
48 |
49 | .PHONY: server
50 | server: prepare templ tailwind
51 | CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build \
52 | -ldflags="$(BUILD_LDFLAGS)" -tags server \
53 | -o bin/${BINARY_NAME}-server-linux-amd64
54 | CGO_ENABLED=0 GOARCH=arm64 GOOS=linux go build \
55 | -ldflags="$(BUILD_LDFLAGS)" -tags server \
56 | -o bin/${BINARY_NAME}-server-linux-arm64
57 |
58 | .PHONY: prepare
59 | prepare:
60 | bun install --frozen-lockfile
61 | @mkdir -p ./internal/handler/views/assets/js
62 | cp ./node_modules/htmx.org/dist/htmx.min.js ./internal/handler/views/assets/js
63 | cp ./node_modules/clipboard/dist/clipboard.min.js ./internal/handler/views/assets/js
64 |
65 | # Compile template
66 | .PHONY: templ
67 | templ:
68 | @echo "Compiling Templ template..."
69 | templ generate
70 |
71 | .PHONY: tailwind
72 | tailwind:
73 | mkdir -p ./internal/handler/views/assets/css
74 | @echo "Compiling TailwindCSS..."
75 | bun tailwindcss -i ./internal/handler/views/src/css/main.css \
76 | -o ./internal/handler/views/assets/css/main.min.css \
77 | -c ./tailwind.config.js \
78 | --minify
79 | bun build ./internal/handler/views/src/js/main.js --minify \
80 | --outfile ./internal/handler/views/assets/js/main.min.js
81 |
82 | .PHONY: clean
83 | clean:
84 | go clean
85 | rm -rfv ./bin
86 | rm -rfv ./tmp/main
87 | rm -rf ./internal/handler/views/*_templ.go
88 | rm -rf ./internal/handler/views/assets/css/
89 |
90 | .PHONY: lint
91 | lint:
92 | golangci-lint run ./...
93 |
94 | .PHONY: test
95 | test:
96 | go test -race -cover ./...
97 |
98 | .PHONY: bench
99 | bench:
100 | go test ./... -bench=. -benchmem -run=^#
101 |
102 | # Deploying new binary file to server and probers host
103 | # The deploy-* command doesn't build the binary file, so you need to run `make build` first.
104 | # And make sure the inventory and deploy-*.yml file is properly configured.
105 | .PHONY: deploy-server
106 | deploy-server:
107 | ansible-playbook -i ./deployment/ansible/inventory.ini \
108 | -l server ./deployment/ansible/deploy-server.yml -K
109 |
110 | .PHONY: deploy-prober
111 | deploy-prober:
112 | ansible-playbook -i ./deployment/ansible/inventory.ini \
113 | -l prober ./deployment/ansible/deploy-prober.yml -K
114 |
--------------------------------------------------------------------------------
/internal/handler/views/assets/img/cf/xk.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------