add proxy settings

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-04-11 22:27:13 +02:00
parent fa58485712
commit 86988d9afe
20 changed files with 1276 additions and 38 deletions

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"`
} }

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 == '-') {

View File

@@ -55,6 +55,7 @@ func NewRouter(
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)

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"
}

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)

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -80,7 +81,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
req.Header.Set("X-RapidAPI-Host", "breachdirectory.p.rapidapi.com") req.Header.Set("X-RapidAPI-Host", "breachdirectory.p.rapidapi.com")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := proxy.ClientFromContext(ctx).Do(req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}

View File

@@ -10,6 +10,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -61,7 +62,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; crtsh-scanner/1.0)") req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; crtsh-scanner/1.0)")
resp, err := http.DefaultClient.Do(req) resp, err := proxy.ClientFromContext(ctx).Do(req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}

View File

@@ -94,7 +94,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
parsed[i] = ansiRe.ReplaceAllString(l, "") parsed[i] = ansiRe.ReplaceAllString(l, "")
} }
start := 0 start := -1
for i, l := range parsed { for i, l := range parsed {
if strings.Contains(l, "[+] Authenticated !") { if strings.Contains(l, "[+] Authenticated !") {
start = i + 1 start = i + 1
@@ -102,6 +102,14 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
} }
} }
if start == -1 {
// Banner printed but auth line never appeared — bad/expired credentials.
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "GHunt authentication failed — credentials may be missing or expired (run 'ghunt login' and update your creds in Settings)"}
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
return nil
}
end := len(lines) end := len(lines)
for i := start; i < len(parsed); i++ { for i := start; i < len(parsed); i++ {
if strings.Contains(parsed[i], "Traceback (most recent call last)") { if strings.Contains(parsed[i], "Traceback (most recent call last)") {
@@ -117,6 +125,8 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
} else if output != "" { } else if output != "" {
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output} out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 1} 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} out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
return nil return nil

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -76,7 +77,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := proxy.ClientFromContext(ctx).Do(req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -90,7 +91,7 @@ func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputTy
req.Header.Set("X-API-Key", r.cfg.APIKey) req.Header.Set("X-API-Key", r.cfg.APIKey)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := proxy.ClientFromContext(ctx).Do(req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}

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

View File

@@ -10,6 +10,7 @@ import (
wappalyzergo "github.com/projectdiscovery/wappalyzergo" wappalyzergo "github.com/projectdiscovery/wappalyzergo"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
) )
@@ -55,7 +56,7 @@ func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)") req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)")
resp, err = http.DefaultClient.Do(req) resp, err = proxy.ClientFromContext(ctx).Do(req)
if err == nil { if err == nil {
defer resp.Body.Close() defer resp.Body.Close()
body, err = io.ReadAll(resp.Body) body, err = io.ReadAll(resp.Body)

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/anotherhadi/iknowyou/internal/proxy"
"github.com/anotherhadi/iknowyou/internal/tools" "github.com/anotherhadi/iknowyou/internal/tools"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -101,9 +102,9 @@ func prettyResult(r gjson.Result, depth int) string {
return sb.String() return sb.String()
} }
func doRequest(ctx context.Context, req *http.Request) ([]byte, *http.Response, error) { func doRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, *http.Response, error) {
for { for {
resp, err := http.DefaultClient.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -161,7 +162,7 @@ func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputTy
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
body, resp, err := doRequest(ctx, req) body, resp, err := doRequest(ctx, proxy.ClientFromContext(ctx), req)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"} out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}

View File

@@ -33,6 +33,7 @@
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}.gravatar-recon

View File

@@ -23,7 +23,7 @@
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: "Enumerate", href: "/enumerate", icon: ListFilter },
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList }, { label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
{ {

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>

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}

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 — behaviour is identical to before.
</p> </p>
</section> </section>
@@ -156,6 +186,11 @@ import Layout from "@src/layouts/Layout.astro";
The backend filters tools by input type and the profile's enabled/disabled rules, The backend filters tools by input type and the profile's enabled/disabled rules,
then skips any tool with a missing required config field. then skips any tool with a missing required config field.
</li> </li>
<li>
If proxies are configured, a proxy-aware HTTP client and a
<code class="font-mono bg-base-300 px-1 rounded text-xs">proxychains4</code>
config are prepared and injected into the search context.
</li>
<li>All eligible tools run in parallel against the target.</li> <li>All eligible tools run in parallel against the target.</li>
<li> <li>
The frontend polls for results and renders them progressively as each tool finishes. The frontend polls for results and renders them progressively as each tool finishes.

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>

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>