OverviewFilesCommitsIssuesDocsReleasesPeers

host-services commits / 305f8ca

host-services 0.1.2: page titles on each card; follow same-port redirects; UTF-8 titles
- Each card gets a web line under its name: the page title, linked, with its port ("Sonarr :8989"), so it is clear what each port serves. - probeWeb follows up to 2 redirects that stay on the probed port, so UIs that answer / with a 30x (Jellyfin, Radarr, Jellyseerr, pear-git-v3, favela-host) get their landing-page title instead of none. Used by host + LAN scans. - Error-page titles ("404 Not Found", "Unauthorized", "Directory listing for") are dropped: they name the error, not the service. - Probe responses decode as UTF-8, not latin1 ("repos · pear-git-v3"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305f8ca80bee6d014c5792a4559e19193bb4b4c0
by plankatron <fleeky@gmail.com> · 2026-10-20T02:09:51.000Z

Changed files (10) +226 -63

.pgh-lock.json +53 -53
……@@ -1,9 +1,9 @@
11{
22 "name": "host-services",
3 "version": "0.1.1",
3 "version": "0.1.2",
44 "runtime": "bare",
5 "rootTreeHash": "7cbabed6ec87cf63386625fd69b7a8ffc7c3448f0fd510f20e195eacbc8a81a8",
6 "publishedAt": "2026-10-20T00:46:07.584Z",
5 "rootTreeHash": "92aa2ddeca1345d8a5abbc0417f06c2fa31e0baa0f70aaa4ea27e68c6773416d",
6 "publishedAt": "2026-10-20T02:09:28.207Z",
77 "deps": [
88 {
99 "name": "@jridgewell/resolve-uri",
……@@ -931,7 +931,7 @@
931931 }
932932 ],
933933 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
934 "acceptedAt": "2026-10-20T00:46:07.608Z"
934 "acceptedAt": "2026-10-20T02:09:28.253Z"
935935 },
936936 {
937937 "type": "VERSION_CONFLICT",
……@@ -1021,7 +1021,7 @@
10211021 }
10221022 ],
10231023 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1024 "acceptedAt": "2026-10-20T00:46:07.608Z"
1024 "acceptedAt": "2026-10-20T02:09:28.253Z"
10251025 },
10261026 {
10271027 "type": "VERSION_CONFLICT",
……@@ -1071,7 +1071,7 @@
10711071 }
10721072 ],
10731073 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1074 "acceptedAt": "2026-10-20T00:46:07.608Z"
1074 "acceptedAt": "2026-10-20T02:09:28.253Z"
10751075 },
10761076 {
10771077 "type": "VERSION_CONFLICT",
……@@ -1111,7 +1111,7 @@
11111111 }
11121112 ],
11131113 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1114 "acceptedAt": "2026-10-20T00:46:07.608Z"
1114 "acceptedAt": "2026-10-20T02:09:28.253Z"
11151115 },
11161116 {
11171117 "type": "VERSION_CONFLICT",
……@@ -1191,7 +1191,7 @@
11911191 }
11921192 ],
11931193 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1194 "acceptedAt": "2026-10-20T00:46:07.608Z"
1194 "acceptedAt": "2026-10-20T02:09:28.253Z"
11951195 },
11961196 {
11971197 "type": "VERSION_CONFLICT",
……@@ -1251,7 +1251,7 @@
12511251 }
12521252 ],
12531253 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1254 "acceptedAt": "2026-10-20T00:46:07.608Z"
1254 "acceptedAt": "2026-10-20T02:09:28.253Z"
12551255 },
12561256 {
12571257 "type": "VERSION_CONFLICT",
……@@ -1331,7 +1331,7 @@
13311331 }
13321332 ],
13331333 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1334 "acceptedAt": "2026-10-20T00:46:07.608Z"
1334 "acceptedAt": "2026-10-20T02:09:28.253Z"
13351335 },
13361336 {
13371337 "type": "VERSION_CONFLICT",
……@@ -1421,7 +1421,7 @@
14211421 }
14221422 ],
14231423 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1424 "acceptedAt": "2026-10-20T00:46:07.608Z"
1424 "acceptedAt": "2026-10-20T02:09:28.253Z"
14251425 },
14261426 {
14271427 "type": "VERSION_CONFLICT",
……@@ -1511,7 +1511,7 @@
15111511 }
15121512 ],
15131513 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1514 "acceptedAt": "2026-10-20T00:46:07.608Z"
1514 "acceptedAt": "2026-10-20T02:09:28.253Z"
15151515 },
15161516 {
15171517 "type": "VERSION_CONFLICT",
……@@ -1556,7 +1556,7 @@
15561556 }
15571557 ],
15581558 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1559 "acceptedAt": "2026-10-20T00:46:07.608Z"
1559 "acceptedAt": "2026-10-20T02:09:28.253Z"
15601560 },
15611561 {
15621562 "type": "VERSION_CONFLICT",
……@@ -1601,7 +1601,7 @@
16011601 }
16021602 ],
16031603 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1604 "acceptedAt": "2026-10-20T00:46:07.608Z"
1604 "acceptedAt": "2026-10-20T02:09:28.253Z"
16051605 },
16061606 {
16071607 "type": "VERSION_CONFLICT",
……@@ -1616,7 +1616,7 @@
16161616 }
16171617 ],
16181618 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1619 "acceptedAt": "2026-10-20T00:46:07.608Z"
1619 "acceptedAt": "2026-10-20T02:09:28.253Z"
16201620 },
16211621 {
16221622 "type": "VERSION_CONFLICT",
……@@ -1716,7 +1716,7 @@
17161716 }
17171717 ],
17181718 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1719 "acceptedAt": "2026-10-20T00:46:07.608Z"
1719 "acceptedAt": "2026-10-20T02:09:28.253Z"
17201720 },
17211721 {
17221722 "type": "VERSION_CONFLICT",
……@@ -1776,7 +1776,7 @@
17761776 }
17771777 ],
17781778 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1779 "acceptedAt": "2026-10-20T00:46:07.608Z"
1779 "acceptedAt": "2026-10-20T02:09:28.253Z"
17801780 },
17811781 {
17821782 "type": "VERSION_CONFLICT",
……@@ -1836,7 +1836,7 @@
18361836 }
18371837 ],
18381838 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1839 "acceptedAt": "2026-10-20T00:46:07.608Z"
1839 "acceptedAt": "2026-10-20T02:09:28.253Z"
18401840 },
18411841 {
18421842 "type": "VERSION_CONFLICT",
……@@ -1916,7 +1916,7 @@
19161916 }
19171917 ],
19181918 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1919 "acceptedAt": "2026-10-20T00:46:07.608Z"
1919 "acceptedAt": "2026-10-20T02:09:28.253Z"
19201920 },
19211921 {
19221922 "type": "VERSION_CONFLICT",
……@@ -1981,7 +1981,7 @@
19811981 }
19821982 ],
19831983 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
1984 "acceptedAt": "2026-10-20T00:46:07.608Z"
1984 "acceptedAt": "2026-10-20T02:09:28.253Z"
19851985 },
19861986 {
19871987 "type": "VERSION_CONFLICT",
……@@ -2031,7 +2031,7 @@
20312031 }
20322032 ],
20332033 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2034 "acceptedAt": "2026-10-20T00:46:07.608Z"
2034 "acceptedAt": "2026-10-20T02:09:28.253Z"
20352035 },
20362036 {
20372037 "type": "VERSION_CONFLICT",
……@@ -2071,7 +2071,7 @@
20712071 }
20722072 ],
20732073 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2074 "acceptedAt": "2026-10-20T00:46:07.608Z"
2074 "acceptedAt": "2026-10-20T02:09:28.253Z"
20752075 },
20762076 {
20772077 "type": "VERSION_CONFLICT",
……@@ -2111,7 +2111,7 @@
21112111 }
21122112 ],
21132113 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2114 "acceptedAt": "2026-10-20T00:46:07.608Z"
2114 "acceptedAt": "2026-10-20T02:09:28.253Z"
21152115 },
21162116 {
21172117 "type": "VERSION_CONFLICT",
……@@ -2191,7 +2191,7 @@
21912191 }
21922192 ],
21932193 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2194 "acceptedAt": "2026-10-20T00:46:07.608Z"
2194 "acceptedAt": "2026-10-20T02:09:28.253Z"
21952195 },
21962196 {
21972197 "type": "VERSION_CONFLICT",
……@@ -2241,7 +2241,7 @@
22412241 }
22422242 ],
22432243 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2244 "acceptedAt": "2026-10-20T00:46:07.608Z"
2244 "acceptedAt": "2026-10-20T02:09:28.253Z"
22452245 },
22462246 {
22472247 "type": "VERSION_CONFLICT",
……@@ -2291,7 +2291,7 @@
22912291 }
22922292 ],
22932293 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2294 "acceptedAt": "2026-10-20T00:46:07.608Z"
2294 "acceptedAt": "2026-10-20T02:09:28.253Z"
22952295 },
22962296 {
22972297 "type": "VERSION_CONFLICT",
……@@ -2326,7 +2326,7 @@
23262326 }
23272327 ],
23282328 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2329 "acceptedAt": "2026-10-20T00:46:07.608Z"
2329 "acceptedAt": "2026-10-20T02:09:28.253Z"
23302330 },
23312331 {
23322332 "type": "VERSION_CONFLICT",
……@@ -2366,7 +2366,7 @@
23662366 }
23672367 ],
23682368 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2369 "acceptedAt": "2026-10-20T00:46:07.608Z"
2369 "acceptedAt": "2026-10-20T02:09:28.253Z"
23702370 },
23712371 {
23722372 "type": "VERSION_CONFLICT",
……@@ -2426,7 +2426,7 @@
24262426 }
24272427 ],
24282428 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2429 "acceptedAt": "2026-10-20T00:46:07.608Z"
2429 "acceptedAt": "2026-10-20T02:09:28.253Z"
24302430 },
24312431 {
24322432 "type": "VERSION_CONFLICT",
……@@ -2466,7 +2466,7 @@
24662466 }
24672467 ],
24682468 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2469 "acceptedAt": "2026-10-20T00:46:07.608Z"
2469 "acceptedAt": "2026-10-20T02:09:28.253Z"
24702470 },
24712471 {
24722472 "type": "VERSION_CONFLICT",
……@@ -2496,7 +2496,7 @@
24962496 }
24972497 ],
24982498 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2499 "acceptedAt": "2026-10-20T00:46:07.608Z"
2499 "acceptedAt": "2026-10-20T02:09:28.253Z"
25002500 },
25012501 {
25022502 "type": "VERSION_CONFLICT",
……@@ -2541,7 +2541,7 @@
25412541 }
25422542 ],
25432543 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2544 "acceptedAt": "2026-10-20T00:46:07.608Z"
2544 "acceptedAt": "2026-10-20T02:09:28.253Z"
25452545 },
25462546 {
25472547 "type": "VERSION_CONFLICT",
……@@ -2581,7 +2581,7 @@
25812581 }
25822582 ],
25832583 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2584 "acceptedAt": "2026-10-20T00:46:07.608Z"
2584 "acceptedAt": "2026-10-20T02:09:28.253Z"
25852585 },
25862586 {
25872587 "type": "VERSION_CONFLICT",
……@@ -2626,7 +2626,7 @@
26262626 }
26272627 ],
26282628 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2629 "acceptedAt": "2026-10-20T00:46:07.608Z"
2629 "acceptedAt": "2026-10-20T02:09:28.253Z"
26302630 },
26312631 {
26322632 "type": "VERSION_CONFLICT",
……@@ -2656,7 +2656,7 @@
26562656 }
26572657 ],
26582658 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2659 "acceptedAt": "2026-10-20T00:46:07.608Z"
2659 "acceptedAt": "2026-10-20T02:09:28.253Z"
26602660 },
26612661 {
26622662 "type": "VERSION_CONFLICT",
……@@ -2701,7 +2701,7 @@
27012701 }
27022702 ],
27032703 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2704 "acceptedAt": "2026-10-20T00:46:07.608Z"
2704 "acceptedAt": "2026-10-20T02:09:28.253Z"
27052705 },
27062706 {
27072707 "type": "VERSION_CONFLICT",
……@@ -2761,7 +2761,7 @@
27612761 }
27622762 ],
27632763 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2764 "acceptedAt": "2026-10-20T00:46:07.608Z"
2764 "acceptedAt": "2026-10-20T02:09:28.253Z"
27652765 },
27662766 {
27672767 "type": "VERSION_CONFLICT",
……@@ -2841,7 +2841,7 @@
28412841 }
28422842 ],
28432843 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2844 "acceptedAt": "2026-10-20T00:46:07.608Z"
2844 "acceptedAt": "2026-10-20T02:09:28.253Z"
28452845 },
28462846 {
28472847 "type": "VERSION_CONFLICT",
……@@ -2901,7 +2901,7 @@
29012901 }
29022902 ],
29032903 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2904 "acceptedAt": "2026-10-20T00:46:07.608Z"
2904 "acceptedAt": "2026-10-20T02:09:28.253Z"
29052905 },
29062906 {
29072907 "type": "VERSION_CONFLICT",
……@@ -2951,7 +2951,7 @@
29512951 }
29522952 ],
29532953 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
2954 "acceptedAt": "2026-10-20T00:46:07.608Z"
2954 "acceptedAt": "2026-10-20T02:09:28.253Z"
29552955 },
29562956 {
29572957 "type": "VERSION_CONFLICT",
……@@ -3001,7 +3001,7 @@
30013001 }
30023002 ],
30033003 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3004 "acceptedAt": "2026-10-20T00:46:07.608Z"
3004 "acceptedAt": "2026-10-20T02:09:28.253Z"
30053005 },
30063006 {
30073007 "type": "VERSION_CONFLICT",
……@@ -3046,7 +3046,7 @@
30463046 }
30473047 ],
30483048 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3049 "acceptedAt": "2026-10-20T00:46:07.608Z"
3049 "acceptedAt": "2026-10-20T02:09:28.253Z"
30503050 },
30513051 {
30523052 "type": "VERSION_CONFLICT",
……@@ -3061,7 +3061,7 @@
30613061 }
30623062 ],
30633063 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3064 "acceptedAt": "2026-10-20T00:46:07.608Z"
3064 "acceptedAt": "2026-10-20T02:09:28.253Z"
30653065 },
30663066 {
30673067 "type": "VERSION_CONFLICT",
……@@ -3141,7 +3141,7 @@
31413141 }
31423142 ],
31433143 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3144 "acceptedAt": "2026-10-20T00:46:07.608Z"
3144 "acceptedAt": "2026-10-20T02:09:28.253Z"
31453145 },
31463146 {
31473147 "type": "VERSION_CONFLICT",
……@@ -3221,7 +3221,7 @@
32213221 }
32223222 ],
32233223 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3224 "acceptedAt": "2026-10-20T00:46:07.608Z"
3224 "acceptedAt": "2026-10-20T02:09:28.253Z"
32253225 },
32263226 {
32273227 "type": "VERSION_CONFLICT",
……@@ -3271,7 +3271,7 @@
32713271 }
32723272 ],
32733273 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3274 "acceptedAt": "2026-10-20T00:46:07.608Z"
3274 "acceptedAt": "2026-10-20T02:09:28.253Z"
32753275 },
32763276 {
32773277 "type": "VERSION_CONFLICT",
……@@ -3336,7 +3336,7 @@
33363336 }
33373337 ],
33383338 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3339 "acceptedAt": "2026-10-20T00:46:07.608Z"
3339 "acceptedAt": "2026-10-20T02:09:28.253Z"
33403340 },
33413341 {
33423342 "type": "VERSION_CONFLICT",
……@@ -3396,7 +3396,7 @@
33963396 }
33973397 ],
33983398 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3399 "acceptedAt": "2026-10-20T00:46:07.608Z"
3399 "acceptedAt": "2026-10-20T02:09:28.253Z"
34003400 },
34013401 {
34023402 "type": "VERSION_CONFLICT",
……@@ -3456,7 +3456,7 @@
34563456 }
34573457 ],
34583458 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3459 "acceptedAt": "2026-10-20T00:46:07.608Z"
3459 "acceptedAt": "2026-10-20T02:09:28.253Z"
34603460 },
34613461 {
34623462 "type": "VERSION_CONFLICT",
……@@ -3516,7 +3516,7 @@
35163516 }
35173517 ],
35183518 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3519 "acceptedAt": "2026-10-20T00:46:07.608Z"
3519 "acceptedAt": "2026-10-20T02:09:28.253Z"
35203520 },
35213521 {
35223522 "type": "VERSION_CONFLICT",
……@@ -3596,7 +3596,7 @@
35963596 }
35973597 ],
35983598 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3599 "acceptedAt": "2026-10-20T00:46:07.608Z"
3599 "acceptedAt": "2026-10-20T02:09:28.253Z"
36003600 },
36013601 {
36023602 "type": "VERSION_CONFLICT",
……@@ -3641,7 +3641,7 @@
36413641 }
36423642 ],
36433643 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3644 "acceptedAt": "2026-10-20T00:46:07.608Z"
3644 "acceptedAt": "2026-10-20T02:09:28.253Z"
36453645 },
36463646 {
36473647 "type": "VERSION_CONFLICT",
……@@ -3661,7 +3661,7 @@
36613661 }
36623662 ],
36633663 "reason": "version-splits only (pgh: no code-level skew); deps pinned to cthuloid-web's tested lockfile for Bare 1.28.7",
3664 "acceptedAt": "2026-10-20T00:46:07.608Z"
3664 "acceptedAt": "2026-10-20T02:09:28.253Z"
36653665 }
36663666 ]
36673667}
lib/collect.js +2 -2
……@@ -16,7 +16,7 @@ import os from 'bare-os'
1616import path from 'bare-path'
1717import process from 'bare-process'
1818import { run } from './run.js'
19import { probeHttp } from './probe.js'
19import { probeWeb } from './probe.js'
2020import { parseProcNetTcp, bindScope, parseSystemctlShow, isVendorUnit, parsePghStatus } from './parse.js'
2121
2222const UNIT_PROPS = 'Id,Description,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,MainPID,User,ActiveEnterTimestamp'
……@@ -309,7 +309,7 @@ export class HostCollector {
309309 // Probe the address it listens on: a loopback listener may be on
310310 // 127.0.0.53, not 127.0.0.1. Wildcard listeners answer on 127.0.0.1.
311311 const host = l.scope === 'all' ? '127.0.0.1' : l.addr.replace(/^::ffff:/, '')
312 probes.set(l.port + '/' + l.addr, await probeHttp(host, l.port))
312 probes.set(l.port + '/' + l.addr, await probeWeb(host, l.port))
313313 }))
314314
315315 const built = buildServices({
lib/lan.js +2 -2
……@@ -15,7 +15,7 @@ import fs from 'bare-fs'
1515import path from 'bare-path'
1616import EventEmitter from 'bare-events'
1717import { run } from './run.js'
18import { tcpOpen, probeHttp } from './probe.js'
18import { tcpOpen, probeWeb } from './probe.js'
1919import { parseArp, parseAvahiBrowse, parseIpAddr } from './parse.js'
2020
2121const SWEEP_PORTS = [80, 443, 22, 445, 139, 53, 8080, 5357, 62078, 9100]
……@@ -147,7 +147,7 @@ export class LanScanner extends EventEmitter {
147147 this.progress = 'reading what answers'
148148 await pool(open.filter(Boolean), 32, async ([h, p]) => {
149149 if (NO_HTTP_PROBE.has(p)) return h.ports.push({ port: p, http: null, banner: null })
150 const r = await probeHttp(h.ip, p, { timeoutMs: 3000 })
150 const r = await probeWeb(h.ip, p, { timeoutMs: 3000 })
151151 h.ports.push({ port: p, http: r.http, banner: r.banner })
152152 })
153153 for (const h of list) h.ports.sort((a, b) => a.port - b.port)
lib/parse.js +23 -0
……@@ -136,6 +136,29 @@ export function extractTitle (html) {
136136 return t || null
137137}
138138
139// A redirect we may follow: one that stays on the port we probed (most web UIs
140// answer / with a 30x to /login or /web/). Returns the path to request, or
141// null for anything that leaves (another port, https, another scheme).
142export function sameOriginPath (location, port) {
143 if (!location) return null
144 if (location.startsWith('//')) return null
145 if (location.startsWith('/')) return location
146 const m = /^(https?):\/\/[^/:?#]+(?::(\d+))?([/?#].*)?$/i.exec(location)
147 if (!m) return /^[a-z]+:/i.test(location) ? null : '/' + location
148 if (m[1].toLowerCase() !== 'http') return null
149 if (Number(m[2] || 80) !== port) return null
150 const rest = m[3] || '/'
151 return rest.startsWith('/') ? rest : '/' + rest
152}
153
154// The title worth showing as "what this page is". Error pages name the error,
155// not the service.
156export function pageTitle (http) {
157 if (!http || !http.title || http.code >= 400) return null
158 if (/^(\d{3}\b|unauthori[sz]ed$|forbidden$|not found$|directory listing for\b)/i.test(http.title)) return null
159 return http.title
160}
161
139162function decodeEntities (s) {
140163 return s.replace(/&(amp|lt|gt|quot|#39|#x27|middot|nbsp);/g, (_, e) => ({
141164 amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", '#x27': "'", middot: '·', nbsp: ' '
lib/probe.js +24 -4
……@@ -6,7 +6,7 @@
66// few KB, read with one hard deadline.
77
88import net from 'bare-net'
9import { parseHttpHead } from './parse.js'
9import { parseHttpHead, sameOriginPath, pageTitle } from './parse.js'
1010
1111const READ_LIMIT = 64 * 1024
1212
……@@ -29,7 +29,7 @@ export function tcpOpen (host, port, timeoutMs = 700) {
2929}
3030
3131// { open, http: { code, location, server, title } | null, banner }
32export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host } = {}) {
32export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host, path = '/' } = {}) {
3333 return new Promise((resolve) => {
3434 let done = false
3535 let open = false
……@@ -41,7 +41,7 @@ export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host } =
4141 done = true
4242 clearTimeout(timer)
4343 sock.destroy()
44 const text = Buffer.concat(chunks).toString('latin1')
44 const text = Buffer.concat(chunks).toString('utf8')
4545 const http = parseHttpHead(text)
4646 const banner = http ? null : text.split(/\r?\n/)[0].slice(0, 120) || null
4747 resolve({ open, http, banner })
……@@ -49,7 +49,7 @@ export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host } =
4949 const timer = setTimeout(finish, timeoutMs)
5050 sock.on('connect', () => {
5151 open = true
52 sock.write('GET / HTTP/1.0\r\nHost: ' + hostHeader + ':' + port + '\r\nUser-Agent: host-services\r\nAccept: text/html\r\nConnection: close\r\n\r\n')
52 sock.write('GET ' + path + ' HTTP/1.0\r\nHost: ' + hostHeader + ':' + port + '\r\nUser-Agent: host-services\r\nAccept: text/html\r\nConnection: close\r\n\r\n')
5353 })
5454 sock.on('data', (d) => {
5555 chunks.push(d)
……@@ -62,3 +62,23 @@ export function probeHttp (host, port, { timeoutMs = 2500, hostHeader = host } =
6262 sock.on('error', finish)
6363 })
6464}
65
66// probeHttp, then up to `maxRedirects` same-port redirects, so the title is
67// the page a browser lands on ("Login - Radarr", not nothing for a bare 302).
68// `code`/`location` stay the first response's; `title` is the landing page's,
69// null when it only names an error. `landing` is the path that gave it.
70export async function probeWeb (host, port, { maxRedirects = 2, ...opts } = {}) {
71 const first = await probeHttp(host, port, opts)
72 if (!first.http) return first
73 let last = first.http
74 let landing = '/'
75 for (let i = 0; i < maxRedirects && last.code >= 300 && last.code < 400; i++) {
76 const next = sameOriginPath(last.location, port)
77 if (!next) break
78 const r = await probeHttp(host, port, { ...opts, path: next })
79 if (!r.http) break
80 last = r.http
81 landing = next
82 }
83 return { ...first, http: { ...first.http, title: pageTitle(last), landing } }
84}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "host-services",
3 "version": "0.1.1",
3 "version": "0.1.2",
44 "description": "One page listing every service a host runs (pgh apps, systemd units, listening ports) with active / dormant state, plus the LAN's other devices and what they serve.",
55 "type": "module",
66 "main": "index.js",
static/app.css +4 -0
……@@ -69,6 +69,10 @@ input[type=search] { flex: 1 1 220px; max-width: 360px; padding: 6px 10px; borde
6969.state.failed { color: var(--failed); }
7070.state.dormant { color: var(--dormant); }
7171.meta { color: var(--muted); font-size: 12.5px; display: flex; flex-wrap: wrap; gap: 4px 14px; }
72.web { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13.5px; }
73.web a { color: var(--accent); text-decoration: none; }
74.web a:hover { text-decoration: underline; }
75.web .mono { color: var(--muted); }
7276.ports { display: flex; flex-wrap: wrap; gap: 6px; }
7377.port { border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; font-size: 12.5px; display: inline-flex; gap: 6px; align-items: center; max-width: 100%; }
7478.port a { color: var(--accent); text-decoration: none; font-weight: 600; }
static/app.js +14 -0
……@@ -52,6 +52,19 @@ function portChip (p, hostname) {
5252 (notes ? '<span class="t">' + esc(notes) + '</span>' : '') + '</span>'
5353}
5454
55// "Sonarr :8989 · Login - Radarr :7878": the web pages this service serves,
56// by title, so the port list isn't the only clue to what's where.
57function webLine (s, hostname) {
58 const pages = s.ports.filter((p) => p.http && p.http.title && p.open !== false)
59 if (!pages.length) return ''
60 return '<div class="web">' + pages.map((p) => {
61 const port = '<span class="mono">:' + p.port + '</span>'
62 return p.scope === 'local'
63 ? '<span>' + esc(p.http.title) + ' ' + port + ' <span class="sub">localhost only</span></span>'
64 : '<a href="' + esc(portUrl(hostname, p)) + '" target="_blank" rel="noopener">' + esc(p.http.title) + ' ' + port + '</a>'
65 }).join('') + '</div>'
66}
67
5568function displayName (s) {
5669 return (s.label && s.label.name) || s.name
5770}
……@@ -101,6 +114,7 @@ function renderHost () {
101114 '<span class="name">' + esc(displayName(s)) + '</span>' +
102115 (displayName(s) !== s.name ? '<span class="mono sub">' + esc(s.name) + '</span>' : '') +
103116 '<span class="badge">' + esc(KIND_LABEL[s.kind] || s.kind) + '</span></div>' +
117 webLine(s, hostname) +
104118 (meta.length ? '<div class="meta">' + meta.map((m) => '<span>' + esc(m) + '</span>').join('') + '</div>' : '') +
105119 (s.ports.length ? '<div class="ports">' + s.ports.map((p) => portChip(p, hostname)).join('') + '</div>' : '') +
106120 '</div>'
test/parse.test.js +22 -1
……@@ -4,7 +4,7 @@ import fs from 'bare-fs'
44import path from 'bare-path'
55import {
66 parseProcNetTcp, bindScope, parseSystemctlShow, isVendorUnit, parsePghStatus,
7 parseHttpHead, extractTitle, parseArp, parseAvahiBrowse, parseIpAddr
7 parseHttpHead, extractTitle, parseArp, parseAvahiBrowse, parseIpAddr, sameOriginPath, pageTitle
88} from '../lib/parse.js'
99
1010const FIX = path.join(path.dirname(new URL(import.meta.url).pathname), 'fixtures')
……@@ -75,6 +75,27 @@ test('HTTP head: status, redirect, title; non-HTTP is null', (t) => {
7575 t.is(extractTitle('<p>no title</p>'), null)
7676})
7777
78test('redirects followed only on the same port', (t) => {
79 t.is(sameOriginPath('/login?returnUrl=%2F', 7878), '/login?returnUrl=%2F')
80 t.is(sameOriginPath('http://127.0.0.1:5005/git/repos', 5005), '/git/repos')
81 t.is(sameOriginPath('http://stortron.local/x', 80), '/x', 'default port 80')
82 t.is(sameOriginPath('web/', 8096), '/web/', 'bare relative path')
83 t.is(sameOriginPath('http://127.0.0.1:9999/', 5005), null, 'other port')
84 t.is(sameOriginPath('https://127.0.0.1:5005/', 5005), null, 'https')
85 t.is(sameOriginPath('//evil.example/', 80), null, 'protocol-relative')
86 t.is(sameOriginPath('mailto:x@y', 80), null)
87 t.is(sameOriginPath(null, 80), null)
88})
89
90test('page title: error pages are not a name', (t) => {
91 t.is(pageTitle({ code: 200, title: 'Sonarr' }), 'Sonarr')
92 t.is(pageTitle({ code: 404, title: '404 Not Found' }), null)
93 t.is(pageTitle({ code: 401, title: 'Unauthorized' }), null)
94 t.is(pageTitle({ code: 200, title: 'Directory listing for /' }), null)
95 t.is(pageTitle({ code: 200, title: null }), null)
96 t.is(pageTitle(null), null)
97})
98
7899test('arp: complete entries only', (t) => {
79100 const arp = parseArp(fix('proc-net-arp.txt'))
80101 t.ok(arp.length > 5)
test/probe.test.js +81 -0
……@@ -0,0 +1,81 @@
1// probeWeb against a real local server shaped like the web UIs on stortron.
2import test from 'brittle'
3import http from 'bare-http1'
4import { probeWeb } from '../lib/probe.js'
5
6function serve (routes) {
7 const server = http.createServer((req, res) => {
8 const r = routes[req.url]
9 if (!r) {
10 res.writeHead(404, { 'content-type': 'text/html' })
11 return res.end('<title>404 Not Found</title>')
12 }
13 // `to` builds a Location that needs the port the server got.
14 const headers = r.to ? { location: r.to(server.address().port) } : r.headers
15 res.writeHead(r.code, headers || { 'content-type': 'text/html' })
16 res.end(r.body || '')
17 })
18 return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
19}
20
21test('302 to /login: title comes from the landing page, code stays 302', async (t) => {
22 const s = await serve({
23 '/': { code: 302, headers: { location: '/login?returnUrl=%2F' } },
24 '/login?returnUrl=%2F': { code: 200, body: '<title>Login - Radarr</title>' }
25 })
26 const port = s.address().port
27 const r = await probeWeb('127.0.0.1', port)
28 t.is(r.open, true)
29 t.is(r.http.code, 302)
30 t.is(r.http.title, 'Login - Radarr')
31 t.is(r.http.landing, '/login?returnUrl=%2F')
32 s.close()
33})
34
35test('a redirect to another port is not followed', async (t) => {
36 const s = await serve({
37 '/': { code: 303, headers: { location: 'http://127.0.0.1:1/elsewhere' } }
38 })
39 const r = await probeWeb('127.0.0.1', s.address().port)
40 t.is(r.http.code, 303)
41 t.is(r.http.title, null, 'did not leave the port')
42 t.is(r.http.landing, '/')
43 s.close()
44})
45
46test('absolute same-port Location is followed', async (t) => {
47 const s = await serve({
48 '/': { code: 303, to: (port) => 'http://127.0.0.1:' + port + '/git/repos' },
49 '/git/repos': { code: 200, body: '<title>repos &middot; pear-git-v3</title>' }
50 })
51 const r = await probeWeb('127.0.0.1', s.address().port)
52 t.is(r.http.title, 'repos · pear-git-v3')
53 t.is(r.http.landing, '/git/repos')
54 s.close()
55})
56
57test('redirect loop stops after maxRedirects', async (t) => {
58 const s = await serve({
59 '/': { code: 302, headers: { location: '/a' } },
60 '/a': { code: 302, headers: { location: '/' } }
61 })
62 const r = await probeWeb('127.0.0.1', s.address().port)
63 t.is(r.http.code, 302)
64 t.is(r.http.title, null)
65 s.close()
66})
67
68test('error page title is dropped', async (t) => {
69 const s = await serve({ '/': { code: 404, body: '<title>404 Not Found</title>' } })
70 const r = await probeWeb('127.0.0.1', s.address().port)
71 t.is(r.http.code, 404)
72 t.is(r.http.title, null)
73 s.close()
74})
75
76test('UTF-8 titles decode as UTF-8', async (t) => {
77 const s = await serve({ '/': { code: 200, body: '<title>repos · pear-git-v3 — café</title>' } })
78 const r = await probeWeb('127.0.0.1', s.address().port)
79 t.is(r.http.title, 'repos · pear-git-v3 — café')
80 s.close()
81})