Mqtt: add mTLS authentication using certificates to MQTT (#15563)
This commit is contained in:
parent
42fa9d263e
commit
0157ea6276
9 changed files with 136 additions and 17 deletions
|
|
@ -63,6 +63,23 @@
|
|||
</label>
|
||||
</div>
|
||||
</FormRow>
|
||||
<PropertyCollapsible>
|
||||
<template #advanced>
|
||||
<FormRow id="mqttCaCert" :label="$t('config.mqtt.labelCaCert')" optional>
|
||||
<PropertyCertField id="mqttCaCert" v-model="values.caCert" />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
id="mqttClientCert"
|
||||
:label="$t('config.mqtt.labelClientCert')"
|
||||
optional
|
||||
>
|
||||
<PropertyCertField id="mqttClientCert" v-model="values.clientCert" />
|
||||
</FormRow>
|
||||
<FormRow id="mqttClientKey" :label="$t('config.mqtt.labelClientKey')" optional>
|
||||
<PropertyCertField id="mqttClientKey" v-model="values.clientKey" />
|
||||
</FormRow>
|
||||
</template>
|
||||
</PropertyCollapsible>
|
||||
</template>
|
||||
</JsonModal>
|
||||
</template>
|
||||
|
|
@ -70,10 +87,12 @@
|
|||
<script>
|
||||
import JsonModal from "./JsonModal.vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyCollapsible from "./PropertyCollapsible.vue";
|
||||
import PropertyCertField from "./PropertyCertField.vue";
|
||||
|
||||
export default {
|
||||
name: "MqttModal",
|
||||
components: { FormRow, JsonModal },
|
||||
components: { FormRow, JsonModal, PropertyCollapsible, PropertyCertField },
|
||||
emits: ["changed"],
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
55
assets/js/components/Config/PropertyCertField.vue
Normal file
55
assets/js/components/Config/PropertyCertField.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div>
|
||||
<textarea :id="id" v-model="value" class="form-control" rows="3" />
|
||||
<div class="d-flex justify-content-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-muted pe-0"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
{{ $t("config.general.readFromFile") }}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
class="d-none"
|
||||
@change="readFile"
|
||||
accept=".crt,.pem,.cer,.csr,.key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PropertyCertField",
|
||||
props: {
|
||||
id: String,
|
||||
modelValue: String,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
computed: {
|
||||
value: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openFilePicker() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
readFile(event) {
|
||||
const file = event.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.value = e.target.result;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -238,11 +238,17 @@ func (c *CmdConfigure) configureMQTT(_ templates.Template) (map[string]interface
|
|||
_, paramPort := templates.ConfigDefaults.ParamByName("port")
|
||||
_, paramUser := templates.ConfigDefaults.ParamByName("user")
|
||||
_, paramPassword := templates.ConfigDefaults.ParamByName("password")
|
||||
_, paramCaCert := templates.ConfigDefaults.ParamByName("caCert")
|
||||
_, paramClientCert := templates.ConfigDefaults.ParamByName("clientCert")
|
||||
_, paramClientKey := templates.ConfigDefaults.ParamByName("clientKey")
|
||||
|
||||
host := c.askParam(paramHost)
|
||||
port := c.askParam(paramPort)
|
||||
user := c.askParam(paramUser)
|
||||
password := c.askParam(paramPassword)
|
||||
caCert := c.askParam(paramCaCert)
|
||||
clientCert := c.askParam(paramClientCert)
|
||||
clientKey := c.askParam(paramClientKey)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("--------------------------------------------")
|
||||
|
|
@ -250,14 +256,17 @@ func (c *CmdConfigure) configureMQTT(_ templates.Template) (map[string]interface
|
|||
broker := fmt.Sprintf("%s:%s", host, port)
|
||||
|
||||
mqttConfig := map[string]interface{}{
|
||||
"broker": broker,
|
||||
"user": user,
|
||||
"password": password,
|
||||
"broker": broker,
|
||||
"user": user,
|
||||
"password": password,
|
||||
"caCert": caCert,
|
||||
"clientCert": clientCert,
|
||||
"clientKey": clientKey,
|
||||
}
|
||||
|
||||
log := util.NewLogger("mqtt")
|
||||
|
||||
if mqtt.Instance, err = mqtt.RegisteredClient(log, broker, user, password, "", 1, false); err == nil {
|
||||
if mqtt.Instance, err = mqtt.RegisteredClient(log, broker, user, password, "", 1, false, caCert, clientCert, clientKey); err == nil {
|
||||
return mqttConfig, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@ func configureMqtt(conf *globalconfig.Mqtt) error {
|
|||
|
||||
log := util.NewLogger("mqtt")
|
||||
|
||||
instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, func(options *paho.ClientOptions) {
|
||||
instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, conf.CaCert, conf.ClientCert, conf.ClientKey, func(options *paho.ClientOptions) {
|
||||
topic := fmt.Sprintf("%s/status", strings.Trim(conf.Topic, "/"))
|
||||
options.SetWill(topic, "offline", 1, true)
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ experimental = "Experimentell"
|
|||
off = "aus"
|
||||
on = "an"
|
||||
password = "Passwort"
|
||||
readFromFile = "Aus Datei lesen"
|
||||
remove = "Entfernen"
|
||||
save = "Speichern"
|
||||
telemetry = "Telemetrie"
|
||||
|
|
@ -155,8 +156,11 @@ description = "Verbinde evcc mit einem MQTT-Broker, um Daten mit anderen Systeme
|
|||
descriptionClientId = "Autor der Nachrichten. Wenn leer, wird `evcc-[rand]` verwendet."
|
||||
descriptionTopic = "Leer lassen, um das Publizieren zu deaktivieren."
|
||||
labelBroker = "Broker"
|
||||
labelCaCert = "Serverzertifikat (CA)"
|
||||
labelCheckInsecure = "Erlaube unsichere Verbindungen"
|
||||
labelClientCert = "Clientzertifikat"
|
||||
labelClientId = "Client ID"
|
||||
labelClientKey = "Client-Key"
|
||||
labelInsecure = "Zertifikatüberprüfung"
|
||||
labelPassword = "Passwort"
|
||||
labelTopic = "Thema"
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ experimental = "Experimental"
|
|||
off = "off"
|
||||
on = "on"
|
||||
password = "Password"
|
||||
readFromFile = "Read from file"
|
||||
remove = "Remove"
|
||||
save = "Save"
|
||||
telemetry = "Telemetry"
|
||||
|
|
@ -154,8 +155,11 @@ description = "Connect to an MQTT broker to exchange data with other systems on
|
|||
descriptionClientId = "Author of the messages. If empty `evcc-[rand]` is used."
|
||||
descriptionTopic = "Leave empty to disable publishing."
|
||||
labelBroker = "Broker"
|
||||
labelCaCert = "Server certificate (CA)"
|
||||
labelCheckInsecure = "Allow self-signed certificates"
|
||||
labelClientCert = "Client certificate"
|
||||
labelClientId = "Client ID"
|
||||
labelClientKey = "Client key"
|
||||
labelInsecure = "Certificate validation"
|
||||
labelPassword = "Password"
|
||||
labelTopic = "Topic"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package mqtt
|
|||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
|
@ -25,11 +26,14 @@ func ClientID() string {
|
|||
|
||||
// Config is the public configuration
|
||||
type Config struct {
|
||||
Broker string `json:"broker"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"clientID"`
|
||||
Insecure bool `json:"insecure"`
|
||||
Broker string `json:"broker"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"clientID"`
|
||||
Insecure bool `json:"insecure"`
|
||||
CaCert string `json:"caCert"`
|
||||
ClientCert string `json:"clientCert"`
|
||||
ClientKey string `json:"clientKey"`
|
||||
}
|
||||
|
||||
// Client encapsulates mqtt publish/subscribe functions
|
||||
|
|
@ -48,7 +52,7 @@ type Option func(*paho.ClientOptions)
|
|||
const secure = "tls://"
|
||||
|
||||
// NewClient creates new Mqtt publisher
|
||||
func NewClient(log *util.Logger, broker, user, password, clientID string, qos byte, insecure bool, opts ...Option) (*Client, error) {
|
||||
func NewClient(log *util.Logger, broker, user, password, clientID string, qos byte, insecure bool, caCert, clientCert, clientKey string, opts ...Option) (*Client, error) {
|
||||
broker, isSecure := strings.CutPrefix(broker, secure)
|
||||
|
||||
// strip schema as it breaks net.SplitHostPort
|
||||
|
|
@ -74,9 +78,24 @@ func NewClient(log *util.Logger, broker, user, password, clientID string, qos by
|
|||
options.SetConnectionLostHandler(mc.ConnectionLostHandler)
|
||||
options.SetConnectTimeout(request.Timeout)
|
||||
|
||||
if insecure {
|
||||
options.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
}
|
||||
if caCert != "" {
|
||||
caCertPool := x509.NewCertPool()
|
||||
if ok := caCertPool.AppendCertsFromPEM([]byte(caCert)); !ok {
|
||||
return nil, fmt.Errorf("failed to add ca cert to cert pool")
|
||||
}
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
}
|
||||
if clientCert != "" && clientKey != "" {
|
||||
clientKeyPair, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add client cert: %w", err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{clientKeyPair}
|
||||
}
|
||||
options.SetTLSConfig(tlsConfig)
|
||||
|
||||
// additional options
|
||||
for _, o := range opts {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ var (
|
|||
)
|
||||
|
||||
// RegisteredClient reuses an registered Mqtt publisher or creates a new one
|
||||
func RegisteredClient(log *util.Logger, broker, user, password, clientID string, qos byte, insecure bool, opts ...Option) (*Client, error) {
|
||||
func RegisteredClient(log *util.Logger, broker, user, password, clientID string, qos byte, insecure bool, caCert, clientCert, clientKey string, opts ...Option) (*Client, error) {
|
||||
key := fmt.Sprintf("%s.%s:%s", broker, user, password)
|
||||
|
||||
mu.Lock()
|
||||
|
|
@ -42,7 +42,7 @@ func RegisteredClient(log *util.Logger, broker, user, password, clientID string,
|
|||
clientID = ClientID()
|
||||
}
|
||||
|
||||
if client, err = NewClient(log, broker, user, password, clientID, qos, insecure, opts...); err == nil {
|
||||
if client, err = NewClient(log, broker, user, password, clientID, qos, insecure, caCert, clientCert, clientKey, opts...); err == nil {
|
||||
registry.Add(key, client)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ func RegisteredClientOrDefault(log *util.Logger, cc Config) (*Client, error) {
|
|||
|
||||
var err error
|
||||
if cc.Broker != "" {
|
||||
client, err = RegisteredClient(log, cc.Broker, cc.User, cc.Password, cc.ClientID, 1, cc.Insecure)
|
||||
client, err = RegisteredClient(log, cc.Broker, cc.User, cc.Password, cc.ClientID, 1, cc.Insecure, cc.CaCert, cc.ClientCert, cc.ClientKey)
|
||||
}
|
||||
|
||||
if client == nil && err == nil {
|
||||
|
|
|
|||
|
|
@ -9,4 +9,13 @@ password: {{ .password }}
|
|||
{{- if ne .timeout "30s" }}
|
||||
timeout: {{ .timeout }}
|
||||
{{- end }}
|
||||
{{- if .caCert }}
|
||||
caCert: {{ .caCert }}
|
||||
{{- end }}
|
||||
{{- if .clientCert }}
|
||||
clientCert: {{ .clientCert }}
|
||||
{{- end }}
|
||||
{{- if .clientKey }}
|
||||
clientKey: {{ .clientKey }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue