diff --git a/.github/workflows/infra-plan.yml b/.github/workflows/infra-plan.yml index f09f8ff..57af57f 100644 --- a/.github/workflows/infra-plan.yml +++ b/.github/workflows/infra-plan.yml @@ -7,13 +7,23 @@ on: - config/environments/** - scripts/infra/** - .github/workflows/infra-plan.yml + workflow_dispatch: + inputs: + environment: + required: true + type: choice + options: [development, production] + stack: + required: true + type: choice + options: [bootstrap, platform] permissions: contents: read jobs: plan: - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository permissions: contents: read id-token: write @@ -41,8 +51,24 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Select requested plan + id: selected + env: + EVENT_NAME: ${{ github.event_name }} + REQUESTED_ENVIRONMENT: ${{ inputs.environment }} + REQUESTED_STACK: ${{ inputs.stack }} + MATRIX_ENVIRONMENT: ${{ matrix.environment }} + MATRIX_STACK: ${{ matrix.stack }} + run: | + if [[ "$EVENT_NAME" != workflow_dispatch ]] || [[ "$MATRIX_ENVIRONMENT" == "$REQUESTED_ENVIRONMENT" && "$MATRIX_STACK" == "$REQUESTED_STACK" ]]; then + echo 'selected=true' >> "$GITHUB_OUTPUT" + else + echo 'selected=false' >> "$GITHUB_OUTPUT" + echo 'Plan matrix entry skipped because it was not selected by workflow_dispatch.' >> "$GITHUB_STEP_SUMMARY" + fi - name: Check plan identity configuration id: configured + if: steps.selected.outputs.selected == 'true' env: WIF_PROVIDER: ${{ vars[matrix.provider] }} PLAN_SERVICE_ACCOUNT: ${{ vars[matrix.service_account] }} @@ -54,27 +80,29 @@ jobs: echo 'Plan skipped because required repository variables are absent.' >> "$GITHUB_STEP_SUMMARY" fi - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 + if: steps.selected.outputs.selected == 'true' with: tofu_version: 1.12.1 - name: Validate infrastructure without credentials + if: steps.selected.outputs.selected == 'true' run: | sudo apt-get update sudo apt-get install --yes ripgrep shellcheck scripts/infra/validate.sh - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 - if: steps.configured.outputs.ready == 'true' + if: steps.selected.outputs.selected == 'true' && steps.configured.outputs.ready == 'true' with: workload_identity_provider: ${{ vars[matrix.provider] }} service_account: ${{ vars[matrix.service_account] }} - name: Create read-only plan - if: steps.configured.outputs.ready == 'true' + if: steps.selected.outputs.selected == 'true' && steps.configured.outputs.ready == 'true' env: ENV: ${{ matrix.environment }} STACK: ${{ matrix.stack }} PLAN_OUTPUT: ${{ runner.temp }}/plan.txt run: make infra-plan ENV="$ENV" STACK="$STACK" - name: Add plan result to summary - if: steps.configured.outputs.ready == 'true' + if: steps.selected.outputs.selected == 'true' && steps.configured.outputs.ready == 'true' env: PLAN_OUTPUT: ${{ runner.temp }}/plan.txt run: | @@ -84,7 +112,7 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: steps.configured.outputs.ready == 'true' + if: steps.selected.outputs.selected == 'true' && steps.configured.outputs.ready == 'true' with: name: plan-${{ matrix.environment }}-${{ matrix.stack }} path: ${{ runner.temp }}/plan.txt diff --git a/Makefile b/Makefile index ca7cfed..528c928 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile for Proxy Builder (Sing-box) # Sing-box 原生模式代理服务管理 -.PHONY: all uuid short-id password reality-key secrets-init secrets-publish subscription-url user-add user-enable user-disable user-rotate github-reset github-configure github-audit bootstrap validate infra-plan infra-apply deploy destroy help +.PHONY: all uuid short-id password reality-key secrets-init secrets-import-production secrets-publish subscription-url user-add user-enable user-disable user-rotate github-reset github-configure github-clean-production-legacy github-audit bootstrap validate infra-plan infra-apply deploy destroy help help: @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -14,6 +14,7 @@ help: @echo " make password - Generate a random secure password" @echo " make reality-key - Generate REALITY key pair (uses Docker)" @echo " make secrets-init ENV=... USER=..." + @echo " make secrets-import-production" @echo " make user-add|user-enable|user-disable|user-rotate ENV=... USER=..." @echo " make secrets-publish ENV=..." @echo " make subscription-url ENV=... USER=... [FORMAT=base64|clash]" @@ -22,6 +23,7 @@ help: @echo " make bootstrap ENV=development|production" @echo " make github-reset ENV=development CONFIRM=owner/repo:development" @echo " make github-configure ENV=development|production" + @echo " make github-clean-production-legacy CONFIRM=owner/repo:production" @echo " make github-audit ENV=development|production" @echo " make validate" @echo " make infra-plan ENV=... [STACK=bootstrap|platform]" @@ -50,6 +52,9 @@ reality-key: secrets-init: @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/init.sh +secrets-import-production: + @./scripts/secrets/import-production.sh + user-add: @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh add @@ -78,6 +83,9 @@ bootstrap: github-configure: @./scripts/github/configure.sh --environment "$(ENV)" +github-clean-production-legacy: + @./scripts/github/clear-legacy-production-secrets.sh --confirm "$(CONFIRM)" + github-reset: @./scripts/github/reset-environment.sh --environment "$(ENV)" --confirm "$(CONFIRM)" diff --git a/cmd/proxyctl/environment.go b/cmd/proxyctl/environment.go index 4e593ee..eff5e99 100644 --- a/cmd/proxyctl/environment.go +++ b/cmd/proxyctl/environment.go @@ -45,25 +45,6 @@ func initEnvironment(args []string) error { if *outputDirectory == "" || *sni == "" || *name == "" || flags.NArg() != 0 { return errors.New("init-environment requires --output-dir, --sni and --user") } - if _, err := os.Lstat(*outputDirectory); !errors.Is(err, os.ErrNotExist) { - if err == nil { - return errors.New("secret output directory already exists") - } - return errors.New("secret output directory cannot be inspected") - } - if err := os.MkdirAll(filepath.Dir(*outputDirectory), 0o700); err != nil { - return errors.New("secret output parent cannot be created") - } - if err := os.Mkdir(*outputDirectory, 0o700); err != nil { - return errors.New("secret output directory cannot be created") - } - committed := false - defer func() { - if !committed { - _ = os.RemoveAll(*outputDirectory) - } - }() - user, err := newUser(*name) if err != nil { return err @@ -91,15 +72,36 @@ func initEnvironment(args []string) error { if _, err := contracts.InspectCertificate(certificate, certificateKey, *sni, time.Now()); err != nil { return err } - files := map[string][]byte{ + return writeSecretBundle(*outputDirectory, map[string][]byte{ "users.json": append(usersJSON, '\n'), "reality-private-key": []byte(realityPrivateKey), "obfs-password": []byte(obfsPassword), "hysteria2.crt": certificate, "hysteria2.key": certificateKey, + }) +} + +func writeSecretBundle(outputDirectory string, files map[string][]byte) error { + if _, err := os.Lstat(outputDirectory); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return errors.New("secret output directory already exists") + } + return errors.New("secret output directory cannot be inspected") + } + if err := os.MkdirAll(filepath.Dir(outputDirectory), 0o700); err != nil { + return errors.New("secret output parent cannot be created") } + if err := os.Mkdir(outputDirectory, 0o700); err != nil { + return errors.New("secret output directory cannot be created") + } + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(outputDirectory) + } + }() for filename, data := range files { - if err := writeExclusive(filepath.Join(*outputDirectory, filename), data); err != nil { + if err := writeExclusive(filepath.Join(outputDirectory, filename), data); err != nil { return err } } diff --git a/cmd/proxyctl/environment_test.go b/cmd/proxyctl/environment_test.go index 335731d..b378580 100644 --- a/cmd/proxyctl/environment_test.go +++ b/cmd/proxyctl/environment_test.go @@ -54,6 +54,74 @@ func TestInitEnvironmentCreatesValidatedPrivateBundle(t *testing.T) { } } +func TestImportLegacyProductionPreservesIdentitiesAndRotatesHY2(t *testing.T) { + directory := t.TempDir() + legacySeed := filepath.Join(directory, "legacy-seed") + if err := initEnvironment([]string{"--output-dir", legacySeed, "--sni", "www.example.com", "--user", "seed"}); err != nil { + t.Fatal(err) + } + realityPrivateKey, err := os.ReadFile(filepath.Join(legacySeed, "reality-private-key")) + if err != nil { + t.Fatal(err) + } + obfsPassword, err := os.ReadFile(filepath.Join(legacySeed, "obfs-password")) + if err != nil { + t.Fatal(err) + } + legacyEnvironment := filepath.Join(directory, ".env.production") + if err := os.WriteFile(legacyEnvironment, append(append([]byte("REALITY_PRIVATE_KEY="), realityPrivateKey...), append([]byte("\nOBFS_PASSWORD="), obfsPassword...)...), 0o600); err != nil { + t.Fatal(err) + } + legacyUsers := filepath.Join(directory, "users.production.json") + const legacyPassword = "legacy-hy2" + const legacyToken = "subscription-token-canary-123456789" + if err := os.WriteFile(legacyUsers, []byte(`[ + {"name":"KYSON","vless_uuid":"00000000-0000-4000-8000-000000000001","hy2_password":"legacy-hy2","sub_token":"subscription-token-canary-123456789"}, + {"name":"LMY","vless_uuid":"00000000-0000-4000-8000-000000000002","hy2_password":"legacy-hy2-two","sub_token":"subscription-token-canary-987654321"} +]`), 0o600); err != nil { + t.Fatal(err) + } + output := filepath.Join(directory, "production") + if err := importLegacyProduction([]string{"--legacy-users", legacyUsers, "--legacy-env", legacyEnvironment, "--output-dir", output, "--sni", "www.example.com", "--rename-user", "KYSON=USA"}); err != nil { + t.Fatal(err) + } + users := readUsersForTest(t, filepath.Join(output, "users.json")) + if len(users.Users) != 2 || users.Users[0].Name != "USA" || !users.Users[0].Enabled || users.Users[0].VLESSUUID != "00000000-0000-4000-8000-000000000001" || users.Users[0].SubscriptionToken != legacyToken { + t.Fatalf("legacy identity was not preserved: %#v", users.Users[0]) + } + if users.Users[0].HY2Password == legacyPassword || len(users.Users[0].HY2Password) < 24 { + t.Fatal("legacy HY2 password was not securely rotated") + } + importedReality, _ := os.ReadFile(filepath.Join(output, "reality-private-key")) + importedObfs, _ := os.ReadFile(filepath.Join(output, "obfs-password")) + if string(importedReality) != string(realityPrivateKey) || string(importedObfs) != string(obfsPassword) { + t.Fatal("legacy environment credentials were not preserved") + } + certPEM, _ := os.ReadFile(filepath.Join(output, "hysteria2.crt")) + keyPEM, _ := os.ReadFile(filepath.Join(output, "hysteria2.key")) + if _, err := contracts.InspectCertificate(certPEM, keyPEM, "www.example.com", time.Now()); err != nil { + t.Fatal(err) + } + if err := importLegacyProduction([]string{"--legacy-users", legacyUsers, "--legacy-env", legacyEnvironment, "--output-dir", output, "--sni", "www.example.com", "--rename-user", "KYSON=USA"}); err == nil { + t.Fatal("production import overwrote an existing bundle") + } +} + +func TestImportLegacyProductionRejectsUnsafeInputs(t *testing.T) { + directory := t.TempDir() + legacyUsers := filepath.Join(directory, "users.production.json") + if err := os.WriteFile(legacyUsers, []byte(`[{"name":"KYSON","vless_uuid":"00000000-0000-4000-8000-000000000001","hy2_password":"legacy-hy2","sub_token":"subscription-token-canary-123456789"}]`), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(directory, "users-link.json") + if err := os.Symlink(legacyUsers, link); err != nil { + t.Fatal(err) + } + if err := importLegacyProduction([]string{"--legacy-users", link, "--legacy-env", filepath.Join(directory, "missing.env"), "--output-dir", filepath.Join(directory, "production"), "--sni", "www.example.com", "--rename-user", "KYSON=USA"}); err == nil { + t.Fatal("production import accepted a symbolic-link users input") + } +} + func TestUserLifecycleUsesImmediateCredentialRotation(t *testing.T) { directory := filepath.Join(t.TempDir(), "development") if err := initEnvironment([]string{"--output-dir", directory, "--sni", "www.example.com", "--user", "alice"}); err != nil { diff --git a/cmd/proxyctl/legacy_production.go b/cmd/proxyctl/legacy_production.go new file mode 100644 index 0000000..180d2ac --- /dev/null +++ b/cmd/proxyctl/legacy_production.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/kyson-dev/proxy-builder/internal/contracts" +) + +type legacyProductionUser struct { + Name *string `json:"name"` + VLESSUUID *string `json:"vless_uuid"` + HY2Password *string `json:"hy2_password"` + Token *string `json:"sub_token"` +} + +func importLegacyProduction(args []string) error { + flags := flag.NewFlagSet("import-legacy-production", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + legacyUsersPath := flags.String("legacy-users", "", "legacy users JSON path") + legacyEnvironmentPath := flags.String("legacy-env", "", "legacy environment file path") + outputDirectory := flags.String("output-dir", "", "new secret directory") + sni := flags.String("sni", "", "HY2 certificate SNI") + renameUser := flags.String("rename-user", "", "legacy_name=new_name") + if err := flags.Parse(args); err != nil { + return errors.New("import-legacy-production arguments are invalid") + } + if *legacyUsersPath == "" || *legacyEnvironmentPath == "" || *outputDirectory == "" || *sni == "" || *renameUser == "" || flags.NArg() != 0 { + return errors.New("import-legacy-production requires --legacy-users, --legacy-env, --output-dir, --sni and --rename-user") + } + oldName, newName, found := strings.Cut(*renameUser, "=") + if !found || oldName == "" || newName == "" || strings.Contains(newName, "=") { + return errors.New("rename-user must be legacy_name=new_name") + } + + legacyUsers, err := readLegacyProductionUsers(*legacyUsersPath) + if err != nil { + return err + } + users := make([]contracts.User, 0, len(legacyUsers)) + renamed := 0 + for index, legacy := range legacyUsers { + if legacy.Name == nil || legacy.VLESSUUID == nil || legacy.HY2Password == nil || legacy.Token == nil { + return fmt.Errorf("legacy users[%d] has a required field missing", index) + } + name := *legacy.Name + if name == oldName { + name = newName + renamed++ + } + user, err := newUser(name) + if err != nil { + return err + } + user.VLESSUUID = *legacy.VLESSUUID + user.SubscriptionToken = *legacy.Token + users = append(users, user) + } + if renamed != 1 { + return errors.New("legacy user rename must match exactly one user") + } + document := contracts.UsersDocument{Version: 1, Users: users} + if err := document.Validate(); err != nil { + return err + } + usersJSON, err := json.MarshalIndent(document, "", " ") + if err != nil { + return errors.New("users document cannot be encoded") + } + + legacyEnvironment, err := readLegacyEnvironment(*legacyEnvironmentPath) + if err != nil { + return err + } + realityPrivateKey, ok := legacyEnvironment["REALITY_PRIVATE_KEY"] + if !ok || realityPrivateKey == "" { + return errors.New("legacy environment is missing REALITY_PRIVATE_KEY") + } + obfsPassword, ok := legacyEnvironment["OBFS_PASSWORD"] + if !ok || obfsPassword == "" { + return errors.New("legacy environment is missing OBFS_PASSWORD") + } + if _, err := contracts.DeriveReality(realityPrivateKey); err != nil { + return fmt.Errorf("legacy Reality private key: %w", err) + } + if len([]byte(obfsPassword)) < 24 { + return errors.New("legacy obfs password must be at least 24 UTF-8 bytes") + } + certificate, certificateKey, err := generateCertificate(*sni, time.Now().UTC()) + if err != nil { + return err + } + if _, err := contracts.InspectCertificate(certificate, certificateKey, *sni, time.Now()); err != nil { + return err + } + return writeSecretBundle(*outputDirectory, map[string][]byte{ + "users.json": append(usersJSON, '\n'), + "reality-private-key": []byte(realityPrivateKey), + "obfs-password": []byte(obfsPassword), + "hysteria2.crt": certificate, + "hysteria2.key": certificateKey, + }) +} + +func readLegacyProductionUsers(path string) ([]legacyProductionUser, error) { + data, err := readRegularFile(path, "legacy users input") + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var users []legacyProductionUser + if err := decoder.Decode(&users); err != nil { + return nil, errors.New("legacy users input is invalid") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errors.New("legacy users input contains a trailing JSON value") + } + if len(users) == 0 { + return nil, errors.New("legacy users input must contain at least one user") + } + return users, nil +} + +func readLegacyEnvironment(path string) (map[string]string, error) { + data, err := readRegularFile(path, "legacy environment input") + if err != nil { + return nil, err + } + values := make(map[string]string) + for lineNumber, line := range strings.Split(string(data), "\n") { + line = strings.TrimSuffix(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found || key == "" || strings.TrimSpace(key) != key { + return nil, fmt.Errorf("legacy environment input is invalid at line %d", lineNumber+1) + } + if _, exists := values[key]; exists { + return nil, fmt.Errorf("legacy environment input duplicates %s", key) + } + values[key] = value + } + return values, nil +} + +func readRegularFile(path, role string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%s must be a regular file", role) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%s cannot be read", role) + } + return data, nil +} diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index d6f9386..d4a56fa 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -36,6 +36,8 @@ func run(args []string) error { return inspectEnvironment(args[1:]) case "init-environment": return initEnvironment(args[1:]) + case "import-legacy-production": + return importLegacyProduction(args[1:]) case "add-user": return mutateUser(args[1:], userMutationAdd) case "enable-user": diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e9348a4..c685cf0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -61,7 +61,7 @@ VM startup metadata 只负责幂等主机供给。应用部署生成不可变 re - `development` 已迁移到专用的 `kyson-proxy-dev` Project,并以 `proxy-*` 资源名完成正式发布和双协议 E2E。 - `development` 的 bootstrap/platform plan 当前为零变更,GitHub 配置审计通过。 -- `production` 资源与秘密尚未激活,任何写操作仍需显式审批。 +- `production` 目标为独立的 `kyson-proxy-prod` Project;资源与秘密尚未激活,任何写操作仍需显式审批。 - Cloud Run request log 按精确服务名排除,避免 query token 被平台日志持久化;应用仅记录脱敏事件。 改变这些边界的原因见 [ADR-0001](../adr/0001-manage-gcp-with-opentofu.md)、[ADR-0002](../adr/0002-separate-proxy-and-subscription-runtimes.md) 和 [ADR-0003](../adr/0003-own-and-deliver-application-secrets.md)。 diff --git a/docs/design/environments-and-delivery.md b/docs/design/environments-and-delivery.md index 6b1a94b..108cea8 100644 --- a/docs/design/environments-and-delivery.md +++ b/docs/design/environments-and-delivery.md @@ -2,7 +2,7 @@ 本文唯一拥有环境配置归属、GitHub Variables/Secrets 名称、工作流接口以及 VM 与 Cloud Run 的发布协议。 -**实现状态:** development 已在 `kyson-proxy-dev` 配置 GitHub/GCP,并通过正式部署、协调回滚与双协议 E2E;production 尚未激活。 +**实现状态:** development 已在 `kyson-proxy-dev` 配置 GitHub/GCP,并通过正式部署、协调回滚与双协议 E2E;production 目标为 `kyson-proxy-prod`,尚未激活。 ## 环境模型 @@ -82,7 +82,7 @@ Project ID、region、zone、VM 名、Artifact Registry 名、公钥、short ID ### `infra-plan.yml` -- 触发:PR 修改 `infra/**`、环境清单或 provider lock file。 +- 触发:PR 修改 `infra/**`、环境清单或 provider lock file;也可手动选择一个 environment/stack。 - 对 development 与 production 分别执行 format、validate、静态安全检查和只读 plan。 - 使用 `github-plan`;不得引用 GitHub Environment Secrets。 - 输出经过脱敏的 plan artifact 和 PR summary,不得输出 state 或 secret payload。 @@ -158,8 +158,10 @@ Secret version 创建成功但 revision 失败时允许保留该 version 供审 ```text make bootstrap ENV=development|production make secrets-init ENV= USER= +make secrets-import-production make github-reset ENV=development CONFIRM=:development make github-configure ENV= +make github-clean-production-legacy CONFIRM=:production make github-audit ENV= make secrets-publish ENV= make user-add|user-enable|user-disable|user-rotate ENV= USER= @@ -171,10 +173,12 @@ make deploy ENV= [GIT_REF=] make destroy ENV= STACK=platform ``` -命令只作为稳定入口;空 development 的首次激活见 [Runbook](../runbooks/development-first-activation.md),迁移到新的 GCP Project 且复用秘密时见 [项目迁移 Runbook](../runbooks/development-project-migration.md)。 +命令只作为稳定入口;空 development 的首次激活见 [Runbook](../runbooks/development-first-activation.md),迁移到新的 GCP Project 且复用秘密时见 [项目迁移 Runbook](../runbooks/development-project-migration.md),production 首次激活见 [production Runbook](../runbooks/production-first-activation.md)。 `secrets-init` 只在本地新建五项随机秘密,不发布或调用 GCP;已有目录时拒绝覆盖。用户变更也只改本地 `users.json`,必须随后显式执行 `secrets-publish` 和 `deploy`。`subscription-url` 从 platform state 读取公开服务 URL,从本地文件读取已启用用户 token,并只向终端输出最终 URL。 +`secrets-import-production` 只接受仓库根目录中受限的旧 production 输入,生成 `.secrets/production/`;它保留 UUID、subscription token、Reality 私钥和 obfs password,重置 HY2 password 与证书,并把 `KYSON` 显示名改为 `USA`。该命令不访问 GitHub/GCP,且拒绝覆盖已有 bundle。 + 就绪工具中,`proxyctl inspect-environment` 校验五项秘密且只输出公开派生值;`validate-subscription` 严格验证 Base64/Clash;`render-probe-config` 只为部署期真实 E2E 生成临时 sing-box 客户端配置。GitHub 的配置、只读保护审计和五项秘密发布分别由 `configure.sh`、`audit.sh` 与 `publish-secrets.sh` 拥有。 ## 关联 diff --git a/docs/design/infrastructure-and-identity.md b/docs/design/infrastructure-and-identity.md index 53d4149..72fc7a6 100644 --- a/docs/design/infrastructure-and-identity.md +++ b/docs/design/infrastructure-and-identity.md @@ -2,7 +2,7 @@ 本文唯一拥有 OpenTofu 目录、stack/state 划分、模块接口、GCP 资源拓扑以及 IAM/WIF 边界。 -**实现状态:** development 已在 `kyson-proxy-dev` apply、部署并通过零变更 plan;production 尚未激活 +**实现状态:** development 已在 `kyson-proxy-dev` apply、部署并通过零变更 plan;production 目标为 `kyson-proxy-prod`,尚未激活。 ## 目录接口 @@ -72,6 +72,8 @@ labels = { application = "proxy-builder", environment = "development 名称包含 `_secret`、`password`、`private_key`、`token` 或证书内容的变量禁止进入 OpenTofu 输入。 +`existing_static_ip_name` 是可选的 production 迁入接口。未设置时 network 创建并管理 `${resource_prefix}-ipv4`;设置时只读取同项目、同区域的现有静态 IPv4 并将 VM 指向它,不把该地址纳入 OpenTofu state。 + ## Stack 所有权 ### bootstrap @@ -89,7 +91,7 @@ labels = { application = "proxy-builder", environment = "development 拥有: -- 专用 VPC、子网、静态外部 IPv4和防火墙; +- 专用 VPC、子网、静态外部 IPv4 和防火墙; - Artifact Registry repository; - proxy VM 与 runtime Service Account; - `proxy-users`、`obfs-password` Secret Manager 容器与 Cloud Run runtime Service Account; diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md index c4202bd..f35ea24 100644 --- a/docs/runbooks/README.md +++ b/docs/runbooks/README.md @@ -5,6 +5,7 @@ | 需求 | 文档 | | --- | --- | | 首次创建并激活 development 新平台 | [development 首次激活](development-first-activation.md) | +| 首次创建并激活独立 production 平台 | [production 首次激活](production-first-activation.md) | | 迁移 development 到新的 GCP Project 并复用秘密 | [development 项目迁移](development-project-migration.md) | | 新增、启停或轮换 development 用户 | [development 用户管理](development-user-management.md) | | 保留 bootstrap 与秘密,重建 development platform | [development platform 重建](development-platform-rebuild.md) | diff --git a/docs/runbooks/production-first-activation.md b/docs/runbooks/production-first-activation.md new file mode 100644 index 0000000..a92cf84 --- /dev/null +++ b/docs/runbooks/production-first-activation.md @@ -0,0 +1,82 @@ +# Production 首次激活 + +**使用时机:** 将新架构首次部署到空的 `kyson-proxy-prod`,旧 `kyson-proxy-builder` 保持运行。 +**风险等级:** 高 +**最后复核:** 2026-08-24 + +## 前置条件 + +- [ ] 实现 PR 已通过验证并合入 `main`;在 `main` 记录待部署 commit SHA。 +- [ ] `kyson-proxy-prod` 为 active 且 billing 已启用;本地 GCP Owner、`gh auth status`、OpenTofu、Go、Docker、jq、ShellCheck 和 Actionlint 可用。 +- [ ] 根目录 `.env.production` 与 `users.production.json` 是常规文件;其中 `KYSON` 是唯一待重命名用户。 +- [ ] 已知这次导入保留 UUID、subscription token、Reality 与 obfs,但生成新 HY2 password/证书;客户端将在最后导入新的订阅 URL。 + +禁止删除或修改 `kyson-proxy-builder`、旧 VM 或旧 IP。首次 platform 使用新建 `proxy-ipv4`。 + +## 步骤 + +1. 本地创建并校验新 production bundle: + + ```bash + make secrets-import-production + make validate + ``` + + 预期:`.secrets/production/` 新建五个 `0600` 文件;`KYSON` 变为 `USA`,其他用户、UUID 和 token 未变;终端不输出秘密。 + +2. 创建 state bucket,先以本地 Owner plan/apply bootstrap: + + ```bash + make bootstrap ENV=production + make infra-plan ENV=production STACK=bootstrap + make infra-apply ENV=production STACK=bootstrap + ``` + + 预期:bucket 为 `kyson-proxy-prod-proxy-builder-tfstate` 且开启 versioning;bootstrap 创建 production WIF 与三种 GitHub 身份。 + +3. 配置 GitHub、替换应用秘密并启用 production gate: + + ```bash + make github-configure ENV=production + make secrets-publish ENV=production + make github-clean-production-legacy CONFIRM=kyson-dev/proxy-builder:production + make github-audit ENV=production + ./scripts/github/configure.sh --environment production --enable-production + ``` + + 预期:Environment 只保留五项新架构应用 Secret;production 仅允许 `main`,且需要 `kysonzou` 审批。旧 VM 不读取 GitHub Environment Secret,不受本步骤影响。 + +4. 在 GitHub Actions 的 `main` 手动运行 `Infrastructure apply`,选择 `production/bootstrap`,完成 Environment 审批后确认零变更。再手动运行 `Infrastructure plan`,选择 `production/platform`,核对只会在 `kyson-proxy-prod` 创建 `proxy-*` 资源和新的 `proxy-ipv4`。 + +5. 审批并运行 `Infrastructure apply` 的 `production/platform`。随后手动运行 `Deploy application`,选择 `production` 和第 1 步记录的 `main` commit SHA。 + +6. 等待 deploy workflow 的 health、Base64/Clash 和 VLESS/Hysteria2 E2E 全部成功。执行: + + ```bash + make infra-plan ENV=production STACK=platform + make subscription-url ENV=production USER=USA FORMAT=base64 + ``` + + 预期:platform 为零变更;把终端输出的新 URL 导入客户端。该 URL 含 token,不得写入日志、工单或 shell history。 + +## 后续旧 IP 接管 + +仅在新 URL 已验证并安排维护窗口后执行。先从旧 VM 解绑 `ip4`,等待 regional address 可移动所需时间,再将其移动到 `kyson-proxy-prod`。提交 `existing_static_ip_name = "ip4"`,通过 PR 或手动 plan 审核后 apply platform;该 apply 将 VM 指向迁入地址,并释放临时 `proxy-ipv4`。移动后的地址由 GCP 外部流程管理,OpenTofu 只引用它。 + +## 失败处理 + +- bundle、bootstrap 或 platform 失败:停止,不要操作旧项目;修复后重试对应阶段。 +- GitHub audit 失败:不要启用 production gate;先恢复 Variables/Secrets 的精确集合。 +- deploy 返回 `20`:两侧已回滚,检查脱敏诊断后重试;返回 `21`:停止所有写操作并人工检查 VM 与 Cloud Run。 +- 新订阅或 E2E 失败:不处理旧 IP,继续使用旧服务;确认新平台恢复后才重新发布。 + +## 成功标准 + +- `kyson-proxy-prod` 的 bootstrap/platform 均可由 GitHub WIF 收敛,platform 第二次 plan 为零变更。 +- Cloud Run 公开 health、token 认证、两种订阅格式和两个真实代理协议均通过。 +- `.secrets/production/` 保留为受限灾难恢复副本,旧项目仍未被修改。 + +## 背景 + +- Design:[环境与交付](../design/environments-and-delivery.md)、[基础设施与身份](../design/infrastructure-and-identity.md) +- ADR:[ADR-0001](../adr/0001-manage-gcp-with-opentofu.md)、[ADR-0003](../adr/0003-own-and-deliver-application-secrets.md) diff --git a/infra/environments/production.tfvars b/infra/environments/production.tfvars index d7eb138..eecbfd4 100644 --- a/infra/environments/production.tfvars +++ b/infra/environments/production.tfvars @@ -1,5 +1,5 @@ environment = "production" -project_id = "kyson-proxy-builder" +project_id = "kyson-proxy-prod" region = "us-west1" zone = "us-west1-b" resource_prefix = "proxy" diff --git a/infra/modules/network/main.tf b/infra/modules/network/main.tf index 4e697fe..0df1ac8 100644 --- a/infra/modules/network/main.tf +++ b/infra/modules/network/main.tf @@ -19,7 +19,17 @@ resource "google_compute_subnetwork" "proxy" { private_ip_google_access = true } +data "google_compute_address" "existing" { + count = var.existing_static_ip_name == null ? 0 : 1 + + project = var.project_id + name = var.existing_static_ip_name + region = var.region +} + resource "google_compute_address" "proxy" { + count = var.existing_static_ip_name == null ? 1 : 0 + project = var.project_id name = "${local.name_prefix}-ipv4" region = var.region diff --git a/infra/modules/network/outputs.tf b/infra/modules/network/outputs.tf index 96e117e..e6bda2f 100644 --- a/infra/modules/network/outputs.tf +++ b/infra/modules/network/outputs.tf @@ -7,7 +7,7 @@ output "subnetwork_self_link" { } output "proxy_ip_address" { - value = google_compute_address.proxy.address + value = var.existing_static_ip_name == null ? google_compute_address.proxy[0].address : data.google_compute_address.existing[0].address } output "proxy_network_tag" { diff --git a/infra/modules/network/variables.tf b/infra/modules/network/variables.tf index 900131c..85bc626 100644 --- a/infra/modules/network/variables.tf +++ b/infra/modules/network/variables.tf @@ -21,3 +21,14 @@ variable "network_cidr" { variable "network_tier" { type = string } + +variable "existing_static_ip_name" { + type = string + default = null + nullable = true + + validation { + condition = var.existing_static_ip_name == null || can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.existing_static_ip_name)) + error_message = "existing_static_ip_name 必须是有效的现有静态 IP 资源名称。" + } +} diff --git a/infra/stacks/platform/main.tf b/infra/stacks/platform/main.tf index 7988af3..6c5c2c5 100644 --- a/infra/stacks/platform/main.tf +++ b/infra/stacks/platform/main.tf @@ -15,12 +15,13 @@ data "terraform_remote_state" "bootstrap" { module "network" { source = "../../modules/network" - project_id = var.project_id - environment = var.environment - resource_prefix = var.resource_prefix - region = var.region - network_cidr = var.network_cidr - network_tier = var.network_tier + project_id = var.project_id + environment = var.environment + resource_prefix = var.resource_prefix + region = var.region + network_cidr = var.network_cidr + network_tier = var.network_tier + existing_static_ip_name = var.existing_static_ip_name } module "artifact_registry" { diff --git a/infra/stacks/platform/tests/contracts.tftest.hcl b/infra/stacks/platform/tests/contracts.tftest.hcl index 4ad65d6..744312b 100644 --- a/infra/stacks/platform/tests/contracts.tftest.hcl +++ b/infra/stacks/platform/tests/contracts.tftest.hcl @@ -1,4 +1,16 @@ mock_provider "google" { + mock_data "google_compute_address" { + defaults = { + address = "198.51.100.20" + } + } + + mock_resource "google_compute_address" { + defaults = { + address = "198.51.100.10" + } + } + mock_resource "google_service_account" { defaults = { email = "mocksa@example-project.iam.gserviceaccount.com" @@ -58,3 +70,16 @@ run "platform_contract" { error_message = "platform 必须公开从完整 resource_prefix 派生的 subscription 请求日志排除名称。" } } + +run "platform_uses_supplied_static_ip" { + command = plan + + variables { + existing_static_ip_name = "legacy-ipv4" + } + + assert { + condition = output.proxy_ip_address == "198.51.100.20" + error_message = "指定 existing_static_ip_name 时 VM 必须使用读取到的静态 IP。" + } +} diff --git a/infra/stacks/platform/variables.tf b/infra/stacks/platform/variables.tf index 963e0a3..5da70e1 100644 --- a/infra/stacks/platform/variables.tf +++ b/infra/stacks/platform/variables.tf @@ -56,6 +56,17 @@ variable "network_tier" { } } +variable "existing_static_ip_name" { + type = string + default = null + nullable = true + + validation { + condition = var.existing_static_ip_name == null || can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.existing_static_ip_name)) + error_message = "existing_static_ip_name 必须是有效的现有静态 IP 资源名称。" + } +} + variable "vm_machine_type" { type = string } diff --git a/scripts/ci/test-workflow-guards.sh b/scripts/ci/test-workflow-guards.sh index 2dc9709..961d62b 100755 --- a/scripts/ci/test-workflow-guards.sh +++ b/scripts/ci/test-workflow-guards.sh @@ -6,6 +6,9 @@ workflow_root="${repo_root}/.github/workflows" rg -q 'head.repo.full_name == github.repository' "${workflow_root}/infra-plan.yml" rg -q 'required repository variables are absent' "${workflow_root}/infra-plan.yml" +rg -q 'workflow_dispatch:' "${workflow_root}/infra-plan.yml" +rg -q 'Select requested plan' "${workflow_root}/infra-plan.yml" +rg -q 'REQUESTED_ENVIRONMENT' "${workflow_root}/infra-plan.yml" rg -q 'PRODUCTION_OPERATIONS_ENABLED' "${workflow_root}/infra-apply.yml" "${workflow_root}/deploy.yml" "${workflow_root}/destroy.yml" rg -q 'confirm_project_id' "${workflow_root}/destroy.yml" if rg -q 'upload-artifact' "${workflow_root}/deploy.yml"; then diff --git a/scripts/github/clear-legacy-production-secrets.sh b/scripts/github/clear-legacy-production-secrets.sh new file mode 100755 index 0000000..b93d626 --- /dev/null +++ b/scripts/github/clear-legacy-production-secrets.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +confirm="" +while (($#)); do + case "$1" in + --confirm) confirm="${2:-}"; shift 2 ;; + *) printf '%s\n' 'usage: clear-legacy-production-secrets.sh --confirm :production' >&2; exit 2 ;; + esac +done + +command -v gh >/dev/null 2>&1 || { printf '%s\n' 'missing command: gh' >&2; exit 1; } +repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +[[ "$confirm" == "${repo}:production" ]] || { printf '%s\n' 'production legacy secret confirmation does not match repository'; exit 2; } + +for secret_name in \ + GCP_AR_LOCATION GCP_AR_REPOSITORY GCP_PROJECT_ID GCP_SERVICE_ACCOUNT GCP_VM_NAME GCP_VM_ZONE GCP_WORKLOAD_IDENTITY_PROVIDER \ + HY2_SNI REALITY_DEST REALITY_PUBLIC_KEY REALITY_SHORT_ID USERS_JSON; do + gh secret delete "$secret_name" --repo "$repo" --env production 2>/dev/null || true +done +printf '%s\n' 'legacy production environment secrets removed; application secrets were not changed' diff --git a/scripts/infra/test-guards.sh b/scripts/infra/test-guards.sh index e6cd6c3..34d1412 100755 --- a/scripts/infra/test-guards.sh +++ b/scripts/infra/test-guards.sh @@ -33,6 +33,7 @@ development_project=$(infra::read_tfvar "$development_file" project_id) production_project=$(infra::read_tfvar "$production_file" project_id) [[ "$development_project" != "$production_project" ]] || infra::die "两个环境不能共用 GCP Project" [[ "$development_project" == "kyson-proxy-dev" ]] || infra::die "development 必须使用 kyson-proxy-dev Project" +[[ "$production_project" == "kyson-proxy-prod" ]] || infra::die "production 必须使用 kyson-proxy-prod Project" [[ "$(infra::read_tfvar "$development_file" resource_prefix)" == "proxy" ]] || infra::die "development 必须使用 proxy 完整资源名前缀" [[ "$(infra::read_tfvar "$production_file" resource_prefix)" == "proxy" ]] || infra::die "production 必须使用 proxy 完整资源名前缀" diff --git a/scripts/secrets/import-production.sh b/scripts/secrets/import-production.sh new file mode 100755 index 0000000..55ab26f --- /dev/null +++ b/scripts/secrets/import-production.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +legacy_users="${repo_root}/users.production.json" +legacy_environment="${repo_root}/.env.production" +config="${repo_root}/config/environments/production.json" +output_directory="${repo_root}/.secrets/production" + +for command_name in go jq; do + command -v "$command_name" >/dev/null 2>&1 || { printf 'missing command: %s\n' "$command_name" >&2; exit 1; } +done +for input in "$legacy_users" "$legacy_environment" "$config"; do + [[ -f "$input" && ! -L "$input" ]] || { printf '%s\n' 'production import inputs must be regular files'; exit 2; } +done + +sni="$(jq -er '.hy2_sni' "$config")" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/proxy-builder-production-import.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +(cd "$repo_root" && go build -trimpath -o "${work_dir}/proxyctl" ./cmd/proxyctl) +"${work_dir}/proxyctl" import-legacy-production \ + --legacy-users "$legacy_users" \ + --legacy-env "$legacy_environment" \ + --output-dir "$output_directory" \ + --sni "$sni" \ + --rename-user 'KYSON=USA' +printf '%s\n' 'production secret bundle imported; no secret was published'