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
13 changes: 7 additions & 6 deletions Makefile.devnet
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ devnet-update-scripts:

.PHONY: devnet-new-1120
.PHONY: devnet-upgrade-1110 devnet-upgrade-1111 devnet-upgrade-1120 devnet-upgrade-1201
.PHONY: devnet-evm-upgrade
.PHONY: devnet-evm-upgrade devnet-upgrade-1202

# Upgrade a running devnet to a pre-downloaded lumera version.
# Expects devnet/bin-<VERSION>/ to already contain the binaries.
Expand Down Expand Up @@ -701,8 +701,9 @@ devnet-upgrade-1201:
@$(MAKE) devnet-refresh-bin
@cd devnet/scripts && ./upgrade.sh v1.20.1 auto-height ../bin

# v1.20.2 — same locally-built-binary pattern as devnet-upgrade-1201, because
# v1.20.2 has no published release to pre-download. Drives the coordinated
# v1.20.2 — same locally-built-binary pattern as devnet-upgrade-1201. The
# on-chain plan remains v1.20.2, but the superseding binary must report v1.20.3.
# Drives the coordinated
# governance halt + binary swap, which is MANDATORY for this upgrade: v1.20.2
# changes evmigration DeliverTx outcomes (PrevSupernodeAccounts append vs
# rewrite, canonical ownership resolution, Everlight SNDistState move), so a
Expand All @@ -714,7 +715,7 @@ devnet-upgrade-1201:
# mainnet-shaped 1.12.0 -> 1.20.2 full EVM bring-up + add-only store mount
devnet-upgrade-1202:
@$(MAKE) devnet-refresh-bin
@cd devnet/scripts && ./upgrade.sh v1.20.2 auto-height ../bin
@cd devnet/scripts && ./upgrade.sh v1.20.2 auto-height ../bin v1.20.3

devnet-new-1120:
@$(MAKE) devnet-new-version VERSION=v1.12.0
Expand All @@ -726,7 +727,7 @@ devnet-evm-upgrade:
@echo "Logging to $(DEVNET_EVM_UPGRADE_LOG)"
@bash -c 'set -euo pipefail; { \
BASE_VERSION=v1.12.0; \
EVM_VERSION=v1.20.1; \
EVM_VERSION=v1.20.2; \
echo "==> Stage: install $$BASE_VERSION devnet"; \
if ! $(MAKE) devnet-down; then \
echo "ERROR: stage install $$BASE_VERSION devnet failed during devnet-down" >&2; \
Expand Down Expand Up @@ -768,7 +769,7 @@ devnet-evm-upgrade:
exit 1; \
fi; \
echo "==> Stage: upgrade to $$EVM_VERSION"; \
if ! $(MAKE) devnet-upgrade-1201; then \
if ! $(MAKE) devnet-upgrade-1202; then \
echo "ERROR: stage upgrade to $$EVM_VERSION failed" >&2; \
exit 1; \
fi; \
Expand Down
134 changes: 127 additions & 7 deletions cmd/lumera/cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"fmt"
"io"
"net"
"strconv"
"strings"
"syscall"

tmcmd "github.com/cometbft/cometbft/cmd/cometbft/commands"
cmttypes "github.com/cometbft/cometbft/types"
Expand Down Expand Up @@ -134,7 +137,35 @@ func wrapJSONRPCAliasStartPreRun(startCmd *cobra.Command) {
return nil
}

internalAddr, err := reserveLoopbackAddr()
excludedAddrs := []string{
v.GetString("json-rpc.ws-address"),
v.GetString("json-rpc.metrics-address"),
v.GetString("evm.geth-metrics-address"),
v.GetString("api.address"),
v.GetString("grpc.address"),
v.GetString("lumera.json-rpc-ratelimit.proxy-address"),
}
if cometConfig := serverCtx.Config; cometConfig != nil {
excludedAddrs = append(excludedAddrs,
cometConfig.ProxyApp,
cometConfig.PrivValidatorListenAddr,
)
if cometConfig.RPC != nil {
excludedAddrs = append(excludedAddrs,
cometConfig.RPC.ListenAddress,
cometConfig.RPC.GRPCListenAddress,
cometConfig.RPC.PprofListenAddress,
)
}
if cometConfig.P2P != nil {
excludedAddrs = append(excludedAddrs, cometConfig.P2P.ListenAddress)
}
if cometConfig.Instrumentation != nil {
excludedAddrs = append(excludedAddrs, cometConfig.Instrumentation.PrometheusListenAddr)
}
}

internalAddr, err := reserveLoopbackAddr(publicAddr, excludedAddrs...)
if err != nil {
return err
}
Expand All @@ -146,16 +177,105 @@ func wrapJSONRPCAliasStartPreRun(startCmd *cobra.Command) {
}
}

