@blakearoberts/visage 0.0.1-rc.8 → 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');
445
+ };
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();
45
454
  };
46
- stopRef = stop;
47
- return stop;
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);
89
498
  }
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');
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'));
103
510
  }
104
- await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
105
- chmodSync(file, 0o755);
106
- return file;
511
+ return mkcert;
512
+ }
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'));
530
+ }
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,18 +805,46 @@ const template = `
230
805
  events {}
231
806
 
232
807
  http {
808
+ # Disable IPv6 DNS lookups that may fail to resolve upstream hostnames.
809
+ resolver 127.0.0.11 ipv6=off;
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
+
233
818
  # Allow WebSockets (Vite HMR).
234
819
  map $http_upgrade $connection_upgrade {
235
820
  default upgrade;
236
821
  '' close;
237
822
  }
238
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
+
239
836
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
837
+
240
838
  upstream <%~ name %> {
839
+ <%_ if (upstream.resolve) { %>
840
+ zone <%~ name %> 64k;
841
+ server <%~ upstream.host %>:<%~ upstream.port %> resolve;
842
+ <%_ } else { %>
241
843
  server <%~ upstream.host %>:<%~ upstream.port %>;
844
+ <%_ } %>
242
845
  }
243
-
244
846
  <%_ } %>
847
+
245
848
  server {
246
849
  listen <%~ it.port %> ssl;
247
850
  server_name <%~ it.host %>;
@@ -249,15 +852,40 @@ http {
249
852
  ssl_certificate <%~ it.ssl.cert %>;
250
853
  ssl_certificate_key <%~ it.ssl.key %>;
251
854
 
252
- # 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.
253
859
  error_page 497 =301 https://$http_host$request_uri;
254
860
 
255
861
  <%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
256
- <%_ for (const [path, location] of Object.entries(upstream.locations ?? {})) { %>
862
+ <%_ for (const [path, location] of Object.entries(upstream.locations)) { %>
257
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
+ <%_ } %>
258
877
  <%_ if (location.auth?.enabled) { %>
259
878
  auth_request /oauth2/auth;
879
+ auth_request_set $authorization $upstream_http_authorization;
260
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;
261
889
 
262
890
  <%_ if (location.auth.redirect) { %>
263
891
  error_page 401 =302 /oauth2/start?rd=$scheme://$http_host$request_uri;
@@ -266,8 +894,10 @@ http {
266
894
  <%_ for (const [header, value] of Object.entries(location.headers ?? {})) { %>
267
895
  proxy_set_header <%~ header %> <%~ value %>;
268
896
  <%_ } %>
269
- <%_ if (location.auth?.enabled && location.auth.forward) { %>
270
- 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
+ <%_ } %>
271
901
  <%_ } %>
272
902
  <%_ if (upstream.scheme === 'https') { %>
273
903
  proxy_ssl_server_name on;
@@ -275,7 +905,7 @@ http {
275
905
  <%_ } %>
276
906
  proxy_pass <%~ upstream.scheme %>://<%~ name %>;
277
907
  }
278
- <%_ } %>
908
+ <%_ } %>
279
909
 
280
910
  <%_ } %>
281
911
  }
@@ -294,55 +924,76 @@ function renderNginxConfig(config) {
294
924
  cert: join(config.files.certs[1], 'tls.crt'),
295
925
  key: join(config.files.certs[1], 'tls.key'),
296
926
  },
297
- 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
+ ])),
298
936
  };
299
937
  return new Eta({ autoTrim: false }).renderString(template, data);
300
938
  }
301
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
+ };
302
947
  function writeOauth2ProxyConfig(config) {
303
948
  const file = join(config.cache, config.files.oauth2Proxy[0]);
304
949
  const render = renderOauth2ProxyConfig(config);
305
950
  writeFileSync(file, render, 'utf-8');
306
- if (config.oauth2.public) {
307
- writeFileSync(join(config.cache, config.files.oauth2ProxyClientSecret[0]), '');
308
- }
309
951
  }
310
952
  function renderOauth2ProxyConfig(config) {
311
953
  const data = {
312
954
  http_address: `0.0.0.0:${config.upstreams.oauth2_proxy.port}`,
313
955
  provider: 'oidc',
314
- oidc_issuer_url: config.idp.issuer,
315
- skip_oidc_discovery: true,
316
- login_url: config.idp.authorization,
317
- redeem_url: config.idp.token,
318
- 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
+ : {}),
319
965
  redirect_url: `https://${config.host}:${config.port}/oauth2/callback`,
