package checker import ( "bufio" "crypto/rand" "encoding/hex" "errors" "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) callID := randHex(12) + "@happydomain.org" sipScheme := "sip" if transport == TransportTLS { sipScheme = "sips" } requestURI := fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper)) if transport == TransportTLS { requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port) } // 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\" ;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 ") && !strings.HasPrefix(statusLine, "SIP/2.1 ") { return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80)) } _, rest, _ := strings.Cut(statusLine, " ") parts := strings.SplitN(rest, " ", 2) if len(parts) < 1 { return nil, errors.New("malformed status line") } 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() }