Эх сурвалжийг харах

增加REDIS管理和初始化密码

ops7503 1 долоо хоног өмнө
parent
commit
6f9e6522d4

+ 7 - 4
API.md

@@ -45,14 +45,14 @@ http://127.0.0.1:15665/
 www/conf/ngs.conf
 ```
 
-首次启动若不存在会自动生成,默认
+首次启动若不存在会自动生成随机密码并打印到控制台,例如
 
 ```text
 username=admin
-password=ngsadmin
+password=<随机16位>
 ```
 
-修改配置后需**重启 ngs** 生效。浏览器使用 Cookie `ngs_session`。
+请保存控制台输出的密码;修改配置后需**重启 ngs** 生效。浏览器使用 Cookie `ngs_session`。
 
 | 方法 | 路径 | 说明 | Body |
 |------|------|------|------|
@@ -189,8 +189,11 @@ password=ngsadmin
 | POST | `/api/redis/uninstall` | 卸载 | - |
 | POST | `/api/redis/start` | 启动 | - |
 | POST | `/api/redis/stop` | 停止 | - |
+| GET | `/api/redis/config` | 读取基础配置 | - |
+| POST | `/api/redis/config` | 保存基础配置并热应用 | `{"bind":"127.0.0.1","port":6379,"requirepass":""}` |
+| POST | `/api/redis/exec` | 执行 redis-cli 命令 | `{"command":"INFO"}` |
 
-默认安装目录:`www/software/redis`。默认端口 `6379`,占用时回退 `6380`。
+默认安装目录:`www/software/redis`。默认端口 `6379`,占用时回退 `6380`。`/api/redis/exec` 仅执行首行命令,输出上限 64KB。
 
 ---
 

+ 48 - 0
data/www/assets/app.css

@@ -53,6 +53,54 @@ html, body {
   background: rgba(13, 148, 136, 0.28) !important;
   color: #fff !important;
 }
+.nav-group {
+  display: flex;
+  flex-direction: column;
+  gap: 0.15rem;
+}
+.nav-group-toggle {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+.nav-group-caret {
+  font-size: 0.75rem;
+  opacity: 0.7;
+  transition: transform 0.15s ease;
+}
+.nav-group.is-open > .nav-group-toggle .nav-group-caret {
+  transform: rotate(180deg);
+}
+.nav-group.is-open > .nav-group-toggle,
+.nav-group.has-active > .nav-group-toggle {
+  color: #fff !important;
+}
+.nav-sub {
+  display: none;
+  flex-direction: column;
+  gap: 0.15rem;
+  padding-left: 0.55rem;
+  margin-left: 0.85rem;
+  border-left: 1px solid rgba(148, 163, 184, 0.25);
+}
+.nav-group.is-open > .nav-sub {
+  display: flex;
+}
+.nav-sub-item {
+  font-size: 0.88rem !important;
+  padding: 0.35rem 0.75rem !important;
+}
+.redis-cmd-output {
+  max-height: 280px;
+  overflow: auto;
+  background: #0f172a;
+  color: #e2e8f0;
+  border-radius: 8px;
+  padding: 0.75rem 1rem;
+  font-size: 0.82rem;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
 .nav-logout {
   color: rgba(248, 113, 113, 0.92) !important;
 }

+ 136 - 1
data/www/assets/app.js

@@ -99,7 +99,8 @@
     console: { title: "控制台", eyebrow: "Console" },
     software: { title: "软件管理", eyebrow: "Software" },
     sites: { title: "网站管理", eyebrow: "Websites" },
-    mysql: { title: "数据库", eyebrow: "MySQL" },
+    mysql: { title: "MySQL", eyebrow: "数据库" },
+    redis: { title: "Redis", eyebrow: "数据库" },
     files: { title: "文件管理", eyebrow: "Files" },
     processes: { title: "进程管理", eyebrow: "Processes" },
     shell: { title: "终端", eyebrow: "Shell" },
@@ -107,6 +108,10 @@
     settings: { title: "设置", eyebrow: "Settings" },
   };
 
+  const navGroups = {
+    database: ["mysql", "redis"],
+  };
+
   function toast(msg, type = "ok") {
     const el = document.createElement("div");
     el.className = `toast-item ${type === "err" ? "err" : "ok"}`;
@@ -161,10 +166,23 @@
     }
   }
 
+  function syncNavGroups(activeView) {
+    $$(".nav-group").forEach((g) => {
+      const key = g.dataset.navGroup;
+      const children = navGroups[key] || [];
+      const hasActive = children.includes(activeView);
+      g.classList.toggle("has-active", hasActive);
+      if (hasActive) g.classList.add("is-open");
+      const toggle = g.querySelector("[data-nav-toggle]");
+      if (toggle) toggle.setAttribute("aria-expanded", g.classList.contains("is-open") ? "true" : "false");
+    });
+  }
+
   function setView(name) {
     if (name === "overview" || name === "monitor") name = "console";
     $$(".nav-item[data-view]").forEach((b) => b.classList.toggle("is-active", b.dataset.view === name));
     $$(".view").forEach((v) => v.classList.toggle("is-active", v.dataset.viewPanel === name));
+    syncNavGroups(name);
     const t = titles[name] || titles.console;
     $("#page-title").textContent = t.title;
     $("#page-eyebrow").textContent = t.eyebrow;
@@ -193,6 +211,9 @@
       stopScheduleAutoRefresh();
     }
     if (name === "settings") fillSettingsForm();
+    if (name === "redis") {
+      loadRedisPage().catch((err) => toast(err.message || String(err), "err"));
+    }
   }
 
   function applyPanelName(name) {
@@ -1545,6 +1566,7 @@
   function renderDatabases() {
     fillSqlDbSelect();
     const tbody = $("#db-table tbody");
+    if (!tbody) return;
     if (!state.databases.length) {
       tbody.innerHTML = `<tr><td colspan="6" class="text-secondary text-center py-4">暂无数据库记录</td></tr>`;
       return;
@@ -1567,6 +1589,87 @@
       .join("");
   }
 
+  function redisInfoCell(label, value) {
+    return `<div class="col-sm-6 col-lg-3">
+      <div class="small text-secondary">${escapeHtml(label)}</div>
+      <div class="fw-semibold text-break">${value}</div>
+    </div>`;
+  }
+
+  function renderRedis() {
+    const grid = $("#redis-info-grid");
+    const actions = $("#redis-actions");
+    const summary = $("#redis-status-summary");
+    if (!grid || !actions) return;
+    const info = (state.status && state.status.redis) || {};
+    const installed = !!info.installed;
+    const running = info.running;
+    if (summary) {
+      summary.textContent = !installed
+        ? "未安装"
+        : running
+          ? "运行中"
+          : "已停止";
+    }
+    const btns = [];
+    if (!installed) {
+      btns.push(`<button type="button" class="btn btn-primary" data-act="redis-install">安装</button>`);
+    } else {
+      if (running) {
+        btns.push(`<button type="button" class="btn btn-outline-secondary" data-act="redis-stop">停止</button>`);
+      } else {
+        btns.push(`<button type="button" class="btn btn-primary" data-act="redis-start">启动</button>`);
+      }
+      btns.push(`<button type="button" class="btn btn-outline-primary" data-act="redis-config" data-conf="${escapeHtml(info.conf || "")}">配置</button>`);
+      btns.push(`<button type="button" class="btn btn-outline-secondary" data-act="redis-refresh">刷新</button>`);
+      btns.push(`<button type="button" class="btn btn-outline-danger" data-act="redis-uninstall">卸载</button>`);
+    }
+    actions.innerHTML = btns.join("");
+    grid.innerHTML = [
+      redisInfoCell("状态", statusPill(installed, running)),
+      redisInfoCell("版本", escapeHtml(info.version || "—")),
+      redisInfoCell("端口", escapeHtml(info.port != null ? String(info.port) : "—")),
+      redisInfoCell("配置文件", `<code class="small">${escapeHtml(info.conf || "—")}</code>`),
+      redisInfoCell("安装目录", `<code class="small">${escapeHtml(info.install_dir || "—")}</code>`),
+      redisInfoCell("可执行文件", `<code class="small">${escapeHtml(info.bin || "—")}</code>`),
+    ].join("");
+  }
+
+  async function loadRedisPage() {
+    try {
+      const data = await api("/api/redis/status");
+      if (!state.status) state.status = {};
+      state.status.redis = { ...(state.status.redis || {}), ...(data || {}) };
+    } catch (_) {
+      /* keep cached status */
+    }
+    renderRedis();
+  }
+
+  async function execRedisCmd(btn) {
+    const input = $("#redis-cmd-input");
+    const meta = $("#redis-cmd-meta");
+    const out = $("#redis-cmd-output");
+    if (!input) return;
+    const command = String(input.value || "").trim();
+    if (!command) {
+      toast("请输入 Redis 命令", "err");
+      return;
+    }
+    await withBusy(async () => {
+      const data = await api("/api/redis/exec", {
+        method: "POST",
+        body: { command },
+      });
+      const elapsed = data && data.elapsed_ms != null ? `${data.elapsed_ms} ms` : "";
+      if (meta) meta.textContent = [elapsed, data && data.truncated ? "输出已截断" : ""].filter(Boolean).join(" · ");
+      if (out) {
+        out.hidden = false;
+        out.textContent = (data && data.output) || "(无输出)";
+      }
+    }, btn);
+  }
+
   function stopProcAutoRefresh() {
     if (state.procTimer) {
       clearInterval(state.procTimer);
@@ -2164,6 +2267,7 @@
       renderSoftware();
       renderSites();
       renderDatabases();
+      renderRedis();
       const hash = (location.hash || "#console").slice(1);
       if (hash === "files") await loadFiles(state.filesPath);
     } catch (err) {
@@ -2174,6 +2278,13 @@
   }
 
   async function softAction(act, btn) {
+    if (act === "redis-refresh") {
+      await withBusy(async () => {
+        await loadRedisPage();
+        toast("已刷新");
+      }, btn);
+      return;
+    }
     if (act === "redis-config") {
       await withBusy(async () => openRedisConfigModal(), btn);
       return;
@@ -4753,6 +4864,30 @@
     $$(".nav-item[data-view]").forEach((btn) => {
       btn.addEventListener("click", () => setView(btn.dataset.view));
     });
+    $$("[data-nav-toggle]").forEach((btn) => {
+      btn.addEventListener("click", () => {
+        const key = btn.dataset.navToggle;
+        const group = $(`.nav-group[data-nav-group="${key}"]`);
+        if (!group) return;
+        group.classList.toggle("is-open");
+        btn.setAttribute("aria-expanded", group.classList.contains("is-open") ? "true" : "false");
+      });
+    });
+
+    $("#redis-actions")?.addEventListener("click", (e) => {
+      const btn = e.target.closest("button[data-act]");
+      if (!btn) return;
+      softAction(btn.dataset.act, btn);
+    });
+    $("#btn-redis-exec")?.addEventListener("click", (e) => {
+      execRedisCmd(e.currentTarget);
+    });
+    $("#redis-cmd-input")?.addEventListener("keydown", (e) => {
+      if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+        e.preventDefault();
+        execRedisCmd($("#btn-redis-exec"));
+      }
+    });
 
     $("#btn-schedule-refresh")?.addEventListener("click", (e) => {
       withBusy(async () => {

+ 44 - 2
data/www/index.html

@@ -30,7 +30,16 @@
         <button type="button" class="nav-link text-start nav-item is-active" data-view="console"><i class="bi bi-speedometer2 me-2"></i>控制台</button>
         <button type="button" class="nav-link text-start nav-item" data-view="sites"><i class="bi bi-globe2 me-2"></i>网站</button>
         <button type="button" class="nav-link text-start nav-item" data-view="files"><i class="bi bi-folder2-open me-2"></i>文件</button>
-        <button type="button" class="nav-link text-start nav-item" data-view="mysql"><i class="bi bi-database me-2"></i>数据库</button>
+        <div class="nav-group" data-nav-group="database">
+          <button type="button" class="nav-link text-start nav-item nav-group-toggle" data-nav-toggle="database" aria-expanded="false">
+            <i class="bi bi-database me-2"></i><span>数据库</span>
+            <i class="bi bi-chevron-down nav-group-caret ms-auto"></i>
+          </button>
+          <div class="nav-sub">
+            <button type="button" class="nav-link text-start nav-item nav-sub-item" data-view="mysql">MySQL</button>
+            <button type="button" class="nav-link text-start nav-item nav-sub-item" data-view="redis">Redis</button>
+          </div>
+        </div>
         <button type="button" class="nav-link text-start nav-item" data-view="processes"><i class="bi bi-cpu me-2"></i>进程</button>
         <button type="button" class="nav-link text-start nav-item" data-view="shell"><i class="bi bi-terminal me-2"></i>终端</button>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
@@ -229,7 +238,7 @@
         <div class="card">
           <div class="card-header py-2 d-flex justify-content-between align-items-center">
             <div>
-              <strong>数据库</strong>
+              <strong>MySQL</strong>
               <span class="small text-secondary ms-2">库 / 账号 / 权限</span>
             </div>
             <div class="btn-group btn-group-sm">
@@ -281,6 +290,39 @@
         </div>
       </section>
 
+      <section class="view" id="view-redis" data-view-panel="redis">
+        <div class="card">
+          <div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
+            <div>
+              <strong>Redis</strong>
+              <span class="small text-secondary ms-2" id="redis-status-summary">状态</span>
+            </div>
+            <div class="btn-group btn-group-sm" id="redis-actions"></div>
+          </div>
+          <div class="card-body py-3">
+            <div class="row g-3" id="redis-info-grid">
+              <div class="col-12 text-secondary text-center py-3">加载中…</div>
+            </div>
+          </div>
+        </div>
+        <div class="card mt-3">
+          <div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
+            <div>
+              <strong>执行命令</strong>
+              <span class="small text-secondary ms-2">经 redis-cli 执行,最多返回 64KB</span>
+            </div>
+            <button type="button" class="btn btn-sm btn-primary" id="btn-redis-exec">执行</button>
+          </div>
+          <div class="card-body py-2">
+            <textarea class="form-control sql-editor" id="redis-cmd-input" rows="4" spellcheck="false"
+                      placeholder="INFO&#10;GET key&#10;SET key value&#10;KEYS *"></textarea>
+            <div class="form-text">Ctrl+Enter 执行。FLUSHALL / SHUTDOWN 等请谨慎使用。</div>
+            <div id="redis-cmd-meta" class="small text-secondary mt-2"></div>
+            <pre class="redis-cmd-output mt-2 mb-0" id="redis-cmd-output" hidden></pre>
+          </div>
+        </div>
+      </section>
+
       <section class="view" id="view-processes" data-view-panel="processes">
         <div class="card">
           <div class="card-header py-2">

+ 32 - 1
src/api/api_server.cpp

@@ -742,6 +742,8 @@ void h_status(request* req, response* resp) {
     rd["version"] = redis::installed_version();
     rd["port"] = redis::listen_port();
     rd["conf"] = redis::conf_file();
+    rd["install_dir"] = redis::install_dir();
+    rd["bin"] = redis::bin_path();
     data["redis"] = rd;
 
     data["sites"] = static_cast<int>(website::list_sites().size());
@@ -2284,13 +2286,13 @@ void h_redis_config(request* req, response* resp) {
         return;
     }
     if (method == "POST") {
-        auto body = parse_body(req);
         redis::QuickConfig cfg;
         std::string err;
         if (!redis::read_quick_config(cfg, err)) {
             reply_err(resp, err.empty() ? "read redis config failed" : err);
             return;
         }
+        const auto body = parse_body(req);
         if (body.exist("bind")) {
             cfg.bind = json_str(body, "bind", cfg.bind);
         }
@@ -2316,6 +2318,34 @@ void h_redis_config(request* req, response* resp) {
     reply_err(resp, "method not allowed", 405);
 }
 
+void h_redis_exec(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const auto body = parse_body(req);
+    const std::string command = json_str(body, "command");
+    if (command.empty()) {
+        reply_err(resp, "请输入 Redis 命令");
+        return;
+    }
+    std::string output;
+    std::string err;
+    bool truncated = false;
+    const auto t0 = std::chrono::steady_clock::now();
+    if (!redis::exec_command(command, output, truncated, err)) {
+        reply_err(resp, err.empty() ? "执行失败" : err);
+        return;
+    }
+    const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+                        std::chrono::steady_clock::now() - t0)
+                        .count();
+    ylib::json data;
+    data["output"] = output;
+    data["truncated"] = truncated;
+    data["elapsed_ms"] = static_cast<int>(ms);
+    reply_ok(resp, data);
+}
+
 void h_files_list(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -2628,6 +2658,7 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/redis/start", h_redis_start);
     reg(router, "/api/redis/stop", h_redis_stop);
     reg(router, "/api/redis/config", h_redis_config);
+    reg(router, "/api/redis/exec", h_redis_exec);
 
     reg(router, "/api/websites", h_websites);
     reg(router, "/api/websites/start", h_websites_start);

+ 43 - 9
src/auth/auth.cpp

@@ -80,7 +80,35 @@ std::string random_token() {
     return out.str();
 }
 
-bool write_default_config(const std::string& path) {
+std::string generate_password(size_t length = 16) {
+    static constexpr char kCharset[] =
+        "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
+    const size_t charset_len = sizeof(kCharset) - 1;
+    std::string password;
+    password.reserve(length);
+
+    std::ifstream urandom("/dev/urandom", std::ios::binary);
+    if (urandom) {
+        while (password.size() < length) {
+            unsigned char byte = 0;
+            urandom.read(reinterpret_cast<char*>(&byte), 1);
+            if (!urandom) {
+                break;
+            }
+            password.push_back(kCharset[byte % charset_len]);
+        }
+    }
+    if (password.size() < length) {
+        static thread_local std::mt19937 rng{std::random_device{}()};
+        std::uniform_int_distribution<size_t> dist(0, charset_len - 1);
+        while (password.size() < length) {
+            password.push_back(kCharset[dist(rng)]);
+        }
+    }
+    return password;
+}
+
+bool write_default_config(const std::string& path, const std::string& password) {
     std::ofstream out(path, std::ios::trunc);
     if (!out) {
         return false;
@@ -88,7 +116,8 @@ bool write_default_config(const std::string& path) {
     out << "# NGS 管理面板登录账号\n"
            "# 修改后需重启 ngs 生效\n"
            "username=admin\n"
-           "password=ngsadmin\n";
+           "password="
+        << password << "\n";
     return true;
 }
 
@@ -180,16 +209,21 @@ bool init(std::string& err) {
     }
     const std::string path = config_path();
     if (!path_exists(path)) {
-        if (!write_default_config(path)) {
+        const std::string password = generate_password();
+        if (!write_default_config(path, password)) {
             err = "无法创建配置文件: " + path;
             return false;
         }
-        log_info("created default auth config path=" + path +
-                 " user=admin pass=ngsadmin");
-        std::cout << "已生成面板登录配置: " << path << "\n"
-                  << "  默认账号: admin\n"
-                  << "  默认密码: ngsadmin\n"
-                  << "  请尽快修改 " << path << " 后重启 ngs\n";
+        log_info("created default auth config path=" + path + " user=admin");
+        std::cout << "\n"
+                  << "========================================\n"
+                  << " 首次启动:已生成面板登录账号\n"
+                  << " 配置文件: " << path << "\n"
+                  << " 账号: admin\n"
+                  << " 密码: " << password << "\n"
+                  << " 请妥善保存,修改请编辑配置后重启 ngs\n"
+                  << "========================================\n"
+                  << "\n";
     }
     Credentials c;
     if (!load_config_file(path, c)) {

+ 1 - 1
src/auth/auth.h

@@ -10,7 +10,7 @@ namespace auth {
 
 struct Credentials {
     std::string username = "admin";
-    std::string password = "ngsadmin";
+    std::string password;
 };
 
 struct CaptchaImage {

+ 67 - 0
src/software/redis/redis.cpp

@@ -596,6 +596,73 @@ bool stop() {
     return true;
 }
 
+bool exec_command(const std::string& command, std::string& output,
+                  bool& truncated, std::string& err) {
+    output.clear();
+    truncated = false;
+    err.clear();
+    if (!is_installed()) {
+        err = "Redis 未安装";
+        return false;
+    }
+    if (!is_running()) {
+        err = "Redis 未运行";
+        return false;
+    }
+    const std::string cli = join_path(install_dir(), "bin/redis-cli");
+    if (!path_exists(cli)) {
+        err = "找不到 redis-cli";
+        return false;
+    }
+    std::string cmd_text = trim_copy(command);
+    if (cmd_text.empty()) {
+        err = "请输入 Redis 命令";
+        return false;
+    }
+    // Only execute the first non-empty line to avoid multi-command injection via
+    // newlines while still allowing typical one-liners from the panel.
+    {
+        std::string first;
+        for (char c : cmd_text) {
+            if (c == '\n' || c == '\r') {
+                break;
+            }
+            first.push_back(c);
+        }
+        cmd_text = trim_copy(first);
+    }
+    if (cmd_text.empty()) {
+        err = "请输入 Redis 命令";
+        return false;
+    }
+    if (cmd_text.size() > 4096) {
+        err = "命令过长";
+        return false;
+    }
+    const int port = listen_port();
+    const std::string pass = conf_requirepass_from_file();
+    std::string shell =
+        "printf %s " + shell_single_quote(cmd_text + "\n") + " | \"" + cli +
+        "\" -p " + std::to_string(port);
+    if (!pass.empty()) {
+        shell += " -a " + shell_single_quote(pass);
+    }
+    shell += " --no-auth-warning --raw 2>&1";
+    std::string captured;
+    const int rc = run_cmd_capture(shell, captured);
+    constexpr size_t kMaxOut = 64 * 1024;
+    if (captured.size() > kMaxOut) {
+        captured.resize(kMaxOut);
+        truncated = true;
+    }
+    output = std::move(captured);
+    if (rc != 0 && output.empty()) {
+        err = "redis-cli 执行失败";
+        return false;
+    }
+    return true;
+}
+
 void menu() {
     while (true) {
         print_header("软件管理 - Redis");

+ 2 - 0
src/software/redis/redis.h

@@ -26,6 +26,8 @@ int listen_port();
 
 bool read_quick_config(QuickConfig& out, std::string& err);
 bool apply_quick_config(const QuickConfig& in, std::string& err);
+bool exec_command(const std::string& command, std::string& output,
+                  bool& truncated, std::string& err);
 
 bool install(const std::string& version = kDefaultVersion);
 bool uninstall();