From 69379edcfc5f73c188f12ae70e0ef7d0f4e647d0 Mon Sep 17 00:00:00 2001 From: TedXiong Date: Sat, 17 Oct 2026 19:30:15 +0800 Subject: [PATCH 1/2] docs: build open source showcase and isolated interactive demo --- .github/ISSUE_TEMPLATE/bug.yml | 21 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature.yml | 15 + .github/PULL_REQUEST_TEMPLATE.md | 11 + .github/workflows/ci.yml | 5 + .github/workflows/pages.yml | 37 ++ .gitignore | 3 + CHANGELOG.md | 24 + CODE_OF_CONDUCT.md | 7 + CONTRIBUTING.md | 29 + README.en.md | 40 ++ README.md | 39 +- SECURITY.md | 19 + ...DR-012-public-demo-and-release-boundary.md | 21 + docs/deployment.md | 4 + docs/dots-example.md | 31 + docs/mcp.md | 4 + docs/opensource-review.md | 29 + docs/showcase-deployment.md | 48 ++ examples/python/README.md | 27 + examples/python/send_event.py | 80 +++ scripts/build-site.sh | 21 + site/collection.html | 50 ++ site/demo.js | 61 ++ site/index.html | 223 ++++++++ site/mark.svg | 1 + site/style.css | 535 ++++++++++++++++++ test/site/check.py | 55 ++ test/site/test_example.py | 68 +++ 29 files changed, 1510 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/pages.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 README.en.md create mode 100644 SECURITY.md create mode 100644 docs/decisions/ADR-012-public-demo-and-release-boundary.md create mode 100644 docs/dots-example.md create mode 100644 docs/opensource-review.md create mode 100644 docs/showcase-deployment.md create mode 100644 examples/python/README.md create mode 100644 examples/python/send_event.py create mode 100755 scripts/build-site.sh create mode 100644 site/collection.html create mode 100644 site/demo.js create mode 100644 site/index.html create mode 100644 site/mark.svg create mode 100644 site/style.css create mode 100644 test/site/check.py create mode 100644 test/site/test_example.py diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..af68950 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,21 @@ +name: Bug report +description: 可复现的问题(安全漏洞请使用私密报告) +body: + - type: input + id: version + attributes: + label: AlertBridge 版本与运行环境 + placeholder: v0.4.2 / Docker / amd64 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: 复现步骤、预期与实际结果 + description: 请使用虚构数据,不提交 Token、数据库或私密 URL。 + validations: + required: true + - type: textarea + id: evidence + attributes: + label: 脱敏日志与最小案例 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9547ac8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security report + url: https://github.com/xiongwei-git/alertbridge/security/advisories/new + about: 请私密报告安全漏洞,不要公开凭据或利用细节。 diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..d910ade --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,15 @@ +name: Feature proposal +description: 以实际场景讨论能力与边界 +body: + - type: textarea + id: problem + attributes: + label: 当前无法解决的具体问题 + validations: + required: true + - type: textarea + id: proposal + attributes: + label: 建议行为、替代方案与兼容性 + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dab23fa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## 解决的问题 + + + +## 验证 + + + +## 兼容性与运维 + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d773af9..e13a8af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: go-version: 1.26.5 cache: true + - name: Check public website and documentation + run: | + ./scripts/build-site.sh + python3 test/site/test_example.py + - name: Run unit tests run: go test ./... diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..bbc8752 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,37 @@ +name: Publish open source showcase + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Validate and build static showcase + run: ./scripts/build-site.sh + - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: dist-site + deploy: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.gitignore b/.gitignore index d3917f7..706e26d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ PROJECT_BRIEF.md PROJECT_MEMORY.md + +/dist-site/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8a0045c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## Unreleased — v1.0 candidate + +本轮是开源体验里程碑候选,尚未发布 v1.0 镜像。运行服务、v1 API、SQLite 与凭据格式不变,无数据库迁移。 + +- 新增独立静态官网、交互模拟 Demo、手动 GitHub Pages 发布流程和宝塔静态部署指引。 +- 明确产品价值、适用边界、英文快速介绍与贡献/安全规范。 +- 新增 Python 完整事件调用示例、Dots 使用场景和验收步骤。 +- 保持生产容器、卷、路由、凭据和 Nginx 配置不变。 + +## v0.4.2 + +- MCP `list_alerts` 支持 `order=desc` 直接读取最近告警;默认正序和历史游标保持兼容。 + +## v0.4.1 + +- 修复 Dots OAuth 授权页 CSP 拦截配置回调的问题。 + +## v0.4.0 + +- 新增按路由授权的只读 MCP 工具和 Dots OAuth + PKCE。 + +更早版本见 [GitHub Releases](https://github.com/xiongwei-git/alertbridge/releases)。 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5b0fc30 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# 行为准则 + +我们欢迎不同背景和经验的贡献者。请就问题和证据讨论,尊重他人的时间与隐私,提供建设性反馈。 + +不接受骚扰、歧视、人身攻击、威胁、恶意泄露个人信息或凭据,以及重复干扰讨论。维护者可编辑或移除违规内容、限制互动或拒绝贡献,并尽可能说明理由。 + +涉及安全与隐私的报告请使用 [安全政策](SECURITY.md) 中的私密入口;其他行为问题可通过 GitHub 的私密举报功能提交。维护者也受本准则约束。举报内容仅在处理所需范围内分享。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ada772b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# 参与 AlertBridge + +欢迎修复问题、改善文档和提供可复现的接入案例。先阅读 [README](README.md)、[架构决策](docs/decisions/) 和仓库 [AGENTS.md](AGENTS.md)。 + +## 开始 + +1. Fork 并从 `main` 创建功能分支;保持一次 PR 解决一个问题。 +2. Go 版本以 `go.mod` 为准;容器验证需要 Docker Compose。`./scripts/local-demo.sh up` 在本地 18081 端口启动隔离环境。 +3. 先用最小案例说明问题;修复逻辑时补充对应回归测试。文档和静态内容无需重复后端逻辑测试。 +4. 提交 PR 时说明动机、行为变化、兼容性与验证输出。面向用户的变化更新相关文档;公共 API 同步更新 `docs/api.md` 与 `docs/openapi.yaml`。 + +```sh +go test ./... +go vet ./... +./test/release/run.sh +./test/e2e/run.sh +python3 test/site/check.py +python3 test/site/test_example.py +``` + +单进程、SQLite、一个持久 Worker、无 CDN 的服务端后台是有意的取舍。新解析协议、多实例或主动 Agent 触发应先通过 Issue 讨论和 ADR 明确边界。 + +## 网站 + +`site/` 是无构建依赖的静态网站与浏览器模拟 Demo,运行 `python3 -m http.server 18083 --directory site` 后访问本机端口。GitHub Pages 与宝塔使用同一份文件。不要在 Demo 中加入生产地址、凭据、分析追踪或真实告警。 + +## 安全与沟通 + +安全问题通过 [SECURITY.md](SECURITY.md) 的私密渠道报告。不要附带 Token、数据库或完整私密 URL。遵守 [行为准则](CODE_OF_CONDUCT.md)。贡献使用仓库现有 Apache-2.0 许可;提交即表示你有权按此许可贡献内容。维护由个人利用业余时间进行,不承诺响应 SLA。 diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..d5f2421 --- /dev/null +++ b/README.en.md @@ -0,0 +1,40 @@ +# AlertBridge + +**One small, self-hosted bridge between your services, your notification channels, and your agents.** + +AlertBridge receives authenticated events, routes them to Feishu, Telegram, ntfy or TLS SMTP, and records delivery attempts in SQLite. Go, one HTTP process, one database connection and one persistent worker. No Redis, Node.js runtime or external CDN for the admin console. + +[中文](README.md) · [Interactive demo source](site/index.html) · [Deployment](docs/deployment.md) · [API](docs/api.md) · [MCP / Dots](docs/mcp.md) + +## Why use it? + +- Keep channel credentials and retry logic out of every application. +- Track `firing` / `resolved` incidents and deduplicate repeated events. +- Inspect queued, sent and failed deliveries; acceptance is not delivery confirmation. +- Give agents read-only access to permitted routes through MCP, with OAuth + PKCE for Dots. + +Use it for personal infrastructure, small teams and single-node operations. It is not a monitoring collector, highly available paging platform, escalation system or autonomous remediation service. + +## Try locally + +```sh +git clone https://github.com/xiongwei-git/alertbridge.git +cd alertbridge +./scripts/local-demo.sh up +``` + +Requires Docker Compose, OpenSSL and curl. The script prints the generated admin password and opens a local-only HTTP environment at `http://127.0.0.1:18081/admin/`. Create a channel, route and client in the console. Never expose this local environment publicly. + +For a browser-only simulation, serve `site/` with `python3 -m http.server 18083 --directory site`. This demo uses fictional data and sends no notifications. + +Production uses the published GHCR image, HTTPS at your reverse proxy, and loopback-only application exposure. Follow the [deployment guide](docs/deployment.md); back up the database and encryption-key volumes together. Detailed operating documentation is currently in Chinese. + +## Integrate + +- `POST /api/v1/notifications`: header-only Bearer token, server-bound route and severity, one-off `info` notifications. +- `POST /api/v1/events`: HMAC-SHA256, replay protection, idempotency and incident lifecycle. See [Python example](examples/python/README.md). +- `/mcp`: optional, read-only `list_alerts`, `get_alert`, `list_active_incidents`; no acknowledgement, mutation or automatic wake-up. + +## Contribute + +Read [CONTRIBUTING](CONTRIBUTING.md), [security policy](SECURITY.md), [code of conduct](CODE_OF_CONDUCT.md) and [decisions](docs/decisions/). Licensed under [Apache-2.0](LICENSE). Maintained on a best-effort basis; no availability or support SLA is promised. diff --git a/README.md b/README.md index 2faeb47..5adb25c 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,39 @@ [![CI](https://github.com/xiongwei-git/alertbridge/actions/workflows/ci.yml/badge.svg)](https://github.com/xiongwei-git/alertbridge/actions/workflows/ci.yml) -AlertBridge 是面向个人、小团队和单节点运维环境的轻量通知网关。外部服务通过简单通知或完整事件 API 提交消息;AlertBridge 负责认证、路由、静默、去重、持久化排队、失败重试和多渠道投递。 +**让服务专注业务,让通知统一到达,让 Agent 按权限读懂告警。** -它采用 Go 单进程、SQLite 和服务端渲染后台,不依赖 Redis、PostgreSQL、Node.js 或 CDN。生产镜像基于 `scratch`;加入官方 MCP SDK 和 Dots OAuth 后,本地 Docker E2E 参考镜像约 21.5 MB、空闲内存约 14 MiB。Compose 默认限制为 128 MiB 内存和 0.5 CPU。 +AlertBridge 是个人、小团队和单节点运维环境的轻量自托管通知网关。业务脚本不再各自维护渠道密钥和重试逻辑:一次接入,统一认证、路由、去重、持久排队与多渠道投递。支持飞书、Telegram、ntfy、TLS SMTP,并提供可选的只读 MCP 与 Dots OAuth。 -image +[English](README.en.md) · [部署指南](docs/deployment.md) · [Dots 实例](docs/dots-example.md) · [Python 接入](examples/python/README.md) · [参与贡献](CONTRIBUTING.md) +## 先理解,再试用 + +| 你的目标 | 从这里开始 | +| --- | --- | +| 不部署,先理解故障到恢复的流程 | [官网与浏览器模拟 Demo 源码](site/index.html),本地预览见下方 | +| 体验真实后台、持久化和投递 | `./scripts/local-demo.sh up`,本机 18081 端口 | +| 部署到自己的服务器 | [Docker + 宝塔指南](docs/deployment.md) | +| 让 Dots 查询最近告警与活跃故障 | [可验收的 Dots 接入案例](docs/dots-example.md) | + +```sh +# 浏览器演示:仅需 Python 3,无凭据、不连接生产 API +./scripts/build-site.sh +python3 -m http.server 18083 --bind 127.0.0.1 --directory dist-site +# 集合首页 http://127.0.0.1:18083/ ,项目 Demo /alertbridge/ +``` + +Demo 使用明确标注的虚构数据;真实接收与渠道送达必须在隔离 Docker 环境验证。GitHub Pages 工作流及独立宝塔静态站部署见 [官网部署](docs/showcase-deployment.md)。公开地址将在实际发布验收后添加。 + +项目采用 Go 单进程、一个 SQLite 连接和一个持久 Worker,不依赖 Redis、PostgreSQL、Node.js 或 CDN。生产镜像基于 `scratch`,Compose 默认限制为 128 MiB 内存和 0.5 CPU。历史本地 E2E 参考镜像约 21.5 MB、空闲内存约 14 MiB;这些不是所有平台的性能保证。 + +## 为什么保留一个小网关 + +- **少一份重复逻辑**:把渠道凭据、路由和失败重试从每个业务服务中抽出来。 +- **多一份可查证据**:从接收、去重到投递记录追踪结果;HTTP 接收成功不等于消息送达。 +- **清晰的 Agent 权限**:按路由读取历史、详情和活跃故障,不把告警文本变成执行授权。 + +适合单节点、小规模自托管;高可用值班、监控采集、升级通知和自动修复需要其他系统或新的架构决策。 ## 当前边界 @@ -235,6 +262,12 @@ test/e2e Docker 端到端测试 test/release GHCR/ACR 发布与生产 Compose 契约检查 ``` +## 开源协作与版本 + +当前源码版本为 v0.4.2;本轮 v1.0 是开源体验里程碑候选,尚未发布正式镜像。候选不改变 API、数据库或现有部署,见 [变更记录](CHANGELOG.md) 与 [全盘审视](docs/opensource-review.md)。安装命令继续引用已有版本。 + +欢迎最小可复现的 Issue、真实接入案例与文档改进。阅读 [贡献指南](CONTRIBUTING.md)、[行为准则](CODE_OF_CONDUCT.md) 和 [安全政策](SECURITY.md)。维护由个人尽力提供,不承诺响应 SLA。AlertBridge 是独立开源项目,与渠道厂商或 OpenAI 无官方从属关系。 + ## 许可证 本项目采用 [Apache License 2.0](LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3625906 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# 安全政策 + +正式发布前的候选版本用于评估;当前维护线为最新的 `v0.4.x`,v1.0 发布后以最新 `v1.x` 为主。旧版本不承诺回补,更新前阅读发布说明并备份数据库与主密钥恢复对。 + +## 私密报告 + +请使用仓库的 [Report a vulnerability](https://github.com/xiongwei-git/alertbridge/security/advisories/new) 私密报告入口。若入口不可用,请不要在公开 Issue 中披露漏洞细节或凭据;先用不包含细节的 Issue 请求维护者开启私密报告。响应由维护者尽力提供,不承诺修复时限。 + +提供受影响版本、最小复现、预期与实际行为、影响范围。使用虚构数据;不提交 `.env`、数据库、Webhook、密钥或密码。请勿对未经授权的部署测试。 + +## 信任边界 + +- 公网 TLS 由反向代理提供,应用只绑定 loopback。 +- HMAC、轻量通知令牌和 MCP OAuth/Bearer 是分离的身份体系。 +- MCP 仅按服务端路由授权读取,不提供确认、关闭或执行命令。 +- 告警正文是不可信内容,不能成为 Agent 执行指令的授权。 +- 交互 Demo 完全在浏览器中模拟,不连接网关,也不能证明真实投递。 + +更多部署约束见 [运维指南](docs/deployment.md)。 diff --git a/docs/decisions/ADR-012-public-demo-and-release-boundary.md b/docs/decisions/ADR-012-public-demo-and-release-boundary.md new file mode 100644 index 0000000..abd4a46 --- /dev/null +++ b/docs/decisions/ADR-012-public-demo-and-release-boundary.md @@ -0,0 +1,21 @@ +# ADR-012: 官网、公共演示与运行服务隔离 + +## 状态 + +Accepted,2026-10-17。 + +## 背景 + +开源访问者需要在不持有凭据、不接触维护者生产告警的情况下理解路由、故障生命周期与 Agent 只读查询。GitHub Pages 只能托管静态内容,不能运行 Go/SQLite 服务。 + +## 决定 + +- `site/` 提供无依赖静态官网与浏览器内模拟 Demo,同一产物可部署 GitHub Pages 或独立宝塔静态站。 +- Demo 显示虚构事件、模拟投递结果与明确的模拟标识;不请求生产 API、不保存输入、不收集凭据、不开放匿名后台。 +- 真实功能验收仍由隔离 Docker E2E 和用户自己的本地 Demo 完成。 +- Pages 工作流只手动触发,发布前必须通过网站检查。不在 `main` 推送时自动覆盖网站,且不调用镜像发布或生产部署。 +- v1.0 作为开源体验里程碑候选;此轮保持 `VERSION` 在已存在的 v0.4.2,不让尚未发布的 v1.0 出现在可执行安装命令里。正式 tag、镜像发布和生产升级各自明确执行。 + +## 备选方案与后果 + +公共可写 Go 后台需要隔离凭据、滥用控制和定期清理,维护成本更高,故不采用。静态模拟不能证明真实认证、持久化、重试或通知送达,页面和文档需持续保留此边界。宝塔域名和 HTTPS 验收独立于通知服务,不改现有反向代理、容器、数据库或主密钥。 diff --git a/docs/deployment.md b/docs/deployment.md index 2a46a0e..75c125e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -360,3 +360,7 @@ curl -fsS http://127.0.0.1:18080/readyz | 通知时间比北京时间少 8 小时 | 使用 v0.2.2 或更高版本;确认 `ALERTBRIDGE_DISPLAY_TIMEZONE=Asia/Shanghai`,不要修改服务器系统时钟 | 不要通过关闭既有认证、把令牌放进 URL、使用固定弱密码、把密码放入容器环境变量、放宽出站地址校验或开放应用端口来绕过故障。 + +## 开源展示站与生产服务隔离 + +个人开源集合和 AlertBridge 模拟 Demo 是独立静态产物,见 [官网部署](showcase-deployment.md)。它不需要升级运行镜像、复制数据库或暴露后台;域名、根目录和发布流程均与现有服务分离。 diff --git a/docs/dots-example.md b/docs/dots-example.md new file mode 100644 index 0000000..3676579 --- /dev/null +++ b/docs/dots-example.md @@ -0,0 +1,31 @@ +# Dots:从“有没有告警”到“查看投递证据” + +这是使用现有只读 MCP 的接入案例,不需要 OpenAI API Key。协议配置见 [MCP 指南](mcp.md)。Dots/ChatGPT 的产品入口、权限和可用性取决于实际账号,不承诺所有账号可安装。 + +## 准备数据与授权 + +1. 先在自己的隔离环境运行 [Python 示例](../examples/python/README.md),确认测试告警及投递记录可见。不要为了演示导入生产数据库。 +2. 将 MCP 路由范围限定为示例路由,例如 `ops`;通过 HTTPS 部署并按 [OAuth 配置](mcp.md#dots-接入推荐) 连接。不要把本机 HTTP Demo 暴露到公网以绕过 TLS。 +3. 连接信息:MCP URL 为 `https://你的服务域名/mcp`;Client ID 为 `alertbridge-dots`,Client Secret 留空,Scope 为 `alerts:read`,PKCE 使用 S256。 +4. 使用 AlertBridge 管理员账号登录授权页,核对允许读取的路由。OAuth access/refresh token 无需手动复制到对话中。 + +公共客户端、预配置 OAuth 与精确回调遵循 [OpenAI 官方认证文档](https://developers.openai.com/plugins/build/auth),核对日期 2026-10-17。实际界面若要求其他正式回调,按 MCP 指南配置单个精确地址,不放宽通配。 + +## 三个可以直接尝试的任务 + +| 对 Dots 说 | 预期工具 | 验收重点 | +| --- | --- | --- | +| “查询最近 10 条告警,按新到旧展示来源、标题和时间。” | `list_alerts`,`{"order":"desc","limit":10}` | 一次读取最近一页;只含被授权路由 | +| “查看其中一条告警的投递详情,区分入队与送达。” | `get_alert`,使用上一步返回的内部 ID | 展示投递状态、次数和响应码;不把 queued 当作 sent | +| “现在还有哪些没有恢复的故障?” | `list_active_incidents` | 只读列出当前 firing;恢复后不再活跃 | + +工具输出是事实依据,Agent 的归纳仍应核对。告警标题、正文是外部不可信数据,里面的命令或请求不构成执行授权。Agent 不应由告警正文自行登录服务器、运行命令或发送消息。 + +## 验收与撤销 + +- 用示例路由建立一条 firing,再发送同去重键 resolved;查询活跃故障前后变化。 +- 用另一条未授权路由的数据验证不会出现在查询结果中;详情越权与不存在统一不可用。 +- 后台撤销全部 MCP OAuth 授权后,旧连接应失效;需要重新授权。 +- 升级工具参数后刷新 Dots 工具定义。协议 E2E 通过与实际账号连接成功是两项验收。 + +AlertBridge 当前不会订阅事件并主动唤醒 Dots,也不会确认、关闭或修改故障。网站中的“模拟查询”不连接 Dots 或真实 MCP。 diff --git a/docs/mcp.md b/docs/mcp.md index bfb1027..eded743 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -83,3 +83,7 @@ URL:https://你的服务域名/mcp ## Dots 主动触发的后续边界 OpenAI 官方已提供 [MCP Events](https://developers.openai.com/plugins/build/mcp-events),适用于 Dots 和指定的 Work 环境。主动触发需要进一步实现官方事件订阅和 Webhook 投递契约,普通 `tools/list` / `tools/call` 查询端点不会自动触发 Dots。本阶段提供 OAuth 和只读查询,没有事件订阅或自动唤醒。 + +## 任务导向案例 + +从最近十条、单条投递证据到故障恢复验收,见 [Dots 接入示例](dots-example.md)。 diff --git a/docs/opensource-review.md b/docs/opensource-review.md new file mode 100644 index 0000000..6f9dc1e --- /dev/null +++ b/docs/opensource-review.md @@ -0,0 +1,29 @@ +# 开源体验审视与 v1.0 候选计划 + +检查日期:2026-10-17。基线:v0.4.2 源码;本次仅改开放协作、示例和独立网站,不修改运行服务。 + +| 维度 | 现状判断 | 本次改造 | +| --- | --- | --- | +| 定位 | 功能完整但首页偏运维手册,首次阅读路径长 | 清晰价值主张、适用/不适用场景、英文章节入口 | +| 试用 | 只有需要 Docker 的本地后台 | 静态浏览器 Demo + 原有真实 Docker Demo,两者分开标注 | +| 接入 | API/OAuth 有契约,缺少任务导向实例 | 标准库 Python HMAC 示例、Dots 提问与验收清单 | +| 信任 | Apache-2.0、ADR、安全容器与 CI 已有基础 | 安全政策、贡献流程、行为准则、Issue/PR 模板 | +| 发布 | GHCR 为权威,已有多架构/供应链证明 | 保留发布边界,官网 Pages 单独手动发布 | +| 展示 | README 截图依赖外链 | 可移植静态官网、响应式交互和独立部署说明 | +| 兼容性 | v1 API,SQLite 单实例 | v1.0 候选不改变 API/数据库/生产配置 | + +## 核心价值 + +让业务脚本只关心“发生了什么”,由一个可自托管的小服务统一处理“谁能发、发给谁、是否重复、有没有送达”;Agent 在明确路由权限下读取这些事实。不要将其描述为监控采集平台、值班系统或自动修复 Agent。 + +## 发布门槛 + +1. Go 测试、vet、release 检查、隔离 Docker E2E 和静态网站检查通过。 +2. 官网桌面/移动视图、模拟故障/恢复/重置流程验收。 +3. 审阅 v1.0 候选变更记录和文档;维护者确定发布日期,再同步 VERSION、安装示例及 tag。 +4. GitHub Pages 发布后验证真实公开 URL;宝塔在独立域名解析后验证 HTTPS 与 Demo。两者不要求升级现有服务。 +5. Dots 账号端安装仍需有权限的真实账号验收,不将模拟或协议测试称为账号端完成。 + +## 后续按需求推进 + +英文文档逐步完善、可选的跨版本备份恢复演练、更多真实接入案例。高可用、第三方解析器、聚合/升级、MCP Events 主动唤醒仍需要独立需求和 ADR,不为开源外观而引入。 diff --git a/docs/showcase-deployment.md b/docs/showcase-deployment.md new file mode 100644 index 0000000..ef8263c --- /dev/null +++ b/docs/showcase-deployment.md @@ -0,0 +1,48 @@ +# 个人开源集合与 AlertBridge 官网部署 + +目标域名:`opensource.tedxiong.com`。集合首页 `/`,AlertBridge 项目页及模拟 Demo `/alertbridge/`。源码 `site/`,生成目录 `dist-site/`,无 Node、构建依赖、CDN、外部字体或数据服务。 + +## 本地预览与打包 + +```sh +./scripts/build-site.sh +python3 -m http.server 18083 --bind 127.0.0.1 --directory dist-site +``` + +访问 `http://127.0.0.1:18083/` 与 `/alertbridge/`。打包时只复制白名单文件,不包含仓库其他内容、环境文件、数据库或凭据。不要把整个仓库作为网站根目录。 + +## GitHub Pages + +GitHub Pages 托管 HTML/CSS/JavaScript,不能运行 Go/SQLite。本站的交互仅为浏览器模拟。依据:[GitHub Pages 官方说明](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages),核对日期 2026-10-17。 + +1. 将网站和 `.github/workflows/pages.yml` 合并到仓库默认分支。 +2. 仓库 Settings → Pages → Build and deployment,Source 选择 GitHub Actions。 +3. Actions → Publish open source showcase → Run workflow。工作流只手动触发,和镜像发布、生产部署无关联。 +4. 打开工作流输出的 `page_url`;项目页是此地址再加 `alertbridge/`。例如标准项目 Pages 路径为 `https://xiongwei-git.github.io/alertbridge/alertbridge/`,集合页为 `https://xiongwei-git.github.io/alertbridge/`。 +5. 验证集合链接、Demo 故障/重复/恢复/重置、移动端和页面资源路径。 + +本轮不设置 Pages 自定义域名,避免与宝塔的 `opensource.tedxiong.com` DNS 冲突。GitHub Pages 作为可独立访问的静态演示镜像;正式个人开源集合以宝塔域名为入口。 + +## 宝塔独立静态站 + +域名尚未解析时,先准备产物,不修改现有通知站点。 + +1. 在 DNS 服务商为 `opensource.tedxiong.com` 设置指向目标宝塔服务器的 A/AAAA 记录,只有服务器支持 IPv6 时才添加 AAAA。 +2. 宝塔新建独立 HTML 静态网站,单独根目录,例如 `/www/wwwroot/opensource.tedxiong.com`,不要复用通知服务目录或反向代理。 +3. 备份该静态站旧产物(若存在),将 `dist-site/` **内部文件** 上传到新 release 目录,再切换该站点根目录。保留上一 release 用于回滚。 +4. 为新域名申请证书、启用 HTTPS;以下配置仅应用于新静态站,不改通知服务 Nginx 配置。 + +```nginx +location / { + try_files $uri $uri/ =404; +} +add_header X-Content-Type-Options nosniff always; +add_header Referrer-Policy strict-origin-when-cross-origin always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always; +``` + +这是静态站,不需要 SPA fallback 或 API 反向代理。确保静态站不代理 `/admin`、`/api`、`/mcp`。`nginx -t` 通过后平滑重载。验收根路径、`/alertbridge/`、CSS/JS/SVG 资源、缺失路径 404、HTTPS 和浏览器交互,再以文件 SHA-256 对照产物。回退只切回旧静态目录,不触碰运行服务和数据卷。 + +## 扩展集合 + +目前只收录 AlertBridge。其他项目核实公开状态与介绍后再新增条目。未来应将集合站移到独立仓库;本轮保留在 AlertBridge 内以便审阅和发布同一产物,且不引入其他项目的源码或私密资料。 diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..f3d4e90 --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,27 @@ +# Python:从测试到故障恢复 + +Python 3 标准库即可,无额外依赖。签名覆盖实际发送的 UTF-8 原始字节;每次调用生成独立事件 ID、时间戳与 Nonce。脚本会真实提交事件,请优先使用本地 Demo 或专用测试路由。 + +1. `./scripts/local-demo.sh up`,在本机后台创建并测试通知渠道。 +2. 创建 `ops` 路由的 `warning` 级别规则;创建仅允许 `ops` 路由的 HMAC 客户端。 +3. 将只显示一次的客户端密钥通过编辑器保存到仓库外的私有文件,执行 `chmod 600 ~/.alertbridge-example-secret`。不要把密钥放在命令参数、版本库或公开截图里。 + +```sh +export ALERTBRIDGE_URL=http://127.0.0.1:18081 +export ALERTBRIDGE_CLIENT_ID=填写客户端ID +export ALERTBRIDGE_ROUTING_KEY=ops +export ALERTBRIDGE_CLIENT_SECRET_FILE=~/.alertbridge-example-secret +python3 examples/python/send_event.py --status test +``` + +在后台“投递记录”确认结果;HTTP 202 只证明事件被接收处理,不证明渠道送达。需要验收接收效果时,还应在你控制的渠道确认收到消息。 + +```sh +python3 examples/python/send_event.py --status firing --dedupe-key example-disk +python3 examples/python/send_event.py --status firing --dedupe-key example-disk +python3 examples/python/send_event.py --status resolved --dedupe-key example-disk +``` + +保持客户端、路由和去重键一致:首次创建故障,第二次去重,最后恢复。去重窗口与路由配置以实际服务为准,勿高频运行。连接生产环境时改用 HTTPS 根地址并显式确认测试范围。脚本拒绝非 loopback 明文 HTTP、带路径/账号的 URL 和 HTTP 重定向。 + +只发一次性消息时可选轻量 Bearer API,见 [API 文档](../../docs/api.md),不要把 HMAC 密钥当作 Bearer 令牌。 diff --git a/examples/python/send_event.py b/examples/python/send_event.py new file mode 100644 index 0000000..13bf0bc --- /dev/null +++ b/examples/python/send_event.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Send one v1 HMAC event using Python's standard library; no secrets in argv.""" +import argparse +import hashlib +import hmac +import json +import os +from pathlib import Path +import secrets +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None # Never forward authentication to a redirect target. + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--status', choices=['test', 'info', 'firing', 'resolved'], default='test') + parser.add_argument('--dedupe-key', default='example-disk') + args = parser.parse_args() + endpoint = os.environ.get('ALERTBRIDGE_URL', 'http://127.0.0.1:18081').rstrip('/') + client = os.environ.get('ALERTBRIDGE_CLIENT_ID', '') + route = os.environ.get('ALERTBRIDGE_ROUTING_KEY', '') + secret_file = os.environ.get('ALERTBRIDGE_CLIENT_SECRET_FILE', '') + url = urllib.parse.urlsplit(endpoint) + local = url.hostname in ('127.0.0.1', 'localhost', '::1') + if (url.scheme != 'https' and not (url.scheme == 'http' and local)) or not url.hostname or url.username or url.password or url.query or url.fragment or url.path: + parser.error('ALERTBRIDGE_URL must be an HTTPS origin (HTTP allowed only on loopback)') + if not client or not route or not secret_file: + parser.error('set ALERTBRIDGE_CLIENT_ID, ALERTBRIDGE_ROUTING_KEY and ALERTBRIDGE_CLIENT_SECRET_FILE') + path = Path(secret_file) + info = path.stat() + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077: + parser.error('client secret must be a private regular file (chmod 600)') + secret = path.read_bytes().strip() + if not secret: + parser.error('client secret file is empty') + event = { + 'event_id': 'example-' + secrets.token_hex(16), 'source': 'python-example', + 'routing_key': route, 'status': args.status, + 'severity': os.environ.get('ALERTBRIDGE_SEVERITY', 'warning'), + 'title': 'AlertBridge Python example', 'message': 'Explicitly requested example event.', + 'occurred_at': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'), + 'dedupe_key': args.dedupe_key, + } + body = json.dumps(event, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + timestamp, nonce = str(int(time.time())), secrets.token_hex(16) + signed = f'{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}'.encode('ascii') + signature = hmac.new(secret, signed, hashlib.sha256).hexdigest() + request = urllib.request.Request(endpoint + '/api/v1/events', data=body, method='POST', headers={ + 'Content-Type': 'application/json', 'X-Notify-Client': client, + 'X-Notify-Timestamp': timestamp, 'X-Notify-Nonce': nonce, 'X-Notify-Signature': signature, + }) + try: + with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response: + print(f'HTTP {response.status}') + print(response.read(65536).decode('utf-8')) + print('Accepted is not delivered. Check delivery records in the admin console.') + except urllib.error.HTTPError as exc: + print(f'HTTP {exc.code}; inspect server logs without sharing credentials.', file=sys.stderr) + return 1 + except urllib.error.URLError: + print('Connection failed; check endpoint, TLS and network.', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except OSError: + sys.exit('Could not read the private secret file; verify path and permissions.') diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..744fc3a --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Build only public, allowlisted files. Never copy the repository or runtime data. +set -eu +cd "$(dirname "$0")/.." +python3 test/site/check.py +python3 - <<'PY' +from pathlib import Path +import shutil +out = Path('dist-site') +# This directory is exclusively generated; fail on a symlink to protect other paths. +if out.is_symlink(): + raise SystemExit('dist-site must not be a symlink') +if out.exists(): + shutil.rmtree(out) +(out / 'alertbridge').mkdir(parents=True) +for name in ('index.html', 'style.css', 'demo.js', 'mark.svg'): + shutil.copyfile(Path('site') / name, out / 'alertbridge' / name) +shutil.copyfile('site/collection.html', out / 'index.html') +(out / '.nojekyll').touch() +print('Built dist-site/: collection at /, AlertBridge at /alertbridge/') +PY diff --git a/site/collection.html b/site/collection.html new file mode 100644 index 0000000..88ec757 --- /dev/null +++ b/site/collection.html @@ -0,0 +1,50 @@ + + + + + +Open Source — Ted Xiong + + + + + + +
+
+
INDEPENDENT TOOLS / OPEN BY DEFAULT
+

