@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/README.md +98 -29
- package/dist/certs.d.ts +2 -6
- package/dist/certs.d.ts.map +1 -1
- package/dist/compose.d.ts +2 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/config.d.ts +50 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/hosts.d.ts +2 -1
- package/dist/hosts.d.ts.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +857 -462
- package/dist/middleware.d.ts +4 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/network.d.ts +3 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/render/index.d.ts +4 -2
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/nginx.d.ts.map +1 -1
- package/dist/render/oauth2-proxy.d.ts.map +1 -1
- package/dist/server.d.ts +31 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/types.d.ts +61 -20
- package/dist/types.d.ts.map +1 -1
- package/docker-compose.images.yml +7 -0
- package/package.json +22 -16
package/dist/index.js
CHANGED
|
@@ -1,71 +1,476 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
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 =
|
|
56
|
-
const
|
|
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;
|
|
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([
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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${
|
|
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
|
|
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: [
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
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:
|
|
213
|
-
name:
|
|
214
|
-
...(
|
|
785
|
+
id: oauth2.id,
|
|
786
|
+
name: oauth2.id,
|
|
787
|
+
...(oauth2.public
|
|
215
788
|
? { public: true }
|
|
216
|
-
: {
|
|
217
|
-
|
|
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
|
-
...(
|
|
222
|
-
staticPasswords:
|
|
796
|
+
...(expiry === undefined ? {} : { expiry }),
|
|
797
|
+
staticPasswords: users.map(({ password, ...user }) => ({
|
|
223
798
|
...user,
|
|
224
|
-
hash: hashSync(password
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%_
|
|
270
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
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
|
-
: {
|
|
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
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
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(
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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 =
|
|
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
|
-
|
|
725
|
-
|
|
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 };
|