Compare commits

...

94 Commits

Author SHA1 Message Date
agent
57c3efb70e feat(auth): 服务端托管 Google OAuth;修复未解锁 vault 时 bootstrap
- API:桌面登录 session、Google 托管回调与轮询
- Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码
- 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
2026-04-14 22:05:11 +08:00
agent
e6bd2225cd fix(desktop): 解析 Google OAuth 客户端文件路径并更新示例与说明
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m5s
2026-04-14 19:40:42 +08:00
agent
328962706b chore(desktop): 跟踪 apps/desktop/dist 静态资源并统一文档与忽略规则 2026-04-14 19:23:58 +08:00
agent
763d99b15e fix(mcp): remove secrets_find/add/update aliases; align docs and repair script
Some checks failed
Secrets v3 CI / 检查 (push) Failing after 2m16s
2026-04-14 18:51:05 +08:00
agent
0374899dab feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db
- Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp*
- Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md
- Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
2026-04-14 17:37:12 +08:00
voson
cb5865b958 release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
2026-04-12 22:47:46 +08:00
voson
34093b0e23 feat: add secrets-mcp-local gateway (proxy, unlock cache, plaintext tool gate) 2026-04-12 22:47:46 +08:00
voson
0bf06bbc73 chore: empty checkpoint 2026-04-12 22:47:46 +08:00
voson
f86d12b80e feat(secrets-mcp): /entries 按 tags 筛选,发版 0.5.27
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m48s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Web:tags 查询参数、folder 计数 SQL、分页与 tab 保留 tags;模板与 i18n
- README / AGENTS / CHANGELOG;plans/web-tags-filter 等计划文档
2026-04-11 21:39:36 +08:00
voson
43d6164a15 fix(oauth): reqwest 启用 system-proxy;Google 换 token 诊断与 0.5.26
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
- 工作区 reqwest 增加 system-proxy,与系统代理一致
- google.rs:超时/非 2xx 体日志;OAuth 请求单独 45s 超时
- README / AGENTS / deploy/.env.example:OAuth 出站与 HTTPS_PROXY 说明
2026-04-11 21:33:30 +08:00
voson
1b2fbdae4d feat(secrets-mcp): /changelog 页(Markdown 渲染)、首页与 Dashboard 入口
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- 新增 CHANGELOG.md 构建嵌入、pulldown-cmark 渲染、路由 /changelog
- 首页页脚与 MCP Dashboard 页脚提供变更记录链接;同步 README、AGENTS
- 版本 secrets-mcp 0.5.24
2026-04-11 21:27:26 +08:00
voson
ab1e3329b9 docs: 同步 README/AGENTS — rollback 仅需 Bearer、env_map 命名规则、key_version 与 JSON 会话、导出 secret_types
本次不发版(仅文档)。
2026-04-11 20:41:20 +08:00
voson
c3b1a0df1a merge: code-review fixes (d7720662 baseline + 9f8a 文案常量化、env_prefix 测试、补充用例); secrets-mcp 0.5.21
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
- default 已 rebase 到 d7720662;合并说明见 plans/merge-code-review-fixes-2026-04-11.md
- Web PATCH 长度错误用 validation 常量拼接;env_map 单测;import/api_key 单测
- rustfmt 收尾
2026-04-11 20:39:01 +08:00
voson
d772066210 fix(secrets-mcp 0.5.20): code review plan — export secret types, env map, rollback, API key, MCP tools, web session & validation
- Export/import: optional secret_types map; AddResult includes entry_id
- env_map: dot→__ segment encoding; collision errors
- rollback: FOR UPDATE + txn-consistent snapshot; restore name from history
- regenerate_api_key: rows_affected guard
- MCP: find count propagates errors; add uses entry_id for relations; rollback no encryption key
- Web: load_session_user_strict + JSON handlers key_version; PATCH length limits
- Tests: ExportEntry serde, env segment
2026-04-11 17:10:28 +08:00
voson
2c7dbf890b docs: add code review fixes plan 2026-04-11 17:04:18 +08:00
voson
8c49316923 release(secrets-mcp): 0.5.19 — 条目列表文件夹列与筛选重置文案;偶数行名称列斑马纹;文档同步 Web 列说明
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:33:05 +08:00
voson
cf93488c6a release(secrets-mcp): 0.5.18 — Web 条目密文值编辑,PATCH /api/secrets/:id 支持 value
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:14:04 +08:00
137a4d42b0 release(secrets-mcp): 0.5.17 — 取消生产环境强制 PG TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m27s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 1m35s
移除 SECRETS_ENV=production 时对 verify-ca/verify-full 的硬性要求,
仍可通过 SECRETS_DATABASE_SSL_MODE 显式选择模式。

Made-with: Cursor
2026-04-10 17:10:55 +08:00
agent
ff2ea91e72 release(secrets-mcp): 0.5.16 — 回收站页面 title 国际化
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m32s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
2026-04-10 14:15:15 +08:00
agent
574c1c9967 release(secrets-mcp): 0.5.15 — 列设置面板锚定优化,移除查看密文隐藏功能
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-10 11:54:20 +08:00
voson
98d69f5f12 fix(update): include deleted_at in SELECT for EntryWriteRow mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m43s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
The update_fields_by_id query was missing deleted_at column, causing
sqlx FromRow mapping to fail against EntryWriteRow struct.
2026-04-09 20:55:05 +08:00
agent
089d0b4b58 style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s
2026-04-09 17:32:40 +08:00
agent
10da51c203 ci: 添加版本 bump 硬检查,防止代码变更未发版
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- CI 工作流解析版本时检查 crates/ 变更是否伴随版本 bump
- 若代码变更但版本号未变,直接失败并提示
- 与 scripts/release-check.sh 本地检查形成双保险
2026-04-07 14:05:59 +08:00
agent
bc8995cf71 chore: sync Cargo.lock with Cargo.toml version 0.5.12
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
2026-04-07 13:47:51 +08:00
agent
5333b863c5 refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 1m44s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
- bump 0.5.12
2026-04-07 13:32:29 +08:00
agent
6fde982c20 refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m6s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
2026-04-07 13:25:33 +08:00
agent
a2a80a1744 fix(dashboard): 修正 OpenCode MCP 配置,移除 enabled 字段、添加 oauth: false
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m50s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 11:17:03 +08:00
dfe282095c feat(dashboard): OpenCode MCP 配置改用原生 Streamable HTTP,移除 mcp-remote 中转;bump 0.5.11
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 10:43:17 +08:00
voson
59084a409d release(secrets-mcp): 0.5.10 — Web 模块化、性能与错误处理
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 拆分 web.rs 为 web/ 子模块;统一 client_ip 提取
- core: user_scope SQL 复用、env_map N+1 消除、FETCH_ALL 上限调整
- entries 列表页并行查询;PgPool 去 Arc;结构化 NotFound 等错误
- CI: SSH 私钥安全写入;crypto/hex 与依赖清理;MCP 输入长度校验
- AGENTS: API Key 明文存储设计说明
2026-04-06 23:41:07 +08:00
voson
b0fcb83592 release(secrets-mcp): 0.5.9 — users.key_version 与会话失效;Web 条目解密 API 与列表增强
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m24s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-06 17:23:20 +08:00
voson
8942718641 scripts: 添加基于 CSV 的 MCP secrets 重加密修复工具
通过读取 entry_id/secret_name/secret_value 调用 secrets_update 让服务端用当前密钥重加密。附带模板 CSV,.gitignore 忽略 *.pyc。
2026-04-06 16:38:37 +08:00
voson
53d53ff96a release(secrets-mcp): 0.5.8 — 修复更换密码短语流程
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- secrets-core: change_user_key() 事务内全量解密并重加密 secrets
- web: POST /api/key-change;已有密钥时拒绝 POST /api/key-setup(409)
- dashboard: 更换密码需当前密码,调用 key-change
- 同步 Cargo.lock
2026-04-06 12:04:35 +08:00
voson
cab234cfcb feat(secrets-mcp): 增强 MCP 请求日志与 encryption_key 参数支持
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m28s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
logging.rs:
- 每条 MCP POST 日志新增 auth_key(Bearer token 前12字符掩码)、
  enc_key(X-Encryption-Key 前4后4字符指纹,如 146b…5516(64) 或 absent)、
  user_id、tool_args(白名单非敏感参数摘要)字段
- 新增辅助函数 mask_bearer / mask_enc_key / extract_tool_args / summarize_value

tools.rs:
- extract_enc_key 成功路径增加 debug 级指纹日志(raw_len/trimmed_len/prefix/suffix)
- 新增 extract_enc_key_or_arg / require_user_and_key_or_arg:优先使用参数传入的密钥,
  fallback 到 X-Encryption-Key 头,绕过 Cursor Chat MCP 头透传异常
- GetSecretInput / AddInput / UpdateInput / ExportInput / EnvMapInput 各增加可选
  encryption_key 字段,对应工具实现改用 require_user_and_key_or_arg
2026-04-06 11:03:01 +08:00
voson
e0fee639c1 release(secrets-mcp): 0.5.7
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-05 17:07:31 +08:00
voson
7c53bfb782 feat(core): entry 历史附带关联 secret 密文快照,rollback 可恢复 N:N 与密文
- db: metadata_with_secret_snapshot / strip / parse 辅助
- add/update/delete/rollback 在写 entries_history 前合并快照
- rollback: 按历史快照同步 entry_secrets、更新或插入 secrets
- 满足 clippy collapsible_if
2026-04-05 17:06:53 +08:00
voson
63cb3a8216 release(secrets-mcp): 0.5.6
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m8s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
修复 OAuth 解绑时非法聚合 FOR UPDATE,Web OAuth 审计 IP 与 TRUST_PROXY 对齐并校验 IP,账号绑定写入 oauth_state 失败时回滚 bind 标记。回滚条目时恢复 folder/type,导入冲突检查在 DB 失败时传播错误,MCP delete/history 要求已登录用户,全局请求体 10MiB 限制。CI 部署支持 DEPLOY_KNOWN_HOSTS,默认 accept-new;文档与 deploy 示例补充连接池、限流、TRUST_PROXY。移除含明文凭据的 sync-test-to-prod 脚本。
2026-04-05 15:29:03 +08:00
voson
2b994141b8 release(secrets-mcp): 0.5.5 — 生产 CORS 显式 allow_methods,修复 tower-http 启动 panic
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
credentials + wildcard methods/headers 被 tower-http 禁止;生产环境改为 GET/POST/PATCH/DELETE/OPTIONS 白名单。
2026-04-05 12:27:40 +08:00
voson
9d6ac5c13a release(secrets-mcp): 0.5.4 — Web 分页修正与 hex 解码;批量删除上限;MCP @ 路径检测
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
2026-04-05 12:14:40 +08:00
voson
1860cce86c release(secrets-mcp): 0.5.3 — 审计日志分页与 Web;CONTRIBUTING;文档与模板修正 2026-04-05 11:34:04 +08:00
dd24f7cc44 release: secrets-mcp 0.5.2
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Bump version: secrets-mcp-0.5.1 tag already existed while crates had further changes.

Made-with: Cursor
2026-04-05 10:38:50 +08:00
voson
aefad33870 chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m22s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库
- MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示
- 新增 deser 单元测试;README/AGENTS 与 models 注释同步

Made-with: Cursor
2026-04-04 21:27:33 +08:00
voson
0ffb81e57f feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
2026-04-04 20:30:32 +08:00
voson
4a1654c820 docs: update MCP tools list, env vars, taxonomy and deploy structure 2026-04-04 18:04:35 +08:00
voson
a15e2eaf4a docs: align README with removed SQL migration scripts
Made-with: Cursor
2026-04-04 17:58:26 +08:00
voson
1518388374 chore(release): secrets-mcp 0.4.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Bump version for the N:N entry_secrets data model and related MCP/Web
changes. Remove superseded SQL migration artifacts; rely on auto-migrate.
Add structured errors, taxonomy normalization, and web i18n helpers.

