1 há 1 mês atrás
pai
commit
83f168adcd

+ 2 - 0
CMakeLists.txt

@@ -55,6 +55,7 @@ find_library(SQLITE3_LIB NAMES sqlite3 REQUIRED)
 find_library(SSL_LIB NAMES ssl REQUIRED)
 find_library(CRYPTO_LIB NAMES crypto REQUIRED)
 find_library(Z_LIB NAMES z REQUIRED)
+find_library(X11_LIB NAMES X11 REQUIRED)
 
 set(NGS_LIBS
     ${YLIB_LIB}
@@ -65,6 +66,7 @@ set(NGS_LIBS
     ${SSL_LIB}
     ${CRYPTO_LIB}
     ${Z_LIB}
+    ${X11_LIB}
     pthread
     dl
     rt

+ 225 - 7
data/www/assets/app.css

@@ -53,6 +53,16 @@ html, body {
   background: rgba(13, 148, 136, 0.28) !important;
   color: #fff !important;
 }
+.nav-logout {
+  color: rgba(248, 113, 113, 0.92) !important;
+}
+.nav-logout:hover {
+  background: rgba(248, 113, 113, 0.14) !important;
+  color: #fecaca !important;
+}
+.rail-foot {
+  margin-top: auto;
+}
 
 .live .pulse {
   width: 8px;
@@ -245,6 +255,21 @@ html, body {
 }
 .files-name:hover { color: var(--accent); text-decoration: underline; }
 
+.files-check-col {
+  width: 2rem;
+  text-align: center;
+  vertical-align: middle;
+}
+#files-table tbody tr.is-selected {
+  background: rgba(37, 99, 235, 0.08);
+}
+#files-table tbody tr.is-selected:hover {
+  background: rgba(37, 99, 235, 0.12);
+}
+#files-bulk-actions .disabled {
+  pointer-events: none;
+}
+
 .crumbs a, .crumbs button {
   appearance: none;
   border: 0;
@@ -280,22 +305,137 @@ html, body {
   to { opacity: 1; transform: none; }
 }
 
+.alert-dialog-msg {
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.8125rem;
+  line-height: 1.45;
+  max-height: min(50vh, 22rem);
+  overflow: auto;
+  margin: 0;
+  color: #991b1b;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+  border-radius: 0.5rem;
+  padding: 0.75rem 0.9rem;
+}
+
+/* Nested dialogs (e.g. URL proxy form over site settings) */
+#modal.modal {
+  z-index: 2010;
+}
+.modal-backdrop.modal-backdrop-nested {
+  z-index: 2000;
+}
+
 .task-panel[hidden] { display: none !important; }
-.task-panel { position: fixed; inset: 0; z-index: 70; }
+.task-panel {
+  position: fixed; inset: 0; z-index: 70;
+  display: flex; align-items: center; justify-content: center;
+  padding: 1.25rem;
+}
 .task-panel-backdrop {
   position: absolute; inset: 0;
-  background: rgba(15, 23, 42, 0.45);
+  background: rgba(15, 23, 42, 0.5);
+  backdrop-filter: blur(2px);
 }
