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
57 changes: 57 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const minimalDesktopFlags = (baseDir: string) => ({
logWebSocketEvents: Option.none<boolean>(),
tailscaleServeEnabled: Option.none<boolean>(),
tailscaleServePort: Option.none<number>(),
advertisedUrls: [],
lan: Option.none<boolean>(),
});

Expand Down Expand Up @@ -232,6 +233,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none<boolean>(),
tailscaleServeEnabled: Option.none<boolean>(),
tailscaleServePort: Option.none<number>(),
advertisedUrls: [],
lan: Option.none<boolean>(),
};
const layerConfig = ConfigProvider.layer(
Expand Down Expand Up @@ -277,6 +279,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none<boolean>(),
tailscaleServeEnabled: Option.none<boolean>(),
tailscaleServePort: Option.none<number>(),
advertisedUrls: [],
lan: Option.none<boolean>(),
};
const layerConfig = ConfigProvider.layer(
Expand Down Expand Up @@ -333,6 +336,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.some(false),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.none(),
Expand Down Expand Up @@ -410,6 +414,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.some("Debug"),
Expand Down Expand Up @@ -558,6 +563,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.none(),
Expand Down Expand Up @@ -607,6 +613,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.none(),
Expand Down Expand Up @@ -648,6 +655,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.none(),
Expand Down Expand Up @@ -711,6 +719,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
Option.none(),
Expand Down Expand Up @@ -751,6 +760,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.none<boolean>(),
tailscaleServeEnabled: Option.none<boolean>(),
tailscaleServePort: Option.none<number>(),
advertisedUrls: [],
lan: Option.none<boolean>(),
});

Expand Down Expand Up @@ -791,6 +801,53 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
}),
);

it.effect("advertises only https origins and rejects anything else at startup", () =>
Effect.gen(function* () {
const baseDir = yield* FileSystem.FileSystem.pipe(
Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-advertise-url-" })),
);
const resolve = (env: Record<string, string>, flag: ReadonlyArray<string> = []) =>
resolveServerConfig({ ...minimalWebFlags(baseDir), advertisedUrls: flag }, Option.none(), {
selectAutomaticPort: () => Effect.succeed(3773),
}).pipe(
Effect.map((config) => config.advertisedUrls),
Effect.provide(
Layer.merge(NetService.layer, ConfigProvider.layer(ConfigProvider.fromEnv({ env }))),
),
);

expect(yield* resolve({})).toBeUndefined();
const env = {
T3CODE_ADVERTISE_URL:
" https://T3.Example.test , https://other.example.test:8443/,https://t3.example.test",
};
expect(yield* resolve(env)).toEqual([
"https://t3.example.test/",
"https://other.example.test:8443/",
]);
expect(yield* resolve(env, ["https://flag.example.test/"])).toEqual([
"https://flag.example.test/",
]);

for (const rejected of [
"http://t3.example.test",
"not a url",
// A proxy under a path prefix shares its origin with other apps.
"https://t3.example.test/app",
"https://t3.example.test?token=secret",
"https://user:secret@t3.example.test",
]) {
const error = String(
yield* resolve({ T3CODE_ADVERTISE_URL: `https://t3.example.test,${rejected}` }).pipe(
Effect.flip,
),
);
expect(error).toContain("An advertised address must be an https origin");
expect(error).not.toContain("secret");
}
}),
);