Made-with: Cursor
2026-04-04 17:58:12 +08:00
b99d821644 Merge pull request 'refactor/entry-secret-nn' (#1) from refactor/entry-secret-nn into main
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m42s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s
Reviewed-on: #1
2026-04-03 19:44:47 +08:00
voson
32f275f88a feat(secrets-mcp): bump 0.3.9 and normalize listen address log
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Prepare a new release version and improve startup log readability by showing localhost for loopback bind addresses without changing runtime binding behavior.

Made-with: Cursor
2026-04-03 19:36:12 +08:00
王松
c6fb457734 feat(nn): entry–secret N:N, unique secret names, web unlink
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 2m37s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Bump secrets-mcp to 0.3.8 (tag 0.3.7 already used).

- Junction table entry_secrets; secrets user-scoped with type
- Per-user unique secrets.name; link_secret_names on add
- Manual migrations + migrate script; MCP/tool and Web updates

Made-with: Cursor
2026-04-03 17:37:04 +08:00
df701f21b9 feat(secrets-mcp): 共享 key 删除时自动迁移并重定向 (v0.3.7)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m4s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
删除仍被 metadata.key_ref 引用的 key 条目时,在同一事务内将密文复制到首个引用方,
其余引用方的 key_ref 重定向到新 owner;env_map 解析 key_ref 时不再限定 type=key。
Web 删除 API 返回 migrated;Dashboard 删除成功后提示迁移。

Bump secrets-mcp to 0.3.7;补充删除迁移相关单测(需 SECRETS_DATABASE_URL)。

Made-with: Cursor
2026-04-03 09:27:20 +08:00
c3c536200e feat(secrets-mcp): Web 条目编辑 API 与 Notes 列表展示优化(0.3.6)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: EntryWriteRow;按 id 更新/删除(含并发冲突与唯一键)
- Web: PATCH/DELETE /api/entries/{id};列表编辑/删除与错误映射
- entries 模板:Notes 限高滚动;空 Notes 不显示占位框
- 版本 0.3.5 → 0.3.6,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:58:10 +08:00
7909f7102d feat(secrets-mcp): 条目页按 folder/type 筛选并发版 0.3.5
- entries 路由支持 ?folder=&type= 查询,与搜索层 SearchParams 对齐
- 条目列表页增加筛选表单与说明文案
- 版本 0.3.4 → 0.3.5,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:37:36 +08:00
87a29af82d feat(web): 条目列表页 /entries 与总条数统计
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m56s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 新增受会话保护的 GET /entries,仅展示 entries 非敏感列
- search: list_entries、count_entries 共享筛选条件;分页与计数不读 secrets
- 侧边栏在 dashboard/audit 增加「条目」入口
- secrets-mcp 0.3.4(tag 尚未存在)

Made-with: Cursor
2026-04-02 11:26:51 +08:00
1b11f7e976 release(secrets-mcp): v0.3.3 — 强制 PostgreSQL TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 7s
显式引入数据库 TLS 配置并在生产环境拒绝弱 sslmode,避免连接静默降级。同步更新 deploy/README 与运维 runbook,落地 db.refining.ltd 的证书与服务器配置流程。

Made-with: Cursor
2026-04-01 15:18:14 +08:00
08e81363c9 release(secrets-mcp): v0.3.2 — 修复 key_ref 多租户与歧义
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m41s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- env_map:key_ref 解析传入 user_id;支持 folder/name;多条匹配时报错
- 文档同步 key_ref 说明
- bump secrets-mcp 0.3.1 → 0.3.2,更新 Cargo.lock

Made-with: Cursor
2026-03-27 10:45:12 +08:00
voson
beade4503d release(secrets-mcp): v0.3.1
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m45s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- MCP: secrets_find, secrets_overview; secrets_get id-only; id on update/delete/history/rollback
- Add meta_obj/secrets_obj; delete guard; env_map/instructions updates
- Core: resolve_entry_by_id; get_*_by_id validates entry + tenant before decrypt

Made-with: Cursor
2026-03-26 17:35:56 +08:00
voson
409fd78a35 Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m39s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Rename namespace/kind to folder/type on entries, audit_log, and history tables;
  add notes. Unique key is (user_id, folder, name).
- Service layer and MCP tools support name-first lookup with optional folder when
  multiple entries share the same name.
- secrets_delete dry_run uses the same disambiguation as real deletes.
- Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and
  AGENTS.md.

Made-with: Cursor
2026-03-26 15:12:28 +08:00
voson
f7afd7f819 docs: 同步 CI 触发路径、覆盖式 tag/Release 说明与 RUST_LOG 示例
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m11s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- AGENTS.md / README:与 workflow 变更路径、远端 tag 覆盖及非 draft Release 行为一致
- deploy/.env.example:补充可选 RUST_LOG 注释

Made-with: Cursor
2026-03-22 16:15:29 +08:00
voson
719bdd7e08 feat(secrets-mcp): public home at /, login at /login (0.2.2)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Bump secrets-mcp to 0.2.2 and sync Cargo.lock.

Add home.html landing with SEO and footer link to the refining/secrets
repository; serve it at / and expose /login for sign-in.

Update OAuth error redirects and dashboard unauthenticated redirects to
/login. Improve login page meta tags, back-home link, and OAuth error
alert. Refresh llms.txt and robots.txt.

Made-with: Cursor
2026-03-22 16:11:59 +08:00
voson
1e597559a2 feat(core): FK for user_id columns; MCP search requires user
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Add fk_entries_user_id, fk_entries_history_user_id, fk_audit_log_user_id (ON DELETE SET NULL)
- Add scripts/cleanup-orphan-user-ids.sql for pre-deploy orphan user_id cleanup
- Remove deprecated SERVER_MASTER_KEY / per-user key wrap helpers from secrets-core
- secrets-mcp: require authenticated user for secrets_search; improve body-read failure response
- Bump secrets-mcp to 0.2.1

Made-with: Cursor
2026-03-22 15:40:02 +08:00
voson
e3ca43ca3f release(secrets-mcp): 0.2.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m12s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 日志时间戳使用本地时区(chrono RFC3339 + 偏移)
- MCP tools / Web 路由与行为调整
- 新增 static/llms.txt、robots.txt;文档与 deploy 示例同步

Made-with: Cursor
2026-03-22 14:44:00 +08:00
voson
0b57605103 feat(secrets-mcp): MCP 请求日志、探测 404 与资源元数据
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 新增 logging 中间件:记录 client_ip、ua、JSON-RPC、tool 等
- tools 各入口/出口结构化日志
- 探测型 404(/.well-known、GET /mcp)降为 debug
- /.well-known/oauth-protected-resource 最小元数据
- secrets-mcp 0.1.11

Made-with: Cursor
2026-03-21 17:57:10 +08:00
voson
8b191937cd docs(AGENTS): 精简提交/推送规则第4条
Made-with: Cursor
2026-03-21 16:56:06 +08:00
voson
11c936a5b8 docs(AGENTS): 明确提交/推送前必须检查版本号与运行 fmt/clippy/test
Made-with: Cursor
2026-03-21 16:48:47 +08:00
voson
b6349dd1c8 chore(secrets-mcp): bump version to 0.1.10
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m57s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 16:46:33 +08:00
voson
f720983328 refactor(db): 移除无意义 actor,修复 history 多租户与模型
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has started running
- 删除 entries_history / audit_log / secrets_history 的 actor 列及写入逻辑
- MCP secrets_history 透传当前 user_id
- Entry 增加 user_id,search 查询不再用伪 UUID
- 迁移:保留 users.api_key,从 api_keys 表回退时生成新明文 key 并删表
- 文档:audit_log auth 语义、API Key 存储说明

Made-with: Cursor
2026-03-21 16:45:50 +08:00
voson
7bd0603dc6 chore(secrets-mcp): bump version to 0.1.9
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 12:25:38 +08:00
voson
17a95bea5b refactor(audit): 移除旧格式兼容,user_id 统一走列字段
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- audit_log 查询去掉 detail->>'user_id' 回退分支
- login_detail 不再冗余写入 user_id 到 detail JSON
- 迁移 SQL 去掉多余的 ALTER TABLE ADD COLUMN

Made-with: Cursor
2026-03-21 12:24:00 +08:00
voson
a42db62702 style(secrets-mcp): rustfmt web.rs audit mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Made-with: Cursor
2026-03-21 12:06:29 +08:00
voson
2edb970cba chore(secrets-mcp): bump version to 0.1.8
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:05:22 +08:00
voson
17f8ac0dbc web: 审计页时间按浏览器本地时区显示
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 25s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:03:44 +08:00
voson
259fbe10a6 ci: 精简 Release upsert 逻辑
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m36s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
提取 auth/api 公共变量避免重复;用 xargs 单行替换 while 循环清理
旧 assets;POST 分支用管道直接取 id 省去临时文件。
279 行 → 248 行。

Made-with: Cursor
2026-03-21 11:36:43 +08:00
voson
c815fb4cc8 ci: 修复覆盖重发时 Release 唯一约束冲突
DELETE + POST 同名 release 会触发 Gitea 的 UQE_release_n 约束。
改为:已有 release → PATCH 更新 name/body,再逐个删除旧 assets 后重传;
      无 release → 正常 POST 新建。

Made-with: Cursor
2026-03-21 11:33:45 +08:00
voson
90cd1eca15 ci: 允许对同版本覆盖重发版
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 4m33s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
- 解析版本时不再 exit 1,改为记录 tag_exists=true 并打印警告
- 创建 Tag 步骤:若 tag 已存在则先本地删除再远端删除,再重新打带注释的 tag
- 创建 Release 步骤:先查询同名 Release,若存在则 DELETE 旧 Release,再 POST 新建

Made-with: Cursor
2026-03-21 11:22:24 +08:00
voson
da007348ea ci: 合并为 ci + deploy 两个 job,check 先于 build
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
单台 self-hosted runner 下并行 job 只是排队,多 job 拆分带来的
artifact 传递、重复 checkout、调度延迟反而更慢。

改动:
- 原 version/check/build-linux/publish-release 四个 job 合并为单个 ci job
- 步骤顺序:版本拦截 → fmt/clippy/test → build → 打 tag → 发 Release
- tag 在构建成功后才创建,避免失败提交留下脏 tag
- Release 创建+上传+发布合并为单步,去掉草稿中转
- deploy job 仅保留 artifact 下载 + SSH 部署逻辑,不再重复编译
- 整体从 400 行缩减至 244 行

Made-with: Cursor
2026-03-21 11:18:10 +08:00
voson
f2344b7543 feat(secrets-mcp): 审计页、audit_log user_id、OAuth 登录与仪表盘 footer
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 7m20s
Secrets MCP — Build & Release / Build Linux (musl) (push) Successful in 8m23s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- audit_log 增加 user_id;业务写审计透传 user_id
- Web /audit 与侧边栏;Dashboard 版本 footer 贴底(margin-top: auto)
- 停止 API Key 鉴权成功写入登录审计
- 文档、CI、release-check 配套更新

Made-with: Cursor
2026-03-21 11:12:11 +08:00
voson
ee028d45c3 ci: 优化 workflow 并行度与产物传递
- check 与 build-linux 改为并行执行,节省约 10min
- 新增 upload-artifact / download-artifact,deploy-mcp 直接复用二进制,免重复编译(节省约 15min)
- check / build 缓存加入 target/ 目录,加速增量编译
- 提取 MUSL_TARGET 全局变量,消除 x86_64-unknown-linux-musl 硬编码
- publish-release 增加 check 结果检查,质量失败时不发布 Release
- 移除 build-linux 冗余飞书通知,publish-release 汇总已覆盖

Made-with: Cursor
2026-03-21 10:07:29 +08:00
voson
a44c8ebf08 feat(mcp): persist login audit for OAuth and API key
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m32s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
- Add audit::log_login in secrets-core (audit_log detail: user_id, provider, client_ip, user_agent)
- Log web Google OAuth success after session established
- Log MCP Bearer API key auth success in middleware
- Bump secrets-mcp to 0.1.6 (tag 0.1.5 existed)

Made-with: Cursor
2026-03-21 09:48:52 +08:00
voson
a595081c4c fix(dashboard): OpenCode 配置顶层 mcp 包裹;bump secrets-mcp 0.1.5
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m15s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m36s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 5s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m34s
Made-with: Cursor
2026-03-21 09:23:51 +08:00
voson
0a8b14211a ci: 恢复 secrets workflow 为标准发版流程
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Failing after 2s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Has been skipped
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
Made-with: Cursor
2026-03-21 09:17:35 +08:00
voson
9cebbd7587 ci: 支持构建重跑并跳过重复发版
All checks were successful
Secrets MCP — Build & Release / 检测变更范围 (push) Successful in 3s
Secrets MCP — Build & Release / 版本 & Release (push) Has been skipped
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Has been skipped
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
让 workflow 根据变更范围区分发版构建与仅验证构建,并补充手动触发入口,避免已有版本 tag 阻塞缓存恢复后的重跑验证。

Made-with: Cursor
2026-03-21 09:10:05 +08:00
voson
4d136a5a20 ci: 停止缓存 target,避免 runner 磁盘耗尽
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m43s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
同时将 secrets-mcp 版本提升到 0.1.4,以触发新的构建与发布流程。

Made-with: Cursor
2026-03-20 22:10:48 +08:00
voson
7ce4aaf835 ci: 缓存键包含 Rust 版本;chore(secrets-mcp): 0.1.3
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m2s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-20 22:04:40 +08:00
voson
bce01a0f2b chore(secrets-mcp): bump version to 0.1.2
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m21s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 8s
Made-with: Cursor
2026-03-20 21:56:57 +08:00
voson
8cd4dbf592 ci: 固定 Rust 1.94.0(rust-toolchain + Gitea Actions)
Made-with: Cursor
2026-03-20 21:54:13 +08:00
voson
ad3c8d1672 chore(secrets-mcp): bump version to 0.1.1
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m12s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-20 21:37:20 +08:00
voson
8d6b9f0368 ci: 质量检查依赖版本 job,重复 tag 时提前失败
Made-with: Cursor
2026-03-20 21:35:00 +08:00
voson
ce9e089348 chore: CI 微调、文档与 dashboard 更新、精简 Gitea Actions 安装脚本
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Failing after 2s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m8s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
Made-with: Cursor
2026-03-20 21:31:43 +08:00
voson
786675ce42 ci: allow secrets-mcp workflow on mcp branch
Enable build and deploy jobs when pushing the current mcp branch so CI artifacts can be used for deployment without waiting for manual server compilation.

Made-with: Cursor
2026-03-20 20:33:47 +08:00
voson
5df4141935 feat: user-scoped history/delete/rollback, dashboard & login UI, ignore *.pem
- Filter history/rollback/delete by user_id in secrets-core
- MCP tools/web pass user context; dashboard refresh; favicon static
- .gitignore *.pem; vscode tasks tweaks
- clippy: collapse else-if in rollback latest-history branch

Made-with: Cursor
2026-03-20 20:11:19 +08:00
voson
49fb7430a8 refactor: workspace secrets-core + secrets-mcp MCP SaaS
- Split library (db/crypto/service) and MCP/Web/OAuth binary
- Add deploy examples and CI/docs updates

Made-with: Cursor
2026-03-20 17:36:00 +08:00
voson
ff9767ff95 chore(ci): 精简 publish-release job,移除多余 checkout
Made-with: Cursor
2026-03-19 20:26:45 +08:00
voson
955acfe9ec feat(run): 选择性字段注入、dry-run 预览、默认 JSON 输出
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m4s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m13s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
- run 新增 -s/--secret 字段过滤,只注入指定字段到子进程(最小权限)
- run 新增 --dry-run 模式,输出变量名与来源映射,不执行命令、不暴露值
- run 新增 -o 参数,dry-run 默认 JSON 输出
- 默认输出格式改为始终 json,移除 TTY 自动切换逻辑,-o text 供人类使用
- build_injected_env_map 签名从 &[SecretField] 改为 &[&SecretField]
- 更新 AGENTS.md、README.md、.vscode/tasks.json
- version: 0.9.5 → 0.9.6

Made-with: Cursor
2026-03-19 17:39:09 +08:00
voson
3a5ec92bf0 fix: inject/run 仅注入 secrets 字段,不含 metadata
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m36s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- build_injected_env_map 不再合并 metadata
- 删除 build_metadata_env_map 及其测试
- 更新 README、AGENTS.md 文档
- bump 版本至 0.9.5

Made-with: Cursor
2026-03-19 17:03:01 +08:00
voson
854720f10c chore: remove field_type and value_len from secrets schema
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m34s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Drop field_type, value_len from secrets and secrets_history tables
- Remove infer_field_type, compute_value_len from add.rs
- Simplify search output to field names only
- Update AGENTS.md, README.md documentation

Bump version to 0.9.4

Made-with: Cursor
2026-03-19 16:48:23 +08:00
89 changed files with 24707 additions and 6729 deletions

View File

@@ -1,12 +1,13 @@
name: Secrets CLI - Build & Release
name: Secrets v3 CI
on:
push:
branches: [main]
paths:
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'deploy/**'
- '.gitea/workflows/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,140 +17,38 @@ permissions:
contents: write
env:
BINARY_NAME: secrets
SECRETS_UPGRADE_URL: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/latest
RUST_TOOLCHAIN: 1.94.0
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
MUSL_TARGET: x86_64-unknown-linux-musl
jobs:
version:
name: 版本 & Release
ci:
name: 检查
runs-on: debian
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
tag_exists: ${{ steps.ver.outputs.tag_exists }}
release_id: ${{ steps.release.outputs.release_id }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 解析版本
id: ver
# ── Rust 工具链 ──────────────────────────────────────────────────────
- name: 安装 Rust 与 musl 工具链
run: |
version=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-${version}"
previous_tag=$(git tag --list 'secrets-*' --sort=-v:refname | awk -v tag="$tag" '$0 != tag { print; exit }')
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "previous_tag=${previous_tag}" >> "$GITHUB_OUTPUT"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
echo "版本 ${tag} 已存在"
else
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
echo "将创建新版本 ${tag}"
fi
- name: 严格拦截重复版本
if: steps.ver.outputs.tag_exists == 'true'
run: |
echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。"
echo "请先 bump Cargo.toml 中的 version并执行 cargo build 同步 Cargo.lock。"
exit 1
- name: 创建 Tag
if: steps.ver.outputs.tag_exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}"
git push origin "${{ steps.ver.outputs.tag }}"
- name: 解析或创建 Release
id: release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "release_id=" >> "$GITHUB_OUTPUT"
exit 0
fi
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ steps.ver.outputs.tag }}"
version="${{ steps.ver.outputs.version }}"
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
http_code=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
"${release_api}/tags/${tag}")
if [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/release.json)
if [ -n "$release_id" ]; then
echo "已找到现有 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
previous_tag="${{ steps.ver.outputs.previous_tag }}"
if [ -n "$previous_tag" ]; then
changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD")
else
changes=$(git log --pretty=format:'- %s (%h)')
fi
[ -z "$changes" ] && changes="- 首次发布"
body=$(printf '## 变更日志\n\n%s' "$changes")
payload=$(jq -n \
--arg tag "$tag" \
--arg name "${{ env.BINARY_NAME }} ${version}" \
--arg body "$body" \
'{tag_name: $tag, name: $name, body: $body, draft: true}')
http_code=$(curl -sS -o /tmp/create-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$release_api" \
-d "$payload")
if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/create-release.json)
fi
if [ -n "$release_id" ]; then
echo "已创建草稿 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
else
echo "⚠ 创建 Release 失败 (HTTP ${http_code}),跳过产物上传"
cat /tmp/create-release.json 2>/dev/null || true
echo "release_id=" >> "$GITHUB_OUTPUT"
fi
check:
name: 质量检查 (fmt / clippy / test)
runs-on: debian
timeout-minutes: 10
steps:
- name: 安装 Rust
run: |
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
sudo apt-get update -qq
sudo apt-get install -y -qq pkg-config musl-tools binutils jq
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup component add rustfmt clippy
- uses: actions/checkout@v4
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal \
--component rustfmt --component clippy
rustup default "${RUST_TOOLCHAIN}"
rustup target add "${MUSL_TARGET}" --toolchain "${RUST_TOOLCHAIN}"
rustc -V && cargo -V
- name: 缓存 Cargo
uses: actions/cache@v4
@@ -159,325 +58,44 @@ jobs:
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-check-${{ hashFiles('Cargo.lock') }}
key: cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-check-
cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-
cargo-${{ env.MUSL_TARGET }}-
- run: cargo fmt -- --check
- run: cargo clippy --locked -- -D warnings
- run: cargo test --locked
# ── 质量检查(先于构建,失败即止)──────────────────────────────────
- name: fmt
run: cargo fmt -- --check
build-linux:
name: Build (x86_64-unknown-linux-musl)
needs: [version, check]
runs-on: debian
timeout-minutes: 15
steps:
- name: 安装依赖
- name: clippy
run: cargo clippy --locked -- -D warnings
- name: test
run: cargo test --locked
- name: 构建 secrets-api
run: |
sudo apt-get update
sudo apt-get install -y pkg-config musl-tools binutils curl
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add x86_64-unknown-linux-musl
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
cargo build --release --locked -p secrets-api
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-x86_64-unknown-linux-musl-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-unknown-linux-musl-
- run: cargo build --release --locked --target x86_64-unknown-linux-musl
- run: strip target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
- name: 构建 secrets-desktop-daemon
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
sha256sum "$archive" > "${archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
cargo build --release --locked -p secrets-desktop-daemon
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ needs.version.outputs.tag }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets linux 构建${icon}
版本:${tag}
msg="secrets v3 CI ${icon}
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
build-macos:
name: Build (macOS aarch64 + x86_64)
needs: [version, check]
runs-on: darwin-arm64
timeout-minutes: 15
steps:
- name: 安装依赖
run: |
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-macos-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-macos-
- run: cargo build --release --locked --target aarch64-apple-darwin
- run: cargo build --release --locked --target x86_64-apple-darwin
- run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}
- run: strip -x target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
release_id="${{ needs.version.outputs.release_id }}"
arm_bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}"
arm_archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$arm_archive" -C "$(dirname "$arm_bin")" "$(basename "$arm_bin")"
shasum -a 256 "$arm_archive" > "${arm_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${arm_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${arm_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
intel_bin="target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}"
intel_archive="${{ env.BINARY_NAME }}-${tag}-x86_64-macos.tar.gz"
tar -czf "$intel_archive" -C "$(dirname "$intel_bin")" "$(basename "$intel_bin")"
shasum -a 256 "$intel_archive" > "${intel_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets macOS 双架构构建${icon}
版本:${tag}
目标aarch64-apple-darwin, x86_64-apple-darwin
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(python3 -c "import json,sys; print(json.dumps({'msg_type':'text','content':{'text':sys.argv[1]}}))" "$msg")
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
build-windows:
name: Build (x86_64-pc-windows-msvc)
needs: [version, check]
runs-on: windows
timeout-minutes: 15
steps:
- name: 安装依赖
shell: pwsh
run: |
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe
}
$env:Path = "$cargoBin;$env:Path"
Add-Content -Path $env:GITHUB_PATH -Value $cargoBin
rustup target add x86_64-pc-windows-msvc
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-x86_64-pc-windows-msvc-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-pc-windows-msvc-
- name: 构建
shell: pwsh
run: cargo build --release --locked --target x86_64-pc-windows-msvc
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
shell: pwsh
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if (-not $env:RELEASE_TOKEN) { exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip"
Compress-Archive -Path $bin -DestinationPath $archive -Force
$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
Set-Content -Path "${archive}.sha256" -Value "$hash $archive" -NoNewline
$url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item $archive }
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item "${archive}.sha256" }
- name: 飞书通知
if: always()
shell: pwsh
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
if (-not $env:WEBHOOK_URL) { exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$commit = (git log -1 --pretty=format:"%s" 2>$null) ?? "N/A"
$url = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
$result = "${{ job.status }}"
$icon = if ($result -eq "success") { "✅" } else { "❌" }
$msg = "secrets windows 构建${icon}`n版本${tag}`n提交${commit}`n作者${{ github.actor }}`n详情${url}"
$payload = @{ msg_type = "text"; content = @{ text = $msg } } | ConvertTo-Json
Invoke-RestMethod -Uri $env:WEBHOOK_URL -Method Post `
-ContentType "application/json" -Body $payload
publish-release:
name: 发布草稿 Release
needs: [version, build-linux, build-macos, build-windows]
if: always() && needs.version.outputs.release_id != ''
runs-on: debian
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
if [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
echo "存在未成功的构建任务,保留草稿 Release"
echo "linux=${linux_r} macos=${macos_r} windows=${windows_r}"
exit 0
fi
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}"
http_code=$(curl -sS -o /tmp/publish-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X PATCH "$release_api" \
-d '{"draft":false}')
if [ "$http_code" != "200" ]; then
echo "发布草稿 Release 失败 (HTTP ${http_code})"
cat /tmp/publish-release.json 2>/dev/null || true
exit 1
fi
echo "Release 已发布"
- name: 飞书汇总通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ needs.version.outputs.tag }}"
tag_exists="${{ needs.version.outputs.tag_exists }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
publish_r="${{ job.status }}"
icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; }
if [ "$linux_r" = "success" ] && [ "$macos_r" = "success" ] && [ "$windows_r" = "success" ] && [ "$publish_r" = "success" ]; then
status="发布成功 ✅"
elif [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
status="构建失败 ❌"
else
status="发布失败 ❌"
fi
if [ "$tag_exists" = "false" ]; then
version_line="🆕 新版本 ${tag}"
else
version_line="🔄 重复构建 ${tag}"
fi
msg="secrets ${status}
${version_line}
linux $(icon "$linux_r") | macOS $(icon "$macos_r") | windows $(icon "$windows_r") | Release $(icon "$publish_r")
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"

11
.gitignore vendored
View File

@@ -2,3 +2,14 @@
.env
.DS_Store
.cursor/
*.pem
tmp/
client_secret_*.apps.googleusercontent.com.json
node_modules/
*.pyc
# Tauri app icon pack: generated by `cargo tauri icon apps/desktop/src-tauri/icons/icon.png`
# Version control only the 1024×1024 master; regenerate the rest locally or in release builds.
apps/desktop/src-tauri/icons/**
!apps/desktop/src-tauri/icons/
!apps/desktop/src-tauri/icons/icon.png

149
.vscode/tasks.json vendored
View File

@@ -1,149 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "cargo build",
"group": { "kind": "build", "isDefault": true }
},
{
"label": "cli: version",
"type": "shell",
"command": "./target/debug/secrets -V",
"dependsOn": "build"
},
{
"label": "cli: help",
"type": "shell",
"command": "./target/debug/secrets --help",
"dependsOn": "build"
},
{
"label": "cli: help add",
"type": "shell",
"command": "./target/debug/secrets help add",
"dependsOn": "build"
},
{
"label": "cli: help config",
"type": "shell",
"command": "./target/debug/secrets help config",
"dependsOn": "build"
},
{
"label": "cli: config path",
"type": "shell",
"command": "./target/debug/secrets config path",
"dependsOn": "build"
},
{
"label": "cli: config show",
"type": "shell",
"command": "./target/debug/secrets config show",
"dependsOn": "build"
},
{
"label": "test: search all",
"type": "shell",
"command": "./target/debug/secrets search",
"dependsOn": "build"
},
{
"label": "test: search all (verbose)",
"type": "shell",
"command": "./target/debug/secrets --verbose search",
"dependsOn": "build"
},
{
"label": "test: search by namespace (refining)",
"type": "shell",
"command": "./target/debug/secrets search -n refining",
"dependsOn": "build"
},
{
"label": "test: search by namespace (ricnsmart)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart",
"dependsOn": "build"
},
{
"label": "test: search servers",
"type": "shell",
"command": "./target/debug/secrets search --kind server",
"dependsOn": "build"
},
{
"label": "test: search services",
"type": "shell",
"command": "./target/debug/secrets search --kind service",
"dependsOn": "build"
},
{
"label": "test: search keys",
"type": "shell",
"command": "./target/debug/secrets search --kind key",
"dependsOn": "build"
},
{
"label": "test: search by tag (aliyun)",
"type": "shell",
"command": "./target/debug/secrets search --tag aliyun",
"dependsOn": "build"
},
{
"label": "test: search by tag (hongkong)",
"type": "shell",
"command": "./target/debug/secrets search --tag hongkong",
"dependsOn": "build"
},
{
"label": "test: search keyword (gitea)",
"type": "shell",
"command": "./target/debug/secrets search -q gitea",
"dependsOn": "build"
},
{
"label": "test: inject service secrets",
"type": "shell",
"command": "./target/debug/secrets inject -n refining --kind service --name gitea",
"dependsOn": "build"
},
{
"label": "test: combined search (ricnsmart + server + shanghai)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart --kind server --tag shanghai",
"dependsOn": "build"
},
{
"label": "test: add + delete roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search metadata ---' && ./target/debug/secrets search -n test && echo '--- inject secrets ---' && ./target/debug/secrets inject -n test --kind demo --name roundtrip-test && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
"dependsOn": "build"
},
{
"label": "test: add + delete roundtrip (verbose)",
"type": "shell",
"command": "echo '--- add (verbose) ---' && ./target/debug/secrets --verbose add -n test --kind demo --name roundtrip-verbose --tag test -m foo=bar -s password=secret123 && echo '--- delete (verbose) ---' && ./target/debug/secrets --verbose delete -n test --kind demo --name roundtrip-verbose",
"dependsOn": "build"
},
{
"label": "test: update roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name update-test --tag v1 -m env=staging && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name update-test --add-tag v2 --remove-tag v1 -m env=production && echo '--- verify ---' && ./target/debug/secrets search -n test --kind demo && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind demo --name update-test",
"dependsOn": "build"
},
{
"label": "test: audit log",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name audit-test -m foo=bar -s key=val && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name audit-test -m foo=baz && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name audit-test && echo '--- audit log (last 5) ---' && psql $DATABASE_URL -c \"SELECT action, namespace, kind, name, actor, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT 5;\"",
"dependsOn": "build"
},
{
"label": "test: add with file secret",
"type": "shell",
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./test-fixtures/example-key.pem && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify inject ---' && ./target/debug/secrets inject -n test --kind key --name test-key && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build"
}
]
}

860
AGENTS.md
View File

@@ -1,686 +1,240 @@
# Secrets CLI — AGENTS.md
# Secrets — AGENTS.md
## 提交 / 发版硬规则(优先于下文其他说明)
本仓库当前为 **v3 桌面端架构**
1. 涉及 `src/**``Cargo.toml``Cargo.lock`、CLI 行为变更的提交,默认视为**需要发版**,除非用户明确说明“本次不发版”。
2. 发版前必须先检查 `Cargo.toml` 中的 `version`,再检查是否已存在对应 tag`git tag -l 'secrets-*'`
3. 若当前版本对应 tag 已存在,必须先 bump `Cargo.toml``version`,再执行 `cargo build` 同步 `Cargo.lock`,然后才能提交。
4. 提交前优先运行 `./scripts/release-check.sh`;该脚本会检查重复版本并执行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
- `apps/api`:远端 JSON API
- `apps/desktop/src-tauri`:桌面客户端
- `crates/desktop-daemon`:本地 MCP daemon
- `crates/application` / `domain` / `infrastructure-db`v3 业务与数据层
跨设备密钥与配置管理 CLI 工具,将服务器信息、服务凭据等存储到 PostgreSQL 18供 AI 工具读取上下文。每个加密字段单独行存储(`secrets` 子表),字段名、类型、长度以明文保存,主密钥由 Argon2id 从主密码派生并存入平台安全存储macOS Keychain / Windows Credential Manager / Linux keyutils
`secrets-core` / `secrets-mcp` / `secrets-mcp-local` 已移除,不再作为开发入口
## 项目结构
## 版本控制
```
secrets/
src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Entry + SecretField 结构体sqlx::FromRow + serde
audit.rs # 审计写入log_tx事务内
commands/
init.rs # init 命令:主密钥初始化(每台设备一次)
add.rs # add 命令upsert entries + 逐字段写入 secrets含历史快照
config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件查询,展示 secrets 字段 schema无需 master_key
delete.rs # delete 命令事务化CASCADE 删除 secrets含历史快照
update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
history.rs # history 命令:查看 entry 变更历史列表
run.rs # inject / run 命令:逐字段解密 + key_ref 引用解析
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入
scripts/
release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
.vscode/tasks.json # 本地测试任务build / config / search / add+delete / update / audit 等)
```
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。
## 数据库
### 常用 jj 命令对照
- **Host**: `<host>:<port>`
- **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@<host>:<port>/secrets`
- **表**: `entries`(主表)+ `secrets`(加密字段子表)+ `entries_history` + `secrets_history` + `audit_log` + `kv_config`首次连接自动建表auto-migrate
| 操作 | jj 命令 |
|------|---------|
| 查看历史 | `jj log` / `jj log 'all()'` |
| 查看状态 | `jj status` |
| 新建提交 | `jj commit` |
| 创建新变更 | `jj new` |
| 变基 | `jj rebase` |
| 合并提交 | `jj squash` |
| 撤销操作 | `jj undo` |
| 查看标签 | `jj tag list` |
| 查看分支 | `jj bookmark list` |
| 推送远端 | `jj git push` |
| 拉取远端 | `jj git fetch` |
### 表结构
### 注意事项
```sql
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart"
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service" | "key"(可扩展)
name VARCHAR(256) NOT NULL, -- 人类可读标识
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
version BIGINT NOT NULL DEFAULT 1, -- 乐观锁版本号,每次写操作自增
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
)
```
- 本仓库为纯 `jj` 模式,本地不要使用 `git` 命令。
- CI Runner 侧仍可能使用 `git` 拉代码,这不影响本地开发。
- 检查 tag 是否存在时,使用 `jj log --no-graph --revisions "tag(${tag})"`
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL, -- 明文字段名: "username", "token", "ssh_key"
field_type VARCHAR(32) NOT NULL DEFAULT 'string', -- 明文类型: "string"|"number"|"boolean"|"json"
value_len INT NOT NULL DEFAULT 0, -- 明文原始值字符数PEM≈4096token≈40
encrypted BYTEA NOT NULL DEFAULT '\x', -- 仅加密值本身nonce(12B)||ciphertext+tag
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
)
```
## 提交前检查
```sql
kv_config (
key TEXT PRIMARY KEY, -- 如 'argon2_salt'
value BYTEA NOT NULL -- Argon2id salt首台设备 init 时生成
)
```
### audit_log 表结构
```sql
audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', -- 变更摘要tags/meta keys/secret keys不含 value
actor VARCHAR(128) NOT NULL DEFAULT '', -- 操作者($USER 环境变量)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### entries_history 表结构
```sql
entries_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL, -- 被快照时的版本号
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### secrets_history 表结构
```sql
secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL, -- 对应 secrets.id
entry_version BIGINT NOT NULL, -- 关联 entries_history 的版本号
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### 字段职责划分
| 字段 | 存什么 | 示例 |
|------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 唯一标识名 | `i-example0abcd1234efgh`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` |
| `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` |
| `secrets.field_type` | 值类型(明文) | `"string"`, `"number"`, `"boolean"`, `"json"` |
| `secrets.value_len` | 原始值字符数(明文) | `4`root`40`token`4096`PEM |
| `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 |
### PEM 共享机制key_ref
同一 PEM 被多台服务器共享时,将 PEM 存为独立的 `kind=key` 记录,服务器通过 `metadata.key_ref` 引用:
每次提交前至少运行:
```bash
# 1. 存共享 PEM
secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \
-s content=@./keys/my-shared-key.pem
# 2. 服务器通过 metadata.key_ref 引用inject/run 时自动合并 key 的 secrets
secrets add -n refining --kind server --name i-example0xyz789 \
-m ip=192.0.2.1 -m key_ref=my-shared-key \
-s username=ecs-user
# 3. 轮换只需更新 key 记录,所有引用服务器自动生效
secrets update -n refining --kind key --name my-shared-key \
-s content=@./keys/new-key.pem
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
## 数据库配置
首次使用需显式配置数据库连接,设置一次后在该设备上持久生效:
```bash
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
secrets config show # 查看当前配置(密码脱敏)
secrets config path # 打印配置文件路径
```
`set-db` 会先验证连接可用,成功后才写入配置文件;连接失败时提示 "Database connection failed" 且不修改配置。
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。
## 主密钥与加密
首次使用(每台设备各执行一次):
```bash
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
secrets init # 提示输入主密码Argon2id 派生主密钥后存入 OS 钥匙串
```
主密码不存储salt 存于 `kv_config`,首台设备生成后共享,确保同一主密码在所有设备派生出相同主密钥。
主密钥存储后端macOS Keychain、Windows Credential Manager、Linux keyutils会话级重启后需再次 `secrets init`)。
**从旧版(明文 JSONB升级**:升级后执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
## CLI 命令
### AI 使用主路径
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则:
- TTY终端直接运行→ 默认 `text`
- 非 TTY管道/重定向/AI 调用)→ 自动 `json-compact`
- 显式 `-o json` → 美化 JSON
---
### init — 主密钥初始化(每台设备一次)
```bash
# 首次设备:生成 Argon2id salt 并存库,派生主密钥后存 OS 钥匙串
secrets init
# 后续设备:复用已有 salt派生主密钥后存钥匙串主密码需与首台相同
secrets init
```
### search — 发现与读取
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh | mqtt
# --tag aliyun | hongkong | production
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata
# secrets schema search 默认展示 secrets 字段名、类型与长度(无需 master_key
# -f / --field metadata.ip | metadata.url | metadata.default_org
# --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at
# --limit 20 | 50默认 50
# --offset 0 | 10 | 20分页偏移
# --sort name默认| updated | created
# -o / --output text | json | json-compact
# 发现概览(起步推荐)
secrets search --summary --limit 20
secrets search -n refining --summary --limit 20
secrets search --sort updated --limit 10 --summary
# 精确定位单条记录
secrets search -n refining --kind service --name gitea
secrets search -n refining --kind server --name i-example0abcd1234efgh
# 精确定位并获取完整内容secrets 保持加密占位)
secrets search -n refining --kind service --name gitea -o json
# 直接提取 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv
# 模糊关键词搜索
secrets search -q mqtt
secrets search -q grafana
secrets search -q 192.0.2
# 按条件过滤
secrets search -n refining --kind service
secrets search -n ricnsmart --kind server
secrets search --tag hongkong
secrets search --tag aliyun --summary
# 分页
secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact
secrets search -n refining --kind service | jq '.[].name'
```
---
### add — 新增或全量覆盖upsert
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | hongkong可重复
# -m / --meta ip=10.0.0.1 | desc="ECS" | url=https://... | tls:cert@./cert.pem可重复
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem可重复
# 添加服务器
secrets add -n refining --kind server --name i-example0abcd1234efgh \
--tag aliyun --tag shanghai \
-m ip=10.0.0.1 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/deploy-key.pem
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://code.example.com -m default_org=refining -m username=voson \
-s token=<token> -s runner_token=<runner_token>
# 从文件读取 token
secrets add -n ricnsmart --kind service --name mqtt \
-m host=mqtt.example.com -m port=1883 \
-s password=@./mqtt_password.txt
# 多行文件直接写入嵌套 secret 字段
secrets add -n refining --kind server --name i-example0abcd1234efgh \
-s credentials:content@./keys/deploy-key.pem
# 使用类型化值key:=<json>)存储非字符串类型
secrets add -n refining --kind service --name prometheus \
-m scrape_interval:=15 \
-m enabled:=true \
-m labels:='["prod","metrics"]' \
-s api_key=abc123
```
---
### update — 增量更新(记录必须已存在)
只有传入的字段才会变动,其余全部保留。
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --add-tag production | backup不影响已有 tag可重复
# --remove-tag staging | deprecated可重复
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root新增或覆盖可重复
# --remove-meta old_port | legacy_key | credentials:content删除 metadata 字段,可重复)
# -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem新增或覆盖可重复
# --remove-secret old_password | deprecated_key | credentials:content删除 secret 字段,可重复)
# 更新单个 metadata 字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
-m ip=10.0.0.1
# 轮换 token
secrets update -n refining --kind service --name gitea \
-s token=<new-token>
# 新增 tag 并轮换 token
secrets update -n refining --kind service --name gitea \
--add-tag production \
-s token=<new-token>
# 移除废弃字段
secrets update -n refining --kind service --name mqtt \
--remove-meta old_port --remove-secret old_password
# 从文件更新嵌套 secret 字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
-s credentials:content@./keys/deploy-key.pem
# 删除嵌套字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
--remove-secret credentials:content
# 移除 tag
secrets update -n refining --kind service --name gitea --remove-tag staging
```
---
### delete — 删除记录(支持单条精确删除与批量删除)
删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart必填
# --kind server | service指定 --name 时必填;批量时可选)
# --name gitea | i-example0abcd1234efgh精确匹配省略则批量删除
# --dry-run 预览将删除的记录,不实际写入(仅批量模式有效)
# -o / --output text | json | json-compact
# 精确删除单条记录(--kind 必填)
secrets delete -n refining --kind service --name legacy-mqtt
secrets delete -n ricnsmart --kind server --name i-old-server-id
# 预览批量删除(不写入数据库)
secrets delete -n refining --dry-run
secrets delete -n ricnsmart --kind server --dry-run
# 批量删除整个 namespace 的所有记录
secrets delete -n ricnsmart
# 批量删除 namespace 下指定 kind 的所有记录
secrets delete -n ricnsmart --kind server
# JSON 输出
secrets delete -n refining --kind service -o json
```
---
### history — 查看变更历史
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --limit 返回条数(默认 20
# 查看某条记录的历史版本列表
secrets history -n refining --kind service --name gitea
# 查最近 5 条
secrets history -n refining --kind service --name gitea --limit 5
# JSON 输出
secrets history -n refining --kind service --name gitea -o json
```
---
### rollback — 回滚到指定版本
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --to-version <N> 目标版本号(省略则恢复最近一次快照)
# 撤销上次修改(回滚到最近一次快照)
secrets rollback -n refining --kind service --name gitea
# 回滚到版本 3
secrets rollback -n refining --kind service --name gitea --to-version 3
```
---
### inject — 输出临时环境变量
敏感值仅打印到 stdout不持久化、不写入当前 shell。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀(留空则以记录 name 作前缀)
# -o / --output text默认 KEY=VALUE| json | json-compact
# 打印单条记录的所有变量KEY=VALUE 格式)
secrets inject -n refining --kind service --name gitea
# 自定义前缀
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON 格式(适合管道或脚本解析)
secrets inject -n refining --kind service --name gitea -o json
# eval 注入当前 shell谨慎使用
eval $(secrets inject -n refining --kind service --name gitea)
```
---
### run — 向子进程注入 secrets 并执行命令
secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀
# -- <command> 执行的命令及参数
# 向脚本注入单条记录的 secrets
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 按 tag 批量注入(多条记录合并)
secrets run --tag production -- env | grep -i token
# 验证注入了哪些变量
secrets run -n refining --kind service --name gitea -- printenv
```
---
### upgrade — 自动更新 CLI 二进制
从 Release 服务器下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。
**配置方式**`SECRETS_UPGRADE_URL` 必填。优先用**构建时**`SECRETS_UPGRADE_URL=https://... cargo build`CI 已自动注入。或**运行时**:写在 `.env``export` 后执行。
```bash
# 检查是否有新版本(不下载)
secrets upgrade --check
# 下载、校验 SHA-256 并安装最新版本
secrets upgrade
```
---
### export — 批量导出记录
将匹配的记录(含解密后的明文 secrets导出到文件或 stdout。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。使用 `--no-secrets` 时无需主密钥。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | production可重复
# -q / --query 模糊关键词
# --file <path> 输出文件路径,格式由扩展名推断(.json / .toml / .yaml / .yml
# --format json | toml | yaml 显式指定格式(输出到 stdout 时必须指定)
# --no-secrets 不导出 secrets无需主密钥
# 全量导出到 JSON 文件
secrets export --file backup.json
# 按 namespace 导出为 TOML
secrets export -n refining --file refining.toml
# 按 kind 导出为 YAML
secrets export -n refining --kind service --file services.yaml
# 按 tag 过滤导出
secrets export --tag production --file prod.json
# 模糊关键词导出
secrets export -q mqtt --file mqtt.json
# 仅导出 schema不含 secrets无需主密钥
secrets export --no-secrets --file schema.json
# 输出到 stdout必须指定 --format
secrets export -n refining --format yaml
secrets export --format json | jq '.'
```
---
### import — 批量导入记录
从导出文件读取记录并写入数据库,自动重新加密 secrets。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。
```bash
# 参数说明
# <file> 必选,输入文件路径(格式由扩展名推断)
# --force 冲突时覆盖已有记录(默认:报错并停止)
# --dry-run 预览将执行的操作,不写入数据库
# -o / --output text | json | json-compact
# 导入 JSON 文件(遇到已存在记录报错)
secrets import backup.json
# 导入 TOML 文件,冲突时覆盖
secrets import --force refining.toml
# 导入 YAML 文件,冲突时覆盖
secrets import --force services.yaml
# 预览将执行的操作(不写入)
secrets import --dry-run backup.json
# JSON 格式输出导入摘要
secrets import backup.json -o json
```
---
### config — 配置管理(无需主密钥)
```bash
# 设置数据库连接(每台设备执行一次,之后永久生效;先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 查看当前配置(密码脱敏)
secrets config show
# 打印配置文件路径
secrets config path
# 输出: /Users/<user>/.config/secrets/config.toml
```
---
### 全局参数
```bash
# debug 日志(位于子命令之前)
secrets --verbose search -q mqtt
secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy
# 或通过环境变量精细控制
RUST_LOG=secrets=trace secrets search
# 一次性覆盖数据库连接
secrets --db-url "postgres://..." search -n refining
```
## 代码规范
- 错误处理:统一使用 `anyhow::Result`,不用 `unwrap()`
- 异步:全程 `tokio`,数据库操作 `sqlx` async
- SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2`
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose`-f`=field`-o`=output
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env写命令 `add` 同样支持 `-o json`
## 提交前检查(必须全部通过)
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
优先使用:
也可以直接运行:
```bash
./scripts/release-check.sh
```
它等价于先检查版本号 / tag再执行下面的格式、Lint、测试。
## 项目结构
### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断:
```bash
# 查看当前 Cargo.toml 版本
grep '^version' Cargo.toml
# 查看是否已存在该版本对应的 tagCI 使用格式 secrets-<version>
git tag -l 'secrets-*'
```text
secrets/
Cargo.toml
apps/
api/ # 远端 JSON API
desktop/src-tauri/ # 桌面端
crates/
application/ # v3 应用服务
client-integrations/ # Cursor / Claude Code 配置注入
crypto/ # 通用加密辅助
desktop-daemon/ # 本地 MCP daemon
device-auth/ # 设备登录 / Desktop OAuth 辅助
domain/ # v3 领域模型
infrastructure-db/ # 数据库与迁移
deploy/
scripts/
.gitea/workflows/
.vscode/tasks.json
```
若当前版本已被 tag例如已有 `secrets-0.3.0``Cargo.toml` 仍为 `0.3.0`),则应在 `Cargo.toml` 中 bump 版本号,再执行 `cargo build` 同步 `Cargo.lock`,最后一并提交,以便 CI 自动打新 Tag 并发布 Release。
## 数据库
### 2. 格式、Lint、测试
- 建议数据库名:`secrets-v3`
- 连接串:`SECRETS_DATABASE_URL`
- 首次连接会自动运行 `secrets-infrastructure-db::migrate_current_schema`
```bash
cargo fmt -- --check # 格式检查(不通过则运行 cargo fmt 修复)
cargo clippy -- -D warnings # Lint 检查(消除所有 warning
cargo test # 单元/集成测试
当前 v3 主要表:
- `users`
- `oauth_accounts`
- `devices`
- `device_login_tokens`
- `auth_events`
- `vault_objects`
- `vault_object_revisions`
### 当前模型约束
- 服务端只保存同步所需的密文对象与版本信息
- 搜索、详情、reveal、history 主要在 desktop 本地 vault 中完成
- 删除通过对象级 `deleted_at` / tombstone 传播
- 历史服务端保留在 `vault_object_revisions`,本地另有 `vault_object_history`
### 字段职责
| 字段 | 含义 | 示例 |
|------|------|------|
| `object_id` | 同步对象标识 | `UUID` |
| `object_kind` | 当前对象类别 | `cipher` |
| `revision` | 对象版本号 | `12` |
| `cipher_version` | 密文封装版本 | `1` |
| `ciphertext` | 密文对象载荷 | AES-GCM 密文 |
| `content_hash` | 密文内容摘要 | `sha256:...` |
| `deleted_at` | 对象删除时间 | `2026-04-14T12:00:00Z` |
## Google 登录
当前登录流为 **Google Desktop OAuth**
- 桌面端使用系统浏览器拉起 Google 授权
- API 服务端持有 Google OAuth client 配置并处理 callback / token exchange
- desktop 创建一次性 login session打开托管登录页后轮询状态
- API 校验 Google userinfo 后发放本地 device token
官网 DMG 正式分发时,服务端至少需要配置:
- `SECRETS_PUBLIC_BASE_URL`
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `GOOGLE_OAUTH_REDIRECT_URI`
推荐约束:
- `SECRETS_PUBLIC_BASE_URL` 使用用户浏览器实际访问的 HTTPS 官网地址
- `GOOGLE_OAUTH_REDIRECT_URI` 配置为 `${SECRETS_PUBLIC_BASE_URL}/auth/google/callback`
- `GOOGLE_OAUTH_CLIENT_SECRET` 只保留在服务端环境变量或密钥管理系统中,不入库
- Google Cloud Console 中登记的 callback URL 必须与 `GOOGLE_OAUTH_REDIRECT_URI` 完全一致
## MCP
本地 MCP 入口由 `crates/desktop-daemon` 提供,默认地址:
```text
http://127.0.0.1:9515/mcp
```
或一次性执行
当前暴露的工具
```bash
cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
```
- `secrets_entry_find`
- `secrets_entry_get`
- `secrets_entry_add`
- `secrets_entry_update`
- `secrets_entry_delete`
- `secrets_entry_restore`
- `secrets_secret_add`
- `secrets_secret_update`
- `secrets_secret_delete`
- `secrets_secret_history`
- `secrets_secret_rollback`
- `target_exec`
## CI/CD
当前不保留:
- Gitea Actionsrunners: debian / darwin-arm64 / windows
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl``aarch64-apple-darwin``x86_64-apple-darwin`(由 ARM mac runner 交叉编译)、`x86_64-pc-windows-msvc`
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制与对应 `.sha256` 摘要到 Gitea Release
- Release 仅在 Linux/macOS/Windows 构建全部成功后才会从 draft 发布
- 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
- **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码
- `secrets_env_map`
## 环境变量
### `target_exec`
| 变量 | 说明 |
|------|------|
| `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn |
| `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 |
| `SECRETS_UPGRADE_URL` | upgrade 的 Release API 地址。构建时cargo build或运行时.env/export |
`target_exec` 会显式读取 entry 当前 secrets 的真实值,并从 metadata / secrets 派生标准环境变量,例如:
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。
- `TARGET_ENTRY_ID`
- `TARGET_NAME`
- `TARGET_FOLDER`
- `TARGET_TYPE`
- `TARGET_HOST`
- `TARGET_PORT`
- `TARGET_USER`
- `TARGET_BASE_URL`
- `TARGET_API_KEY`
- `TARGET_TOKEN`
- `TARGET_SSH_KEY`
## 桌面端
桌面端当前支持:
- Google 登录
- 自动写入 `Cursor` / `Claude Code``mcp.json`
- 新建条目
- 搜索、按 type 筛选
- 右侧原地编辑
- secret 新增、编辑、删除
- secret 明文显示 / 复制
- secret 历史查看与回滚
- 删除到最近删除与恢复
- 登录态仅在当前 desktop 进程内有效,不做自动恢复登录
- desktop 进程退出后,本地 daemon 所有工具不可用
### 配置注入
桌面端会把本地 daemon 配置写入:
- `~/.cursor/mcp.json`
- `~/.claude/mcp.json`
写入策略:
- 保留现有其它 `mcpServers`
- 仅覆盖同名 `secrets` 节点
### 图标与前端 dist本地 / CI
版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包;但 **`apps/desktop/dist/`** 现在作为桌面端前端静态资源目录,**需要提交到版本库**,以保证新机器 clone 后可直接运行 Tauri desktop。
- **图标**:仅跟踪 `apps/desktop/src-tauri/icons/icon.png` 作为源图(建议 **1024×1024** PNG。检出代码后若需要完整 `icons/`(例如打包、验证窗口/托盘图标),在 **`apps/desktop/src-tauri`** 下执行:
```bash
cd apps/desktop/src-tauri
cargo tauri icon icons/icon.png
```
需已安装 **Tauri CLI**(例如 `cargo install tauri-cli`,或与项目一致的 `cargo-tauri` 版本)。
- **前端 dist**`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。当前仓库直接跟踪 **`apps/desktop/dist/`** 下的静态页面资源,因此新机器 clone 后无需额外生成前端产物即可运行 `cargo run -p secrets-desktop`。若后续引入独立前端构建链,再单独把这部分切回构建产物管理。
## 代码规范
- 业务层优先使用 `anyhow::Result`
- 避免生产路径 `unwrap()`
- 使用 `tokio` + `sqlx` async
- SQL 使用参数绑定,不要手拼用户输入
- 运维日志使用 `tracing`
- 变更后优先跑最小必要验证,不要只改不测
## CI / 脚本
- `.gitea/workflows/secrets.yml` 现在是 v3 workspace 级 CI
- `scripts/release-check.sh` 只做 workspace 质量检查
- `deploy/.env.example` 反映当前 v3 API / daemon / desktop 登录配置
## 安全约束
- 不要把 Google `client_secret` 提交到受版本控制的配置文件中
- 不要把 device token、数据库密码、真实生产密钥提交入库
- 数据库生产环境优先使用 `verify-full`
- AI 审查时,不要把“随机高熵 token 明文存储”机械地当成密码学问题处理,必须结合当前架构和威胁模型判断

56
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,56 @@
# Contributing
## 版本控制
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)**。请勿使用 `git` 命令。
```bash
jj log # 查看历史
jj status # 查看状态
jj new # 创建新变更
jj commit # 提交
jj rebase # 变基
jj squash # 合并提交
jj git push # 推送到远端
```
详见 [AGENTS.md](AGENTS.md) 的「版本控制」章节。
## 本地开发
```bash
# 复制环境变量
cp deploy/.env.example .env
# 填写数据库连接等配置后
cargo build
cargo test --locked
```
## 提交前检查
每次提交前必须通过:
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
或使用脚本:
```bash
./scripts/release-check.sh
```
## 发版规则
当前仓库已切换到 v3 架构,不再围绕 `secrets-mcp` 做单独发版。
提交前请至少保证:
1. `cargo fmt -- --check`
2. `cargo clippy --locked -- -D warnings`
3. `cargo test --locked`
详见 [AGENTS.md](AGENTS.md) 中最新的仓库说明。

3817
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,54 @@
[package]
name = "secrets"
version = "0.9.3"
[workspace]
members = [
"apps/api",
"apps/desktop/src-tauri",
"crates/application",
"crates/client-integrations",
"crates/crypto",
"crates/desktop-daemon",
"crates/device-auth",
"crates/domain",
"crates/infrastructure-db",
]
resolver = "2"
[workspace.package]
edition = "2024"
[dependencies]
aes-gcm = "^0.10.3"
anyhow = "^1.0.102"
argon2 = { version = "^0.5.3", features = ["std"] }
chrono = { version = "^0.4.44", features = ["serde"] }
clap = { version = "^4.6.0", features = ["derive"] }
dirs = "^6.0.0"
dotenvy = "^0.15"
flate2 = "^1.1.9"
keyring = { version = "^3.6.3", features = ["apple-native", "windows-native", "linux-native"] }
rand = "^0.10.0"
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
rpassword = "^7.4.0"
self-replace = "^1.5.0"
semver = "^1.0.27"
[workspace.dependencies]
# Async runtime
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
# Database
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "uuid", "json", "chrono"] }
# Serialization
serde = { version = "^1.0.228", features = ["derive"] }
serde_json = "^1.0.149"
serde_yaml = "^0.9"
sha2 = "^0.10.9"
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
tar = "^0.4.44"
tempfile = "^3.19"
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
toml = "^1.0.7"
# Crypto
aes-gcm = "^0.10.3"
sha2 = "^0.10.9"
rand = "^0.10.0"
hex = "0.4"
# Utils
anyhow = "^1.0.102"
thiserror = "^2"
chrono = { version = "^0.4.44", features = ["serde"] }
uuid = { version = "^1.22.0", features = ["serde", "v4"] }
tracing = "^0.1"
tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
uuid = { version = "^1.22.0", features = ["serde"] }
zip = { version = "^8.2.0", default-features = false, features = ["deflate"] }
dotenvy = "^0.15"
# HTTP
# system-proxy与浏览器一致读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] }
axum = "0.8"
http = "1"
url = "2"
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
tauri = { version = "2", features = [] }
tauri-build = { version = "2", features = [] }

447
README.md
View File

@@ -1,346 +1,223 @@
# secrets
# Secrets
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18。
这是 v3 架构的仓库,当前主路径已经收敛为:
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。每个敏感字段单独行存储(`secrets` 子表),字段名、类型、长度以明文保存便于 AI 理解,仅值本身使用 AES-256-GCM 加密;主密钥由 Argon2id 从主密码派生并存入系统钥匙串。
- `apps/api`:远端 JSON API
- `apps/desktop/src-tauri`:桌面客户端
- `crates/desktop-daemon`:本地 MCP 入口
- `crates/application` / `domain` / `infrastructure-db`:业务与数据层
## 安装
## 本地开发
```bash
cargo build --release
# 或从 Release 页面下载预编译二进制
cp deploy/.env.example .env
# 远端 API
cargo run -p secrets-api --bin secrets-api
# 本地 daemon
cargo run -p secrets-desktop-daemon
# 桌面客户端
cargo run -p secrets-desktop
```
已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。该命令会校验 Release 附带的 `.sha256` 摘要后再安装。
说明:
## 首次使用(每台设备各执行一次)
- `apps/desktop/src-tauri/tauri.conf.json``build.frontendDist` 指向 `apps/desktop/dist`
- 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源
- 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop
- 官网 DMG 正式分发不依赖本地 `client_secret_*.json`
- Google OAuth 凭据只配置在 API 服务端desktop 通过浏览器完成托管登录
## 官网 DMG 的服务端 OAuth 配置
官网 DMG 正式分发时,**Google OAuth 只配置在 API 服务端**。桌面端不需要本地 `client_secret_*.json`,也不直接向 Google 换 token。
建议先复制 `deploy/.env.example``.env`,然后至少配置以下变量:
```bash
# 1. 配置数据库连接(会先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
secrets init
SECRETS_PUBLIC_BASE_URL=https://secrets.example.com
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=https://secrets.example.com/auth/google/callback
```
主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
变量含义:
**主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。
- `SECRETS_PUBLIC_BASE_URL`:桌面端打开浏览器时访问的 API 外网基地址,必须是用户浏览器能访问到的公开地址
- `GOOGLE_OAUTH_CLIENT_ID`Google Cloud Console 中为服务端登录流程配置的 OAuth Client ID
- `GOOGLE_OAUTH_CLIENT_SECRET`:对应的 Client Secret只能保留在服务端
- `GOOGLE_OAUTH_REDIRECT_URI`Google 登录完成后回调到 API 的地址,必须与 Google Console 中登记的回调地址完全一致
**从旧版(明文存储)升级**:升级后首次运行需执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
配置步骤建议:
## AI Agent 快速指南
1. 在 Google Cloud Console 创建或选择 OAuth Client
2. 把授权回调地址加入允许列表,例如 `https://secrets.example.com/auth/google/callback`
3. 把上面的 4 个变量配置到 API 服务的运行环境中
4. 确认 `SECRETS_PUBLIC_BASE_URL``GOOGLE_OAUTH_REDIRECT_URI` 使用同一公开域名
5. 重启 API 服务后,再用 desktop / DMG 验证浏览器登录流程
这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。
注意:
### 第一步:发现有哪些数据
- `GOOGLE_OAUTH_CLIENT_SECRET` 不要提交到仓库
- `GOOGLE_OAUTH_REDIRECT_URI` 不要写成 `localhost`,正式分发应使用官网可访问域名
- 如果 API 部署在反向代理后面,`SECRETS_PUBLIC_BASE_URL` 应填写用户实际访问的 HTTPS 地址,而不是内网监听地址
## 当前能力
- 桌面端使用系统浏览器完成 Google Desktop OAuth 登录
- 登录成功后向 API 注册设备,并在当前桌面进程内维护登录会话
- 本地 daemon 提供显式拆分的 MCP 工具:
- `secrets_entry_find` / `secrets_entry_get`
- `secrets_entry_add` / `secrets_entry_update` / `secrets_entry_delete` / `secrets_entry_restore`
- `secrets_secret_add` / `secrets_secret_update` / `secrets_secret_delete`
- `secrets_secret_history` / `secrets_secret_rollback`
- `target_exec`
- 桌面端会自动把本地 daemon MCP 配置写入 `Cursor``Claude Code`
- 桌面端支持条目新建、搜索、按 type 筛选、元数据编辑、最近删除与恢复
- 桌面端支持 secret 新增、编辑、删除、明文显示、真实复制、历史查看与回滚
- 不保留 `secrets_env_map`
- 不做自动恢复登录;重启 app 后必须重新登录
## 提交前检查
```bash
# 列出所有记录摘要(默认最多 50 条,安全起步)
secrets search --summary --limit 20
# 按 namespace 过滤
secrets search -n refining --summary --limit 20
# 按最近更新排序
secrets search --sort updated --limit 10 --summary
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
`--summary` 只返回轻量字段namespace、kind、name、tags、desc、updated_at不含完整 metadata 和 secrets。
## PostgreSQL TLS 加固
### 第二步:精确读取单条记录
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd`
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`
- 可执行运维步骤见 `[deploy/postgres-tls-hardening.md](deploy/postgres-tls-hardening.md)`
```bash
# 精确定位namespace + kind + name 三元组)
secrets search -n refining --kind service --name gitea
## MCP 与 AI 工作流v3
# 获取完整记录(含 secrets 字段 schemafield_name、field_type、value_len无需 master_key
secrets search -n refining --kind service --name gitea -o json
当前 v3 以 **桌面端 + 本地 daemon** 为主路径:
# 直接提取单个 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f metadata.url
- 桌面端登录态仅在当前进程内有效,不持久化 `device token`
- 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp`
- daemon 通过活跃 desktop 进程提供的本地会话转发访问 APIdesktop 进程退出后所有工具不可用
- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_`* 环境变量
- 不保留 `secrets_env_map`
# 同时提取多个 metadata 字段
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
### Canonical MCP 工具
# 需要 secrets 时,改用 inject / run
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv
```
`search` 展示 metadata 与 secrets 的字段 schema字段名、类型、长度不展示 secret 值本身;需要值时用 `inject` / `run`
| 工具 | 说明 |
| ------------------------- | --------------------------------------------------------- |
| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` |
| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 |
| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets |
| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata |
| `secrets_entry_delete` | 将本地对象标记为删除 |
| `secrets_entry_restore` | 恢复本地已删除对象 |
| `secrets_secret_add` | 向已有本地对象新增 secret |
| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 |
| `secrets_secret_delete` | 删除单个本地 secret |
| `secrets_secret_history` | 查看单个本地 secret 的历史版本 |
| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 |
| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_`* 环境变量并执行本地命令 |
### 输出格式
| 场景 | 推荐命令 |
|------|----------|
| AI 解析 / 管道处理 | `-o json``-o json-compact` |
| 注入 secrets 到环境变量 | `inject` / `run` |
| 人类查看 | 默认 `text`TTY 下自动启用) |
| 非 TTY管道/重定向) | 自动 `json-compact` |
## AI 客户端配置
说明:`text` 输出中的时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTCRFC3339 风格)以便脚本和 AI 稳定解析。
桌面端会自动把本地 daemon 写入以下配置:
```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact
secrets search -n refining --kind service | jq '.[].name'
- `~/.cursor/mcp.json`
- `~/.claude/mcp.json`
# 需要 secrets 时,使用 inject / run
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env
secrets run -n refining --kind service --name gitea -- ./deploy.sh
```
写入示例:
## 完整命令参考
```bash
# 查看帮助(包含各子命令 EXAMPLES
secrets --help
secrets init --help # 主密钥初始化
secrets search --help
secrets add --help
secrets update --help
secrets delete --help
secrets config --help
secrets upgrade --help # 检查并更新 CLI 版本
secrets export --help # 批量导出JSON/TOML/YAML
secrets import --help # 批量导入JSON/TOML/YAML
# ── search ──────────────────────────────────────────────────────────────────
secrets search --summary --limit 20 # 发现概览
secrets search -n refining --kind service # 按 namespace + kind
secrets search -n refining --kind service --name gitea # 精确查找
secrets search -q mqtt # 关键词模糊搜索
secrets search --tag hongkong # 按 tag 过滤
secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段
secrets search -n refining --kind service --name gitea -o json # 完整记录(含 secrets schema
secrets search --sort updated --limit 10 --summary # 最近改动
secrets search -n refining --summary --limit 10 --offset 10 # 翻页
# ── add ──────────────────────────────────────────────────────────────────────
secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \
-m ip=10.0.0.1 -m desc="Example ECS" \
-s username=root -s ssh_key=@./keys/server.pem
# 多行文件直接写入嵌套 secret 字段
secrets add -n refining --kind server --name my-server \
-s credentials:content@./keys/server.pem
# 使用 typed JSON 写入 secret布尔、数字、数组、对象
secrets add -n refining --kind service --name deploy-bot \
-s enabled:=true \
-s retry_count:=3 \
-s scopes:='["repo","workflow"]' \
-s extra:='{"region":"ap-east-1","verify_tls":true}'
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://code.example.com -m default_org=myorg \
-s token=<token>
# ── update ───────────────────────────────────────────────────────────────────
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new>
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
# ── delete ───────────────────────────────────────────────────────────────────
secrets delete -n refining --kind service --name legacy-mqtt # 精确删除单条(--kind 必填)
secrets delete -n refining --dry-run # 预览批量删除(不写入)
secrets delete -n ricnsmart # 批量删除整个 namespace
secrets delete -n ricnsmart --kind server # 批量删除指定 kind
# ── init ─────────────────────────────────────────────────────────────────────
secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串)
# ── config ───────────────────────────────────────────────────────────────────
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入
secrets config show # 密码脱敏展示
secrets config path # 打印配置文件路径
# ── upgrade ──────────────────────────────────────────────────────────────────
secrets upgrade --check # 仅检查是否有新版本
secrets upgrade # 下载、校验 SHA-256 并安装最新版(可通过 SECRETS_UPGRADE_URL 自托管)
# ── export ────────────────────────────────────────────────────────────────────
secrets export --file backup.json # 全量导出到 JSON
secrets export -n refining --file refining.toml # 按 namespace 导出为 TOML
secrets export -n refining --kind service --file svc.yaml # 按 kind 导出为 YAML
secrets export --tag production --file prod.json # 按 tag 过滤
secrets export -q mqtt --file mqtt.json # 模糊搜索导出
secrets export --no-secrets --file schema.json # 仅导出 schema无需主密钥
secrets export -n refining --format yaml # 输出到 stdout指定格式
# ── import ────────────────────────────────────────────────────────────────────
secrets import backup.json # 导入(冲突时报错)
secrets import --force refining.toml # 冲突时覆盖已有记录
secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入)
# ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search
```json
{
"mcpServers": {
"secrets": {
"url": "http://127.0.0.1:9515/mcp"
}
}
}
```
## 数据模型
主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、field_type、value_len、encrypted。首次连接自动建表同时创建 `audit_log``entries_history``secrets_history` 等表。
当前 v3 已切到**零知识同步模型**
| 位置 | 字段 | 说明 |
|------|------|------|
| entries | namespace | 一级隔离,如 `refining``ricnsmart` |
| entries | kind | 记录类型,如 `server``service``key`(可自由扩展) |
| entries | name | 人类可读唯一标识 |
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| secrets | field_name / field_type / value_len | 明文search 可见AI 可推断 inject 会生成什么变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM |
- 服务端保存 `vault_objects``vault_object_revisions`
- desktop 本地保存 `vault_objects``vault_object_history``pending_changes``sync_state`
- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成
- 服务端负责 `auth/device``/sync/`*,不再承担明文搜索与明文 reveal
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
主要表:
**PEM 共享**:同一 PEM 被多台服务器共享时,可存为 `kind=key` 记录,服务器通过 `metadata.key_ref` 引用;轮换只需 update 一条 key 记录,所有引用自动生效。详见 [AGENTS.md](AGENTS.md)。
- `users`
- `oauth_accounts`
- `devices`
- `device_login_tokens`
- `auth_events`
- `vault_objects`
- `vault_object_revisions`
### `-m` / `--meta` JSON 语法速查
字段职责:
`-m``-s` 走的是同一套解析规则,只是写入位置不同:`-m` 写到明文 `metadata`,适合端口、开关、标签、描述性配置等非敏感信息。
| 目标值 | 写法示例 | 实际存入 |
|------|------|------|
| 普通字符串 | `-m url=https://code.example.com` | `"https://code.example.com"` |
| 文件内容字符串 | `-m notes=@./service-notes.txt` | `"..."` |
| 布尔值 | `-m enabled:=true` | `true` |
| 数字 | `-m port:=3000` | `3000` |
| `null` | `-m deprecated_at:=null` | `null` |
| 数组 | `-m domains:='["code.example.com","git.example.com"]'` | `["code.example.com","git.example.com"]` |
| 对象 | `-m tls:='{"enabled":true,"redirect_http":true}'` | `{"enabled":true,"redirect_http":true}` |
| 嵌套路径 + JSON | `-m deploy:strategy:='{"type":"rolling","batch":2}'` | `{"deploy":{"strategy":{"type":"rolling","batch":2}}}` |
| 位置 | 字段 | 说明 |
| ------------------------ | ------------------------- | --------------------- |
| `vault_objects` | `object_id` | 同步对象标识 |
| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` |
| `vault_objects` | `revision` | 服务端对象版本 |
| `vault_objects` | `ciphertext` | 密文对象载荷 |
| `vault_objects` | `content_hash` | 密文摘要 |
| `vault_objects` | `deleted_at` | 对象级删除标记 |
| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 |
常见规则:
- `=` 表示按字符串存储。
- `:=` 表示按 JSON 解析。
- shell 中数组和对象建议整体用单引号包住。
- 嵌套字段继续用冒号分隔:`-m runtime:max_open_conns:=20`
## 认证与事件
示例:新增一条带 typed metadata 的记录
当前登录流为 Google Desktop OAuth
```bash
secrets add -n refining --kind service --name gitea \
-m url=https://code.example.com \
-m port:=3000 \
-m enabled:=true \
-m domains:='["code.example.com","git.example.com"]' \
-m tls:='{"enabled":true,"redirect_http":true}'
```
示例:更新已有记录中的嵌套 metadata
```bash
secrets update -n refining --kind service --name gitea \
-m deploy:strategy:='{"type":"rolling","batch":2}' \
-m runtime:max_open_conns:=20
```
### `-s` / `--secret` JSON 语法速查
当你希望写入的不是普通字符串,而是 `true``123``null`、数组或对象时,用 `:=`,右侧按 JSON 解析。
| 目标值 | 写法示例 | 实际存入 |
|------|------|------|
| 普通字符串 | `-s token=abc123` | `"abc123"` |
| 文件内容字符串 | `-s ssh_key=@./id_ed25519` | `"-----BEGIN ..."` |
| 布尔值 | `-s enabled:=true` | `true` |
| 数字 | `-s retry_count:=3` | `3` |
| `null` | `-s deprecated_at:=null` | `null` |
| 数组 | `-s scopes:='["repo","workflow"]'` | `["repo","workflow"]` |
| 对象 | `-s extra:='{"region":"ap-east-1","verify_tls":true}'` | `{"region":"ap-east-1","verify_tls":true}` |
| 嵌套路径 + JSON | `-s auth:policy:='{"mfa":true,"ttl":3600}'` | `{"auth":{"policy":{"mfa":true,"ttl":3600}}}` |
常见规则:
- `=` 表示按字符串存储,不做 JSON 解析。
- `:=` 表示按 JSON 解析,适合布尔、数字、数组、对象、`null`
- shell 里对象和数组通常要整体加引号,推荐单引号:`-s flags:='["a","b"]'`
- 嵌套字段继续用冒号分隔:`-s credentials:enabled:=true`
- 如果你就是想存一个“JSON 字符串字面量”,可以写成 `-s note:='"hello"'`,但大多数字符串场景直接用 `=` 更直观。
示例:新增一条同时包含字符串、文件、布尔、数组、对象的记录
```bash
secrets add -n refining --kind service --name deploy-bot \
-s token=abc123 \
-s ssh_key=@./keys/deploy-bot.pem \
-s enabled:=true \
-s scopes:='["repo","workflow"]' \
-s policy:='{"ttl":3600,"mfa":true}'
```
示例:更新已有记录中的嵌套 JSON 字段
```bash
secrets update -n refining --kind service --name deploy-bot \
-s auth:config:='{"issuer":"gitea","rotate":true}' \
-s auth:retry:=5
```
## 审计日志
`add``update``delete` 操作成功后自动向 `audit_log` 表写入一条记录,包含操作类型、操作对象和变更摘要(不含 secret 值)。操作者取自 `$USER` 环境变量。
```sql
-- 查看最近 20 条审计记录
SELECT action, namespace, kind, name, actor, detail, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
```
- 桌面端使用系统浏览器拉起 Google 授权
- API 服务端负责发起 OAuth、处理 callback、校验 Google userinfo
- desktop 通过创建一次性 login session 并轮询状态获取 `device token`
- 登录与设备活动写入 `auth_events`
## 项目结构
```
src/
main.rs # CLI 入口clap含各子命令 after_help 示例
output.rs # OutputMode 枚举 + TTY 检测
config.rs # 配置读写(~/.config/secrets/config.toml
db.rs # 连接池 + auto-migrateentries + secrets + entries_history + secrets_history + audit_log + kv_config
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Entry + SecretField 结构体
audit.rs # 审计日志写入audit_log 表)
commands/
init.rs # 主密钥初始化(首次/新设备)
add.rs # upsert entries + secrets 行,支持 -o json
config.rs # config set-db/show/path
search.rs # 多条件查询,展示 secrets schema-f/-o/--summary/--limit/--offset/--sort
delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE
rollback.rs # rollback / history按 entry_version 恢复
run.rs # inject / run逐字段解密 + key_ref 引用解析
upgrade.rs # 从 Gitea Release 自更新
export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import批量导入冲突检测dry-run重新加密写入
```text
Cargo.toml
apps/
api/ # 远端 JSON API
desktop/src-tauri/ # Tauri 桌面端
crates/
application/ # v3 应用服务
client-integrations/ # Cursor / Claude Code mcp.json 注入
crypto/ # 通用加密辅助
desktop-daemon/ # 本地 MCP daemon
device-auth/ # Desktop OAuth / device token 辅助
domain/ # 领域模型
infrastructure-db/ # PostgreSQL 连接与迁移
deploy/
.env.example
secrets-mcp.service
postgres-tls-hardening.md
scripts/
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
release-check.sh
setup-gitea-actions.sh
```
## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy/test 检查 → Linux/macOS/Windows 构建 → 上传二进制与 `.sha256` 摘要 → 所有平台成功后发布 Release
当前以 workspace 级检查为主,见 `[.gitea/workflows/secrets.yml](.gitea/workflows/secrets.yml)`
**首次使用需配置 Actions 变量和 Secrets**
提交前建议直接运行:
```bash
# 需有 ~/.config/gitea/config.envGITEA_URL、GITEA_TOKEN、GITEA_WEBHOOK_URL
./scripts/setup-gitea-actions.sh
./scripts/release-check.sh
```
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制
- `WEBHOOK_URL`Variable飞书通知可选
- **注意**Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码
当前 Release 预编译产物覆盖:
- Linux `x86_64-unknown-linux-musl`
- macOS Apple Silicon `aarch64-apple-darwin`
- macOS Intel `x86_64-apple-darwin`(由 ARM mac runner 交叉编译)
- Windows `x86_64-pc-windows-msvc`
详见 [AGENTS.md](AGENTS.md)。
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。

30
apps/api/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "secrets-api"
version = "0.1.0"
edition.workspace = true
[[bin]]
name = "secrets-api"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
base64 = "0.22.1"
secrets-application = { path = "../../crates/application" }
secrets-device-auth = { path = "../../crates/device-auth" }
secrets-domain = { path = "../../crates/domain" }
secrets-infrastructure-db = { path = "../../crates/infrastructure-db" }

View File

@@ -0,0 +1,15 @@
use anyhow::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let database_url = secrets_infrastructure_db::load_database_url()?;
let pool = secrets_infrastructure_db::create_pool(&database_url).await?;
secrets_infrastructure_db::migrate_current_schema(&pool)
.await
.context("failed to initialize current database schema")?;
println!("current database schema initialized");
Ok(())
}

1099
apps/api/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

6
apps/desktop/README.md Normal file
View File

@@ -0,0 +1,6 @@
# apps/desktop
This directory is reserved for the v3 Tauri desktop shell.
The desktop UI is intentionally kept separate from `crates/desktop-daemon` so
that closing the main window does not terminate the local MCP process.

View File

@@ -0,0 +1,208 @@
# Secrets Design System
## 1. Visual Theme & Atmosphere
- Primary inspiration: Raycast desktop UI.
- Secondary influence: Linear information density and list discipline.
- Product personality: secure, local-first, developer-facing, restrained, trustworthy.
- Default mood: dark utility app, not a marketing site and not a glossy consumer app.
- The interface should feel like a native desktop control surface for secrets and MCP integrations.
- Use calm contrast, clean edges, compact spacing, and intentional empty space.
- Prefer precision over decoration. Visual polish should come from alignment, spacing, and hierarchy.
## 2. Color Palette & Roles
### Core Surfaces
- `bg.app`: `#0A0A0B` - app background, deepest canvas.
- `bg.panel`: `#111113` - main panel and modal background.
- `bg.panelElevated`: `#17171A` - cards, selected rows, input shells.
- `bg.panelHover`: `#1D1D22` - hover state for rows and controls.
- `bg.input`: `#141418` - text inputs, code blocks, secret fields.
- `border.subtle`: `#26262C` - default panel borders.
- `border.strong`: `#34343D` - active borders and high-emphasis outlines.
### Text
- `text.primary`: `#F5F5F7` - primary labels and values.
- `text.secondary`: `#B3B3BD` - supporting metadata.
- `text.tertiary`: `#7C7C88` - placeholders and low-emphasis copy.
- `text.inverse`: `#0B0B0D` - text on bright accents.
### Accents
- `accent.blue`: `#3B82F6` - login CTA, toggles, focus ring, trust signals.
- `accent.blueHover`: `#4C8DFF` - hover state for primary interactions.
- `accent.purple`: `#8B5CF6` - secondary accent for selected count pills or light emphasis.
- `accent.amber`: `#D97706` - local warnings or pending states.
- `accent.red`: `#EF4444` - destructive actions.
- `accent.green`: `#22C55E` - success or enabled state when stronger signal is required.
### Semantic Use
- Blue is the main action color. Keep it rare and meaningful.
- Purple can appear in subtle badges or selected-count chips, never as a second primary CTA.
- Red is reserved for delete, revoke, sign-out danger, and destructive confirmations.
- Avoid bright gradients as a dominant surface treatment.
## 3. Typography Rules
- Font stack: `Inter`, `SF Pro Text`, `SF Pro Display`, `Segoe UI`, system sans-serif.
- Use system-friendly text rendering. This is a desktop tool, not a display-heavy website.
- Chinese UI copy is allowed and should feel natural beside English identifiers like `host`, `token`, `MCP`.
- Keep tracking neutral. Avoid wide uppercase spacing except tiny overline labels.
### Type Scale
- App title / page title: 30-34px, weight 700.
- Section title: 18-22px, weight 650-700.
- Card title / row title: 15-17px, weight 600.
- Body text: 13-14px, weight 400-500.
- Caption / metadata label: 11-12px, weight 500, uppercase allowed with modest tracking.
- Monospace values: `SF Mono`, `JetBrains Mono`, `Menlo`, monospace; 12-13px.
## 4. Component Stylings
### App Shell
- Use a three-pane desktop layout for the main screen: left navigation, middle list, right detail pane.
- Pane separation should rely on subtle borders, not strong shadows.
- Sidebar should feel slightly darker than the center list pane.
- The detail pane can be the most open surface, with larger top padding and calmer spacing.
### Login Card
- Centered card on a dark canvas.
- Width: compact, roughly 420-520px.
- Rounded corners: 24-28px.
- Include one lock/trust mark, one clear product title, one short support sentence, one primary Google login button.
- Login should feel calm and premium, never busy.
### Buttons
- Primary button: dark app shell with blue fill, white text, medium radius.
- Secondary button: dark raised surface with subtle border.
- Destructive button: same structure as secondary, with red text or red-emphasis border only when needed.
- Button height should feel desktop-like, not mobile oversized.
- Avoid flashy gradients and oversized glows.
### Inputs
- Inputs use dark filled surfaces, subtle inset feel, 12-14px radius.
- Border should be nearly invisible at rest and stronger on hover/focus.
- Placeholders should be quiet and low-contrast.
- Search and filter inputs should visually align and share the same height.
### Lists and Rows
- Entry rows should be compact, crisp, and easy to scan.
- Selected row: slightly brighter dark card, subtle border, no heavy glow.
- Support a two-line rhythm: primary name and smaller type/folder metadata.
- Counts in the sidebar should use muted rounded chips.
### Detail Pane
- Use strong top title hierarchy with restrained action buttons on the right.
- Metadata should be presented in structured blocks or columns, not loose paragraphs.
- Secret values should live inside dedicated protected field cards.
- Secret field rows should include icon, masked value, reveal action, and copy action.
- Sensitive content must look controlled and deliberate, not playful.
### Modals
- Modal cards should feel like elevated control panels.
- MCP integration modal should support stacked integration rows with trailing toggles.
- Embedded JSON/config blocks should use a darker, code-oriented surface with monospace text.
- Large modal width is acceptable for configuration-heavy content.
### Toggles
- Use blue enabled state by default.
- Toggle track should be compact and clean, avoiding iOS-like softness.
- Align toggles flush right in integration lists.
### Badges and Status Pills
- Use small rounded pills for folder counts, archived state, or recent-delete state.
- Prefer muted purple, gray, or amber fills over saturated color blocks.
## 5. Layout Principles
- Use an 8px spacing system.
- Typical paddings:
- Sidebars: 16-20px.
- List and toolbar: 12-18px.
- Detail pane: 24-32px.
- Modals: 20-28px.
- Favor even vertical rhythm over decorative separators.
- Keep left edges aligned aggressively across sections.
- Avoid oversized hero spacing inside application surfaces.
- The main app should feel dense enough for productivity but never cramped.
## 6. Depth & Elevation
- Most separation should come from tone shifts and borders.
- Base panels: no shadow or extremely soft shadow.
- Elevated cards and modals: subtle shadow only, with low blur and low opacity.
- Do not use neon bloom, oversized backdrop blur, or glassmorphism.
- Focus states should use border color and a faint blue outer ring.
## 7. Do's and Don'ts
### Do
- Keep the UI dark, crisp, and desktop-native.
- Preserve strong information hierarchy in the detail pane.
- Make security-sensitive actions feel explicit and carefully gated.
- Use compact controls and disciplined spacing.
- Let alignment and typography carry most of the visual quality.
- Keep MCP integration screens structured like settings panels.
### Don't
- Do not turn the app into a landing page aesthetic.
- Do not use giant gradients, colorful illustrations, or soft SaaS cards.
- Do not over-round every surface.
- Do not mix many accent colors in one screen.
- Do not make secret fields look like casual form inputs.
- Do not use bright white backgrounds in the desktop app.
## 8. Responsive Behavior
- Primary target is desktop widths from 1280px upward.
- The three-pane shell should remain stable on desktop.
- At narrower widths, collapse from three panes to two panes before using stacked mobile behavior.
- The MCP modal can reduce width but should keep readable row spacing and code block legibility.
- Buttons and toggles should remain mouse-first, with minimum 32px touch-friendly height where practical.
## 9. Screen-Specific Guidance
### Login Screen
- Centered trust card.
- One focal icon or emblem above the title.
- Keep copy short.
- The Google login button should be the visual anchor.
### Main Secrets Screen
- Left sidebar: user card, folder navigation, utility actions near the bottom.
- Middle pane: search, type filter, result list.
- Right pane: selected entry title, metadata grid, secret cards, edit actions.
- The selected item should be immediately obvious but understated.
### MCP Integration Screen
- Treat as a settings modal.
- Integration rows should read like desktop preferences, not marketing feature cards.
- JSON config block should feel developer-native and copy-friendly.
## 10. Agent Prompt Guide
- Keywords: `dark desktop utility`, `Raycast-inspired`, `Linear-density`, `secure control panel`, `developer tool`, `restrained premium`, `MCP settings modal`.
- When generating screens, preserve: dark surfaces, subtle borders, compact controls, right-aligned actions, clean typography, muted status pills.
- If unsure, bias toward less decoration and tighter structure.
## 11. Quick Summary for Agents
Build Secrets like a polished desktop utility: mostly Raycast in atmosphere, a little Linear in density, with dark layered panels, precise typography, subtle borders, blue-only primary actions, and security-sensitive detail cards that feel calm, serious, and highly usable.

File diff suppressed because it is too large Load Diff

41
apps/desktop/dist/disable-features.js vendored Normal file
View File

@@ -0,0 +1,41 @@
(() => {
const tauriInvoke = window.__TAURI_INTERNALS__?.invoke;
// Disable text selection globally, but keep inputs editable.
document.addEventListener("selectstart", (event) => {
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return;
}
event.preventDefault();
});
async function applyProductionGuards() {
if (!tauriInvoke) {
return;
}
let isDebugBuild = false;
try {
isDebugBuild = await tauriInvoke("is_debug_build");
} catch {
return;
}
if (isDebugBuild) {
return;
}
document.addEventListener("contextmenu", (event) => event.preventDefault());
document.addEventListener("keydown", (event) => {
if (event.key === "F12") {
event.preventDefault();
}
if ((event.ctrlKey || event.metaKey) && event.shiftKey && ["I", "C", "J"].includes(event.key.toUpperCase())) {
event.preventDefault();
}
});
}
void applyProductionGuards();
})();

BIN
apps/desktop/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

279
apps/desktop/dist/index.html vendored Normal file
View File

@@ -0,0 +1,279 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Secrets</title>
<link rel="stylesheet" href="./styles.css" />
<script src="./disable-features.js"></script>
</head>
<body>
<div id="login-view" class="login-screen hidden">
<div class="window-titlebar login-titlebar" data-tauri-drag-region aria-hidden="true"></div>
<div class="login-card">
<div class="login-main">
<div class="login-emblem" aria-hidden="true">
<svg class="login-lock-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="16" r="1"></circle>
<rect x="3" y="10" width="18" height="12" rx="2"></rect>
<path d="M7 10V7a5 5 0 0 1 10 0v3"></path>
</svg>
</div>
<div class="login-title-block">
<h1>Secrets</h1>
<p class="login-subtle">用 AI 安全地管理和使用密钥</p>
</div>
<div class="login-actions">
<button id="login-button" class="primary login-google-button">
<svg class="login-google-mark" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
<span>前往浏览器登录</span>
</button>
</div>
<p id="login-error" class="error-text hidden"></p>
</div>
</div>
</div>
<div id="vault-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3 id="vault-modal-title">解锁本地 Vault</h3>
</div>
<p id="vault-modal-copy" class="subtle modal-copy">请输入本地 vault 主密码。</p>
<div class="modal-form">
<label class="field-label">
<span>主密码</span>
<input id="vault-password-input" type="password" class="detail-input" placeholder="输入主密码" />
</label>
</div>
<p id="vault-modal-error" class="error-text hidden"></p>
<div class="modal-actions">
<button id="vault-modal-save" class="primary small">继续</button>
</div>
</div>
</div>
<div id="app-shell" class="shell hidden">
<div class="window-titlebar shell-titlebar" data-tauri-drag-region aria-hidden="true"></div>
<aside class="sidebar">
<div class="user-block">
<button id="user-trigger" class="user-trigger">
<div class="avatar">V</div>
<div class="user-copy">
<div id="user-name" class="user-name">-</div>
<div id="user-email" class="user-email">-</div>
</div>
<span class="caret"></span>
</button>
<div id="user-menu" class="user-menu hidden">
<button id="manage-devices" class="menu-item">管理设备</button>
</div>
</div>
<div id="folder-list" class="folder-list"></div>
<div class="sidebar-spacer"></div>
<div class="sidebar-footer">
<button id="open-mcp-modal" class="sidebar-utility">
<span class="sidebar-utility-icon" aria-hidden="true"></span>
<span>MCP</span>
</button>
<button id="logout-button" class="sidebar-utility">
<span class="sidebar-utility-icon" aria-hidden="true"></span>
<span>退出登录</span>
</button>
</div>
</aside>
<main class="main-shell">
<section class="list-column">
<div class="searchbar-shell">
<input id="search-input" class="search-input global-search" placeholder="按名称模糊搜索" />
</div>
<section class="list-pane">
<div class="toolbar">
<button id="new-entry-button" class="secondary-button small">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">新建条目</span>
</button>
<select id="type-filter" class="filter-select">
<option value="">全部类型</option>
</select>
</div>
<div id="entry-list" class="entry-list"></div>
</section>
</section>
<section class="detail-pane">
<div class="detail-header">
<div class="detail-title-stack">
<div id="detail-folder-label" class="detail-folder-label">-</div>
<div class="detail-title-block">
<h2 id="entry-title">-</h2>
<div id="detail-badge" class="detail-badge hidden">最近删除</div>
</div>
</div>
<div class="detail-actions">
<button id="edit-entry-button" class="secondary-button small action-button">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">编辑</span>
</button>
<button id="delete-entry-button" class="secondary-button small danger action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">删除</span>
</button>
<button id="restore-entry-button" class="secondary-button small action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">恢复</span>
</button>
<button id="save-entry-button" class="primary small action-button hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">保存</span>
</button>
<button id="cancel-edit-button" class="secondary-button small action-button hidden">
<span class="button-icon" aria-hidden="true">×</span>
<span class="button-label">取消</span>
</button>
</div>
</div>
<div id="name-section" class="detail-section detail-edit-section hidden">
<h3>名称</h3>
<div id="name-view" class="detail-inline-value">-</div>
<input id="name-input" class="detail-input hidden" />
</div>
<div class="detail-section">
<h3>元数据</h3>
<div id="metadata-list" class="detail-fields"></div>
<div id="metadata-editor" class="metadata-editor hidden"></div>
<button id="add-metadata-button" class="secondary-button small hidden">新增元数据</button>
</div>
<div class="detail-section">
<div class="section-header-row">
<h3>密钥</h3>
<button id="add-secret-button" class="secondary-button small hidden">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">新增密钥</span>
</button>
</div>
<div id="secret-list" class="secret-list"></div>
</div>
</section>
</main>
<div id="device-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3>设备在线列表</h3>
<button id="close-device-modal" class="icon-button">×</button>
</div>
<p class="subtle modal-copy">查看已登录设备的在线情况与最近活动。</p>
<div id="device-list" class="device-list"></div>
</div>
</div>
<div id="mcp-modal" class="modal hidden">
<div class="modal-card wide">
<div class="modal-header">
<h3>MCP 集成</h3>
<button id="close-mcp-modal" class="icon-button">×</button>
</div>
<p class="subtle modal-copy">查看当前 AI 工具的 MCP 集成情况,并一键写入本地 daemon 配置。</p>
<section class="modal-section">
<div id="mcp-integration-list" class="integration-list"></div>
<p class="modal-footnote">启动 Secrets 桌面端时,可按选择自动为上述工具写入 MCP 配置。</p>
</section>
<section class="detail-section compact modal-section">
<div class="mcp-json-header">
<h4>自定义 MCP 配置</h4>
<button id="copy-mcp-config" class="secondary-button small">
<span class="button-icon" aria-hidden="true"></span>
<span class="button-label">复制</span>
</button>
</div>
<pre id="mcp-config" class="mcp-config"></pre>
</section>
</div>
</div>
<div id="entry-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3>新建条目</h3>
<button id="close-entry-modal" class="icon-button">×</button>
</div>
<div class="modal-form">
<label class="field-label">
<span>项目</span>
<input id="entry-modal-folder" class="detail-input" placeholder="例如Refining" />
</label>
<label class="field-label">
<span>名称</span>
<input id="entry-modal-title" class="detail-input" placeholder="例如secrets-local" />
</label>
<label class="field-label">
<span>类型</span>
<input id="entry-modal-type" class="detail-input" placeholder="例如service" />
</label>
</div>
<div class="modal-actions">
<button id="entry-modal-cancel" class="secondary-button small">取消</button>
<button id="entry-modal-save" class="primary small">创建</button>
</div>
</div>
</div>
<div id="secret-modal" class="modal hidden">
<div class="modal-card">
<div class="modal-header">
<h3 id="secret-modal-title">新增密钥</h3>
<button id="close-secret-modal" class="icon-button">×</button>
</div>
<div class="modal-form">
<label class="field-label">
<span>名称</span>
<input id="secret-name-input" class="detail-input" placeholder="例如token" />
</label>
<label class="field-label">
<span>类型</span>
<select id="secret-type-input" class="filter-select">
<option value="text">text</option>
<option value="password">password</option>
<option value="key">key</option>
</select>
</label>
<label class="field-label">
<span>内容</span>
<textarea id="secret-value-input" class="detail-input detail-textarea" placeholder="输入密钥内容"></textarea>
</label>
</div>
<div class="modal-actions">
<button id="secret-modal-cancel" class="secondary-button small">取消</button>
<button id="secret-modal-save" class="primary small">保存</button>
</div>
</div>
</div>
<div id="history-modal" class="modal hidden">
<div class="modal-card wide">
<div class="modal-header">
<h3>密钥历史</h3>
<button id="close-history-modal" class="icon-button">×</button>
</div>
<p id="history-modal-copy" class="subtle modal-copy">查看版本历史并回滚到指定版本。</p>
<div id="history-list" class="history-list"></div>
</div>
</div>
</div>
<script src="./main.js"></script>
</body>
</html>

1020
apps/desktop/dist/main.js vendored Normal file

File diff suppressed because it is too large Load Diff

1072
apps/desktop/dist/styles.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
[package]
name = "secrets-desktop"
version = "3.0.0"
edition.workspace = true
[build-dependencies]
tauri-build.workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true
tauri.workspace = true
tokio.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
uuid.workspace = true
base64 = "0.22.1"
secrets-client-integrations = { path = "../../../crates/client-integrations" }
secrets-crypto = { path = "../../../crates/crypto" }
secrets-device-auth = { path = "../../../crates/device-auth" }
secrets-domain = { path = "../../../crates/domain" }
[[bin]]
name = "Secrets"
path = "src/main.rs"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,2 @@
const fs = require('fs');
// Very simple check: read the first few bytes, maybe we can use an image library to find the bounding box

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State as AxumState},
http::{StatusCode as AxumStatusCode, header},
response::Response,
routing::{any, get, post},
};
use url::Url;
use crate::local_vault::{
LocalEntryQuery, bootstrap as vault_bootstrap, create_entry as vault_create_entry,
create_secret as vault_create_secret, delete_entry as vault_delete_entry,
delete_secret as vault_delete_secret, entry_detail as vault_entry_detail,
list_entries as vault_list_entries, restore_entry as vault_restore_entry,
reveal_secret_value as vault_reveal_secret_value, rollback_secret as vault_rollback_secret,
secret_history as vault_secret_history, update_entry as vault_update_entry,
update_secret as vault_update_secret,
};
use crate::{
DesktopState, EntryDetail, EntryDraft, EntryListItem, EntryListQuery, SecretDraft,
SecretUpdateDraft, current_device_token, map_entry_detail_to_local, map_entry_draft_to_local,
map_local_entry_detail, map_local_history_item, map_local_secret_value,
map_secret_draft_to_local, map_secret_update_to_local, split_secret_ref_for_ui,
sync_local_vault,
};
pub async fn desktop_session_health(
AxumState(state): AxumState<DesktopState>,
) -> Result<&'static str, AxumStatusCode> {
current_device_token(&state)
.map(|_| "ok")
.map_err(|_| AxumStatusCode::UNAUTHORIZED)
}
pub async fn desktop_session_api(
AxumState(state): AxumState<DesktopState>,
request: Request<Body>,
) -> Response {
let (parts, body) = request.into_parts();
let path_and_query = parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let body_bytes = match to_bytes(body, 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
return Response::builder()
.status(AxumStatusCode::BAD_REQUEST)
.body(Body::from("failed to read relay request body"))
.expect("build relay bad request");
}
};
handle_local_session_request(&state, parts.method.as_str(), path_and_query, &body_bytes)
.await
.unwrap_or_else(|| {
Response::builder()
.status(AxumStatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(
r#"{"error":"desktop local vault route not found"}"#,
))
.expect("build local session not found response")
})
}
async fn handle_local_session_request(
state: &DesktopState,
method: &str,
path_and_query: &str,
body_bytes: &[u8],
) -> Option<Response> {
let path = path_and_query.split('?').next().unwrap_or(path_and_query);
let make_json = |status: AxumStatusCode, value: serde_json::Value| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build local session response")
};
match (method, path) {
("GET", "/vault/status") => {
let status = vault_bootstrap(&state.local_vault).await.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({
"unlocked": status.unlocked,
"has_master_password": status.has_master_password
}),
))
}
("GET", "/vault/entries") => {
let url = format!("http://localhost{path_and_query}");
let parsed = Url::parse(&url).ok()?;
let mut query = EntryListQuery {
folder: None,
entry_type: None,
query: None,
deleted_only: false,
};
for (key, value) in parsed.query_pairs() {
match key.as_ref() {
"folder" => query.folder = Some(value.into_owned()),
"entry_type" => query.entry_type = Some(value.into_owned()),
"query" => query.query = Some(value.into_owned()),
"deleted_only" => query.deleted_only = value == "true",
_ => {}
}
}
let entries = vault_list_entries(
&state.local_vault,
&LocalEntryQuery {
folder: query.folder,
cipher_type: query.entry_type,
query: query.query,
deleted_only: query.deleted_only,
},
)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
entries
.into_iter()
.map(|entry| EntryListItem {
id: entry.id,
title: entry.name,
subtitle: entry.cipher_type,
folder: entry.folder,
deleted: entry.deleted,
})
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/");
let detail = vault_entry_detail(&state.local_vault, entry_id)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(detail)).ok()?,
))
}
("POST", "/vault/entries") => {
let draft: EntryDraft = serde_json::from_slice(body_bytes).ok()?;
let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(draft))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(created)).ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/entries/") => {
let entry_id = path.trim_start_matches("/vault/entries/").to_string();
let mut detail: EntryDetail = serde_json::from_slice(body_bytes).ok()?;
detail.id = entry_id;
let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(detail))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/delete") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/restore") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/restore")
.trim_end_matches('/');
vault_restore_entry(&state.local_vault, entry_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/entries/")
&& path.ends_with("/secrets") =>
{
let entry_id = path
.trim_start_matches("/vault/entries/")
.trim_end_matches("/secrets")
.trim_end_matches('/');
let secret: SecretDraft = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_create_secret(
&state.local_vault,
entry_id,
map_secret_draft_to_local(secret),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "GET" && path.starts_with("/vault/secrets/") && path.ends_with("/value") => {
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/value")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_secret_value(value)).ok()?,
))
}
_ if method == "GET"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/history") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/history")
.trim_end_matches('/')
.to_string();
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name)
.await
.ok()?;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(
history
.into_iter()
.map(map_local_history_item)
.collect::<Vec<_>>(),
)
.ok()?,
))
}
_ if method == "PATCH" && path.starts_with("/vault/secrets/") => {
let secret_id = path.trim_start_matches("/vault/secrets/").to_string();
let mut update: SecretUpdateDraft = serde_json::from_slice(body_bytes).ok()?;
update.id = secret_id;
let updated =
vault_update_secret(&state.local_vault, map_secret_update_to_local(update))
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/delete") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/delete")
.trim_end_matches('/');
vault_delete_secret(&state.local_vault, secret_id)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::json!({ "ok": true }),
))
}
_ if method == "POST"
&& path.starts_with("/vault/secrets/")
&& path.ends_with("/rollback") =>
{
let secret_id = path
.trim_start_matches("/vault/secrets/")
.trim_end_matches("/rollback")
.trim_end_matches('/')
.to_string();
let payload: serde_json::Value = serde_json::from_slice(body_bytes).ok()?;
let updated = vault_rollback_secret(
&state.local_vault,
&secret_id,
payload.get("history_id").and_then(|value| value.as_i64()),
)
.await
.ok()?;
let _ = sync_local_vault(state).await;
Some(make_json(
AxumStatusCode::OK,
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
))
}
_ => None,
}
}
pub async fn start_desktop_session_server(state: DesktopState) -> AnyResult<()> {
let app = Router::new()
.route("/healthz", get(desktop_session_health))
.route("/vault/status", get(desktop_session_api))
.route("/vault/entries", any(desktop_session_api))
.route("/vault/entries/{id}", any(desktop_session_api))
.route("/vault/entries/{id}/delete", post(desktop_session_api))
.route("/vault/entries/{id}/restore", post(desktop_session_api))
.route("/vault/entries/{id}/secrets", post(desktop_session_api))
.route("/vault/secrets/{id}", any(desktop_session_api))
.route("/vault/secrets/{id}/value", get(desktop_session_api))
.route("/vault/secrets/{id}/history", get(desktop_session_api))
.route("/vault/secrets/{id}/delete", post(desktop_session_api))
.route("/vault/secrets/{id}/rollback", post(desktop_session_api))
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&state.session_bind)
.await
.with_context(|| {
format!(
"failed to bind desktop session relay {}",
state.session_bind
)
})?;
axum::serve(listener, app)
.await
.context("desktop session relay server error")
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Secrets",
"version": "3.0.0",
"identifier": "dev.refining.secrets",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Secrets",
"width": 420,
"height": 400,
"minWidth": 420,
"minHeight": 400,
"resizable": true,
"titleBarStyle": "overlay",
"hiddenTitle": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "secrets-application"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_application"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
uuid.workspace = true
secrets-domain = { path = "../domain" }

View File

@@ -0,0 +1,9 @@
use secrets_domain::VaultObjectEnvelope;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct RevisionConflict {
pub change_id: Uuid,
pub object_id: Uuid,
pub server_object: Option<VaultObjectEnvelope>,
}

View File

@@ -0,0 +1,3 @@
pub mod conflict;
pub mod sync;
pub mod vault_store;

View File

@@ -0,0 +1,252 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
SyncPushResponse, VaultObjectChange, VaultObjectEnvelope,
};
use crate::vault_store::{
get_object, list_objects_since, list_tombstones_since, max_server_revision,
};
fn detect_conflict(
change: &VaultObjectChange,
existing: Option<&VaultObjectEnvelope>,
) -> Option<SyncConflict> {
match (change.base_revision, existing) {
(Some(base_revision), Some(server_object)) if server_object.revision != base_revision => {
Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "revision_conflict".to_string(),
server_object: Some(server_object.clone()),
})
}
_ if !matches!(change.operation.as_str(), "upsert" | "delete") => Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "unsupported_operation".to_string(),
server_object: existing.cloned(),
}),
_ => None,
}
}
pub async fn sync_pull(
pool: &PgPool,
user_id: Uuid,
request: SyncPullRequest,
) -> Result<SyncPullResponse> {
let cursor = request.cursor.unwrap_or(0).max(0);
let limit = request.limit.unwrap_or(200).clamp(1, 500);
let objects = list_objects_since(pool, user_id, cursor, limit).await?;
let tombstones = if request.include_deleted {
list_tombstones_since(pool, user_id, cursor, limit).await?
} else {
Vec::new()
};
let server_revision = max_server_revision(pool, user_id).await?;
let next_cursor = objects
.last()
.map(|object| object.revision)
.unwrap_or(cursor);
Ok(SyncPullResponse {
server_revision,
next_cursor,
has_more: (objects.len() as i64) >= limit,
objects,
tombstones,
})
}
pub async fn sync_push(
pool: &PgPool,
user_id: Uuid,
request: SyncPushRequest,
) -> Result<SyncPushResponse> {
let mut accepted = Vec::new();
let mut conflicts = Vec::new();
for change in request.changes {
let existing = get_object(pool, user_id, change.object_id).await?;
if let Some(conflict) = detect_conflict(&change, existing.as_ref()) {
conflicts.push(conflict);
continue;
}
let next_revision = existing
.as_ref()
.map(|object| object.revision + 1)
.unwrap_or(1);
let next_cipher_version = change.cipher_version.unwrap_or(1);
let next_ciphertext = change.ciphertext.clone().unwrap_or_default();
let next_content_hash = change.content_hash.clone().unwrap_or_default();
let next_deleted_at = if change.operation == "delete" {
Some(chrono::Utc::now())
} else {
None
};
match change.operation.as_str() {
"upsert" => {
sqlx::query(
r#"
INSERT INTO vault_objects (
object_id, user_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, created_by_device
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW(), NULL)
ON CONFLICT (object_id)
DO UPDATE SET
revision = EXCLUDED.revision,
cipher_version = EXCLUDED.cipher_version,
ciphertext = EXCLUDED.ciphertext,
content_hash = EXCLUDED.content_hash,
deleted_at = NULL,
updated_at = NOW()
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(change.object_kind.as_str())
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext.clone())
.bind(next_content_hash.clone())
.execute(pool)
.await?;
}
"delete" => {
sqlx::query(
r#"
UPDATE vault_objects
SET revision = $1, deleted_at = NOW(), updated_at = NOW()
WHERE object_id = $2
AND user_id = $3
"#,
)
.bind(next_revision)
.bind(change.object_id)
.bind(user_id)
.execute(pool)
.await?;
}
_ => unreachable!("unsupported operations are filtered by detect_conflict"),
}
sqlx::query(
r#"
INSERT INTO vault_object_revisions (
object_id, user_id, revision, cipher_version, ciphertext, content_hash, deleted_at, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext)
.bind(next_content_hash)
.bind(next_deleted_at)
.execute(pool)
.await?;
accepted.push(SyncAcceptedChange {
change_id: change.change_id,
object_id: change.object_id,
revision: next_revision,
});
}
let server_revision = max_server_revision(pool, user_id).await?;
Ok(SyncPushResponse {
server_revision,
accepted,
conflicts,
})
}
pub async fn fetch_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
get_object(pool, user_id, object_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use secrets_domain::{VaultObjectChange, VaultObjectKind};
use uuid::Uuid;
fn sample_change(operation: &str, base_revision: Option<i64>) -> VaultObjectChange {
VaultObjectChange {
change_id: Uuid::nil(),
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
operation: operation.to_string(),
base_revision,
cipher_version: Some(1),
ciphertext: Some(vec![1, 2, 3]),
content_hash: Some("sha256:test".to_string()),
}
}
fn sample_object(revision: i64) -> VaultObjectEnvelope {
VaultObjectEnvelope {
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
revision,
cipher_version: 1,
ciphertext: vec![9, 9, 9],
content_hash: "sha256:server".to_string(),
deleted_at: None,
updated_at: Utc::now(),
}
}
#[test]
fn conflict_when_base_revision_is_stale() {
let mut change = sample_change("upsert", Some(3));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server)).expect("expected conflict");
assert_eq!(conflict.reason, "revision_conflict");
assert_eq!(conflict.object_id, server.object_id);
assert_eq!(
conflict
.server_object
.as_ref()
.map(|object| object.revision),
Some(5)
);
}
#[test]
fn no_conflict_when_revision_matches() {
let mut change = sample_change("upsert", Some(5));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server));
assert!(conflict.is_none());
}
#[test]
fn unsupported_operation_is_conflict() {
let change = sample_change("merge", None);
let conflict = detect_conflict(&change, None).expect("expected unsupported operation");
assert_eq!(conflict.reason, "unsupported_operation");
assert!(conflict.server_object.is_none());
}
}

View File

@@ -0,0 +1,147 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone};
#[derive(Debug, sqlx::FromRow)]
struct VaultObjectRow {
object_id: Uuid,
_object_kind: String,
revision: i64,
cipher_version: i32,
ciphertext: Vec<u8>,
content_hash: String,
deleted_at: Option<DateTime<Utc>>,
updated_at: DateTime<Utc>,
}
impl From<VaultObjectRow> for VaultObjectEnvelope {
fn from(row: VaultObjectRow) -> Self {
Self {
object_id: row.object_id,
object_kind: VaultObjectKind::Cipher,
revision: row.revision,
cipher_version: row.cipher_version,
ciphertext: row.ciphertext,
content_hash: row.content_hash,
deleted_at: row.deleted_at,
updated_at: row.updated_at,
}
}
}
pub async fn list_objects_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultObjectEnvelope>> {
let rows = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list vault objects")?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
let row = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND object_id = $2
"#,
)
.bind(user_id)
.bind(object_id)
.fetch_optional(pool)
.await
.context("failed to load vault object")?;
Ok(row.map(Into::into))
}
pub async fn list_tombstones_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultTombstone>> {
let rows = sqlx::query_as::<_, (Uuid, i64, DateTime<Utc>)>(
r#"
SELECT object_id, revision, deleted_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
AND deleted_at IS NOT NULL
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list tombstones")?;
Ok(rows
.into_iter()
.map(|(object_id, revision, deleted_at)| VaultTombstone {
object_id,
revision,
deleted_at,
})
.collect())
}
pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result<i64> {
let revision = sqlx::query_scalar::<_, Option<i64>>(
r#"
SELECT MAX(revision)
FROM vault_objects
WHERE user_id = $1
"#,
)
.bind(user_id)
.fetch_one(pool)
.await
.context("failed to load max server revision")?;
Ok(revision.unwrap_or(0))
}

View File

@@ -0,0 +1,13 @@
[package]
name = "secrets-client-integrations"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_client_integrations"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -0,0 +1,162 @@
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use std::{
fs,
path::{Path, PathBuf},
};
pub trait ClientAdapter {
fn client_name(&self) -> &'static str;
fn config_path(&self) -> PathBuf;
}
pub struct CursorAdapter;
impl ClientAdapter for CursorAdapter {
fn client_name(&self) -> &'static str {
"cursor"
}
fn config_path(&self) -> PathBuf {
default_home().join(".cursor").join("mcp.json")
}
}
pub struct ClaudeCodeAdapter;
impl ClientAdapter for ClaudeCodeAdapter {
fn client_name(&self) -> &'static str {
"claude-code"
}
fn config_path(&self) -> PathBuf {
default_home().join(".claude").join("mcp.json")
}
}
fn default_home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result<bool> {
let path = adapter.config_path();
let root = read_config_or_default(&path)?;
Ok(root
.get("mcpServers")
.and_then(Value::as_object)
.is_some_and(|servers| servers.contains_key(server_name)))
}
pub fn upsert_managed_server(
adapter: &dyn ClientAdapter,
server_name: &str,
server_config: Value,
) -> Result<()> {
let path = adapter.config_path();
let mut root = read_config_or_default(&path)?;
let root_object = ensure_object(&mut root);
let mcp_servers = root_object
.entry("mcpServers".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let servers_object = ensure_object(mcp_servers);
servers_object.insert(server_name.to_string(), server_config);
write_config_atomically(&path, &root)
}
fn read_config_or_default(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let raw =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
}
fn write_config_atomically(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let tmp_path = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?;
fs::write(&tmp_path, body)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
value.as_object_mut().expect("object just ensured")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestAdapter {
path: PathBuf,
}
impl ClientAdapter for TestAdapter {
fn client_name(&self) -> &'static str {
"test"
}
fn config_path(&self) -> PathBuf {
self.path.clone()
}
}
#[test]
fn upsert_preserves_other_servers() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}"));
let adapter = TestAdapter {
path: base.join("mcp.json"),
};
fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir");
fs::write(
&adapter.path,
r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#,
)
.expect("seed config");
upsert_managed_server(
&adapter,
"secrets",
serde_json::json!({
"url": "http://127.0.0.1:9515/mcp"
}),
)
.expect("upsert config");
let root: Value =
serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back"))
.expect("parse back");
let servers = root
.get("mcpServers")
.and_then(Value::as_object)
.expect("mcpServers object");
assert!(servers.contains_key("postgres"));
assert_eq!(
servers
.get("secrets")
.and_then(Value::as_object)
.and_then(|value| value.get("url"))
.and_then(Value::as_str),
Some("http://127.0.0.1:9515/mcp")
);
let _ = fs::remove_dir_all(base);
}
}

14
crates/crypto/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "secrets-crypto"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_crypto"
path = "src/lib.rs"
[dependencies]
aes-gcm.workspace = true
anyhow.workspace = true
hex.workspace = true
rand.workspace = true

47
crates/crypto/src/lib.rs Normal file
View File

@@ -0,0 +1,47 @@
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use anyhow::{Context, Result};
use rand::Rng;
pub const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-v3-key-check";
pub fn decode_hex(input: &str) -> Result<Vec<u8>> {
hex::decode(input.trim()).context("invalid hex")
}
pub fn encode_hex(input: &[u8]) -> String {
hex::encode(input)
}
pub fn extract_key_32(input: &str) -> Result<[u8; 32]> {
let bytes = decode_hex(input)?;
let key: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("expected 32-byte key"))?;
Ok(key)
}
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
let mut nonce_bytes = [0_u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut out = nonce_bytes.to_vec();
out.extend(
cipher
.encrypt(nonce, plaintext)
.map_err(|_| anyhow::anyhow!("encryption failed"))?,
);
Ok(out)
}
pub fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>> {
if ciphertext.len() < 12 {
anyhow::bail!("ciphertext too short");
}
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
let (nonce, body) = ciphertext.split_at(12);
cipher
.decrypt(Nonce::from_slice(nonce), body)
.map_err(|_| anyhow::anyhow!("decryption failed"))
}

View File

@@ -0,0 +1,26 @@
[package]
name = "secrets-desktop-daemon"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_desktop_daemon"
path = "src/lib.rs"
[[bin]]
name = "secrets-desktop-daemon"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
reqwest = { workspace = true, features = ["stream"] }
rmcp.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
secrets-device-auth = { path = "../device-auth" }

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub bind: String,
}
pub fn load_config() -> Result<DaemonConfig> {
let bind =
std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string());
if bind.trim().is_empty() {
anyhow::bail!("SECRETS_DAEMON_BIND must not be empty");
}
Ok(DaemonConfig { bind })
}
pub fn load_persisted_device_token() -> Result<Option<String>> {
let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(token)
}

View File

@@ -0,0 +1,139 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tokio::process::Command;
use crate::target::{ExecutionTarget, ResolvedTarget};
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
pub struct TargetExecInput {
pub target_ref: Option<String>,
pub command: String,
pub timeout_secs: Option<u64>,
pub working_dir: Option<String>,
pub env_overrides: Option<Map<String, Value>>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ExecResult {
pub resolved_target: ResolvedTarget,
pub resolved_env_keys: Vec<String>,
pub command: String,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub duration_ms: u128,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
fn truncate_output(text: String) -> (String, bool) {
if text.chars().count() <= MAX_OUTPUT_CHARS {
return (text, false);
}
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
(truncated, true)
}
fn stringify_env_override(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn apply_env_overrides(
env: &mut BTreeMap<String, String>,
overrides: Option<&Map<String, Value>>,
) -> Result<()> {
let Some(overrides) = overrides else {
return Ok(());
};
for (key, value) in overrides {
if key.is_empty() || key.contains('=') {
return Err(anyhow!("invalid env override key: {key}"));
}
if key.starts_with("TARGET_") {
return Err(anyhow!(
"env override `{key}` cannot override reserved TARGET_* variables"
));
}
if let Some(value) = stringify_env_override(value) {
env.insert(key.clone(), value);
}
}
Ok(())
}
pub async fn execute_command(
input: &TargetExecInput,
target: &ExecutionTarget,
timeout_secs: u64,
) -> Result<ExecResult> {
let mut env = target.env.clone();
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
let started = std::time::Instant::now();
let mut command = Command::new("/bin/sh");
command
.arg("-lc")
.arg(&input.command)
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
command.current_dir(dir);
}
for (key, value) in &env {
command.env(key, value);
}
let child = command
.spawn()
.with_context(|| format!("failed to spawn command: {}", input.command))?;
let timed = tokio::time::timeout(
Duration::from_secs(timeout_secs.clamp(1, 86400)),
child.wait_with_output(),
)
.await;
let (exit_code, stdout, stderr, timed_out) = match timed {
Ok(output) => {
let output = output.context("failed waiting for command output")?;
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
false,
)
}
Err(_) => (None, String::new(), "command timed out".to_string(), true),
};
let (stdout, stdout_truncated) = truncate_output(stdout);
let (stderr, stderr_truncated) = truncate_output(stderr);
Ok(ExecResult {
resolved_target: target.resolved.clone(),
resolved_env_keys: target.resolved_env_keys(),
command: input.command.clone(),
exit_code,
stdout,
stderr,
timed_out,
duration_ms: started.elapsed().as_millis(),
stdout_truncated,
stderr_truncated,
})
}

View File

@@ -0,0 +1,642 @@
pub mod config;
pub mod exec;
pub mod target;
pub mod vault_client;
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use axum::{
Router,
body::Body,
extract::State,
http::{StatusCode, header},
response::Response,
routing::{any, get},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
exec::{TargetExecInput, execute_command},
target::{TargetSnapshot, build_execution_target},
vault_client::{
EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get,
authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail,
fetch_revealed_entry_secrets,
},
};
#[derive(Clone)]
pub struct AppState {
session_base: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
id: Value,
method: String,
#[serde(default)]
params: Value,
}
fn json_response(status: StatusCode, value: Value) -> Response {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(value.to_string()))
.expect("build response")
}
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": id,
"result": result,
}),
)
}
fn tool_success_response(id: Value, value: Value) -> Response {
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": pretty
}
],
"isError": false
}),
)
}
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
jsonrpc_result_response(
id,
json!({
"content": [
{
"type": "text",
"text": message.into()
}
],
"isError": true
}),
)
}
fn initialize_response(id: Value) -> Response {
let session_id = format!(
"desktop-daemon-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
);
let payload = json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "secrets-desktop-daemon",
"version": env!("CARGO_PKG_VERSION"),
"title": "Secrets Desktop Daemon"
},
"instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session."
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.header("mcp-session-id", session_id)
.body(Body::from(payload.to_string()))
.expect("build response")
}
fn tool_definitions() -> Vec<Value> {
vec![
json!({
"name": "secrets_entry_find",
"description": "Find entries from the user's secrets vault.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
"folder": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] }
}
}
}),
json!({
"name": "secrets_entry_get",
"description": "Get one entry from the unlocked local vault by entry id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_add",
"description": "Create a new entry and optionally include initial secrets.",
"inputSchema": {
"type": "object",
"properties": {
"folder": { "type": "string" },
"name": { "type": "string" },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] },
"secrets": {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["name", "value"]
}
}
},
"required": ["folder", "name"]
}
}),
json!({
"name": "secrets_entry_update",
"description": "Update an existing entry by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"folder": { "type": ["string", "null"] },
"name": { "type": ["string", "null"] },
"type": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_delete",
"description": "Move an entry into recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_entry_restore",
"description": "Restore a deleted entry from recycle bin by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_add",
"description": "Create one secret under an existing entry.",
"inputSchema": {
"type": "object",
"properties": {
"entry_id": { "type": "string" },
"name": { "type": "string" },
"secret_type": { "type": ["string", "null"] },
"value": { "type": "string" }
},
"required": ["entry_id", "name", "value"]
}
}),
json!({
"name": "secrets_secret_update",
"description": "Update one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": ["string", "null"] },
"secret_type": { "type": ["string", "null"] },
"value": { "type": ["string", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_delete",
"description": "Delete one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_history",
"description": "List history snapshots for one secret by id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}),
json!({
"name": "secrets_secret_rollback",
"description": "Rollback one secret by id to a previous version or history id.",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"version": { "type": ["integer", "null"] },
"history_id": { "type": ["integer", "null"] }
},
"required": ["id"]
}
}),
json!({
"name": "target_exec",
"description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.",
"inputSchema": {
"type": "object",
"properties": {
"target_ref": { "type": ["string", "null"] },
"command": { "type": "string" },
"timeout_secs": { "type": ["integer", "null"] },
"working_dir": { "type": ["string", "null"] },
"env_overrides": { "type": ["object", "null"] }
},
"required": ["target_ref", "command"]
}
}),
]
}
fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot {
let metadata = detail
.metadata
.iter()
.map(|field| (field.label.clone(), Value::String(field.value.clone())))
.collect();
let secret_fields = detail
.secrets
.iter()
.map(|secret| crate::target::SecretFieldRef {
name: secret.name.clone(),
secret_type: Some(secret.secret_type.clone()),
})
.collect();
TargetSnapshot {
id: detail.id.clone(),
folder: detail.folder.clone(),
name: detail.name.clone(),
entry_type: Some(detail.cipher_type.clone()),
metadata,
secret_fields,
}
}
fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap<String, Value> {
secrets
.iter()
.map(|secret| (secret.name.clone(), Value::String(secret.value.clone())))
.collect()
}
async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result<Value> {
match name {
"secrets_entry_find" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let query = arguments
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let mut params = Vec::new();
if let Some(folder) = folder {
params.push(("folder", folder));
}
if let Some(query) = query {
params.push(("query", query));
}
if let Some(entry_type) = entry_type {
params.push(("entry_type", entry_type));
}
params.push(("deleted_only", "false".to_string()));
let entries = authorized_get(state, "/vault/entries", &params)
.await?
.json::<Vec<EntrySummary>>()
.await
.context("failed to decode entries list")?;
Ok(json!({
"entries": entries.into_iter().map(|entry| {
json!({
"id": entry.id,
"folder": entry.folder,
"name": entry.name,
"type": entry.cipher_type
})
}).collect::<Vec<_>>()
}))
}
"secrets_entry_get" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let detail = fetch_entry_detail(state, id).await?;
let secrets = fetch_revealed_entry_secrets(state, id).await?;
Ok(entry_detail_payload(&detail, Some(&secrets)))
}
"secrets_entry_add" => {
let folder = arguments
.get("folder")
.and_then(Value::as_str)
.context("folder is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let entry_type = arguments
.get("type")
.and_then(Value::as_str)
.unwrap_or("entry");
let metadata = arguments
.get("metadata")
.cloned()
.unwrap_or_else(|| json!({}));
let res = authorized_post(
state,
"/vault/entries",
&json!({
"folder": folder,
"name": name,
"entry_type": entry_type,
"metadata": metadata,
"secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode create result")?)
}
"secrets_entry_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let body = json!({
"folder": arguments.get("folder").cloned().unwrap_or(Value::Null),
"entry_type": arguments.get("type").cloned().unwrap_or(Value::Null),
"title": arguments.get("name").cloned().unwrap_or(Value::Null),
"metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null)
});
let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode update result")?)
}
"secrets_entry_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode delete result")?)
}
"secrets_entry_restore" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode restore result")?)
}
"secrets_secret_add" => {
let entry_id = arguments
.get("entry_id")
.and_then(Value::as_str)
.context("entry_id is required")?;
let name = arguments
.get("name")
.and_then(Value::as_str)
.context("name is required")?;
let value = arguments
.get("value")
.and_then(Value::as_str)
.context("value is required")?;
let res = authorized_post(
state,
&format!("/vault/entries/{entry_id}/secrets"),
&json!({
"name": name,
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": value
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret create result")?)
}
"secrets_secret_update" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_patch(
state,
&format!("/vault/secrets/{id}"),
&json!({
"name": arguments.get("name").cloned().unwrap_or(Value::Null),
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
"value": arguments.get("value").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret update result")?)
}
"secrets_secret_delete" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res =
authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret delete result")?)
}
"secrets_secret_history" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[])
.await?
.json::<Vec<SecretHistoryItem>>()
.await
.context("failed to decode secret history")?;
Ok(json!({
"history": history.into_iter().map(|item| {
json!({
"history_id": item.history_id,
"secret_id": item.secret_id,
"name": item.name,
"type": item.secret_type,
"masked_value": item.masked_value,
"value": item.value,
"version": item.version,
"action": item.action,
"created_at": item.created_at
})
}).collect::<Vec<_>>()
}))
}
"secrets_secret_rollback" => {
let id = arguments
.get("id")
.and_then(Value::as_str)
.context("id is required")?;
let res = authorized_post(
state,
&format!("/vault/secrets/{id}/rollback"),
&json!({
"version": arguments.get("version").cloned().unwrap_or(Value::Null),
"history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null)
}),
)
.await?;
Ok(res
.json::<Value>()
.await
.context("failed to decode secret rollback result")?)
}
"target_exec" => {
let input: TargetExecInput =
serde_json::from_value(arguments).context("invalid target_exec arguments")?;
let target_ref = input
.target_ref
.as_ref()
.context("target_ref is required")?;
let detail = fetch_entry_detail(state, target_ref).await?;
let secrets = fetch_revealed_entry_secrets(state, target_ref).await?;
let execution_target = build_execution_target(
&entry_detail_to_snapshot(&detail),
&revealed_secrets_to_env(&secrets),
)?;
let result =
execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30))
.await?;
Ok(serde_json::to_value(result).context("failed to encode exec result")?)
}
other => Err(anyhow!("unsupported tool: {other}")),
}
}
pub async fn handle_mcp(State(state): State<AppState>, body: String) -> Response {
let request: JsonRpcRequest = match serde_json::from_str(&body) {
Ok(request) => request,
Err(err) => {
return json_response(
StatusCode::BAD_REQUEST,
json!({
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": format!("invalid request: {err}")
}
}),
);
}
};
match request.method.as_str() {
"initialize" => initialize_response(request.id),
"tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })),
"tools/call" => {
let name = request
.params
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let arguments = request
.params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
match call_tool(&state, name, arguments).await {
Ok(value) => tool_success_response(request.id, value),
Err(err) => tool_error_response(request.id, err.to_string()),
}
}
other => json_response(
StatusCode::OK,
json!({
"jsonrpc": "2.0",
"id": request.id,
"error": {
"code": -32601,
"message": format!("method `{other}` not supported by secrets-desktop-daemon")
}
}),
),
}
}
pub async fn build_router() -> Result<Router> {
let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL")
.unwrap_or_else(|_| "http://127.0.0.1:9520".to_string());
let state = AppState {
session_base,
client: reqwest::Client::new(),
};
Ok(Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/mcp", any(handle_mcp))
.with_state(state))
}

View File

@@ -0,0 +1,26 @@
use anyhow::{Context, Result};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_desktop_daemon=info".into()),
)
.init();
let config = secrets_desktop_daemon::config::load_config()?;
let app = secrets_desktop_daemon::build_router().await?;
let listener = tokio::net::TcpListener::bind(&config.bind)
.await
.with_context(|| format!("failed to bind {}", config.bind))?;
tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening");
axum::serve(listener, app)
.await
.context("daemon server error")?;
Ok(())
}

View File

@@ -0,0 +1,332 @@
use std::collections::{BTreeMap, HashMap};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SecretFieldRef {
pub name: String,
#[serde(rename = "type")]
pub secret_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TargetSnapshot {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
#[serde(default)]
pub metadata: Map<String, Value>,
#[serde(default)]
pub secret_fields: Vec<SecretFieldRef>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ResolvedTarget {
pub id: String,
pub folder: String,
pub name: String,
#[serde(rename = "type")]
pub entry_type: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ExecutionTarget {
pub resolved: ResolvedTarget,
pub env: BTreeMap<String, String>,
}
impl ExecutionTarget {
pub fn resolved_env_keys(&self) -> Vec<String> {
self.env.keys().cloned().collect()
}
}
fn stringify_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
other => serde_json::to_string(other).ok(),
}
}
fn sanitize_env_key(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for ch in key.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_uppercase());
} else {
out.push('_');
}
}
while out.contains("__") {
out = out.replace("__", "_");
}
out.trim_matches('_').to_string()
}
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
if let Some(value) = value.filter(|v| !v.is_empty()) {
env.entry(key.to_string()).or_insert(value);
}
}
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| metadata.get(*key))
.and_then(stringify_value)
}
fn secret_alias(
secrets: &HashMap<String, Value>,
secret_types: &HashMap<&str, Option<&str>>,
name_match: impl Fn(&str) -> bool,
type_match: impl Fn(Option<&str>) -> bool,
) -> Option<String> {
secrets.iter().find_map(|(name, value)| {
let normalized = sanitize_env_key(name);
let ty = secret_types.get(name.as_str()).copied().flatten();
if name_match(&normalized) || type_match(ty) {
stringify_value(value)
} else {
None
}
})
}
pub fn build_execution_target(
snapshot: &TargetSnapshot,
secrets: &HashMap<String, Value>,
) -> Result<ExecutionTarget> {
if snapshot.id.trim().is_empty() {
return Err(anyhow!("target snapshot missing id"));
}
let mut env = BTreeMap::new();
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
}
for (key, value) in &snapshot.metadata {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_META_{name}"), value);
}
}
}
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
.secret_fields
.iter()
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
.collect();
for (key, value) in secrets {
if let Some(value) = stringify_value(value) {
let name = sanitize_env_key(key);
if !name.is_empty() {
env.insert(format!("TARGET_SECRET_{name}"), value);
}
}
}
set_if_missing(
&mut env,
"TARGET_HOST",
metadata_alias(
&snapshot.metadata,
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
),
);
set_if_missing(
&mut env,
"TARGET_PORT",
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
);
set_if_missing(
&mut env,
"TARGET_USER",
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
);
set_if_missing(
&mut env,
"TARGET_BASE_URL",
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
);
set_if_missing(
&mut env,
"TARGET_API_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_TOKEN",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("TOKEN"),
|_| false,
),
);
set_if_missing(
&mut env,
"TARGET_SSH_KEY",
secret_alias(
secrets,
&secret_type_map,
|name| name.contains("SSH") || name.ends_with("PEM"),
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
),
);
Ok(ExecutionTarget {
resolved: ResolvedTarget {
id: snapshot.id.clone(),
folder: snapshot.folder.clone(),
name: snapshot.name.clone(),
entry_type: snapshot.entry_type.clone(),
},
env,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_snapshot() -> TargetSnapshot {
let mut metadata = Map::new();
metadata.insert(
"host".to_string(),
Value::String("git.example.com".to_string()),
);
metadata.insert("port".to_string(), Value::String("22".to_string()));
metadata.insert("username".to_string(), Value::String("deploy".to_string()));
metadata.insert(
"base_url".to_string(),
Value::String("https://api.example.com".to_string()),
);
TargetSnapshot {
id: "entry-1".to_string(),
folder: "infra".to_string(),
name: "production".to_string(),
entry_type: Some("ssh_key".to_string()),
metadata,
secret_fields: vec![
SecretFieldRef {
name: "api_key".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "token".to_string(),
secret_type: Some("text".to_string()),
},
SecretFieldRef {
name: "ssh_key".to_string(),
secret_type: Some("ssh-key".to_string()),
},
],
}
}
#[test]
fn derives_standard_target_env_keys() {
let snapshot = build_snapshot();
let secrets = HashMap::from([
("api_key".to_string(), Value::String("ak-123".to_string())),
("token".to_string(), Value::String("tok-456".to_string())),
(
"ssh_key".to_string(),
Value::String("-----BEGIN KEY-----".to_string()),
),
]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_ENTRY_ID").map(String::as_str),
Some("entry-1")
);
assert_eq!(
target.env.get("TARGET_NAME").map(String::as_str),
Some("production")
);
assert_eq!(
target.env.get("TARGET_FOLDER").map(String::as_str),
Some("infra")
);
assert_eq!(
target.env.get("TARGET_TYPE").map(String::as_str),
Some("ssh_key")
);
assert_eq!(
target.env.get("TARGET_HOST").map(String::as_str),
Some("git.example.com")
);
assert_eq!(
target.env.get("TARGET_PORT").map(String::as_str),
Some("22")
);
assert_eq!(
target.env.get("TARGET_USER").map(String::as_str),
Some("deploy")
);
assert_eq!(
target.env.get("TARGET_BASE_URL").map(String::as_str),
Some("https://api.example.com")
);
assert_eq!(
target.env.get("TARGET_API_KEY").map(String::as_str),
Some("ak-123")
);
assert_eq!(
target.env.get("TARGET_TOKEN").map(String::as_str),
Some("tok-456")
);
assert_eq!(
target.env.get("TARGET_SSH_KEY").map(String::as_str),
Some("-----BEGIN KEY-----")
);
}
#[test]
fn exports_sanitized_meta_and_secret_keys() {
let mut snapshot = build_snapshot();
snapshot.metadata.insert(
"private-ip".to_string(),
Value::String("10.0.0.8".to_string()),
);
let secrets = HashMap::from([(
"access key id".to_string(),
Value::String("access-1".to_string()),
)]);
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
assert_eq!(
target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str),
Some("10.0.0.8")
);
assert_eq!(
target
.env
.get("TARGET_SECRET_ACCESS_KEY_ID")
.map(String::as_str),
Some("access-1")
);
}
}

View File

@@ -0,0 +1,168 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct EntrySummary {
pub id: String,
pub folder: String,
#[serde(rename = "title")]
pub name: String,
#[serde(rename = "subtitle")]
pub cipher_type: String,
}
#[derive(Debug, Deserialize)]
pub struct EntryDetail {
pub id: String,
#[serde(rename = "title")]
pub name: String,
pub folder: String,
#[serde(rename = "entry_type")]
pub cipher_type: String,
pub metadata: Vec<DetailField>,
pub secrets: Vec<SecretField>,
}
#[derive(Debug, Deserialize)]
pub struct DetailField {
pub label: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretField {
pub id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub version: i64,
}
#[derive(Debug, Deserialize)]
pub struct SecretValueField {
pub id: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct SecretHistoryItem {
pub history_id: i64,
pub secret_id: String,
pub name: String,
pub secret_type: String,
pub masked_value: String,
pub value: String,
pub version: i64,
pub action: String,
pub created_at: String,
}
pub async fn authorized_get(
state: &AppState,
path: &str,
query: &[(&str, String)],
) -> Result<reqwest::Response> {
state
.client
.get(format!("{}{}", state.session_base, path))
.query(query)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_patch(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.patch(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn authorized_post(
state: &AppState,
path: &str,
body: &Value,
) -> Result<reqwest::Response> {
state
.client
.post(format!("{}{}", state.session_base, path))
.json(body)
.send()
.await
.with_context(|| format!("desktop local vault unavailable: {path}"))?
.error_for_status()
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
}
pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result<EntryDetail> {
authorized_get(state, &format!("/vault/entries/{entry_id}"), &[])
.await?
.json::<EntryDetail>()
.await
.context("failed to decode entry detail")
}
pub async fn fetch_revealed_entry_secrets(
state: &AppState,
entry_id: &str,
) -> Result<Vec<SecretValueField>> {
let detail = fetch_entry_detail(state, entry_id).await?;
let mut secrets = Vec::new();
for secret in detail.secrets {
let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[])
.await?
.json::<SecretValueField>()
.await
.context("failed to decode revealed secret value")?;
secrets.push(item);
}
Ok(secrets)
}
pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value {
let revealed_by_id: HashMap<&str, &SecretValueField> = revealed
.unwrap_or(&[])
.iter()
.map(|secret| (secret.id.as_str(), secret))
.collect();
json!({
"id": detail.id,
"folder": detail.folder,
"name": detail.name,
"type": detail.cipher_type,
"metadata": detail.metadata.iter().map(|field| {
json!({
"label": field.label,
"value": field.value
})
}).collect::<Vec<_>>(),
"secrets": detail.secrets.iter().map(|secret| {
let revealed = revealed_by_id.get(secret.id.as_str());
json!({
"id": secret.id,
"name": secret.name,
"type": secret.secret_type,
"masked_value": secret.masked_value,
"value": revealed.map(|item| item.value.clone()),
"version": secret.version
})
}).collect::<Vec<_>>()
})
}

View File

@@ -0,0 +1,16 @@
[package]
name = "secrets-device-auth"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_device_auth"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
hex.workspace = true
rand.workspace = true
sha2.workspace = true
url.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,27 @@
use anyhow::{Context, Result};
use rand::{Rng, RngExt};
use sha2::{Digest, Sha256};
use url::Url;
pub fn loopback_redirect_uri(port: u16) -> Result<Url> {
Url::parse(&format!("http://127.0.0.1:{port}/oauth/callback"))
.context("failed to build loopback redirect URI")
}
pub fn new_device_fingerprint() -> String {
let mut bytes = [0_u8; 16];
rand::rng().fill(&mut bytes);
hex::encode(bytes)
}
pub fn new_device_login_token() -> String {
let mut bytes = [0_u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn hash_device_login_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}

16
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "secrets-domain"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_domain"
path = "src/lib.rs"
[dependencies]
argon2 = "0.5.3"
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
uuid.workspace = true

68
crates/domain/src/auth.rs Normal file
View File

@@ -0,0 +1,68 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
pub key_salt: Option<Vec<u8>>,
pub key_check: Option<Vec<u8>>,
pub key_params: Option<Value>,
pub key_version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub platform: String,
pub client_version: String,
pub device_fingerprint: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceLoginToken {
pub id: Uuid,
pub device_id: Uuid,
pub token_hash: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoginMethod {
GoogleOauth,
DeviceToken,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoginResult {
Success,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientLoginEvent {
pub id: i64,
pub user_id: Uuid,
pub device_id: Uuid,
pub device_name: String,
pub platform: String,
pub client_version: String,
pub ip_addr: Option<String>,
pub forwarded_ip: Option<String>,
pub login_method: LoginMethod,
pub login_result: LoginResult,
pub created_at: DateTime<Utc>,
}

138
crates/domain/src/cipher.rs Normal file
View File

@@ -0,0 +1,138 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CipherType {
Login,
ApiKey,
SecureNote,
SshKey,
Identity,
Card,
}
impl CipherType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::ApiKey => "api_key",
Self::SecureNote => "secure_note",
Self::SshKey => "ssh_key",
Self::Identity => "identity",
Self::Card => "card",
}
}
pub fn parse(input: &str) -> Self {
match input {
"login" => Self::Login,
"api_key" => Self::ApiKey,
"secure_note" => Self::SecureNote,
"ssh_key" => Self::SshKey,
"identity" => Self::Identity,
"card" => Self::Card,
_ => Self::SecureNote,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CustomField {
pub name: String,
pub value: Value,
#[serde(default)]
pub sensitive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LoginPayload {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub uris: Vec<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub totp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ApiKeyPayload {
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SecureNotePayload {
#[serde(default)]
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SshKeyPayload {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub private_key: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ItemPayload {
Login(LoginPayload),
ApiKey(ApiKeyPayload),
SecureNote(SecureNotePayload),
SshKey(SshKeyPayload),
}
impl Default for ItemPayload {
fn default() -> Self {
Self::SecureNote(SecureNotePayload::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CipherView {
pub id: Uuid,
pub cipher_type: CipherType,
pub name: String,
pub folder: String,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub custom_fields: Vec<CustomField>,
#[serde(default)]
pub deleted_at: Option<DateTime<Utc>>,
pub revision_date: DateTime<Utc>,
pub payload: ItemPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cipher {
pub id: Uuid,
pub user_id: Uuid,
pub object_kind: String,
pub cipher_type: CipherType,
pub revision: i64,
pub cipher_version: i32,
pub ciphertext: Vec<u8>,
pub content_hash: String,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,15 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DomainError {
#[error("resource not found")]
NotFound,
#[error("resource already exists")]
Conflict,
#[error("validation failed: {0}")]
Validation(String),
#[error("authentication failed")]
AuthenticationFailed,
#[error("decryption failed")]
DecryptionFailed,
}

37
crates/domain/src/kdf.rs Normal file
View File

@@ -0,0 +1,37 @@
use argon2::{Algorithm, Argon2, Params, Version};
use serde::{Deserialize, Serialize};
use crate::DomainError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KdfType {
Argon2id,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct KdfConfig {
pub kdf_type: KdfType,
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Default for KdfConfig {
fn default() -> Self {
Self {
kdf_type: KdfType::Argon2id,
memory_kib: 64 * 1024,
iterations: 3,
parallelism: 4,
}
}
}
impl KdfConfig {
pub fn build_argon2(&self) -> Result<Argon2<'static>, DomainError> {
let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(32))
.map_err(|err| DomainError::Validation(err.to_string()))?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
}

19
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
pub mod auth;
pub mod cipher;
pub mod error;
pub mod kdf;
pub mod sync;
pub mod vault_object;
pub use auth::{ClientLoginEvent, Device, DeviceLoginToken, LoginMethod, LoginResult, User};
pub use cipher::{
ApiKeyPayload, Cipher, CipherType, CipherView, CustomField, ItemPayload, LoginPayload,
SecureNotePayload, SshKeyPayload,
};
pub use error::DomainError;
pub use kdf::{KdfConfig, KdfType};
pub use sync::{
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
SyncPushResponse,
};
pub use vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultObjectKind, VaultTombstone};

47
crates/domain/src/sync.rs Normal file
View File

@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultTombstone};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPullRequest {
pub cursor: Option<i64>,
pub limit: Option<i64>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPullResponse {
pub server_revision: i64,
pub next_cursor: i64,
pub has_more: bool,
pub objects: Vec<VaultObjectEnvelope>,
pub tombstones: Vec<VaultTombstone>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPushRequest {
pub changes: Vec<VaultObjectChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncAcceptedChange {
pub change_id: uuid::Uuid,
pub object_id: uuid::Uuid,
pub revision: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncConflict {
pub change_id: uuid::Uuid,
pub object_id: uuid::Uuid,
pub reason: String,
pub server_object: Option<VaultObjectEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SyncPushResponse {
pub server_revision: i64,
pub accepted: Vec<SyncAcceptedChange>,
pub conflicts: Vec<SyncConflict>,
}

View File

@@ -0,0 +1,48 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VaultObjectKind {
Cipher,
}
impl VaultObjectKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Cipher => "cipher",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultObjectEnvelope {
pub object_id: Uuid,
pub object_kind: VaultObjectKind,
pub revision: i64,
pub cipher_version: i32,
pub ciphertext: Vec<u8>,
pub content_hash: String,
pub deleted_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultObjectChange {
pub change_id: Uuid,
pub object_id: Uuid,
pub object_kind: VaultObjectKind,
pub operation: String,
pub base_revision: Option<i64>,
pub cipher_version: Option<i32>,
pub ciphertext: Option<Vec<u8>>,
pub content_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VaultTombstone {
pub object_id: Uuid,
pub revision: i64,
pub deleted_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,15 @@
[package]
name = "secrets-infrastructure-db"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_infrastructure_db"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
dotenvy.workspace = true
sqlx.workspace = true
tracing.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,29 @@
mod migrate;
use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use std::str::FromStr;
pub use migrate::migrate_current_schema;
pub fn load_database_url() -> Result<String> {
std::env::var("SECRETS_DATABASE_URL")
.context("SECRETS_DATABASE_URL is required for current services")
}
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let options =
PgConnectOptions::from_str(database_url).context("failed to parse SECRETS_DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(
std::env::var("SECRETS_DATABASE_POOL_SIZE")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(10),
)
.connect_with(options)
.await
.context("failed to connect to PostgreSQL")?;
Ok(pool)
}

View File

@@ -0,0 +1,130 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA,
key_check BYTEA,
key_params JSONB,
key_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id),
UNIQUE(user_id, provider)
);
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
display_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
device_fingerprint TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
CREATE TABLE IF NOT EXISTS device_login_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_device_login_tokens_device_id ON device_login_tokens(device_id);
CREATE TABLE IF NOT EXISTS auth_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
device_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
ip_addr TEXT,
forwarded_ip TEXT,
login_method VARCHAR(32) NOT NULL,
login_result VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_auth_events_user_id_created_at
ON auth_events(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at
ON auth_events(device_id, created_at DESC);
CREATE TABLE IF NOT EXISTS desktop_login_sessions (
session_id TEXT PRIMARY KEY,
oauth_state TEXT NOT NULL UNIQUE,
pkce_verifier TEXT NOT NULL,
device_name VARCHAR(256) NOT NULL,
platform VARCHAR(64) NOT NULL,
client_version VARCHAR(64) NOT NULL,
device_fingerprint TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
device_token TEXT,
device_token_hash TEXT,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_desktop_login_sessions_status_expires
ON desktop_login_sessions(status, expires_at);
CREATE TABLE IF NOT EXISTS vault_objects (
object_id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
object_kind VARCHAR(32) NOT NULL,
revision BIGINT NOT NULL,
cipher_version INTEGER NOT NULL DEFAULT 1,
ciphertext BYTEA NOT NULL DEFAULT '\x',
content_hash TEXT NOT NULL DEFAULT '',
deleted_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_device UUID REFERENCES devices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_revision
ON vault_objects(user_id, revision ASC);
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_deleted
ON vault_objects(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS vault_object_revisions (
object_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision BIGINT NOT NULL,
cipher_version INTEGER NOT NULL DEFAULT 1,
ciphertext BYTEA NOT NULL DEFAULT '\x',
content_hash TEXT NOT NULL DEFAULT '',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (object_id, revision)
);
CREATE INDEX IF NOT EXISTS idx_vault_object_revisions_user_revision
ON vault_object_revisions(user_id, revision ASC);
"#,
)
.execute(pool)
.await?;
Ok(())
}

57
deploy/.env.example Normal file
View File

@@ -0,0 +1,57 @@
# Secrets v3 环境变量配置
# 复制此文件为 .env 并填写真实值
# ─── 数据库 ───────────────────────────────────────────────────────────
# v3 API 与桌面端都复用这套数据库
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-v3
# 强烈建议生产使用 verify-full至少 verify-ca
SECRETS_DATABASE_SSL_MODE=verify-full
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
# SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
# 当设为 prod/production 时,服务会拒绝弱 TLS 模式prefer/disable/allow/require
SECRETS_ENV=production
# ─── 服务地址 ─────────────────────────────────────────────────────────
SECRETS_API_BIND=127.0.0.1:9415
SECRETS_DAEMON_BIND=127.0.0.1:9515
SECRETS_API_BASE=http://127.0.0.1:9415
SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp
# ─── Google OAuth服务端托管──────────────────────────────────────────
# 官网 DMG 正式分发时Google OAuth 凭据只配置在 API 服务端
SECRETS_PUBLIC_BASE_URL=http://127.0.0.1:9415
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:9415/auth/google/callback
# 可选:如不配置则使用 Google 默认公开端点
# GOOGLE_OAUTH_AUTH_URI=https://accounts.google.com/o/oauth2/v2/auth
# GOOGLE_OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token
# 若仍无法换 token仅提供端口代理、无系统代理可取消注释并改为本机代理地址
# HTTPS_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_api=debug,secrets_desktop_daemon=debug
# ─── 数据库连接池(可选)──────────────────────────────────────────────
# 最大连接数,默认 10
# SECRETS_DATABASE_POOL_SIZE=10
# 获取连接超时秒数,默认 5
# SECRETS_DATABASE_ACQUIRE_TIMEOUT=5
# ─── 限流(可选)──────────────────────────────────────────────────────
# 全局限流速率req/s默认 100
# RATE_LIMIT_GLOBAL_PER_SECOND=100
# 全局限流突发量,默认 200
# RATE_LIMIT_GLOBAL_BURST=200
# 单 IP 限流速率req/s默认 20
# RATE_LIMIT_IP_PER_SECOND=20
# 单 IP 限流突发量,默认 40
# RATE_LIMIT_IP_BURST=40
# ─── 代理信任(可选)─────────────────────────────────────────────────
# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP
# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流
# TRUST_PROXY=1
# 桌面端会在 ~/.secrets-v3/desktop 下持久化 device token 与 device fingerprint

View File

@@ -0,0 +1,92 @@
# PostgreSQL TLS Hardening Runbook
This runbook applies to:
- PostgreSQL server: `47.117.131.22` (`db.refining.ltd`)
- `secrets-mcp` app server: `47.238.146.244` (`secrets.refining.app`)
## 1) Issue certificate for `db.refining.ltd` (Let's Encrypt + Cloudflare DNS-01)
Install `acme.sh` on the PostgreSQL server and use a Cloudflare API token with DNS edit permission for the target zone.
```bash
curl https://get.acme.sh | sh -s email=ops@refining.ltd
export CF_Token="your_cloudflare_dns_token"
export CF_Zone_ID="your_zone_id"
~/.acme.sh/acme.sh --issue --dns dns_cf -d db.refining.ltd --keylength ec-256
```
Install cert/key into a PostgreSQL-readable path:
```bash
sudo mkdir -p /etc/postgresql/tls
sudo ~/.acme.sh/acme.sh --install-cert -d db.refining.ltd --ecc \
--fullchain-file /etc/postgresql/tls/fullchain.pem \
--key-file /etc/postgresql/tls/privkey.pem \
--reloadcmd "systemctl reload postgresql || systemctl restart postgresql"
sudo chown -R postgres:postgres /etc/postgresql/tls
sudo chmod 600 /etc/postgresql/tls/privkey.pem
sudo chmod 644 /etc/postgresql/tls/fullchain.pem
```
## 2) Configure PostgreSQL TLS and access rules
In `postgresql.conf`:
```conf
ssl = on
ssl_cert_file = '/etc/postgresql/tls/fullchain.pem'
ssl_key_file = '/etc/postgresql/tls/privkey.pem'
```
In `pg_hba.conf`, allow app traffic via TLS only (example):
```conf
hostssl secrets-mcp postgres 47.238.146.244/32 scram-sha-256
```
Keep a safe admin path (`local` socket or restricted source CIDR) before removing old plaintext `host` rules.
Reload PostgreSQL:
```bash
sudo systemctl reload postgresql
```
## 3) Verify server-side TLS
```bash
openssl s_client -starttls postgres -connect db.refining.ltd:5432 -servername db.refining.ltd
```
The handshake should succeed and the certificate should match `db.refining.ltd`.
## 4) Update `secrets-mcp` app server env
Use environment values like:
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_ENV=production
```
If you use private CA instead of public CA, also set:
```bash
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
```
Restart `secrets-mcp` after updating env.
## 5) Verify from app server
Run positive and negative checks:
- Positive: app starts, migrations pass, dashboard + MCP API work.
- Negative:
- wrong hostname -> connection fails
- wrong CA file -> connection fails
- disable TLS on DB -> connection fails
This ensures no silent downgrade to weak TLS in production.

View File

@@ -0,0 +1,27 @@
[Unit]
Description=Secrets API Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=secrets
Group=secrets
WorkingDirectory=/opt/secrets
EnvironmentFile=/opt/secrets/.env
ExecStart=/opt/secrets/secrets-api
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=secrets-api
# 安全加固
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/secrets
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.0"
components = ["rustfmt", "clippy"]

View File

@@ -5,19 +5,7 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
version="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-${version}"
echo "==> 当前版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "错误: 已存在 tag ${tag}"
echo "请先 bump Cargo.toml 中的 version再执行 cargo build 同步 Cargo.lock。"
exit 1
fi
echo "==> 未发现重复 tag开始执行检查"
echo "==> 开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked

View File

@@ -0,0 +1 @@
entry_id,secret_name,secret_value
1 entry_id secret_name secret_value

View File

@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Batch re-encrypt secret fields from a CSV file.
CSV format:
entry_id,secret_name,secret_value
019d...,api_key,sk-xxxx
019d...,password,hunter2
The script groups rows by entry_id, then calls `secrets_entry_update` with `secrets_obj`
so the server re-encrypts the provided plaintext values with the current key.
Warnings:
- Keep the CSV outside version control whenever possible.
- Delete the filled CSV after the repair is complete.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import urllib.error
import urllib.request
from collections import OrderedDict
from pathlib import Path
from typing import Any
DEFAULT_USER_AGENT = "Cursor/3.0.12 (darwin arm64)"
REQUIRED_COLUMNS = {"entry_id", "secret_name", "secret_value"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Repair secret ciphertexts by re-submitting plaintext via secrets_entry_update."
)
parser.add_argument(
"--csv",
required=True,
help="Path to CSV file with columns: entry_id,secret_name,secret_value",
)
parser.add_argument(
"--mcp-json",
default=str(Path.home() / ".cursor" / "mcp.json"),
help="Path to mcp.json used to resolve URL and headers",
)
parser.add_argument(
"--server",
default="secrets",
help="MCP server name inside mcp.json (default: secrets)",
)
parser.add_argument("--url", help="Override MCP URL")
parser.add_argument("--auth", help="Override Authorization header value")
parser.add_argument("--encryption-key", help="Override X-Encryption-Key header value")
parser.add_argument(
"--user-agent",
default=DEFAULT_USER_AGENT,
help=f"User-Agent header (default: {DEFAULT_USER_AGENT})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse and print grouped updates without sending requests",
)
return parser.parse_args()
def load_mcp_config(path: str, server_name: str) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
servers = data.get("mcpServers", {})
if server_name not in servers:
raise KeyError(f"Server '{server_name}' not found in {path}")
return servers[server_name]
def resolve_connection_settings(args: argparse.Namespace) -> tuple[str, str, str]:
server = load_mcp_config(args.mcp_json, args.server)
headers = server.get("headers", {})
url = args.url or server.get("url")
auth = args.auth or headers.get("Authorization")
encryption_key = args.encryption_key or headers.get("X-Encryption-Key")
if not url:
raise ValueError("Missing MCP URL. Pass --url or configure it in mcp.json.")
if not auth:
raise ValueError(
"Missing Authorization header. Pass --auth or configure it in mcp.json."
)
if not encryption_key:
raise ValueError(
"Missing X-Encryption-Key. Pass --encryption-key or configure it in mcp.json."
)
return url, auth, encryption_key
def load_updates(csv_path: str) -> OrderedDict[str, OrderedDict[str, str]]:
grouped: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
fieldnames = set(reader.fieldnames or [])
missing = REQUIRED_COLUMNS - fieldnames
if missing:
raise ValueError(
"CSV missing required columns: " + ", ".join(sorted(missing))
)
for line_no, row in enumerate(reader, start=2):
entry_id = (row.get("entry_id") or "").strip()
secret_name = (row.get("secret_name") or "").strip()
secret_value = row.get("secret_value") or ""
if not entry_id and not secret_name and not secret_value:
continue
if not entry_id:
raise ValueError(f"Line {line_no}: entry_id is required")
if not secret_name:
raise ValueError(f"Line {line_no}: secret_name is required")
entry_group = grouped.setdefault(entry_id, OrderedDict())
if secret_name in entry_group:
raise ValueError(
f"Line {line_no}: duplicate secret_name '{secret_name}' for entry_id '{entry_id}'"
)
entry_group[secret_name] = secret_value
if not grouped:
raise ValueError("CSV contains no updates")
return grouped
def post_json(
url: str,
payload: dict[str, Any],
auth: str,
encryption_key: str,
user_agent: str,
session_id: str | None = None,
) -> tuple[int, str | None, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": auth,
"X-Encryption-Key": encryption_key,
"User-Agent": user_agent,
}
if session_id:
headers["mcp-session-id"] = session_id
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return (
resp.status,
resp.headers.get("mcp-session-id") or session_id,
resp.read().decode("utf-8"),
)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return exc.code, session_id, body
def parse_sse_json(body: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for line in body.splitlines():
if line.startswith("data: {"):
items.append(json.loads(line[6:]))
return items
def initialize_session(
url: str, auth: str, encryption_key: str, user_agent: str
) -> str:
status, session_id, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "repair-script", "version": "1.0"},
},
},
auth,
encryption_key,
user_agent,
)
if status != 200 or not session_id:
raise RuntimeError(f"initialize failed: status={status}, body={body[:500]}")
status, _, body = post_json(
url,
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
auth,
encryption_key,
user_agent,
session_id,
)
if status not in (200, 202):
raise RuntimeError(
f"notifications/initialized failed: status={status}, body={body[:500]}"
)
return session_id
def load_entry_index(
url: str, auth: str, encryption_key: str, user_agent: str, session_id: str
) -> dict[str, tuple[str, str]]:
status, _, body = post_json(
url,
{
"jsonrpc": "2.0",
"id": 999_001,
"method": "tools/call",
"params": {
"name": "secrets_entry_find",
"arguments": {
"limit": 1000,
},
},
},
auth,
encryption_key,
user_agent,
session_id,
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_entry_find failed: status={status}, body={body[:500]}"
)
if "error" in last:
raise RuntimeError(f"secrets_entry_find returned error: {last}")
content = last.get("result", {}).get("content", [])
if not content:
raise RuntimeError("secrets_entry_find returned no content")
payload = json.loads(content[0]["text"])
index: dict[str, tuple[str, str]] = {}
for entry in payload.get("entries", []):
entry_id = entry.get("id")
name = entry.get("name")
folder = entry.get("folder", "")
if entry_id and name is not None:
index[entry_id] = (name, folder)
return index
def call_secrets_entry_update(
url: str,
auth: str,
encryption_key: str,
user_agent: str,
session_id: str,
request_id: int,
entry_id: str,
entry_name: str,
entry_folder: str,
secrets_obj: dict[str, str],
) -> dict[str, Any]:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": "tools/call",
"params": {
"name": "secrets_entry_update",
"arguments": {
"id": entry_id,
"name": entry_name,
"folder": entry_folder,
"secrets_obj": secrets_obj,
# Pass the key as an argument too, so repair can still work
# even when a client/proxy mishandles custom headers.
"encryption_key": encryption_key,
},
},
}
status, _, body = post_json(
url, payload, auth, encryption_key, user_agent, session_id
)
items = parse_sse_json(body)
last = items[-1] if items else {"raw": body[:1000]}
if status != 200:
raise RuntimeError(
f"secrets_entry_update failed for {entry_id}: status={status}, body={body[:500]}"
)
return last
def main() -> int:
args = parse_args()
try:
url, auth, encryption_key = resolve_connection_settings(args)
updates = load_updates(args.csv)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"Loaded {len(updates)} entries from {args.csv}")
if args.dry_run:
for entry_id, secrets_obj in updates.items():
print(
json.dumps(
{"id": entry_id, "secrets_obj": secrets_obj},
ensure_ascii=False,
indent=2,
)
)
return 0
try:
session_id = initialize_session(url, auth, encryption_key, args.user_agent)
entry_index = load_entry_index(
url, auth, encryption_key, args.user_agent, session_id
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
success = 0
failures = 0
for request_id, (entry_id, secrets_obj) in enumerate(updates.items(), start=2):
try:
if entry_id not in entry_index:
raise RuntimeError(
f"entry id not found in secrets_entry_find results: {entry_id}"
)
entry_name, entry_folder = entry_index[entry_id]
result = call_secrets_entry_update(
url,
auth,
encryption_key,
args.user_agent,
session_id,
request_id,
entry_id,
entry_name,
entry_folder,
secrets_obj,
)
if "error" in result:
failures += 1
print(
json.dumps(
{"id": entry_id, "status": "error", "result": result},
ensure_ascii=False,
),
file=sys.stderr,
)
else:
success += 1
print(
json.dumps(
{"id": entry_id, "status": "ok", "result": result},
ensure_ascii=False,
)
)
except Exception as exc:
failures += 1
print(f"{entry_id}: ERROR: {exc}", file=sys.stderr)
print(f"Done. success={success} failure={failures}")
return 0 if failures == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,20 +1,17 @@
#!/usr/bin/env bash
#
# 为 refining/secrets 仓库配置 Gitea Actions 所需的 Secrets 和 Variables
# 为 refining/secrets 仓库配置 v3 CI 所需的 Variables
# 参考: .gitea/workflows/secrets.yml
#
# 所需配置:
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - vars.WEBHOOK_URL (可选) 飞书通知
#
# 注意:
# - Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
# - Secret/Variable 的 data/value 字段需传入原始值,不要使用 base64 编码
# - Variables 的 value 字段为原始字符串,不要 base64。
#
# 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
# 2. 或通过环境变量覆盖: GITEA_TOKEN(作为 RELEASE_TOKEN 的值), WEBHOOK_URL
# 3. 或使用 secrets CLI 获取: 需 DATABASE_URL从 refining/service gitea 读取
# 2. 或通过环境变量覆盖: GITEA_TOKEN, WEBHOOK_URL
#
set -e
@@ -23,26 +20,38 @@ OWNER="refining"
REPO="secrets"
# 解析参数
USE_SECRETS_CLI=false
while [[ $# -gt 0 ]]; do
case $1 in
--from-secrets) USE_SECRETS_CLI=true; shift ;;
--from-secrets)
echo "❌ --from-secrets 尚未实现,请使用 ~/.config/gitea/config.env 或环境变量" >&2
exit 1
;;
-h|--help)
echo "用法: $0 [--from-secrets]"
echo "用法: $0"
echo ""
echo " --from-secrets 从 secrets CLI (refining/service gitea) 获取 token 和 webhook_url"
echo " 否则从 ~/.config/gitea/config.env 读取"
echo "从 ~/.config/gitea/config.env 读取,或由环境变量覆盖。"
echo ""
echo "环境变量覆盖:"
echo " GITEA_URL Gitea 实例地址"
echo " GITEA_TOKEN 用于 Release 上传的 PAT (创建 RELEASE_TOKEN secret)"
echo " WEBHOOK_URL 飞书 Webhook URL (创建 variable可选)"
echo "环境变量:"
echo " GITEA_URL Gitea 实例地址(可误带尾部 /api/v1脚本会规范化后拼接"
echo " GITEA_TOKEN Gitea PAT"
echo " WEBHOOK_URL 或 GITEA_WEBHOOK_URL vars.WEBHOOK_URL可选"
exit 0
;;
*) shift ;;
*)
echo "❌ 未知参数: $1" >&2
echo " 使用 $0 --help 查看用法" >&2
exit 1
;;
esac
done
for cmd in curl jq; do
if ! command -v "$cmd" &>/dev/null; then
echo "❌ 未找到命令: $cmd(本脚本依赖 curl 与 jq" >&2
exit 1
fi
done
# 加载配置
load_config() {
local config="$HOME/.config/gitea/config.env"
@@ -52,26 +61,6 @@ load_config() {
fi
}
# 从 secrets CLI 获取 gitea 凭据
fetch_from_secrets() {
if ! command -v secrets &>/dev/null; then
echo "❌ secrets CLI 未找到,请先构建: cargo build --release" >&2
return 1
fi
# 输出 JSON 格式便于解析;需要 --show-secrets
# secrets 当前无 JSON 输出,用简单解析
local out
out=$(secrets search -n refining --kind service -q gitea --show-secrets 2>/dev/null || true)
if [[ -z "$out" ]]; then
echo "❌ 未找到 refining/service gitea 记录" >&2
return 1
fi
# 简化:从 metadata 和 secrets 中提取,实际格式需根据 search 输出调整
# 此处仅作占位,实际解析较复杂;建议用户优先用 config.env
echo "⚠️ --from-secrets 暂不支持自动解析,请使用 config.env 或环境变量" >&2
return 1
}
load_config
# 优先使用环境变量
@@ -86,21 +75,19 @@ if [[ -z "$GITEA_URL" ]]; then
exit 1
fi
# 去掉 URL 尾部斜杠
# 规范为实例根 URL:去尾部斜杠,并去掉重复的 .../api/v1 后缀(避免拼成 .../api/v1/api/v1
GITEA_URL="${GITEA_URL%/}"
# 确保使用 /api/v1 基础路径(若用户只写了根 URL
[[ "$GITEA_URL" != *"/api/v1"* ]] || true
while [[ "$GITEA_URL" == */api/v1 ]]; do
GITEA_URL="${GITEA_URL%/api/v1}"
GITEA_URL="${GITEA_URL%/}"
done
API_BASE="${GITEA_URL}/api/v1"
# 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值)
if [[ -z "$GITEA_TOKEN" ]]; then
if $USE_SECRETS_CLI; then
fetch_from_secrets || exit 1
fi
echo "❌ GITEA_TOKEN 未配置"
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2
echo " Token 需具备 repo 写权限" >&2
exit 1
fi
@@ -109,32 +96,9 @@ echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 1. 创建 Secret: RELEASE_TOKEN
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ RELEASE_TOKEN 已创建/更新"
else
echo " ❌ 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
exit 1
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
echo "1. 创建/更新 Variable: WEBHOOK_URL可选"
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
@@ -163,8 +127,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
echo " ⚠ 失败 (HTTP $http_code),飞书通知将不可用" >&2
fi
else
echo ""
echo "2. 跳过 WEBHOOK_URL未配置 GITEA_WEBHOOK_URL 或 WEBHOOK_URL"
echo " 跳过 WEBHOOK_URL未配置 GITEA_WEBHOOK_URL 或 WEBHOOK_URL"
echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加"
fi
@@ -174,8 +137,7 @@ echo "✓ 配置完成"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Workflow 将使用:"
echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo ""
echo "推送代码触发构建:"
echo " git push origin main"

