feat: allow configuring fallback SNI (#5000)

The fallback SNI works around cases that client doesn't send a SNI
during handshake.
By configuring a fallback SNI we can configure a fallback certificate
with the current SSL APIs.
Fix #3147

Signed-off-by: spacewander <spacewanderlzx@gmail.com>
diff --git a/apisix/ssl.lua b/apisix/ssl.lua
index 1dc9cb3..828ebbe 100644
--- a/apisix/ssl.lua
+++ b/apisix/ssl.lua
@@ -35,6 +35,17 @@
 local _M = {}
 
 
+function _M.server_name()
+    local sni, err = ngx_ssl.server_name()
+    if not err and not sni then
+        local local_conf = core.config.local_conf()
+        sni = core.table.try_read_attr(local_conf, "apisix", "ssl", "fallback_sni")
+    end
+
+    return sni, err
+end
+
+
 local _aes_128_cbc_with_iv = false
 local function get_aes_128_cbc_with_iv()
     if _aes_128_cbc_with_iv == false then
diff --git a/apisix/ssl/router/radixtree_sni.lua b/apisix/ssl/router/radixtree_sni.lua
index 6f44a2f..6e7a41c 100644
--- a/apisix/ssl/router/radixtree_sni.lua
+++ b/apisix/ssl/router/radixtree_sni.lua
@@ -128,7 +128,7 @@
     end
 
     local sni
-    sni, err = ngx_ssl.server_name()
+    sni, err = apisix_ssl.server_name()
     if type(sni) ~= "string" then
         local advise = "please check if the client requests via IP or uses an outdated protocol" ..
                        ". If you need to report an issue, " ..
diff --git a/apisix/stream/router/ip_port.lua b/apisix/stream/router/ip_port.lua
index 44b0ab3..271ae73 100644
--- a/apisix/stream/router/ip_port.lua
+++ b/apisix/stream/router/ip_port.lua
@@ -18,7 +18,7 @@
 local config_util = require("apisix.core.config_util")
 local plugin_checker = require("apisix.plugin").stream_plugin_checker
 local router_new = require("apisix.utils.router").new
-local ngx_ssl = require("ngx.ssl")
+local apisix_ssl = require("apisix.ssl")
 local error     = error
 local tonumber  = tonumber
 local ipairs = ipairs
@@ -134,7 +134,7 @@
             router_ver = user_routes.conf_version
         end
 
-        local sni = ngx_ssl.server_name()
+        local sni = apisix_ssl.server_name()
         if sni and tls_router then
             local sni_rev = sni:reverse()
 
diff --git a/conf/config-default.yaml b/conf/config-default.yaml
index ab1f3d4..781239b 100644
--- a/conf/config-default.yaml
+++ b/conf/config-default.yaml
@@ -139,9 +139,12 @@
     ssl_ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
     ssl_session_tickets: false              #  disable ssl_session_tickets by default for 'ssl_session_tickets' would make Perfect Forward Secrecy useless.
                                             #  ref: https://github.com/mozilla/server-side-tls/issues/135
+
     key_encrypt_salt: edd1c9f0985e76a2      #  If not set, will save origin ssl key into etcd.
                                             #  If set this, must be a string of length 16. And it will encrypt ssl key with AES-128-CBC
                                             #  !!! So do not change it after saving your ssl, it can't decrypt the ssl keys have be saved if you change !!
+
+    #fallback_sni: "my.default.domain"      # If set this, when the client doesn't send SNI during handshake, the fallback SNI will be used instead
   enable_control: true
   #control:
   #  ip: 127.0.0.1
diff --git a/t/router/radixtree-sni2.t b/t/router/radixtree-sni2.t
index e3ec3b7..57aadd0 100644
--- a/t/router/radixtree-sni2.t
+++ b/t/router/radixtree-sni2.t
@@ -354,3 +354,47 @@
 failed to fetch ssl config: failed to find SNI: please check if the client requests via IP or uses an outdated protocol
 --- no_error_log
 [alert]
+
+
+
+=== TEST 9: client request without sni, but fallback_sni is set
+--- yaml_config
+apisix:
+  node_listen: 1984
+  ssl:
+    fallback_sni: "a.test2.com"
+--- config
+listen unix:$TEST_NGINX_HTML_DIR/nginx.sock ssl;
+
+location /t {
+    content_by_lua_block {
+        -- etcd sync
+        ngx.sleep(0.2)
+
+        do
+            local sock = ngx.socket.tcp()
+
+            sock:settimeout(2000)
+
+            local ok, err = sock:connect("unix:$TEST_NGINX_HTML_DIR/nginx.sock")
+            if not ok then
+                ngx.say("failed to connect: ", err)
+                return
+            end
+
+            local sess, err = sock:sslhandshake(nil, nil, false)
+            if not sess then
+                ngx.say("failed to do SSL handshake: ", err)
+                return
+            end
+            ngx.say("ssl handshake: ", sess ~= nil)
+        end  -- do
+        -- collectgarbage()
+    }
+}
+--- request
+GET /t
+--- no_error_log
+[error]
+--- response_body
+ssl handshake: true
diff --git a/t/stream-node/sni.t b/t/stream-node/sni.t
index ab70117..ff80c95 100644
--- a/t/stream-node/sni.t
+++ b/t/stream-node/sni.t
@@ -277,7 +277,25 @@
 
 
 
-=== TEST 10: no sni matched, fall back to non-sni route
+=== TEST 10: use fallback sni to match route
+--- yaml_config
+apisix:
+  node_listen: 1984
+  stream_proxy:
+    tcp:
+      - 9100
+  ssl:
+    fallback_sni: a.test.com
+--- stream_tls_request
+mmm
+--- response_body
+hello world
+--- error_log
+proxy request to 127.0.0.2:1995
+
+
+
+=== TEST 11: no sni matched, fall back to non-sni route
 --- config
     location /t {
         content_by_lua_block {
@@ -301,7 +319,7 @@
 
 
 
-=== TEST 11: hit route
+=== TEST 12: hit route
 --- stream_tls_request
 mmm
 --- stream_sni: b.test.com
@@ -312,7 +330,7 @@
 
 
 
-=== TEST 12: clean up routes
+=== TEST 13: clean up routes
 --- config
     location /t {
         content_by_lua_block {