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
4 changes: 4 additions & 0 deletions changelog.d/boot-tailscale-login-pairing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- After the PC restarts, your own devices on Tailscale are no longer asked for a pairing code for
up to five minutes: Ovis reads the PC's Tailscale login again once Tailscale has finished
starting, instead of keeping the empty answer it got at boot.
3 changes: 2 additions & 1 deletion server/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ export function bindScope(hostname: string): BindScope {
return (family === 4 ? tailnetIpv4.check(address, "ipv4") : family === 6 && tailnetIpv6.check(address, "ipv6")) ? "tailnet" : "other";
}

const inTailnet = (address: string | null | undefined): boolean => {
/** Is this one address (never a list) in Tailscale's assigned ranges? */
export const inTailnet = (address: string | null | undefined): boolean => {
const family = isIP(address ?? "");
return family === 4 ? tailnetIpv4.check(address!, "ipv4") : family === 6 && tailnetIpv6.check(address!, "ipv6");
};
Expand Down
34 changes: 34 additions & 0 deletions server/api.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4210,6 +4210,40 @@ describe("pairing and identity", () => {
}
});

it("regression: lets the owner's login in as soon as Tailscale finishes starting, not five minutes after a boot", async () => {
const state = mkdtempSync(join(tmpdir(), "herdr-boot-identity-"));
const savedOwner = process.env["OVIS_TAILSCALE_OWNER"];
delete process.env["OVIS_TAILSCALE_OWNER"];
// a PC that paired a device before: the gate is closed to strangers
writeFileSync(join(state, "devices.json"), JSON.stringify({ gate_closed_at: new Date().toISOString(), devices: [] }));
// at boot the server asks before tailscaled has logged in: no user yet; every later read is Running
const starting = JSON.stringify({ BackendState: "NoState", Self: { DNSName: "" } });
const running = JSON.stringify({ BackendState: "Running", Self: { DNSName: "pc.example.ts.net.", UserID: 7, TailscaleIPs: ["100.101.102.103"] }, User: { "7": { LoginName: OWNER } } });
let reads = 0;
const tailnet = new TailnetIdentitySource(async () => reads++ === 0 ? starting : running);
await tailnet.freshIdentity(null);
const booted = createServer({ port: 0, stateDir: state, tailnet });
const at = async (headers: Record<string, string>) => ((await (await fetch(`http://127.0.0.1:${booted.port}/api/health?scope=bridge`, { headers })).json()) as { auth: HealthAuth }).auth;
// the owner's phone asks a few seconds after the boot read, once tailscaled is up
const start = Date.now();
const clock = spyOn(Date, "now").mockImplementation(() => start + 3_000);
try {
expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" });
// once the login is known it is kept for the cache's life: later polls read nothing
const settled = reads;
expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" });
expect(reads).toBe(settled);
// a stranger still pairs
expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" });
expect(await at(proxied())).toMatchObject({ authenticated: false, reason: "pairing_required" });
} finally {
clock.mockRestore();
booted.stop();
if (savedOwner === undefined) delete process.env["OVIS_TAILSCALE_OWNER"]; else process.env["OVIS_TAILSCALE_OWNER"] = savedOwner;
rmSync(state, { recursive: true, force: true });
}
});

