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,