Beta 1
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user