-.task-panel-sheet {
-  position: absolute; top: 0; left: 0; bottom: 0;
-  width: min(720px, 100vw);
+.task-panel-dialog {
+  position: relative;
+  z-index: 1;
+  width: min(920px, 94vw);
+  height: min(640px, 84vh);
+  min-width: 480px;
+  min-height: 320px;
+  max-width: calc(100vw - 2.5rem);
+  max-height: calc(100vh - 2.5rem);
   background: #0f172a; color: #e2e8f0;
   display: flex; flex-direction: column;
-  box-shadow: 16px 0 40px rgba(0,0,0,.35);
+  border-radius: 12px;
+  overflow: hidden;
+  border: 1px solid rgba(255,255,255,.1);
+  box-shadow: 0 24px 80px rgba(0,0,0,.45);
+  animation: task-dialog-in .18s ease-out;
+}
+.task-panel-dialog.is-resizing {
+  animation: none;
+  user-select: none;
+}
+@keyframes task-dialog-in {
+  from { opacity: 0; transform: translateY(10px) scale(.98); }
+  to { opacity: 1; transform: none; }
+}
+
+/* Shared resize handle for all built-in dialogs */
+.dialog-resize {
+  position: absolute;
+  right: 0; bottom: 0;
+  width: 18px; height: 18px;
+  cursor: nwse-resize;
+  z-index: 5;
+}
+.dialog-resize::before {
+  content: "";
+  position: absolute;
+  right: 4px; bottom: 4px;
+  width: 10px; height: 10px;
+  border-right: 2px solid rgba(148, 163, 184, 0.85);
+  border-bottom: 2px solid rgba(148, 163, 184, 0.85);
+  border-radius: 1px;
+}
+.dialog-resize:hover::before {
+  border-color: #0d9488;
+}
+.modal-content .dialog-resize::before {
+  border-color: rgba(100, 116, 139, 0.9);
+}
+.modal-content .dialog-resize:hover::before {
+  border-color: #0d9488;
+}
+.is-resizable-dialog {
+  position: relative;
+}
+.is-resizable-dialog.is-resizing {
+  user-select: none;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size {
+  max-width: none;
+  margin-left: auto;
+  margin-right: auto;
+  display: flex;
+  flex-direction: column;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .modal-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  position: relative;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .modal-header,
+.modal-dialog.is-resizable-dialog.has-custom-size .modal-footer {
+  flex-shrink: 0;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .modal-body {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: auto;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-body {
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-layout {
+  flex: 1;
+  min-height: 0;
+  height: auto;
 }
 .task-panel-head {
   padding: 0.9rem 1rem;
   border-bottom: 1px solid rgba(255,255,255,.08);
+  flex-shrink: 0;
 }
 .task-panel-body {
   flex: 1; min-height: 0;
@@ -366,6 +506,10 @@ html, body {
   z-index: 1;
   width: min(1100px, 92vw);
   height: min(720px, 82vh);
+  min-width: 560px;
+  min-height: 360px;
+  max-width: calc(100vw - 2.5rem);
+  max-height: calc(100vh - 2.5rem);
   display: flex; flex-direction: column;
   background: #1e1e1e; color: #d4d4d4;
   border-radius: 12px;
@@ -502,7 +646,42 @@ html, body {
 .upload-queue-item.is-run .st { color: #0369a1; }
 .progress-bar.bg-teal { background-color: var(--accent) !important; }
 
-.site-settings-body { min-height: 420px; }
+.site-modules-list {
+  display: grid;
+  gap: 0.65rem;
+}
+.site-module-card {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 0.75rem;
+  align-items: start;
+  padding: 0.75rem 0.85rem;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+.site-module-card .meta { min-width: 0; }
+.site-module-card .name {
+  font-weight: 600;
+  font-size: 0.95rem;
+}
+.site-module-card .name-en {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.78rem;
+  color: #64748b;
+}
+.site-module-card .desc {
+  margin-top: 0.25rem;
+  font-size: 0.82rem;
+  color: #475569;
+}
+.site-module-card .actions {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  align-items: stretch;
+  min-width: 5.5rem;
+}
 .site-settings-layout {
   display: grid;
   grid-template-columns: 168px 1fr;
@@ -655,6 +834,45 @@ html, body {
   .view.is-active > .card > .card-body {
     overflow: visible;
   }
+  .task-panel { padding: 0.75rem; }
+  .task-panel-dialog { height: min(720px, 90vh); }
   .task-panel-body { grid-template-columns: 1fr; }
   .task-side { border-right: 0; border-bottom: 1px solid rgba(255,255,255,.08); max-height: 220px; }
 }
+
+.site-modules-list {
+  display: grid;
+  gap: 0.65rem;
+}
+.site-module-card {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 0.75rem;
+  align-items: start;
+  padding: 0.75rem 0.85rem;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #fff;
+}
+.site-module-card .meta { min-width: 0; }
+.site-module-card .name {
+  font-weight: 600;
+  font-size: 0.95rem;
+}
+.site-module-card .name-en {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.78rem;
+  color: #64748b;
+}
+.site-module-card .desc {
+  margin-top: 0.25rem;
+  font-size: 0.82rem;
+  color: #475569;
+}
+.site-module-card .actions {
+  display: flex;
+  flex-direction: column;
+  gap: 0.35rem;
+  align-items: stretch;
+  min-width: 5.5rem;
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 783 - 26
data/www/assets/app.js


+ 69 - 16
data/www/index.html

@@ -22,19 +22,19 @@
           <i class="bi bi-list-task"></i>
           <span class="badge text-bg-warning position-absolute top-0 start-100 translate-middle rounded-pill" id="task-badge-count">0</span>
         </button>
-        <button type="button" class="btn btn-sm btn-outline-light" id="btn-logout" title="退出登录">
-          <i class="bi bi-box-arrow-right"></i>
-        </button>
       </div>
-      <nav class="nav flex-column px-2 gap-1">
+      <nav class="nav flex-column px-2 gap-1 flex-grow-1">
         <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>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
       </nav>
-      <div class="rail-foot mt-auto px-3 py-2">
-        <div class="live small d-flex align-items-center gap-2" id="live-dot">
+      <div class="rail-foot px-2 pb-2">
+        <button type="button" class="nav-link text-start nav-item nav-logout w-100" id="btn-logout">
+          <i class="bi bi-box-arrow-right me-2"></i>退出
+        </button>
+        <div class="live small d-flex align-items-center gap-2 px-2 pt-1" id="live-dot">
           <span class="pulse"></span>
           <span id="live-label">检测中</span>
         </div>
@@ -128,7 +128,6 @@
                   <th>类型</th>
                   <th>域名</th>
                   <th>目录</th>
-                  <th>上游</th>
                   <th>证书到期</th>
                   <th>状态</th>
                   <th class="text-end">操作</th>
@@ -146,13 +145,19 @@
             <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
               <div>
                 <strong>文件</strong>
-                <code class="small ms-2" id="files-path-label">/</code>
               </div>
-              <div class="btn-group btn-group-sm">
-                <button type="button" class="btn btn-outline-secondary" id="btn-files-up">上级</button>
-                <button type="button" class="btn btn-outline-secondary" id="btn-files-mkdir">新建目录</button>
-                <button type="button" class="btn btn-outline-secondary" id="btn-files-newfile">新建文件</button>
-                <button type="button" class="btn btn-primary" id="btn-files-upload">上传</button>
+              <div class="d-flex flex-wrap align-items-center gap-2">
+                <div class="btn-group btn-group-sm" id="files-bulk-actions" hidden>
+                  <span class="btn btn-outline-secondary disabled" id="files-selected-count">已选 0</span>
+                  <button type="button" class="btn btn-outline-danger" id="btn-files-delete-selected">删除选中</button>
+                  <button type="button" class="btn btn-outline-secondary" id="btn-files-clear-selection">取消选择</button>
+                </div>
+                <div class="btn-group btn-group-sm">
+                  <button type="button" class="btn btn-outline-secondary" id="btn-files-up">上级</button>
+                  <button type="button" class="btn btn-outline-secondary" id="btn-files-mkdir">新建目录</button>
+                  <button type="button" class="btn btn-outline-secondary" id="btn-files-newfile">新建文件</button>
+                  <button type="button" class="btn btn-primary" id="btn-files-upload">上传</button>
+                </div>
               </div>
             </div>
             <div class="crumbs small mt-2" id="files-crumbs"></div>
@@ -161,6 +166,9 @@
             <table class="table table-sm table-hover align-middle mb-0" id="files-table">
               <thead>
                 <tr>
+                  <th class="files-check-col">
+                    <input class="form-check-input" type="checkbox" id="files-select-all" title="全选" aria-label="全选" />
+                  </th>
                   <th>名称</th>
                   <th>类型</th>
                   <th>大小</th>
@@ -214,10 +222,10 @@
 
   <div class="task-panel" id="task-panel" hidden>
     <div class="task-panel-backdrop" id="task-panel-backdrop"></div>
-    <aside class="task-panel-sheet">
+    <div class="task-panel-dialog" role="dialog" aria-modal="true" aria-labelledby="task-panel-title">
       <header class="task-panel-head d-flex justify-content-between align-items-start gap-2">
         <div>
-          <h2 class="h5 mb-1">任务队列</h2>
+          <h2 class="h5 mb-1" id="task-panel-title">任务队列</h2>
           <p class="small text-secondary mb-0">默认显示进行中任务,可切换查看历史。</p>
         </div>
         <button type="button" class="btn btn-sm btn-outline-light" id="task-panel-close" aria-label="关闭">×</button>
@@ -241,7 +249,8 @@
           <pre class="task-log" id="task-log"></pre>
         </div>
       </div>
-    </aside>
+      <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
+    </div>
   </div>
 
   <div class="modal fade" id="modal" tabindex="-1" aria-hidden="true">
@@ -256,6 +265,7 @@
           <button type="button" class="btn btn-sm btn-outline-secondary" id="modal-cancel">取消</button>
           <button type="submit" class="btn btn-sm btn-primary" id="modal-ok">确定</button>
         </div>
+        <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
       </form>
     </div>
   </div>
@@ -274,9 +284,11 @@
           <div class="site-settings-layout">
             <aside class="site-settings-nav">
               <button type="button" class="site-settings-tab is-active" data-site-tab="domain">域名</button>
+              <button type="button" class="site-settings-tab d-none" data-site-tab="proxy" id="site-tab-proxy">反向代理</button>
               <button type="button" class="site-settings-tab" data-site-tab="cert">证书</button>
               <button type="button" class="site-settings-tab" data-site-tab="nginx">配置 (NGINX)</button>
               <button type="button" class="site-settings-tab" data-site-tab="logs">日志 (NGINX)</button>
+              <button type="button" class="site-settings-tab d-none" data-site-tab="modules" id="site-tab-modules">模块</button>
             </aside>
             <div class="site-settings-panels">
               <div class="site-settings-panel is-active" data-site-panel="domain">
@@ -288,6 +300,28 @@
                 </div>
                 <button type="button" class="btn btn-sm btn-primary" id="site-set-domain-save">保存并重载</button>
               </div>
+              <div class="site-settings-panel" data-site-panel="proxy">
+                <div class="d-flex justify-content-between align-items-center mb-3">
+                  <h6 class="mb-0">反向代理</h6>
+                  <button type="button" class="btn btn-sm btn-primary" id="site-proxy-add">添加 URL 代理</button>
+                </div>
+                <div class="table-responsive">
+                  <table class="table table-sm table-hover align-middle mb-0" id="site-proxy-table">
+                    <thead>
+                      <tr>
+                        <th>代理目录</th>
+                        <th>目标</th>
+                        <th>发送域名</th>
+                        <th>备注</th>
+                        <th class="text-end">操作</th>
+                      </tr>
+                    </thead>
+                    <tbody id="site-proxy-tbody">
+                      <tr><td colspan="5" class="text-secondary text-center py-3">加载中…</td></tr>
+                    </tbody>
+                  </table>
+                </div>
+              </div>
               <div class="site-settings-panel" data-site-panel="cert">
                 <div class="btn-group btn-group-sm mb-3" role="group" aria-label="证书子页">
                   <button type="button" class="btn btn-outline-primary active" id="site-cert-tab-pem" data-cert-tab="pem">证书内容</button>
@@ -356,9 +390,25 @@
                 </div>
                 <div class="site-log-view" id="site-log-view">加载中…</div>
               </div>
+              <div class="site-settings-panel" data-site-panel="modules">
+                <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
+                  <div>
+                    <h6 class="mb-1">Fastweb 扩展模块</h6>
+                    <div class="small text-secondary">列表来自 <code>download.fwlua.com/module/module.json</code>,通过 <code>fastweb instmod</code> 安装到当前站点。</div>
+                  </div>
+                  <button type="button" class="btn btn-sm btn-outline-secondary" id="site-modules-refresh">刷新</button>
+                </div>
+                <div class="btn-group btn-group-sm mb-3" role="group" id="site-modules-filter">
+                  <button type="button" class="btn btn-outline-primary active" data-mod-filter="all">全部</button>
+                  <button type="button" class="btn btn-outline-primary" data-mod-filter="fastweb">FastWeb</button>
+                  <button type="button" class="btn btn-outline-primary" data-mod-filter="lua">Lua</button>
+                </div>
+                <div id="site-modules-list" class="site-modules-list">加载中…</div>
+              </div>
             </div>
           </div>
         </div>
+        <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
       </div>
     </div>
   </div>
@@ -406,6 +456,7 @@
           <button type="button" class="btn btn-sm btn-outline-secondary" id="upload-modal-cancel">取消</button>
           <button type="button" class="btn btn-sm btn-primary" id="upload-start" disabled>开始上传</button>
         </div>
+        <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
       </div>
     </div>
   </div>
@@ -430,6 +481,7 @@
         <div class="modal-body" id="analytics-body">
           <div class="text-secondary small">加载中…</div>
         </div>
+        <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
       </div>
     </div>
   </div>
@@ -458,6 +510,7 @@
           <div id="monaco-host" class="monaco-host"></div>
         </div>
       </div>
+      <div class="dialog-resize" data-dialog-resize title="拖动调整大小" aria-hidden="true"></div>
     </div>
   </div>
 

+ 36 - 0
data/www/login.html

@@ -43,6 +43,23 @@
       margin-bottom: 0.85rem;
     }
     .login-err.is-on { display: block; }
+    .login-captcha {
+      display: flex;
+      gap: 0.6rem;
+      align-items: stretch;
+    }
+    .login-captcha input { flex: 1; min-width: 0; }
+    .login-captcha-img {
+      width: 120px;
+      height: 40px;
+      border: 1px solid #e2e8f0;
+      border-radius: 8px;
+      background: #f8fafc;
+      cursor: pointer;
+      flex-shrink: 0;
+      object-fit: fill;
+    }
+    .login-captcha-img:hover { border-color: #94a3b8; }
   </style>
 </head>
 <body>
@@ -64,6 +81,13 @@
         <label class="form-label">密码</label>
         <input class="form-control" type="password" name="password" id="login-pass" required placeholder="密码" />
       </div>
+      <div class="mb-3">
+        <label class="form-label">验证码</label>
+        <div class="login-captcha">
+          <input class="form-control" name="captcha" id="login-captcha" required maxlength="8" autocomplete="off" placeholder="验证码" />
+          <img class="login-captcha-img" id="login-captcha-img" src="/api/auth/captcha" alt="验证码" title="点击刷新" />
+        </div>
+      </div>
       <button type="submit" class="btn btn-primary w-100" id="login-btn">登录</button>
     </form>
   </div>
@@ -81,6 +105,16 @@
       const form = document.getElementById("login-form");
       const err = document.getElementById("login-err");
       const btn = document.getElementById("login-btn");
+      const captchaImg = document.getElementById("login-captcha-img");
+      const captchaInput = document.getElementById("login-captcha");
+
+      function refreshCaptcha() {
+        captchaImg.src = "/api/auth/captcha?t=" + Date.now();
+        captchaInput.value = "";
+        captchaInput.focus();
+      }
+      captchaImg.addEventListener("click", refreshCaptcha);
+
       form.addEventListener("submit", async (e) => {
         e.preventDefault();
         err.classList.remove("is-on");
@@ -93,6 +127,7 @@
             body: JSON.stringify({
               username: document.getElementById("login-user").value.trim(),
               password: document.getElementById("login-pass").value,
+              captcha: captchaInput.value.trim(),
             }),
           });
           const json = await res.json();
@@ -103,6 +138,7 @@
         } catch (ex) {
           err.textContent = ex.message || String(ex);
           err.classList.add("is-on");
+          refreshCaptcha();
         } finally {
           btn.disabled = false;
         }

+ 243 - 2
src/api/api_server.cpp

@@ -272,6 +272,26 @@ void h_ping(request* req, response* resp) {
     reply_ok(resp, data);
 }
 
+void h_auth_captcha(request* req, response* resp) {
+    if (!require_method(req, resp, "GET")) {
+        return;
+    }
+    const auth::CaptchaImage img = auth::create_captcha();
+    if (img.bmp.empty() || img.id.empty()) {
+        reply_err(resp, "验证码生成失败", 500);
+        return;
+    }
+    if (resp->headers()) {
+        (*resp->headers())["Set-Cookie"] =
+            "ngs_captcha=" + img.id +
+            "; Path=/; HttpOnly; SameSite=Lax; Max-Age=300";
+        (*resp->headers())["Content-Type"] = "image/bmp";
+        (*resp->headers())["Cache-Control"] = "no-store, no-cache, must-revalidate";
+        (*resp->headers())["Pragma"] = "no-cache";
+    }
+    resp->send(img.bmp);
+}
+
 void h_auth_login(request* req, response* resp) {
     if (!require_method(req, resp, "POST")) {
         return;
@@ -279,10 +299,21 @@ void h_auth_login(request* req, response* resp) {
     auto body = parse_body(req);
     const std::string username = json_str(body, "username");
     const std::string password = json_str(body, "password");
+    const std::string captcha = json_str(body, "captcha");
     if (username.empty() || password.empty()) {
         reply_err(resp, "请输入账号和密码", 400);
         return;
     }
+    if (captcha.empty()) {
+        reply_err(resp, "请输入验证码", 400);
+        return;
+    }
+    const std::string captcha_id =
+        auth::captcha_from_cookie(request_cookie(req));
+    if (!auth::consume_captcha(captcha_id, captcha)) {
+        reply_err(resp, "验证码错误或已过期", 400);
+        return;
+    }
     if (!auth::verify(username, password)) {
         reply_err(resp, "账号或密码错误", 401);
         return;
@@ -365,9 +396,12 @@ void h_tasks_log(request* req, response* resp) {
     }
     std::vector<std::string> lines;
     size_t next = 0;
+    size_t log_base = 0;
+    bool truncated = false;
     tasks::Status st = tasks::Status::Pending;
     std::string message, err;
-    if (!tasks::get_logs(id, off, lines, next, st, message, err)) {
+    if (!tasks::get_logs(id, off, lines, next, log_base, truncated, st, message,
+                         err)) {
         reply_err(resp, err);
         return;
     }
@@ -379,8 +413,10 @@ void h_tasks_log(request* req, response* resp) {
     data["id"] = id;
     data["status"] = tasks::status_str(st);
     data["message"] = message;
-    data["offset"] = static_cast<double>(off);
+    data["offset"] = static_cast<double>(truncated ? log_base : off);
     data["next_offset"] = static_cast<double>(next);
+    data["log_base"] = static_cast<double>(log_base);
+    data["truncated"] = truncated;
     data["lines"] = arr;
     reply_ok(resp, data);
 }
@@ -555,6 +591,95 @@ void h_fastweb_uninstall(request* req, response* resp) {
     reply_task_queued(resp, id);
 }
 
+void h_fastweb_modules(request* req, response* resp) {
+    if (!require_method(req, resp, "GET")) {
+        return;
+    }
+    const std::string site = url_param(req, "site");
+    if (site.empty()) {
+        reply_err(resp, "缺少 site 参数", 400);
+        return;
+    }
+    std::vector<fastweb::ModuleInfo> list;
+    std::string err;
+    if (!fastweb::list_modules(site, list, err)) {
+        reply_err(resp, err.empty() ? "获取模块列表失败" : err);
+        return;
+    }
+    ylib::json arr;
+    for (const auto& m : list) {
+        ylib::json item;
+        item["id"] = m.id;
+        item["name"] = m.name;
+        item["name_en"] = m.name_en;
+        item["desc"] = m.desc;
+        item["doc"] = m.doc;
+        item["icon"] = m.icon;
+        item["type"] = m.type;
+        item["download_type"] = m.download_type;
+        item["download_url"] = m.download_url;
+        item["installed"] = m.installed;
+        arr.push_back(item);
+    }
+    ylib::json data;
+    data["site"] = site;
+    data["catalog_url"] = fastweb::kModuleCatalogUrl;
+    data["modules"] = arr;
+    reply_ok(resp, data);
+}
+
+void h_fastweb_module_install(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const std::string site = json_str(body, "site");
+    const std::string name_en = json_str(body, "name_en");
+    if (site.empty() || name_en.empty()) {
+        reply_err(resp, "请指定 site 与 name_en", 400);
+        return;
+    }
+    std::string err;
+    const std::string id = tasks::enqueue(
+        "fastweb.module.install", "安装模块 " + name_en,
+        [site, name_en]() {
+            std::string e;
+            return fastweb::install_module(site, name_en, e);
+        },
+        err);
+    if (id.empty()) {
+        reply_err(resp, err.empty() ? "enqueue failed" : err);
+        return;
+    }
+    reply_task_queued(resp, id);
+}
+
+void h_fastweb_module_uninstall(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const std::string site = json_str(body, "site");
+    const std::string name_en = json_str(body, "name_en");
+    if (site.empty() || name_en.empty()) {
+        reply_err(resp, "请指定 site 与 name_en", 400);
+        return;
+    }
+    std::string err;
+    const std::string id = tasks::enqueue(
+        "fastweb.module.uninstall", "卸载模块 " + name_en,
+        [site, name_en]() {
+            std::string e;
+            return fastweb::uninstall_module(site, name_en, e);
+        },
+        err);
+    if (id.empty()) {
+        reply_err(resp, err.empty() ? "enqueue failed" : err);
+        return;
+    }
+    reply_task_queued(resp, id);
+}
+
 void h_mysql_status(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -929,6 +1054,115 @@ void h_websites_update(request* req, response* resp) {
     reply_ok(resp, site_to_json(updated), "site updated");
 }
 
+ylib::json url_proxy_to_json(const website::UrlProxyRule& r) {
+    ylib::json item;
+    item["id"] = r.id;
+    item["path"] = r.path;
+    item["target_type"] = r.target_type;
+    item["target"] = r.target;
+    item["host"] = r.host;
+    item["remark"] = r.remark;
+    return item;
+}
+
+void h_websites_proxies(request* req, response* resp) {
+    const std::string method = req ? req->method() : "";
+    if (method == "GET") {
+        const std::string name = url_param(req, "name");
+        if (name.empty()) {
+            reply_err(resp, "name required");
+            return;
+        }
+        std::string err;
+        auto rules = website::list_url_proxies(name, err);
+        if (!err.empty()) {
+            reply_err(resp, err);
+            return;
+        }
+        ylib::json arr;
+        for (const auto& r : rules) {
+            arr.push_back(url_proxy_to_json(r));
+        }
+        ylib::json data;
+        data["proxies"] = arr;
+        reply_ok(resp, data);
+        return;
+    }
+    if (method == "POST") {
+        auto body = parse_body(req);
+        const std::string name = json_str(body, "name");
+        if (name.empty()) {
+            reply_err(resp, "name required");
+            return;
+        }
+        website::UrlProxyRule rule;
+        rule.path = json_str(body, "path");
+        rule.target_type = json_str(body, "target_type", "url");
+        rule.target = json_str(body, "target");
+        rule.host = json_str(body, "host", "$http_host");
+        rule.remark = json_str(body, "remark");
+        std::string err;
+        if (!website::add_url_proxy(name, rule, err)) {
+            reply_err(resp, err.empty() ? "add url proxy failed" : err);
+            return;
+        }
+        auto rules = website::list_url_proxies(name, err);
+        ylib::json arr;
+        for (const auto& r : rules) {
+            arr.push_back(url_proxy_to_json(r));
+        }
+        ylib::json data;
+        data["proxies"] = arr;
+        reply_ok(resp, data, "url proxy added");
+        return;
+    }
+    reply_err(resp, "method not allowed", 405);
+}
+
+void h_websites_proxies_update(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const std::string name = json_str(body, "name");
+    website::UrlProxyRule rule;
+    rule.id = json_str(body, "id");
+    rule.path = json_str(body, "path");
+    rule.target_type = json_str(body, "target_type", "url");
+    rule.target = json_str(body, "target");
+    rule.host = json_str(body, "host", "$http_host");
+    rule.remark = json_str(body, "remark");
+    if (name.empty() || rule.id.empty()) {
+        reply_err(resp, "name and id required");
+        return;
+    }
+    std::string err;
+    if (!website::update_url_proxy(name, rule, err)) {
+        reply_err(resp, err.empty() ? "update url proxy failed" : err);
+        return;
+    }
+    reply_ok(resp, ylib::json(), "url proxy updated");
+}
+
+void h_websites_proxies_delete(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const std::string name = json_str(body, "name");
+    const std::string id = json_str(body, "id");
+    if (name.empty() || id.empty()) {
+        reply_err(resp, "name and id required");
+        return;
+    }
+    std::string err;
+    if (!website::delete_url_proxy(name, id, err)) {
+        reply_err(resp, err.empty() ? "delete url proxy failed" : err);
+        return;
+    }
+    reply_ok(resp, ylib::json(), "url proxy deleted");
+}
+
 void h_websites_logs(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -1479,6 +1713,7 @@ void h_files_download(request* req, response* resp) {
 void register_routes(ylib::network::http::router* router) {
     // Public endpoints
     reg(router, "/api/ping", h_ping, false);
+    reg(router, "/api/auth/captcha", h_auth_captcha, false);
     reg(router, "/api/auth/login", h_auth_login, false);
     reg(router, "/api/auth/logout", h_auth_logout, false);
     reg(router, "/api/auth/me", h_auth_me, false);
@@ -1500,6 +1735,9 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/fastweb/status", h_fastweb_status);
     reg(router, "/api/fastweb/install", h_fastweb_install);
     reg(router, "/api/fastweb/uninstall", h_fastweb_uninstall);
+    reg(router, "/api/fastweb/modules", h_fastweb_modules);
+    reg(router, "/api/fastweb/modules/install", h_fastweb_module_install);
+    reg(router, "/api/fastweb/modules/uninstall", h_fastweb_module_uninstall);
 
     reg(router, "/api/mysql/status", h_mysql_status);
     reg(router, "/api/mysql/install", h_mysql_install);
@@ -1523,6 +1761,9 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/websites/restart", h_websites_restart);
     reg(router, "/api/websites/delete", h_websites_delete);
     reg(router, "/api/websites/update", h_websites_update);
+    reg(router, "/api/websites/proxies", h_websites_proxies);
+    reg(router, "/api/websites/proxies/update", h_websites_proxies_update);
+    reg(router, "/api/websites/proxies/delete", h_websites_proxies_delete);
     reg(router, "/api/websites/logs", h_websites_logs);
     reg(router, "/api/websites/analytics", h_websites_analytics);
     reg(router, "/api/websites/ssl/apply", h_websites_ssl_apply);

+ 85 - 24
src/auth/auth.cpp

@@ -2,12 +2,15 @@
 
 #include "../utils.h"
 
+#include "util/img.h"
+
+#include <cctype>
 #include <chrono>
 #include <fstream>
+#include <iostream>
 #include <map>
 #include <mutex>
 #include <random>
-#include <iostream>
 #include <sstream>
 
 namespace ngs {
@@ -19,12 +22,19 @@ struct Session {
     int64_t expire_at = 0;
 };
 
+struct CaptchaEntry {
+    std::string code;
+    int64_t expire_at = 0;
+};
+
 std::mutex g_mu;
 Credentials g_cred;
 bool g_ready = false;
 std::map<std::string, Session> g_sessions;
+std::map<std::string, CaptchaEntry> g_captchas;
 
 constexpr int64_t kSessionTtlSec = 7 * 24 * 3600;  // 7 days
+constexpr int64_t kCaptchaTtlSec = 5 * 60;
 
 int64_t now_sec() {
     return std::chrono::duration_cast<std::chrono::seconds>(
@@ -47,6 +57,13 @@ std::string trim(const std::string& s) {
     return s.substr(b, e - b);
 }
 
+std::string to_upper(std::string s) {
+    for (char& c : s) {
+        c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
+    }
+    return s;
+}
+
 std::string random_token() {
     static thread_local std::mt19937_64 rng{
         static_cast<uint64_t>(now_sec()) ^
@@ -110,6 +127,38 @@ void purge_expired_locked() {
             ++it;
         }
     }
+    for (auto it = g_captchas.begin(); it != g_captchas.end();) {
+        if (it->second.expire_at < now) {
+            it = g_captchas.erase(it);
+        } else {
+            ++it;
+        }
+    }
+}
+
+std::string cookie_value(const std::string& cookie_header, const std::string& key) {
+    const std::string prefix = key + "=";
+    size_t pos = 0;
+    while (pos < cookie_header.size()) {
+        while (pos < cookie_header.size() &&
+               (cookie_header[pos] == ' ' || cookie_header[pos] == ';')) {
+            ++pos;
+        }
+        if (cookie_header.compare(pos, prefix.size(), prefix) == 0) {
+            pos += prefix.size();
+            size_t end = cookie_header.find(';', pos);
+            if (end == std::string::npos) {
+                end = cookie_header.size();
+            }
+            return trim(cookie_header.substr(pos, end - pos));
+        }
+        size_t next = cookie_header.find(';', pos);
+        if (next == std::string::npos) {
+            break;
+        }
+        pos = next + 1;
+    }
+    return "";
 }
 
 }  // namespace
@@ -161,6 +210,40 @@ bool verify(const std::string& username, const std::string& password) {
     return username == g_cred.username && password == g_cred.password;
 }
 
+CaptchaImage create_captcha() {
+    CaptchaImage out;
+    std::string code;
+    out.bmp = ylib::img::make_code(120, 40, 4, code);
+    out.id = random_token();
+
+    std::lock_guard<std::mutex> lock(g_mu);
+    purge_expired_locked();
+    CaptchaEntry e;
+    e.code = to_upper(code);
+    e.expire_at = now_sec() + kCaptchaTtlSec;
+    g_captchas[out.id] = e;
+    return out;
+}
+
+bool consume_captcha(const std::string& id, const std::string& code) {
+    if (id.empty() || code.empty()) {
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(g_mu);
+    purge_expired_locked();
+    auto it = g_captchas.find(id);
+    if (it == g_captchas.end()) {
+        return false;
+    }
+    const std::string expect = it->second.code;
+    g_captchas.erase(it);
+    return to_upper(trim(code)) == expect;
+}
+
+std::string captcha_from_cookie(const std::string& cookie_header) {
+    return cookie_value(cookie_header, "ngs_captcha");
+}
+
 std::string create_session(const std::string& username) {
     std::lock_guard<std::mutex> lock(g_mu);
     purge_expired_locked();
@@ -203,29 +286,7 @@ void destroy_session(const std::string& token) {
 }
 
 std::string session_from_cookie(const std::string& cookie_header) {
-    // Cookie: a=1; ngs_session=TOKEN; b=2
-    const std::string key = "ngs_session=";
-    size_t pos = 0;
-    while (pos < cookie_header.size()) {
-        while (pos < cookie_header.size() &&
-               (cookie_header[pos] == ' ' || cookie_header[pos] == ';')) {
-            ++pos;
-        }
-        if (cookie_header.compare(pos, key.size(), key) == 0) {
-            pos += key.size();
-            size_t end = cookie_header.find(';', pos);
-            if (end == std::string::npos) {
-                end = cookie_header.size();
-            }
-            return trim(cookie_header.substr(pos, end - pos));
-        }
-        size_t next = cookie_header.find(';', pos);
-        if (next == std::string::npos) {
-            break;
-        }
-        pos = next + 1;
-    }
-    return "";
+    return cookie_value(cookie_header, "ngs_session");
 }
 
 }  // namespace auth

+ 12 - 0
src/auth/auth.h

@@ -3,6 +3,8 @@
 
 #include <string>
 
+#include "base/buffer.h"
+
 namespace ngs {
 namespace auth {
 
@@ -11,6 +13,11 @@ struct Credentials {
     std::string password = "ngsadmin";
 };
 
+struct CaptchaImage {
+    std::string id;
+    ylib::buffer bmp;
+};
+
 // /ngs/conf/ngs.conf — username / password for panel login.
 std::string config_path();
 
@@ -20,6 +27,11 @@ Credentials credentials();
 
 bool verify(const std::string& username, const std::string& password);
 
+// Image captcha (ylib::img::make_code). Cookie: ngs_captcha=<id>
+CaptchaImage create_captcha();
+bool consume_captcha(const std::string& id, const std::string& code);
+std::string captcha_from_cookie(const std::string& cookie_header);
+
 // Session cookie: ngs_session=<token>
 std::string create_session(const std::string& username);
 bool session_valid(const std::string& token, std::string* username = nullptr);

+ 228 - 1
src/software/fastweb/fastweb.cpp

@@ -1,9 +1,14 @@
 #include "fastweb.h"
 
 #include "../../utils.h"
+#include "../../website/website.h"
 
-#include <iostream>
+#include "util/json.h"
+
+#include <cstdlib>
 #include <fstream>
+#include <iostream>
+#include <sstream>
 
 namespace ngs {
 namespace fastweb {
@@ -400,5 +405,227 @@ void menu() {
     }
 }
 
+namespace {
+
+bool fetch_catalog_json(std::string& body, std::string& err) {
+    const std::string tmp = "/tmp/ngs-fw-module-" + std::to_string(std::rand()) + ".json";
+    const int rc = run_cmd(
+        "curl -fsSL --connect-timeout 10 --max-time 60 -o \"" + tmp + "\" \"" +
+            kModuleCatalogUrl + "\"",
+        false);
+    if (rc != 0 || !path_exists(tmp)) {
+        err = "下载模块列表失败";
+        remove_path(tmp);
+        return false;
+    }
+    std::ifstream in(tmp);
+    if (!in) {
+        err = "读取模块列表失败";
+        remove_path(tmp);
+        return false;
+    }
+    std::ostringstream ss;
+    ss << in.rdbuf();
+    body = ss.str();
+    remove_path(tmp);
+    if (body.empty()) {
+        err = "模块列表为空";
+        return false;
+    }
+    return true;
+}
+
+bool parse_catalog(const std::string& body, std::vector<ModuleInfo>& out,
+                   std::string& err) {
+    out.clear();
+    ylib::json root;
+    try {
+        root = ylib::json::from(body);
+    } catch (...) {
+        err = "模块列表 JSON 解析失败";
+        return false;
+    }
+    auto jstr = [](const ylib::json& j, const std::string& key) -> std::string {
+        if (j.is_empty() || !j.exist(key)) {
+            return "";
+        }
+        try {
+            return j[key].to<std::string>(true);
+        } catch (...) {
+            return "";
+        }
+    };
+    auto jint = [](const ylib::json& j, const std::string& key) -> int {
+        if (j.is_empty() || !j.exist(key)) {
+            return 0;
+        }
+        try {
+            return j[key].to<int>(true);
+        } catch (...) {
+            return 0;
+        }
+    };
+
+    const char* groups[] = {"fastweb", "lua", "other"};
+    for (const char* group : groups) {
+        if (!root.exist(group) || !root[group].is_array()) {
+            continue;
+        }
+        auto& arr = root[group];
+        for (uint32 i = 0; i < arr.size(); ++i) {
+            auto& item = arr[i];
+            ModuleInfo m;
+            m.id = jint(item, "id");
+            m.name = jstr(item, "name");
+            m.name_en = jstr(item, "name_en");
+            m.desc = jstr(item, "desc");
+            m.doc = jstr(item, "doc");
+            m.icon = jstr(item, "icon");
+            m.type = group;
+            if (item.exist("download")) {
+                auto& dl = item["download"];
+                if (dl.exist("linux")) {
+                    auto& linux = dl["linux"];
+                    m.download_type = jstr(linux, "type");
+                    m.download_url = jstr(linux, "url");
+                }
+            }
+            if (m.name_en.empty()) {
+                continue;
+            }
+            // Skip entries without linux install source.
+            if (m.download_type.empty() && m.type != "lua") {
+                continue;
+            }
+            out.push_back(m);
+        }
+    }
+    return true;
+}
+
+std::string module_install_dir(const std::string& config_ini, int id) {
+    const std::string site = [&]() {
+        // config.ini lives in site root; module_dir=${base}/module
+        const auto pos = config_ini.find_last_of('/');
+        if (pos == std::string::npos) {
+            return std::string(".");
+        }
+        return config_ini.substr(0, pos);
+    }();
+    return join_path(join_path(join_path(site, "module"), ".install"),
+                     std::to_string(id));
+}
+
+bool lua_module_installed(const std::string& name_en) {
+    const std::string rocks = "/opt/luarocks/bin/luarocks";
+    if (!path_exists(rocks)) {
+        return false;
+    }
+    return run_cmd("\"" + rocks + "\" show \"" + name_en +
+                       "\" >/dev/null 2>&1",
+                   false) == 0;
+}
+
+bool mark_installed(std::vector<ModuleInfo>& list, const std::string& config_ini) {
+    for (auto& m : list) {
+        if (m.type == "lua") {
+            m.installed = lua_module_installed(m.name_en);
+        } else if (m.id > 0) {
+            m.installed = is_dir(module_install_dir(config_ini, m.id));
+        } else {
+            m.installed = false;
+        }
+    }
+    return true;
+}
+
+}  // namespace
+
+std::string site_config_ini(const std::string& site_name) {
+    website::SiteInfo s;
+    if (!website::find_site(site_name, s)) {
+        return "";
+    }
+    if (s.type != website::SiteType::Fastweb) {
+        return "";
+    }
+    return join_path(website::site_dir(s), "config.ini");
+}
+
+bool list_modules(const std::string& site_name, std::vector<ModuleInfo>& out,
+                  std::string& err) {
+    out.clear();
+    if (!is_installed()) {
+        err = "请先安装 Fastweb";
+        return false;
+    }
+    const std::string cfg = site_config_ini(site_name);
+    if (cfg.empty() || !path_exists(cfg)) {
+        err = "未找到 Fastweb 站点配置";
+        return false;
+    }
+    std::string body;
+    if (!fetch_catalog_json(body, err)) {
+        return false;
+    }
+    if (!parse_catalog(body, out, err)) {
+        return false;
+    }
+    mark_installed(out, cfg);
+    return true;
+}
+
+bool install_module(const std::string& site_name, const std::string& name_en,
+                    std::string& err) {
+    if (!is_installed()) {
+        err = "请先安装 Fastweb";
+        return false;
+    }
+    if (name_en.empty()) {
+        err = "模块名不能为空";
+        return false;
+    }
+    const std::string cfg = site_config_ini(site_name);
+    if (cfg.empty() || !path_exists(cfg)) {
+        err = "未找到 Fastweb 站点配置";
+        return false;
+    }
+    const std::string bin = bin_path();
+    std::cout << "安装 Fastweb 模块: " << name_en << " -> " << cfg << "\n";
+    const int rc =
+        run_cmd("\"" + bin + "\" instmod \"" + cfg + "\" \"" + name_en + "\"");
+    if (rc != 0) {
+        err = "模块安装失败: " + name_en;
+        return false;
+    }
+    return true;
+}
+
+bool uninstall_module(const std::string& site_name, const std::string& name_en,
+                      std::string& err) {
+    if (!is_installed()) {
+        err = "请先安装 Fastweb";
+        return false;
+    }
+    if (name_en.empty()) {
+        err = "模块名不能为空";
+        return false;
+    }
+    const std::string cfg = site_config_ini(site_name);
+    if (cfg.empty() || !path_exists(cfg)) {
+        err = "未找到 Fastweb 站点配置";
+        return false;
+    }
+    const std::string bin = bin_path();
+    std::cout << "卸载 Fastweb 模块: " << name_en << "\n";
+    const int rc =
+        run_cmd("\"" + bin + "\" uninstmod \"" + cfg + "\" \"" + name_en + "\"");
+    if (rc != 0) {
+        err = "模块卸载失败: " + name_en;
+        return false;
+    }
+    return true;
+}
+
 }  // namespace fastweb
 }  // namespace ngs

+ 25 - 0
src/software/fastweb/fastweb.h

@@ -2,11 +2,27 @@
 #define NGS_FASTWEB_H
 
 #include <string>
+#include <vector>
 
 namespace ngs {
 namespace fastweb {
 
 constexpr const char* kDefaultVersion = "1.0.0";
+constexpr const char* kModuleCatalogUrl =
+    "https://download.fwlua.com/module/module.json";
+
+struct ModuleInfo {
+    int id = 0;
+    std::string name;
+    std::string name_en;
+    std::string desc;
+    std::string doc;
+    std::string icon;
+    std::string type;           // fastweb | lua | other
+    std::string download_type;  // github | zip | lua
+    std::string download_url;
+    bool installed = false;
+};
 
 std::string install_dir();
 std::string bin_path();
@@ -20,6 +36,15 @@ void show_status();
 
 void menu();
 
+// Site modules (fastweb instmod / uninstmod).
+std::string site_config_ini(const std::string& site_name);
+bool list_modules(const std::string& site_name, std::vector<ModuleInfo>& out,
+                  std::string& err);
+bool install_module(const std::string& site_name, const std::string& name_en,
+                    std::string& err);
+bool uninstall_module(const std::string& site_name, const std::string& name_en,
+                      std::string& err);
+
 }  // namespace fastweb
 }  // namespace ngs
 

+ 71 - 12
src/tasks/tasks.cpp

@@ -22,6 +22,7 @@ namespace {
 
 constexpr size_t kMaxLogLines = 8000;
 constexpr size_t kMaxHistory = 80;
+constexpr size_t kMaxPendingLogs = 20000;
 
 struct Job {
     Task meta;
@@ -49,6 +50,16 @@ std::vector<std::string>& order() {
     return o;
 }
 
+// Lines that arrived while mu() was held by an API/worker (avoid deadlock drops).
+std::mutex& pending_mu() {
+    static std::mutex m;
+    return m;
+}
+std::vector<std::string>& pending_logs() {
+    static std::vector<std::string> v;
+    return v;
+}
+
 std::atomic<bool>& running_flag() {
     static std::atomic<bool> v{false};
     return v;
@@ -111,12 +122,12 @@ std::string json_escape(const std::string& s) {
 void append_log_unlocked(Job& job, const std::string& line) {
     job.meta.logs.push_back(line);
     if (job.meta.logs.size() > kMaxLogLines) {
+        const size_t drop = job.meta.logs.size() - kMaxLogLines;
         job.meta.logs.erase(job.meta.logs.begin(),
-                            job.meta.logs.begin() +
-                                static_cast<long>(job.meta.logs.size() -
-                                                  kMaxLogLines));
+                            job.meta.logs.begin() + static_cast<long>(drop));
+        job.meta.log_base += drop;
     }
-    job.meta.log_lines = job.meta.logs.size();
+    job.meta.log_lines = job.meta.log_base + job.meta.logs.size();
     ensure_dir(logs_dir());
     const std::string path = join_path(logs_dir(), job.meta.id + ".log");
     std::ofstream out(path, std::ios::app);
@@ -125,6 +136,28 @@ void append_log_unlocked(Job& job, const std::string& line) {
     }
 }
 
+// Caller must hold mu(). Drains try_lock fallback queue into the current task.
+void flush_pending_unlocked() {
+    std::vector<std::string> batch;
+    {
+        std::lock_guard<std::mutex> pl(pending_mu());
+        if (pending_logs().empty()) {
+            return;
+        }
+        batch.swap(pending_logs());
+    }
+    if (current_id().empty() || batch.empty()) {
+        return;
+    }
+    auto it = store().find(current_id());
+    if (it == store().end()) {
+        return;
+    }
+    for (const auto& line : batch) {
+        append_log_unlocked(it->second, line);
+    }
+}
+
 void persist_unlocked() {
     ensure_dir(conf_root());
     ensure_dir(logs_dir());
@@ -261,12 +294,11 @@ void load_history_unlocked() {
             while (std::getline(lin, line)) {
                 job.meta.logs.push_back(line);
             }
-            job.meta.log_lines = job.meta.logs.size();
         }
         if (st == "pending" || st == "running") {
             job.meta.logs.push_back("[TASK] 服务重启,任务中断");
-            job.meta.log_lines = job.meta.logs.size();
         }
+        job.meta.log_lines = job.meta.log_base + job.meta.logs.size();
         const std::string id = job.meta.id;
         store()[id] = std::move(job);
         order().push_back(id);
@@ -317,6 +349,7 @@ void worker_loop() {
 
         {
             std::lock_guard<std::mutex> lock(mu());
+            flush_pending_unlocked();
             current_id().clear();
             auto it = store().find(job.meta.id);
             if (it != store().end()) {
@@ -431,6 +464,7 @@ std::string enqueue(const std::string& type, const std::string& title,
 
 std::vector<Task> list(bool include_logs) {
     std::lock_guard<std::mutex> lock(mu());
+    flush_pending_unlocked();
     std::vector<Task> out;
     out.reserve(order().size());
     for (auto it = order().rbegin(); it != order().rend(); ++it) {
@@ -449,6 +483,7 @@ std::vector<Task> list(bool include_logs) {
 
 Task get(const std::string& id) {
     std::lock_guard<std::mutex> lock(mu());
+    flush_pending_unlocked();
     auto it = store().find(id);
     if (it == store().end()) {
         return {};
@@ -458,22 +493,36 @@ Task get(const std::string& id) {
 
 bool get_logs(const std::string& id, size_t offset,
               std::vector<std::string>& lines, size_t& next_offset,
-              Status& status, std::string& message, std::string& err) {
+              size_t& log_base, bool& truncated, Status& status,
+              std::string& message, std::string& err) {
     lines.clear();
+    truncated = false;
+    log_base = 0;
+    next_offset = 0;
     std::lock_guard<std::mutex> lock(mu());
+    flush_pending_unlocked();
     auto it = store().find(id);
     if (it == store().end()) {
         err = "任务不存在";
         return false;
     }
     const auto& logs = it->second.meta.logs;
-    if (offset > logs.size()) {
-        offset = logs.size();
+    log_base = it->second.meta.log_base;
+    const size_t abs_end = log_base + logs.size();
+    size_t off = offset;
+    if (off < log_base) {
+        // Client offset points into trimmed history — resync from retained window.
+        truncated = true;
+        off = log_base;
     }
-    for (size_t i = offset; i < logs.size(); ++i) {
+    if (off > abs_end) {
+        off = abs_end;
+    }
+    const size_t start = off - log_base;
+    for (size_t i = start; i < logs.size(); ++i) {
         lines.push_back(logs[i]);
     }
-    next_offset = logs.size();
+    next_offset = abs_end;
     status = it->second.meta.status;
     message = it->second.meta.message;
     err.clear();
@@ -517,11 +566,21 @@ void clear_finished() {
 }
 
 void on_log_line(const std::string& line) {
-    // try_lock: logging may be invoked while a tasks API already holds mu().
+    // Logging may be invoked while a tasks API already holds mu(). Never block
+    // on mu() here (deadlock with log_write), and never drop lines: queue them.
     std::unique_lock<std::mutex> lock(mu(), std::try_to_lock);
     if (!lock.owns_lock()) {
+        std::lock_guard<std::mutex> pl(pending_mu());
+        if (pending_logs().size() >= kMaxPendingLogs) {
+            pending_logs().erase(
+                pending_logs().begin(),
+                pending_logs().begin() +
+                    static_cast<long>(pending_logs().size() - kMaxPendingLogs + 1));
+        }
+        pending_logs().push_back(line);
         return;
     }
+    flush_pending_unlocked();
     if (current_id().empty()) {
         return;
     }

+ 4 - 2
src/tasks/tasks.h

@@ -21,6 +21,8 @@ struct Task {
     int64_t finished_at = 0;
     std::string message;
     size_t log_lines = 0;
+    // Absolute index of logs[0]. Grows when old lines are trimmed from the front.
+    size_t log_base = 0;
     std::vector<std::string> logs;
 };
 
@@ -39,8 +41,8 @@ std::string enqueue(const std::string& type, const std::string& title,
 std::vector<Task> list(bool include_logs = false);
 Task get(const std::string& id);
 bool get_logs(const std::string& id, size_t offset, std::vector<std::string>& lines,
-              size_t& next_offset, Status& status, std::string& message,
-              std::string& err);
+              size_t& next_offset, size_t& log_base, bool& truncated, Status& status,
+              std::string& message, std::string& err);
 
 int active_count();  // pending + running
 void clear_finished();

+ 586 - 42
src/website/website.cpp

@@ -7,8 +7,12 @@
 #include "../utils.h"
 #include "../weblog/weblog.h"
 
+#include "util/json.h"
+
 #include <algorithm>
+#include <atomic>
 #include <cctype>
+#include <chrono>
 #include <cstdlib>
 #include <cstring>
 #include <dirent.h>
@@ -18,6 +22,8 @@
 #include <set>
 #include <signal.h>
 #include <sstream>
+#include <sys/stat.h>
+#include <sys/wait.h>
 #include <unistd.h>
 #include <vector>
 
@@ -28,10 +34,15 @@ namespace {
 
 bool write_vhost(const SiteInfo& s);
 bool apply_nginx();
-bool start_site_by_name(const std::string& name);
+bool start_site_by_name(const std::string& name, std::string& err);
 bool stop_site_by_name(const std::string& name);
-bool restart_site_by_name(const std::string& name);
+bool restart_site_by_name(const std::string& name, std::string& err);
 bool find_site_info(const std::string& name, SiteInfo& out);
+bool start_fastweb_process(const SiteInfo& s, std::string& err);
+bool stop_fastweb_process(const SiteInfo& s);
+bool load_url_proxy_rules(const SiteInfo& s, std::vector<UrlProxyRule>& out,
+                          std::string& err);
+std::string nginx_escape_comment(std::string s);
 
 std::string vhost_dir() {
     return join_path(conf_root(), "vhost");
@@ -212,6 +223,94 @@ std::string site_status_label(const SiteInfo& s) {
     return "已停止";
 }
 
+std::string compact_fastweb_error(std::string line) {
+    while (!line.empty() &&
+           (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) {
+        line.pop_back();
+    }
+    // Prefer a short "module 'x' not found" summary when present.
+    const auto mod = line.find("module '");
+    if (mod != std::string::npos) {
+        const auto end = line.find(" not found", mod);
+        if (end != std::string::npos) {
+            // Keep prefix through "not found"
+            std::string out = line.substr(0, end + 10);
+            if (out.size() > 360) {
+                out = out.substr(out.size() - 360);
+            }
+            return out;
+        }
+    }
+    if (line.size() > 360) {
+        line.resize(360);
+        line.append("…");
+    }
+    return line;
+}
+
+// Newest YYYYMMDD.log (or any *.log except unrelated) under site log dir.
+std::string newest_fastweb_text_log(const std::string& log_dir) {
+    DIR* dir = opendir(log_dir.c_str());
+    if (!dir) {
+        return {};
+    }
+    std::string best;
+    time_t best_mtime = 0;
+    while (dirent* ent = readdir(dir)) {
+        const std::string name = ent->d_name;
+        if (name.size() < 5 || name[0] == '.') {
+            continue;
+        }
+        if (name.size() < 4 || name.substr(name.size() - 4) != ".log") {
+            continue;
+        }
+        const std::string path = join_path(log_dir, name);
+        struct stat st {};
+        if (stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) {
+            continue;
+        }
+        if (st.st_mtime >= best_mtime) {
+            best_mtime = st.st_mtime;
+            best = path;
+        }
+    }
+    closedir(dir);
+    return best;
+}
+
+std::string fastweb_recent_error(const SiteInfo& s) {
+    const std::string log_dir = join_path(site_dir(s), "log");
+    const std::string path = newest_fastweb_text_log(log_dir);
+    if (path.empty()) {
+        return {};
+    }
+    std::ifstream in(path);
+    if (!in) {
+        return {};
+    }
+    std::vector<std::string> lines;
+    std::string line;
+    while (std::getline(in, line)) {
+        lines.push_back(line);
+        if (lines.size() > 400) {
+            lines.erase(lines.begin(), lines.begin() + 100);
+        }
+    }
+    std::string best;
+    for (auto it = lines.rbegin(); it != lines.rend(); ++it) {
+        if (it->find("[ERROR]") == std::string::npos &&
+            it->find("start failed") == std::string::npos) {
+            continue;
+        }
+        best = compact_fastweb_error(*it);
+        if (it->find("start failed") != std::string::npos ||
+            it->find("module '") != std::string::npos) {
+            break;
+        }
+    }
+    return best;
+}
+
 SiteType parse_type(const std::string& key) {
     SiteType t = SiteType::Static;
     parse_type_key(key, t);
@@ -516,7 +615,8 @@ bool port_used_by_sites(int port, const std::string& except_name = "") {
         if (s.listen_port == port) {
             return true;
         }
-        if (s.type == SiteType::Fastweb && !s.upstream.empty()) {
+        if ((s.type == SiteType::Fastweb || s.type == SiteType::Proxy) &&
+            !s.upstream.empty()) {
             auto pos = s.upstream.rfind(':');
             if (pos != std::string::npos) {
                 try {
@@ -658,24 +758,81 @@ std::string nginx_static_conf(const SiteInfo& s) {
     return out.str();
 }
 
-std::string nginx_proxy_conf(const SiteInfo& s) {
+std::string nginx_proxy_location_block(const std::string& path,
+                                       const std::string& proxy_pass,
+                                       const std::string& host,
+                                       const std::string& comment) {
     std::ostringstream out;
-    out << "# ngs site: " << s.name << " (proxy)\n"
-        << "server {\n"
-        << nginx_listen_and_ssl(s)
-        << "    server_name  " << nginx_server_names(s) << ";\n"
-        << nginx_acme_location()
-        << "    location / {\n"
-        << "        proxy_pass         http://" << s.upstream << ";\n"
+    if (!comment.empty()) {
+        out << "    # " << comment << "\n";
+    }
+    out << "    location " << path << " {\n"
+        << "        proxy_pass         " << proxy_pass << ";\n"
         << "        proxy_http_version 1.1;\n"
-        << "        proxy_set_header   Host $host;\n"
+        << "        proxy_set_header   Host " << host << ";\n"
         << "        proxy_set_header   X-Real-IP $remote_addr;\n"
         << "        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;\n"
         << "        proxy_set_header   X-Forwarded-Proto $scheme;\n"
         << "        proxy_set_header   Upgrade $http_upgrade;\n"
         << "        proxy_set_header   Connection \"upgrade\";\n"
-        << "    }\n"
-        << nginx_log_block(s) << "}\n";
+        << "    }\n";
+    return out.str();
+}
+
+std::string nginx_url_proxy_pass_target(const UrlProxyRule& r) {
+    if (r.target_type == "unix") {
+        return "http://unix:" + r.target + ":";
+    }
+    return r.target;
+}
+
+std::string nginx_proxy_conf(const SiteInfo& s) {
+    std::vector<UrlProxyRule> rules;
+    std::string load_err;
+    load_url_proxy_rules(s, rules, load_err);
+
+    const UrlProxyRule* root_rule = nullptr;
+    for (const auto& r : rules) {
+        if (r.path == "/") {
+            root_rule = &r;
+            break;
+        }
+    }
+
+    std::ostringstream out;
+    out << "# ngs site: " << s.name << " (proxy)\n"
+        << "server {\n"
+        << nginx_listen_and_ssl(s)
+        << "    server_name  " << nginx_server_names(s) << ";\n"
+        << nginx_acme_location();
+
+    // Sub-path URL proxies first (longer prefixes beat location /).
+    for (const auto& r : rules) {
+        if (r.path == "/") {
+            continue;
+        }
+        const std::string comment =
+            r.remark.empty() ? ("url-proxy " + r.id)
+                             : ("url-proxy " + nginx_escape_comment(r.remark));
+        out << nginx_proxy_location_block(r.path, nginx_url_proxy_pass_target(r),
+                                          r.host.empty() ? "$http_host" : r.host,
+                                          comment);
+    }
+
+    if (root_rule) {
+        const std::string comment =
+            root_rule->remark.empty()
+                ? ("url-proxy " + root_rule->id)
+                : ("url-proxy " + nginx_escape_comment(root_rule->remark));
+        out << nginx_proxy_location_block(
+            "/", nginx_url_proxy_pass_target(*root_rule),
+            root_rule->host.empty() ? "$http_host" : root_rule->host, comment);
+    } else {
+        out << nginx_proxy_location_block("/", "http://" + s.upstream, "$host",
+                                          "");
+    }
+
+    out << nginx_log_block(s) << "}\n";
     return out.str();
 }
 
@@ -688,6 +845,178 @@ bool write_text_file(const std::string& path, const std::string& content) {
     return true;
 }
 
+std::string url_proxy_dir() {
+    return join_path(conf_root(), "proxy");
+}
+
+std::string url_proxy_file(const SiteInfo& s) {
+    return join_path(url_proxy_dir(), site_id(s) + ".json");
+}
+
+std::string make_proxy_id() {
+    static std::atomic<uint64_t> seq{0};
+    using clock = std::chrono::system_clock;
+    const auto sec = std::chrono::duration_cast<std::chrono::seconds>(
+                         clock::now().time_since_epoch())
+                         .count();
+    return "up" + std::to_string(sec) + "-" + std::to_string(++seq);
+}
+
+std::string nginx_escape_comment(std::string s) {
+    for (char& c : s) {
+        if (c == '\n' || c == '\r') {
+            c = ' ';
+        }
+    }
+    return s;
+}
+
+bool load_url_proxy_rules(const SiteInfo& s, std::vector<UrlProxyRule>& out,
+                          std::string& err) {
+    out.clear();
+    const std::string path = url_proxy_file(s);
+    if (!path_exists(path)) {
+        return true;
+    }
+    std::ifstream in(path);
+    if (!in) {
+        err = "无法读取 URL 代理配置";
+        return false;
+    }
+    std::stringstream buf;
+    buf << in.rdbuf();
+    ylib::json root;
+    if (!root.parse(buf.str())) {
+        err = "URL 代理配置 JSON 无效";
+        return false;
+    }
+    if (!root.exist("rules") || !root["rules"].is_array()) {
+        return true;
+    }
+    auto& arr = root["rules"];
+    for (uint32 i = 0; i < arr.size(); ++i) {
+        const auto& j = arr[i];
+        UrlProxyRule r;
+        r.id = j.exist("id") ? j["id"].to<std::string>() : "";
+        r.path = j.exist("path") ? j["path"].to<std::string>() : "";
+        r.target_type =
+            j.exist("target_type") ? j["target_type"].to<std::string>() : "url";
+        r.target = j.exist("target") ? j["target"].to<std::string>() : "";
+        r.host = j.exist("host") ? j["host"].to<std::string>() : "$http_host";
+        r.remark = j.exist("remark") ? j["remark"].to<std::string>() : "";
+        if (r.id.empty() || r.path.empty() || r.target.empty()) {
+            continue;
+        }
+        out.push_back(std::move(r));
+    }
+    return true;
+}
+
+bool save_url_proxy_rules(const SiteInfo& s,
+                          const std::vector<UrlProxyRule>& rules,
+                          std::string& err) {
+    if (!ensure_dir(url_proxy_dir())) {
+        err = "无法创建 URL 代理目录";
+        return false;
+    }
+    ylib::json arr;
+    for (const auto& r : rules) {
+        ylib::json item;
+        item["id"] = r.id;
+        item["path"] = r.path;
+        item["target_type"] = r.target_type;
+        item["target"] = r.target;
+        item["host"] = r.host;
+        item["remark"] = r.remark;
+        arr.push_back(item);
+    }
+    ylib::json root;
+    root["rules"] = arr;
+    if (!write_text_file(url_proxy_file(s), root.to_string(true))) {
+        err = "写入 URL 代理配置失败";
+        return false;
+    }
+    return true;
+}
+
+bool normalize_url_proxy_rule(UrlProxyRule& rule, std::string& err) {
+    // path
+    while (!rule.path.empty() &&
+           (rule.path.back() == ' ' || rule.path.back() == '\t')) {
+        rule.path.pop_back();
+    }
+    size_t i = 0;
+    while (i < rule.path.size() &&
+           (rule.path[i] == ' ' || rule.path[i] == '\t')) {
+        ++i;
+    }
+    rule.path = rule.path.substr(i);
+    if (rule.path.empty() || rule.path[0] != '/') {
+        err = "代理目录必须以 / 开头,例如 / 或 /web";
+        return false;
+    }
+    for (unsigned char c : rule.path) {
+        if (c <= 0x20 || c == '"' || c == '\'' || c == ';') {
+            err = "代理目录含非法字符";
+            return false;
+        }
+    }
+
+    if (rule.target_type != "unix") {
+        rule.target_type = "url";
+    }
+    while (!rule.target.empty() &&
+           (rule.target.back() == ' ' || rule.target.back() == '\t')) {
+        rule.target.pop_back();
+    }
+    i = 0;
+    while (i < rule.target.size() &&
+           (rule.target[i] == ' ' || rule.target[i] == '\t')) {
+        ++i;
+    }
+    rule.target = rule.target.substr(i);
+    if (rule.target.empty()) {
+        err = "请填写目标地址";
+        return false;
+    }
+    if (rule.target_type == "url") {
+        if (rule.target.rfind("http://", 0) != 0 &&
+            rule.target.rfind("https://", 0) != 0) {
+            rule.target = "http://" + rule.target;
+        }
+    } else {
+        if (rule.target[0] != '/') {
+            err = "UNIX 目标应为 socket 文件路径,例如 /tmp/panel.sock";
+            return false;
+        }
+    }
+
+    if (rule.host.empty()) {
+        rule.host = "$http_host";
+    }
+    for (unsigned char c : rule.host) {
+        if (c <= 0x20 || c == '"' || c == ';' || c == '\n') {
+            err = "发送域名(host)含非法字符";
+            return false;
+        }
+    }
+    return true;
+}
+
+bool apply_url_proxy_and_reload(const SiteInfo& s, std::string& err) {
+    if (!write_vhost(s)) {
+        err = "写入 Nginx 配置失败";
+        return false;
+    }
+    if (vhost_enabled(s.name)) {
+        if (!apply_nginx()) {
+            err = "Nginx 重载失败,请检查配置";
+            return false;
+        }
+    }
+    return true;
+}
+
 bool write_vhost(const SiteInfo& s) {
     if (!ensure_dir(vhost_dir())) {
         return false;
@@ -1043,9 +1372,11 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
         }
     }
 
-    if (!start_site_by_name(s.name)) {
+    if (!start_site_by_name(s.name, err)) {
         // Keep created site; caller can start later.
-        log_warn("website created but start incomplete name=" + s.name);
+        log_warn("website created but start incomplete name=" + s.name +
+                 (err.empty() ? "" : (" " + err)));
+        err.clear();
     }
     created = s;
     created.running = site_is_running(s);
@@ -1108,6 +1439,7 @@ bool delete_site_impl(const std::string& name, bool delete_files,
     stop_site_by_name(s.name);
     remove_path(vhost_file(s));
     remove_path(vhost_disabled_file(s));
+    remove_path(url_proxy_file(s));
 
     auto remain = load_sites();
     remain.erase(std::remove_if(remain.begin(), remain.end(),
@@ -1453,6 +1785,138 @@ bool update_site(const UpdateSiteRequest& req, std::string& err) {
     return true;
 }
 
+std::vector<UrlProxyRule> list_url_proxies(const std::string& site_name,
+                                           std::string& err) {
+    err.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return {};
+    }
+    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
+        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
+        return {};
+    }
+    std::vector<UrlProxyRule> rules;
+    if (!load_url_proxy_rules(s, rules, err)) {
+        return {};
+    }
+    return rules;
+}
+
+bool add_url_proxy(const std::string& site_name, UrlProxyRule rule,
+                   std::string& err) {
+    err.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return false;
+    }
+    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
+        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
+        return false;
+    }
+    if (!normalize_url_proxy_rule(rule, err)) {
+        return false;
+    }
+    std::vector<UrlProxyRule> rules;
+    if (!load_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    for (const auto& old : rules) {
+        if (old.path == rule.path) {
+            err = "代理目录已存在: " + rule.path;
+            return false;
+        }
+    }
+    if (rule.id.empty()) {
+        rule.id = make_proxy_id();
+    }
+    rules.push_back(rule);
+    if (!save_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    return apply_url_proxy_and_reload(s, err);
+}
+
+bool update_url_proxy(const std::string& site_name, const UrlProxyRule& in,
+                      std::string& err) {
+    err.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return false;
+    }
+    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
+        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
+        return false;
+    }
+    if (in.id.empty()) {
+        err = "缺少代理规则 id";
+        return false;
+    }
+    UrlProxyRule rule = in;
+    if (!normalize_url_proxy_rule(rule, err)) {
+        return false;
+    }
+    std::vector<UrlProxyRule> rules;
+    if (!load_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    bool found = false;
+    for (auto& old : rules) {
+        if (old.id == rule.id) {
+            for (const auto& other : rules) {
+                if (other.id != rule.id && other.path == rule.path) {
+                    err = "代理目录已存在: " + rule.path;
+                    return false;
+                }
+            }
+            old = rule;
+            found = true;
+            break;
+        }
+    }
+    if (!found) {
+        err = "未找到代理规则";
+        return false;
+    }
+    if (!save_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    return apply_url_proxy_and_reload(s, err);
+}
+
+bool delete_url_proxy(const std::string& site_name, const std::string& id,
+                      std::string& err) {
+    err.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return false;
+    }
+    if (id.empty()) {
+        err = "缺少代理规则 id";
+        return false;
+    }
+    std::vector<UrlProxyRule> rules;
+    if (!load_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    const auto before = rules.size();
+    rules.erase(std::remove_if(rules.begin(), rules.end(),
+                               [&](const UrlProxyRule& r) { return r.id == id; }),
+                rules.end());
+    if (rules.size() == before) {
+        err = "未找到代理规则";
+        return false;
+    }
+    if (!save_url_proxy_rules(s, rules, err)) {
+        return false;
+    }
+    return apply_url_proxy_and_reload(s, err);
+}
+
 bool apply_letsencrypt_impl(const std::string& name, const std::string& email,
                             const std::vector<std::string>& selected_hosts,
                             std::string& err) {
@@ -1612,8 +2076,10 @@ bool start_site(const std::string& name, std::string& err) {
         err = "未找到网站: " + name;
         return false;
     }
-    if (!start_site_by_name(name)) {
-        err = "启动网站失败: " + name;
+    if (!start_site_by_name(name, err)) {
+        if (err.empty()) {
+            err = "启动网站失败: " + name;
+        }
         return false;
     }
     return true;
@@ -1638,8 +2104,10 @@ bool restart_site(const std::string& name, std::string& err) {
         err = "未找到网站: " + name;
         return false;
     }
-    if (!restart_site_by_name(name)) {
-        err = "重启网站失败: " + name;
+    if (!restart_site_by_name(name, err)) {
+        if (err.empty()) {
+            err = "重启网站失败: " + name;
+        }
         return false;
     }
     return true;
@@ -1651,14 +2119,17 @@ bool delete_site(const std::string& name, std::string& err, bool delete_files) {
 
 namespace {
 
-bool start_fastweb_process(const SiteInfo& s) {
+bool start_fastweb_process(const SiteInfo& s, std::string& err) {
+    err.clear();
     if (!fastweb::is_installed()) {
-        std::cerr << "Fastweb 未安装。\n";
+        err = "Fastweb 未安装";
+        std::cerr << err << "。\n";
         return false;
     }
     const std::string cfg = fastweb_config(s.name);
     if (!path_exists(cfg)) {
-        std::cerr << "未找到 Fastweb 配置: " << cfg << "\n";
+        err = "未找到 Fastweb 配置: " + cfg;
+        std::cerr << err << "\n";
         return false;
     }
     if (site_backend_running(s)) {
@@ -1680,7 +2151,8 @@ bool start_fastweb_process(const SiteInfo& s) {
 
     const pid_t child = fork();
     if (child < 0) {
-        std::cerr << "启动 Fastweb 失败: fork 错误。\n";
+        err = "启动 Fastweb 失败: fork 错误";
+        std::cerr << err << "。\n";
         return false;
     }
     if (child == 0) {
@@ -1722,10 +2194,16 @@ bool start_fastweb_process(const SiteInfo& s) {
         _exit(127);
     }
 
-    bool ready = false;
+    auto reap_child = [child]() {
+        int st = 0;
+        waitpid(child, &st, WNOHANG);
+    };
+
+    bool saw_proc = false;
     for (int i = 0; i < 50; ++i) {
+        reap_child();
         if (!find_fastweb_pids(s.name).empty()) {
-            ready = true;
+            saw_proc = true;
             break;
         }
         if (!pid_alive(static_cast<int>(child))) {
@@ -1738,24 +2216,70 @@ bool start_fastweb_process(const SiteInfo& s) {
     if (pos != std::string::npos) {
         try {
             const int port = std::stoi(s.upstream.substr(pos + 1));
-            for (int i = 0; i < 50; ++i) {
-                if (tcp_port_busy(port)) {
-                    ready = true;
+            for (int i = 0; i < 50 && !saw_proc; ++i) {
+                reap_child();
+                if (!find_fastweb_pids(s.name).empty()) {
+                    saw_proc = true;
                     break;
                 }
+                // Port alone is not enough: another process may own it.
                 if (!pid_alive(static_cast<int>(child)) &&
                     find_fastweb_pids(s.name).empty()) {
                     break;
                 }
+                if (tcp_port_busy(port) && !find_fastweb_pids(s.name).empty()) {
+                    saw_proc = true;
+                    break;
+                }
                 usleep(100000);
             }
+            (void)port;
         } catch (...) {
         }
     }
 
-    if (!ready && find_fastweb_pids(s.name).empty()) {
-        std::cerr << "Fastweb 启动后未检测到运行,请查看站点日志目录: "
-                  << join_path(site_dir(s), "log") << "\n";
+    // Initialization scripts can crash shortly after the process appears.
+    if (saw_proc) {
+        for (int i = 0; i < 40; ++i) {
+            reap_child();
+            if (find_fastweb_pids(s.name).empty()) {
+                saw_proc = false;
+                break;
+            }
+            usleep(100000);
+        }
+    } else {
+        // Give late starters a short extra window, then confirm still alive.
+        for (int i = 0; i < 20; ++i) {
+            reap_child();
+            if (!find_fastweb_pids(s.name).empty()) {
+                saw_proc = true;
+                break;
+            }
+            usleep(100000);
+        }
+        if (saw_proc) {
+            for (int i = 0; i < 20; ++i) {
+                reap_child();
+                if (find_fastweb_pids(s.name).empty()) {
+                    saw_proc = false;
+                    break;
+                }
+                usleep(100000);
+            }
+        }
+    }
+    reap_child();
+
+    if (!saw_proc || find_fastweb_pids(s.name).empty()) {
+        const std::string detail = fastweb_recent_error(s);
+        const std::string log_hint = join_path(site_dir(s), "log");
+        if (!detail.empty()) {
+            err = "Fastweb 启动失败: " + detail;
+        } else {
+            err = "Fastweb 启动后未保持运行,请查看日志: " + log_hint;
+        }
+        std::cerr << err << "\n";
         return false;
     }
 
@@ -1798,19 +2322,22 @@ bool stop_fastweb_process(const SiteInfo& s) {
     return true;
 }
 
-bool start_site_by_name(const std::string& name) {
+bool start_site_by_name(const std::string& name, std::string& err) {
+    err.clear();
     SiteInfo s;
     if (!find_site_info(name, s)) {
-        std::cerr << "未找到网站: " << name << "\n";
+        err = "未找到网站: " + name;
+        std::cerr << err << "\n";
         return false;
     }
     if (!nginx::is_installed()) {
-        std::cerr << "请先安装 Nginx。\n";
+        err = "请先安装 Nginx";
+        std::cerr << err << "。\n";
         return false;
     }
 
     if (s.type == SiteType::Fastweb) {
-        if (!start_fastweb_process(s)) {
+        if (!start_fastweb_process(s, err)) {
             return false;
         }
     }
@@ -1819,7 +2346,8 @@ bool start_site_by_name(const std::string& name) {
         if (!enable_vhost(s.name)) {
             // disabled missing: regenerate
             if (!write_vhost(s)) {
-                std::cerr << "启用网站 vhost 失败。\n";
+                err = "启用网站 vhost 失败";
+                std::cerr << err << "。\n";
                 return false;
             }
         }
@@ -1830,9 +2358,21 @@ bool start_site_by_name(const std::string& name) {
 
     if (!nginx::is_running()) {
         if (!nginx::ensure_vhost_include() || !nginx::start()) {
+            err = "Nginx 启动失败";
             return false;
         }
     } else if (!apply_nginx()) {
+        err = "Nginx 重载失败";
+        return false;
+    }
+
+    if (s.type == SiteType::Fastweb && !site_backend_running(s)) {
+        const std::string detail = fastweb_recent_error(s);
+        err = detail.empty()
+                  ? "网站 Nginx 已启用,但 Fastweb 未在运行"
+                  : ("Fastweb 未在运行: " + detail);
+        std::cerr << "网站启动失败: " << s.name << " [" << site_status_label(s)
+                  << "] " << err << "\n";
         return false;
     }
 
@@ -1867,10 +2407,12 @@ bool stop_site_by_name(const std::string& name) {
     return true;
 }
 
-bool restart_site_by_name(const std::string& name) {
+bool restart_site_by_name(const std::string& name, std::string& err) {
+    err.clear();
     SiteInfo s;
     if (!find_site_info(name, s)) {
-        std::cerr << "未找到网站: " << name << "\n";
+        err = "未找到网站: " + name;
+        std::cerr << err << "\n";
         return false;
     }
 
@@ -1878,7 +2420,7 @@ bool restart_site_by_name(const std::string& name) {
     // Stop backend/vhost first; ignore "already stopped" style failures for
     // Fastweb-less static sites that are already disabled.
     stop_site_by_name(name);
-    if (!start_site_by_name(name)) {
+    if (!start_site_by_name(name, err)) {
         std::cerr << "网站重启失败: " << name << "\n";
         return false;
     }
@@ -1977,7 +2519,8 @@ void menu() {
             case 4: {
                 std::string name;
                 if (pick_site_name(name)) {
-                    start_site_by_name(name);
+                    std::string e;
+                    start_site_by_name(name, e);
                 }
                 pause();
                 break;
@@ -1993,7 +2536,8 @@ void menu() {
             case 6: {
                 std::string name;
                 if (pick_site_name(name)) {
-                    restart_site_by_name(name);
+                    std::string e;
+                    restart_site_by_name(name, e);
                 }
                 pause();
                 break;

+ 19 - 0
src/website/website.h

@@ -47,6 +47,16 @@ struct UpdateSiteRequest {
     bool update_ssl = false;  // if false, keep existing ssl fields
 };
 
+// Baota-style URL reverse-proxy rules (nginx locations), not Fastweb config.ini.
+struct UrlProxyRule {
+    std::string id;
+    std::string path;         // e.g. /web
+    std::string target_type;  // url | unix
+    std::string target;       // http://127.0.0.1:9000 or /tmp/app.sock
+    std::string host;         // e.g. $http_host
+    std::string remark;
+};
+
 std::string type_key(SiteType t);
 std::string type_label(SiteType t);
 bool parse_type_key(const std::string& key, SiteType& out);
@@ -83,6 +93,15 @@ bool restart_site(const std::string& name, std::string& err);
 bool delete_site(const std::string& name, std::string& err,
                  bool delete_files = false);
 
+std::vector<UrlProxyRule> list_url_proxies(const std::string& site_name,
+                                           std::string& err);
+bool add_url_proxy(const std::string& site_name, UrlProxyRule rule,
+                   std::string& err);
+bool update_url_proxy(const std::string& site_name, const UrlProxyRule& rule,
+                      std::string& err);
+bool delete_url_proxy(const std::string& site_name, const std::string& id,
+                      std::string& err);
+
 void menu();
 
 }  // namespace website

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff