青龙面板v2.21.0任务卡在「运行中」还刷 jwt malformed和状态更新失败(Validation failed):三层错因的完整排查记录
先说现象
升级到青龙面板 v2.21.0 之后,跑任何脚本,日志尾部都会多出这样的东西:
## 开始执行... 2026-09-21 09:51:08
...
, 状态更新失败(Validation failed)
jwt malformed
## 完成 ✅... 2026-09-21 09:51:09 耗时 1 秒, 状态更新失败(Validation failed) 脚本本身跑完了,业务逻辑也没问题。但面板上的任务状态永远停在「运行中」,得手动去点停止。日志里既报 Validation failed 又报 jwt malformed,看着像两个毛病,实际上你面对的是三个。
这篇记录把三层错因一层层剥开,每层都给可复现的验证命令和最小改动的修复方案。
一键修复
把文末的脚本存成 ql-fix-all.sh,丢进容器执行:
docker cp ql-fix-all.sh qinglong:/tmp/
docker exec qinglong bash /tmp/ql-fix-all.sh容器名用 docker ps 查,按实际的改。已经在容器里的话直接 bash ql-fix-all.sh。
脚本自动备份、改完做语法校验、失败自动回滚,重复跑也没副作用。另有两个开关:
bash ql-fix-all.sh -n # 只看诊断,不改文件
bash ql-fix-all.sh --revert # 还原跑完去面板随便跑一个任务,日志结尾只剩 ## 完成 ✅ 这一行,状态能变回「已结束」,就是好了。
不想用脚本也行,下面三个改动手工做一样的效果。
两个必须知道的点
改完那一次运行,日志里还会再报一次同样的错。 因为 shell 是在任务启动时才 source api.sh 的,你改文件的时候,那个任务早就把旧函数读进内存了。它会带着旧函数收尾,所以再刷一次 404。这是正常的「最后一响」,从下一个任务起才干净,不是补丁没生效。
全程不用重启面板。 三处改动都是改文件或改缓存,立即生效。重启面板反而会杀掉正在运行的任务进程,让任务又一次卡在「运行中」——等于自己把刚修好的问题重新制造一遍。
根治
这些都是壳补丁,是在不想重建容器的前提下最轻的止血办法。真正的根治只有一条:把后端/镜像更新到与 shell/*.sh 同版本。升级之后记得把补丁撤掉:
bash ql-fix-all.sh --revert点击展开:一键修复脚本 ql-fix-all.sh 全文
#!/usr/bin/env bash
# shellcheck shell=bash disable=SC2086,SC2016
# =============================================================================
# ql-fix-all.sh v1.0 —— 青龙「任务卡运行中 + jwt malformed」一键修复
#
# 一次修完三层(全来自同一个根因:shell 比后端新 + 数据卷留了旧状态):
# 1) , 状态更新失败(Validation failed) → 任务永远卡「运行中」
# api.sh 的 update_cron 多发 exit_code,后端 Joi 拒收
# 2) 401 jwt malformed → 鉴权被拒
# keyv:apps 缓存里 system 应用缺 dashboard scope
# 3) 404 Cannot POST dashboard/record → 日志噪音
# 后端无此路由,而 record_cron_stat 只写统计表,屏蔽即可
#
# 用法:
# bash ql-fix-all.sh # 一键修复
# bash ql-fix-all.sh -n # 只看诊断,不改任何文件
# bash ql-fix-all.sh --revert # 还原 api.sh 补丁
#
# 安全:改前自动备份;语法校验失败自动回滚;可重复执行(幂等)。
#
# ⚠️ 打完补丁的那一次运行,日志里仍会再出现一次 404 + jq 报错。
# 因为 shell 是在任务【启动时】才 source api.sh 的,
# 该任务内存里还是旧函数。从下一个任务起才干净,不是没生效。
# =============================================================================
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
bash "${BASH_SOURCE[0]}" ${@+"$@"} 2>/dev/null
return $?
fi
set -uo pipefail
API=/ql/shell/api.sh
DB=/ql/data/db/database.sqlite
KV=/ql/data/db/keyv.sqlite
TOKF=/ql/data/config/token.json
PORT="${ql_port:-5700}"
DRY=0; REVERT=0
while [ $# -gt 0 ]; do
case "$1" in
-n|--dry-run) DRY=1 ;;
--revert) REVERT=1 ;;
-h|--help) sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
esac
shift
done
G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; N=$'\033[0m'
[ -t 1 ] || { G=; R=; Y=; N=; }
ok() { printf ' %s\n' "${G}[OK]${N} $*"; }
bad() { printf ' %s\n' "${R}[!!]${N} $*"; }
warn() { printf ' %s\n' "${Y}[? ]${N} $*"; }
step() { printf '\n==> %s\n' "$*"; }
# --- 不在容器内时,自动钻进去重跑(宿主机上也能一键) -----------------------
if [ ! -f "$API" ]; then
CN="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'qinglong|^ql' | head -1)"
if [ -z "$CN" ]; then
bad "既不在青龙容器内,也找不到运行中的青龙容器"
echo " 请在宿主机执行 docker ps 确认容器名,或把本脚本放进容器再跑"
exit 1
fi
ok "检测到容器 $CN,自动进入容器执行 ..."
docker cp "$0" "$CN:/tmp/ql-fix-all.sh" >/dev/null 2>&1 || { bad "拷贝脚本进容器失败"; exit 1; }
docker exec "$CN" bash /tmp/ql-fix-all.sh "$@"
exit $?
fi
STAMP="$(date +%Y%m%d-%H%M%S)"
BAK="$API.bak.$STAMP"
# =============================================================================
# 还原
# =============================================================================
if [ "$REVERT" = "1" ]; then
step "还原 api.sh"
LAST="$(ls -1t "$API".bak.* 2>/dev/null | head -1)"
if [ -z "$LAST" ]; then bad "找不到备份 $API.bak.*"; exit 1; fi
cp -a "$LAST" "$API" && ok "已还原 ← $LAST"
bash -n "$API" && ok "语法校验通过"
echo
echo " scopes 的改动无需还原(多一个 dashboard 是无害的,升级镜像后自然一致)"
exit 0
fi
# =============================================================================
step "1/4 诊断"
P1=0; P3=0
grep -q '\[qlfix\]' "$API" 2>/dev/null && P1=1
grep -q '\[qlfix-dashcall\]' "$API" 2>/dev/null && P3=1
printf ' api.sh 第1层补丁(去 exit_code) : %s\n' "$([ "$P1" = 1 ] && echo "${G}已打${N}" || echo "${Y}未打${N}")"
printf ' api.sh 第3层补丁(屏蔽上报) : %s\n' "$([ "$P3" = 1 ] && echo "${G}已打${N}" || echo "${Y}未打${N}")"
SCOPE_NEED=0; SKIP_DB=1; KVKEY="-"; KVSC=""; KV_ST=""; DBSC=""
if command -v python3 >/dev/null 2>&1; then
S2="$(python3 - "$KV" "$DB" <<'PYEOF'
import sys, os, json, sqlite3
kv, db = sys.argv[1], sys.argv[2]
def jload(v, d):
if isinstance(v, (list, dict)):
return v
try:
return json.loads(v) if v else d
except Exception:
return d
def read_cache():
if not os.path.exists(kv):
return "NOFILE", None, ""
con = sqlite3.connect("file:%s?mode=ro" % kv, uri=True)
try:
keys = [r[0] for r in con.execute("SELECT key FROM keyv")]
kn = next((k for k in ("apps", "keyv:apps") if k in keys), None) \
or next((k for k in keys if str(k).endswith(":apps")), None)
if not kn:
return "NOKEY", None, ",".join(str(x) for x in keys)
row = next(iter(con.execute("SELECT value FROM keyv WHERE key=?", (kn,))), None)
finally:
con.close()
sc = None
for a in jload(row[0] if row else "[]", []):
if isinstance(a, dict) and a.get("name") == "system":
sc = jload(a.get("scopes"), [])
return "OK", kn, sc
try:
st, kn, val = read_cache()
if st == "OK" and kn:
print("KV|OK|%s|%s" % (kn, ",".join(str(x) for x in (val or []))))
else:
print("KV|BAD|%s" % st)
except Exception as e:
print("KV|BAD|ERR %s" % e)
try:
con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
try:
r = next(iter(con.execute("SELECT scopes FROM Apps WHERE name='system'")), None)
finally:
con.close()
if r is None:
print("DB|NOSYS")
else:
print("DB|%s" % ",".join(str(x) for x in jload(r[0], [])))
except Exception as e:
print("DB|BAD|%s" % e)
PYEOF
)"
KV_ST="$(printf '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $2}' | head -1)"
if [ "$KV_ST" = "OK" ]; then
KVKEY="$(printf '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $3}' | head -1)"
KVSC="$(printf '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $4}' | head -1)"
fi
DB_ST="$(printf '%s\n' "$S2" | awk -F'|' '$1=="DB"{print $2}' | head -1)"
case "$DB_ST" in ""|BAD|NOSYS) SKIP_DB=1 ;; *) DBSC="$DB_ST"; SKIP_DB=0 ;; esac
printf ' system 应用的 scopes:\n'
printf ' keyv 缓存 (%s) : %s\n' "$KVKEY" "${KVSC:-读不到}"
printf ' database.sqlite : %s\n' "${DBSC:-读不到}"
hasd() { printf '%s' "$1" | tr ',' '\n' | grep -qx dashboard; }
if [ -z "$KVSC" ]; then
SCOPE_NEED=1; warn "读不到 keyv 缓存(状态 $KV_ST),将直接尝试写入"
elif hasd "$KVSC"; then
ok "缓存已含 dashboard —— 第2层不成立"
else
SCOPE_NEED=1; bad "★ 缓存缺 dashboard —— 这就是 401 jwt malformed 的原因"
fi
if [ "$SKIP_DB" = 0 ] && ! hasd "$DBSC"; then
SCOPE_NEED=1; bad "源头 database.sqlite 也缺 dashboard(当前 $DBSC)"
fi
else
bad "容器内没有 python3,无法自动修第2层"
fi
# --- 实测对照:同一 token 打两个端点 ---------------------------------------
TOK="$(jq -r '.value // .token // empty' "$TOKF" 2>/dev/null)"
if [ -n "$TOK" ]; then
probe() {
curl -s -o /dev/null -w '%{http_code}' --noproxy '*' -m 8 \
-X "$1" "http://localhost:$PORT$2" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json;charset=UTF-8' \
--data-raw "$3" 2>/dev/null
}
C1="$(probe PUT /open/crons/status '{"ids":[999999999],"status":"1","pid":"1","log_path":"/tmp/x.log","last_execution_time":0,"last_running_time":1}')"
C2="$(probe POST /open/dashboard/record '{"ref_id":999999999,"code":0,"elapsed":1}')"
printf ' crons/status : HTTP %s\n' "${C1:-ERR}"
printf ' dashboard/record : HTTP %s\n' "${C2:-ERR}"
if [ "$C1" = "200" ] && [ "$C2" = "401" ]; then
bad "★ 对照成立:crons 通、dashboard 401 ⇒ 就是第2层(缓存缺 scope)"
fi
else
warn "读不到 token,跳过端点实测"
fi
if [ "$DRY" = "1" ]; then
step "诊断结束(-n 模式,未改任何文件)"
exit 0
fi
# =============================================================================
step "2/4 修第2层:给 system 应用补 dashboard scope"
if [ "$SCOPE_NEED" = "0" ]; then
ok "无需修改"
else
cp -a "$KV" "$KV.bak.$STAMP" 2>/dev/null && ok "已备份 → $(basename "$KV").bak.$STAMP"
[ "$SKIP_DB" = 0 ] && [ -f "$DB" ] && cp -a "$DB" "$DB.bak.$STAMP" 2>/dev/null \
&& ok "已备份 → $(basename "$DB").bak.$STAMP"
RES="$(python3 - "$KV" "$DB" <<'PYEOF'
import sys, os, json, sqlite3
kv, db = sys.argv[1], sys.argv[2]
def jload(v, d):
if isinstance(v, (list, dict)):
return v
try:
return json.loads(v) if v else d
except Exception:
return d
DONE = 0
# ---- keyv.sqlite(鉴权真正读的,改完立即生效,无需重启) ----
try:
if os.path.exists(kv):
con = sqlite3.connect(kv, timeout=20)
try:
keys = [r[0] for r in con.execute("SELECT key FROM keyv")]
kn = next((k for k in ("apps", "keyv:apps") if k in keys), None) \
or next((k for k in keys if str(k).endswith(":apps")), None)
if not kn:
print("KV|NOKEY|键=%s" % ",".join(str(x) for x in keys))
else:
row = next(iter(con.execute("SELECT value FROM keyv WHERE key=?", (kn,))), None)
apps = jload(row[0] if row else "[]", [])
if not isinstance(apps, list):
print("KV|BADJSON|%s" % kn)
else:
hit = False
for a in apps:
if not isinstance(a, dict) or a.get("name") != "system":
continue
hit = True
sc = jload(a.get("scopes"), [])
if not isinstance(sc, list):
sc = []
if "dashboard" not in sc:
sc.append("dashboard")
a["scopes"] = sc
con.execute("UPDATE keyv SET value=? WHERE key=?",
(json.dumps(apps, ensure_ascii=False), kn))
con.commit()
print("KV|ADDED|%s" % kn)
DONE += 1
else:
print("KV|HAS|%s" % kn)
if not hit:
print("KV|NOSYS|缓存里没有 name=system 的应用")
finally:
con.close()
else:
print("KV|NOFILE|%s" % kv)
except Exception as e:
print("KV|ERR|%s" % e)
# ---- database.sqlite(源头,保证下次重启后也一致) ----
try:
if os.path.exists(db):
con = sqlite3.connect(db, timeout=20)
try:
r = next(iter(con.execute("SELECT scopes FROM Apps WHERE name='system'")), None)
if r is None:
print("DB|NOSYS")
else:
sc = jload(r[0], [])
if not isinstance(sc, list):
sc = []
if "dashboard" not in sc:
sc.append("dashboard")
con.execute("UPDATE Apps SET scopes=? WHERE name='system'",
(json.dumps(sc, ensure_ascii=False),))
con.commit()
print("DB|ADDED")
DONE += 1
else:
print("DB|HAS")
finally:
con.close()
else:
print("DB|NOFILE|%s" % db)
except Exception as e:
print("DB|ERR|%s" % e)
print("DONE|%d" % DONE)
PYEOF
)"
printf '%s\n' "$RES" | sed 's/^/ /'
case "$RES" in
*"|ADDED"*) ok "已补上 dashboard scope(即时生效,无需重启)" ;;
*"|HAS"*) ok "已是最新,无需修改" ;;
*) warn "未能确认修复结果,见上方明细" ;;
esac
fi
# =============================================================================
step "3/4 修第1、3层:给 api.sh 打补丁"
if [ "$P1" = "1" ] && [ "$P3" = "1" ]; then
ok "两个补丁都已存在,跳过"
else
cp -a "$API" "$BAK" && ok "已备份 → $(basename "$BAK")" || { bad "备份失败,中止"; exit 1; }
python3 - "$API" <<'PYEOF'
import re, sys
p = sys.argv[1]
s = open(p, encoding="utf-8").read()
out = []
# --- 第1层:屏蔽 update_cron 里 exit_code 的追加 ---
if "[qlfix]" in s:
out.append("L1|HAS")
else:
m = re.search(r'^([ \t]*)if \[\[ -n \$exitCode \]\]; then[ \t]*$', s, re.M)
if m:
ins = (m.group(1) + "if false; then # [qlfix] 后端 Joi 不含 exit_code,"
"发送会被拒为 400 Validation failed")
s = s[:m.start()] + ins + s[m.end():]
out.append("L1|ADDED")
else:
out.append("L1|NOTFOUND")
# --- 第3层:屏蔽 record_cron_stat ---
if "[qlfix-dashcall]" in s:
out.append("L3|HAS")
else:
m = re.search(r'^record_cron_stat\(\)[ \t]*\{[ \t]*$', s, re.M)
if m:
ins = ("\n # [qlfix-dashcall] 后端无 /open/dashboard/record 路由(404)\n"
" # 该调用只写面板统计,与任务状态回写无关,屏蔽以保持日志干净\n"
" return 0")
s = s[:m.end()] + ins + s[m.end():]
out.append("L3|ADDED")
else:
out.append("L3|NOTFOUND")
open(p, "w", encoding="utf-8").write(s)
print(" ".join(out))
PYEOF
P1=0; P3=0
grep -q '\[qlfix\]' "$API" && P1=1
grep -q '\[qlfix-dashcall\]' "$API" && P3=1
[ "$P1" = 1 ] && ok "第1层补丁就位" || warn "第1层未匹配(该文件写法可能不同,已跳过)"
[ "$P3" = 1 ] && ok "第3层补丁就位" || warn "第3层未匹配(该文件写法可能不同,已跳过)"
fi
# =============================================================================
step "4/4 语法校验"
if bash -n "$API" 2>/dev/null; then
ok "api.sh 语法正确"
else
bad "语法错误!自动回滚 ..."
[ -f "$BAK" ] && cp -a "$BAK" "$API" && ok "已回滚到原文件"
exit 1
fi
if [ -n "$TOK" ]; then
C1="$(curl -s -o /dev/null -w '%{http_code}' --noproxy '*' -m 8 \
-X PUT "http://localhost:$PORT/open/crons/status" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json;charset=UTF-8' \
--data-raw '{"ids":[999999999],"status":"1","pid":"1","log_path":"/tmp/x.log","last_execution_time":0,"last_running_time":1}' 2>/dev/null)"
[ "$C1" = "200" ] && ok "端点实测:crons/status → HTTP 200" \
|| warn "端点实测:crons/status → HTTP ${C1:-ERR}(401 说明第2层没修上)"
fi
echo
ok "处理完成"
cat <<EOF
下一步:去面板随便跑一个任务,确认日志结尾只有
## 完成 ✅ 这一行
且任务状态能变回「已结束」。
⚠️ 打完补丁的那一次运行,日志里还会再出现一次 404 + jq 报错。
这是正常的「最后一响」—— shell 是任务启动时才 source 的,
那个任务内存里还是旧函数。下一个任务才干净。
改动与还原:
· api.sh 补丁 备份 $(basename "$BAK") 还原 bash $0 --revert
· scopes 改动 多一个 dashboard 是无害的,无需还原
· 诊断命令 bash $0 -n
EOF
评论 (0)