basic implementation of create/delete pool

This commit is contained in:
2026-03-02 21:45:39 +00:00
parent 75dd027c59
commit 322f233718
16 changed files with 922 additions and 162 deletions

47
app/common/config.go Normal file
View File

@@ -0,0 +1,47 @@
package app
import (
"encoding/json"
"os"
)
type LDAPConfig struct {
LdapURL string `json:"ldapURL"`
StartTLS bool `json:"startTLS"`
BaseDN string `json:"baseDN"`
}
type PVEConfig struct {
URL string `json:"url"`
}
type RealmConfig struct {
Handler string `json:"handler"`
}
type Config struct {
ListenPort int `json:"listenPort"`
SessionCookieName string `json:"sessionCookieName"`
SessionCookie struct {
Path string `json:"path"`
HttpOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
MaxAge int `json:"maxAge"`
}
LDAP LDAPConfig `json:"ldap"`
PVE PVEConfig `json:"pve"`
Realms map[string]RealmConfig `json:"realms"`
}
func GetConfig(configPath string) (Config, error) {
content, err := os.ReadFile(configPath)
if err != nil {
return Config{}, err
}
var config Config
err = json.Unmarshal(content, &config)
if err != nil {
return Config{}, err
}
return config, nil
}

7
app/common/schema.go Normal file
View File

@@ -0,0 +1,7 @@
package app
type Login struct { // login body struct
UsernameRaw string `form:"username" binding:"required"`
Username Username
Password string `form:"password" binding:"required"`
}

111
app/common/types.go Normal file
View File

@@ -0,0 +1,111 @@
package app
type BackendClient interface {
BindUser(username string, password string) error
//GetAllUsers() ([]User, int, error)
GetUser(username string) (User, int, error)
AddUser(username string, user User) (int, error)
ModUser(username string, user User) (int, error)
DelUser(username string) (int, error)
//GetAllGroups() ([]Group, int, error)
GetGroup(groupname string) (Group, int, error)
AddGroup(groupname string, group Group) (int, error)
ModGroup(groupname string, group Group) (int, error)
DelGroup(groupname string) (int, error)
AddUserToGroup(username string, groupname string)
RemoveUserFromGroup(username string, groupname string)
}
type Pool struct {
PoolID string `json:"poolid"`
Path string `json:"-"` // typically /pool/poolid from proxmox, only used internally
Groups []Group `json:"groups"`
Resources map[string]any `json:"resources"`
Cluster Cluster `json:"cluster"`
Templates Templates `json:"templates"`
}
type Groupname struct { // proxmox typically formats as gid-realm for non pve realms
GroupID string `json:"gid"`
Realm string `json:"realm"`
}
type Group struct {
Groupname Groupname `json:"groupname"`
Handler string `json:"-"`
Role string `json:"role"`
Users []User `json:"users"`
}
type Username struct { // ie userid@realm
UserID string `json:"uid"`
Realm string `json:"realm"`
}
type User struct {
Username Username `json:"username"`
Handler string `json:"-"`
CN string `json:"cn"` // aka first name
SN string `json:"sn"` // aka last name
Mail string `json:"mail"`
Password string `json:"password"` // only used for POST requests
}
type Cluster struct {
Nodes map[string]bool `json:"nodes"`
VMID VMID `json:"vmid"`
//Pools map[string]bool `json:"pools"`
Backups Backups `json:"backups"`
}
type VMID struct {
Min int `json:"min"`
MAx int `json:"max"`
}
type Backups struct {
Max int `json:"max"`
}
type Templates struct {
Instances struct {
LXC map[string]ResourceTemplate `json:"lxc"`
QEMU map[string]ResourceTemplate `json:"qemu"`
} `json:"instances"`
}
type SimpleResource struct {
Limits struct {
Global SimpleLimit `json:"global"`
Nodes map[string]SimpleLimit `json:"nodes"`
} `json:"limits"`
}
type SimpleLimit struct {
Max int `json:"max"`
}
type MatchResource struct {
Limits struct {
Global []MatchLimit `json:"global"`
Nodes map[string][]MatchLimit `json:"nodes"`
} `json:"limits"`
}
type MatchLimit struct {
Match string `json:"match"`
Name string `json:"name"`
Max int `json:"max"`
}
type ResourceTemplate struct {
Value string `json:"value"`
Resource struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
Amount int `json:"amount"`
} `json:"resource"`
}

42
app/common/utils.go Normal file
View File

