@blakearoberts/visage 0.0.1-rc.9 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +49 -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 +851 -464
- 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
|
-
spawnSync('docker', down, opts);
|
|
442
|
+
}
|
|
443
|
+
response.statusCode = 403;
|
|
444
|
+
response.end('Forbidden');
|
|
45
445
|
};
|
|
46
|
-
|
|
47
|
-
|
|
446
|
+
}
|
|
447
|
+
function createVisageUpgradeHandler(edgeKey) {
|
|
448
|
+
return function visageUpgrade(request, socket) {
|
|
449
|
+
if (isVisageEdgeRequest(request, edgeKey)) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
453
|
+
socket.destroy();
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function isVisageEdgeRequest(request, edgeKey) {
|
|
457
|
+
const header = request.headers[VisageEdgeKeyHeader.toLowerCase()];
|
|
458
|
+
return typeof header === 'string' && header === edgeKey;
|
|
48
459
|
}
|
|
49
460
|
|
|
50
461
|
const CACHE_HOME = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
51
|
-
async function ensureCerts(
|
|
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);
|
|
498
|
+
}
|
|
499
|
+
function resolveMkcert() {
|
|
500
|
+
const env = process.env;
|
|
501
|
+
const options = { encoding: 'utf8', env };
|
|
502
|
+
const mkcert = findMkcert();
|
|
503
|
+
const result = spawnSync(mkcert, ['-version'], options);
|
|
504
|
+
if (result.error || result.status !== 0) {
|
|
505
|
+
throw new Error([
|
|
506
|
+
`Visage found mkcert at "${mkcert}", but could not execute it.`,
|
|
507
|
+
'',
|
|
508
|
+
mkcertInstallInstructions(),
|
|
509
|
+
].join('\n'));
|
|
510
|
+
}
|
|
511
|
+
return mkcert;
|
|
89
512
|
}
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
throw new Error(
|
|
513
|
+
function findMkcert() {
|
|
514
|
+
const env = process.env;
|
|
515
|
+
const exec = env.VISAGE_MKCERT || 'mkcert';
|
|
516
|
+
const options = { encoding: 'utf8', env };
|
|
517
|
+
const result = process.platform === 'win32'
|
|
518
|
+
? spawnSync('where', [exec], options)
|
|
519
|
+
: spawnSync('sh', ['-c', `command -v ${exec}`], options);
|
|
520
|
+
const path = result.stdout
|
|
521
|
+
.split(/\r?\n/)
|
|
522
|
+
.map((line) => line.trim())
|
|
523
|
+
.find(Boolean);
|
|
524
|
+
if (result.error || result.status !== 0 || !path) {
|
|
525
|
+
throw new Error([
|
|
526
|
+
'Visage requires mkcert to configure HTTPS, but mkcert was not found.',
|
|
527
|
+
'',
|
|
528
|
+
mkcertInstallInstructions(),
|
|
529
|
+
].join('\n'));
|
|
103
530
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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,25 +805,46 @@ const template = `
|
|
|
230
805
|
events {}
|
|
231
806
|
|
|
232
807
|
http {
|
|
808
|
+
# Disable IPv6 DNS lookups that may fail to resolve upstream hostnames.
|
|
233
809
|
resolver 127.0.0.11 ipv6=off;
|
|
234
810
|
|
|
811
|
+
# Configure access log format.
|
|
812
|
+
map $time_iso8601 $access_log_time {
|
|
813
|
+
"~^[0-9]{4}-[0-9]{2}-[0-9]{2}T([0-9]{2}:[0-9]{2}:[0-9]{2})" $1;
|
|
814
|
+
default $time_iso8601;
|
|
815
|
+
}
|
|
816
|
+
log_format access_log_format '$access_log_time | $status | $request_method $request_uri | $auth_email | $proxy_host';
|
|
817
|
+
|
|
235
818
|
# Allow WebSockets (Vite HMR).
|
|
236
819
|
map $http_upgrade $connection_upgrade {
|
|
237
820
|
default upgrade;
|
|
238
821
|
'' close;
|
|
239
822
|
}
|
|
240
823
|
|
|
824
|
+
# Fetch Metadata CSRF guards for cookie-backed locations.
|
|
825
|
+
map $http_sec_fetch_site $csrf_api {
|
|
826
|
+
default 0;
|
|
827
|
+
same-site 1;
|
|
828
|
+
cross-site 1;
|
|
829
|
+
}
|
|
830
|
+
map "$http_sec_fetch_site:$request_method:$http_sec_fetch_mode:$http_sec_fetch_dest" $csrf_app {
|
|
831
|
+
default 0;
|
|
832
|
+
~^(cross-site|same-site):GET:navigate:document$ 0;
|
|
833
|
+
~^(cross-site|same-site): 1;
|
|
834
|
+
}
|
|
835
|
+
|
|
241
836
|
<%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
|
|
837
|
+
|
|
242
838
|
upstream <%~ name %> {
|
|
243
|
-
<%_ if (upstream.
|
|
839
|
+
<%_ if (upstream.resolve) { %>
|
|
244
840
|
zone <%~ name %> 64k;
|
|
245
841
|
server <%~ upstream.host %>:<%~ upstream.port %> resolve;
|
|
246
842
|
<%_ } else { %>
|
|
247
843
|
server <%~ upstream.host %>:<%~ upstream.port %>;
|
|
248
844
|
<%_ } %>
|
|
249
845
|
}
|
|
250
|
-
|
|
251
846
|
<%_ } %>
|
|
847
|
+
|
|
252
848
|
server {
|
|
253
849
|
listen <%~ it.port %> ssl;
|
|
254
850
|
server_name <%~ it.host %>;
|
|
@@ -256,15 +852,40 @@ http {
|
|
|
256
852
|
ssl_certificate <%~ it.ssl.cert %>;
|
|
257
853
|
ssl_certificate_key <%~ it.ssl.key %>;
|
|
258
854
|
|
|
259
|
-
|
|
855
|
+
access_log /var/log/nginx/access.log access_log_format;
|
|
856
|
+
set $auth_email "";
|
|
857
|
+
|
|
858
|
+
# Redirect HTTP to HTTPS.
|
|
260
859
|
error_page 497 =301 https://$http_host$request_uri;
|
|
261
860
|
|
|
262
861
|
<%_ for (const [name, upstream] of Object.entries(it.upstreams)) { %>
|
|
263
|
-
|
|
862
|
+
<%_ for (const [path, location] of Object.entries(upstream.locations)) { %>
|
|
264
863
|
location <%~ path %> {
|
|
864
|
+
<%_ if (location.csrf) { %>
|
|
865
|
+
add_header Vary "Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest" always;
|
|
866
|
+
<%_ if (location.csrf === 'app') { %>
|
|
867
|
+
if ($csrf_app) {
|
|
868
|
+
return 403;
|
|
869
|
+
}
|
|
870
|
+
<%_ } else { %>
|
|
871
|
+
if ($csrf_api) {
|
|
872
|
+
return 403;
|
|
873
|
+
}
|
|
874
|
+
<%_ } %>
|
|
875
|
+
|
|
876
|
+
<%_ } %>
|
|
265
877
|
<%_ if (location.auth?.enabled) { %>
|
|
266
878
|
auth_request /oauth2/auth;
|
|
879
|
+
auth_request_set $authorization $upstream_http_authorization;
|
|
267
880
|
auth_request_set $access_token $upstream_http_x_auth_request_access_token;
|
|
881
|
+
auth_request_set $auth_user $upstream_http_x_auth_request_user;
|
|
882
|
+
auth_request_set $auth_email $upstream_http_x_auth_request_email;
|
|
883
|
+
auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
|
|
884
|
+
auth_request_set $auth_preferred_username $upstream_http_x_auth_request_preferred_username;
|
|
885
|
+
|
|
886
|
+
# Propagate refreshed session cookie.
|
|
887
|
+
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
|
888
|
+
add_header Set-Cookie $auth_cookie;
|
|
268
889
|
|
|
269
890
|
<%_ if (location.auth.redirect) { %>
|
|
270
891
|
error_page 401 =302 /oauth2/start?rd=$scheme://$http_host$request_uri;
|
|
@@ -273,8 +894,10 @@ http {
|
|
|
273
894
|
<%_ for (const [header, value] of Object.entries(location.headers ?? {})) { %>
|
|
274
895
|
proxy_set_header <%~ header %> <%~ value %>;
|
|
275
896
|
<%_ } %>
|
|
276
|
-
<%_
|
|
277
|
-
|
|
897
|
+
<%_ for (const [directive, values] of Object.entries(location.directives ?? {})) { %>
|
|
898
|
+
<%_ for (const value of values) { %>
|
|
899
|
+
<%~ directive %> <%~ value %>;
|
|
900
|
+
<%_ } %>
|
|
278
901
|
<%_ } %>
|
|
279
902
|
<%_ if (upstream.scheme === 'https') { %>
|
|
280
903
|
proxy_ssl_server_name on;
|
|
@@ -282,7 +905,7 @@ http {
|
|
|
282
905
|
<%_ } %>
|
|
283
906
|
proxy_pass <%~ upstream.scheme %>://<%~ name %>;
|
|
284
907
|
}
|
|
285
|
-
|
|
908
|
+
<%_ } %>
|
|
286
909
|
|
|
287
910
|
<%_ } %>
|
|
288
911
|
}
|
|
@@ -301,55 +924,76 @@ function renderNginxConfig(config) {
|
|
|
301
924
|
cert: join(config.files.certs[1], 'tls.crt'),
|
|
302
925
|
key: join(config.files.certs[1], 'tls.key'),
|
|
303
926
|
},
|
|
304
|
-
upstreams: config.upstreams,
|
|
927
|
+
upstreams: Object.fromEntries(Object.entries(config.upstreams).map(([name, upstream]) => [
|
|
928
|
+
name,
|
|
929
|
+
{
|
|
930
|
+
...upstream,
|
|
931
|
+
resolve: upstream.host === 'host.docker.internal'
|
|
932
|
+
? process.platform !== 'linux'
|
|
933
|
+
: upstream.external,
|
|
934
|
+
},
|
|
935
|
+
])),
|
|
305
936
|
};
|
|
306
937
|
return new Eta({ autoTrim: false }).renderString(template, data);
|
|
307
938
|
}
|
|
308
939
|
|
|
940
|
+
const LogTS = '{{slice .Timestamp 11 19}}';
|
|
941
|
+
const LogReqURI = '{{printf "%.*s" (len (slice .RequestURI 2)) (slice .RequestURI 1)}}';
|
|
942
|
+
const LogFormats = {
|
|
943
|
+
standard_logging_format: `${LogTS} | [{{.File}}] {{.Message}}`,
|
|
944
|
+
request_logging_format: `${LogTS} | {{.StatusCode}} | {{.RequestMethod}} ${LogReqURI} | {{.Username}} | {{.Upstream}}`,
|
|
945
|
+
auth_logging_format: `${LogTS} | {{.Status}} | {{.Username}} | {{.Message}}`,
|
|
946
|
+
};
|
|
309
947
|
function writeOauth2ProxyConfig(config) {
|
|
310
948
|
const file = join(config.cache, config.files.oauth2Proxy[0]);
|
|
311
949
|
const render = renderOauth2ProxyConfig(config);
|
|
312
950
|
writeFileSync(file, render, 'utf-8');
|
|
313
|
-
if (config.oauth2.public) {
|
|
314
|
-
writeFileSync(join(config.cache, config.files.oauth2ProxyClientSecret[0]), '');
|
|
315
|
-
}
|
|
316
951
|
}
|
|
317
952
|
function renderOauth2ProxyConfig(config) {
|
|
318
953
|
const data = {
|
|
319
954
|
http_address: `0.0.0.0:${config.upstreams.oauth2_proxy.port}`,
|
|
320
955
|
provider: 'oidc',
|
|
321
|
-
oidc_issuer_url: config.idp.issuer,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
956
|
+
oidc_issuer_url: config.idp.oidc.issuer,
|
|
957
|
+
...('authorization' in config.idp.oidc
|
|
958
|
+
? {
|
|
959
|
+
skip_oidc_discovery: true,
|
|
960
|
+
login_url: config.idp.oidc.authorization,
|
|
961
|
+
redeem_url: config.idp.oidc.token,
|
|
962
|
+
oidc_jwks_url: config.idp.oidc.jwks,
|
|
963
|
+
}
|
|
964
|
+
: {}),
|
|
326
965
|
redirect_url: `https://${config.host}:${config.port}/oauth2/callback`,
|
|
327
966
|
client_id: config.oauth2.id,
|
|
328
|
-
...(config.oauth2.
|
|
967
|
+
...(config.oauth2.public
|
|
329
968
|
? {
|
|
330
|
-
client_secret_file: config.files.oauth2ProxyClientSecret[1],
|
|
331
969
|
code_challenge_method: 'S256',
|
|
970
|
+
client_secret_file: '/dev/null',
|
|
332
971
|
}
|
|
333
|
-
: {
|
|
334
|
-
cookie_secret: createHash('sha256')
|
|
335
|
-
.update('visage:cookie-secret\0')
|
|
336
|
-
.update(config.cache)
|
|
337
|
-
.digest('base64url'),
|
|
972
|
+
: { client_secret_file: `/run/secrets/${config.secrets.clientSecret}` }),
|
|
338
973
|
...config.cookie,
|
|
974
|
+
cookie_secret_file: `/run/secrets/${config.secrets.cookieSecret}`,
|
|
339
975
|
cookie_httponly: true,
|
|
340
976
|
cookie_secure: true,
|
|
341
977
|
cookie_samesite: 'lax',
|
|
342
978
|
cookie_csrf_per_request: true,
|
|
343
979
|
cookie_csrf_per_request_limit: 16,
|
|
344
|
-
email_domains:
|
|
980
|
+
email_domains: config.oauth2.emailDomains,
|
|
981
|
+
whitelist_domains: [
|
|
982
|
+
config.host,
|
|
983
|
+
`${config.host}:${config.port}`,
|
|
984
|
+
...(!('dex' in config.idp) && config.idp.oidc.end_session_endpoint
|
|
985
|
+
? [new URL(config.idp.oidc.end_session_endpoint).host]
|
|
986
|
+
: []),
|
|
987
|
+
],
|
|
345
988
|
scope: config.oauth2.scopes.join(' '),
|
|
346
|
-
upstreams: ['static://202'],
|
|
347
989
|
reverse_proxy: true,
|
|
990
|
+
trusted_proxy_ips: config.network.trustedProxyIps,
|
|
348
991
|
set_xauthrequest: true,
|
|
992
|
+
set_authorization_header: true,
|
|
349
993
|
pass_access_token: true,
|
|
350
|
-
pass_authorization_header: true,
|
|
351
994
|
skip_provider_button: true,
|
|
352
|
-
|
|
995
|
+
approval_prompt: 'auto',
|
|
996
|
+
...LogFormats,
|
|
353
997
|
};
|
|
354
998
|
return `${Object.entries(data)
|
|
355
999
|
.map(([key, value]) => {
|
|
@@ -357,329 +1001,47 @@ function renderOauth2ProxyConfig(config) {
|
|
|
357
1001
|
const values = value.map((item) => JSON.stringify(item)).join(', ');
|
|
358
1002
|
return `${key} = [${values}]`;
|
|
359
1003
|
}
|
|
360
|
-
if (typeof value === 'string')
|
|
1004
|
+
if (typeof value === 'string')
|
|
361
1005
|
return `${key} = ${JSON.stringify(value)}`;
|
|
362
|
-
}
|
|
363
1006
|
return `${key} = ${String(value)}`;
|
|
364
1007
|
})
|
|
365
1008
|
.join('\n')}\n`;
|
|
366
1009
|
}
|
|
367
1010
|
|
|
368
|
-
function
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
writeNginxConfig(config);
|
|
374
|
-
writeOauth2ProxyConfig(config);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const BaseFiles = {
|
|
378
|
-
certs: ['./certs', '/etc/nginx/certs'],
|
|
379
|
-
compose: './compose.yaml',
|
|
380
|
-
dex: ['./dex.yml', '/etc/dex/dex.yml'],
|
|
381
|
-
nginx: ['./nginx.conf', '/etc/nginx/nginx.conf'],
|
|
382
|
-
oauth2ProxyClientSecret: [
|
|
383
|
-
'./oauth2-client-secret',
|
|
384
|
-
'/etc/oauth2-proxy/client-secret',
|
|
385
|
-
],
|
|
386
|
-
oauth2Proxy: ['./oauth2-proxy.yml', '/etc/oauth2-proxy/config.yml'],
|
|
387
|
-
};
|
|
388
|
-
const BaseDexService = {
|
|
389
|
-
image: 'ghcr.io/dexidp/dex:v2.45.1',
|
|
390
|
-
command: ['dex', 'serve', '/etc/dex/dex.yml'],
|
|
391
|
-
};
|
|
392
|
-
const BaseServiceNginx = {
|
|
393
|
-
image: 'nginx:1.30.0-alpine',
|
|
394
|
-
depends_on: ['oauth2_proxy'],
|
|
395
|
-
extra_hosts: ['host.docker.internal:host-gateway'],
|
|
396
|
-
};
|
|
397
|
-
const BaseOAuth2ProxyService = {
|
|
398
|
-
image: 'quay.io/oauth2-proxy/oauth2-proxy:v7.15.2',
|
|
399
|
-
command: ['--config', '/etc/oauth2-proxy/config.yml'],
|
|
400
|
-
extra_hosts: ['host.docker.internal:host-gateway'],
|
|
401
|
-
};
|
|
402
|
-
const BaseServices = {
|
|
403
|
-
nginx: BaseServiceNginx,
|
|
404
|
-
oauth2_proxy: BaseOAuth2ProxyService,
|
|
405
|
-
};
|
|
406
|
-
const BaseDexUpstream = {
|
|
407
|
-
host: 'dex',
|
|
408
|
-
scheme: 'http',
|
|
409
|
-
port: 5556,
|
|
410
|
-
locations: { '/dex/': { auth: { enabled: false } } },
|
|
411
|
-
};
|
|
412
|
-
const BaseOauth2ProxyUpstream = {
|
|
413
|
-
host: 'oauth2_proxy',
|
|
414
|
-
scheme: 'http',
|
|
415
|
-
port: 4180,
|
|
416
|
-
locations: {
|
|
417
|
-
'/oauth2/': {
|
|
418
|
-
auth: { enabled: false },
|
|
419
|
-
headers: {
|
|
420
|
-
Cookie: '$http_cookie', // Forward session cookie.
|
|
421
|
-
'X-Auth-Request-Redirect': '$request_uri',
|
|
422
|
-
},
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
};
|
|
426
|
-
const BaseViteUpstream = {
|
|
427
|
-
host: 'host.docker.internal',
|
|
428
|
-
scheme: 'http',
|
|
429
|
-
locations: {
|
|
430
|
-
'/': {
|
|
431
|
-
auth: { forward: false, redirect: true },
|
|
432
|
-
headers: {
|
|
433
|
-
Upgrade: '$http_upgrade',
|
|
434
|
-
Connection: '$connection_upgrade',
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
};
|
|
439
|
-
const DefaultCookiePolicy = {
|
|
440
|
-
cookie_expire: '8h',
|
|
441
|
-
cookie_refresh: '15m',
|
|
442
|
-
cookie_path: '/',
|
|
443
|
-
};
|
|
444
|
-
const DefaultDexUsers = [
|
|
445
|
-
{
|
|
446
|
-
email: 'user@example.com',
|
|
447
|
-
password: 'pass',
|
|
448
|
-
},
|
|
449
|
-
];
|
|
450
|
-
const DefaultOAuth2Client = {
|
|
451
|
-
id: 'visage',
|
|
452
|
-
secret: 'visage-secret',
|
|
453
|
-
scopes: ['openid', 'email', 'profile', 'offline_access']};
|
|
454
|
-
const DefaultProxyPolicy = {
|
|
455
|
-
auth: { enabled: true, forward: true, redirect: false },
|
|
456
|
-
headers: {
|
|
457
|
-
Cookie: '""', // Don't forward session cookie.
|
|
458
|
-
Host: '$host',
|
|
459
|
-
'X-Real-IP': '$remote_addr',
|
|
460
|
-
'X-Forwarded-For': '$proxy_add_x_forwarded_for',
|
|
461
|
-
'X-Forwarded-Proto': '$scheme',
|
|
462
|
-
},
|
|
463
|
-
};
|
|
464
|
-
function resolveOptions(options) {
|
|
465
|
-
const { host = 'localhost', port = 9001, cookie = {}, oauth2 = {} } = options;
|
|
466
|
-
const cookieName = cookie.name ?? 'session';
|
|
467
|
-
const publicClient = oauth2.clientSecret === null;
|
|
468
|
-
const services = resolveServicesOptions(options.services);
|
|
469
|
-
const upstreams = resolveUpstreamsOptions(services, options.upstreams);
|
|
470
|
-
return {
|
|
471
|
-
host,
|
|
472
|
-
port,
|
|
473
|
-
cookie: {
|
|
474
|
-
...DefaultCookiePolicy,
|
|
475
|
-
cookie_name: cookie.domains === undefined
|
|
476
|
-
? cookieName.startsWith('__Host-')
|
|
477
|
-
? cookieName
|
|
478
|
-
: `__Host-${cookieName}`
|
|
479
|
-
: cookieName,
|
|
480
|
-
...(cookie.expire === undefined ? {} : { cookie_expire: cookie.expire }),
|
|
481
|
-
...(cookie.refresh === undefined
|
|
482
|
-
? {}
|
|
483
|
-
: { cookie_refresh: cookie.refresh }),
|
|
484
|
-
...(cookie.domains === undefined
|
|
485
|
-
? {}
|
|
486
|
-
: { cookie_domains: cookie.domains }),
|
|
487
|
-
...(cookie.path === undefined ? {} : { cookie_path: cookie.path }),
|
|
488
|
-
},
|
|
489
|
-
idp: resolveIdpOption(options.idp),
|
|
490
|
-
oauth2: {
|
|
491
|
-
id: oauth2.clientId ?? DefaultOAuth2Client.id,
|
|
492
|
-
...(publicClient
|
|
493
|
-
? {}
|
|
494
|
-
: { secret: oauth2.clientSecret ?? DefaultOAuth2Client.secret }),
|
|
495
|
-
scopes: oauth2.scopes ?? DefaultOAuth2Client.scopes,
|
|
496
|
-
public: publicClient,
|
|
497
|
-
},
|
|
498
|
-
services,
|
|
499
|
-
upstreams,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
function resolveServicesOptions(services = {}) {
|
|
1011
|
+
function createVisageServer(options) {
|
|
1012
|
+
const cache = join(process.cwd(), '.visage');
|
|
1013
|
+
const edgeKey = randomBytes(32).toString('base64url');
|
|
1014
|
+
const config = resolveConfig(resolveOptions(options), cache, edgeKey);
|
|
1015
|
+
let stop;
|
|
503
1016
|
return {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
...(services.nginx ?? {}),
|
|
509
|
-
extra_hosts: [
|
|
510
|
-
...BaseServiceNginx.extra_hosts,
|
|
511
|
-
...(services.nginx?.extra_hosts ?? []),
|
|
512
|
-
],
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
oauth2_proxy: {
|
|
516
|
-
...BaseOAuth2ProxyService,
|
|
517
|
-
...{
|
|
518
|
-
...(services.oauth2_proxy ?? {}),
|
|
519
|
-
extra_hosts: [
|
|
520
|
-
...BaseOAuth2ProxyService.extra_hosts,
|
|
521
|
-
...(services.oauth2_proxy?.extra_hosts ?? []),
|
|
522
|
-
],
|
|
523
|
-
},
|
|
1017
|
+
middleware: createVisageMiddleware(edgeKey),
|
|
1018
|
+
upgrade: createVisageUpgradeHandler(edgeKey),
|
|
1019
|
+
async listen() {
|
|
1020
|
+
stop ??= await startVisageServer(config);
|
|
524
1021
|
},
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
function resolveUpstream(name, upstream) {
|
|
529
|
-
return {
|
|
530
|
-
...upstream,
|
|
531
|
-
scheme: upstream.scheme,
|
|
532
|
-
host: upstream.host ?? name,
|
|
533
|
-
port: upstream.port ?? (upstream.scheme === 'https' ? 443 : 80),
|
|
534
|
-
locations: upstream.locations ?? { [`/${name}/`]: {} },
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
return {
|
|
538
|
-
...Object.fromEntries(Object.entries(services)
|
|
539
|
-
.filter(([name]) =>
|
|
540
|
-
// Exclude base services handled separately.
|
|
541
|
-
name !== 'dex' && name !== 'nginx' && name !== 'oauth2_proxy')
|
|
542
|
-
.map(([name, service]) => [
|
|
543
|
-
name,
|
|
544
|
-
resolveUpstream(name, { scheme: 'http', ...service.upstream }),
|
|
545
|
-
])),
|
|
546
|
-
...Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => [
|
|
547
|
-
name,
|
|
548
|
-
resolveUpstream(name, {
|
|
549
|
-
scheme: services[name] === undefined ? 'https' : 'http',
|
|
550
|
-
...upstream,
|
|
551
|
-
}),
|
|
552
|
-
])),
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
function resolveIdpOption(idp) {
|
|
556
|
-
if (idp && 'issuer' in idp) {
|
|
557
|
-
return {
|
|
558
|
-
issuer: idp.issuer,
|
|
559
|
-
authorization: idp.authorization ?? '/auth',
|
|
560
|
-
token: idp.token ?? '/token',
|
|
561
|
-
jwks: idp.jwks ?? '/keys',
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
return {
|
|
565
|
-
dex: {
|
|
566
|
-
...(idp?.expiry ? { expiry: idp.expiry } : {}),
|
|
567
|
-
users: (idp?.users ?? DefaultDexUsers).map((user) => ({
|
|
568
|
-
email: user.email,
|
|
569
|
-
password: user.password,
|
|
570
|
-
username: user.username ?? user.email.split('@', 1)[0],
|
|
571
|
-
userID: user.userID ?? user.email,
|
|
572
|
-
})),
|
|
1022
|
+
close() {
|
|
1023
|
+
stop?.();
|
|
1024
|
+
stop = undefined;
|
|
573
1025
|
},
|
|
574
1026
|
};
|
|
575
1027
|
}
|
|
576
|
-
function
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
expiry: idp.dex.expiry,
|
|
588
|
-
users: (idp.dex?.users ?? DefaultDexUsers).map((user) => ({
|
|
589
|
-
email: user.email,
|
|
590
|
-
password: user.password,
|
|
591
|
-
username: user.username ?? user.email.split('@', 1)[0],
|
|
592
|
-
userID: user.userID ?? user.email,
|
|
593
|
-
})),
|
|
594
|
-
},
|
|
595
|
-
};
|
|
1028
|
+
async function startVisageServer(config) {
|
|
1029
|
+
const logs = join(config.cache, 'logs');
|
|
1030
|
+
rmSync(logs, { recursive: true, force: true });
|
|
1031
|
+
mkdirSync(logs, { recursive: true, mode: 0o700 });
|
|
1032
|
+
chmodSync(logs, 0o700);
|
|
1033
|
+
await ensureCerts(config);
|
|
1034
|
+
ensureHostEntry(config);
|
|
1035
|
+
const renderConfig = ensureNginxNetwork(config);
|
|
1036
|
+
writeComposeConfig(renderConfig);
|
|
1037
|
+
if ('dex' in renderConfig.idp) {
|
|
1038
|
+
writeDexConfig(renderConfig);
|
|
596
1039
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
authorization: idp.issuer + (idp.authorization ?? '/auth'),
|
|
601
|
-
token: idp.issuer + (idp.token ?? '/token'),
|
|
602
|
-
jwks: idp.issuer + (idp.jwks ?? '/keys'),
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
function resolveExternalIdpUpstream(idp) {
|
|
606
|
-
const issuer = new URL(idp.issuer);
|
|
607
|
-
return {
|
|
608
|
-
host: issuer.hostname,
|
|
609
|
-
locations: {},
|
|
610
|
-
scheme: issuer.protocol === 'https:' ? 'https' : 'http',
|
|
611
|
-
port: Number(issuer.port) || (issuer.protocol === 'https:' ? 443 : 80),
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
function resolveConfig(options, config, vitePort) {
|
|
615
|
-
const idp = resolveIdpConfig(options);
|
|
616
|
-
const upstreams = {
|
|
617
|
-
oauth2_proxy: BaseOauth2ProxyUpstream,
|
|
618
|
-
vite: { ...BaseViteUpstream, port: vitePort },
|
|
619
|
-
...(idp.dex === undefined
|
|
620
|
-
? { idp: resolveExternalIdpUpstream(idp) }
|
|
621
|
-
: { dex: BaseDexUpstream }),
|
|
622
|
-
...options.upstreams,
|
|
623
|
-
};
|
|
624
|
-
return {
|
|
625
|
-
host: options.host,
|
|
626
|
-
port: options.port,
|
|
627
|
-
cookie: options.cookie,
|
|
628
|
-
idp,
|
|
629
|
-
oauth2: options.oauth2,
|
|
630
|
-
cache: join(config.cacheDir, 'visage'),
|
|
631
|
-
files: { ...BaseFiles },
|
|
632
|
-
services: {
|
|
633
|
-
...(idp.dex === undefined
|
|
634
|
-
? BaseServices
|
|
635
|
-
: {
|
|
636
|
-
dex: BaseDexService,
|
|
637
|
-
nginx: {
|
|
638
|
-
...BaseServices.nginx,
|
|
639
|
-
depends_on: ['dex', 'oauth2_proxy'],
|
|
640
|
-
},
|
|
641
|
-
oauth2_proxy: {
|
|
642
|
-
command: BaseServices.oauth2_proxy.command,
|
|
643
|
-
extra_hosts: BaseServices.oauth2_proxy.extra_hosts,
|
|
644
|
-
image: BaseServices.oauth2_proxy.image,
|
|
645
|
-
depends_on: ['dex'],
|
|
646
|
-
},
|
|
647
|
-
}),
|
|
648
|
-
...Object.fromEntries(Object.entries(options.services).map(([name, { upstream: _upstream, ...service }]) => [name, service])),
|
|
649
|
-
},
|
|
650
|
-
upstreams: Object.fromEntries(Object.entries(upstreams).map(([name, upstream]) => {
|
|
651
|
-
const external = options.upstreams[name] !== undefined &&
|
|
652
|
-
options.services[name] === undefined;
|
|
653
|
-
return [
|
|
654
|
-
name,
|
|
655
|
-
{
|
|
656
|
-
...upstream,
|
|
657
|
-
external,
|
|
658
|
-
locations: Object.fromEntries(Object.entries(upstream.locations ?? {}).map(([path, policy]) => [
|
|
659
|
-
path,
|
|
660
|
-
{
|
|
661
|
-
auth: { ...DefaultProxyPolicy.auth, ...policy.auth },
|
|
662
|
-
headers: {
|
|
663
|
-
...(external
|
|
664
|
-
? { ...DefaultProxyPolicy.headers, Host: upstream.host }
|
|
665
|
-
: DefaultProxyPolicy.headers),
|
|
666
|
-
...policy.headers,
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
])),
|
|
670
|
-
},
|
|
671
|
-
];
|
|
672
|
-
})),
|
|
673
|
-
};
|
|
1040
|
+
writeNginxConfig(renderConfig);
|
|
1041
|
+
writeOauth2ProxyConfig(renderConfig);
|
|
1042
|
+
return startCompose(renderConfig);
|
|
674
1043
|
}
|
|
675
1044
|
|
|
676
|
-
function formatUrl(host, port) {
|
|
677
|
-
const AnsiGreen = '\x1b[32m';
|
|
678
|
-
const AnsiCyan = '\x1b[36m';
|
|
679
|
-
const AnsiBold = '\x1b[1m';
|
|
680
|
-
const AnsiReset = '\x1b[0m';
|
|
681
|
-
return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
|
|
682
|
-
}
|
|
683
1045
|
function visage(options = {}) {
|
|
684
1046
|
const resolvedOptions = resolveOptions(options);
|
|
685
1047
|
let stop;
|
|
@@ -687,55 +1049,80 @@ function visage(options = {}) {
|
|
|
687
1049
|
stop?.();
|
|
688
1050
|
stop = undefined;
|
|
689
1051
|
};
|
|
1052
|
+
const edgeKey = randomBytes(32).toString('base64url');
|
|
690
1053
|
return {
|
|
691
1054
|
name: 'visage',
|
|
692
1055
|
apply: 'serve',
|
|
693
1056
|
config() {
|
|
694
1057
|
return {
|
|
695
1058
|
server: {
|
|
1059
|
+
// Configure Vite to only allow traffic from the intended host.
|
|
1060
|
+
allowedHosts: [resolvedOptions.host],
|
|
696
1061
|
hmr: {
|
|
697
1062
|
protocol: 'wss',
|
|
698
1063
|
host: resolvedOptions.host,
|
|
699
1064
|
clientPort: resolvedOptions.port,
|
|
700
1065
|
},
|
|
701
|
-
host
|
|
1066
|
+
// Configure Vite to listen on the minimal host address to allow
|
|
1067
|
+
// Docker containers to reach it. Visage (internally managed NGINX)
|
|
1068
|
+
// exposes the browser-facing host/port. On non-Linux systems, this is
|
|
1069
|
+
// localhost. On Linux, it's the host's bridge gateway (e.g.,
|
|
1070
|
+
// 172.17.0.1).
|
|
1071
|
+
host: process.platform !== 'linux'
|
|
1072
|
+
? '127.0.0.1'
|
|
1073
|
+
: (spawnSync('docker', [
|
|
1074
|
+
'network',
|
|
1075
|
+
'inspect',
|
|
1076
|
+
'bridge',
|
|
1077
|
+
'--format',
|
|
1078
|
+
'{{range .IPAM.Config}}{{println .Gateway}}{{end}}',
|
|
1079
|
+
], { encoding: 'utf8' })
|
|
1080
|
+
.stdout?.split(/\r?\n/)
|
|
1081
|
+
.map((line) => line.trim())
|
|
1082
|
+
.find((line) => isIP(line)) ?? '0.0.0.0'),
|
|
702
1083
|
},
|
|
703
1084
|
};
|
|
704
1085
|
},
|
|
705
|
-
configureServer(
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1086
|
+
configureServer(vite) {
|
|
1087
|
+
vite.middlewares.use(createVisageMiddleware(edgeKey));
|
|
1088
|
+
vite.httpServer?.prependListener('upgrade', createVisageUpgradeHandler(edgeKey));
|
|
1089
|
+
// Hide Vite's direct URL(s) because browser traffic must flow through the
|
|
1090
|
+
// browser-facing NGINX managed by Visage.
|
|
1091
|
+
let visageUrl;
|
|
1092
|
+
vite.printUrls = () => {
|
|
1093
|
+
vite.config.logger.info(visageUrl ?? 'Visage failed to start');
|
|
712
1094
|
};
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
rmSync(join(config.cache, 'logs'), { recursive: true, force: true });
|
|
717
|
-
await ensureCerts({
|
|
718
|
-
certs: join(config.cache, config.files.certs[0]),
|
|
719
|
-
hostname: config.host,
|
|
720
|
-
});
|
|
721
|
-
ensureHostEntry(config.host);
|
|
722
|
-
render(config);
|
|
723
|
-
return startCompose(join(config.cache, config.files.compose));
|
|
724
|
-
}
|
|
725
|
-
const listen = server.listen.bind(server);
|
|
726
|
-
server.listen = async (port, isRestart) => {
|
|
1095
|
+
// Monkey patch Vite's listen to get the server's auto-resolved port.
|
|
1096
|
+
const listen = vite.listen.bind(vite);
|
|
1097
|
+
vite.listen = async (port, isRestart) => {
|
|
727
1098
|
const result = await listen(port, isRestart);
|
|
728
|
-
const address =
|
|
1099
|
+
const address = vite.httpServer?.address();
|
|
729
1100
|
if (!address || typeof address === 'string') {
|
|
730
1101
|
throw new Error('Failed to resolve port for Visage');
|
|
731
1102
|
}
|
|
732
|
-
|
|
733
|
-
|
|
1103
|
+
const cache = join(vite.config.cacheDir, 'visage');
|
|
1104
|
+
const config = resolveConfig(resolveOptions({
|
|
1105
|
+
...options,
|
|
1106
|
+
upstreams: {
|
|
1107
|
+
...options.upstreams,
|
|
1108
|
+
vite: { port: address.port, ...options.upstreams?.vite },
|
|
1109
|
+
},
|
|
1110
|
+
}), cache, edgeKey);
|
|
1111
|
+
visageUrl = formatVisageUrlLog(config.host, config.port);
|
|
1112
|
+
stop = await startVisageServer(config);
|
|
1113
|
+
vite.httpServer?.once('close', closeBundle);
|
|
734
1114
|
return result;
|
|
735
1115
|
};
|
|
736
1116
|
},
|
|
737
1117
|
closeBundle,
|
|
738
1118
|
};
|
|
739
1119
|
}
|
|
1120
|
+
function formatVisageUrlLog(host, port) {
|
|
1121
|
+
const AnsiGreen = '\x1b[32m';
|
|
1122
|
+
const AnsiCyan = '\x1b[36m';
|
|
1123
|
+
const AnsiBold = '\x1b[1m';
|
|
1124
|
+
const AnsiReset = '\x1b[0m';
|
|
1125
|
+
return ` ${AnsiGreen}➜${AnsiReset} ${AnsiBold}Visage${AnsiReset}: ${AnsiCyan}https://${host}:${AnsiBold}${port}${AnsiReset}${AnsiCyan}/${AnsiReset}`;
|
|
1126
|
+
}
|
|
740
1127
|
|
|
741
|
-
export { visage as default, visage };
|
|
1128
|
+
export { createVisageServer, visage as default, visage };
|