Files
tslink/netdiag/paste.go
T
2026-07-26 09:39:17 +00:00

390 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}