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:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-11-06 21:24:39 -07:00
1 parent 91696c2d1f
commit 4538123d88
44 files changed
+3259 -66

No files matched your search

+1 -2
View File
@@ -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>
);
}
+45 -3
View File
@@ -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} />
)}
+15 -5
View File
@@ -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",
+75 -2
View File
@@ -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>
);
+1
View File
@@ -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 */
+45
View File
@@ -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 }),
});
}
+305
View File
@@ -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"],
});
});
});
+394
View File
@@ -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" },
],
};
}
+19 -1
View File
@@ -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");
});
});
+21
View File
@@ -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" };
}
+1
View File
@@ -58,6 +58,7 @@ const flow: Flow = {
grpcMethod: null,
grpcStatus: null,
grpcMessage: null,
tunnelled: false,
};
function bytes(text: string): FlowBodyData {
+10
View File
@@ -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"),
+7 -2
View File
@@ -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));
+38
View File
@@ -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));
+36 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+35 -3
View File
@@ -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
}
+18 -2
View File
@@ -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)?)
+124 -1
View File
@@ -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);
}
}
+117 -6
View File
@@ -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]
+176 -3
View File
@@ -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(),
})
}
}
+195 -1
View File
@@ -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);
}
+214 -1
View File
@@ -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
+445
View File
@@ -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"));
}
}
+72 -3
View File
@@ -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(),
});
+52 -2
View File
@@ -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,
+68
View File
@@ -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.
+171 -4
View File
@@ -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")]
@@ -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);
+6
View File
@@ -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,