Skip to content

Commit 1efe16b

Browse files
committed
Merge #111 with modifications.
It contains a couple of fixes and simplifications: * Rebased on v3 refactor * CA certificate is embedded (like with the other Azure libraries) * Connection string can be used and is parsed by LoRa Gateway Bridge * Couple of minor fixes / changes Thanks @asanchezdelc for the initial work on this. Closes #111.
1 parent 5f7febd commit 1efe16b

File tree

10 files changed

+335
-6
lines changed

10 files changed

+335
-6
lines changed

cmd/lora-gateway-bridge/cmd/configfile.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,26 @@ marshaler="{{ .Integration.Marshaler }}"
262262
jwt_key_file="{{ .Integration.MQTT.Auth.GCPCloudIoTCore.JWTKeyFile }}"
263263
264264
265+
# Azure IoT Hub
266+
#
267+
# This setting will preset uplink and downlink topics that will only
268+
# work with Azure IoT Hub service.
269+
[integation.mqtt.auth.azure_iot_hub]
270+
271+
# Device connection string.
272+
#
273+
# This connection string can be retrieved from the Azure IoT Hub device
274+
# details.
275+
device_connection_string="{{ .Integration.MQTT.Auth.AzureIoTHub.DeviceConnectionString }}"
276+
277+
# Token expiration.
278+
#
279+
# LoRa Gateway Bridge will generate a SAS token with the given expiration.
280+
# After the token has expired, it will generate a new one and trigger a
281+
# re-connect.
282+
sas_token_expiration="{{ .Integration.MQTT.Auth.AzureIoTHub.SASTokenExpiration }}"
283+
284+
265285
# Metrics configuration.
266286
[metrics]
267287

cmd/lora-gateway-bridge/cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func init() {
6060
viper.SetDefault("integration.mqtt.auth.gcp_cloud_iot_core.server", "ssl://mqtt.googleapis.com:8883")
6161
viper.SetDefault("integration.mqtt.auth.gcp_cloud_iot_core.jwt_expiration", time.Hour*24)
6262

63+
viper.SetDefault("integration.mqtt.auth.azure_iot_hub.sas_token_expiration", 24*time.Hour)
64+
6365
rootCmd.AddCommand(versionCmd)
6466
rootCmd.AddCommand(configCmd)
6567
}

docs/content/install/config.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,15 @@ type="semtech_udp"
101101
# the time would otherwise be unset.
102102
fake_rx_time=false
103103

104-
# # Managed packet-forwarder configuration.
105-
# #
106-
# # By configuring one or multiple managed packet-forwarder sections, the
107-
# # LoRa Gateway Bridge updates the configuration when the backend receives
108-
# # a configuration change, after which it will restart the packet-forwarder.
109-
# [[packet_forwarder.configuration]]
104+
# Managed packet-forwarder configuration.
105+
#
106+
# By configuring one or multiple managed packet-forwarder sections, the
107+
# LoRa Gateway Bridge updates the configuration when the backend receives
108+
# a configuration change, after which it will restart the packet-forwarder.
109+
#
110+
# Example (this configuration can be repeated):
111+
#
112+
# [[backend.semtech_udp.configuration]]
110113
# # Gateway ID.
111114
# #
112115
# # The LoRa Gateway Bridge will only apply the configuration updates for this
@@ -135,6 +138,7 @@ type="semtech_udp"
135138
# # permissions to execute this command.
136139
# restart_command="/etc/init.d/lora-packet-forwarder restart"
137140

141+
138142
# Basic Station backend.
139143
[backend.basic_station]
140144

@@ -304,6 +308,26 @@ marshaler="protobuf"
304308
jwt_key_file=""
305309

306310

