Deploying a secure MQTT test server on Azure with IPv6

MQTT (originally Message Queuing Telemetry Transport) is an important protocol for IoT that has been widely adopted. Devices deployed to the field may be connecting to existing MQTT endpoints, however you may also want to deploy your own MQTT server for testing purposes.

This article shows you how to deploy an Eclipse Mosquitto MQTT server onto Azure, configured for secure connections (MQTTS, which is MQTT over TLS), accessible over the internet, and including support for both IPv6 and legacy IPv4.

First we will configure a network in Azure, then deploy the server, and then test the deployment.

The instructions below show the individual commands, but if you want a quick start then full scripts, with automatic parameters, are available on Github https://github.com/sgryphon/iot-demo-build/blob/main/azure-mosquitto/README-mosquitto.md

To deploy the network and then server components via the scripts:

az login
az account set --subscription <subscription id>
$VerbosePreference = 'Continue'
./azure-landing/infrastructure/deploy-network.ps1
./azure-mosquitto/infrastructure/deploy-mosquitto.ps1 YourSecretPassword

Read on for the full details.

Configure an IPv6 network in Azure

To set up the server you will first need to configure a network in Azure.

If deploying into an existing environment, you may already be following something like the Cloud Adoption Framework and have a network set up in your subscription landing zone, possibly with routes and firewalls, however many blueprints do not include IPv6 so you may need some additional setup.

Otherwise, to deploy separately into a test environment, you can use PowerShell along with the Azure CLI to create a network with a "de-militarised zone" (DMZ) subnet, and full IPv6 support, that will be configured to allow the incoming public access.

The commands below will create the needed Azure resources, with dual stack support for both IPv6 and IPv4.

First login and set the subscription (if needed) to deploy to:

az login
az account set --subscription <subscription id>

Preparing deployment variables

Set up variables for the environment and location. The following generates a consistent unique ULA (Unique Local Address) network, based on the hash of the subscription ID, i.e. it will be the same each time you run the script, but will be unique, allowing interconnection with any other future network without collision.

$VerbosePreference = 'Continue'
$ErrorActionPreference="Stop"
$Environment = 'Dev'
$Location = 'australiaeast'
$UlaGlobalId = (Get-FileHash -InputStream ([IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes((az account show --query id --output tsv))))).Hash.Substring(0, 10),
$UlaDmzSubnetId = "0102"

Define the resource group name, virtual network name, subnet name, and network security group, following the Cloud Adoption Framework naming standards, and use the global ID to build the ULA global network prefix, and then the subnet prefix.

$rgName = "rg-network-$Environment-001".ToLowerInvariant()
$vnetName = "vnet-$Environment-$Location-001".ToLowerInvariant()
$dmzSnetName = "snet-dmz-$Environment-$Location-001".ToLowerInvariant()
$dmzNsgName = "nsg-dmz-$Environment-001".ToLowerInvariant()

$prefix = "fd$($UlaGlobalId.Substring(0, 2)):$($UlaGlobalId.Substring(2, 4)):$($UlaGlobalId.Substring(6))"
$globalAddress = [IPAddress]"$($prefix)::"
$dmzSubnetAddress = [IPAddress]"$($prefix):$UlaDmzSubnetId::"
$vnetIpPrefix = "$globalAddress/48"
$dmzSnetIpPrefix = "$dmzSubnetAddress/64"

Currently Azure only supports dual-stack networks, as NICs require a primary IPv4, so we need to also allocate and IPv4 network and subnet ranges to use. These use a 10.x.y.0/24 network where x is the first byte of the ULA Global ID, and y is the last byte of the subnet ID.

Note: as the IPv4 range is a lot more restricted, these are much more likely to clash if you have other existing networks and limit interconnectivity, but that won't affect basic testing.

$prefixByte = [int]"0x$($UlaGlobalId.Substring(0, 2))"
$vnetIPv4 = "10.$prefixByte.0.0/16"
$dmzSubnet = [int]"0x$UlaDmzSubnetId"
$dmzSnetIPv4 = "10.$prefixByte.$($dmzSubnet -bAnd 0xFF).0/24"

When deploying resources you should always tag them appropriately, e.g. following the Cloud Adoption Framework tagging conventions.

$TagDictionary = @{ DataClassification = 'Non-business'; Criticality = 'Low';
  BusinessUnit = 'IoT'; Env = $Environment }
$tags = $TagDictionary.Keys | ForEach-Object { $key = $_; "$key=$($TagDictionary[$key])" }

