mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Make HTTPS decryption opt-in with a decrypt list (#822)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91696c2d1f
commit
4538123d88
44 files changed
+3259
-66
No files matched your search
@@ -63,8 +63,7 @@ import { isSidebarFocused } from "../lib/scopes";
|
||||
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
|
||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||
import type { FieldDef } from "./core/Editor/filter/extension";
|
||||
import { filter } from "./core/Editor/filter/extension";
|
||||
import { type FieldDef, filter } from "@yaakapp-internal/ui/editor";
|
||||
import type { Ast } from "./core/Editor/filter/query";
|
||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { type EditorView, keymap } from "@codemirror/view";
|
||||
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
|
||||
import { Dialog, Icon, IconButton, Input } from "@yaakapp-internal/ui";
|
||||
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt";
|
||||
|
||||
/**
|
||||
* What HTTPS is decrypted: one row per entry, the user's in order and then the built-in
|
||||
* exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`,
|
||||
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings; until
|
||||
* that exists, by the dialog the state pill's More… opens.
|
||||
*/
|
||||
export function DecryptListEditor({ className }: { className?: string }) {
|
||||
const list = useDecryptList();
|
||||
const save = useEditDecryptList();
|
||||
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
|
||||
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
|
||||
const seenRef = useRef<SeenTargets | undefined>(seen.data);
|
||||
seenRef.current = seen.data;
|
||||
|
||||
// Each change replaces the whole list, so one waits for the last to land rather than undo it.
|
||||
const remove = (entry: string) => {
|
||||
if (save.isPending) return;
|
||||
save.mutate({ add: [], remove: [entry] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(className, "grid gap-2")}>
|
||||
<EntryInput
|
||||
seenRef={seenRef}
|
||||
list={list}
|
||||
disabled={save.isPending}
|
||||
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
|
||||
/>
|
||||
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
|
||||
{list.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-subtle">Nothing decrypted</div>
|
||||
)}
|
||||
{list.map((e) => (
|
||||
<EntryRow
|
||||
key={e.entry}
|
||||
entry={e}
|
||||
disabled={save.isPending}
|
||||
onRemove={() => remove(e.entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryRow({
|
||||
entry,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: DecryptListEntry;
|
||||
disabled: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const negated = entry.entry.startsWith("!");
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"group/entry flex items-center gap-2 h-xs pl-3 pr-1 text-sm",
|
||||
entry.builtIn && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={negated ? "lock" : "lock_open"}
|
||||
size="sm"
|
||||
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
|
||||
title={negated ? "Excluded" : "Decrypted"}
|
||||
/>
|
||||
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
|
||||
{entryLabel(entry.entry)}
|
||||
</span>
|
||||
{entry.builtIn && (
|
||||
<span
|
||||
title="Ships with Yaak Proxy"
|
||||
className="shrink-0 text-2xs rounded px-1 border border-border-subtle"
|
||||
>
|
||||
built-in
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
title={`Remove ${entryLabel(entry.entry)}`}
|
||||
disabled={disabled}
|
||||
className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100"
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The input that adds an entry on Enter, once the suggestions are closed or none is picked */
|
||||
function EntryInput({
|
||||
seenRef,
|
||||
list,
|
||||
disabled,
|
||||
onAdd,
|
||||
}: {
|
||||
seenRef: React.RefObject<SeenTargets | undefined>;
|
||||
list: DecryptListEntry[];
|
||||
/** A change is being saved; Enter waits for it, keeping what was typed */
|
||||
disabled: boolean;
|
||||
onAdd: (entry: string) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onAddRef = useRef(onAdd);
|
||||
onAddRef.current = onAdd;
|
||||
const listRef = useRef(list);
|
||||
listRef.current = list;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const options = decryptFilterOptions(null);
|
||||
// The suggestions read the capture as they open, not as the input was made.
|
||||
for (const field of options.fields ?? []) {
|
||||
field.values = () => {
|
||||
const fresh = decryptFilterOptions(seenRef.current).fields ?? [];
|
||||
const values = fresh.find((f) => f.name === field.name)?.values;
|
||||
return Array.isArray(values) ? values : [];
|
||||
};
|
||||
}
|
||||
const add = (view: EditorView) => {
|
||||
const text = view.state.doc.toString();
|
||||
if (text.trim() === "") return false;
|
||||
if (disabledRef.current) return true;
|
||||
const entry = normalizeEntry(text);
|
||||
if (entry == null) {
|
||||
setError("Not a host or app");
|
||||
return true;
|
||||
}
|
||||
setError(null);
|
||||
if (!listRef.current.some((e) => e.entry === entry)) onAddRef.current(entry);
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "" } });
|
||||
return true;
|
||||
};
|
||||
// Low, so Enter picks a suggestion while they are open.
|
||||
return [filterAutocomplete(options), Prec.low(keymap.of([{ key: "Enter", run: add }]))];
|
||||
}, [seenRef]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-1" title="Applies to new connections">
|
||||
<Input
|
||||
label="Add entry"
|
||||
hideLabel
|
||||
size="sm"
|
||||
stateKey={null}
|
||||
language={null}
|
||||
placeholder="Add host, app or *"
|
||||
extensions={extensions}
|
||||
onChange={() => setError(null)}
|
||||
/>
|
||||
{error != null && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the decrypt list from the state pill's More…, until Settings holds it */
|
||||
export const decryptListDialogOpenAtom = atom(false);
|
||||
|
||||
export function DecryptListDialog() {
|
||||
const [open, setOpen] = useAtom(decryptListDialogOpenAtom);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
size="dynamic"
|
||||
title="Decrypt List"
|
||||
className="w-[36rem]"
|
||||
>
|
||||
<div className="pb-4">{open && <DecryptListEditor />}</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { everythingEdit } from "../lib/decrypt";
|
||||
import { decryptStep } from "../lib/firstRun";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
|
||||
@@ -11,7 +14,7 @@ type StepState = "todo" | "next" | "wait" | "done" | "fail";
|
||||
|
||||
/**
|
||||
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
|
||||
* then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
|
||||
* choose what to decrypt, then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
|
||||
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
|
||||
*/
|
||||
export function FirstRun() {
|
||||
@@ -22,6 +25,8 @@ export function FirstRun() {
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const decryptList = useDecryptList();
|
||||
const saveDecryptList = useEditDecryptList();
|
||||
|
||||
const running = proxyState?.state === "running";
|
||||
// Changed only by the process that owns the engine, so not from here while the CLI does.
|
||||
@@ -47,6 +52,9 @@ export function FirstRun() {
|
||||
: trusted
|
||||
? "next"
|
||||
: "todo";
|
||||
const decrypt = decryptStep(decryptList, trusted);
|
||||
const everything = decrypt.choice === "everything";
|
||||
const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on));
|
||||
const ready = trusted && (systemOn || !systemSupported);
|
||||
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
|
||||
const trafficState: StepState =
|
||||
@@ -57,8 +65,8 @@ export function FirstRun() {
|
||||
<div className="w-full max-w-[40rem]">
|
||||
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
|
||||
<div className="text-text-subtle text-sm leading-relaxed mb-4">
|
||||
Three steps, once. Then every app on this Mac sends its traffic here, and HTTPS reads as
|
||||
plain text.
|
||||
{systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its
|
||||
traffic here.
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
|
||||
<Step
|
||||
@@ -115,6 +123,40 @@ export function FirstRun() {
|
||||
)}
|
||||
<Step
|
||||
n={systemSupported ? 3 : 2}
|
||||
state={decrypt.state}
|
||||
title="What to decrypt"
|
||||
description={
|
||||
everything ? (
|
||||
<span title="Except the built-in exclusions">Everything</span>
|
||||
) : (
|
||||
<span title="From a flow or the sidebar">Pick as they appear</span>
|
||||
)
|
||||
}
|
||||
action={
|
||||
everything ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
isLoading={saveDecryptList.isPending}
|
||||
onClick={() => setEverything(false)}
|
||||
>
|
||||
Pick As They Appear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={decrypt.state === "next" ? "primary" : "default"}
|
||||
isLoading={saveDecryptList.isPending}
|
||||
onClick={() => setEverything(true)}
|
||||
>
|
||||
Everything
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
n={systemSupported ? 4 : 3}
|
||||
state={trafficState}
|
||||
title={
|
||||
encrypting != null
|
||||
|
||||
@@ -30,6 +30,7 @@ import { messagesTabLabel, streamIsOpen } from "../lib/messages";
|
||||
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
|
||||
import { FlowMessages } from "./FlowMessages";
|
||||
import { NotDecrypted } from "./NotDecrypted";
|
||||
|
||||
const TAB_RESPONSE = "response";
|
||||
const TAB_REQUEST = "request";
|
||||
@@ -62,6 +63,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
|
||||
<Keys keys={["⌘", "K"]} />
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : flow.tunnelled ? (
|
||||
<NotDecrypted flow={flow} />
|
||||
) : (
|
||||
<FlowPane flow={flow} />
|
||||
)}
|
||||
|
||||
@@ -457,14 +457,15 @@ const FlowRow = memo(function FlowRow({
|
||||
});
|
||||
|
||||
/**
|
||||
* One mark per row, by a fixed priority: failed, cancelled, cut, saved. Its tooltip lists every
|
||||
* state the flow has. Streaming and agent reads join it once the engine records them.
|
||||
* One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved.
|
||||
* Its tooltip lists every state the flow has. Agent reads join it once the engine records them.
|
||||
*/
|
||||
function FlowMark({ flow }: { flow: Flow }) {
|
||||
const open = isOpenStream(flow);
|
||||
const states: string[] = [];
|
||||
if (flow.error != null) states.push(`Failed: ${flow.error}`);
|
||||
if (flow.cancelled) states.push("Cancelled by the app");
|
||||
if (flow.tunnelled) states.push("Not decrypted");
|
||||
if (open) states.push("Streaming");
|
||||
if (flow.responseTruncated) states.push("Body cut at the capture limit");
|
||||
if (flow.savedRequestId != null) states.push("Saved to Yaak");
|
||||
@@ -475,6 +476,8 @@ function FlowMark({ flow }: { flow: Flow }) {
|
||||
<Icon icon="circle_alert" size="sm" color="danger" />
|
||||
) : flow.cancelled ? (
|
||||
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
|
||||
) : flow.tunnelled ? (
|
||||
<Icon icon="lock" size="sm" className="text-text-subtlest" />
|
||||
) : open ? (
|
||||
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
|
||||
) : flow.responseTruncated ? (
|
||||
@@ -488,6 +491,11 @@ function FlowMark({ flow }: { flow: Flow }) {
|
||||
|
||||
const mono = "font-mono text-xs";
|
||||
|
||||
/** An undecrypted tunnel still open: written as it opens, done once it closes */
|
||||
function isOpenTunnel(flow: Flow): boolean {
|
||||
return flow.tunnelled && flow.elapsedMs == null && flow.error == null;
|
||||
}
|
||||
|
||||
/** A WebSocket, event stream or gRPC call still open: written as it opens, done once it ends */
|
||||
function isOpenStream(flow: Flow): boolean {
|
||||
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
|
||||
@@ -529,6 +537,8 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
</span>
|
||||
);
|
||||
case "path": {
|
||||
// A tunnel's only path is its host; what it carried is unread.
|
||||
if (flow.tunnelled) return <span className="text-text-subtlest">Not decrypted</span>;
|
||||
const { path, query } = splitPath(flow.url);
|
||||
if (flow.streamKind == null) {
|
||||
return (
|
||||
@@ -556,7 +566,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
);
|
||||
}
|
||||
case "status":
|
||||
return flow.cancelled && flow.status == null ? (
|
||||
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
|
||||
<span className={classNames(mono, "text-text-subtlest")}>--</span>
|
||||
) : (
|
||||
<HttpStatusTagRaw status={flow.status ?? 0} short className={mono} />
|
||||
@@ -589,8 +599,8 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
</span>
|
||||
);
|
||||
case "time":
|
||||
// An open stream shows how long it has been open, quietly, as its messages land.
|
||||
if (isOpenStream(flow)) {
|
||||
// An open stream or tunnel shows how long it has been open, quietly, as its messages land.
|
||||
if (isOpenStream(flow) || isOpenTunnel(flow)) {
|
||||
const open = Date.now() - Date.parse(utcTimestamp(flow.startedAt));
|
||||
return (
|
||||
<span className={classNames(mono, "text-right text-text-subtlest")}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import { Button, formatMillis, Icon } from "@yaakapp-internal/ui";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { sidebarPath } from "../lib/search";
|
||||
import { EmptyState } from "./FlowBodyViewer";
|
||||
|
||||
/**
|
||||
* The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its
|
||||
* host or its app to the decrypt list, from the next connection on.
|
||||
*/
|
||||
export function NotDecrypted({ flow }: { flow: Flow }) {
|
||||
const list = useDecryptList();
|
||||
const save = useEditDecryptList();
|
||||
const host = sidebarPath(flow.url).split("/")[0] ?? flow.url;
|
||||
const hostSubject: DecryptSubject = { kind: "host", host };
|
||||
const appSubject: DecryptSubject | null =
|
||||
flow.clientName == null
|
||||
? null
|
||||
: { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
|
||||
const app = flow.clientName;
|
||||
const actions = detailActions(
|
||||
list,
|
||||
host,
|
||||
app == null ? null : { name: app, bundleId: flow.clientBundleId },
|
||||
);
|
||||
const open = flow.elapsedMs == null && flow.error == null;
|
||||
|
||||
const decrypt = (subject: DecryptSubject) =>
|
||||
save.mutate(listEdit(list, setDecrypted(list, [subject], true)));
|
||||
|
||||
const facts = [
|
||||
`${formatSize(Number(flow.requestSize))} sent`,
|
||||
`${formatSize(Number(flow.responseSize))} received`,
|
||||
open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs),
|
||||
].filter((f) => f != null);
|
||||
|
||||
return (
|
||||
<EmptyState>
|
||||
<div className="grid justify-items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-text-subtle">
|
||||
<Icon icon="lock" size="sm" />
|
||||
Not decrypted
|
||||
</div>
|
||||
<div className="font-mono text-xs">{facts.join(" · ")}</div>
|
||||
{flow.error != null && <div className="text-danger">{flow.error}</div>}
|
||||
{actions.listed ? (
|
||||
<div title="Still tunnelled? The app may pin its certificate">
|
||||
Decrypting new connections
|
||||
</div>
|
||||
) : actions.blocked != null ? (
|
||||
<div title="Remove it from the decrypt list to decrypt">
|
||||
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 mt-1">
|
||||
{actions.host && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title={`Decrypt new connections to ${host}`}
|
||||
disabled={save.isPending}
|
||||
onClick={() => decrypt(hostSubject)}
|
||||
>
|
||||
Decrypt Host
|
||||
</Button>
|
||||
)}
|
||||
{actions.app && appSubject != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title={`Decrypt new connections from ${app}`}
|
||||
disabled={save.isPending}
|
||||
onClick={() => decrypt(appSubject)}
|
||||
>
|
||||
Decrypt App
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { FlowDetail } from "./FlowDetail";
|
||||
import { FlowList } from "./FlowList";
|
||||
import { LockedState } from "./LockedState";
|
||||
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
|
||||
import { DecryptListDialog } from "./DecryptListEditor";
|
||||
import { HelperDialog } from "./HelperDialog";
|
||||
import { SaveDialog } from "./SaveDialog";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
@@ -150,6 +151,7 @@ export function ProxyLayout() {
|
||||
</SidebarLayout>
|
||||
<SaveDialog />
|
||||
<HelperDialog />
|
||||
<DecryptListDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { decryptListDialogOpenAtom } from "./DecryptListEditor";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { environmentExports } from "../lib/env";
|
||||
import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store";
|
||||
@@ -45,6 +46,7 @@ export function ProxyStatus() {
|
||||
const setHttpVersion = useSetHttpVersion();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom);
|
||||
const setDecryptListOpen = useSetAtom(decryptListDialogOpenAtom);
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
|
||||
@@ -165,6 +167,11 @@ export function ProxyStatus() {
|
||||
),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Decrypt List…",
|
||||
icon: "lock_open",
|
||||
onSelect: () => setDecryptListOpen(true),
|
||||
},
|
||||
{
|
||||
label: "Copy Environment for a Terminal",
|
||||
icon: "square_terminal",
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Icon, IconButton, Tree } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
ContextMenu,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Tree,
|
||||
type TreeProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
|
||||
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
|
||||
/** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */
|
||||
@@ -52,16 +62,33 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
|
||||
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
|
||||
{item.appPath != null && <AppIcon path={item.appPath} />}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.sublabel != null && (
|
||||
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
|
||||
)}
|
||||
{item.decrypt != null && <DecryptedMark item={item} />}
|
||||
<Count item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row with nothing in its menu: the menu closes as it opens, so the row is not left marked */
|
||||
function CloseAtOnce({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => onClose(), [onClose]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A small open lock on a host, site or app the decrypt list decrypts */
|
||||
function DecryptedMark({ item }: { item: SidebarItem }) {
|
||||
const list = useDecryptList();
|
||||
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
|
||||
return (
|
||||
<Icon icon="lock_open" size="xs" title="Decrypted" className="shrink-0 text-text-subtlest" />
|
||||
);
|
||||
}
|
||||
|
||||
/** Beside the row's button rather than in it, since it is a button of its own */
|
||||
function ItemRightSlot({ item }: { item: SidebarItem }) {
|
||||
return item.id === SYSTEM_ID ? <HideSystemToggle /> : <span aria-hidden />;
|
||||
@@ -94,6 +121,50 @@ export function Sidebar() {
|
||||
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`,
|
||||
[],
|
||||
);
|
||||
const decryptList = useDecryptList();
|
||||
const setDecryptList = useEditDecryptList();
|
||||
const { mutate: saveDecryptList } = setDecryptList;
|
||||
const showToast = useShowToast();
|
||||
|
||||
// Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
|
||||
const getContextMenu = useCallback(
|
||||
(items: SidebarItem[]): DropdownItem[] => {
|
||||
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
|
||||
const action = decryptMenuAction(decryptList, subjects);
|
||||
if (action == null) return [];
|
||||
return [
|
||||
{
|
||||
label: action.label,
|
||||
icon: action.on ? "lock_open" : "lock",
|
||||
onSelect: () => {
|
||||
const blocked = action.on ? blockerOf(decryptList, subjects) : null;
|
||||
if (blocked != null) {
|
||||
showToast({
|
||||
id: "decrypt-blocked",
|
||||
color: "notice",
|
||||
message: `Excluded by ${blocked}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only what this changes, applied to the list as it is then, so a second change
|
||||
// made before this one lands keeps it.
|
||||
const next = setDecrypted(decryptList, subjects, action.on);
|
||||
saveDecryptList(listEdit(decryptList, next));
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
[decryptList, saveDecryptList, showToast],
|
||||
);
|
||||
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
|
||||
({ items, position, onClose }) =>
|
||||
items.length === 0 ? (
|
||||
<CloseAtOnce onClose={onClose} />
|
||||
) : (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
// The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows
|
||||
@@ -114,6 +185,8 @@ export function Sidebar() {
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenu}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ const base: Flow = {
|
||||
grpcMethod: null,
|
||||
grpcStatus: null,
|
||||
grpcMessage: null,
|
||||
tunnelled: false,
|
||||
};
|
||||
|
||||
const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true });
|
||||
|
||||
@@ -49,6 +49,7 @@ const flowBase: Flow = {
|
||||
grpcMethod: null,
|
||||
grpcStatus: null,
|
||||
grpcMessage: null,
|
||||
tunnelled: false,
|
||||
};
|
||||
|
||||
/** Seconds past 21:41:12 as a stored timestamp */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DecryptList,
|
||||
DecryptListChangedPayload,
|
||||
DecryptListEntry,
|
||||
} from "@yaakapp-internal/proxy-lib";
|
||||
import { useCallback } from "react";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { useRpcEvent } from "./useRpcEvent";
|
||||
import { useRpcMutation } from "./useRpcMutation";
|
||||
import { useRpcQuery } from "./useRpcQuery";
|
||||
|
||||
const KEY = ["get_decrypt_list", {}];
|
||||
const EMPTY: DecryptListEntry[] = [];
|
||||
|
||||
/**
|
||||
* The decrypt list's entries, empty until read. Kept current as this process changes it, from
|
||||
* the app or through its control API.
|
||||
*/
|
||||
export function useDecryptList(): DecryptListEntry[] {
|
||||
const queryClient = useQueryClient();
|
||||
const onChanged = useCallback(
|
||||
(payload: DecryptListChangedPayload) =>
|
||||
queryClient.setQueryData<DecryptList>(KEY, payload.list),
|
||||
[queryClient],
|
||||
);
|
||||
useRpcEvent("decrypt_list_changed", onChanged);
|
||||
return useRpcQuery("get_decrypt_list", {}).data?.entries ?? EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds and removes entries, `edit_decrypt_list` applying them to the list as it is when they
|
||||
* land, so two changes made in quick succession both stick. Everything that reads the list
|
||||
* then reads it again. A failure, such as an entry that does not parse, is a toast.
|
||||
*/
|
||||
export function useEditDecryptList() {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useShowToast();
|
||||
return useRpcMutation("edit_decrypt_list", {
|
||||
// Read again rather than taken from the answer, which can land after a newer change's
|
||||
// event and would put the older list back.
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }),
|
||||
onError: (e) => showToast({ id: "decrypt-list", color: "danger", message: e.message }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
|
||||
import { filterCompletionSource } from "@yaakapp-internal/ui/editor";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
blockerOf,
|
||||
blockingExclusion,
|
||||
decryptFilterOptions,
|
||||
detailActions,
|
||||
decryptMenuAction,
|
||||
decrypts,
|
||||
decryptsEverything,
|
||||
entryLabel,
|
||||
everythingEdit,
|
||||
isDecrypted,
|
||||
listEdit,
|
||||
normalizeEntry,
|
||||
setDecrypted,
|
||||
subjectQuery,
|
||||
toggleDecrypt,
|
||||
} from "./decrypt";
|
||||
|
||||
const user = (...entries: string[]): DecryptListEntry[] =>
|
||||
entries.map((entry) => ({ entry, builtIn: false }));
|
||||
const builtIn = (...entries: string[]): DecryptListEntry[] =>
|
||||
entries.map((entry) => ({ entry, builtIn: true }));
|
||||
const BUILT_INS = builtIn("!host:*.apple.com", "!host:*.icloud.com");
|
||||
|
||||
describe("normalizeEntry", () => {
|
||||
test("a bare hostname is a host entry", () => {
|
||||
expect(normalizeEntry("api.example.com")).toBe("host:api.example.com");
|
||||
expect(normalizeEntry(" API.Example.com. ")).toBe("host:api.example.com");
|
||||
expect(normalizeEntry("https://api.example.com:8443/v1")).toBe("host:api.example.com:8443");
|
||||
});
|
||||
|
||||
test("prefixes and exclusions", () => {
|
||||
expect(normalizeEntry("HOST:*.Example.com")).toBe("host:*.example.com");
|
||||
expect(normalizeEntry("!x.com")).toBe("!host:x.com");
|
||||
expect(normalizeEntry("! host:x.com")).toBe("!host:x.com");
|
||||
expect(normalizeEntry('app:"Google Chrome"')).toBe("app:Google Chrome");
|
||||
expect(normalizeEntry("App: com.google.Chrome")).toBe("app:com.google.Chrome");
|
||||
expect(normalizeEntry("host:[::1]:8443")).toBe("host:[::1]:8443");
|
||||
expect(normalizeEntry(" * ")).toBe("*");
|
||||
});
|
||||
|
||||
test("what is not an entry", () => {
|
||||
for (const bad of ["", "!", "app:", "host:a b", "host:a.com:http", "host:a.com:99999"]) {
|
||||
expect(normalizeEntry(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("labels", () => {
|
||||
expect(entryLabel("*")).toBe("Everything");
|
||||
expect(entryLabel("host:a.com")).toBe("host:a.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypts", () => {
|
||||
test("an empty list decrypts nothing, with or without the built-ins", () => {
|
||||
expect(decrypts([], { host: "a.com" })).toBe(false);
|
||||
expect(decrypts(BUILT_INS, { host: "a.com" })).toBe(false);
|
||||
});
|
||||
|
||||
test("hosts, sites and ports", () => {
|
||||
const list = user("host:*.example.com", "host:other.com:8443");
|
||||
expect(decrypts(list, { host: "api.example.com" })).toBe(true);
|
||||
expect(decrypts(list, { host: "example.com" })).toBe(true);
|
||||
expect(decrypts(list, { host: "notexample.com" })).toBe(false);
|
||||
expect(decrypts(list, { host: "other.com", port: 8443 })).toBe(true);
|
||||
expect(decrypts(list, { host: "other.com" })).toBe(false);
|
||||
});
|
||||
|
||||
test("apps by name or bundle id", () => {
|
||||
const chrome = { name: "Google Chrome", bundleId: "com.google.Chrome" };
|
||||
expect(decrypts(user("app:google chrome"), { host: "a.com", app: chrome })).toBe(true);
|
||||
expect(decrypts(user("app:com.google.*"), { host: "a.com", app: chrome })).toBe(true);
|
||||
expect(decrypts(user("app:Safari"), { host: "a.com", app: chrome })).toBe(false);
|
||||
});
|
||||
|
||||
test("everything keeps the built-in exclusions, which a specific entry beats", () => {
|
||||
const list = [...user("*"), ...BUILT_INS];
|
||||
expect(decrypts(list, { host: "api.example.com" })).toBe(true);
|
||||
expect(decrypts(list, { host: "gateway.icloud.com" })).toBe(false);
|
||||
const specific = [...user("*", "host:developer.apple.com"), ...BUILT_INS];
|
||||
expect(decrypts(specific, { host: "developer.apple.com" })).toBe(true);
|
||||
expect(decrypts(specific, { host: "www.apple.com" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the user's exclusions win wherever they sit", () => {
|
||||
for (const list of [user("!host:a.example.com", "*"), user("*", "!host:a.example.com")]) {
|
||||
expect(decrypts(list, { host: "a.example.com" })).toBe(false);
|
||||
expect(decrypts(list, { host: "b.example.com" })).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sidebar's Decrypt and Stop Decrypting", () => {
|
||||
test("Decrypt adds the host's entry before the built-ins", () => {
|
||||
const list = BUILT_INS;
|
||||
const subject = { kind: "host", host: "api.example.com" } as const;
|
||||
expect(isDecrypted(list, subject)).toBe(false);
|
||||
expect(toggleDecrypt(list, subject)).toEqual([
|
||||
"host:api.example.com",
|
||||
"!host:*.apple.com",
|
||||
"!host:*.icloud.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Stop Decrypting removes the entry that decrypts it", () => {
|
||||
const list = [...user("host:api.example.com", "host:b.com"), ...BUILT_INS];
|
||||
const subject = { kind: "host", host: "api.example.com" } as const;
|
||||
expect(isDecrypted(list, subject)).toBe(true);
|
||||
expect(toggleDecrypt(list, subject)).toEqual([
|
||||
"host:b.com",
|
||||
"!host:*.apple.com",
|
||||
"!host:*.icloud.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Stop Decrypting under everything excludes the host", () => {
|
||||
const list = user("*");
|
||||
const subject = { kind: "site", site: "example.com" } as const;
|
||||
expect(isDecrypted(list, subject)).toBe(true);
|
||||
expect(toggleDecrypt(list, subject)).toEqual(["*", "!host:*.example.com"]);
|
||||
});
|
||||
|
||||
test("Decrypt adds nothing while a broader exclusion of the user's would still win", () => {
|
||||
const list = user("*", "!host:*.example.com");
|
||||
const subject = { kind: "host", host: "api.example.com" } as const;
|
||||
expect(blockingExclusion(list, subjectQuery(subject))).toBe("!host:*.example.com");
|
||||
expect(toggleDecrypt(list, subject)).toEqual(["*", "!host:*.example.com"]);
|
||||
// The built-ins are not the user's to block with: a specific entry beats them.
|
||||
expect(blockingExclusion(BUILT_INS, { host: "www.apple.com" })).toBeNull();
|
||||
});
|
||||
|
||||
test("Decrypt undoes the sidebar's own Stop Decrypting", () => {
|
||||
const subject = { kind: "site", site: "example.com" } as const;
|
||||
const stopped = toggleDecrypt(user("*"), subject);
|
||||
expect(stopped).toEqual(["*", "!host:*.example.com"]);
|
||||
const list = user(...stopped);
|
||||
expect(blockerOf(list, [subject])).toBeNull();
|
||||
expect(toggleDecrypt(list, subject)).toEqual(["*"]);
|
||||
const host = { kind: "host", host: "api.example.com" } as const;
|
||||
expect(blockerOf(list, [host])).toBe("!host:*.example.com");
|
||||
});
|
||||
|
||||
test("an exact site entry beats the built-in exclusion of the same site", () => {
|
||||
expect(decrypts([...user("host:apple.com"), ...BUILT_INS], { host: "apple.com" })).toBe(true);
|
||||
});
|
||||
|
||||
test("Decrypt drops the user's own exclusion first", () => {
|
||||
const list = user("*", "!host:*.example.com");
|
||||
const subject = { kind: "site", site: "example.com" } as const;
|
||||
expect(toggleDecrypt(list, subject)).toEqual(["*"]);
|
||||
});
|
||||
|
||||
test("apps by name, and a host with a port", () => {
|
||||
const slack = { kind: "app", name: "Slack", bundleId: "com.tinyspeck.slackmacgap" } as const;
|
||||
expect(toggleDecrypt([], slack)).toEqual(["app:Slack"]);
|
||||
expect(isDecrypted(user("app:com.tinyspeck.slackmacgap"), slack)).toBe(true);
|
||||
expect(isDecrypted(user("*"), slack)).toBe(true);
|
||||
const port = { kind: "host", host: "localhost:8443" } as const;
|
||||
expect(toggleDecrypt([], port)).toEqual(["host:localhost:8443"]);
|
||||
expect(isDecrypted(user("host:localhost:8443"), port)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sidebar menu", () => {
|
||||
const a = { kind: "host", host: "a.com" } as const;
|
||||
const b = { kind: "host", host: "b.com" } as const;
|
||||
|
||||
test("offers Decrypt unless every selected row is decrypted", () => {
|
||||
expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({
|
||||
label: "Stop Decrypting",
|
||||
on: false,
|
||||
});
|
||||
expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ label: "Decrypt", on: true });
|
||||
expect(decryptMenuAction([], [])).toBeNull();
|
||||
});
|
||||
|
||||
test("applies to every selected row, leaving those already so", () => {
|
||||
const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry);
|
||||
expect(next).toEqual(["host:a.com", "host:b.com"]);
|
||||
expect(setDecrypted(user("host:a.com", "host:b.com"), [a, b], false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Not decrypted detail's buttons", () => {
|
||||
const slack = { name: "Slack", bundleId: "com.tinyspeck.slackmacgap" };
|
||||
|
||||
test("each button is offered only when its own change would decrypt the connection", () => {
|
||||
// `!app:Slack` still excludes Slack's connections whatever host is added.
|
||||
const list = user("*", "!app:Slack");
|
||||
expect(detailActions(list, "edgeapi.slack.com", slack)).toEqual({
|
||||
listed: false,
|
||||
host: false,
|
||||
app: true,
|
||||
blocked: null,
|
||||
});
|
||||
// And `!host:` the other way round.
|
||||
expect(detailActions(user("*", "!host:edgeapi.slack.com"), "edgeapi.slack.com", slack)).toEqual(
|
||||
{ listed: false, host: true, app: false, blocked: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("names the exclusion when neither would", () => {
|
||||
const list = user("*", "!host:*.slack.com", "!app:Slack");
|
||||
expect(detailActions(list, "edgeapi.slack.com", slack)).toEqual({
|
||||
listed: false,
|
||||
host: false,
|
||||
app: false,
|
||||
blocked: "!host:*.slack.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("both on an empty list, and none once it is listed", () => {
|
||||
expect(detailActions(BUILT_INS, "a.com", slack)).toEqual({
|
||||
listed: false,
|
||||
host: true,
|
||||
app: true,
|
||||
blocked: null,
|
||||
});
|
||||
expect(detailActions(user("host:a.com"), "a.com", null).listed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first-run step", () => {
|
||||
test("Everything adds *, and Pick as they appear takes it out", () => {
|
||||
expect(decryptsEverything(BUILT_INS)).toBe(false);
|
||||
expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true);
|
||||
expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] });
|
||||
expect(everythingEdit(false)).toEqual({ add: [], remove: ["*"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edits sent as additions and removals", () => {
|
||||
test("a toggle becomes what it adds and removes, not the whole list", () => {
|
||||
const list = [...user("*", "!host:*.example.com"), ...BUILT_INS];
|
||||
const site = { kind: "site", site: "example.com" } as const;
|
||||
expect(listEdit(list, setDecrypted(list, [site], true))).toEqual({
|
||||
add: [],
|
||||
remove: ["!host:*.example.com"],
|
||||
});
|
||||
const host = { kind: "host", host: "a.com" } as const;
|
||||
expect(listEdit(BUILT_INS, setDecrypted(BUILT_INS, [host], true))).toEqual({
|
||||
add: ["host:a.com"],
|
||||
remove: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("two edits from the same stale list both survive, applied in turn", () => {
|
||||
// What the lib does with each edit: remove, then add after the user's own entries.
|
||||
const apply = (entries: string[], e: { add: string[]; remove: string[] }) => [
|
||||
...entries.filter((x) => !e.remove.includes(x)),
|
||||
...e.add,
|
||||
];
|
||||
const stale = BUILT_INS;
|
||||
const first = listEdit(stale, setDecrypted(stale, [{ kind: "host", host: "a.com" }], true));
|
||||
const second = listEdit(stale, setDecrypted(stale, [{ kind: "host", host: "b.com" }], true));
|
||||
const saved = apply(apply([], first), second);
|
||||
expect(saved).toEqual(["host:a.com", "host:b.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list's autocomplete", () => {
|
||||
const source = filterCompletionSource(
|
||||
decryptFilterOptions({
|
||||
hosts: ["api.example.com", "www.example.org"],
|
||||
apps: [
|
||||
{ name: "Google Chrome", bundleId: "com.google.Chrome" },
|
||||
{ name: "curl", bundleId: null },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
function complete(doc: string) {
|
||||
const state = EditorState.create({ doc });
|
||||
const result = source(new CompletionContext(state, doc.length, true));
|
||||
if (result == null || result instanceof Promise) return null;
|
||||
return { from: result.from, labels: result.options.map((o) => o.label) };
|
||||
}
|
||||
|
||||
test("an empty input offers host:, app: and !", () => {
|
||||
expect(complete("")).toEqual({ from: 0, labels: ["host", "app", "!"] });
|
||||
expect(complete("ho")).toEqual({ from: 0, labels: ["host", "app", "!"] });
|
||||
});
|
||||
|
||||
test("after ! it offers the fields negated", () => {
|
||||
expect(complete("!")).toEqual({ from: 0, labels: ["!host", "!app"] });
|
||||
expect(complete("!ap")).toEqual({ from: 0, labels: ["!host", "!app"] });
|
||||
});
|
||||
|
||||
test("after host: the hosts seen, replacing only the value", () => {
|
||||
expect(complete("host:")).toEqual({ from: 5, labels: ["api.example.com", "www.example.org"] });
|
||||
expect(complete("!host:api")?.from).toBe(6);
|
||||
});
|
||||
|
||||
test("after app: the apps seen, by name and bundle id", () => {
|
||||
expect(complete("app:")).toEqual({
|
||||
from: 4,
|
||||
labels: ['"Google Chrome"', "com.google.Chrome", "curl"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
|
||||
import type { FilterOptions } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
/**
|
||||
* The decrypt list as the engine reads it: `host:` and `app:` entries, `*` for everything, any
|
||||
* of them after `!` to exclude. `DecryptEntry` and `DecryptPolicy` in `crates/yaak-proxy`, which
|
||||
* decide each connection, follow the same rules; this copy marks the sidebar and the detail.
|
||||
*/
|
||||
export const EVERYTHING = "*";
|
||||
|
||||
export interface ParsedEntry {
|
||||
negated: boolean;
|
||||
kind: "everything" | "host" | "app";
|
||||
/** A host pattern, lowercased, or an app's name or bundle id as written */
|
||||
pattern: string;
|
||||
/** The port a host entry is limited to */
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry as typed: with its prefix, or a bare hostname, which is a `host:` entry. A URL is
|
||||
* taken as its host. Null when it is not one.
|
||||
*/
|
||||
export function parseEntry(input: string): ParsedEntry | null {
|
||||
let rest = input.trim();
|
||||
const negated = rest.startsWith("!");
|
||||
if (negated) rest = rest.slice(1).trimStart();
|
||||
if (rest === "") return null;
|
||||
if (rest === EVERYTHING) return { negated, kind: "everything", pattern: "", port: null };
|
||||
if (/^app:/i.test(rest)) {
|
||||
const app = unquote(rest.slice(4).trim());
|
||||
return app === "" ? null : { negated, kind: "app", pattern: app, port: null };
|
||||
}
|
||||
const host = unquote((/^host:/i.test(rest) ? rest.slice(5) : rest).trim());
|
||||
const parsed = parseHost(host);
|
||||
return parsed == null ? null : { negated, kind: "host", ...parsed };
|
||||
}
|
||||
|
||||
function unquote(s: string): string {
|
||||
return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).trim() : s;
|
||||
}
|
||||
|
||||
function parseHost(input: string): { pattern: string; port: number | null } | null {
|
||||
let host = input;
|
||||
const scheme = host.indexOf("://");
|
||||
if (scheme !== -1) {
|
||||
host = host.slice(scheme + 3).split(/[/?#]/)[0] ?? "";
|
||||
host = host.slice(host.lastIndexOf("@") + 1);
|
||||
}
|
||||
let pattern: string;
|
||||
let port: number | null = null;
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
if (end === -1) return null;
|
||||
pattern = host.slice(1, end);
|
||||
const after = host.slice(end + 1);
|
||||
if (after !== "") {
|
||||
if (!after.startsWith(":")) return null;
|
||||
port = parsePort(after.slice(1));
|
||||
if (port == null) return null;
|
||||
}
|
||||
} else {
|
||||
const colon = host.lastIndexOf(":");
|
||||
const name = colon === -1 ? host : host.slice(0, colon);
|
||||
// One colon is a port; more, a bare IPv6 address.
|
||||
if (colon !== -1 && !name.includes(":")) {
|
||||
port = parsePort(host.slice(colon + 1));
|
||||
if (port == null) return null;
|
||||
pattern = name;
|
||||
} else {
|
||||
pattern = host;
|
||||
}
|
||||
}
|
||||
pattern = pattern.replace(/\.+$/, "").toLowerCase();
|
||||
if (pattern === "" || !/^[a-z0-9\-._*:]+$/.test(pattern)) return null;
|
||||
return { pattern, port };
|
||||
}
|
||||
|
||||
function parsePort(s: string): number | null {
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
return n <= 65535 ? n : null;
|
||||
}
|
||||
|
||||
/** The entry as stored: `host:api.example.com`, `!app:Slack`, `*` */
|
||||
export function formatEntry(e: ParsedEntry): string {
|
||||
const not = e.negated ? "!" : "";
|
||||
if (e.kind === "everything") return `${not}${EVERYTHING}`;
|
||||
if (e.kind === "app") return `${not}app:${e.pattern}`;
|
||||
if (e.port == null) return `${not}host:${e.pattern}`;
|
||||
const host = e.pattern.includes(":") ? `[${e.pattern}]` : e.pattern;
|
||||
return `${not}host:${host}:${e.port}`;
|
||||
}
|
||||
|
||||
/** An entry as typed, normalized as the engine stores it, or null when it is not one */
|
||||
export function normalizeEntry(input: string): string | null {
|
||||
const parsed = parseEntry(input);
|
||||
return parsed == null ? null : formatEntry(parsed);
|
||||
}
|
||||
|
||||
/** How the list names an entry: `*` is Everything, anything else as written */
|
||||
export function entryLabel(entry: string): string {
|
||||
if (entry === EVERYTHING) return "Everything";
|
||||
if (entry === `!${EVERYTHING}`) return "Nothing";
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** What a connection is: where it goes and, when known, the app that sent it */
|
||||
export interface DecryptQuery {
|
||||
host: string;
|
||||
port?: number | null;
|
||||
app?: { name: string; bundleId: string | null } | null;
|
||||
}
|
||||
|
||||
function glob(pattern: string, text: string): boolean {
|
||||
let pi = 0;
|
||||
let ti = 0;
|
||||
let star: [number, number] | null = null;
|
||||
while (ti < text.length) {
|
||||
if (pi < pattern.length && pattern[pi] === "*") {
|
||||
star = [pi, ti];
|
||||
pi++;
|
||||
} else if (pi < pattern.length && pattern[pi] === text[ti]) {
|
||||
pi++;
|
||||
ti++;
|
||||
} else if (star != null) {
|
||||
pi = star[0] + 1;
|
||||
ti = star[1] + 1;
|
||||
star = [star[0], star[1] + 1];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return pattern
|
||||
.slice(pi)
|
||||
.split("")
|
||||
.every((c) => c === "*");
|
||||
}
|
||||
|
||||
/** `*.example.com` matches `example.com` too, as the search's `@host` does */
|
||||
function hostMatches(pattern: string, host: string): boolean {
|
||||
const h = host
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.replace(/\.+$/, "")
|
||||
.toLowerCase();
|
||||
return glob(pattern, h) || (pattern.startsWith("*.") && glob(pattern.slice(2), h));
|
||||
}
|
||||
|
||||
function matches(e: ParsedEntry, q: DecryptQuery): boolean {
|
||||
switch (e.kind) {
|
||||
case "everything":
|
||||
return true;
|
||||
case "host":
|
||||
return (e.port == null || e.port === (q.port ?? 443)) && hostMatches(e.pattern, q.host);
|
||||
case "app": {
|
||||
if (q.app == null) return false;
|
||||
const p = e.pattern.toLowerCase();
|
||||
return (
|
||||
glob(p, q.app.name.toLowerCase()) ||
|
||||
(q.app.bundleId != null && glob(p, q.app.bundleId.toLowerCase()))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function specificity(e: ParsedEntry): number {
|
||||
if (e.kind === "everything") return 0;
|
||||
if (e.kind === "app") return Number.POSITIVE_INFINITY;
|
||||
// A site's `*.` counts for nothing, as `*.apple.com` matches `apple.com` itself.
|
||||
const pattern = e.pattern.startsWith("*.") ? e.pattern.slice(2) : e.pattern;
|
||||
return pattern.replaceAll("*", "").length + (e.port == null ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the list decrypts `q`: a positive entry matches and no negative one does, wherever
|
||||
* each sits. A built-in exclusion yields to a positive entry at least as specific as itself.
|
||||
*/
|
||||
export function decrypts(list: DecryptListEntry[], q: DecryptQuery): boolean {
|
||||
const parsed = list.flatMap((e) => {
|
||||
const p = parseEntry(e.entry);
|
||||
return p == null ? [] : [{ ...p, builtIn: e.builtIn }];
|
||||
});
|
||||
let best = -1;
|
||||
for (const e of parsed) {
|
||||
if (!e.negated && matches(e, q)) best = Math.max(best, specificity(e));
|
||||
}
|
||||
if (best < 0) return false;
|
||||
return !parsed.some((e) => e.negated && matches(e, q) && (!e.builtIn || specificity(e) > best));
|
||||
}
|
||||
|
||||
/** Some entry decrypts something: the list is not only exclusions */
|
||||
export function decryptsAnything(list: DecryptListEntry[]): boolean {
|
||||
return list.some((e) => !e.entry.trim().startsWith("!"));
|
||||
}
|
||||
|
||||
/** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */
|
||||
export type DecryptSubject =
|
||||
| { kind: "host"; host: string }
|
||||
| { kind: "site"; site: string }
|
||||
| { kind: "app"; name: string; bundleId: string | null };
|
||||
|
||||
/** The entry that names `subject` alone: `host:api.example.com`, `host:*.example.com`, `app:Slack` */
|
||||
export function subjectEntry(subject: DecryptSubject): string {
|
||||
switch (subject.kind) {
|
||||
case "host":
|
||||
return normalizeEntry(`host:${subject.host}`) ?? `host:${subject.host}`;
|
||||
case "site":
|
||||
return `host:*.${subject.site.toLowerCase()}`;
|
||||
case "app":
|
||||
return `app:${subject.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The connection a subject stands for, as the list judges it */
|
||||
export function subjectQuery(subject: DecryptSubject): DecryptQuery {
|
||||
switch (subject.kind) {
|
||||
case "host": {
|
||||
const parsed = parseHost(subject.host);
|
||||
return { host: parsed?.pattern ?? subject.host, port: parsed?.port ?? 443 };
|
||||
}
|
||||
case "site":
|
||||
return { host: subject.site };
|
||||
case "app":
|
||||
// No host matches an empty one, so only `*` and app entries decide.
|
||||
return { host: "", app: { name: subject.name, bundleId: subject.bundleId } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An exclusion of the user's that matches `q`, which no entry added beats: Decrypt cannot help
|
||||
* until it is edited. Null when there is none.
|
||||
*/
|
||||
export function blockingExclusion(
|
||||
list: DecryptListEntry[],
|
||||
q: DecryptQuery,
|
||||
builtIns = false,
|
||||
): string | null {
|
||||
for (const e of list) {
|
||||
if (e.builtIn && !builtIns) continue;
|
||||
const parsed = parseEntry(e.entry);
|
||||
if (parsed?.negated && matches(parsed, q)) return e.entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What keeps Decrypt on `subject` from working: an exclusion of the user's that matches it,
|
||||
* other than its own exact one, which Decrypt removes. Null when Decrypt works.
|
||||
*/
|
||||
export function blockerOf(list: DecryptListEntry[], subjects: DecryptSubject[]): string | null {
|
||||
const own = new Set(subjects.map((s) => `!${subjectEntry(s)}`));
|
||||
const rest = list.filter((e) => !own.has(e.entry));
|
||||
for (const subject of subjects) {
|
||||
const blocker = blockingExclusion(rest, subjectQuery(subject));
|
||||
if (blocker != null) return blocker;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What the Not decrypted detail offers for one tunnelled connection */
|
||||
export interface DetailActions {
|
||||
/** The connection is decrypted from the next one on already */
|
||||
listed: boolean;
|
||||
/** Decrypt Host would decrypt it */
|
||||
host: boolean;
|
||||
/** Decrypt App would decrypt it */
|
||||
app: boolean;
|
||||
/** Neither would: the exclusion that keeps it out */
|
||||
blocked: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each button judged on its own: whether decrypting the host, or the app, would decrypt this
|
||||
* connection, with its host, port and app together. A button whose change something else
|
||||
* still excludes is not offered; with neither, the exclusion is named.
|
||||
*/
|
||||
export function detailActions(
|
||||
list: DecryptListEntry[],
|
||||
host: string,
|
||||
app: { name: string; bundleId: string | null } | null,
|
||||
): DetailActions {
|
||||
const hostSubject: DecryptSubject = { kind: "host", host };
|
||||
const q = { ...subjectQuery(hostSubject), app };
|
||||
if (decrypts(list, q)) return { listed: true, host: false, app: false, blocked: null };
|
||||
const works = (subject: DecryptSubject) => decrypts(setDecrypted(list, [subject], true), q);
|
||||
const hostWorks = works(hostSubject);
|
||||
const appWorks = app != null && works({ kind: "app", ...app });
|
||||
const blocked =
|
||||
hostWorks || appWorks ? null : (blockingExclusion(list, q) ?? blockingExclusion(list, q, true));
|
||||
return { listed: false, host: hostWorks, app: appWorks, blocked };
|
||||
}
|
||||
|
||||
/** The list decrypts `subject`'s connections, as its sidebar row marks it */
|
||||
export function isDecrypted(list: DecryptListEntry[], subject: DecryptSubject): boolean {
|
||||
return decrypts(list, subjectQuery(subject));
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries after Decrypt or Stop Decrypting on `subject`, to save whole. Decrypt drops an
|
||||
* exclusion of the subject the user added, then adds its entry if it is still not decrypted.
|
||||
* Stop drops its entry, then excludes it if something broader, `*` say, still decrypts it.
|
||||
*/
|
||||
export function toggleDecrypt(list: DecryptListEntry[], subject: DecryptSubject): string[] {
|
||||
return setDecrypted(list, [subject], !isDecrypted(list, subject)).map((e) => e.entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list with every one of `subjects` decrypted (`on`) or not, each as [`toggleDecrypt`]
|
||||
* changes it, those already so left alone: several sidebar rows at once.
|
||||
*/
|
||||
export function setDecrypted(
|
||||
list: DecryptListEntry[],
|
||||
subjects: DecryptSubject[],
|
||||
on: boolean,
|
||||
): DecryptListEntry[] {
|
||||
let next = list;
|
||||
for (const subject of subjects) {
|
||||
if (isDecrypted(next, subject) === on) continue;
|
||||
const entry = subjectEntry(subject);
|
||||
const drop = on ? `!${entry}` : entry;
|
||||
next = next.filter((e) => e.builtIn || e.entry !== drop);
|
||||
if (isDecrypted(next, subject) === on) continue;
|
||||
// Something the user excluded more broadly still keeps it out; adding would change nothing.
|
||||
if (on && blockingExclusion(next, subjectQuery(subject)) != null) continue;
|
||||
const add = on ? entry : `!${entry}`;
|
||||
next = [...next.filter((e) => !e.builtIn), { entry: add, builtIn: false }, ...builtIns(next)];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar menu's row for the selected rows that can be decrypted: Stop Decrypting when every
|
||||
* one of them is, else Decrypt. Null when none can.
|
||||
*/
|
||||
export function decryptMenuAction(
|
||||
list: DecryptListEntry[],
|
||||
subjects: DecryptSubject[],
|
||||
): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null {
|
||||
if (subjects.length === 0) return null;
|
||||
const on = !subjects.every((s) => isDecrypted(list, s));
|
||||
return { label: on ? "Decrypt" : "Stop Decrypting", on };
|
||||
}
|
||||
|
||||
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
|
||||
return list.filter((e) => e.builtIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* What turns `before` into `after`, as `edit_decrypt_list` takes it: the entries to add and to
|
||||
* remove, applied to the list as it is when they land, so edits made together each keep the
|
||||
* other's.
|
||||
*/
|
||||
export function listEdit(
|
||||
before: DecryptListEntry[],
|
||||
after: DecryptListEntry[],
|
||||
): { add: string[]; remove: string[] } {
|
||||
const was = new Set(before.map((e) => e.entry));
|
||||
const now = new Set(after.map((e) => e.entry));
|
||||
return {
|
||||
add: after.filter((e) => !was.has(e.entry)).map((e) => e.entry),
|
||||
remove: before.filter((e) => !now.has(e.entry)).map((e) => e.entry),
|
||||
};
|
||||
}
|
||||
|
||||
/** The edit that adds `*`, or takes it out, as the first-run card's choice makes it */
|
||||
export function everythingEdit(on: boolean): { add: string[]; remove: string[] } {
|
||||
return on ? { add: [EVERYTHING], remove: [] } : { add: [], remove: [EVERYTHING] };
|
||||
}
|
||||
|
||||
/** Whether the list decrypts everything, as the first-run card's choice reads it */
|
||||
export function decryptsEverything(list: DecryptListEntry[]): boolean {
|
||||
return list.some((e) => e.entry === EVERYTHING);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the list's input suggests: `host:`, `app:` and `!` to start an entry; after `host:` the
|
||||
* hosts seen in the capture, after `app:` the apps seen, by name and bundle id.
|
||||
*/
|
||||
export function decryptFilterOptions(seen: SeenTargets | null | undefined): FilterOptions {
|
||||
const apps = new Set<string>();
|
||||
for (const app of seen?.apps ?? []) {
|
||||
apps.add(app.name);
|
||||
if (app.bundleId != null) apps.add(app.bundleId);
|
||||
}
|
||||
return {
|
||||
fieldPrefix: "",
|
||||
replaceValueOnly: true,
|
||||
negation: { prefix: "!", info: "Exclude" },
|
||||
fields: [
|
||||
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.site" },
|
||||
{ name: "app", values: [...apps], info: "App name or bundle id" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { setupIncomplete } from "./firstRun";
|
||||
import { decryptStep, setupIncomplete } from "./firstRun";
|
||||
|
||||
describe("setupIncomplete", () => {
|
||||
const done = { trusted: true, systemProxySupported: true, systemProxyEverOn: true };
|
||||
@@ -25,3 +25,21 @@ describe("setupIncomplete", () => {
|
||||
expect(setupIncomplete({ ...done, systemProxySupported: null, trusted: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptStep", () => {
|
||||
const builtIns = [{ entry: "!host:*.apple.com", builtIn: true }];
|
||||
|
||||
test("picks hosts as they appear by default, after the certificate", () => {
|
||||
expect(decryptStep(builtIns, false)).toEqual({ choice: "pick", state: "todo" });
|
||||
expect(decryptStep(builtIns, true)).toEqual({ choice: "pick", state: "next" });
|
||||
});
|
||||
|
||||
test("is done once everything, or a host, is on the list", () => {
|
||||
const everything = [{ entry: "*", builtIn: false }, ...builtIns];
|
||||
expect(decryptStep(everything, true)).toEqual({ choice: "everything", state: "done" });
|
||||
const host = [{ entry: "host:api.example.com", builtIn: false }];
|
||||
expect(decryptStep(host, true)).toEqual({ choice: "pick", state: "done" });
|
||||
// An exclusion alone decrypts nothing.
|
||||
expect(decryptStep([{ entry: "!host:a.com", builtIn: false }], true).state).toBe("next");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
|
||||
import { decryptsEverything } from "./decrypt";
|
||||
|
||||
/** What decides whether setup is done, as the app last read it */
|
||||
export interface SetupState {
|
||||
/** The certificate is trusted, or null until read */
|
||||
@@ -19,3 +22,21 @@ export function setupIncomplete(s: SetupState): boolean {
|
||||
if (!s.trusted) return true;
|
||||
return s.systemProxySupported && !s.systemProxyEverOn;
|
||||
}
|
||||
|
||||
/** The first-run card's "Choose what to decrypt" step, as the decrypt list has it */
|
||||
export interface DecryptStep {
|
||||
/** Everything is decrypted, `*` on the list; else hosts are picked as they appear */
|
||||
choice: "everything" | "pick";
|
||||
state: "todo" | "next" | "done";
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking hosts as they appear is the default and needs nothing done, so the step waits on the
|
||||
* certificate like the others and is done once a choice is on the list: everything, or any host
|
||||
* or app picked already.
|
||||
*/
|
||||
export function decryptStep(list: DecryptListEntry[], trusted: boolean): DecryptStep {
|
||||
const choice = decryptsEverything(list) ? "everything" : "pick";
|
||||
const picked = list.some((e) => !e.builtIn && !e.entry.startsWith("!"));
|
||||
return { choice, state: picked ? "done" : trusted ? "next" : "todo" };
|
||||
}
|
||||
@@ -58,6 +58,7 @@ const flow: Flow = {
|
||||
grpcMethod: null,
|
||||
grpcStatus: null,
|
||||
grpcMessage: null,
|
||||
tunnelled: false,
|
||||
};
|
||||
|
||||
function bytes(text: string): FlowBodyData {
|
||||
|
||||
@@ -229,6 +229,16 @@ describe("parseSearch", () => {
|
||||
expect(parseSearch("@is:system", true)).toEqual({ system: true });
|
||||
});
|
||||
|
||||
test("keeps or drops HTTPS passed through undecrypted", () => {
|
||||
expect(parseSearch("@is:tunnelled")).toEqual({ tunnelled: true });
|
||||
expect(parseSearch("-@is:tunnelled stripe")).toEqual({ q: "stripe", tunnelled: false });
|
||||
const tunnel = { url: "https://a.com", method: "CONNECT", tunnelled: true } as Flow;
|
||||
const decrypted = { url: "https://a.com/x", method: "GET", tunnelled: false } as Flow;
|
||||
expect(matchesQuery(tunnel, { tunnelled: true })).toBe(true);
|
||||
expect(matchesQuery(decrypted, { tunnelled: true })).toBe(false);
|
||||
expect(matchesQuery(decrypted, { tunnelled: false })).toBe(true);
|
||||
});
|
||||
|
||||
test("round-trips what the sidebar writes", () => {
|
||||
const text = [
|
||||
formatFieldFilter("app", "Google Chrome"),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getDomain } from "tldts";
|
||||
* A search over every flow in the database: the search box's terms, its field terms, and
|
||||
* whether System's flows are hidden, as `list_flows` takes them.
|
||||
*/
|
||||
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app" | "system" | "any">;
|
||||
export type FlowQuery = Pick<FlowFilter, "q" | "under" | "app" | "system" | "tunnelled" | "any">;
|
||||
|
||||
/** A word of the search box: a free term, or `@field:value`, maybe negated with `-` */
|
||||
export interface SearchToken {
|
||||
@@ -56,7 +56,8 @@ export function formatFieldFilter(field: string, value: string, negated = false)
|
||||
* not every pairing of their apps and hosts. Within a clause, `@app` terms match any of their
|
||||
* apps; `@host` terms any of their hosts, each with the `@path` terms after it; `@is:system`
|
||||
* keeps only macOS's daemons and `-@is:system` drops them, as hiding System does when the
|
||||
* clause says neither. Other words match the URL, the method, the status or the type.
|
||||
* clause says neither. `@is:tunnelled` keeps only HTTPS passed through undecrypted, and
|
||||
* `-@is:tunnelled` drops it. Other words match the URL, the method, the status or the type.
|
||||
*/
|
||||
export function parseSearch(text: string, hideSystem = false): FlowQuery | null {
|
||||
const clauses = clausesOf(text);
|
||||
@@ -77,6 +78,7 @@ function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | nu
|
||||
// Each host with the paths written after it.
|
||||
const hosts: { host: string; paths: string[] }[] = [];
|
||||
let system: boolean | undefined = hideSystem ? false : undefined;
|
||||
let tunnelled: boolean | undefined;
|
||||
for (const t of tokens) {
|
||||
if (t.field === "app" && !t.negated && t.value !== "") apps.push(t.value);
|
||||
else if (t.field === "host" && !t.negated && t.value !== "") {
|
||||
@@ -91,6 +93,7 @@ function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | nu
|
||||
host.paths.push(path);
|
||||
}
|
||||
} else if (t.field === "is" && t.value.toLowerCase() === "system") system = !t.negated;
|
||||
else if (t.field === "is" && t.value.toLowerCase() === "tunnelled") tunnelled = !t.negated;
|
||||
else if (t.field == null && !t.negated) words.push(t.value);
|
||||
// Fields and exclusions the query language will add are ignored until it lands.
|
||||
}
|
||||
@@ -105,6 +108,7 @@ function parseClause(tokens: SearchToken[], hideSystem: boolean): FlowQuery | nu
|
||||
...(under.length > 0 ? { under } : {}),
|
||||
...(apps.length > 0 ? { app: apps } : {}),
|
||||
...(system == null ? {} : { system }),
|
||||
...(tunnelled == null ? {} : { tunnelled }),
|
||||
};
|
||||
return Object.keys(query).length === 0 ? null : query;
|
||||
}
|
||||
@@ -190,6 +194,7 @@ export function matchesQuery(flow: Flow, query: FlowQuery): boolean {
|
||||
if (query.under != null && !query.under.some((node) => isUnder(flow.url, node))) return false;
|
||||
if (query.app != null && !query.app.some((app) => matchesApp(flow, app))) return false;
|
||||
if (query.system != null && isSystemFlow(flow) !== query.system) return false;
|
||||
if (query.tunnelled != null && flow.tunnelled !== query.tunnelled) return false;
|
||||
// One of the groups, each judged whole, as the database judges them.
|
||||
if (query.any != null && query.any.length > 0) {
|
||||
return query.any.some((group) => matchesQuery(flow, group));
|
||||
|
||||
@@ -103,6 +103,44 @@ describe("buildSidebarTree", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypting from the sidebar", () => {
|
||||
const tunnel = flow("https://bank.example.com", {
|
||||
...slack,
|
||||
method: "CONNECT",
|
||||
tunnelled: true,
|
||||
});
|
||||
const root = buildSidebarTree([...flows, tunnel], 5);
|
||||
const items = itemsById(root);
|
||||
|
||||
test("Not decrypted follows All traffic while there are tunnelled flows", () => {
|
||||
expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([
|
||||
"All traffic 5",
|
||||
"Not decrypted 1",
|
||||
]);
|
||||
expect(items.get("tunnelled")?.query).toBe("@is:tunnelled");
|
||||
expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false);
|
||||
});
|
||||
|
||||
test("hosts, sites and apps name what their menu's Decrypt covers", () => {
|
||||
expect(items.get("host:bank.example.com")?.decrypt).toEqual({
|
||||
kind: "host",
|
||||
host: "bank.example.com",
|
||||
});
|
||||
expect(items.get("site:example.com")?.decrypt).toEqual({ kind: "site", site: "example.com" });
|
||||
expect(items.get("app:Slack|host:edgeapi.slack.com")?.decrypt).toEqual({
|
||||
kind: "host",
|
||||
host: "edgeapi.slack.com",
|
||||
});
|
||||
expect(items.get("app:Slack")?.decrypt).toEqual({
|
||||
kind: "app",
|
||||
name: "Slack",
|
||||
bundleId: "com.slack",
|
||||
});
|
||||
expect(items.get("path:api.github.com/repos")?.decrypt).toBeUndefined();
|
||||
expect(items.get("domains")?.decrypt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and the search text", () => {
|
||||
test("selects rows under different apps as groups of their own", () => {
|
||||
const items = itemsById(buildSidebarTree(flows, 4));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||
import type { DecryptSubject } from "./decrypt";
|
||||
import {
|
||||
appKeyOf,
|
||||
folderOf,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
tokenize,
|
||||
} from "./search";
|
||||
|
||||
export type SidebarKind = "all" | "group" | "app" | "site" | "host" | "path";
|
||||
export type SidebarKind = "all" | "tunnelled" | "group" | "app" | "site" | "host" | "path";
|
||||
|
||||
/** A row of the sidebar: All traffic, a group, an app, or a site, host or path under one */
|
||||
export interface SidebarItem {
|
||||
@@ -27,6 +28,8 @@ export interface SidebarItem {
|
||||
appPath?: string;
|
||||
/** What selecting the row writes into the search box; empty for all traffic */
|
||||
query: string;
|
||||
/** What Decrypt in the row's menu decrypts: its host, site or app */
|
||||
decrypt?: DecryptSubject;
|
||||
}
|
||||
|
||||
/** The ids of All traffic and the groups */
|
||||
@@ -34,6 +37,7 @@ export const ALL_ID = "all";
|
||||
export const APPS_ID = "apps";
|
||||
export const DOMAINS_ID = "domains";
|
||||
export const SYSTEM_ID = "system";
|
||||
export const TUNNELLED_ID = "tunnelled";
|
||||
|
||||
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
|
||||
interface PlaceRow {
|
||||
@@ -41,6 +45,7 @@ interface PlaceRow {
|
||||
kind: "site" | "host" | "path";
|
||||
label: string;
|
||||
query: string;
|
||||
decrypt?: DecryptSubject;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,8 +77,15 @@ function placeOf(flow: Flow): Place {
|
||||
kind: "site",
|
||||
label: site,
|
||||
query: formatFieldFilter("host", `*.${site}`),
|
||||
decrypt: { kind: "site", site },
|
||||
},
|
||||
{
|
||||
id: `host:${host}`,
|
||||
kind: "host",
|
||||
label: host,
|
||||
query: hostQuery,
|
||||
decrypt: { kind: "host", host },
|
||||
},
|
||||
{ id: `host:${host}`, kind: "host", label: host, query: hostQuery },
|
||||
];
|
||||
let p = "";
|
||||
for (const segment of segments) {
|
||||
@@ -121,6 +133,7 @@ function child(parent: Trie, row: PlaceRow): Trie {
|
||||
item: { id: row.id, kind: row.kind, label: row.label, count: 0, query: row.query },
|
||||
children: new Map(),
|
||||
};
|
||||
if (row.decrypt != null) node.item.decrypt = row.decrypt;
|
||||
parent.children.set(row.id, node);
|
||||
}
|
||||
node.item.count++;
|
||||
@@ -162,7 +175,8 @@ function group(id: string, label: string): Trie {
|
||||
/**
|
||||
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
|
||||
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
|
||||
* nothing in it is left out. `total` is All traffic's count: every flow it covers in the
|
||||
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
|
||||
* undecrypted, when there is any. `total` is All traffic's count: every flow it covers in the
|
||||
* database.
|
||||
*/
|
||||
export function buildSidebarTree(flows: Flow[], total: number): TreeNode<SidebarItem> {
|
||||
@@ -177,9 +191,11 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
|
||||
const domains = group(DOMAINS_ID, "Domains");
|
||||
const system = { ...group(SYSTEM_ID, "System") };
|
||||
system.item.query = formatFieldFilter("is", "system");
|
||||
let tunnelled = 0;
|
||||
|
||||
for (const flow of flows) {
|
||||
const place = placeOf(flow);
|
||||
if (flow.tunnelled) tunnelled++;
|
||||
domains.item.count++;
|
||||
addPlace(domains, place.rows);
|
||||
if (place.app == null) continue;
|
||||
@@ -195,6 +211,9 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
|
||||
count: 0,
|
||||
query: appQueryFor(place.app, place.system),
|
||||
};
|
||||
if (flow.clientName != null) {
|
||||
item.decrypt = { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
|
||||
}
|
||||
if (folder != null) item.sublabel = folder;
|
||||
if (flow.clientPath != null) item.appPath = flow.clientPath;
|
||||
app = { item, children: new Map() };
|
||||
@@ -214,6 +233,20 @@ export function buildSidebarTree(flows: Flow[], total: number): TreeNode<Sidebar
|
||||
depth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
if (tunnelled > 0) {
|
||||
root.children?.push({
|
||||
item: {
|
||||
id: TUNNELLED_ID,
|
||||
kind: "tunnelled",
|
||||
label: "Not decrypted",
|
||||
count: tunnelled,
|
||||
query: formatFieldFilter("is", "tunnelled"),
|
||||
},
|
||||
parent: root,
|
||||
depth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
}
|
||||
for (const g of [apps, domains, system]) {
|
||||
if (g.children.size === 0) continue;
|
||||
const node: TreeNode<SidebarItem> = {
|
||||
|
||||
+7
-1
@@ -104,7 +104,13 @@ grpcStatus: number | null,
|
||||
/**
|
||||
* A gRPC call's `grpc-message`, decoded.
|
||||
*/
|
||||
grpcMessage: string | null, };
|
||||
grpcMessage: string | null,
|
||||
/**
|
||||
* An HTTPS connection passed through undecrypted, as the decrypt list leaves it out or its
|
||||
* client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes
|
||||
* the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
|
||||
*/
|
||||
tunnelled: boolean, };
|
||||
|
||||
export type FlowBody = "request" | "response";
|
||||
|
||||
|
||||
+62
-2
@@ -87,10 +87,40 @@ schema: GrpcSchema | null,
|
||||
*/
|
||||
note: string | null, };
|
||||
|
||||
/**
|
||||
* What HTTPS is decrypted: the user's entries in order, then the built-in exclusions not
|
||||
* removed. A connection is decrypted when a positive entry matches it and no exclusion does.
|
||||
*/
|
||||
export type DecryptList = { entries: Array<DecryptListEntry>, };
|
||||
|
||||
/**
|
||||
* The decrypt list changed, here or through the control API.
|
||||
*/
|
||||
export type DecryptListChangedPayload = { list: DecryptList, };
|
||||
|
||||
/**
|
||||
* One entry of the decrypt list, as the app shows it.
|
||||
*/
|
||||
export type DecryptListEntry = {
|
||||
/**
|
||||
* As stored: `host:*.example.com`, `app:Google Chrome`, `!host:*.apple.com`, `*`.
|
||||
*/
|
||||
entry: string,
|
||||
/**
|
||||
* Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
|
||||
*/
|
||||
builtIn: boolean, };
|
||||
|
||||
export type DropReason = "hop_by_hop" | "set_by_sending" | "body_decoded";
|
||||
|
||||
export type DroppedHeader = { name: string, value: string, reason: DropReason, };
|
||||
|
||||
/**
|
||||
* Entries to add after the user's own and to remove, applied to the list as it is when the
|
||||
* change lands. Each is normalized first.
|
||||
*/
|
||||
export type EditDecryptListRequest = { add: Array<string>, remove: Array<string>, };
|
||||
|
||||
export type ExportHarRequest = {
|
||||
/**
|
||||
* The file to write.
|
||||
@@ -145,6 +175,10 @@ app?: Array<string>,
|
||||
* tells them by their executable.
|
||||
*/
|
||||
system?: boolean,
|
||||
/**
|
||||
* Only HTTPS connections tunnelled undecrypted (true), or none of them (false).
|
||||
*/
|
||||
tunnelled?: boolean,
|
||||
/**
|
||||
* Only flows one of these groups covers, each judged on its own: several sidebar rows
|
||||
* selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every
|
||||
@@ -186,6 +220,8 @@ folderId: string | null, };
|
||||
|
||||
export type FormatBodyRequest = { text: string, };
|
||||
|
||||
export type GetDecryptListRequest = Record<string, never>;
|
||||
|
||||
export type GetFlowBodyRequest = { flowId: string, which: FlowBody, };
|
||||
|
||||
export type GetFlowBodyResponse = {
|
||||
@@ -319,6 +355,8 @@ messages: Array<FlowMessage>,
|
||||
*/
|
||||
total: number, };
|
||||
|
||||
export type ListSeenRequest = Record<string, never>;
|
||||
|
||||
export type ListWorkspacesRequest = Record<string, never>;
|
||||
|
||||
export type ListWorkspacesResponse = {
|
||||
@@ -374,9 +412,9 @@ host: string, };
|
||||
|
||||
export type RemoveGrpcSchemaRequest = { id: string, };
|
||||
|
||||
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||
|
||||
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
|
||||
/**
|
||||
@@ -440,6 +478,21 @@ authorization: boolean, };
|
||||
|
||||
export type SavedFlow = { requestId: string, name: string, };
|
||||
|
||||
export type SeenApp = { name: string, bundleId: string | null, };
|
||||
|
||||
/**
|
||||
* The hosts and apps flows have gone to and come from, for the decrypt list's suggestions.
|
||||
*/
|
||||
export type SeenTargets = {
|
||||
/**
|
||||
* Hosts as the sidebar names them, with a port other than the scheme's: most recent first.
|
||||
*/
|
||||
hosts: Array<string>,
|
||||
/**
|
||||
* Apps by name and bundle id, most recent first.
|
||||
*/
|
||||
apps: Array<SeenApp>, };
|
||||
|
||||
export type ServiceProxy = {
|
||||
/**
|
||||
* The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN".
|
||||
@@ -451,6 +504,13 @@ name: string,
|
||||
*/
|
||||
id?: string, http: ProxySetting, https: ProxySetting, };
|
||||
|
||||
export type SetDecryptListRequest = {
|
||||
/**
|
||||
* Every entry, in order, as typed: a bare hostname is a `host:` entry. A built-in
|
||||
* exclusion left out is removed.
|
||||
*/
|
||||
entries: Array<string>, };
|
||||
|
||||
export type SetHttpVersionRequest = { httpVersion: HttpVersion, };
|
||||
|
||||
export type SetRecordingRequest = { enabled: boolean, };
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- An HTTPS connection passed through undecrypted, recorded with only its host and sizes.
|
||||
ALTER TABLE flows ADD COLUMN tunnelled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -5,8 +5,8 @@
|
||||
use crate::bodies::{self, DecodedBody};
|
||||
use crate::ca::CaStatus;
|
||||
use crate::control::{
|
||||
self, ControlInfo, ControlStatus, ErrorBody, NOTE_HEADER, RecordingRequest, StartRequest,
|
||||
SystemProxyRequest, TRUNCATED_HEADER,
|
||||
self, ControlInfo, ControlStatus, DecryptRequest, ErrorBody, NOTE_HEADER, RecordingRequest,
|
||||
StartRequest, SystemProxyRequest, TRUNCATED_HEADER,
|
||||
};
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::db::Stores;
|
||||
@@ -15,7 +15,7 @@ use crate::har::{self, Har};
|
||||
use crate::macos::SystemProxyStatus;
|
||||
use crate::models::{Flow, FlowBody};
|
||||
use crate::save::{SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
|
||||
use crate::settings::ProxySettings;
|
||||
use crate::settings::{DecryptList, ProxySettings};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
@@ -146,6 +146,38 @@ impl ControlClient {
|
||||
self.post("/system-proxy", &SystemProxyRequest { enabled }).await
|
||||
}
|
||||
|
||||
pub async fn decrypt_list(&self) -> Result<DecryptList, String> {
|
||||
self.get("/decrypt").await
|
||||
}
|
||||
|
||||
/// Adds and removes entries in the owner's decrypt list as it is there.
|
||||
pub async fn edit_decrypt_list(
|
||||
&self,
|
||||
edit: &crate::EditDecryptListRequest,
|
||||
) -> Result<DecryptList, String> {
|
||||
let body = serde_json::to_vec(edit).map_err(|e| e.to_string())?;
|
||||
let req = self
|
||||
.http
|
||||
.patch(self.url("/decrypt"))
|
||||
.header("content-type", "application/json")
|
||||
.body(body);
|
||||
let resp = self.send(req).await?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}"))
|
||||
}
|
||||
|
||||
/// Replaces the decrypt list in the owner, which saves it and applies it to its engine.
|
||||
pub async fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
|
||||
let body = serde_json::to_vec(&DecryptRequest { entries: entries.to_vec() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
let req = self
|
||||
.http
|
||||
.put(self.url("/decrypt"))
|
||||
.header("content-type", "application/json")
|
||||
.body(body);
|
||||
let resp = self.send(req).await?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response from the proxy: {e}"))
|
||||
}
|
||||
|
||||
pub async fn workspaces(&self) -> Result<Vec<WorkspaceSummary>, String> {
|
||||
self.get("/workspaces").await
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::messages::{self, FlowMessage, MessageKind};
|
||||
use crate::models::{FlowBody, find_header};
|
||||
use crate::save::SaveFlowRequest;
|
||||
use crate::{
|
||||
AddGrpcSchemaRequest, ListMessagesResponse, ProxyCtx, ProxyState, ReflectGrpcHostRequest,
|
||||
bodies,
|
||||
AddGrpcSchemaRequest, EditDecryptListRequest, ListMessagesResponse, ProxyCtx, ProxyState,
|
||||
ReflectGrpcHostRequest, bodies,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -237,6 +237,13 @@ pub struct RecordingRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `PUT /decrypt`: every entry of the decrypt list, in order. A built-in exclusion left out is
|
||||
/// removed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DecryptRequest {
|
||||
pub entries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClearResponse {
|
||||
/// How many flows were deleted.
|
||||
@@ -527,6 +534,15 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
|
||||
ctx.set_recording(body.enabled);
|
||||
json(&status(ctx).await?)
|
||||
}
|
||||
(&Method::GET, ["decrypt"]) => json(&ctx.decrypt_list()?),
|
||||
(&Method::PATCH, ["decrypt"]) => {
|
||||
let body: EditDecryptListRequest = read_json(req).await?;
|
||||
json(&ctx.edit_decrypt_list(&body.add, &body.remove).map_err(bad_request)?)
|
||||
}
|
||||
(&Method::PUT, ["decrypt"]) => {
|
||||
let body: DecryptRequest = read_json(req).await?;
|
||||
json(&ctx.set_decrypt_list(&body.entries).map_err(bad_request)?)
|
||||
}
|
||||
(&Method::GET, ["flows"]) => {
|
||||
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
|
||||
json(&flows::list(&ctx.db()?, &filter)?)
|
||||
|
||||
@@ -25,6 +25,23 @@ pub const BLOBS_DB: &str = "blobs.sqlite";
|
||||
/// The key both are encrypted with, wrapped by the app's master key in the keychain.
|
||||
pub const DATA_KEY: &str = "data.key";
|
||||
|
||||
/// A pre-release build added `flows.tunnelled` in migration 8. A database it made has the
|
||||
/// column without migration 9 recorded, which would fail adding it again, so it is recorded
|
||||
/// as applied first.
|
||||
fn record_early_tunnelled_column(pool: &SqlitePool) -> Result<()> {
|
||||
let conn = pool.get()?;
|
||||
let has_column = conn.prepare("SELECT tunnelled FROM flows LIMIT 0").is_ok();
|
||||
let has_tracking = conn.prepare("SELECT version FROM _sqlx_migrations LIMIT 0").is_ok();
|
||||
if has_column && has_tracking {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO _sqlx_migrations (version, description) \
|
||||
VALUES ('00000009', '00000009_flow_tunnelled.sql')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads come from a pool; writes go through one connection, as in yaak-models'
|
||||
/// `QueryManager`. WAL lets the CLI read `proxy.db` while the app writes it.
|
||||
#[derive(Clone)]
|
||||
@@ -42,6 +59,7 @@ impl ProxyQueryManager {
|
||||
}
|
||||
encryption::encrypt_in_place(db_path, key, &|_| {})?;
|
||||
let writer = file_pool(db_path, 1, key)?;
|
||||
record_early_tunnelled_column(&writer)?;
|
||||
run_migrations(&writer, &MIGRATIONS)?;
|
||||
Ok(Self { readers: file_pool(db_path, 5, key)?, writer })
|
||||
}
|
||||
@@ -512,7 +530,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.map(|v| v.unwrap())
|
||||
.collect();
|
||||
assert_eq!(&applied[applied.len() - 2..], ["00000007", "00000008"]);
|
||||
assert_eq!(&applied[applied.len() - 3..], ["00000007", "00000008", "00000009"]);
|
||||
let mark_columns = columns(&conn, "flow_clear_mark");
|
||||
assert_eq!(mark_columns, ["id", "max_rowid", "cleaner", "cleaner_at"]);
|
||||
drop(conn);
|
||||
@@ -532,4 +550,109 @@ mod tests {
|
||||
);
|
||||
assert_eq!(crate::messages::count(&db, "fl_ws").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A database a pre-release build made, with `tunnelled` added by its migration 8, opens
|
||||
/// without adding the column twice.
|
||||
#[test]
|
||||
fn opens_a_database_whose_migration_8_added_tunnelled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("proxy.db");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE _sqlx_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
let mut files: Vec<_> = MIGRATIONS
|
||||
.files()
|
||||
.map(|f| (f.path().to_string_lossy().into_owned(), f.contents_utf8().unwrap()))
|
||||
.filter(|(name, _)| name.as_str() < "00000009")
|
||||
.collect();
|
||||
files.sort();
|
||||
for (name, sql) in files {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO _sqlx_migrations (version, description) VALUES (?, ?)",
|
||||
[&name[..8], &name],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE flows ADD COLUMN tunnelled BOOLEAN NOT NULL DEFAULT FALSE;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let db = ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap();
|
||||
let tunnel = crate::models::Flow {
|
||||
id: "fl_tunnel".into(),
|
||||
url: "https://a.com".into(),
|
||||
tunnelled: true,
|
||||
..Default::default()
|
||||
};
|
||||
db.with_tx(|c| c.upsert(&tunnel, &yaak_database::UpdateSource::Background).map(|_| ()))
|
||||
.unwrap();
|
||||
assert!(crate::flows::find(&db, "fl_tunnel").unwrap().tunnelled);
|
||||
drop(db);
|
||||
// And again, with everything recorded.
|
||||
drop(ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap());
|
||||
}
|
||||
|
||||
/// A database that ran every migration up to the clear cleaner's, as the build before
|
||||
/// tunnelled flows left it, takes the `tunnelled` column and stores tunnelled flows.
|
||||
#[test]
|
||||
fn adds_tunnelled_to_a_database_at_migration_8() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("proxy.db");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE _sqlx_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
let mut files: Vec<_> = MIGRATIONS
|
||||
.files()
|
||||
.map(|f| (f.path().to_string_lossy().into_owned(), f.contents_utf8().unwrap()))
|
||||
.filter(|(name, _)| name.as_str() < "00000009")
|
||||
.collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 8);
|
||||
for (name, sql) in files {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO _sqlx_migrations (version, description) VALUES (?, ?)",
|
||||
[&name[..8], &name],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO flows (id, url, method) VALUES ('fl_old', 'https://a.com/', 'GET')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!columns(&conn, "flows").contains(&"tunnelled".to_string()));
|
||||
drop(conn);
|
||||
|
||||
let db = ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap();
|
||||
let tunnel = crate::models::Flow {
|
||||
id: "fl_tunnel".into(),
|
||||
method: "CONNECT".into(),
|
||||
url: "https://bank.example.com".into(),
|
||||
tunnelled: true,
|
||||
..Default::default()
|
||||
};
|
||||
db.with_tx(|c| c.upsert(&tunnel, &yaak_database::UpdateSource::Background).map(|_| ()))
|
||||
.unwrap();
|
||||
let filter = crate::flows::FlowFilter { tunnelled: Some(true), ..Default::default() };
|
||||
let listed = crate::flows::list(&db, &filter).unwrap();
|
||||
assert_eq!(listed.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(), ["fl_tunnel"]);
|
||||
let old = crate::flows::find(&db, "fl_old").unwrap();
|
||||
assert!(!old.tunnelled);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ pub struct FlowFilter {
|
||||
/// tells them by their executable.
|
||||
#[ts(optional)]
|
||||
pub system: Option<bool>,
|
||||
/// Only HTTPS connections tunnelled undecrypted (true), or none of them (false).
|
||||
#[ts(optional)]
|
||||
pub tunnelled: Option<bool>,
|
||||
/// Only flows one of these groups covers, each judged on its own: several sidebar rows
|
||||
/// selected, `@app:Slack @host:a.com OR @app:Chrome @host:b.com`, are two groups and not every
|
||||
/// pairing of their apps and hosts. A group's `after`, `before` and `limit` do not apply. Not
|
||||
@@ -78,6 +81,11 @@ impl FlowFilter {
|
||||
.map(|v| v.parse().map_err(|_| format!("Invalid system {v:?}")))
|
||||
.transpose()?
|
||||
}
|
||||
"tunnelled" => {
|
||||
filter.tunnelled = value
|
||||
.map(|v| v.parse().map_err(|_| format!("Invalid tunnelled {v:?}")))
|
||||
.transpose()?
|
||||
}
|
||||
"method" => filter.method = value,
|
||||
"status" => filter.status = value,
|
||||
"after" => filter.after = value,
|
||||
@@ -97,7 +105,13 @@ impl FlowFilter {
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
let limit = self.limit.map(|l| l.to_string());
|
||||
let system = self.system.map(|s| s.to_string());
|
||||
for (key, value) in [("q", &self.q), ("host", &self.host), ("system", &system)] {
|
||||
let tunnelled = self.tunnelled.map(|t| t.to_string());
|
||||
for (key, value) in [
|
||||
("q", &self.q),
|
||||
("host", &self.host),
|
||||
("system", &system),
|
||||
("tunnelled", &tunnelled),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
query.append_pair(key, value);
|
||||
}
|
||||
@@ -131,6 +145,7 @@ impl FlowFilter {
|
||||
|| self.under.is_some()
|
||||
|| self.app.is_some()
|
||||
|| self.system.is_some()
|
||||
|| self.tunnelled.is_some()
|
||||
|| self.any.as_ref().is_some_and(|groups| groups.iter().any(FlowFilter::narrows))
|
||||
}
|
||||
|
||||
@@ -138,12 +153,17 @@ impl FlowFilter {
|
||||
/// for the database `conn` reads, which may be from before the folder or gRPC columns, when
|
||||
/// read without migrating.
|
||||
fn condition_in(&self, conn: &rusqlite::Connection) -> (String, Vec<Value>) {
|
||||
self.condition(has_column(conn, "client_cwd"), has_column(conn, "grpc_status"))
|
||||
let columns = Columns {
|
||||
cwd: has_column(conn, "client_cwd"),
|
||||
grpc: has_column(conn, "grpc_status"),
|
||||
tunnelled: has_column(conn, "tunnelled"),
|
||||
};
|
||||
self.condition(columns)
|
||||
}
|
||||
|
||||
/// [`Self::condition_in`], told which of the later columns the database has: `has_cwd`,
|
||||
/// the folder a program ran in, and `grpc`, gRPC statuses.
|
||||
fn condition(&self, has_cwd: bool, grpc: bool) -> (String, Vec<Value>) {
|
||||
/// [`Self::condition_in`], told which of the later columns the database has.
|
||||
fn condition(&self, columns: Columns) -> (String, Vec<Value>) {
|
||||
let Columns { cwd: has_cwd, grpc, tunnelled } = columns;
|
||||
let mut sql = String::from("1");
|
||||
let mut params: Vec<Value> = Vec::new();
|
||||
for term in self.q.iter().flat_map(|q| q.split_whitespace()) {
|
||||
@@ -209,11 +229,18 @@ impl FlowFilter {
|
||||
Some(false) => sql.push_str(" AND NOT is_system(client_bundle_id, client_path)"),
|
||||
None => {}
|
||||
}
|
||||
match (self.tunnelled, tunnelled) {
|
||||
(Some(true), true) => sql.push_str(" AND tunnelled"),
|
||||
(Some(false), true) => sql.push_str(" AND NOT tunnelled"),
|
||||
// A database from before tunnelled flows has none.
|
||||
(Some(true), false) => sql.push_str(" AND 0"),
|
||||
_ => {}
|
||||
}
|
||||
// Each group whole, then any of them.
|
||||
if let Some(groups) = self.any.as_ref().filter(|g| !g.is_empty()) {
|
||||
let mut any = Vec::new();
|
||||
for group in groups {
|
||||
let (condition, group_params) = group.condition(has_cwd, grpc);
|
||||
let (condition, group_params) = group.condition(columns);
|
||||
any.push(format!("({condition})"));
|
||||
params.extend(group_params);
|
||||
}
|
||||
@@ -245,6 +272,17 @@ impl FlowFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the columns later migrations added a database has, as one opened read-only before
|
||||
/// they ran may not.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Columns {
|
||||
/// The folder a program ran in.
|
||||
cwd: bool,
|
||||
/// gRPC statuses.
|
||||
grpc: bool,
|
||||
tunnelled: bool,
|
||||
}
|
||||
|
||||
/// The host and port of `url`, in SQL: what follows `://` up to the first `/`, `?` or `#`.
|
||||
const HOST_SQL: &str = "(SELECT CASE WHEN instr(r, '/') > 0 THEN substr(r, 1, instr(r, '/') - 1) \
|
||||
ELSE r END FROM (SELECT replace(replace(CASE WHEN instr(url, '://') > 0 \
|
||||
@@ -647,6 +685,73 @@ pub fn get(db: &ProxyQueryManager, flow_id: &str) -> Result<Flow, String> {
|
||||
find(db, flow_id).ok_or_else(|| format!("No flow {flow_id}"))
|
||||
}
|
||||
|
||||
/// The hosts and apps flows have gone to and come from, for the decrypt list's suggestions.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct SeenTargets {
|
||||
/// Hosts as the sidebar names them, with a port other than the scheme's: most recent first.
|
||||
pub hosts: Vec<String>,
|
||||
/// Apps by name and bundle id, most recent first.
|
||||
pub apps: Vec<SeenApp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct SeenApp {
|
||||
pub name: String,
|
||||
pub bundle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Distinct hosts and apps listed by [`seen`] at most.
|
||||
const SEEN_LIMIT: i64 = 1000;
|
||||
|
||||
/// The distinct hosts and apps of the flows not cleared, most recent first, up to
|
||||
/// [`SEEN_LIMIT`] of each.
|
||||
pub fn seen(db: &ProxyQueryManager) -> Result<SeenTargets, String> {
|
||||
db.with_conn(|c| {
|
||||
let conn = c.conn().resolve();
|
||||
let mut params: Vec<Value> = Vec::new();
|
||||
let mut cleared = String::new();
|
||||
push_clear_mark(conn, &mut cleared, &mut params);
|
||||
params.push(SEEN_LIMIT.into());
|
||||
// The newest flow of each host, whose URL names it as the sidebar does.
|
||||
let sql = format!(
|
||||
"SELECT url FROM flows WHERE rowid IN (SELECT max(rowid) FROM flows WHERE 1{cleared} \
|
||||
GROUP BY lower({HOST_SQL})) ORDER BY rowid DESC LIMIT ?"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let urls = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.clone()), |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut hosts: Vec<String> = Vec::new();
|
||||
for url in urls {
|
||||
let path = sidebar_path(&url);
|
||||
let host = path.split('/').next().unwrap_or_default().to_string();
|
||||
if !host.is_empty() && !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT client_name, client_bundle_id FROM flows WHERE client_name IS NOT NULL{cleared} \
|
||||
GROUP BY client_name, client_bundle_id ORDER BY max(rowid) DESC LIMIT ?"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let apps = stmt
|
||||
.query_map(rusqlite::params_from_iter(params), |r| {
|
||||
Ok(SeenApp { name: r.get(0)?, bundle_id: r.get(1)? })
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(SeenTargets { hosts, apps })
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(db: &ProxyQueryManager) -> Result<usize, String> {
|
||||
db.with_conn(|c| count_in(c.conn().resolve()))
|
||||
}
|
||||
@@ -1326,6 +1431,12 @@ mod tests {
|
||||
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
||||
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
|
||||
assert!(FlowFilter::from_query("limit=lots").is_err());
|
||||
|
||||
let tunnelled = FlowFilter { tunnelled: Some(true), ..Default::default() };
|
||||
assert_eq!(tunnelled.to_query(), "tunnelled=true");
|
||||
assert_eq!(FlowFilter::from_query("tunnelled=true").unwrap(), tunnelled);
|
||||
assert!(tunnelled.narrows());
|
||||
assert!(FlowFilter::from_query("tunnelled=maybe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::macos::{SystemProxyBackend, SystemProxyStatus};
|
||||
use crate::messages::FlowMessage;
|
||||
use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, find_header};
|
||||
use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
|
||||
use crate::settings::{HttpVersion, ProxySettings};
|
||||
use crate::settings::{DecryptList, HttpVersion, ProxySettings};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -80,6 +80,9 @@ pub struct ProxyCtx {
|
||||
/// This context's name as the one deleting cleared flows, which the database leases to one
|
||||
/// process at a time.
|
||||
cleaner_id: String,
|
||||
/// Held through each change to the decrypt list, so concurrent edits apply in turn, each to
|
||||
/// the list the last one left.
|
||||
decrypt_edits: Arc<Mutex<()>>,
|
||||
/// Where the master key that unwraps the data key is kept: the keychain, normally.
|
||||
keys: MasterKeySource,
|
||||
/// The data key, once read. Held while it is read and the databases converted and opened,
|
||||
@@ -127,6 +130,7 @@ impl ProxyCtx {
|
||||
start_error: Arc::new(Mutex::new(None)),
|
||||
deleting_cleared: Arc::new(Mutex::new(false)),
|
||||
cleaner_id: yaak_database::generate_prefixed_id("cln"),
|
||||
decrypt_edits: Arc::new(Mutex::new(())),
|
||||
keys,
|
||||
unlocked: Arc::new(Mutex::new(None)),
|
||||
encrypting: Arc::new(Mutex::new(None)),
|
||||
@@ -370,9 +374,15 @@ impl ProxyCtx {
|
||||
let mut config = ProxyConfig::new(port.unwrap_or(settings.port), Arc::new(ca));
|
||||
config.capture_body_limit = settings.capture_body_limit;
|
||||
config.http2 = settings.http_version == HttpVersion::Auto;
|
||||
config.decrypt = settings.decrypt_policy();
|
||||
// Filled once the engine has bound, since the lookup matches sockets by its port.
|
||||
let attribution_slot: Arc<OnceLock<Attribution>> = Arc::new(OnceLock::new());
|
||||
let slot = attribution_slot.clone();
|
||||
config.app_of = Some(Arc::new(move |connection| {
|
||||
let app = slot.get()?.get(connection, ATTRIBUTION_WAIT)?;
|
||||
Some(yaak_proxy::DecryptApp { name: app.name, bundle_id: app.bundle_id })
|
||||
}));
|
||||
let slot = attribution_slot.clone();
|
||||
config.on_accept = Some(Arc::new(move |connection, client_addr| {
|
||||
// A client that is answered quickly may exit soon after, so it is found before
|
||||
// anything is read from it: a few milliseconds once per connection at most.
|
||||
@@ -430,11 +440,17 @@ impl ProxyCtx {
|
||||
return;
|
||||
};
|
||||
let ended = db.with_tx(|c| {
|
||||
c.conn().resolve().execute(
|
||||
let conn = c.conn().resolve();
|
||||
conn.execute(
|
||||
"UPDATE flows SET error = ?1 WHERE stream_kind IS NOT NULL \
|
||||
AND elapsed_ms IS NULL AND error IS NULL AND NOT cancelled",
|
||||
[STOPPED_MID_STREAM],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE flows SET error = ?1 WHERE tunnelled \
|
||||
AND elapsed_ms IS NULL AND error IS NULL",
|
||||
[STOPPED_MID_TUNNEL],
|
||||
)?;
|
||||
Ok::<_, yaak_database::Error>(())
|
||||
});
|
||||
if let Err(e) = ended {
|
||||
@@ -687,6 +703,47 @@ impl ProxyCtx {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What HTTPS is decrypted, as `settings.json` has it.
|
||||
pub fn decrypt_list(&self) -> Result<DecryptList, String> {
|
||||
Ok(self.settings()?.decrypt_list())
|
||||
}
|
||||
|
||||
/// Replaces the decrypt list, saved in `settings.json` and applied to the running engine's
|
||||
/// next connections. Connections already open stay as they were.
|
||||
pub fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
|
||||
self.change_decrypt_list(|settings| settings.set_decrypt_list(entries))
|
||||
}
|
||||
|
||||
/// Adds and removes entries in the list as it is now, so edits made together each keep
|
||||
/// the other's, unlike replacing the whole list from a copy read earlier.
|
||||
pub fn edit_decrypt_list(
|
||||
&self,
|
||||
add: &[String],
|
||||
remove: &[String],
|
||||
) -> Result<DecryptList, String> {
|
||||
self.change_decrypt_list(|settings| settings.edit_decrypt_list(add, remove))
|
||||
}
|
||||
|
||||
/// Changes the list in `settings.json`, one change at a time, applies it to the running
|
||||
/// engine, and tells the app.
|
||||
fn change_decrypt_list(
|
||||
&self,
|
||||
change: impl FnOnce(&mut ProxySettings) -> Result<(), String>,
|
||||
) -> Result<DecryptList, String> {
|
||||
let _turn = self.decrypt_edits.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||
let mut settings = self.settings()?;
|
||||
change(&mut settings)?;
|
||||
settings.save(&self.settings_path())?;
|
||||
if let Ok(handle) = self.handle.lock()
|
||||
&& let Some(handle) = handle.as_ref()
|
||||
{
|
||||
handle.set_decrypt_policy(settings.decrypt_policy());
|
||||
}
|
||||
let list = settings.decrypt_list();
|
||||
self.events.emit("decrypt_list_changed", &DecryptListChangedPayload { list: list.clone() });
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Intercepts again the hosts passed through after clients rejected the CA, as they no
|
||||
/// longer will once it is trusted.
|
||||
pub fn forget_passthrough(&self) {
|
||||
@@ -947,6 +1004,13 @@ pub enum ProxyState {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// The decrypt list changed, here or through the control API.
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DecryptListChangedPayload {
|
||||
pub list: DecryptList,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ProxyStatePayload {
|
||||
@@ -1090,6 +1154,32 @@ pub struct SetHttpVersionRequest {
|
||||
pub http_version: HttpVersion,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct GetDecryptListRequest {}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct SetDecryptListRequest {
|
||||
/// Every entry, in order, as typed: a bare hostname is a `host:` entry. A built-in
|
||||
/// exclusion left out is removed.
|
||||
pub entries: Vec<String>,
|
||||
}
|
||||
|
||||
/// Entries to add after the user's own and to remove, applied to the list as it is when the
|
||||
/// change lands. Each is normalized first.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct EditDecryptListRequest {
|
||||
pub add: Vec<String>,
|
||||
pub remove: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ListSeenRequest {}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct AppIconRequest {
|
||||
@@ -1498,6 +1588,42 @@ fn set_http_version(ctx: &ProxyCtx, req: SetHttpVersionRequest) -> Result<HttpVe
|
||||
Ok(req.http_version)
|
||||
}
|
||||
|
||||
fn get_decrypt_list(ctx: &ProxyCtx, _req: GetDecryptListRequest) -> Result<DecryptList, RpcError> {
|
||||
ctx.decrypt_list().map_err(rpc_error)
|
||||
}
|
||||
|
||||
/// Saved and applied here, or, while another process such as the CLI daemon owns the engine,
|
||||
/// there, as its engine is the one that reads it.
|
||||
async fn set_decrypt_list(
|
||||
ctx: ProxyCtx,
|
||||
req: SetDecryptListRequest,
|
||||
) -> Result<DecryptList, RpcError> {
|
||||
if ctx.port().is_none()
|
||||
&& let Some(owner) = ctx.other_owner()
|
||||
{
|
||||
return ControlClient::new(owner).set_decrypt_list(&req.entries).await.map_err(rpc_error);
|
||||
}
|
||||
blocking(move || ctx.set_decrypt_list(&req.entries)).await
|
||||
}
|
||||
|
||||
/// [`set_decrypt_list`], adding and removing entries rather than replacing them all.
|
||||
async fn edit_decrypt_list(
|
||||
ctx: ProxyCtx,
|
||||
req: EditDecryptListRequest,
|
||||
) -> Result<DecryptList, RpcError> {
|
||||
if ctx.port().is_none()
|
||||
&& let Some(owner) = ctx.other_owner()
|
||||
{
|
||||
return ControlClient::new(owner).edit_decrypt_list(&req).await.map_err(rpc_error);
|
||||
}
|
||||
blocking(move || ctx.edit_decrypt_list(&req.add, &req.remove)).await
|
||||
}
|
||||
|
||||
async fn list_seen(ctx: ProxyCtx, _req: ListSeenRequest) -> Result<flows::SeenTargets, RpcError> {
|
||||
let db = ctx.db()?;
|
||||
blocking(move || flows::seen(&db)).await
|
||||
}
|
||||
|
||||
async fn take_over(ctx: ProxyCtx, _req: TakeOverRequest) -> Result<bool, RpcError> {
|
||||
ctx.take_over().await.map_err(rpc_error)?;
|
||||
Ok(true)
|
||||
@@ -1802,6 +1928,9 @@ const DUE_CHECK_INTERVAL: Duration = Duration::from_millis(50);
|
||||
/// Why a stream still open when the engine stopped ended.
|
||||
const STOPPED_MID_STREAM: &str = "Yaak Proxy stopped before the stream ended";
|
||||
|
||||
/// Why a tunnel still open when the engine stopped ended.
|
||||
const STOPPED_MID_TUNNEL: &str = "Yaak Proxy stopped before the connection closed";
|
||||
|
||||
/// A flow between its `RequestStart` and its end, with its bodies being written as they
|
||||
/// stream.
|
||||
struct InFlight {
|
||||
@@ -1956,7 +2085,8 @@ fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, sink: FlowSink) {
|
||||
// as they always have been. Every flow that ended is written.
|
||||
for (_, mut f) in in_flight.drain() {
|
||||
if f.persisted {
|
||||
f.flow.error = Some(STOPPED_MID_STREAM.to_string());
|
||||
let why = if f.flow.tunnelled { STOPPED_MID_TUNNEL } else { STOPPED_MID_STREAM };
|
||||
f.flow.error = Some(why.to_string());
|
||||
sink.finish_flow(f);
|
||||
}
|
||||
}
|
||||
@@ -2061,6 +2191,44 @@ impl FlowSink {
|
||||
},
|
||||
);
|
||||
}
|
||||
ProxyEvent::TunnelStart { id, started_at, authority, connection, client_addr } => {
|
||||
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
attribution.begin(connection, client_addr);
|
||||
let flow = Flow {
|
||||
id: Flow::generate_id(),
|
||||
started_at: DateTime::<Utc>::from(started_at).naive_utc(),
|
||||
method: "CONNECT".to_string(),
|
||||
url: format!("https://{authority}"),
|
||||
client_http_version: "HTTP/1.1".to_string(),
|
||||
tunnelled: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut f = InFlight {
|
||||
flow,
|
||||
connection,
|
||||
request_body: None,
|
||||
response_body: None,
|
||||
persisted: false,
|
||||
cleared: false,
|
||||
next_seq: 0,
|
||||
dirty: false,
|
||||
last_write: None,
|
||||
};
|
||||
// Shown while it is open, as tunnels often stay open for minutes.
|
||||
self.write_stream_flow(&mut f);
|
||||
in_flight.insert(id, f);
|
||||
}
|
||||
ProxyEvent::TunnelEnd { id, sent, received, elapsed_ms, error } => {
|
||||
if let Some(mut f) = in_flight.remove(&id) {
|
||||
f.flow.request_size = sent as i64;
|
||||
f.flow.response_size = received as i64;
|
||||
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||
f.flow.error = error;
|
||||
self.finish_flow(f);
|
||||
}
|
||||
}
|
||||
ProxyEvent::StreamStart { id, kind } => {
|
||||
if let Some(f) = in_flight.get_mut(&id) {
|
||||
let kind = StreamKind::from(kind);
|
||||
@@ -2479,6 +2647,7 @@ define_rpc! {
|
||||
commands {
|
||||
execute_action(ActionInvocation) -> bool,
|
||||
format_body(FormatBodyRequest) -> String,
|
||||
get_decrypt_list(GetDecryptListRequest) -> DecryptList,
|
||||
get_message_payload(GetMessagePayloadRequest) -> GetMessagePayloadResponse,
|
||||
list_actions(ListActionsRequest) -> ListActionsResponse,
|
||||
list_flows(flows::FlowFilter) -> ListFlowsResponse,
|
||||
@@ -2502,6 +2671,9 @@ define_rpc! {
|
||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
||||
helper_register(HelperRegisterRequest) -> SystemProxyStatus,
|
||||
helper_unregister(HelperUnregisterRequest) -> SystemProxyStatus,
|
||||
edit_decrypt_list(EditDecryptListRequest) -> DecryptList,
|
||||
list_seen(ListSeenRequest) -> flows::SeenTargets,
|
||||
set_decrypt_list(SetDecryptListRequest) -> DecryptList,
|
||||
list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse,
|
||||
preview_save(PreviewSaveRequest) -> SavePreview,
|
||||
reflect_grpc_host(ReflectGrpcHostRequest) -> GrpcSchema,
|
||||
@@ -2512,6 +2684,7 @@ define_rpc! {
|
||||
take_over(TakeOverRequest) -> bool,
|
||||
}
|
||||
events {
|
||||
decrypt_list_changed(DecryptListChangedPayload),
|
||||
flows_cleared(FlowsClearedPayload),
|
||||
message_write(MessagePayload),
|
||||
model_write(ModelPayload),
|
||||
|
||||
@@ -91,6 +91,10 @@ pub struct Flow {
|
||||
pub grpc_status: Option<i32>,
|
||||
/// A gRPC call's `grpc-message`, decoded.
|
||||
pub grpc_message: Option<String>,
|
||||
/// An HTTPS connection passed through undecrypted, as the decrypt list leaves it out or its
|
||||
/// client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes
|
||||
/// the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
|
||||
pub tunnelled: bool,
|
||||
}
|
||||
|
||||
/// The streaming protocol a flow carries.
|
||||
@@ -239,6 +243,7 @@ impl UpsertModelInfo for Flow {
|
||||
(GrpcMethod, self.grpc_method.into()),
|
||||
(GrpcStatus, self.grpc_status.into()),
|
||||
(GrpcMessage, self.grpc_message.into()),
|
||||
(Tunnelled, self.tunnelled.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -275,6 +280,7 @@ impl UpsertModelInfo for Flow {
|
||||
FlowIden::GrpcMethod,
|
||||
FlowIden::GrpcStatus,
|
||||
FlowIden::GrpcMessage,
|
||||
FlowIden::Tunnelled,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -320,6 +326,7 @@ impl UpsertModelInfo for Flow {
|
||||
grpc_method: added_column(r, "grpc_method")?,
|
||||
grpc_status: added_column(r, "grpc_status")?,
|
||||
grpc_message: added_column(r, "grpc_message")?,
|
||||
tunnelled: added_column(r, "tunnelled")?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy::DEFAULT_CAPTURE_BODY_LIMIT;
|
||||
use yaak_proxy::{BUILT_IN_EXCLUSIONS, DEFAULT_CAPTURE_BODY_LIMIT, DecryptEntry, DecryptPolicy};
|
||||
|
||||
/// `settings.json` in the proxy data directory. Missing keys take their defaults.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -17,6 +17,12 @@ pub struct ProxySettings {
|
||||
pub capture_body_limit: usize,
|
||||
/// The HTTP versions offered to clients in intercepted HTTPS.
|
||||
pub http_version: HttpVersion,
|
||||
/// The user's decrypt entries, in order: `host:api.example.com`, `app:Google Chrome`, `*`,
|
||||
/// `!host:*.bank.com`. Empty, so HTTPS is tunnelled, until hosts or apps are added. The
|
||||
/// built-in exclusions are not kept here; see [`Self::decrypt_list`].
|
||||
pub decrypt: Vec<String>,
|
||||
/// The built-in exclusions the user removed.
|
||||
pub decrypt_built_ins_removed: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ProxySettings {
|
||||
@@ -26,10 +32,32 @@ impl Default for ProxySettings {
|
||||
system_proxy_at_launch: true,
|
||||
capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT,
|
||||
http_version: HttpVersion::Auto,
|
||||
decrypt: Vec::new(),
|
||||
decrypt_built_ins_removed: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the decrypt list, as the app shows it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DecryptListEntry {
|
||||
/// As stored: `host:*.example.com`, `app:Google Chrome`, `!host:*.apple.com`, `*`.
|
||||
pub entry: String,
|
||||
/// Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
|
||||
pub built_in: bool,
|
||||
}
|
||||
|
||||
/// What HTTPS is decrypted: the user's entries in order, then the built-in exclusions not
|
||||
/// removed. A connection is decrypted when a positive entry matches it and no exclusion does.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DecryptList {
|
||||
pub entries: Vec<DecryptListEntry>,
|
||||
}
|
||||
|
||||
/// The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks
|
||||
/// whichever the server picks either way.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
@@ -44,6 +72,84 @@ pub enum HttpVersion {
|
||||
}
|
||||
|
||||
impl ProxySettings {
|
||||
/// The user's entries, then the built-in exclusions they kept.
|
||||
pub fn decrypt_list(&self) -> DecryptList {
|
||||
let user =
|
||||
self.decrypt.iter().map(|e| DecryptListEntry { entry: e.clone(), built_in: false });
|
||||
let built_in = BUILT_IN_EXCLUSIONS
|
||||
.iter()
|
||||
.filter(|e| !self.decrypt_built_ins_removed.iter().any(|r| r == *e))
|
||||
.map(|e| DecryptListEntry { entry: e.to_string(), built_in: true });
|
||||
DecryptList { entries: user.chain(built_in).collect() }
|
||||
}
|
||||
|
||||
/// Replaces the list with `entries`, as the user may type them: each is normalized, a bare
|
||||
/// hostname becoming `host:`, and repeats are dropped. A built-in exclusion left out is
|
||||
/// removed, and one written again is restored. Fails, changing nothing, on an entry that
|
||||
/// does not parse.
|
||||
pub fn set_decrypt_list<S: AsRef<str>>(&mut self, entries: &[S]) -> Result<(), String> {
|
||||
let mut user: Vec<String> = Vec::new();
|
||||
let mut kept_built_ins: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
let parsed = DecryptEntry::parse(entry.as_ref())?;
|
||||
let normalized = parsed.to_string();
|
||||
let list = if parsed.built_in { &mut kept_built_ins } else { &mut user };
|
||||
if !list.contains(&normalized) {
|
||||
list.push(normalized);
|
||||
}
|
||||
}
|
||||
self.decrypt = user;
|
||||
self.decrypt_built_ins_removed = BUILT_IN_EXCLUSIONS
|
||||
.iter()
|
||||
.filter(|e| !kept_built_ins.iter().any(|k| k == *e))
|
||||
.map(|e| e.to_string())
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `remove` from the list as it is and adds `add` after the user's own entries,
|
||||
/// each normalized, as [`Self::set_decrypt_list`] would take the result. A built-in removed
|
||||
/// is remembered as removed, and one added is restored. Fails, changing nothing, on an
|
||||
/// entry that does not parse.
|
||||
pub fn edit_decrypt_list<S: AsRef<str>>(
|
||||
&mut self,
|
||||
add: &[S],
|
||||
remove: &[S],
|
||||
) -> Result<(), String> {
|
||||
let normalize = |e: &str| DecryptEntry::parse(e).ok().map(|p| p.to_string());
|
||||
let add = add
|
||||
.iter()
|
||||
.map(|e| DecryptEntry::parse(e.as_ref()).map(|p| p.to_string()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
// An entry goes as stored, so one edited by hand, odd case or malformed, can go too.
|
||||
let removed = |stored: &str| {
|
||||
remove.iter().any(|r| {
|
||||
let r = r.as_ref();
|
||||
r == stored || normalize(r).is_some_and(|r| Some(r) == normalize(stored))
|
||||
})
|
||||
};
|
||||
let mut entries: Vec<String> = self
|
||||
.decrypt_list()
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| e.entry)
|
||||
.filter(|e| !removed(e))
|
||||
.collect();
|
||||
entries.extend(add);
|
||||
self.set_decrypt_list(&entries)
|
||||
}
|
||||
|
||||
/// The engine's policy from the list. An entry saved by hand that does not parse is left
|
||||
/// out, with a warning, rather than stopping the engine.
|
||||
pub fn decrypt_policy(&self) -> DecryptPolicy {
|
||||
let entries = self.decrypt_list().entries.into_iter().filter_map(|e| {
|
||||
DecryptEntry::parse(&e.entry)
|
||||
.inspect_err(|err| log::warn!("Ignoring decrypt entry: {err}"))
|
||||
.ok()
|
||||
});
|
||||
DecryptPolicy { entries: entries.collect() }
|
||||
}
|
||||
|
||||
/// The settings at `path`, or the defaults when there is no file.
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
@@ -92,6 +198,8 @@ mod tests {
|
||||
system_proxy_at_launch: false,
|
||||
capture_body_limit: 1024,
|
||||
http_version: HttpVersion::Http1Only,
|
||||
decrypt: vec!["host:api.example.com".into()],
|
||||
decrypt_built_ins_removed: vec!["!host:*.zoom.us".into()],
|
||||
};
|
||||
settings.save(&path).unwrap();
|
||||
assert_eq!(ProxySettings::load(&path).unwrap(), settings);
|
||||
@@ -105,6 +213,8 @@ mod tests {
|
||||
"systemProxyAtLaunch": false,
|
||||
"captureBodyLimit": 1024,
|
||||
"httpVersion": "http1Only",
|
||||
"decrypt": ["host:api.example.com"],
|
||||
"decryptBuiltInsRemoved": ["!host:*.zoom.us"],
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -137,6 +247,90 @@ mod tests {
|
||||
assert!(json.get("systemProxy").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decrypt_list_starts_with_only_the_built_in_exclusions() {
|
||||
let settings = ProxySettings::default();
|
||||
let list = settings.decrypt_list();
|
||||
assert!(!list.entries.is_empty());
|
||||
assert!(list.entries.iter().all(|e| e.built_in && e.entry.starts_with('!')));
|
||||
let policy = settings.decrypt_policy();
|
||||
assert!(!policy.decrypts_anything(), "nothing is decrypted by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_the_decrypt_list() {
|
||||
let mut settings = ProxySettings::default();
|
||||
let mut entries: Vec<String> = vec![
|
||||
"api.example.com".into(),
|
||||
"*".into(),
|
||||
"host:API.example.com".into(),
|
||||
"app:Google Chrome".into(),
|
||||
];
|
||||
// Every built-in exclusion but zoom.us, kept.
|
||||
entries.extend(
|
||||
BUILT_IN_EXCLUSIONS.iter().filter(|e| **e != "!host:*.zoom.us").map(|e| e.to_string()),
|
||||
);
|
||||
settings.set_decrypt_list(&entries).unwrap();
|
||||
assert_eq!(settings.decrypt, ["host:api.example.com", "*", "app:Google Chrome"]);
|
||||
assert_eq!(settings.decrypt_built_ins_removed, ["!host:*.zoom.us"]);
|
||||
let list = settings.decrypt_list();
|
||||
assert_eq!(
|
||||
list.entries[0],
|
||||
DecryptListEntry { entry: "host:api.example.com".into(), built_in: false }
|
||||
);
|
||||
assert!(list.entries[3].built_in);
|
||||
assert!(!list.entries.iter().any(|e| e.entry == "!host:*.zoom.us"));
|
||||
|
||||
// Written again, a built-in exclusion is restored.
|
||||
let mut again: Vec<String> = list.entries.iter().map(|e| e.entry.clone()).collect();
|
||||
again.push("!host:*.zoom.us".into());
|
||||
settings.set_decrypt_list(&again).unwrap();
|
||||
assert!(settings.decrypt_built_ins_removed.is_empty());
|
||||
|
||||
// An invalid entry changes nothing.
|
||||
let before = settings.clone();
|
||||
assert!(settings.set_decrypt_list(&["host:a b"]).is_err());
|
||||
assert_eq!(settings, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_applies_to_the_list_as_it_is() {
|
||||
let mut settings =
|
||||
ProxySettings { decrypt: vec!["host:a.com".into()], ..Default::default() };
|
||||
// Two edits made from the same earlier copy both land.
|
||||
settings.edit_decrypt_list(&["b.com"], &[]).unwrap();
|
||||
settings.edit_decrypt_list(&["app:Slack"], &["host:a.com"]).unwrap();
|
||||
assert_eq!(settings.decrypt, ["host:b.com", "app:Slack"]);
|
||||
// A built-in removed and restored.
|
||||
settings.edit_decrypt_list(&[], &["!host:*.zoom.us"]).unwrap();
|
||||
assert_eq!(settings.decrypt_built_ins_removed, ["!host:*.zoom.us"]);
|
||||
settings.edit_decrypt_list(&["!host:*.zoom.us"], &[]).unwrap();
|
||||
assert!(settings.decrypt_built_ins_removed.is_empty());
|
||||
let before = settings.clone();
|
||||
assert!(settings.edit_decrypt_list(&["host:a b"], &[]).is_err());
|
||||
assert_eq!(settings, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_edited_by_hand_can_be_removed() {
|
||||
let mut settings = ProxySettings {
|
||||
decrypt: vec!["API.example.com".into(), "host:a b".into(), "b.com".into()],
|
||||
..Default::default()
|
||||
};
|
||||
settings.edit_decrypt_list(&[], &["host:api.example.com", "host:a b"]).unwrap();
|
||||
assert_eq!(settings.decrypt, ["host:b.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_entry_that_does_not_parse_is_skipped() {
|
||||
let settings = ProxySettings {
|
||||
decrypt: vec!["host:a b".into(), "x.com".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let policy = settings.decrypt_policy();
|
||||
assert_eq!(policy.entries.iter().filter(|e| !e.negated).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -772,3 +772,112 @@ async fn another_app_owning_the_engine_is_not_taken_over() {
|
||||
assert_eq!(app.port(), None);
|
||||
other.stop().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_decrypt_list_is_read_and_replaced_over_the_control_api() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let client_dir = tempfile::tempdir().unwrap();
|
||||
let ctx = ctx(dir.path(), client_dir.path());
|
||||
let _server = ControlServer::start(ctx.clone()).unwrap();
|
||||
let info = control::read(dir.path()).unwrap();
|
||||
let http = reqwest::Client::builder().no_proxy().build().unwrap();
|
||||
let url = format!("{}/decrypt", info.base_url());
|
||||
|
||||
assert_eq!(http.get(&url).send().await.unwrap().status(), 401);
|
||||
let list: Value =
|
||||
http.get(&url).bearer_auth(&info.token).send().await.unwrap().json().await.unwrap();
|
||||
assert!(list["entries"].as_array().unwrap().iter().all(|e| e["builtIn"] == true));
|
||||
|
||||
let put = http
|
||||
.put(&url)
|
||||
.bearer_auth(&info.token)
|
||||
.json(&json!({"entries": ["*", "!host:*.bank.example"]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(put.status(), 200);
|
||||
let list: Value = put.json().await.unwrap();
|
||||
assert_eq!(
|
||||
list["entries"],
|
||||
json!([
|
||||
{"entry": "*", "builtIn": false},
|
||||
{"entry": "!host:*.bank.example", "builtIn": false},
|
||||
]),
|
||||
"every built-in exclusion left out is removed"
|
||||
);
|
||||
let read: Value =
|
||||
http.get(&url).bearer_auth(&info.token).send().await.unwrap().json().await.unwrap();
|
||||
assert_eq!(read, list);
|
||||
|
||||
let bad = http
|
||||
.put(&url)
|
||||
.bearer_auth(&info.token)
|
||||
.json(&json!({"entries": ["app:"]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad.status(), 400);
|
||||
}
|
||||
|
||||
/// While the CLI owns the engine, the app's change to the decrypt list goes to the CLI, whose
|
||||
/// engine decrypts the next connection by it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_decrypt_list_reaches_the_cli_that_owns_the_engine() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let client_dir = tempfile::tempdir().unwrap();
|
||||
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
|
||||
let _server = ControlServer::start(cli.clone()).unwrap();
|
||||
cli.start().unwrap();
|
||||
let app = ctx(dir.path(), client_dir.path());
|
||||
|
||||
let list = rpc(&app, "set_decrypt_list", json!({"entries": ["*"]})).await.unwrap();
|
||||
assert_eq!(list["entries"][0], json!({"entry": "*", "builtIn": false}));
|
||||
|
||||
// Decrypted, the request is a flow of its own: a 502, as nothing listens there.
|
||||
let ca = std::fs::read(dir.path().join("ca/ca.pem")).unwrap();
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{}", cli.port().unwrap())).unwrap())
|
||||
.add_root_certificate(reqwest::Certificate::from_pem(&ca).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
let url = format!("https://localhost:{closed}/forwarded");
|
||||
assert_eq!(client.get(&url).send().await.unwrap().status(), 502);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let flows = cli.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
|
||||
if let Some(flow) = flows.iter().find(|f| f.url == url) {
|
||||
assert!(!flow.tunnelled);
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no decrypted flow for {url}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_decrypt_list_is_edited_over_the_control_api() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let client_dir = tempfile::tempdir().unwrap();
|
||||
let ctx = ctx(dir.path(), client_dir.path());
|
||||
let _server = ControlServer::start(ctx.clone()).unwrap();
|
||||
let info = control::read(dir.path()).unwrap();
|
||||
let http = reqwest::Client::builder().no_proxy().build().unwrap();
|
||||
let url = format!("{}/decrypt", info.base_url());
|
||||
|
||||
for add in ["a.com", "b.com"] {
|
||||
let resp =
|
||||
http.patch(&url).bearer_auth(&info.token).json(&json!({"add": [add]})).send().await;
|
||||
assert_eq!(resp.unwrap().status(), 200);
|
||||
}
|
||||
let resp = http
|
||||
.patch(&url)
|
||||
.bearer_auth(&info.token)
|
||||
.json(&json!({"remove": ["host:a.com"]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let list: Value = resp.json().await.unwrap();
|
||||
assert_eq!(list["entries"][0], json!({"entry": "host:b.com", "builtIn": false}));
|
||||
assert_eq!(list["entries"][1]["builtIn"], true);
|
||||
}
|
||||
@@ -267,7 +267,13 @@ async fn cancelled_flow_is_not_an_error() {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn http_version_setting_applies_to_new_connections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
// Decrypted, as only intercepted HTTPS offers the client a version.
|
||||
let settings = ProxySettings {
|
||||
port: 0,
|
||||
system_proxy_at_launch: false,
|
||||
decrypt: vec!["*".into()],
|
||||
..Default::default()
|
||||
};
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
|
||||
let (events, _event_rx) = RpcEventEmitter::new();
|
||||
@@ -416,6 +422,213 @@ async fn body_rpcs() {
|
||||
assert_eq!(formatted, json!("{\n \"a\": [\n 1,\n 2\n ]\n}"));
|
||||
}
|
||||
|
||||
/// A TCP server that echoes what it reads, then closes once the client does.
|
||||
async fn spawn_echo() -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut tcp, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut read, mut write) = tcp.split();
|
||||
let _ = tokio::io::copy(&mut read, &mut write).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Opens a CONNECT tunnel to `target` through the engine on `port`.
|
||||
async fn connect_through(port: u16, target: &str) -> tokio::net::TcpStream {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||
tcp.write_all(connect.as_bytes()).await.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
head.push(tcp.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(head.starts_with(b"HTTP/1.1 200"), "{}", String::from_utf8_lossy(&head));
|
||||
tcp
|
||||
}
|
||||
|
||||
async fn wait_for(ctx: &ProxyCtx, check: impl Fn(&Flow) -> bool) -> Flow {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
|
||||
if let Some(flow) = flows.into_iter().find(|f| check(f)) {
|
||||
return flow;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no such flow");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// With the default, empty decrypt list, a CONNECT is tunnelled untouched and recorded as a flow
|
||||
/// with its host and the bytes each way, shown while open and finished once closed.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_tunnelled_connect_is_recorded_with_its_sizes() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, _event_rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
ctx.start().unwrap();
|
||||
let port = ctx.port().unwrap();
|
||||
let echo = spawn_echo().await;
|
||||
let target = format!("localhost:{}", echo.port());
|
||||
let url = format!("https://{target}");
|
||||
|
||||
let mut tcp = connect_through(port, &target).await;
|
||||
// TLS-looking bytes, which a decrypting engine would have taken for a ClientHello.
|
||||
let sent = b"\x16\x03\x01 not decrypted";
|
||||
tcp.write_all(sent).await.unwrap();
|
||||
let mut echoed = vec![0u8; sent.len()];
|
||||
tcp.read_exact(&mut echoed).await.unwrap();
|
||||
assert_eq!(&echoed, sent, "bytes pass untouched");
|
||||
|
||||
let open = wait_for(&ctx, |f| f.url == url).await;
|
||||
assert!(open.tunnelled);
|
||||
assert_eq!(open.method, "CONNECT");
|
||||
assert_eq!(open.elapsed_ms, None, "open while the tunnel is");
|
||||
|
||||
drop(tcp);
|
||||
let done = wait_for(&ctx, |f| f.url == url && f.elapsed_ms.is_some()).await;
|
||||
assert_eq!(done.id, open.id);
|
||||
assert_eq!((done.request_size, done.response_size), (sent.len() as i64, sent.len() as i64));
|
||||
assert_eq!(done.error, None);
|
||||
assert!(done.request_headers.is_empty() && done.response_headers.is_empty());
|
||||
|
||||
let tunnelled = rpc(&ctx, "list_flows", json!({"tunnelled": true})).await.unwrap();
|
||||
assert_eq!(tunnelled["flows"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(tunnelled["flows"][0]["tunnelled"], true);
|
||||
let decrypted = rpc(&ctx, "list_flows", json!({"tunnelled": false})).await.unwrap();
|
||||
assert_eq!(decrypted["flows"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_decrypt_list_rpcs_save_and_apply() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, _event_rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
|
||||
let list = rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap();
|
||||
let entries = list["entries"].as_array().unwrap();
|
||||
assert!(entries.iter().all(|e| e["builtIn"] == true), "only the built-ins: {list}");
|
||||
assert!(entries.iter().any(|e| e["entry"] == "!host:*.apple.com"));
|
||||
|
||||
let set = json!({"entries": ["API.example.com", "app:Google Chrome", "!host:*.apple.com"]});
|
||||
let list = rpc(&ctx, "set_decrypt_list", set).await.unwrap();
|
||||
assert_eq!(
|
||||
list["entries"],
|
||||
json!([
|
||||
{"entry": "host:api.example.com", "builtIn": false},
|
||||
{"entry": "app:Google Chrome", "builtIn": false},
|
||||
{"entry": "!host:*.apple.com", "builtIn": true},
|
||||
])
|
||||
);
|
||||
let saved = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
|
||||
assert_eq!(saved.decrypt, ["host:api.example.com", "app:Google Chrome"]);
|
||||
assert!(saved.decrypt_built_ins_removed.contains(&"!host:*.icloud.com".to_string()));
|
||||
|
||||
let invalid = rpc(&ctx, "set_decrypt_list", json!({"entries": ["host:a b"]})).await;
|
||||
assert!(invalid.unwrap_err().contains("Invalid host"));
|
||||
assert_eq!(rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap(), list);
|
||||
|
||||
// Applied to the running engine's next connection: decrypting everything intercepts a
|
||||
// CONNECT, which a client that does not trust the CA then rejects.
|
||||
ctx.start().unwrap();
|
||||
rpc(&ctx, "set_decrypt_list", json!({"entries": ["*"]})).await.unwrap();
|
||||
let echo = spawn_echo().await;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{}", ctx.port().unwrap())).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
let err = client.get(format!("https://localhost:{}/", echo.port())).send().await.unwrap_err();
|
||||
assert!(err.is_connect() || err.is_request(), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn seen_lists_hosts_and_apps_most_recent_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, _event_rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
let flow = |url: &str, app: Option<(&str, Option<&str>)>, at: i64| Flow {
|
||||
id: format!("fl_{at}"),
|
||||
url: url.into(),
|
||||
method: "GET".into(),
|
||||
started_at: chrono::DateTime::from_timestamp(at, 0).unwrap().naive_utc(),
|
||||
client_name: app.map(|(n, _)| n.to_string()),
|
||||
client_bundle_id: app.and_then(|(_, b)| b.map(String::from)),
|
||||
..Default::default()
|
||||
};
|
||||
let chrome = Some(("Google Chrome", Some("com.google.Chrome")));
|
||||
ctx.db()
|
||||
.unwrap()
|
||||
.with_tx(|c| {
|
||||
for f in [
|
||||
flow("https://api.example.com/v1", chrome, 1),
|
||||
flow("https://API.example.com:443/v2", None, 2),
|
||||
flow("https://example.com:8443/", Some(("curl", None)), 3),
|
||||
flow("http://localhost:80/x", chrome, 4),
|
||||
] {
|
||||
c.upsert(&f, &yaak_database::UpdateSource::Background)?;
|
||||
}
|
||||
Ok::<_, yaak_database::Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let seen = rpc(&ctx, "list_seen", json!({})).await.unwrap();
|
||||
assert_eq!(seen["hosts"], json!(["localhost", "example.com:8443", "api.example.com"]));
|
||||
assert_eq!(
|
||||
seen["apps"],
|
||||
json!([
|
||||
{"name": "Google Chrome", "bundleId": "com.google.Chrome"},
|
||||
{"name": "curl", "bundleId": null},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// Edits made together each land on the list the other left, and every change is announced to
|
||||
/// the app, so a list changed elsewhere shows at once.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn decrypt_list_edits_apply_to_the_latest_list_and_are_announced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, event_rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
|
||||
let (a, b) = tokio::join!(
|
||||
rpc(&ctx, "edit_decrypt_list", json!({"add": ["a.com"]})),
|
||||
rpc(&ctx, "edit_decrypt_list", json!({"add": ["app:Slack"]})),
|
||||
);
|
||||
a.unwrap();
|
||||
b.unwrap();
|
||||
let list =
|
||||
rpc(&ctx, "edit_decrypt_list", json!({"remove": ["!host:*.zoom.us"]})).await.unwrap();
|
||||
let user: Vec<&str> = list["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["builtIn"] == false)
|
||||
.map(|e| e["entry"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(user.len(), 2);
|
||||
assert!(user.contains(&"host:a.com") && user.contains(&"app:Slack"), "{user:?}");
|
||||
assert!(!list["entries"].as_array().unwrap().iter().any(|e| e["entry"] == "!host:*.zoom.us"));
|
||||
|
||||
let changes: Vec<_> =
|
||||
event_rx.try_iter().filter(|e| e.event == "decrypt_list_changed").collect();
|
||||
assert_eq!(changes.len(), 3);
|
||||
assert_eq!(changes[2].payload["list"], list);
|
||||
}
|
||||
|
||||
/// Capture throughput through the engine, end to end: 3,000 requests, 16 at a time over
|
||||
/// kept-alive connections, each with a 4 KB response, timed until every flow is stored, and how
|
||||
/// long opening one then takes. Run in release: `cargo test --release -p yaak-proxy-lib --test
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Which HTTPS connections the engine decrypts. The policy is one list of entries:
|
||||
//!
|
||||
//! - `host:api.example.com`, `host:*.example.com` (the host itself and every host below it),
|
||||
//! `host:example.com:8443` (that port only), with `*` matching any run of characters;
|
||||
//! - `app:Google Chrome` (by the app's name) or `app:com.google.Chrome` (by its bundle id);
|
||||
//! - `*` alone, everything;
|
||||
//! - any of these after `!`, which excludes what it matches.
|
||||
//!
|
||||
//! A connection is decrypted when a positive entry matches it and no negative one does, wherever
|
||||
//! each sits in the list. Nothing matching tunnels it, so an empty list decrypts nothing. A
|
||||
//! built-in exclusion (Apple's services, apps that pin their certificates) applies only to
|
||||
//! positive entries broader than itself, such as `*` or `host:*.com`: an app entry, or a host
|
||||
//! entry at least as specific as the exclusion, still decrypts what it names.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Hosts excluded by default: Apple's own services, which macOS daemons reach with pinned
|
||||
/// certificates, and the sync hosts of common apps that pin theirs. Each is removable.
|
||||
pub const BUILT_IN_EXCLUSIONS: &[&str] = &[
|
||||
"!host:*.apple.com",
|
||||
"!host:*.icloud.com",
|
||||
"!host:*.mzstatic.com",
|
||||
"!host:*.apple-cloudkit.com",
|
||||
"!host:*.icloud-content.com",
|
||||
"!host:*.cdn-apple.com",
|
||||
// Push and FaceTime, kept apart so they stay excluded without the rest of apple.com.
|
||||
"!host:*.push.apple.com",
|
||||
"!host:*.ess.apple.com",
|
||||
"!host:*.1password.com",
|
||||
"!host:*.1password.ca",
|
||||
"!host:*.1password.eu",
|
||||
"!host:*.signal.org",
|
||||
"!host:*.whispersystems.org",
|
||||
"!host:*.dropboxapi.com",
|
||||
"!host:client*.dropbox.com",
|
||||
"!host:bolt.dropbox.com",
|
||||
"!host:*.zoom.us",
|
||||
];
|
||||
|
||||
/// What an entry matches.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DecryptTarget {
|
||||
/// `*`: every connection.
|
||||
Everything,
|
||||
/// A host pattern, lowercased, and the port it is limited to, if any.
|
||||
Host { pattern: String, port: Option<u16> },
|
||||
/// An app's name or bundle id, as a pattern, matched in any case.
|
||||
App { pattern: String },
|
||||
}
|
||||
|
||||
/// One entry of the list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecryptEntry {
|
||||
/// Written with `!`: excludes what it matches.
|
||||
pub negated: bool,
|
||||
pub target: DecryptTarget,
|
||||
/// Shipped with the engine rather than added by the user.
|
||||
pub built_in: bool,
|
||||
}
|
||||
|
||||
impl DecryptEntry {
|
||||
/// Parses an entry as the user may type it: with its prefix, or as a bare hostname, which is
|
||||
/// a `host:` entry. A URL is taken as its host. Prefixes are any case.
|
||||
pub fn parse(input: &str) -> Result<Self, String> {
|
||||
let trimmed = input.trim();
|
||||
let (negated, rest) = match trimmed.strip_prefix('!') {
|
||||
Some(rest) => (true, rest.trim_start()),
|
||||
None => (false, trimmed),
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return Err(format!("Empty decrypt entry {input:?}"));
|
||||
}
|
||||
let target = if rest == "*" {
|
||||
DecryptTarget::Everything
|
||||
} else if let Some(app) = strip_prefix_ci(rest, "app:") {
|
||||
let app = unquote(app.trim());
|
||||
if app.is_empty() {
|
||||
return Err(format!("No app in decrypt entry {input:?}"));
|
||||
}
|
||||
DecryptTarget::App { pattern: app.to_string() }
|
||||
} else {
|
||||
let host = unquote(strip_prefix_ci(rest, "host:").unwrap_or(rest).trim());
|
||||
parse_host(host).ok_or_else(|| format!("Invalid host in decrypt entry {input:?}"))?
|
||||
};
|
||||
let mut entry = Self { negated, target, built_in: false };
|
||||
entry.built_in = BUILT_IN_EXCLUSIONS.contains(&entry.to_string().as_str());
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// The literal characters of the pattern: how specific it is. A site's `*.` counts for
|
||||
/// nothing, as `*.apple.com` matches `apple.com` itself, so `host:apple.com` ranks with it.
|
||||
fn specificity(&self) -> usize {
|
||||
match &self.target {
|
||||
DecryptTarget::Everything => 0,
|
||||
DecryptTarget::Host { pattern, port } => {
|
||||
let pattern = pattern.strip_prefix("*.").unwrap_or(pattern);
|
||||
pattern.chars().filter(|c| *c != '*').count() + usize::from(port.is_some())
|
||||
}
|
||||
DecryptTarget::App { .. } => usize::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, conn: &DecryptQuery) -> bool {
|
||||
match &self.target {
|
||||
DecryptTarget::Everything => true,
|
||||
DecryptTarget::Host { pattern, port } => {
|
||||
port.is_none_or(|p| p == conn.port) && host_matches(pattern, &conn.host)
|
||||
}
|
||||
DecryptTarget::App { pattern } => conn.app.as_ref().is_some_and(|app| {
|
||||
let pattern = pattern.to_lowercase();
|
||||
glob(&pattern, &app.name.to_lowercase())
|
||||
|| app.bundle_id.as_ref().is_some_and(|id| glob(&pattern, &id.to_lowercase()))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry as stored and shown: `!host:*.apple.com`, `app:Google Chrome`, `*`.
|
||||
impl fmt::Display for DecryptEntry {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.negated {
|
||||
f.write_str("!")?;
|
||||
}
|
||||
match &self.target {
|
||||
DecryptTarget::Everything => f.write_str("*"),
|
||||
DecryptTarget::Host { pattern, port: None } => write!(f, "host:{pattern}"),
|
||||
DecryptTarget::Host { pattern, port: Some(port) } if pattern.contains(':') => {
|
||||
write!(f, "host:[{pattern}]:{port}")
|
||||
}
|
||||
DecryptTarget::Host { pattern, port: Some(port) } => write!(f, "host:{pattern}:{port}"),
|
||||
DecryptTarget::App { pattern } => write!(f, "app:{pattern}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the search writes one with spaces in it: `"Google Chrome"`.
|
||||
fn unquote(s: &str) -> &str {
|
||||
match s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
||||
Some(inner) => inner.trim(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let head = s.get(..prefix.len())?;
|
||||
head.eq_ignore_ascii_case(prefix).then(|| &s[prefix.len()..])
|
||||
}
|
||||
|
||||
/// A host pattern with an optional port: `api.example.com`, `*.example.com:8443`,
|
||||
/// `[::1]:8443`, or a URL, whose host and port it takes.
|
||||
fn parse_host(input: &str) -> Option<DecryptTarget> {
|
||||
let mut host = input;
|
||||
if let Some((_, rest)) = host.split_once("://") {
|
||||
host = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
host = host.rsplit_once('@').map_or(host, |(_, h)| h);
|
||||
}
|
||||
let (pattern, port) = if let Some(rest) = host.strip_prefix('[') {
|
||||
let (addr, after) = rest.split_once(']')?;
|
||||
let port = match after {
|
||||
"" => None,
|
||||
p => Some(p.strip_prefix(':')?.parse::<u16>().ok()?),
|
||||
};
|
||||
(addr, port)
|
||||
} else {
|
||||
match host.rsplit_once(':') {
|
||||
// One colon: a port. More: a bare IPv6 address.
|
||||
Some((name, port)) if !name.contains(':') => (name, Some(port.parse::<u16>().ok()?)),
|
||||
_ => (host, None),
|
||||
}
|
||||
};
|
||||
let pattern = pattern.trim_end_matches('.').to_lowercase();
|
||||
let valid = !pattern.is_empty()
|
||||
&& pattern.chars().all(|c| c.is_ascii_alphanumeric() || "-._*:".contains(c));
|
||||
valid.then_some(DecryptTarget::Host { pattern, port })
|
||||
}
|
||||
|
||||
/// `*.example.com` matches `example.com` too, as cookie domains do and as the search's `@host`
|
||||
/// does.
|
||||
fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']').trim_end_matches('.');
|
||||
let host = host.to_lowercase();
|
||||
glob(pattern, &host) || pattern.strip_prefix("*.").is_some_and(|apex| glob(apex, &host))
|
||||
}
|
||||
|
||||
/// `*` matches any run of characters, none included; everything else matches itself.
|
||||
fn glob(pattern: &str, text: &str) -> bool {
|
||||
let (p, t) = (pattern.as_bytes(), text.as_bytes());
|
||||
let (mut pi, mut ti) = (0, 0);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, ti));
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == t[ti] {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if let Some((sp, st)) = star {
|
||||
pi = sp + 1;
|
||||
ti = st + 1;
|
||||
star = Some((sp, st + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p[pi..].iter().all(|c| *c == b'*')
|
||||
}
|
||||
|
||||
/// The app that opened a connection, as the policy's `app:` entries match it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DecryptApp {
|
||||
pub name: String,
|
||||
pub bundle_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A CONNECT to judge: where it goes and, when known, the app that sent it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DecryptQuery {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub app: Option<DecryptApp>,
|
||||
}
|
||||
|
||||
/// The list of entries, in order.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DecryptPolicy {
|
||||
pub entries: Vec<DecryptEntry>,
|
||||
}
|
||||
|
||||
impl DecryptPolicy {
|
||||
/// Decrypts everything, without exclusions: the engine's default, for callers that keep no
|
||||
/// list.
|
||||
pub fn everything() -> Self {
|
||||
Self { entries: vec![DecryptEntry::parse("*").unwrap()] }
|
||||
}
|
||||
|
||||
/// The policy from entries as stored. Fails on the first one that does not parse.
|
||||
pub fn parse<S: AsRef<str>>(entries: &[S]) -> Result<Self, String> {
|
||||
let entries = entries.iter().map(|e| DecryptEntry::parse(e.as_ref()));
|
||||
Ok(Self { entries: entries.collect::<Result<_, _>>()? })
|
||||
}
|
||||
|
||||
/// Some entry names an app, so judging a connection needs the app that sent it.
|
||||
pub fn needs_app(&self) -> bool {
|
||||
self.entries.iter().any(|e| matches!(e.target, DecryptTarget::App { .. }))
|
||||
}
|
||||
|
||||
/// Some positive entry could match, so a connection may be decrypted at all.
|
||||
pub fn decrypts_anything(&self) -> bool {
|
||||
self.entries.iter().any(|e| !e.negated)
|
||||
}
|
||||
|
||||
/// Whether to decrypt `conn`: a positive entry matches and no negative one excludes it. A
|
||||
/// built-in exclusion yields to a positive entry at least as specific as itself.
|
||||
pub fn decrypts(&self, conn: &DecryptQuery) -> bool {
|
||||
let Some(best) = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.negated && e.matches(conn))
|
||||
.map(DecryptEntry::specificity)
|
||||
.max()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!self
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| e.negated && e.matches(conn) && (!e.built_in || e.specificity() > best))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn policy(entries: &[&str]) -> DecryptPolicy {
|
||||
DecryptPolicy::parse(entries).unwrap()
|
||||
}
|
||||
|
||||
fn to(host: &str, port: u16) -> DecryptQuery {
|
||||
DecryptQuery { host: host.into(), port, app: None }
|
||||
}
|
||||
|
||||
fn from_app(host: &str, name: &str, bundle_id: Option<&str>) -> DecryptQuery {
|
||||
let app = DecryptApp { name: name.into(), bundle_id: bundle_id.map(String::from) };
|
||||
DecryptQuery { host: host.into(), port: 443, app: Some(app) }
|
||||
}
|
||||
|
||||
fn with_built_ins(entries: &[&str]) -> DecryptPolicy {
|
||||
let mut all: Vec<&str> = entries.to_vec();
|
||||
all.extend(BUILT_IN_EXCLUSIONS);
|
||||
policy(&all)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_tunnels_everything() {
|
||||
let empty = policy(&[]);
|
||||
assert!(!empty.decrypts(&to("api.example.com", 443)));
|
||||
assert!(!empty.decrypts_anything());
|
||||
// The built-in exclusions alone decrypt nothing either.
|
||||
let built_in = with_built_ins(&[]);
|
||||
assert!(!built_in.decrypts(&to("api.example.com", 443)));
|
||||
assert!(!built_in.decrypts_anything());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns() {
|
||||
let p = policy(&["host:api.example.com"]);
|
||||
assert!(p.decrypts(&to("api.example.com", 443)));
|
||||
assert!(p.decrypts(&to("API.Example.com", 8443)), "any case, any port");
|
||||
assert!(!p.decrypts(&to("www.example.com", 443)));
|
||||
assert!(!p.decrypts(&to("example.com", 443)));
|
||||
|
||||
let p = policy(&["host:*.example.com"]);
|
||||
assert!(p.decrypts(&to("api.example.com", 443)));
|
||||
assert!(p.decrypts(&to("a.b.example.com", 443)));
|
||||
assert!(p.decrypts(&to("example.com", 443)), "the site itself too");
|
||||
assert!(!p.decrypts(&to("notexample.com", 443)));
|
||||
assert!(!p.decrypts(&to("example.com.evil.net", 443)));
|
||||
|
||||
let p = policy(&["host:api-*.example.com"]);
|
||||
assert!(p.decrypts(&to("api-eu.example.com", 443)));
|
||||
assert!(!p.decrypts(&to("web.example.com", 443)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns_with_a_port() {
|
||||
let p = policy(&["host:example.com:8443"]);
|
||||
assert!(p.decrypts(&to("example.com", 8443)));
|
||||
assert!(!p.decrypts(&to("example.com", 443)));
|
||||
|
||||
let p = policy(&["host:[::1]:8443", "host:fe80::1"]);
|
||||
assert!(p.decrypts(&to("[::1]", 8443)));
|
||||
assert!(!p.decrypts(&to("::1", 443)));
|
||||
assert!(p.decrypts(&to("fe80::1", 443)));
|
||||
assert_eq!(p.entries[0].to_string(), "host:[::1]:8443");
|
||||
assert_eq!(p.entries[1].to_string(), "host:fe80::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_by_name_and_bundle_id() {
|
||||
let by_name = policy(&["app:Google Chrome"]);
|
||||
assert!(by_name.decrypts(&from_app("a.com", "Google Chrome", Some("com.google.Chrome"))));
|
||||
assert!(by_name.decrypts(&from_app("a.com", "google chrome", None)));
|
||||
assert!(!by_name.decrypts(&from_app("a.com", "Safari", Some("com.apple.Safari"))));
|
||||
assert!(!by_name.decrypts(&to("a.com", 443)), "an unknown app matches no app entry");
|
||||
assert!(by_name.needs_app());
|
||||
|
||||
let by_id = policy(&["app:com.google.Chrome"]);
|
||||
assert!(by_id.decrypts(&from_app("a.com", "Google Chrome", Some("com.google.Chrome"))));
|
||||
assert!(!by_id.decrypts(&from_app("a.com", "Chromium", Some("org.chromium.Chromium"))));
|
||||
|
||||
let pattern = policy(&["app:com.google.*"]);
|
||||
assert!(pattern.decrypts(&from_app("a.com", "Google Chrome", Some("com.google.Chrome"))));
|
||||
assert!(!policy(&["host:a.com"]).needs_app());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_keeps_the_built_in_exclusions() {
|
||||
let p = with_built_ins(&["*"]);
|
||||
assert!(p.decrypts(&to("api.example.com", 443)));
|
||||
assert!(!p.decrypts(&to("gateway.icloud.com", 443)));
|
||||
assert!(!p.decrypts(&to("apple.com", 443)));
|
||||
assert!(!p.decrypts(&to("courier.push.apple.com", 5223)));
|
||||
assert!(!p.decrypts(&to("my.1password.com", 443)));
|
||||
assert!(!p.decrypts(&to("client-cf.dropbox.com", 443)));
|
||||
assert!(p.decrypts(&to("www.dropbox.com", 443)));
|
||||
// A broad wildcard yields to them too.
|
||||
assert!(!with_built_ins(&["host:*.com"]).decrypts(&to("p01.icloud.com", 443)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_specific_entry_beats_a_built_in_exclusion() {
|
||||
let p = with_built_ins(&["*", "host:developer.apple.com"]);
|
||||
assert!(p.decrypts(&to("developer.apple.com", 443)));
|
||||
assert!(!p.decrypts(&to("www.apple.com", 443)));
|
||||
assert!(with_built_ins(&["host:*.apple.com"]).decrypts(&to("www.apple.com", 443)));
|
||||
// The site itself, which the exclusion's `*.apple.com` matches too.
|
||||
assert!(with_built_ins(&["host:apple.com"]).decrypts(&to("apple.com", 443)));
|
||||
// A narrower exclusion still wins over a broader entry.
|
||||
assert!(
|
||||
!with_built_ins(&["host:*.apple.com"]).decrypts(&to("courier.push.apple.com", 443))
|
||||
);
|
||||
let safari = from_app("www.icloud.com", "Safari", Some("com.apple.Safari"));
|
||||
assert!(with_built_ins(&["app:Safari"]).decrypts(&safari));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_exclusion_no_longer_applies() {
|
||||
let kept: Vec<&str> =
|
||||
BUILT_IN_EXCLUSIONS.iter().copied().filter(|e| *e != "!host:*.zoom.us").collect();
|
||||
let mut entries = vec!["*"];
|
||||
entries.extend(kept);
|
||||
let p = policy(&entries);
|
||||
assert!(p.decrypts(&to("us02web.zoom.us", 443)));
|
||||
assert!(!p.decrypts(&to("gateway.icloud.com", 443)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_entries_win_wherever_they_sit() {
|
||||
for entries in [
|
||||
["!host:secret.example.com", "host:*.example.com"],
|
||||
["host:*.example.com", "!host:secret.example.com"],
|
||||
] {
|
||||
let p = policy(&entries);
|
||||
assert!(p.decrypts(&to("api.example.com", 443)));
|
||||
assert!(!p.decrypts(&to("secret.example.com", 443)));
|
||||
}
|
||||
// The user's own exclusion beats even an exact entry.
|
||||
let p = policy(&["host:a.example.com", "!host:*.example.com"]);
|
||||
assert!(!p.decrypts(&to("a.example.com", 443)));
|
||||
let p = policy(&["*", "!app:Slack"]);
|
||||
assert!(!p.decrypts(&from_app("slack.com", "Slack", Some("com.tinyspeck.slackmacgap"))));
|
||||
assert!(p.decrypts(&from_app("slack.com", "Safari", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_normalized() {
|
||||
let parse = |s: &str| DecryptEntry::parse(s).unwrap().to_string();
|
||||
assert_eq!(parse("api.example.com"), "host:api.example.com");
|
||||
assert_eq!(parse(" API.Example.com. "), "host:api.example.com");
|
||||
assert_eq!(parse("HOST:*.Example.com"), "host:*.example.com");
|
||||
assert_eq!(parse("! host:x.com"), "!host:x.com");
|
||||
assert_eq!(parse("!x.com"), "!host:x.com");
|
||||
assert_eq!(parse("https://api.example.com:8443/v1?q"), "host:api.example.com:8443");
|
||||
assert_eq!(parse("App: Google Chrome"), "app:Google Chrome");
|
||||
assert_eq!(parse(" * "), "*");
|
||||
assert_eq!(parse(r#"app:"Google Chrome""#), "app:Google Chrome");
|
||||
assert!(DecryptEntry::parse("").is_err());
|
||||
assert!(DecryptEntry::parse("!").is_err());
|
||||
assert!(DecryptEntry::parse("app:").is_err());
|
||||
assert!(DecryptEntry::parse("host:a b").is_err());
|
||||
assert!(DecryptEntry::parse("host:a.com:http").is_err());
|
||||
assert!(DecryptEntry::parse("!host:*.apple.com").unwrap().built_in);
|
||||
assert!(!DecryptEntry::parse("host:*.apple.com").unwrap().built_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs() {
|
||||
assert!(glob("*", ""));
|
||||
assert!(glob("a*c", "abbbc"));
|
||||
assert!(glob("*b*", "abc"));
|
||||
assert!(!glob("a*c", "abd"));
|
||||
assert!(glob("client*.dropbox.com", "client-lb.dropbox.com"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod body;
|
||||
mod cert;
|
||||
mod connection;
|
||||
mod decode;
|
||||
mod decrypt;
|
||||
mod grpc;
|
||||
mod messages;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -17,7 +18,7 @@ use std::hash::{BuildHasher, Hasher};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use bytes::Bytes;
|
||||
@@ -32,6 +33,9 @@ use tokio::net::TcpListener;
|
||||
|
||||
pub use cert::{CaStore, KeyCipher};
|
||||
use connection::handle_connection;
|
||||
pub use decrypt::{
|
||||
BUILT_IN_EXCLUSIONS, DecryptApp, DecryptEntry, DecryptPolicy, DecryptQuery, DecryptTarget,
|
||||
};
|
||||
pub use grpc::{detect as detect_grpc, service_and_method};
|
||||
pub use messages::{MessageDirection, MessageKind, RAW_LIMIT, StreamKind, StreamMessage};
|
||||
use request::BoxBody;
|
||||
@@ -62,11 +66,21 @@ pub struct ProxyConfig {
|
||||
/// Record WebSocket frames as messages. Off, upgraded connections are spliced blind, as
|
||||
/// other upgrades are.
|
||||
pub websocket_capture: bool,
|
||||
/// Which HTTPS connections are decrypted; the rest are tunnelled, recorded as a flow each
|
||||
/// without their contents. Everything by default. See [`ProxyHandle::set_decrypt_policy`].
|
||||
pub decrypt: DecryptPolicy,
|
||||
/// Finds the app that opened a client connection, by its number, for the policy's `app:`
|
||||
/// entries. Runs on a blocking thread as each CONNECT is judged, only while the policy has
|
||||
/// such an entry, and may wait briefly for a lookup in progress.
|
||||
pub app_of: Option<AppLookup>,
|
||||
}
|
||||
|
||||
/// See [`ProxyConfig::on_accept`].
|
||||
pub type AcceptHook = Arc<dyn Fn(u64, SocketAddr) + Send + Sync>;
|
||||
|
||||
/// See [`ProxyConfig::app_of`].
|
||||
pub type AppLookup = Arc<dyn Fn(u64) -> Option<DecryptApp> + Send + Sync>;
|
||||
|
||||
impl ProxyConfig {
|
||||
pub fn new(port: u16, ca: Arc<CaStore>) -> Self {
|
||||
Self {
|
||||
@@ -77,6 +91,8 @@ impl ProxyConfig {
|
||||
on_accept: None,
|
||||
http2: true,
|
||||
websocket_capture: true,
|
||||
decrypt: DecryptPolicy::everything(),
|
||||
app_of: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,8 +101,10 @@ impl ProxyConfig {
|
||||
/// `RequestStart` and ends with exactly one `ResponseBodyComplete`, `StreamEnd`, `Error` or
|
||||
/// `Cancelled`. Each HTTP/2 stream is a flow of its own. A streaming flow (WebSocket,
|
||||
/// server-sent events, gRPC) has a `StreamStart` and then a `Message` for each message as it
|
||||
/// passes. Traffic the engine tunnels without decrypting (pinned clients, non-TLS CONNECT
|
||||
/// payloads, upgrades to protocols other than WebSocket) produces no events.
|
||||
/// passes. An HTTPS connection the engine tunnels without decrypting, because the decrypt policy
|
||||
/// leaves it out or its client rejected the engine's certificate, is a flow of its own, from
|
||||
/// `TunnelStart` to `TunnelEnd`. Other traffic tunnelled blind (non-TLS CONNECT payloads,
|
||||
/// upgrades to protocols other than WebSocket) produces no events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProxyEvent {
|
||||
/// Request headers arrived from the client.
|
||||
@@ -181,6 +199,26 @@ pub enum ProxyEvent {
|
||||
elapsed_ms: u64,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A CONNECT is tunnelled without being decrypted: its bytes pass untouched, and only their
|
||||
/// count is recorded.
|
||||
TunnelStart {
|
||||
id: u64,
|
||||
started_at: SystemTime,
|
||||
/// `host[:port]`, the port left out when it is 443.
|
||||
authority: String,
|
||||
connection: u64,
|
||||
client_addr: SocketAddr,
|
||||
},
|
||||
/// The tunnel closed, with `error` when connecting upstream or relaying failed.
|
||||
TunnelEnd {
|
||||
id: u64,
|
||||
/// Bytes from the client to the server.
|
||||
sent: u64,
|
||||
/// Bytes from the server to the client.
|
||||
received: u64,
|
||||
elapsed_ms: u64,
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A running engine. Dropping it stops the listener and joins its thread.
|
||||
@@ -212,6 +250,12 @@ impl ProxyHandle {
|
||||
self.engine.http2.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Decides which HTTPS connections are decrypted from the next CONNECT on. Connections
|
||||
/// already open stay decrypted or tunnelled as they were.
|
||||
pub fn set_decrypt_policy(&self, policy: DecryptPolicy) {
|
||||
*self.engine.decrypt.write().unwrap() = Arc::new(policy);
|
||||
}
|
||||
|
||||
/// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it
|
||||
/// already is. For tests that must wait for the engine to record a failure.
|
||||
#[doc(hidden)]
|
||||
@@ -253,6 +297,8 @@ pub(crate) struct Engine {
|
||||
/// Offer HTTP/2 to clients in intercepted TLS.
|
||||
http2: AtomicBool,
|
||||
websocket_capture: bool,
|
||||
decrypt: RwLock<Arc<DecryptPolicy>>,
|
||||
app_of: Option<AppLookup>,
|
||||
/// `Via` value marking the plain-HTTP requests the engine forwards, unique to this run, so one
|
||||
/// that comes back to the engine is refused instead of forwarded again.
|
||||
via: HeaderValue,
|
||||
@@ -271,6 +317,27 @@ impl Engine {
|
||||
self.http2.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn decrypt_policy(&self) -> Arc<DecryptPolicy> {
|
||||
self.decrypt.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Whether the policy decrypts a CONNECT to `host:port` on `connection`, looking up its app
|
||||
/// only when an entry names one.
|
||||
async fn decrypts(&self, host: &str, port: u16, connection: u64) -> bool {
|
||||
let policy = self.decrypt_policy();
|
||||
if !policy.decrypts_anything() {
|
||||
return false;
|
||||
}
|
||||
let app = match &self.app_of {
|
||||
Some(app_of) if policy.needs_app() => {
|
||||
let app_of = app_of.clone();
|
||||
tokio::task::spawn_blocking(move || app_of(connection)).await.ok().flatten()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
policy.decrypts(&DecryptQuery { host: host.to_string(), port, app })
|
||||
}
|
||||
|
||||
fn is_passthrough(&self, addr: &str) -> bool {
|
||||
self.passthrough.lock().unwrap().contains(addr)
|
||||
}
|
||||
@@ -352,6 +419,8 @@ pub fn start_proxy(config: ProxyConfig) -> Result<ProxyHandle, String> {
|
||||
handshake_failures: Mutex::new(HashMap::new()),
|
||||
http2: AtomicBool::new(config.http2),
|
||||
websocket_capture: config.websocket_capture,
|
||||
decrypt: RwLock::new(Arc::new(config.decrypt)),
|
||||
app_of: config.app_of,
|
||||
via: via_marker(),
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::connection::serve_tls;
|
||||
use crate::grpc::{self, GrpcParser};
|
||||
use crate::messages::{MessageDirection, StreamKind};
|
||||
use crate::sse::SseParser;
|
||||
use crate::tunnel::{Rewind, splice, tunnel};
|
||||
use crate::tunnel::{Counted, Rewind, is_own_connection, splice, tunnel};
|
||||
use crate::ws::{self, WsFlow};
|
||||
use crate::{ClientConn, Engine, ProxyEvent};
|
||||
|
||||
@@ -376,6 +376,7 @@ struct Target {
|
||||
authority: String,
|
||||
/// `host:port` to dial.
|
||||
addr: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn handle_connect(
|
||||
@@ -401,6 +402,7 @@ fn handle_connect(
|
||||
server_name: host.trim_start_matches('[').trim_end_matches(']').to_string(),
|
||||
authority: if port == 443 { host.clone() } else { format!("{host}:{port}") },
|
||||
addr: format!("{host}:{port}"),
|
||||
port,
|
||||
};
|
||||
if let Err(e) = intercept(upgraded, target, client, engine).await {
|
||||
debug!("Tunnel to {host}:{port} ended: {}", error_chain(e.as_ref()));
|
||||
@@ -419,8 +421,13 @@ async fn intercept<S>(
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
if !engine.decrypts(&target.server_name, target.port, conn.connection).await {
|
||||
debug!("Tunneling {} undecrypted, as the decrypt list leaves it out", target.addr);
|
||||
return Ok(tunnel_flow(client, &target, conn, &engine).await?);
|
||||
}
|
||||
// The fallback for clients that rejected the engine's certificate.
|
||||
if engine.is_passthrough(&target.addr) {
|
||||
return Ok(tunnel(client, &target.addr, &[]).await?);
|
||||
return Ok(tunnel_flow(client, &target, conn, &engine).await?);
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
@@ -480,6 +487,49 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tunnels a CONNECT undecrypted, recorded as a flow of its own with the bytes each way. Not
|
||||
/// one of the engine's own connections coming back, which [`tunnel`] refuses.
|
||||
async fn tunnel_flow<S>(
|
||||
client: S,
|
||||
target: &Target,
|
||||
conn: ClientConn,
|
||||
engine: &Engine,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
if is_own_connection(&target.addr).await {
|
||||
return tunnel(client, &target.addr, &[]).await;
|
||||
}
|
||||
let id = engine.next_flow_id();
|
||||
let start = Instant::now();
|
||||
engine.emit(ProxyEvent::TunnelStart {
|
||||
id,
|
||||
started_at: SystemTime::now(),
|
||||
authority: target.authority.clone(),
|
||||
connection: conn.connection,
|
||||
client_addr: conn.addr,
|
||||
});
|
||||
let client = Counted::new(client);
|
||||
let (sent, received) = client.counters();
|
||||
let result = tunnel(client, &target.addr, &[]).await;
|
||||
let (sent, received) = (sent.load(Ordering::Relaxed), received.load(Ordering::Relaxed));
|
||||
// A tunnel that carried the server's bytes and then broke ended as connections do; one that
|
||||
// never did failed to reach the server.
|
||||
let error = match &result {
|
||||
Err(e) if received == 0 => Some(error_chain(e)),
|
||||
_ => None,
|
||||
};
|
||||
engine.emit(ProxyEvent::TunnelEnd {
|
||||
id,
|
||||
sent,
|
||||
received,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
error,
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
fn record_handshake_failure(
|
||||
engine: &Engine,
|
||||
addr: &str,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use bytes::Bytes;
|
||||
@@ -23,6 +25,72 @@ where
|
||||
relay(client, TcpStream::connect(target).await?, prefix).await
|
||||
}
|
||||
|
||||
/// Whether a tunnel to `target` (`host:port`) would be one of the engine's own connections that
|
||||
/// came back through the system proxy, which [`tunnel`] refuses. Only port 80 on macOS can be.
|
||||
pub(crate) async fn is_own_connection(target: &str) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some((host, "80")) = target.rsplit_once(':') {
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
return crate::nw::is_own_fallback(host, 80).await;
|
||||
}
|
||||
let _ = target;
|
||||
false
|
||||
}
|
||||
|
||||
/// A stream that counts the bytes read from it and written to it, for a tunnel's sizes.
|
||||
pub(crate) struct Counted<S> {
|
||||
inner: S,
|
||||
read: Arc<AtomicU64>,
|
||||
written: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<S> Counted<S> {
|
||||
pub(crate) fn new(inner: S) -> Self {
|
||||
Self { inner, read: Arc::default(), written: Arc::default() }
|
||||
}
|
||||
|
||||
/// The counts of bytes read and written, which keep counting after the stream moves on.
|
||||
pub(crate) fn counters(&self) -> (Arc<AtomicU64>, Arc<AtomicU64>) {
|
||||
(self.read.clone(), self.written.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for Counted<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let polled = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
let n = buf.filled().len() - before;
|
||||
self.read.fetch_add(n as u64, Ordering::Relaxed);
|
||||
polled
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for Counted<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let polled = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(n)) = &polled {
|
||||
self.written.fetch_add(*n as u64, Ordering::Relaxed);
|
||||
}
|
||||
polled
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`tunnel`] through Network.framework, which can skip the system proxy. When it can't, it falls
|
||||
/// back to the system proxy with a `CONNECT` that arrives here, and tunneling that out the same
|
||||
/// way would repeat it.
|
||||
|
||||
@@ -33,8 +33,8 @@ use tokio_tungstenite::tungstenite::protocol::Role;
|
||||
use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||
use yaak_proxy::{
|
||||
CaStore, MessageDirection, MessageKind, ProxyConfig, ProxyEvent, ProxyHandle, StreamKind,
|
||||
StreamMessage, start_proxy,
|
||||
CaStore, DecryptApp, DecryptPolicy, MessageDirection, MessageKind, ProxyConfig, ProxyEvent,
|
||||
ProxyHandle, StreamKind, StreamMessage, start_proxy,
|
||||
};
|
||||
|
||||
const BIG_BODY: usize = 64 * 1024;
|
||||
@@ -405,6 +405,7 @@ fn is_terminal(event: &ProxyEvent) -> bool {
|
||||
| ProxyEvent::Error { .. }
|
||||
| ProxyEvent::Cancelled { .. }
|
||||
| ProxyEvent::StreamEnd { .. }
|
||||
| ProxyEvent::TunnelEnd { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,7 +423,9 @@ fn flow_id(event: &ProxyEvent) -> u64 {
|
||||
| ProxyEvent::Cancelled { id, .. }
|
||||
| ProxyEvent::StreamStart { id, .. }
|
||||
| ProxyEvent::Message { id, .. }
|
||||
| ProxyEvent::StreamEnd { id, .. } => *id,
|
||||
| ProxyEvent::StreamEnd { id, .. }
|
||||
| ProxyEvent::TunnelStart { id, .. }
|
||||
| ProxyEvent::TunnelEnd { id, .. } => *id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,7 +1255,171 @@ async fn pinned_client_falls_back_to_passthrough() {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert_eq!(body.as_deref(), Some("hello"));
|
||||
assert!(h.events.try_recv().is_err(), "passthrough traffic must not produce flows");
|
||||
// Passed through, it is a tunnelled flow, recorded without its contents.
|
||||
drop(pinned);
|
||||
let events = h.flow_events();
|
||||
let authority = find!(events, ProxyEvent::TunnelStart { authority, .. } => authority.clone());
|
||||
assert_eq!(authority, format!("localhost:{}", upstream.port()));
|
||||
let (sent, received) =
|
||||
find!(events, ProxyEvent::TunnelEnd { sent, received, .. } => (*sent, *received));
|
||||
assert!(sent > 0 && received > 0, "sent {sent}, received {received}");
|
||||
assert!(!events.iter().any(|e| matches!(e, ProxyEvent::RequestStart { .. })));
|
||||
}
|
||||
|
||||
/// TLS straight to the upstream server through a CONNECT tunnel, trusting only the server's own
|
||||
/// CA, as a client that never trusted the proxy's does.
|
||||
async fn upstream_tls(
|
||||
h: &Harness,
|
||||
port: u16,
|
||||
alpn: &[&[u8]],
|
||||
) -> tokio_rustls::client::TlsStream<TcpStream> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(h.pki.ca_der.clone()).unwrap();
|
||||
let mut config = ClientConfig::builder_with_provider(ring())
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = alpn.iter().map(|p| p.to_vec()).collect();
|
||||
let name = ServerName::try_from("localhost").unwrap();
|
||||
let tcp = h.connect_tunnel(port).await;
|
||||
TlsConnector::from(Arc::new(config)).connect(name, tcp).await.expect("TLS to the server")
|
||||
}
|
||||
|
||||
/// `GET /hello` over HTTP/1.1 on `tls`, closing the connection after.
|
||||
async fn get_hello_and_close(mut tls: tokio_rustls::client::TlsStream<TcpStream>) -> String {
|
||||
tls.write_all(b"GET /hello HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = Vec::new();
|
||||
let _ = tls.read_to_end(&mut response).await;
|
||||
String::from_utf8_lossy(&response).into_owned()
|
||||
}
|
||||
|
||||
fn with_policy(entries: &[&str]) -> impl FnOnce(&mut ProxyConfig) {
|
||||
let policy = DecryptPolicy::parse(entries).unwrap();
|
||||
move |config| config.decrypt = policy
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_empty_decrypt_list_tunnels_https_untouched() {
|
||||
within(async {
|
||||
let h = Harness::start_with(1024, with_policy(&[]));
|
||||
let upstream = spawn_upstream(Some(h.pki.server.clone())).await;
|
||||
|
||||
// The server's own certificate arrives, so no CA of the proxy's is involved.
|
||||
let tls = upstream_tls(&h, upstream.port(), &[b"http/1.1"]).await;
|
||||
let response = get_hello_and_close(tls).await;
|
||||
assert!(response.starts_with("HTTP/1.1 200"), "{response}");
|
||||
assert!(response.ends_with("hello"), "{response}");
|
||||
|
||||
let events = h.flow_events();
|
||||
let (connection, authority) = find!(events, ProxyEvent::TunnelStart { connection, authority, .. } => (*connection, authority.clone()));
|
||||
assert_eq!(connection, 1);
|
||||
assert_eq!(authority, format!("localhost:{}", upstream.port()));
|
||||
let (sent, received, error) = find!(events, ProxyEvent::TunnelEnd { sent, received, error, .. } => (*sent, *received, error.clone()));
|
||||
assert_eq!(error, None);
|
||||
// The ClientHello and request one way, the certificate and response the other.
|
||||
assert!(sent > 100, "sent {sent}");
|
||||
assert!(received as usize > response.len(), "received {received}");
|
||||
assert!(!events.iter().any(|e| matches!(e, ProxyEvent::RequestStart { .. })));
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_tunnelled_host_passes_http2_through() {
|
||||
within(async {
|
||||
let h = Harness::start_with(1024, with_policy(&["host:example.com"]));
|
||||
let upstream = spawn_upstream(Some(h.pki.server.clone())).await;
|
||||
let tls = upstream_tls(&h, upstream.port(), &[b"h2", b"http/1.1"]).await;
|
||||
assert_eq!(tls.get_ref().1.alpn_protocol(), Some(&b"h2"[..]), "the server's ALPN");
|
||||
let (mut sender, conn) =
|
||||
http2::handshake(TokioExecutor::new(), TokioIo::new(tls)).await.unwrap();
|
||||
let conn = tokio::spawn(conn);
|
||||
let req = Request::get(format!("https://localhost:{}/hello", upstream.port()))
|
||||
.body(full(Bytes::new()))
|
||||
.unwrap();
|
||||
let resp = sender.send_request(req).await.unwrap();
|
||||
assert_eq!(resp.version(), Version::HTTP_2);
|
||||
assert_eq!(resp.into_body().collect().await.unwrap().to_bytes(), "hello");
|
||||
drop(sender);
|
||||
let _ = conn.await;
|
||||
|
||||
let events = h.flow_events();
|
||||
find!(events, ProxyEvent::TunnelStart { .. } => ());
|
||||
let error = find!(events, ProxyEvent::TunnelEnd { error, .. } => error.clone());
|
||||
assert_eq!(error, None);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_listed_host_is_decrypted_and_others_tunnelled() {
|
||||
within(async {
|
||||
let h = Harness::start_with(1024, with_policy(&["host:localhost"]));
|
||||
let upstream = spawn_upstream(Some(h.pki.server.clone())).await;
|
||||
let url = format!("https://localhost:{}/hello", upstream.port());
|
||||
assert_eq!(h.client().get(&url).send().await.unwrap().text().await.unwrap(), "hello");
|
||||
let events = h.flow_events();
|
||||
let flow_url = find!(events, ProxyEvent::RequestStart { url, .. } => url.clone());
|
||||
assert_eq!(flow_url, url);
|
||||
|
||||
// Only from the next connection on.
|
||||
h.proxy.set_decrypt_policy(DecryptPolicy::parse(&["host:*.example.com"]).unwrap());
|
||||
let tls = upstream_tls(&h, upstream.port(), &[b"http/1.1"]).await;
|
||||
assert!(get_hello_and_close(tls).await.ends_with("hello"));
|
||||
let events = h.flow_events();
|
||||
find!(events, ProxyEvent::TunnelEnd { .. } => ());
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_app_entry_decrypts_that_app_alone() {
|
||||
within(async {
|
||||
let h = Harness::start_with(1024, |config| {
|
||||
config.decrypt = DecryptPolicy::parse(&["app:com.example.curl"]).unwrap();
|
||||
// The first connection is from the listed app, the rest from another.
|
||||
config.app_of = Some(Arc::new(|connection| {
|
||||
let bundle_id = if connection == 1 { "com.example.curl" } else { "com.other" };
|
||||
Some(DecryptApp { name: "curl".into(), bundle_id: Some(bundle_id.into()) })
|
||||
}));
|
||||
});
|
||||
let upstream = spawn_upstream(Some(h.pki.server.clone())).await;
|
||||
let url = format!("https://localhost:{}/hello", upstream.port());
|
||||
let client = h.client();
|
||||
assert_eq!(client.get(&url).send().await.unwrap().text().await.unwrap(), "hello");
|
||||
let events = h.flow_events();
|
||||
find!(events, ProxyEvent::RequestStart { .. } => ());
|
||||
drop(client);
|
||||
|
||||
let tls = upstream_tls(&h, upstream.port(), &[b"http/1.1"]).await;
|
||||
assert!(get_hello_and_close(tls).await.ends_with("hello"));
|
||||
let events = h.flow_events();
|
||||
let connection = find!(events, ProxyEvent::TunnelStart { connection, .. } => *connection);
|
||||
assert_eq!(connection, 2);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_tunnel_to_nowhere_is_a_failed_flow() {
|
||||
within(async {
|
||||
let h = Harness::start_with(1024, with_policy(&[]));
|
||||
// A port nothing listens on.
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let mut tcp = h.connect_tunnel(closed).await;
|
||||
let _ = tcp.write_all(b"\x16\x03\x01").await;
|
||||
let mut rest = Vec::new();
|
||||
let _ = tcp.read_to_end(&mut rest).await;
|
||||
let events = h.flow_events();
|
||||
let (received, error) =
|
||||
find!(events, ProxyEvent::TunnelEnd { received, error, .. } => (*received, error.clone()));
|
||||
assert_eq!(received, 0);
|
||||
assert!(error.is_some(), "{events:#?}");
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
+81
-19
@@ -13,6 +13,18 @@ export interface FieldDef {
|
||||
|
||||
export interface FilterOptions {
|
||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||
/** Written before a field name, `@` by default: `@method:`. Empty for `host:` */
|
||||
fieldPrefix?: string;
|
||||
/**
|
||||
* A prefix that negates a term, such as `!`: offered where a field name is, and a term may
|
||||
* start with it before its field name
|
||||
*/
|
||||
negation?: { prefix: string; info?: string };
|
||||
/**
|
||||
* Value suggestions replace only what follows the field's colon, so `host:api` offers hosts
|
||||
* matching `api`. Off, they replace the whole term
|
||||
*/
|
||||
replaceValueOnly?: boolean;
|
||||
}
|
||||
|
||||
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
|
||||
@@ -44,19 +56,31 @@ function wordBefore(
|
||||
return { from, to: pos, text: m[0] };
|
||||
}
|
||||
|
||||
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
|
||||
function fieldCompletionFrom(
|
||||
doc: string,
|
||||
pos: number,
|
||||
fieldPrefix = "@",
|
||||
negation?: string,
|
||||
): { from: number; includeAt: boolean; negated: boolean } | null {
|
||||
const w = wordBefore(doc, pos, FIELD_IDENT);
|
||||
const from = w?.from ?? pos;
|
||||
const beforeToken = doc[from - 1];
|
||||
|
||||
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
|
||||
return { from, includeAt: true };
|
||||
let from = w?.from ?? pos;
|
||||
const atTermStart = (at: number) => {
|
||||
const before = doc[at - 1];
|
||||
return at === 0 || (before != null && /\s/.test(before));
|
||||
};
|
||||
let negated = false;
|
||||
if (negation != null && doc.slice(0, from).endsWith(negation)) {
|
||||
from -= negation.length;
|
||||
negated = true;
|
||||
}
|
||||
|
||||
if (beforeToken === "@") {
|
||||
const beforeAt = doc[from - 2];
|
||||
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
|
||||
return { from, includeAt: false };
|
||||
if (atTermStart(from)) {
|
||||
return { from, includeAt: true, negated };
|
||||
}
|
||||
|
||||
if (fieldPrefix !== "" && doc.slice(0, from).endsWith(fieldPrefix)) {
|
||||
if (atTermStart(from - fieldPrefix.length)) {
|
||||
return { from, includeAt: false, negated };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +141,20 @@ function contextInfo(stateDoc: string, pos: number) {
|
||||
}
|
||||
|
||||
/** Build a completion list for field names */
|
||||
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
|
||||
function fieldNameCompletions(
|
||||
fieldNames: string[],
|
||||
includeAt: boolean,
|
||||
fieldPrefix = "@",
|
||||
fieldMap: Record<string, { info?: string }> = {},
|
||||
negated = "",
|
||||
): Completion[] {
|
||||
return fieldNames.map((name) => ({
|
||||
label: name,
|
||||
label: `${negated}${name}`,
|
||||
type: "property",
|
||||
info: fieldMap[name]?.info,
|
||||
apply: (view, _completion, from, to) => {
|
||||
// Leave cursor right after the field filter colon.
|
||||
const insert = `${includeAt ? "@" : ""}${name}:`;
|
||||
const insert = `${negated}${includeAt ? fieldPrefix : ""}${name}:`;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
@@ -146,8 +177,24 @@ function fieldValueCompletions(
|
||||
}));
|
||||
}
|
||||
|
||||
/** Offers the negation prefix itself, reopening the list for a field name after it */
|
||||
function negationCompletion(negation: { prefix: string; info?: string }): Completion {
|
||||
return {
|
||||
label: negation.prefix,
|
||||
type: "keyword",
|
||||
info: negation.info,
|
||||
apply: (view, _completion, from, to) => {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: negation.prefix },
|
||||
selection: { anchor: from + negation.prefix.length },
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The main completion source */
|
||||
function makeCompletionSource(opts: FilterOptions) {
|
||||
export function makeCompletionSource(opts: FilterOptions) {
|
||||
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
const { state, pos } = ctx;
|
||||
@@ -157,12 +204,12 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||
const { inValue, fieldName, lastColon, emptyAfterColon } = contextInfo(doc, pos);
|
||||
|
||||
// In field value position
|
||||
if (inValue && fieldName) {
|
||||
const w = wordBefore(doc, pos, VALUE_IDENT);
|
||||
const from = w?.from ?? pos;
|
||||
const from = opts.replaceValueOnly ? lastColon + 1 : (w?.from ?? pos);
|
||||
const to = pos;
|
||||
const valDefs = fieldMap[fieldName];
|
||||
const vals = fieldValueCompletions(valDefs);
|
||||
@@ -186,11 +233,18 @@ function makeCompletionSource(opts: FilterOptions) {
|
||||
}
|
||||
|
||||
// Not in a value: suggest field names (and maybe boolean ops)
|
||||
const completion = fieldCompletionFrom(doc, pos);
|
||||
const completion = fieldCompletionFrom(doc, pos, opts.fieldPrefix, opts.negation?.prefix);
|
||||
if (completion == null) return null;
|
||||
const { from, includeAt } = completion;
|
||||
const { from, includeAt, negated } = completion;
|
||||
const to = pos;
|
||||
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
|
||||
const options: Completion[] = fieldNameCompletions(
|
||||
fieldNames,
|
||||
includeAt,
|
||||
opts.fieldPrefix,
|
||||
fieldMap,
|
||||
negated ? (opts.negation?.prefix ?? "") : "",
|
||||
);
|
||||
if (opts.negation != null && !negated) options.push(negationCompletion(opts.negation));
|
||||
|
||||
return { from, to, options, filter: true };
|
||||
};
|
||||
@@ -204,6 +258,14 @@ const language = LRLanguage.define({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Field and value suggestions, as the sidebar's filter offers them, for a field of another
|
||||
* language: a plain input of `host:` and `app:` entries, say
|
||||
*/
|
||||
export function filterAutocomplete(options: FilterOptions) {
|
||||
return autocompletion({ override: [makeCompletionSource(options)] });
|
||||
}
|
||||
|
||||
/** Public extension */
|
||||
export function filter(options: FilterOptions) {
|
||||
const source = makeCompletionSource(options);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,12 @@
|
||||
export type { CodeEditorProps } from "./components/editor/CodeEditor";
|
||||
export { CodeEditor } from "./components/editor/CodeEditor";
|
||||
export type { LanguageSupports } from "./components/editor/extensions";
|
||||
export type { FieldDef, FilterOptions } from "./components/editor/filter/extension";
|
||||
export {
|
||||
filter,
|
||||
filterAutocomplete,
|
||||
makeCompletionSource as filterCompletionSource,
|
||||
} from "./components/editor/filter/extension";
|
||||
export {
|
||||
closeBracketsExtensions,
|
||||
getLanguageExtension,
|
||||
|
||||
Reference in new issue
Block a user