Skip to content

Commit 2d3e144

Browse files
committed
feat(relay): add auto acme with relay-server started
1 parent 3a49139 commit 2d3e144

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

portal/acme/dnsrecord.go

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ func NewRelayServer(
4343
sniRouter: sni.NewRouter(sniPort),
4444
}
4545

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+
4652
acmeManager, keyFile, err := acme.NewManager(ctx, acme.Config{
4753
BaseDomain: baseHost,
4854
KeyDir: keylessDir,

0 commit comments

Comments
 (0)