Deploy the network resources

Now that all the settings are ready, we can create the resource group:

az group create --name $rgName -l $Location --tags $tags

Then the network security group, along with some initial rules to allow ICMP, SSH (port 22) for access to the server, and both port 80 and 443 which will commonly be used by most servers, even if just to get a TLS certificate.

az network nsg create --name $dmzNsgName -g $rgName -l $Location --tags $tags

az network nsg rule create --name AllowSSH `
                           --nsg-name $dmzNsgName `
                           --priority 1000 `
                           --resource-group $rgName `
                           --access Allow `
                           --source-address-prefixes "*" `
                           --source-port-ranges "*" `
                           --direction Inbound `
                           --destination-port-ranges 22

az network nsg rule create --name AllowICMP `
                           --nsg-name $dmzNsgName `
                           --priority 1001 `
                           --resource-group $rgName `
                           --access Allow `
                           --source-address-prefixes "*" `
                           --direction Inbound `
                           --destination-port-ranges "*" `
                           --protocol Icmp

az network nsg rule create --name AllowHTTP `
                           --nsg-name $dmzNsgName `
                           --priority 1002 `
                           --resource-group $rgName `
                           --access Allow `
                           --source-address-prefixes "*" `
                           --source-port-ranges "*" `
                           --direction Inbound `
                           --destination-port-ranges 80 443

Finally, create the actual network, and then subnet.

az network vnet create --name $vnetName `
                       --resource-group $rgName `
                       --address-prefixes $vnetIpPrefix $vnetIPv4 `
                       --location $Location `
                       --tags $tags

az network vnet subnet create --name $dmzSnetName `
                              --address-prefix $dmzSnetIpPrefix $dmzSnetIPv4 `
                              --resource-group $rgName `
                              --vnet-name $vnetName `
                              --network-security-group $dmzNsgName

Once created, you can see the network in the Azure Portal, with the IPv6 address space.

Azure network created

IPv6 support

Internet traffic is currently getting over 40% IPv6 on the Google statistics, which means it is important to ensure (and test) your solution supports IPv6. Within a few years IPv4 will be in the minority.

An increasing number of networks are moving to IPv6 single stack only, for example Telstra's consumer mobile network is now IPv6-only. Without the users even noticing! Want to check? If you are on Telstra mobile go somewhere like https://whatismyv6.com/.

The Telstra Wireless Application Development Guidelines section 7.5 requires that all new IOT/M2M devices and new end-to-end applications support IPv6 natively, and that all systems will be configured as either dual stack or single stack IPv6.

All large scale deployments are expected to be single stack IPv6, and IPv6 support is a requirement if you want your devices to be Telstra certified.

To access IPv4 only sites, tunnelling is provided (NAT64 or XLAT464), and for most applications this should not matter, but you still need to test for the increasing IPv6 only deployments that you don't have any hidden issues.

Deploying the Mosquitto server

When deploying a server in Azure, we can pass a cloud-init file as custom data, which will specify the files to create and programs to install on the server, such as Certbot for automatic TLS certificates and the Mosquitto MQTT broker, and any configuration needed, such as firewall setup.

Initial Cloud Init file

Create a cloud-init.txt file with the following contents. If running as a script one tricky bit is that we want the cloud init to configure the server using the generated host names and other deployment parameters, so the file below has a number of placeholders such as #INIT_HOST_NAMES# that we will replace with the relevant values before creating the server.

The installed programs include Certbot, for generating the TLS certificates, the Mosquitto server, and Mosquitto tools. There is a script to copy the certificates on update and make them available to Mosquitto, as well as the Mosquitto configuration and Mosquitto password files.

There are four test users configured: mqttuser, mqttservice, mqttdevice1, and mqttdevice2, which will have a chosen password, with the extra users having the suffix 2, 3, and 4.

The run commands, on initial server setup, configure the firewall (ufw) to allow the desired traffic, hash the Mosquitto passwords, configure Certbot, copy across the certificates, and then restart Mosquitto.

#cloud-config
apt:
  conf: |
    Acquire::Retries "60";
    DPkg::Lock::Timeout "60";
  sources:
    mosquitto-ppa:
      # 2.3 PPA shortcut
      source: "ppa:mosquitto-dev/mosquitto-ppa"
package_upgrade: true
packages:
  - certbot
  - mosquitto
  - mosquitto-clients
