pal-explorer-cli 0.4.12 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -149
- package/bin/pal.js +63 -2
- package/extensions/@palexplorer/analytics/extension.json +20 -1
- package/extensions/@palexplorer/analytics/index.js +19 -9
- package/extensions/@palexplorer/audit/extension.json +14 -0
- package/extensions/@palexplorer/auth-email/extension.json +15 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
- package/extensions/@palexplorer/chat/extension.json +14 -0
- package/extensions/@palexplorer/discovery/extension.json +17 -0
- package/extensions/@palexplorer/discovery/index.js +1 -1
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/groups/extension.json +15 -0
- package/extensions/@palexplorer/share-links/extension.json +15 -0
- package/extensions/@palexplorer/sync/extension.json +16 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
- package/lib/capabilities.js +24 -24
- package/lib/commands/analytics.js +175 -175
- package/lib/commands/api-keys.js +131 -131
- package/lib/commands/audit.js +235 -235
- package/lib/commands/auth.js +137 -137
- package/lib/commands/backup.js +76 -76
- package/lib/commands/billing.js +148 -148
- package/lib/commands/chat.js +217 -217
- package/lib/commands/cloud-backup.js +231 -231
- package/lib/commands/comment.js +99 -99
- package/lib/commands/completion.js +203 -203
- package/lib/commands/compliance.js +218 -218
- package/lib/commands/config.js +136 -136
- package/lib/commands/connect.js +44 -44
- package/lib/commands/dept.js +294 -294
- package/lib/commands/device.js +146 -146
- package/lib/commands/download.js +240 -226
- package/lib/commands/explorer.js +178 -178
- package/lib/commands/extension.js +1060 -970
- package/lib/commands/favorite.js +90 -90
- package/lib/commands/federation.js +270 -270
- package/lib/commands/file.js +533 -533
- package/lib/commands/group.js +271 -271
- package/lib/commands/gui-share.js +29 -29
- package/lib/commands/init.js +61 -61
- package/lib/commands/invite.js +59 -59
- package/lib/commands/list.js +58 -58
- package/lib/commands/log.js +116 -116
- package/lib/commands/nearby.js +108 -108
- package/lib/commands/network.js +251 -251
- package/lib/commands/notify.js +198 -198
- package/lib/commands/org.js +273 -273
- package/lib/commands/pal.js +403 -180
- package/lib/commands/permissions.js +216 -216
- package/lib/commands/pin.js +97 -97
- package/lib/commands/protocol.js +357 -357
- package/lib/commands/rbac.js +147 -147
- package/lib/commands/recover.js +36 -36
- package/lib/commands/register.js +171 -171
- package/lib/commands/relay.js +131 -131
- package/lib/commands/remote.js +368 -368
- package/lib/commands/revoke.js +50 -50
- package/lib/commands/scanner.js +280 -280
- package/lib/commands/schedule.js +344 -344
- package/lib/commands/scim.js +203 -203
- package/lib/commands/search.js +181 -181
- package/lib/commands/serve.js +438 -438
- package/lib/commands/server.js +350 -350
- package/lib/commands/share-link.js +199 -199
- package/lib/commands/share.js +336 -323
- package/lib/commands/sso.js +200 -200
- package/lib/commands/status.js +145 -145
- package/lib/commands/stream.js +562 -562
- package/lib/commands/su.js +187 -187
- package/lib/commands/sync.js +979 -979
- package/lib/commands/transfers.js +152 -152
- package/lib/commands/uninstall.js +188 -188
- package/lib/commands/update.js +204 -204
- package/lib/commands/user.js +276 -276
- package/lib/commands/vfs.js +84 -84
- package/lib/commands/web-login.js +79 -79
- package/lib/commands/web.js +52 -52
- package/lib/commands/webhook.js +180 -180
- package/lib/commands/whoami.js +59 -59
- package/lib/commands/workspace.js +121 -121
- package/lib/core/billing.js +16 -5
- package/lib/core/dhtDiscovery.js +9 -2
- package/lib/core/discoveryClient.js +13 -7
- package/lib/core/extensions.js +142 -1
- package/lib/core/identity.js +33 -2
- package/lib/core/imageProcessor.js +109 -0
- package/lib/core/imageTorrent.js +167 -0
- package/lib/core/permissions.js +1 -1
- package/lib/core/pro.js +11 -4
- package/lib/core/serverList.js +4 -1
- package/lib/core/shares.js +12 -1
- package/lib/core/signalingServer.js +14 -2
- package/lib/core/su.js +1 -1
- package/lib/core/users.js +1 -1
- package/lib/protocol/messages.js +12 -3
- package/lib/utils/explorer.js +1 -1
- package/lib/utils/help.js +357 -357
- package/lib/utils/torrent.js +1 -0
- package/package.json +4 -3
package/lib/commands/server.js
CHANGED
|
@@ -1,350 +1,350 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import config from '../utils/config.js';
|
|
3
|
-
import { spawn, exec } from 'child_process';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import fs from 'fs';
|
|
7
|
-
import readline from 'readline';
|
|
8
|
-
import { getPrimaryServer, getServers, checkServer, verifyAllServers, fetchServerKey, acceptServerKey, keyFingerprint } from '../core/discoveryClient.js';
|
|
9
|
-
import { refreshServerList, healthCheckServers, getServerRoles, SERVER_ROLES, isWriteTrusted } from '../core/serverList.js';
|
|
10
|
-
|
|
11
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
|
|
13
|
-
function confirm(question) {
|
|
14
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
-
return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); }));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function installWindows() {
|
|
19
|
-
const pePath = process.execPath;
|
|
20
|
-
const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
|
|
21
|
-
const taskName = 'PalexplorerServe';
|
|
22
|
-
const cmd = `schtasks /create /tn "${taskName}" /tr "\\"${pePath}\\" \\"${scriptPath}\\" serve" /sc onlogon /rl limited /f`;
|
|
23
|
-
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
exec(cmd, (err, stdout, stderr) => {
|
|
26
|
-
if (err) {
|
|
27
|
-
console.log(chalk.yellow('Could not create scheduled task automatically.'));
|
|
28
|
-
console.log(chalk.gray('To install manually, create a scheduled task:'));
|
|
29
|
-
console.log(chalk.gray(` schtasks /create /tn "${taskName}" /tr "
|
|
30
|
-
return resolve();
|
|
31
|
-
}
|
|
32
|
-
console.log(chalk.green(`Scheduled task "${taskName}" created.
|
|
33
|
-
resolve();
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function installLinux() {
|
|
39
|
-
const serviceSource = path.resolve(__dirname, '../../installer/linux/palexplorer.service');
|
|
40
|
-
const serviceDir = path.join(process.env.HOME, '.config/systemd/user');
|
|
41
|
-
const serviceDest = path.join(serviceDir, 'palexplorer.service');
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
fs.mkdirSync(serviceDir, { recursive: true });
|
|
45
|
-
|
|
46
|
-
if (fs.existsSync(serviceSource)) {
|
|
47
|
-
fs.copyFileSync(serviceSource, serviceDest);
|
|
48
|
-
} else {
|
|
49
|
-
const serviceContent = `[Unit]
|
|
50
|
-
Description=Palexplorer P2P File Sharing Daemon
|
|
51
|
-
After=network-online.target
|
|
52
|
-
Wants=network-online.target
|
|
53
|
-
|
|
54
|
-
[Service]
|
|
55
|
-
Type=simple
|
|
56
|
-
ExecStart=${process.execPath} ${path.resolve(__dirname, '../../bin/pal.js')} serve
|
|
57
|
-
Restart=on-failure
|
|
58
|
-
RestartSec=10
|
|
59
|
-
Environment=NODE_ENV=production
|
|
60
|
-
|
|
61
|
-
[Install]
|
|
62
|
-
WantedBy=default.target
|
|
63
|
-
`;
|
|
64
|
-
fs.writeFileSync(serviceDest, serviceContent);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
console.log(chalk.green(`Service file written to ${serviceDest}`));
|
|
68
|
-
console.log(chalk.gray('Enable with:'));
|
|
69
|
-
console.log(chalk.gray(' systemctl --user daemon-reload'));
|
|
70
|
-
console.log(chalk.gray(' systemctl --user enable --now palexplorer'));
|
|
71
|
-
} catch (err) {
|
|
72
|
-
console.log(chalk.red('Failed to install systemd service:'), err.message);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function installMacOS() {
|
|
77
|
-
const plistDir = path.join(process.env.HOME, 'Library/LaunchAgents');
|
|
78
|
-
const plistPath = path.join(plistDir, 'com.palexplorer.serve.plist');
|
|
79
|
-
const pePath = process.execPath;
|
|
80
|
-
const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
|
|
81
|
-
|
|
82
|
-
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
83
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
84
|
-
<plist version="1.0">
|
|
85
|
-
<dict>
|
|
86
|
-
<key>Label</key>
|
|
87
|
-
<string>com.palexplorer.serve</string>
|
|
88
|
-
<key>ProgramArguments</key>
|
|
89
|
-
<array>
|
|
90
|
-
<string>${pePath}</string>
|
|
91
|
-
<string>${scriptPath}</string>
|
|
92
|
-
<string>serve</string>
|
|
93
|
-
</array>
|
|
94
|
-
<key>RunAtLoad</key>
|
|
95
|
-
<true/>
|
|
96
|
-
<key>KeepAlive</key>
|
|
97
|
-
<true/>
|
|
98
|
-
<key>StandardOutPath</key>
|
|
99
|
-
<string>/tmp/palexplorer.log</string>
|
|
100
|
-
<key>StandardErrorPath</key>
|
|
101
|
-
<string>/tmp/palexplorer.err</string>
|
|
102
|
-
</dict>
|
|
103
|
-
</plist>
|
|
104
|
-
`;
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
fs.mkdirSync(plistDir, { recursive: true });
|
|
108
|
-
fs.writeFileSync(plistPath, plistContent);
|
|
109
|
-
console.log(chalk.green(`LaunchAgent written to ${plistPath}`));
|
|
110
|
-
console.log(chalk.gray('Load with:'));
|
|
111
|
-
console.log(chalk.gray(` launchctl load ${plistPath}`));
|
|
112
|
-
} catch (err) {
|
|
113
|
-
console.log(chalk.red('Failed to install LaunchAgent:'), err.message);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export default function serverCommand(program) {
|
|
118
|
-
const server = program.command('server').description('provision and manage Palexplorer server nodes')
|
|
119
|
-
.addHelpText('after', `
|
|
120
|
-
Examples:
|
|
121
|
-
$
|
|
122
|
-
$
|
|
123
|
-
$
|
|
124
|
-
$
|
|
125
|
-
`);
|
|
126
|
-
|
|
127
|
-
server
|
|
128
|
-
.command('install')
|
|
129
|
-
.description('install
|
|
130
|
-
.action(async () => {
|
|
131
|
-
const platform = process.platform;
|
|
132
|
-
console.log(chalk.blue(`Installing Palexplorer service for ${platform}...`));
|
|
133
|
-
|
|
134
|
-
if (platform === 'win32') {
|
|
135
|
-
await installWindows();
|
|
136
|
-
} else if (platform === 'linux') {
|
|
137
|
-
await installLinux();
|
|
138
|
-
} else if (platform === 'darwin') {
|
|
139
|
-
await installMacOS();
|
|
140
|
-
} else {
|
|
141
|
-
console.log(chalk.red(`Unsupported platform: ${platform}`));
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
server
|
|
146
|
-
.command('config <key> <value>')
|
|
147
|
-
.description('set server-specific operational parameters')
|
|
148
|
-
.action((key, value) => {
|
|
149
|
-
// Validate common keys
|
|
150
|
-
const validKeys = ['port', 'storage_path', 'max_connections', 'bandwidth_cap'];
|
|
151
|
-
if (!validKeys.includes(key)) {
|
|
152
|
-
console.log(chalk.yellow(`Warning: '${key}' is not a standard server parameter. Setting anyway.`));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const settings = config.get('settings') || {};
|
|
156
|
-
settings[key] = value;
|
|
157
|
-
config.set('settings', settings);
|
|
158
|
-
console.log(chalk.green(`✔ Server configuration updated: ${key} = ${value}`));
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
server
|
|
162
|
-
.command('register <handle>')
|
|
163
|
-
.description('register this server node with a handle in the Discovery Network')
|
|
164
|
-
.action(async (handle) => {
|
|
165
|
-
const DISCOVERY_URL = getPrimaryServer();
|
|
166
|
-
const { getIdentity, setIdentityHandle } = await import('../core/identity.js');
|
|
167
|
-
const identity = await getIdentity();
|
|
168
|
-
if (!identity || !identity.privateKey) {
|
|
169
|
-
console.log(chalk.red('No identity found. Run `
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const sodium = (await import('sodium-native')).default;
|
|
174
|
-
const privateKey = Buffer.from(identity.privateKey, 'hex');
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
console.log(chalk.blue(`Registering handle @${handle} on discovery server...`));
|
|
178
|
-
// Step 1: Get challenge
|
|
179
|
-
const chalRes = await fetch(`${DISCOVERY_URL}/register/challenge`, {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
headers: { 'Content-Type': 'application/json' },
|
|
182
|
-
body: JSON.stringify({ handle, publicKey: identity.publicKey })
|
|
183
|
-
});
|
|
184
|
-
const chalData = await chalRes.json();
|
|
185
|
-
if (!chalData.challengeId) {
|
|
186
|
-
console.log(chalk.red(`Challenge failed: ${chalData.error || 'unknown error'}`));
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Step 2: Sign challenge and register
|
|
191
|
-
const message = `register:${handle}:${chalData.nonce}`;
|
|
192
|
-
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
193
|
-
sodium.crypto_sign_detached(sig, Buffer.from(message), privateKey);
|
|
194
|
-
|
|
195
|
-
const regRes = await fetch(`${DISCOVERY_URL}/register`, {
|
|
196
|
-
method: 'POST',
|
|
197
|
-
headers: { 'Content-Type': 'application/json' },
|
|
198
|
-
body: JSON.stringify({ handle, publicKey: identity.publicKey, challengeId: chalData.challengeId, signature: sig.toString('hex') })
|
|
199
|
-
});
|
|
200
|
-
const result = await regRes.json();
|
|
201
|
-
if (result.success) {
|
|
202
|
-
console.log(chalk.green(`✔ Node registered as @${handle}`));
|
|
203
|
-
setIdentityHandle(handle);
|
|
204
|
-
} else {
|
|
205
|
-
console.log(chalk.red(`Registration failed: ${result.error}`));
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
console.log(chalk.red(`Could not reach discovery server at ${DISCOVERY_URL}`));
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
server
|
|
213
|
-
.command('status')
|
|
214
|
-
.description('check server health and network connectivity')
|
|
215
|
-
.action(() => {
|
|
216
|
-
const identity = config.get('identity');
|
|
217
|
-
const settings = config.get('settings') || {};
|
|
218
|
-
const shares = config.get('shares') || [];
|
|
219
|
-
|
|
220
|
-
console.log('');
|
|
221
|
-
console.log(chalk.cyan('Palexplorer Server Status:'));
|
|
222
|
-
console.log(`- Node Identity: ${identity ? chalk.green(identity.name) : chalk.red('Not Initialized')}`);
|
|
223
|
-
console.log(`- Public Key: ${identity ? chalk.gray(identity.publicKey) : 'N/A'}`);
|
|
224
|
-
console.log(`- Active Shares: ${chalk.white(shares.length)}`);
|
|
225
|
-
console.log(`- Bound Port: ${chalk.white(settings.port || 'Auto (1900)')}`);
|
|
226
|
-
console.log(`- OS: ${chalk.white(process.platform)}`);
|
|
227
|
-
console.log('');
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
server
|
|
231
|
-
.command('list')
|
|
232
|
-
.description('list all configured discovery servers with health status and roles')
|
|
233
|
-
.action(async () => {
|
|
234
|
-
const servers = getServers();
|
|
235
|
-
console.log('');
|
|
236
|
-
console.log(chalk.cyan('Discovery Servers:'));
|
|
237
|
-
const checks = await Promise.all(servers.map(s => checkServer(s)));
|
|
238
|
-
for (let i = 0; i < checks.length; i++) {
|
|
239
|
-
const c = checks[i];
|
|
240
|
-
const primary = i === 0 ? chalk.cyan(' (primary)') : '';
|
|
241
|
-
const status = c.reachable ? chalk.green('reachable') : chalk.red('down');
|
|
242
|
-
const meta = getServerRoles(c.url);
|
|
243
|
-
const rolesStr = chalk.gray(`[${meta.roles.join(', ')}]`);
|
|
244
|
-
const addedBy = meta.addedBy !== 'bootstrap' ? chalk.gray(` (${meta.addedBy})`) : '';
|
|
245
|
-
const trust = isWriteTrusted(c.url) ? chalk.green('read+write') : chalk.yellow('read-only');
|
|
246
|
-
console.log(` ${i + 1}. ${chalk.white(c.url)} — ${status}${primary} ${rolesStr} ${trust}${addedBy}`);
|
|
247
|
-
}
|
|
248
|
-
console.log('');
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
server
|
|
252
|
-
.command('refresh')
|
|
253
|
-
.description('fetch and update server list from all known servers')
|
|
254
|
-
.action(async () => {
|
|
255
|
-
console.log(chalk.blue('Refreshing server list...'));
|
|
256
|
-
const servers = await refreshServerList((step) => {
|
|
257
|
-
console.log(chalk.gray(` ${step}`));
|
|
258
|
-
});
|
|
259
|
-
console.log(chalk.green(`\nFound ${servers.length} server(s). Running health checks...`));
|
|
260
|
-
const checks = await healthCheckServers(servers);
|
|
261
|
-
for (const c of checks) {
|
|
262
|
-
const status = c.reachable
|
|
263
|
-
? chalk.green(`reachable (${c.latencyMs}ms)`)
|
|
264
|
-
: chalk.red('unreachable');
|
|
265
|
-
console.log(` ${chalk.white(c.url)} — ${status}`);
|
|
266
|
-
}
|
|
267
|
-
console.log('');
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
server
|
|
271
|
-
.command('roles <url>')
|
|
272
|
-
.description('query and display a server\'s supported roles')
|
|
273
|
-
.action(async (url) => {
|
|
274
|
-
const normalized = url.replace(/\/+$/, '');
|
|
275
|
-
try {
|
|
276
|
-
const parsed = new URL(normalized);
|
|
277
|
-
const host = parsed.hostname;
|
|
278
|
-
if (host === 'localhost' || host.startsWith('127.') || host.startsWith('10.') ||
|
|
279
|
-
host.startsWith('192.168.') || host.startsWith('169.254.') || host === '0.0.0.0' ||
|
|
280
|
-
/^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
|
|
281
|
-
console.log(chalk.yellow('⚠ Warning: querying a private/local network address.'));
|
|
282
|
-
}
|
|
283
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
284
|
-
console.log(chalk.red('Server URL must use http:// or https://'));
|
|
285
|
-
process.exitCode = 1;
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
} catch { /* URL validation is best-effort */ }
|
|
289
|
-
console.log(chalk.blue(`Querying roles for ${normalized}...`));
|
|
290
|
-
try {
|
|
291
|
-
const res = await fetch(`${normalized}/status`, { signal: AbortSignal.timeout(5000) });
|
|
292
|
-
if (!res.ok) {
|
|
293
|
-
console.log(chalk.red(`Server returned status ${res.status}`));
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const data = await res.json();
|
|
297
|
-
console.log('');
|
|
298
|
-
console.log(chalk.cyan(`Server: ${normalized}`));
|
|
299
|
-
console.log(` Status: ${chalk.green(data.status || 'unknown')}`);
|
|
300
|
-
console.log(` Version: ${chalk.white(data.version || 'unknown')}`);
|
|
301
|
-
if (Array.isArray(data.roles)) {
|
|
302
|
-
console.log(` Roles: ${chalk.white(data.roles.join(', '))}`);
|
|
303
|
-
} else {
|
|
304
|
-
console.log(` Roles: ${chalk.yellow('not reported (legacy server, assumed all)')}`);
|
|
305
|
-
}
|
|
306
|
-
if (data.network) {
|
|
307
|
-
console.log(` Network: ${chalk.white(data.network)}`);
|
|
308
|
-
}
|
|
309
|
-
console.log('');
|
|
310
|
-
} catch (err) {
|
|
311
|
-
console.log(chalk.red(`Could not reach server: ${err.message}`));
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
server
|
|
316
|
-
.command('verify')
|
|
317
|
-
.description('verify pinned keys for all configured servers')
|
|
318
|
-
.action(async () => {
|
|
319
|
-
console.log(chalk.blue('Verifying server keys...\n'));
|
|
320
|
-
const results = await verifyAllServers();
|
|
321
|
-
|
|
322
|
-
for (const r of results) {
|
|
323
|
-
if (r.status === 'ok') {
|
|
324
|
-
console.log(` ${chalk.green('✔')} ${chalk.white(r.url)} — ${chalk.green('key verified')} (${chalk.cyan(r.fingerprint)})`);
|
|
325
|
-
} else if (r.status === 'key-changed') {
|
|
326
|
-
console.log(` ${chalk.red('✘')} ${chalk.white(r.url)} — ${chalk.red('KEY CHANGED')}`);
|
|
327
|
-
console.log(` Pinned: ${chalk.yellow(r.pinnedFingerprint)}`);
|
|
328
|
-
console.log(` Current: ${chalk.red(r.currentFingerprint)}`);
|
|
329
|
-
console.log(chalk.red(` ${r.message}`));
|
|
330
|
-
|
|
331
|
-
const ok = await confirm(chalk.white(' Accept the new key? (y/N) '));
|
|
332
|
-
if (ok) {
|
|
333
|
-
const keyResult = await fetchServerKey(r.url);
|
|
334
|
-
if (keyResult) {
|
|
335
|
-
acceptServerKey(r.url, keyResult.publicKey);
|
|
336
|
-
console.log(chalk.green(' ✔ New key accepted and pinned.'));
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
console.log(chalk.yellow(' Key NOT accepted. Consider removing this server.'));
|
|
340
|
-
}
|
|
341
|
-
} else if (r.status === 'no-pinned-key') {
|
|
342
|
-
console.log(` ${chalk.yellow('?')} ${chalk.white(r.url)} — ${chalk.yellow('no pinned key')}`);
|
|
343
|
-
console.log(chalk.gray(` ${r.message}`));
|
|
344
|
-
} else if (r.status === 'unreachable') {
|
|
345
|
-
console.log(` ${chalk.red('—')} ${chalk.white(r.url)} — ${chalk.red('unreachable')}`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
console.log('');
|
|
349
|
-
});
|
|
350
|
-
}
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import config from '../utils/config.js';
|
|
3
|
+
import { spawn, exec } from 'child_process';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import readline from 'readline';
|
|
8
|
+
import { getPrimaryServer, getServers, checkServer, verifyAllServers, fetchServerKey, acceptServerKey, keyFingerprint } from '../core/discoveryClient.js';
|
|
9
|
+
import { refreshServerList, healthCheckServers, getServerRoles, SERVER_ROLES, isWriteTrusted } from '../core/serverList.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
function confirm(question) {
|
|
14
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function installWindows() {
|
|
19
|
+
const pePath = process.execPath;
|
|
20
|
+
const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
|
|
21
|
+
const taskName = 'PalexplorerServe';
|
|
22
|
+
const cmd = `schtasks /create /tn "${taskName}" /tr "\\"${pePath}\\" \\"${scriptPath}\\" serve" /sc onlogon /rl limited /f`;
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
exec(cmd, (err, stdout, stderr) => {
|
|
26
|
+
if (err) {
|
|
27
|
+
console.log(chalk.yellow('Could not create scheduled task automatically.'));
|
|
28
|
+
console.log(chalk.gray('To install manually, create a scheduled task:'));
|
|
29
|
+
console.log(chalk.gray(` schtasks /create /tn "${taskName}" /tr "pal serve" /sc onlogon /rl limited`));
|
|
30
|
+
return resolve();
|
|
31
|
+
}
|
|
32
|
+
console.log(chalk.green(`Scheduled task "${taskName}" created. pal serve will run on login.`));
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function installLinux() {
|
|
39
|
+
const serviceSource = path.resolve(__dirname, '../../installer/linux/palexplorer.service');
|
|
40
|
+
const serviceDir = path.join(process.env.HOME, '.config/systemd/user');
|
|
41
|
+
const serviceDest = path.join(serviceDir, 'palexplorer.service');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(serviceDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(serviceSource)) {
|
|
47
|
+
fs.copyFileSync(serviceSource, serviceDest);
|
|
48
|
+
} else {
|
|
49
|
+
const serviceContent = `[Unit]
|
|
50
|
+
Description=Palexplorer P2P File Sharing Daemon
|
|
51
|
+
After=network-online.target
|
|
52
|
+
Wants=network-online.target
|
|
53
|
+
|
|
54
|
+
[Service]
|
|
55
|
+
Type=simple
|
|
56
|
+
ExecStart=${process.execPath} ${path.resolve(__dirname, '../../bin/pal.js')} serve
|
|
57
|
+
Restart=on-failure
|
|
58
|
+
RestartSec=10
|
|
59
|
+
Environment=NODE_ENV=production
|
|
60
|
+
|
|
61
|
+
[Install]
|
|
62
|
+
WantedBy=default.target
|
|
63
|
+
`;
|
|
64
|
+
fs.writeFileSync(serviceDest, serviceContent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(chalk.green(`Service file written to ${serviceDest}`));
|
|
68
|
+
console.log(chalk.gray('Enable with:'));
|
|
69
|
+
console.log(chalk.gray(' systemctl --user daemon-reload'));
|
|
70
|
+
console.log(chalk.gray(' systemctl --user enable --now palexplorer'));
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.log(chalk.red('Failed to install systemd service:'), err.message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function installMacOS() {
|
|
77
|
+
const plistDir = path.join(process.env.HOME, 'Library/LaunchAgents');
|
|
78
|
+
const plistPath = path.join(plistDir, 'com.palexplorer.serve.plist');
|
|
79
|
+
const pePath = process.execPath;
|
|
80
|
+
const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
|
|
81
|
+
|
|
82
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
83
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
84
|
+
<plist version="1.0">
|
|
85
|
+
<dict>
|
|
86
|
+
<key>Label</key>
|
|
87
|
+
<string>com.palexplorer.serve</string>
|
|
88
|
+
<key>ProgramArguments</key>
|
|
89
|
+
<array>
|
|
90
|
+
<string>${pePath}</string>
|
|
91
|
+
<string>${scriptPath}</string>
|
|
92
|
+
<string>serve</string>
|
|
93
|
+
</array>
|
|
94
|
+
<key>RunAtLoad</key>
|
|
95
|
+
<true/>
|
|
96
|
+
<key>KeepAlive</key>
|
|
97
|
+
<true/>
|
|
98
|
+
<key>StandardOutPath</key>
|
|
99
|
+
<string>/tmp/palexplorer.log</string>
|
|
100
|
+
<key>StandardErrorPath</key>
|
|
101
|
+
<string>/tmp/palexplorer.err</string>
|
|
102
|
+
</dict>
|
|
103
|
+
</plist>
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(plistDir, { recursive: true });
|
|
108
|
+
fs.writeFileSync(plistPath, plistContent);
|
|
109
|
+
console.log(chalk.green(`LaunchAgent written to ${plistPath}`));
|
|
110
|
+
console.log(chalk.gray('Load with:'));
|
|
111
|
+
console.log(chalk.gray(` launchctl load ${plistPath}`));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.log(chalk.red('Failed to install LaunchAgent:'), err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default function serverCommand(program) {
|
|
118
|
+
const server = program.command('server').description('provision and manage Palexplorer server nodes')
|
|
119
|
+
.addHelpText('after', `
|
|
120
|
+
Examples:
|
|
121
|
+
$ pal server install Install as system service
|
|
122
|
+
$ pal server status Check service status
|
|
123
|
+
$ pal server config Show server configuration
|
|
124
|
+
$ pal server register Register device with discovery server
|
|
125
|
+
`);
|
|
126
|
+
|
|
127
|
+
server
|
|
128
|
+
.command('install')
|
|
129
|
+
.description('install pal serve as a background service (cross-platform)')
|
|
130
|
+
.action(async () => {
|
|
131
|
+
const platform = process.platform;
|
|
132
|
+
console.log(chalk.blue(`Installing Palexplorer service for ${platform}...`));
|
|
133
|
+
|
|
134
|
+
if (platform === 'win32') {
|
|
135
|
+
await installWindows();
|
|
136
|
+
} else if (platform === 'linux') {
|
|
137
|
+
await installLinux();
|
|
138
|
+
} else if (platform === 'darwin') {
|
|
139
|
+
await installMacOS();
|
|
140
|
+
} else {
|
|
141
|
+
console.log(chalk.red(`Unsupported platform: ${platform}`));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
server
|
|
146
|
+
.command('config <key> <value>')
|
|
147
|
+
.description('set server-specific operational parameters')
|
|
148
|
+
.action((key, value) => {
|
|
149
|
+
// Validate common keys
|
|
150
|
+
const validKeys = ['port', 'storage_path', 'max_connections', 'bandwidth_cap'];
|
|
151
|
+
if (!validKeys.includes(key)) {
|
|
152
|
+
console.log(chalk.yellow(`Warning: '${key}' is not a standard server parameter. Setting anyway.`));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const settings = config.get('settings') || {};
|
|
156
|
+
settings[key] = value;
|
|
157
|
+
config.set('settings', settings);
|
|
158
|
+
console.log(chalk.green(`✔ Server configuration updated: ${key} = ${value}`));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
server
|
|
162
|
+
.command('register <handle>')
|
|
163
|
+
.description('register this server node with a handle in the Discovery Network')
|
|
164
|
+
.action(async (handle) => {
|
|
165
|
+
const DISCOVERY_URL = getPrimaryServer();
|
|
166
|
+
const { getIdentity, setIdentityHandle } = await import('../core/identity.js');
|
|
167
|
+
const identity = await getIdentity();
|
|
168
|
+
if (!identity || !identity.privateKey) {
|
|
169
|
+
console.log(chalk.red('No identity found. Run `pal init <name>` first.'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sodium = (await import('sodium-native')).default;
|
|
174
|
+
const privateKey = Buffer.from(identity.privateKey, 'hex');
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
console.log(chalk.blue(`Registering handle @${handle} on discovery server...`));
|
|
178
|
+
// Step 1: Get challenge
|
|
179
|
+
const chalRes = await fetch(`${DISCOVERY_URL}/register/challenge`, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({ handle, publicKey: identity.publicKey })
|
|
183
|
+
});
|
|
184
|
+
const chalData = await chalRes.json();
|
|
185
|
+
if (!chalData.challengeId) {
|
|
186
|
+
console.log(chalk.red(`Challenge failed: ${chalData.error || 'unknown error'}`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Step 2: Sign challenge and register
|
|
191
|
+
const message = `register:${handle}:${chalData.nonce}`;
|
|
192
|
+
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
193
|
+
sodium.crypto_sign_detached(sig, Buffer.from(message), privateKey);
|
|
194
|
+
|
|
195
|
+
const regRes = await fetch(`${DISCOVERY_URL}/register`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({ handle, publicKey: identity.publicKey, challengeId: chalData.challengeId, signature: sig.toString('hex') })
|
|
199
|
+
});
|
|
200
|
+
const result = await regRes.json();
|
|
201
|
+
if (result.success) {
|
|
202
|
+
console.log(chalk.green(`✔ Node registered as @${handle}`));
|
|
203
|
+
setIdentityHandle(handle);
|
|
204
|
+
} else {
|
|
205
|
+
console.log(chalk.red(`Registration failed: ${result.error}`));
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
console.log(chalk.red(`Could not reach discovery server at ${DISCOVERY_URL}`));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
server
|
|
213
|
+
.command('status')
|
|
214
|
+
.description('check server health and network connectivity')
|
|
215
|
+
.action(() => {
|
|
216
|
+
const identity = config.get('identity');
|
|
217
|
+
const settings = config.get('settings') || {};
|
|
218
|
+
const shares = config.get('shares') || [];
|
|
219
|
+
|
|
220
|
+
console.log('');
|
|
221
|
+
console.log(chalk.cyan('Palexplorer Server Status:'));
|
|
222
|
+
console.log(`- Node Identity: ${identity ? chalk.green(identity.name) : chalk.red('Not Initialized')}`);
|
|
223
|
+
console.log(`- Public Key: ${identity ? chalk.gray(identity.publicKey) : 'N/A'}`);
|
|
224
|
+
console.log(`- Active Shares: ${chalk.white(shares.length)}`);
|
|
225
|
+
console.log(`- Bound Port: ${chalk.white(settings.port || 'Auto (1900)')}`);
|
|
226
|
+
console.log(`- OS: ${chalk.white(process.platform)}`);
|
|
227
|
+
console.log('');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
server
|
|
231
|
+
.command('list')
|
|
232
|
+
.description('list all configured discovery servers with health status and roles')
|
|
233
|
+
.action(async () => {
|
|
234
|
+
const servers = getServers();
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log(chalk.cyan('Discovery Servers:'));
|
|
237
|
+
const checks = await Promise.all(servers.map(s => checkServer(s)));
|
|
238
|
+
for (let i = 0; i < checks.length; i++) {
|
|
239
|
+
const c = checks[i];
|
|
240
|
+
const primary = i === 0 ? chalk.cyan(' (primary)') : '';
|
|
241
|
+
const status = c.reachable ? chalk.green('reachable') : chalk.red('down');
|
|
242
|
+
const meta = getServerRoles(c.url);
|
|
243
|
+
const rolesStr = chalk.gray(`[${meta.roles.join(', ')}]`);
|
|
244
|
+
const addedBy = meta.addedBy !== 'bootstrap' ? chalk.gray(` (${meta.addedBy})`) : '';
|
|
245
|
+
const trust = isWriteTrusted(c.url) ? chalk.green('read+write') : chalk.yellow('read-only');
|
|
246
|
+
console.log(` ${i + 1}. ${chalk.white(c.url)} — ${status}${primary} ${rolesStr} ${trust}${addedBy}`);
|
|
247
|
+
}
|
|
248
|
+
console.log('');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
server
|
|
252
|
+
.command('refresh')
|
|
253
|
+
.description('fetch and update server list from all known servers')
|
|
254
|
+
.action(async () => {
|
|
255
|
+
console.log(chalk.blue('Refreshing server list...'));
|
|
256
|
+
const servers = await refreshServerList((step) => {
|
|
257
|
+
console.log(chalk.gray(` ${step}`));
|
|
258
|
+
});
|
|
259
|
+
console.log(chalk.green(`\nFound ${servers.length} server(s). Running health checks...`));
|
|
260
|
+
const checks = await healthCheckServers(servers);
|
|
261
|
+
for (const c of checks) {
|
|
262
|
+
const status = c.reachable
|
|
263
|
+
? chalk.green(`reachable (${c.latencyMs}ms)`)
|
|
264
|
+
: chalk.red('unreachable');
|
|
265
|
+
console.log(` ${chalk.white(c.url)} — ${status}`);
|
|
266
|
+
}
|
|
267
|
+
console.log('');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
server
|
|
271
|
+
.command('roles <url>')
|
|
272
|
+
.description('query and display a server\'s supported roles')
|
|
273
|
+
.action(async (url) => {
|
|
274
|
+
const normalized = url.replace(/\/+$/, '');
|
|
275
|
+
try {
|
|
276
|
+
const parsed = new URL(normalized);
|
|
277
|
+
const host = parsed.hostname;
|
|
278
|
+
if (host === 'localhost' || host.startsWith('127.') || host.startsWith('10.') ||
|
|
279
|
+
host.startsWith('192.168.') || host.startsWith('169.254.') || host === '0.0.0.0' ||
|
|
280
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
|
|
281
|
+
console.log(chalk.yellow('⚠ Warning: querying a private/local network address.'));
|
|
282
|
+
}
|
|
283
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
284
|
+
console.log(chalk.red('Server URL must use http:// or https://'));
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
} catch { /* URL validation is best-effort */ }
|
|
289
|
+
console.log(chalk.blue(`Querying roles for ${normalized}...`));
|
|
290
|
+
try {
|
|
291
|
+
const res = await fetch(`${normalized}/status`, { signal: AbortSignal.timeout(5000) });
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
console.log(chalk.red(`Server returned status ${res.status}`));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const data = await res.json();
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(chalk.cyan(`Server: ${normalized}`));
|
|
299
|
+
console.log(` Status: ${chalk.green(data.status || 'unknown')}`);
|
|
300
|
+
console.log(` Version: ${chalk.white(data.version || 'unknown')}`);
|
|
301
|
+
if (Array.isArray(data.roles)) {
|
|
302
|
+
console.log(` Roles: ${chalk.white(data.roles.join(', '))}`);
|
|
303
|
+
} else {
|
|
304
|
+
console.log(` Roles: ${chalk.yellow('not reported (legacy server, assumed all)')}`);
|
|
305
|
+
}
|
|
306
|
+
if (data.network) {
|
|
307
|
+
console.log(` Network: ${chalk.white(data.network)}`);
|
|
308
|
+
}
|
|
309
|
+
console.log('');
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.log(chalk.red(`Could not reach server: ${err.message}`));
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
server
|
|
316
|
+
.command('verify')
|
|
317
|
+
.description('verify pinned keys for all configured servers')
|
|
318
|
+
.action(async () => {
|
|
319
|
+
console.log(chalk.blue('Verifying server keys...\n'));
|
|
320
|
+
const results = await verifyAllServers();
|
|
321
|
+
|
|
322
|
+
for (const r of results) {
|
|
323
|
+
if (r.status === 'ok') {
|
|
324
|
+
console.log(` ${chalk.green('✔')} ${chalk.white(r.url)} — ${chalk.green('key verified')} (${chalk.cyan(r.fingerprint)})`);
|
|
325
|
+
} else if (r.status === 'key-changed') {
|
|
326
|
+
console.log(` ${chalk.red('✘')} ${chalk.white(r.url)} — ${chalk.red('KEY CHANGED')}`);
|
|
327
|
+
console.log(` Pinned: ${chalk.yellow(r.pinnedFingerprint)}`);
|
|
328
|
+
console.log(` Current: ${chalk.red(r.currentFingerprint)}`);
|
|
329
|
+
console.log(chalk.red(` ${r.message}`));
|
|
330
|
+
|
|
331
|
+
const ok = await confirm(chalk.white(' Accept the new key? (y/N) '));
|
|
332
|
+
if (ok) {
|
|
333
|
+
const keyResult = await fetchServerKey(r.url);
|
|
334
|
+
if (keyResult) {
|
|
335
|
+
acceptServerKey(r.url, keyResult.publicKey);
|
|
336
|
+
console.log(chalk.green(' ✔ New key accepted and pinned.'));
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
console.log(chalk.yellow(' Key NOT accepted. Consider removing this server.'));
|
|
340
|
+
}
|
|
341
|
+
} else if (r.status === 'no-pinned-key') {
|
|
342
|
+
console.log(` ${chalk.yellow('?')} ${chalk.white(r.url)} — ${chalk.yellow('no pinned key')}`);
|
|
343
|
+
console.log(chalk.gray(` ${r.message}`));
|
|
344
|
+
} else if (r.status === 'unreachable') {
|
|
345
|
+
console.log(` ${chalk.red('—')} ${chalk.white(r.url)} — ${chalk.red('unreachable')}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
console.log('');
|
|
349
|
+
});
|
|
350
|
+
}
|