@@ -0,0 +1,42 @@
package app
import (
"fmt"
"strings"
)
func ParseGroupname(groupname string) (Groupname, error) {
g := Groupname{}
x := strings.Split(groupname, "-")
if len(x) == 1 {
g.GroupID = groupname
g.Realm = "pve"
return g, nil
} else if len(x) == 2 {
g.GroupID = x[0]
g.Realm = x[1]
return g, nil
} else {
return g, fmt.Errorf("groupid did not follow the format <groupid> or <groupid>-<realm>")
}
}
func ParseUsername(username string) (Username, error) {
u := Username{}
x := strings.Split(username, "@")
if len(x) == 2 {
u.UserID = x[0]
u.Realm = x[1]
return u, nil
} else {
return u, fmt.Errorf("userid did not follow the format <userid>@<realm>")
}
}
func (g Groupname) ToString() string {
return fmt.Sprintf("%s-%s", g.GroupID, g.Realm)
}
func (u Username) ToString() string {
return fmt.Sprintf("%s-%s", u.UserID, u.Realm)
}

262
app/ldap/ldap.go Normal file
View File

@@ -0,0 +1,262 @@
package ldap
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/go-ldap/ldap/v3"
common "user-manager-api/app/common"
)
// LDAPClient wrapper struct containing the connection, baseDN, peopleDN, and groupsDN
type LDAPClient struct {
client *ldap.Conn
basedn string
peopledn string
groupsdn string
}
// returns a new LDAPClient from the config
func NewClientFromCredentials(config common.LDAPConfig, username common.Username, password string) (*LDAPClient, int, error) {
LDAPConn, err := ldap.DialURL(config.LdapURL)
if err != nil {
return nil, http.StatusInternalServerError, err
}
if config.StartTLS {
err = LDAPConn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, http.StatusInternalServerError, err
}
}
ldap := LDAPClient{
client: LDAPConn,
basedn: config.BaseDN,
peopledn: "ou=people," + config.BaseDN,
groupsdn: "ou=groups," + config.BaseDN,
}
userdn := fmt.Sprintf("uid=%s,%s", username.UserID, ldap.peopledn)
err = ldap.client.Bind(userdn, password)
if err != nil {
return nil, http.StatusUnauthorized, err
} else {
return &ldap, http.StatusOK, nil
}
}
func (l LDAPClient) GetUser(username common.Username) (common.User, int, error) {
user := common.User{}
searchRequest := ldap.NewSearchRequest( // setup search for user by uid
fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn), // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=inetOrgPerson))", // The filter to apply
[]string{"dn", "cn", "sn", "mail", "uid", "memberOf"}, // A list attributes to retrieve
nil,
)
searchResponse, err := l.client.Search(searchRequest) // perform search
if err != nil {
return user, http.StatusBadRequest, err
}
entry := searchResponse.Entries[0]
user = LDAPEntryToUser(entry)
return user, http.StatusOK, nil
}
func (l LDAPClient) NewUser(username common.Username, user common.User) (int, error) {
if user.CN == "" || user.SN == "" || user.Password == "" || user.Mail == "" {
return http.StatusBadRequest, ldap.NewError(
ldap.LDAPResultUnwillingToPerform,
errors.New("missing one of required fields: cn, sn, mail, userpassword"),
)
}
addRequest := ldap.NewAddRequest(
fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn), // DN
nil, // controls
)
addRequest.Attribute("sn", []string{user.SN})
addRequest.Attribute("cn", []string{user.CN})
addRequest.Attribute("mail", []string{user.Mail})
addRequest.Attribute("userPassword", []string{user.Password})
addRequest.Attribute("objectClass", []string{"inetOrgPerson"})
err := l.client.Add(addRequest)
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) ModUser(username common.Username, user common.User) (int, error) {
if user.CN == "" && user.SN == "" && user.Password == "" && user.Mail == "" {
return http.StatusBadRequest, ldap.NewError(
ldap.LDAPResultUnwillingToPerform,
errors.New("requires one of fields: cn, sn, mail, userpassword"),
)
}
modifyRequest := ldap.NewModifyRequest(
fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn),
nil,
)
if user.CN != "" {
modifyRequest.Replace("cn", []string{user.CN})
}
if user.SN != "" {
modifyRequest.Replace("sn", []string{user.SN})
}
if user.Mail != "" {
modifyRequest.Replace("mail", []string{user.Mail})
}
if user.Password != "" {
modifyRequest.Replace("userPassword", []string{user.Password})
}
err := l.client.Modify(modifyRequest)
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) DelUser(username common.Username) (int, error) {
userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn)
// assumes that olcMemberOfRefint=true updates member attributes of referenced groups
deleteUserRequest := ldap.NewDelRequest( // setup delete request
userDN,
nil,
)
err := l.client.Del(deleteUserRequest) // delete user
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) GetGroup(groupname common.Groupname) (common.Group, int, error) {
group := common.Group{}
searchRequest := ldap.NewSearchRequest( // setup search for user by uid
fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn), // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(objectClass=groupOfNames))", // The filter to apply
[]string{"cn", "member"}, // A list attributes to retrieve
nil,
)
searchResponse, err := l.client.Search(searchRequest) // perform search
if err != nil {
return group, http.StatusBadRequest, err
}
entry := searchResponse.Entries[0]
group = LDAPEntryToGroup(entry)
return group, http.StatusOK, nil
}
func (l LDAPClient) NewGroup(groupname common.Groupname) (int, error) {
addRequest := ldap.NewAddRequest(
fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn), // DN
nil, // controls
)
addRequest.Attribute("cn", []string{groupname.GroupID})
addRequest.Attribute("member", []string{""})
addRequest.Attribute("objectClass", []string{"groupOfNames"})
err := l.client.Add(addRequest)
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) ModGroup(groupname common.Groupname, group common.Group) (int, error) {
modifyRequest := ldap.NewModifyRequest(
fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn),
nil,
)
modifyRequest.Replace("cn", []string{groupname.GroupID})
err := l.client.Modify(modifyRequest)
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) DelGroup(groupname common.Groupname) (int, error) {
groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn)
// assumes that memberOf overlay will automatically update referenced memberOf attributes
deleteGroupRequest := ldap.NewDelRequest( // setup delete request
groupDN,
nil,
)
err := l.client.Del(deleteGroupRequest) // delete group
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) AddUserToGroup(username common.Username, groupname common.Groupname) (int, error) {
userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn)
groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn)
modifyRequest := ldap.NewModifyRequest( // modify group member value
groupDN,
nil,
)
modifyRequest.Add("member", []string{userDN}) // add user to group member attribute
err := l.client.Modify(modifyRequest) // modify group
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}
func (l LDAPClient) DelUserFromGroup(username common.Username, groupname common.Groupname) (int, error) {
userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn)
groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn)
modifyRequest := ldap.NewModifyRequest( // modify group member value
groupDN,
nil,
)
modifyRequest.Delete("member", []string{userDN}) // remove user from group member attribute
err := l.client.Modify(modifyRequest) // modify group
if err != nil {
return http.StatusBadRequest, err
}
return http.StatusOK, nil
}

