The new cmd/tunnel client keeps the API URL and tunnel identity in ~/.config/tunnel/config.json (0600), provisions the key/CSR and certificate, terminates TLS, proxies to local routes, reloads routes without a restart, and runs as a background service. `tunnel import` takes over an existing official CLI profile, preserving its tunnel ID, token, key and certificate. Server hardening: per-IP and per-tunnel rate limits for creates and binds, a CA cool-down after repeated ACME failures, a startup sweep for stale _acme-challenge records, admin metrics, a 60-second half-close timeout, and separate prioritized control / ordered data bridge queues (with `end` ordered after the final bytes). The dashboard shows the new metrics.
140 lines
3.6 KiB
Go
140 lines
3.6 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"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) ([]byte, error) {
|
|
body["apikey"] = p.APIKey
|
|
body["secretapikey"] = p.SecretKey
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := p.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, 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 nil, fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
|
|
}
|
|
if result.Status != "SUCCESS" {
|
|
return nil, fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
|
relative, err := relativeRecordName(name, p.Zone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
|
|
"name": relative, "type": "TXT", "content": value, "ttl": "600",
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
|
|
relative, err := relativeRecordName(name, p.Zone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
|
|
return err
|
|
}
|
|
|
|
type porkbunRecord struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// SweepChallenges removes every TXT record at a _acme-challenge.* name in the
|
|
// zone. It runs at startup, before this process starts any issuance, so any
|
|
// such records are leftovers from an interrupted attempt (single-node use).
|
|
func (p *PorkbunDNS) SweepChallenges(ctx context.Context) (int, error) {
|
|
data, err := p.post(ctx, "/dns/retrieve/"+p.Zone, map[string]string{})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var result struct {
|
|
Records []porkbunRecord `json:"records"`
|
|
}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return 0, fmt.Errorf("porkbun retrieve: %w", err)
|
|
}
|
|
seen := map[string]struct{}{}
|
|
removed := 0
|
|
for _, record := range result.Records {
|
|
if record.Type != "TXT" {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(record.Name, ".")
|
|
if !strings.HasPrefix(name, "_acme-challenge.") {
|
|
continue
|
|
}
|
|
relative, err := relativeRecordName(name, p.Zone)
|
|
if err != nil {
|
|
// Only records inside this zone are ours to delete.
|
|
continue
|
|
}
|
|
if _, done := seen[relative]; done {
|
|
continue
|
|
}
|
|
seen[relative] = struct{}{}
|
|
if _, err := p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{}); err != nil {
|
|
return removed, err
|
|
}
|
|
removed++
|
|
}
|
|
return removed, nil
|
|
}
|