View File

@@ -1,37 +0,0 @@
use serde_json::Value;
use sqlx::{Postgres, Transaction};
/// Return the current OS user as the audit actor (falls back to empty string).
pub fn current_actor() -> String {
std::env::var("USER").unwrap_or_default()
}
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
action: &str,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let actor = current_actor();
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.bind(&actor)
.execute(&mut **tx)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
}
}

View File

@@ -1,507 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use std::fs;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::output::{OutputMode, print_json};
// ── Key/value parsing helpers (shared with update.rs) ───────────────────────
/// Parse secret / metadata entries into a nested key path and JSON value.
/// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value
/// - `key=@file` → reads the file content as a string
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
// Typed JSON form: key:=<json>
if let Some((key, json_str)) = entry.split_once(":=") {
let val: Value = serde_json::from_str(json_str).map_err(|e| {
anyhow::anyhow!(
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
key,
e
)
})?;
return Ok((parse_key_path(key)?, val));
}
// Plain string form: key=value or key=@file
if let Some((key, raw_val)) = entry.split_once('=') {
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
return Ok((parse_key_path(key)?, Value::String(value)));
}
// Shorthand file form: nested:key@file
if let Some((key, path)) = entry.split_once('@') {
let value = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
return Ok((parse_key_path(key)?, Value::String(value)));
}
anyhow::bail!(
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
entry
)
}
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut map, &path, value)?;
}
Ok(Value::Object(map))
}
pub(crate) fn key_path_to_string(path: &[String]) -> String {
path.join(":")
}
pub(crate) fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
.collect()
}
pub(crate) fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
.collect()
}
pub(crate) fn parse_key_path(key: &str) -> Result<Vec<String>> {
let path: Vec<String> = key
.split(':')
.map(str::trim)
.map(ToOwned::to_owned)
.collect();
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
anyhow::bail!(
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
key
);
}
Ok(path)
}
pub(crate) fn insert_path(
map: &mut Map<String, Value>,
path: &[String],
value: Value,
) -> Result<()> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
map.insert(path[0].clone(), value);
return Ok(());
}
let head = path[0].clone();
let tail = &path[1..];
match map.entry(head.clone()) {
serde_json::map::Entry::Vacant(entry) => {
let mut child = Map::new();
insert_path(&mut child, tail, value)?;
entry.insert(Value::Object(child));
}
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
Value::Object(child) => insert_path(child, tail, value)?,
_ => {
anyhow::bail!(
"Cannot set nested key '{}' because '{}' is already a non-object value",
key_path_to_string(path),
head
);
}
},
}
Ok(())
}
pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
return Ok(map.remove(&path[0]).is_some());
}
let Some(value) = map.get_mut(&path[0]) else {
return Ok(false);
};
let Value::Object(child) = value else {
return Ok(false);
};
let removed = remove_path(child, &path[1..])?;
if child.is_empty() {
map.remove(&path[0]);
}
Ok(removed)
}
// ── field_type inference and value_len ──────────────────────────────────────
/// Infer the field type string from a JSON value.
pub(crate) fn infer_field_type(v: &Value) -> &'static str {
match v {
Value::String(_) => "string",
Value::Number(_) => "number",
Value::Bool(_) => "boolean",
Value::Null => "string",
Value::Array(_) | Value::Object(_) => "json",
}
}
/// Compute the plaintext length of a JSON value (chars for string, serialized length otherwise).
pub(crate) fn compute_value_len(v: &Value) -> i32 {
match v {
Value::String(s) => s.chars().count() as i32,
Value::Null => 0,
other => other.to_string().chars().count() as i32,
}
}
/// Flatten a (potentially nested) JSON object into dot-separated field entries.
/// e.g. `{"credentials": {"type": "ssh", "content": "..."}}` →
/// `[("credentials.type", "ssh"), ("credentials.content", "...")]`
/// Top-level non-object values are emitted directly.
pub(crate) fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> {
match value {
Value::Object(map) => {
let mut out = Vec::new();
for (k, v) in map {
let full_key = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
out.extend(flatten_json_fields(&full_key, v));
}
out
}
other => vec![(prefix.to_string(), other.clone())],
}
}
// ── Add command ──────────────────────────────────────────────────────────────
pub struct AddArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let metadata = build_json(args.meta_entries)?;
let secret_json = build_json(args.secret_entries)?;
tracing::debug!(args.namespace, args.kind, args.name, "upserting entry");
let meta_keys = collect_key_paths(args.meta_entries)?;
let secret_keys = collect_key_paths(args.secret_entries)?;
let mut tx = pool.begin().await?;
// Upsert the entry row (tags + metadata).
let existing: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
// Snapshot the current entry state before overwriting.
if let Some(ref ex) = existing
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
let entry_id: uuid::Uuid = sqlx::query_scalar(
r#"
INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, 1, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id
"#,
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(args.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?;
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
// Snapshot existing secret fields before replacing.
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
id: uuid::Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
for f in &existing_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id,
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "add",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
}
// Delete existing secret fields so we can re-insert the full set.
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
}
// Insert new secret fields.
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let field_type = infer_field_type(field_value);
let value_len = compute_value_len(field_value);
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(entry_id)
.bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
"add",
args.namespace,
args.kind,
args.name,
json!({
"tags": args.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "added",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"tags": args.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
});
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
print_json(&result_json, &args.output)?;
}
_ => {
println!("Added: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.tags.is_empty() {
println!(" tags: {}", args.tags.join(", "));
}
if !args.meta_entries.is_empty() {
println!(" metadata: {}", meta_keys.join(", "));
}
if !args.secret_entries.is_empty() {
println!(" secrets: {}", secret_keys.join(", "));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
build_json, compute_value_len, flatten_json_fields, infer_field_type, key_path_to_string,
parse_kv, remove_path,
};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_file_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt"))
}
#[test]
fn parse_nested_file_shorthand() {
let path = temp_file_path("ssh-key");
fs::write(&path, "line1\nline2\n").expect("should write temp file");
let entry = format!("credentials:content@{}", path.display());
let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand");
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
assert_eq!(value, serde_json::Value::String("line1\nline2\n".into()));
fs::remove_file(path).expect("should remove temp file");
}
#[test]
fn build_nested_json_from_mixed_entries() {
let payload = vec![
"credentials:type=ssh".to_string(),
"credentials:enabled:=true".to_string(),
"username=root".to_string(),
];
let value = build_json(&payload).expect("should build nested json");
assert_eq!(
value,
serde_json::json!({
"credentials": {
"type": "ssh",
"enabled": true
},
"username": "root"
})
);
}
#[test]
fn remove_nested_path_prunes_empty_parents() {
let mut value = serde_json::json!({
"credentials": {
"content": "pem-data"
},
"username": "root"
});
let map = match &mut value {
Value::Object(map) => map,
_ => panic!("expected object"),
};
let removed = remove_path(map, &["credentials".to_string(), "content".to_string()])
.expect("should remove nested field");
assert!(removed);
assert_eq!(value, serde_json::json!({ "username": "root" }));
}
#[test]
fn flatten_json_fields_nested() {
let v = serde_json::json!({
"username": "root",
"credentials": {
"type": "ssh",
"content": "pem-data"
}
});
let mut fields = flatten_json_fields("", &v);
fields.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(fields[0].0, "credentials.content");
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
#[test]
fn infer_field_types() {
assert_eq!(infer_field_type(&Value::String("x".into())), "string");
assert_eq!(infer_field_type(&serde_json::json!(42)), "number");
assert_eq!(infer_field_type(&Value::Bool(true)), "boolean");
assert_eq!(infer_field_type(&serde_json::json!(["a"])), "json");
}
#[test]
fn compute_value_len_string() {
assert_eq!(compute_value_len(&Value::String("root".into())), 4);
assert_eq!(compute_value_len(&Value::Null), 0);
assert_eq!(compute_value_len(&serde_json::json!(1234)), 4);
}
}

