house worker
This commit is contained in:
@@ -59,6 +59,31 @@ WAF skip rule for `URI Path starts with /projects/house-tracker/api/worker/`,
|
|||||||
otherwise the worker's POSTs get challenged too. The endpoints are protected by
|
otherwise the worker's POSTs get challenged too. The endpoints are protected by
|
||||||
the bearer token.
|
the bearer token.
|
||||||
|
|
||||||
|
### Development behind Cloudflare Access
|
||||||
|
|
||||||
|
For `https://dev.stanrunge.dev`, keep Access enabled. In Cloudflare Zero Trust,
|
||||||
|
go to **Access controls → Service credentials → Service Tokens** and create a
|
||||||
|
token named `house-worker`. Save its Client ID and Client Secret. In the Access
|
||||||
|
application protecting the dev hostname, add a **Service Auth** policy with
|
||||||
|
**Include → Service Token → house-worker**. Keep the existing browser login
|
||||||
|
policy. See [Cloudflare's service token documentation](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/).
|
||||||
|
|
||||||
|
Set `HOUSE_WORKER_TOKEN` in the dev website's `.env` and restart its dev server.
|
||||||
|
On the home machine, copy `house.env.example` to `house.env`, fill in the same
|
||||||
|
worker token plus the Cloudflare Client ID and Client Secret, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod 600 house.env
|
||||||
|
bun --env-file=house.env scripts/house-worker.ts --check-api
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes one authenticated GET to the website's worker health endpoint. It
|
||||||
|
does not contact Pararius, sync listings or send alerts. The expected output is
|
||||||
|
`API connection OK`. Access credentials are sent only to the website API; the
|
||||||
|
client refuses redirects and stops immediately on an authentication failure.
|
||||||
|
An HTTP 503 can mean the website's worker token is missing; an HTTP 404 can mean
|
||||||
|
the running website does not yet include the health route.
|
||||||
|
|
||||||
## Home worker setup
|
## Home worker setup
|
||||||
|
|
||||||
Needs a machine that stays on (old laptop, Raspberry Pi, NAS) with Bun 1.4 or
|
Needs a machine that stays on (old laptop, Raspberry Pi, NAS) with Bun 1.4 or
|
||||||
|
|||||||
7
house.env.example
Normal file
7
house.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to house.env on the HOME machine. That filename is ignored by Git.
|
||||||
|
HOUSE_API_URL=https://dev.stanrunge.dev
|
||||||
|
# Same value as HOUSE_WORKER_TOKEN in the website's .env.
|
||||||
|
HOUSE_WORKER_TOKEN=
|
||||||
|
# Cloudflare Access service token, used only for requests to HOUSE_API_URL.
|
||||||
|
HOUSE_CF_ACCESS_CLIENT_ID=
|
||||||
|
HOUSE_CF_ACCESS_CLIENT_SECRET=
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* HOUSE_API_URL=https://stanrunge.dev HOUSE_WORKER_TOKEN=... bun scripts/house-worker.ts
|
* HOUSE_API_URL=https://stanrunge.dev HOUSE_WORKER_TOKEN=... bun scripts/house-worker.ts
|
||||||
* bun scripts/house-worker.ts --once # one fast-lane poll, then exit
|
* bun scripts/house-worker.ts --once # one fast-lane poll, then exit
|
||||||
* bun scripts/house-worker.ts --dry-run # fetch + parse, print, send nothing
|
* bun scripts/house-worker.ts --dry-run # fetch + parse, print, send nothing
|
||||||
|
* bun scripts/house-worker.ts --check-api # check website auth only, no Pararius requests
|
||||||
*
|
*
|
||||||
* Politeness rules, in order of importance:
|
* Politeness rules, in order of importance:
|
||||||
* - one request at a time, at least HOUSE_MIN_GAP_SECONDS apart (with jitter)
|
* - one request at a time, at least HOUSE_MIN_GAP_SECONDS apart (with jitter)
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
searchPageUrl
|
searchPageUrl
|
||||||
} from '../src/lib/house-tracker/pararius';
|
} from '../src/lib/house-tracker/pararius';
|
||||||
import { backoffMs, nextDelayMs } from '../src/lib/house-tracker/schedule';
|
import { backoffMs, nextDelayMs } from '../src/lib/house-tracker/schedule';
|
||||||
|
import { createWorkerApi } from '../src/lib/house-tracker/worker-api';
|
||||||
import type {
|
import type {
|
||||||
Lane,
|
Lane,
|
||||||
ListingDetail,
|
ListingDetail,
|
||||||
@@ -31,6 +33,7 @@ import type {
|
|||||||
|
|
||||||
const args = new Set(process.argv.slice(2));
|
const args = new Set(process.argv.slice(2));
|
||||||
const once = args.has('--once');
|
const once = args.has('--once');
|
||||||
|
const checkApi = args.has('--check-api');
|
||||||
const dryRun = args.has('--dry-run') || process.env.HOUSE_DRY_RUN === '1';
|
const dryRun = args.has('--dry-run') || process.env.HOUSE_DRY_RUN === '1';
|
||||||
|
|
||||||
const list = (value: string | undefined, fallback: string[]) =>
|
const list = (value: string | undefined, fallback: string[]) =>
|
||||||
@@ -66,8 +69,18 @@ const config = {
|
|||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!dryRun && (!config.api || config.token.length < 32))
|
if (checkApi && dryRun) throw new Error('--check-api cannot be combined with dry-run mode');
|
||||||
throw new Error('HOUSE_API_URL and HOUSE_WORKER_TOKEN (32+ chars) are required');
|
const requestApi = dryRun
|
||||||
|
? null
|
||||||
|
: createWorkerApi(
|
||||||
|
{
|
||||||
|
url: config.api,
|
||||||
|
token: config.token,
|
||||||
|
accessClientId: process.env.HOUSE_CF_ACCESS_CLIENT_ID,
|
||||||
|
accessClientSecret: process.env.HOUSE_CF_ACCESS_CLIENT_SECRET
|
||||||
|
},
|
||||||
|
checkApi ? 1 : 4
|
||||||
|
);
|
||||||
|
|
||||||
const SOURCE = 'pararius';
|
const SOURCE = 'pararius';
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -173,28 +186,8 @@ async function getHtml(url: string, referer?: string): Promise<{ html: string; s
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Website API
|
// Website API
|
||||||
|
|
||||||
async function api<T>(path: string, body: unknown): Promise<T | null> {
|
async function api<T>(path: 'sync' | 'details' | 'report', body: unknown): Promise<T | null> {
|
||||||
if (dryRun) return null;
|
return requestApi ? requestApi<T>(path, body) : null;
|
||||||
for (let attempt = 0; attempt < 4; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${config.api}/projects/house-tracker/api/worker/${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30_000)
|
|
||||||
});
|
|
||||||
if (response.ok) return (await response.json()) as T;
|
|
||||||
const text = await response.text();
|
|
||||||
if (response.status < 500)
|
|
||||||
throw new Error(`API ${path} ${response.status}: ${text.slice(0, 300)}`);
|
|
||||||
log(`API ${path} ${response.status}, retrying`);
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt === 3) throw error;
|
|
||||||
log(`API ${path} failed: ${(error as Error).message}, retrying`);
|
|
||||||
}
|
|
||||||
await sleep(5000 * 2 ** attempt);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function report(
|
async function report(
|
||||||
@@ -340,6 +333,12 @@ async function guarded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
if (checkApi) {
|
||||||
|
const result = await requestApi!<{ ok: boolean }>('health');
|
||||||
|
if (result?.ok !== true) throw new Error('API health: unexpected response');
|
||||||
|
log('API connection OK. Worker authentication passed; no Pararius requests were made.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
log(
|
log(
|
||||||
`house worker: ${config.fastSearches.length} fast, ${config.slowSearches.length} slow searches, mode=${config.fetchMode}${dryRun ? ', dry run' : ''}`
|
`house worker: ${config.fastSearches.length} fast, ${config.slowSearches.length} slow searches, mode=${config.fetchMode}${dryRun ? ', dry run' : ''}`
|
||||||
);
|
);
|
||||||
|
|||||||
105
src/lib/house-tracker/worker-api.test.ts
Normal file
105
src/lib/house-tracker/worker-api.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createWorkerApi } from './worker-api';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url: 'https://dev.stanrunge.dev',
|
||||||
|
token: 'test-worker-token-0000000000000000',
|
||||||
|
accessClientId: 'test-client.access',
|
||||||
|
accessClientSecret: 'test-access-secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worker API', () => {
|
||||||
|
it('authenticates the health check with both tokens without sending a body', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
await expect(createWorkerApi(config)('health')).resolves.toEqual({ ok: true });
|
||||||
|
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
'https://dev.stanrunge.dev/projects/house-tracker/api/worker/health',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
body: undefined,
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
authorization: `Bearer ${config.token}`,
|
||||||
|
'CF-Access-Client-Id': config.accessClientId,
|
||||||
|
'CF-Access-Client-Secret': config.accessClientSecret
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can POST to a server without Cloudflare Access', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(Response.json({ newIds: [] }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
await createWorkerApi({ url: config.url, token: config.token })('sync', { cards: [] });
|
||||||
|
expect(fetch.mock.calls[0][1]).toMatchObject({ method: 'POST', body: '{"cards":[]}' });
|
||||||
|
expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('CF-Access-Client-Secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([302, 401, 403, 429])(
|
||||||
|
'fails immediately on HTTP %i without exposing response text',
|
||||||
|
async (status) => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response('private login response', {
|
||||||
|
status,
|
||||||
|
headers: { location: 'https://login.example.com/' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const error = await createWorkerApi(config)('health').catch((error: Error) => error);
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain(`API health ${status}`);
|
||||||
|
expect((error as Error).message).not.toContain('private login response');
|
||||||
|
expect((error as Error).message).not.toContain(config.accessClientSecret);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch.mock.calls[0][1].redirect).toBe('manual');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects a login page returned with HTTP 200', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response('<html>login</html>', {
|
||||||
|
headers: { 'content-type': 'text/html' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
await expect(createWorkerApi(config)('health')).rejects.toThrow('expected JSON');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries transient server failures and preserves sync data', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 502 }))
|
||||||
|
.mockResolvedValueOnce(Response.json({ newIds: [] }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const result = createWorkerApi(config)('sync', { cards: [] });
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
await expect(result).resolves.toEqual({ newIds: [] });
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetch.mock.calls[1][1].body).toBe('{"cards":[]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry a one-attempt connection check', async () => {
|
||||||
|
const fetch = vi.fn().mockRejectedValue(new Error('connection failed'));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
await expect(createWorkerApi(config, 1)('health')).rejects.toThrow('could not reach');
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the complete Access credential pair', () => {
|
||||||
|
expect(() => createWorkerApi({ ...config, accessClientSecret: undefined })).toThrow('Set both');
|
||||||
|
expect(() => createWorkerApi({ ...config, accessClientId: undefined })).toThrow('Set both');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects insecure remote URLs and permits local development', () => {
|
||||||
|
expect(() => createWorkerApi({ ...config, url: 'http://dev.stanrunge.dev' })).toThrow('HTTPS');
|
||||||
|
expect(() => createWorkerApi({ ...config, url: 'http://127.0.0.1:5173' })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/lib/house-tracker/worker-api.ts
Normal file
78
src/lib/house-tracker/worker-api.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Only requests to our website use these credentials. Listing fetches stay separate.
|
||||||
|
type WorkerApiConfig = {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
accessClientId?: string;
|
||||||
|
accessClientSecret?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkerApiPath = 'health' | 'sync' | 'details' | 'report';
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export function createWorkerApi(config: WorkerApiConfig, attempts = 4) {
|
||||||
|
if (!config.url || config.token.length < 32)
|
||||||
|
throw new Error('HOUSE_API_URL and HOUSE_WORKER_TOKEN (32+ chars) are required');
|
||||||
|
if (Boolean(config.accessClientId) !== Boolean(config.accessClientSecret))
|
||||||
|
throw new Error('Set both HOUSE_CF_ACCESS_CLIENT_ID and HOUSE_CF_ACCESS_CLIENT_SECRET');
|
||||||
|
const base = new URL(config.url);
|
||||||
|
const local = ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname);
|
||||||
|
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && local))
|
||||||
|
throw new Error('HOUSE_API_URL must use HTTPS (HTTP is allowed for localhost)');
|
||||||
|
if (base.username || base.password || base.search || base.hash || base.pathname !== '/')
|
||||||
|
throw new Error('HOUSE_API_URL must be just the website origin, without a path or credentials');
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${config.token}`
|
||||||
|
};
|
||||||
|
if (config.accessClientId && config.accessClientSecret) {
|
||||||
|
headers['CF-Access-Client-Id'] = config.accessClientId;
|
||||||
|
headers['CF-Access-Client-Secret'] = config.accessClientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async function request<T>(path: WorkerApiPath, body?: unknown): Promise<T> {
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||||
|
if (attempt) await sleep(5000 * 2 ** (attempt - 1));
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${base.origin}/projects/house-tracker/api/worker/${path}`, {
|
||||||
|
method: body === undefined ? 'GET' : 'POST',
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
// Never forward Access credentials to a redirect (including the login page).
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(30_000)
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (attempt + 1 < attempts) continue;
|
||||||
|
throw new Error(`API ${path}: could not reach the website`);
|
||||||
|
}
|
||||||
|
if (response.status >= 500 && attempt + 1 < attempts) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
const hint =
|
||||||
|
response.status >= 300 && response.status < 400
|
||||||
|
? 'redirect refused; check the Cloudflare Access service token and Service Auth policy'
|
||||||
|
: response.status === 401 || response.status === 403
|
||||||
|
? 'check the worker token and Cloudflare Access service token/policy'
|
||||||
|
: 'check the website configuration';
|
||||||
|
throw new Error(`API ${path} ${response.status}: ${hint}`);
|
||||||
|
}
|
||||||
|
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new Error(`API ${path}: expected JSON; check Cloudflare Access and HOUSE_API_URL`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`API ${path}: invalid JSON response`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`API ${path}: no request attempts configured`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { requireWorker } from '$lib/server/house-tracker';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
// Authenticated connectivity check; no database writes or listing-site requests.
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
requireWorker(request);
|
||||||
|
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user