Skip to content

Commit 3fe06bd

Browse files
feat(web): enhance web UI with subscription management and security features
- Added new command-line flags for `--sub-only`, `--secret-path`, `--admin-token`, and `--force-local` to improve UI security and functionality. - Implemented a random token generation for secret paths, allowing users to serve the UI under a secure prefix. - Introduced admin authentication via a token, gating access to the UI and API. - Updated the web server to handle requests based on the new security features, including a login mechanism. - Enhanced documentation to reflect the new features and usage instructions. These changes significantly improve the security and usability of the web interface, providing users with better control over access and visibility.
1 parent 79924f6 commit 3fe06bd

5 files changed

Lines changed: 376 additions & 11 deletions

File tree

cmd/main.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package main
33

44
import (
55
"context"
6+
"crypto/rand"
7+
"encoding/base32"
68
"encoding/json"
79
"fmt"
810
"io"
911
"io/fs"
1012
"os"
1113
"os/signal"
1214
"path/filepath"
15+
"strings"
1316
"syscall"
1417
"text/tabwriter"
1518
"time"
@@ -19,6 +22,7 @@ import (
1922
// Register core runners via side-effect imports.
2023
_ "github.com/hiddify/hiddify_config_health/internal/core"
2124

25+
"github.com/hiddify/hiddify_config_health/internal/proxyuri"
2226
"github.com/hiddify/hiddify_config_health/internal/runner"
2327
"github.com/hiddify/hiddify_config_health/internal/store"
2428
"github.com/hiddify/hiddify_config_health/internal/web"
@@ -382,6 +386,10 @@ func checkCmd() *cobra.Command {
382386

383387
func serveCmd() *cobra.Command {
384388
var addr string
389+
var subOnly bool
390+
var secretPath string
391+
var adminToken string
392+
var forceLocal bool
385393
cmd := &cobra.Command{
386394
Use: "serve",
387395
Short: "Start the web UI",
@@ -390,13 +398,30 @@ func serveCmd() *cobra.Command {
390398
if db != nil {
391399
defer db.Close()
392400
}
401+
// --secret-path: "auto" generates a random token; any other value
402+
// is used literally. Empty = served at root.
403+
base := strings.Trim(secretPath, "/")
404+
if base == "auto" {
405+
base = randToken(16)
406+
}
407+
if base != "" {
408+
base += "/health"
409+
}
410+
proxyuri.ForceLocal = forceLocal
393411
srv := &web.Server{
394412
ExamplesDir: flagExamplesDir,
395413
DB: db,
414+
SubOnly: subOnly,
415+
BasePath: base,
416+
AdminToken: adminToken,
396417
}
397418
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
398419
defer stop()
399-
fmt.Printf("Web UI: http://%s\n", addr)
420+
path := "/"
421+
if base != "" {
422+
path = "/" + base + "/"
423+
}
424+
fmt.Printf("Web UI: http://%s%s\n", addr, path)
400425
go func() {
401426
if err := srv.ListenAndServe(addr); err != nil {
402427
fmt.Fprintln(os.Stderr, "web:", err)
@@ -407,9 +432,25 @@ func serveCmd() *cobra.Command {
407432
},
408433
}
409434
cmd.Flags().StringVar(&addr, "addr", ":8080", "listen address")
435+
cmd.Flags().BoolVar(&subOnly, "sub-only", false, "subscription-only UI: hide the config/deploy mode tabs")
436+
cmd.Flags().StringVar(&secretPath, "secret-path", "", "serve under a secret prefix /<path>/health/ (\"auto\" = random token)")
437+
cmd.Flags().StringVar(&adminToken, "admin-token", "", "require this token (cookie login) to access the UI/API; empty = open")
438+
cmd.Flags().BoolVar(&forceLocal, "force-local", false, "resolve subscription hosts to 127.0.0.1 (use behind a reverse proxy)")
410439
return cmd
411440
}
412441

442+
// randToken returns an uppercase alphanumeric token of n chars (base32, no
443+
// padding) for use as a secret URL path.
444+
func randToken(n int) string {
445+
buf := make([]byte, n)
446+
_, _ = rand.Read(buf)
447+
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
448+
if len(s) > n {
449+
s = s[:n]
450+
}
451+
return s
452+
}
453+
413454
// --- history ---
414455

415456
func historyCmd() *cobra.Command {

docs/web-ui.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,133 @@ Flags:
1616
| `--addr` | `:8080` | Listen address |
1717
| `--examples` | `examples` | Root directory to scan for `run.json` files |
1818
| `--db` | `~/.hiddify-health/results.db` | SQLite history database |
19+
| `--sub-only` | `false` | Subscription-only UI — hide the config/deploy mode tabs |
20+
| `--secret-path` | `""` | Serve the whole UI under a secret prefix `/<path>/health/` (`auto` = random token) |
21+
| `--admin-token` | `""` | Require this token (cookie login) to access the UI/API — empty = open |
22+
| `--force-local` | `false` | Resolve subscription hosts to `127.0.0.1` instead of real DNS — use behind a reverse proxy |
23+
24+
## Secret base path
25+
26+
By default the UI is served at the root (`http://host:8090/`). To hide it
27+
behind an unguessable prefix, pass `--secret-path`:
28+
29+
```bash
30+
# fixed secret
31+
./hiddify-health serve --secret-path DSJKNLIWPFKLKS
32+
# → http://host:8090/DSJKNLIWPFKLKS/health/
33+
34+
# random token (printed at startup)
35+
./hiddify-health serve --secret-path auto
36+
# → http://host:8090/DDE6YYXAYMI2UMFJ/health/
37+
```
38+
39+
The value is normalised to `/<path>/health/`. Behaviour:
40+
41+
- Everything under the prefix works normally (page + `api/*` + SSE).
42+
- Anything **outside** the prefix returns **404**`/`, `/api/examples`, any
43+
other path. The path acts as a shared secret (obscurity gate, not auth).
44+
- A bare prefix without trailing slash (`/<path>/health`) `301`-redirects to
45+
the trailing-slash form.
46+
- The page injects `<base href="/<path>/health/">`; all in-page requests are
47+
relative, so they resolve under the prefix automatically.
48+
49+
> This is obscurity, not authentication — anyone with the link has full
50+
> access. For real protection, also front it with a reverse proxy + auth.
51+
52+
## Admin login (`--admin-token`)
53+
54+
`--secret-path` alone is just obscurity — no login. To require auth on top
55+
of it, pass `--admin-token`:
56+
57+
```bash
58+
./hiddify-health serve --secret-path auto --admin-token <strong-random-token>
59+
```
60+
61+
Behaviour:
62+
63+
- Gates the **whole** UI and API (everything under the secret prefix,
64+
including `/api/sub`) behind a login cookie.
65+
- Unauthenticated requests are redirected to `login` (an in-prefix page);
66+
`/api/*` requests get `401` instead of a redirect.
67+
- Login: POST the token to `login` (form field `token` or `?token=`).
68+
Sets an `HttpOnly` cookie (`hch_admin`), valid 30 days.
69+
- Empty/unset `--admin-token` (default) = no auth, current open behaviour.
70+
- This panel is meant for the admin/operator to test their own configs and
71+
subscription links — not for end users. Always set `--admin-token` on any
72+
internet-reachable deployment.
73+
74+
## Deploying behind a reverse proxy (TLS)
75+
76+
Recommended production setup: run `hiddify-health serve` bound to
77+
`127.0.0.1`, and put a TLS-terminating reverse proxy (nginx, Caddy, etc.) in
78+
front of it serving `https://a.com/<secret-path>/health/`.
79+
80+
```bash
81+
./hiddify-health serve --addr 127.0.0.1:8090 --secret-path auto --admin-token <token>
82+
```
83+
84+
Point the reverse proxy at `127.0.0.1:8090`, terminate TLS there, and forward
85+
the path through unchanged.
86+
87+
### `--force-local`
88+
89+
When testing a subscription link that points back at your own domain (e.g.
90+
the panel is reachable at `https://a.com/secret_path/...` and you paste that
91+
same domain as the subscription URL), the panel would otherwise resolve
92+
`a.com` over real DNS and go back out through the public internet. Pass
93+
`--force-local` to make subscription fetches resolve straight to
94+
`127.0.0.1` (port preserved) instead:
95+
96+
```bash
97+
./hiddify-health serve --addr 127.0.0.1:8090 --force-local
98+
```
99+
100+
Only affects subscription URL fetches (`POST /api/sub` with `sub_url`); pasted
101+
proxy links (`text`) are unaffected since they're not fetched over HTTP.
102+
103+
## Modes
104+
105+
The page has two mutually-exclusive modes, switched by the tabs at the top of
106+
the sidebar:
107+
108+
- **Configs / Deploy** — pick an example config, run it locally, or deploy the
109+
server to a remote host (Global SSH). Default.
110+
- **Subscription / Links** — paste a subscription URL or proxy links; each
111+
proxy is tested on sing-box & xray, scored, and compared to a no-proxy
112+
baseline.
113+
114+
### Subscription-only deployment
115+
116+
To run a public, link-testing-only instance (no example/deploy UI), start with
117+
`--sub-only`:
118+
119+
```bash
120+
./hiddify-health serve --addr :8080 --sub-only
121+
```
122+
123+
The mode tabs are hidden and the page boots straight into the Subscription
124+
mode. (Server-side: injects `window.SUB_ONLY=true` into the page.)
125+
126+
### URL parameters
127+
128+
The page reads query parameters on load, so a subscription can be opened with a
129+
pre-filled link — e.g. `https://host/?sub=https://example.com/sub.txt`:
130+
131+
| Param | Effect |
132+
|---|---|
133+
| `?sub=<url>` | Pre-fill the subscription URL and switch to Subscription mode |
134+
| `?text=<links>` | Pre-fill the proxy-links box (`,` is treated as newline) |
135+
| `?full=1` | Tick the full-suite checkbox |
136+
| `?run=1` | Auto-start the test once filled |
137+
| `?subonly=1` | Hide the mode tabs for this link (per-URL, no server flag) |
138+
139+
Combine freely:
140+
141+
```
142+
https://host/?sub=https://example.com/sub.txt&full=1&run=1
143+
```
144+
145+
fills the URL, enables the full suite, and starts testing immediately.
19146

20147
## Layout
21148

@@ -99,6 +226,26 @@ Returns the most recent `store.Record` for `dir`, or `null`.
99226

100227
Returns the last 50 `store.Record` rows for `dir` (newest first).
101228

229+
### `POST /api/sub`
230+
231+
Tests a subscription or proxy list. Body:
232+
233+
```json
234+
{ "sub_url": "https://example.com/sub.txt", "text": "vless://…\nvmess://…",
235+
"full": false, "submit": false }
236+
```
237+
238+
`sub_url` or `text` (one required). `full` runs the heavy check suite;
239+
`submit` shares anonymous, PII-stripped results to the central server and
240+
returns a private link. Streams SSE events:
241+
242+
| Event | Data |
243+
|---|---|
244+
| `baseline` | No-proxy baseline metrics (JSON) |
245+
| `row` | One proxy×core result (JSON) as each finishes |
246+
| `link` | Private results URL (only when `submit:true`) |
247+
| `done` | Empty — stream ends |
248+
102249
## Concurrent runs
103250

104251
The server blocks a second run for the same example directory while one is

internal/proxyuri/proxyuri.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ import (
99
"encoding/base64"
1010
"fmt"
1111
"io"
12+
"net"
1213
"net/http"
1314
"strings"
1415
"time"
1516
)
1617

18+
// ForceLocal, when true, makes ParseSubscription resolve every subscription
19+
// host to 127.0.0.1 instead of its real DNS address. Set via --force-local;
20+
// used when the panel sits behind a reverse proxy and we want subscription
21+
// fetches to hit the local instance rather than the public domain.
22+
var ForceLocal bool
23+
1724
// Proxy is a normalised, core-agnostic description of one proxy endpoint.
1825
type Proxy struct {
1926
Protocol string // vless | vmess | trojan | shadowsocks | hysteria2 | tuic
@@ -111,7 +118,7 @@ func ParseSubscription(ctx context.Context, subURL string) ([]Proxy, []error) {
111118
return nil, []error{err}
112119
}
113120
req.Header.Set("User-Agent", "hiddify-health/subscription")
114-
cl := &http.Client{Timeout: 20 * time.Second}
121+
cl := &http.Client{Timeout: 20 * time.Second, Transport: subscriptionTransport()}
115122
resp, err := cl.Do(req)
116123
if err != nil {
117124
return nil, []error{fmt.Errorf("fetch subscription: %w", err)}
@@ -127,6 +134,25 @@ func ParseSubscription(ctx context.Context, subURL string) ([]Proxy, []error) {
127134
return ParseList(decodeMaybeBase64(string(body)))
128135
}
129136

137+
// subscriptionTransport returns an http.Transport that, when ForceLocal is
138+
// set, dials 127.0.0.1 for every subscription host (port preserved) instead
139+
// of resolving real DNS — used behind a reverse proxy so subscription
140+
// fetches hit the local instance rather than the public domain.
141+
func subscriptionTransport() http.RoundTripper {
142+
if !ForceLocal {
143+
return http.DefaultTransport
144+
}
145+
t := http.DefaultTransport.(*http.Transport).Clone()
146+
t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
147+
_, port, err := net.SplitHostPort(addr)
148+
if err != nil {
149+
return nil, err
150+
}
151+
return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
152+
}
153+
return t
154+
}
155+
130156
// decodeMaybeBase64 returns the base64-decoded text if the whole blob decodes
131157
// cleanly and yields proxy-looking content, else the original text.
132158
func decodeMaybeBase64(s string) string {

internal/web/static/index.html

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ <h2 id="title">Select an example</h2>
147147
let examplesList = [];
148148

149149
async function loadExamples() {
150-
const res = await fetch('/api/examples');
150+
const res = await fetch('api/examples');
151151
const items = await res.json() || [];
152152
examplesList = items;
153153
// Populate the core filter dropdown (preserve current selection).
@@ -284,7 +284,7 @@ <h2 id="title">Select an example</h2>
284284
const deploy = document.getElementById('inp-deploy').value.trim();
285285
const server = document.getElementById('inp-server').value.trim();
286286
const gport = document.getElementById('global-port').value.trim();
287-
let url = `/api/run?dir=${encodeURIComponent(selectedDir)}`;
287+
let url = `api/run?dir=${encodeURIComponent(selectedDir)}`;
288288
if (deploy) url += `&deploy=${encodeURIComponent(deploy)}`;
289289
if (server) url += `&server=${encodeURIComponent(server)}`;
290290
if (gport) url += `&port=${encodeURIComponent(gport)}`;
@@ -383,7 +383,7 @@ <h2 id="title">Select an example</h2>
383383
}
384384

385385
async function loadHistory(dir) {
386-
const res = await fetch('/api/history?dir='+encodeURIComponent(dir));
386+
const res = await fetch('api/history?dir='+encodeURIComponent(dir));
387387
const recs = await res.json() || [];
388388
const panel = document.getElementById('checks-list');
389389
// Remove existing history section before re-appending.
@@ -593,7 +593,7 @@ <h2 id="title">Select an example</h2>
593593

594594
function runOneCollect(item) {
595595
return new Promise(resolve => {
596-
let runURL = `/api/run?dir=${encodeURIComponent(item.dir)}`;
596+
let runURL = `api/run?dir=${encodeURIComponent(item.dir)}`;
597597
const gd = document.getElementById('global-deploy').value.trim();
598598
if (gd) runURL += `&deploy=${encodeURIComponent(gd)}`;
599599
const gp = document.getElementById('global-port').value.trim();
@@ -708,7 +708,7 @@ <h2 id="title">Select an example</h2>
708708
subRows = []; subBaseline = null;
709709
document.getElementById('log-panel').innerHTML = '<div class="welcome">Testing… results appear when each proxy finishes.</div>';
710710

711-
fetch('/api/sub', {
711+
fetch('api/sub', {
712712
method: 'POST', headers: {'Content-Type':'application/json'},
713713
body: JSON.stringify({sub_url: url, text: text, full: full, submit: submit})
714714
}).then(resp => {
@@ -735,7 +735,37 @@ <h2 id="title">Select an example</h2>
735735
}).catch(() => { btn.disabled=false; btn.textContent='▶ Test Proxies'; });
736736
};
737737

738-
applyMode('config');
738+
// URL parameters + sub-only mode.
739+
// ?sub=<url> pre-fill the subscription URL (and switch to sub mode)
740+
// ?text=<links> pre-fill the proxy-links box (newline or comma separated)
741+
// ?full=1 tick the full-suite box
742+
// ?run=1 auto-start the test once filled
743+
// ?subonly=1 hide the mode tabs entirely — subscription-only UI
744+
// A build-time default is exposed as window.SUB_ONLY (set by `serve --sub-only`).
745+
(function initFromURL() {
746+
const q = new URLSearchParams(location.search);
747+
const subOnly = window.SUB_ONLY || q.get('subonly') === '1' || q.get('subonly') === 'true';
748+
const sub = q.get('sub');
749+
const text = q.get('text');
750+
const full = q.get('full') === '1' || q.get('full') === 'true';
751+
752+
if (subOnly) {
753+
document.getElementById('mode-tabs').style.display = 'none';
754+
applyMode('sub');
755+
} else {
756+
applyMode('config');
757+
}
758+
759+
if (sub || text || subOnly) applyMode('sub');
760+
if (sub) document.getElementById('sub-url').value = sub;
761+
if (text) document.getElementById('sub-text').value = text.replace(/,/g, '\n');
762+
if (full) document.getElementById('sub-full').checked = true;
763+
764+
if ((sub || text) && (q.get('run') === '1' || q.get('run') === 'true')) {
765+
document.getElementById('btn-test-sub').click();
766+
}
767+
})();
768+
739769
loadExamples();
740770
</script>
741771
</body>

0 commit comments

Comments
 (0)