@blakearoberts/visage 0.0.1-rc.9 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,71 +1,476 @@
1
- import { join, dirname } from 'node:path';
2
- import { spawn, spawnSync } from 'node:child_process';
3
- import { mkdirSync, openSync, chmodSync, rmSync, existsSync, createWriteStream, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
1
+ import { spawnSync, spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { isIP } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { readFileSync, mkdirSync, chmodSync, openSync, rmSync, appendFileSync, writeFileSync } from 'node:fs';
6
+ import { parse, stringify } from 'yaml';
4
7
  import { homedir } from 'node:os';
5
- import { Readable } from 'node:stream';
6
- import { pipeline } from 'node:stream/promises';
7
- import { stringify } from 'yaml';
8
8
  import { hashSync } from 'bcryptjs';
9
9
  import { Eta } from 'eta';
10
- import { createHash } from 'node:crypto';
11
10
 
12
- let stopRef;
13
- function startCompose(file) {
14
- stopRef?.();
15
- stopRef = undefined;
16
- const logs = join(dirname(file), 'logs');
17
- mkdirSync(logs, { recursive: true });
18
- const output = openSync(join(logs, 'compose.log'), 'w');
19
- const compose = [
20
- 'compose',
21
- `--project-name=${process.env.COMPOSE_PROJECT_NAME ?? 'visage'}`,
22
- `--file=${file}`,
23
- ];
24
- const env = { ...process.env, COMPOSE_MENU: 'false' };
25
- const opts = {
26
- cwd: dirname(file),
27
- stdio: ['ignore', output, output],
28
- env,
11
+ const VisageEdgeKeyHeader$1 = 'X-Visage-Edge-Key';
12
+ const DockerImages = parse(readFileSync(new URL('../docker-compose.images.yml', import.meta.url), 'utf8')).services;
13
+ const BaseServiceDex = {
14
+ image: DockerImages.dex.image,
15
+ command: ['dex', 'serve', '/etc/dex/dex.yaml'],
16
+ restart: 'always',
17
+ };
18
+ const BaseServiceNginx = {
19
+ image: DockerImages.nginx.image,
20
+ depends_on: ['oauth2_proxy'],
21
+ extra_hosts: ['host.docker.internal:host-gateway'],
22
+ restart: 'always',
23
+ };
24
+ const BaseServiceOAuth2Proxy = {
25
+ image: DockerImages.oauth2_proxy.image,
26
+ command: ['--config', '/etc/oauth2-proxy/config.yml'],
27
+ extra_hosts: ['host.docker.internal:host-gateway'],
28
+ restart: 'always',
29
+ };
30
+ const DefaultProxyPolicy = {
31
+ auth: { enabled: true, forward: false, redirect: false },
32
+ csrf: 'api',
33
+ headers: {
34
+ Host: '$host',
35
+ // Mitigate header injection by clearing auth headers.
36
+ Authorization: '""',
37
+ Cookie: '""',
38
+ 'X-Auth-Request-User': '""',
39
+ 'X-Auth-Request-Email': '""',
40
+ 'X-Auth-Request-Groups': '""',
41
+ 'X-Auth-Request-Preferred-Username': '""',
42
+ // Add common proxy headers.
43
+ 'X-Real-IP': '$remote_addr',
44
+ 'X-Forwarded-For': '$proxy_add_x_forwarded_for',
45
+ 'X-Forwarded-Proto': '$scheme',
46
+ },
47
+ directives: {
48
+ proxy_buffer_size: ['8k'],
49
+ },
50
+ };
51
+ const BaseUpstreamOauth2Proxy = {
52
+ host: 'oauth2_proxy',
53
+ scheme: 'http',
54
+ port: 4180,
55
+ locations: {
56
+ '/oauth2/': {
57
+ auth: { enabled: false, forward: false, redirect: false },
58
+ csrf: false,
59
+ headers: {
60
+ ...DefaultProxyPolicy.headers,
61
+ Cookie: '$http_cookie', // Forward session cookie.
62
+ 'X-Auth-Request-Redirect': '$request_uri',
63
+ },
64
+ directives: { ...DefaultProxyPolicy.directives },
65
+ },
66
+ '/oauth2/sign_out': {
67
+ auth: { enabled: false, forward: false, redirect: false },
68
+ csrf: false,
69
+ headers: {
70
+ ...DefaultProxyPolicy.headers,
71
+ Cookie: '$http_cookie', // Forward session cookie.
72
+ 'X-Auth-Request-Redirect': '/',
73
+ },
74
+ directives: { ...DefaultProxyPolicy.directives },
75
+ },
76
+ },
77
+ };
78
+ const DefaultCookiePolicy = {
79
+ cookie_expire: '8h',
80
+ cookie_refresh: '15m',
81
+ cookie_path: '/',
82
+ };
83
+ const DefaultDexUsers = [
84
+ { email: 'user@example.com', password: 'pass' },
85
+ ];
86
+ const DefaultOAuth2Client = {
87
+ id: 'visage',
88
+ secret: 'visage-secret',
89
+ scopes: ['openid', 'email', 'profile', 'offline_access'],
90
+ emailDomains: ['example.com']};
91
+ function resolveOptions(options) {
92
+ const { host = 'localhost', port = 9001, cookie = {}, idp = {}, oauth2 = {}, } = options;
93
+ const cookieName = cookie.name ?? 'sess';
94
+ const publicClient = oauth2.clientSecret === null;
95
+ const services = resolveServicesOptions(options.services);
96
+ return {
97
+ host,
98
+ port,
99
+ cookie: {
100
+ ...DefaultCookiePolicy,
101
+ cookie_name: cookie.domains === undefined
102
+ ? cookieName.startsWith('__Host-')
103
+ ? cookieName
104
+ : `__Host-${cookieName}`
105
+ : cookieName,
106
+ ...(cookie.expire === undefined ? {} : { cookie_expire: cookie.expire }),
107
+ ...(cookie.refresh === undefined
108
+ ? {}
109
+ : { cookie_refresh: cookie.refresh }),
110
+ ...(cookie.domains === undefined
111
+ ? {}
112
+ : { cookie_domains: cookie.domains }),
113
+ ...(cookie.path === undefined ? {} : { cookie_path: cookie.path }),
114
+ },
115
+ idp: 'issuer' in idp
116
+ ? idp
117
+ : {
118
+ dex: {
119
+ ...(idp.expiry ? { expiry: idp.expiry } : {}),
120
+ users: (idp.users ?? DefaultDexUsers).map((user) => ({
121
+ email: user.email,
122
+ password: user.password,
123
+ username: user.username ?? user.email.split('@', 1)[0],
124
+ userID: user.userID ?? user.email,
125
+ })),
126
+ },
127
+ },
128
+ oauth2: {
129
+ id: oauth2.clientId ?? DefaultOAuth2Client.id,
130
+ ...(publicClient
131
+ ? {}
132
+ : { secret: oauth2.clientSecret ?? DefaultOAuth2Client.secret }),
133
+ scopes: oauth2.scopes ?? DefaultOAuth2Client.scopes,
134
+ emailDomains: oauth2.emailDomains ?? DefaultOAuth2Client.emailDomains,
135
+ public: publicClient,
136
+ },
137
+ services,
138
+ upstreams: resolveUpstreamsOptions(services, options.upstreams),
29
139
  };
30
- const up = [
31
- ...compose,
32
- 'up',
33
- '--abort-on-container-failure',
34
- '--no-color',
35
- '--remove-orphans',
36
- ];
37
- const child = spawn('docker', up, opts);
38
- const stop = () => {
39
- if (stopRef !== stop)
140
+ }
141
+ function resolveServicesOptions(services = {}) {
142
+ return {
143
+ ...services,
144
+ nginx: {
145
+ ...BaseServiceNginx,
146
+ ...{
147
+ ...(services.nginx ?? {}),
148
+ extra_hosts: [
149
+ ...BaseServiceNginx.extra_hosts,
150
+ ...(services.nginx?.extra_hosts ?? []),
151
+ ],
152
+ },
153
+ },
154
+ oauth2_proxy: {
155
+ ...BaseServiceOAuth2Proxy,
156
+ ...{
157
+ ...(services.oauth2_proxy ?? {}),
158
+ extra_hosts: [
159
+ ...BaseServiceOAuth2Proxy.extra_hosts,
160
+ ...(services.oauth2_proxy?.extra_hosts ?? []),
161
+ ],
162
+ },
163
+ },
164
+ };
165
+ }
166
+ function resolveUpstreamsOptions(services, upstreams = {}) {
167
+ return {
168
+ ...Object.fromEntries(Object.entries(services)
169
+ .filter(([name]) =>
170
+ // Exclude base services handled separately.
171
+ name !== 'dex' && name !== 'nginx' && name !== 'oauth2_proxy')
172
+ .map(([name, service]) => [
173
+ name,
174
+ resolveUpstreamOptions(name, service.upstream, false),
175
+ ])),
176
+ ...Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => {
177
+ if (name === 'vite') {
178
+ const vite = resolveViteUpstreamOptions(upstream);
179
+ return [name, resolveUpstreamOptions('vite', vite, true)];
180
+ }
181
+ return [
182
+ name,
183
+ resolveUpstreamOptions(name, upstream, services[name] === undefined),
184
+ ];
185
+ })),
186
+ };
187
+ }
188
+ const BaseViteUpstreamRootLocation = {
189
+ auth: { enabled: true, forward: false, redirect: true },
190
+ csrf: 'app',
191
+ headers: {
192
+ Host: '$host',
193
+ Upgrade: '$http_upgrade',
194
+ Connection: '$connection_upgrade',
195
+ 'X-Auth-Request-User': '$auth_user',
196
+ 'X-Auth-Request-Email': '$auth_email',
197
+ },
198
+ directives: {
199
+ proxy_http_version: ['1.1'],
200
+ proxy_read_timeout: ['1h'],
201
+ },
202
+ };
203
+ function resolveViteUpstreamOptions(upstream) {
204
+ const base = BaseViteUpstreamRootLocation;
205
+ const root = upstream.locations?.['/'];
206
+ return {
207
+ host: 'host.docker.internal',
208
+ scheme: 'http',
209
+ ...upstream,
210
+ locations: {
211
+ ...(upstream.locations ?? {}),
212
+ '/': root === undefined
213
+ ? { ...base }
214
+ : {
215
+ auth: { ...base.auth, ...root.auth },
216
+ csrf: root.csrf ?? base.csrf,
217
+ headers: {
218
+ ...base.headers,
219
+ ...root.headers,
220
+ },
221
+ directives: {
222
+ ...base.directives,
223
+ ...root.directives,
224
+ },
225
+ },
226
+ },
227
+ };
228
+ }
229
+ function resolveUpstreamOptions(name, upstream = {}, external) {
230
+ const scheme = upstream.scheme ?? (external ? 'https' : 'http');
231
+ const host = upstream.host ?? name;
232
+ return {
233
+ ...upstream,
234
+ scheme,
235
+ host,
236
+ port: upstream.port ?? (scheme === 'https' ? 443 : 80),
237
+ locations: {
238
+ ...Object.fromEntries(Object.entries(upstream.locations ?? { [`/${name}/`]: {} }).map(([path, policy]) => [
239
+ path,
240
+ resolveUpstreamLocationOptions(name, host, policy, external),
241
+ ])),
242
+ },
243
+ };
244
+ }
245
+ function resolveUpstreamLocationOptions(name, host, location, external) {
246
+ const auth = resolveAuthPolicy(location.auth, external && name !== 'vite');
247
+ return {
248
+ ...DefaultProxyPolicy,
249
+ ...location,
250
+ auth,
251
+ csrf: location.csrf ?? (auth.enabled ? 'api' : false),
252
+ headers: {
253
+ ...DefaultProxyPolicy.headers,
254
+ ...(external ? { Host: host } : {}),
255
+ ...(auth.enabled && auth.forward === 'id'
256
+ ? { Authorization: '$authorization' }
257
+ : {}),
258
+ ...(auth.enabled && auth.forward === 'access'
259
+ ? { Authorization: '"Bearer $access_token"' }
260
+ : {}),
261
+ ...(location.headers ?? {}),
262
+ },
263
+ directives: {
264
+ ...DefaultProxyPolicy.directives,
265
+ ...Object.fromEntries(Object.entries(location.directives ?? {}).map(([name, value]) => [
266
+ name,
267
+ Array.isArray(value) ? value : [value],
268
+ ])),
269
+ },
270
+ };
271
+ }
272
+ function resolveAuthPolicy(auth = {}, external) {
273
+ return {
274
+ enabled: auth.enabled ?? true,
275
+ forward: auth.forward === true
276
+ ? external
277
+ ? 'access'
278
+ : 'id'
279
+ : (auth.forward ?? false),
280
+ redirect: auth.redirect ?? false,
281
+ };
282
+ }
283
+ function resolveConfig(options, cache, edgeKey) {
284
+ const idp = resolveIdpConfig(options);
285
+ const end_session_endpoint = idp.oidc.end_session_endpoint;
286
+ const upstreams = {
287
+ ...(end_session_endpoint === undefined
288
+ ? { oauth2_proxy: { ...BaseUpstreamOauth2Proxy } }
289
+ : {
290
+ oauth2_proxy: {
291
+ ...BaseUpstreamOauth2Proxy,
292
+ locations: {
293
+ ...BaseUpstreamOauth2Proxy.locations,
294
+ '/oauth2/sign_out': {
295
+ ...BaseUpstreamOauth2Proxy.locations['/oauth2/sign_out'],
296
+ headers: {
297
+ ...BaseUpstreamOauth2Proxy.locations['/oauth2/sign_out']
298
+ .headers,
299
+ 'X-Auth-Request-Redirect': JSON.stringify(end_session_endpoint +
300
+ (end_session_endpoint.includes('?') ? '&' : '?') +
301
+ 'id_token_hint={id_token}&post_logout_redirect_uri=' +
302
+ encodeURIComponent(`https://${options.host}:${options.port}/`)),
303
+ },
304
+ },
305
+ },
306
+ },
307
+ }),
308
+ ...idp.upstream,
309
+ ...options.upstreams,
310
+ ...(edgeKey && options.upstreams.vite
311
+ ? { vite: resolveViteEdgeKeyConfig(options.upstreams.vite, edgeKey) }
312
+ : {}),
313
+ };
314
+ return {
315
+ host: options.host,
316
+ port: options.port,
317
+ cookie: options.cookie,
318
+ idp,
319
+ oauth2: options.oauth2,
320
+ cache,
321
+ files: {
322
+ certs: ['./certs', '/etc/nginx/certs'],
323
+ compose: './compose.yaml',
324
+ dex: ['./dex.yaml', '/etc/dex/dex.yaml'],
325
+ nginx: ['./nginx.conf', '/etc/nginx/nginx.conf'],
326
+ oauth2Proxy: ['./oauth2-proxy.yml', '/etc/oauth2-proxy/config.yml'],
327
+ },
328
+ secrets: {
329
+ cookieSecret: 'OAUTH2_PROXY_COOKIE_SECRET',
330
+ clientSecret: 'OAUTH2_CLIENT_SECRET',
331
+ },
332
+ network: {
333
+ name: process.env.COMPOSE_PROJECT_NAME ?? 'visage',
334
+ trustedProxyIps: [],
335
+ },
336
+ services: {
337
+ ...('dex' in idp
338
+ ? {
339
+ dex: BaseServiceDex,
340
+ nginx: { ...BaseServiceNginx, depends_on: ['dex', 'oauth2_proxy'] },
341
+ oauth2_proxy: { ...BaseServiceOAuth2Proxy, depends_on: ['dex'] },
342
+ }
343
+ : { nginx: BaseServiceNginx, oauth2_proxy: BaseServiceOAuth2Proxy }),
344
+ ...Object.fromEntries(Object.entries(options.services).map(([name, { upstream: _upstream, ...service }]) => [
345
+ name,
346
+ { restart: 'on-failure', ...service },
347
+ ])),
348
+ },
349
+ upstreams: Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => {
350
+ const external = options.upstreams[name] !== undefined &&
351
+ options.services[name] === undefined;
352
+ return [
353
+ name,
354
+ { ...upstream, external },
355
+ ];
356
+ })),
357
+ };
358
+ }
359
+ function resolveViteEdgeKeyConfig(upstream, edgeKey) {
360
+ return {
361
+ ...upstream,
362
+ locations: Object.fromEntries(Object.entries(upstream.locations).map(([path, policy]) => [
363
+ path,
364
+ {
365
+ ...policy,
366
+ headers: {
367
+ [VisageEdgeKeyHeader$1]: edgeKey,
368
+ ...policy.headers,
369
+ },
370
+ },
371
+ ])),
372
+ };
373
+ }
374
+ function resolveIdpConfig({ host, port, idp, }) {
375
+ if ('dex' in idp) {
376
+ return {
377
+ dex: {
378
+ expiry: idp.dex.expiry,
379
+ users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
380
+ email: user.email,
381
+ password: user.password,
382
+ username: user.username ?? user.email.split('@', 1)[0],
383
+ userID: user.userID ?? user.email,
384
+ })),
385
+ },
386
+ oidc: {
387
+ issuer: `https://${host}:${port}/dex`,
388
+ authorization: `https://${host}:${port}/dex/auth`,
389
+ token: 'http://dex:5556/dex/token',
390
+ jwks: 'http://dex:5556/dex/keys',
391
+ },
392
+ upstream: {
393
+ dex: {
394
+ host: 'dex',
395
+ scheme: 'http',
396
+ port: 5556,
397
+ locations: {
398
+ '/dex/': {
399
+ auth: { enabled: false, forward: false, redirect: false },
400
+ csrf: false,
401
+ headers: { ...DefaultProxyPolicy.headers },
402
+ directives: { ...DefaultProxyPolicy.directives },
403
+ },
404
+ },
405
+ },
406
+ },
407
+ };
408
+ }
409
+ const issuer = new URL(idp.issuer);
410
+ const oidc = {
411
+ issuer: idp.issuer,
412
+ ...(idp.end_session_endpoint === undefined
413
+ ? {}
414
+ : { end_session_endpoint: idp.end_session_endpoint }),
415
+ };
416
+ return {
417
+ oidc: !idp.authorization && !idp.token && !idp.jwks
418
+ ? oidc
419
+ : {
420
+ ...oidc,
421
+ authorization: idp.issuer + (idp.authorization ?? '/auth'),
422
+ token: idp.issuer + (idp.token ?? '/token'),
423
+ jwks: idp.issuer + (idp.jwks ?? '/keys'),
424
+ },
425
+ upstream: {
426
+ idp: {
427
+ scheme: issuer.protocol === 'https:' ? 'https' : 'http',
428
+ host: issuer.hostname,
429
+ port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
430
+ locations: {},
431
+ },
432
+ },
433
+ };
434
+ }
435
+
436
+ const VisageEdgeKeyHeader = 'X-Visage-Edge-Key';
437
+ function createVisageMiddleware(edgeKey) {
438
+ return function visageMiddleware(request, response, next) {
439
+ if (isVisageEdgeRequest(request, edgeKey)) {
440
+ next();
40
441
  return;
41
- stopRef = undefined;
42
- child.kill();
43
- const down = [...compose, 'down', '--remove-orphans'];
44
- spawnSync('docker', down, opts);
442
+ }
443
+ response.statusCode = 403;
444
+ response.end('Forbidden');
45
445
  };
46
- stopRef = stop;
47
- return stop;
446
+ }
447
+ function createVisageUpgradeHandler(edgeKey) {
448
+ return function visageUpgrade(request, socket) {
449
+ if (isVisageEdgeRequest(request, edgeKey)) {
450
+ return;
451
+ }
452
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
453
+ socket.destroy();
454
+ };
455
+ }
456
+ function isVisageEdgeRequest(request, edgeKey) {
457
+ const header = request.headers[VisageEdgeKeyHeader.toLowerCase()];
458
+ return typeof header === 'string' && header === edgeKey;
48
459
  }
