Compare commits

..

10 Commits
v1.0.4 ... main

Author SHA1 Message Date
222864868d update README 2024-11-15 00:47:29 +00:00
33566572fb generate session secret key randomly each application start,
bump app version 1.0.6
2024-11-15 00:35:57 +00:00
7da5c22313 update go.mod 2024-11-14 07:26:45 +00:00
bf80945168 rename make testsd target to test 2024-10-24 18:08:43 +00:00
e18737c043 cleanup gitignore 2024-10-23 22:46:43 +00:00
162eda70b9 fix make PHONY targets 2024-10-23 18:58:06 +00:00
0d1dd540c0 update go.mod 2024-10-21 19:56:01 +00:00
b8b0504a70 add unit tests for various utility functions,
add integration test for LDAPClient,
add aiutomatic openldap configuration for testing through make,
add make targets for tests
improve make targets for build/clean,
update README with build and test instructions
2024-10-19 04:16:17 +00:00
99242b70a0 add starttls support,
add starttls option to config
2024-10-18 04:38:26 +00:00
fd84f9a991 Update README.md 2024-10-16 05:03:58 +00:00
17 changed files with 1669 additions and 30 deletions

4
.gitignore vendored
View File

@ -1,3 +1,3 @@
**/go.sum go.sum
**/config.json
dist/* dist/*
**/config.json

View File