write_files:
  - path: /etc/letsencrypt/renewal-hooks/deploy/10-mosquitto-copy.sh
    content: |
      #!/bin/sh
      SOURCE_DIR=/etc/letsencrypt/live/mosquitto-mqtt-cert
      CERTIFICATE_DIR=/etc/mosquitto/certs
      echo "Renew ${RENEWED_LINEAGE}"
      if [ "${RENEWED_LINEAGE}" = "${SOURCE_DIR}" ]; then
        # Copy new certificate to Mosquitto directory
        cp ${RENEWED_LINEAGE}/fullchain.pem ${CERTIFICATE_DIR}/server.pem
        cp ${RENEWED_LINEAGE}/privkey.pem ${CERTIFICATE_DIR}/server.key

        # Set ownership to Mosquitto
        chown mosquitto: ${CERTIFICATE_DIR}/server.pem ${CERTIFICATE_DIR}/server.key

        # Ensure permissions are restrictive
        chmod 0600 ${CERTIFICATE_DIR}/server.pem ${CERTIFICATE_DIR}/server.key

        # Tell Mosquitto to reload certificates and configuration
        pkill -HUP -x mosquitto
      fi
  - path: /etc/mosquitto/conf.d/default.conf
    content: |
      allow_anonymous false
      password_file /etc/mosquitto/passwd

      listener 1883 localhost

      listener 8883
      certfile /etc/mosquitto/certs/server.pem
      keyfile /etc/mosquitto/certs/server.key
  - path: /etc/mosquitto/passwd
    content: |
      mqttuser:#INIT_PASSWORD_INPUT#
      mqttservice:#INIT_PASSWORD_INPUT#2
      mqttdevice1:#INIT_PASSWORD_INPUT#3
      mqttdevice2:#INIT_PASSWORD_INPUT#4
runcmd:
  - ufw allow ssh
  - ufw allow http
  - ufw allow https
  - ufw allow 8883
  - ufw allow 8083
  - ufw enable
  - systemctl stop mosquitto
  - mosquitto_passwd -U /etc/mosquitto/passwd
  - certbot certonly --standalone --preferred-challenges http --cert-name mosquitto-mqtt-cert -d # -n --agree-tos -m #
  - RENEWED_LINEAGE=/etc/letsencrypt/live/mosquitto-mqtt-cert sh /etc/letsencrypt/renewal-hooks/deploy/10-mosquitto-copy.sh
  - systemctl start mosquitto

Preparing deployment variables

Similar to the network deployment, first set variables for the various configuration parameters.

Pick an appropriate value for the MQTT password.

The OrgId parameter is used to make global resource names, such as storage accounts and public DNS hosts unique. By default it is generated using the first four hex characters of your subscription ID, so you can easily run the commands as is in multiple personal developer subscriptions.

For a shared deployed you can alternatively use a more descriptive OrgId, such as purpleiot.

Note that for cost management the test server is set to shut down at 19:00 Brisbane time each day (09:00 UTC); you can change this to an appropriate value for your timezone, or provide an email for notifications.

$MqttPassword = 'YourSecretPassword'
$VerbosePreference = 'Continue'
$ErrorActionPreference="Stop"
$Environment = 'Dev'
$Location = 'australiaeast'
$OrgId = "0x$((az account show --query id --output tsv).Substring(0,4))"
$VmSize = 'Standard_B2s'
$AdminUsername = 'iotadmin'
$ServerNumber = 1
$ShutdownUtc = '0900'
$ShutdownEmail = ''
$AddPublicIpv4 = $true

Next generate the names of the new resources we will be creating, and the network resources we will be referencing (from the above), following the naming guidelines. The server name will be vmmosquitto001 and the unique DNS prefix mqtt001-<OrgId>-dev (with the full host name also based on the region).

$appName = 'mqtt'
$rgName = "rg-$appName-$Environment-001".ToLowerInvariant()

$networkRgName = "rg-network-$Environment-001".ToLowerInvariant()
$vnetName = "vnet-$Environment-$Location-001".ToLowerInvariant()
$dmzSnetName = "snet-dmz-$Environment-$Location-001".ToLowerInvariant()
$dmzNsgName = "nsg-dmz-$Environment-001".ToLowerInvariant()

$numericSuffix = $serverNumber.ToString("000")
$vmName = "vmmosquitto$numericSuffix"
$vmOsDisk = "osdiskvmmosquitto$numericSuffix"