311+
# Azure IoT Hub
312+
#
313+
# This setting will preset uplink and downlink topics that will only
314+
# work with Azure IoT Hub service.
315+
[integation.mqtt.auth.azure_iot_hub]
316+
317+
# Device connection string.
318+
#
319+
# This connection string can be retrieved from the Azure IoT Hub device
320+
# details.
321+
device_connection_string=""
322+
323+
# Token expiration.
324+
#
325+
# LoRa Gateway Bridge will generate a SAS token with the given expiration.
326+
# After the token has expired, it will generate a new one and trigger a
327+
# re-connect.
328+
sas_token_expiration="24h0m0s"
329+
330+
307331
# Metrics configuration.
308332
[metrics]
309333

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
title: Azure IoT Hub
3+
menu:
4+
main:
5+
parent: integrate
6+
weight: 3
7+
description: Setting up the LoRa Gateway Bridge using the Azure IoT Hub MQTT protocol.
8+
---
9+
10+
# Azure IoT Hub
11+
12+
The Azure [IoT Hub](https://azure.microsoft.com/en-us/services/iot-hub/)
13+
authentication thype must be used when connecting with the
14+
[IoT Hub MQTT interface](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-mqtt-support).
15+
16+
## Limitations
17+
18+
* Please note that this authentication type is only available for the `json` or
19+
`protobuf` marshaler.
20+
* As you need to setup the device ID (in this case the device is the gateway)
21+
when provisioning the device (LoRa gateway) in Cloud IoT Core,
22+
this does not allow to connect multiple LoRa gateways to a single LoRa Gateway
23+
Bridge instance.
24+
25+
## Conventions
26+
27+
### Device ID naming
28+
29+
The IoT Hub Device ID must match the Gateway ID (e.g. `0102030405060708`).
30+
31+
### MQTT topics
32+
33+
When the Azure IoT Hub authentication type has been configured, LoRa Gateway
34+
Bridge will use MQTT topics which are expected by Azure IoT Hub and will
35+
ignore the MQTT topic configuration from the `lora-gateway-bridge.toml`
36+
configuration file.
37+
38+
#### Uplink topics
39+
40+
* `devices/[GATEWAY_ID]/messages/events/up`: uplink frame
41+
* `devices/[GATEWAY_ID]/messages/events/stats`: gateway statistics
42+
* `devices/[GATEWAY_ID]/messages/events/ack`: downlink frame acknowledgements (scheduling)
43+
44+
#### Downlink topics
45+
46+
* `devices/[GATEWAY_ID]/messages/devicebound/down`: scheduling downlink frame transmission
47+
* `devices/[GATEWAY_ID]/messages/devicebound/config`: gateway configuration
48+

docs/content/integrate/gcp-cloud-iot-core.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ menu:
44
main:
55
parent: integrate
66
weight: 3
7+
description: Setting up the LoRa Gateway Bridge using the GCP Cloud IoT Core MQTT Bridge.
78
---
89

910
# Google Cloud Platform Cloud IoT Core

docs/content/integrate/generic-mqtt.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ menu:
44
main:
55
parent: integrate
66
weight: 2
7+
description: Setting up the LoRa Gateway Bridge using a generic MQTT broker.
78
---
89

910
# Generic MQTT authentication

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ type Config struct {
7373
JWTExpiration time.Duration `mapstructure:"jwt_expiration"`
7474
JWTKeyFile string `mapstructure:"jwt_key_file"`
7575
} `mapstructure:"gcp_cloud_iot_core"`
76+
77+
AzureIoTHub struct {
78+
DeviceConnectionString string `mapstructure:"device_connection_string"`
79+
DeviceID string `mapstructure:"-"`
80+
Hostname string `mapstructure:"-"`
81+
DeviceKey string `mapstructure:"-"`
82+
SASTokenExpiration time.Duration `mapstructure:"sas_token_expiration"`
83+
} `mapstructure:"azure_iot_hub"`
7684
} `mapstructure:"auth"`
7785
} `mapstructure:"mqtt"`
7886
} `mapstructure:"integration"`
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package auth
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"fmt"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
mqtt "github.com/eclipse/paho.mqtt.golang"
15+
"github.com/pkg/errors"
16+
17+
"github.com/brocaar/lora-gateway-bridge/internal/config"
18+
)
19+
20+
// See:
21+
// https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-mqtt-support#tlsssl-configuration
22+
// https://github.com/Azure/azure-iot-sdk-c/blob/master/certs/certs.c
23+
const digiCertBaltimoreRootCA = `
24+
-----BEGIN CERTIFICATE-----
25+
MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ
26+
RTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYD
27+
VQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoX
28+
DTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9y
29+
ZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVy
30+
VHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKr
31+
mD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjr
32+
IZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeK
33+
mpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSu
34+
XmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZy
35+
dc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/ye
36+
jl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1
37+
BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3
38+
DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT92
39+
9hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3Wgx
40+
jkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0
41+
Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhz
42+
ksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLS
43+
R9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp
44+
-----END CERTIFICATE-----
45+
`
46+
47+
// AzureIoTHubAuthentication implements the Azure IoT Hub authentication.
48+
type AzureIoTHubAuthentication struct {
49+
clientID string
50+
username string
51+
deviceKey []byte
52+
hostname string
53+
sasTokenExpiration time.Duration
54+
55+
tlsConfig *tls.Config
56+
}
57+
58+
// NewAzureIoTHubAuthentication creates an AzureIoTHubAuthentication.
59+
func NewAzureIoTHubAuthentication(c config.Config) (Authentication, error) {
60+
conf := c.Integration.MQTT.Auth.AzureIoTHub
61+
62+
certpool := x509.NewCertPool()
63+
if !certpool.AppendCertsFromPEM([]byte(digiCertBaltimoreRootCA)) {
64+
return nil, errors.New("append ca cert from pem error")
65+
}
66+
67+
if conf.DeviceConnectionString != "" {
68+
kvMap, err := parseConnectionString(conf.DeviceConnectionString)
69+
if err != nil {
70+
return nil, errors.Wrap(err, "parse connection string error")
71+
}
72+
73+
for k, v := range kvMap {
74+
switch k {
75+
case "HostName":
76+
conf.Hostname = v
77+
case "DeviceId":
78+
conf.DeviceID = v
79+
case "SharedAccessKey":
80+
conf.DeviceKey = v
81+
}
82+
}
83+
}
84+
85+
username := fmt.Sprintf("%s/%s",
86+
conf.Hostname,
87+
conf.DeviceID,
88+
)
89+
90+
deviceKeyB, err := base64.StdEncoding.DecodeString(conf.DeviceKey)
91+
if err != nil {
92+
return nil, errors.Wrap(err, "decode device key error")
93+
}
94+
95+
return &AzureIoTHubAuthentication{
96+
clientID: conf.DeviceID,
97+
username: username,
98+
deviceKey: deviceKeyB,
99+
hostname: conf.Hostname,
100+
tlsConfig: &tls.Config{
101+
RootCAs: certpool,
102+
},
103+
}, nil
104+
}
105+
106+
// Init applies the initial configuration.
107+
func (a *AzureIoTHubAuthentication) Init(opts *mqtt.ClientOptions) error {
108+
broker := fmt.Sprintf("ssl://%s:8883", a.hostname)
109+
opts.AddBroker(broker)
110+
opts.SetClientID(a.clientID)
111+
opts.SetUsername(a.username)
112+
113+
return nil
114+
}
115+
116+
// Update updates the authentication options.
117+
func (a *AzureIoTHubAuthentication) Update(opts *mqtt.ClientOptions) error {
118+
resourceURI := fmt.Sprintf("%s/devices/%s",
119+
a.hostname,
120+
a.clientID,
121+
)
122+
token, err := createSASToken(resourceURI, a.deviceKey, a.sasTokenExpiration)
123+
if err != nil {
124+
return errors.Wrap(err, "create SAS token error")
125+
}
126+
127+
opts.SetPassword(token)
128+
129+
return nil
130+
}
131+
132+
// ReconnectAfter returns a time.Duration after which the MQTT client must re-connect.
133+
// Note: return 0 to disable the periodical re-connect feature.
134+
func (a *AzureIoTHubAuthentication) ReconnectAfter() time.Duration {
135+
return a.sasTokenExpiration
136+
}
137+
138+
func createSASToken(uri string, deviceKey []byte, expiration time.Duration) (string, error) {
139+
encoded := url.QueryEscape(uri)
140+
exp := time.Now().Add(expiration).Unix()
141+
142+
signature := fmt.Sprintf("%s\n%d", encoded, exp)
143+
144+
mac := hmac.New(sha256.New, deviceKey)
145+
mac.Write([]byte(signature))
146+
hash := url.QueryEscape(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
147+
148+
// IoT Hub SAS Token only needs `sr`, `sig` and `se` unlike other Azure services
149+
token := fmt.Sprintf("SharedAccessSignature sr=%s&sig=%s&se=%d",
150+
encoded,
151+
hash,
152+
exp,
153+
)
154+
155+
return token, nil
156+
}
157+
158+
func parseConnectionString(str string) (map[string]string, error) {
159+
out := make(map[string]string)
160+
pairs := strings.Split(str, ";")
161+
for _, pair := range pairs {
162+
kv := strings.SplitN(pair, "=", 2)
163+
if len(kv) != 2 {
164+
return nil, fmt.Errorf("expected two items in: %+v", kv)
165+
}
166+
167+
out[kv[0]] = kv[1]
168+
}
169+
170+
return out, nil
171+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseConnectionString(t *testing.T) {
11+
tests := []struct {
12+
Name string
13+
ConnectionString string
14+
ExpectedKV map[string]string
15+
ExpectedError error
16+
}{
17+
{
18+
Name: "valid string",
19+
ConnectionString: "HostName=gateways-eu868.azure-devices.net;DeviceId=00800000a00016b6;SharedAccessKey=WWVQv+auegGaG2mm2/0FIS24xqkmZW/z5cYBO898+8I=",
20+
ExpectedKV: map[string]string{
21+
"HostName": "gateways-eu868.azure-devices.net",
22+
"DeviceId": "00800000a00016b6",
23+
"SharedAccessKey": "WWVQv+auegGaG2mm2/0FIS24xqkmZW/z5cYBO898+8I=",
24+
},
25+
},
26+
{
27+
Name: "invalid string",
28+
ConnectionString: "HostName;gateways-eu868.azure-devices.net;DeviceId=00800000a00016b6;SharedAccessKey=WWVQv+auegGaG2mm2/0FIS24xqkmZW/z5cYBO898+8I=",
29+
ExpectedError: errors.New("expected two items in: [HostName]"),
30+
},
31+
}
32+
33+
for _, tst := range tests {
34+
t.Run(tst.Name, func(t *testing.T) {
35+
assert := require.New(t)
36+
37+
kv, err := parseConnectionString(tst.ConnectionString)
38+
assert.Equal(tst.ExpectedError, err)
39+
if err != nil {
40+
return
41+
}
42+
43+
assert.EqualValues(tst.ExpectedKV, kv)
44+
})
45+
}
46+
}

0 commit comments

Comments
 (0)