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
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ export const make = Effect.gen(function* () {

const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: state.port,
bindHost: state.bindHost,
serveEnabled: state.tailscaleServeEnabled,
servePort: state.tailscaleServePort,
networkInterfaces: currentNetworkInterfaces,
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe("tailscale endpoint provider", () => {
utun4: [
{ address: "100.74.126.34", family: "IPv4", internal: false },
{ address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false },
{ address: "fd7a:115c:a1e0::9999", family: "IPv6", internal: false },
],
},
statusJson: null,
Expand All @@ -80,3 +81,26 @@ describe("tailscale endpoint provider", () => {
}).pipe(Effect.provide(layerUnusedTailscaleExternalServices)),
);
});

it.effect("advertises a bound IPv6 MagicDNS listener after the preferred HTTPS route", () =>
Effect.gen(function* () {
for (const serveEnabled of [false, true]) {
const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: 3773,
bindHost: "fd7a:115c:a1e0::1234",
serveEnabled,
probe: () => Effect.succeed(true),
networkInterfaces: {
tailscale0: [{ address: "fd7a:115c:a1e0::1234", family: "IPv6", internal: false }],
},
statusJson: JSON.stringify({
BackendState: "Running",
Self: { DNSName: "desktop.tail.ts.net.", TailscaleIPs: ["fd7a:115c:a1e0::1234"] },
}),
});
const urls = endpoints.map((endpoint) => endpoint.httpBaseUrl);
assert.deepEqual(urls, ["https://desktop.tail.ts.net/", "http://desktop.tail.ts.net:3773/"]);
assert.strictEqual(endpoints[0]?.status, serveEnabled ? "available" : "unavailable");
}
}).pipe(Effect.provide(layerUnusedTailscaleExternalServices)),
);
29 changes: 27 additions & 2 deletions apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint";
import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
import {
buildTailscaleHttpsBaseUrl,
buildTailscaleDirectBaseUrl,
isTailscaleIpv4Address,
parseTailscaleStatus,
probeTailscaleHttpsEndpoint,
Expand Down Expand Up @@ -114,6 +115,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")(
function* (input: {
readonly port: number;
readonly bindHost?: string;
readonly serveEnabled?: boolean;
readonly servePort?: number;
readonly networkInterfaces: NetworkInterfaces;
Expand Down Expand Up @@ -145,9 +147,32 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
...(input.probe === undefined ? {} : { probe: input.probe }),
});

const directUrl = buildTailscaleDirectBaseUrl({
status,
host: input.bindHost ?? "127.0.0.1",
localAddresses: Object.values(input.networkInterfaces).flatMap((entries) =>
(entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address),
),
port: input.port,
});
const directEndpoints =
directUrl === null
? []
: [
createAdvertisedEndpoint({
provider: TAILSCALE_ENDPOINT_PROVIDER,
source: "desktop-addon",
id: `tailscale-magicdns-direct:${directUrl}`,
label: "Tailscale MagicDNS",
httpBaseUrl: directUrl,
reachability: "private-network",
status: "available",
description: "Reachable from devices on the same Tailnet.",
}),
];
return Option.match(magicDnsEndpoint, {
onNone: () => ipEndpoints,
onSome: (endpoint) => [...ipEndpoints, endpoint],
onNone: () => [...ipEndpoints, ...directEndpoints],
onSome: (endpoint) => [...ipEndpoints, endpoint, ...directEndpoints],
});
},
);
142 changes: 139 additions & 3 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it as effectIt } from "@effect/vitest";
import type * as NodeOS from "node:os";
import * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as TestClock from "effect/testing/TestClock";
import * as Fiber from "effect/Fiber";
import * as Ref from "effect/Ref";
import * as Layer from "effect/Layer";
Expand All @@ -11,12 +12,14 @@ import * as Stream from "effect/Stream";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import * as ServerConfig from "../config.ts";
import * as DirectEndpoints from "./DirectEndpoints.ts";
import { resolveBoundEndpoints } from "./DirectEndpoints.ts";

