341 lines
10 KiB
Go
341 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type accountRenewPayload struct {
|
|
Username string `json:"username,omitempty"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
Days int `json:"days,omitempty"`
|
|
ServerID string `json:"server_id,omitempty"`
|
|
}
|
|
|
|
func renewalDays(owner string, requested int) int {
|
|
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
|
return 31
|
|
}
|
|
if requested == 0 {
|
|
return 30
|
|
}
|
|
return requested
|
|
}
|
|
|
|
func renewalExpiry(existing *time.Time, days int) time.Time {
|
|
base := time.Now()
|
|
if existing != nil && existing.After(base) {
|
|
base = *existing
|
|
}
|
|
return base.AddDate(0, 0, days)
|
|
}
|
|
|
|
func jsonInt(value interface{}) int {
|
|
switch value := value.(type) {
|
|
case int:
|
|
return value
|
|
case int64:
|
|
return int(value)
|
|
case float64:
|
|
return int(value)
|
|
case json.Number:
|
|
result, _ := strconv.Atoi(value.String())
|
|
return result
|
|
default:
|
|
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
|
return int(result)
|
|
}
|
|
}
|
|
|
|
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var p accountRenewPayload
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
|
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 := validateAccountUsername(p.Username); err != nil {
|
|
http.Error(w, "invalid username", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.Days < 0 || p.Days > 3650 {
|
|
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
sess := sessionFromCtx(ctx)
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
quotaUnlock := lockResellerQuota(sess.Username)
|
|
defer quotaUnlock()
|
|
}
|
|
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
|
writeManagedServerSelectionError(w, err)
|
|
return
|
|
} else if remote {
|
|
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
|
if infoErr != nil {
|
|
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if !exists {
|
|
http.Error(w, "SSH account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
|
charged, cost := false, 0
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if owner != sess.Username {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
|
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
|
if infoErr != nil {
|
|
writeResellerProvisionError(w, infoErr)
|
|
return
|
|
}
|
|
p.Days = renewalDays(owner, p.Days)
|
|
}
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
|
}
|
|
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
|
return
|
|
}
|
|
}
|
|
p.ServerID = ""
|
|
body, _ := json.Marshal(p)
|
|
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
|
if proxyErr != nil || status < 200 || status >= 300 {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
|
}
|
|
if proxyErr != nil {
|
|
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
|
return
|
|
}
|
|
}
|
|
writeProxyResponse(w, status, data, contentType)
|
|
return
|
|
}
|
|
|
|
state, ok := userMgr.Get(p.Username)
|
|
if !ok {
|
|
http.Error(w, "SSH account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
state.mu.Lock()
|
|
cfg := state.Cfg
|
|
existingExpiry := state.ExpiresAt
|
|
state.mu.Unlock()
|
|
charged, cost := false, 0
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if cfg.OwnerUsername != sess.Username {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
cost = resellerProvisionCost(cfg.MaxConnections)
|
|
var creditErr error
|
|
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
|
if creditErr != nil {
|
|
writeResellerProvisionError(w, creditErr)
|
|
return
|
|
}
|
|
p.Days = renewalDays(sess.Username, p.Days)
|
|
}
|
|
if p.Days == 0 {
|
|
p.Days = 30
|
|
}
|
|
next := renewalExpiry(existingExpiry, p.Days)
|
|
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
|
if err := store.UpsertUser(ctx, cfg); err != nil {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
|
}
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
userMgr.DisconnectUser(p.Username)
|
|
reloadUsersFromDB(ctx, store)
|
|
if sess != nil {
|
|
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
|
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
|
}
|
|
}
|
|
|
|
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var p accountRenewPayload
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&p); err != nil {
|
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
p.UUID = strings.TrimSpace(p.UUID)
|
|
if _, err := parseUUID(p.UUID); err != nil {
|
|
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if p.Days < 0 || p.Days > 3650 {
|
|
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
sess := sessionFromCtx(ctx)
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
quotaUnlock := lockResellerQuota(sess.Username)
|
|
defer quotaUnlock()
|
|
}
|
|
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
|
writeManagedServerSelectionError(w, err)
|
|
return
|
|
} else if remote {
|
|
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
|
if infoErr != nil {
|
|
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if !exists {
|
|
http.Error(w, "Xray account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
|
charged, cost := false, 0
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if owner != sess.Username {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
|
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
|
if infoErr != nil {
|
|
writeResellerProvisionError(w, infoErr)
|
|
return
|
|
}
|
|
p.Days = renewalDays(owner, p.Days)
|
|
}
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
|
}
|
|
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
|
return
|
|
}
|
|
}
|
|
p.ServerID = ""
|
|
body, _ := json.Marshal(p)
|
|
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
|
if proxyErr != nil || status < 200 || status >= 300 {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
|
}
|
|
if proxyErr != nil {
|
|
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
|
return
|
|
}
|
|
}
|
|
writeProxyResponse(w, status, data, contentType)
|
|
return
|
|
}
|
|
|
|
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
|
if err != nil {
|
|
http.Error(w, "Xray account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
charged, cost := false, 0
|
|
if sess != nil && sess.Role == RoleReseller {
|
|
if meta.OwnerUsername != sess.Username {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
cost = resellerProvisionCost(meta.MaxConns)
|
|
var creditErr error
|
|
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
|
if creditErr != nil {
|
|
writeResellerProvisionError(w, creditErr)
|
|
return
|
|
}
|
|
p.Days = renewalDays(sess.Username, p.Days)
|
|
}
|
|
if p.Days == 0 {
|
|
p.Days = 30
|
|
}
|
|
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
|
meta.ExpiresAt = &next
|
|
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
|
if charged {
|
|
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
|
}
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
runtimeWarning := ""
|
|
if meta.OwnerUsername != "" {
|
|
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
|
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
|
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
|
}
|
|
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
|
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
|
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
|
}
|
|
if sess != nil {
|
|
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
|
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
|
}
|
|
}
|
|
|
|
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
|
inbounds, err := xrayMgr.ListInbounds()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, inbound := range inbounds {
|
|
if inbound.Tag != meta.InboundTag {
|
|
continue
|
|
}
|
|
for _, client := range inbound.Clients {
|
|
if client.UUID == meta.UUID {
|
|
return nil
|
|
}
|
|
}
|
|
email := strings.TrimSpace(meta.Email)
|
|
if email == "" {
|
|
email = meta.UUID
|
|
}
|
|
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
|
return err
|
|
}
|
|
xrayMgr.restartIfExternalRunning()
|
|
return nil
|
|
}
|
|
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
|
}
|