Add the native Go client and finish M3 hardening
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.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -36,38 +37,38 @@ func (p *PorkbunDNS) client() *http.Client {
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) error {
|
||||
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 err
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data)
|
||||
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 fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
|
||||
return nil, 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, fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
|
||||
}
|
||||
return nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
||||
@@ -75,9 +76,10 @@ func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
|
||||
_, 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 {
|
||||
@@ -85,5 +87,53 @@ func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
|
||||
_, 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user