vi.mock("node:os", { spy: true });

const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
lo0: [
{
Expand Down Expand Up @@ -226,7 +229,8 @@ describe("resolveBoundEndpoints", () => {
});

const TAILSCALE_STATUS_JSON = JSON.stringify({
Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
BackendState: "Running",
Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2", "fd7a:115c:a1e0::1234"] },
});

const layerTailscaleUp = Layer.succeed(
Expand Down Expand Up @@ -285,6 +289,58 @@ const resolveWithServe = (probeStatus: number) =>
);

describe("DirectEndpoints Tailscale Serve", () => {
for (const probeState of ["healthy", "failed", "timeout"] as const) {
effectIt.effect(`keeps an unspecified listener loopback when HTTPS is ${probeState}`, () =>
Effect.gen(function* () {
using _interfaces = vi
.spyOn(NodeOS, "networkInterfaces")
.mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") });
const probeStarted = yield* Deferred.make<void>();
const service = yield* DirectEndpoints.make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, host: undefined })),
).pipe(Layer.provide(layerServeConfig())),
layerTailscaleUp,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Deferred.succeed(probeStarted, undefined).pipe(
Effect.andThen(
probeState === "timeout"
? Effect.never
: Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(null, { status: probeState === "healthy" ? 200 : 502 }),
),
),
),
),
),
),
),
),
);
const resolving = yield* Effect.forkScoped(service.resolve());
yield* Deferred.await(probeStarted);
if (probeState === "timeout") {
expect(yield* service.resolveAvailable()).toEqual([]);
yield* TestClock.adjust("3 seconds");
}
const endpoints = yield* Fiber.join(resolving);
expect(endpoints).toEqual(
probeState === "healthy"
? [{ kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" }]
: [],
);
expect(yield* service.resolveAvailable()).toEqual(endpoints);
}).pipe(Effect.scoped),
);
}

effectIt.effect("lists the tailnet name once Serve answers for this server", () =>
Effect.gen(function* () {
expect(yield* resolveWithServe(200)).toEqual([
Expand Down Expand Up @@ -356,3 +412,83 @@ describe("DirectEndpoints Tailscale Serve", () => {
}),
);
});

effectIt.effect("advertises a bound IPv6 MagicDNS listener and drops a removed interface", () =>
Effect.gen(function* () {
using _interfaces = vi.spyOn(NodeOS, "networkInterfaces").mockReturnValue({
tailscale0: (INTERFACES.utun4 ?? []).flatMap((entry) =>
entry.family === "IPv6" ? [{ ...entry, address: "fd7a:115c:a1e0::1234" }] : [],
),
});
const service = yield* DirectEndpoints.make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (config) => ({
...config,
host: "fd7a:115c:a1e0::1234",
tailscaleServeEnabled: false,
})),
).pipe(Layer.provide(layerServeConfig("0.0.0.0"))),
layerTailscaleUp,
layerServeProbe(502),
),
),
);
expect(yield* service.resolve()).toEqual([
{ kind: "tailnet", httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/" },
]);
_interfaces.mockReturnValue({});
expect(yield* service.resolveAvailable()).toEqual([]);
}),
);

