Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the egress card on the swarm page lists and edits the swarm-wide denied domains, and says which hives have not applied them

This commit is contained in:
iris 2026-11-04 23:04:28 +01:00 • committed by mara
commit 0421f28d49
3 changed files with 210 additions and 103 deletions

View file

@ -32,6 +32,7 @@ import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
ALLOWED_DOMAINS_URL,
DENIED_DOMAINS_URL,
entriesFor,
entryBody,
entryGroups,
@ -150,17 +151,20 @@ export function AllowedDomains({ agent }: { agent: string }) {
);
}
// The add form of one allowed domain: for `agent`, or for every agent. With no
// `agent` (the swarm page) it can only add a swarm-wide entry.
// The add form of one allowed or denied domain: for `agent`, or for every
// agent. With no `agent` (the swarm page) it can only add a swarm-wide entry.
export function AddForm({
agent,
kind = "allow",
onClose,
onAdded,
}: {
agent?: string;
kind?: "allow" | "deny";
onClose: () => void;
onAdded: () => void;
}) {
const deny = kind === "deny";
const [domain, setDomain] = useState("");
const [forSwarm, setForSwarm] = useState(agent === undefined);
const [busy, setBusy] = useState(false);
@ -171,7 +175,7 @@ export function AddForm({
setBusy(true);
setProblem(null);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
const r = await fetch(deny ? DENIED_DOMAINS_URL : ALLOWED_DOMAINS_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(entryBody(domain, forSwarm, agent ?? "")),
@ -191,28 +195,30 @@ export function AddForm({
return (
<Panel
title={
agent === undefined
? "allowed domain — swarm-wide"
: `allowed domain — ${agent}`
}
title={`${deny ? "denied" : "allowed"} domain — ${agent ?? "swarm-wide"}`}
icon="🌐"
onClose={onClose}
>
<p>
{agent === undefined
? "Lets every agent reach one more name through its hive's proxy."
: `Lets ${agent} reach one more name through its hive's proxy, or every agent when the box below is ticked.`}{" "}
{deny
? agent === undefined
? "Stops every agent reaching one name through its hive's proxy."
: `Stops ${agent} reaching one name through its hive's proxy, or every agent when the box below is ticked.`
: agent === undefined
? "Lets every agent reach one more name through its hive's proxy."
: `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 entry adds to the names
each hive's own configuration allows. The hive reads a change within
about 30 seconds.
leading dot, as in <code>.githubusercontent.com</code>, also{" "}
{deny ? "refuses" : "allows"} every subdomain. IP addresses and
wildcards are refused.{" "}
{deny
? "A denied name is refused in every egress mode, even when a swarm entry or a hive's own configuration allows it."
: "A name that resolves to a private address stays blocked. The entry adds to 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"
id={deny ? "denied-domain" : "allowed-domain"}
label="domain"
value={domain}
placeholder="e.g. github.com"
@ -220,7 +226,7 @@ export function AddForm({
/>
{agent === undefined ? null : (
<CheckboxField
id="allowed-domain-swarm"
id={deny ? "denied-domain-swarm" : "allowed-domain-swarm"}
label="for every agent in the swarm"
checked={forSwarm}
onChange={setForSwarm}

View file

@ -15,6 +15,7 @@ export interface AllowedDomain {
}
export const ALLOWED_DOMAINS_URL = "/api/egress/allowed-domains";
export const DENIED_DOMAINS_URL = "/api/egress/denied-domains";
/**
* The entries that apply to `agent`: the swarm-wide ones first, then the ones
@ -92,3 +93,16 @@ export function entryBody(
export function swarmWideEntries(entries: AllowedDomain[]): AllowedDomain[] {
return entries.filter((e) => e.agent === undefined && e.derived !== true);
}
/**
* What the deny list says about hives that have not taken its current
* revision: a hive in this list does not refuse the names yet. `null` when
* every hive has.
*/
export function behindNote(behind: string[]): string | null {
if (behind.length === 0) return null;
const names = behind.join(", ");
return behind.length === 1
? `${names} has not applied this list yet, so its proxy may still let these names through.`
: `${names} have not applied this list yet, so their proxies may still let these names through.`;
}

View file

@ -1,20 +1,28 @@
// The swarm's allowed-domain entries and the one change that is not a form:
// removing an entry. Shared by an agent card's allowed domains and the swarm
// page's egress card, which differ only in which entries they show.
// The swarm's allowed-domain and denied-domain entries and the one change that
// is not a form: removing an entry. Shared by an agent card's allowed domains
// and the swarm page's egress card, which differ only in which entries they
// show.
//
// Reads `GET /api/egress/allowed-domains` (every entry) and removes one with a
// `DELETE` carrying the entry, each answering the whole list. `refetch` reads
// the list again, for a caller that added an entry through its own form.
// Reads `GET <url>` (every entry) and removes one with a `DELETE` carrying the
// entry, each answering the whole list. `refetch` reads the list again, for a
// caller that added an entry through its own form. The deny list also reports
// `behind`, the hives that have not taken its current revision yet.
import { useEffect, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ALLOWED_DOMAINS_URL, type AllowedDomain } from "./allowed-domains.js";
import {
ALLOWED_DOMAINS_URL,
DENIED_DOMAINS_URL,
type AllowedDomain,
} from "./allowed-domains.js";
interface Listing {
entries: AllowedDomain[];
behind?: string[];
}
export function useAllowedDomains() {
function useDomainList(url: string) {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [behind, setBehind] = useState<string[]>([]);
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);
@ -23,7 +31,7 @@ export function useAllowedDomains() {
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(ALLOWED_DOMAINS_URL);
const r = await fetch(url);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
@ -31,6 +39,7 @@ export function useAllowedDomains() {
const body = (await r.json()) as Listing;
if (cancelled) return;
setEntries(body.entries);
setBehind(body.behind ?? []);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
@ -38,13 +47,13 @@ export function useAllowedDomains() {
return () => {
cancelled = true;
};
}, [reload]);
}, [url, reload]);
async function remove(entry: AllowedDomain) {
if (busy) return;
setBusy(true);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
const r = await fetch(url, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(entry),
@ -63,9 +72,13 @@ export function useAllowedDomains() {
return {
entries,
behind,
error,
busy,
remove,
refetch: () => setReload((n) => n + 1),
};
}
export const useAllowedDomains = () => useDomainList(ALLOWED_DOMAINS_URL);
export const useDeniedDomains = () => useDomainList(DENIED_DOMAINS_URL);