diff --git a/back/config/config.go b/back/config/config.go index bdd8e77..7e5051b 100644 --- a/back/config/config.go +++ b/back/config/config.go @@ -9,9 +9,14 @@ import ( "gopkg.in/yaml.v3" ) +type ProxyEntry struct { + URL string `yaml:"url" json:"url"` +} + type Config struct { - Tools map[string]yaml.Node `yaml:"tools" json:"tools"` - Profiles map[string]Profile `yaml:"profiles" json:"profiles"` + Proxies []ProxyEntry `yaml:"proxies,omitempty" json:"proxies,omitempty"` + Tools map[string]yaml.Node `yaml:"tools" json:"tools"` + Profiles map[string]Profile `yaml:"profiles" json:"profiles"` } type Profile struct { diff --git a/back/internal/api/handler/config.go b/back/internal/api/handler/config.go index 1a2d850..1bc50c2 100644 --- a/back/internal/api/handler/config.go +++ b/back/internal/api/handler/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "os/exec" "sort" "sync" @@ -40,11 +41,18 @@ func (h *ConfigHandler) Get(w http.ResponseWriter, r *http.Request) { toolConfigs[toolName] = m } } + proxies := cfg.Proxies + if proxies == nil { + proxies = []config.ProxyEntry{} + } + _, pcErr := exec.LookPath("proxychains4") respond.JSON(w, http.StatusOK, map[string]any{ - "tools": toolConfigs, - "profiles": cfg.Profiles, - "readonly": h.demo || config.IsReadonly(h.configPath), - "demo": h.demo, + "tools": toolConfigs, + "profiles": cfg.Profiles, + "proxies": proxies, + "proxychains_available": pcErr == nil, + "readonly": h.demo || config.IsReadonly(h.configPath), + "demo": h.demo, }) } @@ -512,6 +520,39 @@ func (h *ConfigHandler) DeleteProfileToolConfig(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusNoContent) } +// PUT /api/config/proxies +func (h *ConfigHandler) UpdateProxies(w http.ResponseWriter, r *http.Request) { + if h.demo { + respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled") + return + } + if config.IsReadonly(h.configPath) { + respond.Error(w, http.StatusForbidden, "config is read-only") + return + } + + var proxies []config.ProxyEntry + if err := json.NewDecoder(r.Body).Decode(&proxies); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + cfg, err := config.Load(h.configPath) + if err != nil { + respond.Error(w, http.StatusInternalServerError, err.Error()) + return + } + cfg.Proxies = proxies + if err := config.Save(h.configPath, cfg); err != nil { + respond.Error(w, http.StatusInternalServerError, err.Error()) + return + } + respond.JSON(w, http.StatusOK, proxies) +} + func validateProfileName(name string) error { for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { diff --git a/back/internal/api/router.go b/back/internal/api/router.go index d24c119..9a909bd 100644 --- a/back/internal/api/router.go +++ b/back/internal/api/router.go @@ -55,6 +55,7 @@ func NewRouter( r.Route("/config", func(r chi.Router) { r.Get("/", configHandler.Get) + r.Put("/proxies", configHandler.UpdateProxies) r.Route("/tools", func(r chi.Router) { r.Patch("/{toolName}", configHandler.UpdateToolConfig) diff --git a/back/internal/proxy/proxy.go b/back/internal/proxy/proxy.go new file mode 100644 index 0000000..b00eea0 --- /dev/null +++ b/back/internal/proxy/proxy.go @@ -0,0 +1,154 @@ +package proxy + +import ( + "context" + "fmt" + "math/rand/v2" + "net/http" + "net/url" + "os" + "strings" + + "github.com/anotherhadi/iknowyou/config" +) + +type httpClientKey struct{} +type proxychainsConfKey struct{} + +// WithClient injects a proxy-aware HTTP client into the context. +func WithClient(ctx context.Context, client *http.Client) context.Context { + return context.WithValue(ctx, httpClientKey{}, client) +} + +// ClientFromContext returns the proxy-aware HTTP client stored in ctx, +// or http.DefaultClient if none was set. +func ClientFromContext(ctx context.Context) *http.Client { + if client, ok := ctx.Value(httpClientKey{}).(*http.Client); ok && client != nil { + return client + } + return http.DefaultClient +} + +// WithProxychainsConf injects a proxychains config file path into the context. +func WithProxychainsConf(ctx context.Context, confPath string) context.Context { + return context.WithValue(ctx, proxychainsConfKey{}, confPath) +} + +// ProxychainsConfFromContext returns the proxychains config file path stored in +// ctx, or an empty string if none was set. +func ProxychainsConfFromContext(ctx context.Context) string { + if path, ok := ctx.Value(proxychainsConfKey{}).(string); ok { + return path + } + return "" +} + +// fallbackTransport is an http.RoundTripper that tries proxies in random order +// and falls back to the next one on network error. +type fallbackTransport struct { + transports []*http.Transport +} + +func (t *fallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) { + order := rand.Perm(len(t.transports)) + var lastErr error + for _, i := range order { + resp, err := t.transports[i].RoundTrip(req) + if err == nil { + return resp, nil + } + lastErr = err + } + return nil, lastErr +} + +// NewClient builds an *http.Client that routes requests through the given +// proxies, trying them in random order and falling back on network error. +// Returns nil if proxies is empty. +func NewClient(proxies []config.ProxyEntry) (*http.Client, error) { + if len(proxies) == 0 { + return nil, nil + } + transports := make([]*http.Transport, 0, len(proxies)) + for _, p := range proxies { + u, err := url.Parse(p.URL) + if err != nil { + return nil, fmt.Errorf("proxy: invalid URL %q: %w", p.URL, err) + } + transports = append(transports, &http.Transport{ + Proxy: http.ProxyURL(u), + }) + } + return &http.Client{ + Transport: &fallbackTransport{transports: transports}, + }, nil +} + +// WriteProxychainsConf generates a temporary proxychains4 config file from the +// given proxy list and returns its path along with a cleanup function. +// Returns ("", nil, nil) if proxies is empty. +func WriteProxychainsConf(proxies []config.ProxyEntry) (string, func(), error) { + if len(proxies) == 0 { + return "", func() {}, nil + } + + var sb strings.Builder + sb.WriteString("dynamic_chain\nproxy_dns\n\n[ProxyList]\n") + + for _, p := range proxies { + u, err := url.Parse(p.URL) + if err != nil { + return "", nil, fmt.Errorf("proxy: invalid URL %q: %w", p.URL, err) + } + + scheme := u.Scheme + // proxychains only knows socks4, socks5, http + if scheme != "socks4" && scheme != "socks5" && scheme != "http" { + scheme = "socks5" + } + + host := u.Hostname() + port := u.Port() + if port == "" { + port = defaultPort(scheme) + } + + line := fmt.Sprintf("%s %s %s", scheme, host, port) + if u.User != nil { + user := u.User.Username() + pass, _ := u.User.Password() + if user != "" { + line += " " + user + if pass != "" { + line += " " + pass + } + } + } + sb.WriteString(line + "\n") + } + + f, err := os.CreateTemp("", "iky-proxychains-*.conf") + if err != nil { + return "", nil, fmt.Errorf("proxy: create temp conf: %w", err) + } + if _, err := f.WriteString(sb.String()); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", nil, fmt.Errorf("proxy: write conf: %w", err) + } + _ = f.Close() + + path := f.Name() + cleanup := func() { _ = os.Remove(path) } + return path, cleanup, nil +} + +func defaultPort(scheme string) string { + switch scheme { + case "socks4", "socks5": + return "1080" + case "http": + return "8080" + } + return "1080" +} diff --git a/back/internal/search/manager.go b/back/internal/search/manager.go index 361fde7..3afd347 100644 --- a/back/internal/search/manager.go +++ b/back/internal/search/manager.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/anotherhadi/iknowyou/config" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -66,6 +67,24 @@ func (m *Manager) Start( ctx, cancel := context.WithCancel(parentCtx) + // Inject proxy-aware HTTP client into context. + if httpClient, err := proxy.NewClient(cfg.Proxies); err != nil { + cancel() + return nil, fmt.Errorf("manager: building proxy client: %w", err) + } else if httpClient != nil { + ctx = proxy.WithClient(ctx, httpClient) + } + + // Generate proxychains config for external binary tools. + var proxychainsCleanup func() + if confPath, cleanup, err := proxy.WriteProxychainsConf(cfg.Proxies); err != nil { + cancel() + return nil, fmt.Errorf("manager: writing proxychains config: %w", err) + } else if confPath != "" { + ctx = proxy.WithProxychainsConf(ctx, confPath) + proxychainsCleanup = cleanup + } + s := &Search{ ID: uuid.NewString(), Target: target, @@ -81,7 +100,7 @@ func (m *Manager) Start( m.searches[s.ID] = s m.mu.Unlock() - go m.runAll(ctx, s, activeTools) + go m.runAll(ctx, s, activeTools, proxychainsCleanup) return s, nil } @@ -208,7 +227,10 @@ func (m *Manager) instantiate(cfg *config.Config, inputType tools.InputType, pro return runners, statuses, nil } -func (m *Manager) runAll(ctx context.Context, s *Search, runners []tools.ToolRunner) { +func (m *Manager) runAll(ctx context.Context, s *Search, runners []tools.ToolRunner, cleanup func()) { + if cleanup != nil { + defer cleanup() + } var wg sync.WaitGroup for _, tool := range runners { wg.Add(1) diff --git a/back/internal/tools/breachdirectory/tool.go b/back/internal/tools/breachdirectory/tool.go index 3ed2cc5..a74d2a5 100644 --- a/back/internal/tools/breachdirectory/tool.go +++ b/back/internal/tools/breachdirectory/tool.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -80,7 +81,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out req.Header.Set("X-RapidAPI-Host", "breachdirectory.p.rapidapi.com") req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := proxy.ClientFromContext(ctx).Do(req) if err != nil { if ctx.Err() != nil { out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} diff --git a/back/internal/tools/crtsh/tool.go b/back/internal/tools/crtsh/tool.go index f7a80f0..6174785 100644 --- a/back/internal/tools/crtsh/tool.go +++ b/back/internal/tools/crtsh/tool.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -61,7 +62,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; crtsh-scanner/1.0)") - resp, err := http.DefaultClient.Do(req) + resp, err := proxy.ClientFromContext(ctx).Do(req) if err != nil { if ctx.Err() != nil { out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} diff --git a/back/internal/tools/ghunt/tool.go b/back/internal/tools/ghunt/tool.go index a9d1725..818315c 100644 --- a/back/internal/tools/ghunt/tool.go +++ b/back/internal/tools/ghunt/tool.go @@ -94,7 +94,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out parsed[i] = ansiRe.ReplaceAllString(l, "") } - start := 0 + start := -1 for i, l := range parsed { if strings.Contains(l, "[+] Authenticated !") { start = i + 1 @@ -102,6 +102,14 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out } } + if start == -1 { + // Banner printed but auth line never appeared — bad/expired credentials. + out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "GHunt authentication failed — credentials may be missing or expired (run 'ghunt login' and update your creds in Settings)"} + out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0} + out <- tools.Event{Tool: name, Type: tools.EventTypeDone} + return nil + } + end := len(lines) for i := start; i < len(parsed); i++ { if strings.Contains(parsed[i], "Traceback (most recent call last)") { @@ -117,6 +125,8 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out } else if output != "" { out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output} out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 1} + } else { + out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0} } out <- tools.Event{Tool: name, Type: tools.EventTypeDone} return nil diff --git a/back/internal/tools/ipinfo/tool.go b/back/internal/tools/ipinfo/tool.go index ea8ad1f..39a7c5b 100644 --- a/back/internal/tools/ipinfo/tool.go +++ b/back/internal/tools/ipinfo/tool.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -76,7 +77,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out } req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := proxy.ClientFromContext(ctx).Do(req) if err != nil { if ctx.Err() != nil { out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} diff --git a/back/internal/tools/leakcheck/tool.go b/back/internal/tools/leakcheck/tool.go index 09aefcc..7c7cb59 100644 --- a/back/internal/tools/leakcheck/tool.go +++ b/back/internal/tools/leakcheck/tool.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -90,7 +91,7 @@ func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputTy req.Header.Set("X-API-Key", r.cfg.APIKey) req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := proxy.ClientFromContext(ctx).Do(req) if err != nil { if ctx.Err() != nil { out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} diff --git a/back/internal/tools/ptyrun.go b/back/internal/tools/ptyrun.go index 563fc60..dbb9a13 100644 --- a/back/internal/tools/ptyrun.go +++ b/back/internal/tools/ptyrun.go @@ -6,6 +6,7 @@ import ( "os/exec" "regexp" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/creack/pty" ) @@ -14,7 +15,14 @@ var oscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) // RunWithPTY runs cmd under a pseudo-terminal (preserving ANSI colours) and // returns the full output once the process exits. +// If a proxychains config path is stored in ctx, the command is transparently +// wrapped with proxychains4. func RunWithPTY(ctx context.Context, cmd *exec.Cmd) (string, error) { + if confPath := proxy.ProxychainsConfFromContext(ctx); confPath != "" { + args := append([]string{"-q", "-f", confPath, cmd.Path}, cmd.Args[1:]...) + cmd = exec.CommandContext(ctx, "proxychains4", args...) + } + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 220}) if err != nil { return "", err diff --git a/back/internal/tools/wappalyzer/tool.go b/back/internal/tools/wappalyzer/tool.go index 966a3fa..280d26c 100644 --- a/back/internal/tools/wappalyzer/tool.go +++ b/back/internal/tools/wappalyzer/tool.go @@ -10,6 +10,7 @@ import ( wappalyzergo "github.com/projectdiscovery/wappalyzergo" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" ) @@ -55,7 +56,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out } req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)") - resp, err = http.DefaultClient.Do(req) + resp, err = proxy.ClientFromContext(ctx).Do(req) if err == nil { defer resp.Body.Close() body, err = io.ReadAll(resp.Body) diff --git a/back/internal/tools/whoisfreaks/tool.go b/back/internal/tools/whoisfreaks/tool.go index 29dfe84..6809c30 100644 --- a/back/internal/tools/whoisfreaks/tool.go +++ b/back/internal/tools/whoisfreaks/tool.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/anotherhadi/iknowyou/internal/proxy" "github.com/anotherhadi/iknowyou/internal/tools" "github.com/tidwall/gjson" ) @@ -101,9 +102,9 @@ func prettyResult(r gjson.Result, depth int) string { return sb.String() } -func doRequest(ctx context.Context, req *http.Request) ([]byte, *http.Response, error) { +func doRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, *http.Response, error) { for { - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, nil, err } @@ -161,7 +162,7 @@ func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputTy } req.Header.Set("Accept", "application/json") - body, resp, err := doRequest(ctx, req) + body, resp, err := doRequest(ctx, proxy.ClientFromContext(ctx), req) if err != nil { if ctx.Err() != nil { out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} diff --git a/flake.nix b/flake.nix index c604f29..72e7fd3 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ osintTools = with pkgs; [ whois dnsutils + proxychains-ng maigret nur-osint.packages.${system}.user-scanner nur-osint.packages.${system}.gravatar-recon diff --git a/front/src/components/Nav.svelte b/front/src/components/Nav.svelte index 2244d23..43a4437 100644 --- a/front/src/components/Nav.svelte +++ b/front/src/components/Nav.svelte @@ -23,7 +23,7 @@ const navLinks = [ { label: "Search", href: "/", icon: Search }, { label: "Tools", href: "/tools", icon: Hammer }, - { label: "Profiles", href: "/profiles", icon: SlidersHorizontal }, + { label: "Settings", href: "/settings", icon: SlidersHorizontal }, { label: "Enumerate", href: "/enumerate", icon: ListFilter }, { label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList }, { diff --git a/front/src/components/ProxySettings.svelte b/front/src/components/ProxySettings.svelte new file mode 100644 index 0000000..26ac2d1 --- /dev/null +++ b/front/src/components/ProxySettings.svelte @@ -0,0 +1,201 @@ + + +
+ No proxies configured — tools will connect directly. +
+ {:else} + {#each proxies as proxy, i} + {@const lbl = proxyLabel(proxy.url)} +
+
+ Supported: socks5://, + socks4://, + http:// — on failure, the next proxy is tried automatically. +
+
+ Route all tool traffic through one or more proxies.
+ On network failure the next proxy is tried automatically (round-robin).
+ External binaries are wrapped with proxychains4.
+
No proxies — tools connect directly.
+
+
+ Supported: socks5://, + socks4://, + http:// +
+ {/if} +Select a profile.
+ + {:else if profileLoading} +{profileDetail.notes}
+ {/if} + {:else} +No overrides configured.
+ {:else} +No configurable fields.
+ {/if} +- You can create custom profiles on the Profiles page. + You can create custom profiles on the Settings page. +
+ + + + ++ IKY supports routing all tool traffic through one or more proxies. Proxies are configured + globally on the Settings page and apply + to every search. +
+socks5,
+ socks4,
+ http
+ proxychains4 in
+ dynamic_chain mode,
+ which skips dead proxies automatically
+ + If no proxies are configured, tools connect directly — behaviour is identical to before.
proxychains4
+ config are prepared and injected into the search context.
+ - Manage search profiles: allowed/blocked tools and per-tool config overrides. -
-+ Proxy configuration, search profiles, and per-tool overrides. +
+