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:
2026-10-26 00:32:31 +08:00
parent cba7d2366b
commit ebae420ab1
27 changed files with 3745 additions and 56 deletions
+61 -11
View File
@@ -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
}