Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions .github/workflows/infra-plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] }}
Expand All @@ -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: |
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Expand All @@ -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]"
Expand All @@ -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]"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)"

Expand Down
44 changes: 23 additions & 21 deletions cmd/proxyctl/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
68 changes: 68 additions & 0 deletions cmd/proxyctl/environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading