onedeploy-cli 0.1.10 → 0.1.14
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/dist/browser.js +1 -6
- package/dist/index.js +55 -5
- package/dist/nebula.js +46 -51
- package/dist/proxy.js +69 -0
- package/dist/tunnel-menu.js +169 -0
- package/dist/tunnel.js +76 -0
- package/package.json +4 -3
- package/src/index.ts +70 -5
- package/dist/success.js +0 -201
package/dist/browser.js
CHANGED
|
@@ -9,12 +9,7 @@ export const openBrowser = async (url) => {
|
|
|
9
9
|
case "linux": {
|
|
10
10
|
if (isRoot && sudoUser) {
|
|
11
11
|
// Run as the original user to avoid browser sandbox issues
|
|
12
|
-
await promisify(execFile)("sudo", [
|
|
13
|
-
"-u",
|
|
14
|
-
sudoUser,
|
|
15
|
-
"xdg-open",
|
|
16
|
-
url,
|
|
17
|
-
]);
|
|
12
|
+
await promisify(execFile)("sudo", ["-u", sudoUser, "xdg-open", url]);
|
|
18
13
|
}
|
|
19
14
|
else {
|
|
20
15
|
await promisify(execFile)("xdg-open", [url]);
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
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();
|
|
@@ -109,9 +135,30 @@ const mainMenu = async () => {
|
|
|
109
135
|
else {
|
|
110
136
|
urlToConnect = selection;
|
|
111
137
|
}
|
|
112
|
-
// Connect first
|
|
113
138
|
const instance = { url: urlToConnect, apiKey: "" };
|
|
114
|
-
|
|
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);
|
|
148
|
+
}
|
|
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;
|
|
161
|
+
}
|
|
115
162
|
// Only save if connection was successful (didn't throw)
|
|
116
163
|
await addInstance(urlToConnect);
|
|
117
164
|
return true;
|
|
@@ -120,8 +167,11 @@ process.on("SIGINT", () => {
|
|
|
120
167
|
if (!isNebulaRunning) {
|
|
121
168
|
process.exit();
|
|
122
169
|
}
|
|
123
|
-
//
|
|
170
|
+
// When Nebula is running, let prompts handle SIGINT (returns undefined)
|
|
171
|
+
// which triggers the finally block in mainMenu
|
|
124
172
|
});
|
|
173
|
+
process.on("SIGTERM", handleSignal);
|
|
174
|
+
process.on("SIGHUP", handleSignal);
|
|
125
175
|
await installNebula();
|
|
126
176
|
try {
|
|
127
177
|
while (true) {
|
|
@@ -132,5 +182,5 @@ try {
|
|
|
132
182
|
}
|
|
133
183
|
}
|
|
134
184
|
finally {
|
|
135
|
-
await
|
|
185
|
+
await cleanup();
|
|
136
186
|
}
|
package/dist/nebula.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { access, chmod, mkdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { Readable } from "node:stream";
|
|
@@ -9,7 +8,6 @@ import { promisify } from "node:util";
|
|
|
9
8
|
import { parse } from "yaml";
|
|
10
9
|
import { openBrowser } from "./browser.js";
|
|
11
10
|
import { configDir } from "./config.js";
|
|
12
|
-
import { successPage } from "./success.js";
|
|
13
11
|
const getNebulaPackageName = () => {
|
|
14
12
|
switch (process.platform) {
|
|
15
13
|
case "linux":
|
|
@@ -107,6 +105,10 @@ export const getNebulaCert = async (instance, session) => {
|
|
|
107
105
|
`${tmpDir}/nebula-debug.crt`,
|
|
108
106
|
]);
|
|
109
107
|
const cert = JSON.parse(stdout);
|
|
108
|
+
// Extract Nebula IP from certificate (remove CIDR suffix)
|
|
109
|
+
if (cert.details.networks && cert.details.networks.length > 0) {
|
|
110
|
+
nebulaIp = cert.details.networks[0].split("/")[0];
|
|
111
|
+
}
|
|
110
112
|
// in case our local time isn't correct, wait for the certificate to become valid
|
|
111
113
|
const validFromDate = new Date(cert.details.notBefore);
|
|
112
114
|
if (validFromDate.toString() === "Invalid Date") {
|
|
@@ -119,63 +121,56 @@ export const getNebulaCert = async (instance, session) => {
|
|
|
119
121
|
}
|
|
120
122
|
};
|
|
121
123
|
export let isNebulaRunning = false;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
return `http://localhost:${addr.port}`;
|
|
131
|
-
};
|
|
132
|
-
const server = createServer((req, res) => {
|
|
133
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
134
|
-
if (url.pathname === "/authenticated") {
|
|
135
|
-
const token = url.searchParams.get("token");
|
|
136
|
-
if (token === null) {
|
|
137
|
-
throw new Error("Missing token in callback URL");
|
|
138
|
-
}
|
|
139
|
-
// authentication complete
|
|
140
|
-
res.writeHead(200, "OK", {
|
|
141
|
-
"content-type": "text/html",
|
|
142
|
-
});
|
|
143
|
-
res.write(successPage);
|
|
144
|
-
res.end();
|
|
145
|
-
server.close();
|
|
146
|
-
resolve(token);
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
const target = `${getServerAddr()}/authenticated`;
|
|
150
|
-
res.writeHead(302, {
|
|
151
|
-
location: `${instance.url}/auth/nebula?redirect=${encodeURIComponent(target)}`,
|
|
152
|
-
});
|
|
153
|
-
res.end();
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
server.on("listening", async () => {
|
|
157
|
-
const addr = server.address();
|
|
158
|
-
if (addr === null || typeof addr === "string") {
|
|
159
|
-
throw new Error("Unexpected address type");
|
|
160
|
-
}
|
|
161
|
-
const authUrl = getServerAddr();
|
|
162
|
-
console.log(`Visit ${authUrl} to authenticate (opened in browser)`);
|
|
163
|
-
await openBrowser(authUrl);
|
|
164
|
-
});
|
|
165
|
-
server.listen();
|
|
124
|
+
export let nebulaIp = null;
|
|
125
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
|
+
export const getSession = async (instance) => {
|
|
127
|
+
const createRes = await fetch(`${instance.url}/api/auth/nebula/device`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
body: "{}",
|
|
166
131
|
});
|
|
132
|
+
if (!createRes.ok) {
|
|
133
|
+
throw new Error(`Failed to create device code: ${createRes.status}`);
|
|
134
|
+
}
|
|
135
|
+
const { code, verificationUrl } = (await createRes.json());
|
|
136
|
+
console.log(`Visit ${verificationUrl} to authenticate`);
|
|
137
|
+
await openBrowser(verificationUrl);
|
|
138
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
await sleep(2000);
|
|
141
|
+
const pollRes = await fetch(`${instance.url}/api/auth/nebula/device/${code}`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: "{}",
|
|
145
|
+
});
|
|
146
|
+
if (pollRes.status === 404) {
|
|
147
|
+
throw new Error("Device code expired or not found.");
|
|
148
|
+
}
|
|
149
|
+
if (!pollRes.ok) {
|
|
150
|
+
throw new Error(`Poll failed: ${pollRes.status}`);
|
|
151
|
+
}
|
|
152
|
+
const result = (await pollRes.json());
|
|
153
|
+
if (result.status === "complete") {
|
|
154
|
+
return result.token;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw new Error("Authentication timed out after 10 minutes.");
|
|
167
158
|
};
|
|
168
|
-
export const
|
|
169
|
-
const session = await getSession(instance);
|
|
159
|
+
export const startNebula = async (instance, session) => {
|
|
170
160
|
await getNebulaCert(instance, session);
|
|
171
161
|
isNebulaRunning = true;
|
|
172
|
-
const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "
|
|
162
|
+
const nebula = spawn(`${nebulaDir}/nebula`, ["-config", `${tmpDir}/nebula.yml`], { stdio: "ignore" });
|
|
173
163
|
const interval = setInterval(async () => {
|
|
174
164
|
// refresh Nebula cert every 10 minutes (lifetime is 15 minutes)
|
|
175
165
|
await getNebulaCert(instance, session);
|
|
176
166
|
nebula.kill("SIGHUP");
|
|
177
167
|
}, 10 * 60 * 1000);
|
|
178
|
-
|
|
168
|
+
return {
|
|
169
|
+
nebulaProcess: nebula,
|
|
170
|
+
stopRefresh: () => clearInterval(interval),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
export const stopNebula = (nebulaProcess) => {
|
|
174
|
+
nebulaProcess.kill("SIGTERM");
|
|
179
175
|
isNebulaRunning = false;
|
|
180
|
-
clearInterval(interval);
|
|
181
176
|
};
|
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
|
+
};
|
|
@@ -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.14",
|
|
4
4
|
"description": "CLI tool for connecting to OneDeploy via Nebula",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"onedeploy",
|
|
@@ -32,9 +32,10 @@
|
|
|
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": {
|
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");
|
|
@@ -127,9 +164,33 @@ const mainMenu = async (): Promise<boolean> => {
|
|
|
127
164
|
urlToConnect = selection;
|
|
128
165
|
}
|
|
129
166
|
|
|
130
|
-
// Connect first
|
|
131
167
|
const instance = { url: urlToConnect, apiKey: "" };
|
|
132
|
-
|
|
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
|
+
}
|
|
133
194
|
|
|
134
195
|
// Only save if connection was successful (didn't throw)
|
|
135
196
|
await addInstance(urlToConnect);
|
|
@@ -141,8 +202,12 @@ process.on("SIGINT", () => {
|
|
|
141
202
|
if (!isNebulaRunning) {
|
|
142
203
|
process.exit();
|
|
143
204
|
}
|
|
144
|
-
//
|
|
205
|
+
// When Nebula is running, let prompts handle SIGINT (returns undefined)
|
|
206
|
+
// which triggers the finally block in mainMenu
|
|
145
207
|
});
|
|
208
|
+
process.on("SIGTERM", handleSignal);
|
|
209
|
+
process.on("SIGHUP", handleSignal);
|
|
210
|
+
|
|
146
211
|
await installNebula();
|
|
147
212
|
try {
|
|
148
213
|
while (true) {
|
|
@@ -152,5 +217,5 @@ try {
|
|
|
152
217
|
}
|
|
153
218
|
}
|
|
154
219
|
} finally {
|
|
155
|
-
await
|
|
220
|
+
await cleanup();
|
|
156
221
|
}
|
package/dist/success.js
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
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
|
-
`;
|