Cloud container: ACCESS=public, a public Plugin Studio with the admin parts behind a password
The container needed an OIDC provider to run PocketBase at all. With ACCESS=public: - The Studio, every map and the backend's public API are open, with no sign-in. Vite owns the public port and forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs, whose gates decide. - /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER / ADMIN_PASSWORD over HTTP Basic, compared in constant time. The container refuses to start without ADMIN_PASSWORD. In this mode X-Forwarded-Groups is never read: with no oauth2-proxy in front, it is whatever a visitor sends. - Rudder's voice engine stays off. Gated bundles (gatedProfiles) are refused even by URL: source, @fs, dev leaf. Vite's /__open-in-editor is refused. OIDC stays the other mode, unchanged. In BOTH modes the Studio's own /api/pb (the dev backend plugin) now goes through admin.mjs's /pb (DX_PB_URL). The dashboard and _superusers stay behind the same gates, and plugins that use PocketBase work in the container's Studio; they got a 503 before. Checked: bash -n; the mode selection (public / gated / open); a missing password stops the container. The admin gate and the public guard, taken from these files, pass 20/20: - password right / wrong / wrong user; - a spoofed groups header ignored; - 401 + Basic prompt; - gated mode unchanged; - no password = no admin; - every form of a gated path refused and the rest passed.
This commit is contained in:
parent
2a4424b666
commit
f1500a37c2
4 changed files with 118 additions and 20 deletions
|
|
@ -60,6 +60,30 @@ with the redirect URL `https://<your-host>/oauth2/callback`; PKCE (S256) is used
|
|||
|
||||
Secrets reach oauth2-proxy through its environment, never its command line.
|
||||
|
||||
## Public Studio (`ACCESS=public`)
|
||||
|
||||
For a team whose Plugin Studio should be open to everyone: no sign-in provider. The Studio, every map and the
|
||||
backend's public API are open. The admin parts ask for a username and password, set when you deploy.
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `ACCESS` | `public` turns this mode on. OIDC settings are then ignored; unset `ACCESS` to use the sign-in |
|
||||
| `ADMIN_USER`, `ADMIN_PASSWORD` | guard `/admin/` (publishing), PocketBase's dashboard `/pb/_/` and its `_superusers`. `ADMIN_PASSWORD` is required (the container refuses to start without it); use a long random one. `ADMIN_USER` defaults to `admin` |
|
||||
|
||||
| Route | Who |
|
||||
|---|---|
|
||||
| `/` | anyone: the Plugin Studio, with hot reload |
|
||||
| `/cdn/dx-maps/`, `/api-prod/api/` | anyone, as in the signed-in mode |
|
||||
| `/pb/api/` | anyone, as PocketBase's own record rules allow. People sign in with PocketBase's own accounts |
|
||||
| `/pb/_/`, `/pb/api/collections/_…`, `/admin/*` | `ADMIN_USER` / `ADMIN_PASSWORD` (the browser asks) |
|
||||
|
||||
Also in this mode:
|
||||
- Rudder's voice engine stays off.
|
||||
- Gated bundles (`gatedProfiles`) are refused even by URL.
|
||||
- Vite's `/__open-in-editor` is refused.
|
||||
|
||||
In both modes, the Studio's own `/api/pb` goes through the same gates as `/pb/`.
|
||||
|
||||
## Backend and production (v1, experimental)
|
||||
|
||||
With the sign-in on, the container also runs PocketBase and can publish a production build:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
|
||||
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { basename, join, normalize } from 'node:path'
|
||||
import { serveTreeFile } from './cdn-cache.mjs'
|
||||
|
|
@ -19,8 +20,25 @@ const CDN = '/data/cdn/current'
|
|||
let last = { state: 'idle' }
|
||||
|
||||
const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(',').map((g) => g.trim())
|
||||
const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g))
|
||||
// ACCESS=public (entry.sh): the Plugin Studio is open and nobody signs in, so X-Forwarded-Groups is whatever a
|
||||
// visitor sends and is never read. The admin parts (/admin/, PocketBase's dashboard, _superusers) ask for
|
||||
// ADMIN_USER / ADMIN_PASSWORD over HTTP Basic instead, compared in constant time.
|
||||
const PUBLIC = env.ACCESS === 'public'
|
||||
const ADMIN_USER = env.ADMIN_USER || 'admin'
|
||||
const same = (a, b) => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest())
|
||||
function passwordOk(req) {
|
||||
const h = req.headers.get('authorization') || ''
|
||||
if (!env.ADMIN_PASSWORD || !h.startsWith('Basic ')) return false
|
||||
const [user, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':')
|
||||
const userOk = same(user, ADMIN_USER), passOk = same(rest.join(':'), env.ADMIN_PASSWORD)
|
||||
return userOk && passOk
|
||||
}
|
||||
const isAdmin = (req) => (PUBLIC ? passwordOk(req) : ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g)))
|
||||
const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
// Refusing an admin part: in public mode a 401 that makes the browser ask for the password; with the sign-in, 403.
|
||||
const refuse = (msg) => (PUBLIC
|
||||
? new Response(msg + '\n', { status: 401, headers: { 'content-type': 'text/plain; charset=utf-8', 'www-authenticate': 'Basic realm="dx-maps admin", charset="UTF-8"' } })
|
||||
: text(msg, 403))
|
||||
|
||||
// fetch() hands back the body DECOMPRESSED, so the upstream Content-Encoding and Content-Length no longer describe
|
||||
// what is sent on: forwarding them made browsers try to gunzip plain HTML, and PocketBase's dashboard never rendered
|
||||
|
|
@ -165,18 +183,20 @@ Bun.serve({
|
|||
if (p.startsWith('/pb/')) {
|
||||
const bp = backendPath(p)
|
||||
if (bp === null) return text('bad path', 400)
|
||||
if ((bp === '/pb/_' || bp.startsWith('/pb/_/')) && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403)
|
||||
if ((bp === '/pb/_' || bp.startsWith('/pb/_/')) && !isAdmin(req)) return refuse('the PocketBase dashboard is for admins')
|
||||
// The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these
|
||||
// paths to the public (its skip routes exclude names starting with _), so the groups header is its own.
|
||||
if (bp.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403)
|
||||
if (bp.startsWith('/pb/api/collections/_') && !isAdmin(req)) return refuse('for admins only')
|
||||
return proxy(req, url, '/pb/', 8090)
|
||||
}
|
||||
// With the sign-in, oauth2-proxy guards every /admin/ path; in public mode the admin password does.
|
||||
if (PUBLIC && p.startsWith('/admin/') && !isAdmin(req)) return refuse('the admin pages ask for the admin password')
|
||||
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
|
||||
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
||||
if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), prodSchema: prodSchema(), error: watch.error } })
|
||||
if (p === '/admin/build') {
|
||||
if (req.method !== 'POST') return text('POST only', 405)
|
||||
if (!isAdmin(req)) return text('builds are for team admins', 403)
|
||||
if (!isAdmin(req)) return refuse('builds are for team admins')
|
||||
if (last.state === 'building') return text('a build is already running', 409)
|
||||
startBuild('admin')
|
||||
return text('build started', 202)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
#!/bin/bash
|
||||
# dx-maps cloud dev server, run by the image's /boot.sh from the cloned repo (see Dockerfile).
|
||||
# 1. install, 2. pull BRANCH every PULL_INTERVAL s (Vite hot-reloads what changed; 0 = never pull),
|
||||
# 3. serve: Vite alone, or behind an OIDC sign-in when OIDC_ISSUER is set.
|
||||
# 3. serve: Vite alone; behind an OIDC sign-in when OIDC_ISSUER is set; or, with ACCESS=public, the Studio
|
||||
# open to everyone and the admin parts behind ADMIN_USER/ADMIN_PASSWORD.
|
||||
set -u
|
||||
cd /app/repo
|
||||
BRANCH="${BRANCH:-main}"; PORT="${PORT:-5173}"
|
||||
|
|
@ -47,6 +48,18 @@ fi
|
|||
|
||||
CONFIG=/app/repo/deployment/cloud/vite.cloud.config.mjs
|
||||
|
||||
# ACCESS=public: the Plugin Studio (and every map, and the backend's public API) is open to everyone, with no
|
||||
# sign-in. /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER / ADMIN_PASSWORD
|
||||
# instead (admin.mjs). OIDC stays the other mode: unset ACCESS and set OIDC_ISSUER.
|
||||
if [ "${ACCESS:-}" = public ]; then
|
||||
: "${ADMIN_PASSWORD:?ACCESS=public needs ADMIN_PASSWORD (and ADMIN_USER, default admin): it guards publishing, /admin/ and the PocketBase dashboard}"
|
||||
if [ "${RUDDER_ENGINE:-0}" = 1 ]; then
|
||||
echo "[access] public: Rudder's voice engine stays off (a public one would take anyone's microphone traffic)" >&2
|
||||
RUDDER_ENGINE=0
|
||||
fi
|
||||
[ -z "${OIDC_ISSUER:-}" ] || echo "[access] public: OIDC_ISSUER is ignored; unset ACCESS to use the sign-in" >&2
|
||||
fi
|
||||
|
||||
# Rudder (optional, RUDDER_ENGINE=1): its voice-to-text engine runs here, fed by the BROWSER's
|
||||
# microphone (no server mic). The image carries the runtime and the pinned model (/opt/rudder); a copy on
|
||||
# the persistent volume (/data/rudder) wins when it exists. Setup (checksums + building the engine) runs
|
||||
|
|
@ -79,25 +92,27 @@ if [ "${RUDDER_ENGINE:-0}" = 1 ]; then
|
|||
disown
|
||||
fi
|
||||
|
||||
if [ -z "${OIDC_ISSUER:-}" ]; then
|
||||
# Open, like a laptop: no sign-in.
|
||||
if [ -z "${OIDC_ISSUER:-}" ] && [ "${ACCESS:-}" != public ]; then
|
||||
# Open, like a laptop: no sign-in, no backend.
|
||||
export VITE_HOST=0.0.0.0 VITE_PORT="$PORT"
|
||||
exec bun x vite --config "$CONFIG"
|
||||
fi
|
||||
|
||||
# OIDC gate: oauth2-proxy owns the public port; Vite listens on localhost behind it, so the Plugin
|
||||
# Studio, every map and the hot-reload websocket are all behind the same sign-in.
|
||||
: "${OIDC_CLIENT_ID:?}" "${OIDC_CLIENT_SECRET:?}" "${OIDC_REDIRECT_URL:?}"
|
||||
if [ -z "${OIDC_ALLOWED_GROUPS:-}" ]; then
|
||||
echo "refusing to start: set OIDC_ALLOWED_GROUPS (comma-separated). With open sign-up, anyone could log in." >&2
|
||||
exit 1
|
||||
fi
|
||||
ALLOW=(); IFS=',' read -ra G <<< "$OIDC_ALLOWED_GROUPS"
|
||||
for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g"); done
|
||||
if [ "${ACCESS:-}" != public ]; then
|
||||
# OIDC gate: oauth2-proxy owns the public port; Vite listens on localhost behind it, so the Plugin
|
||||
# Studio, every map and the hot-reload websocket are all behind the same sign-in.
|
||||
: "${OIDC_CLIENT_ID:?}" "${OIDC_CLIENT_SECRET:?}" "${OIDC_REDIRECT_URL:?}"
|
||||
if [ -z "${OIDC_ALLOWED_GROUPS:-}" ]; then
|
||||
echo "refusing to start: set OIDC_ALLOWED_GROUPS (comma-separated). With open sign-up, anyone could log in." >&2
|
||||
exit 1
|
||||
fi
|
||||
ALLOW=(); IFS=',' read -ra G <<< "$OIDC_ALLOWED_GROUPS"
|
||||
for g in "${G[@]}"; do g="${g// /}"; [ -n "$g" ] && ALLOW+=(--allowed-group="$g"); done
|
||||
|
||||
# Secrets reach oauth2-proxy through its environment, never its command line.
|
||||
export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET"
|
||||
export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}"
|
||||
# Secrets reach oauth2-proxy through its environment, never its command line.
|
||||
export OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET"
|
||||
export OAUTH2_PROXY_COOKIE_SECRET="${OIDC_COOKIE_SECRET:-$(head -c 32 /dev/urandom | base64 | tr -d '=+/' | head -c 32)}"
|
||||
fi
|
||||
|
||||
# Backend: PocketBase DEV (/pb/, signed-in) and PROD (/api-prod/, public API only); data = the /data volume.
|
||||
# The superuser comes from env, never printed. SCHEMA: dev runs the migrations in this checkout (BRANCH, the
|
||||
|
|
@ -130,6 +145,18 @@ pocketbase serve --http 127.0.0.1:8090 --dir /data/pb-dev --migrationsDir "$MIG"
|
|||
echo "$(date -u +%FT%TZ) [pb-prod] exited ($?); starting again on $(basename "$(readlink "$PROD_MIG")")"; sleep 1
|
||||
done ) &
|
||||
bun /app/repo/deployment/cloud/admin.mjs &
|
||||
# The Studio's own /api/pb (the repo's dev backend plugin) goes through admin.mjs's /pb, so the same gates apply
|
||||
# (the dashboard and _superusers for admins only), and plugins that use PocketBase reach it here too. Without
|
||||
# this the plugin answered 503 (it found the port taken and started nothing).
|
||||
export DX_PB_URL="${DX_PB_URL:-http://127.0.0.1:5175/pb}"
|
||||
if [ "${ACCESS:-}" = public ]; then
|
||||
# Vite owns the public port; vite.cloud.config.mjs forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs.
|
||||
export DX_PUBLIC_STUDIO=1
|
||||
echo "[access] public: the Plugin Studio is open on :$PORT; /admin/, publishing and PocketBase's dashboard ask for ADMIN_USER/ADMIN_PASSWORD"
|
||||
VITE_HOST=0.0.0.0 VITE_PORT="$PORT" bun x vite --config "$CONFIG" &
|
||||
wait -n
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# TEAM PB: laptops reach pb-dev's API WITHOUT the site sign-in (DX_PB_URL=https://<host>/pb), and PocketBase
|
||||
# itself signs people in through the same OIDC provider, members of OIDC_ALLOWED_GROUPS only (the baked
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import base from '/app/repo/vite.config.js'
|
||||
import { mergeConfig } from 'vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { GATED_PROFILES } from '/app/repo/internal/_tree-name.mjs'
|
||||
|
||||
const env = process.env
|
||||
const SETUP_STATE = '/tmp/rudder-setup.state' // written by entry.sh: running -> done | failed
|
||||
|
|
@ -40,13 +41,38 @@ const rudderStarting = {
|
|||
},
|
||||
}
|
||||
|
||||
// ACCESS=public (entry.sh sets DX_PUBLIC_STUDIO=1): Vite owns the public port with no sign-in in front of it.
|
||||
// - The backend's paths go to admin.mjs, whose gates decide: /cdn/ and /api-prod/api/ public, /pb/ records
|
||||
// by PocketBase's own rules, and /pb/_/, _superusers and /admin/ for the admin password.
|
||||
// - GATED bundles (gatedProfiles, e.g. key-cities) must never reach a public surface. The Studio already leaves
|
||||
// them out of its list; here their source and dev pages are refused too, so a typed URL can't load them.
|
||||
// - /__open-in-editor (Vite's own) would start an editor process on this server for a visitor: refused.
|
||||
const PUBLIC = env.DX_PUBLIC_STUDIO === '1'
|
||||
const publicGuard = {
|
||||
name: 'cloud-public-guard',
|
||||
enforce: 'pre',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
if (!PUBLIC) return
|
||||
server.middlewares.use((req, res, next) => {
|
||||
let p = (req.url || '').split('?')[0]
|
||||
try { p = decodeURIComponent(p) } catch { /* keep it raw */ }
|
||||
const gated = GATED_PROFILES.some((g) => p.includes(`/plugin-designs/${g}/`) || p === `/__leaf/${g}` || p.startsWith(`/__leaf/${g}/`))
|
||||
if (!gated && !p.startsWith('/__open-in-editor')) return next()
|
||||
res.statusCode = 404
|
||||
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||
res.end('not found\n')
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default async (e) => {
|
||||
const b = typeof base === 'function' ? await base(e) : base
|
||||
const hmr = env.HMR_CLIENT_PORT
|
||||
? { clientPort: Number(env.HMR_CLIENT_PORT), protocol: env.HMR_PROTOCOL || 'wss' }
|
||||
: undefined
|
||||
return mergeConfig(b, {
|
||||
plugins: [rudderStarting],
|
||||
plugins: [publicGuard, rudderStarting],
|
||||
cacheDir: '/tmp/vite-cache',
|
||||
server: {
|
||||
host: env.VITE_HOST || '0.0.0.0',
|
||||
|
|
@ -54,6 +80,7 @@ export default async (e) => {
|
|||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
...(hmr ? { hmr } : {}),
|
||||
...(PUBLIC ? { proxy: { '^/(cdn|api-prod|pb|admin)/': { target: 'http://127.0.0.1:5175', ws: true } } } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue