feat(core): mgmt 接入 core 进程管理 + 管理员认证

- gateway 新增 POST /admin/verify 验签接口,供内部服务校验管理员 Token
- core 的 /list /exec /kill 接入管理员认证(调用 gateway 验签,fail-closed)
- gateway 代理 opened 白名单加入 core:5200
- mgmt 管理页显示当前用户与角色,进程页接入 core 进程管理接口
This commit is contained in:
qing committed 2026-08-04 20:00:24 +08:00
1 parent c8812dd27c
commit 34cd295005
6 files changed
+381 -9

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ tls = off
[Proxy]
default = http://127.0.0.1:9200
closed = http://127.0.0.1:8005;http://127.0.0.1:8007;
opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200;
[Token]
# Token 有效期(秒),默认 3600 = 1 小时
+1 -1
View File
@@ -16,7 +16,7 @@ CPP:= g++
INCS:= -I include/
MY_LIBS := -L/usr/lib -lus_th -lus_http
MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lcurl
LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem
CPPFLAGS := --std=c++20 -O3 -pipe #-Wall -Werror
+99
View File
@@ -1,6 +1,9 @@
#include <iostream>
#include <cstdlib>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <boost/json.hpp>
#include <us/HttpClient.hpp>
#include "main/HttpTask.hh"
#include "main/pmc_mtd.hh"
#include "main/subsys_call.hh"
@@ -126,6 +129,78 @@ static std::string url_decode(const std::string& str) {
namespace process_http_server {
/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */
static std::string get_cookie_value(const std::string& cookie_header, const std::string& name) {
size_t pos = 0;
while (pos <= cookie_header.size()) {
size_t end = cookie_header.find(';', pos);
if (end == std::string::npos) end = cookie_header.size();
std::string pair = cookie_header.substr(pos, end - pos);
size_t eq = pair.find('=');
if (eq != std::string::npos) {
std::string k = pair.substr(0, eq);
size_t b = k.find_first_not_of(" \t");
size_t e = k.find_last_not_of(" \t");
if (b != std::string::npos) k = k.substr(b, e - b + 1);
if (k == name) return pair.substr(eq + 1);
}
if (end == cookie_header.size()) break;
pos = end + 1;
}
return "";
}
/* 管理员验证:调用 gateway 的 /admin/verify 验签端点,验证请求携带的 Token 是否为有效管理员。
* gateway 是 Token 签发与验证的唯一权威(RSA 验签 + is_admin + 过期检查)。
* gateway 地址由环境变量 GATEWAY_HOST/GATEWAY_PORT 配置,默认 127.0.0.1:8443。
* gateway 不可用或响应异常一律视为未授权(fail-closed)。 */
static bool verify_admin_token(const http::request<http::string_body>& req) {
try {
/* 提取 Token:Authorization 头优先,回退 Cookie */
std::string raw;
auto it = req.base().find(http::field::authorization);
if (it != req.base().end()) {
raw = std::string(it->value());
} else {
auto cit = req.base().find(http::field::cookie);
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
if (raw.empty() || raw.substr(0, 7) != "Bearer ") return false;
/* gateway 地址 */
std::string host = "127.0.0.1";
int port = 8443;
if (const char* h = std::getenv("GATEWAY_HOST")) host = h;
if (const char* p = std::getenv("GATEWAY_PORT")) {
int v = std::atoi(p);
if (v > 0) port = v;
}
std::string url = "http://" + host + ":" + std::to_string(port) + "/admin/verify";
pmc::net::HttpClient client;
client.set_header("Authorization: " + raw);
std::string body;
client.post_stream_sync(url, "", [&](const std::string& chunk) { body += chunk; });
auto jv = boost::json::parse(body);
if (!jv.is_object()) return false;
auto& obj = jv.as_object();
auto it_success = obj.find("success");
auto it_admin = obj.find("is_admin");
if (it_success == obj.end() || it_admin == obj.end()) return false;
if (!it_success->value().is_bool() || !it_admin->value().is_bool()) return false;
return it_success->value().as_bool() && it_admin->value().as_bool();
}
catch (const std::exception&) {
return false;
}
}
/* ---- 注册POST路由 ---- */
void registerRoutes(pmc::net::HttpServer& server) {
server.post("/list", [](const auto& req, const auto& params) {
@@ -199,6 +274,14 @@ namespace process_http_server {
res.version(req.version());
res.set(http::field::content_type, "application/json");
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
if (!verify_admin_token(req)) {
res.result(http::status::unauthorized);
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
res.prepare_payload();
return res;
}
try {
std::string target = getTarget(params);
@@ -226,6 +309,14 @@ namespace process_http_server {
res.version(req.version());
res.set(http::field::content_type, "application/json");
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
if (!verify_admin_token(req)) {
res.result(http::status::unauthorized);
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
res.prepare_payload();
return res;
}
try {
std::string target = getTarget(params);
std::string command = getExecCommand(params);
@@ -271,6 +362,14 @@ namespace process_http_server {
res.version(req.version());
res.set(http::field::content_type, "application/json");
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
if (!verify_admin_token(req)) {
res.result(http::status::unauthorized);
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
res.prepare_payload();
return res;
}
try {
std::string target = getTarget(params);
int pid = getPid(params);
+137
View File
@@ -321,6 +321,11 @@ http::response<http::string_body> handleWhoami(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */
http::response<http::string_body> handleAdminVerify(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
http::response<http::string_body> handleCaptchaImage(
const http::request<http::string_body>& req,
@@ -871,6 +876,12 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleWhoami(req, params); });
});
/* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */
server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleAdminVerify(req, params); });
});
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
@@ -1323,6 +1334,132 @@ http::response<http::string_body> handleWhoami(
return res;
}
/* 内部服务间管理员验签视图:POST /admin/verify
*
* 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。
* gateway 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。
*
* 响应:{ success, is_admin, user }
* - success=true, is_admin=true → 有效管理员(放行)
* - success=true, is_admin=false → 非管理员(拒绝)
* - success=false → 无 Token / 验签失败 / 过期(拒绝)
* 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */
http::response<http::string_body> handleAdminVerify(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)
{
http::response<http::string_body> res;
res.version(req.version());
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
/* 获取 Token:Authorization 头优先,回退 Cookie */
std::string raw;
auto it_auth = req.base().find("Authorization");
if (it_auth != req.base().end()) {
raw = std::string(it_auth->value());
} else {
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
/* 无 Token 或格式错误:按未授权处理 */
if (raw.empty() || raw.substr(0, 7) != "Bearer ") {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
raw = raw.substr(7);
size_t idx = raw.find("_");
if (idx == std::string::npos) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx + 1);
std::string auth;
try { auth = Base64::base64_decode_to_string(data); }
catch (const std::exception&) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
/* RSA 验签:失败按未授权处理 */
if (!_PUBL.Verify(auth, sign)) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
/* 解析 Token payload */
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (const std::exception&) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
if (!jv_t.is_object()) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
json::object& obj = jv_t.as_object();
/* 过期检查:created_at + keep_time 超过当前时间按无效处理 */
auto t = json::value_to<std::string>(obj["created_at"]);
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
auto created_tp = LocalTime::parse(t);
if (created_tp) {
auto now = std::chrono::system_clock::now();
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
if (now > expire_tp) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
}
/* 管理员判定:旧 Token 未携带 is_admin 时默认为 false */
bool is_admin = false;
if (obj.contains("is_admin")) {
auto& v = obj["is_admin"];
if (v.is_bool()) is_admin = v.as_bool();
}
std::string username;
try { username = json::value_to<std::string>(obj["user"]); }
catch (...) {}
json::value jv_r = {
{"success", true},
{"is_admin", is_admin},
{"user", username}
};
res.result(http::status::ok);
res.body() = json::serialize(jv_r);
res.prepare_payload();
return res;
}
/* 刷新 Token:POST /refresh_token
*
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
+111 -7
View File
@@ -33,7 +33,7 @@
</div>
<div class="input">
<div class="prompt">输入模块名:</div>
<div class="prompt">输入命令:</div>
<input id="input-area" class="input-area" type="text">
</div>
<button class="create-btn" onclick="createProcess()">创建</button>
@@ -41,25 +41,124 @@
</div>
<script>
// core 进程管理接口经 gateway 代理(Target-Url 指向 core:5200)
const CORE_TARGET = "http://127.0.0.1:5200";
function handleUnauthorized(res) {
if (res.status === 401) {
alert("未授权或管理员 Token 已失效,请重新登录");
setTimeout(() => { window.location.href = "../index.html"; }, 800);
return true;
}
return false;
}
async function api(path, body) {
const res = await fetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Target-Url': CORE_TARGET
},
body: body ? JSON.stringify(body) : '{}'
});
if (handleUnauthorized(res)) return null;
const text = await res.text();
let result;
try {
result = JSON.parse(text);
} catch (e) {
console.error('接口返回非 JSON:', text);
throw new Error('服务器响应格式异常 (HTTP ' + res.status + ')');
}
if (!res.ok || !result.success) {
throw new Error(result.message || '请求失败 (HTTP ' + res.status + ')');
}
return result;
}
// 列表接口返回的 data,可能是数组,也可能是 ptree 序列化后的
// {"": {...}, "": {...}} 对象(boost property_tree 对数组的表示)
function toProcessArray(data) {
if (Array.isArray(data)) return data;
if (data && typeof data === "object") {
return Object.values(data);
}
return [];
}
async function loadProcesses() {
const tbody = document.getElementById("tBody");
const progress = document.getElementById("inProgressText");
tbody.innerHTML = "";
progress.innerText = "加载中...";
try {
const result = await api('/list');
const list = toProcessArray(result.data);
tbody.innerHTML = "";
for (let i = 0; i < list.length; i++) {
const p = list[i];
const tr = document.createElement("tr");
const pid = p.pid;
const status = p.status === true ? "运行中" : (p.status === false ? "已停止" : (p.status || "未知"));
tr.innerHTML = `
<td>${pid}</td>
<td>${status}</td>
<td>${p.command || p.target || "-"}</td>
<td><button class="stopBtn" onclick="stopBtn(${pid})">停止</button></td>
`;
tbody.appendChild(tr);
}
progress.innerText = "";
} catch (err) {
progress.innerText = "";
tbody.innerHTML = "";
alert(err.message || '加载进程列表失败');
}
}
function newBtn() {
const modal = document.getElementById("modal-con")
modal.style.display = "flex"
}
function createProcess() {
const moduleName = document.getElementById("input-area").value
alert("注意:这是静态页面 - 创建进程需要后端 API 支持,请通过 mgmt Flask 后端访问");
async function createProcess() {
const moduleName = document.getElementById("input-area").value.trim()
if (!moduleName) {
alert("请输入要执行的命令");
return;
}
const progress = document.getElementById("inProgressText");
progress.innerText = "启动中...";
try {
await api('/exec', { command: moduleName });
alert("启动成功");
closeModal();
await loadProcesses();
} catch (err) {
progress.innerText = "";
alert(err.message || '启动失败');
}
}
function flushBtn() {
window.location.reload()
loadProcesses();
}
function stopBtn(pid) {
async function stopBtn(pid) {
if (!confirm("确认停止该服务?")) {
return
}
alert("注意:这是静态页面 - 停止进程需要后端 API 支持,请通过 mgmt Flask 后端访问");
const progress = document.getElementById("inProgressText");
progress.innerText = "停止中...";
try {
await api('/kill', { pid: pid });
alert("已停止");
await loadProcesses();
} catch (err) {
progress.innerText = "";
alert(err.message || '停止失败');
}
}
function closeModal() {
@@ -69,6 +168,11 @@
input.value = ""
modal.style.display = "none"
}
// 页面加载时自动拉取进程列表
window.onload = () => {
loadProcesses();
};
</script>
</body>
+32
View File
@@ -9,7 +9,39 @@
<link rel="stylesheet" href="mgmt.css">
<script>
async function loadUserInfo() {
try {
const res = await fetch('/whoami', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{}'
});
const data = await res.json();
if (data.login && data.user) {
document.getElementById("current-user").innerText = "当前用户: " + data.user;
document.getElementById("current-role").innerText = data.is_admin ? "当前角色: 管理员" : "当前角色: 普通用户";
if (!data.is_admin) {
alert("需要管理员权限才能访问此页面");
}
} else {
document.getElementById("current-user").innerText = "当前用户: 未登录";
document.getElementById("current-role").innerText = "当前角色: 未知";
setTimeout(() => {
window.location = "index.html";
}, 1000);
}
} catch (err) {
console.error('获取用户信息失败:', err);
document.getElementById("current-user").innerText = "当前用户: 未知";
document.getElementById("current-role").innerText = "当前角色: 未知";
}
}
window.onload = () => {
loadUserInfo();
const featureList = [
{
name: "进程",