feat: Wormhole 内网穿透与反向代理网关
CI / docker (push) Successful in 1m58s

Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。
Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 04:46:59 +00:00
committed by renjue
commit 0d84b07f68
111 changed files with 15770 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"net"
"net/http"
"strings"
)
// DirectRemoteIP returns the direct TCP peer address, ignoring X-Forwarded-For.
// Use for security decisions (login rate limit, IP rules, resource auth).
func DirectRemoteIP(r *http.Request) string {
return HostOnly(r.RemoteAddr)
}
// AddrHost extracts the host portion from a net.Addr string (host:port or [host]:port).
func AddrHost(addr net.Addr) string {
if addr == nil {
return ""
}
return HostOnly(addr.String())
}
// HostOnly strips the port from an address string.
func HostOnly(addr string) string {
if addr == "" {
return ""
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
// ExtractRemoteIP returns the client IP, honoring X-Forwarded-For when present.
// Use only when forwarding visitor IP to upstream backends behind a trusted proxy.
func ExtractRemoteIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return DirectRemoteIP(r)
}
// SafeRedirectPath validates a post-login redirect path (relative only).
func SafeRedirectPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return "/"
}
return raw
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"errors"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
mu sync.RWMutex
cfg *config.AuthConfig
limiter *LoginLimiter
}
func NewService(cfg *config.AuthConfig) *Service {
return &Service{
cfg: cfg,
limiter: NewLoginLimiter(cfg),
}
}
func (s *Service) ReloadAuth(cfg *config.AuthConfig) {
s.mu.Lock()
defer s.mu.Unlock()
*s.cfg = *cfg
s.limiter = NewLoginLimiter(cfg)
}
func (s *Service) Login(st *store.Store, ip, username, password string) (string, *store.User, error) {
s.mu.RLock()
limiter := s.limiter
s.mu.RUnlock()
if retry, blocked := limiter.IsBlocked(ip, username); blocked {
return "", nil, &LoginBlockedError{RetryAfter: retry}
}
user, err := st.GetUserByUsername(username)
if err != nil {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !user.Status {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("invalid credentials")
}
if user.Role == "visitor" {
limiter.RecordFailure(ip, username)
time.Sleep(300 * time.Millisecond)
return "", nil, errors.New("访客账号无法登录管理后台")
}
limiter.Reset(ip, username)
token, err := s.GenerateToken(user)
if err != nil {
return "", nil, err
}
return token, user, nil
}
func (s *Service) GenerateToken(user *store.User) (string, error) {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.TokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func (s *Service) ParseToken(tokenStr string) (*Claims, error) {
s.mu.RLock()
secret := s.cfg.JWTSecret
s.mu.RUnlock()
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func IsAdmin(role string) bool {
return role == "admin"
}
func IsVisitor(role string) bool {
return role == "visitor"
}
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"sync"
"time"
"github.com/wormhole/wormhole/internal/config"
)
type LoginBlockedError struct {
RetryAfter time.Duration
}
func (e *LoginBlockedError) Error() string {
return "too many login attempts"
}
type loginRecord struct {
failures int
windowStart time.Time
lockedUntil time.Time
}
type LoginLimiter struct {
mu sync.Mutex
maxAttempts int
window time.Duration
lockout time.Duration
records map[string]*loginRecord
}
func NewLoginLimiter(cfg *config.AuthConfig) *LoginLimiter {
if cfg.LoginMaxAttempts <= 0 {
return nil
}
max := cfg.LoginMaxAttempts
window := cfg.LoginWindow
lockout := cfg.LoginLockout
if window <= 0 {
window = 5 * time.Minute
}
if lockout <= 0 {
lockout = 15 * time.Minute
}
return &LoginLimiter{
maxAttempts: max,
window: window,
lockout: lockout,
records: make(map[string]*loginRecord),
}
}
func (l *LoginLimiter) keys(ip, username string) []string {
keys := []string{"ip:" + ip}
if username != "" {
keys = append(keys, "user:"+username)
}
return keys
}
func (l *LoginLimiter) IsBlocked(ip, username string) (time.Duration, bool) {
if l == nil {
return 0, false
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
var maxRetry time.Duration
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
continue
}
if now.Before(rec.lockedUntil) {
retry := time.Until(rec.lockedUntil)
if retry > maxRetry {
maxRetry = retry
}
}
}
return maxRetry, maxRetry > 0
}
func (l *LoginLimiter) RecordFailure(ip, username string) {
if l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
rec := l.records[key]
if rec == nil {
rec = &loginRecord{windowStart: now}
l.records[key] = rec
}
if now.Before(rec.lockedUntil) {
continue
}
if now.Sub(rec.windowStart) > l.window {
rec.failures = 0
rec.windowStart = now
}
rec.failures++
if rec.failures >= l.maxAttempts {
rec.lockedUntil = now.Add(l.lockout)
rec.failures = 0
}
}
}
func (l *LoginLimiter) Reset(ip, username string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
for _, key := range l.keys(ip, username) {
delete(l.records, key)
}
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"errors"
"testing"
"time"
"github.com/wormhole/wormhole/internal/config"
)
func TestLoginLimiterBlocksAfterMaxAttempts(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 3,
LoginWindow: time.Minute,
LoginLockout: 10 * time.Minute,
})
ip := "192.168.1.1"
user := "admin"
for i := 0; i < 3; i++ {
if _, blocked := l.IsBlocked(ip, user); blocked {
t.Fatalf("unexpected block at attempt %d", i+1)
}
l.RecordFailure(ip, user)
}
retry, blocked := l.IsBlocked(ip, user)
if !blocked || retry <= 0 {
t.Fatalf("expected block after max attempts, retry=%v blocked=%v", retry, blocked)
}
}
func TestLoginLimiterResetClearsBlock(t *testing.T) {
l := NewLoginLimiter(&config.AuthConfig{
LoginMaxAttempts: 2,
LoginWindow: time.Minute,
LoginLockout: time.Minute,
})
ip := "10.0.0.1"
l.RecordFailure(ip, "u1")
l.RecordFailure(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); !blocked {
t.Fatal("expected blocked")
}
l.Reset(ip, "u1")
if _, blocked := l.IsBlocked(ip, "u1"); blocked {
t.Fatal("expected unblocked after reset")
}
}
func TestLoginBlockedError(t *testing.T) {
err := &LoginBlockedError{RetryAfter: 30 * time.Second}
if !errors.As(err, new(*LoginBlockedError)) {
t.Fatal("LoginBlockedError should be detectable via errors.As")
}
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
const accessCookieName = "wh_access"
type ResourceAuth struct {
cache *cache.Manager
store *store.Store
jwtSecret string
limiter *LoginLimiter
mu sync.RWMutex
grants map[string]time.Time
}
func NewResourceAuth(c *cache.Manager, st *store.Store, authCfg *config.AuthConfig) *ResourceAuth {
ra := &ResourceAuth{
cache: c,
store: st,
jwtSecret: authCfg.JWTSecret,
limiter: NewLoginLimiter(authCfg),
grants: make(map[string]time.Time),
}
go ra.cleanupLoop()
return ra
}
func (ra *ResourceAuth) ReloadAuth(cfg *config.AuthConfig) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.jwtSecret = cfg.JWTSecret
ra.limiter = NewLoginLimiter(cfg)
}
func grantKey(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
ra.mu.Lock()
for k, exp := range ra.grants {
if now.After(exp) {
delete(ra.grants, k)
}
}
ra.mu.Unlock()
}
}
func (ra *ResourceAuth) GrantAccess(resourceType string, resourceID uint, ip string, ttl time.Duration) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.grants[grantKey(resourceType, resourceID, ip)] = time.Now().Add(ttl)
}
func (ra *ResourceAuth) HasAccess(resourceType string, resourceID uint, ip string) bool {
ra.mu.RLock()
defer ra.mu.RUnlock()
exp, ok := ra.grants[grantKey(resourceType, resourceID, ip)]
return ok && time.Now().Before(exp)
}
func (ra *ResourceAuth) CheckHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint) bool {
cfg, userIDs, ok := GetVisitorConfig(ra.cache, resourceType, resourceID)
if ok && cfg.Enabled {
return ra.CheckVisitorHTTP(w, r, resourceType, resourceID, cfg, userIDs)
}
ip := DirectRemoteIP(r)
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
switch policy.Type {
case "basic":
_, pass, ok := r.BasicAuth()
if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
w.Header().Set("WWW-Authenticate", `Basic realm="Wormhole"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
case "form":
if r.URL.Path == "/_wormhole/auth" && r.Method == http.MethodPost {
_ = r.ParseForm()
pass := r.FormValue("password")
if subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Value: "1", Path: "/", MaxAge: policy.TTLSeconds})
redirect := SafeRedirectPath(r.FormValue("redirect"))
http.Redirect(w, r, redirect, http.StatusFound)
return false
}
}
if cookie, err := r.Cookie(accessCookieName); err == nil && cookie.Value == "1" {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
redirect := r.URL.RequestURI()
html := fmt.Sprintf(`<!DOCTYPE html><html><body>
<form method="POST" action="/_wormhole/auth">
<input type="hidden" name="redirect" value="%s"/>
<input type="password" name="password" placeholder="Password"/>
<button type="submit">Login</button>
</form></body></html>`, redirect)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(html))
return false
case "callback":
if policy.CallbackURL != "" {
token := r.URL.Query().Get("token")
if token != "" && token == policy.Password {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
cb := policy.CallbackURL
if stringsContains(cb, "?") {
cb += "&"
} else {
cb += "?"
}
cb += "redirect=" + r.URL.RequestURI()
http.Redirect(w, r, cb, http.StatusFound)
return false
}
}
return true
}
func (ra *ResourceAuth) CheckTCP(remoteAddr net.Addr, resourceType string, resourceID uint) bool {
ip := remoteAddr.String()
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
return false
}
func stringsContains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func (ra *ResourceAuth) CreatePolicy(st *store.Store, p *store.AuthPolicy) error {
return st.CreateAuthPolicy(p)
}
+337
View File
@@ -0,0 +1,337 @@
package auth
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/store"
)
const (
visitorLoginPath = "/_wormhole/login"
visitorCookie = "wh_visitor"
)
type VisitorClaims struct {
UserID uint `json:"user_id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
jwt.RegisteredClaims
}
type VisitorConfig struct {
Enabled bool
BindMode string
UserIDs []uint
TTLSeconds int
}
func visitorConfigFromHost(h *store.Host) VisitorConfig {
ttl := h.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: h.VisitorAuthEnabled,
BindMode: h.VisitorBindMode,
TTLSeconds: ttl,
}
}
func visitorConfigFromTunnel(t *store.Tunnel) VisitorConfig {
ttl := t.VisitorTTLSeconds
if ttl <= 0 {
ttl = 7200
}
return VisitorConfig{
Enabled: t.VisitorAuthEnabled,
BindMode: t.VisitorBindMode,
TTLSeconds: ttl,
}
}
func (ra *ResourceAuth) CheckVisitorHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig, userIDs []uint) bool {
if !cfg.Enabled {
return true
}
cfg.UserIDs = userIDs
if bindMode := strings.TrimSpace(cfg.BindMode); bindMode == "" {
cfg.BindMode = "all"
} else {
cfg.BindMode = bindMode
}
if r.URL.Path == visitorLoginPath {
ra.handleVisitorLogin(w, r, resourceType, resourceID, cfg)
return false
}
if claims, ok := ra.parseVisitorCookie(r, resourceType, resourceID); ok {
if ra.visitorAllowed(claims.UserID, cfg) {
ra.logVisitorAccess(claims.UserID, resourceType, resourceID, "access", r)
return true
}
}
redirect := r.URL.RequestURI()
http.Redirect(w, r, visitorLoginPath+"?redirect="+url.QueryEscape(redirect), http.StatusFound)
return false
}
func visitorLimiterIP(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) handleVisitorLogin(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig) {
ip := DirectRemoteIP(r)
scopedIP := visitorLimiterIP(resourceType, resourceID, ip)
switch r.Method {
case http.MethodPost:
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
if retry, blocked := ra.limiter.IsBlocked(scopedIP, username); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
password := r.FormValue("password")
user, err := ra.authenticateVisitor(username, password, cfg)
if err != nil {
ra.limiter.RecordFailure(scopedIP, username)
time.Sleep(300 * time.Millisecond)
ra.renderVisitorLogin(w, r, cfg, "用户名或密码错误")
return
}
ra.limiter.Reset(scopedIP, username)
token, err := ra.signVisitorToken(user.ID, resourceType, resourceID, cfg.TTLSeconds)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: visitorCookie,
Value: token,
Path: "/",
MaxAge: cfg.TTLSeconds,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
redirect := SafeRedirectPath(r.FormValue("redirect"))
ra.logVisitorAccess(user.ID, resourceType, resourceID, "login", r)
http.Redirect(w, r, redirect, http.StatusFound)
case http.MethodGet:
if retry, blocked := ra.limiter.IsBlocked(scopedIP, ""); blocked {
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
return
}
ra.renderVisitorLogin(w, r, cfg, "")
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (ra *ResourceAuth) authenticateVisitor(username, password string, cfg VisitorConfig) (*store.User, error) {
if username == "" || password == "" {
return nil, errors.New("invalid credentials")
}
user, err := ra.store.GetUserByUsername(username)
if err != nil {
return nil, errors.New("invalid credentials")
}
if user.Role != "visitor" || !user.Status {
return nil, errors.New("invalid credentials")
}
if !store.CheckPassword(user.PasswordHash, password) {
return nil, errors.New("invalid credentials")
}
if !ra.visitorAllowed(user.ID, cfg) {
return nil, errors.New("not allowed")
}
return user, nil
}
func (ra *ResourceAuth) visitorAllowed(userID uint, cfg VisitorConfig) bool {
if cfg.BindMode == "all" {
return true
}
for _, id := range cfg.UserIDs {
if id == userID {
return true
}
}
return false
}
func (ra *ResourceAuth) parseVisitorCookie(r *http.Request, resourceType string, resourceID uint) (*VisitorClaims, bool) {
cookie, err := r.Cookie(visitorCookie)
if err != nil || cookie.Value == "" {
return nil, false
}
claims, err := ra.parseVisitorToken(cookie.Value)
if err != nil {
return nil, false
}
if claims.ResourceType != resourceType || claims.ResourceID != resourceID {
return nil, false
}
return claims, true
}
func (ra *ResourceAuth) signVisitorToken(userID uint, resourceType string, resourceID uint, ttlSeconds int) (string, error) {
if ttlSeconds <= 0 {
ttlSeconds = 7200
}
claims := VisitorClaims{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(ra.jwtSecret))
}
func (ra *ResourceAuth) parseVisitorToken(tokenStr string) (*VisitorClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &VisitorClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(ra.jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*VisitorClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func (ra *ResourceAuth) renderVisitorLogin(w http.ResponseWriter, r *http.Request, cfg VisitorConfig, errMsg string) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 24px;color:#71767b;font-size:14px}
label{display:block;margin-bottom:6px;font-size:13px;color:#71767b}
input{width:100%%;padding:10px 12px;margin-bottom:14px;border:1px solid #2f3336;border-radius:8px;background:#000;color:#e7e9ea;font-size:15px}
input:focus{outline:none;border-color:#1d9bf0}
button{width:100%%;padding:11px;border:none;border-radius:999px;background:#1d9bf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
button:hover{background:#1a8cd8}
.error{color:#f4212e;font-size:13px;margin:0 0 12px}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
%s
<form method="POST" action="%s">
<input type="hidden" name="redirect" value="%s"/>
<label>用户名</label>
<input name="username" autocomplete="username" required autofocus/>
<label>密码</label>
<input name="password" type="password" autocomplete="current-password" required/>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>`, errHTML, visitorLoginPath, html.EscapeString(redirect))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(page))
}
func (ra *ResourceAuth) renderVisitorLoginBlocked(w http.ResponseWriter, r *http.Request, _ VisitorConfig, retryAfter time.Duration) {
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
minutes := int(retryAfter.Minutes())
if minutes < 1 {
minutes = 1
}
msg := fmt.Sprintf("登录尝试次数过多,请 %d 分钟后再试", minutes)
if retryAfter < time.Minute {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
msg = fmt.Sprintf("登录尝试次数过多,请 %d 秒后再试", secs)
}
retrySecs := int(retryAfter.Seconds()) + 1
if retrySecs < 1 {
retrySecs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retrySecs))
loginURL := visitorLoginPath + "?redirect=" + url.QueryEscape(redirect)
page := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>登录验证</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
h1{margin:0 0 8px;font-size:22px;font-weight:600}
.sub{margin:0 0 16px;color:#71767b;font-size:14px}
.error{color:#f4212e;font-size:14px;margin:0 0 16px;line-height:1.5}
a{color:#1d9bf0;text-decoration:none}
</style>
</head>
<body>
<div class="card">
<h1>登录验证</h1>
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
<p class="error">%s</p>
<p class="sub"><a href="%s">稍后再试</a></p>
</div>
</body>
</html>`, html.EscapeString(msg), html.EscapeString(loginURL))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(page))
}
func GetVisitorConfig(c *cache.Manager, resourceType string, resourceID uint) (VisitorConfig, []uint, bool) {
switch resourceType {
case "host":
h, ok := c.GetHost(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromHost(h)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
case "tunnel":
t, ok := c.GetTunnel(resourceID)
if !ok {
return VisitorConfig{}, nil, false
}
cfg := visitorConfigFromTunnel(t)
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
return cfg, ids, true
default:
return VisitorConfig{}, nil, false
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"net/http"
"path"
"strings"
"github.com/wormhole/wormhole/internal/store"
)
func shouldLogVisitorAccess(reqPath string) bool {
if reqPath == visitorLoginPath {
return false
}
base := strings.Split(reqPath, "?")[0]
ext := strings.ToLower(path.Ext(base))
switch ext {
case ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".woff", ".woff2", ".ttf", ".eot", ".map", ".wasm":
return false
}
return true
}
func (ra *ResourceAuth) logVisitorAccess(userID uint, resourceType string, resourceID uint, action string, r *http.Request) {
if ra.store == nil {
return
}
reqPath := r.URL.RequestURI()
if action == "access" && !shouldLogVisitorAccess(r.URL.Path) {
return
}
if len(reqPath) > 512 {
reqPath = reqPath[:512]
}
ua := r.UserAgent()
if len(ua) > 256 {
ua = ua[:256]
}
entry := &store.VisitorAccessLog{
UserID: userID,
ResourceType: resourceType,
ResourceID: resourceID,
Action: action,
Method: r.Method,
Path: reqPath,
IP: ExtractRemoteIP(r),
UserAgent: ua,
}
go func() {
_ = ra.store.AppendVisitorAccessLog(entry)
}()
}