@lamalibre/create-lamaste 4.0.0
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/LICENSE.md +118 -0
- package/README.md +114 -0
- package/bin/create-lamaste.js +15 -0
- package/package.json +54 -0
- package/scripts/bundle-vendor.js +110 -0
- package/scripts/lamaste-cert-info +62 -0
- package/scripts/lamaste-certbot +96 -0
- package/scripts/lamaste-priv +693 -0
- package/src/index.js +646 -0
- package/src/lib/accounts.js +86 -0
- package/src/lib/binaries.js +137 -0
- package/src/lib/cert-help-page.js +160 -0
- package/src/lib/env.js +132 -0
- package/src/lib/http-redirect.js +38 -0
- package/src/lib/ownership.js +231 -0
- package/src/lib/relay-services.js +103 -0
- package/src/lib/secrets.js +19 -0
- package/src/lib/service-config.js +311 -0
- package/src/lib/summary.js +131 -0
- package/src/lib/wrappers.js +63 -0
- package/src/tasks/gatekeeper.js +233 -0
- package/src/tasks/harden.js +293 -0
- package/src/tasks/mtls.js +193 -0
- package/src/tasks/nginx.js +265 -0
- package/src/tasks/node.js +110 -0
- package/src/tasks/panel.js +408 -0
- package/src/tasks/redeploy.js +438 -0
- package/vendor/docs/00-introduction/how-it-works.md +447 -0
- package/vendor/docs/00-introduction/quickstart.md +472 -0
- package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
- package/vendor/docs/01-concepts/authentication.md +464 -0
- package/vendor/docs/01-concepts/authorization.md +242 -0
- package/vendor/docs/01-concepts/certificates.md +452 -0
- package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
- package/vendor/docs/01-concepts/mtls.md +490 -0
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
- package/vendor/docs/01-concepts/security-model.md +600 -0
- package/vendor/docs/01-concepts/tickets.md +414 -0
- package/vendor/docs/01-concepts/tunneling.md +367 -0
- package/vendor/docs/02-guides/agent-setup.md +194 -0
- package/vendor/docs/02-guides/certificate-management.md +341 -0
- package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
- package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
- package/vendor/docs/02-guides/disaster-recovery.md +464 -0
- package/vendor/docs/02-guides/first-tunnel.md +230 -0
- package/vendor/docs/02-guides/installation.md +393 -0
- package/vendor/docs/02-guides/managing-users.md +255 -0
- package/vendor/docs/02-guides/onboarding.md +244 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
- package/vendor/docs/02-guides/static-sites.md +364 -0
- package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
- package/vendor/docs/03-architecture/installer-flow.md +151 -0
- package/vendor/docs/03-architecture/installer.md +445 -0
- package/vendor/docs/03-architecture/management-flow.md +288 -0
- package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
- package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
- package/vendor/docs/03-architecture/overview.md +541 -0
- package/vendor/docs/03-architecture/panel-client.md +416 -0
- package/vendor/docs/03-architecture/panel-server.md +695 -0
- package/vendor/docs/03-architecture/state-management.md +529 -0
- package/vendor/docs/03-architecture/system-overview.md +167 -0
- package/vendor/docs/04-api-reference/certificates.md +1141 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
- package/vendor/docs/04-api-reference/onboarding.md +473 -0
- package/vendor/docs/04-api-reference/overview.md +404 -0
- package/vendor/docs/04-api-reference/services.md +267 -0
- package/vendor/docs/04-api-reference/settings.md +343 -0
- package/vendor/docs/04-api-reference/sites.md +716 -0
- package/vendor/docs/04-api-reference/system.md +268 -0
- package/vendor/docs/04-api-reference/tickets.md +618 -0
- package/vendor/docs/04-api-reference/tunnels.md +417 -0
- package/vendor/docs/04-api-reference/users.md +333 -0
- package/vendor/docs/05-operations/backup-and-restore.md +342 -0
- package/vendor/docs/05-operations/monitoring.md +285 -0
- package/vendor/docs/05-operations/uninstalling.md +344 -0
- package/vendor/docs/05-operations/upgrades.md +270 -0
- package/vendor/docs/06-reference/config-files.md +1080 -0
- package/vendor/docs/06-reference/glossary.md +528 -0
- package/vendor/docs/06-reference/installer-flags.md +260 -0
- package/vendor/docs/06-reference/ports-and-services.md +244 -0
- package/vendor/docs/06-reference/troubleshooting.md +665 -0
- package/vendor/docs/_index.json +233 -0
- package/vendor/docs/gatekeeper-design.md +953 -0
- package/vendor/docs/package.json +15 -0
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
- package/vendor/gatekeeper/dist/boot.d.ts +1 -0
- package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/boot.js +17 -0
- package/vendor/gatekeeper/dist/boot.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.js +45 -0
- package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
- package/vendor/gatekeeper/dist/index.d.ts +9 -0
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/index.js +13 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.js +42 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.js +79 -0
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.js +314 -0
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.js +247 -0
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.js +79 -0
- package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
- package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.js +151 -0
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.js +2 -0
- package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/index.js +251 -0
- package/vendor/gatekeeper/dist/server/index.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
- package/vendor/gatekeeper/package.json +49 -0
- package/vendor/server/bin/lamaste-server.js +204 -0
- package/vendor/server/package.json +31 -0
- package/vendor/server/src/commands/certs.js +224 -0
- package/vendor/server/src/commands/chisel.js +132 -0
- package/vendor/server/src/commands/logs.js +49 -0
- package/vendor/server/src/commands/plugins.js +321 -0
- package/vendor/server/src/commands/reset-admin.js +478 -0
- package/vendor/server/src/commands/restart.js +94 -0
- package/vendor/server/src/commands/sites.js +257 -0
- package/vendor/server/src/commands/status.js +158 -0
- package/vendor/server/src/commands/tunnels.js +365 -0
- package/vendor/server/src/commands/uninstall.js +326 -0
- package/vendor/server/src/config.js +38 -0
- package/vendor/server/src/exec.js +26 -0
- package/vendor/server/src/logger.js +39 -0
- package/vendor/server/src/ndjson.js +56 -0
- package/vendor/server/src/panel-api.js +85 -0
- package/vendor/server/src/state.js +45 -0
- package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
- package/vendor/server-ui/dist/index.html +13 -0
- package/vendor/serverd/package.json +44 -0
- package/vendor/serverd/src/boot.js +13 -0
- package/vendor/serverd/src/index.js +191 -0
- package/vendor/serverd/src/lib/app-error.js +14 -0
- package/vendor/serverd/src/lib/authelia.js +85 -0
- package/vendor/serverd/src/lib/certbot.js +74 -0
- package/vendor/serverd/src/lib/chisel-args.js +6 -0
- package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
- package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
- package/vendor/serverd/src/lib/chisel-users.js +109 -0
- package/vendor/serverd/src/lib/chisel.js +81 -0
- package/vendor/serverd/src/lib/config.js +190 -0
- package/vendor/serverd/src/lib/constants.js +8 -0
- package/vendor/serverd/src/lib/csr-signing.js +331 -0
- package/vendor/serverd/src/lib/enrollment.js +302 -0
- package/vendor/serverd/src/lib/files.js +19 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
- package/vendor/serverd/src/lib/invite-page.js +281 -0
- package/vendor/serverd/src/lib/ip-utils.js +79 -0
- package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
- package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
- package/vendor/serverd/src/lib/mtls.js +795 -0
- package/vendor/serverd/src/lib/nginx.js +1090 -0
- package/vendor/serverd/src/lib/pki-paths.js +41 -0
- package/vendor/serverd/src/lib/pki-sign.js +103 -0
- package/vendor/serverd/src/lib/plugins.js +538 -0
- package/vendor/serverd/src/lib/push-install.js +390 -0
- package/vendor/serverd/src/lib/revocation.js +112 -0
- package/vendor/serverd/src/lib/services.js +131 -0
- package/vendor/serverd/src/lib/session.js +151 -0
- package/vendor/serverd/src/lib/state-db.js +130 -0
- package/vendor/serverd/src/lib/state.js +140 -0
- package/vendor/serverd/src/lib/storage.js +63 -0
- package/vendor/serverd/src/lib/system-stats.js +58 -0
- package/vendor/serverd/src/lib/tickets.js +1556 -0
- package/vendor/serverd/src/lib/totp.js +76 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
- package/vendor/serverd/src/lib/user-access-session.js +142 -0
- package/vendor/serverd/src/lib/user-access.js +52 -0
- package/vendor/serverd/src/middleware/errors.js +63 -0
- package/vendor/serverd/src/middleware/mtls.js +120 -0
- package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
- package/vendor/serverd/src/middleware/role-guard.js +58 -0
- package/vendor/serverd/src/middleware/twofa-session.js +65 -0
- package/vendor/serverd/src/middleware/user-access-session.js +52 -0
- package/vendor/serverd/src/routes/enrollment.js +120 -0
- package/vendor/serverd/src/routes/health.js +22 -0
- package/vendor/serverd/src/routes/invite.js +163 -0
- package/vendor/serverd/src/routes/management/agents.js +182 -0
- package/vendor/serverd/src/routes/management/certs.js +911 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
- package/vendor/serverd/src/routes/management/identity.js +107 -0
- package/vendor/serverd/src/routes/management/invitations.js +166 -0
- package/vendor/serverd/src/routes/management/logs.js +218 -0
- package/vendor/serverd/src/routes/management/plugins.js +560 -0
- package/vendor/serverd/src/routes/management/services.js +63 -0
- package/vendor/serverd/src/routes/management/settings.js +325 -0
- package/vendor/serverd/src/routes/management/sites.js +522 -0
- package/vendor/serverd/src/routes/management/storage.js +192 -0
- package/vendor/serverd/src/routes/management/system.js +201 -0
- package/vendor/serverd/src/routes/management/tickets.js +457 -0
- package/vendor/serverd/src/routes/management/tunnels.js +709 -0
- package/vendor/serverd/src/routes/management/users.js +377 -0
- package/vendor/serverd/src/routes/management.js +36 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
- package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
- package/vendor/serverd/src/routes/onboarding/index.js +18 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
- package/vendor/serverd/src/routes/onboarding/status.js +12 -0
- package/vendor/serverd/src/routes/plugin-router.js +79 -0
- package/vendor/serverd/src/routes/user-access.js +389 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { access, lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
6
|
+
const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The root helper that installs vhosts (create-lamaste installs it; see
|
|
10
|
+
* provisioners/server/scripts/lamaste-priv). It accepts only the site names
|
|
11
|
+
* the panel manages and only vhosts built from the directives these
|
|
12
|
+
* generators use: nginx opens the files a configuration names as root, so a
|
|
13
|
+
* free-form vhost would be a root shell for whoever controls the panel.
|
|
14
|
+
*/
|
|
15
|
+
const PRIV = '/usr/local/sbin/lamaste-priv';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run one `lamaste-priv nginx-site` action.
|
|
19
|
+
*
|
|
20
|
+
* @param {'write'|'backup'|'restore'|'discard-backup'|'enable'|'disable'|'remove'} action
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @param {string} [content] - vhost text, for `write`
|
|
23
|
+
*/
|
|
24
|
+
async function siteAction(action, name, content) {
|
|
25
|
+
try {
|
|
26
|
+
await execa('sudo', [PRIV, 'nginx-site', action, name], {
|
|
27
|
+
...(content === undefined ? {} : { input: content }),
|
|
28
|
+
});
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`nginx site ${action} failed for ${name}: ${err.stderr || err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when `name` exists in sites-available. The directory is world-readable. */
|
|
35
|
+
async function siteExists(name) {
|
|
36
|
+
try {
|
|
37
|
+
await access(path.join(SITES_AVAILABLE, name));
|
|
38
|
+
return true;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.code === 'ENOENT') return false;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Install `content` as sites-available/<name>.
|
|
47
|
+
*/
|
|
48
|
+
async function writeVhostFile(name, content) {
|
|
49
|
+
await siteAction('write', name, content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write the panel domain vhost with mTLS and Let's Encrypt certs.
|
|
54
|
+
*/
|
|
55
|
+
export async function writePanelVhost(domain) {
|
|
56
|
+
const fqdn = `panel.${domain}`;
|
|
57
|
+
const config = `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
|
|
58
|
+
limit_req_zone $binary_remote_addr zone=enroll_domain:1m rate=5r/m;
|
|
59
|
+
|
|
60
|
+
# WebSocket upgrade map — must be at http context level
|
|
61
|
+
map $http_upgrade $connection_upgrade {
|
|
62
|
+
default upgrade;
|
|
63
|
+
'' close;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
server {
|
|
67
|
+
listen 443 ssl;
|
|
68
|
+
server_name ${fqdn};
|
|
69
|
+
|
|
70
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
71
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
72
|
+
|
|
73
|
+
# mTLS — same as IP-based access
|
|
74
|
+
include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
|
|
75
|
+
|
|
76
|
+
# SSL settings
|
|
77
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
78
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
79
|
+
ssl_prefer_server_ciphers on;
|
|
80
|
+
|
|
81
|
+
# Default location (mTLS required — reject if cert missing or invalid)
|
|
82
|
+
location / {
|
|
83
|
+
if ($ssl_client_verify != SUCCESS) {
|
|
84
|
+
return 496;
|
|
85
|
+
}
|
|
86
|
+
proxy_pass http://127.0.0.1:3100;
|
|
87
|
+
|
|
88
|
+
# Client cert headers — set from nginx TLS variables, never passed through from client
|
|
89
|
+
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
90
|
+
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
91
|
+
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
|
|
92
|
+
|
|
93
|
+
# Strip Authelia identity headers — not trusted on mTLS vhost
|
|
94
|
+
proxy_set_header Remote-User "";
|
|
95
|
+
proxy_set_header Remote-Groups "";
|
|
96
|
+
proxy_set_header Remote-Name "";
|
|
97
|
+
proxy_set_header Remote-Email "";
|
|
98
|
+
|
|
99
|
+
# Standard proxy headers
|
|
100
|
+
proxy_set_header Host $host;
|
|
101
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
102
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
103
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Public API paths (no mTLS verification check)
|
|
107
|
+
location /api/enroll {
|
|
108
|
+
limit_req zone=enroll_domain burst=5 nodelay;
|
|
109
|
+
proxy_pass http://127.0.0.1:3100;
|
|
110
|
+
proxy_http_version 1.1;
|
|
111
|
+
|
|
112
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
113
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
114
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
115
|
+
|
|
116
|
+
# Strip Authelia identity headers — not trusted on public endpoint
|
|
117
|
+
proxy_set_header Remote-User "";
|
|
118
|
+
proxy_set_header Remote-Groups "";
|
|
119
|
+
proxy_set_header Remote-Name "";
|
|
120
|
+
proxy_set_header Remote-Email "";
|
|
121
|
+
|
|
122
|
+
proxy_set_header Host $host;
|
|
123
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
124
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
125
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
location /api/invite {
|
|
129
|
+
proxy_pass http://127.0.0.1:3100;
|
|
130
|
+
proxy_http_version 1.1;
|
|
131
|
+
|
|
132
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
133
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
134
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
135
|
+
|
|
136
|
+
# Strip Authelia identity headers — not trusted on public endpoint
|
|
137
|
+
proxy_set_header Remote-User "";
|
|
138
|
+
proxy_set_header Remote-Groups "";
|
|
139
|
+
proxy_set_header Remote-Name "";
|
|
140
|
+
proxy_set_header Remote-Email "";
|
|
141
|
+
|
|
142
|
+
proxy_set_header Host $host;
|
|
143
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
144
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
145
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# User access public endpoints (no mTLS — Bearer token auth handled by panel server)
|
|
149
|
+
location /api/user-access/exchange {
|
|
150
|
+
limit_req zone=enroll_domain burst=5 nodelay;
|
|
151
|
+
proxy_pass http://127.0.0.1:3100;
|
|
152
|
+
proxy_http_version 1.1;
|
|
153
|
+
|
|
154
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
155
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
156
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
157
|
+
|
|
158
|
+
proxy_set_header Remote-User "";
|
|
159
|
+
proxy_set_header Remote-Groups "";
|
|
160
|
+
proxy_set_header Remote-Name "";
|
|
161
|
+
proxy_set_header Remote-Email "";
|
|
162
|
+
|
|
163
|
+
proxy_set_header Host $host;
|
|
164
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
165
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
166
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
location /api/user-access/plugins {
|
|
170
|
+
limit_req zone=enroll_domain burst=5 nodelay;
|
|
171
|
+
proxy_pass http://127.0.0.1:3100;
|
|
172
|
+
proxy_http_version 1.1;
|
|
173
|
+
|
|
174
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
175
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
176
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
177
|
+
|
|
178
|
+
proxy_set_header Remote-User "";
|
|
179
|
+
proxy_set_header Remote-Groups "";
|
|
180
|
+
proxy_set_header Remote-Name "";
|
|
181
|
+
proxy_set_header Remote-Email "";
|
|
182
|
+
|
|
183
|
+
proxy_set_header Host $host;
|
|
184
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
185
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
186
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
location /api/user-access/enroll {
|
|
190
|
+
limit_req zone=enroll_domain burst=5 nodelay;
|
|
191
|
+
proxy_pass http://127.0.0.1:3100;
|
|
192
|
+
proxy_http_version 1.1;
|
|
193
|
+
|
|
194
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
195
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
196
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
197
|
+
|
|
198
|
+
proxy_set_header Remote-User "";
|
|
199
|
+
proxy_set_header Remote-Groups "";
|
|
200
|
+
proxy_set_header Remote-Name "";
|
|
201
|
+
proxy_set_header Remote-Email "";
|
|
202
|
+
|
|
203
|
+
proxy_set_header Host $host;
|
|
204
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
205
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
206
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# API paths with WebSocket upgrade support (mTLS required)
|
|
210
|
+
location /api {
|
|
211
|
+
if ($ssl_client_verify != SUCCESS) {
|
|
212
|
+
return 496;
|
|
213
|
+
}
|
|
214
|
+
proxy_pass http://127.0.0.1:3100;
|
|
215
|
+
proxy_http_version 1.1;
|
|
216
|
+
|
|
217
|
+
# Client cert headers — set from nginx TLS variables, never passed through from client
|
|
218
|
+
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
219
|
+
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
220
|
+
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
|
|
221
|
+
|
|
222
|
+
# Strip Authelia identity headers — not trusted on mTLS vhost
|
|
223
|
+
proxy_set_header Remote-User "";
|
|
224
|
+
proxy_set_header Remote-Groups "";
|
|
225
|
+
proxy_set_header Remote-Name "";
|
|
226
|
+
proxy_set_header Remote-Email "";
|
|
227
|
+
|
|
228
|
+
# Standard proxy headers
|
|
229
|
+
proxy_set_header Host $host;
|
|
230
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
231
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
232
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
233
|
+
|
|
234
|
+
# WebSocket: only upgrade when client requests it
|
|
235
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
236
|
+
proxy_set_header Connection $connection_upgrade;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
await writeVhostFile('lamalibre-lamaste-panel-domain', config);
|
|
242
|
+
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Write the Authelia auth portal vhost.
|
|
247
|
+
*/
|
|
248
|
+
export async function writeAuthVhost(domain) {
|
|
249
|
+
const fqdn = `auth.${domain}`;
|
|
250
|
+
const config = `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
|
|
251
|
+
limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
|
|
252
|
+
|
|
253
|
+
server {
|
|
254
|
+
listen 443 ssl;
|
|
255
|
+
server_name ${fqdn};
|
|
256
|
+
|
|
257
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
258
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
259
|
+
|
|
260
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
261
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
262
|
+
ssl_prefer_server_ciphers on;
|
|
263
|
+
|
|
264
|
+
proxy_set_header Host $host;
|
|
265
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
266
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
267
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
268
|
+
|
|
269
|
+
# Invitation page — static HTML
|
|
270
|
+
location /invite/ {
|
|
271
|
+
alias /var/www/lamaste/invite/;
|
|
272
|
+
try_files $uri /invite/index.html;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Invitation API — proxied to panel server (no mTLS)
|
|
276
|
+
location /api/invite/ {
|
|
277
|
+
proxy_pass http://127.0.0.1:3100/api/invite/;
|
|
278
|
+
proxy_set_header Host $host;
|
|
279
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
280
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
281
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# User access authorization — Authelia forward auth protects this endpoint
|
|
285
|
+
# so Remote-User header is set by Authelia on successful authentication.
|
|
286
|
+
location /internal/authelia/authz {
|
|
287
|
+
internal;
|
|
288
|
+
|
|
289
|
+
proxy_pass http://127.0.0.1:9091/api/authz/auth-request;
|
|
290
|
+
proxy_pass_request_body off;
|
|
291
|
+
|
|
292
|
+
proxy_set_header Content-Length "";
|
|
293
|
+
proxy_set_header Connection "";
|
|
294
|
+
proxy_set_header X-Original-Method $request_method;
|
|
295
|
+
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
296
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
297
|
+
|
|
298
|
+
proxy_http_version 1.1;
|
|
299
|
+
proxy_buffers 4 32k;
|
|
300
|
+
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
location /api/user-access/authorize {
|
|
304
|
+
limit_req zone=user_access_auth burst=3 nodelay;
|
|
305
|
+
|
|
306
|
+
# Clear client-supplied identity headers (Authelia re-injects on success)
|
|
307
|
+
proxy_set_header Remote-User "";
|
|
308
|
+
proxy_set_header Remote-Groups "";
|
|
309
|
+
proxy_set_header Remote-Name "";
|
|
310
|
+
proxy_set_header Remote-Email "";
|
|
311
|
+
|
|
312
|
+
auth_request /internal/authelia/authz;
|
|
313
|
+
auth_request_set $user $upstream_http_remote_user;
|
|
314
|
+
auth_request_set $redirection_url $upstream_http_location;
|
|
315
|
+
|
|
316
|
+
proxy_set_header Remote-User $user;
|
|
317
|
+
|
|
318
|
+
proxy_pass http://127.0.0.1:3100;
|
|
319
|
+
proxy_set_header Host $host;
|
|
320
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
321
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
322
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
323
|
+
|
|
324
|
+
error_page 401 =302 $redirection_url;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# Default — proxy to Authelia
|
|
328
|
+
location / {
|
|
329
|
+
proxy_pass http://127.0.0.1:9091;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
await writeVhostFile('lamalibre-lamaste-auth', config);
|
|
335
|
+
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-auth');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Write the Chisel tunnel WebSocket vhost.
|
|
340
|
+
*/
|
|
341
|
+
export async function writeTunnelVhost(domain) {
|
|
342
|
+
const fqdn = `tunnel.${domain}`;
|
|
343
|
+
const config = `server {
|
|
344
|
+
listen 443 ssl;
|
|
345
|
+
server_name ${fqdn};
|
|
346
|
+
|
|
347
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
348
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
349
|
+
|
|
350
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
351
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
352
|
+
ssl_prefer_server_ciphers on;
|
|
353
|
+
|
|
354
|
+
proxy_set_header Host $host;
|
|
355
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
356
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
357
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
358
|
+
|
|
359
|
+
location / {
|
|
360
|
+
proxy_pass http://127.0.0.1:9090;
|
|
361
|
+
proxy_http_version 1.1;
|
|
362
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
363
|
+
proxy_set_header Connection "upgrade";
|
|
364
|
+
|
|
365
|
+
# Long timeout for WebSocket tunnel connections
|
|
366
|
+
proxy_read_timeout 86400s;
|
|
367
|
+
proxy_send_timeout 86400s;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
`;
|
|
371
|
+
|
|
372
|
+
await writeVhostFile('lamalibre-lamaste-tunnel', config);
|
|
373
|
+
return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-tunnel');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Request headers every tunnel location sends upstream.
|
|
378
|
+
*
|
|
379
|
+
* They are declared inside each `location`, not at server level: nginx
|
|
380
|
+
* inherits `proxy_set_header` from the enclosing level only when a level
|
|
381
|
+
* declares none of its own, and every tunnel location declares some — so
|
|
382
|
+
* server-level headers never reached the app (it saw `Host: 127.0.0.1:<port>`).
|
|
383
|
+
*
|
|
384
|
+
* The client-certificate headers are cleared. The panel trusts
|
|
385
|
+
* `X-SSL-Client-*` from nginx as proof of an mTLS client certificate; a
|
|
386
|
+
* tunnel vhost must never pass client-supplied values through, whatever
|
|
387
|
+
* listens on its port.
|
|
388
|
+
*/
|
|
389
|
+
const TUNNEL_PROXY_HEADERS = `
|
|
390
|
+
proxy_set_header Host $host;
|
|
391
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
392
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
393
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
394
|
+
|
|
395
|
+
# Never forward client-supplied certificate headers (see above)
|
|
396
|
+
proxy_set_header X-SSL-Client-Verify "";
|
|
397
|
+
proxy_set_header X-SSL-Client-DN "";
|
|
398
|
+
proxy_set_header X-SSL-Client-Serial "";
|
|
399
|
+
|
|
400
|
+
# WebSocket support
|
|
401
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
402
|
+
proxy_set_header Connection "upgrade";`;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Render the per-tunnel `client_max_body_size` directive. Without it nginx
|
|
406
|
+
* applies its built-in 1 MiB limit, which rejects ordinary uploads with 413.
|
|
407
|
+
* Validated here as well as in the tunnel workflow because the value is
|
|
408
|
+
* interpolated into nginx configuration.
|
|
409
|
+
*
|
|
410
|
+
* @param {number | undefined} maxBodySizeMb
|
|
411
|
+
* @returns {string} a directive line, or '' when no limit was given
|
|
412
|
+
*/
|
|
413
|
+
function bodySizeDirective(maxBodySizeMb) {
|
|
414
|
+
if (maxBodySizeMb === undefined) return '';
|
|
415
|
+
if (!Number.isInteger(maxBodySizeMb) || maxBodySizeMb < 1 || maxBodySizeMb > 10240) {
|
|
416
|
+
throw new Error(`Invalid maxBodySizeMb for nginx vhost: ${maxBodySizeMb}`);
|
|
417
|
+
}
|
|
418
|
+
return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Write a public vhost — no authentication, direct proxy.
|
|
423
|
+
* Used for tunnels with accessMode='public'.
|
|
424
|
+
*/
|
|
425
|
+
export async function writePublicVhost(
|
|
426
|
+
subdomain,
|
|
427
|
+
domain,
|
|
428
|
+
port,
|
|
429
|
+
certPath,
|
|
430
|
+
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
431
|
+
) {
|
|
432
|
+
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
433
|
+
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
437
|
+
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
438
|
+
let certDirClean = certDir;
|
|
439
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
440
|
+
|
|
441
|
+
const config = `server {
|
|
442
|
+
listen 443 ssl;
|
|
443
|
+
server_name ${fqdn};
|
|
444
|
+
|
|
445
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
446
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
447
|
+
|
|
448
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
449
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
450
|
+
ssl_prefer_server_ciphers on;
|
|
451
|
+
${bodySizeDirective(maxBodySizeMb)}
|
|
452
|
+
location / {
|
|
453
|
+
# A public tunnel has no authenticated user: clear identity headers
|
|
454
|
+
# an app might trust from an authenticated vhost.
|
|
455
|
+
proxy_set_header Remote-User "";
|
|
456
|
+
proxy_set_header Remote-Groups "";
|
|
457
|
+
proxy_set_header Remote-Name "";
|
|
458
|
+
proxy_set_header Remote-Email "";
|
|
459
|
+
${TUNNEL_PROXY_HEADERS}
|
|
460
|
+
|
|
461
|
+
${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
|
|
462
|
+
proxy_http_version 1.1;
|
|
463
|
+
|
|
464
|
+
proxy_read_timeout 86400s;
|
|
465
|
+
proxy_send_timeout 86400s;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
`;
|
|
469
|
+
|
|
470
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
471
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
|
|
476
|
+
* Used for tunnels with accessMode='authenticated'.
|
|
477
|
+
* Gatekeeper handles Authelia cookie validation and forwards identity headers.
|
|
478
|
+
*/
|
|
479
|
+
export async function writeAuthenticatedVhost(
|
|
480
|
+
subdomain,
|
|
481
|
+
domain,
|
|
482
|
+
port,
|
|
483
|
+
certPath,
|
|
484
|
+
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
485
|
+
) {
|
|
486
|
+
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
487
|
+
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
491
|
+
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
492
|
+
let certDirClean = certDir;
|
|
493
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
494
|
+
|
|
495
|
+
const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
|
|
496
|
+
pathPrefix,
|
|
497
|
+
maxBodySizeMb,
|
|
498
|
+
restricted: false,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
502
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Write a restricted vhost — Authelia via Gatekeeper, only granted users/groups allowed.
|
|
507
|
+
* Used for tunnels with accessMode='restricted'.
|
|
508
|
+
* On 403, Gatekeeper returns the inline access-request HTML page.
|
|
509
|
+
*/
|
|
510
|
+
export async function writeRestrictedVhost(
|
|
511
|
+
subdomain,
|
|
512
|
+
domain,
|
|
513
|
+
port,
|
|
514
|
+
certPath,
|
|
515
|
+
{ pathPrefix, maxBodySizeMb, enabled = true } = {},
|
|
516
|
+
) {
|
|
517
|
+
if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
|
|
518
|
+
throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
522
|
+
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
523
|
+
let certDirClean = certDir;
|
|
524
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
525
|
+
|
|
526
|
+
const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
|
|
527
|
+
pathPrefix,
|
|
528
|
+
maxBodySizeMb,
|
|
529
|
+
restricted: true,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
533
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Build a Gatekeeper-backed nginx vhost config string.
|
|
538
|
+
* Both 'authenticated' and 'restricted' modes use this — the difference
|
|
539
|
+
* is handled server-side by Gatekeeper based on the tunnel's accessMode.
|
|
540
|
+
* For restricted mode, the 403 body from Gatekeeper is served inline.
|
|
541
|
+
*/
|
|
542
|
+
function buildGatekeeperVhost(
|
|
543
|
+
fqdn,
|
|
544
|
+
certDirClean,
|
|
545
|
+
port,
|
|
546
|
+
domain,
|
|
547
|
+
{ pathPrefix, maxBodySizeMb, restricted } = {},
|
|
548
|
+
) {
|
|
549
|
+
return `server {
|
|
550
|
+
listen 443 ssl;
|
|
551
|
+
server_name ${fqdn};
|
|
552
|
+
|
|
553
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
554
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
555
|
+
|
|
556
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
557
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
558
|
+
ssl_prefer_server_ciphers on;
|
|
559
|
+
${bodySizeDirective(maxBodySizeMb)}
|
|
560
|
+
# Gatekeeper authorization subrequest (handles Authelia validation + grant check)
|
|
561
|
+
location /internal/lamaste/authz {
|
|
562
|
+
internal;
|
|
563
|
+
|
|
564
|
+
proxy_pass http://127.0.0.1:9294/authz/check;
|
|
565
|
+
proxy_pass_request_body off;
|
|
566
|
+
|
|
567
|
+
proxy_set_header Content-Length "";
|
|
568
|
+
proxy_set_header Connection "";
|
|
569
|
+
proxy_set_header X-Original-Method $request_method;
|
|
570
|
+
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
571
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
572
|
+
proxy_set_header Host $http_host;
|
|
573
|
+
proxy_set_header Cookie $http_cookie;
|
|
574
|
+
|
|
575
|
+
proxy_http_version 1.1;
|
|
576
|
+
proxy_buffers 4 32k;
|
|
577
|
+
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
location / {
|
|
581
|
+
auth_request /internal/lamaste/authz;
|
|
582
|
+
auth_request_set $user $upstream_http_remote_user;
|
|
583
|
+
auth_request_set $groups $upstream_http_remote_groups;
|
|
584
|
+
auth_request_set $name $upstream_http_remote_name;
|
|
585
|
+
auth_request_set $email $upstream_http_remote_email;
|
|
586
|
+
|
|
587
|
+
proxy_set_header Remote-User $user;
|
|
588
|
+
proxy_set_header Remote-Groups $groups;
|
|
589
|
+
proxy_set_header Remote-Name $name;
|
|
590
|
+
proxy_set_header Remote-Email $email;
|
|
591
|
+
${TUNNEL_PROXY_HEADERS}
|
|
592
|
+
|
|
593
|
+
${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
|
|
594
|
+
proxy_http_version 1.1;
|
|
595
|
+
|
|
596
|
+
proxy_read_timeout 86400s;
|
|
597
|
+
proxy_send_timeout 86400s;
|
|
598
|
+
|
|
599
|
+
# Authentication failure -> redirect to Authelia login
|
|
600
|
+
error_page 401 =302 https://auth.${domain}/?rd=$scheme://$http_host$request_uri;
|
|
601
|
+
|
|
602
|
+
${
|
|
603
|
+
restricted
|
|
604
|
+
? `# Authorization failure -> Gatekeeper serves inline access-request page
|
|
605
|
+
error_page 403 = /internal/lamaste/authz;`
|
|
606
|
+
: ''
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** True when `name` is linked into sites-enabled. The directory is world-readable. */
|
|
614
|
+
async function isSiteEnabled(name) {
|
|
615
|
+
try {
|
|
616
|
+
await lstat(path.join(SITES_ENABLED, name));
|
|
617
|
+
return true;
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (err.code === 'ENOENT') return false;
|
|
620
|
+
throw err;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Shared safe-write logic: backup -> write -> link state -> test -> reload,
|
|
626
|
+
* with rollback.
|
|
627
|
+
*
|
|
628
|
+
* `enabled` decides whether the site is linked into sites-enabled after the
|
|
629
|
+
* write. On any failure the previous file (or its absence) and the previous
|
|
630
|
+
* link state are restored, so rewriting a disabled tunnel's vhost can never
|
|
631
|
+
* publish it — not even through a failed write.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} name
|
|
634
|
+
* @param {string} config
|
|
635
|
+
* @param {string} fqdn
|
|
636
|
+
* @param {{ enabled?: boolean }} [opts]
|
|
637
|
+
*/
|
|
638
|
+
async function safeWriteVhost(name, config, fqdn, { enabled = true } = {}) {
|
|
639
|
+
const availablePath = path.join(SITES_AVAILABLE, name);
|
|
640
|
+
|
|
641
|
+
const existed = await siteExists(name);
|
|
642
|
+
const wasEnabled = await isSiteEnabled(name);
|
|
643
|
+
if (existed) {
|
|
644
|
+
await siteAction('backup', name);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const restore = async () => {
|
|
648
|
+
if (existed) {
|
|
649
|
+
await siteAction('restore', name).catch(() => {});
|
|
650
|
+
} else {
|
|
651
|
+
await siteAction('remove', name).catch(() => {});
|
|
652
|
+
}
|
|
653
|
+
if (existed && wasEnabled) {
|
|
654
|
+
await enableSite(name).catch(() => {});
|
|
655
|
+
} else {
|
|
656
|
+
await disableSite(name).catch(() => {});
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
await writeVhostFile(name, config);
|
|
662
|
+
if (enabled) {
|
|
663
|
+
await enableSite(name);
|
|
664
|
+
} else {
|
|
665
|
+
await disableSite(name);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const result = await testConfig();
|
|
669
|
+
if (!result.valid) {
|
|
670
|
+
await restore();
|
|
671
|
+
throw Object.assign(
|
|
672
|
+
new Error(`Nginx config test failed after writing vhost for ${fqdn}: ${result.error}`),
|
|
673
|
+
{ restored: true },
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await reload();
|
|
678
|
+
|
|
679
|
+
if (existed) {
|
|
680
|
+
await siteAction('discard-backup', name).catch(() => {});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return availablePath;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
if (!err.restored) await restore();
|
|
686
|
+
throw err;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Write a static site vhost with optional Authelia forward auth.
|
|
692
|
+
*
|
|
693
|
+
* Written through safeWriteVhost (backup, write, enable, test, reload; on
|
|
694
|
+
* failure the previous file and link state are restored).
|
|
695
|
+
*
|
|
696
|
+
* @param {object} site - The site object from sites.json
|
|
697
|
+
* @param {string} site.id - Site UUID
|
|
698
|
+
* @param {string} site.fqdn - Full domain (e.g., "blog.example.com")
|
|
699
|
+
* @param {boolean} site.spaMode - If true, try_files falls back to /index.html
|
|
700
|
+
* @param {boolean} site.autheliaProtected - If true, add Authelia forward auth
|
|
701
|
+
* @param {string} site.rootPath - Directory root (e.g., /var/www/lamaste/{id}/)
|
|
702
|
+
* @param {string} certDir - Certificate directory path
|
|
703
|
+
* @param {string} [domain] - Base domain (needed for Authelia redirect URL)
|
|
704
|
+
*/
|
|
705
|
+
export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
706
|
+
let certDirClean = certDir;
|
|
707
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
708
|
+
const aliasBlock = buildAliasRedirectBlock(site, certDirClean);
|
|
709
|
+
const tryFiles = site.spaMode ? 'try_files $uri $uri/ /index.html' : 'try_files $uri $uri/ =404';
|
|
710
|
+
|
|
711
|
+
let autheliaBlock = '';
|
|
712
|
+
let locationAuthDirectives = '';
|
|
713
|
+
|
|
714
|
+
if (site.autheliaProtected && domain) {
|
|
715
|
+
autheliaBlock = `
|
|
716
|
+
# Authelia forward authentication (AuthRequest implementation for nginx)
|
|
717
|
+
location /internal/authelia/authz {
|
|
718
|
+
internal;
|
|
719
|
+
|
|
720
|
+
proxy_pass http://127.0.0.1:9091/api/authz/auth-request;
|
|
721
|
+
proxy_pass_request_body off;
|
|
722
|
+
|
|
723
|
+
proxy_set_header Content-Length "";
|
|
724
|
+
proxy_set_header Connection "";
|
|
725
|
+
proxy_set_header X-Original-Method $request_method;
|
|
726
|
+
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
727
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
728
|
+
|
|
729
|
+
proxy_http_version 1.1;
|
|
730
|
+
proxy_buffers 4 32k;
|
|
731
|
+
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
732
|
+
}
|
|
733
|
+
`;
|
|
734
|
+
locationAuthDirectives = `
|
|
735
|
+
# Clear client-supplied identity headers (Authelia re-injects on success)
|
|
736
|
+
proxy_set_header Remote-User "";
|
|
737
|
+
proxy_set_header Remote-Groups "";
|
|
738
|
+
proxy_set_header Remote-Name "";
|
|
739
|
+
proxy_set_header Remote-Email "";
|
|
740
|
+
|
|
741
|
+
auth_request /internal/authelia/authz;
|
|
742
|
+
auth_request_set $user $upstream_http_remote_user;
|
|
743
|
+
auth_request_set $groups $upstream_http_remote_groups;
|
|
744
|
+
# $name and $email captured for future proxy use (no proxy_pass in static sites)
|
|
745
|
+
auth_request_set $name $upstream_http_remote_name;
|
|
746
|
+
auth_request_set $email $upstream_http_remote_email;
|
|
747
|
+
auth_request_set $redirection_url $upstream_http_location;`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const config = `server {
|
|
751
|
+
listen 443 ssl;
|
|
752
|
+
server_name ${site.fqdn};
|
|
753
|
+
|
|
754
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
755
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
756
|
+
|
|
757
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
758
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
759
|
+
ssl_prefer_server_ciphers on;
|
|
760
|
+
|
|
761
|
+
root ${site.rootPath};
|
|
762
|
+
index index.html;
|
|
763
|
+
|
|
764
|
+
# Security headers
|
|
765
|
+
add_header X-Frame-Options SAMEORIGIN always;
|
|
766
|
+
add_header X-Content-Type-Options nosniff always;
|
|
767
|
+
${autheliaBlock}
|
|
768
|
+
location / {${locationAuthDirectives}
|
|
769
|
+
${tryFiles};
|
|
770
|
+
}
|
|
771
|
+
${
|
|
772
|
+
site.autheliaProtected && domain
|
|
773
|
+
? `
|
|
774
|
+
# Redirect unauthenticated requests to Authelia login portal
|
|
775
|
+
error_page 401 =302 $redirection_url;
|
|
776
|
+
`
|
|
777
|
+
: ''
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
${aliasBlock}`;
|
|
781
|
+
|
|
782
|
+
const name = `lamalibre-lamaste-site-${site.id}`;
|
|
783
|
+
return safeWriteVhost(name, config, site.fqdn, { enabled: true });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Server block answering a site's aliases with a permanent redirect to the
|
|
788
|
+
* primary hostname, on the same certificate (its lineage covers every alias).
|
|
789
|
+
* Hostnames are interpolated into nginx configuration, so they are validated
|
|
790
|
+
* here as well as in the site workflow.
|
|
791
|
+
*
|
|
792
|
+
* @param {{ fqdn: string, aliases?: string[] }} site
|
|
793
|
+
* @param {string} certDirClean
|
|
794
|
+
* @returns {string} the server block, or '' when the site has no aliases
|
|
795
|
+
*/
|
|
796
|
+
function buildAliasRedirectBlock(site, certDirClean) {
|
|
797
|
+
const aliases = site.aliases ?? [];
|
|
798
|
+
if (aliases.length === 0) return '';
|
|
799
|
+
const hostname = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
800
|
+
for (const name of [site.fqdn, ...aliases]) {
|
|
801
|
+
if (!hostname.test(name)) {
|
|
802
|
+
throw new Error(`Invalid hostname for nginx vhost: ${name}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return `
|
|
806
|
+
server {
|
|
807
|
+
listen 443 ssl;
|
|
808
|
+
server_name ${aliases.join(' ')};
|
|
809
|
+
|
|
810
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
811
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
812
|
+
|
|
813
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
814
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
815
|
+
ssl_prefer_server_ciphers on;
|
|
816
|
+
|
|
817
|
+
return 301 https://${site.fqdn}$request_uri;
|
|
818
|
+
}
|
|
819
|
+
`;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Remove a static site vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
824
|
+
* Idempotent: if files don't exist, proceeds silently.
|
|
825
|
+
*
|
|
826
|
+
* @param {string} siteId - The site UUID
|
|
827
|
+
*/
|
|
828
|
+
export async function removeStaticSiteVhost(siteId) {
|
|
829
|
+
const name = `lamalibre-lamaste-site-${siteId}`;
|
|
830
|
+
await siteAction('remove', name);
|
|
831
|
+
|
|
832
|
+
const result = await testConfig();
|
|
833
|
+
if (!result.valid) {
|
|
834
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
await reload();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
842
|
+
* Idempotent: if files don't exist, proceeds silently.
|
|
843
|
+
*
|
|
844
|
+
* @param {string} subdomain - The subdomain name
|
|
845
|
+
*/
|
|
846
|
+
export async function removeAppVhost(subdomain) {
|
|
847
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
848
|
+
await siteAction('remove', name);
|
|
849
|
+
|
|
850
|
+
const result = await testConfig();
|
|
851
|
+
if (!result.valid) {
|
|
852
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
await reload();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Test the nginx configuration. Returns { valid: true } or { valid: false, error }.
|
|
860
|
+
* Does NOT throw on invalid config.
|
|
861
|
+
*/
|
|
862
|
+
export async function testConfig() {
|
|
863
|
+
try {
|
|
864
|
+
await execa('sudo', ['nginx', '-t']);
|
|
865
|
+
return { valid: true };
|
|
866
|
+
} catch (err) {
|
|
867
|
+
return { valid: false, error: err.stderr || err.message };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Reload the nginx service.
|
|
873
|
+
*/
|
|
874
|
+
export async function reload() {
|
|
875
|
+
try {
|
|
876
|
+
await execa('sudo', ['systemctl', 'reload', 'nginx']);
|
|
877
|
+
return { reloaded: true };
|
|
878
|
+
} catch (err) {
|
|
879
|
+
throw new Error(`Failed to reload nginx: ${err.stderr || err.message}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Enable a site by creating a symlink in sites-enabled.
|
|
885
|
+
*/
|
|
886
|
+
export async function enableSite(name) {
|
|
887
|
+
await siteAction('enable', name);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Disable a site by removing its symlink from sites-enabled.
|
|
892
|
+
*/
|
|
893
|
+
export async function disableSite(name) {
|
|
894
|
+
await siteAction('disable', name);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Enable an app tunnel vhost (restore symlink), test and reload nginx.
|
|
899
|
+
*
|
|
900
|
+
* @param {string} subdomain - The subdomain name
|
|
901
|
+
*/
|
|
902
|
+
export async function enableAppVhost(subdomain) {
|
|
903
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
904
|
+
await enableSite(name);
|
|
905
|
+
|
|
906
|
+
const result = await testConfig();
|
|
907
|
+
if (!result.valid) {
|
|
908
|
+
// Rollback: remove the symlink we just created
|
|
909
|
+
await disableSite(name);
|
|
910
|
+
throw new Error(`Nginx config test failed after enabling vhost ${name}: ${result.error}`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
await reload();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
|
|
918
|
+
*
|
|
919
|
+
* @param {string} subdomain - The subdomain name
|
|
920
|
+
*/
|
|
921
|
+
export async function disableAppVhost(subdomain) {
|
|
922
|
+
const name = `lamalibre-lamaste-app-${subdomain}`;
|
|
923
|
+
await disableSite(name);
|
|
924
|
+
|
|
925
|
+
const result = await testConfig();
|
|
926
|
+
if (!result.valid) {
|
|
927
|
+
// Rollback: re-enable the site
|
|
928
|
+
await enableSite(name);
|
|
929
|
+
throw new Error(`Nginx config test failed after disabling vhost ${name}: ${result.error}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
await reload();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Disable the IP-based panel vhost (used when 2FA is enabled).
|
|
937
|
+
* Tests nginx config before reloading; rolls back on failure.
|
|
938
|
+
*/
|
|
939
|
+
export async function disableIpVhost() {
|
|
940
|
+
await disableSite('lamalibre-lamaste-panel-ip');
|
|
941
|
+
const result = await testConfig();
|
|
942
|
+
if (!result.valid) {
|
|
943
|
+
await enableSite('lamalibre-lamaste-panel-ip');
|
|
944
|
+
throw new Error(`nginx test failed after disabling IP vhost: ${result.error}`);
|
|
945
|
+
}
|
|
946
|
+
await reload();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Re-enable the IP-based panel vhost (used when 2FA is disabled).
|
|
951
|
+
* Tests nginx config before reloading; rolls back on failure.
|
|
952
|
+
*/
|
|
953
|
+
export async function enableIpVhost() {
|
|
954
|
+
await enableSite('lamalibre-lamaste-panel-ip');
|
|
955
|
+
const result = await testConfig();
|
|
956
|
+
if (!result.valid) {
|
|
957
|
+
await disableSite('lamalibre-lamaste-panel-ip');
|
|
958
|
+
throw new Error(`nginx test failed after enabling IP vhost: ${result.error}`);
|
|
959
|
+
}
|
|
960
|
+
await reload();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Write an agent panel vhost with mTLS authentication (not Authelia).
|
|
965
|
+
*
|
|
966
|
+
* Written through safeWriteVhost (backup, test, rollback).
|
|
967
|
+
*
|
|
968
|
+
* @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
|
|
969
|
+
* @param {string} domain - The base domain (e.g., "example.com")
|
|
970
|
+
* @param {number} port - The local port to proxy to
|
|
971
|
+
* @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
|
|
972
|
+
*/
|
|
973
|
+
export async function writeAgentPanelVhost(
|
|
974
|
+
subdomain,
|
|
975
|
+
domain,
|
|
976
|
+
port,
|
|
977
|
+
certPath,
|
|
978
|
+
{ enabled = true } = {},
|
|
979
|
+
) {
|
|
980
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
981
|
+
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
982
|
+
let certDirClean = certDir;
|
|
983
|
+
while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
|
|
984
|
+
|
|
985
|
+
const config = `server {
|
|
986
|
+
listen 443 ssl;
|
|
987
|
+
server_name ${fqdn};
|
|
988
|
+
|
|
989
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
990
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
991
|
+
|
|
992
|
+
# mTLS — same CA as the admin panel
|
|
993
|
+
include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
|
|
994
|
+
|
|
995
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
996
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
997
|
+
ssl_prefer_server_ciphers on;
|
|
998
|
+
|
|
999
|
+
# Reject requests without valid client certificate
|
|
1000
|
+
location / {
|
|
1001
|
+
if ($ssl_client_verify != SUCCESS) {
|
|
1002
|
+
return 496;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
proxy_pass http://127.0.0.1:${port};
|
|
1006
|
+
proxy_http_version 1.1;
|
|
1007
|
+
|
|
1008
|
+
# Client cert headers — set from nginx TLS variables, never passed through from client
|
|
1009
|
+
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
1010
|
+
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
1011
|
+
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
|
|
1012
|
+
|
|
1013
|
+
# Strip Authelia identity headers — not trusted on agent panel (mTLS only)
|
|
1014
|
+
proxy_set_header Remote-User "";
|
|
1015
|
+
proxy_set_header Remote-Groups "";
|
|
1016
|
+
proxy_set_header Remote-Name "";
|
|
1017
|
+
proxy_set_header Remote-Email "";
|
|
1018
|
+
|
|
1019
|
+
# Standard proxy headers
|
|
1020
|
+
proxy_set_header Host $host;
|
|
1021
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
1022
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
1023
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
1024
|
+
|
|
1025
|
+
# WebSocket support
|
|
1026
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
1027
|
+
proxy_set_header Connection "upgrade";
|
|
1028
|
+
proxy_read_timeout 86400s;
|
|
1029
|
+
proxy_send_timeout 86400s;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
`;
|
|
1033
|
+
|
|
1034
|
+
const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
|
|
1035
|
+
return safeWriteVhost(name, config, fqdn, { enabled });
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
1040
|
+
* Idempotent: if files don't exist, proceeds silently.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
|
|
1043
|
+
*/
|
|
1044
|
+
export async function removeAgentPanelVhost(subdomain) {
|
|
1045
|
+
const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
|
|
1046
|
+
await siteAction('remove', name);
|
|
1047
|
+
|
|
1048
|
+
const result = await testConfig();
|
|
1049
|
+
if (!result.valid) {
|
|
1050
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
await reload();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Enable an agent panel vhost (restore symlink), test and reload nginx.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {string} subdomain - The subdomain name
|
|
1060
|
+
*/
|
|
1061
|
+
export async function enableAgentPanelVhost(subdomain) {
|
|
1062
|
+
const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
|
|
1063
|
+
await enableSite(name);
|
|
1064
|
+
|
|
1065
|
+
const result = await testConfig();
|
|
1066
|
+
if (!result.valid) {
|
|
1067
|
+
await disableSite(name);
|
|
1068
|
+
throw new Error(`Nginx config test failed after enabling vhost ${name}: ${result.error}`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
await reload();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
|
|
1076
|
+
*
|
|
1077
|
+
* @param {string} subdomain - The subdomain name
|
|
1078
|
+
*/
|
|
1079
|
+
export async function disableAgentPanelVhost(subdomain) {
|
|
1080
|
+
const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
|
|
1081
|
+
await disableSite(name);
|
|
1082
|
+
|
|
1083
|
+
const result = await testConfig();
|
|
1084
|
+
if (!result.valid) {
|
|
1085
|
+
await enableSite(name);
|
|
1086
|
+
throw new Error(`Nginx config test failed after disabling vhost ${name}: ${result.error}`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
await reload();
|
|
1090
|
+
}
|