Serve admin panel at protected dev custom domain

This commit is contained in:
Matt Isenhower
2026-09-07 14:05:40 -07:00
parent bfc35b9f99
commit 74cd311151
5 changed files with 22 additions and 11 deletions

View File

@@ -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
`<team>.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.

View File

@@ -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__');

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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": [