把日常的问题,
做成可以复用的工具。 +

+

这里收录我维护的开源项目。
从真实需求出发,保持边界清楚,让部署、使用与贡献都有迹可循。

+
+
+
01 / FEATURED PROJECT
+
+
+

AlertBridge

+

轻量自托管通知网关。统一事件接入、多渠道投递、持久重试与 Agent 只读查询。

+
GO + SQLITE / DOCKER / MCP / APACHE-2.0
+
+
+

让业务服务少写一套通知逻辑,让故障从发生到恢复都有记录。适合个人基础设施、小团队与单节点运维。

+ +
+
+
+
+ + + diff --git a/site/demo.js b/site/demo.js new file mode 100644 index 0000000..837ca97 --- /dev/null +++ b/site/demo.js @@ -0,0 +1,61 @@ +'use strict'; +// Presentation only: no network, credentials, persistence or backend claims. +(() => { + let active = false; + let received = 0; + let delivered = 0; + let events = []; + const el = id => document.getElementById(id); + const show = (id, value) => { el(id).textContent = value; }; + const update = () => { + show('active', active ? 1 : 0); + show('received', received); + show('delivered', delivered); + el('fire').disabled = active; + el('repeat').disabled = !active; + el('resolve').disabled = !active; + }; + function event(status, repeated = false) { + received++; + active = status === 'firing'; + if (!repeated) delivered++; + const payload = { + event_id: `demo-${received}`, source: 'demo-server', routing_key: 'ops', + status, severity: 'warning', title: status === 'resolved' ? '磁盘空间恢复' : '磁盘使用率超过 90%', + message: '虚构演示事件', occurred_at: new Date().toISOString(), dedupe_key: 'demo-disk' + }; + const result = repeated ? '模拟:重复故障被去重,活跃故障保持 1 个,不新增通知。' : + status === 'resolved' ? '模拟:故障已恢复,新增 1 条恢复通知。' : '模拟:创建活跃故障,新增 1 条故障通知。'; + events.push({ id: received, ...payload, demo_delivery: repeated ? 'deduplicated' : 'simulated_sent' }); + show('state', result); + show('payload', JSON.stringify(payload, null, 2)); + if (received === 1) el('timeline').replaceChildren(); + const row = document.createElement('li'); + const time = document.createElement('time'); + time.dateTime = payload.occurred_at; + time.textContent = new Date(payload.occurred_at).toLocaleTimeString('zh-CN', {hour12: false}); + row.append(time, document.createTextNode(result)); + el('timeline').prepend(row); + // Bound browser memory during long demo sessions. + events = events.slice(-100); + while (el('timeline').children.length > 30) el('timeline').lastChild.remove(); + show('agent-result', '演示数据已变化。点击查询获取最近的模拟结果。'); + update(); + } + el('fire').addEventListener('click', () => event('firing')); + el('repeat').addEventListener('click', () => event('firing', true)); + el('resolve').addEventListener('click', () => event('resolved')); + el('query').addEventListener('click', () => { + show('agent-result', JSON.stringify({ demo: true, tool: 'list_alerts', arguments: {order: 'desc', limit: 10}, events: events.slice(-10).reverse() }, null, 2)); + }); + el('reset').addEventListener('click', () => { + active = false; received = 0; delivered = 0; events = []; + show('state', '演示已重置。点击“发生故障”开始。'); + show('payload', '尚无请求;本页面不会调用 API。'); + show('agent-result', '参数:{"order":"desc","limit":10}\n等待查询演示数据。'); + const row = document.createElement('li'); row.className = 'empty'; + row.textContent = '这里将展示事件与模拟投递的变化。'; + el('timeline').replaceChildren(row); update(); + }); + update(); +})(); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..3e361f1 --- /dev/null +++ b/site/index.html @@ -0,0 +1,223 @@ + + + + + + +AlertBridge — 把通知接起来 + + + + + + + +
+
+
SELF-HOSTED · GO + SQLITE · APACHE-2.0
+