View File

@@ -1,55 +0,0 @@
use crate::config::{self, Config, config_path};
use anyhow::Result;
pub async fn run(action: crate::ConfigAction) -> Result<()> {
match action {
crate::ConfigAction::SetDb { url } => {
// Verify connection before writing config
let pool = crate::db::create_pool(&url)
.await
.map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
drop(pool);
println!("Database connection successful.");
let cfg = Config {
database_url: Some(url.clone()),
};
config::save_config(&cfg)?;
println!("Database URL saved to: {}", config_path()?.display());
println!(" {}", mask_password(&url));
}
crate::ConfigAction::Show => {
let cfg = config::load_config()?;
match cfg.database_url {
Some(url) => {
println!("database_url = {}", mask_password(&url));
println!("config file: {}", config_path()?.display());
}
None => {
println!("Database URL not configured.");
println!("Run: secrets config set-db <DATABASE_URL>");
}
}
}
crate::ConfigAction::Path => {
println!("{}", config_path()?.display());
}
}
Ok(())
}
/// Mask the password in a postgres://user:password@host/db URL.
fn mask_password(url: &str) -> String {
if let Some(at_pos) = url.rfind('@')
&& let Some(scheme_end) = url.find("://")
{
let prefix = &url[..scheme_end + 3];
let credentials = &url[scheme_end + 3..at_pos];
let rest = &url[at_pos..];
if let Some(colon_pos) = credentials.find(':') {
let user = &credentials[..colon_pos];
return format!("{}{}:***{}", prefix, user, rest);
}
}
url.to_string()
}

