Skip to content
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
23 changes: 23 additions & 0 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,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 @@ -6,6 +6,7 @@ import {
} from "@t3tools/contracts";
import {
buildTailscaleHttpsBaseUrl,
buildTailscaleDirectBaseUrl,
isTailscaleIpv4Address,
parseTailscaleStatus,
probeTailscaleHttpsEndpoint,
Expand Down Expand Up @@ -125,6 +126,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 @@ -157,9 +159,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],
});
},
);
87 changes: 82 additions & 5 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { EnvironmentId } from "@t3tools/contracts";
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 @@ -13,13 +14,15 @@ import * as HttpClient from "effect/http/HttpClient";
import type * as HttpClientRequest from "effect/http/HttpClientRequest";
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 * as ServerEnvironment from "./ServerEnvironment.ts";
import { resolveBoundEndpoints } from "./DirectEndpoints.ts";

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

const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
lo0: [
{
Expand Down Expand Up @@ -229,7 +232,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 @@ -273,14 +277,14 @@ const layerServeProbe = (status: number) =>
HttpClient.make((request) => Effect.succeed(serveProbeResponse(request, status))),
);

const layerServeConfig = (host = "127.0.0.1") =>
const layerServeConfig = (host = "127.0.0.1", tailscaleServeEnabled = true) =>
Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (config) => ({
...config,
host,
port: 3773,
tailscaleServeEnabled: true,
tailscaleServeEnabled,
tailscaleServePort: 443,
})),
).pipe(
Expand Down Expand Up @@ -375,3 +379,76 @@ 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(
layerServeConfig("fd7a:115c:a1e0::1234", false),
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);
}),
);
63 changes: 41 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,62 @@ 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, environmentId }).pipe(
Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
);
if (isLoopbackHost(config.host) && !config.tailscaleServeEnabled)
return yield* resolveAvailable();
// Kept outside the timeout so a slow HTTPS probe does not drop the direct route.
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* () {
// Serve setup can fail without stopping startup; advertise only after it answers as this server.
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, environmentId }))
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
65 changes: 65 additions & 0 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process";
import * as HostProcess from "@t3tools/shared/HostProcess";

import {
buildTailscaleDirectBaseUrl,
ensureTailscaleServe,
parseTailscaleExitNode,
parseTailscaleServeRootHolders,
Expand Down Expand Up @@ -287,11 +288,75 @@ describe("tailscale", () => {
running: null,
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.100.100.100"],
tailnetIpv6Addresses: ["fd7a:115c:a1e0::1"],
httpsServeEnabled: null,
});
}),
);

it("builds direct MagicDNS only for a running Self on the bound local interface", () => {
const localAddresses = ["100.74.126.34", "fd7a:115c:a1e0::1234", "fd7a:115c:a1e0::9999"];
for (const [host, running, selfIp, expected] of [
["0.0.0.0", true, "100.74.126.34", true],
["100.74.126.34", true, "100.74.126.34", true],
["::ffff:100.74.126.34", true, "100.74.126.34", true],
["::ffff:644a:7e22", true, "100.74.126.34", true],
["0:0:0:0:0:ffff:100.74.126.34", true, "100.74.126.34", true],
["fd7a:115c:a1e0::1234", true, "fd7a:115c:a1e0::1234", true],
["fd7a:115c:a1e0:0:0:0:0:1234", true, "fd7a:115c:a1e0::1234", true],
["fd7a:115c:a1e0::1234", true, "fd7a:115c:a1e0:0:0:0:0:1234", true],
["::", true, "fd7a:115c:a1e0::1234", true],
["::ffff:127.0.0.1", true, "100.74.126.34", false],
["fd7a:115c:a1e0::9999", true, "fd7a:115c:a1e0::1234", false],
["2001:db8::1", true, "fd7a:115c:a1e0::1234", false],
["0.0.0.0", true, "fd7a:115c:a1e0::1234", false],
[undefined, true, "100.74.126.34", false],
["127.0.0.1", true, "100.74.126.34", false],
["192.168.1.2", true, "100.74.126.34", false],
["0.0.0.0", false, "100.74.126.34", false],
["::1", true, "100.74.126.34", false],
["0.0.0.0", true, "100.85.0.1", false],
] as const) {
const url = buildTailscaleDirectBaseUrl({
host,
port: 3773,
localAddresses,
status: {
running,
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: selfIp.includes(":") ? [] : [selfIp],
tailnetIpv6Addresses: selfIp.includes(":") ? [selfIp] : [],
httpsServeEnabled: null,
},
});
assert.strictEqual(url, expected ? "http://desktop.tail.ts.net:3773/" : null);
}
const status = {
running: true,
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.74.126.34"],
tailnetIpv6Addresses: [],
httpsServeEnabled: null,
};
for (const unavailable of [
null,
{ ...status, magicDnsName: null },
{ ...status, magicDnsName: "evil.example/path" },
]) {
assert.isNull(
buildTailscaleDirectBaseUrl({
status: unavailable,
host: "0.0.0.0",
localAddresses,
port: 3773,
}),
);
}
assert.isNull(
buildTailscaleDirectBaseUrl({ status, host: "0.0.0.0", localAddresses: [], port: 3773 }),
);
});

it.effect("names the exit node in use", () =>
Effect.gen(function* () {
const inUse = yield* parseTailscaleExitNode(
Expand Down
Loading
Loading