mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-30 13:16:41 -05:00
Serve admin panel at protected dev custom domain
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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__');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user