Compare commits

..

4 Commits

Author SHA1 Message Date
d125a8d6ec fix(tmux): hide web terminal proxy sessions 2026-08-12 11:20:58 +08:00
50371cfb0c ops: use host-managed local service list 2026-08-12 11:20:48 +08:00
176119ca29 fix(auth): persist login and skip redundant login page 2026-08-12 11:20:34 +08:00
76b2ac69f8 fix: let active tmux clients control window size
Keep the global window-size=latest policy for linked web and iOS sessions so switching between phone and Mac sends SIGWINCH and reflows the shared pane.
2026-07-28 23:53:01 +08:00
11 changed files with 127 additions and 247 deletions

View File

@@ -35,7 +35,16 @@ nginx -s stop \
## Production 443 Proxy ## Production 443 Proxy
For port 443, install `deploy/nginx.local-kanban.conf` into the system Nginx site directory and use a trusted certificate. The root domain serves the portal page, and individual apps keep their own ports: For port 443, install `deploy/nginx.local-kanban.conf` into the system Nginx site directory and use a trusted certificate. The root domain serves the host-local service list from `/var/www/local-service-list`, and individual apps keep their own ports. The service-list HTML and CSS are maintained directly in that directory rather than copied from this repo.
Before installing, make sure these files exist:
```text
/var/www/local-service-list/index.html
/var/www/local-service-list/portal.css
```
Then install the Nginx configuration:
```bash ```bash
sudo cp deploy/nginx.local-kanban.conf /etc/nginx/sites-available/local-kanban sudo cp deploy/nginx.local-kanban.conf /etc/nginx/sites-available/local-kanban

View File

@@ -3,7 +3,7 @@ limit_req_zone $binary_remote_addr zone=kanban_login:10m rate=5r/m;
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name gahow-pc.ipads-lab.se.sjtu.edu.cn; server_name gahow-pc.ipads-lab.se.sjtu.edu.cn;
root /var/www/local-kanban-portal; root /var/www/local-service-list;
index index.html; index index.html;
ssl_certificate /etc/nginx/certs/gahow-pc.fullchain.pem; ssl_certificate /etc/nginx/certs/gahow-pc.fullchain.pem;

View File

@@ -1,64 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>gahow-pc services</title>
<link rel="stylesheet" href="/portal.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>gahow-pc</h1>
<p>Local services</p>
</div>
</header>
<section class="service-section">
<div class="section-heading">
<h2>Services</h2>
</div>
<div class="service-grid">
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:3443/">
<span class="service-icon">Gi</span>
<span class="service-topline">Repository</span>
<span class="service-name">Gitea</span>
<span class="service-desc">本地 Git 仓库与项目管理</span>
<span class="service-port">https :3443</span>
</a>
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8443/">
<span class="service-icon">Kb</span>
<span class="service-topline">Ops</span>
<span class="service-name">Kanban</span>
<span class="service-desc">GPU、额度、项目与 agent 任务</span>
<span class="service-port">https :8443</span>
</a>
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:5443/">
<span class="service-icon">St</span>
<span class="service-topline">Finance</span>
<span class="service-name">Stock Agent</span>
<span class="service-desc">个人股票分析与日报</span>
<span class="service-port">https :5443</span>
</a>
</div>
</section>
<details class="api-section">
<summary>后端 API</summary>
<div class="service-grid">
<a class="service-card" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8443/api/health">
<span class="service-name">Kanban API</span>
<span class="service-port">https :8443/api</span>
<span class="service-desc">Kanban 前后端同一个 Node 服务</span>
</a>
<a class="service-card" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8788/api/health">
<span class="service-name">Stock Agent API</span>
<span class="service-port">https :8788/api</span>
<span class="service-desc">Express API位于 /home/gahow/projects/stock-agent</span>
</a>
</div>
</details>
</main>
</body>
</html>

View File