服务各司其职。
通知,交给一座桥。 +

+

把分散在脚本里的通知逻辑,收进一个轻量网关。
统一接入、可靠排队、多渠道投递,再让 Agent 读懂发生了什么。

+ +
一个进程 · 一份 SQLite · 一个持久投递 Worker
+
+
+YOUR SERVICES +监控 / 脚本 / 自动化 +HMAC 事件 · Bearer 通知 +
+ +
+ONE BRIDGE +AlertBridge +认证 · 路由 · 去重 · 重试 +
+ +
+YOUR CHANNELS +飞书 / Telegram +ntfy / TLS SMTP +
+
+
Agent ← 按路由授权的只读 MCP ← AlertBridge
+
+
+
+
+01 / LESS GLUE +

每个服务,少写一套通知。

+

业务只描述发生了什么。渠道密钥、投递路由与失败重试,集中在网关管理。

+
+
+02 / MORE EVIDENCE +

入队之后,仍有迹可循。

+

区分已接收、等待投递与实际送达。查看尝试次数和死信,追踪故障发生与恢复。

+
+
+03 / YOUR CONTROL +

数据留在自己的服务里。

+

单实例自托管,服务端渲染后台。Agent 只读取指定路由的告警,不修改故障。

+
+
+
+
+
+
+
TRY THE FLOW
+

