// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package wasm import ( "fmt" "net/url" "time" envoy_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_wasm_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/wasm/v3" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/envoyextensions/extensioncommon" "github.com/hashicorp/go-multierror" "github.com/mitchellh/mapstructure" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/wrapperspb" ) // wasmConfig defines the configuration for a Wasm Envoy extension. type wasmConfig struct { // Protocol is the type of Wasm filter to apply, "tcp" or "http". Protocol string // ProxyType identifies the type of Envoy proxy that this extension applies to. // The extension will only be configured for proxies that match this type and // will be ignored for all other proxy types. ProxyType api.ServiceKind // ListenerType identifies the listener type the filter will be applied to. ListenerType string // PluginConfig holds the configuration for the Wasm plugin. PluginConfig pluginConfig } // pluginConfig defines a Wasm plugin configuration. type pluginConfig struct { // Name is the unique name for the filter in a VM. For use in identifying the // filter if multiple filters are handled by the same VmID and RootID. // Also used for logging/debugging. Name string // RootID is a unique ID for a set of filters in a VM which will share a // RootContext and Contexts if applicable (e.g. a Wasm HttpFilter and a Wasm AccessLog). // All filters with the same RootID and VmID will share Context(s). RootID string // VmConfig is the configuration for starting or finding the Wasm VM that the // filter will run in. VmConfig vmConfig // Configuration holds the configuration that will be encoded as bytes and passed to // the plugin on startup (proxy_on_configure). Configuration string // CapabilityRestrictionConfiguration controls the Wasm ABI capabilities available // to the filter. CapabilityRestrictionConfiguration capabilityRestrictionConfiguration // failOpen controls the behavior when a runtime error occurs during filter // processing. // // If set to false runtime errors will result in a failed request. // For TCP filters, the connection will be closed. For HTTP filters a 503 // status is returned. // // If set to true, a runtime error will result in the filter being bypassed. failOpen bool } // vmConfig defines a Wasm VM configuration. type vmConfig struct { // VmID is an ID which will be used along with a hash of the Wasm code to // determine which VM will be used for the plugin. All plugins which use // the same VmID and code will use the same VM. May be left blank. VmID string // Runtime is the Wasm runtime type, one of: v8, wasmtime, wamr, or wavm. Runtime string // Code references the Wasm code that will run in the filter. Code dataSource // Configuration holds the configuration that will be encoded as bytes and // passed to the plugin during VM startup (proxy_on_vm_start). Configuration string // EnvironmentVariables specifies environment variables to be injected to // this VM which will be available through WASI’s environ_get and // environ_get_sizes system calls. EnvironmentVariables environmentVariables } // dataSource defines a local or remote location where Wasm code will be loaded from. type dataSource struct { // Local supports loading files from a local volume. Local localDataSource // Remote supports loading files from a remote server. Remote remoteDataSource } // environmentVariables defines the environment variables that will be made available // to the Wasm filter. type environmentVariables struct { // HostEnvKeys holds the keys of Envoy’s environment variables exposed to this VM. // If a key exists in Envoy’s environment variables, then that key-value pair will // be injected into the Wasm VM. If a key does not exist, it will be ignored. HostEnvKeys []string // KeyValues is a list of key-value pairs to be injected to this VM in the form of "KEY=VALUE". KeyValues map[string]string } // localDataSource defines a file from a local file system. type localDataSource struct { // Filename is the path to the Wasm file on the local file system. Filename string } // remoteDataSource defines a file from a remote file server. type remoteDataSource struct { // HttpURI HttpURI httpURI // SHA256 of the remote file. Used to validate the remote file. SHA256 string // RetryPolicy determines how retries are handled. RetryPolicy retryPolicy } // httpURI defines a remote file using an HTTP URI. type httpURI struct { // Service is the upstream service the Wasm plugin will be fetched from. Service api.CompoundServiceName // URI is the location of the Wasm file on the upstream service. URI string // Timeout sets the maximum duration that a response can take. Timeout string timeout time.Duration } // retryPolicy defines how to handle retries when fetching remote files. type retryPolicy struct { // RetryBackOff holds parameters that control retry backoff strategy. RetryBackOff retryBackoff // NumRetries specifies the allowed number of retries. NumRetries int } // retryBackoff holds parameters that control retry backoff strategy. type retryBackoff struct { // BaseInterval is the base interval to be used for the next back off // computation. It should be greater than zero and less than or equal // to MaxInterval. BaseInterval string // MaxInterval is the maximum interval between retries. MaxInterval string baseInterval time.Duration maxInterval time.Duration } // capabilityRestrictionConfiguration controls Wasm capabilities available to modules. type capabilityRestrictionConfiguration struct { // AllowedCapabilities specifies the Wasm capabilities which will be allowed. // Capabilities are mapped by name. The value which each capability maps to is // currently ignored and should be left empty. AllowedCapabilities map[string]any } // newWasmConfig creates a filterConfig from the given args. // It starts with the default wasm configuration and merges in the config // from the given args. func newWasmConfig(args map[string]any) (*wasmConfig, error) { cfg := &wasmConfig{} if err := mapstructure.Decode(args, cfg); err != nil { return cfg, err } cfg.normalize() return cfg, nil } func (p *pluginConfig) asyncDataSource(rtCfg *extensioncommon.RuntimeConfig) (*envoy_core_v3.AsyncDataSource, error) { // Local data source if filename := p.VmConfig.Code.Local.Filename; filename != "" { return &envoy_core_v3.AsyncDataSource{ Specifier: &envoy_core_v3.AsyncDataSource_Local{ Local: &envoy_core_v3.DataSource{ Specifier: &envoy_core_v3.DataSource_Filename{ Filename: filename, }, }, }, }, nil } // Remote data source // For a remote file, ensure there is an upstream cluster for the host specified in the URL. // Envoy requires an explicit cluster in order to perform the DNS lookup required to actually // fetch the data from the upstream source. remote := &p.VmConfig.Code.Remote clusterSNI := "" for service, upstream := range rtCfg.Upstreams { if service == remote.HttpURI.Service { for sni := range upstream.SNI { clusterSNI = sni break } } } if clusterSNI == "" { return nil, fmt.Errorf("no upstream found for remote service %q", remote.HttpURI.Service.Name) } d := time.Second if remote.HttpURI.timeout > 0 { d = remote.HttpURI.timeout } timeout := &durationpb.Duration{Seconds: int64(d.Seconds())} return &envoy_core_v3.AsyncDataSource{ Specifier: &envoy_core_v3.AsyncDataSource_Remote{ Remote: &envoy_core_v3.RemoteDataSource{ Sha256: remote.SHA256, HttpUri: &envoy_core_v3.HttpUri{ Uri: remote.HttpURI.URI, HttpUpstreamType: &envoy_core_v3.HttpUri_Cluster{ Cluster: clusterSNI, }, Timeout: timeout, }, RetryPolicy: p.retryPolicy(), }, }, }, nil } func (p *pluginConfig) capConfig() *envoy_wasm_v3.CapabilityRestrictionConfig { if len(p.CapabilityRestrictionConfiguration.AllowedCapabilities) == 0 { return nil } allowedCaps := make(map[string]*envoy_wasm_v3.SanitizationConfig) for key := range p.CapabilityRestrictionConfiguration.AllowedCapabilities { allowedCaps[key] = &envoy_wasm_v3.SanitizationConfig{} } return &envoy_wasm_v3.CapabilityRestrictionConfig{ AllowedCapabilities: allowedCaps, } } func (p *pluginConfig) envoyPluginConfig(rtCfg *extensioncommon.RuntimeConfig) (*envoy_wasm_v3.PluginConfig, error) { var err error var pluginCfgData, vmCfgData *anypb.Any if p.Configuration != "" { pluginCfgData, err = anypb.New(wrapperspb.String(p.Configuration)) if err != nil { return nil, fmt.Errorf("failed to encode Wasm plugin configuration: %w", err) } } if p.VmConfig.Configuration != "" { vmCfgData, err = anypb.New(wrapperspb.String(p.VmConfig.Configuration)) if err != nil { return nil, fmt.Errorf("failed to encode Wasm VM configuration: %w", err) } } code, err := p.asyncDataSource(rtCfg) if err != nil { return nil, fmt.Errorf("failed to encode async data source configuration: %w", err) } var envVars *envoy_wasm_v3.EnvironmentVariables if len(p.VmConfig.EnvironmentVariables.HostEnvKeys) > 0 || len(p.VmConfig.EnvironmentVariables.KeyValues) > 0 { envVars = &envoy_wasm_v3.EnvironmentVariables{ HostEnvKeys: p.VmConfig.EnvironmentVariables.HostEnvKeys, KeyValues: p.VmConfig.EnvironmentVariables.KeyValues, } } return &envoy_wasm_v3.PluginConfig{ Name: p.Name, RootId: p.RootID, Vm: &envoy_wasm_v3.PluginConfig_VmConfig{ VmConfig: &envoy_wasm_v3.VmConfig{ VmId: p.VmConfig.VmID, Runtime: fmt.Sprintf("envoy.wasm.runtime.%s", p.VmConfig.Runtime), Code: code, Configuration: vmCfgData, EnvironmentVariables: envVars, }, }, Configuration: pluginCfgData, CapabilityRestrictionConfig: p.capConfig(), FailOpen: p.failOpen, }, nil } func (p *pluginConfig) retryPolicy() *envoy_core_v3.RetryPolicy { remote := &p.VmConfig.Code.Remote if remote.RetryPolicy.NumRetries <= 0 && remote.RetryPolicy.RetryBackOff.BaseInterval == "" && remote.RetryPolicy.RetryBackOff.MaxInterval == "" { return nil } retryPolicy := &envoy_core_v3.RetryPolicy{} if remote.RetryPolicy.NumRetries > 0 { retryPolicy.NumRetries = wrapperspb.UInt32(uint32(remote.RetryPolicy.NumRetries)) } var baseInterval, maxInterval *durationpb.Duration if remote.RetryPolicy.RetryBackOff.baseInterval > 0 { baseInterval = &durationpb.Duration{Seconds: int64(remote.RetryPolicy.RetryBackOff.baseInterval.Seconds())} } if remote.RetryPolicy.RetryBackOff.maxInterval > 0 { maxInterval = &durationpb.Duration{Seconds: int64(remote.RetryPolicy.RetryBackOff.maxInterval.Seconds())} } if baseInterval != nil || maxInterval != nil { retryPolicy.RetryBackOff = &envoy_core_v3.BackoffStrategy{ BaseInterval: baseInterval, MaxInterval: maxInterval, } } return retryPolicy } func (w *wasmConfig) normalize() { if w.ProxyType == "" { w.ProxyType = api.ServiceKindConnectProxy } if w.PluginConfig.VmConfig.Runtime == "" { w.PluginConfig.VmConfig.Runtime = supportedRuntimes[0] } httpURI := &w.PluginConfig.VmConfig.Code.Remote.HttpURI httpURI.Service.Namespace = acl.NamespaceOrDefault(httpURI.Service.Namespace) httpURI.Service.Partition = acl.PartitionOrDefault(httpURI.Service.Partition) if httpURI.timeout <= 0 { httpURI.timeout = time.Second } } // validate ensures the filterConfig is valid or it returns an error. // This method must be called before using the configuration. func (w *wasmConfig) validate() error { var err, resultErr error if w.Protocol != "tcp" && w.Protocol != "http" { resultErr = multierror.Append(resultErr, fmt.Errorf(`unsupported Protocol %q, expected "tcp" or "http"`, w.Protocol)) } if w.ProxyType != api.ServiceKindConnectProxy { resultErr = multierror.Append(resultErr, fmt.Errorf("unsupported ProxyType %q, only %q is supported", w.ProxyType, api.ServiceKindConnectProxy)) } if w.ListenerType != "inbound" && w.ListenerType != "outbound" { resultErr = multierror.Append(resultErr, fmt.Errorf(`unsupported ListenerType %q, expected "inbound" or "outbound"`, w.ListenerType)) } if err = validateRuntime(w.PluginConfig.VmConfig.Runtime); err != nil { resultErr = multierror.Append(resultErr, err) } httpURI := &w.PluginConfig.VmConfig.Code.Remote.HttpURI isLocal := w.PluginConfig.VmConfig.Code.Local.Filename != "" isRemote := httpURI.Service.Name != "" || httpURI.URI != "" if isLocal == isRemote { resultErr = multierror.Append(resultErr, fmt.Errorf("VmConfig.Code must provide exactly one of Local or Remote data source")) } // If the data source is Local then validation is complete. if isLocal { return resultErr } // Validate the remote data source fields. // Both Service and URI are required inputs for remote data sources. // We could catch this above in the isRemote check; however, we do an explicit check // here for UX to give the user extra feedback in case they only provide one or the other. if httpURI.Service.Name == "" || httpURI.URI == "" { resultErr = multierror.Append(resultErr, fmt.Errorf("both Service and URI are required for Remote data sources")) } if w.PluginConfig.VmConfig.Code.Remote.SHA256 == "" { resultErr = multierror.Append(resultErr, fmt.Errorf("SHA256 checksum is required for Remote data sources")) } if _, err := url.Parse(httpURI.URI); err != nil { resultErr = multierror.Append(resultErr, fmt.Errorf("invalid HttpURI.URI: %w", err)) } if httpURI.Timeout != "" { httpURI.timeout, err = time.ParseDuration(httpURI.Timeout) if err != nil { resultErr = multierror.Append(resultErr, fmt.Errorf("failed to parse HttpURI.Timeout %q as a duration: %w", httpURI.Timeout, err)) } } retryPolicy := &w.PluginConfig.VmConfig.Code.Remote.RetryPolicy if retryPolicy.NumRetries < 0 { resultErr = multierror.Append(resultErr, fmt.Errorf("RetryPolicy.NumRetries must be greater than or equal to 0")) } if retryPolicy.RetryBackOff.BaseInterval != "" { retryPolicy.RetryBackOff.baseInterval, err = time.ParseDuration(retryPolicy.RetryBackOff.BaseInterval) if err != nil { resultErr = multierror.Append(resultErr, fmt.Errorf("failed to parse RetryBackOff.BaseInterval %q: %w", retryPolicy.RetryBackOff.BaseInterval, err)) } } if retryPolicy.RetryBackOff.MaxInterval != "" { retryPolicy.RetryBackOff.maxInterval, err = time.ParseDuration(retryPolicy.RetryBackOff.MaxInterval) if err != nil { resultErr = multierror.Append(resultErr, fmt.Errorf("failed to parse RetryBackOff.MaxInterval %q: %w", retryPolicy.RetryBackOff.MaxInterval, err)) } } if retryPolicy.RetryBackOff.BaseInterval != "" && retryPolicy.RetryBackOff.baseInterval <= 0 { resultErr = multierror.Append(resultErr, fmt.Errorf("RetryBackOff.BaseInterval %q must be greater than zero and less than or equal to RetryBackOff.MaxInterval %q", retryPolicy.RetryBackOff.BaseInterval, retryPolicy.RetryBackOff.MaxInterval), ) } if retryPolicy.RetryBackOff.MaxInterval != "" && retryPolicy.RetryBackOff.maxInterval < retryPolicy.RetryBackOff.baseInterval { resultErr = multierror.Append(resultErr, fmt.Errorf("RetryBackOff.MaxInterval %q must be greater than or equal to RetryBackOff.BaseInterval %q", retryPolicy.RetryBackOff.MaxInterval, retryPolicy.RetryBackOff.BaseInterval), ) } return resultErr } func validateRuntime(s string) error { for _, rt := range supportedRuntimes { if s == rt { return nil } } return fmt.Errorf("unsupported runtime %q", s) }