diff --git a/workers/updater/README.md b/workers/updater/README.md index 2930430..e98631d 100644 --- a/workers/updater/README.md +++ b/workers/updater/README.md @@ -120,7 +120,7 @@ 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 +`https://admin.dev.splatoon2.ink/` 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 @@ -141,16 +141,21 @@ 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: +The dev admin hostname is attached as a Worker Custom Domain and protected by +the "Splatoon2 dev admin" Access application using the existing owner-only policy. +`ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are stored as Worker secrets to keep +account-specific configuration out of the public repository. `/admin/` remains +an alias; the panel API stays under `/admin/api/`. + +For another deployment: 1. Create a Cloudflare Access self-hosted application protecting the entire - chosen admin hostname (for example `admin.splatoon2.ink`). Allow only the + chosen admin hostname. 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 + audience tag). All admin routes fail closed without these settings. +3. Attach the admin hostname to this Worker and deploy. Open `/` and verify login, status, and a deliberate test run. No Access application or production admin domain is created by the local preview. diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs index 8681ff8..a941f4d 100644 --- a/workers/updater/admin.spec.mjs +++ b/workers/updater/admin.spec.mjs @@ -15,6 +15,7 @@ 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 + '/'), new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post(), @@ -40,9 +41,9 @@ it('returns an accepted run immediately and reports overlap without starting ano 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 () => { +it.each(['/', '/admin/'])('serves the mobile panel at %s with no-store and a nonce-based content policy', async (path) => { verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); - const response = await adminRequest(new Request(url + '/admin/'), {}, vi.fn()); + const response = await adminRequest(new Request(url + path), {}, 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/src/admin/routes.mjs b/workers/updater/src/admin/routes.mjs index bae4f3b..beb689e 100644 --- a/workers/updater/src/admin/routes.mjs +++ b/workers/updater/src/admin/routes.mjs @@ -9,7 +9,7 @@ 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)) { + if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) { let nonce = crypto.randomUUID(); return new Response(page.replaceAll('__NONCE__', nonce), { headers: { diff --git a/workers/updater/src/index.mjs b/workers/updater/src/index.mjs index e107e6c..e9ed035 100644 --- a/workers/updater/src/index.mjs +++ b/workers/updater/src/index.mjs @@ -45,7 +45,8 @@ 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/')) { + if ((url.hostname === env.ADMIN_HOSTNAME && url.pathname === '/') || + url.pathname === '/admin' || url.pathname.startsWith('/admin/')) { try { return await adminRequest(request, env, () => scheduler(env)); } catch (error) { diff --git a/workers/updater/wrangler.jsonc b/workers/updater/wrangler.jsonc index 328d720..58262fc 100644 --- a/workers/updater/wrangler.jsonc +++ b/workers/updater/wrangler.jsonc @@ -11,6 +11,9 @@ // The workers.dev URL only exposes the authenticated manual trigger (see README). "workers_dev": true, "preview_urls": false, + "routes": [ + { "pattern": "admin.dev.splatoon2.ink", "custom_domain": true } + ], "triggers": { // Cron only checks the Scheduler alarm; it does not execute updater work. // The hourly update runs from the object's own alarm at :00:10; this cron is the watchdog @@ -28,7 +31,8 @@ "vars": { // Screenshots for social posts are rendered from the deployed site by Browser Rendering. // The API token is a secret (CLOUDFLARE_BROWSER_RUN_API_TOKEN). - "SITE_URL": "https://splatoon2.ink" + "SITE_URL": "https://splatoon2.ink", + "ADMIN_HOSTNAME": "admin.dev.splatoon2.ink" }, "images": { "binding": "IMAGES" }, "r2_buckets": [