mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-05-20 09:12:34 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72c382bb07 | |||
| d29239cbb5 | |||
| 86988d9afe | |||
| fa58485712 | |||
| a0fceb36df | |||
| f53380fbd9 |
@@ -35,7 +35,7 @@ func main() {
|
|||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 120 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ProxyEntry struct {
|
||||||
|
URL string `yaml:"url" json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Tools map[string]yaml.Node `yaml:"tools" json:"tools"`
|
Proxies []ProxyEntry `yaml:"proxies,omitempty" json:"proxies,omitempty"`
|
||||||
Profiles map[string]Profile `yaml:"profiles" json:"profiles"`
|
Tools map[string]yaml.Node `yaml:"tools" json:"tools"`
|
||||||
|
Profiles map[string]Profile `yaml:"profiles" json:"profiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -40,11 +41,18 @@ func (h *ConfigHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
toolConfigs[toolName] = m
|
toolConfigs[toolName] = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
proxies := cfg.Proxies
|
||||||
|
if proxies == nil {
|
||||||
|
proxies = []config.ProxyEntry{}
|
||||||
|
}
|
||||||
|
_, pcErr := exec.LookPath("proxychains4")
|
||||||
respond.JSON(w, http.StatusOK, map[string]any{
|
respond.JSON(w, http.StatusOK, map[string]any{
|
||||||
"tools": toolConfigs,
|
"tools": toolConfigs,
|
||||||
"profiles": cfg.Profiles,
|
"profiles": cfg.Profiles,
|
||||||
"readonly": h.demo || config.IsReadonly(h.configPath),
|
"proxies": proxies,
|
||||||
"demo": h.demo,
|
"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)
|
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 {
|
func validateProfileName(name string) error {
|
||||||
for _, c := range name {
|
for _, c := range name {
|
||||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/respond"
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ansiRe strips all ANSI/VT100 escape sequences (CSI, OSC, etc.).
|
||||||
|
// RunWithPTY only strips OSC sequences; CSI colour codes need this.
|
||||||
|
var ansiRe = regexp.MustCompile(`\x1b[\x5b-\x5f][0-9;]*[A-Za-z]|\x1b[^[\x5b-\x5f]`)
|
||||||
|
|
||||||
|
type EnumerateHandler struct {
|
||||||
|
demo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnumerateHandler(_ string, demo bool) *EnumerateHandler {
|
||||||
|
return &EnumerateHandler{demo: demo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate ---
|
||||||
|
|
||||||
|
type generateRequest struct {
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type emailEntry struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateResponse struct {
|
||||||
|
Usernames []string `json:"usernames"`
|
||||||
|
Emails []emailEntry `json:"emails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailDomains = []string{
|
||||||
|
"gmail.com",
|
||||||
|
"outlook.com",
|
||||||
|
"hotmail.com",
|
||||||
|
"yahoo.com",
|
||||||
|
"protonmail.com",
|
||||||
|
"proton.me",
|
||||||
|
"icloud.com",
|
||||||
|
"live.com",
|
||||||
|
"msn.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
var accentMap = map[rune]rune{
|
||||||
|
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
|
||||||
|
'à': 'a', 'â': 'a', 'ä': 'a', 'á': 'a',
|
||||||
|
'ù': 'u', 'û': 'u', 'ü': 'u', 'ú': 'u',
|
||||||
|
'ô': 'o', 'ö': 'o', 'ó': 'o',
|
||||||
|
'î': 'i', 'ï': 'i', 'í': 'i',
|
||||||
|
'ç': 'c', 'ñ': 'n', 'ß': 's',
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeName(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if mapped, ok := accentMap[r]; ok {
|
||||||
|
b.WriteRune(mapped)
|
||||||
|
} else if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else if r == ' ' || r == '_' || r == '.' {
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUsernames(first, last string) []string {
|
||||||
|
f := strings.ReplaceAll(normalizeName(first), "-", "")
|
||||||
|
l := strings.ReplaceAll(normalizeName(last), "-", "")
|
||||||
|
if f == "" || l == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi := string([]rune(f)[0])
|
||||||
|
li := string([]rune(l)[0])
|
||||||
|
|
||||||
|
patterns := []string{
|
||||||
|
f + l,
|
||||||
|
l + f,
|
||||||
|
fi + l,
|
||||||
|
f + "." + l,
|
||||||
|
l + "." + f,
|
||||||
|
fi + "." + l,
|
||||||
|
f + "_" + l,
|
||||||
|
l + "_" + f,
|
||||||
|
fi + "_" + l,
|
||||||
|
f + li,
|
||||||
|
f + "." + li,
|
||||||
|
fi + li,
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var result []string
|
||||||
|
for _, p := range patterns {
|
||||||
|
// Skip patterns that are too short to be realistic usernames/emails.
|
||||||
|
if len([]rune(p)) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p != "" && !seen[p] {
|
||||||
|
seen[p] = true
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/enumerate/status
|
||||||
|
type statusResponse struct {
|
||||||
|
UserScannerAvailable bool `json:"user_scanner_available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EnumerateHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := exec.LookPath("user-scanner")
|
||||||
|
respond.JSON(w, http.StatusOK, statusResponse{UserScannerAvailable: err == nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/enumerate/generate
|
||||||
|
func (h *EnumerateHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req generateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var usernames []string
|
||||||
|
if req.Username != "" {
|
||||||
|
u := normalizeName(req.Username)
|
||||||
|
if u == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usernames = []string{u}
|
||||||
|
} else {
|
||||||
|
if req.FirstName == "" || req.LastName == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "provide either username or first_name+last_name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usernames = buildUsernames(req.FirstName, req.LastName)
|
||||||
|
if len(usernames) == 0 {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "could not generate usernames from provided names")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var emails []emailEntry
|
||||||
|
for _, u := range usernames {
|
||||||
|
for _, d := range emailDomains {
|
||||||
|
emails = append(emails, emailEntry{
|
||||||
|
Address: u + "@" + d,
|
||||||
|
Username: u,
|
||||||
|
Domain: d,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond.JSON(w, http.StatusOK, generateResponse{Usernames: usernames, Emails: emails})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Check Email ---
|
||||||
|
|
||||||
|
type checkEmailRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Quick bool `json:"quick"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkEmailResponse struct {
|
||||||
|
Status string `json:"status"` // "found" | "not_found" | "maybe"
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userScannerCheck runs user-scanner via PTY (required for output).
|
||||||
|
// flag is either "-e" (email) or "-u" (username).
|
||||||
|
// Office365 is excluded — it's a known false positive.
|
||||||
|
// quick=true uses a shorter timeout for a faster but incomplete scan.
|
||||||
|
func userScannerCheck(ctx context.Context, flag, target string, quick bool) (status, reason string, sites []string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
status, reason, sites = "maybe", "internal error during scan", nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("user-scanner"); err != nil {
|
||||||
|
return "maybe", "user-scanner not available", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scanTimeout := 90 * time.Second
|
||||||
|
if quick {
|
||||||
|
scanTimeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, scanTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "user-scanner", flag, target, "--only-found")
|
||||||
|
output, _ := tools.RunWithPTY(ctx, cmd)
|
||||||
|
|
||||||
|
// Strip ANSI codes, then normalise PTY line endings (\r\n → \n).
|
||||||
|
output = ansiRe.ReplaceAllString(output, "")
|
||||||
|
output = strings.ReplaceAll(output, "\r\n", "\n")
|
||||||
|
output = strings.ReplaceAll(output, "\r", "\n")
|
||||||
|
|
||||||
|
// Strip banner (first 8 lines)
|
||||||
|
if lines := strings.SplitN(output, "\n", 10); len(lines) > 8 {
|
||||||
|
output = strings.Join(lines[8:], "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
if !strings.Contains(line, "[✔]") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Office365 is a known false positive — skip it.
|
||||||
|
if strings.Contains(line, "Office365") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
sites = append(sites, strings.TrimRight(parts[1], ":"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sites) > 0 {
|
||||||
|
return "found", fmt.Sprintf("found via user-scanner (%d result(s))", len(sites)), sites
|
||||||
|
}
|
||||||
|
return "not_found", "not found via user-scanner", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/enumerate/check-email
|
||||||
|
func (h *EnumerateHandler) CheckEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.demo {
|
||||||
|
respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req checkEmailRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Email == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "email is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, reason, _ := userScannerCheck(r.Context(), "-e", req.Email, req.Quick)
|
||||||
|
respond.JSON(w, http.StatusOK, checkEmailResponse{Status: status, Reason: reason})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Check Username ---
|
||||||
|
|
||||||
|
type checkUsernameRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Quick bool `json:"quick"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkUsernameResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Sites []string `json:"sites"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/enumerate/check-username
|
||||||
|
func (h *EnumerateHandler) CheckUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.demo {
|
||||||
|
respond.Error(w, http.StatusForbidden, "checking is disabled in demo mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req checkUsernameRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" {
|
||||||
|
respond.Error(w, http.StatusBadRequest, "username is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, reason, sites := userScannerCheck(r.Context(), "-u", req.Username, req.Quick)
|
||||||
|
if sites == nil {
|
||||||
|
sites = []string{}
|
||||||
|
}
|
||||||
|
respond.JSON(w, http.StatusOK, checkUsernameResponse{Status: status, Reason: reason, Sites: sites})
|
||||||
|
}
|
||||||
@@ -137,6 +137,10 @@ func (h *SearchHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DELETE /searches/{id}
|
// DELETE /searches/{id}
|
||||||
func (h *SearchHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *SearchHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.demo {
|
||||||
|
respond.Error(w, http.StatusForbidden, "demo mode: deletions are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if err := h.manager.Delete(id); err != nil {
|
if err := h.manager.Delete(id); err != nil {
|
||||||
respond.Error(w, http.StatusNotFound, err.Error())
|
respond.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func NewRouter(
|
|||||||
searchHandler := handler.NewSearchHandler(manager, demo)
|
searchHandler := handler.NewSearchHandler(manager, demo)
|
||||||
toolsHandler := handler.NewToolsHandler(factories)
|
toolsHandler := handler.NewToolsHandler(factories)
|
||||||
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
||||||
|
enumerateHandler := handler.NewEnumerateHandler(configPath, demo)
|
||||||
|
|
||||||
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
||||||
|
|
||||||
@@ -45,8 +46,16 @@ func NewRouter(
|
|||||||
r.Get("/{name}", toolsHandler.Get)
|
r.Get("/{name}", toolsHandler.Get)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/enumerate", func(r chi.Router) {
|
||||||
|
r.Get("/status", enumerateHandler.Status)
|
||||||
|
r.Post("/generate", enumerateHandler.Generate)
|
||||||
|
r.Post("/check-email", enumerateHandler.CheckEmail)
|
||||||
|
r.Post("/check-username", enumerateHandler.CheckUsername)
|
||||||
|
})
|
||||||
|
|
||||||
r.Route("/config", func(r chi.Router) {
|
r.Route("/config", func(r chi.Router) {
|
||||||
r.Get("/", configHandler.Get)
|
r.Get("/", configHandler.Get)
|
||||||
|
r.Put("/proxies", configHandler.UpdateProxies)
|
||||||
|
|
||||||
r.Route("/tools", func(r chi.Router) {
|
r.Route("/tools", func(r chi.Router) {
|
||||||
r.Patch("/{toolName}", configHandler.UpdateToolConfig)
|
r.Patch("/{toolName}", configHandler.UpdateToolConfig)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package registry
|
|||||||
import (
|
import (
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||||
breachdirectory "github.com/anotherhadi/iknowyou/internal/tools/breachdirectory"
|
breachdirectory "github.com/anotherhadi/iknowyou/internal/tools/breachdirectory"
|
||||||
|
ghunt "github.com/anotherhadi/iknowyou/internal/tools/ghunt"
|
||||||
crtsh "github.com/anotherhadi/iknowyou/internal/tools/crtsh"
|
crtsh "github.com/anotherhadi/iknowyou/internal/tools/crtsh"
|
||||||
digtool "github.com/anotherhadi/iknowyou/internal/tools/dig"
|
digtool "github.com/anotherhadi/iknowyou/internal/tools/dig"
|
||||||
githubrecon "github.com/anotherhadi/iknowyou/internal/tools/github-recon"
|
githubrecon "github.com/anotherhadi/iknowyou/internal/tools/github-recon"
|
||||||
@@ -25,6 +26,7 @@ var Factories = []func() tools.ToolRunner{
|
|||||||
gravatarrecon.New,
|
gravatarrecon.New,
|
||||||
whoisfreaks.New,
|
whoisfreaks.New,
|
||||||
maigret.New,
|
maigret.New,
|
||||||
|
ghunt.New,
|
||||||
leakcheck.New,
|
leakcheck.New,
|
||||||
crtsh.New,
|
crtsh.New,
|
||||||
breachdirectory.New,
|
breachdirectory.New,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/anotherhadi/iknowyou/config"
|
"github.com/anotherhadi/iknowyou/config"
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +67,24 @@ func (m *Manager) Start(
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(parentCtx)
|
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{
|
s := &Search{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Target: target,
|
Target: target,
|
||||||
@@ -81,7 +100,7 @@ func (m *Manager) Start(
|
|||||||
m.searches[s.ID] = s
|
m.searches[s.ID] = s
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
go m.runAll(ctx, s, activeTools)
|
go m.runAll(ctx, s, activeTools, proxychainsCleanup)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -208,7 +227,10 @@ func (m *Manager) instantiate(cfg *config.Config, inputType tools.InputType, pro
|
|||||||
return runners, statuses, nil
|
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
|
var wg sync.WaitGroup
|
||||||
for _, tool := range runners {
|
for _, tool := range runners {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"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("X-RapidAPI-Host", "breachdirectory.p.rapidapi.com")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := proxy.ClientFromContext(ctx).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"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("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; crtsh-scanner/1.0)")
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package ghunt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ansiRe = regexp.MustCompile(`\x1b[\x5b-\x5f][0-9;]*[A-Za-z]|\x1b[^[\x5b-\x5f]`)
|
||||||
|
|
||||||
|
const (
|
||||||
|
name = "ghunt"
|
||||||
|
description = "Google account OSINT via GHunt. Extracts profile info, linked services, and activity from a Google email address."
|
||||||
|
link = "https://github.com/mxrch/GHunt"
|
||||||
|
icon = "google"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Creds string `yaml:"creds" iky:"desc=GHunt credentials (content of ~/.malfrats/ghunt/creds.m). To obtain: (1) install GHunt and run 'ghunt login' on your machine, (2) copy the full content of ~/.malfrats/ghunt/creds.m, (3) paste it here.;required=true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() tools.ToolRunner {
|
||||||
|
cfg := Config{}
|
||||||
|
tools.ApplyDefaults(&cfg)
|
||||||
|
return &Runner{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Name() string { return name }
|
||||||
|
func (r *Runner) Description() string { return description }
|
||||||
|
func (r *Runner) Link() string { return link }
|
||||||
|
func (r *Runner) Icon() string { return icon }
|
||||||
|
|
||||||
|
func (r *Runner) InputTypes() []tools.InputType {
|
||||||
|
return []tools.InputType{
|
||||||
|
tools.InputTypeEmail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||||
|
|
||||||
|
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||||
|
return tools.ReflectConfigFields(r.cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Available() (bool, string) {
|
||||||
|
if _, err := exec.LookPath("ghunt"); err != nil {
|
||||||
|
return false, "ghunt binary not found in PATH"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Dependencies() []string { return []string{"ghunt"} }
|
||||||
|
|
||||||
|
func (r *Runner) writeCreds() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine home directory: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".malfrats", "ghunt")
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("cannot create ghunt dir: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(dir, "creds.m"), []byte(r.cfg.Creds), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||||
|
defer close(out)
|
||||||
|
|
||||||
|
if err := r.writeCreds(); err != nil {
|
||||||
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||||
|
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ghunt", "email", target)
|
||||||
|
output, err := tools.RunWithPTY(ctx, cmd)
|
||||||
|
|
||||||
|
output = strings.ReplaceAll(output, "\r\n", "\n")
|
||||||
|
output = strings.ReplaceAll(output, "\r", "\n")
|
||||||
|
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
parsed := make([]string, len(lines))
|
||||||
|
for i, l := range lines {
|
||||||
|
parsed[i] = ansiRe.ReplaceAllString(l, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := -1
|
||||||
|
for i, l := range parsed {
|
||||||
|
if strings.Contains(l, "[+] Authenticated !") {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)") {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output = strings.TrimSpace(strings.Join(lines[start:end], "\n"))
|
||||||
|
|
||||||
|
if err != nil && ctx.Err() != nil {
|
||||||
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"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")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := proxy.ClientFromContext(ctx).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"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("X-API-Key", r.cfg.APIKey)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := proxy.ClientFromContext(ctx).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/creack/pty"
|
"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
|
// RunWithPTY runs cmd under a pseudo-terminal (preserving ANSI colours) and
|
||||||
// returns the full output once the process exits.
|
// 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) {
|
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})
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 220})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
wappalyzergo "github.com/projectdiscovery/wappalyzergo"
|
wappalyzergo "github.com/projectdiscovery/wappalyzergo"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"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)")
|
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 {
|
if err == nil {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err = io.ReadAll(resp.Body)
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/iknowyou/internal/proxy"
|
||||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
@@ -101,9 +102,9 @@ func prettyResult(r gjson.Result, depth int) string {
|
|||||||
return sb.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 {
|
for {
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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")
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||||
|
|||||||
Generated
+6
-6
@@ -59,11 +59,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775036866,
|
"lastModified": 1775710090,
|
||||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774035694,
|
"lastModified": 1775935554,
|
||||||
"narHash": "sha256-PtORnAJ/SKeOwrPAjZ0LR00Pu8aDIzXO8H8v9CoM7zk=",
|
"narHash": "sha256-7StMiQf3HSBruAxZ2/ZYJhhNEp1KbglGVijQBrurqiM=",
|
||||||
"owner": "anotherhadi",
|
"owner": "anotherhadi",
|
||||||
"repo": "nur-osint",
|
"repo": "nur-osint",
|
||||||
"rev": "813351d47721d411441bb6221faf2c6163846946",
|
"rev": "460e377522a43d3d968be20bec2cf40a72013904",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -33,9 +33,12 @@
|
|||||||
osintTools = with pkgs; [
|
osintTools = with pkgs; [
|
||||||
whois
|
whois
|
||||||
dnsutils
|
dnsutils
|
||||||
|
proxychains-ng
|
||||||
maigret
|
maigret
|
||||||
nur-osint.packages.${system}.user-scanner
|
nur-osint.packages.${system}.user-scanner
|
||||||
|
nur-osint.packages.${system}.gravatar-recon
|
||||||
nur-osint.packages.${system}.github-recon
|
nur-osint.packages.${system}.github-recon
|
||||||
|
nur-osint.packages.${system}.ghunt
|
||||||
];
|
];
|
||||||
|
|
||||||
ikyPkg = pkgs.symlinkJoin {
|
ikyPkg = pkgs.symlinkJoin {
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
nixosModules.default = import ./nix/module.nix { inherit self; };
|
nixosModules.default = import ./nix/module.nix {inherit self;};
|
||||||
|
|
||||||
packages.${system} = {
|
packages.${system} = {
|
||||||
backend = backendPkg;
|
backend = backendPkg;
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8080",
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
timeout: 120000,
|
||||||
|
proxyTimeout: 120000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Search, CheckCircle, XCircle, HelpCircle, Play, User, Mail, Zap, AtSign, TriangleAlert } from "@lucide/svelte";
|
||||||
|
|
||||||
|
type EntryKind = "username" | "email";
|
||||||
|
type CheckStatus = "idle" | "checking" | "found" | "not_found" | "maybe";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
id: string;
|
||||||
|
kind: EntryKind;
|
||||||
|
value: string;
|
||||||
|
// email-specific
|
||||||
|
domain?: string;
|
||||||
|
// username-specific
|
||||||
|
sites?: string[];
|
||||||
|
status: CheckStatus;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: "name" | "username" = $state("name");
|
||||||
|
let firstName = $state("");
|
||||||
|
let lastName = $state("");
|
||||||
|
let usernameInput = $state("");
|
||||||
|
|
||||||
|
let entries: Entry[] = $state([]);
|
||||||
|
let generating = $state(false);
|
||||||
|
let generateError = $state("");
|
||||||
|
let demo = $state(false);
|
||||||
|
let userScannerAvailable = $state(true);
|
||||||
|
|
||||||
|
fetch("/api/config")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => { if (d) demo = d.demo === true; })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
fetch("/api/enumerate/status")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => { if (d) userScannerAvailable = d.user_scanner_available; })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
let filter: "all" | "username" | "email" = $state("all");
|
||||||
|
let domainFilter = $state("all");
|
||||||
|
let allDomains: string[] = $state([]);
|
||||||
|
let quickMode = $state(true);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
generateError = "";
|
||||||
|
generating = true;
|
||||||
|
entries = [];
|
||||||
|
allDomains = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
mode === "name"
|
||||||
|
? { first_name: firstName.trim(), last_name: lastName.trim() }
|
||||||
|
: { username: usernameInput.trim() };
|
||||||
|
|
||||||
|
const res = await fetch("/api/enumerate/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const newEntries: Entry[] = [];
|
||||||
|
|
||||||
|
for (const u of data.usernames ?? []) {
|
||||||
|
newEntries.push({ id: "u:" + u, kind: "username", value: u, status: "idle", reason: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set<string>();
|
||||||
|
for (const e of data.emails ?? []) {
|
||||||
|
newEntries.push({
|
||||||
|
id: "e:" + e.address,
|
||||||
|
kind: "email",
|
||||||
|
value: e.address,
|
||||||
|
domain: e.domain,
|
||||||
|
status: "idle",
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
|
domains.add(e.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = newEntries;
|
||||||
|
allDomains = Array.from(domains);
|
||||||
|
} catch (e: any) {
|
||||||
|
generateError = e.message;
|
||||||
|
} finally {
|
||||||
|
generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkEntry(entry: Entry) {
|
||||||
|
entry.status = "checking";
|
||||||
|
entry.reason = "";
|
||||||
|
entry.sites = undefined;
|
||||||
|
entries = entries; // trigger reactivity
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (entry.kind === "email") {
|
||||||
|
const res = await fetch("/api/enumerate/check-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: entry.value, quick: quickMode }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) { entry.status = "maybe"; entry.reason = "empty response from server"; entries = entries; return; }
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
entry.status = d.status;
|
||||||
|
entry.reason = d.reason;
|
||||||
|
} else {
|
||||||
|
const res = await fetch("/api/enumerate/check-username", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: entry.value, quick: quickMode }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) { entry.status = "maybe"; entry.reason = "empty response from server"; entries = entries; return; }
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
entry.status = d.status;
|
||||||
|
entry.reason = d.reason;
|
||||||
|
entry.sites = d.sites ?? [];
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
entry.status = "maybe";
|
||||||
|
entry.reason = e.message;
|
||||||
|
}
|
||||||
|
entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAll() {
|
||||||
|
const visible = filteredEntries;
|
||||||
|
await Promise.all(visible.map((e) => checkEntry(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredEntries = $derived(
|
||||||
|
entries.filter((e) => {
|
||||||
|
if (filter !== "all" && e.kind !== filter) return false;
|
||||||
|
if (filter === "email" && domainFilter !== "all" && e.domain !== domainFilter) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const anyChecking = $derived(entries.some((e) => e.status === "checking"));
|
||||||
|
|
||||||
|
function statusColor(s: CheckStatus) {
|
||||||
|
if (s === "found") return "text-success";
|
||||||
|
if (s === "not_found") return "text-error";
|
||||||
|
if (s === "maybe") return "text-warning";
|
||||||
|
return "text-base-content/40";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: CheckStatus) {
|
||||||
|
if (s === "found") return "Found";
|
||||||
|
if (s === "not_found") return "Not found";
|
||||||
|
if (s === "maybe") return "Maybe";
|
||||||
|
if (s === "checking") return "Checking…";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<!-- Input form -->
|
||||||
|
<div class="card bg-base-200 shadow p-4 flex flex-col gap-3">
|
||||||
|
|
||||||
|
<!-- Mobile layout -->
|
||||||
|
<div class="flex flex-col gap-2 sm:hidden">
|
||||||
|
<div class="flex gap-1 p-1 bg-base-300 rounded-xl border border-base-content/15">
|
||||||
|
<button
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'name' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/60'}"
|
||||||
|
onclick={() => { mode = 'name'; entries = []; }}
|
||||||
|
>
|
||||||
|
<User size={12} /> Name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'username' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/60'}"
|
||||||
|
onclick={() => { mode = 'username'; entries = []; }}
|
||||||
|
>
|
||||||
|
<AtSign size={12} /> Username
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === "name"}
|
||||||
|
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="First name"
|
||||||
|
bind:value={firstName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
<span class="text-base-content/20 shrink-0 px-1 select-none">·</span>
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="Last name"
|
||||||
|
bind:value={lastName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-3 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="@username"
|
||||||
|
bind:value={usernameInput}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary gap-2"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={generating || (mode === "name" ? !firstName.trim() || !lastName.trim() : !usernameInput.trim())}
|
||||||
|
>
|
||||||
|
{#if generating}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Search size={15} />
|
||||||
|
{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop layout -->
|
||||||
|
<div class="hidden sm:flex items-center rounded-xl border border-base-content/15 bg-base-300 focus-within:border-primary/40 transition-colors">
|
||||||
|
<!-- Mode toggle -->
|
||||||
|
<div class="border-r border-base-content/10 flex items-center gap-0.5 p-1 shrink-0">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'name' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/70'}"
|
||||||
|
onclick={() => { mode = 'name'; entries = []; }}
|
||||||
|
>
|
||||||
|
<User size={12} /> Name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all
|
||||||
|
{mode === 'username' ? 'bg-base-100 text-base-content shadow-sm' : 'text-base-content/40 hover:text-base-content/70'}"
|
||||||
|
onclick={() => { mode = 'username'; entries = []; }}
|
||||||
|
>
|
||||||
|
<AtSign size={12} /> Username
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inputs -->
|
||||||
|
{#if mode === "name"}
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="First name"
|
||||||
|
bind:value={firstName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
<span class="text-base-content/20 shrink-0 select-none">·</span>
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="Last name"
|
||||||
|
bind:value={lastName}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class="flex-1 bg-transparent px-4 py-2.5 outline-none text-sm placeholder:text-base-content/30 min-w-0"
|
||||||
|
type="text" placeholder="@username"
|
||||||
|
bind:value={usernameInput}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && generate()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Generate button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm m-1 rounded-lg gap-1 shrink-0"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={generating || (mode === "name" ? !firstName.trim() || !lastName.trim() : !usernameInput.trim())}
|
||||||
|
>
|
||||||
|
{#if generating}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Search size={14} />
|
||||||
|
{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if generateError}
|
||||||
|
<p class="text-error text-xs pl-1">{generateError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- user-scanner warning -->
|
||||||
|
{#if !userScannerAvailable}
|
||||||
|
<div class="alert alert-warning text-sm gap-2">
|
||||||
|
<TriangleAlert size={15} class="shrink-0" />
|
||||||
|
<span><span class="font-mono">user-scanner</span> is not installed — email and username checking will be unavailable.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
{#if entries.length === 0 && !generating}
|
||||||
|
<p class="text-center text-sm text-base-content/30 py-6">
|
||||||
|
Enter a name or username above to generate candidates.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 justify-between">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div class="join border border-base-content/10 rounded-lg">
|
||||||
|
<button class="btn btn-xs join-item {filter === 'all' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'all'; domainFilter = 'all'; }}>All</button>
|
||||||
|
<button class="btn btn-xs join-item {filter === 'username' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'username'; domainFilter = 'all'; }}>
|
||||||
|
<User size={11} /> Usernames
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs join-item {filter === 'email' ? 'btn-neutral' : 'btn-ghost'}" onclick={() => { filter = 'email'; }}>
|
||||||
|
<Mail size={11} /> Emails
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filter === "email" && allDomains.length > 1}
|
||||||
|
<select class="select select-bordered select-xs" bind:value={domainFilter}>
|
||||||
|
<option value="all">All domains</option>
|
||||||
|
{#each allDomains as d}
|
||||||
|
<option value={d}>{d}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="tooltip" data-tip="Quick mode uses a smaller set of checks for faster results.">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-base-content/60 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" bind:checked={quickMode} />
|
||||||
|
<Zap size={11} /> Quick
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tooltip" data-tip={demo ? "Disabled in demo mode" : undefined}>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-2"
|
||||||
|
onclick={checkAll}
|
||||||
|
disabled={anyChecking || demo}
|
||||||
|
>
|
||||||
|
{#if anyChecking}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Play size={13} />
|
||||||
|
{/if}
|
||||||
|
Test all visible
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-base-300">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal">Value</th>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal w-24">Type</th>
|
||||||
|
<th class="text-xs uppercase tracking-wider text-base-content/50 font-normal w-32">Status</th>
|
||||||
|
<th class="w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredEntries as entry (entry.id)}
|
||||||
|
<tr class="hover:bg-base-200/50">
|
||||||
|
<td>
|
||||||
|
<div class="tooltip tooltip-right" data-tip="Open in search">
|
||||||
|
<a
|
||||||
|
href={`/?target=${encodeURIComponent(entry.value)}&type=${entry.kind}&fillOnly=true`}
|
||||||
|
class="font-mono text-sm hover:text-primary hover:underline transition-colors"
|
||||||
|
>{entry.value}</a>
|
||||||
|
</div>
|
||||||
|
{#if entry.sites && entry.sites.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{#each entry.sites as site}
|
||||||
|
<span class="badge badge-xs badge-success badge-soft">{site}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if entry.reason && entry.status !== "idle" && entry.status !== "checking"}
|
||||||
|
<div class="text-xs text-base-content/40 mt-0.5">{entry.reason}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-xs badge-ghost">
|
||||||
|
{entry.kind === "email" ? "email" : "username"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if entry.status === "checking"}
|
||||||
|
<span class="flex items-center gap-1 text-xs text-base-content/50">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span> Checking
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "found"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-success font-medium">
|
||||||
|
<CheckCircle size={13} /> Found
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "not_found"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-error">
|
||||||
|
<XCircle size={13} /> Not found
|
||||||
|
</span>
|
||||||
|
{:else if entry.status === "maybe"}
|
||||||
|
<span class="flex items-center gap-1.5 text-xs text-warning">
|
||||||
|
<HelpCircle size={13} /> Maybe
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-base-content/30">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="tooltip tooltip-left" data-tip={demo ? "Disabled in demo mode" : undefined}>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost gap-1"
|
||||||
|
onclick={() => checkEntry(entry)}
|
||||||
|
disabled={entry.status === "checking" || demo}
|
||||||
|
>
|
||||||
|
{#if entry.status === "checking"}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Play size={11} />
|
||||||
|
{/if}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/40 text-right">
|
||||||
|
{filteredEntries.length} entries shown
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
let redirecting = $state(false);
|
let redirecting = $state(false);
|
||||||
let redirectTarget = $state("");
|
let redirectTarget = $state("");
|
||||||
let demo = $state(false);
|
let demo = $state(false);
|
||||||
|
let prefill = $state({ target: "", type: "" });
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
loadSearches();
|
loadSearches();
|
||||||
@@ -20,10 +22,20 @@
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const target = params.get("target");
|
const target = params.get("target");
|
||||||
const type = params.get("type");
|
const type = params.get("type");
|
||||||
|
const fillOnly = params.get("fillOnly") === "true";
|
||||||
|
|
||||||
if (target && type) {
|
if (target && type) {
|
||||||
// Clean URL before launching so a refresh doesn't re-trigger
|
// Clean URL before acting so a refresh doesn't re-trigger
|
||||||
window.history.replaceState({}, "", window.location.pathname);
|
window.history.replaceState({}, "", window.location.pathname);
|
||||||
|
if (fillOnly) {
|
||||||
|
prefill = { target, type };
|
||||||
|
ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ready = true;
|
||||||
await handleSearch(target, type, params.get("profile") || "default");
|
await handleSearch(target, type, params.get("profile") || "default");
|
||||||
|
} else {
|
||||||
|
ready = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,8 +89,8 @@
|
|||||||
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
|
Searching <span class="font-mono text-base-content/90">{redirectTarget}</span>...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if ready}
|
||||||
<SearchBar onSearch={handleSearch} {demo} />
|
<SearchBar onSearch={handleSearch} {demo} initialTarget={prefill.target} initialType={prefill.type} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Bug,
|
Bug,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
ListFilter,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: "Search", href: "/", icon: Search },
|
{ label: "Search", href: "/", icon: Search },
|
||||||
{ label: "Tools", href: "/tools", icon: Hammer },
|
{ 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 },
|
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
|
||||||
{
|
{
|
||||||
label: "More",
|
label: "More",
|
||||||
@@ -85,17 +87,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Logo à gauche sur écran moyen et grand -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="btn btn-ghost text-xl flex justify-center gap-2 items-center"
|
class="btn btn-ghost text-xl hidden sm:flex justify-center gap-2 items-center"
|
||||||
>
|
>
|
||||||
<img src="/logo.svg" class="m-auto h-6" alt="iky logo" />
|
<img src="/logo.svg" class="m-auto h-4 lg:h-6" alt="iky logo" />
|
||||||
<img src="/logo-large.svg" class="m-auto h-6" alt="iky logo large" />
|
<img src="/logo-large.svg" class="m-auto h-4 lg:h-6" alt="iky logo large" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<!-- Logo centré sur petit écran (mobile) -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="btn btn-ghost text-xl flex sm:hidden justify-center gap-2 items-center"
|
||||||
|
>
|
||||||
|
<img src="/logo.svg" class="m-auto h-4" alt="iky logo" />
|
||||||
|
<img src="/logo-large.svg" class="m-auto h-4" alt="iky logo large" />
|
||||||
|
</a>
|
||||||
|
<!-- Nav links sur grand écran -->
|
||||||
|
<ul class="menu menu-horizontal px-1 hidden lg:flex">
|
||||||
{#each navLinks as link}
|
{#each navLinks as link}
|
||||||
<li>
|
<li>
|
||||||
{#if link.children}
|
{#if link.children}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { Plus, Trash2, Save, Shield, AlertTriangle, Lock } from "@lucide/svelte";
|
||||||
|
|
||||||
|
let proxies = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let msg = $state(null);
|
||||||
|
let configReadonly = $state(false);
|
||||||
|
let newUrl = $state("");
|
||||||
|
let newUrlError = $state("");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/config");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const cfg = await res.json();
|
||||||
|
proxies = cfg.proxies ?? [];
|
||||||
|
configReadonly = cfg.readonly ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
msg = { ok: false, text: e.message };
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateUrl(url) {
|
||||||
|
if (!url) return "URL is required";
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (!["socks4:", "socks5:", "http:"].includes(u.protocol)) {
|
||||||
|
return "Protocol must be socks4, socks5, or http";
|
||||||
|
}
|
||||||
|
if (!u.hostname) return "Missing hostname";
|
||||||
|
} catch {
|
||||||
|
return "Invalid URL format";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProxy() {
|
||||||
|
const url = newUrl.trim();
|
||||||
|
const err = validateUrl(url);
|
||||||
|
if (err) { newUrlError = err; return; }
|
||||||
|
if (proxies.some((p) => p.url === url)) {
|
||||||
|
newUrlError = "This proxy is already in the list";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
proxies = [...proxies, { url }];
|
||||||
|
newUrl = "";
|
||||||
|
newUrlError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProxy(index) {
|
||||||
|
proxies = proxies.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
msg = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/config/proxies", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(proxies),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
msg = { ok: true, text: "Saved" };
|
||||||
|
setTimeout(() => (msg = null), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
msg = { ok: false, text: e.message };
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyLabel(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const proto = u.protocol.replace(":", "").toUpperCase();
|
||||||
|
const auth = u.username ? `${u.username}@` : "";
|
||||||
|
return { proto, host: `${auth}${u.hostname}:${u.port || defaultPort(u.protocol)}` };
|
||||||
|
} catch {
|
||||||
|
return { proto: "?", host: url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPort(protocol) {
|
||||||
|
if (protocol === "http:") return "8080";
|
||||||
|
return "1080";
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTO_COLORS = {
|
||||||
|
SOCKS5: "badge-primary",
|
||||||
|
SOCKS4: "badge-secondary",
|
||||||
|
HTTP: "badge-neutral",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow">
|
||||||
|
<div class="card-body gap-4 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Shield size={15} class="text-base-content/50 shrink-0" />
|
||||||
|
<h3 class="text-xs uppercase tracking-widest text-base-content/50">Proxies</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if msg}
|
||||||
|
<span class="text-xs {msg.ok ? 'text-success' : 'text-error'}">{msg.text}</span>
|
||||||
|
{/if}
|
||||||
|
{#if !configReadonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm gap-1"
|
||||||
|
onclick={save}
|
||||||
|
disabled={saving || loading}
|
||||||
|
>
|
||||||
|
{#if saving}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Save size={13} />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if configReadonly}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-base-content/50">
|
||||||
|
<Lock size={12} />
|
||||||
|
Proxy config is read-only.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if proxies.length === 0}
|
||||||
|
<p class="text-sm text-base-content/40">
|
||||||
|
No proxies configured — tools will connect directly.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each proxies as proxy, i}
|
||||||
|
{@const lbl = proxyLabel(proxy.url)}
|
||||||
|
<div class="flex items-center gap-2 bg-base-300 rounded-box px-3 py-2">
|
||||||
|
<span class="badge badge-xs {PROTO_COLORS[lbl.proto] ?? 'badge-ghost'} shrink-0">
|
||||||
|
{lbl.proto}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-sm flex-1 truncate">{lbl.host}</span>
|
||||||
|
{#if !configReadonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||||
|
onclick={() => removeProxy(i)}
|
||||||
|
title="Remove proxy"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !configReadonly}
|
||||||
|
<div class="flex flex-col gap-1 mt-1">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm font-mono flex-1 {newUrlError ? 'input-error' : ''}"
|
||||||
|
placeholder="socks5://user:pass@host:1080"
|
||||||
|
bind:value={newUrl}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && addProxy()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm gap-1 shrink-0"
|
||||||
|
onclick={addProxy}
|
||||||
|
disabled={!newUrl.trim()}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if newUrlError}
|
||||||
|
<p class="text-xs text-error flex items-center gap-1">
|
||||||
|
<AlertTriangle size={11} />{newUrlError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-base-content/40">
|
||||||
|
Supported: <span class="font-mono">socks5://</span>,
|
||||||
|
<span class="font-mono">socks4://</span>,
|
||||||
|
<span class="font-mono">http://</span> — on failure, the next proxy is tried automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Select from "./comps/Select.svelte";
|
import Select from "./comps/Select.svelte";
|
||||||
import { INPUT_TYPES } from "@src/lib/vars";
|
import { INPUT_TYPES } from "@src/lib/vars";
|
||||||
|
|
||||||
let { onSearch = async () => {}, demo = false } = $props();
|
let { onSearch = async () => {}, demo = false, initialTarget = "", initialType = "" } = $props();
|
||||||
|
|
||||||
const DETECTORS = {
|
const DETECTORS = {
|
||||||
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
email: (_raw, v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
|
domain: { test: (v) => /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), msg: "Invalid domain name" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let target = $state("");
|
let target = $state(initialTarget);
|
||||||
let inputType = $state("email");
|
let inputType = $state(initialType || "email");
|
||||||
let profile = $state("default");
|
let profile = $state("default");
|
||||||
let profiles = $state([]);
|
let profiles = $state([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -164,19 +164,19 @@
|
|||||||
onselect={(v) => { profile = v; }}
|
onselect={(v) => { profile = v; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-sm flex-1 gap-1"
|
|
||||||
onclick={submit}
|
|
||||||
disabled={demo || loading || !target.trim()}
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
{:else}
|
|
||||||
<Search size={14} />
|
|
||||||
{/if}
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm w-full gap-1"
|
||||||
|
onclick={submit}
|
||||||
|
disabled={demo || loading || !target.trim()}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Search size={14} />
|
||||||
|
{/if}
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop layout -->
|
<!-- Desktop layout -->
|
||||||
|
|||||||
@@ -28,42 +28,48 @@
|
|||||||
href={`/search/${s.id}`}
|
href={`/search/${s.id}`}
|
||||||
class="card bg-base-200 hover:bg-base-300 transition-colors shadow-sm cursor-pointer"
|
class="card bg-base-200 hover:bg-base-300 transition-colors shadow-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="card-body flex-row items-center gap-4 py-3 px-4">
|
<div class="card-body py-3 px-4">
|
||||||
<div class="text-base-content/40 w-6 flex items-center justify-center shrink-0">
|
<div class="flex items-start gap-3">
|
||||||
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
|
<!-- Icon -->
|
||||||
<Icon size={16} />
|
<div class="text-base-content/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
{/each}
|
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
|
||||||
</div>
|
<Icon size={16} />
|
||||||
<div class="flex flex-col min-w-0 flex-1">
|
{/each}
|
||||||
<span class="font-mono font-semibold truncate">{s.target}</span>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
|
|
||||||
<span>{s.input_type}</span>
|
<!-- Target + meta -->
|
||||||
{#if s.profile}
|
<div class="flex flex-col min-w-0 flex-1 gap-0.5">
|
||||||
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
|
<span class="font-mono font-semibold truncate leading-snug">{s.target}</span>
|
||||||
{/if}
|
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
|
||||||
<span>· {fmtDate(s.started_at)}</span>
|
<span>{s.input_type}</span>
|
||||||
|
{#if s.profile}
|
||||||
|
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
|
||||||
|
{/if}
|
||||||
|
<span>· {fmtDate(s.started_at)}</span>
|
||||||
|
{#if s.status !== "running"}
|
||||||
|
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
|
||||||
|
{#if total > 0}
|
||||||
|
<span>· {total} result{total !== 1 ? "s" : ""}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status + delete -->
|
||||||
|
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||||
|
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm">
|
||||||
|
{#if s.status === "running"}
|
||||||
|
<span class="loading loading-ring loading-xs mr-1"></span>
|
||||||
|
{/if}
|
||||||
|
{s.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error"
|
||||||
|
onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
|
||||||
|
title="Delete"
|
||||||
|
><X size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if s.status !== "running"}
|
|
||||||
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
|
|
||||||
{#if total > 0}
|
|
||||||
<span class="text-xs font-mono text-base-content/50 shrink-0">{total} result{total !== 1 ? "s" : ""}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm shrink-0">
|
|
||||||
{#if s.status === "running"}
|
|
||||||
<span class="loading loading-ring loading-xs mr-1"></span>
|
|
||||||
{/if}
|
|
||||||
{s.status}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error shrink-0"
|
|
||||||
onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
|
|
||||||
title="Delete"
|
|
||||||
><X size={14} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Save, Shield, ChevronRight, X,
|
||||||
|
Lock, AlertTriangle, SlidersHorizontal,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import Select from "./comps/Select.svelte";
|
||||||
|
import Badge from "./comps/Badge.svelte";
|
||||||
|
import InfoTip from "./comps/InfoTip.svelte";
|
||||||
|
|
||||||
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state("");
|
||||||
|
let configReadonly = $state(false);
|
||||||
|
let selectedView = $state("proxies"); // "proxies" | profile name
|
||||||
|
|
||||||
|
// ── Proxy state ───────────────────────────────────────────────────────────
|
||||||
|
let proxies = $state([]);
|
||||||
|
let proxychainsAvailable = $state(true);
|
||||||
|
let proxySaving = $state(false);
|
||||||
|
let proxyMsg = $state(null);
|
||||||
|
let newUrl = $state("");
|
||||||
|
let newUrlError = $state("");
|
||||||
|
|
||||||
|
// ── Profile state ─────────────────────────────────────────────────────────
|
||||||
|
let tools = $state([]);
|
||||||
|
let profiles = $state([]);
|
||||||
|
let profileDetail = $state(null);
|
||||||
|
let profileLoading = $state(false);
|
||||||
|
let notesEdit = $state("");
|
||||||
|
let enabledEdit = $state([]);
|
||||||
|
let disabledEdit = $state([]);
|
||||||
|
let rulesSaving = $state(false);
|
||||||
|
let rulesMsg = $state(null);
|
||||||
|
let overrideEdits = $state({});
|
||||||
|
let overrideSaving = $state({});
|
||||||
|
let overrideMsg = $state({});
|
||||||
|
let showNewProfile = $state(false);
|
||||||
|
let newName = $state("");
|
||||||
|
let newProfileSaving = $state(false);
|
||||||
|
let newProfileError = $state("");
|
||||||
|
|
||||||
|
let overrideToolNames = $derived(Object.keys(profileDetail?.tools ?? {}));
|
||||||
|
let configurableTools = $derived(tools.filter((t) => t.config_fields?.length > 0));
|
||||||
|
let availableForOverride = $derived(configurableTools.filter((t) => !overrideToolNames.includes(t.name)));
|
||||||
|
let allToolNames = $derived(tools.map((t) => t.name));
|
||||||
|
let isReadonly = $derived((profileDetail?.readonly ?? false) || configReadonly);
|
||||||
|
|
||||||
|
onMount(loadAll);
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [tr, pr, cr] = await Promise.all([
|
||||||
|
fetch("/api/tools"),
|
||||||
|
fetch("/api/config/profiles"),
|
||||||
|
fetch("/api/config"),
|
||||||
|
]);
|
||||||
|
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
|
||||||
|
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
|
||||||
|
tools = await tr.json();
|
||||||
|
profiles = await pr.json();
|
||||||
|
if (cr.ok) {
|
||||||
|
const cfg = await cr.json();
|
||||||
|
configReadonly = cfg.readonly ?? false;
|
||||||
|
proxies = cfg.proxies ?? [];
|
||||||
|
proxychainsAvailable = cfg.proxychains_available ?? true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proxy helpers ─────────────────────────────────────────────────────────
|
||||||
|
function validateProxyUrl(url) {
|
||||||
|
if (!url) return "URL is required";
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (!["socks4:", "socks5:", "http:"].includes(u.protocol))
|
||||||
|
return "Protocol must be socks4, socks5, or http";
|
||||||
|
if (!u.hostname) return "Missing hostname";
|
||||||
|
} catch { return "Invalid URL format"; }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProxy() {
|
||||||
|
const url = newUrl.trim();
|
||||||
|
const err = validateProxyUrl(url);
|
||||||
|
if (err) { newUrlError = err; return; }
|
||||||
|
if (proxies.some((p) => p.url === url)) { newUrlError = "Already in list"; return; }
|
||||||
|
proxies = [...proxies, { url }];
|
||||||
|
newUrl = "";
|
||||||
|
newUrlError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProxy(i) { proxies = proxies.filter((_, j) => j !== i); }
|
||||||
|
|
||||||
|
async function saveProxies() {
|
||||||
|
proxySaving = true;
|
||||||
|
proxyMsg = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/config/proxies", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(proxies),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
proxyMsg = { ok: true, text: "Saved" };
|
||||||
|
setTimeout(() => (proxyMsg = null), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
proxyMsg = { ok: false, text: e.message };
|
||||||
|
} finally {
|
||||||
|
proxySaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyLabel(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const proto = u.protocol.replace(":", "").toUpperCase();
|
||||||
|
const auth = u.username ? `${u.username}@` : "";
|
||||||
|
const port = u.port || (u.protocol === "http:" ? "8080" : "1080");
|
||||||
|
return { proto, host: `${auth}${u.hostname}:${port}` };
|
||||||
|
} catch { return { proto: "?", host: url }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTO_COLOR = { SOCKS5: "badge-primary", SOCKS4: "badge-secondary", HTTP: "badge-neutral" };
|
||||||
|
|
||||||
|
// ── Profile helpers ───────────────────────────────────────────────────────
|
||||||
|
async function selectProfile(name) {
|
||||||
|
selectedView = name;
|
||||||
|
profileLoading = true;
|
||||||
|
profileDetail = null;
|
||||||
|
overrideEdits = {};
|
||||||
|
overrideSaving = {};
|
||||||
|
overrideMsg = {};
|
||||||
|
rulesMsg = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/config/profiles/${encodeURIComponent(name)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
profileDetail = await res.json();
|
||||||
|
notesEdit = profileDetail.notes ?? "";
|
||||||
|
enabledEdit = [...(profileDetail.enabled ?? [])];
|
||||||
|
disabledEdit = [...(profileDetail.disabled ?? [])];
|
||||||
|
const nextEdits = {};
|
||||||
|
for (const [tn, tc] of Object.entries(profileDetail.tools ?? {})) {
|
||||||
|
const tool = tools.find((t) => t.name === tn);
|
||||||
|
if (!tool?.config_fields?.length) continue;
|
||||||
|
nextEdits[tn] = {};
|
||||||
|
for (const f of tool.config_fields)
|
||||||
|
nextEdits[tn][f.name] = tc?.[f.name] !== undefined ? tc[f.name] : (f.default ?? "");
|
||||||
|
}
|
||||||
|
overrideEdits = nextEdits;
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
finally { profileLoading = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNewName(name) {
|
||||||
|
if (!name) return "Name is required";
|
||||||
|
if (!/^[a-z0-9-]+$/.test(name)) return "Only a-z, 0-9 and hyphens";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProfile() {
|
||||||
|
const name = newName.trim();
|
||||||
|
const nameError = validateNewName(name);
|
||||||
|
if (nameError) { newProfileError = nameError; return; }
|
||||||
|
newProfileSaving = true;
|
||||||
|
newProfileError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/config/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
showNewProfile = false;
|
||||||
|
newName = "";
|
||||||
|
await loadAll();
|
||||||
|
await selectProfile(name);
|
||||||
|
} catch (e) { newProfileError = e.message; }
|
||||||
|
finally { newProfileSaving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProfile(name) {
|
||||||
|
if (!confirm(`Delete profile "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/config/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||||
|
if (selectedView === name) { selectedView = "proxies"; profileDetail = null; }
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRules() {
|
||||||
|
rulesSaving = true;
|
||||||
|
rulesMsg = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/config/profiles/${encodeURIComponent(selectedView)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: enabledEdit, disabled: disabledEdit, notes: notesEdit }),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
rulesMsg = { ok: true, text: "Saved" };
|
||||||
|
setTimeout(() => (rulesMsg = null), 3000);
|
||||||
|
await selectProfile(selectedView);
|
||||||
|
} catch (e) { rulesMsg = { ok: false, text: e.message }; }
|
||||||
|
finally { rulesSaving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOverride(toolName) {
|
||||||
|
const tool = tools.find((t) => t.name === toolName);
|
||||||
|
for (const f of tool?.config_fields ?? []) {
|
||||||
|
if (f.required) {
|
||||||
|
const v = overrideEdits[toolName]?.[f.name];
|
||||||
|
if (v === undefined || v === null || v === "") {
|
||||||
|
flashOverride(toolName, { ok: false, text: `"${f.name}" is required` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrideSaving = { ...overrideSaving, [toolName]: true };
|
||||||
|
overrideMsg = { ...overrideMsg, [toolName]: null };
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/config/profiles/${encodeURIComponent(selectedView)}/tools/${encodeURIComponent(toolName)}`,
|
||||||
|
{ method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(overrideEdits[toolName]) }
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
flashOverride(toolName, { ok: true, text: "Saved" });
|
||||||
|
} catch (e) { flashOverride(toolName, { ok: false, text: e.message }); }
|
||||||
|
finally { overrideSaving = { ...overrideSaving, [toolName]: false }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOverride(toolName) {
|
||||||
|
if (!confirm(`Remove "${toolName}" override from "${selectedView}"?`)) return;
|
||||||
|
try {
|
||||||
|
await fetch(
|
||||||
|
`/api/config/profiles/${encodeURIComponent(selectedView)}/tools/${encodeURIComponent(toolName)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
await selectProfile(selectedView);
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOverrideFor(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
const tool = tools.find((t) => t.name === toolName);
|
||||||
|
if (!tool) return;
|
||||||
|
const toolEdits = {};
|
||||||
|
for (const f of tool.config_fields ?? []) toolEdits[f.name] = f.default ?? "";
|
||||||
|
overrideEdits = { ...overrideEdits, [toolName]: toolEdits };
|
||||||
|
profileDetail = { ...profileDetail, tools: { ...(profileDetail.tools ?? {}), [toolName]: {} } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashOverride(toolName, val) {
|
||||||
|
overrideMsg = { ...overrideMsg, [toolName]: val };
|
||||||
|
setTimeout(() => { overrideMsg = { ...overrideMsg, [toolName]: null }; }, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col md:flex-row gap-0 items-start">
|
||||||
|
|
||||||
|
<!-- ── Sidebar ──────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="w-full md:w-56 shrink-0 flex flex-col gap-1
|
||||||
|
border-b border-base-300 pb-4 mb-4
|
||||||
|
md:border-b-0 md:border-r md:pb-0 md:mb-0 md:pr-4 md:mr-6">
|
||||||
|
|
||||||
|
<!-- Proxies entry -->
|
||||||
|
<p class="text-xs uppercase tracking-widest text-base-content/40 px-2 mb-1">Global</p>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 w-full btn btn-sm
|
||||||
|
{selectedView === 'proxies' ? 'btn-primary' : 'btn-ghost'} justify-start"
|
||||||
|
onclick={() => { selectedView = 'proxies'; profileDetail = null; }}
|
||||||
|
>
|
||||||
|
{#if selectedView === 'proxies'}
|
||||||
|
<ChevronRight size={13} class="shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<span class="size-[13px] shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
<Shield size={13} class="shrink-0" />
|
||||||
|
Proxies
|
||||||
|
{#if proxies.length > 0}
|
||||||
|
<span class="badge badge-xs badge-primary ml-auto">{proxies.length}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider my-1 text-xs text-base-content/40">
|
||||||
|
<div class="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span class="uppercase tracking-widest text-[10px]">Profiles</span>
|
||||||
|
{#if !configReadonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
title="New profile"
|
||||||
|
onclick={() => { showNewProfile = !showNewProfile; newName = ""; newProfileError = ""; }}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New profile inline form -->
|
||||||
|
{#if showNewProfile && !configReadonly}
|
||||||
|
<div class="flex flex-col gap-2 p-2 bg-base-300 rounded-box mb-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-xs w-full font-mono
|
||||||
|
{newProfileError && !/^[a-z0-9-]*$/.test(newName) ? 'input-error' : ''}"
|
||||||
|
placeholder="profile-name"
|
||||||
|
bind:value={newName}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && createProfile()}
|
||||||
|
/>
|
||||||
|
{#if newProfileError}
|
||||||
|
<p class="text-[11px] text-error">{newProfileError}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-xs w-full"
|
||||||
|
onclick={createProfile}
|
||||||
|
disabled={newProfileSaving || !newName.trim()}
|
||||||
|
>
|
||||||
|
{#if newProfileSaving}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
Create
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Profile list -->
|
||||||
|
{#each profiles as p}
|
||||||
|
<div class="flex items-center gap-1 group">
|
||||||
|
<button
|
||||||
|
class="flex-1 btn btn-sm justify-start gap-1 truncate
|
||||||
|
{selectedView === p.name ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
onclick={() => selectProfile(p.name)}
|
||||||
|
>
|
||||||
|
{#if selectedView === p.name}
|
||||||
|
<ChevronRight size={13} class="shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<span class="size-[13px] shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
{#if p.readonly}
|
||||||
|
<Lock size={10} class="shrink-0 opacity-40" />
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if !p.readonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||||
|
onclick={() => deleteProfile(p.name)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if profiles.length === 0}
|
||||||
|
<p class="text-base-content/40 text-xs text-center py-3">No profiles yet.</p>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── Main panel ────────────────────────────────────────────────────── -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{#if configReadonly}
|
||||||
|
<div class="alert alert-warning mb-4 py-2 px-3 text-sm gap-2">
|
||||||
|
<Lock size={14} class="shrink-0" />
|
||||||
|
Config is managed externally and is read-only.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ╌╌ Proxy panel ╌╌ -->
|
||||||
|
{#if selectedView === 'proxies'}
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="font-bold text-base flex items-center gap-2">
|
||||||
|
<Shield size={16} class="text-primary" /> Proxies
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-base-content/50 mt-0.5">
|
||||||
|
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 <code class="font-mono text-xs bg-base-300 px-1 rounded">proxychains4</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- proxychains4 missing warning -->
|
||||||
|
{#if proxies.length > 0 && !proxychainsAvailable}
|
||||||
|
<div class="alert alert-warning py-2 px-3 text-sm gap-2">
|
||||||
|
<AlertTriangle size={15} class="shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>proxychains4</strong> not found in PATH — external binary tools
|
||||||
|
(maigret, ghunt, etc.) will <strong>not</strong> be proxied.
|
||||||
|
Only HTTP-based tools are affected by the proxy config.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Proxy list -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if proxies.length === 0}
|
||||||
|
<div class="border border-dashed border-base-300 rounded-box py-8 text-center">
|
||||||
|
<Shield size={24} class="mx-auto mb-2 text-base-content/20" />
|
||||||
|
<p class="text-sm text-base-content/40">No proxies — tools connect directly.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each proxies as proxy, i}
|
||||||
|
{@const lbl = proxyLabel(proxy.url)}
|
||||||
|
<div class="flex items-center gap-3 bg-base-200 rounded-box px-4 py-2.5
|
||||||
|
border border-base-300 hover:border-base-content/20 transition-colors">
|
||||||
|
<span class="badge badge-sm {PROTO_COLOR[lbl.proto] ?? 'badge-ghost'} font-mono shrink-0">
|
||||||
|
{lbl.proto}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-sm flex-1 truncate">{lbl.host}</span>
|
||||||
|
{#if !configReadonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||||
|
onclick={() => removeProxy(i)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add row -->
|
||||||
|
{#if !configReadonly}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm font-mono flex-1
|
||||||
|
{newUrlError ? 'input-error' : ''}"
|
||||||
|
placeholder="socks5://user:pass@host:1080"
|
||||||
|
bind:value={newUrl}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && addProxy()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm gap-1 shrink-0"
|
||||||
|
onclick={addProxy}
|
||||||
|
disabled={!newUrl.trim()}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if newUrlError}
|
||||||
|
<p class="text-xs text-error flex items-center gap-1">
|
||||||
|
<AlertTriangle size={11} />{newUrlError}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-base-content/40">
|
||||||
|
Supported: <span class="font-mono">socks5://</span>,
|
||||||
|
<span class="font-mono">socks4://</span>,
|
||||||
|
<span class="font-mono">http://</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-1">
|
||||||
|
<span></span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if proxyMsg}
|
||||||
|
<span class="text-sm {proxyMsg.ok ? 'text-success' : 'text-error'}">{proxyMsg.text}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm gap-1"
|
||||||
|
onclick={saveProxies}
|
||||||
|
disabled={proxySaving}
|
||||||
|
>
|
||||||
|
{#if proxySaving}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Save size={13} />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ╌╌ Profile panel ╌╌ -->
|
||||||
|
{:else if !selectedView}
|
||||||
|
<p class="text-base-content/40 text-sm text-center py-12">Select a profile.</p>
|
||||||
|
|
||||||
|
{:else if profileLoading}
|
||||||
|
<div class="flex justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if profileDetail}
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
|
<!-- Profile header -->
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<h2 class="font-bold text-base flex items-center gap-2">
|
||||||
|
<SlidersHorizontal size={16} class="text-primary" />
|
||||||
|
{#if isReadonly}<Lock size={13} class="text-base-content/40" />{/if}
|
||||||
|
{selectedView}
|
||||||
|
</h2>
|
||||||
|
{#if isReadonly}<Badge text="read-only" size="sm" />{/if}
|
||||||
|
{#if profileDetail.active_tools?.length > 0}
|
||||||
|
<span class="text-xs text-base-content/50 ml-auto">
|
||||||
|
{profileDetail.active_tools.length} active tool{profileDetail.active_tools.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
{#if isReadonly}
|
||||||
|
{#if profileDetail.notes}
|
||||||
|
<p class="text-sm text-base-content/60 italic">{profileDetail.notes}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs uppercase tracking-widest text-base-content/40">Notes</span>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered text-sm resize-none"
|
||||||
|
placeholder="Describe this profile..."
|
||||||
|
rows="2"
|
||||||
|
bind:value={notesEdit}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Rules card -->
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body gap-4 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xs uppercase tracking-widest text-base-content/40">Rules</h3>
|
||||||
|
{#if !isReadonly && rulesMsg}
|
||||||
|
<span class="text-xs {rulesMsg.ok ? 'text-success' : 'text-error'}">{rulesMsg.text}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-semibold">
|
||||||
|
Enabled
|
||||||
|
<InfoTip tooltip="If non-empty, only these tools will run for this profile." />
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||||
|
{#if isReadonly}
|
||||||
|
{#each (profileDetail.enabled ?? []) as tn}
|
||||||
|
<span class="badge badge-outline gap-1">{tn}</span>
|
||||||
|
{/each}
|
||||||
|
{#if (profileDetail.enabled ?? []).length === 0}
|
||||||
|
<span class="text-xs text-base-content/40">All tools</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each enabledEdit as tn}
|
||||||
|
<span class="badge badge-outline gap-1">
|
||||||
|
{tn}
|
||||||
|
<button onclick={() => (enabledEdit = enabledEdit.filter((x) => x !== tn))}>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<Select
|
||||||
|
options={allToolNames.filter((n) => !enabledEdit.includes(n))}
|
||||||
|
placeholder="add tool" size="xs"
|
||||||
|
onselect={(v) => (enabledEdit = [...enabledEdit, v])}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-semibold">
|
||||||
|
Disabled
|
||||||
|
<InfoTip tooltip="These tools are always skipped, even if they appear in the enabled list." />
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-1 items-center min-h-8">
|
||||||
|
{#if isReadonly}
|
||||||
|
{#each (profileDetail.disabled ?? []) as tn}
|
||||||
|
<span class="badge badge-error gap-1">{tn}</span>
|
||||||
|
{/each}
|
||||||
|
{#if (profileDetail.disabled ?? []).length === 0}
|
||||||
|
<span class="text-xs text-base-content/40">None</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#each disabledEdit as tn}
|
||||||
|
<span class="badge badge-error gap-1">
|
||||||
|
{tn}
|
||||||
|
<button onclick={() => (disabledEdit = disabledEdit.filter((x) => x !== tn))}>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<Select
|
||||||
|
options={allToolNames.filter((n) => !disabledEdit.includes(n))}
|
||||||
|
placeholder="add tool" size="xs"
|
||||||
|
onselect={(v) => (disabledEdit = [...disabledEdit, v])}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isReadonly}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm gap-1 self-start"
|
||||||
|
onclick={saveRules}
|
||||||
|
disabled={rulesSaving}
|
||||||
|
>
|
||||||
|
{#if rulesSaving}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Save size={13} />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overrides card -->
|
||||||
|
{#if !isReadonly}
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body gap-4 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h3 class="text-xs uppercase tracking-widest text-base-content/40">Tool overrides</h3>
|
||||||
|
{#if availableForOverride.length > 0}
|
||||||
|
<Select
|
||||||
|
options={availableForOverride.map((t) => t.name)}
|
||||||
|
placeholder="add override" size="xs"
|
||||||
|
onselect={(v) => addOverrideFor(v)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if overrideToolNames.length === 0}
|
||||||
|
<p class="text-sm text-base-content/40">No overrides configured.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each overrideToolNames as toolName}
|
||||||
|
{@const tool = tools.find((t) => t.name === toolName)}
|
||||||
|
<div class="border border-base-300 rounded-box p-3 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-sm">{toolName}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if overrideMsg[toolName]}
|
||||||
|
<span class="text-xs {overrideMsg[toolName].ok ? 'text-success' : 'text-error'}">
|
||||||
|
{overrideMsg[toolName].text}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
onclick={() => deleteOverride(toolName)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tool?.config_fields?.length && overrideEdits[toolName]}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
{#each tool.config_fields as field}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs font-semibold">{field.name}</span>
|
||||||
|
<span class="badge badge-ghost badge-xs">{field.type}</span>
|
||||||
|
{#if field.required}
|
||||||
|
<span class="badge badge-error badge-xs">required</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if field.type === "bool"}
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary"
|
||||||
|
bind:checked={overrideEdits[toolName][field.name]}
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-base-content/50">
|
||||||
|
{overrideEdits[toolName][field.name] ? "enabled" : "disabled"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else if field.type === "int"}
|
||||||
|
<input type="number" step="1"
|
||||||
|
class="input input-bordered input-sm font-mono"
|
||||||
|
bind:value={overrideEdits[toolName][field.name]} />
|
||||||
|
{:else if field.type === "float"}
|
||||||
|
<input type="number" step="any"
|
||||||
|
class="input input-bordered input-sm font-mono"
|
||||||
|
bind:value={overrideEdits[toolName][field.name]} />
|
||||||
|
{:else if field.type === "enum"}
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-sm font-mono"
|
||||||
|
bind:value={overrideEdits[toolName][field.name]}
|
||||||
|
>
|
||||||
|
{#each field.options as opt}
|
||||||
|
<option value={opt}>{opt}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<input type="text"
|
||||||
|
class="input input-bordered input-sm font-mono"
|
||||||
|
bind:value={overrideEdits[toolName][field.name]} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm gap-1 self-start"
|
||||||
|
onclick={() => saveOverride(toolName)}
|
||||||
|
disabled={overrideSaving[toolName]}
|
||||||
|
>
|
||||||
|
{#if overrideSaving[toolName]}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<Save size={13} />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-base-content/40">No configurable fields.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
|
||||||
let selectedInputType = $state("all");
|
let selectedInputType = $state("all");
|
||||||
|
let nameFilter = $state("");
|
||||||
|
|
||||||
const inputTypeOptions = ["all", ...INPUT_TYPES];
|
const inputTypeOptions = ["all", ...INPUT_TYPES];
|
||||||
|
|
||||||
@@ -47,11 +48,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let visibleTools = $derived(
|
let visibleTools = $derived(
|
||||||
selectedInputType === "all"
|
toolsWithStatus.filter((t) => {
|
||||||
? toolsWithStatus
|
const matchesInput =
|
||||||
: toolsWithStatus.filter((t) =>
|
selectedInputType === "all" || t.input_types.includes(selectedInputType);
|
||||||
t.input_types.includes(selectedInputType),
|
const matchesName =
|
||||||
),
|
nameFilter.trim() === "" ||
|
||||||
|
t.name.toLowerCase().includes(nameFilter.trim().toLowerCase());
|
||||||
|
return matchesInput && matchesName;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let active = $derived(
|
let active = $derived(
|
||||||
@@ -170,6 +174,19 @@
|
|||||||
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
|
||||||
|
>Search</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-sm input-bordered w-40"
|
||||||
|
placeholder="tool name..."
|
||||||
|
bind:value={nameFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
|
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site ?? Astro.url.origin)
|
|||||||
slot="action"
|
slot="action"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
><Coffee class="size-3" /> Support me</a
|
><Coffee class="size-3" /><span class="hidden sm:inline">Support me</span></a
|
||||||
>
|
>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
<div class="m-auto max-w-5xl md:py-10 md:px-10 py-5 px-5 animate-fade-in">
|
<div class="m-auto max-w-5xl md:py-10 md:px-10 py-5 px-5 animate-fade-in">
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import Layout from "@src/layouts/Layout.astro";
|
||||||
|
import EnumeratePage from "@src/components/EnumeratePage.svelte";
|
||||||
|
---
|
||||||
|
<Layout title="Enumerate — iknowyou">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-6 flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Enumerate</h1>
|
||||||
|
<p class="text-base-content/50 text-sm mt-1">
|
||||||
|
Generate usernames and email addresses from a name, then verify their existence.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EnumeratePage client:only="svelte" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
@@ -111,7 +111,37 @@ import Layout from "@src/layouts/Layout.astro";
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-base-content/70 text-sm leading-relaxed">
|
<p class="text-base-content/70 text-sm leading-relaxed">
|
||||||
You can create custom profiles on the <a href="/profiles" class="link link-primary">Profiles page</a>.
|
You can create custom profiles on the <a href="/settings" class="link link-primary">Settings page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold flex items-center gap-2">
|
||||||
|
<span class="size-2 rounded-full bg-primary inline-block"></span>
|
||||||
|
Proxies
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/70 text-sm leading-relaxed">
|
||||||
|
IKY supports routing all tool traffic through one or more proxies. Proxies are configured
|
||||||
|
globally on the <a href="/settings" class="link link-primary">Settings page</a> and apply
|
||||||
|
to every search.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-base-content/70 text-sm leading-relaxed space-y-1 ml-2">
|
||||||
|
<li>Supported protocols: <code class="font-mono bg-base-300 px-1 rounded text-xs">socks5</code>,
|
||||||
|
<code class="font-mono bg-base-300 px-1 rounded text-xs">socks4</code>,
|
||||||
|
<code class="font-mono bg-base-300 px-1 rounded text-xs">http</code>
|
||||||
|
</li>
|
||||||
|
<li><strong>Multiple proxies</strong>: tried in random order per request — if one fails, the next is used automatically</li>
|
||||||
|
<li><strong>HTTP-based tools</strong> (API calls): use a custom Go HTTP client with fallback transport</li>
|
||||||
|
<li><strong>External binary tools</strong> (maigret, ghunt, etc.): wrapped transparently with
|
||||||
|
<code class="font-mono bg-base-300 px-1 rounded text-xs">proxychains4</code> in
|
||||||
|
<code class="font-mono bg-base-300 px-1 rounded text-xs">dynamic_chain</code> mode,
|
||||||
|
which skips dead proxies automatically
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-base-content/70 text-sm leading-relaxed">
|
||||||
|
If no proxies are configured, tools connect directly — behaviour is identical to before.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -156,6 +186,11 @@ import Layout from "@src/layouts/Layout.astro";
|
|||||||
The backend filters tools by input type and the profile's enabled/disabled rules,
|
The backend filters tools by input type and the profile's enabled/disabled rules,
|
||||||
then skips any tool with a missing required config field.
|
then skips any tool with a missing required config field.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
If proxies are configured, a proxy-aware HTTP client and a
|
||||||
|
<code class="font-mono bg-base-300 px-1 rounded text-xs">proxychains4</code>
|
||||||
|
config are prepared and injected into the search context.
|
||||||
|
</li>
|
||||||
<li>All eligible tools run in parallel against the target.</li>
|
<li>All eligible tools run in parallel against the target.</li>
|
||||||
<li>
|
<li>
|
||||||
The frontend polls for results and renders them progressively as each tool finishes.
|
The frontend polls for results and renders them progressively as each tool finishes.
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@src/layouts/Layout.astro";
|
// Redirect to /settings
|
||||||
import ProfileSettings from "@src/components/ProfileSettings.svelte";
|
return Astro.redirect("/settings", 301);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Profiles">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 pb-4">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-xl font-bold tracking-tight">Profiles</h1>
|
|
||||||
<p class="text-base-content/50 text-sm mt-1">
|
|
||||||
Manage search profiles: allowed/blocked tools and per-tool config overrides.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProfileSettings client:only="svelte" />
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import Layout from "@src/layouts/Layout.astro";
|
||||||
|
import SettingsPage from "@src/components/SettingsPage.svelte";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Settings">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 pb-8">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/" class="btn btn-ghost btn-sm gap-1">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
|
<p class="text-base-content/50 text-sm mt-1">
|
||||||
|
Proxy configuration, search profiles, and per-tool overrides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsPage client:only="svelte" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
Reference in New Issue
Block a user