ngs 1 месяц назад
Родитель
Сommit
41ed9c6507

+ 92 - 13
data/www/assets/app.js

@@ -232,6 +232,17 @@
         reject(new Error("Monaco loader 未加载"));
         return;
       }
+      // Workers resolve modules via fetch(); relative "/assets/..." paths are
+      // invalid inside WorkerGlobalScope — force absolute URLs via baseUrl proxy.
+      const monacoBase = `${window.location.origin}/assets/monaco`;
+      window.MonacoEnvironment = {
+        getWorkerUrl() {
+          const code =
+            `self.MonacoEnvironment={baseUrl:${JSON.stringify(monacoBase + "/")}};` +
+            `importScripts(${JSON.stringify(monacoBase + "/vs/base/worker/workerMain.js")});`;
+          return URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
+        },
+      };
       window.require.config({ paths: { vs: "/assets/monaco/vs" } });
       window.require(["vs/editor/editor.main"], () => {
         if (window.monaco) resolve(window.monaco);
@@ -785,10 +796,25 @@
     ].join("");
   }
 
+  function siteSslExpireCell(site) {
+    if (!site || !site.ssl_enable) {
+      return `<span class="text-secondary">—</span>`;
+    }
+    const days = Number(site.ssl_days_left);
+    if (!Number.isFinite(days) || site.ssl_days_left === -1) {
+      return `<span class="text-secondary">—</span>`;
+    }
+    if (days < 0) {
+      return `<span class="text-danger" title="${escapeHtml(site.ssl_not_after || "")}">已过期</span>`;
+    }
+    const cls = days <= 30 ? "text-danger" : "text-success";
+    return `<span class="${cls}" title="${escapeHtml(site.ssl_not_after || "")}">${days}天</span>`;
+  }
+
   function renderSites() {
     const tbody = $("#sites-table tbody");
     if (!state.sites.length) {
-      tbody.innerHTML = `<tr><td colspan="7" class="text-secondary text-center py-4">暂无网站</td></tr>`;
+      tbody.innerHTML = `<tr><td colspan="8" class="text-secondary text-center py-4">暂无网站</td></tr>`;
       return;
     }
     tbody.innerHTML = state.sites
@@ -817,6 +843,7 @@
           <td><a class="sites-link" href="${escapeHtml(openUrl)}" target="_blank" rel="noopener" title="${escapeHtml(domains.join(", "))}">${escapeHtml(domainText)}</a></td>
           <td><button type="button" class="btn btn-link btn-sm sites-link sites-path p-0" data-site-path="${escapeHtml(root)}" title="${escapeHtml(root)}">${escapeHtml(root || "—")}</button></td>
           <td>${escapeHtml(s.upstream || "—")}</td>
+          <td>${siteSslExpireCell(s)}</td>
           <td>${st}</td>
           <td class="text-end">
             <div class="btn-group btn-group-sm">
@@ -1141,7 +1168,7 @@
                 $("#site-set-ssl-enable").checked = true;
                 setSiteCertSubTab("pem");
                 loadSiteSslPem().catch(() => {});
-                toast("证书已申请并写入本站 ssl/");
+                toast("证书已申请并写入 /ngs/conf/ssl/<域名>/");
               }
             }).catch(() => {});
           }
@@ -1592,8 +1619,17 @@
     if (which === "apply") renderSslDomainChecklist(selectedSslHosts());
   }
 
