make squid's allowed domains editable at runtime in the swarm ui
This commit is contained in:
parent
86e80250bb
commit
c9abc2a42b
3 changed files with 2307 additions and 35 deletions
|
|
@ -14,6 +14,7 @@ import { AgentInference } from "./AgentInference.js";
|
|||
import { AgentInstructions } from "./AgentInstructions.js";
|
||||
import { AgentLimits } from "./AgentLimits.js";
|
||||
import { AgentPermissions } from "./AgentPermissions.js";
|
||||
import { AllowedDomains } from "./AllowedDomains.js";
|
||||
import { AgentStateRow } from "./AgentStateRow.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
|
|
@ -122,6 +123,10 @@ export function AgentDetail({
|
|||
<dd>
|
||||
<AgentPermissions key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>allowed domains</dt>
|
||||
<dd>
|
||||
<AllowedDomains key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>accounts</dt>
|
||||
<dd>
|
||||
{row.hive ? (
|
||||
|
|
|
|||
243
frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx
Normal file
243
frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// <AllowedDomains> — the destinations the swarm lets one agent reach through
|
||||
// its hive's egress proxy, beyond the base list every agent has: the entries
|
||||
// that hold for every agent and the ones for this agent alone, with an editor
|
||||
// to add and remove them.
|
||||
//
|
||||
// Reads `GET /api/egress/allowed-domains`, which answers every entry, and
|
||||
// shows the ones that apply to this agent. Adding POSTs one entry and removing
|
||||
// DELETEs one, each with the entry as the body, each answering the whole list.
|
||||
// The route's refusal (an IP address, a wildcard, a name the proxy already
|
||||
// allows everyone) shows as it arrives, next to the box. The hive reads a
|
||||
// change within about 30 seconds.
|
||||
//
|
||||
// The first entry makes the swarm's list replace the names the hive's own
|
||||
// configuration allows, so a name the hive allowed and the swarm's list lacks
|
||||
// stops being allowed once hives read it. An agent that holds the unrestricted
|
||||
// internet flag reaches any name the proxy's private-range rules leave, so an
|
||||
// entry matters only to a restricted one.
|
||||
//
|
||||
// Its state belongs to one agent: callers key it by agent name.
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
ALLOWED_DOMAINS_URL,
|
||||
allowedSummary,
|
||||
entriesFor,
|
||||
entryBody,
|
||||
type AllowedDomain,
|
||||
} from "./allowed-domains.js";
|
||||
import "./AgentLimits.css";
|
||||
|
||||
interface Listing {
|
||||
entries: AllowedDomain[];
|
||||
}
|
||||
|
||||
export function AllowedDomains({ agent }: { agent: string }) {
|
||||
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Bumped after an add or a removal, so the list is fetched again.
|
||||
const [reload, setReload] = useState(0);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const r = await fetch(ALLOWED_DOMAINS_URL);
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const body = (await r.json()) as Listing;
|
||||
if (cancelled) return;
|
||||
setEntries(body.entries);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
async function remove(entry: AllowedDomain) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch(ALLOWED_DOMAINS_URL, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setReload((n) => n + 1);
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A list already loaded stays under a failed refetch, so a change that
|
||||
// worked is not hidden by a read that did not.
|
||||
const errorPanel = error ? (
|
||||
<>
|
||||
<ApiErrorPanel
|
||||
context="the allowed domains were not read or changed"
|
||||
problem={error}
|
||||
/>
|
||||
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
|
||||
</>
|
||||
) : null;
|
||||
if (entries === null) {
|
||||
return (
|
||||
errorPanel ?? (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
loading the allowed domains…
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const shown = entriesFor(entries, agent);
|
||||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{errorPanel}
|
||||
<Badge label="allowed domains" value={allowedSummary(shown)} />
|
||||
{shown.length > 0 ? (
|
||||
<ul class="ui-agent-limits-list">
|
||||
{shown.map((e) => (
|
||||
<li key={`${e.agent ?? "*"}/${e.domain}`}>
|
||||
<Badge
|
||||
label={e.agent === undefined ? "swarm-wide" : agent}
|
||||
value={e.domain}
|
||||
/>{" "}
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="remove"
|
||||
title={`stop allowing ${e.domain} for ${
|
||||
e.agent === undefined ? "every agent" : agent
|
||||
}`}
|
||||
onClick={() => void remove(e)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="add"
|
||||
title={`allow a domain for ${agent} or for every agent`}
|
||||
onClick={() => setAdding(true)}
|
||||
/>
|
||||
<Dialog
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
label="add an allowed domain"
|
||||
plain
|
||||
>
|
||||
{/* Remounted per open, so a cancelled add leaves nothing behind. */}
|
||||
{adding ? (
|
||||
<AddForm
|
||||
agent={agent}
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={() => {
|
||||
setAdding(false);
|
||||
setReload((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({
|
||||
agent,
|
||||
onClose,
|
||||
onAdded,
|
||||
}: {
|
||||
agent: string;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [forSwarm, setForSwarm] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||
|
||||
async function save(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setProblem(null);
|
||||
try {
|
||||
const r = await fetch(ALLOWED_DOMAINS_URL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(entryBody(domain, forSwarm, agent)),
|
||||
});
|
||||
if (!r.ok) {
|
||||
// The box stays as typed, so the operator can fix it and add again.
|
||||
setProblem(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
onAdded();
|
||||
} catch (err: unknown) {
|
||||
setProblem({ detail: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={`allowed domain — ${agent}`} icon="🌐" onClose={onClose}>
|
||||
<p>
|
||||
Lets {agent} reach one more name through its hive's proxy, or every
|
||||
agent when the box below is ticked. A name needs at least two labels,
|
||||
such as <code>github.com</code>; a leading dot, as in{" "}
|
||||
<code>.githubusercontent.com</code>, also allows every subdomain. IP
|
||||
addresses and wildcards are refused. A name that resolves to a private
|
||||
address stays blocked. The first entry makes the swarm's list replace
|
||||
the names each hive's own configuration allows. The hive reads a change
|
||||
within about 30 seconds.
|
||||
</p>
|
||||
<form class="ui-agent-limits-form" onSubmit={(e) => void save(e)}>
|
||||
<TextField
|
||||
id="allowed-domain"
|
||||
label="domain"
|
||||
value={domain}
|
||||
placeholder="e.g. github.com"
|
||||
onInput={setDomain}
|
||||
/>
|
||||
<CheckboxField
|
||||
id="allowed-domain-swarm"
|
||||
label="for every agent in the swarm"
|
||||
checked={forSwarm}
|
||||
onChange={setForSwarm}
|
||||
/>
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="the domain was not added" problem={problem} />
|
||||
) : null}
|
||||
<div class="ui-agent-limits-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={busy || domain.trim() === ""}
|
||||
>
|
||||
{busy ? "adding…" : "add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// The detail panel's allowed-domains editor: which entries one agent's panel
|
||||
// shows and the bodies it sends. Kept out of the component so `npm test` can
|
||||
// run it.
|
||||
|
||||
/** One entry as `GET /api/egress/allowed-domains` carries it. */
|
||||
export interface AllowedDomain {
|
||||
domain: string;
|
||||
/** Absent for an entry that holds for every agent in the swarm. */
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export const ALLOWED_DOMAINS_URL = "/api/egress/allowed-domains";
|
||||
|
||||
/**
|
||||
* The entries that apply to `agent`: the swarm-wide ones first, then the ones
|
||||
* for this agent alone. Entries for other agents are not shown on this panel.
|
||||
*/
|
||||
export function entriesFor(
|
||||
entries: AllowedDomain[],
|
||||
agent: string,
|
||||
): AllowedDomain[] {
|
||||
const swarmWide = entries.filter((e) => e.agent === undefined);
|
||||
const own = entries.filter((e) => e.agent === agent);
|
||||
return [...swarmWide, ...own];
|
||||
}
|
||||
|
||||
/** What the panel says about a list of entries. */
|
||||
export function allowedSummary(shown: AllowedDomain[]): string {
|
||||
if (shown.length === 0) return "none added";
|
||||
const swarmWide = shown.filter((e) => e.agent === undefined).length;
|
||||
const own = shown.length - swarmWide;
|
||||
return [
|
||||
swarmWide > 0 ? `${swarmWide} swarm-wide` : null,
|
||||
own > 0 ? `${own} for this agent` : null,
|
||||
]
|
||||
.filter((part) => part !== null)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The body that adds or removes `domain`, as typed: the route lower-cases it
|
||||
* and refuses what is not a name. `forSwarm` makes it hold for every agent;
|
||||
* otherwise it is for `agent` alone.
|
||||
*/
|
||||
export function entryBody(
|
||||
domain: string,
|
||||
forSwarm: boolean,
|
||||
agent: string,
|
||||
): AllowedDomain {
|
||||
const name = domain.trim();
|
||||
return forSwarm ? { domain: name } : { domain: name, agent };
|
||||
}
|
||||
Loading…
Reference in a new issue