feat: require regional W-ID login for browser pages #1

Open
kanshan wants to merge 1 commits from codex/domestic-login-default into main
5 changed files with 339 additions and 7 deletions
+40
View File
@@ -169,3 +169,43 @@ UDP 示例:
git remote add origin <gitea-repo-url>
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.
+10
View File
@@ -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,
+271
View File
@@ -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(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f6f8;color:#20242c;font:16px system-ui}main{text-align:center;padding:36px;border-radius:18px;background:white;box-shadow:0 8px 36px #0001}a{display:inline-block;background:#20242c;color:white;text-decoration:none;border-radius:9px;padding:12px 30px}p{color:#68707c}</style><main><h1>登录</h1>{{if .}}<a href="/auth/login">使用 W-ID 登录</a>{{else}}<p>登录暂不可用</p>{{end}}</main></html>`))
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)
}
+13 -2
View File
@@ -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
}
+5 -5
View File
@@ -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("<html>dashboard</html>")},
})
@@ -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())
}
}