| package proxy |
|
|
| import ( |
| "encoding/json" |
| "io/fs" |
| "log" |
| "net" |
| "net/http" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
|
|
| "notion-manager/internal/web" |
| ) |
|
|
| const ( |
| dashboardLoginFailureLimit = 5 |
| dashboardLoginMaxSources = 4096 |
| dashboardLoginBlockDuration = 5 * time.Minute |
| dashboardLoginAttemptTTL = 15 * time.Minute |
| dashboardLoginCleanupInterval = time.Minute |
| dashboardLoginOverflowSource = "<overflow>" |
| ) |
|
|
| type dashboardLoginAttempt struct { |
| failures int |
| blockedUntil time.Time |
| expiresAt time.Time |
| } |
|
|
| |
| type DashboardAuth struct { |
| adminPasswordHash string |
| apiKey string |
| sessions sync.Map |
| loginMu sync.Mutex |
| loginAttempts map[string]dashboardLoginAttempt |
| lastLoginCleanup time.Time |
| } |
|
|
| |
| func NewDashboardAuth(adminPasswordHash, apiKey string) *DashboardAuth { |
| return &DashboardAuth{ |
| adminPasswordHash: adminPasswordHash, |
| apiKey: apiKey, |
| loginAttempts: make(map[string]dashboardLoginAttempt), |
| } |
| } |
|
|
| func dashboardLoginSource(r *http.Request) string { |
| host, _, err := net.SplitHostPort(r.RemoteAddr) |
| if err == nil && host != "" { |
| return host |
| } |
| if ip := net.ParseIP(r.RemoteAddr); ip != nil { |
| return ip.String() |
| } |
| return "<unknown>" |
| } |
|
|
| func (da *DashboardAuth) cleanupLoginAttemptsLocked(now time.Time) { |
| if !da.lastLoginCleanup.IsZero() && now.Sub(da.lastLoginCleanup) < dashboardLoginCleanupInterval && len(da.loginAttempts) < dashboardLoginMaxSources { |
| return |
| } |
| for source, attempt := range da.loginAttempts { |
| if !attempt.expiresAt.After(now) { |
| delete(da.loginAttempts, source) |
| } |
| } |
| da.lastLoginCleanup = now |
| } |
|
|
| func (da *DashboardAuth) loginAttemptKeyLocked(source string) string { |
| if _, ok := da.loginAttempts[source]; ok { |
| return source |
| } |
| if _, ok := da.loginAttempts[dashboardLoginOverflowSource]; ok { |
| if len(da.loginAttempts) >= dashboardLoginMaxSources { |
| return dashboardLoginOverflowSource |
| } |
| } |
| if len(da.loginAttempts) < dashboardLoginMaxSources-1 { |
| return source |
| } |
| return dashboardLoginOverflowSource |
| } |
|
|
| func (da *DashboardAuth) loginBlockRemaining(source string, now time.Time) time.Duration { |
| da.loginMu.Lock() |
| defer da.loginMu.Unlock() |
|
|
| if da.loginAttempts == nil { |
| da.loginAttempts = make(map[string]dashboardLoginAttempt) |
| } |
| da.cleanupLoginAttemptsLocked(now) |
| key := da.loginAttemptKeyLocked(source) |
| attempt, ok := da.loginAttempts[key] |
| if !ok || attempt.blockedUntil.IsZero() { |
| return 0 |
| } |
| if !attempt.blockedUntil.After(now) { |
| delete(da.loginAttempts, key) |
| return 0 |
| } |
| return attempt.blockedUntil.Sub(now) |
| } |
|
|
| func (da *DashboardAuth) recordLoginFailure(source string, now time.Time) time.Duration { |
| da.loginMu.Lock() |
| defer da.loginMu.Unlock() |
|
|
| if da.loginAttempts == nil { |
| da.loginAttempts = make(map[string]dashboardLoginAttempt) |
| } |
| da.cleanupLoginAttemptsLocked(now) |
| key := da.loginAttemptKeyLocked(source) |
| attempt := da.loginAttempts[key] |
| attempt.failures++ |
| attempt.expiresAt = now.Add(dashboardLoginAttemptTTL) |
| if attempt.failures >= dashboardLoginFailureLimit { |
| attempt.blockedUntil = now.Add(dashboardLoginBlockDuration) |
| } |
| da.loginAttempts[key] = attempt |
| if attempt.blockedUntil.After(now) { |
| return attempt.blockedUntil.Sub(now) |
| } |
| return 0 |
| } |
|
|
| func (da *DashboardAuth) resetLoginFailures(source string) { |
| da.loginMu.Lock() |
| defer da.loginMu.Unlock() |
|
|
| if da.loginAttempts == nil { |
| return |
| } |
| key := da.loginAttemptKeyLocked(source) |
| delete(da.loginAttempts, key) |
| } |
|
|
| func writeLoginRateLimit(w http.ResponseWriter, remaining time.Duration) { |
| retryAfter := int((remaining + time.Second - 1) / time.Second) |
| w.Header().Set("Content-Type", "application/json") |
| w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) |
| w.WriteHeader(http.StatusTooManyRequests) |
| json.NewEncoder(w).Encode(map[string]string{"error": "too many login attempts"}) |
| } |
|
|
| |
| func (da *DashboardAuth) HasAdminPassword() bool { |
| return da.adminPasswordHash != "" && IsAdminPasswordHashed(da.adminPasswordHash) |
| } |
|
|
| |
| func (da *DashboardAuth) ValidateSession(r *http.Request) bool { |
| c, err := r.Cookie("dashboard_session") |
| if err != nil { |
| return false |
| } |
| if exp, ok := da.sessions.Load(c.Value); ok { |
| if exp.(time.Time).After(time.Now()) { |
| return true |
| } |
| da.sessions.Delete(c.Value) |
| } |
| return false |
| } |
|
|
| |
| func (da *DashboardAuth) CreateSession(w http.ResponseWriter) { |
| id := generateUUIDv4() |
| expiry := time.Now().Add(24 * time.Hour) |
| da.sessions.Store(id, expiry) |
| http.SetCookie(w, &http.Cookie{ |
| Name: "dashboard_session", Value: id, Path: "/", |
| HttpOnly: true, MaxAge: 86400, SameSite: http.SameSiteLaxMode, |
| }) |
| } |
|
|
| |
| func (da *DashboardAuth) DestroySession(w http.ResponseWriter, r *http.Request) { |
| if c, err := r.Cookie("dashboard_session"); err == nil { |
| da.sessions.Delete(c.Value) |
| } |
| http.SetCookie(w, &http.Cookie{ |
| Name: "dashboard_session", Value: "", Path: "/", |
| HttpOnly: true, MaxAge: -1, |
| }) |
| } |
|
|
| |
| |
| func (da *DashboardAuth) RequireAuth(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| path := strings.TrimPrefix(r.URL.Path, "/dashboard") |
|
|
| |
| if strings.HasPrefix(path, "/assets/") { |
| next.ServeHTTP(w, r) |
| return |
| } |
| |
| if strings.HasPrefix(path, "/auth/") || path == "/auth" { |
| next.ServeHTTP(w, r) |
| return |
| } |
|
|
| |
| if !da.HasAdminPassword() { |
| next.ServeHTTP(w, r) |
| return |
| } |
|
|
| |
| if !da.ValidateSession(r) { |
| |
| |
| accept := r.Header.Get("Accept") |
| if strings.Contains(accept, "application/json") { |
| w.Header().Set("Content-Type", "application/json") |
| http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) |
| return |
| } |
| |
| next.ServeHTTP(w, r) |
| return |
| } |
|
|
| next.ServeHTTP(w, r) |
| }) |
| } |
|
|
| |
| func (da *DashboardAuth) HandleAuthSalt() http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| salt := AdminPasswordSalt(da.adminPasswordHash) |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "salt": salt, |
| "required": da.HasAdminPassword(), |
| }) |
| } |
| } |
|
|
| |
| func (da *DashboardAuth) HandleAuthLogin() http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "POST" { |
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| return |
| } |
| source := dashboardLoginSource(r) |
| if remaining := da.loginBlockRemaining(source, time.Now()); remaining > 0 { |
| writeLoginRateLimit(w, remaining) |
| return |
| } |
| var body struct { |
| Hash string `json:"hash"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| if remaining := da.recordLoginFailure(source, time.Now()); remaining > 0 { |
| writeLoginRateLimit(w, remaining) |
| return |
| } |
| http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest) |
| return |
| } |
|
|
| if !VerifyAdminPassword(da.adminPasswordHash, body.Hash) { |
| log.Printf("[dashboard] failed login attempt") |
| if remaining := da.recordLoginFailure(source, time.Now()); remaining > 0 { |
| writeLoginRateLimit(w, remaining) |
| return |
| } |
| w.Header().Set("Content-Type", "application/json") |
| w.WriteHeader(http.StatusUnauthorized) |
| json.NewEncoder(w).Encode(map[string]string{"error": "invalid password"}) |
| return |
| } |
|
|
| da.resetLoginFailures(source) |
| da.CreateSession(w) |
| log.Printf("[dashboard] login success") |
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) |
| } |
| } |
|
|
| |
| func (da *DashboardAuth) HandleAuthLogout() http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| da.DestroySession(w, r) |
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) |
| } |
| } |
|
|
| |
| func (da *DashboardAuth) HandleAuthCheck() http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "authenticated": da.ValidateSession(r), |
| "required": da.HasAdminPassword(), |
| }) |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| func (p *AccountPool) GetAccountByEmail(email string) *Account { |
| p.mu.RLock() |
| defer p.mu.RUnlock() |
| for _, acc := range p.accounts { |
| if acc.UserEmail == email { |
| return acc |
| } |
| } |
| return nil |
| } |
|
|
| |
| |
| func (p *AccountPool) GetBestAccount() *Account { |
| p.mu.RLock() |
| defer p.mu.RUnlock() |
| return p.pickBestAccountLocked(nil) |
| } |
|
|
| |
|
|
| |
| func (rp *ReverseProxy) CreateTargetedSession(w http.ResponseWriter, acc *Account) { |
| id := generateUUIDv4() |
| sess := newProxySession(acc) |
| rp.sessions.Store(id, sess) |
| http.SetCookie(w, &http.Cookie{ |
| Name: "np_session", Value: id, Path: "/", |
| HttpOnly: true, MaxAge: 86400, |
| }) |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| func HandleDashboard(apiKey string, auth *DashboardAuth) http.Handler { |
| |
| distFS, err := fs.Sub(web.DistFS, "dist") |
| if err != nil { |
| panic("failed to get dist sub-filesystem: " + err.Error()) |
| } |
| fileServer := http.FileServer(http.FS(distFS)) |
|
|
| |
| inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| path := strings.TrimPrefix(r.URL.Path, "/dashboard") |
| if path == "" || path == "/" { |
| path = "/index.html" |
| } |
|
|
| |
| switch path { |
| case "/auth/salt": |
| auth.HandleAuthSalt()(w, r) |
| return |
| case "/auth/login": |
| auth.HandleAuthLogin()(w, r) |
| return |
| case "/auth/logout": |
| auth.HandleAuthLogout()(w, r) |
| return |
| case "/auth/check": |
| auth.HandleAuthCheck()(w, r) |
| return |
| } |
|
|
| |
| if path == "/index.html" { |
| data, err := fs.ReadFile(distFS, "index.html") |
| if err != nil { |
| http.Error(w, "index.html not found", http.StatusInternalServerError) |
| return |
| } |
| html := string(data) |
| |
| |
| if !auth.HasAdminPassword() || auth.ValidateSession(r) { |
| html = strings.Replace(html, "<head>", |
| `<head><meta name="api-key" content="`+apiKey+`">`, 1) |
| } |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-cache") |
| w.Write([]byte(html)) |
| return |
| } |
|
|
| |
| if strings.HasPrefix(path, "/assets/") { |
| w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| } |
|
|
| |
| r.URL.Path = path |
| fileServer.ServeHTTP(w, r) |
| }) |
|
|
| |
| return auth.RequireAuth(inner) |
| } |
|
|
| |
| |
| func HandleProxyStart(pool *AccountPool, rp *ReverseProxy, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Redirect(w, r, "/dashboard/", http.StatusFound) |
| return |
| } |
|
|
| email := r.URL.Query().Get("email") |
| accountID := r.URL.Query().Get("account_id") |
| best := r.URL.Query().Get("best") |
|
|
| var acc *Account |
| if best == "true" { |
| acc = pool.GetBestAccount() |
| } else if accountID != "" { |
| acc = pool.FindByAccountID(accountID) |
| } else if email != "" { |
| acc = pool.GetAccountByEmail(email) |
| } |
|
|
| if acc == nil { |
| w.Header().Set("Content-Type", "application/json") |
| http.Error(w, `{"error":"account not found or all exhausted"}`, http.StatusNotFound) |
| return |
| } |
|
|
| |
| |
| |
| |
| |
| if pool.HasNoWorkspace(acc) { |
| w.Header().Set("Content-Type", "application/json") |
| http.Error(w, `{"error":"account has no accessible workspace; pick another or re-register"}`, http.StatusConflict) |
| return |
| } |
|
|
| rp.CreateTargetedSession(w, acc) |
| http.Redirect(w, r, "/ai", http.StatusFound) |
| } |
| } |
|
|