diff --git a/package-lock.json b/package-lock.json index dc5624c..ad6a913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "delay": "^4.4.0", "he": "^1.1.1", "ics": "^3.12.0", + "jose": "^6.2.12", "json-stable-stringify": "^1.3.0", "jsonpath-plus": "^10.4.0", "lodash": "^4.18.1", @@ -9841,6 +9842,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-message": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", diff --git a/package.json b/package.json index 6ea5da9..5155493 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "updater:deploy": "wrangler deploy --config workers/updater/wrangler.jsonc", "updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc", "social": "node src/app/index.js social", - "social:test": "node src/app/index.js socialTest" + "social:test": "node src/app/index.js socialTest", + "admin:preview": "node workers/updater/preview/server.mjs" }, "dependencies": { "@atproto/api": "^0.20.42", @@ -52,6 +53,7 @@ "delay": "^4.4.0", "he": "^1.1.1", "ics": "^3.12.0", + "jose": "^6.2.12", "json-stable-stringify": "^1.3.0", "jsonpath-plus": "^10.4.0", "lodash": "^4.18.1", diff --git a/test/admin/access.test.mjs b/test/admin/access.test.mjs new file mode 100644 index 0000000..ee8296d --- /dev/null +++ b/test/admin/access.test.mjs @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPair, SignJWT } from 'jose'; +import { verifyAccess } from '../../workers/updater/src/admin/access.mjs'; + +const { publicKey, privateKey } = await generateKeyPair('RS256'); +const env = { ADMIN_HOSTNAME: 'admin.example.test', ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience' }; +async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, expires = '5m' } = {}) { + return new SignJWT({ email: 'admin@example.test' }).setProtectedHeader({ alg: 'RS256' }).setSubject('user-id').setIssuer(issuer).setAudience(audience).setExpirationTime(expires).sign(privateKey); +} +function request(jwt, host = env.ADMIN_HOSTNAME) { + return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {} }); +} + +test('validates Access signature, issuer, audience, expiration and configured hostname', async () => { + assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test' }); + for (const options of [{ audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }]) + assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null); + assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null); + const wrong = await generateKeyPair('RS256'); + assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null); +}); + +test('fails closed without configuration or a valid assertion', async () => { + assert.equal(await verifyAccess(request(), env, publicKey), null); + assert.equal(await verifyAccess(request('forged'), env, publicKey), null); + assert.equal(await verifyAccess(request(await token()), {}, publicKey), null); +}); diff --git a/workers/updater/README.md b/workers/updater/README.md index 88c7090..b1f54d2 100644 --- a/workers/updater/README.md +++ b/workers/updater/README.md @@ -117,6 +117,39 @@ persists the pause and deletes the alarm. `/arm` resumes scheduling; a saved overdue run executes immediately. Do not pause in the middle of a run: a busy pause request returns 409 so the caller can retry. +## Admin panel + +`/admin/` provides a mobile-friendly panel for data-only, social-only, and full +manual runs. The authenticated browser starts a persisted request and polls its +status; closing the tab does not cancel the job. One manual request can be +pending at a time, and it shares the hourly scheduler's lock. Social runs keep +normal checkpoints and the published-data check. An interrupted manual run is +reported as failed rather than automatically replaying an uncertain social send. +The hourly schedule is preserved. Paused scheduling also blocks manual runs. + +Preview the actual panel with simulated results using `npm run admin:preview`, +then open `http://127.0.0.1:8788/admin/`. This standalone preview server listens +only on loopback and has no production credentials or bindings. The production +Worker has no local-authentication bypass. + +Before exposing the panel in production: + +1. Create a Cloudflare Access self-hosted application protecting the entire + chosen admin hostname (for example `admin.splatoon2.ink`). Allow only the + owner's identity, with email one-time codes or their preferred provider. +2. Configure the updater with `ADMIN_HOSTNAME`, `ACCESS_TEAM_DOMAIN` (the bare + `.cloudflareaccess.com` hostname), and `ACCESS_AUD` (the application's + audience tag). These settings are deliberately absent until Access is ready; + all admin routes fail closed without them. +3. Attach the admin hostname to this Worker and deploy. Open `/admin/` and verify + login, status, and a deliberate test run. No Access application or production + admin domain is created by the local preview. + +The Worker verifies JWT signature, issuer, audience, expiration, and hostname. +Mutating browser requests also require a matching Origin and JSON content type. +The existing bearer-token API remains available for scripts, independently of +Access. The panel does not expose force-repost, pause, or resume controls. + ## Configuration and local commands Secrets: `NINTENDO_SESSION_ID_NA`, `NINTENDO_SESSION_ID_EU`, diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs new file mode 100644 index 0000000..946499e --- /dev/null +++ b/workers/updater/admin.spec.mjs @@ -0,0 +1,37 @@ +import { expect, it, vi, afterEach } from 'vitest'; +import { adminRequest } from './src/admin/routes.mjs'; +import { verifyAccess } from './src/admin/access.mjs'; +vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() })); +afterEach(() => vi.resetAllMocks()); +const url = 'https://admin.example.test'; +const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) }); + +it('requires Access before exposing the panel, status or actions', async () => { + verifyAccess.mockResolvedValue(null); + const get = vi.fn(); + for (const request of [new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post()]) + expect((await adminRequest(request, {}, get)).status).toBe(401); + expect(get).not.toHaveBeenCalled(); +}); +it('rejects cross-origin requests and unknown modes before scheduling work', async () => { + verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + const get = vi.fn(); + expect((await adminRequest(post('both', 'https://other.test'), {}, get)).status).toBe(403); + expect((await adminRequest(post('force'), {}, get)).status).toBe(400); + expect(get).not.toHaveBeenCalled(); +}); +it('returns an accepted run immediately and reports overlap without starting another', async () => { + verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' } })); + expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202); + expect(startManual).toHaveBeenCalledWith('social'); + startManual.mockResolvedValue({ ok: false, busy: true }); + expect((await adminRequest(post(), {}, () => ({ startManual }))).status).toBe(409); +}); +it('serves the mobile panel with no-store and a nonce-based content policy', async () => { + verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + const response = await adminRequest(new Request(url + '/admin/'), {}, vi.fn()); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-security-policy')).toContain('frame-ancestors \'none\''); + expect(await response.text()).not.toContain('__NONCE__'); +}); diff --git a/workers/updater/preview/server.mjs b/workers/updater/preview/server.mjs new file mode 100644 index 0000000..e3a1cfb --- /dev/null +++ b/workers/updater/preview/server.mjs @@ -0,0 +1,45 @@ +// UI preview only. No Worker bindings, credentials, or production network requests. +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; + +const port = Number(process.env.ADMIN_PREVIEW_PORT || 8788); +const hour = Math.floor(Date.now() / 3600000) * 3600000; +const state = { + preview: true, user: { email: 'Local preview' }, paused: false, busy: false, + hourlyAt: hour + 3600000 + 10000, retryAt: null, pendingManual: null, + lastManualRun: { id: 'previous', mode: 'social', ok: false, status: 'failed', startedAt: hour - 1800000, runMs: 42300, error: 'Browser screenshot timed out. The post was not sent.' }, + lastRun: { mode: 'both', ok: true, startedAt: hour + 10000, runMs: 18200, updaters: { ok: true }, social: { ok: true } }, +}; +createServer(async (request, response) => { + response.setHeader('Cache-Control', 'no-store'); + const url = new URL(request.url, `http://127.0.0.1:${port}`); + function json(body, status = 200) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); } + try { + if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + return response.end((await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll('__NONCE__', 'preview')); + } + if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state); + if (request.method === 'POST' && url.pathname === '/admin/api/run') { + if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403); + if (state.busy) return json({ error: 'A run is already active.' }, 409); + let body = ''; + for await (const chunk of request) { body += chunk; if (body.length > 1024) return json({ error: 'Request too large.' }, 413); } + const { mode } = JSON.parse(body); + if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400); + const run = { id: randomUUID(), mode, status: 'queued', requestedAt: Date.now() }; + state.busy = true; state.pendingManual = run; + setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700); + setTimeout(() => { + state.lastManualRun = { ...run, ok: true, status: 'succeeded', finishedAt: Date.now(), runMs: Date.now() - run.startedAt, + updaters: mode === 'social' ? { ok: true, skipped: true } : { ok: true, updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(name => ({ name, ok: true })) }, + social: mode === 'data' ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] }, + }; + state.busy = false; state.pendingManual = null; + }, 6000); + return json({ ok: true, run }, 202); + } + json({ error: 'Not found.' }, 404); + } catch { json({ error: 'Invalid preview request.' }, 400); } +}).listen(port, '127.0.0.1', () => console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`)); diff --git a/workers/updater/src/admin/access.mjs b/workers/updater/src/admin/access.mjs new file mode 100644 index 0000000..9b5d677 --- /dev/null +++ b/workers/updater/src/admin/access.mjs @@ -0,0 +1,28 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +let keySets = new Map(); + +export async function verifyAccess(request, env, resolveKey) { + let domain = env.ACCESS_TEAM_DOMAIN; + if (!domain || !env.ACCESS_AUD || new URL(request.url).hostname !== env.ADMIN_HOSTNAME) + return null; + let token = request.headers.get('Cf-Access-Jwt-Assertion'); + if (!token) + return null; + try { + let issuer = new URL(`https://${domain}`); + if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com')) + return null; + if (!resolveKey) { + if (!keySets.has(domain)) + keySets.set(domain, createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 })); + resolveKey = keySets.get(domain); + } + let { payload } = await jwtVerify(token, resolveKey, { + issuer: issuer.origin, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub'], + }); + return { email: payload.email ?? 'Authenticated administrator' }; + } catch { + return null; + } +} diff --git a/workers/updater/src/admin/page.html b/workers/updater/src/admin/page.html new file mode 100644 index 0000000..3d3b4c7 --- /dev/null +++ b/workers/updater/src/admin/page.html @@ -0,0 +1,101 @@ + + + + + + +Splatoon2.ink · Control room + + + +
+
splatoon2.ink
Administration
+
Control room