从发生,到恢复。

+
+

浏览器内模拟 · 虚构数据 · 不发送真实通知
真实认证、持久化和重试请用 Docker Demo 验证。

+
+
+
+ + + 交互演示 / ops-demo + +
+
+
+模拟事件 +

一台服务器的磁盘告警

+

依次体验故障、重复上报与恢复,看看网关如何维护同一个故障。

+ + + +
固定演示路由:ops
渠道:飞书(模拟)
去重键:demo-disk
+
+
+
+
+活跃故障 +0 +
+
+接收事件 +0 +
+
+模拟通知 +0 +
+
+

等待第一条事件。点击“发生故障”开始。

+
    +
  1. 这里将展示事件与模拟投递的变化。
  2. +
+
+查看最近一次模拟请求 +
尚无请求;本页面不会调用 API。
+
+
+
+
+
+READ-ONLY AGENT +

“最近发生了什么?”

+

演示如何按接收时间倒序读取最近 10 条。

+
+
+ +
参数:{"order":"desc","limit":10}
+等待查询演示数据。
+
+
+
+ +
+
+
TWO WAYS TO START
+

先试用,再接入自己的服务。

+
+
+01 / LOCAL EXPERIENCE +

运行真实的本地后台

+

需要 Docker Compose、OpenSSL 和 curl。临时凭据由脚本生成;在空后台配置渠道、路由和客户端。

