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
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 { readTailscaleStatus } from "@t3tools/tailscale";
import { readTailscaleStatus, resolveTailscaleAdvertisedEndpoints } from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
Expand All @@ -23,7 +23,6 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
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);

Expand Down Expand Up @@ -539,6 +538,7 @@ export const make = Effect.gen(function* () {

const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
port: state.port,
source: "desktop-addon",
serveEnabled: state.tailscaleServeEnabled,
servePort: state.tailscaleServePort,
networkInterfaces: currentNetworkInterfaces,
Expand Down
129 changes: 129 additions & 0 deletions apps/server/src/remoteAccess/ServerAdvertisedEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { AdvertisedEndpoint } from "@t3tools/contracts";
import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint";
import { assert, describe, it } from "@effect/vitest";

import { resolveServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts";

const tailscaleProvider = {
id: "tailscale",
label: "Tailscale",
kind: "private-network",
isAddon: true,
} as const;

const tailscaleIpEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({
provider: tailscaleProvider,
source: "server",
id: "tailscale-ip:http://100.100.100.100:3773",
label: "Tailscale IP",
httpBaseUrl: "http://100.100.100.100:3773",
reachability: "private-network",
});

const tailscaleServeEndpoint: AdvertisedEndpoint = createAdvertisedEndpoint({
provider: tailscaleProvider,
source: "server",
id: "tailscale-magicdns:https://server.tail.ts.net/",
label: "Tailscale HTTPS",
httpBaseUrl: "https://server.tail.ts.net",
reachability: "private-network",
});

describe("resolveServerAdvertisedEndpoints", () => {
it("advertises only loopback (plus Tailscale Serve) under a loopback binding", () => {
const endpoints = resolveServerAdvertisedEndpoints({
port: 3773,
host: undefined,
networkInterfaces: {
en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }],
},
tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint],
});

// The LAN interface is not advertised: the server is not listening on it.
assert.deepEqual(
endpoints.map((endpoint) => endpoint.id),
["server-loopback:3773", tailscaleServeEndpoint.id],
);
const loopback = endpoints[0];
assert.equal(loopback?.httpBaseUrl, "http://127.0.0.1:3773/");
assert.equal(loopback?.wsBaseUrl, "ws://127.0.0.1:3773/");
assert.equal(loopback?.reachability, "loopback");
assert.equal(loopback?.source, "server");
assert.equal(loopback?.provider.id, "server-core");
});

it("enumerates listening interfaces under a wildcard binding", () => {
const endpoints = resolveServerAdvertisedEndpoints({
port: 8080,
host: "0.0.0.0",
networkInterfaces: {
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [
{ address: "192.168.1.44", family: "IPv4", internal: false },
{ address: "10.4.0.9", family: "IPv4", internal: false },
{ address: "fe80::1", family: "IPv6", internal: false },
],
en1: [
{ address: "169.254.10.2", family: "IPv4", internal: false },
{ address: "203.0.113.7", family: "IPv4", internal: false },
// Duplicated address is deduped.
{ address: "192.168.1.44", family: "IPv4", internal: false },
],
tailscale0: [{ address: "100.100.100.100", family: "IPv4", internal: false }],
docker0: [{ address: "127.0.0.5", family: "IPv4", internal: false }],
},
tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint],
});

assert.deepEqual(
endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]),
[
["server-loopback:8080", "loopback"],
["server-lan:http://192.168.1.44:8080", "lan"],
["server-lan:http://10.4.0.9:8080", "lan"],
["server-public:http://203.0.113.7:8080", "public"],
[tailscaleIpEndpoint.id, "private-network"],
[tailscaleServeEndpoint.id, "private-network"],
],
);
// Only the first LAN endpoint is the default.
assert.deepEqual(
endpoints.filter((endpoint) => endpoint.isDefault === true).map((endpoint) => endpoint.id),
["server-lan:http://192.168.1.44:8080"],
);
});