it.effect(
"resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order",
() =>
Expand Down
60 changes: 60 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ import * as ServerConfig from "../config.ts";
import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";

// Clients send credentials to these and keep only the origin, so a path would name another app.
const AdvertisedUrlsFromString = Schema.String.pipe(
Schema.decodeTo(
Schema.Array(Schema.String),
SchemaTransformation.transformEffect({
decode: (value) => {
const origins = new Set<string>();
for (const entry of value.split(",").map((part) => part.trim())) {
if (entry.length === 0) continue;
const url = URL.parse(entry);
if (
url?.protocol !== "https:" ||
url.pathname !== "/" ||
url.search !== "" ||
url.hash !== "" ||
url.username !== "" ||
url.password !== ""
) {
return Effect.fail(
new SchemaIssue.InvalidValue({
message:
"An advertised address must be an https origin with no path, query or credentials, such as https://t3.example.com.",
}),
);
}
origins.add(`${url.origin}/`);
}
return Effect.succeed<ReadonlyArray<string>>([...origins]);
},
encode: (urls) => Effect.succeed(urls.join(",")),
}),
),
);

const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe(
Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
Flag.optional,
Expand Down Expand Up @@ -85,6 +119,19 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
Flag.optional,
);
const decodeAdvertisedUrls = Schema.decodeEffect(AdvertisedUrlsFromString);
const advertiseUrlFlag = Flag.String("advertise-url").pipe(
Flag.mapEffect((value) =>
decodeAdvertisedUrls(value).pipe(
Effect.mapError((error) => new CliError.ValidationError({ message: error.message })),
),
),
Flag.withDescription(
"HTTPS address that clients and linked environments reach this server at, such as a reverse proxy in front of it. Repeat for several, most preferred first (equivalent to T3CODE_ADVERTISE_URL, comma-separated).",
),
Flag.atLeast(0),
Flag.map((urls): ReadonlyArray<string> => [...new Set(urls.flat())]),
);
const lanFlag = Flag.Boolean("lan").pipe(
Flag.withDescription(
"Accept connections from your local network and tailnet, not only this machine (equivalent to T3CODE_LAN).",
Expand Down Expand Up @@ -171,6 +218,10 @@ const EnvServerConfig = Config.all({
Config.option,
Config.map(Option.getOrUndefined),
),
advertisedUrls: Config.schema(AdvertisedUrlsFromString, "T3CODE_ADVERTISE_URL").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
lan: Config.Boolean("T3CODE_LAN").pipe(Config.option, Config.map(Option.getOrUndefined)),
});

Expand Down Expand Up @@ -207,6 +258,7 @@ export interface CliServerFlags {
readonly logWebSocketEvents: Option.Option<boolean>;
readonly tailscaleServeEnabled: Option.Option<boolean>;
readonly tailscaleServePort: Option.Option<number>;
readonly advertisedUrls: ReadonlyArray<string>;
readonly lan: Option.Option<boolean>;
}

Expand Down Expand Up @@ -242,6 +294,7 @@ export const sharedServerCommandFlags = {
logWebSocketEvents: logWebSocketEventsFlag,
tailscaleServeEnabled: tailscaleServeFlag,
tailscaleServePort: tailscaleServePortFlag,
advertisedUrls: advertiseUrlFlag,
lan: lanFlag,
} as const;

Expand Down Expand Up @@ -290,6 +343,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct
logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
advertisedUrls: flags.advertisedUrls ?? [],
lan: flags.lan ?? Option.none(),
} satisfies CliServerFlags;
const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
Expand Down Expand Up @@ -419,6 +473,10 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct
),
() => 443,
);
const advertisedUrls =
normalizedFlags.advertisedUrls.length > 0
? normalizedFlags.advertisedUrls
: (env.advertisedUrls ?? []);
const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir();
const explicitHost = resolveOptionPrecedence(
normalizedFlags.host,
Expand Down Expand Up @@ -525,6 +583,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct
logWebSocketEvents,
tailscaleServeEnabled,
tailscaleServePort,
...(advertisedUrls.length > 0 ? { advertisedUrls } : {}),
};

return config;
Expand All @@ -549,6 +608,7 @@ export const resolveCliAuthConfig = (
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
advertisedUrls: [],
lan: Option.none(),
},
cliLogLevel,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export class ServerConfig extends Context.Service<
readonly logWebSocketEvents: boolean;
readonly tailscaleServeEnabled: boolean;
readonly tailscaleServePort: number;
/** https origins that reach this server from outside, most preferred first. */
readonly advertisedUrls?: ReadonlyArray<string>;
}
>()("t3/config/ServerConfig") {
static readonly layerTest = (
Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ 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", advertisedUrls: ReadonlyArray<string> = []) =>
Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (config) => ({
Expand All @@ -282,6 +282,7 @@ const layerServeConfig = (host = "127.0.0.1") =>
port: 3773,
tailscaleServeEnabled: true,
tailscaleServePort: 443,
advertisedUrls,
})),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
Expand Down Expand Up @@ -375,3 +376,37 @@ describe("DirectEndpoints Tailscale Serve", () => {
}),
);
});

describe("DirectEndpoints advertised URLs", () => {
const resolveAdvertised = (host: string) =>
Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe(
Effect.provide(
DirectEndpoints.layer.pipe(
Layer.provide(
Layer.mergeAll(
layerServeConfig(host, ["https://t3.example.test/", "https://t3.internal.test/"]),
layerTailscaleUp,
layerServeProbe(200),
),
),
),
),
);

effectIt.effect("lists them first, in the order given, whatever address the server binds", () =>
Effect.gen(function* () {
const configured = [
{ kind: "configured", httpBaseUrl: "https://t3.example.test/" },
{ kind: "configured", httpBaseUrl: "https://t3.internal.test/" },
];
const served = { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" };
expect(yield* resolveAdvertised("192.168.1.10")).toEqual([
...configured,
{ kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
served,
]);
// A reverse proxy on this machine forwards to a loopback bind.
expect(yield* resolveAdvertised("127.0.0.1")).toEqual([...configured, served]);
}),
);
});
4 changes: 4 additions & 0 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ export const make = Effect.gen(function* () {
const servedEndpoint = yield* Ref.make<Option.Option<string>>(Option.none());
const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () {
const endpoints = [
...(config.advertisedUrls ?? []).map((httpBaseUrl) => ({
kind: "configured" as const,
httpBaseUrl,
})),
...resolveBoundEndpoints({
platform,
host: config.host,
Expand Down
Loading
Loading