171 lines
4.6 KiB
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()
|
|
}
|