Skip to content

Commit a6ca1f4

Browse files
t3dotggclaude
andcommitted
fix(tailscale): t3 up keeps Tailscale when it is down, stops Serve with it, and saves a requested owner
Also takes the tailnet sign-in services from the Effect environment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent ee1a0b6 commit a6ca1f4

4 files changed

Lines changed: 118 additions & 85 deletions

File tree

‎apps/server/src/cli/up.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import * as TestConsole from "effect/testing/TestConsole";
1414
import { Command } from "effect/cli";
1515
import { afterEach, vi } from "vite-plus/test";
1616

17+
// The tests must not depend on Tailscale running on the machine that runs them.
18+
vi.mock("@t3tools/tailscale", async (importOriginal) => ({
19+
...(await importOriginal<typeof import("@t3tools/tailscale")>()),
20+
readTailscaleStatus: () => Effect.fail("Tailscale is not running in tests"),
21+
}));
22+
1723
import packageJson from "../../package.json" with { type: "json" };
1824
import * as BootService from "../cloud/bootService.ts";
1925
import { upsertServiceEnv } from "./service.ts";
@@ -262,6 +268,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) =>
262268
restarts: 0,
263269
envAfter: "T3CODE_LAN=true\n",
264270
},
271+
{
272+
label: "Tailscale down here keeps its settings while another route changes",
273+
installed: true,
274+
env: "T3CODE_LAN=true\nT3CODE_TAILSCALE_SIGN_IN=true\n",
275+
args: ["--no-lan"],
276+
installs: 0,
277+
restarts: 1,
278+
envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\n",
279+
},
280+
{
281+
label: "turning Tailscale off stops Serve too",
282+
installed: true,
283+
env: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=true\n",
284+
args: ["--no-tailscale"],
285+
installs: 0,
286+
restarts: 1,
287+
envAfter: "T3CODE_LAN=false\n",
288+
},
265289
{
266290
label: "the old default is kept",
267291
installed: true,

‎apps/server/src/cli/up.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) =>
254254
]),
255255
].join("\n");
256256

257+
// Serve only runs with Tailscale on in `t3 up`, so turning Tailscale off stops it too.
258+
const TAILSCALE_OFF = { T3CODE_TAILSCALE_SIGN_IN: undefined, T3CODE_TAILSCALE_SERVE: undefined };
259+
257260
const interactiveTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
258261

