- 一個基於Mqtt的小項目,服務器採用mosquitto,客戶端有Python,C,Android三種,涉及SSL加密,傳輸內容:文字圖片。
- 時間推移,難免忘記當時學習配置的細節,有走不通的或者其他建議歡迎指出,我們一起努力讓過程變成一條直線而不是虛線。
- 環境:Ubuntu 16.04
目錄
一..Mqtt相關
MQTT(Message Queuing Telemetry Transport,消息隊列遙測傳輸)是IBM開發的一個即時通訊協議,有可能成爲物聯網的重要組成部分。該協議支持所有平臺,幾乎可以把所有聯網物品和外部連接起來,被用來當做傳感器和制動器(比如通過Twitter讓房屋聯網)的通信協議。
1.1 延伸閱讀
推薦一些背景補充:
協議詳細內容,我肯定說得不如協議內容中文版,建議大家先掃一下,對一些名詞有印象,後續再查看。
其中,比較重要的部分,也是代碼裏需要設置的可變頭部部分,推薦幾個比較好的學習地方:
1.2 協議特點
- Mqtt使用發佈/訂閱的消息模式,提供一對多消息分發.
- 對傳輸消息有三種服務質量(QoS):
- 最多一次,這一級別會發生消息丟失或重複,消息發佈依賴於底層TCP/IP網絡。即:<=1
- 至多一次,這一級別會確保消息到達,但消息可能會重複。即:>=1
- 只有一次,確保消息只有一次到達。即:=1。在一些要求比較嚴格的計費系統中,可以使用此級別
訂閱和發佈以及代理服務器的理解示意圖:
工作流:
服務器先啓動,然後客戶端訂閱相關的Topic。Client A 和C發佈主題爲:Question
的What's the temperature?
。Client B因爲訂閱了Question
這個Topic,所以可以收到信息,Client B收到信息做判斷後發佈答案Topic: Temperture
出去,訂閱了相關Topic的Client A 和Client C能接收到37°。
- 實現MQTT協議需要:客戶端和服務器端
- MQTT協議中有三種身份:發佈者(Publish)、代理(Broker)(服務器)、訂閱者(Subscribe)。其中,消息的發佈者和訂閱者都是客戶端,消息代理是服務器,消息發佈者可以同時是訂閱者。
- MQTT傳輸的消息分爲:主題(Topic)和負載(payload)兩部分
Topic,可以理解爲消息的主題,訂閱者訂閱(Subscribe)後,就會收到該主題的消息內容(payload)
payload,可以理解爲消息的內容,是指訂閱者具體要使用的內容
代理服務器,是用於服務客戶端的,目前很多公司都有相關的服務: 列表
裏面我只選用過Mosquitto,也就不做分析.
二..服務器
2.1 Mosquitto的安裝與使用
- Mosquitto加入庫並更新:
sudo apt-add-repository ppa:mosquitto-dev/mosquitto-ppa
sudo apt-get update
- 安裝:
sudo apt-get install mosquitto
安裝後位於:/etc/mosquitto
,裏面可以看到默認配置文件mosquitto.conf
- 查看狀態
sudo service mosquitto status
- 開啓和停止Mosquitto服務:
sudo service mosquitto start
sudo service mosquitto stop (常用,在測試SSL的時候)
- Mosquittof服務器啓動:
上面的指令是加載默認參數,大多時候喜歡自己啓動,也不麻煩
mosquitto -v (加載默認配置)
mosquitto -v -c xx/xx/mosquitto.conf -p 8883 (加載指定配置)
避免麻煩,建議在使用SSL新建一個配置文件,這樣不用一直改來改去。-c加載配置文件,-p端口。
TCP端口8883和1883已在IANA註冊,分別用於MQTT的TLS和非TLS通信。
2.2 Mosquitto-clients
上一步只是安裝了Mosquitto服務器,不包括客戶端,安裝這個是用於調試,可以在命令行測試證書、ip等,很方便。
- 安裝
sudo apt-get install mosquitto-clients
- 訂閱
mosquitto_sub -t temperature
- 發佈
mosquitto_pub -t temperature -m 37°
結果:
三..Python客戶端
服務器我們藉助mosquitto軟件來試下,那麼客戶端我們當然不會自己去寫一個協議.顯然已經有很多先驅寫了,我們只需要導入就好了.這裏採用比較出名的Eclipse Paho庫,它包含的各種語言,或者庫列表.
看圖就明白了:
3.1 導入庫
pip3 install paho-mqtt
3.2 Code
- 關鍵指令
#導入包
import paho.mqtt.client as mqtt
#創建client對象
client = mqtt.Client(id)
#連接
client.connect(host,post)
#訂閱
client.subscribe(topic)
client.on_message = func #接收到信息後的處理函數
#發佈
client.publish(topic, payload)
- 完整Code
import paho.mqtt.client as mqtt
import sys
#改成自己的ip,命令ifconfig可以查看
host = "xx.xxx.xxx.xxx"
topic_sub = "Question"
topic_pub = "temperature"
def on_connect(client, userdata, flags, rc):
print("Connected with result code " + str(rc))
client.subscribe(topic_sub)
def on_message(client, userdata, msg):
print(msg.payload)
client.publish(topic_pub, "37°")
def main(argv = None):
#聲明客戶端
client = mqtt.Client()
#連接
client.connect(host, 1883, 60)
#兩個回調函數,用於執行連接成功和接收到信息要做的事
client.on_connect = on_connect
client.on_message = on_message
client.loop_forever()
if __name__ == "__main__":
sys.exit(main())
運行python客戶端,然後在終端發佈一條消息
mosquitto_pub -t Question -m 123
結果:
可以看到我發佈一條消息,然後上述python客戶端接到後發送了一條37°
信息出來,被訂閱了temperature
的終端接收了.如果你不想知道圖片怎麼發送和接收,跳過這一小段:
#發送端
fp = open("7.jpg", "rb")
payload = fp.read()
client.publish(topic_pub, payload)
#接收端
def on_message(client, userdata, msg):
new_filename = "new_img.jpg"
fp = open(new_filename, 'wb')
fp.write(msg.payload)
fp.close()
這個可以推廣到傳輸其他文件.
四..C客戶端
4.1 導入庫
從源碼編譯,這個稍微比較麻煩,安裝過程如下,注意文件整理:
git clone https://github.com/eclipse/paho.mqtt.c.git
cd paho.mqtt.c
make
sudo make install
在使用的過程中也要注意編譯的方式,這裏提供一個Makefile做參考:
test:test.cpp cmqtt.cpp cmqtt.h
g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3c \
-I ../../paho.mqtt.c/src \
-L ../../paho.mqtt.c/build \
-pthread -Imqtt \
-std=c++11
4.2 Code
C代碼方面我提供關鍵部分供初學者容易上手,我自己進行過一次c++的封裝,比較複雜.有時間的話另開一帖.
- 首先,C代碼方面我們肯定不會是隻做一件事情,所以我們需要開兩個線程兩個客戶端來進行訂閱和發佈.一個同時發佈和訂閱,也可以.
//mqttclient.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "MQTTClient.h"
#include <unistd.h>
#include <sys/stat.h>
#define NUM_THREADS 2
#define ADDRESS "tcp://xx.xxx.xx.xxx:1883"
#define CLIENTID "ExampleClient_pub"
#define SUB_CLIENTID "ExampleClient_sub" //更改此處客戶端ID
#define TOPICPUB "Question" //更改發送的話題
#define TOPICSUB "temperature"
#define QOS 1
#define TIMEOUT 10000L
#define DISCONNECT "out"
int CONNECT = 1;
volatile MQTTClient_deliveryToken deliverytoken;
long PAYLOADLEN;
char* PAYLOAD;
void delivered(void *context, MQTTClient_deliveryToken dt)
{
printf("Message with token value %d delivery confirmed\n", dt);
deliverytoken = dt;
}
int msgarrvd(void *context, char *topicName, int topicLen, MQTTClient_message *message)
{
int i;
char* payloadptr;
printf("Message arrived\n");
printf(" topic: %s\n", topicName);
printf(" message: \n");
payloadptr = message->payload;
if (strcmp(payloadptr, DISCONNECT) == 0) {
printf("\n out!!");
CONNECT = 0;
}
for (i = 0; i < message->payloadlen; i++) {
putchar(*payloadptr++);
}
printf("\n");
MQTTClient_freeMessage(&message);
MQTTClient_free(topicName);
return 1;
}
void connlost(void *context, char *cause)
{
printf("\nConnection lost\n");
printf(" cause: %s\n", cause);
}
void *pubClient(void *threadid) {
long tid;
tid = (long)threadid;
int count = 0;
printf("Hello World! It's me, thread #%ld!\n", tid);
//聲明一個MQTTClient
MQTTClient client;
//初始化MQTT Client選項
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
//#define MQTTClient_message_initializer { {'M', 'Q', 'T', 'M'}, 0, 0, NULL, 0, 0, 0, 0 }
MQTTClient_message pubmsg = MQTTClient_message_initializer;
//聲明消息token
MQTTClient_deliveryToken token;
int rc;
//使用參數創建一個client,並將其賦值給之前聲明的client
MQTTClient_create(&client, ADDRESS, CLIENTID,
MQTTCLIENT_PERSISTENCE_NONE, NULL);
conn_opts.keepAliveInterval = 20;
conn_opts.cleansession = 1;
//使用MQTTClient_connect將client連接到服務器,使用指定的連接選項。成功則返回MQTTCLIENT_SUCCESS
if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
{
printf("Failed to connect, return code %d\n", rc);
exit(EXIT_FAILURE);
}
PAYLOAD = "What's the temperature";
// printf("%s\n", PAYLOAD);
pubmsg.payload = PAYLOAD;
pubmsg.payloadlen = (int)strlen(PAYLOAD);
pubmsg.qos = QOS;
pubmsg.retained = 0;
//循環發佈
while (CONNECT) {
MQTTClient_publishMessage(client, TOPICPUB, &pubmsg, &token);
printf("Waiting for up to %d seconds for publication of %s\n"
"on topic %s for client with ClientID: %s\n",
(int)(TIMEOUT/1000), PAYLOAD, TOPICPUB, CLIENTID);
rc = MQTTClient_waitForCompletion(client, token, TIMEOUT);
printf("Message with delivery token %d delivered\n", token);
// thread sleep
usleep(2000000L);
}
MQTTClient_disconnect(client, 10000);
MQTTClient_destroy(&client);
}
void *subClient(void *threadid) {
long tid;
tid = (long)threadid;
printf("Hello World! It's me, thread #%ld!\n", tid);
MQTTClient client;
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
int rc;
int ch;
MQTTClient_create(&client, ADDRESS, SUB_CLIENTID,
MQTTCLIENT_PERSISTENCE_NONE, NULL);
conn_opts.keepAliveInterval = 20;
conn_opts.cleansession = 1;
//設置回調函數
MQTTClient_setCallbacks(client, NULL, connlost, msgarrvd, delivered);
if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
{
printf("Failed to connect, return code %d\n", rc);
exit(EXIT_FAILURE);
}
printf("Subscribing to topic %s\nfor client %s using QoS%d\n\n"
"Press Q<Enter> to quit\n\n", TOPICSUB, SUB_CLIENTID, QOS);
MQTTClient_subscribe(client, TOPICSUB, QOS);
do
{
ch = getchar();
} while (ch != 'Q' && ch != 'q');
//quit
MQTTClient_unsubscribe(client, TOPICSUB);
MQTTClient_disconnect(client, 10000);
MQTTClient_destroy(&client);
pthread_exit(NULL);
}
int main(int argc, char* argv[])
{
pthread_t threads[NUM_THREADS];
pthread_create(&threads[0], NULL, subClient, (void *)0);
pthread_create(&threads[1], NULL, pubClient, (void *)1);
pthread_exit(NULL);
}
- 結果:
五..Android客戶端
5.1 導入庫
Android客戶端同樣需要一些配置來導入庫,官網教程不是很好看,HIVEMQ的不錯:
- app內的
build.gradle
//和android\dependencies同級
repositories {
maven {
url "https://repo.eclipse.org/content/repositories/paho-snapshots/"
}
}
dependencies {
......
implementation('org.eclipse.paho:org.eclipse.paho.android.service:1.0.2') {
exclude module: 'support-v4'
}
......
}
- 權限設置
AndrroidManifest.xml
// 放在manifest下一級
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 放在<application>下一級
<service android:name="org.eclipse.paho.android.service.MqttService" >
</service>
5.2 Code
- Android代碼比較簡陋:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "LQH";
Button bt_connect;
Button bt_sub;
Button bt_pub;
TextView textView;
String log;
MqttAndroidClient client;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
bt_connect = findViewById(R.id.bt_connect);
bt_pub = findViewById(R.id.bt_pub);
bt_sub = findViewById(R.id.bt_sub);
textView = findViewById(R.id.textView);
log = "Log:\n\n";
textView.setText(log);
bt_connect.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String clientId = MqttClient.generateClientId();
//創建客戶端
client = new MqttAndroidClient(MainActivity.this, "tcp://xx.xxx.xx.xxx:1883",
clientId);
//連接
try {
IMqttToken token = client.connect();
token.setActionCallback(new IMqttActionListener() {
//兩個響應函數
@Override
public void onSuccess(IMqttToken asyncActionToken) {
// We are connected
Log.d(TAG, "onSuccess");
Toast.makeText(MainActivity.this, "connect successed", Toast.LENGTH_SHORT).show();
log += "Connect successed!\n\n";
textView.setText(log);
}
@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
// Something went wrong e.g. connection timeout or firewall problems
Log.d(TAG, "onFailure");
Toast.makeText(MainActivity.this, "not connect", Toast.LENGTH_SHORT).show();
log += "Connect failed!\n\n";
textView.setText(log);
}
});
} catch (MqttException e) {
e.printStackTrace();
}
//設置幾個回調函數
client.setCallback(new MqttCallback() {
//連接斷開
@Override
public void connectionLost(Throwable cause) {
Toast.makeText(MainActivity.this, "connectionLost", Toast.LENGTH_SHORT).show();
}
//接收信息
@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
log = log + "Recevied msg: " + new String(message.getPayload()) + "\n\n";
textView.setText(log);
}
//發佈信息成功
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
Toast.makeText(MainActivity.this, "published", Toast.LENGTH_SHORT).show();
log = log + "Published\n\n";
textView.setText(log);
}
});
}
});
bt_sub.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
final String topic = "temperature";
int qos = 1;
try {
//訂閱
IMqttToken subToken = client.subscribe(topic, qos);
subToken.setActionCallback(new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
// The message was published
Toast.makeText(MainActivity.this, "subscribe successed", Toast.LENGTH_SHORT).show();
log = log + "Subscribe topic: " + topic + " successed!\n\n";
textView.setText(log);
}
@Override
public void onFailure(IMqttToken asyncActionToken,
Throwable exception) {
// The subscription could not be performed, maybe the user was not
// authorized to subscribe on the specified topic e.g. using wildcards
Toast.makeText(MainActivity.this, "subscribe failure", Toast.LENGTH_SHORT).show();
}
});
} catch (MqttException e) {
e.printStackTrace();
}
}
});
bt_pub.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String topic = "Question";
String payload = "What's the temperature?";
try {
MqttMessage message = new MqttMessage(payload.getBytes());
//發佈
client.publish(topic, message);
log = log + "Publish:\n" + " topic:" + topic + "\n payload:" + payload + "\n\n";
textView.setText(log);
} catch (MqttException e) {
e.printStackTrace();
}
}
});
}
}
- 結果:
好吧,就是這麼簡陋的,例如你沒連接就發佈,程序會崩潰.hhh
六..SSL背景知識
這邊有兩個帖子,一個系列,寫得實在好:
- 對於Mqtt和TLS的認識
- 怎麼生成證書
我也稍作解釋:
- 對稱加密
- 鑰匙A加密,再用鑰匙A解密,但密鑰傳輸過程中如果被截獲就泄密了,所以產生非對稱加密
- 非對稱加密
- 鑰匙A加密,只能用鑰匙B加密
- 公鑰(yue)和私鑰
- 非對稱加密用戶擁有公鑰和私鑰,公鑰加密只能用私鑰解密,反之亦然。
公鑰發給別人,而私鑰自己妥善保管。
想象一種情況,A,B,C三人,A給B發信息捎上了自己的公鑰,結果C中途攔截了A公鑰,把C公鑰發給B,B拿到後用公鑰C加密發佈信息,C是不是可以獲得B發佈的信息,C還可以把信息用公鑰A加密發給A,不聲不響截獲數據,所以有了CA(授權中心). - CA(證書授權中心)
- 承擔公鑰合法性檢驗的責任。授權中心會發行一個個的證書,每個證書本質上包含:實體或個人的名字以及對應的公鑰。爲了保證證書的安全性,授權中心用自己的私鑰對證書進行加密,證書接受者用授權中心的公鑰對該證書進行解密,從而實現證書的數字簽名,如圖:
圖片來自: 怎麼生成證書
- 證書的生成過程參考上述的 怎麼生成證書
- 有一點要注意的,生成證書和密鑰要用同一個CA
- Common Name要爲電腦ip,並且儘量不重複,可以用127.0.0.1, xx.xxx.xx.xxx,如果有線無線都有,那麼可以生成ca, server, client.測試雙向認證。建議:
- ca.crt用wifi的ip,server.crt用有線ip,因爲WiFi,Android或者C客戶端可以連接,檢驗證書.
- 單向認證:
- 指的是隻有一個對象校驗對端的證書合法性。
通常都是Client來校驗Server的合法性。那麼client需要一個ca.crt,服務器需要server.crt,server.key。 - 雙向認證
- 指的是相互校驗,服務器需要校驗每個client,client也需要校驗服務器。
Server 需要 server.key 、server.crt 、ca.crt
Client 需要 client.key 、client.crt 、ca.crt
經常採用單向認證,雙向雖然更安全,但是每個客戶還要求生成證書會很麻煩。後面代碼也基於此。
七..客戶端添加SSL模塊
7.1 Mosquitto
修改配置文件
既然我們想要採用ssl認證,那麼我們自然需要改配置,稍微一思考,我們需要改的內容也不多,指定ca.crt, server.crt, server.key三個文件的路徑,指定單向認證或者雙向認證。mqtt_tls.conf
# 這是我的路徑,要改
# CA證書,pem格式,
cafile /xxx/ca/ca.crt
# 服務器證書
certfile /xxx/server/server.crt
# 服務器密鑰
keyfile /xxx/server/server.key
# false->單向認證, true->雙向認證
require_certificate false
# 如果require_certificate爲true,則可以將use_identity_as_username設置爲true以將客戶端證書中的CN值用作用戶名。 如果true,則不會將password_file選項用於此偵聽器。
use_identity_as_username false
- 運行broker
mosquitto -v -c xx/xx/mqtt_tls.conf -p 8883
- 可以用兩個終端來測試一下:
mosquitto_sub -t temperture -h xx.xxx.xx.xxx -p 8883 --cafile /xxx/xxx/ca.crt
mosquitto_pub -t temperture -m 37° -h xx.xxx.xx.xxx -p 8883 --cafile /xx/xxx/ca.crt
這樣就實現了mosquitto的配置測試,
7.2 Python
python代碼的改動比較的簡單:
client.tls_set("/xx/xxx/ca.crt", tls_version=ssl.PROTOCOL_TLSv1_2)
# client.connect(host, 1883, 60)
client.connect(host, 8883, 60)
簡單加載證書,後面的版本指定本來可以沒有,但我這邊後來軟件變動出現問題,所以加上這個。
7.3 C
C的代碼改動也不復雜,就是有個坑
// 1
#define ADDRESS "ssl://xx.xxx.xx.xxx:8883"
// 2
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
MQTTClient_SSLOptions ssl = MQTTClient_SSLOptions_initializer;
ssl.trustStore = "/xx/xxx/ca.pem";
conn_opts.ssl = &ssl;
// 3,巨坑
//編譯加載的庫不一樣,要 +s !!! -lpaho-mqtt3cs
test:test.cpp cmqtt.cpp cmqtt.h
g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3cs \
-I ../../paho.mqtt.c/src \
-L ../../paho.mqtt.c/build \
-pthread -Imqtt \
-std=c++11
7.4 Android
這塊和前面兩個有點不一樣,因爲之前的ca.crt在這裏是不能用的,Android能加載的證書需要是bks格式的,所以這裏需要先生成bks,然後把ca.crt添加進去。再加載。
java -version
根據上面指令查看jdk版本,然後下載合適的bcprov,–>bcprov-ext-jdk15on-160.jar
,然後放到(jdk_home)/jre/lib/ext
這裏可以用下面指令找到放置的文件夾:
locate jaccess
jaccess只是本來存在(jdk_home)/jre/lib/ext
文件夾下的另一個文件,如果安裝了jdk和android-studio,那麼可以找到三條位置,選擇/xxx/android-studio/jre/jre/lib/ext/
,修改這裏比較簡單,/usr/下的往往還涉及權限。
- 生成bks
在隨意位置運行
keytool -importcert -keystore test_ca.bks -file /xxx/ca.crt -storetype BKS -provider org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath "/xxx/android-studio/jre/jre/lib/ext/bcprov-ext-jdk15on-160.jar"
輸入六位密碼,yes,就可以看到生成的test_ca.bks
將test_ca.bks
放到android項目下的/res/raw
,沒有就新建
- Code
// 1
client = new MqttAndroidClient(MainActivity.this, "ssl://xx/xxx/xx/xxx:8883",
clientId);
// 2.配置MqttConnectOptions
MqttConnectOptions options = new MqttConnectOptions();
SSLContext sslContext = SSLContext.getInstance("TLS");
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509");
KeyStore keyStore = KeyStore.getInstance("BKS");
// 剛纔生成的文件加載,“123456”對應密碼
keyStore.load(this.getResources().openRawResource(R.raw.test_ca),"123456".toCharArray());
trustManagerFactory.init(keyStore);
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SocketFactory factory = sslContext.getSocketFactory();
options.setSocketFactory(factory);
然後Alt+Enter
解決各種紅色波浪。基本都是異常處理。
- 代碼有不明白的不妨百度,一行一行看過去是最快的學習路線。
結束語
- 至此基本完結,第一個比較長的帖子,難免出現問題,希望大家可以指出,儘量改正,謝謝。
- 後面會考慮整理代碼上傳github
- 如果有用希望能給個贊鼓勵一下