localhostly 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Iuri Bertto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # localhostly
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Expose any local port on a public HTTPS URL — behind NAT, without opening ports.
4
+
5
+ ```sh
6
+ npm i -g localhostly
7
+ localhostly login <token>
8
+ localhostly 3000
9
+ # → https://brave-amber-otter.localhostly.iblabs.io → http://127.0.0.1:3000
10
+ ```
11
+
12
+ Requires Node.js 24 or newer. Works on Linux, macOS and Windows.
13
+
14
+ ## Get a token
15
+
16
+ Create a free account with your e-mail (no confirmation needed):
17
+
18
+ ```sh
19
+ curl -X POST -d '{"email":"you@example.com"}' https://localhostly.iblabs.io/api/signup
20
+ # {"token":"lh_..."}
21
+ ```
22
+
23
+ Then save it on this computer (once):
24
+
25
+ ```sh
26
+ localhostly login lh_...
27
+ ```
28
+
29
+ The token is kept in `~/.localhostly/config.json`, readable only by you (`$LOCALHOSTLY_HOME` moves the folder).
30
+
31
+ ## Run
32
+
33
+ ```sh
34
+ localhostly 3000 # expose http://127.0.0.1:3000
35
+ localhostly http 3000 --password # visitors must type a password (a random one is shown)
36
+ localhostly http 3000 --password s3cret # ...or your own
37
+ localhostly http 8080 --host myapp.test # forward to another local address; the app receives it as Host
38
+ localhostly http 3000 --allow 203.0.113.7,10.0.0.0/8 # only these IP addresses or ranges get in; others see 403
39
+ ```
40
+
41
+ The terminal shows the public URL and every request live. HTTP, streaming responses and WebSockets all go through.
42
+ Stop with Ctrl+C.
43
+
44
+ The first time a browser opens your URL it sees a short warning page (it protects against phishing); API clients,
45
+ webhooks and `curl` never see it, and a browser can skip it by sending the header `X-Localhostly-Skip-Warning`.
46
+
47
+ With `--allow` (IPv4 or IPv6 addresses and CIDR ranges, comma-separated or repeated, up to 64) only those
48
+ addresses reach your app, without the warning page; every other address gets a 403 page that shows it, WebSockets
49
+ included. `--allow` and `--password` together require both. `localhostly service install` takes `--allow` too.
50
+
51
+ ## Keep it always on
52
+
53
+ ```sh
54
+ localhostly service install 3000 # a system service: starts with the computer, reconnects, keeps the same name
55
+ localhostly service status
56
+ localhostly service uninstall
57
+ ```
58
+
59
+ It uses systemd (user service) on Linux, launchd on macOS and a Windows service on Windows. Install the command
60
+ with `npm i -g` first — a copy run by `npx` lives in a temporary folder.
61
+
62
+ ## Free plan limits
63
+
64
+ 2 tunnels at a time, 1000 requests per minute and 25 GiB per month per account.
65
+
66
+ ## All options
67
+
68
+ ```sh
69
+ localhostly --help
70
+ localhostly --version
71
+ ```
72
+
73
+ ## Links
74
+
75
+ - Website: https://localhostly.iblabs.io
76
+
77
+ ## License
78
+
79
+ MIT
package/agente/cli.js ADDED
@@ -0,0 +1,370 @@
1
+ // A linha de comando: `localhostly <porta>`, `localhostly http <porta>`, `localhostly login <token>` e
2
+ // `localhostly service install|status|uninstall`.
3
+ import { randomInt } from 'node:crypto';
4
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { ErroLista, normalizarLista, textoDaLista } from '../lib/permitidos.js';
8
+ import * as servico from './servico/index.js';
9
+ import { criarLog, criarTela } from './tela.js';
10
+ import { SERVIDOR_PADRAO, Tunel } from './tunel.js';
11
+
12
+ const VERSAO = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
+
14
+ const AJUDA = `localhostly ${VERSAO} — expose a local port on a public HTTPS URL
15
+
16
+ Usage:
17
+ localhostly <port> expose http://127.0.0.1:<port>
18
+ localhostly http <port> [options] the same, with options
19
+ localhostly login <token> save your token on this computer
20
+
21
+ localhostly service install <port> [options]
22
+ keep the tunnel always on as a system service (systemd on Linux,
23
+ launchd on macOS, a Windows service): starts with the computer,
24
+ reconnects by itself and keeps the same name
25
+ localhostly service status is the service running, and its URL
26
+ localhostly service uninstall stop and remove the service
27
+
28
+ Options:
29
+ --host <host[:port]> local address to forward to; the app also receives it as Host
30
+ --server <url> tunnel server (default ${SERVIDOR_PADRAO}, or $LOCALHOSTLY_SERVER)
31
+ --password [value] ask visitors for a password (the browser shows a login box; any user name);
32
+ without a value, a random one is created and shown here
33
+ --password-file <file> the same, with the password read from a file
34
+ --allow <ip[,ip|cidr]> only these IP addresses or ranges can reach the tunnel; everyone else gets 403
35
+ (comma-separated, e.g. 203.0.113.7,10.0.0.0/8; use more than once to add)
36
+ --service service mode: one log line per event, no live table, never gives up
37
+ --home <dir> where the token and the service state live (default ~/.localhostly,
38
+ or $LOCALHOSTLY_HOME)
39
+ -h, --help show this help
40
+ -v, --version show the version
41
+ `;
42
+
43
+ export function pastaConfig(env = process.env) {
44
+ return env.LOCALHOSTLY_HOME || join(homedir(), '.localhostly');
45
+ }
46
+
47
+ const COMANDOS = ['http', 'login', 'service'];
48
+
49
+ export function lerConfig(env = process.env) {
50
+ try {
51
+ return JSON.parse(readFileSync(join(pastaConfig(env), 'config.json'), 'utf8'));
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ export function gravarToken(token, env = process.env) {
58
+ const pasta = pastaConfig(env);
59
+ mkdirSync(pasta, { recursive: true, mode: 0o700 });
60
+ const arquivo = join(pasta, 'config.json');
61
+ const config = { ...lerConfig(env), token };
62
+ writeFileSync(arquivo, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
63
+ chmodSync(arquivo, 0o600);
64
+ return arquivo;
65
+ }
66
+
67
+ // Sem caracteres que se confundem (0/O, 1/l/I).
68
+ const ALFABETO_SENHA = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
69
+
70
+ export function gerarSenha(tamanho = 12) {
71
+ let senha = '';
72
+ for (let i = 0; i < tamanho; i++) senha += ALFABETO_SENHA[randomInt(ALFABETO_SENHA.length)];
73
+ return senha;
74
+ }
75
+
76
+ function erroDeUso(mensagem) {
77
+ const erro = new Error(mensagem);
78
+ erro.uso = true;
79
+ return erro;
80
+ }
81
+
82
+ function porta(texto) {
83
+ const n = Number(texto);
84
+ if (!/^\d+$/.test(String(texto)) || n < 1 || n > 65535) throw erroDeUso(`invalid port: ${texto}`);
85
+ return n;
86
+ }
87
+
88
+ // "--host 127.0.0.1:4777", "--host app.test", "--host [::1]:3000"
89
+ export function lerAlvo(texto, portaPadrao) {
90
+ const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(texto) ?? /^([^:]+)(?::(\d+))?$/.exec(texto);
91
+ if (!m) throw erroDeUso(`invalid --host: ${texto}`);
92
+ return { host: m[1], porta: m[2] ? porta(m[2]) : portaPadrao };
93
+ }
94
+
95
+ export function lerArgumentos(argv) {
96
+ const allow = [];
97
+ const opcoes = {
98
+ comando: null,
99
+ posicionais: [],
100
+ host: null,
101
+ servidor: null,
102
+ senha: false,
103
+ arquivoSenha: null,
104
+ allow: null, // a lista normalizada (textos), ou null sem --allow
105
+ servico: false,
106
+ home: null,
107
+ };
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const a = argv[i];
110
+ const [chave, embutido] = a.startsWith('--') && a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
111
+ const valorDe = () => {
112
+ if (embutido !== undefined) return embutido;
113
+ if (i + 1 >= argv.length) throw erroDeUso(`${chave} needs a value`);
114
+ return argv[++i];
115
+ };
116
+ // "--password" sozinho (true) ou com valor: "--password=x", ou "--password x" quando x não é opção
117
+ // nem a porta que ainda falta ("localhostly --password 4777" é a porta).
118
+ const valorDaSenha = () => {
119
+ if (embutido !== undefined) {
120
+ if (!embutido) throw erroDeUso('--password= needs a value');
121
+ return embutido;
122
+ }
123
+ const proximo = argv[i + 1];
124
+ if (proximo === undefined || proximo.startsWith('-')) return true;
125
+ const semPorta = !opcoes.posicionais.some((p) => /^\d+$/.test(p));
126
+ if (semPorta && /^\d+$/.test(proximo)) return true;
127
+ i++;
128
+ return proximo;
129
+ };
130
+ if (chave === '-h' || chave === '--help') opcoes.comando = 'ajuda';
131
+ else if (chave === '-v' || chave === '--version') opcoes.comando ??= 'versao';
132
+ else if (chave === '--host') opcoes.host = valorDe();
133
+ else if (chave === '--server') opcoes.servidor = valorDe();
134
+ else if (chave === '--password') opcoes.senha = valorDaSenha();
135
+ else if (chave === '--password-file') opcoes.arquivoSenha = valorDe();
136
+ else if (chave === '--allow') {
137
+ const texto = valorDe();
138
+ if (!texto.trim()) throw erroDeUso(`${chave} needs a value: an IP address or range, e.g. --allow 203.0.113.7`);
139
+ allow.push(texto);
140
+ }
141
+ else if (chave === '--service') opcoes.servico = true;
142
+ else if (chave === '--home') opcoes.home = valorDe();
143
+ else if (chave.startsWith('-')) throw erroDeUso(`unknown option: ${chave}`);
144
+ else opcoes.posicionais.push(a);
145
+ }
146
+ if (allow.length) {
147
+ try {
148
+ opcoes.allow = textoDaLista(normalizarLista(allow));
149
+ } catch (erro) {
150
+ if (!(erro instanceof ErroLista)) throw erro;
151
+ throw erroDeUso(`invalid --allow: ${erro.message} (use IP addresses or ranges, e.g. --allow 203.0.113.7,10.0.0.0/8)`);
152
+ }
153
+ if (!opcoes.allow.length) throw erroDeUso('--allow needs at least one IP address or range');
154
+ }
155
+ if (!opcoes.comando) {
156
+ const [primeiro] = opcoes.posicionais;
157
+ if (COMANDOS.includes(primeiro)) opcoes.comando = opcoes.posicionais.shift();
158
+ else if (primeiro === undefined) opcoes.comando = 'ajuda';
159
+ else opcoes.comando = 'http';
160
+ }
161
+ return opcoes;
162
+ }
163
+
164
+ function lerArquivoSenha(arquivo) {
165
+ let texto;
166
+ try {
167
+ texto = readFileSync(arquivo, 'utf8');
168
+ } catch (erro) {
169
+ throw erroDeUso(`cannot read --password-file ${arquivo}: ${erro.code ?? erro.message}`);
170
+ }
171
+ const senha = texto.replace(/\r?\n$/, '');
172
+ if (!senha) throw erroDeUso(`--password-file ${arquivo} is empty`);
173
+ return senha;
174
+ }
175
+
176
+ // A senha pedida: { senha (o texto ou null), gerada (só a tela mostra a gerada) }.
177
+ function senhaDe(opcoes) {
178
+ if (opcoes.arquivoSenha && opcoes.senha) throw erroDeUso('use --password or --password-file, not both');
179
+ if (opcoes.arquivoSenha) return { senha: lerArquivoSenha(opcoes.arquivoSenha), gerada: false };
180
+ if (opcoes.senha === true) return { senha: gerarSenha(), gerada: true };
181
+ return { senha: opcoes.senha || null, gerada: false };
182
+ }
183
+
184
+ function alvoDe(opcoes, uso) {
185
+ if (opcoes.posicionais.length !== 1) throw erroDeUso(uso);
186
+ const p = porta(opcoes.posicionais[0]);
187
+ return { porta: p, alvo: opcoes.host ? lerAlvo(opcoes.host, p) : { host: '127.0.0.1', porta: p } };
188
+ }
189
+
190
+ const USO_SERVICO =
191
+ 'usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--allow ip[,ip|cidr]] [--server url]\n' +
192
+ ' localhostly service status\n' +
193
+ ' localhostly service uninstall';
194
+
195
+ // `localhostly service ...`. rc: 0 ok (status: rodando), 1 falha (status: instalado e parado), 2 uso, 3 não instalado.
196
+ export async function comandoServico(
197
+ opcoes,
198
+ { env = process.env, saida = process.stdout, erros = process.stderr, api = servico, plataforma = process.platform, esperarUrlMs = 15_000 } = {},
199
+ ) {
200
+ const [sub, ...resto] = opcoes.posicionais;
201
+ const home = resolve(pastaConfig(env));
202
+ const nome = api.nomeDoSistema(plataforma);
203
+ try {
204
+ if (sub === 'install') {
205
+ let porta;
206
+ let senha;
207
+ let gerada;
208
+ try {
209
+ ({ porta } = alvoDe({ ...opcoes, posicionais: resto }, USO_SERVICO));
210
+ if (opcoes.host) lerAlvo(opcoes.host, porta);
211
+ ({ senha, gerada } = senhaDe(opcoes));
212
+ } catch (erro) {
213
+ if (!erro.uso) throw erro;
214
+ erros.write(`${erro.message}\n`);
215
+ return 2;
216
+ }
217
+ const config = lerConfig(env);
218
+ if (!config.token) {
219
+ erros.write(
220
+ 'no token saved: create an account at https://localhostly.iblabs.io and run `localhostly login <token>` first\n',
221
+ );
222
+ return 1;
223
+ }
224
+ const servidor = opcoes.servidor || env.LOCALHOSTLY_SERVER || config.server || null;
225
+ const antes = Date.now();
226
+ const r = api.instalar({ porta, host: opcoes.host, servidor, senha, allow: opcoes.allow, home }, { plataforma, env });
227
+ saida.write(`Installed the localhostly ${nome}: ${r.arquivo}\n`);
228
+ saida.write('It starts with the computer, reconnects by itself and keeps the same name.\n');
229
+ if (gerada) saida.write(`Password: ${senha} (any user name)\n`);
230
+ else if (senha) saida.write('Password protected (any user name).\n');
231
+ if (opcoes.allow) saida.write(`Allowed: ${opcoes.allow.join(', ')} (other addresses get 403)\n`);
232
+ const fim = Date.now() + esperarUrlMs;
233
+ let tunel = null;
234
+ while (Date.now() < fim) {
235
+ const e = api.lerEstado(home);
236
+ if (e?.url && Date.parse(e.em) >= antes - 1000) {
237
+ tunel = e;
238
+ break;
239
+ }
240
+ await new Promise((ok) => setTimeout(ok, 200));
241
+ }
242
+ saida.write(tunel ? `URL: ${tunel.url}\n` : 'Run `localhostly service status` in a moment to see the URL.\n');
243
+ saida.write(`Logs: ${r.log}\n`);
244
+ for (const aviso of r.avisos ?? []) saida.write(`\n${aviso}\n`);
245
+ return 0;
246
+ }
247
+ if (sub === 'status' && resto.length === 0) {
248
+ const r = api.status({ plataforma, env, home });
249
+ if (!r.instalado) {
250
+ saida.write('The localhostly service is not installed. Install it with `localhostly service install <port>`.\n');
251
+ return 3;
252
+ }
253
+ saida.write(`localhostly ${nome}: ${r.estado}${r.pid ? ` (pid ${r.pid})` : ''}\n`);
254
+ saida.write(`Starts with the computer: ${r.sobeSozinho ? 'yes' : 'no'}\n`);
255
+ if (r.tunel?.url) saida.write(`URL: ${r.tunel.url} (since ${r.tunel.em})\n`);
256
+ if (r.log) saida.write(`Logs: ${r.log}\n`);
257
+ return r.rodando ? 0 : 1;
258
+ }
259
+ if (sub === 'uninstall' && resto.length === 0) {
260
+ const r = api.remover({ plataforma, env, home });
261
+ saida.write(r.removido ? `Removed the localhostly ${nome}.\n` : 'The localhostly service was not installed.\n');
262
+ return 0;
263
+ }
264
+ } catch (erro) {
265
+ if (!(erro instanceof api.ErroServico)) throw erro;
266
+ erros.write(`${erro.message}\n`);
267
+ return 1;
268
+ }
269
+ erros.write(`${USO_SERVICO}\n`);
270
+ return 2;
271
+ }
272
+
273
+ export async function principal(argv = process.argv.slice(2), { env = process.env, saida = process.stdout } = {}) {
274
+ let opcoes;
275
+ try {
276
+ opcoes = lerArgumentos(argv);
277
+ } catch (erro) {
278
+ process.stderr.write(`${erro.message}\n\n${AJUDA}`);
279
+ return 2;
280
+ }
281
+ if (opcoes.home) env = { ...env, LOCALHOSTLY_HOME: resolve(opcoes.home) };
282
+ if (opcoes.comando === 'ajuda') {
283
+ saida.write(AJUDA);
284
+ return 0;
285
+ }
286
+ if (opcoes.comando === 'versao') {
287
+ saida.write(`${VERSAO}\n`);
288
+ return 0;
289
+ }
290
+ if (opcoes.comando === 'login') {
291
+ const [token] = opcoes.posicionais;
292
+ if (!token) {
293
+ process.stderr.write('usage: localhostly login <token>\n');
294
+ return 2;
295
+ }
296
+ const arquivo = gravarToken(token, env);
297
+ saida.write(`Token saved to ${arquivo}\n`);
298
+ return 0;
299
+ }
300
+ if (opcoes.comando === 'service') return comandoServico(opcoes, { env, saida });
301
+
302
+ let alvo;
303
+ let senha;
304
+ let gerada;
305
+ try {
306
+ ({ alvo } = alvoDe(opcoes, 'usage: localhostly [http] <port>'));
307
+ ({ senha, gerada } = senhaDe(opcoes));
308
+ } catch (erro) {
309
+ process.stderr.write(`${erro.message}\n\n${AJUDA}`);
310
+ return 2;
311
+ }
312
+
313
+ // O modo serviço: uma linha por evento, o último nome guardado e pedido de novo, e nunca desiste.
314
+ const modoServico = opcoes.servico;
315
+ const home = resolve(pastaConfig(env));
316
+ const config = lerConfig(env);
317
+ const servidor = opcoes.servidor || env.LOCALHOSTLY_SERVER || config.server || SERVIDOR_PADRAO;
318
+ const tela = modoServico ? criarLog(saida) : criarTela(saida);
319
+ if (!config.token) {
320
+ tela.aviso('no token saved: create an account at https://localhostly.iblabs.io and run `localhostly login <token>`');
321
+ }
322
+
323
+ const tunel = new Tunel({
324
+ servidor,
325
+ token: config.token ?? null,
326
+ alvo,
327
+ hostCabecalho: opcoes.host,
328
+ senha,
329
+ allow: opcoes.allow,
330
+ sessao: modoServico ? servico.lerEstado(home) : null,
331
+ insistir: modoServico,
332
+ });
333
+ if (modoServico) tela.conectando(servidor);
334
+ tunel.on('pronto', ({ nome, url, tokenSessao, retomado }) => {
335
+ if (modoServico && tokenSessao) {
336
+ try {
337
+ servico.gravarEstado(home, { nome, tokenSessao, url });
338
+ } catch (erro) {
339
+ tela.aviso(`could not save the tunnel state in ${home}: ${erro.code ?? erro.message}`);
340
+ }
341
+ }
342
+ tela.pronto({ url, alvo: tunel.enderecoLocal, senha: senha && (gerada ? senha : true), retomado, permitidos: opcoes.allow });
343
+ });
344
+ tunel.on('nome_trocado', ({ de, para }) =>
345
+ tela.aviso(`name changed: ${de} -> ${para} (the old name was released while the tunnel was offline)`),
346
+ );
347
+ tunel.on('requisicao', (r) => tela.requisicao(r));
348
+ tunel.on('caiu', () => tela.aviso('connection to the server lost; the URL is offline'));
349
+ tunel.on('reconectando', ({ emMs }) => tela.aviso(`reconnecting in ${(emMs / 1000).toFixed(1)}s…`));
350
+ tunel.on('aviso', (erro) => tela.aviso(`server: ${erro.code ?? erro.message}`));
351
+
352
+ return await new Promise((resolver) => {
353
+ tunel.on('recusado', (erro) => {
354
+ if (modoServico) {
355
+ tela.erro(`server refused the tunnel: ${erro.message}; trying again`);
356
+ return;
357
+ }
358
+ tela.erro(`server refused the tunnel: ${erro.message}`);
359
+ tunel.fechar();
360
+ resolver(1);
361
+ });
362
+ const sair = () => {
363
+ tunel.fechar();
364
+ resolver(0);
365
+ };
366
+ process.once('SIGINT', sair);
367
+ process.once('SIGTERM', sair);
368
+ tunel.abrir();
369
+ });
370
+ }
@@ -0,0 +1,41 @@
1
+ // O que os três sistemas dividem: o comando do agente em modo serviço e o executor dos comandos do sistema.
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ export const NOME = 'localhostly';
5
+
6
+ // spawnSync sem lançar: { rc, saida, erro } (rc -1 quando o programa nem existe).
7
+ export function executar(programa, args = [], opcoes = {}) {
8
+ const r = spawnSync(programa, args, { encoding: 'utf8', windowsHide: true, ...opcoes });
9
+ return {
10
+ rc: r.error ? -1 : r.status ?? -1,
11
+ saida: r.stdout ?? '',
12
+ erro: r.error ? r.error.message : r.stderr ?? '',
13
+ };
14
+ }
15
+
16
+ // O argv do agente que o serviço roda: [node, bin/localhostly.js, 'http', porta, '--service', ...]. A senha nunca vai
17
+ // na linha de comando (o `ps` a mostraria a qualquer usuário): só o caminho do arquivo 0600 que a guarda. A lista
18
+ // do --allow (não é segredo) vai como "--allow a,b".
19
+ export function comandoDoAgente({ exe, porta, host = null, servidor = null, home, arquivoSenha = null, allow = null }) {
20
+ if (!Array.isArray(exe) || exe.length === 0) throw new Error('exe: the agent command is missing');
21
+ if (!Number.isInteger(porta) || porta < 1 || porta > 65535) throw new Error(`invalid port: ${porta}`);
22
+ const args = [...exe, 'http', String(porta), '--service', '--home', home];
23
+ if (host) args.push('--host', host);
24
+ if (servidor) args.push('--server', servidor);
25
+ if (arquivoSenha) args.push('--password-file', arquivoSenha);
26
+ if (allow?.length) args.push('--allow', allow.join(','));
27
+ return args;
28
+ }
29
+
30
+ export class ErroServico extends Error {
31
+ constructor(mensagem, detalhe = '') {
32
+ super(detalhe ? `${mensagem}\n${detalhe.trim()}` : mensagem);
33
+ }
34
+ }
35
+
36
+ // Roda e lança com a saída do comando quando ele falha.
37
+ export function exigir(exec, programa, args, mensagem) {
38
+ const r = exec(programa, args);
39
+ if (r.rc !== 0) throw new ErroServico(`${mensagem} (${[programa, ...args].join(' ')}: exit ${r.rc})`, r.erro || r.saida);
40
+ return r;
41
+ }
@@ -0,0 +1,100 @@
1
+ // `localhostly service install|status|uninstall`: escolhe o sistema por process.platform e prepara o que os três
2
+ // dividem (o comando do agente, o arquivo da senha, o estado do último túnel).
3
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { comandoDoAgente, ErroServico, executar } from './comum.js';
8
+ import * as launchd from './launchd.js';
9
+ import * as systemd from './systemd.js';
10
+ import * as windows from './windows.js';
11
+
12
+ export { ErroServico };
13
+
14
+ const SISTEMAS = { linux: systemd, darwin: launchd, win32: windows };
15
+ const NOMES = { linux: 'systemd user service', darwin: 'launchd LaunchAgent', win32: 'Windows service' };
16
+
17
+ export function sistema(plataforma = process.platform) {
18
+ const s = SISTEMAS[plataforma];
19
+ if (!s) throw new ErroServico(`localhostly service does not support ${plataforma} (Linux, macOS and Windows only)`);
20
+ return s;
21
+ }
22
+
23
+ export function nomeDoSistema(plataforma = process.platform) {
24
+ return NOMES[plataforma] ?? plataforma;
25
+ }
26
+
27
+ // O agente que o serviço roda: este node e este bin/localhostly.js, por caminho absoluto; no binário do download
28
+ // (empacotado pelo pkg, scripts/binarios.sh) o próprio executável é o agente.
29
+ export function exePadrao(empacotado = Boolean(process.pkg)) {
30
+ if (empacotado) return [process.execPath];
31
+ return [process.execPath, fileURLToPath(new URL('../../bin/localhostly.js', import.meta.url))];
32
+ }
33
+
34
+ export function arquivoSenha(home) {
35
+ return join(home, 'service-password');
36
+ }
37
+
38
+ export function arquivoEstado(home) {
39
+ return join(home, 'estado.json');
40
+ }
41
+
42
+ // O último túnel do serviço: { nome, tokenSessao, url, em }. A reconexão (e o serviço relançado) pede o mesmo nome.
43
+ export function lerEstado(home) {
44
+ try {
45
+ const e = JSON.parse(readFileSync(arquivoEstado(home), 'utf8'));
46
+ return typeof e?.nome === 'string' && typeof e?.tokenSessao === 'string' ? e : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function gravarEstado(home, { nome, tokenSessao, url }, agora = new Date()) {
53
+ mkdirSync(home, { recursive: true, mode: 0o700 });
54
+ const arquivo = arquivoEstado(home);
55
+ const temp = `${arquivo}.${process.pid}.tmp`;
56
+ writeFileSync(temp, `${JSON.stringify({ nome, tokenSessao, url, em: agora.toISOString() }, null, 2)}\n`, { mode: 0o600 });
57
+ renameSync(temp, arquivo); // inteiro ou nada: o serviço morto no meio não deixa o estado pela metade
58
+ try {
59
+ chmodSync(arquivo, 0o600);
60
+ } catch {}
61
+ return arquivo;
62
+ }
63
+
64
+ // opcoes: { porta, host, servidor, senha (texto ou null), allow (a lista normalizada do --allow, ou null), home, exe }
65
+ export function instalar(opcoes, { plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid } = {}) {
66
+ const s = sistema(plataforma);
67
+ const { porta, host = null, servidor = null, senha = null, allow = null, home, exe = exePadrao() } = opcoes;
68
+ if (!Number.isInteger(porta)) {
69
+ throw new ErroServico('usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--allow ip[,ip|cidr]]');
70
+ }
71
+ mkdirSync(home, { recursive: true, mode: 0o700 });
72
+ let arquivo = null;
73
+ if (senha) {
74
+ arquivo = arquivoSenha(home);
75
+ writeFileSync(arquivo, `${senha}\n`, { mode: 0o600 });
76
+ try {
77
+ chmodSync(arquivo, 0o600);
78
+ } catch {}
79
+ } else {
80
+ rmSync(arquivoSenha(home), { force: true });
81
+ }
82
+ const comando = comandoDoAgente({ exe, porta, host, servidor, home, arquivoSenha: arquivo, allow });
83
+ const r = s.instalar({ comando, porta, home }, { casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
84
+ // o npx roda de uma pasta de cache que pode sumir: o serviço apontaria para um caminho morto
85
+ if (exe.some((parte) => /[\\/]_npx[\\/]/.test(parte))) {
86
+ r.avisos = [...(r.avisos ?? []), 'This localhostly runs from the npx cache, which can be cleaned: install it for good and run `service install` again.'];
87
+ }
88
+ return r;
89
+ }
90
+
91
+ export function remover({ plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid, home } = {}) {
92
+ const r = sistema(plataforma).remover({ casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
93
+ if (home) rmSync(arquivoSenha(home), { force: true });
94
+ return r;
95
+ }
96
+
97
+ export function status({ plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid, home } = {}) {
98
+ const r = sistema(plataforma).status({ casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
99
+ return { ...r, tunel: home ? lerEstado(home) : null };
100
+ }