@@ -1,166 +0,0 @@
:root {
color-scheme: light;
--bg: #f4f6f8;
--panel: #ffffff;
--ink: #17202c;
--muted: #697586;
--line: #d7dee8;
--accent: #2563eb;
--soft: #eef4ff;
--shadow: 0 16px 40px rgba(23, 32, 44, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.12), transparent 28%),
radial-gradient(circle at 90% 18%, rgba(18, 128, 92, 0.11), transparent 26%),
var(--bg);
color: var(--ink);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 42px 0 56px;
}
.topbar {
margin-bottom: 30px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 34px;
line-height: 1.08;
letter-spacing: 0;
}
h2 {
font-size: 14px;
margin-bottom: 0;
text-transform: uppercase;
color: var(--muted);
}
.topbar p,
.service-desc {
color: var(--muted);
}
.topbar p {
margin-top: 6px;
}
.service-section {
margin-top: 18px;
}
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.section-heading span {
color: var(--muted);
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.service-card,
.api-section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.service-card {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 14px;
min-height: 168px;
padding: 18px;
color: inherit;
text-decoration: none;
overflow: hidden;
}
.service-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.service-card.primary {
border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
}
.service-name {
grid-column: 1 / -1;
font-size: 22px;
font-weight: 700;
}
.service-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--soft);
color: var(--accent);
font-weight: 800;
}
.service-topline {
align-self: center;
color: var(--muted);
font-weight: 650;
}
.service-desc {
grid-column: 1 / -1;
}
.service-port {
grid-column: 1 / -1;
justify-self: start;
border-radius: 999px;
background: var(--soft);
color: #1849a9;
padding: 3px 9px;
}
.api-section {
margin-top: 18px;
padding: 14px 16px;
box-shadow: none;
}
.api-section summary {
cursor: pointer;
font-size: 15px;
font-weight: 700;
}
.api-section .service-grid {
margin-top: 14px;
}

View File

