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
3 changes: 3 additions & 0 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
Expand Down Expand Up @@ -111,6 +112,8 @@ function layer(input: {
Layer.provideMerge(NodeFileSystem.layer),
Layer.provideMerge(NodeHttpClient.layerUndici),
Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()),
// The macOS apps are read without the CLI these cases answer through.
Layer.provideMerge(Layer.succeed(HostProcess.Platform, "linux")),
Layer.provideMerge(layerNetwork),
Layer.provideMerge(DesktopConfig.layerTest(env)),
Layer.provideMerge(layerEnvironment),
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type DesktopServerExposureMode,
type DesktopServerExposureState,
} from "@t3tools/contracts";
import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale";
import { isTailscaleIpv4Address, readTailscaleStatusForDiscovery } from "@t3tools/tailscale";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
Expand Down Expand Up @@ -475,7 +475,7 @@ export const make = Effect.gen(function* () {
capacity: 1,
timeToLive: TAILSCALE_STATUS_CACHE_TTL,
lookup: (_addresses: string) =>
readTailscaleStatus.pipe(
readTailscaleStatusForDiscovery.pipe(
Effect.orElseSucceed(() => null),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
),
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { EnvironmentId } from "@t3tools/contracts";
import { it as effectIt } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import type * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -232,7 +233,7 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({
Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
});

const layerTailscaleUp = Layer.succeed(
const layerTailscaleCli = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
Expand All @@ -253,6 +254,12 @@ const layerTailscaleUp = Layer.succeed(
),
);

// The macOS apps are read without the CLI these cases answer through.
const layerTailscaleUp = Layer.merge(
layerTailscaleCli,
Layer.succeed(HostProcess.Platform, "linux"),
);

const T3_DESCRIPTOR_BODY = JSON.stringify({
environmentId: "serve-test",
label: "Serve test",
Expand Down
19 changes: 4 additions & 15 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ServerDirectEndpoint } from "@t3tools/contracts";
import {
buildTailscaleHttpsBaseUrl,
isTailscaleInterface,
probeTailscaleHttpsEndpoint,
readTailscaleStatus,
readTailscaleStatusForDiscovery,
} from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
Expand All @@ -16,11 +17,7 @@ import * as NodeNet from "node:net";
import * as NodeOS from "node:os";

import * as HostProcess from "@t3tools/shared/HostProcess";
import {
formatHostForUrl,
isTailnetHost,
isWildcardHost,
} from "@t3tools/shared/hostClassification";
import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification";

import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "./ServerEnvironment.ts";
Expand All @@ -43,14 +40,6 @@ export class DirectEndpoints extends Context.Service<
const isAdvertisableAddress = (address: string): boolean =>
NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address);

// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range.
const isTailscaleInterface = (
name: string,
entries: ReadonlyArray<NodeOS.NetworkInterfaceInfo>,
): boolean =>
/^tailscale/i.test(name) ||
entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address));

// Windows adapter names are mutable; Linux bridges can inherit these MACs.
const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i;

Expand Down Expand Up @@ -149,7 +138,7 @@ export const make = Effect.gen(function* () {
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(
const servedUrl = yield* readTailscaleStatusForDiscovery.pipe(
Effect.map((status) => status.magicDnsName),
Effect.flatMap((magicDnsName) => {
if (magicDnsName === null) return Effect.succeed(null);
Expand Down
46 changes: 46 additions & 0 deletions apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeModule from "node:module";
import { it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import { TailscaleHost } from "@t3tools/tailscale";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
Expand Down Expand Up @@ -78,11 +79,14 @@ const resolveTargets = (input: {
readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult };
readonly hostname: string;
readonly username?: string | null;
readonly platform?: NodeJS.Platform;
}) => {
const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) =>
service.resolveTargets(),
).pipe(
Effect.provideService(HostProcess.Hostname, input.hostname),
// The macOS apps are read without the CLI these cases answer through.
Effect.provideService(HostProcess.Platform, input.platform ?? "linux"),
Effect.provide(
RemoteOpenTargets.layer.pipe(
Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))),
Expand Down Expand Up @@ -188,6 +192,48 @@ describe("RemoteOpenTargets", () => {
}),
);

it.effect("advertises the tailnet address when the macOS app gives no name", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: true, ipv6: false },
tailscale: TAILSCALE_DOWN,
hostname: "bb-1",
username: "t3-user",
platform: "darwin",
}).pipe(
Effect.provideService(TailscaleHost, {
networkInterfaces: () => ({
utun4: [
{
address: "100.64.1.2",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.64.1.2/32",
},
{
address: "fd7a:115c:a1e0::1",
netmask: "ffff:ffff:ffff::",
family: "IPv6",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "fd7a:115c:a1e0::1/48",
scopeid: 0,
},
],
}),
tailscaledAnswers: Effect.succeed(false),
lookupMagicDnsName: () => Effect.succeed(null),
}),
);
expect(targets).toEqual([
{ kind: "tailscale", host: "100.64.1.2", username: "t3-user" },
{ kind: "mdns", host: "bb-1.local", username: "t3-user" },
]);
}),
);

it.effect("accepts an sshd bound to IPv6 loopback only", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
Expand Down
24 changes: 15 additions & 9 deletions apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { type RemoteOpenTarget } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
import {
readTailscaleSshEnabledForDiscovery,
readTailscaleStatusForDiscovery,
} from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -33,43 +36,46 @@ export const make = Effect.gen(function* () {
);
const username = yield* HostProcess.Username;

const tailscaleStatus = yield* readTailscaleStatus.pipe(
const tailscaleStatus = yield* readTailscaleStatusForDiscovery.pipe(
Effect.orElseSucceed(() => null),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const magicDnsName =
tailscaleStatus?.running === false ? null : (tailscaleStatus?.magicDnsName ?? null);
// With MagicDNS off this machine has no name to give, and its tailnet address still routes.
const tailnetHost =
tailscaleStatus?.running === false
? null
: (tailscaleStatus?.magicDnsName ?? tailscaleStatus?.tailnetIpv4Addresses[0] ?? null);

const targets: Array<RemoteOpenTarget> = [];

if (!sshdListening) {
// sshd may listen only on the tailnet address (ListenAddress), and Tailscale SSH
// needs no local sshd; tailscaled alone does not prove an SSH route.
if (magicDnsName === null) return targets;
if (tailnetHost === null) return targets;
const tailnetSshdListening = yield* Effect.forEach(
tailscaleStatus?.tailnetIpv4Addresses ?? [],
(address) => net.hasListenerOnHost(SSH_PORT, address),
).pipe(Effect.map((listening) => listening.includes(true)));
const sshRoute =
tailnetSshdListening ||
(yield* readTailscaleSshEnabled.pipe(
(yield* readTailscaleSshEnabledForDiscovery.pipe(
Effect.orElseSucceed(() => false),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
));
if (sshRoute) {
targets.push({
kind: "tailscale",
host: magicDnsName,
host: tailnetHost,
...(username === null ? {} : { username }),
});
}
return targets;
}

if (magicDnsName !== null) {
if (tailnetHost !== null) {
targets.push({
kind: "tailscale",
host: magicDnsName,
host: tailnetHost,
...(username === null ? {} : { username }),
});
}
Expand Down
Loading
Loading