Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ All notable changes to RigForge are documented here. The format is based on

## [Unreleased]

### Added

- **IPv6 `api_allow_from` (#243).** The API firewall scoping now accepts an IPv6 address/CIDR
(rendering an `ip6 saddr` rule in the `inet rigforge` table) as well as IPv4, and the sister/control
servers bind dual-stack when `api_bind`/`control_bind` is set to `::`. On an IPv6-primary LAN you
can now pin the writable control path to the stack's v6 source instead of falling back to a broad
LAN CIDR. The per-family charset stays the injection guard; malformed addresses and a bad prefix
hard-error.

### Fixed

- **rig-lock survives a non-root reserve (#242).** The shared `rig_lock` helper now opens
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Pithead.
| `control_port` | `8082` | Control path port (rejects 8080 and the `api_port`). |
| `control_bind` | `"0.0.0.0"` | Control path listen address. Pair with `api_allow_from` (required) to pin who may write. |
| `miner_user` | `""` *(root)* | Run the miner as this dedicated non-root system user (created at setup, nologin). RigForge applies the CPU's MSR preset root-side before start; on families without a known preset the ~10-15% MSR boost is skipped — which is why this ships opt-in. Lowering privilege changes nothing else: HugePages come from the boot reservation, tune/doctor/apply all keep working. |
| `api_allow_from` | `""` *(off)* | Restrict the read-only API port(s) — `:8080`, plus `:8081` when the sister API is on — to a single IPv4 address or CIDR (plus loopback), via an own nftables table. SSH and mining are never touched. Needs `nft`; Linux-only. Layers under the Pithead contract, which is unchanged. |
| `api_allow_from` | `""` *(off)* | Restrict the API port(s) — `:8080`, `:8081` when the sister API is on, `:8082` when the control path is on — to a single IPv4 **or IPv6** address/CIDR (plus loopback), via an own nftables table (renders `ip saddr` or `ip6 saddr` per family). SSH and mining are never touched. For IPv6 to be reachable-and-scoped, set `api_bind`/`control_bind` to `::` (the servers then bind dual-stack). Needs `nft`; Linux-only. |
| `ACCESS_TOKEN` | `""` *(open)* | Optional bearer token for the XMRig HTTP API. Unset (default) leaves the read-only API open, which matches Pithead's default no-auth probe. Set a value to require a `Bearer` token, then match it on the dashboard (`workers.api_auth: token` + `workers.api_token`, or `name` if you set it to the rig name). See [Pithead Integration](pithead-integration.md). |
| `DONATION` | `1` | XMRig donate level, an integer 0–100 (percent). Patched into the build (`donate.h`) and written to the generated config, so it must be a valid integer or setup fails fast. **Lowering it below the level the current binary was compiled with needs a rebuild** — XMRig clamps the config value to its compiled floor (and autosaves the clamped value), so `apply` alone cannot lower it. Raising it works with a plain `apply`. |
| `HOME_DIR` | `DYNAMIC_HOME` | Where worker files live. `DYNAMIC_HOME` puts them in `data/worker` inside the repo; set an absolute path to use `<path>/worker` instead. |
Expand Down
23 changes: 18 additions & 5 deletions rigforge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -416,18 +416,31 @@ parse_config() {
ACCESS_TOKEN=$(jq -r '.ACCESS_TOKEN // empty' "$CONFIG_JSON")

# Opt-in firewall scoping (#142): restrict the read-only API port(s) to one source + loopback.
# Empty (default) = RigForge manages no firewall. IPv4/CIDR only — the Pithead contract and the
# fleet are IPv4 LAN; the strict regex is also the injection guard (the value reaches an nft file).
# Empty (default) = RigForge manages no firewall. Accepts IPv4 or IPv6 address/CIDR (#243): the
# strict per-family charset is the injection guard (the value reaches an nft file), and
# API_ALLOW_FAMILY (ip|ip6) picks the nft match keyword.
API_ALLOW_FROM=$(jq -r '.api_allow_from // empty' "$CONFIG_JSON")
API_ALLOW_FAMILY=ip
if [ -n "$API_ALLOW_FROM" ]; then
if [[ "$API_ALLOW_FROM" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then
local _a _b _c _d _p
IFS='./' read -r _a _b _c _d _p <<<"$API_ALLOW_FROM"
if [ "$_a" -gt 255 ] || [ "$_b" -gt 255 ] || [ "$_c" -gt 255 ] || [ "$_d" -gt 255 ] || [ "${_p:-0}" -gt 32 ]; then
error "api_allow_from must be a valid IPv4 address or CIDR (e.g. 192.168.1.10 or 192.168.1.0/24); got: '$API_ALLOW_FROM'."
error "api_allow_from must be a valid IPv4/IPv6 address or CIDR (e.g. 192.168.1.0/24 or fd00::/64); got: '$API_ALLOW_FROM'."
fi
API_ALLOW_FAMILY=ip
elif [[ "$API_ALLOW_FROM" =~ ^[0-9A-Fa-f:]+(/[0-9]{1,3})?$ ]] && [[ "$API_ALLOW_FROM" == *:*:* ]]; then
# IPv6/CIDR: hex-and-colons charset (the injection guard) + >=2 colons + a 0-128 prefix.
# nft rejects a truly malformed address at load, so this is the guard, not a full RFC
# validator — same posture as the IPv4 octet check above.
local _pfx=""
case "$API_ALLOW_FROM" in */*) _pfx="${API_ALLOW_FROM##*/}" ;; esac
if [ -n "$_pfx" ] && { ! [[ "$_pfx" =~ ^[0-9]+$ ]] || [ "$_pfx" -gt 128 ]; }; then
error "api_allow_from IPv6 prefix must be 0-128 (got: '$API_ALLOW_FROM')."
fi
API_ALLOW_FAMILY=ip6
else
error "api_allow_from must be an IPv4 address or CIDR (e.g. 192.168.1.10 or 192.168.1.0/24); got: '$API_ALLOW_FROM'."
error "api_allow_from must be an IPv4/IPv6 address or CIDR (e.g. 192.168.1.0/24 or fd00::/64); got: '$API_ALLOW_FROM'."
fi
fi
# When set, the token is sent as an HTTP Authorization header, so keep it to safe, header-clean
Expand Down Expand Up @@ -1112,7 +1125,7 @@ table inet rigforge {
chain api {
type filter hook input priority filter; policy accept;
tcp dport { $ports } iifname "lo" accept
tcp dport { $ports } ip saddr $API_ALLOW_FROM accept
tcp dport { $ports } ${API_ALLOW_FAMILY:-ip} saddr $API_ALLOW_FROM accept
tcp dport { $ports } drop
}
}
Expand Down
34 changes: 29 additions & 5 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4663,16 +4663,23 @@ c="$(mkconf api_bbad "{ $POOL, \"api_bind\": \"not an ip!\" }")"
parse_rc "$c" "$ROOT"
assert_rc "malformed api_bind rejected" "$?" "1"

# #142: api_allow_from — valid IPv4/CIDR parse; bad octets, prefix, IPv6, hostname, and a shell
# metachar all hard-error (the value reaches an nft file, so the regex is the injection guard too).
# #142/#243: api_allow_from — valid IPv4 OR IPv6 address/CIDR parse (with the right nft family);
# bad octets, prefixes, hostnames, and shell metachars all hard-error (the value reaches an nft
# file, so the per-family charset is the injection guard too).
for good in "192.168.1.10" "10.0.0.0/8"; do
c="$(mkconf "af_ok_$RANDOM" "{ $POOL, \"api_allow_from\": \"$good\" }")"
assert_eq "api_allow_from '$good' parses (#142)" "$(parse_and_print "$c" "$ROOT" API_ALLOW_FROM)" "$good"
assert_eq "api_allow_from '$good' parses IPv4 (#142)" "$(parse_and_print "$c" "$ROOT" API_ALLOW_FROM)" "$good"
assert_eq "api_allow_from '$good' -> family ip (#243)" "$(parse_and_print "$c" "$ROOT" API_ALLOW_FAMILY)" "ip"
done
for bad in "256.1.1.1" "1.2.3.4/33" "fe80::1" "gouda.lan" "1.2.3.4; rm -rf /"; do
for good6 in "fd00::/64" "2605:59c8::5" "fe80::1" "::1"; do
c="$(mkconf "af6_ok_$RANDOM" "{ $POOL, \"api_allow_from\": \"$good6\" }")"
assert_eq "api_allow_from '$good6' parses IPv6 (#243)" "$(parse_and_print "$c" "$ROOT" API_ALLOW_FROM)" "$good6"
assert_eq "api_allow_from '$good6' -> family ip6 (#243)" "$(parse_and_print "$c" "$ROOT" API_ALLOW_FAMILY)" "ip6"
done
for bad in "256.1.1.1" "1.2.3.4/33" "fd00::/200" "xyz::1" "gouda.lan" "1.2.3.4; rm -rf /" "fd00::/64 accept; drop"; do
c="$(mkconf "af_bad_$RANDOM" "{ $POOL, \"api_allow_from\": \"$bad\" }")"
parse_rc "$c" "$ROOT"
assert_rc "api_allow_from '$bad' rejected (#142)" "$?" "1"
assert_rc "api_allow_from '$bad' rejected (#142/#243)" "$?" "1"
done

# #140: miner_user — valid name parses; root and malformed names hard-error; absent = empty.
Expand Down Expand Up @@ -4718,6 +4725,23 @@ assert_contains "firewall: sister-API port joins the set when enabled (#142)" "$
run_fw "" disabled >/dev/null
assert_eq "firewall: file removed when cleared (#142)" "$([ -f "$FW/api-firewall.nft" ] && echo y || echo n)" "n"
assert_contains "firewall: table destroyed on teardown (#142)" "$(cat "$FW/calls.log")" "[nft] destroy table inet rigforge"
# #243: an IPv6 api_allow_from renders an `ip6 saddr` rule (the inet table carries both families).
run_fw6() {
(
source "$SCRIPT"
OS_TYPE=Linux
WORKER_ROOT="$FW"
API_ALLOW_FROM="$1"
API_ALLOW_FAMILY="$2"
API_MODE=disabled
API_PORT=8081
set +e
PATH="$STUBS:$PATH" CALL_LOG="$FW/calls.log" install_api_firewall >/dev/null 2>&1
cat "$FW/api-firewall.nft" 2>/dev/null
)
}
assert_contains "firewall: IPv6 source renders ip6 saddr (#243)" "$(run_fw6 "fd00::/64" ip6)" "ip6 saddr fd00::/64 accept"
assert_contains "firewall: IPv4 source still renders ip saddr (#243)" "$(run_fw6 "10.0.0.9" ip)" "ip saddr 10.0.0.9 accept"
# nft missing while enabled -> hard error (never silently leave the port open thinking it's guarded).
NONFT="$(mktemp -d "$SANDBOX/nonft.XXXXXX")"
for cmd in sudo tee; do cp "$STUBS/$cmd" "$NONFT/" 2>/dev/null || true; done
Expand Down
16 changes: 15 additions & 1 deletion util/api-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import hmac
import json
import os
import socket
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

Expand Down Expand Up @@ -85,4 +86,17 @@ def _read_only(self):
# single-threaded is that one held-open connection would block everyone (slowloris), so cap
# how long a request may take to arrive.
Handler.timeout = 10
HTTPServer((bind, port), Handler).serve_forever()
if ":" in bind: # IPv6 bind (bind = :: or a v6 addr, #243); dual-stack so IPv4 clients still reach it
class _V6(HTTPServer):
address_family = socket.AF_INET6

def server_bind(self):
try:
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
except OSError:
pass
HTTPServer.server_bind(self)

_V6((bind, port), Handler).serve_forever()
else:
HTTPServer((bind, port), Handler).serve_forever()
16 changes: 15 additions & 1 deletion util/control-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import hmac
import json
import os
import socket
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

Expand Down Expand Up @@ -160,4 +161,17 @@ def _read_only(self):
# Single-threaded like the read server: staging is microseconds, concurrency would only add ways
# to race the spool. Cap request-arrival time so one held-open connection can't wedge it.
Handler.timeout = 10
HTTPServer((bind, port), Handler).serve_forever()
if ":" in bind: # IPv6 bind (bind = :: or a v6 addr, #243); dual-stack so IPv4 clients still reach it
class _V6(HTTPServer):
address_family = socket.AF_INET6

def server_bind(self):
try:
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
except OSError:
pass
HTTPServer.server_bind(self)

_V6((bind, port), Handler).serve_forever()
else:
HTTPServer((bind, port), Handler).serve_forever()