$nicName = "nic-01-$vmName-$Environment-001".ToLowerInvariant()
$ipcName = "ipc-01-$vmName-$Environment-001".ToLowerInvariant()
$pipName = "pip-$vmName-$Environment-$Location-001".ToLowerInvariant()
$pipDnsName = "mqtt$numericSuffix-$OrgId-$Environment".ToLowerInvariant()
$pipv4Name = "pipv4-$vmName-$Environment-$Location-001".ToLowerInvariant()
$pipv4DnsName = "mqtt$numericSuffix-$OrgId-$Environment-ipv4".ToLowerInvariant()
$vmImage = 'UbuntuLTS'

Also create tags, similar to the network.

$TagDictionary = @{ WorkloadName = 'iot'; DataClassification = 'Non-business'; Criticality = 'Low';
  BusinessUnit = 'Dev'; ApplicationName = $appName; Env = $Environment }
$tags = $TagDictionary.Keys | ForEach-Object { $key = $_; "$key=$($TagDictionary[$key])" }

Updating the network security group

We also need to add a rule to the existing NSG to allow MQTTS traffic.

az network nsg rule create --name AllowMQTT `
                           --nsg-name $dmzNsgName `
                           --priority 2200 `
                           --resource-group $networkRgName `
                           --access Allow `
                           --source-address-prefixes "*" `
                           --source-port-ranges "*" `
                           --direction Inbound `
                           --destination-port-ranges 8883 8083

Deploying

To deploy the new resources, first we need to get the network and generate IP addresses for the server. These are based on the server number (to allow multiple MQTT servers), and assume the networks were generated as above, with ranges ending in /64 (and 0/24 for the IPv4 range). The statically allocated server address suffixes will be :1201, :1202, :1203, etc, based on the server number (and .21, etc for IPv4).

$dmzSnet = az network vnet subnet show --name $dmzSnetName -g $networkRgName --vnet-name $vnetName | ConvertFrom-Json

$privateIpSuffix = (0x1200 + $serverNumber).ToString("x")
$privateIPv4Suffix = 20 + $ServerNumber
$dmzUlaPrefix = $dmzSnet.addressPrefixes | Where-Object { $_.StartsWith('fd') } | Select-Object -First 1
$vmIpAddress = "$($dmzUlaPrefix.Substring(0, $dmzUlaPrefix.Length - 3))$privateIpSuffix"
$dmzIPv4Prefix = $dmzSnet.addressPrefixes | Where-Object { $_.StartsWith('10.') } | Select-Object -First 1
$vmIPv4 = "$($dmzIPv4Prefix.Substring(0, $dmzIPv4Prefix.Length - 4))$privateIPv4Suffix"