320
966
  client_id: config.oauth2.id,
321
- ...(config.oauth2.secret === undefined
967
+ ...(config.oauth2.public
322
968
  ? {
323
- client_secret_file: config.files.oauth2ProxyClientSecret[1],
324
969
  code_challenge_method: 'S256',
970
+ client_secret_file: '/dev/null',
325
971
  }
326
- : { client_secret: config.oauth2.secret }),
327
- cookie_secret: createHash('sha256')
328
- .update('visage:cookie-secret\0')
329
- .update(config.cache)
330
- .digest('base64url'),
972
+ : { client_secret_file: `/run/secrets/${config.secrets.clientSecret}` }),
331
973
  ...config.cookie,
974
+ cookie_secret_file: `/run/secrets/${config.secrets.cookieSecret}`,
332
975
  cookie_httponly: true,
333
976
  cookie_secure: true,
334
977
  cookie_samesite: 'lax',
335
978
  cookie_csrf_per_request: true,
336
979
  cookie_csrf_per_request_limit: 16,
337
- 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
+ ],
338
988
  scope: config.oauth2.scopes.join(' '),
339
- upstreams: ['static://202'],
340
989
  reverse_proxy: true,
990
+ trusted_proxy_ips: config.network.trustedProxyIps,
341
991
  set_xauthrequest: true,
992
+ set_authorization_header: true,
342
993
  pass_access_token: true,
343
- pass_authorization_header: true,
344
994
  skip_provider_button: true,
345
- whitelist_domains: [config.host, `${config.host}:${config.port}`],
995
+ approval_prompt: 'auto',
996
+ ...LogFormats,
346
997
  };
347
998
  return `${Object.entries(data)
348
999
  .map(([key, value]) => {
@@ -350,328 +1001,47 @@ function renderOauth2ProxyConfig(config) {
350
1001
  const values = value.map((item) => JSON.stringify(item)).join(', ');
351
1002
  return `${key} = [${values}]`;
352
1003
  }
353
- if (typeof value === 'string') {
1004
+ if (typeof value === 'string')
354
1005
  return `${key} = ${JSON.stringify(value)}`;
355
- }
356
1006
  return `${key} = ${String(value)}`;
357
1007
  })
358
1008
  .join('\n')}\n`;
359
1009
  }
360
1010
 
