mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Privileged helper for system proxy (#796)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d38a482f6
commit
f7aea62c07
33 files changed
+3536
-176
No files matched your search
Generated
+29
-2
@@ -4977,15 +4977,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-service-management"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.1"
|
||||
@@ -11753,6 +11765,19 @@ dependencies = [
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-proxy-helper"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
"security-framework 3.5.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-proxy-lib"
|
||||
version = "0.0.0"
|
||||
@@ -11770,6 +11795,7 @@ dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-service-management",
|
||||
"plist",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
@@ -11789,6 +11815,7 @@ dependencies = [
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-proxy",
|
||||
"yaak-proxy-helper",
|
||||
"yaak-rpc",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ members = [
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-window",
|
||||
@@ -110,6 +111,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,96 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import { Banner, Dialog, DialogFooter, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import loginItems from "../assets/login-items.png";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
|
||||
/** Yaak Proxy's row in `login-items.png`, as percentages of the image, for the highlight */
|
||||
const YAAK_PROXY_ROW = { left: 1, top: 34, width: 98, height: 32 };
|
||||
|
||||
/**
|
||||
* Walks through allowing the helper: registering it opens System Settings, where the user turns
|
||||
* it on. Stays open while macOS awaits that approval, and closes itself once it is given.
|
||||
*/
|
||||
export function HelperDialog() {
|
||||
const [open, setOpen] = useAtom(helperDialogOpenAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const setRegisteredAt = useSetAtom(helperRegisteredAtAtom);
|
||||
// Polled by the header while approval is pending, so this sees it as soon as it is given.
|
||||
const status = useRpcQuery("system_proxy_status", {});
|
||||
const register = useRpcMutation("helper_register", {
|
||||
onSuccess: (status) => {
|
||||
setRegisteredAt(Date.now());
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status);
|
||||
},
|
||||
});
|
||||
const helper = status.data?.helper;
|
||||
const waiting = helper === "requires_approval";
|
||||
const { reset } = register;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && helper === "enabled") {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}
|
||||
}, [open, helper, setOpen, reset]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={close}
|
||||
size="dynamic"
|
||||
title="Turn on Yaak Proxy under Allow in the Background"
|
||||
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
|
||||
className="w-[34rem]"
|
||||
>
|
||||
<div className="grid gap-3 pb-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={loginItems}
|
||||
alt="System Settings, with Yaak Proxy's toggle in the list of apps allowed in the background"
|
||||
className="block w-full rounded-md border border-border-subtle"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-md ring-2 ring-primary bg-primary/10 pointer-events-none"
|
||||
style={{
|
||||
left: `${YAAK_PROXY_ROW.left}%`,
|
||||
top: `${YAAK_PROXY_ROW.top}%`,
|
||||
width: `${YAAK_PROXY_ROW.width}%`,
|
||||
height: `${YAAK_PROXY_ROW.height}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{register.error != null && <Banner color="danger">{register.error.message}</Banner>}
|
||||
</div>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
waiting && (
|
||||
<span className="flex items-center gap-2 text-sm text-text-subtle">
|
||||
<LoadingIcon size="xs" />
|
||||
Waiting for approval
|
||||
</span>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
{ label: waiting ? "Close" : "Cancel", onClick: close },
|
||||
{
|
||||
label: "Open System Settings",
|
||||
color: "primary",
|
||||
isLoading: register.isPending,
|
||||
onClick: () => register.mutate({}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { FirstRun } from "./FirstRun";
|
||||
import { FlowDetail } from "./FlowDetail";
|
||||
import { FlowList } from "./FlowList";
|
||||
import { ProxyStatus } from "./ProxyStatus";
|
||||
import { HelperDialog } from "./HelperDialog";
|
||||
import { SaveDialog } from "./SaveDialog";
|
||||
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||
import { TakeOverBanner } from "./TakeOverBanner";
|
||||
@@ -139,6 +140,7 @@ export function ProxyLayout() {
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
<SaveDialog />
|
||||
<HelperDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,15 +9,22 @@ import {
|
||||
Icon,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useStore } from "jotai";
|
||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { environmentExports } from "../lib/env";
|
||||
import { proxyStateAtom, refreshProxyState } from "../lib/store";
|
||||
import { recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||
import {
|
||||
helperPollInterval,
|
||||
helperRow,
|
||||
recordingLabel,
|
||||
systemProxySwitch,
|
||||
} from "../lib/systemProxy";
|
||||
import { onWindowFocus } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/**
|
||||
@@ -34,6 +41,7 @@ export function ProxyStatus() {
|
||||
const setAtLaunch = useSetSystemProxyAtLaunch();
|
||||
const setHttpVersion = useSetHttpVersion();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom);
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
|
||||
@@ -131,6 +139,19 @@ export function ProxyStatus() {
|
||||
</KeepMenuOpen>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: systemProxy.data != null && (
|
||||
<HelperMenuRow
|
||||
status={systemProxy.data}
|
||||
onOpenDialog={() => {
|
||||
dropdownRef.current?.close?.();
|
||||
setHelperDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: caStatus.data?.trusted ? (
|
||||
@@ -253,7 +274,19 @@ export function ProxyStatus() {
|
||||
export function useProxySetup() {
|
||||
const queryClient = useQueryClient();
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const systemProxy = useRpcQuery("system_proxy_status", {});
|
||||
const registeredAt = useAtomValue(helperRegisteredAtAtom);
|
||||
const systemProxy = useRpcQuery(
|
||||
"system_proxy_status",
|
||||
{},
|
||||
{
|
||||
// The helper is approved in System Settings, out of the app's sight.
|
||||
refetchInterval: (query) =>
|
||||
helperPollInterval(query.state.data?.helper, registeredAt, Date.now()),
|
||||
},
|
||||
);
|
||||
// The user comes back from System Settings, maybe having just allowed the helper.
|
||||
const { refetch: refetchSystemProxy } = systemProxy;
|
||||
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
|
||||
const trust = useRpcMutation("ca_trust", {
|
||||
mutationKey: ["ca_trust"],
|
||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||
@@ -279,9 +312,63 @@ export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
|
||||
port: proxyState?.port ?? proxyState?.configuredPort ?? null,
|
||||
pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null,
|
||||
error: setSystemProxy.error?.message ?? null,
|
||||
helper: systemProxy.data?.backend === "helper",
|
||||
});
|
||||
}
|
||||
|
||||
/** The privileged helper: whether it is installed and approved, and which way changes go */
|
||||
function HelperMenuRow({
|
||||
status,
|
||||
onOpenDialog,
|
||||
}: {
|
||||
status: SystemProxyStatus;
|
||||
onOpenDialog: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const unregister = useRpcMutation("helper_unregister", {
|
||||
onSuccess: (status) =>
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
|
||||
});
|
||||
const row = helperRow(status.helper, status.backend);
|
||||
const error = unregister.error;
|
||||
return (
|
||||
<MenuRow
|
||||
icon={
|
||||
<Icon
|
||||
icon={status.helper === "enabled" ? "badge_check" : "lock"}
|
||||
size="sm"
|
||||
color={status.helper === "enabled" ? "success" : "secondary"}
|
||||
/>
|
||||
}
|
||||
title={row.title}
|
||||
description={
|
||||
error != null ? <span className="text-danger">{error.message}</span> : row.description
|
||||
}
|
||||
action={
|
||||
row.action != null && (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={row.action === "unregister" ? "default" : "primary"}
|
||||
isLoading={unregister.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.action === "unregister") unregister.mutate({});
|
||||
else onOpenDialog();
|
||||
}}
|
||||
>
|
||||
{row.action === "install"
|
||||
? "Install"
|
||||
: row.action === "approve"
|
||||
? "Approve"
|
||||
: "Unregister"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saves whether the system proxy turns on as the app launches */
|
||||
function useSetSystemProxyAtLaunch() {
|
||||
const store = useStore();
|
||||
|
||||
@@ -14,5 +14,11 @@ export const selectedFlowIdAtom = atom((get) => get(selectedFlowAtom)?.id ?? nul
|
||||
/** The list follows new traffic, rather than being held where it was scrolled or selected */
|
||||
export const followingAtom = atom(true);
|
||||
|
||||
/** When the helper was last registered, so its state is read until approval shows */
|
||||
export const helperRegisteredAtAtom = atom<number | null>(null);
|
||||
|
||||
/** The dialog that walks through allowing the helper in System Settings */
|
||||
export const helperDialogOpenAtom = atom(false);
|
||||
|
||||
/** Stacked by default; the header's toggle switches to side by side */
|
||||
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { HelperState } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { recordingLabel, type SystemProxyState, systemProxySwitch } from "./systemProxy";
|
||||
import {
|
||||
helperPollInterval,
|
||||
helperRow,
|
||||
recordingLabel,
|
||||
type SystemProxyState,
|
||||
systemProxySwitch,
|
||||
} from "./systemProxy";
|
||||
|
||||
const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null };
|
||||
|
||||
@@ -25,6 +32,14 @@ describe("systemProxySwitch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("waits for no password when the helper makes the change", () => {
|
||||
expect(systemProxySwitch({ ...off, pending: false, helper: true })).toEqual({
|
||||
checked: false,
|
||||
description: "Changing the system proxy",
|
||||
failed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("snaps back on a cancelled prompt and says so", () => {
|
||||
expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({
|
||||
checked: false,
|
||||
@@ -37,6 +52,107 @@ describe("systemProxySwitch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("helperRow", () => {
|
||||
test("offers each next step, and says which way changes go", () => {
|
||||
expect(helperRow("not_registered", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not installed",
|
||||
action: "install",
|
||||
});
|
||||
expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: awaiting approval",
|
||||
action: "approve",
|
||||
});
|
||||
const enabled = helperRow("enabled", "helper");
|
||||
expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" });
|
||||
expect(enabled.description).toMatch(/No password/);
|
||||
});
|
||||
|
||||
test("says when the password prompt still has settings to put back", () => {
|
||||
expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/);
|
||||
});
|
||||
|
||||
test("offers to install when macOS can't find it, rather than blaming a dev build", () => {
|
||||
expect(helperRow("not_found", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not found by macOS",
|
||||
action: "install",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps every row short", () => {
|
||||
const states: HelperState[] = [
|
||||
"unavailable",
|
||||
"not_found",
|
||||
"not_registered",
|
||||
"requires_approval",
|
||||
"enabled",
|
||||
];
|
||||
for (const state of states) {
|
||||
for (const backend of ["helper", "admin_prompt"] as const) {
|
||||
const row = helperRow(state, backend);
|
||||
expect(row.title.length).toBeLessThanOrEqual(30);
|
||||
expect(row.description.length).toBeLessThanOrEqual(60);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("has a titled row for every state the backend sends", () => {
|
||||
// A missing key here fails the typecheck, so a new state can't be left out.
|
||||
const states: Record<HelperState, true> = {
|
||||
unavailable: true,
|
||||
not_found: true,
|
||||
not_registered: true,
|
||||
requires_approval: true,
|
||||
enabled: true,
|
||||
};
|
||||
for (const state of Object.keys(states) as HelperState[]) {
|
||||
for (const backend of ["helper", "admin_prompt", "sudo"] as const) {
|
||||
expect(helperRow(state, backend).title).toMatch(/^Helper: \S/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("still gives a row for a state this build doesn't know", () => {
|
||||
const row = helperRow("from_the_future" as HelperState, "admin_prompt");
|
||||
expect(row).toEqual({
|
||||
title: "Helper: from_the_future",
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("says plainly that a dev build cannot use it", () => {
|
||||
expect(helperRow("unavailable", "admin_prompt")).toMatchObject({
|
||||
title: "Helper: not in dev builds",
|
||||
action: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("helperPollInterval", () => {
|
||||
const now = 100_000;
|
||||
|
||||
test("polls every 2 seconds while approval is pending", () => {
|
||||
expect(helperPollInterval("requires_approval", null, now)).toBe(2000);
|
||||
});
|
||||
|
||||
test("stops once enabled or not registered, even just after registering", () => {
|
||||
expect(helperPollInterval("enabled", now - 500, now)).toBe(false);
|
||||
expect(helperPollInterval("not_registered", now - 500, now)).toBe(false);
|
||||
});
|
||||
|
||||
test("polls briefly after registering, until approval shows", () => {
|
||||
expect(helperPollInterval("not_found", now - 500, now)).toBe(2000);
|
||||
expect(helperPollInterval(undefined, now - 500, now)).toBe(2000);
|
||||
expect(helperPollInterval("not_found", now - 10_000, now)).toBe(false);
|
||||
});
|
||||
|
||||
test("never polls in other states", () => {
|
||||
for (const helper of ["unavailable", "not_found", undefined] as const) {
|
||||
expect(helperPollInterval(helper, null, now)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordingLabel", () => {
|
||||
test("offers the opposite of the current state", () => {
|
||||
expect(recordingLabel(true)).toBe("Pause Recording");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { HelperState, SystemProxyBackend } from "@yaakapp-internal/proxy-lib";
|
||||
|
||||
/**
|
||||
* "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once
|
||||
* whenever it is flipped, through the macOS password prompt. Whether it turns on as the app
|
||||
* launches is a separate setting.
|
||||
* whenever it is flipped, through the privileged helper or the macOS password prompt. Whether it
|
||||
* turns on as the app launches is a separate setting.
|
||||
*/
|
||||
export interface SystemProxyState {
|
||||
/** This Mac's web traffic points at the engine now */
|
||||
@@ -12,6 +14,8 @@ export interface SystemProxyState {
|
||||
pending: boolean | null;
|
||||
/** The last change failed, with this message */
|
||||
error: string | null;
|
||||
/** Changes go through the helper, with no password prompt */
|
||||
helper?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemProxySwitch {
|
||||
@@ -24,7 +28,8 @@ export interface SystemProxySwitch {
|
||||
/** What the system proxy switch shows */
|
||||
export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
|
||||
if (state.pending != null) {
|
||||
return { checked: state.pending, description: "Waiting for your password", failed: false };
|
||||
const description = state.helper ? "Changing the system proxy" : "Waiting for your password";
|
||||
return { checked: state.pending, description, failed: false };
|
||||
}
|
||||
// A failed change leaves the switch where the system proxy actually is.
|
||||
const checked = state.routing;
|
||||
@@ -45,6 +50,85 @@ export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
|
||||
};
|
||||
}
|
||||
|
||||
export interface HelperRow {
|
||||
title: string;
|
||||
/** Which way changes go, and why */
|
||||
description: string;
|
||||
/** What the row's button does, if it has one: the first two open the approval dialog */
|
||||
action: "install" | "approve" | "unregister" | null;
|
||||
}
|
||||
|
||||
/** The privileged helper's row in the state pill menu, kept short */
|
||||
export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow {
|
||||
switch (helper) {
|
||||
case "unavailable":
|
||||
return {
|
||||
title: "Helper: not in dev builds",
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
};
|
||||
case "not_found":
|
||||
// What macOS reports until the helper is first installed.
|
||||
return {
|
||||
title: "Helper: not found by macOS",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "install",
|
||||
};
|
||||
case "not_registered":
|
||||
return {
|
||||
title: "Helper: not installed",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "install",
|
||||
};
|
||||
case "requires_approval":
|
||||
return {
|
||||
title: "Helper: awaiting approval",
|
||||
description: "Changes use the password prompt.",
|
||||
action: "approve",
|
||||
};
|
||||
case "enabled":
|
||||
return {
|
||||
title: "Helper: enabled",
|
||||
description:
|
||||
backend === "helper"
|
||||
? "No password prompts."
|
||||
: "One more password prompt puts back your settings.",
|
||||
action: "unregister",
|
||||
};
|
||||
default: {
|
||||
// Every state is handled above; one this build doesn't know still gets a row, rather
|
||||
// than taking the whole menu down.
|
||||
const unknown: never = helper;
|
||||
return {
|
||||
title: `Helper: ${String(unknown)}`,
|
||||
description: "Changes use the password prompt.",
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How often the helper's state is read while it can change outside the app */
|
||||
export const HELPER_POLL_MS = 2000;
|
||||
|
||||
/** How long after registering the state is read even before it shows approval pending */
|
||||
export const HELPER_REGISTER_POLL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* The interval to read the helper's state at: while it awaits approval in System Settings, and
|
||||
* briefly after registering, until it settles as enabled or not registered. Never otherwise.
|
||||
*/
|
||||
export function helperPollInterval(
|
||||
helper: HelperState | undefined,
|
||||
registeredAt: number | null,
|
||||
now: number,
|
||||
): number | false {
|
||||
if (helper === "requires_approval") return HELPER_POLL_MS;
|
||||
if (helper === "enabled" || helper === "not_registered") return false;
|
||||
const justRegistered = registeredAt != null && now - registeredAt < HELPER_REGISTER_POLL_MS;
|
||||
return justRegistered ? HELPER_POLL_MS : false;
|
||||
}
|
||||
|
||||
/** The menu item that pauses or resumes storing flows */
|
||||
export function recordingLabel(recording: boolean): string {
|
||||
return recording ? "Pause Recording" : "Resume Recording";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { type as tauriOsType } from "@tauri-apps/plugin-os";
|
||||
import { unsubscribeWhenReady } from "./unsubscribe";
|
||||
|
||||
/** Call a Tauri command. */
|
||||
export function command<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
@@ -19,6 +20,16 @@ export function subscribe<T>(event: string, callback: (payload: T) => void): ()
|
||||
return () => unsub?.();
|
||||
}
|
||||
|
||||
/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */
|
||||
export function onWindowFocus(callback: () => void): () => void {
|
||||
const subscription = unsubscribeWhenReady(
|
||||
getCurrentWebviewWindow().onFocusChanged(
|
||||
({ payload: focused }) => focused && subscription.active() && callback(),
|
||||
),
|
||||
);
|
||||
return subscription.unsubscribe;
|
||||
}
|
||||
|
||||
/** Show the current webview window. */
|
||||
export function showWindow(): Promise<void> {
|
||||
return getCurrentWebviewWindow().show();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { unsubscribeWhenReady } from "./unsubscribe";
|
||||
|
||||
function pending() {
|
||||
let resolve: (fn: () => void) => void = () => {};
|
||||
const promise = new Promise<() => void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("unsubscribeWhenReady", () => {
|
||||
test("unsubscribes once ready when cleanup ran first", async () => {
|
||||
const { promise, resolve } = pending();
|
||||
const unsub = vi.fn();
|
||||
const subscription = unsubscribeWhenReady(promise);
|
||||
subscription.unsubscribe();
|
||||
expect(subscription.active()).toBe(false);
|
||||
resolve(unsub);
|
||||
await promise;
|
||||
await Promise.resolve();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("unsubscribes at once when already ready, and only once", async () => {
|
||||
const unsub = vi.fn();
|
||||
const promise = Promise.resolve(unsub);
|
||||
const subscription = unsubscribeWhenReady(promise);
|
||||
await promise;
|
||||
await Promise.resolve();
|
||||
expect(subscription.active()).toBe(true);
|
||||
subscription.unsubscribe();
|
||||
subscription.unsubscribe();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* An unsubscribe for a subscription that is still being set up. Called before the setup
|
||||
* resolves, it unsubscribes as soon as it does; `active` says whether to still deliver events.
|
||||
*/
|
||||
export function unsubscribeWhenReady(subscription: Promise<() => void>): {
|
||||
unsubscribe: () => void;
|
||||
active: () => boolean;
|
||||
} {
|
||||
let unsub: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
subscription
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unsub = fn;
|
||||
})
|
||||
.catch(console.error);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
cancelled = true;
|
||||
unsub?.();
|
||||
unsub = null;
|
||||
},
|
||||
active: () => !cancelled,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-proxy = { workspace = true }
|
||||
yaak-proxy-helper = { workspace = true, features = ["ts"] }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
@@ -53,9 +54,16 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"NSData",
|
||||
"NSDictionary",
|
||||
"NSError",
|
||||
"NSGeometry",
|
||||
"NSString",
|
||||
] }
|
||||
objc2-service-management = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"SMAppService",
|
||||
] }
|
||||
plist = "1.7"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -68,3 +76,4 @@ hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
yaak-proxy-helper = { workspace = true, features = ["test-support"] }
|
||||
+17
-2
@@ -179,6 +179,12 @@ otherOwner: OtherOwner | null, };
|
||||
|
||||
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||
|
||||
export type HelperRegisterRequest = Record<string, never>;
|
||||
|
||||
export type HelperState = "unavailable" | "not_registered" | "requires_approval" | "enabled" | "not_found";
|
||||
|
||||
export type HelperUnregisterRequest = Record<string, never>;
|
||||
|
||||
/**
|
||||
* The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks
|
||||
* whichever the server picks either way.
|
||||
@@ -232,7 +238,7 @@ export type ProxyStatePayload = { state: ProxyState, };
|
||||
|
||||
export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, };
|
||||
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], 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], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], 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], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], };
|
||||
|
||||
export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string,
|
||||
/**
|
||||
@@ -280,7 +286,12 @@ export type ServiceProxy = {
|
||||
/**
|
||||
* The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN".
|
||||
*/
|
||||
name: string, http: ProxySetting, https: ProxySetting, };
|
||||
name: string,
|
||||
/**
|
||||
* What stays the same when the service is renamed: its device ("en0"), or its hardware
|
||||
* port when it has none. Absent when `networksetup` lists neither.
|
||||
*/
|
||||
id?: string, http: ProxySetting, https: ProxySetting, };
|
||||
|
||||
export type SetHttpVersionRequest = { httpVersion: HttpVersion, };
|
||||
|
||||
@@ -304,6 +315,10 @@ enabled: boolean,
|
||||
* The port checked against.
|
||||
*/
|
||||
port: number, services: Array<ServiceProxy>, backend: SystemProxyBackend,
|
||||
/**
|
||||
* The privileged helper's registration, which decides the backend.
|
||||
*/
|
||||
helper: HelperState,
|
||||
/**
|
||||
* This platform has a system proxy the app can set.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! The privileged helper from the app's side: its registration with launchd through
|
||||
//! `SMAppService`, and requests over its socket. Only the bundled, signed app can register it
|
||||
//! or be answered by it; `tauri dev` and the CLI see it as unavailable.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum HelperState {
|
||||
/// Not running from a bundle with the helper inside: a dev build, the CLI, or not macOS.
|
||||
Unavailable,
|
||||
NotRegistered,
|
||||
/// Registered, and waiting for the user to allow it in System Settings.
|
||||
RequiresApproval,
|
||||
Enabled,
|
||||
/// The app has the helper's plist, but `SMAppService` reports it not found.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::HelperState;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_service_management::{SMAppService, SMAppServiceStatus};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use yaak_proxy_helper::protocol::{Request, Response, Status};
|
||||
|
||||
/// How long a just-approved daemon gets to open its socket.
|
||||
const CONNECT_WAIT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Long enough for `networksetup` on every service.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The helper's plist inside this app, when it runs from a bundle that has one, so
|
||||
/// `SMAppService` can find it.
|
||||
fn plist_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let contents = exe.parent()?.parent()?;
|
||||
let path = contents.join("Library/LaunchDaemons").join(yaak_proxy_helper::PLIST_NAME);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
fn service() -> Option<Retained<SMAppService>> {
|
||||
plist_path()?;
|
||||
let plist = NSString::from_str(yaak_proxy_helper::PLIST_NAME);
|
||||
// SAFETY: a class method taking a valid NSString.
|
||||
Some(unsafe { SMAppService::daemonServiceWithPlistName(&plist) })
|
||||
}
|
||||
|
||||
fn raw_status(service: &SMAppService) -> SMAppServiceStatus {
|
||||
// SAFETY: reads the service's registration status.
|
||||
unsafe { service.status() }
|
||||
}
|
||||
|
||||
fn state_of(service: &SMAppService) -> HelperState {
|
||||
match raw_status(service) {
|
||||
SMAppServiceStatus::Enabled => HelperState::Enabled,
|
||||
SMAppServiceStatus::RequiresApproval => HelperState::RequiresApproval,
|
||||
SMAppServiceStatus::NotRegistered => HelperState::NotRegistered,
|
||||
_ => HelperState::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_name(status: SMAppServiceStatus) -> &'static str {
|
||||
match status {
|
||||
SMAppServiceStatus::NotRegistered => "notRegistered",
|
||||
SMAppServiceStatus::Enabled => "enabled",
|
||||
SMAppServiceStatus::RequiresApproval => "requiresApproval",
|
||||
SMAppServiceStatus::NotFound => "notFound",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state() -> HelperState {
|
||||
let Some(plist) = plist_path() else {
|
||||
log::debug!("No helper plist in this app, so the helper is unavailable");
|
||||
return HelperState::Unavailable;
|
||||
};
|
||||
let Some(service) = service() else {
|
||||
return HelperState::Unavailable;
|
||||
};
|
||||
let status = raw_status(&service);
|
||||
log::debug!(
|
||||
"SMAppService status {} ({}) for {}",
|
||||
status.0,
|
||||
status_name(status),
|
||||
plist.display()
|
||||
);
|
||||
state_of(&service)
|
||||
}
|
||||
|
||||
/// What `SMAppService` says about the helper, for `--helper-status`.
|
||||
pub fn diagnose() -> String {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|e| format!("unknown ({e})"));
|
||||
let mut lines = vec![format!("executable: {exe}")];
|
||||
match plist_path() {
|
||||
Some(plist) => lines.push(format!("plist: {}", plist.display())),
|
||||
None => lines.push(format!(
|
||||
"plist: none at Contents/Library/LaunchDaemons/{}",
|
||||
yaak_proxy_helper::PLIST_NAME
|
||||
)),
|
||||
}
|
||||
if let Some(service) = service() {
|
||||
let status = raw_status(&service);
|
||||
lines.push(format!("SMAppService status: {} ({})", status.0, status_name(status)));
|
||||
}
|
||||
lines.push(format!("state: {:?}", state()));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Registers the daemon and, while it awaits approval, opens the System Settings pane
|
||||
/// that allows it.
|
||||
pub fn register() -> Result<HelperState, String> {
|
||||
let service = service().ok_or_else(unavailable)?;
|
||||
// SAFETY: registers the plist bundled with this app.
|
||||
let registered = unsafe { service.registerAndReturnError() };
|
||||
let state = state_of(&service);
|
||||
match state {
|
||||
// Registering asks for approval by failing with "Operation not permitted".
|
||||
HelperState::RequiresApproval => {
|
||||
// SAFETY: opens System Settings; no arguments.
|
||||
unsafe { SMAppService::openSystemSettingsLoginItems() };
|
||||
Ok(state)
|
||||
}
|
||||
HelperState::Enabled => Ok(state),
|
||||
_ => Err(registered
|
||||
.err()
|
||||
.map(|e| format!("Failed to install the helper: {}", e.localizedDescription()))
|
||||
.unwrap_or_else(|| "Failed to install the helper".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the daemon, once it has put the system proxy back.
|
||||
pub fn unregister() -> Result<HelperState, String> {
|
||||
let service = service().ok_or_else(unavailable)?;
|
||||
// A helper that has not put the settings back stays registered, so launchd restarts it
|
||||
// and it can try again; removed, nothing could restore them.
|
||||
if state_of(&service) == HelperState::Enabled {
|
||||
request(&Request::Unregister).map_err(|e| {
|
||||
format!(
|
||||
"The helper could not restore your proxy settings, so it was kept: {e}. Try \
|
||||
again in a moment."
|
||||
)
|
||||
})?;
|
||||
}
|
||||
// SAFETY: unregisters the plist bundled with this app.
|
||||
unsafe { service.unregisterAndReturnError() }
|
||||
.map_err(|e| format!("Failed to remove the helper: {}", e.localizedDescription()))?;
|
||||
Ok(state_of(&service))
|
||||
}
|
||||
|
||||
fn unavailable() -> String {
|
||||
"The helper is only available in the installed app, not a dev build".to_string()
|
||||
}
|
||||
|
||||
/// Sends one request and reads its response.
|
||||
pub fn request(request: &Request) -> Result<Status, String> {
|
||||
let start = Instant::now();
|
||||
let stream = loop {
|
||||
match UnixStream::connect(yaak_proxy_helper::SOCKET_PATH) {
|
||||
Ok(stream) => break stream,
|
||||
Err(_) if start.elapsed() < CONNECT_WAIT => {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(e) => return Err(format!("The helper is not running: {e}")),
|
||||
}
|
||||
};
|
||||
exchange(stream, request)
|
||||
}
|
||||
|
||||
fn exchange(stream: UnixStream, request: &Request) -> Result<Status, String> {
|
||||
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
||||
let mut line = serde_json::to_string(request).map_err(|e| e.to_string())?;
|
||||
line.push('\n');
|
||||
(&stream).write_all(line.as_bytes()).map_err(|e| format!("The helper hung up: {e}"))?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| format!("The helper did not answer: {e}"))?;
|
||||
let response: Response = serde_json::from_str(&reply)
|
||||
.map_err(|e| format!("The helper's answer was not understood: {e}"))?;
|
||||
response.into_result()
|
||||
}
|
||||
|
||||
/// Points the system proxy at `port`, or puts back what was there, owned by this process.
|
||||
pub fn set(enabled: bool, port: u16) -> Result<(), String> {
|
||||
let owner_pid = std::process::id() as i32;
|
||||
request(&Request::Set { enabled, port, owner_pid }).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use imp::{diagnose, register, set, state, unregister};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn state() -> HelperState {
|
||||
HelperState::Unavailable
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn diagnose() -> String {
|
||||
"The helper is only available on macOS".to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn register() -> Result<HelperState, String> {
|
||||
Err("The helper is only available on macOS".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn unregister() -> Result<HelperState, String> {
|
||||
Ok(HelperState::Unavailable)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod convert;
|
||||
pub mod db;
|
||||
pub mod flows;
|
||||
pub mod har;
|
||||
pub mod helper;
|
||||
pub mod macos;
|
||||
pub mod models;
|
||||
pub mod save;
|
||||
@@ -20,6 +21,7 @@ use crate::client::ControlClient;
|
||||
use crate::control::{ControlFile, ControlInfo, ControlServer, Owner};
|
||||
use crate::convert::DroppedHeader;
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::helper::HelperState;
|
||||
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
|
||||
use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header};
|
||||
use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
|
||||
@@ -364,18 +366,63 @@ impl ProxyCtx {
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper once it is enabled; until then, and while the password dialog still has the
|
||||
/// user's settings to put back, the dialog. The CLI daemon always uses `sudo`.
|
||||
pub fn system_proxy_backend(&self) -> SystemProxyBackend {
|
||||
self.backend_for(self.helper_state())
|
||||
}
|
||||
|
||||
fn backend_for(&self, helper: HelperState) -> SystemProxyBackend {
|
||||
match self.owner {
|
||||
Owner::App => SystemProxyBackend::AdminPrompt,
|
||||
Owner::Cli => SystemProxyBackend::Sudo,
|
||||
// What the dialog saved only it can restore, as the helper never saw it: settings
|
||||
// for routing it turned on, and settings parked for a service that was missing,
|
||||
// which its next capture uses if the service is back.
|
||||
Owner::App if helper == HelperState::Enabled && !self.snapshot_path().exists() => {
|
||||
SystemProxyBackend::Helper
|
||||
}
|
||||
Owner::App => SystemProxyBackend::AdminPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper's registration, as far as this process can use it.
|
||||
pub fn helper_state(&self) -> HelperState {
|
||||
match self.owner {
|
||||
Owner::App => helper::state(),
|
||||
Owner::Cli => HelperState::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the password dialog and `sudo` keep the settings they replaced.
|
||||
fn snapshot_path(&self) -> PathBuf {
|
||||
self.data_dir.join(macos::SNAPSHOT_FILE)
|
||||
}
|
||||
|
||||
/// Judged against the running engine's port, or the configured one while it is stopped.
|
||||
/// Blocks.
|
||||
pub fn system_proxy_status(&self) -> Result<SystemProxyStatus, String> {
|
||||
let port = self.port().unwrap_or(self.settings()?.port);
|
||||
macos::status(port, self.system_proxy_backend())
|
||||
let helper = self.helper_state();
|
||||
macos::status(port, self.backend_for(helper), helper)
|
||||
}
|
||||
|
||||
/// Registers the helper, opening System Settings for the user to allow it. Blocks.
|
||||
pub fn register_helper(&self) -> Result<SystemProxyStatus, String> {
|
||||
helper::register()?;
|
||||
self.system_proxy_status()
|
||||
}
|
||||
|
||||
/// Removes the helper, which first puts back the settings it replaced, so the system proxy
|
||||
/// is off afterwards. Blocks.
|
||||
pub fn unregister_helper(&self) -> Result<SystemProxyStatus, String> {
|
||||
let turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||
let restores = self.system_proxy_backend() == SystemProxyBackend::Helper;
|
||||
helper::unregister()?;
|
||||
if restores {
|
||||
self.system_proxy_on.store(false, Ordering::SeqCst);
|
||||
}
|
||||
drop(turn);
|
||||
self.system_proxy_status()
|
||||
}
|
||||
|
||||
/// Points this Mac's web traffic at the running engine, or stops, at once, through the
|
||||
@@ -465,14 +512,15 @@ impl ProxyCtx {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
macos::set(enabled, port, self.system_proxy_backend())
|
||||
macos::set(enabled, port, self.system_proxy_backend(), &self.snapshot_path())
|
||||
}
|
||||
|
||||
/// Turns off a system proxy left pointing at one of `ports` by an owner that crashed, so
|
||||
/// the Mac is not left offline. Leaves it alone when something is serving on the port.
|
||||
/// Blocks.
|
||||
/// the Mac is not left offline, putting back the settings it replaced when they were
|
||||
/// saved. Leaves it alone when something is serving on the port. Blocks.
|
||||
pub fn reset_stale_system_proxy(&self, ports: &[u16]) -> Result<(), String> {
|
||||
let mut ports = ports.to_vec();
|
||||
ports.extend(macos::saved_port(&self.snapshot_path()));
|
||||
ports.sort_unstable();
|
||||
ports.dedup();
|
||||
for port in ports {
|
||||
@@ -486,7 +534,7 @@ impl ProxyCtx {
|
||||
}
|
||||
// Covers disabled services too, and asks for nothing when none point at the port.
|
||||
debug!("Turning off any stale system proxy on port {port}");
|
||||
macos::set(false, port, self.system_proxy_backend())?;
|
||||
macos::set(false, port, self.system_proxy_backend(), &self.snapshot_path())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -863,6 +911,14 @@ pub struct SystemProxySetRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct HelperRegisterRequest {}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct HelperUnregisterRequest {}
|
||||
|
||||
// -- Handlers --
|
||||
|
||||
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
|
||||
@@ -1096,6 +1152,20 @@ async fn system_proxy_set(
|
||||
blocking(move || ctx.set_system_proxy(req.enabled)).await
|
||||
}
|
||||
|
||||
async fn helper_register(
|
||||
ctx: ProxyCtx,
|
||||
_req: HelperRegisterRequest,
|
||||
) -> Result<SystemProxyStatus, RpcError> {
|
||||
blocking(move || ctx.register_helper()).await
|
||||
}
|
||||
|
||||
async fn helper_unregister(
|
||||
ctx: ProxyCtx,
|
||||
_req: HelperUnregisterRequest,
|
||||
) -> Result<SystemProxyStatus, RpcError> {
|
||||
blocking(move || ctx.unregister_helper()).await
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(
|
||||
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, RpcError> {
|
||||
@@ -1362,6 +1432,8 @@ define_rpc! {
|
||||
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,
|
||||
save_flow(SaveFlowToYaakRequest) -> SavedFlow,
|
||||
@@ -1380,6 +1452,27 @@ define_rpc! {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_the_password_prompt_parked_keep_it_over_the_helper() {
|
||||
use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy, Snapshot};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (events, _rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
|
||||
assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::Helper);
|
||||
|
||||
// A restore parked an unplugged adapter's settings, at port 0.
|
||||
let corp = ProxySetting { enabled: true, server: "corp-proxy".into(), port: 3128 };
|
||||
let parked = Snapshot {
|
||||
services: vec![ServiceProxy::new("USB LAN", corp.clone(), corp)],
|
||||
..Snapshot::default()
|
||||
};
|
||||
std::fs::write(ctx.snapshot_path(), serde_json::to_vec(&parked).unwrap()).unwrap();
|
||||
assert_eq!(macos::saved_port(&ctx.snapshot_path()), None, "routes nothing");
|
||||
// Only the password prompt's capture would use them when the adapter is back.
|
||||
assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::AdminPrompt);
|
||||
assert_eq!(ctx.backend_for(HelperState::NotRegistered), SystemProxyBackend::AdminPrompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_is_recorded_by_the_state_when_it_started() {
|
||||
let mut log = RecordingLog::default();
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
//! The macOS system proxy: `networksetup` pointing the web and secure web proxies of every
|
||||
//! enabled network service at `127.0.0.1:<port>`. Reading needs no rights; changing needs an
|
||||
//! administrator, so changes run as one script through the backend in use. Everything here
|
||||
//! blocks, the password dialog included, so async callers go through `spawn_blocking`.
|
||||
//! administrator, which the privileged helper is, and which the password dialog and `sudo`
|
||||
//! borrow for one script. Each path saves the settings it replaces and puts them back when
|
||||
//! turned off. Everything here blocks, the password dialog included, so async callers go
|
||||
//! through `spawn_blocking`.
|
||||
|
||||
use crate::helper::HelperState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy_helper::networksetup::{
|
||||
NETWORKSETUP, Networksetup, Snapshot, disable_commands, enable_commands, read_services,
|
||||
};
|
||||
|
||||
pub use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy};
|
||||
|
||||
/// How a change gets administrator rights.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum SystemProxyBackend {
|
||||
/// The privileged helper, approved once in System Settings (not built yet).
|
||||
/// The privileged helper, approved once in System Settings. No prompt.
|
||||
Helper,
|
||||
/// The standard macOS password dialog, through `osascript`.
|
||||
AdminPrompt,
|
||||
@@ -29,101 +38,118 @@ pub struct SystemProxyStatus {
|
||||
pub port: u16,
|
||||
pub services: Vec<ServiceProxy>,
|
||||
pub backend: SystemProxyBackend,
|
||||
/// The privileged helper's registration, which decides the backend.
|
||||
pub helper: HelperState,
|
||||
/// This platform has a system proxy the app can set.
|
||||
pub supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ServiceProxy {
|
||||
/// The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN".
|
||||
pub name: String,
|
||||
pub http: ProxySetting,
|
||||
pub https: ProxySetting,
|
||||
}
|
||||
/// Where the password dialog and `sudo` save the settings they replace, in the proxy data
|
||||
/// directory. The helper keeps its own, as root.
|
||||
pub const SNAPSHOT_FILE: &str = "system-proxy-snapshot.json";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ProxySetting {
|
||||
pub enabled: bool,
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
const HOST: &str = "127.0.0.1";
|
||||
|
||||
impl ProxySetting {
|
||||
fn points_at(&self, port: u16) -> bool {
|
||||
self.enabled && self.server == HOST && self.port == port
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn load_snapshot(path: &Path) -> Result<Option<Snapshot>, String> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(format!("Failed to read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceProxy {
|
||||
/// Either proxy of the service sends traffic to `127.0.0.1:<port>`.
|
||||
pub fn points_at(&self, port: u16) -> bool {
|
||||
self.http.points_at(port) || self.https.points_at(port)
|
||||
/// The port the password dialog or `sudo` last pointed the system proxy at, while they have
|
||||
/// settings saved to put back.
|
||||
pub fn saved_port(snapshot_path: &Path) -> Option<u16> {
|
||||
// Port 0 holds only settings no service matched, which route nothing.
|
||||
load_snapshot(snapshot_path).ok().flatten().map(|s| s.port).filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn save_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), String> {
|
||||
let json = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::rename(&tmp, path))
|
||||
.map_err(|e| format!("Failed to save {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn remove_snapshot(path: &Path) -> Result<(), String> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The services from `networksetup -listallnetworkservices`, the disabled ones too with
|
||||
/// `include_disabled`. The first line is a legend; a leading `*` marks a disabled service.
|
||||
/// The password-dialog and `sudo` paths: reads through `ns`, and changes through `run`, which
|
||||
/// gets administrator rights for the whole list at once. Turning on saves the settings it
|
||||
/// replaces at `snapshot_path` first; turning off puts them back and deletes the file.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_services(output: &str, include_disabled: bool) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.skip(1)
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && (include_disabled || !l.starts_with('*')))
|
||||
.map(|l| l.trim_start_matches('*').to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The output of `networksetup -getwebproxy` or `-getsecurewebproxy`.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_proxy(output: &str) -> ProxySetting {
|
||||
let mut setting = ProxySetting::default();
|
||||
for line in output.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"Enabled" => setting.enabled = value.eq_ignore_ascii_case("yes"),
|
||||
"Server" => setting.server = value.to_string(),
|
||||
"Port" => setting.port = value.parse().unwrap_or_default(),
|
||||
_ => {}
|
||||
fn set_scripted(
|
||||
ns: &impl Networksetup,
|
||||
run: impl FnOnce(&[Vec<String>]) -> Result<(), String>,
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if enabled {
|
||||
let services = read_services(ns, false)?;
|
||||
let saved = load_snapshot(snapshot_path)?;
|
||||
let mut snapshot = saved.clone().unwrap_or_default();
|
||||
snapshot.extend(&services, port);
|
||||
// Saved before anything changes, so a crash after the change still restores.
|
||||
save_snapshot(snapshot_path, &snapshot)?;
|
||||
if let Err(e) = run(&enable_commands(&services, port)) {
|
||||
// Usually cancelled, with nothing changed, and a snapshot kept would go stale, so
|
||||
// what was there before goes back. Once anything points at us, it is what puts the
|
||||
// rest back.
|
||||
let changed = read_services(ns, false)?.iter().any(|s| s.points_at(port));
|
||||
if !changed {
|
||||
match saved {
|
||||
Some(saved) => save_snapshot(snapshot_path, &saved)?,
|
||||
None => remove_snapshot(snapshot_path)?,
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
setting
|
||||
}
|
||||
|
||||
/// The `networksetup` calls that point `services` at `port`, or turn off those pointing at it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn commands(services: &[ServiceProxy], enabled: bool, port: u16) -> Vec<Vec<String>> {
|
||||
let mut commands = Vec::new();
|
||||
for service in services {
|
||||
let name = service.name.clone();
|
||||
if enabled {
|
||||
let port = port.to_string();
|
||||
for (set, state) in [
|
||||
("-setwebproxy", "-setwebproxystate"),
|
||||
("-setsecurewebproxy", "-setsecurewebproxystate"),
|
||||
] {
|
||||
commands.push(vec![set.into(), name.clone(), HOST.into(), port.clone()]);
|
||||
commands.push(vec![state.into(), name.clone(), "on".into()]);
|
||||
}
|
||||
} else {
|
||||
if service.http.points_at(port) {
|
||||
commands.push(vec!["-setwebproxystate".into(), name.clone(), "off".into()]);
|
||||
}
|
||||
if service.https.points_at(port) {
|
||||
commands.push(vec!["-setsecurewebproxystate".into(), name, "off".into()]);
|
||||
}
|
||||
}
|
||||
// Disabled services too: one disabled since it was pointed at us would point at us again
|
||||
// once re-enabled.
|
||||
let services = read_services(ns, true)?;
|
||||
let mut snapshot = load_snapshot(snapshot_path)?;
|
||||
let (commands, used_up) = disable_commands(&services, snapshot.as_ref(), port);
|
||||
if commands.is_empty() && !used_up {
|
||||
return Ok(());
|
||||
}
|
||||
// Saved first, so a restore cut short is finished by the next one.
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
snapshot.mark_restoring(&commands, &services);
|
||||
save_snapshot(snapshot_path, snapshot)?;
|
||||
}
|
||||
if !commands.is_empty() {
|
||||
run(&commands)?;
|
||||
}
|
||||
match snapshot {
|
||||
Some(snapshot) if used_up => match snapshot.leftover(&services) {
|
||||
// Kept for when the service is back, and merged into the next capture.
|
||||
Some(leftover) => {
|
||||
let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect();
|
||||
log::warn!("Keeping saved proxy settings no service matched: {}", names.join(", "));
|
||||
save_snapshot(snapshot_path, &leftover)
|
||||
}
|
||||
None => remove_snapshot(snapshot_path),
|
||||
},
|
||||
Some(mut snapshot) => {
|
||||
snapshot.restoring.clear();
|
||||
save_snapshot(snapshot_path, &snapshot)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// The calls as one `sh` script that stops at the first failure.
|
||||
@@ -152,23 +178,11 @@ fn applescript_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', r"\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const NETWORKSETUP: &str = "/usr/sbin/networksetup";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
fn networksetup(args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new(NETWORKSETUP)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {NETWORKSETUP}: {e}"))?;
|
||||
check(&output, NETWORKSETUP)?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn check(output: &Output, program: &str) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
@@ -181,21 +195,10 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn services(include_disabled: bool) -> Result<Vec<ServiceProxy>, String> {
|
||||
parse_services(&networksetup(&["-listallnetworkservices"])?, include_disabled)
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let http = parse_proxy(&networksetup(&["-getwebproxy", &name])?);
|
||||
let https = parse_proxy(&networksetup(&["-getsecurewebproxy", &name])?);
|
||||
Ok(ServiceProxy { name, http, https })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_privileged(script: &str, backend: SystemProxyBackend) -> Result<(), String> {
|
||||
match backend {
|
||||
SystemProxyBackend::Helper => {
|
||||
Err("The privileged helper is not available yet".to_string())
|
||||
Err("The helper changes the system proxy itself, not by script".to_string())
|
||||
}
|
||||
SystemProxyBackend::AdminPrompt => {
|
||||
let source = format!(
|
||||
@@ -228,36 +231,67 @@ mod imp {
|
||||
|
||||
/// Which services point where, judged against `port`.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn status(port: u16, backend: SystemProxyBackend) -> Result<SystemProxyStatus, String> {
|
||||
let services = imp::services(false)?;
|
||||
pub fn status(
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
helper: HelperState,
|
||||
) -> Result<SystemProxyStatus, String> {
|
||||
let ns = yaak_proxy_helper::networksetup::SystemNetworksetup;
|
||||
let services = read_services(&ns, false)?;
|
||||
let enabled = !services.is_empty()
|
||||
&& services.iter().all(|s| s.http.points_at(port) && s.https.points_at(port));
|
||||
Ok(SystemProxyStatus { enabled, port, services, backend, supported: true })
|
||||
Ok(SystemProxyStatus { enabled, port, services, backend, helper, supported: true })
|
||||
}
|
||||
|
||||
/// Points every enabled service at `127.0.0.1:<port>`, or turns off the services that point
|
||||
/// there, disabled ones included, leaving other proxies alone. Turning off with nothing
|
||||
/// pointing at `port` does nothing.
|
||||
/// Points every enabled service at `127.0.0.1:<port>`, or puts back the settings that were
|
||||
/// there, turning off any other proxy still pointing at `port`. Turning off with nothing
|
||||
/// pointing at `port` asks for nothing. `snapshot_path` is where the password dialog and
|
||||
/// `sudo` keep the settings they replace.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set(enabled: bool, port: u16, backend: SystemProxyBackend) -> Result<(), String> {
|
||||
pub fn set(
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if port == 0 {
|
||||
return Err("The system proxy needs a port".to_string());
|
||||
}
|
||||
// A service disabled since it was pointed at us would point at us again once re-enabled.
|
||||
let commands = commands(&imp::services(!enabled)?, enabled, port);
|
||||
if commands.is_empty() {
|
||||
return Ok(());
|
||||
match backend {
|
||||
SystemProxyBackend::Helper => crate::helper::set(enabled, port),
|
||||
SystemProxyBackend::AdminPrompt | SystemProxyBackend::Sudo => set_scripted(
|
||||
&yaak_proxy_helper::networksetup::SystemNetworksetup,
|
||||
|commands| imp::run_privileged(&script(commands), backend),
|
||||
enabled,
|
||||
port,
|
||||
snapshot_path,
|
||||
),
|
||||
}
|
||||
imp::run_privileged(&script(&commands), backend)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn status(port: u16, backend: SystemProxyBackend) -> Result<SystemProxyStatus, String> {
|
||||
Ok(SystemProxyStatus { enabled: false, port, services: Vec::new(), backend, supported: false })
|
||||
pub fn status(
|
||||
port: u16,
|
||||
backend: SystemProxyBackend,
|
||||
helper: HelperState,
|
||||
) -> Result<SystemProxyStatus, String> {
|
||||
Ok(SystemProxyStatus {
|
||||
enabled: false,
|
||||
port,
|
||||
services: Vec::new(),
|
||||
backend,
|
||||
helper,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn set(enabled: bool, _port: u16, _backend: SystemProxyBackend) -> Result<(), String> {
|
||||
pub fn set(
|
||||
enabled: bool,
|
||||
_port: u16,
|
||||
_backend: SystemProxyBackend,
|
||||
_snapshot_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -267,54 +301,181 @@ pub fn set(enabled: bool, _port: u16, _backend: SystemProxyBackend) -> Result<()
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_proxy_helper::networksetup::HOST;
|
||||
use yaak_proxy_helper::networksetup::fake::FakeNetworksetup;
|
||||
|
||||
fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting {
|
||||
ProxySetting { enabled, server: server.into(), port }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_services() {
|
||||
let output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n";
|
||||
assert_eq!(parse_services(output, false), vec!["Wi-Fi", "USB 10/100/1000 LAN"]);
|
||||
// A disabled service can still hold a proxy setting pointing at us.
|
||||
assert_eq!(
|
||||
parse_services(output, true),
|
||||
vec!["Wi-Fi", "Thunderbolt Bridge", "USB 10/100/1000 LAN"]
|
||||
);
|
||||
/// Runs the "privileged" commands against the fake, counting the prompts.
|
||||
fn runner<'a>(
|
||||
ns: &'a FakeNetworksetup,
|
||||
prompts: &'a std::cell::Cell<usize>,
|
||||
) -> impl FnOnce(&[Vec<String>]) -> Result<(), String> + 'a {
|
||||
move |commands| {
|
||||
prompts.set(prompts.get() + 1);
|
||||
yaak_proxy_helper::networksetup::apply(ns, commands)
|
||||
}
|
||||
}
|
||||
|
||||
fn fake() -> FakeNetworksetup {
|
||||
FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)),
|
||||
("Ethernet", ProxySetting::default(), ProxySetting::default()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_proxy() {
|
||||
let on = "Enabled: Yes\nServer: 127.0.0.1\nPort: 9090\nAuthenticated Proxy Enabled: 0\n";
|
||||
assert_eq!(parse_proxy(on), setting(true, "127.0.0.1", 9090));
|
||||
let off = "Enabled: No\nServer: \nPort: 0\nAuthenticated Proxy Enabled: 0\n";
|
||||
assert_eq!(parse_proxy(off), setting(false, "", 0));
|
||||
fn the_password_path_restores_what_it_replaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090)));
|
||||
assert!(load_snapshot(&path).unwrap().is_some());
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!ns.proxies()[1].points_at(9090));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(prompts.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_touches_only_our_proxies() {
|
||||
let services = vec![
|
||||
ServiceProxy {
|
||||
name: "Wi-Fi".into(),
|
||||
http: setting(true, "127.0.0.1", 9090),
|
||||
https: setting(true, "127.0.0.1", 9090),
|
||||
},
|
||||
// Someone else's proxy, and ours on another port.
|
||||
ServiceProxy {
|
||||
name: "Ethernet".into(),
|
||||
http: setting(true, "corp-proxy", 9090),
|
||||
https: setting(true, "127.0.0.1", 8080),
|
||||
},
|
||||
];
|
||||
assert!(services[0].points_at(9090) && !services[1].points_at(9090));
|
||||
assert_eq!(
|
||||
commands(&services, false, 9090),
|
||||
vec![
|
||||
vec!["-setwebproxystate", "Wi-Fi", "off"],
|
||||
vec!["-setsecurewebproxystate", "Wi-Fi", "off"],
|
||||
]
|
||||
);
|
||||
assert_eq!(commands(&services, true, 9090).len(), 8);
|
||||
fn a_stale_reset_after_a_crash_uses_the_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
|
||||
// The app crashed; the next launch resets the configured port, now another one, which
|
||||
// leaves the snapshot alone, and then the port it saved.
|
||||
assert_eq!(saved_port(&path), Some(9090));
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 8080, &path).unwrap();
|
||||
assert!(path.exists());
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_reset_at_an_old_port_leaves_live_routing_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
// A crash left a disabled service on 9090; the new engine routes on 9191.
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.set_enabled("Ethernet", false);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap();
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert!(ns.proxies()[0].http.points_at(9191), "live routing kept");
|
||||
assert!(!ns.proxies()[1].points_at(9090), "the stale port cleared");
|
||||
assert!(path.exists());
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_path_restores_a_renamed_service_and_keeps_an_unplugged_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.rename("Wi-Fi", "Office Wi-Fi");
|
||||
ns.remove("Ethernet");
|
||||
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
let wifi = &ns.proxies()[0];
|
||||
assert_eq!((&wifi.http, &wifi.https), (&before[0].http, &before[0].https));
|
||||
// Ethernet's settings stay saved, without counting as routing.
|
||||
let kept = load_snapshot(&path).unwrap().unwrap();
|
||||
assert_eq!(kept.services.len(), 1);
|
||||
assert_eq!(kept.services[0].name, "Ethernet");
|
||||
assert_eq!(saved_port(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_applied_change_keeps_its_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
// Wi-Fi is changed, then Ethernet fails.
|
||||
*ns.failing.lock().unwrap() = Some("Ethernet".into());
|
||||
assert!(set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).is_err());
|
||||
assert!(ns.proxies()[0].points_at(9090));
|
||||
assert!(path.exists());
|
||||
|
||||
*ns.failing.lock().unwrap() = None;
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_with_nothing_pointing_at_us_asks_for_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
assert_eq!(prompts.get(), 0);
|
||||
assert!(ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_prompt_keeps_no_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let cancel = |_: &[Vec<String>]| Err("Cancelled in the password dialog".to_string());
|
||||
assert!(set_scripted(&ns, cancel, true, 9090, &path).is_err());
|
||||
assert!(!path.exists());
|
||||
assert!(ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_on_again_keeps_the_first_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let before = ns.proxies();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
// A second enable, the engine on a new port, must not save ours as the user's.
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap();
|
||||
let cancel = |_: &[Vec<String>]| Err("Cancelled in the password dialog".to_string());
|
||||
assert!(set_scripted(&ns, cancel, true, 9191, &path).is_err());
|
||||
assert!(path.exists(), "a cancelled re-enable keeps the original snapshot");
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap();
|
||||
assert_eq!(ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_service_pointing_at_us_is_turned_off() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(SNAPSHOT_FILE);
|
||||
let ns = fake();
|
||||
let prompts = std::cell::Cell::new(0);
|
||||
set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap();
|
||||
ns.set_enabled("Ethernet", false);
|
||||
set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap();
|
||||
let ethernet = &ns.proxies()[1];
|
||||
assert!(!ethernet.points_at(9090));
|
||||
assert_eq!(ethernet.http.server, HOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# will have compiled files and executables
|
||||
target/
|
||||
|
||||
vendored/*
|
||||
|
||||
gen/*
|
||||
|
||||
**/permissions/autogenerated
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Registered by the app with SMAppService.daemon(plistName:), approved once in
|
||||
System Settings > General > Login Items & Extensions. -->
|
||||
<key>Label</key>
|
||||
<string>app.yaak.proxy.helper</string>
|
||||
<key>BundleProgram</key>
|
||||
<string>Contents/MacOS/yaak-proxy-helper</string>
|
||||
<key>AssociatedBundleIdentifiers</key>
|
||||
<array>
|
||||
<string>app.yaak.proxy</string>
|
||||
<string>app.yaak.proxy.dev</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<!-- Restarted after a crash, not after a clean exit (unregistered, or stopped). -->
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Library/Logs/app.yaak.proxy.helper.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The hardened runtime with no exceptions. Not sandboxed: the app runs a local
|
||||
proxy server and changes the system proxy through its helper. -->
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,10 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// Says what macOS reports about the privileged helper, without opening a window.
|
||||
if std::env::args().any(|a| a == "--helper-status") {
|
||||
println!("{}", yaak_proxy_lib::helper::diagnose());
|
||||
return;
|
||||
}
|
||||
tauri_app_proxy_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm --prefix ../.. run proxy:tauri-before-bundle"
|
||||
},
|
||||
"bundle": {
|
||||
"externalBin": ["vendored/helper/yaak-proxy-helper"],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "13.0",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "macos/entitlements.plist",
|
||||
"files": {
|
||||
"Library/LaunchDaemons/app.yaak.proxy.helper.plist": "macos/app.yaak.proxy.helper.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "yaak-proxy-helper"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
# The library is the protocol and the `networksetup` logic, shared with `yaak-proxy-lib`, and
|
||||
# builds everywhere. The daemon itself is macOS only.
|
||||
[lib]
|
||||
name = "yaak_proxy_helper"
|
||||
|
||||
[[bin]]
|
||||
name = "yaak-proxy-helper"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# TypeScript bindings for the types the proxy app shows.
|
||||
ts = ["dep:ts-rs"]
|
||||
# An in-memory `networksetup`, for tests that change the system proxy.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
ts-rs = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
libc = "0.2"
|
||||
security-framework = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Who is on the other end of the socket. The kernel says which process connected, by its
|
||||
//! audit token, and Security.framework says whether that process is the proxy app signed by
|
||||
//! [`crate::TEAM_ID`]. The token, unlike a pid, cannot be recycled for another process
|
||||
//! between the two.
|
||||
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::data::CFData;
|
||||
use security_framework::os::macos::code_signing::{
|
||||
Flags, GuestAttributes, SecCode, SecRequirement,
|
||||
};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// The process that connected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Peer {
|
||||
pub pid: i32,
|
||||
/// Its `audit_token_t`.
|
||||
pub audit_token: [u8; 32],
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
pub fn of(stream: &UnixStream) -> Result<Self, String> {
|
||||
let fd = stream.as_raw_fd();
|
||||
let mut pid: libc::pid_t = 0;
|
||||
let mut len = std::mem::size_of::<libc::pid_t>() as libc::socklen_t;
|
||||
// SAFETY: `pid` and `len` describe a buffer the size LOCAL_PEERPID writes.
|
||||
let rc = unsafe {
|
||||
libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_LOCAL,
|
||||
libc::LOCAL_PEERPID,
|
||||
(&mut pid as *mut libc::pid_t).cast(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(format!(
|
||||
"Failed to read the peer pid: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
let mut audit_token = [0u8; 32];
|
||||
let mut len = audit_token.len() as libc::socklen_t;
|
||||
// SAFETY: as above, for the 32-byte audit_token_t LOCAL_PEERTOKEN writes.
|
||||
let rc = unsafe {
|
||||
libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_LOCAL,
|
||||
libc::LOCAL_PEERTOKEN,
|
||||
audit_token.as_mut_ptr().cast(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if rc != 0 || len as usize != audit_token.len() {
|
||||
return Err(format!(
|
||||
"Failed to read the peer's audit token: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(Self { pid, audit_token })
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether a peer may use the daemon.
|
||||
pub trait Verifier: Send + Sync {
|
||||
fn verify(&self, peer: &Peer) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// The real check: the peer's code signature against [`crate::code_requirement`].
|
||||
pub struct CodeSignature {
|
||||
requirement: String,
|
||||
}
|
||||
|
||||
impl Default for CodeSignature {
|
||||
fn default() -> Self {
|
||||
Self { requirement: crate::code_requirement() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Verifier for CodeSignature {
|
||||
fn verify(&self, peer: &Peer) -> Result<(), String> {
|
||||
let refused = |why: String| format!("Refused pid {}: {why}", peer.pid);
|
||||
let token = CFData::from_buffer(&peer.audit_token);
|
||||
let mut attributes = GuestAttributes::new();
|
||||
attributes.set_audit_token(token.as_concrete_TypeRef());
|
||||
let code = SecCode::copy_guest_with_attribues(None, &attributes, Flags::NONE)
|
||||
.map_err(|e| refused(format!("no code object ({e})")))?;
|
||||
let requirement = SecRequirement::from_str(&self.requirement)
|
||||
.map_err(|e| format!("Invalid code requirement: {e}"))?;
|
||||
code.check_validity(Flags::NONE, &requirement)
|
||||
.map_err(|e| refused(format!("not Yaak Proxy signed by {} ({e})", crate::TEAM_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_peer_and_refuses_code_not_signed_as_the_app() {
|
||||
// This test binary is the peer: at most ad-hoc signed, never the proxy app.
|
||||
let (a, _b) = UnixStream::pair().unwrap();
|
||||
let peer = Peer::of(&a).unwrap();
|
||||
assert_eq!(peer.pid, std::process::id() as i32);
|
||||
assert_ne!(peer.audit_token, [0; 32]);
|
||||
let err = CodeSignature::default().verify(&peer).unwrap_err();
|
||||
assert!(err.starts_with(&format!("Refused pid {}", peer.pid)), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requirement_naming_another_team_refuses_too() {
|
||||
let (a, _b) = UnixStream::pair().unwrap();
|
||||
let peer = Peer::of(&a).unwrap();
|
||||
let verifier = CodeSignature {
|
||||
requirement: "anchor apple generic and identifier \"app.yaak.proxy\" and certificate leaf[subject.OU] = \"AAAAAAAAAA\"".into(),
|
||||
};
|
||||
assert!(verifier.verify(&peer).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
//! The daemon: serves the socket, keeps the saved settings in its state file, and restores
|
||||
//! them when the process that turned the system proxy on exits, crashes included.
|
||||
|
||||
use crate::auth::{CodeSignature, Peer, Verifier};
|
||||
use crate::networksetup::{
|
||||
Networksetup, Snapshot, SystemNetworksetup, apply, disable_commands, enable_commands,
|
||||
read_services,
|
||||
};
|
||||
use crate::process::{Owner, Processes, SystemProcesses};
|
||||
use crate::protocol::{MAX_LINE, Request, Response, Status, parse_request};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often the owner is checked on.
|
||||
const WATCH_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Long enough for `networksetup` on every service, short enough that a silent caller lets go.
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// What the daemon has done, saved to its state file before each change, so a daemon that
|
||||
/// crashes or restarts still knows what to put back.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub active: Option<Active>,
|
||||
/// Saved settings a restore found no service for, a service removed or unplugged, kept
|
||||
/// for when it is back and merged into the next capture.
|
||||
#[serde(default)]
|
||||
pub parked: Option<Snapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Active {
|
||||
pub owner: Owner,
|
||||
pub snapshot: Snapshot,
|
||||
}
|
||||
|
||||
pub struct Daemon<N, P> {
|
||||
ns: N,
|
||||
processes: P,
|
||||
state_path: PathBuf,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
impl<N: Networksetup, P: Processes> Daemon<N, P> {
|
||||
/// Starts from the state file at `state_path`, or from nothing.
|
||||
pub fn open(ns: N, processes: P, state_path: PathBuf) -> Self {
|
||||
let state = match std::fs::read(&state_path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
log(&format!("Ignoring an unreadable state file: {e}"));
|
||||
State::default()
|
||||
}),
|
||||
Err(_) => State::default(),
|
||||
};
|
||||
Self { ns, processes, state_path, state: Mutex::new(state) }
|
||||
}
|
||||
|
||||
pub fn state(&self) -> State {
|
||||
self.lock().clone()
|
||||
}
|
||||
|
||||
pub fn handle(&self, request: &Request) -> Response {
|
||||
let result = match *request {
|
||||
Request::Get => Ok(status(&self.lock())),
|
||||
Request::Set { enabled: true, port, owner_pid } => self.enable(port, owner_pid),
|
||||
Request::Set { enabled: false, port, .. } => self.restore(Some(port)),
|
||||
Request::Restore => self.restore(None),
|
||||
Request::Unregister => self.unregister(),
|
||||
};
|
||||
match result {
|
||||
Ok(status) => Response::ok(status),
|
||||
Err(e) => Response::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Points every enabled service at `port`, saving what it replaces first, unless an
|
||||
/// earlier enable already did and nothing was restored since.
|
||||
fn enable(&self, port: u16, owner_pid: i32) -> Result<Status, String> {
|
||||
let owner = self
|
||||
.processes
|
||||
.owner(owner_pid)
|
||||
.ok_or_else(|| format!("The owner, pid {owner_pid}, is not running"))?;
|
||||
let mut state = self.lock();
|
||||
let services = read_services(&self.ns, false)?;
|
||||
let mut snapshot = match state.active.take() {
|
||||
Some(active) => active.snapshot,
|
||||
None => state.parked.take().unwrap_or_default(),
|
||||
};
|
||||
snapshot.extend(&services, port);
|
||||
state.active = Some(Active { owner, snapshot });
|
||||
// Saved before anything changes, so whatever happens next can be undone.
|
||||
self.save(&state)?;
|
||||
if let Err(e) = apply(&self.ns, &enable_commands(&services, port)) {
|
||||
// Half applied is worse than not at all.
|
||||
if let Err(undo) = self.restore_locked(&mut state, None) {
|
||||
log(&format!("Failed to undo a partial change: {undo}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(status(&state))
|
||||
}
|
||||
|
||||
/// Turns the system proxy off at `port`, or wherever it points without one: at the saved
|
||||
/// port this puts back everything saved; at another, a stale one, it only turns off what
|
||||
/// points there, and the saved settings stay with the routing still live.
|
||||
fn restore(&self, port: Option<u16>) -> Result<Status, String> {
|
||||
let mut state = self.lock();
|
||||
self.restore_locked(&mut state, port)?;
|
||||
Ok(status(&state))
|
||||
}
|
||||
|
||||
fn restore_locked(&self, state: &mut State, port: Option<u16>) -> Result<(), String> {
|
||||
let port = port.unwrap_or(0);
|
||||
let snapshot = state.active.as_ref().map(|a| &a.snapshot);
|
||||
if snapshot.is_none() && port == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let services = read_services(&self.ns, true)?;
|
||||
let (commands, used_up) = disable_commands(&services, snapshot, port);
|
||||
// Saved first, so a restore cut short is finished by the next one.
|
||||
if let Some(active) = state.active.as_mut() {
|
||||
active.snapshot.mark_restoring(&commands, &services);
|
||||
self.save(state)?;
|
||||
}
|
||||
// Kept on failure, so the next try still knows what to put back.
|
||||
apply(&self.ns, &commands)?;
|
||||
if used_up {
|
||||
if let Some(active) = state.active.take()
|
||||
&& let Some(leftover) = active.snapshot.leftover(&services)
|
||||
{
|
||||
let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect();
|
||||
log(&format!("Keeping saved settings no service matched: {}", names.join(", ")));
|
||||
state.parked = Some(leftover);
|
||||
}
|
||||
} else if let Some(active) = state.active.as_mut() {
|
||||
active.snapshot.restoring.clear();
|
||||
}
|
||||
self.save(state)
|
||||
}
|
||||
|
||||
/// Restores, then deletes the state file, as the app is about to remove the daemon. Saved
|
||||
/// settings no service matched stay in it, for a daemon registered again.
|
||||
fn unregister(&self) -> Result<Status, String> {
|
||||
let mut state = self.lock();
|
||||
self.restore_locked(&mut state, None)?;
|
||||
if state.parked.is_some() {
|
||||
return Ok(status(&state));
|
||||
}
|
||||
match std::fs::remove_file(&self.state_path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("Failed to delete the state file: {e}")),
|
||||
}
|
||||
Ok(status(&state))
|
||||
}
|
||||
|
||||
/// One look at the owner: restores once it has exited. True when it restored.
|
||||
pub fn watch(&self) -> bool {
|
||||
let mut state = self.lock();
|
||||
let Some(active) = &state.active else {
|
||||
return false;
|
||||
};
|
||||
if self.processes.is_alive(&active.owner) {
|
||||
return false;
|
||||
}
|
||||
log(&format!("The owner, pid {}, exited; restoring the system proxy", active.owner.pid));
|
||||
match self.restore_locked(&mut state, None) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log(&format!("Failed to restore the system proxy: {e}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self, state: &State) -> Result<(), String> {
|
||||
let json = serde_json::to_vec_pretty(state).map_err(|e| e.to_string())?;
|
||||
let tmp = self.state_path.with_extension("tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)))
|
||||
.and_then(|()| std::fs::rename(&tmp, &self.state_path))
|
||||
.map_err(|e| format!("Failed to save the helper's state: {e}"))
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, State> {
|
||||
// A panic mid-change leaves the state as saved, which is still the truth to restore.
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn status(state: &State) -> Status {
|
||||
Status {
|
||||
active: state.active.is_some(),
|
||||
port: state.active.as_ref().map(|a| a.snapshot.port),
|
||||
owner_pid: state.active.as_ref().map(|a| a.owner.pid),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many connections are served at once. The app needs one; more are refused.
|
||||
pub const MAX_CONNECTIONS: usize = 4;
|
||||
|
||||
/// A counting semaphore for connections being served.
|
||||
pub struct Slots {
|
||||
taken: Arc<AtomicUsize>,
|
||||
max: usize,
|
||||
}
|
||||
|
||||
/// A connection's place among [`Slots`], given back when dropped.
|
||||
pub struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Slots {
|
||||
pub fn new(max: usize) -> Self {
|
||||
Self { taken: Arc::new(AtomicUsize::new(0)), max }
|
||||
}
|
||||
|
||||
/// A slot, unless all are taken.
|
||||
pub fn try_take(&self) -> Option<Slot> {
|
||||
let max = self.max;
|
||||
self.taken
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| (n < max).then_some(n + 1))
|
||||
.ok()
|
||||
.map(|_| Slot(self.taken.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a connection on, before any thread is spawned for it: a free slot, and the kernel's
|
||||
/// word on who is calling. Both are cheap; the signature check is not, so it runs in the
|
||||
/// connection's own thread.
|
||||
pub fn admit(stream: &UnixStream, slots: &Slots) -> Result<(Peer, Slot), String> {
|
||||
let slot = slots.try_take().ok_or("The helper is busy; try again")?;
|
||||
let peer = Peer::of(stream)?;
|
||||
Ok((peer, slot))
|
||||
}
|
||||
|
||||
/// Answers a connection that was not admitted with why, briefly, before it is closed.
|
||||
fn refuse(stream: &UnixStream, why: &str) {
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(1)));
|
||||
let mut line = serde_json::to_string(&Response::error(why)).unwrap_or_default();
|
||||
line.push('\n');
|
||||
let mut writer = stream;
|
||||
let _ = writer.write_all(line.as_bytes());
|
||||
}
|
||||
|
||||
/// Answers one connection's requests, one line each, once the peer passes `verifier`. An
|
||||
/// unregister leaves the daemon running: it stops when macOS confirms the unregistration, and
|
||||
/// if that never comes, it is still there to answer.
|
||||
pub fn serve_connection<N: Networksetup, P: Processes>(
|
||||
stream: UnixStream,
|
||||
peer: Peer,
|
||||
daemon: &Daemon<N, P>,
|
||||
verifier: &dyn Verifier,
|
||||
) {
|
||||
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
||||
let mut writer = &stream;
|
||||
let mut reply = |response: &Response| {
|
||||
let mut line = serde_json::to_string(response).unwrap_or_default();
|
||||
line.push('\n');
|
||||
writer.write_all(line.as_bytes()).is_ok()
|
||||
};
|
||||
|
||||
if let Err(e) = verifier.verify(&peer) {
|
||||
log(&e);
|
||||
reply(&Response::error(e));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match (&mut reader).take(MAX_LINE as u64).read_line(&mut line) {
|
||||
Ok(0) => return,
|
||||
Ok(_) if !line.ends_with('\n') => {
|
||||
reply(&Response::error("Request too long or unterminated"));
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
let response = match parse_request(&line) {
|
||||
Ok(request) => {
|
||||
log(&format!("pid {}: {request:?}", peer.pid));
|
||||
daemon.handle(&request)
|
||||
}
|
||||
Err(e) => Response::error(e),
|
||||
};
|
||||
if !reply(&response) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon's entry point, run by launchd as root. Returns the exit code.
|
||||
pub fn run() -> i32 {
|
||||
// SAFETY: geteuid has no preconditions.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
eprintln!("yaak-proxy-helper is started by launchd, as root");
|
||||
return 1;
|
||||
}
|
||||
// Blocked before any thread starts, so every thread inherits it and only `sigwait` sees
|
||||
// them.
|
||||
let signals = blocked_signals();
|
||||
|
||||
let state_dir = Path::new(crate::STATE_DIR);
|
||||
if let Err(e) = std::fs::create_dir_all(state_dir)
|
||||
.and_then(|()| std::fs::set_permissions(state_dir, std::fs::Permissions::from_mode(0o700)))
|
||||
{
|
||||
log(&format!("Failed to create {}: {e}", state_dir.display()));
|
||||
return 1;
|
||||
}
|
||||
let daemon =
|
||||
Arc::new(Daemon::open(SystemNetworksetup, SystemProcesses, state_dir.join("state.json")));
|
||||
// The owner may have exited while the daemon was down.
|
||||
daemon.watch();
|
||||
|
||||
let _ = std::fs::remove_file(crate::SOCKET_PATH);
|
||||
let listener = match UnixListener::bind(crate::SOCKET_PATH) {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
log(&format!("Failed to listen on {}: {e}", crate::SOCKET_PATH));
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// Anyone may connect; only the signed app is answered.
|
||||
let _ = std::fs::set_permissions(crate::SOCKET_PATH, std::fs::Permissions::from_mode(0o666));
|
||||
log(&format!("Listening on {}", crate::SOCKET_PATH));
|
||||
|
||||
let watched = daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(WATCH_INTERVAL);
|
||||
watched.watch();
|
||||
}
|
||||
});
|
||||
|
||||
let stopping = daemon.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut signal = 0;
|
||||
// SAFETY: `signals` is an initialised set, and `signal` a valid out pointer.
|
||||
unsafe { libc::sigwait(&signals, &mut signal) };
|
||||
// Stopped by launchd: unregistered in System Settings, or the Mac shutting down.
|
||||
log(&format!("Signal {signal}; restoring the system proxy and exiting"));
|
||||
if let Response { ok: false, error: Some(e), .. } = stopping.handle(&Request::Restore) {
|
||||
log(&format!("Failed to restore the system proxy: {e}"));
|
||||
}
|
||||
exit();
|
||||
});
|
||||
|
||||
let verifier: Arc<dyn Verifier> = Arc::new(CodeSignature::default());
|
||||
let slots = Slots::new(MAX_CONNECTIONS);
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else {
|
||||
continue;
|
||||
};
|
||||
let (peer, slot) = match admit(&stream, &slots) {
|
||||
Ok(admitted) => admitted,
|
||||
Err(why) => {
|
||||
log(&why);
|
||||
refuse(&stream, &why);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let daemon = daemon.clone();
|
||||
let verifier = verifier.clone();
|
||||
let spawned = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve_connection(stream, peer, &daemon, verifier.as_ref());
|
||||
});
|
||||
// The closure, and with it the connection and its slot, is dropped: closed and freed.
|
||||
if let Err(e) = spawned {
|
||||
log(&format!("Failed to start a thread for pid {}: {e}", peer.pid));
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Exits cleanly, which launchd does not restart.
|
||||
fn exit() -> ! {
|
||||
let _ = std::fs::remove_file(crate::SOCKET_PATH);
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
fn blocked_signals() -> libc::sigset_t {
|
||||
// SAFETY: the set is initialised by sigemptyset before use, and only signal numbers are
|
||||
// added to it.
|
||||
unsafe {
|
||||
let mut set = std::mem::zeroed::<libc::sigset_t>();
|
||||
libc::sigemptyset(&mut set);
|
||||
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
|
||||
libc::sigaddset(&mut set, signal);
|
||||
}
|
||||
libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
|
||||
set
|
||||
}
|
||||
}
|
||||
|
||||
fn log(message: &str) {
|
||||
eprintln!(
|
||||
"{} {message}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::networksetup::fake::FakeNetworksetup;
|
||||
use crate::networksetup::{HOST, ProxySetting};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Processes that are alive while listed.
|
||||
#[derive(Default)]
|
||||
struct FakeProcesses(Mutex<HashMap<i32, u64>>);
|
||||
|
||||
impl FakeProcesses {
|
||||
fn start(&self, pid: i32, started: u64) {
|
||||
self.0.lock().unwrap().insert(pid, started);
|
||||
}
|
||||
fn kill(&self, pid: i32) {
|
||||
self.0.lock().unwrap().remove(&pid);
|
||||
}
|
||||
}
|
||||
|
||||
impl Processes for FakeProcesses {
|
||||
fn start_time(&self, pid: i32) -> Option<u64> {
|
||||
self.0.lock().unwrap().get(&pid).copied()
|
||||
}
|
||||
}
|
||||
|
||||
struct Allow;
|
||||
impl Verifier for Allow {
|
||||
fn verify(&self, _peer: &Peer) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Refuse;
|
||||
impl Verifier for Refuse {
|
||||
fn verify(&self, peer: &Peer) -> Result<(), String> {
|
||||
Err(format!("Refused pid {}: not Yaak Proxy", peer.pid))
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting {
|
||||
ProxySetting { enabled, server: server.into(), port }
|
||||
}
|
||||
|
||||
fn daemon(dir: &Path) -> Daemon<FakeNetworksetup, FakeProcesses> {
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)),
|
||||
("Ethernet", ProxySetting::default(), ProxySetting::default()),
|
||||
]);
|
||||
let processes = FakeProcesses::default();
|
||||
processes.start(42, 1000);
|
||||
Daemon::open(ns, processes, dir.join("state.json"))
|
||||
}
|
||||
|
||||
fn set(enabled: bool, port: u16) -> Request {
|
||||
Request::Set { enabled, port, owner_pid: 42 }
|
||||
}
|
||||
|
||||
fn all_point_at(daemon: &Daemon<FakeNetworksetup, FakeProcesses>, port: u16) -> bool {
|
||||
daemon.ns.proxies().iter().all(|s| s.http.points_at(port) && s.https.points_at(port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_off_restores_exactly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
|
||||
let status = daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
assert!(status.active && status.port == Some(9090) && status.owner_pid == Some(42));
|
||||
assert!(all_point_at(&daemon, 9090));
|
||||
|
||||
let status = daemon.handle(&set(false, 9090)).into_result().unwrap();
|
||||
assert!(!status.active);
|
||||
assert_eq!(daemon.ns.proxies()[0], before[0]);
|
||||
assert!(!daemon.ns.proxies()[1].points_at(9090));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_survives_a_daemon_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = daemon(dir.path());
|
||||
let before = first.ns.proxies();
|
||||
first.handle(&set(true, 9090)).into_result().unwrap();
|
||||
let saved = first.state();
|
||||
assert!(saved.active.is_some());
|
||||
|
||||
// A new daemon over the same machine reads what the first saved.
|
||||
let second = Daemon::open(
|
||||
FakeNetworksetup::default(),
|
||||
FakeProcesses::default(),
|
||||
dir.path().join("state.json"),
|
||||
);
|
||||
assert_eq!(second.state(), saved);
|
||||
*second.ns.services.lock().unwrap() = first.ns.services.lock().unwrap().clone();
|
||||
second.handle(&Request::Restore).into_result().unwrap();
|
||||
assert_eq!(second.ns.proxies()[0], before[0]);
|
||||
assert_eq!(second.state(), State::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_twice_keeps_the_first_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
// The engine moved ports; what was saved is still the user's.
|
||||
daemon.handle(&set(true, 9191)).into_result().unwrap();
|
||||
assert!(all_point_at(&daemon, 9191));
|
||||
daemon.handle(&Request::Restore).into_result().unwrap();
|
||||
assert_eq!(daemon.ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_port_move_still_restores_the_old_port() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
// Moving to 9191 fails on Ethernet, after Wi-Fi moved.
|
||||
*daemon.ns.failing.lock().unwrap() = Some("Ethernet".into());
|
||||
assert!(!daemon.handle(&set(true, 9191)).ok);
|
||||
// The undo failed on Ethernet too, so what to put back is kept for the next try.
|
||||
assert!(daemon.state().active.is_some());
|
||||
*daemon.ns.failing.lock().unwrap() = None;
|
||||
daemon.handle(&Request::Restore).into_result().unwrap();
|
||||
// Restored everywhere, the old port included.
|
||||
assert_eq!(daemon.ns.proxies()[0], before[0]);
|
||||
assert!(!daemon.ns.proxies()[1].points_at(9090));
|
||||
assert_eq!(daemon.state(), State::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_a_stale_port_keeps_the_live_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
daemon.ns.set_enabled("Ethernet", false);
|
||||
daemon.handle(&set(true, 9191)).into_result().unwrap();
|
||||
|
||||
// The app's launch cleanup of its old port.
|
||||
let status = daemon.handle(&set(false, 9090)).into_result().unwrap();
|
||||
assert!(status.active && status.port == Some(9191));
|
||||
assert!(daemon.ns.proxies()[0].http.points_at(9191));
|
||||
assert!(!daemon.ns.proxies()[1].points_at(9090));
|
||||
|
||||
daemon.handle(&set(false, 9191)).into_result().unwrap();
|
||||
assert_eq!(daemon.ns.proxies()[0], before[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_renamed_while_routing_is_restored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
daemon.ns.rename("Wi-Fi", "Office Wi-Fi");
|
||||
daemon.handle(&set(false, 9090)).into_result().unwrap();
|
||||
let after = &daemon.ns.proxies()[0];
|
||||
assert_eq!((&after.http, &after.https), (&before[0].http, &before[0].https));
|
||||
assert_eq!(daemon.state(), State::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_no_service_matched_are_parked_and_merged_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let wifi = daemon.ns.proxies()[0].clone();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
// Wi-Fi is unplugged while ours is on.
|
||||
daemon.ns.remove("Wi-Fi");
|
||||
daemon.handle(&set(false, 9090)).into_result().unwrap();
|
||||
let parked = daemon.state().parked.unwrap();
|
||||
assert_eq!(parked.services.len(), 1);
|
||||
assert_eq!(parked.services[0].http, wifi.http);
|
||||
|
||||
// Kept through unregister, and back in the next capture.
|
||||
daemon.handle(&Request::Unregister).into_result().unwrap();
|
||||
assert!(dir.path().join("state.json").exists());
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
let state = daemon.state();
|
||||
assert!(state.parked.is_none());
|
||||
assert!(state.active.unwrap().snapshot.services.iter().any(|s| s.http == wifi.http));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_watchdog_restores_once_the_owner_exits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
|
||||
assert!(!daemon.watch(), "the owner is alive");
|
||||
assert!(all_point_at(&daemon, 9090));
|
||||
|
||||
daemon.processes.kill(42);
|
||||
assert!(daemon.watch());
|
||||
assert_eq!(daemon.ns.proxies()[0], before[0]);
|
||||
assert!(!daemon.watch(), "nothing left to restore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_watchdog_treats_a_reused_pid_as_gone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
// The owner exited and another process got its pid.
|
||||
daemon.processes.start(42, 2000);
|
||||
assert!(daemon.watch());
|
||||
assert!(!daemon.ns.proxies()[1].points_at(9090));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_owner_that_is_not_running() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let err = daemon.handle(&Request::Set { enabled: true, port: 9090, owner_pid: 7 });
|
||||
assert!(!err.ok);
|
||||
assert!(daemon.ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_enable_undoes_what_it_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let before = daemon.ns.proxies();
|
||||
// Ethernet goes away mid-change, after Wi-Fi was pointed at us.
|
||||
*daemon.ns.failing.lock().unwrap() = Some("Ethernet".into());
|
||||
assert!(!daemon.handle(&set(true, 9090)).ok);
|
||||
assert_eq!(daemon.ns.proxies(), before);
|
||||
assert_eq!(daemon.state(), State::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_restores_and_deletes_the_state_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
daemon.handle(&set(true, 9090)).into_result().unwrap();
|
||||
assert!(dir.path().join("state.json").exists());
|
||||
daemon.handle(&Request::Unregister).into_result().unwrap();
|
||||
assert!(!dir.path().join("state.json").exists());
|
||||
assert!(!daemon.ns.proxies()[0].points_at(9090));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_reports_without_changing_anything() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let status = daemon.handle(&Request::Get).into_result().unwrap();
|
||||
assert!(!status.active);
|
||||
assert!(daemon.ns.changes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn exchange(verifier: &dyn Verifier, lines: &[&str]) -> (Vec<Response>, Vec<ProxySetting>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let daemon = daemon(dir.path());
|
||||
let (server, mut client) = UnixStream::pair().unwrap();
|
||||
for line in lines {
|
||||
client.write_all(format!("{line}\n").as_bytes()).unwrap();
|
||||
}
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let peer = Peer::of(&server).unwrap();
|
||||
serve_connection(server, peer, &daemon, verifier);
|
||||
let mut out = String::new();
|
||||
client.read_to_string(&mut out).unwrap();
|
||||
let responses = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let http = daemon.ns.proxies().into_iter().map(|s| s.http).collect();
|
||||
(responses, http)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_a_verified_peer_line_by_line() {
|
||||
let pid = std::process::id() as i32;
|
||||
let (responses, http) = exchange(
|
||||
&AllowWithOwner(pid),
|
||||
&[
|
||||
r#"{"cmd":"get"}"#,
|
||||
r#"{"cmd":"set","enabled":true,"port":80,"owner_pid":42}"#,
|
||||
r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(responses.len(), 3);
|
||||
assert!(responses[0].ok && !responses[1].ok && responses[2].ok);
|
||||
assert!(http.iter().all(|s| s.server == HOST && s.port == 9090));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unverified_peer_before_reading_anything() {
|
||||
let (responses, http) =
|
||||
exchange(&Refuse, &[r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#]);
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert!(responses[0].error.as_deref().unwrap().starts_with("Refused pid"));
|
||||
assert!(http.iter().all(|s| s.server != HOST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_peer_with_the_real_check() {
|
||||
// The test binary is not the signed app.
|
||||
let (responses, http) = exchange(&CodeSignature::default(), &[r#"{"cmd":"restore"}"#]);
|
||||
assert!(!responses[0].ok);
|
||||
assert!(http.iter().all(|s| s.server != HOST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_answering_after_unregister() {
|
||||
// Should macOS fail to unregister it, the app can still reach it.
|
||||
let (responses, _) = exchange(
|
||||
&Allow,
|
||||
&[
|
||||
r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#,
|
||||
r#"{"cmd":"unregister"}"#,
|
||||
r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(responses.len(), 3);
|
||||
assert!(responses.iter().all(|r| r.ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_cap_connections_and_free_on_drop() {
|
||||
let slots = Slots::new(2);
|
||||
let a = slots.try_take().unwrap();
|
||||
let _b = slots.try_take().unwrap();
|
||||
assert!(slots.try_take().is_none());
|
||||
drop(a);
|
||||
assert!(slots.try_take().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_connection_over_the_cap_with_a_short_reply() {
|
||||
let slots = Slots::new(MAX_CONNECTIONS);
|
||||
let held: Vec<_> = (0..MAX_CONNECTIONS).map(|_| slots.try_take().unwrap()).collect();
|
||||
let (server, mut client) = UnixStream::pair().unwrap();
|
||||
let why = admit(&server, &slots).err().unwrap();
|
||||
refuse(&server, &why);
|
||||
drop(server);
|
||||
let mut out = String::new();
|
||||
client.read_to_string(&mut out).unwrap();
|
||||
let response: Response = serde_json::from_str(out.trim()).unwrap();
|
||||
assert_eq!(response.error.as_deref(), Some("The helper is busy; try again"));
|
||||
|
||||
// Admitted again once one is free, with the peer read before any thread runs.
|
||||
drop(held);
|
||||
let (server, _client) = UnixStream::pair().unwrap();
|
||||
let (peer, _slot) = admit(&server, &slots).unwrap();
|
||||
assert_eq!(peer.pid, std::process::id() as i32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_reading_an_overlong_line() {
|
||||
let long = format!(r#"{{"cmd":"get","x":"{}"}}"#, "a".repeat(MAX_LINE));
|
||||
let (responses, _) = exchange(&Allow, &[&long, r#"{"cmd":"get"}"#]);
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert!(!responses[0].ok);
|
||||
}
|
||||
|
||||
/// Allows only the expected peer, this test process.
|
||||
struct AllowWithOwner(i32);
|
||||
impl Verifier for AllowWithOwner {
|
||||
fn verify(&self, peer: &Peer) -> Result<(), String> {
|
||||
if peer.pid == self.0 { Ok(()) } else { Err("wrong peer".into()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Yaak Proxy's privileged helper: a root launchd daemon, registered by the app through
|
||||
//! `SMAppService`, that points this Mac's web proxies at the engine and puts the user's own
|
||||
//! settings back afterwards, so toggling the system proxy never asks for a password.
|
||||
//!
|
||||
//! The app talks to it over a Unix socket, one JSON line per request ([`protocol`]). Only a
|
||||
//! caller signed as the proxy app by [`TEAM_ID`] is served.
|
||||
|
||||
pub mod networksetup;
|
||||
pub mod protocol;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod auth;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod daemon;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod process;
|
||||
|
||||
/// The launchd label, which is also the plist's name without `.plist`.
|
||||
pub const LABEL: &str = "app.yaak.proxy.helper";
|
||||
|
||||
/// The plist inside the app, at `Contents/Library/LaunchDaemons/`.
|
||||
pub const PLIST_NAME: &str = "app.yaak.proxy.helper.plist";
|
||||
|
||||
/// Created by the daemon, root-owned and world-writable: who may talk is decided per caller.
|
||||
pub const SOCKET_PATH: &str = "/var/run/app.yaak.proxy.helper.sock";
|
||||
|
||||
/// The daemon's own state, root only: the settings to restore, and whose they are.
|
||||
pub const STATE_DIR: &str = "/var/db/app.yaak.proxy.helper";
|
||||
|
||||
/// The Apple Developer Team that signs Yaak Proxy, set at build time with
|
||||
/// `YAAK_PROXY_TEAM_ID`.
|
||||
pub const TEAM_ID: &str = match option_env!("YAAK_PROXY_TEAM_ID") {
|
||||
Some(team) => team,
|
||||
None => "7PU3P6ELJ8",
|
||||
};
|
||||
|
||||
/// The bundle identifiers of the apps served: the release app, and the dev app as well in a
|
||||
/// debug build of the helper.
|
||||
pub const APP_IDENTIFIERS: &[&str] = if cfg!(debug_assertions) {
|
||||
&["app.yaak.proxy", "app.yaak.proxy.dev"]
|
||||
} else {
|
||||
&["app.yaak.proxy"]
|
||||
};
|
||||
|
||||
/// The code requirement a caller must satisfy.
|
||||
pub fn code_requirement() -> String {
|
||||
let identifiers = APP_IDENTIFIERS
|
||||
.iter()
|
||||
.map(|id| format!("identifier \"{id}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ");
|
||||
format!(
|
||||
"anchor apple generic and ({identifiers}) and certificate leaf[subject.OU] = \"{TEAM_ID}\""
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requires_the_app_signed_by_the_team() {
|
||||
let requirement = code_requirement();
|
||||
assert!(requirement.starts_with("anchor apple generic and (identifier \"app.yaak.proxy\""));
|
||||
assert!(requirement.ends_with(&format!("certificate leaf[subject.OU] = \"{TEAM_ID}\"")));
|
||||
assert_eq!(TEAM_ID.len(), 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
std::process::exit(yaak_proxy_helper::daemon::run());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {
|
||||
eprintln!("yaak-proxy-helper only runs on macOS");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
//! The macOS system proxy as `networksetup` sees it: the web and secure web proxies of each
|
||||
//! network service. Reading needs no rights; changing needs an administrator, which the
|
||||
//! daemon is and the app's password-dialog fallback borrows.
|
||||
//!
|
||||
//! Before pointing the services at `127.0.0.1:<port>`, their settings are saved in a
|
||||
//! [`Snapshot`]; turning off puts back exactly what was there, not just "off".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const NETWORKSETUP: &str = "/usr/sbin/networksetup";
|
||||
|
||||
/// Where the system proxy is always pointed. Never anywhere else, so a compromised caller can
|
||||
/// at worst send this Mac's traffic to localhost.
|
||||
pub const HOST: &str = "127.0.0.1";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceProxy {
|
||||
/// The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN".
|
||||
pub name: String,
|
||||
/// What stays the same when the service is renamed: its device ("en0"), or its hardware
|
||||
/// port when it has none. Absent when `networksetup` lists neither.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "ts", ts(optional))]
|
||||
pub id: Option<String>,
|
||||
pub http: ProxySetting,
|
||||
pub https: ProxySetting,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxySetting {
|
||||
pub enabled: bool,
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ProxySetting {
|
||||
pub fn points_at(&self, port: u16) -> bool {
|
||||
self.enabled && self.server == HOST && self.port == port
|
||||
}
|
||||
|
||||
/// This setting as the user's own: one pointing at one of `ports` is ours, left behind, so
|
||||
/// it is saved as off.
|
||||
fn owned(&self, ports: &[u16]) -> Self {
|
||||
let ours = ports.iter().any(|p| self.points_at(*p));
|
||||
Self { enabled: self.enabled && !ours, ..self.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceProxy {
|
||||
/// A service known by its name only.
|
||||
pub fn new(name: &str, http: ProxySetting, https: ProxySetting) -> Self {
|
||||
Self { name: name.into(), id: None, http, https }
|
||||
}
|
||||
|
||||
/// Either proxy of the service sends traffic to `127.0.0.1:<port>`.
|
||||
pub fn points_at(&self, port: u16) -> bool {
|
||||
self.http.points_at(port) || self.https.points_at(port)
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings of the services the system proxy changed, as they were before, and the port
|
||||
/// it points them at.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub port: u16,
|
||||
/// Ports it pointed at before moving to `port`, where a service it could not move, or a
|
||||
/// disabled one, may still point.
|
||||
#[serde(default)]
|
||||
pub previous_ports: Vec<u16>,
|
||||
pub services: Vec<ServiceProxy>,
|
||||
/// Proxies a restore has started putting the saved server back on, as `(saved service,
|
||||
/// "http" or "https")`, keyed by the saved entry's name, which a rename doesn't change.
|
||||
/// Setting a server turns the proxy on, so one of these found on with its saved server,
|
||||
/// saved as off, is a restore that stopped halfway.
|
||||
#[serde(default)]
|
||||
pub restoring: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Records the restores `commands` start over `services`, saved before running them.
|
||||
pub fn mark_restoring(&mut self, commands: &[Vec<String>], services: &[ServiceProxy]) {
|
||||
let mut started = Vec::new();
|
||||
for args in commands {
|
||||
let proto = match args.first().map(String::as_str) {
|
||||
Some("-setwebproxy") => "http",
|
||||
Some("-setsecurewebproxy") => "https",
|
||||
_ => continue,
|
||||
};
|
||||
let Some(name) = args.get(1) else { continue };
|
||||
let saved = services
|
||||
.iter()
|
||||
.find(|s| &s.name == name)
|
||||
.and_then(|s| self.get(s, services))
|
||||
.map_or_else(|| name.clone(), |saved| saved.name.clone());
|
||||
started.push((saved, proto.to_string()));
|
||||
}
|
||||
for entry in started {
|
||||
if !self.restoring.contains(&entry) {
|
||||
self.restoring.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_restoring(&self, name: &str, proto: &str) -> bool {
|
||||
self.restoring.iter().any(|(n, p)| n == name && p == proto)
|
||||
}
|
||||
|
||||
pub fn capture(services: &[ServiceProxy], port: u16) -> Self {
|
||||
let mut snapshot = Self::default();
|
||||
snapshot.extend(services, port);
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Records `services` not saved yet, a network connected since the first capture, and
|
||||
/// moves the snapshot to `port`. Those already saved keep the user's settings, not ours.
|
||||
pub fn extend(&mut self, services: &[ServiceProxy], port: u16) {
|
||||
if self.port != 0 && self.port != port && !self.previous_ports.contains(&self.port) {
|
||||
self.previous_ports.push(self.port);
|
||||
}
|
||||
self.previous_ports.retain(|p| *p != port);
|
||||
self.port = port;
|
||||
let ports = self.ports();
|
||||
// Judged against the snapshot as it was, so an entry added here can't change how the
|
||||
// services after it match.
|
||||
let new: Vec<ServiceProxy> = services
|
||||
.iter()
|
||||
.filter(|service| self.get(service, services).is_none())
|
||||
.map(|service| ServiceProxy {
|
||||
name: service.name.clone(),
|
||||
id: service.id.clone(),
|
||||
http: service.http.owned(&ports),
|
||||
https: service.https.owned(&ports),
|
||||
})
|
||||
.collect();
|
||||
self.services.extend(new);
|
||||
}
|
||||
|
||||
/// Every port this snapshot has pointed services at, the current one first.
|
||||
pub fn ports(&self) -> Vec<u16> {
|
||||
std::iter::once(self.port).chain(self.previous_ports.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// What was saved for `service`, one of `services`, the network's services now: the entry
|
||||
/// with its name, when the ids don't disagree, or, for a service renamed since, the entry
|
||||
/// on its device whose name no service has any more. Services can share a device, so the
|
||||
/// device decides only when it is unambiguous both ways: one such saved entry, and one
|
||||
/// service on the device with no saved entry by name. Otherwise nothing matches, and the
|
||||
/// saved entry is parked rather than given to the wrong service.
|
||||
fn get(&self, service: &ServiceProxy, services: &[ServiceProxy]) -> Option<&ServiceProxy> {
|
||||
let ids_agree =
|
||||
|s: &ServiceProxy| s.id.is_none() || service.id.is_none() || s.id == service.id;
|
||||
if let Some(saved) = self.services.iter().find(|s| s.name == service.name && ids_agree(s)) {
|
||||
return Some(saved);
|
||||
}
|
||||
let id = service.id.as_ref()?;
|
||||
let mut orphans = self
|
||||
.services
|
||||
.iter()
|
||||
.filter(|s| s.id.as_ref() == Some(id) && !services.iter().any(|c| c.name == s.name));
|
||||
let saved = orphans.next()?;
|
||||
let unclaimed = services
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.id.as_ref() == Some(id) && !self.services.iter().any(|s| s.name == c.name)
|
||||
})
|
||||
.count();
|
||||
(orphans.next().is_none() && unclaimed == 1).then_some(saved)
|
||||
}
|
||||
|
||||
/// What is left once this snapshot is restored over `services`: the saved entries none of
|
||||
/// them matched, a service removed or unplugged, kept so its settings are not lost. They
|
||||
/// are parked at port 0, which routes nothing.
|
||||
pub fn leftover(&self, services: &[ServiceProxy]) -> Option<Snapshot> {
|
||||
let unmatched: Vec<ServiceProxy> = self
|
||||
.services
|
||||
.iter()
|
||||
.filter(|saved| {
|
||||
!services
|
||||
.iter()
|
||||
.any(|s| self.get(s, services).is_some_and(|m| std::ptr::eq(m, *saved)))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if unmatched.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Snapshot { services: unmatched, ..Snapshot::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The calls that turn the system proxy off at `port`, and whether `snapshot` is used up by
|
||||
/// them. At the snapshot's current port, or at port 0 meaning wherever it is, they put back
|
||||
/// everything it saved. At any other port they put back only what points there, leaving the
|
||||
/// snapshot to the routing still live on its own port.
|
||||
pub fn disable_commands(
|
||||
services: &[ServiceProxy],
|
||||
snapshot: Option<&Snapshot>,
|
||||
port: u16,
|
||||
) -> (Vec<Vec<String>>, bool) {
|
||||
match snapshot {
|
||||
Some(snapshot) if port == 0 || port == snapshot.port => {
|
||||
let mut ports = snapshot.ports();
|
||||
if port != 0 && !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
(restore_commands(services, Some(snapshot), &ports), true)
|
||||
}
|
||||
Some(snapshot) => (restore_commands(services, Some(snapshot), &[port]), false),
|
||||
None if port == 0 => (Vec::new(), true),
|
||||
None => (restore_commands(services, None, &[port]), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The services from `networksetup -listallnetworkservices`, the disabled ones too with
|
||||
/// `include_disabled`. The first line is a legend; a leading `*` marks a disabled service.
|
||||
pub fn parse_services(output: &str, include_disabled: bool) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.skip(1)
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && (include_disabled || !l.starts_with('*')))
|
||||
.map(|l| l.trim_start_matches('*').to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Each service's id from `networksetup -listnetworkserviceorder`, as `(name, id)`: its device,
|
||||
/// or `port:<hardware port>` when it has no device, as a VPN may not.
|
||||
pub fn parse_service_ids(output: &str) -> Vec<(String, String)> {
|
||||
let mut ids = Vec::new();
|
||||
let mut name = None;
|
||||
for line in output.lines().map(str::trim) {
|
||||
if let Some(rest) = line.strip_prefix("(Hardware Port:") {
|
||||
let Some(name) = name.take() else { continue };
|
||||
let rest = rest.trim_end_matches(')');
|
||||
let (port, device) = rest.split_once(", Device:").unwrap_or((rest, ""));
|
||||
let (port, device) = (port.trim(), device.trim());
|
||||
if !device.is_empty() {
|
||||
ids.push((name, device.to_string()));
|
||||
} else if !port.is_empty() {
|
||||
ids.push((name, format!("port:{port}")));
|
||||
}
|
||||
} else if line.starts_with('(')
|
||||
&& let Some((_, rest)) = line.split_once(") ")
|
||||
{
|
||||
// "(1) Wi-Fi", or "(*) Name" for a disabled service.
|
||||
name = Some(rest.to_string());
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// The output of `networksetup -getwebproxy` or `-getsecurewebproxy`.
|
||||
pub fn parse_proxy(output: &str) -> ProxySetting {
|
||||
let mut setting = ProxySetting::default();
|
||||
for line in output.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"Enabled" => setting.enabled = value.eq_ignore_ascii_case("yes"),
|
||||
"Server" => setting.server = value.to_string(),
|
||||
"Port" => setting.port = value.parse().unwrap_or_default(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
setting
|
||||
}
|
||||
|
||||
const WEB: [(&str, &str); 2] = [
|
||||
("-setwebproxy", "-setwebproxystate"),
|
||||
("-setsecurewebproxy", "-setsecurewebproxystate"),
|
||||
];
|
||||
|
||||
/// The `networksetup` calls that point every one of `services` at `127.0.0.1:<port>`.
|
||||
pub fn enable_commands(services: &[ServiceProxy], port: u16) -> Vec<Vec<String>> {
|
||||
let mut commands = Vec::new();
|
||||
for service in services {
|
||||
for (set, state) in WEB {
|
||||
let name = service.name.clone();
|
||||
commands.push(vec![set.into(), name.clone(), HOST.into(), port.to_string()]);
|
||||
commands.push(vec![state.into(), name, "on".into()]);
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// The `networksetup` calls that put back what `snapshot` saved for each proxy of `services`
|
||||
/// still pointing at one of `ports`, and turn off those it has nothing saved for. A proxy the
|
||||
/// user has pointed elsewhere since is theirs, and left alone.
|
||||
pub fn restore_commands(
|
||||
services: &[ServiceProxy],
|
||||
snapshot: Option<&Snapshot>,
|
||||
ports: &[u16],
|
||||
) -> Vec<Vec<String>> {
|
||||
let mut commands = Vec::new();
|
||||
for service in services {
|
||||
let saved_service = snapshot.and_then(|s| s.get(service, services));
|
||||
let proxies = [
|
||||
(&service.http, saved_service.map(|s| &s.http)),
|
||||
(&service.https, saved_service.map(|s| &s.https)),
|
||||
];
|
||||
for (((current, saved), (set, state)), proto) in
|
||||
proxies.into_iter().zip(WEB).zip(["http", "https"])
|
||||
{
|
||||
let name = service.name.clone();
|
||||
// An earlier restore set the user's server, which turned it on, and stopped
|
||||
// before turning it back off. Marked by the saved entry, so a rename since
|
||||
// doesn't hide it.
|
||||
let restoring = saved_service
|
||||
.zip(snapshot)
|
||||
.is_some_and(|(saved, snapshot)| snapshot.is_restoring(&saved.name, proto));
|
||||
let unfinished = saved.is_some_and(|s| {
|
||||
!s.enabled
|
||||
&& current.enabled
|
||||
&& current.server == s.server
|
||||
&& current.port == s.port
|
||||
&& restoring
|
||||
});
|
||||
if unfinished {
|
||||
commands.push(vec![state.into(), name, "off".into()]);
|
||||
continue;
|
||||
}
|
||||
if !ports.iter().any(|p| current.points_at(*p)) {
|
||||
continue;
|
||||
}
|
||||
match saved {
|
||||
// Setting the server turns the proxy on, so its state follows.
|
||||
Some(saved) if !saved.server.is_empty() && saved.port != 0 => {
|
||||
let port = saved.port.to_string();
|
||||
commands.push(vec![set.into(), name.clone(), saved.server.clone(), port]);
|
||||
commands.push(vec![state.into(), name, on_off(saved.enabled)]);
|
||||
}
|
||||
_ => commands.push(vec![state.into(), name, "off".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn on_off(on: bool) -> String {
|
||||
if on { "on" } else { "off" }.to_string()
|
||||
}
|
||||
|
||||
/// Runs `networksetup`, real or stubbed.
|
||||
pub trait Networksetup {
|
||||
/// Runs it with `args`, returning what it printed.
|
||||
fn run(&self, args: &[String]) -> Result<String, String>;
|
||||
}
|
||||
|
||||
/// `/usr/sbin/networksetup` itself.
|
||||
pub struct SystemNetworksetup;
|
||||
|
||||
impl Networksetup for SystemNetworksetup {
|
||||
fn run(&self, args: &[String]) -> Result<String, String> {
|
||||
let output = std::process::Command::new(NETWORKSETUP)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {NETWORKSETUP}: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
format!("{NETWORKSETUP} exited with {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
// It reports some failures, such as an unknown service, on stdout with status 0.
|
||||
if stdout.starts_with("** Error") {
|
||||
return Err(stdout.trim().to_string());
|
||||
}
|
||||
Ok(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every service and its proxies, the disabled ones too with `include_disabled`.
|
||||
pub fn read_services(
|
||||
ns: &impl Networksetup,
|
||||
include_disabled: bool,
|
||||
) -> Result<Vec<ServiceProxy>, String> {
|
||||
let list = ns.run(&["-listallnetworkservices".to_string()])?;
|
||||
// Without ids, services are still matched by name.
|
||||
let ids = ns
|
||||
.run(&["-listnetworkserviceorder".to_string()])
|
||||
.map(|order| parse_service_ids(&order))
|
||||
.unwrap_or_default();
|
||||
parse_services(&list, include_disabled)
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let http = parse_proxy(&ns.run(&["-getwebproxy".into(), name.clone()])?);
|
||||
let https = parse_proxy(&ns.run(&["-getsecurewebproxy".into(), name.clone()])?);
|
||||
let id = ids.iter().find(|(n, _)| *n == name).map(|(_, id)| id.clone());
|
||||
Ok(ServiceProxy { name, id, http, https })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Runs `commands` in order, stopping at the first that fails.
|
||||
pub fn apply(ns: &impl Networksetup, commands: &[Vec<String>]) -> Result<(), String> {
|
||||
for args in commands {
|
||||
ns.run(args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `networksetup` that keeps its services in memory, for tests here and in the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod fake {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FakeService {
|
||||
pub proxy: ServiceProxy,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FakeNetworksetup {
|
||||
pub services: Mutex<Vec<FakeService>>,
|
||||
/// Every change made, in order.
|
||||
pub changes: Mutex<Vec<Vec<String>>>,
|
||||
/// A service whose changes fail, as one removed mid-change would.
|
||||
pub failing: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl FakeNetworksetup {
|
||||
/// Services on devices `en0`, `en1` and on, in order.
|
||||
pub fn new(services: Vec<(&str, ProxySetting, ProxySetting)>) -> Self {
|
||||
let services = services
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, http, https))| FakeService {
|
||||
proxy: ServiceProxy {
|
||||
name: name.into(),
|
||||
id: Some(format!("en{i}")),
|
||||
http,
|
||||
https,
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
.collect();
|
||||
Self { services: Mutex::new(services), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Renames a service, as System Settings can; its device stays.
|
||||
pub fn rename(&self, name: &str, to: &str) {
|
||||
let mut services = self.services.lock().unwrap();
|
||||
services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.name = to.into();
|
||||
}
|
||||
|
||||
/// Moves a service onto `device`, which another can share, as a duplicated one does.
|
||||
pub fn set_device(&self, name: &str, device: &str) {
|
||||
let mut services = self.services.lock().unwrap();
|
||||
services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.id =
|
||||
Some(device.into());
|
||||
}
|
||||
|
||||
/// Adds an enabled service on `device`.
|
||||
pub fn add(&self, name: &str, device: &str, http: ProxySetting, https: ProxySetting) {
|
||||
let mut proxy = ServiceProxy::new(name, http, https);
|
||||
proxy.id = Some(device.into());
|
||||
self.services.lock().unwrap().push(FakeService { proxy, enabled: true });
|
||||
}
|
||||
|
||||
/// Removes a service, as unplugging an adapter can.
|
||||
pub fn remove(&self, name: &str) {
|
||||
self.services.lock().unwrap().retain(|s| s.proxy.name != name);
|
||||
}
|
||||
|
||||
/// The services' proxies, disabled ones included.
|
||||
pub fn proxies(&self) -> Vec<ServiceProxy> {
|
||||
self.services.lock().unwrap().iter().map(|s| s.proxy.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, name: &str, enabled: bool) {
|
||||
let mut services = self.services.lock().unwrap();
|
||||
services.iter_mut().find(|s| s.proxy.name == name).unwrap().enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_proxy(&self, name: &str, http: ProxySetting, https: ProxySetting) {
|
||||
let mut services = self.services.lock().unwrap();
|
||||
let service = services.iter_mut().find(|s| s.proxy.name == name).unwrap();
|
||||
service.proxy.http = http;
|
||||
service.proxy.https = https;
|
||||
}
|
||||
}
|
||||
|
||||
impl Networksetup for FakeNetworksetup {
|
||||
fn run(&self, args: &[String]) -> Result<String, String> {
|
||||
let mut services = self.services.lock().unwrap();
|
||||
if args.first().map(String::as_str) == Some("-listallnetworkservices") {
|
||||
let mut out =
|
||||
"An asterisk (*) denotes that a network service is disabled.\n".to_string();
|
||||
for s in services.iter() {
|
||||
out.push_str(&format!(
|
||||
"{}{}\n",
|
||||
if s.enabled { "" } else { "*" },
|
||||
s.proxy.name
|
||||
));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("-listnetworkserviceorder") {
|
||||
let mut out =
|
||||
"An asterisk (*) denotes that a network service is disabled.\n".to_string();
|
||||
for (i, s) in services.iter().enumerate() {
|
||||
let order = if s.enabled { (i + 1).to_string() } else { "*".into() };
|
||||
let device = s.proxy.id.clone().unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"({order}) {}\n(Hardware Port: {}, Device: {device})\n\n",
|
||||
s.proxy.name, s.proxy.name
|
||||
));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let [command, name, rest @ ..] = args else {
|
||||
return Err(format!("bad arguments {args:?}"));
|
||||
};
|
||||
let service = services
|
||||
.iter_mut()
|
||||
.find(|s| &s.proxy.name == name)
|
||||
.ok_or_else(|| format!("** Error: {name} is not a recognized network service."))?;
|
||||
let setting = if command.contains("secure") {
|
||||
&mut service.proxy.https
|
||||
} else {
|
||||
&mut service.proxy.http
|
||||
};
|
||||
match (command.as_str(), rest) {
|
||||
("-getwebproxy" | "-getsecurewebproxy", []) => {
|
||||
return Ok(format!(
|
||||
"Enabled: {}\nServer: {}\nPort: {}\nAuthenticated Proxy Enabled: 0\n",
|
||||
if setting.enabled { "Yes" } else { "No" },
|
||||
setting.server,
|
||||
setting.port
|
||||
));
|
||||
}
|
||||
(_, [_, ..]) if self.failing.lock().unwrap().as_ref() == Some(name) => {
|
||||
return Err(format!("** Error: {name} is not a recognized network service."));
|
||||
}
|
||||
("-setwebproxy" | "-setsecurewebproxy", [server, port]) => {
|
||||
setting.server = server.clone();
|
||||
setting.port = port.parse().map_err(|_| format!("bad port {port}"))?;
|
||||
setting.enabled = true;
|
||||
}
|
||||
("-setwebproxystate" | "-setsecurewebproxystate", [state]) => {
|
||||
setting.enabled = state == "on";
|
||||
}
|
||||
_ => return Err(format!("bad arguments {args:?}")),
|
||||
}
|
||||
self.changes.lock().unwrap().push(args.to_vec());
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fake::FakeNetworksetup;
|
||||
use super::*;
|
||||
|
||||
fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting {
|
||||
ProxySetting { enabled, server: server.into(), port }
|
||||
}
|
||||
|
||||
fn ours(port: u16) -> ProxySetting {
|
||||
setting(true, HOST, port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_services() {
|
||||
let output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n";
|
||||
assert_eq!(parse_services(output, false), vec!["Wi-Fi", "USB 10/100/1000 LAN"]);
|
||||
// A disabled service can still hold a proxy setting pointing at us.
|
||||
assert_eq!(
|
||||
parse_services(output, true),
|
||||
vec!["Wi-Fi", "Thunderbolt Bridge", "USB 10/100/1000 LAN"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_proxy() {
|
||||
let on = "Enabled: Yes\nServer: 127.0.0.1\nPort: 9090\nAuthenticated Proxy Enabled: 0\n";
|
||||
assert_eq!(parse_proxy(on), ours(9090));
|
||||
let off = "Enabled: No\nServer: \nPort: 0\nAuthenticated Proxy Enabled: 0\n";
|
||||
assert_eq!(parse_proxy(off), setting(false, "", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_service_ids() {
|
||||
let output = "An asterisk (*) denotes that a network service is disabled.\n(1) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n\n(*) Thunderbolt Bridge\n(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n\n(3) Tailscale\n(Hardware Port: io.tailscale.ipn.macsys, Device: )\n\n";
|
||||
assert_eq!(
|
||||
parse_service_ids(output),
|
||||
vec![
|
||||
("Wi-Fi".to_string(), "en0".to_string()),
|
||||
("Thunderbolt Bridge".to_string(), "bridge0".to_string()),
|
||||
("Tailscale".to_string(), "port:io.tailscale.ipn.macsys".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_renamed_while_routing_gets_its_settings_back() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]);
|
||||
let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
assert_eq!(snapshot.services[0].id.as_deref(), Some("en0"));
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
|
||||
ns.rename("Wi-Fi", "Office Wi-Fi");
|
||||
let services = read_services(&ns, true).unwrap();
|
||||
let (commands, used_up) = disable_commands(&services, Some(&snapshot), 9090);
|
||||
apply(&ns, &commands).unwrap();
|
||||
assert_eq!(ns.proxies()[0].http, corp);
|
||||
assert_eq!(ns.proxies()[0].https, corp);
|
||||
assert!(used_up && snapshot.leftover(&services).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_sharing_a_device_keep_their_own_settings() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let lab = setting(true, "lab-proxy", 8888);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", corp.clone(), corp.clone()),
|
||||
("Wi-Fi Lab", lab.clone(), lab.clone()),
|
||||
]);
|
||||
ns.set_device("Wi-Fi Lab", "en0");
|
||||
let before = ns.proxies();
|
||||
let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
assert_eq!(snapshot.services.len(), 2);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
|
||||
let services = read_services(&ns, true).unwrap();
|
||||
let (commands, _) = disable_commands(&services, Some(&snapshot), 9090);
|
||||
apply(&ns, &commands).unwrap();
|
||||
assert_eq!(ns.proxies(), before);
|
||||
assert!(snapshot.leftover(&services).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_plus_a_new_service_on_its_device_matches_neither() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let lab = setting(true, "lab-proxy", 8888);
|
||||
let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]);
|
||||
let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
// While routing: Wi-Fi is renamed, and a second service is added on its device.
|
||||
ns.rename("Wi-Fi", "Office");
|
||||
ns.add("Lab", "en0", lab.clone(), lab.clone());
|
||||
|
||||
// Turned on again: Lab's own settings are saved, not skipped as Wi-Fi's.
|
||||
let services = read_services(&ns, false).unwrap();
|
||||
snapshot.extend(&services, 9090);
|
||||
assert!(snapshot.services.iter().any(|s| s.name == "Lab" && s.http == lab));
|
||||
apply(&ns, &enable_commands(&services, 9090)).unwrap();
|
||||
|
||||
let services = read_services(&ns, true).unwrap();
|
||||
let (commands, _) = disable_commands(&services, Some(&snapshot), 9090);
|
||||
apply(&ns, &commands).unwrap();
|
||||
let proxies = ns.proxies();
|
||||
assert_eq!(proxies[1].http, lab, "Lab gets its own settings back");
|
||||
assert!(!proxies[0].points_at(9090));
|
||||
assert_ne!(proxies[0].http, corp, "ambiguous, so Office is not handed Wi-Fi's");
|
||||
// Wi-Fi's saved settings are not lost.
|
||||
let leftover = snapshot.leftover(&services).unwrap();
|
||||
assert!(leftover.services.iter().any(|s| s.name == "Wi-Fi" && s.http == corp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_beside_a_duplicate_still_matches() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let lab = setting(true, "lab-proxy", 8888);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", corp.clone(), corp.clone()),
|
||||
("Wi-Fi Lab", lab.clone(), lab.clone()),
|
||||
]);
|
||||
ns.set_device("Wi-Fi Lab", "en0");
|
||||
let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
ns.rename("Wi-Fi", "Office");
|
||||
|
||||
let services = read_services(&ns, true).unwrap();
|
||||
let (commands, _) = disable_commands(&services, Some(&snapshot), 9090);
|
||||
apply(&ns, &commands).unwrap();
|
||||
assert_eq!(ns.proxies()[0].http, corp);
|
||||
assert_eq!(ns.proxies()[1].http, lab);
|
||||
assert!(snapshot.leftover(&services).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_entry_no_service_matches_is_kept() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", corp.clone(), corp.clone()),
|
||||
("USB LAN", corp.clone(), corp.clone()),
|
||||
]);
|
||||
let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
ns.remove("USB LAN");
|
||||
|
||||
let leftover = snapshot.leftover(&read_services(&ns, true).unwrap()).unwrap();
|
||||
assert_eq!(leftover.port, 0);
|
||||
assert_eq!(leftover.services.len(), 1);
|
||||
assert_eq!(leftover.services[0].name, "USB LAN");
|
||||
assert_eq!(leftover.services[0].http, corp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_same_named_service_on_another_device_is_not_matched() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let mut saved = ServiceProxy::new("Ethernet", corp.clone(), corp);
|
||||
saved.id = Some("en5".into());
|
||||
let snapshot = Snapshot { port: 9090, services: vec![saved], ..Snapshot::default() };
|
||||
let mut other = ServiceProxy::new("Ethernet", setting(true, HOST, 9090), ours(9090));
|
||||
other.id = Some("en6".into());
|
||||
assert_eq!(
|
||||
restore_commands(&[other], Some(&snapshot), &[9090]),
|
||||
vec![
|
||||
vec!["-setwebproxystate", "Ethernet", "off"],
|
||||
vec!["-setsecurewebproxystate", "Ethernet", "off"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_points_every_service_at_localhost() {
|
||||
let services = vec![ServiceProxy::new(
|
||||
"Wi-Fi",
|
||||
ProxySetting::default(),
|
||||
ProxySetting::default(),
|
||||
)];
|
||||
assert_eq!(
|
||||
enable_commands(&services, 9090),
|
||||
vec![
|
||||
vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"],
|
||||
vec!["-setwebproxystate", "Wi-Fi", "on"],
|
||||
vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"],
|
||||
vec!["-setsecurewebproxystate", "Wi-Fi", "on"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trip_puts_back_exactly_what_was_there() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let parked = setting(false, "old-proxy", 8888);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", corp.clone(), parked.clone()),
|
||||
("Ethernet", ProxySetting::default(), ProxySetting::default()),
|
||||
]);
|
||||
let before = ns.proxies();
|
||||
|
||||
let services = read_services(&ns, false).unwrap();
|
||||
let snapshot = Snapshot::capture(&services, 9090);
|
||||
apply(&ns, &enable_commands(&services, 9090)).unwrap();
|
||||
assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090)));
|
||||
|
||||
let restore =
|
||||
restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]);
|
||||
apply(&ns, &restore).unwrap();
|
||||
let after = ns.proxies();
|
||||
assert_eq!(after[0], before[0]);
|
||||
// A service that never had a server is turned off, keeping ours as the unused server.
|
||||
assert!(!after[1].http.enabled && !after[1].https.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_leaves_a_proxy_the_user_changed_since() {
|
||||
let ns = FakeNetworksetup::new(vec![(
|
||||
"Wi-Fi",
|
||||
setting(true, "corp-proxy", 3128),
|
||||
setting(true, "corp-proxy", 3128),
|
||||
)]);
|
||||
let services = read_services(&ns, false).unwrap();
|
||||
let snapshot = Snapshot::capture(&services, 9090);
|
||||
apply(&ns, &enable_commands(&services, 9090)).unwrap();
|
||||
// The user points HTTPS somewhere else while ours is on.
|
||||
ns.set_proxy("Wi-Fi", ours(9090), setting(true, "charles", 8888));
|
||||
|
||||
let restore =
|
||||
restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]);
|
||||
assert_eq!(
|
||||
restore,
|
||||
vec![
|
||||
vec!["-setwebproxy", "Wi-Fi", "corp-proxy", "3128"],
|
||||
vec!["-setwebproxystate", "Wi-Fi", "on"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_without_a_snapshot_turns_off_only_our_proxies() {
|
||||
let services = vec![
|
||||
ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)),
|
||||
// Someone else's proxy, and ours on another port.
|
||||
ServiceProxy::new("Ethernet", setting(true, "corp-proxy", 9090), ours(8080)),
|
||||
];
|
||||
assert!(services[0].points_at(9090) && !services[1].points_at(9090));
|
||||
assert_eq!(
|
||||
restore_commands(&services, None, &[9090]),
|
||||
vec![
|
||||
vec!["-setwebproxystate", "Wi-Fi", "off"],
|
||||
vec!["-setsecurewebproxystate", "Wi-Fi", "off"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capture_over_our_own_leftover_saves_it_as_off() {
|
||||
// A crash left Wi-Fi pointing at us with nothing saved.
|
||||
let services = vec![ServiceProxy::new("Wi-Fi", ours(9090), ours(9090))];
|
||||
let snapshot = Snapshot::capture(&services, 9090);
|
||||
assert!(!snapshot.services[0].http.enabled && !snapshot.services[0].https.enabled);
|
||||
let restore = restore_commands(&services, Some(&snapshot), &[9090]);
|
||||
assert_eq!(
|
||||
restore,
|
||||
vec![
|
||||
vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"],
|
||||
vec!["-setwebproxystate", "Wi-Fi", "off"],
|
||||
vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"],
|
||||
vec!["-setsecurewebproxystate", "Wi-Fi", "off"],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_keeps_the_first_capture_and_adds_new_services() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let mut snapshot =
|
||||
Snapshot::capture(&[ServiceProxy::new("Wi-Fi", corp.clone(), corp.clone())], 9090);
|
||||
// Turned on again on another port: Wi-Fi now shows ours, and Ethernet is new.
|
||||
snapshot.extend(
|
||||
&[
|
||||
ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)),
|
||||
ServiceProxy::new("Ethernet", corp.clone(), corp.clone()),
|
||||
],
|
||||
9191,
|
||||
);
|
||||
assert_eq!(snapshot.port, 9191);
|
||||
assert_eq!(snapshot.services.len(), 2);
|
||||
assert_eq!(snapshot.services[0].http, corp);
|
||||
assert_eq!(snapshot.services[1].https, corp);
|
||||
}
|
||||
|
||||
/// Enables on 9090 over `parked`, then runs only the first call of the restore, as a crash
|
||||
/// would leave it: the saved server is back, but on.
|
||||
fn cut_short(parked: &ProxySetting) -> (FakeNetworksetup, Snapshot) {
|
||||
let ns = FakeNetworksetup::new(vec![("Wi-Fi", parked.clone(), parked.clone())]);
|
||||
let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
let restore =
|
||||
restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]);
|
||||
snapshot.mark_restoring(&restore, &read_services(&ns, true).unwrap());
|
||||
apply(&ns, &restore[..1]).unwrap();
|
||||
assert!(ns.proxies()[0].http.enabled);
|
||||
(ns, snapshot)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restore_cut_short_is_finished_on_the_next_try() {
|
||||
let parked = setting(false, "old-proxy", 8888);
|
||||
let (ns, snapshot) = cut_short(&parked);
|
||||
let (retry, used_up) =
|
||||
disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090);
|
||||
assert!(used_up);
|
||||
apply(&ns, &retry).unwrap();
|
||||
assert_eq!(ns.proxies()[0].http, parked);
|
||||
assert_eq!(ns.proxies()[0].https, parked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_short_restore_is_finished_after_a_rename() {
|
||||
let parked = setting(false, "old-proxy", 8888);
|
||||
let (ns, snapshot) = cut_short(&parked);
|
||||
ns.rename("Wi-Fi", "Office Wi-Fi");
|
||||
let (retry, _) =
|
||||
disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090);
|
||||
apply(&ns, &retry).unwrap();
|
||||
assert_eq!(ns.proxies()[0].http, parked);
|
||||
assert_eq!(ns.proxies()[0].https, parked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_short_restore_of_a_localhost_proxy_is_finished_too() {
|
||||
let parked = setting(false, HOST, 8888);
|
||||
let (ns, snapshot) = cut_short(&parked);
|
||||
let (retry, _) =
|
||||
disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090);
|
||||
apply(&ns, &retry).unwrap();
|
||||
assert_eq!(ns.proxies()[0].http, parked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_the_user_turned_on_themselves_is_left_on() {
|
||||
let parked = setting(false, "corp-proxy", 3128);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", ProxySetting::default(), ProxySetting::default()),
|
||||
("Ethernet", parked.clone(), parked.clone()),
|
||||
]);
|
||||
let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&ns.proxies()[..1], 9090)).unwrap();
|
||||
// A VPN turns the parked corporate proxy on while ours runs on Wi-Fi.
|
||||
let on = setting(true, "corp-proxy", 3128);
|
||||
ns.set_proxy("Ethernet", on.clone(), on.clone());
|
||||
let (commands, _) =
|
||||
disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090);
|
||||
apply(&ns, &commands).unwrap();
|
||||
assert_eq!(ns.proxies()[1].http, on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_old_port_puts_back_what_was_saved_there() {
|
||||
let corp = setting(true, "corp-proxy", 3128);
|
||||
let ns = FakeNetworksetup::new(vec![
|
||||
("Wi-Fi", corp.clone(), corp.clone()),
|
||||
("Ethernet", corp.clone(), corp.clone()),
|
||||
]);
|
||||
let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090);
|
||||
apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap();
|
||||
// Ethernet is disabled before the move to 9191, so it stays on 9090.
|
||||
ns.set_enabled("Ethernet", false);
|
||||
let enabled = read_services(&ns, false).unwrap();
|
||||
snapshot.extend(&enabled, 9191);
|
||||
apply(&ns, &enable_commands(&enabled, 9191)).unwrap();
|
||||
|
||||
let (commands, used_up) =
|
||||
disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090);
|
||||
assert!(!used_up);
|
||||
apply(&ns, &commands).unwrap();
|
||||
assert_eq!(ns.proxies()[1].http, corp);
|
||||
assert!(ns.proxies()[0].http.points_at(9191), "live routing kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fake_refuses_an_unknown_service() {
|
||||
let ns = FakeNetworksetup::new(vec![]);
|
||||
assert!(ns.run(&["-getwebproxy".into(), "Nope".into()]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Telling whether the process that turned the system proxy on is still the same process. A
|
||||
//! pid alone is not enough once it can be reused, after a reboot for one, so the owner is the
|
||||
//! pid and the time it started.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Owner {
|
||||
pub pid: i32,
|
||||
/// Microseconds since the epoch.
|
||||
pub started: u64,
|
||||
}
|
||||
|
||||
pub trait Processes {
|
||||
/// When `pid` started, while it runs.
|
||||
fn start_time(&self, pid: i32) -> Option<u64>;
|
||||
|
||||
fn owner(&self, pid: i32) -> Option<Owner> {
|
||||
Some(Owner { pid, started: self.start_time(pid)? })
|
||||
}
|
||||
|
||||
fn is_alive(&self, owner: &Owner) -> bool {
|
||||
self.start_time(owner.pid) == Some(owner.started)
|
||||
}
|
||||
}
|
||||
|
||||
/// This Mac's processes, through `proc_pidinfo`.
|
||||
pub struct SystemProcesses;
|
||||
|
||||
impl Processes for SystemProcesses {
|
||||
fn start_time(&self, pid: i32) -> Option<u64> {
|
||||
if pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
|
||||
let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
|
||||
// SAFETY: `info` is a buffer of exactly `size` bytes for PROC_PIDTBSDINFO to fill.
|
||||
let written = unsafe {
|
||||
libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size)
|
||||
};
|
||||
if written != size {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: proc_pidinfo filled the whole struct.
|
||||
let info = unsafe { info.assume_init() };
|
||||
// A zombie has exited; only its parent has yet to notice.
|
||||
if info.pbi_status == libc::SZOMB {
|
||||
return None;
|
||||
}
|
||||
Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn knows_this_process_and_not_a_missing_one() {
|
||||
let me = SystemProcesses.owner(std::process::id() as i32).unwrap();
|
||||
assert!(SystemProcesses.is_alive(&me));
|
||||
// Same pid, different start: the pid was reused.
|
||||
assert!(!SystemProcesses.is_alive(&Owner { started: me.started + 1, ..me }));
|
||||
assert_eq!(SystemProcesses.start_time(0), None);
|
||||
assert_eq!(SystemProcesses.start_time(i32::MAX), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_that_exited_is_not_alive() {
|
||||
let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap();
|
||||
let owner = SystemProcesses.owner(child.id() as i32);
|
||||
child.wait().unwrap();
|
||||
if let Some(owner) = owner {
|
||||
assert!(!SystemProcesses.is_alive(&owner));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! What the app and the daemon say to each other: one JSON object per line each way, a
|
||||
//! [`Request`] and its [`Response`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The lowest port the system proxy may point at: never a privileged one.
|
||||
pub const MIN_PORT: u16 = 1024;
|
||||
|
||||
/// The longest request line read, far more than any request needs.
|
||||
pub const MAX_LINE: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Request {
|
||||
/// What the daemon has done to the system proxy.
|
||||
Get,
|
||||
/// Points every enabled service at `127.0.0.1:<port>`, saving the settings it replaces,
|
||||
/// and restores them once `owner_pid` exits. Off restores them now.
|
||||
Set {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
owner_pid: i32,
|
||||
},
|
||||
/// Puts back the saved settings.
|
||||
Restore,
|
||||
/// Restores and deletes the daemon's state, before the app unregisters it, which stops it.
|
||||
Unregister,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Status {
|
||||
/// The daemon has pointed the system proxy at the engine and will restore it.
|
||||
pub active: bool,
|
||||
pub port: Option<u16>,
|
||||
pub owner_pid: Option<i32>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Response {
|
||||
pub ok: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<Status>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok(status: Status) -> Self {
|
||||
Self { ok: true, error: None, status: Some(status) }
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self { ok: false, error: Some(message.into()), status: None }
|
||||
}
|
||||
|
||||
pub fn into_result(self) -> Result<Status, String> {
|
||||
if self.ok {
|
||||
Ok(self.status.unwrap_or_default())
|
||||
} else {
|
||||
Err(self.error.unwrap_or_else(|| "The helper refused the request".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and checks one request line. The host is not a field: it is always `127.0.0.1`.
|
||||
pub fn parse_request(line: &str) -> Result<Request, String> {
|
||||
let request: Request =
|
||||
serde_json::from_str(line.trim()).map_err(|e| format!("Invalid request: {e}"))?;
|
||||
if let Request::Set { enabled: true, port, owner_pid } = request {
|
||||
if port < MIN_PORT {
|
||||
return Err(format!("Port {port} is outside {MIN_PORT} to 65535"));
|
||||
}
|
||||
if owner_pid <= 0 {
|
||||
return Err(format!("Invalid owner pid {owner_pid}"));
|
||||
}
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_each_command() {
|
||||
assert_eq!(parse_request(r#"{"cmd":"get"}"#), Ok(Request::Get));
|
||||
assert_eq!(parse_request("{\"cmd\":\"restore\"}\n"), Ok(Request::Restore));
|
||||
assert_eq!(parse_request(r#"{"cmd":"unregister"}"#), Ok(Request::Unregister));
|
||||
assert_eq!(
|
||||
parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#),
|
||||
Ok(Request::Set { enabled: true, port: 9090, owner_pid: 42 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_ports_outside_1024_to_65535() {
|
||||
for port in [0, 80, 1023] {
|
||||
let line = format!(r#"{{"cmd":"set","enabled":true,"port":{port},"owner_pid":42}}"#);
|
||||
assert!(parse_request(&line).is_err(), "port {port}");
|
||||
}
|
||||
assert!(
|
||||
parse_request(r#"{"cmd":"set","enabled":true,"port":65536,"owner_pid":42}"#).is_err()
|
||||
);
|
||||
assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":-1,"owner_pid":42}"#).is_err());
|
||||
assert!(
|
||||
parse_request(r#"{"cmd":"set","enabled":true,"port":1024,"owner_pid":42}"#).is_ok()
|
||||
);
|
||||
assert!(
|
||||
parse_request(r#"{"cmd":"set","enabled":true,"port":65535,"owner_pid":42}"#).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_host() {
|
||||
let line = r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42,"host":"10.0.0.1"}"#;
|
||||
assert!(parse_request(line).unwrap_err().contains("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_bad_owner_and_junk() {
|
||||
assert!(
|
||||
parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":0}"#).is_err()
|
||||
);
|
||||
assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":9090}"#).is_err());
|
||||
assert!(parse_request(r#"{"cmd":"rm -rf"}"#).is_err());
|
||||
assert!(parse_request("not json").is_err());
|
||||
assert!(parse_request("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_needs_no_valid_port() {
|
||||
// Off restores whatever the daemon saved; the port only widens what counts as ours.
|
||||
assert!(parse_request(r#"{"cmd":"set","enabled":false,"port":0,"owner_pid":0}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_round_trip() {
|
||||
let ok = Response::ok(Status {
|
||||
active: true,
|
||||
port: Some(9090),
|
||||
owner_pid: Some(1),
|
||||
version: "1".into(),
|
||||
});
|
||||
let line = serde_json::to_string(&ok).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Response>(&line).unwrap(), ok);
|
||||
assert_eq!(Response::error("no").into_result(), Err("no".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
"client:dev": "node scripts/run-dev.mjs client",
|
||||
"client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign",
|
||||
"proxy:build": "node scripts/run-build.mjs proxy",
|
||||
"proxy:bundle": "node scripts/bundle-proxy.mjs",
|
||||
"proxy:dev": "node scripts/run-dev.mjs proxy",
|
||||
"web:dev": "run-p web:dev:*",
|
||||
"web:dev:app": "node scripts/run-web.mjs dev",
|
||||
@@ -110,6 +111,8 @@
|
||||
"client:tauri-before-build": "npm run bootstrap",
|
||||
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
|
||||
"proxy:tauri-before-build": "npm run bootstrap",
|
||||
"proxy:tauri-before-bundle": "run-s bootstrap proxy:vendor-helper",
|
||||
"proxy:vendor-helper": "node scripts/vendor-proxy-helper.cjs",
|
||||
"proxy:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-proxy"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Builds Yaak Proxy as a `.app` with its privileged helper inside, signed when a Developer
|
||||
* ID Application identity is in the keychain (or named by APPLE_SIGNING_IDENTITY). The helper
|
||||
* only registers from a signed bundle, and only answers an app signed by the same team, so
|
||||
* the team is taken from the identity and built into the helper.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.join(__dirname, "..");
|
||||
|
||||
/**
|
||||
* The keychain's Developer ID Application identities, by hash, since names can repeat. With
|
||||
* `wanted`, only the one with that hash or name.
|
||||
*/
|
||||
function findIdentity(wanted) {
|
||||
try {
|
||||
const output = execFileSync("security", ["find-identity", "-v", "-p", "codesigning"]);
|
||||
const pattern =
|
||||
/^\s*\d+\) ([0-9A-F]{40}) "(Developer ID Application: .+ \(([A-Z0-9]{10})\))"$/gm;
|
||||
for (const match of output.toString().matchAll(pattern)) {
|
||||
if (wanted == null || wanted === match[1] || wanted === match[2]) {
|
||||
return { hash: match[1], name: match[2], team: match[3] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const args = ["--bundles", "app", "--config", "crates-tauri/yaak-app-proxy/tauri.bundle.conf.json"];
|
||||
|
||||
if (env.APPLE_SIGNING_IDENTITY) {
|
||||
console.log("Signing with APPLE_SIGNING_IDENTITY");
|
||||
const team =
|
||||
env.APPLE_SIGNING_IDENTITY.match(/\(([A-Z0-9]{10})\)$/)?.[1] ??
|
||||
findIdentity(env.APPLE_SIGNING_IDENTITY)?.team;
|
||||
env.YAAK_PROXY_TEAM_ID ??= env.APPLE_TEAM_ID ?? team;
|
||||
if (env.YAAK_PROXY_TEAM_ID == null) {
|
||||
console.error(
|
||||
"Could not tell the signing team, which the helper must know. Set YAAK_PROXY_TEAM_ID or APPLE_TEAM_ID.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const identity = process.platform === "darwin" ? findIdentity() : null;
|
||||
if (identity) {
|
||||
console.log(`Signing with "${identity.name}"`);
|
||||
env.APPLE_SIGNING_IDENTITY = identity.hash;
|
||||
env.YAAK_PROXY_TEAM_ID ??= identity.team;
|
||||
} else {
|
||||
console.warn(
|
||||
"No Developer ID Application identity found, so the bundle is unsigned and its helper cannot be registered",
|
||||
);
|
||||
args.push("--no-sign");
|
||||
}
|
||||
}
|
||||
if (env.YAAK_PROXY_TEAM_ID == null) delete env.YAAK_PROXY_TEAM_ID;
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, "run-build.mjs"), "proxy", ...args, ...process.argv.slice(2)],
|
||||
{ cwd: rootDir, stdio: "inherit", env },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Builds Yaak Proxy's privileged helper and places it where the proxy bundle's
|
||||
* `externalBin` expects it, named for the target triple. The helper is macOS only.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { chmodSync, copyFileSync, mkdirSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
console.log("The proxy helper is macOS only, so there is nothing to vendor");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rootDir = path.join(__dirname, "..");
|
||||
const destDir = path.join(rootDir, "crates-tauri", "yaak-app-proxy", "vendored", "helper");
|
||||
const target = process.env.TAURI_ENV_TARGET_TRIPLE || hostTriple();
|
||||
|
||||
function hostTriple() {
|
||||
const output = execFileSync("rustc", ["-vV"]).toString();
|
||||
return output.match(/^host: (\S+)$/m)[1];
|
||||
}
|
||||
|
||||
/** Where Cargo builds, which CARGO_TARGET_DIR or a config's build.target-dir can move */
|
||||
function targetDir() {
|
||||
const args = ["metadata", "--format-version", "1", "--no-deps"];
|
||||
return JSON.parse(execFileSync("cargo", args, { cwd: rootDir })).target_directory;
|
||||
}
|
||||
|
||||
function build(triple) {
|
||||
execFileSync(
|
||||
"cargo",
|
||||
["build", "--package", "yaak-proxy-helper", "--release", "--target", triple],
|
||||
{ cwd: rootDir, stdio: "inherit" },
|
||||
);
|
||||
return path.join(targetDir(), triple, "release", "yaak-proxy-helper");
|
||||
}
|
||||
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
const dest = path.join(destDir, `yaak-proxy-helper-${target}`);
|
||||
if (target === "universal-apple-darwin") {
|
||||
const arm = build("aarch64-apple-darwin");
|
||||
const intel = build("x86_64-apple-darwin");
|
||||
execFileSync("lipo", ["-create", "-output", dest, arm, intel], { stdio: "inherit" });
|
||||
} else {
|
||||
copyFileSync(build(target), dest);
|
||||
}
|
||||
chmodSync(dest, 0o755);
|
||||
console.log(`Vendored the proxy helper to ${path.relative(rootDir, dest)}`);
|
||||
Reference in new issue
Block a user