func reserveLoopbackAddr() (string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
func reserveLoopbackAddr(publicAddr string, excludedAddrs ...string) (string, error) {
Comment thread
akobrin1 marked this conversation as resolved.
internalAddr, err := loopbackAddrForPublic(publicAddr)
if err != nil {
return "", err
}

_, primaryPortText, err := net.SplitHostPort(internalAddr)
if err != nil {
return "", err
}
addr := ln.Addr().String()
if closeErr := ln.Close(); closeErr != nil {
return "", closeErr
primaryPort, err := strconv.Atoi(primaryPortText)
if err != nil {
return "", err
}

excludedPorts := make(map[int]struct{}, len(excludedAddrs)+1)
if publicPort, ok := portFromListenAddr(publicAddr); ok {
excludedPorts[publicPort] = struct{}{}
}
for _, addr := range excludedAddrs {
if port, ok := portFromListenAddr(addr); ok {
excludedPorts[port] = struct{}{}
}
}

// Verify a deterministic candidate is currently available. Closing it
// before the native JSON-RPC server binds still leaves a small
// external-process race, but the deterministic primary prevents sibling
// lumerad processes with distinct public ports from selecting the same
// upstream. If another service owns that primary, walk a deterministic
// permutation of the unprivileged range instead of making an otherwise
// valid public listener unusable. The relatively prime step keeps nearby
// public ports from immediately falling back onto each other's primaries.
const fallbackProbeStep = 7919
for attempt := 0; attempt < unprivilegedPortCount; attempt++ {
candidatePort := firstUnprivilegedPort +
((primaryPort-firstUnprivilegedPort)+(attempt*fallbackProbeStep))%unprivilegedPortCount
// The alias proxy and the daemon's other servers bind later in startup,
// so never consume one of their configured ports as the native HTTP
// server's upstream.
if _, excluded := excludedPorts[candidatePort]; excluded {
continue
}

candidateAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(candidatePort))
ln, listenErr := net.Listen("tcp", candidateAddr)
if listenErr != nil {
if errors.Is(listenErr, syscall.EADDRINUSE) {
continue
}
return "", fmt.Errorf("reserve internal JSON-RPC address %s: %w", candidateAddr, listenErr)
}
if closeErr := ln.Close(); closeErr != nil {
return "", closeErr
}
return candidateAddr, nil
}

return "", fmt.Errorf("no unprivileged internal JSON-RPC port available for %s", publicAddr)
}

func portFromListenAddr(addr string) (int, bool) {
addr = strings.TrimSpace(addr)
if _, remainder, hasScheme := strings.Cut(addr, "://"); hasScheme {
addr = remainder
}
_, portText, err := net.SplitHostPort(addr)
if err != nil {
return 0, false
}
return addr, nil
port, err := strconv.Atoi(portText)
if err != nil || port < 1 || port > 65535 {
return 0, false
}
return port, true
}

const (
firstUnprivilegedPort = 1024
lastUnprivilegedPort = 65535
unprivilegedPortCount = lastUnprivilegedPort - firstUnprivilegedPort + 1
)

func loopbackAddrForPublic(publicAddr string) (string, error) {
_, portText, err := net.SplitHostPort(publicAddr)
if err != nil {
return "", fmt.Errorf("parse public JSON-RPC address %q: %w", publicAddr, err)
}
publicPort, err := strconv.Atoi(portText)
if err != nil || publicPort < 1 || publicPort > 65535 {
return "", fmt.Errorf("invalid public JSON-RPC port %q", portText)
}

// Rotate within the unprivileged TCP port range. Integration fixtures use
// ephemeral public ports, so rotating across the full 1..65535 range could
// map them below 1024 and fail for non-root processes.
internalPort := firstUnprivilegedPort +
((publicPort - 1 + 32768) % unprivilegedPortCount)
return net.JoinHostPort("127.0.0.1", strconv.Itoa(internalPort)), nil
}

func addModuleInitFlags(startCmd *cobra.Command) {
Expand Down
127 changes: 127 additions & 0 deletions cmd/lumera/cmd/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package cmd

import (
"net"
"strconv"
"testing"
)

func TestLoopbackAddrForPublicIsStableAndUnique(t *testing.T) {
t.Parallel()

tests := []struct {
public string
want string
}{
{public: "127.0.0.1:8545", want: "127.0.0.1:42336"},
{public: "0.0.0.0:8645", want: "127.0.0.1:42436"},
{public: "[::]:8745", want: "127.0.0.1:42536"},
{public: "localhost:32768", want: "127.0.0.1:2047"},
{public: "127.0.0.1:32863", want: "127.0.0.1:2142"},
}

seen := make(map[string]string, len(tests))
for _, tc := range tests {
t.Run(tc.public, func(t *testing.T) {
got, err := loopbackAddrForPublic(tc.public)
if err != nil {
t.Fatalf("loopbackAddrForPublic(%q): %v", tc.public, err)
}
if got != tc.want {
t.Fatalf("loopbackAddrForPublic(%q) = %q, want %q", tc.public, got, tc.want)
}
if previous, exists := seen[got]; exists {
t.Fatalf("public addresses %q and %q mapped to the same internal address %q", previous, tc.public, got)
}
seen[got] = tc.public
})
}
}

func TestLoopbackAddrForPublicRejectsInvalidAddress(t *testing.T) {
t.Parallel()

for _, publicAddr := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536"} {
if _, err := loopbackAddrForPublic(publicAddr); err == nil {
t.Fatalf("loopbackAddrForPublic(%q) unexpectedly succeeded", publicAddr)
}
}
}

func TestReserveLoopbackAddrFallsBackWhenPrimaryIsOccupied(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on occupied primary: %v", err)
}
t.Cleanup(func() { _ = occupied.Close() })

occupiedPort := occupied.Addr().(*net.TCPAddr).Port
publicAddr := publicAddrForInternalPort(t, occupiedPort)
got, err := reserveLoopbackAddr(publicAddr)
if err != nil {
t.Fatalf("reserveLoopbackAddr(%q): %v", publicAddr, err)
}
if got == occupied.Addr().String() {
t.Fatalf("reserveLoopbackAddr(%q) returned occupied primary %q", publicAddr, got)
}

_, portText, err := net.SplitHostPort(got)
if err != nil {
t.Fatalf("parse fallback address %q: %v", got, err)
}
port, err := strconv.Atoi(portText)
if err != nil || port < firstUnprivilegedPort || port > lastUnprivilegedPort {
t.Fatalf("fallback address %q is not unprivileged", got)
}

probe, err := net.Listen("tcp", got)
if err != nil {
t.Fatalf("fallback address %q is not available: %v", got, err)
}
_ = probe.Close()
}