@ -1,9 +1,23 @@
build: clean .PHONY: build test clean dev-init dev-reinit
CGO_ENABLED=0 go build -ldflags="-s -w" -o dist/ .
test: clean build: clean
go run . @echo "======================== Building Binary ======================="
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
test: dev-reinit
@echo "======================== Running Tests ========================="
go test -v -cover -coverpkg=./app/ -coverprofile coverage ./test/
@echo "======================= Coverage Report ========================"
go tool cover -func=coverage
@rm -f coverage
clean: clean:
@echo "======================== Cleaning Project ======================"
go clean go clean
rm -f dist/* rm -f dist/*
dev-init:
@cd scripts; make dev-init
dev-reinit:
@cd scripts; make dev-reinit

View File

@ -28,9 +28,33 @@ ProxmoxAAS LDAP provides a simple API for managing users and groups in a simplif
### Installation ### Installation
1. Download `proxmoxaas-ldap` binary and `template.config.json` file from [releases](releases) 1. Download `proxmoxaas-ldap` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
2. Rename `template.config.json` to `config.json` and modify: 2. Rename `template.config.json` to `config.json` and modify:
- listenPort: port for PAAS-LDAP to bind and listen on
- ldapURL: url to the ldap server ie. `ldap://ldap.domain.net` - ldapURL: url to the ldap server ie. `ldap://ldap.domain.net`
- baseDN: base DN ie. `dc=domain,dc=net` - startTLS: true if backend LDAP supports StartTLS
- sessionSecretKey: random value used to randomize cookie values, replace with any sufficiently large random string - basedn: base DN ie. `dc=domain,dc=net`
- sessionCookieName: name of the session cookie
- sessionCookie: specific cookie properties
- path: cookie path
- httpOnly: cookie http-only
- secure: cookie secure
- maxAge: cookie max-age
3. Run the binary 3. Run the binary
## Building and Testing from Source
Building requires the go toolchain. Testing requires the go toolchain, make, and apt. Currently only supports Debian.
### Building from Source
1. Clone the repository
2. Run `go get` to get requirements
3. Run `make` to build the binary
### Testing Source
1. Clone the repository
2. Run `go get` to get requirements
3. Run `make dev-init` to install test requirements including openldap (slapd), ldap-utils, debconf-utils
4. Run `make test` to run all tests

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"crypto/rand"
"encoding/gob" "encoding/gob"
"flag" "flag"
"log" "log"
@ -15,6 +16,7 @@ import (
) )
var LDAPSessions map[string]*LDAPClient var LDAPSessions map[string]*LDAPClient
var AppVersion = "1.0.6"
var APIVersion = "1.0.4" var APIVersion = "1.0.4"
func Run() { func Run() {
@ -27,13 +29,20 @@ func Run() {
config, err := GetConfig(*configPath) config, err := GetConfig(*configPath)
if err != nil { if err != nil {
log.Fatal("Error when reading config file: ", err) log.Fatalf("Error when reading config file: %s\n", err)
} }
log.Printf("Read in config from %s\n", *configPath) log.Printf("Read in config from %s\n", *configPath)
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) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
store := cookie.NewStore([]byte(config.SessionSecretKey)) store := cookie.NewStore(secretKey)
store.Options(sessions.Options{ store.Options(sessions.Options{
Path: config.SessionCookie.Path, Path: config.SessionCookie.Path,
HttpOnly: config.SessionCookie.HttpOnly, HttpOnly: config.SessionCookie.HttpOnly,
@ -47,7 +56,7 @@ func Run() {
LDAPSessions = make(map[string]*LDAPClient) LDAPSessions = make(map[string]*LDAPClient)
router.GET("/version", func(c *gin.Context) { router.GET("/version", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"version": APIVersion}) c.JSON(http.StatusOK, gin.H{"version": APIVersion, "app-version": AppVersion})
}) })
router.POST("/ticket", func(c *gin.Context) { router.POST("/ticket", func(c *gin.Context) {
@ -93,7 +102,7 @@ func Run() {
uuid := SessionUUID.(string) uuid := SessionUUID.(string)
delete(LDAPSessions, uuid) delete(LDAPSessions, uuid)
session.Options(sessions.Options{MaxAge: -1}) // set max age to -1 so it is deleted session.Options(sessions.Options{MaxAge: -1}) // set max age to -1 so it is deleted
_ = session.Save() session.Save()
c.JSON(http.StatusUnauthorized, gin.H{"auth": false}) c.JSON(http.StatusUnauthorized, gin.H{"auth": false})
}) })
@ -309,5 +318,8 @@ func Run() {
log.Printf("Starting LDAP API on port %s\n", strconv.Itoa(config.ListenPort)) log.Printf("Starting LDAP API on port %s\n", strconv.Itoa(config.ListenPort))
router.Run("0.0.0.0:" + 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())
}
} }

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -20,6 +21,17 @@ type LDAPClient struct {
// returns a new LDAPClient from the config // returns a new LDAPClient from the config
func NewLDAPClient(config Config) (*LDAPClient, error) { func NewLDAPClient(config Config) (*LDAPClient, error) {
LDAPConn, err := ldap.DialURL(config.LdapURL) LDAPConn, err := ldap.DialURL(config.LdapURL)
if err != nil {
return nil, err
}
if config.StartTLS {
err = LDAPConn.StartTLS(&tls.Config{InsecureSkipVerify: true})
if err != nil {
return nil, err
}
}
return &LDAPClient{ return &LDAPClient{
client: LDAPConn, client: LDAPConn,
basedn: config.BaseDN, basedn: config.BaseDN,

View File

@ -11,8 +11,8 @@ import (
type Config struct { type Config struct {
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
LdapURL string `json:"ldapURL"` LdapURL string `json:"ldapURL"`
StartTLS bool `json:"startTLS"`
BaseDN string `json:"baseDN"` BaseDN string `json:"baseDN"`
SessionSecretKey string `json:"sessionSecretKey"`
SessionCookieName string `json:"sessionCookieName"` SessionCookieName string `json:"sessionCookieName"`
SessionCookie struct { SessionCookie struct {
Path string `json:"path"` Path string `json:"path"`

View File

@ -1,8 +1,8 @@
{ {
"listenPort": 80, "listenPort": 80,
"ldapURL": "ldap://localhost", "ldapURL": "ldap://localhost",
"startTLS": true,
"basedn": "dc=example,dc=com", "basedn": "dc=example,dc=com",
"sessionSecretKey": "super secret key",
"sessionCookieName": "PAASLDAPAuthTicket", "sessionCookieName": "PAASLDAPAuthTicket",
"sessionCookie": { "sessionCookie": {
"path": "/", "path": "/",

21
go.mod
View File

@ -1,8 +1,6 @@
module proxmoxaas-ldap module proxmoxaas-ldap
go 1.23 go 1.23.2
toolchain go1.23.2
require ( require (
github.com/gin-contrib/sessions v1.0.1 github.com/gin-contrib/sessions v1.0.1
@ -13,8 +11,8 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.3 // indirect github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect
@ -30,7 +28,8 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/knz/go-libedit v1.10.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@ -38,11 +37,11 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.11.0 // indirect golang.org/x/arch v0.12.0 // indirect
golang.org/x/crypto v0.28.0 // indirect golang.org/x/crypto v0.29.0 // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

18
scripts/Makefile Normal file
View File

@ -0,0 +1,18 @@
.PHONY: prerequisites dev-init dev-reinit
prerequisites:
@echo "=================== Installing Prerequisites ==================="
apt install debconf-utils slapd ldap-utils sudo gettext
git clone https://git.tronnet.net/tronnet/open-ldap-setup
cd open-ldap-setup/; bash gencert.sh < ../gencert.conf;
rm -rf open-ldap-setup/
dev-init: prerequisites dev-reinit
dev-reinit:
@echo "====================== Initializing Slapd ======================"
cat debconf-slapd.conf | debconf-set-selections
DEBIAN_FRONTEND=noninteractive dpkg-reconfigure slapd
git clone https://git.tronnet.net/tronnet/open-ldap-setup
cd open-ldap-setup/; bash setup.sh < ../setup.conf;
rm -rf open-ldap-setup/

View File

@ -0,0 +1,16 @@
slapd slapd/password1 password admin
slapd slapd/internal/adminpw password admin
slapd slapd/internal/generated_adminpw password admin
slapd slapd/password2 password admin
slapd slapd/unsafe_selfwrite_acl note
slapd slapd/purge_database boolean true
slapd slapd/domain string test.paasldap
slapd slapd/ppolicy_schema_needs_update select abort installation
slapd slapd/invalid_config boolean true
slapd slapd/move_old_database boolean true
slapd slapd/backend select MDB
slapd shared/organization string paasldap
slapd slapd/dump_database_destdir string /var/backups/slapd-VERSION
slapd slapd/no_configuration boolean false
slapd slapd/dump_database select when needed
slapd slapd/password_mismatch note

2
scripts/gencert.conf Normal file
View File

@ -0,0 +1,2 @@
paasldap
localhost

10
scripts/setup.conf Normal file
View File

@ -0,0 +1,10 @@
dc=test,dc=paasldap
adminuser
adminuser@test.paasldap
admin
user
admin123
admin123
/etc/ssl/certs/ldap-ca-cert.pem
/etc/ldap/ldap-server-cert.pem
/etc/ldap/ldap-server-key.pem

1
test/bad_config.json Normal file
View File

@ -0,0 +1 @@
{,,}

1237
test/integration_test.go Normal file

File diff suppressed because it is too large Load Diff

13
test/test_config.json Normal file
View File

@ -0,0 +1,13 @@
{
"listenPort": 80,
"ldapURL": "ldap://localhost",
"startTLS": true,
"basedn": "dc=test,dc=paasldap",
"sessionCookieName": "PAASLDAPAuthTicket",
"sessionCookie": {
"path": "/",
"httpOnly": true,
"secure": false,
"maxAge": 7200
}
}

156
test/test_utils.go Normal file
View File

@ -0,0 +1,156 @@
package tests
import (
"fmt"
"math/rand"
"net/http"
"proxmoxaas-ldap/app"
"reflect"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)
func RandInt(min int, max int) int {
return rand.Intn(max+1-min) + min
}
func RandString(n int) string {
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
// random ldap style DN
func RandDN(n int) string {
return fmt.Sprintf("cn=%s,ou=%s,dc=%s,dc=%s", RandString(n), RandString(n), RandString(n), RandString(n))
}
// typically for testing values of a variable
func AssertEquals[T comparable](t *testing.T, label string, a T, b T) {
t.Helper()
if a != b {
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
}
// asserting the success or failure of a generic error
func AssertError(t *testing.T, label string, gotErr error, expectErr error) {
t.Helper()
if gotErr != nil && expectErr != nil {
if gotErr.Error() != expectErr.Error() {
t.Errorf(`%s returned %s; expected %s`, label, gotErr.Error(), expectErr.Error())
}
} else {
if gotErr != expectErr {
t.Errorf(`%s returned %s; expected %s`, label, gotErr.Error(), expectErr.Error())
}
}
}
// typically for asserting the success or failure of an ldap result
func AssertLDAPError(t *testing.T, label string, gotErr any, expectErrCode uint16) {
t.Helper()
expectError := ldap.LDAPResultCodeMap[expectErrCode]
if expectErrCode == ldap.LDAPResultSuccess { // expect success
if gotErr != nil { // got an error
gotErr := gotErr.(error)
t.Errorf(`%s returned %s; expected %s.`, label, gotErr.Error(), "success")
} // did not get an error
} else { // expect error
if gotErr == nil { // did not get an error
t.Errorf(`%s returned %s; expected %s.`, label, "success", expectError)
return
}
gotErr := gotErr.(error)
if !ldap.IsErrorWithCode(gotErr, expectErrCode) { // got an error that does not match the expected error
t.Errorf(`%s returned %s; expected %s.`, label, gotErr.Error(), expectError)
} // got the expected error
}
}
// typically for asserting the success or failure of an http result
func AssertStatus(t *testing.T, label string, gotCode int, expectCode int) {
t.Helper()
if expectCode == http.StatusOK {
if gotCode != http.StatusOK { // got an error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
}
} else { // expect error
if gotCode == http.StatusOK { // did not get an error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
} else if gotCode != expectCode { // got an error that does not match the expected error
t.Errorf(`%s returned %d; expected %d.`, label, gotCode, expectCode)
}
}
}
// compare if two users are equal, accepts LDAPUser or gin.H
func AssertLDAPUserEquals(t *testing.T, label string, a any, b app.LDAPUser) {
t.Helper()
aObj, ok := a.(app.LDAPUser)
if ok {
if !reflect.DeepEqual(aObj, b) {
t.Errorf(`%s = %#v; expected %#v.`, label, aObj, b)
}
return
}
aGin, ok := a.(gin.H)
if ok {
bGin := app.LDAPUserToGin(b)
if !reflect.DeepEqual(aGin, bGin) {
t.Errorf(`%s = %#v; expected %#v.`, label, aGin, bGin)
}
return
}
// not a supported type
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
// compare if two users are equal, accepts LDAPUser or gin.H
func AssertLDAPGroupEquals(t *testing.T, label string, a any, b app.LDAPGroup) {
t.Helper()
aObj, ok := a.(app.LDAPGroup)
if ok {
if !reflect.DeepEqual(aObj, b) {
t.Errorf(`%s = %#v; expected %#v.`, label, aObj, b)
}
return
}
aGin, ok := a.(gin.H)
if ok {
bGin := app.LDAPGroupToGin(b)
if !reflect.DeepEqual(aGin, bGin) {
t.Errorf(`%s = %#v; expected %#v.`, label, aGin, bGin)
}
return
}
// not a supported type
t.Errorf(`%s = %#v; expected %#v.`, label, a, b)
}
var _config, _ = app.GetConfig("test_config.json")
var BaseDN = _config.BaseDN
var PeopleDN = fmt.Sprintf("ou=people,%s", BaseDN)
var GroupDN = fmt.Sprintf("ou=groups,%s", BaseDN)
type User struct {
username string
password string
userObj app.LDAPUser
}
type Group struct {
groupname string
groupObj app.LDAPGroup
}

125
test/unit_test.go Normal file
View File

@ -0,0 +1,125 @@
package tests
import (
"errors"
"fmt"
app "proxmoxaas-ldap/app"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
)
// test the GetConfig utility function because it used in other tests
func TestConfig_ValidPath(t *testing.T) {
config, err := app.GetConfig("test_config.json")
AssertError(t, "GetConfig()", err, nil)
AssertEquals(t, "config.ListenPort", config.ListenPort, 80)
AssertEquals(t, "config.LdapURL", config.LdapURL, "ldap://localhost")
AssertEquals(t, "config.BaseDN", config.BaseDN, "dc=test,dc=paasldap")
AssertEquals(t, "config.SessionCookieName", config.SessionCookieName, "PAASLDAPAuthTicket")
AssertEquals(t, "config.SessionCookie.Path", config.SessionCookie.Path, "/")
AssertEquals(t, "config.SessionCookie.HttpOnly", config.SessionCookie.HttpOnly, true)
AssertEquals(t, "config.SessionCookie.Secure", config.SessionCookie.Secure, false)
AssertEquals(t, "config.SessionCookie.MaxAge", config.SessionCookie.MaxAge, 7200)
}
func TestConfig_InvalidPath(t *testing.T) {
badFileName := RandString(16)
_, err := app.GetConfig(badFileName)
expectedErr := fmt.Errorf("open %s: no such file or directory", badFileName)
AssertError(t, "GetConfig()", err, expectedErr)
_, err = app.GetConfig("bad_config.json")
expectedErr = fmt.Errorf("invalid character ',' looking for beginning of object key string")
AssertError(t, "GetConfig()", err, expectedErr)
}
// test the LDAPEntryToUser and LDAPUserToGin utility functions
func TestLDAPUserDataPipeline(t *testing.T) {
var memberOf []string
for i := 0; i < RandInt(5, 20); i++ {
memberOf = append(memberOf, RandDN(16))
}
expectedUser := app.LDAPUser{
DN: RandDN(16),
Attributes: app.LDAPUserAttributes{
CN: RandString(16),
SN: RandString(16),
Mail: RandString(16),
UID: RandString(16),
MemberOf: memberOf,
},
}
attributes := make(map[string][]string)
attributes["cn"] = []string{expectedUser.Attributes.CN}
attributes["sn"] = []string{expectedUser.Attributes.SN}
attributes["mail"] = []string{expectedUser.Attributes.Mail}
attributes["uid"] = []string{expectedUser.Attributes.UID}
attributes["memberOf"] = expectedUser.Attributes.MemberOf
entry := ldap.NewEntry(expectedUser.DN, attributes)
user := app.LDAPEntryToLDAPUser(entry)
AssertLDAPUserEquals(t, "LDAPEntryToLDAPUser(entry) -> user", user, expectedUser)
json := app.LDAPUserToGin(user)
AssertLDAPUserEquals(t, "LDAPUserToGin(user) -> json", json, expectedUser)
}
// test the LDAPEntryToGroup and LDAPGroupToGin utility functions
func TestLDAPGroupDataPipeline(t *testing.T) {
var member []string
for i := 0; i < RandInt(5, 20); i++ {
member = append(member, RandDN(16))
}
expectedGroup := app.LDAPGroup{
DN: RandDN(16),
Attributes: app.LDAPGroupAttributes{
Member: member,
},
}
attributes := make(map[string][]string)
attributes["member"] = expectedGroup.Attributes.Member
entry := ldap.NewEntry(expectedGroup.DN, attributes)
group := app.LDAPEntryToLDAPGroup(entry)
AssertLDAPGroupEquals(t, "LDAPEntryToLDAPGroup(entry) -> group", group, expectedGroup)
json := app.LDAPGroupToGin(group)
AssertLDAPGroupEquals(t, "LDAPGroupToGin(group) -> json", json, expectedGroup)
}
func TestHandleResponse(t *testing.T) {
for errorCode := range ldap.LDAPResultCodeMap {
expectedMessage := RandString(16)
LDAPerr := ldap.NewError(errorCode, errors.New(expectedMessage))
res := gin.H{
"error": LDAPerr,
}
LDAPResult := ldap.LDAPResultCodeMap[errorCode]
handledResponseError := (app.HandleResponse(res))["error"].(gin.H)
AssertEquals(t, `HandleResponse(res)["error"]["code"]`, handledResponseError["code"].(uint16), errorCode)
AssertEquals(t, `HandleResponse(res)["error"]["result"]`, handledResponseError["result"].(string), LDAPResult)
AssertEquals(t, `HandleResponse(res)["error"]["message"]`, handledResponseError["message"].(string), expectedMessage)
}
res := gin.H{
"ok": true,
"status": RandInt(0, 600),
}
handledResponse := app.HandleResponse(res)
AssertEquals(t, `HandleResponse(res)["ok"]`, handledResponse["ok"].(bool), res["ok"].(bool))
AssertEquals(t, `HandleResponse(res)["satus"]`, handledResponse["status"].(int), res["status"].(int))
AssertEquals(t, `HandleResponse(res)["error"]`, handledResponse["error"], nil)
}