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 +21 -0
- package/README.md +78 -2
- package/agente/cli.js +370 -0
- package/agente/servico/comum.js +41 -0
- package/agente/servico/index.js +100 -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 +72 -0
- package/agente/tunel.js +356 -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/permitidos.js +85 -0
- package/lib/protocolo.js +96 -0
- package/package.json +38 -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,79 @@
|
|
|
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
|
+
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
|
+
}
|