it("advertises only the bound host under a specific binding", () => {
const endpoints = resolveServerAdvertisedEndpoints({
port: 3773,
host: "192.168.1.44",
networkInterfaces: {
en0: [{ address: "192.168.1.44", family: "IPv4", internal: false }],
},
// Tailscale Serve proxies to 127.0.0.1, which is not listening here.
tailscaleEndpoints: [tailscaleIpEndpoint, tailscaleServeEndpoint],
});

assert.deepEqual(
endpoints.map((endpoint) => endpoint.id),
["server-lan:http://192.168.1.44:3773"],
);
assert.equal(endpoints[0]?.reachability, "lan");
assert.equal(endpoints[0]?.httpBaseUrl, "http://192.168.1.44:3773/");
});

it("classifies a bound Tailnet address as private-network", () => {
const endpoints = resolveServerAdvertisedEndpoints({
port: 3773,
host: "100.100.100.100",
networkInterfaces: {},
tailscaleEndpoints: [],
});

assert.deepEqual(
endpoints.map((endpoint) => [endpoint.id, endpoint.reachability]),
[["server-private-network:http://100.100.100.100:3773", "private-network"]],
);
});
});
213 changes: 213 additions & 0 deletions apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import * as NodeOS from "node:os";

import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint";
import {
isTailscaleIpv4Address,
resolveTailscaleAdvertisedEndpoints,
type TailscaleNetworkInterfaces,
} from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpServer } from "effect/unstable/http";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as ServerConfig from "../config.ts";
import {
formatHostForUrl,
isLoopbackHost,
isWildcardHost,
resolveListeningPort,
} from "../startupAccess.ts";

const SERVER_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
id: "server-core",
label: "Server",
kind: "core",
isAddon: false,
};

/** How long a resolved endpoint set is reused before `tailscale status` runs
* again. Repeated client polls must not re-spawn the Tailscale CLI. */
const ENDPOINT_CACHE_TTL = "60 seconds";

const isPrivateIpv4 = (address: string): boolean => {
const octets = address.split(".").map((part) => Number.parseInt(part, 10));
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false;
const [first, second] = octets as [number, number, number, number];
if (first === 10) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
return first === 192 && second === 168;
};

const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4;

/** Binding mode implied by the configured host, which decides which addresses
* actually accept connections. */
type BindingMode = "loopback" | "wildcard" | "specific";

const resolveBindingMode = (host: string | undefined): BindingMode => {
if (isWildcardHost(host)) return "wildcard";
// An unset host means the server binds 127.0.0.1 (see server.ts).
if (isLoopbackHost(host)) return "loopback";
return "specific";
};

/** Tailscale Serve proxies to 127.0.0.1, so its HTTPS endpoint stays reachable
* under a loopback binding; the plain Tailnet-IP HTTP endpoints do not. */
const isHttpsEndpoint = (endpoint: AdvertisedEndpoint): boolean =>
endpoint.httpBaseUrl.startsWith("https://");

export interface ResolveServerAdvertisedEndpointsInput {
readonly port: number;
readonly host: string | undefined;
readonly networkInterfaces: TailscaleNetworkInterfaces;
readonly tailscaleEndpoints: readonly AdvertisedEndpoint[];
}

