Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 11 additions & 45 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,14 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import { TailscaleIdentityDiscovery, type TailscaleIdentity } from "@t3tools/tailscale";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";

const encoder = new TextEncoder();

const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {};
const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
en0: [
Expand All @@ -38,36 +34,6 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
],
};

function mockSpawnerLayer(statusJson = "{}") {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode(statusJson)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
),
);
}

function dieOnSpawnLayer() {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")),
);
}

function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
return DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
Expand All @@ -90,21 +56,24 @@ function makeLayer(input: {
readonly baseDir: string;
readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces;
readonly env?: Record<string, string | undefined>;
readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
readonly tailscaleIdentity?: Effect.Effect<TailscaleIdentity>;
readonly desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>;
}) {
const env = { T3CODE_HOME: input.baseDir, ...input.env };
const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, {
read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
});
const tailscaleIdentityLayer = Layer.succeed(TailscaleIdentityDiscovery, {
discover: input.tailscaleIdentity ?? Effect.succeed({ dnsNames: [] }),
});

return DesktopServerExposure.layer.pipe(
Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer),
Layer.provideMerge(NodeFileSystem.layer),
Layer.provideMerge(NodeHttpClient.layerUndici),
Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()),
Layer.provideMerge(networkLayer),
Layer.provideMerge(tailscaleIdentityLayer),
Layer.provideMerge(DesktopConfig.layerTest(env)),
Layer.provideMerge(environmentLayer),
);
Expand All @@ -122,8 +91,8 @@ const withHarness = <A, E, R>(
| DesktopAppSettings.DesktopAppSettings
>,
env: Record<string, string | undefined> = {},
spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>,
tailscaleIdentity?: Effect.Effect<TailscaleIdentity>,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -136,7 +105,7 @@ const withHarness = <A, E, R>(
baseDir,
networkInterfaces,
env,
...(spawnerLayer ? { spawnerLayer } : {}),
...(tailscaleIdentity ? { tailscaleIdentity } : {}),
...(desktopSettingsLayer ? { desktopSettingsLayer } : {}),
}),
),
Expand Down Expand Up @@ -300,7 +269,6 @@ describe("DesktopServerExposure", () => {
assert.notInclude(tailscaleError.message, diskFailure.message);
}),
{},
undefined,
settingsLayer,
);
});
Expand Down Expand Up @@ -348,24 +316,22 @@ describe("DesktopServerExposure", () => {
),
);

it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
it.effect("does not discover a Tailscale identity while server exposure is local-only", () =>
withHarness(
lanNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
// mode stays at default "local-only", tailscaleServeEnabled stays false.

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
// Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer
// would have crashed the test if the gate was missing.
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/"],
);
}),
{},
dieOnSpawnLayer(),
undefined,
Effect.die("unexpected Tailscale identity discovery"),
),
);

Expand Down
30 changes: 4 additions & 26 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,20 @@ import {
type DesktopServerExposureMode,
type DesktopServerExposureState,
} from "@t3tools/contracts";
import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale";
import { isTailscaleIpv4Address, TailscaleIdentityDiscovery } from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";

const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);

const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
const DESKTOP_LAN_BIND_HOST = "0.0.0.0";

Expand Down Expand Up @@ -415,23 +411,11 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo
export const make = Effect.gen(function* () {
const config = yield* DesktopConfig.DesktopConfig;
const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces;
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const tailscaleIdentity = yield* TailscaleIdentityDiscovery;
const httpClient = yield* HttpClient.HttpClient;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const stateRef = yield* Ref.make(initialRuntimeState());

// Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
// Store Tailscale CLI lives inside Tailscale's sandbox container, so each
// spawn re-triggers the "Other apps" TCC prompt.
const cachedReadMagicDnsName = yield* Effect.cachedWithTTL(
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.orElseSucceed(() => null),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
),
TAILSCALE_STATUS_CACHE_TTL,
);

const readNetworkInterfaces = networkInterfaces.read;

const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
Expand Down Expand Up @@ -538,9 +522,6 @@ export const make = Effect.gen(function* () {
customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
});

// Don't spawn the Tailscale CLI when the user hasn't opted into any
// network exposure. The spawn itself triggers a macOS "Other apps"
// TCC prompt on Mac App Store Tailscale builds.
if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) {
return coreEndpoints;
}
Expand All @@ -550,11 +531,8 @@ export const make = Effect.gen(function* () {
serveEnabled: state.tailscaleServeEnabled,
servePort: state.tailscaleServePort,
networkInterfaces: currentNetworkInterfaces,
readMagicDnsName: cachedReadMagicDnsName,
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
identity: yield* tailscaleIdentity.discover,
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
return [...coreEndpoints, ...tailscaleEndpoints];
}).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));

Expand Down
77 changes: 28 additions & 49 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,20 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import {
parseTailscaleMagicDnsName,
resolveTailscaleAdvertisedEndpoints,
} from "./tailscaleEndpointProvider.ts";
import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";

const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
),
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
);

describe("tailscale endpoint provider", () => {
it.effect("parses MagicDNS names from tailscale status", () =>
Effect.gen(function* () {
const dnsName = yield* parseTailscaleMagicDnsName(
`{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
);
assert.equal(dnsName, "desktop.tail.ts.net");
assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
assert.isTrue(malformed._tag === "Failure");
}),
);

it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () =>
Effect.gen(function* () {
const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: 3773,
identity: { dnsNames: ["desktop.tail.ts.net", "desktop.second-tail.ts.net"] },
networkInterfaces: {
tailscale0: [
{
Expand All @@ -49,7 +28,6 @@ describe("tailscale endpoint provider", () => {
},
],
},
statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
});
assert.deepEqual(endpoints, [
{
Expand Down Expand Up @@ -92,27 +70,28 @@ describe("tailscale endpoint provider", () => {
status: "unavailable",
description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
},
{
id: "tailscale-magicdns:https://desktop.second-tail.ts.net/",
label: "Tailscale HTTPS",
provider: {
id: "tailscale",
label: "Tailscale",
kind: "private-network",
isAddon: true,
},
httpBaseUrl: "https://desktop.second-tail.ts.net/",
wsBaseUrl: "wss://desktop.second-tail.ts.net/",
reachability: "private-network",
compatibility: {
hostedHttpsApp: "requires-configuration",
desktopApp: "compatible",
},
source: "desktop-addon",
status: "unavailable",
description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
},
]);
}).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
);

it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () =>
Effect.gen(function* () {
let readerCalls = 0;
const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: 3773,
networkInterfaces: {},
readMagicDnsName: Effect.sync(() => {
readerCalls += 1;
return "desktop.tail.ts.net";
}),
});
assert.equal(readerCalls, 1);
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["https://desktop.tail.ts.net/"],
);
}).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
}).pipe(Effect.provide(httpClientLayer)),
);

it.effect(
Expand All @@ -122,7 +101,7 @@ describe("tailscale endpoint provider", () => {
const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: 3773,
networkInterfaces: {},
statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
identity: { dnsNames: ["desktop.tail.ts.net"] },
serveEnabled: true,
probe: () => Effect.succeed(true),
});
Expand All @@ -148,6 +127,6 @@ describe("tailscale endpoint provider", () => {
description: "HTTPS endpoint served by Tailscale Serve.",
},
]);
}).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
}).pipe(Effect.provide(httpClientLayer)),
);
});
Loading
Loading