ninedeploy 0.18.4 → 0.18.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.
@@ -0,0 +1,218 @@
1
+ import net from 'node:net';
2
+ import process from 'node:process';
3
+ import type { NineDeployClient } from '../client.js';
4
+ import { c, error, info, success } from '../lib/format.js';
5
+ import { plain } from './sources.js';
6
+
7
+ /**
8
+ * G5.8 `ninedeploy tunnel db <id> [--port <n>] [--bind <address>]`: offer a
9
+ * database (on the panel host, or on a node with agent 0.18.7) on a local port. Each local connection opens its own
10
+ * WebSocket to `/v1/databases/:id/tunnel`, which carries it byte for byte to
11
+ * the database container, so the database port stays closed to the internet.
12
+ * Needs the database's admin role; the database's own login applies on top.
13
+ */
14
+
15
+ /** Frames stay well under the server's 1 MiB WebSocket payload cap. */
16
+ const MAX_FRAME = 64 * 1024;
17
+
18
+ /** The parts of a `ws` WebSocket the tunnel uses (injectable for tests). */
19
+ export interface TunnelSocket {
20
+ readonly readyState: number;
21
+ send(data: Uint8Array, opts: { binary: true }): void;
22
+ close(code?: number): void;
23
+ on(event: 'open', cb: () => void): void;
24
+ on(event: 'message', cb: (data: Buffer, isBinary: boolean) => void): void;
25
+ on(event: 'close', cb: (code: number, reason: Buffer) => void): void;
26
+ on(event: 'error', cb: (err: Error) => void): void;
27
+ }
28
+ export type TunnelSocketFactory = (url: string, protocols: string[]) => TunnelSocket;
29
+
30
+ export interface TunnelOptions {
31
+ port?: string;
32
+ bind?: string;
33
+ }
34
+
35
+ export interface TunnelDeps {
36
+ socketFactory?: TunnelSocketFactory;
37
+ /** The bearer and the tunnel URL (defaults: the CLI config, refreshed when about to expire). */
38
+ credentials?: () => Promise<{ url: (path: string) => string; token: string }>;
39
+ /** Resolves once the tunnel is listening (tests); the command itself runs until Ctrl-C. */
40
+ onListening?: (address: { host: string; port: number }, server: net.Server) => void;
41
+ }
42
+
43
+ const OPEN = 1;
44
+
45
+ async function defaultCredentials(): Promise<{ url: (path: string) => string; token: string }> {
46
+ const { loadConfig } = await import('../config.js');
47
+ const { apiUrl, refreshAccessToken, tokenExpiresSoon } = await import('../client.js');
48
+ if (tokenExpiresSoon(loadConfig().token)) await refreshAccessToken();
49
+ return { url: (path) => apiUrl(path).replace(/^http/, 'ws'), token: loadConfig().token ?? '' };
50
+ }
51
+
52
+ async function defaultSocketFactory(): Promise<TunnelSocketFactory> {
53
+ const { WebSocket } = await import('ws');
54
+ return (url, protocols) => new WebSocket(url, protocols) as unknown as TunnelSocket;
55
+ }
56
+
57
+ /** Carry one local connection over one tunnel WebSocket. */
58
+ export function pipeConnection(local: net.Socket, ws: TunnelSocket, onRefused: (reason: string) => void): void {
59
+ const pending: Buffer[] = [];
60
+ const sendChunked = (chunk: Buffer) => {
61
+ for (let at = 0; at < chunk.length; at += MAX_FRAME) ws.send(chunk.subarray(at, at + MAX_FRAME), { binary: true });
62
+ };
63
+ local.on('data', (chunk: Buffer) => {
64
+ if (ws.readyState === OPEN) sendChunked(chunk);
65
+ else pending.push(chunk);
66
+ });
67
+ ws.on('open', () => {
68
+ for (const chunk of pending.splice(0)) sendChunked(chunk);
69
+ });
70
+ ws.on('message', (data, isBinary) => {
71
+ if (isBinary) local.write(data);
72
+ });
73
+ ws.on('close', (code, reason) => {
74
+ if (code === 1008) onRefused(String(reason ?? '') || 'refused');
75
+ else if (code !== 1000 && code !== 1005) onRefused(`closed (${code}${String(reason ?? '') ? `: ${String(reason)}` : ''})`);
76
+ local.destroy();
77
+ });
78
+ ws.on('error', () => local.destroy());
79
+ local.on('close', () => {
80
+ try {
81
+ ws.close(1000);
82
+ } catch {
83
+ /* already closed */
84
+ }
85
+ });
86
+ local.on('error', () => undefined);
87
+ }
88
+
89
+ /** Canonical decimal id only, as the server accepts it. */
90
+ function parseId(raw: string | undefined): number {
91
+ const t = (raw ?? '').trim();
92
+ return /^[1-9]\d*$/.test(t) && Number.isSafeInteger(Number(t)) ? Number(t) : 0;
93
+ }
94
+
95
+ /** Listen locally and carry each connection over its own tunnel WebSocket to `path`. Null when it cannot listen. */
96
+ async function serveTunnel(path: string, host: string, requested: number | undefined, preferred: number, deps: TunnelDeps): Promise<number | null> {
97
+ const creds = await (deps.credentials ?? defaultCredentials)();
98
+ const factory = deps.socketFactory ?? (await defaultSocketFactory());
99
+ const url = creds.url(path);
100
+ const open = () => factory(url, ['ninedeploy.tunnel.v1', `ninedeploy.bearer.${creds.token}`]);
101
+
102
+ let reported = '';
103
+ const server = net.createServer((local) => {
104
+ pipeConnection(local, open(), (reason) => {
105
+ // One line per distinct refusal: a client retrying would print it for every attempt.
106
+ if (reason === reported) return;
107
+ reported = reason;
108
+ error(`Tunnel refused: ${plain(reason)}`);
109
+ });
110
+ });
111
+
112
+ const listen = (port: number) =>
113
+ new Promise<number>((resolve, reject) => {
114
+ server.once('error', reject);
115
+ server.listen(port, host, () => {
116
+ server.off('error', reject);
117
+ resolve((server.address() as net.AddressInfo).port);
118
+ });
119
+ });
120
+ // Default: the preferred port when it is free locally, else any free one.
121
+ let port: number;
122
+ try {
123
+ port = await listen(requested ?? preferred);
124
+ } catch (err) {
125
+ if (requested !== undefined) {
126
+ error(`Cannot listen on ${host}:${requested}: ${err instanceof Error ? err.message : String(err)}`);
127
+ return null;
128
+ }
129
+ port = await listen(0);
130
+ }
131
+ tunnelServers.set(port, server);
132
+ return port;
133
+ }
134
+
135
+ const tunnelServers = new Map<number, net.Server>();
136
+
137
+ /** Hand the listening server to a test, or run until Ctrl-C. */
138
+ async function untilInterrupted(host: string, port: number, deps: TunnelDeps): Promise<void> {
139
+ const server = tunnelServers.get(port)!;
140
+ tunnelServers.delete(port);
141
+ if (host !== '127.0.0.1' && host !== '::1') console.log(` ${c.yellow(`⚠ Bound to ${host}: anyone who can reach this address can use the tunnel.`)}`);
142
+ info(c.dim('Ctrl-C closes the tunnel.'));
143
+ deps.onListening?.({ host, port }, server);
144
+ if (deps.onListening) return;
145
+ await new Promise<void>((resolve) => {
146
+ process.once('SIGINT', () => {
147
+ server.close();
148
+ resolve();
149
+ });
150
+ });
151
+ }
152
+
153
+ export async function tunnelDb(client: NineDeployClient, idRaw: string, opts: TunnelOptions = {}, deps: TunnelDeps = {}): Promise<void> {
154
+ const id = parseId(idRaw);
155
+ if (!id) return error('Usage: ninedeploy tunnel db <id> [--port <n>] [--bind <address>]');
156
+ const requested = opts.port === undefined ? undefined : Number(opts.port);
157
+ if (requested !== undefined && !(Number.isInteger(requested) && requested >= 0 && requested <= 65535)) {
158
+ return error('--port must be 0-65535 (0 picks a free one)');
159
+ }
160
+ const host = opts.bind ?? '127.0.0.1';
161
+ if (net.isIP(host) === 0) return error('--bind must be an IP address');
162
+
163
+ let detail: Awaited<ReturnType<NineDeployClient['databases']['get']>>;
164
+ try {
165
+ detail = await client.databases.get(id);
166
+ } catch (err) {
167
+ return error(plain(err instanceof Error ? err.message : String(err)));
168
+ }
169
+ const port = await serveTunnel(`/v1/databases/${id}/tunnel`, host, requested, detail.port ?? 0, deps);
170
+ if (port === null) return;
171
+
172
+ success(`Tunnel to ${plain(detail.name)} (${plain(detail.engine)}) listening on ${host}:${port}`);
173
+ info(`Connect your client to ${host}:${port} with the database's own credentials (Database page → Credentials).`);
174
+ await untilInterrupted(host, port, deps);
175
+ }
176
+
177
+ export interface ServiceTunnelOptions extends TunnelOptions {
178
+ /** The container port (default: the service port). */
179
+ target?: string;
180
+ replica?: string;
181
+ }
182
+
183
+ /**
184
+ * G5.8 `ninedeploy tunnel service <id> [--target <port>] [--replica <n>]
185
+ * [--port <n>] [--bind <address>]`: offer a port of a docker service (on
186
+ * the panel host, or on a node with agent 0.18.7) locally, for an admin port, a debugger or a service with no domain.
187
+ * Needs the service's admin role.
188
+ */
189
+ export async function tunnelService(client: NineDeployClient, idRaw: string, opts: ServiceTunnelOptions = {}, deps: TunnelDeps = {}): Promise<void> {
190
+ const id = parseId(idRaw);
191
+ if (!id) return error('Usage: ninedeploy tunnel service <id> [--target <port>] [--replica <n>] [--port <n>] [--bind <address>]');
192
+ const requested = opts.port === undefined ? undefined : Number(opts.port);
193
+ if (requested !== undefined && !(Number.isInteger(requested) && requested >= 0 && requested <= 65535)) {
194
+ return error('--port must be 0-65535 (0 picks a free one)');
195
+ }
196
+ const target = opts.target === undefined ? undefined : Number(opts.target);
197
+ if (target !== undefined && !(Number.isInteger(target) && target >= 1 && target <= 65535)) return error('--target must be a container port, 1-65535');
198
+ const replica = opts.replica === undefined ? undefined : Number(opts.replica);
199
+ if (replica !== undefined && !(Number.isInteger(replica) && replica >= 1)) return error('--replica must be 1 or more');
200
+ const host = opts.bind ?? '127.0.0.1';
201
+ if (net.isIP(host) === 0) return error('--bind must be an IP address');
202
+
203
+ let svc: Awaited<ReturnType<NineDeployClient['services']['get']>>;
204
+ try {
205
+ svc = await client.services.get(id);
206
+ } catch (err) {
207
+ return error(plain(err instanceof Error ? err.message : String(err)));
208
+ }
209
+ const containerPort = target ?? svc.port ?? undefined;
210
+ if (!containerPort) return error('The service has no port; name the container port with --target <port>.');
211
+ const query = new URLSearchParams({ port: String(containerPort), ...(replica ? { replica: String(replica) } : {}) });
212
+ const port = await serveTunnel(`/v1/services/${id}/tunnel?${query.toString()}`, host, requested, containerPort, deps);
213
+ if (port === null) return;
214
+
215
+ success(`Tunnel to ${plain(svc.name)}:${containerPort}${replica ? ` (replica ${replica})` : ''} listening on ${host}:${port}`);
216
+ info('It reaches the container directly, past what the service domains enforce.');
217
+ await untilInterrupted(host, port, deps);
218
+ }
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  terminalOpen, terminalsKill, terminalsList, terminalsShow, trafficService, trafficSettings, trafficSummary,
67
67
  type TerminalOpenOptions, type TerminalsListOptions,
