mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
639 lines
15 KiB
Go
639 lines
15 KiB
Go
package capture
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/gopacket"
|
|
"github.com/google/gopacket/layers"
|
|
)
|
|
|
|
// TextWriter writes human-readable one-line-per-packet summaries.
|
|
// It is not safe for concurrent use; callers must serialize access.
|
|
type TextWriter struct {
|
|
w io.Writer
|
|
verbose bool
|
|
ascii bool
|
|
flows map[dirKey]uint32
|
|
}
|
|
|
|
type dirKey struct {
|
|
src netip.AddrPort
|
|
dst netip.AddrPort
|
|
}
|
|
|
|
// NewTextWriter creates a text formatter that writes to w.
|
|
func NewTextWriter(w io.Writer, verbose, ascii bool) *TextWriter {
|
|
return &TextWriter{
|
|
w: w,
|
|
verbose: verbose,
|
|
ascii: ascii,
|
|
flows: make(map[dirKey]uint32),
|
|
}
|
|
}
|
|
|
|
// tag formats the fixed-width "[DIR PROTO]" prefix with right-aligned protocol.
|
|
func tag(dir Direction, proto string) string {
|
|
return fmt.Sprintf("[%-3s %4s]", dir, proto)
|
|
}
|
|
|
|
// WritePacket formats and writes a single packet line.
|
|
func (tw *TextWriter) WritePacket(ts time.Time, data []byte, dir Direction) error {
|
|
ts = ts.Local()
|
|
info, ok := parsePacketInfo(data)
|
|
if !ok {
|
|
_, err := fmt.Fprintf(tw.w, "%s [%-3s ?] ??? len=%d\n",
|
|
ts.Format("15:04:05.000000"), dir, len(data))
|
|
return err
|
|
}
|
|
|
|
timeStr := ts.Format("15:04:05.000000")
|
|
|
|
var err error
|
|
switch info.proto {
|
|
case protoTCP:
|
|
err = tw.writeTCP(timeStr, dir, &info, data)
|
|
case protoUDP:
|
|
err = tw.writeUDP(timeStr, dir, &info, data)
|
|
case protoICMP:
|
|
err = tw.writeICMPv4(timeStr, dir, &info, data)
|
|
case protoICMPv6:
|
|
err = tw.writeICMPv6(timeStr, dir, &info, data)
|
|
default:
|
|
var verbose string
|
|
if tw.verbose {
|
|
verbose = tw.verboseIP(data, info.family)
|
|
}
|
|
_, err = fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n",
|
|
timeStr, tag(dir, fmt.Sprintf("P%d", info.proto)),
|
|
info.srcIP, info.dstIP, len(data)-info.hdrLen, verbose)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
|
tcp := &layers.TCP{}
|
|
if err := tcp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
|
return tw.writeFallback(timeStr, dir, "TCP", info, data)
|
|
}
|
|
|
|
flags := tcpFlagsStr(tcp)
|
|
plen := len(tcp.Payload)
|
|
|
|
// Protocol annotation
|
|
var annotation string
|
|
if plen > 0 {
|
|
annotation = annotatePayload(tcp.Payload)
|
|
}
|
|
|
|
if !tw.verbose {
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n",
|
|
timeStr, tag(dir, "TCP"),
|
|
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
|
flags, plen, annotation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tw.ascii && plen > 0 {
|
|
return tw.writeASCII(tcp.Payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
relSeq, relAck := tw.relativeSeqAck(info, tcp.Seq, tcp.Ack)
|
|
|
|
var seqStr string
|
|
if plen > 0 {
|
|
seqStr = fmt.Sprintf(", seq %d:%d", relSeq, relSeq+uint32(plen))
|
|
} else {
|
|
seqStr = fmt.Sprintf(", seq %d", relSeq)
|
|
}
|
|
|
|
var ackStr string
|
|
if tcp.ACK {
|
|
ackStr = fmt.Sprintf(", ack %d", relAck)
|
|
}
|
|
|
|
var opts string
|
|
if s := formatTCPOptions(tcp.Options); s != "" {
|
|
opts = ", options [" + s + "]"
|
|
}
|
|
|
|
verbose := tw.verboseIP(data, info.family)
|
|
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n",
|
|
timeStr, tag(dir, "TCP"),
|
|
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
|
flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tw.ascii && plen > 0 {
|
|
return tw.writeASCII(tcp.Payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
|
udp := &layers.UDP{}
|
|
if err := udp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
|
return tw.writeFallback(timeStr, dir, "UDP", info, data)
|
|
}
|
|
|
|
plen := len(udp.Payload)
|
|
|
|
// DNS replaces the entire line format
|
|
if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
|
|
if s := formatDNSPayload(udp.Payload); s != "" {
|
|
var verbose string
|
|
if tw.verbose {
|
|
verbose = tw.verboseIP(data, info.family)
|
|
}
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n",
|
|
timeStr, tag(dir, "UDP"),
|
|
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
|
s, verbose)
|
|
return err
|
|
}
|
|
}
|
|
|
|
var verbose string
|
|
if tw.verbose {
|
|
verbose = tw.verboseIP(data, info.family)
|
|
}
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n",
|
|
timeStr, tag(dir, "UDP"),
|
|
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
|
plen, verbose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tw.ascii && plen > 0 {
|
|
return tw.writeASCII(udp.Payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tw *TextWriter) writeICMPv4(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
|
icmp := &layers.ICMPv4{}
|
|
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
|
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
|
|
}
|
|
|
|
var detail string
|
|
if icmp.TypeCode.Type() == layers.ICMPv4TypeEchoRequest || icmp.TypeCode.Type() == layers.ICMPv4TypeEchoReply {
|
|
detail = fmt.Sprintf("%s, id %d, seq %d", icmp.TypeCode.String(), icmp.Id, icmp.Seq)
|
|
} else {
|
|
detail = icmp.TypeCode.String()
|
|
}
|
|
|
|
var verbose string
|
|
if tw.verbose {
|
|
verbose = tw.verboseIP(data, info.family)
|
|
}
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
|
|
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, detail, len(data)-info.hdrLen, verbose)
|
|
return err
|
|
}
|
|
|
|
func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
|
icmp := &layers.ICMPv6{}
|
|
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
|
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
|
|
}
|
|
|
|
var verbose string
|
|
if tw.verbose {
|
|
verbose = tw.verboseIP(data, info.family)
|
|
}
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
|
|
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, icmp.TypeCode.String(), len(data)-info.hdrLen, verbose)
|
|
return err
|
|
}
|
|
|
|
func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error {
|
|
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n",
|
|
timeStr, tag(dir, proto),
|
|
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
|
len(data)-info.hdrLen)
|
|
return err
|
|
}
|
|
|
|
func (tw *TextWriter) verboseIP(data []byte, family uint8) string {
|
|
return fmt.Sprintf(", ttl %d, id %d, iplen %d",
|
|
ipTTL(data, family), ipID(data, family), len(data))
|
|
}
|
|
|
|
// relativeSeqAck returns seq/ack relative to the first seen value per direction.
|
|
func (tw *TextWriter) relativeSeqAck(info *packetInfo, seq, ack uint32) (relSeq, relAck uint32) {
|
|
fwd := dirKey{
|
|
src: netip.AddrPortFrom(info.srcIP, info.srcPort),
|
|
dst: netip.AddrPortFrom(info.dstIP, info.dstPort),
|
|
}
|
|
rev := dirKey{
|
|
src: netip.AddrPortFrom(info.dstIP, info.dstPort),
|
|
dst: netip.AddrPortFrom(info.srcIP, info.srcPort),
|
|
}
|
|
|
|
if isn, ok := tw.flows[fwd]; ok {
|
|
relSeq = seq - isn
|
|
} else {
|
|
tw.flows[fwd] = seq
|
|
}
|
|
|
|
if isn, ok := tw.flows[rev]; ok {
|
|
relAck = ack - isn
|
|
} else {
|
|
relAck = ack
|
|
}
|
|
|
|
return relSeq, relAck
|
|
}
|
|
|
|
// writeASCII prints payload bytes as printable ASCII.
|
|
func (tw *TextWriter) writeASCII(payload []byte) error {
|
|
if len(payload) == 0 {
|
|
return nil
|
|
}
|
|
buf := make([]byte, len(payload))
|
|
for i, b := range payload {
|
|
switch {
|
|
case b >= 0x20 && b < 0x7f:
|
|
buf[i] = b
|
|
case b == '\n' || b == '\r' || b == '\t':
|
|
buf[i] = b
|
|
default:
|
|
buf[i] = '.'
|
|
}
|
|
}
|
|
_, err := fmt.Fprintf(tw.w, "%s\n", buf)
|
|
return err
|
|
}
|
|
|
|
// --- TCP helpers ---
|
|
|
|
func ipTTL(data []byte, family uint8) uint8 {
|
|
if family == 4 && len(data) > 8 {
|
|
return data[8]
|
|
}
|
|
if family == 6 && len(data) > 7 {
|
|
return data[7]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func ipID(data []byte, family uint8) uint16 {
|
|
if family == 4 && len(data) >= 6 {
|
|
return binary.BigEndian.Uint16(data[4:6])
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func tcpFlagsStr(tcp *layers.TCP) string {
|
|
var buf [6]byte
|
|
n := 0
|
|
if tcp.SYN {
|
|
buf[n] = 'S'
|
|
n++
|
|
}
|
|
if tcp.FIN {
|
|
buf[n] = 'F'
|
|
n++
|
|
}
|
|
if tcp.RST {
|
|
buf[n] = 'R'
|
|
n++
|
|
}
|
|
if tcp.PSH {
|
|
buf[n] = 'P'
|
|
n++
|
|
}
|
|
if tcp.ACK {
|
|
buf[n] = '.'
|
|
n++
|
|
}
|
|
if tcp.URG {
|
|
buf[n] = 'U'
|
|
n++
|
|
}
|
|
if n == 0 {
|
|
return "none"
|
|
}
|
|
return string(buf[:n])
|
|
}
|
|
|
|
func formatTCPOptions(opts []layers.TCPOption) string {
|
|
var parts []string
|
|
for _, opt := range opts {
|
|
switch opt.OptionType {
|
|
case layers.TCPOptionKindEndList:
|
|
return strings.Join(parts, ",")
|
|
case layers.TCPOptionKindNop:
|
|
parts = append(parts, "nop")
|
|
case layers.TCPOptionKindMSS:
|
|
if len(opt.OptionData) == 2 {
|
|
parts = append(parts, fmt.Sprintf("mss %d", binary.BigEndian.Uint16(opt.OptionData)))
|
|
}
|
|
case layers.TCPOptionKindWindowScale:
|
|
if len(opt.OptionData) == 1 {
|
|
parts = append(parts, fmt.Sprintf("wscale %d", opt.OptionData[0]))
|
|
}
|
|
case layers.TCPOptionKindSACKPermitted:
|
|
parts = append(parts, "sackOK")
|
|
case layers.TCPOptionKindSACK:
|
|
blocks := len(opt.OptionData) / 8
|
|
parts = append(parts, fmt.Sprintf("sack %d", blocks))
|
|
case layers.TCPOptionKindTimestamps:
|
|
if len(opt.OptionData) == 8 {
|
|
tsval := binary.BigEndian.Uint32(opt.OptionData[0:4])
|
|
tsecr := binary.BigEndian.Uint32(opt.OptionData[4:8])
|
|
parts = append(parts, fmt.Sprintf("TS val %d ecr %d", tsval, tsecr))
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// --- Protocol annotation ---
|
|
|
|
// annotatePayload returns a protocol annotation string for known application protocols.
|
|
func annotatePayload(payload []byte) string {
|
|
if len(payload) < 4 {
|
|
return ""
|
|
}
|
|
|
|
s := string(payload)
|
|
|
|
// SSH banner: "SSH-2.0-OpenSSH_9.6\r\n"
|
|
if strings.HasPrefix(s, "SSH-") {
|
|
if end := strings.IndexByte(s, '\r'); end > 0 && end < 256 {
|
|
return ": " + s[:end]
|
|
}
|
|
}
|
|
|
|
// TLS records
|
|
if ann := annotateTLS(payload); ann != "" {
|
|
return ": " + ann
|
|
}
|
|
|
|
// HTTP request or response
|
|
for _, method := range [...]string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "PATCH ", "OPTIONS ", "CONNECT "} {
|
|
if strings.HasPrefix(s, method) {
|
|
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
|
|
return ": " + s[:end]
|
|
}
|
|
}
|
|
}
|
|
if strings.HasPrefix(s, "HTTP/") {
|
|
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
|
|
return ": " + s[:end]
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// annotateTLS returns a description for TLS handshake and alert records.
|
|
func annotateTLS(data []byte) string {
|
|
if len(data) < 6 {
|
|
return ""
|
|
}
|
|
|
|
switch data[0] {
|
|
case 0x16:
|
|
return annotateTLSHandshake(data)
|
|
case 0x15:
|
|
return annotateTLSAlert(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func annotateTLSHandshake(data []byte) string {
|
|
if len(data) < 10 {
|
|
return ""
|
|
}
|
|
switch data[5] {
|
|
case 0x01:
|
|
if sni := extractSNI(data); sni != "" {
|
|
return "TLS ClientHello SNI=" + sni
|
|
}
|
|
return "TLS ClientHello"
|
|
case 0x02:
|
|
return "TLS ServerHello"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func annotateTLSAlert(data []byte) string {
|
|
if len(data) < 7 {
|
|
return ""
|
|
}
|
|
severity := "warning"
|
|
if data[5] == 2 {
|
|
severity = "fatal"
|
|
}
|
|
return fmt.Sprintf("TLS Alert %s %s", severity, tlsAlertDesc(data[6]))
|
|
}
|
|
|
|
func tlsAlertDesc(code byte) string {
|
|
switch code {
|
|
case 0:
|
|
return "close_notify"
|
|
case 10:
|
|
return "unexpected_message"
|
|
case 40:
|
|
return "handshake_failure"
|
|
case 42:
|
|
return "bad_certificate"
|
|
case 43:
|
|
return "unsupported_certificate"
|
|
case 44:
|
|
return "certificate_revoked"
|
|
case 45:
|
|
return "certificate_expired"
|
|
case 48:
|
|
return "unknown_ca"
|
|
case 49:
|
|
return "access_denied"
|
|
case 50:
|
|
return "decode_error"
|
|
case 70:
|
|
return "protocol_version"
|
|
case 80:
|
|
return "internal_error"
|
|
case 86:
|
|
return "inappropriate_fallback"
|
|
case 90:
|
|
return "user_canceled"
|
|
case 112:
|
|
return "unrecognized_name"
|
|
default:
|
|
return fmt.Sprintf("alert(%d)", code)
|
|
}
|
|
}
|
|
|
|
// extractSNI parses a TLS ClientHello and returns the SNI server name.
|
|
func extractSNI(data []byte) string {
|
|
if len(data) < 6 || data[0] != 0x16 {
|
|
return ""
|
|
}
|
|
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
|
|
handshake := data[5:]
|
|
if len(handshake) > recordLen {
|
|
handshake = handshake[:recordLen]
|
|
}
|
|
|
|
if len(handshake) < 4 || handshake[0] != 0x01 {
|
|
return ""
|
|
}
|
|
hsLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3])
|
|
body := handshake[4:]
|
|
if len(body) > hsLen {
|
|
body = body[:hsLen]
|
|
}
|
|
|
|
extPos := clientHelloExtensionsOffset(body)
|
|
if extPos < 0 {
|
|
return ""
|
|
}
|
|
return findSNIExtension(body, extPos)
|
|
}
|
|
|
|
// clientHelloExtensionsOffset returns the byte offset where extensions begin
|
|
// within the ClientHello body, or -1 if the body is too short.
|
|
func clientHelloExtensionsOffset(body []byte) int {
|
|
if len(body) < 38 {
|
|
return -1
|
|
}
|
|
pos := 34
|
|
|
|
if pos >= len(body) {
|
|
return -1
|
|
}
|
|
pos += 1 + int(body[pos]) // session ID
|
|
|
|
if pos+2 > len(body) {
|
|
return -1
|
|
}
|
|
pos += 2 + int(binary.BigEndian.Uint16(body[pos:pos+2])) // cipher suites
|
|
|
|
if pos >= len(body) {
|
|
return -1
|
|
}
|
|
pos += 1 + int(body[pos]) // compression methods
|
|
|
|
if pos+2 > len(body) {
|
|
return -1
|
|
}
|
|
return pos
|
|
}
|
|
|
|
func findSNIExtension(body []byte, pos int) string {
|
|
extLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
|
|
pos += 2
|
|
extEnd := pos + extLen
|
|
if extEnd > len(body) {
|
|
extEnd = len(body)
|
|
}
|
|
|
|
for pos+4 <= extEnd {
|
|
extType := binary.BigEndian.Uint16(body[pos : pos+2])
|
|
eLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4]))
|
|
pos += 4
|
|
if pos+eLen > extEnd {
|
|
break
|
|
}
|
|
if extType == 0 && eLen >= 5 {
|
|
nameLen := int(binary.BigEndian.Uint16(body[pos+3 : pos+5]))
|
|
if pos+5+nameLen <= extEnd {
|
|
return string(body[pos+5 : pos+5+nameLen])
|
|
}
|
|
}
|
|
pos += eLen
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isDNSPort(src, dst uint16) bool {
|
|
return src == 53 || dst == 53 || src == 5353 || dst == 5353
|
|
}
|
|
|
|
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
|
|
func formatDNSPayload(payload []byte) string {
|
|
d := &layers.DNS{}
|
|
if err := d.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
|
|
return ""
|
|
}
|
|
|
|
rd := ""
|
|
if d.RD {
|
|
rd = "+"
|
|
}
|
|
|
|
if !d.QR {
|
|
return formatDNSQuery(d, rd, len(payload))
|
|
}
|
|
return formatDNSResponse(d, rd, len(payload))
|
|
}
|
|
|
|
func formatDNSQuery(d *layers.DNS, rd string, plen int) string {
|
|
if len(d.Questions) == 0 {
|
|
return fmt.Sprintf("%04x%s (%d)", d.ID, rd, plen)
|
|
}
|
|
q := d.Questions[0]
|
|
return fmt.Sprintf("%04x%s %s? %s. (%d)", d.ID, rd, q.Type, q.Name, plen)
|
|
}
|
|
|
|
func formatDNSResponse(d *layers.DNS, rd string, plen int) string {
|
|
anCount := d.ANCount
|
|
nsCount := d.NSCount
|
|
arCount := d.ARCount
|
|
|
|
if d.ResponseCode != layers.DNSResponseCodeNoErr {
|
|
return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen)
|
|
}
|
|
|
|
if anCount > 0 && len(d.Answers) > 0 {
|
|
rr := d.Answers[0]
|
|
if rdata := shortRData(&rr); rdata != "" {
|
|
return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen)
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen)
|
|
}
|
|
|
|
func shortRData(rr *layers.DNSResourceRecord) string {
|
|
switch rr.Type {
|
|
case layers.DNSTypeA, layers.DNSTypeAAAA:
|
|
if rr.IP != nil {
|
|
return rr.IP.String()
|
|
}
|
|
case layers.DNSTypeCNAME:
|
|
if len(rr.CNAME) > 0 {
|
|
return string(rr.CNAME) + "."
|
|
}
|
|
case layers.DNSTypePTR:
|
|
if len(rr.PTR) > 0 {
|
|
return string(rr.PTR) + "."
|
|
}
|
|
case layers.DNSTypeNS:
|
|
if len(rr.NS) > 0 {
|
|
return string(rr.NS) + "."
|
|
}
|
|
case layers.DNSTypeMX:
|
|
return fmt.Sprintf("%d %s.", rr.MX.Preference, rr.MX.Name)
|
|
case layers.DNSTypeTXT:
|
|
if len(rr.TXTs) > 0 {
|
|
return fmt.Sprintf("%q", string(rr.TXTs[0]))
|
|
}
|
|
case layers.DNSTypeSRV:
|
|
return fmt.Sprintf("%d %d %d %s.", rr.SRV.Priority, rr.SRV.Weight, rr.SRV.Port, rr.SRV.Name)
|
|
}
|
|
return ""
|
|
}
|