From 0fb5fb31a56183554e8766db9e5abe394145151e Mon Sep 17 00:00:00 2001 From: Zaiming Shi Date: Sat, 5 Dec 2020 02:43:04 +0100 Subject: [PATCH] refactor(proj) sync 4.3.0 plugins with tracked files --- Makefile | 2 +- apps/emqx_auth_http/rebar.config | 28 +- apps/emqx_auth_jwt/rebar.config | 21 + apps/emqx_auth_ldap/.ci/docker-compose.yml | 26 - apps/emqx_auth_ldap/.ci/emqx-ldap/Dockerfile | 26 - apps/emqx_auth_ldap/.ci/emqx-ldap/slapd.conf | 16 - apps/emqx_auth_ldap/rebar.config | 22 + apps/emqx_auth_mongo/rebar.config | 32 +- apps/emqx_auth_mysql/rebar.config | 30 + apps/emqx_auth_pgsql/.ci/docker-compose.yml | 30 - apps/emqx_auth_pgsql/.ci/pgsql/Dockerfile | 8 - apps/emqx_auth_pgsql/rebar.config | 30 +- apps/emqx_auth_redis/.ci/docker-compose.yml | 26 - .../emqx_auth_redis/.ci/emqx-redis/Dockerfile | 4 - .../emqx_auth_redis/.ci/emqx-redis/redis.conf | 1377 ----------------- .../priv/emqx_auth_redis.schema | 35 +- apps/emqx_auth_redis/rebar.config | 32 +- .../src/emqx_auth_redis_cli.erl | 14 +- .../src/emqx_auth_redis_sup.erl | 5 +- apps/emqx_bridge_mqtt/rebar.config | 28 +- .../src/emqx_bridge_mqtt_actions.erl | 2 +- apps/emqx_coap/rebar.config | 26 +- apps/emqx_dashboard/rebar.config | 24 +- apps/emqx_exhook/.gitignore | 3 - apps/emqx_exhook/README.md | 76 +- apps/emqx_exhook/docs/design.md | 329 ++-- apps/emqx_exhook/rebar.config | 25 +- apps/emqx_exproto/.gitignore | 5 - apps/emqx_exproto/README.md | 48 +- apps/emqx_exproto/docs/design.md | 219 ++- .../emqx_exproto/docs/images/exproto-arch.jpg | Bin 72633 -> 85466 bytes apps/emqx_exproto/include/emqx_exproto.hrl | 13 - apps/emqx_exproto/priv/emqx_exproto.schema | 90 +- apps/emqx_exproto/rebar.config | 29 +- apps/emqx_exproto/src/emqx_exproto.app.src | 8 +- apps/emqx_exproto/src/emqx_exproto.erl | 155 +- apps/emqx_exproto/src/emqx_exproto_app.erl | 3 +- .../emqx_exproto/src/emqx_exproto_channel.erl | 479 ++---- apps/emqx_exproto/src/emqx_exproto_conn.erl | 39 +- apps/emqx_exproto/src/emqx_exproto_gcli.erl | 110 -- apps/emqx_exproto/src/emqx_exproto_gsvr.erl | 154 -- apps/emqx_exproto/src/emqx_exproto_sup.erl | 66 +- apps/emqx_exproto/test/emqx_exproto_SUITE.erl | 326 +--- apps/emqx_lua_hook/rebar.config | 2 +- apps/emqx_lwm2m/rebar.config | 4 +- apps/emqx_management/src/emqx_mgmt.erl | 4 +- .../src/emqx_mgmt_api_data.erl | 139 +- apps/emqx_management/src/emqx_mgmt_cli.erl | 1 + .../test/emqx_mgmt_api_SUITE.erl | 54 +- apps/emqx_passwd/rebar.config | 8 +- apps/emqx_passwd/src/emqx_passwd.app.src | 4 +- apps/emqx_prometheus/rebar.config | 2 +- apps/emqx_psk_file/rebar.config | 2 +- apps/emqx_recon/rebar.config | 2 +- apps/emqx_retainer/rebar.config | 2 +- apps/emqx_rule_engine/rebar.config | 2 +- .../src/emqx_rule_actions.erl | 50 +- .../emqx_rule_engine/src/emqx_rule_engine.erl | 2 +- apps/emqx_rule_engine/src/emqx_rule_funcs.erl | 23 +- .../src/emqx_rule_validator.erl | 7 +- .../test/emqx_rule_engine_SUITE.erl | 8 +- .../test/emqx_rule_funcs_SUITE.erl | 17 +- apps/emqx_sasl/rebar.config | 2 +- apps/emqx_sasl/src/emqx_sasl_api.erl | 4 +- apps/emqx_sasl/src/emqx_sasl_cli.erl | 2 +- apps/emqx_sasl/test/emqx_sasl_scram_SUITE.erl | 61 + apps/emqx_sn/rebar.config | 2 +- apps/emqx_sn/src/emqx_sn_gateway.erl | 8 +- apps/emqx_stomp/rebar.config | 2 +- apps/emqx_telemetry/rebar.config | 2 +- apps/emqx_web_hook/rebar.config | 2 +- .../src/emqx_web_hook_actions.erl | 81 +- rebar.config | 2 +- 73 files changed, 1431 insertions(+), 3091 deletions(-) delete mode 100644 apps/emqx_auth_ldap/.ci/docker-compose.yml delete mode 100644 apps/emqx_auth_ldap/.ci/emqx-ldap/Dockerfile delete mode 100644 apps/emqx_auth_ldap/.ci/emqx-ldap/slapd.conf delete mode 100644 apps/emqx_auth_pgsql/.ci/docker-compose.yml delete mode 100644 apps/emqx_auth_pgsql/.ci/pgsql/Dockerfile delete mode 100644 apps/emqx_auth_redis/.ci/docker-compose.yml delete mode 100644 apps/emqx_auth_redis/.ci/emqx-redis/Dockerfile delete mode 100644 apps/emqx_auth_redis/.ci/emqx-redis/redis.conf delete mode 100644 apps/emqx_exhook/.gitignore delete mode 100644 apps/emqx_exproto/.gitignore delete mode 100644 apps/emqx_exproto/src/emqx_exproto_gcli.erl delete mode 100644 apps/emqx_exproto/src/emqx_exproto_gsvr.erl diff --git a/Makefile b/Makefile index 26c04b277..557ecadfc 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ PROFILE ?= emqx PROFILES := emqx emqx-edge PKG_PROFILES := emqx-pkg emqx-edge-pkg -export REBAR_GIT_CLONE_OPTIONS=--depth=1 +export REBAR_GIT_CLONE_OPTIONS += --depth=1 .PHONY: default default: $(REBAR) $(PROFILE) diff --git a/apps/emqx_auth_http/rebar.config b/apps/emqx_auth_http/rebar.config index 7b1f06cca..705557fe5 100644 --- a/apps/emqx_auth_http/rebar.config +++ b/apps/emqx_auth_http/rebar.config @@ -1 +1,27 @@ -{deps, []}. \ No newline at end of file +{deps, []}. + +{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, "1.2.0"}}} + ]} + ]} + ]}. + diff --git a/apps/emqx_auth_jwt/rebar.config b/apps/emqx_auth_jwt/rebar.config index 0728a0b95..f711075ba 100644 --- a/apps/emqx_auth_jwt/rebar.config +++ b/apps/emqx_auth_jwt/rebar.config @@ -1,3 +1,24 @@ {deps, [{jwerl, {git, "https://github.com/emqx/jwerl.git", {branch, "1.1.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_ldap/.ci/docker-compose.yml b/apps/emqx_auth_ldap/.ci/docker-compose.yml deleted file mode 100644 index bba9b711f..000000000 --- a/apps/emqx_auth_ldap/.ci/docker-compose.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: '3' - -services: - erlang: - image: erlang:22.1 - volumes: - - ../:/emqx_auth_ldap - networks: - - emqx_bridge - depends_on: - - ldap_server - tty: true - - ldap_server: - build: ./emqx-ldap - image: emqx-ldap:1.0 - restart: always - ports: - - 389:389 - - 636:636 - networks: - - emqx_bridge - -networks: - emqx_bridge: - driver: bridge diff --git a/apps/emqx_auth_ldap/.ci/emqx-ldap/Dockerfile b/apps/emqx_auth_ldap/.ci/emqx-ldap/Dockerfile deleted file mode 100644 index 0a01572c4..000000000 --- a/apps/emqx_auth_ldap/.ci/emqx-ldap/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM buildpack-deps:stretch - -ENV VERSION=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-${VERSION}.tgz \ - && gunzip -c openldap-${VERSION}.tgz | tar xvfB - \ - && cd openldap-${VERSION} \ - && ./configure && make depend && make && make install \ - && cd .. && rm -rf openldap-${VERSION} - -COPY ./slapd.conf /usr/local/etc/openldap/slapd.conf -COPY ./emqx.io.ldif /usr/local/etc/openldap/schema/emqx.io.ldif -COPY ./emqx.schema /usr/local/etc/openldap/schema/emqx.schema -COPY ./*.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/apps/emqx_auth_ldap/.ci/emqx-ldap/slapd.conf b/apps/emqx_auth_ldap/.ci/emqx-ldap/slapd.conf deleted file mode 100644 index d6ba20caa..000000000 --- a/apps/emqx_auth_ldap/.ci/emqx-ldap/slapd.conf +++ /dev/null @@ -1,16 +0,0 @@ -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/apps/emqx_auth_ldap/rebar.config b/apps/emqx_auth_ldap/rebar.config index 8fe128ac5..48eaf812f 100644 --- a/apps/emqx_auth_ldap/rebar.config +++ b/apps/emqx_auth_ldap/rebar.config @@ -1,3 +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_mongo/rebar.config b/apps/emqx_auth_mongo/rebar.config index f913dd272..4c9417195 100644 --- a/apps/emqx_auth_mongo/rebar.config +++ b/apps/emqx_auth_mongo/rebar.config @@ -1,2 +1,32 @@ {deps, - [{mongodb, {git,"https://github.com/emqx/mongodb-erlang", {tag, "v3.0.7"}}}]}. \ No newline at end of file + [{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}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + ]}, + {erl_opts, [debug_info]} + ]} + ]}. diff --git a/apps/emqx_auth_mysql/rebar.config b/apps/emqx_auth_mysql/rebar.config index bc21ad1b6..18841d104 100644 --- a/apps/emqx_auth_mysql/rebar.config +++ b/apps/emqx_auth_mysql/rebar.config @@ -2,3 +2,33 @@ [ {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}. + +{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, "1.2.0"}}} + ]}, + {erl_opts, [debug_info]} + ]} + ]}. diff --git a/apps/emqx_auth_pgsql/.ci/docker-compose.yml b/apps/emqx_auth_pgsql/.ci/docker-compose.yml deleted file mode 100644 index 8782a841d..000000000 --- a/apps/emqx_auth_pgsql/.ci/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: '3' - -services: - erlang: - image: erlang:22.3 - volumes: - - ../:/emqx_auth_pgsql - networks: - - emqx_bridge - depends_on: - - pgsql_server - tty: true - - pgsql_server: - build: - context: ./pgsql - args: - BUILD_FROM: postgres:${PGSQL_TAG} - image: emqx-pgsql - restart: always - environment: - POSTGRES_PASSWORD: public - POSTGRES_USER: root - POSTGRES_DB: mqtt - networks: - - emqx_bridge - -networks: - emqx_bridge: - driver: bridge diff --git a/apps/emqx_auth_pgsql/.ci/pgsql/Dockerfile b/apps/emqx_auth_pgsql/.ci/pgsql/Dockerfile deleted file mode 100644 index 785bb875f..000000000 --- a/apps/emqx_auth_pgsql/.ci/pgsql/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -ARG BUILD_FROM=postgres:11 -FROM ${BUILD_FROM} -COPY pg.conf /etc/postgresql/postgresql.conf -COPY server-cert.pem /etc/postgresql/server-cert.pem -COPY server-key.pem /etc/postgresql/server-key.pem -RUN chown -R postgres:postgres /etc/postgresql \ - && chmod 600 /etc/postgresql/*.pem -CMD ["-c", "config_file=/etc/postgresql/postgresql.conf"] diff --git a/apps/emqx_auth_pgsql/rebar.config b/apps/emqx_auth_pgsql/rebar.config index a56845649..20f84fa83 100644 --- a/apps/emqx_auth_pgsql/rebar.config +++ b/apps/emqx_auth_pgsql/rebar.config @@ -1,3 +1,31 @@ {deps, [{epgsql, {git, "https://github.com/epgsql/epgsql", {tag, "4.4.0"}}} - ]}. \ No newline at end of file + ]}. + +{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}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {branch, "1.2.2"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + ]}, + {erl_opts, [debug_info]} + ]} + ]}. diff --git a/apps/emqx_auth_redis/.ci/docker-compose.yml b/apps/emqx_auth_redis/.ci/docker-compose.yml deleted file mode 100644 index 27794546e..000000000 --- a/apps/emqx_auth_redis/.ci/docker-compose.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: '3' - -services: - erlang: - image: erlang:22.1 - volumes: - - ../:/emqx_auth_redis - networks: - - emqx_bridge - depends_on: - - redis_server - tty: true - - redis_server: - build: - context: ./emqx-redis - args: - BUILD_FROM: redis:${REDIS_TAG} - image: emqx-redis - restart: always - networks: - - emqx_bridge - -networks: - emqx_bridge: - driver: bridge diff --git a/apps/emqx_auth_redis/.ci/emqx-redis/Dockerfile b/apps/emqx_auth_redis/.ci/emqx-redis/Dockerfile deleted file mode 100644 index 9fe51e86e..000000000 --- a/apps/emqx_auth_redis/.ci/emqx-redis/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -ARG BUILD_FROM=redis:5 -FROM ${BUILD_FROM} -COPY redis.conf /usr/local/etc/redis/redis.conf -CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] diff --git a/apps/emqx_auth_redis/.ci/emqx-redis/redis.conf b/apps/emqx_auth_redis/.ci/emqx-redis/redis.conf deleted file mode 100644 index 8415f9d5f..000000000 --- a/apps/emqx_auth_redis/.ci/emqx-redis/redis.conf +++ /dev/null @@ -1,1377 +0,0 @@ -# Redis configuration file example. -# -# Note that in order to read the configuration file, Redis must be -# started with the file path as first argument: -# -# ./redis-server /path/to/redis.conf - -# Note on units: when memory size is needed, it is possible to specify -# it in the usual form of 1k 5GB 4M and so forth: -# -# 1k => 1000 bytes -# 1kb => 1024 bytes -# 1m => 1000000 bytes -# 1mb => 1024*1024 bytes -# 1g => 1000000000 bytes -# 1gb => 1024*1024*1024 bytes -# -# units are case insensitive so 1GB 1Gb 1gB are all the same. - -################################## INCLUDES ################################### - -# Include one or more other config files here. This is useful if you -# have a standard template that goes to all Redis servers but also need -# to customize a few per-server settings. Include files can include -# other files, so use this wisely. -# -# Notice option "include" won't be rewritten by command "CONFIG REWRITE" -# from admin or Redis Sentinel. Since Redis always uses the last processed -# line as value of a configuration directive, you'd better put includes -# at the beginning of this file to avoid overwriting config change at runtime. -# -# If instead you are interested in using includes to override configuration -# options, it is better to use include as the last line. -# -# include /path/to/local.conf -# include /path/to/other.conf - -################################## MODULES ##################################### - -# Load modules at startup. If the server is not able to load modules -# it will abort. It is possible to use multiple loadmodule directives. -# -# loadmodule /path/to/my_module.so -# loadmodule /path/to/other_module.so - -################################## NETWORK ##################################### - -# By default, if no "bind" configuration directive is specified, Redis listens -# for connections from all the network interfaces available on the server. -# It is possible to listen to just one or multiple selected interfaces using -# the "bind" configuration directive, followed by one or more IP addresses. -# -# Examples: -# -# bind 192.168.1.100 10.0.0.1 -# bind 127.0.0.1 ::1 -# -# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the -# internet, binding to all the interfaces is dangerous and will expose the -# instance to everybody on the internet. So by default we uncomment the -# following bind directive, that will force Redis to listen only into -# the IPv4 loopback interface address (this means Redis will be able to -# accept connections only from clients running into the same computer it -# is running). -# -# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES -# JUST COMMENT THE FOLLOWING LINE. -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -bind 0.0.0.0 :: - -# Protected mode is a layer of security protection, in order to avoid that -# Redis instances left open on the internet are accessed and exploited. -# -# When protected mode is on and if: -# -# 1) The server is not binding explicitly to a set of addresses using the -# "bind" directive. -# 2) No password is configured. -# -# The server only accepts connections from clients connecting from the -# IPv4 and IPv6 loopback addresses 127.0.0.1 and ::1, and from Unix domain -# sockets. -# -# By default protected mode is enabled. You should disable it only if -# you are sure you want clients from other hosts to connect to Redis -# even if no authentication is configured, nor a specific set of interfaces -# are explicitly listed using the "bind" directive. -protected-mode no - -# Accept connections on the specified port, default is 6379 (IANA #815344). -# If port 0 is specified Redis will not listen on a TCP socket. -port 6379 - -# TCP listen() backlog. -# -# In high requests-per-second environments you need an high backlog in order -# to avoid slow clients connections issues. Note that the Linux kernel -# will silently truncate it to the value of /proc/sys/net/core/somaxconn so -# make sure to raise both the value of somaxconn and tcp_max_syn_backlog -# in order to get the desired effect. -tcp-backlog 511 - -# Unix socket. -# -# Specify the path for the Unix socket that will be used to listen for -# incoming connections. There is no default, so Redis will not listen -# on a unix socket when not specified. -# -# unixsocket /tmp/redis.sock -# unixsocketperm 700 - -# Close the connection after a client is idle for N seconds (0 to disable) -timeout 0 - -# TCP keepalive. -# -# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence -# of communication. This is useful for two reasons: -# -# 1) Detect dead peers. -# 2) Take the connection alive from the point of view of network -# equipment in the middle. -# -# On Linux, the specified value (in seconds) is the period used to send ACKs. -# Note that to close the connection the double of the time is needed. -# On other kernels the period depends on the kernel configuration. -# -# A reasonable value for this option is 300 seconds, which is the new -# Redis default starting with Redis 3.2.1. -tcp-keepalive 300 - -################################# GENERAL ##################################### - -# By default Redis does not run as a daemon. Use 'yes' if you need it. -# Note that Redis will write a pid file in /var/run/redis.pid when daemonized. -daemonize no - -# If you run Redis from upstart or systemd, Redis can interact with your -# supervision tree. Options: -# supervised no - no supervision interaction -# supervised upstart - signal upstart by putting Redis into SIGSTOP mode -# supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET -# supervised auto - detect upstart or systemd method based on -# UPSTART_JOB or NOTIFY_SOCKET environment variables -# Note: these supervision methods only signal "process is ready." -# They do not enable continuous liveness pings back to your supervisor. -supervised no - -# If a pid file is specified, Redis writes it where specified at startup -# and removes it at exit. -# -# When the server runs non daemonized, no pid file is created if none is -# specified in the configuration. When the server is daemonized, the pid file -# is used even if not specified, defaulting to "/var/run/redis.pid". -# -# Creating a pid file is best effort: if Redis is not able to create it -# nothing bad happens, the server will start and run normally. -pidfile /var/run/redis_6379.pid - -# Specify the server verbosity level. -# This can be one of: -# debug (a lot of information, useful for development/testing) -# verbose (many rarely useful info, but not a mess like the debug level) -# notice (moderately verbose, what you want in production probably) -# warning (only very important / critical messages are logged) -loglevel notice - -# Specify the log file name. Also the empty string can be used to force -# Redis to log on the standard output. Note that if you use standard -# output for logging but daemonize, logs will be sent to /dev/null -logfile "" - -# To enable logging to the system logger, just set 'syslog-enabled' to yes, -# and optionally update the other syslog parameters to suit your needs. -# syslog-enabled no - -# Specify the syslog identity. -# syslog-ident redis - -# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. -# syslog-facility local0 - -# Set the number of databases. The default database is DB 0, you can select -# a different one on a per-connection basis using SELECT where -# dbid is a number between 0 and 'databases'-1 -databases 16 - -# By default Redis shows an ASCII art logo only when started to log to the -# standard output and if the standard output is a TTY. Basically this means -# that normally a logo is displayed only in interactive sessions. -# -# However it is possible to force the pre-4.0 behavior and always show a -# ASCII art logo in startup logs by setting the following option to yes. -always-show-logo yes - -################################ SNAPSHOTTING ################################ -# -# Save the DB on disk: -# -# save -# -# Will save the DB if both the given number of seconds and the given -# number of write operations against the DB occurred. -# -# In the example below the behaviour will be to save: -# after 900 sec (15 min) if at least 1 key changed -# after 300 sec (5 min) if at least 10 keys changed -# after 60 sec if at least 10000 keys changed -# -# Note: you can disable saving completely by commenting out all "save" lines. -# -# It is also possible to remove all the previously configured save -# points by adding a save directive with a single empty string argument -# like in the following example: -# -# save "" - -save 900 1 -save 300 10 -save 60 10000 - -# By default Redis will stop accepting writes if RDB snapshots are enabled -# (at least one save point) and the latest background save failed. -# This will make the user aware (in a hard way) that data is not persisting -# on disk properly, otherwise chances are that no one will notice and some -# disaster will happen. -# -# If the background saving process will start working again Redis will -# automatically allow writes again. -# -# However if you have setup your proper monitoring of the Redis server -# and persistence, you may want to disable this feature so that Redis will -# continue to work as usual even if there are problems with disk, -# permissions, and so forth. -stop-writes-on-bgsave-error yes - -# Compress string objects using LZF when dump .rdb databases? -# For default that's set to 'yes' as it's almost always a win. -# If you want to save some CPU in the saving child set it to 'no' but -# the dataset will likely be bigger if you have compressible values or keys. -rdbcompression yes - -# Since version 5 of RDB a CRC64 checksum is placed at the end of the file. -# This makes the format more resistant to corruption but there is a performance -# hit to pay (around 10%) when saving and loading RDB files, so you can disable it -# for maximum performances. -# -# RDB files created with checksum disabled have a checksum of zero that will -# tell the loading code to skip the check. -rdbchecksum yes - -# The filename where to dump the DB -dbfilename dump.rdb - -# The working directory. -# -# The DB will be written inside this directory, with the filename specified -# above using the 'dbfilename' configuration directive. -# -# The Append Only File will also be created inside this directory. -# -# Note that you must specify a directory here, not a file name. -dir ./ - -################################# REPLICATION ################################# - -# Master-Replica replication. Use replicaof to make a Redis instance a copy of -# another Redis server. A few things to understand ASAP about Redis replication. -# -# +------------------+ +---------------+ -# | Master | ---> | Replica | -# | (receive writes) | | (exact copy) | -# +------------------+ +---------------+ -# -# 1) Redis replication is asynchronous, but you can configure a master to -# stop accepting writes if it appears to be not connected with at least -# a given number of replicas. -# 2) Redis replicas are able to perform a partial resynchronization with the -# master if the replication link is lost for a relatively small amount of -# time. You may want to configure the replication backlog size (see the next -# sections of this file) with a sensible value depending on your needs. -# 3) Replication is automatic and does not need user intervention. After a -# network partition replicas automatically try to reconnect to masters -# and resynchronize with them. -# -# replicaof - -# If the master is password protected (using the "requirepass" configuration -# directive below) it is possible to tell the replica to authenticate before -# starting the replication synchronization process, otherwise the master will -# refuse the replica request. -# -# masterauth - -# When a replica loses its connection with the master, or when the replication -# is still in progress, the replica can act in two different ways: -# -# 1) if replica-serve-stale-data is set to 'yes' (the default) the replica will -# still reply to client requests, possibly with out of date data, or the -# data set may just be empty if this is the first synchronization. -# -# 2) if replica-serve-stale-data is set to 'no' the replica will reply with -# an error "SYNC with master in progress" to all the kind of commands -# but to INFO, replicaOF, AUTH, PING, SHUTDOWN, REPLCONF, ROLE, CONFIG, -# SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB, -# COMMAND, POST, HOST: and LATENCY. -# -replica-serve-stale-data yes - -# You can configure a replica instance to accept writes or not. Writing against -# a replica instance may be useful to store some ephemeral data (because data -# written on a replica will be easily deleted after resync with the master) but -# may also cause problems if clients are writing to it because of a -# misconfiguration. -# -# Since Redis 2.6 by default replicas are read-only. -# -# Note: read only replicas are not designed to be exposed to untrusted clients -# on the internet. It's just a protection layer against misuse of the instance. -# Still a read only replica exports by default all the administrative commands -# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve -# security of read only replicas using 'rename-command' to shadow all the -# administrative / dangerous commands. -replica-read-only yes - -# Replication SYNC strategy: disk or socket. -# -# ------------------------------------------------------- -# WARNING: DISKLESS REPLICATION IS EXPERIMENTAL CURRENTLY -# ------------------------------------------------------- -# -# New replicas and reconnecting replicas that are not able to continue the replication -# process just receiving differences, need to do what is called a "full -# synchronization". An RDB file is transmitted from the master to the replicas. -# The transmission can happen in two different ways: -# -# 1) Disk-backed: The Redis master creates a new process that writes the RDB -# file on disk. Later the file is transferred by the parent -# process to the replicas incrementally. -# 2) Diskless: The Redis master creates a new process that directly writes the -# RDB file to replica sockets, without touching the disk at all. -# -# With disk-backed replication, while the RDB file is generated, more replicas -# can be queued and served with the RDB file as soon as the current child producing -# the RDB file finishes its work. With diskless replication instead once -# the transfer starts, new replicas arriving will be queued and a new transfer -# will start when the current one terminates. -# -# When diskless replication is used, the master waits a configurable amount of -# time (in seconds) before starting the transfer in the hope that multiple replicas -# will arrive and the transfer can be parallelized. -# -# With slow disks and fast (large bandwidth) networks, diskless replication -# works better. -repl-diskless-sync no - -# When diskless replication is enabled, it is possible to configure the delay -# the server waits in order to spawn the child that transfers the RDB via socket -# to the replicas. -# -# This is important since once the transfer starts, it is not possible to serve -# new replicas arriving, that will be queued for the next RDB transfer, so the server -# waits a delay in order to let more replicas arrive. -# -# The delay is specified in seconds, and by default is 5 seconds. To disable -# it entirely just set it to 0 seconds and the transfer will start ASAP. -repl-diskless-sync-delay 5 - -# Replicas send PINGs to server in a predefined interval. It's possible to change -# this interval with the repl_ping_replica_period option. The default value is 10 -# seconds. -# -# repl-ping-replica-period 10 - -# The following option sets the replication timeout for: -# -# 1) Bulk transfer I/O during SYNC, from the point of view of replica. -# 2) Master timeout from the point of view of replicas (data, pings). -# 3) Replica timeout from the point of view of masters (REPLCONF ACK pings). -# -# It is important to make sure that this value is greater than the value -# specified for repl-ping-replica-period otherwise a timeout will be detected -# every time there is low traffic between the master and the replica. -# -# repl-timeout 60 - -# Disable TCP_NODELAY on the replica socket after SYNC? -# -# If you select "yes" Redis will use a smaller number of TCP packets and -# less bandwidth to send data to replicas. But this can add a delay for -# the data to appear on the replica side, up to 40 milliseconds with -# Linux kernels using a default configuration. -# -# If you select "no" the delay for data to appear on the replica side will -# be reduced but more bandwidth will be used for replication. -# -# By default we optimize for low latency, but in very high traffic conditions -# or when the master and replicas are many hops away, turning this to "yes" may -# be a good idea. -repl-disable-tcp-nodelay no - -# Set the replication backlog size. The backlog is a buffer that accumulates -# replica data when replicas are disconnected for some time, so that when a replica -# wants to reconnect again, often a full resync is not needed, but a partial -# resync is enough, just passing the portion of data the replica missed while -# disconnected. -# -# The bigger the replication backlog, the longer the time the replica can be -# disconnected and later be able to perform a partial resynchronization. -# -# The backlog is only allocated once there is at least a replica connected. -# -# repl-backlog-size 1mb - -# After a master has no longer connected replicas for some time, the backlog -# will be freed. The following option configures the amount of seconds that -# need to elapse, starting from the time the last replica disconnected, for -# the backlog buffer to be freed. -# -# Note that replicas never free the backlog for timeout, since they may be -# promoted to masters later, and should be able to correctly "partially -# resynchronize" with the replicas: hence they should always accumulate backlog. -# -# A value of 0 means to never release the backlog. -# -# repl-backlog-ttl 3600 - -# The replica priority is an integer number published by Redis in the INFO output. -# It is used by Redis Sentinel in order to select a replica to promote into a -# master if the master is no longer working correctly. -# -# A replica with a low priority number is considered better for promotion, so -# for instance if there are three replicas with priority 10, 100, 25 Sentinel will -# pick the one with priority 10, that is the lowest. -# -# However a special priority of 0 marks the replica as not able to perform the -# role of master, so a replica with priority of 0 will never be selected by -# Redis Sentinel for promotion. -# -# By default the priority is 100. -replica-priority 100 - -# It is possible for a master to stop accepting writes if there are less than -# N replicas connected, having a lag less or equal than M seconds. -# -# The N replicas need to be in "online" state. -# -# The lag in seconds, that must be <= the specified value, is calculated from -# the last ping received from the replica, that is usually sent every second. -# -# This option does not GUARANTEE that N replicas will accept the write, but -# will limit the window of exposure for lost writes in case not enough replicas -# are available, to the specified number of seconds. -# -# For example to require at least 3 replicas with a lag <= 10 seconds use: -# -# min-replicas-to-write 3 -# min-replicas-max-lag 10 -# -# Setting one or the other to 0 disables the feature. -# -# By default min-replicas-to-write is set to 0 (feature disabled) and -# min-replicas-max-lag is set to 10. - -# A Redis master is able to list the address and port of the attached -# replicas in different ways. For example the "INFO replication" section -# offers this information, which is used, among other tools, by -# Redis Sentinel in order to discover replica instances. -# Another place where this info is available is in the output of the -# "ROLE" command of a master. -# -# The listed IP and address normally reported by a replica is obtained -# in the following way: -# -# IP: The address is auto detected by checking the peer address -# of the socket used by the replica to connect with the master. -# -# Port: The port is communicated by the replica during the replication -# handshake, and is normally the port that the replica is using to -# listen for connections. -# -# However when port forwarding or Network Address Translation (NAT) is -# used, the replica may be actually reachable via different IP and port -# pairs. The following two options can be used by a replica in order to -# report to its master a specific set of IP and port, so that both INFO -# and ROLE will report those values. -# -# There is no need to use both the options if you need to override just -# the port or the IP address. -# -# replica-announce-ip 5.5.5.5 -# replica-announce-port 1234 - -################################## SECURITY ################################### - -# Require clients to issue AUTH before processing any other -# commands. This might be useful in environments in which you do not trust -# others with access to the host running redis-server. -# -# This should stay commented out for backward compatibility and because most -# people do not need auth (e.g. they run their own servers). -# -# Warning: since Redis is pretty fast an outside user can try up to -# 150k passwords per second against a good box. This means that you should -# use a very strong password otherwise it will be very easy to break. -# -# requirepass foobared - -# Command renaming. -# -# It is possible to change the name of dangerous commands in a shared -# environment. For instance the CONFIG command may be renamed into something -# hard to guess so that it will still be available for internal-use tools -# but not available for general clients. -# -# Example: -# -# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 -# -# It is also possible to completely kill a command by renaming it into -# an empty string: -# -# rename-command CONFIG "" -# -# Please note that changing the name of commands that are logged into the -# AOF file or transmitted to replicas may cause problems. - -################################### CLIENTS #################################### - -# Set the max number of connected clients at the same time. By default -# this limit is set to 10000 clients, however if the Redis server is not -# able to configure the process file limit to allow for the specified limit -# the max number of allowed clients is set to the current file limit -# minus 32 (as Redis reserves a few file descriptors for internal uses). -# -# Once the limit is reached Redis will close all the new connections sending -# an error 'max number of clients reached'. -# -# maxclients 10000 - -############################## MEMORY MANAGEMENT ################################ - -# Set a memory usage limit to the specified amount of bytes. -# When the memory limit is reached Redis will try to remove keys -# according to the eviction policy selected (see maxmemory-policy). -# -# If Redis can't remove keys according to the policy, or if the policy is -# set to 'noeviction', Redis will start to reply with errors to commands -# that would use more memory, like SET, LPUSH, and so on, and will continue -# to reply to read-only commands like GET. -# -# This option is usually useful when using Redis as an LRU or LFU cache, or to -# set a hard memory limit for an instance (using the 'noeviction' policy). -# -# WARNING: If you have replicas attached to an instance with maxmemory on, -# the size of the output buffers needed to feed the replicas are subtracted -# from the used memory count, so that network problems / resyncs will -# not trigger a loop where keys are evicted, and in turn the output -# buffer of replicas is full with DELs of keys evicted triggering the deletion -# of more keys, and so forth until the database is completely emptied. -# -# In short... if you have replicas attached it is suggested that you set a lower -# limit for maxmemory so that there is some free RAM on the system for replica -# output buffers (but this is not needed if the policy is 'noeviction'). -# -# maxmemory - -# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory -# is reached. You can select among five behaviors: -# -# volatile-lru -> Evict using approximated LRU among the keys with an expire set. -# allkeys-lru -> Evict any key using approximated LRU. -# volatile-lfu -> Evict using approximated LFU among the keys with an expire set. -# allkeys-lfu -> Evict any key using approximated LFU. -# volatile-random -> Remove a random key among the ones with an expire set. -# allkeys-random -> Remove a random key, any key. -# volatile-ttl -> Remove the key with the nearest expire time (minor TTL) -# noeviction -> Don't evict anything, just return an error on write operations. -# -# LRU means Least Recently Used -# LFU means Least Frequently Used -# -# Both LRU, LFU and volatile-ttl are implemented using approximated -# randomized algorithms. -# -# Note: with any of the above policies, Redis will return an error on write -# operations, when there are no suitable keys for eviction. -# -# At the date of writing these commands are: set setnx setex append -# incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd -# sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby -# zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby -# getset mset msetnx exec sort -# -# The default is: -# -# maxmemory-policy noeviction - -# LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated -# algorithms (in order to save memory), so you can tune it for speed or -# accuracy. For default Redis will check five keys and pick the one that was -# used less recently, you can change the sample size using the following -# configuration directive. -# -# The default of 5 produces good enough results. 10 Approximates very closely -# true LRU but costs more CPU. 3 is faster but not very accurate. -# -# maxmemory-samples 5 - -# Starting from Redis 5, by default a replica will ignore its maxmemory setting -# (unless it is promoted to master after a failover or manually). It means -# that the eviction of keys will be just handled by the master, sending the -# DEL commands to the replica as keys evict in the master side. -# -# This behavior ensures that masters and replicas stay consistent, and is usually -# what you want, however if your replica is writable, or you want the replica to have -# a different memory setting, and you are sure all the writes performed to the -# replica are idempotent, then you may change this default (but be sure to understand -# what you are doing). -# -# Note that since the replica by default does not evict, it may end using more -# memory than the one set via maxmemory (there are certain buffers that may -# be larger on the replica, or data structures may sometimes take more memory and so -# forth). So make sure you monitor your replicas and make sure they have enough -# memory to never hit a real out-of-memory condition before the master hits -# the configured maxmemory setting. -# -# replica-ignore-maxmemory yes - -############################# LAZY FREEING #################################### - -# Redis has two primitives to delete keys. One is called DEL and is a blocking -# deletion of the object. It means that the server stops processing new commands -# in order to reclaim all the memory associated with an object in a synchronous -# way. If the key deleted is associated with a small object, the time needed -# in order to execute the DEL command is very small and comparable to most other -# O(1) or O(log_N) commands in Redis. However if the key is associated with an -# aggregated value containing millions of elements, the server can block for -# a long time (even seconds) in order to complete the operation. -# -# For the above reasons Redis also offers non blocking deletion primitives -# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and -# FLUSHDB commands, in order to reclaim memory in background. Those commands -# are executed in constant time. Another thread will incrementally free the -# object in the background as fast as possible. -# -# DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled. -# It's up to the design of the application to understand when it is a good -# idea to use one or the other. However the Redis server sometimes has to -# delete keys or flush the whole database as a side effect of other operations. -# Specifically Redis deletes objects independently of a user call in the -# following scenarios: -# -# 1) On eviction, because of the maxmemory and maxmemory policy configurations, -# in order to make room for new data, without going over the specified -# memory limit. -# 2) Because of expire: when a key with an associated time to live (see the -# EXPIRE command) must be deleted from memory. -# 3) Because of a side effect of a command that stores data on a key that may -# already exist. For example the RENAME command may delete the old key -# content when it is replaced with another one. Similarly SUNIONSTORE -# or SORT with STORE option may delete existing keys. The SET command -# itself removes any old content of the specified key in order to replace -# it with the specified string. -# 4) During replication, when a replica performs a full resynchronization with -# its master, the content of the whole database is removed in order to -# load the RDB file just transferred. -# -# In all the above cases the default is to delete objects in a blocking way, -# like if DEL was called. However you can configure each case specifically -# in order to instead release memory in a non-blocking way like if UNLINK -# was called, using the following configuration directives: - -lazyfree-lazy-eviction no -lazyfree-lazy-expire no -lazyfree-lazy-server-del no -replica-lazy-flush no - -############################## APPEND ONLY MODE ############################### - -# By default Redis asynchronously dumps the dataset on disk. This mode is -# good enough in many applications, but an issue with the Redis process or -# a power outage may result into a few minutes of writes lost (depending on -# the configured save points). -# -# The Append Only File is an alternative persistence mode that provides -# much better durability. For instance using the default data fsync policy -# (see later in the config file) Redis can lose just one second of writes in a -# dramatic event like a server power outage, or a single write if something -# wrong with the Redis process itself happens, but the operating system is -# still running correctly. -# -# AOF and RDB persistence can be enabled at the same time without problems. -# If the AOF is enabled on startup Redis will load the AOF, that is the file -# with the better durability guarantees. -# -# Please check http://redis.io/topics/persistence for more information. - -appendonly no - -# The name of the append only file (default: "appendonly.aof") - -appendfilename "appendonly.aof" - -# The fsync() call tells the Operating System to actually write data on disk -# instead of waiting for more data in the output buffer. Some OS will really flush -# data on disk, some other OS will just try to do it ASAP. -# -# Redis supports three different modes: -# -# no: don't fsync, just let the OS flush the data when it wants. Faster. -# always: fsync after every write to the append only log. Slow, Safest. -# everysec: fsync only one time every second. Compromise. -# -# The default is "everysec", as that's usually the right compromise between -# speed and data safety. It's up to you to understand if you can relax this to -# "no" that will let the operating system flush the output buffer when -# it wants, for better performances (but if you can live with the idea of -# some data loss consider the default persistence mode that's snapshotting), -# or on the contrary, use "always" that's very slow but a bit safer than -# everysec. -# -# More details please check the following article: -# http://antirez.com/post/redis-persistence-demystified.html -# -# If unsure, use "everysec". - -# appendfsync always -appendfsync everysec -# appendfsync no - -# When the AOF fsync policy is set to always or everysec, and a background -# saving process (a background save or AOF log background rewriting) is -# performing a lot of I/O against the disk, in some Linux configurations -# Redis may block too long on the fsync() call. Note that there is no fix for -# this currently, as even performing fsync in a different thread will block -# our synchronous write(2) call. -# -# In order to mitigate this problem it's possible to use the following option -# that will prevent fsync() from being called in the main process while a -# BGSAVE or BGREWRITEAOF is in progress. -# -# This means that while another child is saving, the durability of Redis is -# the same as "appendfsync none". In practical terms, this means that it is -# possible to lose up to 30 seconds of log in the worst scenario (with the -# default Linux settings). -# -# If you have latency problems turn this to "yes". Otherwise leave it as -# "no" that is the safest pick from the point of view of durability. - -no-appendfsync-on-rewrite no - -# Automatic rewrite of the append only file. -# Redis is able to automatically rewrite the log file implicitly calling -# BGREWRITEAOF when the AOF log size grows by the specified percentage. -# -# This is how it works: Redis remembers the size of the AOF file after the -# latest rewrite (if no rewrite has happened since the restart, the size of -# the AOF at startup is used). -# -# This base size is compared to the current size. If the current size is -# bigger than the specified percentage, the rewrite is triggered. Also -# you need to specify a minimal size for the AOF file to be rewritten, this -# is useful to avoid rewriting the AOF file even if the percentage increase -# is reached but it is still pretty small. -# -# Specify a percentage of zero in order to disable the automatic AOF -# rewrite feature. - -auto-aof-rewrite-percentage 100 -auto-aof-rewrite-min-size 64mb - -# An AOF file may be found to be truncated at the end during the Redis -# startup process, when the AOF data gets loaded back into memory. -# This may happen when the system where Redis is running -# crashes, especially when an ext4 filesystem is mounted without the -# data=ordered option (however this can't happen when Redis itself -# crashes or aborts but the operating system still works correctly). -# -# Redis can either exit with an error when this happens, or load as much -# data as possible (the default now) and start if the AOF file is found -# to be truncated at the end. The following option controls this behavior. -# -# If aof-load-truncated is set to yes, a truncated AOF file is loaded and -# the Redis server starts emitting a log to inform the user of the event. -# Otherwise if the option is set to no, the server aborts with an error -# and refuses to start. When the option is set to no, the user requires -# to fix the AOF file using the "redis-check-aof" utility before to restart -# the server. -# -# Note that if the AOF file will be found to be corrupted in the middle -# the server will still exit with an error. This option only applies when -# Redis will try to read more data from the AOF file but not enough bytes -# will be found. -aof-load-truncated yes - -# When rewriting the AOF file, Redis is able to use an RDB preamble in the -# AOF file for faster rewrites and recoveries. When this option is turned -# on the rewritten AOF file is composed of two different stanzas: -# -# [RDB file][AOF tail] -# -# When loading Redis recognizes that the AOF file starts with the "REDIS" -# string and loads the prefixed RDB file, and continues loading the AOF -# tail. -aof-use-rdb-preamble yes - -################################ LUA SCRIPTING ############################### - -# Max execution time of a Lua script in milliseconds. -# -# If the maximum execution time is reached Redis will log that a script is -# still in execution after the maximum allowed time and will start to -# reply to queries with an error. -# -# When a long running script exceeds the maximum execution time only the -# SCRIPT KILL and SHUTDOWN NOSAVE commands are available. The first can be -# used to stop a script that did not yet called write commands. The second -# is the only way to shut down the server in the case a write command was -# already issued by the script but the user doesn't want to wait for the natural -# termination of the script. -# -# Set it to 0 or a negative value for unlimited execution without warnings. -lua-time-limit 5000 - -################################ REDIS CLUSTER ############################### -# -# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# WARNING EXPERIMENTAL: Redis Cluster is considered to be stable code, however -# in order to mark it as "mature" we need to wait for a non trivial percentage -# of users to deploy it in production. -# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -# -# Normal Redis instances can't be part of a Redis Cluster; only nodes that are -# started as cluster nodes can. In order to start a Redis instance as a -# cluster node enable the cluster support uncommenting the following: -# -# cluster-enabled yes - -# Every cluster node has a cluster configuration file. This file is not -# intended to be edited by hand. It is created and updated by Redis nodes. -# Every Redis Cluster node requires a different cluster configuration file. -# Make sure that instances running in the same system do not have -# overlapping cluster configuration file names. -# -# cluster-config-file nodes-6379.conf - -# Cluster node timeout is the amount of milliseconds a node must be unreachable -# for it to be considered in failure state. -# Most other internal time limits are multiple of the node timeout. -# -# cluster-node-timeout 15000 - -# A replica of a failing master will avoid to start a failover if its data -# looks too old. -# -# There is no simple way for a replica to actually have an exact measure of -# its "data age", so the following two checks are performed: -# -# 1) If there are multiple replicas able to failover, they exchange messages -# in order to try to give an advantage to the replica with the best -# replication offset (more data from the master processed). -# Replicas will try to get their rank by offset, and apply to the start -# of the failover a delay proportional to their rank. -# -# 2) Every single replica computes the time of the last interaction with -# its master. This can be the last ping or command received (if the master -# is still in the "connected" state), or the time that elapsed since the -# disconnection with the master (if the replication link is currently down). -# If the last interaction is too old, the replica will not try to failover -# at all. -# -# The point "2" can be tuned by user. Specifically a replica will not perform -# the failover if, since the last interaction with the master, the time -# elapsed is greater than: -# -# (node-timeout * replica-validity-factor) + repl-ping-replica-period -# -# So for example if node-timeout is 30 seconds, and the replica-validity-factor -# is 10, and assuming a default repl-ping-replica-period of 10 seconds, the -# replica will not try to failover if it was not able to talk with the master -# for longer than 310 seconds. -# -# A large replica-validity-factor may allow replicas with too old data to failover -# a master, while a too small value may prevent the cluster from being able to -# elect a replica at all. -# -# For maximum availability, it is possible to set the replica-validity-factor -# to a value of 0, which means, that replicas will always try to failover the -# master regardless of the last time they interacted with the master. -# (However they'll always try to apply a delay proportional to their -# offset rank). -# -# Zero is the only value able to guarantee that when all the partitions heal -# the cluster will always be able to continue. -# -# cluster-replica-validity-factor 10 - -# Cluster replicas are able to migrate to orphaned masters, that are masters -# that are left without working replicas. This improves the cluster ability -# to resist to failures as otherwise an orphaned master can't be failed over -# in case of failure if it has no working replicas. -# -# Replicas migrate to orphaned masters only if there are still at least a -# given number of other working replicas for their old master. This number -# is the "migration barrier". A migration barrier of 1 means that a replica -# will migrate only if there is at least 1 other working replica for its master -# and so forth. It usually reflects the number of replicas you want for every -# master in your cluster. -# -# Default is 1 (replicas migrate only if their masters remain with at least -# one replica). To disable migration just set it to a very large value. -# A value of 0 can be set but is useful only for debugging and dangerous -# in production. -# -# cluster-migration-barrier 1 - -# By default Redis Cluster nodes stop accepting queries if they detect there -# is at least an hash slot uncovered (no available node is serving it). -# This way if the cluster is partially down (for example a range of hash slots -# are no longer covered) all the cluster becomes, eventually, unavailable. -# It automatically returns available as soon as all the slots are covered again. -# -# However sometimes you want the subset of the cluster which is working, -# to continue to accept queries for the part of the key space that is still -# covered. In order to do so, just set the cluster-require-full-coverage -# option to no. -# -# cluster-require-full-coverage yes - -# This option, when set to yes, prevents replicas from trying to failover its -# master during master failures. However the master can still perform a -# manual failover, if forced to do so. -# -# This is useful in different scenarios, especially in the case of multiple -# data center operations, where we want one side to never be promoted if not -# in the case of a total DC failure. -# -# cluster-replica-no-failover no - -# In order to setup your cluster make sure to read the documentation -# available at http://redis.io web site. - -########################## CLUSTER DOCKER/NAT support ######################## - -# In certain deployments, Redis Cluster nodes address discovery fails, because -# addresses are NAT-ted or because ports are forwarded (the typical case is -# Docker and other containers). -# -# In order to make Redis Cluster working in such environments, a static -# configuration where each node knows its public address is needed. The -# following two options are used for this scope, and are: -# -# * cluster-announce-ip -# * cluster-announce-port -# * cluster-announce-bus-port -# -# Each instruct the node about its address, client port, and cluster message -# bus port. The information is then published in the header of the bus packets -# so that other nodes will be able to correctly map the address of the node -# publishing the information. -# -# If the above options are not used, the normal Redis Cluster auto-detection -# will be used instead. -# -# Note that when remapped, the bus port may not be at the fixed offset of -# clients port + 10000, so you can specify any port and bus-port depending -# on how they get remapped. If the bus-port is not set, a fixed offset of -# 10000 will be used as usually. -# -# Example: -# -# cluster-announce-ip 10.1.1.5 -# cluster-announce-port 6379 -# cluster-announce-bus-port 6380 - -################################## SLOW LOG ################################### - -# The Redis Slow Log is a system to log queries that exceeded a specified -# execution time. The execution time does not include the I/O operations -# like talking with the client, sending the reply and so forth, -# but just the time needed to actually execute the command (this is the only -# stage of command execution where the thread is blocked and can not serve -# other requests in the meantime). -# -# You can configure the slow log with two parameters: one tells Redis -# what is the execution time, in microseconds, to exceed in order for the -# command to get logged, and the other parameter is the length of the -# slow log. When a new command is logged the oldest one is removed from the -# queue of logged commands. - -# The following time is expressed in microseconds, so 1000000 is equivalent -# to one second. Note that a negative number disables the slow log, while -# a value of zero forces the logging of every command. -slowlog-log-slower-than 10000 - -# There is no limit to this length. Just be aware that it will consume memory. -# You can reclaim memory used by the slow log with SLOWLOG RESET. -slowlog-max-len 128 - -################################ LATENCY MONITOR ############################## - -# The Redis latency monitoring subsystem samples different operations -# at runtime in order to collect data related to possible sources of -# latency of a Redis instance. -# -# Via the LATENCY command this information is available to the user that can -# print graphs and obtain reports. -# -# The system only logs operations that were performed in a time equal or -# greater than the amount of milliseconds specified via the -# latency-monitor-threshold configuration directive. When its value is set -# to zero, the latency monitor is turned off. -# -# By default latency monitoring is disabled since it is mostly not needed -# if you don't have latency issues, and collecting data has a performance -# impact, that while very small, can be measured under big load. Latency -# monitoring can easily be enabled at runtime using the command -# "CONFIG SET latency-monitor-threshold " if needed. -latency-monitor-threshold 0 - -############################# EVENT NOTIFICATION ############################## - -# Redis can notify Pub/Sub clients about events happening in the key space. -# This feature is documented at http://redis.io/topics/notifications -# -# For instance if keyspace events notification is enabled, and a client -# performs a DEL operation on key "foo" stored in the Database 0, two -# messages will be published via Pub/Sub: -# -# PUBLISH __keyspace@0__:foo del -# PUBLISH __keyevent@0__:del foo -# -# It is possible to select the events that Redis will notify among a set -# of classes. Every class is identified by a single character: -# -# K Keyspace events, published with __keyspace@__ prefix. -# E Keyevent events, published with __keyevent@__ prefix. -# g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ... -# $ String commands -# l List commands -# s Set commands -# h Hash commands -# z Sorted set commands -# x Expired events (events generated every time a key expires) -# e Evicted events (events generated when a key is evicted for maxmemory) -# A Alias for g$lshzxe, so that the "AKE" string means all the events. -# -# The "notify-keyspace-events" takes as argument a string that is composed -# of zero or multiple characters. The empty string means that notifications -# are disabled. -# -# Example: to enable list and generic events, from the point of view of the -# event name, use: -# -# notify-keyspace-events Elg -# -# Example 2: to get the stream of the expired keys subscribing to channel -# name __keyevent@0__:expired use: -# -# notify-keyspace-events Ex -# -# By default all notifications are disabled because most users don't need -# this feature and the feature has some overhead. Note that if you don't -# specify at least one of K or E, no events will be delivered. -notify-keyspace-events "" - -############################### ADVANCED CONFIG ############################### - -# Hashes are encoded using a memory efficient data structure when they have a -# small number of entries, and the biggest entry does not exceed a given -# threshold. These thresholds can be configured using the following directives. -hash-max-ziplist-entries 512 -hash-max-ziplist-value 64 - -# Lists are also encoded in a special way to save a lot of space. -# The number of entries allowed per internal list node can be specified -# as a fixed maximum size or a maximum number of elements. -# For a fixed maximum size, use -5 through -1, meaning: -# -5: max size: 64 Kb <-- not recommended for normal workloads -# -4: max size: 32 Kb <-- not recommended -# -3: max size: 16 Kb <-- probably not recommended -# -2: max size: 8 Kb <-- good -# -1: max size: 4 Kb <-- good -# Positive numbers mean store up to _exactly_ that number of elements -# per list node. -# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size), -# but if your use case is unique, adjust the settings as necessary. -list-max-ziplist-size -2 - -# Lists may also be compressed. -# Compress depth is the number of quicklist ziplist nodes from *each* side of -# the list to *exclude* from compression. The head and tail of the list -# are always uncompressed for fast push/pop operations. Settings are: -# 0: disable all list compression -# 1: depth 1 means "don't start compressing until after 1 node into the list, -# going from either the head or tail" -# So: [head]->node->node->...->node->[tail] -# [head], [tail] will always be uncompressed; inner nodes will compress. -# 2: [head]->[next]->node->node->...->node->[prev]->[tail] -# 2 here means: don't compress head or head->next or tail->prev or tail, -# but compress all nodes between them. -# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail] -# etc. -list-compress-depth 0 - -# Sets have a special encoding in just one case: when a set is composed -# of just strings that happen to be integers in radix 10 in the range -# of 64 bit signed integers. -# The following configuration setting sets the limit in the size of the -# set in order to use this special memory saving encoding. -set-max-intset-entries 512 - -# Similarly to hashes and lists, sorted sets are also specially encoded in -# order to save a lot of space. This encoding is only used when the length and -# elements of a sorted set are below the following limits: -zset-max-ziplist-entries 128 -zset-max-ziplist-value 64 - -# HyperLogLog sparse representation bytes limit. The limit includes the -# 16 bytes header. When an HyperLogLog using the sparse representation crosses -# this limit, it is converted into the dense representation. -# -# A value greater than 16000 is totally useless, since at that point the -# dense representation is more memory efficient. -# -# The suggested value is ~ 3000 in order to have the benefits of -# the space efficient encoding without slowing down too much PFADD, -# which is O(N) with the sparse encoding. The value can be raised to -# ~ 10000 when CPU is not a concern, but space is, and the data set is -# composed of many HyperLogLogs with cardinality in the 0 - 15000 range. -hll-sparse-max-bytes 3000 - -# Streams macro node max size / items. The stream data structure is a radix -# tree of big nodes that encode multiple items inside. Using this configuration -# it is possible to configure how big a single node can be in bytes, and the -# maximum number of items it may contain before switching to a new node when -# appending new stream entries. If any of the following settings are set to -# zero, the limit is ignored, so for instance it is possible to set just a -# max entires limit by setting max-bytes to 0 and max-entries to the desired -# value. -stream-node-max-bytes 4096 -stream-node-max-entries 100 - -# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in -# order to help rehashing the main Redis hash table (the one mapping top-level -# keys to values). The hash table implementation Redis uses (see dict.c) -# performs a lazy rehashing: the more operation you run into a hash table -# that is rehashing, the more rehashing "steps" are performed, so if the -# server is idle the rehashing is never complete and some more memory is used -# by the hash table. -# -# The default is to use this millisecond 10 times every second in order to -# actively rehash the main dictionaries, freeing memory when possible. -# -# If unsure: -# use "activerehashing no" if you have hard latency requirements and it is -# not a good thing in your environment that Redis can reply from time to time -# to queries with 2 milliseconds delay. -# -# use "activerehashing yes" if you don't have such hard requirements but -# want to free memory asap when possible. -activerehashing yes - -# The client output buffer limits can be used to force disconnection of clients -# that are not reading data from the server fast enough for some reason (a -# common reason is that a Pub/Sub client can't consume messages as fast as the -# publisher can produce them). -# -# The limit can be set differently for the three different classes of clients: -# -# normal -> normal clients including MONITOR clients -# replica -> replica clients -# pubsub -> clients subscribed to at least one pubsub channel or pattern -# -# The syntax of every client-output-buffer-limit directive is the following: -# -# client-output-buffer-limit -# -# A client is immediately disconnected once the hard limit is reached, or if -# the soft limit is reached and remains reached for the specified number of -# seconds (continuously). -# So for instance if the hard limit is 32 megabytes and the soft limit is -# 16 megabytes / 10 seconds, the client will get disconnected immediately -# if the size of the output buffers reach 32 megabytes, but will also get -# disconnected if the client reaches 16 megabytes and continuously overcomes -# the limit for 10 seconds. -# -# By default normal clients are not limited because they don't receive data -# without asking (in a push way), but just after a request, so only -# asynchronous clients may create a scenario where data is requested faster -# than it can read. -# -# Instead there is a default limit for pubsub and replica clients, since -# subscribers and replicas receive data in a push fashion. -# -# Both the hard or the soft limit can be disabled by setting them to zero. -client-output-buffer-limit normal 0 0 0 -client-output-buffer-limit replica 256mb 64mb 60 -client-output-buffer-limit pubsub 32mb 8mb 60 - -# Client query buffers accumulate new commands. They are limited to a fixed -# amount by default in order to avoid that a protocol desynchronization (for -# instance due to a bug in the client) will lead to unbound memory usage in -# the query buffer. However you can configure it here if you have very special -# needs, such us huge multi/exec requests or alike. -# -# client-query-buffer-limit 1gb - -# In the Redis protocol, bulk requests, that are, elements representing single -# strings, are normally limited ot 512 mb. However you can change this limit -# here. -# -# proto-max-bulk-len 512mb - -# Redis calls an internal function to perform many background tasks, like -# closing connections of clients in timeout, purging expired keys that are -# never requested, and so forth. -# -# Not all tasks are performed with the same frequency, but Redis checks for -# tasks to perform according to the specified "hz" value. -# -# By default "hz" is set to 10. Raising the value will use more CPU when -# Redis is idle, but at the same time will make Redis more responsive when -# there are many keys expiring at the same time, and timeouts may be -# handled with more precision. -# -# The range is between 1 and 500, however a value over 100 is usually not -# a good idea. Most users should use the default of 10 and raise this up to -# 100 only in environments where very low latency is required. -hz 10 - -# Normally it is useful to have an HZ value which is proportional to the -# number of clients connected. This is useful in order, for instance, to -# avoid too many clients are processed for each background task invocation -# in order to avoid latency spikes. -# -# Since the default HZ value by default is conservatively set to 10, Redis -# offers, and enables by default, the ability to use an adaptive HZ value -# which will temporary raise when there are many connected clients. -# -# When dynamic HZ is enabled, the actual configured HZ will be used as -# as a baseline, but multiples of the configured HZ value will be actually -# used as needed once more clients are connected. In this way an idle -# instance will use very little CPU time while a busy instance will be -# more responsive. -dynamic-hz yes - -# When a child rewrites the AOF file, if the following option is enabled -# the file will be fsync-ed every 32 MB of data generated. This is useful -# in order to commit the file to the disk more incrementally and avoid -# big latency spikes. -aof-rewrite-incremental-fsync yes - -# When redis saves RDB file, if the following option is enabled -# the file will be fsync-ed every 32 MB of data generated. This is useful -# in order to commit the file to the disk more incrementally and avoid -# big latency spikes. -rdb-save-incremental-fsync yes - -# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good -# idea to start with the default settings and only change them after investigating -# how to improve the performances and how the keys LFU change over time, which -# is possible to inspect via the OBJECT FREQ command. -# -# There are two tunable parameters in the Redis LFU implementation: the -# counter logarithm factor and the counter decay time. It is important to -# understand what the two parameters mean before changing them. -# -# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis -# uses a probabilistic increment with logarithmic behavior. Given the value -# of the old counter, when a key is accessed, the counter is incremented in -# this way: -# -# 1. A random number R between 0 and 1 is extracted. -# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1). -# 3. The counter is incremented only if R < P. -# -# The default lfu-log-factor is 10. This is a table of how the frequency -# counter changes with a different number of accesses with different -# logarithmic factors: -# -# +--------+------------+------------+------------+------------+------------+ -# | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | -# +--------+------------+------------+------------+------------+------------+ -# | 0 | 104 | 255 | 255 | 255 | 255 | -# +--------+------------+------------+------------+------------+------------+ -# | 1 | 18 | 49 | 255 | 255 | 255 | -# +--------+------------+------------+------------+------------+------------+ -# | 10 | 10 | 18 | 142 | 255 | 255 | -# +--------+------------+------------+------------+------------+------------+ -# | 100 | 8 | 11 | 49 | 143 | 255 | -# +--------+------------+------------+------------+------------+------------+ -# -# NOTE: The above table was obtained by running the following commands: -# -# redis-benchmark -n 1000000 incr foo -# redis-cli object freq foo -# -# NOTE 2: The counter initial value is 5 in order to give new objects a chance -# to accumulate hits. -# -# The counter decay time is the time, in minutes, that must elapse in order -# for the key counter to be divided by two (or decremented if it has a value -# less <= 10). -# -# The default value for the lfu-decay-time is 1. A Special value of 0 means to -# decay the counter every time it happens to be scanned. -# -# lfu-log-factor 10 -# lfu-decay-time 1 - -########################### ACTIVE DEFRAGMENTATION ####################### -# -# WARNING THIS FEATURE IS EXPERIMENTAL. However it was stress tested -# even in production and manually tested by multiple engineers for some -# time. -# -# What is active defragmentation? -# ------------------------------- -# -# Active (online) defragmentation allows a Redis server to compact the -# spaces left between small allocations and deallocations of data in memory, -# thus allowing to reclaim back memory. -# -# Fragmentation is a natural process that happens with every allocator (but -# less so with Jemalloc, fortunately) and certain workloads. Normally a server -# restart is needed in order to lower the fragmentation, or at least to flush -# away all the data and create it again. However thanks to this feature -# implemented by Oran Agra for Redis 4.0 this process can happen at runtime -# in an "hot" way, while the server is running. -# -# Basically when the fragmentation is over a certain level (see the -# configuration options below) Redis will start to create new copies of the -# values in contiguous memory regions by exploiting certain specific Jemalloc -# features (in order to understand if an allocation is causing fragmentation -# and to allocate it in a better place), and at the same time, will release the -# old copies of the data. This process, repeated incrementally for all the keys -# will cause the fragmentation to drop back to normal values. -# -# Important things to understand: -# -# 1. This feature is disabled by default, and only works if you compiled Redis -# to use the copy of Jemalloc we ship with the source code of Redis. -# This is the default with Linux builds. -# -# 2. You never need to enable this feature if you don't have fragmentation -# issues. -# -# 3. Once you experience fragmentation, you can enable this feature when -# needed with the command "CONFIG SET activedefrag yes". -# -# The configuration parameters are able to fine tune the behavior of the -# defragmentation process. If you are not sure about what they mean it is -# a good idea to leave the defaults untouched. - -# Enabled active defragmentation -# activedefrag yes - -# Minimum amount of fragmentation waste to start active defrag -# active-defrag-ignore-bytes 100mb - -# Minimum percentage of fragmentation to start active defrag -# active-defrag-threshold-lower 10 - -# Maximum percentage of fragmentation at which we use maximum effort -# active-defrag-threshold-upper 100 - -# Minimal effort for defrag in CPU percentage -# active-defrag-cycle-min 5 - -# Maximal effort for defrag in CPU percentage -# active-defrag-cycle-max 75 - -# Maximum number of set/hash/zset/list fields that will be processed from -# the main dictionary scan -# active-defrag-max-scan-fields 1000 diff --git a/apps/emqx_auth_redis/priv/emqx_auth_redis.schema b/apps/emqx_auth_redis/priv/emqx_auth_redis.schema index eb09fa627..d51b9c1b2 100644 --- a/apps/emqx_auth_redis/priv/emqx_auth_redis.schema +++ b/apps/emqx_auth_redis/priv/emqx_auth_redis.schema @@ -33,6 +33,40 @@ hidden ]}. +{mapping, "auth.redis.ssl", "emqx_auth_redis.options", [ + {default, off}, + {datatype, flag} +]}. + +{mapping, "auth.redis.cafile", "emqx_auth_redis.options", [ + {default, ""}, + {datatype, string} +]}. + +{mapping, "auth.redis.certfile", "emqx_auth_redis.options", [ + {default, ""}, + {datatype, string} +]}. + +{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), + case Ssl of + true -> + CA = cuttlefish:conf_get("auth.redis.cafile", Conf), + Cert = cuttlefish:conf_get("auth.redis.certfile", Conf), + Key = cuttlefish:conf_get("auth.redis.keyfile", Conf), + [{options, [{ssl_options, [{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 @@ -103,4 +137,3 @@ end}. _ -> plain end end}. - diff --git a/apps/emqx_auth_redis/rebar.config b/apps/emqx_auth_redis/rebar.config index dfdf44cc2..af88d3665 100644 --- a/apps/emqx_auth_redis/rebar.config +++ b/apps/emqx_auth_redis/rebar.config @@ -1,3 +1,31 @@ {deps, - [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "v0.6.1"}}} - ]}. \ No newline at end of file + [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.2"}}} + ]}. + +{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}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + ]}, + {erl_opts, [debug_info]} + ]} + ]}. diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl index c6d5dea69..31cb67505 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_cli.erl @@ -41,13 +41,13 @@ connect(Opts) -> 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), - get_value(password, Opts), - no_reconnect - ) of + 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."), diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl index 41148c88e..6066a306a 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl @@ -32,11 +32,12 @@ init([]) -> {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 -> - eredis_cluster:start_pool(?APP, Server), + eredis_cluster:start_pool(?APP, Server ++ Options), []; _ -> - [ecpool:pool_spec(?APP, ?APP, emqx_auth_redis_cli, Server)] + [ecpool:pool_spec(?APP, ?APP, emqx_auth_redis_cli, Server ++ Options)] end. diff --git a/apps/emqx_bridge_mqtt/rebar.config b/apps/emqx_bridge_mqtt/rebar.config index 7b1f06cca..1958724ac 100644 --- a/apps/emqx_bridge_mqtt/rebar.config +++ b/apps/emqx_bridge_mqtt/rebar.config @@ -1 +1,27 @@ -{deps, []}. \ No newline at end of file +{deps, []}. +{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}. + +{shell, [ + % {config, "config/sys.config"}, + {apps, [emqx, emqx_bridge_mqtt]} +]}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}]} + ]} +]}. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl index ca3bba095..e46c6d05b 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl @@ -556,7 +556,7 @@ 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 JOSN format">>, + 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>>} } }, diff --git a/apps/emqx_coap/rebar.config b/apps/emqx_coap/rebar.config index 655e3b14f..dd1ad613e 100644 --- a/apps/emqx_coap/rebar.config +++ b/apps/emqx_coap/rebar.config @@ -1,4 +1,28 @@ {deps, [ {gen_coap, {git, "https://github.com/emqx/gen_coap", {tag, "v0.3.0"}}} - ]}. \ No newline at end of file + ]}. + +{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_dashboard/rebar.config b/apps/emqx_dashboard/rebar.config index 7b1f06cca..5d69c8c0e 100644 --- a/apps/emqx_dashboard/rebar.config +++ b/apps/emqx_dashboard/rebar.config @@ -1 +1,23 @@ -{deps, []}. \ No newline at end of file +{deps, []}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {branch, "1.2.2"}}} + ]} + ]} + ]}. + +{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_exhook/.gitignore b/apps/emqx_exhook/.gitignore deleted file mode 100644 index 520db35b8..000000000 --- a/apps/emqx_exhook/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -src/emqx_exhook_pb.erl -src/emqx_exhook_v_1_hook_provider_bhvr.erl -src/emqx_exhook_v_1_hook_provider_client.erl diff --git a/apps/emqx_exhook/README.md b/apps/emqx_exhook/README.md index 216c39275..9d4ccd81f 100644 --- a/apps/emqx_exhook/README.md +++ b/apps/emqx_exhook/README.md @@ -1,39 +1,75 @@ -# emqx_exhook +# emqx_extension_hook -The `emqx_exhook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. +The `emqx_extension_hook` 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] Support `python` and `java`. +- [x] Support all hooks of emqx. - [x] Allows you to use the return value to extend emqx behavior. +We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. + ## Architecture ``` -EMQ X Third-party Runtime -+========================+ +========+==========+ -| ExHook | | | | -| +----------------+ | gRPC | gRPC | User's | -| | gPRC Client | ------------------> | Server | Codes | -| +----------------+ | (HTTP/2) | | | -| | | | | -+========================+ +========+==========+ + EMQ X Third-party Runtimes ++========================+ +====================+ +| Extension | | | +| +----------------+ | Hooks | Python scripts / | +| | Drivers | ------------------> | Java Classes / | +| +----------------+ | (pipe) | Others ... | +| | | | ++========================+ +====================+ ``` -## Usage +## Drivers -### gRPC service +### Python -See: `priv/protos/exhook.proto` +***Requirements:*** -### CLI +- It requires the emqx hosted machine has Python3 Runtimes (not support python2) +- The `python3` executable commands in your shell -## Example +***Examples:*** -## Recommended gRPC Framework +See `test/scripts/main.py` -See: https://github.com/grpc-ecosystem/awesome-grpc +### Java -## Thanks +***Requirements:*** -- [grpcbox](https://github.com/tsloughter/grpcbox) +- It requires the emqx hosted machine has Java 8+ Runtimes +- An executable commands in your shell, i,g: `java` + +***Examples:*** + +See `test/scripts/Main.java` + +## Configurations + +| Name | Data Type | Options | Default | Description | +| ------------------- | --------- | ------------------------------------- | ---------------- | -------------------------------- | +| drivers | Enum | `python3`
`java` | `python3` | Drivers type | +| .path | String | - | `data/extension` | The codes/library search path | +| .call_timeout | Duration | - | `5s` | Function call timeout | +| .pool_size | Integer | - | `8` | The pool size for the driver | +| .init_module | String | - | main | The module name for initial call | + +## SDK + +See `sdk/README.md` + +## Known Issues or TODOs + +**Configurable Log System** + +- use stderr to print logs to the emqx console instead of stdout. An alternative is to print the logs to a file. +- The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform. + +## Reference + +- [erlport](https://github.com/hdima/erlport) +- [Eexternal Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) +- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) diff --git a/apps/emqx_exhook/docs/design.md b/apps/emqx_exhook/docs/design.md index 671e240cc..1bf74723c 100644 --- a/apps/emqx_exhook/docs/design.md +++ b/apps/emqx_exhook/docs/design.md @@ -2,115 +2,254 @@ ## 动机 -在 EMQ X Broker v4.1-v4.2 中,我们发布了 2 个插件来扩展 emqx 的编程能力: +增强系统的扩展性。包含的目的有: -1. `emqx-extension-hook` 提供了使用 Java, Python 向 Broker 挂载钩子的功能 -2. `emqx-exproto` 提供了使用 Java,Python 编写用户自定义协议接入插件的功能 +- 完全支持各种钩子,能够根据其返回值修改 EMQ X 或者 Client 的行为。 + - 例如 `auth/acl`:可以查询数据库或者执行某种算法校验操作权限。然后返回 `false` 表示 `认证/ACL` 失败。 + - 例如 `message.publish`:可以解析 `消息/主题` 并将其存储至数据库中。 -但在后续的支持中发现许多难以处理的问题: +- 支持多种语言的扩展;并包含该语言的示例程序。 + - python + - webhook + - Java + - Lua + - c,go,..... +- 热操作 + - 允许在插件运行过程中,添加和移除 `Driver`。 -1. 有大量的编程语言需要支持,需要编写和维护如 Go, JavaScript, Lua.. 等语言的驱动。 -2. `erlport` 使用的操作系统的管道进行通信,这让用户代码只能部署在和 emqx 同一个操作系统上。部署方式受到了极大的限制。 -3. 用户程序的启动参数直接打包到 Broker 中,导致用户开发无法实时的进行调试,单步跟踪等。 -4. `erlport` 会占用 `stdin` `stdout`。 +- 需要 CLI ,甚至 API 来管理 `Driver` -因此,我们计划重构这部分的实现,其中主要的内容是: -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) +注:`message` 类钩子仅包括在企业版中。 ## 设计 架构如下: ``` - 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) {}; -} + EMQ X Third-party Runtimes ++========================+ +====================+ +| Extension | | | +| +----------------+ | Hooks | Python scripts / | +| | Drivers | ------------------> | Java Classes / | +| +----------------+ | (pipe) | Others ... | +| | | | ++========================+ +====================+ ``` ### 配置文件示例 -``` -## 配置 gRPC 服务地址 (HTTP) -## -## s1 为服务器的名称 -exhook.server.s1.url = http://127.0.0.1:9001 +#### 驱动 配置 -## 配置 gRPC 服务地址 (HTTPS) +```properties +## Driver type ## -## 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 +## Exmaples: +## - python3 --- 仅配置 python3 +## - python3, java, webhook --- 配置多个 Driver +exhook.dirvers = python3, java, webhook + +## --- 具体 driver 的配置详情 + +## Python +exhook.dirvers.python3.path = data/extension/python +exhook.dirvers.python3.call_timeout = 5s +exhook.dirvers.python3.pool_size = 8 + +## java +exhook.drivers.java.path = data/extension/java +... +``` + +#### 钩子配置 + +钩子支持配置在配置文件中,例如: + +```properties +exhook.rule.python3.client.connected = {"module": "client", "callback": "on_client_connected"} +exhook.rule.python3.message.publish = {"module": "client", "callback": "on_client_connected", "topics": ["#", "t/#"]} +``` + +***已废弃!!(冗余)*** + + +### 驱动抽象 + +#### APIs + +| 方法名 | 说明 | 入参 | 返回 | +| ------------------------ | -------- | ------ | ------ | +| `init` | 初始化 | - | 见下表 | +| `deinit` | 销毁 | - | - | +| `xxx `*(由init函数定义)* | 钩子回调 | 见下表 | 见下表 | + + + +##### init 函数规格 + +```erlang +%% init 函数 +%% HookSpec : 为用户在脚本中的 初始化函数指定的;他会与配置文件中的内容作为默认值,进行合并 +%% 该参数的目的,用于 EMQ X 判断需要执行哪些 Hook 和 如何执行 Hook +%% State : 为用户自己管理的数据内容,EMQ X 不关心它,只来回透传 +init() -> {HookSpec, State}. + +%% 例如: +{[{client_connect, callback_m(), callback_f(),#{}, {}}]} + +%%-------------------------------------------------------------- +%% Type Defines + +-tpye hook_spec() :: [{hookname(), callback_m(), callback_f(), hook_opts()}]. + +-tpye state :: any(). + +-type hookname() :: 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 %% TODO: Should squash to `terminated` ? + | session_takeovered %% TODO: Should squash to `terminated` ? + | session_terminated + | message_publish + | message_delivered + | message_acked + | message_dropped. + +-type callback_m() :: atom(). -- 回调的模块名称;python 为脚本文件名称;java 为类名;webhook 为 URI 地址 + +-type callback_f() :: atom(). -- 回调的方法名称;python,java 等为方法名;webhook 为资源地址 + +-tpye hook_opts() :: [{hook_key(), any()}]. -- 配置项;配置该项钩子的行为 + +-type hook_key() :: topics | ... +``` + + + +##### deinit 函数规格 + +``` erlang +%% deinit 函数;不关心返回的任何内容 +deinit() -> any(). +``` + + + +##### 回调函数规格 + +| 钩子 | 入参 | 返回 | +| -------------------- | ----------------------------------------------------- | --------- | +| client_connect | `connifno`
`props` | - | +| client_connack | `connifno`
`rc`
`props` | - | +| client_connected | `clientinfo`
| - | +| client_disconnected | `clientinfo`
`reason` | - | +| client_authenticate | `clientinfo`
`result` | `result` | +| client_check_acl | `clientinfo`
`pubsub`
`topic`
`result` | `result` | +| client_subscribe | `clientinfo`
`props`
`topicfilters` | - | +| client_unsubscribe | `clientinfo`
`props`
`topicfilters` | - | +| session_created | `clientinfo` | - | +| session_subscribed | `clientinfo`
`topic`
`subopts` | - | +| session_unsubscribed | `clientinfo`
`topic` | - | +| session_resumed | `clientinfo` | - | +| session_discared | `clientinfo` | - | +| session_takeovered | `clientinfo` | - | +| session_terminated | `clientinfo`
`reason` | - | +| message_publish | `messsage` | `message` | +| message_delivered | `clientinfo`
`message` | - | +| message_dropped | `message` | - | +| message_acked | `clientinfo`
`message` | - | + + + +上表中包含数据格式为: + +```erlang +-type conninfo :: [ {node, atom()} + , {clientid, binary()} + , {username, binary()} + , {peerhost, binary()} + , {sockport, integer()} + , {proto_name, binary()} + , {proto_ver, integer()} + , {keepalive, integer()} + ]. + +-type clientinfo :: [ {node, atom()} + , {clientid, binary()} + , {username, binary()} + , {password, binary()} + , {peerhost, binary()} + , {sockport, integer()} + , {protocol, binary()} + , {mountpoint, binary()} + , {is_superuser, boolean()} + , {anonymous, boolean()} + ]. + +-type message :: [ {node, atom()} + , {id, binary()} + , {qos, integer()} + , {from, binary()} + , {topic, binary()} + , {payload, binary()} + , {timestamp, integer()} + ]. + +-type rc :: binary(). +-type props :: [{key(), value()}] + +-type topics :: [topic()]. +-type topic :: binary(). +-type pubsub :: publish | subscribe. +-type result :: true | false. +``` + + + +### 统计 + +在驱动运行过程中,应有对每种钩子调用计数,例如: + +``` +exhook.python3.check_acl 10 +``` + + + +### 管理 + +**CLI 示例:** + + + +**列出所有的驱动** + +``` +./bin/emqx_ctl exhook dirvers list +Drivers(xxx=yyy) +Drivers(aaa=bbb) +``` + + + +**开关驱动** + +``` +./bin/emqx_ctl exhook drivers enable python3 +ok + +./bin/emqx_ctl exhook drivers disable python3 +ok + +./bin/emqx_ctl exhook drivers stats +python3.client_connect 123 +webhook.check_acl 20 ``` diff --git a/apps/emqx_exhook/rebar.config b/apps/emqx_exhook/rebar.config index 1db04037f..7d5a285fb 100644 --- a/apps/emqx_exhook/rebar.config +++ b/apps/emqx_exhook/rebar.config @@ -1,21 +1,5 @@ %%-*- mode: erlang -*- -{plugins, - [rebar3_proper, - {grpcbox_plugin, {git, "https://github.com/zmstone/grpcbox_plugin", {branch, "master"}}} -]}. - -{deps, - [{grpcbox, {git, "https://github.com/tsloughter/grpcbox", {branch, "master"}}} -]}. - -{grpc, - [{protos, ["priv/protos"]}, - {gpb_opts, [{module_name_prefix, "emqx_"}, - {module_name_suffix, "_pb"}]} -]}. - -{provider_hooks, - [{pre, [{compile, {grpc, gen}}]}]}. +{deps, []}. {edoc_opts, [{preprocess, true}]}. @@ -29,18 +13,13 @@ {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_connection_client]}. {profiles, [{test, [ - {deps, [ {emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.1"}}} + {deps, [ {emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.2.2"}}} , {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} ]} ]} diff --git a/apps/emqx_exproto/.gitignore b/apps/emqx_exproto/.gitignore deleted file mode 100644 index 791a6e94e..000000000 --- a/apps/emqx_exproto/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -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 index 4b59dcae3..7fa88a9dc 100644 --- a/apps/emqx_exproto/README.md +++ b/apps/emqx_exproto/README.md @@ -4,25 +4,53 @@ The `emqx_exproto` extremly enhance the extensibility for EMQ X. It allow using ## 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. +- [x] Support Python, Java. +- [x] Support the `tcp`, `ssl`, `udp`, `dtls` socket. +- [x] Provide the `PUB/SUB` interface to others language. + +We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. ## Architecture ![EMQ X ExProto Arch](./docs/images/exproto-arch.jpg) -## Usage +## Drivers -### gRPC service +### Python -See: `priv/protos/exproto.proto` +***Requirements:*** -## Example +- It requires the emqx hosted machine has Python3 Runtimes +- An executable commands in your shell, i,g: `python3` or `python` -## Recommended gRPC Framework +***Examples:*** -See: https://github.com/grpc-ecosystem/awesome-grpc +See [example/main.python](https://github.com/emqx/emqx-exproto/blob/master/example/main.py) -## Thanks +### Java -- [grpcbox](https://github.com/tsloughter/grpcbox) +See [example/Main.java](https://github.com/emqx/emqx-exproto/blob/master/example/Main.java) + + +## SDK + +The SDK encloses the underlying obscure data types and function interfaces. It only provides a convenience for development, it is not required. + +See [sdk/README.md](https://github.com/emqx/emqx-exproto/blob/master/sdk/README.md) + + +## Benchmark + +***Work in progress...*** + + +## Known Issues or TODOs + +- Configurable Log System. + * The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform + +## Reference + +- [erlport](https://github.com/hdima/erlport) +- [External Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) +- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) diff --git a/apps/emqx_exproto/docs/design.md b/apps/emqx_exproto/docs/design.md index 0a6a082e2..b5cc4e49d 100644 --- a/apps/emqx_exproto/docs/design.md +++ b/apps/emqx_exproto/docs/design.md @@ -4,124 +4,173 @@ 该插件给 EMQ X 带来的扩展性十分的强大,它能以你熟悉语言处理任何的私有协议,并享受由 EMQ X 系统带来的高连接,和高并发的优点。 +**声明:当前仅实现了 Python、Java 的支持** + ## 特性 -- 极强的扩展能力。使用 gRPC 作为 RPC 通信框架,支持各个主流编程语言 +- 多语言支持。快速将接入层的协议实现迁移到 EMQ X 中进行管理 - 高吞吐。连接层以完全的异步非阻塞式 I/O 的方式实现 -- 连接层透明。完全的支持 TCP\TLS UDP\DTLS 类型的连接管理,并对上层提供统一个 API +- 完善的连接层。完全的支持 TCP\TLS UDP\DTLS 类型的连接 - 连接层的管理能力。例如,最大连接数,连接和吞吐的速率限制,IP 黑名单 等 -## 架构 +## 架构 ![Extension-Protocol Arch](images/exproto-arch.jpg) -该插件主要需要处理的内容包括: +该插件需要完成的工作包括三部分: -1. **连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: - - 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 - - 调用 `OnSocketCreated` 回调。用于通知外部模块**已新建立了一个连接**。 - - 调用 `OnScoektClosed` 回调。用于通知外部模块连接**已关闭**。 - - 调用 `OnReceivedBytes` 回调。用于通知外部模块**该连接新收到的数据包**。 - - 提供 `Send` 接口。供外部模块调用,**用于发送数据包**。 - - 提供 `Close` 接口。供外部模块调用,**用于主动关闭连接**。 +**初始化:** (TODO) +- loaded: +- unload: -2. **协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: +**连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: - - 提供 `Authenticate` 接口。供外部模块调用,用于向集群注册客户端。 - - 提供 `StartTimer` 接口。供外部模块调用,用于为该连接进程启动心跳等定时器。 - - 提供 `Publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 - - 提供 `Subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 - - 提供 `Unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 - - 调用 `OnTimerTimeout` 回调。用于处理定时器超时的事件。 - - 调用 `OnReceivedMessages` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) +- 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 +- 调用 `init` 回调。用于通知外部模块**已新建立了一个连接**。 +- 调用 `terminated` 回调。用于通知外部模块连接**已关闭**。 +- 调用 `received` 回调。用于通知外部模块**该连接新收到的数据包**。 +- 提供 `send` 接口。供外部模块调用,**用于发送数据包**。 +- 提供 `close` 接口。供外部模块调用,**用于主动关闭连接**。 + + +**协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: + +- 提供 `register` 接口。供外部模块调用,用于向集群注册客户端。 +- 提供 `publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 +- 提供 `subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 +- 提供 `unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 +- 调用 `deliver` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) + + +**管理&统计相关:** 该部分主要提供其他**管理&统计相关的接口**。包括: + +- 提供 `Hooks` 类的接口。用于与系统的钩子系统进行交互。 +- 提供 `Metrics` 类的接口。用于统计。 +- 提供 `HTTP or CLI` 管理类接口。 ## 接口设计 -从 gRPC 上的逻辑来说,emqx-exproto 会作为客户端向用户的 `ProtocolHandler` 服务发送回调请求。同时,它也会作为服务端向用户提供 `ConnectionAdapter` 服务,以提供 emqx-exproto 各个接口的访问。如图: +### 连接层接口 -![Extension Protocol gRPC Arch](images/exproto-grpc-arch.jpg) +多语言组件需要向 EMQ X 注册的回调函数: +```erlang +%% Got a new Connection +init(conn(), conninfo()) -> state(). -详情参见:`priv/protos/exproto.proto`,例如接口的定义有: +%% Incoming a data +recevied(conn(), data(), state()) -> state(). -```protobuff -syntax = "proto3"; +%% Socket & Connection process terminated +terminated(conn(), reason(), state()) -> ok. -package emqx.exproto.v1; +-opaue conn() :: pid(). -// The Broker side serivce. It provides a set of APIs to -// handle a protcol access -service ConnectionAdapter { +-type conninfo() :: [ {socktype, tcp | tls | udp | dtls}, + , {peername, {inet:ip_address(), inet:port_number()}}, + , {sockname, {inet:ip_address(), inet:port_number()}}, + , {peercert, nossl | [{cn, string()}, {dn, string()}]} + ]). - // -- socket layer +-type reason() :: string(). - 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(SocketCreatedRequest) returns (EmptySuccess) {}; - - rpc OnSocketClosed(SocketClosedRequest) returns (EmptySuccess) {}; - - rpc OnReceivedBytes(ReceivedBytesRequest) returns (EmptySuccess) {}; - - // -- pub/sub layer - - rpc OnTimerTimeout(TimerTimeoutRequest) returns (EmptySuccess) {}; - - rpc OnReceivedMessages(ReceivedMessagesRequest) returns (EmptySuccess) {}; -} +-type state() :: any(). ``` + +`emqx-exproto` 需要向多语言插件提供的接口: + +``` erlang +%% Send a data to socket +send(conn(), data()) -> ok. + +%% Close the socket +close(conn() ) -> ok. +``` + + +### 协议/会话层接口 + +多语言组件需要向 EMQ X 注册的回调函数: + +```erlang +%% Received a message from a Topic +deliver(conn(), [message()], state()) -> state(). + +-type message() :: [ {id, binary()} + , {qos, integer()} + , {from, binary()} + , {topic, binary()} + , {payload, binary()} + , {timestamp, integer()} + ]. +``` + + +`emqx-exproto` 需要向多语言插件提供的接口: + +``` erlang +%% Reigster the client to Broker +register(conn(), clientinfo()) -> ok | {error, Reason}. + +%% Publish a message to Broker +publish(conn(), message()) -> ok. + +%% Subscribe a topic +subscribe(conn(), topic(), qos()) -> ok. + +%% Unsubscribe a topic +unsubscribe(conn(), topic()) -> ok. + +-type clientinfo() :: [ {proto_name, binary()} + , {proto_ver, integer() | string()} + , {clientid, binary()} + , {username, binary()} + , {mountpoint, binary()}} + , {keepalive, non_neg_integer()} + ]. +``` + +### 管理&统计相关接口 + +*TODO..* + ## 配置项设计 1. 以 **监听器( Listener)** 为基础,提供 TCP/UDP 的监听。 - Listener 目前仅支持:TCP、TLS、UDP、DTLS。(ws、wss、quic 暂不支持) -2. 每个监听器,会指定一个 `ProtocolHandler` 的服务地址,用于调用外部模块的接口。 -3. emqx-exproto 还会监听一个 gRPC 端口用于提供对 `ConnectionAdapter` 服务的访问。 +2. 每个监听器,会指定一个多语言的驱动,用于调用外部模块的接口 + - Driver 目前仅支持:python,java 例如: ``` properties -## gRPC 服务监听地址 (HTTP) -## -exproto.server.http.url = http://127.0.0.1:9002 +## A JT/T 808 TCP based example: +exproto.listener.jtt808 = 6799 +exproto.listener.jtt808.type = tcp +exproto.listener.jtt808.driver = python +# acceptors, max_connections, max_conn_rate, ... +# proxy_protocol, ... +# sndbuff, recbuff, ... +# ssl, cipher, certfile, psk, ... -## 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 - -## ProtocolHandler 服务地址及 https 的证书配置 -exproto.listener.protoname.proto_handler_url = http://127.0.0.1:9001 -#exproto.listener.protoname.proto_handler_certfile = -#exproto.listener.protoname.proto_handler_cacertfile = -#exproto.listener.protoname.proto_handler_keyfile = +exproto.listener.jtt808. = +## A CoAP UDP based example +exproto.listener.coap = 6799 +exproto.listener.coap.type = udp +exproto.listener.coap.driver = java # ... ``` + +## 集成与调试 + +参见 SDK 规范、和对应语言的开发手册 + +## SDK 实现要求 + +参见 SDK 规范、和对应语言的开发手册 + +## TODOs: + +- 认证 和 发布 订阅鉴权等钩子接入 diff --git a/apps/emqx_exproto/docs/images/exproto-arch.jpg b/apps/emqx_exproto/docs/images/exproto-arch.jpg index dddf7996b98c61bf55f911a35e0f51e76c8e8728..54cd63f613403e86409e6cc95fdea5855482d5e5 100644 GIT binary patch literal 85466 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

T?xL#J!PiY=1=!MF1|)?WPlvM$ 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 literal 72633 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 JjnBu)Ujb2>s0RQ5 diff --git a/apps/emqx_exproto/include/emqx_exproto.hrl b/apps/emqx_exproto/include/emqx_exproto.hrl index 079a1e60f..7dcb377f8 100644 --- a/apps/emqx_exproto/include/emqx_exproto.hrl +++ b/apps/emqx_exproto/include/emqx_exproto.hrl @@ -22,16 +22,3 @@ %% 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 index fb114dc77..af63f56f9 100644 --- a/apps/emqx_exproto/priv/emqx_exproto.schema +++ b/apps/emqx_exproto/priv/emqx_exproto.schema @@ -1,66 +1,25 @@ %% -*-: 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 +%%-------------------------------------------------------------------- + +%%-------------------------------------------------------------------- +%% TCP Listeners {mapping, "exproto.listener.$proto", "emqx_exproto.listeners", [ {datatype, string} ]}. -{mapping, "exproto.listener.$proto.connection_handler_url", "emqx_exproto.listeners", [ +{mapping, "exproto.listener.$proto.driver", "emqx_exproto.listeners", [ + {datatype, {enum, [python3, java]}} +]}. + +{mapping, "exproto.listener.$proto.driver_search_path", "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", [ +{mapping, "exproto.listener.$proto.driver_callback_module", "emqx_exproto.listeners", [ + {default, "main"}, {datatype, string} ]}. @@ -231,23 +190,14 @@ end}. {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, + DriverOpts = fun(Prefix) -> + [{driver, + Filter([{type, cuttlefish:conf_get(Prefix ++ ".driver", Conf)}, + {path, cuttlefish:conf_get(Prefix ++ ".driver_search_path", Conf)}, + {cbm, Atom(cuttlefish:conf_get(Prefix ++ ".driver_callback_module", Conf))} + ]) + }] + end, ConnOpts = fun(Prefix) -> Filter([{active_n, cuttlefish:conf_get(Prefix ++ ".active_n", Conf, undefined)}, @@ -339,7 +289,7 @@ end}. Listeners = fun(Proto) -> Prefix = string:join(["exproto","listener", Proto], "."), - Opts = HandlerOpts(Prefix) ++ ConnOpts(Prefix) ++ LisOpts(Prefix), + Opts = DriverOpts(Prefix) ++ ConnOpts(Prefix) ++ LisOpts(Prefix), case cuttlefish:conf_get(Prefix, Conf, undefined) of undefined -> []; ListenOn0 -> diff --git a/apps/emqx_exproto/rebar.config b/apps/emqx_exproto/rebar.config index bfc87e92c..52225fea3 100644 --- a/apps/emqx_exproto/rebar.config +++ b/apps/emqx_exproto/rebar.config @@ -1,4 +1,7 @@ %%-*- mode: erlang -*- + +{deps, [{erlport, {git, "https://github.com/emqx/erlport", {tag, "v1.2.2"}}}]}. + {edoc_opts, [{preprocess, true}]}. {erl_opts, [warn_unused_vars, @@ -8,38 +11,12 @@ debug_info, {parse_transform}]}. -{plugins, - [{grpcbox_plugin, {git, "https://github.com/zmstone/grpcbox_plugin", {branch, "master"}}} - ]}. - -{deps, - [{grpcbox, {git, "https://github.com/tsloughter/grpcbox", {branch, "master"}}} - ]}. - -{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, [ diff --git a/apps/emqx_exproto/src/emqx_exproto.app.src b/apps/emqx_exproto/src/emqx_exproto.app.src index 6ac404f47..2d445570b 100644 --- a/apps/emqx_exproto/src/emqx_exproto.app.src +++ b/apps/emqx_exproto/src/emqx_exproto.app.src @@ -1,12 +1,14 @@ {application, emqx_exproto, [{description, "EMQ X Extension for Protocol"}, - {vsn, "4.3.0"}, % strict semver, bump manually! + {vsn, "4.3.0"}, %% strict semver {modules, []}, {registered, []}, {mod, {emqx_exproto_app, []}}, - {applications, [kernel,stdlib]}, + {applications, [kernel, stdlib, erlport]}, {env,[]}, {licenses, ["Apache-2.0"]}, {maintainers, ["EMQ X Team "]}, - {links, [{"Homepage", "https://emqx.io/"}]} + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-extension-proto"} + ]} ]}. diff --git a/apps/emqx_exproto/src/emqx_exproto.erl b/apps/emqx_exproto/src/emqx_exproto.erl index 8ec382901..c8c36b19e 100644 --- a/apps/emqx_exproto/src/emqx_exproto.erl +++ b/apps/emqx_exproto/src/emqx_exproto.erl @@ -16,20 +16,24 @@ -module(emqx_exproto). +-compile({no_auto_import, [register/1]}). + -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: Connection level +-export([ send/2 + , close/1 + ]). + +%% APIs: Protocol/Session level +-export([ register/2 + , publish/2 + , subscribe/3 + , unsubscribe/2 ]). %%-------------------------------------------------------------------- @@ -38,71 +42,78 @@ -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). + lists:foreach(fun start_listener/1, application:get_env(?APP, listeners, [])). -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). + lists:foreach(fun stop_listener/1, application:get_env(?APP, listeners, [])). --spec(start_servers() -> ok). -start_servers() -> - lists:foreach(fun start_server/1, application:get_env(?APP, servers, [])). +%%-------------------------------------------------------------------- +%% APIs - Connection level +%%-------------------------------------------------------------------- --spec(stop_servers() -> ok). -stop_servers() -> - lists:foreach(fun stop_server/1, application:get_env(?APP, servers, [])). +-spec(send(pid(), binary()) -> ok). +send(Conn, Data) when is_pid(Conn), is_binary(Data) -> + emqx_exproto_conn:cast(Conn, {send, Data}). + +-spec(close(pid()) -> ok). +close(Conn) when is_pid(Conn) -> + emqx_exproto_conn:cast(Conn, close). + +%%-------------------------------------------------------------------- +%% APIs - Protocol/Session level +%%-------------------------------------------------------------------- + +-spec(register(pid(), list()) -> ok | {error, any()}). +register(Conn, ClientInfo0) -> + case emqx_exproto_types:parse(clientinfo, ClientInfo0) of + {error, Reason} -> + {error, Reason}; + ClientInfo -> + emqx_exproto_conn:cast(Conn, {register, ClientInfo}) + end. + +-spec(publish(pid(), list()) -> ok | {error, any()}). +publish(Conn, Msg0) when is_pid(Conn), is_list(Msg0) -> + case emqx_exproto_types:parse(message, Msg0) of + {error, Reason} -> + {error, Reason}; + Msg -> + emqx_exproto_conn:cast(Conn, {publish, Msg}) + end. + +-spec(subscribe(pid(), binary(), emqx_types:qos()) -> ok | {error, any()}). +subscribe(Conn, Topic, Qos) + when is_pid(Conn), is_binary(Topic), + (Qos =:= 0 orelse Qos =:= 1 orelse Qos =:= 2) -> + emqx_exproto_conn:cast(Conn, {subscribe, Topic, Qos}). + +-spec(unsubscribe(pid(), binary()) -> ok | {error, any()}). +unsubscribe(Conn, Topic) + when is_pid(Conn), is_binary(Topic) -> + emqx_exproto_conn:cast(Conn, {unsubscribe, Topic}). %%-------------------------------------------------------------------- %% Internal functions %%-------------------------------------------------------------------- -start_connection_handler_instance({_Proto, _LisType, _ListenOn, Opts}) -> - Name = name(_Proto, _LisType), - {value, {_, HandlerOpts}, LisOpts} = lists:keytake(handler, 1, Opts), - {Endpoints, ChannelOptions} = handler_opts(HandlerOpts), - case emqx_exproto_sup:start_grpc_client_channel(Name, Endpoints, 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)]); + {value, {_, DriverOpts}, LisOpts} = lists:keytake(driver, 1, Opts), + case emqx_exproto_driver_mngr:ensure_driver(Name, DriverOpts) of + {ok, _DriverPid}-> + case start_listener(LisType, Name, ListenOn, [{driver, Name} |LisOpts]) 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; {error, Reason} -> - io:format(standard_error, "Failed to start ~s listener on ~s - ~0p~n!", - [Name, format(ListenOn), Reason]), + io:format(standard_error, "Failed to start ~s's driver - ~0p~n!", + [Name, Reason]), error(Reason) end. @@ -126,11 +137,11 @@ start_listener(dtls, Name, ListenOn, LisOpts) -> stop_listener({Proto, LisType, ListenOn, Opts}) -> Name = name(Proto, LisType), + _ = emqx_exproto_driver_mngr:stop_driver(Name), StopRet = stop_listener(LisType, Name, ListenOn, Opts), case StopRet of - ok -> - io:format("Stop ~s listener on ~s successfully.~n", - [Name, format(ListenOn)]); + 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]) @@ -146,12 +157,8 @@ 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]). + io_lib:format("~s:~w", [Addr, Port]). %% @private merge_tcp_default(Opts) -> @@ -169,15 +176,3 @@ merge_udp_default(Opts) -> 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), - Options = proplists:get_value(options, Opts, []), - SslOpts = case Scheme of - https -> proplists:get_value(ssl_options, Opts, []); - _ -> [] - end, - {[{Scheme, Host, Port, SslOpts}], maps:from_list(Options)}. diff --git a/apps/emqx_exproto/src/emqx_exproto_app.erl b/apps/emqx_exproto/src/emqx_exproto_app.erl index 73e8a65bc..b12101fda 100644 --- a/apps/emqx_exproto/src/emqx_exproto_app.erl +++ b/apps/emqx_exproto/src/emqx_exproto_app.erl @@ -24,14 +24,13 @@ 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 index 9786c12c2..5a8e2c888 100644 --- a/apps/emqx_exproto/src/emqx_exproto_channel.erl +++ b/apps/emqx_exproto/src/emqx_exproto_channel.erl @@ -16,7 +16,6 @@ -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"). @@ -42,26 +41,20 @@ -export_type([channel/0]). -record(channel, { - %% gRPC channel options - gcli :: map(), + %% Driver name + driver :: atom(), %% Conn info conninfo :: emqx_types:conninfo(), %% Client info from `register` function clientinfo :: maybe(map()), + %% Registered + registered = false :: boolean(), %% 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 + %% Driver level state + state :: any() }). -opaque(channel() :: #channel{}). @@ -74,11 +67,6 @@ -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, @@ -142,44 +130,20 @@ stats(#channel{subscriptions = Subs}) -> %%-------------------------------------------------------------------- -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}. +init(ConnInfo, Options) -> + Driver = proplists:get_value(driver, Options), + case cb_init(ConnInfo, Driver) of + {ok, DState} -> + NConnInfo = default_conninfo(ConnInfo), + ClientInfo = default_clientinfo(ConnInfo), + #channel{driver = Driver, + state = DState, + conninfo = NConnInfo, + clientinfo = ClientInfo, + conn_state = connected}; + {error, Reason} -> + exit({init_channel_failed, Reason}) + end. %%-------------------------------------------------------------------- %% Handle incoming packet @@ -189,163 +153,81 @@ address({Host, Port}) -> -> {ok, channel()} | {shutdown, Reason :: term(), channel()}). handle_in(Data, Channel) -> - Req = #{bytes => Data}, - {ok, try_dispatch(on_received_bytes, wrap(Req), Channel)}. + case cb_received(Data, Channel) of + {ok, NChannel} -> + {ok, NChannel}; + {error, Reason} -> + {shutdown, Reason, Channel} + end. -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)}. +handle_deliver(Delivers, Channel) -> + %% TODO: ?? Nack delivers from shared subscriptions + case cb_deliver(Delivers, Channel) of + {ok, NChannel} -> + {ok, NChannel}; + {error, Reason} -> + {shutdown, Reason, Channel} + end. -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]), - {ok, {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}. + ?WARN("Unexpected call: ~p", [Req]), + {reply, ok, Channel}. -spec(handle_cast(any(), channel()) -> {ok, channel()} | {ok, replies(), channel()} | {shutdown, Reason :: term(), channel()}). +handle_cast({send, Data}, Channel) -> + {ok, [{outgoing, Data}], Channel}; + +handle_cast(close, Channel) -> + {ok, [{close, normal}], Channel}; + +handle_cast({register, ClientInfo}, Channel = #channel{registered = true}) -> + ?WARN("Duplicated register command, dropped ~p", [ClientInfo]), + {ok, Channel}; +handle_cast({register, ClientInfo0}, Channel = #channel{conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + ClientInfo1 = maybe_assign_clientid(ClientInfo0), + NConnInfo = enrich_conninfo(ClientInfo1, ConnInfo), + NClientInfo = enrich_clientinfo(ClientInfo1, ClientInfo), + case emqx_cm:open_session(true, NClientInfo, NConnInfo) of + {ok, _Session} -> + NChannel = Channel#channel{registered = true, + conninfo = NConnInfo, + clientinfo = NClientInfo}, + {ok, [{event, registered}], NChannel}; + {error, Reason} -> + ?ERROR("Register failed, reason: ~p", [Reason]), + {shutdown, Reason, {error, Reason}, Channel} + end; + +handle_cast({subscribe, TopicFilter, Qos}, Channel) -> + do_subscribe([{TopicFilter, #{qos => Qos}}], Channel); + +handle_cast({unsubscribe, TopicFilter}, Channel) -> + do_unsubscribe([{TopicFilter, #{}}], Channel); + +handle_cast({publish, Msg}, Channel) -> + emqx:publish(enrich_msg(Msg, Channel)), + {ok, Channel}; + handle_cast(Req, Channel) -> ?WARN("Unexpected call: ~p", [Req]), {ok, Channel}. @@ -359,41 +241,15 @@ handle_info({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 -> - {shutdown, {sock_closed, Reason}, Channel}; - _ -> - %% 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({sock_closed, Reason}, Channel) -> + {shutdown, {sock_closed, Reason}, Channel}; handle_info(Info, Channel) -> - ?LOG(warning, "Unexpected info: ~p", [Info]), + ?WARN("Unexpected info: ~p", [Info]), {ok, Channel}. --spec(terminate(any(), channel()) -> channel()). +-spec(terminate(any(), channel()) -> ok). terminate(Reason, Channel) -> - Req = #{reason => stringfy(Reason)}, - try_dispatch(on_socket_closed, wrap(Req), Channel). - -is_anonymous(#{anonymous := true}) -> true; -is_anonymous(_AuthResult) -> false. + cb_terminated(Reason, Channel), ok. %%-------------------------------------------------------------------- %% Sub/UnSub @@ -410,22 +266,11 @@ do_subscribe(TopicFilters, Channel) -> 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. + _ = emqx:subscribe(NTopicFilter, SubId, NSubOpts), + Channel#channel{subscriptions = Subs#{NTopicFilter => SubOpts}}. do_unsubscribe(TopicFilters, Channel) -> NChannel = lists:foldl( @@ -435,133 +280,74 @@ do_unsubscribe(TopicFilters, Channel) -> {ok, NChannel}. %% @private -do_unsubscribe(TopicFilter, UnSubOpts, Channel = - #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}, +do_unsubscribe(TopicFilter, _SubOpts, Channel = + #channel{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. + TopicFilter1 = emqx_mountpoint:mount(Mountpoint, TopicFilter), + _ = emqx:unsubscribe(TopicFilter1), + Channel#channel{subscriptions = maps:remove(TopicFilter1, Subs)}. %% @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 +%% Cbs for driver %%-------------------------------------------------------------------- -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 - }. +cb_init(ConnInfo, Driver) -> + Args = [self(), emqx_exproto_types:serialize(conninfo, ConnInfo)], + emqx_exproto_driver_mngr:call(Driver, {'init', Args}). -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}; +cb_received(Data, Channel = #channel{state = DState}) -> + Args = [self(), Data, DState], + do_call_cb('received', Args, Channel). -ensure_disconnected(_Reason, Channel = #channel{conninfo = ConnInfo}) -> - NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, - Channel#channel{conninfo = NConnInfo, conn_state = disconnected}. +cb_terminated(Reason, Channel = #channel{state = DState}) -> + Args = [self(), stringfy(Reason), DState], + do_call_cb('terminated', Args, Channel). -run_hooks(Name, Args) -> - ok = emqx_metrics:inc(Name), emqx_hooks:run(Name, Args). +cb_deliver(Delivers, Channel = #channel{state = DState}) -> + Msgs = [emqx_exproto_types:serialize(message, Msg) || {_, _, Msg} <- Delivers], + Args = [self(), Msgs, DState], + do_call_cb('deliver', Args, Channel). -%%-------------------------------------------------------------------- -%% 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 +%% @private +do_call_cb(Fun, Args, Channel = #channel{driver = D}) -> + case emqx_exproto_driver_mngr:call(D, {Fun, Args}) of + ok -> + {ok, Channel}; + {ok, NDState} -> + {ok, Channel#channel{state = NDState}}; + {error, Reason} -> + {error, Reason} 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 => pid_to_list(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 %%-------------------------------------------------------------------- +maybe_assign_clientid(ClientInfo) -> + case maps:get(clientid, ClientInfo, undefined) of + undefined -> + ClientInfo#{clientid => emqx_guid:to_base62(emqx_guid:gen())}; + _ -> + ClientInfo + end. + +enrich_msg(Msg, #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}}) -> + NMsg = emqx_mountpoint:mount(Mountpoint, Msg), + case maps:get(clientid, ClientInfo, undefined) of + undefined -> NMsg; + ClientId -> NMsg#message{from = ClientId} + end. + enrich_conninfo(InClientInfo, ConnInfo) -> - Ks = [proto_name, proto_ver, clientid, username], - maps:merge(ConnInfo, maps:with(Ks, InClientInfo)). + maps:merge(ConnInfo, maps:with([proto_name, proto_ver, clientid, username, keepalive], InClientInfo)). enrich_clientinfo(InClientInfo = #{proto_name := ProtoName}, ClientInfo) -> - Ks = [clientid, username, mountpoint], - NClientInfo = maps:merge(ClientInfo, maps:with(Ks, InClientInfo)), - NClientInfo#{protocol => ProtoName}. + NClientInfo = maps:merge(ClientInfo, maps:with([clientid, username, mountpoint], InClientInfo)), + NClientInfo#{protocol => lowcase_atom(ProtoName)}. default_conninfo(ConnInfo) -> ConnInfo#{proto_name => undefined, @@ -577,12 +363,12 @@ default_conninfo(ConnInfo) -> expiry_interval => 0}. default_clientinfo(#{peername := {PeerHost, _}, - sockname := {_, SockPort}}) -> - #{zone => external, + sockname := {_, SockPort}}) -> + #{zone => undefined, protocol => undefined, peerhost => PeerHost, sockport => SockPort, - clientid => undefined, + clientid => default_clientid(), username => undefined, is_bridge => false, is_superuser => false, @@ -591,9 +377,10 @@ default_clientinfo(#{peername := {PeerHost, _}, stringfy(Reason) -> unicode:characters_to_binary((io_lib:format("~0p", [Reason]))). -hexstr(Bin) -> - [io_lib:format("~2.16.0B",[X]) || <> <= Bin]. +lowcase_atom(undefined) -> + undefined; +lowcase_atom(S) -> + binary_to_atom(string:lowercase(S), utf8). -fmt_from(undefined) -> <<>>; -fmt_from(Bin) when is_binary(Bin) -> Bin; -fmt_from(T) -> stringfy(T). +default_clientid() -> + <<"exproto_client_", (list_to_binary(pid_to_list(self())))/binary>>. diff --git a/apps/emqx_exproto/src/emqx_exproto_conn.erl b/apps/emqx_exproto/src/emqx_exproto_conn.erl index d3ce75f0b..10a40c987 100644 --- a/apps/emqx_exproto/src/emqx_exproto_conn.erl +++ b/apps/emqx_exproto/src/emqx_exproto_conn.erl @@ -173,10 +173,8 @@ esockd_wait({esockd_transport, Sock}) -> R = {error, _} -> R end. -esockd_close({udp, _SockPid, _Sock}) -> - %% nothing to do for udp socket - %%gen_udp:close(Sock); - ok; +esockd_close({udp, _SockPid, Sock}) -> + gen_udp:close(Sock); esockd_close({esockd_transport, Sock}) -> esockd_transport:fast_close(Sock). @@ -359,9 +357,6 @@ handle_msg({'$gen_call', From, Req}, State) -> {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) @@ -424,16 +419,16 @@ 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}) -> +handle_msg({event, registered}, State = #state{channel = Channel}) -> ClientId = emqx_exproto_channel:info(clientid, Channel), emqx_cm:register_channel(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, 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)), @@ -485,8 +480,6 @@ 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. @@ -502,18 +495,7 @@ handle_timeout(_TRef, limit_timeout, State) -> 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), @@ -683,3 +665,4 @@ stop(Reason, State) -> stop(Reason, Reply, State) -> {stop, Reason, Reply, State}. + diff --git a/apps/emqx_exproto/src/emqx_exproto_gcli.erl b/apps/emqx_exproto/src/emqx_exproto_gcli.erl deleted file mode 100644 index 41af21fb8..000000000 --- a/apps/emqx_exproto/src/emqx_exproto_gcli.erl +++ /dev/null @@ -1,110 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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 - ]). - --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, #{pool => Pool, id => Id}}. - -handle_call(_Request, _From, State) -> - {reply, ok, State}. - -handle_cast({rpc, Fun, Req, Options, From}, State) -> - case catch apply(?CONN_ADAPTER_MOD, Fun, [Req, Options]) of - {ok, Resp, _Metadata} -> - ?LOG(debug, "~p got {ok, ~0p, ~0p}", [Fun, Resp, _Metadata]), - reply(From, Fun, {ok, Resp}); - {error, {Code, Msg}, _Metadata} -> - ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) response errcode: ~0p, errmsg: ~0p", - [?CONN_ADAPTER_MOD, Fun, Req, Options, Code, Msg]), - reply(From, Fun, {error, {Code, Msg}}); - {error, Reason} -> - ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) error: ~0p", - [?CONN_ADAPTER_MOD, Fun, Req, Options, Reason]), - reply(From, Fun, {error, Reason}); - {'EXIT', Reason, Stk} -> - ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) throw an exception: ~0p, stacktrace: ~p", - [?CONN_ADAPTER_MOD, Fun, Req, Options, Reason, Stk]), - reply(From, Fun, {error, Reason}) - end, - {noreply, State}. - -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}. diff --git a/apps/emqx_exproto/src/emqx_exproto_gsvr.erl b/apps/emqx_exproto/src/emqx_exproto_gsvr.erl deleted file mode 100644 index 4b5fbd076..000000000 --- a/apps/emqx_exproto/src/emqx_exproto_gsvr.erl +++ /dev/null @@ -1,154 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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(ctx:ctx(), emqx_exproto_pb:send_bytes_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -send(Ctx, Req = #{conn := Conn, bytes := Bytes}) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response(call(Conn, {send, Bytes})), Ctx}. - --spec close(ctx:ctx(), emqx_exproto_pb:close_socket_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -close(Ctx, Req = #{conn := Conn}) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response(call(Conn, close)), Ctx}. - --spec authenticate(ctx:ctx(), emqx_exproto_pb:authenticate_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -authenticate(Ctx, Req = #{conn := Conn, - password := Password, - clientinfo := ClientInfo}) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - case validate(clientinfo, ClientInfo) of - false -> - {ok, response({error, ?RESP_REQUIRED_PARAMS_MISSED}), Ctx}; - _ -> - {ok, response(call(Conn, {auth, ClientInfo, Password})), Ctx} - end. - --spec start_timer(ctx:ctx(), emqx_exproto_pb:publish_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -start_timer(Ctx, Req = #{conn := Conn, type := Type, interval := Interval}) - 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})), Ctx}; -start_timer(Ctx, Req) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Ctx}. - --spec publish(ctx:ctx(), emqx_exproto_pb:publish_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -publish(Ctx, Req = #{conn := Conn, topic := Topic, qos := Qos, payload := Payload}) - when ?IS_QOS(Qos) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response(call(Conn, {publish, Topic, Qos, Payload})), Ctx}; - -publish(Ctx, Req) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Ctx}. - --spec subscribe(ctx:ctx(), emqx_exproto_pb:subscribe_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -subscribe(Ctx, Req = #{conn := Conn, topic := Topic, qos := Qos}) - when ?IS_QOS(Qos) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response(call(Conn, {subscribe, Topic, Qos})), Ctx}; - -subscribe(Ctx, Req) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Ctx}. - --spec unsubscribe(ctx:ctx(), emqx_exproto_pb:unsubscribe_request()) - -> {ok, emqx_exproto_pb:code_response(), ctx:ctx()} - | grpcbox_stream:grpc_error_response(). -unsubscribe(Ctx, Req = #{conn := Conn, topic := Topic}) -> - ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), - {ok, response(call(Conn, {unsubscribe, Topic})), Ctx}. - -%%-------------------------------------------------------------------- -%% Internal funcs -%%-------------------------------------------------------------------- - -to_pid(ConnStr) -> - list_to_pid(binary_to_list(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 index 1ff4b0575..64be2812c 100644 --- a/apps/emqx_exproto/src/emqx_exproto_sup.erl +++ b/apps/emqx_exproto/src/emqx_exproto_sup.erl @@ -20,67 +20,17 @@ -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) -> - ServerOpts = #{}, - GrpcOpts = #{service_protos => [emqx_exproto_pb], - services => #{'emqx.exproto.v1.ConnectionAdapter' => emqx_exproto_gsvr}}, - ListenOpts = #{port => Port, socket_options => [{reuseaddr, true}]}, - PoolOpts = #{size => 8}, - TransportOpts = maps:from_list(SSLOptions), - Spec = #{id => Name, - start => {grpcbox_services_sup, start_link, - [ServerOpts, GrpcOpts, ListenOpts, - PoolOpts, TransportOpts]}, - type => supervisor, - restart => permanent, - shutdown => infinity}, - supervisor:start_child(?MODULE, Spec). - --spec stop_grpc_server(atom()) -> ok. -stop_grpc_server(Name) -> - ok = supervisor:terminate_child(?MODULE, Name), - ok = supervisor:delete_child(?MODULE, Name). - --spec start_grpc_client_channel( - atom(), - [grpcbox_channel:endpoint()], - grpcbox_channel:options()) -> {ok, pid()} | {error, term()}. -start_grpc_client_channel(Name, Endpoints, Options0) -> - Options = Options0#{sync_start => true}, - Spec = #{id => Name, - start => {grpcbox_channel, start_link, [Name, Endpoints, Options]}, - type => worker}, - supervisor:start_child(?MODULE, Spec). - --spec stop_grpc_client_channel(atom()) -> ok. -stop_grpc_client_channel(Name) -> - ok = supervisor:terminate_child(?MODULE, Name), - ok = supervisor:delete_child(?MODULE, 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]}}. + DriverMngr = #{id => driver_mngr, + start => {emqx_exproto_driver_mngr, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [emqx_exproto_driver_mngr]}, + {ok, {{one_for_all, 10, 5}, [DriverMngr]}}. + diff --git a/apps/emqx_exproto/test/emqx_exproto_SUITE.erl b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl index dc6a25c06..5a9d4b830 100644 --- a/apps/emqx_exproto/test/emqx_exproto_SUITE.erl +++ b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl @@ -19,20 +19,7 @@ -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}]). @@ -50,38 +37,48 @@ groups() -> %% @private metrics() -> - [tcp, ssl, udp, dtls]. + [ list_to_atom(X ++ "_" ++ Y) + || X <- ["python3", "java"], Y <- ["tcp", "ssl", "udp", "dtls"]]. -init_per_group(GrpName, Cfg) -> - put(grpname, GrpName), - Svrs = emqx_exproto_echo_svr:start(), +init_per_group(GrpName, Config) -> + [Lang, LisType] = [list_to_atom(X) || X <- string:tokens(atom_to_list(GrpName), "_")], + put(grpname, {Lang, LisType}), emqx_ct_helpers:start_apps([emqx_exproto], fun set_sepecial_cfg/1), - emqx_logger:set_log_level(debug), - [{servers, Svrs}, {listener_type, GrpName} | Cfg]. + [{driver_type, Lang}, + {listener_type, LisType} | Config]. -end_per_group(_, Cfg) -> - emqx_ct_helpers:stop_apps([emqx_exproto]), - emqx_exproto_echo_svr:stop(proplists:get_value(servers, Cfg)). +end_per_group(_, _) -> + emqx_ct_helpers:stop_apps([emqx_exproto]). set_sepecial_cfg(emqx_exproto) -> - LisType = get(grpname), + {Lang, LisType} = get(grpname), + Path = emqx_ct_helpers:deps_path(emqx_exproto, "example/"), Listeners = application:get_env(emqx_exproto, listeners, []), + Driver = compile(Lang, Path), SockOpts = socketopts(LisType), UpgradeOpts = fun(Opts) -> - Opts2 = lists:keydelete(tcp_options, 1, Opts), + Opts1 = lists:keydelete(driver, 1, Opts), + Opts2 = lists:keydelete(tcp_options, 1, Opts1), Opts3 = lists:keydelete(ssl_options, 1, Opts2), Opts4 = lists:keydelete(udp_options, 1, Opts3), Opts5 = lists:keydelete(dtls_options, 1, Opts4), - SockOpts ++ Opts5 + Driver ++ 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), +set_sepecial_cfg(_App) -> ok. +compile(java, Path) -> + ErlPortJar = emqx_ct_helpers:deps_path(erlport, "priv/java/_pkgs/erlport.jar"), + ct:pal(os:cmd(lists:concat(["cd ", Path, " && ", + "rm -rf Main.class State.class && ", + "javac -cp ", ErlPortJar, " Main.java"]))), + [{driver, [{type, java}, {path, Path}, {cbm, 'Main'}]}]; +compile(python3, Path) -> + [{driver, [{type, python3}, {path, Path}, {cbm, main}]}]. + %%-------------------------------------------------------------------- %% Tests cases %%-------------------------------------------------------------------- @@ -89,263 +86,24 @@ set_sepecial_cfg(emqx) -> t_start_stop(_) -> ok. -t_mountpoint_echo(Cfg) -> +t_echo(Cfg) -> SockType = proplists:get_value(listener_type, Cfg), + Bin = rand_bytes(), + Sock = open(SockType), - Client = #{proto_name => <<"demo">>, - proto_ver => <<"v0.1">>, - clientid => <<"test_client_1">>, - mountpoint => <<"ct/">> - }, - Password = <<"123456">>, + send(Sock, Bin), - 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), - - 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), + {ok, Bin} = recv(Sock, byte_size(Bin), 5000), + %% pubsub echo + emqx:subscribe(<<"t/#">>), emqx:publish(emqx_message:make(<<"t/dn">>, <<"echo">>)), + First = receive {_, _, X} -> X#message.payload end, + First = receive {_, _, Y} -> Y#message.payload end, - 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 @@ -379,15 +137,15 @@ send({ssl, 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), +recv({tcp, Sock}, Size, Ts) -> + gen_tcp:recv(Sock, Size, Ts); +recv({udp, Sock}, Size, Ts) -> + {ok, {_, _, Bin}} = gen_udp:recv(Sock, Size, Ts), {ok, Bin}; -recv({ssl, Sock}, Ts) -> - ssl:recv(Sock, 0, Ts); -recv({dtls, Sock}, Ts) -> - ssl:recv(Sock, 0, Ts). +recv({ssl, Sock}, Size, Ts) -> + ssl:recv(Sock, Size, Ts); +recv({dtls, Sock}, Size, Ts) -> + ssl:recv(Sock, Size, Ts). close({tcp, Sock}) -> gen_tcp:close(Sock); diff --git a/apps/emqx_lua_hook/rebar.config b/apps/emqx_lua_hook/rebar.config index 7a5e7ea5c..fe4fa19cb 100644 --- a/apps/emqx_lua_hook/rebar.config +++ b/apps/emqx_lua_hook/rebar.config @@ -1,3 +1,3 @@ {deps, [{luerl, {git, "https://github.com/emqx/luerl", {tag, "v0.3.1"}}} - ]}. \ No newline at end of file + ]}. diff --git a/apps/emqx_lwm2m/rebar.config b/apps/emqx_lwm2m/rebar.config index 6b72537b7..d82966e71 100644 --- a/apps/emqx_lwm2m/rebar.config +++ b/apps/emqx_lwm2m/rebar.config @@ -1,3 +1,3 @@ {deps, - [{lwm2m_coap, {git, "https://github.com/emqx/lwm2m-coap", {tag, "v1.1.0"}}} - ]}. \ No newline at end of file + [{lwm2m_coap, {git, "https://github.com/emqx/lwm2m-coap", {tag, "v1.1.1"}}} + ]}. diff --git a/apps/emqx_management/src/emqx_mgmt.erl b/apps/emqx_management/src/emqx_mgmt.erl index 36d4b8e73..b5a319278 100644 --- a/apps/emqx_management/src/emqx_mgmt.erl +++ b/apps/emqx_management/src/emqx_mgmt.erl @@ -457,7 +457,9 @@ do_subscribe(ClientId, TopicTables) -> end. %%TODO: ??? -publish(Msg) -> emqx:publish(Msg). +publish(Msg) -> + emqx_metrics:inc_msg(Msg), + emqx:publish(Msg). unsubscribe(ClientId, Topic) -> unsubscribe(ekka_mnesia:running_nodes(), ClientId, Topic). diff --git a/apps/emqx_management/src/emqx_mgmt_api_data.erl b/apps/emqx_management/src/emqx_mgmt_api_data.erl index 6fc44add0..a449141ce 100644 --- a/apps/emqx_management/src/emqx_mgmt_api_data.erl +++ b/apps/emqx_management/src/emqx_mgmt_api_data.erl @@ -70,6 +70,10 @@ , delete/2 ]). +-export([ get_list_exported/0 + , do_import/1 + ]). + export(_Bindings, _Params) -> Rules = emqx_mgmt:export_rules(), Resources = emqx_mgmt:export_resources(), @@ -97,8 +101,11 @@ export(_Bindings, _Params) -> {auth_username, AuthUsername}, {auth_mnesia, AuthMnesia}, {acl_mnesia, AclMnesia}, - {schemas, Schemas}], + {schemas, Schemas} + ], + Bin = emqx_json:encode(Data), + ok = filelib:ensure_dir(NFilename), case file:write_file(NFilename, Bin) of ok -> case file:read_file_info(NFilename) of @@ -106,7 +113,9 @@ export(_Bindings, _Params) -> CreatedAt = io_lib:format("~p-~p-~p ~p:~p:~p", [Y, M, D, H, MM, S]), return({ok, [{filename, list_to_binary(Filename)}, {size, Size}, - {created_at, list_to_binary(CreatedAt)}]}); + {created_at, list_to_binary(CreatedAt)}, + {node, node()} + ]}); {error, Reason} -> return({error, Reason}) end; @@ -115,66 +124,86 @@ export(_Bindings, _Params) -> end. list_exported(_Bindings, _Params) -> + List = [ rpc:call(Node, ?MODULE, get_list_exported, []) || Node <- ekka_mnesia:running_nodes() ], + NList = lists:map(fun({_, FileInfo}) -> FileInfo end, lists:keysort(1, lists:append(List))), + return({ok, NList}). + +get_list_exported() -> Dir = emqx:get_env(data_dir), {ok, Files} = file:list_dir_all(Dir), - List = lists:foldl(fun(File, Acc) -> - case filename:extension(File) =:= ".json" of - true -> - FullFile = filename:join([Dir, File]), - case file:read_file_info(FullFile) of - {ok, #file_info{size = Size, ctime = CTime = {{Y, M, D}, {H, MM, S}}}} -> - CreatedAt = io_lib:format("~p-~p-~p ~p:~p:~p", [Y, M, D, H, MM, S]), - Seconds = calendar:datetime_to_gregorian_seconds(CTime), - [{Seconds, [{filename, list_to_binary(File)}, - {size, Size}, - {created_at, list_to_binary(CreatedAt)}]} | Acc]; - {error, Reason} -> - logger:error("Read file info of ~s failed with: ~p", [File, Reason]), - Acc - end; - false -> - Acc - end - end, [], Files), - NList = lists:map(fun({_, FileInfo}) -> FileInfo end, lists:keysort(1, List)), - return({ok, NList}). + lists:foldl( + fun(File, Acc) -> + case filename:extension(File) =:= ".json" of + true -> + FullFile = filename:join([Dir, File]), + case file:read_file_info(FullFile) of + {ok, #file_info{size = Size, ctime = CTime = {{Y, M, D}, {H, MM, S}}}} -> + CreatedAt = io_lib:format("~p-~p-~p ~p:~p:~p", [Y, M, D, H, MM, S]), + Seconds = calendar:datetime_to_gregorian_seconds(CTime), + [{Seconds, [{filename, list_to_binary(File)}, + {size, Size}, + {created_at, list_to_binary(CreatedAt)}, + {node, node()} + ]} | Acc]; + {error, Reason} -> + logger:error("Read file info of ~s failed with: ~p", [File, Reason]), + Acc + end; + false -> Acc + end + end, [], Files). import(_Bindings, Params) -> case proplists:get_value(<<"filename">>, Params) of undefined -> return({error, missing_required_params}); Filename -> - FullFilename = filename:join([emqx:get_env(data_dir), Filename]), - case file:read_file(FullFilename) of - {ok, Json} -> - Data = emqx_json:decode(Json, [return_maps]), - Version = emqx_mgmt:to_version(maps:get(<<"version">>, Data)), - case lists:member(Version, ?VERSIONS) of - true -> - try - emqx_mgmt:import_resources(maps:get(<<"resources">>, Data, [])), - emqx_mgmt:import_rules(maps:get(<<"rules">>, Data, [])), - emqx_mgmt:import_blacklist(maps:get(<<"blacklist">>, Data, [])), - emqx_mgmt:import_applications(maps:get(<<"apps">>, Data, [])), - emqx_mgmt:import_users(maps:get(<<"users">>, Data, [])), - emqx_mgmt:import_auth_clientid(maps:get(<<"auth_clientid">>, Data, [])), - emqx_mgmt:import_auth_username(maps:get(<<"auth_username">>, Data, [])), - emqx_mgmt:import_auth_mnesia(maps:get(<<"auth_mnesia">>, Data, [])), - emqx_mgmt:import_acl_mnesia(maps:get(<<"acl_mnesia">>, Data, [])), - emqx_mgmt:import_schemas(maps:get(<<"schemas">>, Data, [])), - logger:debug("The emqx data has been imported successfully"), - return() - catch Class:Reason:Stack -> - logger:error("The emqx data import failed: ~0p", [{Class,Reason,Stack}]), - return({error, import_failed}) - end; - false -> - logger:error("Unsupported version: ~p", [Version]), - return({error, unsupported_version}) + Result = case proplists:get_value(<<"node">>, Params) of + undefined -> do_import(Filename); + Node -> + case lists:member(Node, + [ erlang:atom_to_binary(N, utf8) || N <- ekka_mnesia:running_nodes() ] + ) of + true -> rpc:call(erlang:binary_to_atom(Node, utf8), ?MODULE, do_import, [Filename]); + false -> return({error, no_existent_node}) + end + end, + return(Result) + end. + +do_import(Filename) -> + FullFilename = filename:join([emqx:get_env(data_dir), Filename]), + case file:read_file(FullFilename) of + {ok, Json} -> + Data = emqx_json:decode(Json, [return_maps]), + Version = emqx_mgmt:to_version(maps:get(<<"version">>, Data)), + case lists:member(Version, ?VERSIONS) of + true -> + try + emqx_mgmt:import_confs(maps:get(<<"configs">>, Data, []), maps:get(<<"listeners_state">>, Data, [])), + emqx_mgmt:import_resources(maps:get(<<"resources">>, Data, [])), + emqx_mgmt:import_rules(maps:get(<<"rules">>, Data, [])), + emqx_mgmt:import_blacklist(maps:get(<<"blacklist">>, Data, [])), + emqx_mgmt:import_applications(maps:get(<<"apps">>, Data, [])), + emqx_mgmt:import_users(maps:get(<<"users">>, Data, [])), + emqx_mgmt:import_modules(maps:get(<<"modules">>, Data, [])), + emqx_mgmt:import_auth_clientid(maps:get(<<"auth_clientid">>, Data, [])), + emqx_mgmt:import_auth_username(maps:get(<<"auth_username">>, Data, [])), + emqx_mgmt:import_auth_mnesia(maps:get(<<"auth_mnesia">>, Data, []), Version), + emqx_mgmt:import_acl_mnesia(maps:get(<<"acl_mnesia">>, Data, []), Version), + emqx_mgmt:import_schemas(maps:get(<<"schemas">>, Data, [])), + logger:debug("The emqx data has been imported successfully"), + ok + catch Class:Reason:Stack -> + logger:error("The emqx data import failed: ~0p", [{Class,Reason,Stack}]), + {error, import_failed} end; - {error, Reason} -> - return({error, Reason}) - end + false -> + logger:error("Unsupported version: ~p", [Version]), + {error, unsupported_version} + end; + {error, Reason} -> + {error, Reason} end. download(#{filename := Filename}, _Params) -> @@ -195,7 +224,7 @@ do_upload(_Bindings, #{<<"filename">> := Filename, FullFilename = filename:join([emqx:get_env(data_dir), Filename]), case file:write_file(FullFilename, Bin) of ok -> - return(); + return({ok, [{node, node()}]}); {error, Reason} -> return({error, Reason}) end; @@ -214,4 +243,4 @@ delete(#{filename := Filename}, _Params) -> return(); {error, Reason} -> return({error, Reason}) - end. \ No newline at end of file + end. diff --git a/apps/emqx_management/src/emqx_mgmt_cli.erl b/apps/emqx_management/src/emqx_mgmt_cli.erl index ae9b16459..b0870cbf3 100644 --- a/apps/emqx_management/src/emqx_mgmt_cli.erl +++ b/apps/emqx_management/src/emqx_mgmt_cli.erl @@ -585,6 +585,7 @@ data(["export"]) -> {auth_mnesia, AuthMnesia}, {acl_mnesia, AclMnesia}, {schemas, Schemas}], + ok = filelib:ensure_dir(NFilename), case file:write_file(NFilename, emqx_json:encode(Data)) of ok -> emqx_ctl:print("The emqx data has been successfully exported to ~s.~n", [NFilename]); diff --git a/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl index 2b39b95cb..02ab1e8fd 100644 --- a/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl +++ b/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl @@ -53,7 +53,8 @@ groups() -> acl_cache, pubsub, routes_and_subscriptions, - stats]}]. + stats, + data]}]. init_per_suite(Config) -> emqx_ct_helpers:start_apps([emqx, emqx_management, emqx_reloader]), @@ -65,6 +66,36 @@ end_per_suite(_Config) -> emqx_ct_helpers:stop_apps([emqx_reloader, emqx_management, emqx]), ekka_mnesia:ensure_stopped(). +init_per_testcase(data, Config) -> + ok = emqx_dashboard_admin:mnesia(boot), + application:ensure_all_started(emqx_dahboard), + ok = emqx_rule_registry:mnesia(boot), + application:ensure_all_started(emqx_rule_engine), + + meck:new(emqx_sys, [passthrough, no_history]), + meck:expect(emqx_sys, version, 0, + fun() -> + Tag =os:cmd("git describe --abbrev=0 --tags") -- "\n", + re:replace(Tag, "[v|e]", "", [{return ,list}]) + end), + + Config; + +init_per_testcase(_, Config) -> + Config. + +stop_pre_testcase(data, _Config) -> + application:stop(emqx_dahboard), + application:stop(emqx_rule_engine), + application:stop(emqx_modules), + application:stop(emqx_schema_registry), + application:stop(emqx_conf), + meck:unload(emqx_sys), + ok; + +stop_pre_testcase(_, _Config) -> + ok. + get(Key, ResponseBody) -> maps:get(Key, jiffy:decode(list_to_binary(ResponseBody), [return_maps])). @@ -432,6 +463,10 @@ acl_cache(_) -> ok = emqtt:disconnect(C1). pubsub(_) -> + Qos1Received = emqx_metrics:val('messages.qos1.received'), + Qos2Received = emqx_metrics:val('messages.qos2.received'), + Received = emqx_metrics:val('messages.received'), + ClientId = <<"client1">>, Options = #{clientid => ClientId, proto_ver => 5}, @@ -532,7 +567,11 @@ pubsub(_) -> {ok, Data3} = request_api(post, api_path(["mqtt/unsubscribe_batch"]), [], auth_header_(), Body3), loop(maps:get(<<"data">>, jiffy:decode(list_to_binary(Data3), [return_maps]))), - ok = emqtt:disconnect(C1). + ok = emqtt:disconnect(C1), + + ?assertEqual(2, emqx_metrics:val('messages.qos1.received') - Qos1Received), + ?assertEqual(2, emqx_metrics:val('messages.qos2.received') - Qos2Received), + ?assertEqual(4, emqx_metrics:val('messages.received') - Received). loop([]) -> []; @@ -596,6 +635,17 @@ stats(_) -> ?assertEqual(<<"undefined">>, get(<<"message">>, Return)), meck:unload(emqx_mgmt). +data(_) -> + {ok, Data} = request_api(post, api_path(["data","export"]), [], auth_header_(), [#{}]), + #{<<"filename">> := Filename, <<"node">> := Node} = emqx_ct_http:get_http_data(Data), + {ok, DataList} = request_api(get, api_path(["data","export"]), auth_header_()), + ?assertEqual(true, lists:member(emqx_ct_http:get_http_data(Data), emqx_ct_http:get_http_data(DataList))), + + ?assertMatch({ok, _}, request_api(post, api_path(["data","import"]), [], auth_header_(), #{<<"filename">> => Filename, <<"node">> => Node})), + ?assertMatch({ok, _}, request_api(post, api_path(["data","import"]), [], auth_header_(), #{<<"filename">> => Filename})), + + ok. + request_api(Method, Url, Auth) -> request_api(Method, Url, [], Auth, []). diff --git a/apps/emqx_passwd/rebar.config b/apps/emqx_passwd/rebar.config index acaa113b3..ad28a5bff 100644 --- a/apps/emqx_passwd/rebar.config +++ b/apps/emqx_passwd/rebar.config @@ -1,7 +1,7 @@ -{deps, [ - {bcrypt, {git, "https://github.com/emqx/erlang-bcrypt.git", {tag, "0.6.0"}}} -]}. +{deps, + [{pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {branch, "2.0.3"}}} + ]}. {plugins, [ {pc, {git, "https://github.com/emqx/port_compiler.git", {tag, "v1.11.1"}}} - ]}. \ No newline at end of file + ]}. diff --git a/apps/emqx_passwd/src/emqx_passwd.app.src b/apps/emqx_passwd/src/emqx_passwd.app.src index 7d384a2ad..8e66d443a 100644 --- a/apps/emqx_passwd/src/emqx_passwd.app.src +++ b/apps/emqx_passwd/src/emqx_passwd.app.src @@ -1,9 +1,9 @@ {application, emqx_passwd, [{description, "Password Hash Library for EMQ X Broker"}, - {vsn, "4.3.0"}, % strict semver, bump manually! + {vsn, "0.1.1"}, % strict semver, bump manually! {modules, ['emqx_passwd']}, {registered, []}, - {applications, [kernel,stdlib,ssl,pbkdf2,bcrypt,emqx]}, + {applications, [kernel,stdlib,ssl,pbkdf2,emqx]}, {env, []}, {licenses, ["Apache-2.0"]}, {maintainers, ["EMQ X Team "]}, diff --git a/apps/emqx_prometheus/rebar.config b/apps/emqx_prometheus/rebar.config index 0f8b760c1..2d8d63134 100644 --- a/apps/emqx_prometheus/rebar.config +++ b/apps/emqx_prometheus/rebar.config @@ -1,3 +1,3 @@ {deps, [{prometheus, {git, "https://github.com/emqx/prometheus.erl", {tag, "v3.1.1"}}} - ]}. \ No newline at end of file + ]}. diff --git a/apps/emqx_psk_file/rebar.config b/apps/emqx_psk_file/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_psk_file/rebar.config +++ b/apps/emqx_psk_file/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_recon/rebar.config b/apps/emqx_recon/rebar.config index a60702ae3..f5fd83abe 100644 --- a/apps/emqx_recon/rebar.config +++ b/apps/emqx_recon/rebar.config @@ -1,3 +1,3 @@ {deps, [ {recon, {git, "https://github.com/ferd/recon", {tag, "2.5.0"}}} -]}. \ No newline at end of file +]}. diff --git a/apps/emqx_retainer/rebar.config b/apps/emqx_retainer/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_retainer/rebar.config +++ b/apps/emqx_retainer/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_rule_engine/rebar.config b/apps/emqx_rule_engine/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_rule_engine/rebar.config +++ b/apps/emqx_rule_engine/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_rule_engine/src/emqx_rule_actions.erl b/apps/emqx_rule_engine/src/emqx_rule_actions.erl index 5ee860e94..e024f0cfa 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_actions.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_actions.erl @@ -134,35 +134,37 @@ on_action_create_republish(Id, #{<<"target_topic">> := TargetTopic, <<"target_qo (Selected, _Envs = #{qos := QoS, flags := Flags, timestamp := Timestamp}) -> ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", [TargetTopic, Selected]), - emqx_broker:safe_publish( - emqx_message:set_headers( - #{republish_by => Id}, - #message{ - id = emqx_guid:gen(), - qos = if TargetQoS =:= -1 -> QoS; true -> TargetQoS end, - from = Id, - flags = Flags, - topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), - payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), - timestamp = Timestamp - }) - ); + increase_and_publish( + #message{ + id = emqx_guid:gen(), + qos = if TargetQoS =:= -1 -> QoS; true -> TargetQoS end, + from = Id, + flags = Flags, + headers = #{republish_by => Id}, + topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), + payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), + timestamp = Timestamp + }); %% in case this is not a "message.publish" request (Selected, _Envs) -> ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", [TargetTopic, Selected]), - emqx_broker:safe_publish( - #message{ - id = emqx_guid:gen(), - qos = if TargetQoS =:= -1 -> 0; true -> TargetQoS end, - from = Id, - flags = #{dup => false, retain => false}, - headers = #{republish_by => Id}, - topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), - payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), - timestamp = erlang:system_time(millisecond) - }) + increase_and_publish( + #message{ + id = emqx_guid:gen(), + qos = if TargetQoS =:= -1 -> 0; true -> TargetQoS end, + from = Id, + flags = #{dup => false, retain => false}, + headers = #{republish_by => Id}, + topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), + payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), + timestamp = erlang:system_time(millisecond) + }) end. +increase_and_publish(Msg) -> + emqx_metrics:inc_msg(Msg), + emqx_broker:safe_publish(Msg). + on_action_do_nothing(_, _) -> fun(_, _) -> ok end. diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine.erl b/apps/emqx_rule_engine/src/emqx_rule_engine.erl index 41b961230..8384ef4db 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine.erl @@ -420,7 +420,7 @@ action_instance_id(ActionName) -> iolist_to_binary([atom_to_list(ActionName), "_", integer_to_list(erlang:system_time())]). cluster_call(Func, Args) -> - case rpc:multicall([node() | nodes()], ?MODULE, Func, Args, 5000) of + case rpc:multicall(ekka_mnesia:running_nodes(), ?MODULE, Func, Args, 5000) of {ResL, []} -> case lists:filter(fun(ok) -> false; (_) -> true end, ResL) of [] -> ok; diff --git a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl index 66eafc36e..6795f1522 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl @@ -130,10 +130,18 @@ ]). %% Map Funcs +-export([ map_new/0 + ]). + -export([ map_get/2 , map_get/3 , map_put/3 - , map_new/0 + ]). + +%% For backword compatibility +-export([ mget/2 + , mget/3 + , mput/3 ]). %% Array Funcs @@ -225,7 +233,7 @@ payload() -> payload(Path) -> fun(#{payload := Payload}) when erlang:is_map(Payload) -> - emqx_rule_maps:nested_get(map_path(Path), Payload); + map_get(Path, Payload); (_) -> undefined end. @@ -599,6 +607,15 @@ map_get(Key, Map) -> map_get(Key, Map, undefined). map_get(Key, Map, Default) -> + emqx_rule_maps:nested_get(map_path(Key), Map, Default). + +map_put(Key, Val, Map) -> + emqx_rule_maps:nested_put(map_path(Key), Val, Map). + +mget(Key, Map) -> + mget(Key, Map, undefined). + +mget(Key, Map, Default) -> case maps:find(Key, Map) of {ok, Val} -> Val; error when is_atom(Key) -> @@ -622,7 +639,7 @@ map_get(Key, Map, Default) -> Default end. -map_put(Key, Val, Map) -> +mput(Key, Val, Map) -> case maps:find(Key, Map) of {ok, _} -> maps:put(Key, Val, Map); error when is_atom(Key) -> diff --git a/apps/emqx_rule_engine/src/emqx_rule_validator.erl b/apps/emqx_rule_engine/src/emqx_rule_validator.erl index 8acbb3afc..76c5ea19c 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_validator.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_validator.erl @@ -25,7 +25,7 @@ -type(params_spec() :: #{atom() => term()}). -type(params() :: #{binary() => term()}). --define(DATA_TYPES, [string, number, float, boolean, object, array]). +-define(DATA_TYPES, [string, number, float, boolean, object, array, file]). %%------------------------------------------------------------------------------ %% APIs @@ -68,6 +68,8 @@ do_validate_param(Val, Spec = #{type := Type}) -> end, validate_type(Val, Type, Spec). +validate_type(Val, file, _Spec) -> + ok = validate_file(Val); validate_type(Val, string, Spec) -> ok = validate_string(Val, reg_exp(maps:get(format, Spec, any))); validate_type(Val, number, Spec) -> @@ -110,6 +112,9 @@ validate_boolean(true) -> ok; validate_boolean(false) -> ok; validate_boolean(Val) -> error({invalid_data_type, {boolean, Val}}). +validate_file(Val) when is_binary(Val) -> ok; +validate_file(Val) -> error({invalid_data_type, {file, Val}}). + reg_exp(url) -> "^https?://\\w+(\.\\w+)*(:[0-9]+)?"; reg_exp(topic) -> "^/?(\\w|\\#|\\+)+(/?(\\w|\\#|\\+))*/?$"; reg_exp(resource_type) -> "[a-zA-Z0-9_:-]"; diff --git a/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl index 9836b96f0..348ef046f 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl @@ -51,7 +51,7 @@ groups() -> ]}, {actions, [], [t_inspect_action - %,t_republish_action + ,t_republish_action ]}, {api, [], [t_crud_rule_api, @@ -325,12 +325,14 @@ t_inspect_action(_Config) -> ok. t_republish_action(_Config) -> + Qos0Received = emqx_metrics:val('messages.qos0.received'), + Received = emqx_metrics:val('messages.received'), ok = emqx_rule_engine:load_providers(), {ok, #rule{id = Id, for = [<<"t1">>]}} = emqx_rule_engine:create_rule( #{rawsql => <<"select topic, payload, qos from \"t1\"">>, actions => [#{name => 'republish', - args => #{<<"target_topic">> => <<"t1">>, + args => #{<<"target_topic">> => <<"t2">>, <<"target_qos">> => -1, <<"payload_tmpl">> => <<"${payload}">>}}], description => <<"builtin-republish-rule">>}), @@ -347,6 +349,8 @@ t_republish_action(_Config) -> end, emqtt:stop(Client), emqx_rule_registry:remove_rule(Id), + ?assertEqual(2, emqx_metrics:val('messages.qos0.received') - Qos0Received ), + ?assertEqual(2, emqx_metrics:val('messages.received') - Received), ok. %%------------------------------------------------------------------------------ diff --git a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl index 8a49ab520..582eb5832 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl @@ -489,12 +489,27 @@ t_contains(_) -> t_map_get(_) -> ?assertEqual(1, apply_func(map_get, [<<"a">>, #{a => 1}])), - ?assertEqual(undefined, apply_func(map_get, [<<"a">>, #{}])). + ?assertEqual(undefined, apply_func(map_get, [<<"a">>, #{}])), + ?assertEqual(1, apply_func(map_get, [<<"a.b">>, #{a => #{b => 1}}])), + ?assertEqual(undefined, apply_func(map_get, [<<"a.c">>, #{a => #{b => 1}}])). t_map_put(_) -> ?assertEqual(#{<<"a">> => 1}, apply_func(map_put, [<<"a">>, 1, #{}])), + ?assertEqual(#{a => 2}, apply_func(map_put, [<<"a">>, 2, #{a => 1}])), + ?assertEqual(#{<<"a">> => #{<<"b">> => 1}}, apply_func(map_put, [<<"a.b">>, 1, #{}])), + ?assertEqual(#{a => #{b => 1, <<"c">> => 1}}, apply_func(map_put, [<<"a.c">>, 1, #{a => #{b => 1}}])). + + t_mget(_) -> + ?assertEqual(1, apply_func(map_get, [<<"a">>, #{a => 1}])), + ?assertEqual(1, apply_func(map_get, [<<"a">>, #{<<"a">> => 1}])), + ?assertEqual(undefined, apply_func(map_get, [<<"a">>, #{}])). + + t_mput(_) -> + ?assertEqual(#{<<"a">> => 1}, apply_func(map_put, [<<"a">>, 1, #{}])), + ?assertEqual(#{<<"a">> => 2}, apply_func(map_put, [<<"a">>, 2, #{<<"a">> => 1}])), ?assertEqual(#{a => 2}, apply_func(map_put, [<<"a">>, 2, #{a => 1}])). + %%------------------------------------------------------------------------------ %% Test cases for Hash funcs %%------------------------------------------------------------------------------ diff --git a/apps/emqx_sasl/rebar.config b/apps/emqx_sasl/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_sasl/rebar.config +++ b/apps/emqx_sasl/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_sasl/src/emqx_sasl_api.erl b/apps/emqx_sasl/src/emqx_sasl_api.erl index e17296285..1f77cd9e7 100644 --- a/apps/emqx_sasl/src/emqx_sasl_api.erl +++ b/apps/emqx_sasl/src/emqx_sasl_api.erl @@ -92,8 +92,8 @@ get(_Bindings, #{<<"mechanism">> := Mechanism0, case Mechanism of <<"SCRAM-SHA-1">> -> case emqx_sasl_scram:lookup(Username) of - {ok, AuthInfo} -> - return({ok, AuthInfo}); + {ok, AuthInfo = #{salt := Salt}} -> + return({ok, AuthInfo#{salt => base64:decode(Salt)}}); {error, Reason} -> return({error, Reason}) end; diff --git a/apps/emqx_sasl/src/emqx_sasl_cli.erl b/apps/emqx_sasl/src/emqx_sasl_cli.erl index 19736a108..375fc60ec 100644 --- a/apps/emqx_sasl/src/emqx_sasl_cli.erl +++ b/apps/emqx_sasl/src/emqx_sasl_cli.erl @@ -70,7 +70,7 @@ cli(["scram", "lookup", Username0]) -> salt := Salt, iteration_count := IterationCount}} -> emqx_ctl:print("Username: ~s, Stored Key: ~s, Server Key: ~s, Salt: ~s, Iteration Count: ~p~n", - [Username, StoredKey, ServerKey, Salt, IterationCount]); + [Username, StoredKey, ServerKey, base64:decode(Salt), IterationCount]); {error, not_found} -> emqx_ctl:print("Authentication information not found~n") end; diff --git a/apps/emqx_sasl/test/emqx_sasl_scram_SUITE.erl b/apps/emqx_sasl/test/emqx_sasl_scram_SUITE.erl index 752f19152..de87748f7 100644 --- a/apps/emqx_sasl/test/emqx_sasl_scram_SUITE.erl +++ b/apps/emqx_sasl/test/emqx_sasl_scram_SUITE.erl @@ -77,3 +77,64 @@ t_scram(_) -> {ok, {ok, ServerFinal, #{}}} = emqx_sasl:check(AuthMethod, ClientFinal, Cache), {ok, _} = emqx_sasl:check(AuthMethod, ServerFinal, ClientCache). + +t_proto(_) -> + process_flag(trap_exit, true), + + Username = <<"username">>, + Password = <<"password">>, + Salt = <<"emqx">>, + AuthMethod = <<"SCRAM-SHA-1">>, + + {ok, Client0} = emqtt:start_link([{clean_start, true}, + {proto_ver, v5}, + {enhanced_auth, #{method => AuthMethod, + params => #{username => Username, + password => Password, + salt => Salt}}}, + {connect_timeout, 6000}]), + {error,{not_authorized,#{}}} = emqtt:connect(Client0), + + ok = emqx_sasl_scram:add(Username, Password, Salt), + {ok, Client1} = emqtt:start_link([{clean_start, true}, + {proto_ver, v5}, + {enhanced_auth, #{method => AuthMethod, + params => #{username => Username, + password => Password, + salt => Salt}}}, + {connect_timeout, 6000}]), + {ok, _} = emqtt:connect(Client1), + + timer:sleep(200), + ok = emqtt:reauthentication(Client1, #{params => #{username => Username, + password => Password, + salt => Salt}}), + + timer:sleep(200), + ErrorFun = fun (_State) -> {ok, <<>>, #{}} end, + ok = emqtt:reauthentication(Client1, #{params => #{},function => ErrorFun}), + receive + {disconnected,ReasonCode2,#{}} -> + ?assertEqual(ReasonCode2, 135) + after 500 -> + error("emqx re-authentication failed") + end, + + {ok, Client2} = emqtt:start_link([{clean_start, true}, + {proto_ver, v5}, + {enhanced_auth, #{method => AuthMethod, + params => #{}, + function =>fun (_State) -> {ok, <<>>, #{}} end}}, + {connect_timeout, 6000}]), + {error,{not_authorized,#{}}} = emqtt:connect(Client2), + + receive_msg(), + process_flag(trap_exit, false). + +receive_msg() -> + receive + {'EXIT', Msg} -> + ct:print("==========+~p~n", [Msg]), + receive_msg() + after 200 -> ok + end. diff --git a/apps/emqx_sn/rebar.config b/apps/emqx_sn/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_sn/rebar.config +++ b/apps/emqx_sn/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_sn/src/emqx_sn_gateway.erl b/apps/emqx_sn/src/emqx_sn_gateway.erl index aa1f090bb..213aaf385 100644 --- a/apps/emqx_sn/src/emqx_sn_gateway.erl +++ b/apps/emqx_sn/src/emqx_sn_gateway.erl @@ -201,8 +201,7 @@ idle(cast, {incoming, ?SN_PUBLISH_MSG(#mqtt_sn_flags{qos = ?QOS_NEG1, emqx_sn_registry:lookup_topic(Registry, ClientId, TopicId); true -> <> end, - Msg = emqx_message:make({?NEG_QOS_CLIENT_ID, State#state.username}, - ?QOS_0, TopicName, Data), + Msg = emqx_message:make(?NEG_QOS_CLIENT_ID, ?QOS_0, TopicName, Data), (TopicName =/= undefined) andalso emqx_broker:publish(Msg), ?LOG(debug, "Client id=~p receives a publish with QoS=-1 in idle mode!", [ClientId], State), {keep_state_and_data, State#state.idle_timeout}; @@ -942,14 +941,13 @@ do_publish_will(#state{will_msg = #will_msg{payload = undefined}}) -> ok; do_publish_will(#state{will_msg = #will_msg{topic = undefined}}) -> ok; -do_publish_will(#state{channel = Channel, will_msg = WillMsg}) -> +do_publish_will(#state{will_msg = WillMsg, clientid = ClientId}) -> #will_msg{qos = QoS, retain = Retain, topic = Topic, payload = Payload} = WillMsg, Publish = #mqtt_packet{header = #mqtt_packet_header{type = ?PUBLISH, dup = false, qos = QoS, retain = Retain}, variable = #mqtt_packet_publish{topic_name = Topic, packet_id = 1000}, payload = Payload}, - ClientInfo = emqx_channel:info(clientinfo, Channel), - emqx_broker:publish(emqx_packet:to_message(ClientInfo, Publish)), + emqx_broker:publish(emqx_packet:to_message(Publish, ClientId)), ok. do_puback(TopicId, MsgId, ReturnCode, _StateName, diff --git a/apps/emqx_stomp/rebar.config b/apps/emqx_stomp/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_stomp/rebar.config +++ b/apps/emqx_stomp/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_telemetry/rebar.config b/apps/emqx_telemetry/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_telemetry/rebar.config +++ b/apps/emqx_telemetry/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_web_hook/rebar.config b/apps/emqx_web_hook/rebar.config index 7b1f06cca..7b30a8fd8 100644 --- a/apps/emqx_web_hook/rebar.config +++ b/apps/emqx_web_hook/rebar.config @@ -1 +1 @@ -{deps, []}. \ No newline at end of file +{deps, []}. diff --git a/apps/emqx_web_hook/src/emqx_web_hook_actions.erl b/apps/emqx_web_hook/src/emqx_web_hook_actions.erl index a2cb1dec5..2b0cac618 100644 --- a/apps/emqx_web_hook/src/emqx_web_hook_actions.erl +++ b/apps/emqx_web_hook/src/emqx_web_hook_actions.erl @@ -22,27 +22,40 @@ -define(RESOURCE_TYPE_WEBHOOK, 'web_hook'). -define(RESOURCE_CONFIG_SPEC, #{ - url => #{type => string, + url => #{order => 1, + type => string, format => url, required => true, title => #{en => <<"Request URL">>, zh => <<"请求 URL"/utf8>>}, description => #{en => <<"Request URL">>, zh => <<"请求 URL"/utf8>>}}, - headers => #{type => object, - schema => #{}, - default => #{}, - title => #{en => <<"Request Header">>, - zh => <<"请求头"/utf8>>}, - description => #{en => <<"Request Header">>, - zh => <<"请求头"/utf8>>}}, - method => #{type => string, + method => #{order => 2, + type => string, enum => [<<"PUT">>,<<"POST">>,<<"GET">>,<<"DELETE">>], default => <<"POST">>, title => #{en => <<"Request Method">>, zh => <<"请求方法"/utf8>>}, - description => #{en => <<"Request Method. Note that the payload_template will be discarded in case of GET method">>, - zh => <<"请求方法。注意:当请求方法为 GET 的时候,payload_template 参数会被忽略"/utf8>>}} + description => #{en => <<"Request Method. \n" + "Note that: the Payload Template of Action will be discarded in case of GET method">>, + zh => <<"请求方法。\n" + "注意:当方法为 GET 时,动作中的 '消息内容模板' 参数会被忽略"/utf8>>}}, + content_type => #{order => 3, + type => string, + enum => [<<"application/json">>,<<"text/plain;charset=UTF-8">>], + default => <<"application/json">>, + title => #{en => <<"Content-Type">>, + zh => <<"Content-Type"/utf8>>}, + description => #{en => <<"The Content-Type of HTTP Request">>, + zh => <<"HTTP 请求头中的 Content-Type 字段值"/utf8>>}}, + headers => #{order => 4, + type => object, + schema => #{}, + default => #{}, + title => #{en => <<"Request Header">>, + zh => <<"请求头"/utf8>>}, + description => #{en => <<"The custom HTTP request headers">>, + zh => <<"自定义的 HTTP 请求头列表"/utf8>>}} }). -define(ACTION_PARAM_RESOURCE, #{ @@ -57,18 +70,32 @@ -define(ACTION_DATA_SPEC, #{ '$resource' => ?ACTION_PARAM_RESOURCE, + path => #{order => 1, + type => string, + required => false, + default => <<>>, + title => #{en => <<"Path">>, + zh => <<"Path"/utf8>>}, + description => #{en => <<"A path component, variable interpolation from " + "SQL statement is supported. This value will be " + "concatenated with Request URL.">>, + zh => <<"URL 的路径配置,支持使用 ${} 获取规则输出的字段值。\n" + "例如:${clientid}。该值会与 Request URL 组成一个完整的 URL"/utf8>>} + }, payload_tmpl => #{ - order => 1, + 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 JOSN format">>, - zh => <<"消息内容模板,支持变量。若使用空模板(默认),消息内容为 JSON 格式的所有字段"/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>>}} + }). -resource_type(#{name => ?RESOURCE_TYPE_WEBHOOK, create => on_resource_create, @@ -139,11 +166,13 @@ on_resource_destroy(_ResId, _Params) -> %% An action that forwards publish messages to a remote web server. -spec(on_action_create_data_to_webserver(Id::binary(), #{url() := string()}) -> action_fun()). on_action_create_data_to_webserver(_Id, Params) -> - #{url := Url, headers := Headers, method := Method, payload_tmpl := PayloadTmpl} + #{url := Url, headers := Headers, method := Method, content_type := ContentType, payload_tmpl := PayloadTmpl, path := Path} = parse_action_params(Params), PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), + PathTks = emqx_rule_utils:preproc_tmpl(Path), fun(Selected, _Envs) -> - http_request(Url, Headers, Method, format_msg(PayloadTks, Selected)) + FullUrl = Url ++ emqx_rule_utils:proc_tmpl(PathTks, Selected), + http_request(FullUrl, Headers, Method, ContentType, format_msg(PayloadTks, Selected)) end. format_msg([], Data) -> @@ -155,15 +184,15 @@ format_msg(Tokens, Data) -> %% Internal functions %%------------------------------------------------------------------------------ -create_req(get, Url, Headers, _) -> +create_req(get, Url, Headers, _, _) -> {(Url), (Headers)}; -create_req(_, Url, Headers, Body) -> - {(Url), (Headers), "application/json", (Body)}. +create_req(_, Url, Headers, ContentType, Body) -> + {(Url), (Headers), binary_to_list(ContentType), (Body)}. -http_request(Url, Headers, Method, Params) -> - logger:debug("[WebHook Action] ~s to ~s, headers: ~p, body: ~p", [Method, Url, Headers, Params]), - case do_http_request(Method, create_req(Method, Url, Headers, Params), +http_request(Url, Headers, Method, ContentType, Params) -> + logger:debug("[WebHook Action] ~s to ~s, headers: ~p, content-type: ~p, body: ~p", [Method, Url, Headers, ContentType, Params]), + case do_http_request(Method, create_req(Method, Url, Headers, ContentType, Params), [{timeout, 5000}], [], 0) of {ok, _} -> ok; {error, Reason} -> @@ -185,7 +214,9 @@ parse_action_params(Params = #{<<"url">> := Url}) -> #{url => str(Url), headers => headers(maps:get(<<"headers">>, Params, undefined)), method => method(maps:get(<<"method">>, Params, <<"POST">>)), - payload_tmpl => maps:get(<<"payload_tmpl">>, Params, <<>>)} + content_type => maps:get(<<"content_type">>, Params, <<"application/json">>), + payload_tmpl => maps:get(<<"payload_tmpl">>, Params, <<>>), + path => maps:get(<<"path">>, Params, <<>>)} catch _:_ -> throw({invalid_params, Params}) end. diff --git a/rebar.config b/rebar.config index 0026218f3..c6de65fde 100644 --- a/rebar.config +++ b/rebar.config @@ -76,7 +76,7 @@ , {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.5.0"}}} , {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} , {minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.1"}}} - , {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}} + , {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}} , {replayq, {git, "https://github.com/emqx/replayq", {tag, "v0.2.0"}}} , {erlport, {git, "https://github.com/emqx/erlport", {tag, "v1.2.2"}}} , {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {branch, "2.0.3"}}}