diff --git a/analyzer/tcp/tor.go b/analyzer/tcp/tor.go new file mode 100644 index 0000000..c579aa4 --- /dev/null +++ b/analyzer/tcp/tor.go @@ -0,0 +1,61 @@ +package tcp + +import ( + "github.com/apernet/OpenGFW/analyzer" + "github.com/apernet/OpenGFW/ruleset/builtins/tor" +) + +var _ analyzer.TCPAnalyzer = (*TorAnalyzer)(nil) + +type TorAnalyzer struct{ + directory tor.TorDirectory +} + +func (a *TorAnalyzer) Init() error { + var err error + a.directory, err = tor.GetOnionooDirectory() + return err +} + +func (a *TorAnalyzer) Name() string { + return "tor" +} + +// For now only TCP metadata is needed +func (a *TorAnalyzer) Limit() int { + return 1 +} + +func (a *TorAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream { + isRelay := a.directory.Query(info.DstIP, info.DstPort) + return newTorStream(logger, isRelay) +} + +type torStream struct { + logger analyzer.Logger + isRelay bool // Public relay identifier +} + +func newTorStream(logger analyzer.Logger, isRelay bool) *torStream { + return &torStream{logger: logger, isRelay: isRelay} +} + +func (s *torStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) { + if skip != 0 { + return nil, true + } + if len(data) == 0 { + return nil, false + } + + return &analyzer.PropUpdate{ + Type: analyzer.PropUpdateReplace, + M: analyzer.PropMap{ + "relay": s.isRelay, + }, + }, true +} + +func (s *torStream) Close(limited bool) *analyzer.PropUpdate { + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 1ccf025..2e11424 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,6 +93,7 @@ var analyzers = []analyzer.Analyzer{ &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{}, &tcp.TLSAnalyzer{}, + &tcp.TorAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{}, diff --git a/ruleset/builtins/tor/interface.go b/ruleset/builtins/tor/interface.go new file mode 100644 index 0000000..18c7e09 --- /dev/null +++ b/ruleset/builtins/tor/interface.go @@ -0,0 +1,9 @@ +package tor + +import "net" + +type TorDirectory interface { + Init() error + Add(ip net.IP, port uint16) + Query(ip net.IP, port uint16) bool +} diff --git a/ruleset/builtins/tor/onionoo.go b/ruleset/builtins/tor/onionoo.go new file mode 100644 index 0000000..545bffa --- /dev/null +++ b/ruleset/builtins/tor/onionoo.go @@ -0,0 +1,111 @@ +package tor + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strconv" + "sync" +) + +const ( + onionooUrl = "https://onionoo.torproject.org/details" +) + +var _ TorDirectory = (*OnionooDirectory)(nil) + +// Singleton instance +var onionooInstance *OnionooDirectory +var once sync.Once + +func GetOnionooDirectory() (*OnionooDirectory, error) { + var err error + // Singleton initialization + once.Do(func() { + onionooInstance = &OnionooDirectory{ + directory: make(map[string]struct{}), + } + err = onionooInstance.Init() + }) + return onionooInstance, err +} + +type OnionooDirectory struct { + directory map[string]struct{} + sync.RWMutex +} + +// example detail entry +// {..., "or_addresses":["195.15.242.99:9001","[2001:1600:10:100::201]:9001"], ...} + +type OnionooDetail struct { + OrAddresses []string `json:"or_addresses"` +} + +type OnionooResponse struct { + Relays []OnionooDetail `json:"relays"` +} + +func (d *OnionooDirectory) Init() error { + response, err := d.downloadDirectory(onionooUrl) + if err != nil { + return err + } + for _, relay := range response.Relays { + for _, address := range relay.OrAddresses { + ipStr, portStr, err := net.SplitHostPort(address) + if err != nil { + continue + } + ip := net.ParseIP(ipStr) + port, err := strconv.ParseUint(portStr, 10, 16) + if ip != nil && err == nil { + d.Add(ip, uint16(port)) + } + } + } + // TODO: log number of entries loaded + return nil +} + +func (d *OnionooDirectory) Add(ip net.IP, port uint16) { + d.Lock() + defer d.Unlock() + addr := net.JoinHostPort(ip.String(), strconv.FormatUint(uint64(port), 10)) + d.directory[addr] = struct{}{} +} + +func (d *OnionooDirectory) Query(ip net.IP, port uint16) bool { + d.RLock() + defer d.RUnlock() + addr := net.JoinHostPort(ip.String(), strconv.FormatUint(uint64(port), 10)) + _, exists := d.directory[addr] + return exists +} + +func (d *OnionooDirectory) downloadDirectory(url string) (*OnionooResponse, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch onionoo data: status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var onionooResponse OnionooResponse + err = json.Unmarshal(body, &onionooResponse) + if err != nil { + return nil, fmt.Errorf("failed to parse onionoo json response: %s", err) + } + + return &onionooResponse, nil +} diff --git a/ruleset/expr.go b/ruleset/expr.go index 373d0d3..a7539ba 100644 --- a/ruleset/expr.go +++ b/ruleset/expr.go @@ -18,6 +18,7 @@ import ( "gopkg.in/yaml.v3" "github.com/apernet/OpenGFW/analyzer" + "github.com/apernet/OpenGFW/analyzer/tcp" "github.com/apernet/OpenGFW/modifier" "github.com/apernet/OpenGFW/ruleset/builtins" ) @@ -153,6 +154,9 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier } else if a, ok := fullAnMap[name]; ok { // Analyzer, add to dependency map depAnMap[name] = a + if err:= analyzersInit(a); err != nil { + return nil, err + } } } cr := compiledExprRule{ @@ -242,6 +246,17 @@ func analyzersToMap(ans []analyzer.Analyzer) map[string]analyzer.Analyzer { return anMap } +// analyzersInit invokes custom analyzer init logics +func analyzersInit(a analyzer.Analyzer) error { + switch impl := a.(type) { + case *tcp.TorAnalyzer: + if err := impl.Init(); err != nil { + return err + } + } + return nil +} + // modifiersToMap converts a list of modifiers to a map of name -> modifier. // This is for easier lookup when compiling rules. func modifiersToMap(mods []modifier.Modifier) map[string]modifier.Modifier {