39
app/ldap/utils.go Normal file
View File

@@ -0,0 +1,39 @@
package ldap
import (
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
common "user-manager-api/app/common"
)
func LDAPEntryToUser(entry *ldap.Entry) common.User {
return common.User{
CN: entry.GetAttributeValue("cn"),
SN: entry.GetAttributeValue("sn"),
Mail: entry.GetAttributeValue("mail"),
}
}
func LDAPEntryToGroup(entry *ldap.Entry) common.Group {
return common.Group{}
}
func ParseLDAPError(err error) gin.H {
if err != nil {
LDAPerr := err.(*ldap.Error)
return gin.H{
"ok": false,
"code": LDAPerr.ResultCode,
"result": ldap.LDAPResultCodeMap[LDAPerr.ResultCode],
"message": LDAPerr.Err.Error(),
}
} else {
return gin.H{
"ok": true,
"code": 200,
"result": "OK",
"message": "",
}
}
}

166
app/main.go Normal file
View File

@@ -0,0 +1,166 @@
package app
import (
"crypto/rand"
"log"
"net/http"
"strconv"
common "user-manager-api/app/common"
ldap "user-manager-api/app/ldap"
"user-manager-api/app/pve"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
uuid "github.com/nu7hatch/gouuid"
)
var Version = "0.0.1"
var Config common.Config
var UserSessions map[string]*Backends
func Run(configPath *string) {
// load config values
Config, err := common.GetConfig(*configPath)
if err != nil {
log.Fatalf("Error when reading config file: %s\n", err)
}
log.Printf("Read in config from %s\n", *configPath)
// setup router
router := SetupAPI(&Config)
// make global session map
UserSessions = make(map[string]*Backends)
router.POST("/ticket", func(c *gin.Context) {
body := common.Login{}
if err := c.ShouldBind(&body); err != nil { // bad request from binding
c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
return
}
// attempt to parse username
body.Username, err = common.ParseUsername(body.UsernameRaw)
if err != nil { // username format incorrect
c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
return
}
// bind proxmox backend
newPVEClient, code, err := pve.NewClientFromCredentials(Config.PVE, body.Username, body.Password)
if err != nil { // pve client failed to bind
c.JSON(code, gin.H{"auth": false, "error": err.Error()})
return
}
// bind ldap backend
newLDAPClient, code, err := ldap.NewClientFromCredentials(Config.LDAP, body.Username, body.Password)
if err != nil { // ldap client failed to bind
c.JSON(code, gin.H{"auth": false, "error": err.Error()})
return
}
//err = newLDAPClient.BindUser(body.Username, body.Password)
//if err != nil { // failed to authenticate, return error
// c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()})
// return
//}
// todo allow ldap backed to fail if user is not using an ldap backend
// successful binding at this point
// create new session
session := sessions.Default(c)
// create (hopefully) safe uuid to map to ldap session
uuid, _ := uuid.NewV4()
// set uuid mapping in session
session.Set("SessionUUID", uuid.String())
// set uuid mapping in LDAPSessions
UserSessions[uuid.String()] = &Backends{pve: newPVEClient, ldap: newLDAPClient}
// save the session
session.Save()
// return successful auth
c.JSON(http.StatusOK, gin.H{"auth": true})
})
router.DELETE("/ticket", func(c *gin.Context) {
session := sessions.Default(c)
SessionUUID := session.Get("SessionUUID")
if SessionUUID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"auth": false})
return
}
uuid := SessionUUID.(string)
delete(UserSessions, uuid)
session.Options(sessions.Options{MaxAge: -1}) // set max age to -1 so it is deleted
session.Save()
c.JSON(http.StatusUnauthorized, gin.H{"auth": false})
})
router.GET("/version", func(c *gin.Context) {
c.JSON(200, gin.H{"version": Version})
})
router.POST("/pool/:poolid", func(c *gin.Context) {
poolid, _ := c.Params.Get("poolid")
backends, code, err := GetBackendsFromContext(c)
if err != nil {
c.JSON(code, gin.H{"error": err.Error()})
}
code, err = NewPool(backends, poolid)
if err != nil {
c.JSON(code, gin.H{"error": err.Error()})
} else {
c.Status(200)
}
})
router.DELETE("/pool/:poolid", func(c *gin.Context) {
poolid, _ := c.Params.Get("poolid")
backends, code, err := GetBackendsFromContext(c)
if err != nil {
c.JSON(code, gin.H{"error": err.Error()})
}
code, err = DelPool(backends, poolid)
if err != nil {
c.JSON(code, gin.H{"error": err.Error()})
} else {
c.Status(200)
}
})
log.Printf("Starting User Manager API on port %s\n", strconv.Itoa(Config.ListenPort))
err = router.Run("0.0.0.0:" + strconv.Itoa(Config.ListenPort))
if err != nil {
log.Fatalf("Error starting router: %s", err.Error())
}
}
func SetupAPI(config *common.Config) *gin.Engine {
secretKey := make([]byte, 256)
n, err := rand.Read(secretKey)
if err != nil {
log.Fatalf("Error when generating session secret key: %s\n", err.Error())
}
log.Printf("Generated session secret key of length %d\n", n)
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
store := cookie.NewStore(secretKey)
store.Options(sessions.Options{
Path: config.SessionCookie.Path,
HttpOnly: config.SessionCookie.HttpOnly,
Secure: config.SessionCookie.Secure,
MaxAge: config.SessionCookie.MaxAge,
})
router.Use(sessions.Sessions(config.SessionCookieName, store))
log.Printf("Started API router and cookie store (Name: %s Params: %+v)\n", config.SessionCookieName, config.SessionCookie)
return router
}

