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
16 changes: 13 additions & 3 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab

import * as ServerSettings from "../serverSettings.ts";
import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts";

import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
Expand Down Expand Up @@ -1032,11 +1033,17 @@ export const make = Effect.gen(function* () {
};
const probeContext =
yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
const localTargetContext =
yield* Effect.context<Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>();
const service = yield* makeWithHosts(
hosts,
(host) =>
SshDeviceHost.probe(host).pipe(
Effect.provide(probeContext),
Effect.gen(function* () {
if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) {
return yield* localHost.summary;
}
return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext));
}).pipe(
Effect.mapError(
(error) =>
new DeviceOperationError({
Expand All @@ -1051,8 +1058,11 @@ export const make = Effect.gen(function* () {
const hostContext =
yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.make>>>();
const configured = new Map<string, { config: SshDeviceHostConfig; scope: Scope.Closeable }>();
const reconcile = (next: ReadonlyArray<SshDeviceHostConfig>) =>
const reconcile = (configuredHosts: ReadonlyArray<SshDeviceHostConfig>) =>
Effect.gen(function* () {
const next = yield* remoteSshDeviceHosts(configuredHosts).pipe(
Effect.provide(localTargetContext),
);
const removed = yield* service.withLifecycleLock(
Effect.gen(function* () {
const removed: Array<{ id: string; scope: Scope.Closeable }> = [];
Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/device/localSshDeviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import {
isLocalSshDeviceHost,
LocalDeviceHostAddresses,
remoteSshDeviceHosts,
} from "./localSshDeviceHost.ts";

const host = (target: string, port?: number) => ({
id: target,
label: target,
target,
...(port ? { port } : {}),
});
const spawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
// Any attempt to actually connect fails this test.
expect(command.args).toContain("-G");
const target = command.args.at(-1);
const configs: Record<string, string> = {
"mac-mini": "hostname 100.65.180.100\nport 22\n",
remote: "hostname 192.0.2.1\nport 22\n",
loopback: "hostname 127.0.1.1\nport 22\n",
ipv6: "hostname ::1\nport 22\n",
forwarded: "hostname 127.0.0.1\nport 2222\n",
proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n",
command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n",
unresolved: "hostname example.invalid\nport 22\n",
};
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123),
stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")),
stderr: Stream.empty,
all: Stream.empty,
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: Sink.drain,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
});
}),
);
const provide = <A, E>(
effect: Effect.Effect<A, E, Effect.Services<ReturnType<typeof isLocalSshDeviceHost>>>,
) =>
effect.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])),
Effect.provide(NodeServices.layer),
);

it.effect("skips SSH aliases resolving to this machine, including loopback", () =>
provide(
Effect.gen(function* () {
for (const target of ["mac-mini", "loopback", "ipv6"]) {
expect(yield* isLocalSshDeviceHost(host(target))).toBe(true);
}
}),
),
);

it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () =>
provide(
Effect.gen(function* () {
for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) {
expect(yield* isLocalSshDeviceHost(host(target))).toBe(false);
}
}),
),
);

it.effect("removes only self targets from a fanned-out host list", () =>
provide(
Effect.gen(function* () {
expect(
yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]),
).toEqual([host("remote"), host("forwarded")]);
}),
),
);
71 changes: 71 additions & 0 deletions apps/server/src/device/localSshDeviceHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as NodeDnsPromises from "node:dns/promises";
import * as NodeNet from "node:net";
import type { SshDeviceHostConfig } from "@t3tools/contracts";
import * as NodeOS from "node:os";
import * as Context from "effect/Context";
import { runSshCommand } from "@t3tools/ssh/command";
import * as Effect from "effect/Effect";

export const LocalDeviceHostAddresses = Context.Reference<ReadonlySet<string>>(
"LocalDeviceHostAddresses",
{
defaultValue: () =>
new Set(
Object.values(NodeOS.networkInterfaces()).flatMap(
(entries) => entries?.map((entry) => entry.address) ?? [],
),
),
},
);

/** Resolve aliases on the owning environment without opening an SSH connection. */
export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* (
host: SshDeviceHostConfig,
) {
const result = yield* runSshCommand(
{ alias: host.target, hostname: host.target, username: null, port: host.port ?? null },
{
preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])],
timeoutMs: 5000,
},
).pipe(Effect.result);
if (result._tag === "Failure") return false;
const config = new Map(
result.success.stdout.split("\n").map((line) => {
const separator = line.indexOf(" ");
return [line.slice(0, separator), line.slice(separator + 1).trim()];
}),
);
// A local forwarded port or a proxy can lead to a different machine.
if (
config.get("port") !== "22" ||
["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none")
)
return false;
const hostname = config.get("hostname")?.replace(/^\[|\]$/g, "");
if (!hostname) return false;
const addresses = NodeNet.isIP(hostname)
? [hostname]
: yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe(
Effect.map((entries) => entries.map((entry) => entry.address)),
Effect.timeout("2 seconds"),
Effect.orElseSucceed(() => [] as string[]),
);
const localAddresses = yield* LocalDeviceHostAddresses;
return (
addresses.length > 0 &&
addresses.every(
(address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."),
)
);
});

