Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions frontend/src/components/tools/ToolPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,161 @@ function HeadersPanel() {
</div>
);
}
function SubnetPanel() {
const { t } = useTranslations();

// ── pure-JS subnet math (no external deps) ──────────────────────────────
function ipToInt(ip: string): number {
return ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct, 10), 0) >>> 0;
}
function intToIp(n: number): string {
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
}
function cidrToMask(cidr: number): number {
return cidr === 0 ? 0 : (0xffffffff << (32 - cidr)) >>> 0;
}
function maskToCidr(mask: number): number {
let n = mask >>> 0, count = 0;
while (n & 0x80000000) { count++; n = (n << 1) >>> 0; }
return count;
}
function isValidIp(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
return parts.every(p => { const n = parseInt(p, 10); return !isNaN(n) && n >= 0 && n <= 255 && String(n) === p; });
}
function isValidMask(mask: string): boolean {
if (!isValidIp(mask)) return false;
const inv = (~ipToInt(mask)) >>> 0;
return (inv & (inv + 1)) === 0;
}
function getIpType(ip: string): string {
const n = ipToInt(ip);
if ((n >>> 24) === 10 || ((n >>> 16) & 0xfff0) === 0xac10 || (n >>> 16) === 0xc0a8) return 'Private (RFC 1918)';
if ((n >>> 24) === 127) return 'Loopback';
if ((n >>> 28) === 0xe) return 'Multicast';
return 'Public';
}

const [ip, setIp] = useState('');
const [prefix, setPrefix] = useState('24');
const [prefixMode, setPrefixMode] = useState<'cidr' | 'mask'>('cidr');
const [errors, setErrors] = useState<{ ip?: string; prefix?: string }>({});
const [result, setResult] = useState<null | {
cidr: number; networkAddress: string; broadcastAddress: string;
subnetMask: string; wildcardMask: string;
firstUsable: string; lastUsable: string;
usableHosts: number; totalHosts: number; ipType: string;
}>(null);

const run = () => {
const errs: { ip?: string; prefix?: string } = {};
if (!ip.trim()) errs.ip = 'IP address is required';
else if (!isValidIp(ip.trim())) errs.ip = 'Invalid IP (e.g. 192.168.1.0)';

let cidr = 0;
if (prefixMode === 'cidr') {
const n = parseInt(prefix.replace('/', ''), 10);
if (isNaN(n) || n < 0 || n > 32) errs.prefix = 'CIDR must be 0–32';
else cidr = n;
} else {
if (!isValidMask(prefix.trim())) errs.prefix = 'Invalid subnet mask';
else cidr = maskToCidr(ipToInt(prefix.trim()));
}

setErrors(errs);
if (Object.keys(errs).length > 0) return;

const mask = cidrToMask(cidr);
const ipInt = ipToInt(ip.trim());
const netInt = (ipInt & mask) >>> 0;
const bcastInt = (netInt | (~mask >>> 0)) >>> 0;
const total = Math.pow(2, 32 - cidr);
const usable = cidr >= 31 ? total : Math.max(0, total - 2);

setResult({
cidr,
networkAddress: intToIp(netInt),
broadcastAddress: intToIp(bcastInt),
subnetMask: intToIp(mask),
wildcardMask: intToIp(~mask >>> 0),
firstUsable: cidr >= 31 ? intToIp(netInt) : intToIp(netInt + 1),
lastUsable: cidr >= 31 ? intToIp(bcastInt) : intToIp(bcastInt - 1),
usableHosts: usable,
totalHosts: total,
ipType: getIpType(ip.trim()),
});
};