38
app/operations.go Normal file
View File

@@ -0,0 +1,38 @@
package app
import (
common "user-manager-api/app/common"
)
func NewPool(backends *Backends, poolname string) (int, error) {
return backends.pve.NewPool(poolname)
}
func DelPool(backends *Backends, poolname string) (int, error) {
return backends.pve.DelPool(poolname)
}
func NewGroup(backends *Backends, groupname common.Groupname) (int, error) {
handler := Config.Realms[groupname.Realm].Handler
switch handler {
case "pve":
return backends.pve.NewGroup(groupname)
case "ldap":
backends.ldap.NewGroup(groupname)
//pve sync
return 200, nil
}
return 200, nil
}
func DelGroup(backends *Backends, groupname common.Groupname) (int, error) {
handler := Config.Realms[groupname.Realm].Handler
switch handler {
case "pve":
return backends.pve.DelGroup(groupname)
case "ldap":
backends.ldap.DelGroup(groupname)
//pve sync
return 200, nil
}
return 200, nil
}

117
app/pve/pve.go Normal file
View File

@@ -0,0 +1,117 @@
package pve
import (
"context"
"crypto/tls"
"net/http"
common "user-manager-api/app/common"
"github.com/luthermonson/go-proxmox"
)
type ProxmoxClient struct {
client *proxmox.Client
}
func NewClientFromCredentials(config common.PVEConfig, username common.Username, password string) (*ProxmoxClient, int, error) {
HTTPClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
client := proxmox.NewClient(config.URL,
proxmox.WithHTTPClient(&HTTPClient),
proxmox.WithCredentials(&proxmox.Credentials{Username: username.ToString(), Password: password}),
)
// todo this should return an error code if the binding failed (ie fetch version to check if the auth was actually ok)
return &ProxmoxClient{client: client}, http.StatusOK, nil
}
func (pve ProxmoxClient) SyncRealms() (int, error) {
domains, err := pve.client.Domains(context.Background())
if proxmox.IsNotAuthorized(err) {
return 401, err
} else if err != nil {
return 500, err
}
for _, domain := range domains {
if domain.Type != "pam" && domain.Type != "pve" { // pam and pve are not external realm types that require sync
err := domain.Sync(context.Background(), proxmox.DomainSyncOptions{
DryRun: false, // we want to make modifications
EnableNew: true, // allow new users and groups
Scope: "both", // allow new users and groups
RemoveVanished: "acl;entry;properties", // remove deleted objects from ACL, entry in pve, and remove properties (probably not necessary)
})
if proxmox.IsNotAuthorized(err) {
return 401, err
} else if err != nil {
return 500, err
}
}
}
return 200, nil
}
func (pve ProxmoxClient) NewPool(poolname string) (int, error) {
err := pve.client.NewPool(context.Background(), poolname, "")
if proxmox.IsNotAuthorized(err) {
return 401, err
} else if err != nil {
return 500, err
} else {
return 200, nil
}
}
func (pve ProxmoxClient) DelPool(poolname string) (int, error) {
pvepool, err := pve.client.Pool(context.Background(), poolname)
if proxmox.IsNotFound(err) { // errors if pool does not exist
return 404, err
} else if err != nil {
return 500, err
}
err = pvepool.Delete(context.Background())
if proxmox.IsNotAuthorized(err) { // not authorized to delete
return 401, err
} else if err != nil {
return 500, err
} else {
return 200, nil
}
}
func (pve ProxmoxClient) NewGroup(groupname common.Groupname) (int, error) {
err := pve.client.NewGroup(context.Background(), groupname.ToString(), "")
if proxmox.IsNotAuthorized(err) {
return 401, err
} else if err != nil {
return 500, err
} else {
return 200, nil
}
}
func (pve ProxmoxClient) DelGroup(groupname common.Groupname) (int, error) {
pvegroup, err := pve.client.Group(context.Background(), groupname.ToString())
if proxmox.IsNotFound(err) { // errors if group does not exist
return 404, err
} else if err != nil {
return 500, err
}
err = pvegroup.Delete(context.Background())
if proxmox.IsNotAuthorized(err) { // not authorized to delete
return 401, err
} else if err != nil {
return 500, err
} else {
return 200, nil
}
}

