fnos-cli 0.2.0 → 0.3.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.
@@ -1,141 +1,163 @@
1
1
  /**
2
- * FnosClient wrapper with authentication
2
+ * FnosClient wrapper with fnos 0.4 authentication.
3
3
  */
4
4
 
5
- const { FnosClient, ResourceMonitor, Store, SystemInfo, User, Network, File, SAC } = require('fnos');
5
+ const sdk = require('fnos');
6
6
  const settings = require('./settings');
7
7
  const { logger } = require('./logger');
8
8
 
9
- /**
10
- * Create authenticated FnosClient
11
- * @param {Object} options - Options object
12
- * @param {Object} [options.credentials] - Direct credentials object (endpoint, username, password, etc.)
13
- * @param {string} [options.endpoint] - Server endpoint (legacy)
14
- * @param {string} [options.username] - Username (legacy)
15
- * @param {string} [options.password] - Password (legacy)
16
- * @param {number} [options.timeout] - Connection timeout in seconds
17
- * @param {boolean} [options.saveCredentials] - Whether to save credentials to settings (default: false)
18
- * @returns {Promise<FnosClient>} Authenticated client
19
- */
20
- async function createClient(options = {}) {
21
- const { credentials, endpoint, username, password, timeout = 60, saveCredentials = false } = options;
22
-
23
- // Use provided credentials object
24
- if (credentials) {
25
- return await createClientWithCredentials(credentials, timeout, saveCredentials);
26
- }
27
-
28
- // Legacy support: build credentials from individual parameters
29
- const savedCredentials = settings.getCredentials();
30
- const finalEndpoint = endpoint || savedCredentials?.endpoint;
31
- const finalUsername = username || savedCredentials?.username;
32
- const finalPassword = password || savedCredentials?.password;
33
-
34
- if (!finalEndpoint || !finalUsername) {
35
- throw new Error('Missing credentials. Please run "fnos login" first or provide -e, -u, -p parameters.');
9
+ const SDK_CLASS_NAMES = [
10
+ 'ResourceMonitor', 'Store', 'SystemInfo', 'User', 'Network', 'File', 'SAC',
11
+ 'DockerManager', 'EventLogger', 'Share', 'Notify', 'IscsiManager',
12
+ 'BackupManager', 'DownloadCenter', 'IPBlocker', 'LicenseManager',
13
+ 'LiveUpdate', 'MountManager', 'NetworkServer', 'Security', 'SystemRestore'
14
+ ];
15
+
16
+ class AuthenticationError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'AuthenticationError';
20
+ this.exitCode = 3;
36
21
  }
37
-
38
- return await createClientWithCredentials(
39
- {
40
- endpoint: finalEndpoint,
41
- username: finalUsername,
42
- password: finalPassword,
43
- token: savedCredentials?.token,
44
- longToken: savedCredentials?.longToken,
45
- secret: savedCredentials?.secret
46
- },
47
- timeout,
48
- saveCredentials
49
- );
50
22
  }
51
23
 