return (
<div>
<Card>
{/* Mode toggle */}
<div className="flex gap-2 mb-3">
{(['cidr', 'mask'] as const).map(m => (
<button key={m} onClick={() => { setPrefixMode(m); setPrefix(m === 'cidr' ? '24' : '255.255.255.0'); setErrors({}); }}
className={`px-3 py-1 text-[11px] rounded border transition-colors ${prefixMode === m ? 'border-blue/50 text-blue bg-blue/10' : 'border-border-2 text-text-3 hover:text-text-2'}`}>
{m === 'cidr' ? 'CIDR' : 'Subnet Mask'}
</button>
))}
</div>

{/* Inputs */}
<div className="flex gap-2 flex-wrap mb-2">
<div className="flex flex-col gap-1 flex-1 min-w-[140px]">
<input className="input-field" value={ip} onChange={e => { setIp(e.target.value); setErrors(p => ({ ...p, ip: undefined })); }}
placeholder="192.168.1.0" onKeyDown={e => e.key === 'Enter' && run()} />
{errors.ip && <span className="text-[11px] text-red">{errors.ip}</span>}
</div>
<div className="flex flex-col gap-1 min-w-[120px]">
{prefixMode === 'cidr' ? (
<input className="input-field" value={prefix} onChange={e => { setPrefix(e.target.value); setErrors(p => ({ ...p, prefix: undefined })); }}
placeholder="/24" onKeyDown={e => e.key === 'Enter' && run()} />
) : (
<input className="input-field" value={prefix} onChange={e => { setPrefix(e.target.value); setErrors(p => ({ ...p, prefix: undefined })); }}
placeholder="255.255.255.0" onKeyDown={e => e.key === 'Enter' && run()} />
)}
{errors.prefix && <span className="text-[11px] text-red">{errors.prefix}</span>}
</div>
<RunBtn loading={false} label="Calculate" onClick={run} />
</div>

{/* CIDR slider */}
{prefixMode === 'cidr' && (
<div className="mt-3">
<input type="range" min="0" max="32"
value={parseInt(prefix) || 24}
onChange={e => setPrefix(e.target.value)}
className="w-full accent-blue cursor-pointer" />
<div className="flex justify-between text-[10px] text-text-3 mt-0.5">
{[0, 8, 16, 24, 32].map(n => (
<button key={n} onClick={() => setPrefix(String(n))} className="hover:text-blue transition-colors">/{n}</button>
))}
</div>
</div>
)}
</Card>

{result && (
<Card>
<Row label="Network Address" value={`${result.networkAddress}/${result.cidr}`} />
<Row label="Subnet Mask" value={result.subnetMask} />
<Row label="Wildcard Mask" value={result.wildcardMask} />
<Row label="Broadcast Address" value={result.broadcastAddress} />
<Row label="First Usable IP" value={result.firstUsable} />
<Row label="Last Usable IP" value={result.lastUsable} />
<Row label="Usable Hosts" value={result.usableHosts.toLocaleString()} />
<Row label="Total Addresses" value={result.totalHosts.toLocaleString()} />
<Row label="IP Type" value={result.ipType} />
</Card>
)}
</div>
);
}
const TOOL_TITLES: Record<string, string> = {
crypto: 'Crypto Address Lookup', qr: 'QR Code Decoder',
metadata: 'File Metadata & GEOINT', headers: 'Email Header Analyzer', mac: 'MAC Lookup',
subnet: 'IP / Subnet Calculator',
};