361
- function render(config) {
362
- writeComposeConfig(config);
363
- if (config.idp.dex !== undefined) {
364
- writeDexConfig(config);
365
- }
366
- writeNginxConfig(config);
367
- writeOauth2ProxyConfig(config);
368
- }
369
-
370
- const BaseFiles = {
371
- certs: ['./certs', '/etc/nginx/certs'],
372
- compose: './compose.yaml',
373
- dex: ['./dex.yml', '/etc/dex/dex.yml'],
374
- nginx: ['./nginx.conf', '/etc/nginx/nginx.conf'],
375
- oauth2ProxyClientSecret: [
376
- './oauth2-client-secret',
377
- '/etc/oauth2-proxy/client-secret',
378
- ],
379
- oauth2Proxy: ['./oauth2-proxy.yml', '/etc/oauth2-proxy/config.yml'],
380
- };
381
- const BaseDexService = {
382
- image: 'ghcr.io/dexidp/dex:v2.45.1',
383
- command: ['dex', 'serve', '/etc/dex/dex.yml'],
384
- };
385
- const BaseServiceNginx = {
386
- image: 'nginx:1.30.0-alpine',
387
- depends_on: ['oauth2_proxy'],
388
- extra_hosts: ['host.docker.internal:host-gateway'],
389
- };
390
- const BaseOAuth2ProxyService = {
391
- image: 'quay.io/oauth2-proxy/oauth2-proxy:v7.15.2',
392
- command: ['--config', '/etc/oauth2-proxy/config.yml'],
393
- extra_hosts: ['host.docker.internal:host-gateway'],
394
- };
395
- const BaseServices = {
396
- nginx: BaseServiceNginx,
397
- oauth2_proxy: BaseOAuth2ProxyService,
398
- };
399
- const BaseDexUpstream = {
400
- host: 'dex',
401
- scheme: 'http',
402
- port: 5556,
403
- locations: { '/dex/': { auth: { enabled: false } } },
404
- };
405
- const BaseOauth2ProxyUpstream = {
406
- host: 'oauth2_proxy',
407
- scheme: 'http',
408
- port: 4180,
409
- locations: {
410
- '/oauth2/': {
411
- auth: { enabled: false },
412
- headers: {
413
- Cookie: '$http_cookie', // Forward session cookie.
414
- 'X-Auth-Request-Redirect': '$request_uri',
415
- },
416
- },
417
- },
418
- };
419
- const BaseViteUpstream = {
420
- host: 'host.docker.internal',
421
- scheme: 'http',
422
- locations: {
423
- '/': {
424
- auth: { forward: false, redirect: true },
425
- headers: {
426
- Upgrade: '$http_upgrade',
427
- Connection: '$connection_upgrade',
428
- },
429
- },
430
- },
431
- };
432
- const DefaultCookiePolicy = {
433
- cookie_expire: '8h',
434
- cookie_refresh: '15m',
435
- cookie_path: '/',
436
- };
437
- const DefaultDexUsers = [
438
- {
439
- email: 'user@example.com',
440
- password: 'pass',
441
- },
442
- ];
443
- const DefaultOAuth2Client = {
444
- id: 'visage',
445
- secret: 'visage-secret',
446
- scopes: ['openid', 'email', 'profile', 'offline_access']};
447
- const DefaultProxyPolicy = {
448
- auth: { enabled: true, forward: true, redirect: false },
449
- headers: {
450
- Cookie: '""', // Don't forward session cookie.
451
- Host: '$host',
452
- 'X-Real-IP': '$remote_addr',
453
- 'X-Forwarded-For': '$proxy_add_x_forwarded_for',
454
- 'X-Forwarded-Proto': '$scheme',
455
- },
456
- };
457
- function resolveOptions(options) {
458
- const { host = 'localhost', port = 9001, cookie = {}, oauth2 = {} } = options;
459
- const cookieName = cookie.name ?? 'session';
460
- const publicClient = oauth2.clientSecret === null;
461
- const services = resolveServicesOptions(options.services);
462
- const upstreams = resolveUpstreamsOptions(services, options.upstreams);
463
- return {
464
- host,
465
- port,
466
- cookie: {
467
- ...DefaultCookiePolicy,
468
- cookie_name: cookie.domains === undefined
469
- ? cookieName.startsWith('__Host-')
470
- ? cookieName
471
- : `__Host-${cookieName}`
472
- : cookieName,
473
- ...(cookie.expire === undefined ? {} : { cookie_expire: cookie.expire }),
474
- ...(cookie.refresh === undefined
475
- ? {}
476
- : { cookie_refresh: cookie.refresh }),
477
- ...(cookie.domains === undefined
478
- ? {}
479
- : { cookie_domains: cookie.domains }),
480
- ...(cookie.path === undefined ? {} : { cookie_path: cookie.path }),
481
- },
482
- idp: resolveIdpOption(options.idp),
483
- oauth2: {
484
- id: oauth2.clientId ?? DefaultOAuth2Client.id,
485
- ...(publicClient
486
- ? {}
487
- : { secret: oauth2.clientSecret ?? DefaultOAuth2Client.secret }),
488
- scopes: oauth2.scopes ?? DefaultOAuth2Client.scopes,
489
- public: publicClient,
490
- },
491
- services,
492
- upstreams,
493
- };
494
- }
495
- 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;
496
1016
  return {
497
- ...services,
498
- nginx: {
499
- ...BaseServiceNginx,
500
- ...{
501
- ...(services.nginx ?? {}),
502
- extra_hosts: [
503
- ...BaseServiceNginx.extra_hosts,
504
- ...(services.nginx?.extra_hosts ?? []),
505
- ],
506
- },
507
- },
508
- oauth2_proxy: {
509
- ...BaseOAuth2ProxyService,
510
- ...{
511
- ...(services.oauth2_proxy ?? {}),
512
- extra_hosts: [
513
- ...BaseOAuth2ProxyService.extra_hosts,
514
- ...(services.oauth2_proxy?.extra_hosts ?? []),
515
- ],
516
- },
1017
+ middleware: createVisageMiddleware(edgeKey),
1018
+ upgrade: createVisageUpgradeHandler(edgeKey),
1019
+ async listen() {
1020
+ stop ??= await startVisageServer(config);
517
1021
  },
518
- };
519
- }
520
- function resolveUpstreamsOptions(services, upstreams = {}) {
521
- function resolveUpstream(name, upstream) {
522
- return {
523
- ...upstream,
524
- scheme: upstream.scheme,
525
- host: upstream.host ?? name,
526
- port: upstream.port ?? (upstream.scheme === 'https' ? 443 : 80),
527
- locations: upstream.locations ?? { [`/${name}/`]: {} },
528
- };
529
- }
530
- return {
531
- ...Object.fromEntries(Object.entries(services)
532
- .filter(([name]) =>
533
- // Exclude base services handled separately.
534
- name !== 'dex' && name !== 'nginx' && name !== 'oauth2_proxy')
535
- .map(([name, service]) => [
536
- name,
537
- resolveUpstream(name, { scheme: 'http', ...service.upstream }),
538
- ])),
539
- ...Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => [
540
- name,
541
- resolveUpstream(name, {
542
- scheme: services[name] === undefined ? 'https' : 'http',
543
- ...upstream,
544
- }),
545
- ])),
546
- };
547
- }
548
- function resolveIdpOption(idp) {
549
- if (idp && 'issuer' in idp) {
550
- return {
551
- issuer: idp.issuer,
552
- authorization: idp.authorization ?? '/auth',
553
- token: idp.token ?? '/token',
554
- jwks: idp.jwks ?? '/keys',
555
- };
556
- }
557
- return {
558
- dex: {
559
- ...(idp?.expiry ? { expiry: idp.expiry } : {}),
560
- users: (idp?.users ?? DefaultDexUsers).map((user) => ({
561
- email: user.email,
562
- password: user.password,
563
- username: user.username ?? user.email.split('@', 1)[0],
564
- userID: user.userID ?? user.email,
565
- })),
1022
+ close() {
1023
+ stop?.();
1024
+ stop = undefined;
566
1025
  },
567
1026
  };
568
1027
  }
569
- function resolveIdpConfig({ host, port, idp, }) {
570
- if ('dex' in idp) {
571
- const issuer = `https://${host}:${port}/dex`;
572
- const upstream = `http://dex:5556/dex`;
573
- return {
574
- upstream: 'dex',
575
- issuer,
576
- authorization: `${issuer}/auth`,
577
- token: `${upstream}/token`,
578
- jwks: `${upstream}/keys`,
579
- dex: {
580
- expiry: idp.dex.expiry,
581
- users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
582
- email: user.email,
583
- password: user.password,
584
- username: user.username ?? user.email.split('@', 1)[0],
585
- userID: user.userID ?? user.email,
586
- })),
587
- },
588
- };
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);
589
1039
  }