+
git clone https://github.com/xiongwei-git/alertbridge.git
+cd alertbridge
+./scripts/local-demo.sh up
+

打开 http://127.0.0.1:18081/admin/
本地 HTTP 环境仅供测试,不要暴露到公网。

+
+ +
+
+
+
+
+
BRING YOUR AGENT
+

Dots 读事实,
你保留控制权。

+

用 OAuth + PKCE 授权,让 Agent 查询指定路由的历史告警、投递详情和活跃故障。

+按示例连接 Dots ↗ +
+
+THREE TOOLS. READ ONLY. +
+
list_alerts
+
最近告警、按来源筛选、游标分页
+
get_alert
+
事件详情、投递状态与尝试记录
+
list_active_incidents
+
当前尚未恢复的故障
+
+

不确认或关闭故障,不执行系统命令,不自动唤醒 Agent。账号可用性与连接界面以实际环境为准。

+
+
+
+
+
SMALL BY DESIGN
+

边界清楚,才能放心使用。

+
+
+

适合这样的需求

+

个人基础设施、小团队、单节点运维。希望统一脚本通知,有持久队列、清晰投递记录,并允许 Agent 按需读取。

+
+
+

需要其他方案的场景

+

监控采集、多实例高可用、值班排班、告警聚合、超时升级、告警确认和自动修复,当前不在项目能力范围内。