effectIt.effect("keeps completed Self discovery when HTTPS exceeds the shared deadline", () =>
Effect.gen(function* () {
using _interfaces = vi
.spyOn(NodeOS, "networkInterfaces")
.mockReturnValue({ tailscale0: vpnInterface("100.64.1.2") });
const statusStarted = yield* Deferred.make<void>();
const probeStarted = yield* Deferred.make<void>();
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe(
Effect.provide(layerTailscaleUp),
);
const service = yield* DirectEndpoints.make.pipe(
Effect.provide(
Layer.mergeAll(
layerServeConfig("0.0.0.0"),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Deferred.succeed(statusStarted, undefined).pipe(
Effect.andThen(Effect.sleep("1 second")),
Effect.andThen(spawner.spawn(command)),
),
),
),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() =>
Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)),
),
),
),
),
);
const resolving = yield* Effect.forkScoped(service.resolve());
yield* Deferred.await(statusStarted);
const numeric = [{ kind: "tailnet", httpBaseUrl: "http://100.64.1.2:3773/" }];
expect(yield* service.resolveAvailable()).toEqual(numeric);
yield* TestClock.adjust("1 second");
yield* Deferred.await(probeStarted);
expect(yield* service.resolveAvailable()).toEqual(numeric);
yield* TestClock.adjust("2 seconds");
const endpoints = yield* Fiber.join(resolving);
expect(endpoints).toEqual([
...numeric,
{ kind: "tailnet", httpBaseUrl: "http://bb-1.tail1234.ts.net:3773/" },
]);
expect(yield* service.resolveAvailable()).toEqual(endpoints);
}).pipe(Effect.scoped),
);
60 changes: 38 additions & 22 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ServerDirectEndpoint } from "@t3tools/contracts";
import {
buildTailscaleHttpsBaseUrl,
buildTailscaleDirectBaseUrl,
type TailscaleStatus,
probeTailscaleHttpsEndpoint,
readTailscaleStatus,
} from "@t3tools/tailscale";
Expand Down Expand Up @@ -130,45 +132,59 @@ export const make = Effect.gen(function* () {
: Effect.succeed(false);
yield* Effect.forkDetach(onCloudVm);

const cachedStatus = yield* Ref.make<TailscaleStatus | null>(null);
const servedEndpoint = yield* Ref.make<Option.Option<string>>(Option.none());
const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () {
const interfaces = NodeOS.networkInterfaces();
const endpoints = [
...resolveBoundEndpoints({
platform,
host: config.host,
port: config.port,
interfaces: NodeOS.networkInterfaces(),
interfaces,
onCloudVm: yield* onCloudVm,
}),
];
const servedUrl = yield* Ref.get(servedEndpoint);
if (Option.isSome(servedUrl)) endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
const directUrl = buildTailscaleDirectBaseUrl({
status: yield* Ref.get(cachedStatus),
port: config.port,
host: config.host,
localAddresses: Object.values(interfaces).flatMap((entries) =>
(entries ?? []).filter((entry) => !entry.internal).map((entry) => entry.address),
),
});
if (directUrl !== null) endpoints.push({ kind: "tailnet", httpBaseUrl: directUrl });
return endpoints;
});

const resolve = Effect.gen(function* () {
// Serve setup can fail without stopping startup; advertise only after it answers as this server.
if (config.tailscaleServeEnabled) {
const servedUrl = yield* readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.flatMap((magicDnsName) => {
if (magicDnsName === null) return Effect.succeed(null);
const httpBaseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName,
servePort: config.tailscaleServePort,
});
return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
);
if (isLoopbackHost(config.host) && !config.tailscaleServeEnabled)
return yield* resolveAvailable();
const observedStatus = yield* Ref.make<TailscaleStatus | null>(null);
const discovered = yield* readTailscaleStatus.pipe(
Effect.tap((status) => Ref.set(observedStatus, status)),
Effect.flatMap((status) =>
Effect.gen(function* () {
let servedUrl: string | null = null;
if (config.tailscaleServeEnabled && status.magicDnsName !== null) {
const url = buildTailscaleHttpsBaseUrl({
magicDnsName: status.magicDnsName,
servePort: config.tailscaleServePort,
});
if (yield* probeTailscaleHttpsEndpoint({ baseUrl: url })) servedUrl = url;
}
return servedUrl;
}),
Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
Effect.map(Option.flatMap(Option.fromNullishOr)),
Effect.orElseSucceed(() => Option.none<string>()),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
yield* Ref.set(servedEndpoint, servedUrl);
}
),
Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
Effect.orElseSucceed(() => Option.none()),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
yield* Ref.set(cachedStatus, yield* Ref.get(observedStatus));
yield* Ref.set(servedEndpoint, Option.flatMap(discovered, Option.fromNullishOr));
return yield* resolveAvailable();
});

Expand Down
Loading
Loading