Initial commit
This commit is contained in:
commit
612a19c01a
23 changed files with 2402 additions and 0 deletions
171
checker/sip_probe.go
Normal file
171
checker/sip_probe.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue