├── 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 | Coding according to the official construction in "Constitution of the Kingdom of Nepal, Article 5, Shedule 1", adopted in November 1990 -------------------------------------------------------------------------------- /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 |
5 | 53 |
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 | --------------------------------------------------------------------------------