mirror of https://github.com/hashicorp/boundary
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
567 lines
18 KiB
567 lines
18 KiB
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package authmethodscmd
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/boundary/api/authmethods"
|
|
"github.com/hashicorp/boundary/internal/auth/ldap"
|
|
"github.com/hashicorp/boundary/internal/cmd/base"
|
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
|
)
|
|
|
|
func init() {
|
|
extraLdapActionsFlagsMapFunc = extraLdapActionsFlagsMapFuncImpl
|
|
extraLdapFlagsFunc = extraLdapFlagsFuncImpl
|
|
extraLdapFlagsHandlingFunc = extraLdapFlagHandlingFuncImpl
|
|
}
|
|
|
|
type extraLdapCmdVars struct {
|
|
flagState string
|
|
flagUrls []string
|
|
flagInsecureTls bool
|
|
flagDiscoverDn bool
|
|
flagAnonGroupSearch bool
|
|
flagUpnDomain string
|
|
flagStartTls bool
|
|
flagUserDn string
|
|
flagUserAttr string
|
|
flagUserFilter string
|
|
flagEnableGroups bool
|
|
flagGroupDn string
|
|
flagGroupAttr string
|
|
flagGroupFilter string
|
|
flagCertificates []string
|
|
flagClientCertificate string
|
|
flagClientCertificateKey string
|
|
flagBindDn string
|
|
flagBindPassword string
|
|
flagUseTokenGroups bool
|
|
flagAccountAttributeMaps []string
|
|
flagMaxPageSize uint64
|
|
flagDerefAliases string
|
|
}
|
|
|
|
const (
|
|
urlsFlagName = "urls"
|
|
insecureTlsFlagName = "insecure-tls"
|
|
discoverDnFlagName = "discover-dn"
|
|
anonGroupSearchFlagName = "anon-group-search"
|
|
upnDomainFlagName = "upn-domain"
|
|
startTlsFlagName = "start-tls"
|
|
userDnFlagName = "user-dn"
|
|
userAttrFlagName = "user-attr"
|
|
userFilterFlagName = "user-filter"
|
|
enableGroupsFlagName = "enable-groups"
|
|
groupDnFlagName = "group-dn"
|
|
groupAttrFlagName = "group-attr"
|
|
groupFilterFlagName = "group-filter"
|
|
certificatesFlagName = "certificate"
|
|
clientCertificateFlagName = "client-certificate"
|
|
clientCertificateKeyFlagName = "client-certificate-key"
|
|
bindDnFlagName = "bind-dn"
|
|
bindPasswordFlagName = "bind-password"
|
|
useTokenGroupsFlagName = "use-token-groups"
|
|
accountAttributeMapsFlagName = "account-attribute-map"
|
|
maxPageSizeFlagName = "max-page-size"
|
|
derefAliasesFlagName = "deref-aliases"
|
|
)
|
|
|
|
func extraLdapActionsFlagsMapFuncImpl() map[string][]string {
|
|
flags := map[string][]string{
|
|
"create": {
|
|
urlsFlagName,
|
|
insecureTlsFlagName,
|
|
discoverDnFlagName,
|
|
anonGroupSearchFlagName,
|
|
upnDomainFlagName,
|
|
startTlsFlagName,
|
|
userDnFlagName,
|
|
userAttrFlagName,
|
|
userFilterFlagName,
|
|
enableGroupsFlagName,
|
|
groupDnFlagName,
|
|
groupAttrFlagName,
|
|
groupFilterFlagName,
|
|
certificatesFlagName,
|
|
clientCertificateFlagName,
|
|
clientCertificateKeyFlagName,
|
|
bindDnFlagName,
|
|
bindPasswordFlagName,
|
|
useTokenGroupsFlagName,
|
|
accountAttributeMapsFlagName,
|
|
stateFlagName,
|
|
maxPageSizeFlagName,
|
|
derefAliasesFlagName,
|
|
},
|
|
}
|
|
flags["update"] = flags["create"]
|
|
return flags
|
|
}
|
|
|
|
func extraLdapFlagsFuncImpl(c *LdapCommand, set *base.FlagSets, _ *base.FlagSet) {
|
|
f := set.NewFlagSet("LDAP Auth Method Options")
|
|
|
|
for _, name := range flagsLdapMap[c.Func] {
|
|
switch name {
|
|
case urlsFlagName:
|
|
f.StringSliceVar(&base.StringSliceVar{
|
|
Name: urlsFlagName,
|
|
Target: &c.flagUrls,
|
|
Usage: "The LDAP URLs that specify LDAP servers to connect to (required). May be specified multiple times.",
|
|
})
|
|
case startTlsFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: startTlsFlagName,
|
|
Target: &c.flagStartTls,
|
|
Usage: "Issue StartTLS command after connecting (optional).",
|
|
})
|
|
case insecureTlsFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: insecureTlsFlagName,
|
|
Target: &c.flagInsecureTls,
|
|
Usage: "Skip the LDAP server SSL certificate validation (optional) - insecure and use with caution.",
|
|
})
|
|
case discoverDnFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: discoverDnFlagName,
|
|
Target: &c.flagDiscoverDn,
|
|
Usage: "Use anon bind to discover the bind DN of a user (optional).",
|
|
})
|
|
case anonGroupSearchFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: anonGroupSearchFlagName,
|
|
Target: &c.flagAnonGroupSearch,
|
|
Usage: "Use anon bind when performing LDAP group searches (optional).",
|
|
})
|
|
case upnDomainFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: upnDomainFlagName,
|
|
Target: &c.flagUpnDomain,
|
|
Usage: "The userPrincipalDomain used to construct the UPN string for the authenticating user (optional).",
|
|
})
|
|
case userDnFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: userDnFlagName,
|
|
Target: &c.flagUserDn,
|
|
Usage: "The base DN under which to perform user search (optional).",
|
|
})
|
|
case userAttrFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: userAttrFlagName,
|
|
Target: &c.flagUserAttr,
|
|
Usage: "The attribute on user entry matching the username passed when authenticating (optional).",
|
|
})
|
|
case userFilterFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: userFilterFlagName,
|
|
Target: &c.flagUserFilter,
|
|
Usage: "A go template used to construct a LDAP user search filter (optional).",
|
|
})
|
|
case enableGroupsFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: enableGroupsFlagName,
|
|
Target: &c.flagEnableGroups,
|
|
Usage: "Find the authenticated user's groups during authentication (optional).",
|
|
})
|
|
case groupDnFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: groupDnFlagName,
|
|
Target: &c.flagGroupDn,
|
|
Usage: "The base DN under which to perform group search.",
|
|
})
|
|
case groupAttrFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: groupAttrFlagName,
|
|
Target: &c.flagGroupAttr,
|
|
Usage: "The attribute that enumerates a user's group membership from entries returned by a group search (optional).",
|
|
})
|
|
case groupFilterFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: groupFilterFlagName,
|
|
Target: &c.flagGroupFilter,
|
|
Usage: "A go template used to construct a LDAP group search filter (optional).",
|
|
})
|
|
case certificatesFlagName:
|
|
f.StringSliceVar(&base.StringSliceVar{
|
|
Name: certificatesFlagName,
|
|
Target: &c.flagCertificates,
|
|
Usage: "PEM-encoded X.509 CA certificate in ASN.1 DER form that can be used as a trust anchor when connecting to an LDAP server(optional). This may be specified multiple times",
|
|
})
|
|
case clientCertificateFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: clientCertificateFlagName,
|
|
Target: &c.flagClientCertificate,
|
|
Usage: "PEM-encoded X.509 client certificate in ASN.1 DER form that can be used to authenticate against an LDAP server(optional).",
|
|
})
|
|
case clientCertificateKeyFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: clientCertificateKeyFlagName,
|
|
Target: &c.flagClientCertificateKey,
|
|
Usage: "PEM-encoded X.509 client certificate key in PKCS #8, ASN.1 DER form used with the client certificate (optional).",
|
|
})
|
|
case bindDnFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: bindDnFlagName,
|
|
Target: &c.flagBindDn,
|
|
Usage: "The distinguished name of entry to bind when performing user and group searches (optional).",
|
|
})
|
|
case bindPasswordFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: bindPasswordFlagName,
|
|
Target: &c.flagBindPassword,
|
|
Usage: "The password to use along with bind-dn performing user and group searches (optional).",
|
|
})
|
|
case useTokenGroupsFlagName:
|
|
f.BoolVar(&base.BoolVar{
|
|
Name: useTokenGroupsFlagName,
|
|
Target: &c.flagUseTokenGroups,
|
|
Usage: "Use the Active Directory tokenGroups constructed attribute of the user to find the group memberships (optional).",
|
|
})
|
|
case stateFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: stateFlagName,
|
|
Target: &c.flagState,
|
|
Usage: "The desired operational state of the auth method.",
|
|
})
|
|
case maxPageSizeFlagName:
|
|
f.Uint64Var(&base.Uint64Var{
|
|
Name: maxPageSizeFlagName,
|
|
Target: &c.flagMaxPageSize,
|
|
Usage: "MaximumPageSize specifies a maximum search result size to use when retrieving the authenticated user's groups (optional).",
|
|
})
|
|
case derefAliasesFlagName:
|
|
f.StringVar(&base.StringVar{
|
|
Name: derefAliasesFlagName,
|
|
Target: &c.flagDerefAliases,
|
|
Usage: "Control how aliases are dereferenced when performing the search. Possible values are: never, finding, searching, and always (optional).",
|
|
})
|
|
case accountAttributeMapsFlagName:
|
|
f.StringSliceVar(&base.StringSliceVar{
|
|
Name: accountAttributeMapsFlagName,
|
|
Target: &c.flagAccountAttributeMaps,
|
|
Usage: "Attribute maps from custom attributes to the standard fullName and email account attributes. These maps are represented as key=value where the key equals the from_attribute, and the value equals the to_attribute.",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *LdapCommand) extraLdapHelpFunc(helpMap map[string]func() string) string {
|
|
var helpStr string
|
|
switch c.Func {
|
|
case "create":
|
|
helpStr = base.WrapForHelpText([]string{
|
|
"Usage: boundary auth-methods create ldap [options] [args]",
|
|
"",
|
|
" Create an ldap-type auth method. Example:",
|
|
"",
|
|
` $ boundary auth-methods create ldap -name prodops -description "LDAP auth-method for ProdOps"`,
|
|
"",
|
|
"",
|
|
})
|
|
|
|
case "update":
|
|
helpStr = base.WrapForHelpText([]string{
|
|
"Usage: boundary auth-methods update ldap [options] [args]",
|
|
"",
|
|
" Update an ldap-type auth method given its ID. Example:",
|
|
"",
|
|
` $ boundary auth-methods update ldap -id amldap_1234567890 -name "devops" -description "LDAP auth-method for DevOps"`,
|
|
"",
|
|
"",
|
|
})
|
|
}
|
|
return helpStr + c.Flags().Help()
|
|
}
|
|
|
|
func extraLdapFlagHandlingFuncImpl(c *LdapCommand, _ *base.FlagSets, opts *[]authmethods.Option) bool {
|
|
switch {
|
|
case len(c.flagUrls) == 0:
|
|
case len(c.flagUrls) == 1 && c.flagUrls[0] == "null":
|
|
c.UI.Error(fmt.Sprintf("There must be at least one %q", urlsFlagName))
|
|
return false
|
|
default:
|
|
for _, urlString := range c.flagUrls {
|
|
u, err := url.Parse(urlString)
|
|
if err != nil {
|
|
c.UI.Error(fmt.Sprintf("Error parsing URL %q: %s", urlString, err))
|
|
return false
|
|
}
|
|
switch u.Scheme {
|
|
case "ldap", "ldaps":
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("scheme in url %q is neither ldap nor ldaps", urlString))
|
|
return false
|
|
}
|
|
}
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUrls(c.flagUrls))
|
|
}
|
|
|
|
switch c.flagStartTls {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodStartTls(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodStartTls(false))
|
|
}
|
|
|
|
switch c.flagInsecureTls {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodInsecureTls(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodInsecureTls(false))
|
|
}
|
|
|
|
switch c.flagDiscoverDn {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodDiscoverDn(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodDiscoverDn(false))
|
|
}
|
|
|
|
switch c.flagAnonGroupSearch {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodAnonGroupSearch(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodAnonGroupSearch(false))
|
|
}
|
|
|
|
switch c.flagUpnDomain {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodUpnDomain())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUpnDomain(c.flagUpnDomain))
|
|
}
|
|
|
|
switch c.flagUserDn {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodUserDn())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUserDn(c.flagUserDn))
|
|
}
|
|
|
|
switch c.flagUserAttr {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodUserAttr())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUserAttr(c.flagUserAttr))
|
|
}
|
|
|
|
switch c.flagUserFilter {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodUserFilter())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUserFilter(c.flagUserFilter))
|
|
}
|
|
|
|
switch c.flagEnableGroups {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodEnableGroups(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodEnableGroups(false))
|
|
}
|
|
|
|
switch c.flagGroupDn {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodGroupDn())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodGroupDn(c.flagGroupDn))
|
|
}
|
|
|
|
switch c.flagGroupAttr {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodGroupAttr())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodGroupAttr(c.flagGroupAttr))
|
|
}
|
|
|
|
switch c.flagGroupFilter {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodGroupFilter())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodGroupFilter(c.flagGroupFilter))
|
|
}
|
|
|
|
switch c.flagDerefAliases {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodDereferenceAliases())
|
|
default:
|
|
// never, finding, searching, and always
|
|
var derefAliases ldap.DerefAliasType
|
|
switch strings.ToLower(c.flagDerefAliases) {
|
|
case "never":
|
|
derefAliases = ldap.NeverDerefAliases
|
|
case "finding":
|
|
derefAliases = ldap.DerefFindingBaseObj
|
|
case "searching":
|
|
derefAliases = ldap.DerefInSearching
|
|
case "always":
|
|
derefAliases = ldap.DerefAlways
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("%q is an invalid deref aliases (valid values are: never, finding, searching or always)", c.flagDerefAliases))
|
|
return false
|
|
}
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodDereferenceAliases(string(derefAliases)))
|
|
}
|
|
|
|
switch c.flagMaxPageSize {
|
|
case 0:
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodMaximumPageSize())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodMaximumPageSize(uint32(c.flagMaxPageSize)))
|
|
}
|
|
|
|
switch {
|
|
case len(c.flagCertificates) == 0:
|
|
case len(c.flagCertificates) == 1 && c.flagCertificates[0] == "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodCertificates())
|
|
default:
|
|
pems := make([]string, 0, len(c.flagCertificates))
|
|
for _, certFlag := range c.flagCertificates {
|
|
p, err := parseutil.MustParsePath(certFlag)
|
|
switch {
|
|
case err == nil:
|
|
if validationErr := validateCerts(p); validationErr != nil {
|
|
c.UI.Error(fmt.Sprintf("invalid certificate in %q: %s", certFlag, validationErr.Error()))
|
|
return false
|
|
}
|
|
pems = append(pems, p)
|
|
case errors.Is(err, parseutil.ErrNotParsed):
|
|
c.UI.Error("Certificate flag must be used with env:// or file:// syntax")
|
|
return false
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("Error parsing certificate flag: %v", err))
|
|
return false
|
|
}
|
|
}
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodCertificates(pems))
|
|
}
|
|
|
|
switch c.flagClientCertificate {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodClientCertificate())
|
|
default:
|
|
p, err := parseutil.MustParsePath(c.flagClientCertificate)
|
|
switch {
|
|
case err == nil:
|
|
if validationErr := validateCerts(p); validationErr != nil {
|
|
c.UI.Error(fmt.Sprintf("invalid client certificate in %q: %s", c.flagClientCertificate, validationErr.Error()))
|
|
return false
|
|
}
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodClientCertificate(p))
|
|
case errors.Is(err, parseutil.ErrNotParsed):
|
|
c.UI.Error("Client certificate flag must be used with env:// or file:// syntax")
|
|
return false
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("Error parsing client certificate flag: %v", err))
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch c.flagClientCertificateKey {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodClientCertificateKey())
|
|
default:
|
|
key, err := parseutil.MustParsePath(c.flagClientCertificateKey)
|
|
switch {
|
|
case err == nil:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodClientCertificateKey(key))
|
|
case errors.Is(err, parseutil.ErrNotParsed):
|
|
c.UI.Error("Client certificate key flag must be used with env:// or file:// syntax")
|
|
return false
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("Error parsing client certificate key flag: %v", err))
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch c.flagBindDn {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodBindDn())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodBindDn(c.flagBindDn))
|
|
}
|
|
|
|
switch c.flagBindPassword {
|
|
case "":
|
|
case "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodBindPassword())
|
|
default:
|
|
password, err := parseutil.MustParsePath(c.flagBindPassword)
|
|
switch {
|
|
case err == nil:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodBindPassword(password))
|
|
case errors.Is(err, parseutil.ErrNotParsed):
|
|
c.UI.Error("Bind password flag must be used with env:// or file:// syntax")
|
|
return false
|
|
default:
|
|
c.UI.Error(fmt.Sprintf("Error parsing bind password flag: %v", err))
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch c.flagUseTokenGroups {
|
|
case true:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUseTokenGroups(true))
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodUseTokenGroups(false))
|
|
}
|
|
|
|
switch {
|
|
case len(c.flagAccountAttributeMaps) == 0:
|
|
case len(c.flagAccountAttributeMaps) == 1 && c.flagAccountAttributeMaps[0] == "null":
|
|
*opts = append(*opts, authmethods.DefaultLdapAuthMethodAccountAttributeMaps())
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodAccountAttributeMaps(c.flagAccountAttributeMaps))
|
|
}
|
|
|
|
switch c.flagState {
|
|
case "":
|
|
// there is a default value during "create", so it's okay to not
|
|
// specify a state
|
|
case "null":
|
|
c.UI.Error("State is required, you cannot set it to null")
|
|
return false
|
|
default:
|
|
*opts = append(*opts, authmethods.WithLdapAuthMethodState(c.flagState))
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateCerts(pems ...string) error {
|
|
if len(pems) == 0 {
|
|
return errors.New("no PEMs provided")
|
|
}
|
|
for _, p := range pems {
|
|
if p == "" {
|
|
return errors.New("empty certificate PEM")
|
|
}
|
|
block, _ := pem.Decode([]byte(p))
|
|
if block == nil {
|
|
return errors.New("failed to parse certificate: invalid PEM encoding")
|
|
}
|
|
_, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse certificate: invalid block: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|