@fiodos/bridge 0.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/Dockerfile +20 -0
- package/LICENSE +77 -0
- package/README.md +64 -0
- package/dist/allowlist.d.ts +32 -0
- package/dist/allowlist.js +73 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +162 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +81 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/log.d.ts +5 -0
- package/dist/log.js +47 -0
- package/dist/pair.d.ts +21 -0
- package/dist/pair.js +56 -0
- package/dist/runner.d.ts +42 -0
- package/dist/runner.js +206 -0
- package/dist/service.d.ts +11 -0
- package/dist/service.js +142 -0
- package/package.json +40 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Fiodos bridge as a container: for a dashboard that lives on a server nobody
|
|
2
|
+
# logs into. Pair once (interactive), then run forever with the same volume.
|
|
3
|
+
#
|
|
4
|
+
# docker build -t fiodos/bridge packages/bridge
|
|
5
|
+
# docker run --rm -it --network host -v fiodos-bridge:/data fiodos/bridge \
|
|
6
|
+
# pair ABCD-EFGH --target http://localhost:3947 --no-run
|
|
7
|
+
# docker run -d --restart unless-stopped --network host -v fiodos-bridge:/data fiodos/bridge run
|
|
8
|
+
#
|
|
9
|
+
# `--network host` is what lets it reach `localhost:<port>` of the machine;
|
|
10
|
+
# without it, point --target at the service's name on the compose network.
|
|
11
|
+
FROM node:22-alpine
|
|
12
|
+
ENV FIODOS_BRIDGE_HOME=/data
|
|
13
|
+
WORKDIR /app
|
|
14
|
+
COPY package.json README.md LICENSE ./
|
|
15
|
+
COPY dist ./dist
|
|
16
|
+
RUN mkdir -p /data && chown node:node /data
|
|
17
|
+
USER node
|
|
18
|
+
VOLUME ["/data"]
|
|
19
|
+
ENTRYPOINT ["node", "dist/cli.js"]
|
|
20
|
+
CMD ["run"]
|
package/LICENSE
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Fiodos SDK License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fiodos / Rodrigo Martínez. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This is a proprietary, commercial software license. It is NOT an open-source
|
|
6
|
+
license. Please read it carefully before installing or using the Software.
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
"Software" means the Fiodos SDK packages (including, without limitation, the
|
|
10
|
+
@fiodos/* npm packages and the fiodos_flutter package) together with any
|
|
11
|
+
accompanying files, documentation, and updates provided by the Licensor.
|
|
12
|
+
"Service" means the hosted Fiodos backend, APIs, and related services
|
|
13
|
+
operated by the Licensor.
|
|
14
|
+
"Licensor" means Fiodos / Rodrigo Martínez (and, once incorporated, the
|
|
15
|
+
company that succeeds to these rights).
|
|
16
|
+
"You" means the individual or legal entity that installs or uses the
|
|
17
|
+
Software.
|
|
18
|
+
|
|
19
|
+
2. Grant of License.
|
|
20
|
+
Subject to Your continued compliance with this Agreement, the Licensor grants
|
|
21
|
+
You a limited, non-exclusive, non-transferable, non-sublicensable, and
|
|
22
|
+
revocable license to install and use the Software solely for the purpose of
|
|
23
|
+
integrating with and accessing the Fiodos Service from within Your own
|
|
24
|
+
applications.
|
|
25
|
+
|
|
26
|
+
3. Restrictions.
|
|
27
|
+
Except as expressly permitted in Section 2, You may NOT, in whole or in part:
|
|
28
|
+
(a) copy the Software, except for the copying strictly necessary to install
|
|
29
|
+
and run it for its intended purpose;
|
|
30
|
+
(b) modify, adapt, translate, or create derivative works of the Software;
|
|
31
|
+
(c) redistribute, publish, sublicense, sell, rent, lease, lend, or otherwise
|
|
32
|
+
make the Software available to any third party as a standalone item;
|
|
33
|
+
(d) reverse engineer, decompile, or disassemble the Software, or attempt to
|
|
34
|
+
derive its source code, except and only to the extent that this
|
|
35
|
+
restriction is expressly prohibited by applicable law;
|
|
36
|
+
(e) remove, obscure, or alter any copyright, trademark, or other proprietary
|
|
37
|
+
notices contained in the Software; or
|
|
38
|
+
(f) use the Software, or any portion of it, to develop, train, or assist in
|
|
39
|
+
developing a product or service that competes with Fiodos.
|
|
40
|
+
|
|
41
|
+
4. Reservation of Rights.
|
|
42
|
+
The Software is licensed, not sold. All right, title, and interest in and to
|
|
43
|
+
the Software, including all related intellectual property rights, are and
|
|
44
|
+
shall remain the exclusive property of the Licensor. All rights not expressly
|
|
45
|
+
granted to You under this Agreement are reserved by the Licensor.
|
|
46
|
+
|
|
47
|
+
5. Termination.
|
|
48
|
+
This license terminates automatically and immediately if You breach any term
|
|
49
|
+
of this Agreement. Upon termination, You must cease all use of the Software
|
|
50
|
+
and delete all copies in Your possession or control. Sections 3, 4, 6, and 7
|
|
51
|
+
survive termination.
|
|
52
|
+
|
|
53
|
+
6. Disclaimer of Warranty.
|
|
54
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
55
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
56
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
57
|
+
|
|
58
|
+
7. Limitation of Liability.
|
|
59
|
+
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE
|
|
60
|
+
LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
|
|
61
|
+
ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
|
|
62
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
63
|
+
|
|
64
|
+
8. Contact.
|
|
65
|
+
For licensing inquiries, contact Rodrigo Martínez (Fiodos).
|
|
66
|
+
|
|
67
|
+
--------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
Nota (ES) — Licencia propietaria:
|
|
70
|
+
Se permite INSTALAR y USAR el SDK únicamente para integrar y acceder al servicio
|
|
71
|
+
Fiodos desde tus propias aplicaciones. Quedan PROHIBIDOS: copiar el código (salvo
|
|
72
|
+
la copia imprescindible para instalarlo y ejecutarlo), modificarlo, redistribuirlo,
|
|
73
|
+
sublicenciarlo, venderlo, hacer ingeniería inversa o crear obras derivadas o un
|
|
74
|
+
producto que compita con Fiodos. El software se licencia, no se vende; todos los
|
|
75
|
+
derechos no concedidos expresamente quedan reservados al Licenciante. Este texto
|
|
76
|
+
corresponde a la fase de pruebas y será revisado por un abogado al constituir la
|
|
77
|
+
sociedad.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @fiodos/bridge
|
|
2
|
+
|
|
3
|
+
Lets your Fiodos company agent reach a dashboard or API that lives **on your own
|
|
4
|
+
network** (`localhost:3947`, an intranet host) without opening a port, without a
|
|
5
|
+
third-party tunnel and without giving Fiodos your local key.
|
|
6
|
+
|
|
7
|
+
How it works: the bridge is a small process on a machine that can see that
|
|
8
|
+
dashboard. It opens **one outbound** connection to Fiodos and holds it. When the
|
|
9
|
+
agent needs to call the dashboard, Fiodos sends the request down that
|
|
10
|
+
connection, the bridge judges it against the allow-list you set, executes it
|
|
11
|
+
locally with your local credentials, and returns the answer.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npx -y --loglevel=error @fiodos/bridge pair ABCD-EFGH --target http://localhost:3947
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is the whole install. The code comes from the Fiodos dashboard (internal
|
|
18
|
+
orb → connections → "dashboard on my network") and is valid for 15 minutes,
|
|
19
|
+
once.
|
|
20
|
+
|
|
21
|
+
## What stays here, what travels
|
|
22
|
+
|
|
23
|
+
| Kept on this machine (`~/.fiodos/bridge/config.json`, mode 0600) | Never leaves it |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| The pairing token (talks to Fiodos) | Your dashboard's key (`--header`) |
|
|
26
|
+
| The target origin and allowed path prefixes | Any code, any copy of a response |
|
|
27
|
+
| Header **names** you added (values are only in the file) | — |
|
|
28
|
+
|
|
29
|
+
Fiodos keeps the API document of the dashboard (read once through the bridge,
|
|
30
|
+
or uploaded as a file) so the agent knows what it can call — never the code.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pair <code> --target <url> [--header "Name: value"]... [--header-file path]
|
|
36
|
+
[--allow </prefix>]... [--name <label>] [--api-url <url>] [--no-run]
|
|
37
|
+
run serve every paired bridge (what the service runs)
|
|
38
|
+
status what is paired, where it points; never prints a key
|
|
39
|
+
unpair [--bridge <id>]
|
|
40
|
+
service install|uninstall|show launchd (macOS) / systemd --user (Linux); Windows prints instructions
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `--header "Authorization: Bearer …"` — how the bridge authenticates to **your**
|
|
44
|
+
dashboard. Use `--header-file` if the value must not appear in shell history.
|
|
45
|
+
- `--allow /v1/owner` (repeatable) — restrict the bridge to those path prefixes.
|
|
46
|
+
Default is everything on the target origin, nothing off it.
|
|
47
|
+
- The token is sent to Fiodos as the first WebSocket frame, never in the URL.
|
|
48
|
+
|
|
49
|
+
## Run it always
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
fiodos-bridge service install # starts with the machine, restarts if it dies
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or as a container (see `Dockerfile`): pair once with the volume, then `run`.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
Node.js 22.5 or newer (uses the built-in WebSocket and fetch; no dependencies).
|
|
60
|
+
|
|
61
|
+
## Revoking
|
|
62
|
+
|
|
63
|
+
From the Fiodos dashboard, "revoke bridge" ends the pairing: the running bridge
|
|
64
|
+
receives the notice, deletes its token and exits. A new code re-pairs.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The allow-list: what the backend may ask this bridge to touch.
|
|
3
|
+
*
|
|
4
|
+
* A bridge compromised from our side must not be a door into the customer's
|
|
5
|
+
* network — only to the API they chose to expose. So every request is judged
|
|
6
|
+
* here, on the customer's machine, before anything is sent: one origin (the
|
|
7
|
+
* paired target), the path prefixes they allowed, the HTTP verbs an API uses.
|
|
8
|
+
*/
|
|
9
|
+
import type { BridgeTarget } from './config.js';
|
|
10
|
+
export declare const ALLOWED_METHODS: Set<string>;
|
|
11
|
+
export interface IncomingRequest {
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
query?: Record<string, string>;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
body?: string | null;
|
|
17
|
+
}
|
|
18
|
+
export type Verdict = {
|
|
19
|
+
ok: true;
|
|
20
|
+
url: string;
|
|
21
|
+
method: string;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function normalizeOrigin(raw: string): string;
|
|
27
|
+
export declare function normalizePrefix(raw: string): string;
|
|
28
|
+
export declare function judge(target: BridgeTarget, request: IncomingRequest): Verdict;
|
|
29
|
+
/** Headers that travel with the request: what the backend asked for (content
|
|
30
|
+
* negotiation only), then the customer's own — theirs win. Hop-by-hop and
|
|
31
|
+
* identity headers from the wire are dropped. */
|
|
32
|
+
export declare function outboundHeaders(target: BridgeTarget, incoming: Record<string, string> | undefined): Record<string, string>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']);
|
|
2
|
+
export function normalizeOrigin(raw) {
|
|
3
|
+
let text = String(raw || '').trim();
|
|
4
|
+
if (!text)
|
|
5
|
+
throw new Error('target is required (e.g. http://localhost:3947)');
|
|
6
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
|
|
7
|
+
if (!/^https?:\/\//i.test(text))
|
|
8
|
+
throw new Error('target must be http or https');
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
text = `http://${text}`;
|
|
12
|
+
}
|
|
13
|
+
const url = new URL(text);
|
|
14
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
15
|
+
throw new Error('target must be http or https');
|
|
16
|
+
return `${url.protocol}//${url.host}`;
|
|
17
|
+
}
|
|
18
|
+
export function normalizePrefix(raw) {
|
|
19
|
+
let p = String(raw || '').trim();
|
|
20
|
+
if (!p)
|
|
21
|
+
return '/';
|
|
22
|
+
if (!p.startsWith('/'))
|
|
23
|
+
p = `/${p}`;
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
export function judge(target, request) {
|
|
27
|
+
const method = String(request.method || '').toUpperCase();
|
|
28
|
+
if (!ALLOWED_METHODS.has(method))
|
|
29
|
+
return { ok: false, reason: `method ${method || '(none)'} not allowed` };
|
|
30
|
+
const path = String(request.path || '');
|
|
31
|
+
if (!path.startsWith('/'))
|
|
32
|
+
return { ok: false, reason: 'path must be absolute' };
|
|
33
|
+
// A path that climbs (`/a/../admin`) is judged by where it lands, not by
|
|
34
|
+
// how it was written.
|
|
35
|
+
let resolved;
|
|
36
|
+
try {
|
|
37
|
+
resolved = new URL(path, target.origin);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { ok: false, reason: 'path is not valid' };
|
|
41
|
+
}
|
|
42
|
+
if (`${resolved.protocol}//${resolved.host}` !== target.origin) {
|
|
43
|
+
return { ok: false, reason: 'request left the paired target' };
|
|
44
|
+
}
|
|
45
|
+
const allowed = target.allowPaths.length ? target.allowPaths : ['/'];
|
|
46
|
+
const landed = resolved.pathname;
|
|
47
|
+
const inside = allowed.some((prefix) => {
|
|
48
|
+
const p = normalizePrefix(prefix);
|
|
49
|
+
if (p === '/')
|
|
50
|
+
return true;
|
|
51
|
+
return landed === p || landed.startsWith(p.endsWith('/') ? p : `${p}/`);
|
|
52
|
+
});
|
|
53
|
+
if (!inside)
|
|
54
|
+
return { ok: false, reason: `path ${landed} is outside the allowed prefixes (${allowed.join(', ')})` };
|
|
55
|
+
const query = request.query && typeof request.query === 'object' ? request.query : {};
|
|
56
|
+
for (const [k, v] of Object.entries(query))
|
|
57
|
+
resolved.searchParams.set(k, String(v));
|
|
58
|
+
return { ok: true, url: resolved.toString(), method };
|
|
59
|
+
}
|
|
60
|
+
/** Headers that travel with the request: what the backend asked for (content
|
|
61
|
+
* negotiation only), then the customer's own — theirs win. Hop-by-hop and
|
|
62
|
+
* identity headers from the wire are dropped. */
|
|
63
|
+
export function outboundHeaders(target, incoming) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of Object.entries(incoming || {})) {
|
|
66
|
+
const key = k.toLowerCase();
|
|
67
|
+
if (key === 'accept' || key === 'content-type')
|
|
68
|
+
out[key] = String(v);
|
|
69
|
+
}
|
|
70
|
+
for (const [k, v] of Object.entries(target.headers || {}))
|
|
71
|
+
out[k] = v;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fiodos-bridge — the command the dashboard tells you to paste.
|
|
4
|
+
*
|
|
5
|
+
* npx -y --loglevel=error @fiodos/bridge pair ABCD-EFGH --target http://localhost:3947
|
|
6
|
+
* fiodos-bridge run
|
|
7
|
+
* fiodos-bridge status
|
|
8
|
+
* fiodos-bridge service install | uninstall | show
|
|
9
|
+
* fiodos-bridge unpair [--bridge <id>]
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { parseHeaderFlag, pair } from './pair.js';
|
|
13
|
+
import { readConfig, redacted, removeBridge, upsertBridge, writeConfig, configPath } from './config.js';
|
|
14
|
+
import { runBridge } from './runner.js';
|
|
15
|
+
import { activityLogPath, dim, say } from './log.js';
|
|
16
|
+
import * as service from './service.js';
|
|
17
|
+
export function parseArgs(argv) {
|
|
18
|
+
const positional = [];
|
|
19
|
+
const values = {};
|
|
20
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
21
|
+
const arg = argv[i];
|
|
22
|
+
if (arg.startsWith('--')) {
|
|
23
|
+
const eq = arg.indexOf('=');
|
|
24
|
+
const key = eq > 0 ? arg.slice(2, eq) : arg.slice(2);
|
|
25
|
+
let value = eq > 0 ? arg.slice(eq + 1) : '';
|
|
26
|
+
if (eq < 0 && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
27
|
+
value = argv[i + 1];
|
|
28
|
+
i += 1;
|
|
29
|
+
}
|
|
30
|
+
(values[key] ||= []).push(value);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
positional.push(arg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { positional, values };
|
|
37
|
+
}
|
|
38
|
+
function one(flags, key) {
|
|
39
|
+
const list = flags.values[key];
|
|
40
|
+
return list && list.length ? list[list.length - 1] : undefined;
|
|
41
|
+
}
|
|
42
|
+
function usage() {
|
|
43
|
+
process.stderr.write(`fiodos-bridge — reach a dashboard on your own network from your Fiodos company agent
|
|
44
|
+
|
|
45
|
+
pair <code> --target <http://host:port> [--header "Name: value"]... [--allow </prefix>]... [--name <label>] [--api-url <url>]
|
|
46
|
+
Spend the one-time code from the dashboard, keep the pairing here, and start serving.
|
|
47
|
+
run Serve every paired bridge (what the service runs).
|
|
48
|
+
status What is paired, where it points, last activity. Never prints a key.
|
|
49
|
+
unpair [--bridge <id>]
|
|
50
|
+
service install | uninstall | show
|
|
51
|
+
|
|
52
|
+
Config: ${configPath()} Log: ${activityLogPath()}
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
async function runAll(bridges) {
|
|
56
|
+
if (!bridges.length) {
|
|
57
|
+
say('nothing is paired yet. Run the pair command your dashboard shows.', 'warn');
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const runs = bridges.map((bridge) => runBridge(bridge, {
|
|
62
|
+
onRevoked: (b) => writeConfig(removeBridge(readConfig(), b.bridgeId)),
|
|
63
|
+
}));
|
|
64
|
+
await Promise.all(runs);
|
|
65
|
+
say('no bridge left to serve — exiting.', 'info');
|
|
66
|
+
}
|
|
67
|
+
async function main(argv) {
|
|
68
|
+
const flags = parseArgs(argv);
|
|
69
|
+
const [command, ...rest] = flags.positional;
|
|
70
|
+
switch (command) {
|
|
71
|
+
case 'pair': {
|
|
72
|
+
const code = rest[0] || one(flags, 'code');
|
|
73
|
+
const target = one(flags, 'target');
|
|
74
|
+
if (!code || !target) {
|
|
75
|
+
say('pair needs the code and --target, e.g. pair ABCD-EFGH --target http://localhost:3947', 'error');
|
|
76
|
+
process.exitCode = 2;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const headers = {};
|
|
80
|
+
for (const raw of flags.values.header || []) {
|
|
81
|
+
const [name, value] = parseHeaderFlag(raw);
|
|
82
|
+
headers[name] = value;
|
|
83
|
+
}
|
|
84
|
+
const headerFile = one(flags, 'header-file');
|
|
85
|
+
if (headerFile) {
|
|
86
|
+
// "Name: value" per line — for a key that must not appear in shell history.
|
|
87
|
+
for (const line of readFileSync(headerFile, 'utf8').split('\n')) {
|
|
88
|
+
if (!line.trim())
|
|
89
|
+
continue;
|
|
90
|
+
const [name, value] = parseHeaderFlag(line);
|
|
91
|
+
headers[name] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
say(`pairing with Fiodos…`);
|
|
95
|
+
const paired = await pair({
|
|
96
|
+
code,
|
|
97
|
+
target,
|
|
98
|
+
apiUrl: one(flags, 'api-url'),
|
|
99
|
+
name: one(flags, 'name'),
|
|
100
|
+
headers,
|
|
101
|
+
allowPaths: flags.values.allow || [],
|
|
102
|
+
});
|
|
103
|
+
writeConfig(upsertBridge(readConfig(), paired));
|
|
104
|
+
say(`paired — ${paired.name || paired.bridgeId} → ${paired.target.origin}`, 'ok');
|
|
105
|
+
dim(`kept here: ${configPath()} (your local key never leaves this machine)`);
|
|
106
|
+
if (!Object.keys(headers).length)
|
|
107
|
+
dim('no --header given: calls to the local dashboard go without credentials. Add one with --header "Authorization: Bearer …" if it needs it.');
|
|
108
|
+
if (one(flags, 'no-run') !== undefined)
|
|
109
|
+
return;
|
|
110
|
+
await runAll([paired]);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
case undefined:
|
|
114
|
+
case 'run': {
|
|
115
|
+
await runAll(readConfig().bridges);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case 'status': {
|
|
119
|
+
const config = readConfig();
|
|
120
|
+
if (!config.bridges.length) {
|
|
121
|
+
say('nothing paired.', 'warn');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const b of config.bridges)
|
|
125
|
+
process.stdout.write(`${JSON.stringify(redacted(b), null, 2)}\n`);
|
|
126
|
+
dim(`activity: ${activityLogPath()}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case 'unpair': {
|
|
130
|
+
const config = readConfig();
|
|
131
|
+
const id = one(flags, 'bridge');
|
|
132
|
+
const victims = id ? config.bridges.filter((b) => b.bridgeId === id) : config.bridges;
|
|
133
|
+
let next = config;
|
|
134
|
+
for (const v of victims)
|
|
135
|
+
next = removeBridge(next, v.bridgeId);
|
|
136
|
+
writeConfig(next);
|
|
137
|
+
say(`removed ${victims.length} pairing(s) from this machine. Revoke it in the dashboard too.`, 'ok');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
case 'service': {
|
|
141
|
+
const sub = rest[0] || 'show';
|
|
142
|
+
const p = sub === 'install' ? service.install() : sub === 'uninstall' ? service.uninstall() : service.plan();
|
|
143
|
+
if (p.path)
|
|
144
|
+
say(`${sub === 'uninstall' ? 'removed' : sub === 'install' ? 'installed' : 'would write'} ${p.path}`, 'ok');
|
|
145
|
+
if (sub === 'show' && p.contents)
|
|
146
|
+
process.stdout.write(p.contents);
|
|
147
|
+
for (const n of p.notes)
|
|
148
|
+
dim(n);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
usage();
|
|
153
|
+
process.exitCode = 2;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const invokedDirectly = process.argv[1] && /cli\.(js|ts)$/.test(process.argv[1]);
|
|
157
|
+
if (invokedDirectly) {
|
|
158
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
159
|
+
say(err.message || String(err), 'error');
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
});
|
|
162
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface BridgeTarget {
|
|
2
|
+
/** `http://localhost:3947` — scheme, host and port only; paths come per request. */
|
|
3
|
+
origin: string;
|
|
4
|
+
/** Path prefixes the bridge may call on this origin. `['/']` means everything. */
|
|
5
|
+
allowPaths: string[];
|
|
6
|
+
/** Headers added to every call — how the local dashboard authenticates the bridge. */
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
export interface PairedBridge {
|
|
10
|
+
bridgeId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
apiUrl: string;
|
|
13
|
+
wsUrl: string;
|
|
14
|
+
token: string;
|
|
15
|
+
target: BridgeTarget;
|
|
16
|
+
pairedAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface BridgeConfig {
|
|
19
|
+
version: 1;
|
|
20
|
+
bridges: PairedBridge[];
|
|
21
|
+
}
|
|
22
|
+
export declare function bridgeHome(): string;
|
|
23
|
+
export declare function configPath(): string;
|
|
24
|
+
export declare function emptyConfig(): BridgeConfig;
|
|
25
|
+
export declare function readConfig(): BridgeConfig;
|
|
26
|
+
export declare function writeConfig(config: BridgeConfig): void;
|
|
27
|
+
export declare function upsertBridge(config: BridgeConfig, bridge: PairedBridge): BridgeConfig;
|
|
28
|
+
export declare function removeBridge(config: BridgeConfig, bridgeId: string): BridgeConfig;
|
|
29
|
+
/** What `status` prints: never the token, never a header value. */
|
|
30
|
+
export declare function redacted(bridge: PairedBridge): Record<string, unknown>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bridge keeps on the customer's machine — and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Per paired bridge: its pairing token (to talk to Fiodos), the local address
|
|
5
|
+
* it may call, the path prefixes it may touch there, and the header(s) that
|
|
6
|
+
* local service authenticates with. No code, no copies of responses, no
|
|
7
|
+
* document (DESIGN_AGENTE_INTERNO D11).
|
|
8
|
+
*
|
|
9
|
+
* Lives in `~/.fiodos/bridge/config.json` (or `$FIODOS_BRIDGE_HOME`), mode
|
|
10
|
+
* 0600: the local key is in it.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
export function bridgeHome() {
|
|
16
|
+
const fromEnv = (process.env.FIODOS_BRIDGE_HOME || '').trim();
|
|
17
|
+
return fromEnv || join(homedir(), '.fiodos', 'bridge');
|
|
18
|
+
}
|
|
19
|
+
export function configPath() {
|
|
20
|
+
return join(bridgeHome(), 'config.json');
|
|
21
|
+
}
|
|
22
|
+
export function emptyConfig() {
|
|
23
|
+
return { version: 1, bridges: [] };
|
|
24
|
+
}
|
|
25
|
+
export function readConfig() {
|
|
26
|
+
const path = configPath();
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return emptyConfig();
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
const bridges = Array.isArray(parsed.bridges) ? parsed.bridges.filter(isPaired) : [];
|
|
32
|
+
return { version: 1, bridges };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return emptyConfig();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function writeConfig(config) {
|
|
39
|
+
const dir = bridgeHome();
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const path = configPath();
|
|
42
|
+
const tmp = `${path}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
44
|
+
renameSync(tmp, path);
|
|
45
|
+
try {
|
|
46
|
+
chmodSync(path, 0o600);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Windows has no POSIX modes; the directory is the user's own.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function upsertBridge(config, bridge) {
|
|
53
|
+
const others = config.bridges.filter((b) => b.bridgeId !== bridge.bridgeId);
|
|
54
|
+
return { version: 1, bridges: [...others, bridge] };
|
|
55
|
+
}
|
|
56
|
+
export function removeBridge(config, bridgeId) {
|
|
57
|
+
return { version: 1, bridges: config.bridges.filter((b) => b.bridgeId !== bridgeId) };
|
|
58
|
+
}
|
|
59
|
+
/** What `status` prints: never the token, never a header value. */
|
|
60
|
+
export function redacted(bridge) {
|
|
61
|
+
return {
|
|
62
|
+
bridgeId: bridge.bridgeId,
|
|
63
|
+
name: bridge.name,
|
|
64
|
+
apiUrl: bridge.apiUrl,
|
|
65
|
+
target: bridge.target.origin,
|
|
66
|
+
allowPaths: bridge.target.allowPaths,
|
|
67
|
+
headers: Object.keys(bridge.target.headers),
|
|
68
|
+
pairedAt: bridge.pairedAt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isPaired(value) {
|
|
72
|
+
if (!value || typeof value !== 'object')
|
|
73
|
+
return false;
|
|
74
|
+
const b = value;
|
|
75
|
+
const target = b.target;
|
|
76
|
+
return (typeof b.bridgeId === 'string' &&
|
|
77
|
+
typeof b.token === 'string' &&
|
|
78
|
+
typeof b.wsUrl === 'string' &&
|
|
79
|
+
!!target &&
|
|
80
|
+
typeof target.origin === 'string');
|
|
81
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { judge, normalizeOrigin, normalizePrefix, outboundHeaders, ALLOWED_METHODS } from './allowlist.js';
|
|
2
|
+
export type { IncomingRequest, Verdict } from './allowlist.js';
|
|
3
|
+
export { readConfig, writeConfig, upsertBridge, removeBridge, redacted, configPath, bridgeHome } from './config.js';
|
|
4
|
+
export type { BridgeConfig, BridgeTarget, PairedBridge } from './config.js';
|
|
5
|
+
export { pair, parseHeaderFlag, DEFAULT_API_URL } from './pair.js';
|
|
6
|
+
export { runBridge, makeExecutor, SUBPROTOCOL, VERSION } from './runner.js';
|
|
7
|
+
export type { Executor, RunnerEvents } from './runner.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { judge, normalizeOrigin, normalizePrefix, outboundHeaders, ALLOWED_METHODS } from './allowlist.js';
|
|
2
|
+
export { readConfig, writeConfig, upsertBridge, removeBridge, redacted, configPath, bridgeHome } from './config.js';
|
|
3
|
+
export { pair, parseHeaderFlag, DEFAULT_API_URL } from './pair.js';
|
|
4
|
+
export { runBridge, makeExecutor, SUBPROTOCOL, VERSION } from './runner.js';
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function activityLogPath(): string;
|
|
2
|
+
export declare function logActivity(line: string): void;
|
|
3
|
+
/** The same face as the rest of the Fiodos installers: orb, name, message. */
|
|
4
|
+
export declare function say(message: string, tone?: 'info' | 'ok' | 'warn' | 'error'): void;
|
|
5
|
+
export declare function dim(message: string): void;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short, rotating local activity log: what Fiodos asked this bridge to do,
|
|
3
|
+
* so the administrator of the machine can see it. Never a body, never a
|
|
4
|
+
* header value — method, path, status, time.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, renameSync, statSync, appendFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { bridgeHome } from './config.js';
|
|
9
|
+
const MAX_BYTES = 512 * 1024;
|
|
10
|
+
export function activityLogPath() {
|
|
11
|
+
return join(bridgeHome(), 'activity.log');
|
|
12
|
+
}
|
|
13
|
+
export function logActivity(line) {
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
16
|
+
const path = activityLogPath();
|
|
17
|
+
if (existsSync(path) && statSync(path).size > MAX_BYTES) {
|
|
18
|
+
renameSync(path, `${path}.1`);
|
|
19
|
+
}
|
|
20
|
+
appendFileSync(path, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// The log is a courtesy to the administrator, never a reason to fail.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const colors = () => {
|
|
27
|
+
const on = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
|
|
28
|
+
return {
|
|
29
|
+
blue: on ? '\x1b[34m' : '',
|
|
30
|
+
cyan: on ? '\x1b[36m' : '',
|
|
31
|
+
green: on ? '\x1b[32m' : '',
|
|
32
|
+
yellow: on ? '\x1b[33m' : '',
|
|
33
|
+
red: on ? '\x1b[31m' : '',
|
|
34
|
+
dim: on ? '\x1b[2m' : '',
|
|
35
|
+
reset: on ? '\x1b[0m' : '',
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/** The same face as the rest of the Fiodos installers: orb, name, message. */
|
|
39
|
+
export function say(message, tone = 'info') {
|
|
40
|
+
const c = colors();
|
|
41
|
+
const orb = tone === 'ok' ? `${c.green}◉` : tone === 'warn' ? `${c.yellow}◉` : tone === 'error' ? `${c.red}◉` : `${c.cyan}◉`;
|
|
42
|
+
process.stderr.write(`${orb}${c.reset} ${c.blue}Fiodos${c.reset} ${message}\n`);
|
|
43
|
+
}
|
|
44
|
+
export function dim(message) {
|
|
45
|
+
const c = colors();
|
|
46
|
+
process.stderr.write(` ${c.dim}${message}${c.reset}\n`);
|
|
47
|
+
}
|
package/dist/pair.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PairedBridge } from './config.js';
|
|
2
|
+
export declare const DEFAULT_API_URL = "https://api.fiodos.com";
|
|
3
|
+
export interface PairOptions {
|
|
4
|
+
code: string;
|
|
5
|
+
target: string;
|
|
6
|
+
apiUrl?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
allowPaths?: string[];
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
export interface PairAnswer {
|
|
13
|
+
bridgeId: string;
|
|
14
|
+
token: string;
|
|
15
|
+
name: string;
|
|
16
|
+
target: string;
|
|
17
|
+
wsUrl: string;
|
|
18
|
+
}
|
|
19
|
+
/** `"Authorization: Bearer x"` → `["Authorization", "Bearer x"]`. */
|
|
20
|
+
export declare function parseHeaderFlag(raw: string): [string, string];
|
|
21
|
+
export declare function pair(options: PairOptions): Promise<PairedBridge>;
|
package/dist/pair.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend the one-time code the dashboard showed and keep what comes back.
|
|
3
|
+
*/
|
|
4
|
+
import { normalizeOrigin, normalizePrefix } from './allowlist.js';
|
|
5
|
+
import { VERSION } from './runner.js';
|
|
6
|
+
export const DEFAULT_API_URL = 'https://api.fiodos.com';
|
|
7
|
+
/** `"Authorization: Bearer x"` → `["Authorization", "Bearer x"]`. */
|
|
8
|
+
export function parseHeaderFlag(raw) {
|
|
9
|
+
const idx = raw.indexOf(':');
|
|
10
|
+
if (idx <= 0)
|
|
11
|
+
throw new Error(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
|
|
12
|
+
const name = raw.slice(0, idx).trim();
|
|
13
|
+
const value = raw.slice(idx + 1).trim();
|
|
14
|
+
if (!name || !value)
|
|
15
|
+
throw new Error(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
|
|
16
|
+
return [name, value];
|
|
17
|
+
}
|
|
18
|
+
export async function pair(options) {
|
|
19
|
+
const apiUrl = (options.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
20
|
+
const origin = normalizeOrigin(options.target);
|
|
21
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
22
|
+
const res = await fetchImpl(`${apiUrl}/v1/bridge/pair`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
25
|
+
body: JSON.stringify({ code: options.code.trim(), target: origin, name: options.name || '', version: VERSION }),
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
let data = {};
|
|
29
|
+
try {
|
|
30
|
+
data = JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Not JSON: the status carries the news.
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const detail = data.detail || {};
|
|
37
|
+
const message = String(detail.message || detail.error || data.message || `HTTP ${res.status}`);
|
|
38
|
+
throw new Error(message);
|
|
39
|
+
}
|
|
40
|
+
const answer = data;
|
|
41
|
+
if (!answer.token || !answer.bridgeId || !answer.wsUrl)
|
|
42
|
+
throw new Error('Fiodos answered without a token — try a new code.');
|
|
43
|
+
return {
|
|
44
|
+
bridgeId: answer.bridgeId,
|
|
45
|
+
name: answer.name || options.name || '',
|
|
46
|
+
apiUrl,
|
|
47
|
+
wsUrl: answer.wsUrl,
|
|
48
|
+
token: answer.token,
|
|
49
|
+
target: {
|
|
50
|
+
origin,
|
|
51
|
+
allowPaths: (options.allowPaths && options.allowPaths.length ? options.allowPaths : ['/']).map(normalizePrefix),
|
|
52
|
+
headers: { ...(options.headers || {}) },
|
|
53
|
+
},
|
|
54
|
+
pairedAt: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge at work: one outbound WebSocket to Fiodos, held open; every
|
|
3
|
+
* `request` frame judged by the allow-list and executed against the local
|
|
4
|
+
* target; the answer sent back under the same id.
|
|
5
|
+
*
|
|
6
|
+
* Frames (see backend/routes/bridge_routes.py):
|
|
7
|
+
* us → Fiodos {type:"hello", token, version, target} · {type:"ping"} ·
|
|
8
|
+
* {type:"response", id, status, headers, body} | {type:"response", id, error}
|
|
9
|
+
* Fiodos → us {type:"welcome", bridgeId, name} · {type:"pong"} ·
|
|
10
|
+
* {type:"request", id, method, path, query, headers, body} ·
|
|
11
|
+
* {type:"revoked"} · {type:"close", reason}
|
|
12
|
+
*
|
|
13
|
+
* The token travels as the first frame, not in the URL (URLs end up in access
|
|
14
|
+
* logs) and not as a header (Node's browser-shaped WebSocket has none).
|
|
15
|
+
*/
|
|
16
|
+
import { type IncomingRequest } from './allowlist.js';
|
|
17
|
+
import type { PairedBridge } from './config.js';
|
|
18
|
+
export declare const VERSION = "0.1.0";
|
|
19
|
+
export declare const SUBPROTOCOL = "fiodos-bridge";
|
|
20
|
+
export interface RunnerEvents {
|
|
21
|
+
/** The server revoked this bridge: the caller removes it from the config. */
|
|
22
|
+
onRevoked?: (bridge: PairedBridge) => void;
|
|
23
|
+
/** Another process took this bridge's place. */
|
|
24
|
+
onReplaced?: (bridge: PairedBridge) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface Executor {
|
|
27
|
+
(request: IncomingRequest): Promise<{
|
|
28
|
+
status: number;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
body: string;
|
|
31
|
+
} | {
|
|
32
|
+
error: string;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
/** Execute one judged request against the local target with fetch. */
|
|
36
|
+
export declare function makeExecutor(bridge: PairedBridge, fetchImpl?: typeof fetch): Executor;
|
|
37
|
+
/**
|
|
38
|
+
* Hold the socket for one bridge until it is revoked or replaced. Resolves
|
|
39
|
+
* with `'revoked' | 'replaced'`; a dropped connection reconnects with backoff
|
|
40
|
+
* and never resolves on its own.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runBridge(bridge: PairedBridge, events?: RunnerEvents, executor?: Executor): Promise<'revoked' | 'replaced'>;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge at work: one outbound WebSocket to Fiodos, held open; every
|
|
3
|
+
* `request` frame judged by the allow-list and executed against the local
|
|
4
|
+
* target; the answer sent back under the same id.
|
|
5
|
+
*
|
|
6
|
+
* Frames (see backend/routes/bridge_routes.py):
|
|
7
|
+
* us → Fiodos {type:"hello", token, version, target} · {type:"ping"} ·
|
|
8
|
+
* {type:"response", id, status, headers, body} | {type:"response", id, error}
|
|
9
|
+
* Fiodos → us {type:"welcome", bridgeId, name} · {type:"pong"} ·
|
|
10
|
+
* {type:"request", id, method, path, query, headers, body} ·
|
|
11
|
+
* {type:"revoked"} · {type:"close", reason}
|
|
12
|
+
*
|
|
13
|
+
* The token travels as the first frame, not in the URL (URLs end up in access
|
|
14
|
+
* logs) and not as a header (Node's browser-shaped WebSocket has none).
|
|
15
|
+
*/
|
|
16
|
+
import { judge, outboundHeaders } from './allowlist.js';
|
|
17
|
+
import { logActivity, say, dim } from './log.js';
|
|
18
|
+
export const VERSION = '0.1.0';
|
|
19
|
+
export const SUBPROTOCOL = 'fiodos-bridge';
|
|
20
|
+
const PING_MS = 20_000;
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 55_000;
|
|
22
|
+
const MAX_BODY_CHARS = 2_000_000;
|
|
23
|
+
const BACKOFF_MIN_MS = 1_000;
|
|
24
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
25
|
+
/** Execute one judged request against the local target with fetch. */
|
|
26
|
+
export function makeExecutor(bridge, fetchImpl = fetch) {
|
|
27
|
+
return async (request) => {
|
|
28
|
+
const verdict = judge(bridge.target, request);
|
|
29
|
+
if (!verdict.ok) {
|
|
30
|
+
logActivity(`REFUSED ${request.method} ${request.path} — ${verdict.reason}`);
|
|
31
|
+
return { error: verdict.reason };
|
|
32
|
+
}
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
try {
|
|
37
|
+
const hasBody = request.body != null && verdict.method !== 'GET';
|
|
38
|
+
const res = await fetchImpl(verdict.url, {
|
|
39
|
+
method: verdict.method,
|
|
40
|
+
headers: outboundHeaders(bridge.target, request.headers),
|
|
41
|
+
body: hasBody ? String(request.body) : undefined,
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
redirect: 'manual',
|
|
44
|
+
});
|
|
45
|
+
let body = await res.text();
|
|
46
|
+
if (body.length > MAX_BODY_CHARS)
|
|
47
|
+
body = body.slice(0, MAX_BODY_CHARS);
|
|
48
|
+
const headers = {};
|
|
49
|
+
const ct = res.headers.get('content-type');
|
|
50
|
+
if (ct)
|
|
51
|
+
headers['content-type'] = ct;
|
|
52
|
+
logActivity(`${verdict.method} ${request.path} → ${res.status} (${Date.now() - started}ms)`);
|
|
53
|
+
return { status: res.status, headers, body };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const reason = controller.signal.aborted ? `local service did not answer in ${REQUEST_TIMEOUT_MS / 1000}s` : `local service unreachable: ${err.message}`;
|
|
57
|
+
logActivity(`${verdict.method} ${request.path} → ERROR ${reason}`);
|
|
58
|
+
return { error: reason };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function backoff(attempt) {
|
|
66
|
+
const ms = Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** Math.min(attempt, 6));
|
|
67
|
+
return ms + Math.floor(Math.random() * 500);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Hold the socket for one bridge until it is revoked or replaced. Resolves
|
|
71
|
+
* with `'revoked' | 'replaced'`; a dropped connection reconnects with backoff
|
|
72
|
+
* and never resolves on its own.
|
|
73
|
+
*/
|
|
74
|
+
export function runBridge(bridge, events = {}, executor = makeExecutor(bridge)) {
|
|
75
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
76
|
+
if (!WebSocketCtor)
|
|
77
|
+
throw new Error('This Node.js has no WebSocket client. Node 22.5 or newer is required.');
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let attempt = 0;
|
|
80
|
+
let settled = false;
|
|
81
|
+
const finish = (why) => {
|
|
82
|
+
if (settled)
|
|
83
|
+
return;
|
|
84
|
+
settled = true;
|
|
85
|
+
if (why === 'revoked')
|
|
86
|
+
events.onRevoked?.(bridge);
|
|
87
|
+
else
|
|
88
|
+
events.onReplaced?.(bridge);
|
|
89
|
+
resolve(why);
|
|
90
|
+
};
|
|
91
|
+
const connect = () => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
let ws;
|
|
95
|
+
try {
|
|
96
|
+
ws = new WebSocketCtor(bridge.wsUrl, [SUBPROTOCOL]);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
say(`could not open the connection: ${err.message}`, 'error');
|
|
100
|
+
setTimeout(connect, backoff(attempt++));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let pingTimer = null;
|
|
104
|
+
let welcomed = false;
|
|
105
|
+
ws.addEventListener('open', () => {
|
|
106
|
+
ws.send(JSON.stringify({ type: 'hello', token: bridge.token, version: VERSION, target: bridge.target.origin }));
|
|
107
|
+
pingTimer = setInterval(() => {
|
|
108
|
+
try {
|
|
109
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// The close handler will reconnect.
|
|
113
|
+
}
|
|
114
|
+
}, PING_MS);
|
|
115
|
+
});
|
|
116
|
+
ws.addEventListener('message', (event) => {
|
|
117
|
+
let frame;
|
|
118
|
+
try {
|
|
119
|
+
frame = JSON.parse(String(event.data));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const type = String(frame.type || '');
|
|
125
|
+
if (type === 'welcome') {
|
|
126
|
+
welcomed = true;
|
|
127
|
+
attempt = 0;
|
|
128
|
+
say(`connected — ${bridge.name || bridge.bridgeId} is reachable by your company agent`, 'ok');
|
|
129
|
+
dim(`serving ${bridge.target.origin} · Ctrl-C to stop`);
|
|
130
|
+
logActivity(`CONNECTED ${bridge.wsUrl}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (type === 'pong')
|
|
134
|
+
return;
|
|
135
|
+
if (type === 'revoked') {
|
|
136
|
+
say('this bridge was revoked from the Fiodos dashboard — stopping.', 'warn');
|
|
137
|
+
logActivity('REVOKED by the dashboard');
|
|
138
|
+
finish('revoked');
|
|
139
|
+
try {
|
|
140
|
+
ws.close(1000, 'revoked');
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Already closing.
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (type === 'close' && String(frame.reason || '') === 'replaced') {
|
|
148
|
+
say('another bridge process connected with this pairing — this one exits.', 'warn');
|
|
149
|
+
logActivity('REPLACED by another process');
|
|
150
|
+
finish('replaced');
|
|
151
|
+
try {
|
|
152
|
+
ws.close(1000, 'replaced');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Already closing.
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (type === 'request') {
|
|
160
|
+
const id = String(frame.id || '');
|
|
161
|
+
const request = {
|
|
162
|
+
method: String(frame.method || 'GET'),
|
|
163
|
+
path: String(frame.path || '/'),
|
|
164
|
+
query: frame.query || {},
|
|
165
|
+
headers: frame.headers || {},
|
|
166
|
+
body: typeof frame.body === 'string' ? frame.body : null,
|
|
167
|
+
};
|
|
168
|
+
void executor(request).then((answer) => {
|
|
169
|
+
try {
|
|
170
|
+
ws.send(JSON.stringify({ type: 'response', id, ...answer }));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Socket gone; the server times the request out.
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const onGone = (what) => {
|
|
179
|
+
if (pingTimer)
|
|
180
|
+
clearInterval(pingTimer);
|
|
181
|
+
pingTimer = null;
|
|
182
|
+
if (settled)
|
|
183
|
+
return;
|
|
184
|
+
const wait = backoff(attempt++);
|
|
185
|
+
if (welcomed)
|
|
186
|
+
logActivity(`DISCONNECTED (${what})`);
|
|
187
|
+
say(`${what} — reconnecting in ${Math.round(wait / 1000)}s`, 'warn');
|
|
188
|
+
setTimeout(connect, wait);
|
|
189
|
+
};
|
|
190
|
+
ws.addEventListener('close', (raw) => {
|
|
191
|
+
const event = raw;
|
|
192
|
+
if (event.code === 4401) {
|
|
193
|
+
say('Fiodos did not accept this bridge\u2019s token. Pair it again from the dashboard.', 'error');
|
|
194
|
+
logActivity('REFUSED token (4401)');
|
|
195
|
+
finish('revoked');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
onGone(`connection closed (${event.code})`);
|
|
199
|
+
});
|
|
200
|
+
ws.addEventListener('error', () => {
|
|
201
|
+
// `close` follows an error; the message there is enough.
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
connect();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const SERVICE_LABEL = "com.fiodos.bridge";
|
|
2
|
+
export interface ServicePlan {
|
|
3
|
+
platform: string;
|
|
4
|
+
path: string | null;
|
|
5
|
+
contents: string | null;
|
|
6
|
+
commands: string[];
|
|
7
|
+
notes: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function plan(): ServicePlan;
|
|
10
|
+
export declare function install(): ServicePlan;
|
|
11
|
+
export declare function uninstall(): ServicePlan;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fiodos-bridge service install` — start with the machine.
|
|
3
|
+
*
|
|
4
|
+
* macOS: a launchd agent in ~/Library/LaunchAgents (runs when the user is
|
|
5
|
+
* logged in — a headless server should use a LaunchDaemon, which needs root
|
|
6
|
+
* and is printed as instructions). Linux: a systemd --user unit, with the
|
|
7
|
+
* `loginctl enable-linger` reminder so it survives logout. Windows: printed
|
|
8
|
+
* instructions (Task Scheduler), because writing a service there needs tools
|
|
9
|
+
* this package does not ship.
|
|
10
|
+
*
|
|
11
|
+
* Every file written is shown, so the administrator can read what runs.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir, platform } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { bridgeHome } from './config.js';
|
|
18
|
+
export const SERVICE_LABEL = 'com.fiodos.bridge';
|
|
19
|
+
function nodeBinary() {
|
|
20
|
+
return process.execPath;
|
|
21
|
+
}
|
|
22
|
+
function bridgeEntry() {
|
|
23
|
+
// The compiled CLI of THIS installation, so the service runs the same code.
|
|
24
|
+
return new URL('./cli.js', import.meta.url).pathname;
|
|
25
|
+
}
|
|
26
|
+
export function plan() {
|
|
27
|
+
const os = platform();
|
|
28
|
+
const node = nodeBinary();
|
|
29
|
+
const entry = bridgeEntry();
|
|
30
|
+
const home = bridgeHome();
|
|
31
|
+
if (os === 'darwin') {
|
|
32
|
+
const path = join(homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
|
|
33
|
+
const contents = `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>
|
|
40
|
+
<string>${node}</string>
|
|
41
|
+
<string>${entry}</string>
|
|
42
|
+
<string>run</string>
|
|
43
|
+
</array>
|
|
44
|
+
<key>EnvironmentVariables</key>
|
|
45
|
+
<dict><key>FIODOS_BRIDGE_HOME</key><string>${home}</string></dict>
|
|
46
|
+
<key>RunAtLoad</key><true/>
|
|
47
|
+
<key>KeepAlive</key><true/>
|
|
48
|
+
<key>StandardOutPath</key><string>${join(home, 'service.log')}</string>
|
|
49
|
+
<key>StandardErrorPath</key><string>${join(home, 'service.log')}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
return {
|
|
54
|
+
platform: os,
|
|
55
|
+
path,
|
|
56
|
+
contents,
|
|
57
|
+
commands: [`launchctl unload ${path} 2>/dev/null || true`, `launchctl load -w ${path}`],
|
|
58
|
+
notes: [
|
|
59
|
+
'Runs while this user is logged in. For a server nobody logs into, copy the plist to /Library/LaunchDaemons (as root) and load it there.',
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (os === 'linux') {
|
|
64
|
+
const dir = join(homedir(), '.config', 'systemd', 'user');
|
|
65
|
+
const path = join(dir, 'fiodos-bridge.service');
|
|
66
|
+
const contents = `[Unit]
|
|
67
|
+
Description=Fiodos bridge (reaches a dashboard on this network for the Fiodos company agent)
|
|
68
|
+
After=network-online.target
|
|
69
|
+
|
|
70
|
+
[Service]
|
|
71
|
+
ExecStart=${node} ${entry} run
|
|
72
|
+
Environment=FIODOS_BRIDGE_HOME=${home}
|
|
73
|
+
Restart=always
|
|
74
|
+
RestartSec=5
|
|
75
|
+
|
|
76
|
+
[Install]
|
|
77
|
+
WantedBy=default.target
|
|
78
|
+
`;
|
|
79
|
+
return {
|
|
80
|
+
platform: os,
|
|
81
|
+
path,
|
|
82
|
+
contents,
|
|
83
|
+
commands: ['systemctl --user daemon-reload', 'systemctl --user enable --now fiodos-bridge.service'],
|
|
84
|
+
notes: ['So it keeps running after you log out: sudo loginctl enable-linger $USER'],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
platform: os,
|
|
89
|
+
path: null,
|
|
90
|
+
contents: null,
|
|
91
|
+
commands: [],
|
|
92
|
+
notes: [
|
|
93
|
+
'Automatic service install is not available on this platform.',
|
|
94
|
+
`Run at start-up with your scheduler: "${node}" "${entry}" run`,
|
|
95
|
+
`Keep FIODOS_BRIDGE_HOME=${home} in its environment.`,
|
|
96
|
+
'Or run the container image: docker run -d --restart unless-stopped -v fiodos-bridge:/data --network host fiodos/bridge run',
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function install() {
|
|
101
|
+
const p = plan();
|
|
102
|
+
if (!p.path || !p.contents)
|
|
103
|
+
return p;
|
|
104
|
+
mkdirSync(join(p.path, '..'), { recursive: true });
|
|
105
|
+
mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(p.path, p.contents, { mode: 0o644 });
|
|
107
|
+
for (const command of p.commands) {
|
|
108
|
+
const [bin, ...args] = command.replace(/ 2>\/dev\/null \|\| true$/, '').split(' ');
|
|
109
|
+
try {
|
|
110
|
+
execFileSync(bin, args, { stdio: 'ignore' });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// `unload` of a not-yet-loaded agent fails harmlessly; anything else is
|
|
114
|
+
// reported by `status`.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return p;
|
|
118
|
+
}
|
|
119
|
+
export function uninstall() {
|
|
120
|
+
const p = plan();
|
|
121
|
+
if (!p.path)
|
|
122
|
+
return p;
|
|
123
|
+
if (p.platform === 'darwin') {
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('launchctl', ['unload', p.path], { stdio: 'ignore' });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Not loaded.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (p.platform === 'linux') {
|
|
132
|
+
try {
|
|
133
|
+
execFileSync('systemctl', ['--user', 'disable', '--now', 'fiodos-bridge.service'], { stdio: 'ignore' });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Not enabled.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (existsSync(p.path))
|
|
140
|
+
unlinkSync(p.path);
|
|
141
|
+
return p;
|
|
142
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fiodos/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Fiodos bridge — a small process that runs beside a dashboard on your own network and lets your Fiodos company agent reach it. It calls Fiodos (one outbound WebSocket); nothing calls in. Pair it with the one-time code your dashboard shows: `npx -y --loglevel=error @fiodos/bridge pair <code> --target http://localhost:3947`.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"fiodos-bridge": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"Dockerfile",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.5"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"tsx": "^4.19.0",
|
|
38
|
+
"typescript": "^5.6.0"
|
|
39
|
+
}
|
|
40
|
+
}
|