feat(api): add geosite API endpoint and implement geosite tag retrieval

feat(stun): implement STUN message validation and type identification
refactor(config): add FilterSTUN option to UDPConfig and update related logic
This commit is contained in:
Daniel Lavrushin 2025-10-25 15:34:20 +02:00
parent d00d1d204d
commit 2642a42de3
No known key found for this signature in database
GPG key ID: 67D627A2ADB43DD0
7 changed files with 174 additions and 68 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -7,3 +7,7 @@ import (
type API struct {
cfg *config.Config
}
type GeositeResponse struct {
Tags []string `json:"tags"`
}

View file

@ -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")
}

View file

@ -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

94
src/stun/stun.go Normal file
View file

@ -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"
}
}