1
0
Fork 0

Adding upstream version 1.34.4.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-24 07:26:29 +02:00
parent e393c3af3f
commit 4978089aab
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
4963 changed files with 677545 additions and 0 deletions

View file

@ -0,0 +1,241 @@
package opcua
import (
"context"
"errors"
"fmt"
"log" //nolint:depguard // just for debug
"net/url"
"strconv"
"time"
"github.com/gopcua/opcua"
"github.com/gopcua/opcua/debug"
"github.com/gopcua/opcua/ua"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/internal/choice"
)
type OpcUAWorkarounds struct {
AdditionalValidStatusCodes []string `toml:"additional_valid_status_codes"`
}
type ConnectionState opcua.ConnState
const (
Closed ConnectionState = ConnectionState(opcua.Closed)
Connected ConnectionState = ConnectionState(opcua.Connected)
Connecting ConnectionState = ConnectionState(opcua.Connecting)
Disconnected ConnectionState = ConnectionState(opcua.Disconnected)
Reconnecting ConnectionState = ConnectionState(opcua.Reconnecting)
)
func (c ConnectionState) String() string {
return opcua.ConnState(c).String()
}
type OpcUAClientConfig struct {
Endpoint string `toml:"endpoint"`
SecurityPolicy string `toml:"security_policy"`
SecurityMode string `toml:"security_mode"`
Certificate string `toml:"certificate"`
PrivateKey string `toml:"private_key"`
Username config.Secret `toml:"username"`
Password config.Secret `toml:"password"`
AuthMethod string `toml:"auth_method"`
ConnectTimeout config.Duration `toml:"connect_timeout"`
RequestTimeout config.Duration `toml:"request_timeout"`
ClientTrace bool `toml:"client_trace"`
OptionalFields []string `toml:"optional_fields"`
Workarounds OpcUAWorkarounds `toml:"workarounds"`
SessionTimeout config.Duration `toml:"session_timeout"`
}
func (o *OpcUAClientConfig) Validate() error {
if err := o.validateOptionalFields(); err != nil {
return fmt.Errorf("invalid 'optional_fields': %w", err)
}
return o.validateEndpoint()
}
func (o *OpcUAClientConfig) validateOptionalFields() error {
validFields := []string{"DataType"}
return choice.CheckSlice(o.OptionalFields, validFields)
}
func (o *OpcUAClientConfig) validateEndpoint() error {
if o.Endpoint == "" {
return errors.New("endpoint url is empty")
}
_, err := url.Parse(o.Endpoint)
if err != nil {
return errors.New("endpoint url is invalid")
}
switch o.SecurityPolicy {
case "None", "Basic128Rsa15", "Basic256", "Basic256Sha256", "auto":
default:
return fmt.Errorf("invalid security type %q in %q", o.SecurityPolicy, o.Endpoint)
}
switch o.SecurityMode {
case "None", "Sign", "SignAndEncrypt", "auto":
default:
return fmt.Errorf("invalid security type %q in %q", o.SecurityMode, o.Endpoint)
}
return nil
}
func (o *OpcUAClientConfig) CreateClient(telegrafLogger telegraf.Logger) (*OpcUAClient, error) {
err := o.Validate()
if err != nil {
return nil, err
}
if o.ClientTrace {
debug.Enable = true
debug.Logger = log.New(&DebugLogger{Log: telegrafLogger}, "", 0)
}
c := &OpcUAClient{
Config: o,
Log: telegrafLogger,
}
c.Log.Debug("Initialising OpcUAClient")
err = c.setupWorkarounds()
return c, err
}
type OpcUAClient struct {
Config *OpcUAClientConfig
Log telegraf.Logger
Client *opcua.Client
opts []opcua.Option
codes []ua.StatusCode
}
// / setupOptions read the endpoints from the specified server and setup all authentication
func (o *OpcUAClient) SetupOptions() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.Config.ConnectTimeout))
defer cancel()
// Get a list of the endpoints for our target server
endpoints, err := opcua.GetEndpoints(ctx, o.Config.Endpoint)
if err != nil {
return err
}
if o.Config.Certificate == "" && o.Config.PrivateKey == "" {
if o.Config.SecurityPolicy != "None" || o.Config.SecurityMode != "None" {
o.Log.Debug("Generating self-signed certificate")
cert, privateKey, err := generateCert("urn:telegraf:gopcua:client", 2048,
o.Config.Certificate, o.Config.PrivateKey, 365*24*time.Hour)
if err != nil {
return err
}
o.Config.Certificate = cert
o.Config.PrivateKey = privateKey
}
}
o.Log.Debug("Configuring OPC UA connection options")
o.opts, err = o.generateClientOpts(endpoints)
return err
}
func (o *OpcUAClient) setupWorkarounds() error {
o.codes = []ua.StatusCode{ua.StatusOK}
for _, c := range o.Config.Workarounds.AdditionalValidStatusCodes {
val, err := strconv.ParseUint(c, 0, 32) // setting 32 bits to allow for safe conversion
if err != nil {
return err
}
o.codes = append(o.codes, ua.StatusCode(val))
}
return nil
}
func (o *OpcUAClient) StatusCodeOK(code ua.StatusCode) bool {
for _, val := range o.codes {
if val == code {
return true
}
}
return false
}
// Connect to an OPC UA device
func (o *OpcUAClient) Connect(ctx context.Context) error {
o.Log.Debug("Connecting OPC UA Client to server")
u, err := url.Parse(o.Config.Endpoint)
if err != nil {
return err
}
switch u.Scheme {
case "opc.tcp":
if err := o.SetupOptions(); err != nil {
return err
}
if o.Client != nil {
o.Log.Warnf("Closing connection to %q as already connected", u)
if err := o.Client.Close(ctx); err != nil {
// Only log the error but to not bail-out here as this prevents
// reconnections for multiple parties (see e.g. #9523).
o.Log.Errorf("Closing connection failed: %v", err)
}
}
o.Client, err = opcua.NewClient(o.Config.Endpoint, o.opts...)
if err != nil {
return fmt.Errorf("error in new client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.Config.ConnectTimeout))
defer cancel()
if err := o.Client.Connect(ctx); err != nil {
return fmt.Errorf("error in Client Connection: %w", err)
}
o.Log.Debug("Connected to OPC UA Server")
default:
return fmt.Errorf("unsupported scheme %q in endpoint. Expected opc.tcp", u.Scheme)
}
return nil
}
func (o *OpcUAClient) Disconnect(ctx context.Context) error {
o.Log.Debug("Disconnecting from OPC UA Server")
u, err := url.Parse(o.Config.Endpoint)
if err != nil {
return err
}
switch u.Scheme {
case "opc.tcp":
// We can't do anything about failing to close a connection
err := o.Client.Close(ctx)
o.Client = nil
return err
default:
return errors.New("invalid controller")
}
}
func (o *OpcUAClient) State() ConnectionState {
if o.Client == nil {
return Disconnected
}
return ConnectionState(o.Client.State())
}

View file

@ -0,0 +1,33 @@
package opcua
import (
"testing"
"github.com/gopcua/opcua/ua"
"github.com/stretchr/testify/require"
)
func TestSetupWorkarounds(t *testing.T) {
o := OpcUAClient{
Config: &OpcUAClientConfig{
Workarounds: OpcUAWorkarounds{
AdditionalValidStatusCodes: []string{"0xC0", "0x00AA0000", "0x80000000"},
},
},
}
err := o.setupWorkarounds()
require.NoError(t, err)
require.Len(t, o.codes, 4)
require.Equal(t, o.codes[0], ua.StatusCode(0))
require.Equal(t, o.codes[1], ua.StatusCode(192))
require.Equal(t, o.codes[2], ua.StatusCode(11141120))
require.Equal(t, o.codes[3], ua.StatusCode(2147483648))
}
func TestCheckStatusCode(t *testing.T) {
var o OpcUAClient
o.codes = []ua.StatusCode{ua.StatusCode(0), ua.StatusCode(192), ua.StatusCode(11141120)}
require.True(t, o.StatusCodeOK(ua.StatusCode(192)))
}

View file

@ -0,0 +1,500 @@
package input
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/gopcua/opcua/ua"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/internal/choice"
"github.com/influxdata/telegraf/metric"
"github.com/influxdata/telegraf/plugins/common/opcua"
)
type Trigger string
const (
Status Trigger = "Status"
StatusValue Trigger = "StatusValue"
StatusValueTimestamp Trigger = "StatusValueTimestamp"
)
type DeadbandType string
const (
Absolute DeadbandType = "Absolute"
Percent DeadbandType = "Percent"
)
type DataChangeFilter struct {
Trigger Trigger `toml:"trigger"`
DeadbandType DeadbandType `toml:"deadband_type"`
DeadbandValue *float64 `toml:"deadband_value"`
}
type MonitoringParameters struct {
SamplingInterval config.Duration `toml:"sampling_interval"`
QueueSize *uint32 `toml:"queue_size"`
DiscardOldest *bool `toml:"discard_oldest"`
DataChangeFilter *DataChangeFilter `toml:"data_change_filter"`
}
// NodeSettings describes how to map from a OPC UA node to a Metric
type NodeSettings struct {
FieldName string `toml:"name"`
Namespace string `toml:"namespace"`
IdentifierType string `toml:"identifier_type"`
Identifier string `toml:"identifier"`
DataType string `toml:"data_type" deprecated:"1.17.0;1.35.0;option is ignored"`
Description string `toml:"description" deprecated:"1.17.0;1.35.0;option is ignored"`
TagsSlice [][]string `toml:"tags" deprecated:"1.25.0;1.35.0;use 'default_tags' instead"`
DefaultTags map[string]string `toml:"default_tags"`
MonitoringParams MonitoringParameters `toml:"monitoring_params"`
}
// NodeID returns the OPC UA node id
func (tag *NodeSettings) NodeID() string {
return "ns=" + tag.Namespace + ";" + tag.IdentifierType + "=" + tag.Identifier
}
// NodeGroupSettings describes a mapping of group of nodes to Metrics
type NodeGroupSettings struct {
MetricName string `toml:"name"` // Overrides plugin's setting
Namespace string `toml:"namespace"` // Can be overridden by node setting
IdentifierType string `toml:"identifier_type"` // Can be overridden by node setting
Nodes []NodeSettings `toml:"nodes"`
TagsSlice [][]string `toml:"tags" deprecated:"1.26.0;1.35.0;use default_tags"`
DefaultTags map[string]string `toml:"default_tags"`
SamplingInterval config.Duration `toml:"sampling_interval"` // Can be overridden by monitoring parameters
}
type TimestampSource string
const (
TimestampSourceServer TimestampSource = "server"
TimestampSourceSource TimestampSource = "source"
TimestampSourceTelegraf TimestampSource = "gather"
)
// InputClientConfig a configuration for the input client
type InputClientConfig struct {
opcua.OpcUAClientConfig
MetricName string `toml:"name"`
Timestamp TimestampSource `toml:"timestamp"`
TimestampFormat string `toml:"timestamp_format"`
RootNodes []NodeSettings `toml:"nodes"`
Groups []NodeGroupSettings `toml:"group"`
}
func (o *InputClientConfig) Validate() error {
if o.MetricName == "" {
return errors.New("metric name is empty")
}
err := choice.Check(string(o.Timestamp), []string{"", "gather", "server", "source"})
if err != nil {
return err
}
if o.TimestampFormat == "" {
o.TimestampFormat = time.RFC3339Nano
}
if len(o.Groups) == 0 && len(o.RootNodes) == 0 {
return errors.New("no groups or root nodes provided to gather from")
}
for _, group := range o.Groups {
if len(group.Nodes) == 0 {
return errors.New("group has no nodes to collect from")
}
}
return nil
}
func (o *InputClientConfig) CreateInputClient(log telegraf.Logger) (*OpcUAInputClient, error) {
if err := o.Validate(); err != nil {
return nil, err
}
log.Debug("Initialising OpcUAInputClient")
opcClient, err := o.OpcUAClientConfig.CreateClient(log)
if err != nil {
return nil, err
}
c := &OpcUAInputClient{
OpcUAClient: opcClient,
Log: log,
Config: *o,
}
log.Debug("Initialising node to metric mapping")
if err := c.InitNodeMetricMapping(); err != nil {
return nil, err
}
c.initLastReceivedValues()
return c, nil
}
// NodeMetricMapping mapping from a single node to a metric
type NodeMetricMapping struct {
Tag NodeSettings
idStr string
metricName string
MetricTags map[string]string
}
// NewNodeMetricMapping builds a new NodeMetricMapping from the given argument
func NewNodeMetricMapping(metricName string, node NodeSettings, groupTags map[string]string) (*NodeMetricMapping, error) {
mergedTags := make(map[string]string)
for n, t := range groupTags {
mergedTags[n] = t
}
nodeTags := make(map[string]string)
if len(node.DefaultTags) > 0 {
nodeTags = node.DefaultTags
} else if len(node.TagsSlice) > 0 {
// fixme: once the TagsSlice has been removed (after deprecation), remove this if else logic
var err error
nodeTags, err = tagsSliceToMap(node.TagsSlice)
if err != nil {
return nil, err
}
}
for n, t := range nodeTags {
mergedTags[n] = t
}
return &NodeMetricMapping{
Tag: node,
idStr: node.NodeID(),
metricName: metricName,
MetricTags: mergedTags,
}, nil
}
// NodeValue The received value for a node
type NodeValue struct {
TagName string
Value interface{}
Quality ua.StatusCode
ServerTime time.Time
SourceTime time.Time
DataType ua.TypeID
IsArray bool
}
// OpcUAInputClient can receive data from an OPC UA server and map it to Metrics. This type does not contain
// logic for actually retrieving data from the server, but is used by other types like ReadClient and
// OpcUAInputSubscribeClient to store data needed to convert node ids to the corresponding metrics.
type OpcUAInputClient struct {
*opcua.OpcUAClient
Config InputClientConfig
Log telegraf.Logger
NodeMetricMapping []NodeMetricMapping
NodeIDs []*ua.NodeID
LastReceivedData []NodeValue
}
// Stop the connection to the client
func (o *OpcUAInputClient) Stop(ctx context.Context) <-chan struct{} {
ch := make(chan struct{})
defer close(ch)
err := o.Disconnect(ctx)
if err != nil {
o.Log.Warn("Disconnecting from server failed with error ", err)
}
return ch
}
// metricParts is only used to ensure no duplicate metrics are created
type metricParts struct {
metricName string
fieldName string
tags string // sorted by tag name and in format tag1=value1, tag2=value2
}
func newMP(n *NodeMetricMapping) metricParts {
keys := make([]string, 0, len(n.MetricTags))
for key := range n.MetricTags {
keys = append(keys, key)
}
sort.Strings(keys)
var sb strings.Builder
for i, key := range keys {
if i != 0 {
sb.WriteString(", ")
}
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(n.MetricTags[key])
}
x := metricParts{
metricName: n.metricName,
fieldName: n.Tag.FieldName,
tags: sb.String(),
}
return x
}
// fixme: once the TagsSlice has been removed (after deprecation), remove this
// tagsSliceToMap takes an array of pairs of strings and creates a map from it
func tagsSliceToMap(tags [][]string) (map[string]string, error) {
m := make(map[string]string)
for i, tag := range tags {
if len(tag) != 2 {
return nil, fmt.Errorf("tag %d needs 2 values, has %d: %v", i+1, len(tag), tag)
}
if tag[0] == "" {
return nil, fmt.Errorf("tag %d has empty name", i+1)
}
if tag[1] == "" {
return nil, fmt.Errorf("tag %d has empty value", i+1)
}
if _, ok := m[tag[0]]; ok {
return nil, fmt.Errorf("tag %d has duplicate key: %v", i+1, tag[0])
}
m[tag[0]] = tag[1]
}
return m, nil
}
func validateNodeToAdd(existing map[metricParts]struct{}, nmm *NodeMetricMapping) error {
if nmm.Tag.FieldName == "" {
return fmt.Errorf("empty name in %q", nmm.Tag.FieldName)
}
if len(nmm.Tag.Namespace) == 0 {
return errors.New("empty node namespace not allowed")
}
if len(nmm.Tag.Identifier) == 0 {
return errors.New("empty node identifier not allowed")
}
mp := newMP(nmm)
if _, exists := existing[mp]; exists {
return fmt.Errorf("name %q is duplicated (metric name %q, tags %q)",
mp.fieldName, mp.metricName, mp.tags)
}
switch nmm.Tag.IdentifierType {
case "i":
if _, err := strconv.Atoi(nmm.Tag.Identifier); err != nil {
return fmt.Errorf("identifier type %q does not match the type of identifier %q", nmm.Tag.IdentifierType, nmm.Tag.Identifier)
}
case "s", "g", "b":
// Valid identifier type - do nothing.
default:
return fmt.Errorf("invalid identifier type %q in %q", nmm.Tag.IdentifierType, nmm.Tag.FieldName)
}
existing[mp] = struct{}{}
return nil
}
// InitNodeMetricMapping builds nodes from the configuration
func (o *OpcUAInputClient) InitNodeMetricMapping() error {
existing := make(map[metricParts]struct{}, len(o.Config.RootNodes))
for _, node := range o.Config.RootNodes {
nmm, err := NewNodeMetricMapping(o.Config.MetricName, node, make(map[string]string))
if err != nil {
return err
}
if err := validateNodeToAdd(existing, nmm); err != nil {
return err
}
o.NodeMetricMapping = append(o.NodeMetricMapping, *nmm)
}
for _, group := range o.Config.Groups {
if group.MetricName == "" {
group.MetricName = o.Config.MetricName
}
if len(group.DefaultTags) > 0 && len(group.TagsSlice) > 0 {
o.Log.Warn("Tags found in both `tags` and `default_tags`, only using tags defined in `default_tags`")
}
groupTags := make(map[string]string)
if len(group.DefaultTags) > 0 {
groupTags = group.DefaultTags
} else if len(group.TagsSlice) > 0 {
// fixme: once the TagsSlice has been removed (after deprecation), remove this if else logic
var err error
groupTags, err = tagsSliceToMap(group.TagsSlice)
if err != nil {
return err
}
}
for _, node := range group.Nodes {
if node.Namespace == "" {
node.Namespace = group.Namespace
}
if node.IdentifierType == "" {
node.IdentifierType = group.IdentifierType
}
if node.MonitoringParams.SamplingInterval == 0 {
node.MonitoringParams.SamplingInterval = group.SamplingInterval
}
nmm, err := NewNodeMetricMapping(group.MetricName, node, groupTags)
if err != nil {
return err
}
if err := validateNodeToAdd(existing, nmm); err != nil {
return err
}
o.NodeMetricMapping = append(o.NodeMetricMapping, *nmm)
}
}
return nil
}
func (o *OpcUAInputClient) InitNodeIDs() error {
o.NodeIDs = make([]*ua.NodeID, 0, len(o.NodeMetricMapping))
for _, node := range o.NodeMetricMapping {
nid, err := ua.ParseNodeID(node.Tag.NodeID())
if err != nil {
return err
}
o.NodeIDs = append(o.NodeIDs, nid)
}
return nil
}
func (o *OpcUAInputClient) initLastReceivedValues() {
o.LastReceivedData = make([]NodeValue, len(o.NodeMetricMapping))
for nodeIdx, nmm := range o.NodeMetricMapping {
o.LastReceivedData[nodeIdx].TagName = nmm.Tag.FieldName
}
}
func (o *OpcUAInputClient) UpdateNodeValue(nodeIdx int, d *ua.DataValue) {
o.LastReceivedData[nodeIdx].Quality = d.Status
if !o.StatusCodeOK(d.Status) {
// Verify NodeIDs array has been built before trying to get item; otherwise show '?' for node id
if len(o.NodeIDs) > nodeIdx {
o.Log.Errorf("status not OK for node %v (%v): %v", o.NodeMetricMapping[nodeIdx].Tag.FieldName, o.NodeIDs[nodeIdx].String(), d.Status)
} else {
o.Log.Errorf("status not OK for node %v (%v): %v", o.NodeMetricMapping[nodeIdx].Tag.FieldName, '?', d.Status)
}
return
}
if d.Value != nil {
o.LastReceivedData[nodeIdx].DataType = d.Value.Type()
o.LastReceivedData[nodeIdx].IsArray = d.Value.Has(ua.VariantArrayValues)
o.LastReceivedData[nodeIdx].Value = d.Value.Value()
if o.LastReceivedData[nodeIdx].DataType == ua.TypeIDDateTime {
if t, ok := d.Value.Value().(time.Time); ok {
o.LastReceivedData[nodeIdx].Value = t.Format(o.Config.TimestampFormat)
}
}
}
o.LastReceivedData[nodeIdx].ServerTime = d.ServerTimestamp
o.LastReceivedData[nodeIdx].SourceTime = d.SourceTimestamp
}
func (o *OpcUAInputClient) MetricForNode(nodeIdx int) telegraf.Metric {
nmm := &o.NodeMetricMapping[nodeIdx]
tags := map[string]string{
"id": nmm.idStr,
}
for k, v := range nmm.MetricTags {
tags[k] = v
}
fields := make(map[string]interface{})
if o.LastReceivedData[nodeIdx].Value != nil {
// Simple scalar types can be stored directly under the field name while
// arrays (see 5.2.5) and structures (see 5.2.6) must be unpacked.
// Note: Structures and arrays of structures are currently not supported.
if o.LastReceivedData[nodeIdx].IsArray {
switch typedValue := o.LastReceivedData[nodeIdx].Value.(type) {
case []uint8:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []uint16:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []uint32:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []uint64:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []int8:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []int16:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []int32:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []int64:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []float32:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []float64:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []string:
fields = unpack(nmm.Tag.FieldName, typedValue)
case []bool:
fields = unpack(nmm.Tag.FieldName, typedValue)
default:
o.Log.Errorf("could not unpack variant array of type: %T", typedValue)
}
} else {
fields = map[string]interface{}{
nmm.Tag.FieldName: o.LastReceivedData[nodeIdx].Value,
}
}
}
fields["Quality"] = strings.TrimSpace(o.LastReceivedData[nodeIdx].Quality.Error())
if choice.Contains("DataType", o.Config.OptionalFields) {
fields["DataType"] = strings.Replace(o.LastReceivedData[nodeIdx].DataType.String(), "TypeID", "", 1)
}
if !o.StatusCodeOK(o.LastReceivedData[nodeIdx].Quality) {
mp := newMP(nmm)
o.Log.Debugf("status not OK for node %q(metric name %q, tags %q)",
mp.fieldName, mp.metricName, mp.tags)
}
var t time.Time
switch o.Config.Timestamp {
case TimestampSourceServer:
t = o.LastReceivedData[nodeIdx].ServerTime
case TimestampSourceSource:
t = o.LastReceivedData[nodeIdx].SourceTime
default:
t = time.Now()
}
return metric.New(nmm.metricName, tags, fields, t)
}
func unpack[Slice ~[]E, E any](prefix string, value Slice) map[string]interface{} {
fields := make(map[string]interface{}, len(value))
for i, v := range value {
key := fmt.Sprintf("%s[%d]", prefix, i)
fields[key] = v
}
return fields
}

View file

@ -0,0 +1,890 @@
package input
import (
"errors"
"testing"
"time"
"github.com/gopcua/opcua/ua"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/metric"
"github.com/influxdata/telegraf/plugins/common/opcua"
"github.com/influxdata/telegraf/testutil"
)
func TestTagsSliceToMap(t *testing.T) {
m, err := tagsSliceToMap([][]string{{"foo", "bar"}, {"baz", "bat"}})
require.NoError(t, err)
require.Len(t, m, 2)
require.Equal(t, "bar", m["foo"])
require.Equal(t, "bat", m["baz"])
}
func TestTagsSliceToMap_twoStrings(t *testing.T) {
var err error
_, err = tagsSliceToMap([][]string{{"foo", "bar", "baz"}})
require.Error(t, err)
_, err = tagsSliceToMap([][]string{{"foo"}})
require.Error(t, err)
}
func TestTagsSliceToMap_dupeKey(t *testing.T) {
_, err := tagsSliceToMap([][]string{{"foo", "bar"}, {"foo", "bat"}})
require.Error(t, err)
}
func TestTagsSliceToMap_empty(t *testing.T) {
_, err := tagsSliceToMap([][]string{{"foo", ""}})
require.Equal(t, errors.New("tag 1 has empty value"), err)
_, err = tagsSliceToMap([][]string{{"", "bar"}})
require.Equal(t, errors.New("tag 1 has empty name"), err)
}
func TestValidateOPCTags(t *testing.T) {
tests := []struct {
name string
config InputClientConfig
err error
}{
{
"duplicates",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
},
Groups: []NodeGroupSettings{
{
Nodes: []NodeSettings{
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
},
},
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
},
},
errors.New(`name "fn" is duplicated (metric name "mn", tags "t1=v1, t2=v2")`),
},
{
"empty tag value not allowed",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
IdentifierType: "s",
TagsSlice: [][]string{{"t1", ""}},
},
},
},
errors.New("tag 1 has empty value"),
},
{
"empty tag name not allowed",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
IdentifierType: "s",
TagsSlice: [][]string{{"", "1"}},
},
},
},
errors.New("tag 1 has empty name"),
},
{
"different metric tag names",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t3", "v2"}},
},
},
},
nil,
},
{
"different metric tag values",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "foo"}, {"t2", "v2"}},
},
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "bar"}, {"t2", "v2"}},
},
},
},
nil,
},
{
"different metric names",
InputClientConfig{
MetricName: "mn",
Groups: []NodeGroupSettings{
{
MetricName: "mn",
Namespace: "2",
Nodes: []NodeSettings{
{
FieldName: "fn",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
},
},
{
MetricName: "mn2",
Namespace: "2",
Nodes: []NodeSettings{
{
FieldName: "fn",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
},
},
},
},
nil,
},
{
"different field names",
InputClientConfig{
MetricName: "mn",
RootNodes: []NodeSettings{
{
FieldName: "fn",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
{
FieldName: "fn2",
Namespace: "2",
IdentifierType: "s",
Identifier: "i1",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
},
},
},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := OpcUAInputClient{
Config: tt.config,
Log: testutil.Logger{},
}
require.Equal(t, tt.err, o.InitNodeMetricMapping())
})
}
}
func TestNewNodeMetricMappingTags(t *testing.T) {
tests := []struct {
name string
settings NodeSettings
groupTags map[string]string
expectedTags map[string]string
err error
}{
{
name: "empty tags",
settings: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
},
groupTags: map[string]string{},
expectedTags: map[string]string{},
err: nil,
},
{
name: "node tags only",
settings: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
TagsSlice: [][]string{{"t1", "v1"}},
},
groupTags: map[string]string{},
expectedTags: map[string]string{"t1": "v1"},
err: nil,
},
{
name: "group tags only",
settings: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
},
groupTags: map[string]string{"t1": "v1"},
expectedTags: map[string]string{"t1": "v1"},
err: nil,
},
{
name: "node tag overrides group tags",
settings: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
TagsSlice: [][]string{{"t1", "v2"}},
},
groupTags: map[string]string{"t1": "v1"},
expectedTags: map[string]string{"t1": "v2"},
err: nil,
},
{
name: "node tag merged with group tags",
settings: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
TagsSlice: [][]string{{"t2", "v2"}},
},
groupTags: map[string]string{"t1": "v1"},
expectedTags: map[string]string{"t1": "v1", "t2": "v2"},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nmm, err := NewNodeMetricMapping("testmetric", tt.settings, tt.groupTags)
require.Equal(t, tt.err, err)
require.Equal(t, tt.expectedTags, nmm.MetricTags)
})
}
}
func TestNewNodeMetricMappingIdStrInstantiated(t *testing.T) {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "h",
}, map[string]string{})
require.NoError(t, err)
require.Equal(t, "ns=2;s=h", nmm.idStr)
}
func TestValidateNodeToAdd(t *testing.T) {
tests := []struct {
name string
existing map[metricParts]struct{}
nmm *NodeMetricMapping
err error
}{
{
name: "valid",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: nil,
},
{
name: "empty field name not allowed",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "",
Namespace: "2",
IdentifierType: "s",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New(`empty name in ""`),
},
{
name: "empty namespace not allowed",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "",
IdentifierType: "s",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New("empty node namespace not allowed"),
},
{
name: "empty identifier type not allowed",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New(`invalid identifier type "" in "f"`),
},
{
name: "invalid identifier type not allowed",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "j",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New(`invalid identifier type "j" in "f"`),
},
{
name: "duplicate metric not allowed",
existing: map[metricParts]struct{}{
{metricName: "testmetric", fieldName: "f", tags: "t1=v1, t2=v2"}: {},
},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "hf",
TagsSlice: [][]string{{"t1", "v1"}, {"t2", "v2"}},
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New(`name "f" is duplicated (metric name "testmetric", tags "t1=v1, t2=v2")`),
},
{
name: "identifier type mismatch",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "i",
Identifier: "hf",
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: errors.New(`identifier type "i" does not match the type of identifier "hf"`),
},
}
for idT, idV := range map[string]string{
"s": "hf",
"i": "1",
"g": "849683f0-ce92-4fa2-836f-a02cde61d75d",
"b": "aGVsbG8gSSBhbSBhIHRlc3QgaWRlbnRpZmllcg=="} {
tests = append(tests, struct {
name string
existing map[metricParts]struct{}
nmm *NodeMetricMapping
err error
}{
name: "identifier type " + idT + " allowed",
existing: map[metricParts]struct{}{},
nmm: func() *NodeMetricMapping {
nmm, err := NewNodeMetricMapping("testmetric", NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: idT,
Identifier: idV,
}, map[string]string{})
require.NoError(t, err)
return nmm
}(),
err: nil,
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateNodeToAdd(tt.existing, tt.nmm)
require.Equal(t, tt.err, err)
})
}
}
func TestInitNodeMetricMapping(t *testing.T) {
tests := []struct {
testname string
config InputClientConfig
expected []NodeMetricMapping
err error
}{
{
testname: "only root node",
config: InputClientConfig{
MetricName: "testmetric",
Timestamp: TimestampSourceTelegraf,
RootNodes: []NodeSettings{
{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
},
},
},
expected: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
},
idStr: "ns=2;s=id1",
metricName: "testmetric",
MetricTags: map[string]string{"t1": "v1"},
},
},
err: nil,
},
{
testname: "root node and group node",
config: InputClientConfig{
MetricName: "testmetric",
Timestamp: TimestampSourceTelegraf,
RootNodes: []NodeSettings{
{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
},
},
Groups: []NodeGroupSettings{
{
MetricName: "groupmetric",
Namespace: "3",
IdentifierType: "s",
Nodes: []NodeSettings{
{
FieldName: "f",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
},
},
},
},
},
expected: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
},
idStr: "ns=2;s=id1",
metricName: "testmetric",
MetricTags: map[string]string{"t1": "v1"},
},
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "3",
IdentifierType: "s",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
},
idStr: "ns=3;s=id2",
metricName: "groupmetric",
MetricTags: map[string]string{"t2": "v2"},
},
},
err: nil,
},
{
testname: "only group node",
config: InputClientConfig{
MetricName: "testmetric",
Timestamp: TimestampSourceTelegraf,
Groups: []NodeGroupSettings{
{
MetricName: "groupmetric",
Namespace: "3",
IdentifierType: "s",
Nodes: []NodeSettings{
{
FieldName: "f",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
},
},
},
},
},
expected: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "3",
IdentifierType: "s",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
},
idStr: "ns=3;s=id2",
metricName: "groupmetric",
MetricTags: map[string]string{"t2": "v2"},
},
},
err: nil,
},
{
testname: "tags and default only default tags used",
config: InputClientConfig{
MetricName: "testmetric",
Timestamp: TimestampSourceTelegraf,
Groups: []NodeGroupSettings{
{
MetricName: "groupmetric",
Namespace: "3",
IdentifierType: "s",
Nodes: []NodeSettings{
{
FieldName: "f",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
DefaultTags: map[string]string{"t3": "v3"},
},
},
},
},
},
expected: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "3",
IdentifierType: "s",
Identifier: "id2",
TagsSlice: [][]string{{"t2", "v2"}},
DefaultTags: map[string]string{"t3": "v3"},
},
idStr: "ns=3;s=id2",
metricName: "groupmetric",
MetricTags: map[string]string{"t3": "v3"},
},
},
err: nil,
},
{
testname: "only root node default overrides slice",
config: InputClientConfig{
MetricName: "testmetric",
Timestamp: TimestampSourceTelegraf,
RootNodes: []NodeSettings{
{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
DefaultTags: map[string]string{"t3": "v3"},
},
},
},
expected: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
Namespace: "2",
IdentifierType: "s",
Identifier: "id1",
TagsSlice: [][]string{{"t1", "v1"}},
DefaultTags: map[string]string{"t3": "v3"},
},
idStr: "ns=2;s=id1",
metricName: "testmetric",
MetricTags: map[string]string{"t3": "v3"},
},
},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.testname, func(t *testing.T) {
o := OpcUAInputClient{Config: tt.config}
err := o.InitNodeMetricMapping()
require.NoError(t, err)
require.Equal(t, tt.expected, o.NodeMetricMapping)
})
}
}
func TestUpdateNodeValue(t *testing.T) {
type testStep struct {
nodeIdx int
value interface{}
status ua.StatusCode
expected interface{}
}
tests := []struct {
testname string
steps []testStep
}{
{
"value should update when code ok",
[]testStep{
{
0,
"Harmony",
ua.StatusOK,
"Harmony",
},
},
},
{
"value should not update when code bad",
[]testStep{
{
0,
"Harmony",
ua.StatusOK,
"Harmony",
},
{
0,
"Odium",
ua.StatusBad,
"Harmony",
},
{
0,
"Ati",
ua.StatusOK,
"Ati",
},
},
},
}
conf := &opcua.OpcUAClientConfig{
Endpoint: "opc.tcp://localhost:4930",
SecurityPolicy: "None",
SecurityMode: "None",
AuthMethod: "",
ConnectTimeout: config.Duration(2 * time.Second),
RequestTimeout: config.Duration(2 * time.Second),
Workarounds: opcua.OpcUAWorkarounds{},
}
c, err := conf.CreateClient(testutil.Logger{})
require.NoError(t, err)
o := OpcUAInputClient{
OpcUAClient: c,
Log: testutil.Logger{},
NodeMetricMapping: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "f",
},
},
{
Tag: NodeSettings{
FieldName: "f2",
},
},
},
LastReceivedData: make([]NodeValue, 2),
}
for _, tt := range tests {
t.Run(tt.testname, func(t *testing.T) {
o.LastReceivedData = make([]NodeValue, 2)
for i, step := range tt.steps {
v, err := ua.NewVariant(step.value)
require.NoError(t, err)
o.UpdateNodeValue(0, &ua.DataValue{
Value: v,
Status: step.status,
SourceTimestamp: time.Date(2022, 03, 17, 8, 33, 00, 00, &time.Location{}).Add(time.Duration(i) * time.Second),
SourcePicoseconds: 0,
ServerTimestamp: time.Date(2022, 03, 17, 8, 33, 00, 500, &time.Location{}).Add(time.Duration(i) * time.Second),
ServerPicoseconds: 0,
})
require.Equal(t, step.expected, o.LastReceivedData[0].Value)
}
})
}
}
func TestMetricForNode(t *testing.T) {
conf := &opcua.OpcUAClientConfig{
Endpoint: "opc.tcp://localhost:4930",
SecurityPolicy: "None",
SecurityMode: "None",
AuthMethod: "",
ConnectTimeout: config.Duration(2 * time.Second),
RequestTimeout: config.Duration(2 * time.Second),
Workarounds: opcua.OpcUAWorkarounds{},
}
c, err := conf.CreateClient(testutil.Logger{})
require.NoError(t, err)
o := OpcUAInputClient{
Config: InputClientConfig{
Timestamp: TimestampSourceSource,
},
OpcUAClient: c,
Log: testutil.Logger{},
LastReceivedData: make([]NodeValue, 2),
}
tests := []struct {
testname string
nmm []NodeMetricMapping
v interface{}
isArray bool
dataType ua.TypeID
time time.Time
status ua.StatusCode
expected telegraf.Metric
}{
{
testname: "metric build correctly",
nmm: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "fn",
},
idStr: "ns=3;s=hi",
metricName: "testingmetric",
MetricTags: map[string]string{"t1": "v1"},
},
},
v: 16,
isArray: false,
dataType: ua.TypeIDInt32,
time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}),
status: ua.StatusOK,
expected: metric.New("testingmetric",
map[string]string{"t1": "v1", "id": "ns=3;s=hi"},
map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)", "fn": 16},
time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})),
},
{
testname: "array-like metric build correctly",
nmm: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "fn",
},
idStr: "ns=3;s=hi",
metricName: "testingmetric",
MetricTags: map[string]string{"t1": "v1"},
},
},
v: []int32{16, 17},
isArray: true,
dataType: ua.TypeIDInt32,
time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}),
status: ua.StatusOK,
expected: metric.New("testingmetric",
map[string]string{"t1": "v1", "id": "ns=3;s=hi"},
map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)", "fn[0]": 16, "fn[1]": 17},
time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})),
},
{
testname: "nil does not panic",
nmm: []NodeMetricMapping{
{
Tag: NodeSettings{
FieldName: "fn",
},
idStr: "ns=3;s=hi",
metricName: "testingmetric",
MetricTags: map[string]string{"t1": "v1"},
},
},
v: nil,
isArray: false,
dataType: ua.TypeIDNull,
time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}),
status: ua.StatusOK,
expected: metric.New("testingmetric",
map[string]string{"t1": "v1", "id": "ns=3;s=hi"},
map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)"},
time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})),
},
}
for _, tt := range tests {
t.Run(tt.testname, func(t *testing.T) {
o.NodeMetricMapping = tt.nmm
o.LastReceivedData[0].SourceTime = tt.time
o.LastReceivedData[0].Quality = tt.status
o.LastReceivedData[0].Value = tt.v
o.LastReceivedData[0].DataType = tt.dataType
o.LastReceivedData[0].IsArray = tt.isArray
actual := o.MetricForNode(0)
require.Equal(t, tt.expected.Tags(), actual.Tags())
require.Equal(t, tt.expected.Fields(), actual.Fields())
require.Equal(t, tt.expected.Time(), actual.Time())
})
}
}

