diff --git a/src/config/config.go b/src/config/config.go index 2a8f0aba..ca690d29 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -10,7 +10,6 @@ import ( "github.com/daniellavrushin/b4/geodat" "github.com/daniellavrushin/b4/log" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) var ( @@ -62,6 +61,7 @@ type UDPConfig struct { DPortMin int `json:"dport_min" bson:"dport_min"` DPortMax int `json:"dport_max" bson:"dport_max"` FilterQUIC string `json:"filter_quic" bson:"filter_quic"` + FilterSTUN bool `json:"filter_stun" bson:"filter_stun"` } type DomainsConfig struct { @@ -132,6 +132,7 @@ var DefaultConfig = Config{ DPortMin: 0, DPortMax: 0, FilterQUIC: "disabled", + FilterSTUN: true, }, WebServer: WebServer{ @@ -248,6 +249,7 @@ func (c *Config) BindFlags(cmd *cobra.Command) { cmd.Flags().IntVar(&c.UDP.DPortMin, "udp-dport-min", c.UDP.DPortMin, "Minimum UDP destination port to handle") cmd.Flags().IntVar(&c.UDP.DPortMax, "udp-dport-max", c.UDP.DPortMax, "Maximum UDP destination port to handle") cmd.Flags().StringVar(&c.UDP.FilterQUIC, "udp-filter-quic", c.UDP.FilterQUIC, "QUIC filtering mode (disabled|all|parse)") + cmd.Flags().BoolVar(&c.UDP.FilterSTUN, "udp-filter-stun", c.UDP.FilterSTUN, "STUN filtering mode (disabled|all|parse)") // Feature flags cmd.Flags().BoolVar(&c.UseGSO, "gso", c.UseGSO, "Enable Generic Segmentation Offload") @@ -280,46 +282,6 @@ func (cfg *Config) ApplyLogLevel(level string) { } } -func (cfg *Config) ApplyCliOverrides(cmd *cobra.Command) map[string]interface{} { - overrides := make(map[string]interface{}) - - cmd.Flags().Visit(func(f *pflag.Flag) { - // Store the value of each flag that was explicitly set - switch f.Value.Type() { - case "int": - if val, err := cmd.Flags().GetInt(f.Name); err == nil { - overrides[f.Name] = val - } - case "uint": - if val, err := cmd.Flags().GetUint(f.Name); err == nil { - overrides[f.Name] = val - } - case "uint8": - if val, err := cmd.Flags().GetUint8(f.Name); err == nil { - overrides[f.Name] = val - } - case "int32": - if val, err := cmd.Flags().GetInt32(f.Name); err == nil { - overrides[f.Name] = val - } - case "bool": - if val, err := cmd.Flags().GetBool(f.Name); err == nil { - overrides[f.Name] = val - } - case "string": - if val, err := cmd.Flags().GetString(f.Name); err == nil { - overrides[f.Name] = val - } - case "stringSlice": - if val, err := cmd.Flags().GetStringSlice(f.Name); err == nil { - overrides[f.Name] = val - } - } - }) - - return overrides -} - func (c *Config) Validate() error { c.WebServer.IsEnabled = c.WebServer.Port < 0 || c.WebServer.Port > 65535 diff --git a/src/geodat/geosite.go b/src/geodat/geosite.go index 23bf1e62..e844ef51 100644 --- a/src/geodat/geosite.go +++ b/src/geodat/geosite.go @@ -4,12 +4,12 @@ package geodat import ( "bufio" "encoding/binary" - "fmt" "io" "os" "sort" "strings" + "github.com/daniellavrushin/b4/log" "github.com/urlesistiana/v2dat/v2data" "google.golang.org/protobuf/proto" ) @@ -39,7 +39,7 @@ func UnpackGeoSite(args *UnpackArgs) error { } for tag, domains := range entries { if err := save(tag, domains); err != nil { - return fmt.Errorf("failed to save %s: %w", tag, err) + return log.Errorf("failed to save %s: %w", tag, err) } } return nil @@ -47,16 +47,16 @@ func UnpackGeoSite(args *UnpackArgs) error { func readCountryCode(msg []byte) (string, error) { if len(msg) == 0 || msg[0] != 0x0A { - return "", fmt.Errorf("bad key") + return "", log.Errorf("bad key") } l, n := binary.Uvarint(msg[1:]) if n <= 0 { - return "", fmt.Errorf("bad varint") + return "", log.Errorf("bad varint") } start := 1 + n end := start + int(l) if end > len(msg) { - return "", fmt.Errorf("string truncated") + return "", log.Errorf("string truncated") } return strings.ToLower(string(msg[start:end])), nil } @@ -83,7 +83,7 @@ func streamGeoSite(file string, filters []string, save func(string, []*v2data.Do return err } if tagByte != 0x0A { - return fmt.Errorf("unexpected wire tag %02X", tagByte) + return log.Errorf("unexpected wire tag %02X", tagByte) } length, err := binary.ReadUvarint(r) if err != nil { @@ -115,10 +115,12 @@ func streamGeoSite(file string, filters []string, save func(string, []*v2data.Do return nil } -func ListGeoSiteTags(filePath string) error { +func ListGeoSiteTags(filePath string) ([]string, error) { + + log.Tracef("Listing geo site tags from %s", filePath) f, err := os.Open(filePath) if err != nil { - return err + return nil, err } defer f.Close() @@ -130,22 +132,22 @@ func ListGeoSiteTags(filePath string) error { break } if err != nil { - return err + return nil, err } if b != 0x0A { - return fmt.Errorf("unexpected wire tag %02X", b) + return nil, log.Errorf("unexpected wire tag %02X", b) } l, err := binary.ReadUvarint(r) if err != nil { - return err + return nil, log.Errorf("failed to read varint: %w", err) } msg := make([]byte, l) if _, err := io.ReadFull(r, msg); err != nil { - return err + return nil, err } tag, err := readCountryCode(msg) if err != nil { - return err + return nil, err } set[tag] = struct{}{} } @@ -155,10 +157,8 @@ func ListGeoSiteTags(filePath string) error { tags = append(tags, t) } sort.Strings(tags) - for _, t := range tags { - fmt.Println(t) - } - return nil + + return tags, nil } func convertV2DomainToText(dom []*v2data.Domain, w io.Writer) error { @@ -181,13 +181,3 @@ func convertV2DomainToText(dom []*v2data.Domain, w io.Writer) error { _, err := io.WriteString(w, b.String()) return err } - -func convertV2DomainToTextFile(domain []*v2data.Domain, file string) error { - f, err := os.Create(file) - if err != nil { - return err - } - defer f.Close() - - return convertV2DomainToText(domain, f) -} diff --git a/src/http/handler/geosite.go b/src/http/handler/geosite.go new file mode 100644 index 00000000..901918a2 --- /dev/null +++ b/src/http/handler/geosite.go @@ -0,0 +1,47 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/daniellavrushin/b4/config" + "github.com/daniellavrushin/b4/geodat" + "github.com/daniellavrushin/b4/log" +) + +func RegisterGeositeApi(mux *http.ServeMux, cfg *config.Config) { + api := &API{cfg: cfg} + mux.HandleFunc("/api/geosite", api.handleGeoSite) +} + +func (a *API) handleGeoSite(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + a.getGeositeTags(w) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (a *API) getGeositeTags(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + + if a.cfg.Domains.GeoSitePath == "" { + log.Tracef("Geosite path is not configured") + _ = enc.Encode(GeositeResponse{Tags: []string{}}) + return + } + + tags, err := geodat.ListGeoSiteTags(a.cfg.Domains.GeoSitePath) + if err != nil { + http.Error(w, "Failed to load geosite tags: "+err.Error(), http.StatusInternalServerError) + return + } + + response := GeositeResponse{ + Tags: tags, + } + + _ = enc.Encode(response) +} diff --git a/src/http/handler/types.go b/src/http/handler/types.go index 3bdbedf0..8e5641e5 100644 --- a/src/http/handler/types.go +++ b/src/http/handler/types.go @@ -7,3 +7,7 @@ import ( type API struct { cfg *config.Config } + +type GeositeResponse struct { + Tags []string `json:"tags"` +} diff --git a/src/http/server.go b/src/http/server.go index fbbd5b86..9e6cf50d 100644 --- a/src/http/server.go +++ b/src/http/server.go @@ -79,6 +79,7 @@ func registerWebSocketEndpoints(mux *stdhttp.ServeMux) { func registerAPIEndpoints(mux *stdhttp.ServeMux, cfg *config.Config) { handler.RegisterConfigApi(mux, cfg) handler.RegisterMetricsApi(mux, cfg) + handler.RegisterGeositeApi(mux, cfg) log.Infof("REST API endpoints registered") } diff --git a/src/nfq/nfq.go b/src/nfq/nfq.go index cd63905a..d4dd43ba 100644 --- a/src/nfq/nfq.go +++ b/src/nfq/nfq.go @@ -17,6 +17,7 @@ import ( "github.com/daniellavrushin/b4/metrics" "github.com/daniellavrushin/b4/sni" "github.com/daniellavrushin/b4/sock" + "github.com/daniellavrushin/b4/stun" "github.com/florianl/go-nfqueue" ) @@ -216,6 +217,13 @@ func (w *Worker) Start() error { sport := binary.BigEndian.Uint16(udp[0:2]) dport := binary.BigEndian.Uint16(udp[2:4]) + if cfg.UDP.FilterSTUN && stun.IsSTUNMessage(payload) { + // Log at TRACE level to avoid spam (STUN is very frequent) + log.Tracef("STUN %s:%d -> %s:%d", src.String(), sport, dst.String(), dport) + _ = q.SetVerdict(id, nfqueue.NfAccept) + return 0 + } + host := "" if h, ok := sni.ParseQUICClientHelloSNI(payload); ok { host = h diff --git a/src/stun/stun.go b/src/stun/stun.go new file mode 100644 index 00000000..18801c3c --- /dev/null +++ b/src/stun/stun.go @@ -0,0 +1,94 @@ +// path: src/stun/stun.go +package stun + +import ( + "encoding/binary" +) + +// IsSTUNMessage checks if a UDP payload is a STUN message (RFC 5389) +func IsSTUNMessage(data []byte) bool { + // STUN header is 20 bytes: + // 2 (type) + 2 (length) + 4 (magic cookie) + 12 (transaction ID) + if len(data) < 20 { + return false + } + + // Read message type (big endian, network byte order) + messageType := binary.BigEndian.Uint16(data[0:2]) + + // Read message length (big endian) + // This is the length of the message body, NOT including the 20-byte header + messageLength := binary.BigEndian.Uint16(data[2:4]) + + // Validate that the packet length matches the declared message length + // Total packet size should be: 20 (header) + messageLength (body) + if len(data) != int(messageLength)+20 { + return false + } + + // RFC 5389 Section 6: The most significant 2 bits of every STUN message MUST be zeros + // This helps distinguish STUN from other protocols using the same ports + if (messageType & 0xC000) != 0 { + return false + } + + // Filter STUN requests only (not responses or indications) + // Message Type bits: + // Bit 4 (0x0010): 0=request, 1=indication + // Bit 8 (0x0100): 0=request/indication, 1=response/error response + // We want to detect only requests (both bits = 0) + // This prevents mangling STUN responses which could break WebRTC + if (messageType & 0x0110) != 0 { + return false + } + + // Check magic cookie (RFC 5389 Section 6) + // Magic cookie is 0x2112A442 (in network byte order) + // This is a fixed value that helps identify STUN messages + magicCookie := binary.BigEndian.Uint32(data[4:8]) + if magicCookie != 0x2112A442 { + return false + } + + // All checks passed - this is a STUN request + return true +} + +// GetSTUNMessageType returns the STUN message type if it's a valid STUN message +// Returns 0 if not a STUN message +func GetSTUNMessageType(data []byte) uint16 { + if !IsSTUNMessage(data) { + return 0 + } + return binary.BigEndian.Uint16(data[0:2]) & 0x3FFF +} + +// Common STUN message types (for debugging/logging) +const ( + BindingRequest = 0x0001 + BindingResponse = 0x0101 + BindingErrorResponse = 0x0111 + SharedSecretRequest = 0x0002 + SharedSecretResponse = 0x0102 + SharedSecretErrorResponse = 0x0112 +) + +// MessageTypeName returns a human-readable name for STUN message types +func MessageTypeName(msgType uint16) string { + switch msgType { + case BindingRequest: + return "Binding Request" + case BindingResponse: + return "Binding Response" + case BindingErrorResponse: + return "Binding Error Response" + case SharedSecretRequest: + return "Shared Secret Request" + case SharedSecretResponse: + return "Shared Secret Response" + case SharedSecretErrorResponse: + return "Shared Secret Error Response" + default: + return "Unknown" + } +}