590
- return {
591
- upstream: 'idp',
592
- issuer: idp.issuer,
593
- authorization: idp.issuer + (idp.authorization ?? '/auth'),
594
- token: idp.issuer + (idp.token ?? '/token'),
595
- jwks: idp.issuer + (idp.jwks ?? '/keys'),
596
- };
597
- }
598
- function resolveExternalIdpUpstream(idp) {
599
- const issuer = new URL(idp.issuer);
600
- return {
601
- host: issuer.hostname,
602
- locations: {},
603
- scheme: issuer.protocol === 'https:' ? 'https' : 'http',
604
- port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
605
- };
606
- }
607
- function resolveConfig(options, config, vitePort) {
608
- const idp = resolveIdpConfig(options);
609
- const upstreams = {
610
- oauth2_proxy: BaseOauth2ProxyUpstream,
611
- vite: { ...BaseViteUpstream, port: vitePort },
612
- ...(idp.dex === undefined
613
- ? { idp: resolveExternalIdpUpstream(idp) }
614
- : { dex: BaseDexUpstream }),
615
- ...options.upstreams,
616
- };
617
- return {
618
- host: options.host,
619
- port: options.port,
620
- cookie: options.cookie,
621
- idp,
622
- oauth2: options.oauth2,
623
- cache: join(config.cacheDir, 'visage'),
624
- files: { ...BaseFiles },
625
- services: {
626
- ...(idp.dex === undefined
627
- ? BaseServices
628
- : {
629
- dex: BaseDexService,
630
- nginx: {
631
- ...BaseServices.nginx,
632
- depends_on: ['dex', 'oauth2_proxy'],
633
- },
634
- oauth2_proxy: {
635
- command: BaseServices.oauth2_proxy.command,
636
- extra_hosts: BaseServices.oauth2_proxy.extra_hosts,
637
- image: BaseServices.oauth2_proxy.image,
638
- depends_on: ['dex'],
639
- },
640
- }),
641
- ...Object.fromEntries(Object.entries(options.services).map(([name, { upstream: _upstream, ...service }]) => [name, service])),
642
- },
643
- upstreams: Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => {
644
- const external = options.upstreams[name] !== undefined &&
645
- options.services[name] === undefined;
646
- return [
647
- name,
648
- {
649
- ...upstream,
650
- locations: Object.fromEntries(Object.entries(upstream.locations ?? {}).map(([path, policy]) => [
651
- path,
652
- {
653
- auth: { ...DefaultProxyPolicy.auth, ...policy.auth },
654
- headers: {
655
- ...(external
656
- ? { ...DefaultProxyPolicy.headers, Host: upstream.host }
657
- : DefaultProxyPolicy.headers),
658
- ...policy.headers,
659
- },
660
- },
661
- ])),
662
- },
663
- ];
664
- })),
665
- };
1040
+ writeNginxConfig(renderConfig);
1041
+ writeOauth2ProxyConfig(renderConfig);
1042
+ return startCompose(renderConfig);
666
1043
  }
