Skip to content
Merged
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
6 changes: 2 additions & 4 deletions apps/mobile/src/features/cloud/cloud-drafts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentRegistry, connectionRoutes } from "@t3tools/client-runtime/connection";
import { EnvironmentRegistry, removedWithRelay } from "@t3tools/client-runtime/connection";
import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -28,9 +28,7 @@ export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntim
const environmentIds = new Set(
[...entries.values()]
// Only environments that sign-out removes entirely lose their drafts.
.filter((entry) =>
connectionRoutes(entry).every((route) => route.target._tag === "RelayConnectionTarget"),
)
.filter(removedWithRelay)
.map((entry) => entry.target.environmentId),
);
// Credentials are already revoked. A failed backup must leave the local
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
connectionRouteId,
connectionRouteLabel,
connectionRoutes,
isLearned,
} from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -141,7 +142,10 @@ export function EnvironmentRoutesSection({
}}
onStep={(direction) => move(index, direction === "up" ? index - 1 : index + 1)}
// The last route goes with the machine, which is "Remove" on the row.
onRemove={routes.length > 1 ? () => confirmRemove(route) : undefined}
// A learned route would be learned again, so it is only reordered.
onRemove={
routes.length > 1 && !isLearned(route) ? () => confirmRemove(route) : undefined
}
/>
);
})}
Expand Down Expand Up @@ -211,6 +215,7 @@ function RouteRow(props: {
accessibilityLabel={[
label,
address,
isLearned(route) ? "Found automatically" : null,
props.inUse ? "In use" : null,
`Route ${props.position} of ${props.count}`,
]
Expand All @@ -226,7 +231,7 @@ function RouteRow(props: {
</View>
{address !== null ? (
<Text numberOfLines={1} className="text-sm text-foreground-muted">
{address}
{isLearned(route) ? `${address} · found automatically` : address}
</Text>
) : null}
</View>
Expand Down
207 changes: 207 additions & 0 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it as effectIt } from "@effect/vitest";
import type * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import { describe, expect, it } from "vite-plus/test";

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

const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
lo0: [
{
address: "127.0.0.1",
netmask: "255.0.0.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: true,
cidr: "127.0.0.1/8",
},
],
en0: [
{
address: "192.168.1.10",
netmask: "255.255.255.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "192.168.1.10/24",
},
{
address: "fe80::1",
netmask: "ffff:ffff:ffff:ffff::",
family: "IPv6",
mac: "aa:bb:cc:dd:ee:ff",
internal: false,
cidr: "fe80::1/64",
scopeid: 4,
},
],
en1: [
{
address: "203.0.113.20",
netmask: "255.255.255.0",
family: "IPv4",
mac: "aa:bb:cc:dd:ee:00",
internal: false,
cidr: "203.0.113.20/24",
},
],
utun4: [
{
address: "100.101.102.103",
netmask: "255.255.255.255",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "100.101.102.103/32",
},
],
};

const virtualInterface = (address: string) => [
{
address,
netmask: "255.255.0.0",
family: "IPv4" as const,
mac: "02:42:ac:11:00:01",
internal: false,
cidr: `${address}/16`,
},
];

describe("resolveBoundEndpoints", () => {
it("lists nothing for a loopback-only server", () => {
expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual(
[],
);
expect(
resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }),
).toEqual([]);
});

it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => {
expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([
{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
]);
});

it("skips container and VM networks, which only this machine reaches", () => {
const interfaces = {
...INTERFACES,
docker0: virtualInterface("172.17.0.1"),
"br-3f2a1b": virtualInterface("172.18.0.1"),
virbr0: virtualInterface("192.168.122.1"),
"vEthernet (WSL)": virtualInterface("172.24.0.1"),
bridge100: virtualInterface("192.168.64.1"),
vmbr0: virtualInterface("192.168.1.20"),
};
expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([
{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
{ kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" },
]);
});

it("lists only the bound address for a specific bind", () => {
expect(
resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),
).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]);
});

it("never reports a host name, which can resolve to another machine per client", () => {
for (const host of ["server.local", "devbox", "devbox.home.arpa"]) {
expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]);
}
});

it("never reports a public address, which would carry the credential over plain HTTP", () => {
expect(
resolveBoundEndpoints({ host: "203.0.113.20", port: 3773, interfaces: INTERFACES }),
).toEqual([]);
});
});

const TAILSCALE_STATUS_JSON = JSON.stringify({
Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
});

/** `tailscale status --json` reporting a MagicDNS name. */
const tailscaleUpLayer = 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(new TextEncoder().encode(TAILSCALE_STATUS_JSON)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
),
);

/** Answers the Serve probe with `status`. */
const serveProbeLayer = (status: number) =>
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))),
),
);

/** A loopback-only server with Tailscale Serve on, so only the Serve name can be listed. */
const serveConfigLayer = Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (config) => ({
...config,
host: "127.0.0.1",
tailscaleServeEnabled: true,
tailscaleServePort: 443,
})),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
Layer.provide(NodeServices.layer),
);

const resolveWithServe = (probeStatus: number) =>
Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe(
Effect.provide(
DirectEndpoints.layer.pipe(
Layer.provide(
Layer.mergeAll(serveConfigLayer, tailscaleUpLayer, serveProbeLayer(probeStatus)),
),
),
),
);

describe("DirectEndpoints Tailscale Serve", () => {
effectIt.effect("lists the tailnet name once Serve answers for this server", () =>
Effect.gen(function* () {
expect(yield* resolveWithServe(200)).toEqual([
{ kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
]);
}),
);

effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () =>
Effect.gen(function* () {
expect(yield* resolveWithServe(502)).toEqual([]);
}),
);
});
Loading
Loading