From 9812a3cbef171d3fcefe772949aeb8a0145184cf Mon Sep 17 00:00:00 2001 From: lr00rl Date: Sat, 18 Jul 2026 09:57:10 -0700 Subject: [PATCH] Add adopted-track per-line user write path (design-15 D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vpn-core users-admin gains plan_add/plan_remove (in-core, core-backed): each compiles a pending approval whose Plan is a redacted payload (op/line/user/credential SHA — never secret material) and whose Action binds the exact credential bytes. The approval executor renders the on-box `sb user add|del ` invocation, re-deriving the credential from the write-only store and failing closed when the bytes moved since approval. Also adds rotate: regenerate one protocol credential with a one-time reveal, preserving the write-only read model. Every on-line user carries the design-15 §5 derived name (u_) — the single join key for auth, auth_user routing, and per-user stats. A successful remove drops the server-side binding so the read model stops claiming the user belongs on the line. Managed-track user writes (whole-config re-render) are explicitly rejected with a pointer to the later slice. --- internal/server/lineusers.go | 425 ++++++++++++++++++++++++++++++ internal/server/lineusers_test.go | 297 +++++++++++++++++++++ internal/server/server.go | 6 + internal/server/vpnusers.go | 18 +- 4 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 internal/server/lineusers.go create mode 100644 internal/server/lineusers_test.go diff --git a/internal/server/lineusers.go b/internal/server/lineusers.go new file mode 100644 index 0000000..965e1b7 --- /dev/null +++ b/internal/server/lineusers.go @@ -0,0 +1,425 @@ +package server + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/id" +) + +// design-15 D3: per-line user management for adopted (233boy-script) sing-box +// nodes. The vpn-core users-admin plan methods compile a reviewed approval; the +// approval executor renders the `sb user add|del` invocation; nothing touches a +// node before an operator approves the exact credential hash. +// +// The approval carries NO secret material: Plan is the redacted, human-reviewed +// payload and Action binds the credential SHA-256. The apply script re-derives +// the credential from the write-only store at execution time and refuses to run +// when the bytes no longer match what was approved (same discipline as +// proxyCoreApplyScript's current-config SHA binding). +const ( + // singBoxLineUserPlugin is the approval.Plugin value routing line-user + // approvals through lineUserApplyScript / handleLineUserTaskResult. + singBoxLineUserPlugin = "singbox-lineuser" + // lineUserActionPrefix prefixes the credential SHA-256 in approval.Action. + lineUserActionPrefix = "apply-line-user:" + + lineUserOpAdd = "add" + lineUserOpRemove = "remove" +) + +// lineUserProtocols is the set of line protocols the on-box `sb user` CLI can +// mutate (mirrors json_line_user_obj in the 233boy fork: single-user +// shadowsocks and unmanaged inbounds are rejected on-box, so they are rejected +// here first with a clearer error). +var lineUserProtocols = map[string]bool{ + "vless": true, "vmess": true, "trojan": true, + "hysteria2": true, "tuic": true, "anytls": true, "socks": true, +} + +// userLineName derives the sing-box users[].name for a (user, line) pair — +// the single join key for auth, route auth_user rules, and per-user stats +// (design-15 §5). PII-free, deterministic, unique per pair. +func userLineName(userID, lineUUID string) string { + sum := sha256.Sum256([]byte(userID + "|" + lineUUID)) + return "u_" + hex.EncodeToString(sum[:])[:16] +} + +// lineUserPlan is the redacted, operator-reviewed approval payload. It never +// carries uuid/password material — only the hash binding it. +type lineUserPlan struct { + Op string `json:"op"` // add | remove + NodeID string `json:"node_id"` + Line string `json:"line"` // on-box conf name (sb CLI line handle) + LineHashID string `json:"line_hash_id"` + LineUUID string `json:"line_uuid"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` // derived userLineName + Protocol string `json:"protocol"` + CredentialSHA256 string `json:"credential_sha256"` + Summary string `json:"summary"` +} + +// lineUserCredentialPayload is the exact JSON object passed to +// `sb user add|del `. Field order is fixed so CredentialSHA256 +// is stable; omitempty keeps protocol-irrelevant fields out. +type lineUserCredentialPayload struct { + Name string `json:"name"` + UUID string `json:"uuid,omitempty"` + Password string `json:"password,omitempty"` + Username string `json:"username,omitempty"` + Flow string `json:"flow,omitempty"` +} + +// lineUserCredential builds the on-box payload for one (user, protocol) pair +// from the write-only credential store. +func lineUserCredential(u VpnUser, protocol, userName string) (lineUserCredentialPayload, error) { + var cred *VpnCredential + for i := range u.Credentials { + if u.Credentials[i].Protocol == protocol { + cred = &u.Credentials[i] + break + } + } + if cred == nil { + return lineUserCredentialPayload{}, fmt.Errorf("user %q has no %s credential", u.ID, protocol) + } + payload := lineUserCredentialPayload{Name: userName} + switch protocol { + case "vless", "vmess": + if cred.UUID == "" { + return lineUserCredentialPayload{}, fmt.Errorf("user %q %s credential has no uuid", u.ID, protocol) + } + payload.UUID = cred.UUID + if protocol == "vless" { + payload.Flow = cred.Flow + } + case "tuic": + if cred.UUID == "" || cred.Password == "" { + return lineUserCredentialPayload{}, fmt.Errorf("user %q tuic credential needs uuid and password", u.ID) + } + payload.UUID, payload.Password = cred.UUID, cred.Password + case "trojan", "hysteria2", "anytls": + if cred.Password == "" { + return lineUserCredentialPayload{}, fmt.Errorf("user %q %s credential has no password", u.ID, protocol) + } + payload.Password = cred.Password + case "socks": + if cred.Password == "" { + return lineUserCredentialPayload{}, fmt.Errorf("user %q socks credential has no password", u.ID) + } + payload.Username, payload.Password = userName, cred.Password + default: + return lineUserCredentialPayload{}, fmt.Errorf("protocol %q does not support per-line user mutation", protocol) + } + return payload, nil +} + +// lineUserCredentialSHA binds the exact payload bytes an approval reviewed. +func lineUserCredentialSHA(payload lineUserCredentialPayload) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +// resolveAdoptedLine finds a discovered (adopted-track) line by hash. Managed +// lines take the whole-config render path (design-15 D6 deferred), so they are +// rejected here with an explicit error rather than silently mis-routed. +func (s *Server) resolveAdoptedLine(lineHashID string) (Line, error) { + for _, g := range s.buildLineGroups() { + for _, ln := range g.Lines { + if ln.LineHashID != lineHashID { + continue + } + if ln.Managed { + return Line{}, fmt.Errorf("line %q is Lattice-managed; managed-track user writes are a later slice", lineHashID) + } + if ln.LineUUID == "" { + return Line{}, fmt.Errorf("line %q has no line_uuid yet; wait for allocation and retry", lineHashID) + } + protocol := strings.ToLower(strings.TrimSpace(ln.Type)) + if !lineUserProtocols[protocol] { + return Line{}, fmt.Errorf("line %q protocol %q does not support per-line user mutation", lineHashID, ln.Type) + } + if strings.TrimSpace(ln.Tag) == "" { + return Line{}, fmt.Errorf("line %q has no on-box tag", lineHashID) + } + ln.Type = protocol + return ln, nil + } + } + return Line{}, fmt.Errorf("line %q is not a known line on any node", lineHashID) +} + +// vpnUserLinePlan compiles the reviewed approval for one `plan_add` / +// `plan_remove` call. Nothing is applied here: the operator reviews the plan, +// and the approval executor renders the sb invocation against the then-current +// credential bytes. +func (s *Server) vpnUserLinePlan(ctxPrincipal principal, request []byte, op string) ([]byte, error) { + var req struct { + UserID string `json:"user_id"` + LineHashID string `json:"line_hash_id"` + } + if err := json.Unmarshal(request, &req); err != nil { + return nil, fmt.Errorf("vpn-core/users-admin plan_%s: invalid request: %w", op, err) + } + u, ok := s.getVpnUser(strings.TrimSpace(req.UserID)) + if !ok { + return nil, fmt.Errorf("vpn-core/users-admin plan_%s: user %q not found", op, req.UserID) + } + if op == lineUserOpAdd && !u.Enabled { + return nil, fmt.Errorf("user %q is disabled", u.ID) + } + ln, err := s.resolveAdoptedLine(strings.TrimSpace(req.LineHashID)) + if err != nil { + return nil, err + } + name := userLineName(u.ID, ln.LineUUID) + if op == lineUserOpAdd { + bound := false + for _, b := range u.Bindings { + if b.LineHashID == ln.LineHashID && b.Enabled { + bound = true + break + } + } + if !bound { + return nil, fmt.Errorf("user %q is not bound to line %q; bind first, then plan the add", u.ID, ln.LineHashID) + } + } + payload, err := lineUserCredential(u, ln.Type, name) + if err != nil { + return nil, err + } + sha, err := lineUserCredentialSHA(payload) + if err != nil { + return nil, err + } + plan := lineUserPlan{ + Op: op, NodeID: ln.NodeID, Line: ln.Tag, LineHashID: ln.LineHashID, LineUUID: ln.LineUUID, + UserID: u.ID, UserName: name, Protocol: ln.Type, CredentialSHA256: sha, + Summary: fmt.Sprintf("sb user %s %s on node %s (user %s as %s, credential sha %s…)", + op, ln.Tag, ln.NodeID, u.Email, name, sha[:12]), + } + planJSON, err := json.Marshal(plan) + if err != nil { + return nil, err + } + approval := model.Approval{ + ID: id.New("approval"), + NodeID: ln.NodeID, + Plugin: singBoxLineUserPlugin, + Action: lineUserActionPrefix + sha, + Plan: string(planJSON), + Status: model.ApprovalPending, + ActorID: ctxPrincipal.ActorID, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.store.UpsertApproval(approval); err != nil { + return nil, err + } + s.recordPrincipalAudit(ctxPrincipal, model.AuditEvent{ + ID: id.New("audit"), NodeID: ln.NodeID, Action: "vpnuser.line.plan", Scope: "proxy:admin", + Metadata: map[string]string{ + "approval_id": approval.ID, "op": op, "user_id": u.ID, + "line_hash_id": ln.LineHashID, "credential_sha256": sha, + }, + }) + return json.Marshal(struct { + Approval model.Approval `json:"approval"` + }{Approval: approval}) +} + +// lineUserApplyScript renders the on-box `sb user add|del` invocation for an +// approved plan, re-deriving the credential from the write-only store and +// failing closed when the bytes no longer match the approved hash. The script +// never embeds a credential that was not exactly the reviewed one. +func (s *Server) lineUserApplyScript(approval model.Approval) string { + fail := func(err error) string { + return "set -e\n" + + "echo " + shellQuote("lattice lineuser: "+err.Error()) + " >&2\n" + + "exit 1\n" + } + if !strings.HasPrefix(approval.Action, lineUserActionPrefix) { + return fail(fmt.Errorf("invalid approval action %q", approval.Action)) + } + approvedSHA := strings.TrimPrefix(approval.Action, lineUserActionPrefix) + var plan lineUserPlan + if err := json.Unmarshal([]byte(approval.Plan), &plan); err != nil { + return fail(fmt.Errorf("invalid approval plan: %v", err)) + } + if plan.CredentialSHA256 != approvedSHA { + return fail(errors.New("plan credential hash does not match approval action; re-plan")) + } + u, ok := s.getVpnUser(plan.UserID) + if !ok { + return fail(fmt.Errorf("user %q no longer exists; re-plan", plan.UserID)) + } + payload, err := lineUserCredential(u, plan.Protocol, plan.UserName) + if err != nil { + return fail(fmt.Errorf("re-derive credential: %v; re-plan", err)) + } + sha, err := lineUserCredentialSHA(payload) + if err != nil || sha != approvedSHA { + return fail(errors.New("credential changed since approval; re-plan")) + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fail(fmt.Errorf("encode payload: %v", err)) + } + return "set -e\n" + + "SB_BIN=\"${LATTICE_SINGBOX_BIN:-sb}\"\n" + + "command -v \"$SB_BIN\" >/dev/null 2>&1 || { echo " + shellQuote("lattice lineuser: sb binary not found") + " >&2; exit 1; }\n" + + "\"$SB_BIN\" --json user " + plan.Op + " " + shellQuote(plan.Line) + " " + shellQuote(string(payloadJSON)) + "\n" +} + +// handleLineUserTaskResult reconciles a line-user approval once the agent +// reports back. A failed task leaves the approval in place for re-approval; a +// successful remove also drops the (now untrue) server-side binding so the +// read model stops claiming the user should be on the line. +func (s *Server) handleLineUserTaskResult(r *http.Request, approval model.Approval, task model.Task, result model.TaskResult) error { + metadata := map[string]string{ + "approval_id": approval.ID, "task_id": task.ID, "plugin_id": approval.Plugin, + } + if result.Error != "" || result.ExitCode != 0 { + reason := result.Error + if reason == "" { + reason = fmt.Sprintf("line-user task exited %d", result.ExitCode) + } + s.recordRequestAudit(r, model.AuditEvent{ + ID: id.New("audit"), NodeID: approval.NodeID, Action: "vpnuser.line.failed", + Decision: "deny", Reason: reason, Metadata: metadata, + }) + return nil + } + approval.Status = model.ApprovalApplied + approval.Reason = "" + approval.UpdatedAt = time.Now().UTC() + if err := s.store.UpsertApproval(approval); err != nil { + return fmt.Errorf("mark line-user approval applied: %w", err) + } + s.recordRequestAudit(r, model.AuditEvent{ + ID: id.New("audit"), NodeID: approval.NodeID, Action: "vpnuser.line.applied", + Decision: "allow", Metadata: metadata, + }) + var plan lineUserPlan + if err := json.Unmarshal([]byte(approval.Plan), &plan); err == nil && plan.Op == lineUserOpRemove { + if u, ok := s.getVpnUser(plan.UserID); ok { + kept := u.Bindings[:0] + for _, b := range u.Bindings { + if b.LineHashID != plan.LineHashID { + kept = append(kept, b) + } + } + if len(kept) != len(u.Bindings) { + u.Bindings = kept + u.UpdatedAt = s.now() + if err := s.putVpnUser(u); err != nil { + return fmt.Errorf("drop applied remove binding: %w", err) + } + } + } + } + return nil +} + +// ── credential rotation (one-time reveal, write-only invariant preserved) ──── + +// newLineUserPassword generates a fresh URL-safe password for password-based +// protocols (trojan/hysteria2/tuic/anytls/socks/shadowsocks). +func newLineUserPassword() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + out := make([]byte, 24) + for i := range out { + out[i] = alphabet[int(b[i%len(b)])&63] + } + return string(out), nil +} + +// vpnUserRotateCredential regenerates ONE protocol credential for a user. The +// new secret is returned exactly once in the response (`revealed_credential`); +// the store keeps its write-only discipline and read RPCs keep returning +// has_secret only. A rotation only changes server state — pushing it onto +// lines is an explicit plan_add/plan_remove afterwards (drift is surfaced). +func (s *Server) vpnUserRotateCredential(ctxPrincipal principal, request []byte) ([]byte, error) { + var req struct { + UserID string `json:"user_id"` + Protocol string `json:"protocol"` + } + if err := json.Unmarshal(request, &req); err != nil { + return nil, fmt.Errorf("vpn-core/users-admin rotate: invalid request: %w", err) + } + protocol := strings.ToLower(strings.TrimSpace(req.Protocol)) + if !vpnCredProtocols[protocol] { + return nil, fmt.Errorf("unsupported credential protocol %q", req.Protocol) + } + u, ok := s.getVpnUser(strings.TrimSpace(req.UserID)) + if !ok { + return nil, fmt.Errorf("vpn-core/users-admin rotate: user %q not found", req.UserID) + } + idx := -1 + for i := range u.Credentials { + if u.Credentials[i].Protocol == protocol { + idx = i + break + } + } + if idx < 0 { + return nil, fmt.Errorf("user %q has no %s credential to rotate", u.ID, protocol) + } + cred := u.Credentials[idx] + revealed := "" + if vpnCredUUIDProtos[protocol] { + fresh, err := newProxyUUID() + if err != nil { + return nil, err + } + cred.UUID = fresh + revealed = fresh + if protocol == "tuic" { + pw, err := newLineUserPassword() + if err != nil { + return nil, err + } + cred.Password = pw + } + } else { + pw, err := newLineUserPassword() + if err != nil { + return nil, err + } + cred.Password = pw + revealed = pw + } + u.Credentials[idx] = cred + u.UpdatedAt = s.now() + if err := s.putVpnUser(u); err != nil { + return nil, err + } + s.recordPrincipalAudit(ctxPrincipal, model.AuditEvent{ + ID: id.New("audit"), Action: "vpnuser.credential.rotate", Scope: "proxy:admin", + Metadata: map[string]string{"user_id": u.ID, "protocol": protocol}, + }) + return json.Marshal(struct { + User vpnUserView `json:"user"` + Protocol string `json:"protocol"` + // RevealedCredential is the new secret, returned once and never again. + RevealedCredential string `json:"revealed_credential"` + }{User: toVpnUserView(u), Protocol: protocol, RevealedCredential: revealed}) +} diff --git a/internal/server/lineusers_test.go b/internal/server/lineusers_test.go new file mode 100644 index 0000000..84362ae --- /dev/null +++ b/internal/server/lineusers_test.go @@ -0,0 +1,297 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/rbac" + "github.com/LatticeNet/lattice-server/internal/store" +) + +func lineUserTestPrincipal() principal { + return principal{Principal: rbac.Principal{ActorID: "op-1"}} +} + +// seedLineUserFixture seeds node-a with a discovered vless line plus one bound +// VpnUser, returning the resolved line and user. +func seedLineUserFixture(t *testing.T, srv *Server) (Line, VpnUser) { + t.Helper() + seedLinemetaNodes(t, srv) + var line Line + for _, g := range srv.buildLineGroups() { + for _, ln := range g.Lines { + if g.NodeID == "node-a" && ln.Tag == "hub-a" { + line = ln + } + } + } + if line.LineHashID == "" { + t.Fatal("hub-a line not resolved") + } + u := VpnUser{ + ID: "vpnuser_test1", + Email: "alice@example.com", + Enabled: true, + Credentials: []VpnCredential{ + {Protocol: "vless", UUID: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", Flow: "xtls-rprx-vision"}, + {Protocol: "trojan", Password: "old-secret"}, + }, + Bindings: []LineBinding{{LineHashID: line.LineHashID, Enabled: true}}, + } + if err := srv.putVpnUser(u); err != nil { + t.Fatal(err) + } + return line, u +} + +func TestUserLineName(t *testing.T) { + n1 := userLineName("vpnuser_a", "uuid-1") + if !strings.HasPrefix(n1, "u_") || len(n1) != 18 { + t.Fatalf("shape: %q", n1) + } + if n1 != userLineName("vpnuser_a", "uuid-1") { + t.Fatal("not deterministic") + } + if n1 == userLineName("vpnuser_a", "uuid-2") || n1 == userLineName("vpnuser_b", "uuid-1") { + t.Fatal("collides across line or user") + } + for _, c := range n1[2:] { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + t.Fatalf("non-hex char in %q", n1) + } + } +} + +func TestVpnUserLinePlanAdd(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + line, u := seedLineUserFixture(t, srv) + + req, _ := json.Marshal(map[string]string{"user_id": u.ID, "line_hash_id": line.LineHashID}) + out, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd) + if err != nil { + t.Fatalf("plan_add: %v", err) + } + var resp struct { + Approval model.Approval `json:"approval"` + } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + ap := resp.Approval + if ap.Status != model.ApprovalPending || ap.Plugin != singBoxLineUserPlugin || ap.NodeID != "node-a" { + t.Fatalf("approval shape: %+v", ap) + } + if !strings.HasPrefix(ap.Action, lineUserActionPrefix) { + t.Fatalf("action: %q", ap.Action) + } + // The reviewed plan must never carry secret material. + if strings.Contains(ap.Plan, "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d") || strings.Contains(ap.Plan, "old-secret") { + t.Fatalf("plan leaks secret: %s", ap.Plan) + } + var plan lineUserPlan + if err := json.Unmarshal([]byte(ap.Plan), &plan); err != nil { + t.Fatal(err) + } + if plan.Op != "add" || plan.Line != "hub-a" || plan.Protocol != "vless" || plan.LineUUID == "" || + plan.UserName != userLineName(u.ID, plan.LineUUID) || plan.CredentialSHA256 == "" { + t.Fatalf("plan: %+v", plan) + } +} + +func TestVpnUserLinePlanRejections(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + line, u := seedLineUserFixture(t, srv) + + // Missing binding. + u2 := VpnUser{ID: "vpnuser_unbound", Email: "b@example.com", Enabled: true, + Credentials: []VpnCredential{{Protocol: "vless", UUID: "1eec4b5a-9c2f-4a1b-8d3e-5f6a7b8c9d0e"}}} + if err := srv.putVpnUser(u2); err != nil { + t.Fatal(err) + } + req, _ := json.Marshal(map[string]string{"user_id": u2.ID, "line_hash_id": line.LineHashID}) + if _, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd); err == nil || + !strings.Contains(err.Error(), "not bound") { + t.Fatalf("unbound: %v", err) + } + + // Disabled user. + u.Enabled = false + if err := srv.putVpnUser(u); err != nil { + t.Fatal(err) + } + req, _ = json.Marshal(map[string]string{"user_id": u.ID, "line_hash_id": line.LineHashID}) + if _, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd); err == nil || + !strings.Contains(err.Error(), "disabled") { + t.Fatalf("disabled: %v", err) + } + + // Unknown line / unknown user. + req, _ = json.Marshal(map[string]string{"user_id": u.ID, "line_hash_id": "line_nope"}) + if _, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd); err == nil { + t.Fatal("unknown line: want error") + } + req, _ = json.Marshal(map[string]string{"user_id": "vpnuser_nope", "line_hash_id": line.LineHashID}) + if _, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd); err == nil { + t.Fatal("unknown user: want error") + } +} + +func TestLineUserApplyScript(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + line, u := seedLineUserFixture(t, srv) + + req, _ := json.Marshal(map[string]string{"user_id": u.ID, "line_hash_id": line.LineHashID}) + out, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpAdd) + if err != nil { + t.Fatal(err) + } + var resp struct { + Approval model.Approval `json:"approval"` + } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + script := srv.applyScriptFor(resp.Approval) + if !strings.Contains(script, "user add") || !strings.Contains(script, "hub-a") || + !strings.Contains(script, "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d") || + !strings.Contains(script, "xtls-rprx-vision") { + t.Fatalf("script:\n%s", script) + } + var plan lineUserPlan + if err := json.Unmarshal([]byte(resp.Approval.Plan), &plan); err != nil { + t.Fatal(err) + } + if !strings.Contains(script, plan.UserName) { + t.Fatalf("script missing derived user name %q:\n%s", plan.UserName, script) + } + + // Credential drift between approval and apply must fail closed. + u.Credentials[0].UUID = "2af49c3e-1d5b-4e7a-8c9d-0e1f2a3b4c5d" + if err := srv.putVpnUser(u); err != nil { + t.Fatal(err) + } + stale := srv.applyScriptFor(resp.Approval) + if !strings.Contains(stale, "credential changed since approval") || !strings.Contains(stale, "exit 1") { + t.Fatalf("stale credential must fail closed:\n%s", stale) + } +} + +func TestVpnUserRotateCredential(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + _, u := seedLineUserFixture(t, srv) + + req, _ := json.Marshal(map[string]string{"user_id": u.ID, "protocol": "vless"}) + out, err := srv.vpnUserRotateCredential(lineUserTestPrincipal(), req) + if err != nil { + t.Fatalf("rotate: %v", err) + } + var resp struct { + Protocol string `json:"protocol"` + RevealedCredential string `json:"revealed_credential"` + } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + if !proxyUUIDRe.MatchString(resp.RevealedCredential) || resp.RevealedCredential == u.Credentials[0].UUID { + t.Fatalf("revealed: %q", resp.RevealedCredential) + } + stored, _ := srv.getVpnUser(u.ID) + if stored.Credentials[0].UUID != resp.RevealedCredential { + t.Fatal("store not updated to revealed uuid") + } + if stored.Credentials[1].Password != "old-secret" { + t.Fatal("unrelated credential must stay unchanged") + } + + // Password protocol rotates its password. + req, _ = json.Marshal(map[string]string{"user_id": u.ID, "protocol": "trojan"}) + out, err = srv.vpnUserRotateCredential(lineUserTestPrincipal(), req) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + if len(resp.RevealedCredential) != 24 || resp.RevealedCredential == "old-secret" { + t.Fatalf("password reveal: %q", resp.RevealedCredential) + } + + // Missing credential / bad protocol / unknown user. + req, _ = json.Marshal(map[string]string{"user_id": u.ID, "protocol": "hysteria2"}) + if _, err := srv.vpnUserRotateCredential(lineUserTestPrincipal(), req); err == nil { + t.Fatal("missing credential: want error") + } + req, _ = json.Marshal(map[string]string{"user_id": u.ID, "protocol": "bogus"}) + if _, err := srv.vpnUserRotateCredential(lineUserTestPrincipal(), req); err == nil { + t.Fatal("bad protocol: want error") + } + req, _ = json.Marshal(map[string]string{"user_id": "vpnuser_nope", "protocol": "vless"}) + if _, err := srv.vpnUserRotateCredential(lineUserTestPrincipal(), req); err == nil { + t.Fatal("unknown user: want error") + } +} + +func TestLineUserTaskResult(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + srv := newLinemetaTestServer(t, st) + line, u := seedLineUserFixture(t, srv) + + req, _ := json.Marshal(map[string]string{"user_id": u.ID, "line_hash_id": line.LineHashID}) + out, err := srv.vpnUserLinePlan(lineUserTestPrincipal(), req, lineUserOpRemove) + if err != nil { + t.Fatalf("plan_remove: %v", err) + } + var resp struct { + Approval model.Approval `json:"approval"` + } + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("POST", "/api/agent/task-result", nil) + + // Failed task: approval is NOT applied. + if err := srv.handleLineUserTaskResult(r, resp.Approval, model.Task{ID: "task_1"}, model.TaskResult{ExitCode: 1}); err != nil { + t.Fatal(err) + } + fresh, _ := srv.store.Approval(resp.Approval.ID) + if fresh.Status == model.ApprovalApplied { + t.Fatal("failed task must not mark approval applied") + } + + // Successful remove: applied + binding dropped. + if err := srv.handleLineUserTaskResult(r, resp.Approval, model.Task{ID: "task_1"}, model.TaskResult{ExitCode: 0}); err != nil { + t.Fatal(err) + } + fresh, _ = srv.store.Approval(resp.Approval.ID) + if fresh.Status != model.ApprovalApplied { + t.Fatalf("status: %q", fresh.Status) + } + stored, _ := srv.getVpnUser(u.ID) + for _, b := range stored.Bindings { + if b.LineHashID == line.LineHashID { + t.Fatal("applied remove must drop the binding") + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9786d7e..52f521c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4830,6 +4830,9 @@ func (s *Server) applyScriptFor(approval model.Approval) string { } return script } + if approval.Plugin == singBoxLineUserPlugin { + return s.lineUserApplyScript(approval) + } return applyScriptForWithServer(approval, s.publicURL) } @@ -5939,6 +5942,9 @@ func (s *Server) handleApprovalTaskResult(r *http.Request, task model.Task, resu if approval.Plugin == proxyCorePlugin { return s.handleProxyCoreTaskResult(r, approval, task, result) } + if approval.Plugin == singBoxLineUserPlugin { + return s.handleLineUserTaskResult(r, approval, task, result) + } if approval.Plugin == agentUpdatePlugin { return s.handleAgentUpdateTaskResult(r, approval, result) } diff --git a/internal/server/vpnusers.go b/internal/server/vpnusers.go index 5ba7178..e983a3b 100644 --- a/internal/server/vpnusers.go +++ b/internal/server/vpnusers.go @@ -243,7 +243,7 @@ func (s *Server) vpnCoreUsersRPC(_ context.Context, method string, request []byt // ── RPC: writes (proxy:admin) ───────────────────────────────────────────────── -func (s *Server) vpnCoreUsersAdminRPC(_ context.Context, method string, request []byte) ([]byte, error) { +func (s *Server) vpnCoreUsersAdminRPC(ctx context.Context, method string, request []byte) ([]byte, error) { switch method { case "create": return s.vpnUserCreate(request) @@ -265,6 +265,22 @@ func (s *Server) vpnCoreUsersAdminRPC(_ context.Context, method string, request return s.vpnUserBind(request) case "unbind": return s.vpnUserUnbind(request) + case "plan_add", "plan_remove": + p, err := pluginOperatorPrincipal(ctx) + if err != nil { + return nil, err + } + op := lineUserOpAdd + if method == "plan_remove" { + op = lineUserOpRemove + } + return s.vpnUserLinePlan(p, request, op) + case "rotate": + p, err := pluginOperatorPrincipal(ctx) + if err != nil { + return nil, err + } + return s.vpnUserRotateCredential(p, request) default: return nil, fmt.Errorf("vpn-core/users-admin: unknown method %q", method) }