+  function siteSslPrimaryDomain(site) {
+    const domains = domainsFromText(
+      ($("#site-set-domain") && $("#site-set-domain").value) || site.domain || site.name || ""
+    );
+    if (!domains.length) return site.name || "";
+    return parseDomainToken(domains[0]).host;
+  }
+
   function siteSslPaths(site) {
-    const sslDir = joinPath(site.root || "", "ssl");
+    const domain = siteSslPrimaryDomain(site);
+    const sslDir = joinPath("/ngs/conf/ssl", domain);
     return {
       dir: sslDir,
       cert: joinPath(sslDir, "fullchain.pem"),
@@ -1615,7 +1651,7 @@
 
   async function loadSiteSslPem() {
     const site = state.siteSettings;
-    if (!site || !site.root) return;
+    if (!site) return;
     const paths = siteSslPaths(site);
     const [pem, key] = await Promise.all([
       readFileText(paths.readCert),
@@ -1698,6 +1734,21 @@
     return $$("#site-ssl-domain-list input[type=checkbox]:checked").map((el) => el.value).filter(Boolean);
   }
 
+  function formatSslExpire(site) {
+    if (!site || !site.ssl_not_after) return "证书有效期:—";
+    const days = site.ssl_days_left;
+    let extra = "";
+    if (days !== null && days !== undefined && days !== "") {
+      const n = Number(days);
+      if (!Number.isNaN(n)) {
+        if (n < 0) extra = `(已过期 ${Math.abs(n)} 天)`;
+        else if (n === 0) extra = "(今天到期)";
+        else extra = `(剩余 ${n} 天)`;
+      }
+    }
+    return `证书有效期:${site.ssl_not_before || "?"} ~ ${site.ssl_not_after}${extra}`;
+  }
+
   function fillSiteSettingsForm(site) {
     state.siteSettings = site;
     $("#site-settings-name").textContent = site.name || "";
@@ -1708,6 +1759,12 @@
     $("#site-set-domain").value = domains.join("\n");
     $("#site-set-ssl-enable").checked = !!site.ssl_enable;
     $("#site-set-ssl-port").value = site.ssl_port || 443;
+    const expireEl = $("#site-set-ssl-expire");
+    if (expireEl) {
+      expireEl.textContent = formatSslExpire(site);
+      expireEl.classList.toggle("text-danger", Number(site.ssl_days_left) < 14);
+      expireEl.classList.toggle("text-secondary", !(Number(site.ssl_days_left) < 14));
+    }
     $("#site-set-conf-path").textContent = site.conf || "";
     $("#site-set-nginx-conf").value = "";
     state.siteLogKind = "access";
@@ -1847,7 +1904,7 @@
 
   async function saveSiteCert() {
     const site = state.siteSettings;
-    if (!site || !site.root) return;
+    if (!site) return;
     const domains = domainsFromText($("#site-set-domain").value);
     const ssl_enable = $("#site-set-ssl-enable").checked;
     const ssl_port = Number($("#site-set-ssl-port").value || 443);
@@ -2407,7 +2464,8 @@
         bodyHtml: `
           <div class="mb-2">
             <label class="form-label">名称</label>
-            <input class="form-control form-control-sm" name="name" required placeholder="demo" />
+            <input class="form-control form-control-sm" name="name" required placeholder="我的站点" />
+            <div class="form-text">可用中文</div>
           </div>
           <div class="mb-2">
             <label class="form-label">类型</label>
@@ -2416,12 +2474,17 @@
               <option value="proxy">反向代理</option>
               <option value="fastweb">Fastweb</option>
             </select>
-            <div class="form-text field-upstream-tip">静态站仅需名称/域名/端口</div>
+            <div class="form-text field-upstream-tip">静态站仅需名称/域名</div>
           </div>
           <div class="mb-2">
-            <label class="form-label">域名(可空,支持多个,每行一个)</label>
-            <textarea class="form-control form-control-sm" name="domain" rows="3" placeholder="默认等于名称&#10;www.example.com:8080"></textarea>
-            <div class="form-text">可写域名或 域名:端口,不写端口默认 80</div>
+            <label class="form-label">域名</label>
+            <input class="form-control form-control-sm" name="domain" required placeholder="www.example.com 或 www.example.com:8080" />
+            <div class="form-text">仅支持 1 个域名;不写端口默认 80</div>
+          </div>
+          <div class="mb-2">
+            <label class="form-label">目录</label>
+            <input class="form-control form-control-sm" name="root" required placeholder="/ngs/wwwroot/www.example.com" />
+            <div class="form-text">根据域名自动生成,可手动修改</div>
           </div>
           <div class="mb-2 field-upstream d-none">
             <label class="form-label">上游(proxy 必填)</label>
@@ -2429,12 +2492,17 @@
           </div>`,
         onSubmit: async (fd) => {
           const type = fd.get("type");
-          const domains = domainsFromText(String(fd.get("domain") || ""));
+          const domain = String(fd.get("domain") || "").trim();
+          if (!domain) throw new Error("请填写域名");
+          if (/[\s,;]/.test(domain)) throw new Error("仅支持填写一个域名");
+          const root = String(fd.get("root") || "").trim();
+          if (!root) throw new Error("请填写目录");
           const body = {
             name: String(fd.get("name") || "").trim(),
             type,
-            domains,
-            domain: domains.join(" "),
+            domain,
+            domains: [domain],
+            root,
             upstream: type === "proxy" ? String(fd.get("upstream") || "").trim() : "",
           };
           if (type === "proxy" && !body.upstream) throw new Error("反向代理需填写上游");
@@ -2444,6 +2512,17 @@
       });
       const body = $("#modal-body");
       syncSiteFormFields(body);
+      const domainEl = body.querySelector('[name="domain"]');
+      const rootEl = body.querySelector('[name="root"]');
+      let rootTouched = false;
+      rootEl.addEventListener("input", () => { rootTouched = true; });
+      const syncRootFromDomain = () => {
+        if (rootTouched) return;
+        const host = parseDomainToken(domainEl.value.trim()).host || "";
+        rootEl.value = host ? `/ngs/wwwroot/${host}` : "";
+      };
+      domainEl.addEventListener("input", syncRootFromDomain);
+      domainEl.addEventListener("change", syncRootFromDomain);
       body.querySelector('[name="type"]').addEventListener("change", () => syncSiteFormFields(body));
     });
 

+ 4 - 2
data/www/index.html

@@ -129,6 +129,7 @@
                   <th>域名</th>
                   <th>目录</th>
                   <th>上游</th>
+                  <th>证书到期</th>
                   <th>状态</th>
                   <th class="text-end">操作</th>
                 </tr>
@@ -298,6 +299,7 @@
                     <input class="form-check-input" type="checkbox" id="site-set-ssl-enable" />
                     <label class="form-check-label" for="site-set-ssl-enable">启用 HTTPS</label>
                   </div>
+                  <div class="small mb-3" id="site-set-ssl-expire">证书有效期:—</div>
                   <div class="mb-3">
                     <label class="form-label">HTTPS 端口</label>
                     <input class="form-control form-control-sm" id="site-set-ssl-port" type="number" min="1" max="65535" value="443" />
@@ -310,13 +312,13 @@
                     <label class="form-label">私钥 KEY</label>
                     <textarea class="form-control font-monospace site-ssl-pem" id="site-set-ssl-key-pem" rows="8" spellcheck="false" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"></textarea>
                   </div>
-                  <p class="small text-secondary mb-3">保存后写入网站目录 <code>ssl/fullchain.pem</code> 与 <code>ssl/privkey.pem</code> 并重载 Nginx。</p>
+                  <p class="small text-secondary mb-3">保存后写入 <code>/ngs/conf/ssl/&lt;域名&gt;/fullchain.pem</code> 与 <code>privkey.pem</code> 并重载 Nginx。</p>
                   <button type="button" class="btn btn-sm btn-primary" id="site-set-cert-save">保存并重载</button>
                 </div>
 
                 <div id="site-cert-pane-apply" hidden>
                   <div class="fw-semibold mb-2">Let's Encrypt 免费证书</div>
-                  <p class="small text-secondary mb-2">勾选域名后合并签发到一张证书,成功后自动写入本站 <code>ssl/</code> 并启用 HTTPS。域名需已解析到本机,且公网可访问 80 端口。</p>
+                  <p class="small text-secondary mb-2">勾选域名后合并签发到一张证书,成功后自动写入 <code>/ngs/conf/ssl/&lt;主域名&gt;/</code> 并启用 HTTPS。域名需已解析到本机,且公网可访问 80 端口。</p>
                   <div class="mb-2">
                     <div class="d-flex justify-content-between align-items-center mb-1">
                       <label class="form-label mb-0">申请域名</label>

+ 42 - 7
src/api/api_server.cpp

@@ -6,6 +6,7 @@
 #include "../software/mysql/mysql.h"
 #include "../software/nginx/nginx.h"
 #include "../software/redis/redis.h"
+#include "../ssl/acme.h"
 #include "../store/store.h"
 #include "../system/metrics.h"
 #include "../system/metrics_history.h"
@@ -729,11 +730,22 @@ ylib::json site_to_json(const website::SiteInfo& s) {
     item["log_dir"] = log_dir;
     item["access_log"] = join_path(log_dir, "access.log");
     item["error_log"] = join_path(log_dir, "error.log");
-    item["log_db"] = weblog::db_path(s.name);
+    item["log_db"] = weblog::db_path(website::site_key(s));
     item["ssl_enable"] = s.ssl_enable;
     item["ssl_cert"] = s.ssl_cert;
     item["ssl_key"] = s.ssl_key;
     item["ssl_port"] = s.ssl_port;
+    item["ssl_not_before"] = "";
+    item["ssl_not_after"] = "";
+    item["ssl_days_left"] = -1;
+    if (!s.ssl_cert.empty()) {
+        const ssl::CertInfo ci = ssl::read_cert_info(s.ssl_cert);
+        if (ci.ok) {
+            item["ssl_not_before"] = ci.not_before;
+            item["ssl_not_after"] = ci.not_after;
+            item["ssl_days_left"] = ci.days_left;
+        }
+    }
     return item;
 }
 
@@ -776,10 +788,15 @@ void h_websites(request* req, response* resp) {
         }
         creq.listen_port = json_int(body, "listen_port", 0);
         creq.upstream = json_str(body, "upstream");
+        creq.root = json_str(body, "root");
         if (creq.name.empty()) {
             reply_err(resp, "name required");
             return;
         }
+        if (creq.domain.empty()) {
+            reply_err(resp, "domain required");
+            return;
+        }
         std::string err;
         if (!website::create_site(creq, err)) {
             reply_err(resp, err.empty() ? "create site failed" : err);
@@ -1007,7 +1024,7 @@ void h_websites_logs(request* req, response* resp) {
     ylib::json data;
     data["name"] = name;
     data["kind"] = kind == "error" ? "error" : "access";
-    data["log_db"] = weblog::db_path(name);
+    data["log_db"] = weblog::db_path(website::site_key_by_name(name));
     data["items"] = arr;
     reply_ok(resp, data);
 }
@@ -1074,11 +1091,28 @@ void h_websites_ssl_apply(request* req, response* resp) {
             }
         }
     }
-    // Reuse stored ACME account email; generate once if missing.
-    std::string email = store::get_setting("ssl_email", "");
-    if (email.empty()) {
-        const auto now = std::chrono::system_clock::now().time_since_epoch().count();
-        email = "ngs-" + std::to_string(now % 1000000000ULL) + "@users.noreply.ngs.local";
+    // ACME account contact: md5(<domain>)@qq.com (LE requires public-suffix email).
+    // Ignore previously stored invalid/legacy emails.
+    std::string email = json_str(body, "email");
+    if (!ssl::is_valid_acme_email(email) ||
+        email.size() < 8 ||
+        email.compare(email.size() - 7, 7, "@qq.com") != 0) {
+        std::string domain_hint;
+        if (!hosts.empty()) {
+            domain_hint = hosts.front();
+        } else {
+            website::SiteInfo site;
+            if (website::find_site(name, site)) {
+                domain_hint = website::primary_domain(site.domain);
+            }
+        }
+        email = ssl::default_account_email(domain_hint);
+        if (!ssl::is_valid_acme_email(email)) {
+            reply_err(resp, "无法生成 ACME 联系邮箱");
+            return;
+        }
+        store::set_setting("ssl_email", email);
+    } else if (store::get_setting("ssl_email", "") != email) {
         store::set_setting("ssl_email", email);
     }
     std::string err;
@@ -1561,6 +1595,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
 
     ensure_dir(software_root());
     ensure_dir(conf_root());
+    ensure_dir(join_path(conf_root(), "ssl"));
     ensure_dir(wwwroot_dir());
     {
         std::string aerr;

+ 1 - 1
src/auth/auth.h

@@ -11,7 +11,7 @@ struct Credentials {
     std::string password = "ngsadmin";
 };
 
-// www/conf/ngs.conf — username / password for panel login.
+// /ngs/conf/ngs.conf — username / password for panel login.
 std::string config_path();
 
 // Load config; create default file if missing.

+ 1 - 0
src/main.cpp

@@ -26,6 +26,7 @@ int main(int argc, char* argv[]) {
 
     ensure_dir(software_root());
     ensure_dir(conf_root());
+    ensure_dir(join_path(conf_root(), "ssl"));
     ensure_dir(wwwroot_dir());
     if (!store::init()) {
         std::cerr << "初始化数据库失败: " << store::database_path() << "\n";

+ 122 - 211
src/software/fastweb/fastweb.cpp

@@ -10,57 +10,56 @@ namespace fastweb {
 
 namespace {
 
+// Upstream layout for Fastweb runtime deps:
+//   /opt/lua54, /opt/luarocks
+//   cmake default prefix -> /usr/local (bin/lib/share)
+//
+// ylib / HPSocket are already installed by NGS build.sh — do not rebuild them.
 std::string build_root() {
-    return join_path(software_root(), ".build/fastweb");
-}
-
-std::string thirdparty_root() {
-    return join_path(software_root(), "3rdparty");
+    return "/tmp/ngs-fastweb-build";
 }
 
 std::string lua_prefix() {
-    return join_path(thirdparty_root(), "lua54");
+    return "/opt/lua54";
 }
 
 std::string luarocks_prefix() {
-    return join_path(thirdparty_root(), "luarocks");
+    return "/opt/luarocks";
 }
 
-std::string hpsocket_lib_dir() {
-    return join_path(thirdparty_root(), "lib");
+std::string system_prefix() {
+    return "/usr/local";
 }
 
-std::string hpsocket_include_dir() {
-    return join_path(thirdparty_root(), "include/HPSocket");
+std::string version_file() {
+    return join_path(share_dir(), ".ngs_version");
 }
 
 std::string fastweb_bin() {
-    // Prefer bin/fastweb (CMAKE_INSTALL_PREFIX), fallback to prefix/fastweb.
-    const std::string a = join_path(install_dir(), "bin/fastweb");
-    if (path_exists(a)) {
-        return a;
-    }
-    return join_path(install_dir(), "fastweb");
+    return join_path(system_prefix(), "bin/fastweb");
 }
 
 std::string toolchain_env() {
-    const std::string inc = join_path(thirdparty_root(), "include");
-    const std::string lib = hpsocket_lib_dir();
     const std::string lua = lua_prefix();
-    return "export CPATH=\"" + inc + ":${CPATH:-}\" && "
-           "export CPLUS_INCLUDE_PATH=\"" + inc + ":${CPLUS_INCLUDE_PATH:-}\" && "
-           "export LIBRARY_PATH=\"" + lib + ":${LIBRARY_PATH:-}\" && "
-           "export LD_LIBRARY_PATH=\"" + lib + ":" + join_path(lua, "lib") +
-           ":${LD_LIBRARY_PATH:-}\" && "
-           "export LDFLAGS=\"-L" + lib + " ${LDFLAGS:-}\" && "
-           "export CPPFLAGS=\"-I" + inc + " ${CPPFLAGS:-}\" && ";
+    return "export LIBRARY_PATH=\"" + join_path(system_prefix(), "lib") +
+           ":/usr/lib/x86_64-linux-gnu:${LIBRARY_PATH:-}\" && "
+           "export LD_LIBRARY_PATH=\"" + join_path(system_prefix(), "lib") + ":" +
+           join_path(lua, "lib") + ":${LD_LIBRARY_PATH:-}\" && "
+           "export LDFLAGS=\"-L" + join_path(system_prefix(), "lib") +
+           " -L/usr/lib/x86_64-linux-gnu ${LDFLAGS:-}\" && "
+           "export CPPFLAGS=\"-I" + join_path(system_prefix(), "include") +
+           " ${CPPFLAGS:-}\" && "
+           "export CPATH=\"" + join_path(system_prefix(), "include") +
+           ":${CPATH:-}\" && "
+           "export CPLUS_INCLUDE_PATH=\"" + join_path(system_prefix(), "include") +
+           ":${CPLUS_INCLUDE_PATH:-}\" && ";
 }
 
 bool write_version_file(const std::string& version, bool debug) {
-    if (!ensure_dir(install_dir())) {
+    if (!ensure_dir(share_dir())) {
         return false;
     }
-    std::ofstream out(join_path(install_dir(), ".ngs_version"));
+    std::ofstream out(version_file());
     if (!out) {
         return false;
     }
@@ -77,8 +76,20 @@ bool luarocks_installed() {
 }
 
 bool fastweb_bin_installed() {
-    return path_exists(join_path(install_dir(), "bin/fastweb")) ||
-           path_exists(join_path(install_dir(), "fastweb"));
+    return path_exists(fastweb_bin());
+}
+
+bool ylib_present() {
+    return path_exists("/usr/local/lib/libylib.a") ||
+           path_exists("/usr/lib/x86_64-linux-gnu/libylib.a") ||
+           path_exists("/usr/local/lib/libylib_d.a") ||
+           path_exists("/usr/lib/x86_64-linux-gnu/libylib_d.a");
+}
+
+bool hpsocket_present() {
+    return path_exists("/usr/local/lib/libhpsocket.a") ||
+           path_exists("/usr/lib/x86_64-linux-gnu/libhpsocket.a") ||
+           path_exists("/lib/x86_64-linux-gnu/libhpsocket.a");
 }
 
 bool install_apt_deps() {
@@ -99,11 +110,11 @@ bool install_apt_deps() {
     return rc == 0;
 }
 
-bool install_lua(const std::string& third_party) {
+bool install_lua(const std::string& work) {
     const std::string prefix = lua_prefix();
     std::cout << "安装 Lua 5.4.6 -> " << prefix << "\n";
     const std::string tarball = "lua-5.4.6.tar.gz";
-    if (run_cmd("cd \"" + third_party + "\" && "
+    if (run_cmd("cd \"" + work + "\" && "
                 "wget -q --show-progress "
                 "https://download.fwlua.com/software/" + tarball + " && "
                 "tar -zxf " + tarball) != 0) {
@@ -111,7 +122,7 @@ bool install_lua(const std::string& third_party) {
     }
 
     ensure_dir(prefix);
-    const std::string src = join_path(third_party, "lua-5.4.6");
+    const std::string src = join_path(work, "lua-5.4.6");
     return run_cmd(
                "cd \"" + src + "\" && "
                "make linux MYCFLAGS=\"-fPIC\" "
@@ -119,12 +130,12 @@ bool install_lua(const std::string& third_party) {
                "make INSTALL_TOP=\"" + prefix + "\" install") == 0;
 }
 
-bool install_luarocks(const std::string& third_party) {
+bool install_luarocks(const std::string& work) {
     const std::string prefix = luarocks_prefix();
     const std::string lua = lua_prefix();
     std::cout << "安装 LuaRocks 3.9.1 -> " << prefix << "\n";
     const std::string tarball = "luarocks-3.9.1.tar.gz";
-    if (run_cmd("cd \"" + third_party + "\" && "
+    if (run_cmd("cd \"" + work + "\" && "
                 "wget -q --show-progress "
                 "https://download.fwlua.com/software/" + tarball + " && "
                 "tar -zxf " + tarball) != 0) {
@@ -132,7 +143,7 @@ bool install_luarocks(const std::string& third_party) {
     }
 
     ensure_dir(prefix);
-    const std::string src = join_path(third_party, "luarocks-3.9.1");
+    const std::string src = join_path(work, "luarocks-3.9.1");
     return run_cmd(
                "cd \"" + src + "\" && "
                "./configure --with-lua=\"" + lua + "\" "
@@ -141,93 +152,19 @@ bool install_luarocks(const std::string& third_party) {
                "make && make install") == 0;
 }
 
-bool install_hpsocket(const std::string& third_party) {
-    const std::string repo = join_path(third_party, "hpsocket-linux");
-    if (!is_dir(repo)) {
-        std::cout << "克隆 hpsocket-linux...\n";
-        if (run_cmd("cd \"" + third_party + "\" && "
-                    "git clone https://git.ddtalk.net/1585346868/hpsocket-linux.git") != 0) {
-            return false;
-        }
-    }
-
-    const std::string lib_dir = hpsocket_lib_dir();
-    const std::string inc_dir = hpsocket_include_dir();
-    ensure_dir(lib_dir);
-    remove_path(inc_dir);
-    ensure_dir(inc_dir);
-
-    std::cout << "编译安装 HPSocket -> " << software_root() << "\n";
-    return run_cmd(
-               "cd \"" + repo + "\" && "
-               "mkdir -p build && cd build && "
-               "cmake .. && make && "
-               "cp libhpsocket.a \"" + lib_dir + "/\" && "
-               "cp ../hpsocket/* \"" + inc_dir + "/\"") == 0;
-}
-
-bool build_ylib(const std::string& third_party, bool debug) {
-    const std::string repo = join_path(third_party, "ylib");
-    if (!is_dir(repo)) {
-        std::cout << "克隆 ylib...\n";
-        if (run_cmd("cd \"" + third_party + "\" && "
-                    "git clone https://git.ddtalk.net/1585346868/ylib.git") != 0) {
-            return false;
-        }
-    } else {
-        std::cout << "ylib 已存在,跳过克隆。\n";
-    }
-
-    std::cout << "编译 ylib...\n";
-    std::string cmd = toolchain_env() +
-                      "cd \"" + repo + "\" && chmod +x build.sh && ./build.sh";
-    if (debug) {
-        cmd += " --debug";
-    }
-    return run_cmd(cmd) == 0;
-}
-
-bool patch_fastweb_paths(const std::string& repo) {
-    // Redirect upstream hardcoded /opt paths (and previous software_root paths)
-    // to www/software/3rdparty.
-    const std::string lua = lua_prefix();
-    const std::string rocks = luarocks_prefix();
-    const std::string lib_dir = join_path(install_dir(), "lib");
-    const std::string old_lua = join_path(software_root(), "lua54");
-    const std::string old_rocks = join_path(software_root(), "luarocks");
-    const std::string tests = join_path(repo, "tests/fastweb.cpp");
-
-    std::cout << "修正 Fastweb 源码依赖路径...\n";
-    std::cout << "  lua54     -> " << lua << "\n";
-    std::cout << "  luarocks  -> " << rocks << "\n";
-
-    if (run_cmd(
-            "cd \"" + repo + "\" && "
-            "find . -type f \\( -name '*.cpp' -o -name '*.h' -o -name 'CMakeLists.txt' -o -name '*.cmake' \\) "
-            "-print0 | xargs -0 -r sed -i "
-            "-e 's|" + old_lua + "|" + lua + "|g' "
-            "-e 's|" + old_rocks + "|" + rocks + "|g' "
-            "-e 's|/opt/lua54|" + lua + "|g' "
-            "-e 's|/opt/luarocks|" + rocks + "|g' "
-            "-e 's|set(CMAKE_INSTALL_RPATH \"/usr/local/lib\")|"
-            "set(CMAKE_INSTALL_RPATH \"" + lib_dir + "\")|g'") != 0) {
-        return false;
-    }
-
-    if (!path_exists(tests)) {
-        return true;
-    }
-
-    // tests/fastweb.cpp: module install/uninstall uses 3rdparty lua/luarocks.
+// Upstream Linux CMakeLists hardcodes ylib_d; fix to match build type.
+bool patch_ylib_link(const std::string& repo, bool debug) {
+    const std::string ylib_name = debug ? "ylib_d" : "ylib";
+    std::cout << "修正 Linux 链接库: " << ylib_name << "\n";
     return run_cmd(
                "sed -i "
-               "-e 's|if(info.type == \"" + lua + "/bin/lua\")|if(info.type == \"lua\")|g' "
-               "-e 's|if(info.type == \"/opt/lua54/bin/lua\")|if(info.type == \"lua\")|g' "
-               "-e 's|sudo " + rocks + "/bin/luarocks|" + rocks + "/bin/luarocks|g' "
-               "\"" + tests + "\"") == 0;
+               "-e 's/^[[:blank:]]*ylib_d[[:blank:]]*$/\\t\\t\\t" + ylib_name + "/' "
+               "-e 's/^[[:blank:]]*ylib[[:blank:]]*$/\\t\\t\\t" + ylib_name + "/' "
+               "\"" + join_path(repo, "CMakeLists.txt") + "\"") == 0;
 }
 
-bool build_fastweb(const std::string& root, bool debug) {
+bool build_fastweb(bool debug) {
+    const std::string root = build_root();
     const std::string repo = join_path(root, "fastweb");
     if (!is_dir(repo)) {
         std::cout << "克隆 fastweb...\n";
@@ -237,65 +174,40 @@ bool build_fastweb(const std::string& root, bool debug) {
         }
     }
 
-    if (!patch_fastweb_paths(repo)) {
-        std::cerr << "修正依赖路径失败。\n";
+    // Never run fastweb's own build.sh — it re-clones/rebuilds ylib.
+    // NGS already provides system ylib + HPSocket; only build fastweb itself.
+    if (!patch_ylib_link(repo, debug)) {
+        std::cerr << "修正 ylib 链接失败。\n";
         return false;
     }
 
-    const std::string prefix = install_dir();
-    ensure_dir(prefix);
-    ensure_dir(thirdparty_root());
-
-    // Clean previous cmake cache so path changes take effect.
     remove_path(join_path(repo, "build"));
 
-    std::cout << "编译安装 fastweb -> " << prefix
-              << " (" << (debug ? "Debug" : "Release") << ")...\n";
     const std::string build_type = debug ? "Debug" : "Release";
-    const std::string lib_dir = join_path(prefix, "lib");
-    const std::string tp = thirdparty_root();
-    if (run_cmd(
-            toolchain_env() +
-            "cd \"" + repo + "\" && "
-            "mkdir -p build && cd build && "
-            "cmake .. "
-            "-DCMAKE_BUILD_TYPE=" + build_type + " "
-            "-DCMAKE_INSTALL_PREFIX=\"" + prefix + "\" "
-            "-DCMAKE_PREFIX_PATH=\"" + tp + "\" "
-            "-DCMAKE_INCLUDE_PATH=\"" + join_path(tp, "include") + "\" "
-            "-DCMAKE_LIBRARY_PATH=\"" + hpsocket_lib_dir() + "\" "
-            "-DCMAKE_INSTALL_RPATH=\"" + lib_dir + "\" "
-            "-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON "
-            "-DCMAKE_INSTALL_RPATH_USE_LINK_PATH=ON && "
-            "make -j\"$(nproc)\" && make install") != 0) {
-        return false;
-    }
+    std::cout << "编译安装 fastweb -> " << system_prefix()
+              << " (" << build_type << ")...\n";
+    std::cout << "  (不执行 fastweb 自带 build.sh;直接 cmake/make/make install)\n";
 
-    // Ensure runtime can find libfastwebcore.so under prefix/lib.
-    const std::string bin = join_path(prefix, "bin/fastweb");
-    if (path_exists(bin)) {
-        if (command_exists("patchelf")) {
-            run_cmd("patchelf --set-rpath \"" + lib_dir + "\" \"" + bin + "\"", true);
-        } else {
-            // Fallback wrapper in prefix root for convenience.
-            std::ofstream wrap(join_path(prefix, "fastweb"));
-            if (wrap) {
-                wrap << "#!/usr/bin/env bash\n"
-                     << "DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n"
-                     << "export LD_LIBRARY_PATH=\"$DIR/lib:${LD_LIBRARY_PATH:-}\"\n"
-                     << "exec \"$DIR/bin/fastweb\" \"$@\"\n";
-                wrap.close();
-                run_cmd("chmod +x \"" + join_path(prefix, "fastweb") + "\"", false);
-            }
-        }
-    }
-    return true;
+    return run_cmd(
+               toolchain_env() +
+               "cd \"" + repo + "\" && "
+               "mkdir -p build && cd build && "
+               "cmake .. -DCMAKE_BUILD_TYPE=" + build_type + " && "
+               "make -j\"$(nproc)\" && make install") == 0;
+}
+
+void remove_legacy_ngs_install() {
+    remove_path(join_path(software_root(), "fastweb"));
+    remove_path(join_path(software_root(), "3rdparty"));
+    remove_path(join_path(software_root(), "lua54"));
+    remove_path(join_path(software_root(), "luarocks"));
+    remove_path(join_path(software_root(), ".build/fastweb"));
 }
 
 }  // namespace
 
 std::string install_dir() {
-    return join_path(software_root(), "fastweb");
+    return system_prefix();
 }
 
 std::string bin_path() {
@@ -303,7 +215,7 @@ std::string bin_path() {
 }
 
 std::string share_dir() {
-    return join_path(install_dir(), "share/fastweb");
+    return join_path(system_prefix(), "share/fastweb");
 }
 
 bool is_installed() {
@@ -311,7 +223,7 @@ bool is_installed() {
 }
 
 std::string installed_version() {
-    const std::string path = join_path(install_dir(), ".ngs_version");
+    const std::string path = version_file();
     if (path_exists(path)) {
         std::ifstream in(path);
         std::string ver;
@@ -323,13 +235,18 @@ std::string installed_version() {
 }
 
 void show_status() {
-    std::cout << "安装目录: " << install_dir() << "\n";
+    std::cout << "安装前缀: " << install_dir() << "\n";
     std::cout << "构建目录: " << build_root() << "\n";
     std::cout << "可执行文件: " << fastweb_bin() << "\n";
+    std::cout << "模板目录: " << share_dir() << "\n";
     std::cout << "Lua:      " << lua_prefix()
               << (lua_installed() ? " [已安装]" : " [未安装]") << "\n";
     std::cout << "LuaRocks: " << luarocks_prefix()
               << (luarocks_installed() ? " [已安装]" : " [未安装]") << "\n";
+    std::cout << "ylib:     "
+              << (ylib_present() ? "[系统已有]" : "[缺失,请先 ./build.sh]") << "\n";
+    std::cout << "HPSocket: "
+              << (hpsocket_present() ? "[系统已有]" : "[缺失,请先 ./build.sh]") << "\n";
     if (is_installed()) {
         std::cout << "状态: 已安装\n";
         std::cout << "版本: " << installed_version() << "\n";
@@ -345,10 +262,12 @@ bool install(bool debug) {
         return false;
     }
 
-    if (!ensure_dir(software_root()) || !ensure_dir(thirdparty_root())) {
-        std::cerr << "无法创建软件目录: " << software_root() << "\n";
+    if (!ylib_present() || !hpsocket_present()) {
+        std::cerr << "缺少系统依赖 ylib / HPSocket。\n"
+                  << "请先在 NGS 根目录执行 ./build.sh 完成初始化。\n";
         return false;
     }
+    std::cout << "检测到系统已安装 ylib / HPSocket,跳过重复编译。\n";
 
     std::cout << "清理并创建构建目录: " << build_root() << "\n";
     remove_path(build_root());
@@ -362,14 +281,8 @@ bool install(bool debug) {
         return false;
     }
 
-    const std::string third_party = join_path(build_root(), "3rdparty");
-    if (!ensure_dir(third_party)) {
-        std::cerr << "无法创建 3rdparty 目录。\n";
-        return false;
-    }
-
     if (!lua_installed()) {
-        if (!install_lua(third_party)) {
+        if (!install_lua(build_root())) {
             std::cerr << "Lua 安装失败。\n";
             return false;
         }
@@ -378,7 +291,7 @@ bool install(bool debug) {
     }
 
     if (!luarocks_installed()) {
-        if (!install_luarocks(third_party)) {
+        if (!install_luarocks(build_root())) {
             std::cerr << "LuaRocks 安装失败。\n";
             return false;
         }
@@ -386,53 +299,48 @@ bool install(bool debug) {
         std::cout << "LuaRocks 已安装,跳过。\n";
     }
 
-    if (!install_hpsocket(third_party)) {
-        std::cerr << "HPSocket 安装失败。\n";
-        return false;
-    }
-
-    if (!build_ylib(third_party, debug)) {
-        std::cerr << "ylib 编译失败。\n";
-        return false;
-    }
-
-    // Clean previous fastweb install prefix contents except we recreate via make install
-    if (!build_fastweb(build_root(), debug)) {
+    if (!build_fastweb(debug)) {
         std::cerr << "fastweb 编译安装失败。\n";
         return false;
     }
 
     write_version_file(kDefaultVersion, debug);
+    remove_legacy_ngs_install();
+
     std::cout << "\nFastweb " << kDefaultVersion << " 安装成功。\n";
     log_info(std::string("fastweb installed version=") + kDefaultVersion +
-             " debug=" + (debug ? "1" : "0"));
-    std::cout << "安装目录: " << install_dir() << "\n";
+             " debug=" + (debug ? "1" : "0") + " prefix=/usr/local");
+    std::cout << "安装前缀: " << install_dir() << "\n";
     std::cout << "可执行文件: " << fastweb_bin() << "\n";
+    std::cout << "模板目录: " << share_dir() << "\n";
     return true;
 }
 
 bool uninstall() {
-    const bool has_fw = is_installed() || is_dir(install_dir());
-    const bool has_lua = lua_installed() || is_dir(lua_prefix()) ||
-                         is_dir(join_path(software_root(), "lua54"));
-    const bool has_rocks = luarocks_installed() || is_dir(luarocks_prefix()) ||
-                           is_dir(join_path(software_root(), "luarocks"));
-    const bool has_tp = is_dir(thirdparty_root());
-
-    if (!has_fw && !has_lua && !has_rocks && !has_tp && !is_dir(build_root())) {
+    const bool has_fw = is_installed() || is_dir(share_dir());
+    const bool has_lua = lua_installed() || is_dir(lua_prefix());
+    const bool has_rocks = luarocks_installed() || is_dir(luarocks_prefix());
+    const bool has_build = is_dir(build_root());
+    const bool has_legacy = is_dir(join_path(software_root(), "fastweb")) ||
+                            is_dir(join_path(software_root(), "3rdparty"));
+
+    if (!has_fw && !has_lua && !has_rocks && !has_build && !has_legacy) {
         std::cout << "Fastweb 未安装,无需卸载。\n";
         return true;
     }
 
-    std::cout << "卸载 Fastweb 及相关组件...\n";
-    remove_path(install_dir());
-    remove_path(thirdparty_root());
-    // Legacy locations (before 3rdparty layout)
-    remove_path(join_path(software_root(), "lua54"));
-    remove_path(join_path(software_root(), "luarocks"));
-    remove_path(join_path(software_root(), "include"));
-    remove_path(join_path(software_root(), "lib"));
+    std::cout << "卸载 Fastweb(保留系统 ylib / HPSocket)...\n";
+
+    remove_path(fastweb_bin());
+    remove_path(join_path(system_prefix(), "lib/libfastwebcore.so"));
+    remove_path(share_dir());
+    remove_path(join_path(system_prefix(), "include/fastweb"));
+    remove_path(join_path(system_prefix(), "include/sol"));
+
+    remove_path(lua_prefix());
+    remove_path(luarocks_prefix());
     remove_path(build_root());
+    remove_legacy_ngs_install();
 
     std::cout << "Fastweb 已卸载。\n";
     log_info("fastweb uninstalled");
@@ -455,7 +363,9 @@ void menu() {
             case 2: {
                 const bool debug = (choice == 2);
                 std::cout << "\n确认安装 Fastweb " << kDefaultVersion
-                          << " 到 " << install_dir()
+                          << "\n  Lua/LuaRocks -> /opt"
+                          << "\n  Fastweb      -> /usr/local (cmake/make/make install)"
+                          << "\n  ylib/HPSocket 使用 NGS 已安装的系统库"
                           << " [" << (debug ? "Debug" : "Release") << "] ? [y/N]: ";
                 std::string confirm;
                 std::getline(std::cin, confirm);
@@ -468,7 +378,8 @@ void menu() {
                 break;
             }
             case 3: {
-                std::cout << "\n确认卸载 Fastweb(含 3rdparty/构建缓存)? [y/N]: ";
+                std::cout << "\n确认卸载 Fastweb(含 /opt/lua54、/opt/luarocks,"
+                             "不删除 ylib/HPSocket)? [y/N]: ";
                 std::string confirm;
                 std::getline(std::cin, confirm);
                 if (confirm == "y" || confirm == "Y") {

+ 1 - 1
src/software/mysql/mysql.cpp

@@ -82,7 +82,7 @@ std::string mysql_bin() {
 }
 
 std::string data_root() {
-    return join_path(home_dir(), "ngs/www/data/mysql");
+    return join_path(ngs_root(), "data/mysql");
 }
 
 std::string read_stored_version() {

+ 1 - 1
src/software/nginx/nginx.cpp

@@ -237,7 +237,7 @@ bool ensure_nginx_user() {
     }
     const std::string want = "user  " + username + ";";
 
-    // Replace commented/default user lines so workers can read ~/ngs/wwwroot.
+    // Replace commented/default user lines so workers can read /ngs/wwwroot.
     bool changed = false;
     std::istringstream iss(content);
     std::ostringstream oss;

+ 285 - 11
src/ssl/acme.cpp

@@ -4,8 +4,21 @@
 
 #include <cctype>
 #include <cstdlib>
+#include <filesystem>
+#include <fstream>
+#include <iomanip>
 #include <iostream>
 #include <sstream>
+#include <vector>
+
+#include <openssl/evp.h>
+#include <openssl/pem.h>
+#include <openssl/x509.h>
+
+#include <cstdio>
+#include <ctime>
+
+namespace fs = std::filesystem;
 
 namespace ngs {
 namespace ssl {
@@ -28,10 +41,115 @@ bool looks_like_ip(const std::string& d) {
     return d.find(':') != std::string::npos;  // IPv6-ish
 }
 
+std::string md5_hex(const std::string& input) {
+    unsigned char dig[EVP_MAX_MD_SIZE];
+    unsigned int len = 0;
+    if (EVP_Digest(input.data(), input.size(), dig, &len, EVP_md5(),
+                   nullptr) != 1 ||
+        len == 0) {
+        return "";
+    }
+    std::ostringstream oss;
+    oss << std::hex << std::setfill('0');
+    for (unsigned int i = 0; i < len; ++i) {
+        oss << std::setw(2) << static_cast<int>(dig[i]);
+    }
+    return oss.str();
+}
+
+std::string normalize_host(const std::string& domain) {
+    std::string host = domain;
+    const auto colon = host.rfind(':');
+    if (colon != std::string::npos && colon > 0 &&
+        host.find(':') == colon) {
+        bool all_digit = true;
+        for (size_t i = colon + 1; i < host.size(); ++i) {
+            if (!std::isdigit(static_cast<unsigned char>(host[i]))) {
+                all_digit = false;
+                break;
+            }
+        }
+        if (all_digit) {
+            host = host.substr(0, colon);
+        }
+    }
+    for (char& c : host) {
+        c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+    }
+    return host;
+}
+
+// acme.sh sources account.conf in _initpath() and overwrites --accountemail.
+// Keep conf files in sync so LE registration uses the intended contact.
+bool set_conf_kv(const std::string& path, const std::string& key,
+                 const std::string& value) {
+    std::vector<std::string> lines;
+    bool found = false;
+    if (path_exists(path)) {
+        std::ifstream in(path);
+        std::string line;
+        while (std::getline(in, line)) {
+            const std::string prefix = key + "=";
+            if (line.rfind(prefix, 0) == 0 ||
+                line.rfind("#" + prefix, 0) == 0) {
+                lines.push_back(key + "='" + value + "'");
+                found = true;
+            } else {
+                lines.push_back(line);
+            }
+        }
+    }
+    if (!found) {
+        lines.push_back(key + "='" + value + "'");
+    }
+    const std::string parent = fs::path(path).parent_path().string();
+    if (!parent.empty() && !ensure_dir(parent)) {
+        return false;
+    }
+    std::ofstream out(path, std::ios::trunc);
+    if (!out) {
+        return false;
+    }
+    for (const auto& l : lines) {
+        out << l << "\n";
+    }
+    return static_cast<bool>(out);
+}
+
+bool sync_account_email(const std::string& email) {
+    if (!is_valid_acme_email(email)) {
+        return false;
+    }
+    const std::string account_conf = join_path(acme_home(), "account.conf");
+    if (!set_conf_kv(account_conf, "ACCOUNT_EMAIL", email)) {
+        return false;
+    }
+    // Clear stale CA_EMAIL under ca/<server>/...
+    const std::string ca_root = join_path(acme_home(), "ca");
+    if (!is_dir(ca_root)) {
+        return true;
+    }
+    std::error_code ec;
+    for (fs::recursive_directory_iterator it(ca_root, ec), end;
+         !ec && it != end; it.increment(ec)) {
+        if (ec) {
+            break;
+        }
+        if (!it->is_regular_file()) {
+            continue;
+        }
+        if (it->path().filename() != "ca.conf") {
+            continue;
+        }
+        set_conf_kv(it->path().string(), "CA_EMAIL", email);
+    }
+    return true;
+}
+
 }  // namespace
 
 std::string acme_webroot() {
-    return join_path(www_root(), "acme");
+    return join_path(ngs_root(), "acme");
 }
 
 std::string acme_home() {
@@ -42,6 +160,130 @@ std::string acme_bin() {
     return join_path(acme_home(), "acme.sh");
 }
 
+std::string certs_root() {
+    return join_path(conf_root(), "ssl");
+}
+
+std::string cert_dir_for_domain(const std::string& domain) {
+    return join_path(certs_root(), domain);
+}
+
+bool is_valid_acme_email(const std::string& email) {
+    const auto at = email.find('@');
+    if (at == std::string::npos || at == 0 || at + 1 >= email.size()) {
+        return false;
+    }
+    const std::string local = email.substr(0, at);
+    const std::string host = email.substr(at + 1);
+    if (local.empty() || host.empty()) {
+        return false;
+    }
+    if (host.find('.') == std::string::npos) {
+        return false;
+    }
+    if (host == "localhost" || host.find("localhost.") == 0) {
+        return false;
+    }
+    // Reject non-public suffixes (.local / .internal / .lan / .test ...).
+    const auto dot = host.rfind('.');
+    if (dot == std::string::npos || dot + 1 >= host.size()) {
+        return false;
+    }
+    std::string tld = host.substr(dot + 1);
+    for (char& c : tld) {
+        c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+    }
+    if (tld == "local" || tld == "localhost" || tld == "internal" ||
+        tld == "lan" || tld == "home" || tld == "corp" || tld == "test" ||
+        tld == "invalid" || tld == "example" || tld.size() < 2) {
+        return false;
+    }
+    for (char c : tld) {
+        if (!std::isalpha(static_cast<unsigned char>(c))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+std::string default_account_email(const std::string& domain) {
+    const std::string host = normalize_host(domain);
+    if (host.empty() || looks_like_ip(host) || host == "localhost" ||
+        host.find("localhost.") == 0) {
+        return "";
+    }
+    const std::string digest = md5_hex(host);
+    if (digest.empty()) {
+        return "";
+    }
+    return digest + "@qq.com";
+}
+
+namespace {
+
+bool asn1_time_to_text(const ASN1_TIME* t, std::string& text) {
+    if (!t) {
+        return false;
+    }
+    std::tm tm{};
+    if (ASN1_TIME_to_tm(t, &tm) != 1) {
+        return false;
+    }
+    char buf[64];
+    if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S UTC", &tm) == 0) {
+        return false;
+    }
+    text = buf;
+    return true;
+}
+
+}  // namespace
+
+CertInfo read_cert_info(const std::string& pem_path) {
+    CertInfo info;
+    if (pem_path.empty() || !path_exists(pem_path)) {
+        return info;
+    }
+    FILE* fp = std::fopen(pem_path.c_str(), "r");
+    if (!fp) {
+        return info;
+    }
+    X509* cert = PEM_read_X509(fp, nullptr, nullptr, nullptr);
+    std::fclose(fp);
+    if (!cert) {
+        return info;
+    }
+    const ASN1_TIME* nb = X509_get0_notBefore(cert);
+    const ASN1_TIME* na = X509_get0_notAfter(cert);
+    if (!asn1_time_to_text(nb, info.not_before) ||
+        !asn1_time_to_text(na, info.not_after)) {
+        X509_free(cert);
+        return info;
+    }
+    std::tm tm_nb{};
+    std::tm tm_na{};
+    if (ASN1_TIME_to_tm(nb, &tm_nb) == 1) {
+        info.not_before_unix = static_cast<int64_t>(timegm(&tm_nb));
+    }
+    if (ASN1_TIME_to_tm(na, &tm_na) == 1) {
+        info.not_after_unix = static_cast<int64_t>(timegm(&tm_na));
+    }
+    if (info.not_after_unix > 0) {
+        const int64_t now = static_cast<int64_t>(std::time(nullptr));
+        info.days_left =
+            static_cast<int>((info.not_after_unix - now) / 86400);
+    } else {
+        int days = 0;
+        int secs = 0;
+        if (ASN1_TIME_diff(&days, &secs, nullptr, na) == 1) {
+            info.days_left = days;
+        }
+    }
+    info.ok = true;
+    X509_free(cert);
+    return info;
+}
+
 bool ensure_acme_client(const std::string& email, std::string& err) {
     if (!ensure_dir(acme_home())) {
         err = "无法创建 acme 目录: " + acme_home();
@@ -58,7 +300,7 @@ bool ensure_acme_client(const std::string& email, std::string& err) {
     std::cout << "正在安装 acme.sh (Let's Encrypt 客户端)...\n";
     log_info("installing acme.sh home=" + acme_home());
     const std::string mail =
-        email.empty() ? "noreply@localhost" : email;
+        is_valid_acme_email(email) ? email : std::string("ngs@qq.com");
 
     // Prefer China mirrors (get.acme.sh / GitHub is often very slow).
     // See: https://github.com/acmesh-official/acme.sh/wiki/Install-in-China
@@ -134,17 +376,28 @@ bool issue_letsencrypt(const std::vector<std::string>& domains,
         return false;
     }
 
+    const std::string account_email =
+        is_valid_acme_email(email) ? email
+                                   : default_account_email(domains.front());
+    if (account_email.empty() || !is_valid_acme_email(account_email)) {
+        err = "无法生成有效的 ACME 联系邮箱";
+        return false;
+    }
+    // Critical: acme.sh sources account.conf and overwrites CLI --accountemail.
+    if (!sync_account_email(account_email)) {
+        err = "无法写入 acme.sh ACCOUNT_EMAIL";
+        return false;
+    }
+
     // Prefer Let's Encrypt production CA.
     std::ostringstream issue;
     issue << "\"" << acme_bin() << "\" --home \"" << acme_home() << "\""
-          << " --issue --server letsencrypt";
-    if (!email.empty()) {
-        issue << " --accountemail \"" << email << "\"";
-    }
+          << " --issue --server letsencrypt"
+          << " --accountemail \"" << account_email << "\"";
     for (const auto& d : domains) {
         issue << " -d \"" << d << "\"";
     }
-    issue << " -w \"" << acme_webroot() << "\" --force";
+    issue << " -w \"" << acme_webroot() << "\" --force 2>&1";
 
     std::cout << "申请证书: ";
     for (size_t i = 0; i < domains.size(); ++i) {
@@ -155,11 +408,32 @@ bool issue_letsencrypt(const std::vector<std::string>& domains,
     }
     std::cout << "\n";
     log_info("acme issue domains=" + domains.front() +
-             " count=" + std::to_string(domains.size()));
+             " count=" + std::to_string(domains.size()) +
+             " email=" + account_email);
 
-    if (run_cmd(issue.str(), true) != 0) {
-        err = "证书申请失败。请确认:域名已解析到本机、80 端口可从公网访问、"
-              "Nginx 已正确提供 ACME 校验路径";
+    std::string issue_out;
+    if (run_cmd_capture(issue.str(), issue_out) != 0) {
+        // Also print captured output so task logs stay useful.
+        if (!issue_out.empty()) {
+            std::cout << issue_out;
+            if (issue_out.back() != '\n') {
+                std::cout << "\n";
+            }
+        }
+        if (issue_out.find("invalidContact") != std::string::npos ||
+            issue_out.find("invalid domain") != std::string::npos) {
+            err = "ACME 联系邮箱无效(Let's Encrypt 要求公网域名邮箱)。"
+                  "当前: " +
+                  (account_email.empty() ? "(空)" : account_email);
+        } else if (issue_out.find("Connection refused") != std::string::npos ||
+                   issue_out.find("timeout") != std::string::npos ||
+                   issue_out.find("Fetch error") != std::string::npos) {
+            err = "证书申请失败:HTTP-01 校验未通过。请确认域名已解析到本机、"
+                  "80 端口可从公网访问、Nginx 已正确提供 ACME 校验路径";
+        } else {
+            err = "证书申请失败。请确认:域名已解析到本机、80 端口可从公网访问、"
+                  "Nginx 已正确提供 ACME 校验路径";
+        }
         return false;
     }
 

+ 23 - 2
src/ssl/acme.h

@@ -7,12 +7,33 @@
 namespace ngs {
 namespace ssl {
 
-// Shared HTTP-01 webroot: <www>/acme
+// Shared HTTP-01 webroot: /ngs/acme
 std::string acme_webroot();
-// acme.sh home: <software>/acme
+// acme.sh home: /ngs/software/acme
 std::string acme_home();
 std::string acme_bin();
 
+// Issued certs: /ngs/conf/ssl/<domain>/{fullchain.pem,privkey.pem}
+std::string certs_root();
+std::string cert_dir_for_domain(const std::string& domain);
+
+// Let's Encrypt requires a contact email with a public-suffix TLD.
+bool is_valid_acme_email(const std::string& email);
+// Auto account email: md5(<domain>)@qq.com
+std::string default_account_email(const std::string& domain);
+
+struct CertInfo {
+    bool ok = false;
+    std::string not_before;  // e.g. 2026-08-11 07:29:02 UTC
+    std::string not_after;
+    int64_t not_before_unix = 0;
+    int64_t not_after_unix = 0;
+    int days_left = 0;  // floor((not_after - now) / 86400)
+};
+
+// Read leaf cert dates from a PEM file (fullchain ok: uses first cert).
+CertInfo read_cert_info(const std::string& pem_path);
+
 bool ensure_acme_client(const std::string& email, std::string& err);
 
 // Issue Let's Encrypt cert (HTTP-01) for domains into cert_file/key_file.

+ 35 - 13
src/store/store.cpp

@@ -9,6 +9,7 @@
 #include <iostream>
 #include <mutex>
 #include <sstream>
+#include <vector>
 
 namespace fs = std::filesystem;
 
@@ -102,6 +103,7 @@ CREATE TABLE IF NOT EXISTS meta (
     add_column_if_missing("websites", "ssl_cert", "TEXT NOT NULL DEFAULT ''");
     add_column_if_missing("websites", "ssl_key", "TEXT NOT NULL DEFAULT ''");
     add_column_if_missing("websites", "ssl_port", "INTEGER NOT NULL DEFAULT 443");
+    add_column_if_missing("websites", "root", "TEXT NOT NULL DEFAULT ''");
     return true;
 }
 
@@ -179,9 +181,11 @@ void migrate_mysql_accounts_from_file() {
         return;
     }
     // Prefer versioned data dir used by mysql module.
+    // Include legacy ~/ngs/www and /ngs/www paths for one-time migration.
     std::vector<std::string> candidates = {
+        join_path(ngs_root(), "data/mysql/8.0.35/.ngs_accounts"),
+        join_path(ngs_root(), "www/data/mysql/8.0.35/.ngs_accounts"),
         join_path(home_dir(), "ngs/www/data/mysql/8.0.35/.ngs_accounts"),
-        join_path(www_root(), "data/mysql/8.0.35/.ngs_accounts"),
     };
     std::string path;
     for (const auto& c : candidates) {
@@ -238,11 +242,26 @@ void migrate_mysql_settings_from_file() {
     if (meta_get("migrated_mysql_settings") == "1") {
         return;
     }
-    const std::string root_pw =
-        join_path(home_dir(), "ngs/www/software/mysql/.ngs_root_password");
-    const std::string listen =
-        join_path(home_dir(), "ngs/www/software/mysql/.ngs_listen");
-    if (path_exists(root_pw)) {
+    // Prefer /ngs; fall back to legacy /ngs/www and ~/ngs for migration.
+    auto first_existing = [](const std::vector<std::string>& paths) {
+        for (const auto& p : paths) {
+            if (path_exists(p)) {
+                return p;
+            }
+        }
+        return std::string{};
+    };
+    const std::string root_pw = first_existing({
+        join_path(software_root(), "mysql/.ngs_root_password"),
+        join_path(ngs_root(), "www/software/mysql/.ngs_root_password"),
+        join_path(home_dir(), "ngs/www/software/mysql/.ngs_root_password"),
+    });
+    const std::string listen = first_existing({
+        join_path(software_root(), "mysql/.ngs_listen"),
+        join_path(ngs_root(), "www/software/mysql/.ngs_listen"),
+        join_path(home_dir(), "ngs/www/software/mysql/.ngs_listen"),
+    });
+    if (!root_pw.empty()) {
         std::ifstream in(root_pw);
         std::string v;
         if (in && std::getline(in, v)) {
@@ -251,7 +270,7 @@ void migrate_mysql_settings_from_file() {
                      ")");
         }
     }
-    if (path_exists(listen)) {
+    if (!listen.empty()) {
         std::ifstream in(listen);
         std::string v;
         if (in && std::getline(in, v) && !v.empty()) {
@@ -276,7 +295,7 @@ std::string database_path() {
     if (path_exists("data") || ensure_dir("data")) {
         return fs::absolute("data/database.db").string();
     }
-    return join_path(www_root(), "database.db");
+    return join_path(ngs_root(), "database.db");
 #endif
 }
 
@@ -322,6 +341,7 @@ std::vector<SiteRecord> list_sites() {
     SQLITE_RESULT rows;
     if (!g_db.query(
             "SELECT name,type,domain,listen_port,upstream,"
+            "COALESCE(root,'') AS root,"
             "COALESCE(ssl_enable,0) AS ssl_enable,"
             "COALESCE(ssl_cert,'') AS ssl_cert,"
             "COALESCE(ssl_key,'') AS ssl_key,"
@@ -342,6 +362,7 @@ std::vector<SiteRecord> list_sites() {
             s.listen_port = 0;
         }
         s.upstream = row.count("upstream") ? row.at("upstream") : "";
+        s.root = row.count("root") ? row.at("root") : "";
         try {
             s.ssl_enable =
                 row.count("ssl_enable") ? (std::stoi(row.at("ssl_enable")) != 0) : false;
@@ -365,17 +386,18 @@ bool upsert_site(const SiteRecord& site) {
         return false;
     }
     return exec_sql(
-        "INSERT INTO websites(name,type,domain,listen_port,upstream,"
+        "INSERT INTO websites(name,type,domain,listen_port,upstream,root,"
         "ssl_enable,ssl_cert,ssl_key,ssl_port) VALUES(" +
         sql_quote(site.name) + "," + sql_quote(site.type) + "," +
         sql_quote(site.domain) + "," + std::to_string(site.listen_port) + "," +
-        sql_quote(site.upstream) + "," +
+        sql_quote(site.upstream) + "," + sql_quote(site.root) + "," +
         std::to_string(site.ssl_enable ? 1 : 0) + "," +
         sql_quote(site.ssl_cert) + "," + sql_quote(site.ssl_key) + "," +
         std::to_string(site.ssl_port) +
         ") ON CONFLICT(name) DO UPDATE SET type=excluded.type,"
         "domain=excluded.domain,listen_port=excluded.listen_port,"
-        "upstream=excluded.upstream,ssl_enable=excluded.ssl_enable,"
+        "upstream=excluded.upstream,root=excluded.root,"
+        "ssl_enable=excluded.ssl_enable,"
         "ssl_cert=excluded.ssl_cert,ssl_key=excluded.ssl_key,"
         "ssl_port=excluded.ssl_port");
 }
@@ -402,11 +424,11 @@ bool replace_sites(const std::vector<SiteRecord>& sites) {
     }
     for (const auto& site : sites) {
         if (!exec_sql(
-                "INSERT INTO websites(name,type,domain,listen_port,upstream,"
+                "INSERT INTO websites(name,type,domain,listen_port,upstream,root,"
                 "ssl_enable,ssl_cert,ssl_key,ssl_port) VALUES(" +
                 sql_quote(site.name) + "," + sql_quote(site.type) + "," +
                 sql_quote(site.domain) + "," + std::to_string(site.listen_port) +
-                "," + sql_quote(site.upstream) + "," +
+                "," + sql_quote(site.upstream) + "," + sql_quote(site.root) + "," +
                 std::to_string(site.ssl_enable ? 1 : 0) + "," +
                 sql_quote(site.ssl_cert) + "," + sql_quote(site.ssl_key) + "," +
                 std::to_string(site.ssl_port) + ")")) {

+ 1 - 0
src/store/store.h

@@ -13,6 +13,7 @@ struct SiteRecord {
     std::string domain;
     int listen_port = 0;
     std::string upstream;
+    std::string root;
     bool ssl_enable = false;
     std::string ssl_cert;
     std::string ssl_key;

+ 25 - 12
src/utils.cpp

@@ -83,24 +83,29 @@ std::string expand_path(const std::string& path) {
     return path;
 }
 
+std::string ngs_root() {
+    return "/ngs";
+}
+
 std::string software_root() {
-    return join_path(home_dir(), "ngs/www/software");
+    return join_path(ngs_root(), "software");
 }
 
 std::string conf_root() {
-    return join_path(home_dir(), "ngs/www/conf");
+    return join_path(ngs_root(), "conf");
 }
 
 std::string www_root() {
-    return join_path(home_dir(), "ngs/www");
+    // Runtime data lives directly under /ngs (no /www layer).
+    return ngs_root();
 }
 
 std::string wwwroot_dir() {
-    return join_path(www_root(), "wwwroot");
+    return join_path(ngs_root(), "wwwroot");
 }
 
 std::string sites_log_dir() {
-    return join_path(www_root(), "log");
+    return join_path(ngs_root(), "log");
 }
 
 std::string log_file_path() {
@@ -123,7 +128,7 @@ std::string panel_www_dir() {
     if (is_dir("../data/www")) {
         return fs::absolute("../data/www").string();
     }
-    return join_path(www_root(), "panel");
+    return join_path(ngs_root(), "panel");
 #endif
 }
 
@@ -339,18 +344,26 @@ int run_cmd_capture(const std::string& cmd, std::string& output) {
     output.clear();
     log_write("CMD", "capture: " + cmd);
     std::array<char, 256> buf{};
-    std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd.c_str(), "r"),
-                                                  pclose);
-    if (!pipe) {
+    FILE* fp = popen(cmd.c_str(), "r");
+    if (!fp) {
         log_error("popen failed: " + cmd);
         return -1;
     }
-    while (fgets(buf.data(), static_cast<int>(buf.size()), pipe.get()) !=
-           nullptr) {
+    while (fgets(buf.data(), static_cast<int>(buf.size()), fp) != nullptr) {
         output += buf.data();
     }
+    const int status = pclose(fp);
     log_write("OUT", rtrim_copy(output));
-    return 0;
+    int rc = -1;
+    if (status == -1) {
+        rc = -1;
+    } else if (WIFEXITED(status)) {
+        rc = WEXITSTATUS(status);
+    } else if (WIFSIGNALED(status)) {
+        rc = 128 + WTERMSIG(status);
+    }
+    log_write("CMD", "exit=" + std::to_string(rc) + " cmd=" + cmd);
+    return rc;
 }
 
 bool is_root() {

+ 4 - 1
src/utils.h

@@ -8,6 +8,9 @@ namespace ngs {
 
 std::string home_dir();
 std::string expand_path(const std::string& path);
+// Runtime project root: /ngs (not ~/ngs).
+// Layout: /ngs/{software,conf,wwwroot,log,data,acme,...} — no /www layer.
+std::string ngs_root();
 std::string software_root();
 std::string conf_root();
 std::string www_root();
@@ -22,7 +25,7 @@ bool is_dir(const std::string& path);
 bool ensure_dir(const std::string& path);
 bool remove_path(const std::string& path);
 
-// Append a line to www/conf/ngs.log. level e.g. INFO/WARN/ERROR/CMD/OUT.
+// Append a line to /ngs/conf/ngs.log. level e.g. INFO/WARN/ERROR/CMD/OUT.
 void log_write(const std::string& level, const std::string& message);
 void log_info(const std::string& message);
 void log_warn(const std::string& message);

+ 26 - 14
src/weblog/weblog.cpp

@@ -51,17 +51,22 @@ std::thread*& worker() {
 }
 
 bool is_safe_site_name(const std::string& s) {
-    if (s.empty() || s.size() > 64) {
+    // Log DB key is the site domain host.
+    if (s.empty() || s.size() > 128) {
         return false;
     }
     for (unsigned char c : s) {
-        if (!(std::isalnum(c) || c == '_' || c == '-' || c == '.')) {
+        if (!(std::isalnum(c) || c == '_' || c == '-' || c == '.' || c == '*')) {
             return false;
         }
     }
     return true;
 }
 
+std::string resolve_site_key(const std::string& name_or_domain) {
+    return website::site_key_by_name(name_or_domain);
+}
+
 std::string sql_quote(const std::string& s) {
     std::string out;
     out.reserve(s.size() + 8);
@@ -555,10 +560,14 @@ void ingest_file(ylib::sqlite3& db, const std::string& path,
     }
 }
 
-void ingest_site(const std::string& name) {
+void ingest_site(const website::SiteInfo& site) {
+    const std::string key = website::site_key(site);
+    if (key.empty()) {
+        return;
+    }
     std::string err;
     ylib::sqlite3 db;
-    if (!open_site_db(name, db, err)) {
+    if (!open_site_db(key, db, err)) {
         return;
     }
     if (!ensure_schema(db)) {
@@ -566,7 +575,7 @@ void ingest_site(const std::string& name) {
         return;
     }
 
-    const std::string log_dir = website::site_log_dir_path(name);
+    const std::string log_dir = join_path(sites_log_dir(), key);
     ingest_file(db, join_path(log_dir, "access.log"), "offset", "inode",
                 "size", handle_access_line);
     ingest_file(db, join_path(log_dir, "error.log"), "error_offset",
@@ -680,7 +689,7 @@ void ingest_once() {
     }
     for (const auto& s : sites) {
         std::lock_guard<std::mutex> lock(mu());
-        ingest_site(s.name);
+        ingest_site(s);
     }
 }
 
@@ -694,7 +703,7 @@ void start() {
     }
     for (const auto& s : website::list_sites()) {
         std::string err;
-        ensure_db(s.name, err);
+        ensure_db(website::site_key(s), err);
     }
     worker() = new std::thread(worker_loop);
     log_info("weblog ingest started dir=" + db_dir());
@@ -718,7 +727,8 @@ void stop() {
 bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
                   std::string& err) {
     rows.clear();
-    if (!is_safe_site_name(q.name)) {
+    const std::string key = resolve_site_key(q.name);
+    if (!is_safe_site_name(key)) {
         err = "无效站点名";
         return false;
     }
@@ -726,7 +736,7 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
 
     std::lock_guard<std::mutex> lock(mu());
     ylib::sqlite3 db;
-    if (!open_site_db(q.name, db, err)) {
+    if (!open_site_db(key, db, err)) {
         return false;
     }
     if (!ensure_schema(db)) {
@@ -787,7 +797,8 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
 bool query_error(const ErrorQuery& q, std::vector<ErrorRow>& rows,
                  std::string& err) {
     rows.clear();
-    if (!is_safe_site_name(q.name)) {
+    const std::string key = resolve_site_key(q.name);
+    if (!is_safe_site_name(key)) {
         err = "无效站点名";
         return false;
     }
@@ -795,7 +806,7 @@ bool query_error(const ErrorQuery& q, std::vector<ErrorRow>& rows,
 
     std::lock_guard<std::mutex> lock(mu());
     ylib::sqlite3 db;
-    if (!open_site_db(q.name, db, err)) {
+    if (!open_site_db(key, db, err)) {
         return false;
     }
     if (!ensure_schema(db)) {
@@ -884,7 +895,8 @@ ylib::json rows_to_kv_array(const SQLITE_RESULT& rows, const std::string& key_co
 bool analyze(const std::string& name, const std::string& range,
              std::string& json_out, std::string& err) {
     json_out.clear();
-    if (!is_safe_site_name(name)) {
+    const std::string key = resolve_site_key(name);
+    if (!is_safe_site_name(key)) {
         err = "无效站点名";
         return false;
     }
@@ -894,7 +906,7 @@ bool analyze(const std::string& name, const std::string& range,
 
     std::lock_guard<std::mutex> lock(mu());
     ylib::sqlite3 db;
-    if (!open_site_db(name, db, err)) {
+    if (!open_site_db(key, db, err)) {
         return false;
     }
     if (!ensure_schema(db)) {
@@ -907,7 +919,7 @@ bool analyze(const std::string& name, const std::string& range,
     out["name"] = name;
     out["range"] = range.empty() ? "24h" : range;
     out["since_ms"] = static_cast<int64>(since);
-    out["log_db"] = db_path(name);
+    out["log_db"] = db_path(key);
 
     // ---- summary ----
     {

+ 229 - 44
src/website/website.cpp

@@ -37,15 +37,70 @@ std::string vhost_dir() {
     return join_path(conf_root(), "vhost");
 }
 
+// Stable id for files/nginx/logs: primary domain host.
+std::string site_id(const SiteInfo& s) {
+    const std::string host = primary_domain(s.domain);
+    if (!host.empty()) {
+        // primary_domain may return host:port — strip port.
+        const auto colon = host.rfind(':');
+        if (colon != std::string::npos && colon > 0) {
+            bool all_digit = true;
+            for (size_t i = colon + 1; i < host.size(); ++i) {
+                if (!std::isdigit(static_cast<unsigned char>(host[i]))) {
+                    all_digit = false;
+                    break;
+                }
+            }
+            if (all_digit) {
+                return host.substr(0, colon);
+            }
+        }
+        return host;
+    }
+    return s.name;
+}
+
 std::string site_root(const std::string& name) {
+    for (const auto& r : store::list_sites()) {
+        if (r.name == name) {
+            if (!r.root.empty()) {
+                return expand_path(r.root);
+            }
+            SiteInfo tmp;
+            tmp.name = r.name;
+            tmp.domain = r.domain;
+            tmp.root = r.root;
+            const std::string id = site_id(tmp);
+            if (!id.empty()) {
+                return join_path(wwwroot_dir(), id);
+            }
+            break;
+        }
+    }
     return join_path(wwwroot_dir(), name);
 }
 
+std::string vhost_file(const SiteInfo& s) {
+    return join_path(vhost_dir(), site_id(s) + ".conf");
+}
+
+std::string vhost_disabled_file(const SiteInfo& s) {
+    return join_path(vhost_dir(), site_id(s) + ".conf.disabled");
+}
+
 std::string vhost_file(const std::string& name) {
+    SiteInfo s;
+    if (find_site_info(name, s)) {
+        return vhost_file(s);
+    }
     return join_path(vhost_dir(), name + ".conf");
 }
 
 std::string vhost_disabled_file(const std::string& name) {
+    SiteInfo s;
+    if (find_site_info(name, s)) {
+        return vhost_disabled_file(s);
+    }
     return join_path(vhost_dir(), name + ".conf.disabled");
 }
 
@@ -164,15 +219,20 @@ SiteType parse_type(const std::string& key) {
 }
 
 bool is_safe_name(const std::string& s) {
-    if (s.empty() || s.size() > 64) {
+    // Display label only (may be Chinese). Not used for paths / nginx.
+    if (s.empty() || s.size() > 128) {
         return false;
     }
+    bool any_visible = false;
     for (unsigned char c : s) {
-        if (!(std::isalnum(c) || c == '_' || c == '-' || c == '.')) {
+        if (c < 0x20 || c == 0x7f) {
             return false;
         }
+        if (!std::isspace(c)) {
+            any_visible = true;
+        }
     }
-    return true;
+    return any_visible;
 }
 
 bool is_safe_domain(const std::string& s) {
@@ -408,6 +468,7 @@ std::vector<SiteInfo> load_sites() {
         s.domain = r.domain;
         s.listen_port = r.listen_port;
         s.upstream = r.upstream;
+        s.root = r.root;
         s.ssl_enable = r.ssl_enable;
         s.ssl_cert = r.ssl_cert;
         s.ssl_key = r.ssl_key;
@@ -427,6 +488,7 @@ bool save_sites(const std::vector<SiteInfo>& list) {
         r.domain = s.domain;
         r.listen_port = s.listen_port;
         r.upstream = s.upstream;
+        r.root = s.root;
         r.ssl_enable = s.ssl_enable;
         r.ssl_cert = s.ssl_cert;
         r.ssl_key = s.ssl_key;
@@ -510,16 +572,21 @@ void print_site_list() {
     }
 }
 
-std::string site_nginx_log_dir(const std::string& name) {
-    return join_path(sites_log_dir(), name);
+std::string site_nginx_log_dir(const SiteInfo& s) {
+    return join_path(sites_log_dir(), site_id(s));
+}
+
+std::string nginx_quote_path(const std::string& path) {
+    return "\"" + path + "\"";
 }
 
 std::string nginx_log_block(const SiteInfo& s) {
-    const std::string dir = site_nginx_log_dir(s.name);
+    const std::string dir = site_nginx_log_dir(s);
     std::ostringstream out;
-    out << "    access_log   " << join_path(dir, "access.log")
+    out << "    access_log   " << nginx_quote_path(join_path(dir, "access.log"))
         << "  ngs_detail;\n"
-        << "    error_log    " << join_path(dir, "error.log") << "  warn;\n";
+        << "    error_log    " << nginx_quote_path(join_path(dir, "error.log"))
+        << "  warn;\n";
     return out.str();
 }
 
@@ -528,7 +595,8 @@ std::string nginx_acme_location() {
     std::ostringstream out;
     out << "    location ^~ /.well-known/acme-challenge/ {\n"
         << "        default_type \"text/plain\";\n"
-        << "        root         " << ssl::acme_webroot() << ";\n"
+        << "        root         " << nginx_quote_path(ssl::acme_webroot())
+        << ";\n"
         << "    }\n";
     return out.str();
 }
@@ -551,8 +619,10 @@ std::string nginx_listen_and_ssl(const SiteInfo& s) {
     if (s.ssl_enable && !s.ssl_cert.empty() && !s.ssl_key.empty()) {
         const int ssl_port = s.ssl_port > 0 ? s.ssl_port : 443;
         out << "    listen       " << ssl_port << " ssl;\n"
-            << "    ssl_certificate     " << s.ssl_cert << ";\n"
-            << "    ssl_certificate_key " << s.ssl_key << ";\n";
+            << "    ssl_certificate     " << nginx_quote_path(s.ssl_cert)
+            << ";\n"
+            << "    ssl_certificate_key " << nginx_quote_path(s.ssl_key)
+            << ";\n";
     }
     return out.str();
 }
@@ -578,7 +648,7 @@ std::string nginx_static_conf(const SiteInfo& s) {
         << "server {\n"
         << nginx_listen_and_ssl(s)
         << "    server_name  " << nginx_server_names(s) << ";\n"
-        << "    root         " << site_root(s.name) << ";\n"
+        << "    root         " << nginx_quote_path(site_dir(s)) << ";\n"
         << "    index        index.html index.htm;\n"
         << nginx_acme_location()
         << "    location / {\n"
@@ -622,8 +692,8 @@ bool write_vhost(const SiteInfo& s) {
     if (!ensure_dir(vhost_dir())) {
         return false;
     }
-    if (!ensure_dir(site_nginx_log_dir(s.name))) {
-        std::cerr << "无法创建站点日志目录: " << site_nginx_log_dir(s.name)
+    if (!ensure_dir(site_nginx_log_dir(s))) {
+        std::cerr << "无法创建站点日志目录: " << site_nginx_log_dir(s)
                   << "\n";
         return false;
     }
@@ -634,9 +704,9 @@ bool write_vhost(const SiteInfo& s) {
         // proxy + fastweb both use reverse proxy block
         body = nginx_proxy_conf(s);
     }
-    std::string path = vhost_file(s.name);
-    if (!path_exists(path) && path_exists(vhost_disabled_file(s.name))) {
-        path = vhost_disabled_file(s.name);
+    std::string path = vhost_file(s);
+    if (!path_exists(path) && path_exists(vhost_disabled_file(s))) {
+        path = vhost_disabled_file(s);
     }
     return write_text_file(path, body);
 }
@@ -652,7 +722,7 @@ bool apply_nginx() {
 }
 
 bool create_static_site(const SiteInfo& s) {
-    const std::string root = site_root(s.name);
+    const std::string root = site_dir(s);
     if (!ensure_dir(root)) {
         std::cerr << "无法创建网站目录: " << root << "\n";
         return false;
@@ -680,7 +750,7 @@ bool create_static_site(const SiteInfo& s) {
 }
 
 bool create_proxy_site(const SiteInfo& s) {
-    const std::string root = site_root(s.name);
+    const std::string root = site_dir(s);
     if (!ensure_dir(root)) {
         std::cerr << "无法创建网站目录: " << root << "\n";
         return false;
@@ -700,7 +770,7 @@ bool create_fastweb_site(SiteInfo& s) {
         return false;
     }
 
-    const std::string root = site_root(s.name);
+    const std::string root = site_dir(s);
     if (is_dir(root) && path_exists(join_path(root, "config.ini"))) {
         std::cerr << "网站目录已存在: " << root << "\n";
         return false;
@@ -785,9 +855,14 @@ bool create_fastweb_site(SiteInfo& s) {
 
 bool register_site(const SiteInfo& s) {
     auto list = load_sites();
+    const std::string id = site_id(s);
     for (const auto& old : list) {
         if (old.name == s.name) {
-            std::cerr << "网站已存在: " << s.name << "\n";
+            std::cerr << "网站名称已存在: " << s.name << "\n";
+            return false;
+        }
+        if (!id.empty() && site_id(old) == id) {
+            std::cerr << "域名已存在: " << id << "\n";
             return false;
         }
     }
@@ -802,22 +877,57 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
         return false;
     }
     if (!is_safe_name(req.name)) {
-        err = "名称仅允许字母、数字、._-";
+        err = "请填写网站名称";
         return false;
     }
     SiteInfo exists;
-    if (find_site_info(req.name, exists) || is_dir(site_root(req.name))) {
-        err = "网站已存在: " + req.name;
+    if (find_site_info(req.name, exists)) {
+        err = "网站名称已存在: " + req.name;
         return false;
     }
 
-    std::string domain_raw = req.domain.empty() ? req.name : req.domain;
+    std::string domain_raw = req.domain;
+    // Create form only allows a single domain.
+    {
+        std::string trimmed = domain_raw;
+        while (!trimmed.empty() &&
+               std::isspace(static_cast<unsigned char>(trimmed.front()))) {
+            trimmed.erase(trimmed.begin());
+        }
+        while (!trimmed.empty() &&
+               std::isspace(static_cast<unsigned char>(trimmed.back()))) {
+            trimmed.pop_back();
+        }
+        domain_raw = trimmed;
+    }
+    if (domain_raw.empty()) {
+        err = "请填写域名";
+        return false;
+    }
+    if (domain_raw.find('\n') != std::string::npos ||
+        domain_raw.find(',') != std::string::npos ||
+        domain_raw.find(';') != std::string::npos ||
+        domain_raw.find(' ') != std::string::npos) {
+        err = "仅支持填写一个域名";
+        return false;
+    }
     std::vector<DomainBind> binds;
     std::string derr;
     if (!parse_domain_binds(domain_raw, binds, derr)) {
         err = derr;
         return false;
     }
+    if (binds.size() != 1) {
+        err = "仅支持填写一个域名";
+        return false;
+    }
+    const std::string host_id = binds.front().host;
+    for (const auto& old : load_sites()) {
+        if (site_id(old) == host_id) {
+            err = "域名已被占用: " + host_id;
+            return false;
+        }
+    }
     // Optional listen_port override only when domains have no explicit ports
     // and caller still passes a port (legacy create API).
     bool any_explicit_port = false;
@@ -859,12 +969,38 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
         }
     }
 
+    std::string root = req.root;
+    while (!root.empty() &&
+           std::isspace(static_cast<unsigned char>(root.front()))) {
+        root.erase(root.begin());
+    }
+    while (!root.empty() &&
+           std::isspace(static_cast<unsigned char>(root.back()))) {
+        root.pop_back();
+    }
+    if (root.empty()) {
+        root = join_path(wwwroot_dir(), binds.front().host);
+    } else {
+        root = expand_path(root);
+    }
+    // Keep site files under /ngs/wwwroot for safety.
+    const std::string www = wwwroot_dir();
+    if (root != www && root.rfind(www + "/", 0) != 0) {
+        err = "网站目录必须位于 " + www + " 下";
+        return false;
+    }
+    if (is_dir(root) || path_exists(root)) {
+        err = "网站目录已存在: " + root;
+        return false;
+    }
+
     SiteInfo s;
     s.name = req.name;
     s.type = req.type;
     s.domain = domain;
     s.listen_port = listen;
     s.upstream = req.upstream;
+    s.root = root;
 
     if (req.type == SiteType::Proxy) {
         if (s.upstream.empty()) {
@@ -901,8 +1037,9 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
 
     {
         std::string werr;
-        if (!weblog::ensure_db(s.name, werr)) {
-            log_warn("website log db init failed name=" + s.name + " " + werr);
+        if (!weblog::ensure_db(site_id(s), werr)) {
+            log_warn("website log db init failed name=" + s.name + " id=" +
+                     site_id(s) + " " + werr);
         }
     }
 
@@ -949,7 +1086,7 @@ bool create_site_interactive(SiteType type) {
     std::cout << "\n网站创建成功。\n";
     std::cout << "  名称: " << created.name << "\n";
     std::cout << "  类型: " << type_label(created.type) << "\n";
-    std::cout << "  目录: " << site_root(created.name) << "\n";
+    std::cout << "  目录: " << site_dir(created) << "\n";
     std::cout << "  访问: http://" << created.domain << ":" << created.listen_port
               << "  (或 http://127.0.0.1:" << created.listen_port << ")\n";
     if (created.type == SiteType::Fastweb) {
@@ -969,8 +1106,8 @@ bool delete_site_impl(const std::string& name, bool delete_files,
     }
 
     stop_site_by_name(s.name);
-    remove_path(vhost_file(s.name));
-    remove_path(vhost_disabled_file(s.name));
+    remove_path(vhost_file(s));
+    remove_path(vhost_disabled_file(s));
 
     auto remain = load_sites();
     remain.erase(std::remove_if(remain.begin(), remain.end(),
@@ -984,7 +1121,7 @@ bool delete_site_impl(const std::string& name, bool delete_files,
         apply_nginx();
     }
 
-    const std::string root = site_root(s.name);
+    const std::string root = site_dir(s);
     if (delete_files) {
         if (is_dir(root) || path_exists(root)) {
             if (!remove_path(root)) {
@@ -998,7 +1135,7 @@ bool delete_site_impl(const std::string& name, bool delete_files,
     } else {
         log_info("website deleted name=" + s.name + " (wwwroot kept)");
     }
-    weblog::remove_db(s.name);
+    weblog::remove_db(site_id(s));
     return true;
 }
 
@@ -1034,7 +1171,7 @@ bool delete_site_interactive() {
     }
 
     std::cout << "将删除网站 `" << s.name << "` 的配置记录与 Nginx vhost。\n";
-    std::cout << "网站目录: " << site_root(s.name) << "\n";
+    std::cout << "网站目录: " << site_dir(s) << "\n";
     std::string confirm = read_line("请再次输入网站名以确认: ");
     if (confirm != s.name) {
         std::cout << "两次输入不一致,已取消。\n";
@@ -1051,9 +1188,9 @@ bool delete_site_interactive() {
     }
     std::cout << "网站配置已删除: " << s.name << "\n";
     if (delete_files) {
-        std::cout << "网站目录已删除: " << site_root(s.name) << "\n";
+        std::cout << "网站目录已删除: " << site_dir(s) << "\n";
     } else {
-        std::cout << "网站目录已保留: " << site_root(s.name) << "\n";
+        std::cout << "网站目录已保留: " << site_dir(s) << "\n";
     }
     return true;
 }
@@ -1061,25 +1198,59 @@ bool delete_site_interactive() {
 }  // namespace
 
 std::string vhost_conf_path(const std::string& name) {
-    const std::string enabled = join_path(join_path(conf_root(), "vhost"), name + ".conf");
-    const std::string disabled = enabled + ".disabled";
-    if (path_exists(enabled)) {
-        return enabled;
+    SiteInfo s;
+    std::string base;
+    if (find_site_info(name, s)) {
+        base = join_path(join_path(conf_root(), "vhost"), site_key(s) + ".conf");
+    } else {
+        base = join_path(join_path(conf_root(), "vhost"), name + ".conf");
+    }
+    const std::string disabled = base + ".disabled";
+    if (path_exists(base)) {
+        return base;
     }
     if (path_exists(disabled)) {
         return disabled;
     }
-    return enabled;
+    return base;
+}
+
+std::string site_dir(const SiteInfo& s) {
+    if (!s.root.empty()) {
+        return expand_path(s.root);
+    }
+    const std::string id = site_id(s);
+    return join_path(wwwroot_dir(), id.empty() ? s.name : id);
 }
 
 std::string site_root_path(const std::string& name) {
+    SiteInfo s;
+    if (find_site_info(name, s)) {
+        return site_dir(s);
+    }
     return join_path(wwwroot_dir(), name);
 }
 
 std::string site_log_dir_path(const std::string& name) {
+    SiteInfo s;
+    if (find_site_info(name, s)) {
+        return join_path(sites_log_dir(), site_id(s));
+    }
     return join_path(sites_log_dir(), name);
 }
 
+std::string site_key(const SiteInfo& s) {
+    return site_id(s);
+}
+
+std::string site_key_by_name(const std::string& name) {
+    SiteInfo s;
+    if (find_site_info(name, s)) {
+        return site_id(s);
+    }
+    return name;
+}
+
 std::string type_key(SiteType t) {
     switch (t) {
         case SiteType::Proxy:
@@ -1213,6 +1384,18 @@ bool update_site(const UpdateSiteRequest& req, std::string& err) {
 
     s.domain = join_domain_binds(binds);
     s.listen_port = primary_port;
+    {
+        const std::string new_id = binds.front().host;
+        for (const auto& old : load_sites()) {
+            if (old.name == req.name) {
+                continue;
+            }
+            if (site_id(old) == new_id) {
+                err = "域名已被占用: " + new_id;
+                return false;
+            }
+        }
+    }
     if (req.update_ssl) {
         if (req.ssl_enable) {
             if (req.ssl_cert.empty() || req.ssl_key.empty()) {
@@ -1369,7 +1552,8 @@ bool apply_letsencrypt_impl(const std::string& name, const std::string& email,
         return false;
     }
 
-    const std::string ssl_dir = join_path(site_root(s.name), "ssl");
+    const std::string primary = domains.front();
+    const std::string ssl_dir = ssl::cert_dir_for_domain(primary);
     if (!ensure_dir(ssl_dir)) {
         err = "无法创建证书目录: " + ssl_dir;
         return false;
@@ -1483,9 +1667,10 @@ bool start_fastweb_process(const SiteInfo& s) {
     }
 
     const std::string bin = fastweb::bin_path();
-    const std::string cwd = site_root(s.name);
+    const std::string cwd = site_dir(s);
+    // System install: /usr/local/lib + /opt/lua54/lib
     const std::string lib = join_path(fastweb::install_dir(), "lib");
-    const std::string lua = join_path(software_root(), "3rdparty/lua54/lib");
+    const std::string lua = "/opt/lua54/lib";
     std::string ld_path = lib + ":" + lua;
     if (const char* old = std::getenv("LD_LIBRARY_PATH")) {
         if (*old) {
@@ -1570,7 +1755,7 @@ bool start_fastweb_process(const SiteInfo& s) {
 
     if (!ready && find_fastweb_pids(s.name).empty()) {
         std::cerr << "Fastweb 启动后未检测到运行,请查看站点日志目录: "
-                  << join_path(site_root(s.name), "log") << "\n";
+                  << join_path(site_dir(s), "log") << "\n";
         return false;
     }
 

+ 10 - 2
src/website/website.h

@@ -19,6 +19,7 @@ struct SiteInfo {
     std::string domain;
     int listen_port = 0;
     std::string upstream;  // proxy target or fastweb backend host:port
+    std::string root;      // site files dir; empty -> /ngs/wwwroot/<name>
     bool running = false;
     bool ssl_enable = false;
     std::string ssl_cert;
@@ -29,9 +30,10 @@ struct SiteInfo {
 struct CreateSiteRequest {
     std::string name;
     SiteType type = SiteType::Static;
-    std::string domain;      // empty -> use name; supports host or host:port
-    int listen_port = 0;     // 0 -> derive from domain tokens (default 80)
+    std::string domain;      // single host or host:port
+    int listen_port = 0;     // 0 -> derive from domain token (default 80)
     std::string upstream;    // required for Proxy
+    std::string root;        // optional; default /ngs/wwwroot/<domain-host>
 };
 
 struct UpdateSiteRequest {
@@ -59,12 +61,18 @@ std::vector<std::string> domain_tokens(const SiteInfo& s);
 std::vector<SiteInfo> list_sites();
 bool find_site(const std::string& name, SiteInfo& out);
 std::string vhost_conf_path(const std::string& name);
+// Resolved site files directory (custom root or /ngs/wwwroot/<name>).
+std::string site_dir(const SiteInfo& s);
 std::string site_root_path(const std::string& name);
 std::string site_log_dir_path(const std::string& name);
+// Stable key for nginx/logs/db: primary domain host.
+std::string site_key(const SiteInfo& s);
+std::string site_key_by_name(const std::string& name);
 
 bool create_site(const CreateSiteRequest& req, std::string& err);
 bool update_site(const UpdateSiteRequest& req, std::string& err);
 // Apply Let's Encrypt cert (HTTP-01) for selected hosts on one cert (SAN).
+// Certs are stored under /ngs/conf/ssl/<primary-domain>/.
 // hosts empty -> use all site domains.
 bool apply_letsencrypt(const std::string& name, const std::string& email,
                        const std::vector<std::string>& hosts, std::string& err);