49
460
 
50
461
  const CACHE_HOME = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
51
- async function ensureCerts({ certs, hostname }) {
462
+ async function ensureCerts(config) {
52
463
  const CAROOT = join(CACHE_HOME, 'visage/ca');
53
464
  mkdirSync(CAROOT, { recursive: true, mode: 0o700 });
54
465
  chmodSync(CAROOT, 0o700);
55
- const mkcert = await ensureMkCert();
56
- const logs = join(dirname(certs), 'logs');
57
- mkdirSync(logs, { recursive: true });
58
- const log = join(logs, 'mkcert.log');
59
- const output = openSync(log, 'w');
466
+ const mkcert = resolveMkcert();
467
+ const out = openSync(join(config.cache, 'logs', 'mkcert.log'), 'w');
60
468
  const env = { CAROOT, TRUST_STORES: 'system', ...process.env };
61
469
  const tty = process.stdin.isTTY;
62
- const stdio = [
63
- tty ? 'inherit' : 'ignore',
64
- output,
65
- output,
66
- ];
470
+ const stdio = [tty ? 'inherit' : 'ignore', out, out];
67
471
  if (process.env.CI !== 'true') {
68
- // mkcert -install is idempotent; CA files alone do not prove trust-store state.
472
+ // mkcert -install is idempotent;
473
+ // CA files alone don't prove trust-store state.
69
474
  const result = spawnSync(mkcert, ['-install'], { env, stdio });
70
475
  if (result.error)
71
476
  throw result.error;
@@ -73,12 +478,14 @@ async function ensureCerts({ certs, hostname }) {
73
478
  throw new Error('Failed to install CA');
74
479
  }
75
480
  }
481
+ const certs = join(config.cache, config.files.certs[0]);
76
482
  const cert = join(certs, 'tls.crt');
77
483
  const key = join(certs, 'tls.key');
78
- mkdirSync(certs, { recursive: true });
484
+ mkdirSync(certs, { recursive: true, mode: 0o700 });
485
+ chmodSync(certs, 0o700);
79
486
  rmSync(cert, { force: true });
80
487
  rmSync(key, { force: true });
81
- const names = [...new Set([hostname, 'localhost', '127.0.0.1', '::1'])];
488
+ const names = [...new Set([config.host, 'localhost', '127.0.0.1', '::1'])];
82
489
  const args = ['-cert-file', cert, '-key-file', key, ...names];
83
490
  const result = spawnSync(mkcert, args, { env, stdio });
84
491
  if (result.error)
@@ -86,32 +493,128 @@ async function ensureCerts({ certs, hostname }) {
86
493
  if (result.status !== 0) {
87
494
  throw new Error('Failed to generate TLS certificates');
88
495
  }
496
+ chmodSync(cert, 0o600);
497
+ chmodSync(key, 0o600);
498
+ }
499
+ function resolveMkcert() {
500
+ const env = process.env;
501
+ const options = { encoding: 'utf8', env };
502
+ const mkcert = findMkcert();
503
+ const result = spawnSync(mkcert, ['-version'], options);
504
+ if (result.error || result.status !== 0) {
505
+ throw new Error([
506
+ `Visage found mkcert at "${mkcert}", but could not execute it.`,
507
+ '',
508
+ mkcertInstallInstructions(),
509
+ ].join('\n'));
510
+ }
511
+ return mkcert;
89
512
  }
90
- async function ensureMkCert() {
91
- const bin = join(CACHE_HOME, 'visage/bin');
92
- const file = join(bin, `mkcert-${process.platform}-${process.arch}`);
93
- if (existsSync(file))
94
- return file;
95
- mkdirSync(bin, { recursive: true });
96
- const base = 'https://dl.filippo.io/mkcert/latest';
97
- const arch = process.arch === 'x64' ? 'amd64' : process.arch;
98
- const params = `?for=${process.platform}/${arch}`;
99
- const url = new URL(params, base);
100
- const response = await fetch(url);
101
- if (!response.ok || !response.body) {
102
- throw new Error('Failed to download mkcert');
513
+ function findMkcert() {
514
+ const env = process.env;
515
+ const exec = env.VISAGE_MKCERT || 'mkcert';
516
+ const options = { encoding: 'utf8', env };
517
+ const result = process.platform === 'win32'
518
+ ? spawnSync('where', [exec], options)
519
+ : spawnSync('sh', ['-c', `command -v ${exec}`], options);
520
+ const path = result.stdout
521
+ .split(/\r?\n/)
522
+ .map((line) => line.trim())
523
+ .find(Boolean);
524
+ if (result.error || result.status !== 0 || !path) {
525
+ throw new Error([
526
+ 'Visage requires mkcert to configure HTTPS, but mkcert was not found.',
527
+ '',
528
+ mkcertInstallInstructions(),
529
+ ].join('\n'));
103
530
  }
104
- await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
105
- chmodSync(file, 0o755);
106
- return file;
531
+ return path;
532
+ }
533
+ function mkcertInstallInstructions() {
534
+ const common = [
535
+ 'After installing mkcert, run `mkcert -install` once when local ' +
536
+ 'certificates should be trusted.',
537
+ 'Install docs: https://github.com/FiloSottile/mkcert#installation',
538
+ 'Set VISAGE_MKCERT=/path/to/mkcert to use a custom executable.',
539
+ ];
540
+ const platform = process.platform;
541
+ if (platform === 'darwin') {
542
+ return [
543
+ 'Install mkcert with Homebrew:',
544
+ ' brew install mkcert',
545
+ ' brew install nss # optional, for Firefox',
546
+ ...common,
547
+ ].join('\n');
548
+ }
549
+ if (platform === 'win32') {
550
+ return [
551
+ 'Install mkcert with Chocolatey or Scoop:',
552
+ ' choco install mkcert',
553
+ ' scoop install mkcert',
554
+ ...common,
555
+ ].join('\n');
556
+ }
557
+ if (platform === 'linux') {
558
+ return [
559
+ 'Install mkcert with your Linux package manager. Common commands:',
560
+ ' sudo apt install mkcert libnss3-tools',
561
+ ' sudo dnf install mkcert nss-tools',
562
+ ' sudo pacman -Syu mkcert nss',
563
+ ...common,
564
+ ].join('\n');
565
+ }
566
+ return [
567
+ 'Install mkcert for your operating system and make it available on PATH.',
568
+ ...common,
569
+ ].join('\n');
570
+ }
571
+
572
+ let stopRef;
573
+ function startCompose(config) {
574
+ stopRef?.();
575
+ stopRef = undefined;
576
+ const file = join(config.cache, config.files.compose);
577
+ const logs = join(config.cache, 'logs');
578
+ const output = openSync(join(logs, 'compose.log'), 'w');
579
+ const compose = [
580
+ 'compose',
581
+ '--ansi=never',
582
+ `--file=${file}`,
583
+ `--project-name=${process.env.COMPOSE_PROJECT_NAME ?? 'visage'}`,
584
+ ];
585
+ const env = {
586
+ COMPOSE_MENU: 'false',
587
+ [config.secrets.cookieSecret]: randomBytes(32).toString('base64url'),
588
+ ...(config.oauth2.public
589
+ ? {}
590
+ : { [config.secrets.clientSecret]: config.oauth2.secret }),
591
+ ...process.env,
592
+ };
593
+ const opts = {
594
+ cwd: config.cache,
595
+ stdio: ['ignore', output, output],
596
+ env,
597
+ };
598
+ const up = [...compose, 'up', '--remove-orphans'];
599
+ const child = spawn('docker', up, opts);
600
+ const stop = () => {
601
+ if (stopRef !== stop)
602
+ return;
603
+ stopRef = undefined;
604
+ child.kill();
605
+ const down = [...compose, 'down', '--remove-orphans'];
606
+ spawnSync('docker', down, opts);
607
+ };
608
+ stopRef = stop;
609
+ return stop;
107
610
  }
108
611
 
109
612
  const HOSTS_FILE = '/etc/hosts';
110
- function ensureHostEntry(hostname) {
111
- if (!hostname ||
112
- hostname.trim() !== hostname ||
113
- hostname.includes('/') ||
114
- hostname.includes(':')) {
613
+ function ensureHostEntry({ host }) {
614
+ if (!host ||
615
+ host.trim() !== host ||
616
+ host.includes('/') ||
617
+ host.includes(':')) {
115
618
  throw new Error('Invalid hostname');
116
619
  }
117
620
  const contents = readFileSync(HOSTS_FILE, 'utf8');
@@ -121,7 +624,7 @@ function ensureHostEntry(hostname) {
121
624
  continue;
122
625
  }
123
626
  const [address, ...names] = uncommented.split(/\s+/);
124
- if (!names.includes(hostname)) {
627
+ if (!names.includes(host)) {
125
628
  continue;
126
629
  }
127
630
  if (address === '127.0.0.1' || address === '::1') {
@@ -131,7 +634,7 @@ function ensureHostEntry(hostname) {
131
634
  throw new Error('Hosts file contains a conflicting entry');
132
635
  }
133
636
  const prefix = contents.endsWith('\n') ? '' : '\n';
134
- const entry = `${prefix}127.0.0.1\t${hostname} # visage\n`;
637
+ const entry = `${prefix}127.0.0.1\t${host} # visage\n`;
135
638
  try {
136
639
  appendFileSync(HOSTS_FILE, entry);
137
640
  return;
@@ -151,6 +654,65 @@ function ensureHostEntry(hostname) {
151
654
  }
152
655
  }
153
656
 
657
+ function ensureNginxNetwork(config) {
658
+ const exists = spawnSync('docker', [
659
+ 'network',
660
+ 'ls',
661
+ '--filter',
662
+ `name=${config.network.name}`,
663
+ '--format',
664
+ '{{ .Name }}',
665
+ ], { encoding: 'utf-8' });
666
+ if (exists.error)
667
+ throw exists.error;
668
+ if (exists.status !== 0) {
669
+ console.error(exists.stderr);
670
+ throw new Error('Failed to list Docker network');
671
+ }
672
+ if (exists.stdout) {
673
+ return {
674
+ ...config,
675
+ network: {
676
+ ...config.network,
677
+ trustedProxyIps: inspectNetwork(config.network.name),
678
+ },
679
+ };
680
+ }
681
+ const create = spawnSync('docker', ['network', 'create', '--driver', 'bridge', config.network.name], { encoding: 'utf-8' });
682
+ if (create.error)
683
+ throw create.error;
684
+ if (create.status !== 0) {
685
+ console.error(create.stderr);
686
+ throw new Error('Failed to create Docker network');
687
+ }
688
+ return {
689
+ ...config,
690
+ network: {
691
+ ...config.network,
692
+ trustedProxyIps: inspectNetwork(config.network.name),
693
+ },
694
+ };
695
+ }
696
+ function inspectNetwork(name) {
697
+ const result = spawnSync('docker', [
698
+ 'network',
699
+ 'inspect',
700
+ '--format',
701
+ '{{range .IPAM.Config}}{{println .Subnet}}{{end}}',
702
+ name,
703
+ ], { encoding: 'utf-8' });
704
+ if (result.error)
705
+ throw result.error;
706
+ if (result.status !== 0) {
707
+ console.error(result.stderr);
708
+ throw new Error('Failed to inspect Docker network');
709
+ }
710
+ return result.stdout
711
+ .split(/\r?\n/)
712
+ .map((line) => line.trim())
713
+ .filter(Boolean);
714
+ }
715
+
154
716
  function writeComposeConfig(config) {
155
717
  const file = join(config.cache, config.files.compose);
156
718
  const render = renderComposeConfig(config);
@@ -159,29 +721,44 @@ function writeComposeConfig(config) {
159
721
  function renderComposeConfig(config) {
160
722
  const { dex, nginx, oauth2_proxy, ...services } = config.services;
161
723
  return stringify({
724
+ networks: { default: { external: true, name: config.network.name } },
725
+ secrets: {
726
+ [config.secrets.cookieSecret]: {
727
+ environment: config.secrets.cookieSecret,
728
+ },
729
+ ...(config.oauth2.public
730
+ ? {}
731
+ : {
732
+ [config.secrets.clientSecret]: {
733
+ environment: config.secrets.clientSecret,
734
+ },
735
+ }),
736
+ },
162
737
  services: {
163
- ...(config.idp.dex !== undefined
738
+ ...('dex' in config.idp
164
739
  ? {
165
740
  dex: {
166
741
  ...config.services.dex,
167
742
  volumes: [`${config.files.dex[0]}:${config.files.dex[1]}:ro`],
743
+ ...(config.oauth2.public
744
+ ? {}
745
+ : { secrets: [config.secrets.clientSecret] }),
168
746
  },
169
747
  }
170
748
  : {}),
171
749
  nginx: {
172
750
  ...config.services.nginx,
173
- ports: [`${config.port}:${config.port}`],
751
+ ports: [`127.0.0.1:${config.port}:${config.port}`],
174
752
  volumes: [config.files.certs, config.files.nginx].map(([from, to]) => `${from}:${to}:ro`),
175
753
  },
176
754
  oauth2_proxy: {
177
755
  ...config.services.oauth2_proxy,
178
756
  volumes: [
179
757
  `${config.files.oauth2Proxy[0]}:${config.files.oauth2Proxy[1]}:ro`,
180
- ...(config.oauth2.public
181
- ? [
182
- `${config.files.oauth2ProxyClientSecret[0]}:${config.files.oauth2ProxyClientSecret[1]}:ro`,
183
- ]
184
- : []),
758
+ ],
759
+ secrets: [
760
+ config.secrets.cookieSecret,
761
+ ...(config.oauth2.public ? [] : [config.secrets.clientSecret]),
185
762
  ],
186
763
  },
187
764
  ...services,
@@ -190,38 +767,36 @@ function renderComposeConfig(config) {
190
767
  }
191
768
 
192
769
  function writeDexConfig(config) {
193
- const file = join(config.cache, config.files.dex[0]);
194
770
  const render = renderDexConfig(config);
771
+ const file = join(config.cache, config.files.dex[0]);
195
772
  writeFileSync(file, render, 'utf-8');
196
773
  }
197
774
  function renderDexConfig(config) {
198
- const { idp } = config;
199
- if (idp.dex === undefined) {
200
- throw new Error('Dex config is required to render Dex');
201
- }
202
- const origin = `https://${config.host}:${config.port}`;
203
- const redirect = `${origin}/oauth2/callback`;
204
- const upstream = config.upstreams[idp.upstream];
775
+ if (!('dex' in config.idp))
776
+ throw new Error('Dex config missing');
777
+ const { host, port, oauth2, secrets, idp: { dex: { expiry, users }, oidc, upstream, }, } = config;
205
778
  return stringify({
206
- issuer: idp.issuer,
779
+ issuer: oidc.issuer,
207
780
  storage: { type: 'memory' },
208
- web: { http: `0.0.0.0:${upstream.port}` },
781
+ web: { http: `0.0.0.0:${upstream.dex.port}` },
209
782
  oauth2: { skipApprovalScreen: true },
210
783
  staticClients: [
211
784
  {
212
- id: config.oauth2.id,
213
- name: 'Visage',
214
- ...(config.oauth2.secret === undefined
785
+ id: oauth2.id,
786
+ name: oauth2.id,
787
+ ...(oauth2.public
215
788
  ? { public: true }
216
- : { secret: config.oauth2.secret }),
217
- redirectURIs: [redirect],
789
+ : {
790
+ secret: `{{ file.Read "/run/secrets/${secrets.clientSecret}" }}`,
791
+ }),
792
+ redirectURIs: [`https://${host}:${port}/oauth2/callback`],
218
793
  },
219
794
  ],
220
795
  enablePasswordDB: true,
221
- ...(idp.dex.expiry === undefined ? {} : { expiry: idp.dex.expiry }),
222
- staticPasswords: idp.dex.users.map(({ password, ...user }) => ({
796
+ ...(expiry === undefined ? {} : { expiry }),
797
+ staticPasswords: users.map(({ password, ...user }) => ({
223
798
  ...user,
224
- hash: hashSync(password, 10),
799
+ hash: hashSync(password),
225
800
  })),
226
801
  });
227
802
  }
@@ -230,25 +805,46 @@ const template = `
230
805
  events {}
231
806
 
232
807
  http {
808
+ # Disable IPv6 DNS lookups that may fail to resolve upstream hostnames.
233
809
  resolver 127.0.0.11 ipv6=off;
234
810
 
811
+ # Configure access log format.
812
+ map $time_iso8601 $access_log_time {
813
+ "~^[0-9]{4}-[0-9]{2}-[0-9]{2}T([0-9]{2}:[0-9]{2}:[0-9]{2})" $1;
814
+ default $time_iso8601;
815
+ }
816
+ log_format access_log_format '$access_log_time | $status | $request_method $request_uri | $auth_email | $proxy_host';
817
+
235
818
  # Allow WebSockets (Vite HMR).
236
819
  map $http_upgrade $connection_upgrade {
237
820
  default upgrade;
238
821
  '' close;
239
822
  }
240
823
 
824
+ # Fetch Metadata CSRF guards for cookie-backed locations.
825
+ map $http_sec_fetch_site $csrf_api {
826
+ default 0;
827
+ same-site 1;
828
+ cross-site 1;
829
+ }
830
+ map "$http_sec_fetch_site:$request_method:$http_sec_fetch_mode:$http_sec_fetch_dest" $csrf_app {
831
+ default 0;
832
+ ~^(cross-site|same-site):GET:navigate:document$ 0;
833
+ ~^(cross-site|same-site): 1;
834
+ }
835
+
241
836
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
837
+
242
838
  upstream <%~ name %> {
243
- <%_ if (upstream.external) { %>
839
+ <%_ if (upstream.resolve) { %>
244
840
  zone <%~ name %> 64k;
245
841
  server <%~ upstream.host %>:<%~ upstream.port %> resolve;
246
842
  <%_ } else { %>
247
843
  server <%~ upstream.host %>:<%~ upstream.port %>;
248
844
  <%_ } %>
249
845
  }
250
-
251
846
  <%_ } %>
847
+
252
848
  server {
253
849
  listen <%~ it.port %> ssl;
254
850
  server_name <%~ it.host %>;
@@ -256,15 +852,40 @@ http {
256
852
  ssl_certificate <%~ it.ssl.cert %>;
257
853
  ssl_certificate_key <%~ it.ssl.key %>;
258
854
 
259
- # Redirect accidental plaintext HTTP requests sent to the HTTPS port.
855
+ access_log /var/log/nginx/access.log access_log_format;
856
+ set $auth_email "";
857
+
858
+ # Redirect HTTP to HTTPS.
260
859
  error_page 497 =301 https://$http_host$request_uri;
261
860
 
262
861
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
263
- <%_ for (const [path, location] of Object.entries(upstream.locations ?? {})) { %>
862
+ <%_ for (const [path, location] of Object.entries(upstream.locations)) { %>
264
863
  location <%~ path %> {
864
+ <%_ if (location.csrf) { %>
865
+ add_header Vary "Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest" always;
866
+ <%_ if (location.csrf === 'app') { %>
867
+ if ($csrf_app) {
868
+ return 403;
869
+ }
870
+ <%_ } else { %>
871
+ if ($csrf_api) {
872
+ return 403;
873
+ }
874
+ <%_ } %>
875
+
876
+ <%_ } %>
265
877
  <%_ if (location.auth?.enabled) { %>
266
878
  auth_request /oauth2/auth;
879
+ auth_request_set $authorization $upstream_http_authorization;
267
880
  auth_request_set $access_token $upstream_http_x_auth_request_access_token;
881
+ auth_request_set $auth_user $upstream_http_x_auth_request_user;
882
+ auth_request_set $auth_email $upstream_http_x_auth_request_email;
883
+ auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
884
+ auth_request_set $auth_preferred_username $upstream_http_x_auth_request_preferred_username;
885
+
886
+ # Propagate refreshed session cookie.
887
+ auth_request_set $auth_cookie $upstream_http_set_cookie;
888
+ add_header Set-Cookie $auth_cookie;
268
889
 
269
890
  <%_ if (location.auth.redirect) { %>
270
891
  error_page 401 =302 /oauth2/start?rd=$scheme://$http_host$request_uri;
@@ -273,8 +894,10 @@ http {
273
894
  <%_ for (const [header, value] of Object.entries(location.headers ?? {})) { %>
274
895
  proxy_set_header <%~ header %> <%~ value %>;
275
896
  <%_ } %>
276
- <%_ if (location.auth?.enabled && location.auth.forward) { %>
277
- proxy_set_header Authorization "Bearer $access_token";
897
+ <%_ for (const [directive, values] of Object.entries(location.directives ?? {})) { %>
898
+ <%_ for (const value of values) { %>
899
+ <%~ directive %> <%~ value %>;
900
+ <%_ } %>
278
901
  <%_ } %>
279
902
  <%_ if (upstream.scheme === 'https') { %>
280
903
  proxy_ssl_server_name on;
@@ -282,7 +905,7 @@ http {
282
905
  <%_ } %>
283
906
  proxy_pass <%~ upstream.scheme %>://<%~ name %>;
284
907
  }
285
- <%_ } %>
908
+ <%_ } %>
286
909
 
287
910
  <%_ } %>
288
911
  }
@@ -301,55 +924,76 @@ function renderNginxConfig(config) {
301
924
  cert: join(config.files.certs[1], 'tls.crt'),
302
925
  key: join(config.files.certs[1], 'tls.key'),
303
926
  },
304
- upstreams: config.upstreams,
927
+ upstreams: Object.fromEntries(Object.entries(config.upstreams).map(([name, upstream]) => [
928
+ name,
929
+ {
930
+ ...upstream,
931
+ resolve: upstream.host === 'host.docker.internal'
932
+ ? process.platform !== 'linux'
933
+ : upstream.external,
934
+ },
935
+ ])),
305
936
  };
306
937
  return new Eta({ autoTrim: false }).renderString(template, data);
307
938
  }
308
939
 
940
+ const LogTS = '{{slice .Timestamp 11 19}}';
941
+ const LogReqURI = '{{printf "%.*s" (len (slice .RequestURI 2)) (slice .RequestURI 1)}}';
942
+ const LogFormats = {
943
+ standard_logging_format: `${LogTS} | [{{.File}}] {{.Message}}`,
944
+ request_logging_format: `${LogTS} | {{.StatusCode}} | {{.RequestMethod}} ${LogReqURI} | {{.Username}} | {{.Upstream}}`,
945
+ auth_logging_format: `${LogTS} | {{.Status}} | {{.Username}} | {{.Message}}`,
946
+ };
309
947
  function writeOauth2ProxyConfig(config) {
310
948
  const file = join(config.cache, config.files.oauth2Proxy[0]);
311
949
  const render = renderOauth2ProxyConfig(config);
312
950
  writeFileSync(file, render, 'utf-8');
313
- if (config.oauth2.public) {
314
- writeFileSync(join(config.cache, config.files.oauth2ProxyClientSecret[0]), '');
315
- }
316
951
  }
317
952
  function renderOauth2ProxyConfig(config) {
318
953
  const data = {
319
954
  http_address: `0.0.0.0:${config.upstreams.oauth2_proxy.port}`,
320
955
  provider: 'oidc',
321
- oidc_issuer_url: config.idp.issuer,
322
- skip_oidc_discovery: true,
323
- login_url: config.idp.authorization,
324
- redeem_url: config.idp.token,
325
- oidc_jwks_url: config.idp.jwks,
956
+ oidc_issuer_url: config.idp.oidc.issuer,
957
+ ...('authorization' in config.idp.oidc
958
+ ? {
959
+ skip_oidc_discovery: true,
960
+ login_url: config.idp.oidc.authorization,
961
+ redeem_url: config.idp.oidc.token,
962
+ oidc_jwks_url: config.idp.oidc.jwks,
963
+ }
964
+ : {}),
326
965
  redirect_url: `https://${config.host}:${config.port}/oauth2/callback`,
327
966
  client_id: config.oauth2.id,
328
- ...(config.oauth2.secret === undefined
967
+ ...(config.oauth2.public
329
968
  ? {
330
- client_secret_file: config.files.oauth2ProxyClientSecret[1],
331
969
  code_challenge_method: 'S256',
970
+ client_secret_file: '/dev/null',
332
971
  }
333
- : { client_secret: config.oauth2.secret }),
334
- cookie_secret: createHash('sha256')
335
- .update('visage:cookie-secret\0')
336
- .update(config.cache)
337
- .digest('base64url'),
972
+ : { client_secret_file: `/run/secrets/${config.secrets.clientSecret}` }),
338
973
  ...config.cookie,
974
+ cookie_secret_file: `/run/secrets/${config.secrets.cookieSecret}`,
339
975
  cookie_httponly: true,
340
976
  cookie_secure: true,
341
977
  cookie_samesite: 'lax',
342
978
  cookie_csrf_per_request: true,
343
979
  cookie_csrf_per_request_limit: 16,
344
- email_domains: ['*'],
980
+ email_domains: config.oauth2.emailDomains,
981
+ whitelist_domains: [
982
+ config.host,
983
+ `${config.host}:${config.port}`,
984
+ ...(!('dex' in config.idp) && config.idp.oidc.end_session_endpoint
985
+ ? [new URL(config.idp.oidc.end_session_endpoint).host]
986
+ : []),
987
+ ],
345
988
  scope: config.oauth2.scopes.join(' '),
346
- upstreams: ['static://202'],
347
989
  reverse_proxy: true,
990
+ trusted_proxy_ips: config.network.trustedProxyIps,
348
991
  set_xauthrequest: true,
992
+ set_authorization_header: true,
349
993
  pass_access_token: true,
350
- pass_authorization_header: true,
351
994
  skip_provider_button: true,
352
- whitelist_domains: [config.host, `${config.host}:${config.port}`],
995
+ approval_prompt: 'auto',
996
+ ...LogFormats,
353
997
  };
354
998
  return `${Object.entries(data)
355
999
  .map(([key, value]) => {
@@ -357,329 +1001,47 @@ function renderOauth2ProxyConfig(config) {
357
1001
  const values = value.map((item) => JSON.stringify(item)).join(', ');
358
1002
  return `${key} = [${values}]`;
359
1003
  }
360
- if (typeof value === 'string') {
1004
+ if (typeof value === 'string')
361
1005
  return `${key} = ${JSON.stringify(value)}`;
362
- }
363
1006
  return `${key} = ${String(value)}`;
364
1007
  })
365
1008
  .join('\n')}\n`;
366
1009
  }
367
1010
 
368
- function render(config) {
369
- writeComposeConfig(config);
370
- if (config.idp.dex !== undefined) {
371
- writeDexConfig(config);
372
- }
373
- writeNginxConfig(config);
374
- writeOauth2ProxyConfig(config);
375
- }
376
-
377
- const BaseFiles = {
378
- certs: ['./certs', '/etc/nginx/certs'],
379
- compose: './compose.yaml',
380
- dex: ['./dex.yml', '/etc/dex/dex.yml'],
381
- nginx: ['./nginx.conf', '/etc/nginx/nginx.conf'],
382
- oauth2ProxyClientSecret: [
383
- './oauth2-client-secret',
384
- '/etc/oauth2-proxy/client-secret',
385
- ],
386
- oauth2Proxy: ['./oauth2-proxy.yml', '/etc/oauth2-proxy/config.yml'],
387
- };
388
- const BaseDexService = {
389
- image: 'ghcr.io/dexidp/dex:v2.45.1',
390
- command: ['dex', 'serve', '/etc/dex/dex.yml'],
391
- };
392
- const BaseServiceNginx = {
393
- image: 'nginx:1.30.0-alpine',
394
- depends_on: ['oauth2_proxy'],
395
- extra_hosts: ['host.docker.internal:host-gateway'],
396
- };
397
- const BaseOAuth2ProxyService = {
398
- image: 'quay.io/oauth2-proxy/oauth2-proxy:v7.15.2',
399
- command: ['--config', '/etc/oauth2-proxy/config.yml'],
400
- extra_hosts: ['host.docker.internal:host-gateway'],
401
- };
402
- const BaseServices = {
403
- nginx: BaseServiceNginx,
404
- oauth2_proxy: BaseOAuth2ProxyService,
405
- };
406
- const BaseDexUpstream = {
407
- host: 'dex',
408
- scheme: 'http',
409
- port: 5556,
410
- locations: { '/dex/': { auth: { enabled: false } } },
411
- };
412
- const BaseOauth2ProxyUpstream = {
413
- host: 'oauth2_proxy',
414
- scheme: 'http',
415
- port: 4180,
416
- locations: {
417
- '/oauth2/': {
418
- auth: { enabled: false },
419
- headers: {
420
- Cookie: '$http_cookie', // Forward session cookie.
421
- 'X-Auth-Request-Redirect': '$request_uri',
422
- },
423
- },
424
- },
425
- };
426
- const BaseViteUpstream = {
427
- host: 'host.docker.internal',
428
- scheme: 'http',
429
- locations: {
430
- '/': {
431
- auth: { forward: false, redirect: true },
432
- headers: {
433
- Upgrade: '$http_upgrade',
434
- Connection: '$connection_upgrade',
435
- },
436
- },
437
- },
438
- };
439
- const DefaultCookiePolicy = {
440
- cookie_expire: '8h',
441
- cookie_refresh: '15m',
442
- cookie_path: '/',
443
- };
444
- const DefaultDexUsers = [
445
- {
446
- email: 'user@example.com',
447
- password: 'pass',
448
- },
449
- ];
450
- const DefaultOAuth2Client = {
451
- id: 'visage',
452
- secret: 'visage-secret',
453
- scopes: ['openid', 'email', 'profile', 'offline_access']};
454
- const DefaultProxyPolicy = {
455
- auth: { enabled: true, forward: true, redirect: false },
456
- headers: {
457
- Cookie: '""', // Don't forward session cookie.
458
- Host: '$host',
459
- 'X-Real-IP': '$remote_addr',
460
- 'X-Forwarded-For': '$proxy_add_x_forwarded_for',
461
- 'X-Forwarded-Proto': '$scheme',
462
- },
463
- };
464
- function resolveOptions(options) {
465
- const { host = 'localhost', port = 9001, cookie = {}, oauth2 = {} } = options;
466
- const cookieName = cookie.name ?? 'session';
467
- const publicClient = oauth2.clientSecret === null;
468
- const services = resolveServicesOptions(options.services);
469
- const upstreams = resolveUpstreamsOptions(services, options.upstreams);
470
- return {
471
- host,
472
- port,
473
- cookie: {
474
- ...DefaultCookiePolicy,
475
- cookie_name: cookie.domains === undefined
476
- ? cookieName.startsWith('__Host-')
477
- ? cookieName
478
- : `__Host-${cookieName}`
479
- : cookieName,
480
- ...(cookie.expire === undefined ? {} : { cookie_expire: cookie.expire }),
481
- ...(cookie.refresh === undefined
482
- ? {}
483
- : { cookie_refresh: cookie.refresh }),
484
- ...(cookie.domains === undefined
485
- ? {}
486
- : { cookie_domains: cookie.domains }),
487
- ...(cookie.path === undefined ? {} : { cookie_path: cookie.path }),
488
- },
489
- idp: resolveIdpOption(options.idp),
490
- oauth2: {
491
- id: oauth2.clientId ?? DefaultOAuth2Client.id,
492
- ...(publicClient
493
- ? {}
494
- : { secret: oauth2.clientSecret ?? DefaultOAuth2Client.secret }),
495
- scopes: oauth2.scopes ?? DefaultOAuth2Client.scopes,
496
- public: publicClient,
497
- },
498
- services,
499
- upstreams,
500
- };
501
- }
502
- function resolveServicesOptions(services = {}) {
1011
+ function createVisageServer(options) {
1012
+ const cache = join(process.cwd(), '.visage');
1013
+ const edgeKey = randomBytes(32).toString('base64url');
1014
+ const config = resolveConfig(resolveOptions(options), cache, edgeKey);
1015
+ let stop;
503
1016
  return {
504
- ...services,
505
- nginx: {
506
- ...BaseServiceNginx,
507
- ...{
508
- ...(services.nginx ?? {}),
509
- extra_hosts: [
510
- ...BaseServiceNginx.extra_hosts,
511
- ...(services.nginx?.extra_hosts ?? []),
512
- ],
513
- },
514
- },
515
- oauth2_proxy: {
516
- ...BaseOAuth2ProxyService,
517
- ...{
518
- ...(services.oauth2_proxy ?? {}),
519
- extra_hosts: [
520
- ...BaseOAuth2ProxyService.extra_hosts,
521
- ...(services.oauth2_proxy?.extra_hosts ?? []),
522
- ],
523
- },
1017
+ middleware: createVisageMiddleware(edgeKey),
1018
+ upgrade: createVisageUpgradeHandler(edgeKey),
1019
+ async listen() {
1020
+ stop ??= await startVisageServer(config);
524
1021
  },
525
- };
526
- }
527
- function resolveUpstreamsOptions(services, upstreams = {}) {
528
- function resolveUpstream(name, upstream) {
529
- return {
530
- ...upstream,
531
- scheme: upstream.scheme,
532
- host: upstream.host ?? name,
533
- port: upstream.port ?? (upstream.scheme === 'https' ? 443 : 80),
534
- locations: upstream.locations ?? { [`/${name}/`]: {} },
535
- };
536
- }
537
- return {
538
- ...Object.fromEntries(Object.entries(services)
539
- .filter(([name]) =>
540
- // Exclude base services handled separately.
541
- name !== 'dex' && name !== 'nginx' && name !== 'oauth2_proxy')
542
- .map(([name, service]) => [
543
- name,
544
- resolveUpstream(name, { scheme: 'http', ...service.upstream }),
545
- ])),
546
- ...Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => [
547
- name,
548
- resolveUpstream(name, {
549
- scheme: services[name] === undefined ? 'https' : 'http',
550
- ...upstream,
551
- }),
552
- ])),
553
- };
554
- }
555
- function resolveIdpOption(idp) {
556
- if (idp && 'issuer' in idp) {
557
- return {
558
- issuer: idp.issuer,
559
- authorization: idp.authorization ?? '/auth',
560
- token: idp.token ?? '/token',
561
- jwks: idp.jwks ?? '/keys',
562
- };
563
- }
564
- return {
565
- dex: {
566
- ...(idp?.expiry ? { expiry: idp.expiry } : {}),
567
- users: (idp?.users ?? DefaultDexUsers).map((user) => ({
568
- email: user.email,
569
- password: user.password,
570
- username: user.username ?? user.email.split('@', 1)[0],
571
- userID: user.userID ?? user.email,
572
- })),
1022
+ close() {
1023
+ stop?.();
1024
+ stop = undefined;
573
1025
  },
574
1026
  };
575
1027
  }
576
- function resolveIdpConfig({ host, port, idp, }) {
577
- if ('dex' in idp) {
578
- const issuer = `https://${host}:${port}/dex`;
579
- const upstream = `http://dex:5556/dex`;
580
- return {
581
- upstream: 'dex',
582
- issuer,
583
- authorization: `${issuer}/auth`,
584
- token: `${upstream}/token`,
585
- jwks: `${upstream}/keys`,
586
- dex: {
587
- expiry: idp.dex.expiry,
588
- users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
589
- email: user.email,
590
- password: user.password,
591
- username: user.username ?? user.email.split('@', 1)[0],
592
- userID: user.userID ?? user.email,
593
- })),
594
- },
595
- };
1028
+ async function startVisageServer(config) {
1029
+ const logs = join(config.cache, 'logs');
1030
+ rmSync(logs, { recursive: true, force: true });
1031
+ mkdirSync(logs, { recursive: true, mode: 0o700 });
1032
+ chmodSync(logs, 0o700);
1033
+ await ensureCerts(config);
1034
+ ensureHostEntry(config);
1035
+ const renderConfig = ensureNginxNetwork(config);
1036
+ writeComposeConfig(renderConfig);
1037
+ if ('dex' in renderConfig.idp) {
1038
+ writeDexConfig(renderConfig);
596
1039
  }
597
- return {
598
- upstream: 'idp',
599
- issuer: idp.issuer,
600
- authorization: idp.issuer + (idp.authorization ?? '/auth'),
601
- token: idp.issuer + (idp.token ?? '/token'),
602
- jwks: idp.issuer + (idp.jwks ?? '/keys'),
603
- };
604
- }
605
- function resolveExternalIdpUpstream(idp) {
606
- const issuer = new URL(idp.issuer);
607
- return {
608
- host: issuer.hostname,
609
- locations: {},
610
- scheme: issuer.protocol === 'https:' ? 'https' : 'http',
611
- port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
612
- };
613
- }
614
- function resolveConfig(options, config, vitePort) {
615
- const idp = resolveIdpConfig(options);
616
- const upstreams = {
617
- oauth2_proxy: BaseOauth2ProxyUpstream,
618
- vite: { ...BaseViteUpstream, port: vitePort },
619
- ...(idp.dex === undefined
620
- ? { idp: resolveExternalIdpUpstream(idp) }
621
- : { dex: BaseDexUpstream }),
622
- ...options.upstreams,
623
- };
624
- return {
625
- host: options.host,
626
- port: options.port,
627
- cookie: options.cookie,
628
- idp,
629
- oauth2: options.oauth2,
630
- cache: join(config.cacheDir, 'visage'),
631
- files: { ...BaseFiles },
632
- services: {
633
- ...(idp.dex === undefined
634
- ? BaseServices
635
- : {
636
- dex: BaseDexService,
637
- nginx: {
638
- ...BaseServices.nginx,
639
- depends_on: ['dex', 'oauth2_proxy'],
640
- },
641
- oauth2_proxy: {
642
- command: BaseServices.oauth2_proxy.command,
643
- extra_hosts: BaseServices.oauth2_proxy.extra_hosts,
644
- image: BaseServices.oauth2_proxy.image,
645
- depends_on: ['dex'],
646
- },
647
- }),
648
- ...Object.fromEntries(Object.entries(options.services).map(([name, { upstream: _upstream, ...service }]) => [name, service])),
649
- },
650
- upstreams: Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => {
651
- const external = options.upstreams[name] !== undefined &&
652
- options.services[name] === undefined;
653
- return [
654
- name,
655
- {
656
- ...upstream,
657
- external,
658
- locations: Object.fromEntries(Object.entries(upstream.locations ?? {}).map(([path, policy]) => [
659
- path,
660
- {
661
- auth: { ...DefaultProxyPolicy.auth, ...policy.auth },
662
- headers: {
663
- ...(external
664
- ? { ...DefaultProxyPolicy.headers, Host: upstream.host }
665
- : DefaultProxyPolicy.headers),
666
- ...policy.headers,
667
- },
668
- },
669
- ])),
670
- },
671
- ];
672
- })),
673
- };
1040
+ writeNginxConfig(renderConfig);
1041
+ writeOauth2ProxyConfig(renderConfig);
1042
+ return startCompose(renderConfig);
674
1043
  }
675
1044
 
676
- function formatUrl(host, port) {
677
- const AnsiGreen = '\x1b[32m';
678
- const AnsiCyan = '\x1b[36m';
679
- const AnsiBold = '\x1b[1m';
680
- const AnsiReset = '\x1b[0m';
681
- return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
682
- }
683
1045
  function visage(options = {}) {
684
1046
  const resolvedOptions = resolveOptions(options);
685
1047
  let stop;
@@ -687,55 +1049,80 @@ function visage(options = {}) {
687
1049
  stop?.();
688
1050
  stop = undefined;
689
1051
  };
1052
+ const edgeKey = randomBytes(32).toString('base64url');
690
1053
  return {
691
1054
  name: 'visage',
692
1055
  apply: 'serve',
693
1056
  config() {
694
1057
  return {
695
1058
  server: {
1059
+ // Configure Vite to only allow traffic from the intended host.
1060
+ allowedHosts: [resolvedOptions.host],
696
1061
  hmr: {
697
1062
  protocol: 'wss',
698
1063
  host: resolvedOptions.host,
699
1064
  clientPort: resolvedOptions.port,
700
1065
  },
701
- host: '0.0.0.0',
1066
+ // Configure Vite to listen on the minimal host address to allow
1067
+ // Docker containers to reach it. Visage (internally managed NGINX)
1068
+ // exposes the browser-facing host/port. On non-Linux systems, this is
1069
+ // localhost. On Linux, it's the host's bridge gateway (e.g.,
1070
+ // 172.17.0.1).
1071
+ host: process.platform !== 'linux'
1072
+ ? '127.0.0.1'
1073
+ : (spawnSync('docker', [
1074
+ 'network',
1075
+ 'inspect',
1076
+ 'bridge',
1077
+ '--format',
1078
+ '{{range .IPAM.Config}}{{println .Gateway}}{{end}}',
1079
+ ], { encoding: 'utf8' })
1080
+ .stdout?.split(/\r?\n/)
1081
+ .map((line) => line.trim())
1082
+ .find((line) => isIP(line)) ?? '0.0.0.0'),
702
1083
  },
703
1084
  };
704
1085
  },
705
- configureServer(server) {
706
- let url;
707
- const printUrls = server.printUrls.bind(server);
708
- server.printUrls = () => {
709
- printUrls();
710
- if (url)
711
- server.config.logger.info(formatUrl(url.host, url.port));
1086
+ configureServer(vite) {
1087
+ vite.middlewares.use(createVisageMiddleware(edgeKey));
1088
+ vite.httpServer?.prependListener('upgrade', createVisageUpgradeHandler(edgeKey));
1089
+ // Hide Vite's direct URL(s) because browser traffic must flow through the
1090
+ // browser-facing NGINX managed by Visage.
1091
+ let visageUrl;
1092
+ vite.printUrls = () => {
1093
+ vite.config.logger.info(visageUrl ?? 'Visage failed to start');
712
1094
  };
713
- async function startVisage(port) {
714
- const config = resolveConfig(resolvedOptions, server.config, port);
715
- url = { host: config.host, port: config.port };
716
- rmSync(join(config.cache, 'logs'), { recursive: true, force: true });
717
- await ensureCerts({
718
- certs: join(config.cache, config.files.certs[0]),
719
- hostname: config.host,
720
- });
721
- ensureHostEntry(config.host);
722
- render(config);
723
- return startCompose(join(config.cache, config.files.compose));
724
- }
725
- const listen = server.listen.bind(server);
726
- server.listen = async (port, isRestart) => {
1095
+ // Monkey patch Vite's listen to get the server's auto-resolved port.
1096
+ const listen = vite.listen.bind(vite);
1097
+ vite.listen = async (port, isRestart) => {
727
1098
  const result = await listen(port, isRestart);
728
- const address = server.httpServer?.address();
1099
+ const address = vite.httpServer?.address();
729
1100
  if (!address || typeof address === 'string') {
730
1101
  throw new Error('Failed to resolve port for Visage');
731
1102
  }
732
- stop = await startVisage(address.port);
733
- server.httpServer?.once('close', closeBundle);
1103
+ const cache = join(vite.config.cacheDir, 'visage');
1104
+ const config = resolveConfig(resolveOptions({
1105
+ ...options,
1106
+ upstreams: {
1107
+ ...options.upstreams,
1108
+ vite: { port: address.port, ...options.upstreams?.vite },
1109
+ },
1110
+ }), cache, edgeKey);
1111
+ visageUrl = formatVisageUrlLog(config.host, config.port);
1112
+ stop = await startVisageServer(config);
1113
+ vite.httpServer?.once('close', closeBundle);
734
1114
  return result;
735
1115
  };
736
1116
  },
737
1117
  closeBundle,
738
1118
  };
739
1119
  }
1120
+ function formatVisageUrlLog(host, port) {
1121
+ const AnsiGreen = '\x1b[32m';
1122
+ const AnsiCyan = '\x1b[36m';
1123
+ const AnsiBold = '\x1b[1m';
1124
+ const AnsiReset = '\x1b[0m';
1125
+ return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
1126
+ }
740
1127
 
741
- export { visage as default, visage };
1128
+ export { createVisageServer, visage as default, visage };