package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "os" "os/exec" "regexp" "strings" "sync" "time" "unicode" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) type Plugin struct { name string sdk *sdk.PluginSDK mu sync.RWMutex timeout int proxy string client *http.Client } func newHTTPClient(timeout int, proxyURL string) *http.Client { transport := &http.Transport{ DialContext: (&net.Dialer{ Timeout: time.Duration(timeout) * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: time.Duration(timeout) * time.Second, ResponseHeaderTimeout: time.Duration(timeout) * time.Second, } if proxyURL != "" { u, err := url.Parse(proxyURL) if err == nil { transport.Proxy = http.ProxyURL(u) } } return &http.Client{ Timeout: time.Duration(timeout) * time.Second, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("too many redirects") } return nil }, } } func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s s.Settings().RegisterDef(sdk.ConfigDef{ Key: "plugin.browser.timeout", Default: "30", Type: "int", DisplayName: "HTTP 超时(秒)", Description: "HTTP 请求超时时间", Category: "browser", }) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "plugin.browser.proxy", Default: "", Type: "string", DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port。为空则不使用代理", Category: "browser", }) t := getSetting[float64](s.Settings(), "timeout", 30) p.timeout = int(t) if p.timeout < 5 { p.timeout = 5 } if p.timeout > 120 { p.timeout = 120 } p.proxy = getSetting[string](s.Settings(), "proxy", "") p.client = newHTTPClient(p.timeout, p.proxy) tp := p.name + "_" s.RegisterTool(tp+"search", sdk.ToolDef{ Name: tp + "search", Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "query": map[string]interface{}{"type": "string", "description": "Search query"}, "count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"}, }, "required": []string{"query"}, }, }, p.handleSearch) s.RegisterTool(tp+"fetch", sdk.ToolDef{ Name: tp + "fetch", Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"}, "max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"}, }, "required": []string{"url"}, }, }, p.handleFetch) s.RegisterTool(tp+"render", sdk.ToolDef{ Name: tp + "render", Description: "Render a web page using headless Chromium browser and extract the text content. Supports JavaScript-rendered pages. Returns title and first 5000 characters.", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "url": map[string]interface{}{"type": "string", "description": "URL to render"}, "wait": map[string]interface{}{"type": "integer", "description": "Seconds to wait for JS rendering (default 0)"}, }, "required": []string{"url"}, }, }, p.handleRender) log.Printf("[%s] started, timeout=%ds proxy=%q", p.name, p.timeout, p.proxy) return nil } func (p *Plugin) Stop() error { if p.client != nil { p.client.CloseIdleConnections() } log.Printf("[%s] stopped", p.name) return nil } func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { v, err := s.Get(key) if err != nil || v == nil { return def } val, ok := v.(T) if !ok { return def } return val } func convInt64(v interface{}) (int64, error) { switch x := v.(type) { case float64: return int64(x), nil case int64: return x, nil case json.Number: return x.Int64() default: return 0, fmt.Errorf("cannot convert %T to int64", v) } } func errorResult(msg string) map[string]interface{} { return map[string]interface{}{"isError": true, "content": msg} } // ── SSRF ────────────────────────────────────────────────── var privateCIDRs []*net.IPNet func init() { for _, c := range []string{ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10", } { _, n, _ := net.ParseCIDR(c) if n != nil { privateCIDRs = append(privateCIDRs, n) } } } func isPrivateIP(ip net.IP) bool { for _, n := range privateCIDRs { if n.Contains(ip) { return true } } return false } func (p *Plugin) ssrfCheck(rawURL string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("only http/https URLs allowed, got: %s", u.Scheme) } ips, err := net.LookupHost(u.Hostname()) if err != nil { return fmt.Errorf("DNS lookup failed: %w", err) } for _, ip := range ips { if parsed := net.ParseIP(ip); parsed != nil && isPrivateIP(parsed) { return fmt.Errorf("blocked request to private IP: %s (%s)", u.Hostname(), ip) } } return nil } // ── DuckDuckGo Search ───────────────────────────────────── type ddgResult struct { Title, URL, Snippet string } func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) { form := url.Values{"q": {query}} req, _ := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") resp, err := p.client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return parseDDGResults(string(body), count), nil } func parseDDGResults(html string, count int) []ddgResult { var results []ddgResult marker := `result__body"` for i := 0; i < len(html); i++ { idx := strings.Index(html[i:], marker) if idx < 0 { break } i += idx closeIdx := findClosingTag(html, i, "") if closeIdx < 0 { break } if r := parseSingleDDGResult(html[i : closeIdx+6]); r.URL != "" { results = append(results, r) if len(results) >= count { break } } i = closeIdx + 6 } return results } func findClosingTag(s string, start int, tag string) int { depth := 1 for pos := start; pos < len(s); { nextOpen := strings.Index(s[pos:], `