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

@@ -23,7 +23,7 @@
const navLinks = [
{ label: "Search", href: "/", icon: Search },
{ label: "Tools", href: "/tools", icon: Hammer },
{ label: "Profiles", href: "/profiles", icon: SlidersHorizontal },
{ label: "Settings", href: "/settings", icon: SlidersHorizontal },
{ label: "Enumerate", href: "/enumerate", icon: ListFilter },
{ label: "Cheatsheets", href: "/cheatsheets", icon: ClipboardList },
{

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>
<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>
</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,
then skips any tool with a missing required config field.
</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>
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";
import ProfileSettings from "@src/components/ProfileSettings.svelte";
// Redirect to /settings
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>