diff --git a/.ci/apps_tests/.env b/.ci/apps_tests/.env new file mode 100644 index 000000000..d474e2637 --- /dev/null +++ b/.ci/apps_tests/.env @@ -0,0 +1,5 @@ +MYSQL_TAG=8 +REDIS_TAG=6 +MONGO_TAG=4 +PGSQL_TAG=13 +LDAP_TAG=2.4.50 diff --git a/.ci/apps_tests/docker-compose.yaml b/.ci/apps_tests/docker-compose.yaml new file mode 100644 index 000000000..aadb55245 --- /dev/null +++ b/.ci/apps_tests/docker-compose.yaml @@ -0,0 +1,105 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + depends_on: + - mysql_server + - redis_server + - mongo_server + - pgsql_server + - ldap_server + networks: + - emqx_bridge + volumes: + - ../../.:/emqx + working_dir: /emqx + tty: true + + mysql_server: + container_name: mysql + image: mysql:${MYSQL_TAG} + restart: always + ports: + - 3306:3306 + environment: + MYSQL_ROOT_PASSWORD: public + MYSQL_DATABASE: mqtt + command: + --bind-address 0.0.0.0 + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci + --explicit_defaults_for_timestamp=true + --lower_case_table_names=1 + --max_allowed_packet=128M + --skip-symbolic-links + networks: + - emqx_bridge + + redis_server: + container_name: redis + image: redis:${REDIS_TAG} + ports: + - 6379:6379 + command: + - redis-server + - "--bind 0.0.0.0 ::" + restart: always + networks: + - emqx_bridge + + mongo_server: + container_name: mongo + image: mongo:${MONGO_TAG} + ports: + - 27017:27017 + restart: always + environment: + MONGO_INITDB_DATABASE: mqtt + command: + --ipv6 + --bind_ip_all + networks: + - emqx_bridge + + pgsql_server: + container_name: pgsql + image: postgres:${PGSQL_TAG} + ports: + - 5432:5432 + restart: always + environment: + POSTGRES_PASSWORD: public + POSTGRES_USER: root + POSTGRES_DB: mqtt + networks: + - emqx_bridge + + ldap_server: + container_name: openldap + build: + context: ../.. + dockerfile: .ci/apps_tests/openldap/Dockerfile + args: + LDAP_TAG: ${LDAP_TAG} + image: emqx-ldap:1.0 + ports: + - 389:389 + restart: always + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.239.0/24 + gateway: 172.100.239.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/apps_tests/openldap/Dockerfile b/.ci/apps_tests/openldap/Dockerfile new file mode 100644 index 000000000..f15a48e69 --- /dev/null +++ b/.ci/apps_tests/openldap/Dockerfile @@ -0,0 +1,26 @@ +FROM buildpack-deps:stretch + +ARG LDAP_TAG=2.4.50 + +RUN apt-get update && apt-get install -y groff groff-base +RUN wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-${LDAP_TAG}.tgz \ + && gunzip -c openldap-${LDAP_TAG}.tgz | tar xvfB - \ + && cd openldap-${LDAP_TAG} \ + && ./configure && make depend && make && make install \ + && cd .. && rm -rf openldap-${LDAP_TAG} + +COPY .ci/apps_tests/openldap/slapd.conf /usr/local/etc/openldap/slapd.conf +COPY apps/emqx_auth_ldap/emqx.io.ldif /usr/local/etc/openldap/schema/emqx.io.ldif +COPY apps/emqx_auth_ldap/emqx.schema /usr/local/etc/openldap/schema/emqx.schema +COPY apps/emqx_auth_ldap/test/certs/*.pem /usr/local/etc/openldap/ + +RUN mkdir -p /usr/local/etc/openldap/data \ + && slapadd -l /usr/local/etc/openldap/schema/emqx.io.ldif -f /usr/local/etc/openldap/slapd.conf + +WORKDIR /usr/local/etc/openldap + +EXPOSE 389 636 + +ENTRYPOINT ["/usr/local/libexec/slapd", "-h", "ldap:/// ldaps:///", "-d", "3", "-f", "/usr/local/etc/openldap/slapd.conf"] + +CMD [] diff --git a/.ci/apps_tests/openldap/slapd.conf b/.ci/apps_tests/openldap/slapd.conf new file mode 100644 index 000000000..d6ba20caa --- /dev/null +++ b/.ci/apps_tests/openldap/slapd.conf @@ -0,0 +1,16 @@ +include /usr/local/etc/openldap/schema/core.schema +include /usr/local/etc/openldap/schema/cosine.schema +include /usr/local/etc/openldap/schema/inetorgperson.schema +include /usr/local/etc/openldap/schema/ppolicy.schema +include /usr/local/etc/openldap/schema/emqx.schema + +TLSCACertificateFile /usr/local/etc/openldap/cacert.pem +TLSCertificateFile /usr/local/etc/openldap/cert.pem +TLSCertificateKeyFile /usr/local/etc/openldap/key.pem + +database bdb +suffix "dc=emqx,dc=io" +rootdn "cn=root,dc=emqx,dc=io" +rootpw {SSHA}eoF7NhNrejVYYyGHqnt+MdKNBh4r1w3W + +directory /usr/local/etc/openldap/data diff --git a/.ci/build_packages/Dockerfile b/.ci/build_packages/Dockerfile new file mode 100644 index 000000000..197b7e731 --- /dev/null +++ b/.ci/build_packages/Dockerfile @@ -0,0 +1,12 @@ +ARG BUILD_FROM=emqx/build-env:erl23.2.2-ubuntu20.04 +FROM ${BUILD_FROM} + +ARG EMQX_NAME=emqx + +COPY . /emqx + +WORKDIR /emqx + +RUN make ${EMQX_NAME}-pkg || cat rebar3.crashdump + +RUN /emqx/.ci/build_packages/tests.sh diff --git a/.ci/build_packages/tests.sh b/.ci/build_packages/tests.sh new file mode 100755 index 000000000..8895ec3b3 --- /dev/null +++ b/.ci/build_packages/tests.sh @@ -0,0 +1,173 @@ +#!/bin/sh +set -x -e -u +export EMQX_NAME=${EMQX_NAME:-"emqx"} +export PACKAGE_PATH="/emqx/_packages/${EMQX_NAME}" +export RELUP_PACKAGE_PATH="/emqx/relup_packages/${EMQX_NAME}" +# export EMQX_NODE_NAME="emqx-on-$(uname -m)@127.0.0.1" +# export EMQX_NODE_COOKIE=$(date +%s%N) + +emqx_prepare(){ + mkdir -p ${PACKAGE_PATH} + + if [ ! -d "/paho-mqtt-testing" ]; then + git clone -b develop-4.0 https://github.com/emqx/paho.mqtt.testing.git /paho-mqtt-testing + fi + pip3 install pytest +} + +emqx_test(){ + cd ${PACKAGE_PATH} + + for var in $(ls $PACKAGE_PATH/${EMQX_NAME}-*);do + case ${var##*.} in + "zip") + packagename=`basename ${PACKAGE_PATH}/${EMQX_NAME}-*.zip` + unzip -q ${PACKAGE_PATH}/$packagename + sed -i "/zone.external.server_keepalive/c zone.external.server_keepalive = 60" ${PACKAGE_PATH}/emqx/etc/emqx.conf + sed -i "/mqtt.max_topic_alias/c mqtt.max_topic_alias = 10" ${PACKAGE_PATH}/emqx/etc/emqx.conf + sed -i '/emqx_telemetry/d' ${PACKAGE_PATH}/emqx/data/loaded_plugins + + if [ ! -z $(echo ${EMQX_DEPS_DEFAULT_VSN#v} | grep -oE "[0-9]+\.[0-9]+(\.[0-9]+)?-(alpha|beta|rc)\.[0-9]") ]; then + if [ ! -d ${PACKAGE_PATH}/emqx/lib/emqx-${EMQX_DEPS_DEFAULT_VSN#v} ] || [ ! -d ${PACKAGE_PATH}/emqx/releases/${EMQX_DEPS_DEFAULT_VSN#v} ] ;then + echo "emqx zip version error" + exit 1 + fi + fi + + echo "running ${packagename} start" + ${PACKAGE_PATH}/emqx/bin/emqx start || tail ${PACKAGE_PATH}/emqx/log/erlang.log.1 + IDLE_TIME=0 + while [ -z "$(${PACKAGE_PATH}/emqx/bin/emqx_ctl status |grep 'is running'|awk '{print $1}')" ] + do + if [ $IDLE_TIME -gt 10 ] + then + echo "emqx running error" + exit 1 + fi + sleep 10 + IDLE_TIME=$((IDLE_TIME+1)) + done + pytest -v /paho-mqtt-testing/interoperability/test_client/V5/test_connect.py::test_basic + ${PACKAGE_PATH}/emqx/bin/emqx stop + echo "running ${packagename} stop" + rm -rf ${PACKAGE_PATH}/emqx + ;; + "deb") + packagename=`basename ${PACKAGE_PATH}/${EMQX_NAME}-*.deb` + dpkg -i ${PACKAGE_PATH}/$packagename + if [ $(dpkg -l |grep emqx |awk '{print $1}') != "ii" ] + then + echo "package install error" + exit 1 + fi + + echo "running ${packagename} start" + running_test + echo "running ${packagename} stop" + + dpkg -r ${EMQX_NAME} + if [ $(dpkg -l |grep emqx |awk '{print $1}') != "rc" ] + then + echo "package remove error" + exit 1 + fi + + dpkg -P ${EMQX_NAME} + if [ ! -z "$(dpkg -l |grep emqx)" ] + then + echo "package uninstall error" + exit 1 + fi + ;; + "rpm") + packagename=`basename ${PACKAGE_PATH}/${EMQX_NAME}-*.rpm` + rpm -ivh ${PACKAGE_PATH}/$packagename + if [ -z $(rpm -q emqx | grep -o emqx) ];then + echo "package install error" + exit 1 + fi + + echo "running ${packagename} start" + running_test + echo "running ${packagename} stop" + + rpm -e ${EMQX_NAME} + if [ "$(rpm -q emqx)" != "package emqx is not installed" ];then + echo "package uninstall error" + exit 1 + fi + ;; + + esac + done +} + +running_test(){ + if [ ! -z $(echo ${EMQX_DEPS_DEFAULT_VSN#v} | grep -oE "[0-9]+\.[0-9]+(\.[0-9]+)?-(alpha|beta|rc)\.[0-9]") ]; then + if [ ! -d /usr/lib/emqx/lib/emqx-${EMQX_DEPS_DEFAULT_VSN#v} ] || [ ! -d /usr/lib/emqx/releases/${EMQX_DEPS_DEFAULT_VSN#v} ];then + echo "emqx package version error" + exit 1 + fi + fi + + sed -i "/zone.external.server_keepalive/c zone.external.server_keepalive = 60" /etc/emqx/emqx.conf + sed -i "/mqtt.max_topic_alias/c mqtt.max_topic_alias = 10" /etc/emqx/emqx.conf + sed -i '/emqx_telemetry/d' /var/lib/emqx/loaded_plugins + + emqx start || tail /var/log/emqx/erlang.log.1 + IDLE_TIME=0 + while [ -z "$(emqx_ctl status |grep 'is running'|awk '{print $1}')" ] + do + if [ $IDLE_TIME -gt 10 ] + then + echo "emqx running error" + exit 1 + fi + sleep 10 + IDLE_TIME=$((IDLE_TIME+1)) + done + pytest -v /paho-mqtt-testing/interoperability/test_client/V5/test_connect.py::test_basic + emqx stop || kill $(ps -ef | grep -E '\-progname\s.+emqx\s' |awk '{print $2}') + + if [ $(sed -n '/^ID=/p' /etc/os-release | sed -r 's/ID=(.*)/\1/g' | sed 's/"//g') = ubuntu ] \ + || [ $(sed -n '/^ID=/p' /etc/os-release | sed -r 's/ID=(.*)/\1/g' | sed 's/"//g') = debian ] \ + || [ $(sed -n '/^ID=/p' /etc/os-release | sed -r 's/ID=(.*)/\1/g' | sed 's/"//g') = raspbian ];then + service emqx start || tail /var/log/emqx/erlang.log.1 + IDLE_TIME=0 + while [ -z "$(emqx_ctl status |grep 'is running'|awk '{print $1}')" ] + do + if [ $IDLE_TIME -gt 10 ] + then + echo "emqx service error" + exit 1 + fi + sleep 10 + IDLE_TIME=$((IDLE_TIME+1)) + done + service emqx stop + fi +} + +relup_test(){ + if [ -d ${RELUP_PACKAGE_PATH} ];then + cd ${RELUP_PACKAGE_PATH } + + for var in $(ls ${EMQX_NAME}-*-$(uname -m).zip);do + packagename=`basename ${var}` + unzip $packagename + ./emqx/bin/emqx start + ./emqx/bin/emqx_ctl status + ./emqx/bin/emqx versions + cp ${PACKAGE_PATH}/${EMQX_NAME}-*-${EMQX_DEPS_DEFAULT_VSN#v}-$(uname -m).zip ./emqx/releases + ./emqx/bin/emqx install ${EMQX_DEPS_DEFAULT_VSN#v} + [ $(./emqx/bin/emqx versions |grep permanent | grep -oE "[0-9].[0-9].[0-9]") = ${EMQX_DEPS_DEFAULT_VSN#v} ] || exit 1 + ./emqx/bin/emqx_ctl status + ./emqx/bin/emqx stop + rm -rf emqx + done + fi +} + +emqx_prepare +emqx_test +relup_test diff --git a/.ci/build_packages/upload_github_release_asset.sh b/.ci/build_packages/upload_github_release_asset.sh new file mode 100755 index 000000000..42dc7e4ef --- /dev/null +++ b/.ci/build_packages/upload_github_release_asset.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Author: Stefan Buck +# License: MIT +# https://gist.github.com/stefanbuck/ce788fee19ab6eb0b4447a85fc99f447 +# +# +# This script accepts the following parameters: +# +# * owner +# * repo +# * tag +# * filename +# * github_api_token +# +# Script to upload a release asset using the GitHub API v3. +# +# Example: +# +# upload-github-release-asset.sh github_api_token=TOKEN owner=stefanbuck repo=playground tag=v0.1.0 filename=./build.zip +# + +# Check dependencies. +set -e +xargs=$(which gxargs || which xargs) + +# Validate settings. +[ "$TRACE" ] && set -x + +CONFIG=$@ + +for line in $CONFIG; do + eval "$line" +done + +# Define variables. +GH_API="https://api.github.com" +GH_REPO="$GH_API/repos/$owner/$repo" +GH_TAGS="$GH_REPO/releases/tags/$tag" +AUTH="Authorization: token $github_api_token" +WGET_ARGS="--content-disposition --auth-no-challenge --no-cookie" +CURL_ARGS="-LJO#" + +if [[ "$tag" == 'LATEST' ]]; then + GH_TAGS="$GH_REPO/releases/latest" +fi + +# Validate token. +curl -o /dev/null -sH "$AUTH" $GH_REPO || { echo "Error: Invalid repo, token or network issue!"; exit 1; } + +# Read asset tags. +response=$(curl -sH "$AUTH" $GH_TAGS) + +# Get ID of the asset based on given filename. +eval $(echo "$response" | grep -m 1 "id.:" | grep -w id | tr : = | tr -cd '[[:alnum:]]=') +[ "$id" ] || { echo "Error: Failed to get release id for tag: $tag"; echo "$response" | awk 'length($0)<100' >&2; exit 1; } + +# Upload asset +# Construct url +GH_ASSET="https://uploads.github.com/repos/$owner/$repo/releases/$id/assets?name=$(basename $filename)" + +curl "$GITHUB_OAUTH_BASIC" --data-binary @"$filename" -H "Authorization: token $github_api_token" -H "Content-Type: application/octet-stream" $GH_ASSET diff --git a/.ci/compatibility_tests/.env b/.ci/compatibility_tests/.env new file mode 100644 index 000000000..2ac286e98 --- /dev/null +++ b/.ci/compatibility_tests/.env @@ -0,0 +1,5 @@ +MYSQL_TAG=5.7 +REDIS_TAG=6 +MONGO_TAG=4.1 +PGSQL_TAG=11 +LDAP_TAG=2.4.50 diff --git a/.ci/compatibility_tests/docker-compose-ldap.yaml b/.ci/compatibility_tests/docker-compose-ldap.yaml new file mode 100644 index 000000000..33b37e00c --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-ldap.yaml @@ -0,0 +1,41 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + depends_on: + - ldap_server + networks: + - emqx_bridge + volumes: + - ../../.:/emqx + working_dir: /emqx + tty: true + + ldap_server: + container_name: ldap + build: + context: ../.. + dockerfile: .ci/compatibility_tests/openldap/Dockerfile + args: + LDAP_TAG: ${LDAP_TAG} + image: openldap + ports: + - 389:389 + restart: always + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.239.0/24 + gateway: 172.100.239.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-mongo-tls.yaml b/.ci/compatibility_tests/docker-compose-mongo-tls.yaml new file mode 100644 index 000000000..1611534f6 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-mongo-tls.yaml @@ -0,0 +1,43 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../../:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - mongo_server + tty: true + + mongo_server: + container_name: mongo + image: mongo:${MONGO_TAG} + restart: always + environment: + MONGO_INITDB_DATABASE: mqtt + volumes: + - ../../apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/mongodb.pem/:/etc/certs/mongodb.pem + networks: + - emqx_bridge + command: + --ipv6 + --bind_ip_all + --sslMode requireSSL + --sslPEMKeyFile /etc/certs/mongodb.pem + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-mongo.yaml b/.ci/compatibility_tests/docker-compose-mongo.yaml new file mode 100644 index 000000000..2f769ac63 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-mongo.yaml @@ -0,0 +1,39 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - mongo_server + tty: true + + mongo_server: + container_name: mongo + image: mongo:${MONGO_TAG} + restart: always + environment: + MONGO_INITDB_DATABASE: mqtt + networks: + - emqx_bridge + command: + --ipv6 + --bind_ip_all + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-mysql-tls.yaml b/.ci/compatibility_tests/docker-compose-mysql-tls.yaml new file mode 100644 index 000000000..8117907f5 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-mysql-tls.yaml @@ -0,0 +1,53 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../../:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - mysql_server + tty: true + + mysql_server: + container_name: mysql + image: mysql:${MYSQL_TAG} + restart: always + environment: + MYSQL_ROOT_PASSWORD: public + MYSQL_DATABASE: mqtt + volumes: + - ../../apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca.pem:/etc/certs/ca-cert.pem + - ../../apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-cert.pem:/etc/certs/server-cert.pem + - ../../apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-key.pem:/etc/certs/server-key.pem + networks: + - emqx_bridge + command: + --bind-address "::" + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci + --explicit_defaults_for_timestamp=true + --lower_case_table_names=1 + --max_allowed_packet=128M + --skip-symbolic-links + --ssl-ca=/etc/certs/ca.pem + --ssl-cert=/etc/certs/server-cert.pem + --ssl-key=/etc/certs/server-key.pem + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-mysql.yaml b/.ci/compatibility_tests/docker-compose-mysql.yaml new file mode 100644 index 000000000..1f285cc5e --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-mysql.yaml @@ -0,0 +1,46 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../../:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - mysql_server + tty: true + + mysql_server: + container_name: mysql + image: mysql:${MYSQL_TAG} + restart: always + environment: + MYSQL_ROOT_PASSWORD: public + MYSQL_DATABASE: mqtt + networks: + - emqx_bridge + command: + --bind-address "::" + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci + --explicit_defaults_for_timestamp=true + --lower_case_table_names=1 + --max_allowed_packet=128M + --skip-symbolic-links + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-pgsql-tls.yaml b/.ci/compatibility_tests/docker-compose-pgsql-tls.yaml new file mode 100644 index 000000000..d3d9d93b5 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-pgsql-tls.yaml @@ -0,0 +1,57 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../../:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - pgsql_server + tty: true + + pgsql_server: + container_name: pgsql + build: + context: ../.. + dockerfile: .ci/compatibility_tests/pgsql/Dockerfile + args: + POSTGRES_USER: postgres + BUILD_FROM: postgres:${PGSQL_TAG} + image: emqx_pgsql:${PGSQL_TAG} + restart: always + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + command: + - -c + - ssl=on + - -c + - ssl_cert_file=/var/lib/postgresql/server.crt + - -c + - ssl_key_file=/var/lib/postgresql/server.key + - -c + - ssl_ca_file=/var/lib/postgresql/root.crt + - -c + - hba_file=/var/lib/postgresql/pg_hba.conf + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-pgsql.yaml b/.ci/compatibility_tests/docker-compose-pgsql.yaml new file mode 100644 index 000000000..c5492d971 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-pgsql.yaml @@ -0,0 +1,38 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../../:/emqx + working_dir: /emqx + networks: + - emqx_bridge + depends_on: + - pgsql_server + tty: true + + pgsql_server: + container_name: pgsql + image: postgres:${PGSQL_TAG} + restart: always + environment: + POSTGRES_PASSWORD: public + POSTGRES_USER: root + POSTGRES_DB: mqtt + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-redis-cluster-tls.yaml b/.ci/compatibility_tests/docker-compose-redis-cluster-tls.yaml new file mode 100644 index 000000000..06518854f --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-redis-cluster-tls.yaml @@ -0,0 +1,41 @@ +version: '2.4' +# network configuration is limited in version 3 +# https://github.com/docker/compose/issues/4958 + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + networks: + - app_net + depends_on: + - redis_cluster + working_dir: /emqx + tty: true + + redis_cluster: + container_name: redis + image: redis:${REDIS_TAG} + volumes: + - ../../apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs:/tls + - ./redis/:/data/conf + command: bash -c "/bin/bash /data/conf/redis.sh --node cluster --tls-enabled && while true; do echo 1; sleep 1; done" + networks: + app_net: + # Assign a public address. Erlang container cannot find cluster nodes by network-scoped alias (redis_cluster). + ipv4_address: 172.16.239.10 + ipv6_address: 2001:3200:3200::20 + +networks: + app_net: + driver: bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.16.239.0/24 + gateway: 172.16.239.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-redis-cluster.yaml b/.ci/compatibility_tests/docker-compose-redis-cluster.yaml new file mode 100644 index 000000000..213a06866 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-redis-cluster.yaml @@ -0,0 +1,40 @@ +version: '2.4' +# network configuration is limited in version 3 +# https://github.com/docker/compose/issues/4958 + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + networks: + - app_net + depends_on: + - redis_cluster + working_dir: /emqx + tty: true + + redis_cluster: + image: redis:${REDIS_TAG} + container_name: redis + volumes: + - ./redis/:/data/conf + command: bash -c "/bin/bash /data/conf/redis.sh --node cluster && while true; do echo 1; sleep 1; done" + networks: + app_net: + # Assign a public address. Erlang container cannot find cluster nodes by network-scoped alias (redis_cluster). + ipv4_address: 172.16.239.10 + ipv6_address: 2001:3200:3200::20 + +networks: + app_net: + driver: bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.16.239.0/24 + gateway: 172.16.239.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-redis-sentinel.yaml b/.ci/compatibility_tests/docker-compose-redis-sentinel.yaml new file mode 100644 index 000000000..b2b58fefe --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-redis-sentinel.yaml @@ -0,0 +1,40 @@ +version: '2.4' +# network configuration is limited in version 3 +# https://github.com/docker/compose/issues/4958 + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + networks: + - app_net + depends_on: + - redis_cluster + working_dir: /emqx + tty: true + + redis_cluster: + container_name: redis + image: redis:${REDIS_TAG} + volumes: + - ./redis/:/data/conf + command: bash -c "/bin/bash /data/conf/redis.sh --node sentinel && while true; do echo 1; sleep 1; done" + networks: + app_net: + # Assign a public address. Erlang container cannot find cluster nodes by network-scoped alias (redis_cluster). + ipv4_address: 172.16.239.10 + ipv6_address: 2001:3200:3200::20 + +networks: + app_net: + driver: bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.16.239.0/24 + gateway: 172.16.239.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-redis-single-tls.yaml b/.ci/compatibility_tests/docker-compose-redis-single-tls.yaml new file mode 100644 index 000000000..03d643754 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-redis-single-tls.yaml @@ -0,0 +1,43 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + networks: + - emqx_bridge + depends_on: + - redis_server + working_dir: /emqx + tty: true + + redis_server: + container_name: redis + image: redis:${REDIS_TAG} + volumes: + - ../../apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs:/tls + command: + - redis-server + - "--bind 0.0.0.0 ::" + - --tls-port 6380 + - --tls-cert-file /tls/redis.crt + - --tls-key-file /tls/redis.key + - --tls-ca-cert-file /tls/ca.crt + restart: always + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/docker-compose-redis-single.yaml b/.ci/compatibility_tests/docker-compose-redis-single.yaml new file mode 100644 index 000000000..5d7acb865 --- /dev/null +++ b/.ci/compatibility_tests/docker-compose-redis-single.yaml @@ -0,0 +1,37 @@ +version: '3' + +services: + erlang: + container_name: erlang + image: emqx/build-env:erl23.2.2-ubuntu20.04 + volumes: + - ../..:/emqx + networks: + - emqx_bridge + depends_on: + - redis_server + working_dir: /emqx + tty: true + + redis_server: + container_name: redis + image: redis:${REDIS_TAG} + command: + - redis-server + - "--bind 0.0.0.0 ::" + restart: always + networks: + - emqx_bridge + +networks: + emqx_bridge: + driver: bridge + name: emqx_bridge + enable_ipv6: true + ipam: + driver: default + config: + - subnet: 172.100.100.0/24 + gateway: 172.100.100.1 + - subnet: 2001:3200:3200::/64 + gateway: 2001:3200:3200::1 diff --git a/.ci/compatibility_tests/openldap/Dockerfile b/.ci/compatibility_tests/openldap/Dockerfile new file mode 100644 index 000000000..fa15ab5eb --- /dev/null +++ b/.ci/compatibility_tests/openldap/Dockerfile @@ -0,0 +1,26 @@ +FROM buildpack-deps:stretch + +ARG LDAP_TAG=2.4.50 + +RUN apt-get update && apt-get install -y groff groff-base +RUN wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-${LDAP_TAG}.tgz \ + && gunzip -c openldap-${LDAP_TAG}.tgz | tar xvfB - \ + && cd openldap-${LDAP_TAG} \ + && ./configure && make depend && make && make install \ + && cd .. && rm -rf openldap-${LDAP_TAG} + +COPY .ci/compatibility_tests/openldap/slapd.conf /usr/local/etc/openldap/slapd.conf +COPY apps/emqx_auth_ldap/emqx.io.ldif /usr/local/etc/openldap/schema/emqx.io.ldif +COPY apps/emqx_auth_ldap/emqx.schema /usr/local/etc/openldap/schema/emqx.schema +COPY apps/emqx_auth_ldap/test/certs/*.pem /usr/local/etc/openldap/ + +RUN mkdir -p /usr/local/etc/openldap/data \ + && slapadd -l /usr/local/etc/openldap/schema/emqx.io.ldif -f /usr/local/etc/openldap/slapd.conf + +WORKDIR /usr/local/etc/openldap + +EXPOSE 389 636 + +ENTRYPOINT ["/usr/local/libexec/slapd", "-h", "ldap:/// ldaps:///", "-d", "3", "-f", "/usr/local/etc/openldap/slapd.conf"] + +CMD [] diff --git a/.ci/compatibility_tests/openldap/slapd.conf b/.ci/compatibility_tests/openldap/slapd.conf new file mode 100644 index 000000000..d6ba20caa --- /dev/null +++ b/.ci/compatibility_tests/openldap/slapd.conf @@ -0,0 +1,16 @@ +include /usr/local/etc/openldap/schema/core.schema +include /usr/local/etc/openldap/schema/cosine.schema +include /usr/local/etc/openldap/schema/inetorgperson.schema +include /usr/local/etc/openldap/schema/ppolicy.schema +include /usr/local/etc/openldap/schema/emqx.schema + +TLSCACertificateFile /usr/local/etc/openldap/cacert.pem +TLSCertificateFile /usr/local/etc/openldap/cert.pem +TLSCertificateKeyFile /usr/local/etc/openldap/key.pem + +database bdb +suffix "dc=emqx,dc=io" +rootdn "cn=root,dc=emqx,dc=io" +rootpw {SSHA}eoF7NhNrejVYYyGHqnt+MdKNBh4r1w3W + +directory /usr/local/etc/openldap/data diff --git a/.ci/compatibility_tests/pgsql/Dockerfile b/.ci/compatibility_tests/pgsql/Dockerfile new file mode 100644 index 000000000..ca44acffa --- /dev/null +++ b/.ci/compatibility_tests/pgsql/Dockerfile @@ -0,0 +1,12 @@ +ARG BUILD_FROM=postgres:11 +FROM ${BUILD_FROM} +ARG POSTGRES_USER=postgres +COPY --chown=$POSTGRES_USER .ci/compatibility_tests/pgsql/pg_hba.conf /var/lib/postgresql/pg_hba.conf +COPY --chown=$POSTGRES_USER apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.key /var/lib/postgresql/server.key +COPY --chown=$POSTGRES_USER apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.crt /var/lib/postgresql/server.crt +COPY --chown=$POSTGRES_USER apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.crt /var/lib/postgresql/root.crt +RUN chmod 600 /var/lib/postgresql/pg_hba.conf +RUN chmod 600 /var/lib/postgresql/server.key +RUN chmod 600 /var/lib/postgresql/server.crt +RUN chmod 600 /var/lib/postgresql/root.crt +EXPOSE 5432 diff --git a/.ci/compatibility_tests/pgsql/pg_hba.conf b/.ci/compatibility_tests/pgsql/pg_hba.conf new file mode 100644 index 000000000..8b4f9b5a6 --- /dev/null +++ b/.ci/compatibility_tests/pgsql/pg_hba.conf @@ -0,0 +1,9 @@ +# TYPE DATABASE USER CIDR-ADDRESS METHOD +local all all trust +host all all 0.0.0.0/0 trust +host all all ::/0 trust +hostssl all all 0.0.0.0/0 cert +hostssl all all ::/0 cert + +hostssl all www-data 0.0.0.0/0 cert clientcert=1 +hostssl all postgres 0.0.0.0/0 cert clientcert=1 diff --git a/.ci/compatibility_tests/redis/redis-tls.conf b/.ci/compatibility_tests/redis/redis-tls.conf new file mode 100644 index 000000000..3ef09f315 --- /dev/null +++ b/.ci/compatibility_tests/redis/redis-tls.conf @@ -0,0 +1,5 @@ +daemonize yes +bind 0.0.0.0 :: +tls-cert-file /tls/redis.crt +tls-key-file /tls/redis.key +tls-ca-cert-file /tls/ca.crt \ No newline at end of file diff --git a/.ci/compatibility_tests/redis/redis.conf b/.ci/compatibility_tests/redis/redis.conf new file mode 100644 index 000000000..27eabdef5 --- /dev/null +++ b/.ci/compatibility_tests/redis/redis.conf @@ -0,0 +1,2 @@ +daemonize yes +bind 0.0.0.0 :: \ No newline at end of file diff --git a/.ci/compatibility_tests/redis/redis.sh b/.ci/compatibility_tests/redis/redis.sh new file mode 100755 index 000000000..7da422c21 --- /dev/null +++ b/.ci/compatibility_tests/redis/redis.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +node=single +tls=false +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + -n|--node) + node="$2" + shift # past argument + shift # past value + ;; + -t|--tls-enabled) + tls="$2" + shift # past argument + shift # past value + ;; + *) + shift # past argument + ;; +esac +done + +rm -f \ + /data/conf/r7000i.log \ + /data/conf/r7001i.log \ + /data/conf/r7002i.log \ + /data/conf/nodes.7000.conf \ + /data/conf/nodes.7001.conf \ + /data/conf/nodes.7002.conf ; + +if [ ${node} = "cluster" ] ; then + if $tls ; then + redis-server /data/conf/redis-tls.conf --port 7000 --cluster-config-file /data/conf/nodes.7000.conf \ + --tls-port 8000 --cluster-enabled yes ; + redis-server /data/conf/redis-tls.conf --port 7001 --cluster-config-file /data/conf/nodes.7001.conf \ + --tls-port 8001 --cluster-enabled yes; + redis-server /data/conf/redis-tls.conf --port 7002 --cluster-config-file /data/conf/nodes.7002.conf \ + --tls-port 8002 --cluster-enabled yes; + else + redis-server /data/conf/redis.conf --port 7000 --cluster-config-file /data/conf/nodes.7000.conf --cluster-enabled yes; + redis-server /data/conf/redis.conf --port 7001 --cluster-config-file /data/conf/nodes.7001.conf --cluster-enabled yes; + redis-server /data/conf/redis.conf --port 7002 --cluster-config-file /data/conf/nodes.7002.conf --cluster-enabled yes; + fi +elif [ ${node} = "sentinel" ] ; then + redis-server /data/conf/redis.conf --port 7000 --cluster-config-file /data/conf/nodes.7000.conf \ + --cluster-enabled no; + redis-server /data/conf/redis.conf --port 7001 --cluster-config-file /data/conf/nodes.7001.conf \ + --cluster-enabled no --slaveof 172.16.239.10 7000; + redis-server /data/conf/redis.conf --port 7002 --cluster-config-file /data/conf/nodes.7002.conf \ + --cluster-enabled no --slaveof 172.16.239.10 7000; +fi +REDIS_LOAD_FLG=true; + +while $REDIS_LOAD_FLG; +do + sleep 1; + redis-cli -p 7000 info 1> /data/conf/r7000i.log 2> /dev/null; + if [ -s /data/conf/r7000i.log ]; then + : + else + continue; + fi + redis-cli -p 7001 info 1> /data/conf/r7001i.log 2> /dev/null; + if [ -s /data/conf/r7001i.log ]; then + : + else + continue; + fi + redis-cli -p 7002 info 1> /data/conf/r7002i.log 2> /dev/null; + if [ -s /data/conf/r7002i.log ]; then + : + else + continue; + fi + if [ ${node} = "cluster" ] ; then + yes "yes" | redis-cli --cluster create 172.16.239.10:7000 172.16.239.10:7001 172.16.239.10:7002; + elif [ ${node} = "sentinel" ] ; then + cp /data/conf/sentinel.conf /_sentinel.conf + redis-server /_sentinel.conf --sentinel; + fi + REDIS_LOAD_FLG=false; +done + +exit 0; diff --git a/.ci/compatibility_tests/redis/sentinel.conf b/.ci/compatibility_tests/redis/sentinel.conf new file mode 100644 index 000000000..c3f96c1ff --- /dev/null +++ b/.ci/compatibility_tests/redis/sentinel.conf @@ -0,0 +1,3 @@ +port 26379 +dir /tmp +sentinel monitor mymaster 172.16.239.10 7000 1 diff --git a/.ci/fvt_tests/docker-compose.yaml b/.ci/fvt_tests/docker-compose.yaml new file mode 100644 index 000000000..22d48bef7 --- /dev/null +++ b/.ci/fvt_tests/docker-compose.yaml @@ -0,0 +1,70 @@ +version: '3' + +services: + emqx1: + container_name: node1.emqx.io + image: emqx/emqx:build-alpine-amd64 + environment: + - "EMQX_NAME=emqx" + - "EMQX_HOST=node1.emqx.io" + - "EMQX_CLUSTER__DISCOVERY=static" + - "EMQX_CLUSTER__STATIC__SEEDS=emqx@node1.emqx.io, emqx@node2.emqx.io" + - "EMQX_ZONE__EXTERNAL__RETRY_INTERVAL=2s" + - "EMQX_MQTT__MAX_TOPIC_ALIAS=10" + command: + - /bin/sh + - -c + - | + sed -i "s 127.0.0.1 $$(ip route show |grep "link" |awk '{print $$1}') g" /opt/emqx/etc/acl.conf + sed -i '/emqx_telemetry/d' /opt/emqx/data/loaded_plugins + /opt/emqx/bin/emqx foreground + healthcheck: + test: ["CMD", "/opt/emqx/bin/emqx_ctl", "status"] + interval: 5s + timeout: 25s + retries: 5 + networks: + emqx-bridge: + aliases: + - node1.emqx.io + + emqx2: + container_name: node2.emqx.io + image: emqx/emqx:build-alpine-amd64 + environment: + - "EMQX_NAME=emqx" + - "EMQX_HOST=node2.emqx.io" + - "EMQX_CLUSTER__DISCOVERY=static" + - "EMQX_CLUSTER__STATIC__SEEDS=emqx@node1.emqx.io, emqx@node2.emqx.io" + - "EMQX_ZONE__EXTERNAL__RETRY_INTERVAL=2s" + - "EMQX_MQTT__MAX_TOPIC_ALIAS=10" + command: + - /bin/sh + - -c + - | + sed -i "s 127.0.0.1 $$(ip route show |grep "link" |awk '{print $$1}') g" /opt/emqx/etc/acl.conf + sed -i '/emqx_telemetry/d' /opt/emqx/data/loaded_plugins + /opt/emqx/bin/emqx foreground + healthcheck: + test: ["CMD", "/opt/emqx/bin/emqx_ctl", "status"] + interval: 5s + timeout: 25s + retries: 5 + networks: + emqx-bridge: + aliases: + - node2.emqx.io + + client: + container_name: paho_client + image: python:3.7.2-alpine3.9 + depends_on: + - emqx1 + - emqx2 + tty: true + networks: + emqx-bridge: + +networks: + emqx-bridge: + driver: bridge diff --git a/.ci/fvt_tests/http_server/README.md b/.ci/fvt_tests/http_server/README.md new file mode 100644 index 000000000..ea14939b3 --- /dev/null +++ b/.ci/fvt_tests/http_server/README.md @@ -0,0 +1,30 @@ +## http_server + + +The http server for emqx functional validation testing + +### Build + + + $ rebar3 compile + +### Getting Started + +``` +1> http_server:start(). +Start http_server listener on 8080 successfully. +ok +2> http_server:stop(). +ok +``` + +### APIS + ++ GET `/counter` + + 返回计数器的值 + ++ POST `/counter` + + 计数器加一 + diff --git a/.ci/fvt_tests/http_server/rebar.config b/.ci/fvt_tests/http_server/rebar.config new file mode 100644 index 000000000..9cc5f3d02 --- /dev/null +++ b/.ci/fvt_tests/http_server/rebar.config @@ -0,0 +1,10 @@ +{erl_opts, [debug_info]}. +{deps, + [ + {minirest, {git, "https://github.com/emqx/minirest.git", {tag, "0.3.1"}}} + ]}. + +{shell, [ + % {config, "config/sys.config"}, + {apps, [http_server]} +]}. diff --git a/.ci/fvt_tests/http_server/src/http_server.app.src b/.ci/fvt_tests/http_server/src/http_server.app.src new file mode 100644 index 000000000..f351bb349 --- /dev/null +++ b/.ci/fvt_tests/http_server/src/http_server.app.src @@ -0,0 +1,17 @@ +{application, http_server, + [{description, "An OTP application"}, + {vsn, "0.1.0"}, + {registered, []}, + % {mod, {http_server_app, []}}, + {modules, []}, + {applications, + [kernel, + stdlib, + minirest + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache 2.0"]}, + {links, []} + ]}. diff --git a/.ci/fvt_tests/http_server/src/http_server.erl b/.ci/fvt_tests/http_server/src/http_server.erl new file mode 100644 index 000000000..b66b72939 --- /dev/null +++ b/.ci/fvt_tests/http_server/src/http_server.erl @@ -0,0 +1,50 @@ +-module(http_server). + +-import(minirest, [ return/0 + , return/1 + ]). + +-export([ start/0 + , stop/0 + ]). + +-rest_api(#{ name => get_counter + , method => 'GET' + , path => "/counter" + , func => get_counter + , descr => "Check counter" + }). +-rest_api(#{ name => add_counter + , method => 'POST' + , path => "/counter" + , func => add_counter + , descr => "Counter plus one" + }). + +-export([ get_counter/2 + , add_counter/2 + ]). + +start() -> + application:ensure_all_started(minirest), + ets:new(relup_test_message, [named_table, public]), + Handlers = [{"/", minirest:handler(#{modules => [?MODULE]})}], + Dispatch = [{"/[...]", minirest, Handlers}], + minirest:start_http(?MODULE, #{socket_opts => [inet, {port, 8080}]}, Dispatch). + +stop() -> + ets:delete(relup_test_message), + minirest:stop_http(?MODULE). + +get_counter(_Binding, _Params) -> + return({ok, ets:info(relup_test_message, size)}). + +add_counter(_Binding, Params) -> + case lists:keymember(<<"payload">>, 1, Params) of + true -> + {value, {<<"id">>, ID}, Params1} = lists:keytake(<<"id">>, 1, Params), + ets:insert(relup_test_message, {ID, Params1}); + _ -> + ok + end, + return(). diff --git a/.ci/fvt_tests/relup.lux b/.ci/fvt_tests/relup.lux new file mode 100644 index 000000000..c0c2b7592 --- /dev/null +++ b/.ci/fvt_tests/relup.lux @@ -0,0 +1,160 @@ +[config var=PACKAGE_PATH] +[config var=BENCH_PATH] +[config var=ONE_MORE_EMQX_PATH] +[config var=VSN] +[config var=OLD_VSNS] + +[config shell_cmd=/bin/bash] +[config timeout=600000] + +[loop old_vsn $OLD_VSNS] + +[shell http_server] + !cd http_server + !rebar3 shell + ???Eshell + ???> + !http_server:start(). + ?Start http_server listener on 8080 successfully. + ?ok + ?> + +[shell emqx] + !cd $PACKAGE_PATH + !unzip -q -o emqx-ubuntu20.04-$old_vsn-x86_64.zip + ?SH-PROMPT + + !cd emqx + !sed -i 's|listener.wss.external[ \t]*=.*|listener.wss.external = 8085|g' etc/emqx.conf + !sed -i '/emqx_telemetry/d' data/loaded_plugins + !./bin/emqx start + ?EMQ X Broker $old_vsn is started successfully! + + !./bin/emqx_ctl status + """? + Node 'emqx@127.0.0.1' is started + emqx $old_vsn is running + """ + +[shell emqx2] + !cd $PACKAGE_PATH + !cp -f $ONE_MORE_EMQX_PATH/one_more_emqx.sh . + !./one_more_emqx.sh emqx2 + ?SH-PROMPT + !cd emqx2 + + !sed -i '/emqx_telemetry/d' data/loaded_plugins + !./bin/emqx start + ?EMQ X Broker $old_vsn is started successfully! + + !./bin/emqx_ctl status + """? + Node 'emqx2@127.0.0.1' is started + emqx $old_vsn is running + """ + ?SH-PROMPT + + !./bin/emqx_ctl cluster join emqx@127.0.0.1 + ???Join the cluster successfully. + ?SH-PROMPT + + !./bin/emqx_ctl cluster status + """??? + Cluster status: #{running_nodes => ['emqx2@127.0.0.1','emqx@127.0.0.1'], + stopped_nodes => []} + """ + ?SH-PROMPT + + !./bin/emqx_ctl resources create 'web_hook' -i 'resource:691c29ba' -c '{"url": "http://127.0.0.1:8080/counter", "method": "POST"}' + ?created + ?SH-PROMPT + !./bin/emqx_ctl rules create 'SELECT * FROM "t/#"' '[{"name":"data_to_webserver", "params": {"$$resource": "resource:691c29ba"}}]' + ?created + ?SH-PROMPT + +[shell emqx] + !./bin/emqx_ctl resources list + ?691c29ba + ?SH-PROMPT + !./bin/emqx_ctl rules list + ?691c29ba + ?SH-PROMPT + +[shell bench] + !cd $BENCH_PATH + !./emqtt_bench pub -c 10 -I 1000 -t t/%i -s 64 -L 600 + ???sent + +[shell emqx] + !cp -f ../emqx-ubuntu20.04-$VSN-x86_64.zip releases/ + !./bin/emqx install $VSN + ?SH-PROMPT + !./bin/emqx versions |grep permanent | grep -oE "[0-9].[0-9].[0-9]" + ?$VSN + ?SH-PROMPT + + !./bin/emqx_ctl cluster status + """??? + Cluster status: #{running_nodes => ['emqx2@127.0.0.1','emqx@127.0.0.1'], + stopped_nodes => []} + """ + ?SH-PROMPT + +[shell emqx2] + !cp -f ../emqx-ubuntu20.04-$VSN-x86_64.zip releases/ + !./bin/emqx install $VSN + ?SH-PROMPT + !./bin/emqx versions |grep permanent | grep -oE "[0-9].[0-9].[0-9]" + ?$VSN + ?SH-PROMPT + + !./bin/emqx_ctl cluster status + """??? + Cluster status: #{running_nodes => ['emqx2@127.0.0.1','emqx@127.0.0.1'], + stopped_nodes => []} + """ + ?SH-PROMPT + +[shell bench] + ???publish complete + ??SH-PROMPT: + !curl http://127.0.0.1:8080/counter + ???{"data":600,"code":0} + ?SH-PROMPT + +[shell http_server] + !http_server:stop(). + ?ok + ?> + !halt(3). + ?SH-PROMPT: + +[shell emqx2] + !cat log/emqx.log.1 |grep -v 691c29ba |tail -n 100 + -error + ??SH-PROMPT: + + !./bin/emqx stop + ?ok + ?SH-PROMPT: + + !rm -rf $PACKAGE_PATH/emqx2 + ?SH-PROMPT: + +[shell emqx] + !cat log/emqx.log.1 |grep -v 691c29ba |tail -n 100 + -error + ??SH-PROMPT: + + !./bin/emqx stop + ?ok + ?SH-PROMPT: + + !rm -rf $PACKAGE_PATH/emqx + ?SH-PROMPT: + +[endloop] + +[cleanup] + !echo ==$$?== + ?==0== diff --git a/.github/workflows/.gitlint b/.github/workflows/.gitlint index 1396f5911..50e7bf636 100644 --- a/.github/workflows/.gitlint +++ b/.github/workflows/.gitlint @@ -58,7 +58,7 @@ ignore=title-trailing-punctuation, T1, T2, T3, T4, T5, T6, T8, B1, B2, B3, B4, B # python-style regex that the commit-msg title must match # Note that the regex can contradict with other rules if not used correctly # (e.g. title-must-not-contain-word). -regex=^(feat|fix|docs|style|refactor|test|chore|perf)\(.+\): .+ +regex=^(feat|feature|fix|docs|style|refactor|test|chore|build|perf|improve)\(.+\): .+ # [body-max-line-length] # line-length=72 diff --git a/.github/workflows/build_packages.yaml b/.github/workflows/build_packages.yaml new file mode 100644 index 000000000..9854c7a75 --- /dev/null +++ b/.github/workflows/build_packages.yaml @@ -0,0 +1,394 @@ +name: Cross build packages + +on: + push: + tags: + - v* + release: + types: + - published + pull_request: + workflow_dispatch: + repository_dispatch: + types: [run_actions] + +jobs: + windows: + runs-on: windows-2019 + + steps: + - uses: actions/checkout@v1 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: gleam-lang/setup-erlang@v1.1.0 + id: install_erlang + with: + otp-version: 23.2 + - name: build + run: | + # set-executionpolicy remotesigned -s cu + # iex (new-object net.webclient).downloadstring('https://get.scoop.sh') + # # $env:path + ";" + $env:USERPROFILE + "\scoop\shims" + ';C:\Program Files\erl10.4\bin' + # [environment]::SetEnvironmentvariable("Path", ";" + $env:USERPROFILE + "\scoop\shims") + # [environment]::SetEnvironmentvariable("Path", ';C:\Program Files\erl10.4\bin') + # scoop bucket add extras https://github.com/lukesampson/scoop-extras.git + # scoop update + # scoop install sudo curl vcredist2013 + + $env:PATH = "${{ steps.install_erlang.outputs.erlpath }}\bin;$env:PATH" + + $version = $( "${{ github.ref }}" -replace "^(.*)/(.*)/" ) + if ($version -match "^v[0-9]+\.[0-9]+(\.[0-9]+)?") { + $regex = "[0-9]+\.[0-9]+(-alpha|-beta|-rc)?\.[0-9]" + $pkg_name = "emqx-windows-$([regex]::matches($version, $regex).value).zip" + } + else { + $pkg_name = "emqx-windows-$($version -replace '/').zip" + } + + make deps-emqx || cat rebar3.crashdump + $rebar3 = $env:USERPROFILE + "\rebar3" + (New-Object System.Net.WebClient).DownloadFile('https://s3.amazonaws.com/rebar3/rebar3', $rebar3) + cd _build/emqx/lib/jiffy/ + escript $rebar3 compile + cd ../../../../ + + make emqx + mkdir -p _packages/emqx + Compress-Archive -Path _build/emqx/rel/emqx -DestinationPath _build/emqx/rel/$pkg_name + mv _build/emqx/rel/$pkg_name _packages/emqx + Get-FileHash -Path "_packages/emqx/$pkg_name" | Format-List | grep 'Hash' | awk '{print $3}' > _packages/emqx/$pkg_name.sha256 + - name: run emqx + run: | + ./_build/emqx/rel/emqx/bin/emqx start + ./_build/emqx/rel/emqx/bin/emqx stop + ./_build/emqx/rel/emqx/bin/emqx install + ./_build/emqx/rel/emqx/bin/emqx uninstall + - uses: actions/upload-artifact@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: emqx + path: ./_packages/emqx/. + + mac: + runs-on: macos-10.15 + + steps: + - uses: actions/checkout@v1 + - name: prepare + run: | + brew install curl zip unzip gnu-sed kerl unixodbc freetds + echo "/usr/local/bin" >> $GITHUB_PATH + git config --global credential.helper store + - name: build erlang + run: | + kerl build 23.2.2 + kerl install 23.2.2 $HOME/.kerl/23.2.2 + - name: build + run: | + . $HOME/.kerl/23.2.2/activate + make emqx-pkg + - name: test + run: | + pkg_name=$(basename _packages/emqx/emqx-macos-*.zip) + unzip _packages/emqx/$pkg_name + gsed -i '/emqx_telemetry/d' ./emqx/data/loaded_plugins + ./emqx/bin/emqx start || cat emqx/log/erlang.log.1 + ready='no' + for i in {1..10}; do + if curl -fs 127.0.0.1:18083 > /dev/null; then + ready='yes' + break + fi + sleep 1 + done + if [ "$ready" != "yes" ]; then + echo "Timed out waiting for emqx to be ready" + cat emqx/log/erlang.log.1 + exit 1 + fi + ./emqx/bin/emqx_ctl status + ./emqx/bin/emqx stop + rm -rf emqx + openssl dgst -sha256 ./_packages/emqx/$pkg_name | awk '{print $2}' > ./_packages/emqx/$pkg_name.sha256 + - uses: actions/upload-artifact@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: emqx + path: ./_packages/emqx/. + + linux: + runs-on: ubuntu-20.04 + + strategy: + matrix: + arch: + - amd64 + - arm64 + emqx: + - emqx + - emqx-edge + os: + - ubuntu20.04 + - ubuntu18.04 + - ubuntu16.04 + - debian10 + - debian9 + - opensuse + - centos8 + - centos7 + - centos6 + - raspbian10 + - raspbian9 + exclude: + - os: raspbian9 + arch: amd64 + - os: raspbian9 + emqx: emqx + - os: raspbian10 + arch: amd64 + - os: raspbian10 + emqx: emqx + - os: centos6 + arch: arm64 + + defaults: + run: + shell: bash + + steps: + - name: prepare docker + run: | + mkdir -p $HOME/.docker + echo '{ "experimental": "enabled" }' | tee $HOME/.docker/config.json + echo '{ "experimental": true, "storage-driver": "overlay2", "max-concurrent-downloads": 50, "max-concurrent-uploads": 50}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + docker info + docker buildx create --use --name mybuild + docker run --rm --privileged tonistiigi/binfmt --install all + - uses: actions/checkout@v1 + - name: get deps + env: + ERL_OTP: erl23.2.2 + run: | + docker run -i --rm \ + -e GITHUB_RUN_ID=$GITHUB_RUN_ID \ + -e GITHUB_REF=$GITHUB_REF \ + -v $(pwd):/emqx \ + -w /emqx \ + emqx/build-env:${ERL_OTP}-debian10 \ + bash -c "make deps-all" + - name: downloads emqx zip packages + env: + EMQX: ${{ matrix.emqx }} + ARCH: ${{ matrix.arch }} + SYSTEM: ${{ matrix.os }} + run: | + set -e -u -x + if [ $EMQX = "emqx-edge" ];then broker="emqx-edge"; else broker="emqx-ce"; fi + if [ $ARCH = "arm64" ];then arch="aarch64"; else arch="x86_64"; fi + + vsn="$(grep -oE '\{vsn, (.*)\}' src/emqx.app.src | sed -r 's/\{vsn, (.*)\}/\1/g' | sed 's/\"//g')" + pre_vsn="$(echo $vsn | grep -oE '^[0-9]+.[0-9]')" + old_vsns=($(git tag -l "$pre_vsn.[0-9]" | sed "s/$vsn//")) + + mkdir -p tmp/relup_packages/$EMQX + cd tmp/relup_packages/$EMQX + for tag in ${old_vsns[@]};do + if [ ! -z "$(echo $(curl -I -m 10 -o /dev/null -s -w %{http_code} https://s3-us-west-2.amazonaws.com/packages.emqx/$broker/v${tag#[e|v]}/$EMQX-$SYSTEM-${tag#[e|v]}-$arch.zip) | grep -oE "^[23]+")" ];then + wget https://s3-us-west-2.amazonaws.com/packages.emqx/$broker/v${tag#[e|v]}/$EMQX-$SYSTEM-${tag#[e|v]}-$arch.zip + wget https://s3-us-west-2.amazonaws.com/packages.emqx/$broker/v${tag#[e|v]}/$EMQX-$SYSTEM-${tag#[e|v]}-$arch.zip.sha256 + echo "$(cat $EMQX-$SYSTEM-${tag#[e|v]}-$arch.zip.sha256) $EMQX-$SYSTEM-${tag#[e|v]}-$arch.zip" | sha256sum -c || exit 1 + fi + done + cd - + - name: build emqx packages + if: (matrix.arch == 'amd64' && matrix.emqx == 'emqx') || startsWith(github.ref, 'refs/tags/') + env: + ERL_OTP: erl23.2.2 + EMQX: ${{ matrix.emqx }} + ARCH: ${{ matrix.arch }} + SYSTEM: ${{ matrix.os }} + run: | + set -e -u -x + docker buildx build --no-cache \ + --platform=linux/$ARCH \ + -t cross_build_emqx_for_$SYSTEM \ + -f .ci/build_packages/Dockerfile \ + --build-arg BUILD_FROM=emqx/build-env:$ERL_OTP-$SYSTEM \ + --build-arg EMQX_NAME=$EMQX \ + --output type=tar,dest=/tmp/cross-build-$EMQX-for-$SYSTEM.tar . + + mkdir -p /tmp/packages/$EMQX + tar -xvf /tmp/cross-build-$EMQX-for-$SYSTEM.tar --wildcards emqx/_packages/$EMQX/* + mv emqx/_packages/$EMQX/* /tmp/packages/$EMQX/ + rm -rf /tmp/cross-build-$EMQX-for-$SYSTEM.tar + + docker rm -f $(docker ps -a -q) + docker volume prune -f + - name: create sha256 + env: + EMQX: ${{ matrix.emqx }} + run: | + if [ -d /tmp/packages/$EMQX ]; then + cd /tmp/packages/$EMQX + for var in $(ls emqx-* ); do + bash -c "echo $(sha256sum $var | awk '{print $1}') > $var.sha256" + done + cd - + fi + - uses: actions/upload-artifact@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: ${{ matrix.emqx }} + path: /tmp/packages/${{ matrix.emqx }}/. + + docker: + runs-on: ubuntu-20.04 + + strategy: + matrix: + arch: + - [amd64, x86_64] + - [arm64v8, aarch64] + - [arm32v7, arm] + - [i386, i386] + - [s390x, s390x] + + steps: + - uses: actions/checkout@v1 + - name: get deps + env: + ERL_OTP: erl23.2.2 + run: | + docker run -i --rm \ + -e GITHUB_RUN_ID=$GITHUB_RUN_ID \ + -e GITHUB_REF=$GITHUB_REF \ + -v $(pwd):/emqx \ + -w /emqx \ + emqx/build-env:${ERL_OTP}-alpine-amd64 \ + sh -c "make deps-emqx" + - name: build emqx docker image + env: + ARCH: ${{ matrix.arch[0] }} + QEMU_ARCH: ${{ matrix.arch[1] }} + run: | + sudo docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + + sudo TARGET=emqx/emqx ARCH=$ARCH QEMU_ARCH=$QEMU_ARCH make docker + cd _packages/emqx && for var in $(ls emqx-docker-* ); do sudo bash -c "echo $(sha256sum $var | awk '{print $1}') > $var.sha256"; done && cd - + + sudo TARGET=emqx/emqx-edge ARCH=$ARCH QEMU_ARCH=$QEMU_ARCH make docker + cd _packages/emqx-edge && for var in $(ls emqx-edge-docker-* ); do sudo bash -c "echo $(sha256sum $var | awk '{print $1}') > $var.sha256"; done && cd - + - uses: actions/upload-artifact@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: emqx + path: ./_packages/emqx/. + - uses: actions/upload-artifact@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: emqx-edge + path: ./_packages/emqx-edge/. + + upload: + runs-on: ubuntu-20.04 + + needs: [windows, mac, linux, docker] + + if: startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/download-artifact@v2 + with: + name: emqx + path: ./_packages/emqx + - uses: actions/download-artifact@v2 + with: + name: emqx-edge + path: ./_packages/emqx-edge + - name: install dos2unix + run: sudo apt-get update && sudo apt install -y dos2unix + - name: get packages + run: | + set -e -x -u + for EMQX in emqx emqx-edge; do + cd _packages/$EMQX + for var in $( ls |grep emqx |grep -v sha256); do + dos2unix $var.sha256 + echo "$(cat $var.sha256) $var" | sha256sum -c || exit 1 + done + cd - + done + - name: upload aws s3 + run: | + set -e -x -u + version=$(echo ${{ github.ref }} | sed -r "s ^refs/heads/|^refs/tags/(.*) \1 g") + curl "https://d1vvhvl2y92vvt.cloudfront.net/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + unzip awscliv2.zip + sudo ./aws/install + aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }} + aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws configure set default.region us-west-2 + + aws s3 cp --recursive _packages/emqx s3://packages.emqx/emqx-ce/$version + aws s3 cp --recursive _packages/emqx-edge s3://packages.emqx/emqx-edge/$version + aws cloudfront create-invalidation --distribution-id E170YEULGLT8XB --paths "/emqx-ce/$version/*,/emqx-edge/$version/*" + + mkdir packages + mv _packages/emqx/* packages + mv _packages/emqx-edge/* packages + - uses: actions/checkout@v2 + with: + path: emqx + - name: update to github and emqx.io + if: github.event_name == 'release' + run: | + set -e -x -u + version=$(echo ${{ github.ref }} | sed -r "s ^refs/heads/|^refs/tags/(.*) \1 g") + cd packages + for var in $(ls); do + ../emqx/.ci/build_packages/upload_github_release_asset.sh owner=emqx repo=emqx tag=$version filename=$var github_api_token=$(echo ${{ secrets.AccessToken }}) + sleep 1 + done + curl -w %{http_code} \ + --insecure \ + -H "Content-Type: application/json" \ + -H "token: ${{ secrets.EMQX_IO_TOKEN }}" \ + -X POST \ + -d "{\"repo\":\"emqx/emqx\", \"tag\": \"${version}\" }" \ + ${{ secrets.EMQX_IO_RELEASE_API }} + - name: push docker image to docker hub + if: github.event_name == 'release' + run: | + set -e -x -u + version=$(echo ${{ github.ref }} | sed -r "s ^refs/heads/|^refs/tags/(.*) \1 g") + sudo make -C emqx docker-prepare + cd packages && for var in $(ls |grep docker |grep -v sha256); do unzip $var; sudo docker load < ${var%.*}; rm -f ${var%.*}; done && cd - + echo ${{ secrets.DOCKER_HUB_TOKEN }} |sudo docker login -u ${{ secrets.DOCKER_HUB_USER }} --password-stdin + sudo TARGET=emqx/emqx make -C emqx docker-push + sudo TARGET=emqx/emqx make -C emqx docker-manifest-list + sudo TARGET=emqx/emqx-edge make -C emqx docker-push + sudo TARGET=emqx/emqx-edge make -C emqx docker-manifest-list + - name: update repo.emqx.io + if: github.event_name == 'release' + run: | + set -e -x -u + version=$(echo ${{ github.ref }} | sed -r "s ^refs/heads/|^refs/tags/(.*) \1 g") + curl \ + -H "Authorization: token ${{ secrets.AccessToken }}" \ + -H "Accept: application/vnd.github.v3+json" \ + -X POST \ + -d "{\"ref\":\"v1.0.0\",\"inputs\":{\"version\": \"${version}\", \"emqx_ce\": \"true\"}}" \ + https://api.github.com/repos/emqx/emqx-ci-helper/actions/workflows/update_repos.yaml/dispatches + - uses: geekyeggo/delete-artifact@v1 + with: + name: emqx + - uses: geekyeggo/delete-artifact@v1 + with: + name: emqx-edge + # - name: update homebrew packages + # run: | + # version=$(echo ${{ github.ref }} | sed -r "s .*/.*/(.*) \1 g") + # if [ ! -z $(echo $version | grep -oE "v[0-9]+\.[0-9]+(\.[0-9]+)?") ] && [ -z $(echo $version | grep -oE "(alpha|beta|rc)\.[0-9]") ]; then + # curl -H "Authorization: token ${{ secrets.AccessToken }}" -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" -X POST -d "{\"event_type\":\"update_homebrew\",\"client_payload\":{\"version\": \"$version\"}}" https://api.github.com/repos/emqx/emqx-packages-docker/dispatches + # fi diff --git a/.github/workflows/elvis_lint.yaml b/.github/workflows/elvis_lint.yaml index 6f9d8d31a..af824f034 100644 --- a/.github/workflows/elvis_lint.yaml +++ b/.github/workflows/elvis_lint.yaml @@ -4,8 +4,8 @@ on: [pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v1 - run: | - ./elvis-check.sh $GITHUB_BASE_REF + ./scripts/elvis-check.sh $GITHUB_BASE_REF diff --git a/.github/workflows/run_cts_tests.yaml b/.github/workflows/run_cts_tests.yaml new file mode 100644 index 000000000..de54b2b8c --- /dev/null +++ b/.github/workflows/run_cts_tests.yaml @@ -0,0 +1,315 @@ +name: Compatibility Test Suite + +on: + push: + tags: + - v* + release: + types: + - published + pull_request: + workflow_dispatch: + repository_dispatch: + types: [run_actions] + +jobs: + ldap: + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + ldap_tag: + - 2.4.50 + network_type: + - ipv4 + - ipv6 + + steps: + - uses: actions/checkout@v1 + - name: setup + env: + LDAP_TAG: ${{ matrix.ldap_tag }} + run: | + docker-compose -f .ci/apps_tests/docker-compose.yaml build --no-cache + docker-compose -f .ci/compatibility_tests/docker-compose-ldap.yaml up -d + - name: setup + if: matrix.network_type == 'ipv4' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ldap) + sed -i "s|^[#[:space:]]*auth.ldap.servers[[:space:]]*=.*|auth.ldap.servers = $server_address|g" apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf + - name: setup + if: matrix.network_type == 'ipv6' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' ldap) + sed -i "s|^[#[:space:]]*auth.ldap.servers[[:space:]]*=.*|auth.ldap.servers = $server_address|g" apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf + - name: run test cases + run: | + docker exec -i erlang sh -c "make ensure-rebar3" + docker exec -i erlang sh -c "./rebar3 eunit --dir apps/emqx_auth_ldap" + docker exec -i erlang sh -c "./rebar3 ct --dir apps/emqx_auth_ldap" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs_ldap${{ matrix.ldap_tag }}_${{ matrix.network_type }} + path: _build/test/logs + + mongo: + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + mongo_tag: + - 3 + - 4 + network_type: + - ipv4 + - ipv6 + connect_type: + - tls + - tcp + + steps: + - uses: actions/checkout@v1 + - name: setup + env: + MONGO_TAG: ${{ matrix.mongo_tag }} + if: matrix.connect_type == 'tls' + run: | + docker-compose -f .ci/compatibility_tests/docker-compose-mongo-tls.yaml up -d + sed -i 's|^[#[:space:]]*auth.mongo.ssl[[:space:]]*=.*|auth.mongo.ssl = on|g' apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + sed -i 's|^[#[:space:]]*auth.mongo.cacertfile[[:space:]]*=.*|auth.mongo.cacertfile = /emqx/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca.pem|g' apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + sed -i 's|^[#[:space:]]*auth.mongo.certfile[[:space:]]*=.*|auth.mongo.certfile = /emqx/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-cert.pem|g' apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + sed -i 's|^[#[:space:]]*auth.mongo.keyfile[[:space:]]*=.*|auth.mongo.keyfile = /emqx/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-key.pem|g' apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + - name: setup + env: + MONGO_TAG: ${{ matrix.mongo_tag }} + if: matrix.connect_type == 'tcp' + run: docker-compose -f .ci/compatibility_tests/docker-compose-mongo.yaml up -d + - name: setup + if: matrix.network_type == 'ipv4' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mongo) + sed -i "s|^[#[:space:]]*auth.mongo.server[[:space:]]*=.*|auth.mongo.server = $server_address:27017|g" apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + - name: setup + if: matrix.network_type == 'ipv6' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' mongo) + sed -i "s|^[#[:space:]]*auth.mongo.server[[:space:]]*=.*|auth.mongo.server = $server_address:27017|g" apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + - name: run test cases + run: | + docker exec -i erlang sh -c "make ensure-rebar3" + docker exec -i erlang sh -c "./rebar3 eunit --dir apps/emqx_auth_mongo" + docker exec -i erlang sh -c "./rebar3 ct --dir apps/emqx_auth_mongo" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs_mongo${{ matrix.mongo_tag }}_${{ matrix.network_type }}_${{ matrix.connect_type }} + path: _build/test/logs + + mysql: + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + mysql_tag: + - 5.7 + - 8 + network_type: + - ipv4 + - ipv6 + connect_type: + # - tls + - tcp + + steps: + - uses: actions/checkout@v1 + - name: setup + env: + MYSQL_TAG: ${{ matrix.mysql_tag }} + if: matrix.connect_type == 'tls' + run: | + docker-compose -f .ci/compatibility_tests/docker-compose-mysql-tls.yaml up -d + sed -i 's|^[#[:space:]]*auth.mysql.ssl[[:space:]]*=.*|auth.mysql.ssl = on|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.ssl.cacertfile[[:space:]]*=.*|auth.mysql.ssl.cacertfile = /emqx/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca.pem|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.ssl.certfile[[:space:]]*=.*|auth.mysql.ssl.certfile = /emqx/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-cert.pem|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.ssl.keyfile[[:space:]]*=.*|auth.mysql.ssl.keyfile = /emqx/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-key.pem|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + - name: setup + env: + MYSQL_TAG: ${{ matrix.mysql_tag }} + if: matrix.connect_type == 'tcp' + run: docker-compose -f .ci/compatibility_tests/docker-compose-mysql.yaml up -d + - name: setup + if: matrix.network_type == 'ipv4' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mysql) + sed -i "/auth.mysql.server/c auth.mysql.server = $server_address:3306" apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + - name: setup + if: matrix.network_type == 'ipv6' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' mysql) + sed -i "/auth.mysql.server/c auth.mysql.server = $server_address:3306" apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + - name: setup + run: | + sed -i 's|^[#[:space:]]*auth.mysql.username[[:space:]]*=.*|auth.mysql.username = root|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.password[[:space:]]*=.*|auth.mysql.password = public|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.database[[:space:]]*=.*|auth.mysql.database = mqtt|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + - name: run test cases + run: | + docker exec -i erlang sh -c "make ensure-rebar3" + docker exec -i erlang sh -c "./rebar3 eunit --dir apps/emqx_auth_mysql" + docker exec -i erlang sh -c "./rebar3 ct --dir apps/emqx_auth_mysql" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs_mysql${{ matrix.mysql_tag }}_${{ matrix.network_type }}_${{ matrix.connect_type }} + path: _build/test/logs + + pgsql: + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + pgsql_tag: + - 9 + - 10 + - 11 + - 12 + - 13 + network_type: + - ipv4 + - ipv6 + connect_type: + - tls + - tcp + steps: + - uses: actions/checkout@v1 + - name: setup + env: + PGSQL_TAG: ${{ matrix.pgsql_tag }} + if: matrix.connect_type == 'tls' + run: | + docker-compose -f .ci/compatibility_tests/docker-compose-pgsql-tls.yaml build --no-cache + docker-compose -f .ci/compatibility_tests/docker-compose-pgsql-tls.yaml up -d + if [ "$PGSQL_TAG" = "12" ] || [ "$PGSQL_TAG" = "13" ]; then + sed -i 's|^[#[:space:]]*auth.pgsql.ssl.tls_versions[ \t]*=.*|auth.pgsql.ssl.tls_versions = tlsv1.3,tlsv1.2|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + else + sed -i 's|^[#[:space:]]*auth.pgsql.ssl.tls_versions[ \t]*=.*|auth.pgsql.ssl.tls_versions = tlsv1.2,tlsv1.1|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + fi + + sed -i 's|^[#[:space:]]*auth.pgsql.username[ \t]*=.*|auth.pgsql.username = postgres|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.password[ \t]*=.*|auth.pgsql.password = postgres|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.database[ \t]*=.*|auth.pgsql.database = postgres|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.ssl[ \t]*=.*|auth.pgsql.ssl = on|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.cacertfile[ \t]*=.*|auth.pgsql.cacertfile = /emqx/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.crt|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + - name: setup + env: + PGSQL_TAG: ${{ matrix.pgsql_tag }} + if: matrix.connect_type == 'tcp' + run: | + docker-compose -f .ci/compatibility_tests/docker-compose-pgsql.yaml up -d + sed -i 's|^[#[:space:]]*auth.pgsql.username[ \t]*=.*|auth.pgsql.username = root|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.password[ \t]*=.*|auth.pgsql.password = public|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.database[ \t]*=.*|auth.pgsql.database = mqtt|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + - name: setup + if: matrix.network_type == 'ipv4' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pgsql) + sed -i "s|^[#[:space:]]*auth.pgsql.server[[:space:]]*=.*|auth.pgsql.server = $server_address:5432|g" apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + - name: setup + if: matrix.network_type == 'ipv6' + run: | + server_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' pgsql) + sed -i "s|^[#[:space:]]*auth.pgsql.server[[:space:]]*=.*|auth.pgsql.server = $server_address:5432|g" apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + - name: run test cases + run: | + docker exec -i erlang sh -c "make ensure-rebar3" + docker exec -i erlang sh -c "./rebar3 eunit --dir apps/emqx_auth_pgsql" + docker exec -i erlang sh -c "./rebar3 ct --dir apps/emqx_auth_pgsql" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs_pgsql${{ matrix.pgsql_tag }}_${{ matrix.network_type }}_${{ matrix.connect_type }} + path: _build/test/logs + + redis: + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + redis_tag: + - 5 + - 6 + network_type: + - ipv4 + - ipv6 + connect_type: + - tls + - tcp + node_type: + - single + - cluster + + steps: + - uses: actions/checkout@v1 + - name: setup + env: + REDIS_TAG: ${{ matrix.redis_tag }} + if: matrix.connect_type == 'tls' && matrix.redis_tag != '5' + run: | + set -exu + docker-compose -f .ci/compatibility_tests/docker-compose-redis-${{ matrix.node_type }}-tls.yaml up -d + sed -i 's|^[#[:space:]]*auth.redis.ssl[[:space:]]*=.*|auth.redis.ssl = on|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + sed -i 's|^[#[:space:]]*auth.redis.ssl.cacertfile[[:space:]]*=.*|auth.redis.ssl.cacertfile = /emqx/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.crt|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + sed -i 's|^[#[:space:]]*auth.redis.ssl.certfile[[:space:]]*=.*|auth.redis.ssl.certfile = /emqx/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.crt|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + sed -i 's|^[#[:space:]]*auth.redis.ssl.keyfile[[:space:]]*=.*|auth.redis.ssl.keyfile = /emqx/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.key|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + - name: setup + env: + REDIS_TAG: ${{ matrix.redis_tag }} + if: matrix.connect_type == 'tcp' + run: docker-compose -f .ci/compatibility_tests/docker-compose-redis-${{ matrix.node_type }}.yaml up -d + - name: get server address + if: matrix.connect_type == 'tcp' || (matrix.connect_type == 'tls' && matrix.redis_tag != '5') + run: | + set -exu + ipv4_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis) + ipv6_address=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}' redis) + echo "redis_ipv4_address=$ipv4_address" >> $GITHUB_ENV + echo "redis_ipv6_address=$ipv6_address" >> $GITHUB_ENV + - name: setup + if: matrix.node_type == 'single' && matrix.connect_type == 'tcp' + run: | + set -exu + sed -i "s|^[#[:space:]]*auth.redis.server[[:space:]]*=.*|auth.redis.server = ${redis_${{ matrix.network_type }}_address}:6379|g" apps/emqx_auth_redis/etc/emqx_auth_redis.conf + - name: setup + if: matrix.node_type == 'single' && matrix.connect_type == 'tls' && matrix.redis_tag != '5' + run: | + set -exu + sed -i "s|^[#[:space:]]*auth.redis.server[[:space:]]*=.*|auth.redis.server = ${redis_${{ matrix.network_type }}_address}:6380|g" apps/emqx_auth_redis/etc/emqx_auth_redis.conf + - name: setup + if: matrix.node_type == 'cluster' && matrix.connect_type == 'tcp' + run: | + set -exu + sed -i 's|^[#[:space:]]*auth.redis.type[[:space:]]*=.*|auth.redis.type = cluster|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + sed -i "s|^[#[:space:]]*auth.redis.server[[:space:]]*=.*|auth.redis.server = ${redis_${{ matrix.network_type }}_address}:7000, ${redis_${{ matrix.network_type }}_address}:7001, ${redis_${{ matrix.network_type }}_address}:7002|g" apps/emqx_auth_redis/etc/emqx_auth_redis.conf + - name: setup + if: matrix.node_type == 'cluster' && matrix.connect_type == 'tls' && matrix.redis_tag != '5' + run: | + set -exu + sed -i 's|^[#[:space:]]*auth.redis.type[[:space:]]*=.*|auth.redis.type = cluster|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + sed -i "s|^[#[:space:]]*auth.redis.server[[:space:]]*=.*|auth.redis.server = ${redis_${{ matrix.network_type }}_address}:8000, ${redis_${{ matrix.network_type }}_address}:8001, ${redis_${{ matrix.network_type }}_address}:8002|g" apps/emqx_auth_redis/etc/emqx_auth_redis.conf + - name: run test cases + if: matrix.connect_type == 'tcp' || (matrix.connect_type == 'tls' && matrix.redis_tag != '5') + run: | + docker exec -i erlang sh -c "make ensure-rebar3" + docker exec -i erlang sh -c "./rebar3 eunit --dir apps/emqx_auth_redis" + docker exec -i erlang sh -c "./rebar3 ct --dir apps/emqx_auth_redis" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs_redis${{ matrix.redis_tag }}_${{ matrix.node_type }}_${{ matrix.network_type }}_${{ matrix.connect_type }} + path: _build/test/logs diff --git a/.github/workflows/run_fvt_tests.yaml b/.github/workflows/run_fvt_tests.yaml new file mode 100644 index 000000000..761daa237 --- /dev/null +++ b/.github/workflows/run_fvt_tests.yaml @@ -0,0 +1,206 @@ +name: Functional Verification Tests + +on: + push: + tags: + - v* + release: + types: + - published + pull_request: + workflow_dispatch: + repository_dispatch: + types: [run_actions] + +jobs: + docker_test: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v1 + - name: make emqx image + run: TARGET=emqx/emqx make docker + - name: run emqx + timeout-minutes: 5 + run: | + set -e -u -x + docker-compose -f .ci/fvt_tests/docker-compose.yaml up -d + while [ "$(docker inspect -f '{{ .State.Health.Status}}' node1.emqx.io)" != "healthy" ] || [ "$(docker inspect -f '{{ .State.Health.Status}}' node2.emqx.io)" != "healthy" ]; do + if [ $(docker ps -a -f name=fvt_tests_emqx -f status=exited -q | wc -l) -ne 0 ]; then + echo "['$(date -u +"%Y-%m-%dT%H:%M:%SZ")']:emqx stop"; + exit; + else + echo "['$(date -u +"%Y-%m-%dT%H:%M:%SZ")']:waiting emqx"; + sleep 5; + fi; + done + - name: make paho tests + run: | + docker exec -i paho_client sh -c "apk update && apk add git curl \ + && git clone -b develop-4.0 https://github.com/emqx/paho.mqtt.testing.git /paho.mqtt.testing \ + && pip install pytest \ + && pytest -v /paho.mqtt.testing/interoperability/test_client/V5/test_connect.py -k test_basic --host node1.emqx.io \ + && pytest -v /paho.mqtt.testing/interoperability/test_cluster --host1 node1.emqx.io --host2 node2.emqx.io \ + && pytest -v /paho.mqtt.testing/interoperability/test_client --host node1.emqx.io" + + helm_test: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v1 + - name: make emqx image + run: TARGET=emqx/emqx make docker + - name: install k3s + env: + KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" + run: | + sudo sh -c "echo \"127.0.0.1 $(hostname)\" >> /etc/hosts" + curl -sfL https://get.k3s.io | sh - + sudo chmod 644 /etc/rancher/k3s/k3s.yaml + kubectl cluster-info + - name: install helm + env: + KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 + sudo chmod 700 get_helm.sh + sudo ./get_helm.sh + helm version + - name: run emqx on chart + env: + KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" + timeout-minutes: 5 + run: | + version=$(./pkg-vsn.sh) + sudo docker save emqx/emqx:$version -o emqx.tar.gz + sudo k3s ctr image import emqx.tar.gz + + sed -i -r "s/^appVersion: .*$/appVersion: \"${version}\"/g" deploy/charts/emqx/Chart.yaml + sed -i -r 's/ pullPolicy: .*$/ pullPolicy: Never/g' deploy/charts/emqx/values.yaml + sed -i '/emqx_telemetry/d' deploy/charts/emqx/values.yaml + + helm install emqx --set emqxAclConfig="" --set emqxConfig.EMQX_ZONE__EXTERNAL__RETRY_INTERVAL=2s --set emqxConfig.EMQX_MQTT__MAX_TOPIC_ALIAS=10 deploy/charts/emqx --debug --dry-run + helm install emqx --set emqxAclConfig="" --set emqxConfig.EMQX_ZONE__EXTERNAL__RETRY_INTERVAL=2s --set emqxConfig.EMQX_MQTT__MAX_TOPIC_ALIAS=10 deploy/charts/emqx + + while [ "$(kubectl get StatefulSet -l app.kubernetes.io/name=emqx -o jsonpath='{.items[0].status.replicas}')" \ + != "$(kubectl get StatefulSet -l app.kubernetes.io/name=emqx -o jsonpath='{.items[0].status.readyReplicas}')" ]; do + echo "=============================="; + kubectl get pods; + echo "=============================="; + echo "waiting emqx started"; + sleep 10; + done + - name: get pods log + if: failure() + env: + KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" + run: kubectl describe pods emqx-0 + - uses: actions/checkout@v2 + with: + repository: emqx/paho.mqtt.testing + ref: develop-4.0 + path: paho.mqtt.testing + - name: install pytest + run: | + pip install pytest + echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: run paho test + env: + KUBECONFIG: "/etc/rancher/k3s/k3s.yaml" + run: | + emqx_svc=$(kubectl get svc --namespace default emqx -o jsonpath="{.spec.clusterIP}") + emqx1=$(kubectl get pods emqx-1 -o jsonpath='{.status.podIP}') + emqx2=$(kubectl get pods emqx-2 -o jsonpath='{.status.podIP}') + + pytest -v paho.mqtt.testing/interoperability/test_client/V5/test_connect.py -k test_basic --host $emqx_svc + pytest -v paho.mqtt.testing/interoperability/test_cluster --host1 $emqx1 --host2 $emqx2 + + relup_test: + runs-on: ubuntu-20.04 + container: emqx/build-env:erl23.2.2-ubuntu20.04 + defaults: + run: + shell: bash + steps: + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + architecture: 'x64' + - uses: actions/checkout@v2 + with: + repository: emqx/paho.mqtt.testing + ref: develop-4.0 + path: paho.mqtt.testing + - uses: actions/checkout@v2 + with: + repository: terry-xiaoyu/one_more_emqx + ref: master + path: one_more_emqx + - uses: actions/checkout@v2 + with: + repository: emqx/emqtt-bench + ref: master + path: emqtt-bench + - uses: actions/checkout@v2 + with: + repository: hawk/lux + ref: lux-2.4 + path: lux + - uses: actions/checkout@v2 + with: + repository: ${{ github.repository }} + path: emqx + fetch-depth: 0 + - name: get version + run: | + set -e -x -u + cd emqx + vsn="$(erl -eval '{ok, [{application,emqx, L} | _]} = file:consult("src/emqx.app.src"), {vsn, VSN} = lists:keyfind(vsn,1,L), io:fwrite(VSN), halt().' -noshell)" + echo "VSN=$vsn" >> $GITHUB_ENV + pre_tag="$(echo $vsn | grep -oE '^[0-9]+.[0-9]')" + old_vsns="$(git tag -l "$pre_tag.[0-9]" | tr "\n" " " | sed "s/$vsn//")" + echo "OLD_VSNS=$old_vsns" >> $GITHUB_ENV + - name: download emqx + run: | + set -e -x -u + cd emqx + old_vsns=($(echo $OLD_VSNS | tr ' ' ' ')) + for old_vsn in ${old_vsns[@]}; do + wget https://s3-us-west-2.amazonaws.com/packages.emqx/emqx-ce/v$old_vsn/emqx-ubuntu20.04-${old_vsn}-x86_64.zip + done + - name: build emqx + run: make -C emqx emqx-zip + - name: build emqtt-bench + run: make -C emqtt-bench + - name: build lux + run: | + set -e -u -x + cd lux + autoconf + ./configure + make + make install + - name: run relup test + run: | + set -e -x -u + if [ -n "$OLD_VSNS" ]; then + mkdir -p packages + cp emqx/_packages/emqx/*.zip packages + cp emqx/*.zip packages + lux -v \ + --timeout 600000 \ + --var PACKAGE_PATH=$(pwd)/packages \ + --var BENCH_PATH=$(pwd)/emqtt-bench \ + --var ONE_MORE_EMQX_PATH=$(pwd)/one_more_emqx \ + --var VSN="$VSN" \ + --var OLD_VSNS="$OLD_VSNS" \ + emqx/.ci/fvt_tests/relup.lux + fi + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: lux_logs + path: lux_logs + + + diff --git a/.github/workflows/run_gitlint.yaml b/.github/workflows/run_gitlint.yaml index 7640c0676..9d5d72ab6 100644 --- a/.github/workflows/run_gitlint.yaml +++ b/.github/workflows/run_gitlint.yaml @@ -4,7 +4,7 @@ on: [pull_request] jobs: run_gitlint: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout source code uses: actions/checkout@master diff --git a/.github/workflows/run_test_case.yaml b/.github/workflows/run_test_case.yaml deleted file mode 100644 index ef49d8696..000000000 --- a/.github/workflows/run_test_case.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: Run test case - -on: [push, pull_request] - -jobs: - - run_test_case: - - runs-on: ubuntu-latest - - container: - image: erlang:22.1 - - steps: - - uses: actions/checkout@v1 - - name: Code dialyzer - run: | - make xref - make dialyzer - rm -f rebar.lock - - name: Run tests - run: | - make eunit - rm -f rebar.lock - make ct - rm -f rebar.lock - make cover - - name: Coveralls - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - make coveralls - - uses: actions/upload-artifact@v1 - if: always() - with: - name: logs - path: _build/test/logs - - uses: actions/upload-artifact@v1 - with: - name: cover - path: _build/test/cover diff --git a/.github/workflows/run_test_cases.yaml b/.github/workflows/run_test_cases.yaml new file mode 100644 index 000000000..fd0443d9a --- /dev/null +++ b/.github/workflows/run_test_cases.yaml @@ -0,0 +1,59 @@ +name: Run test case + +on: + push: + tags: + - v* + release: + types: + - published + pull_request: + workflow_dispatch: + repository_dispatch: + types: [run_actions] + +jobs: + run_test_case: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v2 + - name: set up + env: + MYSQL_TAG: 8 + REDIS_TAG: 6 + MONGO_TAG: 4 + PGSQL_TAG: 13 + LDAP_TAG: 2.4.50 + run: | + docker-compose -f .ci/apps_tests/docker-compose.yaml build --no-cache + docker-compose -f .ci/apps_tests/docker-compose.yaml up -d + - name: set config files + run: | + sed -i 's|^[#[:space:]]*auth.ldap.servers[[:space:]]*=.*|auth.ldap.servers = ldap_server|g' apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf + sed -i 's|^[#[:space:]]*auth.mongo.server[[:space:]]*=.*|auth.mongo.server = mongo_server:27017|g' apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf + sed -i 's|^[#[:space:]]*auth.redis.server[[:space:]]*=.*|auth.redis.server = redis_server:6379|g' apps/emqx_auth_redis/etc/emqx_auth_redis.conf + + sed -i 's|^[#[:space:]]*auth.mysql.server[[:space:]]*=.*|auth.mysql.server = mysql_server:3306|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.username[[:space:]]*=.*|auth.mysql.username = root|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.password[[:space:]]*=.*|auth.mysql.password = public|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + sed -i 's|^[#[:space:]]*auth.mysql.database[[:space:]]*=.*|auth.mysql.database = mqtt|g' apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf + + sed -i 's|^[#[:space:]]*auth.pgsql.server[[:space:]]*=.*|auth.pgsql.server = pgsql_server:5432|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.username[[:space:]]*=.*|auth.pgsql.username = root|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.password[[:space:]]*=.*|auth.pgsql.password = public|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + sed -i 's|^[#[:space:]]*auth.pgsql.database[[:space:]]*=.*|auth.pgsql.database = mqtt|g' apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf + - name: run tests + run: | + docker exec -i erlang bash -c "make xref" + docker exec -i erlang bash -c "make ct" + docker exec -i erlang bash -c "make cover" + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs + path: _build/test/logs + - uses: actions/upload-artifact@v1 + with: + name: cover + path: _build/test/cover diff --git a/.gitignore b/.gitignore index 20ae8f090..d320582c5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,34 +12,34 @@ ebin test/ebin/*.beam .exrc plugins/*/ebin -log/ *.swp *.so .erlang.mk/ cover/ -emqx.d eunit.coverdata test/ct.cover.spec -logs ct.coverdata .idea/ -emqx.iml -_rel/ -data/ _build .rebar3 rebar3.crashdump .DS_Store -emqx.iml -bbmustache/ etc/gen.emqx.conf compile_commands.json cuttlefish -rebar.lock xrefr -erlang.mk *.coverdata etc/emqx.conf.rendered Mnesia.*/ -elvis +*.DS_Store +_checkouts +rebar.config.rendered +/rebar3 +rebar.lock .stamp +tmp/ +_packages +elvis +emqx_dialyzer_*_plt +apps/emqx_dashboard/priv/www +dist.zip diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..6ece25a0e --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +erlang 23.2.2 diff --git a/Makefile b/Makefile index d1cd12a60..c0ad74c93 100644 --- a/Makefile +++ b/Makefile @@ -1,139 +1,106 @@ -## shallow clone for speed +REBAR_VERSION = 3.14.3-emqx-4 +DASHBOARD_VERSION = v4.3.0 +REBAR = $(CURDIR)/rebar3 +BUILD = $(CURDIR)/build +export PKG_VSN ?= $(shell $(CURDIR)/pkg-vsn.sh) -REBAR_GIT_CLONE_OPTIONS += --depth 1 -export REBAR_GIT_CLONE_OPTIONS +PROFILE ?= emqx +REL_PROFILES := emqx emqx-edge +PKG_PROFILES := emqx-pkg emqx-edge-pkg +PROFILES := $(REL_PROFILES) $(PKG_PROFILES) -SUITES_FILES := $(shell find test -name '*_SUITE.erl' | sort) +export REBAR_GIT_CLONE_OPTIONS += --depth=1 -CT_SUITES := $(foreach value,$(SUITES_FILES),$(shell val=$$(basename $(value) .erl); echo $${val%_*})) - -CT_NODE_NAME = emqxct@127.0.0.1 - -RUN_NODE_NAME = emqxdebug@127.0.0.1 +.PHONY: default +default: $(REBAR) $(PROFILE) .PHONY: all -all: compile +all: $(REBAR) $(PROFILES) -.PHONY: tests -tests: eunit ct +.PHONY: ensure-rebar3 +ensure-rebar3: + $(CURDIR)/ensure-rebar3.sh $(REBAR_VERSION) -.PHONY: run -run: run_setup unlock - @rebar3 as test get-deps - @rebar3 as test auto --name $(RUN_NODE_NAME) --script scripts/run_emqx.escript +$(REBAR): ensure-rebar3 -.PHONY: run_setup -run_setup: - @erl -noshell -eval \ - "{ok, [[HOME]]} = init:get_argument(home), \ - FilePath = HOME ++ \"/.config/rebar3/rebar.config\", \ - case file:consult(FilePath) of \ - {ok, Term} -> \ - NewTerm = case lists:keyfind(plugins, 1, Term) of \ - false -> [{plugins, [rebar3_auto]} | Term]; \ - {plugins, OldPlugins} -> \ - NewPlugins0 = OldPlugins -- [rebar3_auto], \ - NewPlugins = [rebar3_auto | NewPlugins0], \ - lists:keyreplace(plugins, 1, Term, {plugins, NewPlugins}) \ - end, \ - ok = file:write_file(FilePath, [io_lib:format(\"~p.\n\", [I]) || I <- NewTerm]); \ - _Enoent -> \ - os:cmd(\"mkdir -p ~/.config/rebar3/ \"), \ - NewTerm=[{plugins, [rebar3_auto]}], \ - ok = file:write_file(FilePath, [io_lib:format(\"~p.\n\", [I]) || I <- NewTerm]) \ - end, \ - halt(0)." - -.PHONY: shell -shell: - @rebar3 as test auto - -compile: unlock - @rebar3 compile - -unlock: - @rebar3 unlock - -clean: distclean - -## Cuttlefish escript is built by default when cuttlefish app (as dependency) was built -CUTTLEFISH_SCRIPT := _build/default/lib/cuttlefish/cuttlefish - -.PHONY: cover -cover: - @rebar3 cover - -.PHONY: coveralls -coveralls: - @rebar3 as test coveralls send - -.PHONY: xref -xref: - @rebar3 xref - -.PHONY: dialyzer -dialyzer: - @rebar3 dialyzer - -.PHONY: proper -proper: - @rebar3 proper -d test/props -c - -.PHONY: deps -deps: - @rebar3 get-deps +.PHONY: get-dashboard +get-dashboard: + $(CURDIR)/get-dashboard.sh $(DASHBOARD_VERSION) .PHONY: eunit -eunit: - @rebar3 eunit -v - -.PHONY: ct_setup -ct_setup: - rebar3 as test compile - @mkdir -p data - @if [ ! -f data/loaded_plugins ]; then touch data/loaded_plugins; fi - @ln -s -f '../../../../etc' _build/test/lib/emqx/ - @ln -s -f '../../../../data' _build/test/lib/emqx/ +eunit: $(REBAR) + $(REBAR) eunit .PHONY: ct -ct: ct_setup - @rebar3 ct -v --name $(CT_NODE_NAME) --suite=$(shell echo $(foreach var,$(CT_SUITES),test/$(var)_SUITE) | tr ' ' ',') +ct: $(REBAR) + $(REBAR) ct --name 'test@127.0.0.1' -c -v -## Run one single CT with rebar3 -## e.g. make ct-one-suite suite=emqx_bridge -.PHONY: $(SUITES:%=ct-%) -$(CT_SUITES:%=ct-%): ct_setup - @rebar3 ct -v --readable=false --name $(CT_NODE_NAME) --suite=$(@:ct-%=%)_SUITE --cover +.PHONY: cover +cover: $(REBAR) + $(REBAR) cover -.PHONY: app.config -app.config: $(CUTTLEFISH_SCRIPT) etc/gen.emqx.conf - $(CUTTLEFISH_SCRIPT) -l info -e etc/ -c etc/gen.emqx.conf -i priv/emqx.schema -d data/ +.PHONY: $(REL_PROFILES) +$(REL_PROFILES:%=%): $(REBAR) get-dashboard +ifneq ($(shell echo $(@) |grep edge),) + export EMQX_DESC="EMQ X Edge" +else + export EMQX_DESC="EMQ X Broker" +endif + $(REBAR) as $(@) release -$(CUTTLEFISH_SCRIPT): - @rebar3 get-deps - @if [ ! -f cuttlefish ]; then make -C _build/default/lib/cuttlefish; fi +# rebar clean +.PHONY: clean $(PROFILES:%=clean-%) +clean: $(PROFILES:%=clean-%) +$(PROFILES:%=clean-%): $(REBAR) + $(REBAR) as $(@:clean-%=%) clean + rm -rf apps/emqx_dashboard/priv/www -bbmustache: - @git clone https://github.com/soranoba/bbmustache.git && cd bbmustache && ./rebar3 compile && cd .. +.PHONY: deps-all +deps-all: $(REBAR) $(PROFILES:%=deps-%) -# This hack is to generate a conf file for testing -# relx overlay is used for release -etc/gen.emqx.conf: bbmustache etc/emqx.conf - @erl -noshell -pa bbmustache/_build/default/lib/bbmustache/ebin -eval \ - "{ok, Temp} = file:read_file('etc/emqx.conf'), \ - {ok, Vars0} = file:consult('vars'), \ - Vars = [{atom_to_list(N), list_to_binary(V)} || {N, V} <- Vars0], \ - Targ = bbmustache:render(Temp, Vars), \ - ok = file:write_file('etc/gen.emqx.conf', Targ), \ - halt(0)." +.PHONY: $(PROFILES:%=deps-%) +$(PROFILES:%=deps-%): $(REBAR) get-dashboard +ifneq ($(shell echo $(@) |grep edge),) + export EMQX_DESC="EMQ X Edge" +else + export EMQX_DESC="EMQ X Broker" +endif + $(REBAR) as $(@:deps-%=%) get-deps -.PHONY: gen-clean -gen-clean: - @rm -rf bbmustache - @rm -f etc/gen.emqx.conf etc/emqx.conf.rendered +.PHONY: xref +xref: $(REBAR) + $(REBAR) as check xref -.PHONY: distclean -distclean: gen-clean - @rm -rf Mnesia.* - @rm -rf _build cover deps logs log data - @rm -f rebar.lock compile_commands.json cuttlefish erl_crash.dump +.PHONY: dialyzer +dialyzer: $(REBAR) + $(REBAR) as check dialyzer + +.PHONY: $(REL_PROFILES:%=relup-%) +$(REL_PROFILES:%=relup-%): $(REBAR) +ifneq ($(OS),Windows_NT) + $(BUILD) $(@:relup-%=%) relup +endif + +.PHONY: $(REL_PROFILES:%=%-tar) $(PKG_PROFILES:%=%-tar) +$(REL_PROFILES:%=%-tar) $(PKG_PROFILES:%=%-tar): $(REBAR) get-dashboard + $(BUILD) $(subst -tar,,$(@)) tar + +## zip targets depend on the corresponding relup and tar artifacts +.PHONY: $(REL_PROFILES:%=%-zip) +define gen-zip-target +$1-zip: relup-$1 $1-tar + $(BUILD) $1 zip +endef +ALL_ZIPS = $(REL_PROFILES) $(PKG_PROFILES) +$(foreach zt,$(ALL_ZIPS),$(eval $(call gen-zip-target,$(zt)))) + +## A pkg target depend on a regular release profile zip to include relup, +## and also a -pkg suffixed profile tar (without relup) for making deb/rpm package +.PHONY: $(PKG_PROFILES) +define gen-pkg-target +$1: $(subst -pkg,,$1)-zip $1-tar + $(BUILD) $1 pkg +endef +$(foreach pt,$(PKG_PROFILES),$(eval $(call gen-pkg-target,$(pt)))) + +include docker.mk diff --git a/README-CN.md b/README-CN.md index 3d7843ed7..045a089ee 100644 --- a/README-CN.md +++ b/README-CN.md @@ -62,6 +62,17 @@ cd _rel/emqx && ./bin/emqx console *EMQ X* 启动,可以使用浏览器访问 http://localhost:18083 来查看 Dashboard。 +### 静态分析(Dialyzer) +##### 分析所有应用程序 +``` +make dialyzer +``` + +##### 要分析特定的应用程序,(用逗号分隔的应用程序列表) +``` +DIALYZER_ANALYSE_APP=emqx_lwm2m,emqx_auth_jwt,emqx_auth_ldap make dialyzer +``` + ## FAQ 访问 [EMQ X FAQ](https://docs.emqx.io/broker/latest/cn/faq/faq.html) 以获取常见问题的帮助。 diff --git a/README-JP.md b/README-JP.md index 665f18b1b..f4d6390a1 100644 --- a/README-JP.md +++ b/README-JP.md @@ -62,6 +62,17 @@ cd _rel/emqx && ./bin/emqx console *EMQ X* ブローカーを起動したら、ブラウザで http://localhost:18083 にアクセスしてダッシュボードを表示できます。 +### Dialyzer +##### アプリケーションの型情報を解析する +``` +make dialyzer +``` + +##### 特定のアプリケーションのみ解析する(アプリケーション名をコンマ区切りで入力) +``` +DIALYZER_ANALYSE_APP=emqx_lwm2m,emqx_auth_jwt,emqx_auth_ldap make dialyzer +``` + ## FAQ よくある質問については、[EMQ X FAQ](https://docs.emqx.io/broker/latest/en/faq/faq.html)にアクセスしてください。 diff --git a/README.md b/README.md index e94243a41..14e0abde4 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,30 @@ Get the binary package of the corresponding OS from [EMQ X Download](https://www The *EMQ X* broker requires Erlang/OTP R21+ to build since 3.0 release. +For 4.3 and later versions. + +```bash +git clone https://github.com/emqx/emqx.git +cd emqx +make +_build/emqx/rel/emqx/bin console ``` -git clone -b v4.0.0 https://github.com/emqx/emqx-rel.git -cd emqx-rel && make - -cd _build/emqx/rel/emqx && ./bin/emqx console +For earlier versions, release has to be built from another repo. +```bash +git clone https://github.com/emqx/emqx-rel.git +cd emqx-rel +make +_build/emqx/rel/emqx/bin/emqx console ``` ## Quick Start -``` +If emqx is built from source, `cd _buid/emqx/rel/emqx`. +Or change to the installation root directory if emqx is installed from a release package. + +```bash # Start emqx ./bin/emqx start @@ -65,6 +77,38 @@ cd _build/emqx/rel/emqx && ./bin/emqx console To view the dashboard after running, use your browser to open: http://localhost:18083 +## Test + +### To test everything in one go + +``` +make eunit ct +``` + +### To run subset of the common tests + +examples + +```bash +./rebar3 ct --name 'test@127.0.0.1' -c -v --dir test,apps/emqx_sn,apps/emqx_coap +./rebar3 ct --name 'test@127.0.0.1' -c -v --dir apps/emqx_auth_mnesi --suite emqx_acl_mnesia_SUITE +./rebar3 ct --name 'test@127.0.0.1' -c -v --dir apps/emqx_auth_mnesi --suite emqx_acl_mnesia_SUITE --case t_rest_api +``` + +NOTE: Do *NOT* use full (relative) path to SUITE files like this `--suite apps/emqx_auth_mnesia/test/emqx_acl_mnesia_SUITE.erl`, +because it will lead to a full copy of `apps` dir into `_buid/test/lib/emqx`. + +### Dialyzer +##### To Analyze all the apps +``` +make dialyzer +``` + +##### To Analyse specific apps, (list of comma separated apps) +``` +DIALYZER_ANALYSE_APP=emqx_lwm2m,emqx_auth_jwt,emqx_auth_ldap make dialyzer +``` + ## FAQ Visiting [EMQ X FAQ](https://docs.emqx.io/broker/latest/en/faq/faq.html) to get help of common problems. diff --git a/apps/.gitkeep b/apps/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/emqx_auth_http/.gitignore b/apps/emqx_auth_http/.gitignore new file mode 100644 index 000000000..557a3a337 --- /dev/null +++ b/apps/emqx_auth_http/.gitignore @@ -0,0 +1,25 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +emqx_auth_http.d +data +ct.cover.spec +cover/ +ct.coverdata +eunit.coverdata +logs/ +erlang.mk +_build/ +rebar.lock +rebar3.crashdump +etc/emqx_auth_http.conf.rendered +.rebar3/ +*.swp diff --git a/apps/emqx_auth_http/README.md b/apps/emqx_auth_http/README.md new file mode 100644 index 000000000..ed743334a --- /dev/null +++ b/apps/emqx_auth_http/README.md @@ -0,0 +1,100 @@ +emqx_auth_http +============== + +EMQ X HTTP Auth/ACL Plugin + +Build +----- + +``` +make && make tests +``` + +Configure the Plugin +-------------------- + +File: etc/emqx_auth_http.conf + +``` +##-------------------------------------------------------------------- +## Authentication request. +## +## Variables: +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %P: password +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Value: URL +auth.http.auth_req = http://127.0.0.1:8080/mqtt/auth +## Value: post | get | put +auth.http.auth_req.method = post +## Value: Params +auth.http.auth_req.params = clientid=%c,username=%u,password=%P + +##-------------------------------------------------------------------- +## Superuser request. +## +## Variables: +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %P: password +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Value: URL +auth.http.super_req = http://127.0.0.1:8080/mqtt/superuser +## Value: post | get | put +auth.http.super_req.method = post +## Value: Params +auth.http.super_req.params = clientid=%c,username=%u + +##-------------------------------------------------------------------- +## ACL request. +## +## Variables: +## - %A: 1 | 2, 1 = sub, 2 = pub +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %m: mountpoint +## - %t: topic +## +## Value: URL +auth.http.acl_req = http://127.0.0.1:8080/mqtt/acl +## Value: post | get | put +auth.http.acl_req.method = get +## Value: Params +auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t +``` + +Load the Plugin +--------------- + +``` +./bin/emqx_ctl plugins load emqx_auth_http +``` + +HTTP API +-------- + +200 if ok + +4xx if unauthorized + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_auth_http/etc/emqx_auth_http.conf b/apps/emqx_auth_http/etc/emqx_auth_http.conf new file mode 100644 index 000000000..3d5c45ea7 --- /dev/null +++ b/apps/emqx_auth_http/etc/emqx_auth_http.conf @@ -0,0 +1,153 @@ +##-------------------------------------------------------------------- +## HTTP Auth/ACL Plugin +##-------------------------------------------------------------------- + +## HTTP URL API path for Auth Request +## +## Value: URL +## +## Examples: http://127.0.0.1:80/mqtt/auth, https://[::1]:80/mqtt/auth +auth.http.auth_req.url = http://127.0.0.1:80/mqtt/auth + +## HTTP Request Method for Auth Request +## +## Value: post | get +auth.http.auth_req.method = post + +## HTTP Request Headers for Auth Request, Content-Type header is configured by default. +## The possible values of the Content-Type header: application/x-www-form-urlencoded, application/json +## +## Examples: auth.http.auth_req.headers.accept = */* +auth.http.auth_req.headers.content-type = application/x-www-form-urlencoded + +## Parameters used to construct the request body or query string parameters +## When the request method is GET, these parameters will be converted into query string parameters +## When the request method is POST, the final format is determined by content-type +## +## Available Variables: +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %P: password +## - %p: sockport of server accepted +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Value: =,=,... +auth.http.auth_req.params = clientid=%c,username=%u,password=%P + +## HTTP URL API path for SuperUser Request +## +## Value: URL +## +## Examples: http://127.0.0.1:80/mqtt/superuser, https://[::1]:80/mqtt/superuser +auth.http.super_req.url = http://127.0.0.1:80/mqtt/superuser + +## HTTP Request Method for SuperUser Request +## +## Value: post | get +auth.http.super_req.method = post + +## HTTP Request Headers for SuperUser Request, Content-Type header is configured by default. +## The possible values of the Content-Type header: application/x-www-form-urlencoded, application/json +## +## Examples: auth.http.super_req.headers.accept = */* +auth.http.super_req.headers.content-type = application/x-www-form-urlencoded + +## Parameters used to construct the request body or query string parameters +## When the request method is GET, these parameters will be converted into query string parameters +## When the request method is POST, the final format is determined by content-type +## +## Available Variables: +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %P: password +## - %p: sockport of server accepted +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Value: =,=,... +auth.http.super_req.params = clientid=%c,username=%u + +## HTTP URL API path for ACL Request +## +## Value: URL +## +## Examples: http://127.0.0.1:80/mqtt/acl, https://[::1]:80/mqtt/acl +auth.http.acl_req.url = http://127.0.0.1:80/mqtt/acl + +## HTTP Request Method for ACL Request +## +## Value: post | get +auth.http.acl_req.method = post + +## HTTP Request Headers for ACL Request, Content-Type header is configured by default. +## The possible values of the Content-Type header: application/x-www-form-urlencoded, application/json +## +## Examples: auth.http.acl_req.headers.accept = */* +auth.http.acl_req.headers.content-type = application/x-www-form-urlencoded + +## Parameters used to construct the request body or query string parameters +## When the request method is GET, these parameters will be converted into query string parameters +## When the request method is POST, the final format is determined by content-type +## +## Available Variables: +## - %u: username +## - %c: clientid +## - %a: ipaddress +## - %r: protocol +## - %P: password +## - %p: sockport of server accepted +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Value: =,=,... +auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t,mountpoint=%m + +## Time-out time for the request. +## +## Value: Duration +## -h: hour, e.g. '2h' for 2 hours +## -m: minute, e.g. '5m' for 5 minutes +## -s: second, e.g. '30s' for 30 seconds +## +## Default: 5s +auth.http.timeout = 5s + +## Connection time-out time, used during the initial request, +## when the client is connecting to the server. +## +## Value: Duration +## -h: hour, e.g. '2h' for 2 hours +## -m: minute, e.g. '5m' for 5 minutes +## -s: second, e.g. '30s' for 30 seconds +## +## Default: 5s +auth.http.connect_timeout = 5s + +## Connection process pool size +## +## Value: Number +auth.http.pool_size = 32 + +##------------------------------------------------------------------------------ +## SSL options + +## Path to the file containing PEM-encoded CA certificates. The CA certificates +## are used during server authentication and when building the client certificate chain. +## +## Value: File +## auth.http.ssl.cacertfile = {{ platform_etc_dir }}/certs/ca.pem + +## The path to a file containing the client's certificate. +## +## Value: File +## auth.http.ssl.certfile = {{ platform_etc_dir }}/certs/client-cert.pem + +## Path to a file containing the client's private PEM-encoded key. +## +## Value: File +## auth.http.ssl.keyfile = {{ platform_etc_dir }}/certs/client-key.pem diff --git a/apps/emqx_auth_http/include/emqx_auth_http.hrl b/apps/emqx_auth_http/include/emqx_auth_http.hrl new file mode 100644 index 000000000..9c1216357 --- /dev/null +++ b/apps/emqx_auth_http/include/emqx_auth_http.hrl @@ -0,0 +1,23 @@ + +-define(APP, emqx_auth_http). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_http/priv/emqx_auth_http.schema b/apps/emqx_auth_http/priv/emqx_auth_http.schema new file mode 100644 index 000000000..afd71cfd9 --- /dev/null +++ b/apps/emqx_auth_http/priv/emqx_auth_http.schema @@ -0,0 +1,118 @@ +%%-*- mode: erlang -*- +%% emqx_auth_http config mapping +{mapping, "auth.http.auth_req.url", "emqx_auth_http.auth_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.auth_req.method", "emqx_auth_http.auth_req", [ + {default, post}, + {datatype, {enum, [post, get]}} +]}. + +{mapping, "auth.http.auth_req.headers.$field", "emqx_auth_http.auth_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.auth_req.params", "emqx_auth_http.auth_req", [ + {datatype, string} +]}. + +{translation, "emqx_auth_http.auth_req", fun(Conf) -> + case cuttlefish:conf_get("auth.http.auth_req.url", Conf, undefined) of + undefined -> cuttlefish:unset(); + Url -> + Headers = cuttlefish_variable:filter_by_prefix("auth.http.auth_req.headers", Conf), + Params = cuttlefish:conf_get("auth.http.auth_req.params", Conf), + [{url, Url}, + {method, cuttlefish:conf_get("auth.http.auth_req.method", Conf)}, + {headers, [{K, V} || {[_, _, _, _, K], V} <- Headers]}, + {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] + end +end}. + +{mapping, "auth.http.super_req.url", "emqx_auth_http.super_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.super_req.method", "emqx_auth_http.super_req", [ + {default, post}, + {datatype, {enum, [post, get]}} +]}. + +{mapping, "auth.http.super_req.headers.$field", "emqx_auth_http.super_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.super_req.params", "emqx_auth_http.super_req", [ + {datatype, string} +]}. + +{translation, "emqx_auth_http.super_req", fun(Conf) -> + case cuttlefish:conf_get("auth.http.super_req.url", Conf, undefined) of + undefined -> cuttlefish:unset(); + Url -> + Headers = cuttlefish_variable:filter_by_prefix("auth.http.super_req.headers", Conf), + Params = cuttlefish:conf_get("auth.http.super_req.params", Conf), + [{url, Url}, + {method, cuttlefish:conf_get("auth.http.super_req.method", Conf)}, + {headers, [{K, V} || {[_, _, _, _, K], V} <- Headers]}, + {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] + end +end}. + +{mapping, "auth.http.acl_req.url", "emqx_auth_http.acl_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.acl_req.method", "emqx_auth_http.acl_req", [ + {default, post}, + {datatype, {enum, [post, get]}} +]}. + +{mapping, "auth.http.acl_req.headers.$field", "emqx_auth_http.acl_req", [ + {datatype, string} +]}. + +{mapping, "auth.http.acl_req.params", "emqx_auth_http.acl_req", [ + {datatype, string} +]}. + +{translation, "emqx_auth_http.acl_req", fun(Conf) -> + case cuttlefish:conf_get("auth.http.acl_req.url", Conf, undefined) of + undefined -> cuttlefish:unset(); + Url -> + Headers = cuttlefish_variable:filter_by_prefix("auth.http.acl_req.headers", Conf), + Params = cuttlefish:conf_get("auth.http.acl_req.params", Conf), + [{url, Url}, + {method, cuttlefish:conf_get("auth.http.acl_req.method", Conf)}, + {headers, [{K, V} || {[_, _, _, _, K], V} <- Headers]}, + {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] + end +end}. + +{mapping, "auth.http.timeout", "emqx_auth_http.timeout", [ + {default, "5s"}, + {datatype, [integer, {duration, ms}]} +]}. + +{mapping, "auth.http.connect_timeout", "emqx_auth_http.connect_timeout", [ + {default, "5s"}, + {datatype, [integer, {duration, ms}]} +]}. + +{mapping, "auth.http.pool_size", "emqx_auth_http.pool_size", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.http.ssl.cacertfile", "emqx_auth_http.cacertfile", [ + {datatype, string} +]}. + +{mapping, "auth.http.ssl.certfile", "emqx_auth_http.certfile", [ + {datatype, string} +]}. + +{mapping, "auth.http.ssl.keyfile", "emqx_auth_http.keyfile", [ + {datatype, string} +]}. diff --git a/apps/emqx_auth_http/rebar.config b/apps/emqx_auth_http/rebar.config new file mode 100644 index 000000000..d159825ee --- /dev/null +++ b/apps/emqx_auth_http/rebar.config @@ -0,0 +1,28 @@ +{deps, + [{ehttpc, {git, "https://github.com/emqx/ehttpc", {tag, "0.1.2"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "v1.2.2"}}} + ]} + ]} + ]}. diff --git a/apps/emqx_auth_http/src/emqx_acl_http.erl b/apps/emqx_auth_http/src/emqx_acl_http.erl new file mode 100644 index 000000000..23f6ff62b --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_acl_http.erl @@ -0,0 +1,88 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_http). + +-include("emqx_auth_http.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ACL http]"). + +-import(emqx_auth_http_cli, + [ request/6 + , feedvar/2 + ]). + +%% ACL callbacks +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +%%-------------------------------------------------------------------- +%% ACL callbacks +%%-------------------------------------------------------------------- + +check_acl(ClientInfo, PubSub, Topic, AclResult, Params) -> + return_with(fun inc_metrics/1, + do_check_acl(ClientInfo, PubSub, Topic, AclResult, Params)). + +do_check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _AclResult, _Params) -> + ok; +do_check_acl(ClientInfo, PubSub, Topic, _AclResult, #{acl := ACLParams = #{path := Path}}) -> + ClientInfo1 = ClientInfo#{access => access(PubSub), topic => Topic}, + case check_acl_request(ACLParams, ClientInfo1) of + {ok, 200, <<"ignore">>} -> ok; + {ok, 200, _Body} -> {stop, allow}; + {ok, _Code, _Body} -> {stop, deny}; + {error, Error} -> + ?LOG(error, "Request ACL path ~s, error: ~p", [Path, Error]), + ok + end. + +description() -> "ACL with HTTP API". + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +inc_metrics(ok) -> + emqx_metrics:inc(?ACL_METRICS(ignore)); +inc_metrics({stop, allow}) -> + emqx_metrics:inc(?ACL_METRICS(allow)); +inc_metrics({stop, deny}) -> + emqx_metrics:inc(?ACL_METRICS(deny)). + +return_with(Fun, Result) -> + Fun(Result), Result. + +check_acl_request(#{pool_name := PoolName, + path := Path, + method := Method, + headers := Headers, + params := Params, + timeout := Timeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), Timeout). + +access(subscribe) -> 1; +access(publish) -> 2. + diff --git a/apps/emqx_auth_http/src/emqx_auth_http.app.src b/apps/emqx_auth_http/src/emqx_auth_http.app.src new file mode 100644 index 000000000..b2c3221e6 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_auth_http.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_http, + [{description, "EMQ X Authentication/ACL with HTTP API"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_http_sup]}, + {applications, [kernel,stdlib,ehttpc]}, + {mod, {emqx_auth_http_app, []}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-http"} + ]} + ]}. diff --git a/apps/emqx_auth_http/src/emqx_auth_http.erl b/apps/emqx_auth_http/src/emqx_auth_http.erl new file mode 100644 index 000000000..e82a37625 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_auth_http.erl @@ -0,0 +1,112 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_http). + +-include("emqx_auth_http.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/types.hrl"). + +-logger_header("[Auth http]"). + +-import(emqx_auth_http_cli, + [ request/6 + , feedvar/2 + ]). + +%% Callbacks +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo, AuthResult, #{auth := AuthParms = #{path := Path}, + super := SuperParams}) -> + case authenticate(AuthParms, ClientInfo) of + {ok, 200, <<"ignore">>} -> + emqx_metrics:inc(?AUTH_METRICS(ignore)), ok; + {ok, 200, Body} -> + emqx_metrics:inc(?AUTH_METRICS(success)), + IsSuperuser = is_superuser(SuperParams, ClientInfo), + {stop, AuthResult#{is_superuser => IsSuperuser, + auth_result => success, + anonymous => false, + mountpoint => mountpoint(Body, ClientInfo)}}; + {ok, Code, _Body} -> + ?LOG(error, "Deny connection from path: ~s, response http code: ~p", + [Path, Code]), + emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => http_to_connack_error(Code), + anonymous => false}}; + {error, Error} -> + ?LOG(error, "Request auth path: ~s, error: ~p", [Path, Error]), + emqx_metrics:inc(?AUTH_METRICS(failure)), + %%FIXME later: server_unavailable is not right. + {stop, AuthResult#{auth_result => server_unavailable, + anonymous => false}} + end. + +description() -> "Authentication by HTTP API". + +%%-------------------------------------------------------------------- +%% Requests +%%-------------------------------------------------------------------- + +authenticate(#{pool_name := PoolName, + path := Path, + method := Method, + headers := Headers, + params := Params, + timeout := Timeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), Timeout). + +-spec(is_superuser(maybe(map()), emqx_types:client()) -> boolean()). +is_superuser(undefined, _ClientInfo) -> + false; +is_superuser(#{pool_name := PoolName, + path := Path, + method := Method, + headers := Headers, + params := Params, + timeout := Timeout}, ClientInfo) -> + case request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), Timeout) of + {ok, 200, _Body} -> true; + {ok, _Code, _Body} -> false; + {error, Error} -> ?LOG(error, "Request superuser path ~s, error: ~p", [Path, Error]), + false + end. + +mountpoint(Body, #{mountpoint := Mountpoint}) -> + case emqx_json:safe_decode(Body, [return_maps]) of + {error, _} -> Mountpoint; + {ok, Json} when is_map(Json) -> + maps:get(<<"mountpoint">>, Json, Mountpoint); + {ok, _NotMap} -> Mountpoint + end. + +http_to_connack_error(400) -> bad_username_or_password; +http_to_connack_error(401) -> bad_username_or_password; +http_to_connack_error(403) -> not_authorized; +http_to_connack_error(429) -> banned; +http_to_connack_error(503) -> server_unavailable; +http_to_connack_error(504) -> server_busy; +http_to_connack_error(_) -> server_unavailable. diff --git a/apps/emqx_auth_http/src/emqx_auth_http_app.erl b/apps/emqx_auth_http/src/emqx_auth_http_app.erl new file mode 100644 index 000000000..ba88ca3be --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_auth_http_app.erl @@ -0,0 +1,175 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_http_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_http.hrl"). + +-export([ start/2 + , stop/1 + ]). + +%%-------------------------------------------------------------------- +%% Application Callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_http_sup:start_link(), + translate_env(), + load_hooks(), + {ok, Sup}. + +stop(_State) -> + unload_hooks(). + +%%-------------------------------------------------------------------- +%% Internel functions +%%-------------------------------------------------------------------- + +translate_env() -> + lists:foreach(fun translate_env/1, [auth_req, super_req, acl_req]). + +translate_env(EnvName) -> + case application:get_env(?APP, EnvName) of + undefined -> ok; + {ok, Req} -> + {ok, PoolSize} = application:get_env(?APP, pool_size), + {ok, ConnectTimeout} = application:get_env(?APP, connect_timeout), + URL = proplists:get_value(url, Req), + #{host := Host0, + path := Path0, + scheme := Scheme} = URIMap = uri_string:parse(add_default_scheme(uri_string:normalize(URL))), + Port = maps:get(port, URIMap, case Scheme of + "https" -> 443; + "http" -> 80 + end), + Path = path(Path0), + {Inet, Host} = parse_host(Host0), + MoreOpts = case Scheme of + "http" -> + [{transport_opts, [Inet]}]; + "https" -> + CACertFile = application:get_env(?APP, cacertfile, undefined), + CertFile = application:get_env(?APP, certfile, undefined), + KeyFile = application:get_env(?APP, keyfile, undefined), + TLSOpts = lists:filter(fun({_K, V}) when V =:= <<>> -> + false; + (_) -> + true + end, [{keyfile, KeyFile}, {certfile, CertFile}, {cacertfile, CACertFile}]), + TlsVers = ['tlsv1.2','tlsv1.1',tlsv1], + NTLSOpts = [{versions, TlsVers}, + {ciphers, lists:foldl(fun(TlsVer, Ciphers) -> + Ciphers ++ ssl:cipher_suites(all, TlsVer) + end, [], TlsVers)} | TLSOpts], + [{transport, ssl}, {transport_opts, [Inet | NTLSOpts]}] + end, + PoolOpts = [{host, Host}, + {port, Port}, + {pool_size, PoolSize}, + {pool_type, random}, + {connect_timeout, ConnectTimeout}, + {retry, 5}, + {retry_timeout, 1000}] ++ MoreOpts, + Method = proplists:get_value(method, Req), + Headers = proplists:get_value(headers, Req), + NHeaders = ensure_content_type_header(Method, to_lower(Headers)), + NReq = lists:keydelete(headers, 1, Req), + {ok, Timeout} = application:get_env(?APP, timeout), + application:set_env(?APP, EnvName, [{path, Path}, + {headers, NHeaders}, + {timeout, Timeout}, + {pool_name, list_to_atom("emqx_auth_http/" ++ atom_to_list(EnvName))}, + {pool_opts, PoolOpts} | NReq]) + end. + +load_hooks() -> + case application:get_env(?APP, auth_req) of + undefined -> ok; + {ok, AuthReq} -> + ok = emqx_auth_http:register_metrics(), + PoolOpts = proplists:get_value(pool_opts, AuthReq), + PoolName = proplists:get_value(pool_name, AuthReq), + ehttpc_sup:start_pool(PoolName, PoolOpts), + case application:get_env(?APP, super_req) of + undefined -> + emqx:hook('client.authenticate', {emqx_auth_http, check, [#{auth => maps:from_list(AuthReq), + super => undefined}]}); + {ok, SuperReq} -> + PoolOpts1 = proplists:get_value(pool_opts, SuperReq), + PoolName1 = proplists:get_value(pool_name, SuperReq), + ehttpc_sup:start_pool(PoolName1, PoolOpts1), + emqx:hook('client.authenticate', {emqx_auth_http, check, [#{auth => maps:from_list(AuthReq), + super => maps:from_list(SuperReq)}]}) + end + end, + case application:get_env(?APP, acl_req) of + undefined -> ok; + {ok, ACLReq} -> + ok = emqx_acl_http:register_metrics(), + PoolOpts2 = proplists:get_value(pool_opts, ACLReq), + PoolName2 = proplists:get_value(pool_name, ACLReq), + ehttpc_sup:start_pool(PoolName2, PoolOpts2), + emqx:hook('client.check_acl', {emqx_acl_http, check_acl, [#{acl => maps:from_list(ACLReq)}]}) + end, + ok. + +unload_hooks() -> + emqx:unhook('client.authenticate', {emqx_auth_http, check}), + emqx:unhook('client.check_acl', {emqx_acl_http, check_acl}), + ehttpc_sup:stop_pool('emqx_auth_http/auth_req'), + ehttpc_sup:stop_pool('emqx_auth_http/super_req'), + ehttpc_sup:stop_pool('emqx_auth_http/acl_req'), + ok. + +parse_host(Host) -> + case inet:parse_address(Host) of + {ok, Addr} when size(Addr) =:= 4 -> {inet, Addr}; + {ok, Addr} when size(Addr) =:= 8 -> {inet6, Addr}; + {error, einval} -> + case inet:getaddr(Host, inet6) of + {ok, _} -> {inet6, Host}; + {error, _} -> {inet, Host} + end + end. + +to_lower(Headers) -> + [{string:to_lower(K), V} || {K, V} <- Headers]. + +ensure_content_type_header(Method, Headers) + when Method =:= post orelse Method =:= put -> + Headers; +ensure_content_type_header(_Method, Headers) -> + lists:keydelete("content-type", 1, Headers). + +add_default_scheme(URL) when is_list(URL) -> + binary_to_list(add_default_scheme(list_to_binary(URL))); +add_default_scheme(<<"http://", _/binary>> = URL) -> + URL; +add_default_scheme(<<"https://", _/binary>> = URL) -> + URL; +add_default_scheme(URL) -> + <<"http://", URL/binary>>. + +path("") -> + "/"; +path(Path) -> + Path. + diff --git a/apps/emqx_auth_http/src/emqx_auth_http_cli.erl b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl new file mode 100644 index 000000000..02fdd9862 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl @@ -0,0 +1,92 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_http_cli). + +-include("emqx_auth_http.hrl"). + +-export([ request/6 + , feedvar/2 + , feedvar/3 + ]). + +%%-------------------------------------------------------------------- +%% HTTP Request +%%-------------------------------------------------------------------- + +request(PoolName, get, Path, Headers, Params, Timeout) -> + NewPath = Path ++ "?" ++ binary_to_list(cow_qs:qs(bin_kw(Params))), + reply(ehttpc:request(ehttpc_pool:pick_worker(PoolName), get, {NewPath, Headers}, Timeout)); + +request(PoolName, post, Path, Headers, Params, Timeout) -> + Body = case proplists:get_value("content-type", Headers) of + "application/x-www-form-urlencoded" -> + cow_qs:qs(bin_kw(Params)); + "application/json" -> + emqx_json:encode(bin_kw(Params)) + end, + reply(ehttpc:request(ehttpc_pool:pick_worker(PoolName), post, {Path, Headers, Body}, Timeout)). + +reply({ok, StatusCode, _Headers}) -> + {ok, StatusCode, <<>>}; +reply({ok, StatusCode, _Headers, Body}) -> + {ok, StatusCode, Body}; +reply({error, Reason}) -> + {error, Reason}. + +%% TODO: move this conversion to cuttlefish config and schema +bin_kw(KeywordList) when is_list(KeywordList) -> + [{bin(K), bin(V)} || {K, V} <- KeywordList]. + +bin(Atom) when is_atom(Atom) -> + list_to_binary(atom_to_list(Atom)); +bin(Int) when is_integer(Int) -> + integer_to_binary(Int); +bin(Float) when is_float(Float) -> + float_to_binary(Float, [{decimals, 12}, compact]); +bin(List) when is_list(List)-> + list_to_binary(List); +bin(Binary) when is_binary(Binary) -> + Binary. + +%%-------------------------------------------------------------------- +%% Feed Variables +%%-------------------------------------------------------------------- + +feedvar(Params, ClientInfo = #{clientid := ClientId, + protocol := Protocol, + peerhost := Peerhost}) -> + lists:map(fun({Param, "%u"}) -> {Param, maps:get(username, ClientInfo, null)}; + ({Param, "%c"}) -> {Param, ClientId}; + ({Param, "%r"}) -> {Param, Protocol}; + ({Param, "%a"}) -> {Param, inet:ntoa(Peerhost)}; + ({Param, "%P"}) -> {Param, maps:get(password, ClientInfo, null)}; + ({Param, "%p"}) -> {Param, maps:get(sockport, ClientInfo, null)}; + ({Param, "%C"}) -> {Param, maps:get(cn, ClientInfo, null)}; + ({Param, "%d"}) -> {Param, maps:get(dn, ClientInfo, null)}; + ({Param, "%A"}) -> {Param, maps:get(access, ClientInfo, null)}; + ({Param, "%t"}) -> {Param, maps:get(topic, ClientInfo, null)}; + ({Param, "%m"}) -> {Param, maps:get(mountpoint, ClientInfo, null)}; + ({Param, Var}) -> {Param, Var} + end, Params). + +feedvar(Params, Var, Val) -> + lists:map(fun({Param, Var0}) when Var0 == Var -> + {Param, Val}; + ({Param, Var0}) -> + {Param, Var0} + end, Params). + diff --git a/apps/emqx_auth_http/src/emqx_auth_http_sup.erl b/apps/emqx_auth_http/src/emqx_auth_http_sup.erl new file mode 100644 index 000000000..36b61a224 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_auth_http_sup.erl @@ -0,0 +1,29 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_http_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, {{one_for_all, 0, 1}, []}}. diff --git a/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl new file mode 100644 index 000000000..c2ad0ac43 --- /dev/null +++ b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl @@ -0,0 +1,173 @@ +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +-module(emqx_auth_http_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-define(APP, emqx_auth_http). + +-define(USER(ClientId, Username, Protocol, Peerhost, Zone), + #{clientid => ClientId, username => Username, protocol => Protocol, + peerhost => Peerhost, zone => Zone}). + +-define(USER(ClientId, Username, Protocol, Peerhost, Zone, Mountpoint), + #{clientid => ClientId, username => Username, protocol => Protocol, + peerhost => Peerhost, zone => Zone, mountpoint => Mountpoint}). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + [{group, http_inet}, + {group, http_inet6}, + {group, https_inet}, + {group, https_inet6}]. + +groups() -> + Cases = emqx_ct:all(?MODULE), + [{Name, Cases} || Name <- [http_inet, http_inet6, https_inet, https_inet6]]. + +init_per_group(GrpName, Cfg) -> + [Schema, Inet] = [list_to_atom(X) || X <- string:tokens(atom_to_list(GrpName), "_")], + http_auth_server:start(Schema, Inet), + Fun = fun(App) -> set_special_configs(App, Schema, Inet) end, + emqx_ct_helpers:start_apps([emqx_auth_http], Fun), + Cfg. + +end_per_group(_GrpName, _Cfg) -> + http_auth_server:stop(), + emqx_ct_helpers:stop_apps([emqx_auth_http, emqx]). + +set_special_configs(emqx, _Schmea, _Inet) -> + application:set_env(emqx, allow_anonymous, true), + application:set_env(emqx, enable_acl_cache, false), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); + +set_special_configs(emqx_auth_http, Schema, Inet) -> + ServerAddr = http_server(Schema, Inet), + + AuthReq = #{method => get, + url => ServerAddr ++ "/mqtt/auth", + headers => [{"content-type", "application/json"}], + params => [{"clientid", "%c"}, {"username", "%u"}, {"password", "%P"}]}, + SuperReq = #{method => post, + url => ServerAddr ++ "/mqtt/superuser", + headers => [{"content-type", "application/json"}], + params => [{"clientid", "%c"}, {"username", "%u"}]}, + AclReq = #{method => post, + url => ServerAddr ++ "/mqtt/acl", + headers => [{"content-type", "application/json"}], + params => [{"access", "%A"}, {"username", "%u"}, {"clientid", "%c"}, {"ipaddr", "%a"}, {"topic", "%t"}, {"mountpoint", "%m"}]}, + + Schema =:= https andalso set_https_client_opts(), + + application:set_env(emqx_auth_http, auth_req, maps:to_list(AuthReq)), + application:set_env(emqx_auth_http, super_req, maps:to_list(SuperReq)), + application:set_env(emqx_auth_http, acl_req, maps:to_list(AclReq)). + +%% @private +set_https_client_opts() -> + SSLOpt = emqx_ct_helpers:client_ssl_twoway(), + application:set_env(emqx_auth_http, cacertfile, proplists:get_value(cacertfile, SSLOpt, undefined)), + application:set_env(emqx_auth_http, certfile, proplists:get_value(certfile, SSLOpt, undefined)), + application:set_env(emqx_auth_http, keyfile, proplists:get_value(keyfile, SSLOpt, undefined)). + +%% @private +http_server(http, inet) -> "http://127.0.0.1:8991"; +http_server(http, inet6) -> "http://[::1]:8991"; +http_server(https, inet) -> "https://127.0.0.1:8991"; +http_server(https, inet6) -> "https://[::1]:8991". + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_check_acl(_) -> + SuperUser = ?USER(<<"superclient">>, <<"superuser">>, mqtt, {127,0,0,1}, external), + deny = emqx_access_control:check_acl(SuperUser, subscribe, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(SuperUser, publish, <<"anytopic">>), + + User1 = ?USER(<<"client1">>, <<"testuser">>, mqtt, {127,0,0,1}, external), + UnIpUser1 = ?USER(<<"client1">>, <<"testuser">>, mqtt, {192,168,0,4}, external), + UnClientIdUser1 = ?USER(<<"unkonwc">>, <<"testuser">>, mqtt, {127,0,0,1}, external), + UnnameUser1= ?USER(<<"client1">>, <<"unuser">>, mqtt, {127,0,0,1}, external), + allow = emqx_access_control:check_acl(User1, subscribe, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(User1, publish, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(UnIpUser1, subscribe, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(UnClientIdUser1, subscribe, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(UnnameUser1, subscribe, <<"$SYS/testuser/1">>), + + User2 = ?USER(<<"client2">>, <<"xyz">>, mqtt, {127,0,0,1}, external), + UserC = ?USER(<<"client2">>, <<"xyz">>, mqtt, {192,168,1,3}, external), + allow = emqx_access_control:check_acl(UserC, publish, <<"a/b/c">>), + deny = emqx_access_control:check_acl(User2, publish, <<"a/b/c">>), + deny = emqx_access_control:check_acl(User2, subscribe, <<"$SYS/testuser/1">>). + +t_check_auth(_) -> + User1 = ?USER(<<"client1">>, <<"testuser1">>, mqtt, {127,0,0,1}, external, undefined), + User2 = ?USER(<<"client2">>, <<"testuser2">>, mqtt, {127,0,0,1}, exteneral, undefined), + User3 = ?USER(<<"client3">>, undefined, mqtt, {127,0,0,1}, exteneral, undefined), + + {ok, #{auth_result := success, + anonymous := false, + is_superuser := false}} = emqx_access_control:authenticate(User1#{password => <<"pass1">>}), + {error, bad_username_or_password} = emqx_access_control:authenticate(User1#{password => <<"pass">>}), + {error, bad_username_or_password} = emqx_access_control:authenticate(User1#{password => <<>>}), + + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(User2#{password => <<"pass2">>}), + {error, bad_username_or_password} = emqx_access_control:authenticate(User2#{password => <<>>}), + {error, bad_username_or_password} = emqx_access_control:authenticate(User2#{password => <<"errorpwd">>}), + + {error, bad_username_or_password} = emqx_access_control:authenticate(User3#{password => <<"pwd">>}). + +t_sub_pub(_) -> + ct:pal("start client"), + {ok, T1} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client1">>}, + {username, <<"testuser1">>}, + {password, <<"pass1">>}]), + {ok, _} = emqtt:connect(T1), + emqtt:publish(T1, <<"topic">>, <<"body">>, [{qos, 0}, {retain, true}]), + timer:sleep(1000), + {ok, T2} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client2">>}, + {username, <<"testuser2">>}, + {password, <<"pass2">>}]), + {ok, _} = emqtt:connect(T2), + emqtt:subscribe(T2, <<"topic">>), + receive + {publish, _Topic, Payload} -> + ?assertEqual(<<"body">>, Payload) + after 1000 -> false end, + emqtt:disconnect(T1), + emqtt:disconnect(T2). + +t_comment_config(_) -> + AuthCount = length(emqx_hooks:lookup('client.authenticate')), + AclCount = length(emqx_hooks:lookup('client.check_acl')), + application:stop(?APP), + [application:unset_env(?APP, Par) || Par <- [acl_req, auth_req]], + application:start(?APP), + ?assertEqual([], emqx_hooks:lookup('client.authenticate')), + ?assertEqual(AuthCount - 1, length(emqx_hooks:lookup('client.authenticate'))), + ?assertEqual(AclCount - 1, length(emqx_hooks:lookup('client.check_acl'))). diff --git a/apps/emqx_auth_http/test/http_auth_server.erl b/apps/emqx_auth_http/test/http_auth_server.erl new file mode 100644 index 000000000..54c4d38b3 --- /dev/null +++ b/apps/emqx_auth_http/test/http_auth_server.erl @@ -0,0 +1,152 @@ +-module(http_auth_server). + +-export([ start/2 + , stop/0 + ]). + +-define(SUPERUSER, [[{"username", "superuser"}, {"clientid", "superclient"}]]). + +-define(ACL, [[{<<"username">>, <<"testuser">>}, + {<<"clientid">>, <<"client1">>}, + {<<"access">>, <<"1">>}, + {<<"topic">>, <<"users/testuser/1">>}, + {<<"ipaddr">>, <<"127.0.0.1">>}, + {<<"mountpoint">>, <<"null">>}], + [{<<"username">>, <<"xyz">>}, + {<<"clientid">>, <<"client2">>}, + {<<"access">>, <<"2">>}, + {<<"topic">>, <<"a/b/c">>}, + {<<"ipaddr">>, <<"192.168.1.3">>}, + {<<"mountpoint">>, <<"null">>}], + [{<<"username">>, <<"testuser1">>}, + {<<"clientid">>, <<"client1">>}, + {<<"access">>, <<"2">>}, + {<<"topic">>, <<"topic">>}, + {<<"ipaddr">>, <<"127.0.0.1">>}, + {<<"mountpoint">>, <<"null">>}], + [{<<"username">>, <<"testuser2">>}, + {<<"clientid">>, <<"client2">>}, + {<<"access">>, <<"1">>}, + {<<"topic">>, <<"topic">>}, + {<<"ipaddr">>, <<"127.0.0.1">>}, + {<<"mountpoint">>, <<"null">>}]]). + +-define(AUTH, [[{<<"clientid">>, <<"client1">>}, + {<<"username">>, <<"testuser1">>}, + {<<"password">>, <<"pass1">>}], + [{<<"clientid">>, <<"client2">>}, + {<<"username">>, <<"testuser2">>}, + {<<"password">>, <<"pass2">>}]]). + +%%------------------------------------------------------------------------------ +%% REST Interface +%%------------------------------------------------------------------------------ + +-rest_api(#{ name => auth + , method => 'GET' + , path => "/mqtt/auth" + , func => authenticate + , descr => "Authenticate user access permission" + }). + +-rest_api(#{ name => is_superuser + , method => 'GET' + , path => "/mqtt/superuser" + , func => is_superuser + , descr => "Is super user" + }). + +-rest_api(#{ name => acl + , method => 'GET' + , path => "/mqtt/acl" + , func => check_acl + , descr => "Check acl" + }). + +-rest_api(#{ name => auth + , method => 'POST' + , path => "/mqtt/auth" + , func => authenticate + , descr => "Authenticate user access permission" + }). + +-rest_api(#{ name => is_superuser + , method => 'POST' + , path => "/mqtt/superuser" + , func => is_superuser + , descr => "Is super user" + }). + +-rest_api(#{ name => acl + , method => 'POST' + , path => "/mqtt/acl" + , func => check_acl + , descr => "Check acl" + }). + +-export([ authenticate/2 + , is_superuser/2 + , check_acl/2 + ]). + +authenticate(_Binding, Params) -> + return(check(Params, ?AUTH)). + +is_superuser(_Binding, Params) -> + return(check(Params, ?SUPERUSER)). + +check_acl(_Binding, Params) -> + return(check(Params, ?ACL)). + +return(allow) -> {200, <<"allow">>}; +return(deny) -> {400, <<"deny">>}. + +start(http, Inet) -> + application:ensure_all_started(minirest), + Handlers = [{"/", minirest:handler(#{modules => [?MODULE]})}], + Dispatch = [{"/[...]", minirest, Handlers}], + minirest:start_http(http_auth_server, #{socket_opts => [Inet, {port, 8991}]}, Dispatch); + +start(https, Inet) -> + application:ensure_all_started(minirest), + Handlers = [{"/", minirest:handler(#{modules => [?MODULE]})}], + Dispatch = [{"/[...]", minirest, Handlers}], + minirest:start_https(http_auth_server, #{socket_opts => [Inet, {port, 8991} | certopts()]}, Dispatch). + +%% @private +certopts() -> + Certfile = filename:join(["etc", "certs", "cert.pem"]), + Keyfile = filename:join(["etc", "certs", "key.pem"]), + CaCert = filename:join(["etc", "certs", "cacert.pem"]), + [{verify, verify_peer}, + {certfile, emqx_ct_helpers:deps_path(emqx, Certfile)}, + {keyfile, emqx_ct_helpers:deps_path(emqx, Keyfile)}, + {cacertfile, emqx_ct_helpers:deps_path(emqx, CaCert)}] ++ emqx_ct_helpers:client_ssl(). + +stop() -> + minirest:stop_http(http_auth_server). + +-spec check(HttpReqParams :: list(), DefinedConf :: list()) -> allow | deny. +check(_Params, []) -> + %ct:pal("check auth_result: deny~n"), + deny; +check(Params, [ConfRecord|T]) -> + % ct:pal("Params: ~p, ConfRecord:~p ~n", [Params, ConfRecord]), + case match_config(Params, ConfRecord) of + not_match -> + check(Params, T); + matched -> allow + end. + +match_config([], _ConfigColumn) -> + %ct:pal("match_config auth_result: matched~n"), + matched; + +match_config([Param|T], ConfigColumn) -> + %ct:pal("Param: ~p, ConfigColumn:~p ~n", [Param, ConfigColumn]), + case lists:member(Param, ConfigColumn) of + true -> + match_config(T, ConfigColumn); + false -> + not_match + end. diff --git a/apps/emqx_auth_jwt/.gitignore b/apps/emqx_auth_jwt/.gitignore new file mode 100644 index 000000000..62e4fbb25 --- /dev/null +++ b/apps/emqx_auth_jwt/.gitignore @@ -0,0 +1,28 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +emqx_auth_jwt.d +data/ +.DS_Store +cover/ +ct.coverdata +eunit.coverdata +logs/ +test/ct.cover.spec +emq_auth_jwt.d +erlang.mk +_build/ +rebar.lock +rebar3.crashdump +etc/emqx_auth_jwt.conf.rendered +.rebar3/ +*.swp +Mnesia.nonode@nohost/ diff --git a/apps/emqx_auth_jwt/README.md b/apps/emqx_auth_jwt/README.md new file mode 100644 index 000000000..9675ae87c --- /dev/null +++ b/apps/emqx_auth_jwt/README.md @@ -0,0 +1,90 @@ + +# emqx-auth-jwt + +EMQ X JWT Authentication Plugin + +Build +----- + +``` +make && make tests +``` + +Configure the Plugin +-------------------- + +File: etc/plugins/emqx_auth_jwt.conf + +``` +## HMAC Hash Secret. +## +## Value: String +auth.jwt.secret = emqxsecret + +## From where the JWT string can be got +## +## Value: username | password +## Default: password +auth.jwt.from = password + +## RSA or ECDSA public key file. +## +## Value: File +## auth.jwt.pubkey = etc/certs/jwt_public_key.pem + +## Enable to verify claims fields +## +## Value: on | off +auth.jwt.verify_claims = off + +## The checklist of claims to validate +## +## Value: String +## auth.jwt.verify_claims.$name = expected +## +## Variables: +## - %u: username +## - %c: clientid +# auth.jwt.verify_claims.username = %u +``` + +Load the Plugin +--------------- + +``` +./bin/emqx_ctl plugins load emqx_auth_jwt +``` + +Example +------- + +``` +mosquitto_pub -t 'pub' -m 'hello' -i test -u test -P eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiYm9iIiwiYWdlIjoyOX0.bIV_ZQ8D5nQi0LT8AVkpM4Pd6wmlbpR9S8nOLJAsA8o +``` + +Algorithms +---------- + +The JWT spec supports several algorithms for cryptographic signing. This plugin currently supports: + +* HS256 - HMAC using SHA-256 hash algorithm +* HS384 - HMAC using SHA-384 hash algorithm +* HS512 - HMAC using SHA-512 hash algorithm + +* RS256 - RSA with the SHA-256 hash algorithm +* RS384 - RSA with the SHA-384 hash algorithm +* RS512 - RSA with the SHA-512 hash algorithm + +* ES256 - ECDSA using the P-256 curve +* ES384 - ECDSA using the P-384 curve +* ES512 - ECDSA using the P-512 curve + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. diff --git a/apps/emqx_auth_jwt/TODO.md b/apps/emqx_auth_jwt/TODO.md new file mode 100644 index 000000000..dfd730e0a --- /dev/null +++ b/apps/emqx_auth_jwt/TODO.md @@ -0,0 +1,2 @@ +1. Notice for the [Critical vulnerabilities in JSON Web Token](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/) + diff --git a/apps/emqx_auth_jwt/doc/hmac-vs-ecdsa-for-jwt.txt b/apps/emqx_auth_jwt/doc/hmac-vs-ecdsa-for-jwt.txt new file mode 100644 index 000000000..88fa5ebde --- /dev/null +++ b/apps/emqx_auth_jwt/doc/hmac-vs-ecdsa-for-jwt.txt @@ -0,0 +1,3 @@ + +https://crypto.stackexchange.com/questions/30657/hmac-vs-ecdsa-for-jwt + diff --git a/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf new file mode 100644 index 000000000..5a599ca23 --- /dev/null +++ b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf @@ -0,0 +1,45 @@ +##-------------------------------------------------------------------- +## JWT Auth Plugin +##-------------------------------------------------------------------- + +## HMAC Hash Secret. +## +## Value: String +auth.jwt.secret = emqxsecret + +## RSA or ECDSA public key file. +## +## Value: File +#auth.jwt.pubkey = etc/certs/jwt_public_key.pem + +## The JWKs server address +## +## see: http://self-issued.info/docs/draft-ietf-jose-json-web-key.html +## +#auth.jwt.jwks = https://127.0.0.1:8080/jwks + +## The JWKs refresh interval +## +## Value: Duration +#auth.jwt.jwks.refresh_interval = 5m + +## From where the JWT string can be got +## +## Value: username | password +## Default: password +auth.jwt.from = password + +## Enable to verify claims fields +## +## Value: on | off +auth.jwt.verify_claims = off + +## The checklist of claims to validate +## +## Value: String +## auth.jwt.verify_claims.$name = expected +## +## Variables: +## - %u: username +## - %c: clientid +#auth.jwt.verify_claims.username = %u diff --git a/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema new file mode 100644 index 000000000..3d8de3678 --- /dev/null +++ b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema @@ -0,0 +1,49 @@ +%%-*- mode: erlang -*- + +{mapping, "auth.jwt.secret", "emqx_auth_jwt.secret", [ + {datatype, string} +]}. + +{mapping, "auth.jwt.jwks", "emqx_auth_jwt.jwks", [ + {datatype, string} +]}. + +{mapping, "auth.jwt.jwks.refresh_interval", "emqx_auth_jwt.refresh_interval", [ + {datatype, {duration, ms}} +]}. + +{mapping, "auth.jwt.from", "emqx_auth_jwt.from", [ + {default, password}, + {datatype, atom} +]}. + +{mapping, "auth.jwt.pubkey", "emqx_auth_jwt.pubkey", [ + {datatype, string} +]}. + +{mapping, "auth.jwt.signature_format", "emqx_auth_jwt.jwerl_opts", [ + {default, "der"}, + {datatype, {enum, [raw, der]}} +]}. + +{mapping, "auth.jwt.verify_claims", "emqx_auth_jwt.verify_claims", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.jwt.verify_claims.$name", "emqx_auth_jwt.verify_claims", [ + {datatype, string} +]}. + +{translation, "emqx_auth_jwt.verify_claims", fun(Conf) -> + case cuttlefish:conf_get("auth.jwt.verify_claims", Conf) of + false -> cuttlefish:unset(); + true -> + lists:foldr( + fun({["auth","jwt","verify_claims", Name], Value}, Acc) -> + [{list_to_atom(Name), list_to_binary(Value)} | Acc]; + ({["auth","jwt","verify_claims"], _Value}, Acc) -> + Acc + end, [], cuttlefish_variable:filter_by_prefix("auth.jwt.verify_claims", Conf)) + end +end}. diff --git a/apps/emqx_auth_jwt/rebar.config b/apps/emqx_auth_jwt/rebar.config new file mode 100644 index 000000000..5e7575881 --- /dev/null +++ b/apps/emqx_auth_jwt/rebar.config @@ -0,0 +1,25 @@ +{deps, + [ + {jose, {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.1"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + +{profiles, + [{test, + [{deps, [{emqx_ct_helpers, {git, "http://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}]} + ]} + ]}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src new file mode 100644 index 000000000..bf0ba78aa --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_jwt, + [{description, "EMQ X Authentication with JWT"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_jwt_sup]}, + {applications, [kernel,stdlib,jose]}, + {mod, {emqx_auth_jwt_app, []}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-jwt"} + ]} + ]}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl new file mode 100644 index 000000000..6be726dc9 --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl @@ -0,0 +1,99 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_jwt). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[JWT]"). + +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +%%-------------------------------------------------------------------- +%% Authentication callbacks +%%-------------------------------------------------------------------- + +check(ClientInfo, AuthResult, #{pid := Pid, + from := From, + checklists := Checklists}) -> + case maps:find(From, ClientInfo) of + error -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {ok, undefined} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {ok, Token} -> + case emqx_auth_jwt_svr:verify(Pid, Token) of + {error, not_found} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, not_token} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, Reason} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => Reason, anonymous => false}}; + {ok, Claims} -> + {stop, maps:merge(AuthResult, verify_claims(Checklists, Claims, ClientInfo))} + end + end. + +description() -> "Authentication with JWT". + +%%------------------------------------------------------------------------------ +%% Verify Claims +%%-------------------------------------------------------------------- + +verify_claims(Checklists, Claims, ClientInfo) -> + case do_verify_claims(feedvar(Checklists, ClientInfo), Claims) of + {error, Reason} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + #{auth_result => Reason, anonymous => false}; + ok -> + ok = emqx_metrics:inc(?AUTH_METRICS(success)), + #{auth_result => success, anonymous => false, jwt_claims => Claims} + end. + +do_verify_claims([], _Claims) -> + ok; +do_verify_claims([{Key, Expected} | L], Claims) -> + case maps:get(Key, Claims, undefined) =:= Expected of + true -> do_verify_claims(L, Claims); + false -> {error, {verify_claim_failed, Key}} + end. + +feedvar(Checklists, #{username := Username, clientid := ClientId}) -> + lists:map(fun({K, <<"%u">>}) -> {K, Username}; + ({K, <<"%c">>}) -> {K, ClientId}; + ({K, Expected}) -> {K, Expected} + end, Checklists). diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl new file mode 100644 index 000000000..0926bf697 --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl @@ -0,0 +1,81 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_jwt_app). + +-behaviour(application). + +-behaviour(supervisor). + +-emqx_plugin(auth). + +-export([start/2, stop/1]). + +-export([init/1]). + +-define(APP, emqx_auth_jwt). + +start(_Type, _Args) -> + {ok, Sup} = supervisor:start_link({local, ?MODULE}, ?MODULE, []), + + {ok, Pid} = start_auth_server(jwks_svr_options()), + ok = emqx_auth_jwt:register_metrics(), + AuthEnv0 = auth_env(), + AuthEnv1 = AuthEnv0#{pid => Pid}, + + _ = emqx:hook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv1]}), + {ok, Sup, AuthEnv1}. + +stop(AuthEnv) -> + emqx:unhook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv]}). + +%%-------------------------------------------------------------------- +%% Dummy supervisor +%%-------------------------------------------------------------------- + +init([]) -> + {ok, {{one_for_all, 1, 10}, []}}. + +start_auth_server(Options) -> + Spec = #{id => jwt_svr, + start => {emqx_auth_jwt_svr, start_link, [Options]}, + restart => permanent, + shutdown => brutal_kill, + type => worker, + modules => [emqx_auth_jwt_svr]}, + supervisor:start_child(?MODULE, Spec). + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +auth_env() -> + Checklists = [{atom_to_binary(K, utf8), V} + || {K, V} <- env(verify_claims, [])], + #{ from => env(from, password) + , checklists => Checklists + }. + +jwks_svr_options() -> + [{K, V} || {K, V} + <- [{secret, env(secret, undefined)}, + {pubkey, env(pubkey, undefined)}, + {jwks_addr, env(jwks, undefined)}, + {interval, env(refresh_interval, undefined)}], + V /= undefined]. + +env(Key, Default) -> + application:get_env(?APP, Key, Default). diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl new file mode 100644 index 000000000..00cc590e9 --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl @@ -0,0 +1,222 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_jwt_svr). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("jose/include/jose_jwk.hrl"). + +-logger_header("[JWT-SVR]"). + +%% APIs +-export([start_link/1]). + +-export([verify/2]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-type options() :: [option()]. +-type option() :: {secret, list()} + | {pubkey, list()} + | {jwks_addr, list()} + | {interval, pos_integer()}. + +-define(INTERVAL, 300000). + +-record(state, {static, remote, addr, tref, intv}). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +-spec start_link(options()) -> gen_server:start_ret(). +start_link(Options) -> + gen_server:start_link(?MODULE, [Options], []). + +-spec verify(pid(), binary()) + -> {error, term()} + | {ok, Payload :: map()}. +verify(S, JwsCompacted) when is_binary(JwsCompacted) -> + case catch jose_jws:peek(JwsCompacted) of + {'EXIT', _} -> {error, not_token}; + _ -> gen_server:call(S, {verify, JwsCompacted}) + end. + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Options]) -> + ok = jose:json_module(jiffy), + {Static, Remote} = do_init_jwks(Options), + Intv = proplists:get_value(interval, Options, ?INTERVAL), + {ok, reset_timer( + #state{ + static = Static, + remote = Remote, + addr = proplists:get_value(jwks_addr, Options), + intv = Intv})}. + +%% @private +do_init_jwks(Options) -> + K2J = fun(K, F) -> + case proplists:get_value(K, Options) of + undefined -> undefined; + V -> + try F(V) of + {error, Reason} -> + ?LOG(warning, "Build ~p JWK ~p failed: {error, ~p}~n", + [K, V, Reason]), + undefined; + J -> J + catch T:R:_ -> + ?LOG(warning, "Build ~p JWK ~p failed: {~p, ~p}~n", + [K, V, T, R]), + undefined + end + end + end, + OctJwk = K2J(secret, fun(V) -> + jose_jwk:from_oct(list_to_binary(V)) + end), + PemJwk = K2J(pubkey, fun jose_jwk:from_pem_file/1), + Remote = K2J(jwks_addr, fun request_jwks/1), + {[J ||J <- [OctJwk, PemJwk], J /= undefined], Remote}. + +handle_call({verify, JwsCompacted}, _From, State) -> + handle_verify(JwsCompacted, State); + +handle_call(_Req, _From, State) -> + {reply, ok, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({timeout, _TRef, refresh}, State = #state{addr = Addr}) -> + NState = try + State#state{remote = request_jwks(Addr)} + catch _:_ -> + State + end, + {noreply, reset_timer(NState)}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, State) -> + _ = cancel_timer(State), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +handle_verify(JwsCompacted, + State = #state{static = Static, remote = Remote}) -> + try + Jwks = case emqx_json:decode(jose_jws:peek_protected(JwsCompacted), [return_maps]) of + #{<<"kid">> := Kid} -> + [J || J <- Remote, maps:get(<<"kid">>, J#jose_jwk.fields, undefined) =:= Kid]; + _ -> Static + end, + case Jwks of + [] -> {reply, {error, not_found}, State}; + _ -> + {reply, do_verify(JwsCompacted, Jwks), State} + end + catch + _:_ -> + {reply, {error, invalid_signature}, State} + end. + +request_jwks(Addr) -> + case httpc:request(get, {Addr, []}, [], [{body_format, binary}]) of + {error, Reason} -> + error(Reason); + {ok, {_Code, _Headers, Body}} -> + try + JwkSet = jose_jwk:from(emqx_json:decode(Body, [return_maps])), + {_, Jwks} = JwkSet#jose_jwk.keys, Jwks + catch _:_ -> + ?LOG(error, "Invalid jwks server response: ~p~n", [Body]), + error(badarg) + end + end. + +reset_timer(State = #state{addr = undefined}) -> + State; +reset_timer(State = #state{intv = Intv}) -> + State#state{tref = erlang:start_timer(Intv, self(), refresh)}. + +cancel_timer(State = #state{tref = undefined}) -> + State; +cancel_timer(State = #state{tref = TRef}) -> + _ = erlang:cancel_timer(TRef), + State#state{tref = undefined}. + +do_verify(_JwsCompated, []) -> + {error, invalid_signature}; +do_verify(JwsCompacted, [Jwk|More]) -> + case jose_jws:verify(Jwk, JwsCompacted) of + {true, Payload, _Jws} -> + Claims = emqx_json:decode(Payload, [return_maps]), + case check_claims(Claims) of + false -> + {error, invalid_signature}; + NClaims -> + {ok, NClaims} + end; + {false, _, _} -> + do_verify(JwsCompacted, More) + end. + +check_claims(Claims) -> + Now = os:system_time(seconds), + Checker = [{<<"exp">>, fun(ExpireTime) -> + Now < ExpireTime + end}, + {<<"iat">>, fun(IssueAt) -> + IssueAt =< Now + end}, + {<<"nbf">>, fun(NotBefore) -> + NotBefore =< Now + end} + ], + do_check_claim(Checker, Claims). + +do_check_claim([], Claims) -> + Claims; +do_check_claim([{K, F}|More], Claims) -> + case maps:take(K, Claims) of + error -> do_check_claim(More, Claims); + {V, NClaims} -> + case F(V) of + true -> do_check_claim(More, NClaims); + _ -> false + end + end. diff --git a/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl new file mode 100644 index 000000000..12f307b2a --- /dev/null +++ b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl @@ -0,0 +1,142 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_jwt_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(APP, emqx_auth_jwt). + +all() -> + [{group, emqx_auth_jwt}]. + +groups() -> + [{emqx_auth_jwt, [sequence], [ t_check_auth + , t_check_claims + , t_check_claims_clientid + , t_check_claims_username + ]} + ]. + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx, emqx_auth_jwt], fun set_special_configs/1), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_auth_jwt, emqx]). + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, acl_nomatch, deny), + application:set_env(emqx, enable_acl_cache, false), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + AclFilePath = filename:join(["test", "emqx_SUITE_data", "acl.conf"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)), + application:set_env(emqx, acl_file, + emqx_ct_helpers:deps_path(emqx, AclFilePath)); + +set_special_configs(emqx_auth_jwt) -> + application:set_env(emqx_auth_jwt, secret, "emqxsecret"), + application:set_env(emqx_auth_jwt, from, password); + +set_special_configs(_) -> + ok. + +sign(Payload, Alg, Key) -> + Jwk = jose_jwk:from_oct(Key), + Jwt = emqx_json:encode(Payload), + {_, Token} = jose_jws:compact(jose_jwt:sign(Jwk, #{<<"alg">> => Alg}, Jwt)), + Token. + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_check_auth(_) -> + Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, + Jwt = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), + ct:pal("Jwt: ~p~n", [Jwt]), + + Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), + ct:pal("Auth result: ~p~n", [Result0]), + ?assertMatch({ok, #{auth_result := success, jwt_claims := #{<<"clientid">> := <<"client1">>}}}, Result0), + + ct:sleep(3100), + Result1 = emqx_access_control:authenticate(Plain#{password => Jwt}), + ct:pal("Auth result after 1000ms: ~p~n", [Result1]), + ?assertMatch({error, _}, Result1), + + Jwt_Error = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), + ct:pal("invalid jwt: ~p~n", [Jwt_Error]), + Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), + ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), + ?assertEqual({error, invalid_signature}, Result2), + ?assertMatch({error, _}, emqx_access_control:authenticate(Plain#{password => <<"asd">>})). + +t_check_claims(_) -> + application:set_env(emqx_auth_jwt, verify_claims, [{sub, <<"value">>}]), + Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, + Jwt = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}, + {sub, value}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), + Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), + ct:pal("Auth result: ~p~n", [Result0]), + ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), + Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), + ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), + ?assertEqual({error, invalid_signature}, Result2). + +t_check_claims_clientid(_) -> + application:set_env(emqx_auth_jwt, verify_claims, [{clientid, <<"%c">>}]), + Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), + Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), + ct:pal("Auth result: ~p~n", [Result0]), + ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), + Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), + ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), + ?assertEqual({error, invalid_signature}, Result2). + +t_check_claims_username(_) -> + application:set_env(emqx_auth_jwt, verify_claims, [{username, <<"%u">>}]), + Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), + Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), + ct:pal("Auth result: ~p~n", [Result0]), + ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), + Result3 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), + ct:pal("Auth result for the invalid jwt: ~p~n", [Result3]), + ?assertEqual({error, invalid_signature}, Result3). diff --git a/apps/emqx_auth_ldap/.gitignore b/apps/emqx_auth_ldap/.gitignore new file mode 100644 index 000000000..eb8f0639f --- /dev/null +++ b/apps/emqx_auth_ldap/.gitignore @@ -0,0 +1,25 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +emqx_auth_ldap.d +data/ +cover/ +ct.coverdata +eunit.coverdata +logs/ +test/ct.cover.spec +.DS_Store +_build/ +rebar.lock +erlang.mk +rebar3.crashdump +.rebar3/ +etc/emqx_auth_ldap.conf.rendered diff --git a/apps/emqx_auth_ldap/README.md b/apps/emqx_auth_ldap/README.md new file mode 100644 index 000000000..c4d56c839 --- /dev/null +++ b/apps/emqx_auth_ldap/README.md @@ -0,0 +1,96 @@ +emqx_auth_ldap +============== + +EMQ X LDAP Authentication Plugin + +Build +----- + +``` +make +``` + +Load the Plugin +--------------- + +``` +# ./bin/emqx_ctl plugins load emqx_auth_ldap +``` + +Generate Password +--------------- + +``` +slappasswd -h '{ssha}' -s public +``` + +Configuration Open LDAP +----------------------- + +vim /etc/openldap/slapd.conf + +``` +include /etc/openldap/schema/core.schema +include /etc/openldap/schema/cosine.schema +include /etc/openldap/schema/inetorgperson.schema +include /etc/openldap/schema/ppolicy.schema +include /etc/openldap/schema/emqx.schema + +database bdb +suffix "dc=emqx,dc=io" +rootdn "cn=root,dc=emqx,dc=io" +rootpw {SSHA}eoF7NhNrejVYYyGHqnt+MdKNBh4r1w3W + +directory /etc/openldap/data +``` + +If the ldap launched with error below: +``` +Unrecognized database type (bdb) +5c4a72b9 slapd.conf: line 7: failed init (bdb) +slapadd: bad configuration file! +``` + +Insert lines to the slapd.conf +``` +modulepath /usr/lib/ldap +moduleload back_bdb.la +``` + +Import EMQX User Data +---------------------- + +Use ldapadd +``` +# ldapadd -x -D "cn=root,dc=emqx,dc=io" -w public -f emqx.com.ldif +``` + +Use slapadd +``` +# sudo slapadd -l schema/emqx.io.ldif -f slapd.conf +``` + +Launch slapd +``` +# sudo slapd -d 3 +``` + +Test +----- +After configure slapd correctly and launch slapd successfully. +You could execute + +``` bash +# make tests +``` + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_auth_ldap/emqx.io.ldif b/apps/emqx_auth_ldap/emqx.io.ldif new file mode 100644 index 000000000..f9833cd88 --- /dev/null +++ b/apps/emqx_auth_ldap/emqx.io.ldif @@ -0,0 +1,135 @@ +## create emqx.io + +dn:dc=emqx,dc=io +objectclass: top +objectclass: dcobject +objectclass: organization +dc:emqx +o:emqx,Inc. + +# create testdevice.emqx.io +dn:ou=testdevice,dc=emqx,dc=io +objectClass: top +objectclass:organizationalUnit +ou:testdevice + +# create user admin +dn:uid=admin,ou=testdevice,dc=emqx,dc=io +objectClass: top +objectClass: simpleSecurityObject +objectClass: account +userPassword:: e1NIQX1XNnBoNU1tNVB6OEdnaVVMYlBnekczN21qOWc9 +uid: admin + +## create user=mqttuser0001, +# password=mqttuser0001, +# passhash={SHA}mlb3fat40MKBTXUVZwCKmL73R/0= +# base64passhash=e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9 +dn:uid=mqttuser0001,ou=testdevice,dc=emqx,dc=io +objectClass: top +objectClass: mqttUser +objectClass: mqttDevice +objectClass: mqttSecurity +uid: mqttuser0001 +isEnabled: TRUE +mqttAccountName: user1 +mqttPublishTopic: mqttuser0001/pub/1 +mqttPublishTopic: mqttuser0001/pub/+ +mqttPublishTopic: mqttuser0001/pub/# +mqttSubscriptionTopic: mqttuser0001/sub/1 +mqttSubscriptionTopic: mqttuser0001/sub/+ +mqttSubscriptionTopic: mqttuser0001/sub/# +mqttPubSubTopic: mqttuser0001/pubsub/1 +mqttPubSubTopic: mqttuser0001/pubsub/+ +mqttPubSubTopic: mqttuser0001/pubsub/# +userPassword:: e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9 + +## create user=mqttuser0002 +# password=mqttuser0002, +# passhash={SSHA}n9XdtoG4Q/TQ3TQF4Y+khJbMBH4qXj4M +# base64passhash=e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0= +dn:uid=mqttuser0002,ou=testdevice,dc=emqx,dc=io +objectClass: top +objectClass: mqttUser +objectClass: mqttDevice +objectClass: mqttSecurity +uid: mqttuser0002 +isEnabled: TRUE +mqttAccountName: user2 +mqttPublishTopic: mqttuser0002/pub/1 +mqttPublishTopic: mqttuser0002/pub/+ +mqttPublishTopic: mqttuser0002/pub/# +mqttSubscriptionTopic: mqttuser0002/sub/1 +mqttSubscriptionTopic: mqttuser0002/sub/+ +mqttSubscriptionTopic: mqttuser0002/sub/# +mqttPubSubTopic: mqttuser0002/pubsub/1 +mqttPubSubTopic: mqttuser0002/pubsub/+ +mqttPubSubTopic: mqttuser0002/pubsub/# +userPassword:: e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0= + +## create user mqttuser0003 +# password=mqttuser0003, +# passhash={MD5}ybsPGoaK3nDyiQvveiCOIw== +# base64passhash=e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0= +dn:uid=mqttuser0003,ou=testdevice,dc=emqx,dc=io +objectClass: top +objectClass: mqttUser +objectClass: mqttDevice +objectClass: mqttSecurity +uid: mqttuser0003 +isEnabled: TRUE +mqttPublishTopic: mqttuser0003/pub/1 +mqttPublishTopic: mqttuser0003/pub/+ +mqttPublishTopic: mqttuser0003/pub/# +mqttSubscriptionTopic: mqttuser0003/sub/1 +mqttSubscriptionTopic: mqttuser0003/sub/+ +mqttSubscriptionTopic: mqttuser0003/sub/# +mqttPubSubTopic: mqttuser0003/pubsub/1 +mqttPubSubTopic: mqttuser0003/pubsub/+ +mqttPubSubTopic: mqttuser0003/pubsub/# +userPassword:: e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0= + +## create user mqttuser0004 +# password=mqttuser0004, +# passhash={MD5}2Br6pPDSEDIEvUlu9+s+MA== +# base64passhash=e01ENX0yQnI2cFBEU0VESUV2VWx1OStzK01BPT0= +dn:uid=mqttuser0004,ou=testdevice,dc=emqx,dc=io +objectClass: top +objectClass: mqttUser +objectClass: mqttDevice +objectClass: mqttSecurity +uid: mqttuser0004 +isEnabled: TRUE +mqttPublishTopic: mqttuser0004/pub/1 +mqttPublishTopic: mqttuser0004/pub/+ +mqttPublishTopic: mqttuser0004/pub/# +mqttSubscriptionTopic: mqttuser0004/sub/1 +mqttSubscriptionTopic: mqttuser0004/sub/+ +mqttSubscriptionTopic: mqttuser0004/sub/# +mqttPubSubTopic: mqttuser0004/pubsub/1 +mqttPubSubTopic: mqttuser0004/pubsub/+ +mqttPubSubTopic: mqttuser0004/pubsub/# +userPassword: {MD5}2Br6pPDSEDIEvUlu9+s+MA== + +## create user mqttuser0005 +# password=mqttuser0005, +# passhash={SHA}jKnxeEDGR14kE8AR7yuVFOelhz4= +# base64passhash=e1NIQX1qS254ZUVER1IxNGtFOEFSN3l1VkZPZWxoejQ9 +objectClass: top +dn:uid=mqttuser0005,ou=testdevice,dc=emqx,dc=io +objectClass: mqttUser +objectClass: mqttDevice +objectClass: mqttSecurity +uid: mqttuser0005 +isEnabled: TRUE +mqttPublishTopic: mqttuser0005/pub/1 +mqttPublishTopic: mqttuser0005/pub/+ +mqttPublishTopic: mqttuser0005/pub/# +mqttSubscriptionTopic: mqttuser0005/sub/1 +mqttSubscriptionTopic: mqttuser0005/sub/+ +mqttSubscriptionTopic: mqttuser0005/sub/# +mqttPubSubTopic: mqttuser0005/pubsub/1 +mqttPubSubTopic: mqttuser0005/pubsub/+ +mqttPubSubTopic: mqttuser0005/pubsub/# +userPassword: {SHA}jKnxeEDGR14kE8AR7yuVFOelhz4= + diff --git a/apps/emqx_auth_ldap/emqx.schema b/apps/emqx_auth_ldap/emqx.schema new file mode 100644 index 000000000..55f92269b --- /dev/null +++ b/apps/emqx_auth_ldap/emqx.schema @@ -0,0 +1,46 @@ +# +# Preliminary Apple OS X Native LDAP Schema +# This file is subject to change. +# +attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.1.3 NAME 'isEnabled' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE + USAGE userApplications ) + +attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.1 NAME ( 'mqttPublishTopic' 'mpt' ) + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + USAGE userApplications ) +attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.2 NAME ( 'mqttSubscriptionTopic' 'mst' ) + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + USAGE userApplications ) +attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.3 NAME ( 'mqttPubSubTopic' 'mpst' ) + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + USAGE userApplications ) +attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.4 NAME ( 'mqttAccountName' 'man' ) + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + USAGE userApplications ) + + +objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4 NAME 'mqttUser' + AUXILIARY + MAY ( mqttPublishTopic $ mqttSubscriptionTopic $ mqttPubSubTopic $ mqttAccountName) ) + +objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.2 NAME 'mqttDevice' + SUP top + STRUCTURAL + MUST ( uid ) + MAY ( isEnabled ) ) + +objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.3 NAME 'mqttSecurity' + SUP top + AUXILIARY + MAY ( userPassword $ userPKCS12 $ pwdAttribute $ pwdLockout ) ) diff --git a/apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf b/apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf new file mode 100644 index 000000000..746510fb3 --- /dev/null +++ b/apps/emqx_auth_ldap/etc/emqx_auth_ldap.conf @@ -0,0 +1,78 @@ +##-------------------------------------------------------------------- +## LDAP Auth Plugin +##-------------------------------------------------------------------- + +## LDAP server list, seperated by ','. +## +## Value: String +auth.ldap.servers = 127.0.0.1 + +## LDAP server port. +## +## Value: Port +auth.ldap.port = 389 + +## LDAP pool size +## +## Value: String +auth.ldap.pool = 8 + +## LDAP Bind DN. +## +## Value: DN +auth.ldap.bind_dn = cn=root,dc=emqx,dc=io + +## LDAP Bind Password. +## +## Value: String +auth.ldap.bind_password = public + +## LDAP query timeout. +## +## Value: Number +auth.ldap.timeout = 30s + +## Device DN. +## +## Variables: +## +## Value: DN +auth.ldap.device_dn = ou=device,dc=emqx,dc=io + +## Specified ObjectClass +## +## Variables: +## +## Value: string +auth.ldap.match_objectclass = mqttUser + +## attributetype for username +## +## Variables: +## +## Value: string +auth.ldap.username.attributetype = uid + +## attributetype for password +## +## Variables: +## +## Value: string +auth.ldap.password.attributetype = userPassword + +## Whether to enable SSL. +## +## Value: true | false +auth.ldap.ssl = false + +#auth.ldap.ssl.certfile = etc/certs/cert.pem + +#auth.ldap.ssl.keyfile = etc/certs/key.pem + +#auth.ldap.ssl.cacertfile = etc/certs/cacert.pem + +#auth.ldap.ssl.verify = verify_peer + +#auth.ldap.ssl.fail_if_no_peer_cert = true + +#auth.ldap.ssl.server_name_indication = your_server_name diff --git a/apps/emqx_auth_ldap/include/emqx_auth_ldap.hrl b/apps/emqx_auth_ldap/include/emqx_auth_ldap.hrl new file mode 100644 index 000000000..8950c0ec8 --- /dev/null +++ b/apps/emqx_auth_ldap/include/emqx_auth_ldap.hrl @@ -0,0 +1,23 @@ + +-define(APP, emqx_auth_ldap). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_ldap/priv/emqx_auth_ldap.schema b/apps/emqx_auth_ldap/priv/emqx_auth_ldap.schema new file mode 100644 index 000000000..554752a0b --- /dev/null +++ b/apps/emqx_auth_ldap/priv/emqx_auth_ldap.schema @@ -0,0 +1,176 @@ +%%-*- mode: erlang -*- +%% emqx_auth_ldap config mapping + +{mapping, "auth.ldap.servers", "emqx_auth_ldap.ldap", [ + {default, "127.0.0.1"}, + {datatype, string} +]}. + +{mapping, "auth.ldap.port", "emqx_auth_ldap.ldap", [ + {default, 389}, + {datatype, integer} +]}. + +{mapping, "auth.ldap.pool", "emqx_auth_ldap.ldap", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.ldap.bind_dn", "emqx_auth_ldap.ldap", [ + {datatype, string}, + {default, "cn=root,dc=emqx,dc=io"} +]}. + +{mapping, "auth.ldap.bind_password", "emqx_auth_ldap.ldap", [ + {datatype, string}, + {default, "public"} +]}. + +{mapping, "auth.ldap.timeout", "emqx_auth_ldap.ldap", [ + {default, "30s"}, + {datatype, {duration, ms}} +]}. + +{mapping, "auth.ldap.ssl", "emqx_auth_ldap.ldap", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "auth.ldap.ssl.certfile", "emqx_auth_ldap.ldap", [ + {datatype, string} +]}. + +{mapping, "auth.ldap.ssl.keyfile", "emqx_auth_ldap.ldap", [ + {datatype, string} +]}. + +{mapping, "auth.ldap.ssl.cacertfile", "emqx_auth_ldap.ldap", [ + {datatype, string} +]}. + +{mapping, "auth.ldap.ssl.verify", "emqx_auth_ldap.ldap", [ + {default, verify_none}, + {datatype, {enum, [verify_none, verify_peer]}} +]}. + +{mapping, "auth.ldap.ssl.fail_if_no_peer_cert", "emqx_auth_ldap.ldap", [ + {datatype, {enum, [true, false]}} +]}. + +{mapping, "auth.ldap.ssl.server_name_indication", "emqx_auth_ldap.ldap", [ + {datatype, string} +]}. + +{translation, "emqx_auth_ldap.ldap", fun(Conf) -> + A2N = fun(A) -> case inet:parse_address(A) of {ok, N} -> N; _ -> A end end, + Servers = [A2N(A) || A <- string:tokens(cuttlefish:conf_get("auth.ldap.servers", Conf), ",")], + Port = cuttlefish:conf_get("auth.ldap.port", Conf), + Pool = cuttlefish:conf_get("auth.ldap.pool", Conf), + BindDN = cuttlefish:conf_get("auth.ldap.bind_dn", Conf), + BindPassword = cuttlefish:conf_get("auth.ldap.bind_password", Conf), + Timeout = cuttlefish:conf_get("auth.ldap.timeout", Conf), + Filter = fun(Ls) -> [E || E = {_, V} <- Ls, V /= undefined]end, + SslOpts = fun() -> + [{certfile, cuttlefish:conf_get("auth.ldap.ssl.certfile", Conf)}, + {keyfile, cuttlefish:conf_get("auth.ldap.ssl.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get("auth.ldap.ssl.cacertfile", Conf, undefined)}, + {verify, cuttlefish:conf_get("auth.ldap.ssl.verify", Conf, undefined)}, + {server_name_indication, cuttlefish:conf_get("auth.ldap.ssl.server_name_indication", Conf, disable)}, + {fail_if_no_peer_cert, cuttlefish:conf_get("auth.ldap.ssl.fail_if_no_peer_cert", Conf, undefined)}] + end, + Opts = [{servers, Servers}, + {port, Port}, + {timeout, Timeout}, + {bind_dn, BindDN}, + {bind_password, BindPassword}, + {pool, Pool}, + {auto_reconnect, 2}], + case cuttlefish:conf_get("auth.ldap.ssl", Conf) of + true -> [{ssl, true}, {sslopts, Filter(SslOpts())}|Opts]; + false -> [{ssl, false}|Opts] + end +end}. + +{mapping, "auth.ldap.device_dn", "emqx_auth_ldap.device_dn", [ + {default, "ou=device,dc=emqx,dc=io"}, + {datatype, string} +]}. + +{mapping, "auth.ldap.match_objectclass", "emqx_auth_ldap.match_objectclass", [ + {default, "mqttUser"}, + {datatype, string} +]}. + +{mapping, "auth.ldap.custom_base_dn", "emqx_auth_ldap.custom_base_dn", [ + {default, "${username_attr}=${user},${device_dn}"}, + {datatype, string} +]}. + +%% auth.ldap.filters.1.key = "objectClass" +%% auth.ldap.filters.1.value = "mqttUser" +%% auth.ldap.filters.1.op = "and" +%% auth.ldap.filters.2.key = "uiAttr" +%% auth.ldap.filters.2.value "someAttr" +%% auth.ldap.filters.2.op = "or" +%% auth.ldap.filters.3.key = "someKey" +%% auth.ldap.filters.3.value = "someValue" +%% The configuratation structure sent to the application: +%% [{"objectClass","mqttUser"},"and",{"uiAttr","someAttr"},"or",{"someKey","someAttr"}] +%% The resulting LDAP filter would look like this: +%% ==> "|(&(objectClass=Class)(uiAttr=someAttr)(someKey=someValue))" +{translation, "emqx_auth_ldap.filters", +fun(Conf) -> + Settings = cuttlefish_variable:filter_by_prefix("auth.ldap.filters", Conf), + Keys = [{Num, {key, V}} || {["auth","ldap","filters", Num, "key"], V} <- Settings], + Values = [{Num, {value, V}} || {["auth","ldap","filters", Num, "value"], V} <- Settings], + Ops = [{Num, {op, V}} || {["auth","ldap","filters", Num, "op"], V} <- Settings], + RawFilters = Keys ++ Values ++ Ops, + Filters = + lists:foldl( + fun({Num,{T,V}}, Acc)-> + maps:update_with(Num, + fun(F)-> + maps:put(T,V,F) + end, + #{T=>V}, Acc) + end, #{}, RawFilters), + Order=lists:usort(maps:keys(Filters)), + lists:reverse( + lists:foldl( + fun(F,Acc)-> + case F of + #{key:=K, op:=Op, value:=V} -> [Op,{K,V}|Acc]; + #{key:=K, value:=V} -> [{K,V}|Acc] + end + end, + [], + lists:map(fun(K) -> maps:get(K, Filters) end, Order))) +end}. + +{mapping, "auth.ldap.filters.$num.key", "emqx_auth_ldap.filters", [ + {datatype, string} +]}. + +{mapping, "auth.ldap.filters.$num.value", "emqx_auth_ldap.filters", [ + {datatype, string} +]}. + +{mapping, "auth.ldap.filters.$num.op", "emqx_auth_ldap.filters", [ + {datatype, {enum, [ "or", "and" ] } } +]}. + + +{mapping, "auth.ldap.bind_as_user", "emqx_auth_ldap.bind_as_user", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "auth.ldap.username.attributetype", "emqx_auth_ldap.username_attr", [ + {default, "uid"}, + {datatype, string} +]}. + +{mapping, "auth.ldap.password.attributetype", "emqx_auth_ldap.password_attr", [ + {default, "userPassword"}, + {datatype, string} +]}. diff --git a/apps/emqx_auth_ldap/rebar.config b/apps/emqx_auth_ldap/rebar.config new file mode 100644 index 000000000..48eaf812f --- /dev/null +++ b/apps/emqx_auth_ldap/rebar.config @@ -0,0 +1,25 @@ +{deps, + [{eldap2, {git, "https://github.com/emqx/eldap2", {tag, "v0.2.2"}}} + ]}. + +{profiles, + [{test, + [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}]} + ]} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + diff --git a/apps/emqx_auth_ldap/src/emqx_acl_ldap.erl b/apps/emqx_auth_ldap/src/emqx_acl_ldap.erl new file mode 100644 index 000000000..cfd51164a --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_acl_ldap.erl @@ -0,0 +1,98 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_ldap). + +-include("emqx_auth_ldap.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eldap/include/eldap.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). + +-import(proplists, [get_value/2]). + +-import(emqx_auth_ldap_cli, [search/4]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(ClientInfo, PubSub, Topic, NoMatchAction, State) -> + case do_check_acl(ClientInfo, PubSub, Topic, NoMatchAction, State) of + ok -> emqx_metrics:inc(?ACL_METRICS(ignore)), ok; + {stop, allow} -> emqx_metrics:inc(?ACL_METRICS(allow)), {stop, allow}; + {stop, deny} -> emqx_metrics:inc(?ACL_METRICS(deny)), {stop, deny} + end. + +do_check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _NoMatchAction, _State) -> + ok; + +do_check_acl(#{username := Username}, PubSub, Topic, _NoMatchAction, + #{device_dn := DeviceDn, + match_objectclass := ObjectClass, + username_attr := UidAttr, + custom_base_dn := CustomBaseDN, + pool := Pool} = Config) -> + + Filters = maps:get(filters, Config, []), + + ReplaceRules = [{"${username_attr}", UidAttr}, + {"${user}", binary_to_list(Username)}, + {"${device_dn}", DeviceDn}], + + Filter = emqx_auth_ldap:prepare_filter(Filters, UidAttr, ObjectClass, ReplaceRules), + + Attribute = case PubSub of + publish -> "mqttPublishTopic"; + subscribe -> "mqttSubscriptionTopic" + end, + Attribute1 = "mqttPubSubTopic", + ?LOG(debug, "[LDAP] search dn:~p filter:~p, attribute:~p", + [DeviceDn, Filter, Attribute]), + + BaseDN = emqx_auth_ldap:replace_vars(CustomBaseDN, ReplaceRules), + + case search(Pool, BaseDN, Filter, [Attribute, Attribute1]) of + {error, noSuchObject} -> + ok; + {ok, #eldap_search_result{entries = []}} -> + ok; + {ok, #eldap_search_result{entries = [Entry]}} -> + Topics = get_value(Attribute, Entry#eldap_entry.attributes) + ++ get_value(Attribute1, Entry#eldap_entry.attributes), + match(Topic, Topics); + Error -> + ?LOG(error, "[LDAP] search error:~p", [Error]), + {stop, deny} + end. + +match(_Topic, []) -> + ok; + +match(Topic, [Filter | Topics]) -> + case emqx_topic:match(Topic, list_to_binary(Filter)) of + true -> {stop, allow}; + false -> match(Topic, Topics) + end. + +description() -> + "ACL with LDAP". + diff --git a/apps/emqx_auth_ldap/src/emqx_auth_ldap.app.src b/apps/emqx_auth_ldap/src/emqx_auth_ldap.app.src new file mode 100644 index 000000000..8635c4834 --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_auth_ldap.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_ldap, + [{description, "EMQ X Authentication/ACL with LDAP"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_ldap_sup]}, + {applications, [kernel,stdlib,eldap2,ecpool]}, + {mod, {emqx_auth_ldap_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-ldap"} + ]} + ]}. diff --git a/apps/emqx_auth_ldap/src/emqx_auth_ldap.erl b/apps/emqx_auth_ldap/src/emqx_auth_ldap.erl new file mode 100644 index 000000000..01cbb0ecb --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_auth_ldap.erl @@ -0,0 +1,210 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap). + +-include("emqx_auth_ldap.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eldap/include/eldap.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-import(proplists, [get_value/2]). + +-import(emqx_auth_ldap_cli, [search/3]). + +-export([ register_metrics/0 + , check/3 + , description/0 + , prepare_filter/4 + , replace_vars/2 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo = #{username := Username, password := Password}, AuthResult, + State = #{password_attr := PasswdAttr, bind_as_user := BindAsUserRequired, pool := Pool}) -> + CheckResult = + case lookup_user(Username, State) of + undefined -> {error, not_found}; + {error, Error} -> {error, Error}; + Entry -> + PasswordString = binary_to_list(Password), + ObjectName = Entry#eldap_entry.object_name, + Attributes = Entry#eldap_entry.attributes, + case BindAsUserRequired of + true -> + emqx_auth_ldap_cli:post_bind(Pool, ObjectName, PasswordString); + false -> + case get_value(PasswdAttr, Attributes) of + undefined -> + logger:error("LDAP Search State: ~p, uid: ~p, result:~p", + [State, Username, Attributes]), + {error, not_found}; + [Passhash1] -> + format_password(Passhash1, Password, ClientInfo) + end + end + end, + case CheckResult of + ok -> + ok = emqx_metrics:inc(?AUTH_METRICS(success)), + {stop, AuthResult#{auth_result => success, anonymous => false}}; + {error, not_found} -> + emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, ResultCode} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + ?LOG(error, "[LDAP] Auth from ldap failed: ~p", [ResultCode]), + {stop, AuthResult#{auth_result => ResultCode, anonymous => false}} + end. + +lookup_user(Username, #{username_attr := UidAttr, + match_objectclass := ObjectClass, + device_dn := DeviceDn, + custom_base_dn := CustomBaseDN, pool := Pool} = Config) -> + + Filters = maps:get(filters, Config, []), + + ReplaceRules = [{"${username_attr}", UidAttr}, + {"${user}", binary_to_list(Username)}, + {"${device_dn}", DeviceDn}], + + Filter = prepare_filter(Filters, UidAttr, ObjectClass, ReplaceRules), + + %% auth.ldap.custom_base_dn = "${username_attr}=${user},${device_dn}" + BaseDN = replace_vars(CustomBaseDN, ReplaceRules), + + case search(Pool, BaseDN, Filter) of + %% This clause seems to be impossible to match. `eldap2:search/2` does + %% not validates the result, so if it returns "successfully" from the + %% LDAP server, it always returns `{ok, #eldap_search_result{}}`. + {error, noSuchObject} -> + undefined; + %% In case no user was found by the search, but the search was completed + %% without error we get an empty `entries` list. + {ok, #eldap_search_result{entries = []}} -> + undefined; + {ok, #eldap_search_result{entries = [Entry]}} -> + Attributes = Entry#eldap_entry.attributes, + case get_value("isEnabled", Attributes) of + undefined -> + Entry; + [Val] -> + case list_to_atom(string:to_lower(Val)) of + true -> Entry; + false -> {error, username_disabled} + end + end; + {error, Error} -> + ?LOG(error, "[LDAP] Search dn: ~p, filter: ~p, fail:~p", [DeviceDn, Filter, Error]), + {error, username_or_password_error} + end. + +check_pass(Password, Password, _ClientInfo) -> ok; +check_pass(_, _, _) -> {error, bad_username_or_password}. + +format_password(Passhash, Password, ClientInfo) -> + case do_format_password(Passhash, Password) of + {error, Error2} -> + {error, Error2}; + {Passhash1, Password1} -> + check_pass(Passhash1, Password1, ClientInfo) + end. + +do_format_password(Passhash, Password) -> + Base64PasshashHandler = + handle_passhash(fun(HashType, Passhash1, Password1) -> + Passhash2 = binary_to_list(base64:decode(Passhash1)), + resolve_passhash(HashType, Passhash2, Password1) + end, + fun(_Passhash, _Password) -> + {error, password_error} + end), + PasshashHandler = handle_passhash(fun resolve_passhash/3, Base64PasshashHandler), + PasshashHandler(Passhash, Password). + +resolve_passhash(HashType, Passhash, Password) -> + [_, Passhash1] = string:tokens(Passhash, "}"), + do_resolve(HashType, Passhash1, Password). + +handle_passhash(HandleMatch, HandleNoMatch) -> + fun(Passhash, Password) -> + case re:run(Passhash, "(?<={)[^{}]+(?=})", [{capture, all, list}, global]) of + {match, [[HashType]]} -> + HandleMatch(list_to_atom(string:to_lower(HashType)), Passhash, Password); + _ -> + HandleNoMatch(Passhash, Password) + end + end. + +do_resolve(ssha, Passhash, Password) -> + D64 = base64:decode(Passhash), + {HashedData, Salt} = lists:split(20, binary_to_list(D64)), + NewHash = crypto:hash(sha, <>), + {list_to_binary(HashedData), NewHash}; +do_resolve(HashType, Passhash, Password) -> + Password1 = base64:encode(crypto:hash(HashType, Password)), + {list_to_binary(Passhash), Password1}. + +description() -> "LDAP Authentication Plugin". + +prepare_filter(Filters, _UidAttr, ObjectClass, ReplaceRules) -> + SubFilters = + lists:map(fun({K, V}) -> + {replace_vars(K, ReplaceRules), replace_vars(V, ReplaceRules)}; + (Op) -> + Op + end, Filters), + case SubFilters of + [] -> eldap2:equalityMatch("objectClass", ObjectClass); + _List -> compile_filters(SubFilters, []) + end. + + +compile_filters([{Key, Value}], []) -> + compile_equal(Key, Value); +compile_filters([{K1, V1}, "and", {K2, V2} | Rest], []) -> + compile_filters( + Rest, + eldap2:'and'([compile_equal(K1, V1), + compile_equal(K2, V2)])); +compile_filters([{K1, V1}, "or", {K2, V2} | Rest], []) -> + compile_filters( + Rest, + eldap2:'or'([compile_equal(K1, V1), + compile_equal(K2, V2)])); +compile_filters(["and", {K, V} | Rest], PartialFilter) -> + compile_filters( + Rest, + eldap2:'and'([PartialFilter, + compile_equal(K, V)])); +compile_filters(["or", {K, V} | Rest], PartialFilter) -> + compile_filters( + Rest, + eldap2:'or'([PartialFilter, + compile_equal(K, V)])); +compile_filters([], Filter) -> + Filter. + +compile_equal(Key, Value) -> + eldap2:equalityMatch(Key, Value). + +replace_vars(CustomBaseDN, ReplaceRules) -> + lists:foldl(fun({Pattern, Substitute}, DN) -> + lists:flatten(string:replace(DN, Pattern, Substitute)) + end, CustomBaseDN, ReplaceRules). diff --git a/apps/emqx_auth_ldap/src/emqx_auth_ldap_app.erl b/apps/emqx_auth_ldap/src/emqx_auth_ldap_app.erl new file mode 100644 index 000000000..8a1343870 --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_auth_ldap_app.erl @@ -0,0 +1,78 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_ldap.hrl"). + +%% Application callbacks +-export([ start/2 + , prep_stop/1 + , stop/1 + ]). + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_ldap_sup:start_link(), + _ = if_enabled([device_dn, match_objectclass, + username_attr, password_attr, + filters, custom_base_dn, bind_as_user], + fun load_auth_hook/1), + _ = if_enabled([device_dn, match_objectclass, + username_attr, password_attr, + filters, custom_base_dn, bind_as_user], + fun load_acl_hook/1), + {ok, Sup}. + +prep_stop(State) -> + emqx:unhook('client.authenticate', fun emqx_auth_ldap:check/3), + emqx:unhook('client.check_acl', fun emqx_acl_ldap:check_acl/5), + State. + +stop(_State) -> + ok. + +load_auth_hook(DeviceDn) -> + ok = emqx_auth_ldap:register_metrics(), + Params = maps:from_list(DeviceDn), + emqx:hook('client.authenticate', fun emqx_auth_ldap:check/3, [Params#{pool => ?APP}]). + +load_acl_hook(DeviceDn) -> + ok = emqx_acl_ldap:register_metrics(), + Params = maps:from_list(DeviceDn), + emqx:hook('client.check_acl', fun emqx_acl_ldap:check_acl/5 , [Params#{pool => ?APP}]). + +if_enabled(Cfgs, Fun) -> + case get_env(Cfgs) of + {ok, []} -> ok; + {ok, InitArgs} -> Fun(InitArgs) + end. + +get_env(Cfgs) -> + get_env(Cfgs, []). + +get_env([Cfg | LeftCfgs], ENVS) -> + case application:get_env(?APP, Cfg) of + {ok, ENV} -> + get_env(LeftCfgs, [{Cfg, ENV} | ENVS]); + undefined -> + get_env(LeftCfgs, ENVS) + end; +get_env([], ENVS) -> + {ok, ENVS}. diff --git a/apps/emqx_auth_ldap/src/emqx_auth_ldap_cli.erl b/apps/emqx_auth_ldap/src/emqx_auth_ldap_cli.erl new file mode 100644 index 000000000..2f6e8099c --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_auth_ldap_cli.erl @@ -0,0 +1,150 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap_cli). + +-behaviour(ecpool_worker). + +-include("emqx_auth_ldap.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +%% ecpool callback +-export([connect/1]). + +-export([ search/3 + , search/4 + , post_bind/3 + , init_args/1 + ]). + +-import(proplists, + [ get_value/2 + , get_value/3 + ]). + +%%-------------------------------------------------------------------- +%% LDAP Connect/Search +%%-------------------------------------------------------------------- + +connect(Opts) -> + Servers = get_value(servers, Opts, ["localhost"]), + Port = get_value(port, Opts, 389), + Timeout = get_value(timeout, Opts, 30), + BindDn = get_value(bind_dn, Opts), + BindPassword = get_value(bind_password, Opts), + LdapOpts = case get_value(ssl, Opts, false)of + true -> + SslOpts = get_value(sslopts, Opts), + [{port, Port}, {timeout, Timeout}, {sslopts, SslOpts}]; + false -> + [{port, Port}, {timeout, Timeout}] + end, + ?LOG(debug, "[LDAP] Connecting to OpenLDAP server: ~p, Opts:~p ...", [Servers, LdapOpts]), + + case eldap2:open(Servers, LdapOpts) of + {ok, LDAP} -> + try eldap2:simple_bind(LDAP, BindDn, BindPassword) of + ok -> {ok, LDAP}; + {error, Error} -> + ?LOG(error, "[LDAP] Can't authenticated to OpenLDAP server: ~p", [Error]), + {error, Error} + catch + error:Reason -> + ?LOG(error, "[LDAP] Can't authenticated to OpenLDAP server: ~p", [Reason]), + {error, Reason} + end; + {error, Reason} -> + ?LOG(error, "[LDAP] Can't connect to OpenLDAP server: ~p", [Reason]), + {error, Reason} + end. + +search(Pool, Base, Filter) -> + ecpool:with_client(Pool, + fun(C) -> + case application:get_env(?APP, bind_as_user) of + {ok, true} -> + {ok, Opts} = application:get_env(?APP, ldap), + BindDn = get_value(bind_dn, Opts), + BindPassword = get_value(bind_password, Opts), + try eldap2:simple_bind(C, BindDn, BindPassword) of + ok -> + eldap2:search(C, [{base, Base}, + {filter, Filter}, + {deref, eldap2:derefFindingBaseObj()}]); + {error, Error} -> + {error, Error} + catch + error:Reason -> {error, Reason} + end; + {ok, false} -> + eldap2:search(C, [{base, Base}, + {filter, Filter}, + {deref, eldap2:derefFindingBaseObj()}]) + end + end). + +search(Pool, Base, Filter, Attributes) -> + ecpool:with_client(Pool, + fun(C) -> + case application:get_env(?APP, bind_as_user) of + {ok, true} -> + {ok, Opts} = application:get_env(?APP, ldap), + BindDn = get_value(bind_dn, Opts), + BindPassword = get_value(bind_password, Opts), + try eldap2:simple_bind(C, BindDn, BindPassword) of + ok -> + eldap2:search(C, [{base, Base}, + {filter, Filter}, + {attributes, Attributes}, + {deref, eldap2:derefFindingBaseObj()}]); + {error, Error} -> + {error, Error} + catch + error:Reason -> {error, Reason} + end; + {ok, false} -> + eldap2:search(C, [{base, Base}, + {filter, Filter}, + {attributes, Attributes}, + {deref, eldap2:derefFindingBaseObj()}]) + end + end). + +post_bind(Pool, BindDn, BindPassword) -> + ecpool:with_client(Pool, + fun(C) -> + try eldap2:simple_bind(C, BindDn, BindPassword) of + ok -> ok; + {error, Error} -> + {error, Error} + catch + error:Reason -> {error, Reason} + end + end). + + +init_args(ENVS) -> + DeviceDn = get_value(device_dn, ENVS), + ObjectClass = get_value(match_objectclass, ENVS), + UidAttr = get_value(username_attr, ENVS), + PasswdAttr = get_value(password_attr, ENVS), + {ok, #{device_dn => DeviceDn, + match_objectclass => ObjectClass, + username_attr => UidAttr, + password_attr => PasswdAttr}}. + diff --git a/apps/emqx_auth_ldap/src/emqx_auth_ldap_sup.erl b/apps/emqx_auth_ldap/src/emqx_auth_ldap_sup.erl new file mode 100644 index 000000000..ca4440f1a --- /dev/null +++ b/apps/emqx_auth_ldap/src/emqx_auth_ldap_sup.erl @@ -0,0 +1,35 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap_sup). + +-behaviour(supervisor). + +-include("emqx_auth_ldap.hrl"). + +-export([start_link/0]). + +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + %% LDAP Connection Pool. + {ok, Server} = application:get_env(?APP, ldap), + PoolSpec = ecpool:pool_spec(?APP, ?APP, emqx_auth_ldap_cli, Server), + {ok, {{one_for_one, 10, 100}, [PoolSpec]}}. + diff --git a/apps/emqx_auth_ldap/test/certs/cacert.pem b/apps/emqx_auth_ldap/test/certs/cacert.pem new file mode 100644 index 000000000..604fd2362 --- /dev/null +++ b/apps/emqx_auth_ldap/test/certs/cacert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjmgAwIBAgIJAPPYCjTmxdt/MA0GCSqGSIb3DQEBCwUAMD8xCzAJBgNV +BAYTAkNOMREwDwYDVQQIDAhoYW5nemhvdTEMMAoGA1UECgwDRU1RMQ8wDQYDVQQD +DAZSb290Q0EwHhcNMjAwNTA4MDgwNjUyWhcNMzAwNTA2MDgwNjUyWjA/MQswCQYD +VQQGEwJDTjERMA8GA1UECAwIaGFuZ3pob3UxDDAKBgNVBAoMA0VNUTEPMA0GA1UE +AwwGUm9vdENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzcgVLex1 +EZ9ON64EX8v+wcSjzOZpiEOsAOuSXOEN3wb8FKUxCdsGrsJYB7a5VM/Jot25Mod2 +juS3OBMg6r85k2TWjdxUoUs+HiUB/pP/ARaaW6VntpAEokpij/przWMPgJnBF3Ur +MjtbLayH9hGmpQrI5c2vmHQ2reRZnSFbY+2b8SXZ+3lZZgz9+BaQYWdQWfaUWEHZ +uDaNiViVO0OT8DRjCuiDp3yYDj3iLWbTA/gDL6Tf5XuHuEwcOQUrd+h0hyIphO8D +tsrsHZ14j4AWYLk1CPA6pq1HIUvEl2rANx2lVUNv+nt64K/Mr3RnVQd9s8bK+TXQ +KGHd2Lv/PALYuwIDAQABo1AwTjAdBgNVHQ4EFgQUGBmW+iDzxctWAWxmhgdlE8Pj +EbQwHwYDVR0jBBgwFoAUGBmW+iDzxctWAWxmhgdlE8PjEbQwDAYDVR0TBAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAGbhRUjpIred4cFAFJ7bbYD9hKu/yzWPWkMRa +ErlCKHmuYsYk+5d16JQhJaFy6MGXfLgo3KV2itl0d+OWNH0U9ULXcglTxy6+njo5 +CFqdUBPwN1jxhzo9yteDMKF4+AHIxbvCAJa17qcwUKR5MKNvv09C6pvQDJLzid7y +E2dkgSuggik3oa0427KvctFf8uhOV94RvEDyqvT5+pgNYZ2Yfga9pD/jjpoHEUlo +88IGU8/wJCx3Ds2yc8+oBg/ynxG8f/HmCC1ET6EHHoe2jlo8FpU/SgGtghS1YL30 +IWxNsPrUP+XsZpBJy/mvOhE5QXo6Y35zDqqj8tI7AGmAWu22jg== +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_ldap/test/certs/cert.pem b/apps/emqx_auth_ldap/test/certs/cert.pem new file mode 100644 index 000000000..092390b1d --- /dev/null +++ b/apps/emqx_auth_ldap/test/certs/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIBAjANBgkqhkiG9w0BAQsFADA/MQswCQYDVQQGEwJDTjER +MA8GA1UECAwIaGFuZ3pob3UxDDAKBgNVBAoMA0VNUTEPMA0GA1UEAwwGUm9vdENB +MB4XDTIwMDUwODA4MDcwNVoXDTMwMDUwNjA4MDcwNVowPzELMAkGA1UEBhMCQ04x +ETAPBgNVBAgMCGhhbmd6aG91MQwwCgYDVQQKDANFTVExDzANBgNVBAMMBlNlcnZl +cjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALNeWT3pE+QFfiRJzKmn +AMUrWo3K2j/Tm3+Xnl6WLz67/0rcYrJbbKvS3uyRP/stXyXEKw9CepyQ1ViBVFkW +Aoy8qQEOWFDsZc/5UzhXUnb6LXr3qTkFEjNmhj+7uzv/lbBxlUG1NlYzSeOB6/RT +8zH/lhOeKhLnWYPXdXKsa1FL6ij4X8DeDO1kY7fvAGmBn/THh1uTpDizM4YmeI+7 +4dmayA5xXvARte5h4Vu5SIze7iC057N+vymToMk2Jgk+ZZFpyXrnq+yo6RaD3ANc +lrc4FbeUQZ5a5s5Sxgs9a0Y3WMG+7c5VnVXcbjBRz/aq2NtOnQQjikKKQA8GF080 +BQkCAwEAAaMaMBgwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQEL +BQADggEBAJefnMZpaRDHQSNUIEL3iwGXE9c6PmIsQVE2ustr+CakBp3TZ4l0enLt +iGMfEVFju69cO4oyokWv+hl5eCMkHBf14Kv51vj448jowYnF1zmzn7SEzm5Uzlsa +sqjtAprnLyof69WtLU1j5rYWBuFX86yOTwRAFNjm9fvhAcrEONBsQtqipBWkMROp +iUYMkRqbKcQMdwxov+lHBYKq9zbWRoqLROAn54SRqgQk6c15JdEfgOOjShbsOkIH +UhqcwRkQic7n1zwHVGVDgNIZVgmJ2IdIWBlPEC7oLrRrBD/X1iEEXtKab6p5o22n +KB5mN+iQaE+Oe2cpGKZJiJRdM+IqDDQ= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_ldap/test/certs/client-cert.pem b/apps/emqx_auth_ldap/test/certs/client-cert.pem new file mode 100644 index 000000000..09d855221 --- /dev/null +++ b/apps/emqx_auth_ldap/test/certs/client-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIBATANBgkqhkiG9w0BAQsFADA/MQswCQYDVQQGEwJDTjER +MA8GA1UECAwIaGFuZ3pob3UxDDAKBgNVBAoMA0VNUTEPMA0GA1UEAwwGUm9vdENB +MB4XDTIwMDUwODA4MDY1N1oXDTMwMDUwNjA4MDY1N1owPzELMAkGA1UEBhMCQ04x +ETAPBgNVBAgMCGhhbmd6aG91MQwwCgYDVQQKDANFTVExDzANBgNVBAMMBkNsaWVu +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMy4hoksKcZBDbY680u6 +TS25U51nuB1FBcGMlF9B/t057wPOlxF/OcmbxY5MwepS41JDGPgulE1V7fpsXkiW +1LUimYV/tsqBfymIe0mlY7oORahKji7zKQ2UBIVFhdlvQxunlIDnw6F9popUgyHt +dMhtlgZK8oqRwHxO5dbfoukYd6J/r+etS5q26sgVkf3C6dt0Td7B25H9qW+f7oLV +PbcHYCa+i73u9670nrpXsC+Qc7Mygwa2Kq/jwU+ftyLQnOeW07DuzOwsziC/fQZa +nbxR+8U9FNftgRcC3uP/JMKYUqsiRAuaDokARZxVTV5hUElfpO6z6/NItSDvvh3i +eikCAwEAAaMaMBgwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQEL +BQADggEBABchYxKo0YMma7g1qDswJXsR5s56Czx/I+B41YcpMBMTrRqpUC0nHtLk +M7/tZp592u/tT8gzEnQjZLKBAhFeZaR3aaKyknLqwiPqJIgg0pgsBGITrAK3Pv4z +5/YvAJJKgTe5UdeTz6U4lvNEux/4juZ4pmqH4qSFJTOzQS7LmgSmNIdd072rwXBd +UzcSHzsJgEMb88u/LDLjj1pQ7AtZ4Tta8JZTvcgBFmjB0QUi6fgkHY6oGat/W4kR +jSRUBlMUbM/drr2PVzRc2dwbFIl3X+ZE6n5Sl3ZwRAC/s92JU6CPMRW02muVu6xl +goraNgPISnrbpR6KjxLZkVembXzjNNc= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_ldap/test/certs/client-key.pem b/apps/emqx_auth_ldap/test/certs/client-key.pem new file mode 100644 index 000000000..2b3f30cf6 --- /dev/null +++ b/apps/emqx_auth_ldap/test/certs/client-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAzLiGiSwpxkENtjrzS7pNLblTnWe4HUUFwYyUX0H+3TnvA86X +EX85yZvFjkzB6lLjUkMY+C6UTVXt+mxeSJbUtSKZhX+2yoF/KYh7SaVjug5FqEqO +LvMpDZQEhUWF2W9DG6eUgOfDoX2milSDIe10yG2WBkryipHAfE7l1t+i6Rh3on+v +561LmrbqyBWR/cLp23RN3sHbkf2pb5/ugtU9twdgJr6Lve73rvSeulewL5BzszKD +BrYqr+PBT5+3ItCc55bTsO7M7CzOIL99BlqdvFH7xT0U1+2BFwLe4/8kwphSqyJE +C5oOiQBFnFVNXmFQSV+k7rPr80i1IO++HeJ6KQIDAQABAoIBAGWgvPjfuaU3qizq +uti/FY07USz0zkuJdkANH6LiSjlchzDmn8wJ0pApCjuIE0PV/g9aS8z4opp5q/gD +UBLM/a8mC/xf2EhTXOMrY7i9p/I3H5FZ4ZehEqIw9sWKK9YzC6dw26HabB2BGOnW +5nozPSQ6cp2RGzJ7BIkxSZwPzPnVTgy3OAuPOiJytvK+hGLhsNaT+Y9bNDvplVT2 +ZwYTV8GlHZC+4b2wNROILm0O86v96O+Qd8nn3fXjGHbMsAnONBq10bZS16L4fvkH +5G+W/1PeSXmtZFppdRRDxIW+DWcXK0D48WRliuxcV4eOOxI+a9N2ZJZZiNLQZGwg +w3A8+mECgYEA8HuJFrlRvdoBe2U/EwUtG74dcyy30L4yEBnN5QscXmEEikhaQCfX +Wm6EieMcIB/5I5TQmSw0cmBMeZjSXYoFdoI16/X6yMMuATdxpvhOZGdUGXxhAH+x +xoTUavWZnEqW3fkUU71kT5E2f2i+0zoatFESXHeslJyz85aAYpP92H0CgYEA2e5A +Yozt5eaA1Gyhd8SeptkEU4xPirNUnVQHStpMWUb1kzTNXrPmNWccQ7JpfpG6DcYl +zUF6p6mlzY+zkMiyPQjwEJlhiHM2NlL1QS7td0R8ewgsFoyn8WsBI4RejWrEG9td +EDniuIw+pBFkcWthnTLHwECHdzgquToyTMjrBB0CgYEA28tdGbrZXhcyAZEhHAZA +Gzog+pKlkpEzeonLKIuGKzCrEKRecIK5jrqyQsCjhS0T7ZRnL4g6i0s+umiV5M5w +fcc292pEA1h45L3DD6OlKplSQVTv55/OYS4oY3YEJtf5mfm8vWi9lQeY8sxOlQpn +O+VZTdBHmTC8PGeTAgZXHZUCgYA6Tyv88lYowB7SN2qQgBQu8jvdGtqhcs/99GCr +H3N0I69LPsKAR0QeH8OJPXBKhDUywESXAaEOwS5yrLNP1tMRz5Vj65YUCzeDG3kx +gpvY4IMp7ArX0bSRvJ6mYSFnVxy3k174G3TVCfksrtagHioVBGQ7xUg5ltafjrms +n8l55QKBgQDVzU8tQvBVqY8/1lnw11Vj4fkE/drZHJ5UkdC1eenOfSWhlSLfUJ8j +ds7vEWpRPPoVuPZYeR1y78cyxKe1GBx6Wa2lF5c7xjmiu0xbRnrxYeLolce9/ntp +asClqpnHT8/VJYTD7Kqj0fouTTZf0zkig/y+2XERppd8k+pSKjUCPQ== +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_ldap/test/certs/key.pem b/apps/emqx_auth_ldap/test/certs/key.pem new file mode 100644 index 000000000..6c338216e --- /dev/null +++ b/apps/emqx_auth_ldap/test/certs/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAs15ZPekT5AV+JEnMqacAxStajcraP9Obf5eeXpYvPrv/Stxi +sltsq9Le7JE/+y1fJcQrD0J6nJDVWIFUWRYCjLypAQ5YUOxlz/lTOFdSdvotevep +OQUSM2aGP7u7O/+VsHGVQbU2VjNJ44Hr9FPzMf+WE54qEudZg9d1cqxrUUvqKPhf +wN4M7WRjt+8AaYGf9MeHW5OkOLMzhiZ4j7vh2ZrIDnFe8BG17mHhW7lIjN7uILTn +s36/KZOgyTYmCT5lkWnJeuer7KjpFoPcA1yWtzgVt5RBnlrmzlLGCz1rRjdYwb7t +zlWdVdxuMFHP9qrY206dBCOKQopADwYXTzQFCQIDAQABAoIBAQCuvCbr7Pd3lvI/ +n7VFQG+7pHRe1VKwAxDkx2t8cYos7y/QWcm8Ptwqtw58HzPZGWYrgGMCRpzzkRSF +V9g3wP1S5Scu5C6dBu5YIGc157tqNGXB+SpdZddJQ4Nc6yGHXYERllT04ffBGc3N +WG/oYS/1cSteiSIrsDy/91FvGRCi7FPxH3wIgHssY/tw69s1Cfvaq5lr2NTFzxIG +xCvpJKEdSfVfS9I7LYiymVjst3IOR/w76/ZFY9cRa8ZtmQSWWsm0TUpRC1jdcbkm +ZoJptYWlP+gSwx/fpMYftrkJFGOJhHJHQhwxT5X/ajAISeqjjwkWSEJLwnHQd11C +Zy2+29lBAoGBANlEAIK4VxCqyPXNKfoOOi5dS64NfvyH4A1v2+KaHWc7lqaqPN49 +ezfN2n3X+KWx4cviDD914Yc2JQ1vVJjSaHci7yivocDo2OfZDmjBqzaMp/y+rX1R +/f3MmiTqMa468rjaxI9RRZu7vDgpTR+za1+OBCgMzjvAng8dJuN/5gjlAoGBANNY +uYPKtearBmkqdrSV7eTUe49Nhr0XotLaVBH37TCW0Xv9wjO2xmbm5Ga/DCtPIsBb +yPeYwX9FjoasuadUD7hRvbFu6dBa0HGLmkXRJZTcD7MEX2Lhu4BuC72yDLLFd0r+ +Ep9WP7F5iJyagYqIZtz+4uf7gBvUDdmvXz3sGr1VAoGAdXTD6eeKeiI6PlhKBztF +zOb3EQOO0SsLv3fnodu7ZaHbUgLaoTMPuB17r2jgrYM7FKQCBxTNdfGZmmfDjlLB +0xZ5wL8ibU30ZXL8zTlWPElST9sto4B+FYVVF/vcG9sWeUUb2ncPcJ/Po3UAktDG +jYQTTyuNGtSJHpad/YOZctkCgYBtWRaC7bq3of0rJGFOhdQT9SwItN/lrfj8hyHA +OjpqTV4NfPmhsAtu6j96OZaeQc+FHvgXwt06cE6Rt4RG4uNPRluTFgO7XYFDfitP +vCppnoIw6S5BBvHwPP+uIhUX2bsi/dm8vu8tb+gSvo4PkwtFhEr6I9HglBKmcmog +q6waEQKBgHyecFBeM6Ls11Cd64vborwJPAuxIW7HBAFj/BS99oeG4TjBx4Sz2dFd +rzUibJt4ndnHIvCN8JQkjNG14i9hJln+H3mRss8fbZ9vQdqG+2vOWADYSzzsNI55 +RFY7JjluKcVkp/zCDeUxTU3O6sS+v6/3VE11Cob6OYQx3lN5wrZ3 +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_ldap/test/emqx_auth_ldap_SUITE.erl b/apps/emqx_auth_ldap/test/emqx_auth_ldap_SUITE.erl new file mode 100644 index 000000000..513ad79e1 --- /dev/null +++ b/apps/emqx_auth_ldap/test/emqx_auth_ldap_SUITE.erl @@ -0,0 +1,153 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(PID, emqx_auth_ldap). + +-define(APP, emqx_auth_ldap). + +-define(DeviceDN, "ou=test_device,dc=emqx,dc=io"). + +-define(AuthDN, "ou=test_auth,dc=emqx,dc=io"). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + [{group, nossl}, {group, ssl}]. + +groups() -> + Cases = emqx_ct:all(?MODULE), + [{nossl, Cases}, {ssl, Cases}]. + +init_per_group(GrpName, Cfg) -> + Fun = fun(App) -> set_special_configs(GrpName, App) end, + emqx_ct_helpers:start_apps([emqx_auth_ldap], Fun), + emqx_mod_acl_internal:unload([]), + Cfg. + +end_per_group(_GrpName, _Cfg) -> + emqx_ct_helpers:stop_apps([emqx_auth_ldap]). + +%%-------------------------------------------------------------------- +%% Cases +%%-------------------------------------------------------------------- + +t_check_auth(_) -> + MqttUser1 = #{clientid => <<"mqttuser1">>, + username => <<"mqttuser0001">>, + password => <<"mqttuser0001">>, + zone => external}, + MqttUser2 = #{clientid => <<"mqttuser2">>, + username => <<"mqttuser0002">>, + password => <<"mqttuser0002">>, + zone => external}, + MqttUser3 = #{clientid => <<"mqttuser3">>, + username => <<"mqttuser0003">>, + password => <<"mqttuser0003">>, + zone => external}, + MqttUser4 = #{clientid => <<"mqttuser4">>, + username => <<"mqttuser0004">>, + password => <<"mqttuser0004">>, + zone => external}, + MqttUser5 = #{clientid => <<"mqttuser5">>, + username => <<"mqttuser0005">>, + password => <<"mqttuser0005">>, + zone => external}, + NonExistUser1 = #{clientid => <<"mqttuser6">>, + username => <<"mqttuser0006">>, + password => <<"mqttuser0006">>, + zone => external}, + NonExistUser2 = #{clientid => <<"mqttuser7">>, + username => <<"mqttuser0005">>, + password => <<"mqttuser0006">>, + zone => external}, + ct:log("MqttUser: ~p", [emqx_access_control:authenticate(MqttUser1)]), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser1)), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser2)), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser3)), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser4)), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser5)), + ?assertEqual({error, not_authorized}, emqx_access_control:authenticate(NonExistUser1)), + ?assertEqual({error, bad_username_or_password}, emqx_access_control:authenticate(NonExistUser2)). + +t_check_acl(_) -> + MqttUser = #{clientid => <<"mqttuser1">>, username => <<"mqttuser0001">>, zone => external}, + NoMqttUser = #{clientid => <<"mqttuser2">>, username => <<"mqttuser0007">>, zone => external}, + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/1">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/+">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/#">>), + + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/1">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/+">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/#">>), + + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/1">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/+">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/#">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/1">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/+">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/#">>), + + deny = emqx_access_control:check_acl(NoMqttUser, publish, <<"mqttuser0001/req/mqttuser0001/+">>), + deny = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/req/mqttuser0002/+">>), + deny = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/req/+/mqttuser0002">>), + ok. + +%%-------------------------------------------------------------------- +%% Helpers +%%-------------------------------------------------------------------- + +set_special_configs(_, emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, acl_nomatch, deny), + AclFilePath = filename:join(["test", "emqx_SUITE_data", "acl.conf"]), + application:set_env(emqx, acl_file, + emqx_ct_helpers:deps_path(emqx, AclFilePath)), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); + +set_special_configs(Ssl, emqx_auth_ldap) -> + case Ssl == ssl of + true -> + LdapOpts = application:get_env(emqx_auth_ldap, ldap, []), + Path = emqx_ct_helpers:deps_path(emqx_auth_ldap, "test/certs/"), + SslOpts = [{verify, verify_peer}, + {fail_if_no_peer_cert, true}, + {server_name_indication, disable}, + {keyfile, Path ++ "/client-key.pem"}, + {certfile, Path ++ "/client-cert.pem"}, + {cacertfile, Path ++ "/cacert.pem"}], + LdapOpts1 = lists:keystore(ssl, 1, LdapOpts, {ssl, true}), + LdapOpts2 = lists:keystore(sslopts, 1, LdapOpts1, {sslopts, SslOpts}), + LdapOpts3 = lists:keystore(port, 1, LdapOpts2, {port, 636}), + application:set_env(emqx_auth_ldap, ldap, LdapOpts3); + _ -> + ok + end, + application:set_env(emqx_auth_ldap, device_dn, "ou=testdevice, dc=emqx, dc=io"). + diff --git a/apps/emqx_auth_ldap/test/emqx_auth_ldap_bind_as_user_SUITE.erl b/apps/emqx_auth_ldap/test/emqx_auth_ldap_bind_as_user_SUITE.erl new file mode 100644 index 000000000..a0a960f92 --- /dev/null +++ b/apps/emqx_auth_ldap/test/emqx_auth_ldap_bind_as_user_SUITE.erl @@ -0,0 +1,114 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_ldap_bind_as_user_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(PID, emqx_auth_ldap). + +-define(APP, emqx_auth_ldap). + +-define(DeviceDN, "ou=test_device,dc=emqx,dc=io"). + +-define(AuthDN, "ou=test_auth,dc=emqx,dc=io"). + +all() -> + [check_auth, + check_acl]. + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx, emqx_auth_ldap], fun set_special_configs/1), + emqx_mod_acl_internal:unload([]), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_auth_ldap, emqx]). + +check_auth(_) -> + MqttUser1 = #{clientid => <<"mqttuser1">>, + username => <<"user1">>, + password => <<"mqttuser0001">>, + zone => external}, + MqttUser2 = #{clientid => <<"mqttuser2">>, + username => <<"user2">>, + password => <<"mqttuser0002">>, + zone => external}, + NonExistUser1 = #{clientid => <<"mqttuser3">>, + username => <<"user3">>, + password => <<"mqttuser0003">>, + zone => external}, + ct:log("MqttUser: ~p", [emqx_access_control:authenticate(MqttUser1)]), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser1)), + ?assertMatch({ok, #{auth_result := success}}, emqx_access_control:authenticate(MqttUser2)), + ?assertEqual({error, not_authorized}, emqx_access_control:authenticate(NonExistUser1)). + +check_acl(_) -> + % emqx_modules:load_module(emqx_mod_acl_internal, false), + MqttUser = #{clientid => <<"mqttuser1">>, username => <<"user1">>, zone => external}, + NoMqttUser = #{clientid => <<"mqttuser2">>, username => <<"user7">>, zone => external}, + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/1">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/+">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pub/#">>), + + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/1">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/+">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/sub/#">>), + + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/1">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/+">>), + allow = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/pubsub/#">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/1">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/+">>), + allow = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/pubsub/#">>), + + deny = emqx_access_control:check_acl(NoMqttUser, publish, <<"mqttuser0001/req/mqttuser0001/+">>), + deny = emqx_access_control:check_acl(MqttUser, publish, <<"mqttuser0001/req/mqttuser0002/+">>), + deny = emqx_access_control:check_acl(MqttUser, subscribe, <<"mqttuser0001/req/+/mqttuser0002">>), + ok. + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, acl_nomatch, deny), + AclFilePath = filename:join(["test", "emqx_SUITE_data", "acl.conf"]), + application:set_env(emqx, acl_file, + emqx_ct_helpers:deps_path(emqx, AclFilePath)), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); + +set_special_configs(emqx_auth_ldap) -> + application:set_env(emqx_auth_ldap, bind_as_user, true), + application:set_env(emqx_auth_ldap, device_dn, "ou=testdevice, dc=emqx, dc=io"), + application:set_env(emqx_auth_ldap, custom_base_dn, "${device_dn}"), + %% auth.ldap.filters.1.key = mqttAccountName + %% auth.ldap.filters.1.value = ${user} + %% auth.ldap.filters.1.op = and + %% auth.ldap.filters.2.key = objectClass + %% auth.ldap.filters.1.value = mqttUser + application:set_env(emqx_auth_ldap, filters, [{"mqttAccountName", "${user}"}, + "and", + {"objectClass", "mqttUser"}]); + +set_special_configs(_App) -> + ok. + diff --git a/apps/emqx_auth_mnesia/.gitignore b/apps/emqx_auth_mnesia/.gitignore new file mode 100644 index 000000000..a4d9fea0a --- /dev/null +++ b/apps/emqx_auth_mnesia/.gitignore @@ -0,0 +1,26 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +emqx_auth_mnesia.d +data/ +_build/ +.DS_Store +cover/ +ct.coverdata +eunit.coverdata +logs/ +test/ct.cover.spec +rebar.lock +rebar3.crashdump +erlang.mk +.*.swp +.rebar3/ +etc/emqx_auth_mnesia.conf.rendered diff --git a/apps/emqx_auth_mnesia/README.md b/apps/emqx_auth_mnesia/README.md new file mode 100644 index 000000000..8b4c145a8 --- /dev/null +++ b/apps/emqx_auth_mnesia/README.md @@ -0,0 +1,2 @@ +emqx_auth_mnesia +=============== diff --git a/apps/emqx_auth_mnesia/etc/emqx_auth_mnesia.conf b/apps/emqx_auth_mnesia/etc/emqx_auth_mnesia.conf new file mode 100644 index 000000000..ff74656cb --- /dev/null +++ b/apps/emqx_auth_mnesia/etc/emqx_auth_mnesia.conf @@ -0,0 +1,30 @@ +## Password hash. +## +## Value: plain | md5 | sha | sha256 | sha512 +auth.mnesia.password_hash = sha256 + +##-------------------------------------------------------------------- +## ClientId Authentication +##-------------------------------------------------------------------- + +## Examples +##auth.client.1.clientid = id +##auth.client.1.password = passwd +##auth.client.2.clientid = dev:devid +##auth.client.2.password = passwd2 +##auth.client.3.clientid = app:appid +##auth.client.3.password = passwd3 +##auth.client.4.clientid = client~!@#$%^&*()_+ +##auth.client.4.password = passwd~!@#$%^&*()_+ + +##-------------------------------------------------------------------- +## Username Authentication +##-------------------------------------------------------------------- + +## Examples: +##auth.user.1.username = admin +##auth.user.1.password = public +##auth.user.2.username = feng@emqtt.io +##auth.user.2.password = public +##auth.user.3.username = name~!@#$%^&*()_+ +##auth.user.3.password = pwsswd~!@#$%^&*()_+ diff --git a/apps/emqx_auth_mnesia/include/emqx_auth_mnesia.hrl b/apps/emqx_auth_mnesia/include/emqx_auth_mnesia.hrl new file mode 100644 index 000000000..034bd4f30 --- /dev/null +++ b/apps/emqx_auth_mnesia/include/emqx_auth_mnesia.hrl @@ -0,0 +1,38 @@ +-define(APP, emqx_auth_mnesia). + +-type(login():: {clientid, binary()} + | {username, binary()}). + +-record(emqx_user, { + login :: login(), + password :: binary(), + created_at :: integer() + }). + +-record(emqx_acl, { + filter:: {login() | all, emqx_topic:topic()}, + action :: pub | sub | pubsub, + access :: allow | deny, + created_at :: integer() + }). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_mnesia/priv/emqx_auth_mnesia.schema b/apps/emqx_auth_mnesia/priv/emqx_auth_mnesia.schema new file mode 100644 index 000000000..87d6bf47f --- /dev/null +++ b/apps/emqx_auth_mnesia/priv/emqx_auth_mnesia.schema @@ -0,0 +1,43 @@ +%%-*- mode: erlang -*- +%% emqx_auth_mnesia config mapping + +{mapping, "auth.mnesia.password_hash", "emqx_auth_mnesia.password_hash", [ + {default, sha256}, + {datatype, {enum, [plain, md5, sha, sha256, sha512]}} +]}. + +{mapping, "auth.client.$id.clientid", "emqx_auth_mnesia.clientid_list", [ + {datatype, string} +]}. + +{mapping, "auth.client.$id.password", "emqx_auth_mnesia.clientid_list", [ + {datatype, string} +]}. + +{translation, "emqx_auth_mnesia.clientid_list", fun(Conf) -> + ClientList = cuttlefish_variable:filter_by_prefix("auth.client", Conf), + lists:foldl( + fun({["auth", "client", Id, "clientid"], ClientId}, AccIn) -> + [{ClientId, cuttlefish:conf_get("auth.client." ++ Id ++ ".password", Conf)} | AccIn]; + (_, AccIn) -> + AccIn + end, [], ClientList) +end}. + +{mapping, "auth.user.$id.username", "emqx_auth_mnesia.username_list", [ + {datatype, string} +]}. + +{mapping, "auth.user.$id.password", "emqx_auth_mnesia.username_list", [ + {datatype, string} +]}. + +{translation, "emqx_auth_mnesia.username_list", fun(Conf) -> + Userlist = cuttlefish_variable:filter_by_prefix("auth.user", Conf), + lists:foldl( + fun({["auth", "user", Id, "username"], Username}, AccIn) -> + [{Username, cuttlefish:conf_get("auth.user." ++ Id ++ ".password", Conf)} | AccIn]; + (_, AccIn) -> + AccIn + end, [], Userlist) +end}. diff --git a/apps/emqx_auth_mnesia/rebar.config b/apps/emqx_auth_mnesia/rebar.config new file mode 100644 index 000000000..4c695ec69 --- /dev/null +++ b/apps/emqx_auth_mnesia/rebar.config @@ -0,0 +1,17 @@ +{deps, + [ ]}. + +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + diff --git a/apps/emqx_auth_mnesia/src/emqx_acl_mnesia.erl b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia.erl new file mode 100644 index 000000000..c657e54a0 --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia.erl @@ -0,0 +1,103 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mnesia). + +-include("emqx_auth_mnesia.hrl"). + +-include_lib("stdlib/include/ms_transform.hrl"). + +-define(TABLE, emqx_acl). + +%% ACL Callbacks +-export([ init/0 + , register_metrics/0 + , check_acl/5 + , description/0 + ]). + +init() -> + ok = ekka_mnesia:create_table(emqx_acl, [ + {disc_copies, [node()]}, + {attributes, record_info(fields, emqx_acl)}, + {storage_properties, [{ets, [{read_concurrency, true}]}]}]), + ok = ekka_mnesia:copy_table(emqx_acl, disc_copies). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(ClientInfo = #{ clientid := Clientid }, PubSub, Topic, _NoMatchAction, _Params) -> + Username = maps:get(username, ClientInfo, undefined), + + Acls = case Username of + undefined -> + emqx_acl_mnesia_cli:lookup_acl({clientid, Clientid}) ++ + emqx_acl_mnesia_cli:lookup_acl(all); + _ -> + emqx_acl_mnesia_cli:lookup_acl({clientid, Clientid}) ++ + emqx_acl_mnesia_cli:lookup_acl({username, Username}) ++ + emqx_acl_mnesia_cli:lookup_acl(all) + end, + + case match(ClientInfo, PubSub, Topic, Acls) of + allow -> + emqx_metrics:inc(?ACL_METRICS(allow)), + {stop, allow}; + deny -> + emqx_metrics:inc(?ACL_METRICS(deny)), + {stop, deny}; + _ -> + emqx_metrics:inc(?ACL_METRICS(ignore)), + ok + end. + +description() -> "Acl with Mnesia". + +%%-------------------------------------------------------------------- +%% Internal functions +%%------------------------------------------------------------------- + +match(_ClientInfo, _PubSub, _Topic, []) -> + nomatch; +match(ClientInfo, PubSub, Topic, [ {_, ACLTopic, Action, Access, _} | Acls]) -> + case match_actions(PubSub, Action) andalso match_topic(ClientInfo, Topic, ACLTopic) of + true -> Access; + false -> match(ClientInfo, PubSub, Topic, Acls) + end. + +match_topic(ClientInfo, Topic, ACLTopic) when is_binary(Topic) -> + emqx_topic:match(Topic, feed_var(ClientInfo, ACLTopic)). + +match_actions(_, pubsub) -> true; +match_actions(subscribe, sub) -> true; +match_actions(publish, pub) -> true; +match_actions(_, _) -> false. + +feed_var(ClientInfo, Pattern) -> + feed_var(ClientInfo, emqx_topic:words(Pattern), []). +feed_var(_ClientInfo, [], Acc) -> + emqx_topic:join(lists:reverse(Acc)); +feed_var(ClientInfo = #{clientid := undefined}, [<<"%c">>|Words], Acc) -> + feed_var(ClientInfo, Words, [<<"%c">>|Acc]); +feed_var(ClientInfo = #{clientid := ClientId}, [<<"%c">>|Words], Acc) -> + feed_var(ClientInfo, Words, [ClientId |Acc]); +feed_var(ClientInfo = #{username := undefined}, [<<"%u">>|Words], Acc) -> + feed_var(ClientInfo, Words, [<<"%u">>|Acc]); +feed_var(ClientInfo = #{username := Username}, [<<"%u">>|Words], Acc) -> + feed_var(ClientInfo, Words, [Username|Acc]); +feed_var(ClientInfo, [W|Words], Acc) -> + feed_var(ClientInfo, Words, [W|Acc]). diff --git a/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_api.erl b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_api.erl new file mode 100644 index 000000000..38087135f --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_api.erl @@ -0,0 +1,231 @@ +%c%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mnesia_api). + +-include("emqx_auth_mnesia.hrl"). + +-include_lib("stdlib/include/ms_transform.hrl"). + +-import(proplists, [ get_value/2 + , get_value/3 + ]). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_clientid, + method => 'GET', + path => "/acl/clientid", + func => list_clientid, + descr => "List available mnesia in the cluster" + }). + +-rest_api(#{name => list_username, + method => 'GET', + path => "/acl/username", + func => list_username, + descr => "List available mnesia in the cluster" + }). + +-rest_api(#{name => list_all, + method => 'GET', + path => "/acl/$all", + func => list_all, + descr => "List available mnesia in the cluster" + }). + +-rest_api(#{name => lookup_clientid, + method => 'GET', + path => "/acl/clientid/:bin:clientid", + func => lookup, + descr => "Lookup mnesia in the cluster" + }). + +-rest_api(#{name => lookup_username, + method => 'GET', + path => "/acl/username/:bin:username", + func => lookup, + descr => "Lookup mnesia in the cluster" + }). + +-rest_api(#{name => add, + method => 'POST', + path => "/acl", + func => add, + descr => "Add mnesia in the cluster" + }). + +-rest_api(#{name => delete_clientid, + method => 'DELETE', + path => "/acl/clientid/:bin:clientid/topic/:bin:topic", + func => delete, + descr => "Delete mnesia in the cluster" + }). + +-rest_api(#{name => delete_username, + method => 'DELETE', + path => "/acl/username/:bin:username/topic/:bin:topic", + func => delete, + descr => "Delete mnesia in the cluster" + }). + +-rest_api(#{name => delete_all, + method => 'DELETE', + path => "/acl/$all/topic/:bin:topic", + func => delete, + descr => "Delete mnesia in the cluster" + }). + + +-export([ list_clientid/2 + , list_username/2 + , list_all/2 + , lookup/2 + , add/2 + , delete/2 + ]). + +list_clientid(_Bindings, Params) -> + MatchSpec = ets:fun2ms( + fun({emqx_acl, {{clientid, Clientid}, Topic}, Action, Access, CreatedAt}) -> {{clientid,Clientid}, Topic, Action,Access, CreatedAt} end), + return({ok, emqx_auth_mnesia_api:paginate(emqx_acl, MatchSpec, Params, fun emqx_acl_mnesia_cli:comparing/2, fun format/1)}). + +list_username(_Bindings, Params) -> + MatchSpec = ets:fun2ms( + fun({emqx_acl, {{username, Username}, Topic}, Action, Access, CreatedAt}) -> {{username, Username}, Topic, Action,Access, CreatedAt} end), + return({ok, emqx_auth_mnesia_api:paginate(emqx_acl, MatchSpec, Params, fun emqx_acl_mnesia_cli:comparing/2, fun format/1)}). + +list_all(_Bindings, Params) -> + MatchSpec = ets:fun2ms( + fun({emqx_acl, {all, Topic}, Action, Access, CreatedAt}) -> {all, Topic, Action,Access, CreatedAt}end + ), + return({ok, emqx_auth_mnesia_api:paginate(emqx_acl, MatchSpec, Params, fun emqx_acl_mnesia_cli:comparing/2, fun format/1)}). + + +lookup(#{clientid := Clientid}, _Params) -> + return({ok, format(emqx_acl_mnesia_cli:lookup_acl({clientid, urldecode(Clientid)}))}); +lookup(#{username := Username}, _Params) -> + return({ok, format(emqx_acl_mnesia_cli:lookup_acl({username, urldecode(Username)}))}). + +add(_Bindings, Params) -> + [ P | _] = Params, + case is_list(P) of + true -> return(do_add(Params, [])); + false -> + Re = do_add(Params), + case Re of + #{result := ok} -> return({ok, Re}); + #{result := <<"ok">>} -> return({ok, Re}); + _ -> return({error, Re}) + end + end. + +do_add([ Params | ParamsN ], ReList) -> + do_add(ParamsN, [do_add(Params) | ReList]); + +do_add([], ReList) -> + {ok, ReList}. + +do_add(Params) -> + Clientid = get_value(<<"clientid">>, Params, undefined), + Username = get_value(<<"username">>, Params, undefined), + Login = case {Clientid, Username} of + {undefined, undefined} -> all; + {_, undefined} -> {clientid, urldecode(Clientid)}; + {undefined, _} -> {username, urldecode(Username)} + end, + Topic = urldecode(get_value(<<"topic">>, Params)), + Action = urldecode(get_value(<<"action">>, Params)), + Access = urldecode(get_value(<<"access">>, Params)), + Re = case validate([login, topic, action, access], [Login, Topic, Action, Access]) of + ok -> + emqx_acl_mnesia_cli:add_acl(Login, Topic, erlang:binary_to_atom(Action, utf8), erlang:binary_to_atom(Access, utf8)); + Err -> Err + end, + maps:merge(#{topic => Topic, + action => Action, + access => Access, + result => format_msg(Re) + }, case Login of + all -> #{all => '$all'}; + _ -> maps:from_list([Login]) + end). + +delete(#{clientid := Clientid, topic := Topic}, _) -> + return(emqx_acl_mnesia_cli:remove_acl({clientid, urldecode(Clientid)}, urldecode(Topic))); +delete(#{username := Username, topic := Topic}, _) -> + return(emqx_acl_mnesia_cli:remove_acl({username, urldecode(Username)}, urldecode(Topic))); +delete(#{topic := Topic}, _) -> + return(emqx_acl_mnesia_cli:remove_acl(all, urldecode(Topic))). + +%%------------------------------------------------------------------------------ +%% Interval Funcs +%%------------------------------------------------------------------------------ +format({{clientid, Clientid}, Topic, Action, Access, _CreatedAt}) -> + #{clientid => Clientid, topic => Topic, action => Action, access => Access}; +format({{username, Username}, Topic, Action, Access, _CreatedAt}) -> + #{username => Username, topic => Topic, action => Action, access => Access}; +format({all, Topic, Action, Access, _CreatedAt}) -> + #{all => '$all', topic => Topic, action => Action, access => Access}; +format(List) when is_list(List) -> + format(List, []). + +format([L | List], Relist) -> + format(List, [format(L) | Relist]); +format([], ReList) -> lists:reverse(ReList). + +validate([], []) -> + ok; +validate([K|Keys], [V|Values]) -> + case do_validation(K, V) of + false -> {error, K}; + true -> validate(Keys, Values) + end. +do_validation(login, all) -> + true; +do_validation(login, {clientid, V}) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(login, {username, V}) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(topic, V) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(action, V) when is_binary(V) -> + case V =:= <<"pub">> orelse V =:= <<"sub">> orelse V =:= <<"pubsub">> of + true -> true; + false -> false + end; +do_validation(access, V) when V =:= <<"allow">> orelse V =:= <<"deny">> -> + true; +do_validation(_, _) -> + false. + +format_msg(Message) + when is_atom(Message); + is_binary(Message) -> Message; + +format_msg(Message) when is_tuple(Message) -> + iolist_to_binary(io_lib:format("~p", [Message])). + +-if(?OTP_RELEASE >= 23). +urldecode(S) -> + [{R, _}] = uri_string:dissect_query(S), R. +-else. +urldecode(S) -> + http_uri:decode(S). +-endif. diff --git a/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_cli.erl b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_cli.erl new file mode 100644 index 000000000..ae4fcee1f --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_acl_mnesia_cli.erl @@ -0,0 +1,226 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mnesia_cli). + +-include("emqx_auth_mnesia.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). +-define(TABLE, emqx_acl). + +%% Acl APIs +-export([ add_acl/4 + , lookup_acl/1 + , all_acls/0 + , all_acls/1 + , remove_acl/2 + ]). + +-export([cli/1]). +-export([comparing/2]). +%%-------------------------------------------------------------------- +%% Acl API +%%-------------------------------------------------------------------- + +%% @doc Add Acls +-spec(add_acl(login() | all, emqx_topic:topic(), pub | sub | pubsub, allow | deny) -> + ok | {error, any()}). +add_acl(Login, Topic, Action, Access) -> + Acls = #?TABLE{ + filter = {Login, Topic}, + action = Action, + access = Access, + created_at = erlang:system_time(millisecond) + }, + ret(mnesia:transaction(fun mnesia:write/1, [Acls])). + +%% @doc Lookup acl by login +-spec(lookup_acl(login() | all) -> list()). +lookup_acl(undefined) -> []; +lookup_acl(Login) -> + MatchSpec = ets:fun2ms(fun({?TABLE, {Filter, ACLTopic}, Action, Access, CreatedAt}) + when Filter =:= Login -> + {Filter, ACLTopic, Action, Access, CreatedAt} + end), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)). + +%% @doc Remove acl +-spec(remove_acl(login() | all, emqx_topic:topic()) -> ok | {error, any()}). +remove_acl(Login, Topic) -> + ret(mnesia:transaction(fun mnesia:delete/1, [{?TABLE, {Login, Topic}}])). + +%% @doc All logins +-spec(all_acls() -> list()). +all_acls() -> + all_acls(clientid) ++ + all_acls(username) ++ + all_acls(all). + +all_acls(clientid) -> + MatchSpec = ets:fun2ms( + fun({?TABLE, {{clientid, Clientid}, Topic}, Action, Access, CreatedAt}) -> + {{clientid, Clientid}, Topic, Action, Access, CreatedAt} + end), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)); +all_acls(username) -> + MatchSpec = ets:fun2ms( + fun({?TABLE, {{username, Username}, Topic}, Action, Access, CreatedAt}) -> + {{username, Username}, Topic, Action, Access, CreatedAt} + end), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)); +all_acls(all) -> + MatchSpec = ets:fun2ms( + fun({?TABLE, {all, Topic}, Action, Access, CreatedAt}) -> + {all, Topic, Action, Access, CreatedAt} + end + ), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)). + +%%-------------------------------------------------------------------- +%% ACL Cli +%%-------------------------------------------------------------------- + +cli(["list"]) -> + [print_acl(Acl) || Acl <- all_acls()]; + +cli(["list", "clientid"]) -> + [print_acl(Acl) || Acl <- all_acls(clientid)]; + +cli(["list", "username"]) -> + [print_acl(Acl) || Acl <- all_acls(username)]; + +cli(["list", "_all"]) -> + [print_acl(Acl) || Acl <- all_acls(all)]; + +cli(["add", "clientid", Clientid, Topic, Action, Access]) -> + case validate(action, Action) andalso validate(access, Access) of + true -> + case add_acl( + {clientid, iolist_to_binary(Clientid)}, + iolist_to_binary(Topic), + list_to_existing_atom(Action), + list_to_existing_atom(Access) + ) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + _ -> + emqx_ctl:print("Error: Input is illegal~n") + end; + +cli(["add", "username", Username, Topic, Action, Access]) -> + case validate(action, Action) andalso validate(access, Access) of + true -> + case add_acl( + {username, iolist_to_binary(Username)}, + iolist_to_binary(Topic), + list_to_existing_atom(Action), + list_to_existing_atom(Access) + ) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + _ -> + emqx_ctl:print("Error: Input is illegal~n") + end; + +cli(["add", "_all", Topic, Action, Access]) -> + case validate(action, Action) andalso validate(access, Access) of + true -> + case add_acl( + all, + iolist_to_binary(Topic), + list_to_existing_atom(Action), + list_to_existing_atom(Access) + ) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + _ -> + emqx_ctl:print("Error: Input is illegal~n") + end; + +cli(["show", "clientid", Clientid]) -> + [print_acl(Acl) || Acl <- lookup_acl({clientid, iolist_to_binary(Clientid)})]; + +cli(["show", "username", Username]) -> + [print_acl(Acl) || Acl <- lookup_acl({username, iolist_to_binary(Username)})]; + +cli(["del", "clientid", Clientid, Topic])-> + case remove_acl({clientid, iolist_to_binary(Clientid)}, iolist_to_binary(Topic)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +cli(["del", "username", Username, Topic])-> + case remove_acl({username, iolist_to_binary(Username)}, iolist_to_binary(Topic)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +cli(["del", "_all", Topic])-> + case remove_acl(all, iolist_to_binary(Topic)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +cli(_) -> + emqx_ctl:usage([ {"acl list clientid", "List clientid acls"} + , {"acl list username", "List username acls"} + , {"acl list _all", "List $all acls"} + , {"acl show clientid ", "Lookup clientid acl detail"} + , {"acl show username ", "Lookup username acl detail"} + , {"acl aad clientid ", "Add clientid acl"} + , {"acl add Username ", "Add username acl"} + , {"acl add _all ", "Add $all acl"} + , {"acl del clientid ", "Delete clientid acl"} + , {"acl del username ", "Delete username acl"} + , {"acl del _all ", "Delete $all acl"} + ]). + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +comparing({_, _, _, _, CreatedAt1}, + {_, _, _, _, CreatedAt2}) -> + CreatedAt1 >= CreatedAt2. + +ret({atomic, ok}) -> ok; +ret({aborted, Error}) -> {error, Error}. + +validate(action, "pub") -> true; +validate(action, "sub") -> true; +validate(action, "pubsub") -> true; +validate(access, "allow") -> true; +validate(access, "deny") -> true; +validate(_, _) -> false. + +print_acl({{clientid, Clientid}, Topic, Action, Access, _}) -> + emqx_ctl:print( + "Acl(clientid = ~p topic = ~p action = ~p access = ~p)~n", + [Clientid, Topic, Action, Access] + ); +print_acl({{username, Username}, Topic, Action, Access, _}) -> + emqx_ctl:print( + "Acl(username = ~p topic = ~p action = ~p access = ~p)~n", + [Username, Topic, Action, Access] + ); +print_acl({all, Topic, Action, Access, _}) -> + emqx_ctl:print( + "Acl($all topic = ~p action = ~p access = ~p)~n", + [Topic, Action, Access] + ). diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src new file mode 100644 index 000000000..f51395f2c --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_mnesia, + [{description, "EMQ X Authentication with Mnesia"}, + {vsn, "4.3.0"}, % strict semver, bump manually + {modules, []}, + {registered, []}, + {applications, [kernel,stdlib,mnesia]}, + {mod, {emqx_auth_mnesia_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-mnesia"} + ]} + ]}. diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.erl b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.erl new file mode 100644 index 000000000..45dbd4573 --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.erl @@ -0,0 +1,109 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia). + +-include("emqx_auth_mnesia.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/types.hrl"). + +-include_lib("stdlib/include/ms_transform.hrl"). + +-define(TABLE, emqx_user). +%% Auth callbacks +-export([ init/1 + , register_metrics/0 + , check/3 + , description/0 + ]). + +init(#{clientid_list := ClientidList, username_list := UsernameList}) -> + ok = ekka_mnesia:create_table(?TABLE, [ + {disc_copies, [node()]}, + {attributes, record_info(fields, emqx_user)}, + {storage_properties, [{ets, [{read_concurrency, true}]}]}]), + _ = [ add_default_user({{clientid, iolist_to_binary(Clientid)}, iolist_to_binary(Password)}) + || {Clientid, Password} <- ClientidList], + _ = [ add_default_user({{username, iolist_to_binary(Username)}, iolist_to_binary(Password)}) + || {Username, Password} <- UsernameList], + ok = ekka_mnesia:copy_table(?TABLE, disc_copies). + +%% @private +add_default_user({Login, Password}) when is_tuple(Login) -> + emqx_auth_mnesia_cli:add_user(Login, Password). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo = #{ clientid := Clientid + , password := NPassword + }, AuthResult, #{hash_type := HashType}) -> + Username = maps:get(username, ClientInfo, undefined), + MatchSpec = ets:fun2ms(fun({?TABLE, {clientid, X}, Password, InterTime}) when X =:= Clientid-> Password; + ({?TABLE, {username, X}, Password, InterTime}) when X =:= Username andalso X =/= undefined -> Password + end), + case ets:select(?TABLE, MatchSpec) of + [] -> + emqx_metrics:inc(?AUTH_METRICS(ignore)), + ok; + List -> + case match_password(NPassword, HashType, List) of + false -> + ?LOG(error, "[Mnesia] Auth from mnesia failed: ~p", [ClientInfo]), + emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{anonymous => false, auth_result => password_error}}; + _ -> + emqx_metrics:inc(?AUTH_METRICS(success)), + {stop, AuthResult#{anonymous => false, auth_result => success}} + end + end. + +description() -> "Authentication with Mnesia". + +match_password(Password, HashType, HashList) -> + lists:any( + fun(Secret) -> + case is_salt_hash(Secret, HashType) of + true -> + <> = Secret, + Hash =:= hash(Password, Salt, HashType); + _ -> + Secret =:= hash(Password, HashType) + end + end, HashList). + +hash(undefined, HashType) -> + hash(<<>>, HashType); +hash(Password, HashType) -> + emqx_passwd:hash(HashType, Password). + +hash(undefined, SaltBin, HashType) -> + hash(<<>>, SaltBin, HashType); +hash(Password, SaltBin, HashType) -> + emqx_passwd:hash(HashType, <>). + +is_salt_hash(_, plain) -> + true; +is_salt_hash(Secret, HashType) -> + not (byte_size(Secret) == len(HashType)). + +len(md5) -> 32; +len(sha) -> 40; +len(sha256) -> 64; +len(sha512) -> 128. diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_api.erl b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_api.erl new file mode 100644 index 000000000..049af79ad --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_api.erl @@ -0,0 +1,310 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia_api). + +-include_lib("stdlib/include/qlc.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). + +-define(TABLE, emqx_user). + +-import(proplists, [get_value/2]). +-import(minirest, [return/1]). +-export([paginate/5]). + +-export([ list_clientid/2 + , lookup_clientid/2 + , add_clientid/2 + , update_clientid/2 + , delete_clientid/2 + ]). + +-rest_api(#{name => list_clientid, + method => 'GET', + path => "/auth_clientid", + func => list_clientid, + descr => "List available clientid in the cluster" + }). + +-rest_api(#{name => lookup_clientid, + method => 'GET', + path => "/auth_clientid/:bin:clientid", + func => lookup_clientid, + descr => "Lookup clientid in the cluster" + }). + +-rest_api(#{name => add_clientid, + method => 'POST', + path => "/auth_clientid", + func => add_clientid, + descr => "Add clientid in the cluster" + }). + +-rest_api(#{name => update_clientid, + method => 'PUT', + path => "/auth_clientid/:bin:clientid", + func => update_clientid, + descr => "Update clientid in the cluster" + }). + +-rest_api(#{name => delete_clientid, + method => 'DELETE', + path => "/auth_clientid/:bin:clientid", + func => delete_clientid, + descr => "Delete clientid in the cluster" + }). + +-export([ list_username/2 + , lookup_username/2 + , add_username/2 + , update_username/2 + , delete_username/2 + ]). + +-rest_api(#{name => list_username, + method => 'GET', + path => "/auth_username", + func => list_username, + descr => "List available username in the cluster" + }). + +-rest_api(#{name => lookup_username, + method => 'GET', + path => "/auth_username/:bin:username", + func => lookup_username, + descr => "Lookup username in the cluster" + }). + +-rest_api(#{name => add_username, + method => 'POST', + path => "/auth_username", + func => add_username, + descr => "Add username in the cluster" + }). + +-rest_api(#{name => update_username, + method => 'PUT', + path => "/auth_username/:bin:username", + func => update_username, + descr => "Update username in the cluster" + }). + +-rest_api(#{name => delete_username, + method => 'DELETE', + path => "/auth_username/:bin:username", + func => delete_username, + descr => "Delete username in the cluster" + }). + +%%------------------------------------------------------------------------------ +%% Auth Clientid Api +%%------------------------------------------------------------------------------ + +list_clientid(_Bindings, Params) -> + MatchSpec = ets:fun2ms(fun({?TABLE, {clientid, Clientid}, Password, CreatedAt}) -> {?TABLE, {clientid, Clientid}, Password, CreatedAt} end), + return({ok, paginate(?TABLE, MatchSpec, Params, fun emqx_auth_mnesia_cli:comparing/2, fun({?TABLE, {clientid, X}, _, _}) -> #{clientid => X} end)}). + +lookup_clientid(#{clientid := Clientid}, _Params) -> + return({ok, format(emqx_auth_mnesia_cli:lookup_user({clientid, urldecode(Clientid)}))}). + +add_clientid(_Bindings, Params) -> + [ P | _] = Params, + case is_list(P) of + true -> return(do_add_clientid(Params, [])); + false -> + Re = do_add_clientid(Params), + case Re of + ok -> return(ok); + {error, Error} -> return({error, format_msg(Error)}) + end + end. + +do_add_clientid([ Params | ParamsN ], ReList ) -> + Clientid = urldecode(get_value(<<"clientid">>, Params)), + do_add_clientid(ParamsN, [{Clientid, format_msg(do_add_clientid(Params))} | ReList]); + +do_add_clientid([], ReList) -> + {ok, ReList}. + +do_add_clientid(Params) -> + Clientid = urldecode(get_value(<<"clientid">>, Params)), + Password = urldecode(get_value(<<"password">>, Params)), + Login = {clientid, Clientid}, + case validate([login, password], [Login, Password]) of + ok -> + emqx_auth_mnesia_cli:add_user(Login, Password); + Err -> Err + end. + +update_clientid(#{clientid := Clientid}, Params) -> + Password = get_value(<<"password">>, Params), + case validate([password], [Password]) of + ok -> return(emqx_auth_mnesia_cli:update_user({clientid, urldecode(Clientid)}, urldecode(Password))); + Err -> return(Err) + end. + +delete_clientid(#{clientid := Clientid}, _) -> + return(emqx_auth_mnesia_cli:remove_user({clientid, urldecode(Clientid)})). + +%%------------------------------------------------------------------------------ +%% Auth Username Api +%%------------------------------------------------------------------------------ + +list_username(_Bindings, Params) -> + MatchSpec = ets:fun2ms(fun({?TABLE, {username, Username}, Password, CreatedAt}) -> {?TABLE, {username, Username}, Password, CreatedAt} end), + return({ok, paginate(?TABLE, MatchSpec, Params, fun emqx_auth_mnesia_cli:comparing/2, fun({?TABLE, {username, X}, _, _}) -> #{username => X} end)}). + +lookup_username(#{username := Username}, _Params) -> + return({ok, format(emqx_auth_mnesia_cli:lookup_user({username, urldecode(Username)}))}). + +add_username(_Bindings, Params) -> + [ P | _] = Params, + case is_list(P) of + true -> return(do_add_username(Params, [])); + false -> + case do_add_username(Params) of + ok -> return(ok); + {error, Error} -> return({error, format_msg(Error)}) + end + end. + +do_add_username([ Params | ParamsN ], ReList ) -> + Username = urldecode(get_value(<<"username">>, Params)), + do_add_username(ParamsN, [{Username, format_msg(do_add_username(Params))} | ReList]); + +do_add_username([], ReList) -> + {ok, ReList}. + +do_add_username(Params) -> + Username = urldecode(get_value(<<"username">>, Params)), + Password = urldecode(get_value(<<"password">>, Params)), + Login = {username, Username}, + case validate([login, password], [Login, Password]) of + ok -> + emqx_auth_mnesia_cli:add_user(Login, Password); + Err -> Err + end. + +update_username(#{username := Username}, Params) -> + Password = get_value(<<"password">>, Params), + case validate([password], [Password]) of + ok -> return(emqx_auth_mnesia_cli:update_user({username, urldecode(Username)}, urldecode(Password))); + Err -> return(Err) + end. + +delete_username(#{username := Username}, _) -> + return(emqx_auth_mnesia_cli:remove_user({username, urldecode(Username)})). + +%%------------------------------------------------------------------------------ +%% Paging Query +%%------------------------------------------------------------------------------ + +paginate(Tables, MatchSpec, Params, ComparingFun, RowFun) -> + Qh = query_handle(Tables, MatchSpec), + Count = count(Tables, MatchSpec), + Page = page(Params), + Limit = limit(Params), + Cursor = qlc:cursor(Qh), + case Page > 1 of + true -> + _ = qlc:next_answers(Cursor, (Page - 1) * Limit), + ok; + false -> ok + end, + Rows = qlc:next_answers(Cursor, Limit), + qlc:delete_cursor(Cursor), + #{meta => #{page => Page, limit => Limit, count => Count}, + data => [RowFun(Row) || Row <- lists:sort(ComparingFun, Rows)]}. + +query_handle(Table, MatchSpec) when is_atom(Table) -> + Options = {traverse, {select, MatchSpec}}, + qlc:q([R|| R <- ets:table(Table, Options)]); +query_handle([Table], MatchSpec) when is_atom(Table) -> + Options = {traverse, {select, MatchSpec}}, + qlc:q([R|| R <- ets:table(Table, Options)]); +query_handle(Tables, MatchSpec) -> + Options = {traverse, {select, MatchSpec}}, + qlc:append([qlc:q([E || E <- ets:table(T, Options)]) || T <- Tables]). + +count(Table, MatchSpec) when is_atom(Table) -> + [{MatchPattern, Where, _Re}] = MatchSpec, + NMatchSpec = [{MatchPattern, Where, [true]}], + ets:select_count(Table, NMatchSpec); +count([Table], MatchSpec) when is_atom(Table) -> + [{MatchPattern, Where, _Re}] = MatchSpec, + NMatchSpec = [{MatchPattern, Where, [true]}], + ets:select_count(Table, NMatchSpec); +count(Tables, MatchSpec) -> + lists:sum([count(T, MatchSpec) || T <- Tables]). + +page(Params) -> + binary_to_integer(proplists:get_value(<<"_page">>, Params, <<"1">>)). + +limit(Params) -> + case proplists:get_value(<<"_limit">>, Params) of + undefined -> 10; + Size -> binary_to_integer(Size) + end. + +%%------------------------------------------------------------------------------ +%% Interval Funcs +%%------------------------------------------------------------------------------ + +format([{?TABLE, {clientid, ClientId}, Password, _InterTime}]) -> + #{clientid => ClientId, + password => Password}; + +format([{?TABLE, {username, Username}, Password, _InterTime}]) -> + #{username => Username, + password => Password}; + +format([]) -> + #{}. + +validate([], []) -> + ok; +validate([K|Keys], [V|Values]) -> + case do_validation(K, V) of + false -> {error, K}; + true -> validate(Keys, Values) + end. + +do_validation(login, {clientid, V}) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(login, {username, V}) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(password, V) when is_binary(V) + andalso byte_size(V) > 0 -> + true; +do_validation(_, _) -> + false. + +format_msg(Message) + when is_atom(Message); + is_binary(Message) -> Message; + +format_msg(Message) when is_tuple(Message) -> + iolist_to_binary(io_lib:format("~p", [Message])). + +-if(?OTP_RELEASE >= 23). +urldecode(S) -> + [{R, _}] = uri_string:dissect_query(S), R. +-else. +urldecode(S) -> + http_uri:decode(S). +-endif. diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_app.erl b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_app.erl new file mode 100644 index 000000000..e5941ea07 --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_app.erl @@ -0,0 +1,68 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_mnesia.hrl"). + +%% Application callbacks +-export([ start/2 + , prep_stop/1 + , stop/1 + ]). + +%%-------------------------------------------------------------------- +%% Application callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_mnesia_sup:start_link(), + emqx_ctl:register_command(clientid, {emqx_auth_mnesia_cli, auth_clientid_cli}, []), + emqx_ctl:register_command(user, {emqx_auth_mnesia_cli, auth_username_cli}, []), + emqx_ctl:register_command(acl, {emqx_acl_mnesia_cli, cli}, []), + _ = load_auth_hook(), + _ = load_acl_hook(), + {ok, Sup}. + +prep_stop(State) -> + emqx:unhook('client.authenticate', fun emqx_auth_mnesia:check/3), + emqx:unhook('client.check_acl', fun emqx_acl_mnesia:check_acl/5), + emqx_ctl:unregister_command(clientid), + emqx_ctl:unregister_command(user), + emqx_ctl:unregister_command(acl), + State. + +stop(_State) -> + ok. + +load_auth_hook() -> + ClientidList = application:get_env(?APP, clientid_list, []), + UsernameList = application:get_env(?APP, username_list, []), + ok = emqx_auth_mnesia:init(#{clientid_list => ClientidList, username_list => UsernameList}), + ok = emqx_auth_mnesia:register_metrics(), + Params = #{ + hash_type => application:get_env(emqx_auth_mnesia, hash_type, sha256) + }, + emqx:hook('client.authenticate', fun emqx_auth_mnesia:check/3, [Params]). + +load_acl_hook() -> + ok = emqx_acl_mnesia:init(), + ok = emqx_acl_mnesia:register_metrics(), + emqx:hook('client.check_acl', fun emqx_acl_mnesia:check_acl/5, [#{}]). diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_cli.erl b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_cli.erl new file mode 100644 index 000000000..e7c9d982b --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_cli.erl @@ -0,0 +1,188 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia_cli). + +-include("emqx_auth_mnesia.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). +-define(TABLE, emqx_user). +%% Auth APIs +-export([ add_user/2 + , update_user/2 + , remove_user/1 + , lookup_user/1 + , all_users/0 + , all_users/1 + ]). +%% Cli +-export([ auth_clientid_cli/1 + , auth_username_cli/1 + ]). + +%% Helper +-export([comparing/2]). + +%%-------------------------------------------------------------------- +%% Auth APIs +%%-------------------------------------------------------------------- + +%% @doc Add User +-spec(add_user(tuple(), binary()) -> ok | {error, any()}). +add_user(Login, Password) -> + User = #emqx_user{ + login = Login, + password = encrypted_data(Password), + created_at = erlang:system_time(millisecond) + }, + ret(mnesia:transaction(fun insert_user/1, [User])). + +insert_user(User = #emqx_user{login = Login}) -> + case mnesia:read(?TABLE, Login) of + [] -> mnesia:write(User); + [_|_] -> mnesia:abort(existed) + end. + +%% @doc Update User +-spec(update_user(tuple(), binary()) -> ok | {error, any()}). +update_user(Login, NewPassword) -> + User = #emqx_user{login = Login, password = encrypted_data(NewPassword)}, + ret(mnesia:transaction(fun do_update_user/1, [User])). + +do_update_user(User = #emqx_user{login = Login}) -> + case mnesia:read(?TABLE, Login) of + [{?TABLE, Login, _, CreateAt}] -> mnesia:write(User#emqx_user{created_at = CreateAt}); + [] -> mnesia:abort(noexisted) + end. + +%% @doc Lookup user by login +-spec(lookup_user(tuple()) -> list()). +lookup_user(undefined) -> []; +lookup_user(Login) -> + Re = mnesia:dirty_read(?TABLE, Login), + lists:sort(fun comparing/2, Re). + +%% @doc Remove user +-spec(remove_user(tuple()) -> ok | {error, any()}). +remove_user(Login) -> + ret(mnesia:transaction(fun mnesia:delete/1, [{?TABLE, Login}])). + +%% @doc All logins +-spec(all_users() -> list()). +all_users() -> mnesia:dirty_all_keys(?TABLE). + +all_users(clientid) -> + MatchSpec = ets:fun2ms( + fun({?TABLE, {clientid, Clientid}, Password, CreatedAt}) -> + {?TABLE, {clientid, Clientid}, Password, CreatedAt} + end), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)); +all_users(username) -> + MatchSpec = ets:fun2ms( + fun({?TABLE, {username, Username}, Password, CreatedAt}) -> + {?TABLE, {username, Username}, Password, CreatedAt} + end), + lists:sort(fun comparing/2, ets:select(?TABLE, MatchSpec)). + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +comparing({?TABLE, _, _, CreatedAt1}, + {?TABLE, _, _, CreatedAt2}) -> + CreatedAt1 >= CreatedAt2. + +ret({atomic, ok}) -> ok; +ret({aborted, Error}) -> {error, Error}. + +encrypted_data(Password) -> + HashType = application:get_env(emqx_auth_mnesia, password_hash, sha256), + SaltBin = salt(), + <>. + +hash(undefined, SaltBin, HashType) -> + hash(<<>>, SaltBin, HashType); +hash(Password, SaltBin, HashType) -> + emqx_passwd:hash(HashType, <>). + +salt() -> + rand:seed(exsplus, erlang:timestamp()), + Salt = rand:uniform(16#ffffffff), <>. + +%%-------------------------------------------------------------------- +%% Auth Clientid Cli +%%-------------------------------------------------------------------- + +auth_clientid_cli(["list"]) -> + [emqx_ctl:print("~s~n", [ClientId]) + || {?TABLE, {clientid, ClientId}, _Password, _CreatedAt} <- all_users(clientid) + ]; + +auth_clientid_cli(["add", ClientId, Password]) -> + case add_user({clientid, iolist_to_binary(ClientId)}, iolist_to_binary(Password)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +auth_clientid_cli(["update", ClientId, NewPassword]) -> + case update_user({clientid, iolist_to_binary(ClientId)}, iolist_to_binary(NewPassword)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +auth_clientid_cli(["del", ClientId]) -> + case remove_user({clientid, iolist_to_binary(ClientId)}) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +auth_clientid_cli(_) -> + emqx_ctl:usage([{"clientid list", "List clientid auth rules"}, + {"clientid add ", "Add clientid auth rule"}, + {"clientid update ", "Update clientid auth rule"}, + {"clientid del ", "Delete clientid auth rule"}]). + +%%-------------------------------------------------------------------- +%% Auth Username Cli +%%-------------------------------------------------------------------- + +auth_username_cli(["list"]) -> + [emqx_ctl:print("~s~n", [Username]) + || {?TABLE, {username, Username}, _Password, _CreatedAt} <- all_users(username) + ]; + +auth_username_cli(["add", Username, Password]) -> + case add_user({username, iolist_to_binary(Username)}, iolist_to_binary(Password)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +auth_username_cli(["update", Username, NewPassword]) -> + case update_user({username, iolist_to_binary(Username)}, iolist_to_binary(NewPassword)) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; +auth_username_cli(["del", Username]) -> + case remove_user({username, iolist_to_binary(Username)}) of + ok -> emqx_ctl:print("ok~n"); + {error, Reason} -> emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +auth_username_cli(_) -> + emqx_ctl:usage([{"user list", "List username auth rules"}, + {"user add ", "Add username auth rule"}, + {"user update ", "Update username auth rule"}, + {"user del ", "Delete username auth rule"}]). diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_sup.erl b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_sup.erl new file mode 100644 index 000000000..ed32b31e7 --- /dev/null +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia_sup.erl @@ -0,0 +1,36 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia_sup). + +-behaviour(supervisor). + +-include("emqx_auth_mnesia.hrl"). + +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +%%-------------------------------------------------------------------- +%% Supervisor callbacks +%%-------------------------------------------------------------------- + +init([]) -> + {ok, {{one_for_one, 10, 100}, []}}. \ No newline at end of file diff --git a/apps/emqx_auth_mnesia/test/emqx_acl_mnesia_SUITE.erl b/apps/emqx_auth_mnesia/test/emqx_acl_mnesia_SUITE.erl new file mode 100644 index 000000000..b2c0b8b5f --- /dev/null +++ b/apps/emqx_auth_mnesia/test/emqx_acl_mnesia_SUITE.erl @@ -0,0 +1,257 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mnesia_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include("emqx_auth_mnesia.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-import(emqx_ct_http, [ request_api/3 + , request_api/5 + , get_http_data/1 + , create_default_app/0 + , delete_default_app/0 + , default_auth_header/0 + ]). + +-define(HOST, "http://127.0.0.1:8081/"). +-define(API_VERSION, "v4"). +-define(BASE_PATH, "api"). + +all() -> + emqx_ct:all(?MODULE). + +groups() -> + []. + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx_management, emqx_auth_mnesia], fun set_special_configs/1), + create_default_app(), + Config. + +end_per_suite(_Config) -> + delete_default_app(), + emqx_ct_helpers:stop_apps([emqx_management, emqx_auth_mnesia]). + +init_per_testcase(t_check_acl_as_clientid, Config) -> + emqx:hook('client.check_acl', fun emqx_acl_mnesia:check_acl/5, [#{key_as => clientid}]), + Config; + +init_per_testcase(_, Config) -> + emqx:hook('client.check_acl', fun emqx_acl_mnesia:check_acl/5, [#{key_as => username}]), + Config. + +end_per_testcase(_, Config) -> + emqx:unhook('client.check_acl', fun emqx_acl_mnesia:check_acl/5), + Config. + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, true), + application:set_env(emqx, enable_acl_cache, false), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); + +set_special_configs(_App) -> + ok. + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_management(_Config) -> + clean_all_acls(), + ?assertEqual("Acl with Mnesia", emqx_acl_mnesia:description()), + ?assertEqual([], emqx_acl_mnesia_cli:all_acls()), + + ok = emqx_acl_mnesia_cli:add_acl({clientid, <<"test_clientid">>}, <<"topic/%c">>, sub, allow), + ok = emqx_acl_mnesia_cli:add_acl({clientid, <<"test_clientid">>}, <<"topic/+">>, pub, deny), + ok = emqx_acl_mnesia_cli:add_acl({username, <<"test_username">>}, <<"topic/%u">>, sub, deny), + ok = emqx_acl_mnesia_cli:add_acl({username, <<"test_username">>}, <<"topic/+">>, pub, allow), + ok = emqx_acl_mnesia_cli:add_acl(all, <<"#">>, pubsub, deny), + + ?assertEqual(2, length(emqx_acl_mnesia_cli:lookup_acl({clientid, <<"test_clientid">>}))), + ?assertEqual(2, length(emqx_acl_mnesia_cli:lookup_acl({username, <<"test_username">>}))), + ?assertEqual(1, length(emqx_acl_mnesia_cli:lookup_acl(all))), + ?assertEqual(5, length(emqx_acl_mnesia_cli:all_acls())), + + User1 = #{zone => external, clientid => <<"test_clientid">>}, + User2 = #{zone => external, clientid => <<"no_exist">>, username => <<"test_username">>}, + User3 = #{zone => external, clientid => <<"test_clientid">>, username => <<"test_username">>}, + allow = emqx_access_control:check_acl(User1, subscribe, <<"topic/test_clientid">>), + deny = emqx_access_control:check_acl(User1, publish, <<"topic/A">>), + deny = emqx_access_control:check_acl(User2, subscribe, <<"topic/test_username">>), + allow = emqx_access_control:check_acl(User2, publish, <<"topic/A">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"topic/test_clientid">>), + deny = emqx_access_control:check_acl(User3, subscribe, <<"topic/test_username">>), + deny = emqx_access_control:check_acl(User3, publish, <<"topic/A">>), + deny = emqx_access_control:check_acl(User3, subscribe, <<"topic/A/B">>), + deny = emqx_access_control:check_acl(User3, publish, <<"topic/A/B">>), + + ok = emqx_acl_mnesia_cli:remove_acl({clientid, <<"test_clientid">>}, <<"topic/%c">>), + ok = emqx_acl_mnesia_cli:remove_acl({clientid, <<"test_clientid">>}, <<"topic/+">>), + ok = emqx_acl_mnesia_cli:remove_acl({username, <<"test_username">>}, <<"topic/%u">>), + ok = emqx_acl_mnesia_cli:remove_acl({username, <<"test_username">>}, <<"topic/+">>), + ok = emqx_acl_mnesia_cli:remove_acl(all, <<"#">>), + + ?assertEqual([], emqx_acl_mnesia_cli:all_acls()). + +t_acl_cli(_Config) -> + meck:new(emqx_ctl, [non_strict, passthrough]), + meck:expect(emqx_ctl, print, fun(Arg) -> emqx_ctl:format(Arg) end), + meck:expect(emqx_ctl, print, fun(Msg, Arg) -> emqx_ctl:format(Msg, Arg) end), + meck:expect(emqx_ctl, usage, fun(Usages) -> emqx_ctl:format_usage(Usages) end), + meck:expect(emqx_ctl, usage, fun(Cmd, Descr) -> emqx_ctl:format_usage(Cmd, Descr) end), + + clean_all_acls(), + + ?assertEqual(0, length(emqx_acl_mnesia_cli:cli(["list"]))), + + emqx_acl_mnesia_cli:cli(["add", "clientid", "test_clientid", "topic/A", "pub", "allow"]), + R1 = emqx_ctl:format("Acl(clientid = ~p topic = ~p action = ~p access = ~p)~n", + [<<"test_clientid">>, <<"topic/A">>, pub, allow]), + ?assertEqual([R1], emqx_acl_mnesia_cli:cli(["show", "clientid", "test_clientid"])), + ?assertEqual([R1], emqx_acl_mnesia_cli:cli(["list", "clientid"])), + + emqx_acl_mnesia_cli:cli(["add", "username", "test_username", "topic/B", "sub", "deny"]), + R2 = emqx_ctl:format("Acl(username = ~p topic = ~p action = ~p access = ~p)~n", + [<<"test_username">>, <<"topic/B">>, sub, deny]), + ?assertEqual([R2], emqx_acl_mnesia_cli:cli(["show", "username", "test_username"])), + ?assertEqual([R2], emqx_acl_mnesia_cli:cli(["list", "username"])), + + emqx_acl_mnesia_cli:cli(["add", "_all", "#", "pubsub", "deny"]), + ?assertMatch(["Acl($all topic = <<\"#\">> action = pubsub access = deny)\n"], + emqx_acl_mnesia_cli:cli(["list", "_all"]) + ), + ?assertEqual(3, length(emqx_acl_mnesia_cli:cli(["list"]))), + + emqx_acl_mnesia_cli:cli(["del", "clientid", "test_clientid", "topic/A"]), + emqx_acl_mnesia_cli:cli(["del", "username", "test_username", "topic/B"]), + emqx_acl_mnesia_cli:cli(["del", "_all", "#"]), + ?assertEqual(0, length(emqx_acl_mnesia_cli:cli(["list"]))), + + meck:unload(emqx_ctl). + +t_rest_api(_Config) -> + clean_all_acls(), + + Params1 = [#{<<"clientid">> => <<"test_clientid">>, + <<"topic">> => <<"topic/A">>, + <<"action">> => <<"pub">>, + <<"access">> => <<"allow">> + }, + #{<<"clientid">> => <<"test_clientid">>, + <<"topic">> => <<"topic/B">>, + <<"action">> => <<"sub">>, + <<"access">> => <<"allow">> + }, + #{<<"clientid">> => <<"test_clientid">>, + <<"topic">> => <<"topic/C">>, + <<"action">> => <<"pubsub">>, + <<"access">> => <<"deny">> + }], + {ok, _} = request_http_rest_add([], Params1), + {ok, Re1} = request_http_rest_list(["clientid", "test_clientid"]), + ?assertMatch(3, length(get_http_data(Re1))), + {ok, _} = request_http_rest_delete(["clientid", "test_clientid", "topic", "topic/A"]), + {ok, _} = request_http_rest_delete(["clientid", "test_clientid", "topic", "topic/B"]), + {ok, _} = request_http_rest_delete(["clientid", "test_clientid", "topic", "topic/C"]), + {ok, Res1} = request_http_rest_list(["clientid"]), + ?assertMatch([], get_http_data(Res1)), + + Params2 = [#{<<"username">> => <<"test_username">>, + <<"topic">> => <<"topic/A">>, + <<"action">> => <<"pub">>, + <<"access">> => <<"allow">> + }, + #{<<"username">> => <<"test_username">>, + <<"topic">> => <<"topic/B">>, + <<"action">> => <<"sub">>, + <<"access">> => <<"allow">> + }, + #{<<"username">> => <<"test_username">>, + <<"topic">> => <<"topic/C">>, + <<"action">> => <<"pubsub">>, + <<"access">> => <<"deny">> + }], + {ok, _} = request_http_rest_add([], Params2), + {ok, Re2} = request_http_rest_list(["username", "test_username"]), + ?assertMatch(3, length(get_http_data(Re2))), + {ok, _} = request_http_rest_delete(["username", "test_username", "topic", "topic/A"]), + {ok, _} = request_http_rest_delete(["username", "test_username", "topic", "topic/B"]), + {ok, _} = request_http_rest_delete(["username", "test_username", "topic", "topic/C"]), + {ok, Res2} = request_http_rest_list(["username"]), + ?assertMatch([], get_http_data(Res2)), + + Params3 = [#{<<"topic">> => <<"topic/A">>, + <<"action">> => <<"pub">>, + <<"access">> => <<"allow">> + }, + #{<<"topic">> => <<"topic/B">>, + <<"action">> => <<"sub">>, + <<"access">> => <<"allow">> + }, + #{<<"topic">> => <<"topic/C">>, + <<"action">> => <<"pubsub">>, + <<"access">> => <<"deny">> + }], + {ok, _} = request_http_rest_add([], Params3), + {ok, Re3} = request_http_rest_list(["$all"]), + ?assertMatch(3, length(get_http_data(Re3))), + {ok, _} = request_http_rest_delete(["$all", "topic", "topic/A"]), + {ok, _} = request_http_rest_delete(["$all", "topic", "topic/B"]), + {ok, _} = request_http_rest_delete(["$all", "topic", "topic/C"]), + {ok, Res3} = request_http_rest_list(["$all"]), + ?assertMatch([], get_http_data(Res3)). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +clean_all_acls() -> + [ mnesia:dirty_delete({emqx_acl, Login}) + || Login <- mnesia:dirty_all_keys(emqx_acl)]. + +%%-------------------------------------------------------------------- +%% HTTP Request +%%-------------------------------------------------------------------- + +request_http_rest_list(Path) -> + request_api(get, uri(Path), default_auth_header()). + +request_http_rest_lookup(Path) -> + request_api(get, uri(Path), default_auth_header()). + +request_http_rest_add(Path, Params) -> + request_api(post, uri(Path), [], default_auth_header(), Params). + +request_http_rest_delete(Path) -> + request_api(delete, uri(Path), default_auth_header()). + +uri() -> uri([]). +uri(Parts) when is_list(Parts) -> + NParts = [b2l(E) || E <- Parts], + ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION, "acl"| NParts]). + +%% @private +b2l(B) when is_binary(B) -> + http_uri:encode(binary_to_list(B)); +b2l(L) when is_list(L) -> + http_uri:encode(L). diff --git a/apps/emqx_auth_mnesia/test/emqx_auth_mnesia_SUITE.erl b/apps/emqx_auth_mnesia/test/emqx_auth_mnesia_SUITE.erl new file mode 100644 index 000000000..1c3dc50b4 --- /dev/null +++ b/apps/emqx_auth_mnesia/test/emqx_auth_mnesia_SUITE.erl @@ -0,0 +1,314 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mnesia_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include("emqx_auth_mnesia.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-import(emqx_ct_http, [ request_api/3 + , request_api/5 + , get_http_data/1 + , create_default_app/0 + , delete_default_app/0 + , default_auth_header/0 + ]). + +-define(HOST, "http://127.0.0.1:8081/"). +-define(API_VERSION, "v4"). +-define(BASE_PATH, "api"). + +-define(TABLE, emqx_user). +-define(CLIENTID, <<"clientid_for_ct">>). +-define(USERNAME, <<"username_for_ct">>). +-define(PASSWORD, <<"password">>). +-define(NPASSWORD, <<"new_password">>). + +all() -> + emqx_ct:all(?MODULE). + +groups() -> + []. + +init_per_suite(Config) -> + ok = emqx_ct_helpers:start_apps([emqx_management, emqx_auth_mnesia], fun set_special_configs/1), + create_default_app(), + Config. + +end_per_suite(_Config) -> + delete_default_app(), + emqx_ct_helpers:stop_apps([emqx_management, emqx_auth_mnesia]). + +init_per_testcase(t_check_as_clientid, Config) -> + Params = #{ + hash_type => application:get_env(emqx_auth_mnesia, hash_type, sha256), + key_as => clientid + }, + emqx:hook('client.authenticate', fun emqx_auth_mnesia:check/3, [Params]), + Config; + +init_per_testcase(_, Config) -> + Params = #{ + hash_type => application:get_env(emqx_auth_mnesia, hash_type, sha256), + key_as => username + }, + emqx:hook('client.authenticate', fun emqx_auth_mnesia:check/3, [Params]), + Config. + +end_per_suite(_, Config) -> + emqx:unhook('client.authenticate', fun emqx_auth_mnesia:check/3), + Config. + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, true), + application:set_env(emqx, enable_acl_cache, false), + LoadedPluginPath = filename:join(["test", "emqx_SUITE_data", "loaded_plugins"]), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); + +set_special_configs(_App) -> + ok. + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_management(_Config) -> + clean_all_users(), + + ok = emqx_auth_mnesia_cli:add_user({username, ?USERNAME}, ?PASSWORD), + {error, existed} = emqx_auth_mnesia_cli:add_user({username, ?USERNAME}, ?PASSWORD), + ?assertMatch([{?TABLE, {username, ?USERNAME}, _, _}], + emqx_auth_mnesia_cli:all_users(username) + ), + + ok = emqx_auth_mnesia_cli:add_user({clientid, ?CLIENTID}, ?PASSWORD), + {error, existed} = emqx_auth_mnesia_cli:add_user({clientid, ?CLIENTID}, ?PASSWORD), + ?assertMatch([{?TABLE, {clientid, ?CLIENTID}, _, _}], + emqx_auth_mnesia_cli:all_users(clientid) + ), + + ?assertEqual(2, length(emqx_auth_mnesia_cli:all_users())), + + ok = emqx_auth_mnesia_cli:update_user({username, ?USERNAME}, ?NPASSWORD), + {error, noexisted} = emqx_auth_mnesia_cli:update_user( + {username, <<"no_existed_user">>}, ?PASSWORD + ), + + ok = emqx_auth_mnesia_cli:update_user({clientid, ?CLIENTID}, ?NPASSWORD), + {error, noexisted} = emqx_auth_mnesia_cli:update_user( + {clientid, <<"no_existed_user">>}, ?PASSWORD + ), + + ?assertMatch([{?TABLE, {username, ?USERNAME}, _, _}], + emqx_auth_mnesia_cli:lookup_user({username, ?USERNAME}) + ), + ?assertMatch([{?TABLE, {clientid, ?CLIENTID}, _, _}], + emqx_auth_mnesia_cli:lookup_user({clientid, ?CLIENTID}) + ), + + User1 = #{username => ?USERNAME, + clientid => undefined, + password => ?NPASSWORD, + zone => external}, + + {ok, #{auth_result := success, + anonymous := false}} = emqx_access_control:authenticate(User1), + + {error, password_error} = emqx_access_control:authenticate( + User1#{password => <<"error_password">>} + ), + + ok = emqx_auth_mnesia_cli:remove_user({username, ?USERNAME}), + {ok, #{auth_result := success, + anonymous := true }} = emqx_access_control:authenticate(User1), + + User2 = #{clientid => ?CLIENTID, + password => ?NPASSWORD, + zone => external}, + + {ok, #{auth_result := success, + anonymous := false}} = emqx_access_control:authenticate(User2), + + {error, password_error} = emqx_access_control:authenticate( + User2#{password => <<"error_password">>} + ), + + ok = emqx_auth_mnesia_cli:remove_user({clientid, ?CLIENTID}), + {ok, #{auth_result := success, + anonymous := true }} = emqx_access_control:authenticate(User2), + + [] = emqx_auth_mnesia_cli:all_users(). + +t_auth_clientid_cli(_) -> + clean_all_users(), + + HashType = application:get_env(emqx_auth_mnesia, password_hash, sha256), + + emqx_auth_mnesia_cli:auth_clientid_cli(["add", ?CLIENTID, ?PASSWORD]), + [{_, {clientid, ?CLIENTID}, + <>, + _}] = emqx_auth_mnesia_cli:lookup_user({clientid, ?CLIENTID}), + ?assertEqual(Hash, emqx_passwd:hash(HashType, <>)), + + emqx_auth_mnesia_cli:auth_clientid_cli(["update", ?CLIENTID, ?NPASSWORD]), + [{_, {clientid, ?CLIENTID}, + <>, + _}] = emqx_auth_mnesia_cli:lookup_user({clientid, ?CLIENTID}), + ?assertEqual(Hash1, emqx_passwd:hash(HashType, <>)), + + emqx_auth_mnesia_cli:auth_clientid_cli(["del", ?CLIENTID]), + ?assertEqual([], emqx_auth_mnesia_cli:lookup_user(?CLIENTID)), + + emqx_auth_mnesia_cli:auth_clientid_cli(["add", "user1", "pass1"]), + emqx_auth_mnesia_cli:auth_clientid_cli(["add", "user2", "pass2"]), + ?assertEqual(2, length(emqx_auth_mnesia_cli:auth_clientid_cli(["list"]))), + + emqx_auth_mnesia_cli:auth_clientid_cli(usage). + +t_auth_username_cli(_) -> + clean_all_users(), + + HashType = application:get_env(emqx_auth_mnesia, password_hash, sha256), + + emqx_auth_mnesia_cli:auth_username_cli(["add", ?USERNAME, ?PASSWORD]), + [{_, {username, ?USERNAME}, + <>, + _}] = emqx_auth_mnesia_cli:lookup_user({username, ?USERNAME}), + ?assertEqual(Hash, emqx_passwd:hash(HashType, <>)), + + emqx_auth_mnesia_cli:auth_username_cli(["update", ?USERNAME, ?NPASSWORD]), + [{_, {username, ?USERNAME}, + <>, + _}] = emqx_auth_mnesia_cli:lookup_user({username, ?USERNAME}), + ?assertEqual(Hash1, emqx_passwd:hash(HashType, <>)), + + emqx_auth_mnesia_cli:auth_username_cli(["del", ?USERNAME]), + ?assertEqual([], emqx_auth_mnesia_cli:lookup_user(?USERNAME)), + + emqx_auth_mnesia_cli:auth_username_cli(["add", "user1", "pass1"]), + emqx_auth_mnesia_cli:auth_username_cli(["add", "user2", "pass2"]), + ?assertEqual(2, length(emqx_auth_mnesia_cli:auth_username_cli(["list"]))), + + emqx_auth_mnesia_cli:auth_username_cli(usage). + + +t_clientid_rest_api(_Config) -> + clean_all_users(), + + {ok, Result1} = request_http_rest_list(["auth_clientid"]), + [] = get_http_data(Result1), + + Params1 = #{<<"clientid">> => ?CLIENTID, <<"password">> => ?PASSWORD}, + {ok, _} = request_http_rest_add(["auth_clientid"], Params1), + + Path = ["auth_clientid/" ++ binary_to_list(?CLIENTID)], + Params2 = #{<<"clientid">> => ?CLIENTID, <<"password">> => ?NPASSWORD}, + {ok, _} = request_http_rest_update(Path, Params2), + + {ok, Result2} = request_http_rest_lookup(Path), + ?assertMatch(#{<<"clientid">> := ?CLIENTID}, get_http_data(Result2)), + + Params3 = [ #{<<"clientid">> => ?CLIENTID, <<"password">> => ?PASSWORD} + , #{<<"clientid">> => <<"clientid1">>, <<"password">> => ?PASSWORD} + , #{<<"clientid">> => <<"clientid2">>, <<"password">> => ?PASSWORD} + ], + {ok, Result3} = request_http_rest_add(["auth_clientid"], Params3), + ?assertMatch(#{ ?CLIENTID := <<"{error,existed}">> + , <<"clientid1">> := <<"ok">> + , <<"clientid2">> := <<"ok">> + }, get_http_data(Result3)), + + {ok, Result4} = request_http_rest_list(["auth_clientid"]), + ?assertEqual(3, length(get_http_data(Result4))), + + {ok, _} = request_http_rest_delete(Path), + {ok, Result5} = request_http_rest_lookup(Path), + ?assertMatch(#{}, get_http_data(Result5)). + +t_username_rest_api(_Config) -> + clean_all_users(), + + {ok, Result1} = request_http_rest_list(["auth_username"]), + [] = get_http_data(Result1), + + Params1 = #{<<"username">> => ?USERNAME, <<"password">> => ?PASSWORD}, + {ok, _} = request_http_rest_add(["auth_username"], Params1), + + Path = ["auth_username/" ++ binary_to_list(?USERNAME)], + Params2 = #{<<"username">> => ?USERNAME, <<"password">> => ?NPASSWORD}, + {ok, _} = request_http_rest_update(Path, Params2), + + {ok, Result2} = request_http_rest_lookup(Path), + ?assertMatch(#{<<"username">> := ?USERNAME}, get_http_data(Result2)), + + Params3 = [ #{<<"username">> => ?USERNAME, <<"password">> => ?PASSWORD} + , #{<<"username">> => <<"username1">>, <<"password">> => ?PASSWORD} + , #{<<"username">> => <<"username2">>, <<"password">> => ?PASSWORD} + ], + {ok, Result3} = request_http_rest_add(["auth_username"], Params3), + ?assertMatch(#{ ?USERNAME := <<"{error,existed}">> + , <<"username1">> := <<"ok">> + , <<"username2">> := <<"ok">> + }, get_http_data(Result3)), + + {ok, Result4} = request_http_rest_list(["auth_username"]), + ?assertEqual(3, length(get_http_data(Result4))), + + {ok, _} = request_http_rest_delete(Path), + {ok, Result5} = request_http_rest_lookup([Path]), + ?assertMatch(#{}, get_http_data(Result5)). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +clean_all_users() -> + [ mnesia:dirty_delete({emqx_user, Login}) + || Login <- mnesia:dirty_all_keys(emqx_user)]. + +%%-------------------------------------------------------------------- +%% HTTP Request +%%-------------------------------------------------------------------- + +request_http_rest_list(Path) -> + request_api(get, uri(Path), default_auth_header()). + +request_http_rest_lookup(Path) -> + request_api(get, uri([Path]), default_auth_header()). + +request_http_rest_add(Path, Params) -> + request_api(post, uri(Path), [], default_auth_header(), Params). + +request_http_rest_update(Path, Params) -> + request_api(put, uri([Path]), [], default_auth_header(), Params). + +request_http_rest_delete(Login) -> + request_api(delete, uri([Login]), default_auth_header()). + +uri() -> uri([]). +uri(Parts) when is_list(Parts) -> + NParts = [b2l(E) || E <- Parts], + ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION | NParts]). + +%% @private +b2l(B) when is_binary(B) -> + binary_to_list(B); +b2l(L) when is_list(L) -> + L. diff --git a/apps/emqx_auth_mongo/.gitignore b/apps/emqx_auth_mongo/.gitignore new file mode 100644 index 000000000..a6635ffa0 --- /dev/null +++ b/apps/emqx_auth_mongo/.gitignore @@ -0,0 +1,24 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.DS_Store +.erlang.mk/ +emqx_auth_mongo.d +ct.coverdata +logs/ +test/ct.cover.spec +data/ +cover/ +eunit.coverdata +_build/ +rebar.lock +erlang.mk +etc/emqx_auth_mongo.conf.rendered +.rebar3 diff --git a/apps/emqx_auth_mongo/CHANGES b/apps/emqx_auth_mongo/CHANGES new file mode 100644 index 000000000..4bddd63a9 --- /dev/null +++ b/apps/emqx_auth_mongo/CHANGES @@ -0,0 +1,31 @@ + +2.0.7 (2017-01-20) +------------------ + +Tag 2.0.7 - use `cuttlefish:unset()` for commented ACL/super config + +2.0.1 (2016-11-30) +------------------ + +Tag 2.0.1 + +2.0-beta.1 (2016-08-24) +----------------------- + +gen_conf + +1.1.3-beta (2016-08-19) +----------------------- + +Bump version to 1.1.3 + +1.1.2-beta (2016-06-30) +----------------------- + +Bump version to 1.1.2 + +1.1-beta (2016-05-28) +--------------------- + +First public release + diff --git a/apps/emqx_auth_mongo/README.md b/apps/emqx_auth_mongo/README.md new file mode 100644 index 000000000..3bacfca5b --- /dev/null +++ b/apps/emqx_auth_mongo/README.md @@ -0,0 +1,192 @@ +emqx_auth_mongo +=============== + +EMQ X Authentication/ACL with MongoDB + +Build the Plugin +---------------- + +``` +make & make tests +``` + +Configuration +------------- + +File: etc/emqx_auth_mongo.conf + +``` +## MongoDB Topology Type. +## +## Value: single | unknown | sharded | rs +auth.mongo.type = single + +## Sets the set name if type is rs. +## +## Value: String +## auth.mongo.rs_set_name = + +## MongoDB server list. +## +## Value: String +## +## Examples: 127.0.0.1:27017,127.0.0.2:27017... +auth.mongo.server = 127.0.0.1:27017 + +## MongoDB pool size +## +## Value: Number +auth.mongo.pool = 8 + +## MongoDB login user. +## +## Value: String +## auth.mongo.login = + +## MongoDB password. +## +## Value: String +## auth.mongo.password = + +## MongoDB AuthSource +## +## Value: String +## Default: mqtt +## auth.mongo.auth_source = admin + +## MongoDB database +## +## Value: String +auth.mongo.database = mqtt + +## MongoDB write mode. +## +## Value: unsafe | safe +## auth.mongo.w_mode = + +## Mongo read mode. +## +## Value: master | slave_ok +## auth.mongo.r_mode = + +## MongoDB topology options. +auth.mongo.topology.pool_size = 1 +auth.mongo.topology.max_overflow = 0 +## auth.mongo.topology.overflow_ttl = 1000 +## auth.mongo.topology.overflow_check_period = 1000 +## auth.mongo.topology.local_threshold_ms = 1000 +## auth.mongo.topology.connect_timeout_ms = 20000 +## auth.mongo.topology.socket_timeout_ms = 100 +## auth.mongo.topology.server_selection_timeout_ms = 30000 +## auth.mongo.topology.wait_queue_timeout_ms = 1000 +## auth.mongo.topology.heartbeat_frequency_ms = 10000 +## auth.mongo.topology.min_heartbeat_frequency_ms = 1000 + +## Authentication query. +auth.mongo.auth_query.collection = mqtt_user + +auth.mongo.auth_query.password_field = password + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.mongo.auth_query.password_hash = sha256 + +## sha256 with salt suffix +## auth.mongo.auth_query.password_hash = sha256,salt + +## sha256 with salt prefix +## auth.mongo.auth_query.password_hash = salt,sha256 + +## bcrypt with salt prefix +## auth.mongo.auth_query.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.mongo.auth_query.password_hash = pbkdf2,sha256,1000,20 + +auth.mongo.auth_query.selector = username=%u + +## Enable superuser query. +auth.mongo.super_query = on + +auth.mongo.super_query.collection = mqtt_user + +auth.mongo.super_query.super_field = is_superuser + +auth.mongo.super_query.selector = username=%u + +## Enable ACL query. +auth.mongo.acl_query = on + +auth.mongo.acl_query.collection = mqtt_acl + +auth.mongo.acl_query.selector = username=%u +``` + +Load the Plugin +--------------- + +``` +./bin/emqx_ctl plugins load emqx_auth_mongo +``` + +MongoDB Database +---------------- + +``` +use mqtt +db.createCollection("mqtt_user") +db.createCollection("mqtt_acl") +db.mqtt_user.ensureIndex({"username":1}) +``` + +mqtt_user Collection +-------------------- + +``` +{ + username: "user", + password: "password hash", + salt: "password salt", + is_superuser: boolean (true, false), + created: "datetime" +} +``` + +For example: +``` +db.mqtt_user.insert({username: "test", password: "password hash", salt: "password salt", is_superuser: false}) +db.mqtt_user.insert({username: "root", is_superuser: true}) +``` + +mqtt_acl Collection +------------------- + +``` +{ + username: "username", + clientid: "clientid", + publish: ["topic1", "topic2", ...], + subscribe: ["subtop1", "subtop2", ...], + pubsub: ["topic/#", "topic1", ...] +} +``` + +For example: + +``` +db.mqtt_acl.insert({username: "test", publish: ["t/1", "t/2"], subscribe: ["user/%u", "client/%c"]}) +db.mqtt_acl.insert({username: "admin", pubsub: ["#"]}) +``` + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf b/apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf new file mode 100644 index 000000000..073feeb6d --- /dev/null +++ b/apps/emqx_auth_mongo/etc/emqx_auth_mongo.conf @@ -0,0 +1,172 @@ +##-------------------------------------------------------------------- +## MongoDB Auth/ACL Plugin +##-------------------------------------------------------------------- + +## MongoDB Topology Type. +## +## Value: single | unknown | sharded | rs +auth.mongo.type = single + +## The set name if type is rs. +## +## Value: String +## auth.mongo.rs_set_name = + +## MongoDB server list. +## +## Value: String +## +## Examples: 127.0.0.1:27017,127.0.0.2:27017... +auth.mongo.server = 127.0.0.1:27017 + +## MongoDB pool size +## +## Value: Number +auth.mongo.pool = 8 + +## MongoDB login user. +## +## Value: String +# auth.mongo.username = + +## MongoDB password. +## +## Value: String +## auth.mongo.password = + +## MongoDB AuthSource +## +## Value: String +## Default: mqtt +## auth.mongo.auth_source = admin + +## MongoDB database +## +## Value: String +auth.mongo.database = mqtt + +## MongoDB query timeout +## +## Value: Duration +## auth.mongo.query_timeout = 5s + +## Whether to enable SSL connection. +## +## Value: on | off +## auth.mongo.ssl = off + +## SSL keyfile. +## +## Value: File +## auth.mongo.ssl.keyfile = + +## SSL certfile. +## +## Value: File +## auth.mongo.ssl.certfile = + +## SSL cacertfile. +## +## Value: File +## auth.mongo.ssl.cacertfile = + +## MongoDB write mode. +## +## Value: unsafe | safe +## auth.mongo.w_mode = + +## Mongo read mode. +## +## Value: master | slave_ok +## auth.mongo.r_mode = + +## MongoDB topology options. +auth.mongo.topology.pool_size = 1 +auth.mongo.topology.max_overflow = 0 +## auth.mongo.topology.overflow_ttl = 1000 +## auth.mongo.topology.overflow_check_period = 1000 +## auth.mongo.topology.local_threshold_ms = 1000 +## auth.mongo.topology.connect_timeout_ms = 20000 +## auth.mongo.topology.socket_timeout_ms = 100 +## auth.mongo.topology.server_selection_timeout_ms = 30000 +## auth.mongo.topology.wait_queue_timeout_ms = 1000 +## auth.mongo.topology.heartbeat_frequency_ms = 10000 +## auth.mongo.topology.min_heartbeat_frequency_ms = 1000 + +## ------------------------------------------------- +## Auth Query +## ------------------------------------------------- +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.mongo.auth_query.password_hash = sha256 + +## sha256 with salt suffix +## auth.mongo.auth_query.password_hash = sha256,salt + +## sha256 with salt prefix +## auth.mongo.auth_query.password_hash = salt,sha256 + +## bcrypt with salt prefix +## auth.mongo.auth_query.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.mongo.auth_query.password_hash = pbkdf2,sha256,1000,20 + +## Authentication query. +auth.mongo.auth_query.collection = mqtt_user + +## Password mainly fields +## +## Value: password | password,salt +auth.mongo.auth_query.password_field = password + +## Authentication Selector. +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## auth.mongo.auth_query.selector = {Field}={Placeholder} +auth.mongo.auth_query.selector = username=%u + +## ------------------------------------------------- +## Super User Query +## ------------------------------------------------- +auth.mongo.super_query.collection = mqtt_user +auth.mongo.super_query.super_field = is_superuser +#auth.mongo.super_query.selector = username=%u, clientid=%c +auth.mongo.super_query.selector = username=%u + +## ACL Selector. +## +## Multiple selectors could be combined with '$or' +## when query acl from mongo. +## +## e.g. +## +## With following 2 selectors configured: +## +## auth.mongo.acl_query.selector.1 = username=%u +## auth.mongo.acl_query.selector.2 = username=$all +## +## And if a client connected using username 'ilyas', +## then the following mongo command will be used to +## retrieve acl entries: +## +## db.mqtt_acl.find({$or: [{username: "ilyas"}, {username: "$all"}]}); +## +## Variables: +## - %u: username +## - %c: clientid +## +## Examples: +## +## auth.mongo.acl_query.selector.1 = username=%u,clientid=%c +## auth.mongo.acl_query.selector.2 = username=$all +## auth.mongo.acl_query.selector.3 = clientid=$all +auth.mongo.acl_query.collection = mqtt_acl +auth.mongo.acl_query.selector = username=%u diff --git a/apps/emqx_auth_mongo/include/emqx_auth_mongo.hrl b/apps/emqx_auth_mongo/include/emqx_auth_mongo.hrl new file mode 100644 index 000000000..97ecf9973 --- /dev/null +++ b/apps/emqx_auth_mongo/include/emqx_auth_mongo.hrl @@ -0,0 +1,37 @@ + +-define(APP, emqx_auth_mongo). + +-define(DEFAULT_SELECTORS, [{<<"username">>, <<"%u">>}]). + +-record(superquery, {collection = <<"mqtt_user">>, + field = <<"is_superuser">>, + selector = {<<"username">>, <<"%u">>}}). + +-record(authquery, {collection = <<"mqtt_user">>, + field = <<"password">>, + hash = sha256, + selector = {<<"username">>, <<"%u">>}}). + +-record(aclquery, {collection = <<"mqtt_acl">>, + selector = {<<"username">>, <<"%u">>}}). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_mongo/priv/emqx_auth_mongo.schema b/apps/emqx_auth_mongo/priv/emqx_auth_mongo.schema new file mode 100644 index 000000000..bef569306 --- /dev/null +++ b/apps/emqx_auth_mongo/priv/emqx_auth_mongo.schema @@ -0,0 +1,318 @@ +%%-*- mode: erlang -*- +%% emqx_auth_mongo config mapping + +{mapping, "auth.mongo.type", "emqx_auth_mongo.server", [ + {default, single}, + {datatype, {enum, [single, unknown, sharded, rs]}} +]}. + +{mapping, "auth.mongo.rs_set_name", "emqx_auth_mongo.server", [ + {default, "mqtt"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.server", "emqx_auth_mongo.server", [ + {default, "127.0.0.1:27017"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.pool", "emqx_auth_mongo.server", [ + {default, 8}, + {datatype, integer} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.mongo.login", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.username", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.password", "emqx_auth_mongo.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.mongo.database", "emqx_auth_mongo.server", [ + {default, "mqtt"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.auth_source", "emqx_auth_mongo.server", [ + {default, "mqtt"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.ssl", "emqx_auth_mongo.server", [ + {default, off}, + {datatype, {enum, [on, off, true, false]}} %% FIXME: ture/false is compatible with 4.0-4.2 version format, plan to delete in 5.0 +]}. + +{mapping, "auth.mongo.ssl.keyfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.ssl.certfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.ssl.cacertfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.mongo.ssl_opts.keyfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.mongo.ssl_opts.certfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.mongo.ssl_opts.cacertfile", "emqx_auth_mongo.server", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.w_mode", "emqx_auth_mongo.server", [ + {default, undef}, + {datatype, {enum, [safe, unsafe, undef]}} +]}. + +{mapping, "auth.mongo.r_mode", "emqx_auth_mongo.server", [ + {default, undef}, + {datatype, {enum, [master, slave_ok, undef]}} +]}. + +{mapping, "auth.mongo.topology.$name", "emqx_auth_mongo.server", [ + {datatype, integer} +]}. + +{translation, "emqx_auth_mongo.server", fun(Conf) -> + H = cuttlefish:conf_get("auth.mongo.server", Conf), + Hosts = string:tokens(H, ","), + Type0 = cuttlefish:conf_get("auth.mongo.type", Conf), + Pool = cuttlefish:conf_get("auth.mongo.pool", Conf), + %% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 + Login = cuttlefish:conf_get("auth.mongo.username", Conf, + cuttlefish:conf_get("auth.mongo.login", Conf, "") + ), + Passwd = cuttlefish:conf_get("auth.mongo.password", Conf), + DB = cuttlefish:conf_get("auth.mongo.database", Conf), + AuthSrc = cuttlefish:conf_get("auth.mongo.auth_source", Conf), + R = cuttlefish:conf_get("auth.mongo.w_mode", Conf), + W = cuttlefish:conf_get("auth.mongo.r_mode", Conf), + Login0 = case Login =:= [] of + true -> []; + false -> [{login, list_to_binary(Login)}] + end, + Passwd0 = case Passwd =:= [] of + true -> []; + false -> [{password, list_to_binary(Passwd)}] + end, + W0 = case W =:= undef of + true -> []; + false -> [{w_mode, W}] + end, + R0 = case R =:= undef of + true -> []; + false -> [{r_mode, R}] + end, + + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + SslOpts = fun(Prefix) -> + Filter([{keyfile, cuttlefish:conf_get(Prefix ++ ".keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined)}]) + end, + + %% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 + Ssl = case cuttlefish:conf_get("auth.mongo.ssl", Conf) of + on -> [{ssl, true}, {ssl_opts, SslOpts("auth.mongo.ssl")}]; + off -> []; + true -> [{ssl, true}, {ssl_opts, SslOpts("auth.mongo.ssl_opts")}]; + false -> [] + end, + + WorkerOptions = [{database, list_to_binary(DB)}, {auth_source, list_to_binary(AuthSrc)}] + ++ Login0 ++ Passwd0 ++ W0 ++ R0 ++ Ssl, + + Vars = cuttlefish_variable:fuzzy_matches(["auth", "mongo", "topology", "$name"], Conf), + Options = lists:map(fun({_, Name}) -> + Name2 = case Name of + "local_threshold_ms" -> "localThresholdMS"; + "connect_timeout_ms" -> "connectTimeoutMS"; + "socket_timeout_ms" -> "socketTimeoutMS"; + "server_selection_timeout_ms" -> "serverSelectionTimeoutMS"; + "wait_queue_timeout_ms" -> "waitQueueTimeoutMS"; + "heartbeat_frequency_ms" -> "heartbeatFrequencyMS"; + "min_heartbeat_frequency_ms" -> "minHeartbeatFrequencyMS"; + _ -> Name + end, + {list_to_atom(Name2), cuttlefish:conf_get("auth.mongo.topology."++Name, Conf)} + end, Vars), + + Type = case Type0 =:= rs of + true -> {Type0, list_to_binary(cuttlefish:conf_get("auth.mongo.rs_set_name", Conf))}; + false -> Type0 + end, + [{type, Type}, + {hosts, Hosts}, + {options, Options}, + {worker_options, WorkerOptions}, + {auto_reconnect, 1}, + {pool_size, Pool}] +end}. + +%% The mongodb operation timeout is specified by the value of `cursor_timeout` from application config, +%% or `infinity` if `cursor_timeout` not specified +{mapping, "auth.mongo.query_timeout", "mongodb.cursor_timeout", [ + {datatype, string} +]}. + +{translation, "mongodb.cursor_timeout", fun(Conf) -> + case cuttlefish:conf_get("auth.mongo.query_timeout", Conf, undefined) of + undefined -> infinity; + Duration -> + case cuttlefish_duration:parse(Duration, ms) of + {error, Reason} -> error(Reason); + Ms when is_integer(Ms) -> Ms + end + end +end}. + +{mapping, "auth.mongo.auth_query.collection", "emqx_auth_mongo.auth_query", [ + {default, "mqtt_user"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.auth_query.password_field", "emqx_auth_mongo.auth_query", [ + {default, "password"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.auth_query.password_hash", "emqx_auth_mongo.auth_query", [ + {datatype, string} +]}. + +{mapping, "auth.mongo.auth_query.selector", "emqx_auth_mongo.auth_query", [ + {default, ""}, + {datatype, string} +]}. + +{translation, "emqx_auth_mongo.auth_query", fun(Conf) -> + case cuttlefish:conf_get("auth.mongo.auth_query.collection", Conf) of + undefined -> cuttlefish:unset(); + Collection -> + PasswordField = cuttlefish:conf_get("auth.mongo.auth_query.password_field", Conf), + PasswordHash = cuttlefish:conf_get("auth.mongo.auth_query.password_hash", Conf), + SelectorStr = cuttlefish:conf_get("auth.mongo.auth_query.selector", Conf), + SelectorList = + lists:map(fun(Selector) -> + case string:tokens(Selector, "=") of + [Field, Val] -> {list_to_binary(Field), list_to_binary(Val)}; + _ -> {<<"username">>, <<"%u">>} + end + end, string:tokens(SelectorStr, ", ")), + + PasswordFields = [list_to_binary(Field) || Field <- string:tokens(PasswordField, ",")], + HashValue = + case string:tokens(PasswordHash, ",") of + [Hash] -> list_to_atom(Hash); + [Prefix, Suffix] -> {list_to_atom(Prefix), list_to_atom(Suffix)}; + [Hash, MacFun, Iterations, Dklen] -> {list_to_atom(Hash), list_to_atom(MacFun), list_to_integer(Iterations), list_to_integer(Dklen)}; + _ -> plain + end, + [{collection, Collection}, + {password_field, PasswordFields}, + {password_hash, HashValue}, + {selector, SelectorList}] + end +end}. + +{mapping, "auth.mongo.super_query", "emqx_auth_mongo.super_query", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.mongo.super_query.collection", "emqx_auth_mongo.super_query", [ + {default, "mqtt_user"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.super_query.super_field", "emqx_auth_mongo.super_query", [ + {default, "is_superuser"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.super_query.selector", "emqx_auth_mongo.super_query", [ + {default, ""}, + {datatype, string} +]}. + +{translation, "emqx_auth_mongo.super_query", fun(Conf) -> + case cuttlefish:conf_get("auth.mongo.super_query.collection", Conf) of + undefined -> cuttlefish:unset(); + Collection -> + SuperField = cuttlefish:conf_get("auth.mongo.super_query.super_field", Conf), + SelectorStr = cuttlefish:conf_get("auth.mongo.super_query.selector", Conf), + SelectorList = + lists:map(fun(Selector) -> + case string:tokens(Selector, "=") of + [Field, Val] -> {list_to_binary(Field), list_to_binary(Val)}; + _ -> {<<"username">>, <<"%u">>} + end + end, string:tokens(SelectorStr, ", ")), + [{collection, Collection}, {super_field, SuperField}, {selector, SelectorList}] + end +end}. + +{mapping, "auth.mongo.acl_query", "emqx_auth_mongo.acl_query", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.mongo.acl_query.collection", "emqx_auth_mongo.acl_query", [ + {default, "mqtt_user"}, + {datatype, string} +]}. + +{mapping, "auth.mongo.acl_query.selector", "emqx_auth_mongo.acl_query", [ + {default, ""}, + {datatype, string} +]}. +{mapping, "auth.mongo.acl_query.selector.$id", "emqx_auth_mongo.acl_query", [ + {default, ""}, + {datatype, string} +]}. + +{translation, "emqx_auth_mongo.acl_query", fun(Conf) -> + case cuttlefish:conf_get("auth.mongo.acl_query.collection", Conf) of + undefined -> cuttlefish:unset(); + Collection -> + SelectorStrList = + lists:map( + fun + ({["auth","mongo","acl_query","selector"], ConfEntry}) -> + ConfEntry; + ({["auth","mongo","acl_query","selector", _], ConfEntry}) -> + ConfEntry + end, + cuttlefish_variable:filter_by_prefix("auth.mongo.acl_query.selector", Conf)), + SelectorListList = + lists:map( + fun(SelectorStr) -> + lists:map(fun(Selector) -> + case string:tokens(Selector, "=") of + [Field, Val] -> {list_to_binary(Field), list_to_binary(Val)}; + _ -> {<<"username">>, <<"%u">>} + end + end, string:tokens(SelectorStr, ", ")) + end, + SelectorStrList), + [{collection, Collection}, {selector, SelectorListList}] + end +end}. diff --git a/apps/emqx_auth_mongo/rebar.config b/apps/emqx_auth_mongo/rebar.config new file mode 100644 index 000000000..834bff904 --- /dev/null +++ b/apps/emqx_auth_mongo/rebar.config @@ -0,0 +1,24 @@ +{deps, + [{mongodb, {git,"https://github.com/emqx/mongodb-erlang", {tag, "v3.0.7"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + compressed, + {parse_transform} + ]}. +{overrides, [{add, [{erl_opts, [compressed]}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions + ]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + diff --git a/apps/emqx_auth_mongo/src/emqx_acl_mongo.erl b/apps/emqx_auth_mongo/src/emqx_acl_mongo.erl new file mode 100644 index 000000000..c0ff5f8ac --- /dev/null +++ b/apps/emqx_auth_mongo/src/emqx_acl_mongo.erl @@ -0,0 +1,91 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mongo). + +-include("emqx_auth_mongo.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +%% ACL callbacks +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _AclResult, _State) -> + ok; + +check_acl(ClientInfo, PubSub, Topic, _AclResult, Env = #{aclquery := AclQuery}) -> + #aclquery{collection = Coll, selector = SelectorList} = AclQuery, + Pool = maps:get(pool, Env, ?APP), + SelectorMapList = + lists:map(fun(Selector) -> + maps:from_list(emqx_auth_mongo:replvars(Selector, ClientInfo)) + end, SelectorList), + case emqx_auth_mongo:query_multi(Pool, Coll, SelectorMapList) of + [] -> ok; + Rows -> + try match(ClientInfo, Topic, topics(PubSub, Rows)) of + matched -> emqx_metrics:inc(?ACL_METRICS(allow)), + {stop, allow}; + nomatch -> emqx_metrics:inc(?ACL_METRICS(deny)), + {stop, deny} + catch + _Err:Reason-> + ?LOG(error, "[MongoDB] Check mongo ~p ACL failed, got ACL config: ~p, error: :~p", + [PubSub, Rows, Reason]), + emqx_metrics:inc(?ACL_METRICS(ignore)), + ignore + end + end. + + +match(_ClientInfo, _Topic, []) -> + nomatch; +match(ClientInfo, Topic, [TopicFilter|More]) -> + case emqx_topic:match(Topic, feedvar(ClientInfo, TopicFilter)) of + true -> matched; + false -> match(ClientInfo, Topic, More) + end. + +topics(publish, Rows) -> + lists:foldl(fun(Row, Acc) -> + Topics = maps:get(<<"publish">>, Row, []) ++ maps:get(<<"pubsub">>, Row, []), + lists:umerge(Acc, Topics) + end, [], Rows); + +topics(subscribe, Rows) -> + lists:foldl(fun(Row, Acc) -> + Topics = maps:get(<<"subscribe">>, Row, []) ++ maps:get(<<"pubsub">>, Row, []), + lists:umerge(Acc, Topics) + end, [], Rows). + +feedvar(#{clientid := ClientId, username := Username}, Str) -> + lists:foldl(fun({Var, Val}, Acc) -> + feedvar(Acc, Var, Val) + end, Str, [{"%u", Username}, {"%c", ClientId}]). + +feedvar(Str, _Var, undefined) -> + Str; +feedvar(Str, Var, Val) -> + re:replace(Str, Var, Val, [global, {return, binary}]). + +description() -> "ACL with MongoDB". + diff --git a/apps/emqx_auth_mongo/src/emqx_auth_mongo.app.src b/apps/emqx_auth_mongo/src/emqx_auth_mongo.app.src new file mode 100644 index 000000000..cc4e72ef3 --- /dev/null +++ b/apps/emqx_auth_mongo/src/emqx_auth_mongo.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_mongo, + [{description, "EMQ X Authentication/ACL with MongoDB"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_mongo_sup]}, + {applications, [kernel,stdlib,mongodb,ecpool]}, + {mod, {emqx_auth_mongo_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-mongo"} + ]} + ]}. diff --git a/apps/emqx_auth_mongo/src/emqx_auth_mongo.erl b/apps/emqx_auth_mongo/src/emqx_auth_mongo.erl new file mode 100644 index 000000000..2ca4a6f54 --- /dev/null +++ b/apps/emqx_auth_mongo/src/emqx_auth_mongo.erl @@ -0,0 +1,138 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mongo). + +-behaviour(ecpool_worker). + +-include("emqx_auth_mongo.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/types.hrl"). + +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-export([ replvar/2 + , replvars/2 + , connect/1 + , query/3 + , query_multi/3 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo = #{password := Password}, AuthResult, + Env = #{authquery := AuthQuery, superquery := SuperQuery}) -> + #authquery{collection = Collection, field = Fields, + hash = HashType, selector = Selector} = AuthQuery, + Pool = maps:get(pool, Env, ?APP), + case query(Pool, Collection, maps:from_list(replvars(Selector, ClientInfo))) of + undefined -> emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, Reason} -> + ?LOG(error, "[MongoDB] Can't connect to MongoDB server: ~0p", [Reason]), + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => not_authorized, anonymous => false}}; + UserMap -> + Result = case [maps:get(Field, UserMap, undefined) || Field <- Fields] of + [undefined] -> {error, password_error}; + [PassHash] -> + check_pass({PassHash, Password}, HashType); + [PassHash, Salt|_] -> + check_pass({PassHash, Salt, Password}, HashType) + end, + case Result of + ok -> + ok = emqx_metrics:inc(?AUTH_METRICS(success)), + {stop, AuthResult#{is_superuser => is_superuser(Pool, SuperQuery, ClientInfo), + anonymous => false, + auth_result => success}}; + {error, Error} -> + ?LOG(error, "[MongoDB] check auth fail: ~p", [Error]), + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => Error, anonymous => false}} + end + end. + +check_pass(Password, HashType) -> + case emqx_passwd:check_pass(Password, HashType) of + ok -> ok; + {error, _Reason} -> {error, not_authorized} + end. + +description() -> "Authentication with MongoDB". + +%%-------------------------------------------------------------------- +%% Is Superuser? +%%-------------------------------------------------------------------- +is_superuser(_Pool, undefined, _ClientInfo) -> + false; +is_superuser(Pool, #superquery{collection = Coll, field = Field, selector = Selector}, ClientInfo) -> + case query(Pool, Coll, maps:from_list(replvars(Selector, ClientInfo))) of + undefined -> false; + {error, Reason} -> + ?LOG(error, "[MongoDB] Can't connect to MongoDB server: ~0p", [Reason]), + false; + Row -> + case maps:get(Field, Row, false) of + true -> true; + _False -> false + end + end. + +replvars(VarList, ClientInfo) -> + lists:map(fun(Var) -> replvar(Var, ClientInfo) end, VarList). + +replvar({Field, <<"%u">>}, #{username := Username}) -> + {Field, Username}; +replvar({Field, <<"%c">>}, #{clientid := ClientId}) -> + {Field, ClientId}; +replvar({Field, <<"%C">>}, #{cn := CN}) -> + {Field, CN}; +replvar({Field, <<"%d">>}, #{dn := DN}) -> + {Field, DN}; +replvar(Selector, _ClientInfo) -> + Selector. + +%%-------------------------------------------------------------------- +%% MongoDB Connect/Query +%%-------------------------------------------------------------------- + +connect(Opts) -> + Type = proplists:get_value(type, Opts, single), + Hosts = proplists:get_value(hosts, Opts, []), + Options = proplists:get_value(options, Opts, []), + WorkerOptions = proplists:get_value(worker_options, Opts, []), + mongo_api:connect(Type, Hosts, Options, WorkerOptions). + +query(Pool, Collection, Selector) -> + ecpool:with_client(Pool, fun(Conn) -> mongo_api:find_one(Conn, Collection, Selector, #{}) end). + +query_multi(Pool, Collection, SelectorList) -> + lists:reverse(lists:flatten(lists:foldl(fun(Selector, Acc1) -> + Batch = ecpool:with_client(Pool, fun(Conn) -> + case mongo_api:find(Conn, Collection, Selector, #{}) of + [] -> []; + {ok, Cursor} -> + mc_cursor:foldl(fun(O, Acc2) -> [O|Acc2] end, [], Cursor, 1000) + end + end), + [Batch|Acc1] + end, [], SelectorList))). diff --git a/apps/emqx_auth_mongo/src/emqx_auth_mongo_app.erl b/apps/emqx_auth_mongo/src/emqx_auth_mongo_app.erl new file mode 100644 index 000000000..e13fe30b7 --- /dev/null +++ b/apps/emqx_auth_mongo/src/emqx_auth_mongo_app.erl @@ -0,0 +1,87 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mongo_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_mongo.hrl"). + +-import(proplists, [get_value/3]). + +%% Application callbacks +-export([ start/2 + , prep_stop/1 + , stop/1 + ]). + +%%-------------------------------------------------------------------- +%% Application callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_mongo_sup:start_link(), + with_env(auth_query, fun reg_authmod/1), + with_env(acl_query, fun reg_aclmod/1), + {ok, Sup}. + +prep_stop(State) -> + ok = emqx:unhook('client.authenticate', fun emqx_auth_mongo:check/3), + ok = emqx:unhook('client.check_acl', fun emqx_acl_mongo:check_acl/5), + State. + +stop(_State) -> + ok. + +reg_authmod(AuthQuery) -> + emqx_auth_mongo:register_metrics(), + SuperQuery = r(super_query, application:get_env(?APP, super_query, undefined)), + ok = emqx:hook('client.authenticate', fun emqx_auth_mongo:check/3, + [#{authquery => AuthQuery, superquery => SuperQuery, pool => ?APP}]). + +reg_aclmod(AclQuery) -> + emqx_acl_mongo:register_metrics(), + ok = emqx:hook('client.check_acl', fun emqx_acl_mongo:check_acl/5, [#{aclquery => AclQuery, pool => ?APP}]). + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +with_env(Name, Fun) -> + case application:get_env(?APP, Name) of + undefined -> ok; + {ok, Config} -> Fun(r(Name, Config)) + end. + +r(super_query, undefined) -> + undefined; +r(super_query, Config) -> + #superquery{collection = list_to_binary(get_value(collection, Config, "mqtt_user")), + field = list_to_binary(get_value(super_field, Config, "is_superuser")), + selector = get_value(selector, Config, ?DEFAULT_SELECTORS)}; + +r(auth_query, Config) -> + #authquery{collection = list_to_binary(get_value(collection, Config, "mqtt_user")), + field = get_value(password_field, Config, [<<"password">>]), + hash = get_value(password_hash, Config, sha256), + selector = get_value(selector, Config, ?DEFAULT_SELECTORS)}; + +r(acl_query, Config) -> + #aclquery{collection = list_to_binary(get_value(collection, Config, "mqtt_acl")), + selector = get_value(selector, Config, [?DEFAULT_SELECTORS])}. + diff --git a/apps/emqx_auth_mongo/src/emqx_auth_mongo_sup.erl b/apps/emqx_auth_mongo/src/emqx_auth_mongo_sup.erl new file mode 100644 index 000000000..dc2c37fd4 --- /dev/null +++ b/apps/emqx_auth_mongo/src/emqx_auth_mongo_sup.erl @@ -0,0 +1,34 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mongo_sup). + +-behaviour(supervisor). + +-include("emqx_auth_mongo.hrl"). + +-export([start_link/0]). + +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, PoolEnv} = application:get_env(?APP, server), + PoolSpec = ecpool:pool_spec(?APP, ?APP, ?APP, PoolEnv), + {ok, {{one_for_all, 10, 100}, [PoolSpec]}}. + diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE.erl b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE.erl new file mode 100644 index 000000000..4e76026ec --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE.erl @@ -0,0 +1,174 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mongo_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-define(APP, emqx_auth_mongo). + +-define(POOL(App), ecpool_worker:client(gproc_pool:pick_worker({ecpool, App}))). + +-define(MONGO_CL_ACL, <<"mqtt_acl">>). +-define(MONGO_CL_USER, <<"mqtt_user">>). + +-define(INIT_ACL, [{<<"username">>, <<"testuser">>, <<"clientid">>, <<"null">>, <<"subscribe">>, [<<"#">>]}, + {<<"username">>, <<"dashboard">>, <<"clientid">>, <<"null">>, <<"pubsub">>, [<<"$SYS/#">>]}, + {<<"username">>, <<"user3">>, <<"clientid">>, <<"null">>, <<"publish">>, [<<"a/b/c">>]}]). + +-define(INIT_AUTH, [{<<"username">>, <<"plain">>, <<"password">>, <<"plain">>, <<"salt">>, <<"salt">>, <<"is_superuser">>, true}, + {<<"username">>, <<"md5">>, <<"password">>, <<"1bc29b36f623ba82aaf6724fd3b16718">>, <<"salt">>, <<"salt">>, <<"is_superuser">>, false}, + {<<"username">>, <<"sha">>, <<"password">>, <<"d8f4590320e1343a915b6394170650a8f35d6926">>, <<"salt">>, <<"salt">>, <<"is_superuser">>, false}, + {<<"username">>, <<"sha256">>, <<"password">>, <<"5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e">>, <<"salt">>, <<"salt">>, <<"is_superuser">>, false}, + {<<"username">>, <<"pbkdf2_password">>, <<"password">>, <<"cdedb5281bb2f801565a1122b2563515">>, <<"salt">>, <<"ATHENA.MIT.EDUraeburn">>, <<"is_superuser">>, false}, + {<<"username">>, <<"bcrypt_foo">>, <<"password">>, <<"$2a$12$sSS8Eg.ovVzaHzi1nUHYK.HbUIOdlQI0iS22Q5rd5z.JVVYH6sfm6">>, <<"salt">>, <<"$2a$12$sSS8Eg.ovVzaHzi1nUHYK.">>, <<"is_superuser">>, false} + ]). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + emqx_ct_helpers:start_apps([emqx_auth_mongo], fun set_special_confs/1), + emqx_modules:load_module(emqx_mod_acl_internal, false), + init_mongo_data(), + Cfg. + +end_per_suite(_Cfg) -> + deinit_mongo_data(), + emqx_ct_helpers:stop_apps([emqx_auth_mongo]). + +set_special_confs(emqx) -> + application:set_env(emqx, acl_nomatch, deny), + application:set_env(emqx, acl_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/acl.conf")), + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_confs(_App) -> + ok. + +init_mongo_data() -> + %% Users + {ok, Connection} = ?POOL(?APP), + mongo_api:delete(Connection, ?MONGO_CL_USER, {}), + ?assertMatch({{true, _}, _}, mongo_api:insert(Connection, ?MONGO_CL_USER, ?INIT_AUTH)), + %% ACLs + mongo_api:delete(Connection, ?MONGO_CL_ACL, {}), + ?assertMatch({{true, _}, _}, mongo_api:insert(Connection, ?MONGO_CL_ACL, ?INIT_ACL)). + +deinit_mongo_data() -> + {ok, Connection} = ?POOL(?APP), + mongo_api:delete(Connection, ?MONGO_CL_USER, {}), + mongo_api:delete(Connection, ?MONGO_CL_ACL, {}). + +%%-------------------------------------------------------------------- +%% Test cases +%%-------------------------------------------------------------------- + +t_check_auth(_) -> + Plain = #{zone => external, clientid => <<"client1">>, username => <<"plain">>}, + Plain1 = #{zone => external, clientid => <<"client1">>, username => <<"plain2">>}, + Md5 = #{zone => external, clientid => <<"md5">>, username => <<"md5">>}, + Sha = #{zone => external, clientid => <<"sha">>, username => <<"sha">>}, + Sha256 = #{zone => external, clientid => <<"sha256">>, username => <<"sha256">>}, + Pbkdf2 = #{zone => external, clientid => <<"pbkdf2_password">>, username => <<"pbkdf2_password">>}, + Bcrypt = #{zone => external, clientid => <<"bcrypt_foo">>, username => <<"bcrypt_foo">>}, + User1 = #{zone => external, clientid => <<"bcrypt_foo">>, username => <<"user">>}, + reload({auth_query, [{password_hash, plain}]}), + %% With exactly username/password, connection success + {ok, #{is_superuser := true}} = emqx_access_control:authenticate(Plain#{password => <<"plain">>}), + %% With exactly username and wrong password, connection fail + {error, _} = emqx_access_control:authenticate(Plain#{password => <<"error_pwd">>}), + %% With wrong username and wrong password, emqx_auth_mongo auth fail, then allow anonymous authentication + {error, _} = emqx_access_control:authenticate(Plain1#{password => <<"error_pwd">>}), + %% With wrong username and exactly password, emqx_auth_mongo auth fail, then allow anonymous authentication + {error, _} = emqx_access_control:authenticate(Plain1#{password => <<"plain">>}), + reload({auth_query, [{password_hash, md5}]}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Md5#{password => <<"md5">>}), + reload({auth_query, [{password_hash, sha}]}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Sha#{password => <<"sha">>}), + reload({auth_query, [{password_hash, sha256}]}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Sha256#{password => <<"sha256">>}), + %%pbkdf2 sha + reload({auth_query, [{password_hash, {pbkdf2, sha, 1, 16}}, {password_field, [<<"password">>, <<"salt">>]}]}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Pbkdf2#{password => <<"password">>}), + reload({auth_query, [{password_hash, {salt, bcrypt}}]}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Bcrypt#{password => <<"foo">>}), + {error, _} = emqx_access_control:authenticate(User1#{password => <<"foo">>}). + +t_check_acl(_) -> + {ok, Connection} = ?POOL(?APP), + User1 = #{zone => external, clientid => <<"client1">>, username => <<"testuser">>}, + User2 = #{zone => external, clientid => <<"client2">>, username => <<"dashboard">>}, + User3 = #{zone => external, clientid => <<"client2">>, username => <<"user3">>}, + User4 = #{zone => external, clientid => <<"$$client2">>, username => <<"$$user3">>}, + 3 = mongo_api:count(Connection, ?MONGO_CL_ACL, {}, 17), + %% ct log output + allow = emqx_access_control:check_acl(User1, subscribe, <<"users/testuser/1">>), + deny = emqx_access_control:check_acl(User1, subscribe, <<"$SYS/testuser/1">>), + deny = emqx_access_control:check_acl(User2, subscribe, <<"a/b/c">>), + allow = emqx_access_control:check_acl(User2, subscribe, <<"$SYS/testuser/1">>), + allow = emqx_access_control:check_acl(User3, publish, <<"a/b/c">>), + deny = emqx_access_control:check_acl(User3, publish, <<"c">>), + allow = emqx_access_control:check_acl(User4, publish, <<"a/b/c">>). + +t_acl_super(_) -> + reload({auth_query, [{password_hash, plain}, {password_field, [<<"password">>]}]}), + {ok, C} = emqtt:start_link([{clientid, <<"simpleClient">>}, + {username, <<"plain">>}, + {password, <<"plain">>}]), + {ok, _} = emqtt:connect(C), + timer:sleep(10), + emqtt:subscribe(C, <<"TopicA">>, qos2), + timer:sleep(1000), + emqtt:publish(C, <<"TopicA">>, <<"Payload">>, qos2), + timer:sleep(1000), + receive + {publish, #{payload := Payload}} -> + ?assertEqual(<<"Payload">>, Payload) + after + 1000 -> + ct:fail({receive_timeout, <<"Payload">>}), + ok + end, + emqtt:disconnect(C). + +%%-------------------------------------------------------------------- +%% Utils +%%-------------------------------------------------------------------- + +reload({Par, Vals}) when is_list(Vals) -> + application:stop(?APP), + {ok, TupleVals} = application:get_env(?APP, Par), + NewVals = + lists:filtermap(fun({K, V}) -> + case lists:keymember(K, 1, Vals) of + false ->{true, {K, V}}; + _ -> false + end + end, TupleVals), + application:set_env(?APP, Par, lists:append(NewVals, Vals)), + application:start(?APP). diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca-key.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca-key.pem new file mode 100644 index 000000000..e9717011e --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0kGUBi9NDp65jgdxKfizIfuSr2wpwb44yM9SuP4oUQSULOA2 +4iFpLR/c5FAYHU81y9Vx91dQjdZfffaBZuv2zVvteXUkol8Nez7boKbo2E41MTew +8edtNKZAQVvnaHAC2NCZxjchCzUCDEoUUcl+cIERZ8R48FBqK5iTVcMRIx1akwus ++dhBqP0ykA5TGOWZkJrLM9aUXSPQha9+wXlOpkvu0Ur2nkX8PPJnifWao9UShSar +ll1IqPZNCSlZMwcFYcQNBCpdvITUUYlHvMRQV64bUpOxUGDuJkQL3dLKBlNuBRlJ +BcjBAKw7rFnwwHZcMmQ9tan/dZzpzwjo/T0XjwIDAQABAoIBAQCSHvUqnzDkWjcG +l/Fzg92qXlYBCCC0/ugj1sHcwvVt6Mq5rVE3MpUPwTcYjPlVVTlD4aEEjm/zQuq2 +ddxUlOS+r4aIhHrjRT/vSS4FpjnoKeIZxGR6maVxk6DQS3i1QjMYT1CvSpzyVvKH +a+xXMrtmoKxh+085ZAmFJtIuJhUA2yEa4zggCxWnvz8ecLClUPfVDPhdLBHc3KmL +CRpHEC6L/wanvDPRdkkzfKyaJuIJlTDaCg63AY5sDkTW2I57iI/nJ3haSeidfQKz +39EfbnM1A/YprIakafjAu3frBIsjBVcxwGihZmL/YriTHjOggJF841kT5zFkkv2L +/530Wk6xAoGBAOqZLZ4DIi/zLndEOz1mRbUfjc7GQUdYplBnBwJ22VdS0P4TOXnd +UbJth2MA92NM7ocTYVFl4TVIZY/Y+Prxk7KQdHWzR7JPpKfx9OEVgtSqV0vF9eGI +rKp79Y1T4Mvc3UcQCXX6TP7nHLihEzpS8odm2LW4txrOiLsn4Fq/IWrLAoGBAOVv +6U4tm3lImotUupKLZPKEBYwruo9qRysoug9FiorP4TjaBVOfltiiHbAQD6aGfVtN +SZpZZtrs17wL7Xl4db5asgMcZd+8Hkfo5siR7AuGW9FZloOjDcXb5wCh9EvjJ74J +Cjw7RqyVymq9t7IP6wnVwj5Ck48YhlOZCz/mzlnNAoGAWq7NYFgLvgc9feLFF23S +IjpJQZWHJEITP98jaYNxbfzYRm49+GphqxwFinKULjFNvq7yHlnIXSVYBOu1CqOZ +GRwXuGuNmlKI7lZr9xmukfAqgGLMMdr4C4qRF4lFyufcLRz42z7exmWlx4ST/yaT +E13hBRWayeTuG5JFei6Jh1MCgYEAqmX4LyC+JFBgvvQZcLboLRkSCa18bADxhENG +FAuAvmFvksqRRC71WETmqZj0Fqgxt7pp3KFjO1rFSprNLvbg85PmO1s+6fCLyLpX +lESTu2d5D71qhK93jigooxalGitFm+SY3mzjq0/AOpBWOn+J/w7rqVPGxXLgaHv0 +l+vx+00CgYBOvo9/ImjwYii2jFl+sHEoCzlvpITi2temRlT2j6ulSjCLJgjwEFw9 +8e+vvfQumQOsutakUVyURrkMGNDiNlIv8kv5YLCCkrwN22E6Ghyi69MJUvHQXkc/ +QZhjn/luyfpB5f/BeHFS2bkkxAXo+cfG45ApY3Qfz6/7o+H+vDa6/A== +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca.pem new file mode 100644 index 000000000..00b31d8a4 --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIBATANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0NloXDTMwMDYwOTAzMzg0NlowPDE6MDgGA1UEAwwxTXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9DQV9DZXJ0aWZpY2F0ZTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANJBlAYvTQ6euY4HcSn4syH7kq9s +KcG+OMjPUrj+KFEElCzgNuIhaS0f3ORQGB1PNcvVcfdXUI3WX332gWbr9s1b7Xl1 +JKJfDXs+26Cm6NhONTE3sPHnbTSmQEFb52hwAtjQmcY3IQs1AgxKFFHJfnCBEWfE +ePBQaiuYk1XDESMdWpMLrPnYQaj9MpAOUxjlmZCayzPWlF0j0IWvfsF5TqZL7tFK +9p5F/DzyZ4n1mqPVEoUmq5ZdSKj2TQkpWTMHBWHEDQQqXbyE1FGJR7zEUFeuG1KT +sVBg7iZEC93SygZTbgUZSQXIwQCsO6xZ8MB2XDJkPbWp/3Wc6c8I6P09F48CAwEA +AaMQMA4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEADKz6bIpP5anp +GgLB0jkclRWuMlS4qqIt4itSsMXPJ/ezpHwECixmgW2TIQl6S1woRkUeMxhT2/Ay +Sn/7aKxuzRagyE5NEGOvrOuAP5RO2ZdNJ/X3/Rh533fK1sOTEEbSsWUvW6iSkZef +rsfZBVP32xBhRWkKRdLeLB4W99ADMa0IrTmZPCXHSSE2V4e1o6zWLXcOZeH1Qh8N +SkelBweR+8r1Fbvy1r3s7eH7DCbYoGEDVLQGOLvzHKBisQHmoDnnF5E9g1eeNRdg +o+vhOKfYCOzeNREJIqS42PHcGhdNRk90ycigPmfUJclz1mDHoMjKR2S5oosTpr65 +tNPx3CL7GA== +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-cert.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-cert.pem new file mode 100644 index 000000000..aad1404ca --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBAzANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0N1oXDTMwMDYwOTAzMzg0N1owQDE+MDwGA1UEAww1TXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9DbGllbnRfQ2VydGlmaWNhdGUw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVYSWpOvCTupz82fc85Opv +EQ7rkB8X2oOMyBCpkyHKBIr1ZQgRDWBp9UVOASq3GnSElm6+T3Kb1QbOffa8GIlw +sjAueKdq5L2eSkmPIEQ7eoO5kEW+4V866hE1LeL/PmHg2lGP0iqZiJYtElhHNQO8 +3y9I7cm3xWMAA3SSWikVtpJRn3qIp2QSrH+tK+/HHbE5QwtPxdir4ULSCSOaM5Yh +Wi5Oto88TZqe1v7SXC864JVvO4LuS7TuSreCdWZyPXTJFBFeCEWSAxonKZrqHbBe +CwKML6/0NuzjaQ51c2tzmVI6xpHj3nnu4cSRx6Jf9WBm+35vm0wk4pohX3ptdzeV +AgMBAAGjDTALMAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAByQ5zSNeFUH +Aw7JlpZHtHaSEeiiyBHke20ziQ07BK1yi/ms2HAWwQkpZv149sjNuIRH8pkTmkZn +g8PDzSefjLbC9AsWpWV0XNV22T/cdobqLqMBDDZ2+5bsV+jTrOigWd9/AHVZ93PP +IJN8HJn6rtvo2l1bh/CdsX14uVSdofXnuWGabNTydqtMvmCerZsdf6qKqLL+PYwm +RDpgWiRUY7KPBSSlKm/9lJzA+bOe4dHeJzxWFVCJcbpoiTFs1je1V8kKQaHtuW39 +ifX6LTKUMlwEECCbDKM8Yq2tm8NjkjCcnFDtKg8zKGPUu+jrFMN5otiC3wnKcP7r +O9EkaPcgYH8= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-key.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-key.pem new file mode 100644 index 000000000..6789d0291 --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/client-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1WElqTrwk7qc/Nn3POTqbxEO65AfF9qDjMgQqZMhygSK9WUI +EQ1gafVFTgEqtxp0hJZuvk9ym9UGzn32vBiJcLIwLninauS9nkpJjyBEO3qDuZBF +vuFfOuoRNS3i/z5h4NpRj9IqmYiWLRJYRzUDvN8vSO3Jt8VjAAN0klopFbaSUZ96 +iKdkEqx/rSvvxx2xOUMLT8XYq+FC0gkjmjOWIVouTraPPE2antb+0lwvOuCVbzuC +7ku07kq3gnVmcj10yRQRXghFkgMaJyma6h2wXgsCjC+v9Dbs42kOdXNrc5lSOsaR +49557uHEkceiX/VgZvt+b5tMJOKaIV96bXc3lQIDAQABAoIBAF7yjXmSOn7h6P0y +WCuGiTLG2mbDiLJqj2LTm2Z5i+2Cu/qZ7E76Ls63TxF4v3MemH5vGfQhEhR5ZD/6 +GRJ1sKKvB3WGRqjwA9gtojHH39S/nWGy6vYW/vMOOH37XyjIr3EIdIaUtFQBTSHd +Kd71niYrAbVn6fyWHolhADwnVmTMOl5OOAhCdEF4GN3b5aIhIu8BJ7EUzTtHBJIj +CAEfjZFjDs1y1cIgGFJkuIQxMfCpq5recU2qwip7YO6fk//WEjOPu7kSf5IEswL8 +jg1dea9rGBV6KaD2xsgsC6Ll6Sb4BbsrHMfflG3K2Lk3RdVqqTFp1Fn1PTLQE/1S +S/SZPYECgYEA9qYcHKHd0+Q5Ty5wgpxKGa4UCWkpwvfvyv4bh8qlmxueB+l2AIdo +ZvkM8gTPagPQ3WypAyC2b9iQu70uOJo1NizTtKnpjDdN1YpDjISJuS/P0x73gZwy +gmoM5AzMtN4D6IbxXtXnPaYICvwLKU80ouEN5ZPM4/ODLUu6gsp0v2UCgYEA3Xgi +zMC4JF0vEKEaK0H6QstaoXUmw/lToZGH3TEojBIkb/2LrHUclygtONh9kJSFb89/ +jbmRRLAOrx3HZKCNGUmF4H9k5OQyAIv6OGBinvLGqcbqnyNlI+Le8zxySYwKMlEj +EMrBCLmSyi0CGFrbZ3mlj/oCET/ql9rNvcK+DHECgYAEx5dH3sMjtgp+RFId1dWB +xePRgt4yTwewkVgLO5wV82UOljGZNQaK6Eyd7AXw8f38LHzh+KJQbIvxd2sL4cEi +OaAoohpKg0/Y0YMZl//rPMf0OWdmdZZs/I0fZjgZUSwWN3c59T8z7KG/RL8an9RP +S7kvN7wCttdV61/D5RR6GQKBgDxCe/WKWpBKaovzydMLWLTj7/0Oi0W3iXHkzzr4 +LTgvl4qBSofaNbVLUUKuZTv5rXUG2IYPf99YqCYtzBstNDc1MiAriaBeFtzfOW4t +i6gEFtoLLbuvPc3N5Sv5vn8Ug5G9UfU3td5R4AbyyCcoUZqOFuZd+EIJSiOXfXOs +kVmBAoGBAIU9aPAqhU5LX902oq8KsrpdySONqv5mtoStvl3wo95WIqXNEsFY60wO +q02jKQmJJ2MqhkJm2EoF2Mq8+40EZ5sz8LdgeQ/M0yQ9lAhPi4rftwhpe55Ma9dk +SE9X1c/DMCBEaIjJqVXdy0/EeArwpb8sHkguVVAZUWxzD+phm1gs +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/mongodb.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/mongodb.pem new file mode 100644 index 000000000..1fe94891a --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/mongodb.pem @@ -0,0 +1,46 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBAjANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0NloXDTMwMDYwOTAzMzg0NlowQDE+MDwGA1UEAww1TXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9TZXJ2ZXJfQ2VydGlmaWNhdGUw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcEnEm5hqP1EbEJycOz8Ua +NWp29QdpFUzTWhkKGhVXk+0msmNTw4NBAFB42moY44OU8wvDideOlJNhPRWveD8z +G2lxzJA91p0UK4et8ia9MmeuCGhdC9jxJ8X69WNlUiPyy0hI/ZsqRq9Z0C2eW0iL +JPXsy4X8Xpw3SFwoXf5pR9RFY5Pb2tuyxqmSestu2VXT/NQjJg4CVDR3mFcHPXZB +4elRzH0WshExEGkgy0bg20MJeRc2Qdb5Xx+EakbmwroDWaCn3NSGqQ7jv6Vw0doy +TGvS6h6RHBxnyqRfRgKGlCoOMG9/5+rFJC00QpCUG2vHXHWGoWlMlJ3foN7rj5v9 +AgMBAAGjDTALMAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAJ5zt2rj4Ag6 +zpN59AWC1Fur8g8l41ksHkSpKPp+PtyO/ngvbMqBpfmK1e7JCKZv/68QXfMyWWAI +hwalqZkXXWHKjuz3wE7dE25PXFXtGJtcZAaj10xt98fzdqt8lQSwh2kbfNwZIz1F +sgAStgE7+ZTcqTgvNB76Os1UK0to+/P0VBWktaVFdyub4Nc2SdPVnZNvrRBXBwOD +3V8ViwywDOFoE7DvCvwx/SVsvoC0Z4j3AMMovO6oHicP7uU83qsQgm1Qru3YeoLR ++DoVi7IPHbWvN7MqFYn3YjNlByO2geblY7MR0BlqbFlmFrqLsUfjsh2ys7/U/knC +dN/klu446fI= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnBJxJuYaj9RGxCcnDs/FGjVqdvUHaRVM01oZChoVV5PtJrJj +U8ODQQBQeNpqGOODlPMLw4nXjpSTYT0Vr3g/MxtpccyQPdadFCuHrfImvTJnrgho +XQvY8SfF+vVjZVIj8stISP2bKkavWdAtnltIiyT17MuF/F6cN0hcKF3+aUfURWOT +29rbssapknrLbtlV0/zUIyYOAlQ0d5hXBz12QeHpUcx9FrIRMRBpIMtG4NtDCXkX +NkHW+V8fhGpG5sK6A1mgp9zUhqkO47+lcNHaMkxr0uoekRwcZ8qkX0YChpQqDjBv +f+fqxSQtNEKQlBtrx1x1hqFpTJSd36De64+b/QIDAQABAoIBAFiah66Dt9SruLkn +WR8piUaFyLlcBib8Nq9OWSTJBhDAJERxxb4KIvvGB+l0ZgNXNp5bFPSfzsZdRwZP +PX5uj8Kd71Dxx3mz211WESMJdEC42u+MSmN4lGLkJ5t/sDwXU91E1vbJM0ve8THV +4/Ag9qA4DX2vVZOeyqT/6YHpSsPNZplqzrbAiwrfHwkctHfgqwOf3QLfhmVQgfCS +VwidBldEUv2whSIiIxh4Rv5St4kA68IBCbJxdpOpyuQBkk6CkxZ7VN9FqOuSd4Pk +Wm7iWyBMZsCmELZh5XAXld4BEt87C5R4CvbPBDZxAv3THk1DNNvpy3PFQfwARRFb +SAToYMECgYEAyL7U8yxpzHDYWd3oCx6vTi9p9N/z0FfAkWrRF6dm4UcSklNiT1Aq +EOnTA+SaW8tV3E64gCWcY23gNP8so/ZseWj6L+peHwtchaP9+KB7yGw2A+05+lOx +VetLTjAOmfpiUXFe5w1q4C1RGhLjZjjzW+GvwdAuchQgUEFaomrV+PUCgYEAxwfH +cmVGFbAktcjU4HSRjKSfawCrut+3YUOLybyku3Q/hP9amG8qkVTFe95CTLjLe2D0 +ccaTTpofFEJ32COeck0g0Ujn/qQ+KXRoauOYs4FB1DtqMpqB78wufWEUpDpbd9/h +J+gJdC/IADd4tJW9zA92g8IA7ZtFmqDtiSpQ0ekCgYAQGkaorvJZpN+l7cf0RGTZ +h7IfI2vCVZer0n6tQA9fmLzjoe6r4AlPzAHSOR8sp9XeUy43kUzHKQQoHCPvjw/K +eWJAP7OHF/k2+x2fOPhU7mEy1W+mJdp+wt4Kio5RSaVjVQ3AyPG+w8PSrJszEvRq +dWMMz+851WV2KpfjmWBKlQKBgQC++4j4DZQV5aMkSKV1CIZOBf3vaIJhXKEUFQPD +PmB4fBEjpwCg+zNGp6iktt65zi17o8qMjrb1mtCt2SY04eD932LZUHNFlwcLMmes +Ad+aiDLJ24WJL1f16eDGcOyktlblDZB5gZ/ovJzXEGOkLXglosTfo77OQculmDy2 +/UL2WQKBgGeKasmGNfiYAcWio+KXgFkHXWtAXB9B91B1OFnCa40wx+qnl71MIWQH +PQ/CZFNWOfGiNEJIZjrHsfNJoeXkhq48oKcT0AVCDYyLV0VxDO4ejT95mGW6njNd +JpvmhwwAjOvuWVr0tn4iXlSK8irjlJHmwcRjLTJq97vE9fsA2MjI +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/private_key.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/private_key.pem new file mode 100644 index 000000000..8fbf6bdec --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA1zVmMhPqpSPMmYkKh5wwlRD5XuS8YWJKEM6tjFx61VK8qxHE +YngkC2KnL5EuKAjQZIF3tJskwt0hAat047CCCZxrkNEpbVvSnvnk+A/8bg/Ww1n3 +qxzfifhsWfpUKlDnwrtH+ftt+5rZeEkf37XAPy7ZjzecAF9SDV6WSiPeAxUX2+hN +dId42Pf45woo4LFGUlQeagCFkD/R0dpNIMGwcnkKCUikiBqr2ijSIgvRtBfZ9fBG +jFGER2uE/Eay4AgcQsHue8skRwDCng8OnqtPnBtTytmqTy9V/BRgsVKUoksm6wsx +kUYwgHeaq7UCvlCm25SZ7yRyd4k8t0BKDf2h+wIDAQABAoIBAEQcrHmRACTADdNS +IjkFYALt2l8EOfMAbryfDSJtapr1kqz59JPNvmq0EIHnixo0n/APYdmReLML1ZR3 +tYkSpjVwgkLVUC1CcIjMQoGYXaZf8PLnGJHZk45RR8m6hsTV0mQ5bfBaeVa2jbma +OzJMjcnxg/3l9cPQZ2G/3AUfEPccMxOXp1KRz3mUQcGnKJGtDbN/kfmntcwYoxaE +Zg4RoeKAoMpK1SSHAiJKe7TnztINJ7uygR9XSzNd6auY8A3vomSIjpYO7XL+lh7L +izm4Ir3Gb/eCYBvWgQyQa2KCJgK/sQyEs3a09ngofSEUhQJQYhgZDwUj+fDDOGqj +hCZOA8ECgYEA+ZWuHdcUQ3ygYhLds2QcogUlIsx7C8n/Gk/FUrqqXJrTkuO0Eqqa +B47lCITvmn2zm0ODfSFIARgKEUEDLS/biZYv7SUTrFqBLcet+aGI7Dpv91CgB75R +tNzcIf8VxoiP0jPqdbh9mLbbxGi5Uc4p9TVXRljC4hkswaouebWee0sCgYEA3L2E +YB3kiHrhPI9LHS5Px9C1w+NOu5wP5snxrDGEgaFCvL6zgY6PflacppgnmTXl8D1x +im0IDKSw5dP3FFonSVXReq3CXDql7UnhfTCiLDahV7bLxTH42FofcBpDN3ERdOal +58RwQh6VrLkzQRVoObo+hbGlFiwwSAfQC509FhECgYBsRSBpVXo25IN2yBRg09cP ++gdoFyhxrsj5kw1YnB13WrrZh+oABv4WtUhp77E5ZbpaamlKCPwBbXpAjeFg4tfr +0bksuN7V79UGFQ9FsWuCfr8/nDwv38H2IbFlFhFONMOfPmJBey0Q6JJhm8R41mSh +OOiJXcv85UrjIH5U0hLUDQKBgQDVLOU5WcUJlPoOXSgiT0ZW5xWSzuOLRUUKEf6l +19BqzAzCcLy0orOrRAPW01xylt2v6/bJw1Ahva7k1ZZo/kOwjANYoZPxM+ZoSZBN +MXl8j2mzZuJVV1RFxItV3NcLJNPB/Lk+IbRz9kt/2f9InF7iWR3mSU/wIM6j0X+2 +p6yFsQKBgQCM/ldWb511lA+SNkqXB2P6WXAgAM/7+jwsNHX2ia2Ikufm4SUEKMSv +mti/nZkHDHsrHU4wb/2cOAywMELzv9EHzdcoenjBQP65OAc/1qWJs+LnBcCXfqKk +aHjEZW6+brkHdRGLLY3YAHlt/AUL+RsKPJfN72i/FSpmu+52G36eeQ== +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/public_key.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/public_key.pem new file mode 100644 index 000000000..f9772b533 --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1zVmMhPqpSPMmYkKh5ww +lRD5XuS8YWJKEM6tjFx61VK8qxHEYngkC2KnL5EuKAjQZIF3tJskwt0hAat047CC +CZxrkNEpbVvSnvnk+A/8bg/Ww1n3qxzfifhsWfpUKlDnwrtH+ftt+5rZeEkf37XA +Py7ZjzecAF9SDV6WSiPeAxUX2+hNdId42Pf45woo4LFGUlQeagCFkD/R0dpNIMGw +cnkKCUikiBqr2ijSIgvRtBfZ9fBGjFGER2uE/Eay4AgcQsHue8skRwDCng8OnqtP +nBtTytmqTy9V/BRgsVKUoksm6wsxkUYwgHeaq7UCvlCm25SZ7yRyd4k8t0BKDf2h ++wIDAQAB +-----END PUBLIC KEY----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-cert.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-cert.pem new file mode 100644 index 000000000..a2f9688df --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBAjANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0NloXDTMwMDYwOTAzMzg0NlowQDE+MDwGA1UEAww1TXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9TZXJ2ZXJfQ2VydGlmaWNhdGUw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcEnEm5hqP1EbEJycOz8Ua +NWp29QdpFUzTWhkKGhVXk+0msmNTw4NBAFB42moY44OU8wvDideOlJNhPRWveD8z +G2lxzJA91p0UK4et8ia9MmeuCGhdC9jxJ8X69WNlUiPyy0hI/ZsqRq9Z0C2eW0iL +JPXsy4X8Xpw3SFwoXf5pR9RFY5Pb2tuyxqmSestu2VXT/NQjJg4CVDR3mFcHPXZB +4elRzH0WshExEGkgy0bg20MJeRc2Qdb5Xx+EakbmwroDWaCn3NSGqQ7jv6Vw0doy +TGvS6h6RHBxnyqRfRgKGlCoOMG9/5+rFJC00QpCUG2vHXHWGoWlMlJ3foN7rj5v9 +AgMBAAGjDTALMAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAJ5zt2rj4Ag6 +zpN59AWC1Fur8g8l41ksHkSpKPp+PtyO/ngvbMqBpfmK1e7JCKZv/68QXfMyWWAI +hwalqZkXXWHKjuz3wE7dE25PXFXtGJtcZAaj10xt98fzdqt8lQSwh2kbfNwZIz1F +sgAStgE7+ZTcqTgvNB76Os1UK0to+/P0VBWktaVFdyub4Nc2SdPVnZNvrRBXBwOD +3V8ViwywDOFoE7DvCvwx/SVsvoC0Z4j3AMMovO6oHicP7uU83qsQgm1Qru3YeoLR ++DoVi7IPHbWvN7MqFYn3YjNlByO2geblY7MR0BlqbFlmFrqLsUfjsh2ys7/U/knC +dN/klu446fI= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-key.pem b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-key.pem new file mode 100644 index 000000000..a1dfd5f78 --- /dev/null +++ b/apps/emqx_auth_mongo/test/emqx_auth_mongo_SUITE_data/server-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnBJxJuYaj9RGxCcnDs/FGjVqdvUHaRVM01oZChoVV5PtJrJj +U8ODQQBQeNpqGOODlPMLw4nXjpSTYT0Vr3g/MxtpccyQPdadFCuHrfImvTJnrgho +XQvY8SfF+vVjZVIj8stISP2bKkavWdAtnltIiyT17MuF/F6cN0hcKF3+aUfURWOT +29rbssapknrLbtlV0/zUIyYOAlQ0d5hXBz12QeHpUcx9FrIRMRBpIMtG4NtDCXkX +NkHW+V8fhGpG5sK6A1mgp9zUhqkO47+lcNHaMkxr0uoekRwcZ8qkX0YChpQqDjBv +f+fqxSQtNEKQlBtrx1x1hqFpTJSd36De64+b/QIDAQABAoIBAFiah66Dt9SruLkn +WR8piUaFyLlcBib8Nq9OWSTJBhDAJERxxb4KIvvGB+l0ZgNXNp5bFPSfzsZdRwZP +PX5uj8Kd71Dxx3mz211WESMJdEC42u+MSmN4lGLkJ5t/sDwXU91E1vbJM0ve8THV +4/Ag9qA4DX2vVZOeyqT/6YHpSsPNZplqzrbAiwrfHwkctHfgqwOf3QLfhmVQgfCS +VwidBldEUv2whSIiIxh4Rv5St4kA68IBCbJxdpOpyuQBkk6CkxZ7VN9FqOuSd4Pk +Wm7iWyBMZsCmELZh5XAXld4BEt87C5R4CvbPBDZxAv3THk1DNNvpy3PFQfwARRFb +SAToYMECgYEAyL7U8yxpzHDYWd3oCx6vTi9p9N/z0FfAkWrRF6dm4UcSklNiT1Aq +EOnTA+SaW8tV3E64gCWcY23gNP8so/ZseWj6L+peHwtchaP9+KB7yGw2A+05+lOx +VetLTjAOmfpiUXFe5w1q4C1RGhLjZjjzW+GvwdAuchQgUEFaomrV+PUCgYEAxwfH +cmVGFbAktcjU4HSRjKSfawCrut+3YUOLybyku3Q/hP9amG8qkVTFe95CTLjLe2D0 +ccaTTpofFEJ32COeck0g0Ujn/qQ+KXRoauOYs4FB1DtqMpqB78wufWEUpDpbd9/h +J+gJdC/IADd4tJW9zA92g8IA7ZtFmqDtiSpQ0ekCgYAQGkaorvJZpN+l7cf0RGTZ +h7IfI2vCVZer0n6tQA9fmLzjoe6r4AlPzAHSOR8sp9XeUy43kUzHKQQoHCPvjw/K +eWJAP7OHF/k2+x2fOPhU7mEy1W+mJdp+wt4Kio5RSaVjVQ3AyPG+w8PSrJszEvRq +dWMMz+851WV2KpfjmWBKlQKBgQC++4j4DZQV5aMkSKV1CIZOBf3vaIJhXKEUFQPD +PmB4fBEjpwCg+zNGp6iktt65zi17o8qMjrb1mtCt2SY04eD932LZUHNFlwcLMmes +Ad+aiDLJ24WJL1f16eDGcOyktlblDZB5gZ/ovJzXEGOkLXglosTfo77OQculmDy2 +/UL2WQKBgGeKasmGNfiYAcWio+KXgFkHXWtAXB9B91B1OFnCa40wx+qnl71MIWQH +PQ/CZFNWOfGiNEJIZjrHsfNJoeXkhq48oKcT0AVCDYyLV0VxDO4ejT95mGW6njNd +JpvmhwwAjOvuWVr0tn4iXlSK8irjlJHmwcRjLTJq97vE9fsA2MjI +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mysql/.gitignore b/apps/emqx_auth_mysql/.gitignore new file mode 100644 index 000000000..bc6fa0f2f --- /dev/null +++ b/apps/emqx_auth_mysql/.gitignore @@ -0,0 +1,31 @@ +.eunit +deps +*.so +.iml +.idea +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +emqx_auth_mysql.d +ct.coverdata +logs/ +test/ct.cover.spec +test/*.beam +cover/ +eunit.coverdata +data +.placeholder +_build/ +rebar.lock +erlang.mk +rebar3.crashdump +etc/emqx_auth_mysql.conf.rendered +.rebar3/ +*.swp +.DS_Store diff --git a/apps/emqx_auth_mysql/README.md b/apps/emqx_auth_mysql/README.md new file mode 100644 index 000000000..e55a2103f --- /dev/null +++ b/apps/emqx_auth_mysql/README.md @@ -0,0 +1,167 @@ +emqx_auth_mysql +=============== + +Authentication, ACL with MySQL Database. + +Notice: changed mysql driver to [mysql-otp](https://github.com/mysql-otp/mysql-otp). + +Features +--------- + +- Full *Authentication*, *Superuser*, *ACL* support +- IPv4, IPv6 and TLS support +- Connection pool by [ecpool](https://github.com/emqx/ecpool) +- Completely cover MySQL 5.7, MySQL 8 in our tests + +Build Plugin +------------- + +make && make tests + +Configure Plugin +---------------- + +File: etc/emqx_auth_mysql.conf + +``` +## MySQL server address. +## +## Value: Port | IP:Port +## +## Examples: 3306, 127.0.0.1:3306, localhost:3306 +auth.mysql.server = 127.0.0.1:3306 + +## MySQL pool size. +## +## Value: Number +auth.mysql.pool = 8 + +## MySQL username. +## +## Value: String +## auth.mysql.username = + +## MySQL Password. +## +## Value: String +## auth.mysql.password = + +## MySQL database. +## +## Value: String +auth.mysql.database = mqtt + +## Variables: %u = username, %c = clientid + +## Authentication query. +## +## Note that column names should be 'password' and 'salt' (if used). +## In case column names differ in your DB - please use aliases, +## e.g. "my_column_name as password". +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +auth.mysql.auth_query = select password from mqtt_user where username = '%u' limit 1 +## auth.mysql.auth_query = select password_hash as password from mqtt_user where username = '%u' limit 1 + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.mysql.password_hash = sha256 + +## sha256 with salt prefix +## auth.mysql.password_hash = salt,sha256 + +## bcrypt with salt only prefix +## auth.mysql.password_hash = salt,bcrypt + +## sha256 with salt suffix +## auth.mysql.password_hash = sha256,salt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.mysql.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +auth.mysql.super_query = select is_superuser from mqtt_user where username = '%u' limit 1 + +## ACL query. +## +## Value: SQL +## +## Variables: +## - %a: ipaddr +## - %u: username +## - %c: clientid +## Note: You can add the 'ORDER BY' statement to control the rules match order +auth.mysql.acl_query = select allow, ipaddr, username, clientid, access, topic from mqtt_acl where ipaddr = '%a' or username = '%u' or username = '$all' or clientid = '%c' + +``` + +Import mqtt.sql +--------------- + +Import mqtt.sql into your database. + +Load Plugin +----------- + +./bin/emqx_ctl plugins load emqx_auth_mysql + +Auth Table +---------- + +Notice: This is a demo table. You could authenticate with any user table. + +```sql +CREATE TABLE `mqtt_user` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(100) DEFAULT NULL, + `password` varchar(100) DEFAULT NULL, + `salt` varchar(35) DEFAULT NULL, + `is_superuser` tinyint(1) DEFAULT 0, + `created` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `mqtt_username` (`username`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; +``` + +ACL Table +---------- + +```sql +CREATE TABLE `mqtt_acl` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `allow` int(1) DEFAULT NULL COMMENT '0: deny, 1: allow', + `ipaddr` varchar(60) DEFAULT NULL COMMENT 'IpAddress', + `username` varchar(100) DEFAULT NULL COMMENT 'Username', + `clientid` varchar(100) DEFAULT NULL COMMENT 'ClientId', + `access` int(2) NOT NULL COMMENT '1: subscribe, 2: publish, 3: pubsub', + `topic` varchar(100) NOT NULL DEFAULT '' COMMENT 'Topic Filter', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +``` + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. diff --git a/apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf b/apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf new file mode 100644 index 000000000..d367c2edc --- /dev/null +++ b/apps/emqx_auth_mysql/etc/emqx_auth_mysql.conf @@ -0,0 +1,116 @@ +##-------------------------------------------------------------------- +## MySQL Auth/ACL Plugin +##-------------------------------------------------------------------- + +## MySQL server address. +## +## Value: Port | IP:Port +## +## Examples: 3306, 127.0.0.1:3306, localhost:3306 +auth.mysql.server = 127.0.0.1:3306 + +## MySQL pool size. +## +## Value: Number +auth.mysql.pool = 8 + +## MySQL username. +## +## Value: String +#auth.mysql.username = + +## MySQL password. +## +## Value: String +#auth.mysql.password = + +## MySQL database. +## +## Value: String +auth.mysql.database = mqtt + +## MySQL query timeout +## +## Value: Duration +## auth.mysql.query_timeout = 5s + +## Variables: %u = username, %c = clientid + +## Authentication query. +## +## Note that column names should be 'password' and 'salt' (if used). +## In case column names differ in your DB - please use aliases, +## e.g. "my_column_name as password". +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +auth.mysql.auth_query = select password from mqtt_user where username = '%u' limit 1 +## auth.mysql.auth_query = select password_hash as password from mqtt_user where username = '%u' limit 1 + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.mysql.password_hash = sha256 + +## sha256 with salt prefix +## auth.mysql.password_hash = salt,sha256 + +## bcrypt with salt only prefix +## auth.mysql.password_hash = salt,bcrypt + +## sha256 with salt suffix +## auth.mysql.password_hash = sha256,salt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.mysql.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +auth.mysql.super_query = select is_superuser from mqtt_user where username = '%u' limit 1 + +## ACL query. +## +## Value: SQL +## +## Variables: +## - %a: ipaddr +## - %u: username +## - %c: clientid +## +## Note: You can add the 'ORDER BY' statement to control the rules match order +auth.mysql.acl_query = select allow, ipaddr, username, clientid, access, topic from mqtt_acl where ipaddr = '%a' or username = '%u' or username = '$all' or clientid = '%c' + +## Mysql ssl configuration. +## +## Value: on | off +#auth.mysql.ssl = off + +## CA certificate. +## +## Value: File +#auth.mysql.ssl.cacertfile = /path/to/ca.pem + +## Client ssl certificate. +## +## Value: File +#auth.mysql.ssl.certfile = /path/to/your/clientcert.pem + +## Client ssl keyfile. +## +## Value: File +#auth.mysql.ssl.keyfile = /path/to/your/clientkey.pem diff --git a/apps/emqx_auth_mysql/include/emqx_auth_mysql.hrl b/apps/emqx_auth_mysql/include/emqx_auth_mysql.hrl new file mode 100644 index 000000000..fca431e81 --- /dev/null +++ b/apps/emqx_auth_mysql/include/emqx_auth_mysql.hrl @@ -0,0 +1,23 @@ + +-define(APP, emqx_auth_mysql). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_mysql/mqtt.sql b/apps/emqx_auth_mysql/mqtt.sql new file mode 100644 index 000000000..9635bee58 --- /dev/null +++ b/apps/emqx_auth_mysql/mqtt.sql @@ -0,0 +1,41 @@ + +DROP TABLE IF EXISTS `mqtt_acl`; + +CREATE TABLE `mqtt_acl` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `allow` int(1) DEFAULT NULL COMMENT '0: deny, 1: allow', + `ipaddr` varchar(60) DEFAULT NULL COMMENT 'IpAddress', + `username` varchar(100) DEFAULT NULL COMMENT 'Username', + `clientid` varchar(100) DEFAULT NULL COMMENT 'ClientId', + `access` int(2) NOT NULL COMMENT '1: subscribe, 2: publish, 3: pubsub', + `topic` varchar(100) NOT NULL DEFAULT '' COMMENT 'Topic Filter', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +LOCK TABLES `mqtt_acl` WRITE; + +INSERT INTO `mqtt_acl` (`id`, `allow`, `ipaddr`, `username`, `clientid`, `access`, `topic`) +VALUES + (1,1,NULL,'$all',NULL,2,'#'), + (2,0,NULL,'$all',NULL,1,'$SYS/#'), + (3,0,NULL,'$all',NULL,1,'eq #'), + (4,1,'127.0.0.1',NULL,NULL,2,'$SYS/#'), + (5,1,'127.0.0.1',NULL,NULL,2,'#'), + (6,1,NULL,'dashboard',NULL,1,'$SYS/#'); + +UNLOCK TABLES; + + +DROP TABLE IF EXISTS `mqtt_user`; + +CREATE TABLE `mqtt_user` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(100) DEFAULT NULL, + `password` varchar(100) DEFAULT NULL, + `salt` varchar(35) DEFAULT NULL, + `is_superuser` tinyint(1) DEFAULT 0, + `created` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `mqtt_username` (`username`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; + diff --git a/apps/emqx_auth_mysql/priv/emqx_auth_mysql.schema b/apps/emqx_auth_mysql/priv/emqx_auth_mysql.schema new file mode 100644 index 000000000..8f9c069c4 --- /dev/null +++ b/apps/emqx_auth_mysql/priv/emqx_auth_mysql.schema @@ -0,0 +1,137 @@ +%%-*- mode: erlang -*- +%% emqx_auth_mysql config mapping +{mapping, "auth.mysql.server", "emqx_auth_mysql.server", [ + {default, {"127.0.0.1", 3306}}, + {datatype, [integer, ip, string]} +]}. + +{mapping, "auth.mysql.pool", "emqx_auth_mysql.server", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.mysql.username", "emqx_auth_mysql.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.mysql.password", "emqx_auth_mysql.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.mysql.database", "emqx_auth_mysql.server", [ + {default, "mqtt"}, + {datatype, string} +]}. + +{mapping, "auth.mysql.query_timeout", "emqx_auth_mysql.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.mysql.ssl", "emqx_auth_mysql.server", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.mysql.ssl.cafile", "emqx_auth_mysql.server", [ + {datatype, string} +]}. + +{mapping, "auth.mysql.ssl.cacertfile", "emqx_auth_mysql.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.mysql.ssl.certfile", "emqx_auth_mysql.server", [ + {datatype, string} +]}. + +{mapping, "auth.mysql.ssl.keyfile", "emqx_auth_mysql.server", [ + {datatype, string} +]}. + +{translation, "emqx_auth_mysql.server", fun(Conf) -> + {MyHost, MyPort} = + case cuttlefish:conf_get("auth.mysql.server", Conf) of + {Ip, Port} -> {Ip, Port}; + S -> case string:tokens(S, ":") of + [Domain] -> {Domain, 3306}; + [Domain, Port] -> {Domain, list_to_integer(Port)} + end + end, + Pool = cuttlefish:conf_get("auth.mysql.pool", Conf), + Username = cuttlefish:conf_get("auth.mysql.username", Conf), + Passwd = cuttlefish:conf_get("auth.mysql.password", Conf), + DB = cuttlefish:conf_get("auth.mysql.database", Conf), + Timeout = case cuttlefish:conf_get("auth.mysql.query_timeout", Conf) of + "" -> 300000; + Duration -> + case cuttlefish_duration:parse(Duration, ms) of + {error, Reason} -> error(Reason); + Ms when is_integer(Ms) -> Ms + end + end, + Options = [{pool_size, Pool}, + {auto_reconnect, 1}, + {host, MyHost}, + {port, MyPort}, + {user, Username}, + {password, Passwd}, + {database, DB}, + {encoding, utf8}, + {query_timeout, Timeout}, + {keep_alive, true}], + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + Options1 = + case cuttlefish:conf_get("auth.mysql.ssl", Conf) of + true -> + %% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 + CA = cuttlefish:conf_get( + "auth.mysql.ssl.cacertfile", Conf, + cuttlefish:conf_get("auth.mysql.ssl.cafile", Conf, undefined) + ), + Cert = cuttlefish:conf_get("auth.mysql.ssl.certfile", Conf, undefined), + Key = cuttlefish:conf_get("auth.mysql.ssl.keyfile", Conf, undefined), + Options ++ [{ssl, Filter([{server_name_indication, disable}, + {cacertfile, CA}, + {certfile, Cert}, + {keyfile, Key}]) + }]; + _ -> + Options + end, + case inet:parse_address(MyHost) of + {ok, IpAddr} when tuple_size(IpAddr) =:= 8 -> + [{tcp_options, [inet6]} | Options1]; + _ -> + Options1 + end +end}. + +{mapping, "auth.mysql.auth_query", "emqx_auth_mysql.auth_query", [ + {datatype, string} +]}. + +{mapping, "auth.mysql.password_hash", "emqx_auth_mysql.password_hash", [ + {datatype, string} +]}. + +{mapping, "auth.mysql.super_query", "emqx_auth_mysql.super_query", [ + {datatype, string} +]}. + +{mapping, "auth.mysql.acl_query", "emqx_auth_mysql.acl_query", [ + {datatype, string} +]}. + +{translation, "emqx_auth_mysql.password_hash", fun(Conf) -> + HashValue = cuttlefish:conf_get("auth.mysql.password_hash", Conf), + case string:tokens(HashValue, ",") of + [Hash] -> list_to_atom(Hash); + [Prefix, Suffix] -> {list_to_atom(Prefix), list_to_atom(Suffix)}; + [Hash, MacFun, Iterations, Dklen] -> {list_to_atom(Hash), list_to_atom(MacFun), list_to_integer(Iterations), list_to_integer(Dklen)}; + _ -> plain + end +end}. diff --git a/apps/emqx_auth_mysql/rebar.config b/apps/emqx_auth_mysql/rebar.config new file mode 100644 index 000000000..a02471969 --- /dev/null +++ b/apps/emqx_auth_mysql/rebar.config @@ -0,0 +1,24 @@ +{deps, + [ + {mysql, {git, "https://github.com/emqx/mysql-otp", {tag, "1.6.1"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + compressed, + {parse_transform} + ]}. +{overrides, [{add, [{erl_opts, [compressed]}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions + ]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_auth_mysql/src/emqx_acl_mysql.erl b/apps/emqx_auth_mysql/src/emqx_acl_mysql.erl new file mode 100644 index 000000000..174e3cd27 --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_acl_mysql.erl @@ -0,0 +1,119 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_mysql). + +-include("emqx_auth_mysql.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +%% ACL Callbacks +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(ClientInfo, PubSub, Topic, NoMatchAction, #{pool := Pool} = State) -> + case do_check_acl(Pool, ClientInfo, PubSub, Topic, NoMatchAction, State) of + ok -> emqx_metrics:inc(?ACL_METRICS(ignore)), ok; + {stop, allow} -> emqx_metrics:inc(?ACL_METRICS(allow)), {stop, allow}; + {stop, deny} -> emqx_metrics:inc(?ACL_METRICS(deny)), {stop, deny} + end. + +do_check_acl(_Pool, #{username := <<$$, _/binary>>}, _PubSub, _Topic, _NoMatchAction, _State) -> + ok; +do_check_acl(Pool, ClientInfo, PubSub, Topic, _NoMatchAction, #{acl_query := {AclSql, AclParams}}) -> + case emqx_auth_mysql_cli:query(Pool, AclSql, AclParams, ClientInfo) of + {ok, _Columns, []} -> ok; + {ok, _Columns, Rows} -> + Rules = filter(PubSub, compile(Rows)), + case match(ClientInfo, Topic, Rules) of + {matched, allow} -> {stop, allow}; + {matched, deny} -> {stop, deny}; + nomatch -> ok + end; + {error, Reason} -> + ?LOG(error, "[MySQL] do_check_acl error: ~p~n", [Reason]), + ok + end. + +match(_ClientInfo, _Topic, []) -> + nomatch; + +match(ClientInfo, Topic, [Rule|Rules]) -> + case emqx_access_rule:match(ClientInfo, Topic, Rule) of + nomatch -> + match(ClientInfo, Topic, Rules); + {matched, AllowDeny} -> + {matched, AllowDeny} + end. + +filter(PubSub, Rules) -> + [Term || Term = {_, _, Access, _} <- Rules, + Access =:= PubSub orelse Access =:= pubsub]. + +compile(Rows) -> + compile(Rows, []). +compile([], Acc) -> + Acc; +compile([[Allow, IpAddr, Username, ClientId, Access, Topic]|T], Acc) -> + Who = who(IpAddr, Username, ClientId), + Term = {allow(Allow), Who, access(Access), [topic(Topic)]}, + compile(T, [emqx_access_rule:compile(Term) | Acc]). + +who(_, <<"$all">>, _) -> + all; +who(null, null, null) -> + throw(undefined_who); +who(CIDR, Username, ClientId) -> + Cols = [{ipaddr, b2l(CIDR)}, {user, Username}, {client, ClientId}], + case [{C, V} || {C, V} <- Cols, not empty(V)] of + [Who] -> Who; + Conds -> {'and', Conds} + end. + +allow(1) -> allow; +allow(0) -> deny; +allow(<<"1">>) -> allow; +allow(<<"0">>) -> deny. + +access(1) -> subscribe; +access(2) -> publish; +access(3) -> pubsub; +access(<<"1">>) -> subscribe; +access(<<"2">>) -> publish; +access(<<"3">>) -> pubsub. + +topic(<<"eq ", Topic/binary>>) -> + {eq, Topic}; +topic(Topic) -> + Topic. + +description() -> + "ACL with Mysql". + +b2l(null) -> null; +b2l(B) -> binary_to_list(B). + +empty(null) -> true; +empty("") -> true; +empty(<<>>) -> true; +empty(_) -> false. diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src b/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src new file mode 100644 index 000000000..221061f03 --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_mysql, + [{description, "EMQ X Authentication/ACL with MySQL"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_mysql_sup]}, + {applications, [kernel,stdlib,mysql,ecpool]}, + {mod, {emqx_auth_mysql_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-mysql"} + ]} + ]}. diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql.erl b/apps/emqx_auth_mysql/src/emqx_auth_mysql.erl new file mode 100644 index 000000000..9cd9d5a25 --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql.erl @@ -0,0 +1,91 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mysql). + +-include("emqx_auth_mysql.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/types.hrl"). + +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-define(EMPTY(Username), (Username =:= undefined orelse Username =:= <<>>)). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo = #{password := Password}, AuthResult, + #{auth_query := {AuthSql, AuthParams}, + super_query := SuperQuery, + hash_type := HashType, + pool := Pool}) -> + CheckPass = case emqx_auth_mysql_cli:query(Pool, AuthSql, AuthParams, ClientInfo) of + {ok, [<<"password">>], [[PassHash]]} -> + check_pass({PassHash, Password}, HashType); + {ok, [<<"password">>, <<"salt">>], [[PassHash, Salt]]} -> + check_pass({PassHash, Salt, Password}, HashType); + {ok, _Columns, []} -> + {error, not_found}; + {error, Reason} -> + ?LOG(error, "[MySQL] query '~p' failed: ~p", [AuthSql, Reason]), + {error, Reason} + end, + case CheckPass of + ok -> + emqx_metrics:inc(?AUTH_METRICS(success)), + {stop, AuthResult#{is_superuser => is_superuser(Pool, SuperQuery, ClientInfo), + anonymous => false, + auth_result => success}}; + {error, not_found} -> + emqx_metrics:inc(?AUTH_METRICS(ignore)), ok; + {error, ResultCode} -> + ?LOG(error, "[MySQL] Auth from mysql failed: ~p", [ResultCode]), + emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => ResultCode, anonymous => false}} + end. + +%%-------------------------------------------------------------------- +%% Is Superuser? +%%-------------------------------------------------------------------- + +-spec(is_superuser(atom(), maybe({string(), list()}), emqx_types:client()) -> boolean()). +is_superuser(_Pool, undefined, _ClientInfo) -> false; +is_superuser(Pool, {SuperSql, Params}, ClientInfo) -> + case emqx_auth_mysql_cli:query(Pool, SuperSql, Params, ClientInfo) of + {ok, [_Super], [[1]]} -> + true; + {ok, [_Super], [[_False]]} -> + false; + {ok, [_Super], []} -> + false; + {error, _Error} -> + false + end. + +check_pass(Password, HashType) -> + case emqx_passwd:check_pass(Password, HashType) of + ok -> ok; + {error, _Reason} -> {error, not_authorized} + end. + +description() -> "Authentication with MySQL". + diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql_app.erl b/apps/emqx_auth_mysql/src/emqx_auth_mysql_app.erl new file mode 100644 index 000000000..3936f6c4c --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql_app.erl @@ -0,0 +1,74 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mysql_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_mysql.hrl"). + +-import(emqx_auth_mysql_cli, [parse_query/1]). + +%% Application callbacks +-export([ start/2 + , prep_stop/1 + , stop/1 + ]). + +%%-------------------------------------------------------------------- +%% Application callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_mysql_sup:start_link(), + _ = if_enabled(auth_query, fun load_auth_hook/1), + _ = if_enabled(acl_query, fun load_acl_hook/1), + + {ok, Sup}. + +prep_stop(State) -> + emqx:unhook('client.authenticate', fun emqx_auth_mysql:check/3), + emqx:unhook('client.check_acl', fun emqx_acl_mysql:check_acl/5), + State. + +stop(_State) -> + ok. + +load_auth_hook(AuthQuery) -> + ok = emqx_auth_mysql:register_metrics(), + SuperQuery = parse_query(application:get_env(?APP, super_query, undefined)), + {ok, HashType} = application:get_env(?APP, password_hash), + Params = #{auth_query => AuthQuery, + super_query => SuperQuery, + hash_type => HashType, + pool => ?APP}, + emqx:hook('client.authenticate', fun emqx_auth_mysql:check/3, [Params]). + +load_acl_hook(AclQuery) -> + ok = emqx_acl_mysql:register_metrics(), + emqx:hook('client.check_acl', fun emqx_acl_mysql:check_acl/5, [#{acl_query => AclQuery, pool =>?APP}]). + +%%-------------------------------------------------------------------- +%% Internal function +%%-------------------------------------------------------------------- + +if_enabled(Cfg, Fun) -> + case application:get_env(?APP, Cfg) of + {ok, Query} -> Fun(parse_query(Query)); + undefined -> ok + end. diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql_cli.erl b/apps/emqx_auth_mysql/src/emqx_auth_mysql_cli.erl new file mode 100644 index 000000000..c3ee3b02a --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql_cli.erl @@ -0,0 +1,91 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mysql_cli). + +-behaviour(ecpool_worker). + +-include("emqx_auth_mysql.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ parse_query/1 + , connect/1 + , query/4 + ]). + +%%-------------------------------------------------------------------- +%% Avoid SQL Injection: Parse SQL to Parameter Query. +%%-------------------------------------------------------------------- + +parse_query(undefined) -> + undefined; +parse_query(Sql) -> + case re:run(Sql, "'%[ucCad]'", [global, {capture, all, list}]) of + {match, Variables} -> + Params = [Var || [Var] <- Variables], + {re:replace(Sql, "'%[ucCad]'", "?", [global, {return, list}]), Params}; + nomatch -> + {Sql, []} + end. + +%%-------------------------------------------------------------------- +%% MySQL Connect/Query +%%-------------------------------------------------------------------- + +connect(Options) -> + case mysql:start_link(Options) of + {ok, Pid} -> {ok, Pid}; + ignore -> {error, ignore}; + {error, Reason = {{_, {error, econnrefused}}, _}} -> + ?LOG(error, "[MySQL] Can't connect to MySQL server: Connection refused."), + {error, Reason}; + {error, Reason = {ErrorCode, _, Error}} -> + ?LOG(error, "[MySQL] Can't connect to MySQL server: ~p - ~p", [ErrorCode, Error]), + {error, Reason}; + {error, Reason} -> + ?LOG(error, "[MySQL] Can't connect to MySQL server: ~p", [Reason]), + {error, Reason} + end. + +query(Pool, Sql, Params, ClientInfo) -> + ecpool:with_client(Pool, fun(C) -> mysql:query(C, Sql, replvar(Params, ClientInfo)) end). + +replvar(Params, ClientInfo) -> + replvar(Params, ClientInfo, []). + +replvar([], _ClientInfo, Acc) -> + lists:reverse(Acc); + +replvar(["'%u'" | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [safe_get(username, ClientInfo) | Acc]); +replvar(["'%c'" | Params], ClientInfo = #{clientid := ClientId}, Acc) -> + replvar(Params, ClientInfo, [ClientId | Acc]); +replvar(["'%a'" | Params], ClientInfo = #{peerhost := IpAddr}, Acc) -> + replvar(Params, ClientInfo, [inet_parse:ntoa(IpAddr) | Acc]); +replvar(["'%C'" | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [safe_get(cn, ClientInfo)| Acc]); +replvar(["'%d'" | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [safe_get(dn, ClientInfo)| Acc]); +replvar([Param | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [Param | Acc]). + +safe_get(K, ClientInfo) -> + bin(maps:get(K, ClientInfo, "undefined")). + +bin(A) when is_atom(A) -> atom_to_binary(A, utf8); +bin(B) when is_binary(B) -> B; +bin(X) -> X. diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql_sup.erl b/apps/emqx_auth_mysql/src/emqx_auth_mysql_sup.erl new file mode 100644 index 000000000..0b2bf2c38 --- /dev/null +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql_sup.erl @@ -0,0 +1,40 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mysql_sup). + +-behaviour(supervisor). + +-include("emqx_auth_mysql.hrl"). + +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +%%-------------------------------------------------------------------- +%% Supervisor callbacks +%%-------------------------------------------------------------------- + +init([]) -> + %% MySQL Connection Pool. + {ok, Server} = application:get_env(?APP, server), + PoolSpec = ecpool:pool_spec(?APP, ?APP, emqx_auth_mysql_cli, Server), + {ok, {{one_for_one, 10, 100}, [PoolSpec]}}. + diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE.erl b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE.erl new file mode 100644 index 000000000..044655ac1 --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE.erl @@ -0,0 +1,234 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_mysql_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-define(APP, emqx_auth_mysql). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-define(DROP_ACL_TABLE, <<"DROP TABLE IF EXISTS mqtt_acl">>). + +-define(CREATE_ACL_TABLE, <<"CREATE TABLE mqtt_acl (" + " id int(11) unsigned NOT NULL AUTO_INCREMENT," + " allow int(1) DEFAULT NULL COMMENT '0: deny, 1: allow'," + " ipaddr varchar(60) DEFAULT NULL COMMENT 'IpAddress'," + " username varchar(100) DEFAULT NULL COMMENT 'Username'," + " clientid varchar(100) DEFAULT NULL COMMENT 'ClientId'," + " access int(2) NOT NULL COMMENT '1: subscribe, 2: publish, 3: pubsub'," + " topic varchar(100) NOT NULL DEFAULT '' COMMENT 'Topic Filter'," + " PRIMARY KEY (`id`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4">>). + +-define(INIT_ACL, <<"INSERT INTO mqtt_acl (id, allow, ipaddr, username, clientid, access, topic)" + "VALUES (1,1,'127.0.0.1','u1','c1',1,'t1')," + "(2,0,'127.0.0.1','u2','c2',1,'t1')," + "(3,1,'10.10.0.110','u1','c1',1,'t1')," + "(4,1,'127.0.0.1','u3','c3',3,'t1')">>). + +-define(DROP_AUTH_TABLE, <<"DROP TABLE IF EXISTS `mqtt_user`">>). + +-define(CREATE_AUTH_TABLE, <<"CREATE TABLE `mqtt_user` (" + "`id` int(11) unsigned NOT NULL AUTO_INCREMENT," + "`username` varchar(100) DEFAULT NULL," + "`password` varchar(100) DEFAULT NULL," + "`salt` varchar(100) DEFAULT NULL," + "`is_superuser` tinyint(1) DEFAULT 0," + "`created` datetime DEFAULT NULL," + "PRIMARY KEY (`id`)," + "UNIQUE KEY `mqtt_username` (`username`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4">>). + +-define(INIT_AUTH, <<"INSERT INTO mqtt_user (id, is_superuser, username, password, salt)" + "VALUES (1, 1, 'plain', 'plain', 'salt')," + "(2, 0, 'md5', '1bc29b36f623ba82aaf6724fd3b16718', 'salt')," + "(3, 0, 'sha', 'd8f4590320e1343a915b6394170650a8f35d6926', 'salt')," + "(4, 0, 'sha256', '5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e', 'salt')," + "(5, 0, 'pbkdf2_password', 'cdedb5281bb2f801565a1122b2563515', 'ATHENA.MIT.EDUraeburn')," + "(6, 0, 'bcrypt_foo', '$2a$12$sSS8Eg.ovVzaHzi1nUHYK.HbUIOdlQI0iS22Q5rd5z.JVVYH6sfm6', '$2a$12$sSS8Eg.ovVzaHzi1nUHYK.')," + "(7, 0, 'bcrypt', '$2y$16$rEVsDarhgHYB0TGnDFJzyu5f.T.Ha9iXMTk9J36NCMWWM7O16qyaK', 'salt')," + "(8, 0, 'bcrypt_wrong', '$2y$16$rEVsDarhgHYB0TGnDFJzyu', 'salt')">>). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + emqx_ct_helpers:start_apps([emqx_auth_mysql], fun set_special_configs/1), + init_mysql_data(), + Cfg. + +end_per_suite(_) -> + deinit_mysql_data(), + emqx_ct_helpers:stop_apps([emqx_auth_mysql]), + ok. + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); + +set_special_configs(_App) -> + ok. + +init_mysql_data() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?APP})), + %% Users + ok = mysql:query(Pid, ?DROP_AUTH_TABLE), + ok = mysql:query(Pid, ?CREATE_AUTH_TABLE), + ok = mysql:query(Pid, ?INIT_AUTH), + + %% ACLs + ok = mysql:query(Pid, ?DROP_ACL_TABLE), + ok = mysql:query(Pid, ?CREATE_ACL_TABLE), + ok = mysql:query(Pid, ?INIT_ACL). + +deinit_mysql_data() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?APP})), + ok = mysql:query(Pid, ?DROP_AUTH_TABLE), + ok = mysql:query(Pid, ?DROP_ACL_TABLE). + +%%-------------------------------------------------------------------- +%% Test cases +%%-------------------------------------------------------------------- + +t_check_acl(_) -> + User0 = #{zone => external,peerhost => {127,0,0,1}}, + allow = emqx_access_control:check_acl(User0, subscribe, <<"t1">>), + User1 = #{zone => external, clientid => <<"c1">>, username => <<"u1">>, peerhost => {127,0,0,1}}, + User2 = #{zone => external, clientid => <<"c2">>, username => <<"u2">>, peerhost => {127,0,0,1}}, + allow = emqx_access_control:check_acl(User1, subscribe, <<"t1">>), + deny = emqx_access_control:check_acl(User2, subscribe, <<"t1">>), + + User3 = #{zone => external, peerhost => {10,10,0,110}, clientid => <<"c1">>, username => <<"u1">>}, + User4 = #{zone => external, peerhost => {10,10,10,110}, clientid => <<"c1">>, username => <<"u1">>}, + allow = emqx_access_control:check_acl(User3, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"t2">>),%% nomatch -> ignore -> emqx acl + allow = emqx_access_control:check_acl(User4, subscribe, <<"t1">>),%% nomatch -> ignore -> emqx acl + User5 = #{zone => external, peerhost => {127,0,0,1}, clientid => <<"c3">>, username => <<"u3">>}, + allow = emqx_access_control:check_acl(User5, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User5, publish, <<"t1">>). + +t_acl_super(_Config) -> + reload([{password_hash, plain}, + {auth_query, "select password from mqtt_user where username = '%u' limit 1"}]), + {ok, C} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"simpleClient">>}, + {username, <<"plain">>}, + {password, <<"plain">>}]), + {ok, _} = emqtt:connect(C), + timer:sleep(10), + emqtt:subscribe(C, <<"TopicA">>, qos2), + timer:sleep(1000), + emqtt:publish(C, <<"TopicA">>, <<"Payload">>, qos2), + timer:sleep(1000), + receive + {publish, #{payload := Payload}} -> + ?assertEqual(<<"Payload">>, Payload) + after + 1000 -> + ct:fail({receive_timeout, <<"Payload">>}), + ok + end, + emqtt:disconnect(C). + +t_check_auth(_) -> + Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, + Md5 = #{clientid => <<"md5">>, username => <<"md5">>, zone => external}, + Sha = #{clientid => <<"sha">>, username => <<"sha">>, zone => external}, + Sha256 = #{clientid => <<"sha256">>, username => <<"sha256">>, zone => external}, + Pbkdf2 = #{clientid => <<"pbkdf2_password">>, username => <<"pbkdf2_password">>, zone => external}, + BcryptFoo = #{clientid => <<"bcrypt_foo">>, username => <<"bcrypt_foo">>, zone => external}, + User1 = #{clientid => <<"bcrypt_foo">>, username => <<"user">>, zone => external}, + Bcrypt = #{clientid => <<"bcrypt">>, username => <<"bcrypt">>, zone => external}, + BcryptWrong = #{clientid => <<"bcrypt_wrong">>, username => <<"bcrypt_wrong">>, zone => external}, + reload([{password_hash, plain}]), + {ok,#{is_superuser := true}} = + emqx_access_control:authenticate(Plain#{password => <<"plain">>}), + reload([{password_hash, md5}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Md5#{password => <<"md5">>}), + reload([{password_hash, sha}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Sha#{password => <<"sha">>}), + reload([{password_hash, sha256}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Sha256#{password => <<"sha256">>}), + reload([{password_hash, bcrypt}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}), + {error, not_authorized} = + emqx_access_control:authenticate(BcryptWrong#{password => <<"password">>}), + %%pbkdf2 sha + reload([{password_hash, {pbkdf2, sha, 1, 16}}, + {auth_query, "select password, salt from mqtt_user where username = '%u' limit 1"}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Pbkdf2#{password => <<"password">>}), + reload([{password_hash, {salt, bcrypt}}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(BcryptFoo#{password => <<"foo">>}), + {error, _} = emqx_access_control:authenticate(User1#{password => <<"foo">>}), + {error, not_authorized} = emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}). + +t_comment_config(_) -> + application:stop(?APP), + [application:unset_env(?APP, Par) || Par <- [acl_query, auth_query]], + application:start(?APP). + +t_placeholders(_) -> + ClientA = #{username => <<"plain">>, clientid => <<"plain">>, zone => external}, + + reload([{password_hash, plain}, + {auth_query, "select password from mqtt_user where username = '%u' and 'a_cn_val' = '%C' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, cn => undefined}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, cn => <<"a_cn_val">>}), + + reload([{auth_query, "select password from mqtt_user where username = '%c' and 'a_dn_val' = '%d' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, dn => undefined}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, dn => <<"a_dn_val">>}), + + reload([{auth_query, "select password from mqtt_user where username = '%u' and '192.168.1.5' = '%a' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, peerhost => {192,168,1,5}}). + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +reload(Config) when is_list(Config) -> + application:stop(?APP), + [application:set_env(?APP, K, V) || {K, V} <- Config], + application:start(?APP). diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca-key.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca-key.pem new file mode 100644 index 000000000..e9717011e --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0kGUBi9NDp65jgdxKfizIfuSr2wpwb44yM9SuP4oUQSULOA2 +4iFpLR/c5FAYHU81y9Vx91dQjdZfffaBZuv2zVvteXUkol8Nez7boKbo2E41MTew +8edtNKZAQVvnaHAC2NCZxjchCzUCDEoUUcl+cIERZ8R48FBqK5iTVcMRIx1akwus ++dhBqP0ykA5TGOWZkJrLM9aUXSPQha9+wXlOpkvu0Ur2nkX8PPJnifWao9UShSar +ll1IqPZNCSlZMwcFYcQNBCpdvITUUYlHvMRQV64bUpOxUGDuJkQL3dLKBlNuBRlJ +BcjBAKw7rFnwwHZcMmQ9tan/dZzpzwjo/T0XjwIDAQABAoIBAQCSHvUqnzDkWjcG +l/Fzg92qXlYBCCC0/ugj1sHcwvVt6Mq5rVE3MpUPwTcYjPlVVTlD4aEEjm/zQuq2 +ddxUlOS+r4aIhHrjRT/vSS4FpjnoKeIZxGR6maVxk6DQS3i1QjMYT1CvSpzyVvKH +a+xXMrtmoKxh+085ZAmFJtIuJhUA2yEa4zggCxWnvz8ecLClUPfVDPhdLBHc3KmL +CRpHEC6L/wanvDPRdkkzfKyaJuIJlTDaCg63AY5sDkTW2I57iI/nJ3haSeidfQKz +39EfbnM1A/YprIakafjAu3frBIsjBVcxwGihZmL/YriTHjOggJF841kT5zFkkv2L +/530Wk6xAoGBAOqZLZ4DIi/zLndEOz1mRbUfjc7GQUdYplBnBwJ22VdS0P4TOXnd +UbJth2MA92NM7ocTYVFl4TVIZY/Y+Prxk7KQdHWzR7JPpKfx9OEVgtSqV0vF9eGI +rKp79Y1T4Mvc3UcQCXX6TP7nHLihEzpS8odm2LW4txrOiLsn4Fq/IWrLAoGBAOVv +6U4tm3lImotUupKLZPKEBYwruo9qRysoug9FiorP4TjaBVOfltiiHbAQD6aGfVtN +SZpZZtrs17wL7Xl4db5asgMcZd+8Hkfo5siR7AuGW9FZloOjDcXb5wCh9EvjJ74J +Cjw7RqyVymq9t7IP6wnVwj5Ck48YhlOZCz/mzlnNAoGAWq7NYFgLvgc9feLFF23S +IjpJQZWHJEITP98jaYNxbfzYRm49+GphqxwFinKULjFNvq7yHlnIXSVYBOu1CqOZ +GRwXuGuNmlKI7lZr9xmukfAqgGLMMdr4C4qRF4lFyufcLRz42z7exmWlx4ST/yaT +E13hBRWayeTuG5JFei6Jh1MCgYEAqmX4LyC+JFBgvvQZcLboLRkSCa18bADxhENG +FAuAvmFvksqRRC71WETmqZj0Fqgxt7pp3KFjO1rFSprNLvbg85PmO1s+6fCLyLpX +lESTu2d5D71qhK93jigooxalGitFm+SY3mzjq0/AOpBWOn+J/w7rqVPGxXLgaHv0 +l+vx+00CgYBOvo9/ImjwYii2jFl+sHEoCzlvpITi2temRlT2j6ulSjCLJgjwEFw9 +8e+vvfQumQOsutakUVyURrkMGNDiNlIv8kv5YLCCkrwN22E6Ghyi69MJUvHQXkc/ +QZhjn/luyfpB5f/BeHFS2bkkxAXo+cfG45ApY3Qfz6/7o+H+vDa6/A== +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca.pem new file mode 100644 index 000000000..00b31d8a4 --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIBATANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0NloXDTMwMDYwOTAzMzg0NlowPDE6MDgGA1UEAwwxTXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9DQV9DZXJ0aWZpY2F0ZTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANJBlAYvTQ6euY4HcSn4syH7kq9s +KcG+OMjPUrj+KFEElCzgNuIhaS0f3ORQGB1PNcvVcfdXUI3WX332gWbr9s1b7Xl1 +JKJfDXs+26Cm6NhONTE3sPHnbTSmQEFb52hwAtjQmcY3IQs1AgxKFFHJfnCBEWfE +ePBQaiuYk1XDESMdWpMLrPnYQaj9MpAOUxjlmZCayzPWlF0j0IWvfsF5TqZL7tFK +9p5F/DzyZ4n1mqPVEoUmq5ZdSKj2TQkpWTMHBWHEDQQqXbyE1FGJR7zEUFeuG1KT +sVBg7iZEC93SygZTbgUZSQXIwQCsO6xZ8MB2XDJkPbWp/3Wc6c8I6P09F48CAwEA +AaMQMA4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEADKz6bIpP5anp +GgLB0jkclRWuMlS4qqIt4itSsMXPJ/ezpHwECixmgW2TIQl6S1woRkUeMxhT2/Ay +Sn/7aKxuzRagyE5NEGOvrOuAP5RO2ZdNJ/X3/Rh533fK1sOTEEbSsWUvW6iSkZef +rsfZBVP32xBhRWkKRdLeLB4W99ADMa0IrTmZPCXHSSE2V4e1o6zWLXcOZeH1Qh8N +SkelBweR+8r1Fbvy1r3s7eH7DCbYoGEDVLQGOLvzHKBisQHmoDnnF5E9g1eeNRdg +o+vhOKfYCOzeNREJIqS42PHcGhdNRk90ycigPmfUJclz1mDHoMjKR2S5oosTpr65 +tNPx3CL7GA== +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-cert.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-cert.pem new file mode 100644 index 000000000..aad1404ca --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBAzANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0N1oXDTMwMDYwOTAzMzg0N1owQDE+MDwGA1UEAww1TXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9DbGllbnRfQ2VydGlmaWNhdGUw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVYSWpOvCTupz82fc85Opv +EQ7rkB8X2oOMyBCpkyHKBIr1ZQgRDWBp9UVOASq3GnSElm6+T3Kb1QbOffa8GIlw +sjAueKdq5L2eSkmPIEQ7eoO5kEW+4V866hE1LeL/PmHg2lGP0iqZiJYtElhHNQO8 +3y9I7cm3xWMAA3SSWikVtpJRn3qIp2QSrH+tK+/HHbE5QwtPxdir4ULSCSOaM5Yh +Wi5Oto88TZqe1v7SXC864JVvO4LuS7TuSreCdWZyPXTJFBFeCEWSAxonKZrqHbBe +CwKML6/0NuzjaQ51c2tzmVI6xpHj3nnu4cSRx6Jf9WBm+35vm0wk4pohX3ptdzeV +AgMBAAGjDTALMAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAByQ5zSNeFUH +Aw7JlpZHtHaSEeiiyBHke20ziQ07BK1yi/ms2HAWwQkpZv149sjNuIRH8pkTmkZn +g8PDzSefjLbC9AsWpWV0XNV22T/cdobqLqMBDDZ2+5bsV+jTrOigWd9/AHVZ93PP +IJN8HJn6rtvo2l1bh/CdsX14uVSdofXnuWGabNTydqtMvmCerZsdf6qKqLL+PYwm +RDpgWiRUY7KPBSSlKm/9lJzA+bOe4dHeJzxWFVCJcbpoiTFs1je1V8kKQaHtuW39 +ifX6LTKUMlwEECCbDKM8Yq2tm8NjkjCcnFDtKg8zKGPUu+jrFMN5otiC3wnKcP7r +O9EkaPcgYH8= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-key.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-key.pem new file mode 100644 index 000000000..6789d0291 --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/client-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1WElqTrwk7qc/Nn3POTqbxEO65AfF9qDjMgQqZMhygSK9WUI +EQ1gafVFTgEqtxp0hJZuvk9ym9UGzn32vBiJcLIwLninauS9nkpJjyBEO3qDuZBF +vuFfOuoRNS3i/z5h4NpRj9IqmYiWLRJYRzUDvN8vSO3Jt8VjAAN0klopFbaSUZ96 +iKdkEqx/rSvvxx2xOUMLT8XYq+FC0gkjmjOWIVouTraPPE2antb+0lwvOuCVbzuC +7ku07kq3gnVmcj10yRQRXghFkgMaJyma6h2wXgsCjC+v9Dbs42kOdXNrc5lSOsaR +49557uHEkceiX/VgZvt+b5tMJOKaIV96bXc3lQIDAQABAoIBAF7yjXmSOn7h6P0y +WCuGiTLG2mbDiLJqj2LTm2Z5i+2Cu/qZ7E76Ls63TxF4v3MemH5vGfQhEhR5ZD/6 +GRJ1sKKvB3WGRqjwA9gtojHH39S/nWGy6vYW/vMOOH37XyjIr3EIdIaUtFQBTSHd +Kd71niYrAbVn6fyWHolhADwnVmTMOl5OOAhCdEF4GN3b5aIhIu8BJ7EUzTtHBJIj +CAEfjZFjDs1y1cIgGFJkuIQxMfCpq5recU2qwip7YO6fk//WEjOPu7kSf5IEswL8 +jg1dea9rGBV6KaD2xsgsC6Ll6Sb4BbsrHMfflG3K2Lk3RdVqqTFp1Fn1PTLQE/1S +S/SZPYECgYEA9qYcHKHd0+Q5Ty5wgpxKGa4UCWkpwvfvyv4bh8qlmxueB+l2AIdo +ZvkM8gTPagPQ3WypAyC2b9iQu70uOJo1NizTtKnpjDdN1YpDjISJuS/P0x73gZwy +gmoM5AzMtN4D6IbxXtXnPaYICvwLKU80ouEN5ZPM4/ODLUu6gsp0v2UCgYEA3Xgi +zMC4JF0vEKEaK0H6QstaoXUmw/lToZGH3TEojBIkb/2LrHUclygtONh9kJSFb89/ +jbmRRLAOrx3HZKCNGUmF4H9k5OQyAIv6OGBinvLGqcbqnyNlI+Le8zxySYwKMlEj +EMrBCLmSyi0CGFrbZ3mlj/oCET/ql9rNvcK+DHECgYAEx5dH3sMjtgp+RFId1dWB +xePRgt4yTwewkVgLO5wV82UOljGZNQaK6Eyd7AXw8f38LHzh+KJQbIvxd2sL4cEi +OaAoohpKg0/Y0YMZl//rPMf0OWdmdZZs/I0fZjgZUSwWN3c59T8z7KG/RL8an9RP +S7kvN7wCttdV61/D5RR6GQKBgDxCe/WKWpBKaovzydMLWLTj7/0Oi0W3iXHkzzr4 +LTgvl4qBSofaNbVLUUKuZTv5rXUG2IYPf99YqCYtzBstNDc1MiAriaBeFtzfOW4t +i6gEFtoLLbuvPc3N5Sv5vn8Ug5G9UfU3td5R4AbyyCcoUZqOFuZd+EIJSiOXfXOs +kVmBAoGBAIU9aPAqhU5LX902oq8KsrpdySONqv5mtoStvl3wo95WIqXNEsFY60wO +q02jKQmJJ2MqhkJm2EoF2Mq8+40EZ5sz8LdgeQ/M0yQ9lAhPi4rftwhpe55Ma9dk +SE9X1c/DMCBEaIjJqVXdy0/EeArwpb8sHkguVVAZUWxzD+phm1gs +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/private_key.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/private_key.pem new file mode 100644 index 000000000..8fbf6bdec --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA1zVmMhPqpSPMmYkKh5wwlRD5XuS8YWJKEM6tjFx61VK8qxHE +YngkC2KnL5EuKAjQZIF3tJskwt0hAat047CCCZxrkNEpbVvSnvnk+A/8bg/Ww1n3 +qxzfifhsWfpUKlDnwrtH+ftt+5rZeEkf37XAPy7ZjzecAF9SDV6WSiPeAxUX2+hN +dId42Pf45woo4LFGUlQeagCFkD/R0dpNIMGwcnkKCUikiBqr2ijSIgvRtBfZ9fBG +jFGER2uE/Eay4AgcQsHue8skRwDCng8OnqtPnBtTytmqTy9V/BRgsVKUoksm6wsx +kUYwgHeaq7UCvlCm25SZ7yRyd4k8t0BKDf2h+wIDAQABAoIBAEQcrHmRACTADdNS +IjkFYALt2l8EOfMAbryfDSJtapr1kqz59JPNvmq0EIHnixo0n/APYdmReLML1ZR3 +tYkSpjVwgkLVUC1CcIjMQoGYXaZf8PLnGJHZk45RR8m6hsTV0mQ5bfBaeVa2jbma +OzJMjcnxg/3l9cPQZ2G/3AUfEPccMxOXp1KRz3mUQcGnKJGtDbN/kfmntcwYoxaE +Zg4RoeKAoMpK1SSHAiJKe7TnztINJ7uygR9XSzNd6auY8A3vomSIjpYO7XL+lh7L +izm4Ir3Gb/eCYBvWgQyQa2KCJgK/sQyEs3a09ngofSEUhQJQYhgZDwUj+fDDOGqj +hCZOA8ECgYEA+ZWuHdcUQ3ygYhLds2QcogUlIsx7C8n/Gk/FUrqqXJrTkuO0Eqqa +B47lCITvmn2zm0ODfSFIARgKEUEDLS/biZYv7SUTrFqBLcet+aGI7Dpv91CgB75R +tNzcIf8VxoiP0jPqdbh9mLbbxGi5Uc4p9TVXRljC4hkswaouebWee0sCgYEA3L2E +YB3kiHrhPI9LHS5Px9C1w+NOu5wP5snxrDGEgaFCvL6zgY6PflacppgnmTXl8D1x +im0IDKSw5dP3FFonSVXReq3CXDql7UnhfTCiLDahV7bLxTH42FofcBpDN3ERdOal +58RwQh6VrLkzQRVoObo+hbGlFiwwSAfQC509FhECgYBsRSBpVXo25IN2yBRg09cP ++gdoFyhxrsj5kw1YnB13WrrZh+oABv4WtUhp77E5ZbpaamlKCPwBbXpAjeFg4tfr +0bksuN7V79UGFQ9FsWuCfr8/nDwv38H2IbFlFhFONMOfPmJBey0Q6JJhm8R41mSh +OOiJXcv85UrjIH5U0hLUDQKBgQDVLOU5WcUJlPoOXSgiT0ZW5xWSzuOLRUUKEf6l +19BqzAzCcLy0orOrRAPW01xylt2v6/bJw1Ahva7k1ZZo/kOwjANYoZPxM+ZoSZBN +MXl8j2mzZuJVV1RFxItV3NcLJNPB/Lk+IbRz9kt/2f9InF7iWR3mSU/wIM6j0X+2 +p6yFsQKBgQCM/ldWb511lA+SNkqXB2P6WXAgAM/7+jwsNHX2ia2Ikufm4SUEKMSv +mti/nZkHDHsrHU4wb/2cOAywMELzv9EHzdcoenjBQP65OAc/1qWJs+LnBcCXfqKk +aHjEZW6+brkHdRGLLY3YAHlt/AUL+RsKPJfN72i/FSpmu+52G36eeQ== +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/public_key.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/public_key.pem new file mode 100644 index 000000000..f9772b533 --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1zVmMhPqpSPMmYkKh5ww +lRD5XuS8YWJKEM6tjFx61VK8qxHEYngkC2KnL5EuKAjQZIF3tJskwt0hAat047CC +CZxrkNEpbVvSnvnk+A/8bg/Ww1n3qxzfifhsWfpUKlDnwrtH+ftt+5rZeEkf37XA +Py7ZjzecAF9SDV6WSiPeAxUX2+hNdId42Pf45woo4LFGUlQeagCFkD/R0dpNIMGw +cnkKCUikiBqr2ijSIgvRtBfZ9fBGjFGER2uE/Eay4AgcQsHue8skRwDCng8OnqtP +nBtTytmqTy9V/BRgsVKUoksm6wsxkUYwgHeaq7UCvlCm25SZ7yRyd4k8t0BKDf2h ++wIDAQAB +-----END PUBLIC KEY----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-cert.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-cert.pem new file mode 100644 index 000000000..a2f9688df --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBAjANBgkqhkiG9w0BAQsFADA8MTowOAYDVQQDDDFNeVNR +TF9TZXJ2ZXJfOC4wLjE5X0F1dG9fR2VuZXJhdGVkX0NBX0NlcnRpZmljYXRlMB4X +DTIwMDYxMTAzMzg0NloXDTMwMDYwOTAzMzg0NlowQDE+MDwGA1UEAww1TXlTUUxf +U2VydmVyXzguMC4xOV9BdXRvX0dlbmVyYXRlZF9TZXJ2ZXJfQ2VydGlmaWNhdGUw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcEnEm5hqP1EbEJycOz8Ua +NWp29QdpFUzTWhkKGhVXk+0msmNTw4NBAFB42moY44OU8wvDideOlJNhPRWveD8z +G2lxzJA91p0UK4et8ia9MmeuCGhdC9jxJ8X69WNlUiPyy0hI/ZsqRq9Z0C2eW0iL +JPXsy4X8Xpw3SFwoXf5pR9RFY5Pb2tuyxqmSestu2VXT/NQjJg4CVDR3mFcHPXZB +4elRzH0WshExEGkgy0bg20MJeRc2Qdb5Xx+EakbmwroDWaCn3NSGqQ7jv6Vw0doy +TGvS6h6RHBxnyqRfRgKGlCoOMG9/5+rFJC00QpCUG2vHXHWGoWlMlJ3foN7rj5v9 +AgMBAAGjDTALMAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggEBAJ5zt2rj4Ag6 +zpN59AWC1Fur8g8l41ksHkSpKPp+PtyO/ngvbMqBpfmK1e7JCKZv/68QXfMyWWAI +hwalqZkXXWHKjuz3wE7dE25PXFXtGJtcZAaj10xt98fzdqt8lQSwh2kbfNwZIz1F +sgAStgE7+ZTcqTgvNB76Os1UK0to+/P0VBWktaVFdyub4Nc2SdPVnZNvrRBXBwOD +3V8ViwywDOFoE7DvCvwx/SVsvoC0Z4j3AMMovO6oHicP7uU83qsQgm1Qru3YeoLR ++DoVi7IPHbWvN7MqFYn3YjNlByO2geblY7MR0BlqbFlmFrqLsUfjsh2ys7/U/knC +dN/klu446fI= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-key.pem b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-key.pem new file mode 100644 index 000000000..a1dfd5f78 --- /dev/null +++ b/apps/emqx_auth_mysql/test/emqx_auth_mysql_SUITE_data/server-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnBJxJuYaj9RGxCcnDs/FGjVqdvUHaRVM01oZChoVV5PtJrJj +U8ODQQBQeNpqGOODlPMLw4nXjpSTYT0Vr3g/MxtpccyQPdadFCuHrfImvTJnrgho +XQvY8SfF+vVjZVIj8stISP2bKkavWdAtnltIiyT17MuF/F6cN0hcKF3+aUfURWOT +29rbssapknrLbtlV0/zUIyYOAlQ0d5hXBz12QeHpUcx9FrIRMRBpIMtG4NtDCXkX +NkHW+V8fhGpG5sK6A1mgp9zUhqkO47+lcNHaMkxr0uoekRwcZ8qkX0YChpQqDjBv +f+fqxSQtNEKQlBtrx1x1hqFpTJSd36De64+b/QIDAQABAoIBAFiah66Dt9SruLkn +WR8piUaFyLlcBib8Nq9OWSTJBhDAJERxxb4KIvvGB+l0ZgNXNp5bFPSfzsZdRwZP +PX5uj8Kd71Dxx3mz211WESMJdEC42u+MSmN4lGLkJ5t/sDwXU91E1vbJM0ve8THV +4/Ag9qA4DX2vVZOeyqT/6YHpSsPNZplqzrbAiwrfHwkctHfgqwOf3QLfhmVQgfCS +VwidBldEUv2whSIiIxh4Rv5St4kA68IBCbJxdpOpyuQBkk6CkxZ7VN9FqOuSd4Pk +Wm7iWyBMZsCmELZh5XAXld4BEt87C5R4CvbPBDZxAv3THk1DNNvpy3PFQfwARRFb +SAToYMECgYEAyL7U8yxpzHDYWd3oCx6vTi9p9N/z0FfAkWrRF6dm4UcSklNiT1Aq +EOnTA+SaW8tV3E64gCWcY23gNP8so/ZseWj6L+peHwtchaP9+KB7yGw2A+05+lOx +VetLTjAOmfpiUXFe5w1q4C1RGhLjZjjzW+GvwdAuchQgUEFaomrV+PUCgYEAxwfH +cmVGFbAktcjU4HSRjKSfawCrut+3YUOLybyku3Q/hP9amG8qkVTFe95CTLjLe2D0 +ccaTTpofFEJ32COeck0g0Ujn/qQ+KXRoauOYs4FB1DtqMpqB78wufWEUpDpbd9/h +J+gJdC/IADd4tJW9zA92g8IA7ZtFmqDtiSpQ0ekCgYAQGkaorvJZpN+l7cf0RGTZ +h7IfI2vCVZer0n6tQA9fmLzjoe6r4AlPzAHSOR8sp9XeUy43kUzHKQQoHCPvjw/K +eWJAP7OHF/k2+x2fOPhU7mEy1W+mJdp+wt4Kio5RSaVjVQ3AyPG+w8PSrJszEvRq +dWMMz+851WV2KpfjmWBKlQKBgQC++4j4DZQV5aMkSKV1CIZOBf3vaIJhXKEUFQPD +PmB4fBEjpwCg+zNGp6iktt65zi17o8qMjrb1mtCt2SY04eD932LZUHNFlwcLMmes +Ad+aiDLJ24WJL1f16eDGcOyktlblDZB5gZ/ovJzXEGOkLXglosTfo77OQculmDy2 +/UL2WQKBgGeKasmGNfiYAcWio+KXgFkHXWtAXB9B91B1OFnCa40wx+qnl71MIWQH +PQ/CZFNWOfGiNEJIZjrHsfNJoeXkhq48oKcT0AVCDYyLV0VxDO4ejT95mGW6njNd +JpvmhwwAjOvuWVr0tn4iXlSK8irjlJHmwcRjLTJq97vE9fsA2MjI +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_pgsql/.gitignore b/apps/emqx_auth_pgsql/.gitignore new file mode 100644 index 000000000..672d34c0c --- /dev/null +++ b/apps/emqx_auth_pgsql/.gitignore @@ -0,0 +1,20 @@ +ebin +.rebar +.eunit +.DS_Store +.erlang.mk/ +deps/ +emqx_auth_pgsql.d +ct.coverdata +logs/ +test/ct.cover.spec +test/*.beam +data/ +.DS_Store +cover/ +eunit.coverdata +_build/ +rebar.lock +erlang.mk +*.conf.rendered +.rebar3/ diff --git a/apps/emqx_auth_pgsql/README.md b/apps/emqx_auth_pgsql/README.md new file mode 100644 index 000000000..2dccd6f53 --- /dev/null +++ b/apps/emqx_auth_pgsql/README.md @@ -0,0 +1,183 @@ +emqx_auth_pgsql +=============== + +Authentication/ACL with PostgreSQL Database. + +Build Plugin +------------ + +make && make tests + +Configuration +------------- + +File: etc/emqx_auth_pgsql.conf + +``` +## PostgreSQL server address. +## +## Value: Port | IP:Port +## +## Examples: 5432, 127.0.0.1:5432, localhost:5432 +auth.pgsql.server = 127.0.0.1:5432 + +## PostgreSQL pool size. +## +## Value: Number +auth.pgsql.pool = 8 + +## PostgreSQL username. +## +## Value: String +auth.pgsql.username = root + +## PostgreSQL password. +## +## Value: String +## auth.pgsql.password = + +## PostgreSQL database. +## +## Value: String +auth.pgsql.database = mqtt + +## PostgreSQL database encoding. +## +## Value: String +auth.pgsql.encoding = utf8 + +## Whether to enable SSL connection. +## +## Value: true | false +auth.pgsql.ssl = false + +## SSL keyfile. +## +## Value: File +## auth.pgsql.ssl_opts.keyfile = + +## SSL certfile. +## +## Value: File +## auth.pgsql.ssl_opts.certfile = + +## SSL cacertfile. +## +## Value: File +## auth.pgsql.ssl_opts.cacertfile = + +## Authentication query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## +auth.pgsql.auth_query = select password from mqtt_user where username = '%u' limit 1 + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.pgsql.password_hash = sha256 + +## sha256 with salt prefix +## auth.pgsql.password_hash = salt,sha256 + +## sha256 with salt suffix +## auth.pgsql.password_hash = sha256,salt + +## bcrypt with salt prefix +## auth.pgsql.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.pgsql.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +auth.pgsql.super_query = select is_superuser from mqtt_user where username = '%u' limit 1 + +## ACL query. Comment this query, the ACL will be disabled. +## +## Value: SQL +## +## Variables: +## - %a: ipaddress +## - %u: username +## - %c: clientid +auth.pgsql.acl_query = select allow, ipaddr, username, clientid, access, topic from mqtt_acl where ipaddr = '%a' or username = '%u' or username = '$all' or clientid = '%c' +``` + +Load Plugin +----------- + +./bin/emqx_ctl plugins load emqx_auth_pgsql + +Auth Table +---------- + +Notice: This is a demo table. You could authenticate with any user table. + +```sql +CREATE TABLE mqtt_user ( + id SERIAL primary key, + is_superuser boolean, + username character varying(100), + password character varying(100), + salt character varying(40) +) +``` + +ACL Table +--------- + +```sql +CREATE TABLE mqtt_acl ( + id SERIAL primary key, + allow integer, + ipaddr character varying(60), + username character varying(100), + clientid character varying(100), + access integer, + topic character varying(100) +) + +INSERT INTO mqtt_acl (id, allow, ipaddr, username, clientid, access, topic) +VALUES + (1,1,NULL,'$all',NULL,2,'#'), + (2,0,NULL,'$all',NULL,1,'$SYS/#'), + (3,0,NULL,'$all',NULL,1,'eq #'), + (5,1,'127.0.0.1',NULL,NULL,2,'$SYS/#'), + (6,1,'127.0.0.1',NULL,NULL,2,'#'), + (7,1,NULL,'dashboard',NULL,1,'$SYS/#'); +``` +**allow:** Client's permission to access a topic. '0' means that the client does not have permission to access the topic, '1' means that the client have permission to access the topic. + +**ipaddr:** Client IP address. For all ip addresses it can be '$all' or 'NULL'. + +**username:** Client username. For all users it can be '$all' or 'NULL'. + +**clientid:** Client id. For all client ids it can be '$all' or 'NULL'. + +**access:** Operations that the client can perform. '1' means that the client can subscribe to a topic, '2' means that the client can publish to a topic, '3' means that the client can subscribe and can publish to a topic. + +**topic:** Topic name. Topic wildcards are supported. + +**Notice that only one value allowed for ipaddr, username and clientid fields.** + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf b/apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf new file mode 100644 index 000000000..ef8e7533a --- /dev/null +++ b/apps/emqx_auth_pgsql/etc/emqx_auth_pgsql.conf @@ -0,0 +1,117 @@ +##-------------------------------------------------------------------- +## PostgreSQL Auth/ACL Plugin +##-------------------------------------------------------------------- + +## PostgreSQL server address. +## +## Value: Port | IP:Port +## +## Examples: 5432, 127.0.0.1:5432, localhost:5432 +auth.pgsql.server = 127.0.0.1:5432 + +## PostgreSQL pool size. +## +## Value: Number +auth.pgsql.pool = 8 + +## PostgreSQL username. +## +## Value: String +auth.pgsql.username = root + +## PostgreSQL password. +## +## Value: String +# auth.pgsql.password = + +## PostgreSQL database. +## +## Value: String +auth.pgsql.database = mqtt + +## PostgreSQL database encoding. +## +## Value: String +auth.pgsql.encoding = utf8 + +## Whether to enable SSL connection. +## +## Value: on | off +auth.pgsql.ssl = off + +## TLS version +## You can configure multi-version use "," split, +## default value is :tlsv1.2 +## Example: +## tlsv1.1,tlsv1.2,tlsv1.3 +## +#auth.pgsql.ssl.tls_versions = tlsv1.2 + +## SSL keyfile. +## +## Value: File +#auth.pgsql.ssl.keyfile = + +## SSL certfile. +## +## Value: File +#auth.pgsql.ssl.certfile = + +## SSL cacertfile. +## +## Value: File +#auth.pgsql.ssl.cacertfile = + +## Authentication query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +auth.pgsql.auth_query = select password from mqtt_user where username = '%u' limit 1 + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.pgsql.password_hash = sha256 + +## sha256 with salt prefix +## auth.pgsql.password_hash = salt,sha256 + +## sha256 with salt suffix +## auth.pgsql.password_hash = sha256,salt + +## bcrypt with salt prefix +## auth.pgsql.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.pgsql.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query. +## +## Value: SQL +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +auth.pgsql.super_query = select is_superuser from mqtt_user where username = '%u' limit 1 + +## ACL query. Comment this query, the ACL will be disabled. +## +## Value: SQL +## +## Variables: +## - %a: ipaddress +## - %u: username +## - %c: clientid +## +## Note: You can add the 'ORDER BY' statement to control the rules match order +auth.pgsql.acl_query = select allow, ipaddr, username, clientid, access, topic from mqtt_acl where ipaddr = '%a' or username = '%u' or username = '$all' or clientid = '%c' diff --git a/apps/emqx_auth_pgsql/include/emqx_auth_pgsql.hrl b/apps/emqx_auth_pgsql/include/emqx_auth_pgsql.hrl new file mode 100644 index 000000000..b86692752 --- /dev/null +++ b/apps/emqx_auth_pgsql/include/emqx_auth_pgsql.hrl @@ -0,0 +1,23 @@ +-define(APP, emqx_auth_pgsql). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). + diff --git a/apps/emqx_auth_pgsql/mqtt.sql b/apps/emqx_auth_pgsql/mqtt.sql new file mode 100644 index 000000000..933b0058a --- /dev/null +++ b/apps/emqx_auth_pgsql/mqtt.sql @@ -0,0 +1,28 @@ + +CREATE TABLE mqtt_user ( + id SERIAL primary key, + is_superuser boolean, + username character varying(100), + password character varying(100), + salt character varying(40) +); + +CREATE TABLE mqtt_acl ( + id SERIAL primary key, + allow integer, + ipaddr character varying(60), + username character varying(100), + clientid character varying(100), + access integer, + topic character varying(100) +); + +INSERT INTO mqtt_acl (id, allow, ipaddr, username, clientid, access, topic) +VALUES + (1,1,NULL,'$all',NULL,2,'#'), + (2,0,NULL,'$all',NULL,1,'$SYS/#'), + (3,0,NULL,'$all',NULL,1,'eq #'), + (5,1,'127.0.0.1',NULL,NULL,2,'$SYS/#'), + (6,1,'127.0.0.1',NULL,NULL,2,'#'), + (7,1,NULL,'dashboard',NULL,1,'$SYS/#'); + diff --git a/apps/emqx_auth_pgsql/priv/emqx_auth_pgsql.schema b/apps/emqx_auth_pgsql/priv/emqx_auth_pgsql.schema new file mode 100644 index 000000000..859495a60 --- /dev/null +++ b/apps/emqx_auth_pgsql/priv/emqx_auth_pgsql.schema @@ -0,0 +1,160 @@ +%%-*- mode: erlang -*- +%% emqx_auth_pgsl config mapping + +{mapping, "auth.pgsql.server", "emqx_auth_pgsql.server", [ + {default, {"127.0.0.1", 5432}}, + {datatype, [integer, ip, string]} +]}. + +{mapping, "auth.pgsql.pool", "emqx_auth_pgsql.server", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.pgsql.database", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.username", "emqx_auth_pgsql.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.pgsql.password", "emqx_auth_pgsql.server", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.pgsql.encoding", "emqx_auth_pgsql.server", [ + {default, utf8}, + {datatype, atom} +]}. + +{mapping, "auth.pgsql.ssl", "emqx_auth_pgsql.server", [ + {default, off}, + {datatype, {enum, [on, off, true, false]}} %% FIXME: true/fasle is compatible with 4.0-4.2 version format, plan to delete in 5.0 +]}. + +{mapping, "auth.pgsql.ssl.tls_versions", "emqx_auth_pgsql.server", [ + {default, "tlsv1.2"}, + {datatype, string} +]}. + +{mapping, "auth.pgsql.ssl.keyfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.ssl.certfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.ssl.cacertfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.pgsql.ssl_opts.keyfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.pgsql.ssl_opts.certfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.pgsql.ssl_opts.cacertfile", "emqx_auth_pgsql.server", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.pgsql.ssl_opts.tls_versions", "emqx_auth_pgsql.server", [ + {default, "tlsv1.2"}, + {datatype, string} +]}. + +{translation, "emqx_auth_pgsql.server", fun(Conf) -> + {PgHost, PgPort} = + case cuttlefish:conf_get("auth.pgsql.server", Conf) of + {Ip, Port} -> {Ip, Port}; + S -> case string:tokens(S, ":") of + [Domain] -> {Domain, 5432}; + [Domain, Port] -> {Domain, list_to_integer(Port)} + end + end, + Pool = cuttlefish:conf_get("auth.pgsql.pool", Conf), + Username = cuttlefish:conf_get("auth.pgsql.username", Conf), + Passwd = cuttlefish:conf_get("auth.pgsql.password", Conf, ""), + DB = cuttlefish:conf_get("auth.pgsql.database", Conf), + Encoding = cuttlefish:conf_get("auth.pgsql.encoding", Conf), + + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + SslOpts = fun(Prefix) -> + Filter([{keyfile, cuttlefish:conf_get(Prefix ++ ".keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined), + {versions, [list_to_existing_atom(Value) + ||Value <- string:tokens(cuttlefish:conf_get(Prefix ++ ".tls_versions", Conf), " ,")]}}]) + end, + + %% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 + Ssl = case cuttlefish:conf_get("auth.pgsql.ssl", Conf) of + on -> [{ssl, true}, {ssl_opts, SslOpts("auth.pgsql.ssl")}]; + off -> []; + true -> [{ssl, true}, {ssl_opts, SslOpts("auth.pgsql.ssl_opts")}]; + false -> [] + end, + + TempHost = case inet:parse_address(PgHost) of + {ok, IpAddr} -> + IpAddr; + _ -> + PgHost + end, + [{pool_size, Pool}, + {auto_reconnect, 1}, + {host, TempHost}, + {port, PgPort}, + {username, Username}, + {password, Passwd}, + {database, DB}, + {encoding, Encoding}] ++ Ssl +end}. + +{mapping, "auth.pgsql.auth_query", "emqx_auth_pgsql.auth_query", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.password_hash", "emqx_auth_pgsql.password_hash", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.pbkdf2_macfun", "emqx_auth_pgsql.pbkdf2_macfun", [ + {datatype, atom} +]}. + +{mapping, "auth.pgsql.pbkdf2_iterations", "emqx_auth_pgsql.pbkdf2_iterations", [ + {datatype, integer} +]}. + +{mapping, "auth.pgsql.pbkdf2_dklen", "emqx_auth_pgsql.pbkdf2_dklen", [ + {datatype, integer} +]}. + +{mapping, "auth.pgsql.super_query", "emqx_auth_pgsql.super_query", [ + {datatype, string} +]}. + +{mapping, "auth.pgsql.acl_query", "emqx_auth_pgsql.acl_query", [ + {datatype, string} +]}. + +{translation, "emqx_auth_pgsql.password_hash", fun(Conf) -> + HashValue = cuttlefish:conf_get("auth.pgsql.password_hash", Conf), + case string:tokens(HashValue, ",") of + [Hash] -> list_to_atom(Hash); + [Prefix, Suffix] -> {list_to_atom(Prefix), list_to_atom(Suffix)}; + [Hash, MacFun, Iterations, Dklen] -> {list_to_atom(Hash), list_to_atom(MacFun), list_to_integer(Iterations), list_to_integer(Dklen)}; + _ -> plain + end +end}. diff --git a/apps/emqx_auth_pgsql/rebar.config b/apps/emqx_auth_pgsql/rebar.config new file mode 100644 index 000000000..3155bbef3 --- /dev/null +++ b/apps/emqx_auth_pgsql/rebar.config @@ -0,0 +1,21 @@ +{deps, + [{epgsql, {git, "https://github.com/epgsql/epgsql", {tag, "4.4.0"}}} + ]}. + +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + compressed + ]}. +{overrides, [{add, [{erl_opts, [compressed]}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions + ]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_auth_pgsql/src/emqx_acl_pgsql.erl b/apps/emqx_auth_pgsql/src/emqx_acl_pgsql.erl new file mode 100644 index 000000000..c1792f1e2 --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_acl_pgsql.erl @@ -0,0 +1,117 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_pgsql). + +-include("emqx_auth_pgsql.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +%% ACL callbacks +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(ClientInfo, PubSub, Topic, NoMatchAction, #{pool := Pool} = State) -> + case do_check_acl(Pool, ClientInfo, PubSub, Topic, NoMatchAction, State) of + ok -> emqx_metrics:inc(?ACL_METRICS(ignore)), ok; + {stop, allow} -> emqx_metrics:inc(?ACL_METRICS(allow)), {stop, allow}; + {stop, deny} -> emqx_metrics:inc(?ACL_METRICS(deny)), {stop, deny} + end. + +do_check_acl(_Pool, #{username := <<$$, _/binary>>}, _PubSub, _Topic, _NoMatchAction, _State) -> + ok; +do_check_acl(Pool, ClientInfo, PubSub, Topic, _NoMatchAction, #{acl_query := {AclSql, AclParams}}) -> + case emqx_auth_pgsql_cli:equery(Pool, AclSql, AclParams, ClientInfo) of + {ok, _, []} -> ok; + {ok, _, Rows} -> + Rules = filter(PubSub, compile(Rows)), + case match(ClientInfo, Topic, Rules) of + {matched, allow} -> {stop, allow}; + {matched, deny} -> {stop, deny}; + nomatch -> ok + end; + {error, Reason} -> + ?LOG(error, "[Postgres] do_check_acl error: ~p~n", [Reason]), + ok + end. + +match(_ClientInfo, _Topic, []) -> + nomatch; + +match(ClientInfo, Topic, [Rule|Rules]) -> + case emqx_access_rule:match(ClientInfo, Topic, Rule) of + nomatch -> match(ClientInfo, Topic, Rules); + {matched, AllowDeny} -> {matched, AllowDeny} + end. + +filter(PubSub, Rules) -> + [Term || Term = {_, _, Access, _} <- Rules, + Access =:= PubSub orelse Access =:= pubsub]. + +compile(Rows) -> + compile(Rows, []). +compile([], Acc) -> + Acc; +compile([{Allow, IpAddr, Username, ClientId, Access, Topic}|T], Acc) -> + Who = who(IpAddr, Username, ClientId), + Term = {allow(Allow), Who, access(Access), [topic(Topic)]}, + compile(T, [emqx_access_rule:compile(Term) | Acc]). + +who(_, <<"$all">>, _) -> + all; +who(null, null, null) -> + throw(undefined_who); +who(CIDR, Username, ClientId) -> + Cols = [{ipaddr, b2l(CIDR)}, {user, Username}, {client, ClientId}], + case [{C, V} || {C, V} <- Cols, not empty(V)] of + [Who] -> Who; + Conds -> {'and', Conds} + end. + +allow(1) -> allow; +allow(0) -> deny; +allow(<<"1">>) -> allow; +allow(<<"0">>) -> deny. + +access(1) -> subscribe; +access(2) -> publish; +access(3) -> pubsub; +access(<<"1">>) -> subscribe; +access(<<"2">>) -> publish; +access(<<"3">>) -> pubsub. + +topic(<<"eq ", Topic/binary>>) -> + {eq, Topic}; +topic(Topic) -> + Topic. + +description() -> + "ACL with Postgres". + +b2l(null) -> null; +b2l(B) -> binary_to_list(B). + +empty(null) -> true; +empty("") -> true; +empty(<<>>) -> true; +empty(_) -> false. + diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.app.src b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.app.src new file mode 100644 index 000000000..f70612262 --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_pgsql, + [{description, "EMQ X Authentication/ACL with PostgreSQL"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_pgsql_sup]}, + {applications, [kernel,stdlib,epgsql,ecpool]}, + {mod, {emqx_auth_pgsql_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-pgsql"} + ]} + ]}. diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.erl b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.erl new file mode 100644 index 000000000..2dee5ef50 --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.erl @@ -0,0 +1,91 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_pgsql). + +-include("emqx_auth_pgsql.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +%%-------------------------------------------------------------------- +%% Auth Module Callbacks +%%-------------------------------------------------------------------- + +check(ClientInfo = #{password := Password}, AuthResult, + #{auth_query := {AuthSql, AuthParams}, + super_query := SuperQuery, + hash_type := HashType, + pool := Pool}) -> + CheckPass = case emqx_auth_pgsql_cli:equery(Pool, AuthSql, AuthParams, ClientInfo) of + {ok, _, [Record]} -> + check_pass(erlang:append_element(Record, Password), HashType); + {ok, _, []} -> + {error, not_found}; + {error, Reason} -> + ?LOG(error, "[Postgres] query '~p' failed: ~p", [AuthSql, Reason]), + {error, not_found} + end, + case CheckPass of + ok -> + emqx_metrics:inc(?AUTH_METRICS(success)), + {stop, AuthResult#{is_superuser => is_superuser(Pool, SuperQuery, ClientInfo), + anonymous => false, + auth_result => success}}; + {error, not_found} -> + emqx_metrics:inc(?AUTH_METRICS(ignore)), ok; + {error, ResultCode} -> + ?LOG(error, "[Postgres] Auth from pgsql failed: ~p", [ResultCode]), + emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => ResultCode, anonymous => false}} + end. + +%%-------------------------------------------------------------------- +%% Is Superuser? +%%-------------------------------------------------------------------- + +-spec(is_superuser(atom(),undefined | {string(), list()}, emqx_types:client()) -> boolean()). +is_superuser(_Pool, undefined, _Client) -> + false; +is_superuser(Pool, {SuperSql, Params}, ClientInfo) -> + case emqx_auth_pgsql_cli:equery(Pool, SuperSql, Params, ClientInfo) of + {ok, [_Super], [{true}]} -> + true; + {ok, [_Super], [_False]} -> + false; + {ok, [_Super], []} -> + false; + {error, _Error} -> + false + end. + +check_pass(Password, HashType) -> + case emqx_passwd:check_pass(Password, HashType) of + ok -> ok; + {error, _Reason} -> {error, not_authorized} + end. + +description() -> "Authentication with PostgreSQL". + diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_app.erl b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_app.erl new file mode 100644 index 000000000..1d05f6b8a --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_app.erl @@ -0,0 +1,63 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_pgsql_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_pgsql.hrl"). + +-import(emqx_auth_pgsql_cli, [parse_query/2]). + +%% Application callbacks +-export([ start/2 + , stop/1 + ]). + +%%-------------------------------------------------------------------- +%% Application callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_pgsql_sup:start_link(), + if_enabled(auth_query, fun(AuthQuery) -> + SuperQuery = parse_query(super_query, application:get_env(?APP, super_query, undefined)), + {ok, HashType} = application:get_env(?APP, password_hash), + AuthEnv = #{auth_query => AuthQuery, + super_query => SuperQuery, + hash_type => HashType, + pool => ?APP}, + ok = emqx_auth_pgsql:register_metrics(), + ok = emqx:hook('client.authenticate', fun emqx_auth_pgsql:check/3, [AuthEnv]) + end), + if_enabled(acl_query, fun(AclQuery) -> + ok = emqx_acl_pgsql:register_metrics(), + ok = emqx:hook('client.check_acl', fun emqx_acl_pgsql:check_acl/5, [#{acl_query => AclQuery, pool => ?APP}]) + end), + {ok, Sup}. + +stop(_State) -> + ok = emqx:unhook('client.authenticate', fun emqx_auth_pgsql:check/3), + ok = emqx:unhook('client.check_acl', fun emqx_acl_pgsql:check_acl/5). + +if_enabled(Par, Fun) -> + case application:get_env(?APP, Par) of + {ok, Query} -> Fun(parse_query(Par, Query)); + undefined -> ok + end. + diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_cli.erl b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_cli.erl new file mode 100644 index 000000000..5b9dbd24a --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_cli.erl @@ -0,0 +1,150 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_pgsql_cli). + +-behaviour(ecpool_worker). + +-include("emqx_auth_pgsql.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([connect/1]). +-export([parse_query/2]). +-export([ equery/4 + , equery/3 + ]). + +-type client_info() :: #{username := _, + clientid := _, + peerhost := _, + _ => _}. + +%%-------------------------------------------------------------------- +%% Avoid SQL Injection: Parse SQL to Parameter Query. +%%-------------------------------------------------------------------- + +parse_query(_Par, undefined) -> + undefined; +parse_query(Par, Sql) -> + case re:run(Sql, "'%[ucCad]'", [global, {capture, all, list}]) of + {match, Variables} -> + Params = [Var || [Var] <- Variables], + {atom_to_list(Par), Params}; + nomatch -> + {atom_to_list(Par), []} + end. + +pgvar(Sql, Params) -> + Vars = ["$" ++ integer_to_list(I) || I <- lists:seq(1, length(Params))], + lists:foldl(fun({Param, Var}, S) -> + re:replace(S, Param, Var, [{return, list}]) + end, Sql, lists:zip(Params, Vars)). + +%%-------------------------------------------------------------------- +%% PostgreSQL Connect/Query +%%-------------------------------------------------------------------- + +%% Due to a bug in epgsql the caluse for `econnrefused` is not recognised by +%% dialyzer, result in this error: +%% The pattern {'error', Reason = 'econnrefused'} can never match the type ... +%% https://github.com/epgsql/epgsql/issues/246 +-dialyzer([{nowarn_function, [connect/1]}]). +connect(Opts) -> + Host = proplists:get_value(host, Opts), + Username = proplists:get_value(username, Opts), + Password = proplists:get_value(password, Opts), + case epgsql:connect(Host, Username, Password, conn_opts(Opts)) of + {ok, C} -> + conn_post(C), + {ok, C}; + {error, Reason = econnrefused} -> + ?LOG(error, "[Postgres] Can't connect to Postgres server: Connection refused."), + {error, Reason}; + {error, Reason = invalid_authorization_specification} -> + ?LOG(error, "[Postgres] Can't connect to Postgres server: Invalid authorization specification."), + {error, Reason}; + {error, Reason = invalid_password} -> + ?LOG(error, "[Postgres] Can't connect to Postgres server: Invalid password."), + {error, Reason}; + {error, Reason} -> + ?LOG(error, "[Postgres] Can't connect to Postgres server: ~p", [Reason]), + {error, Reason} + end. + +conn_post(Connection) -> + lists:foreach(fun(Par) -> + Sql0 = application:get_env(?APP, Par, undefined), + case parse_query(Par, Sql0) of + undefined -> ok; + {_, Params} -> + Sql = pgvar(Sql0, Params), + epgsql:parse(Connection, atom_to_list(Par), Sql, []) + end + end, [auth_query, acl_query, super_query]). + +conn_opts(Opts) -> + conn_opts(Opts, []). +conn_opts([], Acc) -> + Acc; +conn_opts([Opt = {database, _}|Opts], Acc) -> + conn_opts(Opts, [Opt|Acc]); +conn_opts([Opt = {ssl, _}|Opts], Acc) -> + conn_opts(Opts, [Opt|Acc]); +conn_opts([Opt = {port, _}|Opts], Acc) -> + conn_opts(Opts, [Opt|Acc]); +conn_opts([Opt = {timeout, _}|Opts], Acc) -> + conn_opts(Opts, [Opt|Acc]); +conn_opts([Opt = {ssl_opts, _}|Opts], Acc) -> + conn_opts(Opts, [Opt|Acc]); +conn_opts([_Opt|Opts], Acc) -> + conn_opts(Opts, Acc). + +-spec(equery(atom(), string() | epgsql:statement(), Parameters::[any()]) -> {ok, ColumnsDescription :: [any()], RowsValues :: [any()]} | {error, any()} ). +equery(Pool, Sql, Params) -> + ecpool:with_client(Pool, fun(C) -> epgsql:prepared_query(C, Sql, Params) end). + +-spec(equery(atom(), string() | epgsql:statement(), Parameters::[any()], client_info()) -> {ok, ColumnsDescription :: [any()], RowsValues :: [any()]} | {error, any()} ). +equery(Pool, Sql, Params, ClientInfo) -> + ecpool:with_client(Pool, fun(C) -> epgsql:prepared_query(C, Sql, replvar(Params, ClientInfo)) end). + +replvar(Params, ClientInfo) -> + replvar(Params, ClientInfo, []). + +replvar([], _ClientInfo, Acc) -> + lists:reverse(Acc); + +replvar(["'%u'" | Params], ClientInfo = #{username := Username}, Acc) -> + replvar(Params, ClientInfo, [Username | Acc]); +replvar(["'%c'" | Params], ClientInfo = #{clientid := ClientId}, Acc) -> + replvar(Params, ClientInfo, [ClientId | Acc]); +replvar(["'%a'" | Params], ClientInfo = #{peerhost := IpAddr}, Acc) -> + replvar(Params, ClientInfo, [inet_parse:ntoa(IpAddr) | Acc]); +replvar(["'%C'" | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [safe_get(cn, ClientInfo)| Acc]); +replvar(["'%d'" | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [safe_get(dn, ClientInfo)| Acc]); +replvar([Param | Params], ClientInfo, Acc) -> + replvar(Params, ClientInfo, [Param | Acc]). + +safe_get(K, ClientInfo) -> + bin(maps:get(K, ClientInfo, undefined)). + +bin(A) when is_atom(A) -> atom_to_binary(A, utf8); +bin(B) when is_binary(B) -> B; +bin(X) -> X. + diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_sup.erl b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_sup.erl new file mode 100644 index 000000000..162d04747 --- /dev/null +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql_sup.erl @@ -0,0 +1,37 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_pgsql_sup). + +-behaviour(supervisor). + +-include("emqx_auth_pgsql.hrl"). + +%% API +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + %% PgSQL Connection Pool + {ok, Opts} = application:get_env(?APP, server), + PoolSpec = ecpool:pool_spec(?APP, ?APP, emqx_auth_pgsql_cli, Opts), + {ok, {{one_for_one, 10, 100}, [PoolSpec]}}. + diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE.erl b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE.erl new file mode 100644 index 000000000..904e3dc16 --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE.erl @@ -0,0 +1,221 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_pgsql_SUITE). + +-compile(export_all). + +-define(POOL, emqx_auth_pgsql). + +-define(APP, emqx_auth_pgsql). + +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("common_test/include/ct.hrl"). + +%%setp1 init table +-define(DROP_ACL_TABLE, "DROP TABLE IF EXISTS mqtt_acl"). + +-define(CREATE_ACL_TABLE, "CREATE TABLE mqtt_acl ( + id SERIAL primary key, + allow integer, + ipaddr character varying(60), + username character varying(100), + clientid character varying(100), + access integer, + topic character varying(100))"). + +-define(INIT_ACL, "INSERT INTO mqtt_acl (id, allow, ipaddr, username, clientid, access, topic) + VALUES + (1,1,'127.0.0.1','u1','c1',1,'t1'), + (2,0,'127.0.0.1','u2','c2',1,'t1'), + (3,1,'10.10.0.110','u1','c1',1,'t1'), + (4,1,'127.0.0.1','u3','c3',3,'t1')"). + +-define(DROP_AUTH_TABLE, "DROP TABLE IF EXISTS mqtt_user"). + +-define(CREATE_AUTH_TABLE, "CREATE TABLE mqtt_user ( + id SERIAL primary key, + is_superuser boolean, + username character varying(100), + password character varying(100), + salt character varying(40))"). + +-define(INIT_AUTH, "INSERT INTO mqtt_user (id, is_superuser, username, password, salt) + VALUES + (1, true, 'plain', 'plain', 'salt'), + (2, false, 'md5', '1bc29b36f623ba82aaf6724fd3b16718', 'salt'), + (3, false, 'sha', 'd8f4590320e1343a915b6394170650a8f35d6926', 'salt'), + (4, false, 'sha256', '5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e', 'salt'), + (5, false, 'pbkdf2_password', 'cdedb5281bb2f801565a1122b2563515', 'ATHENA.MIT.EDUraeburn'), + (6, false, 'bcrypt_foo', '$2a$12$sSS8Eg.ovVzaHzi1nUHYK.HbUIOdlQI0iS22Q5rd5z.JVVYH6sfm6', '$2a$12$sSS8Eg.ovVzaHzi1nUHYK.'), + (7, false, 'bcrypt', '$2y$16$rEVsDarhgHYB0TGnDFJzyu5f.T.Ha9iXMTk9J36NCMWWM7O16qyaK', 'salt')"). + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx_auth_pgsql]), + drop_acl(), + drop_auth(), + init_auth(), + init_acl(), + set_special_configs(), + Config. + +end_per_suite(Config) -> + emqx_ct_helpers:stop_apps([emqx_auth_pgsql]), + Config. + +set_special_configs() -> + application:set_env(emqx, acl_nomatch, deny), + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false). + +t_comment_config(_) -> + AuthCount = length(emqx_hooks:lookup('client.authenticate')), + AclCount = length(emqx_hooks:lookup('client.check_acl')), + application:stop(?APP), + [application:unset_env(?APP, Par) || Par <- [acl_query, auth_query]], + application:start(?APP), + ?assertEqual([], emqx_hooks:lookup('client.authenticate')), + ?assertEqual(AuthCount - 1, length(emqx_hooks:lookup('client.authenticate'))), + ?assertEqual(AclCount - 1, length(emqx_hooks:lookup('client.check_acl'))). + +t_placeholders(_) -> + ClientA = #{username => <<"plain">>, clientid => <<"plain">>, zone => external}, + reload([{password_hash, plain}, + {auth_query, "select password from mqtt_user where username = '%u' and 'a_cn_val' = '%C' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, cn => undefined}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, cn => <<"a_cn_val">>}), + + reload([{auth_query, "select password from mqtt_user where username = '%c' and 'a_dn_val' = '%d' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, dn => undefined}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, dn => <<"a_dn_val">>}), + + reload([{auth_query, "select password from mqtt_user where username = '%u' and '192.168.1.5' = '%a' limit 1"}]), + {error, not_authorized} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>}), + {ok, _} = + emqx_access_control:authenticate(ClientA#{password => <<"plain">>, peerhost => {192,168,1,5}}). + +t_check_auth(_) -> + Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, + Md5 = #{clientid => <<"md5">>, username => <<"md5">>, zone => external}, + Sha = #{clientid => <<"sha">>, username => <<"sha">>, zone => external}, + Sha256 = #{clientid => <<"sha256">>, username => <<"sha256">>, zone => external}, + Pbkdf2 = #{clientid => <<"pbkdf2_password">>, username => <<"pbkdf2_password">>, zone => external}, + BcryptFoo = #{clientid => <<"bcrypt_foo">>, username => <<"bcrypt_foo">>, zone => external}, + User1 = #{clientid => <<"bcrypt_foo">>, username => <<"user">>, zone => external}, + Bcrypt = #{clientid => <<"bcrypt">>, username => <<"bcrypt">>, zone => external}, + BcryptWrong = #{clientid => <<"bcrypt_wrong">>, username => <<"bcrypt_wrong">>, zone => external}, + reload([{password_hash, plain}]), + {ok,#{is_superuser := true}} = + emqx_access_control:authenticate(Plain#{password => <<"plain">>}), + reload([{password_hash, md5}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Md5#{password => <<"md5">>}), + reload([{password_hash, sha}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Sha#{password => <<"sha">>}), + reload([{password_hash, sha256}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Sha256#{password => <<"sha256">>}), + reload([{password_hash, bcrypt}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}), + {error, not_authorized} = + emqx_access_control:authenticate(BcryptWrong#{password => <<"password">>}), + %%pbkdf2 sha + reload([{password_hash, {pbkdf2, sha, 1, 16}}, + {auth_query, "select password, salt from mqtt_user where username = '%u' limit 1"}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(Pbkdf2#{password => <<"password">>}), + reload([{password_hash, {salt, bcrypt}}]), + {ok,#{is_superuser := false}} = + emqx_access_control:authenticate(BcryptFoo#{password => <<"foo">>}), + {error, _} = emqx_access_control:authenticate(User1#{password => <<"foo">>}), + {error, not_authorized} = emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}). + +t_check_acl(_) -> + emqx_modules:load_module(emqx_mod_acl_internal, false), + User1 = #{zone => external, peerhost => {127,0,0,1}, clientid => <<"c1">>, username => <<"u1">>}, + User2 = #{zone => external, peerhost => {127,0,0,1}, clientid => <<"c2">>, username => <<"u2">>}, + allow = emqx_access_control:check_acl(User1, subscribe, <<"t1">>), + deny = emqx_access_control:check_acl(User2, subscribe, <<"t1">>), + User3 = #{zone => external, peerhost => {10,10,0,110}, clientid => <<"c1">>, username => <<"u1">>}, + User4 = #{zone => external, peerhost => {10,10,10,110}, clientid => <<"c1">>, username => <<"u1">>}, + allow = emqx_access_control:check_acl(User3, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"t2">>),%% nomatch -> ignore -> emqttd acl + allow = emqx_access_control:check_acl(User4, subscribe, <<"t1">>),%% nomatch -> ignore -> emqttd acl + User5 = #{zone => external, peerhost => {127,0,0,1}, clientid => <<"c3">>, username => <<"u3">>}, + allow = emqx_access_control:check_acl(User5, subscribe, <<"t1">>), + allow = emqx_access_control:check_acl(User5, publish, <<"t1">>). + +t_acl_super(_) -> + reload([{password_hash, plain}, {auth_query, "select password from mqtt_user where username = '%u' limit 1"}]), + {ok, C} = emqtt:start_link([{host, "localhost"}, {clientid, <<"simpleClient">>}, + {username, <<"plain">>}, {password, <<"plain">>}]), + {ok, _} = emqtt:connect(C), + timer:sleep(10), + emqtt:subscribe(C, <<"TopicA">>, qos2), + emqtt:publish(C, <<"TopicA">>, <<"Payload">>, qos2), + timer:sleep(1000), + receive + {publish, #{payload := Payload}} -> + ?assertEqual(<<"Payload">>, Payload) + after + 1000 -> + ct:fail({receive_timeout, <<"Payload">>}), + ok + end, + emqtt:disconnect(C). + +reload(Config) when is_list(Config) -> + application:stop(?APP), + [application:set_env(?APP, K, V) || {K, V} <- Config], + application:start(?APP). + +init_acl() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?POOL})), + {ok, [], []} = epgsql:squery(Pid, ?DROP_ACL_TABLE), + {ok, [], []} = epgsql:squery(Pid, ?CREATE_ACL_TABLE), + {ok, _} = epgsql:equery(Pid, ?INIT_ACL). + +drop_acl() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?POOL})), + {ok, [], []}= epgsql:squery(Pid, ?DROP_ACL_TABLE). + +init_auth() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?POOL})), + {ok, [], []} = epgsql:squery(Pid, ?DROP_AUTH_TABLE), + {ok, [], []} = epgsql:squery(Pid, ?CREATE_AUTH_TABLE), + {ok, _} = epgsql:equery(Pid, ?INIT_AUTH). + +drop_auth() -> + {ok, Pid} = ecpool_worker:client(gproc_pool:pick_worker({ecpool, ?POOL})), + {ok, [], []} = epgsql:squery(Pid, ?DROP_AUTH_TABLE). diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.crt b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.crt new file mode 100644 index 000000000..9867681b9 --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDYzCCAksCCQC7J1oPkDz7vTANBgkqhkiG9w0BAQUFADCBhTELMAkGA1UEBhMC +Q0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29sdW1iaWExDjAMBgNVBAcMBUNvbW94MRQw +EgYDVQQKDAtUaGVCcmFpbi5jYTEUMBIGA1UEAwwLdGhlYnJhaW4uY2ExHzAdBgkq +hkiG9w0BCQEWEGluZm9AdGhlYnJhaW4uY2EwHhcNMjEwMTEzMDkwNzM2WhcNMjEw +MjEyMDkwNzM2WjBhMQswCQYDVQQGEwJDQTEZMBcGA1UECAwQQnJpdGlzaCBDb2x1 +bWJpYTEOMAwGA1UEBwwFQ29tb3gxFDASBgNVBAoMC1RoZUJyYWluLmNhMREwDwYD +VQQDDAh3d3ctZGF0YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJv9 +yO5JGKBl+7w0HGkRDIPZ5Ku3lIAzB4ThszRHBqll7VjlTz+q16OQOONqeHBuxPjj +11WMXD2KnfYZW2ZWd0U8FKzuIGOCStGbSUi2hC0owp+KkJcDujfIafXQnAa0fUiS +FBB5iG98vm3QI4gv9135LgnO5oHopH6oZ/t0Id1LzFhp2sdhebdtczmImpo+nt7v +fduapptuIJ20ThdAvo3MlYoAhivsvJKntlWPAwPMQdyezww/q7T5Y8DCyJJTydr5 +PrMz9S/WQTkj/G0y4dZgQonG5r0d1Nf+rwkn78DdXGktVDMBBP41+VWnEDBCTlgS +FjQEY6Izaof8s8q8K2UCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAdlAQkumOAKbQ +SW5gtkHgKyIQyfwk9maKqKccK04WlNk1t1jsvk7kaOEHr3t7YG28yKqicGHAcfFf +i/RU51v2GJVzWCbzkAAH/zNgDcYnYk6sn54YcuBzrPliVH1xxmZy/52+huTxy8Vd +3nmCjdYR/I764rd8gkRK+aHaUTLyitzX1kW90LtXonKY72CNZVXHEBom3XM/a6ff +ilybDloNVTfHstnfsnHHyNYn0SfapqXxPCO+FL9hQjlztUBZryRdS0nq66hB2GSB +CEst/vtNGo/2aa1Vw4bKl2oGepjKNzxp0ZTTVuIcwGzV6oKIsx1ZnWE3gQLEH/TX +dzMzesBayA== +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.csr b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.csr new file mode 100644 index 000000000..325fbe397 --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.csr @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICpjCCAY4CAQAwYTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29s +dW1iaWExDjAMBgNVBAcMBUNvbW94MRQwEgYDVQQKDAtUaGVCcmFpbi5jYTERMA8G +A1UEAwwId3d3LWRhdGEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCb +/cjuSRigZfu8NBxpEQyD2eSrt5SAMweE4bM0RwapZe1Y5U8/qtejkDjjanhwbsT4 +49dVjFw9ip32GVtmVndFPBSs7iBjgkrRm0lItoQtKMKfipCXA7o3yGn10JwGtH1I +khQQeYhvfL5t0COIL/dd+S4JzuaB6KR+qGf7dCHdS8xYadrHYXm3bXM5iJqaPp7e +733bmqabbiCdtE4XQL6NzJWKAIYr7LySp7ZVjwMDzEHcns8MP6u0+WPAwsiSU8na ++T6zM/Uv1kE5I/xtMuHWYEKJxua9HdTX/q8JJ+/A3VxpLVQzAQT+NflVpxAwQk5Y +EhY0BGOiM2qH/LPKvCtlAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAN6Q8MEDx +g5xlpYB/fFmagpe15+G2QbqVf2mH1a4aBcBns4jMMqNidi4gyjGfzvNxX77R6KcI +AfcxENRVDYJbhAgEQ96jv4jv5pEMuyvQ8VLhn9AOXCaK/VHxbYlOiM7tfFtEDrrB +wTn8FvoEwjehfsSX2dWiwcUK4SPPeuklE/EGjRgoVCwg8EqWzf1fn+tzME8OpnRQ +I8coyALF6ANehvP7ADV3m5iOOaNhfnqmqGBEwjB3TTvE1gZ4UvAyl75bi+Zh3Osn +qemyxocp/ML4o6d/F+nKIZOe6309V2nyrY6RSd2fBCrhYj2rKTbrGTZrpKXeAhtI +jMivnjCK+WNHpQ== +-----END CERTIFICATE REQUEST----- diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.key b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.key new file mode 100644 index 000000000..787246f6f --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/postgresql.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAm/3I7kkYoGX7vDQcaREMg9nkq7eUgDMHhOGzNEcGqWXtWOVP +P6rXo5A442p4cG7E+OPXVYxcPYqd9hlbZlZ3RTwUrO4gY4JK0ZtJSLaELSjCn4qQ +lwO6N8hp9dCcBrR9SJIUEHmIb3y+bdAjiC/3XfkuCc7mgeikfqhn+3Qh3UvMWGna +x2F5t21zOYiamj6e3u9925qmm24gnbROF0C+jcyVigCGK+y8kqe2VY8DA8xB3J7P +DD+rtPljwMLIklPJ2vk+szP1L9ZBOSP8bTLh1mBCicbmvR3U1/6vCSfvwN1caS1U +MwEE/jX5VacQMEJOWBIWNARjojNqh/yzyrwrZQIDAQABAoIBAAOicycSLu+10Jq/ +ABZ2njsIPaq+mUgvaDJxa9KBASe7Rz92AFW0blfSSXELDwlXm2FNNbw5jACnFS0h +xB5rT1Yeo0CwP7Lx2zptCtUV45iFxZsgCGRsYs9f7RAcLzZ8yBqDxNHpcwNd/bXj +TqCitXnMD4WM+5P1TrfgxqN2Pj/Atg8w/4dP7KcFcTzcZzIz5rr3NTyjsrLdiFis +sR+7m7Qu4PyEfrDpR9Np111nQqVJ1bpt9qt/hv318FaBnpNY6MMBaSni99mvMXSd +SwHn3gnfHREWcNSLGA9gjEQmyIPHpV9T6SJ/zyr++6y8QCq4DiSP36A9zeA1XThP +YEIsWxUCgYEAyLppQerpOT2CnbTbKO/9rGwlbf8FT2GWFcPBtUm0lp21/C32BX+H +jNCmQsE1pZ6+sqv2mb1onr6Xl9cSEt6KsI1EJtFFR9Lnvqqu+JKo31U94z2yTqgv +sc+qMl7shy1kja8T5NaRc++UkCVzVNsnFB9torIaqQwY9IRdRwmYjisCgYEAxvHR +MwvWpOg25zz75OfupIOQhj9W6yphpY5/yoYBms/4OeabJhMrOV142s9souCHmuGU +EtzOQC5jbEc+3MUjx1ZlboHY7UuoEu87kykFEs9mnaD+T34PEAJcQjSzqzS5KMJE +Ro275xf+V/e3hS/Z3hQXmDQNQDNRYMcAZfTW9K8CgYBkHITOuYikYcc5PLBplHhi +fHWWjLBrTPJ73GxKLH6C+BmBsrKXP2mtk4q4lIBbH/dgSV/ugYciVVBqDHwZKSDm +uS4aZhk1nzyx3ZLyqsLK0ErTgTvi+wL+neH2yV0SdlNGTuGPKmzU89KWqfcBhWPS +J3KYyFd/pGb13OZgvap2jQKBgBXCXR84LEHdJCQmh2aB95gGy8fjJZ6TBBsXeuKr +xYEpPf0XO+DuN8wObSmBhmBKLorCIW/utqBOcpFlOXrsFP24dV+g1BkgLUHk6J8v +3V4xUQfsk+Qd5YfaujyDhyMyoQ3UMaOF3QdpmGgGsAvhL/MaP3pmNwzOkBgFrAV6 +wggBAoGBAMflqy2pfqGhaj9S6qZ3K95h7NdCUikdQzqmgbNtOHaZ2kHByyYtOPLB +1VnuDRQiacmum+fTZa6wNmvp2FWg+uxI/aspfF6SdPfGpyPrG5D+ITtqKF2xieK+ +XpzehKTrTuYQRAVhmWbhpuyahYnQyd/MrsCMGzUfAJtM7l5vKa2O +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.crt b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.crt new file mode 100644 index 000000000..46b1e2a7a --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDiDCCAnACCQCCsPcIlZO4TDANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC +Q0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29sdW1iaWExDjAMBgNVBAcMBUNvbW94MRQw +EgYDVQQKDAtUaGVCcmFpbi5jYTEUMBIGA1UEAwwLdGhlYnJhaW4uY2ExHzAdBgkq +hkiG9w0BCQEWEGluZm9AdGhlYnJhaW4uY2EwHhcNMjEwMTEzMDkwNDIyWhcNMzEw +MTExMDkwNDIyWjCBhTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29s +dW1iaWExDjAMBgNVBAcMBUNvbW94MRQwEgYDVQQKDAtUaGVCcmFpbi5jYTEUMBIG +A1UEAwwLdGhlYnJhaW4uY2ExHzAdBgkqhkiG9w0BCQEWEGluZm9AdGhlYnJhaW4u +Y2EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2YWuwplM2Hc5tzBMu +covW9nwZ8iNEFo5pbDc8710pmnkF+wsDztLy4afJe6OeVHyCgQxmE+rTZcoWbvoh +pxW3Zy/8es4My07RKHqI3NYadThUvDsmI10cF3tJbhOZaIrMaExLGookZYKwbNAy +7yJ1+MLyNCuFFsaOiNNxHOjH/InKSzEuGSLV68tdC7Pe+uanBcC7RKhOrjUC6Occ +naHPC+a/YMyRYx29T8CfkCBB7N6WanWylFN/1RBmAgq++kDflSaF9k+Zdl6I4jiF +mCPGS0k+AMre4PuAKOZOZOwhF0sWlXIxH6zPm9w0bSYdTLBupL846RTO72NtNP+X +KX5DAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACXXFws+h+Zo9HsxW3BWpl2JU5u6 +KyfbLQt4kSN/gqltd4s84Q8c4z2jNdI0t8Oh5dXTjbLCpFjzuF2tdMtOWeYBCdsQ +4NJ69RrwkFdsSPxDPhSE0WGXPaOBaA92wJjTkVf+UYIek1ozeyWwFm1LPiZVei00 +mwDVgbAbIEb8cf6OqJrl2r5PMBCLWBwwg5aca3fe6TopJhyPA//DZDRPA5xzKb9e +PHUgF3apbcWxuxm8Mts4bAq8BcKoEvLHYWJ4fEWQvXPP7q1jYC3TkpSt5n3FQZTe +nLyQ+RNzsEHzmyOtTSa0Q+5KVluO1TE3ifpv8737pTLdY8t2waBamoboCu8= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.srl b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.srl new file mode 100644 index 000000000..cf7e9e551 --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/root.srl @@ -0,0 +1 @@ +BB275A0F903CFBBD diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.crt b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.crt new file mode 100644 index 000000000..46b1e2a7a --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDiDCCAnACCQCCsPcIlZO4TDANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC +Q0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29sdW1iaWExDjAMBgNVBAcMBUNvbW94MRQw +EgYDVQQKDAtUaGVCcmFpbi5jYTEUMBIGA1UEAwwLdGhlYnJhaW4uY2ExHzAdBgkq +hkiG9w0BCQEWEGluZm9AdGhlYnJhaW4uY2EwHhcNMjEwMTEzMDkwNDIyWhcNMzEw +MTExMDkwNDIyWjCBhTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgMEEJyaXRpc2ggQ29s +dW1iaWExDjAMBgNVBAcMBUNvbW94MRQwEgYDVQQKDAtUaGVCcmFpbi5jYTEUMBIG +A1UEAwwLdGhlYnJhaW4uY2ExHzAdBgkqhkiG9w0BCQEWEGluZm9AdGhlYnJhaW4u +Y2EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2YWuwplM2Hc5tzBMu +covW9nwZ8iNEFo5pbDc8710pmnkF+wsDztLy4afJe6OeVHyCgQxmE+rTZcoWbvoh +pxW3Zy/8es4My07RKHqI3NYadThUvDsmI10cF3tJbhOZaIrMaExLGookZYKwbNAy +7yJ1+MLyNCuFFsaOiNNxHOjH/InKSzEuGSLV68tdC7Pe+uanBcC7RKhOrjUC6Occ +naHPC+a/YMyRYx29T8CfkCBB7N6WanWylFN/1RBmAgq++kDflSaF9k+Zdl6I4jiF +mCPGS0k+AMre4PuAKOZOZOwhF0sWlXIxH6zPm9w0bSYdTLBupL846RTO72NtNP+X +KX5DAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACXXFws+h+Zo9HsxW3BWpl2JU5u6 +KyfbLQt4kSN/gqltd4s84Q8c4z2jNdI0t8Oh5dXTjbLCpFjzuF2tdMtOWeYBCdsQ +4NJ69RrwkFdsSPxDPhSE0WGXPaOBaA92wJjTkVf+UYIek1ozeyWwFm1LPiZVei00 +mwDVgbAbIEb8cf6OqJrl2r5PMBCLWBwwg5aca3fe6TopJhyPA//DZDRPA5xzKb9e +PHUgF3apbcWxuxm8Mts4bAq8BcKoEvLHYWJ4fEWQvXPP7q1jYC3TkpSt5n3FQZTe +nLyQ+RNzsEHzmyOtTSa0Q+5KVluO1TE3ifpv8737pTLdY8t2waBamoboCu8= +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.key b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.key new file mode 100644 index 000000000..8bd131632 --- /dev/null +++ b/apps/emqx_auth_pgsql/test/emqx_auth_pgsql_SUITE_data/server.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAtmFrsKZTNh3ObcwTLnKL1vZ8GfIjRBaOaWw3PO9dKZp5BfsL +A87S8uGnyXujnlR8goEMZhPq02XKFm76IacVt2cv/HrODMtO0Sh6iNzWGnU4VLw7 +JiNdHBd7SW4TmWiKzGhMSxqKJGWCsGzQMu8idfjC8jQrhRbGjojTcRzox/yJyksx +Lhki1evLXQuz3vrmpwXAu0SoTq41AujnHJ2hzwvmv2DMkWMdvU/An5AgQezelmp1 +spRTf9UQZgIKvvpA35UmhfZPmXZeiOI4hZgjxktJPgDK3uD7gCjmTmTsIRdLFpVy +MR+sz5vcNG0mHUywbqS/OOkUzu9jbTT/lyl+QwIDAQABAoIBAA6UVR6G/UnrMhBW +6wWghItHov4T/Du6LeJBk1zcqa7kuV4ABo5kXzqpTVdu+dJzYIyyMkKKvw/tKC2I +65f7GmJR7mUZkBU3v3I68Si1tqvgyQMFFRlkZFIVknZ5RTnTQJ08jTTHx1lHgB4I +ZNBdi3ywySzBfOUjv/Wu/HAjZnxuEh2guBpRMZdwQwZLXr2koDa5inL3IwJrA4Ir +QzpZ0y6ql3A0tw7jAw36G1AKyyz74aFwJ0I8U8w+2Uk4iX5hcKGA8mFq4lyO4/3+ +7W2Z4V8cQzwMq2SMixI0Omxlc2BJUi9j17Ey//5dAXyPaG8QI1kzeL/3Gbs8YBMq +ekN8AZECgYEA5YxcFIVv3yO+ARNWUHovrsMuf9ElhyRuZd0I2+vjrq1b9zQsSy2d +PsyYWD17lO/GDmpTzZOdVsYtZHi+EiXmQnkzLJ4m2nlc7W4annWlbzlQMEn6vAji +l9bSHJXXiiIB7X/oHpDUdsnJp/uyAJppmnVLbSBboNCrG4Mf5cJqOnsCgYEAy2We +scp19h4UEKAU0Yh+5jh8W4VVtlISkH64vMgz/JZWXMPt1bM5C/5j+3UVUL5VmFqF +J1g0gXYkTGTL0+entb3SUiL42zrp3rZ3GgMU6V+aktq3dmri5bOifzihuLHLgjO5 +u/MJPBzvFxIiJxnNBybNLijIZfPm+9roUfpcBNkCgYBGE3Zc0WuYnEm5/FRCVzrN +SEqevJOPUSDeuf6lXLryLXxA2E2ZWcCCVmU/su1SR2yYI/+XZ7QFtJRQ8sdbtPQ5 +YNStj05fLeOfnBhGPbYWYVHInB0OYEwEfJFCJsBZLA6YmY6cHiyuYuXMAXuS0ZDh +lWNEWjd+vZUu3fXT52kUlwKBgDgq/eH3GRA4Si41JsqeOPz2iFD1xy+sBnhkpjtr +xf9wvLStXpZvAcfwHkgokxRTG2wRQ0gUMZu2tltqUmdYR5YGr3gDNFnGMSNRnB5Q +z4uK3TLEt3k6FyJ7stoTF4Xbg2mXQylF+jzheJ0UYt4NX/MjofGnTX/qFNVkJFfP +HW4xAoGBAMBb9cXTpzOMiMcSdQRlaLttV1p05pqxTgQNEQD8HB+lkx4AGnnHvtxW +XQJvPumtqdCEpfe4kaqLip8T+67sGfcDVQMogJc/tpvZ0AN4FuViFsf/YDuTPXEp +whMldPHtusbRP2fk/JFq4Ak0Xz2wAI1iMD3qfBeW6eJpvRllUo69 +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_redis/.gitignore b/apps/emqx_auth_redis/.gitignore new file mode 100644 index 000000000..71ecbe89a --- /dev/null +++ b/apps/emqx_auth_redis/.gitignore @@ -0,0 +1,28 @@ +.rebar/ +.eunit/ +.erlang.mk/ +emqttd_auth_redis.d +deps/ +ct.coverdata +logs/ +test/ct.cover.spec +ebin/ +*.o +*.beam +*.plt +erl_crash.dump +data +emqx_auth_redis.d +cover/ +eunit.coverdata +_build/ +rebar.lock +erlang.mk +*.conf.rendered +.rebar3/ +*.swp +rebar.lock +/.idea/ +.DS_Store +/.ci/redis/nodes.*.conf +/.ci/redis/*.log \ No newline at end of file diff --git a/apps/emqx_auth_redis/README.md b/apps/emqx_auth_redis/README.md new file mode 100644 index 000000000..9aa851f88 --- /dev/null +++ b/apps/emqx_auth_redis/README.md @@ -0,0 +1,171 @@ +emqx_auth_redis +=============== + +EMQ X Redis Authentication/ACL Plugin + +Features +--------- + +- Full *Authentication*, *Superuser*, *ACL* support +- IPv4, IPv6 support +- Connection pool by [ecpool](https://github.com/emqx/ecpool) +- Support `single`, `sentinel`, `cluster` deployment structures of Redis +- Completely cover Redis 5, Redis 6 in our tests + + +Build Plugin +------------ + +``` +make && make tests +``` + +Configure Plugin +---------------- + +File: etc/emqx_auth_redis.conf + +``` +## Redis server address. +## +## Value: Port | IP:Port +## +## Redis Server: 6379, 127.0.0.1:6379, localhost:6379, Redis Sentinel: 127.0.0.1:26379 +auth.redis.server = 127.0.0.1:6379 + +## redis sentinel cluster name +## auth.redis.sentinel = mymaster + +## Redis pool size. +## +## Value: Number +auth.redis.pool = 8 + +## Redis database no. +## +## Value: Number +auth.redis.database = 0 + +## Redis password. +## +## Value: String +## auth.redis.password = + +## Authentication query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Examples: +## - HGET mqtt_user:%u password +## - HMGET mqtt_user:%u password +## - HMGET mqtt_user:%u password salt +auth.redis.auth_cmd = HMGET mqtt_user:%u password + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.redis.password_hash = plain + +## sha256 with salt prefix +## auth.redis.password_hash = salt,sha256 + +## sha256 with salt suffix +## auth.redis.password_hash = sha256,salt + +## bcrypt with salt prefix +## auth.redis.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.redis.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +auth.redis.super_cmd = HGET mqtt_user:%u is_superuser + +## ACL query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +auth.redis.acl_cmd = HGETALL mqtt_acl:%u +``` + +SuperUser +--------- + +``` +HSET mqtt_user: is_superuser 1 +``` + +User Hash with Password Salt +---------------------------- + +Set a 'user' hash with 'password' 'salt' field, for example: + +``` +HMSET mqtt_user: password "password" salt "salt" +``` + +User Set with Password +----------------------- + +Set a 'user' Set with 'password' field for example: + +``` +HSET mqtt_user: password "password" +``` + +ACL Rule Hash +------------- + +The plugin uses a redis hash to store ACL rules: + +``` +HSET mqtt_acl: topic1 1 +HSET mqtt_acl: topic2 2 +HSET mqtt_acl: topic3 3 +``` + +NOTE: 1: subscribe, 2: publish, 3: pubsub + +Subscription Hash +----------------- + +NOTICE: Move to emqx_backend_redis... + +The plugin could store the static subscriptions into a redis Hash: + +``` +HSET mqtt_sub: topic1 0 +HSET mqtt_sub: topic2 1 +HSET mqtt_sub: topic3 2 +``` + +Load Plugin +----------- + +``` +./bin/emqx_ctl plugins load emqx_auth_redis +``` + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_auth_redis/etc/emqx_auth_redis.conf b/apps/emqx_auth_redis/etc/emqx_auth_redis.conf new file mode 100644 index 000000000..77b247a06 --- /dev/null +++ b/apps/emqx_auth_redis/etc/emqx_auth_redis.conf @@ -0,0 +1,117 @@ +##-------------------------------------------------------------------- +## Redis Auth/ACL Plugin +##-------------------------------------------------------------------- +## Redis Server cluster type +## single Single redis server +## sentinel Redis cluster through sentinel +## cluster Redis through cluster +auth.redis.type = single + +## Redis server address. +## +## Value: Port | IP:Port +## +## Single Redis Server: 127.0.0.1:6379, localhost:6379 +## Redis Sentinel: 127.0.0.1:26379,127.0.0.2:26379,127.0.0.3:26379 +## Redis Cluster: 127.0.0.1:6379,127.0.0.2:6379,127.0.0.3:6379 +auth.redis.server = 127.0.0.1:6379 + +## Redis sentinel cluster name. +## +## Value: String +## auth.redis.sentinel = mymaster + +## Redis pool size. +## +## Value: Number +auth.redis.pool = 8 + +## Redis database no. +## +## Value: Number +auth.redis.database = 0 + +## Redis password. +## +## Value: String +## auth.redis.password = + +## Redis query timeout +## +## Value: Duration +## auth.redis.query_timeout = 5s + +## Authentication query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +## +## Examples: +## - HGET mqtt_user:%u password +## - HMGET mqtt_user:%u password +## - HMGET mqtt_user:%u password salt +auth.redis.auth_cmd = HMGET mqtt_user:%u password + +## Password hash. +## +## Value: plain | md5 | sha | sha256 | bcrypt +auth.redis.password_hash = plain + +## sha256 with salt prefix +## auth.redis.password_hash = salt,sha256 + +## sha256 with salt suffix +## auth.redis.password_hash = sha256,salt + +## bcrypt with salt prefix +## auth.redis.password_hash = salt,bcrypt + +## pbkdf2 with macfun iterations dklen +## macfun: md4, md5, ripemd160, sha, sha224, sha256, sha384, sha512 +## auth.redis.password_hash = pbkdf2,sha256,1000,20 + +## Superuser query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +## - %C: common name of client TLS cert +## - %d: subject of client TLS cert +auth.redis.super_cmd = HGET mqtt_user:%u is_superuser + +## ACL query command. +## +## Value: Redis cmd +## +## Variables: +## - %u: username +## - %c: clientid +auth.redis.acl_cmd = HGETALL mqtt_acl:%u + +## Redis ssl configuration. +## +## Value: on | off +#auth.redis.ssl = off + +## CA certificate. +## +## Value: File +#auth.redis.ssl.cacertfile = path/to/your/cafile.pem + +## Client ssl certificate. +## +## Value: File +#auth.redis.ssl.certfile = path/to/your/certfile + +## Client ssl keyfile. +## +## Value: File +#auth.redis.ssl.keyfile = path/to/your/keyfile + diff --git a/apps/emqx_auth_redis/include/emqx_auth_redis.hrl b/apps/emqx_auth_redis/include/emqx_auth_redis.hrl new file mode 100644 index 000000000..204d8ef70 --- /dev/null +++ b/apps/emqx_auth_redis/include/emqx_auth_redis.hrl @@ -0,0 +1,23 @@ + +-define(APP, emqx_auth_redis). + +-record(auth_metrics, { + success = 'client.auth.success', + failure = 'client.auth.failure', + ignore = 'client.auth.ignore' + }). + +-record(acl_metrics, { + allow = 'client.acl.allow', + deny = 'client.acl.deny', + ignore = 'client.acl.ignore' + }). + +-define(METRICS(Type), tl(tuple_to_list(#Type{}))). +-define(METRICS(Type, K), #Type{}#Type.K). + +-define(AUTH_METRICS, ?METRICS(auth_metrics)). +-define(AUTH_METRICS(K), ?METRICS(auth_metrics, K)). + +-define(ACL_METRICS, ?METRICS(acl_metrics)). +-define(ACL_METRICS(K), ?METRICS(acl_metrics, K)). diff --git a/apps/emqx_auth_redis/priv/emqx_auth_redis.schema b/apps/emqx_auth_redis/priv/emqx_auth_redis.schema new file mode 100644 index 000000000..070f306af --- /dev/null +++ b/apps/emqx_auth_redis/priv/emqx_auth_redis.schema @@ -0,0 +1,167 @@ +%%-*- mode: erlang -*- +%% emqx_auth_redis config mapping + +{mapping, "auth.redis.type", "emqx_auth_redis.server", [ + {default, single}, + {datatype, {enum, [single, sentinel, cluster]}} +]}. + +{mapping, "auth.redis.server", "emqx_auth_redis.server", [ + {default, "127.0.0.1:6379"}, + {datatype, [string]} +]}. + +{mapping, "auth.redis.sentinel", "emqx_auth_redis.server", [ + {default, ""}, + {datatype, string}, + hidden +]}. + +{mapping, "auth.redis.pool", "emqx_auth_redis.server", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.redis.database", "emqx_auth_redis.server", [ + {default, 0}, + {datatype, integer} +]}. + +{mapping, "auth.redis.password", "emqx_auth_redis.server", [ + {default, ""}, + {datatype, string}, + hidden +]}. + +{mapping, "auth.redis.ssl", "emqx_auth_redis.options", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.redis.ssl.cacertfile", "emqx_auth_redis.options", [ + {datatype, string} +]}. + +{mapping, "auth.redis.ssl.certfile", "emqx_auth_redis.options", [ + {datatype, string} +]}. + +{mapping, "auth.redis.ssl.keyfile", "emqx_auth_redis.options", [ + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.redis.cafile", "emqx_auth_redis.options", [ + {default, ""}, + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.redis.certfile", "emqx_auth_redis.options", [ + {default, ""}, + {datatype, string} +]}. + +%% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 +{mapping, "auth.redis.keyfile", "emqx_auth_redis.options", [ + {default, ""}, + {datatype, string} +]}. + +{translation, "emqx_auth_redis.options", fun(Conf) -> + Ssl = cuttlefish:conf_get("auth.redis.ssl", Conf, false), + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + case Ssl of + true -> + %% FIXME: compatible with 4.0-4.2 version format, plan to delete in 5.0 + CA = cuttlefish:conf_get( + "auth.redis.ssl.cacertfile", Conf, + cuttlefish:conf_get("auth.redis.cacertfile", Conf, undefined) + ), + Cert = cuttlefish:conf_get( + "auth.redis.ssl.certfile", Conf, + cuttlefish:conf_get("auth.redis.certfile", Conf, undefined) + ), + Key = cuttlefish:conf_get( + "auth.redis.ssl.keyfile", Conf, + cuttlefish:conf_get("auth.redis.keyfile", Conf, undefined) + ), + [{options, [{ssl_options, + Filter([{cacertfile, CA}, + {certfile, Cert}, + {keyfile, Key}]) + }]}]; + _ -> [{options, []}] + end +end}. + +{translation, "emqx_auth_redis.server", fun(Conf) -> + Fun = fun(S) -> + case string:split(S, ":", trailing) of + [Domain] -> {Domain, 6379}; + [Domain, Port] -> {Domain, list_to_integer(Port)} + end + end, + Servers = cuttlefish:conf_get("auth.redis.server", Conf), + Type = cuttlefish:conf_get("auth.redis.type", Conf), + Server = case Type of + single -> + {Host, Port} = Fun(Servers), + [{host, Host}, {port, Port}]; + _ -> + S = string:tokens(Servers, ","), + [{servers, [Fun(S1) || S1 <- S]}] + end, + Pool = cuttlefish:conf_get("auth.redis.pool", Conf), + Passwd = cuttlefish:conf_get("auth.redis.password", Conf), + DB = cuttlefish:conf_get("auth.redis.database", Conf), + Sentinel = cuttlefish:conf_get("auth.redis.sentinel", Conf), + [{type, Type}, + {pool_size, Pool}, + {auto_reconnect, 1}, + {database, DB}, + {password, Passwd}, + {sentinel, Sentinel}] ++ Server +end}. + +{mapping, "auth.redis.query_timeout", "emqx_auth_redis.query_timeout", [ + {default, ""}, + {datatype, string} +]}. + +{translation, "emqx_auth_redis.query_timeout", fun(Conf) -> + case cuttlefish:conf_get("auth.redis.query_timeout", Conf) of + "" -> infinity; + Duration -> + case cuttlefish_duration:parse(Duration, ms) of + {error, Reason} -> error(Reason); + Ms when is_integer(Ms) -> Ms + end + end +end}. + +{mapping, "auth.redis.auth_cmd", "emqx_auth_redis.auth_cmd", [ + {datatype, string} +]}. + +{mapping, "auth.redis.password_hash", "emqx_auth_redis.password_hash", [ + {datatype, string} +]}. + +{mapping, "auth.redis.super_cmd", "emqx_auth_redis.super_cmd", [ + {datatype, string} +]}. + +{mapping, "auth.redis.acl_cmd", "emqx_auth_redis.acl_cmd", [ + {datatype, string} +]}. + +{translation, "emqx_auth_redis.password_hash", fun(Conf) -> + HashValue = cuttlefish:conf_get("auth.redis.password_hash", Conf), + case string:tokens(HashValue, ",") of + [Hash] -> list_to_atom(Hash); + [Prefix, Suffix] -> {list_to_atom(Prefix), list_to_atom(Suffix)}; + [Hash, MacFun, Iterations, Dklen] -> {list_to_atom(Hash), list_to_atom(MacFun), list_to_integer(Iterations), list_to_integer(Dklen)}; + _ -> plain + end +end}. diff --git a/apps/emqx_auth_redis/rebar.config b/apps/emqx_auth_redis/rebar.config new file mode 100644 index 000000000..72c91aa4e --- /dev/null +++ b/apps/emqx_auth_redis/rebar.config @@ -0,0 +1,21 @@ +{deps, + [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.4"}}} + ]}. + +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + compressed + ]}. +{overrides, [{add, [{erl_opts, [compressed]}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions + ]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_auth_redis/src/emqx_acl_redis.erl b/apps/emqx_auth_redis/src/emqx_acl_redis.erl new file mode 100644 index 000000000..096523487 --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_acl_redis.erl @@ -0,0 +1,86 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_acl_redis). + +-include("emqx_auth_redis.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ register_metrics/0 + , check_acl/5 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?ACL_METRICS). + +check_acl(ClientInfo, PubSub, Topic, AclResult, Config) -> + case do_check_acl(ClientInfo, PubSub, Topic, AclResult, Config) of + ok -> emqx_metrics:inc(?ACL_METRICS(ignore)), ok; + {stop, allow} -> emqx_metrics:inc(?ACL_METRICS(allow)), {stop, allow}; + {stop, deny} -> emqx_metrics:inc(?ACL_METRICS(deny)), {stop, deny} + end. + +do_check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _AclResult, _Config) -> + ok; +do_check_acl(ClientInfo, PubSub, Topic, _AclResult, + #{acl_cmd := AclCmd, timeout := Timeout, type := Type, pool := Pool}) -> + case emqx_auth_redis_cli:q(Pool, Type, AclCmd, ClientInfo, Timeout) of + {ok, []} -> ok; + {ok, Rules} -> + case match(ClientInfo, PubSub, Topic, Rules) of + allow -> {stop, allow}; + nomatch -> {stop, deny} + end; + {error, Reason} -> + ?LOG(error, "[Redis] do_check_acl error: ~p", [Reason]), + ok + end. + +match(_ClientInfo, _PubSub, _Topic, []) -> + nomatch; +match(ClientInfo, PubSub, Topic, [Filter, Access | Rules]) -> + case {match_topic(Topic, feed_var(ClientInfo, Filter)), + match_access(PubSub, b2i(Access))} of + {true, true} -> allow; + {_, _} -> match(ClientInfo, PubSub, Topic, Rules) + end. + +match_topic(Topic, Filter) -> + emqx_topic:match(Topic, Filter). + +match_access(subscribe, Access) -> + (1 band Access) > 0; +match_access(publish, Access) -> + (2 band Access) > 0. + +feed_var(#{clientid := ClientId, username := Username}, Str) -> + lists:foldl(fun({Var, Val}, Acc) -> + feed_var(Acc, Var, Val) + end, Str, [{"%u", Username}, {"%c", ClientId}]). + +feed_var(Str, _Var, undefined) -> + Str; +feed_var(Str, Var, Val) -> + re:replace(Str, Var, Val, [global, {return, binary}]). + +b2i(Bin) -> list_to_integer(binary_to_list(Bin)). + +description() -> "Redis ACL Module". + diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis.app.src b/apps/emqx_auth_redis/src/emqx_auth_redis.app.src new file mode 100644 index 000000000..6a38af2ce --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_auth_redis.app.src @@ -0,0 +1,14 @@ +{application, emqx_auth_redis, + [{description, "EMQ X Authentication/ACL with Redis"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_auth_redis_sup]}, + {applications, [kernel,stdlib,eredis,eredis_cluster,ecpool]}, + {mod, {emqx_auth_redis_app, []}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-auth-redis"} + ]} + ]}. diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis.erl b/apps/emqx_auth_redis/src/emqx_auth_redis.erl new file mode 100644 index 000000000..65f4d9735 --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_auth_redis.erl @@ -0,0 +1,85 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_redis). + +-include("emqx_auth_redis.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ register_metrics/0 + , check/3 + , description/0 + ]). + +-spec(register_metrics() -> ok). +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, ?AUTH_METRICS). + +check(ClientInfo = #{password := Password}, AuthResult, + #{auth_cmd := AuthCmd, + super_cmd := SuperCmd, + hash_type := HashType, + timeout := Timeout, + type := Type, + pool := Pool}) -> + CheckPass = case emqx_auth_redis_cli:q(Pool, Type, AuthCmd, ClientInfo, Timeout) of + {ok, PassHash} when is_binary(PassHash) -> + check_pass({PassHash, Password}, HashType); + {ok, [undefined|_]} -> + {error, not_found}; + {ok, [PassHash]} -> + check_pass({PassHash, Password}, HashType); + {ok, [PassHash, Salt|_]} -> + check_pass({PassHash, Salt, Password}, HashType); + {error, Reason} -> + ?LOG(error, "[Redis] Command: ~p failed: ~p", [AuthCmd, Reason]), + {error, not_found} + end, + case CheckPass of + ok -> + ok = emqx_metrics:inc(?AUTH_METRICS(success)), + IsSuperuser = is_superuser(Pool, Type, SuperCmd, ClientInfo, Timeout), + {stop, AuthResult#{is_superuser => IsSuperuser, + anonymous => false, + auth_result => success}}; + {error, not_found} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, ResultCode} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + ?LOG(error, "[Redis] Auth from redis failed: ~p", [ResultCode]), + {stop, AuthResult#{auth_result => ResultCode, anonymous => false}} + end. + +description() -> "Authentication with Redis". + +-spec(is_superuser(atom(), atom(), undefined|list(), emqx_types:client(), timeout()) -> boolean()). +is_superuser(_Pool, _Type, undefined, _ClientInfo, _Timeout) -> false; +is_superuser(Pool, Type, SuperCmd, ClientInfo, Timeout) -> + case emqx_auth_redis_cli:q(Pool, Type, SuperCmd, ClientInfo, Timeout) of + {ok, undefined} -> false; + {ok, <<"1">>} -> true; + {ok, _Other} -> false; + {error, _Error} -> false + end. + +check_pass(Password, HashType) -> + case emqx_passwd:check_pass(Password, HashType) of + ok -> ok; + {error, _Reason} -> {error, not_authorized} + end. + diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_app.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_app.erl new file mode 100644 index 000000000..619b3f78d --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_app.erl @@ -0,0 +1,70 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_redis_app). + +-behaviour(application). + +-emqx_plugin(auth). + +-include("emqx_auth_redis.hrl"). + +-export([ start/2 + , stop/1 + ]). + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_auth_redis_sup:start_link(), + _ = if_cmd_enabled(auth_cmd, fun load_auth_hook/1), + _ = if_cmd_enabled(acl_cmd, fun load_acl_hook/1), + {ok, Sup}. + +stop(_State) -> + emqx:unhook('client.authenticate', fun emqx_auth_redis:check/3), + emqx:unhook('client.check_acl', fun emqx_acl_redis:check_acl/5), + %% Ensure stop cluster pool if the server type is cluster + eredis_cluster:stop_pool(?APP). + +load_auth_hook(AuthCmd) -> + SuperCmd = application:get_env(?APP, super_cmd, undefined), + {ok, HashType} = application:get_env(?APP, password_hash), + {ok, Timeout} = application:get_env(?APP, query_timeout), + Type = proplists:get_value(type, application:get_env(?APP, server, [])), + Config = #{auth_cmd => AuthCmd, + super_cmd => SuperCmd, + hash_type => HashType, + timeout => Timeout, + type => Type, + pool => ?APP}, + ok = emqx_auth_redis:register_metrics(), + emqx:hook('client.authenticate', fun emqx_auth_redis:check/3, [Config]). + +load_acl_hook(AclCmd) -> + {ok, Timeout} = application:get_env(?APP, query_timeout), + Type = proplists:get_value(type, application:get_env(?APP, server, [])), + Config = #{acl_cmd => AclCmd, + timeout => Timeout, + type => Type, + pool => ?APP}, + ok = emqx_acl_redis:register_metrics(), + emqx:hook('client.check_acl', fun emqx_acl_redis:check_acl/5, [Config]). + +if_cmd_enabled(Par, Fun) -> + case application:get_env(?APP, Par) of + {ok, Cmd} -> Fun(Cmd); + undefined -> ok + end. + diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl new file mode 100644 index 000000000..26550dff4 --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl @@ -0,0 +1,89 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_redis_cli). + +-behaviour(ecpool_worker). + +-include("emqx_auth_redis.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-import(proplists, [get_value/2, get_value/3]). + +-export([ connect/1 + , q/5 + ]). + +%%-------------------------------------------------------------------- +%% Redis Connect/Query +%%-------------------------------------------------------------------- + +connect(Opts) -> + Sentinel = get_value(sentinel, Opts), + Host = case Sentinel =:= "" of + true -> get_value(host, Opts); + false -> + _ = eredis_sentinel:start_link(get_value(servers, Opts)), + "sentinel:" ++ Sentinel + end, + case eredis:start_link(Host, + get_value(port, Opts, 6379), + get_value(database, Opts, 0), + get_value(password, Opts, ""), + 3000, + 5000, + get_value(options, Opts, [])) of + {ok, Pid} -> {ok, Pid}; + {error, Reason = {connection_error, _}} -> + ?LOG(error, "[Redis] Can't connect to Redis server: Connection refused."), + {error, Reason}; + {error, Reason = {authentication_error, _}} -> + ?LOG(error, "[Redis] Can't connect to Redis server: Authentication failed."), + {error, Reason}; + {error, Reason} -> + ?LOG(error, "[Redis] Can't connect to Redis server: ~p", [Reason]), + {error, Reason} + end. + +%% Redis Query. +-spec(q(atom(), atom(), string(), emqx_types:credentials(), timeout()) + -> {ok, undefined | binary() | list()} | {error, atom() | binary()}). +q(Pool, Type, CmdStr, Credentials, Timeout) -> + Cmd = string:tokens(replvar(CmdStr, Credentials), " "), + case Type of + cluster -> eredis_cluster:q(Pool, Cmd); + _ -> ecpool:with_client(Pool, fun(C) -> eredis:q(C, Cmd, Timeout) end) + end. + +replvar(Cmd, Credentials = #{cn := CN}) -> + replvar(repl(Cmd, "%C", CN), maps:remove(cn, Credentials)); +replvar(Cmd, Credentials = #{dn := DN}) -> + replvar(repl(Cmd, "%d", DN), maps:remove(dn, Credentials)); +replvar(Cmd, Credentials = #{clientid := ClientId}) -> + replvar(repl(Cmd, "%c", ClientId), maps:remove(clientid, Credentials)); +replvar(Cmd, Credentials = #{username := Username}) -> + replvar(repl(Cmd, "%u", Username), maps:remove(username, Credentials)); +replvar(Cmd, _) -> + Cmd. + +repl(S, _Var, undefined) -> + S; +repl(S, Var, Val) -> + NVal = re:replace(Val, "&", "\\\\&", [global, {return, list}]), + re:replace(S, Var, NVal, [{return, list}]). + diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl new file mode 100644 index 000000000..83112976d --- /dev/null +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl @@ -0,0 +1,43 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_redis_sup). + +-behaviour(supervisor). + +-include("emqx_auth_redis.hrl"). + +-export([start_link/0]). + +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, Server} = application:get_env(?APP, server), + {ok, {{one_for_one, 10, 100}, pool_spec(Server)}}. + +pool_spec(Server) -> + Options = application:get_env(?APP, options, []), + case proplists:get_value(type, Server) of + cluster -> + {ok, _} = eredis_cluster:start_pool(?APP, Server ++ Options), + []; + _ -> + [ecpool:pool_spec(?APP, ?APP, emqx_auth_redis_cli, Server ++ Options)] + end. + diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE.erl b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE.erl new file mode 100644 index 000000000..b2fac9be8 --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE.erl @@ -0,0 +1,190 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_redis_SUITE). + +-compile(export_all). + +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("common_test/include/ct.hrl"). + +-include_lib("eunit/include/eunit.hrl"). + +-define(APP, emqx_auth_redis). + +-define(POOL(App), ecpool_worker:client(gproc_pool:pick_worker({ecpool, App}))). + +-define(INIT_ACL, [{"mqtt_acl:test1", "topic1", "2"}, + {"mqtt_acl:test2", "topic2", "1"}, + {"mqtt_acl:test3", "topic3", "3"}]). + +-define(INIT_AUTH, [{"mqtt_user:plain", ["password", "plain", "salt", "salt", "is_superuser", "1"]}, + {"mqtt_user:special&symbol", ["password", "plain", "salt", "salt", "is_superuser", "0"]}, + {"mqtt_user:md5", ["password", "1bc29b36f623ba82aaf6724fd3b16718", "salt", "salt", "is_superuser", "0"]}, + {"mqtt_user:sha", ["password", "d8f4590320e1343a915b6394170650a8f35d6926", "salt", "salt", "is_superuser", "0"]}, + {"mqtt_user:sha256", ["password", "5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e", "salt", "salt", "is_superuser", "0"]}, + {"mqtt_user:pbkdf2_password", ["password", "cdedb5281bb2f801565a1122b2563515", "salt", "ATHENA.MIT.EDUraeburn", "is_superuser", "0"]}, + {"mqtt_user:bcrypt_foo", ["password", "$2a$12$sSS8Eg.ovVzaHzi1nUHYK.HbUIOdlQI0iS22Q5rd5z.JVVYH6sfm6", "salt", "$2a$12$sSS8Eg.ovVzaHzi1nUHYK.", "is_superuser", "0"]}, + {"mqtt_user:bcrypt", ["password", "$2y$16$rEVsDarhgHYB0TGnDFJzyu5f.T.Ha9iXMTk9J36NCMWWM7O16qyaK", "salt", "salt", "is_superuser", "0"]}]). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + emqx_ct_helpers:start_apps([emqx_auth_redis], fun set_special_configs/1), + init_redis_rows(), + Cfg. + +end_per_suite(_Cfg) -> + deinit_redis_rows(), + emqx_ct_helpers:stop_apps([emqx_auth_redis]). + +set_special_configs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, acl_nomatch, deny), + application:set_env(emqx, acl_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/acl.conf")), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_configs(_App) -> + ok. + +init_redis_rows() -> + %% Users + [q(["HMSET", Key|FiledValue]) || {Key, FiledValue} <- ?INIT_AUTH], + %% ACLs + emqx_modules:load_module(emqx_mod_acl_internal, false), + Result = [q(["HSET", Key, Filed, Value]) || {Key, Filed, Value} <- ?INIT_ACL], + ct:pal("redis init result: ~p~n", [Result]). + +deinit_redis_rows() -> + AuthKeys = [Key || {Key, _Filed, _Value} <- ?INIT_AUTH], + AclKeys = [Key || {Key, _Value} <- ?INIT_ACL], + q(["DEL" | AuthKeys]), + q(["DEL" | AclKeys]). + +%%-------------------------------------------------------------------- +%% Cases +%%-------------------------------------------------------------------- + +t_check_auth(_) -> + Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, + SpecialSymbol = #{clientid => <<"special_symbol">>, username => <<"special&symbol">>, zone => external}, + Md5 = #{clientid => <<"md5">>, username => <<"md5">>, zone => external}, + Sha = #{clientid => <<"sha">>, username => <<"sha">>, zone => external}, + Sha256 = #{clientid => <<"sha256">>, username => <<"sha256">>, zone => external}, + Pbkdf2 = #{clientid => <<"pbkdf2_password">>, username => <<"pbkdf2_password">>, zone => external}, + BcryptFoo = #{clientid => <<"bcrypt_foo">>, username => <<"bcrypt_foo">>, zone => external}, + User1 = #{clientid => <<"bcrypt_foo">>, username => <<"user">>, zone => external}, + User3 = #{clientid => <<"client3">>, zone => external}, + Bcrypt = #{clientid => <<"bcrypt">>, username => <<"bcrypt">>, zone => external}, + {error, _} = emqx_access_control:authenticate(User3#{password => <<>>}), + reload([{password_hash, plain}]), + {ok, #{is_superuser := true}} = emqx_access_control:authenticate(Plain#{password => <<"plain">>}), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(SpecialSymbol#{password => <<"plain">>}), + reload([{password_hash, md5}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Md5#{password => <<"md5">>}), + reload([{password_hash, sha}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Sha#{password => <<"sha">>}), + reload([{password_hash, sha256}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Sha256#{password => <<"sha256">>}), + reload([{password_hash, bcrypt}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}), + %%pbkdf2 sha + reload([{password_hash, {pbkdf2, sha, 1, 16}}, {auth_cmd, "HMGET mqtt_user:%u password salt"}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(Pbkdf2#{password => <<"password">>}), + reload([{password_hash, {salt, bcrypt}}]), + {ok, #{is_superuser := false}} = emqx_access_control:authenticate(BcryptFoo#{password => <<"foo">>}), + {error,_} = emqx_access_control:authenticate(User1#{password => <<"foo">>}), + {error, _} = emqx_access_control:authenticate(Bcrypt#{password => <<"password">>}). + +t_check_auth_hget(_) -> + q(["HSET", "mqtt_user:hset", "password", "hset"]), + q(["HSET", "mqtt_user:hset", "is_superuser", "1"]), + reload([{password_hash, plain}, {auth_cmd, "HGET mqtt_user:%u password"}]), + Hset = #{clientid => <<"hset">>, username => <<"hset">>, zone => external}, + {ok, #{is_superuser := true}} = emqx_access_control:authenticate(Hset#{password => <<"hset">>}). + +t_check_acl(_) -> + User1 = #{zone => external, clientid => <<"client1">>, username => <<"test1">>}, + User2 = #{zone => external, clientid => <<"client2">>, username => <<"test2">>}, + User3 = #{zone => external, clientid => <<"client3">>, username => <<"test3">>}, + User4 = #{zone => external, clientid => <<"client4">>, username => <<"$$user4">>}, + deny = emqx_access_control:check_acl(User1, subscribe, <<"topic1">>), + allow = emqx_access_control:check_acl(User1, publish, <<"topic1">>), + + deny = emqx_access_control:check_acl(User2, publish, <<"topic2">>), + allow = emqx_access_control:check_acl(User2, subscribe, <<"topic2">>), + allow = emqx_access_control:check_acl(User3, publish, <<"topic3">>), + allow = emqx_access_control:check_acl(User3, subscribe, <<"topic3">>), + allow = emqx_access_control:check_acl(User4, publish, <<"a/b/c">>). + +t_acl_super(_) -> + reload([{password_hash, plain}]), + {ok, C} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"simpleClient">>}, + {username, <<"plain">>}, + {password, <<"plain">>}]), + {ok, _} = emqtt:connect(C), + timer:sleep(10), + emqtt:subscribe(C, <<"TopicA">>, qos2), + timer:sleep(1000), + emqtt:publish(C, <<"TopicA">>, <<"Payload">>, qos2), + timer:sleep(1000), + receive + {publish, #{payload := Payload}} -> + ?assertEqual(<<"Payload">>, Payload) + after + 1000 -> + ct:fail({receive_timeout, <<"Payload">>}), + ok + end, + emqtt:disconnect(C). + +t_check_cluster_connection(_) -> + ?assertMatch({error, _Reason}, reload([{server, [{type,cluster}, + {pool_size,8}, + {auto_reconnect,1}, + {database,0}, + {password,[]}, + {sentinel,[]}, + {servers,[{"wrong",6379},{"wrong",6380},{"wrong",6381}]}]}])). + + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +reload(Config) when is_list(Config) -> + application:stop(?APP), + [application:set_env(?APP, K, V) || {K, V} <- Config], + application:start(?APP). + +q(Cmd) -> + {ok, Server} = application:get_env(?APP, server), + case proplists:get_value(type, Server) of + cluster -> + eredis_cluster:q(emqx_auth_redis, Cmd); + _ -> + {ok, Connection} = ?POOL(?APP), + eredis:q(Connection, Cmd) + end. \ No newline at end of file diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.crt b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.crt new file mode 100644 index 000000000..b46bef4e5 --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE5jCCAs4CCQCc1DzEYETfKTANBgkqhkiG9w0BAQsFADA1MRMwEQYDVQQKDApS +ZWRpcyBUZXN0MR4wHAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjAx +MDI5MDEzNDE2WhcNMzAxMDI3MDEzNDE2WjA1MRMwEQYDVQQKDApSZWRpcyBUZXN0 +MR4wHAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQC/RxC/zQ6+ThI2l+LT5tpuvljE7CPca5erahTjv1Pq +mbmHYIVlige9jvZKR/AaaHuhNRT6C4PDpD98TgrhSLSgMMFImoFMSnmFEOVave3O +y1qV9vtoHLMB9hO+t7P98KRi1sCoMdPIE/o5uEGSd4YgWbk3NllAV6me108UniWU +yZMCSEKmV9OpfQ+YfHFolESV92ajdViDbtRBjfDNwD7qb8zgigxIJvBzEnWF4RZl +4+KIiyoJ55AQ3omdEi0QwiRRRONFtB6kRSqjGS8genGnycX1ZNPRB8JeG3ESuFj9 +1WQUD0EMBXFB5agHoZjvtFwxOkUkA4XbcnpKddHGKRt4BAbm+YcizJaT7mRytGWZ +UoTrDWz8/Cc0BlwAfPEk6ogU/sLSZpdxjxwprCNB89UOI+q7ng7CYiFnxY9HHZeg +GCJxYfvpKM/eOT9mSLUug8EGITd0j2cusflO4Q243clPyRbTSSr39Pcpy8rfKApF +HkUuGIpa/qgAbez+lPlIydzpbrTgrnHvL1P6fCYTnHkcgSn8glBIKv3vh4zQd6df +JvcLv3WEka9+lyoCvJ0QH+/ITqrToyWa8g9fR3ajTlyMANesKxQejo80zCwk/0ns +SFKRIJc6vfnUJ12Vdxpmm1LeoJZnCYODNUeeksL1ahHCBGq4M8UJ+ycUM6N4ndWE +6QIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQAg0BaIi7lzrNb7xC42c+GJVrBq8Qf8 +7CBzP8SXYGUavQIYNRrtH8UgTOwaju9vOn3zoY8L59N6e+Icyt+Oh1FENcQMCZ2l +SP79iaY9A/dRV56p6NqNd3VWH+EuRGbQVatLdhJf3l5+W1z3Dum1YXmIn26acawF +GZVqLalgvLqPzPHHWEqz9RnmcvTu3w9YVb4NgbmY4byCb6mB2avt0iWQrY/fZSMe +FvRXurr0jwyIXBncqnXu97sCeccNc+fo3qZC1xxH9iXOIzrRg0ud7VGMTKcNLTTc +GqnbjNT8BC96Qp2Bs8J+JGZa3mT/usKBq2TT/3q6oKevuc23u/a5s1rztnqZgIe5 +RzfevJ79xdva6DMSq/8Yyd3I8hrs3oZKJbAce6ux01RsrCcY2O7gi4dAMoEGumxW +CS9XLchNy7QxQ+J2AKBZXd6AZjvTvloDGz/yC5EbdK/MnLz8oApK5Z8U/huEilFa +AymVWQWpmlX2KxW0nkCperlb7lcbPS+ZuH0+Zd9HOvqr9cpYMrwpF54q4vnzUQkR +Hsxoapv/FBsVoxtcOqrcxwGpYWCsV0VBnv9+1fzzZ83aK7CHDIeGVuKPyjkhHzLy +v7Ljuqg400wH0WB9pyEdK+O3F+xO3zJgf4o0JptOKOFBVVSkZWTrqlDjjbcnXBmh +dwgj2xYeigqHJA== +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.key b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.key new file mode 100644 index 000000000..b615a8c1e --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEAv0cQv80Ovk4SNpfi0+babr5YxOwj3GuXq2oU479T6pm5h2CF +ZYoHvY72SkfwGmh7oTUU+guDw6Q/fE4K4Ui0oDDBSJqBTEp5hRDlWr3tzstalfb7 +aByzAfYTvrez/fCkYtbAqDHTyBP6ObhBkneGIFm5NzZZQFepntdPFJ4llMmTAkhC +plfTqX0PmHxxaJRElfdmo3VYg27UQY3wzcA+6m/M4IoMSCbwcxJ1heEWZePiiIsq +CeeQEN6JnRItEMIkUUTjRbQepEUqoxkvIHpxp8nF9WTT0QfCXhtxErhY/dVkFA9B +DAVxQeWoB6GY77RcMTpFJAOF23J6SnXRxikbeAQG5vmHIsyWk+5kcrRlmVKE6w1s +/PwnNAZcAHzxJOqIFP7C0maXcY8cKawjQfPVDiPqu54OwmIhZ8WPRx2XoBgicWH7 +6SjP3jk/Zki1LoPBBiE3dI9nLrH5TuENuN3JT8kW00kq9/T3KcvK3ygKRR5FLhiK +Wv6oAG3s/pT5SMnc6W604K5x7y9T+nwmE5x5HIEp/IJQSCr974eM0HenXyb3C791 +hJGvfpcqArydEB/vyE6q06MlmvIPX0d2o05cjADXrCsUHo6PNMwsJP9J7EhSkSCX +Or351CddlXcaZptS3qCWZwmDgzVHnpLC9WoRwgRquDPFCfsnFDOjeJ3VhOkCAwEA +AQKCAgBF1jSPUtcnNGoB9MKki40FEgpnG7CcMcxWkYy++oQxC59phhwuTo807pWN +2WYYvj0lRrQ59ypMrBNh1zyxtFH+is6HK6I5sJddtiWHVAEXl7ejOWHhSVkyRh4/ +a+MTvGDIlZAR2N9yFZkuqc+HIoyeEyREvFsp2tfbXtFIvdUK1e4Oz0NGaJqnLzoa +epUNkdTYzFN1Ksr+ceCdbq2U8bQG9HrhIIYLcewol3zBPMVoviNfpy/aHenDvvyP +lKtPixKneXdhY7osT/SZSACk4w/MKydTyVRs5WBZ67sFErmrM9YuXMNrGDGZ1bfb +0Wx9WGSwtI258G9XCB0OQqYsq6WTMaEei+z8l0iarZi1l2bz2F89J+IBYM8RqSsa +E30F8AtEG32QJUfK3F6k6N4uLx6JZduJgLyzsSh6q51ghAJ8kD1vUkEeXffCzynp +hzwRHUw5O1jNLEBdKYHpSyszlFX6qbzR1YXypzZs/aehZi5d89eBKN8X/Fnbi9a2 +Q0MqpZ5J/1hH7zadJFibNyuOCP4CNO3Hm18PjyEFRrCMbSF293kY9GoiOlQiwNAT +MqrsyLYgHPCXKXpG/R60lyHEfWKO9sOjyh+mSbv3QfNZS32Fweuo0R/vGYmkmtGn +wpn2IeSmX8ychdQrSemJjwzjUl/EUN0lGRAlHEt4ZDf52vHZ4QKCAQEA9k7b2vpU +g3S3GRCMzhl8GKZloNSbnR/ZHE8b9PVNahp0bQcZj+1yimF35p27VZR662ZBoKLy +/MLyPT+ZyynykwypcTVA5U9CABpSlyZMeezLnFlBXeHHMeoXcBZfFqHeXSsNYbhW +OStf4BGwKf7m/V0P/QL9mNsA/iq2uugC1gHoyp422YUIQQvKkBiFyMl34Zp2URsX +yIwb9aVyg2GogKDtbDPIwW89l3BCBiwalvjR6UotbXh3PQgYbsv62rTJ8AN+E+XH +eSQPnmPR6EXtX2nDuov996qlbja+JQE3SAls4EXLbrLyjSlObjcvkA59r+kZgNIY +g88hv7e9ublfqwKCAQEAxs3d9Zmjh9ByCT6jOUVnRMqw0+lVyVOs0kp7nOCb4HKM +CnupZuJQHQVQt7VhgD7FrALmYwkpt2e/WllN9bPFHRJcsw+SylOqyPil9G4DO5XZ +YPvk6PeQ/c0cbREKhsYNXqj5fWdq5pRd8rE72rK82mhdGQtAAN7NOEW5fo5tqHDK +D079SZmpcgd8Wz9luNpnZRpNhO3ccKV5yf0S1LZOZBbG9t875OVNhxlQY5wwIBXv +8ab13zcFKG21tWvLzz80vgkMIp0A9xh0XznIRnH3NnBZB80Yubg4sIaWvX7bqZ4X +EE9HGeiamw6c6Sm/Lvh/H659ri95l7C9TgAfAA7puwKCAQEAgJ/N0BzJ5ZwdwckS +vs4wL+81QzfDy9nF1zK4tsMjGjWWdxkuECs/lWQw6Q2VtqtDRYqw2uI9YiGrvrBn +7+CH/KKwGZ5ltVoebU9Rsf0eEs3FxnAV4qD1FOvaMX59SaReKulAo7dPz6sG9kxG +YqfqmITwxH+7TwePDSvhINnoITn+B1F38z+1f8JYlcc4lhIfuIChKNmtId2I/E7Z +7iIhjIp9cfPY8qrUzzCgSfjeKdjmRZ2m+3PdUNHZcIK1DWE700r/nARylqBuR5h5 +FYLu4tSokdJpXdyPZ27O/SQValkBslzAT57Da1QW0RegjuoCWMqxtsQAaVTRmvyo +50QW4QKCAQBLJFbn1MmFtRjVO7KwG/Z7fu01O7WsIg9pcLOmSRNB06nw8GrIM3Q6 +c97dgRY4RgGrEXGJL1ZwNyuRd73Kx8cSRPV6zMEb7mHYEnuPluFr7Si7ypnsIF7S +P2umIdHLvSIijFW4u5UhUCTubWUFNZfCKb4+kA0CBzSkN150Yls6Vl9ZR+7emdD9 +A61SQ/Ur2IlKIpX4T3uJrFILMbejZMDefel4OEpIKw+Rp9TFwaxDBGer/AJk+0Pc +0xLiXrsrO2WxCnRmxNcvjjO2Jn33em4JSo+sLi5RTDtJJaXmPAPE6bcn9/8U4OFH +CE/wpVHY7B4ImIhyhQk9d5Ul3U/aUsivAoIBAG8zk3CnFAnii1ENxhPtE8GCinvs +NdsluVtvUgMcA8gNzvqLHLQCoIy/b1wkqxPVsdTq1gZ7+FX9D9LzW9JxrWeEZqVV +jrUQIbls6HZei7i5x0tPwh1shOZiijgY24I6HDX9QRKZ7H7lLw0HfJI9YBI1Hl8E +naOtCuzFiaYEPfbGQACL8/UuoOZaD31JQda2EGYysGRxxJ2ZNPJIphCrwRb/nQBG +7WwCSCzu0peFNhZPVvkWHaFN73Uv/MmgFkp8RZzw9TEENB05wluCZB1TYJAOe65n +HnRWSDvWYR4lzMtq5WASFLC0WrFTiJKRCuKPljjoTptbXsJKyskW/t/+XPM= +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.txt b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.txt new file mode 100644 index 000000000..cf4e2aba5 --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/ca.txt @@ -0,0 +1 @@ +BFFAA2A065DFA6FC diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.crt b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.crt new file mode 100644 index 000000000..5eefadf62 --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1zCCAb8CCQC/+qKgZd+m/DANBgkqhkiG9w0BAQsFADA1MRMwEQYDVQQKDApS +ZWRpcyBUZXN0MR4wHAYDVQQDDBVDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjAx +MDI5MDEzNDE2WhcNMjExMDI5MDEzNDE2WjAmMRMwEQYDVQQKDApSZWRpcyBUZXN0 +MQ8wDQYDVQQDDAZTZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDSs3bQ9sYi2AhFuHU75Ryk1HHSgfzA6pQAJilmJdTy0s5vyiWe1HQJaWkMcS5V +GVzGMK+c+OBqtXtDDninL3betg1YPMjSCOjPMOTC1H9K7+effwf7Iwpnw9Zro8mb +TEmMslIYhhcDedzT9Owli4QAgbgTn4l1BYuKX9CLrrKFtnr21miKu3ydViy9q7T1 +pib3eigvAyk7X2fadHFArGEttsXrD6cetPPkSF/1OLWNlqzUKXzhSyrBXzO44Kks +fwR/EpTiES9g4dNOL2wvKS/YE1fNKhiCENrNxTXQo1l0yOdm2+MeyOeHFzRuS0b/ ++uGDFOPPi04KXeO6dQ5olBCPAgMBAAEwDQYJKoZIhvcNAQELBQADggIBADn0E2vG +iQWe8/I7VbBdPhPNupVNcLvew10eIHxY2g5vSruCSVRQTgk8itVMRmDQxbb7gdDW +jnCRbxykxbLjM9iCRljnOCsIcTi7qO7JRl8niV8dtEpPOs9lZxEdNXjIV1iZoWf3 +arBbPQSyQZvTQHG6qbFnyCdMMyyXGGvEPGQDaBiKH+Ko1qeAbCi0zupChYvxmtZ8 +hSTPlMFezDT9bKoNY0pkJSELfokEPU/Pn6Lz/NVbdzmCMjVa/xmF3s31g+DGhz95 +4AyOnCr6o0aydPVVV3pB/BCezNXPUxpp53BG0w/K2f2DnKYCvGvJbqDAaJ8bG/J1 +EFSOmwobdwVxJz3KNubmo1qJ6xOl/YT7yyqPRQRM1SY8nZW+YcoJSZjOe8wJVlob +d0bOwN1C3HQwomyMWes187bEQP6Y36HuEbR1fK8yIOzGsGDKRFAFwQwMgw2M91lr +EJIP5NRD3OZRuiYDiVfVhDZDaNahrAMZUcPCgeCAwc4YG6Gp2sDtdorOl4kIJYWE +BbBZ0Jplq9+g6ciu5ChjAW8iFl0Ae5U24MxPGXnrxiRF4WWxLeZMVLXLDvlPqReD +CHII5ifyvGEt5+RhqtZC/L+HimL+5wQgOlntqhUdLb6yWRz7YW37PFMnUXU3MXe9 +uY7m73ZLluXiLojcZxU2+cx89u5FOJxrYtrj +-----END CERTIFICATE----- diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.dh b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.dh new file mode 100644 index 000000000..f7dd0569d --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.dh @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEAo2dgOzTnLK7c8AjkiTXxdmo2MJsyzTlNXUDxLfl2hgwic6benyQ3 +9iL95wKjYg2YpMhzbwux50D+9XeVkRatf1pRi/N9H911f90MO6penzUx/dxfOepN +qoGK/T9xO8e6aFCYOoQjJaZzQYC0HixJVadZd7wRlHkZ3siNKUU5QK68KaN3JE3J +R3yZ9A7MU/TVdwZyVIyoWF2+WJMQW+qaezoqiuVKZXXzzoqbj14ZrtPRmO26vMV/ +bmMuHwPsk9dL7tKnTWEOrs6NVHIQW+RxJuRE9wGa0qqzHAzysEQ8q9QYPRvGo5y+ +XRWosl1bHG4+EmvXsCCs35bcbKToi3NFWwIBAg== +-----END DH PARAMETERS----- diff --git a/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.key b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.key new file mode 100644 index 000000000..b76303f14 --- /dev/null +++ b/apps/emqx_auth_redis/test/emqx_auth_redis_SUITE_data/certs/redis.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEA0rN20PbGItgIRbh1O+UcpNRx0oH8wOqUACYpZiXU8tLOb8ol +ntR0CWlpDHEuVRlcxjCvnPjgarV7Qw54py923rYNWDzI0gjozzDkwtR/Su/nn38H ++yMKZ8PWa6PJm0xJjLJSGIYXA3nc0/TsJYuEAIG4E5+JdQWLil/Qi66yhbZ69tZo +irt8nVYsvau09aYm93ooLwMpO19n2nRxQKxhLbbF6w+nHrTz5Ehf9Ti1jZas1Cl8 +4UsqwV8zuOCpLH8EfxKU4hEvYOHTTi9sLykv2BNXzSoYghDazcU10KNZdMjnZtvj +Hsjnhxc0bktG//rhgxTjz4tOCl3junUOaJQQjwIDAQABAoIBAQCP7CJ27nm9B0/v +P+ZkeUWtmaf+IOhjZlieGXMh4SmqjDCSz8QO0BRK8YPeCdmaK27huhPa521ztm9y +CIqFuLg7vKM06KBMR+Wu0TkRlFE3ANR4cC8lbnQHGRB4CjMGL3/16UCGm+FQcIdV +CPHdW4VZS0JPtSQRmS4N4RD0uOocxqGcVbCRqnJoNp1zyXhookgHfZsC3b3cgzC7 +qvI9F1oY4Yg4b9Lw5sNi3JXWtFth8JFOPyImRcE0ngcGZK4iWjiufNKWVeTmSmVy +njMZfj8xKSpfqO3sOTbJMdrH1v5pMrAR/Ed748HheXuL15Ur9n88683hMMATZInn +YzIqNSrBAoGBAO94YBB1hN+jSKw+2FbAhuuM0gWHREmLQuaF2vjeVXL3r6YofFmf ++oJNgoOWXsv4KO2MgKDv4qrz7RohhhQpOFm5PpapSH/di7u6KsbJLYSxv/TEqQFE +NPyGywwNDIkn1wPlnX3LXp26puj2Gtn21Z0trUrpgsDM99BaTBbqTR2xAoGBAOE+ +tw0GHD/6CRPfoBIgVilS/sUJ5VJYTTKo/y6ozovCAq4bt5LkYmAOy6q8paHb58Oc +J890+LEPhelM/ZJDDz9oQFfq5LvuzgNfzDRyIhgDSpghtFrdDxQZP1X1lSdh+MFW +gx0k9h8VuIPksBsIgcmUtyCYitxLFep/0tAA/GI/AoGBAMxexEVntjWSScROgh1P +hBXlAZycO4g0ZK0OEboRLYXHos1AghePM6Ee+0LIAzE6IdvR7DjtYVoagQCrGZ19 +LE1Ojf7QjEIr1kQpdrZeHQ3BERyY9c9R4ZKeiw1G2ar4KEV4Ifeop6AfGrF4z6Oz +R80znVBwhxl6FAhp98QaxCORAoGBAInkc/nEKN09u/rvpzYRl83aol+MDFjZ+ACw +lvBApZnHnw5pp3uE13jI9gRDUv8A+iS1X2XQzULQJwHJgV7eMOJ3dxSbl4Y5zuMf +7YqZ6KdctHjoAVqzBD0gq7Z7DuG6R6hMxx27d/VVvcz43preHV6D7YxF9pSgXv1d +XXi7ccbPAoGBAIeLzCYd+JGufHwbq7oNvSyXJjGMjsAQuErUQ0xXwo7VAyOere2P +Dwk67wq6vsmn38EAs7IkXDgIoTD9z69DNtcjr/3fARYfmDSWyHscRwyUaJ15WQcZ +TCXAPf70Vf0KGBpRkgD+Qnq+lMZ3dr1uINGdalI4AWsXje0dPKpd+W8U +-----END RSA PRIVATE KEY----- diff --git a/apps/emqx_bridge_mqtt/.gitignore b/apps/emqx_bridge_mqtt/.gitignore new file mode 100644 index 000000000..bf9523be5 --- /dev/null +++ b/apps/emqx_bridge_mqtt/.gitignore @@ -0,0 +1,21 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin/*.beam +rel +_build +.concrete/DEV_MODE +.rebar +.erlang.mk +data +ebin +emqx_bridge_mqtt.d +*.rendered +.rebar3/ +*.coverdata +rebar.lock +.DS_Store +Mnesia.*/ \ No newline at end of file diff --git a/apps/emqx_bridge_mqtt/README.md b/apps/emqx_bridge_mqtt/README.md new file mode 100644 index 000000000..6656aa36f --- /dev/null +++ b/apps/emqx_bridge_mqtt/README.md @@ -0,0 +1,265 @@ +# EMQ Bridge MQTT + +The concept of **Bridge** means that EMQ X supports forwarding messages +of one of its own topics to another MQTT Broker in some way. + +**Bridge** differs from **Cluster** in that the bridge does not +replicate the topic trie and routing tables and only forwards MQTT +messages based on bridging rules. + +At present, the bridging methods supported by EMQ X are as follows: + +- RPC bridge: RPC Bridge only supports message forwarding and does not + support subscribing to the topic of remote nodes to synchronize + data; +- MQTT Bridge: MQTT Bridge supports both forwarding and data + synchronization through subscription topic. + +These concepts are shown below: + +![bridge](docs/images/bridge.png) + +In addition, the EMQ X message broker supports multi-node bridge mode interconnection + +``` + --------- --------- --------- +Publisher --> | Node1 | --Bridge Forward--> | Node2 | --Bridge Forward--> | Node3 | --> Subscriber + --------- --------- --------- +``` + +In EMQ X, bridge is configured by modifying `etc/emqx.conf`. EMQ X distinguishes between different bridges based on different names. E.g + +``` +## Bridge address: node name for local bridge, host:port for remote. +bridge.mqtt.aws.address = 127.0.0.1:1883 +``` + +This configuration declares a bridge named `aws` and specifies that it is bridged to the MQTT broker of 127.0.0.1:1883 by MQTT mode. + +In case of creating multiple bridges, it is convenient to replicate all configuration items of the first bridge, and modify the bridge name and other configuration items if necessary (such as bridge.$name.address, where $name refers to the name of bridge) + +The next two sections describe how to create a bridge in RPC and MQTT mode respectively and create a forwarding rule that forwards the messages from sensors. Assuming that two EMQ X nodes are running on two hosts: + + +| Name | Node | MQTT Port | +|------|-------------------|-----------| +| emqx1| emqx1@192.168.1.1.| 1883 | +| emqx2| emqx2@192.168.1.2 | 1883 | + + +## EMQ X RPC Bridge Configuration + +The following is the basic configuration of RPC bridging. A simplest RPC bridging only requires the following three items + +``` +## Bridge Address: Use node name (nodename@host) for rpc bridging, and host:port for mqtt connection +bridge.mqtt.emqx2.address = emqx2@192.168.1.2 + +## Forwarding topics of the message +bridge.mqtt.emqx2.forwards = sensor1/#,sensor2/# + +## bridged mountpoint +bridge.mqtt.emqx2.mountpoint = bridge/emqx2/${node}/ +``` + +If the messages received by the local node emqx1 matches the topic `sersor1/#` or `sensor2/#`, these messages will be forwarded to the `sensor1/#` or `sensor2/#` topic of the remote node emqx2. + +`forwards` is used to specify topics. Messages of the in `forwards` specified topics on local node are forwarded to the remote node. + +`mountpoint` is used to add a topic prefix when forwarding a message. To use `mountpoint`, the `forwards` directive must be set. In the above example, a message with the topic `sensor1/hello` received by the local node will be forwarded to the remote node with the topic `bridge/emqx2/emqx1@192.168.1.1/sensor1/hello`. + +Limitations of RPC bridging: + +1. The RPC bridge of emqx can only forward local messages to the remote node, and cannot synchronize the messages of the remote node to the local node; + +2. RPC bridge can only bridge two EMQ X broker together and cannot bridge EMQ X broker to other MQTT brokers. + +## EMQ X MQTT Bridge Configuration + +EMQ X 3.0 officially introduced MQTT bridge, so that EMQ X can bridge any MQTT broker. Because of the characteristics of the MQTT protocol, EMQ X can subscribe to the remote mqtt broker's topic through MQTT bridge, and then synchronize the remote MQTT broker's message to the local. + +EMQ X MQTT bridging principle: Create an MQTT client on the EMQ X broker, and connect this MQTT client to the remote MQTT broker. Therefore, in the MQTT bridge configuration, following fields may be set for the EMQ X to connect to the remote broker as an mqtt client + +``` +## Bridge Address: Use node name for rpc bridging, use host:port for mqtt connection +bridge.mqtt.emqx2.address = 192.168.1.2:1883 + +## Bridged Protocol Version +## Enumeration value: mqttv3 | mqttv4 | mqttv5 +bridge.mqtt.emqx2.proto_ver = mqttv4 + +## mqtt client's clientid +bridge.mqtt.emqx2.clientid = bridge_emq + +## mqtt client's clean_start field +## Note: Some MQTT Brokers need to set the clean_start value as `true` +bridge.mqtt.emqx2.clean_start = true + +## mqtt client's username field +bridge.mqtt.emqx2.username = user + +## mqtt client's password field +bridge.mqtt.emqx2.password = passwd + +## Whether the mqtt client uses ssl to connect to a remote serve or not +bridge.mqtt.emqx2.ssl = off + +## CA Certificate of Client SSL Connection (PEM format) +bridge.mqtt.emqx2.cacertfile = etc/certs/cacert.pem + +## SSL certificate of Client SSL connection +bridge.mqtt.emqx2.certfile = etc/certs/client-cert.pem + +## Key file of Client SSL connection +bridge.mqtt.emqx2.keyfile = etc/certs/client-key.pem + +## SSL encryption +bridge.mqtt.emqx2.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384 + +## TTLS PSK password +## Note 'listener.ssl.external.ciphers' and 'listener.ssl.external.psk_ciphers' cannot be configured at the same time +## +## See 'https://tools.ietf.org/html/rfc4279#section-2'. +## bridge.mqtt.emqx2.psk_ciphers = PSK-AES128-CBC-SHA,PSK-AES256-CBC-SHA,PSK-3DES-EDE-CBC-SHA,PSK-RC4-SHA + +## Client's heartbeat interval +bridge.mqtt.emqx2.keepalive = 60s + +## Supported TLS version +bridge.mqtt.emqx2.tls_versions = tlsv1.2,tlsv1.1,tlsv1 + +## Forwarding topics of the message +bridge.mqtt.emqx2.forwards = sensor1/#,sensor2/# + +## Bridged mountpoint +bridge.mqtt.emqx2.mountpoint = bridge/emqx2/${node}/ + +## Subscription topic for bridging +bridge.mqtt.emqx2.subscription.1.topic = cmd/topic1 + +## Subscription qos for bridging +bridge.mqtt.emqx2.subscription.1.qos = 1 + +## Subscription topic for bridging +bridge.mqtt.emqx2.subscription.2.topic = cmd/topic2 + +## Subscription qos for bridging +bridge.mqtt.emqx2.subscription.2.qos = 1 + +## Bridging reconnection interval +## Default: 30s +bridge.mqtt.emqx2.reconnect_interval = 30s + +## QoS1 message retransmission interval +bridge.mqtt.emqx2.retry_interval = 20s + +## Inflight Size. +bridge.mqtt.emqx2.max_inflight_batches = 32 +``` + +## Bridge Cache Configuration + +The bridge of EMQ X has a message caching mechanism. The caching mechanism is applicable to both RPC bridging and MQTT bridging. When the bridge is disconnected (such as when the network connection is unstable), the messages with a topic specified in `forwards` can be cached to the local message queue. Until the bridge is restored, these messages are re-forwarded to the remote node. The configuration of the cache queue is as follows + +``` +## emqx_bridge internal number of messages used for batch +bridge.mqtt.emqx2.queue.batch_count_limit = 32 + +## emqx_bridge internal number of message bytes used for batch +bridge.mqtt.emqx2.queue.batch_bytes_limit = 1000MB + +## The path for placing replayq queue. If it is not specified, then replayq will run in `mem-only` mode and messages will not be cached on disk. +bridge.mqtt.emqx2.queue.replayq_dir = data/emqx_emqx2_bridge/ + +## Replayq data segment size +bridge.mqtt.emqx2.queue.replayq_seg_bytes = 10MB +``` + +`bridge.mqtt.emqx2.queue.replayq_dir` is a configuration parameter for specifying the path of the bridge storage queue. + +`bridge.mqtt.emqx2.queue.replayq_seg_bytes` is used to specify the size of the largest single file of the message queue that is cached on disk. If the message queue size exceeds the specified value, a new file is created to store the message queue. + +## CLI for EMQ X Bridge MQTT + +CLI for EMQ X Bridge MQTT: + +``` bash +$ cd emqx1/ && ./bin/emqx_ctl bridges +bridges list # List bridges +bridges start # Start a bridge +bridges stop # Stop a bridge +bridges forwards # Show a bridge forward topic +bridges add-forward # Add bridge forward topic +bridges del-forward # Delete bridge forward topic +bridges subscriptions # Show a bridge subscriptions topic +bridges add-subscription # Add bridge subscriptions topic +``` + +List all bridge states + +``` bash +$ ./bin/emqx_ctl bridges list +name: emqx status: Stopped $ ./bin/emqx_ctl bridges list +name: emqx status: Stopped +``` + +Start the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges start emqx +Start bridge successfully. +``` + +Stop the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges stop emqx +Stop bridge successfully. +``` +List the forwarding topics for the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges forwards emqx +topic: topic1/# +topic: topic2/# +``` + +Add a forwarding topic for the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges add-forwards emqx topic3/# +Add-forward topic successfully. +``` + +Delete the forwarding topic for the specified bridge + + +``` bash +$ ./bin/emqx_ctl bridges del-forwards emqx topic3/# +Del-forward topic successfully. +``` + +List subscriptions for the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges subscriptions emqx +topic: cmd/topic1, qos: 1 +topic: cmd/topic2, qos: 1 +``` + +Add a subscription topic for the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges add-subscription emqx cmd/topic3 1 +Add-subscription topic successfully. +``` + +Delete the subscription topic for the specified bridge + +``` bash +$ ./bin/emqx_ctl bridges del-subscription emqx cmd/topic3 +Del-subscription topic successfully. +``` + +Note: In case of creating multiple bridges, it is convenient to replicate all configuration items of the first bridge, and modify the bridge name and other configuration items if necessary. + diff --git a/apps/emqx_bridge_mqtt/docs/guide.rst b/apps/emqx_bridge_mqtt/docs/guide.rst new file mode 100644 index 000000000..73350ca1f --- /dev/null +++ b/apps/emqx_bridge_mqtt/docs/guide.rst @@ -0,0 +1,286 @@ + +EMQ Bridge MQTT +=============== + +The concept of **Bridge** means that EMQ X supports forwarding messages +of one of its own topics to another MQTT Broker in some way. + +**Bridge** differs from **Cluster** in that the bridge does not +replicate the topic trie and routing tables and only forwards MQTT +messages based on bridging rules. + +At present, the bridging methods supported by EMQ X are as follows: + + +* RPC bridge: RPC Bridge only supports message forwarding and does not + support subscribing to the topic of remote nodes to synchronize + data; +* MQTT Bridge: MQTT Bridge supports both forwarding and data + synchronization through subscription topic. + +These concepts are shown below: + + +.. image:: images/bridge.png + :target: images/bridge.png + :alt: bridge + + +In addition, the EMQ X message broker supports multi-node bridge mode interconnection + +.. code-block:: + + --------- --------- --------- + Publisher --> | Node1 | --Bridge Forward--> | Node2 | --Bridge Forward--> | Node3 | --> Subscriber + --------- --------- --------- + +In EMQ X, bridge is configured by modifying ``etc/emqx.conf``. EMQ X distinguishes between different bridges based on different names. E.g + +.. code-block:: + + ## Bridge address: node name for local bridge, host:port for remote. + bridge.mqtt.aws.address = 127.0.0.1:1883 + +This configuration declares a bridge named ``aws`` and specifies that it is bridged to the MQTT broker of 127.0.0.1:1883 by MQTT mode. + +In case of creating multiple bridges, it is convenient to replicate all configuration items of the first bridge, and modify the bridge name and other configuration items if necessary (such as bridge.$name.address, where $name refers to the name of bridge) + +The next two sections describe how to create a bridge in RPC and MQTT mode respectively and create a forwarding rule that forwards the messages from sensors. Assuming that two EMQ X nodes are running on two hosts: + +.. list-table:: + :header-rows: 1 + + * - Name + - Node + - MQTT Port + * - emqx1 + - emqx1@192.168.1.1. + - 1883 + * - emqx2 + - emqx2@192.168.1.2 + - 1883 + + +EMQ X RPC Bridge Configuration +------------------------------ + +The following is the basic configuration of RPC bridging. A simplest RPC bridging only requires the following three items + +.. code-block:: + + ## Bridge Address: Use node name (nodename@host) for rpc bridging, and host:port for mqtt connection + bridge.mqtt.emqx2.address = emqx2@192.168.1.2 + + ## Forwarding topics of the message + bridge.mqtt.emqx2.forwards = sensor1/#,sensor2/# + + ## bridged mountpoint + bridge.mqtt.emqx2.mountpoint = bridge/emqx2/${node}/ + +If the messages received by the local node emqx1 matches the topic ``sersor1/#`` or ``sensor2/#``\ , these messages will be forwarded to the ``sensor1/#`` or ``sensor2/#`` topic of the remote node emqx2. + +``forwards`` is used to specify topics. Messages of the in ``forwards`` specified topics on local node are forwarded to the remote node. + +``mountpoint`` is used to add a topic prefix when forwarding a message. To use ``mountpoint``\ , the ``forwards`` directive must be set. In the above example, a message with the topic ``sensor1/hello`` received by the local node will be forwarded to the remote node with the topic ``bridge/emqx2/emqx1@192.168.1.1/sensor1/hello``. + +Limitations of RPC bridging: + + +#. + The RPC bridge of emqx can only forward local messages to the remote node, and cannot synchronize the messages of the remote node to the local node; + +#. + RPC bridge can only bridge two EMQ X broker together and cannot bridge EMQ X broker to other MQTT brokers. + +EMQ X MQTT Bridge Configuration +------------------------------- + +EMQ X 3.0 officially introduced MQTT bridge, so that EMQ X can bridge any MQTT broker. Because of the characteristics of the MQTT protocol, EMQ X can subscribe to the remote mqtt broker's topic through MQTT bridge, and then synchronize the remote MQTT broker's message to the local. + +EMQ X MQTT bridging principle: Create an MQTT client on the EMQ X broker, and connect this MQTT client to the remote MQTT broker. Therefore, in the MQTT bridge configuration, following fields may be set for the EMQ X to connect to the remote broker as an mqtt client + +.. code-block:: + + ## Bridge Address: Use node name for rpc bridging, use host:port for mqtt connection + bridge.mqtt.emqx2.address = 192.168.1.2:1883 + + ## Bridged Protocol Version + ## Enumeration value: mqttv3 | mqttv4 | mqttv5 + bridge.mqtt.emqx2.proto_ver = mqttv4 + + ## mqtt client's clientid + bridge.mqtt.emqx2.clientid = bridge_emq + + ## mqtt client's clean_start field + ## Note: Some MQTT Brokers need to set the clean_start value as `true` + bridge.mqtt.emqx2.clean_start = true + + ## mqtt client's username field + bridge.mqtt.emqx2.username = user + + ## mqtt client's password field + bridge.mqtt.emqx2.password = passwd + + ## Whether the mqtt client uses ssl to connect to a remote serve or not + bridge.mqtt.emqx2.ssl = off + + ## CA Certificate of Client SSL Connection (PEM format) + bridge.mqtt.emqx2.cacertfile = etc/certs/cacert.pem + + ## SSL certificate of Client SSL connection + bridge.mqtt.emqx2.certfile = etc/certs/client-cert.pem + + ## Key file of Client SSL connection + bridge.mqtt.emqx2.keyfile = etc/certs/client-key.pem + + ## SSL encryption + bridge.mqtt.emqx2.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384 + + ## TTLS PSK password + ## Note 'listener.ssl.external.ciphers' and 'listener.ssl.external.psk_ciphers' cannot be configured at the same time + ## + ## See 'https://tools.ietf.org/html/rfc4279#section-2'. + ## bridge.mqtt.emqx2.psk_ciphers = PSK-AES128-CBC-SHA,PSK-AES256-CBC-SHA,PSK-3DES-EDE-CBC-SHA,PSK-RC4-SHA + + ## Client's heartbeat interval + bridge.mqtt.emqx2.keepalive = 60s + + ## Supported TLS version + bridge.mqtt.emqx2.tls_versions = tlsv1.2,tlsv1.1,tlsv1 + + ## Forwarding topics of the message + bridge.mqtt.emqx2.forwards = sensor1/#,sensor2/# + + ## Bridged mountpoint + bridge.mqtt.emqx2.mountpoint = bridge/emqx2/${node}/ + + ## Subscription topic for bridging + bridge.mqtt.emqx2.subscription.1.topic = cmd/topic1 + + ## Subscription qos for bridging + bridge.mqtt.emqx2.subscription.1.qos = 1 + + ## Subscription topic for bridging + bridge.mqtt.emqx2.subscription.2.topic = cmd/topic2 + + ## Subscription qos for bridging + bridge.mqtt.emqx2.subscription.2.qos = 1 + + ## Bridging reconnection interval + ## Default: 30s + bridge.mqtt.emqx2.reconnect_interval = 30s + + ## QoS1 message retransmission interval + bridge.mqtt.emqx2.retry_interval = 20s + + ## Inflight Size. + bridge.mqtt.emqx2.max_inflight_batches = 32 + +Bridge Cache Configuration +-------------------------- + +The bridge of EMQ X has a message caching mechanism. The caching mechanism is applicable to both RPC bridging and MQTT bridging. When the bridge is disconnected (such as when the network connection is unstable), the messages with a topic specified in ``forwards`` can be cached to the local message queue. Until the bridge is restored, these messages are re-forwarded to the remote node. The configuration of the cache queue is as follows + +.. code-block:: + + ## emqx_bridge internal number of messages used for batch + bridge.mqtt.emqx2.queue.batch_count_limit = 32 + + ## emqx_bridge internal number of message bytes used for batch + bridge.mqtt.emqx2.queue.batch_bytes_limit = 1000MB + + ## The path for placing replayq queue. If it is not specified, then replayq will run in `mem-only` mode and messages will not be cached on disk. + bridge.mqtt.emqx2.queue.replayq_dir = data/emqx_emqx2_bridge/ + + ## Replayq data segment size + bridge.mqtt.emqx2.queue.replayq_seg_bytes = 10MB + +``bridge.mqtt.emqx2.queue.replayq_dir`` is a configuration parameter for specifying the path of the bridge storage queue. + +``bridge.mqtt.emqx2.queue.replayq_seg_bytes`` is used to specify the size of the largest single file of the message queue that is cached on disk. If the message queue size exceeds the specified value, a new file is created to store the message queue. + +CLI for EMQ X Bridge MQTT +------------------------- + +CLI for EMQ X Bridge MQTT: + +.. code-block:: bash + + $ cd emqx1/ && ./bin/emqx_ctl bridges + bridges list # List bridges + bridges start # Start a bridge + bridges stop # Stop a bridge + bridges forwards # Show a bridge forward topic + bridges add-forward # Add bridge forward topic + bridges del-forward # Delete bridge forward topic + bridges subscriptions # Show a bridge subscriptions topic + bridges add-subscription # Add bridge subscriptions topic + +List all bridge states + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges list + name: emqx status: Stopped $ ./bin/emqx_ctl bridges list + name: emqx status: Stopped + +Start the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges start emqx + Start bridge successfully. + +Stop the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges stop emqx + Stop bridge successfully. + +List the forwarding topics for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges forwards emqx + topic: topic1/# + topic: topic2/# + +Add a forwarding topic for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges add-forwards emqx topic3/# + Add-forward topic successfully. + +Delete the forwarding topic for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges del-forwards emqx topic3/# + Del-forward topic successfully. + +List subscriptions for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges subscriptions emqx + topic: cmd/topic1, qos: 1 + topic: cmd/topic2, qos: 1 + +Add a subscription topic for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges add-subscription emqx cmd/topic3 1 + Add-subscription topic successfully. + +Delete the subscription topic for the specified bridge + +.. code-block:: bash + + $ ./bin/emqx_ctl bridges del-subscription emqx cmd/topic3 + Del-subscription topic successfully. + +Note: In case of creating multiple bridges, it is convenient to replicate all configuration items of the first bridge, and modify the bridge name and other configuration items if necessary. + diff --git a/apps/emqx_bridge_mqtt/docs/images/bridge.png b/apps/emqx_bridge_mqtt/docs/images/bridge.png new file mode 100644 index 000000000..9bb9c024c Binary files /dev/null and b/apps/emqx_bridge_mqtt/docs/images/bridge.png differ diff --git a/apps/emqx_bridge_mqtt/etc/emqx_bridge_mqtt.conf b/apps/emqx_bridge_mqtt/etc/emqx_bridge_mqtt.conf new file mode 100644 index 000000000..28d532adf --- /dev/null +++ b/apps/emqx_bridge_mqtt/etc/emqx_bridge_mqtt.conf @@ -0,0 +1,173 @@ +##==================================================================== +## Configuration for EMQ X MQTT Broker Bridge +##==================================================================== + +##-------------------------------------------------------------------- +## Bridges to aws +##-------------------------------------------------------------------- + +## Bridge address: node name for local bridge, host:port for remote. +## +## Value: String +## Example: emqx@127.0.0.1, 127.0.0.1:1883 +bridge.mqtt.aws.address = 127.0.0.1:1883 + +## Protocol version of the bridge. +## +## Value: Enum +## - mqttv5 +## - mqttv4 +## - mqttv3 +bridge.mqtt.aws.proto_ver = mqttv4 + +## Start type of the bridge. +## +## Value: enum +## manual +## auto +bridge.mqtt.aws.start_type = manual + +## Whether to enable bridge mode for mqtt bridge +## +## This option is prepared for the mqtt broker which does not +## support bridge_mode such as the mqtt-plugin of the rabbitmq +## +## Value: boolean +#bridge.mqtt.aws.bridge_mode = false + +## The ClientId of a remote bridge. +## +## Placeholders: +## ${node}: Node name +## +## Value: String +bridge.mqtt.aws.clientid = bridge_aws + +## The Clean start flag of a remote bridge. +## +## Value: boolean +## Default: true +## +## NOTE: Some IoT platforms require clean_start +## must be set to 'true' +bridge.mqtt.aws.clean_start = true + +## The username for a remote bridge. +## +## Value: String +bridge.mqtt.aws.username = user + +## The password for a remote bridge. +## +## Value: String +bridge.mqtt.aws.password = passwd + +## Topics that need to be forward to AWS IoTHUB +## +## Value: String +## Example: topic1/#,topic2/# +bridge.mqtt.aws.forwards = topic1/#,topic2/# + +## Forward messages to the mountpoint of an AWS IoTHUB +## +## Value: String +bridge.mqtt.aws.forward_mountpoint = bridge/aws/${node}/ + +## Need to subscribe to AWS topics +## +## Value: String +## bridge.mqtt.aws.subscription.1.topic = cmd/topic1 + +## Need to subscribe to AWS topics QoS. +## +## Value: Number +## bridge.mqtt.aws.subscription.1.qos = 1 + +## A mountpoint that receives messages from AWS IoTHUB +## +## Value: String +## bridge.mqtt.aws.receive_mountpoint = receive/aws/ + + +## Bribge to remote server via SSL. +## +## Value: on | off +bridge.mqtt.aws.ssl = off + +## PEM-encoded CA certificates of the bridge. +## +## Value: File +bridge.mqtt.aws.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem + +## Client SSL Certfile of the bridge. +## +## Value: File +bridge.mqtt.aws.certfile = {{ platform_etc_dir }}/certs/client-cert.pem + +## Client SSL Keyfile of the bridge. +## +## Value: File +bridge.mqtt.aws.keyfile = {{ platform_etc_dir }}/certs/client-key.pem + +## SSL Ciphers used by the bridge. +## +## Value: String +bridge.mqtt.aws.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA + +## Ciphers for TLS PSK. +## Note that 'bridge.${BridgeName}.ciphers' and 'bridge.${BridgeName}.psk_ciphers' cannot +## be configured at the same time. +## See 'https://tools.ietf.org/html/rfc4279#section-2'. +#bridge.mqtt.aws.psk_ciphers = PSK-AES128-CBC-SHA,PSK-AES256-CBC-SHA,PSK-3DES-EDE-CBC-SHA,PSK-RC4-SHA + +## Ping interval of a down bridge. +## +## Value: Duration +## Default: 10 seconds +bridge.mqtt.aws.keepalive = 60s + +## TLS versions used by the bridge. +## +## Value: String +bridge.mqtt.aws.tls_versions = tlsv1.2,tlsv1.1,tlsv1 + +## Bridge reconnect time. +## +## Value: Duration +## Default: 30 seconds +bridge.mqtt.aws.reconnect_interval = 30s + +## Retry interval for bridge QoS1 message delivering. +## +## Value: Duration +bridge.mqtt.aws.retry_interval = 20s + +## Publish messages in batches, only RPC Bridge supports +## +## Value: Integer +## default: 32 +bridge.mqtt.aws.batch_size = 32 + +## Inflight size. +## 0 means infinity (no limit on the inflight window) +## +## Value: Integer +bridge.mqtt.aws.max_inflight_size = 32 + +## Base directory for replayq to store messages on disk +## If this config entry is missing or set to undefined, +## replayq works in a mem-only manner. +## +## Value: String +bridge.mqtt.aws.queue.replayq_dir = {{ platform_data_dir }}/replayq/emqx_aws_bridge/ + +## Replayq segment size +## +## Value: Bytesize +bridge.mqtt.aws.queue.replayq_seg_bytes = 10MB + +## Replayq max total size +## +## Value: Bytesize +bridge.mqtt.aws.queue.max_total_size = 5GB + diff --git a/apps/emqx_bridge_mqtt/include/emqx_bridge_mqtt.hrl b/apps/emqx_bridge_mqtt/include/emqx_bridge_mqtt.hrl new file mode 100644 index 000000000..4bc9ede14 --- /dev/null +++ b/apps/emqx_bridge_mqtt/include/emqx_bridge_mqtt.hrl @@ -0,0 +1,18 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-define(APP, emqx_bridge_mqtt). + diff --git a/apps/emqx_bridge_mqtt/priv/emqx_bridge_mqtt.schema b/apps/emqx_bridge_mqtt/priv/emqx_bridge_mqtt.schema new file mode 100644 index 000000000..301737afd --- /dev/null +++ b/apps/emqx_bridge_mqtt/priv/emqx_bridge_mqtt.schema @@ -0,0 +1,244 @@ +%%-*- mode: erlang -*- +%%-------------------------------------------------------------------- +%% Bridges +%%-------------------------------------------------------------------- +{mapping, "bridge.mqtt.$name.address", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.proto_ver", "emqx_bridge_mqtt.bridges", [ + {datatype, {enum, [mqttv3, mqttv4, mqttv5]}} +]}. + +{mapping, "bridge.mqtt.$name.bridge_mode", "emqx_bridge_mqtt.bridges", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "bridge.mqtt.$name.start_type", "emqx_bridge_mqtt.bridges", [ + {datatype, {enum, [manual, auto]}}, + {default, auto} +]}. + +{mapping, "bridge.mqtt.$name.clientid", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.clean_start", "emqx_bridge_mqtt.bridges", [ + {default, true}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "bridge.mqtt.$name.username", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.password", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.forwards", "emqx_bridge_mqtt.bridges", [ + {datatype, string}, + {default, ""} +]}. + +{mapping, "bridge.mqtt.$name.forward_mountpoint", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.subscription.$id.topic", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.subscription.$id.qos", "emqx_bridge_mqtt.bridges", [ + {datatype, integer} +]}. + +{mapping, "bridge.mqtt.$name.receive_mountpoint", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.ssl", "emqx_bridge_mqtt.bridges", [ + {datatype, flag}, + {default, off} +]}. + +{mapping, "bridge.mqtt.$name.cacertfile", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.certfile", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.keyfile", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.ciphers", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.psk_ciphers", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.keepalive", "emqx_bridge_mqtt.bridges", [ + {default, "10s"}, + {datatype, {duration, s}} +]}. + +{mapping, "bridge.mqtt.$name.tls_versions", "emqx_bridge_mqtt.bridges", [ + {datatype, string}, + {default, "tlsv1,tlsv1.1,tlsv1.2"} +]}. + +{mapping, "bridge.mqtt.$name.reconnect_interval", "emqx_bridge_mqtt.bridges", [ + {default, "30s"}, + {datatype, {duration, ms}} +]}. + +{mapping, "bridge.mqtt.$name.retry_interval", "emqx_bridge_mqtt.bridges", [ + {default, "20s"}, + {datatype, {duration, s}} +]}. + +{mapping, "bridge.mqtt.$name.max_inflight_size", "emqx_bridge_mqtt.bridges", [ + {default, 0}, + {datatype, integer} + ]}. + +{mapping, "bridge.mqtt.$name.batch_size", "emqx_bridge_mqtt.bridges", [ + {default, 0}, + {datatype, integer} +]}. + +{mapping, "bridge.mqtt.$name.queue.replayq_dir", "emqx_bridge_mqtt.bridges", [ + {datatype, string} +]}. + +{mapping, "bridge.mqtt.$name.queue.replayq_seg_bytes", "emqx_bridge_mqtt.bridges", [ + {datatype, bytesize} +]}. + +{mapping, "bridge.mqtt.$name.queue.max_total_size", "emqx_bridge_mqtt.bridges", [ + {datatype, bytesize} +]}. + +{translation, "emqx_bridge_mqtt.bridges", fun(Conf) -> + + MapPSKCiphers = fun(PSKCiphers) -> + lists:map( + fun("PSK-AES128-CBC-SHA") -> {psk, aes_128_cbc, sha}; + ("PSK-AES256-CBC-SHA") -> {psk, aes_256_cbc, sha}; + ("PSK-3DES-EDE-CBC-SHA") -> {psk, '3des_ede_cbc', sha}; + ("PSK-RC4-SHA") -> {psk, rc4_128, sha} + end, PSKCiphers) + end, + + Split = fun(undefined) -> undefined; (S) -> string:tokens(S, ",") end, + + IsSsl = fun(cacertfile) -> true; + (certfile) -> true; + (keyfile) -> true; + (ciphers) -> true; + (psk_ciphers) -> true; + (tls_versions) -> true; + (_Opt) -> false + end, + + Parse = fun(tls_versions, Vers) -> + [{versions, [list_to_atom(S) || S <- Split(Vers)]}]; + (ciphers, Ciphers) -> + [{ciphers, Split(Ciphers)}]; + (psk_ciphers, Ciphers) -> + [{ciphers, MapPSKCiphers(Split(Ciphers))}, {user_lookup_fun, {fun emqx_psk:lookup/3, <<>>}}]; + (Opt, Val) -> + [{Opt, Val}] + end, + + Merge = fun(forwards, Val, Opts) -> + [{forwards, string:tokens(Val, ",")}|Opts]; + (Opt, Val, Opts) -> + case IsSsl(Opt) of + true -> + SslOpts = Parse(Opt, Val) ++ proplists:get_value(ssl_opts, Opts, []), + lists:ukeymerge(1, [{ssl_opts, SslOpts}], lists:usort(Opts)); + false -> + [{Opt, Val}|Opts] + end + end, + Queue = fun(Name) -> + Configs = cuttlefish_variable:filter_by_prefix("bridge.mqtt." ++ Name ++ ".queue", Conf), + + QOpts = [{list_to_atom(QOpt), QValue}|| {[_, _, _, "queue", QOpt], QValue} <- Configs], + maps:from_list(QOpts) + end, + Subscriptions = fun(Name) -> + Configs = cuttlefish_variable:filter_by_prefix("bridge.mqtt." ++ Name ++ ".subscription", Conf), + lists:zip([Topic || {_, Topic} <- lists:sort([{I, Topic} || {[_, _, _, "subscription", I, "topic"], Topic} <- Configs])], + [QoS || {_, QoS} <- lists:sort([{I, QoS} || {[_, _, _, "subscription", I, "qos"], QoS} <- Configs])]) + end, + IsNodeAddr = fun(Addr) -> + case string:tokens(Addr, "@") of + [_NodeName, _Hostname] -> true; + _ -> false + end + end, + ConnMod = fun(Name) -> + + [AddrConfig] = cuttlefish_variable:filter_by_prefix("bridge.mqtt." ++ Name ++ ".address", Conf), + {_, Addr} = AddrConfig, + + Subs = Subscriptions(Name), + case IsNodeAddr(Addr) of + true when Subs =/= [] -> + error({"subscriptions are not supported when bridging between emqx nodes", Name, Subs}); + true -> + emqx_bridge_rpc; + false -> + emqx_bridge_mqtt + end + end, + + %% to be backward compatible + Translate = + fun Tr(queue, Q, Cfg) -> + NewQ = maps:fold(Tr, #{}, Q), + Cfg#{queue => NewQ}; + Tr(address, Addr0, Cfg) -> + Addr = case IsNodeAddr(Addr0) of + true -> list_to_atom(Addr0); + false -> Addr0 + end, + Cfg#{address => Addr}; + Tr(reconnect_interval, Ms, Cfg) -> + Cfg#{reconnect_delay_ms => Ms}; + Tr(proto_ver, Ver, Cfg) -> + Cfg#{proto_ver => + case Ver of + mqttv3 -> v3; + mqttv4 -> v4; + mqttv5 -> v5; + _ -> v4 + end}; + Tr(max_inflight_size, Size, Cfg) -> + Cfg#{max_inflight => Size}; + Tr(Key, Value, Cfg) -> + Cfg#{Key => Value} + end, + C = lists:foldl( + fun({["bridge", "mqtt", Name, Opt], Val}, Acc) -> + %% e.g #{aws => [{OptKey, OptVal}]} + Init = [{list_to_atom(Opt), Val}, + {connect_module, ConnMod(Name)}, + {subscriptions, Subscriptions(Name)}, + {queue, Queue(Name)}], + maps:update_with(list_to_atom(Name), fun(Opts) -> Merge(list_to_atom(Opt), Val, Opts) end, Init, Acc); + (_, Acc) -> Acc + end, #{}, lists:usort(cuttlefish_variable:filter_by_prefix("bridge.mqtt", Conf))), + C1 = maps:map(fun(Bn, Bc) -> + maps:to_list(maps:fold(Translate, #{}, maps:from_list(Bc))) + end, C), + maps:to_list(C1) +end}. diff --git a/apps/emqx_bridge_mqtt/rebar.config b/apps/emqx_bridge_mqtt/rebar.config new file mode 100644 index 000000000..37ac5b034 --- /dev/null +++ b/apps/emqx_bridge_mqtt/rebar.config @@ -0,0 +1,19 @@ +{deps, []}. +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + +{shell, [ + % {config, "config/sys.config"}, + {apps, [emqx, emqx_bridge_mqtt]} +]}. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_connect.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_connect.erl new file mode 100644 index 000000000..5086ca574 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_connect.erl @@ -0,0 +1,74 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_connect). + +-export([start/2]). + +-export_type([config/0, connection/0]). + +-optional_callbacks([ensure_subscribed/3, ensure_unsubscribed/2]). + +%% map fields depend on implementation +-type(config() :: map()). +-type(connection() :: term()). +-type(batch() :: emqx_protal:batch()). +-type(ack_ref() :: emqx_bridge_worker:ack_ref()). +-type(topic() :: emqx_topic:topic()). +-type(qos() :: emqx_mqtt_types:qos()). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[Bridge Connect]"). + +%% establish the connection to remote node/cluster +%% protal worker (the caller process) should be expecting +%% a message {disconnected, conn_ref()} when disconnected. +-callback start(config()) -> {ok, connection()} | {error, any()}. + +%% send to remote node/cluster +%% bridge worker (the caller process) should be expecting +%% a message {batch_ack, reference()} when batch is acknowledged by remote node/cluster +-callback send(connection(), batch()) -> {ok, ack_ref()} | {ok, integer()} | {error, any()}. + +%% called when owner is shutting down. +-callback stop(connection()) -> ok. + +-callback ensure_subscribed(connection(), topic(), qos()) -> ok. + +-callback ensure_unsubscribed(connection(), topic()) -> ok. + +start(Module, Config) -> + case Module:start(Config) of + {ok, Conn} -> + {ok, Conn}; + {error, Reason} -> + Config1 = obfuscate(Config), + ?LOG(error, "Failed to connect with module=~p\n" + "config=~p\nreason:~p", [Module, Config1, Reason]), + {error, Reason} + end. + +obfuscate(Map) -> + maps:fold(fun(K, V, Acc) -> + case is_sensitive(K) of + true -> [{K, '***'} | Acc]; + false -> [{K, V} | Acc] + end + end, [], Map). + +is_sensitive(password) -> true; +is_sensitive(_) -> false. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.app.src b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.app.src new file mode 100644 index 000000000..945abcdfc --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.app.src @@ -0,0 +1,14 @@ +{application, emqx_bridge_mqtt, + [{description, "EMQ X Bridge to MQTT Broker"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, []}, + {applications, [kernel,stdlib,replayq,emqtt]}, + {mod, {emqx_bridge_mqtt_app, []}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-bridge-mqtt"} + ]} + ]}. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl new file mode 100644 index 000000000..3f63cdb46 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl @@ -0,0 +1,198 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc This module implements EMQX Bridge transport layer on top of MQTT protocol + +-module(emqx_bridge_mqtt). + +-behaviour(emqx_bridge_connect). + +%% behaviour callbacks +-export([ start/1 + , send/2 + , stop/1 + ]). + +%% optional behaviour callbacks +-export([ ensure_subscribed/3 + , ensure_unsubscribed/2 + ]). + +%% callbacks for emqtt +-export([ handle_puback/2 + , handle_publish/2 + , handle_disconnected/2 + ]). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-define(ACK_REF(ClientPid, PktId), {ClientPid, PktId}). + +%% Messages towards ack collector process +-define(REF_IDS(Ref, Ids), {Ref, Ids}). + +%%-------------------------------------------------------------------- +%% emqx_bridge_connect callbacks +%%-------------------------------------------------------------------- + +start(Config = #{address := Address}) -> + Parent = self(), + Mountpoint = maps:get(receive_mountpoint, Config, undefined), + Handlers = make_hdlr(Parent, Mountpoint), + {Host, Port} = case string:tokens(Address, ":") of + [H] -> {H, 1883}; + [H, P] -> {H, list_to_integer(P)} + end, + ClientConfig = Config#{msg_handler => Handlers, + host => Host, + port => Port, + force_ping => true + }, + case emqtt:start_link(replvar(ClientConfig)) of + {ok, Pid} -> + case emqtt:connect(Pid) of + {ok, _} -> + try + subscribe_remote_topics(Pid, maps:get(subscriptions, Config, [])), + {ok, #{client_pid => Pid}} + catch + throw : Reason -> + ok = stop(#{client_pid => Pid}), + {error, Reason} + end; + {error, Reason} -> + ok = stop(#{client_pid => Pid}), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +stop(#{client_pid := Pid}) -> + safe_stop(Pid, fun() -> emqtt:stop(Pid) end, 1000), + ok. + +ensure_subscribed(#{client_pid := Pid}, Topic, QoS) when is_pid(Pid) -> + case emqtt:subscribe(Pid, Topic, QoS) of + {ok, _, _} -> ok; + Error -> Error + end; +ensure_subscribed(_Conn, _Topic, _QoS) -> + %% return ok for now + %% next re-connect should should call start with new topic added to config + ok. + +ensure_unsubscribed(#{client_pid := Pid}, Topic) when is_pid(Pid) -> + case emqtt:unsubscribe(Pid, Topic) of + {ok, _, _} -> ok; + Error -> Error + end; +ensure_unsubscribed(_, _) -> + %% return ok for now + %% next re-connect should should call start with this topic deleted from config + ok. + +safe_stop(Pid, StopF, Timeout) -> + MRef = monitor(process, Pid), + unlink(Pid), + try + StopF() + catch + _ : _ -> + ok + end, + receive + {'DOWN', MRef, _, _, _} -> + ok + after + Timeout -> + exit(Pid, kill) + end. + +send(Conn, Msgs) -> + send(Conn, Msgs, undefined). +send(_Conn, [], PktId) -> + {ok, PktId}; +send(#{client_pid := ClientPid} = Conn, [Msg | Rest], _PktId) -> + case emqtt:publish(ClientPid, Msg) of + ok -> + Ref = make_ref(), + self() ! {batch_ack, Ref}, + send(Conn, Rest, Ref); + {ok, PktId} -> + send(Conn, Rest, PktId); + {error, Reason} -> + %% NOTE: There is no partial sucess of a batch and recover from the middle + %% only to retry all messages in one batch + {error, Reason} + end. + + +handle_puback(#{packet_id := PktId, reason_code := RC}, Parent) + when RC =:= ?RC_SUCCESS; + RC =:= ?RC_NO_MATCHING_SUBSCRIBERS -> + Parent ! {batch_ack, PktId}, ok; +handle_puback(#{packet_id := PktId, reason_code := RC}, _Parent) -> + ?LOG(warning, "Publish ~p to remote node falied, reason_code: ~p", [PktId, RC]). + +handle_publish(Msg, Mountpoint) -> + emqx_broker:publish(emqx_bridge_msg:to_broker_msg(Msg, Mountpoint)). + +handle_disconnected(Reason, Parent) -> + Parent ! {disconnected, self(), Reason}. + +make_hdlr(Parent, Mountpoint) -> + #{puback => {fun ?MODULE:handle_puback/2, [Parent]}, + publish => {fun ?MODULE:handle_publish/2, [Mountpoint]}, + disconnected => {fun ?MODULE:handle_disconnected/2, [Parent]} + }. + +subscribe_remote_topics(ClientPid, Subscriptions) -> + lists:foreach(fun({Topic, Qos}) -> + case emqtt:subscribe(ClientPid, Topic, Qos) of + {ok, _, _} -> ok; + Error -> throw(Error) + end + end, Subscriptions). + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +replvar(Options) -> + replvar([clientid, max_inflight], Options). + +replvar([], Options) -> + Options; +replvar([Key|More], Options) -> + case maps:get(Key, Options, undefined) of + undefined -> + replvar(More, Options); + Val -> + replvar(More, maps:put(Key, feedvar(Key, Val, Options), Options)) + end. + +%% ${node} => node() +feedvar(clientid, ClientId, _) -> + iolist_to_binary(re:replace(ClientId, "\\${node}", atom_to_list(node()))); + +feedvar(max_inflight, 0, _) -> + infinity; + +feedvar(max_inflight, Size, _) -> + Size. + diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl new file mode 100644 index 000000000..d63f20141 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl @@ -0,0 +1,784 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc This module implements EMQX Bridge transport layer on top of MQTT protocol + +-module(emqx_bridge_mqtt_actions). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_rule_engine/include/rule_actions.hrl"). + +-import(emqx_rule_utils, [str/1]). + +-export([ on_resource_create/2 + , on_get_resource_status/2 + , on_resource_destroy/2 + ]). + +%% Callbacks of ecpool Worker +-export([connect/1]). + +-export([subscriptions/1]). + +-export([ on_action_create_data_to_mqtt_broker/2 + , on_action_data_to_mqtt_broker/2 + ]). + +-define(RESOURCE_TYPE_MQTT, 'bridge_mqtt'). +-define(RESOURCE_TYPE_MQTT_SUB, 'bridge_mqtt_sub'). +-define(RESOURCE_TYPE_RPC, 'bridge_rpc'). + +-define(RESOURCE_CONFIG_SPEC_MQTT, #{ + address => #{ + order => 1, + type => string, + required => true, + default => <<"127.0.0.1:1883">>, + title => #{en => <<" Broker Address">>, + zh => <<"远程 broker 地址"/utf8>>}, + description => #{en => <<"The MQTT Remote Address">>, + zh => <<"远程 MQTT Broker 的地址"/utf8>>} + }, + pool_size => #{ + order => 2, + type => number, + required => true, + default => 8, + title => #{en => <<"Pool Size">>, + zh => <<"连接池大小"/utf8>>}, + description => #{en => <<"MQTT Connection Pool Size">>, + zh => <<"连接池大小"/utf8>>} + }, + clientid => #{ + order => 3, + type => string, + required => true, + default => <<"client">>, + title => #{en => <<"ClientId">>, + zh => <<"客户端 Id"/utf8>>}, + description => #{en => <<"ClientId for connecting to remote MQTT broker">>, + zh => <<"连接远程 Broker 的 ClientId"/utf8>>} + }, + append => #{ + order => 4, + type => boolean, + required => false, + default => true, + title => #{en => <<"Append GUID">>, + zh => <<"附加 GUID"/utf8>>}, + description => #{en => <<"Append GUID to MQTT ClientId?">>, + zh => <<"是否将GUID附加到 MQTT ClientId 后"/utf8>>} + }, + username => #{ + order => 5, + type => string, + required => false, + default => <<"">>, + title => #{en => <<"Username">>, zh => <<"用户名"/utf8>>}, + description => #{en => <<"Username for connecting to remote MQTT Broker">>, + zh => <<"连接远程 Broker 的用户名"/utf8>>} + }, + password => #{ + order => 6, + type => string, + required => false, + default => <<"">>, + title => #{en => <<"Password">>, + zh => <<"密码"/utf8>>}, + description => #{en => <<"Password for connecting to remote MQTT Broker">>, + zh => <<"连接远程 Broker 的密码"/utf8>>} + }, + mountpoint => #{ + order => 7, + type => string, + required => false, + default => <<"bridge/aws/${node}/">>, + title => #{en => <<"Bridge MountPoint">>, + zh => <<"桥接挂载点"/utf8>>}, + description => #{ + en => <<"MountPoint for bridge topic:
" + "Example: The topic of messages sent to `topic1` on local node" + "will be transformed to `bridge/aws/${node}/topic1`">>, + zh => <<"桥接主题的挂载点:
" + "示例: 本地节点向 `topic1` 发消息,远程桥接节点的主题" + "会变换为 `bridge/aws/${node}/topic1`"/utf8>> + } + }, + disk_cache => #{ + order => 8, + type => string, + required => false, + default => <<"off">>, + enum => [<<"on">>, <<"off">>], + title => #{en => <<"Disk Cache">>, + zh => <<"磁盘缓存"/utf8>>}, + description => #{en => <<"The flag which determines whether messages" + "can be cached on local disk when bridge is" + "disconnected">>, + zh => <<"当桥接断开时用于控制是否将消息缓存到本地磁" + "盘队列上"/utf8>>} + }, + proto_ver => #{ + order => 9, + type => string, + required => false, + default => <<"mqttv4">>, + enum => [<<"mqttv3">>, <<"mqttv4">>, <<"mqttv5">>], + title => #{en => <<"Protocol Version">>, + zh => <<"协议版本"/utf8>>}, + description => #{en => <<"MQTTT Protocol version">>, + zh => <<"MQTT 协议版本"/utf8>>} + }, + keepalive => #{ + order => 10, + type => string, + required => false, + default => <<"60s">> , + title => #{en => <<"Keepalive">>, + zh => <<"心跳间隔"/utf8>>}, + description => #{en => <<"Keepalive">>, + zh => <<"心跳间隔"/utf8>>} + }, + reconnect_interval => #{ + order => 11, + type => string, + required => false, + default => <<"30s">>, + title => #{en => <<"Reconnect Interval">>, + zh => <<"重连间隔"/utf8>>}, + description => #{en => <<"Reconnect interval of bridge:
">>, + zh => <<"重连间隔"/utf8>>} + }, + retry_interval => #{ + order => 12, + type => string, + required => false, + default => <<"20s">>, + title => #{en => <<"Retry interval">>, + zh => <<"重传间隔"/utf8>>}, + description => #{en => <<"Retry interval for bridge QoS1 message delivering">>, + zh => <<"消息重传间隔"/utf8>>} + }, + bridge_mode => #{ + order => 13, + type => boolean, + required => false, + default => false, + title => #{en => <<"Bridge Mode">>, + zh => <<"桥接模式"/utf8>>}, + description => #{en => <<"Bridge mode for MQTT bridge connection">>, + zh => <<"MQTT 连接是否为桥接模式"/utf8>>} + }, + ssl => #{ + order => 14, + type => string, + required => false, + default => <<"off">>, + enum => [<<"on">>, <<"off">>], + title => #{en => <<"Bridge SSL">>, + zh => <<"Bridge SSL"/utf8>>}, + description => #{en => <<"Switch which used to enable ssl connection of the bridge">>, + zh => <<"是否启用 Bridge SSL 连接"/utf8>>} + }, + cacertfile => #{ + order => 15, + type => string, + required => false, + default => <<"etc/certs/cacert.pem">>, + title => #{en => <<"CA certificates">>, + zh => <<"CA 证书"/utf8>>}, + description => #{en => <<"The file path of the CA certificates">>, + zh => <<"CA 证书路径"/utf8>>} + }, + certfile => #{ + order => 16, + type => string, + required => false, + default => <<"etc/certs/client-cert.pem">>, + title => #{en => <<"SSL Certfile">>, + zh => <<"SSL 客户端证书"/utf8>>}, + description => #{en => <<"The file path of the client certfile">>, + zh => <<"客户端证书路径"/utf8>>} + }, + keyfile => #{ + order => 17, + type => string, + required => false, + default => <<"etc/certs/client-key.pem">>, + title => #{en => <<"SSL Keyfile">>, + zh => <<"SSL 密钥文件"/utf8>>}, + description => #{en => <<"The file path of the client keyfile">>, + zh => <<"客户端密钥路径"/utf8>>} + }, + ciphers => #{ + order => 18, + type => string, + required => false, + default => <<"ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,", + "ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,", + "ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,", + "ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,", + "AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,", + "ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,", + "ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,", + "DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,", + "ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,", + "ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,", + "DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA">>, + title => #{en => <<"SSL Ciphers">>, + zh => <<"SSL 加密算法"/utf8>>}, + description => #{en => <<"SSL Ciphers">>, + zh => <<"SSL 加密算法"/utf8>>} + } + }). + + +-define(RESOURCE_CONFIG_SPEC_MQTT_SUB, #{ + address => #{ + order => 1, + type => string, + required => true, + default => <<"127.0.0.1:1883">>, + title => #{en => <<" Broker Address">>, + zh => <<"远程 broker 地址"/utf8>>}, + description => #{en => <<"The MQTT Remote Address">>, + zh => <<"远程 MQTT Broker 的地址"/utf8>>} + }, + pool_size => #{ + order => 2, + type => number, + required => true, + default => 8, + title => #{en => <<"Pool Size">>, + zh => <<"连接池大小"/utf8>>}, + description => #{en => <<"MQTT Connection Pool Size">>, + zh => <<"连接池大小"/utf8>>} + }, + clientid => #{ + order => 3, + type => string, + required => true, + default => <<"client">>, + title => #{en => <<"ClientId">>, + zh => <<"客户端 Id"/utf8>>}, + description => #{en => <<"ClientId for connecting to remote MQTT broker">>, + zh => <<"连接远程 Broker 的 ClientId"/utf8>>} + }, + append => #{ + order => 4, + type => boolean, + required => true, + default => true, + title => #{en => <<"Append GUID">>, + zh => <<"附加 GUID"/utf8>>}, + description => #{en => <<"Append GUID to MQTT ClientId?">>, + zh => <<"是否将GUID附加到 MQTT ClientId 后"/utf8>>} + }, + username => #{ + order => 5, + type => string, + required => false, + default => <<"">>, + title => #{en => <<"Username">>, zh => <<"用户名"/utf8>>}, + description => #{en => <<"Username for connecting to remote MQTT Broker">>, + zh => <<"连接远程 Broker 的用户名"/utf8>>} + }, + password => #{ + order => 6, + type => string, + required => false, + default => <<"">>, + title => #{en => <<"Password">>, + zh => <<"密码"/utf8>>}, + description => #{en => <<"Password for connecting to remote MQTT Broker">>, + zh => <<"连接远程 Broker 的密码"/utf8>>} + }, + subscription_opts => #{ + order => 7, + type => array, + items => #{ + type => object, + schema => #{ + topic => #{ + order => 1, + type => string, + default => <<>>, + title => #{en => <<"MQTT Topic">>, + zh => <<"MQTT 主题"/utf8>>}, + description => #{en => <<"MQTT Topic">>, + zh => <<"MQTT 主题"/utf8>>} + }, + qos => #{ + order => 2, + type => number, + enum => [0, 1, 2], + default => 0, + title => #{en => <<"MQTT Topic QoS">>, + zh => <<"MQTT 服务质量"/utf8>>}, + description => #{en => <<"MQTT Topic QoS">>, + zh => <<"MQTT 服务质量"/utf8>>} + } + } + }, + default => [], + title => #{en => <<"Subscription Opts">>, + zh => <<"订阅选项"/utf8>>}, + description => #{en => <<"Subscription Opts">>, + zh => <<"订阅选项"/utf8>>} + }, + proto_ver => #{ + order => 8, + type => string, + required => false, + default => <<"mqttv4">>, + enum => [<<"mqttv3">>, <<"mqttv4">>, <<"mqttv5">>], + title => #{en => <<"Protocol Version">>, + zh => <<"协议版本"/utf8>>}, + description => #{en => <<"MQTTT Protocol version">>, + zh => <<"MQTT 协议版本"/utf8>>} + }, + keepalive => #{ + order => 9, + type => string, + required => false, + default => <<"60s">> , + title => #{en => <<"Keepalive">>, + zh => <<"心跳间隔"/utf8>>}, + description => #{en => <<"Keepalive">>, + zh => <<"心跳间隔"/utf8>>} + }, + reconnect_interval => #{ + order => 10, + type => string, + required => false, + default => <<"30s">>, + title => #{en => <<"Reconnect Interval">>, + zh => <<"重连间隔"/utf8>>}, + description => #{en => <<"Reconnect interval of bridge">>, + zh => <<"重连间隔"/utf8>>} + }, + ssl => #{ + order => 11, + type => string, + required => false, + default => <<"off">>, + enum => [<<"on">>, <<"off">>], + title => #{en => <<"Bridge SSL">>, + zh => <<"Bridge SSL"/utf8>>}, + description => #{en => <<"Switch which used to enable ssl connection of the bridge">>, + zh => <<"是否启用 Bridge SSL 连接"/utf8>>} + }, + cacertfile => #{ + order => 12, + type => string, + required => false, + default => <<"etc/certs/cacert.pem">>, + title => #{en => <<"CA certificates">>, + zh => <<"CA 证书"/utf8>>}, + description => #{en => <<"The file path of the CA certificates">>, + zh => <<"CA 证书路径"/utf8>>} + }, + certfile => #{ + order => 13, + type => string, + required => false, + default => <<"etc/certs/client-cert.pem">>, + title => #{en => <<"SSL Certfile">>, + zh => <<"SSL 客户端证书"/utf8>>}, + description => #{en => <<"The file path of the client certfile">>, + zh => <<"客户端证书路径"/utf8>>} + }, + keyfile => #{ + order => 14, + type => string, + required => false, + default => <<"etc/certs/client-key.pem">>, + title => #{en => <<"SSL Keyfile">>, + zh => <<"SSL 密钥文件"/utf8>>}, + description => #{en => <<"The file path of the client keyfile">>, + zh => <<"客户端密钥路径"/utf8>>} + }, + ciphers => #{ + order => 15, + type => string, + required => false, + default => <<"ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384">>, + title => #{en => <<"SSL Ciphers">>, + zh => <<"SSL 加密算法"/utf8>>}, + description => #{en => <<"SSL Ciphers">>, + zh => <<"SSL 加密算法"/utf8>>} + } + }). + + +-define(RESOURCE_CONFIG_SPEC_RPC, #{ + address => #{ + order => 1, + type => string, + required => true, + default => <<"emqx2@127.0.0.1">>, + title => #{en => <<"EMQ X Node Name">>, + zh => <<"EMQ X 节点名称"/utf8>>}, + description => #{en => <<"EMQ X Remote Node Name">>, + zh => <<"远程 EMQ X 节点名称 "/utf8>>} + }, + mountpoint => #{ + order => 2, + type => string, + required => false, + default => <<"bridge/emqx/${node}/">>, + title => #{en => <<"Bridge MountPoint">>, + zh => <<"桥接挂载点"/utf8>>}, + description => #{en => <<"MountPoint for bridge topic
" + "Example: The topic of messages sent to `topic1` on local node" + "will be transformed to `bridge/aws/${node}/topic1`">>, + zh => <<"桥接主题的挂载点
" + "示例: 本地节点向 `topic1` 发消息,远程桥接节点的主题" + "会变换为 `bridge/aws/${node}/topic1`"/utf8>>} + }, + pool_size => #{ + order => 3, + type => number, + required => true, + default => 8, + title => #{en => <<"Pool Size">>, + zh => <<"连接池大小"/utf8>>}, + description => #{en => <<"MQTT/RPC Connection Pool Size">>, + zh => <<"连接池大小"/utf8>>} + }, + reconnect_interval => #{ + order => 4, + type => string, + required => false, + default => <<"30s">>, + title => #{en => <<"Reconnect Interval">>, + zh => <<"重连间隔"/utf8>>}, + description => #{en => <<"Reconnect Interval of bridge">>, + zh => <<"重连间隔"/utf8>>} + }, + batch_size => #{ + order => 5, + type => number, + required => false, + default => 32, + title => #{en => <<"Batch Size">>, + zh => <<"批处理大小"/utf8>>}, + description => #{en => <<"Batch Size">>, + zh => <<"批处理大小"/utf8>>} + }, + disk_cache => #{ + order => 6, + type => string, + required => false, + default => <<"off">>, + enum => [<<"on">>, <<"off">>], + title => #{en => <<"Disk Cache">>, + zh => <<"磁盘缓存"/utf8>>}, + description => #{en => <<"The flag which determines whether messages" + "can be cached on local disk when bridge is" + "disconnected">>, + zh => <<"当桥接断开时用于控制是否将消息缓存到本地磁" + "盘队列上"/utf8>>} + } + }). + +-define(ACTION_PARAM_RESOURCE, #{ + type => string, + required => true, + title => #{en => <<"Resource ID">>, zh => <<"资源 ID"/utf8>>}, + description => #{en => <<"Bind a resource to this action">>, + zh => <<"给动作绑定一个资源"/utf8>>} + }). + +-resource_type(#{ + name => ?RESOURCE_TYPE_MQTT, + create => on_resource_create, + status => on_get_resource_status, + destroy => on_resource_destroy, + params => ?RESOURCE_CONFIG_SPEC_MQTT, + title => #{en => <<"MQTT Bridge">>, zh => <<"MQTT Bridge"/utf8>>}, + description => #{en => <<"MQTT Message Bridge">>, zh => <<"MQTT 消息桥接"/utf8>>} + }). + +-resource_type(#{ + name => ?RESOURCE_TYPE_MQTT_SUB, + create => on_resource_create, + status => on_get_resource_status, + destroy => on_resource_destroy, + params => ?RESOURCE_CONFIG_SPEC_MQTT_SUB, + title => #{en => <<"MQTT Subscribe">>, zh => <<"MQTT Subscribe"/utf8>>}, + description => #{en => <<"MQTT Subscribe">>, zh => <<"MQTT 订阅消息"/utf8>>} + }). + +-resource_type(#{ + name => ?RESOURCE_TYPE_RPC, + create => on_resource_create, + status => on_get_resource_status, + destroy => on_resource_destroy, + params => ?RESOURCE_CONFIG_SPEC_RPC, + title => #{en => <<"EMQX Bridge">>, zh => <<"EMQX Bridge"/utf8>>}, + description => #{en => <<"EMQ X RPC Bridge">>, zh => <<"EMQ X RPC 消息桥接"/utf8>>} + }). + +-rule_action(#{ + name => data_to_mqtt_broker, + category => data_forward, + for => 'message.publish', + types => [?RESOURCE_TYPE_MQTT, ?RESOURCE_TYPE_RPC], + create => on_action_create_data_to_mqtt_broker, + params => #{'$resource' => ?ACTION_PARAM_RESOURCE, + forward_topic => #{ + order => 1, + type => string, + required => false, + default => <<"">>, + title => #{en => <<"Forward Topic">>, + zh => <<"转发消息主题"/utf8>>}, + description => #{en => <<"The topic used when forwarding the message. Defaults to the topic of the bridge message if not provided.">>, + zh => <<"转发消息时使用的主题。如果未提供,则默认为桥接消息的主题。"/utf8>>} + }, + payload_tmpl => #{ + order => 2, + type => string, + input => textarea, + required => false, + default => <<"">>, + title => #{en => <<"Payload Template">>, + zh => <<"消息内容模板"/utf8>>}, + description => #{en => <<"The payload template, variable interpolation is supported. If using empty template (default), then the payload will be all the available vars in JSON format">>, + zh => <<"消息内容模板,支持变量。若使用空模板(默认),消息内容为 JSON 格式的所有字段"/utf8>>} + } + }, + title => #{en => <<"Data bridge to MQTT Broker">>, + zh => <<"桥接数据到 MQTT Broker"/utf8>>}, + description => #{en => <<"Bridge Data to MQTT Broker">>, + zh => <<"桥接数据到 MQTT Broker"/utf8>>} + }). + +on_resource_create(ResId, Params) -> + ?LOG(info, "Initiating Resource ~p, ResId: ~p", [?RESOURCE_TYPE_MQTT, ResId]), + {ok, _} = application:ensure_all_started(ecpool), + PoolName = pool_name(ResId), + Options = options(Params, PoolName), + start_resource(ResId, PoolName, Options), + case test_resource_status(PoolName) of + true -> ok; + false -> + on_resource_destroy(ResId, #{<<"pool">> => PoolName}), + error({{?RESOURCE_TYPE_MQTT, ResId}, connection_failed}) + end, + #{<<"pool">> => PoolName}. + +start_resource(ResId, PoolName, Options) -> + case ecpool:start_sup_pool(PoolName, ?MODULE, Options) of + {ok, _} -> + ?LOG(info, "Initiated Resource ~p Successfully, ResId: ~p", [?RESOURCE_TYPE_MQTT, ResId]); + {error, {already_started, _Pid}} -> + on_resource_destroy(ResId, #{<<"pool">> => PoolName}), + start_resource(ResId, PoolName, Options); + {error, Reason} -> + ?LOG(error, "Initiate Resource ~p failed, ResId: ~p, ~p", [?RESOURCE_TYPE_MQTT, ResId, Reason]), + on_resource_destroy(ResId, #{<<"pool">> => PoolName}), + error({{?RESOURCE_TYPE_MQTT, ResId}, create_failed}) + end. + +test_resource_status(PoolName) -> + IsConnected = fun(Worker) -> + case ecpool_worker:client(Worker) of + {ok, Bridge} -> + try emqx_bridge_worker:status(Bridge) of + connected -> true; + _ -> false + catch _Error:_Reason -> + false + end; + {error, _} -> + false + end + end, + Status = [IsConnected(Worker) || {_WorkerName, Worker} <- ecpool:workers(PoolName)], + lists:any(fun(St) -> St =:= true end, Status). + +-spec(on_get_resource_status(ResId::binary(), Params::map()) -> Status::map()). +on_get_resource_status(_ResId, #{<<"pool">> := PoolName}) -> + IsAlive = test_resource_status(PoolName), + #{is_alive => IsAlive}. + +on_resource_destroy(ResId, #{<<"pool">> := PoolName}) -> + ?LOG(info, "Destroying Resource ~p, ResId: ~p", [?RESOURCE_TYPE_MQTT, ResId]), + case ecpool:stop_sup_pool(PoolName) of + ok -> + ?LOG(info, "Destroyed Resource ~p Successfully, ResId: ~p", [?RESOURCE_TYPE_MQTT, ResId]); + {error, Reason} -> + ?LOG(error, "Destroy Resource ~p failed, ResId: ~p, ~p", [?RESOURCE_TYPE_MQTT, ResId, Reason]), + error({{?RESOURCE_TYPE_MQTT, ResId}, destroy_failed}) + end. + +on_action_create_data_to_mqtt_broker(ActId, Opts = #{<<"pool">> := PoolName, + <<"forward_topic">> := ForwardTopic, + <<"payload_tmpl">> := PayloadTmpl}) -> + ?LOG(info, "Initiating Action ~p.", [?FUNCTION_NAME]), + PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), + TopicTks = case ForwardTopic == <<"">> of + true -> undefined; + false -> emqx_rule_utils:preproc_tmpl(ForwardTopic) + end, + Opts. + +on_action_data_to_mqtt_broker(Msg, _Env = + #{id := Id, clientid := From, flags := Flags, + topic := Topic, timestamp := TimeStamp, qos := QoS, + ?BINDING_KEYS := #{ + 'ActId' := ActId, + 'PoolName' := PoolName, + 'TopicTks' := TopicTks, + 'PayloadTks' := PayloadTks + }}) -> + Topic1 = case TopicTks =:= undefined of + true -> Topic; + false -> emqx_rule_utils:proc_tmpl(TopicTks, Msg) + end, + BrokerMsg = #message{id = Id, + qos = QoS, + from = From, + flags = Flags, + topic = Topic1, + payload = format_data(PayloadTks, Msg), + timestamp = TimeStamp}, + ecpool:with_client(PoolName, + fun(BridgePid) -> + BridgePid ! {deliver, rule_engine, BrokerMsg} + end), + emqx_rule_metrics:inc_actions_success(ActId). + +format_data([], Msg) -> + emqx_json:encode(Msg); + +format_data(Tokens, Msg) -> + emqx_rule_utils:proc_tmpl(Tokens, Msg). + +tls_versions() -> + ['tlsv1.2','tlsv1.1', tlsv1]. + +ciphers(Ciphers) -> + string:tokens(str(Ciphers), ", "). + +subscriptions(Subscriptions) -> + scan_binary(<<"[", Subscriptions/binary, "].">>). + +is_node_addr(Addr0) -> + Addr = binary_to_list(Addr0), + case string:tokens(Addr, "@") of + [_NodeName, _Hostname] -> true; + _ -> false + end. + +scan_binary(Bin) -> + TermString = binary_to_list(Bin), + scan_string(TermString). + +scan_string(TermString) -> + {ok, Tokens, _} = erl_scan:string(TermString), + {ok, Term} = erl_parse:parse_term(Tokens), + Term. + +connect(Options) when is_list(Options) -> + connect(maps:from_list(Options)); +connect(Options = #{disk_cache := DiskCache, ecpool_worker_id := Id, pool_name := Pool}) -> + Options0 = case DiskCache of + true -> + DataDir = filename:join([emqx:get_env(data_dir), replayq, Pool, integer_to_list(Id)]), + QueueOption = #{replayq_dir => DataDir}, + Options#{queue => QueueOption}; + false -> + Options + end, + Options1 = case maps:is_key(append, Options0) of + false -> Options0; + true -> + case maps:get(append, Options0, false) of + true -> + ClientId = lists:concat([str(maps:get(clientid, Options0)), "_", str(emqx_guid:to_hexstr(emqx_guid:gen()))]), + Options0#{clientid => ClientId}; + false -> + Options0 + end + end, + Options2 = maps:without([ecpool_worker_id, pool_name, append], Options1), + emqx_bridge_worker:start_link(name(Pool, Id), Options2). +name(Pool, Id) -> + list_to_atom(atom_to_list(Pool) ++ ":" ++ integer_to_list(Id)). +pool_name(ResId) -> + list_to_atom("bridge_mqtt:" ++ str(ResId)). + +options(Options, PoolName) -> + GetD = fun(Key, Default) -> maps:get(Key, Options, Default) end, + Get = fun(Key) -> GetD(Key, undefined) end, + Address = Get(<<"address">>), + [{max_inflight_batches, 32}, + {forward_mountpoint, str(Get(<<"mountpoint">>))}, + {disk_cache, cuttlefish_flag:parse(str(GetD(<<"disk_cache">>, "off")))}, + {start_type, auto}, + {reconnect_delay_ms, cuttlefish_duration:parse(str(Get(<<"reconnect_interval">>)), ms)}, + {if_record_metrics, false}, + {pool_size, GetD(<<"pool_size">>, 1)}, + {pool_name, PoolName} + ] ++ case is_node_addr(Address) of + true -> + [{address, binary_to_atom(Get(<<"address">>), utf8)}, + {connect_module, emqx_bridge_rpc}, + {batch_size, Get(<<"batch_size">>)}]; + false -> + Subscriptions = format_subscriptions(GetD(<<"subscription_opts">>, [])), + Subscriptions1 = case Get(<<"topic">>) of + undefined -> Subscriptions; + Topic -> + [{subscriptions, [{Topic, Get(<<"qos">>)}]} | Subscriptions] + end, + [{address, binary_to_list(Address)}, + {bridge_mode, GetD(<<"bridge_mode">>, true)}, + {clean_start, true}, + {clientid, str(Get(<<"clientid">>))}, + {append, Get(<<"append">>)}, + {connect_module, emqx_bridge_mqtt}, + {keepalive, cuttlefish_duration:parse(str(Get(<<"keepalive">>)), s)}, + {username, str(Get(<<"username">>))}, + {password, str(Get(<<"password">>))}, + {proto_ver, mqtt_ver(Get(<<"proto_ver">>))}, + {retry_interval, cuttlefish_duration:parse(str(GetD(<<"retry_interval">>, "30s")), s)}, + {ssl, cuttlefish_flag:parse(str(Get(<<"ssl">>)))}, + {ssl_opts, [{versions, tls_versions()}, + {ciphers, ciphers(Get(<<"ciphers">>))}, + {keyfile, str(Get(<<"keyfile">>))}, + {certfile, str(Get(<<"certfile">>))}, + {cacertfile, str(Get(<<"cacertfile">>))} + ]}] ++ Subscriptions1 + end. + + +mqtt_ver(ProtoVer) -> + case ProtoVer of + <<"mqttv3">> -> v3; + <<"mqttv4">> -> v4; + <<"mqttv5">> -> v5; + _ -> v4 + end. + +format_subscriptions(SubOpts) -> + lists:map(fun(Sub) -> + {maps:get(<<"topic">>, Sub), maps:get(<<"qos">>, Sub)} + end, SubOpts). diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_app.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_app.erl new file mode 100644 index 000000000..2c0a0b9a9 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_app.erl @@ -0,0 +1,33 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_mqtt_app). + +-emqx_plugin(bridge). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + emqx_ctl:register_command(bridges, {emqx_bridge_mqtt_cli, cli}, []), + emqx_bridge_worker:register_metrics(), + emqx_bridge_mqtt_sup:start_link(). + +stop(_State) -> + emqx_ctl:unregister_command(bridges), + ok. + diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_cli.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_cli.erl new file mode 100644 index 000000000..c6d6e2378 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_cli.erl @@ -0,0 +1,92 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_mqtt_cli). + +-include("emqx_bridge_mqtt.hrl"). + +-import(lists, [foreach/2]). + +-export([cli/1]). + +cli(["list"]) -> + foreach(fun({Name, State0}) -> + State = case State0 of + connected -> <<"Running">>; + _ -> <<"Stopped">> + end, + emqx_ctl:print("name: ~s status: ~s~n", [Name, State]) + end, emqx_bridge_mqtt_sup:bridges()); + +cli(["start", Name]) -> + emqx_ctl:print("~s.~n", [try emqx_bridge_worker:ensure_started(Name) of + ok -> <<"Start bridge successfully">>; + connected -> <<"Bridge already started">>; + _ -> <<"Start bridge failed">> + catch + _Error:_Reason -> + <<"Start bridge failed">> + end]); + +cli(["stop", Name]) -> + emqx_ctl:print("~s.~n", [try emqx_bridge_worker:ensure_stopped(Name) of + ok -> <<"Stop bridge successfully">>; + _ -> <<"Stop bridge failed">> + catch + _Error:_Reason -> + <<"Stop bridge failed">> + end]); + +cli(["forwards", Name]) -> + foreach(fun(Topic) -> + emqx_ctl:print("topic: ~s~n", [Topic]) + end, emqx_bridge_worker:get_forwards(Name)); + +cli(["add-forward", Name, Topic]) -> + ok = emqx_bridge_worker:ensure_forward_present(Name, iolist_to_binary(Topic)), + emqx_ctl:print("Add-forward topic successfully.~n"); + +cli(["del-forward", Name, Topic]) -> + ok = emqx_bridge_worker:ensure_forward_absent(Name, iolist_to_binary(Topic)), + emqx_ctl:print("Del-forward topic successfully.~n"); + +cli(["subscriptions", Name]) -> + foreach(fun({Topic, Qos}) -> + emqx_ctl:print("topic: ~s, qos: ~p~n", [Topic, Qos]) + end, emqx_bridge_worker:get_subscriptions(Name)); + +cli(["add-subscription", Name, Topic, Qos]) -> + case emqx_bridge_worker:ensure_subscription_present(Name, Topic, list_to_integer(Qos)) of + ok -> emqx_ctl:print("Add-subscription topic successfully.~n"); + {error, Reason} -> emqx_ctl:print("Add-subscription failed reason: ~p.~n", [Reason]) + end; + +cli(["del-subscription", Name, Topic]) -> + ok = emqx_bridge_worker:ensure_subscription_absent(Name, Topic), + emqx_ctl:print("Del-subscription topic successfully.~n"); + +cli(_) -> + emqx_ctl:usage([{"bridges list", "List bridges"}, + {"bridges start ", "Start a bridge"}, + {"bridges stop ", "Stop a bridge"}, + {"bridges forwards ", "Show a bridge forward topic"}, + {"bridges add-forward ", "Add bridge forward topic"}, + {"bridges del-forward ", "Delete bridge forward topic"}, + {"bridges subscriptions ", "Show a bridge subscriptions topic"}, + {"bridges add-subscription ", "Add bridge subscriptions topic"}, + {"bridges del-subscription ", "Delete bridge subscriptions topic"}]). + + diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_sup.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_sup.erl new file mode 100644 index 000000000..92d8e4083 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_sup.erl @@ -0,0 +1,84 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_mqtt_sup). +-behaviour(supervisor). + +-include("emqx_bridge_mqtt.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[Bridge]"). + +%% APIs +-export([ start_link/0 + , start_link/1 + ]). + +-export([ create_bridge/2 + , drop_bridge/1 + , bridges/0 + , is_bridge_exist/1 + ]). + +%% supervisor callbacks +-export([init/1]). + +-define(SUP, ?MODULE). +-define(WORKER_SUP, emqx_bridge_worker_sup). + +start_link() -> start_link(?SUP). + +start_link(Name) -> + supervisor:start_link({local, Name}, ?MODULE, Name). + +init(?SUP) -> + BridgesConf = application:get_env(?APP, bridges, []), + BridgeSpec = lists:map(fun bridge_spec/1, BridgesConf), + SupFlag = #{strategy => one_for_one, + intensity => 100, + period => 10}, + {ok, {SupFlag, BridgeSpec}}. + +bridge_spec({Name, Config}) -> + #{id => Name, + start => {emqx_bridge_worker, start_link, [Name, Config]}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [emqx_bridge_worker]}. + +-spec(bridges() -> [{node(), map()}]). +bridges() -> + [{Name, emqx_bridge_worker:status(Pid)} || {Name, Pid, _, _} <- supervisor:which_children(?SUP)]. + +-spec(is_bridge_exist(atom() | pid()) -> boolean()). +is_bridge_exist(Id) -> + case supervisor:get_childspec(?SUP, Id) of + {ok, _ChildSpec} -> true; + {error, _Error} -> false + end. + +create_bridge(Id, Config) -> + supervisor:start_child(?SUP, bridge_spec({Id, Config})). + +drop_bridge(Id) -> + case supervisor:terminate_child(?SUP, Id) of + ok -> + supervisor:delete_child(?SUP, Id); + {error, Error} -> + ?LOG(error, "Delete bridge failed, error : ~p", [Error]), + {error, Error} + end. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_msg.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_msg.erl new file mode 100644 index 000000000..20600282e --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_msg.erl @@ -0,0 +1,98 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_msg). + +-export([ to_binary/1 + , from_binary/1 + , to_export/3 + , to_broker_msgs/1 + , to_broker_msg/1 + , to_broker_msg/2 + , estimate_size/1 + ]). + +-export_type([msg/0]). + +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("emqx_bridge_mqtt/include/emqx_bridge_mqtt.hrl"). +-include_lib("emqtt/include/emqtt.hrl"). + + +-type msg() :: emqx_types:message(). +-type exp_msg() :: emqx_types:message() | #mqtt_msg{}. + +%% @doc Make export format: +%% 1. Mount topic to a prefix +%% 2. Fix QoS to 1 +%% @end +%% Shame that we have to know the callback module here +%% would be great if we can get rid of #mqtt_msg{} record +%% and use #message{} in all places. +-spec to_export(emqx_bridge_rpc | emqx_bridge_worker, + undefined | binary(), msg()) -> exp_msg(). +to_export(emqx_bridge_mqtt, Mountpoint, + #message{topic = Topic, + payload = Payload, + flags = Flags, + qos = QoS + }) -> + Retain = maps:get(retain, Flags, false), + #mqtt_msg{qos = QoS, + retain = Retain, + topic = topic(Mountpoint, Topic), + payload = Payload}; +to_export(_Module, Mountpoint, + #message{topic = Topic} = Msg) -> + Msg#message{topic = topic(Mountpoint, Topic)}. + +%% @doc Make `binary()' in order to make iodata to be persisted on disk. +-spec to_binary(msg()) -> binary(). +to_binary(Msg) -> term_to_binary(Msg). + +%% @doc Unmarshal binary into `msg()'. +-spec from_binary(binary()) -> msg(). +from_binary(Bin) -> binary_to_term(Bin). + +%% @doc Estimate the size of a message. +%% Count only the topic length + payload size +-spec estimate_size(msg()) -> integer(). +estimate_size(#message{topic = Topic, payload = Payload}) -> + size(Topic) + size(Payload). + +%% @doc By message/batch receiver, transform received batch into +%% messages to deliver to local brokers. +to_broker_msgs(Batch) -> lists:map(fun to_broker_msg/1, Batch). + +to_broker_msg(#message{} = Msg) -> + %% internal format from another EMQX node via rpc + Msg; +to_broker_msg(Msg) -> + to_broker_msg(Msg, undefined). +to_broker_msg(#{qos := QoS, dup := Dup, retain := Retain, topic := Topic, + properties := Props, payload := Payload}, Mountpoint) -> + %% published from remote node over a MQTT connection + set_headers(Props, + emqx_message:set_flags(#{dup => Dup, retain => Retain}, + emqx_message:make(bridge, QoS, topic(Mountpoint, Topic), Payload))). + +set_headers(undefined, Msg) -> + Msg; +set_headers(Val, Msg) -> + emqx_message:set_headers(Val, Msg). +topic(undefined, Topic) -> Topic; +topic(Prefix, Topic) -> emqx_topic:prepend(Prefix, Topic). diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_rpc.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_rpc.erl new file mode 100644 index 000000000..4226137a1 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_rpc.erl @@ -0,0 +1,100 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc This module implements EMQX Bridge transport layer based on gen_rpc. + +-module(emqx_bridge_rpc). + +-behaviour(emqx_bridge_connect). + +%% behaviour callbacks +-export([ start/1 + , send/2 + , stop/1 + ]). + +%% Internal exports +-export([ handle_send/1 + , heartbeat/2 + ]). + +-type ack_ref() :: emqx_bridge_worker:ack_ref(). +-type batch() :: emqx_bridge_worker:batch(). +-type node_or_tuple() :: atom() | {atom(), term()}. + +-define(HEARTBEAT_INTERVAL, timer:seconds(1)). + +-define(RPC, emqx_rpc). + +start(#{address := Remote}) -> + case poke(Remote) of + ok -> + Pid = proc_lib:spawn_link(?MODULE, heartbeat, [self(), Remote]), + {ok, #{client_pid => Pid, address => Remote}}; + Error -> + Error + end. + +stop(#{client_pid := Pid}) when is_pid(Pid) -> + Ref = erlang:monitor(process, Pid), + unlink(Pid), + Pid ! stop, + receive + {'DOWN', Ref, process, Pid, _Reason} -> + ok + after + 1000 -> + exit(Pid, kill) + end, + ok. + +%% @doc Callback for `emqx_bridge_connect' behaviour +-spec send(#{address := node_or_tuple(), _ => _}, batch()) -> {ok, ack_ref()} | {error, any()}. +send(#{address := Remote}, Batch) -> + case ?RPC:call(Remote, ?MODULE, handle_send, [Batch]) of + ok -> + Ref = make_ref(), + self() ! {batch_ack, Ref}, + {ok, Ref}; + {badrpc, Reason} -> {error, Reason} + end. + +%% @doc Handle send on receiver side. +-spec handle_send(batch()) -> ok. +handle_send(Batch) -> + lists:foreach(fun(Msg) -> emqx_broker:publish(Msg) end, Batch). + +%% @hidden Heartbeat loop +heartbeat(Parent, RemoteNode) -> + Interval = ?HEARTBEAT_INTERVAL, + receive + stop -> exit(normal) + after + Interval -> + case poke(RemoteNode) of + ok -> + ?MODULE:heartbeat(Parent, RemoteNode); + {error, Reason} -> + Parent ! {disconnected, self(), Reason}, + exit(normal) + end + end. + +poke(Node) -> + case ?RPC:call(Node, erlang, node, []) of + Node -> ok; + {badrpc, Reason} -> {error, Reason} + end. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl new file mode 100644 index 000000000..95b3aa861 --- /dev/null +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl @@ -0,0 +1,598 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc Bridge works in two layers (1) batching layer (2) transport layer +%% The `bridge' batching layer collects local messages in batches and sends over +%% to remote MQTT node/cluster via `connection' transport layer. +%% In case `REMOTE' is also an EMQX node, `connection' is recommended to be +%% the `gen_rpc' based implementation `emqx_bridge_rpc'. Otherwise `connection' +%% has to be `emqx_bridge_mqtt'. +%% +%% ``` +%% +------+ +--------+ +%% | EMQX | | REMOTE | +%% | | | | +%% | (bridge) <==(connection)==> | | +%% | | | | +%% | | | | +%% +------+ +--------+ +%% ''' +%% +%% +%% This module implements 2 kinds of APIs with regards to batching and +%% messaging protocol. (1) A `gen_statem' based local batch collector; +%% (2) APIs for incoming remote batches/messages. +%% +%% Batch collector state diagram +%% +%% [idle] --(0) --> [connecting] --(2)--> [connected] +%% | ^ | +%% | | | +%% '--(1)---'--------(3)------' +%% +%% (0): auto or manual start +%% (1): retry timeout +%% (2): successfuly connected to remote node/cluster +%% (3): received {disconnected, Reason} OR +%% failed to send to remote node/cluster. +%% +%% NOTE: A bridge worker may subscribe to multiple (including wildcard) +%% local topics, and the underlying `emqx_bridge_connect' may subscribe to +%% multiple remote topics, however, worker/connections are not designed +%% to support automatic load-balancing, i.e. in case it can not keep up +%% with the amount of messages comming in, administrator should split and +%% balance topics between worker/connections manually. +%% +%% NOTES: +%% * Local messages are all normalised to QoS-1 when exporting to remote + +-module(emqx_bridge_worker). +-behaviour(gen_statem). + +%% APIs +-export([ start_link/1 + , start_link/2 + , register_metrics/0 + , stop/1 + ]). + +%% gen_statem callbacks +-export([ terminate/3 + , code_change/4 + , init/1 + , callback_mode/0 + ]). + +%% state functions +-export([ idle/3 + , connected/3 + ]). + +%% management APIs +-export([ ensure_started/1 + , ensure_stopped/1 + , ensure_stopped/2 + , status/1 + ]). + +-export([ get_forwards/1 + , ensure_forward_present/2 + , ensure_forward_absent/2 + ]). + +-export([ get_subscriptions/1 + , ensure_subscription_present/3 + , ensure_subscription_absent/2 + ]). + +%% Internal +-export([msg_marshaller/1]). + +-export_type([ config/0 + , batch/0 + , ack_ref/0 + ]). + +-type id() :: atom() | string() | pid(). +-type qos() :: emqx_mqtt_types:qos(). +-type config() :: map(). +-type batch() :: [emqx_bridge_msg:exp_msg()]. +-type ack_ref() :: term(). +-type topic() :: emqx_topic:topic(). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-logger_header("[Bridge]"). + +%% same as default in-flight limit for emqtt +-define(DEFAULT_BATCH_SIZE, 32). +-define(DEFAULT_RECONNECT_DELAY_MS, timer:seconds(5)). +-define(DEFAULT_SEG_BYTES, (1 bsl 20)). +-define(DEFAULT_MAX_TOTAL_SIZE, (1 bsl 31)). +-define(NO_BRIDGE_HANDLER, undefined). + +%% @doc Start a bridge worker. Supported configs: +%% start_type: 'manual' (default) or 'auto', when manual, bridge will stay +%% at 'idle' state until a manual call to start it. +%% connect_module: The module which implements emqx_bridge_connect behaviour +%% and work as message batch transport layer +%% reconnect_delay_ms: Delay in milli-seconds for the bridge worker to retry +%% in case of transportation failure. +%% max_inflight: Max number of batches allowed to send-ahead before receiving +%% confirmation from remote node/cluster +%% mountpoint: The topic mount point for messages sent to remote node/cluster +%% `undefined', `<<>>' or `""' to disable +%% forwards: Local topics to subscribe. +%% queue.batch_bytes_limit: Max number of bytes to collect in a batch for each +%% send call towards emqx_bridge_connect +%% queue.batch_count_limit: Max number of messages to collect in a batch for +%% each send call towards emqx_bridge_connect +%% queue.replayq_dir: Directory where replayq should persist messages +%% queue.replayq_seg_bytes: Size in bytes for each replayq segment file +%% +%% Find more connection specific configs in the callback modules +%% of emqx_bridge_connect behaviour. +start_link(Config) when is_list(Config) -> + start_link(maps:from_list(Config)); +start_link(Config) -> + gen_statem:start_link(?MODULE, Config, []). + +start_link(Name, Config) when is_list(Config) -> + start_link(Name, maps:from_list(Config)); +start_link(Name, Config) -> + Name1 = name(Name), + gen_statem:start_link({local, Name1}, ?MODULE, Config#{name => Name1}, []). + +ensure_started(Name) -> + gen_statem:call(name(Name), ensure_started). + +%% @doc Manually stop bridge worker. State idempotency ensured. +ensure_stopped(Id) -> + ensure_stopped(Id, 1000). + +ensure_stopped(Id, Timeout) -> + Pid = case id(Id) of + P when is_pid(P) -> P; + N -> whereis(N) + end, + case Pid of + undefined -> + ok; + _ -> + MRef = monitor(process, Pid), + unlink(Pid), + _ = gen_statem:call(id(Id), ensure_stopped, Timeout), + receive + {'DOWN', MRef, _, _, _} -> + ok + after + Timeout -> + exit(Pid, kill) + end + end. + +stop(Pid) -> gen_statem:stop(Pid). + +status(Pid) when is_pid(Pid) -> + gen_statem:call(Pid, status); +status(Id) -> + gen_statem:call(name(Id), status). + +%% @doc Return all forwards (local subscriptions). +-spec get_forwards(id()) -> [topic()]. +get_forwards(Id) -> gen_statem:call(id(Id), get_forwards, timer:seconds(1000)). + +%% @doc Return all subscriptions (subscription over mqtt connection to remote broker). +-spec get_subscriptions(id()) -> [{emqx_topic:topic(), qos()}]. +get_subscriptions(Id) -> gen_statem:call(id(Id), get_subscriptions). + +%% @doc Add a new forward (local topic subscription). +-spec ensure_forward_present(id(), topic()) -> ok. +ensure_forward_present(Id, Topic) -> + gen_statem:call(id(Id), {ensure_present, forwards, topic(Topic)}). + +%% @doc Ensure a forward topic is deleted. +-spec ensure_forward_absent(id(), topic()) -> ok. +ensure_forward_absent(Id, Topic) -> + gen_statem:call(id(Id), {ensure_absent, forwards, topic(Topic)}). + +%% @doc Ensure subscribed to remote topic. +%% NOTE: only applicable when connection module is emqx_bridge_mqtt +%% return `{error, no_remote_subscription_support}' otherwise. +-spec ensure_subscription_present(id(), topic(), qos()) -> ok | {error, any()}. +ensure_subscription_present(Id, Topic, QoS) -> + gen_statem:call(id(Id), {ensure_present, subscriptions, {topic(Topic), QoS}}). + +%% @doc Ensure unsubscribed from remote topic. +%% NOTE: only applicable when connection module is emqx_bridge_mqtt +-spec ensure_subscription_absent(id(), topic()) -> ok. +ensure_subscription_absent(Id, Topic) -> + gen_statem:call(id(Id), {ensure_absent, subscriptions, topic(Topic)}). + +callback_mode() -> [state_functions]. + +%% @doc Config should be a map(). +init(Config) -> + erlang:process_flag(trap_exit, true), + ConnectModule = maps:get(connect_module, Config), + Subscriptions = maps:get(subscriptions, Config, []), + Forwards = maps:get(forwards, Config, []), + Queue = open_replayq(Config), + State = init_opts(Config), + Topics = [iolist_to_binary(T) || T <- Forwards], + Subs = check_subscriptions(Subscriptions), + ConnectCfg = get_conn_cfg(Config), + self() ! idle, + {ok, idle, State#{connect_module => ConnectModule, + connect_cfg => ConnectCfg, + forwards => Topics, + subscriptions => Subs, + replayq => Queue + }}. + +init_opts(Config) -> + IfRecordMetrics = maps:get(if_record_metrics, Config, true), + ReconnDelayMs = maps:get(reconnect_delay_ms, Config, ?DEFAULT_RECONNECT_DELAY_MS), + StartType = maps:get(start_type, Config, manual), + BridgeHandler = maps:get(bridge_handler, Config, ?NO_BRIDGE_HANDLER), + Mountpoint = maps:get(forward_mountpoint, Config, undefined), + ReceiveMountpoint = maps:get(receive_mountpoint, Config, undefined), + MaxInflightSize = maps:get(max_inflight, Config, ?DEFAULT_BATCH_SIZE), + BatchSize = maps:get(batch_size, Config, ?DEFAULT_BATCH_SIZE), + Name = maps:get(name, Config, undefined), + #{start_type => StartType, + reconnect_delay_ms => ReconnDelayMs, + batch_size => BatchSize, + mountpoint => format_mountpoint(Mountpoint), + receive_mountpoint => ReceiveMountpoint, + inflight => [], + max_inflight => MaxInflightSize, + connection => undefined, + bridge_handler => BridgeHandler, + if_record_metrics => IfRecordMetrics, + name => Name}. + +open_replayq(Config) -> + QCfg = maps:get(queue, Config, #{}), + Dir = maps:get(replayq_dir, QCfg, undefined), + SegBytes = maps:get(replayq_seg_bytes, QCfg, ?DEFAULT_SEG_BYTES), + MaxTotalSize = maps:get(max_total_size, QCfg, ?DEFAULT_MAX_TOTAL_SIZE), + QueueConfig = case Dir =:= undefined orelse Dir =:= "" of + true -> #{mem_only => true}; + false -> #{dir => Dir, seg_bytes => SegBytes, max_total_size => MaxTotalSize} + end, + replayq:open(QueueConfig#{sizer => fun emqx_bridge_msg:estimate_size/1, + marshaller => fun ?MODULE:msg_marshaller/1}). + +check_subscriptions(Subscriptions) -> + lists:map(fun({Topic, QoS}) -> + Topic1 = iolist_to_binary(Topic), + true = emqx_topic:validate({filter, Topic1}), + {Topic1, QoS} + end, Subscriptions). + +get_conn_cfg(Config) -> + maps:without([connect_module, + queue, + reconnect_delay_ms, + forwards, + mountpoint, + name + ], Config). + +code_change(_Vsn, State, Data, _Extra) -> + {ok, State, Data}. + +terminate(_Reason, _StateName, #{replayq := Q} = State) -> + _ = disconnect(State), + _ = replayq:close(Q), + ok. + +%% ensure_started will be deprecated in the future +idle({call, From}, ensure_started, State) -> + case do_connect(State) of + {ok, State1} -> + {next_state, connected, State1, [{reply, From, ok}, {state_timeout, 0, connected}]}; + {error, Reason, _State} -> + {keep_state_and_data, [{reply, From, {error, Reason}}]} + end; +%% @doc Standing by for manual start. +idle(info, idle, #{start_type := manual}) -> + keep_state_and_data; +%% @doc Standing by for auto start. +idle(info, idle, #{start_type := auto} = State) -> + connecting(State); +idle(state_timeout, reconnect, State) -> + connecting(State); + +idle(info, {batch_ack, Ref}, State) -> + {ok, NewState} = do_ack(State, Ref), + {keep_state, NewState}; + +idle(Type, Content, State) -> + common(idle, Type, Content, State). + +connecting(#{reconnect_delay_ms := ReconnectDelayMs} = State) -> + case do_connect(State) of + {ok, State1} -> + {next_state, connected, State1, {state_timeout, 0, connected}}; + _ -> + {keep_state_and_data, {state_timeout, ReconnectDelayMs, reconnect}} + end. + +connected(state_timeout, connected, #{inflight := Inflight} = State) -> + case retry_inflight(State, Inflight) of + {ok, NewState} -> + {keep_state, NewState, {next_event, internal, maybe_send}}; + {error, NewState} -> + {keep_state, NewState} + end; +connected(internal, maybe_send, State) -> + {_, NewState} = pop_and_send(State), + {keep_state, NewState}; + +connected(info, {disconnected, Conn, Reason}, + #{connection := Connection, name := Name, reconnect_delay_ms := ReconnectDelayMs} = State) -> + case Conn =:= maps:get(client_pid, Connection, undefined) of + true -> + ?LOG(info, "Bridge ~p diconnected~nreason=~p", [Name, Reason]), + {next_state, idle, State#{connection => undefined}, {state_timeout, ReconnectDelayMs, reconnect}}; + false -> + keep_state_and_data + end; +connected(info, {batch_ack, Ref}, State) -> + {ok, NewState} = do_ack(State, Ref), + {keep_state, NewState, {next_event, internal, maybe_send}}; +connected(Type, Content, State) -> + common(connected, Type, Content, State). + +%% Common handlers +common(StateName, {call, From}, status, _State) -> + {keep_state_and_data, [{reply, From, StateName}]}; +common(_StateName, {call, From}, ensure_started, _State) -> + {keep_state_and_data, [{reply, From, connected}]}; +common(_StateName, {call, From}, ensure_stopped, _State) -> + {stop_and_reply, {shutdown, manual}, [{reply, From, ok}]}; +common(_StateName, {call, From}, get_forwards, #{forwards := Forwards}) -> + {keep_state_and_data, [{reply, From, Forwards}]}; +common(_StateName, {call, From}, get_subscriptions, #{subscriptions := Subs}) -> + {keep_state_and_data, [{reply, From, Subs}]}; +common(_StateName, {call, From}, {ensure_present, What, Topic}, State) -> + {Result, NewState} = ensure_present(What, Topic, State), + {keep_state, NewState, [{reply, From, Result}]}; +common(_StateName, {call, From}, {ensure_absent, What, Topic}, State) -> + {Result, NewState} = ensure_absent(What, Topic, State), + {keep_state, NewState, [{reply, From, Result}]}; +common(_StateName, info, {deliver, _, Msg}, + State = #{replayq := Q, if_record_metrics := IfRecordMetric}) -> + Msgs = collect([Msg]), + bridges_metrics_inc(IfRecordMetric, + 'bridge.mqtt.message_received', + length(Msgs) + ), + NewQ = replayq:append(Q, Msgs), + {keep_state, State#{replayq => NewQ}, {next_event, internal, maybe_send}}; +common(_StateName, info, {'EXIT', _, _}, State) -> + {keep_state, State}; +common(StateName, Type, Content, #{name := Name} = State) -> + ?LOG(notice, "Bridge ~p discarded ~p type event at state ~p:~p", + [Name, Type, StateName, Content]), + {keep_state, State}. + +eval_bridge_handler(State = #{bridge_handler := ?NO_BRIDGE_HANDLER}, _Msg) -> + State; +eval_bridge_handler(State = #{bridge_handler := Handler}, Msg) -> + Handler(Msg), + State. + +ensure_present(Key, Topic, State) -> + Topics = maps:get(Key, State), + case is_topic_present(Topic, Topics) of + true -> + {ok, State}; + false -> + R = do_ensure_present(Key, Topic, State), + {R, State#{Key := lists:usort([Topic | Topics])}} + end. + +ensure_absent(Key, Topic, State) -> + Topics = maps:get(Key, State), + case is_topic_present(Topic, Topics) of + true -> + R = do_ensure_absent(Key, Topic, State), + {R, State#{Key := ensure_topic_absent(Topic, Topics)}}; + false -> + {ok, State} + end. + +ensure_topic_absent(_Topic, []) -> []; +ensure_topic_absent(Topic, [{_, _} | _] = L) -> lists:keydelete(Topic, 1, L); +ensure_topic_absent(Topic, L) -> lists:delete(Topic, L). + +is_topic_present({Topic, _QoS}, Topics) -> + is_topic_present(Topic, Topics); +is_topic_present(Topic, Topics) -> + lists:member(Topic, Topics) orelse false =/= lists:keyfind(Topic, 1, Topics). + +do_connect(#{forwards := Forwards, + subscriptions := Subs, + connect_module := ConnectModule, + connect_cfg := ConnectCfg, + name := Name} = State) -> + ok = subscribe_local_topics(Forwards, Name), + case emqx_bridge_connect:start(ConnectModule, ConnectCfg#{subscriptions => Subs}) of + {ok, Conn} -> + ?LOG(info, "Bridge ~p is connecting......", [Name]), + {ok, eval_bridge_handler(State#{connection => Conn}, connected)}; + {error, Reason} -> + {error, Reason, State} + end. + +do_ensure_present(forwards, Topic, #{name := Name}) -> + subscribe_local_topic(Topic, Name); +do_ensure_present(subscriptions, _Topic, #{connection := undefined}) -> + {error, no_connection}; +do_ensure_present(subscriptions, _Topic, #{connect_module := emqx_bridge_rpc}) -> + {error, no_remote_subscription_support}; +do_ensure_present(subscriptions, {Topic, QoS}, #{connect_module := ConnectModule, + connection := Conn}) -> + ConnectModule:ensure_subscribed(Conn, Topic, QoS). + +do_ensure_absent(forwards, Topic, _) -> + do_unsubscribe(Topic); +do_ensure_absent(subscriptions, _Topic, #{connection := undefined}) -> + {error, no_connection}; +do_ensure_absent(subscriptions, _Topic, #{connect_module := emqx_bridge_rpc}) -> + {error, no_remote_subscription_support}; +do_ensure_absent(subscriptions, Topic, #{connect_module := ConnectModule, + connection := Conn}) -> + ConnectModule:ensure_unsubscribed(Conn, Topic). + +collect(Acc) -> + receive + {deliver, _, Msg} -> + collect([Msg | Acc]) + after + 0 -> + lists:reverse(Acc) + end. + +%% Retry all inflight (previously sent but not acked) batches. +retry_inflight(State, []) -> {ok, State}; +retry_inflight(State, [#{q_ack_ref := QAckRef, batch := Batch} | Inflight]) -> + case do_send(State#{inflight := Inflight}, QAckRef, Batch) of + {ok, State1} -> retry_inflight(State1, Inflight); + {error, State1} -> {error, State1} + end. + +pop_and_send(#{inflight := Inflight, max_inflight := Max } = State) when length(Inflight) >= Max -> + {ok, State}; +pop_and_send(#{replayq := Q, connect_module := Module} = State) -> + case replayq:is_empty(Q) of + true -> + {ok, State}; + false -> + BatchSize = case Module of + emqx_bridge_rpc -> maps:get(batch_size, State); + _ -> 1 + end, + Opts = #{count_limit => BatchSize, bytes_limit => 999999999}, + {Q1, QAckRef, Batch} = replayq:pop(Q, Opts), + do_send(State#{replayq := Q1}, QAckRef, Batch) + end. + +%% Assert non-empty batch because we have a is_empty check earlier. +do_send(#{inflight := Inflight, + connect_module := Module, + connection := Connection, + mountpoint := Mountpoint, + if_record_metrics := IfRecordMetrics} = State, QAckRef, Batch) -> + ExportMsg = fun(Message) -> + bridges_metrics_inc(IfRecordMetrics, 'bridge.mqtt.message_sent'), + emqx_bridge_msg:to_export(Module, Mountpoint, Message) + end, + case Module:send(Connection, [ExportMsg(M) || M <- Batch]) of + {ok, Ref} -> + {ok, State#{inflight := Inflight ++ [#{q_ack_ref => QAckRef, + send_ack_ref => Ref, + batch => Batch}]}}; + {error, Reason} -> + ?LOG(info, "Batch produce failed~p", [Reason]), + {error, State} + end. + + +do_ack(#{inflight := []} = State, Ref) -> + ?LOG(error, "Can't be found from the inflight:~p", [Ref]), + {ok, State}; + +do_ack(#{inflight := [#{send_ack_ref := Ref, + q_ack_ref := QAckRef}| Rest], replayq := Q} = State, Ref) -> + ok = replayq:ack(Q, QAckRef), + {ok, State#{inflight => Rest}}; + +do_ack(#{inflight := [#{q_ack_ref := QAckRef, + batch := Batch}| Rest], replayq := Q} = State, Ref) -> + ok = replayq:ack(Q, QAckRef), + NewQ = replayq:append(Q, Batch), + do_ack(State#{replayq => NewQ, inflight => Rest}, Ref). + +subscribe_local_topics(Topics, Name) -> + lists:foreach(fun(Topic) -> subscribe_local_topic(Topic, Name) end, Topics). + +subscribe_local_topic(Topic, Name) -> + do_subscribe(Topic, Name). + +topic(T) -> iolist_to_binary(T). + +validate(RawTopic) -> + Topic = topic(RawTopic), + try emqx_topic:validate(Topic) of + _Success -> Topic + catch + error:Reason -> + error({bad_topic, Topic, Reason}) + end. + +do_subscribe(RawTopic, Name) -> + TopicFilter = validate(RawTopic), + {Topic, SubOpts} = emqx_topic:parse(TopicFilter, #{qos => ?QOS_1}), + emqx_broker:subscribe(Topic, Name, SubOpts). + +do_unsubscribe(RawTopic) -> + TopicFilter = validate(RawTopic), + {Topic, _SubOpts} = emqx_topic:parse(TopicFilter), + emqx_broker:unsubscribe(Topic). + +disconnect(#{connection := Conn, + connect_module := Module + } = State) when Conn =/= undefined -> + Module:stop(Conn), + State0 = State#{connection => undefined}, + eval_bridge_handler(State0, disconnected); +disconnect(State) -> + eval_bridge_handler(State, disconnected). + +%% Called only when replayq needs to dump it to disk. +msg_marshaller(Bin) when is_binary(Bin) -> emqx_bridge_msg:from_binary(Bin); +msg_marshaller(Msg) -> emqx_bridge_msg:to_binary(Msg). + +format_mountpoint(undefined) -> + undefined; +format_mountpoint(Prefix) -> + binary:replace(iolist_to_binary(Prefix), <<"${node}">>, atom_to_binary(node(), utf8)). + +name(Id) -> list_to_atom(lists:concat([?MODULE, "_", Id])). + +id(Pid) when is_pid(Pid) -> Pid; +id(Name) -> name(Name). + +register_metrics() -> + lists:foreach(fun emqx_metrics:ensure/1, + ['bridge.mqtt.message_sent', + 'bridge.mqtt.message_received' + ]). + +bridges_metrics_inc(true, Metric) -> + emqx_metrics:inc(Metric); +bridges_metrics_inc(_IsRecordMetric, _Metric) -> + ok. + +bridges_metrics_inc(true, Metric, Value) -> + emqx_metrics:inc(Metric, Value); +bridges_metrics_inc(_IsRecordMetric, _Metric, _Value) -> + ok. diff --git a/apps/emqx_bridge_mqtt/test/emqx_bridge_mqtt_tests.erl b/apps/emqx_bridge_mqtt/test/emqx_bridge_mqtt_tests.erl new file mode 100644 index 000000000..392fa8e17 --- /dev/null +++ b/apps/emqx_bridge_mqtt/test/emqx_bridge_mqtt_tests.erl @@ -0,0 +1,47 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_mqtt_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +send_and_ack_test() -> + %% delegate from gen_rpc to rpc for unit test + meck:new(emqtt, [passthrough, no_history]), + meck:expect(emqtt, start_link, 1, + fun(_) -> + {ok, spawn_link(fun() -> ok end)} + end), + meck:expect(emqtt, connect, 1, {ok, dummy}), + meck:expect(emqtt, stop, 1, + fun(Pid) -> Pid ! stop end), + meck:expect(emqtt, publish, 2, + fun(Client, Msg) -> + Client ! {publish, Msg}, + {ok, Msg} %% as packet id + end), + try + Max = 1, + Batch = lists:seq(1, Max), + {ok, Conn} = emqx_bridge_mqtt:start(#{address => "127.0.0.1:1883"}), + % %% return last packet id as batch reference + {ok, _AckRef} = emqx_bridge_mqtt:send(Conn, Batch), + + ok = emqx_bridge_mqtt:stop(Conn) + after + meck:unload(emqtt) + end. \ No newline at end of file diff --git a/apps/emqx_bridge_mqtt/test/emqx_bridge_rpc_tests.erl b/apps/emqx_bridge_mqtt/test/emqx_bridge_rpc_tests.erl new file mode 100644 index 000000000..f79d12dfb --- /dev/null +++ b/apps/emqx_bridge_mqtt/test/emqx_bridge_rpc_tests.erl @@ -0,0 +1,42 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_rpc_tests). +-include_lib("eunit/include/eunit.hrl"). + +send_and_ack_test() -> + %% delegate from emqx_rpc to rpc for unit test + meck:new(emqx_rpc, [passthrough, no_history]), + meck:expect(emqx_rpc, call, 4, + fun(Node, Module, Fun, Args) -> + rpc:call(Node, Module, Fun, Args) + end), + meck:expect(emqx_rpc, cast, 4, + fun(Node, Module, Fun, Args) -> + rpc:cast(Node, Module, Fun, Args) + end), + meck:new(emqx_bridge_worker, [passthrough, no_history]), + try + {ok, #{client_pid := Pid, address := Node}} = emqx_bridge_rpc:start(#{address => node()}), + {ok, Ref} = emqx_bridge_rpc:send(#{address => Node}, []), + receive + {batch_ack, Ref} -> + ok + end, + ok = emqx_bridge_rpc:stop( #{client_pid => Pid}) + after + meck:unload(emqx_rpc) + end. diff --git a/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_SUITE.erl b/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_SUITE.erl new file mode 100644 index 000000000..7cbf0987c --- /dev/null +++ b/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_SUITE.erl @@ -0,0 +1,176 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_worker_SUITE). + +-export([ all/0 + , init_per_suite/1 + , end_per_suite/1]). +-export([ t_rpc/1 + , t_mqtt/1 + , t_mngr/1]). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-define(wait(For, Timeout), emqx_ct_helpers:wait_for(?FUNCTION_NAME, ?LINE, fun() -> For end, Timeout)). + +receive_messages(Count) -> + receive_messages(Count, []). + +receive_messages(0, Msgs) -> + Msgs; +receive_messages(Count, Msgs) -> + receive + {publish, Msg} -> + receive_messages(Count-1, [Msg|Msgs]); + _Other -> + receive_messages(Count, Msgs) + after 1000 -> + Msgs + end. + +all() -> [ t_rpc + , t_mqtt + , t_mngr + ]. + +init_per_suite(Config) -> + case node() of + nonode@nohost -> net_kernel:start(['emqx@127.0.0.1', longnames]); + _ -> ok + end, + ok = application:set_env(gen_rpc, tcp_client_num, 1), + emqx_ct_helpers:start_apps([emqx_bridge_mqtt]), + emqx_logger:set_log_level(error), + [{log_level, error} | Config]. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_bridge_mqtt]). + +t_mngr(Config) when is_list(Config) -> + Subs = [{<<"a">>, 1}, {<<"b">>, 2}], + Cfg = #{address => node(), + forwards => [<<"mngr">>], + connect_module => emqx_bridge_rpc, + mountpoint => <<"forwarded">>, + subscriptions => Subs, + start_type => auto}, + Name = ?FUNCTION_NAME, + {ok, Pid} = emqx_bridge_worker:start_link(Name, Cfg), + try + ?assertEqual([<<"mngr">>], emqx_bridge_worker:get_forwards(Name)), + ?assertEqual(ok, emqx_bridge_worker:ensure_forward_present(Name, "mngr")), + ?assertEqual(ok, emqx_bridge_worker:ensure_forward_present(Name, "mngr2")), + ?assertEqual([<<"mngr">>, <<"mngr2">>], emqx_bridge_worker:get_forwards(Pid)), + ?assertEqual(ok, emqx_bridge_worker:ensure_forward_absent(Name, "mngr2")), + ?assertEqual(ok, emqx_bridge_worker:ensure_forward_absent(Name, "mngr3")), + ?assertEqual([<<"mngr">>], emqx_bridge_worker:get_forwards(Pid)), + ?assertEqual({error, no_remote_subscription_support}, + emqx_bridge_worker:ensure_subscription_present(Pid, <<"t">>, 0)), + ?assertEqual({error, no_remote_subscription_support}, + emqx_bridge_worker:ensure_subscription_absent(Pid, <<"t">>)), + ?assertEqual(Subs, emqx_bridge_worker:get_subscriptions(Pid)) + after + ok = emqx_bridge_worker:stop(Pid) + end. + +%% A loopback RPC to local node +t_rpc(Config) when is_list(Config) -> + Cfg = #{address => node(), + forwards => [<<"t_rpc/#">>], + connect_module => emqx_bridge_rpc, + forward_mountpoint => <<"forwarded">>, + start_type => auto}, + {ok, Pid} = emqx_bridge_worker:start_link(?FUNCTION_NAME, Cfg), + ClientId = <<"ClientId">>, + try + {ok, ConnPid} = emqtt:start_link([{clientid, ClientId}]), + {ok, _Props} = emqtt:connect(ConnPid), + {ok, _Props, [1]} = emqtt:subscribe(ConnPid, {<<"forwarded/t_rpc/one">>, ?QOS_1}), + timer:sleep(100), + {ok, _PacketId} = emqtt:publish(ConnPid, <<"t_rpc/one">>, <<"hello">>, ?QOS_1), + timer:sleep(100), + ?assertEqual(1, length(receive_messages(1))), + emqtt:disconnect(ConnPid) + after + ok = emqx_bridge_worker:stop(Pid) + end. + +%% Full data loopback flow explained: +%% mqtt-client ----> local-broker ---(local-subscription)---> +%% bridge(export) --- (mqtt-connection)--> local-broker ---(remote-subscription) --> +%% bridge(import) --> mqtt-client +t_mqtt(Config) when is_list(Config) -> + SendToTopic = <<"t_mqtt/one">>, + SendToTopic2 = <<"t_mqtt/two">>, + SendToTopic3 = <<"t_mqtt/three">>, + Mountpoint = <<"forwarded/${node}/">>, + Cfg = #{address => "127.0.0.1:1883", + forwards => [SendToTopic], + connect_module => emqx_bridge_mqtt, + forward_mountpoint => Mountpoint, + username => "user", + clean_start => true, + clientid => "bridge_aws", + keepalive => 60000, + password => "passwd", + proto_ver => mqttv4, + queue => #{replayq_dir => "data/t_mqtt/", + replayq_seg_bytes => 10000, + batch_bytes_limit => 1000, + batch_count_limit => 10 + }, + reconnect_delay_ms => 1000, + ssl => false, + %% Consume back to forwarded message for verification + %% NOTE: this is a indefenite loopback without mocking emqx_bridge_worker:import_batch/1 + subscriptions => [{SendToTopic2, _QoS = 1}], + receive_mountpoint => <<"receive/aws/">>, + start_type => auto}, + {ok, Pid} = emqx_bridge_worker:start_link(?FUNCTION_NAME, Cfg), + ClientId = <<"client-1">>, + try + ?assertEqual([{SendToTopic2, 1}], emqx_bridge_worker:get_subscriptions(Pid)), + ok = emqx_bridge_worker:ensure_subscription_present(Pid, SendToTopic3, _QoS = 1), + ?assertEqual([{SendToTopic3, 1},{SendToTopic2, 1}], + emqx_bridge_worker:get_subscriptions(Pid)), + {ok, ConnPid} = emqtt:start_link([{clientid, ClientId}]), + {ok, _Props} = emqtt:connect(ConnPid), + emqtt:subscribe(ConnPid, <<"forwarded/+/t_mqtt/one">>, 1), + %% message from a different client, to avoid getting terminated by no-local + Max = 10, + Msgs = lists:seq(1, Max), + lists:foreach(fun(I) -> + {ok, _PacketId} = emqtt:publish(ConnPid, SendToTopic, integer_to_binary(I), ?QOS_1) + end, Msgs), + ?assertEqual(10, length(receive_messages(200))), + + emqtt:subscribe(ConnPid, <<"receive/aws/t_mqtt/two">>, 1), + %% message from a different client, to avoid getting terminated by no-local + Max = 10, + Msgs = lists:seq(1, Max), + lists:foreach(fun(I) -> + {ok, _PacketId} = emqtt:publish(ConnPid, SendToTopic2, integer_to_binary(I), ?QOS_1) + end, Msgs), + ?assertEqual(10, length(receive_messages(200))), + + emqtt:disconnect(ConnPid) + after + ok = emqx_bridge_worker:stop(Pid) + end. diff --git a/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_tests.erl b/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_tests.erl new file mode 100644 index 000000000..1e2dfda1b --- /dev/null +++ b/apps/emqx_bridge_mqtt/test/emqx_bridge_worker_tests.erl @@ -0,0 +1,134 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_worker_tests). +-behaviour(emqx_bridge_connect). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-define(BRIDGE_NAME, test). +-define(BRIDGE_REG_NAME, emqx_bridge_worker_test). +-define(WAIT(PATTERN, TIMEOUT), + receive + PATTERN -> + ok + after + TIMEOUT -> + error(timeout) + end). + +%% stub callbacks +-export([start/1, send/2, stop/1]). + +start(#{connect_result := Result, test_pid := Pid, test_ref := Ref}) -> + case is_pid(Pid) of + true -> Pid ! {connection_start_attempt, Ref}; + false -> ok + end, + Result. + +send(SendFun, Batch) when is_function(SendFun, 2) -> + SendFun(Batch). + +stop(_Pid) -> ok. + +%% bridge worker should retry connecting remote node indefinitely +% reconnect_test() -> +% emqx_metrics:start_link(), +% emqx_bridge_worker:register_metrics(), +% Ref = make_ref(), +% Config = make_config(Ref, self(), {error, test}), +% {ok, Pid} = emqx_bridge_worker:start_link(?BRIDGE_NAME, Config), +% %% assert name registered +% ?assertEqual(Pid, whereis(?BRIDGE_REG_NAME)), +% ?WAIT({connection_start_attempt, Ref}, 1000), +% %% expect same message again +% ?WAIT({connection_start_attempt, Ref}, 1000), +% ok = emqx_bridge_worker:stop(?BRIDGE_REG_NAME), +% emqx_metrics:stop(), +% ok. + +%% connect first, disconnect, then connect again +disturbance_test() -> + emqx_metrics:start_link(), + emqx_bridge_worker:register_metrics(), + Ref = make_ref(), + TestPid = self(), + Config = make_config(Ref, TestPid, {ok, #{client_pid => TestPid}}), + {ok, Pid} = emqx_bridge_worker:start_link(?BRIDGE_NAME, Config), + ?assertEqual(Pid, whereis(?BRIDGE_REG_NAME)), + ?WAIT({connection_start_attempt, Ref}, 1000), + Pid ! {disconnected, TestPid, test}, + ?WAIT({connection_start_attempt, Ref}, 1000), + emqx_metrics:stop(), + ok = emqx_bridge_worker:stop(?BRIDGE_REG_NAME). + +% % %% buffer should continue taking in messages when disconnected +% buffer_when_disconnected_test_() -> +% {timeout, 10000, fun test_buffer_when_disconnected/0}. + +% test_buffer_when_disconnected() -> +% Ref = make_ref(), +% Nums = lists:seq(1, 100), +% Sender = spawn_link(fun() -> receive {bridge, Pid} -> sender_loop(Pid, Nums, _Interval = 5) end end), +% SenderMref = monitor(process, Sender), +% Receiver = spawn_link(fun() -> receive {bridge, Pid} -> receiver_loop(Pid, Nums, _Interval = 1) end end), +% ReceiverMref = monitor(process, Receiver), +% SendFun = fun(Batch) -> +% BatchRef = make_ref(), +% Receiver ! {batch, BatchRef, Batch}, +% {ok, BatchRef} +% end, +% Config0 = make_config(Ref, false, {ok, #{client_pid => undefined}}), +% Config = Config0#{reconnect_delay_ms => 100}, +% emqx_metrics:start_link(), +% emqx_bridge_worker:register_metrics(), +% {ok, Pid} = emqx_bridge_worker:start_link(?BRIDGE_NAME, Config), +% Sender ! {bridge, Pid}, +% Receiver ! {bridge, Pid}, +% ?assertEqual(Pid, whereis(?BRIDGE_REG_NAME)), +% Pid ! {disconnected, Ref, test}, +% ?WAIT({'DOWN', SenderMref, process, Sender, normal}, 5000), +% ?WAIT({'DOWN', ReceiverMref, process, Receiver, normal}, 1000), +% ok = emqx_bridge_worker:stop(?BRIDGE_REG_NAME), +% emqx_metrics:stop(). + +manual_start_stop_test() -> + emqx_metrics:start_link(), + emqx_bridge_worker:register_metrics(), + Ref = make_ref(), + TestPid = self(), + Config0 = make_config(Ref, TestPid, {ok, #{client_pid => TestPid}}), + Config = Config0#{start_type := manual}, + {ok, Pid} = emqx_bridge_worker:start_link(?BRIDGE_NAME, Config), + %% call ensure_started again should yeld the same result + ok = emqx_bridge_worker:ensure_started(?BRIDGE_NAME), + ?assertEqual(Pid, whereis(?BRIDGE_REG_NAME)), + emqx_bridge_worker:ensure_stopped(unknown), + emqx_bridge_worker:ensure_stopped(Pid), + emqx_bridge_worker:ensure_stopped(?BRIDGE_REG_NAME), + emqx_metrics:stop(). + +make_config(Ref, TestPid, Result) -> + #{test_pid => TestPid, + test_ref => Ref, + connect_module => ?MODULE, + reconnect_delay_ms => 50, + connect_result => Result, + start_type => auto + }. diff --git a/apps/emqx_coap/.gitignore b/apps/emqx_coap/.gitignore new file mode 100644 index 000000000..67eaa0145 --- /dev/null +++ b/apps/emqx_coap/.gitignore @@ -0,0 +1,25 @@ +deps/ +ebin/ +_rel/ +.erlang.mk/ +*.d +*.o +*.exe +data/ +*.iml +.idea/ +logs/ +*.beam +emqx_coap.d +intergration_test/emqx-rel/ +intergration_test/libcoap/ +intergration_test/case*.txt +.DS_Store +_build/ +rebar.lock +rebar3.crashdump +*.swp +erlang.mk +.rebar3/ +etc/emqx_coap.conf.rendered +.tags* diff --git a/apps/emqx_coap/README.md b/apps/emqx_coap/README.md new file mode 100644 index 000000000..2da7b9fca --- /dev/null +++ b/apps/emqx_coap/README.md @@ -0,0 +1,254 @@ + +# emqx-coap + +emqx-coap is a CoAP Gateway for EMQ X Broker. It translates CoAP messages into MQTT messages and make it possible to communiate between CoAP clients and MQTT clients. + +### Client Usage Example +libcoap is an excellent coap library which has a simple client tool. It is recommended to use libcoap as a coap client. + +To compile libcoap, do following steps: + +``` +git clone http://github.com/obgm/libcoap +cd libcoap +./autogen.sh +./configure --enable-documentation=no --enable-tests=no +make +``` + +### Publish example: +``` +libcoap/examples/coap-client -m put -e 1234 "coap://127.0.0.1/mqtt/topic1?c=client1&u=tom&p=secret" +``` +- topic name is "topic1", NOT "/topic1" +- client id is client1 +- username is tom +- password is secret +- payload is a text string "1234" + +A mqtt message with topic="topic1", payload="1234" has been published. Any mqtt client or coap client, who has subscribed this topic could receive this message immediately. + +### Subscribe example: + +``` +libcoap/examples/coap-client -m get -s 10 "coap://127.0.0.1/mqtt/topic1?c=client1&u=tom&p=secret" +``` +- topic name is "topic1", NOT "/topic1" +- client id is client1 +- username is tom +- password is secret +- subscribe time is 10 seconds + +And you will get following result if any mqtt client or coap client sent message with text "1234567" to "topic1": + +``` +v:1 t:CON c:GET i:31ae {} [ ] +1234567v:1 t:CON c:GET i:31af {} [ Observe:1, Uri-Path:mqtt, Uri-Path:topic1, Uri-Query:c=client1, Uri-Query:u=tom, Uri-Query:p=secret ] +``` +The output message is not well formatted which hide "1234567" at the head of the 2nd line. + +### Configure + +#### Common + +File: etc/emqx_coap.conf + +```properties + +## The UDP port that CoAP is listening on. +## +## Value: Port +coap.port = 5683 + +## Interval for keepalive, specified in seconds. +## +## Value: Duration +## -s: seconds +## -m: minutes +## -h: hours +coap.keepalive = 120s + +## Whether to enable statistics for CoAP clients. +## +## Value: on | off +coap.enable_stats = off + +``` + +#### DTLS + +emqx_coap enable one-way authentication by default. + +If you want to disable it, comment these lines. + +File: etc/emqx_coap.conf + +```properties + +## The DTLS port that CoAP is listening on. +## +## Value: Port +coap.dtls.port = 5684 + +## Private key file for DTLS +## +## Value: File +coap.dtls.keyfile = {{ platform_etc_dir }}/certs/key.pem + +## Server certificate for DTLS. +## +## Value: File +coap.dtls.certfile = {{ platform_etc_dir }}/certs/cert.pem + +``` + +##### Enable two-way autentication + +For two-way autentication: + +```properties + +## A server only does x509-path validation in mode verify_peer, +## as it then sends a certificate request to the client (this +## message is not sent if the verify option is verify_none). +## You can then also want to specify option fail_if_no_peer_cert. +## More information at: http://erlang.org/doc/man/ssl.html +## +## Value: verify_peer | verify_none +## coap.dtls.verify = verify_peer + +## PEM-encoded CA certificates for DTLS +## +## Value: File +## coap.dtls.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem + +## Used together with {verify, verify_peer} by an SSL server. If set to true, +## the server fails if the client does not have a certificate to send, that is, +## sends an empty certificate. +## +## Value: true | false +## coap.dtls.fail_if_no_peer_cert = false + +``` + +### Load emqx-coap + +```bash +./bin/emqx_ctl plugins load emqx_coap +``` + +CoAP Client Observe Operation (subscribe topic) +----------------------------------------------- +To subscribe any topic, issue following command: + +``` + GET coap://localhost/mqtt/{topicname}?c={clientid}&u={username}&p={password} with OBSERVE=0 +``` + +- "mqtt" in the path is mandatory. +- replace {topicname}, {clientid}, {username} and {password} with your true values. +- {topicname} and {clientid} is mandatory. +- if clientid is absent, a "bad_request" will be returned. +- {topicname} in URI should be percent-encoded to prevent special characters, such as + and #. +- {username} and {password} are optional. +- if {username} and {password} are not correct, an uauthorized error will be returned. +- topic is subscribed with qos1. + +CoAP Client Unobserve Operation (unsubscribe topic) +--------------------------------------------------- +To cancel observation, issue following command: + +``` + GET coap://localhost/mqtt/{topicname}?c={clientid}&u={username}&p={password} with OBSERVE=1 +``` + +- "mqtt" in the path is mandatory. +- replace {topicname}, {clientid}, {username} and {password} with your true values. +- {topicname} and {clientid} is mandatory. +- if clientid is absent, a "bad_request" will be returned. +- {topicname} in URI should be percent-encoded to prevent special characters, such as + and #. +- {username} and {password} are optional. +- if {username} and {password} are not correct, an uauthorized error will be returned. + +CoAP Client Notification Operation (subscribed Message) +------------------------------------------------------- +Server will issue an observe-notification as a subscribed message. + +- Its payload is exactly the mqtt payload. +- payload data type is "application/octet-stream". + +CoAP Client Publish Operation +----------------------------- +Issue a coap put command to do publishment. For example: + +``` + PUT coap://localhost/mqtt/{topicname}?c={clientid}&u={username}&p={password} +``` + +- "mqtt" in the path is mandatory. +- replace {topicname}, {clientid}, {username} and {password} with your true values. +- {topicname} and {clientid} is mandatory. +- if clientid is absent, a "bad_request" will be returned. +- {topicname} in URI should be percent-encoded to prevent special characters, such as + and #. +- {username} and {password} are optional. +- if {username} and {password} are not correct, an uauthorized error will be returned. +- payload could be any binary data. +- payload data type is "application/octet-stream". +- publish message will be sent with qos0. + +CoAP Client Keep Alive +---------------------- +Device should issue a get command periodically, serve as a ping to keep mqtt session online. + +``` + GET coap://localhost/mqtt/{any_topicname}?c={clientid}&u={username}&p={password} +``` + +- "mqtt" in the path is mandatory. +- replace {any_topicname}, {clientid}, {username} and {password} with your true values. +- {any_topicname} is optional, and should be percent-encoded to prevent special characters. +- {clientid} is mandatory. If clientid is absent, a "bad_request" will be returned. +- {username} and {password} are optional. +- if {username} and {password} are not correct, an uauthorized error will be returned. +- coap client should do keepalive work periodically to keep mqtt session online, especially those devices in a NAT network. + + +CoAP Client NOTES +----------------- +emqx-coap gateway does not accept POST and DELETE requests. + +Topics in URI should be percent-encoded, but corresponding uri_path option has percent-encoding converted. Please refer to RFC 7252 section 6.4, "Decomposing URIs into Options": + +> Note that these rules completely resolve any percent-encoding. + +That implies coap client is responsible to convert any percert-encoding into true character while assembling coap packet. + + +ClientId, Username, Password and Topic +-------------------------------------- +ClientId/username/password/topic in the coap URI are the concepts in mqtt. That is to say, emqx-coap is trying to fit coap message into mqtt system, by borrowing the client/username/password/topic from mqtt. + +The Auth/ACL/Hook features in mqtt also applies on coap stuff. For example: +- If username/password is not authorized, coap client will get an uauthorized error. +- If username or clientid is not allowed to published specific topic, coap message will be dropped in fact, although coap client will get an acknoledgement from emqx-coap. +- If a coap message is published, a 'message.publish' hook is able to capture this message as well. + +well-known locations +-------------------- +Discovery always return "," + +For example +``` +libcoap/examples/coap-client -m get "coap://127.0.0.1/.well-known/core" +``` + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_coap/TODO b/apps/emqx_coap/TODO new file mode 100644 index 000000000..2af129d6c --- /dev/null +++ b/apps/emqx_coap/TODO @@ -0,0 +1,13 @@ +1. Remove the test/test_mqtt_broker and use emqx-ct-helpers -> Done! + - Enhance all test case + +2. Remove the mqtt adaptor +3. Remove the emqx_coap_ps_topics.erl + + +### Problems + +1. The coap-client of libcoap does not support Fragment DTLS handshake frame + * So, the connection will be established failed, if the 'Server Hello' frame is too big + * Why is the 'Server Hello' too big when enable the 'coap.dtls.cacertfile' option? +2. diff --git a/apps/emqx_coap/docs/rfc7049.pdf b/apps/emqx_coap/docs/rfc7049.pdf new file mode 100644 index 000000000..a16db36ef Binary files /dev/null and b/apps/emqx_coap/docs/rfc7049.pdf differ diff --git a/apps/emqx_coap/docs/rfc7228.pdf b/apps/emqx_coap/docs/rfc7228.pdf new file mode 100644 index 000000000..c9dc1b59f Binary files /dev/null and b/apps/emqx_coap/docs/rfc7228.pdf differ diff --git a/apps/emqx_coap/docs/rfc7252.pdf b/apps/emqx_coap/docs/rfc7252.pdf new file mode 100644 index 000000000..6876fad3e Binary files /dev/null and b/apps/emqx_coap/docs/rfc7252.pdf differ diff --git a/apps/emqx_coap/etc/emqx_coap.conf b/apps/emqx_coap/etc/emqx_coap.conf new file mode 100644 index 000000000..0590a348e --- /dev/null +++ b/apps/emqx_coap/etc/emqx_coap.conf @@ -0,0 +1,82 @@ +##-------------------------------------------------------------------- +## CoAP Gateway +##-------------------------------------------------------------------- + +## The IP and UDP port that CoAP bind with. +## +## Default: 0.0.0.0:5683 +## +## Examples: +## coap.bind.udp.x = 0.0.0.0:5683 | :::5683 | 127.0.0.1:5683 | ::1:5683 +## +coap.bind.udp.1 = 0.0.0.0:5683 +##coap.bind.udp.2 = 0.0.0.0:6683 + +## Whether to enable statistics for CoAP clients. +## +## Value: on | off +coap.enable_stats = off + + +##------------------------------------------------------------------------------ +## DTLS options + +## The DTLS port that CoAP is listening on. +## +## Default: 0.0.0.0:5684 +## +## Examples: +## coap.bind.dtls.x = 0.0.0.0:5684 | :::5684 | 127.0.0.1:5684 | ::1:5684 +## +coap.bind.dtls.1 = 0.0.0.0:5684 +##coap.bind.dtls.2 = 0.0.0.0:6684 + +## A server only does x509-path validation in mode verify_peer, +## as it then sends a certificate request to the client (this +## message is not sent if the verify option is verify_none). +## You can then also want to specify option fail_if_no_peer_cert. +## More information at: http://erlang.org/doc/man/ssl.html +## +## Value: verify_peer | verify_none +## coap.dtls.verify = verify_peer + +## Private key file for DTLS +## +## Value: File +coap.dtls.keyfile = {{ platform_etc_dir }}/certs/key.pem + +## Server certificate for DTLS. +## +## Value: File +coap.dtls.certfile = {{ platform_etc_dir }}/certs/cert.pem + +## PEM-encoded CA certificates for DTLS +## +## Value: File +## coap.dtls.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem + +## Used together with {verify, verify_peer} by an SSL server. If set to true, +## the server fails if the client does not have a certificate to send, that is, +## sends an empty certificate. +## +## Value: true | false +## coap.dtls.fail_if_no_peer_cert = false + +## This is the single most important configuration option of an Erlang SSL +## application. Ciphers (and their ordering) define the way the client and +## server encrypt information over the wire, from the initial Diffie-Helman +## key exchange, the session key encryption ## algorithm and the message +## digest algorithm. Selecting a good cipher suite is critical for the +## application’s data security, confidentiality and performance. +## +## The cipher list above offers: +## +## A good balance between compatibility with older browsers. +## It can get stricter for Machine-To-Machine scenarios. +## Perfect Forward Secrecy. +## No old/insecure encryption and HMAC algorithms +## +## Most of it was copied from Mozilla’s Server Side TLS article +## +## Value: Ciphers +coap.dtls.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA diff --git a/apps/emqx_coap/include/emqx_coap.hrl b/apps/emqx_coap/include/emqx_coap.hrl new file mode 100644 index 000000000..8204dc98c --- /dev/null +++ b/apps/emqx_coap/include/emqx_coap.hrl @@ -0,0 +1,20 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-define(APP, emqx_coap). + +-record(coap_mqtt_auth, {clientid, username, password}). + diff --git a/apps/emqx_coap/intergration_test/Makefile b/apps/emqx_coap/intergration_test/Makefile new file mode 100644 index 000000000..12a2081dd --- /dev/null +++ b/apps/emqx_coap/intergration_test/Makefile @@ -0,0 +1,129 @@ +.PHONY: clean, clean_result, start_broker stop_broker case1 case2 case3 + +RELX_CONF = emqx-rel/relx.config +LIBCOAP_GIT = libcoap/README.md + +all: clean_result $(RELX_CONF) $(LIBCOAP_GIT) start_broker clean_result case1 case2 case3 case4 stop_broker + @echo " " + @echo " test complete" + @echo " " + +clean_result: + -rm -f case*.txt + + +start_broker: + -rm -f emqx-rel/_rel/emqx/log/* + -emqx-rel/_rel/emqx/bin/emqx stop + sleep 1 + emqx-rel/_rel/emqx/bin/emqx start + sleep 1 + emqx-rel/_rel/emqx/bin/emqx_ctl plugins load emqx_coap + +stop_broker: + -emqx-rel/_rel/emqx/bin/emqx stop + +case1: + libcoap/examples/coap-client -m get -s 5 "coap://127.0.0.1/mqtt/topic1?c=client1&u=tom&p=secret" > case1_output.txt & + sleep 1 + libcoap/examples/coap-client -m put -e w123G45 "coap://127.0.0.1/mqtt/topic1?c=client2&u=mike&p=pw12" + sleep 6 + python check_result.py case1 case1_output.txt==w123G45 + +case2: + # subscribe to topic="x/y" + libcoap/examples/coap-client -m get -s 5 "coap://127.0.0.1/mqtt/x%2Fy?c=client3&u=tom&p=secret" > case2_output1.txt & + # subscribe to topic="+/z" + libcoap/examples/coap-client -m get -s 5 "coap://127.0.0.1/mqtt/%2B%2Fz?c=client4&u=mike&p=pw12" > case2_output2.txt & + sleep 1 + # publish to topic="x/y" + libcoap/examples/coap-client -m put -e big9wolf "coap://127.0.0.1/mqtt/x%2Fy?c=client5&u=sun&p=pw3" + # publish to topic="p/z" + libcoap/examples/coap-client -m put -e black2ant "coap://127.0.0.1/mqtt/p%2Fz?c=client5&u=sun&p=pw3" + sleep 6 + python check_result.py case2 case2_output1.txt==big9wolf case2_output1.txt!=black2ant case2_output2.txt!=big9wolf case2_output2.txt==black2ant + +case3: + libcoap/examples/coap-client -m get -T tk12 -s 5 "coap://127.0.0.1/mqtt/a%2Fb?c=client3&u=tom&p=secret" > case3_output1.txt & + libcoap/examples/coap-client -m get -T tk34 -s 5 "coap://127.0.0.1/mqtt/c%2Fd?c=client3&u=tom&p=secret" > case3_output2.txt & + sleep 1 + libcoap/examples/coap-client -m put -e big9wolf "coap://127.0.0.1/mqtt/c%2Fd?c=client5&u=sun&p=pw3" + libcoap/examples/coap-client -m put -e black2ant "coap://127.0.0.1/mqtt/a%2Fb?c=client5&u=sun&p=pw3" + sleep 6 + python check_result.py case3 case3_output1.txt==black2ant case3_output2.txt==big9wolf case3_output2.txt!=black2ant + + + +case4: + # reload emqx_coap, does it work as expected? + sleep 1 + emqx-rel/_rel/emqx/bin/emqx_ctl plugins unload emqx_coap + sleep 1 + emqx-rel/_rel/emqx/bin/emqx_ctl plugins load emqx_coap + sleep 1 + libcoap/examples/coap-client -m get -s 5 "coap://127.0.0.1/mqtt/topic1?c=client1&u=tom&p=secret" > case4_output.txt & + sleep 1 + libcoap/examples/coap-client -m put -e w6J3G45 "coap://127.0.0.1/mqtt/topic1?c=client2&u=mike&p=pw12" + sleep 6 + python check_result.py case4 case4_output.txt==w6J3G45 + + + + +$(RELX_CONF): + git clone https://github.com/emqx/emqx-rel.git + git clone https://github.com/emqx/emq-coap.git + @echo "update emq-coap with this development code" + mv emq-coap emqx_coap + -rm -rf emqx_coap/etc + -rm -rf emqx_coap/include + -rm -rf emqx_coap/priv + -rm -rf emqx_coap/src + -rm -rf emqx_coap/Makefile + cp -rf ../etc emqx_coap/ + cp -rf ../include emqx_coap/ + cp -rf ../priv emqx_coap/ + cp -rf ../src emqx_coap/ + cp -rf ../Makefile emqx_coap/Makefile + -mkdir emqx-rel/deps + mv emqx_coap emqx-rel/deps/ + @echo "start building ..." + make -C emqx-rel -f Makefile + + +coap: $(LIBCOAP_GIT) + @echo "make coap" + +$(LIBCOAP_GIT): + git clone -b v4.1.2 http://github.com/obgm/libcoap + cd libcoap && ./autogen.sh && ./configure --enable-documentation=no --enable-tests=no + make -C libcoap -f Makefile + +r: rebuild_emq + # r short for rebuild_emq + @echo " rebuild complete " + +rebuild_emq: + -emqx-rel/_rel/emqx/bin/emqx stop + -rm -rf emqx-rel/deps/emqx_coap/etc + -rm -rf emqx-rel/deps/emqx_coap/include + -rm -rf emqx-rel/deps/emqx_coap/priv + -rm -rf emqx-rel/deps/emqx_coap/src + -rm -rf emqx-rel/deps/emqx_coap/Makefile + cp -rf ../etc emqx-rel/deps/emqx_coap/ + cp -rf ../include emqx-rel/deps/emqx_coap/ + cp -rf ../priv emqx-rel/deps/emqx_coap/ + cp -rf ../src emqx-rel/deps/emqx_coap/ + cp -rf ../Makefile emqx-rel/deps/emqx_coap/Makefile + make -C emqx-rel -f Makefile + +clean: clean_result + -rm -f client/*.exe + -rm -f client/*.o + -rm -rf emqx-rel + -rm -rf libcoap + +lazy: clean_result start_broker case2 stop_broker + # custom your command here + @echo "you are so lazy" + diff --git a/apps/emqx_coap/intergration_test/README.md b/apps/emqx_coap/intergration_test/README.md new file mode 100644 index 000000000..eb3507923 --- /dev/null +++ b/apps/emqx_coap/intergration_test/README.md @@ -0,0 +1,8 @@ +Integration test for emq-coap +====== + +execute following command +``` +make +``` + diff --git a/apps/emqx_coap/intergration_test/check_result.py b/apps/emqx_coap/intergration_test/check_result.py new file mode 100644 index 000000000..f9baaefae --- /dev/null +++ b/apps/emqx_coap/intergration_test/check_result.py @@ -0,0 +1,52 @@ +import sys + + +def have_string(filename, text): + data = open(filename, "rb").read() + if data.find(text) > 0: + return True + else: + return False + + +def mark(case_number, result, description): + if result: + f = open(case_number+"_PASS.txt", "wb") + f.close() + print("\n\n"+case_number+" PASS\n\n") + else: + f = open(case_number+"_FAIL.txt", "wb") + f.write(description) + f.close() + print("\n\n"+case_number+" FAIL\n\n") + +def parse_condition(condition): + if condition.find("==") > 0: + r = condition.split("==") + return r[0], r[1], True + elif condition.find("!=") > 0: + r = condition.split("!=") + return r[0], r[1], False + else: + print("\ncondition syntax error\n\n\n") + sys.exit("condition syntax error") + + +def main(): + case_number = sys.argv[1] + description = "" + conclustion = True + for condition in sys.argv[2:]: + filename, text, result = parse_condition(condition) + if have_string(filename, text) == result: + pass + else: + conclustion = False + description = description + "\n" + condition + " failed\n" + + mark(case_number, conclustion, description) + + +if __name__ == "__main__": + main() + diff --git a/apps/emqx_coap/priv/emqx_coap.schema b/apps/emqx_coap/priv/emqx_coap.schema new file mode 100644 index 000000000..465979964 --- /dev/null +++ b/apps/emqx_coap/priv/emqx_coap.schema @@ -0,0 +1,93 @@ +%%-*- mode: erlang -*- +%% emqx_coap config mapping +{mapping, "coap.bind.udp.$number", "emqx_coap.bind_udp", [ + {datatype, ip}, + {default, "0.0.0.0:5683"} +]}. + +{mapping, "coap.enable_stats", "emqx_coap.enable_stats", [ + {datatype, flag} +]}. + +{mapping, "coap.bind.dtls.$number", "emqx_coap.bind_dtls", [ + {datatype, ip}, + {default, "0.0.0.0:5684"} +]}. + +{mapping, "coap.dtls.keyfile", "emqx_coap.dtls_opts", [ + {datatype, string} +]}. + +{mapping, "coap.dtls.certfile", "emqx_coap.dtls_opts", [ + {datatype, string} +]}. + +{mapping, "coap.dtls.verify", "emqx_coap.dtls_opts", [ + {default, verify_none}, + {datatype, {enum, [verify_none, verify_peer]}} +]}. + +{mapping, "coap.dtls.cacertfile", "emqx_coap.dtls_opts", [ + {datatype, string} +]}. + +{mapping, "coap.dtls.fail_if_no_peer_cert", "emqx_coap.dtls_opts", [ + {datatype, {enum, [true, false]}} +]}. + +{mapping, "coap.dtls.ciphers", "emqx_coap.dtls_opts", [ + {datatype, string} +]}. + +{translation, "emqx_coap.bind_udp", fun(Conf) -> + Options = cuttlefish_variable:filter_by_prefix("coap.bind.udp", Conf), + lists:map(fun({_, Bind}) -> + {Ip, Port} = cuttlefish_datatypes:from_string(Bind, ip), + Opts = case inet:parse_address(Ip) of + {ok, {_,_,_,_} = Address} -> + [inet, {ip, Address}]; + {ok, {_,_,_,_,_,_,_,_} = Address} -> + [inet6, {ip, Address}] + end, + {Port, Opts} + end, Options) +end}. + +{translation, "emqx_coap.bind_dtls", fun(Conf) -> + Options = cuttlefish_variable:filter_by_prefix("coap.bind.dtls", Conf), + lists:map(fun({_, Bind}) -> + {Ip, Port} = cuttlefish_datatypes:from_string(Bind, ip), + Opts = case inet:parse_address(Ip) of + {ok, {_,_,_,_} = Address} -> + [inet, {ip, Address}]; + {ok, {_,_,_,_,_,_,_,_} = Address} -> + [inet6, {ip, Address}] + end, + {Port, Opts} + end, Options) +end}. + +{translation, "emqx_coap.dtls_opts", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + + %% Ciphers + SplitFun = fun(undefined) -> undefined; (S) -> string:tokens(S, ",") end, + Ciphers = + case cuttlefish:conf_get("coap.dtls.ciphers", Conf, undefined) of + undefined -> + lists:foldl( + fun(TlsVer, Ciphers) -> + Ciphers ++ ssl:cipher_suites(all, TlsVer) + end, [], ['dtlsv1', 'dtlsv1.2']); + C -> + SplitFun(C) + end, + + Filter([{verify, cuttlefish:conf_get("coap.dtls.verify", Conf, undefined)}, + {keyfile, cuttlefish:conf_get("coap.dtls.keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get("coap.dtls.certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get("coap.dtls.cacertfile", Conf, undefined)}, + {fail_if_no_peer_cert, cuttlefish:conf_get("coap.dtls.fail_if_no_peer_cert", Conf, undefined)}, + {ciphers, Ciphers}]) +end}. + diff --git a/apps/emqx_coap/rebar.config b/apps/emqx_coap/rebar.config new file mode 100644 index 000000000..0b85b4f18 --- /dev/null +++ b/apps/emqx_coap/rebar.config @@ -0,0 +1,28 @@ +{deps, + [ + {gen_coap, {git, "https://github.com/emqx/gen_coap", {tag, "v0.3.1"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. + +{profiles, + [{test, + [{deps, + [{er_coap_client, {git, "https://github.com/emqx/er_coap_client", {tag, "v1.0"}}}, + {emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}} + ]} + ]} + ]}. diff --git a/apps/emqx_coap/src/emqx_coap.app.src b/apps/emqx_coap/src/emqx_coap.app.src new file mode 100644 index 000000000..2b5fcbb6a --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap.app.src @@ -0,0 +1,14 @@ +{application, emqx_coap, + [{description, "EMQ X CoAP Gateway"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, []}, + {applications, [kernel,stdlib,gen_coap]}, + {mod, {emqx_coap_app, []}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-coap"} + ]} + ]}. diff --git a/apps/emqx_coap/src/emqx_coap_app.erl b/apps/emqx_coap/src/emqx_coap_app.erl new file mode 100644 index 000000000..4e7655a74 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_app.erl @@ -0,0 +1,40 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_app). + +-behaviour(application). + +-emqx_plugin(protocol). + +-include("emqx_coap.hrl"). + +-export([ start/2 + , stop/1 + ]). + +start(_Type, _Args) -> + {ok, Sup} = emqx_coap_sup:start_link(), + coap_server_registry:add_handler([<<"mqtt">>], emqx_coap_resource, undefined), + coap_server_registry:add_handler([<<"ps">>], emqx_coap_ps_resource, undefined), + _ = emqx_coap_ps_topics:start_link(), + emqx_coap_server:start(application:get_all_env(?APP)), + {ok,Sup}. + +stop(_State) -> + coap_server_registry:remove_handler([<<"mqtt">>], emqx_coap_resource, undefined), + coap_server_registry:remove_handler([<<"ps">>], emqx_coap_ps_resource, undefined), + emqx_coap_server:stop(application:get_all_env(?APP)). diff --git a/apps/emqx_coap/src/emqx_coap_mqtt_adapter.erl b/apps/emqx_coap/src/emqx_coap_mqtt_adapter.erl new file mode 100644 index 000000000..537f5137b --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_mqtt_adapter.erl @@ -0,0 +1,379 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_mqtt_adapter). + +-behaviour(gen_server). + +-include("emqx_coap.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-logger_header("[CoAP-Adpter]"). + +%% API. +-export([ subscribe/2 + , unsubscribe/2 + , publish/3 + ]). + +-export([ client_pid/4 + , stop/1 + ]). + +-export([call/2]). + +%% gen_server. +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, {peername, clientid, username, password, sub_topics = [], connected_at}). + +-define(ALIVE_INTERVAL, 20000). + +-define(CONN_STATS, [recv_pkt, recv_msg, send_pkt, send_msg]). + +-define(SUBOPTS, #{rh => 0, rap => 0, nl => 0, qos => ?QOS_0, is_new => false}). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +client_pid(undefined, _Username, _Password, _Channel) -> + {error, bad_request}; +client_pid(ClientId, Username, Password, Channel) -> + % check authority + case start(ClientId, Username, Password, Channel) of + {ok, Pid1} -> {ok, Pid1}; + {error, {already_started, Pid2}} -> {ok, Pid2}; + {error, auth_failure} -> {error, auth_failure}; + Other -> {error, Other} + end. + +start(ClientId, Username, Password, Channel) -> + % DO NOT use start_link, since multiple coap_reponsder may have relation with one mqtt adapter, + % one coap_responder crashes should not make mqtt adapter crash too + % And coap_responder is not a system process + % it is dangerous to link mqtt adapter to coap_responder + gen_server:start({via, emqx_coap_registry, {ClientId, Username, Password}}, + ?MODULE, {ClientId, Username, Password, Channel}, []). + +stop(Pid) -> + gen_server:stop(Pid). + +subscribe(Pid, Topic) -> + gen_server:call(Pid, {subscribe, Topic, self()}). + +unsubscribe(Pid, Topic) -> + gen_server:call(Pid, {unsubscribe, Topic, self()}). + +publish(Pid, Topic, Payload) -> + gen_server:call(Pid, {publish, Topic, Payload}). + +%% For emqx_management plugin +call(Pid, Msg) -> + Pid ! Msg, ok. + +%%-------------------------------------------------------------------- +%% gen_server Callbacks +%%-------------------------------------------------------------------- + +init({ClientId, Username, Password, Channel}) -> + ?LOG(debug, "try to start adapter ClientId=~p, Username=~p, Password=~p, Channel=~p", + [ClientId, Username, Password, Channel]), + State0 = #state{peername = Channel, + clientid = ClientId, + username = Username, + password = Password}, + _ = run_hooks('client.connect', [conninfo(State0)], undefined), + case emqx_access_control:authenticate(clientinfo(State0)) of + {ok, _AuthResult} -> + ok = emqx_cm:discard_session(ClientId), + + _ = run_hooks('client.connack', [conninfo(State0), success], undefined), + + State = State0#state{connected_at = erlang:system_time(millisecond)}, + + run_hooks('client.connected', [clientinfo(State), conninfo(State)]), + + Self = self(), + erlang:send_after(?ALIVE_INTERVAL, Self, check_alive), + _ = emqx_cm_locker:trans(ClientId, fun(_) -> + emqx_cm:register_channel(ClientId, Self, conninfo(State)) + end), + emqx_cm:insert_channel_info(ClientId, info(State), stats(State)), + {ok, State}; + {error, Reason} -> + ?LOG(debug, "authentication faild: ~p", [Reason]), + _ = run_hooks('client.connack', [conninfo(State0), not_authorized], undefined), + {stop, {shutdown, Reason}} + end. + +handle_call({subscribe, Topic, CoapPid}, _From, State=#state{sub_topics = TopicList}) -> + NewTopics = proplists:delete(Topic, TopicList), + IsWild = emqx_topic:wildcard(Topic), + chann_subscribe(Topic, State), + {reply, ok, State#state{sub_topics = [{Topic, {IsWild, CoapPid}}|NewTopics]}, hibernate}; + +handle_call({unsubscribe, Topic, _CoapPid}, _From, State=#state{sub_topics = TopicList}) -> + NewTopics = proplists:delete(Topic, TopicList), + chann_unsubscribe(Topic, State), + {reply, ok, State#state{sub_topics = NewTopics}, hibernate}; + +handle_call({publish, Topic, Payload}, _From, State) -> + _ = chann_publish(Topic, Payload, State), + {reply, ok, State}; + +handle_call(info, _From, State) -> + {reply, info(State), State}; + +handle_call(stats, _From, State) -> + {reply, stats(State), State, hibernate}; + +handle_call(kick, _From, State) -> + {stop, {shutdown, kick}, ok, State}; + +handle_call({set_rate_limit, _Rl}, _From, State) -> + ?LOG(error, "set_rate_limit is not support", []), + {reply, ok, State}; + +handle_call(get_rate_limit, _From, State) -> + ?LOG(error, "get_rate_limit is not support", []), + {reply, ok, State}; + +handle_call(Request, _From, State) -> + ?LOG(error, "adapter unexpected call ~p", [Request]), + {reply, ignored, State, hibernate}. + +handle_cast(Msg, State) -> + ?LOG(error, "broker_api unexpected cast ~p", [Msg]), + {noreply, State, hibernate}. + +handle_info({deliver, _Topic, #message{topic = Topic, payload = Payload}}, + State = #state{sub_topics = Subscribers}) -> + deliver([{Topic, Payload}], Subscribers), + {noreply, State, hibernate}; + +handle_info(check_alive, State = #state{sub_topics = []}) -> + {stop, {shutdown, check_alive}, State}; +handle_info(check_alive, State) -> + erlang:send_after(?ALIVE_INTERVAL, self(), check_alive), + {noreply, State, hibernate}; + +handle_info({shutdown, Error}, State) -> + {stop, {shutdown, Error}, State}; + +handle_info({shutdown, conflict, {ClientId, NewPid}}, State) -> + ?LOG(warning, "clientid '~s' conflict with ~p", [ClientId, NewPid]), + {stop, {shutdown, conflict}, State}; + +handle_info(discard, State) -> + ?LOG(warning, "the connection is discarded. " ++ + "possibly there is another client with the same clientid", []), + {stop, {shutdown, discarded}, State}; + +handle_info(kick, State) -> + ?LOG(info, "Kicked", []), + {stop, {shutdown, kick}, State}; + +handle_info(Info, State) -> + ?LOG(error, "adapter unexpected info ~p", [Info]), + {noreply, State, hibernate}. + +terminate(Reason, State = #state{clientid = ClientId, sub_topics = SubTopics}) -> + ?LOG(debug, "unsubscribe ~p while exiting for ~p", [SubTopics, Reason]), + [chann_unsubscribe(Topic, State) || {Topic, _} <- SubTopics], + emqx_cm:unregister_channel(ClientId), + + ConnInfo0 = conninfo(State), + ConnInfo = ConnInfo0#{disconnected_at => erlang:system_time(millisecond)}, + run_hooks('client.disconnected', [clientinfo(State), Reason, ConnInfo]). + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Channel adapter functions + +chann_subscribe(Topic, State = #state{clientid = ClientId}) -> + ?LOG(debug, "subscribe Topic=~p", [Topic]), + case emqx_access_control:check_acl(clientinfo(State), subscribe, Topic) of + allow -> + emqx_broker:subscribe(Topic, ClientId, ?SUBOPTS), + emqx_hooks:run('session.subscribed', [clientinfo(State), Topic, ?SUBOPTS]); + deny -> + ?LOG(warning, "subscribe to ~p by clientid ~p failed due to acl check.", + [Topic, ClientId]) + end. + +chann_unsubscribe(Topic, State) -> + ?LOG(debug, "unsubscribe Topic=~p", [Topic]), + Opts = #{rh => 0, rap => 0, nl => 0, qos => 0}, + emqx_broker:unsubscribe(Topic), + emqx_hooks:run('session.unsubscribed', [clientinfo(State), Topic, Opts]). + +chann_publish(Topic, Payload, State = #state{clientid = ClientId}) -> + ?LOG(debug, "publish Topic=~p, Payload=~p", [Topic, Payload]), + case emqx_access_control:check_acl(clientinfo(State), publish, Topic) of + allow -> + emqx_broker:publish( + emqx_message:set_flag(retain, false, + emqx_message:make(ClientId, ?QOS_0, Topic, Payload))); + deny -> + ?LOG(warning, "publish to ~p by clientid ~p failed due to acl check.", + [Topic, ClientId]) + end. + + +%%-------------------------------------------------------------------- +%% Deliver + +deliver([], _) -> ok; +deliver([Pub | More], Subscribers) -> + ok = do_deliver(Pub, Subscribers), + deliver(More, Subscribers). + +do_deliver({Topic, Payload}, Subscribers) -> + %% handle PUBLISH packet from broker + ?LOG(debug, "deliver message from broker Topic=~p, Payload=~p", [Topic, Payload]), + deliver_to_coap(Topic, Payload, Subscribers), + ok. + +deliver_to_coap(_TopicName, _Payload, []) -> + ok; +deliver_to_coap(TopicName, Payload, [{TopicFilter, {IsWild, CoapPid}}|T]) -> + Matched = case IsWild of + true -> emqx_topic:match(TopicName, TopicFilter); + false -> TopicName =:= TopicFilter + end, + %?LOG(debug, "deliver_to_coap Matched=~p, CoapPid=~p, TopicName=~p, Payload=~p, T=~p", + % [Matched, CoapPid, TopicName, Payload, T]), + Matched andalso (CoapPid ! {dispatch, TopicName, Payload}), + deliver_to_coap(TopicName, Payload, T). + +%%-------------------------------------------------------------------- +%% Helper funcs + +-compile({inline, [run_hooks/2, run_hooks/3]}). +run_hooks(Name, Args) -> + ok = emqx_metrics:inc(Name), emqx_hooks:run(Name, Args). + +run_hooks(Name, Args, Acc) -> + ok = emqx_metrics:inc(Name), emqx_hooks:run_fold(Name, Args, Acc). + +%%-------------------------------------------------------------------- +%% Info & Stats + +info(State) -> + ChannInfo = chann_info(State), + ChannInfo#{sockinfo => sockinfo(State)}. + +%% copies from emqx_connection:info/1 +sockinfo(#state{peername = Peername}) -> + #{socktype => udp, + peername => Peername, + sockname => {{127, 0, 0, 1}, 5683}, %% FIXME: Sock? + sockstate => running, + active_n => 1 + }. + +%% copies from emqx_channel:info/1 +chann_info(State) -> + #{conninfo => conninfo(State), + conn_state => connected, + clientinfo => clientinfo(State), + session => maps:from_list(session_info(State)), + will_msg => undefined + }. + +conninfo(#state{peername = Peername, + clientid = ClientId, + connected_at = ConnectedAt}) -> + #{socktype => udp, + sockname => {{127, 0, 0, 1}, 5683}, + peername => Peername, + peercert => nossl, %% TODO: dtls + conn_mod => ?MODULE, + proto_name => <<"CoAP">>, + proto_ver => 1, + clean_start => true, + clientid => ClientId, + username => undefined, + conn_props => undefined, + connected => true, + connected_at => ConnectedAt, + keepalive => 0, + receive_maximum => 0, + expiry_interval => 0 + }. + +%% copies from emqx_session:info/1 +session_info(#state{sub_topics = SubTopics, connected_at = ConnectedAt}) -> + Subs = lists:foldl( + fun({Topic, _}, Acc) -> + Acc#{Topic => ?SUBOPTS} + end, #{}, SubTopics), + [{subscriptions, Subs}, + {upgrade_qos, false}, + {retry_interval, 0}, + {await_rel_timeout, 0}, + {created_at, ConnectedAt} + ]. + +%% The stats keys copied from emqx_connection:stats/1 +stats(#state{sub_topics = SubTopics}) -> + SockStats = [{recv_oct, 0}, {recv_cnt, 0}, {send_oct, 0}, {send_cnt, 0}, {send_pend, 0}], + ConnStats = emqx_pd:get_counters(?CONN_STATS), + ChanStats = [{subscriptions_cnt, length(SubTopics)}, + {subscriptions_max, length(SubTopics)}, + {inflight_cnt, 0}, + {inflight_max, 0}, + {mqueue_len, 0}, + {mqueue_max, 0}, + {mqueue_dropped, 0}, + {next_pkt_id, 0}, + {awaiting_rel_cnt, 0}, + {awaiting_rel_max, 0} + ], + ProcStats = emqx_misc:proc_stats(), + lists:append([SockStats, ConnStats, ChanStats, ProcStats]). + +clientinfo(#state{peername = {PeerHost, _}, + clientid = ClientId, + username = Username, + password = Password}) -> + #{zone => undefined, + protocol => coap, + peerhost => PeerHost, + sockport => 5683, %% FIXME: + clientid => ClientId, + username => Username, + password => Password, + peercert => nossl, + is_bridge => false, + is_superuser => false, + mountpoint => undefined, + ws_cookie => undefined + }. + diff --git a/apps/emqx_coap/src/emqx_coap_ps_resource.erl b/apps/emqx_coap/src/emqx_coap_ps_resource.erl new file mode 100644 index 000000000..144dba1bd --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_ps_resource.erl @@ -0,0 +1,326 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_ps_resource). + +-behaviour(coap_resource). + +-include("emqx_coap.hrl"). +-include_lib("gen_coap/include/coap.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[CoAP-PS-RES]"). + +-export([ coap_discover/2 + , coap_get/5 + , coap_post/4 + , coap_put/4 + , coap_delete/3 + , coap_observe/5 + , coap_unobserve/1 + , handle_info/2 + , coap_ack/2 + ]). + +-ifdef(TEST). +-export([topic/1]). +-endif. + +-define(PS_PREFIX, [<<"ps">>]). + +%%-------------------------------------------------------------------- +%% Resource Callbacks +%%-------------------------------------------------------------------- +coap_discover(_Prefix, _Args) -> + [{absolute, [<<"ps">>], []}]. + +coap_get(ChId, ?PS_PREFIX, TopicPath, Query, Content=#coap_content{format = Format}) when TopicPath =/= [] -> + Topic = topic(TopicPath), + ?LOG(debug, "coap_get() Topic=~p, Query=~p~n", [Topic, Query]), + #coap_mqtt_auth{clientid = Clientid, username = Usr, password = Passwd} = get_auth(Query), + case emqx_coap_mqtt_adapter:client_pid(Clientid, Usr, Passwd, ChId) of + {ok, Pid} -> + put(mqtt_client_pid, Pid), + case Format of + <<"application/link-format">> -> + Content; + _Other -> + %% READ the topic info + read_last_publish_message(emqx_topic:wildcard(Topic), Topic, Content) + end; + {error, auth_failure} -> + put(mqtt_client_pid, undefined), + {error, uauthorized}; + {error, bad_request} -> + put(mqtt_client_pid, undefined), + {error, bad_request}; + {error, _Other} -> + put(mqtt_client_pid, undefined), + {error, internal_server_error} + end; +coap_get(ChId, Prefix, TopicPath, Query, _Content) -> + ?LOG(error, "ignore bad get request ChId=~p, Prefix=~p, TopicPath=~p, Query=~p", [ChId, Prefix, TopicPath, Query]), + {error, bad_request}. + +coap_post(_ChId, ?PS_PREFIX, TopicPath, #coap_content{format = Format, payload = Payload, max_age = MaxAge}) when TopicPath =/= [] -> + Topic = topic(TopicPath), + ?LOG(debug, "coap_post() Topic=~p, MaxAge=~p, Format=~p~n", [Topic, MaxAge, Format]), + case Format of + %% We treat ct of "application/link-format" as CREATE message + <<"application/link-format">> -> + handle_received_create(Topic, MaxAge, Payload); + %% We treat ct of other values as PUBLISH message + Other -> + ?LOG(debug, "coap_post() receive payload format=~p, will process as PUBLISH~n", [Format]), + handle_received_publish(Topic, MaxAge, Other, Payload) + end; + +coap_post(_ChId, _Prefix, _TopicPath, _Content) -> + {error, method_not_allowed}. + +coap_put(_ChId, ?PS_PREFIX, TopicPath, #coap_content{max_age = MaxAge, format = Format, payload = Payload}) when TopicPath =/= [] -> + Topic = topic(TopicPath), + ?LOG(debug, "put message, Topic=~p, Payload=~p~n", [Topic, Payload]), + handle_received_publish(Topic, MaxAge, Format, Payload); + +coap_put(_ChId, Prefix, TopicPath, Content) -> + ?LOG(error, "put has error, Prefix=~p, TopicPath=~p, Content=~p", [Prefix, TopicPath, Content]), + {error, bad_request}. + +coap_delete(_ChId, ?PS_PREFIX, TopicPath) -> + delete_topic_info(topic(TopicPath)); + +coap_delete(_ChId, _Prefix, _TopicPath) -> + {error, method_not_allowed}. + +coap_observe(ChId, ?PS_PREFIX, TopicPath, Ack, Content) when TopicPath =/= [] -> + Topic = topic(TopicPath), + ?LOG(debug, "observe Topic=~p, Ack=~p,Content=~p", [Topic, Ack, Content]), + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:subscribe(Pid, Topic), + Code = case emqx_coap_ps_topics:is_topic_timeout(Topic) of + true -> + nocontent; + false-> + content + end, + {ok, {state, ChId, ?PS_PREFIX, [Topic]}, Code, Content}; + +coap_observe(ChId, Prefix, TopicPath, Ack, _Content) -> + ?LOG(error, "unknown observe request ChId=~p, Prefix=~p, TopicPath=~p, Ack=~p", [ChId, Prefix, TopicPath, Ack]), + {error, bad_request}. + +coap_unobserve({state, _ChId, ?PS_PREFIX, TopicPath}) when TopicPath =/= [] -> + Topic = topic(TopicPath), + ?LOG(debug, "unobserve ~p", [Topic]), + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:unsubscribe(Pid, Topic), + ok; +coap_unobserve({state, ChId, Prefix, TopicPath}) -> + ?LOG(error, "ignore unknown unobserve request ChId=~p, Prefix=~p, TopicPath=~p", [ChId, Prefix, TopicPath]), + ok. + +handle_info({dispatch, Topic, Payload}, State) -> + ?LOG(debug, "dispatch Topic=~p, Payload=~p", [Topic, Payload]), + {ok, Ret} = emqx_coap_ps_topics:reset_topic_info(Topic, Payload), + ?LOG(debug, "Updated publish info of topic=~p, the Ret is ~p", [Topic, Ret]), + {notify, [], #coap_content{format = <<"application/octet-stream">>, payload = Payload}, State}; +handle_info(Message, State) -> + ?LOG(error, "Unknown Message ~p", [Message]), + {noreply, State}. + +coap_ack(_Ref, State) -> {ok, State}. + + +%%-------------------------------------------------------------------- +%% Internal Functions +%%-------------------------------------------------------------------- +get_auth(Query) -> + get_auth(Query, #coap_mqtt_auth{}). + +get_auth([], Auth=#coap_mqtt_auth{}) -> + Auth; +get_auth([<<$c, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{clientid = Rest}); +get_auth([<<$u, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{username = Rest}); +get_auth([<<$p, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{password = Rest}); +get_auth([Param|T], Auth=#coap_mqtt_auth{}) -> + ?LOG(error, "ignore unknown parameter ~p", [Param]), + get_auth(T, Auth). + +add_topic_info(publish, Topic, MaxAge, Format, Payload) when is_binary(Topic), Topic =/= <<>> -> + case emqx_coap_ps_topics:lookup_topic_info(Topic) of + [{_, StoredMaxAge, StoredCT, _, _}] -> + ?LOG(debug, "publish topic=~p already exists, need reset the topic info", [Topic]), + %% check whether the ct value stored matches the ct option in this POST message + case Format =:= StoredCT of + true -> + {ok, Ret} = + case StoredMaxAge =:= MaxAge of + true -> + emqx_coap_ps_topics:reset_topic_info(Topic, Payload); + false -> + emqx_coap_ps_topics:reset_topic_info(Topic, MaxAge, Payload) + end, + {changed, Ret}; + false -> + ?LOG(debug, "ct values of topic=~p do not match, stored ct=~p, new ct=~p, ignore the PUBLISH", [Topic, StoredCT, Format]), + {changed, false} + end; + [] -> + ?LOG(debug, "publish topic=~p will be created", [Topic]), + {ok, Ret} = emqx_coap_ps_topics:add_topic_info(Topic, MaxAge, Format, Payload), + {created, Ret} + end; + +add_topic_info(create, Topic, MaxAge, Format, _Payload) when is_binary(Topic), Topic =/= <<>> -> + case emqx_coap_ps_topics:is_topic_existed(Topic) of + true -> + %% Whether we should support CREATE to an existed topic is TBD!! + ?LOG(debug, "create topic=~p already exists, need reset the topic info", [Topic]), + {ok, Ret} = emqx_coap_ps_topics:reset_topic_info(Topic, MaxAge, Format, <<>>); + false -> + ?LOG(debug, "create topic=~p will be created", [Topic]), + {ok, Ret} = emqx_coap_ps_topics:add_topic_info(Topic, MaxAge, Format, <<>>) + end, + {created, Ret}; + +add_topic_info(_, Topic, _MaxAge, _Format, _Payload) -> + ?LOG(debug, "create topic=~p info failed", [Topic]), + {badarg, false}. + +concatenate_location_path(List = [TopicPart1, TopicPart2, TopicPart3]) when is_binary(TopicPart1), is_binary(TopicPart2), is_binary(TopicPart3) -> + list_to_binary(lists:foldl( fun (Element, AccIn) when Element =/= <<>> -> + AccIn ++ "/" ++ binary_to_list(Element); + (_Element, AccIn) -> + AccIn + end, [], List)). + +format_string_to_int(<<"application/octet-stream">>) -> + <<"42">>; +format_string_to_int(<<"application/exi">>) -> + <<"47">>; +format_string_to_int(<<"application/json">>) -> + <<"50">>. + +handle_received_publish(Topic, MaxAge, Format, Payload) -> + case add_topic_info(publish, Topic, MaxAge, format_string_to_int(Format), Payload) of + {Ret ,true} -> + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:publish(Pid, topic(Topic), Payload), + Content = case Ret of + changed -> + #coap_content{}; + created -> + LocPath = concatenate_location_path([<<"ps">>, Topic, <<>>]), + #coap_content{location_path = [LocPath]} + end, + {ok, Ret, Content}; + {_, false} -> + ?LOG(debug, "add_topic_info failed, will return bad_request", []), + {error, bad_request} + end. + +handle_received_create(TopicPrefix, MaxAge, Payload) -> + case core_link:decode(Payload) of + [{rootless, [Topic], [{ct, CT}]}] when is_binary(Topic), Topic =/= <<>> -> + TrueTopic = percent_decode(Topic), + ?LOG(debug, "decoded link-format payload, the Topic=~p, CT=~p~n", [TrueTopic, CT]), + LocPath = concatenate_location_path([<<"ps">>, TopicPrefix, TrueTopic]), + FullTopic = binary:part(LocPath, 4, byte_size(LocPath)-4), + ?LOG(debug, "the location path is ~p, the full topic is ~p~n", [LocPath, FullTopic]), + case add_topic_info(create, FullTopic, MaxAge, CT, <<>>) of + {_, true} -> + ?LOG(debug, "create topic info successfully, will return LocPath=~p", [LocPath]), + {ok, created, #coap_content{location_path = [LocPath]}}; + {_, false} -> + ?LOG(debug, "create topic info failed, will return bad_request", []), + {error, bad_request} + end; + Other -> + ?LOG(debug, "post with bad payload of link-format ~p, will return bad_request", [Other]), + {error, bad_request} + end. + +%% @private Copy from http_uri.erl which has been deprecated since OTP-23 +percent_decode(<<$%, Hex:2/binary, Rest/bits>>) -> + <<(binary_to_integer(Hex, 16)), (percent_decode(Rest))/binary>>; +percent_decode(<>) -> + <>; +percent_decode(<<>>) -> + <<>>. + +%% When topic is timeout, server should return nocontent here, +%% but gen_coap only receive return value of #coap_content from coap_get, so temporarily we can't give the Code 2.07 {ok, nocontent} out.TBC!!! +return_resource(Topic, Payload, MaxAge, TimeStamp, Content) -> + TimeElapsed = trunc((erlang:system_time(millisecond) - TimeStamp) / 1000), + case TimeElapsed < MaxAge of + true -> + LeftTime = (MaxAge - TimeElapsed), + ?LOG(debug, "topic=~p has max age left time is ~p", [Topic, LeftTime]), + Content#coap_content{max_age = LeftTime, payload = Payload}; + false -> + ?LOG(debug, "topic=~p has been timeout, will return empty content", [Topic]), + #coap_content{} + end. + +read_last_publish_message(false, Topic, Content=#coap_content{format = QueryFormat}) when is_binary(QueryFormat)-> + ?LOG(debug, "the QueryFormat=~p", [QueryFormat]), + case emqx_coap_ps_topics:lookup_topic_info(Topic) of + [] -> + {error, not_found}; + [{_, MaxAge, CT, Payload, TimeStamp}] -> + case CT =:= format_string_to_int(QueryFormat) of + true -> + return_resource(Topic, Payload, MaxAge, TimeStamp, Content); + false -> + ?LOG(debug, "format value does not match, the queried format=~p, the stored format=~p", [QueryFormat, CT]), + {error, bad_request} + end + end; + +read_last_publish_message(false, Topic, Content) -> + case emqx_coap_ps_topics:lookup_topic_info(Topic) of + [] -> + {error, not_found}; + [{_, MaxAge, _, Payload, TimeStamp}] -> + return_resource(Topic, Payload, MaxAge, TimeStamp, Content) + end; + +read_last_publish_message(true, Topic, _Content) -> + ?LOG(debug, "the topic=~p is illegal wildcard topic", [Topic]), + {error, bad_request}. + +delete_topic_info(Topic) -> + case emqx_coap_ps_topics:lookup_topic_info(Topic) of + [] -> + {error, not_found}; + [{_, _, _, _, _}] -> + emqx_coap_ps_topics:delete_sub_topics(Topic) + end. + +topic(Topic) when is_binary(Topic) -> Topic; +topic([]) -> <<>>; +topic([Path | TopicPath]) -> + case topic(TopicPath) of + <<>> -> Path; + RemTopic -> + <> + end. diff --git a/apps/emqx_coap/src/emqx_coap_ps_topics.erl b/apps/emqx_coap/src/emqx_coap_ps_topics.erl new file mode 100644 index 000000000..b4affab28 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_ps_topics.erl @@ -0,0 +1,185 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_ps_topics). + +-behaviour(gen_server). + +-include("emqx_coap.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[CoAP-PS-TOPICS]"). + +-export([ start_link/0 + , stop/1 + ]). + +-export([ add_topic_info/4 + , delete_topic_info/1 + , delete_sub_topics/1 + , is_topic_existed/1 + , is_topic_timeout/1 + , reset_topic_info/2 + , reset_topic_info/3 + , reset_topic_info/4 + , lookup_topic_info/1 + , lookup_topic_payload/1 + ]). + +%% gen_server. +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, {}). + +-define(COAP_TOPIC_TABLE, coap_topic). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +stop(Pid) -> + gen_server:stop(Pid). + +add_topic_info(Topic, MaxAge, CT, Payload) when is_binary(Topic), is_integer(MaxAge), is_binary(CT), is_binary(Payload) -> + gen_server:call(?MODULE, {add_topic, {Topic, MaxAge, CT, Payload}}). + +delete_topic_info(Topic) when is_binary(Topic) -> + gen_server:call(?MODULE, {remove_topic, Topic}). + +delete_sub_topics(Topic) when is_binary(Topic) -> + gen_server:cast(?MODULE, {remove_sub_topics, Topic}). + +reset_topic_info(Topic, Payload) -> + gen_server:call(?MODULE, {reset_topic, {Topic, Payload}}). + +reset_topic_info(Topic, MaxAge, Payload) -> + gen_server:call(?MODULE, {reset_topic, {Topic, MaxAge, Payload}}). + +reset_topic_info(Topic, MaxAge, CT, Payload) -> + gen_server:call(?MODULE, {reset_topic, {Topic, MaxAge, CT, Payload}}). + +is_topic_existed(Topic) -> + ets:member(?COAP_TOPIC_TABLE, Topic). + +is_topic_timeout(Topic) when is_binary(Topic) -> + [{Topic, MaxAge, _, _, TimeStamp}] = ets:lookup(?COAP_TOPIC_TABLE, Topic), + %% MaxAge: x seconds + MaxAge < ((erlang:system_time(millisecond) - TimeStamp) / 1000). + +lookup_topic_info(Topic) -> + ets:lookup(?COAP_TOPIC_TABLE, Topic). + +lookup_topic_payload(Topic) -> + try ets:lookup_element(?COAP_TOPIC_TABLE, Topic, 4) + catch + error:badarg -> undefined + end. + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([]) -> + _ = ets:new(?COAP_TOPIC_TABLE, [set, named_table, protected]), + ?LOG(debug, "Create the coap_topic table", []), + {ok, #state{}}. + +handle_call({add_topic, {Topic, MaxAge, CT, Payload}}, _From, State) -> + Ret = create_table_element(Topic, MaxAge, CT, Payload), + {reply, {ok, Ret}, State, hibernate}; + +handle_call({reset_topic, {Topic, Payload}}, _From, State) -> + Ret = update_table_element(Topic, Payload), + {reply, {ok, Ret}, State, hibernate}; + +handle_call({reset_topic, {Topic, MaxAge, Payload}}, _From, State) -> + Ret = update_table_element(Topic, MaxAge, Payload), + {reply, {ok, Ret}, State, hibernate}; + +handle_call({reset_topic, {Topic, MaxAge, CT, Payload}}, _From, State) -> + Ret = update_table_element(Topic, MaxAge, CT, Payload), + {reply, {ok, Ret}, State, hibernate}; + +handle_call({remove_topic, {Topic, _Content}}, _From, State) -> + ets:delete(?COAP_TOPIC_TABLE, Topic), + ?LOG(debug, "Remove topic ~p in the coap_topic table", [Topic]), + {reply, ok, State, hibernate}; + +handle_call(Request, _From, State) -> + ?LOG(error, "adapter unexpected call ~p", [Request]), + {reply, ignored, State, hibernate}. + +handle_cast({remove_sub_topics, TopicPrefix}, State) -> + DeletedTopicNum = ets:foldl(fun ({Topic, _, _, _, _}, AccIn) -> + case binary:match(Topic, TopicPrefix) =/= nomatch of + true -> + ?LOG(debug, "Remove topic ~p in the coap_topic table", [Topic]), + ets:delete(?COAP_TOPIC_TABLE, Topic), + AccIn + 1; + false -> + AccIn + end + end, 0, ?COAP_TOPIC_TABLE), + ?LOG(debug, "Remove number of ~p topics with prefix=~p in the coap_topic table", [DeletedTopicNum, TopicPrefix]), + {noreply, State, hibernate}; + +handle_cast(Msg, State) -> + ?LOG(error, "broker_api unexpected cast ~p", [Msg]), + {noreply, State, hibernate}. + +handle_info(Info, State) -> + ?LOG(error, "adapter unexpected info ~p", [Info]), + {noreply, State, hibernate}. + +terminate(Reason, #state{}) -> + ets:delete(?COAP_TOPIC_TABLE), + ?LOG(error, "the ~p terminate for reason ~p", [?MODULE, Reason]), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + + +%%-------------------------------------------------------------------- +%% Internal Functions +%%-------------------------------------------------------------------- +create_table_element(Topic, MaxAge, CT, Payload) -> + TopicInfo = {Topic, MaxAge, CT, Payload, erlang:system_time(millisecond)}, + ?LOG(debug, "Insert ~p in the coap_topic table", [TopicInfo]), + ets:insert_new(?COAP_TOPIC_TABLE, TopicInfo). + +update_table_element(Topic, Payload) -> + ?LOG(debug, "Update the topic=~p only with Payload", [Topic]), + ets:update_element(?COAP_TOPIC_TABLE, Topic, [{4, Payload}, {5, erlang:system_time(millisecond)}]). + +update_table_element(Topic, MaxAge, Payload) -> + ?LOG(debug, "Update the topic=~p info of MaxAge=~p and Payload", [Topic, MaxAge]), + ets:update_element(?COAP_TOPIC_TABLE, Topic, [{2, MaxAge}, {4, Payload}, {5, erlang:system_time(millisecond)}]). + +update_table_element(Topic, MaxAge, CT, <<>>) -> + ?LOG(debug, "Update the topic=~p info of MaxAge=~p, CT=~p, payload=<<>>", [Topic, MaxAge, CT]), + ets:update_element(?COAP_TOPIC_TABLE, Topic, [{2, MaxAge}, {3, CT}, {5, erlang:system_time(millisecond)}]). diff --git a/apps/emqx_coap/src/emqx_coap_registry.erl b/apps/emqx_coap/src/emqx_coap_registry.erl new file mode 100644 index 000000000..8e1936a98 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_registry.erl @@ -0,0 +1,154 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_registry). + +-author("Feng Lee "). + +-include("emqx_coap.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[CoAP-Registry]"). + +-behaviour(gen_server). + +%% API. +-export([ start_link/0 + , register_name/2 + , unregister_name/1 + , whereis_name/1 + , send/2 + , stop/0 + ]). + +%% gen_server. +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, {}). + +-define(RESPONSE_TAB, coap_response_process). +-define(RESPONSE_REF_TAB, coap_response_process_ref). + +%% ------------------------------------------------------------------ +%% API Function Definitions +%% ------------------------------------------------------------------ + + +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +register_name(Name, Pid) -> + gen_server:call(?MODULE, {register_name, Name, Pid}). + +unregister_name(Name) -> + gen_server:call(?MODULE, {unregister_name, Name}). + +whereis_name(Name) -> + case ets:lookup(?RESPONSE_TAB, Name) of + [] -> undefined; + [{Name, Pid, _MRef}] -> Pid + end. + +send(Name, Msg) -> + case whereis_name(Name) of + undefined -> + exit({badarg, {Name, Msg}}); + Pid when is_pid(Pid) -> + Pid ! Msg, + Pid + end. + +stop() -> + gen_server:stop(?MODULE). + + +%% ------------------------------------------------------------------ +%% gen_server Function Definitions +%% ------------------------------------------------------------------ + +init([]) -> + _ = ets:new(?RESPONSE_TAB, [set, named_table, protected]), + _ = ets:new(?RESPONSE_REF_TAB, [set, named_table, protected]), + {ok, #state{}}. + +handle_call({register_name, Name, Pid}, _From, State) -> + case ets:member(?RESPONSE_TAB, Name) of + false -> + MRef = monitor_client(Pid), + ets:insert(?RESPONSE_TAB, {Name, Pid, MRef}), + ets:insert(?RESPONSE_REF_TAB, {MRef, Name, Pid}), + {reply, yes, State}; + true -> {reply, no, State} + end; + +handle_call({unregister_name, Name}, _From, State) -> + case ets:lookup(?RESPONSE_TAB, Name) of + [] -> + ok; + [{Name, _Pid, MRef}] -> + erase_monitor(MRef), + ets:delete(?RESPONSE_TAB, Name), + ets:delete(?RESPONSE_REF_TAB, MRef) + end, + {reply, ok, State}; + +handle_call(_Request, _From, State) -> + {reply, ignored, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + + +handle_info({'DOWN', MRef, process, DownPid, _Reason}, State) -> + case ets:lookup(?RESPONSE_REF_TAB, MRef) of + [{MRef, Name, _Pid}] -> + ets:delete(?RESPONSE_TAB, Name), + ets:delete(?RESPONSE_REF_TAB, MRef), + erase_monitor(MRef); + [] -> + ?LOG(error, "MRef of client ~p not found", [DownPid]) + end, + {noreply, State}; + + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ets:delete(?RESPONSE_TAB), + ets:delete(?RESPONSE_REF_TAB), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + + + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +monitor_client(Pid) -> + erlang:monitor(process, Pid). + +erase_monitor(MRef) -> + catch erlang:demonitor(MRef, [flush]). diff --git a/apps/emqx_coap/src/emqx_coap_resource.erl b/apps/emqx_coap/src/emqx_coap_resource.erl new file mode 100644 index 000000000..e11788a04 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_resource.erl @@ -0,0 +1,136 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_resource). + +-behaviour(coap_resource). + +-include("emqx_coap.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("gen_coap/include/coap.hrl"). + +-logger_header("[CoAP-RES]"). + +-export([ coap_discover/2 + , coap_get/5 + , coap_post/4 + , coap_put/4 + , coap_delete/3 + , coap_observe/5 + , coap_unobserve/1 + , handle_info/2 + , coap_ack/2 + ]). + +-ifdef(TEST). +-export([topic/1]). +-endif. + +-define(MQTT_PREFIX, [<<"mqtt">>]). + +% resource operations +coap_discover(_Prefix, _Args) -> + [{absolute, [<<"mqtt">>], []}]. + +coap_get(ChId, ?MQTT_PREFIX, Path, Query, _Content) -> + ?LOG(debug, "coap_get() Path=~p, Query=~p~n", [Path, Query]), + #coap_mqtt_auth{clientid = Clientid, username = Usr, password = Passwd} = get_auth(Query), + case emqx_coap_mqtt_adapter:client_pid(Clientid, Usr, Passwd, ChId) of + {ok, Pid} -> + put(mqtt_client_pid, Pid), + #coap_content{}; + {error, auth_failure} -> + put(mqtt_client_pid, undefined), + {error, unauthorized}; + {error, bad_request} -> + put(mqtt_client_pid, undefined), + {error, bad_request}; + {error, _Other} -> + put(mqtt_client_pid, undefined), + {error, internal_server_error} + end; +coap_get(ChId, Prefix, Path, Query, _Content) -> + ?LOG(error, "ignore bad get request ChId=~p, Prefix=~p, Path=~p, Query=~p", [ChId, Prefix, Path, Query]), + {error, bad_request}. + +coap_post(_ChId, _Prefix, _Topic, _Content) -> + {error, method_not_allowed}. + +coap_put(_ChId, ?MQTT_PREFIX, Topic, #coap_content{payload = Payload}) when Topic =/= [] -> + ?LOG(debug, "put message, Topic=~p, Payload=~p~n", [Topic, Payload]), + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:publish(Pid, topic(Topic), Payload), + ok; +coap_put(_ChId, Prefix, Topic, Content) -> + ?LOG(error, "put has error, Prefix=~p, Topic=~p, Content=~p", [Prefix, Topic, Content]), + {error, bad_request}. + +coap_delete(_ChId, _Prefix, _Topic) -> + {error, method_not_allowed}. + +coap_observe(ChId, ?MQTT_PREFIX, Topic, Ack, Content) when Topic =/= [] -> + TrueTopic = topic(Topic), + ?LOG(debug, "observe Topic=~p, Ack=~p", [TrueTopic, Ack]), + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:subscribe(Pid, TrueTopic), + {ok, {state, ChId, ?MQTT_PREFIX, [TrueTopic]}, content, Content}; +coap_observe(ChId, Prefix, Topic, Ack, _Content) -> + ?LOG(error, "unknown observe request ChId=~p, Prefix=~p, Topic=~p, Ack=~p", [ChId, Prefix, Topic, Ack]), + {error, bad_request}. + +coap_unobserve({state, _ChId, ?MQTT_PREFIX, Topic}) when Topic =/= [] -> + ?LOG(debug, "unobserve ~p", [Topic]), + Pid = get(mqtt_client_pid), + emqx_coap_mqtt_adapter:unsubscribe(Pid, topic(Topic)), + ok; +coap_unobserve({state, ChId, Prefix, Topic}) -> + ?LOG(error, "ignore unknown unobserve request ChId=~p, Prefix=~p, Topic=~p", [ChId, Prefix, Topic]), + ok. + +handle_info({dispatch, Topic, Payload}, State) -> + ?LOG(debug, "dispatch Topic=~p, Payload=~p", [Topic, Payload]), + {notify, [], #coap_content{format = <<"application/octet-stream">>, payload = Payload}, State}; +handle_info(Message, State) -> + emqx_coap_mqtt_adapter:handle_info(Message, State). + +coap_ack(_Ref, State) -> {ok, State}. + +get_auth(Query) -> + get_auth(Query, #coap_mqtt_auth{}). + +get_auth([], Auth=#coap_mqtt_auth{}) -> + Auth; +get_auth([<<$c, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{clientid = Rest}); +get_auth([<<$u, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{username = Rest}); +get_auth([<<$p, $=, Rest/binary>>|T], Auth=#coap_mqtt_auth{}) -> + get_auth(T, Auth#coap_mqtt_auth{password = Rest}); +get_auth([Param|T], Auth=#coap_mqtt_auth{}) -> + ?LOG(error, "ignore unknown parameter ~p", [Param]), + get_auth(T, Auth). + +topic(Topic) when is_binary(Topic) -> Topic; +topic([]) -> <<>>; +topic([Path | TopicPath]) -> + case topic(TopicPath) of + <<>> -> Path; + RemTopic -> + <> + end. + diff --git a/apps/emqx_coap/src/emqx_coap_server.erl b/apps/emqx_coap/src/emqx_coap_server.erl new file mode 100644 index 000000000..0d571fac3 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_server.erl @@ -0,0 +1,106 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_server). + +-include("emqx_coap.hrl"). + +-export([ start/1 + , stop/1 + ]). + +-export([ start_listener/1 + , start_listener/3 + , stop_listener/1 + , stop_listener/2 + ]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start(Envs) -> + {ok, _} = application:ensure_all_started(gen_coap), + start_listeners(Envs). + +stop(Envs) -> + stop_listeners(Envs). + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +start_listeners(Envs) -> + lists:foreach(fun start_listener/1, listeners_confs(Envs)). + +stop_listeners(Envs) -> + lists:foreach(fun stop_listener/1, listeners_confs(Envs)). + +start_listener({Proto, ListenOn, Opts}) -> + case start_listener(Proto, ListenOn, Opts) of + {ok, _Pid} -> + io:format("Start coap:~s listener on ~s successfully.~n", + [Proto, format(ListenOn)]); + {error, Reason} -> + io:format(standard_error, "Failed to start coap:~s listener on ~s - ~0p~n!", + [Proto, format(ListenOn), Reason]), + error(Reason) + end. + +start_listener(udp, ListenOn, Opts) -> + coap_server:start_udp('coap:udp', ListenOn, Opts); +start_listener(dtls, ListenOn, Opts) -> + coap_server:start_dtls('coap:dtls', ListenOn, Opts). + +stop_listener({Proto, ListenOn, _Opts}) -> + Ret = stop_listener(Proto, ListenOn), + case Ret of + ok -> io:format("Stop coap:~s listener on ~s successfully.~n", + [Proto, format(ListenOn)]); + {error, Reason} -> + io:format(standard_error, "Failed to stop coap:~s listener on ~s - ~p~n.", + [Proto, format(ListenOn), Reason]) + end, + Ret. + +stop_listener(udp, ListenOn) -> + coap_server:stop_udp('coap:udp', ListenOn); +stop_listener(dtls, ListenOn) -> + coap_server:stop_dtls('coap:dtls', ListenOn). + +%% XXX: It is a temporary func to convert conf format for esockd +listeners_confs(Envs) -> + listeners_confs(udp, Envs) ++ listeners_confs(dtls, Envs). + +listeners_confs(udp, Envs) -> + Udps = proplists:get_value(bind_udp, Envs, []), + [{udp, Port, [{udp_options, InetOpts}]} || {Port, InetOpts} <- Udps]; + +listeners_confs(dtls, Envs) -> + case proplists:get_value(dtls_opts, Envs, []) of + [] -> []; + DtlsOpts -> + BindDtls = proplists:get_value(bind_dtls, Envs, []), + [{dtls, Port, [{dtls_options, InetOpts ++ DtlsOpts}]} || {Port, InetOpts} <- BindDtls] + end. + +format(Port) when is_integer(Port) -> + io_lib:format("0.0.0.0:~w", [Port]); +format({Addr, Port}) when is_list(Addr) -> + io_lib:format("~s:~w", [Addr, Port]); +format({Addr, Port}) when is_tuple(Addr) -> + io_lib:format("~s:~w", [inet:ntoa(Addr), Port]). + diff --git a/apps/emqx_coap/src/emqx_coap_sup.erl b/apps/emqx_coap/src/emqx_coap_sup.erl new file mode 100644 index 000000000..a3a0fdc53 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_sup.erl @@ -0,0 +1,42 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_sup). + +-behaviour(supervisor). + +-export([ start_link/0 + , init/1 + ]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init(_Args) -> + Registry = #{id => emqx_coap_registry, + start => {emqx_coap_registry, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [emqx_coap_registry]}, + PsTopics = #{id => emqx_coap_ps_topics, + start => {emqx_coap_ps_topics, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [emqx_coap_ps_topics]}, + {ok, {{one_for_all, 10, 3600}, [Registry, PsTopics]}}. + diff --git a/apps/emqx_coap/src/emqx_coap_timer.erl b/apps/emqx_coap/src/emqx_coap_timer.erl new file mode 100644 index 000000000..3924ba239 --- /dev/null +++ b/apps/emqx_coap/src/emqx_coap_timer.erl @@ -0,0 +1,59 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_timer). + +-include("emqx_coap.hrl"). + +-export([ cancel_timer/1 + , start_timer/2 + , restart_timer/1 + , kick_timer/1 + , is_timeout/1 + , get_timer_length/1 + ]). + +-record(timer_state, {interval, kickme, tref, message}). + +-define(LOG(Level, Format, Args), + emqx_logger:Level("CoAP-Timer: " ++ Format, Args)). + +cancel_timer(#timer_state{tref = TRef}) when is_reference(TRef) -> + catch erlang:cancel_timer(TRef), + ok; +cancel_timer(_) -> + ok. + +kick_timer(State=#timer_state{kickme = false}) -> + State#timer_state{kickme = true}; +kick_timer(State=#timer_state{kickme = true}) -> + State. + +start_timer(Sec, Msg) -> + ?LOG(debug, "emqx_coap_timer:start_timer ~p", [Sec]), + TRef = erlang:send_after(timer:seconds(Sec), self(), Msg), + #timer_state{interval = Sec, kickme = false, tref = TRef, message = Msg}. + +restart_timer(State=#timer_state{interval = Sec, message = Msg}) -> + ?LOG(debug, "emqx_coap_timer:restart_timer ~p", [Sec]), + TRef = erlang:send_after(timer:seconds(Sec), self(), Msg), + State#timer_state{kickme = false, tref = TRef}. + +is_timeout(#timer_state{kickme = Bool}) -> + not Bool. + +get_timer_length(#timer_state{interval = Interval}) -> + Interval. diff --git a/apps/emqx_coap/test/emqx_coap_SUITE.erl b/apps/emqx_coap/test/emqx_coap_SUITE.erl new file mode 100644 index 000000000..672113e57 --- /dev/null +++ b/apps/emqx_coap/test/emqx_coap_SUITE.erl @@ -0,0 +1,283 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("gen_coap/include/coap.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-define(LOGT(Format, Args), ct:pal(Format, Args)). + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx_coap], fun set_sepecial_cfg/1), + Config. + +set_sepecial_cfg(emqx_coap) -> + Opts = application:get_env(emqx_coap, dtls_opts,[]), + Opts2 = [{keyfile, emqx_ct_helpers:deps_path(emqx, "etc/certs/key.pem")}, + {certfile, emqx_ct_helpers:deps_path(emqx, "etc/certs/cert.pem")}], + application:set_env(emqx_coap, dtls_opts, emqx_misc:merge_opts(Opts, Opts2)), + application:set_env(emqx_coap, enable_stats, true); +set_sepecial_cfg(_) -> + ok. + +end_per_suite(Config) -> + emqx_ct_helpers:stop_apps([emqx_coap]), + Config. + +%%-------------------------------------------------------------------- +%% Test Cases +%%-------------------------------------------------------------------- + +t_publish(_Config) -> + Topic = <<"abc">>, Payload = <<"123">>, + TopicStr = binary_to_list(Topic), + URI = "coap://127.0.0.1/mqtt/"++TopicStr++"?c=client1&u=tom&p=secret", + + %% Sub topic first + emqx:subscribe(Topic), + + Reply = er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + {ok, changed, _} = Reply, + + receive + {deliver, Topic, Msg} -> + ?assertEqual(Topic, Msg#message.topic), + ?assertEqual(Payload, Msg#message.payload) + after + 500 -> + ?assert(false) + end. + +t_observe(_Config) -> + Topic = <<"abc">>, TopicStr = binary_to_list(Topic), + Payload = <<"123">>, + Uri = "coap://127.0.0.1/mqtt/"++TopicStr++"?c=client1&u=tom&p=secret", + {ok, Pid, N, Code, Content} = er_coap_observer:observe(Uri), + ?LOGT("observer Pid=~p, N=~p, Code=~p, Content=~p", [Pid, N, Code, Content]), + + [SubPid] = emqx:subscribers(Topic), + ?assert(is_pid(SubPid)), + + %% Publish a message + emqx:publish(emqx_message:make(Topic, Payload)), + + Notif = receive_notification(), + ?LOGT("observer get Notif=~p", [Notif]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv}} = Notif, + ?assertEqual(Payload, PayloadRecv), + + er_coap_observer:stop(Pid), + timer:sleep(100), + + [] = emqx:subscribers(Topic). + +t_observe_wildcard(_Config) -> + Topic = <<"+/b">>, TopicStr = http_uri:encode(binary_to_list(Topic)), + Payload = <<"123">>, + Uri = "coap://127.0.0.1/mqtt/"++TopicStr++"?c=client1&u=tom&p=secret", + {ok, Pid, N, Code, Content} = er_coap_observer:observe(Uri), + ?LOGT("observer Uri=~p, Pid=~p, N=~p, Code=~p, Content=~p", [Uri, Pid, N, Code, Content]), + + [SubPid] = emqx:subscribers(Topic), + ?assert(is_pid(SubPid)), + + %% Publish a message + emqx:publish(emqx_message:make(Topic, Payload)), + + Notif = receive_notification(), + ?LOGT("observer get Notif=~p", [Notif]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv}} = Notif, + ?assertEqual(Payload, PayloadRecv), + + er_coap_observer:stop(Pid), + timer:sleep(100), + + [] = emqx:subscribers(Topic). + +t_observe_pub(_Config) -> + Topic = <<"+/b">>, TopicStr = http_uri:encode(binary_to_list(Topic)), + Uri = "coap://127.0.0.1/mqtt/"++TopicStr++"?c=client1&u=tom&p=secret", + {ok, Pid, N, Code, Content} = er_coap_observer:observe(Uri), + ?LOGT("observer Pid=~p, N=~p, Code=~p, Content=~p", [Pid, N, Code, Content]), + + [SubPid] = emqx:subscribers(Topic), + ?assert(is_pid(SubPid)), + + Topic2 = <<"a/b">>, Payload2 = <<"UFO">>, + TopicStr2 = http_uri:encode(binary_to_list(Topic2)), + URI2 = "coap://127.0.0.1/mqtt/"++TopicStr2++"?c=client1&u=tom&p=secret", + + Reply2 = er_coap_client:request(put, URI2, #coap_content{format = <<"application/octet-stream">>, payload = Payload2}), + {ok,changed, _} = Reply2, + + Notif2 = receive_notification(), + ?LOGT("observer get Notif2=~p", [Notif2]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv2}} = Notif2, + ?assertEqual(Payload2, PayloadRecv2), + + Topic3 = <<"j/b">>, Payload3 = <<"ET629">>, + TopicStr3 = http_uri:encode(binary_to_list(Topic3)), + URI3 = "coap://127.0.0.1/mqtt/"++TopicStr3++"?c=client2&u=mike&p=guess", + Reply3 = er_coap_client:request(put, URI3, #coap_content{format = <<"application/octet-stream">>, payload = Payload3}), + {ok,changed, _} = Reply3, + + Notif3 = receive_notification(), + ?LOGT("observer get Notif3=~p", [Notif3]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv3}} = Notif3, + ?assertEqual(Payload3, PayloadRecv3), + + er_coap_observer:stop(Pid). + +t_one_clientid_sub_2_topics(_Config) -> + Topic1 = <<"abc">>, TopicStr1 = binary_to_list(Topic1), + Payload1 = <<"123">>, + Uri1 = "coap://127.0.0.1/mqtt/"++TopicStr1++"?c=client1&u=tom&p=secret", + {ok, Pid1, N1, Code1, Content1} = er_coap_observer:observe(Uri1), + ?LOGT("observer 1 Pid=~p, N=~p, Code=~p, Content=~p", [Pid1, N1, Code1, Content1]), + + [SubPid] = emqx:subscribers(Topic1), + ?assert(is_pid(SubPid)), + + Topic2 = <<"x/y">>, TopicStr2 = http_uri:encode(binary_to_list(Topic2)), + Payload2 = <<"456">>, + Uri2 = "coap://127.0.0.1/mqtt/"++TopicStr2++"?c=client1&u=tom&p=secret", + {ok, Pid2, N2, Code2, Content2} = er_coap_observer:observe(Uri2), + ?LOGT("observer 2 Pid=~p, N=~p, Code=~p, Content=~p", [Pid2, N2, Code2, Content2]), + + [SubPid] = emqx:subscribers(Topic2), + ?assert(is_pid(SubPid)), + + emqx:publish(emqx_message:make(Topic1, Payload1)), + + Notif1 = receive_notification(), + ?LOGT("observer 1 get Notif=~p", [Notif1]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv1}} = Notif1, + ?assertEqual(Payload1, PayloadRecv1), + + emqx:publish(emqx_message:make(Topic2, Payload2)), + + Notif2 = receive_notification(), + ?LOGT("observer 2 get Notif=~p", [Notif2]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv2}} = Notif2, + ?assertEqual(Payload2, PayloadRecv2), + + er_coap_observer:stop(Pid1), + er_coap_observer:stop(Pid2). + +t_invalid_parameter(_Config) -> + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% "cid=client2" is invaid + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + Topic3 = <<"a/b">>, Payload3 = <<"ET629">>, + TopicStr3 = http_uri:encode(binary_to_list(Topic3)), + URI3 = "coap://127.0.0.1/mqtt/"++TopicStr3++"?cid=client2&u=tom&p=simple", + Reply3 = er_coap_client:request(put, URI3, #coap_content{format = <<"application/octet-stream">>, payload = Payload3}), + ?assertMatch({error,bad_request}, Reply3), + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% "what=hello" is invaid + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + URI4 = "coap://127.0.0.1/mqtt/"++TopicStr3++"?what=hello", + Reply4 = er_coap_client:request(put, URI4, #coap_content{format = <<"application/octet-stream">>, payload = Payload3}), + ?assertMatch({error, bad_request}, Reply4). + +t_invalid_topic(_Config) -> + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% "a/b" is a valid topic string + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + Topic3 = <<"a/b">>, Payload3 = <<"ET629">>, + TopicStr3 = binary_to_list(Topic3), + URI3 = "coap://127.0.0.1/mqtt/"++TopicStr3++"?c=client2&u=tom&p=simple", + Reply3 = er_coap_client:request(put, URI3, #coap_content{format = <<"application/octet-stream">>, payload = Payload3}), + ?assertMatch({ok,changed,_Content}, Reply3), + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% "+?#" is invaid topic string + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + URI4 = "coap://127.0.0.1/mqtt/"++"+?#"++"?what=hello", + Reply4 = er_coap_client:request(put, URI4, #coap_content{format = <<"application/octet-stream">>, payload = Payload3}), + ?assertMatch({error,bad_request}, Reply4). + +% mqtt connection kicked by coap with same client id +t_kick_1(_Config) -> + URI = "coap://127.0.0.1/mqtt/abc?c=clientid&u=tom&p=secret", + % workaround: emqx:subscribe does not kick same client id. + spawn_monitor(fun() -> + {ok, C} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"clientid">>}, + {username, <<"plain">>}, + {password, <<"plain">>}]), + {ok, _} = emqtt:connect(C) end), + er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, + payload = <<"123">>}), + receive + {'DOWN', _, _, _, _} -> ok + after 2000 -> + ?assert(false) + end. + +% mqtt connection kicked by coap with same client id +t_acl(Config) -> + %% Update acl file and reload mod_acl_internal + Path = filename:join([testdir(proplists:get_value(data_dir, Config)), "deny.conf"]), + ok = file:write_file(Path, <<"{deny, {user, \"coap\"}, publish, [\"abc\"]}.">>), + OldPath = emqx:get_env(acl_file), + emqx_mod_acl_internal:reload([{acl_file, Path}]), + + emqx:subscribe(<<"abc">>), + URI = "coap://127.0.0.1/mqtt/adbc?c=client1&u=coap&p=secret", + er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, + payload = <<"123">>}), + receive + _Something -> ?assert(false) + after 2000 -> + ok + end, + + application:set_env(emqx, acl_file, OldPath), + file:delete(Path), + emqx_mod_acl_internal:reload([{acl_file, OldPath}]). + +t_stats(_) -> + ok. + +t_auth_failure(_) -> + ok. + +t_qos_supprot(_) -> + ok. + +%%-------------------------------------------------------------------- +%% Helpers + +receive_notification() -> + receive + {coap_notify, Pid, N2, Code2, Content2} -> + {coap_notify, Pid, N2, Code2, Content2} + after 2000 -> + receive_notification_timeout + end. + +testdir(DataPath) -> + Ls = filename:split(DataPath), + filename:join(lists:sublist(Ls, 1, length(Ls) - 1)). diff --git a/apps/emqx_coap/test/emqx_coap_ps_SUITE.erl b/apps/emqx_coap/test/emqx_coap_ps_SUITE.erl new file mode 100644 index 000000000..2bde5dfbd --- /dev/null +++ b/apps/emqx_coap/test/emqx_coap_ps_SUITE.erl @@ -0,0 +1,676 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_coap_ps_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("gen_coap/include/coap.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-define(LOGT(Format, Args), ct:pal(Format, Args)). + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx_coap], fun set_sepecial_cfg/1), + Config. + +set_sepecial_cfg(emqx_coap) -> + application:set_env(emqx_coap, enable_stats, true); +set_sepecial_cfg(_) -> + ok. + +end_per_suite(Config) -> + emqx_ct_helpers:stop_apps([emqx_coap]), + Config. + +%%-------------------------------------------------------------------- +%% Test Cases +%%-------------------------------------------------------------------- + +t_update_max_age(_Config) -> + TopicInPayload = <<"topic1">>, + Payload = <<";ct=42">>, + Payload1 = <<";ct=50">>, + URI = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + URI2 = "coap://127.0.0.1/ps/topic1"++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + TopicInfo = [{TopicInPayload, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + timer:sleep(50), + + %% post to create the same topic but with different max age and ct value in payload + Reply1 = er_coap_client:request(post, URI, #coap_content{max_age = 70, format = <<"application/link-format">>, payload = Payload1}), + {ok,created, #coap_content{location_path = LocPath}} = Reply1, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{TopicInPayload, MaxAge2, CT2, _ResPayload, _TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?assertEqual(70, MaxAge2), + ?assertEqual(<<"50">>, CT2), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI2). + +t_create_subtopic(_Config) -> + TopicInPayload = <<"topic1">>, + TopicInPayloadStr = "topic1", + Payload = <<";ct=42">>, + URI = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + RealURI = "coap://127.0.0.1/ps/topic1"++"?c=client1&u=tom&p=secret", + + Reply = er_coap_client:request(post, URI, #coap_content{format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + TopicInfo = [{TopicInPayload, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + timer:sleep(50), + + %% post to create the a sub topic + SubPayload = <<";ct=42">>, + SubTopicInPayloadStr = "subtopic", + SubURI = "coap://127.0.0.1/ps/"++TopicInPayloadStr++"?c=client1&u=tom&p=secret", + SubRealURI = "coap://127.0.0.1/ps/"++TopicInPayloadStr++"/"++SubTopicInPayloadStr++"?c=client1&u=tom&p=secret", + FullTopic = list_to_binary(TopicInPayloadStr++"/"++SubTopicInPayloadStr), + Reply1 = er_coap_client:request(post, SubURI, #coap_content{format = <<"application/link-format">>, payload = SubPayload}), + ?LOGT("Reply =~p", [Reply1]), + {ok,created, #coap_content{location_path = LocPath1}} = Reply1, + ?assertEqual([<<"/ps/topic1/subtopic">>] ,LocPath1), + [{FullTopic, MaxAge2, CT2, _ResPayload, _}] = emqx_coap_ps_topics:lookup_topic_info(FullTopic), + ?assertEqual(60, MaxAge2), + ?assertEqual(<<"42">>, CT2), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, SubRealURI), + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, RealURI). + +t_over_max_age(_Config) -> + TopicInPayload = <<"topic1">>, + Payload = <<";ct=42">>, + URI = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{max_age = 2, format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + TopicInfo = [{TopicInPayload, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(2, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + timer:sleep(3000), + ?assertEqual(true, emqx_coap_ps_topics:is_topic_timeout(TopicInPayload)). + +t_refreash_max_age(_Config) -> + TopicInPayload = <<"topic1">>, + Payload = <<";ct=42">>, + Payload1 = <<";ct=50">>, + URI = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + RealURI = "coap://127.0.0.1/ps/topic1"++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{max_age = 5, format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + TopicInfo = [{TopicInPayload, MaxAge1, CT1, _ResPayload, TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?LOGT("TimeStamp=~p", [TimeStamp]), + ?assertEqual(5, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + timer:sleep(3000), + + %% post to create the same topic, the max age timer will be restarted with the new max age value + Reply1 = er_coap_client:request(post, URI, #coap_content{max_age = 5, format = <<"application/link-format">>, payload = Payload1}), + {ok,created, #coap_content{location_path = LocPath}} = Reply1, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{TopicInPayload, MaxAge2, CT2, _ResPayload, TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(TopicInPayload), + ?LOGT("TimeStamp1=~p", [TimeStamp1]), + ?assertEqual(5, MaxAge2), + ?assertEqual(<<"50">>, CT2), + + timer:sleep(3000), + ?assertEqual(false, emqx_coap_ps_topics:is_topic_timeout(TopicInPayload)), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, RealURI). + +t_case01_publish_post(_Config) -> + timer:sleep(100), + MainTopic = <<"maintopic">>, + TopicInPayload = <<"topic1">>, + Payload = <<";ct=42">>, + MainTopicStr = binary_to_list(MainTopic), + + %% post to create topic maintopic/topic1 + URI1 = "coap://127.0.0.1/ps/"++MainTopicStr++"?c=client1&u=tom&p=secret", + FullTopic = list_to_binary(MainTopicStr++"/"++binary_to_list(TopicInPayload)), + Reply1 = er_coap_client:request(post, URI1, #coap_content{format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply1]), + {ok,created, #coap_content{location_path = LocPath1}} = Reply1, + ?assertEqual([<<"/ps/maintopic/topic1">>] ,LocPath1), + [{FullTopic, MaxAge, CT2, <<>>, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(FullTopic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT2), + + %% post to publish message to topic maintopic/topic1 + FullTopicStr = http_uri:encode(binary_to_list(FullTopic)), + URI2 = "coap://127.0.0.1/ps/"++FullTopicStr++"?c=client1&u=tom&p=secret", + PubPayload = <<"PUBLISH">>, + + %% Sub topic first + emqx:subscribe(FullTopic), + + Reply2 = er_coap_client:request(post, URI2, #coap_content{format = <<"application/octet-stream">>, payload = PubPayload}), + ?LOGT("Reply =~p", [Reply2]), + {ok,changed, _} = Reply2, + TopicInfo = [{FullTopic, MaxAge, CT2, PubPayload, _TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(FullTopic), + ?LOGT("the topic info =~p", [TopicInfo]), + + assert_recv(FullTopic, PubPayload), + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI2). + +t_case02_publish_post(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% Sub topic first + emqx:subscribe(Topic), + + %% post to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT), + + assert_recv(Topic, Payload), + + %% post to publish a new message to the same topic "topic1" with different payload + NewPayload = <<"newpayload">>, + Reply1 = er_coap_client:request(post, URI, #coap_content{format = <<"application/octet-stream">>, payload = NewPayload}), + ?LOGT("Reply =~p", [Reply1]), + {ok,changed, _} = Reply1, + [{Topic, MaxAge, CT, NewPayload, _TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + + assert_recv(Topic, NewPayload), + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case03_publish_post(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% Sub topic first + emqx:subscribe(Topic), + + %% post to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT), + + assert_recv(Topic, Payload), + + %% post to publish a new message to the same topic "topic1", but the ct is not same as created + NewPayload = <<"newpayload">>, + Reply1 = er_coap_client:request(post, URI, #coap_content{format = <<"application/exi">>, payload = NewPayload}), + ?LOGT("Reply =~p", [Reply1]), + ?assertEqual({error,bad_request}, Reply1), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case04_publish_post(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% post to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{max_age = 5, format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(5, MaxAge), + ?assertEqual(<<"42">>, CT), + + %% after max age timeout, the topic still exists but the status is timeout + timer:sleep(6000), + ?assertEqual(true, emqx_coap_ps_topics:is_topic_timeout(Topic)), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case01_publish_put(_Config) -> + MainTopic = <<"maintopic">>, + TopicInPayload = <<"topic1">>, + Payload = <<";ct=42">>, + MainTopicStr = binary_to_list(MainTopic), + + %% post to create topic maintopic/topic1 + URI1 = "coap://127.0.0.1/ps/"++MainTopicStr++"?c=client1&u=tom&p=secret", + FullTopic = list_to_binary(MainTopicStr++"/"++binary_to_list(TopicInPayload)), + Reply1 = er_coap_client:request(post, URI1, #coap_content{format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply1]), + {ok,created, #coap_content{location_path = LocPath1}} = Reply1, + ?assertEqual([<<"/ps/maintopic/topic1">>] ,LocPath1), + [{FullTopic, MaxAge, CT2, <<>>, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(FullTopic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT2), + + %% put to publish message to topic maintopic/topic1 + FullTopicStr = http_uri:encode(binary_to_list(FullTopic)), + URI2 = "coap://127.0.0.1/ps/"++FullTopicStr++"?c=client1&u=tom&p=secret", + PubPayload = <<"PUBLISH">>, + + %% Sub topic first + emqx:subscribe(FullTopic), + + Reply2 = er_coap_client:request(put, URI2, #coap_content{format = <<"application/octet-stream">>, payload = PubPayload}), + ?LOGT("Reply =~p", [Reply2]), + {ok,changed, _} = Reply2, + [{FullTopic, MaxAge, CT2, PubPayload, _TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(FullTopic), + + assert_recv(FullTopic, PubPayload), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI2). + +t_case02_publish_put(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% Sub topic first + emqx:subscribe(Topic), + + %% put to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT), + + assert_recv(Topic, Payload), + + %% put to publish a new message to the same topic "topic1" with different payload + NewPayload = <<"newpayload">>, + Reply1 = er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, payload = NewPayload}), + ?LOGT("Reply =~p", [Reply1]), + {ok,changed, _} = Reply1, + [{Topic, MaxAge, CT, NewPayload, _TimeStamp1}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + + assert_recv(Topic, NewPayload), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case03_publish_put(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% Sub topic first + emqx:subscribe(Topic), + + %% put to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(60, MaxAge), + ?assertEqual(<<"42">>, CT), + + assert_recv(Topic, Payload), + + %% put to publish a new message to the same topic "topic1", but the ct is not same as created + NewPayload = <<"newpayload">>, + Reply1 = er_coap_client:request(put, URI, #coap_content{format = <<"application/exi">>, payload = NewPayload}), + ?LOGT("Reply =~p", [Reply1]), + ?assertEqual({error,bad_request}, Reply1), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case04_publish_put(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"payload">>, + + %% put to publish a new topic "topic1", and the topic is created + URI = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(put, URI, #coap_content{max_age = 5, format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/topic1">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(5, MaxAge), + ?assertEqual(<<"42">>, CT), + + %% after max age timeout, no publish message to the same topic, the topic info will be deleted + %%%%%%%%%%%%%%%%%%%%%%%%%% + % but there is one thing to do is we don't count in the publish message received from emqx(from other node).TBD!!!!!!!!!!!!! + %%%%%%%%%%%%%%%%%%%%%%%%%% + timer:sleep(6000), + ?assertEqual(true, emqx_coap_ps_topics:is_topic_timeout(Topic)), + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case01_subscribe(_Config) -> + Topic = <<"topic1">>, + Payload1 = <<";ct=42">>, + timer:sleep(100), + + %% First post to create a topic "topic1" + Uri = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, Uri, #coap_content{format = <<"application/link-format">>, payload = Payload1}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = [LocPath]}} = Reply, + ?assertEqual(<<"/ps/topic1">> ,LocPath), + TopicInfo = [{Topic, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + %% Subscribe the topic + Uri1 = "coap://127.0.0.1"++binary_to_list(LocPath)++"?c=client1&u=tom&p=secret", + {ok, Pid, N, Code, Content} = er_coap_observer:observe(Uri1), + ?LOGT("observer Pid=~p, N=~p, Code=~p, Content=~p", [Pid, N, Code, Content]), + + [SubPid] = emqx:subscribers(Topic), + ?assert(is_pid(SubPid)), + + %% Publish a message + Payload = <<"123">>, + emqx:publish(emqx_message:make(Topic, Payload)), + + Notif = receive_notification(), + ?LOGT("observer get Notif=~p", [Notif]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = PayloadRecv}} = Notif, + + ?assertEqual(Payload, PayloadRecv), + + %% GET to read the publish message of the topic + Reply1 = er_coap_client:request(get, Uri1), + ?LOGT("Reply=~p", [Reply1]), + {ok,content, #coap_content{payload = <<"123">>}} = Reply1, + + er_coap_observer:stop(Pid), + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, Uri1). + +t_case02_subscribe(_Config) -> + Topic = <<"a/b">>, + TopicStr = binary_to_list(Topic), + PercentEncodedTopic = http_uri:encode(TopicStr), + Payload = <<"payload">>, + + %% post to publish a new topic "a/b", and the topic is created + URI = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{max_age = 5, format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/a/b">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(5, MaxAge), + ?assertEqual(<<"42">>, CT), + + %% Wait for the max age of the timer expires + timer:sleep(6000), + ?assertEqual(true, emqx_coap_ps_topics:is_topic_timeout(Topic)), + + %% Subscribe to the timeout topic "a/b", still successfully,got {ok, nocontent} Method + Uri = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + Reply1 = {ok, Pid, _N, nocontent, _} = er_coap_observer:observe(Uri), + ?LOGT("Subscribe Reply=~p", [Reply1]), + + [SubPid] = emqx:subscribers(Topic), + ?assert(is_pid(SubPid)), + + %% put to publish to topic "a/b" + Reply2 = er_coap_client:request(put, URI, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + {ok,changed, #coap_content{}} = Reply2, + [{Topic, MaxAge1, CT, Payload, TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT), + ?assertEqual(false, TimeStamp =:= timeout), + + %% Publish a message + emqx:publish(emqx_message:make(Topic, Payload)), + + Notif = receive_notification(), + ?LOGT("observer get Notif=~p", [Notif]), + {coap_notify, _, _, {ok,content}, #coap_content{payload = Payload}} = Notif, + + er_coap_observer:stop(Pid), + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case03_subscribe(_Config) -> + %% Subscribe to the unexisted topic "a/b", got not_found + Topic = <<"a/b">>, + TopicStr = binary_to_list(Topic), + PercentEncodedTopic = http_uri:encode(TopicStr), + Uri = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + {error, not_found} = er_coap_observer:observe(Uri), + + [] = emqx:subscribers(Topic). + +t_case04_subscribe(_Config) -> + %% Subscribe to the wildcad topic "+/b", got bad_request + Topic = <<"+/b">>, + TopicStr = binary_to_list(Topic), + PercentEncodedTopic = http_uri:encode(TopicStr), + Uri = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + {error, bad_request} = er_coap_observer:observe(Uri), + + [] = emqx:subscribers(Topic). + +t_case01_read(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"PubPayload">>, + timer:sleep(100), + + %% First post to create a topic "topic1" + Uri = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, Uri, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = [LocPath]}} = Reply, + ?assertEqual(<<"/ps/topic1">> ,LocPath), + TopicInfo = [{Topic, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + %% GET to read the publish message of the topic + timer:sleep(1000), + Reply1 = er_coap_client:request(get, Uri), + ?LOGT("Reply=~p", [Reply1]), + {ok,content, #coap_content{payload = Payload}} = Reply1, + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, Uri). + +t_case02_read(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"PubPayload">>, + timer:sleep(100), + + %% First post to publish a topic "topic1" + Uri = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, Uri, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = [LocPath]}} = Reply, + ?assertEqual(<<"/ps/topic1">> ,LocPath), + TopicInfo = [{Topic, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + %% GET to read the publish message of unmatched format, got bad_request + Reply1 = er_coap_client:request(get, Uri, #coap_content{format = <<"application/json">>}), + ?LOGT("Reply=~p", [Reply1]), + {error, bad_request} = Reply1, + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, Uri). + +t_case03_read(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Uri = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + timer:sleep(100), + + %% GET to read the nexisted topic "topic1", got not_found + Reply = er_coap_client:request(get, Uri), + ?LOGT("Reply=~p", [Reply]), + {error, not_found} = Reply. + +t_case04_read(_Config) -> + Topic = <<"topic1">>, + TopicStr = binary_to_list(Topic), + Payload = <<"PubPayload">>, + timer:sleep(100), + + %% First post to publish a topic "topic1" + Uri = "coap://127.0.0.1/ps/"++TopicStr++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, Uri, #coap_content{format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = [LocPath]}} = Reply, + ?assertEqual(<<"/ps/topic1">> ,LocPath), + TopicInfo = [{Topic, MaxAge1, CT1, _ResPayload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?LOGT("lookup topic info=~p", [TopicInfo]), + ?assertEqual(60, MaxAge1), + ?assertEqual(<<"42">>, CT1), + + %% GET to read the publish message of wildcard topic, got bad_request + WildTopic = binary_to_list(<<"+/topic1">>), + Uri1 = "coap://127.0.0.1/ps/"++WildTopic++"?c=client1&u=tom&p=secret", + Reply1 = er_coap_client:request(get, Uri1, #coap_content{format = <<"application/json">>}), + ?LOGT("Reply=~p", [Reply1]), + {error, bad_request} = Reply1, + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, Uri). + +t_case05_read(_Config) -> + Topic = <<"a/b">>, + TopicStr = binary_to_list(Topic), + PercentEncodedTopic = http_uri:encode(TopicStr), + Payload = <<"payload">>, + + %% post to publish a new topic "a/b", and the topic is created + URI = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + Reply = er_coap_client:request(post, URI, #coap_content{max_age = 5, format = <<"application/octet-stream">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/a/b">>] ,LocPath), + [{Topic, MaxAge, CT, Payload, _TimeStamp}] = emqx_coap_ps_topics:lookup_topic_info(Topic), + ?assertEqual(5, MaxAge), + ?assertEqual(<<"42">>, CT), + + %% Wait for the max age of the timer expires + timer:sleep(6000), + ?assertEqual(true, emqx_coap_ps_topics:is_topic_timeout(Topic)), + + %% GET to read the expired publish message, supposed to get {ok, nocontent}, but now got {ok, content} + Reply1 = er_coap_client:request(get, URI), + ?LOGT("Reply=~p", [Reply1]), + {ok, content, #coap_content{payload = <<>>}}= Reply1, + + {ok, deleted, #coap_content{}} = er_coap_client:request(delete, URI). + +t_case01_delete(_Config) -> + TopicInPayload = <<"a/b">>, + TopicStr = binary_to_list(TopicInPayload), + PercentEncodedTopic = http_uri:encode(TopicStr), + Payload = list_to_binary("<"++PercentEncodedTopic++">;ct=42"), + URI = "coap://127.0.0.1/ps/"++"?c=client1&u=tom&p=secret", + + %% Client post to CREATE topic "a/b" + Reply = er_coap_client:request(post, URI, #coap_content{format = <<"application/link-format">>, payload = Payload}), + ?LOGT("Reply =~p", [Reply]), + {ok,created, #coap_content{location_path = LocPath}} = Reply, + ?assertEqual([<<"/ps/a/b">>] ,LocPath), + + %% Client post to CREATE topic "a/b/c" + TopicInPayload1 = <<"a/b/c">>, + PercentEncodedTopic1 = http_uri:encode(binary_to_list(TopicInPayload1)), + Payload1 = list_to_binary("<"++PercentEncodedTopic1++">;ct=42"), + Reply1 = er_coap_client:request(post, URI, #coap_content{format = <<"application/link-format">>, payload = Payload1}), + ?LOGT("Reply =~p", [Reply1]), + {ok,created, #coap_content{location_path = LocPath1}} = Reply1, + ?assertEqual([<<"/ps/a/b/c">>] ,LocPath1), + + timer:sleep(50), + + %% DELETE the topic "a/b" + UriD = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + ReplyD = er_coap_client:request(delete, UriD), + ?LOGT("Reply=~p", [Reply1]), + {ok, deleted, #coap_content{}}= ReplyD, + + ?assertEqual(false, emqx_coap_ps_topics:is_topic_existed(TopicInPayload)), + ?assertEqual(false, emqx_coap_ps_topics:is_topic_existed(TopicInPayload1)). + +t_case02_delete(_Config) -> + TopicInPayload = <<"a/b">>, + TopicStr = binary_to_list(TopicInPayload), + PercentEncodedTopic = http_uri:encode(TopicStr), + + %% DELETE the unexisted topic "a/b" + Uri1 = "coap://127.0.0.1/ps/"++PercentEncodedTopic++"?c=client1&u=tom&p=secret", + Reply1 = er_coap_client:request(delete, Uri1), + ?LOGT("Reply=~p", [Reply1]), + {error, not_found} = Reply1. + +t_case13_emit_stats_test(_Config) -> + ok. + +%%-------------------------------------------------------------------- +%% Internal functions + +receive_notification() -> + receive + {coap_notify, Pid, N2, Code2, Content2} -> + {coap_notify, Pid, N2, Code2, Content2} + after 2000 -> + receive_notification_timeout + end. + +assert_recv(Topic, Payload) -> + receive + {deliver, _, Msg} -> + ?assertEqual(Topic, Msg#message.topic), + ?assertEqual(Payload, Msg#message.payload) + after + 500 -> + ?assert(false) + end. + diff --git a/apps/emqx_dashboard/.gitignore b/apps/emqx_dashboard/.gitignore new file mode 100644 index 000000000..d19e1b7d5 --- /dev/null +++ b/apps/emqx_dashboard/.gitignore @@ -0,0 +1,25 @@ +.eunit +deps +*.o +*.beam +*.plt +erl_crash.dump +ebin +rel/example_project +.concrete/DEV_MODE +.rebar +.erlang.mk/ +ct.coverdata +logs/ +test/ct.cover.spec +data/ +.DS_Store +emqx_dashboard.d +cover/ +eunit.coverdata +.DS_Store +erlang.mk +rebar.lock +_build/ +*.conf.rendered +.rebar3/ diff --git a/apps/emqx_dashboard/README.md b/apps/emqx_dashboard/README.md new file mode 100644 index 000000000..e9e50a7c9 --- /dev/null +++ b/apps/emqx_dashboard/README.md @@ -0,0 +1,88 @@ + +emqx-dashboard +============== + +Dashboard for the EMQ X Broker. + +REST API +-------- + +The prefix of REST API is '/api/v4/'. + +Method | Path | Description +-------|---------------------------------------|------------------------------------ +GET | /nodes/ | A list of nodes in the cluster +GET | /nodes/:node | Lookup a node in the cluster +GET | /brokers/ | A list of brokers in the cluster +GET | /brokers/:node | Get broker info of a node +GET | /metrics/ | A list of metrics of all nodes in the cluster +GET | /nodes/:node/metrics/ | A list of metrics of a node +GET | /stats/ | A list of stats of all nodes in the cluster +GET | /nodes/:node/stats/ | A list of stats of a node +GET | /nodes/:node/clients/ | A list of clients on a node +GET | /listeners/ | A list of listeners in the cluster +GET | /nodes/:node/listeners | A list of listeners on the node +GET | /nodes/:node/sessions/ | A list of sessions on a node +GET | /subscriptions/:clientid | A list of subscriptions of a client +GET | /nodes/:node/subscriptions/:clientid | A list of subscriptions of a client on the node +GET | /nodes/:node/subscriptions/ | A list of subscriptions on a node +PUT | /clients/:clientid/clean_acl_cache | Clean ACL cache of a client +GET | /configs/ | Get all configs +GET | /nodes/:node/configs/ | Get all configs of a node +GET | /nodes/:node/plugin_configs/:plugin | Get configurations of a plugin on the node +DELETE | /clients/:clientid | Kick out a client +GET | /alarms/:node | List alarms of a node +GET | /alarms/ | List all alarms +GET | /plugins/ | List all plugins in the cluster +GET | /nodes/:node/plugins/ | List all plugins on a node +GET | /routes/ | List routes +POST | /nodes/:node/plugins/:plugin/load | Load a plugin +GET | /clients/:clientid | Lookup a client in the cluster +GET | nodes/:node/clients/:clientid | Lookup a client on node +GET | nodes/:node/sessions/:clientid | Lookup a session in the cluster +GET | nodes/:node/sessions/:clientid | Lookup a session on the node +POST | /mqtt/publish | Publish a MQTT message +POST | /mqtt/subscribe | Subscribe a topic +POST | /nodes/:node/plugins/:plugin/unload | Unload a plugin +POST | /mqtt/unsubscribe | Unsubscribe a topic +PUT | /configs/:app | Update config of an application in the cluster +PUT | /nodes/:node/configs/:app | Update config of an application on a node +PUT | /nodes/:node/plugin_configs/:plugin | Update configurations of a plugin on the node + +Build +----- + +make && make ct + +Configurtion +------------ + +``` +dashboard.listener = 18083 + +dashboard.listener.acceptors = 2 + +dashboard.listener.max_clients = 512 +``` + +Load Plugin +----------- + +``` +./bin/emqx_ctl plugins load emqx_dashboard +``` + +Login +----- + +URL: http://host:18083 + +Username: admin + +Password: public + +License +------- + +Apache License Version 2.0 + diff --git a/apps/emqx_dashboard/etc/emqx_dashboard.conf b/apps/emqx_dashboard/etc/emqx_dashboard.conf new file mode 100644 index 000000000..7c2125b4c --- /dev/null +++ b/apps/emqx_dashboard/etc/emqx_dashboard.conf @@ -0,0 +1,129 @@ +##-------------------------------------------------------------------- +## EMQ X Dashboard +##-------------------------------------------------------------------- + +## Default user's login name. +## +## Value: String +dashboard.default_user.login = admin + +## Default user's password. +## +## Value: String +dashboard.default_user.password = public + +##-------------------------------------------------------------------- +## HTTP Listener + +## The port that the Dashboard HTTP listener will bind. +## +## Value: Port +## +## Examples: 18083 +dashboard.listener.http = 18083 + +## The acceptor pool for external Dashboard HTTP listener. +## +## Value: Number +dashboard.listener.http.acceptors = 4 + +## Maximum number of concurrent Dashboard HTTP connections. +## +## Value: Number +dashboard.listener.http.max_clients = 512 + +## Set up the socket for IPv6. +## +## Value: false | true +dashboard.listener.http.inet6 = false + +## Listen on IPv4 and IPv6 (false) or only on IPv6 (true). Use with inet6. +## +## Value: false | true +dashboard.listener.http.ipv6_v6only = false + +##-------------------------------------------------------------------- +## HTTPS Listener + +## The port that the Dashboard HTTPS listener will bind. +## +## Value: Port +## +## Examples: 18084 +## dashboard.listener.https = 18084 + +## The acceptor pool for external Dashboard HTTPS listener. +## +## Value: Number +## dashboard.listener.https.acceptors = 2 + +## Maximum number of concurrent Dashboard HTTPS connections. +## +## Value: Number +## dashboard.listener.https.max_clients = 512 + +## Set up the socket for IPv6. +## +## Value: false | true +## dashboard.listener.https.inet6 = false + +## Listen on IPv4 and IPv6 (false) or only on IPv6 (true). Use with inet6. +## +## Value: false | true +## dashboard.listener.https.ipv6_v6only = false + +## Path to the file containing the user's private PEM-encoded key. +## +## Value: File +## dashboard.listener.https.keyfile = etc/certs/key.pem + +## Path to a file containing the user certificate. +## +## Value: File +## dashboard.listener.https.certfile = etc/certs/cert.pem + +## Path to the file containing PEM-encoded CA certificates. +## +## Value: File +## dashboard.listener.https.cacertfile = etc/certs/cacert.pem + +## See: 'listener.ssl..dhfile' in emq.conf +## +## Value: File +## dashboard.listener.https.dhfile = {{ platform_etc_dir }}/certs/dh-params.pem + +## See: 'listener.ssl..vefify' in emq.conf +## +## Value: vefify_peer | verify_none +## dashboard.listener.https.verify = verify_peer + +## See: 'listener.ssl..fail_if_no_peer_cert' in emq.conf +## +## Value: false | true +## dashboard.listener.https.fail_if_no_peer_cert = true + +## TLS versions only to protect from POODLE attack. +## +## Value: String, seperated by ',' +## dashboard.listener.https.tls_versions = tlsv1.2,tlsv1.1,tlsv1 + +## See: 'listener.ssl..ciphers' in emq.conf +## +## Value: Ciphers +## dashboard.listener.https.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA + +## See: 'listener.ssl..secure_renegotiate' in emq.conf +## +## Value: on | off +## dashboard.listener.https.secure_renegotiate = off + +## See: 'listener.ssl..reuse_sessions' in emq.conf +## +## Value: on | off +## dashboard.listener.https.reuse_sessions = on + +## See: 'listener.ssl..honor_cipher_order' in emq.conf +## +## Value: on | off +## dashboard.listener.https.honor_cipher_order = on + diff --git a/apps/emqx_dashboard/include/emqx_dashboard.hrl b/apps/emqx_dashboard/include/emqx_dashboard.hrl new file mode 100644 index 000000000..73b64de77 --- /dev/null +++ b/apps/emqx_dashboard/include/emqx_dashboard.hrl @@ -0,0 +1,21 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-record(mqtt_admin, {username, password, tags}). + +-type(mqtt_admin() :: #mqtt_admin{}). + +-define(EMPTY_KEY(Key), ((Key == undefined) orelse (Key == <<>>))). diff --git a/apps/emqx_dashboard/priv/emqx_dashboard.schema b/apps/emqx_dashboard/priv/emqx_dashboard.schema new file mode 100644 index 000000000..fcc8f3489 --- /dev/null +++ b/apps/emqx_dashboard/priv/emqx_dashboard.schema @@ -0,0 +1,151 @@ +%%-*- mode: erlang -*- +%% emqx_dashboard config mapping + +{mapping, "dashboard.default_user.login", "emqx_dashboard.default_user_username", [ + {datatype, string} +]}. + +{mapping, "dashboard.default_user.password", "emqx_dashboard.default_user_passwd", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.http", "emqx_dashboard.listeners", [ + {datatype, integer} +]}. + +{mapping, "dashboard.listener.http.acceptors", "emqx_dashboard.listeners", [ + {default, 4}, + {datatype, integer} +]}. + +{mapping, "dashboard.listener.http.max_clients", "emqx_dashboard.listeners", [ + {default, 512}, + {datatype, integer} +]}. + +{mapping, "dashboard.listener.http.access.$id", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.http.inet6", "emqx_dashboard.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "dashboard.listener.http.ipv6_v6only", "emqx_dashboard.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "dashboard.listener.https", "emqx_dashboard.listeners", [ + {datatype, integer} +]}. + +{mapping, "dashboard.listener.https.acceptors", "emqx_dashboard.listeners", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "dashboard.listener.https.max_clients", "emqx_dashboard.listeners", [ + {default, 64}, + {datatype, integer} +]}. + +{mapping, "dashboard.listener.https.inet6", "emqx_dashboard.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "dashboard.listener.https.ipv6_v6only", "emqx_dashboard.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "dashboard.listener.https.tls_versions", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.dhfile", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.keyfile", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.certfile", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.cacertfile", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.verify", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.fail_if_no_peer_cert", "emqx_dashboard.listeners", [ + {datatype, {enum, [true, false]}} +]}. + +{mapping, "dashboard.listener.https.ciphers", "emqx_dashboard.listeners", [ + {datatype, string} +]}. + +{mapping, "dashboard.listener.https.secure_renegotiate", "emqx_dashboard.listeners", [ + {datatype, flag} +]}. + +{mapping, "dashboard.listener.https.reuse_sessions", "emqx_dashboard.listeners", [ + {default, on}, + {datatype, flag} +]}. + +{mapping, "dashboard.listener.https.honor_cipher_order", "emqx_dashboard.listeners", [ + {datatype, flag} +]}. + +{translation, "emqx_dashboard.listeners", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + LisOpts = fun(Prefix) -> + Filter([{num_acceptors, cuttlefish:conf_get(Prefix ++ ".acceptors", Conf)}, + {max_connections, cuttlefish:conf_get(Prefix ++ ".max_clients", Conf)}, + {inet6, cuttlefish:conf_get(Prefix ++ ".inet6", Conf)}, + {ipv6_v6only, cuttlefish:conf_get(Prefix ++ ".ipv6_v6only", Conf)}]) + end, + + SplitFun = fun(undefined) -> undefined; (S) -> string:tokens(S, ",") end, + + SslOpts = fun(Prefix) -> + Versions = case SplitFun(cuttlefish:conf_get(Prefix ++ ".tls_versions", Conf, undefined)) of + undefined -> undefined; + L -> [list_to_atom(V) || V <- L] + end, + Filter([{versions, Versions}, + {ciphers, SplitFun(cuttlefish:conf_get(Prefix ++ ".ciphers", Conf, undefined))}, + {dhfile, cuttlefish:conf_get(Prefix ++ ".dhfile", Conf, undefined)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined)}, + {verify, cuttlefish:conf_get(Prefix ++ ".verify", Conf, undefined)}, + {fail_if_no_peer_cert, cuttlefish:conf_get(Prefix ++ ".fail_if_no_peer_cert", Conf, undefined)}, + {secure_renegotiate, cuttlefish:conf_get(Prefix ++ ".secure_renegotiate", Conf, undefined)}, + {reuse_sessions, cuttlefish:conf_get(Prefix ++ ".reuse_sessions", Conf, undefined)}, + {honor_cipher_order, cuttlefish:conf_get(Prefix ++ ".honor_cipher_order", Conf, undefined)}]) + end, + lists:append( + lists:map( + fun(Proto) -> + Prefix = "dashboard.listener." ++ atom_to_list(Proto), + case cuttlefish:conf_get(Prefix, Conf, undefined) of + undefined -> []; + Port -> + [{Proto, Port, case Proto of + http -> LisOpts(Prefix); + https -> LisOpts(Prefix) ++ SslOpts(Prefix) + end}] + end + end, [http, https])) +end}. + diff --git a/apps/emqx_dashboard/rebar.config b/apps/emqx_dashboard/rebar.config new file mode 100644 index 000000000..bdb491bcb --- /dev/null +++ b/apps/emqx_dashboard/rebar.config @@ -0,0 +1,15 @@ +{deps, []}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {d, 'APPLICATION', emqx}]}. +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_dashboard/src/emqx_dashboard.app.src b/apps/emqx_dashboard/src/emqx_dashboard.app.src new file mode 100644 index 000000000..92bd59a7a --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard.app.src @@ -0,0 +1,14 @@ +{application, emqx_dashboard, + [{description, "EMQ X Web Dashboard"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, [emqx_dashboard_sup]}, + {applications, [kernel,stdlib,mnesia,minirest]}, + {mod, {emqx_dashboard_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-dashboard"} + ]} + ]}. diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl new file mode 100644 index 000000000..cbf0d81d5 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard.erl @@ -0,0 +1,120 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-import(proplists, [get_value/3]). + +-export([ start_listeners/0 + , stop_listeners/0 + ]). + +%% for minirest +-export([ filter/1 + , is_authorized/1 + ]). + +-define(APP, ?MODULE). + +%%-------------------------------------------------------------------- +%% Start/Stop listeners. +%%-------------------------------------------------------------------- + +start_listeners() -> + lists:foreach(fun(Listener) -> start_listener(Listener) end, listeners()). + +%% Start HTTP Listener +start_listener({Proto, Port, Options}) when Proto == http -> + Dispatch = [{"/", cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}}, + {"/static/[...]", cowboy_static, {priv_dir, emqx_dashboard, "www/static"}}, + {"/api/v4/[...]", minirest, http_handlers()}], + minirest:start_http(listener_name(Proto), ranch_opts(Port, Options), Dispatch); + +start_listener({Proto, Port, Options}) when Proto == https -> + Dispatch = [{"/", cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}}, + {"/static/[...]", cowboy_static, {priv_dir, emqx_dashboard, "www/static"}}, + {"/api/v4/[...]", minirest, http_handlers()}], + minirest:start_https(listener_name(Proto), ranch_opts(Port, Options), Dispatch). + +ranch_opts(Port, Options0) -> + NumAcceptors = get_value(num_acceptors, Options0, 4), + MaxConnections = get_value(max_connections, Options0, 512), + Options = lists:foldl(fun({K, _V}, Acc) when K =:= max_connections orelse K =:= num_acceptors -> + Acc; + ({inet6, true}, Acc) -> [inet6 | Acc]; + ({inet6, false}, Acc) -> Acc; + ({ipv6_v6only, true}, Acc) -> [{ipv6_v6only, true} | Acc]; + ({ipv6_v6only, false}, Acc) -> Acc; + ({K, V}, Acc)-> + [{K, V} | Acc] + end, [], Options0), + #{num_acceptors => NumAcceptors, + max_connections => MaxConnections, + socket_opts => [{port, Port} | Options]}. + +stop_listeners() -> + lists:foreach(fun(Listener) -> stop_listener(Listener) end, listeners()). + +stop_listener({Proto, _Port, _}) -> + minirest:stop_http(listener_name(Proto)). + +listeners() -> + application:get_env(?APP, listeners, []). + +listener_name(Proto) -> + list_to_atom(atom_to_list(Proto) ++ ":dashboard"). + +%%-------------------------------------------------------------------- +%% HTTP Handlers and Dispatcher +%%-------------------------------------------------------------------- + +http_handlers() -> + Plugins = lists:map(fun(Plugin) -> Plugin#plugin.name end, emqx_plugins:list()), + [{"/api/v4/", + minirest:handler(#{apps => Plugins, filter => fun ?MODULE:filter/1}), + [{authorization, fun ?MODULE:is_authorized/1}]}]. + +%%-------------------------------------------------------------------- +%% Basic Authorization +%%-------------------------------------------------------------------- + +is_authorized(Req) -> + is_authorized(binary_to_list(cowboy_req:path(Req)), Req). + +is_authorized("/api/v4/auth", _Req) -> + true; +is_authorized(_Path, Req) -> + case cowboy_req:parse_header(<<"authorization">>, Req) of + {basic, Username, Password} -> + case emqx_dashboard_admin:check(iolist_to_binary(Username), + iolist_to_binary(Password)) of + ok -> true; + {error, Reason} -> + ?LOG(error, "[Dashboard] Authorization Failure: username=~s, reason=~p", + [Username, Reason]), + false + end; + _ -> false + end. + +filter(#{app := App}) -> + case emqx_plugins:find_plugin(App) of + false -> false; + Plugin -> Plugin#plugin.active + end. diff --git a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl new file mode 100644 index 000000000..0e5616c64 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl @@ -0,0 +1,228 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc Web dashboard admin authentication with username and password. + +-module(emqx_dashboard_admin). + +-behaviour(gen_server). + +-include("emqx_dashboard.hrl"). + +-boot_mnesia({mnesia, [boot]}). +-copy_mnesia({mnesia, [copy]}). + +%% Mnesia bootstrap +-export([mnesia/1]). + +%% API Function Exports +-export([start_link/0]). + +%% mqtt_admin api +-export([ add_user/3 + , force_add_user/3 + , remove_user/1 + , update_user/2 + , lookup_user/1 + , change_password/2 + , change_password/3 + , all_users/0 + , check/2 + ]). + +%% gen_server Function Exports +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +%%-------------------------------------------------------------------- +%% Mnesia bootstrap +%%-------------------------------------------------------------------- + +mnesia(boot) -> + ok = ekka_mnesia:create_table(mqtt_admin, [ + {type, set}, + {disc_copies, [node()]}, + {record_name, mqtt_admin}, + {attributes, record_info(fields, mqtt_admin)}, + {storage_properties, [{ets, [{read_concurrency, true}, + {write_concurrency, true}]}]}]); +mnesia(copy) -> + ok = ekka_mnesia:copy_table(mqtt_admin, disc_copies). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +-spec(start_link() -> {ok, pid()} | ignore | {error, any()}). +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +-spec(add_user(binary(), binary(), binary()) -> ok | {error, any()}). +add_user(Username, Password, Tags) when is_binary(Username), is_binary(Password) -> + Admin = #mqtt_admin{username = Username, password = hash(Password), tags = Tags}, + return(mnesia:transaction(fun add_user_/1, [Admin])). + +force_add_user(Username, Password, Tags) -> + AddFun = fun() -> + mnesia:write(#mqtt_admin{username = Username, + password = Password, + tags = Tags}) + end, + case mnesia:transaction(AddFun) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. + +%% @private +add_user_(Admin = #mqtt_admin{username = Username}) -> + case mnesia:wread({mqtt_admin, Username}) of + [] -> mnesia:write(Admin); + [_] -> mnesia:abort(<<"Username Already Exist">>) + end. + +-spec(remove_user(binary()) -> ok | {error, any()}). +remove_user(Username) when is_binary(Username) -> + Trans = fun() -> + case lookup_user(Username) of + [] -> + mnesia:abort(<<"Username Not Found">>); + _ -> ok + end, + mnesia:delete({mqtt_admin, Username}) + end, + return(mnesia:transaction(Trans)). + +-spec(update_user(binary(), binary()) -> ok | {error, term()}). +update_user(Username, Tags) when is_binary(Username) -> + return(mnesia:transaction(fun update_user_/2, [Username, Tags])). + +%% @private +update_user_(Username, Tags) -> + case mnesia:wread({mqtt_admin, Username}) of + [] -> mnesia:abort(<<"Username Not Found">>); + [Admin] -> mnesia:write(Admin#mqtt_admin{tags = Tags}) + end. + +change_password(Username, OldPasswd, NewPasswd) when is_binary(Username) -> + case check(Username, OldPasswd) of + ok -> change_password(Username, NewPasswd); + Error -> Error + end. + +change_password(Username, Password) when is_binary(Username), is_binary(Password) -> + change_password_hash(Username, hash(Password)). + +change_password_hash(Username, PasswordHash) -> + update_pwd(Username, fun(User) -> + User#mqtt_admin{password = PasswordHash} + end). + +update_pwd(Username, Fun) -> + Trans = fun() -> + User = + case lookup_user(Username) of + [Admin] -> Admin; + [] -> + mnesia:abort(<<"Username Not Found">>) + end, + mnesia:write(Fun(User)) + end, + return(mnesia:transaction(Trans)). + + +-spec(lookup_user(binary()) -> [mqtt_admin()]). +lookup_user(Username) when is_binary(Username) -> mnesia:dirty_read(mqtt_admin, Username). + +-spec(all_users() -> [#mqtt_admin{}]). +all_users() -> ets:tab2list(mqtt_admin). + +return({atomic, _}) -> + ok; +return({aborted, Reason}) -> + {error, Reason}. + +check(undefined, _) -> + {error, <<"Username undefined">>}; +check(_, undefined) -> + {error, <<"Password undefined">>}; +check(Username, Password) -> + case lookup_user(Username) of + [#mqtt_admin{password = <>}] -> + case Hash =:= md5_hash(Salt, Password) of + true -> ok; + false -> {error, <<"Password Error">>} + end; + [] -> + {error, <<"Username Not Found">>} + end. + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([]) -> + %% Add default admin user + _ = add_default_user(binenv(default_user_username), binenv(default_user_passwd)), + {ok, state}. + +handle_call(_Req, _From, State) -> + {reply, error, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info(_Msg, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +hash(Password) -> + SaltBin = salt(), + <>. + +md5_hash(SaltBin, Password) -> + erlang:md5(<>). + +salt() -> + _ = emqx_misc:rand_seed(), + Salt = rand:uniform(16#ffffffff), + <>. + +binenv(Key) -> + iolist_to_binary(application:get_env(emqx_dashboard, Key, "")). + +add_default_user(Username, Password) when ?EMPTY_KEY(Username) orelse ?EMPTY_KEY(Password) -> + igonre; + +add_default_user(Username, Password) -> + case lookup_user(Username) of + [] -> add_user(Username, Password, <<"administrator">>); + _ -> ok + end. + diff --git a/apps/emqx_dashboard/src/emqx_dashboard_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_api.erl new file mode 100644 index 000000000..8663b13ae --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_api.erl @@ -0,0 +1,109 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_api). + +-include("emqx_dashboard.hrl"). + +-import(minirest, [return/1]). + +-rest_api(#{name => auth_user, + method => 'POST', + path => "/auth", + func => auth, + descr => "Authenticate an user" + }). + +-rest_api(#{name => create_user, + method => 'POST', + path => "/users/", + func => create, + descr => "Create an user" + }). + +-rest_api(#{name => list_users, + method => 'GET', + path => "/users/", + func => list, + descr => "List users" + }). + +-rest_api(#{name => update_user, + method => 'PUT', + path => "/users/:bin:name", + func => update, + descr => "Update an user" + }). + +-rest_api(#{name => delete_user, + method => 'DELETE', + path => "/users/:bin:name", + func => delete, + descr => "Delete an user" + }). + +-rest_api(#{name => change_pwd, + method => 'PUT', + path => "/change_pwd/:bin:username", + func => change_pwd, + descr => "Change password for an user" + }). + +-export([ list/2 + , create/2 + , update/2 + , delete/2 + , auth/2 + , change_pwd/2 + ]). + +-define(EMPTY(V), (V == undefined orelse V == <<>>)). + +auth(_Bindings, Params) -> + Username = proplists:get_value(<<"username">>, Params), + Password = proplists:get_value(<<"password">>, Params), + return(emqx_dashboard_admin:check(Username, Password)). + +change_pwd(#{username := Username}, Params) -> + OldPwd = proplists:get_value(<<"old_pwd">>, Params), + NewPwd = proplists:get_value(<<"new_pwd">>, Params), + return(emqx_dashboard_admin:change_password(Username, OldPwd, NewPwd)). + +create(_Bindings, Params) -> + Username = proplists:get_value(<<"username">>, Params), + Password = proplists:get_value(<<"password">>, Params), + Tags = proplists:get_value(<<"tags">>, Params), + return(case ?EMPTY(Username) orelse ?EMPTY(Password) of + true -> {error, <<"Username or password undefined">>}; + false -> emqx_dashboard_admin:add_user(Username, Password, Tags) + end). + +list(_Bindings, _Params) -> + return({ok, [row(User) || User <- emqx_dashboard_admin:all_users()]}). + +update(#{name := Username}, Params) -> + Tags = proplists:get_value(<<"tags">>, Params), + return(emqx_dashboard_admin:update_user(Username, Tags)). + +delete(#{name := <<"admin">>}, _Params) -> + return({error, <<"Cannot delete admin">>}); + +delete(#{name := Username}, _Params) -> + return(emqx_dashboard_admin:remove_user(Username)). + +row(#mqtt_admin{username = Username, tags = Tags}) -> + #{username => Username, tags => Tags}. + diff --git a/apps/emqx_dashboard/src/emqx_dashboard_app.erl b/apps/emqx_dashboard/src/emqx_dashboard_app.erl new file mode 100644 index 000000000..76228ec8b --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_app.erl @@ -0,0 +1,35 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_app). + +-behaviour(application). + +-emqx_plugin(?MODULE). + +-export([ start/2 + , stop/1 + ]). + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_dashboard_sup:start_link(), + emqx_dashboard:start_listeners(), + emqx_dashboard_cli:load(), + {ok, Sup}. + +stop(_State) -> + emqx_dashboard_cli:unload(), + emqx_dashboard:stop_listeners(). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_cli.erl b/apps/emqx_dashboard/src/emqx_dashboard_cli.erl new file mode 100644 index 000000000..3977e292e --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_cli.erl @@ -0,0 +1,56 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_cli). + +-export([ load/0 + , admins/1 + , unload/0 + ]). + +load() -> + emqx_ctl:register_command(admins, {?MODULE, admins}, []). + +admins(["add", Username, Password]) -> + admins(["add", Username, Password, ""]); + +admins(["add", Username, Password, Tag]) -> + case emqx_dashboard_admin:add_user(bin(Username), bin(Password), bin(Tag)) of + ok -> + emqx_ctl:print("ok~n"); + {error, already_existed} -> + emqx_ctl:print("Error: already existed~n"); + {error, Reason} -> + emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +admins(["passwd", Username, Password]) -> + Status = emqx_dashboard_admin:change_password(bin(Username), bin(Password)), + emqx_ctl:print("~p~n", [Status]); + +admins(["del", Username]) -> + Status = emqx_dashboard_admin:remove_user(bin(Username)), + emqx_ctl:print("~p~n", [Status]); + +admins(_) -> + emqx_ctl:usage([{"admins add ", "Add dashboard user"}, + {"admins passwd ", "Reset dashboard user password"}, + {"admins del ", "Delete dashboard user" }]). + +unload() -> + emqx_ctl:unregister_command(admins). + +bin(S) -> iolist_to_binary(S). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_sup.erl b/apps/emqx_dashboard/src/emqx_dashboard_sup.erl new file mode 100644 index 000000000..141465e40 --- /dev/null +++ b/apps/emqx_dashboard/src/emqx_dashboard_sup.erl @@ -0,0 +1,32 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +-define(CHILD(I), {I, {I, start_link, []}, permanent, 5000, worker, [I]}). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, { {one_for_all, 10, 100}, [?CHILD(emqx_dashboard_admin)] } }. + diff --git a/apps/emqx_dashboard/test/.placeholder b/apps/emqx_dashboard/test/.placeholder new file mode 100644 index 000000000..e69de29bb diff --git a/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl new file mode 100644 index 000000000..4290db22a --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl @@ -0,0 +1,163 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-import(emqx_ct_http, + [ request_api/3 + , request_api/5 + , get_http_data/1 + ]). + +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("emqx/include/emqx.hrl"). + +-define(CONTENT_TYPE, "application/x-www-form-urlencoded"). + +-define(HOST, "http://127.0.0.1:18083/"). + +-define(API_VERSION, "v4"). + +-define(BASE_PATH, "api"). + +-define(OVERVIEWS, ['alarms/activated', 'alarms/deactivated', banned, brokers, stats, metrics, listeners, clients, subscriptions, routes, plugins]). + +all() -> + [{group, overview}, + {group, admins}, + {group, rest}, + {group, cli} + ]. + +groups() -> + [{overview, [sequence], [t_overview]}, + {admins, [sequence], [t_admins_add_delete]}, + {rest, [sequence], [t_rest_api]}, + {cli, [sequence], [t_cli]} + ]. + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx, emqx_management, emqx_dashboard]), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_dashboard, emqx_management, emqx]), + ekka_mnesia:ensure_stopped(). + +t_overview(_) -> + [?assert(request_dashboard(get, api_path(erlang:atom_to_list(Overview)), auth_header_()))|| Overview <- ?OVERVIEWS]. + +t_admins_add_delete(_) -> + ok = emqx_dashboard_admin:add_user(<<"username">>, <<"password">>, <<"tag">>), + ok = emqx_dashboard_admin:add_user(<<"username1">>, <<"password1">>, <<"tag1">>), + Admins = emqx_dashboard_admin:all_users(), + ?assertEqual(3, length(Admins)), + ok = emqx_dashboard_admin:remove_user(<<"username1">>), + Users = emqx_dashboard_admin:all_users(), + ?assertEqual(2, length(Users)), + ok = emqx_dashboard_admin:change_password(<<"username">>, <<"password">>, <<"pwd">>), + timer:sleep(10), + ?assert(request_dashboard(get, api_path("brokers"), auth_header_("username", "pwd"))), + + ok = emqx_dashboard_admin:remove_user(<<"username">>), + ?assertNotEqual(true, request_dashboard(get, api_path("brokers"), auth_header_("username", "pwd"))). + +t_rest_api(_Config) -> + {ok, Res0} = http_get("users"), + + ?assertEqual([#{<<"username">> => <<"admin">>, + <<"tags">> => <<"administrator">>}], get_http_data(Res0)), + + AssertSuccess = fun({ok, Res}) -> + ?assertEqual(#{<<"code">> => 0}, json(Res)) + end, + [AssertSuccess(R) + || R <- [ http_put("users/admin", #{<<"tags">> => <<"a_new_tag">>}) + , http_post("users", #{<<"username">> => <<"usera">>, <<"password">> => <<"passwd">>}) + , http_post("auth", #{<<"username">> => <<"usera">>, <<"password">> => <<"passwd">>}) + , http_delete("users/usera") + , http_put("change_pwd/admin", #{<<"old_pwd">> => <<"public">>, <<"new_pwd">> => <<"newpwd">>}) + , http_post("auth", #{<<"username">> => <<"admin">>, <<"password">> => <<"newpwd">>}) + ]], + ok. + +t_cli(_Config) -> + [mnesia:dirty_delete({mqtt_admin, Admin}) || Admin <- mnesia:dirty_all_keys(mqtt_admin)], + emqx_dashboard_cli:admins(["add", "username", "password"]), + [{mqtt_admin, <<"username">>, <>, _}] = + emqx_dashboard_admin:lookup_user(<<"username">>), + ?assertEqual(Hash, erlang:md5(<>/binary>>)), + emqx_dashboard_cli:admins(["passwd", "username", "newpassword"]), + [{mqtt_admin, <<"username">>, <>, _}] = + emqx_dashboard_admin:lookup_user(<<"username">>), + ?assertEqual(Hash1, erlang:md5(<>/binary>>)), + emqx_dashboard_cli:admins(["del", "username"]), + [] = emqx_dashboard_admin:lookup_user(<<"username">>), + emqx_dashboard_cli:admins(["add", "admin1", "pass1"]), + emqx_dashboard_cli:admins(["add", "admin2", "passw2"]), + AdminList = emqx_dashboard_admin:all_users(), + ?assertEqual(2, length(AdminList)). + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +http_get(Path) -> + request_api(get, api_path(Path), auth_header_()). + +http_delete(Path) -> + request_api(delete, api_path(Path), auth_header_()). + +http_post(Path, Body) -> + request_api(post, api_path(Path), [], auth_header_(), Body). + +http_put(Path, Body) -> + request_api(put, api_path(Path), [], auth_header_(), Body). + +request_dashboard(Method, Url, Auth) -> + Request = {Url, [Auth]}, + do_request_dashboard(Method, Request). +request_dashboard(Method, Url, QueryParams, Auth) -> + Request = {Url ++ "?" ++ QueryParams, [Auth]}, + do_request_dashboard(Method, Request). +do_request_dashboard(Method, Request)-> + ct:pal("Method: ~p, Request: ~p", [Method, Request]), + case httpc:request(Method, Request, [], []) of + {error, socket_closed_remotely} -> + {error, socket_closed_remotely}; + {ok, {{"HTTP/1.1", 200, _}, _, _Return} } -> + true; + {ok, {Reason, _, _}} -> + {error, Reason} + end. + +auth_header_() -> + auth_header_("admin", "public"). + +auth_header_(User, Pass) -> + Encoded = base64:encode_to_string(lists:append([User,":",Pass])), + {"Authorization","Basic " ++ Encoded}. + +api_path(Path) -> + ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION, Path]). + +json(Data) -> + {ok, Jsx} = emqx_json:safe_decode(Data, [return_maps]), Jsx. + diff --git a/apps/emqx_exhook/.gitignore b/apps/emqx_exhook/.gitignore new file mode 100644 index 000000000..da1f0db23 --- /dev/null +++ b/apps/emqx_exhook/.gitignore @@ -0,0 +1,29 @@ +.rebar3 +_* +.eunit +*.o +*.beam +*.plt +*.swp +*.swo +.erlang.cookie +ebin +log +erl_crash.dump +.rebar +logs +_build +.idea +*.iml +rebar3.crashdump +*~ +rebar.lock +data/ +*.conf.rendered +*.pyc +.DS_Store +*.class +Mnesia.nonode@nohost/ +src/emqx_exhook_pb.erl +src/emqx_exhook_v_1_hook_provider_client.erl +src/emqx_exhook_v_1_hook_provider_bhvr.erl diff --git a/apps/emqx_exhook/README.md b/apps/emqx_exhook/README.md new file mode 100644 index 000000000..216c39275 --- /dev/null +++ b/apps/emqx_exhook/README.md @@ -0,0 +1,39 @@ +# emqx_exhook + +The `emqx_exhook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. + +## Feature + +- [x] Based on gRPC, it brings a very wide range of applicability +- [x] Allows you to use the return value to extend emqx behavior. + +## Architecture + +``` +EMQ X Third-party Runtime ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gPRC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ +``` + +## Usage + +### gRPC service + +See: `priv/protos/exhook.proto` + +### CLI + +## Example + +## Recommended gRPC Framework + +See: https://github.com/grpc-ecosystem/awesome-grpc + +## Thanks + +- [grpcbox](https://github.com/tsloughter/grpcbox) diff --git a/apps/emqx_exhook/docs/design.md b/apps/emqx_exhook/docs/design.md new file mode 100644 index 000000000..671e240cc --- /dev/null +++ b/apps/emqx_exhook/docs/design.md @@ -0,0 +1,116 @@ +# 设计 + +## 动机 + +在 EMQ X Broker v4.1-v4.2 中,我们发布了 2 个插件来扩展 emqx 的编程能力: + +1. `emqx-extension-hook` 提供了使用 Java, Python 向 Broker 挂载钩子的功能 +2. `emqx-exproto` 提供了使用 Java,Python 编写用户自定义协议接入插件的功能 + +但在后续的支持中发现许多难以处理的问题: + +1. 有大量的编程语言需要支持,需要编写和维护如 Go, JavaScript, Lua.. 等语言的驱动。 +2. `erlport` 使用的操作系统的管道进行通信,这让用户代码只能部署在和 emqx 同一个操作系统上。部署方式受到了极大的限制。 +3. 用户程序的启动参数直接打包到 Broker 中,导致用户开发无法实时的进行调试,单步跟踪等。 +4. `erlport` 会占用 `stdin` `stdout`。 + +因此,我们计划重构这部分的实现,其中主要的内容是: +1. 使用 `gRPC` 替换 `erlport`。 +2. 将 `emqx-extension-hook` 重命名为 `emqx-exhook` + + +旧版本的设计参考:[emqx-extension-hook design in v4.2.0](https://github.com/emqx/emqx-exhook/blob/v4.2.0/docs/design.md) + +## 设计 + +架构如下: + +``` + EMQ X ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gRPC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ +``` + +`emqx-exhook` 通过 gRPC 的方式向用户部署的 gRPC 服务发送钩子的请求,并处理其返回的值。 + + +和 emqx 原生的钩子一致,emqx-exhook 也支持链式的方式计算和返回: + + + +### gRPC 服务示例 + +用户需要实现的方法,和数据类型的定义在 `priv/protos/exhook.proto` 文件中。例如,其支持的接口有: + +```protobuff +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} +``` + +### 配置文件示例 + +``` +## 配置 gRPC 服务地址 (HTTP) +## +## s1 为服务器的名称 +exhook.server.s1.url = http://127.0.0.1:9001 + +## 配置 gRPC 服务地址 (HTTPS) +## +## s2 为服务器名称 +exhook.server.s2.url = https://127.0.0.1:9002 +exhook.server.s2.cacertfile = ca.pem +exhook.server.s2.certfile = cert.pem +exhook.server.s2.keyfile = key.pem +``` diff --git a/apps/emqx_exhook/etc/emqx_exhook.conf b/apps/emqx_exhook/etc/emqx_exhook.conf new file mode 100644 index 000000000..f6f5213f7 --- /dev/null +++ b/apps/emqx_exhook/etc/emqx_exhook.conf @@ -0,0 +1,15 @@ +##==================================================================== +## EMQ X Hooks +##==================================================================== + +##-------------------------------------------------------------------- +## Server Address + +## The gRPC server url +## +## exhook.server.$name.url = url() +exhook.server.default.url = http://127.0.0.1:9000 + +#exhook.server.default.ssl.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem +#exhook.server.default.ssl.certfile = {{ platform_etc_dir }}/certs/cert.pem +#exhook.server.default.ssl.keyfile = {{ platform_etc_dir }}/certs/key.pem diff --git a/apps/emqx_exhook/include/emqx_exhook.hrl b/apps/emqx_exhook/include/emqx_exhook.hrl new file mode 100644 index 000000000..8a404ca39 --- /dev/null +++ b/apps/emqx_exhook/include/emqx_exhook.hrl @@ -0,0 +1,22 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-ifndef(EMQX_EXHOOK_HRL). +-define(EMQX_EXHOOK_HRL, true). + +-define(APP, emqx_exhook). + +-endif. diff --git a/apps/emqx_exhook/priv/emqx_exhook.schema b/apps/emqx_exhook/priv/emqx_exhook.schema new file mode 100644 index 000000000..2a926b968 --- /dev/null +++ b/apps/emqx_exhook/priv/emqx_exhook.schema @@ -0,0 +1,38 @@ +%%-*- mode: erlang -*- + +{mapping, "exhook.server.$name.url", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.cacertfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.certfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.keyfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{translation, "emqx_exhook.servers", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + ServerOptions = fun(Prefix) -> + case http_uri:parse(cuttlefish:conf_get(Prefix ++ ".url", Conf)) of + {ok, {http, _, Host, Port, _, _}} -> + [{scheme, http}, {host, Host}, {port, Port}]; + {ok, {https, _, Host, Port, _, _}} -> + [{scheme, https}, {host, Host}, {port, Port}, + {ssl_options, + Filter([{ssl, true}, + {certfile, cuttlefish:conf_get(Prefix ++ ".ssl.certfile", Conf)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".ssl.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".ssl.cacertfile", Conf)} + ])}]; + _ -> error(invalid_server_options) + end + end, + [{list_to_atom(Name), ServerOptions("exhook.server." ++ Name)} + || {["exhook", "server", Name, "url"], _} <- cuttlefish_variable:filter_by_prefix("exhook.server", Conf)] +end}. diff --git a/apps/emqx_exhook/priv/protos/exhook.proto b/apps/emqx_exhook/priv/protos/exhook.proto new file mode 100644 index 000000000..8dc9641b9 --- /dev/null +++ b/apps/emqx_exhook/priv/protos/exhook.proto @@ -0,0 +1,395 @@ +//------------------------------------------------------------------------------ +// Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//------------------------------------------------------------------------------ + +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} + +//------------------------------------------------------------------------------ +// Request & Response +//------------------------------------------------------------------------------ + +message ProviderLoadedRequest { + + BrokerInfo broker = 1; +} + +message LoadedResponse { + + repeated HookSpec hooks = 1; +} + +message ProviderUnloadedRequest { } + +message ClientConnectRequest { + + ConnInfo conninfo = 1; + + // MQTT CONNECT packet's properties (MQTT v5.0) + // + // It should be empty on MQTT v3.1.1/v3.1 or others protocol + repeated Property props = 2; +} + +message ClientConnackRequest { + + ConnInfo conninfo = 1; + + string result_code = 2; + + repeated Property props = 3; +} + +message ClientConnectedRequest { + + ClientInfo clientinfo = 1; +} + +message ClientDisconnectedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message ClientAuthenticateRequest { + + ClientInfo clientinfo = 1; + + bool result = 2; +} + +message ClientCheckAclRequest { + + ClientInfo clientinfo = 1; + + enum AclReqType { + + PUBLISH = 0; + + SUBSCRIBE = 1; + } + + AclReqType type = 2; + + string topic = 3; + + bool result = 4; +} + +message ClientSubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message ClientUnsubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message SessionCreatedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionSubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; + + SubOpts subopts = 3; +} + +message SessionUnsubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; +} + +message SessionResumedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionDiscardedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTakeoveredRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTerminatedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message MessagePublishRequest { + + Message message = 1; +} + +message MessageDeliveredRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +message MessageDroppedRequest { + + Message message = 1; + + string reason = 2; +} + +message MessageAckedRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +//------------------------------------------------------------------------------ +// Basic data types +//------------------------------------------------------------------------------ + +message EmptySuccess { } + +message ValuedResponse { + + // The responsed value type + // - ignore: Ignore the responsed value + // - contiune: Use the responsed value and execute the next hook + // - stop_and_return: Use the responsed value and stop the chain executing + enum ResponsedType { + + IGNORE = 0; + + CONTINUE = 1; + + STOP_AND_RETURN = 2; + } + + ResponsedType type = 1; + + oneof value { + + // Boolean result, used on the 'client.authenticate', 'client.check_acl' hooks + bool bool_result = 3; + + // Message result, used on the 'message.*' hooks + Message message = 4; + } +} + +message BrokerInfo { + + string version = 1; + + string sysdescr = 2; + + string uptime = 3; + + string datetime = 4; +} + +message HookSpec { + + // The registered hooks name + // + // Available value: + // "client.connect", "client.connack" + // "client.connected", "client.disconnected" + // "client.authenticate", "client.check_acl" + // "client.subscribe", "client.unsubscribe" + // + // "session.created", "session.subscribed" + // "session.unsubscribed", "session.resumed" + // "session.discarded", "session.takeovered" + // "session.terminated" + // + // "message.publish", "message.delivered" + // "message.acked", "message.dropped" + string name = 1; + + // The topic filters for message hooks + repeated string topics = 2; +} + +message ConnInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string peerhost = 4; + + uint32 sockport = 5; + + string proto_name = 6; + + string proto_ver = 7; + + uint32 keepalive = 8; +} + +message ClientInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string password = 4; + + string peerhost = 5; + + uint32 sockport = 6; + + string protocol = 7; + + string mountpoint = 8; + + bool is_superuser = 9; + + bool anonymous = 10; +} + +message Message { + + string node = 1; + + string id = 2; + + uint32 qos = 3; + + string from = 4; + + string topic = 5; + + bytes payload = 6; + + uint64 timestamp = 7; +} + +message Property { + + string name = 1; + + string value = 2; +} + +message TopicFilter { + + string name = 1; + + uint32 qos = 2; +} + +message SubOpts { + + // The QoS level + uint32 qos = 1; + + // The group name for shared subscription + string share = 2; + + // The Retain Handling option (MQTT v5.0) + // + // 0 = Send retained messages at the time of the subscribe + // 1 = Send retained messages at subscribe only if the subscription does + // not currently exist + // 2 = Do not send retained messages at the time of the subscribe + uint32 rh = 3; + + // The Retain as Published option (MQTT v5.0) + // + // If 1, Application Messages forwarded using this subscription keep the + // RETAIN flag they were published with. + // If 0, Application Messages forwarded using this subscription have the + // RETAIN flag set to 0. + // Retained messages sent when the subscription is established have the RETAIN flag set to 1. + uint32 rap = 4; + + // The No Local option (MQTT v5.0) + // + // If the value is 1, Application Messages MUST NOT be forwarded to a + // connection with a ClientID equal to the ClientID of the publishing + uint32 nl = 5; +} diff --git a/apps/emqx_exhook/rebar.config b/apps/emqx_exhook/rebar.config new file mode 100644 index 000000000..d2e437b8b --- /dev/null +++ b/apps/emqx_exhook/rebar.config @@ -0,0 +1,47 @@ +%%-*- mode: erlang -*- +{plugins, + [rebar3_proper, + {grpc_plugin, {git, "https://github.com/HJianBo/grpcbox_plugin", {tag, "v0.10.0"}}} +]}. + +{deps, + [{grpc, {git, "https://github.com/emqx/grpc", {tag, "0.6.0"}}} +]}. + +{grpc, + [{protos, ["priv/protos"]}, + {gpb_opts, [{module_name_prefix, "emqx_"}, + {module_name_suffix, "_pb"}]} +]}. + +{provider_hooks, + [{pre, [{compile, {grpc, gen}}]}]}. + +{edoc_opts, [{preprocess, true}]}. + +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{xref_ignores, [emqx_exhook_pb]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. +{cover_excl_mods, [emqx_exhook_pb, + emqx_exhook_v_1_hook_provider_bhvr, + emqx_exhook_v_1_hook_provider_client]}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.1"}}} + ]} + ]} +]}. diff --git a/apps/emqx_exhook/src/emqx_exhook.app.src b/apps/emqx_exhook/src/emqx_exhook.app.src new file mode 100644 index 000000000..fd8bc98ae --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.app.src @@ -0,0 +1,12 @@ +{application, emqx_exhook, + [{description, "EMQ X Extension for Hook"}, + {vsn, "git"}, + {modules, []}, + {registered, []}, + {mod, {emqx_exhook_app, []}}, + {applications, [kernel,stdlib,grpc]}, + {env,[]}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}]} + ]}. diff --git a/apps/emqx_exhook/src/emqx_exhook.erl b/apps/emqx_exhook/src/emqx_exhook.erl new file mode 100644 index 000000000..9f6d27b26 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.erl @@ -0,0 +1,134 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook). + +-include("emqx_exhook.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook]"). + +%% Mgmt APIs +-export([ enable/2 + , disable/1 + , disable_all/0 + , list/0 + ]). + +-export([ cast/2 + , call_fold/3 + ]). + +%%-------------------------------------------------------------------- +%% Mgmt APIs +%%-------------------------------------------------------------------- + +-spec list() -> [emqx_exhook_server:server()]. +list() -> + [server(Name) || Name <- running()]. + +-spec enable(atom()|string(), list()) -> ok | {error, term()}. +enable(Name, Opts) -> + case lists:member(Name, running()) of + true -> + {error, already_started}; + _ -> + case emqx_exhook_server:load(Name, Opts) of + {ok, ServiceState} -> + save(Name, ServiceState); + {error, Reason} -> + ?LOG(error, "Load server ~p failed: ~p", [Name, Reason]), + {error, Reason} + end + end. + +-spec disable(atom()|string()) -> ok | {error, term()}. +disable(Name) -> + case server(Name) of + undefined -> {error, not_running}; + Service -> + ok = emqx_exhook_server:unload(Service), + unsave(Name) + end. + +-spec disable_all() -> ok. +disable_all() -> + lists:foreach(fun disable/1, running()). + +%%---------------------------------------------------------- +%% Dispatch APIs +%%---------------------------------------------------------- + +-spec cast(atom(), map()) -> ok. +cast(Hookpoint, Req) -> + cast(Hookpoint, Req, running()). + +cast(_, _, []) -> + ok; +cast(Hookpoint, Req, [ServiceName|More]) -> + %% XXX: Need a real asynchronous running + _ = emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)), + cast(Hookpoint, Req, More). + +-spec call_fold(atom(), term(), function()) + -> {ok, term()} + | {stop, term()}. +call_fold(Hookpoint, Req, AccFun) -> + call_fold(Hookpoint, Req, AccFun, running()). + +call_fold(_, Req, _, []) -> + {ok, Req}; +call_fold(Hookpoint, Req, AccFun, [ServiceName|More]) -> + case emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)) of + {ok, Resp} -> + case AccFun(Req, Resp) of + {stop, NReq} -> {stop, NReq}; + {ok, NReq} -> call_fold(Hookpoint, NReq, AccFun, More) + end; + _ -> + call_fold(Hookpoint, Req, AccFun, More) + end. + +%%---------------------------------------------------------- +%% Storage + +-compile({inline, [save/2]}). +save(Name, ServiceState) -> + Saved = persistent_term:get(?APP, []), + persistent_term:put(?APP, lists:reverse([Name | Saved])), + persistent_term:put({?APP, Name}, ServiceState). + +-compile({inline, [unsave/1]}). +unsave(Name) -> + case persistent_term:get(?APP, []) of + [] -> + persistent_term:erase(?APP); + Saved -> + persistent_term:put(?APP, lists:delete(Name, Saved)) + end, + persistent_term:erase({?APP, Name}), + ok. + +-compile({inline, [running/0]}). +running() -> + persistent_term:get(?APP, []). + +-compile({inline, [server/1]}). +server(Name) -> + case catch persistent_term:get({?APP, Name}) of + {'EXIT', {badarg,_}} -> undefined; + Service -> Service + end. diff --git a/apps/emqx_exhook/src/emqx_exhook_app.erl b/apps/emqx_exhook/src/emqx_exhook_app.erl new file mode 100644 index 000000000..b008c251a --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_app.erl @@ -0,0 +1,117 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_app). + +-behaviour(application). + +-include("emqx_exhook.hrl"). + +-emqx_plugin(extension). + +-export([ start/2 + , stop/1 + , prep_stop/1 + ]). + +%% Internal export +-export([ load_server/2 + , unload_server/1 + , load_exhooks/0 + , unload_exhooks/0 + ]). + +%%-------------------------------------------------------------------- +%% Application callbacks +%%-------------------------------------------------------------------- + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_exhook_sup:start_link(), + + %% Load all dirvers + load_all_servers(), + + %% Register all hooks + _ = load_exhooks(), + + %% Register CLI + emqx_ctl:register_command(exhook, {emqx_exhook_cli, cli}, []), + {ok, Sup}. + +prep_stop(State) -> + emqx_ctl:unregister_command(exhook), + _ = unload_exhooks(), + ok = unload_all_servers(), + State. + +stop(_State) -> + ok. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +load_all_servers() -> + lists:foreach(fun({Name, Options}) -> + load_server(Name, Options) + end, application:get_env(?APP, servers, [])). + +unload_all_servers() -> + emqx_exhook:disable_all(). + +load_server(Name, Options) -> + emqx_exhook:enable(Name, Options). + +unload_server(Name) -> + emqx_exhook:disable(Name). + +%%-------------------------------------------------------------------- +%% Exhooks + +load_exhooks() -> + [emqx:hook(Name, {M, F, A}) || {Name, {M, F, A}} <- search_exhooks()]. + +unload_exhooks() -> + [emqx:unhook(Name, {M, F}) || {Name, {M, F, _A}} <- search_exhooks()]. + +search_exhooks() -> + search_exhooks(ignore_lib_apps(application:loaded_applications())). +search_exhooks(Apps) -> + lists:flatten([ExHooks || App <- Apps, {_App, _Mod, ExHooks} <- find_attrs(App, exhooks)]). + +ignore_lib_apps(Apps) -> + LibApps = [kernel, stdlib, sasl, appmon, eldap, erts, + syntax_tools, ssl, crypto, mnesia, os_mon, + inets, goldrush, gproc, runtime_tools, + snmp, otp_mibs, public_key, asn1, ssh, hipe, + common_test, observer, webtool, xmerl, tools, + test_server, compiler, debugger, eunit, et, + wx], + [AppName || {AppName, _, _} <- Apps, not lists:member(AppName, LibApps)]. + +find_attrs(App, Def) -> + [{App, Mod, Attr} || {ok, Modules} <- [application:get_key(App, modules)], + Mod <- Modules, + {Name, Attrs} <- module_attributes(Mod), Name =:= Def, + Attr <- Attrs]. + +module_attributes(Module) -> + try Module:module_info(attributes) + catch + error:undef -> []; + error:Reason -> error(Reason) + end. + diff --git a/apps/emqx_exhook/src/emqx_exhook_cli.erl b/apps/emqx_exhook/src/emqx_exhook_cli.erl new file mode 100644 index 000000000..8bab9ced5 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_cli.erl @@ -0,0 +1,80 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_cli). + +-include("emqx_exhook.hrl"). + +-export([cli/1]). + +cli(["server", "list"]) -> + if_enabled(fun() -> + Services = emqx_exhook:list(), + [emqx_ctl:print("HookServer(~s)~n", [emqx_exhook_server:format(Service)]) || Service <- Services] + end); + +cli(["server", "enable", Name0]) -> + if_enabled(fun() -> + Name = list_to_atom(Name0), + case proplists:get_value(Name, application:get_env(?APP, servers, [])) of + undefined -> + emqx_ctl:print("not_found~n"); + Opts -> + print(emqx_exhook:enable(Name, Opts)) + end + end); + +cli(["server", "disable", Name]) -> + if_enabled(fun() -> + print(emqx_exhook:disable(list_to_atom(Name))) + end); + +cli(["server", "stats"]) -> + if_enabled(fun() -> + [emqx_ctl:print("~-35s:~w~n", [Name, N]) || {Name, N} <- stats()] + end); + +cli(_) -> + emqx_ctl:usage([{"exhook server list", "List all running exhook server"}, + {"exhook server enable ", "Enable a exhook server in the configuration"}, + {"exhook server disable ", "Disable a exhook server"}, + {"exhook server stats", "Print exhook server statistic"}]). + +print(ok) -> + emqx_ctl:print("ok~n"); +print({error, Reason}) -> + emqx_ctl:print("~p~n", [Reason]). + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +if_enabled(Fun) -> + case lists:keymember(?APP, 1, application:which_applications()) of + true -> Fun(); + _ -> hint() + end. + +hint() -> + emqx_ctl:print("Please './bin/emqx_ctl plugins load emqx_exhook' first.~n"). + +stats() -> + lists:usort(lists:foldr(fun({K, N}, Acc) -> + case atom_to_list(K) of + "exhook." ++ Key -> [{Key, N}|Acc]; + _ -> Acc + end + end, [], emqx_metrics:all())). diff --git a/apps/emqx_exhook/src/emqx_exhook_handler.erl b/apps/emqx_exhook/src/emqx_exhook_handler.erl new file mode 100644 index 000000000..3a35073ca --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_handler.erl @@ -0,0 +1,288 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_handler). + +-include("emqx_exhook.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook]"). + +-export([ on_client_connect/2 + , on_client_connack/3 + , on_client_connected/2 + , on_client_disconnected/3 + , on_client_authenticate/2 + , on_client_check_acl/4 + , on_client_subscribe/3 + , on_client_unsubscribe/3 + ]). + +%% Session Lifecircle Hooks +-export([ on_session_created/2 + , on_session_subscribed/3 + , on_session_unsubscribed/3 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/3 + ]). + +%% Utils +-export([ message/1 + , stringfy/1 + , merge_responsed_bool/2 + , merge_responsed_message/2 + , assign_to_message/2 + , clientinfo/1 + ]). + +-import(emqx_exhook, + [ cast/2 + , call_fold/3 + ]). + +-exhooks([ {'client.connect', {?MODULE, on_client_connect, []}} + , {'client.connack', {?MODULE, on_client_connack, []}} + , {'client.connected', {?MODULE, on_client_connected, []}} + , {'client.disconnected', {?MODULE, on_client_disconnected, []}} + , {'client.authenticate', {?MODULE, on_client_authenticate, []}} + , {'client.check_acl', {?MODULE, on_client_check_acl, []}} + , {'client.subscribe', {?MODULE, on_client_subscribe, []}} + , {'client.unsubscribe', {?MODULE, on_client_unsubscribe, []}} + , {'session.created', {?MODULE, on_session_created, []}} + , {'session.subscribed', {?MODULE, on_session_subscribed, []}} + , {'session.unsubscribed',{?MODULE, on_session_unsubscribed, []}} + , {'session.resumed', {?MODULE, on_session_resumed, []}} + , {'session.discarded', {?MODULE, on_session_discarded, []}} + , {'session.takeovered', {?MODULE, on_session_takeovered, []}} + , {'session.terminated', {?MODULE, on_session_terminated, []}} + ]). + +%%-------------------------------------------------------------------- +%% Clients +%%-------------------------------------------------------------------- + +on_client_connect(ConnInfo, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + props => properties(Props) + }, + cast('client.connect', Req). + +on_client_connack(ConnInfo, Rc, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + result_code => stringfy(Rc), + props => properties(Props)}, + cast('client.connack', Req). + +on_client_connected(ClientInfo, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('client.connected', Req). + +on_client_disconnected(ClientInfo, Reason, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason) + }, + cast('client.disconnected', Req). + +on_client_authenticate(ClientInfo, AuthResult) -> + Bool = maps:get(auth_result, AuthResult, undefined) == success, + Req = #{clientinfo => clientinfo(ClientInfo), + result => Bool + }, + + case call_fold('client.authenticate', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + Result = case Bool of true -> success; _ -> not_authorized end, + {StopOrOk, AuthResult#{auth_result => Result, anonymous => false}}; + _ -> + {ok, AuthResult} + end. + +on_client_check_acl(ClientInfo, PubSub, Topic, Result) -> + Bool = Result == allow, + Type = case PubSub of + publish -> 'PUBLISH'; + subscribe -> 'SUBSCRIBE' + end, + Req = #{clientinfo => clientinfo(ClientInfo), + type => Type, + topic => Topic, + result => Bool + }, + case call_fold('client.check_acl', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + NResult = case Bool of true -> allow; _ -> deny end, + {StopOrOk, NResult}; + _ -> {ok, Result} + end. + +on_client_subscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.subscribe', Req). + +on_client_unsubscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.unsubscribe', Req). + +%%-------------------------------------------------------------------- +%% Session +%%-------------------------------------------------------------------- + +on_session_created(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.created', Req). + +on_session_subscribed(ClientInfo, Topic, SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic, + subopts => maps:with([qos, share, rh, rap, nl], SubOpts) + }, + cast('session.subscribed', Req). + +on_session_unsubscribed(ClientInfo, Topic, _SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic + }, + cast('session.unsubscribed', Req). + +on_session_resumed(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.resumed', Req). + +on_session_discarded(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.discarded', Req). + +on_session_takeovered(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.takeovered', Req). + +on_session_terminated(ClientInfo, Reason, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason)}, + cast('session.terminated', Req). + +%%-------------------------------------------------------------------- +%% Types + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +conninfo(_ConnInfo = + #{clientid := ClientId, username := Username, peername := {Peerhost, _}, + sockname := {_, SockPort}, proto_name := ProtoName, proto_ver := ProtoVer, + keepalive := Keepalive}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + peerhost => ntoa(Peerhost), + sockport => SockPort, + proto_name => ProtoName, + proto_ver => stringfy(ProtoVer), + keepalive => Keepalive}. + +clientinfo(ClientInfo = + #{clientid := ClientId, username := Username, peerhost := PeerHost, + sockport := SockPort, protocol := Protocol, mountpoint := Mountpoiont}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + password => maybe(maps:get(password, ClientInfo, undefined)), + peerhost => ntoa(PeerHost), + sockport => SockPort, + protocol => stringfy(Protocol), + mountpoint => maybe(Mountpoiont), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true)}. + +message(#message{id = Id, qos = Qos, from = From, topic = Topic, payload = Payload, timestamp = Ts}) -> + #{node => stringfy(node()), + id => hexstr(Id), + qos => Qos, + from => stringfy(From), + topic => Topic, + payload => Payload, + timestamp => Ts}. + +assign_to_message(#{qos := Qos, topic := Topic, payload := Payload}, Message) -> + Message#message{qos = Qos, topic = Topic, payload = Payload}. + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +hexstr(B) -> + iolist_to_binary([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(B)]). + +%%-------------------------------------------------------------------- +%% Acc funcs + +%% see exhook.proto +merge_responsed_bool(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_bool(Req, #{type := Type, value := {bool_result, NewBool}}) + when is_boolean(NewBool) -> + NReq = Req#{result => NewBool}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_bool(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. + +merge_responsed_message(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_message(Req, #{type := Type, value := {message, NMessage}}) -> + NReq = Req#{message => NMessage}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_message(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. diff --git a/apps/emqx_exhook/src/emqx_exhook_server.erl b/apps/emqx_exhook/src/emqx_exhook_server.erl new file mode 100644 index 000000000..451983437 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_server.erl @@ -0,0 +1,286 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_server). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook Svr]"). + +-define(PB_CLIENT_MOD, emqx_exhook_v_1_hook_provider_client). + +%% Load/Unload +-export([ load/2 + , unload/1 + ]). + +%% APIs +-export([call/3]). + +%% Infos +-export([ name/1 + , format/1 + ]). + +-record(server, { + %% Server name (equal to grpc client channel name) + name :: server_name(), + %% The server started options + options :: list(), + %% gRPC channel pid + channel :: pid(), + %% Registered hook names and options + hookspec :: #{hookpoint() => map()}, + %% Metrcis name prefix + prefix :: list() + }). + +-type server_name() :: string(). +-type server() :: #server{}. + +-type hookpoint() :: 'client.connect' + | 'client.connack' + | 'client.connected' + | 'client.disconnected' + | 'client.authenticate' + | 'client.check_acl' + | 'client.subscribe' + | 'client.unsubscribe' + | 'session.created' + | 'session.subscribed' + | 'session.unsubscribed' + | 'session.resumed' + | 'session.discarded' + | 'session.takeovered' + | 'session.terminated' + | 'message.publish' + | 'message.delivered' + | 'message.acked' + | 'message.dropped'. + +-export_type([server/0]). + +-dialyzer({nowarn_function, [inc_metrics/2]}). + +%%-------------------------------------------------------------------- +%% Load/Unload APIs +%%-------------------------------------------------------------------- + +-spec load(atom(), list()) -> {ok, server()} | {error, term()} . +load(Name0, Opts0) -> + Name = prefix(Name0), + {SvrAddr, ClientOpts} = channel_opts(Opts0), + case emqx_exhook_sup:start_grpc_client_channel(Name, SvrAddr, ClientOpts) of + {ok, _ChannPoolPid} -> + case do_init(Name) of + {ok, HookSpecs} -> + %% Reigster metrics + Prefix = lists:flatten(io_lib:format("exhook.~s.", [Name])), + ensure_metrics(Prefix, HookSpecs), + {ok, #server{name = Name, + options = Opts0, + channel = _ChannPoolPid, + hookspec = HookSpecs, + prefix = Prefix }}; + {error, _} = E -> + emqx_exhook_sup:stop_grpc_client_channel(Name), E + end; + {error, _} = E -> E + end. + +%% @private +prefix(Name) when is_atom(Name) -> + "exhook:" ++ atom_to_list(Name); +prefix(Name) when is_binary(Name) -> + "exhook:" ++ binary_to_list(Name); +prefix(Name) when is_list(Name) -> + "exhook:" ++ Name. + +%% @private +channel_opts(Opts) -> + Scheme = proplists:get_value(scheme, Opts), + Host = proplists:get_value(host, Opts), + Port = proplists:get_value(port, Opts), + SvrAddr = lists:flatten(io_lib:format("~s://~s:~w", [Scheme, Host, Port])), + ClientOpts = case Scheme of + https -> + SslOpts = lists:keydelete(ssl, 1, proplists:get_value(ssl_options, Opts, [])), + #{gun_opts => + #{transport => ssl, + transport_opts => SslOpts}}; + _ -> #{} + end, + {SvrAddr, ClientOpts}. + +-spec unload(server()) -> ok. +unload(#server{name = Name}) -> + _ = do_deinit(Name), + _ = emqx_exhook_sup:stop_grpc_client_channel(Name), + ok. + +do_deinit(Name) -> + _ = do_call(Name, 'on_provider_unloaded', #{}), + ok. + +do_init(ChannName) -> + Req = #{broker => maps:from_list(emqx_sys:info())}, + case do_call(ChannName, 'on_provider_loaded', Req) of + {ok, InitialResp} -> + try + {ok, resovle_hookspec(maps:get(hooks, InitialResp, []))} + catch _:Reason:Stk -> + ?LOG(error, "try to init ~p failed, reason: ~p, stacktrace: ~0p", + [ChannName, Reason, Stk]), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +%% @private +resovle_hookspec(HookSpecs) when is_list(HookSpecs) -> + MessageHooks = message_hooks(), + AvailableHooks = available_hooks(), + lists:foldr(fun(HookSpec, Acc) -> + case maps:get(name, HookSpec, undefined) of + undefined -> Acc; + Name0 -> + Name = try binary_to_existing_atom(Name0, utf8) catch T:R:_ -> {T,R} end, + case lists:member(Name, AvailableHooks) of + true -> + case lists:member(Name, MessageHooks) of + true -> + Acc#{Name => #{topics => maps:get(topics, HookSpec, [])}}; + _ -> + Acc#{Name => #{}} + end; + _ -> error({unknown_hookpoint, Name}) + end + end + end, #{}, HookSpecs). + +ensure_metrics(Prefix, HookSpecs) -> + Keys = [list_to_atom(Prefix ++ atom_to_list(Hookpoint)) + || Hookpoint <- maps:keys(HookSpecs)], + lists:foreach(fun emqx_metrics:ensure/1, Keys). + +format(#server{name = Name, hookspec = Hooks}) -> + io_lib:format("name=~p, hooks=~0p", [Name, Hooks]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +name(#server{name = Name}) -> + Name. + +-spec call(hookpoint(), map(), server()) + -> ignore + | {ok, Resp :: term()} + | {error, term()}. +call(Hookpoint, Req, #server{name = ChannName, hookspec = Hooks, prefix = Prefix}) -> + GrpcFunc = hk2func(Hookpoint), + case maps:get(Hookpoint, Hooks, undefined) of + undefined -> ignore; + Opts -> + NeedCall = case lists:member(Hookpoint, message_hooks()) of + false -> true; + _ -> + #{message := #{topic := Topic}} = Req, + match_topic_filter(Topic, maps:get(topics, Opts, [])) + end, + case NeedCall of + false -> ignore; + _ -> + inc_metrics(Prefix, Hookpoint), + do_call(ChannName, GrpcFunc, Req) + end + end. + +%% @private +inc_metrics(IncFun, Name) when is_function(IncFun) -> + %% BACKW: e4.2.0-e4.2.2 + {env, [Prefix|_]} = erlang:fun_info(IncFun, env), + inc_metrics(Prefix, Name); +inc_metrics(Prefix, Name) when is_list(Prefix) -> + emqx_metrics:inc(list_to_atom(Prefix ++ atom_to_list(Name))). + +-compile({inline, [match_topic_filter/2]}). +match_topic_filter(_, []) -> + true; +match_topic_filter(TopicName, TopicFilter) -> + lists:any(fun(F) -> emqx_topic:match(TopicName, F) end, TopicFilter). + +-spec do_call(string(), atom(), map()) -> {ok, map()} | {error, term()}. +do_call(ChannName, Fun, Req) -> + Options = #{channel => ChannName}, + ?LOG(debug, "Call ~0p:~0p(~0p, ~0p)", [?PB_CLIENT_MOD, Fun, Req, Options]), + case catch apply(?PB_CLIENT_MOD, Fun, [Req, Options]) of + {ok, Resp, _Metadata} -> + ?LOG(debug, "Response {ok, ~0p, ~0p}", [Resp, _Metadata]), + {ok, Resp}; + {error, {Code, Msg}, _Metadata} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) response errcode: ~0p, errmsg: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Code, Msg]), + {error, {Code, Msg}}; + {error, Reason} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) error: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason]), + {error, Reason}; + {'EXIT', {Reason, Stk}} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) throw an exception: ~0p, stacktrace: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason, Stk]), + {error, Reason} + end. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +-compile({inline, [hk2func/1]}). +hk2func('client.connect') -> 'on_client_connect'; +hk2func('client.connack') -> 'on_client_connack'; +hk2func('client.connected') -> 'on_client_connected'; +hk2func('client.disconnected') -> 'on_client_disconnected'; +hk2func('client.authenticate') -> 'on_client_authenticate'; +hk2func('client.check_acl') -> 'on_client_check_acl'; +hk2func('client.subscribe') -> 'on_client_subscribe'; +hk2func('client.unsubscribe') -> 'on_client_unsubscribe'; +hk2func('session.created') -> 'on_session_created'; +hk2func('session.subscribed') -> 'on_session_subscribed'; +hk2func('session.unsubscribed') -> 'on_session_unsubscribed'; +hk2func('session.resumed') -> 'on_session_resumed'; +hk2func('session.discarded') -> 'on_session_discarded'; +hk2func('session.takeovered') -> 'on_session_takeovered'; +hk2func('session.terminated') -> 'on_session_terminated'; +hk2func('message.publish') -> 'on_message_publish'; +hk2func('message.delivered') ->'on_message_delivered'; +hk2func('message.acked') -> 'on_message_acked'; +hk2func('message.dropped') ->'on_message_dropped'. + +-compile({inline, [message_hooks/0]}). +message_hooks() -> + ['message.publish', 'message.delivered', + 'message.acked', 'message.dropped']. + +-compile({inline, [available_hooks/0]}). +available_hooks() -> + ['client.connect', 'client.connack', 'client.connected', + 'client.disconnected', 'client.authenticate', 'client.check_acl', + 'client.subscribe', 'client.unsubscribe', + 'session.created', 'session.subscribed', 'session.unsubscribed', + 'session.resumed', 'session.discarded', 'session.takeovered', + 'session.terminated' | message_hooks()]. diff --git a/apps/emqx_exhook/src/emqx_exhook_sup.erl b/apps/emqx_exhook/src/emqx_exhook_sup.erl new file mode 100644 index 000000000..c8d2ecf35 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_sup.erl @@ -0,0 +1,59 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_sup). + +-behaviour(supervisor). + +-export([ start_link/0 + , init/1 + ]). + +-export([ start_grpc_client_channel/3 + , stop_grpc_client_channel/1 + ]). + +%%-------------------------------------------------------------------- +%% Supervisor APIs & Callbacks +%%-------------------------------------------------------------------- + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, {{one_for_one, 10, 100}, []}}. + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +-spec start_grpc_client_channel( + string(), + uri_string:uri_string(), + grpc_client:options()) -> {ok, pid()} | {error, term()}. +start_grpc_client_channel(Name, SvrAddr, Options) -> + grpc_client_sup:create_channel_pool(Name, SvrAddr, Options). + +-spec stop_grpc_client_channel(string()) -> ok. +stop_grpc_client_channel(Name) -> + %% Avoid crash due to hot-upgrade had unloaded + %% grpc application + try + grpc_client_sup:stop_channel_pool(Name) + catch + _:_:_ -> + ok + end. diff --git a/apps/emqx_exhook/test/emqx_exhook_SUITE.erl b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl new file mode 100644 index 000000000..b66950215 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl @@ -0,0 +1,53 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + Cfg. + +end_per_suite(_Cfg) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + emqx_exhook_demo_svr:stop(). + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Test cases +%%-------------------------------------------------------------------- + +t_hooks(_Cfg) -> + ok. diff --git a/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl new file mode 100644 index 000000000..05fa07465 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl @@ -0,0 +1,297 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_demo_svr). + +-behavior(emqx_exhook_v_1_hook_provider_bhvr). + +%% +-export([ start/0 + , stop/0 + , take/0 + , in/1 + ]). + +%% gRPC server HookProvider callbacks +-export([ on_provider_loaded/2 + , on_provider_unloaded/2 + , on_client_connect/2 + , on_client_connack/2 + , on_client_connected/2 + , on_client_disconnected/2 + , on_client_authenticate/2 + , on_client_check_acl/2 + , on_client_subscribe/2 + , on_client_unsubscribe/2 + , on_session_created/2 + , on_session_subscribed/2 + , on_session_unsubscribed/2 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/2 + , on_message_publish/2 + , on_message_delivered/2 + , on_message_dropped/2 + , on_message_acked/2 + ]). + +-define(PORT, 9000). +-define(NAME, ?MODULE). + +%%-------------------------------------------------------------------- +%% Server APIs +%%-------------------------------------------------------------------- + +start() -> + Pid = spawn(fun mngr_main/0), + register(?MODULE, Pid), + {ok, Pid}. + +stop() -> + grpc:stop_server(?NAME), + ?MODULE ! stop. + +take() -> + ?MODULE ! {take, self()}, + receive {value, V} -> V + after 5000 -> error(timeout) end. + +in({FunName, Req}) -> + ?MODULE ! {in, FunName, Req}. + +mngr_main() -> + application:ensure_all_started(grpc), + Services = #{protos => [emqx_exhook_pb], + services => #{'emqx.exhook.v1.HookProvider' => emqx_exhook_demo_svr} + }, + Options = [], + Svr = grpc:start_server(?NAME, ?PORT, Services, Options), + mngr_loop([Svr, queue:new(), queue:new()]). + +mngr_loop([Svr, Q, Takes]) -> + receive + {in, FunName, Req} -> + {NQ1, NQ2} = reply(queue:in({FunName, Req}, Q), Takes), + mngr_loop([Svr, NQ1, NQ2]); + {take, From} -> + {NQ1, NQ2} = reply(Q, queue:in(From, Takes)), + mngr_loop([Svr, NQ1, NQ2]); + stop -> + exit(normal) + end. + +reply(Q1, Q2) -> + case queue:len(Q1) =:= 0 orelse + queue:len(Q2) =:= 0 of + true -> {Q1, Q2}; + _ -> + {{value, {Name, V}}, NQ1} = queue:out(Q1), + {{value, From}, NQ2} = queue:out(Q2), + From ! {value, {Name, V}}, + {NQ1, NQ2} + end. + +%%-------------------------------------------------------------------- +%% callbacks +%%-------------------------------------------------------------------- + +-spec on_provider_loaded(emqx_exhook_pb:provider_loaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:loaded_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. + +on_provider_loaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{hooks => [ + #{name => <<"client.connect">>}, + #{name => <<"client.connack">>}, + #{name => <<"client.connected">>}, + #{name => <<"client.disconnected">>}, + #{name => <<"client.authenticate">>}, + #{name => <<"client.check_acl">>}, + #{name => <<"client.subscribe">>}, + #{name => <<"client.unsubscribe">>}, + #{name => <<"session.created">>}, + #{name => <<"session.subscribed">>}, + #{name => <<"session.unsubscribed">>}, + #{name => <<"session.resumed">>}, + #{name => <<"session.discarded">>}, + #{name => <<"session.takeovered">>}, + #{name => <<"session.terminated">>}, + #{name => <<"message.publish">>}, + #{name => <<"message.delivered">>}, + #{name => <<"message.acked">>}, + #{name => <<"message.dropped">>}]}, Md}. +-spec on_provider_unloaded(emqx_exhook_pb:provider_unloaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_provider_unloaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connect(emqx_exhook_pb:client_connect_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connect(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connack(emqx_exhook_pb:client_connack_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connack(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connected(emqx_exhook_pb:client_connected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_disconnected(emqx_exhook_pb:client_disconnected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_disconnected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_authenticate(emqx_exhook_pb:client_authenticate_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_authenticate(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'IGNORE'}, Md}. + +-spec on_client_check_acl(emqx_exhook_pb:client_check_acl_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_check_acl(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'STOP_AND_RETURN', value => {bool_result, true}}, Md}. + +-spec on_client_subscribe(emqx_exhook_pb:client_subscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_subscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_unsubscribe(emqx_exhook_pb:client_unsubscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_unsubscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_created(emqx_exhook_pb:session_created_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_created(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_subscribed(emqx_exhook_pb:session_subscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_subscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_unsubscribed(emqx_exhook_pb:session_unsubscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_unsubscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_resumed(emqx_exhook_pb:session_resumed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_resumed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_discarded(emqx_exhook_pb:session_discarded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_discarded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_takeovered(emqx_exhook_pb:session_takeovered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_takeovered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_terminated(emqx_exhook_pb:session_terminated_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_terminated(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_publish(emqx_exhook_pb:message_publish_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_publish(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_delivered(emqx_exhook_pb:message_delivered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_delivered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_dropped(emqx_exhook_pb:message_dropped_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_dropped(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_acked(emqx_exhook_pb:message_acked_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_acked(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. diff --git a/apps/emqx_exhook/test/props/prop_exhook_hooks.erl b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl new file mode 100644 index 000000000..e4c11dd3d --- /dev/null +++ b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl @@ -0,0 +1,537 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(prop_exhook_hooks). + +-include_lib("proper/include/proper.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-import(emqx_ct_proper_types, + [ conninfo/0 + , clientinfo/0 + , sessioninfo/0 + , message/0 + , connack_return_code/0 + , topictab/0 + , topic/0 + , subopts/0 + ]). + +-define(ALL(Vars, Types, Exprs), + ?SETUP(fun() -> + State = do_setup(), + fun() -> do_teardown(State) end + end, ?FORALL(Vars, Types, Exprs))). + +%%-------------------------------------------------------------------- +%% Properties +%%-------------------------------------------------------------------- + +prop_client_connect() -> + ?ALL({ConnInfo, ConnProps}, + {conninfo(), conn_properties()}, + begin + _OutConnProps = emqx_hooks:run_fold('client.connect', [ConnInfo], ConnProps), + {'on_client_connect', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(ConnProps), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_connack() -> + ?ALL({ConnInfo, Rc, AckProps}, + {conninfo(), connack_return_code(), ack_properties()}, + begin + _OutAckProps = emqx_hooks:run_fold('client.connack', [ConnInfo, Rc], AckProps), + {'on_client_connack', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(AckProps), + result_code => atom_to_binary(Rc, utf8), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_authenticate() -> + ?ALL({ClientInfo, AuthResult}, {clientinfo(), authresult()}, + begin + _OutAuthResult = emqx_hooks:run_fold('client.authenticate', [ClientInfo], AuthResult), + {'on_client_authenticate', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => authresult_to_bool(AuthResult), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_check_acl() -> + ?ALL({ClientInfo, PubSub, Topic, Result}, + {clientinfo(), oneof([publish, subscribe]), topic(), oneof([allow, deny])}, + begin + _OutResult = emqx_hooks:run_fold('client.check_acl', [ClientInfo, PubSub, Topic], Result), + {'on_client_check_acl', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => aclresult_to_bool(Result), + type => pubsub_to_enum(PubSub), + topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + + +prop_client_connected() -> + ?ALL({ClientInfo, ConnInfo}, + {clientinfo(), conninfo()}, + begin + ok = emqx_hooks:run('client.connected', [ClientInfo, ConnInfo]), + {'on_client_connected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_disconnected() -> + ?ALL({ClientInfo, Reason, ConnInfo}, + {clientinfo(), shutdown_reason(), conninfo()}, + begin + ok = emqx_hooks:run('client.disconnected', [ClientInfo, Reason, ConnInfo]), + {'on_client_disconnected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_subscribe() -> + ?ALL({ClientInfo, SubProps, TopicTab}, + {clientinfo(), sub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.subscribe', [ClientInfo, SubProps], TopicTab), + {'on_client_subscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(SubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_unsubscribe() -> + ?ALL({ClientInfo, UnSubProps, TopicTab}, + {clientinfo(), unsub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.unsubscribe', [ClientInfo, UnSubProps], TopicTab), + {'on_client_unsubscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(UnSubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_created() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.created', [ClientInfo, SessInfo]), + {'on_session_created', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_subscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.subscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_subscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + subopts => subopts(SubOpts), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_unsubscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.unsubscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_unsubscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_resumed() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.resumed', [ClientInfo, SessInfo]), + {'on_session_resumed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_discared() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.discarded', [ClientInfo, SessInfo]), + {'on_session_discarded', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_takeovered() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.takeovered', [ClientInfo, SessInfo]), + {'on_session_takeovered', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_terminated() -> + ?ALL({ClientInfo, Reason, SessInfo}, + {clientinfo(), shutdown_reason(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.terminated', [ClientInfo, Reason, SessInfo]), + {'on_session_terminated', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + + true + end). + +nodestr() -> + stringfy(node()). + +peerhost(#{peername := {Host, _}}) -> + ntoa(Host). + +sockport(#{sockname := {_, Port}}) -> + Port. + +%% copied from emqx_exhook + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +subopts(SubOpts) -> + #{qos => maps:get(qos, SubOpts, 0), + rh => maps:get(rh, SubOpts, 0), + rap => maps:get(rap, SubOpts, 0), + nl => maps:get(nl, SubOpts, 0), + share => maps:get(share, SubOpts, <<>>) + }. + +authresult_to_bool(AuthResult) -> + maps:get(auth_result, AuthResult, undefined) == success. + +aclresult_to_bool(Result) -> + Result == allow. + +pubsub_to_enum(publish) -> 'PUBLISH'; +pubsub_to_enum(subscribe) -> 'SUBSCRIBE'. + +%prop_message_publish() -> +% ?ALL({Msg, Env, Encode}, {message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_delivered() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_acked() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message()}, +% begin +% true +% end). + +%%-------------------------------------------------------------------- +%% Helper +%%-------------------------------------------------------------------- + +do_setup() -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + emqx_logger:set_log_level(warning), + %% waiting first loaded event + {'on_provider_loaded', _} = emqx_exhook_demo_svr:take(), + ok. + +do_teardown(_) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + %% waiting last unloaded event + {'on_provider_unloaded', _} = emqx_exhook_demo_svr:take(), + _ = emqx_exhook_demo_svr:stop(), + timer:sleep(2000), + ok. + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Generators +%%-------------------------------------------------------------------- + +conn_properties() -> + #{}. + +ack_properties() -> + #{}. + +sub_properties() -> + #{}. + +unsub_properties() -> + #{}. + +shutdown_reason() -> + oneof([utf8(), {shutdown, atom()}]). + +authresult() -> + #{auth_result => connack_return_code()}. + +%topic_filter_env() -> +% oneof([{<<"#">>}, {undefined}, {topic()}]). diff --git a/apps/emqx_exproto/.gitignore b/apps/emqx_exproto/.gitignore new file mode 100644 index 000000000..384f2255a --- /dev/null +++ b/apps/emqx_exproto/.gitignore @@ -0,0 +1,48 @@ +.eunit +deps +!deps/.placeholder +*.o +*.beam +*.plt +erl_crash.dump +ebin +!ebin/.placeholder +.concrete/DEV_MODE +.rebar +test/ebin/*.beam +.exrc +plugins/*/ebin +log/ +*.swp +*.so +.erlang.mk/ +cover/ +emqx.d +eunit.coverdata +test/ct.cover.spec +logs +ct.coverdata +.idea/ +emqx.iml +_rel/ +data/ +_build +.rebar3 +rebar3.crashdump +.DS_Store +emqx.iml +bbmustache/ +etc/gen.emqx.conf +compile_commands.json +cuttlefish +rebar.lock +xrefr +erlang.mk +*.coverdata +etc/emqx_exproto.conf.rendered +Mnesia.*/ +src/emqx_exproto_pb.erl +src/emqx_exproto_v_1_connection_adapter_bhvr.erl +src/emqx_exproto_v_1_connection_adapter_client.erl +src/emqx_exproto_v_1_connection_handler_bhvr.erl +src/emqx_exproto_v_1_connection_handler_client.erl diff --git a/apps/emqx_exproto/README.md b/apps/emqx_exproto/README.md new file mode 100644 index 000000000..a9375e5d3 --- /dev/null +++ b/apps/emqx_exproto/README.md @@ -0,0 +1,24 @@ +# emqx-exproto + +The `emqx_exproto` extremly enhance the extensibility for EMQ X. It allow using an others programming language to **replace the protocol handling layer in EMQ X Broker**. + +## Feature + +- [x] Based on gRPC, it brings a very wide range of applicability +- [x] Allows you to use the return value to extend emqx behavior. + +## Architecture + +![EMQ X ExProto Arch](./docs/images/exproto-arch.jpg) + +## Usage + +### gRPC service + +See: `priv/protos/exproto.proto` + +## Example + +## Recommended gRPC Framework + +See: https://github.com/grpc-ecosystem/awesome-grpc diff --git a/apps/emqx_exproto/docs/design-cn.md b/apps/emqx_exproto/docs/design-cn.md new file mode 100644 index 000000000..7af7dbdb3 --- /dev/null +++ b/apps/emqx_exproto/docs/design-cn.md @@ -0,0 +1,127 @@ +# 多语言 - 协议接入 + +`emqx-exproto` 插件用于协议解析的多语言支持。它能够允许其他编程语言(例如:Python,Java 等)直接处理数据流实现协议的解析,并提供 Pub/Sub 接口以实现与系统其它组件的通信。 + +该插件给 EMQ X 带来的扩展性十分的强大,它能以你熟悉语言处理任何的私有协议,并享受由 EMQ X 系统带来的高连接,和高并发的优点。 + +## 特性 + +- 极强的扩展能力。使用 gRPC 作为 RPC 通信框架,支持各个主流编程语言 +- 高吞吐。连接层以完全的异步非阻塞式 I/O 的方式实现 +- 连接层透明。完全的支持 TCP\TLS UDP\DTLS 类型的连接管理,并对上层提供统一的 API 接口 +- 连接层的管理能力。例如,最大连接数,连接和吞吐的速率限制,IP 黑名单 等 + +## 架构 + +![Extension-Protocol Arch](images/exproto-arch.jpg) + +该插件主要需要处理的内容包括: + +1. **连接层:** 该部分主要 **维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: + - 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 + - 调用 `OnSocketCreated` 回调。用于通知外部模块**已新建立了一个连接**。 + - 调用 `OnScoektClosed` 回调。用于通知外部模块连接**已关闭**。 + - 调用 `OnReceivedBytes` 回调。用于通知外部模块**该连接新收到的数据包**。 + - 提供 `Send` 接口。供外部模块调用,**用于发送数据包**。 + - 提供 `Close` 接口。供外部模块调用,**用于主动关闭连接**。 + +2. **协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: + + - 提供 `Authenticate` 接口。供外部模块调用,用于向集群注册客户端。 + - 提供 `StartTimer` 接口。供外部模块调用,用于为该连接进程启动心跳等定时器。 + - 提供 `Publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 + - 提供 `Subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 + - 提供 `Unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 + - 调用 `OnTimerTimeout` 回调。用于处理定时器超时的事件。 + - 调用 `OnReceivedMessages` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) + +## 接口设计 + +从 gRPC 上的逻辑来说,emqx-exproto 会作为客户端向用户的 `ConnectionHandler` 服务发送回调请求。同时,它也会作为服务端向用户提供 `ConnectionAdapter` 服务,以提供 emqx-exproto 各个接口的访问。如图: + +![Extension Protocol gRPC Arch](images/exproto-grpc-arch.jpg) + + +详情参见:`priv/protos/exproto.proto`,例如接口的定义有: + +```protobuff +syntax = "proto3"; + +package emqx.exproto.v1; + +// The Broker side serivce. It provides a set of APIs to +// handle a protcol access +service ConnectionAdapter { + + // -- socket layer + + rpc Send(SendBytesRequest) returns (CodeResponse) {}; + + rpc Close(CloseSocketRequest) returns (CodeResponse) {}; + + // -- protocol layer + + rpc Authenticate(AuthenticateRequest) returns (CodeResponse) {}; + + rpc StartTimer(TimerRequest) returns (CodeResponse) {}; + + // -- pub/sub layer + + rpc Publish(PublishRequest) returns (CodeResponse) {}; + + rpc Subscribe(SubscribeRequest) returns (CodeResponse) {}; + + rpc Unsubscribe(UnsubscribeRequest) returns (CodeResponse) {}; +} + +service ConnectionHandler { + + // -- socket layer + + rpc OnSocketCreated(stream SocketCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSocketClosed(stream SocketClosedRequest) returns (EmptySuccess) {}; + + rpc OnReceivedBytes(stream ReceivedBytesRequest) returns (EmptySuccess) {}; + + // -- pub/sub layer + + rpc OnTimerTimeout(stream TimerTimeoutRequest) returns (EmptySuccess) {}; + + rpc OnReceivedMessages(stream ReceivedMessagesRequest) returns (EmptySuccess) {}; +} +``` + +## 配置项设计 + +1. 以 **监听器(Listener)** 为基础,提供 TCP/UDP 的监听。 + - Listener 目前仅支持:TCP、TLS、UDP、DTLS。(ws、wss、quic 暂不支持) +2. 每个监听器,会指定一个 `ConnectionHandler` 的服务地址,用于调用外部模块的接口。 +3. emqx-exproto 还会监听一个 gRPC 端口用于提供对 `ConnectionAdapter` 服务的访问。 + +例如: + +``` properties +## gRPC 服务监听地址 (HTTP) +## +exproto.server.http.url = http://127.0.0.1:9002 + +## gRPC 服务监听地址 (HTTPS) +## +exproto.server.https.url = https://127.0.0.1:9002 +exproto.server.https.cacertfile = ca.pem +exproto.server.https.certfile = cert.pem +exproto.server.https.keyfile = key.pem + +## Listener 配置 +## 例如,名称为 protoname 协议的 TCP 监听器配置 +exproto.listener.protoname = tcp://0.0.0.0:7993 + +## ConnectionHandler 服务地址及 https 的证书配置 +exproto.listener.protoname.connection_handler_url = http://127.0.0.1:9001 +#exproto.listener.protoname.connection_handler_certfile = +#exproto.listener.protoname.connection_handler_cacertfile = +#exproto.listener.protoname.connection_handler_keyfile = + +# ... +``` diff --git a/apps/emqx_exproto/docs/images/exproto-arch.jpg b/apps/emqx_exproto/docs/images/exproto-arch.jpg new file mode 100644 index 000000000..dddf7996b Binary files /dev/null and b/apps/emqx_exproto/docs/images/exproto-arch.jpg differ diff --git a/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg b/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg new file mode 100644 index 000000000..71efa76f9 Binary files /dev/null and b/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg differ diff --git a/apps/emqx_exproto/etc/emqx_exproto.conf b/apps/emqx_exproto/etc/emqx_exproto.conf new file mode 100644 index 000000000..a64153791 --- /dev/null +++ b/apps/emqx_exproto/etc/emqx_exproto.conf @@ -0,0 +1,252 @@ +##==================================================================== +## EMQ X ExProto +##==================================================================== + +exproto.server.http.port = 9100 + +exproto.server.https.port = 9101 +exproto.server.https.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem +exproto.server.https.certfile = {{ platform_etc_dir }}/certs/cert.pem +exproto.server.https.keyfile = {{ platform_etc_dir }}/certs/key.pem + +##-------------------------------------------------------------------- +## Listeners +##-------------------------------------------------------------------- + +##-------------------------------------------------------------------- +## MQTT/TCP - External TCP Listener for MQTT Protocol + +## The IP address and port that the listener will bind. +## +## Value: ://: +## +## Examples: tcp://0.0.0.0:7993 | ssl://127.0.0.1:7994 +exproto.listener.protoname = tcp://0.0.0.0:7993 + +## The ConnectionHandler server address +## +exproto.listener.protoname.connection_handler_url = http://127.0.0.1:9001 + +#exproto.listener.protoname.connection_handler_certfile = +#exproto.listener.protoname.connection_handler_cacertfile = +#exproto.listener.protoname.connection_handler_keyfile = + +## The acceptor pool for external MQTT/TCP listener. +## +## Value: Number +exproto.listener.protoname.acceptors = 8 + +## Maximum number of concurrent MQTT/TCP connections. +## +## Value: Number +exproto.listener.protoname.max_connections = 1024000 + +## Maximum external connections per second. +## +## Value: Number +exproto.listener.protoname.max_conn_rate = 1000 + +## Specify the {active, N} option for the external MQTT/TCP Socket. +## +## Value: Number +exproto.listener.protoname.active_n = 100 + +## Idle timeout +## +## Value: Duration +exproto.listener.protoname.idle_timeout = 30s + +## The access control rules for the MQTT/TCP listener. +## +## See: https://github.com/emqtt/esockd#allowdeny +## +## Value: ACL Rule +## +## Example: allow 192.168.0.0/24 +exproto.listener.protoname.access.1 = allow all + +## Enable the Proxy Protocol V1/2 if the EMQ X cluster is deployed +## behind HAProxy or Nginx. +## +## See: https://www.haproxy.com/blog/haproxy/proxy-protocol/ +## +## Value: on | off +## exproto.listener.protoname.proxy_protocol = on + +## Sets the timeout for proxy protocol. EMQ X will close the TCP connection +## if no proxy protocol packet recevied within the timeout. +## +## Value: Duration +#exproto.listener.protoname.proxy_protocol_timeout = 3s + +## The TCP backlog defines the maximum length that the queue of pending +## connections can grow to. +## +## Value: Number >= 0 +exproto.listener.protoname.backlog = 1024 + +## The TCP send timeout for external MQTT connections. +## +## Value: Duration +exproto.listener.protoname.send_timeout = 15s + +## Close the TCP connection if send timeout. +## +## Value: on | off +exproto.listener.protoname.send_timeout_close = on + +## The TCP receive buffer(os kernel) for MQTT connections. +## +## See: http://erlang.org/doc/man/inet.html +## +## Value: Bytes +#exproto.listener.protoname.recbuf = 2KB + +## The TCP send buffer(os kernel) for MQTT connections. +## +## See: http://erlang.org/doc/man/inet.html +## +## Value: Bytes +#exproto.listener.protoname.sndbuf = 2KB + +## The size of the user-level software buffer used by the driver. +## Not to be confused with options sndbuf and recbuf, which correspond +## to the Kernel socket buffers. It is recommended to have val(buffer) +## >= max(val(sndbuf),val(recbuf)) to avoid performance issues because +## of unnecessary copying. val(buffer) is automatically set to the above +## maximum when values sndbuf or recbuf are set. +## +## See: http://erlang.org/doc/man/inet.html +## +## Value: Bytes +#exproto.listener.protoname.buffer = 2KB + +## Sets the 'buffer = max(sndbuf, recbuf)' if this option is enabled. +## +## Value: on | off +#exproto.listener.protoname.tune_buffer = off + +## The TCP_NODELAY flag for MQTT connections. Small amounts of data are +## sent immediately if the option is enabled. +## +## Value: true | false +exproto.listener.protoname.nodelay = true + +## The SO_REUSEADDR flag for TCP listener. +## +## Value: true | false +exproto.listener.protoname.reuseaddr = true + + +##-------------------------------------------------------------------- +## TLS/DTLS options + +## TLS versions only to protect from POODLE attack. +## +## See: http://erlang.org/doc/man/ssl.html +## +## Value: String, seperated by ',' +#exproto.listener.protoname.tls_versions = tlsv1.2,tlsv1.1,tlsv1 + +## Path to the file containing the user's private PEM-encoded key. +## +## See: http://erlang.org/doc/man/ssl.html +## +## Value: File +#exproto.listener.protoname.keyfile = {{ platform_etc_dir }}/certs/key.pem + +## Path to a file containing the user certificate. +## +## See: http://erlang.org/doc/man/ssl.html +## +## Value: File +#exproto.listener.protoname.certfile = {{ platform_etc_dir }}/certs/cert.pem + +## Path to the file containing PEM-encoded CA certificates. The CA certificates +## are used during server authentication and when building the client certificate chain. +## +## Value: File +#exproto.listener.protoname.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem + +## The Ephemeral Diffie-Helman key exchange is a very effective way of +## ensuring Forward Secrecy by exchanging a set of keys that never hit +## the wire. Since the DH key is effectively signed by the private key, +## it needs to be at least as strong as the private key. In addition, +## the default DH groups that most of the OpenSSL installations have +## are only a handful (since they are distributed with the OpenSSL +## package that has been built for the operating system it’s running on) +## and hence predictable (not to mention, 1024 bits only). +## In order to escape this situation, first we need to generate a fresh, +## strong DH group, store it in a file and then use the option above, +## to force our SSL application to use the new DH group. Fortunately, +## OpenSSL provides us with a tool to do that. Simply run: +## openssl dhparam -out dh-params.pem 2048 +## +## Value: File +#exproto.listener.protoname.dhfile = {{ platform_etc_dir }}/certs/dh-params.pem + +## A server only does x509-path validation in mode verify_peer, +## as it then sends a certificate request to the client (this +## message is not sent if the verify option is verify_none). +## You can then also want to specify option fail_if_no_peer_cert. +## More information at: http://erlang.org/doc/man/ssl.html +## +## Value: verify_peer | verify_none +#exproto.listener.protoname.verify = verify_peer + +## Used together with {verify, verify_peer} by an SSL server. If set to true, +## the server fails if the client does not have a certificate to send, that is, +## sends an empty certificate. +## +## Value: true | false +#exproto.listener.protoname.fail_if_no_peer_cert = true + +## This is the single most important configuration option of an Erlang SSL +## application. Ciphers (and their ordering) define the way the client and +## server encrypt information over the wire, from the initial Diffie-Helman +## key exchange, the session key encryption ## algorithm and the message +## digest algorithm. Selecting a good cipher suite is critical for the +## application’s data security, confidentiality and performance. +## +## The cipher list above offers: +## +## A good balance between compatibility with older browsers. +## It can get stricter for Machine-To-Machine scenarios. +## Perfect Forward Secrecy. +## No old/insecure encryption and HMAC algorithms +## +## Most of it was copied from Mozilla’s Server Side TLS article +## +## Value: Ciphers +#exproto.listener.protoname.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA + +## Ciphers for TLS PSK. +## Note that 'listener.ssl.external.ciphers' and 'listener.ssl.external.psk_ciphers' cannot +## be configured at the same time. +## See 'https://tools.ietf.org/html/rfc4279#section-2'. +#exproto.listener.protoname.psk_ciphers = PSK-AES128-CBC-SHA,PSK-AES256-CBC-SHA,PSK-3DES-EDE-CBC-SHA,PSK-RC4-SHA + +## SSL parameter renegotiation is a feature that allows a client and a server +## to renegotiate the parameters of the SSL connection on the fly. +## RFC 5746 defines a more secure way of doing this. By enabling secure renegotiation, +## you drop support for the insecure renegotiation, prone to MitM attacks. +## +## Value: on | off +#exproto.listener.protoname.secure_renegotiate = off + +## A performance optimization setting, it allows clients to reuse +## pre-existing sessions, instead of initializing new ones. +## Read more about it here. +## +## See: http://erlang.org/doc/man/ssl.html +## +## Value: on | off +#exproto.listener.protoname.reuse_sessions = on + +## An important security setting, it forces the cipher to be set based +## on the server-specified order instead of the client-specified order, +## hence enforcing the (usually more properly configured) security +## ordering of the server administrator. +## +## Value: on | off +#exproto.listener.protoname.honor_cipher_order = on diff --git a/apps/emqx_exproto/include/emqx_exproto.hrl b/apps/emqx_exproto/include/emqx_exproto.hrl new file mode 100644 index 000000000..079a1e60f --- /dev/null +++ b/apps/emqx_exproto/include/emqx_exproto.hrl @@ -0,0 +1,37 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-define(APP, emqx_exproto). + +-define(TCP_SOCKOPTS, [binary, {packet, raw}, {reuseaddr, true}, + {backlog, 512}, {nodelay, true}]). + +%% TODO: +-define(UDP_SOCKOPTS, []). + +%%-------------------------------------------------------------------- +%% gRPC result code + +-define(RESP_UNKNOWN, 'UNKNOWN'). +-define(RESP_SUCCESS, 'SUCCESS'). +-define(RESP_CONN_PROCESS_NOT_ALIVE, 'CONN_PROCESS_NOT_ALIVE'). +-define(RESP_PARAMS_TYPE_ERROR, 'PARAMS_TYPE_ERROR'). +-define(RESP_REQUIRED_PARAMS_MISSED, 'REQUIRED_PARAMS_MISSED'). +-define(RESP_PERMISSION_DENY, 'PERMISSION_DENY'). +-define(IS_GRPC_RESULT_CODE(C), ( C =:= ?RESP_SUCCESS + orelse C =:= ?RESP_CONN_PROCESS_NOT_ALIVE + orelse C =:= ?RESP_REQUIRED_PARAMS_MISSED + orelse C =:= ?RESP_PERMISSION_DENY)). diff --git a/apps/emqx_exproto/priv/emqx_exproto.schema b/apps/emqx_exproto/priv/emqx_exproto.schema new file mode 100644 index 000000000..fb114dc77 --- /dev/null +++ b/apps/emqx_exproto/priv/emqx_exproto.schema @@ -0,0 +1,364 @@ +%% -*-: erlang -*- + +%%-------------------------------------------------------------------- +%% Services + +{mapping, "exproto.server.http.port", "emqx_exproto.servers", [ + {datatype, integer} +]}. + +{mapping, "exproto.server.https.port", "emqx_exproto.servers", [ + {datatype, integer} +]}. + +{mapping, "exproto.server.https.cacertfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{mapping, "exproto.server.https.certfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{mapping, "exproto.server.https.keyfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{translation, "emqx_exproto.servers", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + Http = case cuttlefish:conf_get("exproto.server.http.port", Conf, undefined) of + undefined -> []; + P1 -> [{http, P1, []}] + end, + Https = case cuttlefish:conf_get("exproto.server.https.port", Conf, undefined) of + undefined -> []; + P2 -> + [{https, P2, + Filter([{ssl, true}, + {certfile, cuttlefish:conf_get("exproto.server.https.certfile", Conf)}, + {keyfile, cuttlefish:conf_get("exproto.server.https.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get("exproto.server.https.cacertfile", Conf)}])}] + end, + Http ++ Https +end}. + +%%-------------------------------------------------------------------- +%% Listeners + +{mapping, "exproto.listener.$proto", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_url", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_certfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_cacertfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_keyfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.acceptors", "emqx_exproto.listeners", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "exproto.listener.$proto.max_connections", "emqx_exproto.listeners", [ + {default, 1024}, + {datatype, integer} +]}. + +{mapping, "exproto.listener.$proto.max_conn_rate", "emqx_exproto.listeners", [ + {datatype, integer} +]}. + +{mapping, "exproto.listener.$proto.active_n", "emqx_exproto.listeners", [ + {default, 100}, + {datatype, integer} +]}. + +{mapping, "exproto.listener.$proto.idle_timeout", "emqx_exproto.listeners", [ + {default, "30s"}, + {datatype, {duration, ms}} +]}. + +{mapping, "exproto.listener.$proto.access.$id", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.proxy_protocol", "emqx_exproto.listeners", [ + {datatype, flag} +]}. + +{mapping, "exproto.listener.$proto.proxy_protocol_timeout", "emqx_exproto.listeners", [ + {datatype, {duration, ms}} +]}. + +{mapping, "exproto.listener.$proto.backlog", "emqx_exproto.listeners", [ + {datatype, integer}, + {default, 1024} +]}. + +{mapping, "exproto.listener.$proto.send_timeout", "emqx_exproto.listeners", [ + {datatype, {duration, ms}}, + {default, "15s"} +]}. + +{mapping, "exproto.listener.$proto.send_timeout_close", "emqx_exproto.listeners", [ + {datatype, flag}, + {default, on} +]}. + +{mapping, "exproto.listener.$proto.recbuf", "emqx_exproto.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "exproto.listener.$proto.sndbuf", "emqx_exproto.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "exproto.listener.$proto.buffer", "emqx_exproto.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "exproto.listener.$proto.tune_buffer", "emqx_exproto.listeners", [ + {datatype, flag}, + hidden +]}. + +{mapping, "exproto.listener.$proto.nodelay", "emqx_exproto.listeners", [ + {datatype, {enum, [true, false]}}, + hidden +]}. + +{mapping, "exproto.listener.$proto.reuseaddr", "emqx_exproto.listeners", [ + {datatype, {enum, [true, false]}}, + hidden +]}. + +%%-------------------------------------------------------------------- +%% TLS Options + +{mapping, "exproto.listener.$proto.tls_versions", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.ciphers", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.psk_ciphers", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.dhfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.keyfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.certfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.cacertfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.verify", "emqx_exproto.listeners", [ + {datatype, atom} +]}. + +{mapping, "exproto.listener.$proto.fail_if_no_peer_cert", "emqx_exproto.listeners", [ + {datatype, {enum, [true, false]}} +]}. + +{mapping, "exproto.listener.$proto.secure_renegotiate", "emqx_exproto.listeners", [ + {datatype, flag} +]}. + +{mapping, "exproto.listener.$proto.reuse_sessions", "emqx_exproto.listeners", [ + {default, on}, + {datatype, flag} +]}. + +{mapping, "exproto.listener.$proto.honor_cipher_order", "emqx_exproto.listeners", [ + {datatype, flag} +]}. + +{translation, "emqx_exproto.listeners", fun(Conf) -> + + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + + Atom = fun(undefined) -> undefined; (S) -> list_to_atom(S) end, + + Access = fun(S) -> + [A, CIDR] = string:tokens(S, " "), + {list_to_atom(A), case CIDR of "all" -> all; _ -> CIDR end} + end, + + AccOpts = fun(Prefix) -> + case cuttlefish_variable:filter_by_prefix(Prefix ++ ".access", Conf) of + [] -> []; + Rules -> [{access_rules, [Access(Rule) || {_, Rule} <- Rules]}] + end + end, + + RateLimit = fun(undefined) -> + undefined; + (Val) -> + [L, D] = string:tokens(Val, ", "), + Limit = case cuttlefish_bytesize:parse(L) of + Sz when is_integer(Sz) -> Sz; + {error, Reason} -> error(Reason) + end, + Duration = case cuttlefish_duration:parse(D, s) of + Secs when is_integer(Secs) -> Secs; + {error, Reason1} -> error(Reason1) + end, + Rate = Limit / Duration, + {Rate, Limit} + end, + + HandlerOpts = fun(Prefix) -> + Opts = + case http_uri:parse(cuttlefish:conf_get(Prefix ++ ".connection_handler_url", Conf)) of + {ok, {http, _, Host, Port, _, _}} -> + [{scheme, http}, {host, Host}, {port, Port}]; + {ok, {https, _, Host, Port, _, _}} -> + [{scheme, https}, {host, Host}, {port, Port}, + {ssl_options, + Filter([{certfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_certfile", Conf)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_cacertfile", Conf)} + ])}]; + _ -> + error(invaild_connection_handler_url) + end, + [{handler, Opts}] + end, + + ConnOpts = fun(Prefix) -> + Filter([{active_n, cuttlefish:conf_get(Prefix ++ ".active_n", Conf, undefined)}, + {idle_timeout, cuttlefish:conf_get(Prefix ++ ".idle_timeout", Conf, undefined)}]) + end, + + LisOpts = fun(Prefix) -> + Filter([{acceptors, cuttlefish:conf_get(Prefix ++ ".acceptors", Conf)}, + {max_connections, cuttlefish:conf_get(Prefix ++ ".max_connections", Conf)}, + {max_conn_rate, cuttlefish:conf_get(Prefix ++ ".max_conn_rate", Conf, undefined)}, + {tune_buffer, cuttlefish:conf_get(Prefix ++ ".tune_buffer", Conf, undefined)}, + {proxy_protocol, cuttlefish:conf_get(Prefix ++ ".proxy_protocol", Conf, undefined)}, + {proxy_protocol_timeout, cuttlefish:conf_get(Prefix ++ ".proxy_protocol_timeout", Conf, undefined)} | AccOpts(Prefix)]) + end, + + TcpOpts = fun(Prefix) -> + Filter([{backlog, cuttlefish:conf_get(Prefix ++ ".backlog", Conf, undefined)}, + {send_timeout, cuttlefish:conf_get(Prefix ++ ".send_timeout", Conf, undefined)}, + {send_timeout_close, cuttlefish:conf_get(Prefix ++ ".send_timeout_close", Conf, undefined)}, + {recbuf, cuttlefish:conf_get(Prefix ++ ".recbuf", Conf, undefined)}, + {sndbuf, cuttlefish:conf_get(Prefix ++ ".sndbuf", Conf, undefined)}, + {buffer, cuttlefish:conf_get(Prefix ++ ".buffer", Conf, undefined)}, + {nodelay, cuttlefish:conf_get(Prefix ++ ".nodelay", Conf, true)}, + {reuseaddr, cuttlefish:conf_get(Prefix ++ ".reuseaddr", Conf, undefined)}]) + end, + SplitFun = fun(undefined) -> undefined; (S) -> string:tokens(S, ",") end, + MapPSKCiphers = fun(PSKCiphers) -> + lists:map( + fun("PSK-AES128-CBC-SHA") -> {psk, aes_128_cbc, sha}; + ("PSK-AES256-CBC-SHA") -> {psk, aes_256_cbc, sha}; + ("PSK-3DES-EDE-CBC-SHA") -> {psk, '3des_ede_cbc', sha}; + ("PSK-RC4-SHA") -> {psk, rc4_128, sha} + end, PSKCiphers) + end, + SslOpts = fun(Prefix) -> + Versions = case SplitFun(cuttlefish:conf_get(Prefix ++ ".tls_versions", Conf, undefined)) of + undefined -> undefined; + L -> [list_to_atom(V) || V <- L] + end, + TLSCiphers = cuttlefish:conf_get(Prefix++".ciphers", Conf, undefined), + PSKCiphers = cuttlefish:conf_get(Prefix++".psk_ciphers", Conf, undefined), + Ciphers = + case {TLSCiphers, PSKCiphers} of + {undefined, undefined} -> + cuttlefish:invalid(Prefix++".ciphers or "++Prefix++".psk_ciphers is absent"); + {TLSCiphers, undefined} -> + SplitFun(TLSCiphers); + {undefined, PSKCiphers} -> + MapPSKCiphers(SplitFun(PSKCiphers)); + {_TLSCiphers, _PSKCiphers} -> + cuttlefish:invalid(Prefix++".ciphers and "++Prefix++".psk_ciphers cannot be configured at the same time") + end, + UserLookupFun = + case PSKCiphers of + undefined -> undefined; + _ -> {fun emqx_psk:lookup/3, <<>>} + end, + Filter([{versions, Versions}, + {ciphers, Ciphers}, + {user_lookup_fun, UserLookupFun}, + %{handshake_timeout, cuttlefish:conf_get(Prefix ++ ".handshake_timeout", Conf, undefined)}, + {dhfile, cuttlefish:conf_get(Prefix ++ ".dhfile", Conf, undefined)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined)}, + {verify, cuttlefish:conf_get(Prefix ++ ".verify", Conf, undefined)}, + {fail_if_no_peer_cert, cuttlefish:conf_get(Prefix ++ ".fail_if_no_peer_cert", Conf, undefined)}, + {secure_renegotiate, cuttlefish:conf_get(Prefix ++ ".secure_renegotiate", Conf, undefined)}, + {reuse_sessions, cuttlefish:conf_get(Prefix ++ ".reuse_sessions", Conf, undefined)}, + {honor_cipher_order, cuttlefish:conf_get(Prefix ++ ".honor_cipher_order", Conf, undefined)}]) + end, + + UdpOpts = fun(Prefix) -> + Filter([{recbuf, cuttlefish:conf_get(Prefix ++ ".recbuf", Conf, undefined)}, + {sndbuf, cuttlefish:conf_get(Prefix ++ ".sndbuf", Conf, undefined)}, + {buffer, cuttlefish:conf_get(Prefix ++ ".buffer", Conf, undefined)}, + {reuseaddr, cuttlefish:conf_get(Prefix ++ ".reuseaddr", Conf, undefined)}]) + end, + + ParseListenOn = fun(ListenOn) -> + case string:tokens(ListenOn, "://") of + [Port] -> {tcp, list_to_integer(Port)}; + [T, Ip, Port] + when T =:= "tcp"; T =:= "ssl"; + T =:= "udp"; T =:= "dtls" -> + {Atom(T), {Ip, list_to_integer(Port)}} + end + end, + + Listeners = fun(Proto) -> + Prefix = string:join(["exproto","listener", Proto], "."), + Opts = HandlerOpts(Prefix) ++ ConnOpts(Prefix) ++ LisOpts(Prefix), + case cuttlefish:conf_get(Prefix, Conf, undefined) of + undefined -> []; + ListenOn0 -> + case ParseListenOn(ListenOn0) of + {tcp, ListenOn} -> + [{Proto, tcp, ListenOn, [{tcp_options, TcpOpts(Prefix)} | Opts]}]; + {ssl, ListenOn} -> + [{Proto, ssl, ListenOn, [{tcp_options, TcpOpts(Prefix)}, + {ssl_options, SslOpts(Prefix)} | Opts]}]; + {udp, ListenOn} -> + [{Proto, udp, ListenOn, [{udp_options, UdpOpts(Prefix)} | Opts]}]; + {dtls, ListenOn} -> + [{Proto, dtls, ListenOn, [{udp_options, UdpOpts(Prefix)}, + {dtls_options, SslOpts(Prefix)} | Opts]}]; + {_, _} -> + cuttlefish:invalid("Not supported listener type") + end + end + end, + lists:flatten([Listeners(Proto) || {[_, "listener", Proto], ListenOn} + <- cuttlefish_variable:filter_by_prefix("exproto.listener", Conf)]) +end}. diff --git a/apps/emqx_exproto/priv/protos/exproto.proto b/apps/emqx_exproto/priv/protos/exproto.proto new file mode 100644 index 000000000..4b567693c --- /dev/null +++ b/apps/emqx_exproto/priv/protos/exproto.proto @@ -0,0 +1,259 @@ +//------------------------------------------------------------------------------ +// Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//------------------------------------------------------------------------------ + +syntax = "proto3"; + +package emqx.exproto.v1; + +// The Broker side serivce. It provides a set of APIs to +// handle a protcol access +service ConnectionAdapter { + + // -- socket layer + + rpc Send(SendBytesRequest) returns (CodeResponse) {}; + + rpc Close(CloseSocketRequest) returns (CodeResponse) {}; + + // -- protocol layer + + rpc Authenticate(AuthenticateRequest) returns (CodeResponse) {}; + + rpc StartTimer(TimerRequest) returns (CodeResponse) {}; + + // -- pub/sub layer + + rpc Publish(PublishRequest) returns (CodeResponse) {}; + + rpc Subscribe(SubscribeRequest) returns (CodeResponse) {}; + + rpc Unsubscribe(UnsubscribeRequest) returns (CodeResponse) {}; +} + +service ConnectionHandler { + + // -- socket layer + + rpc OnSocketCreated(stream SocketCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSocketClosed(stream SocketClosedRequest) returns (EmptySuccess) {}; + + rpc OnReceivedBytes(stream ReceivedBytesRequest) returns (EmptySuccess) {}; + + // -- pub/sub layer + + rpc OnTimerTimeout(stream TimerTimeoutRequest) returns (EmptySuccess) {}; + + rpc OnReceivedMessages(stream ReceivedMessagesRequest) returns (EmptySuccess) {}; +} + +message EmptySuccess { } + +enum ResultCode { + + // Operation successfully + SUCCESS = 0; + + // Unknown Error + UNKNOWN = 1; + + // Connection process is not alive + CONN_PROCESS_NOT_ALIVE = 2; + + // Miss the required parameter + REQUIRED_PARAMS_MISSED = 3; + + // Params type or values incorrect + PARAMS_TYPE_ERROR = 4; + + // No permission or Pre-conditions not fulfilled + PERMISSION_DENY = 5; +} + +message CodeResponse { + + ResultCode code = 1; + + // The reason message if result is false + string message = 2; +} + +message SendBytesRequest { + + string conn = 1; + + bytes bytes = 2; +} + +message CloseSocketRequest { + + string conn = 1; +} + +message AuthenticateRequest { + + string conn = 1; + + ClientInfo clientinfo = 2; + + string password = 3; +} + +message TimerRequest { + + string conn = 1; + + TimerType type = 2; + + uint32 interval = 3; +} + +enum TimerType { + + KEEPALIVE = 0; +} + +message PublishRequest { + + string conn = 1; + + string topic = 2; + + uint32 qos = 3; + + bytes payload = 4; +} + +message SubscribeRequest { + + string conn = 1; + + string topic = 2; + + uint32 qos = 3; +} + +message UnsubscribeRequest { + + string conn = 1; + + string topic = 2; +} + +message SocketCreatedRequest { + + string conn = 1; + + ConnInfo conninfo = 2; +} + +message ReceivedBytesRequest { + + string conn = 1; + + bytes bytes = 2; +} + +message TimerTimeoutRequest { + + string conn = 1; + + TimerType type = 2; +} + +message SocketClosedRequest { + + string conn = 1; + + string reason = 2; +} + +message ReceivedMessagesRequest { + + string conn = 1; + + repeated Message messages = 2; +} + +//-------------------------------------------------------------------- +// Basic data types +//-------------------------------------------------------------------- + +message ConnInfo { + + SocketType socktype = 1; + + Address peername = 2; + + Address sockname = 3; + + CertificateInfo peercert = 4; +} + +enum SocketType { + + TCP = 0; + + SSL = 1; + + UDP = 2; + + DTLS = 3; +} + +message Address { + + string host = 1; + + uint32 port = 2; +} + +message CertificateInfo { + + string cn = 1; + + string dn = 2; +} + +message ClientInfo { + + string proto_name = 1; + + string proto_ver = 2; + + string clientid = 3; + + string username = 4; + + string mountpoint = 5; +} + +message Message { + + string node = 1; + + string id = 2; + + uint32 qos = 3; + + string from = 4; + + string topic = 5; + + bytes payload = 6; + + uint64 timestamp = 7; +} diff --git a/apps/emqx_exproto/rebar.config b/apps/emqx_exproto/rebar.config new file mode 100644 index 000000000..88831ce15 --- /dev/null +++ b/apps/emqx_exproto/rebar.config @@ -0,0 +1,50 @@ +%%-*- mode: erlang -*- +{edoc_opts, [{preprocess, true}]}. + +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + {parse_transform}]}. +{plugins, + [rebar3_proper, + {grpc_plugin, {git, "https://github.com/HJianBo/grpcbox_plugin", {tag, "v0.10.0"}}} +]}. + +{deps, + [{grpc, {git, "https://github.com/emqx/grpc", {tag, "0.6.0"}}} + ]}. + +{grpc, + [{type, all}, + {protos, ["priv/protos"]}, + {gpb_opts, [{module_name_prefix, "emqx_"}, + {module_name_suffix, "_pb"}]} + ]}. + +{provider_hooks, + [{pre, [{compile, {grpc, gen}}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. + +{xref_ignores, [emqx_exproto_pb]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. +{cover_excl_mods, [emqx_exproto_pb, + emqx_exproto_v_1_connection_adapter_client, + emqx_exproto_v_1_connection_adapter_bhvr, + emqx_exproto_v_1_connection_handler_client, + emqx_exproto_v_1_connection_handler_bhvr]}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.0"}}} + ]} + ]} +]}. diff --git a/apps/emqx_exproto/src/emqx_exproto.app.src b/apps/emqx_exproto/src/emqx_exproto.app.src new file mode 100644 index 000000000..52ce96a54 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto.app.src @@ -0,0 +1,12 @@ +{application, emqx_exproto, + [{description, "EMQ X Extension for Protocol"}, + {vsn, "4.3.0"}, %% strict semver + {modules, []}, + {registered, []}, + {mod, {emqx_exproto_app, []}}, + {applications, [kernel,stdlib,grpc]}, + {env,[]}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}]} + ]}. diff --git a/apps/emqx_exproto/src/emqx_exproto.erl b/apps/emqx_exproto/src/emqx_exproto.erl new file mode 100644 index 000000000..7d986ecdd --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto.erl @@ -0,0 +1,187 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto). + +-include("emqx_exproto.hrl"). + +-export([ start_listeners/0 + , stop_listeners/0 + , start_listener/1 + , start_listener/4 + , stop_listener/4 + , stop_listener/1 + ]). + +-export([ start_servers/0 + , stop_servers/0 + , start_server/1 + , stop_server/1 + ]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +-spec(start_listeners() -> ok). +start_listeners() -> + Listeners = application:get_env(?APP, listeners, []), + NListeners = [start_connection_handler_instance(Listener) + || Listener <- Listeners], + lists:foreach(fun start_listener/1, NListeners). + +-spec(stop_listeners() -> ok). +stop_listeners() -> + Listeners = application:get_env(?APP, listeners, []), + lists:foreach(fun stop_connection_handler_instance/1, Listeners), + lists:foreach(fun stop_listener/1, Listeners). + +-spec(start_servers() -> ok). +start_servers() -> + lists:foreach(fun start_server/1, application:get_env(?APP, servers, [])). + +-spec(stop_servers() -> ok). +stop_servers() -> + lists:foreach(fun stop_server/1, application:get_env(?APP, servers, [])). + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +start_connection_handler_instance({_Proto, _LisType, _ListenOn, Opts}) -> + Name = name(_Proto, _LisType), + {value, {_, HandlerOpts}, LisOpts} = lists:keytake(handler, 1, Opts), + {SvrAddr, ChannelOptions} = handler_opts(HandlerOpts), + case emqx_exproto_sup:start_grpc_client_channel(Name, SvrAddr, ChannelOptions) of + {ok, _ClientChannelPid} -> + {_Proto, _LisType, _ListenOn, [{handler, Name} | LisOpts]}; + {error, Reason} -> + io:format(standard_error, "Failed to start ~s's connection handler - ~0p~n!", + [Name, Reason]), + error(Reason) + end. + +stop_connection_handler_instance({_Proto, _LisType, _ListenOn, _Opts}) -> + Name = name(_Proto, _LisType), + _ = emqx_exproto_sup:stop_grpc_client_channel(Name), + ok. + +start_server({Name, Port, SSLOptions}) -> + case emqx_exproto_sup:start_grpc_server(Name, Port, SSLOptions) of + {ok, _} -> + io:format("Start ~s gRPC server on ~w successfully.~n", + [Name, Port]); + {error, Reason} -> + io:format(standard_error, "Failed to start ~s gRPC server on ~w - ~0p~n!", + [Name, Port, Reason]), + error({failed_start_server, Reason}) + end. + +stop_server({Name, Port, _SSLOptions}) -> + ok = emqx_exproto_sup:stop_grpc_server(Name), + io:format("Stop ~s gRPC server on ~w successfully.~n", [Name, Port]). + +start_listener({Proto, LisType, ListenOn, Opts}) -> + Name = name(Proto, LisType), + case start_listener(LisType, Name, ListenOn, Opts) of + {ok, _} -> + io:format("Start ~s listener on ~s successfully.~n", + [Name, format(ListenOn)]); + {error, Reason} -> + io:format(standard_error, "Failed to start ~s listener on ~s - ~0p~n!", + [Name, format(ListenOn), Reason]), + error(Reason) + end. + +%% @private +start_listener(LisType, Name, ListenOn, LisOpts) + when LisType =:= tcp; + LisType =:= ssl -> + SockOpts = esockd:parse_opt(LisOpts), + esockd:open(Name, ListenOn, merge_tcp_default(SockOpts), + {emqx_exproto_conn, start_link, [LisOpts-- SockOpts]}); + +start_listener(udp, Name, ListenOn, LisOpts) -> + SockOpts = esockd:parse_opt(LisOpts), + esockd:open_udp(Name, ListenOn, merge_udp_default(SockOpts), + {emqx_exproto_conn, start_link, [LisOpts-- SockOpts]}); + +start_listener(dtls, Name, ListenOn, LisOpts) -> + SockOpts = esockd:parse_opt(LisOpts), + esockd:open_dtls(Name, ListenOn, merge_udp_default(SockOpts), + {emqx_exproto_conn, start_link, [LisOpts-- SockOpts]}). + +stop_listener({Proto, LisType, ListenOn, Opts}) -> + Name = name(Proto, LisType), + StopRet = stop_listener(LisType, Name, ListenOn, Opts), + case StopRet of + ok -> + io:format("Stop ~s listener on ~s successfully.~n", + [Name, format(ListenOn)]); + {error, Reason} -> + io:format(standard_error, "Failed to stop ~s listener on ~s - ~p~n.", + [Name, format(ListenOn), Reason]) + end, + StopRet. + +%% @private +stop_listener(_LisType, Name, ListenOn, _Opts) -> + esockd:close(Name, ListenOn). + +%% @private +name(Proto, LisType) -> + list_to_atom(lists:flatten(io_lib:format("~s:~s", [Proto, LisType]))). + +%% @private +format(Port) when is_integer(Port) -> + io_lib:format("0.0.0.0:~w", [Port]); +format({Addr, Port}) when is_list(Addr) -> + io_lib:format("~s:~w", [Addr, Port]); +format({Addr, Port}) when is_tuple(Addr) -> + io_lib:format("~s:~w", [inet:ntoa(Addr), Port]). + +%% @private +merge_tcp_default(Opts) -> + case lists:keytake(tcp_options, 1, Opts) of + {value, {tcp_options, TcpOpts}, Opts1} -> + [{tcp_options, emqx_misc:merge_opts(?TCP_SOCKOPTS, TcpOpts)} | Opts1]; + false -> + [{tcp_options, ?TCP_SOCKOPTS} | Opts] + end. + +merge_udp_default(Opts) -> + case lists:keytake(udp_options, 1, Opts) of + {value, {udp_options, TcpOpts}, Opts1} -> + [{udp_options, emqx_misc:merge_opts(?UDP_SOCKOPTS, TcpOpts)} | Opts1]; + false -> + [{udp_options, ?UDP_SOCKOPTS} | Opts] + end. + +%% @private +handler_opts(Opts) -> + Scheme = proplists:get_value(scheme, Opts), + Host = proplists:get_value(host, Opts), + Port = proplists:get_value(port, Opts), + SvrAddr = lists:flatten(io_lib:format("~s://~s:~w", [Scheme, Host, Port])), + ClientOpts = case Scheme of + https -> + SslOpts = lists:keydelete(ssl, 1, proplists:get_value(ssl_options, Opts, [])), + #{gun_opts => + #{transport => ssl, + transport_opts => SslOpts}}; + _ -> #{} + end, + {SvrAddr, ClientOpts}. diff --git a/apps/emqx_exproto/src/emqx_exproto_app.erl b/apps/emqx_exproto/src/emqx_exproto_app.erl new file mode 100644 index 000000000..73e8a65bc --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_app.erl @@ -0,0 +1,37 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_app). + +-behaviour(application). + +-emqx_plugin(extension). + +-export([start/2, prep_stop/1, stop/1]). + +start(_StartType, _StartArgs) -> + {ok, Sup} = emqx_exproto_sup:start_link(), + emqx_exproto:start_servers(), + emqx_exproto:start_listeners(), + {ok, Sup}. + +prep_stop(State) -> + emqx_exproto:stop_servers(), + emqx_exproto:stop_listeners(), + State. + +stop(_State) -> + ok. diff --git a/apps/emqx_exproto/src/emqx_exproto_channel.erl b/apps/emqx_exproto/src/emqx_exproto_channel.erl new file mode 100644 index 000000000..04ed8b414 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_channel.erl @@ -0,0 +1,600 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_channel). + +-include("emqx_exproto.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/types.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto Channel]"). + +-export([ info/1 + , info/2 + , stats/1 + ]). + +-export([ init/2 + , handle_in/2 + , handle_deliver/2 + , handle_timeout/3 + , handle_call/2 + , handle_cast/2 + , handle_info/2 + , terminate/2 + ]). + +-export_type([channel/0]). + +-record(channel, { + %% gRPC channel options + gcli :: map(), + %% Conn info + conninfo :: emqx_types:conninfo(), + %% Client info from `register` function + clientinfo :: maybe(map()), + %% Connection state + conn_state :: conn_state(), + %% Subscription + subscriptions = #{}, + %% Request queue + rqueue = queue:new(), + %% Inflight function name + inflight = undefined, + %% Keepalive + keepalive :: maybe(emqx_keepalive:keepalive()), + %% Timers + timers :: #{atom() => disabled | maybe(reference())}, + %% Closed reason + closed_reason = undefined + }). + +-opaque(channel() :: #channel{}). + +-type(conn_state() :: idle | connecting | connected | disconnected). + +-type(reply() :: {outgoing, binary()} + | {outgoing, [binary()]} + | {close, Reason :: atom()}). + +-type(replies() :: emqx_types:packet() | reply() | [reply()]). + +-define(TIMER_TABLE, #{ + alive_timer => keepalive, + force_timer => force_close + }). + +-define(INFO_KEYS, [conninfo, conn_state, clientinfo, session, will_msg]). + +-define(SESSION_STATS_KEYS, + [subscriptions_cnt, + subscriptions_max, + inflight_cnt, + inflight_max, + mqueue_len, + mqueue_max, + mqueue_dropped, + next_pkt_id, + awaiting_rel_cnt, + awaiting_rel_max + ]). + +%%-------------------------------------------------------------------- +%% Info, Attrs and Caps +%%-------------------------------------------------------------------- + +%% @doc Get infos of the channel. +-spec(info(channel()) -> emqx_types:infos()). +info(Channel) -> + maps:from_list(info(?INFO_KEYS, Channel)). + +-spec(info(list(atom())|atom(), channel()) -> term()). +info(Keys, Channel) when is_list(Keys) -> + [{Key, info(Key, Channel)} || Key <- Keys]; +info(conninfo, #channel{conninfo = ConnInfo}) -> + ConnInfo; +info(clientid, #channel{clientinfo = ClientInfo}) -> + maps:get(clientid, ClientInfo, undefined); +info(clientinfo, #channel{clientinfo = ClientInfo}) -> + ClientInfo; +info(session, #channel{subscriptions = Subs, + conninfo = ConnInfo}) -> + #{subscriptions => Subs, + upgrade_qos => false, + retry_interval => 0, + await_rel_timeout => 0, + created_at => maps:get(connected_at, ConnInfo)}; +info(conn_state, #channel{conn_state = ConnState}) -> + ConnState; +info(will_msg, _) -> + undefined. + +-spec(stats(channel()) -> emqx_types:stats()). +stats(#channel{subscriptions = Subs}) -> + [{subscriptions_cnt, maps:size(Subs)}, + {subscriptions_max, 0}, + {inflight_cnt, 0}, + {inflight_max, 0}, + {mqueue_len, 0}, + {mqueue_max, 0}, + {mqueue_dropped, 0}, + {next_pkt_id, 0}, + {awaiting_rel_cnt, 0}, + {awaiting_rel_max, 0}]. + +%%-------------------------------------------------------------------- +%% Init the channel +%%-------------------------------------------------------------------- + +-spec(init(emqx_exproto_types:conninfo(), proplists:proplist()) -> channel()). +init(ConnInfo = #{socktype := Socktype, + peername := Peername, + sockname := Sockname, + peercert := Peercert}, Options) -> + GRpcChann = proplists:get_value(handler, Options), + NConnInfo = default_conninfo(ConnInfo), + ClientInfo = default_clientinfo(ConnInfo), + Channel = #channel{gcli = #{channel => GRpcChann}, + conninfo = NConnInfo, + clientinfo = ClientInfo, + conn_state = connecting, + timers = #{} + }, + + Req = #{conninfo => + peercert(Peercert, + #{socktype => socktype(Socktype), + peername => address(Peername), + sockname => address(Sockname)})}, + try_dispatch(on_socket_created, wrap(Req), Channel). + +%% @private +peercert(nossl, ConnInfo) -> + ConnInfo; +peercert(Peercert, ConnInfo) -> + ConnInfo#{peercert => + #{cn => esockd_peercert:common_name(Peercert), + dn => esockd_peercert:subject(Peercert)}}. + +%% @private +socktype(tcp) -> 'TCP'; +socktype(ssl) -> 'SSL'; +socktype(udp) -> 'UDP'; +socktype(dtls) -> 'DTLS'. + +%% @private +address({Host, Port}) -> + #{host => inet:ntoa(Host), port => Port}. + +%%-------------------------------------------------------------------- +%% Handle incoming packet +%%-------------------------------------------------------------------- + +-spec(handle_in(binary(), channel()) + -> {ok, channel()} + | {shutdown, Reason :: term(), channel()}). +handle_in(Data, Channel) -> + Req = #{bytes => Data}, + {ok, try_dispatch(on_received_bytes, wrap(Req), Channel)}. + +-spec(handle_deliver(list(emqx_types:deliver()), channel()) + -> {ok, channel()} + | {shutdown, Reason :: term(), channel()}). +handle_deliver(Delivers, Channel = #channel{clientinfo = ClientInfo}) -> + %% XXX: ?? Nack delivers from shared subscriptions + Mountpoint = maps:get(mountpoint, ClientInfo), + NodeStr = atom_to_binary(node(), utf8), + Msgs = lists:map(fun({_, _, Msg}) -> + ok = emqx_metrics:inc('messages.delivered'), + Msg1 = emqx_hooks:run_fold('message.delivered', + [ClientInfo], Msg), + NMsg = emqx_mountpoint:unmount(Mountpoint, Msg1), + #{node => NodeStr, + id => hexstr(emqx_message:id(NMsg)), + qos => emqx_message:qos(NMsg), + from => fmt_from(emqx_message:from(NMsg)), + topic => emqx_message:topic(NMsg), + payload => emqx_message:payload(NMsg), + timestamp => emqx_message:timestamp(NMsg) + } + end, Delivers), + Req = #{messages => Msgs}, + {ok, try_dispatch(on_received_messages, wrap(Req), Channel)}. + +-spec(handle_timeout(reference(), Msg :: term(), channel()) + -> {ok, channel()} + | {shutdown, Reason :: term(), channel()}). +handle_timeout(_TRef, {keepalive, _StatVal}, + Channel = #channel{keepalive = undefined}) -> + {ok, Channel}; +handle_timeout(_TRef, {keepalive, StatVal}, + Channel = #channel{keepalive = Keepalive}) -> + case emqx_keepalive:check(StatVal, Keepalive) of + {ok, NKeepalive} -> + NChannel = Channel#channel{keepalive = NKeepalive}, + {ok, reset_timer(alive_timer, NChannel)}; + {error, timeout} -> + Req = #{type => 'KEEPALIVE'}, + {ok, try_dispatch(on_timer_timeout, wrap(Req), Channel)} + end; + +handle_timeout(_TRef, force_close, Channel = #channel{closed_reason = Reason}) -> + {shutdown, {error, {force_close, Reason}}, Channel}; + +handle_timeout(_TRef, Msg, Channel) -> + ?WARN("Unexpected timeout: ~p", [Msg]), + {ok, Channel}. + +-spec(handle_call(any(), channel()) + -> {reply, Reply :: term(), channel()} + | {reply, Reply :: term(), replies(), channel()} + | {shutdown, Reason :: term(), Reply :: term(), channel()}). + +handle_call({send, Data}, Channel) -> + {reply, ok, [{outgoing, Data}], Channel}; + +handle_call(close, Channel = #channel{conn_state = connected}) -> + {reply, ok, [{event, disconnected}, {close, normal}], Channel}; +handle_call(close, Channel) -> + {reply, ok, [{close, normal}], Channel}; + +handle_call({auth, ClientInfo, _Password}, Channel = #channel{conn_state = connected}) -> + ?LOG(warning, "Duplicated authorized command, dropped ~p", [ClientInfo]), + {reply, {error, ?RESP_PERMISSION_DENY, <<"Duplicated authenticate command">>}, Channel}; +handle_call({auth, ClientInfo0, Password}, + Channel = #channel{conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + ClientInfo1 = enrich_clientinfo(ClientInfo0, ClientInfo), + NConnInfo = enrich_conninfo(ClientInfo1, ConnInfo), + + Channel1 = Channel#channel{conninfo = NConnInfo, + clientinfo = ClientInfo1}, + + #{clientid := ClientId, username := Username} = ClientInfo1, + + case emqx_access_control:authenticate(ClientInfo1#{password => Password}) of + {ok, AuthResult} -> + emqx_logger:set_metadata_clientid(ClientId), + is_anonymous(AuthResult) andalso + emqx_metrics:inc('client.auth.anonymous'), + NClientInfo = maps:merge(ClientInfo1, AuthResult), + NChannel = Channel1#channel{clientinfo = NClientInfo}, + case emqx_cm:open_session(true, NClientInfo, NConnInfo) of + {ok, _Session} -> + ?LOG(debug, "Client ~s (Username: '~s') authorized successfully!", + [ClientId, Username]), + {reply, ok, [{event, connected}], ensure_connected(NChannel)}; + {error, Reason} -> + ?LOG(warning, "Client ~s (Username: '~s') open session failed for ~0p", + [ClientId, Username, Reason]), + {reply, {error, ?RESP_PERMISSION_DENY, Reason}, Channel} + end; + {error, Reason} -> + ?LOG(warning, "Client ~s (Username: '~s') login failed for ~0p", + [ClientId, Username, Reason]), + {reply, {error, ?RESP_PERMISSION_DENY, Reason}, Channel} + end; + +handle_call({start_timer, keepalive, Interval}, + Channel = #channel{ + conninfo = ConnInfo, + clientinfo = ClientInfo + }) -> + NConnInfo = ConnInfo#{keepalive => Interval}, + NClientInfo = ClientInfo#{keepalive => Interval}, + NChannel = Channel#channel{conninfo = NConnInfo, clientinfo = NClientInfo}, + {reply, ok, ensure_keepalive(NChannel)}; + +handle_call({subscribe, TopicFilter, Qos}, + Channel = #channel{ + conn_state = connected, + clientinfo = ClientInfo}) -> + case is_acl_enabled(ClientInfo) andalso + emqx_access_control:check_acl(ClientInfo, subscribe, TopicFilter) of + deny -> + {reply, {error, ?RESP_PERMISSION_DENY, <<"ACL deny">>}, Channel}; + _ -> + {ok, NChannel} = do_subscribe([{TopicFilter, #{qos => Qos}}], Channel), + {reply, ok, NChannel} + end; + +handle_call({unsubscribe, TopicFilter}, + Channel = #channel{conn_state = connected}) -> + {ok, NChannel} = do_unsubscribe([{TopicFilter, #{}}], Channel), + {reply, ok, NChannel}; + +handle_call({publish, Topic, Qos, Payload}, + Channel = #channel{ + conn_state = connected, + clientinfo = ClientInfo + = #{clientid := From, + mountpoint := Mountpoint}}) -> + case is_acl_enabled(ClientInfo) andalso + emqx_access_control:check_acl(ClientInfo, publish, Topic) of + deny -> + {reply, {error, ?RESP_PERMISSION_DENY, <<"ACL deny">>}, Channel}; + _ -> + Msg = emqx_message:make(From, Qos, Topic, Payload), + NMsg = emqx_mountpoint:mount(Mountpoint, Msg), + _ = emqx:publish(NMsg), + {reply, ok, Channel} + end; + +handle_call(kick, Channel) -> + {shutdown, kicked, ok, Channel}; + +handle_call(Req, Channel) -> + ?LOG(warning, "Unexpected call: ~p", [Req]), + {reply, {error, unexpected_call}, Channel}. + +-spec(handle_cast(any(), channel()) + -> {ok, channel()} + | {ok, replies(), channel()} + | {shutdown, Reason :: term(), channel()}). +handle_cast(Req, Channel) -> + ?WARN("Unexpected call: ~p", [Req]), + {ok, Channel}. + +-spec(handle_info(any(), channel()) + -> {ok, channel()} + | {shutdown, Reason :: term(), channel()}). +handle_info({subscribe, TopicFilters}, Channel) -> + do_subscribe(TopicFilters, Channel); + +handle_info({unsubscribe, TopicFilters}, Channel) -> + do_unsubscribe(TopicFilters, Channel); + +handle_info({sock_closed, Reason}, + Channel = #channel{rqueue = Queue, inflight = Inflight}) -> + case queue:len(Queue) =:= 0 + andalso Inflight =:= undefined of + true -> + Channel1 = ensure_disconnected({sock_closed, Reason}, Channel), + {shutdown, {sock_closed, Reason}, Channel1}; + _ -> + %% delayed close process for flushing all callback funcs to gRPC server + Channel1 = Channel#channel{closed_reason = {sock_closed, Reason}}, + Channel2 = ensure_timer(force_timer, Channel1), + {ok, ensure_disconnected({sock_closed, Reason}, Channel2)} + end; + +handle_info({hreply, on_socket_created, ok}, Channel) -> + dispatch_or_close_process(Channel#channel{inflight = undefined}); +handle_info({hreply, FunName, ok}, Channel) + when FunName == on_socket_closed; + FunName == on_received_bytes; + FunName == on_received_messages; + FunName == on_timer_timeout -> + dispatch_or_close_process(Channel#channel{inflight = undefined}); +handle_info({hreply, FunName, {error, Reason}}, Channel) -> + {shutdown, {error, {FunName, Reason}}, Channel}; + +handle_info(Info, Channel) -> + ?LOG(warning, "Unexpected info: ~p", [Info]), + {ok, Channel}. + +-spec(terminate(any(), channel()) -> channel()). +terminate(Reason, Channel) -> + Req = #{reason => stringfy(Reason)}, + try_dispatch(on_socket_closed, wrap(Req), Channel). + +is_anonymous(#{anonymous := true}) -> true; +is_anonymous(_AuthResult) -> false. + +%%-------------------------------------------------------------------- +%% Sub/UnSub +%%-------------------------------------------------------------------- + +do_subscribe(TopicFilters, Channel) -> + NChannel = lists:foldl( + fun({TopicFilter, SubOpts}, ChannelAcc) -> + do_subscribe(TopicFilter, SubOpts, ChannelAcc) + end, Channel, parse_topic_filters(TopicFilters)), + {ok, NChannel}. + +%% @private +do_subscribe(TopicFilter, SubOpts, Channel = + #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}, + subscriptions = Subs}) -> + %% Mountpoint first + NTopicFilter = emqx_mountpoint:mount(Mountpoint, TopicFilter), + NSubOpts = maps:merge(?DEFAULT_SUBOPTS, SubOpts), + SubId = maps:get(clientid, ClientInfo, undefined), + IsNew = not maps:is_key(NTopicFilter, Subs), + case IsNew of + true -> + ok = emqx:subscribe(NTopicFilter, SubId, NSubOpts), + ok = emqx_hooks:run('session.subscribed', + [ClientInfo, NTopicFilter, NSubOpts#{is_new => IsNew}]), + Channel#channel{subscriptions = Subs#{NTopicFilter => NSubOpts}}; + _ -> + %% Update subopts + ok = emqx:subscribe(NTopicFilter, SubId, NSubOpts), + Channel#channel{subscriptions = Subs#{NTopicFilter => NSubOpts}} + end. + +do_unsubscribe(TopicFilters, Channel) -> + NChannel = lists:foldl( + fun({TopicFilter, SubOpts}, ChannelAcc) -> + do_unsubscribe(TopicFilter, SubOpts, ChannelAcc) + end, Channel, parse_topic_filters(TopicFilters)), + {ok, NChannel}. + +%% @private +do_unsubscribe(TopicFilter, UnSubOpts, Channel = + #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}, + subscriptions = Subs}) -> + NTopicFilter = emqx_mountpoint:mount(Mountpoint, TopicFilter), + case maps:find(NTopicFilter, Subs) of + {ok, SubOpts} -> + ok = emqx:unsubscribe(NTopicFilter), + ok = emqx_hooks:run('session.unsubscribed', + [ClientInfo, TopicFilter, maps:merge(SubOpts, UnSubOpts)]), + Channel#channel{subscriptions = maps:remove(NTopicFilter, Subs)}; + _ -> + Channel + end. + +%% @private +parse_topic_filters(TopicFilters) -> + lists:map(fun emqx_topic:parse/1, TopicFilters). + +-compile({inline, [is_acl_enabled/1]}). +is_acl_enabled(#{zone := Zone, is_superuser := IsSuperuser}) -> + (not IsSuperuser) andalso emqx_zone:enable_acl(Zone). + +%%-------------------------------------------------------------------- +%% Ensure & Hooks +%%-------------------------------------------------------------------- + +ensure_connected(Channel = #channel{conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + NConnInfo = ConnInfo#{connected_at => erlang:system_time(millisecond)}, + ok = run_hooks('client.connected', [ClientInfo, NConnInfo]), + Channel#channel{conninfo = NConnInfo, + conn_state = connected + }. + +ensure_disconnected(Reason, Channel = #channel{ + conn_state = connected, + conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, + ok = run_hooks('client.disconnected', [ClientInfo, Reason, NConnInfo]), + Channel#channel{conninfo = NConnInfo, conn_state = disconnected}; + +ensure_disconnected(_Reason, Channel = #channel{conninfo = ConnInfo}) -> + NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, + Channel#channel{conninfo = NConnInfo, conn_state = disconnected}. + +run_hooks(Name, Args) -> + ok = emqx_metrics:inc(Name), emqx_hooks:run(Name, Args). + +%%-------------------------------------------------------------------- +%% Enrich Keepalive + +ensure_keepalive(Channel = #channel{clientinfo = ClientInfo}) -> + ensure_keepalive_timer(maps:get(keepalive, ClientInfo, 0), Channel). + +ensure_keepalive_timer(Interval, Channel) when Interval =< 0 -> + Channel; +ensure_keepalive_timer(Interval, Channel) -> + Keepalive = emqx_keepalive:init(timer:seconds(Interval)), + ensure_timer(alive_timer, Channel#channel{keepalive = Keepalive}). + +ensure_timer(Name, Channel = #channel{timers = Timers}) -> + TRef = maps:get(Name, Timers, undefined), + Time = interval(Name, Channel), + case TRef == undefined andalso Time > 0 of + true -> ensure_timer(Name, Time, Channel); + false -> Channel %% Timer disabled or exists + end. + +ensure_timer(Name, Time, Channel = #channel{timers = Timers}) -> + Msg = maps:get(Name, ?TIMER_TABLE), + TRef = emqx_misc:start_timer(Time, Msg), + Channel#channel{timers = Timers#{Name => TRef}}. + +reset_timer(Name, Channel) -> + ensure_timer(Name, clean_timer(Name, Channel)). + +clean_timer(Name, Channel = #channel{timers = Timers}) -> + Channel#channel{timers = maps:remove(Name, Timers)}. + +interval(force_timer, _) -> + 15000; +interval(alive_timer, #channel{keepalive = Keepalive}) -> + emqx_keepalive:info(interval, Keepalive). + +%%-------------------------------------------------------------------- +%% Dispatch +%%-------------------------------------------------------------------- + +wrap(Req) -> + Req#{conn => base64:encode(term_to_binary(self()))}. + +dispatch_or_close_process(Channel = #channel{ + rqueue = Queue, + inflight = undefined, + gcli = GClient}) -> + case queue:out(Queue) of + {empty, _} -> + case Channel#channel.conn_state of + disconnected -> + {shutdown, Channel#channel.closed_reason, Channel}; + _ -> + {ok, Channel} + end; + {{value, {FunName, Req}}, NQueue} -> + emqx_exproto_gcli:async_call(FunName, Req, GClient), + {ok, Channel#channel{inflight = FunName, rqueue = NQueue}} + end. + +try_dispatch(FunName, Req, Channel = #channel{inflight = undefined, gcli = GClient}) -> + emqx_exproto_gcli:async_call(FunName, Req, GClient), + Channel#channel{inflight = FunName}; +try_dispatch(FunName, Req, Channel = #channel{rqueue = Queue}) -> + Channel#channel{rqueue = queue:in({FunName, Req}, Queue)}. + +%%-------------------------------------------------------------------- +%% Format +%%-------------------------------------------------------------------- + +enrich_conninfo(InClientInfo, ConnInfo) -> + Ks = [proto_name, proto_ver, clientid, username], + maps:merge(ConnInfo, maps:with(Ks, InClientInfo)). + +enrich_clientinfo(InClientInfo = #{proto_name := ProtoName}, ClientInfo) -> + Ks = [clientid, username, mountpoint], + NClientInfo = maps:merge(ClientInfo, maps:with(Ks, InClientInfo)), + NClientInfo#{protocol => ProtoName}. + +default_conninfo(ConnInfo) -> + ConnInfo#{proto_name => undefined, + proto_ver => undefined, + clean_start => true, + clientid => undefined, + username => undefined, + conn_props => [], + connected => true, + connected_at => erlang:system_time(millisecond), + keepalive => undefined, + receive_maximum => 0, + expiry_interval => 0}. + +default_clientinfo(#{peername := {PeerHost, _}, + sockname := {_, SockPort}}) -> + #{zone => external, + protocol => undefined, + peerhost => PeerHost, + sockport => SockPort, + clientid => undefined, + username => undefined, + is_bridge => false, + is_superuser => false, + mountpoint => undefined}. + +stringfy(Reason) -> + unicode:characters_to_binary((io_lib:format("~0p", [Reason]))). + +hexstr(Bin) -> + [io_lib:format("~2.16.0B",[X]) || <> <= Bin]. + +fmt_from(undefined) -> <<>>; +fmt_from(Bin) when is_binary(Bin) -> Bin; +fmt_from(T) -> stringfy(T). diff --git a/apps/emqx_exproto/src/emqx_exproto_conn.erl b/apps/emqx_exproto/src/emqx_exproto_conn.erl new file mode 100644 index 000000000..72c18410a --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_conn.erl @@ -0,0 +1,686 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% TCP/TLS/UDP/DTLS Connection +-module(emqx_exproto_conn). + +-include_lib("emqx/include/types.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto Conn]"). + +%% API +-export([ start_link/3 + , stop/1 + ]). + +-export([ info/1 + , stats/1 + ]). + +-export([ call/2 + , cast/2 + ]). + +%% Callback +-export([init/4]). + +%% Sys callbacks +-export([ system_continue/3 + , system_terminate/4 + , system_code_change/4 + , system_get_state/1 + ]). + +%% Internal callback +-export([wakeup_from_hib/2]). + +-import(emqx_misc, [start_timer/2]). + +-record(state, { + %% TCP/SSL/UDP/DTLS Wrapped Socket + socket :: {esockd_transport, esockd:socket()} | {udp, _, _}, + %% Peername of the connection + peername :: emqx_types:peername(), + %% Sockname of the connection + sockname :: emqx_types:peername(), + %% Sock State + sockstate :: emqx_types:sockstate(), + %% The {active, N} option + active_n :: pos_integer(), + %% BACKW: e4.2.0-e4.2.1 + %% We should remove it + sendfun :: function() | undefined, + %% Limiter + limiter :: maybe(emqx_limiter:limiter()), + %% Limit Timer + limit_timer :: maybe(reference()), + %% Channel State + channel :: emqx_exproto_channel:channel(), + %% GC State + gc_state :: maybe(emqx_gc:gc_state()), + %% Stats Timer + stats_timer :: disabled | maybe(reference()), + %% Idle Timeout + idle_timeout :: integer(), + %% Idle Timer + idle_timer :: maybe(reference()) + }). + +-type(state() :: #state{}). + +-define(ACTIVE_N, 100). +-define(INFO_KEYS, [socktype, peername, sockname, sockstate, active_n]). +-define(CONN_STATS, [recv_pkt, recv_msg, send_pkt, send_msg]). +-define(SOCK_STATS, [recv_oct, recv_cnt, send_oct, send_cnt, send_pend]). + +-define(ENABLED(X), (X =/= undefined)). + +-dialyzer({nowarn_function, + [ system_terminate/4 + , handle_call/3 + , handle_msg/2 + , shutdown/3 + , stop/3 + ]}). + +%% udp +start_link(Socket = {udp, _SockPid, _Sock}, Peername, Options) -> + Args = [self(), Socket, Peername, Options], + {ok, proc_lib:spawn_link(?MODULE, init, Args)}; + +%% tcp/ssl/dtls +start_link(esockd_transport, Sock, Options) -> + Socket = {esockd_transport, Sock}, + case esockd_transport:peername(Sock) of + {ok, Peername} -> + Args = [self(), Socket, Peername, Options], + {ok, proc_lib:spawn_link(?MODULE, init, Args)}; + R = {error, _} -> R + end. + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +%% @doc Get infos of the connection/channel. +-spec(info(pid()|state()) -> emqx_types:infos()). +info(CPid) when is_pid(CPid) -> + call(CPid, info); +info(State = #state{channel = Channel}) -> + ChanInfo = emqx_exproto_channel:info(Channel), + SockInfo = maps:from_list( + info(?INFO_KEYS, State)), + ChanInfo#{sockinfo => SockInfo}. + +info(Keys, State) when is_list(Keys) -> + [{Key, info(Key, State)} || Key <- Keys]; +info(socktype, #state{socket = Socket}) -> + esockd_type(Socket); +info(peername, #state{peername = Peername}) -> + Peername; +info(sockname, #state{sockname = Sockname}) -> + Sockname; +info(sockstate, #state{sockstate = SockSt}) -> + SockSt; +info(active_n, #state{active_n = ActiveN}) -> + ActiveN. + +-spec(stats(pid()|state()) -> emqx_types:stats()). +stats(CPid) when is_pid(CPid) -> + call(CPid, stats); +stats(#state{socket = Socket, + channel = Channel}) -> + SockStats = case esockd_getstat(Socket, ?SOCK_STATS) of + {ok, Ss} -> Ss; + {error, _} -> [] + end, + ConnStats = emqx_pd:get_counters(?CONN_STATS), + ChanStats = emqx_exproto_channel:stats(Channel), + ProcStats = emqx_misc:proc_stats(), + lists:append([SockStats, ConnStats, ChanStats, ProcStats]). + +call(Pid, Req) -> + gen_server:call(Pid, Req, infinity). + +cast(Pid, Req) -> + gen_server:cast(Pid, Req). + +stop(Pid) -> + gen_server:stop(Pid). + +%%-------------------------------------------------------------------- +%% Wrapped funcs +%%-------------------------------------------------------------------- + +esockd_wait(Socket = {udp, _SockPid, _Sock}) -> + {ok, Socket}; +esockd_wait({esockd_transport, Sock}) -> + case esockd_transport:wait(Sock) of + {ok, NSock} -> {ok, {esockd_transport, NSock}}; + R = {error, _} -> R + end. + +esockd_close({udp, _SockPid, _Sock}) -> + %% nothing to do for udp socket + %%gen_udp:close(Sock); + ok; +esockd_close({esockd_transport, Sock}) -> + esockd_transport:fast_close(Sock). + +esockd_ensure_ok_or_exit(peercert, {udp, _SockPid, _Sock}) -> + nossl; +esockd_ensure_ok_or_exit(Fun, {udp, _SockPid, Sock}) -> + esockd_transport:ensure_ok_or_exit(Fun, [Sock]); +esockd_ensure_ok_or_exit(Fun, {esockd_transport, Socket}) -> + esockd_transport:ensure_ok_or_exit(Fun, [Socket]). + +esockd_type({udp, _, _}) -> + udp; +esockd_type({esockd_transport, Socket}) -> + esockd_transport:type(Socket). + +esockd_setopts({udp, _, _}, _) -> + ok; +esockd_setopts({esockd_transport, Socket}, Opts) -> + %% FIXME: DTLS works?? + esockd_transport:setopts(Socket, Opts). + +esockd_getstat({udp, _SockPid, Sock}, Stats) -> + inet:getstat(Sock, Stats); +esockd_getstat({esockd_transport, Sock}, Stats) -> + esockd_transport:getstat(Sock, Stats). + +send(Data, #state{socket = {udp, _SockPid, Sock}, peername = {Ip, Port}}) -> + gen_udp:send(Sock, Ip, Port, Data); +send(Data, #state{socket = {esockd_transport, Sock}}) -> + esockd_transport:async_send(Sock, Data). + +%%-------------------------------------------------------------------- +%% callbacks +%%-------------------------------------------------------------------- + +-define(DEFAULT_GC_OPTS, #{count => 1000, bytes => 1024*1024}). +-define(DEFAULT_IDLE_TIMEOUT, 30000). +-define(DEFAULT_OOM_POLICY, #{max_heap_size => 4194304,message_queue_len => 32000}). + +init(Parent, WrappedSock, Peername, Options) -> + case esockd_wait(WrappedSock) of + {ok, NWrappedSock} -> + run_loop(Parent, init_state(NWrappedSock, Peername, Options)); + {error, Reason} -> + ok = esockd_close(WrappedSock), + exit_on_sock_error(Reason) + end. + +init_state(WrappedSock, Peername, Options) -> + {ok, Sockname} = esockd_ensure_ok_or_exit(sockname, WrappedSock), + Peercert = esockd_ensure_ok_or_exit(peercert, WrappedSock), + ConnInfo = #{socktype => esockd_type(WrappedSock), + peername => Peername, + sockname => Sockname, + peercert => Peercert, + conn_mod => ?MODULE + }, + + ActiveN = proplists:get_value(active_n, Options, ?ACTIVE_N), + + %% FIXME: + %%Limiter = emqx_limiter:init(Options), + + Channel = emqx_exproto_channel:init(ConnInfo, Options), + + GcState = emqx_gc:init(?DEFAULT_GC_OPTS), + + IdleTimeout = proplists:get_value(idle_timeout, Options, ?DEFAULT_IDLE_TIMEOUT), + IdleTimer = start_timer(IdleTimeout, idle_timeout), + #state{socket = WrappedSock, + peername = Peername, + sockname = Sockname, + sockstate = idle, + active_n = ActiveN, + sendfun = undefined, + limiter = undefined, + channel = Channel, + gc_state = GcState, + stats_timer = undefined, + idle_timeout = IdleTimeout, + idle_timer = IdleTimer + }. + +run_loop(Parent, State = #state{socket = Socket, + peername = Peername}) -> + emqx_logger:set_metadata_peername(esockd:format(Peername)), + emqx_misc:tune_heap_size(?DEFAULT_OOM_POLICY), + case activate_socket(State) of + {ok, NState} -> + hibernate(Parent, NState); + {error, Reason} -> + ok = esockd_close(Socket), + exit_on_sock_error(Reason) + end. + +exit_on_sock_error(Reason) when Reason =:= einval; + Reason =:= enotconn; + Reason =:= closed -> + erlang:exit(normal); +exit_on_sock_error(timeout) -> + erlang:exit({shutdown, ssl_upgrade_timeout}); +exit_on_sock_error(Reason) -> + erlang:exit({shutdown, Reason}). + +%%-------------------------------------------------------------------- +%% Recv Loop + +recvloop(Parent, State = #state{idle_timeout = IdleTimeout}) -> + receive + {system, From, Request} -> + sys:handle_system_msg(Request, From, Parent, ?MODULE, [], State); + {'EXIT', Parent, Reason} -> + terminate(Reason, State); + Msg -> + process_msg([Msg], Parent, ensure_stats_timer(IdleTimeout, State)) + after + IdleTimeout -> + hibernate(Parent, cancel_stats_timer(State)) + end. + +hibernate(Parent, State) -> + proc_lib:hibernate(?MODULE, wakeup_from_hib, [Parent, State]). + +%% Maybe do something here later. +wakeup_from_hib(Parent, State) -> recvloop(Parent, State). + +%%-------------------------------------------------------------------- +%% Ensure/cancel stats timer + +-compile({inline, [ensure_stats_timer/2]}). +ensure_stats_timer(Timeout, State = #state{stats_timer = undefined}) -> + State#state{stats_timer = start_timer(Timeout, emit_stats)}; +ensure_stats_timer(_Timeout, State) -> State. + +-compile({inline, [cancel_stats_timer/1]}). +cancel_stats_timer(State = #state{stats_timer = TRef}) when is_reference(TRef) -> + ok = emqx_misc:cancel_timer(TRef), + State#state{stats_timer = undefined}; +cancel_stats_timer(State) -> State. + +%%-------------------------------------------------------------------- +%% Process next Msg + +process_msg([], Parent, State) -> recvloop(Parent, State); + +process_msg([Msg|More], Parent, State) -> + case catch handle_msg(Msg, State) of + ok -> + process_msg(More, Parent, State); + {ok, NState} -> + process_msg(More, Parent, NState); + {ok, Msgs, NState} -> + process_msg(append_msg(More, Msgs), Parent, NState); + {stop, Reason} -> + terminate(Reason, State); + {stop, Reason, NState} -> + terminate(Reason, NState); + {'EXIT', Reason} -> + terminate(Reason, State) + end. + +-compile({inline, [append_msg/2]}). +append_msg([], Msgs) when is_list(Msgs) -> + Msgs; +append_msg([], Msg) -> [Msg]; +append_msg(Q, Msgs) when is_list(Msgs) -> + lists:append(Q, Msgs); +append_msg(Q, Msg) -> + lists:append(Q, [Msg]). + +%%-------------------------------------------------------------------- +%% Handle a Msg + +handle_msg({'$gen_call', From, Req}, State) -> + case handle_call(From, Req, State) of + {reply, Reply, NState} -> + gen_server:reply(From, Reply), + {ok, NState}; + {reply, Reply, Msgs, NState} -> + gen_server:reply(From, Reply), + {ok, next_msgs(Msgs), NState}; + {stop, Reason, Reply, NState} -> + gen_server:reply(From, Reply), + stop(Reason, NState) + end; + +handle_msg({'$gen_cast', Req}, State) -> + with_channel(handle_cast, [Req], State); + +handle_msg({datagram, _SockPid, Data}, State) -> + process_incoming(Data, State); + +handle_msg({Inet, _Sock, Data}, State) when Inet == tcp; Inet == ssl -> + process_incoming(Data, State); + +handle_msg({outgoing, Data}, State) -> + handle_outgoing(Data, State); + +handle_msg({Error, _Sock, Reason}, State) + when Error == tcp_error; Error == ssl_error -> + handle_info({sock_error, Reason}, State); + +handle_msg({Closed, _Sock}, State) + when Closed == tcp_closed; Closed == ssl_closed -> + handle_info({sock_closed, Closed}, close_socket(State)); + +%% TODO: udp_passive??? +handle_msg({Passive, _Sock}, State) + when Passive == tcp_passive; Passive == ssl_passive -> + %% In Stats + Bytes = emqx_pd:reset_counter(incoming_bytes), + Pubs = emqx_pd:reset_counter(incoming_pkt), + InStats = #{cnt => Pubs, oct => Bytes}, + %% Ensure Rate Limit + NState = ensure_rate_limit(InStats, State), + %% Run GC and Check OOM + NState1 = check_oom(run_gc(InStats, NState)), + handle_info(activate_socket, NState1); + +handle_msg(Deliver = {deliver, _Topic, _Msg}, + State = #state{active_n = ActiveN}) -> + Delivers = [Deliver|emqx_misc:drain_deliver(ActiveN)], + with_channel(handle_deliver, [Delivers], State); + +%% Something sent +%% TODO: Who will deliver this message? +handle_msg({inet_reply, _Sock, ok}, State = #state{active_n = ActiveN}) -> + case emqx_pd:get_counter(outgoing_pkt) > ActiveN of + true -> + Pubs = emqx_pd:reset_counter(outgoing_pkt), + Bytes = emqx_pd:reset_counter(outgoing_bytes), + OutStats = #{cnt => Pubs, oct => Bytes}, + {ok, check_oom(run_gc(OutStats, State))}; + false -> ok + end; + +handle_msg({inet_reply, _Sock, {error, Reason}}, State) -> + handle_info({sock_error, Reason}, State); + +handle_msg({close, Reason}, State) -> + ?LOG(debug, "Force to close the socket due to ~p", [Reason]), + handle_info({sock_closed, Reason}, close_socket(State)); + +handle_msg({event, connected}, State = #state{channel = Channel}) -> + ClientId = emqx_exproto_channel:info(clientid, Channel), + emqx_cm:insert_channel_info(ClientId, info(State), stats(State)); + +handle_msg({event, disconnected}, State = #state{channel = Channel}) -> + ClientId = emqx_exproto_channel:info(clientid, Channel), + emqx_cm:set_chan_info(ClientId, info(State)), + emqx_cm:connection_closed(ClientId), + {ok, State}; + +%handle_msg({event, _Other}, State = #state{channel = Channel}) -> +% ClientId = emqx_exproto_channel:info(clientid, Channel), +% emqx_cm:set_chan_info(ClientId, info(State)), +% emqx_cm:set_chan_stats(ClientId, stats(State)), +% {ok, State}; + +handle_msg({timeout, TRef, TMsg}, State) -> + handle_timeout(TRef, TMsg, State); + +handle_msg(Shutdown = {shutdown, _Reason}, State) -> + stop(Shutdown, State); + +handle_msg(Msg, State) -> + handle_info(Msg, State). + +%%-------------------------------------------------------------------- +%% Terminate + +terminate(Reason, State = #state{channel = Channel}) -> + ?LOG(debug, "Terminated due to ~p", [Reason]), + _ = emqx_exproto_channel:terminate(Reason, Channel), + _ = close_socket(State), + exit(Reason). + +%%-------------------------------------------------------------------- +%% Sys callbacks + +system_continue(Parent, _Debug, State) -> + recvloop(Parent, State). + +system_terminate(Reason, _Parent, _Debug, State) -> + terminate(Reason, State). + +system_code_change(State, _Mod, _OldVsn, _Extra) -> + {ok, State}. + +system_get_state(State) -> {ok, State}. + +%%-------------------------------------------------------------------- +%% Handle call + +handle_call(_From, info, State) -> + {reply, info(State), State}; + +handle_call(_From, stats, State) -> + {reply, stats(State), State}; + +handle_call(_From, Req, State = #state{channel = Channel}) -> + case emqx_exproto_channel:handle_call(Req, Channel) of + {reply, Reply, NChannel} -> + {reply, Reply, State#state{channel = NChannel}}; + {reply, Reply, Replies, NChannel} -> + {reply, Reply, Replies, State#state{channel = NChannel}}; + {shutdown, Reason, Reply, NChannel} -> + shutdown(Reason, Reply, State#state{channel = NChannel}) + end. + +%%-------------------------------------------------------------------- +%% Handle timeout + +handle_timeout(_TRef, idle_timeout, State) -> + shutdown(idle_timeout, State); + +handle_timeout(_TRef, limit_timeout, State) -> + NState = State#state{sockstate = idle, + limit_timer = undefined + }, + handle_info(activate_socket, NState); +handle_timeout(TRef, keepalive, State = #state{socket = Socket, + channel = Channel})-> + case emqx_exproto_channel:info(conn_state, Channel) of + disconnected -> {ok, State}; + _ -> + case esockd_getstat(Socket, [recv_oct]) of + {ok, [{recv_oct, RecvOct}]} -> + handle_timeout(TRef, {keepalive, RecvOct}, State); + {error, Reason} -> + handle_info({sock_error, Reason}, State) + end + end; +handle_timeout(_TRef, emit_stats, State = + #state{channel = Channel}) -> + ClientId = emqx_exproto_channel:info(clientid, Channel), + emqx_cm:set_chan_stats(ClientId, stats(State)), + {ok, State#state{stats_timer = undefined}}; + +handle_timeout(TRef, Msg, State) -> + with_channel(handle_timeout, [TRef, Msg], State). + +%%-------------------------------------------------------------------- +%% Parse incoming data + +-compile({inline, [process_incoming/2]}). +process_incoming(Data, State = #state{idle_timer = IdleTimer}) -> + ?LOG(debug, "RECV ~0p", [Data]), + Oct = iolist_size(Data), + inc_counter(incoming_bytes, Oct), + inc_counter(incoming_pkt, 1), + inc_counter(recv_pkt, 1), + inc_counter(recv_msg, 1), + % TODO: + %ok = emqx_metrics:inc('bytes.received', Oct), + + ok = emqx_misc:cancel_timer(IdleTimer), + NState = State#state{idle_timer = undefined}, + + with_channel(handle_in, [Data], NState). + +%%-------------------------------------------------------------------- +%% With Channel + +with_channel(Fun, Args, State = #state{channel = Channel}) -> + case erlang:apply(emqx_exproto_channel, Fun, Args ++ [Channel]) of + ok -> {ok, State}; + {ok, NChannel} -> + {ok, State#state{channel = NChannel}}; + {ok, Replies, NChannel} -> + {ok, next_msgs(Replies), State#state{channel = NChannel}}; + {shutdown, Reason, NChannel} -> + shutdown(Reason, State#state{channel = NChannel}) + end. + +%%-------------------------------------------------------------------- +%% Handle outgoing packets + +handle_outgoing(IoData, State = #state{socket = Socket}) -> + ?LOG(debug, "SEND ~0p", [IoData]), + + Oct = iolist_size(IoData), + + inc_counter(send_pkt, 1), + inc_counter(send_msg, 1), + inc_counter(outgoing_pkt, 1), + inc_counter(outgoing_bytes, Oct), + + %% FIXME: + %%ok = emqx_metrics:inc('bytes.sent', Oct), + case send(IoData, State) of + ok -> ok; + Error = {error, _Reason} -> + %% Send an inet_reply to postpone handling the error + self() ! {inet_reply, Socket, Error}, + ok + end. + +%%-------------------------------------------------------------------- +%% Handle Info + +handle_info(activate_socket, State = #state{sockstate = OldSst}) -> + case activate_socket(State) of + {ok, NState = #state{sockstate = NewSst}} -> + if OldSst =/= NewSst -> + {ok, {event, NewSst}, NState}; + true -> {ok, NState} + end; + {error, Reason} -> + handle_info({sock_error, Reason}, State) + end; + +handle_info({sock_error, Reason}, State) -> + ?LOG(debug, "Socket error: ~p", [Reason]), + handle_info({sock_closed, Reason}, close_socket(State)); + +handle_info(Info, State) -> + with_channel(handle_info, [Info], State). + +%%-------------------------------------------------------------------- +%% Ensure rate limit + +ensure_rate_limit(Stats, State = #state{limiter = Limiter}) -> + case ?ENABLED(Limiter) andalso emqx_limiter:check(Stats, Limiter) of + false -> State; + {ok, Limiter1} -> + State#state{limiter = Limiter1}; + {pause, Time, Limiter1} -> + ?LOG(warning, "Pause ~pms due to rate limit", [Time]), + TRef = start_timer(Time, limit_timeout), + State#state{sockstate = blocked, + limiter = Limiter1, + limit_timer = TRef + } + end. + +%%-------------------------------------------------------------------- +%% Run GC and Check OOM + +run_gc(Stats, State = #state{gc_state = GcSt}) -> + case ?ENABLED(GcSt) andalso emqx_gc:run(Stats, GcSt) of + false -> State; + {_IsGC, GcSt1} -> + State#state{gc_state = GcSt1} + end. + +check_oom(State) -> + OomPolicy = ?DEFAULT_OOM_POLICY, + case ?ENABLED(OomPolicy) andalso emqx_misc:check_oom(OomPolicy) of + Shutdown = {shutdown, _Reason} -> + erlang:send(self(), Shutdown); + _Other -> ok + end, + State. + +%%-------------------------------------------------------------------- +%% Activate Socket + +-compile({inline, [activate_socket/1]}). +activate_socket(State = #state{sockstate = closed}) -> + {ok, State}; +activate_socket(State = #state{sockstate = blocked}) -> + {ok, State}; +activate_socket(State = #state{socket = Socket, + active_n = N}) -> + %% FIXME: Works on dtls/udp ??? + %% How to hanlde buffer? + case esockd_setopts(Socket, [{active, N}]) of + ok -> {ok, State#state{sockstate = running}}; + Error -> Error + end. + +%%-------------------------------------------------------------------- +%% Close Socket + +close_socket(State = #state{sockstate = closed}) -> State; +close_socket(State = #state{socket = Socket}) -> + ok = esockd_close(Socket), + State#state{sockstate = closed}. + +%%-------------------------------------------------------------------- +%% Helper functions + +-compile({inline, [next_msgs/1]}). +next_msgs(Event) when is_tuple(Event) -> + Event; +next_msgs(More) when is_list(More) -> + More. + +-compile({inline, [shutdown/2, shutdown/3]}). +shutdown(Reason, State) -> + stop({shutdown, Reason}, State). + +shutdown(Reason, Reply, State) -> + stop({shutdown, Reason}, Reply, State). + +-compile({inline, [stop/2, stop/3]}). +stop(Reason, State) -> + {stop, Reason, State}. + +stop(Reason, Reply, State) -> + {stop, Reason, Reply, State}. + +inc_counter(Name, Value) -> + _ = emqx_pd:inc_counter(Name, Value), + ok. diff --git a/apps/emqx_exproto/src/emqx_exproto_gcli.erl b/apps/emqx_exproto/src/emqx_exproto_gcli.erl new file mode 100644 index 000000000..90e5d75a3 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_gcli.erl @@ -0,0 +1,131 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% the gRPC client worker for ConnectionHandler service +-module(emqx_exproto_gcli). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto gClient]"). + +%% APIs +-export([async_call/3]). + +-export([start_link/2]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, { + pool, + id, + streams + }). + +-define(CONN_ADAPTER_MOD, emqx_exproto_v_1_connection_handler_client). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start_link(Pool, Id) -> + gen_server:start_link({local, emqx_misc:proc_name(?MODULE, Id)}, + ?MODULE, [Pool, Id], []). + +async_call(FunName, Req = #{conn := Conn}, Options) -> + cast(pick(Conn), {rpc, FunName, Req, Options, self()}). + +%%-------------------------------------------------------------------- +%% cast, pick +%%-------------------------------------------------------------------- + +-compile({inline, [cast/2, pick/1]}). + +cast(Deliver, Msg) -> + gen_server:cast(Deliver, Msg). + +pick(Conn) -> + gproc_pool:pick_worker(exproto_gcli_pool, Conn). + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Pool, Id]) -> + true = gproc_pool:connect_worker(Pool, {Pool, Id}), + {ok, #state{pool = Pool, id = Id, streams = #{}}}. + +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +handle_cast({rpc, Fun, Req, Options, From}, State = #state{streams = Streams}) -> + case ensure_stream_opened(Fun, Options, Streams) of + {error, Reason} -> + ?LOG(error, "CALL ~0p:~0p(~0p) failed, reason: ~0p", + [?CONN_ADAPTER_MOD, Fun, Options, Reason]), + reply(From, Fun, {error, Reason}), + {noreply, State#state{streams = Streams#{Fun => undefined}}}; + {ok, Stream} -> + case catch grpc_client:send(Stream, Req) of + ok -> + ?LOG(debug, "Send to ~p method successfully, request: ~0p", [Fun, Req]), + reply(From, Fun, ok), + {noreply, State#state{streams = Streams#{Fun => Stream}}}; + {'EXIT', {timeout, _Stk}} -> + ?LOG(error, "Send to ~p method timeout, request: ~0p", [Fun, Req]), + reply(From, Fun, {error, timeout}), + {noreply, State#state{streams = Streams#{Fun => Stream}}}; + {'EXIT', {Reason1, _Stk}} -> + ?LOG(error, "Send to ~p method failure, request: ~0p, stacktrace: ~0p", [Fun, Req, _Stk]), + reply(From, Fun, {error, Reason1}), + {noreply, State#state{streams = Streams#{Fun => undefined}}} + end + end. + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +reply(Pid, Fun, Result) -> + Pid ! {hreply, Fun, Result}, + ok. + +ensure_stream_opened(Fun, Options, Streams) -> + case maps:get(Fun, Streams, undefined) of + undefined -> + case apply(?CONN_ADAPTER_MOD, Fun, [Options]) of + {ok, Stream} -> {ok, Stream}; + {error, Reason} -> {error, Reason} + end; + Stream -> {ok, Stream} + end. diff --git a/apps/emqx_exproto/src/emqx_exproto_gsvr.erl b/apps/emqx_exproto/src/emqx_exproto_gsvr.erl new file mode 100644 index 000000000..c1007ee1d --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_gsvr.erl @@ -0,0 +1,154 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% The gRPC server for ConnectionAdapter +-module(emqx_exproto_gsvr). + +-behavior(emqx_exproto_v_1_connection_adapter_bhvr). + +-include("emqx_exproto.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto gServer]"). + +-define(IS_QOS(X), (X =:= 0 orelse X =:= 1 orelse X =:= 2)). + +%% gRPC server callbacks +-export([ send/2 + , close/2 + , authenticate/2 + , start_timer/2 + , publish/2 + , subscribe/2 + , unsubscribe/2 + ]). + +%%-------------------------------------------------------------------- +%% gRPC ConnectionAdapter service +%%-------------------------------------------------------------------- + +-spec send(emqx_exproto_pb:send_bytes_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +send(Req = #{conn := Conn, bytes := Bytes}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {send, Bytes})), Md}. + +-spec close(emqx_exproto_pb:close_socket_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +close(Req = #{conn := Conn}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, close)), Md}. + +-spec authenticate(emqx_exproto_pb:authenticate_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +authenticate(Req = #{conn := Conn, + password := Password, + clientinfo := ClientInfo}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + case validate(clientinfo, ClientInfo) of + false -> + {ok, response({error, ?RESP_REQUIRED_PARAMS_MISSED}), Md}; + _ -> + {ok, response(call(Conn, {auth, ClientInfo, Password})), Md} + end. + +-spec start_timer(emqx_exproto_pb:timer_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +start_timer(Req = #{conn := Conn, type := Type, interval := Interval}, Md) + when Type =:= 'KEEPALIVE' andalso Interval > 0 -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {start_timer, keepalive, Interval})), Md}; +start_timer(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec publish(emqx_exproto_pb:publish_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +publish(Req = #{conn := Conn, topic := Topic, qos := Qos, payload := Payload}, Md) + when ?IS_QOS(Qos) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {publish, Topic, Qos, Payload})), Md}; + +publish(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec subscribe(emqx_exproto_pb:subscribe_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +subscribe(Req = #{conn := Conn, topic := Topic, qos := Qos}, Md) + when ?IS_QOS(Qos) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {subscribe, Topic, Qos})), Md}; + +subscribe(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec unsubscribe(emqx_exproto_pb:unsubscribe_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +unsubscribe(Req = #{conn := Conn, topic := Topic}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {unsubscribe, Topic})), Md}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +to_pid(ConnStr) -> + binary_to_term(base64:decode(ConnStr)). + +call(ConnStr, Req) -> + case catch to_pid(ConnStr) of + {'EXIT', {badarg, _}} -> + {error, ?RESP_PARAMS_TYPE_ERROR, + <<"The conn type error">>}; + Pid when is_pid(Pid) -> + case erlang:is_process_alive(Pid) of + true -> + emqx_exproto_conn:call(Pid, Req); + false -> + {error, ?RESP_CONN_PROCESS_NOT_ALIVE, + <<"Connection process is not alive">>} + end + end. + +%%-------------------------------------------------------------------- +%% Data types + +stringfy(Reason) -> + unicode:characters_to_binary((io_lib:format("~0p", [Reason]))). + +validate(clientinfo, M) -> + Required = [proto_name, proto_ver, clientid], + lists:all(fun(K) -> maps:is_key(K, M) end, Required). + +response(ok) -> + #{code => ?RESP_SUCCESS}; +response({error, Code, Reason}) + when ?IS_GRPC_RESULT_CODE(Code) -> + #{code => Code, message => stringfy(Reason)}; +response({error, Code}) + when ?IS_GRPC_RESULT_CODE(Code) -> + #{code => Code}; +response(Other) -> + #{code => ?RESP_UNKNOWN, message => stringfy(Other)}. diff --git a/apps/emqx_exproto/src/emqx_exproto_sup.erl b/apps/emqx_exproto/src/emqx_exproto_sup.erl new file mode 100644 index 000000000..d4bde316a --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_sup.erl @@ -0,0 +1,83 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([ start_grpc_server/3 + , stop_grpc_server/1 + , start_grpc_client_channel/3 + , stop_grpc_client_channel/1 + ]). + +-export([init/1]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +-spec start_grpc_server(atom(), inet:port_number(), list()) + -> {ok, pid()} | {error, term()}. +start_grpc_server(Name, Port, SSLOptions) -> + Services = #{protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionAdapter' => emqx_exproto_gsvr} + }, + Options = case SSLOptions of + [] -> []; + _ -> + [{ssl_options, lists:keydelete(ssl, 1, SSLOptions)}] + end, + grpc:start_server(prefix(Name), Port, Services, Options). + +-spec stop_grpc_server(atom()) -> ok. +stop_grpc_server(Name) -> + grpc:stop_server(prefix(Name)). + +-spec start_grpc_client_channel( + atom(), + uri_string:uri_string(), + grpc_client:grpc_opts()) -> {ok, pid()} | {error, term()}. +start_grpc_client_channel(Name, SvrAddr, ClientOpts) -> + grpc_client_sup:create_channel_pool(Name, SvrAddr, ClientOpts). + +-spec stop_grpc_client_channel(atom()) -> ok. +stop_grpc_client_channel(Name) -> + grpc_client_sup:stop_channel_pool(Name). + +%% @private +prefix(Name) when is_atom(Name) -> + "exproto:" ++ atom_to_list(Name); +prefix(Name) when is_binary(Name) -> + "exproto:" ++ binary_to_list(Name); +prefix(Name) when is_list(Name) -> + "exproto:" ++ Name. + +%%-------------------------------------------------------------------- +%% Supervisor callbacks +%%-------------------------------------------------------------------- + +init([]) -> + %% gRPC Client Pool + PoolSize = emqx_vm:schedulers() * 2, + Pool = emqx_pool_sup:spec([exproto_gcli_pool, hash, PoolSize, + {emqx_exproto_gcli, start_link, []}]), + {ok, {{one_for_one, 10, 5}, [Pool]}}. diff --git a/apps/emqx_exproto/test/emqx_exproto_SUITE.erl b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl new file mode 100644 index 000000000..bf5c0943f --- /dev/null +++ b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl @@ -0,0 +1,453 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-import(emqx_exproto_echo_svr, + [ frame_connect/2 + , frame_connack/1 + , frame_publish/3 + , frame_puback/1 + , frame_subscribe/2 + , frame_suback/1 + , frame_unsubscribe/1 + , frame_unsuback/1 + , frame_disconnect/0 + ]). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-define(TCPOPTS, [binary, {active, false}]). +-define(DTLSOPTS, [binary, {active, false}, {protocol, dtls}]). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> + [{group, Name} || Name <- metrics()]. + +groups() -> + Cases = emqx_ct:all(?MODULE), + [{Name, Cases} || Name <- metrics()]. + +%% @private +metrics() -> + [tcp, ssl, udp, dtls]. + +init_per_group(GrpName, Cfg) -> + put(grpname, GrpName), + Svrs = emqx_exproto_echo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exproto], fun set_sepecial_cfg/1), + emqx_logger:set_log_level(debug), + [{servers, Svrs}, {listener_type, GrpName} | Cfg]. + +end_per_group(_, Cfg) -> + emqx_ct_helpers:stop_apps([emqx_exproto]), + emqx_exproto_echo_svr:stop(proplists:get_value(servers, Cfg)). + +set_sepecial_cfg(emqx_exproto) -> + LisType = get(grpname), + Listeners = application:get_env(emqx_exproto, listeners, []), + SockOpts = socketopts(LisType), + UpgradeOpts = fun(Opts) -> + Opts2 = lists:keydelete(tcp_options, 1, Opts), + Opts3 = lists:keydelete(ssl_options, 1, Opts2), + Opts4 = lists:keydelete(udp_options, 1, Opts3), + Opts5 = lists:keydelete(dtls_options, 1, Opts4), + SockOpts ++ Opts5 + end, + NListeners = [{Proto, LisType, LisOn, UpgradeOpts(Opts)} + || {Proto, _Type, LisOn, Opts} <- Listeners], + application:set_env(emqx_exproto, listeners, NListeners); +set_sepecial_cfg(emqx) -> + application:set_env(emqx, allow_anonymous, true), + application:set_env(emqx, enable_acl_cache, false), + ok. + +%%-------------------------------------------------------------------- +%% Tests cases +%%-------------------------------------------------------------------- + +t_start_stop(_) -> + ok. + +t_mountpoint_echo(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">>, + mountpoint => <<"ct/">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SubBin = frame_subscribe(<<"t/dn">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + emqx:publish(emqx_message:make(<<"ct/t/dn">>, <<"echo">>)), + PubBin1 = frame_publish(<<"t/dn">>, 0, <<"echo">>), + {ok, PubBin1} = recv(Sock, 5000), + + PubBin2 = frame_publish(<<"t/up">>, 0, <<"echo">>), + PubAckBin = frame_puback(0), + + emqx:subscribe(<<"ct/t/up">>), + + send(Sock, PubBin2), + {ok, PubAckBin} = recv(Sock, 5000), + + receive + {deliver, _, _} -> ok + after 1000 -> + error(echo_not_running) + end, + close(Sock). + +t_auth_deny(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ok = meck:new(emqx_access_control, [passthrough, no_history, no_link]), + ok = meck:expect(emqx_access_control, authenticate, + fun(_) -> {error, ?RC_NOT_AUTHORIZED} end), + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(1), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, + meck:unload([emqx_access_control]). + +t_acl_deny(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ok = meck:new(emqx_access_control, [passthrough, no_history, no_link]), + ok = meck:expect(emqx_access_control, check_acl, fun(_, _, _) -> deny end), + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(1), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + emqx:publish(emqx_message:make(<<"t/dn">>, <<"echo">>)), + + PubBin = frame_publish(<<"t/dn">>, 0, <<"echo">>), + PubBinFailedAck = frame_puback(1), + PubBinSuccesAck = frame_puback(0), + + send(Sock, PubBin), + {ok, PubBinFailedAck} = recv(Sock, 5000), + + meck:unload([emqx_access_control]), + + send(Sock, PubBin), + {ok, PubBinSuccesAck} = recv(Sock, 5000), + close(Sock). + +t_keepalive_timeout(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">>, + keepalive => 2 + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + DisconnectBin = frame_disconnect(), + {ok, DisconnectBin} = recv(Sock, 10000), + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, ok. + +t_hook_connected_disconnected(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + Parent = self(), + HookFun1 = fun(_, _) -> Parent ! connected, ok end, + HookFun2 = fun(_, _, _) -> Parent ! disconnected, ok end, + emqx:hook('client.connected', HookFun1), + emqx:hook('client.disconnected', HookFun2), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + receive + connected -> ok + after 1000 -> + error(hook_is_not_running) + end, + + DisconnectBin = frame_disconnect(), + send(Sock, DisconnectBin), + + receive + disconnected -> ok + after 1000 -> + error(hook_is_not_running) + end, + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, + emqx:unhook('client.connected', HookFun1), + emqx:unhook('client.disconnected', HookFun2). + +t_hook_session_subscribed_unsubscribed(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + Parent = self(), + HookFun1 = fun(_, _, _) -> Parent ! subscribed, ok end, + HookFun2 = fun(_, _, _) -> Parent ! unsubscribed, ok end, + emqx:hook('session.subscribed', HookFun1), + emqx:hook('session.unsubscribed', HookFun2), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + receive + subscribed -> ok + after 1000 -> + error(hook_is_not_running) + end, + + UnsubBin = frame_unsubscribe(<<"t/#">>), + UnsubAckBin = frame_unsuback(0), + + send(Sock, UnsubBin), + {ok, UnsubAckBin} = recv(Sock, 5000), + + receive + unsubscribed -> ok + after 1000 -> + error(hook_is_not_running) + end, + + close(Sock), + emqx:unhook('session.subscribed', HookFun1), + emqx:unhook('session.unsubscribed', HookFun2). + +t_hook_message_delivered(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + HookFun1 = fun(_, Msg) -> {ok, Msg#message{payload = <<"2">>}} end, + emqx:hook('message.delivered', HookFun1), + + emqx:publish(emqx_message:make(<<"t/dn">>, <<"1">>)), + PubBin1 = frame_publish(<<"t/dn">>, 0, <<"2">>), + {ok, PubBin1} = recv(Sock, 5000), + + close(Sock), + emqx:unhook('message.delivered', HookFun1). + +%%-------------------------------------------------------------------- +%% Utils + +rand_bytes() -> + crypto:strong_rand_bytes(rand:uniform(256)). + +%%-------------------------------------------------------------------- +%% Sock funcs + +open(tcp) -> + {ok, Sock} = gen_tcp:connect("127.0.0.1", 7993, ?TCPOPTS), + {tcp, Sock}; +open(udp) -> + {ok, Sock} = gen_udp:open(0, ?TCPOPTS), + {udp, Sock}; +open(ssl) -> + SslOpts = client_ssl_opts(), + {ok, SslSock} = ssl:connect("127.0.0.1", 7993, ?TCPOPTS ++ SslOpts), + {ssl, SslSock}; +open(dtls) -> + SslOpts = client_ssl_opts(), + {ok, SslSock} = ssl:connect("127.0.0.1", 7993, ?DTLSOPTS ++ SslOpts), + {dtls, SslSock}. + +send({tcp, Sock}, Bin) -> + gen_tcp:send(Sock, Bin); +send({udp, Sock}, Bin) -> + gen_udp:send(Sock, "127.0.0.1", 7993, Bin); +send({ssl, Sock}, Bin) -> + ssl:send(Sock, Bin); +send({dtls, Sock}, Bin) -> + ssl:send(Sock, Bin). + +recv({tcp, Sock}, Ts) -> + gen_tcp:recv(Sock, 0, Ts); +recv({udp, Sock}, Ts) -> + {ok, {_, _, Bin}} = gen_udp:recv(Sock, 0, Ts), + {ok, Bin}; +recv({ssl, Sock}, Ts) -> + ssl:recv(Sock, 0, Ts); +recv({dtls, Sock}, Ts) -> + ssl:recv(Sock, 0, Ts). + +close({tcp, Sock}) -> + gen_tcp:close(Sock); +close({udp, Sock}) -> + gen_udp:close(Sock); +close({ssl, Sock}) -> + ssl:close(Sock); +close({dtls, Sock}) -> + ssl:close(Sock). + +%%-------------------------------------------------------------------- +%% Server-Opts + +socketopts(tcp) -> + [{tcp_options, tcp_opts()}]; +socketopts(ssl) -> + [{tcp_options, tcp_opts()}, + {ssl_options, ssl_opts()}]; +socketopts(udp) -> + [{udp_options, udp_opts()}]; +socketopts(dtls) -> + [{udp_options, udp_opts()}, + {dtls_options, dtls_opts()}]. + +tcp_opts() -> + [{send_timeout, 15000}, + {send_timeout_close, true}, + {backlog, 100}, + {nodelay, true} | udp_opts()]. + +udp_opts() -> + [{recbuf, 1024}, + {sndbuf, 1024}, + {buffer, 1024}, + {reuseaddr, true}]. + +ssl_opts() -> + Path = emqx_ct_helpers:deps_path(emqx, "etc/certs"), + [{versions, ['tlsv1.2','tlsv1.1',tlsv1]}, + {ciphers, ciphers()}, + {keyfile, Path ++ "/key.pem"}, + {certfile, Path ++ "/cert.pem"}, + {cacertfile, Path ++ "/cacert.pem"}, + {verify, verify_peer}, + {fail_if_no_peer_cert, true}, + {secure_renegotiate, false}, + {reuse_sessions, true}, + {honor_cipher_order, true}]. + +dtls_opts() -> + Opts = ssl_opts(), + lists:keyreplace(versions, 1, Opts, {versions, ['dtlsv1.2', 'dtlsv1']}). + +ciphers() -> + proplists:get_value(ciphers, emqx_ct_helpers:client_ssl()). + +%%-------------------------------------------------------------------- +%% Client-Opts + +client_ssl_opts() -> + Path = emqx_ct_helpers:deps_path(emqx, "etc/certs"), + [{keyfile, Path ++ "/client-key.pem"}, + {certfile, Path ++ "/client-cert.pem"}, + {cacertfile, Path ++ "/cacert.pem"}]. diff --git a/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl b/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl new file mode 100644 index 000000000..031093b50 --- /dev/null +++ b/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl @@ -0,0 +1,278 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_echo_svr). + +-behavior(emqx_exproto_v_1_connection_handler_bhvr). + +-export([ start/0 + , stop/1 + ]). + +-export([ frame_connect/2 + , frame_connack/1 + , frame_publish/3 + , frame_puback/1 + , frame_subscribe/2 + , frame_suback/1 + , frame_unsubscribe/1 + , frame_unsuback/1 + , frame_disconnect/0 + ]). + +-export([ on_socket_created/2 + , on_received_bytes/2 + , on_socket_closed/2 + , on_timer_timeout/2 + , on_received_messages/2 + ]). + +-define(LOG(Fmt, Args), io:format(standard_error, Fmt, Args)). + +-define(HTTP, #{grpc_opts => #{service_protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionHandler' => ?MODULE}}, + listen_opts => #{port => 9001, + socket_options => []}, + pool_opts => #{size => 8}, + transport_opts => #{ssl => false}}). + +-define(CLIENT, emqx_exproto_v_1_connection_adapter_client). + +-define(send(Req), ?CLIENT:send(Req, #{channel => ct_test_channel})). +-define(close(Req), ?CLIENT:close(Req, #{channel => ct_test_channel})). +-define(authenticate(Req), ?CLIENT:authenticate(Req, #{channel => ct_test_channel})). +-define(start_timer(Req), ?CLIENT:start_timer(Req, #{channel => ct_test_channel})). +-define(publish(Req), ?CLIENT:publish(Req, #{channel => ct_test_channel})). +-define(subscribe(Req), ?CLIENT:subscribe(Req, #{channel => ct_test_channel})). +-define(unsubscribe(Req), ?CLIENT:unsubscribe(Req, #{channel => ct_test_channel})). + +-define(TYPE_CONNECT, 1). +-define(TYPE_CONNACK, 2). +-define(TYPE_PUBLISH, 3). +-define(TYPE_PUBACK, 4). +-define(TYPE_SUBSCRIBE, 5). +-define(TYPE_SUBACK, 6). +-define(TYPE_UNSUBSCRIBE, 7). +-define(TYPE_UNSUBACK, 8). +-define(TYPE_DISCONNECT, 9). + +-define(loop_recv_and_reply_empty_success(Stream), + ?loop_recv_and_reply_empty_success(Stream, fun(_) -> ok end)). + +-define(loop_recv_and_reply_empty_success(Stream, Fun), + begin + LoopRecv = fun _Lp(_St) -> + case grpc_stream:recv(_St) of + {more, _Reqs, _NSt} -> + ?LOG("~p: ~p~n", [?FUNCTION_NAME, _Reqs]), + Fun(_Reqs), _Lp(_NSt); + {eos, _Reqs, _NSt} -> + ?LOG("~p: ~p~n", [?FUNCTION_NAME, _Reqs]), + Fun(_Reqs), _NSt + end + end, + NStream = LoopRecv(Stream), + grpc_stream:reply(NStream, #{}), + {ok, NStream} + end). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start() -> + application:ensure_all_started(grpc), + [start_channel(), start_server()]. + +start_channel() -> + grpc_client_sup:create_channel_pool(ct_test_channel, "http://127.0.0.1:9100", #{}). + +start_server() -> + Services = #{protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionHandler' => ?MODULE} + }, + Options = [], + grpc:start_server(?MODULE, 9001, Services, Options). + +stop([_ChannPid, _SvrPid]) -> + grpc:stop_server(?MODULE), + grpc_client_sup:stop_channel_pool(ct_test_channel). + +%%-------------------------------------------------------------------- +%% Protocol Adapter callbacks +%%-------------------------------------------------------------------- + +-spec on_socket_created(grpc_stream:stream(), grpc:metadata()) + -> {ok, grpc_stream:stream()}. +on_socket_created(Stream, _Md) -> + ?loop_recv_and_reply_empty_success(Stream). + +-spec on_socket_closed(grpc_stream:stream(), grpc:metadata()) + -> {ok, grpc_stream:stream()}. +on_socket_closed(Stream, _Md) -> + ?loop_recv_and_reply_empty_success(Stream). + +-spec on_received_bytes(grpc_stream:stream(), grpc:metadata()) + -> {ok, grpc_stream:stream()}. +on_received_bytes(Stream, _Md) -> + ?loop_recv_and_reply_empty_success(Stream, + fun(Reqs) -> + lists:foreach( + fun(#{conn := Conn, bytes := Bytes}) -> + #{<<"type">> := Type} = Params = emqx_json:decode(Bytes, [return_maps]), + _ = handle_in(Conn, Type, Params) + end, Reqs) + end). + +-spec on_timer_timeout(grpc_stream:stream(), grpc:metadata()) + -> {ok, grpc_stream:stream()}. +on_timer_timeout(Stream, _Md) -> + ?loop_recv_and_reply_empty_success(Stream, + fun(Reqs) -> + lists:foreach( + fun(#{conn := Conn, type := 'KEEPALIVE'}) -> + ?LOG("Close this connection ~p due to keepalive timeout", [Conn]), + handle_out(Conn, ?TYPE_DISCONNECT), + ?close(#{conn => Conn}) + end, Reqs) + end). + +-spec on_received_messages(grpc_stream:stream(), grpc:metadata()) + -> {ok, grpc_stream:stream()}. +on_received_messages(Stream, _Md) -> + ?loop_recv_and_reply_empty_success(Stream, + fun(Reqs) -> + lists:foreach( + fun(#{conn := Conn, messages := Messages}) -> + lists:foreach(fun(Message) -> + handle_out(Conn, ?TYPE_PUBLISH, Message) + end, Messages) + end, Reqs) + end). + +%%-------------------------------------------------------------------- +%% The Protocol Example: +%% CONN: +%% {"type": 1, "clientinfo": {...}} +%% +%% CONNACK: +%% {"type": 2, "code": 0} +%% +%% PUBLISH: +%% {"type": 3, "topic": "xxx", "payload": "", "qos": 0} +%% +%% PUBACK: +%% {"type": 4, "code": 0} +%% +%% SUBSCRIBE: +%% {"type": 5, "topic": "xxx", "qos": 1} +%% +%% SUBACK: +%% {"type": 6, "code": 0} +%% +%% DISCONNECT: +%% {"type": 7, "code": 1} +%%-------------------------------------------------------------------- + +handle_in(Conn, ?TYPE_CONNECT, #{<<"clientinfo">> := ClientInfo, <<"password">> := Password}) -> + NClientInfo = maps:from_list([{binary_to_atom(K, utf8), V} || {K, V} <- maps:to_list(ClientInfo)]), + case ?authenticate(#{conn => Conn, clientinfo => NClientInfo, password => Password}) of + {ok, #{code := 'SUCCESS'}, _} -> + case maps:get(keepalive, NClientInfo, 0) of + 0 -> ok; + Intv -> + io:format("Try call start_timer with ~ps", [Intv]), + ?start_timer(#{conn => Conn, type => 'KEEPALIVE', interval => Intv}) + end, + handle_out(Conn, ?TYPE_CONNACK, 0); + _ -> + handle_out(Conn, ?TYPE_CONNACK, 1), + ?close(#{conn => Conn}) + end; +handle_in(Conn, ?TYPE_PUBLISH, #{<<"topic">> := Topic, + <<"qos">> := Qos, + <<"payload">> := Payload}) -> + case ?publish(#{conn => Conn, topic => Topic, qos => Qos, payload => Payload}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_PUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_PUBACK, 1) + end; +handle_in(Conn, ?TYPE_SUBSCRIBE, #{<<"qos">> := Qos, <<"topic">> := Topic}) -> + case ?subscribe(#{conn => Conn, topic => Topic, qos => Qos}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_SUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_SUBACK, 1) + end; +handle_in(Conn, ?TYPE_UNSUBSCRIBE, #{<<"topic">> := Topic}) -> + case ?unsubscribe(#{conn => Conn, topic => Topic}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_UNSUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_UNSUBACK, 1) + end; + +handle_in(Conn, ?TYPE_DISCONNECT, _) -> + ?close(#{conn => Conn}). + +handle_out(Conn, ?TYPE_CONNACK, Code) -> + ?send(#{conn => Conn, bytes => frame_connack(Code)}); +handle_out(Conn, ?TYPE_PUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_puback(Code)}); +handle_out(Conn, ?TYPE_SUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_suback(Code)}); +handle_out(Conn, ?TYPE_UNSUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_unsuback(Code)}); +handle_out(Conn, ?TYPE_PUBLISH, #{qos := Qos, topic := Topic, payload := Payload}) -> + ?send(#{conn => Conn, bytes => frame_publish(Topic, Qos, Payload)}). + +handle_out(Conn, ?TYPE_DISCONNECT) -> + ?send(#{conn => Conn, bytes => frame_disconnect()}). + +%%-------------------------------------------------------------------- +%% Frame + +frame_connect(ClientInfo, Password) -> + emqx_json:encode(#{type => ?TYPE_CONNECT, + clientinfo => ClientInfo, + password => Password}). +frame_connack(Code) -> + emqx_json:encode(#{type => ?TYPE_CONNACK, code => Code}). + +frame_publish(Topic, Qos, Payload) -> + emqx_json:encode(#{type => ?TYPE_PUBLISH, + topic => Topic, + qos => Qos, + payload => Payload}). + +frame_puback(Code) -> + emqx_json:encode(#{type => ?TYPE_PUBACK, code => Code}). + +frame_subscribe(Topic, Qos) -> + emqx_json:encode(#{type => ?TYPE_SUBSCRIBE, topic => Topic, qos => Qos}). + +frame_suback(Code) -> + emqx_json:encode(#{type => ?TYPE_SUBACK, code => Code}). + +frame_unsubscribe(Topic) -> + emqx_json:encode(#{type => ?TYPE_UNSUBSCRIBE, topic => Topic}). + +frame_unsuback(Code) -> + emqx_json:encode(#{type => ?TYPE_UNSUBACK, code => Code}). + +frame_disconnect() -> + emqx_json:encode(#{type => ?TYPE_DISCONNECT}). diff --git a/apps/emqx_lua_hook/.gitignore b/apps/emqx_lua_hook/.gitignore new file mode 100644 index 000000000..af616ea1a --- /dev/null +++ b/apps/emqx_lua_hook/.gitignore @@ -0,0 +1,19 @@ +deps/ +ebin/ +_rel/ +.erlang.mk/ +*.d +data/ +*.iml +.idea/ +logs/ +*.beam +.DS_Store +erlang.mk +_build/ +rebar.lock +rebar3.crashdump +bbmustache/ +*.conf.rendered +.rebar3 +*.swp diff --git a/apps/emqx_lua_hook/README.md b/apps/emqx_lua_hook/README.md new file mode 100644 index 000000000..a3f65a094 --- /dev/null +++ b/apps/emqx_lua_hook/README.md @@ -0,0 +1,338 @@ + +# emqx-lua-hook + +This plugin makes it possible to write hooks in lua scripts. + +Lua virtual machine is implemented by [luerl](https://github.com/rvirding/luerl) which supports Lua 5.2. Following features may not work properly: +* label and goto +* tail-call optimisation in return +* only limited standard libraries +* proper handling of `__metatable` + +For the supported functions, please refer to luerl's [project page](https://github.com/rvirding/luerl). + +Lua scripts are stored in 'data/scripts' directory, and will be loaded automatically. If a script is changed during runtime, it should be reloaded to take effect. + +Each lua script could export several functions binding with emqx hooks, triggered by message publish, topic subscribe, client connect, etc. Different lua scripts may export same type function, binding with a same event. But their order being triggered is not guaranteed. + +To start this plugin, run following command: + +```shell +bin/emqx_ctl plugins load emqx_lua_hook +``` + + +## NOTE + +* Since lua VM is run on erlang VM, its performance is poor. Please do NOT write long or complicated lua scripts which may degrade entire system. +* It's hard to debug lua script in emqx environment. Recommended to unit test your lua script in your host first. If everything is OK, deploy it to emqx 'data/scripts' directory. +* Global variable will lost its value for each call. Do NOT use global variable in lua scripts. + + +# Example + +Suppose your emqx is installed in /emqx, and the lua script directory should be /emqx/data/scripts. + +Make a new file called "test.lua" and put following code into this file: + +```lua +function on_message_publish(clientid, username, topic, payload, qos, retain) + return topic, "hello", qos, retain +end + +function register_hook() + return "on_message_publish" +end +``` + +Execute following command to start emq-lua-hook and load scripts in 'data/scripts' directory. + +``` +/emqx/bin/emqx_ctl plugins load emqx_lua_hook +``` + +Now let's take a look at what will happend. + +- Start a mqtt client, such as mqtt.fx. +- Subscribe a topic="a/b". +- Send a message, topic="a/b", payload="123" +- Subscriber will get a message with topic="a/b" and payload="hello". test.lua modifies the payload. + +If there are "test1.lua", "test2.lua" and "test3.lua" in /emqx/data/scripts, all these files will be loaded once emq-lua-hook get started. + +If test2.lua has been changed, restart emq-lua-hook to reload all scripts, or execute following command to reload test2.lua only: + +``` +/emqx/bin/emqx_ctl luahook reload test2.lua +``` + + +# Hook API + +You can find all example codes in the `examples.lua` file. + +## on_client_connected + +```lua +function on_client_connected(clientId, userName, returncode) + return 0 +end +``` +This API is called after a mqtt client has establish a connection with broker. + +### Input +* clientid : a string, mqtt client id. +* username : a string mqtt username +* returncode : a string, has following values + - success : Connection accepted + - Others is failed reason + +### Output +Needless + +## on_client_disconnected + +```lua +function on_client_disconnected(clientId, username, error) + return +end +``` +This API is called after a mqtt client has disconnected. + +### Input +* clientId : a string, mqtt client id. +* username : a string mqtt username +* error : a string, denote the disconnection reason. + +### Output +Needless + +## on_client_subscribe + +```lua +function on_client_subscribe(clientId, username, topic) + -- do your job here + if some_condition then + return new_topic + else + return false + end +end +``` +This API is called before mqtt engine process client's subscribe command. It is possible to change topic or cancel it. + +### Input +* clientid : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt message's topic + +### Output +* new_topic : a string, change mqtt message's topic +* false : cancel subscription + + +## on_client_unsubscribe + +```lua + function on_client_unsubscribe(clientId, username, topic) + -- do your job here + if some_condition then + return new_topic + else + return false + end +end +``` +This API is called before mqtt engine process client's unsubscribe command. It is possible to change topic or cancel it. + +### Input +* clientid : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt message's topic + +### Output +* new_topic : a string, change mqtt message's topic +* false : cancel unsubscription + + +## on_session_subscribed + +```lua +function on_session_subscribed(ClientId, Username, Topic) + return +end +``` +This API is called after a subscription has been done. + +### Input +* clientid : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt's topic filter. + +### Output +Needless + + +## on_session_unsubscribed + +```lua +function on_session_unsubscribed(clientid, username, topic) + return +end +``` +This API is called after a unsubscription has been done. + +### Input +* clientid : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt's topic filter. + +### Output +Needless + +## on_message_delivered + +```lua +function on_message_delivered(clientid, username, topic, payload, qos, retain) + -- do your job here + return topic, payload, qos, retain +end +``` +This API is called after a message has been pushed to mqtt clients. + +### Input +* clientId : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt message's topic +* payload : a string, mqtt message's payload +* qos : a number, mqtt message's QOS (0, 1, 2) +* retain : a boolean, mqtt message's retain flag + +### Output +Needless + +## on_message_acked + +```lua +function on_message_acked(clientId, username, topic, payload, qos, retain) + return +end +``` +This API is called after a message has been acknowledged. + +### Input +* clientId : a string, mqtt client id. +* username : a string mqtt username +* topic : a string, mqtt message's topic +* payload : a string, mqtt message's payload +* qos : a number, mqtt message's QOS (0, 1, 2) +* retain : a boolean, mqtt message's retain flag + +### Output +Needless + +## on_message_publish + +```lua +function on_message_publish(clientid, username, topic, payload, qos, retain) + -- do your job here + if some_condition then + return new_topic, new_payload, new_qos, new_retain + else + return false + end +end +``` +This API is called before publishing message into mqtt engine. It's possible to change message or cancel publish in this API. + +### Input +* clientid : a string, mqtt client id of publisher. +* username : a string, mqtt username of publisher +* topic : a string, mqtt message's topic +* payload : a string, mqtt message's payload +* qos : a number, mqtt message's QOS (0, 1, 2) +* retain : a boolean, mqtt message's retain flag + +### Output +* new_topic : a string, change mqtt message's topic +* new_payload : a string, change mqtt message's payload +* new_qos : a number, change mqtt message's QOS +* new_retain : a boolean, change mqtt message's retain flag +* false : cancel publishing this mqtt message + +## register_hook + +```lua +function register_hook() + return "hook_name" +end + +-- Or register multiple callbacks + +function register_hook() + return "hook_name1", "hook_name2", ... , "hook_nameX" +end +``` + +This API exports hook(s) implemented in its lua script. + +### Output +* hook_name must be a string, which is equal to the hook API(s) implemented. Possible values: + - "on_client_connected" + - "on_client_disconnected" + - "on_client_subscribe" + - "on_client_unsubscribe" + - "on_session_subscribed" + - "on_session_unsubscribed" + - "on_message_delivered" + - "on_message_acked" + - "on_message_publish" + +# management command + +## load + +```shell +emqx_ctl luahook load script_name +``` +This command will load lua file "script_name" in 'data/scripts' directory, into emqx hook. + +## unload + +```shell +emqx_ctl luahook unload script_name +``` +This command will unload lua file "script_name" out of emqx hook. + +## reload + +```shell +emqx_ctl luahook reload script_name +``` +This command will reload lua file "script_name" in 'data/scripts'. It is useful if a lua script has been modified and apply it immediately. + +## enable + +```shell +emqx_ctl luahook enable script_name +``` +This command will rename lua file "script_name.x" to "script_name", and load it immediately. + +## disable + +```shell +emqx_ctl luahook disable script_name +``` +This command will unload this script, and rename lua file "script_name" to "script_name.x", which will not be loaded during next boot. + + +License +------- + +Apache License Version 2.0 + +Author +------ + +EMQ X Team. + diff --git a/apps/emqx_lua_hook/etc/emqx_lua_hook.conf b/apps/emqx_lua_hook/etc/emqx_lua_hook.conf new file mode 100644 index 000000000..f0256afae --- /dev/null +++ b/apps/emqx_lua_hook/etc/emqx_lua_hook.conf @@ -0,0 +1,4 @@ +##-------------------------------------------------------------------- +## EMQ X Lua Hook +##-------------------------------------------------------------------- + diff --git a/apps/emqx_lua_hook/examples.lua b/apps/emqx_lua_hook/examples.lua new file mode 100644 index 000000000..bc36eb771 --- /dev/null +++ b/apps/emqx_lua_hook/examples.lua @@ -0,0 +1,71 @@ +-- +-- Given all funcation names needed register to system +-- +function register_hook() + return "on_client_connected", + "on_client_disconnected", + "on_client_subscribe", + "on_client_unsubscribe", + "on_session_subscribed", + "on_session_unsubscribed", + "on_message_delivered", + "on_message_acked", + "on_message_publish" +end + +---------------------------------------------------------------------- +-- Callback Functions + +function on_client_connected(clientid, username, returncode) + print("Lua: on_client_connected - " .. clientid) + -- do your job here + return +end + +function on_client_disconnected(clientid, username, reason) + print("Lua: on_client_disconnected - " .. clientid) + -- do your job here + return +end + +function on_client_subscribe(clientid, username, topic) + print("Lua: on_client_subscribe - " .. clientid) + -- do your job here + return topic +end + +function on_client_unsubscribe(clientid, username, topic) + print("Lua: on_client_unsubscribe - " .. clientid) + -- do your job here + return topic +end + +function on_session_subscribed(clientid, username, topic) + print("Lua: on_session_subscribed - " .. clientid) + -- do your job here + return +end + +function on_session_unsubscribed(clientid, username, topic) + print("Lua: on_session_unsubscribed - " .. clientid) + -- do your job here + return +end + +function on_message_delivered(clientid, username, topic, payload, qos, retain) + print("Lua: on_message_delivered - " .. clientid) + -- do your job here + return topic, payload, qos, retain +end + +function on_message_acked(clientid, username, topic, payload, qos, retain) + print("Lua: on_message_acked- " .. clientid) + -- do your job here + return +end + +function on_message_publish(clientid, username, topic, payload, qos, retain) + print("Lua: on_message_publish - " .. clientid) + -- do your job here + return topic, payload, qos, retain +end diff --git a/apps/emqx_lua_hook/include/emqx_lua_hook.hrl b/apps/emqx_lua_hook/include/emqx_lua_hook.hrl new file mode 100644 index 000000000..94d7b7623 --- /dev/null +++ b/apps/emqx_lua_hook/include/emqx_lua_hook.hrl @@ -0,0 +1,18 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-define(LOG(Level, Format, Args), emqx_logger:Level("Lua Hook: " ++ Format, Args)). + diff --git a/apps/emqx_lua_hook/priv/emqx_lua_hook.schema b/apps/emqx_lua_hook/priv/emqx_lua_hook.schema new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/emqx_lua_hook/priv/emqx_lua_hook.schema @@ -0,0 +1 @@ + diff --git a/apps/emqx_lua_hook/rebar.config b/apps/emqx_lua_hook/rebar.config new file mode 100644 index 000000000..97a06a77c --- /dev/null +++ b/apps/emqx_lua_hook/rebar.config @@ -0,0 +1,21 @@ +{deps, + [{luerl, {git, "https://github.com/emqx/luerl", {tag, "v0.3.1"}}} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + debug_info, + compressed, + {parse_transform} + ]}. +{overrides, [{add, [{erl_opts, [compressed]}]}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_lua_hook/src/emqx_lua_hook.app.src b/apps/emqx_lua_hook/src/emqx_lua_hook.app.src new file mode 100644 index 000000000..627c8e29d --- /dev/null +++ b/apps/emqx_lua_hook/src/emqx_lua_hook.app.src @@ -0,0 +1,14 @@ +{application, emqx_lua_hook, + [{description, "EMQ X Lua Hooks"}, + {vsn, "4.3.0"}, % strict semver, bump manually! + {modules, []}, + {registered, []}, + {applications, [kernel,stdlib]}, + {mod, {emqx_lua_hook_app,[]}}, + {env,[]}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-lua-hook"} + ]} + ]}. diff --git a/apps/emqx_lua_hook/src/emqx_lua_hook.erl b/apps/emqx_lua_hook/src/emqx_lua_hook.erl new file mode 100644 index 000000000..40fd26ab7 --- /dev/null +++ b/apps/emqx_lua_hook/src/emqx_lua_hook.erl @@ -0,0 +1,199 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_lua_hook). + +-behaviour(gen_server). + +-include("emqx_lua_hook.hrl"). +-include_lib("luerl/src/luerl.hrl"). + +-export([ start_link/0 + , stop/0 + ]). + +-export([ load_scripts/0 + , unload_scripts/0 + , load_script/1 + , unload_script/1 + ]). + +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-export([lua_dir/0]). + +-define(SERVER, ?MODULE). + +-record(state, {loaded_scripts = []}). + +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, {}, []). + +stop() -> + gen_server:call(?SERVER, stop). + +load_scripts() -> + gen_server:call(?SERVER, load_scripts). + +unload_scripts() -> + gen_server:call(?SERVER, unload_scrips). + +load_script(ScriptName) -> + gen_server:call(?SERVER, {load_script, ScriptName}). + +unload_script(ScriptName) -> + gen_server:call(?SERVER, {unload_script, ScriptName}). + +lua_dir() -> + filename:join([emqx:get_env(data_dir, "data"), "scripts"]). + +%%----------------------------------------------------------------------------- +%% gen_server callbacks +%%----------------------------------------------------------------------------- + +init({}) -> + {ok, #state{}}. + +handle_call(stop, _From, State) -> + {stop, normal, ok, State}; + +handle_call(load_scripts, _From, State) -> + {reply, ok, State#state{loaded_scripts = do_loadall()}, hibernate}; + +handle_call(unload_scrips, _From, State=#state{loaded_scripts = Scripts}) -> + do_unloadall(Scripts), + {reply, ok, State#state{loaded_scripts = []}, hibernate}; + +handle_call({load_script, ScriptName}, _From, State=#state{loaded_scripts = Scripts}) -> + {Ret, NewScripts} = case do_load(ScriptName) of + error -> {error, Scripts}; + {ScriptName, LuaState} -> + case lists:member({ScriptName, LuaState}, Scripts) of + true -> {ok, Scripts}; + false -> {ok, lists:append([{ScriptName, LuaState}], Scripts)} + end + end, + {reply, Ret, State#state{loaded_scripts = NewScripts}, hibernate}; + +handle_call({unload_script, ScriptName}, _From, State=#state{loaded_scripts = Scripts}) -> + case proplists:get_all_values(ScriptName, Scripts) of + [] -> + {reply, ok, State, hibernate}; + LuaStates -> + lists:foreach(fun(LuaState) -> + % Unload first! If this gen_server has been crashed, loaded_scripts will be empty + do_unload({ScriptName, LuaState}) + end, LuaStates), + NewScripts = proplists:delete(ScriptName, Scripts), + {reply, ok, State#state{loaded_scripts = NewScripts}, hibernate} + end; + +handle_call(Request, From, State) -> + ?LOG(error, "Unknown Request=~p from ~p", [Request, From]), + {reply, ignored, State, hibernate}. + +handle_cast(Msg, State) -> + ?LOG(error, "unexpected cast: ~p", [Msg]), + {noreply, State, hibernate}. + +handle_info(Info, State) -> + ?LOG(error, "unexpected info: ~p", [Info]), + {noreply, State}. + +terminate(_Reason, #state{loaded_scripts = Scripts}) -> + do_unloadall(Scripts), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%% ------------------------------------------------------------------ +%% Internal Function Definitions +%% ------------------------------------------------------------------ + +do_loadall() -> + FileList = filelib:wildcard(filename:join([lua_dir(), "*.lua"])), + List = [do_load(X) || X <- FileList], + [X || X <- List, is_tuple(X)]. + +do_load(FileName) -> + case catch luerl:dofile(FileName) of + {'EXIT', St00} -> + ?LOG(error, "Failed to load lua script ~p due to error ~p", [FileName, St00]), + error; + {_Ret, St0=#luerl{}} -> + case catch luerl:call_function([register_hook], [], St0) of + {'EXIT', St1} -> + ?LOG(error, "Failed to execute register_hook function in lua script ~p, which has syntax error, St1=~p", [FileName, St1]), + error; + {Ret1, St1} -> + ?LOG(debug, "Register lua script ~p", [FileName]), + _ = do_register_hooks(Ret1, FileName, St1), + {FileName, St1}; + Other -> + ?LOG(error, "Failed to load lua script ~p, register_hook() raise exception ~p", [FileName, Other]), + error + end; + Exception -> + ?LOG(error, "Failed to load lua script ~p with error ~p", [FileName, Exception]), + error + end. + +do_register(<<"on_message_publish">>, ScriptName, St) -> + emqx_lua_script:register_on_message_publish(ScriptName, St); +do_register(<<"on_message_delivered">>, ScriptName, St) -> + emqx_lua_script:register_on_message_delivered(ScriptName, St); +do_register(<<"on_message_acked">>, ScriptName, St) -> + emqx_lua_script:register_on_message_acked(ScriptName, St); +do_register(<<"on_client_connected">>, ScriptName, St) -> + emqx_lua_script:register_on_client_connected(ScriptName, St); +do_register(<<"on_client_subscribe">>, ScriptName, St) -> + emqx_lua_script:register_on_client_subscribe(ScriptName, St); +do_register(<<"on_client_unsubscribe">>, ScriptName, St) -> + emqx_lua_script:register_on_client_unsubscribe(ScriptName, St); +do_register(<<"on_client_disconnected">>, ScriptName, St) -> + emqx_lua_script:register_on_client_disconnected(ScriptName, St); +do_register(<<"on_session_subscribed">>, ScriptName, St) -> + emqx_lua_script:register_on_session_subscribed(ScriptName, St); +do_register(<<"on_client_authenticate">>, ScriptName, St) -> + emqx_lua_script:register_on_client_authenticate(ScriptName, St); +do_register(<<"on_client_check_acl">>, ScriptName, St) -> + emqx_lua_script:register_on_client_check_acl(ScriptName, St); +do_register(Hook, ScriptName, _St) -> + ?LOG(error, "Discard unknown hook ~p ScriptName=~p", [Hook, ScriptName]). + +do_register_hooks([], _ScriptName, _St) -> + ok; +do_register_hooks([H|T], ScriptName, St) -> + _ = do_register(H, ScriptName, St), + do_register_hooks(T, ScriptName, St); +do_register_hooks(Hook = <<$o, $n, _Rest/binary>>, ScriptName, St) -> + do_register(Hook, ScriptName, St); +do_register_hooks(Hook, ScriptName, _St) -> + ?LOG(error, "Discard unknown hook type ~p from ~p", [Hook, ScriptName]). + +do_unloadall(Scripts) -> + lists:foreach(fun do_unload/1, Scripts). + +do_unload(Script) -> + emqx_lua_script:unregister_hooks(Script), + ok. diff --git a/apps/emqx_lua_hook/src/emqx_lua_hook_app.erl b/apps/emqx_lua_hook/src/emqx_lua_hook_app.erl new file mode 100644 index 000000000..076c74031 --- /dev/null +++ b/apps/emqx_lua_hook/src/emqx_lua_hook_app.erl @@ -0,0 +1,40 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_lua_hook_app). + +-behaviour(application). + +-emqx_plugin(?MODULE). + +-export([ start/2 + , stop/1 + , prep_stop/1 + ]). + +start(_Type, _Args) -> + {ok, Sup} = emqx_lua_hook_sup:start_link(), + emqx_lua_hook:load_scripts(), + emqx_lua_hook_cli:load(), + {ok, Sup}. + +prep_stop(State) -> + emqx_lua_hook:unload_scripts(), + emqx_lua_hook_cli:unload(), + State. + +stop(_State) -> + ok. diff --git a/apps/emqx_lua_hook/src/emqx_lua_hook_cli.erl b/apps/emqx_lua_hook/src/emqx_lua_hook_cli.erl new file mode 100644 index 000000000..3839b01c9 --- /dev/null +++ b/apps/emqx_lua_hook/src/emqx_lua_hook_cli.erl @@ -0,0 +1,88 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_lua_hook_cli). + +-export([ load/0 + , cmd/1 + , unload/0 + ]). + +-include("emqx_lua_hook.hrl"). +-include_lib("luerl/src/luerl.hrl"). + +-define(PRINT(Format, Args), io:format(Format, Args)). +-define(PRINT_CMD(Cmd, Descr), io:format("~-48s# ~s~n", [Cmd, Descr])). +-define(USAGE(CmdList), [?PRINT_CMD(Cmd, Descr) || {Cmd, Descr} <- CmdList]). + +load() -> + emqx_ctl:register_command(luahook, {?MODULE, cmd}, []). + +unload() -> + emqx_ctl:unregister_command(luahook). + +cmd(["load", Script]) -> + case emqx_lua_hook:load_script(fullname(Script)) of + ok -> emqx_ctl:print("Load ~p successfully~n", [Script]); + error -> emqx_ctl:print("Load ~p error~n", [Script]) + end; + +cmd(["reload", Script]) -> + FullName = fullname(Script), + emqx_lua_hook:unload_script(FullName), + case emqx_lua_hook:load_script(FullName) of + ok -> emqx_ctl:print("Reload ~p successfully~n", [Script]); + error -> emqx_ctl:print("Reload ~p error~n", [Script]) + end; + +cmd(["unload", Script]) -> + emqx_lua_hook:unload_script(fullname(Script)), + emqx_ctl:print("Unload ~p successfully~n", [Script]); + +cmd(["enable", Script]) -> + FullName = fullname(Script), + case file:rename(fullnamedisable(Script), FullName) of + ok -> case emqx_lua_hook:load_script(FullName) of + ok -> + emqx_ctl:print("Enable ~p successfully~n", [Script]); + error -> + emqx_ctl:print("Fail to enable ~p~n", [Script]) + end; + {error, Reason} -> + emqx_ctl:print("Fail to enable ~p due to ~p~n", [Script, Reason]) + end; + +cmd(["disable", Script]) -> + FullName = fullname(Script), + emqx_lua_hook:unload_script(FullName), + case file:rename(FullName, fullnamedisable(Script)) of + ok -> + emqx_ctl:print("Disable ~p successfully~n", [Script]); + {error, Reason} -> + emqx_ctl:print("Fail to disable ~p due to ~p~n", [Script, Reason]) + end; + +cmd(_) -> + emqx_ctl:usage([{"luahook load