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
2 changes: 2 additions & 0 deletions cmd/workflow/simulate/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings)
h.log.Debug().Msgf("RPC not provided for %s; skipping", chainName)
continue
}
h.log.Debug().Msgf("Using RPC for %s: %s", chainName, redactURL(rpcURL))

c, err := ethclient.Dial(rpcURL)
if err != nil {
Expand Down Expand Up @@ -190,6 +191,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings)
}

// Dial the RPC
h.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, redactURL(ec.RPCURL))
c, err := ethclient.Dial(ec.RPCURL)
if err != nil {
return Inputs{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err)
Expand Down
27 changes: 27 additions & 0 deletions cmd/workflow/simulate/simulator_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"

"github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -113,6 +115,31 @@ func parseChainSelectorFromTriggerID(id string) (uint64, bool) {
return v, true
}

// redactURL returns a version of the URL with path segments and query parameters
// masked to avoid leaking secrets that may have been resolved from environment variables.
// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***".
func redactURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return "***"
}
// Mask the last path segment (most common location for API keys)
u.Path = strings.TrimRight(u.Path, "/")
if u.Path != "" && u.Path != "/" {
parts := strings.Split(u.Path, "/")
if len(parts) > 1 {
parts[len(parts)-1] = "***"
}
u.RawPath = ""
u.Path = strings.Join(parts, "/")
}
// Remove query params entirely
u.RawQuery = ""
u.Fragment = ""
// Use Opaque to avoid re-encoding the path
return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)
}

// runRPCHealthCheck runs connectivity check against every configured client.
// experimentalForwarders keys identify experimental chains (not in chain-selectors).
func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalForwarders map[uint64]common.Address) error {
Expand Down
33 changes: 33 additions & 0 deletions internal/settings/envresolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package settings

import (
"fmt"
"os"
"regexp"
)

// envVarPattern matches ${VAR_NAME} references in strings.
var envVarPattern = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)

// ResolveEnvVars replaces all ${VAR_NAME} references in s with their
// corresponding environment variable values. It returns an error if any
// referenced variable is not set.
func ResolveEnvVars(s string) (string, error) {
var resolveErr error
result := envVarPattern.ReplaceAllStringFunc(s, func(match string) string {
if resolveErr != nil {
return match
}
varName := envVarPattern.FindStringSubmatch(match)[1]
val, ok := os.LookupEnv(varName)
if !ok {
resolveErr = fmt.Errorf("environment variable %q referenced in URL is not set; add it to your .env file or export it in your shell", varName)
return match
}
return val
})
if resolveErr != nil {
return "", resolveErr
}
return result, nil
}
60 changes: 60 additions & 0 deletions internal/settings/envresolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package settings

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveEnvVars(t *testing.T) {
t.Run("plain URL without vars returned unchanged", func(t *testing.T) {
t.Parallel()
result, err := ResolveEnvVars("https://rpc.example.com/v1/abc123")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/v1/abc123", result)
})

t.Run("single var at end of URL resolves", func(t *testing.T) {
t.Setenv("TEST_RPC_KEY", "my-secret-key")
result, err := ResolveEnvVars("https://rpc.example.com/${TEST_RPC_KEY}")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/my-secret-key", result)
})

t.Run("multiple vars resolve", func(t *testing.T) {
t.Setenv("TEST_HOST", "rpc.example.com")
t.Setenv("TEST_KEY", "abc123")
result, err := ResolveEnvVars("https://${TEST_HOST}/v1/${TEST_KEY}")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/v1/abc123", result)
})

t.Run("var in middle of URL resolves", func(t *testing.T) {
t.Setenv("TEST_MID_VAR", "segment")
result, err := ResolveEnvVars("https://rpc.example.com/${TEST_MID_VAR}/endpoint")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/segment/endpoint", result)
})

t.Run("missing env var returns error", func(t *testing.T) {
_, err := ResolveEnvVars("https://rpc.example.com/${ENVRESOLVE_TEST_MISSING_VAR}")
require.Error(t, err)
assert.Contains(t, err.Error(), `environment variable "ENVRESOLVE_TEST_MISSING_VAR"`)
assert.Contains(t, err.Error(), "not set")
})

t.Run("empty env var value resolves to empty", func(t *testing.T) {
t.Setenv("TEST_EMPTY_VAR", "")
result, err := ResolveEnvVars("https://rpc.example.com/${TEST_EMPTY_VAR}")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/", result)
})

t.Run("dollar var without braces is not resolved", func(t *testing.T) {
t.Setenv("TEST_NO_BRACES", "value")
result, err := ResolveEnvVars("https://rpc.example.com/$TEST_NO_BRACES")
require.NoError(t, err)
assert.Equal(t, "https://rpc.example.com/$TEST_NO_BRACES", result)
})
}
15 changes: 14 additions & 1 deletion internal/settings/settings_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ func GetRpcUrlSettings(v *viper.Viper, chainName string) (string, error) {

for _, rpc := range rpcs {
if rpc.ChainName == chainName {
return rpc.Url, nil
resolved, resolveErr := ResolveEnvVars(rpc.Url)
if resolveErr != nil {
return "", fmt.Errorf("rpc url for chain %q: %w", chainName, resolveErr)
}
return resolved, nil
}
}

Expand All @@ -87,6 +91,15 @@ func GetExperimentalChains(v *viper.Viper) ([]ExperimentalChain, error) {
return nil, fmt.Errorf("failed to unmarshal experimental-chains: %w", err)
}

for i := range chains {
resolved, resolveErr := ResolveEnvVars(chains[i].RPCURL)
if resolveErr != nil {
return nil, fmt.Errorf("experimental chain rpc-url (selector %d): %w",
chains[i].ChainSelector, resolveErr)
}
chains[i].RPCURL = resolved
}

return chains, nil
}

