Release Network Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Network Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Network Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Network Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Calculate and construct network values locally in the browser.</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parseCidr } from "@add-ideas/toolbox-helpers";
|
||||
import { buildDnsRecord, type DnsRecordType } from "../network/dns";
|
||||
import { buildCsp, inspectHeaders } from "../network/http";
|
||||
import { lookupMime } from "../network/mime";
|
||||
import { inspectUrl } from "../network/url";
|
||||
|
||||
const tabs = [
|
||||
["cidr", "IP & CIDR"],
|
||||
["url", "URL"],
|
||||
["dns", "DNS"],
|
||||
["http", "HTTP & CSP"],
|
||||
["mime", "MIME types"],
|
||||
] as const;
|
||||
|
||||
type Tab = (typeof tabs)[number][0];
|
||||
|
||||
function attempt<T>(operation: () => T): { value?: T; error?: string } {
|
||||
try {
|
||||
return { value: operation() };
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The value could not be processed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function CidrWorkspace() {
|
||||
const [source, setSource] = useState("192.168.10.42/24");
|
||||
const parsed = useMemo(() => attempt(() => parseCidr(source)), [source]);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="cidr-heading">
|
||||
<div>
|
||||
<h2 id="cidr-heading">IP address and subnet calculator</h2>
|
||||
<p className="muted">
|
||||
Canonicalise IPv4 or IPv6 CIDR notation and inspect its address range.
|
||||
</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Address with prefix</span>
|
||||
<input
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{parsed.error ? (
|
||||
<p className="error" role="alert">
|
||||
{parsed.error}
|
||||
</p>
|
||||
) : (
|
||||
parsed.value && (
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>Canonical</dt>
|
||||
<dd>{parsed.value.canonical}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Network</dt>
|
||||
<dd>{parsed.value.network.canonical}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>First address</dt>
|
||||
<dd>{parsed.value.first.canonical}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last address</dt>
|
||||
<dd>{parsed.value.last.canonical}</dd>
|
||||
</div>
|
||||
{parsed.value.broadcast && (
|
||||
<div>
|
||||
<dt>IPv4 broadcast</dt>
|
||||
<dd>{parsed.value.broadcast.canonical}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Addresses</dt>
|
||||
<dd>{parsed.value.size.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlWorkspace() {
|
||||
const [source, setSource] = useState(
|
||||
"https://example.com/docs?q=local#result",
|
||||
);
|
||||
const [base, setBase] = useState("");
|
||||
const inspection = useMemo(
|
||||
() => attempt(() => inspectUrl(source, base)),
|
||||
[source, base],
|
||||
);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="url-heading">
|
||||
<div>
|
||||
<h2 id="url-heading">URL inspector</h2>
|
||||
<p className="muted">
|
||||
Resolve and split a URL without requesting it. Passwords are removed
|
||||
from displayed output.
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>URL or reference</span>
|
||||
<input
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Optional base URL</span>
|
||||
<input
|
||||
value={base}
|
||||
onChange={(event) => setBase(event.target.value)}
|
||||
placeholder="https://example.com/a/"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{inspection.error ? (
|
||||
<p className="error" role="alert">
|
||||
{inspection.error}
|
||||
</p>
|
||||
) : (
|
||||
inspection.value && (
|
||||
<>
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>Safe URL</dt>
|
||||
<dd>{inspection.value.href}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scheme</dt>
|
||||
<dd>{inspection.value.scheme}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host</dt>
|
||||
<dd>
|
||||
{inspection.value.hostname || "—"}
|
||||
{inspection.value.port ? `:${inspection.value.port}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Path</dt>
|
||||
<dd>{inspection.value.pathname}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fragment</dt>
|
||||
<dd>{inspection.value.fragment || "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>Query parameters</h3>
|
||||
{inspection.value.query.length ? (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspection.value.query.map((entry, index) => (
|
||||
<tr key={`${entry.key}-${index}`}>
|
||||
<td>{entry.key}</td>
|
||||
<td>{entry.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="muted">No query parameters.</p>
|
||||
)}
|
||||
{inspection.value.warnings.length > 0 && (
|
||||
<ul className="compact-list warning">
|
||||
{inspection.value.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsWorkspace() {
|
||||
const [owner, setOwner] = useState("www.example.com.");
|
||||
const [ttl, setTtl] = useState(3600);
|
||||
const [type, setType] = useState<DnsRecordType>("A");
|
||||
const [value, setValue] = useState("192.0.2.10");
|
||||
const [priority, setPriority] = useState(10);
|
||||
const [weight, setWeight] = useState(5);
|
||||
const [port, setPort] = useState(443);
|
||||
const record = useMemo(
|
||||
() =>
|
||||
attempt(() =>
|
||||
buildDnsRecord({ owner, ttl, type, value, priority, weight, port }),
|
||||
),
|
||||
[owner, ttl, type, value, priority, weight, port],
|
||||
);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="dns-heading">
|
||||
<div>
|
||||
<h2 id="dns-heading">DNS record builder</h2>
|
||||
<p className="muted">
|
||||
Validate and compose common zone-file records. This does not query or
|
||||
change DNS.
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Owner</span>
|
||||
<input
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>TTL (seconds)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={ttl}
|
||||
onChange={(event) => setTtl(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Record type</span>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as DnsRecordType)}
|
||||
>
|
||||
{(["A", "AAAA", "CNAME", "MX", "SRV", "CAA", "TXT"] as const).map(
|
||||
(candidate) => (
|
||||
<option key={candidate}>{candidate}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Value or target</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{(type === "MX" || type === "SRV") && (
|
||||
<label className="field">
|
||||
<span>Priority</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{type === "SRV" && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Weight</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
value={weight}
|
||||
onChange={(event) => setWeight(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Port</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
value={port}
|
||||
onChange={(event) => setPort(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{record.error ? (
|
||||
<p className="error" role="alert">
|
||||
{record.error}
|
||||
</p>
|
||||
) : (
|
||||
<pre>{record.value}</pre>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpWorkspace() {
|
||||
const [headers, setHeaders] = useState(
|
||||
"Content-Type: text/html; charset=utf-8\nX-Content-Type-Options: nosniff\nReferrer-Policy: strict-origin-when-cross-origin",
|
||||
);
|
||||
const [scriptSources, setScriptSources] = useState("'self'");
|
||||
const [styleSources, setStyleSources] = useState("'self' 'unsafe-inline'");
|
||||
const parsed = useMemo(
|
||||
() => attempt(() => inspectHeaders(headers)),
|
||||
[headers],
|
||||
);
|
||||
const csp = useMemo(
|
||||
() =>
|
||||
attempt(() =>
|
||||
buildCsp({
|
||||
defaultSrc: "'self'",
|
||||
scriptSrc: scriptSources,
|
||||
styleSrc: styleSources,
|
||||
imgSrc: "'self' data: blob:",
|
||||
connectSrc: "'self'",
|
||||
workerSrc: "'self' blob:",
|
||||
}),
|
||||
),
|
||||
[scriptSources, styleSources],
|
||||
);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="http-heading">
|
||||
<div>
|
||||
<h2 id="http-heading">HTTP headers and CSP</h2>
|
||||
<p className="muted">
|
||||
Inspect pasted response headers and draft a conservative Content
|
||||
Security Policy.
|
||||
</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Response headers</span>
|
||||
<textarea
|
||||
value={headers}
|
||||
onChange={(event) => setHeaders(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{parsed.error ? (
|
||||
<p className="error" role="alert">
|
||||
{parsed.error}
|
||||
</p>
|
||||
) : (
|
||||
parsed.value && (
|
||||
<ul className="compact-list">
|
||||
{parsed.value.findings.length ? (
|
||||
parsed.value.findings.map((finding) => (
|
||||
<li key={finding}>{finding}</li>
|
||||
))
|
||||
) : (
|
||||
<li>No baseline header gaps detected.</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>script-src values</span>
|
||||
<input
|
||||
value={scriptSources}
|
||||
onChange={(event) => setScriptSources(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>style-src values</span>
|
||||
<input
|
||||
value={styleSources}
|
||||
onChange={(event) => setStyleSources(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{csp.error ? (
|
||||
<p className="error" role="alert">
|
||||
{csp.error}
|
||||
</p>
|
||||
) : (
|
||||
csp.value && (
|
||||
<>
|
||||
<pre>{csp.value.policy}</pre>
|
||||
{csp.value.warnings.length > 0 && (
|
||||
<ul className="compact-list warning">
|
||||
{csp.value.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MimeWorkspace() {
|
||||
const [query, setQuery] = useState("");
|
||||
const matches = useMemo(() => lookupMime(query), [query]);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="mime-heading">
|
||||
<div>
|
||||
<h2 id="mime-heading">MIME type reference</h2>
|
||||
<p className="muted">
|
||||
Search a compact offline reference by extension or media type.
|
||||
</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Extension or media type</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="wasm or image/"
|
||||
/>
|
||||
</label>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Extension</th>
|
||||
<th>MIME type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.map((match) => (
|
||||
<tr key={match.extension}>
|
||||
<td>.{match.extension}</td>
|
||||
<td>{match.mime}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const fromHash = window.location.hash.slice(1) as Tab;
|
||||
const [tab, setTab] = useState<Tab>(
|
||||
tabs.some(([key]) => key === fromHash) ? fromHash : "cidr",
|
||||
);
|
||||
const choose = (next: Tab) => {
|
||||
setTab(next);
|
||||
window.history.replaceState(null, "", `#${next}`);
|
||||
};
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first workbench</p>
|
||||
<h1>Network Tools</h1>
|
||||
<p>
|
||||
Calculate, inspect, and construct common network values without
|
||||
making network requests.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">No queries sent</span>
|
||||
</header>
|
||||
<nav
|
||||
className="workspace-tabs panel"
|
||||
aria-label="Network workspaces"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
onClick={() => choose(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tab === "cidr" && <CidrWorkspace />}
|
||||
{tab === "url" && <UrlWorkspace />}
|
||||
{tab === "dns" && <DnsWorkspace />}
|
||||
{tab === "http" && <HttpWorkspace />}
|
||||
{tab === "mime" && <MimeWorkspace />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { parseIpv4, parseIpv6 } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
export type DnsRecordType =
|
||||
"A" | "AAAA" | "CAA" | "CNAME" | "MX" | "SRV" | "TXT";
|
||||
|
||||
export interface DnsRecordInput {
|
||||
owner: string;
|
||||
ttl: number;
|
||||
type: DnsRecordType;
|
||||
value: string;
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
port?: number;
|
||||
flag?: number;
|
||||
tag?: "issue" | "issuewild" | "iodef";
|
||||
}
|
||||
|
||||
const LABEL =
|
||||
/^(?:[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)(?:\.(?:[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?))*\.?$/u;
|
||||
function integer(
|
||||
value: number | undefined,
|
||||
name: string,
|
||||
maximum = 65_535,
|
||||
): number {
|
||||
if (!Number.isInteger(value) || value! < 0 || value! > maximum)
|
||||
throw new Error(`${name} must be an integer from 0 to ${maximum}.`);
|
||||
return value!;
|
||||
}
|
||||
|
||||
function domain(value: string, name: string): string {
|
||||
const candidate = value.trim();
|
||||
if (candidate === "@") return candidate;
|
||||
if (!candidate || candidate.length > 253 || !LABEL.test(candidate))
|
||||
throw new Error(`${name} must be a valid ASCII DNS name.`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function quoteTxt(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
if (bytes.length > 255)
|
||||
throw new Error("A TXT character-string is limited to 255 UTF-8 bytes.");
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
export function buildDnsRecord(input: DnsRecordInput): string {
|
||||
const owner = domain(input.owner, "Owner");
|
||||
const ttl = integer(input.ttl, "TTL", 2_147_483_647);
|
||||
const value = input.value.trim();
|
||||
if (!value) throw new Error("Record value is required.");
|
||||
|
||||
let data: string;
|
||||
switch (input.type) {
|
||||
case "A":
|
||||
try {
|
||||
data = parseIpv4(value).canonical;
|
||||
} catch {
|
||||
throw new Error("A records require a dotted-decimal IPv4 address.");
|
||||
}
|
||||
break;
|
||||
case "AAAA":
|
||||
try {
|
||||
data = parseIpv6(value).canonical;
|
||||
} catch {
|
||||
throw new Error("AAAA records require an IPv6 address.");
|
||||
}
|
||||
break;
|
||||
case "CNAME":
|
||||
data = domain(value, "Target");
|
||||
break;
|
||||
case "MX":
|
||||
data = `${integer(input.priority, "Priority")} ${domain(value, "Mail exchanger")}`;
|
||||
break;
|
||||
case "SRV":
|
||||
data = `${integer(input.priority, "Priority")} ${integer(input.weight, "Weight")} ${integer(input.port, "Port")} ${domain(value, "Target")}`;
|
||||
break;
|
||||
case "CAA": {
|
||||
const tag = input.tag ?? "issue";
|
||||
data = `${integer(input.flag ?? 0, "Flag", 255)} ${tag} ${quoteTxt(value)}`;
|
||||
break;
|
||||
}
|
||||
case "TXT":
|
||||
data = quoteTxt(value);
|
||||
break;
|
||||
}
|
||||
return `${owner} ${ttl} IN ${input.type} ${data}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export interface HeaderEntry {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HeaderInspection {
|
||||
headers: HeaderEntry[];
|
||||
duplicates: string[];
|
||||
findings: string[];
|
||||
}
|
||||
|
||||
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
|
||||
|
||||
function hasInvalidHeaderControl(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0)!;
|
||||
return (code < 32 && code !== 9) || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectHeaders(source: string): HeaderInspection {
|
||||
if (source.length > 256 * 1024)
|
||||
throw new Error("Header block exceeds 256 KiB.");
|
||||
const headers: HeaderEntry[] = [];
|
||||
for (const [index, raw] of source
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split("\n")
|
||||
.entries()) {
|
||||
if (!raw.trim()) continue;
|
||||
if (/^[ \t]/u.test(raw))
|
||||
throw new Error(
|
||||
`Line ${index + 1}: obsolete folded headers are rejected.`,
|
||||
);
|
||||
const colon = raw.indexOf(":");
|
||||
if (colon <= 0)
|
||||
throw new Error(
|
||||
`Line ${index + 1}: expected a name followed by a colon.`,
|
||||
);
|
||||
const name = raw.slice(0, colon).trim();
|
||||
const value = raw.slice(colon + 1).trim();
|
||||
if (!TOKEN.test(name))
|
||||
throw new Error(`Line ${index + 1}: invalid header name.`);
|
||||
if (hasInvalidHeaderControl(value))
|
||||
throw new Error(`Line ${index + 1}: invalid control character.`);
|
||||
headers.push({ name, value });
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const header of headers) {
|
||||
const key = header.name.toLowerCase();
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const duplicates = [...counts]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name);
|
||||
const names = new Set(counts.keys());
|
||||
const findings: string[] = [];
|
||||
if (!names.has("content-security-policy"))
|
||||
findings.push("No Content-Security-Policy header is present.");
|
||||
if (!names.has("x-content-type-options"))
|
||||
findings.push("Add X-Content-Type-Options: nosniff.");
|
||||
if (!names.has("referrer-policy"))
|
||||
findings.push("No Referrer-Policy header is present.");
|
||||
if (!names.has("permissions-policy"))
|
||||
findings.push("No Permissions-Policy header is present.");
|
||||
if (headers.some((header) => header.name.toLowerCase() === "server"))
|
||||
findings.push("The Server header may disclose implementation details.");
|
||||
return { headers, duplicates, findings };
|
||||
}
|
||||
|
||||
export interface CspInput {
|
||||
defaultSrc: string;
|
||||
scriptSrc: string;
|
||||
styleSrc: string;
|
||||
imgSrc: string;
|
||||
connectSrc: string;
|
||||
workerSrc: string;
|
||||
}
|
||||
|
||||
function sources(value: string): string {
|
||||
const tokens = value.trim().split(/\s+/u).filter(Boolean);
|
||||
if (tokens.length === 0) return "'none'";
|
||||
for (const token of tokens) {
|
||||
if (/[;,\r\n]/u.test(token))
|
||||
throw new Error(`Invalid CSP source token: ${token}`);
|
||||
}
|
||||
return [...new Set(tokens)].join(" ");
|
||||
}
|
||||
|
||||
export function buildCsp(input: CspInput): {
|
||||
policy: string;
|
||||
warnings: string[];
|
||||
} {
|
||||
const directives = [
|
||||
["default-src", input.defaultSrc],
|
||||
["base-uri", "'self'"],
|
||||
["object-src", "'none'"],
|
||||
["frame-ancestors", "'none'"],
|
||||
["form-action", "'self'"],
|
||||
["script-src", input.scriptSrc],
|
||||
["style-src", input.styleSrc],
|
||||
["img-src", input.imgSrc],
|
||||
["connect-src", input.connectSrc],
|
||||
["worker-src", input.workerSrc],
|
||||
] as const;
|
||||
const policy = directives
|
||||
.map(([name, value]) => `${name} ${sources(value)}`)
|
||||
.join("; ");
|
||||
const warnings: string[] = [];
|
||||
if (/\*/u.test(policy))
|
||||
warnings.push("Wildcard sources broaden trust substantially.");
|
||||
if (policy.includes("'unsafe-eval'"))
|
||||
warnings.push("unsafe-eval permits string-to-code execution.");
|
||||
if (policy.includes("'unsafe-inline'"))
|
||||
warnings.push(
|
||||
"unsafe-inline weakens script or style injection protection.",
|
||||
);
|
||||
if (/script-src[^;]*\bdata:/u.test(policy))
|
||||
warnings.push("data: in script-src can enable injected code.");
|
||||
return { policy, warnings };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
avif: "image/avif",
|
||||
bin: "application/octet-stream",
|
||||
css: "text/css",
|
||||
csv: "text/csv",
|
||||
epub: "application/epub+zip",
|
||||
gif: "image/gif",
|
||||
gz: "application/gzip",
|
||||
html: "text/html",
|
||||
ico: "image/x-icon",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
js: "text/javascript",
|
||||
json: "application/json",
|
||||
jsonld: "application/ld+json",
|
||||
mjs: "text/javascript",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
odp: "application/vnd.oasis.opendocument.presentation",
|
||||
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
||||
odt: "application/vnd.oasis.opendocument.text",
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
svg: "image/svg+xml",
|
||||
tar: "application/x-tar",
|
||||
txt: "text/plain",
|
||||
wasm: "application/wasm",
|
||||
webm: "video/webm",
|
||||
webmanifest: "application/manifest+json",
|
||||
webp: "image/webp",
|
||||
xml: "application/xml",
|
||||
zip: "application/zip",
|
||||
};
|
||||
|
||||
export function lookupMime(
|
||||
value: string,
|
||||
): { extension: string; mime: string }[] {
|
||||
const query = value.trim().toLowerCase().replace(/^\./u, "");
|
||||
if (!query)
|
||||
return Object.entries(MIME_TYPES).map(([extension, mime]) => ({
|
||||
extension,
|
||||
mime,
|
||||
}));
|
||||
return Object.entries(MIME_TYPES)
|
||||
.filter(
|
||||
([extension, mime]) => extension.includes(query) || mime.includes(query),
|
||||
)
|
||||
.map(([extension, mime]) => ({ extension, mime }));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface QueryEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UrlInspection {
|
||||
href: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
hasPassword: boolean;
|
||||
hostname: string;
|
||||
port: string;
|
||||
origin: string;
|
||||
pathname: string;
|
||||
query: QueryEntry[];
|
||||
fragment: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function containsControl(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0)!;
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectUrl(input: string, base?: string): UrlInspection {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) throw new Error("Enter a URL to inspect.");
|
||||
if (trimmed.length > 16_384) throw new Error("URL exceeds the 16 KiB limit.");
|
||||
if (containsControl(trimmed))
|
||||
throw new Error("URL contains control characters.");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = base?.trim()
|
||||
? new URL(trimmed, new URL(base.trim()))
|
||||
: new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"The value is not a valid absolute URL (or relative to the supplied base).",
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const hasPassword = parsed.password !== "";
|
||||
if (hasPassword)
|
||||
warnings.push(
|
||||
"The URL contains a password; it was removed from the result.",
|
||||
);
|
||||
if (parsed.username) warnings.push("The URL contains user information.");
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
warnings.push(
|
||||
`The ${parsed.protocol.slice(0, -1)} scheme is not an HTTP URL.`,
|
||||
);
|
||||
if (parsed.protocol === "http:" && parsed.hostname !== "localhost")
|
||||
warnings.push(
|
||||
"Plain HTTP does not protect content or credentials in transit.",
|
||||
);
|
||||
if (parsed.hostname.includes("xn--"))
|
||||
warnings.push(
|
||||
"The hostname contains an internationalized-domain punycode label.",
|
||||
);
|
||||
|
||||
parsed.password = "";
|
||||
return {
|
||||
href: parsed.href,
|
||||
scheme: parsed.protocol.slice(0, -1),
|
||||
username: parsed.username,
|
||||
hasPassword,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
origin: parsed.origin,
|
||||
pathname: parsed.pathname,
|
||||
query: [...parsed.searchParams.entries()].map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
fragment: parsed.hash.slice(1),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUrl(
|
||||
inspection: UrlInspection,
|
||||
query = inspection.query,
|
||||
): string {
|
||||
const parsed = new URL(inspection.href);
|
||||
parsed.search = "";
|
||||
for (const { key, value } of query) parsed.searchParams.append(key, value);
|
||||
return parsed.href;
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
.button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled),
|
||||
.button:hover {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel,
|
||||
.workspace {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero h1,
|
||||
.workspace h2,
|
||||
.workspace h3,
|
||||
.help-dialog h2,
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 52rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.8rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: clamp(1rem, 2vw, 1.35rem);
|
||||
}
|
||||
.workspace h2 + p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
.workspace h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.muted {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
.facts div {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.facts dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.73rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.facts dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.warning {
|
||||
color: #8a5a00;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
|
||||
border-radius: 0.65rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 8%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.73rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.network-tools",
|
||||
"name": "Network Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Calculate and construct network values locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["network", "developer", "security"],
|
||||
"tags": ["ip", "cidr", "url", "dns", "http", "csp"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/network-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/network-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user