68
68
  } from './commands/operations.js';
69
+ import { tunnelDb, tunnelService, type ServiceTunnelOptions, type TunnelOptions } from './commands/tunnel.js';
69
70
  import {
70
71
  communityTemplatesImport,
71
72
  communityTemplatesList,
@@ -1081,12 +1082,16 @@ providersCmd.command('set-aws')
1081
1082
  .option('--disable', 'Disable the provider (references stay literal)')
1082
1083
  .action((opts: AwsCliOptions) => secretProvidersSetAws(getClient(), opts));
1083
1084
  providersCmd.command('test <kind>')
1084
- .description('Test a provider (vault | aws), optionally reading one secret')
1085
+ .description('Test a provider (vault | aws | gcp | azure | onepassword | exec), optionally reading one secret')
1085
1086
  .option('--probe-path <path>', 'vault: also read this path (relative to the mount)')
1086
1087
  .option('--probe-secret-id <id>', 'aws: also read this secret')
1087
- .action((kind: string, opts: { probePath?: string; probeSecretId?: string }) => secretProvidersTest(getClient(), kind, opts));
1088
+ .option('--probe-parameter <name>', 'aws: also read this Parameter Store parameter')
1089
+ .option('--probe-name <name>', 'gcp, azure, onepassword, exec: also resolve this reference name')
1090
+ .action((kind: string, opts: { probePath?: string; probeSecretId?: string; probeParameter?: string; probeName?: string }) =>
1091
+ secretProvidersTest(getClient(), kind, opts),
1092
+ );
1088
1093
  providersCmd.command('delete <kind>')
1089
- .description('Remove a provider (vault | aws); its references stay literal at deploy')
1094
+ .description('Remove a provider (vault | aws | gcp | azure | onepassword | exec); its references stay literal at deploy')
1090
1095
  .alias('rm')
1091
1096
  .option('-y, --yes', 'Skip the confirmation prompt')
1092
1097
  .action((kind: string, opts: { yes?: boolean }) => secretProvidersDelete(getClient(), kind, opts));
@@ -1126,6 +1131,21 @@ terminalCmd.command('host [serverId]')
1126
1131
  .description('Root shell on the panel host or a node (off by default; asks for your password)')
1127
1132
  .action((serverId: string | undefined) => terminalOpen(getClient(), 'host', serverId));
1128
1133
  /* v8 ignore stop */
1134
+ const tunnelCmd = program.command('tunnel').description('Reach a database or a service port through the panel on a local port (G5.8)');
1135
+ /* v8 ignore start -- exercised by test/tunnel.test.ts. */
1136
+ tunnelCmd.command('db <id>')
1137
+ .description('Offer a panel-host database on a local port; its port stays closed to the internet (database admin)')
1138
+ .option('--port <n>', 'Local port (default: the engine port when free, else any free one; 0 picks one)')
1139
+ .option('--bind <address>', 'Local address to listen on (default 127.0.0.1)')
1140
+ .action((id: string, opts: TunnelOptions) => tunnelDb(getClient(), id, opts));
1141
+ tunnelCmd.command('service <id>')
1142
+ .description('Offer a port of a panel-host docker service on a local port, past its domains (service admin)')
1143
+ .option('--target <port>', 'Container port (default: the service port)')
1144
+ .option('--replica <n>', 'Replica to reach (default 1)')
1145
+ .option('--port <n>', 'Local port (default: the container port when free, else any free one; 0 picks one)')
1146
+ .option('--bind <address>', 'Local address to listen on (default 127.0.0.1)')
1147
+ .action((id: string, opts: ServiceTunnelOptions) => tunnelService(getClient(), id, opts));
1148
+ /* v8 ignore stop */
1129
1149
  const trafficCmd = program.command('traffic').description('Traffic analytics (opt-in)');
1130
1150
  /* v8 ignore start -- exercised by test/operations.test.ts. */
1131
1151
  trafficCmd.command('settings')
@@ -1387,7 +1407,7 @@ sources.command('list').description('List configured sources').action(() => sour
1387
1407
  sources.command('show <id>').description('Show one source in detail').action((id: string) => sourcesShow(getClient(), id));
1388
1408
  sources.command('add [name]')
1389
1409
  .description('Add a new source (interactive; PAT/SSH key from env or masked prompt)')
1390
- .option('--base-url <url>', 'Gitea base URL, e.g. https://git.example.com (enables the live test and repo list)')
1410
+ .option('--base-url <url>', 'Gitea or self-hosted GitLab base URL, e.g. https://git.example.com (enables the live test and repo list)')
1391
1411
  .action((name: string | undefined, opts: { baseUrl?: string }) => sourcesAdd(getClient(), name, opts));
1392
1412
  sources.command('test [id]').description('Verify that a stored source token still authenticates').action((id?: string) => sourcesTest(getClient(), id));
1393
1413
  sources.command('keygen [id]').description('Generate an ed25519 deploy-key pair on the panel and print the public key').action((id?: string) => sourcesKeygen(getClient(), id));