it("takes the login named in OVIS_TAILSCALE_OWNER for the PC's own, as a tagged node needs", async () => {
const state = mkdtempSync(join(tmpdir(), "herdr-named-owner-"));
const before = process.env["OVIS_TAILSCALE_OWNER"];
Expand Down
10 changes: 7 additions & 3 deletions server/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { OrderSequence } from "./order-sequence.ts";
import type { DevicePairedEvent, HealthAuth, SecuritySummary, SessionSnapshot } from "../shared/protocol.ts";
import { DEFAULT_PORT } from "../shared/protocol.ts";
import { authClient, deviceCookieValue, bearerMatches, handleAuthRequest, isAuthenticated, isSecureRequest, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts";
import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress } from "./access.ts";
import { bindScope, cameThroughProxy, decideAccess, forwardingHeaders, inTailnet, isLoopbackAddress, isServeOwnerRequest, lanExposed, observedIngress } from "./access.ts";
import { type DeviceStore, handleDeviceRequest, newStreamKey, sameBrowser, streamCookie, streamKeys, type DeviceMatch } from "./devices.ts";
import { handlePreferencesRequest, type PreferenceStore } from "./preferences.ts";
import { handleSeenRequest, type SeenStore } from "./seen.ts";
Expand Down Expand Up @@ -71,7 +71,8 @@ export interface RequestServices {
readonly devices: DeviceStore;
readonly preferences: PreferenceStore;
readonly herdrSidebar: HerdrSidebarConfig;
readonly identityOf: (grantPath: boolean, host: string | null) => Promise<TailnetIdentity>;
/** `loginPath`: the request states a Tailscale login, so the PC's own login must be known, not merely cached as unknown */
readonly identityOf: (grantPath: boolean, host: string | null, loginPath?: boolean) => Promise<TailnetIdentity>;
readonly serveOnly: boolean;
readonly portalGuards: () => { tokenSet: boolean; serveOnly: boolean };
readonly shellAgents: ShellAgentLocator;
Expand Down Expand Up @@ -151,8 +152,11 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
return errorResponse(error);
}
const requestShape = { loopback, forwarded, funnel, tailscaleLogin, forwardedFor, serveOnly };
const apiPath = pathname === "/ws" || pathname.startsWith("/api/");
const identity = relayPeer === undefined
? await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && (pathname === "/ws" || pathname.startsWith("/api/")), requestHost)
? await identityOf(token === "" && pairedDevice === null && isServeOwnerRequest(requestShape) && apiPath, requestHost,
// only the shape decideAccess could let in by login: from loopback, one tailnet address, not Funnel
token === "" && pairedDevice === null && loopback && tailscaleLogin !== null && !funnel && inTailnet(forwardedFor) && apiPath)
: { owner: null, tagged: false, soleLogin: null, dnsName: null, tailnetIp: null };
const portalProven = relayPeer === undefined && (options.portal?.vouches(request.headers.get("x-portal-proof") ?? null) ?? false);
const access = decideAccess({
Expand Down
4 changes: 2 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,9 @@ export function createServer(
/** a login named here is taken as it is: a tagged node has none of its own to read (OVIS_TAILSCALE_OWNER) */
const namedOwner = options.tailscaleOwner !== undefined ? options.tailscaleOwner : process.env["OVIS_TAILSCALE_OWNER"]?.trim() || undefined;
const tailnet = options.tailnet ?? new TailnetIdentitySource();
const identityOf = async (grantPath: boolean, host: string | null) => {
const identityOf = async (grantPath: boolean, host: string | null, loginPath = false) => {
if (namedOwner !== undefined) return { ...(grantPath ? await tailnet.freshIdentity(host) : { soleLogin: null, dnsName: null, tailnetIp: null }), owner: namedOwner, tagged: false };
return grantPath ? tailnet.freshIdentity(host) : tailnet.identity();
return grantPath ? tailnet.freshIdentity(host) : loginPath ? tailnet.loginIdentity() : tailnet.identity();
};
const serveOnly = options.tailscaleServeOnly ?? process.env["OVIS_TAILSCALE_SERVE_ONLY"] === "1";
/** what lets Portal open an address: this server's own values, never a request's */
Expand Down
56 changes: 56 additions & 0 deletions server/tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,62 @@ describe("the owner's grant read, by Host and by outcome", () => {
});
});

describe("loginIdentity", () => {
/** a source whose reads answer `reads` in turn (the last one from then on), on a clock the test moves */
function source(reads: (string | null)[]) {
let now = 1_000_000;
let count = 0;
const clock = spyOn(Date, "now").mockImplementation(() => now);
const identities = new TailnetIdentitySource(async () => reads[Math.min(count++, reads.length - 1)] ?? null);
return { identities, reads: () => count, later: (ms: number) => { now += ms; }, restore: () => clock.mockRestore() };
}
const booting = JSON.stringify({ BackendState: "NoState", Self: { DNSName: "" } });

it("reads again after a boot read named no owner, at most every two seconds", async () => {
const s = source([booting, booting, tailnet([42, undefined])]);
try {
expect((await s.identities.loginIdentity()).owner).toBeNull();
// within two seconds of a read, requests answer from it: nothing can keep the CLI spawning
for (let i = 0; i < 20; i++) await s.identities.loginIdentity();
expect(s.reads()).toBe(1);
s.later(2_000);
expect((await s.identities.loginIdentity()).owner).toBeNull();
s.later(2_000);
expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com");
expect(s.reads()).toBe(3);
// known, it stands for the cache's life
s.later(2_000);
await s.identities.loginIdentity();
expect(s.reads()).toBe(3);
} finally { s.restore(); }
});

it("keeps a known owner through a failed read and a daemon that is not Running", async () => {
const s = source([tailnet([42, undefined]), null, booting]);
try {
expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com");
for (let i = 0; i < 2; i++) {
s.later(6 * 60_000);
await s.identities.freshIdentity(HOST);
expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com");
}
expect(s.reads()).toBe(3);
} finally { s.restore(); }
});

it("reads the new login on a login request after the daemon restarts under another account", async () => {
const s = source([tailnet([42, undefined]), booting, tailnet([43, undefined])]);
try {
expect((await s.identities.loginIdentity()).owner).toBe("user42@example.com");
s.later(6 * 60_000);
await s.identities.freshIdentity(HOST);
s.later(2_000);
expect((await s.identities.loginIdentity()).owner).toBe("user43@example.com");
expect(s.reads()).toBe(3);
} finally { s.restore(); }
});
});

describe("parseTailscaleIp", () => {
it("picks this PC's IPv4 tailnet address, and nothing when there is none", () => {
expect(parseTailscaleIp(JSON.stringify({ Self: { TailscaleIPs: ["fd7a:115c:a1e0::1", "100.64.0.7"] } }))).toBe("100.64.0.7");
Expand Down
35 changes: 31 additions & 4 deletions server/tailscale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export interface TailnetIdentity {
}

const OWNER_TTL_MS = 5 * 60_000;
/** how soon a request stating a login may read the status again while this PC's own login is unknown */
const LOGIN_RETRY_MS = 2_000;

const cliStatus = async (): Promise<string | null> => {
const binary = tailscaleBinary();
Expand All @@ -208,8 +210,11 @@ const cliStatus = async (): Promise<string | null> => {
* that waits for a read, shared with any read already in flight.
*/
export class TailnetIdentitySource {
/** the last read's answer and when it landed; null until the first read lands */
private cache: (TailnetIdentity & { at: number; settled: boolean }) | null = null;
/**
* the last read's answer and when it landed; null until the first read lands. `ownerKnown`: a
* Running status named this PC's login, or that its node is tagged and has none
*/
private cache: (TailnetIdentity & { at: number; settled: boolean; ownerKnown: boolean }) | null = null;
/** the read in flight, so concurrent grants share one bounded read */
private reading: Promise<void> | null = null;

Expand All @@ -232,18 +237,26 @@ export class TailnetIdentitySource {
dnsName: this.cache?.dnsName ?? null,
tailnetIp: this.cache?.tailnetIp ?? null,
settled: false,
ownerKnown: this.cache?.ownerKnown ?? false,
at: Date.now(),
};
return;
}
const sole = readSoleLogin(status);
// a daemon that is not Running says nothing about whose PC this is: keep the login a Running one
// named, and leave it unconfirmed so a login request reads again (`loginIdentity`)
const running = parseStatus(status)?.BackendState === "Running";
const kept = !running && this.cache !== null && (this.cache.owner !== null || this.cache.tagged) ? this.cache : null;
const owner = kept ? kept.owner : parseTailscaleOwner(status);
const tagged = kept ? kept.tagged : isTaggedNode(status);
this.cache = {
owner: parseTailscaleOwner(status),
tagged: isTaggedNode(status),
owner,
tagged,
soleLogin: sole.login,
dnsName: parseNodeName(status),
tailnetIp: parseTailscaleIp(status),
settled: sole.settled,
ownerKnown: running && (owner !== null || tagged),
at: Date.now(),
};
}
Expand All @@ -269,6 +282,20 @@ export class TailnetIdentitySource {
};
}

/**
* `identity` for a request that states a Tailscale login. Until a read has named this PC's own
* login (or found its node tagged), this waits for a read, shared and bounded like
* `freshIdentity`: a server started at boot asks before tailscaled has logged in, and that
* answer must not stand for five minutes against the owner's own devices. A new read starts at
* most every `LOGIN_RETRY_MS`, so requests cannot keep the tailscale CLI spawning.
*/
async loginIdentity(): Promise<TailnetIdentity> {
if (this.cache !== null && this.cache.ownerKnown) return this.identity();
if (this.reading === null && this.cache !== null && Date.now() - this.cache.at < LOGIN_RETRY_MS) return this.identity();
await this.refresh();
return this.identity();
}

/**
* `identity` for a request the owner's sole-login proof would admit. A cache that already says
* a node is tagged, that the tailnet is not one login's, or that this Host is not this PC's name
Expand Down
Loading