From 5d71174bc2b031ea8f391706226ffd628ac80f5d Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 11 Jul 2026 20:36:53 -0500 Subject: [PATCH] feat(api): accept IPv6 in api_allow_from + dual-stack bind (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an IPv6-primary LAN the stack (pithead-prod/gouda) resolves to IPv6 only, while the rig APIs bind IPv4 — so enabling the control path (#236) couldn't pin api_allow_from to the stack and fell back to a broad LAN /24. Now: - parse_config accepts an IPv6 address/CIDR (charset+colon+prefix guard, same injection-safety posture as the IPv4 octet check) and sets API_ALLOW_FAMILY=ip|ip6. - install_api_firewall renders vs 74.244.47.217 per family in the dual-family inet table. - api-server.py + control-server.py bind AF_INET6 (dual-stack via IPV6_V6ONLY=0) when the bind address is v6, so :: actually serves. 19 new assertions (IPv4+IPv6 parse + families, malformed/injection rejects, ip6 saddr rendering); IPv6 bind smoke-verified on ::1. Docs updated. 1239 passed / 0 failed, lint + lint-md green. Closes #243 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 +++++++++ docs/configuration.md | 2 +- rigforge.sh | 23 ++++++++++++++++++----- tests/run.sh | 34 +++++++++++++++++++++++++++++----- util/api-server.py | 16 +++++++++++++++- util/control-server.py | 16 +++++++++++++++- 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6736537..6d3eff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index f0eed44..c06d2eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `/worker` instead. | diff --git a/rigforge.sh b/rigforge.sh index 4bb9371..6f4d183 100755 --- a/rigforge.sh +++ b/rigforge.sh @@ -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 @@ -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 } } diff --git a/tests/run.sh b/tests/run.sh index 72f9b7d..c37b5d6 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -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. @@ -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 diff --git a/util/api-server.py b/util/api-server.py index 0ccc0be..bc4fa1c 100644 --- a/util/api-server.py +++ b/util/api-server.py @@ -15,6 +15,7 @@ import hmac import json import os +import socket import sys from http.server import BaseHTTPRequestHandler, HTTPServer @@ -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() diff --git a/util/control-server.py b/util/control-server.py index fd20b15..f16b323 100644 --- a/util/control-server.py +++ b/util/control-server.py @@ -21,6 +21,7 @@ import hmac import json import os +import socket import sys from http.server import BaseHTTPRequestHandler, HTTPServer @@ -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()