Compare commits

...

11 Commits

Author SHA1 Message Date
dependabot[bot] c53d4532bf Bump astro from 6.1.2 to 6.1.10 in /front
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 6.1.2 to 6.1.10.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.1.10/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.1.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 19:06:38 +00:00
Hadi ea5c3484d9 Edit home
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-13 19:34:17 +02:00
Hadi c1a50103b9 gravatar: no output when not found
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-13 19:28:43 +02:00
Hadi 564798d8fb exemple of overwrite tool's conf
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-12 19:30:56 +02:00
Hadi 302166c87d Improve docs, responsive, add Material Icons
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-12 16:42:19 +02:00
Hadi 72c382bb07 Responsive
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 23:09:38 +02:00
Hadi d29239cbb5 add demo check
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 22:29:06 +02:00
Hadi 86988d9afe add proxy settings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 22:27:13 +02:00
Hadi fa58485712 init ghunt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 21:29:59 +02:00
Hadi a0fceb36df init enumerate
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 20:43:30 +02:00
Hadi f53380fbd9 longer timeout
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-04-11 20:42:56 +02:00
57 changed files with 2465 additions and 202 deletions
+8 -7
View File
@@ -1,6 +1,6 @@
# Tools # Tools
_12 tools registered._ _13 tools registered._
| Tool | Input types | Description | Link | | Tool | Input types | Description | Link |
|------|-------------|-------------|------| |------|-------------|-------------|------|
@@ -8,11 +8,12 @@ _12 tools registered._
| [`github-recon`](tools/github-recon.md) | `username`, `email` | GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email. | [Link](https://github.com/anotherhadi/nur-osint) | | [`github-recon`](tools/github-recon.md) | `username`, `email` | GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email. | [Link](https://github.com/anotherhadi/nur-osint) |
| [`whois`](tools/whois.md) | `domain`, `ip` | WHOIS lookup for domain registration and IP ownership information. | [Link](https://en.wikipedia.org/wiki/WHOIS) | | [`whois`](tools/whois.md) | `domain`, `ip` | WHOIS lookup for domain registration and IP ownership information. | [Link](https://en.wikipedia.org/wiki/WHOIS) |
| [`dig`](tools/dig.md) | `domain`, `ip` | DNS lookup querying A, AAAA, MX, NS, TXT, and SOA records for a domain, or reverse DNS (PTR) for an IP. | [Link](https://linux.die.net/man/1/dig) | | [`dig`](tools/dig.md) | `domain`, `ip` | DNS lookup querying A, AAAA, MX, NS, TXT, and SOA records for a domain, or reverse DNS (PTR) for an IP. | [Link](https://linux.die.net/man/1/dig) |
| [`ipinfo`](tools/ipinfo.md) | `ip` | IP geolocation via ipinfo.io returns city, region, country, coordinates, ASN/org, timezone, and hostname. | [Link](https://ipinfo.io) | | [`ipinfo`](tools/ipinfo.md) | `ip` | IP geolocation via ipinfo.io: returns city, region, country, coordinates, ASN/org, timezone, and hostname. | [Link](https://ipinfo.io) |
| [`gravatar-recon`](tools/gravatar-recon.md) | `email` | Gravatar OSINT tool. Extracts public profile data from a Gravatar account: name, bio, location, employment, social accounts, phone, and more. | [Link](https://github.com/anotherhadi/gravatar-recon) | | [`gravatar-recon`](tools/gravatar-recon.md) | `email` | Gravatar OSINT tool. Extracts public profile data from a Gravatar account: name, bio, location, employment, social accounts, phone, and more. | [Link](https://github.com/anotherhadi/gravatar-recon) |
| [`whoisfreaks`](tools/whoisfreaks.md) | `email`, `name`, `domain` | Reverse WHOIS lookup via WhoisFreaks find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records. | [Link](https://whoisfreaks.com) | | [`whoisfreaks`](tools/whoisfreaks.md) | `email`, `name`, `domain` | Reverse WHOIS lookup via WhoisFreaks: find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records. | [Link](https://whoisfreaks.com) |
| [`maigret`](tools/maigret.md) | `username` | Username OSINT across 3000+ sites. Searches social networks, forums, and online platforms for an account matching the target username. | [Link](https://github.com/soxoj/maigret) | | [`maigret`](tools/maigret.md) | `username` | Username OSINT across 3000+ sites. Searches social networks, forums, and online platforms for an account matching the target username. | [Link](https://github.com/soxoj/maigret) |
| [`leakcheck`](tools/leakcheck.md) | `email`, `username`, `phone` | Data breach lookup via LeakCheck.io — searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches. | [Link](https://leakcheck.io) | | [`ghunt`](tools/ghunt.md) | `email` | Google account OSINT via GHunt. Extracts profile info, linked services, and activity from a Google email address. | [Link](https://github.com/mxrch/GHunt) |
| [`crt.sh`](tools/crt.sh.md) | `domain` | SSL/TLS certificate transparency log search via crt.sh — enumerates subdomains and certificates issued for a domain. | [Link](https://crt.sh) | | [`leakcheck`](tools/leakcheck.md) | `email`, `username`, `phone` | Data breach lookup via LeakCheck.io: searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches. | [Link](https://leakcheck.io) |
| [`breachdirectory`](tools/breachdirectory.md) | `email`, `username` | Data breach search via BreachDirectory — checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes. | [Link](https://breachdirectory.org) | | [`crt.sh`](tools/crt.sh.md) | `domain` | SSL/TLS certificate transparency log search via crt.sh: enumerates subdomains and certificates issued for a domain. | [Link](https://crt.sh) |
| [`wappalyzer`](tools/wappalyzer.md) | `domain` | Web technology fingerprinting via wappalyzergo — detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain. | [Link](https://github.com/projectdiscovery/wappalyzergo) | | [`breachdirectory`](tools/breachdirectory.md) | `email`, `username` | Data breach search via BreachDirectory: checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes. | [Link](https://breachdirectory.org) |
| [`wappalyzer`](tools/wappalyzer.md) | `domain` | Web technology fingerprinting via wappalyzergo: detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain. | [Link](https://github.com/projectdiscovery/wappalyzergo) |
+2 -2
View File
@@ -1,6 +1,6 @@
# `breachdirectory` # `breachdirectory`
Data breach search via BreachDirectory checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes. Data breach search via BreachDirectory: checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes.
**Source / documentation:** [https://breachdirectory.org](https://breachdirectory.org) **Source / documentation:** [https://breachdirectory.org](https://breachdirectory.org)
@@ -15,7 +15,7 @@ Configure globally via the Tools page or override per profile.
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
|-------|------|:--------:|---------|-------------| |-------|------|:--------:|---------|-------------|
| `api_key` | `string` | **yes** | - | RapidAPI key for BreachDirectory (required get one at rapidapi.com/rohan-patra/api/breachdirectory) | | `api_key` | `string` | **yes** | - | RapidAPI key for BreachDirectory (required, get one at rapidapi.com/rohan-patra/api/breachdirectory) |
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# `crt.sh` # `crt.sh`
SSL/TLS certificate transparency log search via crt.sh enumerates subdomains and certificates issued for a domain. SSL/TLS certificate transparency log search via crt.sh: enumerates subdomains and certificates issued for a domain.
**Source / documentation:** [https://crt.sh](https://crt.sh) **Source / documentation:** [https://crt.sh](https://crt.sh)
+27
View File
@@ -0,0 +1,27 @@
# `ghunt`
Google account OSINT via GHunt. Extracts profile info, linked services, and activity from a Google email address.
**Source / documentation:** [https://github.com/mxrch/GHunt](https://github.com/mxrch/GHunt)
## Input types
- `email`
## External dependencies
The following binaries must be installed and available in `$PATH`:
- `ghunt`
## Configuration
Configure globally via the Tools page or override per profile.
| Field | Type | Required | Default | Description |
|-------|------|:--------:|---------|-------------|
| `creds` | `string` | **yes** | - | 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. |
---
[← Back to tools index](../tools.md)
+2 -2
View File
@@ -1,6 +1,6 @@
# `ipinfo` # `ipinfo`
IP geolocation via ipinfo.io returns city, region, country, coordinates, ASN/org, timezone, and hostname. IP geolocation via ipinfo.io: returns city, region, country, coordinates, ASN/org, timezone, and hostname.
**Source / documentation:** [https://ipinfo.io](https://ipinfo.io) **Source / documentation:** [https://ipinfo.io](https://ipinfo.io)
@@ -14,7 +14,7 @@ Configure globally via the Tools page or override per profile.
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
|-------|------|:--------:|---------|-------------| |-------|------|:--------:|---------|-------------|
| `token` | `string` | - | - | ipinfo.io API token (optional free tier allows 50k req/month without one) | | `token` | `string` | - | - | ipinfo.io API token (optional, free tier allows 50k req/month without one) |
--- ---
+2 -2
View File
@@ -1,6 +1,6 @@
# `leakcheck` # `leakcheck`
Data breach lookup via LeakCheck.io searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches. Data breach lookup via LeakCheck.io: searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches.
**Source / documentation:** [https://leakcheck.io](https://leakcheck.io) **Source / documentation:** [https://leakcheck.io](https://leakcheck.io)
@@ -16,7 +16,7 @@ Configure globally via the Tools page or override per profile.
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
|-------|------|:--------:|---------|-------------| |-------|------|:--------:|---------|-------------|
| `api_key` | `string` | **yes** | - | LeakCheck API key (required get one at leakcheck.io) | | `api_key` | `string` | **yes** | - | LeakCheck API key (required, get one at leakcheck.io) |
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# `wappalyzer` # `wappalyzer`
Web technology fingerprinting via wappalyzergo detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain. Web technology fingerprinting via wappalyzergo: detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain.
**Source / documentation:** [https://github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo) **Source / documentation:** [https://github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo)
+2 -2
View File
@@ -1,6 +1,6 @@
# `whoisfreaks` # `whoisfreaks`
Reverse WHOIS lookup via WhoisFreaks find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records. Reverse WHOIS lookup via WhoisFreaks: find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records.
**Source / documentation:** [https://whoisfreaks.com](https://whoisfreaks.com) **Source / documentation:** [https://whoisfreaks.com](https://whoisfreaks.com)
@@ -16,7 +16,7 @@ Configure globally via the Tools page or override per profile.
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
|-------|------|:--------:|---------|-------------| |-------|------|:--------:|---------|-------------|
| `api_key` | `string` | **yes** | - | WhoisFreaks API key (required free account at whoisfreaks.com) | | `api_key` | `string` | **yes** | - | WhoisFreaks API key (required, free account at whoisfreaks.com) |
--- ---
+1
View File
@@ -1,2 +1,3 @@
.claude/ .claude/
CLAUDE.md
todolist.md todolist.md
+11 -1
View File
@@ -30,6 +30,7 @@ Designed for security researchers, penetration testers, and OSINT investigators
- **Parallel execution**: all tools run simultaneously; results stream in as they arrive - **Parallel execution**: all tools run simultaneously; results stream in as they arrive
- **Profile system**: create named profiles to enable/disable specific tools or override their config per investigation type (quick recon vs. thorough sweep) - **Profile system**: create named profiles to enable/disable specific tools or override their config per investigation type (quick recon vs. thorough sweep)
- **Per-tool configuration**: set API keys, rate limits, and options globally or per profile - **Per-tool configuration**: set API keys, rate limits, and options globally or per profile
- **Proxy support**: route all tool traffic through SOCKS5/SOCKS4/HTTP proxies, with automatic failover across multiple proxies; external binary tools are transparently wrapped with proxychains4
- **Tool availability checks**: tools that depend on an external binary report their status; the interface shows which tools are ready, which need config, and which are unavailable - **Tool availability checks**: tools that depend on an external binary report their status; the interface shows which tools are ready, which need config, and which are unavailable
- **Search history**: completed searches are kept in memory; results can be reviewed without re-running - **Search history**: completed searches are kept in memory; results can be reviewed without re-running
- **Extensible architecture**: adding a new tool is a single Go file implementing one interface, registered in one line - **Extensible architecture**: adding a new tool is a single Go file implementing one interface, registered in one line
@@ -110,6 +111,7 @@ Create `/etc/iky/config.yaml` (or any path, then point `IKY_CONFIG` to it):
tools: tools:
github-recon: github-recon:
token: ghp_yourtoken token: ghp_yourtoken
deepscan: true
whoisfreaks: whoisfreaks:
api_key: yourkey api_key: yourkey
ipinfo: ipinfo:
@@ -117,16 +119,24 @@ tools:
breachdirectory: breachdirectory:
api_key: yourkey api_key: yourkey
proxies:
- url: socks5://user:pass@127.0.0.1:9050
- url: http://proxy.example.com:8080
profiles: profiles:
quick: quick:
enabled: enabled:
- whois - whois
- dig - dig
- crt.sh - crt.sh
- github-recon
disabled: [] disabled: []
tools:
github-recon:
deepscan: false # Overwrite
``` ```
Only include the tools you want to configure everything else falls back to defaults. Only include the tools you want to configure; everything else falls back to defaults.
</details> </details>
+1 -1
View File
@@ -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,
} }
+5
View File
@@ -9,7 +9,12 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type ProxyEntry struct {
URL string `yaml:"url" json:"url"`
}
type Config struct { type Config struct {
Proxies []ProxyEntry `yaml:"proxies,omitempty" json:"proxies,omitempty"`
Tools map[string]yaml.Node `yaml:"tools" json:"tools"` Tools map[string]yaml.Node `yaml:"tools" json:"tools"`
Profiles map[string]Profile `yaml:"profiles" json:"profiles"` Profiles map[string]Profile `yaml:"profiles" json:"profiles"`
} }
+41
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os/exec"
"sort" "sort"
"sync" "sync"
@@ -40,9 +41,16 @@ 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,
"proxies": proxies,
"proxychains_available": pcErr == nil,
"readonly": h.demo || config.IsReadonly(h.configPath), "readonly": h.demo || config.IsReadonly(h.configPath),
"demo": h.demo, "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 == '-') {
+296
View File
@@ -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(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 (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})
}
+4
View File
@@ -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())
+9
View File
@@ -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(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)
+154
View File
@@ -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"
}
+2
View File
@@ -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,
+24 -2
View File
@@ -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)
+5 -4
View File
@@ -8,18 +8,19 @@ 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"
) )
const ( const (
name = "breachdirectory" name = "breachdirectory"
description = "Data breach search via BreachDirectory checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes." description = "Data breach search via BreachDirectory: checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes."
link = "https://breachdirectory.org" link = "https://breachdirectory.org"
icon = "" icon = "mdi:shield-alert"
) )
type Config struct { type Config struct {
APIKey string `yaml:"api_key" iky:"desc=RapidAPI key for BreachDirectory (required get one at rapidapi.com/rohan-patra/api/breachdirectory);required=true"` APIKey string `yaml:"api_key" iky:"desc=RapidAPI key for BreachDirectory (required, get one at rapidapi.com/rohan-patra/api/breachdirectory);required=true"`
} }
type Runner struct { type Runner struct {
@@ -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"}
+4 -3
View File
@@ -10,14 +10,15 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
const ( const (
name = "crt.sh" name = "crt.sh"
description = "SSL/TLS certificate transparency log search via crt.sh enumerates subdomains and certificates issued for a domain." description = "SSL/TLS certificate transparency log search via crt.sh: enumerates subdomains and certificates issued for a domain."
link = "https://crt.sh" link = "https://crt.sh"
icon = "" icon = "mdi:certificate-outline"
) )
type Runner struct{} type Runner struct{}
@@ -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"}
+8 -2
View File
@@ -13,7 +13,7 @@ const (
name = "dig" name = "dig"
description = "DNS lookup querying A, AAAA, MX, NS, TXT, and SOA records for a domain, or reverse DNS (PTR) for an IP." description = "DNS lookup querying A, AAAA, MX, NS, TXT, and SOA records for a domain, or reverse DNS (PTR) for an IP."
link = "https://linux.die.net/man/1/dig" link = "https://linux.die.net/man/1/dig"
icon = "" icon = "mdi:dns"
) )
var recordTypes = []string{"A", "AAAA", "MX", "NS", "TXT", "SOA"} var recordTypes = []string{"A", "AAAA", "MX", "NS", "TXT", "SOA"}
@@ -67,7 +67,13 @@ func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputTy
break break
} }
cmd := exec.CommandContext(ctx, "dig", target, rtype, "+noall", "+answer") cmd := exec.CommandContext(ctx, "dig", target, rtype, "+noall", "+answer")
output, _ := cmd.Output() output, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
break
}
continue
}
result := strings.TrimSpace(string(output)) result := strings.TrimSpace(string(output))
if result == "" { if result == "" {
continue continue
+133
View File
@@ -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 = "si: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
}
+1 -1
View File
@@ -12,7 +12,7 @@ const (
name = "github-recon" name = "github-recon"
description = "GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email." description = "GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email."
link = "https://github.com/anotherhadi/nur-osint" link = "https://github.com/anotherhadi/nur-osint"
icon = "github" icon = "si:github"
) )
type Config struct { type Config struct {
+4 -1
View File
@@ -3,6 +3,7 @@ package gravatarrecon
import ( import (
"context" "context"
"os/exec" "os/exec"
"strings"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -11,7 +12,7 @@ const (
name = "gravatar-recon" name = "gravatar-recon"
description = "Gravatar OSINT tool. Extracts public profile data from a Gravatar account: name, bio, location, employment, social accounts, phone, and more." description = "Gravatar OSINT tool. Extracts public profile data from a Gravatar account: name, bio, location, employment, social accounts, phone, and more."
link = "https://github.com/anotherhadi/gravatar-recon" link = "https://github.com/anotherhadi/gravatar-recon"
icon = "" icon = "si:gravatar"
) )
type Runner struct{} type Runner struct{}
@@ -46,9 +47,11 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
if err != nil && ctx.Err() != nil { if err != nil && 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"}
} else if output != "" { } else if output != "" {
if !strings.Contains(output, "status 404") {
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output} out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
count = 1 count = 1
} }
}
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count} out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
out <- tools.Event{Tool: name, Type: tools.EventTypeDone} out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
return nil return nil
+5 -4
View File
@@ -7,18 +7,19 @@ 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"
) )
const ( const (
name = "ipinfo" name = "ipinfo"
description = "IP geolocation via ipinfo.io returns city, region, country, coordinates, ASN/org, timezone, and hostname." description = "IP geolocation via ipinfo.io: returns city, region, country, coordinates, ASN/org, timezone, and hostname."
link = "https://ipinfo.io" link = "https://ipinfo.io"
icon = "" icon = "mdi:ip-network"
) )
type Config struct { type Config struct {
Token string `yaml:"token" iky:"desc=ipinfo.io API token (optional free tier allows 50k req/month without one);required=false"` Token string `yaml:"token" iky:"desc=ipinfo.io API token (optional, free tier allows 50k req/month without one);required=false"`
} }
type Runner struct { type Runner struct {
@@ -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"}
+5 -4
View File
@@ -8,18 +8,19 @@ 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"
) )
const ( const (
name = "leakcheck" name = "leakcheck"
description = "Data breach lookup via LeakCheck.io searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches." description = "Data breach lookup via LeakCheck.io: searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches."
link = "https://leakcheck.io" link = "https://leakcheck.io"
icon = "" icon = "mdi:database-alert"
) )
type Config struct { type Config struct {
APIKey string `yaml:"api_key" iky:"desc=LeakCheck API key (required get one at leakcheck.io);required=true"` APIKey string `yaml:"api_key" iky:"desc=LeakCheck API key (required, get one at leakcheck.io);required=true"`
} }
type Runner struct { type Runner struct {
@@ -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"}
+2 -2
View File
@@ -14,7 +14,7 @@ const (
name = "maigret" name = "maigret"
description = "Username OSINT across 3000+ sites. Searches social networks, forums, and online platforms for an account matching the target username." description = "Username OSINT across 3000+ sites. Searches social networks, forums, and online platforms for an account matching the target username."
link = "https://github.com/soxoj/maigret" link = "https://github.com/soxoj/maigret"
icon = "" icon = "mdi:radar"
) )
var accountsRe = regexp.MustCompile(`returned (\d+) accounts`) var accountsRe = regexp.MustCompile(`returned (\d+) accounts`)
@@ -68,7 +68,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
cmd := exec.CommandContext(ctx, "maigret", args...) cmd := exec.CommandContext(ctx, "maigret", args...)
output, err := tools.RunWithPTY(ctx, cmd) output, err := tools.RunWithPTY(ctx, cmd)
// Crop at Python traceback (NixOS read-only store error results are unaffected) // Crop at Python traceback (NixOS read-only store error, results are unaffected)
if idx := strings.Index(output, "Traceback (most recent call last)"); idx != -1 { if idx := strings.Index(output, "Traceback (most recent call last)"); idx != -1 {
output = strings.TrimSpace(output[:idx]) output = strings.TrimSpace(output[:idx])
} }
+8
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@ type EventType string
const ( const (
EventTypeOutput EventType = "output" // raw ANSI text, payload is a plain string EventTypeOutput EventType = "output" // raw ANSI text, payload is a plain string
EventTypeError EventType = "error" EventTypeError EventType = "error"
EventTypeCount EventType = "count" // payload is int, additive emit once or multiple times from Run EventTypeCount EventType = "count" // payload is int, additive; emit once or multiple times from Run
EventTypeDone EventType = "done" EventTypeDone EventType = "done"
) )
+1 -1
View File
@@ -12,7 +12,7 @@ const (
name = "user-scanner" name = "user-scanner"
description = "🕵️‍♂️ (2-in-1) Email & Username OSINT suite. Analyzes 195+ scan vectors (95+ email / 100+ username) for security research, investigations, and digital footprinting." description = "🕵️‍♂️ (2-in-1) Email & Username OSINT suite. Analyzes 195+ scan vectors (95+ email / 100+ username) for security research, investigations, and digital footprinting."
link = "https://github.com/kaifcodec/user-scanner" link = "https://github.com/kaifcodec/user-scanner"
icon = "" icon = "mdi:account-search"
) )
type Config struct { type Config struct {
+4 -3
View File
@@ -10,14 +10,15 @@ 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"
) )
const ( const (
name = "wappalyzer" name = "wappalyzer"
description = "Web technology fingerprinting via wappalyzergo detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain." description = "Web technology fingerprinting via wappalyzergo: detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain."
link = "https://github.com/projectdiscovery/wappalyzergo" link = "https://github.com/projectdiscovery/wappalyzergo"
icon = "wappalyzer" icon = "si:wappalyzer"
) )
type Runner struct { type Runner struct {
@@ -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)
+1 -1
View File
@@ -12,7 +12,7 @@ const (
name = "whois" name = "whois"
description = "WHOIS lookup for domain registration and IP ownership information." description = "WHOIS lookup for domain registration and IP ownership information."
link = "https://en.wikipedia.org/wiki/WHOIS" link = "https://en.wikipedia.org/wiki/WHOIS"
icon = "" icon = "mdi:card-search"
) )
type Runner struct{} type Runner struct{}
+7 -6
View File
@@ -9,19 +9,20 @@ 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"
) )
const ( const (
name = "whoisfreaks" name = "whoisfreaks"
description = "Reverse WHOIS lookup via WhoisFreaks find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records." description = "Reverse WHOIS lookup via WhoisFreaks: find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records."
link = "https://whoisfreaks.com" link = "https://whoisfreaks.com"
icon = "" icon = "mdi:database-search"
) )
type Config struct { type Config struct {
APIKey string `yaml:"api_key" iky:"desc=WhoisFreaks API key (required free account at whoisfreaks.com);required=true"` APIKey string `yaml:"api_key" iky:"desc=WhoisFreaks API key (required, free account at whoisfreaks.com);required=true"`
} }
type Runner struct { type Runner struct {
@@ -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
View File
@@ -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": {
+3
View File
@@ -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 {
+5 -1
View File
@@ -30,7 +30,11 @@ export default defineConfig({
], ],
server: { server: {
proxy: { proxy: {
"/api": "http://localhost:8080", "/api": {
target: "http://localhost:8080",
timeout: 120000,
proxyTimeout: 120000,
},
}, },
}, },
}, },
+1 -1
View File
@@ -15,7 +15,7 @@
"@lucide/svelte": "^1.7.0", "@lucide/svelte": "^1.7.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"ansi_up": "^6.0.6", "ansi_up": "^6.0.6",
"astro": "6.1.2", "astro": "6.1.10",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"remark-github-blockquote-alert": "^2.1.0", "remark-github-blockquote-alert": "^2.1.0",
+5 -3
View File
@@ -60,17 +60,19 @@
{#each filtered as sheet} {#each filtered as sheet}
<a <a
href={`/cheatsheets/${sheet.id}`} href={`/cheatsheets/${sheet.id}`}
class="card bg-base-200 hover:bg-base-300 transition-colors p-4 flex flex-row items-center gap-4" class="card bg-base-200 hover:bg-base-300 transition-colors p-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4"
> >
<div class="flex items-center gap-4 flex-1 min-w-0">
<div class="size-2 rounded-full bg-primary shrink-0"></div> <div class="size-2 rounded-full bg-primary shrink-0"></div>
<div class="flex-1 min-w-0"> <div class="min-w-0">
<div class="font-semibold text-sm">{sheet.title}</div> <div class="font-semibold text-sm">{sheet.title}</div>
{#if sheet.description} {#if sheet.description}
<div class="text-base-content/50 text-xs mt-0.5">{sheet.description}</div> <div class="text-base-content/50 text-xs mt-0.5">{sheet.description}</div>
{/if} {/if}
</div> </div>
</div>
{#if sheet.tags && sheet.tags.length > 0} {#if sheet.tags && sheet.tags.length > 0}
<div class="flex gap-1 shrink-0"> <div class="flex flex-wrap gap-1 sm:justify-end">
{#each sheet.tags as tag} {#each sheet.tags as tag}
<span class="badge badge-xs badge-ghost">{tag}</span> <span class="badge badge-xs badge-ghost">{tag}</span>
{/each} {/each}
+1 -1
View File
@@ -19,6 +19,6 @@
{#if demo} {#if demo}
<div class="w-full bg-warning/15 border-b border-warning/30 py-1.5 px-4 flex items-center justify-center gap-2 text-xs text-warning"> <div class="w-full bg-warning/15 border-b border-warning/30 py-1.5 px-4 flex items-center justify-center gap-2 text-xs text-warning">
<FlaskConical size={13} class="shrink-0" /> <FlaskConical size={13} class="shrink-0" />
<span>Demo mode searches and configuration changes are disabled</span> <span>Demo mode: searches and configuration changes are disabled</span>
</div> </div>
{/if} {/if}
+448
View File
@@ -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>
+15 -3
View File
@@ -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>
+48 -15
View File
@@ -9,9 +9,19 @@
BookOpen, BookOpen,
Bug, Bug,
ClipboardList, ClipboardList,
ListFilter,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
let mobileMenuOpen = $state(false);
function onDocumentClick(e: MouseEvent) {
if (!mobileMenuOpen) return;
if (!(e.target as HTMLElement).closest("[data-mobile-nav]")) {
mobileMenuOpen = false;
}
}
let { let {
action, action,
}: { }: {
@@ -22,7 +32,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",
@@ -44,17 +55,20 @@
]; ];
</script> </script>
<svelte:document onclick={onDocumentClick} />
<div class="bg-base-200"> <div class="bg-base-200">
<div class="navbar max-w-5xl m-auto"> <div class="navbar max-w-5xl m-auto">
<div class="navbar-start"> <div class="navbar-start">
<div class="dropdown"> <div class="relative" data-mobile-nav>
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden"> <button
<Menu size={20} /> class="btn btn-ghost lg:hidden"
</div> onclick={(e) => { e.stopPropagation(); mobileMenuOpen = !mobileMenuOpen; }}
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-300 rounded-box z-50 mt-3 w-52 p-2"
> >
<Menu size={20} />
</button>
{#if mobileMenuOpen}
<ul class="menu menu-sm absolute bg-base-300 rounded-box z-50 mt-1 w-52 p-2">
{#each navLinks as link} {#each navLinks as link}
<li> <li>
{#if link.children} {#if link.children}
@@ -62,7 +76,11 @@
<ul class="p-2"> <ul class="p-2">
{#each link.children as sublink} {#each link.children as sublink}
<li> <li>
<a href={sublink.href} class="flex items-center gap-2"> <a
href={sublink.href}
class="flex items-center gap-2"
onclick={() => (mobileMenuOpen = false)}
>
{#if sublink.icon} {#if sublink.icon}
{@const Icon = sublink.icon} {@const Icon = sublink.icon}
<Icon size={12} /> <Icon size={12} />
@@ -73,7 +91,11 @@
{/each} {/each}
</ul> </ul>
{:else} {:else}
<a href={link.href} class="flex items-center gap-2"> <a
href={link.href}
class="flex items-center gap-2"
onclick={() => (mobileMenuOpen = false)}
>
{#if link.icon} {#if link.icon}
{@const Icon = link.icon} {@const Icon = link.icon}
<Icon size={12} /> <Icon size={12} />
@@ -84,18 +106,29 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{/if}
</div> </div>
<!-- Logo on medium/large screens -->
<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 centered on 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 on large screens -->
<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}
+201
View File
@@ -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>
+8 -8
View File
@@ -3,12 +3,12 @@
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),
phone: (_raw, v) => /^\+\d{1,4} \d{4,}$/.test(v), phone: (_raw, v) => /^\+\d{1,4} \d{4,}$/.test(v),
ip: (_raw, v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v), ip: (_raw, v) => /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)){3}$/.test(v) || (/^[0-9a-fA-F:]{3,39}$/.test(v) && v.includes(':')),
domain: (raw, v) => /^https?:\/\//.test(raw) || /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v), domain: (raw, v) => /^https?:\/\//.test(raw) || /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(v),
name: (_raw, v) => /^[a-zA--ÿ'-]+(?: [a-zA-ZÀ-ÿ'-]+){1,2}$/.test(v), name: (_raw, v) => /^[a-zA--ÿ'-]+(?: [a-zA-ZÀ-ÿ'-]+){1,2}$/.test(v),
}; };
@@ -16,13 +16,13 @@
const VALIDATORS = { const VALIDATORS = {
email: { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: "Invalid email address" }, email: { test: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), msg: "Invalid email address" },
username: { test: (v) => /^[a-zA-Z0-9._-]+$/.test(v), msg: "Username may only contain a-z, 0-9, . - _" }, username: { test: (v) => /^[a-zA-Z0-9._-]+$/.test(v), msg: "Username may only contain a-z, 0-9, . - _" },
phone: { test: (v) => /^\+\d{1,4} \d{4,}$/.test(v), msg: "Format: +INDICATIF NUMERO (ex: +33 0612345678)" }, phone: { test: (v) => /^\+\d{1,4} \d{4,}$/.test(v), msg: "Format: +COUNTRYCODE NUMBER (e.g. +1 2025550147)" },
ip: { test: (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]{3,39}$/.test(v), msg: "Invalid IP address" }, ip: { test: (v) => /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)){3}$/.test(v) || (/^[0-9a-fA-F:]{3,39}$/.test(v) && v.includes(':')), msg: "Invalid IP address" },
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,8 +164,9 @@
onselect={(v) => { profile = v; }} onselect={(v) => { profile = v; }}
/> />
</div> </div>
</div>
<button <button
class="btn btn-primary btn-sm flex-1 gap-1" class="btn btn-primary btn-sm w-full gap-1"
onclick={submit} onclick={submit}
disabled={demo || loading || !target.trim()} disabled={demo || loading || !target.trim()}
> >
@@ -177,7 +178,6 @@
Search Search
</button> </button>
</div> </div>
</div>
<!-- Desktop layout --> <!-- Desktop layout -->
<div <div
+1 -1
View File
@@ -230,7 +230,7 @@
</details> </details>
{#if demo} {#if demo}
<p class="text-xs text-base-content/40 italic">Results shown are not exhaustive demo mode only displays a subset of what the tools can find.</p> <p class="text-xs text-base-content/40 italic">Results shown are not exhaustive: demo mode only displays a subset of what the tools can find.</p>
{/if} {/if}
</div> </div>
+17 -11
View File
@@ -28,43 +28,49 @@
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">
<!-- Icon -->
<div class="text-base-content/40 flex items-center justify-center shrink-0 mt-0.5">
{#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon} {#each [INPUT_TYPE_ICON[s.input_type] ?? FileText] as Icon}
<Icon size={16} /> <Icon size={16} />
{/each} {/each}
</div> </div>
<div class="flex flex-col min-w-0 flex-1">
<span class="font-mono font-semibold truncate">{s.target}</span> <!-- Target + meta -->
<div class="flex flex-col min-w-0 flex-1 gap-0.5">
<span class="font-mono font-semibold truncate leading-snug">{s.target}</span>
<div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50"> <div class="flex items-center gap-1.5 flex-wrap text-xs text-base-content/50">
<span>{s.input_type}</span> <span>{s.input_type}</span>
{#if s.profile} {#if s.profile}
<span class="badge badge-outline badge-xs font-semibold">{s.profile}</span> <span class="badge badge-outline badge-xs font-semibold">{s.profile}</span>
{/if} {/if}
<span>· {fmtDate(s.started_at)}</span> <span>· {fmtDate(s.started_at)}</span>
</div>
</div>
{#if s.status !== "running"} {#if s.status !== "running"}
{@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)} {@const total = (s.planned_tools ?? []).reduce((sum, t) => sum + (t.result_count ?? 0), 0)}
{#if total > 0} {#if total > 0}
<span class="text-xs font-mono text-base-content/50 shrink-0">{total} result{total !== 1 ? "s" : ""}</span> <span>· {total} result{total !== 1 ? "s" : ""}</span>
{/if} {/if}
{/if} {/if}
</div>
</div>
<span class="badge {STATUS_BADGE[s.status] ?? 'badge-ghost'} badge-sm shrink-0"> <!-- 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"} {#if s.status === "running"}
<span class="loading loading-ring loading-xs mr-1"></span> <span class="loading loading-ring loading-xs mr-1"></span>
{/if} {/if}
{s.status} {s.status}
</span> </span>
<button <button
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error shrink-0" class="btn btn-ghost btn-xs text-base-content/30 hover:text-error"
onclick={(e) => { e.preventDefault(); onDelete(s.id); }} onclick={(e) => { e.preventDefault(); onDelete(s.id); }}
title="Delete" title="Delete"
><X size={14} /></button> ><X size={14} /></button>
</div> </div>
</div>
</div>
</a> </a>
{/each} {/each}
</div> </div>
+749
View File
@@ -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}
+22 -5
View File
@@ -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"
+17 -14
View File
@@ -1,13 +1,26 @@
<script lang="ts"> <script lang="ts">
const { iconName = "", size=16 }: { iconName: string , size: number} = $props(); const { iconName = "", size = 16 }: { iconName: string; size: number } = $props();
const genericFallbackUrl = "/Wrench.svg"; const genericFallbackUrl = "/Wrench.svg";
function resolveUrl(name: string): string {
if (name.startsWith("mdi:")) {
return `https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${name.slice(4)}.svg`;
}
if (name.startsWith("sh:")) {
return `https://cdn.jsdelivr.net/gh/selfhst/icons/svg/${name.slice(3)}.svg`;
}
// si: prefix or no prefix, default to Simple Icons
const slug = name.startsWith("si:") ? name.slice(3) : name;
return `https://cdn.simpleicons.org/${slug}`;
}
const src = $derived(iconName ? resolveUrl(iconName) : genericFallbackUrl);
</script> </script>
{#if iconName}
<img <img
src="https://cdn.simpleicons.org/{iconName}" {src}
alt={iconName + " icon"} alt={iconName ? iconName + " icon" : "Tool icon"}
class="opacity-50" class="opacity-50"
width={size} width={size}
height={size} height={size}
@@ -17,13 +30,3 @@
target.src = genericFallbackUrl; target.src = genericFallbackUrl;
}} }}
/> />
{:else}
<img
src={genericFallbackUrl}
alt={"Tool icon"}
class="opacity-50"
width={size}
height={size}
style="filter: brightness(0) invert(1);"
/>
{/if}
@@ -97,7 +97,7 @@ Once you have a **name**, an **email**, or a **unique username**, its time to
If you want to move from manual investigation to automated intelligence, check out [Github-Recon](https://github.com/anotherhadi/github-recon). If you want to move from manual investigation to automated intelligence, check out [Github-Recon](https://github.com/anotherhadi/github-recon).
Written in Go, this powerful CLI tool aggregates public OSINT data by automating the techniques mentioned above and more. Whether you start with a username or a single email address, it can retrieve SSH/GPG keys, enumerate social accounts, and find "close friends" based on interactions. Written in Go, this powerful CLI tool aggregates public OSINT data by automating the techniques mentioned above and more. Whether you start with a username or a single email address, it can retrieve SSH/GPG keys, enumerate social accounts, and find "close friends" based on interactions.
Its standout features include a **Deep Scan** mode-which clones repositories to perform regex searches and TruffleHog secret detectionand an automated **Email Spoofing** engine that instantly identifies the account linked to any primary email address. Its standout features include a **Deep Scan** mode (clones repositories for regex searches and TruffleHog secret detection) and an automated **Email Spoofing** engine that identifies the account linked to any primary email address.
<a href="https://github.com/anotherhadi/github-recon" class="link-card" target="_blank"> <a href="https://github.com/anotherhadi/github-recon" class="link-card" target="_blank">
<span> <span>
+1 -1
View File
@@ -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">
+16
View File
@@ -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>
+36 -1
View File
@@ -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.
</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.
+2 -19
View File
@@ -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>
+22
View File
@@ -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>
+1
View File
@@ -75,6 +75,7 @@ in {
IKY_FRONT_DIR = "${cfg.package}/share/iky/frontend"; IKY_FRONT_DIR = "${cfg.package}/share/iky/frontend";
IKY_SEARCH_TTL = cfg.searchTTL; IKY_SEARCH_TTL = cfg.searchTTL;
IKY_CLEANUP_INTERVAL = cfg.cleanupInterval; IKY_CLEANUP_INTERVAL = cfg.cleanupInterval;
HOME = "%S/iky";
}; };
serviceConfig = { serviceConfig = {