checker-sip/checker/sip_probe.go

171 lines
4.6 KiB
Go

package checker
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net"
"strconv"
"strings"
)
// sipResponse is the minimal parsed form of a SIP response line + headers
// we need to power the rule and the report.
type sipResponse struct {
StatusCode int
StatusPhrase string
Server string
UserAgent string // some stacks use this instead of Server
Contact string
Allow []string
}
// buildOptionsRequest returns a ready-to-send SIP OPTIONS message for
// the given target / transport pair.
//
// The message is deliberately minimal and RFC 3261 §11-conforming: just
// enough for any SIP stack to recognise it as an OPTIONS ping.
func buildOptionsRequest(target string, port uint16, transport Transport, localAddr string, userAgent string) string {
tUpper := "UDP"
switch transport {
case TransportTCP:
tUpper = "TCP"
case TransportTLS:
tUpper = "TLS"
}
branch := "z9hG4bK-" + randHex(8)
tag := randHex(6)
// Use an RFC 2606 reserved TLD so the host part of Call-ID never
// resolves to a real domain we don't control.
callID := randHex(12) + "@checker-sip.invalid"
sipScheme := "sip"
if transport == TransportTLS {
sipScheme = "sips"
}
var requestURI string
if transport == TransportTLS {
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
} else {
requestURI = fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
}
// Via uses the remote transport name; local address is a best-effort
// hint that servers echo back via ;rport. We don't actually listen
// on it, this is a one-shot probe.
lines := []string{
"OPTIONS " + requestURI + " SIP/2.0",
"Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport",
"Max-Forwards: 70",
"From: \"happyDomain\" <sip:check@checker-sip.invalid>;tag=" + tag,
"To: <" + sipScheme + ":ping@" + target + ">",
"Call-ID: " + callID,
"CSeq: 1 OPTIONS",
"User-Agent: " + userAgent,
"Accept: application/sdp",
"Content-Length: 0",
}
return strings.Join(lines, "\r\n") + "\r\n\r\n"
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// parseSIPResponse reads a SIP response from r and extracts the fields
// we care about. It tolerates bodies (reads Content-Length bytes) and
// truncates defensively so a chatty server can't OOM us.
func parseSIPResponse(r io.Reader) (*sipResponse, error) {
br := bufio.NewReaderSize(io.LimitReader(r, 16*1024), 8*1024)
statusLine, err := br.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read status line: %w", err)
}
statusLine = strings.TrimRight(statusLine, "\r\n")
if !strings.HasPrefix(statusLine, "SIP/2.0 ") {
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
}
_, rest, _ := strings.Cut(statusLine, " ")
parts := strings.SplitN(rest, " ", 2)
code, convErr := strconv.Atoi(strings.TrimSpace(parts[0]))
if convErr != nil {
return nil, fmt.Errorf("non-numeric status code %q", parts[0])
}
phrase := ""
if len(parts) == 2 {
phrase = strings.TrimSpace(parts[1])
}
resp := &sipResponse{StatusCode: code, StatusPhrase: phrase}
for {
line, err := br.ReadString('\n')
if err != nil && err != io.EOF {
return resp, fmt.Errorf("read header: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
// Fold continuation lines per RFC 3261 §7.3.1: a header line
// starting with whitespace continues the previous one. We don't
// need perfect fidelity, so just skip continuations.
if line[0] == ' ' || line[0] == '\t' {
continue
}
colon := strings.IndexByte(line, ':')
if colon < 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(line[:colon]))
value := strings.TrimSpace(line[colon+1:])
switch name {
case "server", "s":
resp.Server = value
case "user-agent":
resp.UserAgent = value
case "contact", "m":
if resp.Contact == "" {
resp.Contact = value
}
case "allow":
// SIP allows multiple Allow headers *or* comma-separated;
// handle both.
for m := range strings.SplitSeq(value, ",") {
m = strings.TrimSpace(strings.ToUpper(m))
if m != "" {
resp.Allow = append(resp.Allow, m)
}
}
}
if err == io.EOF {
break
}
}
return resp, nil
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// localAddrFor returns a best-effort "host:port" describing the local
// side of conn, or "0.0.0.0:0" if conn is nil (UDP probe before dial).
func localAddrFor(conn net.Conn) string {
if conn == nil {
return "0.0.0.0:0"
}
return conn.LocalAddr().String()
}