Expand Down
5 changes: 5 additions & 0 deletions internal/settings/template/.env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@
###############################################################################
# Ethereum private key or 1Password reference (e.g. op://vault/item/field)
CRE_ETH_PRIVATE_KEY={{EthPrivateKey}}

# RPC secret keys — referenced in project.yaml via ${VAR_NAME} syntax.
# Example:
# CRE_SECRET_RPC_SEPOLIA=my-secret-api-key
# CRE_SECRET_RPC_MAINNET=my-other-api-key
7 changes: 7 additions & 0 deletions internal/settings/template/project.yaml.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
# - chain-name: ethereum-mainnet # Required if your workflow interacts with this chain
# url: "<select your own rpc url>"
#
# RPC URLs support ${VAR_NAME} syntax to reference environment variables.
# This keeps secrets out of project.yaml (which is committed to git).
# Variables are resolved from your .env file or exported shell variables.
# Example:
# - chain-name: ethereum-testnet-sepolia
# url: https://rpc.example.com/${CRE_SECRET_RPC_SEPOLIA}
#
# Experimental chains (automatically used by the simulator when present):
# Use this for chains not yet in official chain-selectors (e.g., hackathons, new chain integrations).
# In your workflow, reference the chain as evm:ChainSelector:<chain-selector>@1.0.0
Expand Down
9 changes: 9 additions & 0 deletions internal/settings/workflow_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ func loadWorkflowSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Com
logger.Debug().Msgf("rpcs settings not found in target %q", target)
}

for i := range workflowSettings.RPCs {
resolved, err := ResolveEnvVars(workflowSettings.RPCs[i].Url)
if err != nil {
return WorkflowSettings{}, fmt.Errorf("rpc url for chain %q: %w",
workflowSettings.RPCs[i].ChainName, err)
}
workflowSettings.RPCs[i].Url = resolved
}

if registryChainName != "" {
if err := validateDeploymentRPC(&workflowSettings, registryChainName); err != nil {
return WorkflowSettings{}, errors.Wrap(err, "for target "+target)
Expand Down
Loading