Add wireguard-ui-multi core: multi-server DB, WireGuard manager, REST API, UI, installers
Implements the from-scratch multi-server WireGuard management fork per CLAUDE.md spec: sqlite schema (servers/peers/audit_log/users), Curve25519 key generation, per-interface config rendering + wg-quick/systemd control, nftables hook scaffolding, session+CSRF-protected REST API with QR code and config download endpoints, a minimal vanilla-JS web UI, legacy wg0.conf migration, and both a native installer and a Proxmox LXC provisioning script (with auto-detected latest Debian template). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d6608ef80
commit
3b3ffd8ebf
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const sessionCookieName = "wgm_session"
|
||||
const csrfCookieName = "wgm_csrf"
|
||||
const sessionTTL = 12 * time.Hour
|
||||
|
||||
type session struct {
|
||||
username string
|
||||
csrf string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// SessionStore is a simple in-memory session store (single-process deployment).
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*session
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{sessions: make(map[string]*session)}
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) {
|
||||
sessionToken, err = randomToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
csrfToken, err = randomToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionToken] = &session{
|
||||
username: username,
|
||||
csrf: csrfToken,
|
||||
expiresAt: time.Now().Add(sessionTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return sessionToken, csrfToken, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(token string) (*session, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[token]
|
||||
if !ok || time.Now().After(sess.expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// HashPassword bcrypt-hashes a plaintext password for storage.
|
||||
func HashPassword(pw string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// CheckPassword compares a plaintext password against a stored bcrypt hash.
|
||||
func CheckPassword(hash, pw string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
||||
}
|
||||
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
||||
// requireAuth resolves the session from the request cookie, or fails.
|
||||
func (a *API) requireAuth(r *http.Request) (*session, error) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
sess, ok := a.sessions.Get(c.Value)
|
||||
if !ok {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// requireCSRF checks the X-CSRF-Token header against the session's csrf token,
|
||||
// mandatory for all state-changing (non-GET) requests.
|
||||
func requireCSRF(sess *session, r *http.Request) bool {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return true
|
||||
}
|
||||
token := r.Header.Get("X-CSRF-Token")
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1
|
||||
}
|
||||
|
||||
func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: csrfToken,
|
||||
Path: "/",
|
||||
HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookies(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
Reference in New Issue
Block a user