taurusdb-mcp 0.5.0-rc.2 → 0.5.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -29
- package/dist/security/browser-operator-session.d.ts +14 -0
- package/dist/security/browser-operator-session.js +57 -0
- package/dist/security/local-credential-login.d.ts +20 -0
- package/dist/security/local-credential-login.js +296 -33
- package/dist/security/local-recovery-approval.d.ts +62 -0
- package/dist/security/local-recovery-approval.js +258 -0
- package/dist/security/session-credential-manager.d.ts +29 -0
- package/dist/security/session-credential-manager.js +85 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +24 -2
- package/dist/tools/query.d.ts +1 -1
- package/dist/tools/query.js +219 -37
- package/dist/tools/registry.d.ts +1 -1
- package/dist/tools/registry.js +40 -12
- package/dist/tools/taurus/cloud-context.js +29 -9
- package/dist/tools/taurus/recycle-bin.d.ts +2 -1
- package/dist/tools/taurus/recycle-bin.js +241 -98
- package/dist/tools/taurus/sql-login.d.ts +1 -0
- package/dist/tools/taurus/sql-login.js +156 -50
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ TaurusDB MCP server for MCP clients such as:
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install taurusdb-mcp
|
|
13
|
+
npm install taurusdb-mcp@latest
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Run directly with:
|
|
@@ -46,9 +46,6 @@ claude mcp add huaweicloud-taurusdb \
|
|
|
46
46
|
-e TAURUSDB_CLOUD_REGION=<your-region> \
|
|
47
47
|
-e TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
|
|
48
48
|
-e TAURUSDB_ENABLE_DYNAMIC_TARGETS=true \
|
|
49
|
-
-e TAURUSDB_SQL_DATABASE=<your-database> \
|
|
50
|
-
-e TAURUSDB_SQL_USER=<your-readonly-user> \
|
|
51
|
-
-e TAURUSDB_SQL_PASSWORD='hw-kms-file:~/.taurusdb-mcp/password.ciphertext' \
|
|
52
49
|
-- npx -y taurusdb-mcp
|
|
53
50
|
```
|
|
54
51
|
|
|
@@ -59,12 +56,19 @@ codex mcp add huaweicloud-taurusdb \
|
|
|
59
56
|
--env TAURUSDB_CLOUD_REGION=<your-region> \
|
|
60
57
|
--env TAURUSDB_CLOUD_KEYCHAIN_SERVICE=taurusdb-mcp/huaweicloud \
|
|
61
58
|
--env TAURUSDB_ENABLE_DYNAMIC_TARGETS=true \
|
|
62
|
-
--env TAURUSDB_SQL_DATABASE=<your-database> \
|
|
63
|
-
--env TAURUSDB_SQL_USER=<your-readonly-user> \
|
|
64
|
-
--env TAURUSDB_SQL_PASSWORD='hw-kms-file:~/.taurusdb-mcp/password.ciphertext' \
|
|
65
59
|
-- npx -y taurusdb-mcp
|
|
66
60
|
```
|
|
67
61
|
|
|
62
|
+
Interactive connection flow:
|
|
63
|
+
|
|
64
|
+
1. Call `list_cloud_taurus_instances`.
|
|
65
|
+
2. Call `select_cloud_taurus_instance`.
|
|
66
|
+
3. Open the returned `login_url` in a browser and enter the database credentials.
|
|
67
|
+
4. Continue with `list_databases`, `set_default_database`, and readonly tools.
|
|
68
|
+
|
|
69
|
+
The browser submits credentials directly to the loopback MCP process. They are not
|
|
70
|
+
sent through Agent-visible tool arguments or persisted by MCP.
|
|
71
|
+
|
|
68
72
|
Initialize local MCP client config:
|
|
69
73
|
|
|
70
74
|
```bash
|
|
@@ -84,32 +88,20 @@ npx taurusdb-mcp credentials check
|
|
|
84
88
|
|
|
85
89
|
- Requires Node.js `>= 20`
|
|
86
90
|
- Depends on `taurusdb-core`
|
|
87
|
-
-
|
|
88
|
-
-
|
|
91
|
+
- Interactive clients do not need database usernames or passwords in MCP configuration
|
|
92
|
+
- Unattended static deployments may use `env:`, `file:`, `hw-csms:`, `hw-kms:`, or `hw-kms-file:` password references
|
|
89
93
|
- macOS Keychain, Linux Secret Service, or Windows Credential Manager cloud identity is enabled with `TAURUSDB_CLOUD_KEYCHAIN_SERVICE`
|
|
90
94
|
- SQL TLS with certificate verification is required by default
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
|
|
95
|
+
- General database mutation tools do not exist; the Agent-facing operating plane remains read-only regardless of database account privileges
|
|
96
|
+
- Recycle-bin request/status tools are visible by default: `prepare_recycle_bin_restore` performs readonly preflight and the loopback-only operator page requires the Agent-invisible HttpOnly browser session established by database login before executing one target-bound native restore with the active in-memory SQL session; no secret file, direct restore tool, or Agent-held execution token is required
|
|
97
|
+
- `analyze_mutation_sql` returns evidence-backed SQL Advice with `execution_status: not_executed`
|
|
98
|
+
- Selecting a cloud instance returns a local SQL login link immediately; login validates the connection before binding credentials, allows at most three attempts per five-minute link, and never sends the password through Agent-visible tool arguments
|
|
99
|
+
- Session SQL credentials expire after 30 idle minutes and eight absolute hours; administrators may shorten these limits with `TAURUSDB_SQL_CREDENTIAL_IDLE_TTL_MINUTES` and `TAURUSDB_SQL_CREDENTIAL_MAX_TTL_MINUTES`
|
|
94
100
|
- MCP audit events are written to `~/.taurusdb-mcp/audit.jsonl` by default, with
|
|
95
101
|
configurable size rotation for collection into centralized immutable storage
|
|
96
102
|
- Run one stdio process per customer/client trust boundary; this package is not
|
|
97
103
|
a shared multi-tenant HTTP service
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
export TAURUSDB_ENABLE_MUTATIONS=true
|
|
103
|
-
export TAURUSDB_SQL_MUTATION_USER='<dedicated-writer>'
|
|
104
|
-
export TAURUSDB_SQL_MUTATION_PASSWORD='hw-csms:<writer-secret-name>'
|
|
105
|
-
export TAURUSDB_MUTATION_APPROVAL_SECRET_FILE='/run/secrets/taurusdb-approval'
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Sign a returned `approval_request` outside the MCP client:
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
npx taurusdb-mcp approve \
|
|
112
|
-
--request '<approval_request>' \
|
|
113
|
-
--actor '<operator-identity>' \
|
|
114
|
-
--secret-file /run/secrets/taurusdb-approval
|
|
115
|
-
```
|
|
105
|
+
Customers execute reviewed general-purpose `advised_sql` through their own controlled
|
|
106
|
+
change process. The only MCP database-state exception is the target-bound,
|
|
107
|
+
same-browser-confirmed recycle-bin recovery described above.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class BrowserOperatorSessionStore {
|
|
2
|
+
private readonly now;
|
|
3
|
+
private readonly cookieName;
|
|
4
|
+
private readonly sessions;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
});
|
|
8
|
+
issue(datasource: string, ttlMs?: number): string;
|
|
9
|
+
authorizes(cookieHeader: string | undefined, datasource: string): boolean;
|
|
10
|
+
clear(): void;
|
|
11
|
+
revokeDatasource(datasource: string): void;
|
|
12
|
+
private readCookie;
|
|
13
|
+
private expire;
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const DEFAULT_TTL_MS = 8 * 60 * 60 * 1000;
|
|
3
|
+
export class BrowserOperatorSessionStore {
|
|
4
|
+
now;
|
|
5
|
+
cookieName;
|
|
6
|
+
sessions = new Map();
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.now = options.now ?? Date.now;
|
|
9
|
+
this.cookieName = `taurusdb_mcp_operator_${randomBytes(8).toString("hex")}`;
|
|
10
|
+
}
|
|
11
|
+
issue(datasource, ttlMs = DEFAULT_TTL_MS) {
|
|
12
|
+
this.expire();
|
|
13
|
+
const token = randomBytes(32).toString("base64url");
|
|
14
|
+
const boundedTtlMs = Math.max(1, Math.min(ttlMs, DEFAULT_TTL_MS));
|
|
15
|
+
this.sessions.set(token, {
|
|
16
|
+
datasource,
|
|
17
|
+
expiresAtMs: this.now() + boundedTtlMs,
|
|
18
|
+
});
|
|
19
|
+
return `${this.cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil(boundedTtlMs / 1000)}`;
|
|
20
|
+
}
|
|
21
|
+
authorizes(cookieHeader, datasource) {
|
|
22
|
+
this.expire();
|
|
23
|
+
const token = this.readCookie(cookieHeader);
|
|
24
|
+
const session = token ? this.sessions.get(token) : undefined;
|
|
25
|
+
return session?.datasource === datasource;
|
|
26
|
+
}
|
|
27
|
+
clear() {
|
|
28
|
+
this.sessions.clear();
|
|
29
|
+
}
|
|
30
|
+
revokeDatasource(datasource) {
|
|
31
|
+
for (const [token, session] of this.sessions) {
|
|
32
|
+
if (session.datasource === datasource)
|
|
33
|
+
this.sessions.delete(token);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
readCookie(cookieHeader) {
|
|
37
|
+
if (!cookieHeader)
|
|
38
|
+
return undefined;
|
|
39
|
+
for (const part of cookieHeader.split(";")) {
|
|
40
|
+
const separator = part.indexOf("=");
|
|
41
|
+
if (separator < 0)
|
|
42
|
+
continue;
|
|
43
|
+
const name = part.slice(0, separator).trim();
|
|
44
|
+
if (name === this.cookieName) {
|
|
45
|
+
return part.slice(separator + 1).trim() || undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
expire() {
|
|
51
|
+
const now = this.now();
|
|
52
|
+
for (const [token, session] of this.sessions) {
|
|
53
|
+
if (session.expiresAtMs <= now)
|
|
54
|
+
this.sessions.delete(token);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,16 +1,30 @@
|
|
|
1
|
+
import { BrowserOperatorSessionStore } from "./browser-operator-session.js";
|
|
1
2
|
export type SqlLoginCredentials = {
|
|
2
3
|
datasource: string;
|
|
3
4
|
username: string;
|
|
4
5
|
password: string;
|
|
5
6
|
};
|
|
7
|
+
export type SqlLoginTarget = {
|
|
8
|
+
datasource: string;
|
|
9
|
+
instanceId?: string;
|
|
10
|
+
region?: string;
|
|
11
|
+
credentialIdleTtlMinutes?: number;
|
|
12
|
+
credentialMaxTtlMinutes?: number;
|
|
13
|
+
};
|
|
6
14
|
export type SqlLoginRequest = {
|
|
7
15
|
datasource: string;
|
|
16
|
+
target?: SqlLoginTarget;
|
|
8
17
|
bind: (credentials: SqlLoginCredentials) => Promise<void>;
|
|
9
18
|
};
|
|
10
19
|
export type IssuedSqlLogin = {
|
|
11
20
|
loginUrl: string;
|
|
12
21
|
expiresAt: string;
|
|
13
22
|
};
|
|
23
|
+
export type CredentialValidationFailure = "credentials" | "connectivity" | "tls" | "timeout";
|
|
24
|
+
export declare class SqlCredentialValidationError extends Error {
|
|
25
|
+
readonly kind: CredentialValidationFailure;
|
|
26
|
+
constructor(kind: CredentialValidationFailure);
|
|
27
|
+
}
|
|
14
28
|
export interface CredentialLoginService {
|
|
15
29
|
issueSqlLogin(request: SqlLoginRequest): Promise<IssuedSqlLogin>;
|
|
16
30
|
close(): Promise<void>;
|
|
@@ -18,10 +32,16 @@ export interface CredentialLoginService {
|
|
|
18
32
|
export type LocalCredentialLoginServiceOptions = {
|
|
19
33
|
now?: () => number;
|
|
20
34
|
tokenTtlMs?: number;
|
|
35
|
+
maxAttempts?: number;
|
|
36
|
+
failureDelayMs?: number;
|
|
37
|
+
operatorSessions?: BrowserOperatorSessionStore;
|
|
21
38
|
};
|
|
22
39
|
export declare class LocalCredentialLoginService implements CredentialLoginService {
|
|
23
40
|
private readonly now;
|
|
24
41
|
private readonly tokenTtlMs;
|
|
42
|
+
private readonly maxAttempts;
|
|
43
|
+
private readonly failureDelayMs;
|
|
44
|
+
private readonly operatorSessions?;
|
|
25
45
|
private readonly pending;
|
|
26
46
|
private server;
|
|
27
47
|
private port;
|
|
@@ -1,42 +1,247 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
4
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
5
|
+
const DEFAULT_FAILURE_DELAY_MS = 1_000;
|
|
4
6
|
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export class SqlCredentialValidationError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
constructor(kind) {
|
|
10
|
+
super(`SQL credential validation failed: ${kind}`);
|
|
11
|
+
this.name = "SqlCredentialValidationError";
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const COPY = {
|
|
16
|
+
"zh-CN": {
|
|
17
|
+
product: "TaurusDB MCP",
|
|
18
|
+
secureSession: "安全会话",
|
|
19
|
+
title: "连接数据库",
|
|
20
|
+
intro: "输入数据库账号和密码以验证连接。",
|
|
21
|
+
target: "连接目标",
|
|
22
|
+
instance: "实例",
|
|
23
|
+
region: "区域",
|
|
24
|
+
datasource: "数据源",
|
|
25
|
+
configured: "已配置的数据源",
|
|
26
|
+
unknown: "未指定",
|
|
27
|
+
username: "数据库账号",
|
|
28
|
+
password: "数据库密码",
|
|
29
|
+
usernamePlaceholder: "请输入数据库账号",
|
|
30
|
+
passwordPlaceholder: "请输入数据库密码",
|
|
31
|
+
submit: "连接数据库",
|
|
32
|
+
submitting: "正在验证连接…",
|
|
33
|
+
privacy: "凭据对 Agent 不可见,仅用于连接您选择的数据库,不会由 MCP 持久化保存。",
|
|
34
|
+
retention: (idle, maximum) => `空闲 ${idle} 分钟后清除 · 最长保留 ${maximum >= 60 ? `${maximum / 60} 小时` : `${maximum} 分钟`}`,
|
|
35
|
+
attempts: (remaining) => `本链接还可尝试 ${remaining} 次`,
|
|
36
|
+
required: "请输入数据库账号和密码。",
|
|
37
|
+
credentials: "无法验证账号信息,请检查账号和密码。",
|
|
38
|
+
connectivity: "数据库服务暂时不可达,请检查网络和实例状态。",
|
|
39
|
+
tls: "TLS 安全连接验证失败,请联系管理员检查证书配置。",
|
|
40
|
+
timeout: "连接验证超时,请稍后重试。",
|
|
41
|
+
busy: "连接正在验证,请勿重复提交。",
|
|
42
|
+
successTitle: "账号验证成功",
|
|
43
|
+
successMessage: "您现在可以返回 MCP 会话并选择需要访问的数据库。",
|
|
44
|
+
expiredTitle: "登录链接已失效",
|
|
45
|
+
expiredMessage: "请返回 MCP 会话并重新调用 begin_sql_login。",
|
|
46
|
+
lockedTitle: "尝试次数已用完",
|
|
47
|
+
lockedMessage: "为保护数据库账号,此链接已失效。请返回 MCP 会话重新生成登录链接。",
|
|
48
|
+
methodTitle: "不支持此请求",
|
|
49
|
+
methodMessage: "请使用登录表单继续。",
|
|
50
|
+
invalidTitle: "无法处理请求",
|
|
51
|
+
invalidMessage: "登录请求无效,请重新生成登录链接。",
|
|
52
|
+
},
|
|
53
|
+
en: {
|
|
54
|
+
product: "TaurusDB MCP",
|
|
55
|
+
secureSession: "Secure session",
|
|
56
|
+
title: "Connect to database",
|
|
57
|
+
intro: "Enter your database credentials to validate the connection.",
|
|
58
|
+
target: "Connection target",
|
|
59
|
+
instance: "Instance",
|
|
60
|
+
region: "Region",
|
|
61
|
+
datasource: "Datasource",
|
|
62
|
+
configured: "Configured datasource",
|
|
63
|
+
unknown: "Not specified",
|
|
64
|
+
username: "Database username",
|
|
65
|
+
password: "Database password",
|
|
66
|
+
usernamePlaceholder: "Enter database username",
|
|
67
|
+
passwordPlaceholder: "Enter database password",
|
|
68
|
+
submit: "Connect to database",
|
|
69
|
+
submitting: "Validating connection…",
|
|
70
|
+
privacy: "Credentials are not visible to the Agent. They are used only to connect to your selected database and are not persisted by MCP.",
|
|
71
|
+
retention: (idle, maximum) => `Cleared after ${idle} idle minutes · ${maximum >= 60 ? `${maximum / 60} ${maximum === 60 ? "hour" : "hours"}` : `${maximum} ${maximum === 1 ? "minute" : "minutes"}`} maximum`,
|
|
72
|
+
attempts: (remaining) => `${remaining} attempts remaining for this link`,
|
|
73
|
+
required: "Enter both the database username and password.",
|
|
74
|
+
credentials: "The account could not be validated. Check the username and password.",
|
|
75
|
+
connectivity: "The database is currently unreachable. Check the network and instance status.",
|
|
76
|
+
tls: "The TLS connection could not be validated. Ask an administrator to check the certificate configuration.",
|
|
77
|
+
timeout: "Connection validation timed out. Try again shortly.",
|
|
78
|
+
busy: "Connection validation is already in progress. Do not submit again.",
|
|
79
|
+
successTitle: "Account validated",
|
|
80
|
+
successMessage: "Return to your MCP session and select the database you want to access.",
|
|
81
|
+
expiredTitle: "Login link expired",
|
|
82
|
+
expiredMessage: "Return to your MCP session and call begin_sql_login again.",
|
|
83
|
+
lockedTitle: "Attempt limit reached",
|
|
84
|
+
lockedMessage: "This link has expired to protect the database account. Return to your MCP session and create a new login link.",
|
|
85
|
+
methodTitle: "Unsupported request",
|
|
86
|
+
methodMessage: "Use the login form to continue.",
|
|
87
|
+
invalidTitle: "Request could not be processed",
|
|
88
|
+
invalidMessage: "The login request is invalid. Create a new login link.",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
function localeFromRequest(req) {
|
|
92
|
+
return /(?:^|,)\s*zh(?:-|_|;|,|$)/i.test(req.headers["accept-language"] ?? "")
|
|
93
|
+
? "zh-CN"
|
|
94
|
+
: "en";
|
|
95
|
+
}
|
|
96
|
+
function escapeHtml(value) {
|
|
97
|
+
return value
|
|
98
|
+
.replaceAll("&", "&")
|
|
99
|
+
.replaceAll("<", "<")
|
|
100
|
+
.replaceAll(">", ">")
|
|
101
|
+
.replaceAll('"', """)
|
|
102
|
+
.replaceAll("'", "'");
|
|
103
|
+
}
|
|
104
|
+
function maskInstanceId(value, fallback) {
|
|
105
|
+
if (!value) {
|
|
106
|
+
return fallback;
|
|
107
|
+
}
|
|
108
|
+
if (value.length <= 10) {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
return `${value.slice(0, 5)}…${value.slice(-4)}`;
|
|
112
|
+
}
|
|
113
|
+
function page(input) {
|
|
114
|
+
const copy = COPY[input.locale];
|
|
115
|
+
const isForm = input.state === "form";
|
|
116
|
+
const title = isForm
|
|
117
|
+
? copy.title
|
|
118
|
+
: input.state === "success"
|
|
119
|
+
? copy.successTitle
|
|
120
|
+
: input.state === "expired"
|
|
121
|
+
? copy.expiredTitle
|
|
122
|
+
: input.state === "locked"
|
|
123
|
+
? copy.lockedTitle
|
|
124
|
+
: input.state === "method"
|
|
125
|
+
? copy.methodTitle
|
|
126
|
+
: copy.invalidTitle;
|
|
127
|
+
const message = input.message ?? (isForm
|
|
128
|
+
? copy.intro
|
|
129
|
+
: input.state === "success"
|
|
130
|
+
? copy.successMessage
|
|
131
|
+
: input.state === "expired"
|
|
132
|
+
? copy.expiredMessage
|
|
133
|
+
: input.state === "locked"
|
|
134
|
+
? copy.lockedMessage
|
|
135
|
+
: input.state === "method"
|
|
136
|
+
? copy.methodMessage
|
|
137
|
+
: copy.invalidMessage);
|
|
138
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
139
|
+
const target = input.target;
|
|
140
|
+
const formHtml = isForm
|
|
141
|
+
? `<form method="post" autocomplete="on" data-login-form>
|
|
142
|
+
${input.message ? `<div class="alert" role="alert">${escapeHtml(input.message)}</div>` : ""}
|
|
143
|
+
<label for="username">${copy.username}</label>
|
|
144
|
+
<input id="username" name="username" autocomplete="username" maxlength="256" value="${escapeHtml(input.username ?? "")}" placeholder="${copy.usernamePlaceholder}" required autofocus>
|
|
145
|
+
<label for="password">${copy.password}</label>
|
|
146
|
+
<input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="${copy.passwordPlaceholder}" required>
|
|
147
|
+
<button type="submit" data-submit data-pending="${copy.submitting}">${copy.submit}</button>
|
|
148
|
+
${input.remainingAttempts !== undefined ? `<p class="attempts">${copy.attempts(input.remainingAttempts)}</p>` : ""}
|
|
149
|
+
</form>`
|
|
150
|
+
: `<section class="result ${input.state}" aria-live="polite">
|
|
151
|
+
<span class="result-mark" aria-hidden="true">${input.state === "success" ? "✓" : "!"}</span>
|
|
152
|
+
<h1>${escapeHtml(title)}</h1>
|
|
153
|
+
<p>${escapeHtml(message)}</p>
|
|
154
|
+
</section>`;
|
|
13
155
|
return `<!doctype html>
|
|
14
|
-
<html lang="
|
|
156
|
+
<html lang="${input.locale}">
|
|
15
157
|
<head>
|
|
16
158
|
<meta charset="utf-8">
|
|
17
159
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
-
<
|
|
160
|
+
<meta name="color-scheme" content="light">
|
|
161
|
+
<title>${escapeHtml(title)} · ${copy.product}</title>
|
|
19
162
|
<style>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
163
|
+
:root { color-scheme: light; --ink:#172033; --muted:#637083; --cloud:#f3f6fa; --surface:#fff; --line:#dce3eb; --teal:#087f73; --teal-dark:#06665e; --teal-soft:#e7f5f2; --danger:#b42318; --danger-soft:#fff0ee; --shadow:0 22px 60px rgba(23,32,51,.12); }
|
|
164
|
+
* { box-sizing:border-box; }
|
|
165
|
+
body { margin:0; min-height:100vh; color:var(--ink); background:var(--cloud); font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
166
|
+
.shell { width:min(980px,calc(100% - 32px)); margin:clamp(24px,7vh,72px) auto; }
|
|
167
|
+
.masthead { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; font-size:13px; letter-spacing:.04em; }
|
|
168
|
+
.brand { font-weight:760; font-size:15px; letter-spacing:-.01em; }
|
|
169
|
+
.session { color:var(--teal-dark); background:var(--teal-soft); padding:7px 11px; border-radius:999px; font-weight:650; }
|
|
170
|
+
.panel { display:grid; grid-template-columns:minmax(270px,.82fr) minmax(340px,1.18fr); overflow:hidden; border:1px solid rgba(23,32,51,.08); border-radius:22px; background:var(--surface); box-shadow:var(--shadow); }
|
|
171
|
+
.context { position:relative; padding:44px 40px; color:#f7fbff; background:var(--ink); }
|
|
172
|
+
.context:after { content:""; position:absolute; width:240px; height:240px; right:-120px; bottom:-130px; border:1px solid rgba(255,255,255,.14); border-radius:50%; box-shadow:0 0 0 34px rgba(255,255,255,.035),0 0 0 72px rgba(255,255,255,.025); }
|
|
173
|
+
.eyebrow { margin:0 0 28px; color:#9edbd4; font-size:12px; font-weight:720; letter-spacing:.11em; text-transform:uppercase; }
|
|
174
|
+
.rail { margin:0; }
|
|
175
|
+
.rail-item { display:grid; grid-template-columns:14px 1fr; gap:0 14px; }
|
|
176
|
+
.rail dt,.rail dd { margin:0; padding-bottom:25px; }
|
|
177
|
+
.rail dt { color:#aeb8c8; font-size:12px; }
|
|
178
|
+
.rail dd { margin-top:18px; font-size:15px; font-weight:650; overflow-wrap:anywhere; }
|
|
179
|
+
.node { position:relative; width:10px; height:10px; margin-top:4px; border:2px solid #73d2c8; border-radius:50%; background:var(--ink); }
|
|
180
|
+
.node:not(.last):after { content:""; position:absolute; left:2px; top:10px; width:2px; height:54px; background:linear-gradient(#3b8f88,rgba(59,143,136,.16)); }
|
|
181
|
+
.target-row { grid-column:2; display:grid; grid-template-columns:76px 1fr; }
|
|
182
|
+
.retention { position:relative; z-index:1; margin:18px 0 0; padding-top:18px; border-top:1px solid rgba(255,255,255,.13); color:#aeb8c8; font-size:12px; line-height:1.55; }
|
|
183
|
+
.content { padding:44px 48px 36px; }
|
|
184
|
+
.content h1 { margin:0 0 10px; font-size:clamp(28px,4vw,38px); line-height:1.08; letter-spacing:-.035em; }
|
|
185
|
+
.intro { margin:0 0 30px; color:var(--muted); line-height:1.65; }
|
|
186
|
+
form { display:grid; gap:9px; }
|
|
187
|
+
label { margin-top:9px; font-size:13px; font-weight:700; }
|
|
188
|
+
input { width:100%; min-height:48px; border:1px solid #cbd4df; border-radius:10px; padding:11px 13px; color:var(--ink); background:#fbfcfe; font:inherit; outline:none; transition:border-color .16s,box-shadow .16s,background .16s; }
|
|
189
|
+
input:focus { border-color:var(--teal); background:#fff; box-shadow:0 0 0 4px rgba(8,127,115,.12); }
|
|
190
|
+
button { min-height:50px; margin-top:17px; border:0; border-radius:10px; color:#fff; background:var(--teal); font:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .16s,transform .16s; }
|
|
191
|
+
button:hover { background:var(--teal-dark); }
|
|
192
|
+
button:active { transform:translateY(1px); }
|
|
193
|
+
button:focus-visible { outline:3px solid rgba(8,127,115,.28); outline-offset:3px; }
|
|
194
|
+
button:disabled { cursor:wait; opacity:.72; }
|
|
195
|
+
.alert { margin-bottom:4px; border-left:3px solid var(--danger); border-radius:7px; padding:11px 13px; color:#8d1b13; background:var(--danger-soft); font-size:13px; line-height:1.5; }
|
|
196
|
+
.attempts { margin:2px 0 0; color:var(--muted); text-align:center; font-size:12px; }
|
|
197
|
+
.privacy { display:flex; gap:10px; align-items:flex-start; margin:27px 0 0; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; line-height:1.6; }
|
|
198
|
+
.shield { flex:0 0 auto; color:var(--teal); font-size:15px; line-height:1.3; }
|
|
199
|
+
.result { min-height:280px; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; }
|
|
200
|
+
.result-mark { display:grid; place-items:center; width:46px; height:46px; margin-bottom:22px; border-radius:50%; color:#fff; background:var(--danger); font-size:22px; font-weight:800; }
|
|
201
|
+
.result.success .result-mark { background:var(--teal); }
|
|
202
|
+
.result p { max-width:35rem; color:var(--muted); line-height:1.7; }
|
|
203
|
+
@media (max-width:760px) { .shell{width:min(100% - 20px,560px);margin:18px auto}.panel{grid-template-columns:1fr;border-radius:17px}.context{padding:28px 26px 18px}.content{padding:30px 26px}.rail dt,.rail dd{padding-bottom:17px}.node:not(.last):after{height:44px}.content h1{font-size:30px} }
|
|
204
|
+
@media (prefers-reduced-motion:reduce) { *,*:before,*:after{scroll-behavior:auto!important;transition:none!important} }
|
|
24
205
|
</style>
|
|
25
206
|
</head>
|
|
26
207
|
<body>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
208
|
+
<main class="shell">
|
|
209
|
+
<header class="masthead"><span class="brand">${copy.product}</span><span class="session">${copy.secureSession}</span></header>
|
|
210
|
+
<section class="panel">
|
|
211
|
+
<aside class="context">
|
|
212
|
+
<p class="eyebrow">${copy.target}</p>
|
|
213
|
+
<dl class="rail">
|
|
214
|
+
<div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.instance}</dt><dd>${escapeHtml(maskInstanceId(target?.instanceId, copy.configured))}</dd></div></div>
|
|
215
|
+
<div class="rail-item"><span class="node"></span><div class="target-row"><dt>${copy.region}</dt><dd>${escapeHtml(target?.region ?? copy.unknown)}</dd></div></div>
|
|
216
|
+
<div class="rail-item"><span class="node last"></span><div class="target-row"><dt>${copy.datasource}</dt><dd>${escapeHtml(target?.datasource ?? copy.unknown)}</dd></div></div>
|
|
217
|
+
</dl>
|
|
218
|
+
${target?.credentialIdleTtlMinutes && target.credentialMaxTtlMinutes ? `<p class="retention">${copy.retention(target.credentialIdleTtlMinutes, target.credentialMaxTtlMinutes)}</p>` : ""}
|
|
219
|
+
</aside>
|
|
220
|
+
<div class="content">
|
|
221
|
+
${isForm ? `<h1>${copy.title}</h1><p class="intro">${copy.intro}</p>` : ""}
|
|
222
|
+
${formHtml}
|
|
223
|
+
${isForm ? `<p class="privacy"><span class="shield" aria-hidden="true">◆</span><span>${copy.privacy}</span></p>` : ""}
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
</main>
|
|
227
|
+
${isForm ? `<script nonce="${nonce}">document.querySelector('[data-login-form]').addEventListener('submit',()=>{const b=document.querySelector('[data-submit]');b.disabled=true;b.textContent=b.dataset.pending;});</script>` : ""}
|
|
30
228
|
</body>
|
|
31
229
|
</html>`;
|
|
32
230
|
}
|
|
33
231
|
function respond(res, status, body) {
|
|
232
|
+
const nonce = /<script nonce="([^"]+)"/.exec(body)?.[1];
|
|
34
233
|
res.writeHead(status, {
|
|
35
234
|
"cache-control": "no-store",
|
|
36
|
-
"content-security-policy":
|
|
235
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'${nonce ? `; script-src 'nonce-${nonce}'` : ""}`,
|
|
37
236
|
"content-type": "text/html; charset=utf-8",
|
|
237
|
+
"cross-origin-opener-policy": "same-origin",
|
|
238
|
+
"cross-origin-resource-policy": "same-origin",
|
|
239
|
+
expires: "0",
|
|
240
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
|
241
|
+
pragma: "no-cache",
|
|
38
242
|
"referrer-policy": "no-referrer",
|
|
39
243
|
"x-content-type-options": "nosniff",
|
|
244
|
+
"x-frame-options": "DENY",
|
|
40
245
|
});
|
|
41
246
|
res.end(body);
|
|
42
247
|
}
|
|
@@ -53,21 +258,30 @@ async function readBody(req) {
|
|
|
53
258
|
}
|
|
54
259
|
return Buffer.concat(chunks).toString("utf8");
|
|
55
260
|
}
|
|
261
|
+
function delay(ms) {
|
|
262
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
263
|
+
}
|
|
56
264
|
export class LocalCredentialLoginService {
|
|
57
265
|
now;
|
|
58
266
|
tokenTtlMs;
|
|
267
|
+
maxAttempts;
|
|
268
|
+
failureDelayMs;
|
|
269
|
+
operatorSessions;
|
|
59
270
|
pending = new Map();
|
|
60
271
|
server;
|
|
61
272
|
port;
|
|
62
273
|
constructor(options = {}) {
|
|
63
274
|
this.now = options.now ?? Date.now;
|
|
64
275
|
this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
|
276
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
277
|
+
this.failureDelayMs = options.failureDelayMs ?? DEFAULT_FAILURE_DELAY_MS;
|
|
278
|
+
this.operatorSessions = options.operatorSessions;
|
|
65
279
|
}
|
|
66
280
|
async issueSqlLogin(request) {
|
|
67
281
|
await this.ensureStarted();
|
|
68
282
|
const token = randomBytes(32).toString("base64url");
|
|
69
283
|
const expiresAtMs = this.now() + this.tokenTtlMs;
|
|
70
|
-
this.pending.set(token, { ...request, expiresAtMs });
|
|
284
|
+
this.pending.set(token, { ...request, expiresAtMs, failedAttempts: 0, inFlight: false });
|
|
71
285
|
return {
|
|
72
286
|
loginUrl: `http://127.0.0.1:${this.port}/sql-login/${token}`,
|
|
73
287
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
@@ -75,6 +289,7 @@ export class LocalCredentialLoginService {
|
|
|
75
289
|
}
|
|
76
290
|
async close() {
|
|
77
291
|
this.pending.clear();
|
|
292
|
+
this.operatorSessions?.clear();
|
|
78
293
|
const server = this.server;
|
|
79
294
|
this.server = undefined;
|
|
80
295
|
this.port = undefined;
|
|
@@ -108,6 +323,7 @@ export class LocalCredentialLoginService {
|
|
|
108
323
|
this.port = address.port;
|
|
109
324
|
}
|
|
110
325
|
async handle(req, res) {
|
|
326
|
+
const locale = localeFromRequest(req);
|
|
111
327
|
try {
|
|
112
328
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
113
329
|
const match = url.pathname.match(/^\/sql-login\/([^/]+)$/);
|
|
@@ -117,40 +333,87 @@ export class LocalCredentialLoginService {
|
|
|
117
333
|
if (token) {
|
|
118
334
|
this.pending.delete(token);
|
|
119
335
|
}
|
|
120
|
-
respond(res, 410, page(
|
|
336
|
+
respond(res, 410, page({ locale, state: "expired", target: pending?.target }));
|
|
121
337
|
return;
|
|
122
338
|
}
|
|
123
339
|
if (req.method === "GET") {
|
|
124
|
-
respond(res, 200, page(
|
|
340
|
+
respond(res, 200, page({
|
|
341
|
+
locale,
|
|
342
|
+
state: "form",
|
|
343
|
+
target: pending.target,
|
|
344
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
345
|
+
}));
|
|
125
346
|
return;
|
|
126
347
|
}
|
|
127
348
|
if (req.method !== "POST") {
|
|
128
|
-
respond(res, 405, page(
|
|
349
|
+
respond(res, 405, page({ locale, state: "method", target: pending.target }));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const origin = req.headers.origin;
|
|
353
|
+
if (origin && origin !== `http://${req.headers.host}`) {
|
|
354
|
+
respond(res, 403, page({ locale, state: "invalid", target: pending.target }));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (pending.inFlight) {
|
|
358
|
+
respond(res, 409, page({
|
|
359
|
+
locale,
|
|
360
|
+
state: "form",
|
|
361
|
+
target: pending.target,
|
|
362
|
+
message: COPY[locale].busy,
|
|
363
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
364
|
+
}));
|
|
129
365
|
return;
|
|
130
366
|
}
|
|
131
367
|
const form = new URLSearchParams(await readBody(req));
|
|
132
368
|
const username = form.get("username")?.trim() ?? "";
|
|
133
369
|
const password = form.get("password") ?? "";
|
|
134
370
|
if (!username || !password) {
|
|
135
|
-
respond(res, 400, page(
|
|
371
|
+
respond(res, 400, page({
|
|
372
|
+
locale,
|
|
373
|
+
state: "form",
|
|
374
|
+
target: pending.target,
|
|
375
|
+
message: COPY[locale].required,
|
|
376
|
+
username,
|
|
377
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
378
|
+
}));
|
|
136
379
|
return;
|
|
137
380
|
}
|
|
138
|
-
this.pending.delete(token);
|
|
139
381
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
username,
|
|
143
|
-
password,
|
|
144
|
-
});
|
|
382
|
+
pending.inFlight = true;
|
|
383
|
+
await pending.bind({ datasource: pending.datasource, username, password });
|
|
145
384
|
}
|
|
146
|
-
catch {
|
|
147
|
-
|
|
385
|
+
catch (error) {
|
|
386
|
+
pending.inFlight = false;
|
|
387
|
+
const kind = error instanceof SqlCredentialValidationError ? error.kind : "connectivity";
|
|
388
|
+
if (kind === "credentials") {
|
|
389
|
+
pending.failedAttempts += 1;
|
|
390
|
+
await delay(this.failureDelayMs * 2 ** (pending.failedAttempts - 1));
|
|
391
|
+
if (pending.failedAttempts >= this.maxAttempts) {
|
|
392
|
+
this.pending.delete(token);
|
|
393
|
+
respond(res, 429, page({ locale, state: "locked", target: pending.target }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
respond(res, kind === "credentials" ? 401 : kind === "timeout" ? 504 : 502, page({
|
|
398
|
+
locale,
|
|
399
|
+
state: "form",
|
|
400
|
+
target: pending.target,
|
|
401
|
+
message: COPY[locale][kind],
|
|
402
|
+
username,
|
|
403
|
+
remainingAttempts: this.maxAttempts - pending.failedAttempts,
|
|
404
|
+
}));
|
|
148
405
|
return;
|
|
149
406
|
}
|
|
150
|
-
|
|
407
|
+
this.pending.delete(token);
|
|
408
|
+
const maxTtlMinutes = Math.min(pending.target?.credentialIdleTtlMinutes ?? 30, pending.target?.credentialMaxTtlMinutes ?? 480);
|
|
409
|
+
const operatorCookie = this.operatorSessions?.issue(pending.datasource, maxTtlMinutes * 60_000);
|
|
410
|
+
if (operatorCookie) {
|
|
411
|
+
res.setHeader("set-cookie", operatorCookie);
|
|
412
|
+
}
|
|
413
|
+
respond(res, 200, page({ locale, state: "success", target: pending.target }));
|
|
151
414
|
}
|
|
152
415
|
catch {
|
|
153
|
-
respond(res, 400, page(
|
|
416
|
+
respond(res, 400, page({ locale, state: "invalid" }));
|
|
154
417
|
}
|
|
155
418
|
}
|
|
156
419
|
}
|