1400 lines
46 KiB
Go
1400 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
errResellerForbidden = errors.New("reseller is outside your management scope")
|
|
errResellerQuota = errors.New("insufficient reseller quota")
|
|
errResellerCredits = errors.New("insufficient reseller credits")
|
|
errResellerHasUsage = errors.New("limit cannot be lower than current committed usage")
|
|
errResellerInvalidState = errors.New("reseller cannot be activated while its parent is inactive or expired")
|
|
errResellerParent = errors.New("parent reseller not found")
|
|
errResellerPlan = errors.New("sub-reseller plan must match its parent")
|
|
errResellerConnLimit = errors.New("reseller accounts require at least one allowed connection")
|
|
errCreditLimitImmutable = errors.New("the connection limit of a credit account cannot be changed")
|
|
)
|
|
|
|
var (
|
|
resellerQuotaLocks sync.Map
|
|
resellerLifecycleMu sync.Mutex
|
|
)
|
|
|
|
func lockResellerQuota(username string) func() {
|
|
value, _ := resellerQuotaLocks.LoadOrStore(strings.TrimSpace(username), &sync.Mutex{})
|
|
mu := value.(*sync.Mutex)
|
|
mu.Lock()
|
|
return mu.Unlock
|
|
}
|
|
|
|
func lockResellerQuotaSet(usernames []string) func() {
|
|
unique := make(map[string]struct{}, len(usernames))
|
|
ordered := make([]string, 0, len(usernames))
|
|
for _, username := range usernames {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
continue
|
|
}
|
|
if _, exists := unique[username]; exists {
|
|
continue
|
|
}
|
|
unique[username] = struct{}{}
|
|
ordered = append(ordered, username)
|
|
}
|
|
sort.Strings(ordered)
|
|
unlocks := make([]func(), 0, len(ordered))
|
|
for _, username := range ordered {
|
|
unlocks = append(unlocks, lockResellerQuota(username))
|
|
}
|
|
return func() {
|
|
for i := len(unlocks) - 1; i >= 0; i-- {
|
|
unlocks[i]()
|
|
}
|
|
}
|
|
}
|
|
|
|
func resellerSubtreeUsernames(users []*AdminUser) []string {
|
|
usernames := make([]string, 0, len(users))
|
|
for _, user := range users {
|
|
if user != nil {
|
|
usernames = append(usernames, user.Username)
|
|
}
|
|
}
|
|
return usernames
|
|
}
|
|
|
|
func normalizeQuotaMode(mode string) string {
|
|
if strings.EqualFold(strings.TrimSpace(mode), QuotaModeCredit) || strings.EqualFold(strings.TrimSpace(mode), "credito") {
|
|
return QuotaModeCredit
|
|
}
|
|
return QuotaModeSlots
|
|
}
|
|
|
|
func normalizeWhatsApp(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
var b strings.Builder
|
|
for i, r := range value {
|
|
if r >= '0' && r <= '9' {
|
|
b.WriteRune(r)
|
|
} else if r == '+' && i == 0 {
|
|
b.WriteRune(r)
|
|
}
|
|
if b.Len() >= 32 {
|
|
break
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func resellerProvisionCost(maxConnections int) int {
|
|
if maxConnections < 1 {
|
|
return 1
|
|
}
|
|
return maxConnections
|
|
}
|
|
|
|
func adminAccountChainActive(username string) error {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return nil
|
|
}
|
|
seen := make(map[string]bool)
|
|
now := time.Now()
|
|
for depth := 0; username != "" && depth < 128; depth++ {
|
|
if seen[username] {
|
|
return fmt.Errorf("reseller hierarchy cycle detected")
|
|
}
|
|
seen[username] = true
|
|
parentUsername := ""
|
|
active := false
|
|
var expiresAt *time.Time
|
|
if u, ok := adminUsers.get(username); ok {
|
|
active = u.IsActive
|
|
expiresAt = u.ExpiresAt
|
|
parentUsername = u.ParentUsername
|
|
} else if state, ok := resellerRuntimeStates.get(username); ok {
|
|
active = state.IsActive
|
|
expiresAt = state.ExpiresAt
|
|
parentUsername = state.ParentUsername
|
|
} else {
|
|
return fmt.Errorf("reseller account not found")
|
|
}
|
|
if !active {
|
|
return fmt.Errorf("reseller account suspended")
|
|
}
|
|
if expiresAt != nil && now.After(*expiresAt) {
|
|
return fmt.Errorf("reseller account expired")
|
|
}
|
|
username = strings.TrimSpace(parentUsername)
|
|
}
|
|
if username != "" {
|
|
return fmt.Errorf("reseller hierarchy is too deep")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resellerCanManage(sess *AdminSession, target *AdminUser) bool {
|
|
if sess == nil || target == nil || target.Role != RoleReseller {
|
|
return false
|
|
}
|
|
if sess.Role == RoleSuperAdmin {
|
|
return true
|
|
}
|
|
return sess.Role == RoleReseller && target.ParentUsername == sess.Username
|
|
}
|
|
|
|
func listResellerSubtree(all []*AdminUser, root string) []*AdminUser {
|
|
children := make(map[string][]*AdminUser)
|
|
byUsername := make(map[string]*AdminUser)
|
|
for _, u := range all {
|
|
if u == nil || u.Role != RoleReseller {
|
|
continue
|
|
}
|
|
byUsername[u.Username] = u
|
|
children[u.ParentUsername] = append(children[u.ParentUsername], u)
|
|
}
|
|
rootUser := byUsername[root]
|
|
if rootUser == nil {
|
|
return nil
|
|
}
|
|
out := make([]*AdminUser, 0, 4)
|
|
queue := []*AdminUser{rootUser}
|
|
seen := make(map[string]bool)
|
|
for len(queue) > 0 {
|
|
u := queue[0]
|
|
queue = queue[1:]
|
|
if u == nil || seen[u.Username] {
|
|
continue
|
|
}
|
|
seen[u.Username] = true
|
|
out = append(out, u)
|
|
queue = append(queue, children[u.Username]...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) directChildAllocation(ctx context.Context, parentUsername, excludeUsername string) (int, error) {
|
|
var total int
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(max_users), 0)
|
|
FROM admin_users
|
|
WHERE role=$1 AND parent_username=$2 AND quota_mode=$3 AND username<>$4`,
|
|
RoleReseller, parentUsername, QuotaModeSlots, excludeUsername).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func (s *Store) directChildCount(ctx context.Context, parentUsername string) int {
|
|
var total int
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM admin_users WHERE role=$1 AND parent_username=$2`,
|
|
RoleReseller, parentUsername).Scan(&total); err != nil {
|
|
log.Printf("count child resellers for %s: %v", parentUsername, err)
|
|
return 0
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (s *Store) appendResellerAudit(ctx context.Context, actor, target, action, details string) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO reseller_audit_log (actor_username, target_username, action, details)
|
|
VALUES ($1,$2,$3,$4)`, actor, target, action, details)
|
|
return err
|
|
}
|
|
|
|
type ResellerDTO struct {
|
|
ID int `json:"id"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
ParentUsername string `json:"parent_username,omitempty"`
|
|
QuotaMode string `json:"quota_mode"`
|
|
MaxUsers int `json:"max_users"`
|
|
CreditBalance int `json:"credit_balance"`
|
|
UsedUsers int `json:"used_users"`
|
|
UsedSSH int `json:"used_ssh_users"`
|
|
UsedXray int `json:"used_xray_users"`
|
|
ChildAllocation int `json:"child_allocation"`
|
|
ChildCount int `json:"child_count"`
|
|
Available int `json:"available"`
|
|
UsageIncomplete bool `json:"usage_incomplete,omitempty"`
|
|
WhatsApp string `json:"whatsapp,omitempty"`
|
|
MonthlyPriceCents int `json:"monthly_price_cents"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
IsActive bool `json:"is_active"`
|
|
EffectiveActive bool `json:"effective_active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type ResellerPayload struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password,omitempty"`
|
|
ParentUsername string `json:"parent_username,omitempty"`
|
|
QuotaMode string `json:"quota_mode"`
|
|
MaxUsers int `json:"max_users"`
|
|
Credits int `json:"credits"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
IsActive bool `json:"is_active"`
|
|
WhatsApp string `json:"whatsapp"`
|
|
MonthlyPriceCents int `json:"monthly_price_cents"`
|
|
}
|
|
|
|
func handleListResellers(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
users, err := store.ListAdminUsers(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
out := make([]ResellerDTO, 0, len(users))
|
|
for _, u := range users {
|
|
if u.Role != RoleReseller || !resellerCanManage(sess, u) {
|
|
continue
|
|
}
|
|
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), store, u.Username)
|
|
used := usage.Weighted
|
|
usedSSH := usage.SSHAccounts
|
|
usedXray := usage.XrayAccounts
|
|
if usageErr != nil {
|
|
used = countOwnedQuota(r.Context(), store, u.Username)
|
|
usedSSH = countOwnedUsers(u.Username)
|
|
usedXray = countOwnedXrayClients(r.Context(), store, u.Username)
|
|
}
|
|
childAllocation, _ := store.directChildAllocation(r.Context(), u.Username, "")
|
|
available := u.CreditBalance
|
|
if normalizeQuotaMode(u.QuotaMode) == QuotaModeSlots {
|
|
if u.MaxUsers == 0 {
|
|
available = -1
|
|
} else {
|
|
available = max(0, u.MaxUsers-used-childAllocation)
|
|
}
|
|
}
|
|
out = append(out, ResellerDTO{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Role: u.Role,
|
|
ParentUsername: u.ParentUsername,
|
|
QuotaMode: normalizeQuotaMode(u.QuotaMode),
|
|
MaxUsers: u.MaxUsers,
|
|
CreditBalance: u.CreditBalance,
|
|
UsedUsers: used,
|
|
UsedSSH: usedSSH,
|
|
UsedXray: usedXray,
|
|
ChildAllocation: childAllocation,
|
|
ChildCount: store.directChildCount(r.Context(), u.Username),
|
|
Available: available,
|
|
UsageIncomplete: usageErr != nil,
|
|
WhatsApp: u.WhatsApp,
|
|
MonthlyPriceCents: u.MonthlyPriceCents,
|
|
ExpiresAt: u.ExpiresAt,
|
|
IsActive: u.IsActive,
|
|
EffectiveActive: adminAccountChainActive(u.Username) == nil,
|
|
CreatedAt: u.CreatedAt,
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(out)
|
|
}
|
|
}
|
|
|
|
func parseResellerExpiry(raw string) (*time.Time, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid expires_at (RFC3339 required)")
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) saveResellerRecord(ctx context.Context, actor string, desired, existing *AdminUser, parentDirectUsed int) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if desired.ParentUsername != "" {
|
|
var parentMode string
|
|
var parentMax, parentCredits int
|
|
var parentActive bool
|
|
var parentExpiry sql.NullTime
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT COALESCE(quota_mode, 'slots'), max_users, COALESCE(credit_balance, 0), is_active, expires_at
|
|
FROM admin_users WHERE username=$1 AND role=$2 FOR UPDATE`,
|
|
desired.ParentUsername, RoleReseller).Scan(&parentMode, &parentMax, &parentCredits, &parentActive, &parentExpiry); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return errResellerParent
|
|
}
|
|
return err
|
|
}
|
|
if !parentActive || (parentExpiry.Valid && time.Now().After(parentExpiry.Time)) {
|
|
return errResellerInvalidState
|
|
}
|
|
parentMode = normalizeQuotaMode(parentMode)
|
|
if parentMode != desired.QuotaMode {
|
|
return errResellerPlan
|
|
}
|
|
if parentMode == QuotaModeSlots {
|
|
var allocated int
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(max_users), 0) FROM admin_users
|
|
WHERE role=$1 AND parent_username=$2 AND quota_mode=$3 AND username<>$4`,
|
|
RoleReseller, desired.ParentUsername, QuotaModeSlots, desired.Username).Scan(&allocated); err != nil {
|
|
return err
|
|
}
|
|
if parentMax > 0 && parentDirectUsed+allocated+desired.MaxUsers > parentMax {
|
|
return errResellerQuota
|
|
}
|
|
} else {
|
|
oldCredits := 0
|
|
if existing != nil {
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT COALESCE(credit_balance, 0) FROM admin_users
|
|
WHERE id=$1 AND role=$2 FOR UPDATE`, existing.ID, RoleReseller).Scan(&oldCredits); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
diff := desired.CreditBalance - oldCredits
|
|
if diff > parentCredits {
|
|
return errResellerCredits
|
|
}
|
|
if diff != 0 {
|
|
parentCredits -= diff
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE admin_users SET credit_balance=$1 WHERE username=$2`,
|
|
parentCredits, desired.ParentUsername); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_credit_ledger
|
|
(reseller_username, actor_username, delta, balance_after, action, target)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
desired.ParentUsername, actor, -diff, parentCredits, "sub-reseller transfer", desired.Username); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var expiresAt interface{}
|
|
if desired.ExpiresAt != nil {
|
|
expiresAt = *desired.ExpiresAt
|
|
}
|
|
if existing == nil {
|
|
if err := tx.QueryRowContext(ctx, `
|
|
INSERT INTO admin_users
|
|
(username, password_hash, role, max_users, parent_username, quota_mode,
|
|
credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
RETURNING id, created_at`,
|
|
desired.Username, desired.PasswordHash, RoleReseller, desired.MaxUsers,
|
|
desired.ParentUsername, desired.QuotaMode, desired.CreditBalance, desired.WhatsApp,
|
|
desired.MonthlyPriceCents, expiresAt, desired.IsActive).Scan(&desired.ID, &desired.CreatedAt); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE admin_users SET password_hash=$2, max_users=$3, credit_balance=$4,
|
|
whatsapp=$5, monthly_price_cents=$6, expires_at=$7, is_active=$8
|
|
WHERE id=$1 AND role=$9`,
|
|
desired.ID, desired.PasswordHash, desired.MaxUsers, desired.CreditBalance,
|
|
desired.WhatsApp, desired.MonthlyPriceCents, expiresAt, desired.IsActive,
|
|
RoleReseller); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
details := fmt.Sprintf("mode=%s limit=%d credits=%d parent=%s active=%t",
|
|
desired.QuotaMode, desired.MaxUsers, desired.CreditBalance, desired.ParentUsername, desired.IsActive)
|
|
action := "created reseller"
|
|
if existing != nil {
|
|
action = "updated reseller"
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_audit_log (actor_username, target_username, action, details)
|
|
VALUES ($1,$2,$3,$4)`, actor, desired.Username, action, details); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func handleCreateReseller(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
var p ResellerPayload
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&p); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
p.Username = strings.TrimSpace(p.Username)
|
|
if err := validateAdminUsername(p.Username); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.MaxUsers < 0 || p.MaxUsers > 1000000 || p.Credits < 0 || p.Credits > 1000000000 {
|
|
http.Error(w, "limit is outside the allowed range", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.MonthlyPriceCents < 0 || p.MonthlyPriceCents > 1000000000 {
|
|
http.Error(w, "monthly price is outside the allowed range", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
sess := sessionFromCtx(ctx)
|
|
if sess == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
resellerLifecycleMu.Lock()
|
|
defer resellerLifecycleMu.Unlock()
|
|
existing, err := store.GetAdminUserByUsername(ctx, p.Username)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if existing != nil && !resellerCanManage(sess, existing) {
|
|
http.Error(w, errResellerForbidden.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
desired := &AdminUser{Username: p.Username, Role: RoleReseller, IsActive: p.IsActive}
|
|
if existing != nil {
|
|
*desired = *existing
|
|
desired.IsActive = p.IsActive
|
|
}
|
|
if existing == nil && strings.TrimSpace(p.Password) == "" {
|
|
http.Error(w, "password required for new account", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if strings.TrimSpace(p.Password) != "" {
|
|
if err := validateAdminPassword(p.Password); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
hash, err := hashAdminPassword(p.Password)
|
|
if err != nil {
|
|
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
desired.PasswordHash = hash
|
|
}
|
|
|
|
if existing == nil {
|
|
desired.ParentUsername = strings.TrimSpace(p.ParentUsername)
|
|
desired.QuotaMode = normalizeQuotaMode(p.QuotaMode)
|
|
if sess.Role == RoleReseller {
|
|
parent, ok := adminUsers.get(sess.Username)
|
|
if !ok {
|
|
http.Error(w, "parent reseller not found", http.StatusForbidden)
|
|
return
|
|
}
|
|
desired.ParentUsername = sess.Username
|
|
desired.QuotaMode = normalizeQuotaMode(parent.QuotaMode)
|
|
}
|
|
} else {
|
|
if strings.TrimSpace(p.ParentUsername) != "" && strings.TrimSpace(p.ParentUsername) != existing.ParentUsername {
|
|
http.Error(w, "a reseller parent cannot be changed from the edit form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if strings.TrimSpace(p.QuotaMode) != "" && normalizeQuotaMode(p.QuotaMode) != normalizeQuotaMode(existing.QuotaMode) {
|
|
http.Error(w, "plan mode cannot be changed after creation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
desired.QuotaMode = normalizeQuotaMode(existing.QuotaMode)
|
|
}
|
|
|
|
if desired.ParentUsername != "" {
|
|
parent, err := store.GetAdminUserByUsername(ctx, desired.ParentUsername)
|
|
if err != nil || parent == nil || parent.Role != RoleReseller {
|
|
http.Error(w, "parent reseller not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if adminAccountChainActive(parent.Username) != nil {
|
|
http.Error(w, "parent reseller is inactive or expired", http.StatusForbidden)
|
|
return
|
|
}
|
|
if normalizeQuotaMode(parent.QuotaMode) != desired.QuotaMode {
|
|
http.Error(w, "sub-reseller plan must match its parent", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if desired.QuotaMode == QuotaModeCredit {
|
|
desired.MaxUsers = 0
|
|
desired.CreditBalance = p.Credits
|
|
desired.ExpiresAt = nil
|
|
} else {
|
|
desired.MaxUsers = p.MaxUsers
|
|
desired.CreditBalance = 0
|
|
desired.ExpiresAt, err = parseResellerExpiry(p.ExpiresAt)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if desired.ParentUsername != "" && desired.MaxUsers < 1 {
|
|
http.Error(w, "sub-reseller limit must be at least 1", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
desired.WhatsApp = normalizeWhatsApp(p.WhatsApp)
|
|
desired.MonthlyPriceCents = p.MonthlyPriceCents
|
|
lockNames := []string{desired.ParentUsername}
|
|
if existing != nil {
|
|
lockNames = append(lockNames, desired.Username)
|
|
}
|
|
quotaUnlock := lockResellerQuotaSet(lockNames)
|
|
defer quotaUnlock()
|
|
|
|
if desired.IsActive && desired.ParentUsername != "" && adminAccountChainActive(desired.ParentUsername) != nil {
|
|
http.Error(w, errResellerInvalidState.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if desired.QuotaMode == QuotaModeSlots && existing != nil && desired.MaxUsers > 0 {
|
|
directUsed, usageErr := countOwnedQuotaAcrossManagedServers(ctx, store, desired.Username)
|
|
if usageErr != nil {
|
|
http.Error(w, "could not verify current reseller usage", http.StatusBadGateway)
|
|
return
|
|
}
|
|
children, childErr := store.directChildAllocation(ctx, desired.Username, "")
|
|
if childErr != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if directUsed+children > desired.MaxUsers {
|
|
http.Error(w, errResellerHasUsage.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
parentDirectUsed := 0
|
|
if desired.ParentUsername != "" && desired.QuotaMode == QuotaModeSlots {
|
|
parentDirectUsed, err = countOwnedQuotaAcrossManagedServers(ctx, store, desired.ParentUsername)
|
|
if err != nil {
|
|
http.Error(w, "could not verify parent quota", http.StatusBadGateway)
|
|
return
|
|
}
|
|
}
|
|
wasActive := existing != nil && adminAccountChainActive(existing.Username) == nil
|
|
if err := store.saveResellerRecord(ctx, sess.Username, desired, existing, parentDirectUsed); err != nil {
|
|
switch {
|
|
case errors.Is(err, errResellerQuota), errors.Is(err, errResellerCredits), errors.Is(err, errResellerHasUsage):
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
case errors.Is(err, errResellerParent), errors.Is(err, errResellerPlan):
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
case errors.Is(err, errResellerInvalidState):
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
default:
|
|
writeInternalError(w, "save reseller "+desired.Username, err)
|
|
}
|
|
return
|
|
}
|
|
if err := loadAdminUsersIntoCache(ctx, store); err != nil {
|
|
log.Printf("reload reseller cache: %v", err)
|
|
}
|
|
if existing != nil && strings.TrimSpace(p.Password) != "" {
|
|
sessions.DeleteUser(desired.ID)
|
|
}
|
|
isActive := adminAccountChainActive(desired.Username) == nil
|
|
if wasActive != isActive {
|
|
if err := applyResellerSubtreeRuntime(ctx, store, desired.Username, isActive); err != nil {
|
|
log.Printf("apply reseller runtime for %s: %v", desired.Username, err)
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"username": desired.Username, "created": existing == nil})
|
|
}
|
|
}
|
|
|
|
type resellerRuntimePayload struct {
|
|
Owner string `json:"owner_username"`
|
|
ParentUsername string `json:"parent_username,omitempty"`
|
|
Action string `json:"action"`
|
|
EffectiveActive bool `json:"effective_active"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
func resellerRuntimePayloadFromState(state ResellerRuntimeState, action string) resellerRuntimePayload {
|
|
return resellerRuntimePayload{
|
|
Owner: state.OwnerUsername,
|
|
ParentUsername: state.ParentUsername,
|
|
Action: action,
|
|
EffectiveActive: state.IsActive,
|
|
ExpiresAt: state.ExpiresAt,
|
|
}
|
|
}
|
|
|
|
func sendResellerRuntimeToServer(ctx context.Context, ms *ManagedServer, payload resellerRuntimePayload) error {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/resellers/runtime", body, "application/json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyOwnerRuntimeLocal(ctx context.Context, store *Store, owner, action string) error {
|
|
switch action {
|
|
case "suspend":
|
|
disconnectOwnerUsers(owner)
|
|
return suspendOwnerXrayClients(ctx, store, owner)
|
|
case "reactivate":
|
|
return restoreOwnerXrayClients(ctx, store, owner)
|
|
case "delete":
|
|
rows, err := store.db.QueryContext(ctx, `SELECT username FROM ssh_users WHERE owner_username=$1`, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var usernames []string
|
|
for rows.Next() {
|
|
var username string
|
|
if err := rows.Scan(&username); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
usernames = append(usernames, username)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
for _, username := range usernames {
|
|
userMgr.DisconnectUser(username)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `DELETE FROM ssh_users WHERE owner_username=$1`, owner); err != nil {
|
|
return err
|
|
}
|
|
removeOwnerXrayClients(ctx, store, owner)
|
|
reloadUsersFromDB(ctx, store)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported runtime action")
|
|
}
|
|
}
|
|
|
|
func applyOwnerRuntimeRemote(ctx context.Context, store *Store, owner, action string) error {
|
|
servers, err := store.ListManagedServers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
effectiveActive := action == "reactivate" && adminAccountChainActive(owner) == nil
|
|
state, err := resellerRuntimeStateFor(owner, effectiveActive)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload := resellerRuntimePayloadFromState(state, action)
|
|
var failures []string
|
|
for _, ms := range servers {
|
|
// Cleanup also targets disabled nodes: skipping one could leave access that
|
|
// becomes live again when the node or protocol is re-enabled.
|
|
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
|
failures = append(failures, ms.Name+": "+err.Error())
|
|
}
|
|
}
|
|
if len(failures) > 0 {
|
|
return fmt.Errorf("managed server runtime update failed: %s", strings.Join(failures, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyResellerSubtreeRuntime(ctx context.Context, store *Store, root string, active bool) error {
|
|
all, err := store.ListAdminUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
action := "suspend"
|
|
if active {
|
|
action = "reactivate"
|
|
}
|
|
var failures []string
|
|
for _, owner := range listResellerSubtree(all, root) {
|
|
if active && adminAccountChainActive(owner.Username) != nil {
|
|
continue
|
|
}
|
|
if err := applyOwnerRuntimeLocal(ctx, store, owner.Username, action); err != nil {
|
|
failures = append(failures, "local "+owner.Username+": "+err.Error())
|
|
}
|
|
if err := applyOwnerRuntimeRemote(ctx, store, owner.Username, action); err != nil {
|
|
failures = append(failures, owner.Username+": "+err.Error())
|
|
}
|
|
}
|
|
if len(failures) > 0 {
|
|
return errors.New(strings.Join(failures, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleResellerRuntime(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var p resellerRuntimePayload
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&p); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
p.Owner = strings.TrimSpace(p.Owner)
|
|
p.ParentUsername = strings.TrimSpace(p.ParentUsername)
|
|
if err := validateAdminUsername(p.Owner); err != nil {
|
|
http.Error(w, "invalid owner", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.ParentUsername != "" {
|
|
if err := validateAdminUsername(p.ParentUsername); err != nil || p.ParentUsername == p.Owner {
|
|
http.Error(w, "invalid parent owner", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
p.Action = strings.ToLower(strings.TrimSpace(p.Action))
|
|
state := ResellerRuntimeState{
|
|
OwnerUsername: p.Owner,
|
|
ParentUsername: p.ParentUsername,
|
|
IsActive: p.EffectiveActive,
|
|
ExpiresAt: p.ExpiresAt,
|
|
}
|
|
ctx := r.Context()
|
|
switch p.Action {
|
|
case "sync":
|
|
if err := store.UpsertResellerRuntimeState(ctx, state); err != nil {
|
|
log.Printf("sync reseller runtime state for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime state update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
action := "suspend"
|
|
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
|
action = "reactivate"
|
|
}
|
|
if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, action); err != nil {
|
|
if action == "reactivate" {
|
|
// State synchronization is still useful for SSH authentication even
|
|
// when an unrelated legacy Xray client cannot be restored.
|
|
log.Printf("restore Xray while syncing reseller %s: %v", p.Owner, err)
|
|
break
|
|
}
|
|
log.Printf("reconcile synced reseller runtime for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
case "suspend":
|
|
state.IsActive = false
|
|
if err := store.UpsertResellerRuntimeState(ctx, state); err != nil {
|
|
log.Printf("suspend reseller runtime state for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime state update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "suspend"); err != nil {
|
|
log.Printf("suspend reseller runtime for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
case "reactivate":
|
|
state.IsActive = true
|
|
if err := store.UpsertResellerRuntimeState(ctx, state); err != nil {
|
|
log.Printf("reactivate reseller runtime state for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime state update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "reactivate"); err != nil {
|
|
state.IsActive = false
|
|
_ = store.UpsertResellerRuntimeState(ctx, state)
|
|
_ = applyOwnerRuntimeLocal(ctx, store, p.Owner, "suspend")
|
|
log.Printf("reactivate reseller runtime for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
case "delete":
|
|
state.IsActive = false
|
|
if err := store.UpsertResellerRuntimeState(ctx, state); err != nil {
|
|
log.Printf("block reseller runtime state before delete for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime state update failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := applyOwnerRuntimeLocal(ctx, store, p.Owner, "delete"); err != nil {
|
|
log.Printf("delete reseller runtime for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime cleanup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := store.DeleteResellerRuntimeState(ctx, p.Owner); err != nil {
|
|
log.Printf("delete reseller runtime state for %s: %v", p.Owner, err)
|
|
http.Error(w, "runtime state cleanup failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, "unsupported runtime action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
type resellerActionPayload struct {
|
|
Username string `json:"username"`
|
|
Action string `json:"action"`
|
|
Days int `json:"days,omitempty"`
|
|
}
|
|
|
|
func handleResellerAction(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var p resellerActionPayload
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&p); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
p.Username = strings.TrimSpace(p.Username)
|
|
resellerLifecycleMu.Lock()
|
|
defer resellerLifecycleMu.Unlock()
|
|
target, err := store.GetAdminUserByUsername(r.Context(), p.Username)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if target == nil {
|
|
http.Error(w, "reseller not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if !resellerCanManage(sess, target) {
|
|
http.Error(w, errResellerForbidden.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
all, err := store.ListAdminUsers(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, target.Username)))
|
|
defer quotaUnlock()
|
|
// The target may have changed while another lifecycle action held the
|
|
// subtree lock, so base this action on a fresh row.
|
|
target, err = store.GetAdminUserByUsername(r.Context(), p.Username)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if target == nil {
|
|
http.Error(w, "reseller not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if !resellerCanManage(sess, target) {
|
|
http.Error(w, errResellerForbidden.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
|
details := ""
|
|
active := target.IsActive
|
|
expiresAt := target.ExpiresAt
|
|
switch action {
|
|
case "suspend":
|
|
active = false
|
|
case "reactivate":
|
|
if target.ExpiresAt != nil && time.Now().After(*target.ExpiresAt) {
|
|
http.Error(w, "renew this reseller before reactivating it", http.StatusConflict)
|
|
return
|
|
}
|
|
if target.ParentUsername != "" && adminAccountChainActive(target.ParentUsername) != nil {
|
|
http.Error(w, errResellerInvalidState.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
active = true
|
|
case "renew":
|
|
if normalizeQuotaMode(target.QuotaMode) == QuotaModeCredit {
|
|
http.Error(w, "credit resellers do not expire", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.Days == 0 {
|
|
p.Days = 30
|
|
}
|
|
if p.Days < 1 || p.Days > 3650 {
|
|
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
|
return
|
|
}
|
|
base := time.Now()
|
|
if target.ExpiresAt != nil && target.ExpiresAt.After(base) {
|
|
base = *target.ExpiresAt
|
|
}
|
|
next := base.AddDate(0, 0, p.Days)
|
|
expiresAt = &next
|
|
active = true
|
|
details = "days=" + strconv.Itoa(p.Days)
|
|
case "pull":
|
|
if sess.Role != RoleSuperAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if target.ParentUsername == "" {
|
|
http.Error(w, "reseller is already attached to the main panel", http.StatusConflict)
|
|
return
|
|
}
|
|
details = "previous_parent=" + target.ParentUsername
|
|
default:
|
|
http.Error(w, "unsupported reseller action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := store.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
updateQuery := `UPDATE admin_users SET is_active=$1, expires_at=$2 WHERE id=$3 AND role=$4`
|
|
updateArgs := []interface{}{active, expiresAt, target.ID, RoleReseller}
|
|
if action == "pull" {
|
|
updateQuery = `UPDATE admin_users SET parent_username='' WHERE id=$1 AND role=$2`
|
|
updateArgs = []interface{}{target.ID, RoleReseller}
|
|
}
|
|
if _, err := tx.ExecContext(r.Context(), updateQuery, updateArgs...); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := tx.ExecContext(r.Context(), `
|
|
INSERT INTO reseller_audit_log (actor_username, target_username, action, details)
|
|
VALUES ($1,$2,$3,$4)`, sess.Username, target.Username, action+" reseller", details); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = loadAdminUsersIntoCache(r.Context(), store)
|
|
if !active {
|
|
sessions.DeleteUser(target.ID)
|
|
}
|
|
runtimeActive := active
|
|
if action == "pull" {
|
|
runtimeActive = adminAccountChainActive(target.Username) == nil
|
|
}
|
|
runtimeErr := applyResellerSubtreeRuntime(r.Context(), store, target.Username, runtimeActive)
|
|
runtimeWarning := ""
|
|
if runtimeErr != nil {
|
|
log.Printf("apply reseller action runtime for %s: %v", target.Username, runtimeErr)
|
|
runtimeWarning = "Some managed servers could not be updated. Check server status and retry."
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"ok": true,
|
|
"runtime_warning": runtimeWarning,
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleDeleteReseller(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
|
if err := validateAdminUsername(username); err != nil {
|
|
http.Error(w, "invalid username", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
sess := sessionFromCtx(ctx)
|
|
resellerLifecycleMu.Lock()
|
|
defer resellerLifecycleMu.Unlock()
|
|
target, err := store.GetAdminUserByUsername(ctx, username)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if target == nil {
|
|
http.Error(w, "reseller not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if !resellerCanManage(sess, target) {
|
|
http.Error(w, errResellerForbidden.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
all, err := store.ListAdminUsers(ctx)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
subtree := listResellerSubtree(all, username)
|
|
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(subtree))
|
|
defer quotaUnlock()
|
|
// Reload balances after acquiring the provisioning locks. This prevents an
|
|
// in-flight credit spend or child transfer from being refunded twice.
|
|
all, err = store.ListAdminUsers(ctx)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
subtree = listResellerSubtree(all, username)
|
|
if len(subtree) == 0 {
|
|
http.Error(w, "reseller not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
// Suspend first and refresh the session cache before distributed cleanup.
|
|
// A failed node cleanup can then be retried safely without allowing new
|
|
// accounts to appear underneath the reseller being deleted.
|
|
blockTx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for _, owner := range subtree {
|
|
if _, err := blockTx.ExecContext(ctx, `UPDATE admin_users SET is_active=FALSE WHERE id=$1 AND role=$2`, owner.ID, RoleReseller); err != nil {
|
|
_ = blockTx.Rollback()
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
if err := blockTx.Commit(); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = loadAdminUsersIntoCache(ctx, store)
|
|
for _, owner := range subtree {
|
|
sessions.DeleteUser(owner.ID)
|
|
}
|
|
for _, owner := range subtree {
|
|
if err := applyOwnerRuntimeRemote(ctx, store, owner.Username, "delete"); err != nil {
|
|
writeBadGatewayError(w, "delete reseller resources from managed servers", err)
|
|
return
|
|
}
|
|
}
|
|
for i := len(subtree) - 1; i >= 0; i-- {
|
|
if err := applyOwnerRuntimeLocal(ctx, store, subtree[i].Username, "delete"); err != nil {
|
|
writeInternalError(w, "delete local reseller resources", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if target.ParentUsername != "" && normalizeQuotaMode(target.QuotaMode) == QuotaModeCredit {
|
|
refund := 0
|
|
for _, u := range subtree {
|
|
if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
|
refund += u.CreditBalance
|
|
}
|
|
}
|
|
if refund > 0 {
|
|
var balance int
|
|
if err := tx.QueryRowContext(ctx, `
|
|
UPDATE admin_users SET credit_balance=credit_balance+$1
|
|
WHERE username=$2 AND quota_mode=$3 RETURNING credit_balance`,
|
|
refund, target.ParentUsername, QuotaModeCredit).Scan(&balance); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_credit_ledger
|
|
(reseller_username, actor_username, delta, balance_after, action, target)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
target.ParentUsername, sess.Username, refund, balance, "deleted sub-reseller refund", target.Username); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_audit_log (actor_username, target_username, action, details)
|
|
VALUES ($1,$2,$3,$4)`, sess.Username, target.Username, "deleted reseller",
|
|
fmt.Sprintf("removed_accounts=%d", len(subtree))); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for i := len(subtree) - 1; i >= 0; i-- {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM admin_users WHERE id=$1 AND role=$2`, subtree[i].ID, RoleReseller); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
for _, u := range subtree {
|
|
sessions.DeleteUser(u.ID)
|
|
adminUsers.delete(u.Username)
|
|
}
|
|
_ = loadAdminUsersIntoCache(ctx, store)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
type ResellerAuditDTO struct {
|
|
ID int64 `json:"id"`
|
|
ActorUsername string `json:"actor_username"`
|
|
TargetUsername string `json:"target_username"`
|
|
Action string `json:"action"`
|
|
Details string `json:"details"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func handleResellerAudit(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
query := `SELECT id, actor_username, target_username, action, details, created_at
|
|
FROM reseller_audit_log ORDER BY created_at DESC LIMIT 200`
|
|
args := []interface{}{}
|
|
if sess.Role == RoleReseller {
|
|
query = `SELECT id, actor_username, target_username, action, details, created_at
|
|
FROM reseller_audit_log
|
|
WHERE actor_username=$1 OR target_username=$1 OR target_username IN
|
|
(SELECT username FROM admin_users WHERE parent_username=$1 AND role=$2)
|
|
ORDER BY created_at DESC LIMIT 200`
|
|
args = append(args, sess.Username, RoleReseller)
|
|
}
|
|
rows, err := store.db.QueryContext(r.Context(), query, args...)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := make([]ResellerAuditDTO, 0, 64)
|
|
for rows.Next() {
|
|
var item ResellerAuditDTO
|
|
if err := rows.Scan(&item.ID, &item.ActorUsername, &item.TargetUsername, &item.Action, &item.Details, &item.CreatedAt); err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(out)
|
|
}
|
|
}
|
|
|
|
func reserveResellerProvisionCredits(ctx context.Context, store *Store, owner string, cost int, target string) (bool, error) {
|
|
u, ok := adminUsers.get(owner)
|
|
if !ok || normalizeQuotaMode(u.QuotaMode) != QuotaModeCredit {
|
|
return false, nil
|
|
}
|
|
if cost < 1 {
|
|
cost = 1
|
|
}
|
|
tx, err := store.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer tx.Rollback()
|
|
var balance int
|
|
err = tx.QueryRowContext(ctx, `
|
|
UPDATE admin_users SET credit_balance=credit_balance-$1
|
|
WHERE username=$2 AND role=$3 AND quota_mode=$4 AND is_active=TRUE AND credit_balance >= $1
|
|
RETURNING credit_balance`, cost, owner, RoleReseller, QuotaModeCredit).Scan(&balance)
|
|
if err == sql.ErrNoRows {
|
|
return false, errResellerCredits
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_credit_ledger
|
|
(reseller_username, actor_username, delta, balance_after, action, target)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`, owner, owner, -cost, balance, "provision account", target); err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO reseller_audit_log (actor_username, target_username, action, details)
|
|
VALUES ($1,$2,$3,$4)`, owner, owner, "spent credits", fmt.Sprintf("target=%s cost=%d", target, cost)); err != nil {
|
|
return false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return false, err
|
|
}
|
|
copyUser := *u
|
|
copyUser.CreditBalance = balance
|
|
adminUsers.set(©User)
|
|
return true, nil
|
|
}
|
|
|
|
func refundResellerProvisionCredits(ctx context.Context, store *Store, owner string, cost int, target string) {
|
|
if cost < 1 {
|
|
return
|
|
}
|
|
var balance int
|
|
err := store.db.QueryRowContext(ctx, `
|
|
UPDATE admin_users SET credit_balance=credit_balance+$1
|
|
WHERE username=$2 AND quota_mode=$3 RETURNING credit_balance`,
|
|
cost, owner, QuotaModeCredit).Scan(&balance)
|
|
if err != nil {
|
|
log.Printf("refund reseller credits for %s: %v", owner, err)
|
|
return
|
|
}
|
|
_, _ = store.db.ExecContext(ctx, `
|
|
INSERT INTO reseller_credit_ledger
|
|
(reseller_username, actor_username, delta, balance_after, action, target)
|
|
VALUES ($1,$2,$3,$4,$5,$6)`, owner, owner, cost, balance, "provision refund", target)
|
|
if u, ok := adminUsers.get(owner); ok {
|
|
copyUser := *u
|
|
copyUser.CreditBalance = balance
|
|
adminUsers.set(©User)
|
|
}
|
|
}
|
|
|
|
func authorizeResellerProvision(ctx context.Context, store *Store, owner, target string, maxConnections int) (bool, int, error) {
|
|
u, ok := adminUsers.get(owner)
|
|
if !ok || u.Role != RoleReseller {
|
|
return false, 0, fmt.Errorf("reseller account not found")
|
|
}
|
|
if err := adminAccountChainActive(owner); err != nil {
|
|
return false, 0, err
|
|
}
|
|
if maxConnections < 1 {
|
|
return false, 0, errResellerConnLimit
|
|
}
|
|
cost := resellerProvisionCost(maxConnections)
|
|
if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
|
charged, err := reserveResellerProvisionCredits(ctx, store, owner, cost, target)
|
|
return charged, cost, err
|
|
}
|
|
used, err := countOwnedQuotaAcrossManagedServers(ctx, store, owner)
|
|
if err != nil {
|
|
return false, 0, err
|
|
}
|
|
children, err := store.directChildAllocation(ctx, owner, "")
|
|
if err != nil {
|
|
return false, 0, err
|
|
}
|
|
if u.MaxUsers > 0 && used+children+cost > u.MaxUsers {
|
|
return false, 0, errResellerQuota
|
|
}
|
|
return false, 0, nil
|
|
}
|
|
|
|
func authorizeResellerQuotaChange(ctx context.Context, store *Store, owner string, oldMaxConnections, newMaxConnections int) error {
|
|
u, ok := adminUsers.get(owner)
|
|
if !ok || u.Role != RoleReseller {
|
|
return fmt.Errorf("reseller account not found")
|
|
}
|
|
if err := adminAccountChainActive(owner); err != nil {
|
|
return err
|
|
}
|
|
if newMaxConnections < 1 {
|
|
return errResellerConnLimit
|
|
}
|
|
oldCost := resellerProvisionCost(oldMaxConnections)
|
|
newCost := resellerProvisionCost(newMaxConnections)
|
|
if normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
|
if newCost != oldCost {
|
|
return errCreditLimitImmutable
|
|
}
|
|
return nil
|
|
}
|
|
delta := newCost - oldCost
|
|
if delta <= 0 || u.MaxUsers == 0 {
|
|
return nil
|
|
}
|
|
used, err := countOwnedQuotaAcrossManagedServers(ctx, store, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children, err := store.directChildAllocation(ctx, owner, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if used+children+delta > u.MaxUsers {
|
|
return errResellerQuota
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeResellerProvisionError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, errResellerConnLimit) || errors.Is(err, errCreditLimitImmutable) {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, errResellerCredits) || errors.Is(err, errResellerQuota) {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
writeBadGatewayError(w, "verify reseller quota", err)
|
|
}
|
|
|
|
func resellerProvisionExpiry(owner string) string {
|
|
u, ok := adminUsers.get(owner)
|
|
if !ok || normalizeQuotaMode(u.QuotaMode) != QuotaModeCredit {
|
|
return ""
|
|
}
|
|
return time.Now().UTC().AddDate(0, 0, 31).Format(time.RFC3339)
|
|
}
|
|
|
|
func resellerExpiryExtended(existing, requested string) bool {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested == "" || requested == strings.TrimSpace(existing) {
|
|
return false
|
|
}
|
|
requestedTime, err := time.Parse(time.RFC3339, requested)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
existingTime, err := time.Parse(time.RFC3339, strings.TrimSpace(existing))
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return requestedTime.After(existingTime)
|
|
}
|
|
|
|
func resellerTimeExtended(existing *time.Time, requested string) bool {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested == "" {
|
|
return false
|
|
}
|
|
var requestedTime time.Time
|
|
var err error
|
|
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
|
requestedTime, err = time.Parse(layout, requested)
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil || existing == nil {
|
|
return true
|
|
}
|
|
return requestedTime.After(*existing)
|
|
}
|