Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check public RPC bind addresses
run: python -m unittest discover --start-directory tests --verbose
- uses: pre-commit/action@v3.0.1
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ origin.key
# Chain-spec generation intermediates
**/spec-plain.json
**/spec-customized.json

# Python test artifacts
__pycache__/
*.py[cod]
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ cp /path/to/origin.key origin.key
docker compose up -d # builds the Caddy image from ../../common on first run
```

Caddy proxies HTTPS/WSS on your domain to the node on `localhost:9944`.
Caddy proxies HTTPS/WSS on your domain to the node on `localhost:9944`. The
node RPC binds to loopback (`--rpc-external` is intentionally omitted), leaving
Caddy as the only network-facing RPC entry point.

### TLS

Expand Down
6 changes: 3 additions & 3 deletions mainnet/rpc/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ P2P_PORT=30333
# both read this one variable, so they cannot drift apart.
#
# Internal only: public traffic arrives on 443 at RPC_DOMAIN and Caddy forwards
# it here, so changing this does NOT change the public endpoint. Keep it
# firewalled (`ufw deny <port>`) — clients must go through Caddy, which is what
# applies the rate limits.
# it here, so changing this does NOT change the public endpoint. The node binds
# to loopback because --rpc-external is omitted; keep the firewall deny as
# defense in depth so clients go through Caddy and its rate limits.
RPC_PORT=9944

# With host networking this binds on the public NIC — firewall it to the
Expand Down
4 changes: 3 additions & 1 deletion mainnet/rpc/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# Same shape as testnet/rpc, pointed at the mainnet image and chain spec: the
# node behind Caddy for TLS, `--rpc-methods Safe` because the endpoint is
# public, archive pruning so historical queries answer.
# Caddy shares the host network and reaches the node over loopback; keep
# --rpc-external absent so the RPC port cannot bypass Caddy if the firewall
# is misconfigured.
#
# The image tag differs on purpose (`mainnet-latest`): a stable `vX.Y.Z` tag
# publishes it, a prerelease never does, so an rc can never reach mainnet by
Expand All @@ -27,7 +30,6 @@ services:
${TELEMETRY_URL---telemetry-url "wss://telemetry.orbinum.io/submit/ 0"}
--rpc-port ${RPC_PORT:-9944}
--rpc-cors all
--rpc-external
--rpc-methods Safe
--rpc-max-connections 5000
--state-pruning archive
Expand Down
6 changes: 3 additions & 3 deletions testnet/rpc/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ P2P_PORT=30333
# both read this one variable, so they cannot drift apart.
#
# Internal only: public traffic arrives on 443 at RPC_DOMAIN and Caddy forwards
# it here, so changing this does NOT change the public endpoint. Keep it
# firewalled (`ufw deny <port>`) — clients must go through Caddy, which is what
# applies the rate limits.
# it here, so changing this does NOT change the public endpoint. The node binds
# to loopback because --rpc-external is omitted; keep the firewall deny as
# defense in depth so clients go through Caddy and its rate limits.
RPC_PORT=9944

# With host networking this binds on the public NIC — firewall it to the
Expand Down
5 changes: 3 additions & 2 deletions testnet/rpc/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
# What the public endpoint (rpc-1, rpc-2, …) serves: wallets, the explorer and
# anyone querying the chain. Caddy terminates HTTPS/WSS in front of it with a
# Cloudflare Origin Certificate, so the node itself speaks plain HTTP on
# RPC_PORT and is never exposed directly.
# RPC_PORT and is never exposed directly. Keep --rpc-external absent: Caddy
# shares the host network and reaches the node over loopback, so firewall
# mistakes cannot expose an RPC endpoint that bypasses Caddy's rate limits.
#
# `--rpc-methods Safe`: a public endpoint must not offer author_rotateKeys,
# offchain storage or the other unsafe namespaces. Archive on both axes
Expand Down Expand Up @@ -32,7 +34,6 @@ services:
${TELEMETRY_URL---telemetry-url "wss://telemetry.orbinum.io/submit/ 0"}
--rpc-port ${RPC_PORT:-9944}
--rpc-cors all
--rpc-external
--rpc-methods Safe
--rpc-max-connections 5000
--in-peers 64
Expand Down
36 changes: 36 additions & 0 deletions tests/test_rpc_loopback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Guard the public RPC stacks against bypassing their Caddy proxy."""

from pathlib import Path
import re
import unittest


REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
RPC_COMPOSE_FILES = (
Path("testnet/rpc/docker-compose.yml"),
Path("mainnet/rpc/docker-compose.yml"),
)
EXTERNAL_RPC_FLAG = re.compile(r"(?m)^\s+--(?:unsafe-)?rpc-external(?:\s|$)")


class PublicRpcLoopbackTests(unittest.TestCase):
def test_rpc_nodes_do_not_bind_all_interfaces(self) -> None:
for relative_path in RPC_COMPOSE_FILES:
with self.subTest(network=relative_path.parts[0]):
compose_file = REPOSITORY_ROOT / relative_path
compose = compose_file.read_text(encoding="utf-8")
service_start = compose.index(" orbinum-rpc-node:\n")
service_end = compose.find("\n caddy:\n", service_start)
self.assertNotEqual(service_end, -1, "expected a Caddy service")
rpc_service = compose[service_start:service_end]

self.assertRegex(rpc_service, r"(?m)^ network_mode: host$")
self.assertNotRegex(
rpc_service,
EXTERNAL_RPC_FLAG,
"public RPC must stay on loopback so it cannot bypass Caddy",
)


if __name__ == "__main__":
unittest.main()