feat(admin): log in as a user (impersonation)

- Admin /login-as: create an impersonated user session, set the user-panel
  cookie, redirect into that user's panel regardless of password
- Session DB migration: sessions.impersonated flag to distinguish operator
  logins; user panel shows a 'logged in as' banner + return link
- Audit: impersonated-session actions are attributed '000+<user>' via an
  auditActor helper (all 12 panel call sites)
- Audit coverage: add user/admin login, init-script save, ipv6 pool add/del
- Admin UI: 'login panel' button per user row and 'log in as' in the
  post-create modal (flash now carries opaque data)
This commit is contained in:
bluebluesoda committed 2026-08-24 09:19:58 +00:00
1 parent 5dd172ddcb
commit a0ae53204d
12 files changed
+478 -39

No files matched your search

+10
View File
@@ -77,6 +77,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.handleResetPanelPass)))
mux.HandleFunc("/admin-pass", s.requireAuth(s.requirePost(s.handleAdminPass)))
mux.HandleFunc("/keys", s.requireAuth(s.requirePost(s.handleAdminKeys)))
mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.handleLoginAs)))
mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash)))
prefix := s.prefix()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -258,6 +259,15 @@ func (s *Server) redirectModal(w http.ResponseWriter, r *http.Request, path, msg
http.Redirect(w, r, path, http.StatusFound)
}
// redirectModalData is redirectModal plus an opaque data payload attached to
// the flash (used for the "log in as" button after creating a user).
func (s *Server) redirectModalData(w http.ResponseWriter, r *http.Request, path, msg, data string) {
if c, err := r.Cookie("vpsmgr_admin_session"); err == nil {
s.flash.SetWithData(c.Value, msg, "user_created", data)
}
http.Redirect(w, r, path, http.StatusFound)
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
s.renderStatus(w, r, http.StatusOK, name, data)
}
+143 -1
View File
@@ -425,7 +425,7 @@ func TestAuditPageAndAPI(t *testing.T) {
t.Fatalf("GET /audit/api = %d", rr.Code)
}
b := rr.Body.String()
for _, want := range []string{`"actor":"000+alice"`, `"action":"power.stop"`, `"total":2`, `"more":false`} {
for _, want := range []string{`"actor":"000+alice"`, `"action":"power.stop"`, `"total":3`, `"more":false`, `"actor":"000"`, `"action":"session.login"`} {
if !strings.Contains(b, want) {
t.Errorf("audit api missing %s:\n%s", want, b)
}
@@ -690,3 +690,145 @@ func TestAdminKeysSave(t *testing.T) {
t.Error("admin key rows should no longer render an active checkbox")
}
}
// TestLoginAs covers "log in as user": the admin POSTs /login-as and gets a
// redirect to the user panel with a fresh, impersonated user-panel session
// cookie, and the event is audited "000+<user>".
func TestLoginAs(t *testing.T) {
srv, d := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
userPrefix := "/UserSecRet99" // cfg.Panel.URLPath in newTestServer
if _, err := d.CreateUser("alice", "x", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
rr := doReq(t, h, http.MethodPost, prefix+"/login-as", url.Values{"name": {"alice"}}, cookie)
if rr.Code != http.StatusFound {
t.Fatalf("login-as = %d, want 302 (body %s)", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != userPrefix+"/" {
t.Fatalf("Location = %q, want %q", loc, userPrefix+"/")
}
var uc *http.Cookie
for _, c := range rr.Result().Cookies() {
if c.Name == "vpsmgr_session" {
v := *c
uc = &v
}
}
if uc == nil {
t.Fatal("no user-panel session cookie set")
}
if uc.Path != userPrefix {
t.Errorf("cookie Path = %q, want %q", uc.Path, userPrefix)
}
u, imp, err := d.SessionWithFlag(uc.Value)
if err != nil {
t.Fatalf("session lookup: %v", err)
}
if u.Name != "alice" || !imp {
t.Errorf("session = user %q imp=%v, want alice imp=true", u.Name, imp)
}
rows, _ := d.ListAuditLog(0, 10)
var found bool
for _, r := range rows {
if r.Actor == "000+alice" && r.Action == "session.login" {
found = true
}
}
if !found {
t.Errorf("audit rows = %+v, want 000+alice session.login", rows)
}
}
// TestLoginAsRequiresAdminAuth ensures /login-as is gated on an admin session.
func TestLoginAsRequiresAdminAuth(t *testing.T) {
srv, _ := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
rr := doReq(t, h, http.MethodPost, prefix+"/login-as", url.Values{"name": {"alice"}}, nil)
if rr.Code != http.StatusFound || rr.Header().Get("Location") != prefix+"/login" {
t.Fatalf("unauthenticated login-as = %d (loc %q), want redirect to login", rr.Code, rr.Header().Get("Location"))
}
}
// TestFlashCarriesData verifies the post-create flash delivers the username as
// opaque data so the modal can offer "log in as this user".
func TestFlashCarriesData(t *testing.T) {
srv, _ := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
srv.flash.SetWithData(cookie.Value, "user: alice\npassword: xyz", "user_created", "alice")
rr := doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("/flash = %d", rr.Code)
}
b := rr.Body.String()
for _, want := range []string{
`"kind":"user_created"`,
`"data":"alice"`,
"\"msg\":\"user: alice\\npassword: xyz\"",
} {
if !strings.Contains(b, want) {
t.Errorf("flash missing %s:\n%s", want, b)
}
}
}
// TestAdminLoginWritesAudit verifies admin login is audited as "000".
func TestAdminLoginWritesAudit(t *testing.T) {
srv, d := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
adminLogin(t, h, prefix, "correct-horse-battery")
rows, _ := d.ListAuditLog(0, 10)
var found bool
for _, r := range rows {
if r.Actor == "000" && r.Action == "session.login" {
found = true
}
}
if !found {
t.Errorf("audit rows = %+v, want 000 session.login", rows)
}
}
// TestAdminOverviewLoginButtons checks the admin overview renders both the
// per-user "login panel" button and the modal's "log in as this user" form.
func TestAdminOverviewLoginButtons(t *testing.T) {
srv, _ := newTestServer(t)
html := srv.renderToString(t, "admin_overview.html", pageData{
Prefix: "/" + testAdminSecret,
Lang: langZh,
Users: []userView{{Name: "alice", State: "Running", Status: "ready", SSHPort: "30001"}},
})
for _, want := range []string{
"login-as", "登录面板", `name="name" value="alice"`,
`id="loginAsForm"`, `id="loginAsName"`,
} {
if !strings.Contains(html, want) {
t.Errorf("admin overview missing %q", want)
}
}
}
// renderToString executes a named admin template into a string for assertions.
func (s *Server) renderToString(t *testing.T, name string, data pageData) string {
t.Helper()
tpl, err := s.templates()
if err != nil {
t.Fatal(err)
}
var b strings.Builder
if err := tpl.ExecuteTemplate(&b, name, data); err != nil {
t.Fatal(err)
}
return b.String()
}
+11 -4
View File
@@ -8,6 +8,7 @@ import (
type flashEntry struct {
msg string
kind string
data string
ts time.Time
}
@@ -26,6 +27,12 @@ const (
)
func (f *flashStore) Set(token, msg, kind string) {
f.SetWithData(token, msg, kind, "")
}
// SetWithData stores a banner plus an optional opaque data payload (e.g. the
// username of a freshly created user, so the modal can offer "log in as").
func (f *flashStore) SetWithData(token, msg, kind, data string) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.items) >= flashMaxItems {
@@ -36,18 +43,18 @@ func (f *flashStore) Set(token, msg, kind string) {
}
}
}
f.items[token] = &flashEntry{msg: msg, kind: kind, ts: time.Now()}
f.items[token] = &flashEntry{msg: msg, kind: kind, data: data, ts: time.Now()}
}
func (f *flashStore) Pop(token string) (msg, kind string, ok bool) {
func (f *flashStore) Pop(token string) (msg, kind, data string, ok bool) {
f.mu.Lock()
defer f.mu.Unlock()
e, ok := f.items[token]
if !ok {
return "", "toast", false
return "", "toast", "", false
}
delete(f.items, token)
return e.msg, e.kind, true
return e.msg, e.kind, e.data, true
}
func (f *flashStore) Clear(token string) {
+48 -3
View File
@@ -277,6 +277,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.setSessionCookie(w, token)
_ = s.db.AddAuditLog("000", "session.login")
s.redirect(w, r, s.p(""), "")
return
}
@@ -306,12 +307,13 @@ func (s *Server) handleFlash(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusUnauthorized)
return
}
msg, kind, _ := s.flash.Pop(c.Value)
msg, kind, data, _ := s.flash.Pop(c.Value)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Msg string `json:"msg"`
Kind string `json:"kind"`
}{msg, kind})
Data string `json:"data"`
}{msg, kind, data})
}
// handleUserAdd creates a user with the CLI's Add logic and shows the full
@@ -358,7 +360,8 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
cred := "user: " + res.User.Name +
"\npassword: " + res.Password +
"\npanel: " + s.cfg.PanelURL("/"+s.cfg.Panel.URLPath)
s.redirectModal(w, r, s.p(""), cred)
// Carry the username as flash data so the modal can offer "log in as".
s.redirectModalData(w, r, s.p(""), cred, res.User.Name)
}
func (s *Server) handleUserDel(w http.ResponseWriter, r *http.Request) {
@@ -497,6 +500,46 @@ func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed"))
}
// handleLoginAs ("log in as user" / impersonation) creates a user-panel
// session for the given username and hands the browser its cookie, dropping the
// operator straight into that user's panel — regardless of the user's password.
// The admin's own session cookie is separate and untouched, so returning to the
// admin panel works normally; impersonating another user later simply replaces
// the user-panel cookie. The session is flagged impersonated, so the user
// panel attributes its audit events "000+<user>" and shows a banner.
func (s *Server) handleLoginAs(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := strings.TrimSpace(r.FormValue("name"))
u, err := s.db.GetUserByName(name)
if err != nil {
s.redirect(w, r, s.p(""), "error: user not found")
return
}
sess, err := s.db.CreateImpersonatedSession(u.ID, s.cfg.Panel.SessionDays)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
// Set the user panel's own session cookie (same name, path and flags as a
// real login) so the browser becomes that user. Only the user-panel cookie
// is written here; the admin cookie is left alone.
userPrefix := "/" + s.cfg.Panel.URLPath
http.SetCookie(w, &http.Cookie{
Name: "vpsmgr_session",
Value: sess.Token,
Path: userPrefix,
MaxAge: s.cfg.Panel.SessionDays * 86400,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
_ = s.db.AddAuditLog("000+"+u.Name, "session.login")
http.Redirect(w, r, userPrefix+"/", http.StatusFound)
}
// parseMem parses a memory string ("512" or "1G") into MiB, mirroring the CLI.
func parseMem(s string) (int, error) {
s = strings.TrimSpace(s)
@@ -750,6 +793,7 @@ func (s *Server) handleIPv6PoolAdd(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p("/ipv6pool"), "ok: no new addresses")
return
}
_ = s.db.AddAuditLog("000", "ipv6pool.add")
s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_added", len(added)))
}
@@ -764,6 +808,7 @@ func (s *Server) handleIPv6PoolDel(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog("000", "ipv6pool.del")
s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_removed", addr))
}
@@ -226,6 +226,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="start"><button class="ok">{{if eq $.Lang "zh"}}启动{{else}}Start{{end}}</button></form>
{{end}}
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}')">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
<form action="{{$.Prefix}}/login-as" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button class="primary" title="{{if eq $.Lang "zh"}}以该用户身份登录用户面板{{else}}log in to this user's panel{{end}}">{{if eq $.Lang "zh"}}登录面板{{else}}Login{{end}}</button></form>
<form action="{{$.Prefix}}/reset-panel-pass" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button>{{if eq $.Lang "zh"}}重置密码{{else}}Reset pw{{end}}</button></form>
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
</div>
@@ -256,6 +257,10 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<div class="body" id="modalBody"></div>
<div class="foot">
<a class="copy" id="modalCopy" onclick="copyModal()">{{if eq .Lang "zh"}}复制{{else}}Copy{{end}}</a>
<form action="{{.Prefix}}/login-as" method="post" id="loginAsForm" class="inline" hidden>
<input type="hidden" name="name" id="loginAsName">
<button class="primary" type="submit">{{if eq .Lang "zh"}}以此身份登录{{else}}Log in as this user{{end}}</button>
</form>
<button onclick="closeModal()">{{if eq .Lang "zh"}}关闭{{else}}Close{{end}}</button>
</div>
</div>
@@ -316,7 +321,10 @@ var COPY_OK = {{if eq .Lang "zh"}}"已复制 ✓"{{else}}"Copied ✓"{{end}};
var COPY_LBL = {{if eq .Lang "zh"}}"复制"{{else}}"Copy"{{end}};
var CREATE_ING = {{if eq .Lang "zh"}}"创建中…"{{else}}"Creating…"{{end}};
function openModal(msg){ document.getElementById('modalBody').textContent = msg; document.getElementById('modal').classList.add('open'); }
function closeModal(){ document.getElementById('modal').classList.remove('open'); }
function closeModal(){ document.getElementById('modal').classList.remove('open'); hideLoginAs(); }
// The "log in as this user" button appears only in the post-create modal.
function showLoginAs(name){ var f = document.getElementById('loginAsForm'); if(!f) return; document.getElementById('loginAsName').value = name || ''; f.hidden = !name; }
function hideLoginAs(){ var f = document.getElementById('loginAsForm'); if(f) f.hidden = true; }
function copyModal(){
var b = document.getElementById('modalBody');
var done = function(){
@@ -458,7 +466,11 @@ function del(name){
})();
// consume flash banner once
fetch(P + '/flash', {method:'POST'}).then(function(r){return r.json();}).then(function(j){
if (j.msg) { if (j.kind === 'modal') openModal(j.msg); else alert(j.msg); }
if (j.msg) {
if (j.kind === 'modal') { openModal(j.msg); }
else if (j.kind === 'user_created') { openModal(j.msg); showLoginAs(j.data); }
else { alert(j.msg); }
}
});
// Admin SSH-key management panel: floating modal, no page switch. Same
// full-set save contract as the user panel (id > 0 updates, 0 adds, missing
+8 -1
View File
@@ -55,7 +55,7 @@ func (d *DB) Close() error { return d.sql.Close() }
// schemaVersion is the current schema version. Every migration in
// migrations must be applied in order; Open refuses to start on a database
// whose version is newer than this binary understands (downgrade protection).
const schemaVersion = 10
const schemaVersion = 11
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -221,6 +221,13 @@ var migrations = []struct {
)`,
`CREATE INDEX IF NOT EXISTS idx_admin_key_grants_user ON admin_key_grants(user_id)`,
}},
// v11: mark user sessions created by the operator ("log in as user"). Lets
// the user panel tell an admin impersonation apart from the user's own
// login, so audit events can be attributed "000+<user>". Existing rows are
// normal (0).
{11, []string{
`ALTER TABLE sessions ADD COLUMN impersonated INTEGER NOT NULL DEFAULT 0`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
+32 -7
View File
@@ -24,6 +24,19 @@ func hashToken(token string) string {
}
func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
return d.createSession(userID, days, false)
}
// CreateImpersonatedSession creates a user session on behalf of the operator
// ("log in as user"). It is indistinguishable from a real login to the user
// panel except for the impersonated flag, which the panel uses to attribute
// audit events to the operator ("000+<user>") and to show a "logged in as"
// banner.
func (d *DB) CreateImpersonatedSession(userID int64, days int) (*Session, error) {
return d.createSession(userID, days, true)
}
func (d *DB) createSession(userID int64, days int, impersonated bool) (*Session, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
@@ -33,8 +46,8 @@ func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
token := hex.EncodeToString(b)
s := &Session{Token: token, UserID: userID,
ExpiresAt: time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix()}
_, err := d.sql.Exec(`INSERT INTO sessions(token, user_id, expires_at) VALUES(?,?,?)`,
hashToken(token), s.UserID, s.ExpiresAt)
_, err := d.sql.Exec(`INSERT INTO sessions(token, user_id, expires_at, impersonated) VALUES(?,?,?,?)`,
hashToken(token), s.UserID, s.ExpiresAt, b2i(impersonated))
if err != nil {
return nil, err
}
@@ -43,18 +56,30 @@ func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
// SessionUser returns the user for a valid (unexpired) session token.
func (d *DB) SessionUser(token string) (*User, error) {
u, _, err := d.SessionWithFlag(token)
return u, err
}
// SessionWithFlag returns the user for a valid session token plus whether the
// session was created by the operator (impersonation).
func (d *DB) SessionWithFlag(token string) (*User, bool, error) {
var userID int64
var expiresAt int64
err := d.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, hashToken(token)).
Scan(&userID, &expiresAt)
var imp int
err := d.sql.QueryRow(`SELECT user_id, expires_at, impersonated FROM sessions WHERE token=?`, hashToken(token)).
Scan(&userID, &expiresAt, &imp)
if err != nil {
return nil, err
return nil, false, err
}
if time.Now().Unix() > expiresAt {
d.DeleteSession(token)
return nil, errors.New("session expired")
return nil, false, errors.New("session expired")
}
return d.GetUserByID(userID)
u, err := d.GetUserByID(userID)
if err != nil {
return nil, false, err
}
return u, imp != 0, nil
}
func (d *DB) DeleteSession(token string) error {
+52
View File
@@ -0,0 +1,52 @@
package db
import (
"path/filepath"
"testing"
)
func TestImpersonatedSession(t *testing.T) {
d, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
u, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
// A normal login session is not impersonated.
ns, err := d.CreateSession(u.ID, 3)
if err != nil {
t.Fatal(err)
}
gu, imp, err := d.SessionWithFlag(ns.Token)
if err != nil {
t.Fatal(err)
}
if gu.ID != u.ID || imp {
t.Errorf("normal session: user=%d imp=%v, want user=%d imp=false", gu.ID, imp, u.ID)
}
// An operator "log in as user" session carries the impersonated flag.
is, err := d.CreateImpersonatedSession(u.ID, 3)
if err != nil {
t.Fatal(err)
}
gu, imp, err = d.SessionWithFlag(is.Token)
if err != nil {
t.Fatal(err)
}
if gu.ID != u.ID || !imp {
t.Errorf("impersonated session: user=%d imp=%v, want imp=true", gu.ID, imp)
}
// The compat accessor still resolves the user either way.
if _, err := d.SessionUser(ns.Token); err != nil {
t.Errorf("SessionUser(normal): %v", err)
}
if _, err := d.SessionUser(is.Token); err != nil {
t.Errorf("SessionUser(impersonated): %v", err)
}
}
+46 -16
View File
@@ -18,7 +18,13 @@ func itoa(n int) string { return strconv.Itoa(n) }
type ctxKey int
const userKey ctxKey = 0
const (
userKey ctxKey = 0
// impersonatedKey marks a session created by the operator ("log in as
// user"). When set, audit events are attributed "000+<user>" and the panel
// shows a "logged in as" banner.
impersonatedKey ctxKey = 1
)
// loginDummyHash is compared against when the username is unknown so that a
// login attempt takes the same time whether or not the account exists,
@@ -60,16 +66,35 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
s.redirect(w, r, s.p("/login"), "")
return
}
u, err := s.db.SessionUser(c.Value)
u, imp, err := s.db.SessionWithFlag(c.Value)
if err != nil {
s.clearSessionCookie(w)
s.redirect(w, r, s.p("/login"), "")
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
ctx := context.WithValue(r.Context(), userKey, u)
ctx = context.WithValue(ctx, impersonatedKey, imp)
next(w, r.WithContext(ctx))
}
}
// isImpersonated reports whether the current session was created by the
// operator ("log in as user").
func (s *Server) isImpersonated(r *http.Request) bool {
v, _ := r.Context().Value(impersonatedKey).(bool)
return v
}
// auditActor returns the actor name for an audit event: the plain username for
// a user's own action, or "000+<username>" when the operator is acting as that
// user (impersonation). Mirrors the admin panel's "000+" convention.
func (s *Server) auditActor(r *http.Request, name string) string {
if s.isImpersonated(r) {
return "000+" + name
}
return name
}
// requirePost rejects everything but POST so no state-changing action can be
// triggered by a top-level GET navigation (CSRF via SameSite=Lax). A wrong
// method gets the same bare 404 as any other wrong path — never a 405, which
@@ -146,6 +171,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
sess, err := s.db.CreateSession(u.ID, s.cfg.Panel.SessionDays)
if err == nil {
s.setSessionCookie(w, sess.Token)
_ = s.db.AddAuditLog(u.Name, "session.login")
s.redirect(w, r, s.p(""), "")
return
}
@@ -166,7 +192,10 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
s.render(w, r, "overview.html", s.buildData(u, "", ""))
d := s.buildData(u, "", "")
d.Impersonated = s.isImpersonated(r)
d.AdminPrefix = "/" + s.cfg.Panel.AdminPath
s.render(w, r, "overview.html", d)
}
func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) {
@@ -180,7 +209,7 @@ func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.Power(u.Name, action); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(u.Name, "power."+action)
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "power."+action)
msg = "ok: " + action
}
s.redirect(w, r, s.p(""), msg)
@@ -201,7 +230,7 @@ func (s *Server) handleReinstall(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(u.Name, "reinstall")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reinstall")
s.redirectModal(w, r, s.p(""), s.t(r, "reinstall_done", pass))
}
@@ -231,7 +260,7 @@ func (s *Server) handlePanelPassword(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(u.Name, "passwd.change")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "passwd.change")
s.redirect(w, r, s.p(""), "ok: panel password changed")
}
@@ -244,7 +273,7 @@ func (s *Server) handleRootReset(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(u.Name, "reset_root_password")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reset_root_password")
s.redirectModal(w, r, s.p(""), s.t(r, "new_root_password", pass))
}
@@ -259,7 +288,7 @@ func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(u.Name, "domain_update")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
s.redirect(w, r, s.p(""), "ok: domain added")
}
@@ -296,7 +325,7 @@ func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "ok: no changes")
return
}
_ = s.db.AddAuditLog(u.Name, "domain_update")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
s.redirect(w, r, s.p(""), "ok: domain settings saved")
}
@@ -310,7 +339,7 @@ func (s *Server) handleDomainDel(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(u.Name, "domain_update")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
s.redirect(w, r, s.p(""), "ok: domain removed")
}
@@ -347,6 +376,7 @@ func (s *Server) handleInitScript(w http.ResponseWriter, r *http.Request) {
}
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "init_script")
if jsonReq {
writeInitScriptJSON(w, true, "")
} else {
@@ -404,7 +434,7 @@ func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.SnapshotCreate(u.Name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(u.Name, "snapshot.create")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.create")
msg = "ok: snapshot created"
}
s.redirect(w, r, s.p(""), msg)
@@ -422,7 +452,7 @@ func (s *Server) handleSnapshotDel(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.SnapshotDelete(u.Name, name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(u.Name, "snapshot.delete")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.delete")
msg = "ok: snapshot deleted"
}
s.redirect(w, r, s.p(""), msg)
@@ -441,7 +471,7 @@ func (s *Server) handleSnapshotRestore(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.SnapshotRestore(u.Name, name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(u.Name, "snapshot.restore")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.restore")
msg = "ok: snapshot restored"
}
s.redirect(w, r, s.p(""), msg)
@@ -473,7 +503,7 @@ func (s *Server) handleSSHKeys(w http.ResponseWriter, r *http.Request) {
writeSSHKeys(w, false, err.Error(), "", nil, nil)
return
}
_ = s.db.AddAuditLog(u.Name, "ssh_keys")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "ssh_keys")
// Apply the selection immediately. Admin keys are fully synced (activated
// ones written with the AdminKeyMarker, stale/unactivated ones removed), so
// this runs even when nothing is active — deactivating an admin key must
@@ -550,7 +580,7 @@ func (s *Server) handleNotesReset(w http.ResponseWriter, r *http.Request) {
writeNotesJSON(w, false, "storage error", true, "")
return
}
_ = s.db.AddAuditLog(u.Name, "notes_reset")
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "notes_reset")
writeNotesJSON(w, true, "", false, "")
}
+2
View File
@@ -160,6 +160,8 @@ type pageData struct {
SnapshotLimit int // configured per-container snapshot cap (for display)
SSHKeys []sshKeyRow
AdminSSHKeys []sshKeyRow // operator's own public keys, shown read-only
Impersonated bool // operator "log in as user": show a banner
AdminPrefix string // admin panel path, for the banner's return link
MaxNotesPlaintext int // sticky-notes plaintext byte cap (client-side check)
}
+101 -5
View File
@@ -797,8 +797,16 @@ func TestDomainAddLogsAudit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Actor != "alice" || rows[0].Action != "domain_update" {
t.Errorf("audit rows = %+v", rows)
// The login also writes a session.login row; assert the domain_update row
// is present (and attributed to the acting user).
var found bool
for _, r := range rows {
if r.Actor == "alice" && r.Action == "domain_update" {
found = true
}
}
if !found {
t.Errorf("audit rows = %+v, want a domain_update row for alice", rows)
}
}
@@ -890,13 +898,16 @@ func TestSnapshotRoutesRegistered(t *testing.T) {
t.Fatalf("POST /snapshot-restore = %d, want 302", rr.Code)
}
// No audit rows: all snapshot calls failed before any successful action.
// No audit rows for the snapshot ops: every snapshot call failed before
// any successful action (the login row is expected).
rows, err := d.ListAuditLog(0, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Errorf("audit rows = %+v, want none (all snapshot ops failed)", rows)
for _, r := range rows {
if strings.HasPrefix(r.Action, "snapshot.") {
t.Errorf("failed snapshot op wrote an audit row: %+v", r)
}
}
}
@@ -1427,3 +1438,88 @@ func jsonQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// TestUserLoginWritesAudit verifies a successful user login records a
// session.login audit event (newly added audit coverage).
func TestUserLoginWritesAudit(t *testing.T) {
srv, d := newTestServer(t)
h := srv.Handler()
prefix := "/" + testSecret
hash, _ := pw.Hash("pw")
if _, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
t.Fatal(err)
}
rr := doReq(t, h, http.MethodPost, prefix+"/login",
url.Values{"username": {"alice"}, "password": {"pw"}}, nil)
if rr.Code != http.StatusFound {
t.Fatalf("login = %d, want 302", rr.Code)
}
rows, _ := d.ListAuditLog(0, 10)
var found bool
for _, r := range rows {
if r.Actor == "alice" && r.Action == "session.login" {
found = true
}
}
if !found {
t.Errorf("audit rows = %+v, want alice session.login", rows)
}
}
// TestImpersonatedSessionAuditActor verifies that a user-panel action carried
// out under an operator-created session is attributed "000+<user>" (not the
// user's own name).
func TestImpersonatedSessionAuditActor(t *testing.T) {
srv, d := newTestServer(t)
h := srv.Handler()
prefix := "/" + testSecret
hash, _ := pw.Hash("pw")
u, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
sess, err := d.CreateImpersonatedSession(u.ID, 3)
if err != nil {
t.Fatal(err)
}
cookie := &http.Cookie{Name: "vpsmgr_session", Value: sess.Token, Path: prefix}
rr := postJSON(t, h, prefix+"/init-script", `{"script":"#!/bin/bash\necho hi"}`, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("init-script save = %d, want 200", rr.Code)
}
rows, _ := d.ListAuditLog(0, 10)
var found bool
for _, r := range rows {
if r.Action == "init_script" && r.Actor == "000+alice" {
found = true
}
}
if !found {
t.Errorf("audit rows = %+v, want init_script attributed 000+alice", rows)
}
}
// TestOverviewImpersonationBanner verifies the "logged in as" banner renders
// only when the session is impersonated, with a link back to the admin panel.
func TestOverviewImpersonationBanner(t *testing.T) {
srv, _ := newTestServer(t)
html := srv.renderToString(t, "overview.html", pageData{
User: &db.User{Name: "alice"}, Prefix: "/" + testSecret,
Impersonated: true, AdminPrefix: "/Adm1n", Lang: langZh,
})
for _, want := range []string{"管理员代客登录中", "返回管理员面板", "/Adm1n"} {
if !strings.Contains(html, want) {
t.Errorf("impersonated overview missing %q", want)
}
}
plain := srv.renderToString(t, "overview.html", pageData{
User: &db.User{Name: "alice"}, Prefix: "/" + testSecret,
})
for _, absent := range []string{`class="impbanner"`, "管理员代客登录中"} {
if strings.Contains(plain, absent) {
t.Errorf("normal overview should not render %q", absent)
}
}
}
@@ -68,6 +68,10 @@ input.bad{border-color:var(--danger);outline:1px solid var(--danger)}
.frow label{flex:0 0 4.2rem}
label{font-size:.84rem;color:var(--muted)}
.err{background:var(--err-bg);color:var(--err-fg);border:1px solid var(--danger);padding:.5rem .7rem;border-radius:8px;margin-bottom:.8rem;font-size:.87rem;word-break:break-all}
.impbanner{background:var(--violet-bg);color:var(--violet-fg);border:1px solid var(--violet-border);padding:.55rem .8rem;border-radius:8px;margin-bottom:.8rem;font-size:.87rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.impbanner b{color:var(--violet-fg)}
.impbanner .impback{margin-left:auto}
.impbanner a{color:var(--accent);text-decoration:underline;font-weight:600}
a{color:var(--accent);text-decoration:none}
.domtable{width:100%;border-collapse:collapse;font-size:.85rem;margin:.2rem 0 .6rem}
.domtable th,.domtable td{text-align:left;padding:.3rem .4rem;border-bottom:1px solid var(--border);vertical-align:middle}
@@ -161,6 +165,13 @@ footer a{color:var(--muted);text-decoration:underline}
</div>
</div>
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
{{if .Impersonated}}
<div class="impbanner">
<b>{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}}</b>
<span class="muted" style="color:inherit">{{if eq .Lang "zh"}}当前会话由管理员以你的身份建立。{{else}}This session was opened by the administrator acting as you.{{end}}</span>
<span class="impback"><a href="{{.AdminPrefix}}">{{if eq .Lang "zh"}}返回管理员面板{{else}}Back to admin panel{{end}}</a></span>
</div>
{{end}}
<div class="card">
<h2>{{if eq .Lang "zh"}}概览{{else}}Overview{{end}}<span> · {{.User.Name}}</span></h2>