Issue real certificates via ACME DNS-01
Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PorkbunDNS manages TXT records through Porkbun's DNS API
|
||||
// (https://api.porkbun.com/api/json/v3).
|
||||
type PorkbunDNS struct {
|
||||
APIKey string
|
||||
SecretKey string
|
||||
Zone string
|
||||
BaseURL string // default https://api.porkbun.com/api/json/v3
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
const porkbunDefaultBaseURL = "https://api.porkbun.com/api/json/v3"
|
||||
|
||||
func (p *PorkbunDNS) base() string {
|
||||
if p.BaseURL != "" {
|
||||
return p.BaseURL
|
||||
}
|
||||
return porkbunDefaultBaseURL
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) client() *http.Client {
|
||||
if p.HTTP != nil {
|
||||
return p.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) error {
|
||||
body["apikey"] = p.APIKey
|
||||
body["secretapikey"] = p.SecretKey
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data)
|
||||
}
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
|
||||
}
|
||||
if result.Status != "SUCCESS" {
|
||||
return fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
||||
relative, err := relativeRecordName(name, p.Zone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
|
||||
"name": relative, "type": "TXT", "content": value, "ttl": "600",
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
|
||||
relative, err := relativeRecordName(name, p.Zone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
|
||||
}
|
||||
Reference in New Issue
Block a user