| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ecdsa" |
| 7 | "crypto/elliptic" |
| 8 | "crypto/rand" |
| 9 | "crypto/tls" |
| 10 | "crypto/x509" |
| 11 | "crypto/x509/pkix" |
| 12 | "encoding/json" |
| 13 | "encoding/pem" |
| 14 | "errors" |
| 15 | "fmt" |
| 16 | "io" |
| 17 | "log" |
| 18 | "net" |
| 19 | "net/http" |
| 20 | "os" |
| 21 | "strings" |
| 22 | "sync/atomic" |
| 23 | "time" |
| 24 | |
| 25 | "golang.org/x/crypto/acme" |
| 26 | "golang.org/x/crypto/acme/autocert" |
| 27 | ) |
| 28 | |
| 29 | // Wildcard TLS (ACME DNS-01) for the space base domain(s). |
| 30 | // |
| 31 | // Every new space at <sqid>.11ch.at used to mint its own per-host certificate |
| 32 | // (autocert HTTP-01), which has a hard Let's Encrypt ceiling (~50 new certs a |
| 33 | // week per registered domain) and means a never-served subdomain fails the TLS |
| 34 | // handshake outright. A single wildcard cert for *.<domain>, obtained over |
| 35 | // DNS-01 (a TXT record at _acme-challenge.<domain> via the DNSimple API), |
| 36 | // covers every space instantly and forever-many of them. |
| 37 | // |
| 38 | // The wildcard only changes how a handshake GETS a certificate: routing and |
| 39 | // the autocert HostPolicy are untouched, and per-host HTTP-01 remains the path |
| 40 | // for every host outside the wildcard domains (and the fallback for hosts |
| 41 | // inside them until the wildcard is first obtained — so a DNS-provider or ACME |
| 42 | // outage degrades to exactly today's behaviour, never a dead router). |
| 43 | // |
| 44 | // The DNS-01 solver is PLUGGABLE (the dnsProvider seam): DNSimple stays the |
| 45 | // default, fully-supported backend (LCHAT_DNSIMPLE_TOKEN), and Route53 |
| 46 | // (route53dns.go) is an ADDITIVE off-ramp selected by LCHAT_DNS_PROVIDER=route53 |
| 47 | // for boxes that would rather authenticate with an AWS IAM role than a token on |
| 48 | // disk (docs/multi-edge.md). Selection lives in dnsProviderFromEnv; everything |
| 49 | // below is provider-blind. |
| 50 | // |
| 51 | // Off unless LCHAT_WILDCARD_DOMAINS is set and a DNS provider is configured |
| 52 | // (DNSimple by default, so LCHAT_DNSIMPLE_TOKEN). See |
| 53 | // docs/instance-router.md § Wildcard TLS. |
| 54 | |
| 55 | const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| 56 | |
| 57 | // wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90 |
| 58 | // days; renewing with 30 left tolerates a month of provider outage. |
| 59 | const wildcardRenewBefore = 30 * 24 * time.Hour |
| 60 | |
| 61 | // dnsProvider is the DNS-01 seam. It publishes the ACME challenge TXT and hands |
| 62 | // back a closure that removes it — a closure so each backend keeps its own |
| 63 | // teardown detail private (DNSimple deletes by the numeric record id it minted; |
| 64 | // Route53 re-submits the record set). wildcardManager.solveDNS01 only ever calls |
| 65 | // present, so it never learns which backend is in use. |
| 66 | // |
| 67 | // Implementations: dnsimpleDNS (the default, this file) and route53DNS |
| 68 | // (route53dns.go, the additive off-ramp). Choose with dnsProviderFromEnv. |
| 69 | type dnsProvider interface { |
| 70 | // present creates a TXT record named name under zone (e.g. |
| 71 | // "_acme-challenge" under "11ch.at") holding value, returning a cleanup that |
| 72 | // deletes it. cleanup runs on its own context (see solveDNS01's defer) so it |
| 73 | // still fires after the issuance context is dead. |
| 74 | present(ctx context.Context, zone, name, value string) (cleanup func(context.Context) error, err error) |
| 75 | } |
| 76 | |
| 77 | // dnsProviderFromEnv selects the DNS-01 backend, keeping DNSimple the DEFAULT |
| 78 | // with its trigger unchanged: |
| 79 | // |
| 80 | // - unset / LCHAT_DNS_PROVIDER=dnsimple → DNSimple, exactly as before — needs |
| 81 | // LCHAT_DNSIMPLE_TOKEN (an account API token); a missing token leaves the |
| 82 | // wildcard off (per-host issuance continues), the historical behaviour. |
| 83 | // - LCHAT_DNS_PROVIDER=route53 → Route53 (the multi-edge off-ramp), authed by |
| 84 | // the standard AWS credential chain (env vars / EC2-or-container IAM role) — |
| 85 | // no DNS token on disk. The dev boxes use their zone-scoped role for |
| 86 | // *.dev.11ch.at (docs/multi-edge.md). |
| 87 | // |
| 88 | // Returns (nil, reason) when nothing is configured, so the caller logs the human |
| 89 | // reason and falls back to per-host issuance rather than failing to boot. |
| 90 | func dnsProviderFromEnv() (dnsProvider, string) { |
| 91 | switch strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_DNS_PROVIDER"))) { |
| 92 | case "route53": |
| 93 | r53, err := newRoute53DNS(context.Background()) |
| 94 | if err != nil { |
| 95 | return nil, fmt.Sprintf("LCHAT_DNS_PROVIDER=route53 but AWS config load failed: %v", err) |
| 96 | } |
| 97 | return r53, "" |
| 98 | case "", "dnsimple": |
| 99 | token := os.Getenv("LCHAT_DNSIMPLE_TOKEN") |
| 100 | if token == "" { |
| 101 | return nil, "LCHAT_DNSIMPLE_TOKEN missing" |
| 102 | } |
| 103 | return newDNSimpleDNS(token), "" |
| 104 | default: |
| 105 | return nil, fmt.Sprintf("unknown LCHAT_DNS_PROVIDER %q (want dnsimple or route53)", os.Getenv("LCHAT_DNS_PROVIDER")) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | type wildcardManager struct { |
| 110 | domains []string |
| 111 | // cache is the shared cert cache (certcache.go) — the same backend |
| 112 | // autocert uses for per-host certs, so pooled routers share the wildcard |
| 113 | // too. Under the dir backend the keys land as the historical file names |
| 114 | // beside autocert's certs (wildcard-<domain>.pem, wildcard-account.key). |
| 115 | cache autocert.Cache |
| 116 | dns dnsProvider |
| 117 | directory string |
| 118 | // One atomically-swapped cert per domain, read on every 11ch.at handshake. |
| 119 | certs map[string]*atomic.Pointer[tls.Certificate] |
| 120 | } |
| 121 | |
| 122 | func newWildcardManager(domains []string, cache autocert.Cache, dns dnsProvider) *wildcardManager { |
| 123 | w := &wildcardManager{ |
| 124 | domains: domains, |
| 125 | cache: cache, |
| 126 | dns: dns, |
| 127 | directory: envOr("LCHAT_ACME_DIRECTORY", letsEncryptDirectory), |
| 128 | certs: map[string]*atomic.Pointer[tls.Certificate]{}, |
| 129 | } |
| 130 | // The startup load is synchronous (runRouter wires certFor into the TLS |
| 131 | // config right after this), so bound it: a dir read is instant, but a |
| 132 | // remote cache backend must degrade to "re-obtain" rather than hang boot. |
| 133 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| 134 | defer cancel() |
| 135 | for _, d := range domains { |
| 136 | w.certs[d] = &atomic.Pointer[tls.Certificate]{} |
| 137 | raw, err := w.cache.Get(ctx, wildcardPEMKey(d)) |
| 138 | if errors.Is(err, autocert.ErrCacheMiss) { |
| 139 | continue // first run for this domain — run() obtains one |
| 140 | } |
| 141 | if err == nil { |
| 142 | var cert *tls.Certificate |
| 143 | if cert, err = parseWildcardPEM(raw); err == nil { |
| 144 | w.certs[d].Store(cert) |
| 145 | log.Printf("router: wildcard *.%s loaded from cache (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| 146 | continue |
| 147 | } |
| 148 | } |
| 149 | log.Printf("router: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", d, err) |
| 150 | } |
| 151 | return w |
| 152 | } |
| 153 | |
| 154 | // wildcardPEMKey is the cache key for a domain's wildcard cert+key PEM. It is |
| 155 | // deliberately the pre-seam file name, so the dir backend reads the PEMs an |
| 156 | // older binary wrote to the acme cache dir with no migration. |
| 157 | func wildcardPEMKey(domain string) string { |
| 158 | return "wildcard-" + domain + ".pem" |
| 159 | } |
| 160 | |
| 161 | // certFor returns the wildcard cert for serverName, or nil when the name isn't |
| 162 | // exactly one label under a configured domain (a wildcard doesn't cover the |
| 163 | // apex or deeper levels) or no usable cert is loaded — nil sends the handshake |
| 164 | // to the per-host autocert path. |
| 165 | func (w *wildcardManager) certFor(serverName string) *tls.Certificate { |
| 166 | sn := strings.ToLower(strings.TrimSuffix(serverName, ".")) |
| 167 | for _, d := range w.domains { |
| 168 | sub, ok := strings.CutSuffix(sn, "."+d) |
| 169 | if !ok || sub == "" || strings.Contains(sub, ".") { |
| 170 | continue |
| 171 | } |
| 172 | cert := w.certs[d].Load() |
| 173 | if cert == nil || time.Now().After(cert.Leaf.NotAfter) { |
| 174 | // Never serve a dead cert: a wildcard whose renewal has been broken |
| 175 | // past its actual expiry falls back to per-host issuance (today's |
| 176 | // behaviour) instead of hard-failing every space handshake. |
| 177 | return nil |
| 178 | } |
| 179 | return cert |
| 180 | } |
| 181 | return nil |
| 182 | } |
| 183 | |
| 184 | // run keeps the wildcard certs current: an immediate check at startup (first |
| 185 | // issuance), then every 12h, dropping to hourly retries after a failure. |
| 186 | func (w *wildcardManager) run() { |
| 187 | for { |
| 188 | delay := 12 * time.Hour |
| 189 | for _, d := range w.domains { |
| 190 | if !w.needsObtain(d, time.Now()) { |
| 191 | continue |
| 192 | } |
| 193 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| 194 | err := w.obtain(ctx, d) |
| 195 | cancel() |
| 196 | if err != nil { |
| 197 | log.Printf("router: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", d, err) |
| 198 | delay = time.Hour |
| 199 | continue |
| 200 | } |
| 201 | cert := w.certs[d].Load() |
| 202 | log.Printf("router: wildcard *.%s obtained (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| 203 | } |
| 204 | time.Sleep(delay) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func (w *wildcardManager) needsObtain(domain string, now time.Time) bool { |
| 209 | cert := w.certs[domain].Load() |
| 210 | return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter) |
| 211 | } |
| 212 | |
| 213 | // obtain runs one full ACME DNS-01 issuance for *.domain and swaps the result in. |
| 214 | func (w *wildcardManager) obtain(ctx context.Context, domain string) error { |
| 215 | acctKey, err := w.accountKey(ctx) |
| 216 | if err != nil { |
| 217 | return fmt.Errorf("account key: %v", err) |
| 218 | } |
| 219 | client := &acme.Client{Key: acctKey, DirectoryURL: w.directory} |
| 220 | if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists { |
| 221 | return fmt.Errorf("acme register: %v", err) |
| 222 | } |
| 223 | |
| 224 | order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + domain}}) |
| 225 | if err != nil { |
| 226 | return fmt.Errorf("authorize order: %v", err) |
| 227 | } |
| 228 | for _, zurl := range order.AuthzURLs { |
| 229 | if err := w.solveDNS01(ctx, client, domain, zurl); err != nil { |
| 230 | return err |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 235 | if err != nil { |
| 236 | return err |
| 237 | } |
| 238 | csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ |
| 239 | Subject: pkix.Name{CommonName: "*." + domain}, |
| 240 | DNSNames: []string{"*." + domain}, |
| 241 | }, certKey) |
| 242 | if err != nil { |
| 243 | return fmt.Errorf("csr: %v", err) |
| 244 | } |
| 245 | ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true) |
| 246 | if err != nil { |
| 247 | return fmt.Errorf("finalize: %v", err) |
| 248 | } |
| 249 | leaf, err := x509.ParseCertificate(ders[0]) |
| 250 | if err != nil { |
| 251 | return fmt.Errorf("parse issued cert: %v", err) |
| 252 | } |
| 253 | cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf} |
| 254 | raw, err := encodeWildcardPEM(cert, certKey) |
| 255 | if err != nil { |
| 256 | return fmt.Errorf("encode: %v", err) |
| 257 | } |
| 258 | if err := w.cache.Put(ctx, wildcardPEMKey(domain), raw); err != nil { |
| 259 | return fmt.Errorf("persist: %v", err) |
| 260 | } |
| 261 | w.certs[domain].Store(cert) |
| 262 | return nil |
| 263 | } |
| 264 | |
| 265 | // solveDNS01 answers one authorization: publish the challenge TXT record, |
| 266 | // wait for the CA to see it, and always clean the record up afterwards. |
| 267 | func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, domain, authzURL string) error { |
| 268 | authz, err := client.GetAuthorization(ctx, authzURL) |
| 269 | if err != nil { |
| 270 | return fmt.Errorf("get authorization: %v", err) |
| 271 | } |
| 272 | if authz.Status == acme.StatusValid { |
| 273 | return nil // a recent order already proved this name |
| 274 | } |
| 275 | var chal *acme.Challenge |
| 276 | for _, c := range authz.Challenges { |
| 277 | if c.Type == "dns-01" { |
| 278 | chal = c |
| 279 | break |
| 280 | } |
| 281 | } |
| 282 | if chal == nil { |
| 283 | return errors.New("CA offered no dns-01 challenge") |
| 284 | } |
| 285 | txt, err := client.DNS01ChallengeRecord(chal.Token) |
| 286 | if err != nil { |
| 287 | return err |
| 288 | } |
| 289 | |
| 290 | cleanup, err := w.dns.present(ctx, domain, "_acme-challenge", txt) |
| 291 | if err != nil { |
| 292 | return fmt.Errorf("publish challenge TXT: %v", err) |
| 293 | } |
| 294 | // Clean up with a fresh context: the record must not outlive the attempt |
| 295 | // even when ctx is already dead (stale challenge TXTs break the NEXT run). |
| 296 | defer func() { |
| 297 | cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 298 | defer cancel() |
| 299 | if err := cleanup(cctx); err != nil { |
| 300 | log.Printf("router: wildcard *.%s: cleanup of challenge TXT failed: %v", domain, err) |
| 301 | } |
| 302 | }() |
| 303 | |
| 304 | // Best-effort: give the record a moment to reach the authoritative NS |
| 305 | // before telling the CA to look. Proceed on timeout — the CA does its own |
| 306 | // retries, and a hard failure just comes back through the retry loop. |
| 307 | waitForTXT(ctx, "_acme-challenge."+domain, txt, time.Minute) |
| 308 | |
| 309 | if _, err := client.Accept(ctx, chal); err != nil { |
| 310 | return fmt.Errorf("accept challenge: %v", err) |
| 311 | } |
| 312 | if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil { |
| 313 | return fmt.Errorf("authorization: %v", err) |
| 314 | } |
| 315 | return nil |
| 316 | } |
| 317 | |
| 318 | // wildcardAccountKeyName is the ACME account key's cache key — the pre-seam |
| 319 | // file name, for the same no-migration reason as wildcardPEMKey. |
| 320 | const wildcardAccountKeyName = "wildcard-account.key" |
| 321 | |
| 322 | // accountKey loads (or mints) the ACME account key. Separate from autocert's |
| 323 | // account on purpose: this flow manages its own registration lifecycle. |
| 324 | func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) { |
| 325 | raw, err := w.cache.Get(ctx, wildcardAccountKeyName) |
| 326 | if err == nil { |
| 327 | block, _ := pem.Decode(raw) |
| 328 | if block == nil { |
| 329 | return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName) |
| 330 | } |
| 331 | return x509.ParseECPrivateKey(block.Bytes) |
| 332 | } |
| 333 | if !errors.Is(err, autocert.ErrCacheMiss) { |
| 334 | // Only a genuine miss mints a key: treating a backend fault as "no key |
| 335 | // yet" would register a fresh ACME account on every hiccup. Fail and |
| 336 | // let obtain()'s hourly retry come back around. |
| 337 | return nil, err |
| 338 | } |
| 339 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 340 | if err != nil { |
| 341 | return nil, err |
| 342 | } |
| 343 | der, err := x509.MarshalECPrivateKey(key) |
| 344 | if err != nil { |
| 345 | return nil, err |
| 346 | } |
| 347 | // Crash-safety (temp + rename, 0600) is the cache backend's contract now — |
| 348 | // the dir backend inherits it from autocert.DirCache. |
| 349 | raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) |
| 350 | if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil { |
| 351 | return nil, err |
| 352 | } |
| 353 | return key, nil |
| 354 | } |
| 355 | |
| 356 | // waitForTXT polls DNS until name resolves to a TXT containing value, or the |
| 357 | // deadline passes (best-effort — see caller). |
| 358 | func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) { |
| 359 | deadline := time.Now().Add(timeout) |
| 360 | for time.Now().Before(deadline) && ctx.Err() == nil { |
| 361 | if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil { |
| 362 | for _, t := range txts { |
| 363 | if t == value { |
| 364 | return |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | time.Sleep(3 * time.Second) |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | // ---- PEM encode/parse (key + chain in one blob; the cert cache owns |
| 373 | // placement, permissions, and write atomicity) ---------------------------------- |
| 374 | |
| 375 | func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) { |
| 376 | var buf bytes.Buffer |
| 377 | der, err := x509.MarshalECPrivateKey(key) |
| 378 | if err != nil { |
| 379 | return nil, err |
| 380 | } |
| 381 | if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil { |
| 382 | return nil, err |
| 383 | } |
| 384 | for _, c := range cert.Certificate { |
| 385 | if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil { |
| 386 | return nil, err |
| 387 | } |
| 388 | } |
| 389 | return buf.Bytes(), nil |
| 390 | } |
| 391 | |
| 392 | func parseWildcardPEM(raw []byte) (*tls.Certificate, error) { |
| 393 | cert := &tls.Certificate{} |
| 394 | for { |
| 395 | var block *pem.Block |
| 396 | block, raw = pem.Decode(raw) |
| 397 | if block == nil { |
| 398 | break |
| 399 | } |
| 400 | switch block.Type { |
| 401 | case "EC PRIVATE KEY": |
| 402 | key, err := x509.ParseECPrivateKey(block.Bytes) |
| 403 | if err != nil { |
| 404 | return nil, err |
| 405 | } |
| 406 | cert.PrivateKey = key |
| 407 | case "CERTIFICATE": |
| 408 | cert.Certificate = append(cert.Certificate, block.Bytes) |
| 409 | } |
| 410 | } |
| 411 | if cert.PrivateKey == nil || len(cert.Certificate) == 0 { |
| 412 | return nil, errors.New("missing key or certificate in PEM") |
| 413 | } |
| 414 | leaf, err := x509.ParseCertificate(cert.Certificate[0]) |
| 415 | if err != nil { |
| 416 | return nil, err |
| 417 | } |
| 418 | cert.Leaf = leaf |
| 419 | return cert, nil |
| 420 | } |
| 421 | |
| 422 | // ---- DNSimple zone records (the existing internal/bot client is registrar/ |
| 423 | // OAuth-only; the router needs record create/delete with a long-lived token) -- |
| 424 | |
| 425 | type dnsimpleDNS struct { |
| 426 | base string |
| 427 | token string |
| 428 | account string // discovered via /v2/whoami on first use |
| 429 | } |
| 430 | |
| 431 | // newDNSimpleDNS builds the default DNS-01 backend from an account API token, |
| 432 | // honouring the LCHAT_DNSIMPLE_BASE override (kept here so all env reads for |
| 433 | // this backend live in one place). |
| 434 | func newDNSimpleDNS(token string) *dnsimpleDNS { |
| 435 | return &dnsimpleDNS{ |
| 436 | base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"), |
| 437 | token: token, |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | // present satisfies dnsProvider: create the challenge TXT, then hand back a |
| 442 | // closure that deletes it by the record id DNSimple returned. createTXT and |
| 443 | // deleteRecord are unchanged, so the DNSimple wire behaviour is byte-for-byte |
| 444 | // what it was before the seam existed. |
| 445 | func (d *dnsimpleDNS) present(ctx context.Context, zone, name, value string) (func(context.Context) error, error) { |
| 446 | id, err := d.createTXT(ctx, zone, name, value) |
| 447 | if err != nil { |
| 448 | return nil, fmt.Errorf("dnsimple: create TXT: %v", err) |
| 449 | } |
| 450 | return func(cctx context.Context) error { return d.deleteRecord(cctx, zone, id) }, nil |
| 451 | } |
| 452 | |
| 453 | func (d *dnsimpleDNS) do(ctx context.Context, method, path string, body, out any) error { |
| 454 | var rdr io.Reader |
| 455 | if body != nil { |
| 456 | raw, err := json.Marshal(body) |
| 457 | if err != nil { |
| 458 | return err |
| 459 | } |
| 460 | rdr = bytes.NewReader(raw) |
| 461 | } |
| 462 | req, err := http.NewRequestWithContext(ctx, method, d.base+path, rdr) |
| 463 | if err != nil { |
| 464 | return err |
| 465 | } |
| 466 | req.Header.Set("Authorization", "Bearer "+d.token) |
| 467 | req.Header.Set("Accept", "application/json") |
| 468 | if body != nil { |
| 469 | req.Header.Set("Content-Type", "application/json") |
| 470 | } |
| 471 | client := &http.Client{Timeout: 30 * time.Second} |
| 472 | resp, err := client.Do(req) |
| 473 | if err != nil { |
| 474 | return err |
| 475 | } |
| 476 | defer resp.Body.Close() |
| 477 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| 478 | msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) |
| 479 | return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(msg))) |
| 480 | } |
| 481 | if out != nil { |
| 482 | return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) |
| 483 | } |
| 484 | return nil |
| 485 | } |
| 486 | |
| 487 | func (d *dnsimpleDNS) accountID(ctx context.Context) (string, error) { |
| 488 | if d.account != "" { |
| 489 | return d.account, nil |
| 490 | } |
| 491 | var who struct { |
| 492 | Data struct { |
| 493 | Account *struct { |
| 494 | ID int64 `json:"id"` |
| 495 | } `json:"account"` |
| 496 | } `json:"data"` |
| 497 | } |
| 498 | if err := d.do(ctx, "GET", "/v2/whoami", nil, &who); err != nil { |
| 499 | return "", err |
| 500 | } |
| 501 | if who.Data.Account == nil { |
| 502 | return "", errors.New("whoami: token is not an account token") |
| 503 | } |
| 504 | d.account = fmt.Sprint(who.Data.Account.ID) |
| 505 | return d.account, nil |
| 506 | } |
| 507 | |
| 508 | func (d *dnsimpleDNS) createTXT(ctx context.Context, zone, name, content string) (int64, error) { |
| 509 | acct, err := d.accountID(ctx) |
| 510 | if err != nil { |
| 511 | return 0, err |
| 512 | } |
| 513 | var created struct { |
| 514 | Data struct { |
| 515 | ID int64 `json:"id"` |
| 516 | } `json:"data"` |
| 517 | } |
| 518 | err = d.do(ctx, "POST", "/v2/"+acct+"/zones/"+zone+"/records", map[string]any{ |
| 519 | "name": name, "type": "TXT", "content": content, "ttl": 60, |
| 520 | }, &created) |
| 521 | if err != nil { |
| 522 | return 0, err |
| 523 | } |
| 524 | return created.Data.ID, nil |
| 525 | } |
| 526 | |
| 527 | func (d *dnsimpleDNS) deleteRecord(ctx context.Context, zone string, id int64) error { |
| 528 | acct, err := d.accountID(ctx) |
| 529 | if err != nil { |
| 530 | return err |
| 531 | } |
| 532 | return d.do(ctx, "DELETE", fmt.Sprintf("/v2/%s/zones/%s/records/%d", acct, zone, id), nil, nil) |
| 533 | } |
| 534 | |
| 535 | // splitDomains parses the LCHAT_WILDCARD_DOMAINS comma list. |
| 536 | func splitDomains(s string) []string { |
| 537 | var out []string |
| 538 | for _, d := range strings.Split(s, ",") { |
| 539 | if d = strings.ToLower(strings.TrimSpace(d)); d != "" { |
| 540 | out = append(out, d) |
| 541 | } |
| 542 | } |
| 543 | return out |
| 544 | } |
| 545 | |