diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..c728eeb --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy Foundry Core +on: + push: + branches: [main] + paths: + - 'cmd/**' + - 'internal/**' + - 'go.mod' + - 'go.sum' + - 'deploy/**' + - '.gitea/workflows/deploy.yml' + +concurrency: + group: foundry-core-main-deploy + cancel-in-progress: false + +jobs: + deploy: + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' + runs-on: ces-control + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Verify and build + run: | + set -eu + test -z "$(gofmt -l cmd internal)" + go test -mod=readonly ./... + CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Deploy and verify OAuth entry + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + run: bash deploy/release.sh bin/foundry diff --git a/README.md b/README.md index 447473c..740d98e 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,15 @@ 1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https:///auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。 2. 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。 3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。 -4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署时运行 `foundry` 二进制,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 +4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署工作流仅处理 `main` 上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。 +## Actions 部署配置 + +在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。 + +Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401、Google 授权跳转参数和 HTTPS 就绪。完整登录还需在浏览器用 Google 测试账号走完回调、兑换和会话验证。 + ## 前端接入 1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。 diff --git a/deploy/release.sh b/deploy/release.sh new file mode 100644 index 0000000..0d9633d --- /dev/null +++ b/deploy/release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +base=/opt/endoleap-infra +foundry=$base/foundry +origin=https://api.foundry.entroia.com +candidate=foundry-core-candidate +network=endoleap-dev_default + +for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET FOUNDRY_PROJECTS; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi +done +test -s "$foundry/database.env" +docker network inspect "$network" >/dev/null + +release=$(mktemp -d "$foundry/release.XXXXXXXX") +install -m 755 "$1" "$release/foundry" +export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +docker run -d --name "$candidate" --network "$network" \ + --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ + --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 128m \ + --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ + node:24-bookworm-slim /opt/foundry >/dev/null + +cleanup_candidate() { + docker rm -f "${live:-$candidate}" >/dev/null 2>&1 || true +} +trap cleanup_candidate EXIT + +docker exec "$candidate" node -e ' + const assert = require("node:assert/strict"); + async function verify() { + const base = "http://127.0.0.1:8080"; + for (let attempt = 0; ; attempt++) { + try { + const ready = await fetch(base + "/readyz", {signal: AbortSignal.timeout(3000)}); + if (ready.ok) break; + } catch (error) { + if (attempt === 9) throw error; + } + assert.ok(attempt < 9); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; + const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; + assert.equal((await fetch(route + "/me")).status, 401); + const challenge = require("node:crypto").randomBytes(32).toString("base64url"); + const start = await fetch(route + "/google?challenge=" + challenge, {redirect: "manual"}); + assert.ok([302, 303].includes(start.status)); + const location = new URL(start.headers.get("location")); + assert.equal(location.hostname, "accounts.google.com"); + assert.equal(location.searchParams.get("client_id"), process.env.GOOGLE_CLIENT_ID); + assert.equal(location.searchParams.get("redirect_uri"), process.env.PUBLIC_ORIGIN + "/auth/google/callback"); + } + verify().catch(() => { console.error("Foundry readiness or OAuth entry check failed"); process.exit(1); }); +' + +# Keep the previous running container until the new candidate has passed checks. +# Traefik reads the route atomically from its watched config directory. +id=$(docker inspect -f '{{.Id}}' "$candidate") +live=foundry-core-${id:0:12} +docker rename "$candidate" "$live" +docker update --restart unless-stopped "$live" >/dev/null +bash "$(dirname "$0")/route.sh" "$live" +trap - EXIT +printf 'Foundry readiness, unauthenticated rejection and Google redirect verified.\n' diff --git a/deploy/route.sh b/deploy/route.sh new file mode 100644 index 0000000..5a7c29b --- /dev/null +++ b/deploy/route.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +cd /opt/endoleap-infra +live=$1 +[[ "$live" =~ ^foundry-core-[a-f0-9]{12}$ ]] + +install -d -m 700 dev/foundry-edge +edge=dev/foundry-edge +route=$edge/foundry.yml +previous= +if [[ -f "$route" ]]; then + previous=$(sed -n 's@.*url: http://\(foundry-core-[a-f0-9]\{12\}\):8080.*@\1@p' "$route") +fi +backup=$(mktemp) +if [[ -f "$route" ]]; then cp "$route" "$backup"; fi +rollback() { + if [[ -s "$backup" ]]; then + cp "$backup" "$edge/.restore" + mv "$edge/.restore" "$route" + else + rm -f "$route" + fi + rm -f "$backup" +} +trap rollback EXIT + +touch "$edge/acme.json" +chmod 600 "$edge/acme.json" +cat > "$edge/.foundry.yml" < foundry-edge.yml <<'YAML' +services: + traefik: + command: + - --entrypoints.web.address=:8080 + - --entrypoints.websecure.address=:8443 + - --ping=true + - --ping.entrypoint=web + - --api.dashboard=false + - --providers.file.directory=/edge + - --certificatesresolvers.public.acme.storage=/edge/acme.json + - --certificatesresolvers.public.acme.tlschallenge=true + ports: + - "443:8443" + volumes: + - ./dev/foundry-edge:/edge +YAML +docker compose -p endoleap-dev --env-file dev/env -f compose.yml -f dev.yml -f foundry-edge.yml up -d --no-deps traefik + +curl --fail --silent --show-error --output /dev/null --max-time 6 \ + --retry 15 --retry-delay 4 --retry-all-errors \ + --resolve api.foundry.entroia.com:443:127.0.0.1 \ + https://api.foundry.entroia.com/readyz + +trap - EXIT +rm -f "$backup" +if [[ "$previous" =~ ^foundry-core-[a-f0-9]{12}$ && "$previous" != "$live" ]]; then + docker stop "$previous" >/dev/null || printf 'Previous container needs manual cleanup.\n' >&2 +fi