Files
CLICD/backend/internal/api/subuser.go
T
MengMengCode e306d2d06b first commit
2026-06-05 19:23:28 +08:00

423 lines
12 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
func generateRandomStr(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length]
}
// HandleSubUserCreate creates a sub-user for a specific container
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
ContainerName string `json:"container_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
c := containerByIdentifier(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
containerName := c.Name
// 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 {
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)
}
}
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))
config.SaveConfig()
// Return existing
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Sub-user already exists",
Data: *su,
})
return
}
}
}
// Create new sub-user
username := "user-" + generateRandomStr(8)
password := generateRandomStr(16)
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// 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,
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
config.SaveConfig()
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
}
// HandleSubUserLogin handles sub-user login
func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
// Find sub-user
for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"token": tokenStr,
"username": su.Username,
"container_uuids": containerUUIDs,
},
})
return
}
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
}
// HandleSubUserAccessCode handles access via short code + password (no token in URL)
func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
Code string `json:"code"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
// Find sub-user by access code
for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return
}
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"token": tokenStr,
"username": su.Username,
"container_uuids": containerUUIDs,
},
})
return
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
}
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username,
"container_uuids": containerUUIDs,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
})
tokenStr, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
return tokenStr
}
type subUserAccess struct {
names map[string]bool
uuids map[string]bool
}
func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
claims, ok := claimsFromRequest(r)
if !ok {
return subUserAccess{}, false
}
if _, isSubUser := claims["sub_user"]; !isSubUser {
return subUserAccess{}, false
}
allowed := subUserAccess{
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 {
allowed.uuids[uuid] = true
}
}
}
if containerUUIDs, ok := claims["container_uuids"].([]string); ok {
for _, uuid := range containerUUIDs {
allowed.uuids[uuid] = true
}
}
return allowed, true
}
func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier)
}
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return true
}
c := containerByIdentifier(identifier)
if c == nil {
return false
}
return isContainerAllowed(allowed, c)
}
// HandleAuditLogs returns audit logs
func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
logs := config.AppConfig.AuditLogs
if logs == nil {
logs = []config.AuditLog{}
}
// Return in reverse order (newest first)
reversed := make([]config.AuditLog, len(logs))
for i, l := range logs {
reversed[len(logs)-1-i] = l
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
}
// SubUserMiddleware checks if a request is from a sub-user and restricts container access
func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
next(w, r)
return
}
path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet {
next(w, r)
return
}
if path == "/api/containers" {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return
}
next(w, r)
return
}
if len(path) > len("/api/containers/") {
rest := path[len("/api/containers/"):]
parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0])
if c == nil || !isContainerAllowed(allowed, c) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
action := ""
if len(parts) > 1 {
action = parts[1]
}
if !isSubUserContainerActionAllowed(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
return
}
}
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied"})
return
}
}
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return containers
}
filtered := make([]config.Container, 0, len(containers))
for _, c := range containers {
if isContainerAllowed(allowed, &c) {
filtered = append(filtered, c)
}
}
return filtered
}
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
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])
}
func isSubUserContainerActionAllowed(action string, method string) bool {
if action == "" {
return method == http.MethodGet
}
switch {
case action == "usage" || action == "traffic" || action == "random-port":
return method == http.MethodGet
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
return method == http.MethodPut
default:
return false
}
}
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)
}
}
return uuids
}
func splitPath(path string) []string {
parts := make([]string, 0)
for _, p := range splitBy(path, "/") {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func splitBy(s, sep string) []string {
result := make([]string, 0)
current := ""
for _, c := range s {
if string(c) == sep {
result = append(result, current)
current = ""
} else {
current += string(c)
}
}
result = append(result, current)
return result
}