390 lines
12 KiB
Go
390 lines
12 KiB
Go
package netdiag
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// pasteUserAgent identifies tslink to the paste services. 0x0.st rejects the
|
||
// Go default user agent with 403, so this is not merely cosmetic.
|
||
const pasteUserAgent = "tslink/1.0 (+diagnostics)"
|
||
|
||
// pasteTimeout bounds a single upload attempt, including connect, write and
|
||
// the read of the response body.
|
||
const pasteTimeout = 20 * time.Second
|
||
|
||
// MaxPasteBytes is the largest payload accepted by [Upload]. Bigger dumps are
|
||
// rejected rather than truncated: the tail of a log is usually the part the
|
||
// helper needs, and silently dropping it wastes everyone's time.
|
||
const MaxPasteBytes = 1 << 20
|
||
|
||
// pasteReadLimit caps how much of a response body is read back. A URL is a
|
||
// couple hundred bytes; anything larger is an error page.
|
||
const pasteReadLimit = 64 << 10
|
||
|
||
// PasteTarget is one supported paste service.
|
||
type PasteTarget struct {
|
||
Key string // stable id used by the UI
|
||
Name string // human label
|
||
Note string // short caveat: retention, region reachability
|
||
}
|
||
|
||
// pasteService is a target plus the code that performs the upload.
|
||
type pasteService struct {
|
||
PasteTarget
|
||
upload func(ctx context.Context, text string) (string, error)
|
||
}
|
||
|
||
// pasteServices is the internal registry, in preference order.
|
||
var pasteServices = []pasteService{
|
||
{
|
||
PasteTarget: PasteTarget{
|
||
Key: "0x0",
|
||
Name: "0x0.st",
|
||
Note: "保留 30 天以上(按大小递减),境内访问可能较慢",
|
||
},
|
||
upload: uploadNullPointer,
|
||
},
|
||
{
|
||
PasteTarget: PasteTarget{
|
||
Key: "paste_rs",
|
||
Name: "paste.rs",
|
||
Note: "无固定保留期,容量满后自动淘汰旧内容",
|
||
},
|
||
upload: uploadPasteRS,
|
||
},
|
||
{
|
||
PasteTarget: PasteTarget{
|
||
Key: "dpaste",
|
||
Name: "dpaste.org",
|
||
Note: "保留 7 天后自动删除",
|
||
},
|
||
upload: uploadDpaste,
|
||
},
|
||
{
|
||
PasteTarget: PasteTarget{
|
||
Key: "termbin",
|
||
Name: "termbin.com",
|
||
Note: "纯 TCP (9999),HTTPS 被墙时仍可用;保留约 1 个月",
|
||
},
|
||
upload: uploadTermbin,
|
||
},
|
||
}
|
||
|
||
// PasteTargets lists the supported services in preference order.
|
||
func PasteTargets() []PasteTarget {
|
||
out := make([]PasteTarget, 0, len(pasteServices))
|
||
for _, s := range pasteServices {
|
||
out = append(out, s.PasteTarget)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PasteResult is a successful upload.
|
||
type PasteResult struct {
|
||
URL string
|
||
Target string
|
||
Bytes int
|
||
Uploaded time.Time
|
||
}
|
||
|
||
// ErrPasteEmpty is returned when there is nothing to upload.
|
||
var ErrPasteEmpty = errors.New("netdiag: refusing to upload empty text")
|
||
|
||
// ErrPasteTooLarge is returned when the payload exceeds [MaxPasteBytes].
|
||
var ErrPasteTooLarge = fmt.Errorf("netdiag: text exceeds the %d byte paste limit", MaxPasteBytes)
|
||
|
||
// Upload sends text to the named target and returns the resulting public URL.
|
||
// An empty targetKey tries every target in [PasteTargets] order and returns the
|
||
// first success; when all of them fail the returned error names each failure.
|
||
//
|
||
// The caller MUST redact the text before calling: uploading is an outbound
|
||
// publication of user data to a third party. core.LogBuffer.ExportText performs
|
||
// that redaction (leave ExportOptions.NoRedact false). The resulting paste is
|
||
// PUBLIC — anyone holding the URL can read it, and most of these services offer
|
||
// no way to delete it afterwards.
|
||
//
|
||
// Every attempt is bounded by a ~20s timeout and honours ctx.
|
||
func Upload(ctx context.Context, targetKey, text string, logger *slog.Logger) (*PasteResult, error) {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
logger = logger.With(slog.String("from", "paste"))
|
||
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil, ErrPasteEmpty
|
||
}
|
||
if len(text) > MaxPasteBytes {
|
||
return nil, fmt.Errorf("%w (got %d bytes); filter the log before sharing", ErrPasteTooLarge, len(text))
|
||
}
|
||
|
||
candidates := pasteServices
|
||
if targetKey != "" {
|
||
svc, ok := lookupPasteService(targetKey)
|
||
if !ok {
|
||
return nil, fmt.Errorf("netdiag: unknown paste target %q", targetKey)
|
||
}
|
||
candidates = []pasteService{svc}
|
||
}
|
||
|
||
var failures []string
|
||
for _, svc := range candidates {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := attemptPaste(ctx, svc, text)
|
||
if err != nil {
|
||
logger.With(
|
||
slog.String("target", svc.Key),
|
||
slog.String("error", err.Error()),
|
||
).Debug("paste upload failed")
|
||
failures = append(failures, fmt.Sprintf("%s: %v", svc.Key, err))
|
||
continue
|
||
}
|
||
logger.With(
|
||
slog.String("target", res.Target),
|
||
slog.String("url", res.URL),
|
||
slog.Int("bytes", res.Bytes),
|
||
).Info("uploaded diagnostic paste")
|
||
return res, nil
|
||
}
|
||
|
||
if len(candidates) == 1 {
|
||
return nil, fmt.Errorf("netdiag: upload to %s failed: %s", candidates[0].Key, strings.TrimPrefix(failures[0], candidates[0].Key+": "))
|
||
}
|
||
return nil, fmt.Errorf("netdiag: every paste target failed: %s", strings.Join(failures, "; "))
|
||
}
|
||
|
||
// attemptPaste runs one upload under its own timeout and validates the URL the
|
||
// service handed back.
|
||
func attemptPaste(ctx context.Context, svc pasteService, text string) (*PasteResult, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, pasteTimeout)
|
||
defer cancel()
|
||
|
||
raw, err := svc.upload(ctx, text)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
clean, err := normalisePasteURL(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &PasteResult{
|
||
URL: clean,
|
||
Target: svc.Key,
|
||
Bytes: len(text),
|
||
Uploaded: time.Now(),
|
||
}, nil
|
||
}
|
||
|
||
// lookupPasteService finds a service by its stable key.
|
||
func lookupPasteService(key string) (pasteService, bool) {
|
||
for _, s := range pasteServices {
|
||
if s.Key == key {
|
||
return s, true
|
||
}
|
||
}
|
||
return pasteService{}, false
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Response validation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// normalisePasteURL trims a service response down to a single http(s) URL.
|
||
// termbin answers with a bare host such as "termbin.com/abcd", so a missing
|
||
// scheme is tolerated and upgraded to https. Anything that smells like an HTML
|
||
// error page is rejected outright.
|
||
func normalisePasteURL(raw string) (string, error) {
|
||
s := strings.TrimSpace(raw)
|
||
// termbin pads its reply with NULs and terminal escapes.
|
||
s = strings.Trim(s, "\x00\r\n\t ")
|
||
if s == "" {
|
||
return "", errors.New("empty response")
|
||
}
|
||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||
s = strings.TrimSpace(s[:i])
|
||
}
|
||
if looksLikeHTML(s) {
|
||
return "", fmt.Errorf("service returned an error page: %s", snippet(s))
|
||
}
|
||
if len(s) > 512 {
|
||
return "", fmt.Errorf("response is not a URL: %s", snippet(s))
|
||
}
|
||
if !strings.Contains(s, "://") {
|
||
s = "https://" + s
|
||
}
|
||
|
||
u, err := url.Parse(s)
|
||
if err != nil {
|
||
return "", fmt.Errorf("response is not a URL: %s", snippet(raw))
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return "", fmt.Errorf("response has unexpected scheme %q", u.Scheme)
|
||
}
|
||
if u.Host == "" || !strings.Contains(u.Host, ".") {
|
||
return "", fmt.Errorf("response has no usable host: %s", snippet(s))
|
||
}
|
||
return u.String(), nil
|
||
}
|
||
|
||
// looksLikeHTML reports whether s is the beginning of an HTML document rather
|
||
// than a URL.
|
||
func looksLikeHTML(s string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(s))
|
||
return strings.HasPrefix(lower, "<") ||
|
||
strings.Contains(lower, "<html") ||
|
||
strings.Contains(lower, "<!doctype")
|
||
}
|
||
|
||
// snippet shortens an untrusted response for inclusion in an error message.
|
||
func snippet(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
s = strings.ReplaceAll(s, "\n", " ")
|
||
if len(s) > 120 {
|
||
return s[:120] + "…"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// HTTP plumbing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// pasteHTTPClient is shared by the HTTP-based targets. The per-attempt context
|
||
// timeout is the real deadline; the client timeout is a backstop.
|
||
var pasteHTTPClient = &http.Client{
|
||
Timeout: pasteTimeout,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 5 {
|
||
return errors.New("too many redirects")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// doPaste issues one request and returns the (size-limited) response body.
|
||
func doPaste(ctx context.Context, method, endpoint, contentType string, body []byte) (string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("User-Agent", pasteUserAgent)
|
||
req.Header.Set("Accept", "text/plain, */*")
|
||
if contentType != "" {
|
||
req.Header.Set("Content-Type", contentType)
|
||
}
|
||
req.ContentLength = int64(len(body))
|
||
|
||
resp, err := pasteHTTPClient.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, pasteReadLimit))
|
||
if err != nil {
|
||
return "", fmt.Errorf("reading response: %w", err)
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, snippet(string(data)))
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
// uploadNullPointer posts to 0x0.st as multipart/form-data.
|
||
func uploadNullPointer(ctx context.Context, text string) (string, error) {
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
part, err := mw.CreateFormFile("file", "tslink-log.txt")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if _, err := io.WriteString(part, text); err != nil {
|
||
return "", err
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
return "", err
|
||
}
|
||
return doPaste(ctx, http.MethodPost, "https://0x0.st/", mw.FormDataContentType(), buf.Bytes())
|
||
}
|
||
|
||
// uploadPasteRS posts the raw text to paste.rs.
|
||
func uploadPasteRS(ctx context.Context, text string) (string, error) {
|
||
return doPaste(ctx, http.MethodPost, "https://paste.rs/", "text/plain; charset=utf-8", []byte(text))
|
||
}
|
||
|
||
// uploadDpaste posts a urlencoded form to dpaste.org and asks for a bare URL
|
||
// back rather than the JSON representation.
|
||
func uploadDpaste(ctx context.Context, text string) (string, error) {
|
||
form := url.Values{
|
||
"content": {text},
|
||
"lexer": {"text"},
|
||
"format": {"url"},
|
||
"expires": {"604800"},
|
||
}
|
||
return doPaste(ctx, http.MethodPost, "https://dpaste.org/api/",
|
||
"application/x-www-form-urlencoded", []byte(form.Encode()))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// termbin (raw TCP)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// termbinAddr is the netcat-style endpoint termbin.com exposes.
|
||
const termbinAddr = "termbin.com:9999"
|
||
|
||
// uploadTermbin writes the text over a plain TCP connection, half-closes the
|
||
// write side so the server knows the paste is complete, then reads the URL it
|
||
// replies with. No TLS is involved, which is exactly why this target survives
|
||
// environments where the HTTPS paste sites are unreachable.
|
||
func uploadTermbin(ctx context.Context, text string) (string, error) {
|
||
var d net.Dialer
|
||
conn, err := d.DialContext(ctx, "tcp", termbinAddr)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer conn.Close()
|
||
|
||
// Bound the whole exchange, and make sure a hung read is interrupted when
|
||
// the caller cancels: a blocked socket must never freeze the GUI.
|
||
if dl, ok := ctx.Deadline(); ok {
|
||
_ = conn.SetDeadline(dl)
|
||
} else {
|
||
_ = conn.SetDeadline(time.Now().Add(pasteTimeout))
|
||
}
|
||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||
defer stop()
|
||
|
||
tcp, ok := conn.(*net.TCPConn)
|
||
if !ok {
|
||
return "", errors.New("termbin: connection is not tcp")
|
||
}
|
||
if _, err := io.WriteString(tcp, text); err != nil {
|
||
return "", fmt.Errorf("termbin: write: %w", err)
|
||
}
|
||
// Half-close: termbin only answers once it sees EOF on its read side.
|
||
if err := tcp.CloseWrite(); err != nil {
|
||
return "", fmt.Errorf("termbin: close write: %w", err)
|
||
}
|
||
|
||
data, err := io.ReadAll(io.LimitReader(tcp, pasteReadLimit))
|
||
if err != nil {
|
||
return "", fmt.Errorf("termbin: read: %w", err)
|
||
}
|
||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||
return "", ctxErr
|
||
}
|
||
return string(data), nil
|
||
}
|