home-hosted 0.7.1 → 0.7.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/docs/DDNS.md CHANGED
@@ -68,9 +68,10 @@ and the guess is wrong (`home.example.co.uk` is handled; an unusual suffix may n
68
68
  | **Dynu** | A, AAAA | Username + password | `password` may be the SHA-256 hash. |
69
69
  | **Hurricane Electric** | A, AAAA | Per-host DDNS key | `dyn.dns.he.net`. |
70
70
  | **FreeDNS (afraid.org)** | A, AAAA | The per-record "Direct URL" | Paste the whole URL or just its hash. |
71
+ | **Namecheap (API)** | — | API user + key + whitelisted IP | For **DNS-01 only**: it answers a challenge through the XML API, which rewrites the whole zone per write. It updates no addresses — use the Dynamic DNS provider above for that. The client IP must be whitelisted under Profile → Tools → Namecheap API Access, and error `1017105` means it is not. |
71
72
 
72
- Not supported: **Route 53** (AWS SigV4 signing, a much larger dependency), **DreamHost** (no public
73
- DNS API), and the Namecheap XML API (a full zone rewrite per update).
73
+ Not supported: **Route 53** (AWS SigV4 signing, a much larger dependency) and **DreamHost** (no
74
+ public DNS API).
74
75
 
75
76
  ## Notifications
76
77
 
@@ -17,23 +17,25 @@ opt-in, and the bot token never leaves the secrets file.
17
17
 
18
18
  ```bash
19
19
  TOKEN='123456:ABC...'
20
+ WS='?workspace=default' # every notifications route is workspace-scoped; omit for the default one
20
21
 
21
- curl -X POST http://127.0.0.1:3999/api/notifications/token \
22
+ curl -X POST "http://127.0.0.1:3999/api/notifications/token$WS" \
22
23
  -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
23
24
  -d "{\"botToken\":\"$TOKEN\"}"
24
25
 
25
26
  # which chats can this bot see (after you message it once)?
26
- curl -X POST http://127.0.0.1:3999/api/notifications/detect-chats \
27
+ curl -X POST "http://127.0.0.1:3999/api/notifications/detect-chats$WS" \
27
28
  -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
28
29
  -d "{\"botToken\":\"$TOKEN\"}"
29
30
 
30
- curl -X POST http://127.0.0.1:3999/api/notifications/test \
31
+ curl -X POST "http://127.0.0.1:3999/api/notifications/test$WS" \
31
32
  -H 'content-type: application/json' -H "Authorization: Bearer $HH_TOKEN" \
32
33
  -d '{"chatId":"123456789"}'
33
34
  ```
34
35
 
35
36
  `detect-chats` and `test` accept an override, so a token can be tried before it is saved.
36
- `DELETE /api/notifications/token` removes it.
37
+ `DELETE /api/notifications/token` removes it. Each workspace keeps its own token and target; the
38
+ panel-wide host alerts use every workspace's own settings.
37
39
 
38
40
  </details>
39
41
 
@@ -0,0 +1,242 @@
1
+ # Reverse proxy
2
+
3
+ Expose the whole stack through one engine with automatic HTTPS: `git.example.com` →
4
+ your Gitea entry, `media.example.com` → Jellyfin, `panel.example.com` → this panel.
5
+
6
+ The engine is **Caddy**, downloaded and supervised by the panel. You never edit its
7
+ configuration — the panel generates it from the route table and applies it through
8
+ Caddy's admin API, which is atomic: a configuration that does not load leaves the
9
+ running one in place.
10
+
11
+ Setup is **Others → Reverse Proxy** in the sidebar.
12
+
13
+ ## Quick start
14
+
15
+ 1. **Install the engine** — one button, the current Caddy release (~46 MB) into
16
+ `$HHOSTED_HOME/.hh/.proxy/bin/`. The version the binary reports is shown afterwards.
17
+ 2. **Set the ports** — `80` and `443` by default, plus the ACME account e-mail.
18
+ 3. **Add a route** — a hostname and a target.
19
+ 4. Point the hostname's DNS at this machine (Dynamic DNS can do that) and switch the
20
+ proxy on.
21
+
22
+ ```json
23
+ {
24
+ "proxy": {
25
+ "enabled": true,
26
+ "engine": "caddy",
27
+ "httpPort": 80,
28
+ "httpsPort": 443,
29
+ "email": "me@example.com",
30
+ "staging": false,
31
+ "routes": [
32
+ { "id": "gitea", "host": "git.example.com", "target": "server", "workspace": "default", "server": "gitea" },
33
+ { "id": "panel", "host": "panel.example.com", "target": "panel" },
34
+ { "id": "nas", "host": "nas.example.com", "target": "external", "url": "http://10.0.0.5:8080", "tls": "off" }
35
+ ]
36
+ }
37
+ }
38
+ ```
39
+
40
+ That block lives in `$HHOSTED_HOME/.hh/settings.json` — the panel writes it for you.
41
+
42
+ ## Ports
43
+
44
+ `80` and `443` are what automatic HTTPS wants: a certificate is issued over an ACME
45
+ challenge on one of them, and anything else needs the router to forward them anyway.
46
+
47
+ Binding below 1024 needs a privilege. On Linux:
48
+
49
+ ```sh
50
+ sudo setcap 'cap_net_bind_service=+ep' "$HHOSTED_HOME/.hh/.proxy/bin/hh-caddy"
51
+ ```
52
+
53
+ Otherwise use **4480/4443** — one click in the page, or `httpPort`/`httpsPort` — and
54
+ forward `80 → 4480` and `443 → 4443` on your router. A port already in use is reported with
55
+ the pid that holds it, on a first start and on a later change alike, and no listener the
56
+ panel did not start is ever killed.
57
+
58
+ ## Routes
59
+
60
+ | Field | Notes |
61
+ | --- | --- |
62
+ | `id` | Stable name, `[a-z0-9_-]`. |
63
+ | `host` | The public hostname. A local-only name (`gitea.lan`, `box`, an IP) gets the engine's own CA instead of a public certificate. |
64
+ | `enabled` | A disabled route stays configured but is not served. |
65
+ | `target` | `server` (a supervised entry), `panel` (this control panel), `external` (any `host:port`). |
66
+ | `workspace` + `server` | For `target: "server"` — an id is only unique inside its workspace. The upstream address is resolved live; a stopped entry leaves the hostname dark and the page says so. |
67
+ | `url` | For `target: "external"`, e.g. `http://10.0.0.5:8080`. An `https://` upstream is dialled with TLS, and **its certificate is not verified** — a homelab upstream behind a self-signed certificate works, and so does an impostor. |
68
+ | `path` | Optional prefix, so one hostname can serve several apps. |
69
+ | `tls` | `auto` (default), `off`, `manual`. |
70
+
71
+ The list's hostname is a link to where the route answers — `https://host:<https port>`, or
72
+ the plain port for `tls: "off"`, path included. A route that is switched off is plain text.
73
+
74
+ A route pointing at the panel is exposure, and answers to the same rule as binding the
75
+ listener beyond loopback: authentication on and a non-default password, or the save is
76
+ refused.
77
+
78
+ ## HTTPS
79
+
80
+ | `tls` | What it means |
81
+ | --- | --- |
82
+ | `auto` | The engine decides. A public name gets a Let's Encrypt certificate (HTTP-01 or TLS-ALPN-01, with ZeroSSL as fallback and renewal in the background). A local-only name gets the engine's own locally-trusted CA. |
83
+ | `off` | Plain HTTP on the http port, no certificate. |
84
+ | `manual` | One of the uploaded pairs, whichever covers the hostname (the engine picks by SNI). A route set to this with no pair covering it is refused, rather than quietly given a certificate from somewhere else. |
85
+
86
+ `email` is required as soon as a route uses a public hostname, and it is the contact
87
+ address the ACME account is registered with. `staging` points that account at the ACME
88
+ staging endpoint while you test: the certificates are untrusted, and it is the way to
89
+ avoid burning a rate limit. Both reach the engine as one automation policy covering the
90
+ **public** names only, so a local-only name keeps the engine's own CA. The engine page
91
+ shows the soonest expiry among the publicly-issued certificates. Issuance is Let's
92
+ Encrypt; ZeroSSL is not offered as a second issuer because it needs an EAB key the panel
93
+ does not collect.
94
+
95
+ The challenge is the part that catches people out: with the proxy on 4480/4443, **80 has
96
+ to reach 4480 and 443 has to reach 4443**, or the CA never sees the challenge and the name
97
+ never gets its certificate.
98
+
99
+ A name the CA will not issue for falls back to the engine's own CA. The route reads
100
+ **Fell back to Local CA**: the name answers, but with an untrusted certificate, and the
101
+ engine keeps trying on its own. certmagic renews once a third of a certificate's lifetime
102
+ is left, and the engine's own CA issues 12 hours by default, so the next attempt is about
103
+ **8 hours** away — no restart, no downtime.
104
+
105
+ **Force retry certificate** appears on that row only, and asks the CA again immediately.
106
+ The engine is **not** restarted: a certificate the engine holds cannot be cleared by
107
+ deleting the file or by reloading the configuration, but it *is* released when the name
108
+ leaves the configuration — so the panel reloads without that one route, drops the stored
109
+ pair while it is out, and puts the route back. Every other route keeps serving; the name
110
+ itself has no certificate until the CA answers. It is not offered while a name is still
111
+ *pending*, because there the engine is already mid-ACME.
112
+
113
+ A **removed** route has its stored certificate dropped on the next apply, so adding it
114
+ back asks the CA instead of serving the old pair. A **disabled** route keeps its
115
+ certificate: it is still configured, and switching it back on should not cost a new
116
+ issuance.
117
+
118
+ The wait can be shortened by giving the engine's own issuer a shorter `lifetime` in the
119
+ generated policy; the row reads the interval from the certificate, so the confirmation
120
+ follows whatever is configured.
121
+
122
+ A hostname **no route claims** gets a page saying so, on both ports — not a redirect into
123
+ a handshake that cannot finish. A visitor with no hostname at all (a bare IP on the HTTPS
124
+ port) is answered by the engine's own CA under `hh-fallback.invalid`, so the page appears
125
+ after the browser's warning. A hostname that is neither routed nor covered by a
126
+ certificate still fails its handshake: there is nothing to present a certificate for.
127
+
128
+ **A name with no certificate yet is still reachable.** The engine's own CA is the last
129
+ issuer in the policy, so the handshake completes with an untrusted certificate — the
130
+ browser offers to continue instead of failing with `ERR_SSL_PROTOCOL_ERROR` — and over
131
+ plain HTTP the same name answers with a page saying so, naming the ports to forward,
132
+ rather than redirecting into a handshake that cannot finish. The panel re-applies the
133
+ configuration when the certificate arrives, and the route table shows where each name
134
+ stands: *issued* · *pending* · *failed* (with the engine's own reason) · *local* (the
135
+ engine CA) · *uploaded*.
136
+
137
+ ## Uploaded certificates
138
+
139
+ *Manual certificate* takes as many PEM pairs as you have names: add one per hostname,
140
+ label it, and the engine serves each to the name it covers. A route set to `manual` is
141
+ refused while no uploaded pair covers its hostname, and a pair cannot be removed while a
142
+ route still serves it — so the two can never disagree.
143
+
144
+ Certificates are read, never edited: the panel reports each pair's subject, issuer,
145
+ expiry and the hostnames it covers (from its SANs, wildcards included), and marks a pair
146
+ no route would be served from. An expired pair is never served — the route that asks for
147
+ it is refused with the reason. An uploaded pair is re-read as soon as its file changes,
148
+ so editing or replacing one outside the panel is picked up on the next look; the engine's
149
+ own certificate store (the issued certificates) is read at most every 15 seconds.
150
+
151
+ Certificates are managed by the engine, inside `.hh/.proxy/engine/`, and nothing else on
152
+ the machine is touched: the generated configuration pins its storage root, and the
153
+ panel passes the engine no `$HOME` of its own to write into.
154
+
155
+ **DNS-01** is the way round a router that will not forward 80 and 443. A route is one
156
+ hostname, so there is no wildcard to issue; what DNS-01 buys is a certificate with
157
+ **no inbound port at all** — behind CGNAT, or on a host only reachable over VPN.
158
+ Turn it on in the page and point each route at a DNS account:
159
+
160
+ ```json
161
+ {
162
+ "proxy": {
163
+ "dns01": { "enabled": true, "resolvers": [] },
164
+ "routes": [
165
+ { "id": "gitea", "host": "git.example.com", "target": "server", "workspace": "default", "server": "gitea", "dnsAccount": "default/cf" }
166
+ ]
167
+ }
168
+ }
169
+ ```
170
+
171
+ The account is the one from **Workspace Settings → Dynamic DNS** — same credentials,
172
+ same workspace, same notification routing. It does not need the hostname in its
173
+ hostname list: the account names the zone, the route names the subject. A route may
174
+ name a different account from its neighbour, so names in two zones at two registrars
175
+ each get the right one; the panel gives each account a policy of its own.
176
+
177
+ Leave a route on **Automatic** and it uses the only account that can answer a
178
+ challenge. With more than one account, Automatic cannot choose: pick one per route, or
179
+ those names keep using the port challenges. A local-only name (`.lan`, an IP) is never
180
+ challenged — it is signed by the engine's own CA.
181
+
182
+ The account is checked when you save, not when the certificate is due: unknown, an
183
+ account whose provider cannot write a `TXT` record (the router-style Dynamic DNS
184
+ password providers), or one with no credentials stored yet is refused there.
185
+
186
+ Which accounts can answer: any whose provider writes a `TXT` record. Today that is
187
+ Cloudflare and **Namecheap (API)** — the latter is a separate provider from Namecheap's
188
+ Dynamic DNS entry, because its credentials differ (API user, key and a whitelisted
189
+ client IP) and it rewrites the whole zone per write. Namecheap's Dynamic DNS password
190
+ cannot write a TXT record at all, so that account is refused with the reason.
191
+
192
+ The engine holds no DNS credentials. It asks the panel over the ACMEProxy protocol on
193
+ loopback, with credentials the panel generated for it, and the panel writes the
194
+ `_acme-challenge` record through that account. `resolvers` overrides the nameservers
195
+ the engine checks the record against, which is what split-horizon DNS needs.
196
+
197
+ Setting it up needs no inbound port, but the record has to be **published** before the
198
+ CA looks: the panel writes it, the CA reads it, and the panel removes it again. A
199
+ provider that can only write a whole record set — Namecheap's XML API is the one —
200
+ is read-modify-write, so the panel rewrites records it did not create, and refuses
201
+ rather than adding a 151st record to a zone already at the limit.
202
+
203
+ ## The engine
204
+
205
+ | | |
206
+ | --- | --- |
207
+ | Comes from | Caddy's own build service (`caddyserver.com/api/download`), at **its current release** — the panel pins no version, so **Update engine** is how a security fix reaches you. |
208
+ | Verified against | **Caddy 2.11.6.** That is the release this build was tested with; a newer one is installed anyway, and the panel reports the version that actually arrived. A release that changes the JSON schema may need a panel update first. |
209
+ | Carries | The `acmeproxy` DNS provider, compiled in for every install so the binary does not depend on a setting. `DNS-01` is offered because of it; an engine installed by an older release is refused with *update the engine* rather than failing inside Caddy. |
210
+ | Installed at | `.hh/.proxy/bin/hh-caddy` + `engine.json` (version, URL, size, when). |
211
+ | Replaceable | Drop any `hh-caddy` build at that path and the panel runs it: it reports *a path configured by hand* and probes no version. Installing or updating is refused while the engine is up — **Stop** it first, because the binary is replaced on disk. |
212
+ | Runs as | A child of the panel's own nanny, exactly like a `persistent: true` entry. |
213
+ | Supervised by | The panel. The admin API is opened on the panel side only: a unix socket in a `0700` directory (loopback TCP plus Caddy's origin check on Windows). |
214
+ | Reloaded by | `POST /load` — atomic, and the revision that applied is kept on disk so a change can be reverted in one click (the first one has nothing to go back to). A reload onto a port something else holds is refused with that pid before the engine is touched, and the panel keeps reporting the ports the engine is really on. |
215
+ | Stops with | **Stop** in the page, switching the proxy off, or `POST /api/proxy/stop`. It never stops itself, and `down` leaves it serving — the socket facing the internet is not something a panel restart should drop. It is never restarted automatically either: a configuration the engine refuses needs a person, not a retry loop. |
216
+
217
+ Because it runs under a nanny, a panel restart does not drop the socket that faces the
218
+ internet: the engine keeps serving, and the panel reattaches to it on the next boot.
219
+
220
+ ## API
221
+
222
+ | Route | What it does |
223
+ | --- | --- |
224
+ | `GET /api/proxy` | Policy, engine, live state and every route with its resolved upstream. |
225
+ | `PATCH /api/proxy` | Change the settings or replace the route list, then apply. |
226
+ | `POST /api/proxy/engine` | Install or update the engine (`{ "version": "" }` = the current release). |
227
+ | `PUT` \| `DELETE /api/proxy/certificates/:id` | Store or remove one uploaded pair (`{ label, certificate, privateKey }`). |
228
+ | `POST /api/proxy/start` \| `stop` \| `apply` \| `revert` | Engine lifecycle and configuration. |
229
+ | `POST /api/proxy/routes/:id/retry-certificate` | Ask the CA again for one route, without restarting the engine. |
230
+ | `POST /_acme/present` \| `cleanup` | The engine's DNS-01 channel: loopback only, and behind credentials the panel generated. Not part of `/api`, so it needs no session. |
231
+
232
+
233
+ ## Files
234
+
235
+ | Path | What |
236
+ | --- | --- |
237
+ | `.hh/.proxy/bin/` | The engine binary and the record of what was installed. |
238
+ | `.hh/.proxy/engine/current.json` | The generated configuration; `previous.json` is the revision before it. Both are `0600`: with DNS-01 on they carry the challenge credentials the engine authenticates with. |
239
+ | `.hh/.proxy/state/` | The nanny's spec and state, and `admin.json` (how to reach a running engine). |
240
+ | `.hh/.proxy/state/challenge.json` | The credentials the engine presents to `/_acme`, `0600`; written only when DNS-01 is on. |
241
+ | `.hh/.proxy/tls/<id>.crt.pem` \| `<id>.key.pem` | One uploaded pair per entry (the key is `0600`). |
242
+ | `.hh/.logs/proxy.log` | The engine's own JSON output, as the nanny captures it. |
@@ -90,6 +90,21 @@ must never lock you out of your own machine.
90
90
  drive letters.
91
91
  7. **Offline and plain-http friendly.** Assume a LAN over `http://`: no `Secure`-only cookies, no
92
92
  hard-coded port, no https-only APIs.
93
+ 8. **Give each page its own path.** An SPA with canonical URLs is the best default: a path is what
94
+ makes a page bookmarkable, shareable, and reachable with back/forward — and matching the stock
95
+ paths keeps two UIs of the same panel feeling alike. Those are:
96
+
97
+ | page | path |
98
+ | --- | --- |
99
+ | workspace: overview · servers · logs · settings | `/w/<workspace>` · `/w/<workspace>/servers` · `/w/<workspace>/logs` · `/w/<workspace>/settings` |
100
+ | one server | `/w/<workspace>/servers/<id>` |
101
+ | panel-wide: overview · settings | `/global/overview` · `/global/settings` |
102
+ | reverse proxy | `/proxy` |
103
+
104
+ Keep the workspace id in the URL (a link then opens the same workspace in a browser that never
105
+ selected it), and rewrite bare or unknown paths onto the canonical one (`/` → Global Overview) so an
106
+ old bookmark still lands somewhere sensible. **Not a hard rule** — a different direction may route
107
+ however it fits; just keep deep links working.
93
108
 
94
109
  ## The API
95
110
 
@@ -124,6 +139,7 @@ naming one that does not exist is a `404 UNKNOWN_WORKSPACE`, never a silent fall
124
139
  | `GET` / `PATCH /api/settings/workspace` | one workspace's settings: server defaults, log retention, notifications |
125
140
  | `POST` / `DELETE /api/settings/ui` | install or revert a UI — what Global settings calls |
126
141
  | `POST` / `DELETE /api/settings/tls` | upload or clear a PEM pair |
142
+ | `GET` / `PATCH /api/proxy`, `POST /api/proxy/{engine,start,stop,apply,revert}`, `PUT` / `DELETE /api/proxy/tls` | the panel-wide reverse proxy: engine, route table, its own PEM pair. Its live view also rides in the state frame as an optional `proxy` — read it defensively |
127
143
  | `GET` / `POST /api/backups`, `/api/backups/restore`, `/api/backups/:name/download` | archives; entries are global (`global:settings`, `global:secrets`, `global:tls`) or per workspace, whose leaves are `workspace:<id>:settings`, `:servers`, `:secrets` plus `data:<path>`; a create/restore body `include` selects them |
128
144
  | `POST` / `DELETE /api/notifications/token`, `/api/notifications/test`, `/detect-chats` | Telegram, per workspace |
129
145
  | `POST /api/auth/login`, `GET /api/auth/session`, `POST /api/auth/logout` | the session |
@@ -246,7 +262,7 @@ with an installed panel on the default 3999; override with `--port`/`--ui-port`
246
262
  - [ ] only `/api/...` calls, no absolute origins, no hard-coded port
247
263
  - [ ] `401 { code: 'AUTH_REQUIRED' }` handled with a login screen
248
264
  - [ ] live data from SSE — a panel that only polls feels broken
249
- - [ ] deep links render (the server falls back to `index.html`)
265
+ - [ ] deep links render (the server falls back to `index.html`) and each page has its own path
250
266
  - [ ] assets self-hosted, no CDN
251
267
  - [ ] works over plain http on a LAN
252
268
  - [ ] `ui.json` with a name and version, so Global settings can tell you what is installed — plus `repo`/`tag`/`asset` if you want `ui-update` to follow your releases
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "home-hosted",
3
3
  "type": "module",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "packageManager": "pnpm@12.5.1",
6
6
  "description": "Self-hosted control panel that keeps your home-server processes alive: supervision, restarts, port conflicts, logs — and a UI you can replace.",
7
7
  "author": "NamesMT <dangquoctrung123@gmail.com>",
@@ -57,6 +57,7 @@
57
57
  "dist",
58
58
  "docs/DDNS.md",
59
59
  "docs/NOTIFICATIONS.md",
60
+ "docs/REVERSE_PROXY.md",
60
61
  "docs/SERVERS.md",
61
62
  "docs/UI_CREATION.md",
62
63
  "uis/stock/dist"
@@ -132,7 +133,7 @@
132
133
  "vue-tsc": "^3.3.11"
133
134
  },
134
135
  "simple-git-hooks": {
135
- "pre-commit": "pnpm lint-staged"
136
+ "pre-commit": "pnpm lint-staged && node scripts/stamp-uis.mjs"
136
137
  },
137
138
  "lint-staged": {
138
139
  "*": "eslint --fix"