From 5889488ecc2e05699233a54bd55971b0d5b43c38 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 9 Nov 2026 00:36:10 +0800 Subject: [PATCH] feat: require regional W-ID login for browser pages [BREAKING CHANGE / CONTRACT IMPACT] Anonymous HTML and static assets now return a minimal login page; missing browser configuration returns 503. Documented CLI, transfer, queue, records and health API contracts remain unchanged. Configure the regional W-ID client and exact callback before activation. --- README.md | 40 ++++++ cmd/netstable/main.go | 10 ++ internal/web/auth.go | 271 ++++++++++++++++++++++++++++++++++++ internal/web/server.go | 15 +- internal/web/server_test.go | 10 +- 5 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 internal/web/auth.go diff --git a/README.md b/README.md index 18c1303..82f88fc 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,43 @@ UDP 示例: git remote add origin git push -u origin codex/go-network-stability-tester ``` + +### Regional browser login + +The default HTML page and all static assets now require this instance's W-ID +browser session. Anonymous requests receive a plain login page without the +business bundle or tracking information. Configure these environment values +at runtime (never embed credentials in a binary or a command line): + +- `NETSTABLE_PUBLIC_URL`: this deployment's canonical HTTPS origin. +- `NETSTABLE_WID_ISSUER`: its regional W-ID HTTPS origin. +- `NETSTABLE_WID_CLIENT_ID`: the client registered in that same realm. +- `NETSTABLE_WID_CLIENT_SECRET`: optional, only for a confidential client. + +Register the exact callback `NETSTABLE_PUBLIC_URL/auth/callback` with W-ID, +allow authorization-code + S256 PKCE, and allow `openid profile`. Authorization, +token and userinfo use the issuer's `/oauth/authorize`, `/oauth/token` and +`/oauth/userinfo` endpoints. The public origin and issuer cannot include a path, +query, fragment or embedded credentials. A missing configuration returns a +minimal login-unavailable page with HTTP 503; partial/invalid configuration +stops startup. No browser session, W-ID account or product role is fabricated. + +State is browser-bound, one-use and expires after five minutes. The opaque +HttpOnly/Secure host-only session lasts at most 15 minutes or the upstream token +lifetime, whichever is shorter. Both maps are bounded and held in memory; +restarts require login again. Tokens and identity data are not persisted. +Only a successful token exchange and authenticated W-ID userinfo response with +`sub` create a session. Upstream requests have a ten-second timeout and do not +follow redirects. W-ID realm changes require a process restart and thus clear +old sessions. + +This change protects browser HTML/static resources. Existing documented public +CLI downloads, test/queue/transfer/iperf3 APIs, anonymized record API and health +stream keep their existing protocol and access rules. It does not introduce +platform administrator rights or product tenant memberships. + +Production activation is a separate operation. The binary observed on cn244 +on 2026-11-09 identifies itself as commit `4df04d1` with local modifications. +The current source baseline `4d8a295` also contains later record-completion and +legacy-SSE removals. Reconcile the production dirty source before replacing it; +this source change alone is not evidence that activation preserves that runtime. diff --git a/cmd/netstable/main.go b/cmd/netstable/main.go index a7667cc..fd6de1a 100644 --- a/cmd/netstable/main.go +++ b/cmd/netstable/main.go @@ -49,6 +49,15 @@ func runServer(args []string) { iperf3Path := flags.String("iperf3-path", "iperf3", "iperf3 executable path for one-off CLI iperf3 sessions") _ = flags.Parse(args) + browserLogin, err := appweb.NewBrowserLogin(appweb.BrowserAuth{ + PublicURL: os.Getenv("NETSTABLE_PUBLIC_URL"), + Issuer: os.Getenv("NETSTABLE_WID_ISSUER"), + ClientID: os.Getenv("NETSTABLE_WID_CLIENT_ID"), + ClientSecret: os.Getenv("NETSTABLE_WID_CLIENT_SECRET"), + }) + if err != nil { + log.Fatal("invalid browser login configuration: ", err) + } store := recordstore.New(*dataPath) var resolvers []appweb.ClientResolver if db, err := ipdata.FileSystem().ReadFile("ip2region_v4.xdb"); err != nil { @@ -71,6 +80,7 @@ func runServer(args []string) { resolver := chainResolvers(resolvers...) options := appweb.Options{ + BrowserLogin: browserLogin, Store: store, ClientResolver: resolver, TrustProxyHeaders: *trustProxyHeaders, diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..ffbd146 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,271 @@ +package web + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "html/template" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// BrowserAuth configures this instance's W-ID realm; it never grants tenant or platform roles. +type BrowserAuth struct { + PublicURL string + Issuer string + ClientID string + ClientSecret string +} + +type loginAttempt struct { + verifier string + expires time.Time +} +type BrowserLogin struct { + config BrowserAuth + client *http.Client + mu sync.Mutex + pending map[string]loginAttempt + sessions map[string]time.Time +} + +// NewBrowserLogin validates explicit regional endpoints, without remote discovery at startup. +func NewBrowserLogin(config BrowserAuth) (*BrowserLogin, error) { + a := &BrowserLogin{config: config, client: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, pending: make(map[string]loginAttempt), sessions: make(map[string]time.Time)} + if config == (BrowserAuth{}) { + return a, nil + } + if config.ClientID == "" { + return nil, errors.New("W-ID client ID is required") + } + for i, raw := range []string{config.PublicURL, config.Issuer} { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.RawQuery != "" { + return nil, errors.New("W-ID URLs must be explicit HTTPS URLs without credentials, query or fragment") + } + if i == 0 && u.Path != "" && u.Path != "/" { + return nil, errors.New("public URL must be an HTTPS origin") + } + if i == 1 && u.Path != "" && u.Path != "/" { + return nil, errors.New("W-ID issuer must be an HTTPS origin") + } + } + a.config.PublicURL = strings.TrimRight(config.PublicURL, "/") + a.config.Issuer = strings.TrimRight(config.Issuer, "/") + return a, nil +} + +func randomLoginToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func (a *BrowserLogin) pruneLocked(now time.Time) { + for key, value := range a.pending { + if !now.Before(value.expires) { + delete(a.pending, key) + } + } + for key, expiry := range a.sessions { + if !now.Before(expiry) { + delete(a.sessions, key) + } + } +} + +func loginCookie(w http.ResponseWriter, name, value, path string, lifetime int) { + http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: path, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: lifetime}) +} + +var loginPage = template.Must(template.New("login").Parse(`登录

