Skip to content

Commit 82e8600

Browse files
mosajjalclaude
andcommitted
feat: add DNSEvent protobuf schema and NATS JetStream output module
Define a shared .proto wire format for DNS events with pre-extracted index fields (qname, qtype, rcode) for hot-path filtering and full dns_wire bytes for deep inspection. Add NATS output module (#16) following existing output module pattern with mTLS support. Closes #157 Closes #158 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 24426d0 commit 82e8600

5 files changed

Lines changed: 494 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ require (
4949
github.com/josharian/intern v1.0.0 // indirect
5050
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
5151
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
52+
github.com/nats-io/nats.go v1.49.0 // indirect
53+
github.com/nats-io/nkeys v0.4.12 // indirect
54+
github.com/nats-io/nuid v1.0.1 // indirect
5255
github.com/oapi-codegen/runtime v1.2.0 // indirect
5356
github.com/parquet-go/bitpack v1.0.0 // indirect
5457
github.com/parquet-go/jsonlite v1.4.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ github.com/mosajjal/Go-Splunk-HTTP/splunk/v2 v2.0.7/go.mod h1:AcnVnp0ahYACYnWg1n
231231
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
232232
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
233233
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
234+
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
235+
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
236+
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
237+
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
238+
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
239+
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
234240
github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4=
235241
github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
236242
github.com/olivere/elastic v6.2.37+incompatible h1:UfSGJem5czY+x/LqxgeCBgjDn6St+z8OnsCuxwD3L0U=

internal/output/nats.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/* {{{ Copyright (C) 2022 Ali Mosajjal
2+
*
3+
* This program is free software: you can redistribute it and/or modify
4+
* it under the terms of the GNU General Public License as published by
5+
* the Free Software Foundation, either version 3 of the License, or
6+
* (at your option) any later version.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License
14+
* along with this program. If not, see <https://www.gnu.org/licenses/>. }}} */
15+
16+
package output
17+
18+
import (
19+
"context"
20+
"crypto/tls"
21+
"crypto/x509"
22+
"errors"
23+
"os"
24+
25+
"github.com/mosajjal/dnsmonster/internal/util"
26+
pb "github.com/mosajjal/dnsmonster/proto"
27+
"github.com/nats-io/nats.go"
28+
metrics "github.com/rcrowley/go-metrics"
29+
log "github.com/sirupsen/logrus"
30+
"google.golang.org/protobuf/proto"
31+
)
32+
33+
type natsConfig struct {
34+
NatsOutputType uint `long:"natsoutputtype" ini-name:"natsoutputtype" env:"DNSMONSTER_NATSOUTPUTTYPE" default:"0" description:"What should be written to NATS. options:\n;\t0: Disable Output\n;\t1: Enable Output without any filters\n;\t2: Enable Output and apply skipdomains logic\n;\t3: Enable Output and apply allowdomains logic\n;\t4: Enable Output and apply both skip and allow domains logic" choice:"0" choice:"1" choice:"2" choice:"3" choice:"4"`
35+
NatsOutputServer string `long:"natsoutputserver" ini-name:"natsoutputserver" env:"DNSMONSTER_NATSOUTPUTSERVER" default:"nats://localhost:4222" description:"NATS server address"`
36+
NatsOutputSubject string `long:"natsoutputsubject" ini-name:"natsoutputsubject" env:"DNSMONSTER_NATSOUTPUTSUBJECT" default:"dns.events" description:"NATS subject to publish to"`
37+
NatsOutputTLSCert string `long:"natsoutputtlscert" ini-name:"natsoutputtlscert" env:"DNSMONSTER_NATSOUTPUTTLSCERT" default:"" description:"Path to client TLS certificate for NATS mTLS"`
38+
NatsOutputTLSKey string `long:"natsoutputtlskey" ini-name:"natsoutputtlskey" env:"DNSMONSTER_NATSOUTPUTTLSKEY" default:"" description:"Path to client TLS key for NATS mTLS"`
39+
NatsOutputTLSCA string `long:"natsoutputtlsca" ini-name:"natsoutputtlsca" env:"DNSMONSTER_NATSOUTPUTTLSCA" default:"" description:"Path to CA certificate for NATS TLS verification"`
40+
outputChannel chan util.DNSResult
41+
closeChannel chan bool
42+
}
43+
44+
func init() {
45+
c := natsConfig{}
46+
if _, err := util.GlobalParser.AddGroup("nats_output", "NATS Output", &c); err != nil {
47+
log.Fatalf("error adding NATS output module")
48+
}
49+
c.outputChannel = make(chan util.DNSResult, util.GeneralFlags.ResultChannelSize)
50+
util.GlobalDispatchList = append(util.GlobalDispatchList, &c)
51+
}
52+
53+
func (nc natsConfig) Initialize(ctx context.Context) error {
54+
if nc.NatsOutputType > 0 && nc.NatsOutputType < 5 {
55+
log.Info("Creating NATS Output Channel")
56+
go nc.Output(ctx)
57+
} else {
58+
return errors.New("no output")
59+
}
60+
return nil
61+
}
62+
63+
func (nc natsConfig) Close() {
64+
close(nc.closeChannel)
65+
}
66+
67+
func (nc natsConfig) OutputChannel() chan util.DNSResult {
68+
return nc.outputChannel
69+
}
70+
71+
func (nc natsConfig) connect() (*nats.Conn, error) {
72+
opts := []nats.Option{
73+
nats.Name("dnsmonster"),
74+
nats.MaxReconnects(-1),
75+
}
76+
77+
if nc.NatsOutputTLSCert != "" && nc.NatsOutputTLSKey != "" {
78+
cert, err := tls.LoadX509KeyPair(nc.NatsOutputTLSCert, nc.NatsOutputTLSKey)
79+
if err != nil {
80+
return nil, err
81+
}
82+
tlsConfig := &tls.Config{
83+
Certificates: []tls.Certificate{cert},
84+
}
85+
if nc.NatsOutputTLSCA != "" {
86+
caCert, err := os.ReadFile(nc.NatsOutputTLSCA)
87+
if err != nil {
88+
return nil, err
89+
}
90+
pool := x509.NewCertPool()
91+
pool.AppendCertsFromPEM(caCert)
92+
tlsConfig.RootCAs = pool
93+
}
94+
opts = append(opts, nats.Secure(tlsConfig))
95+
} else if nc.NatsOutputTLSCA != "" {
96+
caCert, err := os.ReadFile(nc.NatsOutputTLSCA)
97+
if err != nil {
98+
return nil, err
99+
}
100+
pool := x509.NewCertPool()
101+
pool.AppendCertsFromPEM(caCert)
102+
opts = append(opts, nats.Secure(&tls.Config{RootCAs: pool}))
103+
}
104+
105+
return nats.Connect(nc.NatsOutputServer, opts...)
106+
}
107+
108+
func dnsResultToProto(d util.DNSResult) ([]byte, error) {
109+
ev := &pb.DNSEvent{
110+
TimestampNs: d.Timestamp.UnixNano(),
111+
SrcIp: d.SrcIP,
112+
DstIp: d.DstIP,
113+
SrcPort: uint32(d.SrcPort),
114+
DstPort: uint32(d.DstPort),
115+
IpVersion: uint32(d.IPVersion),
116+
Protocol: d.Protocol,
117+
PacketLength: uint32(d.PacketLength),
118+
Identity: d.Identity,
119+
Version: d.Version,
120+
IsResponse: d.DNS.Response,
121+
Rcode: uint32(d.DNS.Rcode),
122+
}
123+
124+
if len(d.DNS.Question) > 0 {
125+
ev.Qname = d.DNS.Question[0].Name
126+
ev.Qtype = uint32(d.DNS.Question[0].Qtype)
127+
}
128+
129+
wire, err := d.DNS.Pack()
130+
if err != nil {
131+
log.Debugf("failed to pack DNS message: %v", err)
132+
} else {
133+
ev.DnsWire = wire
134+
}
135+
136+
return proto.Marshal(ev)
137+
}
138+
139+
func (nc natsConfig) Output(ctx context.Context) {
140+
conn, err := nc.connect()
141+
if err != nil {
142+
log.Fatalf("could not connect to NATS: %v", err)
143+
}
144+
defer conn.Close()
145+
146+
natsSent := metrics.GetOrRegisterCounter("natsSentToOutput", metrics.DefaultRegistry)
147+
natsSkipped := metrics.GetOrRegisterCounter("natsSkipped", metrics.DefaultRegistry)
148+
natsErrors := metrics.GetOrRegisterCounter("natsErrors", metrics.DefaultRegistry)
149+
150+
for {
151+
select {
152+
case data := <-nc.outputChannel:
153+
for _, dnsQuery := range data.DNS.Question {
154+
if util.CheckIfWeSkip(nc.NatsOutputType, dnsQuery.Name) {
155+
natsSkipped.Inc(1)
156+
continue
157+
}
158+
}
159+
160+
buf, err := dnsResultToProto(data)
161+
if err != nil {
162+
natsErrors.Inc(1)
163+
log.Errorf("failed to marshal DNSEvent: %v", err)
164+
continue
165+
}
166+
167+
if err := conn.Publish(nc.NatsOutputSubject, buf); err != nil {
168+
natsErrors.Inc(1)
169+
log.Errorf("failed to publish to NATS: %v", err)
170+
continue
171+
}
172+
natsSent.Inc(1)
173+
174+
case <-ctx.Done():
175+
log.Info("Context cancelled, closing NATS connection")
176+
conn.Flush()
177+
return
178+
case <-nc.closeChannel:
179+
log.Info("Closing NATS connection")
180+
conn.Flush()
181+
return
182+
}
183+
}
184+
}
185+
186+
// vim: foldmethod=marker

0 commit comments

Comments
 (0)