@serfab/cadre-host 0.7.1 → 0.8.1
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/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +44 -6
- package/dist/auth/trust-circle.js +115 -58
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/authority/authority-node-client.d.ts +77 -0
- package/dist/authority/authority-node-client.js +143 -0
- package/dist/authority/authority-node-client.js.map +1 -0
- package/dist/authority/index.d.ts +11 -0
- package/dist/authority/index.js +11 -0
- package/dist/authority/index.js.map +1 -0
- package/dist/bin/host.js +210 -97
- package/dist/bin/host.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +25 -0
- package/dist/installer/config.js +1 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +5 -2
- package/dist/installer/index.js +14 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
- package/dist/orchestrator/host-process-orchestrator.js +246 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +2 -0
- package/dist/server/error-handler.js +4 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +4 -3
- package/dist/server/routes/nodes.js +38 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +28 -15
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-authority.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-authority.test.ts +198 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
- package/src/auth/__tests__/trust-circle.test.ts +90 -23
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/authority/__tests__/authority-node-client.test.ts +166 -0
- package/src/authority/authority-node-client.ts +199 -0
- package/src/authority/index.ts +12 -0
- package/src/bin/host.ts +1038 -905
- package/src/index.ts +34 -3
- package/src/installer/__tests__/installer.smoke.test.ts +64 -0
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +27 -1
- package/src/installer/index.ts +18 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +294 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +4 -4
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +2 -2
- package/src/server/error-handler.ts +4 -0
- package/src/server/routes/nodes.ts +48 -26
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +29 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +30 -16
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/README.md
CHANGED
|
@@ -1,101 +1,296 @@
|
|
|
1
|
-
# @serfab/cadre-host
|
|
2
|
-
|
|
3
|
-
Self-hosted cadre node manager for basement-PC deployments. Runs one always-on host machine, manages cadre nodes for a small trust circle (family, friends, hobby group), exposes a localhost web UI, and handles NAT/DDNS so members behind residential connections can still be reached.
|
|
4
|
-
|
|
5
|
-
The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider is a multi-tenant hosting service with API keys, billing, and Docker; cadre-host is a single-household manager with trust-circle auth, native child processes, and an installer.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
# @serfab/cadre-host
|
|
2
|
+
|
|
3
|
+
Self-hosted cadre node manager for basement-PC deployments. Runs one always-on host machine, manages cadre nodes for a small trust circle (family, friends, hobby group), exposes a localhost web UI, and handles NAT/DDNS so members behind residential connections can still be reached.
|
|
4
|
+
|
|
5
|
+
The sibling of [`@serfab/cadre-provider`](../cadre-provider/README.md): the provider is a multi-tenant hosting service with API keys, billing, and Docker; cadre-host is a single-household manager with trust-circle auth, native child processes, and an installer.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
cadre-host installs entirely within your user account. **No step requires root**, with one optional exception (`loginctl enable-linger`) called out under *Root requirements* below.
|
|
10
|
+
|
|
11
|
+
Pick whichever install style suits you:
|
|
12
|
+
|
|
13
|
+
### Global install (binary on PATH)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @serfab/cadre-host # sudo only if your npm prefix is system-owned (e.g. /usr/lib)
|
|
17
|
+
cadre-host install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Local install (self-contained — recommended if you want everything under one folder)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
mkdir -p ~/cadre && cd ~/cadre
|
|
24
|
+
npm install @serfab/cadre-host
|
|
25
|
+
npx cadre-host install --data-dir ~/cadre/data
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From inside `~/cadre`, `npx cadre-host <command>` runs the local binary (`invite`, `trust`, `nat`, `uninstall`, …) without any path prefix — `npx` resolves it from `node_modules/.bin/`. **Caveat:** outside `~/cadre`, `npx cadre-host` won't find the local install and will silently download a fresh copy from the npm registry. To avoid that, either always `cd ~/cadre` first or symlink the binary onto your PATH:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ln -s ~/cadre/node_modules/.bin/cadre-host ~/.local/bin/cadre-host
|
|
32
|
+
# now `cadre-host invite …` works from anywhere
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If you'd rather skip `npx` and the symlink, the explicit path `./node_modules/.bin/cadre-host <command>` always works from `~/cadre` too.
|
|
36
|
+
|
|
37
|
+
The wizard (run either way):
|
|
38
|
+
|
|
39
|
+
1. Prompts for the data directory, UI port, libp2p port, and UPnP toggle (defaults shown in `[...]`).
|
|
40
|
+
2. Generates a fresh Ed25519 node identity (`<dataDir>/identity.key`, mode 600 on POSIX).
|
|
41
|
+
3. Writes `<dataDir>/host.config.json` and seeds `<dataDir>/nat.json` with the chosen libp2p port.
|
|
42
|
+
4. Registers a per-user service: `systemctl --user` unit (Linux), `LaunchAgent` (macOS), or NSSM service (Windows; requires `nssm.exe` on PATH — see `service/README.md`).
|
|
43
|
+
5. Opens `http://127.0.0.1:<uiPort>/` in your browser.
|
|
44
|
+
6. Issues a 24-hour enrollment invite and prints it as both a QR code and a paste-friendly token.
|
|
45
|
+
|
|
46
|
+
Run `cadre-host install --non-interactive --data-dir <path>` for unattended provisioning.
|
|
47
|
+
|
|
48
|
+
### Root requirements
|
|
49
|
+
|
|
50
|
+
The installer never writes outside your home directory. On Linux the unit goes to `$XDG_CONFIG_HOME/systemd/user/cadre-host.service` (default `~/.config/systemd/user/cadre-host.service`); the data dir defaults to `$XDG_DATA_HOME/cadre-host` (default `~/.local/share/cadre-host`) or wherever you point `--data-dir`. Registering a `systemctl --user` unit does **not** require root — any user can do it.
|
|
51
|
+
|
|
52
|
+
There is **one optional** root command:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sudo loginctl enable-linger <your-user>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`enable-linger` is what allows your `systemctl --user` services to start at boot and keep running after you log out. Without linger, cadre-host runs only while you have an active session (desktop login or SSH) and stops when that session ends. With linger, it runs whenever the machine is on.
|
|
59
|
+
|
|
60
|
+
The installer attempts `loginctl enable-linger` for you and logs a warning if it can't (you aren't root, `loginctl` is missing, running inside a container, …). **The install succeeds either way** — you can enable linger separately, before or after `cadre-host install`. Without linger you can still verify everything by logging in and running `systemctl --user start cadre-host` by hand.
|
|
61
|
+
|
|
62
|
+
### System-wide install (not yet supported)
|
|
63
|
+
|
|
64
|
+
`cadre-host install --system` is accepted by the CLI but currently errors out. A proper system-wide install — dedicated `cadre` user, unit at `/etc/systemd/system/`, install under `/opt/cadre/...` — needs additional work around data-dir ownership and capability dropping and is tracked separately.
|
|
65
|
+
|
|
66
|
+
### Service-host details
|
|
67
|
+
|
|
68
|
+
The rendered unit files live at:
|
|
69
|
+
|
|
70
|
+
| Platform | Path |
|
|
71
|
+
| -------- | ---- |
|
|
72
|
+
| Linux | `~/.config/systemd/user/cadre-host.service` |
|
|
73
|
+
| macOS | `~/Library/LaunchAgents/com.serfab.cadre-host.plist` |
|
|
74
|
+
| Windows | NSSM-managed service `CadreHost` (registry-stored config) |
|
|
75
|
+
|
|
76
|
+
See [`service/README.md`](./service/README.md) for templates, manual-smoke instructions, and the cross-platform CI gap.
|
|
77
|
+
|
|
78
|
+
## After install — getting your first user running
|
|
79
|
+
|
|
80
|
+
`cadre-host install` leaves you with a running management service, a local UI, and **no cadre nodes yet**. In v1, cadre-host does not pre-spawn nodes; each one materializes when a person you trust enrolls a device against an invite you issue. This walkthrough goes from "install just finished" to "first member is enrolled."
|
|
81
|
+
|
|
82
|
+
### 1. Verify the service is running
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
systemctl --user status cadre-host
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Look for `Active: active (running)`. If it's not active, `journalctl --user -u cadre-host -f` shows the live log.
|
|
89
|
+
|
|
90
|
+
### 2. Open the local UI
|
|
91
|
+
|
|
92
|
+
The UI listens on `http://127.0.0.1:<uiPort>/` (default port 8765) on the host machine itself.
|
|
93
|
+
|
|
94
|
+
- **On the host:** open the URL in a browser, or run `cadre-host ui` to print + open it.
|
|
95
|
+
- **From a different machine on your LAN** (most basement PCs are headless): the UI is bound to loopback only and has no authentication — that's intentional, the trust boundary is "same machine, same user as cadre-host." Use SSH port-forwarding from the client side:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# from your laptop:
|
|
99
|
+
ssh -L 8765:127.0.0.1:8765 user@my-basement-pc
|
|
100
|
+
# then open http://127.0.0.1:8765 in the laptop's browser
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The forward stays up as long as the SSH session does.
|
|
104
|
+
|
|
105
|
+
### 3. Issue your first invite
|
|
106
|
+
|
|
107
|
+
Decide whose cadre you want to host first — yourself (from your phone), a family member, or a friend. You'll deliver the invite to that device out-of-band (in person, Signal, etc.).
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cadre-host invite "<label>"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`<label>` is a human-readable name **you** choose to identify this device or person in your member list. It's purely for your own bookkeeping — the system doesn't use it for anything. Quote it if it has spaces. Examples:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cadre-host invite "Mom's phone"
|
|
117
|
+
cadre-host invite mylaptop
|
|
118
|
+
cadre-host invite "Friend — tablet"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The command prints two things — a terminal-rendered QR code and an encoded invite string — both representing the same single-use token. The recipient can use either form. By default the token expires in 24 hours; override with `--ttl 7d` (or `30m`, `1h`, etc.).
|
|
122
|
+
|
|
123
|
+
Hand the QR or token to the device's owner in person if possible. **Anyone who gets the token can claim the cadre identity it grants**, so treat it like a one-time password.
|
|
124
|
+
|
|
125
|
+
Terminology aside: cadre-host's **trust circle** is just the cryptographically-authenticated list of identities allowed to have a cadre node on this host. An invite adds one new identity to that list.
|
|
126
|
+
|
|
127
|
+
### 4. The invitee redeems the invite
|
|
128
|
+
|
|
129
|
+
On the invitee's device, they install a cadre-aware app — the React Native reference app at [`@serfab/reference-app-rn`](../reference-app-rn), or any sApp built on `@serfab/cadre-core` — and scan or paste the invite. The app dials your cadre-host over libp2p (NAT/DDNS permitting; see step 6) and redeems the token. At that moment:
|
|
130
|
+
|
|
131
|
+
- A child cadre node spawns inside your cadre-host process for this member's identity.
|
|
132
|
+
- The member appears in `cadre-host trust list` and on the Trust Circle and Nodes pages of the UI.
|
|
133
|
+
- The invitee's device can now use that cadre to participate in strands (shared SQL databases) and connect to other members.
|
|
134
|
+
|
|
135
|
+
Until someone redeems an invite, the Nodes page in the UI stays empty — that's expected.
|
|
136
|
+
|
|
137
|
+
### 5. Manage members
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
$ cadre-host trust list
|
|
141
|
+
Members:
|
|
142
|
+
12D3KooWHcdEf... kyle's laptop [self]
|
|
143
|
+
12D3KooW2aB... Mom's phone
|
|
144
|
+
|
|
145
|
+
Pending invites:
|
|
146
|
+
invite_a3f8... Friend — tablet (expires 2026-06-01T12:00:00Z)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A **peerId** is a libp2p identity string — for the common Ed25519 case it starts with `12D3Koo...` (you'll also see `16Uiu2HAm...` for secp256k1 or `Qm...` for legacy RSA). Each member has exactly one.
|
|
150
|
+
|
|
151
|
+
Remove a pending invite or evict an existing member:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
cadre-host trust revoke <token-or-peerId>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
cadre-host auto-detects whether the argument is a pending-invite token or a member peerId. Force interpretation with `--kind invite` or `--kind member` if it ever guesses wrong.
|
|
158
|
+
|
|
159
|
+
### 6. Reachability — can people actually reach you?
|
|
160
|
+
|
|
161
|
+
After install (and any time your network changes):
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
cadre-host nat status # current external IP, port-mapping state, reachability
|
|
165
|
+
cadre-host nat test # re-run the reachability probe right now
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If `Reachability` shows anything other than `direct`, peers will fall back to libp2p relays — slower but still functional. If UPnP didn't work on your router, the UI's **Connectivity** page has manual port-forwarding instructions.
|
|
169
|
+
|
|
170
|
+
When your residential IP changes (it will), members can't find you on the old IP. DDNS (a hostname that auto-updates to your current IP) fixes this:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# DuckDNS — cadre-host updates the record itself. Sign up at duckdns.org and grab a token first.
|
|
174
|
+
cadre-host nat ddns set duckdns --hostname mybox.duckdns.org --token <duckdns-token>
|
|
175
|
+
|
|
176
|
+
# Externally-managed — your router or another tool updates DNS; cadre-host just records the hostname.
|
|
177
|
+
cadre-host nat ddns external --hostname mybox.example.com
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
If you use the DuckDNS form, the token is stored in the OS keychain when `libsecret` is installed (`sudo apt install libsecret-1-0` on Debian/Ubuntu), and unencrypted in `<dataDir>/nat-secrets.json` otherwise. The service logs a warning at startup when it falls back to unencrypted storage.
|
|
181
|
+
|
|
182
|
+
## CLI reference
|
|
183
|
+
|
|
184
|
+
All commands except `install`, `uninstall`, `start`, and `ui` talk to the running cadre-host management API over loopback. They print a connection error if the service isn't running.
|
|
185
|
+
|
|
186
|
+
### `cadre-host status`
|
|
187
|
+
|
|
188
|
+
Print whether the service is registered and currently active.
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
$ cadre-host status
|
|
192
|
+
Service installed: yes
|
|
193
|
+
Service running: yes
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `cadre-host ui [--no-browser]`
|
|
197
|
+
|
|
198
|
+
Print the local-UI URL (e.g. `http://127.0.0.1:8765`) and open it in the default browser. Reads `uiPort` from `host.config.json`; doesn't require the service to be running (if not, the browser will fail to connect — that's feedback enough). Pass `--no-browser` to just print the URL.
|
|
199
|
+
|
|
200
|
+
### `cadre-host invite <label> [--ttl <duration>]`
|
|
201
|
+
|
|
202
|
+
Generate a single-use invite token to add a member to your trust circle. `<label>` is whatever human-readable name helps you track the invitee — quote it if it has spaces. `--ttl` defaults to `24h`; accepts `s`/`m`/`h`/`d` suffixes (e.g. `7d`, `30m`). Prints a QR code and the encoded invite string.
|
|
203
|
+
|
|
204
|
+
### `cadre-host trust list`
|
|
205
|
+
|
|
206
|
+
Print all current trust-circle members (with peerIds) and pending invites (with tokens and expiry).
|
|
207
|
+
|
|
208
|
+
### `cadre-host trust revoke <token-or-peerId> [--kind invite|member|auto]`
|
|
209
|
+
|
|
210
|
+
Revoke a pending invite (by token) or evict an existing member (by peerId). `--kind` defaults to `auto`, which decides based on whether the argument looks like a peerId.
|
|
211
|
+
|
|
212
|
+
### `cadre-host nat status [--json]`
|
|
213
|
+
|
|
214
|
+
Print current NAT state — port-mapping mode, external IP, CGNAT detection, direct-reachability result, DDNS configuration. With `--json`, dumps the raw response from the management API.
|
|
215
|
+
|
|
216
|
+
### `cadre-host nat test [--json]`
|
|
217
|
+
|
|
218
|
+
Re-run the reachability probe right now and print the updated NAT state. Useful after changing port-forwarding rules on your router.
|
|
219
|
+
|
|
220
|
+
### `cadre-host nat settings [--external-port N] [--internal-port N] [--no-upnp]`
|
|
221
|
+
|
|
222
|
+
Adjust NAT settings. Pass only the flag(s) you want to change.
|
|
223
|
+
|
|
224
|
+
### `cadre-host nat ddns set <provider> --hostname <h> [--token <t>]`
|
|
225
|
+
|
|
226
|
+
Configure cadre-host to push DNS updates itself. Currently the only `<provider>` is `duckdns`. If `--token` is omitted, the command prompts for it (with echo suppressed) when stdin is a TTY. The token is stored in the OS keychain when available, otherwise unencrypted at `<dataDir>/nat-secrets.json` (a startup warning surfaces this).
|
|
227
|
+
|
|
228
|
+
### `cadre-host nat ddns external --hostname <h>`
|
|
229
|
+
|
|
230
|
+
Tell cadre-host that some other tool (your router firmware, a separate `ddclient`, etc.) is updating DNS, and to record the hostname for publishing to peers without trying to update it itself.
|
|
231
|
+
|
|
232
|
+
### `cadre-host start [--data-dir <path>] [--no-tui]`
|
|
233
|
+
|
|
234
|
+
Run cadre-host in the foreground. Normally invoked by the systemd unit, not directly. `--data-dir` overrides the install-time data directory (also honors `$CADRE_HOST_DATA_DIR`).
|
|
235
|
+
|
|
236
|
+
### `cadre-host install [...flags]`
|
|
237
|
+
|
|
238
|
+
Run the first-run wizard. See [**Install**](#install) at the top of this README.
|
|
239
|
+
|
|
240
|
+
### `cadre-host uninstall [--remove-data] [--yes]`
|
|
241
|
+
|
|
242
|
+
Stop and deregister the service. Preserves the data dir by default; pass `--remove-data --yes` to wipe identity, trust circle, NAT state, and update state too.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
cadre-host uninstall # stop + deregister, keep data
|
|
246
|
+
cadre-host uninstall --remove-data --yes # also delete the data dir
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## What `cadre-host start` does today
|
|
250
|
+
|
|
251
|
+
`start` loads `host.config.json` + the identity, brings up the trust-circle / NAT / update services, and binds the Fastify management server on `127.0.0.1:<uiPort>` (loopback only). Routes:
|
|
252
|
+
|
|
253
|
+
- `/auth/*` (trust circle), `/nat/*` (NAT/DDNS), `/update/*` (update flow) — these match the CLI's contract.
|
|
254
|
+
- `/api/status`, `/api/nodes`, `/api/nodes/:id/{logs,stop,start,restart}`, `/api/settings`, `/api/events` (Server-Sent Events) — the local-UI surface consumed by the Svelte SPA.
|
|
255
|
+
- `/` — the SPA bundle (or a placeholder HTML when running from source before the SPA is built — see `6.5.2-cadre-host-local-ui-spa`).
|
|
256
|
+
|
|
257
|
+
If the configured `uiPort` is in use the server tries `uiPort+1..uiPort+9`; on total failure it exits with a message listing every port attempted. An origin guard rejects requests whose `Host` or `Origin` is not `127.0.0.1[:port]` / `localhost[:port]` (defeats DNS-rebind from a malicious page). There is no login — the security model is "same machine as the cadre-host user" (see threat model below).
|
|
258
|
+
|
|
259
|
+
## Updates
|
|
260
|
+
|
|
261
|
+
cadre-host checks `https://releases.serfab.io/cadre-host/latest.json` once per `start` and every 24 hours thereafter. **Notify-by-default**: an available update is recorded in `<dataDir>/update-state.json` and surfaced by the local UI; the user clicks "apply" to install it. Auto-apply is opt-in via the local-UI settings page (writes `updates.autoApply: true` into `host.config.json`).
|
|
262
|
+
|
|
263
|
+
The manifest URL is overridable two ways:
|
|
264
|
+
- `CADRE_HOST_UPDATE_MANIFEST_URL` env var (wins over config).
|
|
265
|
+
- `updates.manifestUrl` in `host.config.json` (settable from the local UI).
|
|
266
|
+
|
|
267
|
+
Manifests are signed with Ed25519; cadre-host refuses to apply any release whose signature doesn't match the embedded release key. For CI / dev signing, set `CADRE_HOST_UPDATE_DEV_KEY` to a base64-encoded raw 32-byte public key.
|
|
268
|
+
|
|
269
|
+
**Threat model.** Any local process running as the cadre-host user can fully control cadre-host (read identity, mutate trust circle, install arbitrary global packages). Signature verification protects against a compromised release CDN — it is **not** a defense against local-machine compromise. Treat the host like any other long-running service: limit who can run shells as that user, keep the OS patched, and rely on the trust-circle membership model for inter-cadre auth.
|
|
270
|
+
|
|
271
|
+
Apply flow: re-fetch + re-verify the manifest, record `applyInProgress`, run `npm install -g @serfab/cadre-host@<version>` (5-minute timeout), and restart the OS service unit so the new binary takes effect. On install failure, the previous version is reinstalled and the error is surfaced via `update-state.json` — the still-running binary continues to serve. The service-host restart is best-effort; if it fails, the binary swap already succeeded and the user can restart manually.
|
|
272
|
+
|
|
273
|
+
## Local UI
|
|
274
|
+
|
|
275
|
+
`cadre-host start` serves a Svelte 5 SPA at `http://127.0.0.1:<uiPort>/`. **Local-only by design:** the server binds to loopback (`127.0.0.1`) only and rejects requests whose `Host` or `Origin` header is not a loopback hostname, so the UI is unreachable from your LAN even though it has no login. To use it from another machine, SSH-port-forward as shown in [*After install*, step 2](#2-open-the-local-ui).
|
|
276
|
+
|
|
277
|
+
Five pages cover the day-to-day operations:
|
|
278
|
+
|
|
279
|
+
- **Home / Status** — green/yellow/red dot, service version + uptime, trust-circle size, connectivity at a glance, "update available" banner.
|
|
280
|
+
- **Trust Circle** — list members, invite a friend (modal generates a paste-friendly token + QR), revoke pending invites or remove members.
|
|
281
|
+
- **Connectivity** — port-forwarding status, "Test reachability", DDNS provider configuration, manual port-forward instructions when UPnP isn't working.
|
|
282
|
+
- **Nodes** — per-managed-node detail, recent stats, log tail (last 200 lines, "Refresh" pulls again), start/stop/restart. `cadre-host` v1 doesn't auto-spawn nodes, so this list is usually empty until a trust-circle member completes enrollment.
|
|
283
|
+
- **Settings** — update preferences (autoApply toggle, manifest URL override), install metadata (install ID, data dir, ports), uninstall pointer.
|
|
284
|
+
|
|
285
|
+
The SPA opens an `EventSource` against `/api/events` and re-fetches the relevant slice when a node state changes, the trust circle changes, connectivity changes, or an update is announced. No login — the page is bound to loopback only, with an Origin/Host guard for DNS-rebind defence. See the threat-model note in the *Updates* section above and in [docs/cadre-host.md](../../docs/cadre-host.md) for the full security posture.
|
|
286
|
+
|
|
287
|
+
### Building the SPA
|
|
288
|
+
|
|
289
|
+
`yarn workspace @serfab/cadre-host build` compiles both the server (TypeScript) and the SPA (`vite build` against `ui/`). The bundle lands in `dist/ui/` and is served by the same Fastify instance that handles `/api/*` and friends. When `dist/ui/` is missing (e.g. running from source without building), the server still answers all API routes and shows a placeholder at `/` explaining how to build.
|
|
290
|
+
|
|
291
|
+
For UI-only iteration: `yarn workspace @serfab/cadre-host dev:ui` starts Vite on `:5173` and proxies `/api`, `/auth`, `/nat`, `/update` to `127.0.0.1:8765` (override with `CADRE_HOST_PORT`).
|
|
292
|
+
|
|
293
|
+
## More
|
|
294
|
+
|
|
295
|
+
- [docs/cadre-host.md](../../docs/cadre-host.md) — persona, package boundary, deployment model, security posture.
|
|
296
|
+
- [docs/architecture.md](../../docs/architecture.md) — overall cadre architecture.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CadreInvite
|
|
1
|
+
import type { CadreInvite } from '@serfab/cadre-core';
|
|
2
2
|
import type { TrustCircleStore } from './trust-circle-store.js';
|
|
3
3
|
import type { TrustCircleHandlers, TrustCircleSnapshot } from './types.js';
|
|
4
4
|
/** Default invite lifetime: 24 hours. */
|
|
@@ -28,19 +28,35 @@ export interface CadreNodeLike {
|
|
|
28
28
|
}, issuedInvite?: CadreInvite): Promise<void>;
|
|
29
29
|
removePeer(peerId: string): Promise<void>;
|
|
30
30
|
encodeInvite(invite: CadreInvite): string;
|
|
31
|
-
|
|
31
|
+
/** Enumerate the cadre's `CadrePeer` membership (over the admin channel). */
|
|
32
|
+
listMembers(): Promise<Array<{
|
|
33
|
+
peerId: string;
|
|
34
|
+
multiaddr: string | null;
|
|
35
|
+
}>>;
|
|
36
|
+
/** Probe whether a peer is a `CadrePeer` member (over the admin channel). */
|
|
37
|
+
isMember(peerId: string): Promise<boolean>;
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* TrustCircleService — orchestrates invite issuance, redemption, and
|
|
35
41
|
* membership management.
|
|
36
42
|
*
|
|
37
|
-
* Membership is canonical in the cadre control DB (`CadrePeer` table)
|
|
38
|
-
*
|
|
43
|
+
* Membership is canonical in the cadre control DB (`CadrePeer` table),
|
|
44
|
+
* reached over the authority node's admin channel; labels and pending tokens
|
|
45
|
+
* live in the host-local `TrustCircleStore`. When the node is unreachable,
|
|
46
|
+
* authority operations surface `node_unavailable` (→ 503) and `list()`
|
|
47
|
+
* degrades to the local labels file.
|
|
39
48
|
*/
|
|
40
49
|
export declare class TrustCircleService {
|
|
41
50
|
private readonly cadreNode;
|
|
42
51
|
private readonly store;
|
|
43
52
|
private readonly now;
|
|
53
|
+
/**
|
|
54
|
+
* In-flight redemption claims (by token). Serialises concurrent redeems
|
|
55
|
+
* for the same token without burning the durable pending row before
|
|
56
|
+
* `acceptPhone` succeeds. Intra-process only — the orchestrator enforces
|
|
57
|
+
* single-process ownership of a given rootDir, so no file lock is needed.
|
|
58
|
+
*/
|
|
59
|
+
private readonly inFlightRedemptions;
|
|
44
60
|
constructor(opts: TrustCircleServiceOptions);
|
|
45
61
|
/**
|
|
46
62
|
* Issue a new invite. Generates a token, persists a pending row, and
|
|
@@ -60,6 +76,20 @@ export declare class TrustCircleService {
|
|
|
60
76
|
* - cadre-core authorizes the peer (inserts CadrePeer row).
|
|
61
77
|
* - The pending row is consumed (one-time enforcement).
|
|
62
78
|
* - A labelled member row is written locally.
|
|
79
|
+
*
|
|
80
|
+
* Ordering: the synchronous *claim* against `inFlightRedemptions` serialises
|
|
81
|
+
* concurrent redeems for the same token (second concurrent caller rejects
|
|
82
|
+
* synchronously before its first await). The durable `removePending` only
|
|
83
|
+
* runs *after* `acceptPhone` succeeds — so a transient `node_unavailable`
|
|
84
|
+
* leaves the token re-redeemable rather than permanently burning it.
|
|
85
|
+
*
|
|
86
|
+
* Crash-safety tradeoff: if the host crashes between `acceptPhone` success
|
|
87
|
+
* and the durable `removePending`, a retry will dial in fine but the
|
|
88
|
+
* authority node's `acceptPhone` will reject the second `CadrePeer` insert
|
|
89
|
+
* with a PK constraint error (cadre-core does not upsert). That surfaces to
|
|
90
|
+
* the redeemer as a non-`node_unavailable` failure; the admin can revoke
|
|
91
|
+
* the lingering pending row. This is a narrower window than the prior
|
|
92
|
+
* behaviour, where *any* transient node outage burned the token.
|
|
63
93
|
*/
|
|
64
94
|
redeemInvite(opts: {
|
|
65
95
|
token: string;
|
|
@@ -78,10 +108,18 @@ export declare class TrustCircleService {
|
|
|
78
108
|
/** Whether the peer is present in the cadre's CadrePeer table. */
|
|
79
109
|
isMember(peerId: string): Promise<boolean>;
|
|
80
110
|
/**
|
|
81
|
-
* UI snapshot. Joins the canonical CadrePeer list
|
|
82
|
-
* prunes any orphan labels (peer is no longer
|
|
111
|
+
* UI snapshot. Joins the canonical CadrePeer list (fetched over the admin
|
|
112
|
+
* channel) with local labels; prunes any orphan labels (peer is no longer
|
|
113
|
+
* authorised). When the authority node is unreachable, degrades to the
|
|
114
|
+
* local labels file so listing keeps working while the node is down.
|
|
83
115
|
*/
|
|
84
116
|
list(): Promise<TrustCircleSnapshot>;
|
|
117
|
+
/**
|
|
118
|
+
* Translate an admin-channel transport failure into a `node_unavailable`
|
|
119
|
+
* TrustCircleError (→ 503). Re-throws anything else unchanged. Returns
|
|
120
|
+
* `never` so callers can `.catch(err => this.toDomainError(err))`.
|
|
121
|
+
*/
|
|
122
|
+
private toDomainError;
|
|
85
123
|
}
|
|
86
124
|
/**
|
|
87
125
|
* Wrap a TrustCircleService into the typed handler shape consumed by
|