/** Pure projection of the server's own reachable endpoints, given its binding,
* the host's network interfaces, and any Tailscale endpoints resolved
* separately. Exported for tests. */
export function resolveServerAdvertisedEndpoints(
input: ResolveServerAdvertisedEndpointsInput,
): readonly AdvertisedEndpoint[] {
const mode = resolveBindingMode(input.host);
const loopbackEndpoint = createAdvertisedEndpoint({
provider: SERVER_ENDPOINT_PROVIDER,
source: "server",
id: `server-loopback:${input.port}`,
label: "This machine",
httpBaseUrl: `http://127.0.0.1:${input.port}`,
Comment on lines +77 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium remoteAccess/ServerAdvertisedEndpoints.ts:77

resolveServerAdvertisedEndpoints always constructs the loopback endpoint as http://127.0.0.1:..., even when the server binds to an IPv6 loopback address like ::1 or [::1]. Consumers of the advertised endpoints receive an IPv4 URL that the server is not listening on, so the endpoint is unreachable. The function should use the actual configured loopback host (wrapped with formatHostForUrl) to build the loopback httpBaseUrl instead of hardcoding 127.0.0.1.

Suggested change
const loopbackEndpoint = createAdvertisedEndpoint({
provider: SERVER_ENDPOINT_PROVIDER,
source: "server",
id: `server-loopback:${input.port}`,
label: "This machine",
httpBaseUrl: `http://127.0.0.1:${input.port}`,
const loopbackEndpoint = createAdvertisedEndpoint({
provider: SERVER_ENDPOINT_PROVIDER,
source: "server",
id: `server-loopback:${input.port}`,
label: "This machine",
httpBaseUrl: `http://${formatHostForUrl(input.host ?? "127.0.0.1")}:${input.port}`,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts around lines 77-82:

`resolveServerAdvertisedEndpoints` always constructs the loopback endpoint as `http://127.0.0.1:...`, even when the server binds to an IPv6 loopback address like `::1` or `[::1]`. Consumers of the advertised endpoints receive an IPv4 URL that the server is not listening on, so the endpoint is unreachable. The function should use the actual configured loopback host (wrapped with `formatHostForUrl`) to build the loopback `httpBaseUrl` instead of hardcoding `127.0.0.1`.

reachability: "loopback",
status: "available",
description: "Loopback endpoint for this server.",
});

if (mode === "loopback") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium remoteAccess/ServerAdvertisedEndpoints.ts:88

In the loopback branch, every HTTPS Tailscale Serve endpoint is advertised on the assumption that Serve proxies to 127.0.0.1. When the server binds to an explicit loopback address like ::1 or 127.0.0.2, the listener is not reachable at 127.0.0.1, so the advertised HTTPS route points at a dead endpoint. Clients following it fail to connect, and direct promotion repeatedly probes an unreachable target. Consider filtering the Tailscale HTTPS endpoints to only those whose Serve target matches the actual bound loopback address, or document why this mismatch is acceptable if Serve is expected to bind 127.0.0.1 only.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/remoteAccess/ServerAdvertisedEndpoints.ts around line 88:

In the `loopback` branch, every HTTPS Tailscale Serve endpoint is advertised on the assumption that Serve proxies to `127.0.0.1`. When the server binds to an explicit loopback address like `::1` or `127.0.0.2`, the listener is not reachable at `127.0.0.1`, so the advertised HTTPS route points at a dead endpoint. Clients following it fail to connect, and direct promotion repeatedly probes an unreachable target. Consider filtering the Tailscale HTTPS endpoints to only those whose Serve target matches the actual bound loopback address, or document why this mismatch is acceptable if Serve is expected to bind `127.0.0.1` only.

return [loopbackEndpoint, ...input.tailscaleEndpoints.filter(isHttpsEndpoint)];
}

if (mode === "specific") {
// A specific non-loopback binding is not listening on 127.0.0.1, so neither
// the loopback endpoint nor Tailscale Serve (which proxies there) applies.
// A bound Tailnet IP is covered by the record below.
const rawHost = input.host ?? "";
const host = formatHostForUrl(rawHost);
const { reachability, label } = isTailscaleIpv4Address(rawHost)
? ({ reachability: "private-network", label: "Private network" } as const)
: isPrivateIpv4(rawHost)
? ({ reachability: "lan", label: "Local network" } as const)
: ({ reachability: "public", label: "Public IP" } as const);
const httpBaseUrl = `http://${host}:${input.port}`;
return [
createAdvertisedEndpoint({
provider: SERVER_ENDPOINT_PROVIDER,
source: "server",
id: `server-${reachability}:${httpBaseUrl}`,
label,
httpBaseUrl,
reachability,
status: "available",
}),
];
}

const endpoints: AdvertisedEndpoint[] = [loopbackEndpoint];
const seen = new Set<string>();
let hasDefaultLan = false;

for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
if (!interfaceAddresses) continue;

for (const entry of interfaceAddresses) {
if (entry.internal) continue;
if (!isIpv4Family(entry.family)) continue;
const address = entry.address;
if (address.startsWith("127.") || address.startsWith("169.254.")) continue;
// Tailnet addresses are advertised by the Tailscale provider instead.
if (isTailscaleIpv4Address(address)) continue;
if (seen.has(address)) continue;
seen.add(address);

const isLan = isPrivateIpv4(address);
const httpBaseUrl = `http://${address}:${input.port}`;
const isDefault = isLan && !hasDefaultLan;
if (isDefault) hasDefaultLan = true;

endpoints.push(
createAdvertisedEndpoint({
provider: SERVER_ENDPOINT_PROVIDER,
source: "server",
id: `server-${isLan ? "lan" : "public"}:${httpBaseUrl}`,
label: isLan ? "Local network" : "Public IP",
httpBaseUrl,
reachability: isLan ? "lan" : "public",
status: "available",
...(isDefault ? { isDefault: true } : {}),
}),
);
}
}

return [...endpoints, ...input.tailscaleEndpoints];
}

export class ServerAdvertisedEndpoints extends Context.Service<
ServerAdvertisedEndpoints,
{
readonly getEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
}
>()("t3/remoteAccess/ServerAdvertisedEndpoints") {}

export const make = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const httpServer = yield* HttpServer.HttpServer;
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;

const readNetworkInterfaces = Effect.sync(() => NodeOS.networkInterfaces()).pipe(
Effect.map((interfaces): TailscaleNetworkInterfaces => interfaces),
);

const resolveEndpoints = Effect.gen(function* () {
const port = resolveListeningPort(httpServer.address, config.port);
const networkInterfaces = yield* readNetworkInterfaces;
const mode = resolveBindingMode(config.host);

// Skip the Tailscale resolver when nothing Tailscale-reachable could exist:
// spawning the CLI triggers a macOS TCC prompt on sandboxed installs.
const shouldResolveTailscale =
mode === "wildcard" || (mode === "loopback" && config.tailscaleServeEnabled);
const tailscaleEndpoints = shouldResolveTailscale
? yield* resolveTailscaleAdvertisedEndpoints({
port,
source: "server",
serveEnabled: config.tailscaleServeEnabled,
servePort: config.tailscaleServePort,
networkInterfaces,
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
)
: [];

return resolveServerAdvertisedEndpoints({
port,
host: config.host,
networkInterfaces,
tailscaleEndpoints,
});
});

const getEndpoints = yield* Effect.cachedWithTTL(resolveEndpoints, ENDPOINT_CACHE_TTL);

return ServerAdvertisedEndpoints.of({
getEndpoints: getEndpoints.pipe(
Effect.withSpan("server.remoteAccess.resolveAdvertisedEndpoints"),
),
});
});

export const layer = Layer.effect(ServerAdvertisedEndpoints, make);
25 changes: 25 additions & 0 deletions apps/server/src/remoteAccess/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AuthRelayReadScope, EnvironmentHttpApi } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";

import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts";
import { ServerAdvertisedEndpoints } from "./ServerAdvertisedEndpoints.ts";

// `relay:read` is part of the standard client scope set, so an ordinary paired
// or relay-tunneled client can discover the direct endpoints it might promote
// to. `access:read` would restrict this to admin clients.
export const remoteAccessHttpApiLayer = HttpApiBuilder.group(
EnvironmentHttpApi,
"remoteAccess",
Effect.fnUntraced(function* (handlers) {
const advertisedEndpoints = yield* ServerAdvertisedEndpoints;
return handlers.handle(
"advertisedEndpoints",
Effect.fn("environment.remoteAccess.advertisedEndpoints")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthRelayReadScope);
return yield* advertisedEndpoints.getEndpoints;
}),
);
}),
);
Loading
Loading