@thinremote/thinr-cli 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +79 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +22 -0
- package/README.md +206 -0
- package/bin/thinr.js +78 -0
- package/commands/device.js +272 -0
- package/commands/logout.js +45 -0
- package/commands/product.js +198 -0
- package/lib/api.js +60 -0
- package/lib/auth.js +463 -0
- package/lib/config.js +88 -0
- package/lib/console.js +172 -0
- package/lib/device.js +76 -0
- package/lib/property.js +134 -0
- package/lib/proxy.js +310 -0
- package/lib/resource.js +124 -0
- package/lib/status.js +96 -0
- package/package.json +39 -0
package/lib/console.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readConfig } from './config.js';
|
|
4
|
+
import api from './api.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a WebSocket connection to a device terminal
|
|
8
|
+
* @param {string} deviceId - The ID of the device to connect to
|
|
9
|
+
*/
|
|
10
|
+
export async function connectToDeviceConsole(deviceId) {
|
|
11
|
+
// Get configuration
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
|
|
14
|
+
if (!config.token || !config.server || !config.username) {
|
|
15
|
+
console.error(chalk.red('Error: Not configured. Run thinr without parameters to set up.'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Create a unique session ID
|
|
20
|
+
const sessionId = createUUID();
|
|
21
|
+
|
|
22
|
+
// Create WebSocket URL
|
|
23
|
+
const wsUrl = `wss://${config.server}/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}?raw=1`;
|
|
24
|
+
|
|
25
|
+
//console.log(chalk.blue(`Connecting to ${deviceId}...`));
|
|
26
|
+
|
|
27
|
+
// Create WebSocket connection
|
|
28
|
+
const ws = new WebSocket(wsUrl, {
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${config.token}`
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Flag to track if terminal settings have been restored
|
|
35
|
+
let terminalRestored = false;
|
|
36
|
+
|
|
37
|
+
// Setup raw mode for process.stdin
|
|
38
|
+
process.stdin.setRawMode(true);
|
|
39
|
+
process.stdin.resume();
|
|
40
|
+
process.stdin.setEncoding('utf8');
|
|
41
|
+
|
|
42
|
+
// Function to restore terminal settings
|
|
43
|
+
const restoreTerminal = () => {
|
|
44
|
+
if (!terminalRestored) {
|
|
45
|
+
// Restore terminal settings
|
|
46
|
+
process.stdin.setRawMode(false);
|
|
47
|
+
process.stdin.pause();
|
|
48
|
+
terminalRestored = true;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Handle WebSocket connection
|
|
53
|
+
ws.on('open', () => {
|
|
54
|
+
//console.log(chalk.green(`Connected to ${deviceId}`));
|
|
55
|
+
|
|
56
|
+
// Set initial terminal size
|
|
57
|
+
updateTerminalSize(config, deviceId, sessionId, process.stdout.columns, process.stdout.rows);
|
|
58
|
+
|
|
59
|
+
// Handle window resize
|
|
60
|
+
process.stdout.on('resize', () => {
|
|
61
|
+
updateTerminalSize(config, deviceId, sessionId, process.stdout.columns, process.stdout.rows);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Forward stdin to WebSocket
|
|
65
|
+
process.stdin.on('data', (data) => {
|
|
66
|
+
// Check for Ctrl+D (EOT character, ASCII 4) to exit gracefully
|
|
67
|
+
if (data.toString() === '\u0004') {
|
|
68
|
+
ws.close();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Forward any other input (including Ctrl+C) to the remote device
|
|
73
|
+
ws.send(data);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Forward WebSocket data to stdout
|
|
77
|
+
ws.on('message', (data) => {
|
|
78
|
+
// Ensure we're writing Buffer data to avoid encoding issues
|
|
79
|
+
if (!(data instanceof Buffer)) {
|
|
80
|
+
data = Buffer.from(data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.stdout.write(data);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Handle WebSocket close
|
|
88
|
+
ws.on('close', () => {
|
|
89
|
+
console.log(chalk.yellow('\nConnection closed'));
|
|
90
|
+
restoreTerminal();
|
|
91
|
+
|
|
92
|
+
// Small delay before exiting to flush any remaining output
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}, 100);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Handle WebSocket errors
|
|
99
|
+
ws.on('error', (error) => {
|
|
100
|
+
console.error(chalk.red(`\nWebSocket error: ${error.message}`));
|
|
101
|
+
restoreTerminal();
|
|
102
|
+
process.exit(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Handle SIGINT (Ctrl+C) at process level
|
|
106
|
+
process.on('SIGINT', () => {
|
|
107
|
+
// Try to send Ctrl+C to the remote terminal
|
|
108
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
109
|
+
ws.send('\u0003');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Don't exit our process - let the remote terminal handle it
|
|
113
|
+
return false;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Ensure cleanup on other exit signals
|
|
117
|
+
process.on('SIGTERM', () => {
|
|
118
|
+
restoreTerminal();
|
|
119
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
120
|
+
ws.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Handle uncaught exceptions gracefully
|
|
125
|
+
process.on('uncaughtException', (error) => {
|
|
126
|
+
console.error(chalk.red(`\nUnexpected error: ${error.message}`));
|
|
127
|
+
restoreTerminal();
|
|
128
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
129
|
+
ws.close();
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Update terminal size on remote device
|
|
137
|
+
* @param {Object} config - Configuration object
|
|
138
|
+
* @param {string} deviceId - Device ID
|
|
139
|
+
* @param {string} sessionId - Session ID
|
|
140
|
+
* @param {number} cols - Number of columns
|
|
141
|
+
* @param {number} rows - Number of rows
|
|
142
|
+
*/
|
|
143
|
+
async function updateTerminalSize(config, deviceId, sessionId, cols, rows) {
|
|
144
|
+
try {
|
|
145
|
+
const url = `/v3/users/${config.username}/devices/${deviceId}/resources/$terminal/${sessionId}/params`;
|
|
146
|
+
|
|
147
|
+
await api.post(
|
|
148
|
+
url,
|
|
149
|
+
{
|
|
150
|
+
size: {
|
|
151
|
+
cols: cols,
|
|
152
|
+
rows: rows
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(chalk.red(`Error updating terminal size: ${error.message}`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generate a UUID
|
|
163
|
+
* @returns {string} UUID
|
|
164
|
+
*/
|
|
165
|
+
function createUUID() {
|
|
166
|
+
let dt = new Date().getTime();
|
|
167
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
168
|
+
const r = (dt + Math.random() * 16) % 16 | 0;
|
|
169
|
+
dt = Math.floor(dt / 16);
|
|
170
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
171
|
+
});
|
|
172
|
+
}
|
package/lib/device.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import api from './api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get devices belonging to a product
|
|
7
|
+
* @param {Object} filter - Filters to query devices
|
|
8
|
+
* @return {Promise<Array>} List of devices
|
|
9
|
+
*/
|
|
10
|
+
export async function getDevices(filter) {
|
|
11
|
+
// Get configuration
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
|
|
14
|
+
if (!config.token || !config.server || !config.username) {
|
|
15
|
+
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let devices = [];
|
|
19
|
+
|
|
20
|
+
// Query parameters to string
|
|
21
|
+
let queryParametersString = "";
|
|
22
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
23
|
+
if ( value ) queryParametersString += key+"="+value+"&";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const count = 50;
|
|
28
|
+
let url = `/v1/users/${config.username}/devices?count=${count}&${queryParametersString}`;
|
|
29
|
+
|
|
30
|
+
let index = 0;
|
|
31
|
+
let res_length = 0;
|
|
32
|
+
|
|
33
|
+
do {
|
|
34
|
+
// Fetch devices for the product
|
|
35
|
+
const response = await api.get(
|
|
36
|
+
`${url}index=${index}`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
res_length = response.data.length;
|
|
40
|
+
index += res_length;
|
|
41
|
+
devices = devices.concat(response.data);
|
|
42
|
+
|
|
43
|
+
} while ( res_length === count );
|
|
44
|
+
|
|
45
|
+
return devices;
|
|
46
|
+
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.response) {
|
|
49
|
+
// The request was made and the server responded with a status code
|
|
50
|
+
if (error.response.status === 404) {
|
|
51
|
+
throw new Error(`Product not found: ${productId}`);
|
|
52
|
+
} else if (error.response.status === 401) {
|
|
53
|
+
throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
} else if (error.request) {
|
|
58
|
+
// The request was made but no response was received
|
|
59
|
+
throw new Error('No response from server. Please check your connection.');
|
|
60
|
+
} else {
|
|
61
|
+
// Something happened in setting up the request that triggered an Error
|
|
62
|
+
throw new Error(`Error: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Filter active devices from a list of devices
|
|
70
|
+
* @param {Array} devices - List of devices to filter
|
|
71
|
+
* @return {Array} List of active devices
|
|
72
|
+
*/
|
|
73
|
+
export function filterActiveDevices(devices) {
|
|
74
|
+
return devices.filter(device => device.connection && device.connection.active === true);
|
|
75
|
+
}
|
|
76
|
+
|
package/lib/property.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import api from './api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get device properties
|
|
7
|
+
* @param {string} deviceId - Device ID to query
|
|
8
|
+
* @returns {Promise<Object>} Device properties
|
|
9
|
+
*/
|
|
10
|
+
export async function getDeviceProperties(deviceId) {
|
|
11
|
+
// Get configuration
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
|
|
14
|
+
if (!config.token || !config.server || !config.username) {
|
|
15
|
+
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Fetch device properties
|
|
20
|
+
const response = await api.get(
|
|
21
|
+
`/v3/users/${config.username}/devices/${deviceId}/properties`,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return response.data;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.response) {
|
|
27
|
+
// The request was made and the server responded with a status code
|
|
28
|
+
if (error.response.status === 404) {
|
|
29
|
+
throw new Error(`Device not found: ${deviceId}`);
|
|
30
|
+
} else if (error.response.status === 401) {
|
|
31
|
+
throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
|
|
32
|
+
} else {
|
|
33
|
+
throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
|
|
34
|
+
}
|
|
35
|
+
} else if (error.request) {
|
|
36
|
+
// The request was made but no response was received
|
|
37
|
+
throw new Error('No response from server. Please check your connection.');
|
|
38
|
+
} else {
|
|
39
|
+
// Something happened in setting up the request that triggered an Error
|
|
40
|
+
throw new Error(`Error: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get device property value
|
|
47
|
+
* @param {string} deviceId - Device ID to query
|
|
48
|
+
* @param {string} propertyId - Property ID to retrieve
|
|
49
|
+
* @returns {Promise<Object>} Property value
|
|
50
|
+
*/
|
|
51
|
+
export async function getDeviceProperty(deviceId, propertyId) {
|
|
52
|
+
// Get configuration
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
|
|
55
|
+
if (!config.token || !config.server || !config.username) {
|
|
56
|
+
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Fetch property value
|
|
61
|
+
const response = await api.get(
|
|
62
|
+
`/v3/users/${config.username}/devices/${deviceId}/properties/${propertyId}`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return response.data.value;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.log(error);
|
|
68
|
+
if (error.response) {
|
|
69
|
+
// The request was made and the server responded with a status code
|
|
70
|
+
if (error.response.status === 404) {
|
|
71
|
+
throw new Error(`Property not found: ${propertyId} on device ${deviceId}`);
|
|
72
|
+
} else if (error.response.status === 401) {
|
|
73
|
+
throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
} else if (error.request) {
|
|
78
|
+
// The request was made but no response was received
|
|
79
|
+
throw new Error('No response from server. Please check your connection.');
|
|
80
|
+
} else {
|
|
81
|
+
// Something happened in setting up the request that triggered an Error
|
|
82
|
+
throw new Error(`Error: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Format device properties for display
|
|
89
|
+
* @param {string} deviceId - Device ID
|
|
90
|
+
* @param {Object} properties - Properties object from API
|
|
91
|
+
* @returns {string} Formatted properties
|
|
92
|
+
*/
|
|
93
|
+
export function formatDeviceProperties(deviceId, properties) {
|
|
94
|
+
|
|
95
|
+
let formattedProperties = '\nProperties of device ' + chalk.cyan(deviceId) + ':\n';
|
|
96
|
+
|
|
97
|
+
properties.forEach(property => {
|
|
98
|
+
formattedProperties += `- ${property.property}\n`;
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return formattedProperties;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Format device property value for display
|
|
106
|
+
* @param {string} deviceId - Device ID
|
|
107
|
+
* @param {string} propertyId - Property ID
|
|
108
|
+
* @param {Object} propertyValue - Property value from API
|
|
109
|
+
* @returns {string} Formatted property value
|
|
110
|
+
*/
|
|
111
|
+
export function formatDeviceProperty(deviceId, propertyId, propertyValue) {
|
|
112
|
+
// show json with flattened structure
|
|
113
|
+
let formattedValue = '\nValue of property ' + chalk.cyan(propertyId) + ' for device ' + chalk.cyan(deviceId) + ':\n';
|
|
114
|
+
const flattenedValue = flatten(propertyValue);
|
|
115
|
+
for (const key in flattenedValue) {
|
|
116
|
+
formattedValue += `${key}: ${chalk.green(flattenedValue[key])}\n`;
|
|
117
|
+
}
|
|
118
|
+
return formattedValue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function flatten(obj, prefix = '', res = {}) {
|
|
122
|
+
for (const key in obj) {
|
|
123
|
+
const val = obj[key];
|
|
124
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
125
|
+
if (typeof val === 'object' && val !== null) {
|
|
126
|
+
flatten(val, path, res);
|
|
127
|
+
} else {
|
|
128
|
+
res[path] = val;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return res;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import open, { apps } from 'open';
|
|
3
|
+
import { configExists, readConfig } from './config.js';
|
|
4
|
+
import api from './api.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse target argument into address and port
|
|
8
|
+
* @param {string} target - Target in format 'address:port', 'port', 'address', or 'protocol://hostname_or_ip:port'
|
|
9
|
+
* @param {number} defaultPort - Default port if not specified
|
|
10
|
+
* @returns {Object} Parsed address, port, and secure flag
|
|
11
|
+
*/
|
|
12
|
+
function parseTarget(target, defaultPort) {
|
|
13
|
+
if (!target) {
|
|
14
|
+
return { address: 'localhost', port: defaultPort, isSecure: false };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check if target is just a number (port only)
|
|
18
|
+
if (/^\d+$/.test(target)) {
|
|
19
|
+
return { address: 'localhost', port: parseInt(target), isSecure: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let isSecure = false;
|
|
23
|
+
let cleanTarget = target;
|
|
24
|
+
|
|
25
|
+
// Check if target starts with a protocol (full URL format)
|
|
26
|
+
if (target.startsWith('https://')) {
|
|
27
|
+
isSecure = true;
|
|
28
|
+
cleanTarget = target.replace('https://', '');
|
|
29
|
+
} else if (target.startsWith('http://')) {
|
|
30
|
+
isSecure = false;
|
|
31
|
+
cleanTarget = target.replace('http://', '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Remove any path/query/fragment from the URL (everything after the first slash)
|
|
35
|
+
const slashIndex = cleanTarget.indexOf('/');
|
|
36
|
+
if (slashIndex !== -1) {
|
|
37
|
+
cleanTarget = cleanTarget.substring(0, slashIndex);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse the remaining part (hostname_or_ip:port or just hostname_or_ip)
|
|
41
|
+
let address;
|
|
42
|
+
let port;
|
|
43
|
+
|
|
44
|
+
if (cleanTarget.includes(':')) {
|
|
45
|
+
// Format: hostname_or_ip:port
|
|
46
|
+
const lastColonIndex = cleanTarget.lastIndexOf(':');
|
|
47
|
+
address = cleanTarget.substring(0, lastColonIndex);
|
|
48
|
+
const portStr = cleanTarget.substring(lastColonIndex + 1);
|
|
49
|
+
|
|
50
|
+
// Check if the part after the last colon is actually a port number
|
|
51
|
+
const parsedPort = parseInt(portStr);
|
|
52
|
+
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
|
53
|
+
port = parsedPort;
|
|
54
|
+
} else {
|
|
55
|
+
// Not a valid port, treat the whole thing as address
|
|
56
|
+
address = cleanTarget;
|
|
57
|
+
port = defaultPort;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Format: just hostname_or_ip
|
|
61
|
+
address = cleanTarget;
|
|
62
|
+
port = defaultPort;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// If we detected a protocol but no explicit port, use protocol defaults
|
|
66
|
+
if ((target.startsWith('https://') || target.startsWith('http://')) && port === defaultPort) {
|
|
67
|
+
if (isSecure && defaultPort !== 443) {
|
|
68
|
+
port = 443; // Default HTTPS port
|
|
69
|
+
} else if (!isSecure && defaultPort !== 80) {
|
|
70
|
+
port = 80; // Default HTTP port
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
address: address || 'localhost',
|
|
76
|
+
port: port,
|
|
77
|
+
isSecure
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create proxy action handler
|
|
83
|
+
*/
|
|
84
|
+
export async function handleProxyAction(deviceId, protocol, target, options) {
|
|
85
|
+
// Check if configured
|
|
86
|
+
if (!configExists()) {
|
|
87
|
+
console.error(chalk.red('Error: Not configured. Run thinr without parameters to set up.'));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Determine default port based on protocol
|
|
92
|
+
const defaultPort = protocol === 'http' ? 80 : (protocol === 'tls' ? 443 : 22);
|
|
93
|
+
const { address, port, isSecure } = parseTarget(target, defaultPort);
|
|
94
|
+
|
|
95
|
+
// Determine proxy configuration based on protocol
|
|
96
|
+
let proxyConfig = {
|
|
97
|
+
targetAddress: address,
|
|
98
|
+
targetPort: port,
|
|
99
|
+
serverPort: options.port ? parseInt(options.port) : null,
|
|
100
|
+
targetSecure: false,
|
|
101
|
+
serverSecure: false,
|
|
102
|
+
web: false
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
switch (protocol) {
|
|
106
|
+
case 'tcp':
|
|
107
|
+
// TCP proxy without TLS
|
|
108
|
+
proxyConfig.serverSecure = false;
|
|
109
|
+
break;
|
|
110
|
+
case 'tls':
|
|
111
|
+
// TLS proxy
|
|
112
|
+
proxyConfig.targetSecure = true;
|
|
113
|
+
proxyConfig.serverSecure = true;
|
|
114
|
+
break;
|
|
115
|
+
case 'http':
|
|
116
|
+
// HTTP proxy
|
|
117
|
+
proxyConfig.web = true;
|
|
118
|
+
proxyConfig.serverSecure = true; // Default to HTTPS for web interface
|
|
119
|
+
// Use the isSecure flag from parseTarget
|
|
120
|
+
proxyConfig.targetSecure = isSecure || port === 443;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const proxyType = proxyConfig.web ? 'HTTP proxy' : `${protocol.toUpperCase()} proxy`;
|
|
125
|
+
const spinner = ora(`Creating ${proxyType} to ${deviceId} (${address}:${port})...`).start();
|
|
126
|
+
let proxyId = null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Create proxy
|
|
130
|
+
const proxy = await createProxy(deviceId, proxyConfig);
|
|
131
|
+
proxyId = proxy.proxyId;
|
|
132
|
+
|
|
133
|
+
if (proxyConfig.web) {
|
|
134
|
+
// For HTTP proxy, show target as URL format
|
|
135
|
+
const targetProtocol = isSecure ? 'https' : 'http';
|
|
136
|
+
const targetUrl = `${targetProtocol}://${address}${port === 80 || port === 443 ? '' : `:${port}`}`;
|
|
137
|
+
|
|
138
|
+
spinner.succeed(`HTTP proxy running at ${chalk.blue(proxy.url)} → ${deviceId} -> ${chalk.cyan(targetUrl)}`);
|
|
139
|
+
|
|
140
|
+
// Open browser if not disabled
|
|
141
|
+
if (options.openBrowser !== false) {
|
|
142
|
+
await openBrowser(proxy.url);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// For TCP/TLS proxy, show address:port format
|
|
146
|
+
const secureInfo = isSecure ? ' [TLS]' : '';
|
|
147
|
+
spinner.succeed(`${protocol.toUpperCase()} proxy running on ${chalk.blue(proxy.serverHost + ':' + proxy.serverPort)} → ${deviceId} (${address}:${port})${secureInfo}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(chalk.gray(`Press Ctrl+C to stop`));
|
|
151
|
+
|
|
152
|
+
// Handle process termination
|
|
153
|
+
process.on('SIGINT', async () => {
|
|
154
|
+
console.log(chalk.yellow('\nStopping proxy...'));
|
|
155
|
+
if (proxyId) {
|
|
156
|
+
await deleteProxy(proxyId);
|
|
157
|
+
}
|
|
158
|
+
process.exit(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Keep the process running
|
|
162
|
+
process.stdin.resume();
|
|
163
|
+
|
|
164
|
+
} catch (error) {
|
|
165
|
+
spinner.fail(`Failed to create ${proxyType}`);
|
|
166
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
167
|
+
|
|
168
|
+
// Clean up if needed
|
|
169
|
+
if (proxyId) {
|
|
170
|
+
await deleteProxy(proxyId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Generate a random port number within a range
|
|
179
|
+
* @param {number} min - Minimum port number
|
|
180
|
+
* @param {number} max - Maximum port number
|
|
181
|
+
* @returns {number} Random port number
|
|
182
|
+
*/
|
|
183
|
+
function getRandomPort(min = 50000, max = 51000) {
|
|
184
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create a proxy to a device
|
|
189
|
+
* @param {string} deviceId - Device ID
|
|
190
|
+
* @param {Object} options - Proxy options
|
|
191
|
+
* @returns {Promise<Object>} Proxy information
|
|
192
|
+
*/
|
|
193
|
+
async function createProxy(deviceId, options) {
|
|
194
|
+
// Get configuration
|
|
195
|
+
const config = readConfig();
|
|
196
|
+
|
|
197
|
+
if (!config.token || !config.server || !config.username) {
|
|
198
|
+
throw new Error('Not configured. Run thinr without parameters to set up.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const protocol = options.web ? 'http_iotmp' : 'tcp_iotmp';
|
|
202
|
+
const targetAddress = options.targetAddress || 'localhost';
|
|
203
|
+
const targetPort = options.targetPort || (options.web ? 80 : 22);
|
|
204
|
+
const serverPort = options.serverPort || getRandomPort();
|
|
205
|
+
const serverSecure = options.serverSecure || (!!options.web);
|
|
206
|
+
const targetSecure = options.targetSecure || false;
|
|
207
|
+
|
|
208
|
+
// Create a unique proxy ID based on device, protocol and a timestamp
|
|
209
|
+
const timestamp = new Date().getTime().toString(36);
|
|
210
|
+
const proxyId = `${protocol}_${deviceId.slice(0,12)}_${timestamp}`;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// Create proxy
|
|
214
|
+
const response = await api.post(
|
|
215
|
+
`/v1/proxies`,
|
|
216
|
+
{
|
|
217
|
+
enabled: true,
|
|
218
|
+
config: {
|
|
219
|
+
target: {
|
|
220
|
+
type: 'address',
|
|
221
|
+
user: config.username,
|
|
222
|
+
device: deviceId,
|
|
223
|
+
address: targetAddress,
|
|
224
|
+
port: targetPort,
|
|
225
|
+
secure: targetSecure
|
|
226
|
+
},
|
|
227
|
+
protocol: protocol,
|
|
228
|
+
source: {
|
|
229
|
+
port: serverPort,
|
|
230
|
+
secure: serverSecure
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
proxy: proxyId,
|
|
234
|
+
name: options.web ? `Web access for ${deviceId}` : `TCP proxy for ${deviceId}`,
|
|
235
|
+
description: `${options.web ? 'Web interface' : 'TCP proxy'} for ${deviceId}:${targetPort} created by ThingR CLI`
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
headers: {
|
|
239
|
+
'Content-Type': 'application/json'
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Determine the URL based on protocol and SSL setting
|
|
245
|
+
const scheme = serverSecure ? 'https' : 'http';
|
|
246
|
+
const url = options.web ? `${scheme}://${config.server}:${serverPort}` : null;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
proxyId: proxyId,
|
|
250
|
+
url: url,
|
|
251
|
+
serverPort: serverPort,
|
|
252
|
+
serverHost: config.server,
|
|
253
|
+
targetAddress: targetAddress,
|
|
254
|
+
targetPort: targetPort,
|
|
255
|
+
protocol: protocol,
|
|
256
|
+
serverSecure: serverSecure,
|
|
257
|
+
targetSecure: targetSecure
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.response) {
|
|
261
|
+
if (error.response.status === 401) {
|
|
262
|
+
throw new Error('Unauthorized. Your token may have expired. Please reconfigure.');
|
|
263
|
+
} else {
|
|
264
|
+
throw new Error(`Server error: ${error.response.status} ${error.response.statusText}`);
|
|
265
|
+
}
|
|
266
|
+
} else if (error.request) {
|
|
267
|
+
throw new Error('No response from server. Please check your connection.');
|
|
268
|
+
} else {
|
|
269
|
+
throw new Error(`Error: ${error.message}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Delete a proxy
|
|
276
|
+
* @param {string} proxyId - Proxy ID to delete
|
|
277
|
+
*/
|
|
278
|
+
async function deleteProxy(proxyId) {
|
|
279
|
+
// Get configuration
|
|
280
|
+
const config = readConfig();
|
|
281
|
+
|
|
282
|
+
if (!config.token || !config.server) {
|
|
283
|
+
return; // Silently fail on config issues during cleanup
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
await api.delete(
|
|
288
|
+
`/v1/proxies/${proxyId}`,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return true;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(chalk.red(`Error deleting proxy: ${error.message}`));
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Open web browser to the proxy URL
|
|
300
|
+
* @param {string} url - URL to open
|
|
301
|
+
*/
|
|
302
|
+
async function openBrowser(url) {
|
|
303
|
+
try {
|
|
304
|
+
await open(url, {app: [{name: apps.browser}, 'firefox-developer-edition']});
|
|
305
|
+
return true;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(chalk.red(`Error opening browser: ${error.message}`));
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|