feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组

针对反馈的五个问题:

1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」,
   把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿
   (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。
   解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。
2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 /
   图标 / 自动抓取」。
3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」,
   并补上 WebSocket 透传与上传上限。
4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷
   (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量,
   改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。
5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级),
   外观卡片并入站点组。

另修两个本轮引入的缺陷:
- 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080;
  部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。
- kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。

测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、
垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
This commit is contained in:
HomeDash Bot
2026-09-11 22:00:09 +08:00
parent 994d93cc98
commit 53beff646a
10 changed files with 671 additions and 43 deletions

View File

@ -88,6 +88,9 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
.gw-mb{flex:0 0 110px;min-width:0}
.gw-ck{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--tx2);white-space:nowrap}
.gw-kind,.gw-sec{flex:0 0 118px;min-width:0}
.grp{margin:26px 0 10px;font-size:12px;font-weight:800;letter-spacing:.08em;color:var(--tx3);text-transform:uppercase}
.grp-in{margin:16px 0 8px;font-size:12.5px;font-weight:700;color:var(--tx2);padding-top:12px;border-top:1px dashed var(--bd)}
#gw-import{min-height:110px;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.gw-navname{flex:1 1 130px;min-width:0}
.gw-navdesc{flex:1 1 150px;min-width:0}
.gw-navicon{flex:1 1 150px;min-width:0}
@ -121,6 +124,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<h1>导航管理</h1>
<p class="sub">站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。</p>
<div class="grp">站点与外观</div>
<!-- 站点信息 -->
<div class="card">
<h2>站点信息</h2>
@ -144,6 +149,33 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div style="margin-top:14px"><button class="primary" id="save-info">保存站点信息</button></div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<div class="grp">历史记录</div>
<!-- 历史访问 -->
<div class="card">
<h2>历史访问</h2>
@ -152,6 +184,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div style="margin-top:14px"><button class="ghost" id="clear-hist">清空全部历史</button></div>
</div>
<div class="grp">导航内容</div>
<!-- 导航分组 -->
<div class="card">
<h2>导航分组</h2>
@ -196,39 +230,77 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<!-- 分组 -->
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
<!-- 统一入口:反代 + 认证 -->
<!-- ===== 转发与统一登录 ===== -->
<div class="grp">转发与统一登录</div>
<div class="card">
<h2>统一入口(反向代理与认证)</h2>
<h2>服务转发</h2>
<p class="sub" style="margin:0 0 12px">
在这里维护「域名 → 内网服务」,并逐条决定是否走<b>统一登录认证</b>。
生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
一条记录 = 一个域名 → 内网后端。<b>是否走统一登录</b>、<b>是否加入 Dashy 导航</b>
都是这条记录上的开关;勾选导航后名称/描述/图标(或自动抓取)也在同一处配置。
</p>
<div id="gw-routes"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<div class="grp-in">添加转发</div>
<div class="row">
<div class="fg"><label>域名</label><input id="gw-n-host" placeholder="git.example.com"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://10.0.0.10:3000"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加映射</button>
<div class="fg" style="max-width:170px"><label>类型</label>
<select id="gw-n-kind">
<option value="proxy">反向代理</option>
<option value="portal">门户静态页</option>
<option value="auth">登录服务</option>
</select>
</div>
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-auth" checked>启用 portal 统一登录保护</label>
<label class="gw-ck"><input type="checkbox" id="gw-n-ws">WebSocket 透传</label>
<input class="gw-mb" id="gw-n-mb" placeholder="上传MB上限">
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-nav" checked>同时加入 Dashy 导航</label>
<div class="fg" style="max-width:180px"><label>导航分组</label><select id="gw-n-sec"></select></div>
<div class="fg" style="max-width:180px"><label>导航名称</label><input id="gw-n-name" placeholder="Gitea"></div>
<div class="fg" style="max-width:180px"><label>导航描述</label><input id="gw-n-desc" placeholder="代码仓库"></div>
</div>
<div class="row">
<div class="fg"><label>图标(文件名 / fas fa-*,留空则自动抓取)</label><input id="gw-n-icon" placeholder="gitea.png"></div>
<label class="gw-ck"><input type="checkbox" id="gw-n-auto" checked>自动抓取站点图标</label>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加转发</button>
</div>
</div>
<details style="margin-top:12px">
<summary>从现有 nginx 配置导入(迁移用)</summary>
<p class="hint" style="margin:8px 0">把旧统一入口的 nginx 配置整段贴进来,点「解析」会在下面列出识别出的域名与后端。
解析只读,不会写盘;确认后点「保存并应用」。导入默认<b>不</b>接管导航,需要接管的条目自己勾上。</p>
<textarea id="gw-import" placeholder="map $host $backend { ... } server { server_name ... }"></textarea>
<div class="row" style="margin-top:8px">
<button id="gw-import-run">解析</button>
<span class="hint" id="gw-import-msg"></span>
</div>
</details>
<details style="margin-top:10px">
<summary>入口参数(端口 / 证书 / 认证地址)</summary>
<div class="row" style="margin-top:10px">
<div class="fg" style="max-width:150px"><label>HTTP 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTP(S) 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTPS 端口</label><input id="gw-tlsport" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>终结 TLS</label>
<label class="gw-ck" style="padding:9px 0"><input type="checkbox" id="gw-tls"> 本容器持有证书</label>
</div>
<div class="fg" style="max-width:150px"><label>HTTP 跳转端口</label><input id="gw-redirect" inputmode="numeric"></div>
</div>
<div class="row">
<div class="fg"><label>证书路径</label><input id="gw-cert" placeholder="/etc/nginx/certs/fullchain.pem"></div>
<div class="fg"><label>私钥路径</label><input id="gw-key" placeholder="/etc/nginx/certs/privkey.pem"></div>
<label class="gw-ck"><input type="checkbox" id="gw-tls">由本容器终结 TLS</label>
</div>
<div class="row">
<div class="fg"><label>认证校验地址(auth_request)</label><input id="gw-auth" placeholder="http://127.0.0.1:4001/_auth"></div>
<div class="fg"><label>认证校验地址</label><input id="gw-auth" placeholder="http://127.0.0.1:3005/auth/verify"></div>
<div class="fg"><label>未登录跳转</label><input id="gw-login" placeholder="https://login.example.com/login"></div>
</div>
</details>
<div class="row" style="margin-top:14px">
<button class="primary" id="gw-apply">保存并应用</button>
<button id="gw-saveonly">仅保存</button>
@ -236,31 +308,23 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
</div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<h2>统一登录账号</h2>
<p class="sub" style="margin:0 0 12px">
受保护域名登录时使用。账号存在数据卷里,改完立即生效,不用重新部署。
当前来源:<b id="pa-source">读取中…</b>
</p>
<div class="row">
<div class="fg" style="max-width:220px"><label>用户名</label><input id="pa-user" autocomplete="off"></div>
<div class="fg" style="max-width:260px"><label>新密码(至少 6 位)</label><input id="pa-pass" type="password" autocomplete="new-password" placeholder="留空则不修改"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
<label>&nbsp;</label><button class="primary" id="pa-save">保存账号</button>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<div class="grp">高级</div>
<!-- 令牌 -->
<div class="card">
<details>
@ -793,6 +857,7 @@ function collectGateway() {
});
GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0;
GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0;
GW.redirectPort = parseInt(gwVal('gw-redirect'), 10) || 0;
GW.listenHttps = gwChk('gw-tls');
GW.certFile = gwVal('gw-cert');
GW.keyFile = gwVal('gw-key');
@ -841,6 +906,7 @@ function renderGateway() {
gwSet('gw-port', GW.listenPort);
gwSet('gw-tlsport', GW.listenTlsPort);
gwSet('gw-redirect', GW.redirectPort);
gwSet('gw-cert', GW.certFile);
gwSet('gw-key', GW.keyFile);
gwSet('gw-auth', GW.authUrl);
@ -873,20 +939,87 @@ async function gatewaySave() {
document.getElementById('gw-add').addEventListener('click', () => {
const host = gwVal('gw-n-host');
const up = gwVal('gw-n-up');
if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; }
const kind = gwVal('gw-n-kind') || 'proxy';
if (!host) { toast('请填写域名', '#ef4444'); return; }
if (kind === 'proxy' && !up) { toast('反向代理需要填写后端地址', '#ef4444'); return; }
collectGateway();
const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || '';
const navEnabled = gwChk('gw-n-nav');
const mb = parseInt(gwVal('gw-n-mb'), 10);
GW.routes.push({
host: host, upstream: up, requireLogin: true, websocket: false, maxBodyMB: 0, kind: 'proxy',
navEnabled: !!firstSection, navSection: firstSection, navName: host.split('.')[0],
navDescription: '', navIcon: '', navAutoIcon: true,
host: host,
upstream: kind === 'portal' ? '' : up,
kind: kind,
requireLogin: gwChk('gw-n-auth'),
websocket: gwChk('gw-n-ws'),
maxBodyMB: Number.isFinite(mb) && mb > 0 ? mb : 0,
navEnabled: navEnabled,
navSection: navEnabled ? gwVal('gw-n-sec') : '',
navName: navEnabled ? (gwVal('gw-n-name') || host.split('.')[0]) : '',
navDescription: navEnabled ? gwVal('gw-n-desc') : '',
navIcon: navEnabled ? gwVal('gw-n-icon') : '',
navAutoIcon: navEnabled && gwChk('gw-n-auto'),
});
['gw-n-host', 'gw-n-up', 'gw-n-name', 'gw-n-desc', 'gw-n-icon', 'gw-n-mb'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('gw-n-host').value = '';
document.getElementById('gw-n-up').value = '';
renderGateway();
toast('已加入列表,记得点「保存并应用」');
});
document.getElementById('gw-import-run').addEventListener('click', async () => {
const box = document.getElementById('gw-import');
const msg = document.getElementById('gw-import-msg');
const conf = (box && box.value || '').trim();
if (!conf) { toast('请先贴上 nginx 配置', '#ef4444'); return; }
if (msg) msg.textContent = '解析中…';
const r = await fetch(A + '/gateway/import', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ conf: conf }),
});
const text = await r.text();
if (!r.ok) {
if (msg) msg.innerHTML = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(text.slice(0, 300)) + '</span>';
return;
}
const data = JSON.parse(text);
collectGateway();
// 导入结果覆盖当前列表:这是迁移,不是追加(避免同一域名两条记录)
GW.routes = data.routes || [];
renderGateway();
if (msg) msg.textContent = data.hint || ('已解析 ' + GW.routes.length + ' 条');
toast('已解析 ' + GW.routes.length + ' 个域名,确认后点「保存并应用」');
});
/* ── 统一登录账号 ── */
async function loadPortalAuth() {
try {
const r = await fetch(A + '/portal-auth', { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
const u = document.getElementById('pa-user');
const src = document.getElementById('pa-source');
if (u && !u.value) u.value = d.username || '';
if (src) src.textContent = d.source || '';
} catch (e) { /* 忽略 */ }
}
document.getElementById('pa-save').addEventListener('click', async () => {
const user = (document.getElementById('pa-user').value || '').trim();
const pass = document.getElementById('pa-pass').value || '';
if (!user) { toast('请填写用户名', '#ef4444'); return; }
if (pass.length < 6) { toast('密码至少 6 位', '#ef4444'); return; }
const r = await fetch(A + '/portal-auth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ username: user, password: pass }),
});
if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 140), '#ef4444'); return; }
document.getElementById('pa-pass').value = '';
toast('账号已更新(下次登录生效)');
loadPortalAuth();
});
document.getElementById('gw-saveonly').addEventListener('click', async () => {
if (await gatewaySave()) toast('已保存(未应用)');
});
@ -913,6 +1046,7 @@ document.getElementById('gw-apply').addEventListener('click', async () => {
await loadWallpaper();
await loadHistory();
await loadGateway();
await loadPortalAuth();
})();
</script>
</body>