55
app/utils.go Normal file
View File

@@ -0,0 +1,55 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"os"
ldap "user-manager-api/app/ldap"
"user-manager-api/app/pve"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
common "user-manager-api/app/common"
)
type Backends struct {
pve *pve.ProxmoxClient
ldap *ldap.LDAPClient
}
func GetBackendsFromContext(c *gin.Context) (*Backends, int, error) {
session := sessions.Default(c)
SessionUUID := session.Get("SessionUUID")
if SessionUUID == nil {
return nil, http.StatusUnauthorized, fmt.Errorf("No auth session found")
}
uuid := SessionUUID.(string)
usersession := UserSessions[uuid]
return usersession, http.StatusOK, nil
}
func LoadLocaldb(dbPath string) (map[string]common.User, error) {
users := map[string]common.User{}
content, err := os.ReadFile(dbPath)
if err != nil {
//log.Fatal("Error when opening file: ", err)
return users, err
}
err = json.Unmarshal(content, &users)
if err != nil {
//log.Fatal("Error during Unmarshal(): ", err)
return users, err
}
return users, nil
}
func SaveLocaldb(configPath string, users map[string]common.User) error {
json, err := json.Marshal(users)
if err != nil {
return err
}
err = os.WriteFile(configPath, []byte(json), 0644)
return err
}