backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник 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>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,169 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user