diff --git a/auth/auth.go b/auth/auth.go index 180470a..7fa108c 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -4,12 +4,10 @@ import ( "fmt" "log" "net/http" - "os" "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/go-ldap/ldap/v3" ) // struct to hold username and password received from post request @@ -35,77 +33,27 @@ func LoginHandler(c *gin.Context) { return } - // LDAP stuff - ldapServer := os.Getenv("LDAP_SERVER") - baseDN := os.Getenv("LDAP_BASE_DN") - bindDN := os.Getenv("LDAP_BIND_DN") - bindPassword := os.Getenv("LDAP_BIND_PASSWORD") - - // for deployment debugging purposes - if ldapServer == "" || baseDN == "" || bindDN == "" || bindPassword == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "LDAP configuration is missing"}) - return - } - - // connect to LDAP server - l, err := ldap.DialURL("ldap://" + ldapServer + ":389") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("LDAP bind to %s failed", ldapServer)}) - return - } - - // make sure connection closes at function return even if error occurs - defer l.Close() - - // First bind as service account - err = l.Bind(bindDN, bindPassword) + // Connect to LDAP + ldapConn, err := ConnectToLDAP() if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid ldap service account"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("LDAP connection failed: %v", err)}) return } + defer ldapConn.Close() - // Define search request - searchRequest := ldap.NewSearchRequest( - baseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(sAMAccountName=%s)", username), - []string{"dn", "memberOf"}, - nil, - ) - - // search for user - sr, err := l.Search(searchRequest) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found in LDAP"}) - return - } - - // handle user not found - if len(sr.Entries) != 1 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found or multiple users found"}) - return - } - - userDN := sr.Entries[0].DN - - // bind as user to verify password - err = l.Bind(userDN, password) + // Authenticate user + _, groups, err := ldapConn.AuthenticateUser(username, password) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } - isAdmin := false + // Check if user is admin + isAdmin := CheckIfAdmin(groups) - // check if user is in "Domain Admins" - groups := sr.Entries[0].GetAttributeValues("memberOf") log.Println("logging user membership: ", groups) for _, group := range groups { log.Println("User is a member of: ", group) - if strings.Contains(strings.ToLower(group), "cn=domain admins") { - isAdmin = true - break - } } // create session @@ -138,8 +86,8 @@ func ProfileHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "message": username, - "isAdmin": isAdmin, + "username": username, + "isAdmin": isAdmin, }) } diff --git a/auth/ldap.go b/auth/ldap.go new file mode 100644 index 0000000..ebeaae0 --- /dev/null +++ b/auth/ldap.go @@ -0,0 +1,185 @@ +package auth + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// LDAPConnection holds the LDAP connection and configuration +type LDAPConnection struct { + conn *ldap.Conn + server string + baseDN string + bindDN string + bindPassword string +} + +// ConnectToLDAP creates a new LDAP connection and returns it +func ConnectToLDAP() (*LDAPConnection, error) { + // LDAP configuration from environment variables + ldapServer := os.Getenv("LDAP_SERVER") + baseDN := os.Getenv("LDAP_BASE_DN") + bindDN := os.Getenv("LDAP_BIND_DN") + bindPassword := os.Getenv("LDAP_BIND_PASSWORD") + + // check LDAP configuration + if ldapServer == "" || baseDN == "" || bindDN == "" || bindPassword == "" { + return nil, fmt.Errorf("LDAP configuration is missing") + } + + // connect to LDAP server + conn, err := ldap.DialURL("ldap://" + ldapServer + ":389") + if err != nil { + return nil, fmt.Errorf("LDAP connection failed: %v", err) + } + + // bind as service account + err = conn.Bind(bindDN, bindPassword) + if err != nil { + conn.Close() + return nil, fmt.Errorf("LDAP service account bind failed: %v", err) + } + + return &LDAPConnection{ + conn: conn, + server: ldapServer, + baseDN: baseDN, + bindDN: bindDN, + bindPassword: bindPassword, + }, nil +} + +// Close closes the LDAP connection +func (lc *LDAPConnection) Close() { + if lc.conn != nil { + lc.conn.Close() + } +} + +// AuthenticateUser authenticates a user against LDAP and returns user info +func (lc *LDAPConnection) AuthenticateUser(username, password string) (string, []string, error) { + // Define search request + searchRequest := ldap.NewSearchRequest( + lc.baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(sAMAccountName=%s)", username), + []string{"dn", "memberOf"}, + nil, + ) + + // search for user + sr, err := lc.conn.Search(searchRequest) + if err != nil { + return "", nil, fmt.Errorf("user not found in LDAP: %v", err) + } + + // handle user not found + if len(sr.Entries) != 1 { + return "", nil, fmt.Errorf("user not found or multiple users found") + } + + userDN := sr.Entries[0].DN + groups := sr.Entries[0].GetAttributeValues("memberOf") + + // bind as user to verify password + err = lc.conn.Bind(userDN, password) + if err != nil { + return "", nil, fmt.Errorf("invalid credentials") + } + + // rebind as service account for further operations + err = lc.conn.Bind(lc.bindDN, lc.bindPassword) + if err != nil { + return "", nil, fmt.Errorf("failed to rebind as service account") + } + + return userDN, groups, nil +} + +// GetAllUsers fetches all users from Active Directory +func (lc *LDAPConnection) GetAllUsers() (*UserResponse, error) { + // search for all users + searchRequest := ldap.NewSearchRequest( + lc.baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(&(objectClass=user)(objectCategory=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))", + []string{"sAMAccountName", "whenCreated", "memberOf"}, + nil, + ) + + // perform search + sr, err := lc.conn.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("LDAP search failed: %v", err) + } + + var userResponse UserResponse + userResponse.Users = make([]UserWithRoles, 0) + + // process each user entry + for _, entry := range sr.Entries { + username := entry.GetAttributeValue("sAMAccountName") + whenCreated := entry.GetAttributeValue("whenCreated") + groups := entry.GetAttributeValues("memberOf") + + // skip if no username + if username == "" { + continue + } + + // parse and format creation date + createdDate := "Unknown" + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + createdDate = parsedTime.Format("2006-01-02 15:04:05") + } + } + + // check if user is admin + isAdmin := false + for _, group := range groups { + if strings.Contains(strings.ToLower(group), "cn=domain admins") || strings.Contains(strings.ToLower(group), "cn=kamino admin") { + isAdmin = true + break + } + } + + // clean up group names (extract CN values) + cleanGroups := make([]string, 0) + for _, group := range groups { + // extract CN from DN format + parts := strings.Split(group, ",") + if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "cn=") { + groupName := strings.TrimPrefix(parts[0], "CN=") + groupName = strings.TrimPrefix(groupName, "cn=") + cleanGroups = append(cleanGroups, groupName) + } + } + + user := UserWithRoles{ + Username: username, + CreatedDate: createdDate, + IsAdmin: isAdmin, + Groups: cleanGroups, + } + + userResponse.Users = append(userResponse.Users, user) + } + + return &userResponse, nil +} + +// CheckIfAdmin checks if a user is in the Domain Admins group +func CheckIfAdmin(groups []string) bool { + for _, group := range groups { + if strings.Contains(strings.ToLower(group), "cn=domain admins") { + return true + } + } + return false +} diff --git a/auth/users.go b/auth/users.go new file mode 100644 index 0000000..734d1ab --- /dev/null +++ b/auth/users.go @@ -0,0 +1,73 @@ +package auth + +import ( + "log" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type UserResponse struct { + Users []UserWithRoles `json:"users"` +} + +type UserWithRoles struct { + Username string `json:"username"` + CreatedDate string `json:"createdDate"` + IsAdmin bool `json:"isAdmin"` + Groups []string `json:"groups"` +} + +// helper function that fetches all users from Active Directory +func buildUserResponse() (*UserResponse, error) { + // Connect to LDAP + ldapConn, err := ConnectToLDAP() + if err != nil { + return nil, err + } + defer ldapConn.Close() + + // Get all users using the LDAP connection + return ldapConn.GetAllUsers() +} + +/* + * ===== ADMIN ENDPOINT ===== + * This function returns a list of + * all users and their roles in Active Directory + */ +func GetUsers(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // make sure user is authenticated and is admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt by user %s", username) + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can see all domain users", + }) + return + } + + // fetch user response + userResponse, err := getAdminUserResponse() + + // if error, return error status + if err != nil { + log.Printf("Failed to fetch user list for admin %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch user list from Active Directory", + "details": err.Error(), + }) + return + } + + log.Printf("Successfully fetched user list for admin %s", username) + c.JSON(http.StatusOK, userResponse) +} + +func getAdminUserResponse() (*UserResponse, error) { + return buildUserResponse() +} diff --git a/main.go b/main.go index 178f46a..03ed0c1 100644 --- a/main.go +++ b/main.go @@ -67,6 +67,9 @@ func main() { // Proxmox Admin Pod endpoints admin.GET("/proxmox/pods/all", cloning.GetPods) + // Active Directory User endpoints + admin.GET("/users", auth.GetUsers) + // get port to run server on via. PC_PORT env variable port := os.Getenv("PC_PORT") if port == "" { diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go index 74cfb67..04e3223 100644 --- a/proxmox/cloning/cloning.go +++ b/proxmox/cloning/cloning.go @@ -466,7 +466,7 @@ func makeCloneRequest(config *proxmox.ProxmoxConfig, vm proxmox.VirtualResource, // finds lowest available POD ID between 1001 - 1255 func nextPodID(config *proxmox.ProxmoxConfig, c *gin.Context) (string, int, error) { - podResponse, err := getPodResponse(config) + podResponse, err := getAdminPodResponse(config) // if error, return error status if err != nil { diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go index 27e8866..441ec65 100644 --- a/proxmox/cloning/pods.go +++ b/proxmox/cloning/pods.go @@ -13,11 +13,57 @@ import ( ) type PodResponse struct { - Pods []Pod `json:"templates"` + Pods []PodWithVMs `json:"pods"` } -type Pod struct { - Name string `json:"name"` +type PodWithVMs struct { + Name string `json:"name"` + VMs []proxmox.VirtualResource `json:"vms"` +} + +// helper function that builds a maps pod names to their VMs based on the provided regex pattern +func buildPodResponse(config *proxmox.ProxmoxConfig, regexPattern string) (*PodResponse, error) { + // get all virtual resources from proxmox + apiResp, err := proxmox.GetVirtualResources(config) + + // if error, return error + if err != nil { + return nil, err + } + + // map pod pools to their VMs + resources := apiResp + podMap := make(map[string]*PodWithVMs) + reg := regexp.MustCompile(regexPattern) + + // first pass: find all pools that are pods + for _, r := range *resources { + if r.Type == "pool" && reg.MatchString(r.ResourcePool) { + name := r.ResourcePool + podMap[name] = &PodWithVMs{ + Name: name, + VMs: []proxmox.VirtualResource{}, + } + } + } + + // second pass: map VMs to their pod pool + for _, r := range *resources { + if r.Type == "qemu" && reg.MatchString(r.ResourcePool) { + name := r.ResourcePool + if pod, ok := podMap[name]; ok { + pod.VMs = append(pod.VMs, r) + } + } + } + + // build response + var podResponse PodResponse + for _, pod := range podMap { + podResponse.Pods = append(podResponse.Pods, *pod) + } + + return &podResponse, nil } /* @@ -63,7 +109,7 @@ func GetPods(c *gin.Context) { var error error // get Pod list and assign response - podResponse, error = getPodResponse(config) + podResponse, error = getAdminPodResponse(config) // if error, return error status if error != nil { @@ -78,31 +124,8 @@ func GetPods(c *gin.Context) { c.JSON(http.StatusOK, podResponse) } -func getPodResponse(config *proxmox.ProxmoxConfig) (*PodResponse, error) { - - // get all virtual resources from proxmox - apiResp, err := proxmox.GetVirtualResources(config) - - // if error, return error - if err != nil { - return nil, err - } - - // Extract pod templates from response, store in templates array - var podResponse PodResponse - for _, r := range *apiResp { - if r.Type == "pool" { - reg, _ := regexp.Compile("1[0-9][0-9][0-9]_.*") - if reg.MatchString(r.ResourcePool) { - var temp Pod - // remove kamino_template_ label when assigning the name to be returned to user - temp.Name = r.ResourcePool - podResponse.Pods = append(podResponse.Pods, temp) - } - } - } - - return &podResponse, nil +func getAdminPodResponse(config *proxmox.ProxmoxConfig) (*PodResponse, error) { + return buildPodResponse(config, `1[0-9]{3}_.*`) } /* @@ -164,28 +187,5 @@ func GetUserPods(c *gin.Context) { } func getUserPodResponse(user string, config *proxmox.ProxmoxConfig) (*PodResponse, error) { - - // get all virtual resources from proxmox - apiResp, err := proxmox.GetVirtualResources(config) - - // if error, return error - if err != nil { - return nil, err - } - - // Extract pod templates from response, store in templates array - var podResponse PodResponse - for _, r := range *apiResp { - if r.Type == "pool" { - reg, _ := regexp.Compile(fmt.Sprintf("1[0-9][0-9][0-9]_.*_%s", user)) - if reg.MatchString(r.ResourcePool) { - var temp Pod - // remove kamino_template_ label when assigning the name to be returned to user - temp.Name = r.ResourcePool - podResponse.Pods = append(podResponse.Pods, temp) - } - } - } - - return &podResponse, nil + return buildPodResponse(config, fmt.Sprintf(`1[0-9]{3}_.*_%s`, user)) } diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index 019f4d1..cc4fe81 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -13,11 +13,13 @@ import ( ) type TemplateResponse struct { - Templates []Template `json:"templates"` + Templates []TemplateWithVMs `json:"templates"` } -type Template struct { - Name string `json:"name"` +type TemplateWithVMs struct { + Name string `json:"name"` + Deployments int `json:"deployments"` + VMs []proxmox.VirtualResource `json:"vms"` } /* @@ -56,49 +58,59 @@ func GetAvailableTemplates(c *gin.Context) { return } - // fetch template reponse - var templateResponse *TemplateResponse - var error error - - // get Template list and assign response - templateResponse, error = getTemplateResponse(config) - - // if error, return error status - if error != nil { + // fetch template response + templateResponse, err := getTemplateResponse(config) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to fetch template list from proxmox cluster", - "details": error, + "details": err, }) return } - log.Printf("Successfully fetched teamplate list for user %s", username) + log.Printf("Successfully fetched template list for user %s", username) c.JSON(http.StatusOK, templateResponse) } func getTemplateResponse(config *proxmox.ProxmoxConfig) (*TemplateResponse, error) { // get all virtual resources from proxmox - apiResp, err := proxmox.GetVirtualResources(config) - - // if error, return error + resources, err := proxmox.GetVirtualResources(config) if err != nil { return nil, err } - // Extract pod templates from response, store in templates array - var templateResponse TemplateResponse - for _, r := range *apiResp { - if r.Type == "pool" { - reg, _ := regexp.Compile("kamino_template_.*") - if reg.MatchString(r.ResourcePool) { - var temp Template - // remove kamino_template_ label when assigning the name to be returned to user - temp.Name = r.ResourcePool[16:] - templateResponse.Templates = append(templateResponse.Templates, temp) + // map template pools to their VMs + templateMap := make(map[string]*TemplateWithVMs) + reg := regexp.MustCompile(`kamino_template_.*`) + + // first pass: find all pools that are templates + for _, r := range *resources { + if r.Type == "pool" && reg.MatchString(r.ResourcePool) { + name := r.ResourcePool[16:] + templateMap[name] = &TemplateWithVMs{ + Name: name, + Deployments: 0, + VMs: []proxmox.VirtualResource{}, + } + } + } + + // second pass: map VMs to their template pool + for _, r := range *resources { + if r.Type == "qemu" && reg.MatchString(r.ResourcePool) { + name := r.ResourcePool[16:] + if template, ok := templateMap[name]; ok { + template.VMs = append(template.VMs, r) } } } + // build response + var templateResponse TemplateResponse + for _, template := range templateMap { + templateResponse.Templates = append(templateResponse.Templates, *template) + } + return &templateResponse, nil }