+
+
+
+

把它变成你愿意长期使用的工具。

+

从一个最小接入案例、一份更清楚的文档,或一个可复现的问题开始。

+参与贡献 ↗ +安全政策 ↗ +版本记录 ↗ +
+
+
+ + + diff --git a/site/mark.svg b/site/mark.svg new file mode 100644 index 0000000..63431ce --- /dev/null +++ b/site/mark.svg @@ -0,0 +1 @@ + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..78a7d9d --- /dev/null +++ b/site/style.css @@ -0,0 +1,535 @@ +:root { +--bg:#fafbf7; +--ink:#182e28; +--muted:#52665e; +--green:#164d41; +--line:#d6ded5; +--accent:#ddecbd; +--mono:ui-monospace,SFMono-Regular,Consolas,monospace +} +* { +box-sizing:border-box +} +html { +scroll-behavior:smooth; +scroll-padding-top:24px +} +body { +margin:0; +background:var(--bg); +color:var(--ink); +font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; +line-height:1.75 +} +a { +color:inherit; +text-underline-offset:5px +} +button,a { +touch-action:manipulation +} +button { +cursor:pointer; +font:inherit +} +a:focus-visible,button:focus-visible,summary:focus-visible { +outline:3px solid #347bce; +outline-offset:5px +} +button:disabled { +cursor:not-allowed; +opacity:.45 +} +.wrap { +max-width:1160px; +margin:auto; +padding-inline:32px +} +.nav { +display:flex; +justify-content:space-between; +align-items:center; +min-height:100px; +border-bottom:1px solid var(--line); +gap:24px +} +.brand { +display:flex; +gap:10px; +align-items:center; +font-size:21px; +font-weight:750; +text-decoration:none; +letter-spacing:-.6px +} +.tag { +font:10px var(--mono); +border:1px solid var(--line); +padding:5px; +letter-spacing:1px; +margin-left:5px +} +nav { +display:flex; +gap:25px; +font-size:14px +} +nav a { +text-decoration:none +} +nav a:hover { +text-decoration:underline +} +.hero { +padding-block:82px 60px +} +.eyebrow,.number { +font:11px var(--mono); +letter-spacing:1.5px; +color:var(--muted) +} +h1 { +font-size:clamp(42px,6.1vw,76px); +line-height:1.24; +letter-spacing:-3px; +font-weight:650; +margin:26px 0 +} +em { +font-style:normal; +color:var(--green); +background:linear-gradient(transparent 77%,var(--accent) 77%) +} +.intro { +font-size:19px; +color:var(--muted); +max-width:680px; +line-height:1.9 +} +.actions { +display:flex; +gap:12px; +flex-wrap:wrap; +margin-top:30px +} +.button { +display:inline-flex; +align-items:center; +justify-content:center; +gap:32px; +min-height:48px; +padding:11px 22px; +background:var(--green); +color:white; +text-decoration:none; +border:1px solid var(--green); +border-radius:5px; +font-size:14px; +font-weight:600 +} +.button:hover { +filter:brightness(1.12) +} +.secondary { +background:transparent; +color:var(--green); +border-color:var(--line) +} +.hero-note { +font:12px var(--mono); +color:var(--muted); +margin:24px 0 48px +} +.bridge { +display:grid; +grid-template-columns:1fr 50px 1fr 50px 1fr; +align-items:center; +padding:30px; +background:#f0f3eb; +border:1px solid var(--line); +border-radius:8px +} +.bridge div { +display:flex; +flex-direction:column; +gap:7px +} +.bridge small { +font:10px var(--mono); +letter-spacing:1.5px; +color:var(--muted) +} +.bridge strong { +font-size:20px +} +.bridge span:not(.arrow) { +font-size:12px; +color:var(--muted) +} +.bridge-core { +padding-left:24px; +border-left:3px solid #6a8e50 +} +.arrow { +font:26px var(--mono); +color:#698573 +} +.agent-line { +text-align:center; +color:var(--muted); +font:11px var(--mono); +margin-top:17px +} +.band { +background:#eef2e9; +border-block:1px solid var(--line) +} +.reasons { +display:grid; +grid-template-columns:repeat(3,1fr); +gap:34px; +padding-block:44px +} +.reasons h2 { +font-size:19px; +margin:17px 0 10px +} +.reasons p,p { +color:var(--muted); +font-size:15px +} +.section { +padding-block:72px +} +.section h2 { +font-size:34px; +line-height:1.4; +letter-spacing:-1px; +margin:15px 0 28px +} +.section-heading { +display:flex; +justify-content:space-between; +gap:30px; +align-items:center +} +.section-heading p { +font-size:12px +} +.console { +border:1px solid var(--line); +border-radius:9px; +overflow:hidden; +background:#fff +} +.console-bar { +background:#f3f5f0; +padding:14px 22px; +border-bottom:1px solid var(--line); +display:flex; +justify-content:space-between; +gap:15px; +font:12px var(--mono) +} +.dot { +display:inline-block; +width:7px; +height:7px; +background:#367d53; +border-radius:50%; +margin-right:6px +} +.text-button { +border:0; +background:none; +color:var(--muted); +font:12px var(--mono); +padding:3px +} +.demo-body { +display:grid; +grid-template-columns:320px 1fr +} +.demo-controls { +padding:30px; +border-right:1px solid var(--line) +} +h3 { +font-size:19px; +margin:16px 0 12px +} +.demo-controls p { +font-size:13px; +margin-bottom:24px +} +.demo-controls button { +width:100%; +display:flex; +justify-content:space-between; +text-align:left; +padding:12px; +margin:8px 0; +border:1px solid var(--line); +border-radius:4px; +background:var(--bg); +font-size:13px; +color:var(--ink) +} +.demo-controls button:enabled:hover { +background:var(--accent) +} +code { +font:11px var(--mono) +} +.demo-footnote { +font-size:11px; +color:var(--muted); +margin-top:22px +} +.demo-output { +padding:30px; +min-width:0 +} +.metrics { +display:grid; +grid-template-columns:repeat(3,1fr); +gap:15px +} +.metrics span { +font-size:12px; +color:var(--muted) +} +.metrics strong { +display:block; +font:36px var(--mono); +margin-top:8px +} +.state { +padding:14px; +background:#f0f4e8; +font-size:13px; +border-radius:4px +} +.timeline { +padding:0; +list-style:none; +max-height:260px; +overflow:auto +} +.timeline li { +font-size:13px; +padding:12px 0; +border-bottom:1px solid var(--line) +} +.timeline time { +font:10px var(--mono); +color:var(--muted); +margin-right:12px +} +.timeline .empty { +color:var(--muted) +} +summary { +cursor:pointer; +color:var(--muted); +font-size:12px; +padding:12px 0 +} +pre { +white-space:pre-wrap; +overflow-wrap:anywhere; +font:12px/1.8 var(--mono); +background:#f2f5ef; +padding:18px; +border-radius:4px; +max-width:100% +} +.agent-demo { +display:grid; +grid-template-columns:1fr 1.25fr; +gap:30px; +border-top:1px solid var(--line); +padding:25px 30px +} +.agent-demo h3 { +margin-top:8px +} +.agent-demo p { +font-size:12px +} +.agent-demo pre { +min-height:100px; +max-height:260px; +overflow:auto +} +.split { +display:grid; +grid-template-columns:1fr 1fr; +gap:50px +} +.guide { +border-top:2px solid var(--green); +padding-top:22px; +min-width:0 +} +.guide .muted { +font-size:12px +} +.link-list { +display:flex; +flex-direction:column; +gap:15px; +margin-top:28px; +font-size:14px +} +.small { +font-size:17px +} +.agent-card { +background:var(--bg); +padding:30px; +border:1px solid var(--line); +border-radius:6px +} +.agent-card dt { +font:15px var(--mono); +margin-top:20px +} +.agent-card dd { +margin:5px 0; +color:var(--muted); +font-size:13px +} +.agent-card p { +border-top:1px solid var(--line); +padding-top:17px; +font-size:12px +} +.closing { +border-top:1px solid var(--line); +margin-top:50px; +padding-top:25px +} +.closing a { +display:inline-block; +margin:10px 28px 0 0; +font-size:14px +} +.footer { +border-top:1px solid var(--line); +display:flex; +justify-content:space-between; +gap:24px; +padding-block:26px; +font:11px/1.8 var(--mono); +color:var(--muted) +} +.skip { +position:absolute; +top:-100px; +left:15px; +background:#fff; +z-index:2; +padding:10px +} +.skip:focus { +top:10px +} +@media(max-width:800px) { +.wrap { +padding-inline:22px +} +.nav { +flex-wrap:wrap; +gap:15px; +padding-block:20px +} +.tag { +display:none +} +nav { +gap:18px; +font-size:12px; +flex-wrap:wrap +} +.hero { +padding-top:48px +} +h1 { +letter-spacing:-2px +} +.intro { +font-size:16px +} +.bridge { +grid-template-columns:1fr; +padding:24px; +gap:12px +} +.bridge div { +gap:3px +} +.bridge-core { +padding:15px 0 15px 15px +} +.arrow { +transform:rotate(90deg); +width:25px +} +.agent-line { +font-size:10px; +line-height:1.8 +} +.reasons,.split,.agent-demo { +grid-template-columns:1fr; +gap:28px +} +.reasons { +padding-block:32px +} +.section { +padding-block:48px +} +.section h2 { +font-size:28px +} +.section-heading { +display:block +} +.demo-body { +grid-template-columns:1fr +} +.demo-controls { +border-right:0; +border-bottom:1px solid var(--line); +padding:22px +} +.demo-output { +padding:22px +} +.metrics strong { +font-size:30px +} +.console-bar { +padding-inline:15px; +font-size:11px +} +.agent-demo { +padding:22px +} +.footer { +flex-direction:column; +gap:10px +} +.hero-note { +line-height:1.8 +} + +} +@media(prefers-reduced-motion:reduce) { +html { +scroll-behavior:auto +} + +} + diff --git a/test/site/check.py b/test/site/check.py new file mode 100644 index 0000000..6a85fa4 --- /dev/null +++ b/test/site/check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Offline validation for the public-only static artifact and documentation links.""" +from html.parser import HTMLParser +from pathlib import Path +import re +from urllib.parse import unquote, urlsplit + +ROOT = Path(__file__).resolve().parents[2] + + +class Page(HTMLParser): + def __init__(self): + super().__init__() + self.ids = set() + self.links = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if 'id' in attrs: + assert attrs['id'] not in self.ids, f'duplicate ID {attrs["id"]}' + self.ids.add(attrs['id']) + for key in ('href', 'src'): + if key in attrs: + self.links.append(attrs[key]) + + +for name in ('index.html', 'collection.html'): + file = ROOT / 'site' / name + page = Page() + page.feed(file.read_text()) + for link in page.links: + url = urlsplit(link) + if url.scheme or url.netloc: + assert url.scheme == 'https', f'insecure external link: {link}' + continue + # collection.html is published one level above the project assets. + path = url.path.removeprefix('alertbridge/') if name == 'collection.html' else url.path + target = file.parent / unquote(path) + if path: + assert target.exists(), f'missing site asset: {link}' + if not path and url.fragment: + assert url.fragment in page.ids, f'missing anchor: {link}' + +script = (ROOT / 'site/demo.js').read_text() +for forbidden in ('fetch(', 'XMLHttpRequest', 'WebSocket(', 'localStorage', 'sessionStorage', 'innerHTML', 'sendBeacon'): + assert forbidden not in script, f'demo must remain local-only: {forbidden}' + +# Validate local Markdown references in all changed/new public documentation. +for file in [ROOT / n for n in ('README.md', 'README.en.md', 'CONTRIBUTING.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'docs/dots-example.md', 'docs/showcase-deployment.md', 'docs/opensource-review.md', 'examples/python/README.md')]: + for link in re.findall(r'\]\(([^)]+)\)', file.read_text()): + url = urlsplit(link) + if url.scheme or url.netloc or not url.path: + continue + assert (file.parent / unquote(url.path)).exists(), f'{file}: broken link {link}' +print('Static assets, anchors, documentation links and demo isolation checks passed.') diff --git a/test/site/test_example.py b/test/site/test_example.py new file mode 100644 index 0000000..b3bd9a4 --- /dev/null +++ b/test/site/test_example.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify the public Python example's wire signature and redirect boundary.""" +import hashlib +import hmac +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class ExampleTest(unittest.TestCase): + def test_signed_bytes_and_no_redirect(self): + received = [] + response_code = [202] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers['Content-Length'])) + received.append((self.path, self.headers, body)) + self.send_response(response_code[0]) + if response_code[0] == 307: + self.send_header('Location', '/must-not-follow') + self.end_headers() + self.wfile.write(b'{"outcome":"queued"}') + + def log_message(self, *args): + pass + + server = HTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as folder: + secret = Path(folder) / 'secret' + secret.write_text('example-only-secret\n') + secret.chmod(0o600) + env = {**os.environ, 'ALERTBRIDGE_URL': f'http://127.0.0.1:{server.server_port}', 'ALERTBRIDGE_CLIENT_ID': 'example-client', 'ALERTBRIDGE_ROUTING_KEY': 'ops', 'ALERTBRIDGE_CLIENT_SECRET_FILE': str(secret)} + command = [sys.executable, str(ROOT / 'examples/python/send_event.py'), '--status', 'firing'] + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + path, headers, body = received[-1] + self.assertEqual(path, '/api/v1/events') + signed = f'{headers["X-Notify-Timestamp"]}\n{headers["X-Notify-Nonce"]}\n{hashlib.sha256(body).hexdigest()}'.encode() + self.assertEqual(headers['X-Notify-Signature'], hmac.new(b'example-only-secret', signed, hashlib.sha256).hexdigest()) + self.assertEqual(json.loads(body)['status'], 'firing') + response_code[0] = 307 + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(received), 2, 'must not forward authentication through redirects') + secret.chmod(0o644) + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(received), 2, 'must reject unsafe secret before connecting') + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +if __name__ == '__main__': + unittest.main() From 574cc10916723908e8c1cc7fa7a5894c59efce1d Mon Sep 17 00:00:00 2001 From: TedXiong Date: Sat, 17 Oct 2026 19:31:01 +0800 Subject: [PATCH 2/2] docs: link project collection and emit static asset checksums --- docs/showcase-deployment.md | 2 +- scripts/build-site.sh | 3 +++ site/index.html | 1 + test/site/check.py | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/showcase-deployment.md b/docs/showcase-deployment.md index ef8263c..9a5df9f 100644 --- a/docs/showcase-deployment.md +++ b/docs/showcase-deployment.md @@ -1,6 +1,6 @@ # 个人开源集合与 AlertBridge 官网部署 -目标域名:`opensource.tedxiong.com`。集合首页 `/`,AlertBridge 项目页及模拟 Demo `/alertbridge/`。源码 `site/`,生成目录 `dist-site/`,无 Node、构建依赖、CDN、外部字体或数据服务。 +目标域名:`opensource.tedxiong.com`。集合首页 `/`,AlertBridge 项目页及模拟 Demo `/alertbridge/`。源码 `site/`,生成目录 `dist-site/`(含 `SHA256SUMS` 逐文件校验清单),无 Node、构建依赖、CDN、外部字体或数据服务。 ## 本地预览与打包 diff --git a/scripts/build-site.sh b/scripts/build-site.sh index 744fc3a..bcc2735 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -6,6 +6,7 @@ python3 test/site/check.py python3 - <<'PY' from pathlib import Path import shutil +import hashlib out = Path('dist-site') # This directory is exclusively generated; fail on a symlink to protect other paths. if out.is_symlink(): @@ -17,5 +18,7 @@ for name in ('index.html', 'style.css', 'demo.js', 'mark.svg'): shutil.copyfile(Path('site') / name, out / 'alertbridge' / name) shutil.copyfile('site/collection.html', out / 'index.html') (out / '.nojekyll').touch() +entries = [f'{hashlib.sha256(p.read_bytes()).hexdigest()} {p.relative_to(out).as_posix()}' for p in sorted(out.rglob('*')) if p.is_file()] +(out / 'SHA256SUMS').write_text('\n'.join(entries) + '\n') print('Built dist-site/: collection at /, AlertBridge at /alertbridge/') PY diff --git a/site/index.html b/site/index.html index 3e361f1..13f26a4 100644 --- a/site/index.html +++ b/site/index.html @@ -17,6 +17,7 @@ AlertBridgeOPEN SOURCE