View File

@@ -1,293 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, SecretFieldRow};
use crate::output::{OutputMode, print_json};
pub struct DeleteArgs<'a> {
pub namespace: &'a str,
/// Kind filter. Required when --name is given; optional for bulk deletes.
pub kind: Option<&'a str>,
/// Exact record name. When None, bulk-delete all matching records.
pub name: Option<&'a str>,
/// Preview without writing to the database (bulk mode only).
pub dry_run: bool,
pub output: OutputMode,
}
// ── Internal row type used for bulk queries ────────────────────────────────
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
pub id: Uuid,
pub version: i64,
pub kind: String,
pub name: String,
pub metadata: serde_json::Value,
pub tags: Vec<String>,
}
// ── Entry point ────────────────────────────────────────────────────────────
pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
match args.name {
Some(name) => {
let kind = args
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(pool, args.namespace, kind, name, args.output).await
}
None => delete_bulk(pool, args.namespace, args.kind, args.dry_run, args.output).await,
}
}
// ── Single-record delete (original behaviour) ─────────────────────────────
async fn delete_one(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting entry");
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
tracing::warn!(namespace, kind, name, "entry not found for deletion");
let v = json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Not found: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
return Ok(());
};
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
let v = json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(())
}
// ── Bulk delete by namespace (+ optional kind filter) ─────────────────────
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, ?kind, dry_run, "bulk-deleting entries");
let rows: Vec<FullEntryRow> = if let Some(k) = kind {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 AND kind = $2 \
ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 \
ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
};
if rows.is_empty() {
let v = json!({
"action": "noop",
"namespace": namespace,
"kind": kind,
"deleted": 0,
"dry_run": dry_run
});
match output {
OutputMode::Text => println!(
"No records found in namespace \"{}\"{}.",
namespace,
kind.map(|k| format!(" with kind \"{}\"", k))
.unwrap_or_default()
),
ref mode => print_json(&v, mode)?,
}
return Ok(());
}
if dry_run {
let count = rows.len();
match output {
OutputMode::Text => {
println!(
"dry-run: would delete {} record(s) in namespace \"{}\":",
count, namespace
);
for r in &rows {
println!(" [{}/{}] {}", namespace, r.kind, r.name);
}
}
ref mode => {
let items: Vec<_> = rows
.iter()
.map(|r| json!({"namespace": namespace, "kind": r.kind, "name": r.name}))
.collect();
print_json(
&json!({
"action": "dry_run",
"namespace": namespace,
"kind": kind,
"would_delete": count,
"entries": items
}),
mode,
)?;
}
}
return Ok(());
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
tags: row.tags.clone(),
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
crate::audit::log_tx(
&mut tx,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(json!({"namespace": namespace, "kind": row.kind, "name": row.name}));
tracing::info!(namespace, kind = %row.kind, name = %row.name, "bulk deleted");
}
let count = deleted.len();
match output {
OutputMode::Text => {
for item in &deleted {
println!(
"Deleted: [{}/{}] {}",
item["namespace"].as_str().unwrap_or(""),
item["kind"].as_str().unwrap_or(""),
item["name"].as_str().unwrap_or("")
);
}
println!("Total: {} record(s) deleted.", count);
}
ref mode => print_json(
&json!({
"action": "deleted",
"namespace": namespace,
"kind": kind,
"deleted": count,
"entries": deleted
}),
mode,
)?,
}
Ok(())
}
// ── Shared helper: snapshot history then DELETE ────────────────────────────
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &str,
name: &str,
row: &EntryRow,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
namespace,
kind,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -1,109 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use std::collections::BTreeMap;
use std::io::Write;
use crate::commands::search::{fetch_entries, fetch_secrets_for_entries};
use crate::crypto;
use crate::models::{ExportData, ExportEntry, ExportFormat};
pub struct ExportArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
/// Output file path. None means write to stdout.
pub file: Option<&'a str>,
/// Explicit format override (e.g. from --format flag).
pub format: Option<&'a str>,
/// When true, secrets are omitted and master_key is not used.
pub no_secrets: bool,
}
pub async fn run(pool: &PgPool, args: ExportArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
// Determine output format: --format > file extension > default JSON.
let format = if let Some(fmt_str) = args.format {
ExportFormat::from_str(fmt_str)?
} else if let Some(path) = args.file {
ExportFormat::from_extension(path).unwrap_or(ExportFormat::Json)
} else {
ExportFormat::Json
};
let entries = fetch_entries(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.query,
)
.await?;
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let secrets_map = if !args.no_secrets && !entry_ids.is_empty() {
fetch_secrets_for_entries(pool, &entry_ids).await?
} else {
std::collections::HashMap::new()
};
let key = if !args.no_secrets { master_key } else { None };
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
for entry in &entries {
let secrets = if args.no_secrets {
None
} else {
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
if fields.is_empty() {
Some(BTreeMap::new())
} else {
let mk =
key.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
let mut map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Some(map)
}
};
export_entries.push(ExportEntry {
namespace: entry.namespace.clone(),
kind: entry.kind.clone(),
name: entry.name.clone(),
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,
});
}
let data = ExportData {
version: 1,
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
entries: export_entries,
};
let serialized = format.serialize(&data)?;
if let Some(path) = args.file {
std::fs::write(path, &serialized)?;
println!(
"Exported {} record(s) to {} ({:?})",
data.entries.len(),
path,
format
);
} else {
std::io::stdout().write_all(serialized.as_bytes())?;
// Ensure trailing newline on stdout.
if !serialized.ends_with('\n') {
println!();
}
}
Ok(())
}