@@ -8,18 +8,20 @@ ENABLE_HTTP_REDIRECT="${KANBAN_ENABLE_HTTP_REDIRECT:-0}"
GITEA_PORT="${KANBAN_GITEA_PORT:-3443}" GITEA_PORT="${KANBAN_GITEA_PORT:-3443}"
STOCK_FRONTEND_PORT="${KANBAN_STOCK_FRONTEND_PORT:-5443}" STOCK_FRONTEND_PORT="${KANBAN_STOCK_FRONTEND_PORT:-5443}"
STOCK_API_PORT="${KANBAN_STOCK_API_PORT:-8788}" STOCK_API_PORT="${KANBAN_STOCK_API_PORT:-8788}"
SERVICE_LIST_ROOT="/var/www/local-service-list"
if [[ "${EUID}" -ne 0 ]]; then if [[ "${EUID}" -ne 0 ]]; then
exec sudo -E bash "$0" "$@" exec sudo -E bash "$0" "$@"
fi fi
if [[ ! -f "${SERVICE_LIST_ROOT}/index.html" || ! -f "${SERVICE_LIST_ROOT}/portal.css" ]]; then
echo "Missing local service list assets under ${SERVICE_LIST_ROOT}" >&2
exit 1
fi
mkdir -p /etc/nginx/certs mkdir -p /etc/nginx/certs
mkdir -p /var/www/local-kanban-portal
cp "${ROOT_DIR}/deploy/certs/local-kanban.crt" /etc/nginx/certs/gahow-pc.fullchain.pem cp "${ROOT_DIR}/deploy/certs/local-kanban.crt" /etc/nginx/certs/gahow-pc.fullchain.pem
cp "${ROOT_DIR}/deploy/certs/local-kanban.key" /etc/nginx/certs/gahow-pc.key cp "${ROOT_DIR}/deploy/certs/local-kanban.key" /etc/nginx/certs/gahow-pc.key
cp "${ROOT_DIR}/portal/index.html" /var/www/local-kanban-portal/index.html
cp "${ROOT_DIR}/portal/portal.css" /var/www/local-kanban-portal/portal.css
rm -f /var/www/local-kanban-portal/portal.js
chmod 600 /etc/nginx/certs/gahow-pc.key chmod 600 /etc/nginx/certs/gahow-pc.key
{ {
@@ -43,7 +45,7 @@ cat <<EOF
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name ${DOMAIN}; server_name ${DOMAIN};
root /var/www/local-kanban-portal; root ${SERVICE_LIST_ROOT};
index index.html; index index.html;
ssl_certificate /etc/nginx/certs/gahow-pc.fullchain.pem; ssl_certificate /etc/nginx/certs/gahow-pc.fullchain.pem;
@@ -135,7 +137,7 @@ rm -f /etc/nginx/sites-enabled/default
nginx -t nginx -t
systemctl reload nginx || systemctl restart nginx systemctl reload nginx || systemctl restart nginx
echo "Nginx portal is configured for https://${DOMAIN}/" echo "Nginx local service list is configured for https://${DOMAIN}/"
echo "Kanban remains available on its own port, for example https://${DOMAIN}:8443/" echo "Kanban remains available on its own port, for example https://${DOMAIN}:8443/"
echo "Gitea HTTPS is configured for https://${DOMAIN}:${GITEA_PORT}/" echo "Gitea HTTPS is configured for https://${DOMAIN}:${GITEA_PORT}/"
echo "Stock Agent HTTPS is configured for https://${DOMAIN}:${STOCK_FRONTEND_PORT}/" echo "Stock Agent HTTPS is configured for https://${DOMAIN}:${STOCK_FRONTEND_PORT}/"

View File

@@ -44,14 +44,11 @@ tmux set-option -t "$tmp" mouse on
first="$(tmux display-message -p -t "$tmp" '#{window_index}')" first="$(tmux display-message -p -t "$tmp" '#{window_index}')"
tmux link-window -k -s "$src" -t "$tmp:$first" tmux link-window -k -s "$src" -t "$tmp:$first"
# A tmux window has a single size shared by every viewer. With the global # A tmux window has one size shared by every viewer. Keep the global
# "latest" policy a bigger local client cuts the browser view off, so pin this # "window-size latest" policy so the most recently active Mac, web, or iOS
# window to "smallest" while a web viewer is attached: the browser always sees # client owns that size and full-screen programs receive SIGWINCH to reflow.
# the full content; a larger local client sees dotted padding instead.
tmux set-window-option -t "$src" window-size smallest
cleanup() { cleanup() {
tmux set-window-option -u -t "$src" window-size 2>/dev/null || true
tmux kill-session -t "$tmp" 2>/dev/null || true tmux kill-session -t "$tmp" 2>/dev/null || true
} }
trap cleanup EXIT HUP TERM INT trap cleanup EXIT HUP TERM INT

View File

@@ -103,7 +103,11 @@ async function route(req, res) {
clearLoginFailures(req); clearLoginFailures(req);
return sendJsonWithHeaders(res, 200, loginHeaders(config), { ok: true }); return sendJsonWithHeaders(res, 200, loginHeaders(config), { ok: true });
} }
if (url.pathname === '/login' || url.pathname === '/login.html' || url.pathname === '/styles.css' || url.pathname === '/login.js') return serveStatic(req, res); if (url.pathname === '/login' || url.pathname === '/login.html') {
if (isAuthenticated(req, config)) return redirect(res, '/');
return serveStatic(req, res);
}
if (url.pathname === '/styles.css' || url.pathname === '/login.js') return serveStatic(req, res);
if (!isAuthenticated(req, config)) { if (!isAuthenticated(req, config)) {
if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' }); if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' });
return redirect(res, '/login'); return redirect(res, '/login');

View File

@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'n
import { dirname } from 'node:path'; import { dirname } from 'node:path';
const COOKIE_NAME = 'kanban_session'; const COOKIE_NAME = 'kanban_session';
const SESSION_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const attempts = new Map(); const attempts = new Map();
const LOGIN_WINDOW_MS = 5 * 60 * 1000; const LOGIN_WINDOW_MS = 5 * 60 * 1000;
const MAX_LOGIN_FAILURES = 8; const MAX_LOGIN_FAILURES = 8;
@@ -101,7 +102,7 @@ export function isAuthenticated(req, config) {
export function loginHeaders(config) { export function loginHeaders(config) {
const secure = config.authSecureCookie ? ' Secure;' : ''; const secure = config.authSecureCookie ? ' Secure;' : '';
return { return {
'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(config.auth.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000;${secure}` 'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(config.auth.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MAX_AGE_SECONDS};${secure}`
}; };
} }

View File

@@ -3,6 +3,7 @@ import { basename } from 'node:path';
import { runProcess } from '../process.js'; import { runProcess } from '../process.js';
const PANE_FORMAT = '#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_current_command}\t#{pane_current_path}'; const PANE_FORMAT = '#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_current_command}\t#{pane_current_path}';
const WEB_TERMINAL_SESSION_PATTERN = /^web-\d+-\d+$/;
const ENGINE_BY_COMMAND = { const ENGINE_BY_COMMAND = {
claude: 'claude', claude: 'claude',
@@ -21,7 +22,9 @@ export function parseTmuxPanes(output) {
.map((line) => { .map((line) => {
const [session, windowIndex, windowName, paneId, command, cwd] = line.split('\t'); const [session, windowIndex, windowName, paneId, command, cwd] = line.split('\t');
const engine = ENGINE_BY_COMMAND[command]; const engine = ENGINE_BY_COMMAND[command];
if (!engine || !paneId) return null; // Web terminal sessions only link an existing pane into a temporary view.
// They are not additional agents and must not be enumerated in Live Agents.
if (!engine || !paneId || WEB_TERMINAL_SESSION_PATTERN.test(session)) return null;
return { engine, session, windowIndex: Number(windowIndex), windowName, paneId, cwd }; return { engine, session, windowIndex: Number(windowIndex), windowName, paneId, cwd };
}) })
.filter(Boolean); .filter(Boolean);

67
test/auth.test.js Normal file
View File

@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
test('login persists authentication and skips the login page on later visits', async () => {
const testDirectory = mkdtempSync(join(tmpdir(), 'local-kanban-auth-'));
const token = 'test-only-auth-token';
process.env.KANBAN_CONFIG = join(testDirectory, 'config.json');
process.env.KANBAN_STATE = join(testDirectory, 'state.json');
process.env.KANBAN_AUTH_TOKEN = token;
process.env.KANBAN_AUTH_SECURE_COOKIE = '1';
process.env.KANBAN_REQUIRE_HTTPS = '0';
const { createKanbanServer } = await import(`../src/server.js?auth-test=${Date.now()}`);
const server = createKanbanServer();
try {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
const initialLoginPage = await fetch(`${origin}/login`, { redirect: 'manual' });
assert.equal(initialLoginPage.status, 200);
const rejectedLogin = await fetch(`${origin}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'wrong-token' })
});
assert.equal(rejectedLogin.status, 401);
assert.equal(rejectedLogin.headers.get('set-cookie'), null);
const loginResponse = await fetch(`${origin}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token })
});
assert.equal(loginResponse.status, 200);
const setCookie = loginResponse.headers.get('set-cookie');
assert.match(setCookie, /^kanban_session=[^;]+;/);
const cookie = setCookie.split(';', 1)[0];
const laterLoginPage = await fetch(`${origin}/login`, {
headers: { cookie },
redirect: 'manual'
});
assert.equal(laterLoginPage.status, 302);
assert.equal(laterLoginPage.headers.get('location'), '/');
assert.match(setCookie, /Max-Age=31536000/);
assert.match(setCookie, /HttpOnly/);
assert.match(setCookie, /Secure/);
const logoutResponse = await fetch(`${origin}/api/auth/logout`, {
method: 'POST',
headers: { cookie }
});
assert.equal(logoutResponse.status, 200);
assert.match(logoutResponse.headers.get('set-cookie'), /^kanban_session=;/);
assert.match(logoutResponse.headers.get('set-cookie'), /Max-Age=0/);
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(testDirectory, { recursive: true, force: true });
}
});

View File

@@ -36,6 +36,33 @@ test('parseTmuxPanes drops unrelated commands', () => {
assert.deepEqual(parseTmuxPanes(output), []); assert.deepEqual(parseTmuxPanes(output), []);
}); });
test('parseTmuxPanes hides temporary web terminal sessions', () => {
const output = [
'main\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban',
'web-1234-5678\t1\tcodex:kanban\t%5\tnode\t/home/gahow/projects/kanban',
'web-development\t2\tcodex:web\t%6\tnode\t/home/gahow/projects/web'
].join('\n');
assert.deepEqual(parseTmuxPanes(output), [
{
engine: 'codex',
session: 'main',
windowIndex: 1,
windowName: 'codex:kanban',
paneId: '%5',
cwd: '/home/gahow/projects/kanban'
},
{
engine: 'codex',
session: 'web-development',
windowIndex: 2,
windowName: 'codex:web',
paneId: '%6',
cwd: '/home/gahow/projects/web'
}
]);
});
test('parseTmuxPanes handles empty output', () => { test('parseTmuxPanes handles empty output', () => {
assert.deepEqual(parseTmuxPanes(''), []); assert.deepEqual(parseTmuxPanes(''), []);
assert.deepEqual(parseTmuxPanes(null), []); assert.deepEqual(parseTmuxPanes(null), []);