func TestReserveLoopbackAddrSkipsLaterListenerPorts(t *testing.T) {
const publicAddr = "0.0.0.0:39267" // primary candidate is default WS port 8546
primary, err := loopbackAddrForPublic(publicAddr)
if err != nil {
t.Fatalf("loopbackAddrForPublic(%q): %v", publicAddr, err)
}
if primary != "127.0.0.1:8546" {
t.Fatalf("test precondition: primary = %q, want default WS address", primary)
}

for _, excludedAddr := range []string{
"127.0.0.1:8546",
"tcp://127.0.0.1:8546",
} {
t.Run(excludedAddr, func(t *testing.T) {
got, err := reserveLoopbackAddr(publicAddr, excludedAddr)
if err != nil {
t.Fatalf("reserveLoopbackAddr(%q): %v", publicAddr, err)
}
if got == "127.0.0.1:8546" {
t.Fatalf("reserveLoopbackAddr(%q) selected excluded listener %q", publicAddr, got)
}
})
}
}

func publicAddrForInternalPort(t *testing.T, internalPort int) string {
t.Helper()

want := net.JoinHostPort("127.0.0.1", strconv.Itoa(internalPort))
for publicPort := firstUnprivilegedPort; publicPort <= lastUnprivilegedPort; publicPort++ {
publicAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(publicPort))
got, err := loopbackAddrForPublic(publicAddr)
if err != nil {
t.Fatalf("loopbackAddrForPublic(%q): %v", publicAddr, err)
}
if got == want {
return publicAddr
}
}

t.Fatalf("no public port maps to internal port %d", internalPort)
return ""
}
8 changes: 0 additions & 8 deletions cmd/lumera/cmd/config_migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,6 @@ func TestNeedsConfigMigration_DisabledMempool(t *testing.T) {
// start with a legacy pre-EVM app.toml, run the migrator, and confirm both
// the disk file and in-memory Viper contain the correct EVM config.
func TestMigrateAppConfig_LegacyTomlOnDisk(t *testing.T) {
t.Parallel()

// Create a temp directory with a minimal legacy app.toml (no EVM sections).
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, "config")
Expand Down Expand Up @@ -193,8 +191,6 @@ max-txs = 3000
}

func TestMigrateAppConfig_FullyMigratedNegativeMaxTxsTriggersRepair(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, "config")
require.NoError(t, os.MkdirAll(configDir, 0o755))
Expand Down Expand Up @@ -245,8 +241,6 @@ certificate-path = ""
}

func TestMigrateAppConfig_LegacyNegativeMaxTxsUsesNetworkDefault(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
chainID string
Expand All @@ -262,8 +256,6 @@ func TestMigrateAppConfig_LegacyNegativeMaxTxsUsesNetworkDefault(t *testing.T) {
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, "config")
require.NoError(t, os.MkdirAll(configDir, 0o755))
Expand Down
4 changes: 0 additions & 4 deletions cmd/lumera/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ func TestNewRootCmd_DoesNotPanic(t *testing.T) {
// TestNewRootCmdStartWiresEVMFlags verifies `start` command includes Cosmos EVM
// server flags required by JSON-RPC and indexer startup path.
func TestNewRootCmdStartWiresEVMFlags(t *testing.T) {
t.Parallel()

rootCmd := NewRootCmd()
startCmd := mustFindSubcommand(t, rootCmd, "start")

Expand All @@ -39,8 +37,6 @@ func TestNewRootCmdStartWiresEVMFlags(t *testing.T) {
// TestNewRootCmdDefaultKeyTypeOverridden verifies recursive default overrides
// set EthSecp256k1 key type across key-management and testnet commands.
func TestNewRootCmdDefaultKeyTypeOverridden(t *testing.T) {
t.Parallel()

rootCmd := NewRootCmd()
expectedAlgo := string(evmhd.EthSecp256k1Type)

Expand Down
Loading
Loading