onedeploy-cli 0.1.9 → 0.1.11
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 +6 -7
- package/dist/browser.js +41 -0
- package/dist/config.js +2 -10
- package/dist/index.js +114 -47
- package/dist/nebula.js +70 -25
- package/dist/proxy.js +69 -0
- package/dist/success.js +201 -0
- package/dist/tunnel-menu.js +169 -0
- package/dist/tunnel.js +76 -0
- package/package.json +5 -5
- package/src/index.ts +141 -46
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oneDeploy CLI
|
|
2
2
|
|
|
3
|
-
This CLI allows you to connect to a
|
|
3
|
+
This CLI allows you to connect to a oneDeploy cluster via
|
|
4
4
|
[Nebula](https://github.com/slackhq/nebula).
|
|
5
5
|
|
|
6
6
|
## Getting Started
|
|
@@ -17,12 +17,11 @@ or from an administrator prompt on Windows:
|
|
|
17
17
|
npx onedeploy-cli
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Then, you need to create an API key on your
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
-
per computer (for the root user), not individually per user.
|
|
20
|
+
Then, you need to create an API key on your oneDeploy dashboard. You can
|
|
21
|
+
`Enter new URL` to enter your oneDeploy URL. After the first connection, this
|
|
22
|
+
URL will be remembered. You will need to authenticate using your passkey.
|
|
24
23
|
|
|
25
24
|
## License
|
|
26
25
|
|
|
27
|
-
The
|
|
26
|
+
The oneDeploy CLI licensed under MIT, see [here](./LICENSE). It uses the Nebula
|
|
28
27
|
overlay network, which is also MIT licensed.
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
export const openBrowser = async (url) => {
|
|
4
|
+
try {
|
|
5
|
+
// If running as root via sudo, run browser as the original user
|
|
6
|
+
const isRoot = process.getuid?.() === 0;
|
|
7
|
+
const sudoUser = process.env.SUDO_USER;
|
|
8
|
+
switch (process.platform) {
|
|
9
|
+
case "linux": {
|
|
10
|
+
if (isRoot && sudoUser) {
|
|
11
|
+
// Run as the original user to avoid browser sandbox issues
|
|
12
|
+
await promisify(execFile)("sudo", ["-u", sudoUser, "xdg-open", url]);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
await promisify(execFile)("xdg-open", [url]);
|
|
16
|
+
}
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
case "darwin": {
|
|
20
|
+
if (isRoot && sudoUser) {
|
|
21
|
+
// Run as the original user to avoid browser issues
|
|
22
|
+
await promisify(execFile)("sudo", ["-u", sudoUser, "open", url]);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
await promisify(execFile)("open", [url]);
|
|
26
|
+
}
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case "win32":
|
|
30
|
+
await promisify(execFile)("cmd", ["/c", "start", '""', url]);
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
console.error(`Unsupported platform: ${process.platform}.`);
|
|
34
|
+
console.error("Please open the URL manually.");
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
console.error("Could not open browser automatically. Please open the URL manually.");
|
|
40
|
+
}
|
|
41
|
+
};
|
package/dist/config.js
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
|
|
4
|
-
const configDir = `${homedir()}/.config/onedeploy`;
|
|
3
|
+
export const configDir = `${homedir()}/.config/onedeploy`;
|
|
5
4
|
await mkdir(configDir, { recursive: true });
|
|
6
|
-
const Instance = y.object({
|
|
7
|
-
url: y.string(),
|
|
8
|
-
});
|
|
9
|
-
const Config = y.object({
|
|
10
|
-
instances: Instance.array(),
|
|
11
|
-
});
|
|
12
5
|
export const loadConfig = async () => {
|
|
13
6
|
try {
|
|
14
7
|
const result = await readFile(`${configDir}/config.json`, "utf-8");
|
|
15
|
-
|
|
16
|
-
return Config.parse(config);
|
|
8
|
+
return JSON.parse(result);
|
|
17
9
|
}
|
|
18
10
|
catch (_error) {
|
|
19
11
|
// no config yet
|
package/dist/index.js
CHANGED
|
@@ -1,27 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import prompts from "prompts";
|
|
3
3
|
import { loadConfig, saveConfig } from "./config.js";
|
|
4
|
-
import { cleanupNebula,
|
|
4
|
+
import { cleanupNebula, getSession, installNebula, isNebulaRunning, startNebula, stopNebula, } from "./nebula.js";
|
|
5
|
+
import { deleteAllTunnels } from "./tunnel.js";
|
|
6
|
+
import { tunnelMenu } from "./tunnel-menu.js";
|
|
7
|
+
// Track active connection for cleanup on signals
|
|
8
|
+
let activeConnection = null;
|
|
9
|
+
const cleanup = async () => {
|
|
10
|
+
if (activeConnection) {
|
|
11
|
+
console.log("\nCleaning up...");
|
|
12
|
+
try {
|
|
13
|
+
await deleteAllTunnels(activeConnection.instance, activeConnection.session);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore if already disconnected
|
|
17
|
+
}
|
|
18
|
+
activeConnection.stopRefresh();
|
|
19
|
+
stopNebula(activeConnection.nebulaProcess);
|
|
20
|
+
activeConnection = null;
|
|
21
|
+
}
|
|
22
|
+
await cleanupNebula();
|
|
23
|
+
};
|
|
24
|
+
const handleSignal = () => {
|
|
25
|
+
if (!isNebulaRunning) {
|
|
26
|
+
process.exit();
|
|
27
|
+
}
|
|
28
|
+
// Perform cleanup and exit
|
|
29
|
+
cleanup().finally(() => process.exit());
|
|
30
|
+
};
|
|
5
31
|
if (process.argv.includes("--version")) {
|
|
6
32
|
console.log("onedeploy-cli version 0.1.4");
|
|
7
33
|
process.exit();
|
|
8
34
|
}
|
|
9
35
|
const config = await loadConfig();
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
36
|
+
const NEW_URL_VALUE = "__NEW_URL__";
|
|
37
|
+
const normalizeUrl = (url) => {
|
|
38
|
+
const trimmedUrl = url.trim();
|
|
39
|
+
// Convert http:// to https://
|
|
40
|
+
if (trimmedUrl.startsWith("http://")) {
|
|
41
|
+
return trimmedUrl.replace("http://", "https://");
|
|
42
|
+
}
|
|
43
|
+
// Add https:// if no protocol specified
|
|
44
|
+
if (!trimmedUrl.startsWith("https://")) {
|
|
45
|
+
return `https://${trimmedUrl}`;
|
|
46
|
+
}
|
|
47
|
+
return trimmedUrl;
|
|
48
|
+
};
|
|
49
|
+
const addInstance = async (url) => {
|
|
50
|
+
// Check for duplicates
|
|
51
|
+
if (config.instances.some((instance) => instance.url === url)) {
|
|
19
52
|
return;
|
|
20
53
|
}
|
|
21
|
-
config.instances.push({ url
|
|
54
|
+
config.instances.push({ url });
|
|
22
55
|
await saveConfig(config);
|
|
23
56
|
};
|
|
57
|
+
const REMOVE_URL_VALUE = "__REMOVE_URL__";
|
|
24
58
|
const removeConnection = async () => {
|
|
59
|
+
if (config.instances.length === 0) {
|
|
60
|
+
console.log("No connections to remove.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
25
63
|
const { connection } = await prompts([
|
|
26
64
|
{
|
|
27
65
|
type: "select",
|
|
@@ -50,61 +88,90 @@ const removeConnection = async () => {
|
|
|
50
88
|
}
|
|
51
89
|
};
|
|
52
90
|
const mainMenu = async () => {
|
|
53
|
-
const
|
|
91
|
+
const choices = [
|
|
92
|
+
...config.instances.map((instance) => ({
|
|
93
|
+
title: instance.url,
|
|
94
|
+
value: instance.url,
|
|
95
|
+
})),
|
|
96
|
+
{
|
|
97
|
+
title: "Enter new URL",
|
|
98
|
+
value: NEW_URL_VALUE,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
title: "Remove a URL",
|
|
102
|
+
value: REMOVE_URL_VALUE,
|
|
103
|
+
disabled: config.instances.length === 0,
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
const { selection } = await prompts([
|
|
54
107
|
{
|
|
55
108
|
type: "select",
|
|
56
|
-
name: "
|
|
57
|
-
message: "
|
|
58
|
-
choices
|
|
59
|
-
{
|
|
60
|
-
title: "Connect",
|
|
61
|
-
value: "connect",
|
|
62
|
-
disabled: config.instances.length === 0,
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
title: "Setup new connection",
|
|
66
|
-
value: "setup",
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
title: "Remove connection",
|
|
70
|
-
value: "remove",
|
|
71
|
-
},
|
|
72
|
-
],
|
|
109
|
+
name: "selection",
|
|
110
|
+
message: "Select oneDeploy instance",
|
|
111
|
+
choices,
|
|
73
112
|
},
|
|
74
113
|
]);
|
|
75
|
-
if (
|
|
114
|
+
if (selection === undefined) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (selection === REMOVE_URL_VALUE) {
|
|
118
|
+
await removeConnection();
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
let urlToConnect;
|
|
122
|
+
if (selection === NEW_URL_VALUE) {
|
|
76
123
|
const result = await prompts([
|
|
77
124
|
{
|
|
78
|
-
type: "
|
|
79
|
-
name: "
|
|
80
|
-
message: "
|
|
81
|
-
choices: config.instances.map((instance) => ({
|
|
82
|
-
title: instance.url,
|
|
83
|
-
value: instance,
|
|
84
|
-
})),
|
|
125
|
+
type: "text",
|
|
126
|
+
name: "url",
|
|
127
|
+
message: "Enter oneDeploy URL",
|
|
85
128
|
},
|
|
86
129
|
]);
|
|
87
|
-
if (result.
|
|
88
|
-
|
|
130
|
+
if (result.url === undefined) {
|
|
131
|
+
return true;
|
|
89
132
|
}
|
|
133
|
+
urlToConnect = normalizeUrl(result.url);
|
|
90
134
|
}
|
|
91
|
-
else
|
|
92
|
-
|
|
135
|
+
else {
|
|
136
|
+
urlToConnect = selection;
|
|
93
137
|
}
|
|
94
|
-
|
|
95
|
-
|
|
138
|
+
const instance = { url: urlToConnect, apiKey: "" };
|
|
139
|
+
// 1. Authenticate
|
|
140
|
+
const session = await getSession(instance);
|
|
141
|
+
// 2. Start Nebula (non-blocking)
|
|
142
|
+
const { nebulaProcess, stopRefresh } = await startNebula(instance, session);
|
|
143
|
+
// Track connection for signal handlers
|
|
144
|
+
activeConnection = { instance, session, nebulaProcess, stopRefresh };
|
|
145
|
+
// 3. Enter tunnel management loop
|
|
146
|
+
try {
|
|
147
|
+
await tunnelMenu(instance, session);
|
|
96
148
|
}
|
|
97
|
-
|
|
98
|
-
|
|
149
|
+
finally {
|
|
150
|
+
// 4. Cleanup on exit
|
|
151
|
+
console.log("Cleaning up tunnels...");
|
|
152
|
+
try {
|
|
153
|
+
await deleteAllTunnels(instance, session);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// ignore if already disconnected
|
|
157
|
+
}
|
|
158
|
+
stopRefresh();
|
|
159
|
+
stopNebula(nebulaProcess);
|
|
160
|
+
activeConnection = null;
|
|
99
161
|
}
|
|
162
|
+
// Only save if connection was successful (didn't throw)
|
|
163
|
+
await addInstance(urlToConnect);
|
|
100
164
|
return true;
|
|
101
165
|
};
|
|
102
166
|
process.on("SIGINT", () => {
|
|
103
167
|
if (!isNebulaRunning) {
|
|
104
168
|
process.exit();
|
|
105
169
|
}
|
|
106
|
-
//
|
|
170
|
+
// When Nebula is running, let prompts handle SIGINT (returns undefined)
|
|
171
|
+
// which triggers the finally block in mainMenu
|
|
107
172
|
});
|
|
173
|
+
process.on("SIGTERM", handleSignal);
|
|
174
|
+
process.on("SIGHUP", handleSignal);
|
|
108
175
|
await installNebula();
|
|
109
176
|
try {
|
|
110
177
|
while (true) {
|
|
@@ -115,5 +182,5 @@ try {
|
|
|
115
182
|
}
|
|
116
183
|
}
|
|
117
184
|
finally {
|
|
118
|
-
await
|
|
185
|
+
await cleanup();
|
|
119
186
|
}
|
package/dist/nebula.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { access, chmod, mkdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { parse } from "yaml";
|
|
10
|
+
import { openBrowser } from "./browser.js";
|
|
11
|
+
import { configDir } from "./config.js";
|
|
12
|
+
import { successPage } from "./success.js";
|
|
10
13
|
const getNebulaPackageName = () => {
|
|
11
14
|
switch (process.platform) {
|
|
12
15
|
case "linux":
|
|
@@ -33,22 +36,52 @@ const getNebulaPackageName = () => {
|
|
|
33
36
|
const nebulaVersion = "v1.10.0";
|
|
34
37
|
const packageName = getNebulaPackageName();
|
|
35
38
|
const nebulaUrl = `https://github.com/slackhq/nebula/releases/download/${nebulaVersion}/${packageName}`;
|
|
39
|
+
const nebulaDir = path.join(configDir, "nebula");
|
|
36
40
|
const tmpDir = path.join(tmpdir(), `onedeploy-${randomUUID()}`);
|
|
37
41
|
await mkdir(tmpDir, { recursive: true });
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
const isCacheValid = async () => {
|
|
43
|
+
try {
|
|
44
|
+
const versionFile = path.join(nebulaDir, "version");
|
|
45
|
+
const cachedVersion = await readFile(versionFile, "utf-8");
|
|
46
|
+
if (cachedVersion.trim() !== nebulaVersion) {
|
|
47
|
+
// Version mismatch - delete old version
|
|
48
|
+
await rm(nebulaDir, { recursive: true, force: true });
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// Check if binaries exist
|
|
52
|
+
await access(path.join(nebulaDir, "nebula"));
|
|
53
|
+
await access(path.join(nebulaDir, "nebula-cert"));
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// If any error occurs (file doesn't exist, etc.), invalidate cache
|
|
58
|
+
await rm(nebulaDir, { recursive: true, force: true });
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const downloadNebula = async () => {
|
|
63
|
+
console.log(`Downloading Nebula ${nebulaVersion}...`);
|
|
40
64
|
const file = await fetch(nebulaUrl);
|
|
41
65
|
if (file.body === null) {
|
|
42
66
|
throw new Error("Could not download Nebula executable.");
|
|
43
67
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
const downloadPath = path.join(tmpDir, packageName);
|
|
69
|
+
await writeFile(downloadPath, Readable.from(file.body));
|
|
70
|
+
console.log(`Extracting Nebula...`);
|
|
71
|
+
// Extract directly to nebulaDir
|
|
72
|
+
await mkdir(nebulaDir, { recursive: true });
|
|
73
|
+
await promisify(execFile)("tar", ["-xf", downloadPath, "-C", nebulaDir]);
|
|
74
|
+
// Store version info
|
|
75
|
+
await writeFile(path.join(nebulaDir, "version"), nebulaVersion);
|
|
76
|
+
// Clean up downloaded archive
|
|
77
|
+
await rm(downloadPath, { force: true });
|
|
78
|
+
console.log(`Stored Nebula binaries to ${nebulaDir}`);
|
|
79
|
+
};
|
|
80
|
+
export const installNebula = async () => {
|
|
81
|
+
const cached = await isCacheValid();
|
|
82
|
+
if (!cached) {
|
|
83
|
+
await downloadNebula();
|
|
84
|
+
}
|
|
52
85
|
};
|
|
53
86
|
export const cleanupNebula = async () => {
|
|
54
87
|
await rm(tmpDir, { recursive: true, force: true });
|
|
@@ -68,12 +101,16 @@ export const getNebulaCert = async (instance, session) => {
|
|
|
68
101
|
console.log(`Wrote Nebula configuration to ${tmpDir}/nebula.yml`);
|
|
69
102
|
const parsedConfig = parse(nebulaConfig);
|
|
70
103
|
await writeFile(`${tmpDir}/nebula-debug.crt`, parsedConfig.pki.cert);
|
|
71
|
-
const { stdout } = await promisify(execFile)(`${
|
|
104
|
+
const { stdout } = await promisify(execFile)(`${nebulaDir}/nebula-cert`, [
|
|
72
105
|
"print",
|
|
73
106
|
"-path",
|
|
74
107
|
`${tmpDir}/nebula-debug.crt`,
|
|
75
108
|
]);
|
|
76
109
|
const cert = JSON.parse(stdout);
|
|
110
|
+
// Extract Nebula IP from certificate (remove CIDR suffix)
|
|
111
|
+
if (cert.details.networks && cert.details.networks.length > 0) {
|
|
112
|
+
nebulaIp = cert.details.networks[0].split("/")[0];
|
|
113
|
+
}
|
|
77
114
|
// in case our local time isn't correct, wait for the certificate to become valid
|
|
78
115
|
const validFromDate = new Date(cert.details.notBefore);
|
|
79
116
|
if (validFromDate.toString() === "Invalid Date") {
|
|
@@ -86,8 +123,8 @@ export const getNebulaCert = async (instance, session) => {
|
|
|
86
123
|
}
|
|
87
124
|
};
|
|
88
125
|
export let isNebulaRunning = false;
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
export let nebulaIp = null;
|
|
127
|
+
export const getSession = (instance) => {
|
|
91
128
|
return new Promise((resolve) => {
|
|
92
129
|
const getServerAddr = () => {
|
|
93
130
|
const addr = server.address();
|
|
@@ -105,8 +142,10 @@ const getSession = (instance) => {
|
|
|
105
142
|
throw new Error("Missing token in callback URL");
|
|
106
143
|
}
|
|
107
144
|
// authentication complete
|
|
108
|
-
res.writeHead(200
|
|
109
|
-
|
|
145
|
+
res.writeHead(200, "OK", {
|
|
146
|
+
"content-type": "text/html",
|
|
147
|
+
});
|
|
148
|
+
res.write(successPage);
|
|
110
149
|
res.end();
|
|
111
150
|
server.close();
|
|
112
151
|
resolve(token);
|
|
@@ -114,32 +153,38 @@ const getSession = (instance) => {
|
|
|
114
153
|
else {
|
|
115
154
|
const target = `${getServerAddr()}/authenticated`;
|
|
116
155
|
res.writeHead(302, {
|
|
117
|
-
location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}
|
|
156
|
+
location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}`,
|
|
118
157
|
});
|
|
119
158
|
res.end();
|
|
120
159
|
}
|
|
121
160
|
});
|
|
122
|
-
server.on("listening", () => {
|
|
161
|
+
server.on("listening", async () => {
|
|
123
162
|
const addr = server.address();
|
|
124
163
|
if (addr === null || typeof addr === "string") {
|
|
125
164
|
throw new Error("Unexpected address type");
|
|
126
165
|
}
|
|
127
|
-
|
|
166
|
+
const authUrl = getServerAddr();
|
|
167
|
+
console.log(`Visit ${authUrl} to authenticate (opened in browser)`);
|
|
168
|
+
await openBrowser(authUrl);
|
|
128
169
|
});
|
|
129
170
|
server.listen();
|
|
130
171
|
});
|
|
131
172
|
};
|
|
132
|
-
export const
|
|
133
|
-
const session = await getSession(instance);
|
|
173
|
+
export const startNebula = async (instance, session) => {
|
|
134
174
|
await getNebulaCert(instance, session);
|
|
135
175
|
isNebulaRunning = true;
|
|
136
|
-
const nebula = spawn(`${
|
|
176
|
+
const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "ignore" });
|
|
137
177
|
const interval = setInterval(async () => {
|
|
138
178
|
// refresh Nebula cert every 10 minutes (lifetime is 15 minutes)
|
|
139
179
|
await getNebulaCert(instance, session);
|
|
140
180
|
nebula.kill("SIGHUP");
|
|
141
181
|
}, 10 * 60 * 1000);
|
|
142
|
-
|
|
182
|
+
return {
|
|
183
|
+
nebulaProcess: nebula,
|
|
184
|
+
stopRefresh: () => clearInterval(interval),
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
export const stopNebula = (nebulaProcess) => {
|
|
188
|
+
nebulaProcess.kill("SIGTERM");
|
|
143
189
|
isNebulaRunning = false;
|
|
144
|
-
clearInterval(interval);
|
|
145
190
|
};
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer, Socket } from "node:net";
|
|
2
|
+
// Track active proxies by port
|
|
3
|
+
const activeProxies = new Map();
|
|
4
|
+
const connectToLocalhost = (port, clientSocket) => {
|
|
5
|
+
const targetSocket = new Socket();
|
|
6
|
+
const tryConnect = (host, fallback) => {
|
|
7
|
+
targetSocket.connect(port, host, () => {
|
|
8
|
+
clientSocket.pipe(targetSocket);
|
|
9
|
+
targetSocket.pipe(clientSocket);
|
|
10
|
+
});
|
|
11
|
+
targetSocket.once("error", (err) => {
|
|
12
|
+
if (err.code === "ECONNREFUSED" && fallback) {
|
|
13
|
+
// Try fallback address
|
|
14
|
+
targetSocket.removeAllListeners();
|
|
15
|
+
const fallbackSocket = new Socket();
|
|
16
|
+
fallbackSocket.connect(port, fallback, () => {
|
|
17
|
+
clientSocket.pipe(fallbackSocket);
|
|
18
|
+
fallbackSocket.pipe(clientSocket);
|
|
19
|
+
});
|
|
20
|
+
fallbackSocket.on("error", () => clientSocket.destroy());
|
|
21
|
+
clientSocket.on("error", () => fallbackSocket.destroy());
|
|
22
|
+
clientSocket.on("close", () => fallbackSocket.destroy());
|
|
23
|
+
fallbackSocket.on("close", () => clientSocket.destroy());
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
clientSocket.destroy();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
tryConnect("127.0.0.1", "::1");
|
|
31
|
+
clientSocket.on("error", () => targetSocket.destroy());
|
|
32
|
+
clientSocket.on("close", () => targetSocket.destroy());
|
|
33
|
+
targetSocket.on("close", () => clientSocket.destroy());
|
|
34
|
+
};
|
|
35
|
+
export const startProxy = (port, bindIp) => {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const server = createServer((clientSocket) => {
|
|
38
|
+
connectToLocalhost(port, clientSocket);
|
|
39
|
+
});
|
|
40
|
+
server.on("error", (err) => {
|
|
41
|
+
if (err.code === "EADDRINUSE") {
|
|
42
|
+
// App already binds to this IP, no proxy needed
|
|
43
|
+
resolve(false);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Other error, log and continue without proxy
|
|
47
|
+
console.error(`Proxy error on port ${port}: ${err.message}`);
|
|
48
|
+
resolve(false);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
server.listen(port, bindIp, () => {
|
|
52
|
+
activeProxies.set(port, server);
|
|
53
|
+
resolve(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
export const stopProxy = (port) => {
|
|
58
|
+
const server = activeProxies.get(port);
|
|
59
|
+
if (server) {
|
|
60
|
+
server.close();
|
|
61
|
+
activeProxies.delete(port);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
export const stopAllProxies = () => {
|
|
65
|
+
for (const [port, server] of activeProxies) {
|
|
66
|
+
server.close();
|
|
67
|
+
activeProxies.delete(port);
|
|
68
|
+
}
|
|
69
|
+
};
|
package/dist/success.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
export const successPage = `<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>oneDeploy</title>
|
|
8
|
+
<link rel="icon" type="image/svg+xml"
|
|
9
|
+
href="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='14' cy='27' r='14' fill='%2322C55E'/%3E%3Ccircle cx='38' cy='10' r='10' fill='%234ADE80'/%3E%3Ccircle cx='40' cy='40' r='8' fill='%2316A34A'/%3E%3Cline x1='26' y1='19' x2='30' y2='14' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cline x1='26' y1='36' x2='34' y2='38' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M9 20V34L21 27L9 20Z' fill='white'/%3E%3C/svg%3E">
|
|
10
|
+
<style>
|
|
11
|
+
* {
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding: 0;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.page-container {
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
background-color: #0b0b0f;
|
|
26
|
+
color: #f3f4f6;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.navbar {
|
|
30
|
+
padding: 1rem;
|
|
31
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
32
|
+
background-color: #0e0e14;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.logo-container {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 0.75rem;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.logo-container img {
|
|
42
|
+
width: 2.5rem;
|
|
43
|
+
height: 2.5rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.logo-text {
|
|
47
|
+
font-size: 1.5rem;
|
|
48
|
+
font-weight: 700;
|
|
49
|
+
letter-spacing: -0.025em;
|
|
50
|
+
line-height: 1.2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (min-width: 768px) {
|
|
54
|
+
.logo-text {
|
|
55
|
+
font-size: 1.875rem;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.logo-one {
|
|
60
|
+
color: #4ade80;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.logo-deploy {
|
|
64
|
+
color: #f3f4f6;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.content-wrapper {
|
|
68
|
+
padding: 1rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.container {
|
|
72
|
+
min-height: 80vh;
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
padding-left: 1rem;
|
|
77
|
+
padding-right: 1rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.card {
|
|
81
|
+
width: 100%;
|
|
82
|
+
max-width: 28rem;
|
|
83
|
+
background-color: #14141c;
|
|
84
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
85
|
+
border-radius: 1rem;
|
|
86
|
+
padding: 2rem;
|
|
87
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.header {
|
|
91
|
+
text-align: center;
|
|
92
|
+
margin-bottom: 2rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.icon-container {
|
|
96
|
+
display: inline-flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: center;
|
|
99
|
+
width: 4rem;
|
|
100
|
+
height: 4rem;
|
|
101
|
+
border-radius: 1rem;
|
|
102
|
+
background-color: rgba(139, 92, 246, 0.1);
|
|
103
|
+
color: #a78bfa;
|
|
104
|
+
margin-bottom: 1.5rem;
|
|
105
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.icon-container svg {
|
|
109
|
+
width: 2rem;
|
|
110
|
+
height: 2rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
h1 {
|
|
114
|
+
font-size: 1.875rem;
|
|
115
|
+
font-weight: 700;
|
|
116
|
+
color: #f3f4f6;
|
|
117
|
+
letter-spacing: -0.025em;
|
|
118
|
+
margin: 0 0 0.5rem 0;
|
|
119
|
+
line-height: 2.25rem;
|
|
120
|
+
text-align: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.description {
|
|
124
|
+
color: #9ca3af;
|
|
125
|
+
font-size: 1rem;
|
|
126
|
+
line-height: 1.5rem;
|
|
127
|
+
text-align: center;
|
|
128
|
+
margin: 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.status-container {
|
|
132
|
+
min-height: 60px;
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.success-box {
|
|
139
|
+
width: 100%;
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
justify-content: center;
|
|
143
|
+
padding-top: 0.75rem;
|
|
144
|
+
padding-bottom: 0.75rem;
|
|
145
|
+
font-size: 1rem;
|
|
146
|
+
font-weight: 500;
|
|
147
|
+
background-color: rgba(34, 197, 94, 0.1);
|
|
148
|
+
border: 1px solid rgba(34, 197, 94, 0.2);
|
|
149
|
+
border-radius: 0.5rem;
|
|
150
|
+
color: #4ade80;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.success-box svg {
|
|
154
|
+
width: 1.25rem;
|
|
155
|
+
height: 1.25rem;
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
158
|
+
</head>
|
|
159
|
+
|
|
160
|
+
<body>
|
|
161
|
+
<div class="page-container">
|
|
162
|
+
<div class="navbar">
|
|
163
|
+
<div class="logo-container">
|
|
164
|
+
<img alt="oneDeploy Logo"
|
|
165
|
+
src="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='14' cy='27' r='14' fill='%2322C55E'/%3E%3Ccircle cx='38' cy='10' r='10' fill='%234ADE80'/%3E%3Ccircle cx='40' cy='40' r='8' fill='%2316A34A'/%3E%3Cline x1='26' y1='19' x2='30' y2='14' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cline x1='26' y1='36' x2='34' y2='38' stroke='%2386EFAC' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M9 20V34L21 27L9 20Z' fill='white'/%3E%3C/svg%3E">
|
|
166
|
+
<div class="logo-text">
|
|
167
|
+
<span class="logo-one">one</span><span class="logo-deploy">Deploy</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="content-wrapper">
|
|
172
|
+
<div class="container">
|
|
173
|
+
<div class="card">
|
|
174
|
+
<div class="header">
|
|
175
|
+
<div class="icon-container">
|
|
176
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
|
177
|
+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
178
|
+
<circle cx="12" cy="12" r="10" />
|
|
179
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
180
|
+
<path
|
|
181
|
+
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
182
|
+
</svg>
|
|
183
|
+
</div>
|
|
184
|
+
<h1>Network Access Request</h1>
|
|
185
|
+
<p class="description">oneDeploy CLI is requesting access to the oneDeploy network</p>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="status-container">
|
|
189
|
+
<div class="success-box">
|
|
190
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
|
|
191
|
+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
192
|
+
<path d="M20 6 9 17l-5-5" />
|
|
193
|
+
</svg>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</body>
|
|
201
|
+
`;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import { createTunnel, deleteAllTunnels, deleteTunnel, getActiveTunnels, validateName, validatePort, } from "./tunnel.js";
|
|
3
|
+
const MENU_CREATE = "create";
|
|
4
|
+
const MENU_DELETE = "delete";
|
|
5
|
+
const MENU_DELETE_ALL = "delete_all";
|
|
6
|
+
const MENU_DISCONNECT = "disconnect";
|
|
7
|
+
const displayActiveTunnels = () => {
|
|
8
|
+
const tunnels = getActiveTunnels();
|
|
9
|
+
console.log("\n--- Active Tunnels ---");
|
|
10
|
+
if (tunnels.length === 0) {
|
|
11
|
+
console.log(" (no active tunnels)");
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (const tunnel of tunnels) {
|
|
15
|
+
const nameDisplay = tunnel.name ? ` (${tunnel.name})` : "";
|
|
16
|
+
console.log(` https://${tunnel.hostname}${nameDisplay} → localhost:${tunnel.port}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
console.log("");
|
|
20
|
+
};
|
|
21
|
+
const createTunnelPrompt = async (instance, session) => {
|
|
22
|
+
const { port } = await prompts({
|
|
23
|
+
type: "number",
|
|
24
|
+
name: "port",
|
|
25
|
+
message: "Enter port number",
|
|
26
|
+
validate: (value) => {
|
|
27
|
+
const error = validatePort(value);
|
|
28
|
+
return error ?? true;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (port === undefined) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const { name } = await prompts({
|
|
35
|
+
type: "text",
|
|
36
|
+
name: "name",
|
|
37
|
+
message: "Enter tunnel name (optional, press Enter to skip)",
|
|
38
|
+
validate: (value) => {
|
|
39
|
+
if (!value)
|
|
40
|
+
return true;
|
|
41
|
+
const error = validateName(value);
|
|
42
|
+
return error ?? true;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (name === undefined) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await createTunnel(instance, session, port, name || undefined);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof Error) {
|
|
53
|
+
console.error(`\nFailed to create tunnel: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.error("\nFailed to create tunnel");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const deleteTunnelPrompt = async (instance, session) => {
|
|
61
|
+
const tunnels = getActiveTunnels();
|
|
62
|
+
if (tunnels.length === 0) {
|
|
63
|
+
console.log("\nNo tunnels to delete.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { tunnelId } = await prompts({
|
|
67
|
+
type: "select",
|
|
68
|
+
name: "tunnelId",
|
|
69
|
+
message: "Select tunnel to delete",
|
|
70
|
+
choices: tunnels.map((tunnel) => {
|
|
71
|
+
const nameDisplay = tunnel.name ? ` (${tunnel.name})` : "";
|
|
72
|
+
return {
|
|
73
|
+
title: `https://${tunnel.hostname}${nameDisplay} → localhost:${tunnel.port}`,
|
|
74
|
+
value: tunnel.id,
|
|
75
|
+
};
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
if (tunnelId === undefined) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const { confirm } = await prompts({
|
|
82
|
+
type: "confirm",
|
|
83
|
+
name: "confirm",
|
|
84
|
+
message: "Are you sure you want to delete this tunnel?",
|
|
85
|
+
initial: false,
|
|
86
|
+
});
|
|
87
|
+
if (confirm !== true) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await deleteTunnel(instance, session, tunnelId);
|
|
92
|
+
console.log("\nTunnel deleted.");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof Error) {
|
|
96
|
+
console.error(`\nFailed to delete tunnel: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.error("\nFailed to delete tunnel");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const deleteAllTunnelsPrompt = async (instance, session) => {
|
|
104
|
+
const tunnels = getActiveTunnels();
|
|
105
|
+
if (tunnels.length === 0) {
|
|
106
|
+
console.log("\nNo tunnels to delete.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { confirm } = await prompts({
|
|
110
|
+
type: "confirm",
|
|
111
|
+
name: "confirm",
|
|
112
|
+
message: `Are you sure you want to delete all ${tunnels.length} tunnel(s)?`,
|
|
113
|
+
initial: false,
|
|
114
|
+
});
|
|
115
|
+
if (confirm !== true) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await deleteAllTunnels(instance, session);
|
|
120
|
+
console.log("\nAll tunnels deleted.");
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof Error) {
|
|
124
|
+
console.error(`\nFailed to delete tunnels: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.error("\nFailed to delete tunnels");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
export const tunnelMenu = async (instance, session) => {
|
|
132
|
+
while (true) {
|
|
133
|
+
displayActiveTunnels();
|
|
134
|
+
const tunnels = getActiveTunnels();
|
|
135
|
+
const { action } = await prompts({
|
|
136
|
+
type: "select",
|
|
137
|
+
name: "action",
|
|
138
|
+
message: "Tunnel Management",
|
|
139
|
+
choices: [
|
|
140
|
+
{ title: "Create new tunnel", value: MENU_CREATE },
|
|
141
|
+
{
|
|
142
|
+
title: "Delete a tunnel",
|
|
143
|
+
value: MENU_DELETE,
|
|
144
|
+
disabled: tunnels.length === 0,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
title: "Delete all tunnels",
|
|
148
|
+
value: MENU_DELETE_ALL,
|
|
149
|
+
disabled: tunnels.length === 0,
|
|
150
|
+
},
|
|
151
|
+
{ title: "Disconnect from Nebula", value: MENU_DISCONNECT },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
if (action === undefined || action === MENU_DISCONNECT) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
switch (action) {
|
|
158
|
+
case MENU_CREATE:
|
|
159
|
+
await createTunnelPrompt(instance, session);
|
|
160
|
+
break;
|
|
161
|
+
case MENU_DELETE:
|
|
162
|
+
await deleteTunnelPrompt(instance, session);
|
|
163
|
+
break;
|
|
164
|
+
case MENU_DELETE_ALL:
|
|
165
|
+
await deleteAllTunnelsPrompt(instance, session);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { nebulaIp } from "./nebula.js";
|
|
2
|
+
import { startProxy, stopAllProxies, stopProxy } from "./proxy.js";
|
|
3
|
+
// In-memory list of active tunnels
|
|
4
|
+
let activeTunnels = [];
|
|
5
|
+
export const getActiveTunnels = () => {
|
|
6
|
+
return [...activeTunnels];
|
|
7
|
+
};
|
|
8
|
+
export const createTunnel = async (instance, session, port, name) => {
|
|
9
|
+
const body = { port };
|
|
10
|
+
if (name) {
|
|
11
|
+
body.name = name;
|
|
12
|
+
}
|
|
13
|
+
const response = await fetch(`${instance.url}/api/tunnels`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${session}`,
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const errorText = await response.text();
|
|
23
|
+
throw new Error(`Failed to create tunnel: ${response.status} ${errorText}`);
|
|
24
|
+
}
|
|
25
|
+
const tunnel = (await response.json());
|
|
26
|
+
tunnel.port = port; // Ensure port is set even if API doesn't return it
|
|
27
|
+
activeTunnels.push(tunnel);
|
|
28
|
+
// Try to start IPv6→IPv4 proxy on the Nebula interface
|
|
29
|
+
if (nebulaIp) {
|
|
30
|
+
await startProxy(port, nebulaIp);
|
|
31
|
+
}
|
|
32
|
+
return tunnel;
|
|
33
|
+
};
|
|
34
|
+
export const deleteTunnel = async (instance, session, id) => {
|
|
35
|
+
const response = await fetch(`${instance.url}/api/tunnels/${id}`, {
|
|
36
|
+
method: "DELETE",
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${session}`,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok && response.status !== 404) {
|
|
42
|
+
const errorText = await response.text();
|
|
43
|
+
throw new Error(`Failed to delete tunnel: ${response.status} ${errorText}`);
|
|
44
|
+
}
|
|
45
|
+
const tunnel = activeTunnels.find((t) => t.id === id);
|
|
46
|
+
if (tunnel) {
|
|
47
|
+
stopProxy(tunnel.port);
|
|
48
|
+
}
|
|
49
|
+
activeTunnels = activeTunnels.filter((t) => t.id !== id);
|
|
50
|
+
};
|
|
51
|
+
export const deleteAllTunnels = async (instance, session) => {
|
|
52
|
+
const response = await fetch(`${instance.url}/api/tunnels`, {
|
|
53
|
+
method: "DELETE",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${session}`,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok && response.status !== 404) {
|
|
59
|
+
const errorText = await response.text();
|
|
60
|
+
throw new Error(`Failed to delete all tunnels: ${response.status} ${errorText}`);
|
|
61
|
+
}
|
|
62
|
+
stopAllProxies();
|
|
63
|
+
activeTunnels = [];
|
|
64
|
+
};
|
|
65
|
+
export const validatePort = (port) => {
|
|
66
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
67
|
+
return "Port must be an integer between 1 and 65535";
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
export const validateName = (name) => {
|
|
72
|
+
if (name && !/^[a-z0-9-]+$/.test(name)) {
|
|
73
|
+
return "Name must only contain lowercase letters, numbers, and hyphens";
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onedeploy-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "CLI tool for connecting to OneDeploy via Nebula",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"onedeploy",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"fix": "biome check src/* --fix"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@biomejs/biome": "^2.3.
|
|
36
|
-
"@types/node": "^25.0
|
|
35
|
+
"@biomejs/biome": "^2.3.13",
|
|
36
|
+
"@types/node": "^25.2.0",
|
|
37
37
|
"@types/prompts": "^2.4.9",
|
|
38
|
+
"@typescript/native-preview": "^7.0.0-dev.20260203.1",
|
|
38
39
|
"typescript": "^5.9.3"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"prompts": "^2.4.2",
|
|
42
|
-
"yaml": "^2.8.2"
|
|
43
|
-
"yedra": "^0.19.0"
|
|
43
|
+
"yaml": "^2.8.2"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
3
|
import prompts from "prompts";
|
|
4
|
+
import type { Instance } from "./config.js";
|
|
3
5
|
import { loadConfig, saveConfig } from "./config.js";
|
|
4
6
|
import {
|
|
5
7
|
cleanupNebula,
|
|
6
|
-
|
|
8
|
+
getSession,
|
|
7
9
|
installNebula,
|
|
8
10
|
isNebulaRunning,
|
|
11
|
+
startNebula,
|
|
12
|
+
stopNebula,
|
|
9
13
|
} from "./nebula.js";
|
|
14
|
+
import { deleteAllTunnels } from "./tunnel.js";
|
|
15
|
+
import { tunnelMenu } from "./tunnel-menu.js";
|
|
16
|
+
|
|
17
|
+
// Track active connection for cleanup on signals
|
|
18
|
+
let activeConnection: {
|
|
19
|
+
instance: Instance;
|
|
20
|
+
session: string;
|
|
21
|
+
nebulaProcess: ChildProcess;
|
|
22
|
+
stopRefresh: () => void;
|
|
23
|
+
} | null = null;
|
|
24
|
+
|
|
25
|
+
const cleanup = async () => {
|
|
26
|
+
if (activeConnection) {
|
|
27
|
+
console.log("\nCleaning up...");
|
|
28
|
+
try {
|
|
29
|
+
await deleteAllTunnels(activeConnection.instance, activeConnection.session);
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore if already disconnected
|
|
32
|
+
}
|
|
33
|
+
activeConnection.stopRefresh();
|
|
34
|
+
stopNebula(activeConnection.nebulaProcess);
|
|
35
|
+
activeConnection = null;
|
|
36
|
+
}
|
|
37
|
+
await cleanupNebula();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleSignal = () => {
|
|
41
|
+
if (!isNebulaRunning) {
|
|
42
|
+
process.exit();
|
|
43
|
+
}
|
|
44
|
+
// Perform cleanup and exit
|
|
45
|
+
cleanup().finally(() => process.exit());
|
|
46
|
+
};
|
|
10
47
|
|
|
11
48
|
if (process.argv.includes("--version")) {
|
|
12
49
|
console.log("onedeploy-cli version 0.1.4");
|
|
@@ -15,22 +52,37 @@ if (process.argv.includes("--version")) {
|
|
|
15
52
|
|
|
16
53
|
const config = await loadConfig();
|
|
17
54
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if
|
|
55
|
+
const NEW_URL_VALUE = "__NEW_URL__";
|
|
56
|
+
|
|
57
|
+
const normalizeUrl = (url: string): string => {
|
|
58
|
+
const trimmedUrl = url.trim();
|
|
59
|
+
// Convert http:// to https://
|
|
60
|
+
if (trimmedUrl.startsWith("http://")) {
|
|
61
|
+
return trimmedUrl.replace("http://", "https://");
|
|
62
|
+
}
|
|
63
|
+
// Add https:// if no protocol specified
|
|
64
|
+
if (!trimmedUrl.startsWith("https://")) {
|
|
65
|
+
return `https://${trimmedUrl}`;
|
|
66
|
+
}
|
|
67
|
+
return trimmedUrl;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const addInstance = async (url: string) => {
|
|
71
|
+
// Check for duplicates
|
|
72
|
+
if (config.instances.some((instance) => instance.url === url)) {
|
|
27
73
|
return;
|
|
28
74
|
}
|
|
29
|
-
config.instances.push({ url
|
|
75
|
+
config.instances.push({ url });
|
|
30
76
|
await saveConfig(config);
|
|
31
77
|
};
|
|
32
78
|
|
|
79
|
+
const REMOVE_URL_VALUE = "__REMOVE_URL__";
|
|
80
|
+
|
|
33
81
|
const removeConnection = async () => {
|
|
82
|
+
if (config.instances.length === 0) {
|
|
83
|
+
console.log("No connections to remove.");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
34
86
|
const { connection } = await prompts([
|
|
35
87
|
{
|
|
36
88
|
type: "select",
|
|
@@ -60,50 +112,89 @@ const removeConnection = async () => {
|
|
|
60
112
|
};
|
|
61
113
|
|
|
62
114
|
const mainMenu = async (): Promise<boolean> => {
|
|
63
|
-
const
|
|
115
|
+
const choices = [
|
|
116
|
+
...config.instances.map((instance) => ({
|
|
117
|
+
title: instance.url,
|
|
118
|
+
value: instance.url,
|
|
119
|
+
})),
|
|
120
|
+
{
|
|
121
|
+
title: "Enter new URL",
|
|
122
|
+
value: NEW_URL_VALUE,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
title: "Remove a URL",
|
|
126
|
+
value: REMOVE_URL_VALUE,
|
|
127
|
+
disabled: config.instances.length === 0,
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const { selection } = await prompts([
|
|
64
132
|
{
|
|
65
133
|
type: "select",
|
|
66
|
-
name: "
|
|
67
|
-
message: "
|
|
68
|
-
choices
|
|
69
|
-
{
|
|
70
|
-
title: "Connect",
|
|
71
|
-
value: "connect",
|
|
72
|
-
disabled: config.instances.length === 0,
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
title: "Setup new connection",
|
|
76
|
-
value: "setup",
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
title: "Remove connection",
|
|
80
|
-
value: "remove",
|
|
81
|
-
},
|
|
82
|
-
],
|
|
134
|
+
name: "selection",
|
|
135
|
+
message: "Select oneDeploy instance",
|
|
136
|
+
choices,
|
|
83
137
|
},
|
|
84
138
|
]);
|
|
85
|
-
|
|
139
|
+
|
|
140
|
+
if (selection === undefined) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (selection === REMOVE_URL_VALUE) {
|
|
145
|
+
await removeConnection();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let urlToConnect: string;
|
|
150
|
+
|
|
151
|
+
if (selection === NEW_URL_VALUE) {
|
|
86
152
|
const result = await prompts([
|
|
87
153
|
{
|
|
88
|
-
type: "
|
|
89
|
-
name: "
|
|
90
|
-
message: "
|
|
91
|
-
choices: config.instances.map((instance) => ({
|
|
92
|
-
title: instance.url,
|
|
93
|
-
value: instance,
|
|
94
|
-
})),
|
|
154
|
+
type: "text",
|
|
155
|
+
name: "url",
|
|
156
|
+
message: "Enter oneDeploy URL",
|
|
95
157
|
},
|
|
96
158
|
]);
|
|
97
|
-
if (result.
|
|
98
|
-
|
|
159
|
+
if (result.url === undefined) {
|
|
160
|
+
return true;
|
|
99
161
|
}
|
|
100
|
-
|
|
101
|
-
await setupConnection();
|
|
102
|
-
} else if (action === "remove") {
|
|
103
|
-
await removeConnection();
|
|
162
|
+
urlToConnect = normalizeUrl(result.url);
|
|
104
163
|
} else {
|
|
105
|
-
|
|
164
|
+
urlToConnect = selection;
|
|
106
165
|
}
|
|
166
|
+
|
|
167
|
+
const instance = { url: urlToConnect, apiKey: "" };
|
|
168
|
+
|
|
169
|
+
// 1. Authenticate
|
|
170
|
+
const session = await getSession(instance);
|
|
171
|
+
|
|
172
|
+
// 2. Start Nebula (non-blocking)
|
|
173
|
+
const { nebulaProcess, stopRefresh } = await startNebula(instance, session);
|
|
174
|
+
|
|
175
|
+
// Track connection for signal handlers
|
|
176
|
+
activeConnection = { instance, session, nebulaProcess, stopRefresh };
|
|
177
|
+
|
|
178
|
+
// 3. Enter tunnel management loop
|
|
179
|
+
try {
|
|
180
|
+
await tunnelMenu(instance, session);
|
|
181
|
+
} finally {
|
|
182
|
+
// 4. Cleanup on exit
|
|
183
|
+
console.log("Cleaning up tunnels...");
|
|
184
|
+
try {
|
|
185
|
+
await deleteAllTunnels(instance, session);
|
|
186
|
+
} catch {
|
|
187
|
+
// ignore if already disconnected
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
stopRefresh();
|
|
191
|
+
stopNebula(nebulaProcess);
|
|
192
|
+
activeConnection = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Only save if connection was successful (didn't throw)
|
|
196
|
+
await addInstance(urlToConnect);
|
|
197
|
+
|
|
107
198
|
return true;
|
|
108
199
|
};
|
|
109
200
|
|
|
@@ -111,8 +202,12 @@ process.on("SIGINT", () => {
|
|
|
111
202
|
if (!isNebulaRunning) {
|
|
112
203
|
process.exit();
|
|
113
204
|
}
|
|
114
|
-
//
|
|
205
|
+
// When Nebula is running, let prompts handle SIGINT (returns undefined)
|
|
206
|
+
// which triggers the finally block in mainMenu
|
|
115
207
|
});
|
|
208
|
+
process.on("SIGTERM", handleSignal);
|
|
209
|
+
process.on("SIGHUP", handleSignal);
|
|
210
|
+
|
|
116
211
|
await installNebula();
|
|
117
212
|
try {
|
|
118
213
|
while (true) {
|
|
@@ -122,5 +217,5 @@ try {
|
|
|
122
217
|
}
|
|
123
218
|
}
|
|
124
219
|
} finally {
|
|
125
|
-
await
|
|
220
|
+
await cleanup();
|
|
126
221
|
}
|