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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testRunner struct{}
|
||||
|
||||
func (testRunner) DevicesJSON(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) AuditsJSON(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) PanelExec(w http.ResponseWriter, r *http.Request) {}
|
||||
func (testRunner) RotateAPIKey(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func TestStaticCSSContentType(t *testing.T) {
|
||||
p := New("test-password", testRunner{})
|
||||
req := httptest.NewRequest("GET", "/static/app.css", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
p.static(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status=%d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/css; charset=utf-8" {
|
||||
t.Fatalf("content-type=%q, want text/css; charset=utf-8", got)
|
||||
}
|
||||
if strings.TrimSpace(rec.Body.String()) == "" {
|
||||
t.Fatal("CSS body is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#0b1020;color:#e7ebf3;font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #202a3d;background:#10182b;position:sticky;top:0;z-index:2}header strong{font-size:18px}header span{margin-left:12px;color:#7f8ba3;font-size:12px}main{max-width:1400px;margin:auto;padding:22px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.wide{grid-column:1/-1}.card{background:#111a2d;border:1px solid #24304a;border-radius:14px;padding:18px;box-shadow:0 8px 30px #0003}.cardhead{display:flex;align-items:center;justify-content:space-between}h2{margin:0 0 14px;font-size:16px}button,select,input,textarea{border:1px solid #34415d;border-radius:9px;background:#0d1526;color:#e7ebf3;padding:9px 12px}button{cursor:pointer}button:hover{background:#17233a}textarea{width:100%;height:110px;resize:vertical;font-family:ui-monospace,monospace;margin:8px 0}pre{white-space:pre-wrap;overflow:auto;background:#090e1a;border-radius:9px;padding:12px;color:#b9c5da}.devices{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.device{padding:14px;border:1px solid #283650;border-radius:10px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;margin-right:7px}.offline .dot{background:#f87171}.muted{color:#7f8ba3}.tablewrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #26324a;font-size:12px}th{color:#91a0bb}.login{display:grid;place-items:center;min-height:100vh}.login form{width:min(380px,90vw);background:#111a2d;border:1px solid #24304a;border-radius:16px;padding:28px}.login input,.login button{width:100%;margin-top:10px}.login h1{margin:0}.login p,#error{color:#8c9ab2}.status{font-size:12px;color:#9aabc5}@media(max-width:800px){.grid{grid-template-columns:1fr}.wide{grid-column:auto}main{padding:12px}header{padding:0 14px}}
|
||||
@@ -0,0 +1,12 @@
|
||||
const $=s=>document.querySelector(s);
|
||||
async function api(path,opt={}){let r=await fetch("/panel/api"+path,opt);if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}if(!r.ok)throw Error(await r.text());return r.json()}
|
||||
function esc(v){return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
async function loadDevices(){let ds=await api("/devices");$("#devices").innerHTML=ds.map(d=>`<div class="device ${d.status==="online"?"":"offline"}"><b><i class="dot"></i>${esc(d.name)}</b><div class="status">${esc(d.status)} · ${d.is_local?"local":"remote"} · ${esc(d.arch||"")}/${esc(d.os||"")}</div></div>`).join("");$("#device").innerHTML=ds.map(d=>`<option>${esc(d.name)}</option>`).join("")}
|
||||
async function loadAudits(){let a=await api("/audits?limit=100");$("#audits").innerHTML=a.map(x=>`<tr><td>${new Date(x.timestamp*1000).toLocaleString()}</td><td>${esc(x.device)}</td><td>${esc(x.action)}</td><td><code>${esc(x.command)}</code></td><td>${x.exit_code}</td><td>${x.duration_ms}ms</td><td>${esc(x.caller_ip)}</td></tr>`).join("")}
|
||||
$("#refresh").onclick=loadDevices;$("#auditRefresh").onclick=loadAudits;
|
||||
$("#run").onclick=async()=>{let o=$("#output");o.textContent="执行中…";try{let x=await api("/exec",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({device:$("#device").value,command:$("#command").value,timeout:30})});o.textContent=`exit_code: ${x.exit_code}\n\nstdout:\n${x.stdout}\n\nstderr:\n${x.stderr}\nduration: ${x.duration_ms}ms`}catch(e){o.textContent=e.message}};
|
||||
$("#copySchema").onclick=()=>navigator.clipboard.writeText(location.origin+"/openapi.json");
|
||||
$("#copyPrompt").onclick=()=>navigator.clipboard.writeText("你是我的私有 VPS 运维助手。执行操作前确认目标设备;优先使用 list_devices 确认节点状态;高风险操作先向用户确认。");
|
||||
$("#rotateKey").onclick=async()=>{if(!confirm("轮换后旧 API Key 会立即失效,确认继续?"))return;try{let x=await api("/key/rotate",{method:"POST"});$("#newKey").textContent="新 API Key(仅本次显示):\n"+x.api_key}catch(e){$("#newKey").textContent=e.message}};
|
||||
$("#logout").onclick=async()=>{await fetch("/panel/logout",{method:"POST"});location.href="/panel/login"};
|
||||
loadDevices().then(loadAudits).catch(console.error);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>VPS-Commander</title><link rel="stylesheet" href="/static/app.css"></head>
|
||||
<body>
|
||||
<header><div><strong>VPS-Commander</strong><span>Private Infrastructure Console</span></div><button id="logout">退出</button></header>
|
||||
<main>
|
||||
<section class="grid">
|
||||
<article class="card wide"><div class="cardhead"><h2>设备看板</h2><button id="refresh">刷新</button></div><div id="devices" class="devices"></div></article>
|
||||
<article class="card"><h2>Actions 向导</h2><p>当前 Hub 的 OpenAPI Schema:</p><button id="copySchema">复制 Schema 地址</button><button id="copyPrompt">复制 System Prompt</button><button id="rotateKey">轮换 API Key</button><pre id="schemaHint">/openapi.json</pre><pre id="newKey"></pre></article>
|
||||
<article class="card wide"><div class="cardhead"><h2>快速控制台</h2><select id="device"></select></div><textarea id="command" placeholder="输入 Shell 命令,例如 uname -a"></textarea><div><button id="run">执行</button></div><pre id="output"></pre></article>
|
||||
<article class="card wide"><div class="cardhead"><h2>审计日志</h2><button id="auditRefresh">刷新</button></div><div class="tablewrap"><table><thead><tr><th>时间</th><th>设备</th><th>动作</th><th>命令/路径</th><th>退出码</th><th>耗时</th><th>来源</th></tr></thead><tbody id="audits"></tbody></table></div></article>
|
||||
</section>
|
||||
</main><script src="/static/app.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VPS-Commander Login</title><link rel="stylesheet" href="/static/app.css"></head><body class="login"><form id="login"><h1>VPS-Commander</h1><p>Private Infrastructure Console</p><input id="password" type="password" placeholder="面板密码" autocomplete="current-password"><button>登录</button><div id="error"></div></form><script>document.querySelector("#login").onsubmit=async e=>{e.preventDefault();let r=await fetch("/panel/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:document.querySelector("#password").value})});if(r.ok)location.href="/";else document.querySelector("#error").textContent="密码错误或未配置面板密码";}</script></body></html>
|
||||
Reference in New Issue
Block a user