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
3 changes: 2 additions & 1 deletion src/cli/commands/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomBytes } from 'node:crypto';
import { createDaemonProxyServer } from '../../daemon-proxy.ts';
import { buildDaemonHttpBaseUrl } from '../../daemon/http-contract.ts';
import { ensureDaemon, resolveClientSettings } from '../../daemon-client-lifecycle.ts';
import { AppError } from '../../utils/errors.ts';
import type { CliFlags } from '../../utils/cli-flags.ts';
Expand Down Expand Up @@ -54,7 +55,7 @@ async function startProxy(flags: CliFlags): Promise<ProxyStartup> {
const proxyBaseUrl = `http://${formatHostForUrl(address.address)}:${address.port}`;
return {
proxyBaseUrl,
agentDeviceBaseUrl: `${proxyBaseUrl}/agent-device`,
agentDeviceBaseUrl: buildDaemonHttpBaseUrl(proxyBaseUrl),
token,
upstreamBaseUrl,
stateDir: settings.paths.baseDir,
Expand Down
8 changes: 2 additions & 6 deletions src/daemon-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { AppError } from './utils/errors.ts';
import type { DaemonArtifact, DaemonRequest, DaemonResponse } from './daemon/types.ts';
import { buildDaemonHttpAuthHeaders } from './daemon/http-contract.ts';
import { uploadArtifact } from './upload-client.ts';

// Mirrors the current daemon RPC timeout, but artifact download timeouts may diverge.
Expand Down Expand Up @@ -319,12 +320,7 @@ export async function downloadRemoteArtifact(params: {
port: artifactUrl.port,
method: 'GET',
path: artifactUrl.pathname + artifactUrl.search,
headers: params.token
? {
authorization: `Bearer ${params.token}`,
'x-agent-device-token': params.token,
}
: undefined,
headers: buildDaemonHttpAuthHeaders(params.token),
},
(res) => {
if ((res.statusCode ?? 500) >= 400) {
Expand Down
18 changes: 4 additions & 14 deletions src/daemon-client-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readDaemonSocketProgressResponse,
shouldReadDaemonProgressStream,
} from './daemon-client-progress.ts';
import { buildDaemonHttpAuthHeaders, buildDaemonHttpUrl } from './daemon/http-contract.ts';
import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts';
import { handleRequestTimeout } from './daemon-client-timeout.ts';
import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts';
Expand Down Expand Up @@ -108,11 +109,7 @@ function readDaemonHttpHealth(info: DaemonInfo): Promise<RemoteDaemonHealth> {
? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS
: LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS;
return new Promise((resolve) => {
const headers: Record<string, string> = {};
if (info.baseUrl && info.token) {
headers.authorization = `Bearer ${info.token}`;
headers['x-agent-device-token'] = info.token;
}
const headers = info.baseUrl ? buildDaemonHttpAuthHeaders(info.token) : {};
const req = transport.request(
{
protocol: url.protocol,
Expand Down Expand Up @@ -369,9 +366,8 @@ async function sendHttpRequest(
'content-type': 'application/json',
'content-length': Buffer.byteLength(rpcPayload),
};
if (info.baseUrl && info.token) {
headers.authorization = `Bearer ${info.token}`;
headers['x-agent-device-token'] = info.token;
if (info.baseUrl) {
Object.assign(headers, buildDaemonHttpAuthHeaders(info.token));
}

return await new Promise((resolve, reject) => {
Expand Down Expand Up @@ -444,9 +440,3 @@ async function sendHttpRequest(
request.end();
});
}

function buildDaemonHttpUrl(baseUrl: string, route: 'health' | 'rpc'): string {
// URL(base, relative) treats a base without trailing slash as a file path, so normalize to a directory-like base.
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(route, normalizedBase).toString();
}
18 changes: 12 additions & 6 deletions src/daemon-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import { AppError, normalizeError } from './utils/errors.ts';
import { timingSafeStringEqual } from './utils/timing-safe-equal.ts';
import {
DAEMON_HTTP_BASE_PATH,
buildDaemonHttpAuthHeaders,
buildDaemonHttpUrl,
} from './daemon/http-contract.ts';
import { buildDaemonHealthPayload } from './daemon/http-health.ts';

export type DaemonProxyOptions = {
Expand All @@ -17,7 +22,7 @@ export type DaemonProxyOptions = {

const DEFAULT_MAX_RPC_BODY_BYTES = 1024 * 1024;
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5 * 60 * 1000;
const DAEMON_PROXY_PREFIX = '/agent-device/';
const DAEMON_PROXY_PREFIX = `${DAEMON_HTTP_BASE_PATH}/`;
const FORWARDED_REQUEST_HEADERS = ['content-type', 'x-artifact-type', 'x-artifact-filename'];
const FORWARDED_RESPONSE_HEADERS = ['content-type', 'content-disposition', 'x-request-id'];

Expand Down Expand Up @@ -68,7 +73,7 @@ async function sendProxyHealth(res: ServerResponse, options: Required<DaemonProx
}

async function readUpstreamHealth(options: Required<DaemonProxyOptions>): Promise<unknown> {
const upstreamUrl = new URL('health', `${options.upstreamBaseUrl}/`);
const upstreamUrl = new URL(buildDaemonHttpUrl(options.upstreamBaseUrl, 'health'));
const response = await options.fetchImpl(upstreamUrl, {
method: 'GET',
headers: buildUpstreamHeaders({ headers: {} }, options.upstreamToken, '/health'),
Expand Down Expand Up @@ -153,7 +158,7 @@ function normalizeToken(value: string, label: string): string {

function resolveProxyRoute(requestUrl: string): string {
const pathname = new URL(requestUrl, 'http://127.0.0.1').pathname;
if (pathname === '/agent-device') return '/';
if (pathname === DAEMON_HTTP_BASE_PATH) return '/';
if (pathname.startsWith(DAEMON_PROXY_PREFIX)) {
return `/${pathname.slice(DAEMON_PROXY_PREFIX.length)}`;
}
Expand All @@ -168,7 +173,7 @@ function isSupportedDaemonRoute(route: string, method: string | undefined): bool
}

function buildUpstreamUrl(upstreamBaseUrl: string, route: string, rawUrl: string): URL {
const upstreamUrl = new URL(route.replace(/^\//, ''), `${upstreamBaseUrl}/`);
const upstreamUrl = new URL(buildDaemonHttpUrl(upstreamBaseUrl, route));
const rawSearchIndex = rawUrl.indexOf('?');
if (rawSearchIndex >= 0) upstreamUrl.search = rawUrl.slice(rawSearchIndex);
return upstreamUrl;
Expand All @@ -187,8 +192,9 @@ function buildUpstreamHeaders(
if (route === '/rpc' && !headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
headers.set('authorization', `Bearer ${upstreamToken}`);
headers.set('x-agent-device-token', upstreamToken);
for (const [name, value] of Object.entries(buildDaemonHttpAuthHeaders(upstreamToken))) {
headers.set(name, value);
}
return headers;
}

Expand Down
37 changes: 37 additions & 0 deletions src/daemon/__tests__/http-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
buildDaemonHttpAuthHeaders,
buildDaemonHttpBaseUrl,
buildDaemonHttpUrl,
} from '../http-contract.ts';

test('buildDaemonHttpBaseUrl appends the public agent-device base path', () => {
assert.equal(
buildDaemonHttpBaseUrl('https://example.trycloudflare.com'),
'https://example.trycloudflare.com/agent-device',
);
assert.equal(
buildDaemonHttpBaseUrl('http://127.0.0.1:4310/'),
'http://127.0.0.1:4310/agent-device',
);
});

test('buildDaemonHttpUrl preserves daemon base paths for remote routes', () => {
assert.equal(
buildDaemonHttpUrl('https://example.trycloudflare.com/agent-device', 'health'),
'https://example.trycloudflare.com/agent-device/health',
);
assert.equal(
buildDaemonHttpUrl('https://example.trycloudflare.com/agent-device/', '/rpc'),
'https://example.trycloudflare.com/agent-device/rpc',
);
});

test('buildDaemonHttpAuthHeaders writes both supported daemon auth headers', () => {
assert.deepEqual(buildDaemonHttpAuthHeaders(' token-1 '), {
authorization: 'Bearer token-1',
'x-agent-device-token': 'token-1',
});
assert.deepEqual(buildDaemonHttpAuthHeaders(''), {});
});
19 changes: 19 additions & 0 deletions src/daemon/http-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const DAEMON_HTTP_BASE_PATH = '/agent-device';

export function buildDaemonHttpBaseUrl(baseUrl: string): string {
return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH);
}

export function buildDaemonHttpUrl(baseUrl: string, route: string): string {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(route.replace(/^\/+/, ''), normalizedBase).toString();
}

export function buildDaemonHttpAuthHeaders(token: string | undefined): Record<string, string> {
const normalizedToken = token?.trim();
if (!normalizedToken) return {};
return {
authorization: `Bearer ${normalizedToken}`,
'x-agent-device-token': normalizedToken,
};
}
16 changes: 4 additions & 12 deletions src/upload-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { pipeline } from 'node:stream/promises';
import { AppError } from './utils/errors.ts';
import { readNodeHttpResponseBody } from './utils/node-http.ts';
import { runCmd } from './utils/exec.ts';
import { buildDaemonHttpAuthHeaders } from './daemon/http-contract.ts';

const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const UPLOAD_PREFLIGHT_TIMEOUT_MS = 30 * 1000;
Expand Down Expand Up @@ -188,10 +189,7 @@ async function uploadLegacyArtifact(options: {
'x-artifact-hash-algorithm': ARTIFACT_HASH_ALGORITHM,
'transfer-encoding': 'chunked',
};
if (token) {
headers.authorization = `Bearer ${token}`;
headers['x-agent-device-token'] = token;
}
Object.assign(headers, buildDaemonHttpAuthHeaders(token));

const response = await streamFileToHttpRequest({
url: uploadUrl,
Expand Down Expand Up @@ -225,10 +223,7 @@ async function requestUploadPreflight(options: {
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
headers['x-agent-device-token'] = options.token;
}
Object.assign(headers, buildDaemonHttpAuthHeaders(options.token));

const response = await fetch(preflightUrl, {
method: 'POST',
Expand Down Expand Up @@ -517,10 +512,7 @@ async function finalizeDirectUpload(options: {
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (options.token) {
headers.authorization = `Bearer ${options.token}`;
headers['x-agent-device-token'] = options.token;
}
Object.assign(headers, buildDaemonHttpAuthHeaders(options.token));

const response = await fetch(finalizeUrl, {
method: 'POST',
Expand Down
Loading