You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by me...@apache.org on 2021/04/10 10:55:30 UTC

[apisix] branch master updated: feat: support upstream mTLS (#4005)

This is an automated email from the ASF dual-hosted git repository.

membphis pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new e295ebb  feat: support upstream mTLS (#4005)
e295ebb is described below

commit e295ebb9e811e505f0c9f6b604dd6a7b25bb0a74
Author: 罗泽轩 <sp...@gmail.com>
AuthorDate: Sat Apr 10 18:55:20 2021 +0800

    feat: support upstream mTLS (#4005)
---
 apisix/admin/routes.lua               |   4 +-
 apisix/admin/services.lua             |   4 +-
 apisix/admin/upstreams.lua            |  77 +----
 apisix/http/service.lua               |  41 +--
 apisix/router.lua                     |  40 +--
 apisix/schema_def.lua                 |  28 +-
 apisix/ssl.lua                        |  50 +++-
 apisix/ssl/router/radixtree_sni.lua   |  49 +--
 apisix/upstream.lua                   | 202 ++++++++++---
 docs/en/latest/admin-api.md           |   6 +
 docs/zh/latest/admin-api.md           |   6 +
 t/APISIX.pm                           |  10 +
 t/config-center-yaml/route-upstream.t |  54 ++++
 t/node/upstream-mtls.t                | 547 ++++++++++++++++++++++++++++++++++
 14 files changed, 867 insertions(+), 251 deletions(-)

diff --git a/apisix/admin/routes.lua b/apisix/admin/routes.lua
index 28d0272..cad66fc 100644
--- a/apisix/admin/routes.lua
+++ b/apisix/admin/routes.lua
@@ -16,8 +16,8 @@
 --
 local expr = require("resty.expr.v1")
 local core = require("apisix.core")
+local apisix_upstream = require("apisix.upstream")
 local schema_plugin = require("apisix.admin.plugins").check_schema
-local upstreams = require("apisix.admin.upstreams")
 local utils = require("apisix.admin.utils")
 local tostring = tostring
 local type = type
@@ -68,7 +68,7 @@ local function check_conf(id, conf, need_id)
 
     local upstream_conf = conf.upstream
     if upstream_conf then
-        local ok, err = upstreams.check_upstream_conf(upstream_conf)
+        local ok, err = apisix_upstream.check_upstream_conf(upstream_conf)
         if not ok then
             return nil, {error_msg = err}
         end
diff --git a/apisix/admin/services.lua b/apisix/admin/services.lua
index 9ac26aa..4b6e98e 100644
--- a/apisix/admin/services.lua
+++ b/apisix/admin/services.lua
@@ -16,8 +16,8 @@
 --
 local core = require("apisix.core")
 local get_routes = require("apisix.router").http_routes
+local apisix_upstream = require("apisix.upstream")
 local schema_plugin = require("apisix.admin.plugins").check_schema
-local upstreams = require("apisix.admin.upstreams")
 local utils = require("apisix.admin.utils")
 local tostring = tostring
 local ipairs = ipairs
@@ -63,7 +63,7 @@ local function check_conf(id, conf, need_id)
 
     local upstream_conf = conf.upstream
     if upstream_conf then
-        local ok, err = upstreams.check_upstream_conf(upstream_conf)
+        local ok, err = apisix_upstream.check_upstream_conf(upstream_conf)
         if not ok then
             return nil, {error_msg = err}
         end
diff --git a/apisix/admin/upstreams.lua b/apisix/admin/upstreams.lua
index 312bf77..d367ec3 100644
--- a/apisix/admin/upstreams.lua
+++ b/apisix/admin/upstreams.lua
@@ -17,6 +17,7 @@
 local core = require("apisix.core")
 local get_routes = require("apisix.router").http_routes
 local get_services = require("apisix.http.service").services
+local apisix_upstream = require("apisix.upstream")
 local utils = require("apisix.admin.utils")
 local tostring = tostring
 local ipairs = ipairs
@@ -28,77 +29,6 @@ local _M = {
 }
 
 
-local function get_chash_key_schema(hash_on)
-    if not hash_on then
-        return nil, "hash_on is nil"
-    end
-
-    if hash_on == "vars" then
-        return core.schema.upstream_hash_vars_schema
-    end
-
-    if hash_on == "header" or hash_on == "cookie" then
-        return core.schema.upstream_hash_header_schema
-    end
-
-    if hash_on == "consumer" then
-        return nil, nil
-    end
-
-    if hash_on == "vars_combinations" then
-        return core.schema.upstream_hash_vars_combinations_schema
-    end
-
-    return nil, "invalid hash_on type " .. hash_on
-end
-
-
-local function check_upstream_conf(conf)
-    local ok, err = core.schema.check(core.schema.upstream, conf)
-    if not ok then
-        return false, "invalid configuration: " .. err
-    end
-
-    if conf.pass_host == "node" and conf.nodes and
-        core.table.nkeys(conf.nodes) ~= 1
-    then
-        return false, "only support single node for `node` mode currently"
-    end
-
-    if conf.pass_host == "rewrite" and
-        (conf.upstream_host == nil or conf.upstream_host == "")
-    then
-        return false, "`upstream_host` can't be empty when `pass_host` is `rewrite`"
-    end
-
-    if conf.type ~= "chash" then
-        return true
-    end
-
-    if not conf.hash_on then
-        conf.hash_on = "vars"
-    end
-
-    if conf.hash_on ~= "consumer" and not conf.key then
-        return false, "missing key"
-    end
-
-    local key_schema, err = get_chash_key_schema(conf.hash_on)
-    if err then
-        return false, "type is chash, err: " .. err
-    end
-
-    if key_schema then
-        local ok, err = core.schema.check(key_schema, conf.key)
-        if not ok then
-            return false, "invalid configuration: " .. err
-        end
-    end
-
-    return true
-end
-
-
 local function check_conf(id, conf, need_id)
     if not conf then
         return nil, {error_msg = "missing configurations"}
@@ -123,7 +53,7 @@ local function check_conf(id, conf, need_id)
     core.log.info("schema: ", core.json.delay_encode(core.schema.upstream))
     core.log.info("conf  : ", core.json.delay_encode(conf))
 
-    local ok, err = check_upstream_conf(conf)
+    local ok, err = apisix_upstream.check_upstream_conf(conf)
     if not ok then
         return nil, {error_msg = err}
     end
@@ -295,8 +225,5 @@ function _M.patch(id, conf, sub_path)
     return res.status, res.body
 end
 
--- for routes and services check upstream conf
-_M.check_upstream_conf = check_upstream_conf
-
 
 return _M
diff --git a/apisix/http/service.lua b/apisix/http/service.lua
index 0e36f9a..83bcb9b 100644
--- a/apisix/http/service.lua
+++ b/apisix/http/service.lua
@@ -15,11 +15,10 @@
 -- limitations under the License.
 --
 local core   = require("apisix.core")
+local apisix_upstream = require("apisix.upstream")
 local plugin_checker = require("apisix.plugin").plugin_checker
-local ipairs = ipairs
 local services
 local error = error
-local pairs = pairs
 
 
 local _M = {
@@ -47,43 +46,7 @@ local function filter(service)
         return
     end
 
-    if not service.value.upstream then
-        return
-    end
-
-    service.value.upstream.parent = service
-
-    if not service.value.upstream.nodes then
-        return
-    end
-
-    local nodes = service.value.upstream.nodes
-    if core.table.isarray(nodes) then
-        for _, node in ipairs(nodes) do
-            local host = node.host
-            if not core.utils.parse_ipv4(host) and
-                    not core.utils.parse_ipv6(host) then
-                service.has_domain = true
-                break
-            end
-        end
-    else
-        local new_nodes = core.table.new(core.table.nkeys(nodes), 0)
-        for addr, weight in pairs(nodes) do
-            local host, port = core.utils.parse_addr(addr)
-            if not core.utils.parse_ipv4(host) and
-                    not core.utils.parse_ipv6(host) then
-                service.has_domain = true
-            end
-            local node = {
-                host = host,
-                port = port,
-                weight = weight,
-            }
-            core.table.insert(new_nodes, node)
-        end
-        service.value.upstream.nodes = new_nodes
-    end
+    apisix_upstream.filter_upstream(service.value.upstream, service)
 
     core.log.info("filter service: ", core.json.delay_encode(service, true))
 end
diff --git a/apisix/router.lua b/apisix/router.lua
index 3afb3a4..840f0cd 100644
--- a/apisix/router.lua
+++ b/apisix/router.lua
@@ -16,11 +16,11 @@
 --
 local require = require
 local http_route = require("apisix.http.route")
+local apisix_upstream = require("apisix.upstream")
 local core    = require("apisix.core")
 local plugin_checker = require("apisix.plugin").plugin_checker
 local str_lower = string.lower
 local error   = error
-local pairs   = pairs
 local ipairs  = ipairs
 
 
@@ -44,43 +44,7 @@ local function filter(route)
         end
     end
 
-    if not route.value.upstream then
-        return
-    end
-
-    route.value.upstream.parent = route
-
-    if not route.value.upstream.nodes then
-        return
-    end
-
-    local nodes = route.value.upstream.nodes
-    if core.table.isarray(nodes) then
-        for _, node in ipairs(nodes) do
-            local host = node.host
-            if not core.utils.parse_ipv4(host) and
-                    not core.utils.parse_ipv6(host) then
-                route.has_domain = true
-                break
-            end
-        end
-    else
-        local new_nodes = core.table.new(core.table.nkeys(nodes), 0)
-        for addr, weight in pairs(nodes) do
-            local host, port = core.utils.parse_addr(addr)
-            if not core.utils.parse_ipv4(host) and
-                    not core.utils.parse_ipv6(host) then
-                route.has_domain = true
-            end
-            local node = {
-                host = host,
-                port = port,
-                weight = weight,
-            }
-            core.table.insert(new_nodes, node)
-        end
-        route.value.upstream.nodes = new_nodes
-    end
+    apisix_upstream.filter_upstream(route.value.upstream, route)
 
     core.log.info("filter route: ", core.json.delay_encode(route, true))
 end
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 6e171a7..b60faad 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -322,6 +322,16 @@ local nodes_schema = {
 }
 
 
+local certificate_scheme = {
+    type = "string", minLength = 128, maxLength = 64*1024
+}
+
+
+local private_key_schema = {
+    type = "string", minLength = 128, maxLength = 64*1024
+}
+
+
 local upstream_schema = {
     type = "object",
     properties = {
@@ -341,6 +351,14 @@ local upstream_schema = {
             },
             required = {"connect", "send", "read"},
         },
+        tls = {
+            type = "object",
+            properties = {
+                client_cert = certificate_scheme,
+                client_key = private_key_schema,
+            },
+            required = {"client_cert", "client_key"},
+        },
         type = {
             description = "algorithms of load balancing",
             type = "string",
@@ -598,16 +616,6 @@ _M.consumer = {
 _M.upstream = upstream_schema
 
 
-local certificate_scheme = {
-    type = "string", minLength = 128, maxLength = 64*1024
-}
-
-
-local private_key_schema = {
-    type = "string", minLength = 128, maxLength = 64*1024
-}
-
-
 _M.ssl = {
     type = "object",
     properties = {
diff --git a/apisix/ssl.lua b/apisix/ssl.lua
index 47a4e34..a3b9a96 100644
--- a/apisix/ssl.lua
+++ b/apisix/ssl.lua
@@ -23,6 +23,15 @@ local assert = assert
 local type = type
 
 
+local cert_cache = core.lrucache.new {
+    ttl = 3600, count = 1024,
+}
+
+local pkey_cache = core.lrucache.new {
+    ttl = 3600, count = 1024,
+}
+
+
 local _M = {}
 
 
@@ -60,13 +69,13 @@ end
 local function decrypt_priv_pkey(iv, key)
     local decoded_key = ngx_decode_base64(key)
     if not decoded_key then
-        core.log.error("base64 decode ssl key failed and skipped. key[", key, "] ")
+        core.log.error("base64 decode ssl key failed. key[", key, "] ")
         return nil
     end
 
     local decrypted = iv:decrypt(decoded_key)
     if not decrypted then
-        core.log.error("decrypt ssl key failed and skipped. key[", key, "] ")
+        core.log.error("decrypt ssl key failed. key[", key, "] ")
     end
 
     return decrypted
@@ -84,7 +93,6 @@ local function aes_decrypt_pkey(origin)
     end
     return origin
 end
-_M.aes_decrypt_pkey = aes_decrypt_pkey
 
 
 function _M.validate(cert, key)
@@ -108,4 +116,40 @@ function _M.validate(cert, key)
 end
 
 
+local function parse_pem_cert(sni, cert)
+    core.log.debug("parsing cert for sni: ", sni)
+
+    local parsed, err = ngx_ssl.parse_pem_cert(cert)
+    return parsed, err
+end
+
+
+function _M.fetch_cert(sni, cert)
+    local parsed_cert, err = cert_cache(cert, nil, parse_pem_cert, sni, cert)
+    if not parsed_cert then
+        return false, err
+    end
+
+    return parsed_cert
+end
+
+
+local function parse_pem_priv_key(sni, pkey)
+    core.log.debug("parsing priv key for sni: ", sni)
+
+    local parsed, err = ngx_ssl.parse_pem_priv_key(aes_decrypt_pkey(pkey))
+    return parsed, err
+end
+
+
+function _M.fetch_pkey(sni, pkey)
+    local parsed_pkey, err = pkey_cache(pkey, nil, parse_pem_priv_key, sni, pkey)
+    if not parsed_pkey then
+        return false, err
+    end
+
+    return parsed_pkey
+end
+
+
 return _M
diff --git a/apisix/ssl/router/radixtree_sni.lua b/apisix/ssl/router/radixtree_sni.lua
index 0790627..fe0bf35 100644
--- a/apisix/ssl/router/radixtree_sni.lua
+++ b/apisix/ssl/router/radixtree_sni.lua
@@ -29,37 +29,12 @@ local ssl_certificates
 local radixtree_router
 local radixtree_router_ver
 
-local cert_cache = core.lrucache.new {
-    ttl = 3600, count = 512,
-}
-
-local pkey_cache = core.lrucache.new {
-    ttl = 3600, count = 512,
-}
-
-
 local _M = {
     version = 0.1,
     server_name = ngx_ssl.server_name,
 }
 
 
-local function parse_pem_cert(sni, cert)
-    core.log.debug("parsing cert for sni: ", sni)
-
-    local parsed, err = ngx_ssl.parse_pem_cert(cert)
-    return parsed, err
-end
-
-
-local function parse_pem_priv_key(sni, pkey)
-    core.log.debug("parsing priv key for sni: ", sni)
-
-    local parsed, err = ngx_ssl.parse_pem_priv_key(pkey)
-    return parsed, err
-end
-
-
 local function create_router(ssl_items)
     local ssl_items = ssl_items or {}
 
@@ -82,23 +57,6 @@ local function create_router(ssl_items)
                 sni = ssl.value.sni:reverse()
             end
 
-            -- decrypt private key
-            if ssl.value.key then
-                local decrypted = apisix_ssl.aes_decrypt_pkey(ssl.value.key)
-                if decrypted then
-                    ssl.value.key = decrypted
-                end
-            end
-
-            if ssl.value.keys then
-                for i = 1, #ssl.value.keys do
-                    local decrypted = apisix_ssl.aes_decrypt_pkey(ssl.value.keys[i])
-                    if decrypted then
-                        ssl.value.keys[i] = decrypted
-                    end
-                end
-            end
-
             idx = idx + 1
             route_items[idx] = {
                 paths = sni,
@@ -133,7 +91,7 @@ local function set_pem_ssl_key(sni, cert, pkey)
         return false, "no request found"
     end
 
-    local parsed_cert, err = cert_cache(cert, nil, parse_pem_cert, sni, cert)
+    local parsed_cert, err = apisix_ssl.fetch_cert(sni, cert)
     if not parsed_cert then
         return false, "failed to parse PEM cert: " .. err
     end
@@ -143,9 +101,8 @@ local function set_pem_ssl_key(sni, cert, pkey)
         return false, "failed to set PEM cert: " .. err
     end
 
-    local parsed_pkey, err = pkey_cache(pkey, nil, parse_pem_priv_key, sni,
-                                        pkey)
-    if not parsed_pkey then
+    local parsed_pkey, err = apisix_ssl.fetch_pkey(sni, pkey)
+    if not parsed_cert then
         return false, "failed to parse PEM priv key: " .. err
     end
 
diff --git a/apisix/upstream.lua b/apisix/upstream.lua
index 7cef761..5bcc679 100644
--- a/apisix/upstream.lua
+++ b/apisix/upstream.lua
@@ -18,6 +18,7 @@ local require = require
 local core = require("apisix.core")
 local discovery = require("apisix.discovery.init").discovery
 local upstream_util = require("apisix.utils.upstream")
+local apisix_ssl = require("apisix.ssl")
 local error = error
 local tostring = tostring
 local ipairs = ipairs
@@ -27,6 +28,17 @@ local upstreams
 local healthcheck
 
 
+local set_upstream_tls_client_param
+local ok, apisix_ngx_upstream = pcall(require, "resty.apisix.upstream")
+if ok then
+    set_upstream_tls_client_param = apisix_ngx_upstream.set_cert_and_key
+else
+    set_upstream_tls_client_param = function ()
+        return nil, "need to build APISIX-Openresty to support upstream mTLS"
+    end
+end
+
+
 local HTTP_CODE_UPSTREAM_UNAVAILABLE = 503
 local _M = {}
 
@@ -279,6 +291,25 @@ function _M.set_by_route(route, api_ctx)
         api_ctx.up_checker = checker
     end
 
+    if up_conf.scheme == "https" and up_conf.tls then
+        -- the sni here is just for logging
+        local sni = api_ctx.var.upstream_host
+        local cert, err = apisix_ssl.fetch_cert(sni, up_conf.tls.client_cert)
+        if not ok then
+            return 503, err
+        end
+
+        local key, err = apisix_ssl.fetch_pkey(sni, up_conf.tls.client_key)
+        if not ok then
+            return 503, err
+        end
+
+        local ok, err = set_upstream_tls_client_param(cert, key)
+        if not ok then
+            return 503, err
+        end
+    end
+
     return
 end
 
@@ -297,50 +328,149 @@ function _M.check_schema(conf)
 end
 
 
+local function get_chash_key_schema(hash_on)
+    if not hash_on then
+        return nil, "hash_on is nil"
+    end
+
+    if hash_on == "vars" then
+        return core.schema.upstream_hash_vars_schema
+    end
+
+    if hash_on == "header" or hash_on == "cookie" then
+        return core.schema.upstream_hash_header_schema
+    end
+
+    if hash_on == "consumer" then
+        return nil, nil
+    end
+
+    if hash_on == "vars_combinations" then
+        return core.schema.upstream_hash_vars_combinations_schema
+    end
+
+    return nil, "invalid hash_on type " .. hash_on
+end
+
+
+local function check_upstream_conf(in_dp, conf)
+    if not in_dp then
+        local ok, err = core.schema.check(core.schema.upstream, conf)
+        if not ok then
+            return false, "invalid configuration: " .. err
+        end
+
+        -- encrypt the key in the admin
+        if conf.tls and conf.tls.client_key then
+            conf.tls.client_key = apisix_ssl.aes_encrypt_pkey(conf.tls.client_key)
+        end
+    end
+
+    if conf.pass_host == "node" and conf.nodes and
+        core.table.nkeys(conf.nodes) ~= 1
+    then
+        return false, "only support single node for `node` mode currently"
+    end
+
+    if conf.pass_host == "rewrite" and
+        (conf.upstream_host == nil or conf.upstream_host == "")
+    then
+        return false, "`upstream_host` can't be empty when `pass_host` is `rewrite`"
+    end
+
+    if conf.tls then
+        local cert = conf.tls.client_cert
+        local key = conf.tls.client_key
+        local ok, err = apisix_ssl.validate(cert, key)
+        if not ok then
+            return false, err
+        end
+    end
+
+    if conf.type ~= "chash" then
+        return true
+    end
+
+    if conf.hash_on ~= "consumer" and not conf.key then
+        return false, "missing key"
+    end
+
+    local key_schema, err = get_chash_key_schema(conf.hash_on)
+    if err then
+        return false, "type is chash, err: " .. err
+    end
+
+    if key_schema then
+        local ok, err = core.schema.check(key_schema, conf.key)
+        if not ok then
+            return false, "invalid configuration: " .. err
+        end
+    end
+
+    return true
+end
+
+
+function _M.check_upstream_conf(conf)
+    return check_upstream_conf(false, conf)
+end
+
+
+local function filter_upstream(value, parent)
+    if not value then
+        return
+    end
+
+    value.parent = parent
+
+    if not value.nodes then
+        return
+    end
+
+    local nodes = value.nodes
+    if core.table.isarray(nodes) then
+        for _, node in ipairs(nodes) do
+            local host = node.host
+            if not core.utils.parse_ipv4(host) and
+                    not core.utils.parse_ipv6(host) then
+                parent.has_domain = true
+                break
+            end
+        end
+    else
+        local new_nodes = core.table.new(core.table.nkeys(nodes), 0)
+        for addr, weight in pairs(nodes) do
+            local host, port = core.utils.parse_addr(addr)
+            if not core.utils.parse_ipv4(host) and
+                    not core.utils.parse_ipv6(host) then
+                parent.has_domain = true
+            end
+            local node = {
+                host = host,
+                port = port,
+                weight = weight,
+            }
+            core.table.insert(new_nodes, node)
+        end
+        value.nodes = new_nodes
+    end
+end
+_M.filter_upstream = filter_upstream
+
+
 function _M.init_worker()
     local err
     upstreams, err = core.config.new("/upstreams", {
             automatic = true,
             item_schema = core.schema.upstream,
+            -- also check extra fields in the DP side
+            checker = function (item, schema_type)
+                return check_upstream_conf(true, item)
+            end,
             filter = function(upstream)
                 upstream.has_domain = false
-                if not upstream.value then
-                    return
-                end
-
-                upstream.value.parent = upstream
 
-                if not upstream.value.nodes then
-                    return
-                end
-
-                local nodes = upstream.value.nodes
-                if core.table.isarray(nodes) then
-                    for _, node in ipairs(nodes) do
-                        local host = node.host
-                        if not core.utils.parse_ipv4(host) and
-                                not core.utils.parse_ipv6(host) then
-                            upstream.has_domain = true
-                            break
-                        end
-                    end
-                else
-                    local new_nodes = core.table.new(core.table.nkeys(nodes), 0)
-                    for addr, weight in pairs(nodes) do
-                        local host, port = core.utils.parse_addr(addr)
-                        if not core.utils.parse_ipv4(host) and
-                                not core.utils.parse_ipv6(host) then
-                            upstream.has_domain = true
-                        end
-                        local node = {
-                            host = host,
-                            port = port,
-                            weight = weight,
-                        }
-                        core.table.insert(new_nodes, node)
-                    end
-                    upstream.value.nodes = new_nodes
-                end
+                filter_upstream(upstream.value, upstream)
 
                 core.log.info("filter upstream: ", core.json.delay_encode(upstream, true))
             end,
diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md
index 1634ef7..c0500c6 100644
--- a/docs/en/latest/admin-api.md
+++ b/docs/en/latest/admin-api.md
@@ -543,6 +543,8 @@ In addition to the basic complex equalization algorithm selection, APISIX's Upst
 |labels          |optional |Key/value pairs to specify attributes|{"version":"v2","build":"16","env":"production"}|
 |create_time     |optional| epoch timestamp in second, like `1602883670`, will be created automatically if missing|
 |update_time     |optional| epoch timestamp in second, like `1602883670`, will be created automatically if missing|
+|tls.client_cert |optional| Set the client certificate when connecting to TLS upstream, see below for more details|
+|tls.client_key  |optional| Set the client priviate key when connecting to TLS upstream, see below for more details|
 
 `type` can be one of:
 
@@ -560,6 +562,10 @@ In addition to the basic complex equalization algorithm selection, APISIX's Upst
 1. when it is `vars_combinations`, the `key` is required. The `key` can be any [Nginx builtin variables](http://nginx.org/en/docs/varindex.html) combinations, such as `$request_uri$remote_addr`.
 1. If there is no value for either `hash_on` or `key`, `remote_addr` will be used as key.
 
+`tls.client_cert/key` can be used to communicate with upstream via mTLS.
+Their formats are the same as SSL's `cert` and `key` fields.
+This feature requires APISIX to run on [APISIX-OpenResty](../how-to-build.md#6-build-openresty-for-apisix).
+
 **Config Example:**
 
 ```shell
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md
index e3ca1c2..8bffa0f 100644
--- a/docs/zh/latest/admin-api.md
+++ b/docs/zh/latest/admin-api.md
@@ -546,6 +546,8 @@ APISIX 的 Upstream 除了基本的复杂均衡算法选择外,还支持对上
 | labels         | 可选                               | 匹配规则       | 标识附加属性的键值对                                                                                                                                                                                                                                                                                                                                        | {"version":"v2","build":"16","env":"production"} |
 | create_time    | 可选                               | 辅助           | 单位为秒的 epoch 时间戳,如果不指定则自动创建                                                                                                                                                                                                                                                                                                               | 1602883670                                       |
 | update_time    | 可选                               | 辅助           | 单位为秒的 epoch 时间戳,如果不指定则自动创建                                                                                                                                                                                                                                                                                                               | 1602883670                                       |
+| tls.client_cert    | 可选                               | https 证书           | 设置跟上游通信时的客户端证书,细节见下文                                                                          | |
+| update_time    | 可选                               | https 证书私钥           | 设置跟上游通信时的客户端私钥,细节见下文                                                                                                                                                                                                                                                                                                              | |
 
 `type` 可以是以下的一种:
 
@@ -562,6 +564,10 @@ APISIX 的 Upstream 除了基本的复杂均衡算法选择外,还支持对上
 4. 设为 `consumer` 时,`key` 不需要设置。此时哈希算法采用的 `key` 为认证通过的 `consumer_name`。
 5. 如果指定的 `hash_on` 和 `key` 获取不到值时,就是用默认值:`remote_addr`。
 
+`tls.client_cert/key` 可以用来跟上游进行 mTLS 通信。
+他们的格式和 SSL 对象的 `cert` 和 `key` 一样。
+这个特性需要 APISIX 运行于 [APISIX-OpenResty](../how-to-build.md#6-build-openresty-for-apisix)。
+
 **upstream 对象 json 配置内容:**
 
 ```shell
diff --git a/t/APISIX.pm b/t/APISIX.pm
index 2d72236..2b7324d 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -389,6 +389,10 @@ _EOC_
 
 _EOC_
 
+    if (defined $block->upstream_server_config) {
+        $http_config .= $block->upstream_server_config;
+    }
+
     my $ipv6_fake_server = "";
     if (defined $block->listen_ipv6) {
         $ipv6_fake_server = "listen \[::1\]:1980;";
@@ -426,7 +430,13 @@ _EOC_
         ssl_certificate             cert/apisix.crt;
         ssl_certificate_key         cert/apisix.key;
         lua_ssl_trusted_certificate cert/apisix.crt;
+_EOC_
 
+    if (defined $block->upstream_server_config) {
+        $http_config .= $block->upstream_server_config;
+    }
+
+    $http_config .= <<_EOC_;
         server_tokens off;
 
         ssl_certificate_by_lua_block {
diff --git a/t/config-center-yaml/route-upstream.t b/t/config-center-yaml/route-upstream.t
index 42908da..ff50ce2 100644
--- a/t/config-center-yaml/route-upstream.t
+++ b/t/config-center-yaml/route-upstream.t
@@ -158,3 +158,57 @@ GET /get
 --- error_code: 200
 --- no_error_log
 [error]
+
+
+
+=== TEST 6: upstream hash_on (bad)
+--- yaml_config eval: $::yaml_config
+--- apisix_yaml
+routes:
+    -
+        id: 1
+        uri: /get
+        upstream_id: 1
+upstreams:
+    -
+        id: 1
+        nodes:
+            "httpbin.org:80": 1
+        type: chash
+        hash_on: header
+        key: "$aaa"
+#END
+--- request
+GET /get
+--- error_code: 502
+--- error_log
+invalid configuration: failed to match pattern
+
+
+
+=== TEST 7: upstream hash_on (good)
+--- yaml_config eval: $::yaml_config
+--- apisix_yaml
+routes:
+    -
+        id: 1
+        uri: /hello
+        upstream_id: 1
+upstreams:
+    -
+        id: 1
+        nodes:
+            "127.0.0.1:1980": 1
+            "127.0.0.2:1980": 1
+        type: chash
+        hash_on: header
+        key: "test"
+#END
+--- request
+GET /hello
+--- more_headers
+test: one
+--- error_log
+proxy request to 127.0.0.1:1980
+--- no_error_log
+[error]
diff --git a/t/node/upstream-mtls.t b/t/node/upstream-mtls.t
new file mode 100644
index 0000000..9c0a49d
--- /dev/null
+++ b/t/node/upstream-mtls.t
@@ -0,0 +1,547 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+use t::APISIX;
+
+my $nginx_binary = $ENV{'TEST_NGINX_BINARY'} || 'nginx';
+my $version = eval { `$nginx_binary -V 2>&1` };
+
+if ($version !~ m/\/apisix-nginx-module/) {
+    plan(skip_all => "apisix-nginx-module not installed");
+} else {
+    plan('no_plan');
+}
+
+repeat_each(1);
+log_level('info');
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+        $block->set_value("no_error_log", "[error]");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: tls without key
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_cert = t.read_file("t/certs/mtls_client.crt")
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                    }
+                },
+                uri = "/hello"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"invalid configuration: property \"upstream\" validation failed: property \"tls\" validation failed: property \"client_key\" is required"}
+
+
+
+=== TEST 2: tls with bad key
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_cert = t.read_file("t/certs/mtls_client.crt")
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ("AAA"):rep(128),
+                    }
+                },
+                uri = "/hello"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to decrypt previous encrypted key"}
+--- error_log
+decrypt ssl key failed
+
+
+
+=== TEST 3: encrypt key by default
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_cert = t.read_file("t/certs/mtls_client.crt")
+            local ssl_key = t.read_file("t/certs/mtls_client.key")
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ssl_key,
+                    }
+                },
+                uri = "/hello"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.upstream.tls.client_key == ssl_key)
+
+            -- upstream
+            local data = {
+                scheme = "https",
+                type = "roundrobin",
+                nodes = {
+                    ["127.0.0.1:1983"] = 1,
+                },
+                tls = {
+                    client_cert = ssl_cert,
+                    client_key = ssl_key,
+                }
+            }
+            local code, body = t.test('/apisix/admin/upstreams/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/upstreams/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.tls.client_key == ssl_key)
+
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ssl_key,
+                    }
+                },
+            }
+            local code, body = t.test('/apisix/admin/services/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/services/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.upstream.tls.client_key == ssl_key)
+        }
+    }
+--- request
+GET /t
+--- response_body
+false
+false
+false
+
+
+
+=== TEST 4: hit
+--- upstream_server_config
+    ssl_client_certificate ../../certs/mtls_ca.crt;
+    ssl_verify_client on;
+--- request
+GET /hello
+--- response_body
+hello world
+
+
+
+=== TEST 5: wrong cert
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_cert = t.read_file("t/certs/apisix.crt")
+            local ssl_key = t.read_file("t/certs/apisix.key")
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ssl_key,
+                    }
+                },
+                uri = "/hello"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 6: hit
+--- upstream_server_config
+    ssl_client_certificate ../../certs/mtls_ca.crt;
+    ssl_verify_client on;
+--- request
+GET /hello
+--- error_code: 400
+--- error_log
+client SSL certificate verify error
+
+
+
+=== TEST 7: clean old data
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            assert(t.test('/apisix/admin/routes/1',
+                ngx.HTTP_DELETE
+            ))
+            assert(t.test('/apisix/admin/services/1',
+                ngx.HTTP_DELETE
+            ))
+            assert(t.test('/apisix/admin/upstreams/1',
+                ngx.HTTP_DELETE
+            ))
+        }
+    }
+--- request
+GET /t
+
+
+
+=== TEST 8: don't encrypt key
+--- yaml_config
+apisix:
+    node_listen: 1984
+    admin_key: null
+    ssl:
+        key_encrypt_salt: null
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local ssl_cert = t.read_file("t/certs/mtls_client.crt")
+            local ssl_key = t.read_file("t/certs/mtls_client.key")
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ssl_key,
+                    }
+                },
+                uri = "/hello"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.upstream.tls.client_key == ssl_key)
+
+            -- upstream
+            local data = {
+                scheme = "https",
+                type = "roundrobin",
+                nodes = {
+                    ["127.0.0.1:1983"] = 1,
+                },
+                tls = {
+                    client_cert = ssl_cert,
+                    client_key = ssl_key,
+                }
+            }
+            local code, body = t.test('/apisix/admin/upstreams/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/upstreams/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.tls.client_key == ssl_key)
+
+            local data = {
+                upstream = {
+                    scheme = "https",
+                    type = "roundrobin",
+                    nodes = {
+                        ["127.0.0.1:1983"] = 1,
+                    },
+                    tls = {
+                        client_cert = ssl_cert,
+                        client_key = ssl_key,
+                    }
+                },
+            }
+            local code, body = t.test('/apisix/admin/services/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            local code, body, res = t.test('/apisix/admin/services/1',
+                ngx.HTTP_GET
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+
+            res = json.decode(res)
+            ngx.say(res.node.value.upstream.tls.client_key == ssl_key)
+        }
+    }
+--- request
+GET /t
+--- response_body
+true
+true
+true
+
+
+
+=== TEST 9: bind upstream
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local data = {
+                upstream_id = 1,
+                uri = "/server_port"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+        }
+    }
+--- request
+GET /t
+
+
+
+=== TEST 10: hit
+--- upstream_server_config
+    ssl_client_certificate ../../certs/mtls_ca.crt;
+    ssl_verify_client on;
+--- request
+GET /server_port
+--- response_body chomp
+1983
+
+
+
+=== TEST 11: bind service
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+            local json = require("toolkit.json")
+            local data = {
+                service_id = 1,
+                uri = "/hello_chunked"
+            }
+            local code, body = t.test('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                json.encode(data)
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                ngx.say(body)
+                return
+            end
+        }
+    }
+--- request
+GET /t
+
+
+
+=== TEST 12: hit
+--- upstream_server_config
+    ssl_client_certificate ../../certs/mtls_ca.crt;
+    ssl_verify_client on;
+--- request
+GET /hello_chunked
+--- response_body
+hello world