View File

@@ -1,78 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use crate::output::{OutputMode, format_local_time, print_json};
pub struct HistoryArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub limit: u32,
pub output: OutputMode,
}
/// List history entries for an entry.
pub async fn run(pool: &PgPool, args: HistoryArgs<'_>) -> Result<()> {
#[derive(FromRow)]
struct HistorySummary {
version: i64,
action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<HistorySummary> = sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT $4",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(args.limit as i64)
.fetch_all(pool)
.await?;
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| {
json!({
"version": r.version,
"action": r.action,
"actor": r.actor,
"created_at": r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
})
.collect();
print_json(&Value::Array(arr), &args.output)?;
}
_ => {
if rows.is_empty() {
println!(
"No history found for [{}/{}] {}.",
args.namespace, args.kind, args.name
);
return Ok(());
}
println!(
"History for [{}/{}] {}:",
args.namespace, args.kind, args.name
);
for r in &rows {
println!(
" v{:<4} {:8} {} {}",
r.version,
r.action,
r.actor,
format_local_time(r.created_at)
);
}
println!(" (use `secrets rollback --to-version <N>` to restore)");
}
}
Ok(())
}

View File

@@ -1,217 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::BTreeMap;
use crate::commands::add::{self, AddArgs};
use crate::models::ExportFormat;
use crate::output::{OutputMode, print_json};
pub struct ImportArgs<'a> {
pub file: &'a str,
/// Overwrite existing records when there is a conflict (upsert).
/// Without this flag, the import aborts on the first conflict.
/// A future `--skip` flag could allow silently skipping conflicts and continuing.
pub force: bool,
/// Check and preview operations without writing to the database.
pub dry_run: bool,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: ImportArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let format = ExportFormat::from_extension(args.file)?;
let content = std::fs::read_to_string(args.file)
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", args.file, e))?;
let data = format.deserialize(&content)?;
if data.version != 1 {
anyhow::bail!(
"Unsupported export version {}. Only version 1 is supported.",
data.version
);
}
let total = data.entries.len();
let mut inserted = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for entry in &data.entries {
// Check if record already exists.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3)",
)
.bind(&entry.namespace)
.bind(&entry.kind)
.bind(&entry.name)
.fetch_one(pool)
.await
.unwrap_or(false);
if exists && !args.force {
let v = serde_json::json!({
"action": "conflict",
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
});
match args.output {
OutputMode::Text => eprintln!(
"[{}/{}/{}] conflict — record already exists (use --force to overwrite)",
entry.namespace, entry.kind, entry.name
),
ref mode => {
// Write conflict notice to stderr so it does not mix with summary JSON.
eprint!(
"{}",
if *mode == OutputMode::Json {
serde_json::to_string_pretty(&v)?
} else {
serde_json::to_string(&v)?
}
);
eprintln!();
}
}
return Err(anyhow::anyhow!(
"Import aborted: conflict on [{}/{}/{}]",
entry.namespace,
entry.kind,
entry.name
));
}
let action = if exists { "upsert" } else { "insert" };
if args.dry_run {
let v = serde_json::json!({
"action": action,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"dry_run": true,
});
match args.output {
OutputMode::Text => println!(
"[dry-run] {} [{}/{}/{}]",
action, entry.namespace, entry.kind, entry.name
),
ref mode => print_json(&v, mode)?,
}
if exists {
skipped += 1;
} else {
inserted += 1;
}
continue;
}
// Build secret_entries: convert BTreeMap<String, Value> to Vec<String> ("key:=json")
let secret_entries = build_secret_entries(entry.secrets.as_ref());
// Build meta_entries from metadata JSON object.
let meta_entries = build_meta_entries(&entry.metadata);
match add::run(
pool,
AddArgs {
namespace: &entry.namespace,
kind: &entry.kind,
name: &entry.name,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
output: OutputMode::Text,
},
master_key,
)
.await
{
Ok(()) => {
let v = serde_json::json!({
"action": action,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
});
match args.output {
OutputMode::Text => println!(
"Imported [{}/{}/{}]",
entry.namespace, entry.kind, entry.name
),
ref mode => print_json(&v, mode)?,
}
inserted += 1;
}
Err(e) => {
eprintln!(
"Error importing [{}/{}/{}]: {}",
entry.namespace, entry.kind, entry.name, e
);
failed += 1;
}
}
}
let summary = serde_json::json!({
"total": total,
"inserted": inserted,
"skipped": skipped,
"failed": failed,
"dry_run": args.dry_run,
});
match args.output {
OutputMode::Text => {
if args.dry_run {
println!(
"\n[dry-run] {} total: {} would insert, {} would skip, {} would fail",
total, inserted, skipped, failed
);
} else {
println!(
"\nImport done: {} total — {} inserted, {} skipped, {} failed",
total, inserted, skipped, failed
);
}
}
ref mode => print_json(&summary, mode)?,
}
if failed > 0 {
anyhow::bail!("{} record(s) failed to import", failed);
}
Ok(())
}
/// Convert metadata JSON object into Vec<String> of "key:=json_value" entries.
fn build_meta_entries(metadata: &Value) -> Vec<String> {
let mut entries = Vec::new();
if let Some(obj) = metadata.as_object() {
for (k, v) in obj {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
/// Convert a BTreeMap<String, Value> (secrets) into Vec<String> of "key:=json_value" entries.
fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
let mut entries = Vec::new();
if let Some(map) = secrets {
for (k, v) in map {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
/// Convert a key/value pair to a CLI-style entry string.
/// Strings use `key=value`; everything else uses `key:=<json>`.
fn value_to_kv_entry(key: &str, value: &Value) -> String {
match value {
Value::String(s) => format!("{}={}", key, s),
other => format!("{}:={}", key, other),
}
}

View File

@@ -1,70 +0,0 @@
use anyhow::{Context, Result};
use rand::RngExt;
use sqlx::PgPool;
use crate::{crypto, db};
const MIN_MASTER_PASSWORD_LEN: usize = 8;
pub async fn run(pool: &PgPool) -> Result<()> {
println!("Initializing secrets master key...");
println!();
// Read password (no echo)
let password = rpassword::prompt_password(format!(
"Enter master password (at least {} characters): ",
MIN_MASTER_PASSWORD_LEN
))
.context("failed to read password")?;
if password.chars().count() < MIN_MASTER_PASSWORD_LEN {
anyhow::bail!(
"Master password must be at least {} characters.",
MIN_MASTER_PASSWORD_LEN
);
}
let confirm = rpassword::prompt_password("Confirm master password: ")
.context("failed to read password confirmation")?;
if password != confirm {
anyhow::bail!("Passwords do not match.");
}
// Get or create Argon2id salt
let salt = match db::load_argon2_salt(pool).await? {
Some(existing) => {
println!("Found existing salt in database (not the first device).");
existing
}
None => {
println!("Generating new Argon2id salt and storing in database...");
let mut salt = vec![0u8; 16];
rand::rng().fill(&mut salt[..]);
db::store_argon2_salt(pool, &salt).await?;
salt
}
};
// Derive master key
print!("Deriving master key (Argon2id, this takes a moment)... ");
let master_key = crypto::derive_master_key(&password, &salt)?;
println!("done.");
// Store in OS Keychain
crypto::store_master_key(&master_key)?;
// Self-test: encrypt and decrypt a canary value
let canary = b"secrets-cli-canary";
let enc = crypto::encrypt(&master_key, canary)?;
let dec = crypto::decrypt(&master_key, &enc)?;
if dec != canary {
anyhow::bail!("Self-test failed: encryption roundtrip mismatch");
}
println!();
println!("Master key stored in OS Keychain.");
println!("You can now use `secrets add` / `secrets search` commands.");
println!();
println!("IMPORTANT: Remember your master password — it is not stored anywhere.");
println!(" On a new device, run `secrets init` with the same password.");
Ok(())
}

View File

@@ -1,12 +0,0 @@
pub mod add;
pub mod config;
pub mod delete;
pub mod export_cmd;
pub mod history;
pub mod import_cmd;
pub mod init;
pub mod rollback;
pub mod run;
pub mod search;
pub mod update;
pub mod upgrade;

View File

@@ -1,266 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::output::{OutputMode, print_json};
pub struct RollbackArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
/// Target entry version to restore. None → restore the most recent history entry.
pub to_version: Option<i64>,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
// ── Find the target entry history snapshot ────────────────────────────────
#[derive(FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = args.to_version {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata \
FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(ver)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata \
FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
args.namespace,
args.kind,
args.name,
args.to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
// ── Find the matching secret field snapshots ──────────────────────────────
#[derive(FromRow)]
struct SecretHistoryRow {
secret_id: Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT secret_id, field_name, field_type, value_len, encrypted, action \
FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 \
ORDER BY field_name",
)
.bind(snap.entry_id)
.bind(snap.version)
.fetch_all(pool)
.await?;
// Validate: try decrypting all encrypted fields before writing anything.
for f in &field_snaps {
if f.action != "delete" && !f.encrypted.is_empty() {
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
anyhow::anyhow!(
"Cannot decrypt snapshot for field '{}': {}",
f.field_name,
e
)
})?;
}
}
let mut tx = pool.begin().await?;
// ── Snapshot the current live state before overwriting ────────────────────
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
}
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ref lr) = live {
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
}
// Snapshot existing secret fields.
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
for f in &live_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: lr.id,
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
}
}
}
// ── Restore entry row ─────────────────────────────────────────────────────
sqlx::query(
"INSERT INTO entries (id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) \
ON CONFLICT (namespace, kind, name) DO UPDATE SET \
tags = EXCLUDED.tags, \
metadata = EXCLUDED.metadata, \
version = entries.version + 1, \
updated_at = NOW()",
)
.bind(snap.entry_id)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.execute(&mut *tx)
.await?;
// ── Restore secret fields ─────────────────────────────────────────────────
// Delete all current fields and re-insert from snapshot
// (only non-deleted fields from the snapshot are restored).
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(snap.entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
// Field was deleted at this snapshot point — don't restore it.
continue;
}
sqlx::query(
"INSERT INTO secrets (id, entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5, $6) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
)
.bind(f.secret_id)
.bind(snap.entry_id)
.bind(&f.field_name)
.bind(&f.field_type)
.bind(f.value_len)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
"rollback",
args.namespace,
args.kind,
args.name,
json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "rolled_back",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"restored_version": snap.version,
});
match args.output {
OutputMode::Text => println!(
"Rolled back: [{}/{}] {} → version {}",
args.namespace, args.kind, args.name, snap.version
),
ref mode => print_json(&result_json, mode)?,
}
Ok(())
}

