diff --git a/src/internal/admin/admin.go b/src/internal/admin/admin.go index f8118ec..b48ed5a 100644 --- a/src/internal/admin/admin.go +++ b/src/internal/admin/admin.go @@ -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) } diff --git a/src/internal/admin/admin_test.go b/src/internal/admin/admin_test.go index 4a06c0a..a438adb 100644 --- a/src/internal/admin/admin_test.go +++ b/src/internal/admin/admin_test.go @@ -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+". +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() +} diff --git a/src/internal/admin/flash.go b/src/internal/admin/flash.go index 447c2af..4e72b5d 100644 --- a/src/internal/admin/flash.go +++ b/src/internal/admin/flash.go @@ -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) { diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index 2714c93..5744721 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -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+" 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)) } diff --git a/src/internal/admin/templates/admin_overview.html b/src/internal/admin/templates/admin_overview.html index 3f8e031..5584b21 100644 --- a/src/internal/admin/templates/admin_overview.html +++ b/src/internal/admin/templates/admin_overview.html @@ -226,6 +226,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{end}} +
@@ -256,6 +257,10 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{if eq .Lang "zh"}}复制{{else}}Copy{{end}} +
@@ -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 diff --git a/src/internal/db/db.go b/src/internal/db/db.go index 2a8f43d..393a1ca 100644 --- a/src/internal/db/db.go +++ b/src/internal/db/db.go @@ -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+". 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. diff --git a/src/internal/db/session.go b/src/internal/db/session.go index bfaf0f3..cd6f805 100644 --- a/src/internal/db/session.go +++ b/src/internal/db/session.go @@ -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+") 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 { diff --git a/src/internal/db/session_test.go b/src/internal/db/session_test.go new file mode 100644 index 0000000..a6e42d8 --- /dev/null +++ b/src/internal/db/session_test.go @@ -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) + } +} diff --git a/src/internal/panel/handlers.go b/src/internal/panel/handlers.go index 04368ce..2e409a5 100644 --- a/src/internal/panel/handlers.go +++ b/src/internal/panel/handlers.go @@ -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+" 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+" 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, "") } diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index e7f0999..d6679eb 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -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) } diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index e605031..a68bc46 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -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+" (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) + } + } +} diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index 3c80dbf..77c4fe5 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -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} {{if .Err}}
{{.Err}}
{{end}} +{{if .Impersonated}} +
+{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}} +{{if eq .Lang "zh"}}当前会话由管理员以你的身份建立。{{else}}This session was opened by the administrator acting as you.{{end}} +{{if eq .Lang "zh"}}返回管理员面板{{else}}Back to admin panel{{end}} +
+{{end}}

{{if eq .Lang "zh"}}概览{{else}}Overview{{end}} · {{.User.Name}}