export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* (
hosts: ReadonlyArray<SshDeviceHostConfig>,
) {
return yield* Effect.filter(
hosts,
(host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)),
{ concurrency: 4 },
);
});
18 changes: 15 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import * as TerminalManager from "./terminal/Manager.ts";
import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import * as DeviceService from "./device/DeviceService.ts";
import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
import * as PreviewManager from "./preview/Manager.ts";
import { issueAssetUrl } from "./assets/AssetAccess.ts";
import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
Expand Down Expand Up @@ -558,6 +559,8 @@ const makeWsRpcLayer = (
const terminalManager = yield* TerminalManager.TerminalManager;
const previewManager = yield* PreviewManager.PreviewManager;
const deviceService = yield* DeviceService.DeviceService;
const deviceHostContext =
yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();
const portDiscovery = yield* PortScanner.PortDiscovery;
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
const providerService = yield* ProviderService.ProviderService;
Expand Down Expand Up @@ -2849,9 +2852,18 @@ const makeWsRpcLayer = (
serverSettings.getSettings.pipe(
Effect.map((current) => providerSettingsMutationInstanceIds(current, patch)),
),
serverSettings
.updateSettings(patch)
.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
Effect.gen(function* () {
const deviceHosts = patch.deviceHosts
? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe(
Effect.provide(deviceHostContext),
)
: undefined;
const settings = yield* serverSettings.updateSettings({
...patch,
...(deviceHosts ? { deviceHosts } : {}),
});
return ServerSettings.redactServerSettingsForClient(settings);
}),
),
{
"rpc.aggregate": "server",
Expand Down
81 changes: 44 additions & 37 deletions apps/web/src/components/settings/DeviceHostsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SshDeviceHostConfig,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
import { randomUUID } from "../../lib/utils";
import { useState } from "react";
import { deviceEnvironment, useDeviceState } from "../../state/device";
Expand All @@ -20,6 +21,9 @@ import { Input } from "../ui/input";
import { MoreVertical, PlusIcon } from "lucide-react";
import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
import { SettingsRow } from "./settingsLayout";
import { useSettingsScope } from "./SettingsScopeContext";
import { useHostConnectionChecks } from "./useHostConnectionChecks";
import { deviceHostChecksKey, parseDeviceHostDraft } from "./deviceHostConnectionChecks";

/** Host names and identity paths belong to the selected environment, never all environments. */
export function DeviceHostsSettings(props: {
Expand All @@ -30,11 +34,19 @@ export function DeviceHostsSettings(props: {
const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
const retry = useAtomCommand(deviceEnvironment.list);
const { state } = useDeviceState(props.environmentId);
const { environments } = useSettingsScope();
const targets = environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
connected: environment.connection.phase === "connected",
}));
const { checks: environmentChecks, testConnection: testAcrossEnvironments } =
useHostConnectionChecks(targets);
const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
const parsedEditing = editing ? parseDeviceHostDraft(editing) : Option.none();
const validEditing = Option.isSome(parsedEditing) && editing?.label.trim() !== "";
const [busy, setBusy] = useState(false);
const [retrying, setRetrying] = useState<string | null>(null);
const validPort = (port: number | undefined) =>
port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
const [checks, setChecks] = useState<
Record<
string,
Expand Down Expand Up @@ -250,7 +262,12 @@ export function DeviceHostsSettings(props: {
className="space-y-3 border-t border-border/50 py-3"
onSubmit={(event) => {
event.preventDefault();
void save([...props.hosts.filter((host) => host.id !== editing.id), editing]);
if (validEditing && Option.isSome(parsedEditing)) {
void save([
...props.hosts.filter((host) => host.id !== editing.id),
parsedEditing.value,
]);
}
}}
>
<label className="block space-y-1 text-sm">
Expand Down Expand Up @@ -305,29 +322,18 @@ export function DeviceHostsSettings(props: {
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="submit"
disabled={
busy ||
!editing.label.trim() ||
!editing.target.trim() ||
!validPort(editing.port)
}
>
<Button size="sm" type="submit" disabled={busy || !validEditing}>
Save host
</Button>
<Button
size="sm"
type="button"
variant="outline"
disabled={
busy ||
!editing.label.trim() ||
!editing.target.trim() ||
!validPort(editing.port)
}
onClick={() => void testConnection(editing)}
disabled={busy || !validEditing}
onClick={() => {
if (Option.isSome(parsedEditing))
void testAcrossEnvironments(parsedEditing.value);
}}
>
Test connection
</Button>
Expand All @@ -343,23 +349,24 @@ export function DeviceHostsSettings(props: {
Cancel
</Button>
</div>
{checks[editing.id]?.pending ? (
<span
role="status"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Spinner className="size-3" />
Checking connection…
</span>
) : null}
{checks[editing.id]?.platforms ? (
<DeviceHostAvailability platforms={checks[editing.id]?.platforms ?? []} />
) : null}
{checks[editing.id]?.error ? (
<p role="alert" className="text-xs text-destructive">
{checks[editing.id]?.error}
</p>
) : null}
{targets.map((target) => {
const result =
environmentChecks[deviceHostChecksKey(editing, targets)]?.[
target.environmentId
];
if (!result) return null;
return (
<div key={target.environmentId} className="text-xs" role="status">
<span className="font-medium">{target.label}: </span>
{result.status === "pending" ? "Checking…" : null}
{result.status === "local" ? "Already available locally" : null}
{result.status === "failed" ? result.error : null}
{result.status === "connected" ? (
<DeviceHostAvailability platforms={result.platforms} />
) : null}
</div>
);
})}
</form>
) : null}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ vi.mock("./SettingsScopeContext", () => ({
}
: null,
connectedEnvironments: selectedDeviceEnvironment.aggregate ? [{}, {}] : [],
environments: selectedDeviceEnvironment.aggregate
? [
{ environmentId: "remote", label: "Selected remote", connection: { phase: "connected" } },
{ environmentId: "other", label: "Other", connection: { phase: "connected" } },
]
: [],
targets: [],
}),
useOptionalSettingsScope: () => null,
Expand Down
Loading
Loading