View File

@@ -1,143 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
use crate::output::OutputMode;
pub struct InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub output: OutputMode,
}
pub struct RunArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub command: &'a [String],
}
/// Fetch entries matching the filter and build a flat env map (metadata + decrypted secrets).
pub async fn collect_env_map(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() {
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run"
);
}
let entries = fetch_entries(pool, namespace, kind, name, tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
for entry in &entries {
let empty = vec![];
let fields = fields_map.get(&entry.id).unwrap_or(&empty);
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?;
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok(map)
}
/// `inject` command: print env vars to stdout.
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
}
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
}
Ok(())
}
/// `run` command: inject secrets into a child process environment and execute.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if args.command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
tracing::debug!(
vars = env_map.len(),
cmd = args.command[0].as_str(),
"injecting secrets into child process"
);
let status = std::process::Command::new(&args.command[0])
.args(&args.command[1..])
.envs(&env_map)
.status()
.map_err(|e| anyhow::anyhow!("Failed to execute '{}': {}", args.command[0], e))?;
if !status.success() {
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
Ok(())
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -1,611 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::PgPool;
use std::collections::HashMap;
use crate::crypto;
use crate::models::{Entry, SecretField};
use crate::output::{OutputMode, format_local_time};
pub struct SearchArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub fields: &'a [String],
pub summary: bool,
pub limit: u32,
pub offset: u32,
pub sort: &'a str,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
validate_safe_search_args(args.fields)?;
let rows = fetch_entries_paged(
pool,
PagedFetchArgs {
namespace: args.namespace,
kind: args.kind,
name: args.name,
tags: args.tags,
query: args.query,
sort: args.sort,
limit: args.limit,
offset: args.offset,
},
)
.await?;
// -f/--field: extract specific metadata field values directly
if !args.fields.is_empty() {
return print_fields(&rows, args.fields);
}
// Fetch secret schemas for all returned entries (no master key needed).
let entry_ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.id).collect();
let schema_map = if !args.summary && !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice)))
.collect();
let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)?
} else {
serde_json::to_string(&arr)?
};
println!("{}", out);
}
OutputMode::Text => {
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
print_text(
row,
args.summary,
schema_map.get(&row.id).map(Vec::as_slice),
)?;
}
println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize {
println!(
" (showing up to {}; use --offset {} to see more)",
args.limit,
args.offset + args.limit
);
}
}
}
Ok(())
}
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.",
field
);
}
Ok(())
}
fn is_secret_field(field: &str) -> bool {
matches!(
field.split_once('.').map(|(section, _)| section),
Some("secret" | "secrets" | "encrypted")
)
}
// ── Entry fetching ────────────────────────────────────────────────────────────
struct PagedFetchArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
query: Option<&'a str>,
sort: &'a str,
limit: u32,
offset: u32,
}
/// A very large limit used when callers need all matching records (export, inject, run).
/// Postgres will stop scanning when this many rows are found; adjust if needed.
pub const FETCH_ALL_LIMIT: u32 = 100_000;
/// Fetch entries matching the given filters (used by search, inject, run).
/// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records.
pub async fn fetch_entries(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
) -> Result<Vec<Entry>> {
fetch_entries_with_limit(pool, namespace, kind, name, tags, query, FETCH_ALL_LIMIT).await
}
/// Like `fetch_entries` but with an explicit limit. Used internally by `search`.
pub(crate) async fn fetch_entries_with_limit(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
limit: u32,
) -> Result<Vec<Entry>> {
fetch_entries_paged(
pool,
PagedFetchArgs {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit,
offset: 0,
},
)
.await
}
async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Entry>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if a.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if a.kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if a.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if !a.tags.is_empty() {
let placeholders: Vec<String> = a
.tags
.iter()
.map(|_| {
let p = format!("${}", idx);
idx += 1;
p
})
.collect();
conditions.push(format!("tags @> ARRAY[{}]", placeholders.join(", ")));
}
if a.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "namespace, kind, name",
};
let sql = format!(
"SELECT * FROM entries {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
idx + 1
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Entry>(&sql);
if let Some(v) = a.namespace {
q = q.bind(v);
}
if let Some(v) = a.kind {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for v in a.tags {
q = q.bind(v.as_str());
}
if let Some(v) = a.query {
q = q.bind(format!(
"%{}%",
v.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
));
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
Ok(q.fetch_all(pool).await?)
}
// ── Secret schema fetching (no master key) ───────────────────────────────────
/// Fetch secret field schemas (field_name, field_type, value_len) for a set of entry ids.
/// Returns a map from entry_id to list of SecretField (encrypted field not used here).
async fn fetch_secret_schemas(
pool: &PgPool,
entry_ids: &[uuid::Uuid],
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
pub async fn fetch_secrets_for_entries(
pool: &PgPool,
entry_ids: &[uuid::Uuid],
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
// ── Display helpers ───────────────────────────────────────────────────────────
fn env_prefix(entry: &Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
}
}
/// Build a flat KEY=VALUE map from metadata only (no master key required).
pub fn build_metadata_env_map(entry: &Entry, prefix: &str) -> HashMap<String, String> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
if let Some(meta) = entry.metadata.as_object() {
for (k, v) in meta {
let key = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
}
}
map
}
/// Build a flat KEY=VALUE map from metadata + decrypted secret fields.
/// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry.
pub async fn build_injected_env_map(
pool: &PgPool,
entry: &Entry,
prefix: &str,
master_key: &[u8; 32],
fields: &[SecretField],
) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = build_metadata_env_map(entry, prefix);
// Decrypt each secret field and add to env map.
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(&decrypted));
}
// Resolve key_ref: merge secrets from the referenced key entry.
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
let key_entries = fetch_entries(
pool,
Some(&entry.namespace),
Some("key"),
Some(key_ref),
&[],
None,
)
.await?;
if let Some(key_entry) = key_entries.first() {
let key_ids = vec![key_entry.id];
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
let empty = vec![];
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
let key_prefix = env_prefix(key_entry, prefix);
for f in key_fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key_var = format!(
"{}_{}",
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key_var, json_value_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, "key_ref target not found");
}
}
Ok(map)
}
fn json_value_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Value {
if summary {
let desc = entry
.metadata
.get("desc")
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return json!({
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"desc": desc,
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
});
}
let secrets_val: Value = match schema {
Some(fields) if !fields.is_empty() => {
let schema_arr: Vec<Value> = fields
.iter()
.map(|f| {
json!({
"field_name": f.field_name,
"field_type": f.field_type,
"value_len": f.value_len,
})
})
.collect();
Value::Array(schema_arr)
}
_ => Value::Array(vec![]),
};
json!({
"id": entry.id,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"metadata": entry.metadata,
"secrets": secrets_val,
"version": entry.version,
"created_at": entry.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> {
println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name);
if summary {
let desc = entry
.metadata
.get("desc")
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("-");
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
println!(" desc: {}", desc);
println!(" updated: {}", format_local_time(entry.updated_at));
} else {
println!(" id: {}", entry.id);
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) {
println!(
" metadata: {}",
serde_json::to_string_pretty(&entry.metadata)?
);
}
match schema {
Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields
.iter()
.map(|f| format!("{}: {}({})", f.field_name, f.field_type, f.value_len))
.collect();
println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)");
}
_ => {}
}
println!(" version: {}", entry.version);
println!(" created: {}", format_local_time(entry.created_at));
}
println!();
Ok(())
}
/// Extract one or more metadata field paths like `metadata.url`.
fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> {
for row in rows {
for field in fields {
let val = extract_field(row, field)?;
println!("{}", val);
}
}
Ok(())
}
fn extract_field(entry: &Entry, field: &str) -> Result<String> {
let (section, key) = field
.split_once('.')
.ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
let obj = match section {
"metadata" | "meta" => &entry.metadata,
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
};
obj.get(key)
.and_then(|v| {
v.as_str()
.map(|s| s.to_string())
.or_else(|| Some(v.to_string()))
})
.ok_or_else(|| {
anyhow::anyhow!(
"Field '{}' not found in record [{}/{}/{}]",
field,
entry.namespace,
entry.kind,
entry.name
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use serde_json::json;
use uuid::Uuid;
fn sample_entry() -> Entry {
Entry {
id: Uuid::nil(),
namespace: "refining".to_string(),
kind: "service".to_string(),
name: "gitea.main".to_string(),
tags: vec!["prod".to_string()],
metadata: json!({"url": "https://code.example.com", "enabled": true}),
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn sample_fields() -> Vec<SecretField> {
let key = [0x42u8; 32];
let enc = crypto::encrypt_json(&key, &json!("abc123")).unwrap();
vec![SecretField {
id: Uuid::nil(),
entry_id: Uuid::nil(),
field_name: "token".to_string(),
field_type: "string".to_string(),
value_len: 6,
encrypted: enc,
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}]
}
#[test]
fn rejects_secret_field_extraction() {
let fields = vec!["secret.token".to_string()];
let err = validate_safe_search_args(&fields).unwrap_err();
assert!(err.to_string().contains("sensitive"));
}
#[test]
fn metadata_env_map_excludes_secret_values() {
let entry = sample_entry();
let map = build_metadata_env_map(&entry, "");
assert_eq!(
map.get("GITEA_MAIN_URL").map(String::as_str),
Some("https://code.example.com")
);
assert_eq!(
map.get("GITEA_MAIN_ENABLED").map(String::as_str),
Some("true")
);
assert!(!map.contains_key("GITEA_MAIN_TOKEN"));
}
#[test]
fn to_json_full_includes_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, false, Some(&fields));
let secrets = v.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0]["field_name"], "token");
assert_eq!(secrets[0]["field_type"], "string");
assert_eq!(secrets[0]["value_len"], 6);
}
#[test]
fn to_json_summary_omits_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, true, Some(&fields));
assert!(v.get("secrets").is_none());
}
}

View File

@@ -1,307 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use super::add::{
collect_field_paths, collect_key_paths, compute_value_len, flatten_json_fields,
infer_field_type, insert_path, parse_key_path, parse_kv, remove_path,
};
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::output::{OutputMode, print_json};
pub struct UpdateArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
pub remove_meta: &'a [String],
pub secret_entries: &'a [String],
pub remove_secrets: &'a [String],
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata \
FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
FOR UPDATE",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
args.namespace,
args.kind,
args.name
)
})?;
// Snapshot current entry state before modifying.
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before update");
}
// ── Merge tags ────────────────────────────────────────────────────────────
let mut tags: Vec<String> = row.tags;
for t in args.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !args.remove_tags.contains(t));
// ── Merge metadata ────────────────────────────────────────────────────────
let mut meta_map: Map<String, Value> = match row.metadata {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in args.meta_entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut meta_map, &path, value)?;
}
for key in args.remove_meta {
let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
}
let metadata = Value::Object(meta_map);
// CAS update of the entry row.
let result = sqlx::query(
"UPDATE entries \
SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3 AND version = $4",
)
.bind(&tags)
.bind(&metadata)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for [{}/{}] {}. Please retry.",
args.namespace,
args.kind,
args.name
);
}
let new_version = row.version + 1;
// ── Update secret fields ──────────────────────────────────────────────────
for entry in args.secret_entries {
let (path, field_value) = parse_kv(entry)?;
// For nested paths (e.g. credentials:type), flatten into dot-separated names
// and treat the sub-value as the individual field to store.
let flat = flatten_json_fields("", &{
let mut m = Map::new();
insert_path(&mut m, &path, field_value)?;
Value::Object(m)
});
for (field_name, fv) in &flat {
let field_type = infer_field_type(fv);
let value_len = compute_value_len(fv);
let encrypted = crypto::encrypt_json(master_key, fv)?;
// Snapshot existing field before replacing.
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let existing_field: Option<ExistingField> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ef) = &existing_field
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
field_type: &ef.field_type,
value_len: ef.value_len,
encrypted: &ef.encrypted,
action: "update",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
}
// ── Remove secret fields ──────────────────────────────────────────────────
for key in args.remove_secrets {
let path = parse_key_path(key)?;
// Dot-join the path to match flattened field_name storage.
let field_name = path.join(".");
// Snapshot before delete.
#[derive(sqlx::FromRow)]
struct FieldToDelete {
id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(&field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(f) = field {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
let meta_keys = collect_key_paths(args.meta_entries)?;
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
let secret_keys = collect_key_paths(args.secret_entries)?;
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
crate::audit::log_tx(
&mut tx,
"update",
args.namespace,
args.kind,
args.name,
json!({
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "updated",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
});
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
print_json(&result_json, &args.output)?;
}
_ => {
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.add_tags.is_empty() {
println!(" +tags: {}", args.add_tags.join(", "));
}
if !args.remove_tags.is_empty() {
println!(" -tags: {}", args.remove_tags.join(", "));
}
if !args.meta_entries.is_empty() {
println!(" +metadata: {}", meta_keys.join(", "));
}
if !args.remove_meta.is_empty() {
println!(" -metadata: {}", remove_meta_keys.join(", "));
}
if !args.secret_entries.is_empty() {
println!(" +secrets: {}", secret_keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", remove_secret_keys.join(", "));
}
}
}
Ok(())
}

View File

@@ -1,411 +0,0 @@
use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write};
use std::time::Duration;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build-time config via `option_env!("SECRETS_UPGRADE_URL")`. Set during `cargo build`, e.g.:
/// SECRETS_UPGRADE_URL=https://... cargo build --release
const BUILD_UPGRADE_URL: Option<&'static str> = option_env!("SECRETS_UPGRADE_URL");
fn upgrade_api_url() -> Result<String> {
if let Some(url) = BUILD_UPGRADE_URL.filter(|s| !s.trim().is_empty()) {
return Ok(url.to_string());
}
let url = std::env::var("SECRETS_UPGRADE_URL").context(
"SECRETS_UPGRADE_URL is not set at build or runtime. Set it when building: \
SECRETS_UPGRADE_URL=https://... cargo build, or export before running secrets upgrade.",
)?;
if url.trim().is_empty() {
anyhow::bail!("SECRETS_UPGRADE_URL is empty.");
}
Ok(url)
}
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn available_assets(assets: &[Asset]) -> String {
assets
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn release_asset_name(tag_name: &str, suffix: &str) -> String {
format!("secrets-{tag_name}-{suffix}")
}
fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> {
assets.iter().find(|a| a.name == name).with_context(|| {
format!(
"no matching release asset found: {name}\navailable: {}",
available_assets(assets)
)
})
}
/// Detect the asset suffix for the current platform/arch at compile time.
fn platform_asset_suffix() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
Ok("x86_64-linux-musl.tar.gz")
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
Ok("aarch64-macos.tar.gz")
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
Ok("x86_64-macos.tar.gz")
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
Ok("x86_64-windows.zip")
}
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
bail!(
"Unsupported platform: {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
)
}
/// Strip the "secrets-" prefix from the tag and parse as semver.
fn parse_tag_version(tag: &str) -> Result<semver::Version> {
let ver_str = tag
.strip_prefix("secrets-")
.with_context(|| format!("unexpected tag format: {tag}"))?;
semver::Version::parse(ver_str)
.with_context(|| format!("failed to parse version from tag: {tag}"))
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
format!("{digest:x}")
}
fn verify_checksum(asset_name: &str, archive: &[u8], checksum_contents: &str) -> Result<String> {
let expected_checksum = parse_checksum_file(checksum_contents)?;
let actual_checksum = sha256_hex(archive);
if actual_checksum != expected_checksum {
bail!(
"checksum verification failed for {}: expected {}, got {}",
asset_name,
expected_checksum,
actual_checksum
);
}
Ok(actual_checksum)
}
fn parse_checksum_file(contents: &str) -> Result<String> {
let checksum = contents
.split_whitespace()
.next()
.context("checksum file is empty")?
.trim()
.to_ascii_lowercase();
if checksum.len() != 64 || !checksum.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("invalid SHA-256 checksum format")
}
Ok(checksum)
}
async fn download_bytes(client: &reqwest::Client, url: &str, context: &str) -> Result<Vec<u8>> {
Ok(client
.get(url)
.send()
.await
.with_context(|| format!("{context}: request failed"))?
.error_for_status()
.with_context(|| format!("{context}: server returned an error"))?
.bytes()
.await
.with_context(|| format!("{context}: failed to read response body"))?
.to_vec())
}
/// Extract the binary from a tar.gz archive (first file whose name == "secrets").
fn extract_from_targz(bytes: &[u8]) -> Result<Vec<u8>> {
let gz = GzDecoder::new(Cursor::new(bytes));
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().context("failed to read tar entries")? {
let mut entry = entry.context("bad tar entry")?;
let path = entry.path().context("bad tar entry path")?.into_owned();
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if fname == "secrets" || fname == "secrets.exe" {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).context("read tar entry")?;
return Ok(buf);
}
}
bail!("binary not found inside tar.gz archive")
}
/// Extract the binary from a zip archive (first file whose name matches).
#[cfg(target_os = "windows")]
fn extract_from_zip(bytes: &[u8]) -> Result<Vec<u8>> {
let reader = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).context("failed to open zip archive")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("bad zip entry")?;
let fname = file.name().to_owned();
if fname.ends_with("secrets.exe") || fname.ends_with("secrets") {
let mut buf = Vec::new();
file.read_to_end(&mut buf).context("read zip entry")?;
return Ok(buf);
}
}
bail!("binary not found inside zip archive")
}
pub async fn run(check_only: bool) -> Result<()> {
let current = semver::Version::parse(CURRENT_VERSION).context("invalid current version")?;
println!("Current version: v{current}");
println!("Checking for updates...");
let client = reqwest::Client::builder()
.user_agent(format!("secrets-cli/{CURRENT_VERSION}"))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
let api_url = upgrade_api_url()?;
let release: Release = client
.get(&api_url)
.send()
.await
.context("failed to fetch release info")?
.error_for_status()
.context("release API returned an error")?
.json()
.await
.context("failed to parse release JSON")?;
let latest = parse_tag_version(&release.tag_name)?;
if latest <= current {
println!("Already up to date (v{current})");
return Ok(());
}
println!("New version available: v{latest}");
if check_only {
println!("Run `secrets upgrade` to update.");
return Ok(());
}
let suffix = platform_asset_suffix()?;
let asset_name = release_asset_name(&release.tag_name, suffix);
let asset = find_asset_by_name(&release.assets, &asset_name)?;
let checksum_name = format!("{}.sha256", asset.name);
let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?;
println!("Downloading {}...", asset.name);
let archive = download_bytes(&client, &asset.browser_download_url, "archive download").await?;
let checksum_contents = download_bytes(
&client,
&checksum_asset.browser_download_url,
"checksum download",
)
.await?;
let actual_checksum = verify_checksum(
&asset.name,
&archive,
std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?,
)?;
println!("Verified SHA-256: {actual_checksum}");
println!("Extracting...");
let binary = if suffix.ends_with(".tar.gz") {
extract_from_targz(&archive)?
} else {
#[cfg(target_os = "windows")]
{
extract_from_zip(&archive)?
}
#[cfg(not(target_os = "windows"))]
bail!("zip extraction is only supported on Windows")
};
// Write to a temporary file, set executable permission, then atomically replace.
let mut tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
tmp.write_all(&binary)
.context("failed to write temp binary")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(tmp.path(), perms).context("failed to chmod temp binary")?;
}
self_replace::self_replace(tmp.path()).context("failed to replace current binary")?;
println!("Updated: v{current} → v{latest}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
#[test]
fn parse_tag_version_accepts_release_tag() {
let version = parse_tag_version("secrets-0.6.1").expect("version should parse");
assert_eq!(version, semver::Version::new(0, 6, 1));
}
#[test]
fn parse_tag_version_rejects_invalid_tag() {
let err = parse_tag_version("v0.6.1").expect_err("tag should be rejected");
assert!(err.to_string().contains("unexpected tag format"));
}
#[test]
fn parse_checksum_file_accepts_sha256sum_format() {
let checksum = parse_checksum_file(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz",
)
.expect("checksum should parse");
assert_eq!(
checksum,
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
);
}
#[test]
fn parse_checksum_file_rejects_invalid_checksum() {
let err = parse_checksum_file("not-a-sha256").expect_err("checksum should be rejected");
assert!(err.to_string().contains("invalid SHA-256 checksum format"));
}
#[test]
fn release_asset_name_matches_release_tag() {
assert_eq!(
release_asset_name("secrets-0.7.0", "x86_64-linux-musl.tar.gz"),
"secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz"
);
}
#[test]
fn find_asset_by_name_rejects_stale_platform_match() {
let assets = vec![
Asset {
name: "secrets-secrets-0.6.9-x86_64-linux-musl.tar.gz".into(),
browser_download_url: "https://example.invalid/old".into(),
},
Asset {
name: "secrets-secrets-0.7.0-aarch64-macos.tar.gz".into(),
browser_download_url: "https://example.invalid/other".into(),
},
];
let err = find_asset_by_name(&assets, "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz")
.expect_err("stale asset should not match");
assert!(err.to_string().contains("no matching release asset found"));
}
#[test]
fn sha256_hex_matches_known_value() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_checksum_rejects_mismatch() {
let err = verify_checksum(
"secrets.tar.gz",
b"abc",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz",
)
.expect_err("checksum mismatch should fail");
assert!(err.to_string().contains("checksum verification failed"));
}
#[test]
fn extract_from_targz_reads_binary() {
let payload = b"fake-secrets-binary";
let archive = make_test_targz("secrets", payload);
let extracted = extract_from_targz(&archive).expect("binary should extract");
assert_eq!(extracted, payload);
}
fn make_test_targz(name: &str, payload: &[u8]) -> Vec<u8> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_mode(0o755);
header.set_size(payload.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, name, payload)
.expect("append tar entry");
let encoder = builder.into_inner().expect("finish tar builder");
encoder.finish().expect("finish gzip")
}
#[cfg(target_os = "windows")]
#[test]
fn extract_from_zip_reads_binary() {
use zip::write::SimpleFileOptions;
let cursor = Cursor::new(Vec::<u8>::new());
let mut writer = zip::ZipWriter::new(cursor);
writer
.start_file("secrets.exe", SimpleFileOptions::default())
.expect("start zip file");
writer
.write_all(b"fake-secrets-binary")
.expect("write zip payload");
let bytes = writer.finish().expect("finish zip").into_inner();
let extracted = extract_from_zip(&bytes).expect("binary should extract");
assert_eq!(extracted, b"fake-secrets-binary");
}
}

