feat: Enhance ECH configuration with public IP resolution and A record management

Kim committed May 7, 2026 at 11:22 UTC 382ecf2543bec3ff632276cdad86a5a542f27f7b
3 files changed +109
.github/workflows/branch-artifacts.yml new
+77
@@ -0,0 +1,77 @@
1 +name: Branch Artifacts
2 +
3 +permissions:
4 + contents: read
5 +
6 +on:
7 + workflow_dispatch:
8 + inputs:
9 + ref:
10 + description: Branch, tag, or commit SHA to build
11 + required: true
12 + default: feature/encrypted-clienthello
13 +
14 +concurrency:
15 + group: branch-artifacts-${{ inputs.ref }}
16 + cancel-in-progress: true
17 +
18 +jobs:
19 + build:
20 + name: Build Binaries
21 + runs-on: ubuntu-latest
22 +
23 + steps:
24 + - name: Checkout selected ref
25 + uses: actions/checkout@v6
26 + with:
27 + ref: ${{ inputs.ref }}
28 +
29 + - name: Set up Go
30 + uses: actions/setup-go@v6
31 + with:
32 + go-version-file: go.mod
33 + cache: true
34 +
35 + - name: Prepare artifact metadata
36 + id: meta
37 + shell: bash
38 + run: |
39 + safe_ref="$(printf '%s' "${{ inputs.ref }}" | tr '/:@' '---' | tr -cd 'A-Za-z0-9._-')"
40 + if [ -z "$safe_ref" ]; then
41 + safe_ref="selected-ref"
42 + fi
43 + echo "safe_ref=$safe_ref" >> "$GITHUB_OUTPUT"
44 + echo "short_sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
45 +
46 + - name: Build binaries
47 + shell: bash
48 + run: |
49 + mkdir -p release
50 +
51 + make build-tunnel
52 + cp cmd/relay-server/dist/tunnel/portal-* release/
53 +
54 + for GOARCH in amd64 arm64; do
55 + out="release/relay-server-linux-${GOARCH}"
56 + echo " - ${out}"
57 + CGO_ENABLED=0 GOOS=linux GOARCH="${GOARCH}" go build -trimpath -ldflags "-s -w" -o "${out}" ./cmd/relay-server
58 + done
59 +
60 + cp cmd/portal-tunnel/installer/install.sh release/install.sh
61 + cp cmd/portal-tunnel/installer/install.ps1 release/install.ps1
62 +
63 + - name: Generate SHA256 checksums
64 + shell: bash
65 + run: |
66 + cd release
67 + sha256sum * > checksums.txt
68 + while read -r sum file; do
69 + printf '%s %s\n' "$sum" "$file" > "${file}.sha256"
70 + done < checksums.txt
71 +
72 + - name: Upload artifact
73 + uses: actions/upload-artifact@v4
74 + with:
75 + name: portal-${{ steps.meta.outputs.safe_ref }}-${{ steps.meta.outputs.short_sha }}
76 + path: release/*
77 + if-no-files-found: error
\ No newline at end of file
portal/acme/acme.go
+25
@@ -483,6 +483,14 @@ func (m *Manager) SyncECHConfig(ctx context.Context, hostname string, echConfigL
483 m.echRecords[hostname] = record
484 m.echMu.Unlock()
485
486 + publicIP, err := utils.ResolvePublicIPv4(ctx)
487 + if err != nil {
488 + return fmt.Errorf("detect public ip for ECH hostname %s: %w", hostname, err)
489 + }
490 + if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
491 + return fmt.Errorf("ensure ECH A record for %s: %w", hostname, err)
492 + }
493 +
494 if err := m.dns.EnsureHTTPSRecord(ctx, hostname, record.Priority, record.Target, svcParams, content); err != nil {
495 return err
496 }
@@ -511,6 +519,11 @@ func (m *Manager) DeleteECHConfig(ctx context.Context, hostname string) error {
519 if err := m.dns.DeleteHTTPSRecord(ctx, hostname); err != nil {
520 return err
521 }
522 + if hostname != m.cfg.BaseDomain {
523 + if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
524 + return fmt.Errorf("delete ECH A record for %s: %w", hostname, err)
525 + }
526 + }
527 return nil
528 }
529
@@ -527,7 +540,19 @@ func (m *Manager) syncECHRecords(ctx context.Context) error {
540 m.echMu.Unlock()
541
542 var syncErr error
543 + publicIP := ""
544 + if len(records) > 0 {
545 + var err error
546 + publicIP, err = utils.ResolvePublicIPv4(ctx)
547 + if err != nil {
548 + return fmt.Errorf("detect public ip for ECH records: %w", err)
549 + }
550 + }
551 for hostname, record := range records {
552 + if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
553 + syncErr = errors.Join(syncErr, fmt.Errorf("ensure ECH A record for %s: %w", hostname, err))
554 + continue
555 + }
556 content, err := record.Content()
557 if err != nil {
558 syncErr = errors.Join(syncErr, fmt.Errorf("build ECH HTTPS record for %s: %w", hostname, err))
utils/cmd.go
+7
@@ -305,6 +305,13 @@ func normalizeFlagArgs(fs *flag.FlagSet, args []string) []string {
305 }
306 boolValue, ok := flagDef.Value.(boolFlagValue)
307 if ok && boolValue.IsBoolFlag() {
308 + if i+1 < len(args) {
309 + next := strings.TrimSpace(args[i+1])
310 + if _, err := strconv.ParseBool(next); err == nil {
311 + flags[len(flags)-1] = args[i] + "=" + next
312 + i++
313 + }
314 + }
315 continue
316 }
317 if i+1 >= len(args) {