Mqtt: add mTLS authentication using certificates to MQTT (#15563)

This commit is contained in:
Tom Jason Schwanke 2024-09-17 21:23:35 +02:00 • committed by GitHub
parent 42fa9d263e
commit 0157ea6276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 136 additions and 17 deletions

View file

@ -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>

View 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>

View file

@ -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
}

View file

@ -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)

View file

@ -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"

View file

@ -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"

View file

@ -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 {

View file

@ -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 {

View file

@ -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 }}