52
- /**
53
- * Create authenticated client with credentials
54
- * @param {Object} credentials - Credentials object
55
- * @param {string} credentials.endpoint - Server endpoint
56
- * @param {string} credentials.username - Username
57
- * @param {string} credentials.password - Password
58
- * @param {number} timeout - Connection timeout in seconds
59
- * @param {boolean} saveToSettings - Whether to save credentials to settings
60
- * @returns {Promise<FnosClient>} Authenticated client
61
- */
62
- async function createClientWithCredentials(credentials, timeout, saveToSettings) {
63
- const { endpoint, username, password } = credentials;
24
+ function canUseToken(credentials) {
25
+ return credentials.credentialVersion === 2 &&
26
+ credentials.secretFormat === 'decrypted-v1' &&
27
+ [credentials.token, credentials.longToken, credentials.secret]
28
+ .every((value) => typeof value === 'string' && value.length > 0);
29
+ }
64
30
 
65
- // Create client
66
- const client = new FnosClient();
67
- logger.info(`Connecting to ${endpoint}...`);
31
+ function tokenLoginSucceeded(result) {
32
+ return Boolean(result && (result.result === 'succ' || result.errno === 0));
33
+ }
68
34
 
69
- // Connect
70
- await client.connect(endpoint, timeout * 1000);
71
- logger.info('Connected successfully');
35
+ // fnos 0.4.0 treats every response containing data.hostName as the connection
36
+ // handshake, so a later SystemInfo.getHostName response never reaches its waiter.
37
+ function installHostNameResponseCompatibility(client) {
38
+ if (typeof client.processMessage !== 'function' || !(client.pendingRequests instanceof Map)) return;
39
+ const processMessage = client.processMessage.bind(client);
40
+ client.processMessage = (message) => {
41
+ let response;
42
+ try {
43
+ response = JSON.parse(message);
44
+ } catch (error) {
45
+ return processMessage(message);
46
+ }
47
+ const hasHostName = response && response.data &&
48
+ typeof response.data === 'object' && 'hostName' in response.data;
49
+ const pending = hasHostName && typeof response.reqid === 'string'
50
+ ? client.pendingRequests.get(response.reqid)
51
+ : null;
52
+ if (pending) {
53
+ if (pending.timeoutTimer) clearTimeout(pending.timeoutTimer);
54
+ client.pendingRequests.delete(response.reqid);
55
+ pending.future.resolve(response);
56
+ return;
57
+ }
58
+ return processMessage(message);
59
+ };
60
+ }
72
61
 
73
- // Login with password
74
- if (!password) {
75
- throw new Error('Password required. Please run "fnos login" first or provide -p parameter.');
62
+ async function passwordLogin(client, credentials, options) {
63
+ if (!credentials.password) throw new AuthenticationError('Password is required.');
64
+ let result;
65
+ try {
66
+ result = await client.login(credentials.username, credentials.password, 10000);
67
+ } catch (error) {
68
+ if (client.loginResponse?.result === 'fail') {
69
+ throw new AuthenticationError(error.message || 'Login failed.');
70
+ }
71
+ throw error;
76
72
  }
77
-
78
- logger.info('Logging in...');
79
- const loginResult = await client.login(username, password);
80
- logger.info('Logged in successfully');
81
-
82
- // Save credentials only if explicitly requested (e.g., from login command)
83
- if (saveToSettings) {
84
- settings.saveCredentials({
85
- endpoint,
86
- username,
87
- password,
88
- token: loginResult.token,
89
- longToken: loginResult.longToken,
90
- secret: loginResult.secret
91
- });
92
- logger.info('Credentials saved');
73
+ if (!result) throw new AuthenticationError('Login failed.');
74
+ if (result.twofaSetupRequired) {
75
+ throw new AuthenticationError('This account must bind two-factor authentication in the fnOS web interface first.');
93
76
  }
94
-
95
- return client;
77
+ if (result.twofaRequired) {
78
+ if (!/^\d{6}$/.test(options.twofaCode || '')) {
79
+ throw new AuthenticationError('A six-digit --twofa-code is required for this account.');
80
+ }
81
+ result = await client.submitTwofaCode(options.twofaCode, options.trustDevice === true, 10000);
82
+ }
83
+ if (!result || result.result !== 'succ' || !result.token || !result.secret) {
84
+ throw new AuthenticationError(result?.msg || result?.errmsg || 'Login failed.');
85
+ }
86
+ return result;
96
87
  }
97
88
 
98
- /**
99
- * Execute a command with auto-retry
100
- * @param {FnosClient} client - FnosClient instance
101
- * @param {Function} commandFn - Command function to execute
102
- * @returns {Promise<*>} Command result
103
- */
104
- async function executeCommand(client, commandFn) {
89
+ async function createClientWithCredentials(credentials, options = {}, dependencies = {}) {
90
+ const sdkModule = dependencies.sdk || sdk;
91
+ const settingsStore = dependencies.settings || settings;
92
+ const activeLogger = dependencies.logger || logger;
93
+ const Client = sdkModule.FnosClient;
94
+ const client = new Client();
95
+ const timeout = options.timeout ?? 60;
96
+
105
97
  try {
106
- return await commandFn();
107
- } catch (error) {
108
- if (error.message.includes('token') || error.message.includes('auth')) {
109
- logger.warn('Authentication failed, trying to re-login...');
110
- // Re-login logic could be added here
98
+ activeLogger.info(`Connecting to ${credentials.endpoint}...`);
99
+ await client.connect(credentials.endpoint, timeout * 1000, false, options.verifySsl === true ? false : true);
100
+ installHostNameResponseCompatibility(client);
101
+ let result;
102
+ let usedToken = false;
103
+
104
+ if (canUseToken(credentials)) {
105
+ result = await client.loginViaToken(credentials.token, credentials.longToken, credentials.secret, 10000);
106
+ usedToken = tokenLoginSucceeded(result);
107
+ if (!usedToken) activeLogger.debug('Saved token was rejected; falling back to password login.');
111
108
  }
109
+
110
+ if (!usedToken) {
111
+ result = await passwordLogin(client, credentials, options);
112
+ if (options.saveCredentials || credentials.source === 'settings_file') {
113
+ const decryptedSecret = client.getDecryptedSecret();
114
+ if (typeof decryptedSecret !== 'string' || decryptedSecret.length === 0) {
115
+ throw new AuthenticationError('Login secret decryption failed.');
116
+ }
117
+ settingsStore.saveCredentials({
118
+ endpoint: credentials.endpoint,
119
+ username: credentials.username,
120
+ password: credentials.password,
121
+ credentialVersion: 2,
122
+ token: result.token || null,
123
+ longToken: result.longToken || null,
124
+ secret: decryptedSecret,
125
+ secretFormat: 'decrypted-v1'
126
+ });
127
+ }
128
+ }
129
+
130
+ return client;
131
+ } catch (error) {
132
+ client.close();
112
133
  throw error;
113
134
  }
114
135
  }
115
136
 
116
- /**
117
- * Get SDK class instance
118
- * @param {FnosClient} client - FnosClient instance
119
- * @param {string} className - Name of the SDK class
120
- * @returns {*} SDK class instance
121
- */
122
- function getSDKInstance(client, className) {
123
- const instances = {
124
- ResourceMonitor: new ResourceMonitor(client),
125
- Store: new Store(client),
126
- SystemInfo: new SystemInfo(client),
127
- User: new User(client),
128
- Network: new Network(client),
129
- File: new File(client),
130
- SAC: new SAC(client)
137
+ async function createClient(options = {}, dependencies = {}) {
138
+ const credentials = options.credentials || {
139
+ endpoint: options.endpoint,
140
+ username: options.username,
141
+ password: options.password,
142
+ source: 'command_line'
131
143
  };
144
+ if (!credentials.endpoint || !credentials.username) {
145
+ throw new AuthenticationError('Missing credentials. Run "fnos login" or provide -e/-u/-p.');
146
+ }
147
+ return createClientWithCredentials(credentials, options, dependencies);
148
+ }
132
149
 
133
- const instance = instances[className];
134
- if (!instance) {
150
+ function getSDKInstance(client, className, sdkModule = sdk) {
151
+ if (!SDK_CLASS_NAMES.includes(className) || typeof sdkModule[className] !== 'function') {
135
152
  throw new Error(`Unknown SDK class: ${className}`);
136
153
  }
137
-
138
- return instance;
154
+ return new sdkModule[className](client);
139
155
  }
140
156
 
141
- module.exports = { createClient, executeCommand, getSDKInstance };
157
+ module.exports = {
158
+ AuthenticationError,
159
+ SDK_CLASS_NAMES,
160
+ createClient,
161
+ createClientWithCredentials,
162
+ getSDKInstance
163
+ };
@@ -0,0 +1,11 @@
1
+ const { HTTPSRequiredError } = require('fnos');
2
+ const { redactSensitive } = require('./logger');
3
+
4
+ function errorMessage(error) {
5
+ const message = error instanceof HTTPSRequiredError
6
+ ? `fnOS requires HTTPS: ${error.requestedUri} was redirected to ${error.redirectUri}. Use the corresponding wss:// endpoint.`
7
+ : error?.message || String(error);
8
+ return redactSensitive(message);
9
+ }
10
+
11
+ module.exports = { errorMessage };
@@ -19,6 +19,26 @@ if (!fs.existsSync(logsDir)) {
19
19
  const randomNum = crypto.randomBytes(4).toString('hex');
20
20
  const logFilename = `fnos-${new Date().toISOString().split('T')[0]}-${randomNum}.log`;
21
21
 
22
+ const SENSITIVE_VALUE = /((?:"?(?:longToken|long_token|accessToken|access_token|twofaCode|twofa_code|password|secret|token|code|hmac)"?)\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[REDACTED\]|[^\s,}\]]+)/gi;
23
+ const SIGNED_MESSAGE = /^(Calculated iz-result:|Sending msg to channel:|已发送请求:)\s*.*$/i;
24
+
25
+ function redactSensitive(value) {
26
+ const message = String(value);
27
+ if (SIGNED_MESSAGE.test(message)) return message.replace(SIGNED_MESSAGE, '$1 [REDACTED]');
28
+ return message
29
+ .replace(SENSITIVE_VALUE, (match, prefix, rawValue) => {
30
+ const quote = rawValue[0] === '"' || rawValue[0] === "'" ? rawValue[0] : '';
31
+ const value = quote ? rawValue.slice(1, -1) : rawValue;
32
+ return value === '[REDACTED]' ? match : `${prefix}${quote}[REDACTED]${quote}`;
33
+ })
34
+ .replace(/(服务器返回的secret:\s*)\S+/gi, '$1[REDACTED]');
35
+ }
36
+
37
+ const redactFormat = winston.format((info) => {
38
+ info.message = redactSensitive(info.message);
39
+ return info;
40
+ });
41
+
22
42
  // Log format
23
43
  const logFormat = winston.format.printf(({ timestamp, level, message }) => {
24
44
  return `${timestamp} - ${level.toUpperCase()} - ${message}`;
@@ -52,6 +72,7 @@ class StderrTransport extends winston.Transport {
52
72
  const logger = winston.createLogger({
53
73
  level: 'silly', // Capture all levels for file output
54
74
  format: winston.format.combine(
75
+ redactFormat(),
55
76
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
56
77
  logFormat
57
78
  ),
@@ -60,6 +81,7 @@ const logger = winston.createLogger({
60
81
  new StderrTransport({
61
82
  level: 'error', // Default: only show errors
62
83
  format: winston.format.combine(
84
+ redactFormat(),
63
85
  winston.format.colorize(),
64
86
  logFormat
65
87
  )
@@ -102,10 +124,12 @@ function setLogLevel(verbose) {
102
124
  if (sdkConsoleTransport) {
103
125
  fnosLogger.default.remove(sdkConsoleTransport);
104
126
  }
127
+ fnosLogger.default.level = consoleTransport.level;
105
128
  // Add our custom stderr transport
106
129
  const sdkStderrTransport = new StderrTransport({
107
130
  level: consoleTransport.level,
108
131
  format: winston.format.combine(
132
+ redactFormat(),
109
133
  winston.format.colorize(),
110
134
  logFormat
111
135
  )
@@ -117,4 +141,4 @@ function setLogLevel(verbose) {
117
141
  }
118
142
  }
119
143
 
120
- module.exports = { logger, setLogLevel };
144
+ module.exports = { logger, redactSensitive, setLogLevel };
@@ -2,122 +2,83 @@
2
2
  * Settings manager for fnos
3
3
  */
4
4
 
5
- const fs = require('fs');
6
- const path = require('path');
7
- const os = require('os');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
8
 
9
9
  class Settings {
10
- constructor() {
11
- this.settingsDir = path.join(os.homedir(), '.fnos');
10
+ constructor(homeDir = os.homedir()) {
11
+ this.settingsDir = path.join(homeDir, '.fnos');
12
12
  this.settingsPath = path.join(this.settingsDir, 'settings.json');
13
13
  }
14
14
 
15
- /**
16
- * Ensure settings directory exists
17
- */
18
15
  _ensureDir() {
19
- if (!fs.existsSync(this.settingsDir)) {
20
- fs.mkdirSync(this.settingsDir, { recursive: true });
21
- }
16
+ fs.mkdirSync(this.settingsDir, { recursive: true, mode: 0o700 });
22
17
  }
23
18
 
24
- /**
25
- * Load settings from file
26
- * @returns {Object|null} Settings object or null if not exists
27
- */
28
19
  load() {
29
- if (!fs.existsSync(this.settingsPath)) {
30
- return null;
31
- }
20
+ if (!fs.existsSync(this.settingsPath)) return null;
32
21
  try {
33
- const data = fs.readFileSync(this.settingsPath, 'utf8');
34
- return JSON.parse(data);
22
+ return JSON.parse(fs.readFileSync(this.settingsPath, 'utf8'));
35
23
  } catch (error) {
36
- console.error('Failed to load settings:', error.message);
24
+ console.error(error instanceof SyntaxError
25
+ ? 'Failed to load settings: invalid JSON.'
26
+ : 'Failed to load settings.');
37
27
  return null;
38
28
  }
39
29
  }
40
30
 
41
- /**
42
- * Save settings to file
43
- * @param {Object} data - Settings data to save
44
- */
45
31
  save(data) {
46
32
  this._ensureDir();
47
33
  try {
48
- fs.writeFileSync(this.settingsPath, JSON.stringify(data, null, 2), {
49
- mode: 0o600 // Read/write for owner only
50
- });
34
+ fs.writeFileSync(this.settingsPath, JSON.stringify(data, null, 2), { mode: 0o600 });
35
+ fs.chmodSync(this.settingsPath, 0o600);
51
36
  } catch (error) {
52
- console.error('Failed to save settings:', error.message);
37
+ console.error('Failed to save settings.');
53
38
  throw error;
54
39
  }
55
40
  }
56
41
 
57
- /**
58
- * Clear settings file
59
- */
60
42
  clear() {
61
- if (fs.existsSync(this.settingsPath)) {
62
- fs.unlinkSync(this.settingsPath);
63
- }
43
+ if (fs.existsSync(this.settingsPath)) fs.unlinkSync(this.settingsPath);
64
44
  }
65
45
 
66
- /**
67
- * Clear only authentication credentials, preserving other settings
68
- */
69
46
  clearCredentials() {
70
47
  const existingSettings = this.load();
71
- if (!existingSettings) {
72
- return;
73
- }
74
-
75
- // Remove only auth-related fields, keep other settings
76
- const authFields = ['endpoint', 'username', 'password', 'token', 'longToken', 'secret'];
77
- authFields.forEach(field => delete existingSettings[field]);
78
-
79
- // Always save the file (even if empty), to preserve the settings.json file
48
+ if (!existingSettings) return;
49
+ const authFields = [
50
+ 'endpoint', 'username', 'password', 'token', 'longToken', 'secret',
51
+ 'credentialVersion', 'secretFormat'
52
+ ];
53
+ authFields.forEach((field) => delete existingSettings[field]);
80
54
  this.save(existingSettings);
81
55
  }
82
56
 
83
- /**
84
- * Check if settings file exists
85
- * @returns {boolean} True if settings file exists
86
- */
87
57
  exists() {
88
58
  return fs.existsSync(this.settingsPath);
89
59
  }
90
60
 
91
- /**
92
- * Get authentication credentials
93
- * @returns {Object|null} Credentials object or null
94
- */
95
61
  getCredentials() {
96
- const settings = this.load();
97
- if (!settings) {
98
- return null;
99
- }
100
- return {
101
- endpoint: settings.endpoint,
102
- username: settings.username,
103
- password: settings.password,
104
- token: settings.token,
105
- longToken: settings.longToken,
106
- secret: settings.secret
107
- };
62
+ const data = this.load();
63
+ if (!data) return null;
64
+ const fields = [
65
+ 'endpoint', 'username', 'password', 'token', 'longToken', 'secret',
66
+ 'credentialVersion', 'secretFormat'
67
+ ];
68
+ return Object.fromEntries(fields.map((field) => [field, data[field]]));
108
69
  }
109
70
 
110
- /**
111
- * Save authentication credentials
112
- * @param {Object} credentials - Credentials to save
113
- */
114
71
  saveCredentials(credentials) {
115
72
  const existingSettings = this.load() || {};
116
73
  this.save({
117
74
  ...existingSettings,
75
+ credentialVersion: 2,
76
+ secretFormat: 'decrypted-v1',
118
77
  ...credentials
119
78
  });
120
79
  }
121
80
  }
122
81
 
123
- module.exports = new Settings();
82
+ const settings = new Settings();
83
+ module.exports = settings;
84
+ module.exports.Settings = Settings;
package/src/constants.js DELETED
@@ -1,69 +0,0 @@
1
- /**
2
- * Command mapping configuration
3
- */
4
-
5
- const COMMAND_MAPPING = {
6
- resmon: {
7
- className: 'ResourceMonitor',
8
- commands: {
9
- cpu: { method: 'cpu', description: 'Get CPU resource monitoring information' },
10
- gpu: { method: 'gpu', description: 'Get GPU resource monitoring information' },
11
- mem: { method: 'memory', description: 'Get memory resource monitoring information' },
12
- disk: { method: 'disk', description: 'Get disk resource monitoring information' },
13
- net: { method: 'net', description: 'Get network resource monitoring information' },
14
- gen: { method: 'general', description: 'Get general resource monitoring information', params: ['items'] }
15
- }
16
- },
17
- store: {
18
- className: 'Store',
19
- commands: {
20
- general: { method: 'general', description: 'Get storage general information' },
21
- calcSpace: { method: 'calculateSpace', description: 'Calculate storage space information' },
22
- listDisk: { method: 'listDisks', description: 'List disk information', params: ['noHotSpare'] },
23
- diskSmart: { method: 'getDiskSmart', description: 'Get disk SMART information', params: ['disk'] },
24
- state: { method: 'getState', description: 'Get storage state information', params: ['name', 'uuid'] }
25
- }
26
- },
27
- sysinfo: {
28
- className: 'SystemInfo',
29
- commands: {
30
- getHostName: { method: 'getHostName', description: 'Get host name information' },
31
- getTrimVersion: { method: 'getTrimVersion', description: 'Get Trim version information' },
32
- getMachineId: { method: 'getMachineId', description: 'Get machine ID information' },
33
- getHardwareInfo: { method: 'getHardwareInfo', description: 'Get hardware information' },
34
- getUptime: { method: 'getUptime', description: 'Get system uptime information' }
35
- }
36
- },
37
- user: {
38
- className: 'User',
39
- commands: {
40
- info: { method: 'getInfo', description: 'Get user information' },
41
- listUG: { method: 'listUserGroups', description: 'List users and groups' },
42
- groupUsers: { method: 'groupUsers', description: 'Get user grouping information' },
43
- isAdmin: { method: 'isAdmin', description: 'Check if current user is admin' }
44
- }
45
- },
46
- network: {
47
- className: 'Network',
48
- commands: {
49
- list: { method: 'list', description: 'List network information', params: ['type'] },
50
- detect: { method: 'detect', description: 'Detect network interface', params: ['ifName'] }
51
- }
52
- },
53
- file: {
54
- className: 'File',
55
- commands: {
56
- ls: { method: 'list', description: 'List files and directories', params: ['path'] },
57
- mkdir: { method: 'mkdir', description: 'Create directory', params: ['path'] },
58
- rm: { method: 'remove', description: 'Remove files or directories', params: ['files', 'moveToTrashbin', 'details'] }
59
- }
60
- },
61
- sac: {
62
- className: 'SAC',
63
- commands: {
64
- upsStatus: { method: 'upsStatus', description: 'Get UPS status information' }
65
- }
66
- }
67
- };
68
-
69
- module.exports = { COMMAND_MAPPING };