667
1044
 
668
- function formatUrl(host, port) {
669
- const AnsiGreen = '\x1b[32m';
670
- const AnsiCyan = '\x1b[36m';
671
- const AnsiBold = '\x1b[1m';
672
- const AnsiReset = '\x1b[0m';
673
- return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
674
- }
675
1045
  function visage(options = {}) {
676
1046
  const resolvedOptions = resolveOptions(options);
677
1047
  let stop;
@@ -679,55 +1049,80 @@ function visage(options = {}) {
679
1049
  stop?.();
680
1050
  stop = undefined;
681
1051
  };
1052
+ const edgeKey = randomBytes(32).toString('base64url');
682
1053
  return {
683
1054
  name: 'visage',
684
1055
  apply: 'serve',
685
1056
  config() {
686
1057
  return {
687
1058
  server: {
1059
+ // Configure Vite to only allow traffic from the intended host.
1060
+ allowedHosts: [resolvedOptions.host],
688
1061
  hmr: {
689
1062
  protocol: 'wss',
690
1063
  host: resolvedOptions.host,
691
1064
  clientPort: resolvedOptions.port,
692
1065
  },
693
- 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'),
694
1083
  },
695
1084
  };
696
1085
  },
697
- configureServer(server) {
698
- let url;
699
- const printUrls = server.printUrls.bind(server);
700
- server.printUrls = () => {
701
- printUrls();
702
- if (url)
703
- 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');
704
1094
  };
705
- async function startVisage(port) {
706
- const config = resolveConfig(resolvedOptions, server.config, port);
707
- url = { host: config.host, port: config.port };
708
- rmSync(join(config.cache, 'logs'), { recursive: true, force: true });
709
- await ensureCerts({
710
- certs: join(config.cache, config.files.certs[0]),
711
- hostname: config.host,
712
- });
713
- ensureHostEntry(config.host);
714
- render(config);
715
- return startCompose(join(config.cache, config.files.compose));
716
- }
717
- const listen = server.listen.bind(server);
718
- 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) => {
719
1098
  const result = await listen(port, isRestart);
720
- const address = server.httpServer?.address();
1099
+ const address = vite.httpServer?.address();
721
1100
  if (!address || typeof address === 'string') {
722
1101
  throw new Error('Failed to resolve port for Visage');
723
1102
  }
724
- stop = await startVisage(address.port);
725
- 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);
726
1114
  return result;
727
1115
  };
728
1116
  },
729
1117
  closeBundle,
730
1118
  };
731
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
+ }
732
1127
 
733
- export { visage as default, visage };
1128
+ export { createVisageServer, visage as default, visage };