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:
1 parent
c8812dd27c
commit
34cd295005
6 files changed
+381
-9
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: "进程",
|
||||
|
||||
Reference in new issue
Block a user