feat(gateway): 一体化统一入口——路由与 Dashy 导航同一份数据、内置登录、原自定义错误页
目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。 - Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、 insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/ navDescription/navUrl/navIcon/navAutoIcon - 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go): 同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组 一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话) - 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录), 不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计 - 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验, portal-auth 容器可以退场 - 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon) - 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段 - 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080) - 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败) 测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、 分组不存在必须报错等用例;admin-server 与 history-server 全绿。
This commit is contained in:
210
admin/index.html
210
admin/index.html
@ -83,6 +83,16 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
|
||||
.sec-in-name{flex:1 1 120px;max-width:200px}
|
||||
.sec-in-icon{flex:1 1 140px;max-width:220px}
|
||||
.sec-acts{display:flex;gap:6px;flex:0 0 auto}
|
||||
.gw-host{flex:1 1 170px;max-width:280px;min-width:0}
|
||||
.gw-up{flex:1 1 190px;max-width:320px;min-width:0}
|
||||
.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}
|
||||
.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}
|
||||
.gw-ck input{width:auto;padding:0}
|
||||
#gw-msg{word-break:break-all}
|
||||
|
||||
@media (max-width:560px){
|
||||
body{padding:16px 12px 48px}
|
||||
@ -94,7 +104,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
|
||||
.fg[style]{max-width:none!important}
|
||||
.itm{flex-wrap:wrap;gap:8px}
|
||||
.sec-row .itm-i{flex-basis:100%;min-width:0}
|
||||
.sec-in-name,.sec-in-icon{flex:1 1 100%;max-width:none!important}
|
||||
.sec-in-name,.sec-in-icon,.gw-host,.gw-up{flex:1 1 100%;max-width:none!important}
|
||||
.gw-mb,.gw-kind,.gw-sec,.gw-navname,.gw-navdesc,.gw-navicon{flex:1 1 100%;max-width:none!important}
|
||||
.sec-acts{width:100%;justify-content:flex-end}
|
||||
.link-row{flex-wrap:wrap}
|
||||
.link-row input{flex:1 1 100%}
|
||||
@ -185,6 +196,46 @@ 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="card">
|
||||
<h2>统一入口(反向代理与认证)</h2>
|
||||
<p class="sub" style="margin:0 0 12px">
|
||||
在这里维护「域名 → 内网服务」,并逐条决定是否走<b>统一登录认证</b>。
|
||||
生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
|
||||
</p>
|
||||
<div id="gw-routes"><div class="muted">读取中…</div></div>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<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> </label><button class="primary" id="gw-add">添加映射</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>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>
|
||||
<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>
|
||||
</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-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>
|
||||
<span class="hint" id="gw-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外观 -->
|
||||
<div class="card">
|
||||
<h2>外观</h2>
|
||||
@ -700,11 +751,168 @@ document.getElementById('file-wall').addEventListener('change', async (ev) => {
|
||||
toast('壁纸已上传并应用');
|
||||
});
|
||||
|
||||
|
||||
/* ── 统一入口:反代 + 认证 ──
|
||||
路由表存 gateway.yml;"是否走统一认证"就是每条记录上的一个开关。
|
||||
"保存并应用"会先让服务端做 nginx -t 校验,不通过会把原始报错显示出来,
|
||||
并保留上一份可用配置(不会把入口改坏)。 */
|
||||
let GW = { routes: [] };
|
||||
|
||||
const gwVal = (id) => { const e = document.getElementById(id); return e ? e.value.trim() : ''; };
|
||||
const gwChk = (id) => { const e = document.getElementById(id); return !!(e && e.checked); };
|
||||
const gwSet = (id, v) => { const e = document.getElementById(id); if (e) e.value = (v === undefined || v === null) ? '' : v; };
|
||||
|
||||
async function loadGateway() {
|
||||
try {
|
||||
const r = await fetch(A + '/gateway/routes', { headers: authHeaders() });
|
||||
if (r.ok) GW = Object.assign(GW, await r.json());
|
||||
} catch (e) { /* 读不到就先给空表 */ }
|
||||
if (!Array.isArray(GW.routes)) GW.routes = [];
|
||||
renderGateway();
|
||||
}
|
||||
|
||||
/* 把界面上的编辑回读到 GW(保存/应用前必调) */
|
||||
function collectGateway() {
|
||||
const routes = GW.routes;
|
||||
document.querySelectorAll('input[data-gw-host]').forEach((el) => {
|
||||
const i = +el.dataset.gwHost;
|
||||
if (!routes[i]) return;
|
||||
routes[i].host = el.value.trim();
|
||||
routes[i].upstream = (document.querySelector(`input[data-gw-up="${i}"]`) || {}).value?.trim() || '';
|
||||
routes[i].requireLogin = !!(document.querySelector(`input[data-gw-auth="${i}"]`) || {}).checked;
|
||||
routes[i].websocket = !!(document.querySelector(`input[data-gw-ws="${i}"]`) || {}).checked;
|
||||
const mb = parseInt((document.querySelector(`input[data-gw-mb="${i}"]`) || {}).value, 10);
|
||||
routes[i].maxBodyMB = Number.isFinite(mb) && mb > 0 ? mb : 0;
|
||||
routes[i].kind = (document.querySelector(`select[data-gw-kind="${i}"]`) || {}).value || 'proxy';
|
||||
routes[i].navEnabled = !!(document.querySelector(`input[data-gw-nav="${i}"]`) || {}).checked;
|
||||
routes[i].navSection = (document.querySelector(`select[data-gw-sec="${i}"]`) || {}).value || '';
|
||||
routes[i].navName = ((document.querySelector(`input[data-gw-navname="${i}"]`) || {}).value || '').trim();
|
||||
routes[i].navDescription = ((document.querySelector(`input[data-gw-navdesc="${i}"]`) || {}).value || '').trim();
|
||||
routes[i].navIcon = ((document.querySelector(`input[data-gw-navicon="${i}"]`) || {}).value || '').trim();
|
||||
routes[i].navAutoIcon = !!(document.querySelector(`input[data-gw-auto="${i}"]`) || {}).checked;
|
||||
});
|
||||
GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0;
|
||||
GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0;
|
||||
GW.listenHttps = gwChk('gw-tls');
|
||||
GW.certFile = gwVal('gw-cert');
|
||||
GW.keyFile = gwVal('gw-key');
|
||||
GW.authUrl = gwVal('gw-auth');
|
||||
GW.loginUrl = gwVal('gw-login');
|
||||
}
|
||||
|
||||
/* 导航分组下拉:分组来自「导航分组」卡片,必须已存在(服务端会校验) */
|
||||
function sectionOptions(current) {
|
||||
const opts = ['<option value="">选择分组…</option>'];
|
||||
(S || []).forEach((s) => {
|
||||
const n = String(s.name || '');
|
||||
if (n === '历史访问') return; // 系统分组不接受手工导航
|
||||
opts.push(`<option value="${esc(n)}"${n === current ? ' selected' : ''}>${esc(n)}</option>`);
|
||||
});
|
||||
return opts.join('');
|
||||
}
|
||||
|
||||
function renderGateway() {
|
||||
const box = document.getElementById('gw-routes');
|
||||
box.innerHTML = GW.routes.length ? GW.routes.map((r, i) => `
|
||||
<div class="itm sec-row">
|
||||
<div class="itm-i sec-fields">
|
||||
<input class="gw-host" data-gw-host="${i}" value="${esc(r.host || '')}" placeholder="域名">
|
||||
<input class="gw-up" data-gw-up="${i}" value="${esc(r.upstream || '')}" placeholder="后端 http://ip:port">
|
||||
<label class="gw-ck"><input type="checkbox" data-gw-auth="${i}" ${r.requireLogin ? 'checked' : ''}>统一认证</label>
|
||||
<label class="gw-ck"><input type="checkbox" data-gw-ws="${i}" ${r.websocket ? 'checked' : ''}>WebSocket</label>
|
||||
<input class="gw-mb" data-gw-mb="${i}" value="${r.maxBodyMB || ''}" placeholder="上传MB上限">
|
||||
<select data-gw-kind="${i}" class="gw-kind">
|
||||
<option value="proxy"${(r.kind || 'proxy') === 'proxy' ? ' selected' : ''}>反向代理</option>
|
||||
<option value="portal"${r.kind === 'portal' ? ' selected' : ''}>门户静态页</option>
|
||||
<option value="auth"${r.kind === 'auth' ? ' selected' : ''}>登录服务</option>
|
||||
</select>
|
||||
<label class="gw-ck"><input type="checkbox" data-gw-nav="${i}" ${r.navEnabled ? 'checked' : ''}>加入导航</label>
|
||||
<select data-gw-sec="${i}" class="gw-sec">${sectionOptions(r.navSection)}</select>
|
||||
<input class="gw-navname" data-gw-navname="${i}" value="${esc(r.navName || '')}" placeholder="导航名称">
|
||||
<input class="gw-navdesc" data-gw-navdesc="${i}" value="${esc(r.navDescription || '')}" placeholder="导航描述">
|
||||
<input class="gw-navicon" data-gw-navicon="${i}" value="${esc(r.navIcon || '')}" placeholder="图标:文件名 / fas fa-*">
|
||||
<label class="gw-ck"><input type="checkbox" data-gw-auto="${i}" ${r.navAutoIcon ? 'checked' : ''}>自动抓图标</label>
|
||||
</div>
|
||||
<div class="sec-acts">
|
||||
<button class="icon-btn danger" data-gw-del="${i}" title="删除这条映射">✕</button>
|
||||
</div>
|
||||
</div>`).join('')
|
||||
: '<p class="hint" style="margin:0">还没有映射。添加一条「域名 → 内网服务」,需要登录才能访问的勾上「统一认证」。</p>';
|
||||
|
||||
gwSet('gw-port', GW.listenPort);
|
||||
gwSet('gw-tlsport', GW.listenTlsPort);
|
||||
gwSet('gw-cert', GW.certFile);
|
||||
gwSet('gw-key', GW.keyFile);
|
||||
gwSet('gw-auth', GW.authUrl);
|
||||
gwSet('gw-login', GW.loginUrl);
|
||||
const tls = document.getElementById('gw-tls');
|
||||
if (tls) tls.checked = !!GW.listenHttps;
|
||||
|
||||
document.querySelectorAll('button[data-gw-del]').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
collectGateway();
|
||||
const i = +el.dataset.gwDel;
|
||||
if (!confirm('删除映射「' + (GW.routes[i] || {}).host + '」?')) return;
|
||||
GW.routes.splice(i, 1);
|
||||
renderGateway();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function gatewaySave() {
|
||||
collectGateway();
|
||||
const r = await fetch(A + '/gateway/routes', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(GW),
|
||||
});
|
||||
if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 160), '#ef4444'); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
document.getElementById('gw-add').addEventListener('click', () => {
|
||||
const host = gwVal('gw-n-host');
|
||||
const up = gwVal('gw-n-up');
|
||||
if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; }
|
||||
collectGateway();
|
||||
const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || '';
|
||||
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,
|
||||
});
|
||||
document.getElementById('gw-n-host').value = '';
|
||||
document.getElementById('gw-n-up').value = '';
|
||||
renderGateway();
|
||||
toast('已加入列表,记得点「保存并应用」');
|
||||
});
|
||||
|
||||
document.getElementById('gw-saveonly').addEventListener('click', async () => {
|
||||
if (await gatewaySave()) toast('已保存(未应用)');
|
||||
});
|
||||
|
||||
document.getElementById('gw-apply').addEventListener('click', async () => {
|
||||
const msg = document.getElementById('gw-msg');
|
||||
if (msg) msg.textContent = '校验中…';
|
||||
if (!(await gatewaySave())) { if (msg) msg.textContent = ''; return; }
|
||||
const r = await fetch(A + '/gateway/apply', { method: 'POST', headers: authHeaders() });
|
||||
const body = await r.text();
|
||||
if (r.ok) {
|
||||
toast('已应用并热加载');
|
||||
if (msg) msg.textContent = '已应用 ✓';
|
||||
} else {
|
||||
toast('应用失败', '#ef4444');
|
||||
if (msg) msg.innerHTML = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(body.slice(0, 600)) + '</span>';
|
||||
}
|
||||
loadGateway();
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
await loadPageInfo();
|
||||
await loadSections();
|
||||
await loadWallpaper();
|
||||
await loadHistory();
|
||||
await loadGateway();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user