259262
interface Owner {
@@ -410,14 +413,16 @@ const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* (
410413
const { entries, path: serviceEnvPath } = yield* CliService.readServiceEnv(config.baseDir);
411414
const tagged = tailscale.status.selfTags.length > 0;
412415
// With grants required, the policy names who gets in, so the box needs no owner.
413-
const owner = requireGrant
414-
? null
415-
: yield* resolveOwner(
416-
tailscale.status,
417-
flags.owner,
418-
entries.T3CODE_TAILSCALE_OWNER,
419-
!flags.yes && interactiveTerminal(),
420-
);
416+
// A requested owner is saved even when grants decide who gets in.
417+
const owner =
418+
requireGrant && Option.isNone(flags.owner)
419+
? null
420+
: yield* resolveOwner(
421+
tailscale.status,
422+
flags.owner,
423+
entries.T3CODE_TAILSCALE_OWNER,
424+
!flags.yes && interactiveTerminal(),
425+
);
421426
const configuredPort = entries.T3CODE_PORT;
422427
const port = Number(configuredPort ?? ServerConfig.DEFAULT_PORT);
423428
const net = yield* NetService.NetService;
@@ -586,13 +591,18 @@ const setUp = Effect.fn("cli.up")(function* (
586591
}
587592
yield* CliService.writeServiceEnv(config.baseDir, {
588593
...(current.lan ? { T3CODE_LAN: "false" } : {}),
589-
...(current.tailscale ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {}),
594+
...(current.tailscale ? TAILSCALE_OFF : {}),
590595
});
591596
return yield* Console.log(formatNextSteps(routes, false));
592597
}
593-
if (routes.tailscale && !tailscale.ok) {
598+
// Tailscale left on while it is down here keeps its settings; turning it on needs it.
599+
const tailscaleKept = routes.tailscale && current.tailscale && Option.isNone(flags.tailscale);
600+
if (routes.tailscale && !tailscale.ok && !tailscaleKept) {
594601
return yield* upError(`${tailscale.reason} Then run t3 up again.`);
595602
}
603+
if (tailscaleKept && !tailscale.ok && !(plan.ask.length > 0 && interactive)) {
604+
yield* Console.log(`${tailscale.reason} Tailscale settings stay as they are.`);
605+
}
596606

597607
const requireGrant = flags.requireGrant || before.requireGrant;
598608
const signIn =
@@ -601,7 +611,7 @@ const setUp = Effect.fn("cli.up")(function* (
601611
: null;
602612
const writes: Readonly<Record<string, string | undefined>> = {
603613
T3CODE_LAN: String(routes.lan),
604-
...(signIn?.entries ?? (current.tailscale ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {})),
614+
...(routes.tailscale ? (signIn?.entries ?? {}) : current.tailscale ? TAILSCALE_OFF : {}),
605615
};
606616
const planned = Object.fromEntries(
607617
Object.entries({ ...before.entries, ...writes }).flatMap(([key, value]) =>

‎packages/client-runtime/src/authorization/service.ts‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
mapRemoteEnvironmentError,
1919
} from "../connection/errors.ts";
2020
import type { TailnetConnectionCredential } from "../connection/catalog.ts";
21-
import * as ConnectionCredentialStore from "../connection/credentialStore.ts";
2221
import {
2322
ConnectionBlockedError,
2423
ConnectionTransientError,
@@ -141,12 +140,8 @@ export const make = Effect.gen(function* () {
141140
const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity;
142141
const presentation = yield* ClientCapabilities.ClientPresentation;
143142
const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
144-
const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
145143
const httpClient = yield* HttpClient.HttpClient;
146-
const tailnet = makeTailnetSessions({
147-
credentials,
148-
signer,
149-
httpClient,
144+
const tailnet = yield* makeTailnetSessions({
150145
clientMetadata: presentation.metadata,
151146
scope: serviceScope,
152147
});
@@ -607,9 +602,11 @@ export const make = Effect.gen(function* () {
607602
signInTailnet: Effect.fn("clientRuntime.connection.remote.signInTailnet")(function* (input) {
608603
return yield* signInOverTailnet({
609604
httpBaseUrl: input.httpBaseUrl,
610-
signer,
611605
clientMetadata: presentation.metadata,
612-
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
606+
}).pipe(
607+
Effect.provideService(ManagedRelay.ManagedRelayDpopSigner, signer),
608+
Effect.provideService(HttpClient.HttpClient, httpClient),
609+
);
613610
}),
614611
authorizeTailnet,
615612
authorizeTailnetHttp: Effect.fn("clientRuntime.connection.remote.authorizeTailnetHttp")(

‎packages/client-runtime/src/authorization/tailnet.ts‎

Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type * as Scope from "effect/Scope";
88
import * as HttpClient from "effect/http/HttpClient";
99

1010
import { TailnetConnectionCredential } from "../connection/catalog.ts";
11-
import type * as ConnectionCredentialStore from "../connection/credentialStore.ts";
11+
import * as ConnectionCredentialStore from "../connection/credentialStore.ts";
1212
import { credentialMissingError, mapRemoteEnvironmentError } from "../connection/errors.ts";
1313
import {
1414
ConnectionBlockedError,
@@ -17,7 +17,7 @@ import {
1717
} from "../connection/model.ts";
1818
import { tailnetRefusalError } from "../connection/tailnet.ts";
1919
import { environmentEndpointUrl } from "../environment/endpoint.ts";
20-
import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
20+
import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
2121
import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts";
2222
import { exchangeRemoteDpopAccessToken } from "./remote.ts";
2323

@@ -39,12 +39,12 @@ const proofError = () =>
3939
export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOverTailnet")(
4040
function* (input: {
4141
readonly httpBaseUrl: string;
42-
readonly signer: ManagedRelayDpopSigner["Service"];
4342
readonly clientMetadata?: AuthClientPresentationMetadata;
4443
}) {
45-
const thumbprint = yield* input.signer.thumbprint.pipe(Effect.mapError(proofError));
44+
const signer = yield* ManagedRelayDpopSigner;
45+
const thumbprint = yield* signer.thumbprint.pipe(Effect.mapError(proofError));
4646
const signInUrl = environmentEndpointUrl(input.httpBaseUrl, "/api/auth/tailnet");
47-
const signInProof = yield* input.signer
47+
const signInProof = yield* signer
4848
.createProof({ method: "POST", url: signInUrl })
4949
.pipe(Effect.mapError(proofError));
5050
const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "tailnet");
@@ -67,7 +67,7 @@ export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOv
6767
answer.refused.suggestedGrant,
6868
);
6969
}
70-
const tokenProof = yield* input.signer
70+
const tokenProof = yield* signer
7171
.createProof({
7272
method: "POST",
7373
url: environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"),
@@ -94,67 +94,69 @@ export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOv
9494
* again at its pinned address, before expiry or after a rejection, so every
9595
* host check runs again. Concurrent callers share one sign-in.
9696
*/
97-
export const makeTailnetSessions = (input: {
98-
readonly credentials: ConnectionCredentialStore.ConnectionCredentialStore["Service"];
99-
readonly signer: ManagedRelayDpopSigner["Service"];
100-
readonly httpClient: HttpClient.HttpClient;
101-
readonly clientMetadata: AuthClientPresentationMetadata;
102-
readonly scope: Scope.Scope;
103-
}) => {
104-
const renewals = new Map<
105-
string,
106-
{
107-
readonly id: object;
108-
readonly fiber: Fiber.Fiber<TailnetConnectionCredential, ConnectionAttemptError>;
109-
}
110-
>();
97+
export const makeTailnetSessions = Effect.fn("clientRuntime.authorization.makeTailnetSessions")(
98+
function* (input: {
99+
readonly clientMetadata: AuthClientPresentationMetadata;
100+
readonly scope: Scope.Scope;
101+
}) {
102+
const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
103+
const signer = yield* ManagedRelayDpopSigner;
104+
const httpClient = yield* HttpClient.HttpClient;
105+
const renewals = new Map<
106+
string,
107+
{
108+
readonly id: object;
109+
readonly fiber: Fiber.Fiber<TailnetConnectionCredential, ConnectionAttemptError>;
110+
}
111+
>();
111112

112-
const renew = Effect.fn("clientRuntime.authorization.tailnet.renew")(function* (
113-
credentialId: string,
114-
current: TailnetConnectionCredential,
115-
) {
116-
const pending = renewals.get(credentialId);
117-
if (pending !== undefined) return yield* Fiber.join(pending.fiber);
118-
const id = {};
119-
const fiber = yield* signInOverTailnet({
120-
httpBaseUrl: current.signInBaseUrl,
121-
signer: input.signer,
122-
clientMetadata: input.clientMetadata,
123-
}).pipe(
124-
Effect.tap((fresh) => input.credentials.put(credentialId, fresh)),
125-
Effect.provideService(HttpClient.HttpClient, input.httpClient),
126-
Effect.ensuring(
127-
Effect.sync(() => {
128-
if (renewals.get(credentialId)?.id === id) renewals.delete(credentialId);
129-
}),
130-
),
131-
// One caller's interruption must not cancel the renewal others wait on.
132-
Effect.forkIn(input.scope),
133-
);
134-
renewals.set(credentialId, { id, fiber });
135-
return yield* Fiber.join(fiber);
136-
});
113+
const renew = Effect.fn("clientRuntime.authorization.tailnet.renew")(function* (
114+
credentialId: string,
115+
current: TailnetConnectionCredential,
116+
) {
117+
const pending = renewals.get(credentialId);
118+
if (pending !== undefined) return yield* Fiber.join(pending.fiber);
119+
const id = {};
120+
const fiber = yield* signInOverTailnet({
121+
httpBaseUrl: current.signInBaseUrl,
122+
clientMetadata: input.clientMetadata,
123+
}).pipe(
124+
Effect.tap((fresh) => credentials.put(credentialId, fresh)),
125+
Effect.provideService(ManagedRelayDpopSigner, signer),
126+
Effect.provideService(HttpClient.HttpClient, httpClient),
127+
Effect.ensuring(
128+
Effect.sync(() => {
129+
if (renewals.get(credentialId)?.id === id) renewals.delete(credentialId);
130+
}),
131+
),
132+
// One caller's interruption must not cancel the renewal others wait on.
133+
Effect.forkIn(input.scope),
134+
);
135+
renewals.set(credentialId, { id, fiber });
136+
return yield* Fiber.join(fiber);
137+
});
137138

138-
/** The stored session, or a fresh one when it is missing a key match, near expiry, or rejected. */
139-
const session = Effect.fn("clientRuntime.authorization.tailnet.session")(function* (
140-
credentialId: string,
141-
rejectedAccessToken?: string,
142-
) {
143-
const stored = Option.getOrUndefined(yield* input.credentials.get(credentialId));
144-
if (stored === undefined || !isTailnetCredential(stored)) {
145-
return yield* credentialMissingError(credentialId);
146-
}
147-
const thumbprint = yield* input.signer.thumbprint.pipe(Effect.mapError(proofError));
148-
const now = yield* Clock.currentTimeMillis;
149-
if (
150-
stored.dpopThumbprint === thumbprint &&
151-
stored.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS &&
152-
stored.accessToken !== rejectedAccessToken
139+
/** The stored session, or a fresh one when it is missing a key match, near expiry, or rejected. */
140+
const session = Effect.fn("clientRuntime.authorization.tailnet.session")(function* (
141+
credentialId: string,
142+
rejectedAccessToken?: string,
153143
) {
154-
return stored;
155-
}
156-
return yield* renew(credentialId, stored);
157-
});
144+
const stored = Option.getOrUndefined(yield* credentials.get(credentialId));
145+
if (stored === undefined || !isTailnetCredential(stored)) {
146+
return yield* credentialMissingError(credentialId);
147+
}
148+
const thumbprint = yield* signer.thumbprint.pipe(Effect.mapError(proofError));
149+
const now = yield* Clock.currentTimeMillis;
150+
if (
151+
stored.dpopThumbprint === thumbprint &&
152+
stored.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS &&
153+
stored.accessToken !== rejectedAccessToken
154+
) {
155+
return stored;
156+
}
157+
return yield* renew(credentialId, stored);
158+
});
158159

159-
return { session };
160-
};
160+
return { session };
161+
},
162+
);

0 commit comments

Comments
 (0)