interface Props {
Expand All @@ -395,6 +546,7 @@ export function ToolPanels({ mode, onBack }: Props) {
{activePanel === 'qr' && <QrPanel />}
{activePanel === 'metadata' && <MetadataPanel />}
{activePanel === 'headers' && <HeadersPanel />}
{activePanel === 'subnet' && <SubnetPanel />}
{activePanel === 'mac' && <MacPanel />}
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/views/IdleView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { useTranslations } from '@/lib/i18n';
import { Logo } from '../Logo';
import type { ToolMode } from '@/lib/types';

const TOOL_IDS = ['metadata', 'headers', 'crypto', 'qr', 'mac'] as const;
const TOOL_IDS = ['metadata', 'headers', 'crypto', 'qr', 'mac', 'subnet'] as const;
type ToolId = typeof TOOL_IDS[number];

const ICONS: Record<ToolId, React.ElementType> = {
subnet: Globe,
metadata: FileText,
headers: Mail,
crypto: Bitcoin,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type ScanType = 'domain' | 'ip' | 'email' | 'phone' | 'username';
export type ScanStatus = 'idle' | 'running' | 'completed' | 'failed';
export type ToolMode = 'metadata' | 'headers' | 'crypto' | 'qr' | 'mac' | null;
export type ToolMode = 'metadata' | 'headers' | 'crypto' | 'qr' | 'mac' | 'subnet' | null;

export interface ScanMeta {
id: string;
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
"headers": { "label": "E-Mail-Header", "desc": "SPF, DKIM, Routing-Hops" },
"crypto": { "label": "Krypto-Adresse", "desc": "Bitcoin und Ethereum" },
"qr": { "label": "QR-Decoder", "desc": "Decodieren & analysieren" },
"mac": { "label": "MAC Lookup", "desc": "Hersteller-Suche" }
"mac": { "label": "MAC Lookup", "desc": "Hersteller-Suche" },
"subnet": { "label": "IP / Subnetz", "desc": "Netzwerkbereiche & CIDR" }
},
"demo": {
"line1": "Dies ist eine öffentliche Demo mit begrenzten API-Kontingenten. Manche Module (Shodan, VirusTotal) können ratenbegrenzt sein.",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
"headers": { "label": "Email Headers", "desc": "SPF, DKIM, routing hops" },
"crypto": { "label": "Crypto Address", "desc": "Bitcoin & Ethereum" },
"qr": { "label": "QR Decode", "desc": "Decode & analyze" },
"mac": { "label": "MAC Lookup", "desc": "Vendor lookup" }
"mac": { "label": "MAC Lookup", "desc": "Vendor lookup" },
"subnet": { "label": "IP / Subnet", "desc": "Network ranges & CIDR" }
},
"demo": {
"line1": "This is a public demo with limited API quotas. Some modules (Shodan, VirusTotal) may be rate-limited.",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
"headers": { "label": "Cabeceras de correo", "desc": "SPF, DKIM, saltos de enrutamiento" },
"crypto": { "label": "Dirección cripto", "desc": "Bitcoin y Ethereum" },
"qr": { "label": "Decodificar QR", "desc": "Decodificar y analizar" },
"mac": { "label": "Búsqueda MAC", "desc": "Búsqueda de proveedor" }
"mac": { "label": "Búsqueda MAC", "desc": "Búsqueda de proveedor" },
"subnet": { "label": "IP / Subred", "desc": "Rangos de red & CIDR" }
},
"demo": {
"line1": "Esta es una demo pública con cuotas de API limitadas. Algunos módulos (Shodan, VirusTotal) pueden tener límite de velocidad.",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"headers": { "label": "En-têtes d'e-mail", "desc": "SPF, DKIM, sauts de routage" },
"crypto": { "label": "Adresse crypto", "desc": "Bitcoin et Ethereum" },
"qr": { "label": "Décodeur QR", "desc": "Décoder et analyser" },
"mac": { "label": "Recherche MAC", "desc": "Recherche de fournisseur" }
"mac": { "label": "Recherche MAC", "desc": "Recherche de fournisseur" },
"subnet": { "label": "IP / Sous-réseau", "desc": "Plages réseau & CIDR" }
},
"demo": {
"line1": "Ceci est une démo publique avec des quotas API limités. Certains modules (Shodan, VirusTotal) peuvent être soumis à des limites de débit.",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
"headers": { "label": "Заголовки письма", "desc": "SPF, DKIM, маршрут" },
"crypto": { "label": "Крипто-адрес", "desc": "Bitcoin и Ethereum" },
"qr": { "label": "Декодер QR", "desc": "Декодирование и анализ" },
"mac": { "label": "MAC Lookup", "desc": "Поиск производителя" }
"mac": { "label": "MAC Lookup", "desc": "Поиск производителя" },
"subnet": { "label": "IP / Подсеть", "desc": "Диапазоны сети и CIDR" }
},
"demo": {
"line1": "Это публичное демо с ограниченными квотами API. Некоторые модули (Shodan, VirusTotal) могут быть ограничены частотой запросов.",
Expand Down