Skip to content

Commit

Permalink
Mqtt: add mTLS authentication using certificates to MQTT (#15563)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomjschwanke authored Sep 17, 2024
1 parent 42fa9d2 commit 0157ea6
Show file tree
Hide file tree
Showing 9 changed files with 136 additions and 17 deletions.
21 changes: 20 additions & 1 deletion assets/js/components/Config/MqttModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,36 @@
</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>

<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 changes: 55 additions & 0 deletions assets/js/components/Config/PropertyCertField.vue
Original file line number Diff line number Diff line change
@@ -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>
17 changes: 13 additions & 4 deletions cmd/configure/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,26 +238,35 @@ 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("--------------------------------------------")

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
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions i18n/de.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ experimental = "Experimentell"
off = "aus"
on = "an"
password = "Passwort"
readFromFile = "Aus Datei lesen"
remove = "Entfernen"
save = "Speichern"
telemetry = "Telemetrie"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions i18n/en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ experimental = "Experimental"
off = "off"
on = "on"
password = "Password"
readFromFile = "Read from file"
remove = "Remove"
save = "Save"
telemetry = "Telemetry"
Expand Down Expand Up @@ -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"
Expand Down
35 changes: 27 additions & 8 deletions provider/mqtt/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mqtt

import (
"crypto/tls"
"crypto/x509"
"fmt"
"math/rand/v2"
"strings"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions provider/mqtt/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
}
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions util/templates/includes/mqtt.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

0 comments on commit 0157ea6

Please sign in to comment.