diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 146945d..abf8b84 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: - tofu_version: 1.12.1 + tofu_version: 1.12.6 - name: Read platform deployment context id: platform env: diff --git a/.github/workflows/destroy.yml b/.github/workflows/destroy.yml index ff98fca..a2d95e2 100644 --- a/.github/workflows/destroy.yml +++ b/.github/workflows/destroy.yml @@ -43,7 +43,6 @@ jobs: service_account: ${{ vars.GCP_APPLY_SERVICE_ACCOUNT }} - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: - tofu_version: 1.12.1 + tofu_version: 1.12.6 - name: Destroy platform only run: make destroy ENV="${{ inputs.environment }}" STACK=platform CONFIRM_PROJECT_ID="${{ inputs.confirm_project_id }}" - diff --git a/.github/workflows/infra-apply.yml b/.github/workflows/infra-apply.yml index ef69bf8..3af4e0b 100644 --- a/.github/workflows/infra-apply.yml +++ b/.github/workflows/infra-apply.yml @@ -39,7 +39,6 @@ jobs: service_account: ${{ vars.GCP_APPLY_SERVICE_ACCOUNT }} - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: - tofu_version: 1.12.1 + tofu_version: 1.12.6 - name: Regenerate plan and apply run: make infra-apply ENV="${{ inputs.environment }}" STACK="${{ inputs.stack }}" - diff --git a/.github/workflows/infra-plan.yml b/.github/workflows/infra-plan.yml index 57af57f..222cab8 100644 --- a/.github/workflows/infra-plan.yml +++ b/.github/workflows/infra-plan.yml @@ -82,7 +82,7 @@ jobs: - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 if: steps.selected.outputs.selected == 'true' with: - tofu_version: 1.12.1 + tofu_version: 1.12.6 - name: Validate infrastructure without credentials if: steps.selected.outputs.selected == 'true' run: | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1c51ae4..e7cbd2b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: - tofu_version: 1.12.1 + tofu_version: 1.12.6 - name: Install validation tools run: | sudo apt-get update diff --git a/Makefile b/Makefile index 528c928..a3ea6d7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,11 @@ # Makefile for Proxy Builder (Sing-box) # Sing-box 原生模式代理服务管理 -.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 +.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 user-protocol-enable user-protocol-disable github-reset github-configure github-clean-production-legacy github-audit bootstrap validate infra-plan infra-apply deploy destroy help + +# Accept short operator-facing aliases while keeping canonical names everywhere +# that forms part of a path, Terraform state, or GitHub Environment identity. +CANONICAL_ENV = $(if $(filter dev,$(ENV)),development,$(if $(filter prod,$(ENV)),production,$(ENV))) help: @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -13,23 +17,25 @@ help: @echo " make short-id - Generate a random 8-character hex ID" @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 " ENV accepts dev|development or prod|production" + @echo " make secrets-init ENV=dev|prod 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]" + @echo " make user-add|user-enable|user-disable|user-rotate ENV=dev|prod USER=..." + @echo " make user-protocol-enable|user-protocol-disable ENV=dev|prod USER=... PROTOCOL=vless|hysteria2" + @echo " make secrets-publish ENV=dev|prod" + @echo " make subscription-url ENV=dev|prod USER=... [FORMAT=base64|clash]" @echo "" @echo "🏗️ OpenTofu Platform:" - @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 bootstrap ENV=dev|prod" + @echo " make github-reset ENV=dev CONFIRM=owner/repo:development" + @echo " make github-configure ENV=dev|prod" @echo " make github-clean-production-legacy CONFIRM=owner/repo:production" - @echo " make github-audit ENV=development|production" + @echo " make github-audit ENV=dev|prod" @echo " make validate" - @echo " make infra-plan ENV=... [STACK=bootstrap|platform]" - @echo " make infra-apply ENV=... [STACK=bootstrap|platform]" - @echo " make deploy ENV=... [GIT_REF=]" - @echo " make destroy ENV=... STACK=platform" + @echo " make infra-plan ENV=dev|prod [STACK=bootstrap|platform]" + @echo " make infra-apply ENV=dev|prod [STACK=bootstrap|platform]" + @echo " make deploy ENV=dev|prod [GIT_REF=]" + @echo " make destroy ENV=dev|prod STACK=platform" # ============================================================ @@ -50,59 +56,65 @@ reality-key: @docker run --rm ghcr.io/sagernet/sing-box generate reality-keypair secrets-init: - @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/init.sh + @ENV="$(CANONICAL_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 + @ENV="$(CANONICAL_ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh add user-enable: - @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh enable + @ENV="$(CANONICAL_ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh enable user-disable: - @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh disable + @ENV="$(CANONICAL_ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh disable user-rotate: - @ENV="$(ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh rotate + @ENV="$(CANONICAL_ENV)" USER="$(USER)" ./scripts/secrets/manage-user.sh rotate + +user-protocol-enable: + @ENV="$(CANONICAL_ENV)" USER="$(USER)" PROTOCOL="$(PROTOCOL)" ./scripts/secrets/manage-user.sh protocol-enable + +user-protocol-disable: + @ENV="$(CANONICAL_ENV)" USER="$(USER)" PROTOCOL="$(PROTOCOL)" ./scripts/secrets/manage-user.sh protocol-disable secrets-publish: - @./scripts/github/publish-secrets.sh --environment "$(ENV)" --secret-dir ".secrets/$(ENV)" + @./scripts/github/publish-secrets.sh --environment "$(CANONICAL_ENV)" --secret-dir ".secrets/$(CANONICAL_ENV)" subscription-url: - @ENV="$(ENV)" USER="$(USER)" FORMAT="$(or $(FORMAT),base64)" ./scripts/secrets/subscription-url.sh + @ENV="$(CANONICAL_ENV)" USER="$(USER)" FORMAT="$(or $(FORMAT),base64)" ./scripts/secrets/subscription-url.sh # ============================================================ # OpenTofu Platform # ============================================================ bootstrap: - @ENV="$(ENV)" ./scripts/infra/bootstrap-state.sh + @ENV="$(CANONICAL_ENV)" ./scripts/infra/bootstrap-state.sh github-configure: - @./scripts/github/configure.sh --environment "$(ENV)" + @./scripts/github/configure.sh --environment "$(CANONICAL_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)" + @./scripts/github/reset-environment.sh --environment "$(CANONICAL_ENV)" --confirm "$(CONFIRM)" github-audit: - @./scripts/github/audit.sh --environment "$(ENV)" + @./scripts/github/audit.sh --environment "$(CANONICAL_ENV)" validate: @./scripts/validate.sh infra-plan: - @ENV="$(ENV)" STACK="$(or $(STACK),platform)" ./scripts/infra/tofu-stack.sh plan + @ENV="$(CANONICAL_ENV)" STACK="$(or $(STACK),platform)" ./scripts/infra/tofu-stack.sh plan infra-apply: - @ENV="$(ENV)" STACK="$(or $(STACK),platform)" ./scripts/infra/tofu-stack.sh apply + @ENV="$(CANONICAL_ENV)" STACK="$(or $(STACK),platform)" ./scripts/infra/tofu-stack.sh apply deploy: - @ENV="$(ENV)" GIT_REF="$(GIT_REF)" ./scripts/github/dispatch-deploy.sh + @ENV="$(CANONICAL_ENV)" GIT_REF="$(GIT_REF)" ./scripts/github/dispatch-deploy.sh destroy: - @ENV="$(ENV)" CONFIRM_PROJECT_ID="$(CONFIRM_PROJECT_ID)" ./scripts/infra/destroy-platform.sh + @ENV="$(CANONICAL_ENV)" CONFIRM_PROJECT_ID="$(CONFIRM_PROJECT_ID)" ./scripts/infra/destroy-platform.sh diff --git a/cmd/proxyctl/environment.go b/cmd/proxyctl/environment.go index eff5e99..0d27a36 100644 --- a/cmd/proxyctl/environment.go +++ b/cmd/proxyctl/environment.go @@ -31,6 +31,8 @@ const ( userMutationEnable userMutationDisable userMutationRotate + userMutationEnableProtocol + userMutationDisableProtocol ) func initEnvironment(args []string) error { @@ -113,17 +115,22 @@ func mutateUser(args []string, mutation userMutation) error { command := map[userMutation]string{ userMutationAdd: "add-user", userMutationEnable: "enable-user", userMutationDisable: "disable-user", userMutationRotate: "rotate-user", + userMutationEnableProtocol: "enable-user-protocol", userMutationDisableProtocol: "disable-user-protocol", }[mutation] flags := flag.NewFlagSet(command, flag.ContinueOnError) flags.SetOutput(os.Stderr) usersPath := flags.String("users", "", "users JSON path") name := flags.String("name", "", "user name") + protocol := flags.String("protocol", "", "vless or hysteria2") if err := flags.Parse(args); err != nil { return fmt.Errorf("%s arguments are invalid", command) } if *usersPath == "" || *name == "" || flags.NArg() != 0 { return fmt.Errorf("%s requires --users and --name", command) } + if (mutation == userMutationEnableProtocol || mutation == userMutationDisableProtocol) && *protocol != "vless" && *protocol != "hysteria2" { + return fmt.Errorf("%s requires --protocol=vless|hysteria2", command) + } info, err := os.Lstat(*usersPath) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return errors.New("users input must be a regular file") @@ -153,7 +160,7 @@ func mutateUser(args []string, mutation userMutation) error { return err } document.Users = append(document.Users, user) - case userMutationEnable, userMutationDisable, userMutationRotate: + case userMutationEnable, userMutationDisable, userMutationRotate, userMutationEnableProtocol, userMutationDisableProtocol: if index < 0 { return errors.New("user name does not exist") } @@ -161,13 +168,18 @@ func mutateUser(args []string, mutation userMutation) error { document.Users[index].Enabled = true } else if mutation == userMutationDisable { document.Users[index].Enabled = false - } else { + } else if mutation == userMutationRotate { rotated, err := newUser(document.Users[index].Name) if err != nil { return err } rotated.Enabled = document.Users[index].Enabled + rotated.Protocols = document.Users[index].Protocols document.Users[index] = rotated + } else if *protocol == "vless" { + document.Users[index].Protocols.VLESS = mutation == userMutationEnableProtocol + } else { + document.Users[index].Protocols.Hysteria2 = mutation == userMutationEnableProtocol } } if err := document.Validate(); err != nil { @@ -193,7 +205,7 @@ func newUser(name string) (contracts.User, error) { if err != nil { return contracts.User{}, err } - return contracts.User{Name: name, Enabled: true, VLESSUUID: uuid, HY2Password: password, SubscriptionToken: token}, nil + return contracts.User{Name: name, Enabled: true, Protocols: contracts.Protocols{VLESS: true, Hysteria2: true}, VLESSUUID: uuid, HY2Password: password, SubscriptionToken: token}, nil } func randomEncoded(size int) (string, error) { diff --git a/cmd/proxyctl/environment_test.go b/cmd/proxyctl/environment_test.go index b378580..8845cae 100644 --- a/cmd/proxyctl/environment_test.go +++ b/cmd/proxyctl/environment_test.go @@ -28,7 +28,7 @@ func TestInitEnvironmentCreatesValidatedPrivateBundle(t *testing.T) { } usersData, _ := os.ReadFile(filepath.Join(directory, "users.json")) document, err := contracts.ParseUsers(usersData) - if err != nil || len(document.Users) != 1 || document.Users[0].Name != "alice" || !document.Users[0].Enabled { + if err != nil || len(document.Users) != 1 || document.Users[0].Name != "alice" || !document.Users[0].Enabled || !document.Users[0].Protocols.VLESS || !document.Users[0].Protocols.Hysteria2 { t.Fatalf("unexpected users: %#v, err = %v", document, err) } privateKey, _ := readTrimmed(filepath.Join(directory, "reality-private-key"), "Reality private key") @@ -152,6 +152,19 @@ func TestUserLifecycleUsesImmediateCredentialRotation(t *testing.T) { if !readUsersForTest(t, path).Users[1].Enabled { t.Fatal("enable-user left the user disabled") } + if err := mutateUser([]string{"--users", path, "--name", "alice", "--protocol", "hysteria2"}, userMutationDisableProtocol); err != nil { + t.Fatal(err) + } + protocols := readUsersForTest(t, path).Users[0].Protocols + if !protocols.VLESS || protocols.Hysteria2 { + t.Fatalf("disable-user-protocol did not preserve VLESS-only access: %#v", protocols) + } + if err := mutateUser([]string{"--users", path, "--name", "alice", "--protocol", "hysteria2"}, userMutationEnableProtocol); err != nil { + t.Fatal(err) + } + if !readUsersForTest(t, path).Users[0].Protocols.Hysteria2 { + t.Fatal("enable-user-protocol did not restore Hysteria2 access") + } } func TestUserLifecycleRejectsDisablingLastEnabledUserAndSymlink(t *testing.T) { @@ -168,6 +181,12 @@ func TestUserLifecycleRejectsDisablingLastEnabledUserAndSymlink(t *testing.T) { if string(before) != string(after) { t.Fatal("failed mutation changed users.json") } + if err := mutateUser([]string{"--users", path, "--name", "alice", "--protocol", "hysteria2"}, userMutationDisableProtocol); err != nil { + t.Fatal(err) + } + if err := mutateUser([]string{"--users", path, "--name", "alice", "--protocol", "vless"}, userMutationDisableProtocol); err == nil { + t.Fatal("disable-user-protocol accepted disabling the final protocol") + } symlink := filepath.Join(t.TempDir(), "users.json") if err := os.Symlink(path, symlink); err != nil { t.Fatal(err) diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index d4a56fa..4ef68dc 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -46,6 +46,10 @@ func run(args []string) error { return mutateUser(args[1:], userMutationDisable) case "rotate-user": return mutateUser(args[1:], userMutationRotate) + case "enable-user-protocol": + return mutateUser(args[1:], userMutationEnableProtocol) + case "disable-user-protocol": + return mutateUser(args[1:], userMutationDisableProtocol) case "validate-subscription": return validateSubscription(args[1:]) case "render-probe-config": diff --git a/cmd/proxyctl/readiness.go b/cmd/proxyctl/readiness.go index b55701e..e57afe7 100644 --- a/cmd/proxyctl/readiness.go +++ b/cmd/proxyctl/readiness.go @@ -112,26 +112,41 @@ func parseBase64Subscription(data []byte) ([]*url.URL, error) { return nil, errors.New("base64 subscription is not strict standard Base64") } lines := strings.Split(string(decoded), "\n") - if len(lines) != 2 || lines[0] == "" || lines[1] == "" { - return nil, errors.New("base64 subscription must contain exactly two non-empty links") - } - if err := validateSubscriptionURL(lines[0], "vless", map[string]string{ - "encryption": "none", "flow": "xtls-rprx-vision", "fp": "chrome", "headerType": "none", - "pbk": "", "security": "reality", "sid": "", "sni": "", "type": "tcp", - }); err != nil { - return nil, err - } - if err := validateSubscriptionURL(lines[1], "hysteria2", map[string]string{ - "insecure": "1", "obfs": "salamander", "obfs-password": "", "pinSHA256": "", "sni": "", - }); err != nil { - return nil, err + if len(lines) < 1 || len(lines) > 2 { + return nil, errors.New("base64 subscription must contain one or two links") + } + links := make([]*url.URL, 0, len(lines)) + seen := make(map[string]struct{}, len(lines)) + for _, line := range lines { + if line == "" { + return nil, errors.New("base64 subscription contains an empty link") + } + parsed, err := url.Parse(line) + if err != nil { + return nil, errors.New("base64 subscription contains an invalid link") + } + if _, exists := seen[parsed.Scheme]; exists { + return nil, fmt.Errorf("base64 subscription duplicates %s", parsed.Scheme) + } + var expected map[string]string + switch parsed.Scheme { + case "vless": + expected = map[string]string{"encryption": "none", "flow": "xtls-rprx-vision", "fp": "chrome", "headerType": "none", "pbk": "", "security": "reality", "sid": "", "sni": "", "type": "tcp"} + case "hysteria2": + expected = map[string]string{"insecure": "1", "obfs": "salamander", "obfs-password": "", "pinSHA256": "", "sni": ""} + default: + return nil, errors.New("base64 subscription contains an unsupported protocol") + } + if err := validateSubscriptionURL(line, parsed.Scheme, expected); err != nil { + return nil, err + } + seen[parsed.Scheme] = struct{}{} + links = append(links, parsed) } if strings.Contains(string(decoded), "pubKeySHA256") { return nil, errors.New("base64 subscription contains the unsupported pubKeySHA256 parameter") } - vless, _ := url.Parse(lines[0]) - hy2, _ := url.Parse(lines[1]) - return []*url.URL{vless, hy2}, nil + return links, nil } func renderProbeConfig(args []string) error { @@ -155,9 +170,15 @@ func renderProbeConfig(args []string) error { if err != nil { return err } - link := links[0] - if *protocol == "hysteria2" { - link = links[1] + var link *url.URL + for _, candidate := range links { + if candidate.Scheme == *protocol { + link = candidate + break + } + } + if link == nil { + return fmt.Errorf("subscription does not authorize %s", *protocol) } query := link.Query() outbound := map[string]any{ @@ -246,28 +267,42 @@ func validateClashSubscription(data []byte) error { if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return errors.New("Clash subscription contains a trailing YAML document") } - if len(value.Proxies) != 2 || value.Proxies[0].Type != "vless" || value.Proxies[1].Type != "hysteria2" { - return errors.New("Clash subscription must contain one VLESS and one Hysteria2 proxy") + if len(value.Proxies) < 1 || len(value.Proxies) > 2 { + return errors.New("Clash subscription must contain one or two proxies") } + proxyNames := make([]string, 0, len(value.Proxies)+1) + seen := make(map[string]struct{}, len(value.Proxies)) for _, item := range value.Proxies { if item.Name == "" || item.Server == "" || item.Port != 443 { return errors.New("Clash subscription proxy structure is incomplete") } + if _, exists := seen[item.Type]; exists { + return fmt.Errorf("Clash subscription duplicates %s", item.Type) + } + switch item.Type { + case "vless": + if item.UUID == "" || item.Network != "tcp" || !item.TLS || !item.UDP || item.Flow != "xtls-rprx-vision" || item.ServerName == "" || item.ClientFP != "chrome" || len(item.RealityOptions) != 2 || item.RealityOptions["public-key"] == nil || item.RealityOptions["short-id"] == nil { + return errors.New("Clash VLESS proxy structure is incomplete") + } + case "hysteria2": + if item.Password == "" || item.SNI == "" || item.SkipCertVerify || item.Fingerprint == "" || item.Obfs != "salamander" || item.ObfsPassword == "" { + return errors.New("Clash Hysteria2 proxy structure is incomplete") + } + default: + return errors.New("Clash subscription contains an unsupported protocol") + } + seen[item.Type] = struct{}{} + proxyNames = append(proxyNames, item.Name) } - if value.Proxies[0].UUID == "" || value.Proxies[0].Network != "tcp" || !value.Proxies[0].TLS || !value.Proxies[0].UDP || - value.Proxies[0].Flow != "xtls-rprx-vision" || value.Proxies[0].ServerName == "" || value.Proxies[0].ClientFP != "chrome" || - len(value.Proxies[0].RealityOptions) != 2 || value.Proxies[0].RealityOptions["public-key"] == nil || value.Proxies[0].RealityOptions["short-id"] == nil { - return errors.New("Clash VLESS proxy structure is incomplete") - } - if value.Proxies[1].Password == "" || value.Proxies[1].SNI == "" || value.Proxies[1].SkipCertVerify || - value.Proxies[1].Fingerprint == "" || value.Proxies[1].Obfs != "salamander" || value.Proxies[1].ObfsPassword == "" { - return errors.New("Clash Hysteria2 proxy structure is incomplete") - } - if len(value.ProxyGroups) != 1 || value.ProxyGroups[0].Name != "Auto" || value.ProxyGroups[0].Type != "select" || - len(value.ProxyGroups[0].Proxies) != 3 || value.ProxyGroups[0].Proxies[0] != value.Proxies[0].Name || - value.ProxyGroups[0].Proxies[1] != value.Proxies[1].Name || value.ProxyGroups[0].Proxies[2] != "DIRECT" { + proxyNames = append(proxyNames, "DIRECT") + if len(value.ProxyGroups) != 1 || value.ProxyGroups[0].Name != "Auto" || value.ProxyGroups[0].Type != "select" || len(value.ProxyGroups[0].Proxies) != len(proxyNames) { return errors.New("Clash subscription proxy group is invalid") } + for index, name := range proxyNames { + if value.ProxyGroups[0].Proxies[index] != name { + return errors.New("Clash subscription proxy group is invalid") + } + } if len(value.Rules) != 1 || value.Rules[0] != "MATCH,Auto" { return errors.New("Clash subscription rules are invalid") } diff --git a/cmd/proxyctl/readiness_test.go b/cmd/proxyctl/readiness_test.go index 013c8bb..53af29d 100644 --- a/cmd/proxyctl/readiness_test.go +++ b/cmd/proxyctl/readiness_test.go @@ -21,7 +21,7 @@ import ( ) func TestValidateSubscriptionAcceptsRenderedFormats(t *testing.T) { - user := contracts.User{Name: "alice", Enabled: true, VLESSUUID: "00000000-0000-4000-8000-000000000001", HY2Password: "hy2-password-123456789012", SubscriptionToken: "subscription-token-123456"} + user := contracts.User{Name: "alice", Enabled: true, Protocols: contracts.Protocols{VLESS: true, Hysteria2: true}, VLESSUUID: "00000000-0000-4000-8000-000000000001", HY2Password: "hy2-password-123456789012", SubscriptionToken: "subscription-token-123456"} config := subscription.Config{ ProxyIP: "203.0.113.10", RealityPublicKey: "public-key", RealityShortID: "0123456789abcdef", RealitySNI: "example.com", HY2SNI: "example.com", HY2CertSHA256: strings.Repeat("AA:", 31) + "AA", ObfsPassword: "obfs-password-1234567890", @@ -60,7 +60,7 @@ func TestValidateSubscriptionRejectsNonstandardHY2Parameter(t *testing.T) { } func TestRenderProbeConfigUsesSubscriptionCredentials(t *testing.T) { - user := contracts.User{Name: "alice", Enabled: true, VLESSUUID: "00000000-0000-4000-8000-000000000001", HY2Password: "hy2-password-123456789012", SubscriptionToken: "subscription-token-123456"} + user := contracts.User{Name: "alice", Enabled: true, Protocols: contracts.Protocols{VLESS: true, Hysteria2: true}, VLESSUUID: "00000000-0000-4000-8000-000000000001", HY2Password: "hy2-password-123456789012", SubscriptionToken: "subscription-token-123456"} config := subscription.Config{ ProxyIP: "203.0.113.10", RealityPublicKey: "public-key", RealityShortID: "0123456789abcdef", RealitySNI: "www.example.com", HY2SNI: "hy2.example.com", HY2CertSHA256: strings.Repeat("AA:", 31) + "AA", ObfsPassword: "obfs-password-1234567890", diff --git a/config/environments/development.json b/config/environments/development.json index 5195f62..f29cda4 100644 --- a/config/environments/development.json +++ b/config/environments/development.json @@ -2,5 +2,5 @@ "reality_dest": "www.nvidia.com:443", "hy2_sni": "www.bing.com", "egress_probe_url": "https://connectivitycheck.gstatic.com/generate_204", - "sing_box_image": "ghcr.io/sagernet/sing-box@sha256:c8b67944345dc84a226b648a71f854818606eae0813c4e6a452f192ef821b5b8" + "sing_box_image": "ghcr.io/sagernet/sing-box@sha256:78ca1bc9f3b563b0944fd6b1126522ee631e7b44d540b91fc435ba7062ef08b2" } diff --git a/config/environments/production.json b/config/environments/production.json index 5195f62..f29cda4 100644 --- a/config/environments/production.json +++ b/config/environments/production.json @@ -2,5 +2,5 @@ "reality_dest": "www.nvidia.com:443", "hy2_sni": "www.bing.com", "egress_probe_url": "https://connectivitycheck.gstatic.com/generate_204", - "sing_box_image": "ghcr.io/sagernet/sing-box@sha256:c8b67944345dc84a226b648a71f854818606eae0813c4e6a452f192ef821b5b8" + "sing_box_image": "ghcr.io/sagernet/sing-box@sha256:78ca1bc9f3b563b0944fd6b1126522ee631e7b44d540b91fc435ba7062ef08b2" } diff --git a/docs/design/environments-and-delivery.md b/docs/design/environments-and-delivery.md index 108cea8..ba47bf6 100644 --- a/docs/design/environments-and-delivery.md +++ b/docs/design/environments-and-delivery.md @@ -165,6 +165,7 @@ 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= +make user-protocol-enable|user-protocol-disable ENV= USER= PROTOCOL=vless|hysteria2 make subscription-url ENV= USER= [FORMAT=base64|clash] make validate make infra-plan ENV= [STACK=bootstrap|platform] diff --git a/docs/design/proxy-and-subscription-contracts.md b/docs/design/proxy-and-subscription-contracts.md index 1091111..71f6249 100644 --- a/docs/design/proxy-and-subscription-contracts.md +++ b/docs/design/proxy-and-subscription-contracts.md @@ -15,6 +15,7 @@ { "name": "alice", "enabled": true, + "protocols": { "vless": true, "hysteria2": true }, "vless_uuid": "00000000-0000-4000-8000-000000000000", "hy2_password": "high-entropy password", "subscription_token": "high-entropy token" @@ -26,12 +27,13 @@ 规则: - 顶层只允许 `version` 与 `users`;`version` 当前只能为整数 `1`。 -- 用户对象五个字段都必需,不允许未知字段。 +- 用户对象字段均必需且不允许未知字段;为兼容既有 users Secret,缺少整个 `protocols` 对象时等同于两种协议均为 `true`。 +- `protocols.vless` 与 `protocols.hysteria2` 为布尔值,显式提供 `protocols` 时两者都必须存在,且至少一项为 `true`。 - `name` 为 1–64 个 Unicode code point;每个字符必须可打印,且首尾不得为空白。 - `vless_uuid` 必须是规范小写 UUID。 - `hy2_password` 与 `subscription_token` 的 UTF-8 编码至少 24 bytes。 - name、UUID、HY2 password 与 subscription token 在同一环境内分别唯一,包括 disabled 用户。 -- `enabled=false` 的用户不进入 sing-box 配置;匹配其 token 的订阅请求返回 `403`。 +- `enabled=false` 的用户不进入 sing-box 配置;匹配其 token 的订阅请求返回 `403`。已启用用户只会进入其获授权协议的 inbound,订阅也只返回这些协议。 - 至少有一个 enabled 用户才能发布或启动 subscription 服务。 - 解码必须拒绝尾随第二个 JSON value,而不是只读取第一个对象。 @@ -69,6 +71,7 @@ private key 的字符串表示不参与 short ID 计算。实现必须用固定 ```text proxyctl init-environment --output-dir --sni --user proxyctl add-user|enable-user|disable-user|rotate-user --users --name +proxyctl enable-user-protocol|disable-user-protocol --users --name --protocol vless|hysteria2 proxyctl validate-users --input proxyctl validate-release --input proxyctl derive-reality --private-key-file --output diff --git a/docs/runbooks/development-first-activation.md b/docs/runbooks/development-first-activation.md index 11f88b1..76094a3 100644 --- a/docs/runbooks/development-first-activation.md +++ b/docs/runbooks/development-first-activation.md @@ -9,7 +9,7 @@ - [ ] 重构 PR 已通过验证并合入 `main`;记录待部署的 main commit SHA,工作区干净。 - [ ] `gcloud` 活动账号可管理 development Project,ADC 可供 OpenTofu 使用;`gh auth status` 成功。 -- [ ] OpenTofu 1.12.1、Go、Docker、jq、ShellCheck 和 Actionlint 1.7.12 可用。 +- [ ] OpenTofu 1.12.6、Go、Docker、jq、ShellCheck 和 Actionlint 1.7.12 可用。 - [ ] `infra/environments/development.tfvars` 与 `config/environments/development.json` 已复核。 禁止选择 production、设置 `PRODUCTION_OPERATIONS_ENABLED`,或删除 bootstrap/state bucket。任一步结果不符立即停止。 diff --git a/docs/runbooks/development-user-management.md b/docs/runbooks/development-user-management.md index d774580..60f84d0 100644 --- a/docs/runbooks/development-user-management.md +++ b/docs/runbooks/development-user-management.md @@ -16,9 +16,10 @@ ```bash make user-add ENV=development USER= # 或 user-enable、user-disable、user-rotate + make user-protocol-disable ENV=development USER= PROTOCOL=hysteria2 ``` - `rotate` 会立即替换 UUID、HY2 password 和 subscription token,没有重叠窗口;不得禁用最后一个 enabled 用户。 + `rotate` 会立即替换 UUID、HY2 password 和 subscription token,没有重叠窗口,并保留总开关与协议权限;不得禁用最后一个 enabled 用户,也不得关闭某用户最后一个协议。 2. 检查文件权限和变更对象后发布并部署: @@ -37,7 +38,7 @@ ## 成功标准 -- 部署的双协议 E2E 与公网复验成功。 +- 部署会验证每种仍有已启用获授权用户的协议与公网订阅;对所有用户关闭的协议会跳过 E2E。 - 新增/启用用户可用;禁用用户收到 `403`;轮换后旧三项凭据立即失效。 - 本地 `users.json` 与 GitHub Secret 是同一次发布的版本。 diff --git a/infra/environments/production.tfvars b/infra/environments/production.tfvars index eecbfd4..111992b 100644 --- a/infra/environments/production.tfvars +++ b/infra/environments/production.tfvars @@ -6,7 +6,7 @@ resource_prefix = "proxy" network_cidr = "10.30.0.0/24" network_tier = "STANDARD" vm_machine_type = "e2-micro" -vm_boot_disk_gb = 10 +vm_boot_disk_gb = 30 artifact_location = "us-west1" github_repository = "kyson-dev/proxy-builder" github_repository_id = "986343343" diff --git a/infra/stacks/bootstrap/versions.tf b/infra/stacks/bootstrap/versions.tf index 7a12ef1..47b7bbe 100644 --- a/infra/stacks/bootstrap/versions.tf +++ b/infra/stacks/bootstrap/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = "= 1.12.1" + required_version = "= 1.12.6" required_providers { google = { diff --git a/infra/stacks/platform/versions.tf b/infra/stacks/platform/versions.tf index 7a12ef1..47b7bbe 100644 --- a/infra/stacks/platform/versions.tf +++ b/infra/stacks/platform/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = "= 1.12.1" + required_version = "= 1.12.6" required_providers { google = { diff --git a/internal/contracts/singbox.go b/internal/contracts/singbox.go index a0d0afd..5084464 100644 --- a/internal/contracts/singbox.go +++ b/internal/contracts/singbox.go @@ -56,7 +56,7 @@ func RenderSingBox(templateData []byte, users UsersDocument, release Release, pr vlessCount++ vlessUsers := make([]any, 0, len(users.Users)) for _, user := range users.Users { - if user.Enabled { + if user.Enabled && user.Protocols.VLESS { vlessUsers = append(vlessUsers, map[string]any{ "name": user.Name, "uuid": user.VLESSUUID, @@ -86,7 +86,7 @@ func RenderSingBox(templateData []byte, users UsersDocument, release Release, pr hy2Count++ hy2Users := make([]any, 0, len(users.Users)) for _, user := range users.Users { - if user.Enabled { + if user.Enabled && user.Protocols.Hysteria2 { hy2Users = append(hy2Users, map[string]any{ "name": user.Name, "password": user.HY2Password, diff --git a/internal/contracts/singbox_test.go b/internal/contracts/singbox_test.go index 6401eb5..2bf11e6 100644 --- a/internal/contracts/singbox_test.go +++ b/internal/contracts/singbox_test.go @@ -36,3 +36,27 @@ func TestRenderSingBoxRejectsTrailingJSON(t *testing.T) { t.Fatal("RenderSingBox() unexpectedly accepted trailing JSON") } } + +func TestRenderSingBoxFiltersUsersByProtocol(t *testing.T) { + users, err := ParseUsers([]byte(strings.Replace(validUsersJSON, `"enabled": true`, `"enabled": true, "protocols": {"vless": true, "hysteria2": false}`, 1))) + if err != nil { + t.Fatal(err) + } + template := []byte(`{"inbounds":[{"type":"vless","users":[],"tls":{"server_name":"","reality":{"private_key":"","short_id":[],"handshake":{"server":"","server_port":443}}}},{"type":"hysteria2","users":[],"obfs":{"type":"salamander","password":""},"tls":{}}]}`) + output, err := RenderSingBox(template, users, validRelease(), "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo", "obfs-password-123456789012") + if err != nil { + t.Fatal(err) + } + var document struct { + Inbounds []struct { + Type string `json:"type"` + Users []any `json:"users"` + } `json:"inbounds"` + } + if err := json.Unmarshal(output, &document); err != nil { + t.Fatal(err) + } + if len(document.Inbounds) != 2 || len(document.Inbounds[0].Users) != 1 || len(document.Inbounds[1].Users) != 0 { + t.Fatalf("protocol permissions were not reflected in inbounds: %#v", document.Inbounds) + } +} diff --git a/internal/contracts/users.go b/internal/contracts/users.go index d98dd97..f453793 100644 --- a/internal/contracts/users.go +++ b/internal/contracts/users.go @@ -15,11 +15,17 @@ import ( var canonicalUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) type User struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - VLESSUUID string `json:"vless_uuid"` - HY2Password string `json:"hy2_password"` - SubscriptionToken string `json:"subscription_token"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Protocols Protocols `json:"protocols"` + VLESSUUID string `json:"vless_uuid"` + HY2Password string `json:"hy2_password"` + SubscriptionToken string `json:"subscription_token"` +} + +type Protocols struct { + VLESS bool `json:"vless"` + Hysteria2 bool `json:"hysteria2"` } type UsersDocument struct { @@ -29,8 +35,12 @@ type UsersDocument struct { func ParseUsers(data []byte) (UsersDocument, error) { type userWire struct { - Name *string `json:"name"` - Enabled *bool `json:"enabled"` + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + Protocols *struct { + VLESS *bool `json:"vless"` + Hysteria2 *bool `json:"hysteria2"` + } `json:"protocols"` VLESSUUID *string `json:"vless_uuid"` HY2Password *string `json:"hy2_password"` SubscriptionToken *string `json:"subscription_token"` @@ -73,9 +83,20 @@ func ParseUsers(data []byte) (UsersDocument, error) { if user.SubscriptionToken == nil { return UsersDocument{}, fmt.Errorf("%s.subscription_token is required", prefix) } + protocols := Protocols{VLESS: true, Hysteria2: true} + if user.Protocols != nil { + if user.Protocols.VLESS == nil { + return UsersDocument{}, fmt.Errorf("%s.protocols.vless is required", prefix) + } + if user.Protocols.Hysteria2 == nil { + return UsersDocument{}, fmt.Errorf("%s.protocols.hysteria2 is required", prefix) + } + protocols = Protocols{VLESS: *user.Protocols.VLESS, Hysteria2: *user.Protocols.Hysteria2} + } document.Users = append(document.Users, User{ Name: *user.Name, Enabled: *user.Enabled, + Protocols: protocols, VLESSUUID: *user.VLESSUUID, HY2Password: *user.HY2Password, SubscriptionToken: *user.SubscriptionToken, @@ -139,6 +160,9 @@ func (document UsersDocument) Validate() error { if len([]byte(user.SubscriptionToken)) < 24 { return fmt.Errorf("%s.subscription_token must be at least 24 UTF-8 bytes", prefix) } + if !user.Protocols.VLESS && !user.Protocols.Hysteria2 { + return fmt.Errorf("%s.protocols must enable at least one protocol", prefix) + } if err := unique(names, user.Name, prefix+".name"); err != nil { return err diff --git a/internal/contracts/users_test.go b/internal/contracts/users_test.go index da25199..654956e 100644 --- a/internal/contracts/users_test.go +++ b/internal/contracts/users_test.go @@ -34,6 +34,9 @@ func TestParseUsers(t *testing.T) { if len(document.Users) != 2 || !document.Users[0].Enabled || document.Users[1].Enabled { t.Fatalf("unexpected users document: %#v", document) } + if !document.Users[0].Protocols.VLESS || !document.Users[0].Protocols.Hysteria2 { + t.Fatal("legacy users without protocols must default to both protocols") + } } func TestRepositoryUsersExampleUsesV1Schema(t *testing.T) { @@ -58,6 +61,8 @@ func TestParseUsersRejectsInvalidDocuments(t *testing.T) { "uppercase uuid": strings.Replace(validUsersJSON, "00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-00000000000A", 1), "short password": strings.Replace(validUsersJSON, "hy2-password-123456789012", "too-short", 1), "trimmed name": strings.Replace(validUsersJSON, `"name": "alice"`, `"name": " alice"`, 1), + "partial protocols": strings.Replace(validUsersJSON, `"enabled": true`, `"enabled": true, "protocols": {"vless": true}`, 1), + "no protocols": strings.Replace(validUsersJSON, `"enabled": true`, `"enabled": true, "protocols": {"vless": false, "hysteria2": false}`, 1), } for name, input := range tests { t.Run(name, func(t *testing.T) { @@ -67,3 +72,14 @@ func TestParseUsersRejectsInvalidDocuments(t *testing.T) { }) } } + +func TestParseUsersHonorsExplicitProtocolPermissions(t *testing.T) { + input := strings.Replace(validUsersJSON, `"enabled": true`, `"enabled": true, "protocols": {"vless": true, "hysteria2": false}`, 1) + document, err := ParseUsers([]byte(input)) + if err != nil { + t.Fatal(err) + } + if !document.Users[0].Protocols.VLESS || document.Users[0].Protocols.Hysteria2 { + t.Fatalf("explicit protocol permissions were not preserved: %#v", document.Users[0].Protocols) + } +} diff --git a/internal/subscription/render.go b/internal/subscription/render.go index f929815..64a648e 100644 --- a/internal/subscription/render.go +++ b/internal/subscription/render.go @@ -12,7 +12,13 @@ import ( ) func RenderBase64(user contracts.User, config Config) (string, error) { - links := []string{renderVLESSURI(user, config), renderHY2URI(user, config)} + links := make([]string, 0, 2) + if user.Protocols.VLESS { + links = append(links, renderVLESSURI(user, config)) + } + if user.Protocols.Hysteria2 { + links = append(links, renderHY2URI(user, config)) + } return base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n"))), nil } @@ -103,42 +109,50 @@ type clashGroup struct { func RenderClash(user contracts.User, config Config) (string, error) { vlessName := user.Name + "-VLESS" hy2Name := user.Name + "-HY2" - document := clashConfig{ - Proxies: []any{ - clashVLESS{ - Name: vlessName, - Type: "vless", - Server: config.ProxyIP, - Port: 443, - UUID: user.VLESSUUID, - Network: "tcp", - TLS: true, - UDP: true, - Flow: "xtls-rprx-vision", - ServerName: config.RealitySNI, - ClientFingerprint: "chrome", - RealityOptions: realityClash{ - PublicKey: config.RealityPublicKey, - ShortID: config.RealityShortID, - }, - }, - clashHY2{ - Name: hy2Name, - Type: "hysteria2", - Server: config.ProxyIP, - Port: 443, - Password: user.HY2Password, - SNI: config.HY2SNI, - SkipCertVerify: false, - Fingerprint: config.HY2CertSHA256, - Obfs: "salamander", - ObfsPassword: config.ObfsPassword, + proxies := make([]any, 0, 2) + proxyNames := make([]string, 0, 3) + if user.Protocols.VLESS { + proxies = append(proxies, clashVLESS{ + Name: vlessName, + Type: "vless", + Server: config.ProxyIP, + Port: 443, + UUID: user.VLESSUUID, + Network: "tcp", + TLS: true, + UDP: true, + Flow: "xtls-rprx-vision", + ServerName: config.RealitySNI, + ClientFingerprint: "chrome", + RealityOptions: realityClash{ + PublicKey: config.RealityPublicKey, + ShortID: config.RealityShortID, }, - }, + }) + proxyNames = append(proxyNames, vlessName) + } + if user.Protocols.Hysteria2 { + proxies = append(proxies, clashHY2{ + Name: hy2Name, + Type: "hysteria2", + Server: config.ProxyIP, + Port: 443, + Password: user.HY2Password, + SNI: config.HY2SNI, + SkipCertVerify: false, + Fingerprint: config.HY2CertSHA256, + Obfs: "salamander", + ObfsPassword: config.ObfsPassword, + }) + proxyNames = append(proxyNames, hy2Name) + } + proxyNames = append(proxyNames, "DIRECT") + document := clashConfig{ + Proxies: proxies, ProxyGroups: []clashGroup{{ Name: "Auto", Type: "select", - Proxies: []string{vlessName, hy2Name, "DIRECT"}, + Proxies: proxyNames, }}, Rules: []string{"MATCH,Auto"}, } diff --git a/internal/subscription/render_test.go b/internal/subscription/render_test.go index 06ce789..e11573f 100644 --- a/internal/subscription/render_test.go +++ b/internal/subscription/render_test.go @@ -43,3 +43,38 @@ func TestRenderClashProducesParseableYAML(t *testing.T) { t.Fatalf("unexpected proxies: %#v", document["proxies"]) } } + +func TestRenderSubscriptionsFilterUnauthorizedProtocol(t *testing.T) { + config := validConfig() + for name, protocols := range map[string]struct { + vless bool + hysteria2 bool + }{ + "VLESS only": {vless: true, hysteria2: false}, + "Hysteria2 only": {vless: false, hysteria2: true}, + } { + t.Run(name, func(t *testing.T) { + user := config.Users.Users[0] + user.Protocols.VLESS = protocols.vless + user.Protocols.Hysteria2 = protocols.hysteria2 + base64Output, err := RenderBase64(user, config) + if err != nil { + t.Fatal(err) + } + decoded, err := base64.StdEncoding.DecodeString(base64Output) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(decoded), "vless://") != protocols.vless || strings.Contains(string(decoded), "hysteria2://") != protocols.hysteria2 { + t.Fatalf("Base64 subscription did not match protocol permissions: %s", decoded) + } + clashOutput, err := RenderClash(user, config) + if err != nil { + t.Fatal(err) + } + if strings.Contains(clashOutput, "type: vless") != protocols.vless || strings.Contains(clashOutput, "type: hysteria2") != protocols.hysteria2 { + t.Fatalf("Clash subscription did not match protocol permissions: %s", clashOutput) + } + }) + } +} diff --git a/internal/subscription/test_helpers_test.go b/internal/subscription/test_helpers_test.go index e8094fc..313ae0d 100644 --- a/internal/subscription/test_helpers_test.go +++ b/internal/subscription/test_helpers_test.go @@ -26,6 +26,7 @@ func validConfig() Config { { Name: "alice", Enabled: true, + Protocols: contracts.Protocols{VLESS: true, Hysteria2: true}, VLESSUUID: "00000000-0000-4000-8000-000000000001", HY2Password: testPassword, SubscriptionToken: testToken, @@ -33,6 +34,7 @@ func validConfig() Config { { Name: "disabled", Enabled: false, + Protocols: contracts.Protocols{VLESS: true, Hysteria2: true}, VLESSUUID: "00000000-0000-4000-8000-000000000002", HY2Password: "hy2-password-disabled-12345", SubscriptionToken: "subscription-token-disabled-1", diff --git a/scripts/delivery/deploy.sh b/scripts/delivery/deploy.sh index eb2956f..bf71c34 100755 --- a/scripts/delivery/deploy.sh +++ b/scripts/delivery/deploy.sh @@ -80,7 +80,8 @@ rollback_vm() { } fetch_and_validate_subscription() { - local service_url="$1" suffix="$2" format + local service_url="$1" suffix="$2" token="$3" format token_encoded + token_encoded="$(jq -nr --arg value "$token" '$value | @uri')" for format in base64 clash; do printf 'url = "%s/v1/subscription?token=%s&format=%s"\nsilent\nshow-error\nfail\n' "$service_url" "$token_encoded" "$format" | \ curl --config - --output "${work_dir}/${format}.${suffix}.response" || return 1 @@ -142,19 +143,23 @@ deploy_cloud_run() { gcloud run services update-traffic "$SUBSCRIPTION_SERVICE_NAME" --quiet --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --to-revisions "${old_revision}=100" || return 21 return 1 fi - if [[ -z "$service_url" ]] || ! wait_for_public_health "$service_url" || ! fetch_and_validate_subscription "$service_url" public; then - gcloud run services update-traffic "$SUBSCRIPTION_SERVICE_NAME" --quiet --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --to-revisions "${old_revision}=100" || return 21 - return 1 - fi - if ! "${script_dir}/e2e-proxy.sh" --subscription "${work_dir}/base64.public.response" \ - --image "$sing_box_image" --probe-url "$egress_probe_url" --proxyctl "${work_dir}/proxyctl"; then + if [[ -z "$service_url" ]] || ! wait_for_public_health "$service_url"; then gcloud run services update-traffic "$SUBSCRIPTION_SERVICE_NAME" --quiet --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --to-revisions "${old_revision}=100" || return 21 return 1 fi + local protocol token + for protocol in vless hysteria2; do + token="$(jq -r --arg protocol "$protocol" '[.users[] | select(.enabled == true and (if has("protocols") then .protocols[$protocol] == true else true end)) | .subscription_token][0] // empty' "${inputs_dir}/proxy-users.json")" + [[ -n "$token" ]] || continue + if ! fetch_and_validate_subscription "$service_url" "$protocol" "$token" || ! "${script_dir}/e2e-proxy.sh" \ + --subscription "${work_dir}/base64.${protocol}.response" --protocol "$protocol" \ + --image "$sing_box_image" --probe-url "$egress_probe_url" --proxyctl "${work_dir}/proxyctl"; then + gcloud run services update-traffic "$SUBSCRIPTION_SERVICE_NAME" --quiet --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --to-revisions "${old_revision}=100" || return 21 + return 1 + fi + done } -token="$(jq -er '.users[] | select(.enabled) | .subscription_token' "${inputs_dir}/proxy-users.json" | head -n1)" -token_encoded="$(jq -nr --arg value "$token" '$value | @uri')" set +e deploy_cloud_run cloud_status=$? diff --git a/scripts/delivery/e2e-proxy.sh b/scripts/delivery/e2e-proxy.sh index adfaabb..9608a11 100755 --- a/scripts/delivery/e2e-proxy.sh +++ b/scripts/delivery/e2e-proxy.sh @@ -6,16 +6,19 @@ subscription="" image="" probe_url="" proxyctl="" +protocol="" while (($#)); do case "$1" in --subscription) subscription="${2:-}"; shift 2 ;; --image) image="${2:-}"; shift 2 ;; --probe-url) probe_url="${2:-}"; shift 2 ;; --proxyctl) proxyctl="${2:-}"; shift 2 ;; - *) printf '%s\n' 'usage: e2e-proxy.sh --subscription --image --probe-url --proxyctl ' >&2; exit 2 ;; + --protocol) protocol="${2:-}"; shift 2 ;; + *) printf '%s\n' 'usage: e2e-proxy.sh --subscription --protocol vless|hysteria2 --image --probe-url --proxyctl ' >&2; exit 2 ;; esac done [[ -f "$subscription" && -x "$proxyctl" ]] || { printf '%s\n' 'subscription and proxyctl are required' >&2; exit 2; } +[[ "$protocol" == vless || "$protocol" == hysteria2 ]] || { printf '%s\n' 'protocol must be vless or hysteria2' >&2; exit 2; } [[ "$image" =~ @sha256:[0-9a-f]{64}$ ]] || { printf '%s\n' 'probe image must use a sha256 digest' >&2; exit 2; } [[ "$probe_url" =~ ^https://[^?#]+$ ]] || { printf '%s\n' 'probe URL must be HTTPS without query or fragment' >&2; exit 2; } for command_name in curl docker; do command -v "$command_name" >/dev/null 2>&1 || { printf 'missing command: %s\n' "$command_name" >&2; exit 1; }; done @@ -29,23 +32,21 @@ cleanup() { } trap cleanup EXIT -for item in 'vless:18080' 'hysteria2:18081'; do - protocol="${item%%:*}" - port="${item##*:}" - config="${work_dir}/${protocol}.json" - container="proxy-builder-probe-${protocol}-${RANDOM}-${RANDOM}" - "$proxyctl" render-probe-config --input "$subscription" --protocol "$protocol" --listen-port "$port" --output "$config" - docker run --rm -v "${config}:/etc/sing-box/config.json:ro" "$image" check -c /etc/sing-box/config.json >/dev/null 2>&1 - docker run --detach --name "$container" --network host -v "${config}:/etc/sing-box/config.json:ro" "$image" run -c /etc/sing-box/config.json >/dev/null - containers+=("$container") - status="" - for _ in {1..20}; do - status="$(curl --silent --show-error --max-time 10 --proxy "socks5h://127.0.0.1:${port}" --output /dev/null --write-out '%{http_code}' "$probe_url" 2>/dev/null || true)" - [[ "$status" == "204" ]] && break - sleep 1 - done - [[ "$status" == "204" ]] || { printf '%s proxy egress probe failed\n' "$protocol" >&2; exit 1; } - docker rm -f "$container" >/dev/null - containers=() +port=18080 +[[ "$protocol" == hysteria2 ]] && port=18081 +config="${work_dir}/${protocol}.json" +container="proxy-builder-probe-${protocol}-${RANDOM}-${RANDOM}" +"$proxyctl" render-probe-config --input "$subscription" --protocol "$protocol" --listen-port "$port" --output "$config" +docker run --rm -v "${config}:/etc/sing-box/config.json:ro" "$image" check -c /etc/sing-box/config.json >/dev/null 2>&1 +docker run --detach --name "$container" --network host -v "${config}:/etc/sing-box/config.json:ro" "$image" run -c /etc/sing-box/config.json >/dev/null +containers+=("$container") +status="" +for _ in {1..20}; do + status="$(curl --silent --show-error --max-time 10 --proxy "socks5h://127.0.0.1:${port}" --output /dev/null --write-out '%{http_code}' "$probe_url" 2>/dev/null || true)" + [[ "$status" == "204" ]] && break + sleep 1 done -printf '%s\n' 'VLESS and Hysteria2 egress probes passed' +[[ "$status" == "204" ]] || { printf '%s proxy egress probe failed\n' "$protocol" >&2; exit 1; } +docker rm -f "$container" >/dev/null +containers=() +printf '%s egress probe passed\n' "$protocol" diff --git a/scripts/delivery/platform-context.sh b/scripts/delivery/platform-context.sh index dd6e7fc..f515743 100755 --- a/scripts/delivery/platform-context.sh +++ b/scripts/delivery/platform-context.sh @@ -34,4 +34,3 @@ done printf '%s=%s\n' "$name" "$(jq -r --arg name "$name" '.[$name].value' <<<"$outputs")" done } >>"$output_file" - diff --git a/scripts/delivery/test-deploy.sh b/scripts/delivery/test-deploy.sh index 89fe3a4..20a327d 100755 --- a/scripts/delivery/test-deploy.sh +++ b/scripts/delivery/test-deploy.sh @@ -55,6 +55,8 @@ chmod 0755 "${fake_bin}"/* run_deploy() { printf '0' >"${test_root}/health-count" + default_users_json='{"version":1,"users":[{"name":"alice","enabled":true,"vless_uuid":"00000000-0000-4000-8000-000000000001","hy2_password":"HY2_PASSWORD_CANARY_123456789","subscription_token":"SUB_TOKEN_CANARY_123456789012"}]}' + users_json="${FAKE_USERS_JSON:-$default_users_json}" PATH="${fake_bin}:$PATH" FAKE_PROXYCTL="${test_root}/proxyctl" FAKE_COMMAND_LOG="$command_log" FAKE_VM_STATUS="${1:-0}" FAKE_REMOTE_CERT="${2:-}" \ FAKE_VM_ROLLBACK_STATUS="${FAKE_VM_ROLLBACK_STATUS:-0}" FAKE_CLOUD_ROLLBACK_STATUS="${FAKE_CLOUD_ROLLBACK_STATUS:-0}" \ FAKE_PUBLIC_HEALTH_FAIL="${FAKE_PUBLIC_HEALTH_FAIL:-0}" FAKE_PUBLIC_HEALTH_FAILURES="${FAKE_PUBLIC_HEALTH_FAILURES:-0}" \ @@ -66,7 +68,7 @@ run_deploy() { PROXY_USERS_SECRET_ID=proxy-dev-users OBFS_PASSWORD_SECRET_ID=proxy-dev-obfs \ REALITY_PRIVATE_KEY=REALITY_PRIVATE_CANARY_1234567890 OBFS_PASSWORD=OBFS_CANARY_12345678901234567890 \ HY2_CERT_PEM=HY2_CERT_CANARY HY2_KEY_PEM=HY2_KEY_CANARY \ - PROXY_USERS_JSON='{"version":1,"users":[{"name":"alice","enabled":true,"vless_uuid":"00000000-0000-4000-8000-000000000001","hy2_password":"HY2_PASSWORD_CANARY_123456789","subscription_token":"SUB_TOKEN_CANARY_123456789012"}]}' \ + PROXY_USERS_JSON="$users_json" \ "$script_dir/deploy.sh" } @@ -152,4 +154,13 @@ status=$? set -e [[ "$status" == "21" ]] || { printf 'expected intervention status 21 after Cloud Run rollback failure, got %s\n' "$status" >&2; exit 1; } rg -q -- '--rollback '\''0123456789abcdef0123456789abcdef01234567-123-1'\''' "$command_log" || { printf '%s\n' 'VM rollback was skipped after Cloud Run rollback failure' >&2; exit 1; } + +: >"$command_log" +FAKE_USERS_JSON='{"version":1,"users":[{"name":"alice","enabled":true,"protocols":{"vless":true,"hysteria2":false},"vless_uuid":"00000000-0000-4000-8000-000000000001","hy2_password":"HY2_PASSWORD_CANARY_123456789","subscription_token":"SUB_TOKEN_CANARY_123456789012"}]}' \ + run_deploy 0 >"${test_root}/vless-only.log" 2>&1 +rg -q 'vless\.json' "$command_log" || { printf '%s\n' 'VLESS-authorized user was not probed' >&2; exit 1; } +if rg -q 'hysteria2\.json' "$command_log"; then + printf '%s\n' 'disabled Hysteria2 protocol was still probed' >&2 + exit 1 +fi printf '%s\n' 'delivery isolation tests passed' diff --git a/scripts/github/dispatch-deploy.sh b/scripts/github/dispatch-deploy.sh index fcdbb42..e721268 100755 --- a/scripts/github/dispatch-deploy.sh +++ b/scripts/github/dispatch-deploy.sh @@ -9,4 +9,3 @@ command -v gh >/dev/null 2>&1 || { printf '%s\n' 'missing command: gh' >&2; exit workflow_ref="$(git symbolic-ref --quiet --short HEAD || printf '%s' main)" gh workflow run deploy.yml --ref "$workflow_ref" -f "environment=${environment}" -f "git_ref=${git_ref}" printf 'deploy workflow dispatched for %s at %s\n' "$environment" "$git_ref" - diff --git a/scripts/infra/common.sh b/scripts/infra/common.sh index b2e2c67..4ca85ce 100755 --- a/scripts/infra/common.sh +++ b/scripts/infra/common.sh @@ -45,7 +45,7 @@ infra::read_tfvar() { infra::tofu_bin() { local tofu_bin="${TOFU_BIN:-tofu}" - command -v "$tofu_bin" >/dev/null 2>&1 || infra::die "缺少 OpenTofu;安装 1.12.1 或设置 TOFU_BIN" + command -v "$tofu_bin" >/dev/null 2>&1 || infra::die "缺少 OpenTofu;安装 1.12.6 或设置 TOFU_BIN" printf '%s\n' "$tofu_bin" } @@ -53,5 +53,5 @@ infra::require_tofu_version() { local tofu_bin="$1" local version version=$($tofu_bin version -json | sed -nE 's/.*"terraform_version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1) - [[ "$version" == "1.12.1" ]] || infra::die "需要 OpenTofu 1.12.1,当前为 ${version:-unknown}" + [[ "$version" == "1.12.6" ]] || infra::die "需要 OpenTofu 1.12.6,当前为 ${version:-unknown}" } diff --git a/scripts/infra/test-guards.sh b/scripts/infra/test-guards.sh index 34d1412..b9cff97 100755 --- a/scripts/infra/test-guards.sh +++ b/scripts/infra/test-guards.sh @@ -29,6 +29,8 @@ rg -q '^[[:space:]]+client_version,$' "$subscription_module" || infra::die "plat development_file=$(infra::require_environment development) production_file=$(infra::require_environment production) +make -n -C "$INFRA_ROOT" infra-plan ENV=dev | rg -q 'ENV="development"' +make -n -C "$INFRA_ROOT" deploy ENV=prod | rg -q 'ENV="production"' 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" diff --git a/scripts/secrets/manage-user.sh b/scripts/secrets/manage-user.sh index cd4006f..45ffe24 100755 --- a/scripts/secrets/manage-user.sh +++ b/scripts/secrets/manage-user.sh @@ -5,9 +5,13 @@ umask 077 action="${1:-}" environment="${ENV:-}" user="${USER:-}" -case "$action" in add|enable|disable|rotate) ;; *) printf '%s\n' 'action must be add, enable, disable or rotate' >&2; exit 2 ;; esac +protocol="${PROTOCOL:-}" +case "$action" in add|enable|disable|rotate|protocol-enable|protocol-disable) ;; *) printf '%s\n' 'action must be add, enable, disable, rotate, protocol-enable or protocol-disable' >&2; exit 2 ;; esac case "$environment" in development|production) ;; *) printf '%s\n' 'ENV must be development or production' >&2; exit 2 ;; esac [[ -n "$user" ]] || { printf '%s\n' 'USER is required' >&2; exit 2; } +if [[ "$action" == protocol-enable || "$action" == protocol-disable ]]; then + [[ "$protocol" == vless || "$protocol" == hysteria2 ]] || { printf '%s\n' 'PROTOCOL must be vless or hysteria2' >&2; exit 2; } +fi repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" users="${repo_root}/.secrets/${environment}/users.json" @@ -15,5 +19,10 @@ users="${repo_root}/.secrets/${environment}/users.json" work_dir="$(mktemp -d "${TMPDIR:-/tmp}/proxy-builder-user.XXXXXX")" trap 'rm -rf "$work_dir"' EXIT (cd "$repo_root" && go build -trimpath -o "${work_dir}/proxyctl" ./cmd/proxyctl) -"${work_dir}/proxyctl" "${action}-user" --users "$users" --name "$user" +command="${action}-user" +if [[ "$action" == protocol-enable ]]; then command="enable-user-protocol"; fi +if [[ "$action" == protocol-disable ]]; then command="disable-user-protocol"; fi +arguments=("$command" --users "$users" --name "$user") +if [[ -n "$protocol" ]]; then arguments+=(--protocol "$protocol"); fi +"${work_dir}/proxyctl" "${arguments[@]}" printf 'local %s completed for %s; publish and deploy explicitly\n' "$action" "$environment" diff --git a/scripts/secrets/test-tools.sh b/scripts/secrets/test-tools.sh index 6f3dc32..289356f 100755 --- a/scripts/secrets/test-tools.sh +++ b/scripts/secrets/test-tools.sh @@ -12,7 +12,7 @@ printf '%s\n' '{"version":1,"users":[{"name":"alice","enabled":true,"vless_uuid" chmod 0600 "$secrets_root/development/users.json" printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ - 'if [[ "${1:-}" == "version" ]]; then printf '\''{"terraform_version":"1.12.1"}\n'\''; exit 0; fi' \ + 'if [[ "${1:-}" == "version" ]]; then printf '\''{"terraform_version":"1.12.6"}\n'\''; exit 0; fi' \ 'if [[ "$*" == *" output -raw subscription_service_url"* ]]; then printf '\''https://subscription.example'\''; fi' >"$fake_tofu" chmod 0755 "$fake_tofu" diff --git a/users.example.json b/users.example.json index 8248eff..7992369 100644 --- a/users.example.json +++ b/users.example.json @@ -4,6 +4,7 @@ { "name": "example-user", "enabled": true, + "protocols": { "vless": true, "hysteria2": true }, "vless_uuid": "00000000-0000-4000-8000-000000000001", "hy2_password": "replace-with-at-least-24-bytes", "subscription_token": "replace-with-at-least-24-bytes"