mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
修复一些已知问题,优化V6路由分配
This commit is contained in:
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
@@ -11,8 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
@@ -116,6 +116,11 @@ func generateShortID() string {
|
||||
|
||||
// hashKey creates a simple hash for storage (not reversible)
|
||||
func hashKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func legacyHashKey(key string) string {
|
||||
b := make([]byte, 32)
|
||||
for i := range key {
|
||||
b[i%32] ^= key[i]
|
||||
@@ -126,8 +131,10 @@ func hashKey(key string) string {
|
||||
// validateApiKey checks if the given key is valid and IP is allowed
|
||||
func validateApiKey(rawKey, clientIP string) bool {
|
||||
hashed := hashKey(rawKey)
|
||||
legacyHashed := legacyHashKey(rawKey)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if k.KeyHash == hashed {
|
||||
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
||||
if k.IPWhitelist == "" {
|
||||
return true
|
||||
}
|
||||
@@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func apiKeyFromRequest(r *http.Request) string {
|
||||
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidApiKeyRequest(r *http.Request) bool {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" {
|
||||
return false
|
||||
}
|
||||
if !validateApiKey(apiKey, clientIP(r)) {
|
||||
return false
|
||||
}
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
return true
|
||||
}
|
||||
|
||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||
func isIPAllowed(clientIP, whitelist string) bool {
|
||||
clientIP = strings.TrimSpace(clientIP)
|
||||
@@ -211,52 +241,15 @@ func updateApiKeyLastUsed(rawKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check header
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
// Check query param
|
||||
apiKey = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if apiKey == "" {
|
||||
// Check Bearer token (some clients use this)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
apiKey = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
clientIP := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
clientIP = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a short-lived JWT so downstream admin middleware passes
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": config.AppConfig.AdminUser,
|
||||
"api_key": true,
|
||||
"exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
|
||||
// Set cookie for subsequent requests
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clicd_token",
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 300,
|
||||
})
|
||||
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
@@ -100,10 +100,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = forwarded
|
||||
}
|
||||
ip := clientIP(r)
|
||||
ua := r.Header.Get("User-Agent")
|
||||
|
||||
if req.Username != config.AppConfig.AdminUser {
|
||||
@@ -192,7 +189,7 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString := tokenFromRequest(r)
|
||||
if !isValidToken(tokenString) {
|
||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
||||
return
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if cfg.VCPU <= 0 {
|
||||
cfg.VCPU = 1
|
||||
}
|
||||
@@ -316,6 +320,10 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
HandleEnabledImages(w, r)
|
||||
return
|
||||
}
|
||||
templates := lxc.GetTemplates()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
|
||||
}
|
||||
|
||||
@@ -272,6 +272,15 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
}
|
||||
|
||||
func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||
tmpl := lxc.FindTemplate(templateID)
|
||||
if tmpl == nil {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
}
|
||||
|
||||
func ensureImageEnabled(id string) {
|
||||
// If the enabled list is empty, all templates are currently enabled by default.
|
||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -73,7 +74,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
|
||||
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
ticket := webSSHTicketFromRequest(r)
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -114,7 +115,11 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
responseHeader := http.Header{}
|
||||
if protocol := webSSHTicketProtocol(r); protocol != "" {
|
||||
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
log.Printf("WebSSH upgrade failed: %v", err)
|
||||
return
|
||||
@@ -141,7 +146,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(c.SSHPassword),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: containerHostKeyCallback(c),
|
||||
Timeout: 4 * time.Second,
|
||||
}
|
||||
|
||||
@@ -250,6 +255,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("WebSSH disconnected for container %s", containerName)
|
||||
}
|
||||
|
||||
func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
sum := sha256.Sum256(key.Marshal())
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
|
||||
return fmt.Errorf("container SSH host key mismatch")
|
||||
}
|
||||
if c.SSHHostKey == "" {
|
||||
c.SSHHostKey = fingerprint
|
||||
config.SaveConfig()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func webSSHTicketFromRequest(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol[len(prefix):]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSSHTicketProtocol(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
|
||||
@@ -21,6 +21,28 @@ func generateRandomStr(length int) string {
|
||||
return hex.EncodeToString(b)[:length]
|
||||
}
|
||||
|
||||
type subUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||
return subUserResponse{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
Password: password,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSubUserCreate creates a sub-user for a specific container
|
||||
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -47,29 +69,24 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if sub-user already exists for this container
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
su := &config.AppConfig.SubUsers[i]
|
||||
for _, cn := range su.ContainerNames {
|
||||
if cn == containerName {
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if uuid == c.UUID {
|
||||
if su.AccessCode == "" {
|
||||
su.AccessCode = generateRandomStr(8)
|
||||
}
|
||||
if su.PassHash == "" && su.Password != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
password := generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
if su.Password == "" {
|
||||
su.Password = generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
}
|
||||
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
su.Password = ""
|
||||
su.Token = ""
|
||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||
config.SaveConfig()
|
||||
// Return existing
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Sub-user already exists",
|
||||
Data: *su,
|
||||
Message: "Sub-user password rotated",
|
||||
Data: newSubUserResponse(*su, password),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -84,16 +101,12 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// Generate short access code (8 chars, for URL sharing)
|
||||
accessCode := generateRandomStr(8)
|
||||
|
||||
// Generate JWT for sub-user
|
||||
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
|
||||
subUser := config.SubUser{
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
Token: tokenStr,
|
||||
ContainerUUIDs: []string{c.UUID},
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -102,7 +115,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
config.SaveConfig()
|
||||
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: newSubUserResponse(subUser, password)})
|
||||
}
|
||||
|
||||
// HandleSubUserLogin handles sub-user login
|
||||
@@ -126,7 +139,7 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if su.Username == req.Username {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
|
||||
// Generate fresh token
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||
if len(containerUUIDs) == 0 {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
|
||||
return
|
||||
@@ -173,7 +186,7 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||
if len(containerUUIDs) == 0 {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
|
||||
return
|
||||
@@ -224,18 +237,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
names: make(map[string]bool),
|
||||
uuids: make(map[string]bool),
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]interface{}); ok {
|
||||
for _, cn := range containerNames {
|
||||
if name, ok := cn.(string); ok {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]string); ok {
|
||||
for _, name := range containerNames {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, item := range containerUUIDs {
|
||||
if uuid, ok := item.(string); ok {
|
||||
@@ -359,15 +360,27 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||
}
|
||||
filtered := make([]*Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID])
|
||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
||||
}
|
||||
|
||||
func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
@@ -392,16 +405,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func activeSubUserContainerUUIDs(su *config.SubUser) []string {
|
||||
uuids := make([]string, 0, len(su.ContainerUUIDs))
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
if len(uuids) > 0 {
|
||||
return uuids
|
||||
}
|
||||
return subUserContainerUUIDs(su.ContainerNames)
|
||||
}
|
||||
|
||||
func subUserContainerUUIDs(containerNames []string) []string {
|
||||
uuids := make([]string, 0, len(containerNames))
|
||||
for _, name := range containerNames {
|
||||
if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
|
||||
uuids = append(uuids, c.UUID)
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
return uuids
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range splitBy(path, "/") {
|
||||
|
||||
@@ -442,6 +442,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
templateID = c.Template
|
||||
}
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(templateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
@@ -504,6 +508,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user