localhostly 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +73 -2
- package/agente/cli.js +349 -0
- package/agente/servico/comum.js +39 -0
- package/agente/servico/index.js +98 -0
- package/agente/servico/launchd.js +100 -0
- package/agente/servico/systemd.js +115 -0
- package/agente/servico/windows.js +247 -0
- package/agente/tela.js +67 -0
- package/agente/tunel.js +335 -0
- package/bin/localhostly.js +5 -0
- package/lib/cabecalhos.js +63 -0
- package/lib/canal.js +87 -0
- package/lib/fluxo.js +100 -0
- package/lib/protocolo.js +96 -0
- package/package.json +34 -4
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,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# localhostly
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|
|
39
|
+
|
|
40
|
+
The terminal shows the public URL and every request live. HTTP, streaming responses and WebSockets all go through.
|
|
41
|
+
Stop with Ctrl+C.
|
|
42
|
+
|
|
43
|
+
The first time a browser opens your URL it sees a short warning page (it protects against phishing); API clients,
|
|
44
|
+
webhooks and `curl` never see it, and a browser can skip it by sending the header `X-Localhostly-Skip-Warning`.
|
|
45
|
+
|
|
46
|
+
## Keep it always on
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
localhostly service install 3000 # a system service: starts with the computer, reconnects, keeps the same name
|
|
50
|
+
localhostly service status
|
|
51
|
+
localhostly service uninstall
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It uses systemd (user service) on Linux, launchd on macOS and a Windows service on Windows. Install the command
|
|
55
|
+
with `npm i -g` first — a copy run by `npx` lives in a temporary folder.
|
|
56
|
+
|
|
57
|
+
## Free plan limits
|
|
58
|
+
|
|
59
|
+
2 tunnels at a time, 150 requests per minute and 25 GiB per month per account.
|
|
60
|
+
|
|
61
|
+
## All options
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
localhostly --help
|
|
65
|
+
localhostly --version
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Links
|
|
69
|
+
|
|
70
|
+
- Website: https://localhostly.iblabs.io
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/agente/cli.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
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 * as servico from './servico/index.js';
|
|
8
|
+
import { criarLog, criarTela } from './tela.js';
|
|
9
|
+
import { SERVIDOR_PADRAO, Tunel } from './tunel.js';
|
|
10
|
+
|
|
11
|
+
const VERSAO = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
|
|
13
|
+
const AJUDA = `localhostly ${VERSAO} — expose a local port on a public HTTPS URL
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
localhostly <port> expose http://127.0.0.1:<port>
|
|
17
|
+
localhostly http <port> [options] the same, with options
|
|
18
|
+
localhostly login <token> save your token on this computer
|
|
19
|
+
|
|
20
|
+
localhostly service install <port> [options]
|
|
21
|
+
keep the tunnel always on as a system service (systemd on Linux,
|
|
22
|
+
launchd on macOS, a Windows service): starts with the computer,
|
|
23
|
+
reconnects by itself and keeps the same name
|
|
24
|
+
localhostly service status is the service running, and its URL
|
|
25
|
+
localhostly service uninstall stop and remove the service
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--host <host[:port]> local address to forward to; the app also receives it as Host
|
|
29
|
+
--server <url> tunnel server (default ${SERVIDOR_PADRAO}, or $LOCALHOSTLY_SERVER)
|
|
30
|
+
--password [value] ask visitors for a password (the browser shows a login box; any user name);
|
|
31
|
+
without a value, a random one is created and shown here
|
|
32
|
+
--password-file <file> the same, with the password read from a file
|
|
33
|
+
--service service mode: one log line per event, no live table, never gives up
|
|
34
|
+
--home <dir> where the token and the service state live (default ~/.localhostly,
|
|
35
|
+
or $LOCALHOSTLY_HOME)
|
|
36
|
+
-h, --help show this help
|
|
37
|
+
-v, --version show the version
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export function pastaConfig(env = process.env) {
|
|
41
|
+
return env.LOCALHOSTLY_HOME || join(homedir(), '.localhostly');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMANDOS = ['http', 'login', 'service'];
|
|
45
|
+
|
|
46
|
+
export function lerConfig(env = process.env) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(join(pastaConfig(env), 'config.json'), 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function gravarToken(token, env = process.env) {
|
|
55
|
+
const pasta = pastaConfig(env);
|
|
56
|
+
mkdirSync(pasta, { recursive: true, mode: 0o700 });
|
|
57
|
+
const arquivo = join(pasta, 'config.json');
|
|
58
|
+
const config = { ...lerConfig(env), token };
|
|
59
|
+
writeFileSync(arquivo, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
chmodSync(arquivo, 0o600);
|
|
61
|
+
return arquivo;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sem caracteres que se confundem (0/O, 1/l/I).
|
|
65
|
+
const ALFABETO_SENHA = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
66
|
+
|
|
67
|
+
export function gerarSenha(tamanho = 12) {
|
|
68
|
+
let senha = '';
|
|
69
|
+
for (let i = 0; i < tamanho; i++) senha += ALFABETO_SENHA[randomInt(ALFABETO_SENHA.length)];
|
|
70
|
+
return senha;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function erroDeUso(mensagem) {
|
|
74
|
+
const erro = new Error(mensagem);
|
|
75
|
+
erro.uso = true;
|
|
76
|
+
return erro;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function porta(texto) {
|
|
80
|
+
const n = Number(texto);
|
|
81
|
+
if (!/^\d+$/.test(String(texto)) || n < 1 || n > 65535) throw erroDeUso(`invalid port: ${texto}`);
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// "--host 127.0.0.1:4777", "--host app.test", "--host [::1]:3000"
|
|
86
|
+
export function lerAlvo(texto, portaPadrao) {
|
|
87
|
+
const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(texto) ?? /^([^:]+)(?::(\d+))?$/.exec(texto);
|
|
88
|
+
if (!m) throw erroDeUso(`invalid --host: ${texto}`);
|
|
89
|
+
return { host: m[1], porta: m[2] ? porta(m[2]) : portaPadrao };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function lerArgumentos(argv) {
|
|
93
|
+
const opcoes = {
|
|
94
|
+
comando: null,
|
|
95
|
+
posicionais: [],
|
|
96
|
+
host: null,
|
|
97
|
+
servidor: null,
|
|
98
|
+
senha: false,
|
|
99
|
+
arquivoSenha: null,
|
|
100
|
+
servico: false,
|
|
101
|
+
home: null,
|
|
102
|
+
};
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const a = argv[i];
|
|
105
|
+
const [chave, embutido] = a.startsWith('--') && a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
|
|
106
|
+
const valorDe = () => {
|
|
107
|
+
if (embutido !== undefined) return embutido;
|
|
108
|
+
if (i + 1 >= argv.length) throw erroDeUso(`${chave} needs a value`);
|
|
109
|
+
return argv[++i];
|
|
110
|
+
};
|
|
111
|
+
// "--password" sozinho (true) ou com valor: "--password=x", ou "--password x" quando x não é opção
|
|
112
|
+
// nem a porta que ainda falta ("localhostly --password 4777" é a porta).
|
|
113
|
+
const valorDaSenha = () => {
|
|
114
|
+
if (embutido !== undefined) {
|
|
115
|
+
if (!embutido) throw erroDeUso('--password= needs a value');
|
|
116
|
+
return embutido;
|
|
117
|
+
}
|
|
118
|
+
const proximo = argv[i + 1];
|
|
119
|
+
if (proximo === undefined || proximo.startsWith('-')) return true;
|
|
120
|
+
const semPorta = !opcoes.posicionais.some((p) => /^\d+$/.test(p));
|
|
121
|
+
if (semPorta && /^\d+$/.test(proximo)) return true;
|
|
122
|
+
i++;
|
|
123
|
+
return proximo;
|
|
124
|
+
};
|
|
125
|
+
if (chave === '-h' || chave === '--help') opcoes.comando = 'ajuda';
|
|
126
|
+
else if (chave === '-v' || chave === '--version') opcoes.comando ??= 'versao';
|
|
127
|
+
else if (chave === '--host') opcoes.host = valorDe();
|
|
128
|
+
else if (chave === '--server') opcoes.servidor = valorDe();
|
|
129
|
+
else if (chave === '--password') opcoes.senha = valorDaSenha();
|
|
130
|
+
else if (chave === '--password-file') opcoes.arquivoSenha = valorDe();
|
|
131
|
+
else if (chave === '--service') opcoes.servico = true;
|
|
132
|
+
else if (chave === '--home') opcoes.home = valorDe();
|
|
133
|
+
else if (chave.startsWith('-')) throw erroDeUso(`unknown option: ${chave}`);
|
|
134
|
+
else opcoes.posicionais.push(a);
|
|
135
|
+
}
|
|
136
|
+
if (!opcoes.comando) {
|
|
137
|
+
const [primeiro] = opcoes.posicionais;
|
|
138
|
+
if (COMANDOS.includes(primeiro)) opcoes.comando = opcoes.posicionais.shift();
|
|
139
|
+
else if (primeiro === undefined) opcoes.comando = 'ajuda';
|
|
140
|
+
else opcoes.comando = 'http';
|
|
141
|
+
}
|
|
142
|
+
return opcoes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function lerArquivoSenha(arquivo) {
|
|
146
|
+
let texto;
|
|
147
|
+
try {
|
|
148
|
+
texto = readFileSync(arquivo, 'utf8');
|
|
149
|
+
} catch (erro) {
|
|
150
|
+
throw erroDeUso(`cannot read --password-file ${arquivo}: ${erro.code ?? erro.message}`);
|
|
151
|
+
}
|
|
152
|
+
const senha = texto.replace(/\r?\n$/, '');
|
|
153
|
+
if (!senha) throw erroDeUso(`--password-file ${arquivo} is empty`);
|
|
154
|
+
return senha;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A senha pedida: { senha (o texto ou null), gerada (só a tela mostra a gerada) }.
|
|
158
|
+
function senhaDe(opcoes) {
|
|
159
|
+
if (opcoes.arquivoSenha && opcoes.senha) throw erroDeUso('use --password or --password-file, not both');
|
|
160
|
+
if (opcoes.arquivoSenha) return { senha: lerArquivoSenha(opcoes.arquivoSenha), gerada: false };
|
|
161
|
+
if (opcoes.senha === true) return { senha: gerarSenha(), gerada: true };
|
|
162
|
+
return { senha: opcoes.senha || null, gerada: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function alvoDe(opcoes, uso) {
|
|
166
|
+
if (opcoes.posicionais.length !== 1) throw erroDeUso(uso);
|
|
167
|
+
const p = porta(opcoes.posicionais[0]);
|
|
168
|
+
return { porta: p, alvo: opcoes.host ? lerAlvo(opcoes.host, p) : { host: '127.0.0.1', porta: p } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const USO_SERVICO =
|
|
172
|
+
'usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--server url]\n' +
|
|
173
|
+
' localhostly service status\n' +
|
|
174
|
+
' localhostly service uninstall';
|
|
175
|
+
|
|
176
|
+
// `localhostly service ...`. rc: 0 ok (status: rodando), 1 falha (status: instalado e parado), 2 uso, 3 não instalado.
|
|
177
|
+
export async function comandoServico(
|
|
178
|
+
opcoes,
|
|
179
|
+
{ env = process.env, saida = process.stdout, erros = process.stderr, api = servico, plataforma = process.platform, esperarUrlMs = 15_000 } = {},
|
|
180
|
+
) {
|
|
181
|
+
const [sub, ...resto] = opcoes.posicionais;
|
|
182
|
+
const home = resolve(pastaConfig(env));
|
|
183
|
+
const nome = api.nomeDoSistema(plataforma);
|
|
184
|
+
try {
|
|
185
|
+
if (sub === 'install') {
|
|
186
|
+
let porta;
|
|
187
|
+
let senha;
|
|
188
|
+
let gerada;
|
|
189
|
+
try {
|
|
190
|
+
({ porta } = alvoDe({ ...opcoes, posicionais: resto }, USO_SERVICO));
|
|
191
|
+
if (opcoes.host) lerAlvo(opcoes.host, porta);
|
|
192
|
+
({ senha, gerada } = senhaDe(opcoes));
|
|
193
|
+
} catch (erro) {
|
|
194
|
+
if (!erro.uso) throw erro;
|
|
195
|
+
erros.write(`${erro.message}\n`);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
const config = lerConfig(env);
|
|
199
|
+
if (!config.token) {
|
|
200
|
+
erros.write(
|
|
201
|
+
'no token saved: create an account at https://localhostly.iblabs.io and run `localhostly login <token>` first\n',
|
|
202
|
+
);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
const servidor = opcoes.servidor || env.LOCALHOSTLY_SERVER || config.server || null;
|
|
206
|
+
const antes = Date.now();
|
|
207
|
+
const r = api.instalar({ porta, host: opcoes.host, servidor, senha, home }, { plataforma, env });
|
|
208
|
+
saida.write(`Installed the localhostly ${nome}: ${r.arquivo}\n`);
|
|
209
|
+
saida.write('It starts with the computer, reconnects by itself and keeps the same name.\n');
|
|
210
|
+
if (gerada) saida.write(`Password: ${senha} (any user name)\n`);
|
|
211
|
+
else if (senha) saida.write('Password protected (any user name).\n');
|
|
212
|
+
const fim = Date.now() + esperarUrlMs;
|
|
213
|
+
let tunel = null;
|
|
214
|
+
while (Date.now() < fim) {
|
|
215
|
+
const e = api.lerEstado(home);
|
|
216
|
+
if (e?.url && Date.parse(e.em) >= antes - 1000) {
|
|
217
|
+
tunel = e;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
await new Promise((ok) => setTimeout(ok, 200));
|
|
221
|
+
}
|
|
222
|
+
saida.write(tunel ? `URL: ${tunel.url}\n` : 'Run `localhostly service status` in a moment to see the URL.\n');
|
|
223
|
+
saida.write(`Logs: ${r.log}\n`);
|
|
224
|
+
for (const aviso of r.avisos ?? []) saida.write(`\n${aviso}\n`);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (sub === 'status' && resto.length === 0) {
|
|
228
|
+
const r = api.status({ plataforma, env, home });
|
|
229
|
+
if (!r.instalado) {
|
|
230
|
+
saida.write('The localhostly service is not installed. Install it with `localhostly service install <port>`.\n');
|
|
231
|
+
return 3;
|
|
232
|
+
}
|
|
233
|
+
saida.write(`localhostly ${nome}: ${r.estado}${r.pid ? ` (pid ${r.pid})` : ''}\n`);
|
|
234
|
+
saida.write(`Starts with the computer: ${r.sobeSozinho ? 'yes' : 'no'}\n`);
|
|
235
|
+
if (r.tunel?.url) saida.write(`URL: ${r.tunel.url} (since ${r.tunel.em})\n`);
|
|
236
|
+
if (r.log) saida.write(`Logs: ${r.log}\n`);
|
|
237
|
+
return r.rodando ? 0 : 1;
|
|
238
|
+
}
|
|
239
|
+
if (sub === 'uninstall' && resto.length === 0) {
|
|
240
|
+
const r = api.remover({ plataforma, env, home });
|
|
241
|
+
saida.write(r.removido ? `Removed the localhostly ${nome}.\n` : 'The localhostly service was not installed.\n');
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
} catch (erro) {
|
|
245
|
+
if (!(erro instanceof api.ErroServico)) throw erro;
|
|
246
|
+
erros.write(`${erro.message}\n`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
erros.write(`${USO_SERVICO}\n`);
|
|
250
|
+
return 2;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function principal(argv = process.argv.slice(2), { env = process.env, saida = process.stdout } = {}) {
|
|
254
|
+
let opcoes;
|
|
255
|
+
try {
|
|
256
|
+
opcoes = lerArgumentos(argv);
|
|
257
|
+
} catch (erro) {
|
|
258
|
+
process.stderr.write(`${erro.message}\n\n${AJUDA}`);
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
if (opcoes.home) env = { ...env, LOCALHOSTLY_HOME: resolve(opcoes.home) };
|
|
262
|
+
if (opcoes.comando === 'ajuda') {
|
|
263
|
+
saida.write(AJUDA);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
if (opcoes.comando === 'versao') {
|
|
267
|
+
saida.write(`${VERSAO}\n`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
if (opcoes.comando === 'login') {
|
|
271
|
+
const [token] = opcoes.posicionais;
|
|
272
|
+
if (!token) {
|
|
273
|
+
process.stderr.write('usage: localhostly login <token>\n');
|
|
274
|
+
return 2;
|
|
275
|
+
}
|
|
276
|
+
const arquivo = gravarToken(token, env);
|
|
277
|
+
saida.write(`Token saved to ${arquivo}\n`);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
if (opcoes.comando === 'service') return comandoServico(opcoes, { env, saida });
|
|
281
|
+
|
|
282
|
+
let alvo;
|
|
283
|
+
let senha;
|
|
284
|
+
let gerada;
|
|
285
|
+
try {
|
|
286
|
+
({ alvo } = alvoDe(opcoes, 'usage: localhostly [http] <port>'));
|
|
287
|
+
({ senha, gerada } = senhaDe(opcoes));
|
|
288
|
+
} catch (erro) {
|
|
289
|
+
process.stderr.write(`${erro.message}\n\n${AJUDA}`);
|
|
290
|
+
return 2;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// O modo serviço: uma linha por evento, o último nome guardado e pedido de novo, e nunca desiste.
|
|
294
|
+
const modoServico = opcoes.servico;
|
|
295
|
+
const home = resolve(pastaConfig(env));
|
|
296
|
+
const config = lerConfig(env);
|
|
297
|
+
const servidor = opcoes.servidor || env.LOCALHOSTLY_SERVER || config.server || SERVIDOR_PADRAO;
|
|
298
|
+
const tela = modoServico ? criarLog(saida) : criarTela(saida);
|
|
299
|
+
if (!config.token) {
|
|
300
|
+
tela.aviso('no token saved: create an account at https://localhostly.iblabs.io and run `localhostly login <token>`');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const tunel = new Tunel({
|
|
304
|
+
servidor,
|
|
305
|
+
token: config.token ?? null,
|
|
306
|
+
alvo,
|
|
307
|
+
hostCabecalho: opcoes.host,
|
|
308
|
+
senha,
|
|
309
|
+
sessao: modoServico ? servico.lerEstado(home) : null,
|
|
310
|
+
insistir: modoServico,
|
|
311
|
+
});
|
|
312
|
+
if (modoServico) tela.conectando(servidor);
|
|
313
|
+
tunel.on('pronto', ({ nome, url, tokenSessao, retomado }) => {
|
|
314
|
+
if (modoServico && tokenSessao) {
|
|
315
|
+
try {
|
|
316
|
+
servico.gravarEstado(home, { nome, tokenSessao, url });
|
|
317
|
+
} catch (erro) {
|
|
318
|
+
tela.aviso(`could not save the tunnel state in ${home}: ${erro.code ?? erro.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
tela.pronto({ url, alvo: tunel.enderecoLocal, senha: senha && (gerada ? senha : true), retomado });
|
|
322
|
+
});
|
|
323
|
+
tunel.on('nome_trocado', ({ de, para }) =>
|
|
324
|
+
tela.aviso(`name changed: ${de} -> ${para} (the old name was released while the tunnel was offline)`),
|
|
325
|
+
);
|
|
326
|
+
tunel.on('requisicao', (r) => tela.requisicao(r));
|
|
327
|
+
tunel.on('caiu', () => tela.aviso('connection to the server lost; the URL is offline'));
|
|
328
|
+
tunel.on('reconectando', ({ emMs }) => tela.aviso(`reconnecting in ${(emMs / 1000).toFixed(1)}s…`));
|
|
329
|
+
tunel.on('aviso', (erro) => tela.aviso(`server: ${erro.code ?? erro.message}`));
|
|
330
|
+
|
|
331
|
+
return await new Promise((resolver) => {
|
|
332
|
+
tunel.on('recusado', (erro) => {
|
|
333
|
+
if (modoServico) {
|
|
334
|
+
tela.erro(`server refused the tunnel: ${erro.message}; trying again`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
tela.erro(`server refused the tunnel: ${erro.message}`);
|
|
338
|
+
tunel.fechar();
|
|
339
|
+
resolver(1);
|
|
340
|
+
});
|
|
341
|
+
const sair = () => {
|
|
342
|
+
tunel.fechar();
|
|
343
|
+
resolver(0);
|
|
344
|
+
};
|
|
345
|
+
process.once('SIGINT', sair);
|
|
346
|
+
process.once('SIGTERM', sair);
|
|
347
|
+
tunel.abrir();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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.
|
|
18
|
+
export function comandoDoAgente({ exe, porta, host = null, servidor = null, home, arquivoSenha = null }) {
|
|
19
|
+
if (!Array.isArray(exe) || exe.length === 0) throw new Error('exe: the agent command is missing');
|
|
20
|
+
if (!Number.isInteger(porta) || porta < 1 || porta > 65535) throw new Error(`invalid port: ${porta}`);
|
|
21
|
+
const args = [...exe, 'http', String(porta), '--service', '--home', home];
|
|
22
|
+
if (host) args.push('--host', host);
|
|
23
|
+
if (servidor) args.push('--server', servidor);
|
|
24
|
+
if (arquivoSenha) args.push('--password-file', arquivoSenha);
|
|
25
|
+
return args;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ErroServico extends Error {
|
|
29
|
+
constructor(mensagem, detalhe = '') {
|
|
30
|
+
super(detalhe ? `${mensagem}\n${detalhe.trim()}` : mensagem);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Roda e lança com a saída do comando quando ele falha.
|
|
35
|
+
export function exigir(exec, programa, args, mensagem) {
|
|
36
|
+
const r = exec(programa, args);
|
|
37
|
+
if (r.rc !== 0) throw new ErroServico(`${mensagem} (${[programa, ...args].join(' ')}: exit ${r.rc})`, r.erro || r.saida);
|
|
38
|
+
return r;
|
|
39
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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), 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, home, exe = exePadrao() } = opcoes;
|
|
68
|
+
if (!Number.isInteger(porta)) throw new ErroServico('usage: localhostly service install <port> [--host h[:p]] [--password [value]]');
|
|
69
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
70
|
+
let arquivo = null;
|
|
71
|
+
if (senha) {
|
|
72
|
+
arquivo = arquivoSenha(home);
|
|
73
|
+
writeFileSync(arquivo, `${senha}\n`, { mode: 0o600 });
|
|
74
|
+
try {
|
|
75
|
+
chmodSync(arquivo, 0o600);
|
|
76
|
+
} catch {}
|
|
77
|
+
} else {
|
|
78
|
+
rmSync(arquivoSenha(home), { force: true });
|
|
79
|
+
}
|
|
80
|
+
const comando = comandoDoAgente({ exe, porta, host, servidor, home, arquivoSenha: arquivo });
|
|
81
|
+
const r = s.instalar({ comando, porta, home }, { casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
|
|
82
|
+
// o npx roda de uma pasta de cache que pode sumir: o serviço apontaria para um caminho morto
|
|
83
|
+
if (exe.some((parte) => /[\\/]_npx[\\/]/.test(parte))) {
|
|
84
|
+
r.avisos = [...(r.avisos ?? []), 'This localhostly runs from the npx cache, which can be cleaned: install it for good and run `service install` again.'];
|
|
85
|
+
}
|
|
86
|
+
return r;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function remover({ plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid, home } = {}) {
|
|
90
|
+
const r = sistema(plataforma).remover({ casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
|
|
91
|
+
if (home) rmSync(arquivoSenha(home), { force: true });
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function status({ plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid, home } = {}) {
|
|
96
|
+
const r = sistema(plataforma).status({ casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
|
|
97
|
+
return { ...r, tunel: home ? lerEstado(home) : null };
|
|
98
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// macOS: um LaunchAgent do usuário (~/Library/LaunchAgents/io.iblabs.localhostly.plist, `launchctl`), que sobe no
|
|
2
|
+
// login (RunAtLoad) e é relançado pelo launchd quando cai (KeepAlive).
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ErroServico, executar } from './comum.js';
|
|
7
|
+
|
|
8
|
+
export const ROTULO = 'io.iblabs.localhostly';
|
|
9
|
+
|
|
10
|
+
export function arquivoDoPlist({ casa = homedir() } = {}) {
|
|
11
|
+
return join(casa, 'Library', 'LaunchAgents', `${ROTULO}.plist`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function arquivoDoLog({ casa = homedir() } = {}) {
|
|
15
|
+
return join(casa, 'Library', 'Logs', 'localhostly.log');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function escaparXml(texto) {
|
|
19
|
+
return String(texto)
|
|
20
|
+
.replaceAll('&', '&')
|
|
21
|
+
.replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>')
|
|
23
|
+
.replaceAll('"', '"')
|
|
24
|
+
.replaceAll("'", ''');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function gerarPlist(comando, { log }) {
|
|
28
|
+
const str = (v) => `<string>${escaparXml(v)}</string>`;
|
|
29
|
+
return [
|
|
30
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
31
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
32
|
+
'<plist version="1.0">',
|
|
33
|
+
'<dict>',
|
|
34
|
+
' <key>Label</key>',
|
|
35
|
+
` ${str(ROTULO)}`,
|
|
36
|
+
' <key>ProgramArguments</key>',
|
|
37
|
+
' <array>',
|
|
38
|
+
...comando.map((a) => ` ${str(a)}`),
|
|
39
|
+
' </array>',
|
|
40
|
+
' <key>EnvironmentVariables</key>',
|
|
41
|
+
' <dict>',
|
|
42
|
+
' <key>NO_COLOR</key>',
|
|
43
|
+
' <string>1</string>',
|
|
44
|
+
' </dict>',
|
|
45
|
+
' <key>RunAtLoad</key>',
|
|
46
|
+
' <true/>',
|
|
47
|
+
' <key>KeepAlive</key>',
|
|
48
|
+
' <true/>',
|
|
49
|
+
' <key>ThrottleInterval</key>',
|
|
50
|
+
' <integer>5</integer>',
|
|
51
|
+
' <key>ProcessType</key>',
|
|
52
|
+
' <string>Background</string>',
|
|
53
|
+
' <key>StandardOutPath</key>',
|
|
54
|
+
` ${str(log)}`,
|
|
55
|
+
' <key>StandardErrorPath</key>',
|
|
56
|
+
` ${str(log)}`,
|
|
57
|
+
'</dict>',
|
|
58
|
+
'</plist>',
|
|
59
|
+
'',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dominio(uid) {
|
|
64
|
+
return `gui/${uid}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function instalar({ comando }, { casa, uid = process.getuid?.(), exec = executar } = {}) {
|
|
68
|
+
const arquivo = arquivoDoPlist({ casa });
|
|
69
|
+
const log = arquivoDoLog({ casa });
|
|
70
|
+
mkdirSync(join(arquivo, '..'), { recursive: true });
|
|
71
|
+
mkdirSync(join(log, '..'), { recursive: true });
|
|
72
|
+
// reinstalar troca a configuração: o agente de antes sai do launchd antes do novo entrar
|
|
73
|
+
exec('launchctl', ['bootout', `${dominio(uid)}/${ROTULO}`]);
|
|
74
|
+
writeFileSync(arquivo, gerarPlist(comando, { log }));
|
|
75
|
+
exec('launchctl', ['enable', `${dominio(uid)}/${ROTULO}`]);
|
|
76
|
+
const r = exec('launchctl', ['bootstrap', dominio(uid), arquivo]);
|
|
77
|
+
if (r.rc !== 0) {
|
|
78
|
+
throw new ErroServico(`could not load the LaunchAgent (launchctl bootstrap ${dominio(uid)}: exit ${r.rc})`, r.erro || r.saida);
|
|
79
|
+
}
|
|
80
|
+
return { arquivo, log: `tail -f ${log}`, avisos: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function remover({ casa, uid = process.getuid?.(), exec = executar } = {}) {
|
|
84
|
+
const arquivo = arquivoDoPlist({ casa });
|
|
85
|
+
const existia = existsSync(arquivo);
|
|
86
|
+
exec('launchctl', ['bootout', `${dominio(uid)}/${ROTULO}`]);
|
|
87
|
+
rmSync(arquivo, { force: true });
|
|
88
|
+
return { removido: existia, arquivo };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function status({ casa, uid = process.getuid?.(), exec = executar } = {}) {
|
|
92
|
+
const arquivo = arquivoDoPlist({ casa });
|
|
93
|
+
if (!existsSync(arquivo)) return { instalado: false, arquivo };
|
|
94
|
+
const r = exec('launchctl', ['print', `${dominio(uid)}/${ROTULO}`]);
|
|
95
|
+
const base = { instalado: true, arquivo, sobeSozinho: true, log: `tail -f ${arquivoDoLog({ casa })}` };
|
|
96
|
+
if (r.rc !== 0) return { ...base, rodando: false, estado: 'not loaded', pid: null, sobeSozinho: false };
|
|
97
|
+
const estado = /^\s*state = (.+)$/m.exec(r.saida)?.[1]?.trim() ?? 'unknown';
|
|
98
|
+
const pid = Number(/^\s*pid = (\d+)/m.exec(r.saida)?.[1]) || null;
|
|
99
|
+
return { ...base, rodando: estado === 'running', estado, pid };
|
|
100
|
+
}
|