登录

{{if .}}使用 W-ID 登录{{else}}

登录暂不可用

{{end}}
`)) + +func (a *BrowserLogin) page(w http.ResponseWriter, r *http.Request, status int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.WriteHeader(status) + if r.Method != http.MethodHead { + _ = loginPage.Execute(w, a.config.ClientID != "") + } +} + +func (a *BrowserLogin) protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + a.mu.Lock() + a.pruneLocked(time.Now()) + cookie, err := r.Cookie("__Host-netstable-session") + authenticated := false + if err == nil { + expiry, ok := a.sessions[cookie.Value] + authenticated = ok && time.Now().Before(expiry) + } + a.mu.Unlock() + if authenticated { + next.ServeHTTP(w, r) + return + } + status := http.StatusOK + if a.config.ClientID == "" { + status = http.StatusServiceUnavailable + } + a.page(w, r, status) + }) +} + +func (a *BrowserLogin) handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if a.config.ClientID == "" { + a.page(w, r, http.StatusServiceUnavailable) + return + } + state, err := randomLoginToken() + if err != nil { + http.Error(w, "登录暂不可用", 503) + return + } + verifier, err := randomLoginToken() + if err != nil { + http.Error(w, "登录暂不可用", 503) + return + } + a.mu.Lock() + a.pruneLocked(time.Now()) + if len(a.pending) >= 4096 { + a.mu.Unlock() + http.Error(w, "登录暂不可用", 503) + return + } + a.pending[state] = loginAttempt{verifier: verifier, expires: time.Now().Add(5 * time.Minute)} + a.mu.Unlock() + loginCookie(w, "__Host-netstable-state", state, "/", 300) + challenge := sha256.Sum256([]byte(verifier)) + u, _ := url.Parse(a.config.Issuer + "/oauth/authorize") + q := u.Query() + q.Set("client_id", a.config.ClientID) + q.Set("response_type", "code") + q.Set("scope", "openid profile") + q.Set("redirect_uri", a.config.PublicURL+"/auth/callback") + q.Set("state", state) + q.Set("code_challenge_method", "S256") + q.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (a *BrowserLogin) handleCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + cookie, err := r.Cookie("__Host-netstable-state") + state := r.URL.Query().Get("state") + if err != nil || state == "" || cookie.Value != state { + http.Error(w, "登录失败,请重新登录", 400) + return + } + loginCookie(w, "__Host-netstable-state", "", "/", -1) + a.mu.Lock() + a.pruneLocked(time.Now()) + attempt, ok := a.pending[state] + delete(a.pending, state) + a.mu.Unlock() + code := r.URL.Query().Get("code") + if !ok || code == "" || len(code) > 4096 || r.URL.Query().Get("error") != "" { + http.Error(w, "登录失败,请重新登录", 400) + return + } + form := url.Values{"grant_type": {"authorization_code"}, "client_id": {a.config.ClientID}, "code": {code}, "code_verifier": {attempt.verifier}, "redirect_uri": {a.config.PublicURL + "/auth/callback"}} + if a.config.ClientSecret != "" { + form.Set("client_secret", a.config.ClientSecret) + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.Issuer+"/oauth/token", strings.NewReader(form.Encode())) + if err != nil { + http.Error(w, "登录暂不可用", 503) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + var token struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + } + if err := a.fetch(req, &token); err != nil || token.AccessToken == "" || !strings.EqualFold(token.TokenType, "Bearer") || token.ExpiresIn <= 0 { + http.Error(w, "登录凭证交换失败,请重新登录", 502) + return + } + req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.Issuer+"/oauth/userinfo", nil) + if err != nil { + http.Error(w, "登录暂不可用", 503) + return + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + var identity struct { + Subject string `json:"sub"` + } + if err := a.fetch(req, &identity); err != nil || strings.TrimSpace(identity.Subject) == "" { + http.Error(w, "登录验证失败,请重新登录", 502) + return + } + session, err := randomLoginToken() + if err != nil { + http.Error(w, "登录暂不可用", 503) + return + } + lifetime := int64(900) + if token.ExpiresIn < lifetime { + lifetime = token.ExpiresIn + } + a.mu.Lock() + a.pruneLocked(time.Now()) + if len(a.sessions) >= 4096 { + a.mu.Unlock() + http.Error(w, "登录暂不可用", 503) + return + } + if old, err := r.Cookie("__Host-netstable-session"); err == nil { + delete(a.sessions, old.Value) + } + a.sessions[session] = time.Now().Add(time.Duration(lifetime) * time.Second) + a.mu.Unlock() + loginCookie(w, "__Host-netstable-session", session, "/", int(lifetime)) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *BrowserLogin) fetch(req *http.Request, target any) error { + response, err := a.client.Do(req) + if err != nil { + return errors.New("W-ID request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return errors.New("W-ID rejected request") + } + body, err := io.ReadAll(io.LimitReader(response.Body, 65537)) + if err != nil || len(body) > 65536 { + return errors.New("invalid W-ID response") + } + return json.Unmarshal(body, target) +} diff --git a/internal/web/server.go b/internal/web/server.go index 090eda1..f640b72 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -35,6 +35,7 @@ type RecordStore interface { type ClientResolver func(ctx context.Context, ip string) (recordstore.ClientInfo, error) type Options struct { + BrowserLogin *BrowserLogin Store RecordStore ClientResolver ClientResolver TrustProxyHeaders bool @@ -46,6 +47,7 @@ type Options struct { } type Server struct { + browserLogin *BrowserLogin store RecordStore clientResolver ClientResolver trustProxyHeaders bool @@ -178,11 +180,15 @@ type iperfSession struct { const maxCompleteBodyBytes = 64 * 1024 * 1024 func New(options Options) *Server { + if options.BrowserLogin == nil { + options.BrowserLogin, _ = NewBrowserLogin(BrowserAuth{}) + } iperf3Path := strings.TrimSpace(options.Iperf3Path) if iperf3Path == "" { iperf3Path = "iperf3" } return &Server{ + browserLogin: options.BrowserLogin, store: options.Store, clientResolver: options.ClientResolver, trustProxyHeaders: options.TrustProxyHeaders, @@ -198,6 +204,8 @@ func New(options Options) *Server { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/auth/login", s.browserLogin.handleLogin) + mux.HandleFunc("/auth/callback", s.browserLogin.handleCallback) mux.HandleFunc("/api/config", s.handleConfig) mux.HandleFunc("/api/health/events", s.handleHealthEvents) mux.HandleFunc("/api/download", s.handleDownload) @@ -209,9 +217,12 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/tests/", s.handleTestRoute) mux.HandleFunc("/downloads/", s.handleBinaryDownload) if s.staticFS != nil { - mux.Handle("/", http.FileServer(s.staticFS)) + mux.Handle("/", s.browserLogin.protect(http.FileServer(s.staticFS))) } else if s.staticDir != "" { - mux.Handle("/", http.FileServer(http.Dir(s.staticDir))) + mux.Handle("/", s.browserLogin.protect(http.FileServer(http.Dir(s.staticDir)))) + } + if s.staticFS == nil && s.staticDir == "" { + mux.Handle("/", s.browserLogin.protect(http.NotFoundHandler())) } return mux } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 2716aba..5b7f48a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -847,7 +847,7 @@ func TestSSEUnknownSessionReturnsNotFound(t *testing.T) { } } -func TestHandlerServesStaticFilesFromFS(t *testing.T) { +func TestHandlerServesLoginInsteadOfAnonymousStaticFiles(t *testing.T) { staticFS := http.FS(fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("dashboard")}, }) @@ -857,11 +857,11 @@ func TestHandlerServesStaticFilesFromFS(t *testing.T) { res := httptest.NewRecorder() server.Handler().ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String()) + if res.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body=%s", res.Code, res.Body.String()) } - if !strings.Contains(res.Body.String(), "dashboard") { - t.Fatalf("body = %q, want dashboard content", res.Body.String()) + if strings.Contains(res.Body.String(), "dashboard") || !strings.Contains(res.Body.String(), "登录暂不可用") { + t.Fatalf("body = %q, want only the login-unavailable page", res.Body.String()) } } -- 2.54.0