nterminal 1.2.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/.env.example +12 -0
- package/LICENSE +674 -0
- package/README.md +181 -0
- package/assets/brand/app-icon-1024.png +0 -0
- package/assets/brand/app-icon-384.png +0 -0
- package/assets/brand/apple-touch-icon-360.png +0 -0
- package/assets/brand/favicon-32.png +0 -0
- package/assets/brand/favicon-64.png +0 -0
- package/assets/brand/favicon-96.png +0 -0
- package/assets/brand/favicon.svg +4 -0
- package/assets/brand/nterminal-mark-64.png +0 -0
- package/assets/brand/nterminal-mark.svg +4 -0
- package/assets/brand/nterminal-wordmark-486x68.png +0 -0
- package/assets/brand/nterminal-wordmark.svg +3 -0
- package/assets/screenshot/scr.png +0 -0
- package/bin/nterminal.js +114 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/MarkdownPreview-BeDi-V7k.js +29 -0
- package/dist/client/assets/MesloLGS-NF-Bold-Italic-DwFsXcwX.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Bold-kN-HYz-g.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Italic-CMg1T6-G.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Regular-Cxr8pvCI.ttf +0 -0
- package/dist/client/assets/index-BQkKYjXb.js +33 -0
- package/dist/client/assets/index-WqeS39wU.css +1 -0
- package/dist/client/assets/notifications/character-2258.mp4 +0 -0
- package/dist/client/assets/notifications/character-2260.mp4 +0 -0
- package/dist/client/assets/notifications/character-2272.mp4 +0 -0
- package/dist/client/brand/nterminal-mark-64.png +0 -0
- package/dist/client/brand/nterminal-mark.svg +4 -0
- package/dist/client/brand/nterminal-wordmark-486x68.png +0 -0
- package/dist/client/brand/nterminal-wordmark.svg +3 -0
- package/dist/client/icons/app-icon-1024.png +0 -0
- package/dist/client/icons/app-icon-384.png +0 -0
- package/dist/client/icons/favicon-32.png +0 -0
- package/dist/client/icons/favicon-64.png +0 -0
- package/dist/client/icons/favicon-96.png +0 -0
- package/dist/client/icons/favicon.svg +4 -0
- package/dist/client/index.html +21 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/scripts/generate-secrets.js +3 -0
- package/dist/scripts/generate-secrets.js.map +1 -0
- package/dist/scripts/onboarding.js +814 -0
- package/dist/scripts/onboarding.js.map +1 -0
- package/dist/scripts/proxySetup.js +1007 -0
- package/dist/scripts/proxySetup.js.map +1 -0
- package/dist/server/agent/agentAuth.d.ts +6 -0
- package/dist/server/agent/agentAuth.js +35 -0
- package/dist/server/agent/agentAuth.js.map +1 -0
- package/dist/server/agent/agentProxy.d.ts +5 -0
- package/dist/server/agent/agentProxy.js +63 -0
- package/dist/server/agent/agentProxy.js.map +1 -0
- package/dist/server/agent/agentRoutes.d.ts +9 -0
- package/dist/server/agent/agentRoutes.js +327 -0
- package/dist/server/agent/agentRoutes.js.map +1 -0
- package/dist/server/agent/agentWebSocketProxy.d.ts +3 -0
- package/dist/server/agent/agentWebSocketProxy.js +65 -0
- package/dist/server/agent/agentWebSocketProxy.js.map +1 -0
- package/dist/server/auth/authService.d.ts +100 -0
- package/dist/server/auth/authService.js +415 -0
- package/dist/server/auth/authService.js.map +1 -0
- package/dist/server/auth/cookies.d.ts +11 -0
- package/dist/server/auth/cookies.js +39 -0
- package/dist/server/auth/cookies.js.map +1 -0
- package/dist/server/auth/ipMatch.d.ts +14 -0
- package/dist/server/auth/ipMatch.js +103 -0
- package/dist/server/auth/ipMatch.js.map +1 -0
- package/dist/server/auth/rateLimit.d.ts +17 -0
- package/dist/server/auth/rateLimit.js +25 -0
- package/dist/server/auth/rateLimit.js.map +1 -0
- package/dist/server/auth/totpService.d.ts +10 -0
- package/dist/server/auth/totpService.js +37 -0
- package/dist/server/auth/totpService.js.map +1 -0
- package/dist/server/config.d.ts +27 -0
- package/dist/server/config.js +138 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/files/fileExplorerService.d.ts +38 -0
- package/dist/server/files/fileExplorerService.js +551 -0
- package/dist/server/files/fileExplorerService.js.map +1 -0
- package/dist/server/files/rootToken.d.ts +51 -0
- package/dist/server/files/rootToken.js +139 -0
- package/dist/server/files/rootToken.js.map +1 -0
- package/dist/server/http.d.ts +13 -0
- package/dist/server/http.js +69 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +45 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/agentManagementRoutes.d.ts +9 -0
- package/dist/server/routes/agentManagementRoutes.js +304 -0
- package/dist/server/routes/agentManagementRoutes.js.map +1 -0
- package/dist/server/routes/authRoutes.d.ts +10 -0
- package/dist/server/routes/authRoutes.js +95 -0
- package/dist/server/routes/authRoutes.js.map +1 -0
- package/dist/server/routes/fileRoutes.d.ts +11 -0
- package/dist/server/routes/fileRoutes.js +185 -0
- package/dist/server/routes/fileRoutes.js.map +1 -0
- package/dist/server/routes/notificationAssetRoutes.d.ts +9 -0
- package/dist/server/routes/notificationAssetRoutes.js +280 -0
- package/dist/server/routes/notificationAssetRoutes.js.map +1 -0
- package/dist/server/routes/securityRoutes.d.ts +7 -0
- package/dist/server/routes/securityRoutes.js +53 -0
- package/dist/server/routes/securityRoutes.js.map +1 -0
- package/dist/server/routes/socketBackpressure.d.ts +26 -0
- package/dist/server/routes/socketBackpressure.js +63 -0
- package/dist/server/routes/socketBackpressure.js.map +1 -0
- package/dist/server/routes/terminalLayoutRoutes.d.ts +9 -0
- package/dist/server/routes/terminalLayoutRoutes.js +108 -0
- package/dist/server/routes/terminalLayoutRoutes.js.map +1 -0
- package/dist/server/routes/terminalRoutes.d.ts +14 -0
- package/dist/server/routes/terminalRoutes.js +177 -0
- package/dist/server/routes/terminalRoutes.js.map +1 -0
- package/dist/server/routes/terminalWebSocket.d.ts +9 -0
- package/dist/server/routes/terminalWebSocket.js +129 -0
- package/dist/server/routes/terminalWebSocket.js.map +1 -0
- package/dist/server/routes/totpRoutes.d.ts +7 -0
- package/dist/server/routes/totpRoutes.js +46 -0
- package/dist/server/routes/totpRoutes.js.map +1 -0
- package/dist/server/routes/updateRoutes.d.ts +7 -0
- package/dist/server/routes/updateRoutes.js +24 -0
- package/dist/server/routes/updateRoutes.js.map +1 -0
- package/dist/server/routes/uploadRoutes.d.ts +9 -0
- package/dist/server/routes/uploadRoutes.js +95 -0
- package/dist/server/routes/uploadRoutes.js.map +1 -0
- package/dist/server/storage/fileStore.d.ts +90 -0
- package/dist/server/storage/fileStore.js +275 -0
- package/dist/server/storage/fileStore.js.map +1 -0
- package/dist/server/system/stats.d.ts +2 -0
- package/dist/server/system/stats.js +37 -0
- package/dist/server/system/stats.js.map +1 -0
- package/dist/server/terminal/NodePtyAdapter.d.ts +4 -0
- package/dist/server/terminal/NodePtyAdapter.js +14 -0
- package/dist/server/terminal/NodePtyAdapter.js.map +1 -0
- package/dist/server/terminal/PtyAdapter.d.ts +57 -0
- package/dist/server/terminal/PtyAdapter.js +2 -0
- package/dist/server/terminal/PtyAdapter.js.map +1 -0
- package/dist/server/terminal/TerminalManager.d.ts +74 -0
- package/dist/server/terminal/TerminalManager.js +561 -0
- package/dist/server/terminal/TerminalManager.js.map +1 -0
- package/dist/server/terminal/TmuxPtyAdapter.d.ts +25 -0
- package/dist/server/terminal/TmuxPtyAdapter.js +543 -0
- package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -0
- package/dist/server/terminal/codexTranscriptSource.d.ts +9 -0
- package/dist/server/terminal/codexTranscriptSource.js +144 -0
- package/dist/server/terminal/codexTranscriptSource.js.map +1 -0
- package/dist/server/terminal/cwdResolver.d.ts +8 -0
- package/dist/server/terminal/cwdResolver.js +37 -0
- package/dist/server/terminal/cwdResolver.js.map +1 -0
- package/dist/server/terminal/outputBuffer.d.ts +7 -0
- package/dist/server/terminal/outputBuffer.js +17 -0
- package/dist/server/terminal/outputBuffer.js.map +1 -0
- package/dist/server/terminal/transcriptHistory.d.ts +7 -0
- package/dist/server/terminal/transcriptHistory.js +315 -0
- package/dist/server/terminal/transcriptHistory.js.map +1 -0
- package/dist/server/update/gitUpdate.d.ts +27 -0
- package/dist/server/update/gitUpdate.js +241 -0
- package/dist/server/update/gitUpdate.js.map +1 -0
- package/dist/server/uploads/uploadPaths.d.ts +18 -0
- package/dist/server/uploads/uploadPaths.js +116 -0
- package/dist/server/uploads/uploadPaths.js.map +1 -0
- package/dist/server/uploads/uploadService.d.ts +21 -0
- package/dist/server/uploads/uploadService.js +230 -0
- package/dist/server/uploads/uploadService.js.map +1 -0
- package/dist/shared/layoutState.d.ts +6 -0
- package/dist/shared/layoutState.js +115 -0
- package/dist/shared/layoutState.js.map +1 -0
- package/dist/shared/notificationAssets.d.ts +9 -0
- package/dist/shared/notificationAssets.js +27 -0
- package/dist/shared/notificationAssets.js.map +1 -0
- package/dist/shared/protocol.d.ts +308 -0
- package/dist/shared/protocol.js +29 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/types.d.ts +56 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/docs/assets/nterminal-workspace.png +0 -0
- package/docs/configuration.md +97 -0
- package/docs/features.md +126 -0
- package/docs/onboarding.md +122 -0
- package/docs/operations.md +112 -0
- package/docs/terminal-history.md +54 -0
- package/package.json +85 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/notifications/character-2258.mp4 +0 -0
- package/public/assets/notifications/character-2260.mp4 +0 -0
- package/public/assets/notifications/character-2272.mp4 +0 -0
- package/public/brand/nterminal-mark-64.png +0 -0
- package/public/brand/nterminal-mark.svg +4 -0
- package/public/brand/nterminal-wordmark-486x68.png +0 -0
- package/public/brand/nterminal-wordmark.svg +3 -0
- package/public/icons/app-icon-1024.png +0 -0
- package/public/icons/app-icon-384.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/favicon-64.png +0 -0
- package/public/icons/favicon-96.png +0 -0
- package/public/icons/favicon.svg +4 -0
- package/public/manifest.webmanifest +24 -0
- package/scripts/nterminalctl +588 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { promises as dnsPromises } from 'node:dns';
|
|
4
|
+
import { request as httpRequest } from 'node:http';
|
|
5
|
+
import { request as httpsRequest } from 'node:https';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
// A deployment URL has to be a real internet-resolvable HTTPS endpoint
|
|
9
|
+
// because the security model is "TLS in front via Let's Encrypt + reverse
|
|
10
|
+
// proxy". Anything else (raw IP, http, localhost, .local) cannot get a
|
|
11
|
+
// public cert and is rejected at the prompt so the operator does not silently
|
|
12
|
+
// end up with an unencrypted exposed setup.
|
|
13
|
+
export function validateDeploymentUrl(url) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(url);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ok: false, reason: 'not a valid URL' };
|
|
20
|
+
}
|
|
21
|
+
if (parsed.protocol !== 'https:') {
|
|
22
|
+
return { ok: false, reason: 'must use https://' };
|
|
23
|
+
}
|
|
24
|
+
const host = parsed.hostname.toLowerCase();
|
|
25
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
26
|
+
return { ok: false, reason: 'use a domain name, not an IP — Let’s Encrypt does not issue certs for raw IPs' };
|
|
27
|
+
}
|
|
28
|
+
if (host.startsWith('[') || host.includes(':')) {
|
|
29
|
+
return { ok: false, reason: 'use a domain name, not an IPv6 literal' };
|
|
30
|
+
}
|
|
31
|
+
if (host === 'localhost' || host.endsWith('.local')) {
|
|
32
|
+
return { ok: false, reason: 'use a public DNS domain, not localhost / .local' };
|
|
33
|
+
}
|
|
34
|
+
if (!host.includes('.')) {
|
|
35
|
+
return { ok: false, reason: 'use a fully qualified domain (e.g. cli.example.com)' };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
// Best-effort guess at the DNS record's "Name" field at the provider's UI.
|
|
40
|
+
// For "cli.example.com" we return "cli"; for the apex "example.com" we return
|
|
41
|
+
// "@". We do NOT try to handle ccTLDs (cli.example.co.uk) correctly — the
|
|
42
|
+
// Public Suffix List is out of scope; the printed instructions tell the
|
|
43
|
+
// operator to double-check.
|
|
44
|
+
export function dnsRecordName(domain) {
|
|
45
|
+
const parts = domain.toLowerCase().split('.').filter(Boolean);
|
|
46
|
+
if (parts.length <= 2) {
|
|
47
|
+
return '@';
|
|
48
|
+
}
|
|
49
|
+
return parts.slice(0, -2).join('.');
|
|
50
|
+
}
|
|
51
|
+
export function generateNginxConfig(domain, port) {
|
|
52
|
+
return `# NTerminal — generated by onboarding for ${domain}
|
|
53
|
+
# Run \`sudo certbot --nginx -d ${domain}\` after the site is reachable on :80
|
|
54
|
+
# to layer HTTPS on top of this server block. certbot edits this file in
|
|
55
|
+
# place; rerunning onboarding will skip overwriting once the file exists.
|
|
56
|
+
server {
|
|
57
|
+
listen 80;
|
|
58
|
+
listen [::]:80;
|
|
59
|
+
server_name ${domain};
|
|
60
|
+
|
|
61
|
+
location / {
|
|
62
|
+
proxy_pass http://127.0.0.1:${port};
|
|
63
|
+
proxy_http_version 1.1;
|
|
64
|
+
proxy_set_header Host $host;
|
|
65
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
66
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
67
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
68
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
69
|
+
proxy_set_header Connection "upgrade";
|
|
70
|
+
# Long timeout so terminal WebSockets do not idle out.
|
|
71
|
+
proxy_read_timeout 86400;
|
|
72
|
+
proxy_send_timeout 86400;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
// Order matters: Debian-style sites-available/sites-enabled is checked first
|
|
78
|
+
// because conf.d exists on Debian too (for global tweaks) but the per-site
|
|
79
|
+
// convention is sites-available.
|
|
80
|
+
const NGINX_LAYOUT_CANDIDATES = [
|
|
81
|
+
{ confDir: '/etc/nginx/sites-available', enabledDir: '/etc/nginx/sites-enabled', family: 'debian' },
|
|
82
|
+
{ confDir: '/etc/nginx/conf.d', family: 'rhel-mac' },
|
|
83
|
+
{ confDir: '/usr/local/etc/nginx/servers', family: 'rhel-mac' },
|
|
84
|
+
{ confDir: '/opt/homebrew/etc/nginx/servers', family: 'rhel-mac' }
|
|
85
|
+
];
|
|
86
|
+
function whichBinary(cmd) {
|
|
87
|
+
const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
88
|
+
if (result.status !== 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const out = result.stdout.toString().trim();
|
|
92
|
+
return out || null;
|
|
93
|
+
}
|
|
94
|
+
// Return the apt/dnf/pacman command (as argv minus the leading "sudo") that
|
|
95
|
+
// installs certbot + its nginx plugin on this host. Returns null when the
|
|
96
|
+
// package manager is unknown or known not to support sudo installs (e.g.
|
|
97
|
+
// homebrew, which refuses to run as root). The caller passes the array to
|
|
98
|
+
// `sudoRun` so we get a TTY-attached install with apt's "Y" handled by `-y`
|
|
99
|
+
// and noninteractive frontend.
|
|
100
|
+
function detectCertbotInstaller() {
|
|
101
|
+
if (whichBinary('apt-get') || whichBinary('apt')) {
|
|
102
|
+
// DEBIAN_FRONTEND=noninteractive prevents debconf dialogs (e.g. the
|
|
103
|
+
// post-install kernel-upgrade whiptail that bit us during nginx install).
|
|
104
|
+
return ['env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
|
|
105
|
+
}
|
|
106
|
+
if (whichBinary('dnf')) {
|
|
107
|
+
return ['dnf', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
|
|
108
|
+
}
|
|
109
|
+
if (whichBinary('yum')) {
|
|
110
|
+
return ['yum', 'install', '-y', 'certbot', 'python3-certbot-nginx'];
|
|
111
|
+
}
|
|
112
|
+
if (whichBinary('pacman')) {
|
|
113
|
+
return ['pacman', '-S', '--noconfirm', 'certbot', 'certbot-nginx'];
|
|
114
|
+
}
|
|
115
|
+
// homebrew should not be run via sudo (it warns + can break perms), so
|
|
116
|
+
// leave Mac users to install certbot themselves.
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
export function detectNginxLayout() {
|
|
120
|
+
const binary = whichBinary('nginx');
|
|
121
|
+
if (!binary) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
for (const candidate of NGINX_LAYOUT_CANDIDATES) {
|
|
125
|
+
try {
|
|
126
|
+
if (existsSync(candidate.confDir) && statSync(candidate.confDir).isDirectory()) {
|
|
127
|
+
return { binary, ...candidate };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// unreadable dir — keep looking
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
export async function detectPublicIp() {
|
|
137
|
+
// Multiple sources because any one of these can be down or block specific
|
|
138
|
+
// user-agents; we accept the first plausible IPv4 reply.
|
|
139
|
+
const sources = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
|
|
140
|
+
for (const source of sources) {
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(source, { signal: AbortSignal.timeout(5000) });
|
|
143
|
+
if (!res.ok)
|
|
144
|
+
continue;
|
|
145
|
+
const text = (await res.text()).trim();
|
|
146
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) {
|
|
147
|
+
return text;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// try the next source
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
// Query public DNS (Cloudflare 1.1.1.1 + Google 8.8.8.8 as fallback) directly
|
|
157
|
+
// instead of going through whatever's in /etc/resolv.conf. Local resolvers
|
|
158
|
+
// like systemd-resolved or dnsmasq cache for the record's TTL, which during
|
|
159
|
+
// propagation can keep returning stale answers long after the operator has
|
|
160
|
+
// already changed the record at their DNS provider — so we'd sit in the
|
|
161
|
+
// "DNS not pointing yet" retry loop even though the change went through.
|
|
162
|
+
// Pinning to a public resolver skips that cache layer entirely.
|
|
163
|
+
export async function resolveDomainIps(domain) {
|
|
164
|
+
const resolver = new dnsPromises.Resolver();
|
|
165
|
+
resolver.setServers(['1.1.1.1', '8.8.8.8']);
|
|
166
|
+
try {
|
|
167
|
+
return await resolver.resolve4(domain);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function tryWriteConfig(filepath, contents) {
|
|
174
|
+
if (existsSync(filepath)) {
|
|
175
|
+
return { kind: 'exists', path: filepath };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(filepath, contents, { mode: 0o644, encoding: 'utf8' });
|
|
179
|
+
return { kind: 'written' };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const code = err.code;
|
|
183
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
184
|
+
return { kind: 'permission' };
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function trySymlink(target, link) {
|
|
190
|
+
if (existsSync(link)) {
|
|
191
|
+
return { kind: 'exists' };
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
symlinkSync(target, link);
|
|
195
|
+
return { kind: 'created' };
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const code = err.code;
|
|
199
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
200
|
+
return { kind: 'permission' };
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// True when `sudo` would not prompt for a password right now — either the
|
|
206
|
+
// process is already root, or the operator has a cached sudo timestamp, or
|
|
207
|
+
// they have NOPASSWD configured for these commands. Lets us skip the
|
|
208
|
+
// "use sudo?" prompt entirely in those cases.
|
|
209
|
+
export function canSudoNonInteractive() {
|
|
210
|
+
const result = spawnSync('sudo', ['-n', 'true'], { stdio: 'ignore' });
|
|
211
|
+
return result.status === 0;
|
|
212
|
+
}
|
|
213
|
+
// Run a command via sudo with the parent's terminal attached so any password
|
|
214
|
+
// prompt (or other interactive output) is answered directly by the operator.
|
|
215
|
+
// Synchronous so the verify step that runs after this sees the final state.
|
|
216
|
+
//
|
|
217
|
+
// Important: we explicitly restore the TTY to canonical mode before
|
|
218
|
+
// spawning. The onboarding prompter uses readline with terminal=true, which
|
|
219
|
+
// can leave stdin in raw mode between question() calls — and a child that
|
|
220
|
+
// expects line-mode input (e.g. an interactive certbot) would then read
|
|
221
|
+
// nothing because Enter never gets delivered as a newline. We re-enable raw
|
|
222
|
+
// mode afterwards so the prompter still works on return.
|
|
223
|
+
export function sudoRun(args) {
|
|
224
|
+
const stdin = process.stdin;
|
|
225
|
+
const wasRaw = Boolean(stdin.isTTY && stdin.isRaw);
|
|
226
|
+
if (wasRaw && typeof stdin.setRawMode === 'function') {
|
|
227
|
+
stdin.setRawMode(false);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const result = spawnSync('sudo', args, { stdio: 'inherit' });
|
|
231
|
+
return { ok: result.status === 0 };
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
if (wasRaw && typeof stdin.setRawMode === 'function') {
|
|
235
|
+
stdin.setRawMode(true);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Place `content` at `targetPath` as root in one shot: stage to a per-process
|
|
240
|
+
// tmp file (writable by us), then `sudo install -m 644` it into place. install(1)
|
|
241
|
+
// is atomic-rename-equivalent and sets perms in one syscall, which is cleaner
|
|
242
|
+
// than `sudo tee + sudo chmod`. The tmp file is removed whether the install
|
|
243
|
+
// succeeds or not so we don't litter /tmp on cancel.
|
|
244
|
+
export function sudoWriteFile(targetPath, content) {
|
|
245
|
+
const tempPath = `/tmp/nterminal-onboard-${process.pid}-${randomUUID()}.tmp`;
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(tempPath, content, { mode: 0o644, encoding: 'utf8' });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { ok: false };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const install = spawnSync('sudo', ['install', '-m', '644', tempPath, targetPath], {
|
|
254
|
+
stdio: 'inherit'
|
|
255
|
+
});
|
|
256
|
+
return { ok: install.status === 0 };
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
try {
|
|
260
|
+
unlinkSync(tempPath);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// tmp file may have been moved or never created; ignore
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export function tryRun(cmd, args) {
|
|
268
|
+
const result = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
269
|
+
const stdout = result.stdout?.toString() ?? '';
|
|
270
|
+
const stderr = result.stderr?.toString() ?? '';
|
|
271
|
+
const combined = `${stdout}${stderr}`.trim();
|
|
272
|
+
if (result.status === 0) {
|
|
273
|
+
return { kind: 'ok', output: stdout };
|
|
274
|
+
}
|
|
275
|
+
const permission = /permission|eacces|operation not permitted|must be run as root/i.test(combined);
|
|
276
|
+
return { kind: 'failed', permission, output: combined };
|
|
277
|
+
}
|
|
278
|
+
// Check that nginx is actually proxying `domain` to NTerminal (and not just
|
|
279
|
+
// serving the default nginx welcome page or routing to some unrelated site).
|
|
280
|
+
// We hit the local nginx with a faked Host header so the check works regardless
|
|
281
|
+
// of DNS, and look for NTerminal's JSON response on /api/auth/session — the
|
|
282
|
+
// default nginx vhost would return HTML, and a misconfigured proxy_pass would
|
|
283
|
+
// return 502.
|
|
284
|
+
export function verifyProxyServing(domain, expectedBackendPort) {
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
const req = httpRequest({
|
|
287
|
+
host: '127.0.0.1',
|
|
288
|
+
port: 80,
|
|
289
|
+
path: '/api/auth/session',
|
|
290
|
+
method: 'GET',
|
|
291
|
+
headers: { Host: domain },
|
|
292
|
+
timeout: 5000
|
|
293
|
+
}, async (res) => {
|
|
294
|
+
const contentType = String(res.headers['content-type'] ?? '');
|
|
295
|
+
res.resume();
|
|
296
|
+
if (res.statusCode === 502 || res.statusCode === 503 || res.statusCode === 504) {
|
|
297
|
+
resolve({ ok: false, reason: `nginx replied ${res.statusCode} — config is loaded but it can't reach NTerminal on 127.0.0.1:${expectedBackendPort}. Run \`scripts/nterminalctl status\` to confirm NTerminal is running.` });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (isHttpsRedirectForDomain(res.statusCode, String(res.headers.location ?? ''), domain)) {
|
|
301
|
+
const https = await verifyHttpsReachable(domain);
|
|
302
|
+
if (https.ok) {
|
|
303
|
+
resolve({ ok: true });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
resolve({ ok: false, reason: `nginx redirects ${domain} HTTP to HTTPS, but HTTPS verify failed: ${https.reason}` });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!contentType.includes('json')) {
|
|
310
|
+
resolve({ ok: false, reason: `expected JSON from /api/auth/session, got ${contentType || 'no content-type'} — nginx is still serving its default vhost, not the new ${domain} block.` });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (res.statusCode !== 200) {
|
|
314
|
+
resolve({ ok: false, reason: `nginx routed to NTerminal but it returned ${res.statusCode}.` });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
resolve({ ok: true });
|
|
318
|
+
});
|
|
319
|
+
req.on('error', (err) => resolve({ ok: false, reason: `could not reach nginx on 127.0.0.1:80 — ${err.message}` }));
|
|
320
|
+
req.on('timeout', () => {
|
|
321
|
+
req.destroy();
|
|
322
|
+
resolve({ ok: false, reason: 'nginx did not respond within 5s' });
|
|
323
|
+
});
|
|
324
|
+
req.end();
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
export function isHttpsRedirectForDomain(statusCode, location, domain) {
|
|
328
|
+
if (statusCode !== 301 && statusCode !== 302 && statusCode !== 307 && statusCode !== 308) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const parsed = new URL(location, `http://${domain}`);
|
|
333
|
+
return parsed.protocol === 'https:' && parsed.hostname === domain;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Check that HTTPS is actually serving the domain — proves both that nginx
|
|
340
|
+
// has a port-443 server block (certbot --nginx wrote one) AND that the cert
|
|
341
|
+
// is valid for the requested domain.
|
|
342
|
+
export function verifyHttpsReachable(domain) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const req = httpsRequest({
|
|
345
|
+
host: domain,
|
|
346
|
+
port: 443,
|
|
347
|
+
path: '/api/auth/session',
|
|
348
|
+
method: 'GET',
|
|
349
|
+
timeout: 10000
|
|
350
|
+
}, (res) => {
|
|
351
|
+
const contentType = String(res.headers['content-type'] ?? '');
|
|
352
|
+
res.resume();
|
|
353
|
+
if (res.statusCode == null || res.statusCode >= 500) {
|
|
354
|
+
resolve({ ok: false, reason: `https://${domain} responded ${res.statusCode ?? '<no status>'}` });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!contentType.includes('json')) {
|
|
358
|
+
resolve({ ok: false, reason: `https://${domain}/api/auth/session returned ${contentType || 'no content-type'}, not NTerminal JSON` });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (res.statusCode !== 200) {
|
|
362
|
+
resolve({ ok: false, reason: `https://${domain}/api/auth/session returned ${res.statusCode}` });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
resolve({ ok: true });
|
|
366
|
+
});
|
|
367
|
+
req.on('error', (err) => {
|
|
368
|
+
const message = err.message;
|
|
369
|
+
if (/certificate|self.?signed|hostname|ALPN|protocol|wrong|UNABLE_TO_GET/i.test(message)) {
|
|
370
|
+
resolve({ ok: false, reason: `TLS error: ${message} — certbot may not have completed.` });
|
|
371
|
+
}
|
|
372
|
+
else if (/ECONNREFUSED|EADDRNOTAVAIL/i.test(message)) {
|
|
373
|
+
resolve({ ok: false, reason: `nothing listening on 443 yet — certbot adds the SSL server block during \`certbot --nginx\`.` });
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
resolve({ ok: false, reason: message });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
req.on('timeout', () => {
|
|
380
|
+
req.destroy();
|
|
381
|
+
resolve({ ok: false, reason: `https://${domain} did not respond within 10s` });
|
|
382
|
+
});
|
|
383
|
+
req.end();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const PROXY_STEPS = [
|
|
387
|
+
'Public IP',
|
|
388
|
+
'DNS A record',
|
|
389
|
+
'nginx installed',
|
|
390
|
+
'nginx site config',
|
|
391
|
+
'nginx reload + verify',
|
|
392
|
+
'Firewall (80/443 open)',
|
|
393
|
+
"TLS cert + HTTPS verify"
|
|
394
|
+
];
|
|
395
|
+
function printStepsOverview() {
|
|
396
|
+
console.log('\x1b[2mSteps:\x1b[0m');
|
|
397
|
+
for (let i = 0; i < PROXY_STEPS.length; i += 1) {
|
|
398
|
+
console.log(`\x1b[2m ${i + 1}/${PROXY_STEPS.length} ${PROXY_STEPS[i]}\x1b[0m`);
|
|
399
|
+
}
|
|
400
|
+
console.log('');
|
|
401
|
+
}
|
|
402
|
+
function printStepHeader(stepIndex) {
|
|
403
|
+
// 1-indexed for human reading. Bold + brackets so the eye lands on the step
|
|
404
|
+
// boundary even when the screen has scrolled past several lines of output
|
|
405
|
+
// from the previous step.
|
|
406
|
+
const label = PROXY_STEPS[stepIndex - 1];
|
|
407
|
+
console.log(`\n\x1b[1m[${stepIndex}/${PROXY_STEPS.length}] ${label}\x1b[0m`);
|
|
408
|
+
}
|
|
409
|
+
export async function setupNginxProxy(args) {
|
|
410
|
+
const { domain, port, prompter } = args;
|
|
411
|
+
console.log(`\n=== Reverse proxy + TLS setup (${domain}) ===\n`);
|
|
412
|
+
printStepsOverview();
|
|
413
|
+
// Pick how to elevate. We do this once up-front so the operator
|
|
414
|
+
// answers at most one question, and so the missing-package check below
|
|
415
|
+
// can offer to install nginx / certbot itself when auto-sudo is on.
|
|
416
|
+
const sudoMode = await chooseSudoMode(prompter);
|
|
417
|
+
// Step 1 — Public IP, auto or ask if detection failed.
|
|
418
|
+
printStepHeader(1);
|
|
419
|
+
const publicIp = await resolvePublicIp(prompter);
|
|
420
|
+
if (!publicIp) {
|
|
421
|
+
console.log('Skipping reverse-proxy setup. Re-run onboarding once you know your public IP.');
|
|
422
|
+
return { httpsReady: false };
|
|
423
|
+
}
|
|
424
|
+
// Step 2 — DNS check loop. Print the exact A record and let the operator
|
|
425
|
+
// retry as many times as they need.
|
|
426
|
+
printStepHeader(2);
|
|
427
|
+
await waitForDnsMatch(domain, publicIp, prompter);
|
|
428
|
+
// Step 3 — nginx detection + auto-install when allowed.
|
|
429
|
+
printStepHeader(3);
|
|
430
|
+
let layout = detectNginxLayout();
|
|
431
|
+
if (!layout) {
|
|
432
|
+
layout = await tryInstallNginx(sudoMode, prompter);
|
|
433
|
+
if (!layout) {
|
|
434
|
+
printNginxInstallHint();
|
|
435
|
+
return { httpsReady: false };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
console.log(`nginx detected at ${layout.binary} (config dir: ${layout.confDir}).`);
|
|
439
|
+
// Step 4 — write the per-site config + (Debian only) enable via symlink.
|
|
440
|
+
printStepHeader(4);
|
|
441
|
+
const configPath = path.join(layout.confDir, `${domain}.conf`);
|
|
442
|
+
const config = generateNginxConfig(domain, port);
|
|
443
|
+
if (!(await ensureConfigWritten(configPath, config, prompter, sudoMode))) {
|
|
444
|
+
return { httpsReady: false };
|
|
445
|
+
}
|
|
446
|
+
if (layout.family === 'debian' && layout.enabledDir) {
|
|
447
|
+
const linkPath = path.join(layout.enabledDir, `${domain}.conf`);
|
|
448
|
+
if (!(await ensureSymlinked(configPath, linkPath, prompter, sudoMode))) {
|
|
449
|
+
return { httpsReady: false };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Step 5 — reload nginx, verified by curl-with-Host against the local
|
|
453
|
+
// nginx so we know the new site block is actually loaded (not the default
|
|
454
|
+
// welcome page) and proxies to NTerminal.
|
|
455
|
+
printStepHeader(5);
|
|
456
|
+
if (!(await ensureNginxServingDomain(layout, domain, port, prompter, sudoMode))) {
|
|
457
|
+
return { httpsReady: false };
|
|
458
|
+
}
|
|
459
|
+
// Step 6 — open the host firewall so Let's Encrypt's HTTP-01 challenge
|
|
460
|
+
// can actually reach :80 from the internet. Catches the canonical
|
|
461
|
+
// "ufw is active and only ssh is allowed" case before certbot wastes its
|
|
462
|
+
// time hitting a ratelimit on a guaranteed failure.
|
|
463
|
+
printStepHeader(6);
|
|
464
|
+
await ensureFirewallAllowsHttp(prompter, sudoMode);
|
|
465
|
+
// Step 7 — certbot, verified by a real HTTPS request against the domain
|
|
466
|
+
// so we know both the cert and the port-443 server block are in place.
|
|
467
|
+
printStepHeader(7);
|
|
468
|
+
if (!(await ensureHttpsWorks(domain, prompter, sudoMode))) {
|
|
469
|
+
return { httpsReady: false };
|
|
470
|
+
}
|
|
471
|
+
console.log(`\n\x1b[32m✓\x1b[0m Reverse proxy + TLS ready. https://${domain} is serving NTerminal.`);
|
|
472
|
+
return { httpsReady: true };
|
|
473
|
+
}
|
|
474
|
+
async function chooseSudoMode(prompter) {
|
|
475
|
+
if (canSudoNonInteractive()) {
|
|
476
|
+
console.log('sudo works without a password here — running the privileged steps automatically.');
|
|
477
|
+
return 'auto-no-prompt';
|
|
478
|
+
}
|
|
479
|
+
console.log('The reverse-proxy / TLS steps below need root (install nginx + certbot if\n' +
|
|
480
|
+
'missing, write the site config, reload nginx, issue the Let’s Encrypt cert).\n' +
|
|
481
|
+
'You can:\n' +
|
|
482
|
+
' • let onboarding run them via sudo (you will be prompted for your password\n' +
|
|
483
|
+
' once — sudo caches it briefly for the rest of the flow), or\n' +
|
|
484
|
+
' • have onboarding print the commands so you can run them manually in another\n' +
|
|
485
|
+
' terminal (useful when copying out of tmux is awkward, or when you want to\n' +
|
|
486
|
+
' review each command before it runs).');
|
|
487
|
+
const useSudo = await prompter.confirm('Run the privileged steps via sudo?', true);
|
|
488
|
+
return useSudo ? 'auto-with-prompt' : 'manual';
|
|
489
|
+
}
|
|
490
|
+
// Auto-install nginx when sudoMode permits. Returns the freshly-detected
|
|
491
|
+
// layout on success, null when the operator declined, the package manager
|
|
492
|
+
// is unknown, or the install failed (in which case the caller falls back
|
|
493
|
+
// to printing manual install instructions).
|
|
494
|
+
async function tryInstallNginx(sudoMode, prompter) {
|
|
495
|
+
if (sudoMode === 'manual') {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const installer = detectNginxInstaller();
|
|
499
|
+
if (!installer) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
console.log(`\nnginx is not installed. Install it via \`sudo ${installer.join(' ')}\`?`);
|
|
503
|
+
const ok = await prompter.confirm('Install nginx now?', true);
|
|
504
|
+
if (!ok) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const install = sudoRun(installer);
|
|
508
|
+
if (!install.ok) {
|
|
509
|
+
console.log('nginx install failed; falling back to manual instructions.');
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
const layout = detectNginxLayout();
|
|
513
|
+
if (!layout) {
|
|
514
|
+
console.log('nginx was installed but the standard config directories were not found.');
|
|
515
|
+
}
|
|
516
|
+
return layout;
|
|
517
|
+
}
|
|
518
|
+
function detectNginxInstaller() {
|
|
519
|
+
if (whichBinary('apt-get') || whichBinary('apt')) {
|
|
520
|
+
return ['env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', 'install', '-y', 'nginx'];
|
|
521
|
+
}
|
|
522
|
+
if (whichBinary('dnf')) {
|
|
523
|
+
return ['dnf', 'install', '-y', 'nginx'];
|
|
524
|
+
}
|
|
525
|
+
if (whichBinary('yum')) {
|
|
526
|
+
return ['yum', 'install', '-y', 'nginx'];
|
|
527
|
+
}
|
|
528
|
+
if (whichBinary('pacman')) {
|
|
529
|
+
return ['pacman', '-S', '--noconfirm', 'nginx'];
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
async function waitForOperator(args) {
|
|
534
|
+
const check = async () => {
|
|
535
|
+
const raw = await args.verify();
|
|
536
|
+
return typeof raw === 'boolean' ? { ok: raw } : raw;
|
|
537
|
+
};
|
|
538
|
+
// Upfront check — the operator may have already finished this manually
|
|
539
|
+
// before reaching the prompt (re-runs, parallel terminal, etc.).
|
|
540
|
+
let result = await check();
|
|
541
|
+
if (result.ok) {
|
|
542
|
+
console.log(`\x1b[32m✓\x1b[0m ${args.label} — already done.`);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
console.log(`\n\x1b[1mNeed root to: ${args.label}\x1b[0m`);
|
|
546
|
+
console.log(`\x1b[2m${args.description}\x1b[0m`);
|
|
547
|
+
console.log('\nRun in another terminal:\n');
|
|
548
|
+
for (const block of args.commands) {
|
|
549
|
+
for (const line of block.split('\n')) {
|
|
550
|
+
console.log(` ${line}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (;;) {
|
|
554
|
+
if (result.reason) {
|
|
555
|
+
console.log(`\n\x1b[2m${result.reason}\x1b[0m`);
|
|
556
|
+
}
|
|
557
|
+
const choice = (await args.prompter.ask('Press Enter to re-check, or type "skip" to skip remaining proxy steps', '')).trim().toLowerCase();
|
|
558
|
+
if (choice === 'skip' || choice === 's') {
|
|
559
|
+
console.log(`Skipped: ${args.label}. Re-run onboarding (or finish the remaining commands manually) when ready.`);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
result = await check();
|
|
563
|
+
if (result.ok) {
|
|
564
|
+
console.log(`\x1b[32m✓\x1b[0m ${args.label}`);
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function ensureConfigWritten(configPath, config, prompter, sudoMode) {
|
|
570
|
+
const attempt = tryWriteConfig(configPath, config);
|
|
571
|
+
if (attempt.kind === 'written') {
|
|
572
|
+
console.log(`\x1b[32m✓\x1b[0m wrote ${configPath}`);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
if (attempt.kind === 'exists') {
|
|
576
|
+
console.log(`\x1b[32m✓\x1b[0m config already exists at ${configPath} — leaving it alone`);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
// attempt.kind === 'permission'
|
|
580
|
+
if (sudoMode !== 'manual') {
|
|
581
|
+
console.log(`\nWriting ${configPath} via sudo (proxies https://<this domain> to NTerminal on 127.0.0.1)…`);
|
|
582
|
+
const written = sudoWriteFile(configPath, config);
|
|
583
|
+
if (written.ok && existsSync(configPath)) {
|
|
584
|
+
console.log(`\x1b[32m✓\x1b[0m wrote ${configPath} via sudo`);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
console.log('sudo write failed; falling back to manual instructions.');
|
|
588
|
+
}
|
|
589
|
+
return waitForOperator({
|
|
590
|
+
label: `write the nginx site config at ${configPath}`,
|
|
591
|
+
description: 'Creates a new nginx site config that tells nginx to proxy requests for this ' +
|
|
592
|
+
'domain to NTerminal running on 127.0.0.1. The file is a plain config text — ' +
|
|
593
|
+
'no service is restarted yet, nginx will only pick it up on reload below.',
|
|
594
|
+
commands: [
|
|
595
|
+
`sudo tee ${configPath} > /dev/null <<'NGINX_EOF'\n${config.trimEnd()}\nNGINX_EOF`
|
|
596
|
+
],
|
|
597
|
+
verify: () => existsSync(configPath),
|
|
598
|
+
prompter
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
async function ensureSymlinked(configPath, linkPath, prompter, sudoMode) {
|
|
602
|
+
const attempt = trySymlink(configPath, linkPath);
|
|
603
|
+
if (attempt.kind === 'created') {
|
|
604
|
+
console.log(`\x1b[32m✓\x1b[0m enabled via ${linkPath}`);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
if (attempt.kind === 'exists') {
|
|
608
|
+
console.log(`\x1b[32m✓\x1b[0m already enabled at ${linkPath}`);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
// attempt.kind === 'permission'
|
|
612
|
+
if (sudoMode !== 'manual') {
|
|
613
|
+
console.log(`\nEnabling site via sudo (symlinks the config into ${path.dirname(linkPath)})…`);
|
|
614
|
+
const linked = sudoRun(['ln', '-s', configPath, linkPath]);
|
|
615
|
+
if (linked.ok && existsSync(linkPath)) {
|
|
616
|
+
console.log(`\x1b[32m✓\x1b[0m enabled via ${linkPath}`);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
console.log('sudo symlink failed; falling back to manual instructions.');
|
|
620
|
+
}
|
|
621
|
+
return waitForOperator({
|
|
622
|
+
label: `enable the site by symlinking into ${path.dirname(linkPath)}`,
|
|
623
|
+
description: 'Debian/Ubuntu’s nginx only loads configs that have a symlink in sites-enabled. ' +
|
|
624
|
+
'This creates that symlink so nginx will include the file we just wrote on the ' +
|
|
625
|
+
'next reload. Nothing else changes — no restart, no traffic shift.',
|
|
626
|
+
commands: [`sudo ln -s ${configPath} ${linkPath}`],
|
|
627
|
+
verify: () => existsSync(linkPath),
|
|
628
|
+
prompter
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async function ensureNginxServingDomain(layout, domain, port, prompter, sudoMode) {
|
|
632
|
+
// Try reload ourselves first; the test step happens implicitly inside the
|
|
633
|
+
// reload (an invalid config keeps the old workers running, which the curl
|
|
634
|
+
// verification below would catch).
|
|
635
|
+
const reloadAttempt = tryRun(layout.binary, ['-s', 'reload']);
|
|
636
|
+
if (reloadAttempt.kind === 'failed' && !reloadAttempt.permission) {
|
|
637
|
+
console.log(`\n\x1b[33m⚠\x1b[0m nginx reload failed (not a permission issue):\n${reloadAttempt.output}`);
|
|
638
|
+
console.log('Fix the issue and run `sudo nginx -t` for details.');
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
if (reloadAttempt.kind === 'failed' && sudoMode !== 'manual') {
|
|
642
|
+
console.log('\nReloading nginx via sudo (validates the config, then graceful re-read — no downtime)…');
|
|
643
|
+
const testResult = sudoRun([layout.binary, '-t']);
|
|
644
|
+
if (!testResult.ok) {
|
|
645
|
+
console.log(`\x1b[33m⚠\x1b[0m \`sudo ${layout.binary} -t\` failed. Fix the config and re-run.`);
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
const reload = sudoRun([layout.binary, '-s', 'reload']);
|
|
649
|
+
if (!reload.ok) {
|
|
650
|
+
console.log('sudo reload failed; falling back to manual instructions.');
|
|
651
|
+
// fall through to waitForOperator
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
const verified = await verifyProxyServing(domain, port);
|
|
655
|
+
if (verified.ok) {
|
|
656
|
+
console.log(`\x1b[32m✓\x1b[0m nginx reloaded and proxying ${domain} to NTerminal`);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
console.log(`\x1b[33m⚠\x1b[0m proxy verification still failing: ${verified.reason}`);
|
|
660
|
+
// fall through so the operator gets the wait loop with the reason
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Whether we managed the reload or not, verify via curl-with-Host that
|
|
664
|
+
// the new block is actually serving. This is the only check that proves
|
|
665
|
+
// the config loaded; nginx -t alone just validates syntax.
|
|
666
|
+
return waitForOperator({
|
|
667
|
+
label: `reload nginx and verify it proxies ${domain} to NTerminal`,
|
|
668
|
+
description: '`nginx -t` parses every config under /etc/nginx to confirm the new file is ' +
|
|
669
|
+
'syntactically valid — it changes nothing on disk. ' +
|
|
670
|
+
'`nginx -s reload` then signals the running master process to re-read its ' +
|
|
671
|
+
'configs gracefully: existing connections finish on the old workers while ' +
|
|
672
|
+
'new ones use the new config. No downtime, no kill, no service restart.',
|
|
673
|
+
commands: [`sudo ${layout.binary} -t`, `sudo ${layout.binary} -s reload`],
|
|
674
|
+
verify: () => verifyProxyServing(domain, port),
|
|
675
|
+
prompter
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
async function ensureHttpsWorks(domain, prompter, sudoMode) {
|
|
679
|
+
// Quick check first — maybe an earlier onboarding run already issued the
|
|
680
|
+
// cert and we just need to confirm.
|
|
681
|
+
const upfront = await verifyHttpsReachable(domain);
|
|
682
|
+
if (upfront.ok) {
|
|
683
|
+
console.log(`\x1b[32m✓\x1b[0m https://${domain} already working`);
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
if (sudoMode !== 'manual') {
|
|
687
|
+
// Pre-flight check: missing certbot would surface as a confusing
|
|
688
|
+
// "command not found" mid-flow. Offer to install it ourselves since we
|
|
689
|
+
// already have sudo capability — the operator only sees one extra
|
|
690
|
+
// confirmation instead of bailing back to manual mode for an apt install.
|
|
691
|
+
if (!whichBinary('certbot')) {
|
|
692
|
+
const installer = detectCertbotInstaller();
|
|
693
|
+
if (installer) {
|
|
694
|
+
console.log(`\ncertbot is not installed. Install it via \`sudo ${installer.join(' ')}\`?`);
|
|
695
|
+
const ok = await prompter.confirm('Install certbot now?', true);
|
|
696
|
+
if (ok) {
|
|
697
|
+
const install = sudoRun(installer);
|
|
698
|
+
if (!install.ok || !whichBinary('certbot')) {
|
|
699
|
+
console.log('certbot install failed; falling back to manual instructions.');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
console.log('certbot is not installed and no auto-installer is available on this platform.');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (sudoMode !== 'manual' && whichBinary('certbot')) {
|
|
709
|
+
// Gather the email up-front via our own prompter and pass it as a flag
|
|
710
|
+
// so certbot runs fully non-interactive. The previous "let certbot
|
|
711
|
+
// prompt directly" approach hit a TTY-mode conflict with readline that
|
|
712
|
+
// hid the input + ate Enter — keeping certbot non-interactive sidesteps
|
|
713
|
+
// that entirely and is also faster for the operator (no waiting for
|
|
714
|
+
// certbot to print its prompt).
|
|
715
|
+
const email = await askCertbotEmail(prompter);
|
|
716
|
+
if (!email) {
|
|
717
|
+
console.log('Skipping auto-certbot (no email). Falling back to manual instructions.');
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const certArgs = [
|
|
721
|
+
'certbot',
|
|
722
|
+
'--nginx',
|
|
723
|
+
'-d',
|
|
724
|
+
domain,
|
|
725
|
+
'--email',
|
|
726
|
+
email,
|
|
727
|
+
'--agree-tos',
|
|
728
|
+
'--no-eff-email',
|
|
729
|
+
'--non-interactive',
|
|
730
|
+
'--keep-until-expiring' // skip the "renew?" prompt if a valid cert is already in place
|
|
731
|
+
];
|
|
732
|
+
console.log(`\nIssuing TLS cert via \`sudo ${certArgs.join(' ')}\`…`);
|
|
733
|
+
const cert = sudoRun(certArgs);
|
|
734
|
+
if (cert.ok) {
|
|
735
|
+
const verified = await verifyHttpsReachable(domain);
|
|
736
|
+
if (verified.ok) {
|
|
737
|
+
console.log(`\x1b[32m✓\x1b[0m https://${domain} is up`);
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
console.log(`\x1b[33m⚠\x1b[0m certbot exited 0 but HTTPS verify failed: ${verified.reason}`);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
console.log('certbot run failed; falling back to manual instructions.');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return waitForOperator({
|
|
748
|
+
label: `issue an HTTPS cert for ${domain}`,
|
|
749
|
+
description: 'certbot contacts Let’s Encrypt, proves you control this domain via an HTTP-01 ' +
|
|
750
|
+
'challenge served over the port 80 nginx site we just set up, downloads a real ' +
|
|
751
|
+
'TLS cert, and edits the same nginx config file in place to add a port-443 ' +
|
|
752
|
+
'server block (with the cert) plus an http→https redirect. It also installs a ' +
|
|
753
|
+
'systemd timer that auto-renews the cert before it expires.',
|
|
754
|
+
commands: [
|
|
755
|
+
`sudo certbot --nginx -d ${domain}`,
|
|
756
|
+
`# If certbot isn't installed:`,
|
|
757
|
+
`# Debian/Ubuntu: sudo apt install certbot python3-certbot-nginx`,
|
|
758
|
+
`# RHEL/Fedora: sudo dnf install certbot python3-certbot-nginx`
|
|
759
|
+
],
|
|
760
|
+
verify: () => verifyHttpsReachable(domain),
|
|
761
|
+
prompter
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
// Ask the operator for a Let's Encrypt account email and loop on invalid
|
|
765
|
+
// input. Returns an empty string when the operator gives up — caller treats
|
|
766
|
+
// that as "skip auto-certbot, fall back to manual". The default suggestion
|
|
767
|
+
// comes from `git config user.email` when set, so a usual server's already-
|
|
768
|
+
// configured identity becomes a one-Enter answer.
|
|
769
|
+
async function askCertbotEmail(prompter) {
|
|
770
|
+
const suggestion = readGitEmail() ?? '';
|
|
771
|
+
for (;;) {
|
|
772
|
+
const raw = (await prompter.ask('Email for Let’s Encrypt cert (renewal + expiry warnings; blank to skip auto-certbot)', suggestion)).trim();
|
|
773
|
+
if (!raw) {
|
|
774
|
+
return '';
|
|
775
|
+
}
|
|
776
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) {
|
|
777
|
+
console.log('That does not look like an email. Try again, or leave blank to skip.');
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
return raw;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function readGitEmail() {
|
|
784
|
+
const result = spawnSync('git', ['config', '--get', 'user.email'], {
|
|
785
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
786
|
+
});
|
|
787
|
+
if (result.status !== 0) {
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
const value = result.stdout.toString().trim();
|
|
791
|
+
return value || undefined;
|
|
792
|
+
}
|
|
793
|
+
// Check the host firewall and open ports 80/443 when needed. We treat this
|
|
794
|
+
// as best-effort: returns silently when we can't detect a firewall config
|
|
795
|
+
// we know how to drive (raw iptables, nftables, none) — certbot's challenge
|
|
796
|
+
// step itself is the real test, and we don't want to refuse the cert step
|
|
797
|
+
// just because we couldn't introspect.
|
|
798
|
+
// Open a single TCP port through whichever firewall is active on this host.
|
|
799
|
+
// Used by secondary onboarding so a NTerminal agent bound to `0.0.0.0:<port>`
|
|
800
|
+
// is actually reachable from the main on first run — without this, the agent
|
|
801
|
+
// is invisible until the operator manually edits ufw / firewalld and remembers
|
|
802
|
+
// to re-register.
|
|
803
|
+
export async function ensureFirewallAllowsPort(port, prompter, sudoMode) {
|
|
804
|
+
if (whichBinary('ufw')) {
|
|
805
|
+
const status = spawnSync('sudo', ['-n', 'ufw', 'status'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
806
|
+
if (status.status === 0) {
|
|
807
|
+
const out = status.stdout.toString();
|
|
808
|
+
if (/Status:\s*inactive/i.test(out)) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// Match either explicit port allow (`<port>/tcp` or bare `<port>`) so a
|
|
812
|
+
// re-run on an already-open host doesn't re-prompt.
|
|
813
|
+
const portPattern = new RegExp(`\\b${port}(/tcp)?\\b`);
|
|
814
|
+
if (portPattern.test(out)) {
|
|
815
|
+
console.log(`\x1b[32m✓\x1b[0m ufw already allows ${port}/tcp.`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (sudoMode === 'manual') {
|
|
819
|
+
console.log(`\nufw is active but ${port}/tcp is not allowed — the main will not be able to reach this agent. Run:\n` +
|
|
820
|
+
` sudo ufw allow ${port}/tcp`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
console.log(`\nufw is active but ${port}/tcp is not allowed. The main reaches this agent on that port,\n` +
|
|
824
|
+
`so it has to be open or registration looks healthy while every request times out.`);
|
|
825
|
+
const ok = await prompter.confirm(`Open ${port}/tcp in ufw?`, true);
|
|
826
|
+
if (!ok)
|
|
827
|
+
return;
|
|
828
|
+
const allow = sudoRun(['ufw', 'allow', `${port}/tcp`]);
|
|
829
|
+
if (allow.ok) {
|
|
830
|
+
console.log(`\x1b[32m✓\x1b[0m ufw now allows ${port}/tcp.`);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
console.log(`ufw allow failed; run \`sudo ufw allow ${port}/tcp\` manually before the main tries to reach this agent.`);
|
|
834
|
+
}
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (whichBinary('firewall-cmd')) {
|
|
839
|
+
const state = spawnSync('sudo', ['-n', 'firewall-cmd', '--state'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
840
|
+
if (state.status === 0 && state.stdout.toString().trim() === 'running') {
|
|
841
|
+
const list = spawnSync('sudo', ['-n', 'firewall-cmd', '--list-ports'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
842
|
+
if (list.status === 0) {
|
|
843
|
+
const ports = list.stdout.toString().split(/\s+/);
|
|
844
|
+
if (ports.includes(`${port}/tcp`)) {
|
|
845
|
+
console.log(`\x1b[32m✓\x1b[0m firewalld already allows ${port}/tcp.`);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (sudoMode === 'manual') {
|
|
849
|
+
console.log(`\nfirewalld is running but ${port}/tcp is not open. Run:\n` +
|
|
850
|
+
` sudo firewall-cmd --permanent --add-port=${port}/tcp\n` +
|
|
851
|
+
' sudo firewall-cmd --reload');
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
console.log(`\nfirewalld is running but ${port}/tcp is not open — the main needs this port to reach the agent.`);
|
|
855
|
+
const ok = await prompter.confirm(`Open ${port}/tcp in firewalld?`, true);
|
|
856
|
+
if (!ok)
|
|
857
|
+
return;
|
|
858
|
+
const add = sudoRun(['firewall-cmd', '--permanent', `--add-port=${port}/tcp`]);
|
|
859
|
+
const reload = sudoRun(['firewall-cmd', '--reload']);
|
|
860
|
+
if (add.ok && reload.ok) {
|
|
861
|
+
console.log(`\x1b[32m✓\x1b[0m firewalld now allows ${port}/tcp.`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
console.log(`firewalld update failed; run the commands above manually.`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// No detectable firewall — silent. Raw iptables/nftables setups are
|
|
870
|
+
// operator-managed and a noisy probe here would only add noise.
|
|
871
|
+
}
|
|
872
|
+
// Re-export the up-front sudo mode picker so secondary onboarding can share
|
|
873
|
+
// the same "ask once, remember for the rest of the run" semantics.
|
|
874
|
+
export async function pickSudoMode(prompter) {
|
|
875
|
+
return chooseSudoMode(prompter);
|
|
876
|
+
}
|
|
877
|
+
async function ensureFirewallAllowsHttp(prompter, sudoMode) {
|
|
878
|
+
// ufw (Debian/Ubuntu's default friendly frontend over iptables)
|
|
879
|
+
if (whichBinary('ufw')) {
|
|
880
|
+
const status = spawnSync('sudo', ['-n', 'ufw', 'status'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
881
|
+
// If sudo -n fails here we just skip the introspection — opening the
|
|
882
|
+
// firewall is opportunistic, and the operator can also do it themselves.
|
|
883
|
+
if (status.status === 0) {
|
|
884
|
+
const out = status.stdout.toString();
|
|
885
|
+
if (/Status:\s*inactive/i.test(out)) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
// ufw is active; check whether port 80 (and ideally 443) are already
|
|
889
|
+
// allowed under any naming. We match liberally because operators write
|
|
890
|
+
// these rules in many shapes: `ufw allow 80`, `ufw allow 80/tcp`,
|
|
891
|
+
// `ufw allow http`, `ufw allow 'Nginx Full'`, etc.
|
|
892
|
+
if (/\b80(\/tcp)?\b/.test(out) || /Nginx Full|Nginx HTTP/.test(out)) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (sudoMode === 'manual') {
|
|
896
|
+
console.log('\nufw is active but port 80 is not allowed — Let’s Encrypt’s HTTP-01 challenge\n' +
|
|
897
|
+
'will fail to reach this host from the internet. Run:\n' +
|
|
898
|
+
" sudo ufw allow 'Nginx Full'");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
console.log('\nufw is active but port 80/443 are not allowed. Let’s Encrypt needs port 80\n' +
|
|
902
|
+
'reachable from the internet to issue a cert. Opening via `ufw allow \'Nginx Full\'`\n' +
|
|
903
|
+
'lets both HTTP and HTTPS in.');
|
|
904
|
+
const ok = await prompter.confirm('Open 80 + 443 in ufw?', true);
|
|
905
|
+
if (!ok)
|
|
906
|
+
return;
|
|
907
|
+
const allow = sudoRun(['ufw', 'allow', 'Nginx Full']);
|
|
908
|
+
if (allow.ok) {
|
|
909
|
+
console.log('\x1b[32m✓\x1b[0m ufw now allows Nginx Full (80 + 443).');
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
console.log('ufw allow failed; you can run it manually before certbot below.');
|
|
913
|
+
}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// firewalld (RHEL/Fedora/Rocky)
|
|
918
|
+
if (whichBinary('firewall-cmd')) {
|
|
919
|
+
const state = spawnSync('sudo', ['-n', 'firewall-cmd', '--state'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
920
|
+
if (state.status === 0 && state.stdout.toString().trim() === 'running') {
|
|
921
|
+
const list = spawnSync('sudo', ['-n', 'firewall-cmd', '--list-services'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
922
|
+
if (list.status === 0) {
|
|
923
|
+
const services = list.stdout.toString().split(/\s+/);
|
|
924
|
+
if (services.includes('http') && services.includes('https')) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (sudoMode === 'manual') {
|
|
928
|
+
console.log('\nfirewalld is running but http/https services are not enabled. Run:\n' +
|
|
929
|
+
' sudo firewall-cmd --permanent --add-service=http --add-service=https\n' +
|
|
930
|
+
' sudo firewall-cmd --reload');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
console.log('\nfirewalld is running but http/https services are not enabled. Let’s Encrypt needs both.');
|
|
934
|
+
const ok = await prompter.confirm('Add http + https to firewalld?', true);
|
|
935
|
+
if (!ok)
|
|
936
|
+
return;
|
|
937
|
+
const add = sudoRun(['firewall-cmd', '--permanent', '--add-service=http', '--add-service=https']);
|
|
938
|
+
const reload = sudoRun(['firewall-cmd', '--reload']);
|
|
939
|
+
if (add.ok && reload.ok) {
|
|
940
|
+
console.log('\x1b[32m✓\x1b[0m firewalld now allows http + https.');
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
console.log('firewalld update failed; you can run the commands manually before certbot.');
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// Unknown firewall config (raw iptables/nftables/none): silently proceed.
|
|
949
|
+
}
|
|
950
|
+
async function resolvePublicIp(prompter) {
|
|
951
|
+
const detected = await detectPublicIp();
|
|
952
|
+
if (detected) {
|
|
953
|
+
console.log(`Detected public IP: ${detected}`);
|
|
954
|
+
const ok = await prompter.confirm(`Use ${detected} as the IP DNS should point to?`, true);
|
|
955
|
+
if (ok)
|
|
956
|
+
return detected;
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
console.log('Could not auto-detect a public IP from the usual sources.');
|
|
960
|
+
}
|
|
961
|
+
const typed = (await prompter.ask('Enter the public IPv4 DNS should point to (blank to skip)')).trim();
|
|
962
|
+
if (!typed)
|
|
963
|
+
return null;
|
|
964
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(typed)) {
|
|
965
|
+
console.log(`"${typed}" is not a valid IPv4.`);
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
return typed;
|
|
969
|
+
}
|
|
970
|
+
async function waitForDnsMatch(domain, publicIp, prompter) {
|
|
971
|
+
for (;;) {
|
|
972
|
+
const resolved = await resolveDomainIps(domain);
|
|
973
|
+
if (resolved && resolved.includes(publicIp)) {
|
|
974
|
+
console.log(`\x1b[32m✓\x1b[0m ${domain} resolves to ${publicIp}.`);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
console.log(`\n${domain} is not pointing to ${publicIp} yet.`);
|
|
978
|
+
if (resolved && resolved.length > 0) {
|
|
979
|
+
console.log(` Currently resolves to: ${resolved.join(', ')}`);
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
console.log(' No A record found.');
|
|
983
|
+
}
|
|
984
|
+
console.log('\nAt your domain provider, set:');
|
|
985
|
+
console.log(' Type: A');
|
|
986
|
+
console.log(` Name: ${dnsRecordName(domain)} \x1b[2m(double-check the field your provider uses)\x1b[0m`);
|
|
987
|
+
console.log(` Value: ${publicIp}`);
|
|
988
|
+
console.log(' TTL: 60–300 (lower TTL while iterating speeds up retries)');
|
|
989
|
+
console.log('\n\x1b[2mOnboarding queries 1.1.1.1 directly, so a local DNS cache won’t hide a fresh');
|
|
990
|
+
console.log('answer here — but your browser still might. If you also test in a browser,');
|
|
991
|
+
console.log('flush the OS cache (Linux: sudo resolvectl flush-caches / macOS: sudo');
|
|
992
|
+
console.log('dscacheutil -flushcache; sudo killall -HUP mDNSResponder).\x1b[0m');
|
|
993
|
+
const choice = (await prompter.ask('Press Enter to re-check, or type "skip" to continue without verifying', '')).trim().toLowerCase();
|
|
994
|
+
if (choice === 'skip' || choice === 's') {
|
|
995
|
+
console.log('Skipping DNS verification.');
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function printNginxInstallHint() {
|
|
1001
|
+
console.log('\nnginx is not installed. Install it first, then re-run onboarding:');
|
|
1002
|
+
console.log(' Debian/Ubuntu: sudo apt install nginx');
|
|
1003
|
+
console.log(' RHEL/Fedora: sudo dnf install nginx');
|
|
1004
|
+
console.log(' Arch: sudo pacman -S nginx');
|
|
1005
|
+
console.log(' macOS (brew): brew install nginx');
|
|
1006
|
+
}
|
|
1007
|
+
//# sourceMappingURL=proxySetup.js.map
|