feat: initial commit of vps-commander release v1.0.0
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
type Runner interface {
|
||||
DevicesJSON(http.ResponseWriter, *http.Request)
|
||||
AuditsJSON(http.ResponseWriter, *http.Request)
|
||||
PanelExec(http.ResponseWriter, *http.Request)
|
||||
RotateAPIKey(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Panel struct {
|
||||
Password string
|
||||
Runner Runner
|
||||
mu sync.RWMutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
func New(password string, runner Runner) *Panel {
|
||||
return &Panel{Password: password, Runner: runner, sessions: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (p *Panel) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/login", p.login)
|
||||
mux.HandleFunc("/panel/logout", p.logout)
|
||||
mux.HandleFunc("/static/", p.static)
|
||||
mux.HandleFunc("/panel/api/devices", p.protected(p.Runner.DevicesJSON))
|
||||
mux.HandleFunc("/panel/api/audits", p.protected(p.Runner.AuditsJSON))
|
||||
mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec))
|
||||
mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey))
|
||||
mux.HandleFunc("/", p.index)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (p *Panel) index(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.valid(r) {
|
||||
http.Redirect(w, r, "/panel/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (p *Panel) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
data, _ := staticFS.ReadFile("static/login.html")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
if p.Password == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(p.Password)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
http.Error(w, "session error", 500)
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
p.mu.Lock()
|
||||
p.sessions[token] = time.Now().Add(12 * time.Hour)
|
||||
p.mu.Unlock()
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 43200})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (p *Panel) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("vpc_session"); err == nil {
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, c.Value)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{Name: "vpc_session", Value: "", Path: "/", HttpOnly: true, Secure: secure, MaxAge: -1, SameSite: http.SameSiteStrictMode})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (p *Panel) valid(r *http.Request) bool {
|
||||
c, err := r.Cookie("vpc_session")
|
||||
if err != nil || c.Value == "" {
|
||||
return false
|
||||
}
|
||||
p.mu.RLock()
|
||||
expiry, ok := p.sessions[c.Value]
|
||||
p.mu.RUnlock()
|
||||
return ok && time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
func (p *Panel) protected(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.valid(r) {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Panel) static(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if len(path) < 9 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, err := staticFS.ReadFile("static/" + path[len("/static/"):])
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, ".css") {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
}
|
||||
if len(path) >= 3 && path[len(path)-3:] == ".js" {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (p *Panel) Cleanup() {
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for now := range t.C {
|
||||
p.mu.Lock()
|
||||
for k, v := range p.sessions {
|
||||
if now.After(v) {
|
||||
delete(p.sessions, k)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user