View File

@@ -1,77 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub database_url: Option<String>,
}
pub fn config_dir() -> Result<PathBuf> {
let dir = dirs::config_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.context(
"Cannot determine config directory: \
neither XDG_CONFIG_HOME nor HOME is set",
)?
.join("secrets");
Ok(dir)
}
pub fn config_path() -> Result<PathBuf> {
Ok(config_dir()?.join("config.toml"))
}
pub fn load_config() -> Result<Config> {
let path = config_path()?;
if !path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn save_config(config: &Config) -> Result<()> {
let dir = config_dir()?;
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create config dir: {}", dir.display()))?;
let path = dir.join("config.toml");
let content = toml::to_string_pretty(config).context("failed to serialize config")?;
fs::write(&path, &content)
.with_context(|| format!("failed to write config file: {}", path.display()))?;
// Set file permissions to 0600 (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&path, perms)
.with_context(|| format!("failed to set file permissions: {}", path.display()))?;
}
Ok(())
}
/// Resolve database URL by priority:
/// 1. --db-url CLI flag (if non-empty)
/// 2. database_url in ~/.config/secrets/config.toml
/// 3. Error with setup instructions
pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
if !cli_db_url.is_empty() {
return Ok(cli_db_url.to_string());
}
let config = load_config()?;
if let Some(url) = config.database_url
&& !url.is_empty()
{
return Ok(url);
}
anyhow::bail!("Database not configured. Run:\n\n secrets config set-db <DATABASE_URL>\n")
}

View File

@@ -1,195 +0,0 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use anyhow::{Context, Result, bail};
use argon2::{Argon2, Params, Version};
use serde_json::Value;
const KEYRING_SERVICE: &str = "secrets-cli";
const KEYRING_USER: &str = "master-key";
const NONCE_LEN: usize = 12;
// Argon2id parameters — OWASP recommended (m=64 MiB, t=3 iterations, p=4 threads, key=32 B)
const ARGON2_M_COST: u32 = 65_536;
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 4;
const ARGON2_KEY_LEN: usize = 32;
// ─── Argon2id key derivation ─────────────────────────────────────────────────
/// Derive a 32-byte Master Key from a password and salt using Argon2id.
/// Parameters: m=65536 KiB (64 MB), t=3, p=4 — OWASP recommended.
pub fn derive_master_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = Params::new(
ARGON2_M_COST,
ARGON2_T_COST,
ARGON2_P_COST,
Some(ARGON2_KEY_LEN),
)
.context("invalid Argon2id params")?;
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
Ok(key)
}
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
/// Encrypt plaintext bytes with AES-256-GCM.
/// Returns `nonce (12 B) || ciphertext+tag`.
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < NONCE_LEN {
bail!(
"encrypted data too short ({}B); possibly corrupted",
data.len()
);
}
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
}
// ─── JSON helpers ─────────────────────────────────────────────────────────────
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
encrypt(master_key, &bytes)
}
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
let bytes = decrypt(master_key, data)?;
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
}
// ─── OS Keychain ──────────────────────────────────────────────────────────────
/// Load the Master Key from the OS Keychain.
/// Returns an error with a helpful message if it hasn't been initialized.
pub fn load_master_key() -> Result<[u8; 32]> {
let entry =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
let hex = entry.get_password().map_err(|_| {
anyhow::anyhow!("Master key not found in keychain. Run `secrets init` first.")
})?;
let bytes = hex::decode_hex(&hex)?;
if bytes.len() != 32 {
bail!(
"stored master key has unexpected length {}; re-run `secrets init`",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
/// Store the Master Key in the OS Keychain (overwrites any existing value).
pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
let entry =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
let hex = hex::encode_hex(key);
entry
.set_password(&hex)
.map_err(|e| anyhow::anyhow!("keychain write failed: {}", e))?;
Ok(())
}
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
mod hex {
use anyhow::{Result, bail};
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encrypt_decrypt() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc = encrypt(&key, plaintext).unwrap();
let dec = decrypt(&key, &enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn encrypt_produces_different_ciphertexts() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
// Different nonces → different ciphertexts
assert_ne!(enc1, enc2);
}
#[test]
fn wrong_key_fails_decryption() {
let key1 = [0x42u8; 32];
let key2 = [0x43u8; 32];
let enc = encrypt(&key1, b"secret").unwrap();
assert!(decrypt(&key2, &enc).is_err());
}
#[test]
fn json_roundtrip() {
let key = [0x42u8; 32];
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
let enc = encrypt_json(&key, &value).unwrap();
let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value);
}
#[test]
fn derive_master_key_deterministic() {
let salt = b"fixed_test_salt_";
let k1 = derive_master_key("password", salt).unwrap();
let k2 = derive_master_key("password", salt).unwrap();
assert_eq!(k1, k2);
}
#[test]
fn derive_master_key_different_passwords() {
let salt = b"fixed_test_salt_";
let k1 = derive_master_key("password1", salt).unwrap();
let k2 = derive_master_key("password2", salt).unwrap();
assert_ne!(k1, k2);
}
}

223
src/db.rs
View File

@@ -1,223 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use crate::audit::current_actor;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.await?;
tracing::debug!("database connection established");
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
sqlx::raw_sql(
r#"
-- ── entries: top-level entities (server, service, key, …) ──────────────
CREATE TABLE IF NOT EXISTS entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
);
CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace);
CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
-- ── secrets: one row per encrypted field, plaintext schema metadata ────
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
);
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
-- ── kv_config: global key-value store (Argon2id salt, etc.) ────────────
CREATE TABLE IF NOT EXISTS kv_config (
key TEXT PRIMARY KEY,
value BYTEA NOT NULL
);
-- ── audit_log: append-only operation log ────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
-- ── entries_history: entry-level snapshot (tags + metadata) ─────────────
CREATE TABLE IF NOT EXISTS entries_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id
ON entries_history(entry_id, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_ns_kind_name
ON entries_history(namespace, kind, name, version DESC);
-- ── secrets_history: field-level snapshot ───────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL,
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id
ON secrets_history(entry_id, entry_version DESC);
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON secrets_history(secret_id);
"#,
)
.execute(pool)
.await?;
tracing::debug!("migrations complete");
Ok(())
}
// ── Entry-level history snapshot ────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub version: i64,
pub action: &'a str,
pub tags: &'a [String],
pub metadata: &'a Value,
}
/// Snapshot an entry row into `entries_history` before a write operation.
pub async fn snapshot_entry_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: EntrySnapshotParams<'_>,
) -> Result<()> {
let actor = current_actor();
sqlx::query(
"INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.namespace)
.bind(p.kind)
.bind(p.name)
.bind(p.version)
.bind(p.action)
.bind(p.tags)
.bind(p.metadata)
.bind(&actor)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── Secret field-level history snapshot ─────────────────────────────────────
pub struct SecretSnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub secret_id: uuid::Uuid,
pub entry_version: i64,
pub field_name: &'a str,
pub field_type: &'a str,
pub value_len: i32,
pub encrypted: &'a [u8],
pub action: &'a str,
}
/// Snapshot a single secret field into `secrets_history`.
pub async fn snapshot_secret_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SecretSnapshotParams<'_>,
) -> Result<()> {
let actor = current_actor();
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, field_type, value_len, encrypted, action, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.field_type)
.bind(p.value_len)
.bind(p.encrypted)
.bind(p.action)
.bind(&actor)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── Argon2 salt helpers ──────────────────────────────────────────────────────
/// Load the Argon2id salt from the database.
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {
let row: Option<(Vec<u8>,)> =
sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'")
.fetch_optional(pool)
.await?;
Ok(row.map(|(v,)| v))
}
/// Store the Argon2id salt in the database (only called once on first device init).
pub async fn store_argon2_salt(pool: &PgPool, salt: &[u8]) -> Result<()> {
sqlx::query(
"INSERT INTO kv_config (key, value) VALUES ('argon2_salt', $1) \
ON CONFLICT (key) DO NOTHING",
)
.bind(salt)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,863 +0,0 @@
mod audit;
mod commands;
mod config;
mod crypto;
mod db;
mod models;
mod output;
use anyhow::Result;
/// Load .env from current or parent directories (best-effort, no error if missing).
fn load_dotenv() {
let _ = dotenvy::dotenv();
}
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use output::resolve_output_mode;
#[derive(Parser)]
#[command(
name = "secrets",
version,
about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents",
after_help = "QUICK START:
# 1. Configure database (once per device)
secrets config set-db \"postgres://postgres:<password>@<host>:<port>/secrets\"
# 2. Initialize master key (once per device)
secrets init
# Discover what namespaces / kinds exist
secrets search --summary --limit 20
# Precise lookup (JSON output for easy parsing)
secrets search -n refining --kind service --name gitea -o json
# Extract a single metadata field directly
secrets search -n refining --kind service --name gitea -f metadata.url
# Pipe-friendly (non-TTY defaults to json-compact automatically)
secrets search -n refining --kind service | jq '.[].name'
# Inject secrets into environment variables when you really need them
secrets inject -n refining --kind service --name gitea"
)]
struct Cli {
/// Database URL, overrides saved config (one-time override)
#[arg(long, global = true, default_value = "")]
db_url: String,
/// Enable verbose debug output
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize master key on this device (run once per device).
///
/// Prompts for a master password, derives a key with Argon2id, and stores
/// it in the OS Keychain. Use the same password on every device.
///
/// NOTE: Run `secrets config set-db <URL>` first if database is not configured.
#[command(after_help = "PREREQUISITE:
Database must be configured first. Run: secrets config set-db <DATABASE_URL>
EXAMPLES:
# First device: generates a new Argon2id salt and stores master key
secrets init
# Subsequent devices: reuses existing salt from the database
secrets init")]
Init,
/// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets.
#[command(after_help = "EXAMPLES:
# Add a server
secrets add -n refining --kind server --name my-server \\
--tag aliyun --tag shanghai \\
-m ip=10.0.0.1 -m desc=\"Example ECS\" \\
-s username=root -s ssh_key=@./keys/server.pem
# Add a service credential
secrets add -n refining --kind service --name gitea \\
--tag gitea \\
-m url=https://code.example.com -m default_org=myorg \\
-s token=<token>
# Add typed JSON metadata
secrets add -n refining --kind service --name gitea \\
-m port:=3000 \\
-m enabled:=true \\
-m domains:='[\"code.example.com\",\"git.example.com\"]' \\
-m tls:='{\"enabled\":true,\"redirect_http\":true}'
# Add with token read from a file
secrets add -n ricnsmart --kind service --name mqtt \\
-m host=mqtt.example.com -m port=1883 \\
-s password=@./mqtt_password.txt
# Add typed JSON secrets
secrets add -n refining --kind service --name deploy-bot \\
-s enabled:=true \\
-s retry_count:=3 \\
-s scopes:='[\"repo\",\"workflow\"]' \\
-s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}'
# Write a multiline file into a nested secret field
secrets add -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem")]
Add {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: String,
/// Kind of record: server, service, key, ...
#[arg(long)]
kind: String,
/// Human-readable unique name, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: String,
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Search / read records. This is the primary read command for AI agents.
///
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
/// summary view (--summary), pagination (--limit / --offset), and structured
/// output (-o json / json-compact). When stdout is not a TTY, output
/// defaults to json-compact automatically.
#[command(after_help = "EXAMPLES:
# Discover all records (summary, safe default limit)
secrets search --summary --limit 20
# Filter by namespace and kind
secrets search -n refining --kind service
# Exact lookup — returns 0 or 1 record
secrets search -n refining --kind service --name gitea
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
secrets search -q mqtt
# Extract a single metadata field value
secrets search -n refining --kind service --name gitea -f metadata.url
# Multiple fields at once
secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org
# Inject decrypted secrets only when needed
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets
secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10
# Sort by most recently updated
secrets search --sort updated --limit 5 --summary
# Non-TTY / pipe: output is json-compact by default
secrets search -n refining --kind service | jq '.[].name'")]
Search {
/// Filter by namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: Option<String>,
/// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection)
#[arg(long)]
tag: Vec<String>,
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
#[arg(short, long)]
query: Option<String>,
/// Extract metadata field value(s) directly: metadata.<key> (repeatable)
#[arg(short = 'f', long = "field")]
fields: Vec<String>,
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
#[arg(long)]
summary: bool,
/// Maximum number of records to return [default: 50]
#[arg(long, default_value = "50")]
limit: u32,
/// Skip this many records (for pagination)
#[arg(long, default_value = "0")]
offset: u32,
/// Sort order: name (default), updated, created
#[arg(long, default_value = "name")]
sort: String,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Delete one record precisely, or bulk-delete by namespace.
///
/// With --name: deletes exactly that record (--kind also required).
/// Without --name: bulk-deletes all records matching namespace + optional --kind.
/// Use --dry-run to preview bulk deletes before committing.
#[command(after_help = "EXAMPLES:
# Delete a single record (exact match)
secrets delete -n refining --kind service --name legacy-mqtt
# Preview what a bulk delete would remove (no writes)
secrets delete -n refining --dry-run
# Bulk-delete all records in a namespace
secrets delete -n ricnsmart
# Bulk-delete only server records in a namespace
secrets delete -n ricnsmart --kind server
# JSON output
secrets delete -n refining --kind service -o json")]
Delete {
/// Namespace, e.g. refining
#[arg(short, long)]
namespace: String,
/// Kind filter, e.g. server, service (required with --name; optional for bulk)
#[arg(long)]
kind: Option<String>,
/// Exact name of the record to delete (omit for bulk delete)
#[arg(long)]
name: Option<String>,
/// Preview what would be deleted without making any changes (bulk mode only)
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Incrementally update an existing record (merge semantics; record must exist).
///
/// Only the fields you pass are changed — everything else is preserved.
/// Use --add-tag / --remove-tag to modify tags without touching other fields.
#[command(after_help = "EXAMPLES:
# Update a single metadata field (all other fields unchanged)
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
# Rotate a secret token
secrets update -n refining --kind service --name gitea -s token=<new-token>
# Update typed JSON metadata
secrets update -n refining --kind service --name gitea \\
-m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\
-m runtime:max_open_conns:=20
# Add a tag and rotate password at the same time
secrets update -n refining --kind service --name gitea \\
--add-tag production -s token=<new-token>
# Remove a deprecated metadata field and a stale secret key
secrets update -n refining --kind service --name mqtt \\
--remove-meta old_port --remove-secret old_password
# Remove a nested field
secrets update -n refining --kind server --name my-server \\
--remove-secret credentials:content
# Remove a tag
secrets update -n refining --kind service --name gitea --remove-tag staging
# Update a nested secret field from a file
secrets update -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem
# Update nested typed JSON fields
secrets update -n refining --kind service --name deploy-bot \\
-s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\
-s auth:retry:=5")]
Update {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: String,
/// Kind of record: server, service, key, ...
#[arg(long)]
kind: String,
/// Human-readable unique name
#[arg(long)]
name: String,
/// Add a tag (repeatable; does not affect existing tags)
#[arg(long = "add-tag")]
add_tags: Vec<String>,
/// Remove a tag (repeatable)
#[arg(long = "remove-tag")]
remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
#[arg(long = "remove-meta")]
remove_meta: Vec<String>,
/// Set or overwrite a secret field: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Delete a secret field by key or nested path, e.g. old_password or credentials:content
#[arg(long = "remove-secret")]
remove_secrets: Vec<String>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Manage CLI configuration (database connection, etc.)
#[command(after_help = "EXAMPLES:
# Configure the database URL (run once per device; persisted to config file)
secrets config set-db \"postgres://postgres:<password>@<host>:<port>/secrets\"
# Show current config (password is masked)
secrets config show
# Print path to the config file
secrets config path")]
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Show the change history for a record.
#[command(after_help = "EXAMPLES:
# Show last 20 versions for a service record
secrets history -n refining --kind service --name gitea
# Show last 5 versions
secrets history -n refining --kind service --name gitea --limit 5")]
History {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Number of history entries to show [default: 20]
#[arg(long, default_value = "20")]
limit: u32,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Roll back a record to a previous version.
#[command(after_help = "EXAMPLES:
# Roll back to the most recent snapshot (undo last change)
secrets rollback -n refining --kind service --name gitea
# Roll back to a specific version number
secrets rollback -n refining --kind service --name gitea --to-version 3")]
Rollback {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Target version to restore. Omit to restore the most recent snapshot.
#[arg(long)]
to_version: Option<i64>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Print secrets as environment variables (stdout only, nothing persisted).
///
/// Outputs KEY=VALUE pairs for all matched records. Safe to pipe or eval.
#[command(after_help = "EXAMPLES:
# Print env vars for a single service
secrets inject -n refining --kind service --name gitea
# With a custom prefix
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON output (all vars as a JSON object)
secrets inject -n refining --kind service --name gitea -o json
# Eval into current shell (use with caution)
eval $(secrets inject -n refining --kind service --name gitea)")]
Inject {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Output format: text/KEY=VALUE (default), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Run a command with secrets injected as environment variables.
///
/// Secrets are available only to the child process; the current shell
/// environment is not modified. The process exit code is propagated.
#[command(after_help = "EXAMPLES:
# Run a script with a single service's secrets injected
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# Run with a tag filter (all matched records merged)
secrets run --tag production -- env | grep GITEA
# With prefix
secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv")]
Run {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Command and arguments to execute with injected environment
#[arg(last = true, required = true)]
command: Vec<String>,
},
/// Check for a newer version and update the binary in-place.
///
/// Downloads the latest release and replaces the current binary. No database connection or master key required.
/// Release URL defaults to the upstream server; override via SECRETS_UPGRADE_URL for self-hosted or fork.
#[command(after_help = "EXAMPLES:
# Check for updates only (no download)
secrets upgrade --check
# Download and install the latest version
secrets upgrade")]
Upgrade {
/// Only check if a newer version is available; do not download
#[arg(long)]
check: bool,
},
/// Export records to a file (JSON, TOML, or YAML).
///
/// Decrypts and exports all matched records. Requires master key unless --no-secrets is used.
#[command(after_help = "EXAMPLES:
# Export everything to JSON
secrets export --file backup.json
# Export a specific namespace to TOML
secrets export -n refining --file refining.toml
# Export a specific kind
secrets export -n refining --kind service --file services.yaml
# Export by tag
secrets export --tag production --file prod.json
# Export schema only (no decryption needed)
secrets export --no-secrets --file schema.json
# Print to stdout in YAML
secrets export -n refining --format yaml")]
Export {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter
#[arg(long)]
name: Option<String>,
/// Filter by tag (repeatable)
#[arg(long)]
tag: Vec<String>,
/// Fuzzy keyword search
#[arg(short, long)]
query: Option<String>,
/// Output file path (format inferred from extension: .json / .toml / .yaml / .yml)
#[arg(long)]
file: Option<String>,
/// Explicit format: json, toml, or yaml (overrides file extension; required for stdout)
#[arg(long)]
format: Option<String>,
/// Omit secrets from output (no master key required)
#[arg(long)]
no_secrets: bool,
},
/// Import records from a file (JSON, TOML, or YAML).
///
/// Reads an export file and inserts or updates entries. Requires master key to re-encrypt secrets.
#[command(after_help = "EXAMPLES:
# Import a JSON backup (conflict = error by default)
secrets import backup.json
# Import and overwrite existing records
secrets import --force refining.toml
# Preview what would be imported (no writes)
secrets import --dry-run backup.yaml
# JSON output for the import summary
secrets import backup.json -o json")]
Import {
/// Input file path (format inferred from extension: .json / .toml / .yaml / .yml)
file: String,
/// Overwrite existing records on conflict (default: error and abort)
#[arg(long)]
force: bool,
/// Preview operations without writing to the database
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Save database URL to config file (~/.config/secrets/config.toml)
SetDb {
/// PostgreSQL connection string, e.g. postgres://user:pass@<host>:<port>/dbname
url: String,
},
/// Show current configuration (password masked)
Show,
/// Print path to the config file
Path,
}
#[tokio::main]
async fn main() -> Result<()> {
load_dotenv();
let cli = Cli::parse();
let filter = if cli.verbose {
EnvFilter::new("secrets=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("secrets=warn"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
// config subcommand needs no database or master key
if let Commands::Config { action } = cli.command {
return commands::config::run(action).await;
}
// upgrade needs no database or master key either
if let Commands::Upgrade { check } = cli.command {
return commands::upgrade::run(check).await;
}
let db_url = config::resolve_db_url(&cli.db_url)?;
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
// init needs a pool but sets up the master key — handle before loading it
if let Commands::Init = cli.command {
return commands::init::run(&pool).await;
}
// All remaining commands require the master key from the OS Keychain,
// except delete which operates on plaintext metadata only.
match cli.command {
Commands::Init | Commands::Config { .. } | Commands::Upgrade { .. } => unreachable!(),
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span =
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::add::run(
&pool,
commands::add::AddArgs {
namespace: &namespace,
kind: &kind,
name: &name,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
output: out,
},
&master_key,
)
.await?;
}
Commands::Search {
namespace,
kind,
name,
tag,
query,
fields,
summary,
limit,
offset,
sort,
output,
} => {
let _span = tracing::info_span!("cmd", command = "search").entered();
let out = resolve_output_mode(output.as_deref())?;
commands::search::run(
&pool,
commands::search::SearchArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
query: query.as_deref(),
fields: &fields,
summary,
limit,
offset,
sort: &sort,
output: out,
},
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
dry_run,
output,
} => {
let _span =
tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::delete::run(
&pool,
commands::delete::DeleteArgs {
namespace: &namespace,
kind: kind.as_deref(),
name: name.as_deref(),
dry_run,
output: out,
},
)
.await?;
}
Commands::Update {
namespace,
kind,
name,
add_tags,
remove_tags,
meta,
remove_meta,
secrets,
remove_secrets,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span =
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::update::run(
&pool,
commands::update::UpdateArgs {
namespace: &namespace,
kind: &kind,
name: &name,
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
remove_meta: &remove_meta,
secret_entries: &secrets,
remove_secrets: &remove_secrets,
output: out,
},
&master_key,
)
.await?;
}
Commands::History {
namespace,
kind,
name,
limit,
output,
} => {
let out = resolve_output_mode(output.as_deref())?;
commands::history::run(
&pool,
commands::history::HistoryArgs {
namespace: &namespace,
kind: &kind,
name: &name,
limit,
output: out,
},
)
.await?;
}
Commands::Rollback {
namespace,
kind,
name,
to_version,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::rollback::run(
&pool,
commands::rollback::RollbackArgs {
namespace: &namespace,
kind: &kind,
name: &name,
to_version,
output: out,
},
&master_key,
)
.await?;
}
Commands::Inject {
namespace,
kind,
name,
tag,
prefix,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::run::run_inject(
&pool,
commands::run::InjectArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
output: out,
},
&master_key,
)
.await?;
}
Commands::Run {
namespace,
kind,
name,
tag,
prefix,
command,
} => {
let master_key = crypto::load_master_key()?;
commands::run::run_exec(
&pool,
commands::run::RunArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
command: &command,
},
&master_key,
)
.await?;
}
Commands::Export {
namespace,
kind,
name,
tag,
query,
file,
format,
no_secrets,
} => {
let master_key = if no_secrets {
None
} else {
Some(crypto::load_master_key()?)
};
let _span = tracing::info_span!("cmd", command = "export").entered();
commands::export_cmd::run(
&pool,
commands::export_cmd::ExportArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
query: query.as_deref(),
file: file.as_deref(),
format: format.as_deref(),
no_secrets,
},
master_key.as_ref(),
)
.await?;
}
Commands::Import {
file,
force,
dry_run,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span = tracing::info_span!("cmd", command = "import").entered();
let out = resolve_output_mode(output.as_deref())?;
commands::import_cmd::run(
&pool,
commands::import_cmd::ImportArgs {
file: &file,
force,
dry_run,
output: out,
},
&master_key,
)
.await?;
}
}
Ok(())
}

View File

@@ -1,219 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A single encrypted field belonging to an Entry.
/// field_name, field_type, and value_len are stored in plaintext so that
/// `search` can show the schema without requiring the master key.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
/// Inferred type: "string", "number", "boolean", "json"
pub field_type: String,
/// Length of the plaintext value in characters (0 for binary-like PEM)
pub value_len: i32,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Internal query row types (shared across commands) ─────────────────────────
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub tags: Vec<String>,
pub metadata: Value,
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub field_type: String,
pub value_len: i32,
pub encrypted: Vec<u8>,
}
// ── Export / Import types ──────────────────────────────────────────────────────
/// Supported file formats for export/import.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportFormat {
Json,
Toml,
Yaml,
}
impl ExportFormat {
/// Infer format from file extension (.json / .toml / .yaml / .yml).
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
Self::from_str(&ext).map_err(|_| {
anyhow::anyhow!(
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
ext
)
})
}
/// Parse from --format CLI value.
pub fn from_str(s: &str) -> anyhow::Result<Self> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
}
}
/// Serialize ExportData to a string in this format.
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(data)?),
Self::Toml => {
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
toml::to_string_pretty(&toml_val)
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
}
Self::Yaml => serde_yaml::to_string(data)
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
}
}
/// Deserialize ExportData from a string in this format.
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
match self {
Self::Json => Ok(serde_json::from_str(content)?),
Self::Toml => {
let toml_val: toml::Value = toml::from_str(content)
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
let json_val = toml_to_json_value(&toml_val);
Ok(serde_json::from_value(json_val)?)
}
Self::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
}
}
}
/// Top-level structure for export/import files.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportData {
pub version: u32,
pub exported_at: String,
pub entries: Vec<ExportEntry>,
}
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub namespace: String,
pub kind: String,
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.
/// `null` values are filtered out (TOML does not support null).
/// Mixed-type arrays are serialised as JSON strings.
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
match v {
Value::Null => anyhow::bail!("TOML does not support null values"),
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
anyhow::bail!("unsupported number: {}", n)
}
}
Value::String(s) => Ok(toml::Value::String(s.clone())),
Value::Array(arr) => {
let items: anyhow::Result<Vec<toml::Value>> =
arr.iter().map(json_to_toml_value).collect();
match items {
Ok(vals) => Ok(toml::Value::Array(vals)),
Err(e) => {
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
Ok(toml::Value::String(serde_json::to_string(v)?))
}
}
}
Value::Object(map) => {
let mut toml_map = toml::map::Map::new();
for (k, val) in map {
if val.is_null() {
// Skip null entries
continue;
}
match json_to_toml_value(val) {
Ok(tv) => {
toml_map.insert(k.clone(), tv);
}
Err(e) => {
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
toml_map
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
}
}
}
Ok(toml::Value::Table(toml_map))
}
}
}
/// Convert a toml Value back to a serde_json Value.
pub fn toml_to_json_value(v: &toml::Value) -> Value {
match v {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
toml::Value::Table(map) => {
let obj: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
.collect();
Value::Object(obj)
}
}
}

View File

@@ -1,65 +0,0 @@
use chrono::{DateTime, Local, Utc};
use std::io::IsTerminal;
use std::str::FromStr;
/// Output format for all commands.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum OutputMode {
/// Human-readable text (default when stdout is a TTY)
#[default]
Text,
/// Pretty-printed JSON
Json,
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
JsonCompact,
}
impl FromStr for OutputMode {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
"json-compact" => Ok(Self::JsonCompact),
other => Err(anyhow::anyhow!(
"Unknown output format '{}'. Valid: text, json, json-compact",
other
)),
}
}
}
/// Resolve the effective output mode.
/// - Explicit value from `--output` takes priority.
/// - TTY → text; non-TTY (piped/redirected) → json-compact.
pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> {
if let Some(s) = explicit {
return s.parse();
}
if std::io::stdout().is_terminal() {
Ok(OutputMode::Text)
} else {
Ok(OutputMode::JsonCompact)
}
}
/// Format a UTC timestamp for local human-readable output.
pub fn format_local_time(dt: DateTime<Utc>) -> String {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
}
/// Print a JSON value to stdout in the requested output mode.
/// - `Json` → pretty-printed
/// - `JsonCompact` → single line
/// - `Text` → no-op (caller is responsible for the text branch)
pub fn print_json(value: &serde_json::Value, mode: &OutputMode) -> anyhow::Result<()> {
match mode {
OutputMode::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputMode::JsonCompact => println!("{}", serde_json::to_string(value)?),
OutputMode::Text => {}
}
Ok(())
}

View File

@@ -1,3 +0,0 @@
-----BEGIN EXAMPLE KEY PLACEHOLDER-----
This file is for local dev/testing. Replace with a real key when needed.
-----END EXAMPLE KEY PLACEHOLDER-----