feat(relay): add auto acme with relay-server started
Hee Sung Son committed
Mar 5, 2026 at 17:03 UTC
2d3e144d680290465e081ca2bf3c0efb8dad3505
2 files changed
+332
portal/acme/dnsrecord.go
new
+326
@@ -0,0 +1,326 @@
1
+package acme
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net"
10
+ "net/http"
11
+ "net/url"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/rs/zerolog/log"
16
+
17
+ "gosuda.org/portal/types"
18
+)
19
+
20
+const (
21
+ cfAPIBase = "https://api.cloudflare.com/client/v4"
22
+ publicIPURL = "https://api4.ipify.org"
23
+ dnsHTTPTimeout = 15 * time.Second
24
+ dnsAutoTTL = 1 // Cloudflare "automatic" TTL
25
+)
26
+
27
+// Cloudflare API response types.
28
+
29
+type cfError struct {
30
+ Code int `json:"code"`
31
+ Message string `json:"message"`
32
+}
33
+
34
+type cfZone struct {
35
+ ID string `json:"id"`
36
+ Name string `json:"name"`
37
+}
38
+
39
+type cfDNSRecord struct {
40
+ ID string `json:"id"`
41
+ Type string `json:"type"`
42
+ Name string `json:"name"`
43
+ Content string `json:"content"`
44
+ TTL int `json:"ttl"`
45
+ Proxied bool `json:"proxied"`
46
+}
47
+
48
+type cfZonesResult struct {
49
+ Success bool `json:"success"`
50
+ Errors []cfError `json:"errors"`
51
+ Result []cfZone `json:"result"`
52
+}
53
+
54
+type cfRecordsResult struct {
55
+ Success bool `json:"success"`
56
+ Errors []cfError `json:"errors"`
57
+ Result []cfDNSRecord `json:"result"`
58
+}
59
+
60
+type cfRecordResult struct {
61
+ Success bool `json:"success"`
62
+ Errors []cfError `json:"errors"`
63
+ Result cfDNSRecord `json:"result"`
64
+}
65
+
66
+// EnsureDNSRecords creates or updates Cloudflare A records for the base domain
67
+// and its wildcard subdomain, pointing to the server's detected public IP.
68
+// Skips silently when baseDomain is empty, localhost, or cloudflareToken is missing.
69
+func EnsureDNSRecords(ctx context.Context, baseDomain, cloudflareToken string) error {
70
+ baseDomain = strings.TrimSpace(baseDomain)
71
+ cloudflareToken = strings.TrimSpace(cloudflareToken)
72
+
73
+ if baseDomain == "" || cloudflareToken == "" || types.IsLocalhost(baseDomain) {
74
+ return nil
75
+ }
76
+
77
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
78
+ defer cancel()
79
+
80
+ publicIP, err := detectPublicIP(ctx)
81
+ if err != nil {
82
+ return fmt.Errorf("detect public IP: %w", err)
83
+ }
84
+
85
+ log.Info().
86
+ Str("public_ip", publicIP).
87
+ Str("base_domain", baseDomain).
88
+ Msg("[DNS] detected server public IP")
89
+
90
+ zoneID, err := findZoneID(ctx, cloudflareToken, baseDomain)
91
+ if err != nil {
92
+ return fmt.Errorf("find Cloudflare zone for %s: %w", baseDomain, err)
93
+ }
94
+
95
+ targets := []string{baseDomain, "*." + baseDomain}
96
+ for _, name := range targets {
97
+ if err := ensureARecord(ctx, cloudflareToken, zoneID, name, publicIP); err != nil {
98
+ return fmt.Errorf("ensure A record for %s: %w", name, err)
99
+ }
100
+ }
101
+
102
+ return nil
103
+}
104
+
105
+// detectPublicIP fetches the server's public IPv4 address from an external service.
106
+func detectPublicIP(ctx context.Context) (string, error) {
107
+ ctx, cancel := context.WithTimeout(ctx, dnsHTTPTimeout)
108
+ defer cancel()
109
+
110
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, publicIPURL, nil)
111
+ if err != nil {
112
+ return "", err
113
+ }
114
+
115
+ resp, err := http.DefaultClient.Do(req)
116
+ if err != nil {
117
+ return "", err
118
+ }
119
+ defer resp.Body.Close()
120
+
121
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
122
+ if err != nil {
123
+ return "", err
124
+ }
125
+
126
+ ip := strings.TrimSpace(string(body))
127
+ parsed := net.ParseIP(ip)
128
+ if parsed == nil {
129
+ return "", fmt.Errorf("invalid IP address: %q", ip)
130
+ }
131
+ if parsed.To4() == nil {
132
+ return "", fmt.Errorf("expected IPv4 address, got: %q", ip)
133
+ }
134
+
135
+ return ip, nil
136
+}
137
+
138
+// findZoneID looks up the Cloudflare zone ID by progressively stripping
139
+// subdomain labels from the given domain (e.g., portal.example.com → example.com).
140
+func findZoneID(ctx context.Context, token, domain string) (string, error) {
141
+ parts := strings.Split(domain, ".")
142
+ for i := range len(parts) - 1 {
143
+ candidate := strings.Join(parts[i:], ".")
144
+
145
+ zones, err := cfListZones(ctx, token, candidate)
146
+ if err != nil {
147
+ return "", err
148
+ }
149
+ for _, z := range zones {
150
+ if strings.EqualFold(z.Name, candidate) {
151
+ log.Debug().
152
+ Str("zone", z.Name).
153
+ Str("zone_id", z.ID).
154
+ Msg("[DNS] found Cloudflare zone")
155
+ return z.ID, nil
156
+ }
157
+ }
158
+ }
159
+
160
+ return "", fmt.Errorf("no Cloudflare zone found for domain %s", domain)
161
+}
162
+
163
+// ensureARecord creates or updates a single A record.
164
+// If the record exists with the correct IP and proxy-off, it is left untouched.
165
+func ensureARecord(ctx context.Context, token, zoneID, name, ip string) error {
166
+ records, err := cfListDNSRecords(ctx, token, zoneID, name, "A")
167
+ if err != nil {
168
+ return err
169
+ }
170
+
171
+ for _, r := range records {
172
+ if !strings.EqualFold(r.Name, name) {
173
+ continue
174
+ }
175
+ if r.Content == ip && !r.Proxied {
176
+ log.Info().
177
+ Str("name", name).
178
+ Str("ip", ip).
179
+ Msg("[DNS] A record already up to date")
180
+ return nil
181
+ }
182
+ // Record exists but IP or proxy status differs — update it.
183
+ return cfUpdateDNSRecord(ctx, token, zoneID, r.ID, name, ip)
184
+ }
185
+
186
+ return cfCreateDNSRecord(ctx, token, zoneID, name, ip)
187
+}
188
+
189
+// ── Cloudflare API helpers ──────────────────────────────────────────
190
+
191
+func cfListZones(ctx context.Context, token, name string) ([]cfZone, error) {
192
+ u, _ := url.Parse(cfAPIBase + "/zones")
193
+ q := u.Query()
194
+ q.Set("name", name)
195
+ u.RawQuery = q.Encode()
196
+
197
+ var out cfZonesResult
198
+ if err := cfGet(ctx, token, u.String(), &out); err != nil {
199
+ return nil, err
200
+ }
201
+ if !out.Success {
202
+ return nil, cfErrs(out.Errors)
203
+ }
204
+ return out.Result, nil
205
+}
206
+
207
+func cfListDNSRecords(ctx context.Context, token, zoneID, name, recordType string) ([]cfDNSRecord, error) {
208
+ u, _ := url.Parse(fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID))
209
+ q := u.Query()
210
+ q.Set("name", name)
211
+ q.Set("type", recordType)
212
+ u.RawQuery = q.Encode()
213
+
214
+ var out cfRecordsResult
215
+ if err := cfGet(ctx, token, u.String(), &out); err != nil {
216
+ return nil, err
217
+ }
218
+ if !out.Success {
219
+ return nil, cfErrs(out.Errors)
220
+ }
221
+ return out.Result, nil
222
+}
223
+
224
+func cfCreateDNSRecord(ctx context.Context, token, zoneID, name, ip string) error {
225
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID)
226
+
227
+ body := map[string]any{
228
+ "type": "A",
229
+ "name": name,
230
+ "content": ip,
231
+ "ttl": dnsAutoTTL,
232
+ "proxied": false,
233
+ }
234
+
235
+ var out cfRecordResult
236
+ if err := cfMutate(ctx, http.MethodPost, token, endpoint, body, &out); err != nil {
237
+ return err
238
+ }
239
+ if !out.Success {
240
+ return cfErrs(out.Errors)
241
+ }
242
+
243
+ log.Info().
244
+ Str("name", name).
245
+ Str("ip", ip).
246
+ Msg("[DNS] created A record")
247
+ return nil
248
+}
249
+
250
+func cfUpdateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip string) error {
251
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
252
+
253
+ body := map[string]any{
254
+ "type": "A",
255
+ "name": name,
256
+ "content": ip,
257
+ "ttl": dnsAutoTTL,
258
+ "proxied": false,
259
+ }
260
+
261
+ var out cfRecordResult
262
+ if err := cfMutate(ctx, http.MethodPut, token, endpoint, body, &out); err != nil {
263
+ return err
264
+ }
265
+ if !out.Success {
266
+ return cfErrs(out.Errors)
267
+ }
268
+
269
+ log.Info().
270
+ Str("name", name).
271
+ Str("ip", ip).
272
+ Msg("[DNS] updated A record")
273
+ return nil
274
+}
275
+
276
+// ── HTTP transport ──────────────────────────────────────────────────
277
+
278
+func cfGet(ctx context.Context, token, rawURL string, out any) error {
279
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
280
+ if err != nil {
281
+ return err
282
+ }
283
+ req.Header.Set("Authorization", "Bearer "+token)
284
+ req.Header.Set("Content-Type", "application/json")
285
+
286
+ resp, err := http.DefaultClient.Do(req)
287
+ if err != nil {
288
+ return err
289
+ }
290
+ defer resp.Body.Close()
291
+
292
+ return json.NewDecoder(resp.Body).Decode(out)
293
+}
294
+
295
+func cfMutate(ctx context.Context, method, token, rawURL string, body any, out any) error {
296
+ payload, err := json.Marshal(body)
297
+ if err != nil {
298
+ return err
299
+ }
300
+
301
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(payload))
302
+ if err != nil {
303
+ return err
304
+ }
305
+ req.Header.Set("Authorization", "Bearer "+token)
306
+ req.Header.Set("Content-Type", "application/json")
307
+
308
+ resp, err := http.DefaultClient.Do(req)
309
+ if err != nil {
310
+ return err
311
+ }
312
+ defer resp.Body.Close()
313
+
314
+ return json.NewDecoder(resp.Body).Decode(out)
315
+}
316
+
317
+func cfErrs(errs []cfError) error {
318
+ if len(errs) == 0 {
319
+ return fmt.Errorf("cloudflare API request failed")
320
+ }
321
+ msgs := make([]string, 0, len(errs))
322
+ for _, e := range errs {
323
+ msgs = append(msgs, fmt.Sprintf("[%d] %s", e.Code, e.Message))
324
+ }
325
+ return fmt.Errorf("cloudflare API: %s", strings.Join(msgs, "; "))
326
+}
portal/relay.go
+6
@@ -43,6 +43,12 @@ func NewRelayServer(
43
sniRouter: sni.NewRouter(sniPort),
44
}
45
46
+
47
+ // Auto-register DNS A records in Cloudflare (best-effort, non-fatal).
48
+ if err := acme.EnsureDNSRecords(ctx, baseHost, cloudflareToken); err != nil {
49
+ log.Warn().Err(err).Msg("[DNS] failed to auto-register DNS records; continuing without")
50
+ }
51
+
52
acmeManager, keyFile, err := acme.NewManager(ctx, acme.Config{
53
BaseDomain: baseHost,
54
KeyDir: keylessDir,