Next create a resource group for the server, the public IP (v6) address, the network interface card (which must have the initial internal IPv4 address), then add the public address configuration (with both private and public IPv6) to the NIC. Finally create a public IPv4 address (for clients that don't support IPv6) and update the default config.

The commands also collect the generated host names, which will have the unique prefix (set above), region, and other details, which will be passed to Certbot to create the TLS certificate.

az group create --name $rgName -l $Location --tags $tags

az network public-ip create `
  --name $pipName  `
  --dns-name $pipDnsName `
  --resource-group $rgName `
  --location $Location `
  --sku Standard  `
  --allocation-method static  `
  --version IPv6 `
  --tags $tags

az network nic create `
  --name $nicName `
  --resource-group $rgName `
  --subnet $dmzSnet.Id `
  --private-ip-address $vmIPv4 `
  --tags $tags

az network nic ip-config create `
  --name $ipcName `
  --nic-name $nicName  `
  --resource-group $rgName `
  --subnet $dmzSnet.Id `
  --private-ip-address $vmIpAddress `
  --private-ip-address-version IPv6 `
  --public-ip-address $pipName

$hostNames = $(az network public-ip show --name $pipName --resource-group $rgName --query dnsSettings.fqdn --output tsv)
$certEmail = "postmaster@$hostNames" # Using the main host name

az network public-ip create `
  --name $pipv4Name  `
  --dns-name $pipv4DnsName `
  --resource-group $rgName `
  --location $Location  `
  --sku Standard  `
  --allocation-method static  `
  --version IPv4 `
  --tags $tags

az network nic ip-config update `
  --name 'ipconfig1' `
  --nic-name $nicName `
  -g $rgName `
  --public-ip-address $pipv4Name

$hostNames = "$hostNames,$(az network public-ip show --name $pipv4Name --resource-group $rgName --query dnsSettings.fqdn --output tsv)"

Updating the Cloud Init file

Once we have the host names, we can insert them into the template Cloud Init file, along with the other parameters, by replacing the tokens, and then storing in a temporary cloud-init.txt~ file.

(Get-Content -Path (Join-Path $PSScriptRoot cloud-init.txt) -Raw) `
  -replace '#INIT_HOST_NAMES#',$hostNames `
  -replace '#INIT_CERT_EMAIL#',$certEmail `
  -replace '#INIT_PASSWORD_INPUT#',$MqttPassword `
  | Set-Content -Path (Join-Path $PSScriptRoot cloud-init.txt~)

Deploying the server

We are now ready to create the actual server, using the updated Cloud Init file, and configure the auto shutdown.

Note that --generate-ssh-keys will configure the SSH certificate for the current user. You can also use az vm user update to add SSH access for additional users.

az vm create `
    --resource-group $rgName `
    --name $vmName `
    --size $VmSize `
    --image $vmImage `
    --os-disk-name $vmOsDisk `
    --admin-username $AdminUsername `
    --generate-ssh-keys `
    --nics $nicName `
    --public-ip-sku Standard `
    --custom-data cloud-init.txt~ `
    --tags $tags

if ($ShutdownUtc) {
  if ($ShutdownEmail) {
    az vm auto-shutdown -g $rgName -n $vmName --time $ShutdownUtc --email $ShutdownEmail
  } else {
    az vm auto-shutdown -g $rgName -n $vmName --time $ShutdownUtc
  }
}

Once the server is created, you can see it in the Azure Portal, with the allocated IPv6 addresses and other details:

Azure MQTT server created

Testing the deployment

After the creation process, you can output the details of the server, including the host names and public IP addresses:

$vm = (az vm show --name $vmName -g $rgName -d) | ConvertFrom-Json
$vm | Format-List name, fqdns, publicIps, privateIps, location, hardwareProfile

This will output something like:

Output created MQTT server details

To test you will need to have some MQTT tools installed, such as the Mosquitto client tools. Then you can test the server, first subscribe in one terminal:

$mqttPassword = 'YourSecretPassword'
mosquitto_sub -h mqtt001-0xacc5-dev.australiaeast.cloudapp.azure.com  -t '#' -F '%I %t [%l] %p' -v -p 8883 -u mqttuser -P $mqttPassword

Then use another terminal to publish a message (note that mqttdevice1 has the password suffix '3'):

$mqttDevice1Password = 'YourSecretPassword3'
mosquitto_pub -h mqtt001-0xacc5-dev.australiaeast.cloudapp.azure.com -t test -m '[{"n":"urn:dev:ow:10e2073a01080063","u":"Cel","v":23.1}]' -p 8883 -u mqttdevice1 -P $mqttDevice1Password

The successful message will be displayed in the subscribed client (and in the server logs):

MQTTS server working

SSH access

You can also SSH into the server, to check the application (the script automatically assigns your local SSH key with access):

ssh iotadmin@mqtt001-0xacc5-dev.australiaeast.cloudapp.azure.com

You can then follow the Mosquitto logs, as shown in the screen above, with:

sudo tail -f /var/log/mosquitto/mosquitto.log

Next steps

Once you have a secure MQTT test server set up on the Internet, you can use it to test and validate devices. We will look to cover the device side in a future article.

Consider the Telstra Wireless Application Development Guidelines, particularly the considerations around IPv6 and security; and if you are looking to get devices Telstra Certified then these features are required: https://www.telstra.com.au/business-enterprise/products/internet-of-things/capabilities

In some cases existing devices may not support IPv6 -- the deployed MQTT server is dual stack, so you can use the alternate host name to access it via IPv4.

You may also have existing devices that don't support MQTTS / TLS security. In these cases you would need to set up a private APN (Access Point Name) and private network to your service endpoint, to ensure that credentials are kept secure. For testing purposes you could set up an insecure MQTT test server, on a temporary or limited basis.

Note that while MQTT is a standardised protocol it does not define any semantics of the messages being transported. This means that simply supporting MQTT does not mean that two services can communicate. What is sent over MQTT ranges from the complex usernames and long topics of Azure IoT hub, sending verbose JSON messages through to short topics like s/us and compact comma separated values used by Cumulocity.

This means that even once you have two systems connected via MQTT there may still be custom work needed to translate the payloads.

Leave a Reply

Your email address will not be published.