View file

@ -0,0 +1,15 @@
package opcua
import (
"github.com/influxdata/telegraf"
)
// DebugLogger logs messages from opcua at the debug level.
type DebugLogger struct {
Log telegraf.Logger
}
func (l *DebugLogger) Write(p []byte) (n int, err error) {
l.Log.Debug(string(p))
return len(p), nil
}

View file

@ -0,0 +1,359 @@
package opcua
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/gopcua/opcua"
"github.com/gopcua/opcua/debug"
"github.com/gopcua/opcua/ua"
"github.com/influxdata/telegraf/config"
)
// SELF SIGNED CERT FUNCTIONS
func newTempDir() (string, error) {
dir, err := os.MkdirTemp("", "ssc")
return dir, err
}
func generateCert(host string, rsaBits int, certFile, keyFile string, dur time.Duration) (cert, key string, err error) {
dir, err := newTempDir()
if err != nil {
return "", "", fmt.Errorf("failed to create certificate: %w", err)
}
if len(host) == 0 {
return "", "", errors.New("missing required host parameter")
}
if rsaBits == 0 {
rsaBits = 2048
}
if len(certFile) == 0 {
certFile = dir + "/cert.pem"
}
if len(keyFile) == 0 {
keyFile = dir + "/key.pem"
}
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return "", "", fmt.Errorf("failed to generate private key: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(dur)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return "", "", fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Telegraf OPC UA Client"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageContentCommitment | x509.KeyUsageKeyEncipherment |
x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
hosts := strings.Split(host, ",")
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
if uri, err := url.Parse(h); err == nil {
template.URIs = append(template.URIs, uri)
}
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv)
if err != nil {
return "", "", fmt.Errorf("failed to create certificate: %w", err)
}
certOut, err := os.Create(certFile)
if err != nil {
return "", "", fmt.Errorf("failed to open %s for writing: %w", certFile, err)
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return "", "", fmt.Errorf("failed to write data to %s: %w", certFile, err)
}
if err := certOut.Close(); err != nil {
return "", "", fmt.Errorf("error closing %s: %w", certFile, err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return "", "", fmt.Errorf("failed to open %s for writing: %w", keyFile, err)
}
keyBlock, err := pemBlockForKey(priv)
if err != nil {
return "", "", fmt.Errorf("error generating block: %w", err)
}
if err := pem.Encode(keyOut, keyBlock); err != nil {
return "", "", fmt.Errorf("failed to write data to %s: %w", keyFile, err)
}
if err := keyOut.Close(); err != nil {
return "", "", fmt.Errorf("error closing %s: %w", keyFile, err)
}
return certFile, keyFile, nil
}
func publicKey(priv interface{}) interface{} {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
default:
return nil
}
}
func pemBlockForKey(priv interface{}) (*pem.Block, error) {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}, nil
case *ecdsa.PrivateKey:
b, err := x509.MarshalECPrivateKey(k)
if err != nil {
return nil, fmt.Errorf("unable to marshal ECDSA private key: %w", err)
}
return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}, nil
default:
return nil, nil
}
}
func (o *OpcUAClient) generateClientOpts(endpoints []*ua.EndpointDescription) ([]opcua.Option, error) {
appuri := "urn:telegraf:gopcua:client"
appname := "Telegraf"
// ApplicationURI is automatically read from the cert so is not required if a cert if provided
opts := []opcua.Option{
opcua.ApplicationURI(appuri),
opcua.ApplicationName(appname),
opcua.RequestTimeout(time.Duration(o.Config.RequestTimeout)),
}
if o.Config.SessionTimeout != 0 {
opts = append(opts, opcua.SessionTimeout(time.Duration(o.Config.SessionTimeout)))
}
certFile := o.Config.Certificate
keyFile := o.Config.PrivateKey
policy := o.Config.SecurityPolicy
mode := o.Config.SecurityMode
var err error
if certFile == "" && keyFile == "" {
if policy != "None" || mode != "None" {
certFile, keyFile, err = generateCert(appuri, 2048, certFile, keyFile, 365*24*time.Hour)
if err != nil {
return nil, err
}
}
}
var cert []byte
if certFile != "" && keyFile != "" {
debug.Printf("Loading cert/key from %s/%s", certFile, keyFile)
c, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
o.Log.Warnf("Failed to load certificate: %s", err)
} else {
pk, ok := c.PrivateKey.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("invalid private key")
}
cert = c.Certificate[0]
opts = append(opts, opcua.PrivateKey(pk), opcua.Certificate(cert))
}
}
var secPolicy string
switch {
case policy == "auto":
// set it later
case strings.HasPrefix(policy, ua.SecurityPolicyURIPrefix):
secPolicy = policy
policy = ""
case policy == "None" || policy == "Basic128Rsa15" || policy == "Basic256" || policy == "Basic256Sha256" ||
policy == "Aes128_Sha256_RsaOaep" || policy == "Aes256_Sha256_RsaPss":
secPolicy = ua.SecurityPolicyURIPrefix + policy
policy = ""
default:
return nil, fmt.Errorf("invalid security policy: %s", policy)
}
o.Log.Debugf("security policy from configuration %s", secPolicy)
// Select the most appropriate authentication mode from server capabilities and user input
authMode, authOption, err := o.generateAuth(o.Config.AuthMethod, cert, o.Config.Username, o.Config.Password)
if err != nil {
return nil, err
}
opts = append(opts, authOption)
var secMode ua.MessageSecurityMode
switch strings.ToLower(mode) {
case "auto":
case "none":
secMode = ua.MessageSecurityModeNone
mode = ""
case "sign":
secMode = ua.MessageSecurityModeSign
mode = ""
case "signandencrypt":
secMode = ua.MessageSecurityModeSignAndEncrypt
mode = ""
default:
return nil, fmt.Errorf("invalid security mode: %s", mode)
}
// Allow input of only one of sec-mode,sec-policy when choosing 'None'
if secMode == ua.MessageSecurityModeNone || secPolicy == ua.SecurityPolicyURINone {
secMode = ua.MessageSecurityModeNone
secPolicy = ua.SecurityPolicyURINone
}
// Find the best endpoint based on our input and server recommendation (highest SecurityMode+SecurityLevel)
var serverEndpoint *ua.EndpointDescription
switch {
case mode == "auto" && policy == "auto": // No user selection, choose best
for _, e := range endpoints {
if serverEndpoint == nil || (e.SecurityMode >= serverEndpoint.SecurityMode && e.SecurityLevel >= serverEndpoint.SecurityLevel) {
serverEndpoint = e
}
}
case mode != "auto" && policy == "auto": // User only cares about mode, select highest securitylevel with that mode
for _, e := range endpoints {
if e.SecurityMode == secMode && (serverEndpoint == nil || e.SecurityLevel >= serverEndpoint.SecurityLevel) {
serverEndpoint = e
}
}
case mode == "auto" && policy != "auto": // User only cares about policy, select highest securitylevel with that policy
for _, e := range endpoints {
if e.SecurityPolicyURI == secPolicy && (serverEndpoint == nil || e.SecurityLevel >= serverEndpoint.SecurityLevel) {
serverEndpoint = e
}
}
default: // User cares about both
o.Log.Debugf("User cares about both the policy (%s) and security mode (%s)", secPolicy, secMode)
o.Log.Debugf("Server has %d endpoints", len(endpoints))
for _, e := range endpoints {
o.Log.Debugf("Evaluating endpoint %s, policy %s, mode %s, level %d", e.EndpointURL, e.SecurityPolicyURI, e.SecurityMode, e.SecurityLevel)
if e.SecurityPolicyURI == secPolicy && e.SecurityMode == secMode && (serverEndpoint == nil || e.SecurityLevel >= serverEndpoint.SecurityLevel) {
serverEndpoint = e
o.Log.Debugf(
"Security policy and mode found. Using server endpoint %s for security. Policy %s",
serverEndpoint.EndpointURL,
serverEndpoint.SecurityPolicyURI,
)
}
}
}
if serverEndpoint == nil { // Didn't find an endpoint with matching policy and mode.
return nil, errors.New("unable to find suitable server endpoint with selected sec-policy and sec-mode")
}
secPolicy = serverEndpoint.SecurityPolicyURI
secMode = serverEndpoint.SecurityMode
// Check that the selected endpoint is a valid combo
err = validateEndpointConfig(endpoints, secPolicy, secMode, authMode)
if err != nil {
return nil, fmt.Errorf("error validating input: %w", err)
}
opts = append(opts, opcua.SecurityFromEndpoint(serverEndpoint, authMode))
return opts, nil
}
func (o *OpcUAClient) generateAuth(a string, cert []byte, user, passwd config.Secret) (ua.UserTokenType, opcua.Option, error) {
var authMode ua.UserTokenType
var authOption opcua.Option
switch strings.ToLower(a) {
case "anonymous":
authMode = ua.UserTokenTypeAnonymous
authOption = opcua.AuthAnonymous()
case "username":
authMode = ua.UserTokenTypeUserName
var username, password []byte
if !user.Empty() {
usecret, err := user.Get()
if err != nil {
return 0, nil, fmt.Errorf("error reading the username input: %w", err)
}
defer usecret.Destroy()
username = usecret.Bytes()
}
if !passwd.Empty() {
psecret, err := passwd.Get()
if err != nil {
return 0, nil, fmt.Errorf("error reading the password input: %w", err)
}
defer psecret.Destroy()
password = psecret.Bytes()
}
authOption = opcua.AuthUsername(string(username), string(password))
case "certificate":
authMode = ua.UserTokenTypeCertificate
authOption = opcua.AuthCertificate(cert)
case "issuedtoken":
// todo: this is unsupported, fail here or fail in the opcua package?
authMode = ua.UserTokenTypeIssuedToken
authOption = opcua.AuthIssuedToken([]byte(nil))
default:
o.Log.Warnf("unknown auth-mode, defaulting to Anonymous")
authMode = ua.UserTokenTypeAnonymous
authOption = opcua.AuthAnonymous()
}
return authMode, authOption, nil
}
func validateEndpointConfig(endpoints []*ua.EndpointDescription, secPolicy string, secMode ua.MessageSecurityMode, authMode ua.UserTokenType) error {
for _, e := range endpoints {
if e.SecurityMode == secMode && e.SecurityPolicyURI == secPolicy {
for _, t := range e.UserIdentityTokens {
if t.TokenType == authMode {
return nil
}
}
}
}
return fmt.Errorf("server does not support an endpoint with security: %q, %q", secPolicy, secMode)
}