feat(panel): add grouped multi-container users
- group sibling users by the username-number convention - synchronize group credentials, colors, and session rotation - add group switching and destructive-action warnings - keep group updates transactional and validate names server-side Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
1 parent
6856e510a4
commit
e6bf390da0
10 files changed
+433
-28
No files matched your search
@@ -366,7 +366,11 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
if err := s.mgr.ValidateAddName(name, true); err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
cpu, err := mgr.ParseCPU(r.FormValue("cpu"))
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
@@ -393,7 +397,7 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if ipv6 == "auto" {
|
||||
ipv6 = ""
|
||||
}
|
||||
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6})
|
||||
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true})
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
@@ -402,9 +406,13 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
|
||||
// The password is always auto-generated and shown once. The panel address
|
||||
// is taken from the request's own Host (see panelURL), so it matches
|
||||
// whatever origin the operator actually used to reach the admin panel.
|
||||
cred := "user: " + res.User.Name +
|
||||
"\npassword: " + res.Password +
|
||||
"\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath)
|
||||
cred := "user: " + res.User.Name
|
||||
if res.Password != "" {
|
||||
cred += "\npassword: " + res.Password
|
||||
} else {
|
||||
cred += "\npassword: same as the existing user-group password"
|
||||
}
|
||||
cred += "\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath)
|
||||
// Carry the username as flash data so the modal can offer "log in as".
|
||||
s.redirectModalData(w, r, s.p(""), cred, res.User.Name)
|
||||
}
|
||||
@@ -499,7 +507,7 @@ func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: user not found")
|
||||
return
|
||||
}
|
||||
if err := s.db.UpdateUserColor(u.ID, color); err != nil {
|
||||
if err := s.mgr.SetGroupColor(u.Name, color); err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
<h2>{{if eq .Lang "zh"}}创建用户{{else}}Create user{{end}}</h2>
|
||||
<div class="plans" id="plans"></div>
|
||||
<form action="{{.Prefix}}/user-add" method="post" class="form-grid" id="createForm">
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}用户名{{else}}Username{{end}}</label><input name="name" id="cfName" pattern="[a-z]([a-z0-9-]*[a-z])?" maxlength="31" autocomplete="off" required title="{{if eq .Lang "zh"}}小写字母开头和结尾,仅含小写字母、数字和连字符{{else}}start/end with a lowercase letter; lowercase letters, digits, hyphens only{{end}}"><small id="cfNameHint" style="color:#6b7280;display:block;margin-top:4px;min-height:1em">{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}</small></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}用户名{{else}}Username{{end}}</label><input name="name" id="cfName" pattern="[a-z]([a-z0-9-]*[a-z0-9])?" maxlength="31" autocomplete="off" required title="{{if eq .Lang "zh"}}小写字母开头和结尾,仅含小写字母、数字和连字符{{else}}start/end with a lowercase letter; lowercase letters, digits, hyphens only{{end}}"><small id="cfNameHint" style="color:#6b7280;display:block;margin-top:4px;min-height:1em">{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}</small></div>
|
||||
<div class="field"><label>CPU</label>
|
||||
<div class="cpu-row"><input name="cpu" id="cfCpu" type="number" min="0.1" step="any" value="1" required title="{{if eq .Lang "zh"}}整数核心(≥1)或 0.1~0.9 小数(单核的时间片占比){{else}}whole cores (>= 1) or a decimal 0.1..0.9 (a fraction of one core){{end}}"><span class="step"><button type="button" onclick="stepCpu('cfCpu',1)" title="+">▲</button><button type="button" onclick="stepCpu('cfCpu',-1)" title="−">▼</button></span></div>
|
||||
</div>
|
||||
@@ -580,7 +580,7 @@ function del(name){
|
||||
var cpu = document.getElementById('cfCpu'), mem = document.getElementById('cfMem'), disk = document.getElementById('cfDisk'), bandwidth = document.getElementById('cfBandwidth'), btn = document.getElementById('cfBtn');
|
||||
var nameIn = document.getElementById('cfName'), nameHint = document.getElementById('cfNameHint');
|
||||
var existing = { {{range .Users}}"{{.Name}}":1, {{end}}};
|
||||
var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', dup:'该用户名已存在', invalid:'小写字母开头和结尾,仅含小写字母、数字和连字符', hint:'自动转为小写'}{{else}}{ok:'Username available', dup:'Username already exists', invalid:'start/end with a lowercase letter; lowercase letters, digits, hyphens only', hint:'Folded to lowercase'}{{end}};
|
||||
var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', group:'将创建用户组:', groupAfter:'(已有 ', groupEnd:' 个账号,创建后 ', accounts:' 个)', dup:'该用户名已存在', invalid:'主用户必须以字母结尾;子用户格式为 主用户-数字,且不允许嵌套', hint:'自动转为小写'}{{else}}{ok:'Username available', group:'User group to create: ', groupAfter:' (', groupEnd:' accounts, ', accounts:' after creation)', dup:'Username already exists', invalid:'parent must end with a letter; child format is parent-number, nesting is not allowed', hint:'Folded to lowercase'}{{end}};
|
||||
function nameStatus(){
|
||||
var raw = nameIn.value;
|
||||
// Fold any uppercase to lowercase as the user types.
|
||||
@@ -589,18 +589,27 @@ function del(name){
|
||||
nameIn.setCustomValidity('');
|
||||
nameHint.classList.remove('hint-ok', 'hint-bad');
|
||||
if (!low) { nameHint.textContent = MSG.hint; nameIn.classList.remove('bad'); return; }
|
||||
var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low);
|
||||
if (!okRe) {
|
||||
nameIn.classList.add('bad');
|
||||
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid;
|
||||
nameIn.setCustomValidity(MSG.invalid);
|
||||
var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
|
||||
var parent = child ? child[1] : '';
|
||||
var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low) || !!child;
|
||||
if (!okRe || (child && /^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/.test(parent))) {
|
||||
nameIn.classList.add('bad');
|
||||
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid;
|
||||
nameIn.setCustomValidity(MSG.invalid);
|
||||
} else if (existing[low]) {
|
||||
nameIn.classList.add('bad');
|
||||
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup;
|
||||
nameIn.setCustomValidity(MSG.dup);
|
||||
nameIn.classList.add('bad');
|
||||
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup;
|
||||
nameIn.setCustomValidity(MSG.dup);
|
||||
} else {
|
||||
nameIn.classList.remove('bad');
|
||||
nameHint.classList.add('hint-ok'); nameHint.textContent = MSG.ok;
|
||||
nameIn.classList.remove('bad');
|
||||
var count = 0;
|
||||
var group = child ? parent : low;
|
||||
Object.keys(existing).forEach(function(n){
|
||||
var m = n.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
|
||||
if (n === group || (m && m[1] === group)) count++;
|
||||
});
|
||||
nameHint.classList.add('hint-ok');
|
||||
nameHint.textContent = child ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : (count ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : MSG.ok);
|
||||
}
|
||||
}
|
||||
if (nameIn) nameIn.addEventListener('input', nameStatus);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package db
|
||||
|
||||
func (d *DB) UpdateUsersPasswordAndDeleteSessions(ids []int64, passHash, keepToken string) error {
|
||||
tx, err := d.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(`UPDATE users SET pass_hash=? WHERE id=?`, passHash, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if keepToken == "" {
|
||||
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=? AND token<>?`, id, hashToken(keepToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateUsersColor(ids []int64, color string) error {
|
||||
tx, err := d.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(`UPDATE users SET color=? WHERE id=?`, color, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"vpsmgr/internal/pw"
|
||||
)
|
||||
|
||||
func TestUpdateUsersColorAtomically(t *testing.T) {
|
||||
d, err := Open(t.TempDir() + "/group.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateUsersColor([]int64{first.ID, second.ID}, "#16a34a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []int64{first.ID, second.ID} {
|
||||
u, err := d.GetUserByID(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Color != "#16a34a" {
|
||||
t.Errorf("user %d color = %q, want #16a34a", id, u.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsersPasswordAndDeleteSessions(t *testing.T) {
|
||||
d, err := Open(t.TempDir() + "/group.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep, err := d.CreateSession(first.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := d.CreateSession(second.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := pw.Hash("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateUsersPasswordAndDeleteSessions([]int64{first.ID, second.ID}, hash, keep.Token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []int64{first.ID, second.ID} {
|
||||
u, err := d.GetUserByID(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pw.Verify(u.PassHash, "new-password") {
|
||||
t.Errorf("user %d password was not updated", id)
|
||||
}
|
||||
}
|
||||
if _, _, err := d.SessionWithFlag(keep.Token); err != nil {
|
||||
t.Fatalf("kept session rejected: %v", err)
|
||||
}
|
||||
if _, _, err := d.SessionWithFlag(old.Token); err == nil {
|
||||
t.Fatal("old sibling session remained valid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"vpsmgr/internal/db"
|
||||
)
|
||||
|
||||
var childNameRe = regexp.MustCompile(`^([a-z][a-z0-9-]*[a-z])-([0-9]+)$`)
|
||||
var numericSuffixRe = regexp.MustCompile(`-[0-9]+$`)
|
||||
|
||||
type UserGroup struct {
|
||||
Name string
|
||||
Parent string
|
||||
Child bool
|
||||
}
|
||||
|
||||
func ParseUserGroup(name string) UserGroup {
|
||||
name = strings.ToLower(name)
|
||||
m := childNameRe.FindStringSubmatch(name)
|
||||
if len(m) == 0 || childNameRe.MatchString(m[1]) {
|
||||
return UserGroup{Name: name}
|
||||
}
|
||||
return UserGroup{Name: name, Parent: m[1], Child: true}
|
||||
}
|
||||
|
||||
func UserGroupName(name string) string {
|
||||
g := ParseUserGroup(name)
|
||||
if g.Child {
|
||||
return g.Parent
|
||||
}
|
||||
return g.Name
|
||||
}
|
||||
|
||||
func (m *Manager) ValidateAddName(name string, allowChild bool) error {
|
||||
name = strings.ToLower(name)
|
||||
g := ParseUserGroup(name)
|
||||
if !g.Child {
|
||||
if numericSuffixRe.MatchString(name) {
|
||||
return errors.New("invalid child username: text before -number must start and end with a letter")
|
||||
}
|
||||
return ValidateName(name)
|
||||
}
|
||||
if !allowChild {
|
||||
return errors.New("child users can only be created from the admin panel")
|
||||
}
|
||||
if len(name) > 31 {
|
||||
return errors.New("invalid child username: max 31 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UsersInGroup(name string) ([]*db.User, error) {
|
||||
group := UserGroupName(name)
|
||||
users, err := m.db.ListUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*db.User, 0)
|
||||
for _, u := range users {
|
||||
if UserGroupName(u.Name) == group {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetGroupColor(name, color string) error {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
users, err := m.UsersInGroup(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, u := range users {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
return m.db.UpdateUsersColor(ids, color)
|
||||
}
|
||||
|
||||
func (m *Manager) inheritGroupColorLocked(name string, excludeID int64) error {
|
||||
users, err := m.UsersInGroup(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.ID != excludeID && u.Color != "" {
|
||||
return m.db.UpdateUsersColor([]int64{excludeID}, u.Color)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"vpsmgr/internal/db"
|
||||
)
|
||||
|
||||
func TestParseUserGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parent string
|
||||
child bool
|
||||
}{
|
||||
{name: "alice", parent: "", child: false},
|
||||
{name: "alice-1", parent: "alice", child: true},
|
||||
{name: "alice-12", parent: "alice", child: true},
|
||||
{name: "my-box-3", parent: "my-box", child: true},
|
||||
{name: "alice-1-2", parent: "", child: false},
|
||||
{name: "alice1", parent: "", child: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ParseUserGroup(tt.name)
|
||||
if got.Parent != tt.parent || got.Child != tt.child {
|
||||
t.Fatalf("ParseUserGroup(%q) = %+v, want parent=%q child=%v", tt.name, got, tt.parent, tt.child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGroupName(t *testing.T) {
|
||||
if got := UserGroupName("alice-1"); got != "alice" {
|
||||
t.Fatalf("UserGroupName(alice-1) = %q, want alice", got)
|
||||
}
|
||||
if got := UserGroupName("alice"); got != "alice" {
|
||||
t.Fatalf("UserGroupName(alice) = %q, want alice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAddName(t *testing.T) {
|
||||
m := &Manager{}
|
||||
for _, name := range []string{"alice", "alice-1", "alice-99"} {
|
||||
if err := m.ValidateAddName(name, true); err != nil {
|
||||
t.Errorf("ValidateAddName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"alice-1-2", "alice--1", "-1"} {
|
||||
if err := m.ValidateAddName(name, true); err == nil {
|
||||
t.Errorf("ValidateAddName(%q) = nil, want error", name)
|
||||
}
|
||||
}
|
||||
if err := m.ValidateAddName("alice-1", false); err == nil {
|
||||
t.Error("ValidateAddName child with allowChild=false = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersInGroup(t *testing.T) {
|
||||
d := testDB(t)
|
||||
for i, name := range []string{"alice", "alice-1", "alice-2", "bob"} {
|
||||
if _, err := d.CreateUser(name, "hash", "10.42.0."+string(rune('2'+i)), i+1, 30001+i, 10000+i*100, 1, 1024, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &Manager{db: d}
|
||||
users, err := m.UsersInGroup("alice-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("UsersInGroup(alice-1) returned %d users, want 3", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func testDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
d, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
+36
-10
@@ -316,9 +316,10 @@ systemctl restart ssh`
|
||||
}
|
||||
|
||||
type AddOptions struct {
|
||||
CPU int
|
||||
MemMB int
|
||||
DiskGB int
|
||||
CPU int
|
||||
MemMB int
|
||||
DiskGB int
|
||||
AllowChild bool
|
||||
// BandwidthGB is the monthly bandwidth quota in GiB (0 = unlimited).
|
||||
BandwidthGB int
|
||||
// IPv6Addr is the pool-mode address to assign ("" = auto-pick the first
|
||||
@@ -335,7 +336,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
|
||||
// Usernames are case-insensitive: fold to lowercase before validating and
|
||||
// checking for duplicates, so "Alice" and "alice" are the same user.
|
||||
name = strings.ToLower(name)
|
||||
if err := ValidateName(name); err != nil {
|
||||
if err := m.ValidateAddName(name, opt.AllowChild); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateCPU(opt.CPU); err != nil {
|
||||
@@ -350,14 +351,27 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
|
||||
if _, err := m.db.GetUserByName(name); err == nil {
|
||||
return nil, errors.New("user already exists: " + name)
|
||||
}
|
||||
// Passwords are always generated ([a-zA-Z0-9]) — no user-supplied password
|
||||
// is ever accepted, so nothing untrusted reaches the provisioning shell
|
||||
// scripts.
|
||||
// The panel password is shared by a user group. A new group gets a fresh
|
||||
// password; a new member of an existing group reuses its hash. The generated
|
||||
// plaintext is still used as the container root password, but is not exposed
|
||||
// when the panel password was inherited.
|
||||
pass := pw.Generate(20)
|
||||
hash, err := pw.Hash(pass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inheritedPanelPassword := false
|
||||
if groupUsers, err := m.UsersInGroup(name); err == nil {
|
||||
for _, member := range groupUsers {
|
||||
if member.PassHash != "" {
|
||||
hash = member.PassHash
|
||||
inheritedPanelPassword = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
// IPv4 slot: a random unused idx in 1..MaxUsers (the /24 host range). The
|
||||
// port block is allocated independently from the configured user-port
|
||||
// ranges (allocUserPortBlock) — the two are no longer coupled.
|
||||
@@ -527,7 +541,12 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
|
||||
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
|
||||
return nil, fmt.Errorf("db: mark user ready: %w", err)
|
||||
}
|
||||
return m.ResultFor(u, pass), nil
|
||||
_ = m.inheritGroupColorLocked(u.Name, u.ID)
|
||||
result := m.ResultFor(u, pass)
|
||||
if inheritedPanelPassword {
|
||||
result.Password = ""
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// checkIPv6BlockCollision refuses a new container if its deterministic /112
|
||||
@@ -1168,6 +1187,8 @@ func snapshotCreateTime(s lx.SnapshotInfo) time.Time {
|
||||
// preserve (empty means none, so every session is dropped). Container root
|
||||
// password is managed separately via ResetRootPassword.
|
||||
func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
u, err := m.db.GetUserByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1176,10 +1197,15 @@ func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.db.UpdatePassword(u.ID, hash); err != nil {
|
||||
members, err := m.UsersInGroup(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.db.DeleteSessionsForUserExcept(u.ID, keepToken)
|
||||
ids := make([]int64, 0, len(members))
|
||||
for _, member := range members {
|
||||
ids = append(ids, member.ID)
|
||||
}
|
||||
return m.db.UpdateUsersPasswordAndDeleteSessions(ids, hash, keepToken)
|
||||
}
|
||||
|
||||
// ResetPanelPassword sets the panel login password to a new random 20-char
|
||||
|
||||
@@ -190,6 +190,46 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p("/login"), "")
|
||||
}
|
||||
|
||||
func (s *Server) handleSwitchUser(w http.ResponseWriter, r *http.Request) {
|
||||
current := s.currentUser(r)
|
||||
targetName := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
||||
members, err := s.mgr.UsersInGroup(current.Name)
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p("/"), "error: unable to switch account")
|
||||
return
|
||||
}
|
||||
var target *db.User
|
||||
for _, member := range members {
|
||||
if member.Name == targetName {
|
||||
target = member
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
s.redirect(w, r, s.p("/"), "error: account is not in this user group")
|
||||
return
|
||||
}
|
||||
oldToken := ""
|
||||
if cookie, err := r.Cookie("vpsmgr_session"); err == nil {
|
||||
oldToken = cookie.Value
|
||||
}
|
||||
sess, err := s.db.CreateSession(target.ID, s.cfg.Panel.SessionDays)
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p("/"), "error: unable to switch account")
|
||||
return
|
||||
}
|
||||
if oldToken != "" {
|
||||
if err := s.db.DeleteSession(oldToken); err != nil {
|
||||
s.db.DeleteSession(sess.Token)
|
||||
s.redirect(w, r, s.p("/"), "error: unable to switch account")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.setSessionCookie(w, sess.Token)
|
||||
_ = s.db.AddAuditLog(current.Name, "session.switch."+target.Name)
|
||||
http.Redirect(w, r, s.p("/"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.currentUser(r)
|
||||
d := s.buildData(u, "", "")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -129,6 +130,9 @@ type sshKeyRow struct {
|
||||
type pageData struct {
|
||||
Title string
|
||||
User *db.User
|
||||
GroupUsers []*db.User
|
||||
GroupIndex int
|
||||
GroupCount int
|
||||
State string
|
||||
IP string
|
||||
SSHPort int
|
||||
@@ -179,6 +183,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/logout", s.requireAuth(s.requirePost(s.handleLogout)))
|
||||
mux.HandleFunc("/switch-user", s.requireAuth(s.requirePost(s.handleSwitchUser)))
|
||||
mux.HandleFunc("/", s.requireAuth(s.handleOverview))
|
||||
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower)))
|
||||
mux.HandleFunc("/reinstall", s.requireAuth(s.requirePost(s.handleReinstall)))
|
||||
@@ -301,9 +306,21 @@ func (s *Server) redirectModal(w http.ResponseWriter, r *http.Request, path, msg
|
||||
}
|
||||
|
||||
func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
|
||||
groupUsers, _ := s.mgr.UsersInGroup(u.Name)
|
||||
sort.Slice(groupUsers, func(i, j int) bool { return groupUsers[i].Name < groupUsers[j].Name })
|
||||
groupIndex := 0
|
||||
for i, member := range groupUsers {
|
||||
if member.ID == u.ID {
|
||||
groupIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
d := pageData{
|
||||
Title: "VPS Manager",
|
||||
User: u,
|
||||
GroupUsers: groupUsers,
|
||||
GroupIndex: groupIndex,
|
||||
GroupCount: len(groupUsers),
|
||||
ThemeColor: u.Color,
|
||||
PublicIP: s.cfg.DisplayIP(),
|
||||
Prefix: s.prefix(),
|
||||
|
||||
@@ -74,6 +74,8 @@ label{font-size:.84rem;color:var(--muted)}
|
||||
.impbanner b{color:var(--violet-fg)}
|
||||
.impbanner .impback{margin-left:auto}
|
||||
.impbanner a{color:var(--accent);text-decoration:underline;font-weight:600}
|
||||
.groupbanner{background:#fef3c7;color:#92400e;border:2px solid #f59e0b;padding:.65rem .85rem;border-radius:8px;margin-bottom:.8rem;font-size:.92rem;font-weight:700}
|
||||
@media (prefers-color-scheme: dark){.groupbanner{background:#3b2f10;color:#fcd34d;border-color:#b45309}}
|
||||
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}
|
||||
@@ -223,12 +225,18 @@ footer a{color:var(--muted);text-decoration:underline}
|
||||
<div class="top">
|
||||
<h1>VPS Manager</h1>
|
||||
<div class="actions">
|
||||
{{if gt (len .GroupUsers) 1}}
|
||||
<form action="{{.Prefix}}/switch-user" method="post" class="inline"><select name="name" onchange="this.form.submit()" aria-label="{{if eq .Lang "zh"}}切换容器{{else}}Switch container{{end}}">{{range .GroupUsers}}<option value="{{.Name}}"{{if eq .Name $.User.Name}} selected{{end}}>{{.Name}}</option>{{end}}</select></form>
|
||||
{{end}}
|
||||
{{if eq .Lang "zh"}}<a class="btn" href="{{.Prefix}}/?lang=en">English</a>{{else}}<a class="btn" href="{{.Prefix}}/?lang=zh">中文</a>{{end}}
|
||||
<button class="btn" id="pwBtn">{{if eq .Lang "zh"}}改密{{else}}Change password{{end}}</button>
|
||||
<form action="{{.Prefix}}/logout" method="post" class="inline"><button class="btn" type="submit">{{if eq .Lang "zh"}}退出{{else}}Log out{{end}}</button></form>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
|
||||
{{if gt .GroupCount 1}}
|
||||
<div class="groupbanner">⚠ {{if eq .Lang "zh"}}您的用户组内有多个容器,当前容器序号为 {{.GroupIndex}} / {{.GroupCount}}({{.User.Name}})。请确认当前容器后再执行重装或快照操作。{{else}}Your user group has multiple containers. Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}). Confirm the current container before reinstalling or using snapshots.{{end}}</div>
|
||||
{{end}}
|
||||
{{if .Impersonated}}
|
||||
<div class="impbanner">
|
||||
<b>{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}}</b>
|
||||
@@ -442,6 +450,7 @@ footer a{color:var(--muted);text-decoration:underline}
|
||||
<div class="overlay" id="snapModal">
|
||||
<div class="modal modal-wide">
|
||||
<h3>{{if eq .Lang "zh"}}时光机{{else}}Time machine{{end}}</h3>
|
||||
{{if gt .GroupCount 1}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}当前容器序号:{{.GroupIndex}} / {{.GroupCount}}({{.User.Name}}){{else}}Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}){{end}}</div>{{end}}
|
||||
<p class="muted">{{if eq .Lang "zh"}}时光机只能回到过去,不能前往未来。恢复到某个检查点会丢失当前数据,以及在此之后创建的所有检查点。每容器最多 {{.SnapshotLimit}} 个检查点。{{else}}A time machine only goes back in time. Restoring to a checkpoint loses the current data and every checkpoint created after it. Up to {{.SnapshotLimit}} per container.{{end}}</p>
|
||||
<div class="btnrow">
|
||||
<form action="{{.Prefix}}/snapshot" method="post" class="inline"><button class="btn ok" type="submit" id="snapCreate">{{if eq .Lang "zh"}}创建检查点{{else}}Create checkpoint{{end}}</button></form>
|
||||
@@ -519,6 +528,7 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
|
||||
<div class="overlay" id="reModal">
|
||||
<div class="modal modal-wide">
|
||||
<h3>{{if eq .Lang "zh"}}重装系统{{else}}Reinstall{{end}}</h3>
|
||||
{{if gt .GroupCount 1}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}当前容器序号:{{.GroupIndex}} / {{.GroupCount}}({{.User.Name}}){{else}}Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}){{end}}</div>{{end}}
|
||||
<p class="muted">{{if eq .Lang "zh"}}删除全部数据,以相同 IP/端口/配额重建。请选择要安装的系统:{{else}}Deletes all data and rebuilds with the same IP/ports/quota. Pick the system to install:{{end}}</p>
|
||||
<form action="{{.Prefix}}/reinstall" method="post" id="reForm">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
|
||||
Reference in new issue
Block a user