@catheadowl/ukp 0.2.2 → 0.2.3
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 +50 -40
- package/docs/remote-deployment.md +309 -0
- package/package.json +2 -1
- package/src/cli.ts +2 -2
- package/src/commands/diagnose.ts +1 -1
- package/src/commands/guide-content/remote.txt +2 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/inspect.ts +1 -1
- package/src/commands/inventory.ts +14 -2
- package/src/commands/read.ts +1 -1
- package/src/commands/serve-task.ts +198 -0
- package/src/commands/serve.ts +49 -4
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
# UKP
|
|
1
|
+
# UKP — Unified Knowledge Plane
|
|
2
2
|
|
|
3
3
|
Folder as a Service: turn folders into named knowledge endpoints — one
|
|
4
4
|
stable command surface, on this machine or your own server.
|
|
5
5
|
|
|
6
6
|
Register a folder and it becomes an endpoint for humans and agents alike —
|
|
7
7
|
nothing is started, the folder stays a folder. Navigate, read, and search
|
|
8
|
-
it; propose changes back to the owner as reviewable suggestions;
|
|
9
|
-
|
|
10
|
-
provider-specific commands to relearn.
|
|
8
|
+
it; propose changes back to the owner as reviewable suggestions; refresh
|
|
9
|
+
the search index behind it (local folders today). No physical paths to
|
|
10
|
+
memorize, no provider-specific commands to relearn.
|
|
11
11
|
|
|
12
12
|
> [!NOTE]
|
|
13
13
|
> Early-stage CLI (MVP) — commands and behavior may still change.
|
|
@@ -27,7 +27,8 @@ $ ukp read ukp://notes/2026/q3-plan.md # read straight from the reference
|
|
|
27
27
|
# Q3 Plan
|
|
28
28
|
Goals for Q3: ship the knowledge plane.
|
|
29
29
|
|
|
30
|
-
$
|
|
30
|
+
$ echo '[capabilities.propose]' >> .ukp/service.toml # the write face is opt-in: declare propose
|
|
31
|
+
$ ukp propose --endpoint notes --id typo-fix --file typo-fix.md
|
|
31
32
|
proposal typo-fix created (revision 1)
|
|
32
33
|
```
|
|
33
34
|
|
|
@@ -53,38 +54,41 @@ predictable scope, and a single command surface. Use UKP when:
|
|
|
53
54
|
command will touch before running it.
|
|
54
55
|
- Navigate the Markdown structure of an endpoint — folders, descriptions,
|
|
55
56
|
depth — with zero provider dependency.
|
|
56
|
-
- Read endpoint
|
|
57
|
-
reference recovery re-finds it (git history
|
|
57
|
+
- Read endpoint files through the built-in `read/file` capability — if a
|
|
58
|
+
file moves, reference recovery re-finds it best-effort (git history
|
|
59
|
+
first, then search). The
|
|
58
60
|
optional `read/qmd` goes through QMD (an external tool — see
|
|
59
61
|
Requirements). `read` and `nav` are derived defaults of every local
|
|
60
62
|
Service.
|
|
61
|
-
- Run base lexical search with `rg` across endpoint files —
|
|
62
|
-
|
|
63
|
-
- Search
|
|
64
|
-
or
|
|
65
|
-
|
|
63
|
+
- Run base lexical search with `rg` across endpoint files — no search
|
|
64
|
+
provider needed, on by default, shaped into `read`-ready references.
|
|
65
|
+
- Search indexed content, QMD-backed: one endpoint, the workspace default
|
|
66
|
+
scope, or the whole local Registry. `--recursive` additionally searches
|
|
67
|
+
the endpoints a Service declares in its manifest `dependencies` —
|
|
68
|
+
nothing recurs unless declared.
|
|
66
69
|
- Propose changes as an idempotent, reviewable suggestion — the proposal
|
|
67
70
|
lands in the endpoint's inbox and the verdict stays with its owner.
|
|
68
71
|
- Update provider-owned indexes through a stable UKP command (QMD-backed).
|
|
69
|
-
- Serve an endpoint over HTTP — a single endpoint, or
|
|
72
|
+
- Serve an endpoint over HTTP — a single endpoint, or every local binding as
|
|
70
73
|
a host door — when you want it reachable from other machines.
|
|
71
74
|
- Want a read-only endpoint? Declare zero capabilities — an empty
|
|
72
75
|
`[capabilities]` table still registers one (derived `read`/`nav` only).
|
|
73
76
|
- Give agents JSON output (`--json`; `ukp read` uses `--format json`),
|
|
74
|
-
reference sidecars, and
|
|
75
|
-
|
|
77
|
+
reference sidecars, docid handoff keys, and durable `ukp://` references
|
|
78
|
+
when they need machine-readable results.
|
|
76
79
|
|
|
77
|
-
UKP owns endpoint names, Registry bindings,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
UKP owns endpoint names, Registry bindings, scope, and output shape; the
|
|
81
|
+
provider owns collection setup, indexes, ranking, and maintenance. UKP
|
|
82
|
+
points you at the provider's own setup — it does not rewrite provider
|
|
83
|
+
config for you.
|
|
81
84
|
|
|
82
85
|
## Install
|
|
83
86
|
|
|
84
87
|
Requirements:
|
|
85
88
|
|
|
86
|
-
- Bun `1.3.14` or newer
|
|
87
|
-
on Bun
|
|
89
|
+
- Bun `1.3.14` or newer, installed by you — the npm package installs the
|
|
90
|
+
`ukp` command, but the CLI runs on Bun; when `bun` is missing the command
|
|
91
|
+
says how to install it instead of showing a stack trace.
|
|
88
92
|
- QMD on `PATH` only for the QMD-backed capabilities (`search/qmd`,
|
|
89
93
|
`read/qmd`, `update/qmd`). QMD is an external tool maintained as a
|
|
90
94
|
separate project; see its own release channel for installation. UKP
|
|
@@ -114,8 +118,8 @@ For a human or agent starting from a folder:
|
|
|
114
118
|
7. `ukp search "<query>" --endpoint <name>` finds indexed matches after
|
|
115
119
|
provider setup.
|
|
116
120
|
|
|
117
|
-
No QMD installed? Stop at step 6 — `nav`, `read`,
|
|
118
|
-
|
|
121
|
+
No QMD installed? Stop at step 6 — `nav`, `read`, and `rg` work on any
|
|
122
|
+
endpoint without it; `propose` works wherever it's declared.
|
|
119
123
|
|
|
120
124
|
An agent can carry out the same flow on your behalf.
|
|
121
125
|
|
|
@@ -125,20 +129,21 @@ For workspace defaults, use `ukp guide client` and `.ukp/client.toml`.
|
|
|
125
129
|
|
|
126
130
|
Grouping below follows `ukp --help`; every command stays a flat
|
|
127
131
|
`ukp <verb>`. `--endpoint <name>` is the canonical endpoint selector
|
|
128
|
-
(
|
|
129
|
-
where supported.
|
|
132
|
+
(`ukp unregister <name>` still takes the endpoint as a positional legacy
|
|
133
|
+
form); `-g` selects the full local Host Registry where supported.
|
|
130
134
|
|
|
131
|
-
The same five commands
|
|
132
|
-
remote endpoint is a one-command gesture
|
|
135
|
+
The same five commands — search, read, nav, rg, propose — work on local and
|
|
136
|
+
remote endpoints; consuming a remote endpoint is a one-command gesture
|
|
137
|
+
(see below).
|
|
133
138
|
|
|
134
139
|
Endpoint commands:
|
|
135
140
|
|
|
136
141
|
| Command | What it does |
|
|
137
142
|
|---|---|
|
|
138
|
-
| `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands
|
|
139
|
-
| `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or `docid`
|
|
143
|
+
| `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands the manifest's declared `dependencies`. Results hand off via docid session keys and verified `ukp://` references. |
|
|
144
|
+
| `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or a `docid` handed off by search results. |
|
|
140
145
|
| `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
|
|
141
|
-
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning
|
|
146
|
+
| `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning. |
|
|
142
147
|
| `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
|
|
143
148
|
|
|
144
149
|
Registry commands:
|
|
@@ -146,8 +151,8 @@ Registry commands:
|
|
|
146
151
|
| Command | What it does |
|
|
147
152
|
|---|---|
|
|
148
153
|
| `ukp init service` | Creates a minimal `.ukp/service.toml`. |
|
|
149
|
-
| `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings
|
|
150
|
-
| `ukp list` | Lists registered endpoints with their declared capabilities.
|
|
154
|
+
| `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings — local folders and remote endpoints alike (`ukp register --url`, see Remote in 60 seconds). `--name <handle>` disambiguates collisions: `notes` vs `ali-notes`. |
|
|
155
|
+
| `ukp list` | Lists registered endpoints with their declared capabilities. New endpoints on a host door show as a stderr note (`door <origin>: N unimported endpoint(s) …`) — importing stays an explicit gesture. |
|
|
151
156
|
|
|
152
157
|
Operations commands:
|
|
153
158
|
|
|
@@ -156,7 +161,7 @@ Operations commands:
|
|
|
156
161
|
| `ukp diagnose` | Checks a Service folder or endpoint scope for wiring problems. |
|
|
157
162
|
| `ukp inspect` | Explains current scope, bindings, manifest capabilities, and provider availability. |
|
|
158
163
|
| `ukp update` | Runs provider-owned maintenance when `update/qmd` is declared (local endpoints today). |
|
|
159
|
-
| `ukp serve` | Serves one endpoint — or, without `--endpoint`,
|
|
164
|
+
| `ukp serve` | Serves one endpoint — or, without `--endpoint`, every local binding as a **host door** — over HTTP (a discovery document plus the five command routes; loopback by default). |
|
|
160
165
|
|
|
161
166
|
Help commands:
|
|
162
167
|
|
|
@@ -180,7 +185,7 @@ The two fastest paths — each block notes where its commands run:
|
|
|
180
185
|
# From the consumer machine — SSH between two personal machines; the door is
|
|
181
186
|
# woken on demand, nothing to start on the host (prerequisites: ssh
|
|
182
187
|
# reachable + ukp on its PATH):
|
|
183
|
-
ukp register --url ssh://<host> # once;
|
|
188
|
+
ukp register --url ssh://<host> # once; trust-on-first-use pinned
|
|
184
189
|
ukp read --endpoint <name> notes/x.md # just works, like local
|
|
185
190
|
|
|
186
191
|
# Native TLS on a bare IP — self-signed, pinned automatically at registration.
|
|
@@ -188,11 +193,18 @@ ukp read --endpoint <name> notes/x.md # just works, like local
|
|
|
188
193
|
# register line runs on the consumer machine:
|
|
189
194
|
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 --tls
|
|
190
195
|
ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
|
|
191
|
-
# NAT/EIP cloud host (public IP on no NIC)? Name it explicitly — the persisted
|
|
192
|
-
# cert re-signs over the same key, so the pin (and every registration) survives:
|
|
193
|
-
# ukp serve … --tls --tls-san <public-ip>
|
|
194
196
|
```
|
|
195
197
|
|
|
198
|
+
NAT/EIP cloud host (public IP on no NIC)? Name it with
|
|
199
|
+
`ukp serve --tls --tls-san <public-ip>` — the persisted certificate re-signs
|
|
200
|
+
over the same key, so the pin (and every registration) survives; details in
|
|
201
|
+
the deployment guide.
|
|
202
|
+
|
|
203
|
+
Registration mechanics: a **host door** url imports every endpoint behind it
|
|
204
|
+
(`--endpoint <name>` checks the served name and narrows the import to that
|
|
205
|
+
one, `--select` narrows to a subset); `--token` is stored with the binding;
|
|
206
|
+
re-running a registration refreshes it idempotently.
|
|
207
|
+
|
|
196
208
|
Remote endpoints take the same commands as local ones — search, read, nav,
|
|
197
209
|
rg, propose. The full guide — the host door (one port for the whole
|
|
198
210
|
registry), Caddy and Tailscale fronting, the certificate pinning lifecycle,
|
|
@@ -210,9 +222,7 @@ and the hardening posture — lives in the
|
|
|
210
222
|
|
|
211
223
|
## Documentation
|
|
212
224
|
|
|
213
|
-
- [Remote deployment](https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — serving endpoints
|
|
214
|
-
SSH/HTTPS, the host door, Caddy and Tailscale fronting, the certificate
|
|
215
|
-
pinning lifecycle, and the hardening posture.
|
|
225
|
+
- [Remote deployment](https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — serving endpoints across machines: the full guide.
|
|
216
226
|
- [Contributing](https://github.com/CatheadOwl/ukp/blob/main/CONTRIBUTING.md) — development setup and release packaging.
|
|
217
227
|
- [Changelog](https://github.com/CatheadOwl/ukp/blob/main/CHANGELOG.md) — what changed per release.
|
|
218
228
|
- Repository: https://github.com/CatheadOwl/ukp ·
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# Remote deployment
|
|
2
|
+
|
|
3
|
+
Operational guide for exposing UKP endpoints over the network. For the
|
|
4
|
+
two fastest consumer-side paths (SSH between personal machines, native TLS
|
|
5
|
+
on a bare IP), see the README's "Remote in 60 seconds"; this guide covers
|
|
6
|
+
every deployment shape end to end.
|
|
7
|
+
|
|
8
|
+
**Choosing a path by burden** (someone always holds the port — the only
|
|
9
|
+
question is who): the ssh:// path wakes the door on demand and adds zero
|
|
10
|
+
resident processes on every OS (Windows hosts enable the built-in OpenSSH
|
|
11
|
+
Server feature once). The https path needs a resident listener: on Linux,
|
|
12
|
+
systemd socket activation holds the port and spawns the door per use
|
|
13
|
+
(zero resident ukp); on Windows there is no systemd equivalent and a bun
|
|
14
|
+
process cannot natively be a service — the resident door runs as a
|
|
15
|
+
hidden logon task (no visible window, bounded crash retry; see the
|
|
16
|
+
Windows section), and only unattended-boot always-on would want a real
|
|
17
|
+
service wrapper (WinSW-class), or prefer ssh:// there. The https door is
|
|
18
|
+
the right shape when consumers have no SSH credentials.
|
|
19
|
+
|
|
20
|
+
`ukp serve` speaks plain HTTP by default; TLS and public exposure are either
|
|
21
|
+
**native** (`--tls` self-signs through the local openssl, `--tls-cert/--tls-key`
|
|
22
|
+
serve your own certificate) or **delegated to a fronting component** (reverse
|
|
23
|
+
proxy, SSH tunnel, overlay). The trust model is public PKI, or automatic TOFU
|
|
24
|
+
pinning for self-signed and private-CA certificates. **Authentication is
|
|
25
|
+
deny-by-default**: serving requires `UKP_SERVE_TOKEN`; tokenless serving needs
|
|
26
|
+
an explicit `--allow-anonymous` and is refused off loopback. A reverse proxy on
|
|
27
|
+
the same host forwards from the public side to the loopback bind, so proxied
|
|
28
|
+
deployments treat the token as mandatory (serve cannot see past its own bind
|
|
29
|
+
address).
|
|
30
|
+
|
|
31
|
+
## Via SSH (two personal machines — zero extra components, zero ceremony)
|
|
32
|
+
|
|
33
|
+
The `ssh://` URL scheme makes the transport transparent AND wakes the door
|
|
34
|
+
on demand: each invocation runs one ssh process that opens the forward and
|
|
35
|
+
starts a pinned loopback `ukp serve` on the host (the door serves the host
|
|
36
|
+
Registry fresh and self-reaps when idle — nothing to pre-start, no token:
|
|
37
|
+
SSH carries encryption and auth). Later invocations within a couple of
|
|
38
|
+
minutes reuse the warmed SSH connection. Prerequisites on the host: ssh
|
|
39
|
+
reachable (your key) and ukp on its PATH. A first call that cannot find
|
|
40
|
+
`ukp` on the remote PATH says so with the remedy.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ukp register --url ssh://<host> # once; the first call wakes the door
|
|
44
|
+
ukp read --endpoint <name> notes/x.md # just works, like local
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Notes:
|
|
48
|
+
- The `ssh://` url port selects nothing — the woken door listens on a
|
|
49
|
+
client-chosen loopback port per invocation.
|
|
50
|
+
- **Host prerequisite = a standard install.** `npm install -g
|
|
51
|
+
@catheadowl/ukp` (or `bun add -g`) on the host is all it takes — the wake
|
|
52
|
+
command prepends the standard install locations (`~/.bun/bin`,
|
|
53
|
+
`~/.npm-global/bin`, brew prefixes, then `$PATH`) before exec, so both
|
|
54
|
+
`ukp` and its bun shebang resolve even though `ssh host cmd` runs a bare
|
|
55
|
+
non-interactive PATH. Verified with a user-prefix npm install (ukp + bun
|
|
56
|
+
both in `~/.npm-global/bin`, invisible to a bare shell):
|
|
57
|
+
wake register 5.2s, day-2 read 4.8s, no orphans. Only exception:
|
|
58
|
+
nvm-style versioned layouts have no fixed path shape — link once with
|
|
59
|
+
`sudo ln -s "$(command -v ukp)" /usr/local/bin/ukp`.
|
|
60
|
+
- Consecutive invocations share an ssh multiplexing master for two minutes
|
|
61
|
+
(handshake amortization); Windows' native ssh has no multiplexing and
|
|
62
|
+
quietly pays the handshake per call.
|
|
63
|
+
- To lock down what SSH may run, restrict the login shell (git-shell
|
|
64
|
+
style). A forced `command=` in authorized_keys does NOT compose with
|
|
65
|
+
wake — the client chooses the door port per invocation, which a fixed
|
|
66
|
+
forced command cannot express.
|
|
67
|
+
|
|
68
|
+
Windows hosts work out of the box: when the host's OpenSSH default shell
|
|
69
|
+
(cmd.exe) rejects the POSIX wake form, the client detects the signature and
|
|
70
|
+
resends the pinned cmd.exe form automatically — nothing to configure beyond
|
|
71
|
+
the OpenSSH Server feature and your key. A powershell DefaultShell is not
|
|
72
|
+
supported (its `-c` quoting drops the wake form).
|
|
73
|
+
|
|
74
|
+
## Native TLS (bare IP, no domain — zero extra components)
|
|
75
|
+
|
|
76
|
+
`--tls` self-signs on first start (identity = keypair, persisted under the
|
|
77
|
+
Service folder's `.ukp/tls/`; SAN covers the host's addresses). Clients pin
|
|
78
|
+
the certificate automatically at registration — same command as any other
|
|
79
|
+
remote, no flags added:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 --tls
|
|
83
|
+
# consumer machine (self-signed is TOFU-pinned at registration):
|
|
84
|
+
ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
|
|
85
|
+
ukp read --endpoint <name> notes/x.md # just works, like local
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A certificate renewal that keeps the key re-anchors transparently; a new key
|
|
89
|
+
(reinstall) blocks with both fingerprints until you re-register. Prefer real
|
|
90
|
+
certificates? Let's Encrypt issues IP-address certificates (GA 2026-01) —
|
|
91
|
+
run certbot with a renewal timer and point `--tls-cert/--tls-key` at the
|
|
92
|
+
files; clients then need no pinning at all.
|
|
93
|
+
|
|
94
|
+
**NAT/EIP cloud hosts** (public IP on no NIC — Alibaba/AWS-style 1:1 NAT):
|
|
95
|
+
the automatic SAN coverage can never see the public address, so name it
|
|
96
|
+
explicitly with `--tls-san` (repeatable; DNS names too):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 \
|
|
100
|
+
--tls --tls-san <public-ip>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A persisted certificate missing a requested entry is re-signed over the
|
|
104
|
+
SAME key — the pin (and thus every client registration) survives, the
|
|
105
|
+
banner honestly reports `self-signed identity (re-signed)`, and coverage
|
|
106
|
+
only grows: the re-sign keeps everything the certificate already carried
|
|
107
|
+
(so changing the flag list loses nothing), and already-covered or
|
|
108
|
+
narrower requests reuse the certificate untouched. Explicit `--tls-cert` certificates carry their own SAN, so
|
|
109
|
+
`--tls-san` next to them is a usage error, not a no-op.
|
|
110
|
+
|
|
111
|
+
## Windows host (https — resident form)
|
|
112
|
+
|
|
113
|
+
Windows has no socket-activation equivalent, so the https path on a
|
|
114
|
+
Windows host is the resident form: one-time setup, auto-start at logon,
|
|
115
|
+
one bun process always running — with **no visible window** (a hidden
|
|
116
|
+
launcher owns the console; a popping cmd box is not an acceptable product
|
|
117
|
+
form) and bounded crash retry. Verified end to end (register/TOFU →
|
|
118
|
+
read → nav, plus zero-flash and crash-recovery drills) on a Windows 11
|
|
119
|
+
host with a plain user-level install. Steps 3–6 are exactly what
|
|
120
|
+
`ukp serve --print-task --host 0.0.0.0 --tls`
|
|
121
|
+
(or `--tls-cert/--tls-key`) prints for you — the generator assembles the
|
|
122
|
+
mechanical half (start script, hidden launcher, Task Scheduler command,
|
|
123
|
+
firewall rule, token placeholder); this section keeps the judgment calls
|
|
124
|
+
and the pitfalls the verification run caught:
|
|
125
|
+
|
|
126
|
+
1. Install the usual way (bun's official installer + `npm i -g
|
|
127
|
+
@catheadowl/ukp` — the npm shim needs bun on PATH, both land in
|
|
128
|
+
per-user PATH dirs, which is fine here: the door is started by a
|
|
129
|
+
logged-on task, not a bare ssh shell).
|
|
130
|
+
2. `ukp init service` in each folder, then run `ukp register` from inside
|
|
131
|
+
it (register takes no folder argument) for every endpoint to expose.
|
|
132
|
+
3. Certificate — generate ONCE (Git for Windows' openssl works; avoids
|
|
133
|
+
any runtime openssl dependency of `--tls`):
|
|
134
|
+
`openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout
|
|
135
|
+
%USERPROFILE%\.ukp\tls\key.pem -out %USERPROFILE%\.ukp\tls\cert.pem
|
|
136
|
+
-subj "/CN=ukp" -addext "subjectAltName=IP:<lan-ip>,DNS:localhost"`
|
|
137
|
+
4. `%USERPROFILE%\.ukp\start-door.cmd` (CRLF!) — set UKP_SERVE_TOKEN,
|
|
138
|
+
run `ukp serve --host 0.0.0.0 --port 8570 --tls-cert ... --tls-key ...`
|
|
139
|
+
with the door.log redirect. No PATH export: the launcher finds bun
|
|
140
|
+
itself, and the logged-on task carries the per-user PATH. Write it
|
|
141
|
+
with a real editor (writing cmd files over ssh echo mangles `%`
|
|
142
|
+
escaping — copy the file instead).
|
|
143
|
+
5. `%USERPROFILE%\.ukp\start-door-hidden.vbs` (CRLF, real editor) — the
|
|
144
|
+
hidden launcher the task actually runs: it starts the console hidden
|
|
145
|
+
at process creation (nothing ever flashes), captures everything to
|
|
146
|
+
door.log, propagates the exit code, and retries a crashed door
|
|
147
|
+
3 times, 30s apart. `ukp serve --print-task` prints it verbatim.
|
|
148
|
+
6. `schtasks /Create /TN ukp-door /TR "wscript.exe
|
|
149
|
+
\"%USERPROFILE%\.ukp\start-door-hidden.vbs\"" /SC ONLOGON /F` (from an
|
|
150
|
+
elevated shell — creating an ONLOGON task from a plain one fails with
|
|
151
|
+
Access denied) + a firewall rule for the port; `schtasks /Run /TN
|
|
152
|
+
ukp-door` to start now.
|
|
153
|
+
7. Client: `ukp register --url https://<host>:8570 --token <token>` —
|
|
154
|
+
TOFU pins the certificate; re-registering refreshes the pin.
|
|
155
|
+
|
|
156
|
+
Pitfalls the verification run caught:
|
|
157
|
+
- **A silent bind failure**: if something already listens on the port
|
|
158
|
+
(a leftover manual door, say), the new door exits EADDRINUSE invisibly
|
|
159
|
+
— door.log carries the line (the hidden launcher captures everything);
|
|
160
|
+
check `netstat -ano | findstr :8570` and the PID's command line.
|
|
161
|
+
- ONLOGON starts at LOGON, not boot, and the launcher's crash retry is
|
|
162
|
+
bounded (3×30s): after an unattended reboot nobody logs into, or after
|
|
163
|
+
exhausted retries, the door is down until the next logon or a manual
|
|
164
|
+
`schtasks /Run`. Unattended-boot always-on would need WinSW/NSSM-class
|
|
165
|
+
wrapping — or prefer ssh:// there. (Task Scheduler's own
|
|
166
|
+
restart-on-failure setting does not fire on exit codes — do not rely
|
|
167
|
+
on it.)
|
|
168
|
+
- To restart the door by hand (new token, ukp upgrade, new cert):
|
|
169
|
+
`taskkill /PID <door-pid> /T /F`, then `schtasks /Run /TN ukp-door` —
|
|
170
|
+
`schtasks /End` does not reliably kill the process tree.
|
|
171
|
+
- `rg` on the host is the host's business — no ripgrep installed means
|
|
172
|
+
the remote `rg` capability reports unavailable (everything else works).
|
|
173
|
+
|
|
174
|
+
## Socket activation (Linux — no resident process on the https path either)
|
|
175
|
+
|
|
176
|
+
systemd can hold the listening port itself and spawn the door on first
|
|
177
|
+
connection; the door then self-reaps after `--max-idle` seconds of no
|
|
178
|
+
requests and systemd re-spawns it on the next one. Nothing resident
|
|
179
|
+
between uses — the same zero-maintenance posture as the ssh wake, for the
|
|
180
|
+
consumer-facing https door (Cockpit and Ubuntu's own sshd run this way).
|
|
181
|
+
|
|
182
|
+
Two user-level units (matches a user-local install). Two systemd facts
|
|
183
|
+
learned the hard way: **`Environment=` does not expand
|
|
184
|
+
`%h`/`$HOME`** (specifiers work in `ExecStart=` paths but NOT inside
|
|
185
|
+
`Environment=` — a literal `%h/...` PATH yields 203/EXEC), and **units
|
|
186
|
+
source no profile** — so write literal paths:
|
|
187
|
+
|
|
188
|
+
```ini
|
|
189
|
+
# ~/.config/systemd/user/ukp-door.socket
|
|
190
|
+
[Unit]
|
|
191
|
+
Description=ukp host door (socket-activated)
|
|
192
|
+
|
|
193
|
+
[Socket]
|
|
194
|
+
ListenStream=8570
|
|
195
|
+
|
|
196
|
+
# ~/.config/systemd/user/ukp-door.service
|
|
197
|
+
[Unit]
|
|
198
|
+
Description=ukp host door
|
|
199
|
+
|
|
200
|
+
[Service]
|
|
201
|
+
Environment=PATH=/home/<user>/.npm-global/bin:/home/<user>/.bun/bin:/usr/local/bin:/usr/bin
|
|
202
|
+
EnvironmentFile=-/home/<user>/.config/ukp-door.env # UKP_SERVE_TOKEN=… (chmod 600)
|
|
203
|
+
ExecStart=/home/<user>/.npm-global/bin/ukp serve --systemd-socket --max-idle 60 --tls
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
loginctl enable-linger "$USER" # let the socket answer while you're
|
|
208
|
+
# not logged in
|
|
209
|
+
systemctl --user daemon-reload
|
|
210
|
+
systemctl --user enable --now ukp-door.socket
|
|
211
|
+
# nothing is running yet — the first client connection spawns the door:
|
|
212
|
+
ukp register --url https://<host>:8570 --token <token> # cold start, TOFU-pins
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Notes:
|
|
216
|
+
- The port belongs to the SOCKET unit — serve runs `--systemd-socket`
|
|
217
|
+
(`--port` is rejected alongside) and requires `UKP_SERVE_TOKEN`: the
|
|
218
|
+
bind address is the unit's (possibly public), so tokenless serving is
|
|
219
|
+
not verifiable from inside the process.
|
|
220
|
+
- TLS: `--tls` self-signs on first start (identity under `~/.ukp/tls/`);
|
|
221
|
+
clients pin at registration. LE/mkcert: `--tls-cert/--tls-key` as usual.
|
|
222
|
+
- Do NOT put an HTTP health-check in front of the socket — it would wake
|
|
223
|
+
the door on every probe and defeat `--max-idle`.
|
|
224
|
+
- `systemctl --user` over bare ssh needs `XDG_RUNTIME_DIR=/run/user/$(id -u)`.
|
|
225
|
+
- Restarting the SOCKET while a door is still running is refused
|
|
226
|
+
("Socket service already active") — `systemctl --user restart
|
|
227
|
+
ukp-door.service` instead, or let the door idle out first.
|
|
228
|
+
- **NAT/EIP hosts**: `--tls` self-signs with the machine's interface
|
|
229
|
+
addresses — a public EIP is not among them, and registration would fail
|
|
230
|
+
CERT_ALTNAME_INVALID. Add `--tls-san <public-ip>` next to `--tls`
|
|
231
|
+
(see Native TLS above): the SAN grows, the key (and pin) does not.
|
|
232
|
+
|
|
233
|
+
## Host door (one host, many endpoints — one gesture, zero tokens)
|
|
234
|
+
|
|
235
|
+
Trust's natural unit is the host, not the endpoint: if you can ssh to a
|
|
236
|
+
machine, per-endpoint tokens are ceremony — and with the on-demand wake you
|
|
237
|
+
don't even run the door yourself: `ukp register --url ssh://<host>`
|
|
238
|
+
wakes a loopback door (`docker DOCKER_HOST=ssh://` posture — SSH carries
|
|
239
|
+
encryption and auth), imports everything behind it, and the door reaps
|
|
240
|
+
itself when idle:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
# nothing to run on the host first — one gesture imports everything:
|
|
244
|
+
ukp register --url ssh://<host>
|
|
245
|
+
# door ssh://<host>: 2 endpoint(s)
|
|
246
|
+
# imported: notes (search,propose)
|
|
247
|
+
# imported: archive (-)
|
|
248
|
+
ukp list # flat rows, urls like ssh://<host>/notes
|
|
249
|
+
ukp read ukp://notes/plan.md # day-2 is byte-identical to today's remotes
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A manually started door still works (e.g. under systemd, for a host you
|
|
253
|
+
deliberately keep serving): `ukp serve --allow-anonymous` without
|
|
254
|
+
`--endpoint` exposes the same whole-registry door on a fixed loopback port —
|
|
255
|
+
but the wake makes that an explicit choice, not a prerequisite.
|
|
256
|
+
|
|
257
|
+
Imported endpoints are ordinary remote bindings (`url = <door>/<name>`); a
|
|
258
|
+
name already bound elsewhere is skipped with a visible reason, re-running
|
|
259
|
+
the import refreshes pins idempotently, and `--select a,b` narrows the
|
|
260
|
+
take. When the door grows, `ukp list` says so on stderr (`1 unimported
|
|
261
|
+
endpoint(s): …`) — importing it stays your call. The consumer variant of
|
|
262
|
+
the same door is public: `ukp serve --host 0.0.0.0 --tls-cert …` with
|
|
263
|
+
`UKP_SERVE_TOKEN`, imported with `ukp register --url https://<ip>:8570
|
|
264
|
+
--token <t>` for the whole door, or `ukp register --url https://<ip>:8570
|
|
265
|
+
--endpoint <name> --token <t>` for one asserted endpoint (token and pinned
|
|
266
|
+
certificate copied into each binding).
|
|
267
|
+
|
|
268
|
+
## Behind Caddy (public domain)
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
# Caddyfile — automatic Let's Encrypt
|
|
272
|
+
kb.example.com {
|
|
273
|
+
reverse_proxy 127.0.0.1:8570
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 127.0.0.1 --port 8570
|
|
279
|
+
# on the consumer machine:
|
|
280
|
+
UKP_ENDPOINT_<NAME>_TOKEN=<token> ukp register --url https://kb.example.com
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Via Tailscale (two machines on any network — including the same LAN)
|
|
284
|
+
|
|
285
|
+
Enable HTTPS certificates on your tailnet once (admin console: DNS →
|
|
286
|
+
MagicDNS → HTTPS Certificates); Tailscale then provisions Let's Encrypt
|
|
287
|
+
certificates for your `*.ts.net` hostnames.
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
# both machines: tailscale up (same tailnet)
|
|
291
|
+
# server machine:
|
|
292
|
+
UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 127.0.0.1 --port 8570
|
|
293
|
+
tailscale serve --bg --https=443 http://127.0.0.1:8570
|
|
294
|
+
# consumer machine:
|
|
295
|
+
UKP_ENDPOINT_<NAME>_TOKEN=<token> ukp register --url https://<machine>.<tailnet>.ts.net
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The overlay covers LAN and roaming machines alike; a fully offline LAN (no
|
|
299
|
+
coordination reachability) is the one gap — a dedicated private-CA recipe is
|
|
300
|
+
not covered here.
|
|
301
|
+
|
|
302
|
+
## Hardening posture
|
|
303
|
+
|
|
304
|
+
- rate limiting, IP allowlists, and audit logging live at the proxy layer —
|
|
305
|
+
the `ukp` core stays thin;
|
|
306
|
+
- multiple client tokens: comma-separate them — `UKP_SERVE_TOKEN=alice,bob`
|
|
307
|
+
(any listed token authorizes);
|
|
308
|
+
- identity pinning is TOFU on `instance_uid`: a changed endpoint warns by
|
|
309
|
+
default; set `UKP_TOFU=block` on hostile networks to refuse it outright.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -198,9 +198,9 @@ export function renderHelp(): string {
|
|
|
198
198
|
]),
|
|
199
199
|
"Endpoint names for --endpoint come from 'ukp list'.",
|
|
200
200
|
"",
|
|
201
|
-
"A Service's declared capabilities are backed by a provider (QMD backs search and update today); rg and nav work on endpoint files directly, no provider needed.",
|
|
201
|
+
"A Service's declared capabilities are backed by a provider (QMD, an external tool, backs search and update today); rg and nav work on endpoint files directly, no provider needed.",
|
|
202
202
|
"",
|
|
203
|
-
"A Service is a folder with a manifest (a name plus optional declared capabilities); registering it binds that name as an endpoint you address with --endpoint. The scope is which endpoints commands use when no --endpoint
|
|
203
|
+
"A Service is a folder with a manifest (a name plus optional declared capabilities); registering it binds that name as an endpoint you address with --endpoint. The scope is which endpoints commands use when no --endpoint is given; -g widens a command to every registered endpoint; 'ukp guide client' shows how to set the workspace default.",
|
|
204
204
|
"",
|
|
205
205
|
"Guides:",
|
|
206
206
|
...GUIDE_TOPICS.map(([topic, summary]) => ` ukp ${`guide ${topic}`.padEnd(17)} ${summary}`),
|
package/src/commands/diagnose.ts
CHANGED
|
@@ -227,7 +227,7 @@ function diagnoseBinding(
|
|
|
227
227
|
if (isRemoteBinding(binding)) {
|
|
228
228
|
return {
|
|
229
229
|
status: "failed",
|
|
230
|
-
message: `remote endpoint '${binding.name}' (${binding.url}): diagnose is local-only
|
|
230
|
+
message: `remote endpoint '${binding.name}' (${binding.url}): diagnose is local-only for remote endpoints; reachability shows in 'ukp list'`,
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
try {
|
|
@@ -55,7 +55,8 @@ Name and identity:
|
|
|
55
55
|
|
|
56
56
|
Operational note:
|
|
57
57
|
- ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
|
|
58
|
-
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door.
|
|
58
|
+
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door; on Windows, ukp serve --print-task prints the resident-door artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) for you to review and apply yourself — the token stays yours to paste, never printed.
|
|
59
|
+
- The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — Windows resident door (certificate, start script, Task Scheduler), systemd socket-activation units, Caddy, Tailscale, hardening notes.
|
|
59
60
|
- serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
|
|
60
61
|
|
|
61
62
|
Remember:
|
package/src/commands/init.ts
CHANGED
|
@@ -189,7 +189,7 @@ function executeInitServiceCommand(args: readonly string[], context: InitCommand
|
|
|
189
189
|
`location: ${result.folder}`,
|
|
190
190
|
"declared_capabilities: -",
|
|
191
191
|
"derived_capabilities: read, nav",
|
|
192
|
-
"
|
|
192
|
+
"base_tier: rg (external tool; active when ripgrep is installed)",
|
|
193
193
|
// Folder-existence probe only (no provider state read): an existing
|
|
194
194
|
// provider index means collections may already be registered — route
|
|
195
195
|
// to the provider-scoped topic instead of naming provider commands
|
package/src/commands/inspect.ts
CHANGED
|
@@ -66,7 +66,7 @@ function inspectBinding(
|
|
|
66
66
|
if (isRemoteBinding(binding)) {
|
|
67
67
|
return {
|
|
68
68
|
status: "failed",
|
|
69
|
-
message: `remote endpoint '${binding.name}' (${binding.url}): inspect deep-dive is local-only
|
|
69
|
+
message: `remote endpoint '${binding.name}' (${binding.url}): inspect deep-dive is local-only for remote endpoints; capabilities show in 'ukp list'`,
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
try {
|
|
@@ -665,9 +665,21 @@ async function collectDoorDriftNotes(
|
|
|
665
665
|
const unimported = door.doc.endpoints
|
|
666
666
|
.map((endpoint) => endpoint.name)
|
|
667
667
|
.filter((name) => !group.imported.has(name));
|
|
668
|
-
if (unimported.length
|
|
668
|
+
if (unimported.length === 0) continue;
|
|
669
|
+
// A roster name can be unimported because it COLLIDED — the name is
|
|
670
|
+
// held by another binding, and the plain import remedy would skip it
|
|
671
|
+
// again. Those names get the single-endpoint --name remedy instead
|
|
672
|
+
// (W11 / ADR-REM-007).
|
|
673
|
+
const free = unimported.filter((name) => !endpoints.some((binding) => binding.name === name));
|
|
674
|
+
const taken = unimported.filter((name) => endpoints.some((binding) => binding.name === name));
|
|
675
|
+
if (free.length > 0) {
|
|
669
676
|
notes.push(
|
|
670
|
-
`door ${origin}: ${
|
|
677
|
+
`door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")} — run 'ukp register --url ${origin}' to import`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if (taken.length > 0) {
|
|
681
|
+
notes.push(
|
|
682
|
+
`door ${origin}: name(s) taken: ${taken.join(", ")} — import under another handle: 'ukp register --url ${origin}/<name> --name <handle>'`,
|
|
671
683
|
);
|
|
672
684
|
}
|
|
673
685
|
} catch {
|
package/src/commands/read.ts
CHANGED
|
@@ -66,7 +66,7 @@ const READ_EXIT_BY_ERROR_CLASS: Record<ReadErrorClass, number> = {
|
|
|
66
66
|
* dispatch and its precedence stay command-side. */
|
|
67
67
|
export const READ_SPEC: UkpCommandSpec = {
|
|
68
68
|
name: "read",
|
|
69
|
-
summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or docid
|
|
69
|
+
summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or a docid from search results)",
|
|
70
70
|
group: "endpoint",
|
|
71
71
|
description:
|
|
72
72
|
"Read an endpoint-scoped resource reference from one Service endpoint (endpoint names come from 'ukp list'). "
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Windows resident-door artifact generator (W12): `ukp serve --print-task`
|
|
2
|
+
* renders the process-manager artifacts for an https door on a Windows host
|
|
3
|
+
* — the start script, the hidden launcher (vbs), the Task Scheduler command,
|
|
4
|
+
* the firewall rule — as text the operator reviews and applies. Print-only
|
|
5
|
+
* by construction: UKP installs nothing, starts nothing, and never sees the
|
|
6
|
+
* real token (the script line carries a placeholder; the operator pastes the
|
|
7
|
+
* token into the file they save, under their own permission regime).
|
|
8
|
+
*
|
|
9
|
+
* The emitted shapes mirror the machine-verified liku recipe (deployment
|
|
10
|
+
* handbook, Windows section): a bare `ukp` resolves through the logged-on
|
|
11
|
+
* task's per-user PATH and the self-locating launcher finds bun itself, so
|
|
12
|
+
* no PATH export is emitted. The hidden launcher is the product default
|
|
13
|
+
* (owner ruling 2026-09-21: a visibly popping cmd window is not a correct
|
|
14
|
+
* product form): zero flash (hidden at process creation), door.log capture,
|
|
15
|
+
* exit-code propagation, bounded crash retry — Task Scheduler's own
|
|
16
|
+
* restart-on-failure does not fire on exit codes (disproven on liku). */
|
|
17
|
+
|
|
18
|
+
export interface ServeTaskTlsSelfSigned {
|
|
19
|
+
mode: "self-signed";
|
|
20
|
+
sanEntries: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServeTaskTlsCertificates {
|
|
24
|
+
mode: "certificates";
|
|
25
|
+
certPath: string;
|
|
26
|
+
keyPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ServeTaskTls = ServeTaskTlsSelfSigned | ServeTaskTlsCertificates;
|
|
30
|
+
|
|
31
|
+
export interface ServeTaskInput {
|
|
32
|
+
/** Present = single-endpoint mode; absent = host door mode. */
|
|
33
|
+
endpoint?: string;
|
|
34
|
+
host: string;
|
|
35
|
+
port: number;
|
|
36
|
+
tls?: ServeTaskTls;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Door-shape guards the command layer runs before printing. Pure so both
|
|
40
|
+
* platforms are testable: `platform` is `process.platform` in production.
|
|
41
|
+
* These mirror what serve itself would refuse at task start — they never
|
|
42
|
+
* widen admission, and the TLS one stops a dead-end artifact before it is
|
|
43
|
+
* applied (clients refuse to register plain-http urls off loopback). */
|
|
44
|
+
export function printTaskPreflightError(input: {
|
|
45
|
+
platform: string;
|
|
46
|
+
host: string;
|
|
47
|
+
hasTls: boolean;
|
|
48
|
+
allowAnonymous: boolean;
|
|
49
|
+
}): string | undefined {
|
|
50
|
+
if (input.platform !== "win32") {
|
|
51
|
+
return "--print-task is Windows-only; on Linux prefer systemd socket activation (ukp serve --systemd-socket) — the deployment handbook carries that recipe";
|
|
52
|
+
}
|
|
53
|
+
const loopback =
|
|
54
|
+
input.host === "127.0.0.1" || input.host === "localhost" || input.host === "::1" || input.host === "[::1]";
|
|
55
|
+
if (!loopback && !input.hasTls) {
|
|
56
|
+
return "refusing to print an off-loopback door without TLS: clients refuse to register plain-http urls — pass --tls (self-signed, pinned at registration) or --tls-cert/--tls-key, or bind loopback behind a TLS-terminating proxy";
|
|
57
|
+
}
|
|
58
|
+
if (input.allowAnonymous && !loopback) {
|
|
59
|
+
return "refusing to print this shape: --allow-anonymous is loopback-only and serve would refuse it at task start — set the token in the generated script instead";
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function serveCommandLine(input: ServeTaskInput): string {
|
|
65
|
+
const tls =
|
|
66
|
+
input.tls === undefined
|
|
67
|
+
? []
|
|
68
|
+
: input.tls.mode === "self-signed"
|
|
69
|
+
? ["--tls", ...input.tls.sanEntries.map((entry) => `--tls-san ${entry}`)]
|
|
70
|
+
: [`--tls-cert "${input.tls.certPath}"`, `--tls-key "${input.tls.keyPath}"`];
|
|
71
|
+
return [
|
|
72
|
+
"ukp serve",
|
|
73
|
+
...(input.endpoint !== undefined ? [`--endpoint ${input.endpoint}`] : []),
|
|
74
|
+
`--host ${input.host}`,
|
|
75
|
+
`--port ${input.port}`,
|
|
76
|
+
...tls,
|
|
77
|
+
].join(" ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parent directory of a path string, or undefined for a bare filename. */
|
|
81
|
+
function parentDirOf(path: string): string | undefined {
|
|
82
|
+
const cut = Math.max(path.lastIndexOf("\\"), path.lastIndexOf("/"));
|
|
83
|
+
return cut > 0 ? path.slice(0, cut) : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderServeTaskArtifacts(input: ServeTaskInput): string {
|
|
87
|
+
const serveLine = serveCommandLine(input);
|
|
88
|
+
const out: string[] = [
|
|
89
|
+
"ukp serve --print-task — Windows resident-door artifacts",
|
|
90
|
+
"",
|
|
91
|
+
"Print-only: review these, then apply them yourself. UKP installs nothing,",
|
|
92
|
+
"starts nothing, and supervises nothing — the door's lifecycle belongs to",
|
|
93
|
+
"you and your process manager.",
|
|
94
|
+
"",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
if (input.tls?.mode === "certificates") {
|
|
98
|
+
const certDir = parentDirOf(input.tls.certPath);
|
|
99
|
+
out.push(
|
|
100
|
+
"0) Certificate — generate ONCE (Git for Windows' openssl works; this",
|
|
101
|
+
" avoids any runtime openssl dependency of --tls):",
|
|
102
|
+
"",
|
|
103
|
+
" openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout",
|
|
104
|
+
` ${input.tls.keyPath} -out ${input.tls.certPath}`,
|
|
105
|
+
" -subj \"/CN=ukp\" -addext \"subjectAltName=IP:<lan-ip>,DNS:localhost\"",
|
|
106
|
+
"",
|
|
107
|
+
" The SAN must carry the address consumers dial (find <lan-ip> with",
|
|
108
|
+
" ipconfig); a mismatch fails registration with a certificate name",
|
|
109
|
+
" error. Renewing over the same key keeps client pins valid.",
|
|
110
|
+
"",
|
|
111
|
+
);
|
|
112
|
+
if (certDir !== undefined) {
|
|
113
|
+
out.push(` (openssl will not create the directory: if not exist "${certDir}" mkdir "${certDir}" first)`, "");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
out.push(
|
|
118
|
+
"1) Start script — save as %USERPROFILE%\\.ukp\\start-door.cmd",
|
|
119
|
+
" (write it with a real editor and CRLF line endings; writing cmd",
|
|
120
|
+
" files over ssh echo mangles % escaping)",
|
|
121
|
+
"",
|
|
122
|
+
" @echo off",
|
|
123
|
+
" rem UKP resident door (generated by ukp serve --print-task)",
|
|
124
|
+
" rem The token line is the only secret: mint a long random string",
|
|
125
|
+
" rem (e.g. openssl rand -hex 32), paste it in, keep this file private.",
|
|
126
|
+
" set \"UKP_SERVE_TOKEN=<paste-your-token>\"",
|
|
127
|
+
" rem ukp resolves through the logged-on task's per-user PATH; the",
|
|
128
|
+
" rem launcher finds bun itself — no PATH export needed.",
|
|
129
|
+
" if not exist \"%USERPROFILE%\\.ukp\" mkdir \"%USERPROFILE%\\.ukp\"",
|
|
130
|
+
` ${serveLine} >> "%USERPROFILE%\\.ukp\\door.log" 2>&1`,
|
|
131
|
+
"",
|
|
132
|
+
"2) Hidden launcher — save as %USERPROFILE%\\.ukp\\start-door-hidden.vbs",
|
|
133
|
+
" (CRLF and a real editor again: the vbs carries literal % signs that",
|
|
134
|
+
" ssh echo would expand — copy the file instead)",
|
|
135
|
+
"",
|
|
136
|
+
" ' UKP door hidden launcher: runs start-door.cmd with NO visible",
|
|
137
|
+
" ' window (hidden at process creation - nothing flashes), captures",
|
|
138
|
+
" ' everything to door.log, waits out the door's lifetime, propagates",
|
|
139
|
+
" ' its exit code, and retries a crashed door 3 times, 30s apart.",
|
|
140
|
+
" Dim shell : Set shell = CreateObject(\"WScript.Shell\")",
|
|
141
|
+
" Dim q : q = Chr(34)",
|
|
142
|
+
" Dim home : home = shell.ExpandEnvironmentStrings(\"%USERPROFILE%\")",
|
|
143
|
+
" Dim cmdline : cmdline = \"cmd /c \" & q & q & home & \"\\.ukp\\start-door.cmd\" & q & \" >> \" & q & home & \"\\.ukp\\door.log\" & q & \" 2>&1\" & q",
|
|
144
|
+
" Dim rc : rc = 0",
|
|
145
|
+
" Dim attempt",
|
|
146
|
+
" For attempt = 1 To 3",
|
|
147
|
+
" rc = shell.Run(cmdline, 0, True)",
|
|
148
|
+
" If rc = 0 Then Exit For",
|
|
149
|
+
" If attempt < 3 Then WScript.Sleep 30000",
|
|
150
|
+
" Next",
|
|
151
|
+
" WScript.Quit rc",
|
|
152
|
+
"",
|
|
153
|
+
"3) Scheduled task — starts the door at LOGON, hidden",
|
|
154
|
+
" (create it from an elevated shell: creating an ONLOGON task from a",
|
|
155
|
+
" plain shell fails with Access denied)",
|
|
156
|
+
"",
|
|
157
|
+
" schtasks /Create /TN ukp-door /TR \"wscript.exe \\\"%USERPROFILE%\\.ukp\\start-door-hidden.vbs\\\"\" /SC ONLOGON /F",
|
|
158
|
+
" schtasks /Run /TN ukp-door",
|
|
159
|
+
"",
|
|
160
|
+
"4) Firewall rule for the port (from an elevated shell)",
|
|
161
|
+
"",
|
|
162
|
+
` netsh advfirewall firewall add rule name="ukp-door" dir=in action=allow protocol=TCP localport=${input.port}`,
|
|
163
|
+
"",
|
|
164
|
+
"Notes:",
|
|
165
|
+
...(input.tls?.mode === "self-signed"
|
|
166
|
+
? [
|
|
167
|
+
"- --tls self-signs at first start using an openssl on PATH (Git",
|
|
168
|
+
" for Windows' openssl qualifies). On a host with no openssl",
|
|
169
|
+
" anywhere, pre-generate the certificate once instead: re-run with",
|
|
170
|
+
" --tls-cert/--tls-key — the handbook's Windows section carries",
|
|
171
|
+
" that recipe.",
|
|
172
|
+
]
|
|
173
|
+
: []),
|
|
174
|
+
"- No visible window, by design: the hidden launcher starts the console",
|
|
175
|
+
" hidden at process creation (nothing ever flashes on the desktop) and",
|
|
176
|
+
" captures everything to door.log — that redirect is the paper trail (a",
|
|
177
|
+
" silent EADDRINUSE exit leaves a line).",
|
|
178
|
+
"- Crash self-recovery is the launcher's bounded retry: a non-zero exit",
|
|
179
|
+
" is retried up to 3 times, 30s apart; exhausted retries leave the",
|
|
180
|
+
" failure visible (non-zero task result + door.log). Do not rely on",
|
|
181
|
+
" Task Scheduler's restart-on-failure setting — it does not fire on",
|
|
182
|
+
" exit codes.",
|
|
183
|
+
"- To restart the door by hand (new token, ukp upgrade, new cert):",
|
|
184
|
+
" taskkill /PID <door-pid> /T /F, then schtasks /Run /TN ukp-door",
|
|
185
|
+
" (schtasks /End does not reliably kill the process tree).",
|
|
186
|
+
"- LOGON, not boot: the door starts when someone logs on — nobody",
|
|
187
|
+
" logged on means no door. Unattended-boot always-on needs a service",
|
|
188
|
+
" wrapper (WinSW-class) — deliberately outside this recipe; prefer the",
|
|
189
|
+
" ssh:// path or a Linux host (systemd socket activation) for that.",
|
|
190
|
+
"- Re-run ukp serve --print-task with different flags to regenerate;",
|
|
191
|
+
" edits land by saving the files and re-running schtasks /Run.",
|
|
192
|
+
"- Full recipes and hardening: the deployment handbook —",
|
|
193
|
+
" docs/remote-deployment.md in this package, or",
|
|
194
|
+
" https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md",
|
|
195
|
+
"",
|
|
196
|
+
);
|
|
197
|
+
return out.join("\n");
|
|
198
|
+
}
|
package/src/commands/serve.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./kit.ts";
|
|
10
10
|
import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
|
|
11
11
|
import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
|
|
12
|
+
import { printTaskPreflightError, renderServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
|
|
12
13
|
|
|
13
14
|
export interface ServeCommandContext {
|
|
14
15
|
currentDirectory: string;
|
|
@@ -33,11 +34,11 @@ function parseServeTokens(raw: string | undefined): string[] {
|
|
|
33
34
|
* over `/e/<name>/` routing. */
|
|
34
35
|
export const SERVE_SPEC: UkpCommandSpec = {
|
|
35
36
|
name: "serve",
|
|
36
|
-
summary: "serve one endpoint — or the whole registry as a host door
|
|
37
|
+
summary: "serve one endpoint — or the whole registry as a host door, one HTTP server for every endpoint — for remote UKP clients",
|
|
37
38
|
group: "operations",
|
|
38
39
|
description:
|
|
39
40
|
"Expose a registered endpoint over HTTP using the ukp-remote wire, or — without --endpoint — serve every local endpoint as one host door routed by name.",
|
|
40
|
-
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket]",
|
|
41
|
+
usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task]",
|
|
41
42
|
options: [
|
|
42
43
|
{ flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
|
|
43
44
|
{ flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
|
|
@@ -49,6 +50,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
49
50
|
{ flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
|
|
50
51
|
{ flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors — fractional values accepted for tests)" },
|
|
51
52
|
{ flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port — the port belongs to your .socket unit; Linux-only" },
|
|
53
|
+
{ flags: "--print-task", help: "Windows: print this door's process-manager artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) instead of serving — review and apply them yourself; nothing is installed or started for you, and the token is yours to paste (never printed)" },
|
|
52
54
|
],
|
|
53
55
|
helpSuffix: [
|
|
54
56
|
"",
|
|
@@ -81,7 +83,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
|
|
|
81
83
|
" 'ukp register --url ssh://<host>' — zero tokens, docker",
|
|
82
84
|
" DOCKER_HOST=ssh:// posture). It is not the way to consume a",
|
|
83
85
|
" same-machine endpoint — register its local path instead.",
|
|
84
|
-
" TLS
|
|
86
|
+
" TLS: pass --tls to serve HTTPS with a self-signed identity",
|
|
85
87
|
" (generated under .ukp/tls/ — under the registry directory in door",
|
|
86
88
|
" mode —, SAN covers this host's addresses; remote clients TOFU-pin it",
|
|
87
89
|
" at registration and refresh by re-registering), or --tls-cert/--tls-key",
|
|
@@ -107,6 +109,9 @@ export interface ParsedServe {
|
|
|
107
109
|
maxIdleSeconds?: number;
|
|
108
110
|
/** Present = the systemd-passed listener (W10, Linux-only). */
|
|
109
111
|
systemdSocket?: boolean;
|
|
112
|
+
/** Present = render the Windows process-manager artifacts instead of
|
|
113
|
+
* serving (W12; Windows-only, print-only, never starts a listener). */
|
|
114
|
+
printTask?: boolean;
|
|
110
115
|
/** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
|
|
111
116
|
* valid only next to `--tls` — the execute layer enforces the pairing.
|
|
112
117
|
* Absent when the flag is not passed (parse output stays byte-identical
|
|
@@ -115,7 +120,7 @@ export interface ParsedServe {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
118
|
-
const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; tlsSan?: string[] };
|
|
123
|
+
const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; tlsSan?: string[] };
|
|
119
124
|
const endpoint = options.endpoint;
|
|
120
125
|
if (endpoint !== undefined && endpoint.length === 0) {
|
|
121
126
|
throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
|
|
@@ -129,12 +134,19 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
129
134
|
if (systemdSocket && options.port !== undefined) {
|
|
130
135
|
throw new KitUsageError("--systemd-socket and --port are mutually exclusive (the socket unit owns the port)");
|
|
131
136
|
}
|
|
137
|
+
const printTask = options.printTask === true;
|
|
138
|
+
if (printTask && systemdSocket) {
|
|
139
|
+
throw new KitUsageError("--print-task is the Windows resident form; --systemd-socket is the Linux one (systemd holds the port — see the deployment handbook)");
|
|
140
|
+
}
|
|
132
141
|
let maxIdleSeconds: number | undefined;
|
|
133
142
|
if (options.maxIdle !== undefined) {
|
|
134
143
|
maxIdleSeconds = Number(options.maxIdle);
|
|
135
144
|
if (!Number.isFinite(maxIdleSeconds) || maxIdleSeconds <= 0 || maxIdleSeconds > 86400) {
|
|
136
145
|
throw new KitUsageError("--max-idle must be a positive number of seconds (at most 86400)");
|
|
137
146
|
}
|
|
147
|
+
if (printTask) {
|
|
148
|
+
throw new KitUsageError("--print-task is for a resident door; --max-idle self-reaps (it is the on-demand backstop) and would leave the door down until the next logon");
|
|
149
|
+
}
|
|
138
150
|
}
|
|
139
151
|
let tlsSan: string[] = [];
|
|
140
152
|
if (options.tlsSan !== undefined) {
|
|
@@ -152,6 +164,7 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
|
|
|
152
164
|
port,
|
|
153
165
|
...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
|
|
154
166
|
...(systemdSocket ? { systemdSocket } : {}),
|
|
167
|
+
...(printTask ? { printTask } : {}),
|
|
155
168
|
...(tlsSan.length > 0 ? { tlsSan } : {}),
|
|
156
169
|
};
|
|
157
170
|
}
|
|
@@ -230,6 +243,7 @@ export function executeServeCommand(
|
|
|
230
243
|
return executeKitCommand(SERVE_SPEC, args, (parsed) => {
|
|
231
244
|
const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
|
|
232
245
|
const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
|
|
246
|
+
const printTask = (parsed.options as { printTask?: boolean }).printTask === true;
|
|
233
247
|
const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
|
|
234
248
|
// TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
|
|
235
249
|
// exclusive; the explicit pair must arrive complete. --tls-san only
|
|
@@ -244,6 +258,37 @@ export function executeServeCommand(
|
|
|
244
258
|
if (tlsSan.length > 0 && options.tls !== true) {
|
|
245
259
|
throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
|
|
246
260
|
}
|
|
261
|
+
// W12 print-only branch: render the Windows artifacts and stop. No
|
|
262
|
+
// listener, no admission token check (the token is pasted into the
|
|
263
|
+
// generated script later), and the env token is deliberately not read
|
|
264
|
+
// — the output is secret-free by construction.
|
|
265
|
+
if (printTask) {
|
|
266
|
+
const preflight = printTaskPreflightError({
|
|
267
|
+
platform: process.platform,
|
|
268
|
+
host,
|
|
269
|
+
hasTls: options.tls === true || options.tlsCert !== undefined,
|
|
270
|
+
allowAnonymous,
|
|
271
|
+
});
|
|
272
|
+
if (preflight !== undefined) {
|
|
273
|
+
throw new Error(preflight);
|
|
274
|
+
}
|
|
275
|
+
const tls: ServeTaskTls | undefined =
|
|
276
|
+
options.tls === true
|
|
277
|
+
? { mode: "self-signed", sanEntries: tlsSan }
|
|
278
|
+
: options.tlsCert !== undefined && options.tlsKey !== undefined
|
|
279
|
+
? { mode: "certificates", certPath: options.tlsCert, keyPath: options.tlsKey }
|
|
280
|
+
: undefined;
|
|
281
|
+
return {
|
|
282
|
+
exitCode: 0,
|
|
283
|
+
stdout: renderServeTaskArtifacts({
|
|
284
|
+
host,
|
|
285
|
+
port,
|
|
286
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
287
|
+
...(tls !== undefined ? { tls } : {}),
|
|
288
|
+
}),
|
|
289
|
+
stderr: "",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
247
292
|
const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
|
|
248
293
|
// W10: socket activation must not admit tokenless serving — the bind
|
|
249
294
|
// address belongs to the socket unit (possibly public), so the loopback
|