A little maintenance.

Check the latest runs, refresh the data, or catch up on social posts.

+
Local preview · All runs are simulated. No production data or social accounts are touched.
+
Connecting…
Checking the updater
+ +
+

Update game data

Refresh schedules, gear, and the rest of the SplatNet data.

+

Catch up on social

Send any due Bluesky posts using the latest published data.

+

Run the full cycle

Update game data, then send any social posts that are due.

+
+

Successful posts are remembered. Retrying a cycle skips posts already sent.

+
Sign in again +

Loading recent runs…

+
Protected by Cloudflare Access
Times shown in your local timezone.
+
+ + + diff --git a/workers/updater/src/admin/routes.mjs b/workers/updater/src/admin/routes.mjs new file mode 100644 index 0000000..508614d --- /dev/null +++ b/workers/updater/src/admin/routes.mjs @@ -0,0 +1,36 @@ +import page from './page.html'; +import { verifyAccess } from './access.mjs'; +import { MANUAL_MODES } from '../Scheduler.mjs'; + +const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }; +const json = (body, status = 200) => Response.json(body, { status, headers }); + +export async function adminRequest(request, env, getScheduler) { + let user = await verifyAccess(request, env); + if (!user) + return json({ error: 'Sign in through Cloudflare Access to continue.' }, 401); + let url = new URL(request.url); + if (request.method === 'GET' && ['/admin', '/admin/'].includes(url.pathname)) { + let nonce = crypto.randomUUID(); + return new Response(page.replaceAll('__NONCE__', nonce), { headers: { + ...headers, + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`, + 'Referrer-Policy': 'no-referrer', + } }); + } + if (request.method === 'GET' && url.pathname === '/admin/api/status') + return json({ ...await getScheduler().status(), user, preview: false }); + if (request.method === 'POST' && url.pathname === '/admin/api/run') { + // Access cookies authenticate the user; require a same-origin JSON request as well. + if (request.headers.get('Origin') !== url.origin || request.headers.get('Content-Type') !== 'application/json') + return json({ error: 'A same-origin JSON request is required.' }, 403); + let mode; + try { ({ mode } = await request.json()); } catch { return json({ error: 'Invalid request.' }, 400); } + if (!MANUAL_MODES.includes(mode)) + return json({ error: 'Unknown run mode.' }, 400); + let result = await getScheduler().startManual(mode); + return json(result, result.ok ? 202 : result.busy || result.paused ? 409 : 400); + } + return json({ error: 'Not found.' }, 404); +} diff --git a/workers/updater/src/index.mjs b/workers/updater/src/index.mjs index 24dac66..e107e6c 100644 --- a/workers/updater/src/index.mjs +++ b/workers/updater/src/index.mjs @@ -1,3 +1,4 @@ +import { adminRequest } from './admin/routes.mjs'; import { withSentry, instrumentDurableObjectWithSentry } from '@sentry/cloudflare'; import { Scheduler as SchedulerClass } from './Scheduler.mjs'; import { createLogger, describeError } from './log.mjs'; @@ -44,6 +45,14 @@ export default withSentry(sentryOptions, { // Manual runs use the same owner as alarms. Targeted runs refresh data only. async fetch(request, env, ctx) { let url = new URL(request.url); + if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) { + try { + return await adminRequest(request, env, () => scheduler(env)); + } catch (error) { + createLogger('admin').error('Admin request failed', describeError(error)); + return Response.json({ error: 'The request failed. Refresh status before retrying.' }, { status: 500, headers: { 'Cache-Control': 'no-store' } }); + } + } let route = `${request.method} ${url.pathname}`; if (!['POST /run', 'POST /arm', 'POST /pause', 'GET /status', 'GET /list'].includes(route)) return new Response('Not found', { status: 404 });