This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 316aea792 fix(standalone): harden the declarative configuration paths
(#13886)
316aea792 is described below
commit 316aea792eae20e69e8109d781e5629998735c91
Author: AlinsRan <[email protected]>
AuthorDate: Tue Sep 8 09:05:22 2026 +0800
fix(standalone): harden the declarative configuration paths (#13886)
---
apisix/admin/config_validate.lua | 42 +++++-
apisix/admin/init.lua | 35 ++++-
apisix/admin/standalone.lua | 21 ++-
apisix/admin/stream_routes.lua | 7 +-
apisix/cli/file.lua | 9 +-
apisix/cli/ops.lua | 6 +-
apisix/consumer.lua | 8 +-
apisix/core/config_yaml.lua | 13 +-
t/admin/config-validate.t | 199 +++++++++++++++++++++++++++
t/admin/standalone.t | 124 +++++++++++++++++
t/cli/test_deployment_null_sections.sh | 82 +++++++++++
t/cli/test_standalone_plugin_reload.sh | 241 +++++++++++++++++++++++++++++++++
12 files changed, 760 insertions(+), 27 deletions(-)
diff --git a/apisix/admin/config_validate.lua b/apisix/admin/config_validate.lua
index da9aad2f7..02e16c5dc 100644
--- a/apisix/admin/config_validate.lua
+++ b/apisix/admin/config_validate.lua
@@ -73,6 +73,27 @@ for dir in pairs(constants.STREAM_ETCD_DIRECTORY) do
STREAM_RESOURCE_KEYS[key] = key .. CONF_VERSION_KEY_SUFFIX
end
+-- The body is client-supplied, so its shape has to be checked before anything
+-- iterates it: `#items` and `ipairs` raise on a scalar, and a section that is
+-- not an array is silently dropped by standalone.update() while its version is
+-- advanced, which clears the resources that were there.
+local config_schema
+do
+ local properties = {}
+ -- only the resource sections: the *_conf_version fields keep their own
+ -- checks below and in standalone.update(), whose messages callers rely on
+ for key in pairs(ALL_RESOURCE_KEYS) do
+ properties[key] = {
+ type = "array",
+ items = {type = "object"},
+ }
+ end
+ config_schema = {
+ type = "object",
+ properties = properties,
+ }
+end
+
local function check_duplicate(item, key, id_set)
local identifier, identifier_type
@@ -131,6 +152,15 @@ function _M.validate_configuration(req_body,
collect_all_errors)
local is_valid = true
local validation_results = {}
+ local shape_ok, shape_err = core.schema.check(config_schema, req_body)
+ if not shape_ok then
+ local err_msg = "invalid request body: " .. shape_err
+ if not collect_all_errors then
+ return false, err_msg
+ end
+ return false, {{resource_type = "", error = err_msg}}
+ end
+
for key, conf_version_key in pairs(ALL_RESOURCE_KEYS) do
local items = req_body[key]
local resource = resources[key] or {}
@@ -217,7 +247,9 @@ function _M.validate()
local data
if core.string.has_prefix(content_type, "application/yaml") then
local ok, result = pcall(yaml.load, req_body, { all = false })
- if not ok or type(result) ~= "table" then
+ -- a null document (`~`) loads as lyaml's sentinel table, which would
+ -- otherwise pass as an empty object and clear every resource
+ if not ok or type(result) ~= "table" or result == yaml.null then
err = "invalid yaml request body"
else
data = result
@@ -227,16 +259,20 @@ function _M.validate()
end
if err then
- core.log.warn("invalid request body: ", req_body, " err: ", err)
+ -- the body is a full declarative configuration and can carry plugin
+ -- credentials and TLS private keys; log the parser error only
+ core.log.warn("invalid request body, err: ", err)
return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
end
local ok, valid, validation_results = pcall(_M.validate_configuration,
data, true)
if not ok then
core.log.warn("unexpected error during validation: ", tostring(valid))
+ -- the raw Lua error carries the source path and whatever the failing
+ -- code put in it; keep it in the log, not in the response
return core.response.exit(400, {
error_msg = "Configuration validation failed",
- errors = {{error = tostring(valid)}}
+ errors = {{error = "unexpected validation error"}}
})
end
if not valid then
diff --git a/apisix/admin/init.lua b/apisix/admin/init.lua
index bffefcac4..41b9aced8 100644
--- a/apisix/admin/init.lua
+++ b/apisix/admin/init.lua
@@ -82,8 +82,10 @@ local router
local function check_token(ctx)
local local_conf = core.config.local_conf()
- -- check if admin_key is required
- if local_conf.deployment.admin.admin_key_required == false then
+ -- check if admin_key is required; `admin:` written as YAML null makes
+ -- merge_conf drop the default table, so it cannot be indexed blindly
+ if core.table.try_read_attr(local_conf, "deployment", "admin",
+ "admin_key_required") == false then
return true
end
@@ -347,6 +349,13 @@ end
local function sync_local_conf_to_etcd(reset)
local local_conf = core.config.local_conf()
+ if local_conf.deployment.config_provider == "yaml" then
+ -- standalone keeps its configuration in the shared dict, there is no
+ -- etcd to sync to. Guarded here rather than at the call sites so a new
+ -- caller cannot reintroduce the write.
+ return
+ end
+
local plugins = {}
for _, name in ipairs(local_conf.plugins) do
core.table.insert(plugins, {
@@ -405,6 +414,16 @@ local function sync_local_conf_to_etcd(reset)
end
+-- /v1/plugins/reload bumps the shared version and control/router.lua loads the
+-- plugins in its own handler for that event. Record the version it applied, or
+-- the reconciliation timer below sees a mismatch and loads them a second time.
+local function ack_plugins_reload()
+ if plugins_conf_ver_dict then
+ applied_plugins_conf_version =
plugins_conf_ver_dict:get(PLUGINS_CONF_VERSION_KEY)
+ end
+end
+
+
local function reload_plugins(data, event, source, pid)
core.log.info("start to hot reload plugins")
@@ -553,13 +572,18 @@ function _M.init_worker()
-- register reload plugin handler
events = require("apisix.events")
events:register(reload_plugins, reload_event, "PUT")
+ events:register(ack_plugins_reload,
require("apisix.control.v1").RELOAD_EVENT, "PUT")
- if plugins_conf_ver_dict and not is_yaml_config_provider then
+ if plugins_conf_ver_dict then
-- The events broadcast has no delivery guarantee: a process that is
-- (re)connecting to the events broker loses the event for good, which
-- leaves it running e.g. the timers of plugins that were removed.
-- Reconcile against the version in the shared dict, the same pattern
-- admin/standalone.lua uses for the same reason.
+ --
+ -- This is not gated on the config provider: /v1/plugins/reload bumps
+ -- the same version and stays reachable in standalone mode, so a worker
+ -- that missed its broadcast has to be able to converge there too.
applied_plugins_conf_version =
plugins_conf_ver_dict:get(PLUGINS_CONF_VERSION_KEY) or 0
@@ -579,8 +603,9 @@ function _M.init_worker()
end
if ngx_worker_id() == 0 then
- -- check if admin_key is required
- if local_conf.deployment.admin.admin_key_required == false then
+ -- see check_token for why this is not indexed blindly
+ if core.table.try_read_attr(local_conf, "deployment", "admin",
+ "admin_key_required") == false then
core.log.warn("Admin key is bypassed! ",
"If you are deploying APISIX in a production environment, ",
"please enable `admin_key_required` and set a secure admin
key!")
diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua
index 8ab7280c4..0064d19bf 100644
--- a/apisix/admin/standalone.lua
+++ b/apisix/admin/standalone.lua
@@ -16,6 +16,7 @@
local type = type
local pairs = pairs
local ipairs = ipairs
+local pcall = pcall
local tostring = tostring
local tonumber = tonumber
local str_lower = string.lower
@@ -120,7 +121,9 @@ local function update_config(apisix_yaml)
if not ok then
return nil, "failed to save config to shared dict: " .. err
end
- core.log.info("standalone config updated: ", raw)
+ -- the payload is a full declarative configuration and can carry TLS
+ -- private keys and plugin credentials; log its size, not its contents
+ core.log.info("standalone config updated, size: ", #raw)
else
core.log.crit(config_yaml.ERR_NO_SHARED_DICT)
end
@@ -231,7 +234,8 @@ local function try_restore_from_shared_dict()
core.log.info("no config found in shared dict")
return true
end
- core.log.info("startup config loaded from shared dict: ", stored)
+ -- the payload can carry TLS private keys and plugin credentials
+ core.log.info("startup config loaded from shared dict, size: ", #stored)
local _, raw, err = decode_config(tostring(stored))
if not raw then
@@ -273,16 +277,21 @@ local function update(ctx)
-- parse the request body
local data
if core.string.has_prefix(content_type, "application/yaml") then
- data = yaml.load(req_body, { all = false })
- if not data or type(data) ~= "table" then
+ local ok, result = pcall(yaml.load, req_body, { all = false })
+ -- a null document (`~`) loads as lyaml's sentinel table, which would
+ -- otherwise pass as an empty object and clear every resource
+ if not ok or type(result) ~= "table" or result == yaml.null then
err = "invalid yaml request body"
+ else
+ data = result
end
else
data, err = core.json.decode(req_body)
end
if err then
- core.log.error("invalid request body: ", req_body, " err: ", err)
- core.response.exit(400, {error_msg = "invalid request body: " .. err})
+ -- same reason as above: the body is the configuration itself
+ core.log.error("invalid request body, err: ", err)
+ return core.response.exit(400, {error_msg = "invalid request body: "
.. err})
end
req_body = data
diff --git a/apisix/admin/stream_routes.lua b/apisix/admin/stream_routes.lua
index 9bd94906b..0ec1dcf0e 100644
--- a/apisix/admin/stream_routes.lua
+++ b/apisix/admin/stream_routes.lua
@@ -64,12 +64,17 @@ local function check_conf(id, conf, need_id, schema, opts)
end
end
- if conf.protocol and conf.protocol.superior_id and not
opts.skip_references_check then
+ -- the self-reference check needs no lookup, so it stays outside the gate;
+ -- only the etcd fetch below is skipped for standalone validation
+ if conf.protocol and conf.protocol.superior_id then
local superior_id = conf.protocol.superior_id
if id and tostring(superior_id) == tostring(id) then
return nil, {error_msg = "stream route can not set itself as
superior_id"}
end
+ end
+ if conf.protocol and conf.protocol.superior_id and not
opts.skip_references_check then
+ local superior_id = conf.protocol.superior_id
local key = "/stream_routes/" .. superior_id
local res, err = core.etcd.get(key)
if not res then
diff --git a/apisix/cli/file.lua b/apisix/cli/file.lua
index ec5bb534a..6e03801cf 100644
--- a/apisix/cli/file.lua
+++ b/apisix/cli/file.lua
@@ -266,7 +266,9 @@ function _M.read_yaml_conf(apisix_home)
if not is_empty_file then
local user_conf = yaml.load(user_conf_yaml)
- if not user_conf then
+ -- lyaml returns a scalar for a document such as `foo`, which would
blow
+ -- up in resolve_conf_var's pairs() below
+ if type(user_conf) ~= "table" then
return nil, "invalid config.yaml file"
end
@@ -290,7 +292,10 @@ function _M.read_yaml_conf(apisix_home)
default_conf.deployment.config_provider = "etcd"
if default_conf.deployment.role == "traditional" then
default_conf.etcd = default_conf.deployment.etcd
- if default_conf.deployment.role_traditional.config_provider ==
"yaml" then
+ -- `role_traditional:` written as YAML null makes merge_conf drop
the
+ -- default table, so it cannot be indexed blindly
+ local role_traditional = default_conf.deployment.role_traditional
+ if role_traditional and role_traditional.config_provider == "yaml"
then
default_conf.deployment.config_provider = "yaml"
end
diff --git a/apisix/cli/ops.lua b/apisix/cli/ops.lua
index 6b0f6ee62..58428421c 100644
--- a/apisix/cli/ops.lua
+++ b/apisix/cli/ops.lua
@@ -332,8 +332,10 @@ local function init(env)
and #allow_admin == 1 and allow_admin[1] == "127.0.0.0/24" then
checked_admin_key = true
end
- -- check if admin_key is required
- if yaml_conf.deployment.admin.admin_key_required == false then
+ -- check if admin_key is required; deployment.admin is guarded above and
below
+ -- because `admin:` written as YAML null makes merge_conf drop it
+ if yaml_conf.deployment.admin
+ and yaml_conf.deployment.admin.admin_key_required == false then
checked_admin_key = true
print("Warning! Admin key is bypassed! "
.. "If you are deploying APISIX in a production environment, "
diff --git a/apisix/consumer.lua b/apisix/consumer.lua
index c13d3a73c..b7d72c4e0 100644
--- a/apisix/consumer.lua
+++ b/apisix/consumer.lua
@@ -104,7 +104,11 @@ local function construct_consumer_data(val, name,
plugin_config)
local consumer_name =
get_consumer_name_from_credential_etcd_key(val.key)
local the_consumer = consumers:get(consumer_name)
if the_consumer and the_consumer.value then
- consumer = consumers_id_lrucache(val.value.id .. name,
val.modifiedIndex..
+ -- the parts are separated: without it ("ab", "c") and ("a", "bc")
+ -- build the same key, and modifiedIndex 1 + 23 the same version as
+ -- 12 + 3, which would serve a stale consumer
+ consumer = consumers_id_lrucache(val.value.id .. "#" .. name,
+ val.modifiedIndex .. "#" ..
the_consumer.modifiedIndex,
function (val, the_consumer)
consumer = core.table.clone(the_consumer.value)
@@ -120,7 +124,7 @@ local function construct_consumer_data(val, name,
plugin_config)
" credential key: ", val.key, ", consumer name: ",
consumer_name
end
else
- consumer = consumers_id_lrucache(val.value.id .. name,
val.modifiedIndex,
+ consumer = consumers_id_lrucache(val.value.id .. "#" .. name,
val.modifiedIndex,
function (val)
consumer = core.table.clone(val.value)
consumer.modifiedIndex = val.modifiedIndex
diff --git a/apisix/core/config_yaml.lua b/apisix/core/config_yaml.lua
index 12b515188..f05ca8d0a 100644
--- a/apisix/core/config_yaml.lua
+++ b/apisix/core/config_yaml.lua
@@ -311,8 +311,10 @@ local function sync_data(self)
if self.item_schema then
data_valid, err = check_schema(self.item_schema, item)
if not data_valid then
+ -- the item is a resource and can carry TLS private keys or
+ -- plugin credentials, so only its identity is logged
log.error("failed to check item data of [", self.key,
- "] err:", err, " ,val: ", json.delay_encode(item))
+ "] err:", err, ", key: ", conf_item.key)
end
if data_valid and self.checker then
@@ -321,7 +323,7 @@ local function sync_data(self)
data_valid, err = self.checker(item, conf_item.key)
if not data_valid then
log.error("failed to check item data of [", self.key,
- "] err:", err, " ,val: ",
json.delay_encode(item))
+ "] err:", err, ", key: ", conf_item.key)
end
end
end
@@ -348,8 +350,7 @@ local function sync_data(self)
if type(item) ~= "table" then
data_valid = false
log.error("invalid item data of [", self.key .. "/" .. idx,
- "], val: ", json.delay_encode(item),
- ", it should be an object")
+ "], type: ", type(item), ", it should be an object")
end
local id = item.id or item.username or ("arr_" .. idx)
@@ -361,7 +362,7 @@ local function sync_data(self)
data_valid, err = check_schema(self.item_schema, item)
if not data_valid then
log.error("failed to check item data of [", self.key,
- "] err:", err, " ,val: ",
json.delay_encode(item))
+ "] err:", err, ", key: ", conf_item.key)
end
end
@@ -369,7 +370,7 @@ local function sync_data(self)
data_valid, err = self.checker(item, conf_item.key)
if not data_valid then
log.error("failed to check item data of [", self.key,
- "] err:", err, " ,val: ",
json.delay_encode(item))
+ "] err:", err, ", key: ", conf_item.key)
end
end
diff --git a/t/admin/config-validate.t b/t/admin/config-validate.t
index 0226a9c17..6e1a4736c 100644
--- a/t/admin/config-validate.t
+++ b/t/admin/config-validate.t
@@ -656,3 +656,202 @@ location /t {
--- error_code: 400
--- response_body
passed
+
+
+
+=== TEST 17: validate configs - a resource list that is not an array
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{"routes": "not-an-array"}]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 18: validate configs - a scalar request body
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[123]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 19: validate configs - a stream route may not name itself as
superior_id
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{
+ "stream_routes": [
+ {
+ "id": "r1",
+ "server_addr": "127.0.0.1",
+ "server_port": 1985,
+ "protocol": {
+ "name": "redis",
+ "superior_id": "r1"
+ },
+ "upstream": {
+ "nodes": {"127.0.0.1:1995": 1},
+ "type": "roundrobin"
+ }
+ }
+ ]
+ }]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error,
+ "stream route can not set itself as superior_id",
1, true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 20: a top-level array is rejected
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [==[[{"routes": []}]]==]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 21: a resource section that is not an array is rejected
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{"routes": {"id": "r1"}}]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 22: a scalar element is rejected
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{"routes": [123]}]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
+
+
+
+=== TEST 23: a null element is rejected
+--- config
+location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local json = require("cjson")
+ local code, body = t('/apisix/admin/configs/validate',
+ ngx.HTTP_POST,
+ [[{"routes": [null]}]]
+ )
+
+ ngx.status = code
+ local data = json.decode(body)
+ assert(data.error_msg == "Configuration validation failed",
+ "expected validation failed, got: " .. tostring(data.error_msg))
+ assert(string.find(data.errors[1].error, "invalid request body", 1,
true),
+ "unexpected error: " .. body)
+ ngx.say("passed")
+ }
+}
+--- error_code: 400
+--- response_body
+passed
diff --git a/t/admin/standalone.t b/t/admin/standalone.t
index 9f1cc60fe..9209cd40a 100644
--- a/t/admin/standalone.t
+++ b/t/admin/standalone.t
@@ -378,3 +378,127 @@ X-Digest: t17
--- error_code: 202
--- no_error_log
report_failure(): update endpoint: http://127.0.0.1:2379 to unhealthy
+
+
+
+=== TEST 18: a pushed configuration never reaches the log
+--- request
+PUT /apisix/admin/configs
+{
+ "consumers": [
+ {
+ "username": "jack",
+ "plugins": {"key-auth": {"key":
"SENTINEL-CREDENTIAL-MUST-NOT-BE-LOGGED"}}
+ }
+ ]
+}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t18
+--- error_code: 202
+--- no_error_log
+SENTINEL-CREDENTIAL-MUST-NOT-BE-LOGGED
+
+
+
+=== TEST 19: an unparsable body never reaches the log
+--- request
+PUT /apisix/admin/configs
+{"consumers": [ SENTINEL-BROKEN-BODY
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t19
+--- error_code: 400
+--- no_error_log
+SENTINEL-BROKEN-BODY
+
+
+
+=== TEST 20: seed a config
+--- request
+PUT /apisix/admin/configs
+{"routes":[{"id":"r1","uri":"/r1","upstream":{"nodes":{"127.0.0.1:1980":1},"type":"roundrobin"}}]}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t20
+--- error_code: 202
+
+
+
+=== TEST 21: a resource section that is not an array is rejected
+--- request
+PUT /apisix/admin/configs
+{"routes": {"id": "r1"}}
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t21
+--- error_code: 400
+
+
+
+=== TEST 22: a top-level array is rejected
+--- request
+PUT /apisix/admin/configs
+[{"routes": []}]
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t22
+--- error_code: 400
+
+
+
+=== TEST 23: the rejected pushes left the config and its version untouched
+--- request
+GET /apisix/admin/configs
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 200
+--- response_body_like: .*X-Digest.*t20.*
+
+
+
+=== TEST 24: a malformed yaml body is rejected
+--- request
+PUT /apisix/admin/configs
+routes: [
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t24
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 25: a yaml null document is rejected
+--- request
+PUT /apisix/admin/configs
+~
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+X-Digest: t25
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 26: a malformed yaml body is rejected by the validate endpoint too
+--- request
+POST /apisix/admin/configs/validate
+routes: [
+--- more_headers
+Content-Type: application/yaml
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 400
+--- response_body_like: .*invalid yaml request body.*
+
+
+
+=== TEST 27: the rejected yaml pushes left the config and its version untouched
+--- request
+GET /apisix/admin/configs
+--- more_headers
+X-API-KEY: edd1c9f034335f136f87ad84b625c8f1
+--- error_code: 200
+--- response_body_like: .*X-Digest.*t20.*
diff --git a/t/cli/test_deployment_null_sections.sh
b/t/cli/test_deployment_null_sections.sh
new file mode 100755
index 000000000..1bfd49938
--- /dev/null
+++ b/t/cli/test_deployment_null_sections.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+. ./t/cli/common.sh
+
+# Writing a deployment section as YAML null makes merge_conf drop the default
+# table, so anything reading through it has to be guarded. Both reads below
used
+# to die with a Lua stack trace instead of a configuration error.
+
+echo '
+apisix:
+ node_listen: 9080
+deployment:
+ role: traditional
+ role_traditional:
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "attempt to index"; then
+ echo "failed: a null role_traditional should not raise a Lua error"
+ exit 1
+fi
+
+echo "passed: a null role_traditional is tolerated"
+
+echo '
+apisix:
+ node_listen: 9080
+ enable_admin: true
+deployment:
+ role: traditional
+ role_traditional:
+ config_provider: etcd
+ admin:
+ etcd:
+ host:
+ - http://127.0.0.1:2379
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "attempt to index"; then
+ echo "failed: a null deployment.admin should not raise a Lua error"
+ exit 1
+fi
+
+if ! echo "$out" | grep -F 'Please modify "admin_key" in conf/config.yaml';
then
+ echo "failed: a null deployment.admin should report a missing admin key"
+ exit 1
+fi
+
+echo "passed: a null deployment.admin reports a missing admin key"
+
+echo 'just-a-scalar' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if echo "$out" | grep -F "bad argument"; then
+ echo "failed: a scalar config.yaml should not raise a Lua error"
+ exit 1
+fi
+
+if ! echo "$out" | grep -F "invalid config.yaml file"; then
+ echo "failed: a scalar config.yaml should be reported as invalid"
+ exit 1
+fi
+
+echo "passed: a scalar config.yaml is reported as invalid"
diff --git a/t/cli/test_standalone_plugin_reload.sh
b/t/cli/test_standalone_plugin_reload.sh
new file mode 100755
index 000000000..abcf8cc3b
--- /dev/null
+++ b/t/cli/test_standalone_plugin_reload.sh
@@ -0,0 +1,241 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+# Plugin load / unload against a real gateway process, with the configuration
+# served by the standalone Admin API rather than etcd. The reconciliation timer
+# in admin/init.lua is registered in this mode as well, because
/v1/plugins/reload
+# stays reachable here and its broadcast has no delivery guarantee.
+
+. ./t/cli/common.sh
+
+ADMIN_KEY=edd1c9f034335f136f87ad84b625c8f1
+
+write_config() {
+ # $1: the plugin list body
+ echo "
+apisix:
+ node_listen: 9080
+ enable_admin: true
+ enable_control: true
+deployment:
+ role: traditional
+ role_traditional:
+ config_provider: yaml
+ admin:
+ allow_admin:
+ - 127.0.0.0/24
+ admin_key:
+ - name: admin
+ key: $ADMIN_KEY
+ role: admin
+nginx_config:
+ error_log_level: info
+plugins:
+$1
+" > conf/config.yaml
+}
+
+# Port 9 (discard) is never listening, so the status tells the two states apart
+# without needing a backend: 401 means key-auth ran and rejected the request,
+# 502 means the request got past the plugins and reached the proxy.
+status_of() {
+ curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:9080/hello"
+}
+
+# deadline-bounded, so the test does not depend on a fixed reload latency
+wait_for_status() {
+ local i
+ { set +x; } 2>/dev/null
+ for i in $(seq 1 50); do
+ if [ "$(status_of)" = "$1" ]; then
+ set -x
+ return 0
+ fi
+ sleep 0.2
+ done
+ set -x
+ echo "failed: $2 (last status: $(status_of))"
+ exit 1
+}
+
+# prometheus is never loaded or unloaded here, it is in both lists because the
+# prometheus-cache shared dict is only rendered when it is enabled at init time
+# and error-log-logger reaches the exporter through its require chain
+PLUGINS_ALL=" - key-auth
+ - serverless-pre-function
+ - public-api
+ - prometheus
+ - error-log-logger
+ - node-status"
+PLUGINS_MIN=" - serverless-pre-function
+ - public-api
+ - prometheus"
+
+write_config "$PLUGINS_ALL"
+make init
+make run
+wait_for_tcp 127.0.0.1 9180
+
+curl -s -o /dev/null -XPUT "http://127.0.0.1:9180/apisix/admin/configs" \
+ -H "X-API-KEY: $ADMIN_KEY" -H "X-Digest: reload-1" \
+ -d '{
+ "routes": [
+ {
+ "id": "r1",
+ "uri": "/hello",
+ "plugins": {"key-auth": {}},
+ "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+ },
+ {
+ "id": "bump",
+ "uri": "/bump",
+ "plugins": {
+ "serverless-pre-function": {
+ "phase": "rewrite",
+ "functions": [
+ "return function()
ngx.shared[\"internal-status\"]:incr(\"plugins_conf_version\", 1, 0)
ngx.exit(200) end"
+ ]
+ }
+ },
+ "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+ },
+ {
+ "id": "status",
+ "uri": "/apisix/status",
+ "plugins": {"public-api": {}},
+ "upstream": {"nodes": {"127.0.0.1:9": 1}, "type": "roundrobin"}
+ }
+ ],
+ "consumers": [
+ {"username": "jack", "plugins": {"key-auth": {"key": "jack-key"}}}
+ ]
+ }'
+
+wait_for_status 401 "key-auth should reject an unauthenticated request"
+
+echo "passed: key-auth is in effect on a standalone configuration"
+
+echo "unloading key-auth"
+write_config "$PLUGINS_MIN"
+curl -s -o /dev/null -XPUT http://127.0.0.1:9090/v1/plugins/reload
+
+wait_for_status 502 "the request should reach the proxy once key-auth is
unloaded"
+
+echo "passed: key-auth was unloaded by a reload"
+
+echo "loading key-auth again"
+write_config "$PLUGINS_ALL"
+curl -s -o /dev/null -XPUT http://127.0.0.1:9090/v1/plugins/reload
+
+wait_for_status 401 "key-auth should be in effect again after reloading it"
+
+echo "passed: key-auth was loaded again by a reload"
+
+if grep -q "sync local conf to etcd" logs/error.log; then
+ echo "failed: standalone has no etcd, the reload path must not sync to it"
+ exit 1
+fi
+
+echo "passed: no etcd sync was attempted"
+
+
+# --- missed-event reconciliation -------------------------------------------
+#
+# The events broadcast has no delivery guarantee: a worker that is
(re)connecting
+# to the broker loses the event for good and is left running the plugins of the
+# previous list. GET /bump reproduces that state deterministically -- it
advances
+# plugins_conf_version exactly the way a reload does, but posts no event -- so
+# only the reconciliation timer in admin/init.lua can converge the workers.
+
+# a plugin's api() routes live in a module-level registry that is rebuilt by
+# plugin.load(), so this tells a real reload from a per-route plugin lookup
+status_of_uri() {
+ curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:9080$1"
+}
+
+wait_for_uri_status() {
+ local i
+ { set +x; } 2>/dev/null
+ for i in $(seq 1 50); do
+ if [ "$(status_of_uri "$1")" = "$2" ]; then
+ set -x
+ return 0
+ fi
+ sleep 0.2
+ done
+ set -x
+ echo "failed: $3 (last status: $(status_of_uri "$1"))"
+ exit 1
+}
+
+timer_runs_since() {
+ # $1: byte offset in the error log to count from
+ tail -c "+$1" logs/error.log | grep -c "run
timer\[plugin#error-log-logger\]" || true
+}
+
+if [ "$(status_of_uri /apisix/status)" = "404" ]; then
+ echo "failed: node-status is loaded, its api route should be registered"
+ exit 1
+fi
+
+# the background timer runs once a second, so this is bounded, not a race
+sleep 2
+if [ "$(timer_runs_since 1)" = "0" ]; then
+ echo "failed: error-log-logger is loaded, its timer should be running"
+ exit 1
+fi
+
+echo "passed: the plugin api route and the plugin timer are both live"
+
+echo "dropping the plugins without broadcasting the reload"
+write_config "$PLUGINS_MIN"
+curl -s -o /dev/null "http://127.0.0.1:9080/bump"
+
+wait_for_status 502 "the reconciliation timer should unload key-auth without
an event"
+
+echo "passed: a version-behind worker converged on its own"
+
+wait_for_uri_status /apisix/status 404 "plugin.load() should have dropped the
node-status api route"
+
+echo "passed: the plugin api registry was rebuilt, not just the route lookup"
+
+offset=$(( $(wc -c < logs/error.log) + 1 ))
+sleep 3
+if [ "$(timer_runs_since $offset)" != "0" ]; then
+ echo "failed: error-log-logger was unloaded, its timer must not still run"
+ exit 1
+fi
+
+echo "passed: no stale plugin timer survived the unload"
+
+echo "restoring the plugins without broadcasting the reload"
+write_config "$PLUGINS_ALL"
+offset=$(( $(wc -c < logs/error.log) + 1 ))
+curl -s -o /dev/null "http://127.0.0.1:9080/bump"
+
+wait_for_status 401 "the reconciliation timer should load key-auth back
without an event"
+wait_for_uri_status /apisix/status 200 "the node-status api route should be
registered again"
+
+sleep 2
+if [ "$(timer_runs_since $offset)" = "0" ]; then
+ echo "failed: error-log-logger was loaded again, its timer should run
again"
+ exit 1
+fi
+
+echo "passed: the reverse transition converged the same way"