diff --git a/Cargo.lock b/Cargo.lock index d423c35..8f8495d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 8b0e268..33c44da 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.2.1" +version = "0.2.2" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 44b36d8..be2f451 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -39,6 +39,15 @@ const SESSION_LOGIN_PROVIDER: &str = "login_provider"; #[template(path = "login.html")] struct LoginTemplate { has_google: bool, + base_url: String, + version: &'static str, +} + +#[derive(Template)] +#[template(path = "home.html")] +struct HomeTemplate { + is_logged_in: bool, + base_url: String, version: &'static str, } @@ -134,7 +143,8 @@ pub fn web_router() -> Router { "/.well-known/oauth-protected-resource", get(oauth_protected_resource_metadata), ) - .route("/", get(login_page)) + .route("/", get(home_page)) + .route("/login", get(login_page)) .route("/auth/google", get(auth_google)) .route("/auth/google/callback", get(auth_google_callback)) .route("/auth/logout", post(auth_logout)) @@ -188,6 +198,21 @@ async fn favicon_svg() -> Response { .expect("favicon response") } +// ── Home page (public) ─────────────────────────────────────────────────────── + +async fn home_page( + State(state): State, + session: Session, +) -> Result { + let is_logged_in = current_user_id(&session).await.is_some(); + let tmpl = HomeTemplate { + is_logged_in, + base_url: state.base_url.clone(), + version: env!("CARGO_PKG_VERSION"), + }; + render_template(tmpl) +} + // ── Login page ──────────────────────────────────────────────────────────────── async fn login_page( @@ -200,6 +225,7 @@ async fn login_page( let tmpl = LoginTemplate { has_google: state.google_config.is_some(), + base_url: state.base_url.clone(), version: env!("CARGO_PKG_VERSION"), }; render_template(tmpl) @@ -282,16 +308,16 @@ where { if let Some(err) = params.error { tracing::warn!(provider, error = %err, "OAuth error"); - return Ok(Redirect::to("/?error=oauth_error").into_response()); + return Ok(Redirect::to("/login?error=oauth_error").into_response()); } let Some(code) = params.code else { tracing::warn!(provider, "OAuth callback missing code"); - return Ok(Redirect::to("/?error=oauth_missing_code").into_response()); + return Ok(Redirect::to("/login?error=oauth_missing_code").into_response()); }; let Some(returned_state) = params.state.as_deref() else { tracing::warn!(provider, "OAuth callback missing state"); - return Ok(Redirect::to("/?error=oauth_missing_state").into_response()); + return Ok(Redirect::to("/login?error=oauth_missing_state").into_response()); }; let expected_state: Option = session.get(SESSION_OAUTH_STATE).await.map_err(|e| { @@ -304,7 +330,7 @@ where expected_present = expected_state.is_some(), "OAuth state mismatch (empty session often means SameSite=Strict or server restart)" ); - return Ok(Redirect::to("/?error=oauth_state").into_response()); + return Ok(Redirect::to("/login?error=oauth_state").into_response()); } if let Err(e) = session.remove::(SESSION_OAUTH_STATE).await { tracing::warn!(provider, error = %e, "failed to remove oauth_state from session"); @@ -430,7 +456,7 @@ async fn dashboard( session: Session, ) -> Result { let Some(user_id) = current_user_id(&session).await else { - return Ok(Redirect::to("/").into_response()); + return Ok(Redirect::to("/login").into_response()); }; let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| { @@ -438,7 +464,7 @@ async fn dashboard( StatusCode::INTERNAL_SERVER_ERROR })? { Some(u) => u, - None => return Ok(Redirect::to("/").into_response()), + None => return Ok(Redirect::to("/login").into_response()), }; let tmpl = DashboardTemplate { @@ -457,7 +483,7 @@ async fn audit_page( session: Session, ) -> Result { let Some(user_id) = current_user_id(&session).await else { - return Ok(Redirect::to("/").into_response()); + return Ok(Redirect::to("/login").into_response()); }; let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| { @@ -465,7 +491,7 @@ async fn audit_page( StatusCode::INTERNAL_SERVER_ERROR })? { Some(u) => u, - None => return Ok(Redirect::to("/").into_response()), + None => return Ok(Redirect::to("/login").into_response()), }; let rows = list_for_user(&state.pool, user_id, 100) diff --git a/crates/secrets-mcp/static/llms.txt b/crates/secrets-mcp/static/llms.txt index e8fa819..e7672e3 100644 --- a/crates/secrets-mcp/static/llms.txt +++ b/crates/secrets-mcp/static/llms.txt @@ -2,10 +2,15 @@ > 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**:Streamable HTTP **MCP**(Model Context Protocol)与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth(如已配置)登录 Web;MCP 调用使用 API Key 与加密相关请求头。 +## 公开页面 + +- **`/`**:公开首页,说明安全架构(客户端密钥派生、密文存储、多租户与审计等),无需登录。 + ## 不应抓取或索引的内容 - **`/mcp`**:MCP 流式 HTTP 端点(JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。 - **`/api/*`**:会话或 API Key 相关的 HTTP API。 +- **`/login`**:登录入口页(`noindex` / robots 通常 disallow)。 - **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。 ## 给 AI 助手的实用提示 @@ -16,7 +21,7 @@ ## 延伸阅读 -- 开源仓库中的 `README.md`、`AGENTS.md`(若可访问)包含环境变量、表结构与运维约定。 +- 源码仓库:(`README.md`、`AGENTS.md` 含环境变量、表结构与运维约定)。 ## 关于本文件 diff --git a/crates/secrets-mcp/static/robots.txt b/crates/secrets-mcp/static/robots.txt index 51bcb18..4109430 100644 --- a/crates/secrets-mcp/static/robots.txt +++ b/crates/secrets-mcp/static/robots.txt @@ -8,8 +8,11 @@ Disallow: /api/ Disallow: /dashboard Disallow: /audit Disallow: /auth/ +Disallow: /login Disallow: /account/ +# 首页 `/` 为公开安全说明页,允许抓取。 + # 面向 AI / LLM 的机器可读站点说明(Markdown):/llms.txt # Human & AI-readable site summary: /llms.txt (also /ai.txt) @@ -24,4 +27,5 @@ Disallow: /api/ Disallow: /dashboard Disallow: /audit Disallow: /auth/ +Disallow: /login Disallow: /account/ diff --git a/crates/secrets-mcp/templates/home.html b/crates/secrets-mcp/templates/home.html new file mode 100644 index 0000000..a5d34ee --- /dev/null +++ b/crates/secrets-mcp/templates/home.html @@ -0,0 +1,269 @@ + + + + + + + + + + + Secrets MCP — 端到端加密的密钥管理 + + + + + + + + + + + +
+
+

端到端加密的密钥与配置管理

+

Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。

+
+
+
+ +

客户端密钥派生

+

PBKDF2-SHA256(约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。

+
+
+ +

AES-256-GCM 加密

+

敏感字段以 AES-GCM 密文落库;Web 端在本地加解密,明文默认不经过服务端持久化。

+
+
+ +

审计与历史

+

操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。

+
+
+
+ + + + diff --git a/crates/secrets-mcp/templates/login.html b/crates/secrets-mcp/templates/login.html index 835e507..a7cedee 100644 --- a/crates/secrets-mcp/templates/login.html +++ b/crates/secrets-mcp/templates/login.html @@ -3,8 +3,19 @@ + + + + - Secrets — Sign In + 登录 — Secrets MCP + + + + + + +