Один бинарник backupctl: - server: API для агентов и restore, веб-морда (один админ), SQLite, ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh; - backup/plan: сбор по каталогу категорий (files/systemd/xui/compose), горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома, digest образов, проверка crontab, потоковая отправка tar.zst; - restore: провижининг на основе vps_installer с теми же версиями пакетов, recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose с заливкой дампов и закреплением образов по digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
4.7 KiB
Go
170 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "bm_session"
|
|
sessionTTL = 7 * 24 * time.Hour
|
|
csrfKey ctxKey = 2
|
|
)
|
|
|
|
// SetAdmin задаёт логин и пароль единственного администратора и сбрасывает сессии.
|
|
func (s *Store) SetAdmin(user, password string) error {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetSetting(settingAdminUser, user); err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetSetting(settingAdminHash, string(hash)); err != nil {
|
|
return err
|
|
}
|
|
return s.DeleteAllSessions()
|
|
}
|
|
|
|
func (s *Store) CheckAdmin(user, password string) bool {
|
|
hash := s.Setting(settingAdminHash)
|
|
if hash == "" {
|
|
return false
|
|
}
|
|
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.Setting(settingAdminUser))) == 1
|
|
passOK := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
|
return userOK && passOK
|
|
}
|
|
|
|
// admin пропускает только авторизованного админа; на POST дополнительно проверяет CSRF.
|
|
func (s *Server) admin(h http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ck, err := r.Cookie(sessionCookie)
|
|
var csrf string
|
|
if err == nil {
|
|
csrf, err = s.st.SessionCSRF(ck.Value)
|
|
}
|
|
if err != nil {
|
|
if r.Method == http.MethodGet {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
} else {
|
|
http.Error(w, "нужна авторизация", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrf)) != 1 {
|
|
http.Error(w, "неверный CSRF-токен, обновите страницу", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
h(w, r.WithContext(context.WithValue(r.Context(), csrfKey, csrf)))
|
|
}
|
|
}
|
|
|
|
func csrfFrom(r *http.Request) string {
|
|
v, _ := r.Context().Value(csrfKey).(string)
|
|
return v
|
|
}
|
|
|
|
func (s *Server) secureCookies() bool { return strings.HasPrefix(s.cfg.PublicURL, "https://") }
|
|
|
|
func (s *Server) webLoginPage(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, r, "login", map[string]any{"NoAdmin": s.st.Setting(settingAdminHash) == ""})
|
|
}
|
|
|
|
func (s *Server) webLogin(w http.ResponseWriter, r *http.Request) {
|
|
ip := s.clientIP(r)
|
|
if !s.login.allowed(ip) {
|
|
s.render(w, r, "login", map[string]any{"Error": "Слишком много попыток, подождите несколько минут"})
|
|
return
|
|
}
|
|
if !s.st.CheckAdmin(r.FormValue("user"), r.FormValue("password")) {
|
|
s.login.fail(ip)
|
|
time.Sleep(700 * time.Millisecond)
|
|
s.render(w, r, "login", map[string]any{"Error": "Неверный логин или пароль"})
|
|
return
|
|
}
|
|
s.login.reset(ip)
|
|
tok := newToken()
|
|
if err := s.st.CreateSession(tok, newToken(), sessionTTL); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, Secure: s.secureCookies(),
|
|
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
|
|
})
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) webLogout(w http.ResponseWriter, r *http.Request) {
|
|
if ck, err := r.Cookie(sessionCookie); err == nil {
|
|
_ = s.st.DeleteSession(ck.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
if s.cfg.TrustProxy {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
// loginLimiter: после 5 неудач подряд вход с IP блокируется на 10 минут.
|
|
type loginLimiter struct {
|
|
mu sync.Mutex
|
|
fails map[string]*loginState
|
|
}
|
|
|
|
type loginState struct {
|
|
n int
|
|
until time.Time
|
|
}
|
|
|
|
func newLoginLimiter() *loginLimiter { return &loginLimiter{fails: map[string]*loginState{}} }
|
|
|
|
func (l *loginLimiter) allowed(ip string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
st := l.fails[ip]
|
|
return st == nil || time.Now().After(st.until)
|
|
}
|
|
|
|
func (l *loginLimiter) fail(ip string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
st := l.fails[ip]
|
|
if st == nil {
|
|
st = &loginState{}
|
|
l.fails[ip] = st
|
|
}
|
|
st.n++
|
|
if st.n >= 5 {
|
|
st.n = 0
|
|
st.until = time.Now().Add(10 * time.Minute)
|
|
}
|
|
}
|
|
|
|
func (l *loginLimiter) reset(ip string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.fails, ip)
|
|
}
|