commit
8ce85540b3
4
Makefile
4
Makefile
|
@ -35,10 +35,14 @@ clean:
|
|||
authors:
|
||||
git log --format='%aN <%aE>' | sort -u > AUTHORS
|
||||
|
||||
cover:
|
||||
go list ./... | xargs -n1 go test --cover
|
||||
|
||||
test:
|
||||
go get
|
||||
go get github.com/stretchr/testify/assert
|
||||
go test -v
|
||||
make cover
|
||||
|
||||
changelog: release
|
||||
git log $(shell git tag | tail -n1)..HEAD --no-merges --format=%B > changelog
|
||||
|
|
|
@ -29,14 +29,14 @@ type authTokenPlugin struct {
|
|||
client *api.Client
|
||||
}
|
||||
|
||||
// NewUserTokenPlugin ... creates a new User Token plugin
|
||||
// NewUserTokenPlugin creates a new User Token plugin
|
||||
func NewUserTokenPlugin(client *api.Client) AuthInterface {
|
||||
return &authTokenPlugin{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// create ... retrieves the token from an environment variable or file
|
||||
// Create retrieves the token from an environment variable or file
|
||||
func (r authTokenPlugin) Create(cfg map[string]string) (string, error) {
|
||||
filename, _ := cfg["filename"]
|
||||
if filename != "" {
|
||||
|
@ -48,7 +48,7 @@ func (r authTokenPlugin) Create(cfg map[string]string) (string, error) {
|
|||
// check: ensure we have a token in the file
|
||||
token, found := content["token"]
|
||||
if !found {
|
||||
fmt.Errorf("the auth file: %s does not contain a token", filename)
|
||||
return "", fmt.Errorf("the auth file: %s does not contain a token", filename)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
|
|
@ -27,20 +27,19 @@ type authUserPassPlugin struct {
|
|||
client *api.Client
|
||||
}
|
||||
|
||||
// auth token
|
||||
type userPassLogin struct {
|
||||
// the password for the account
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// NewUserPassPlugin ... creates a new User Pass plugin
|
||||
// NewUserPassPlugin creates a new User Pass plugin
|
||||
func NewUserPassPlugin(client *api.Client) AuthInterface {
|
||||
return &authUserPassPlugin{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// create ... login with the username and password provide in the file
|
||||
// Create a userpass plugin with the username and password provide in the file
|
||||
func (r authUserPassPlugin) Create(cfg map[string]string) (string, error) {
|
||||
// step: extract the options
|
||||
username, _ := cfg["username"]
|
||||
|
|
|
@ -23,7 +23,6 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// config ... the command line configuration
|
||||
type config struct {
|
||||
// the url for th vault server
|
||||
vaultURL string
|
||||
|
@ -64,14 +63,14 @@ func init() {
|
|||
flag.Var(options.resources, "cn", "a resource to retrieve and monitor from vault")
|
||||
}
|
||||
|
||||
// parseOptions ... validate the command line options and validates them
|
||||
// parseOptions validate the command line options and validates them
|
||||
func parseOptions() error {
|
||||
flag.Parse()
|
||||
|
||||
return validateOptions(&options)
|
||||
}
|
||||
|
||||
// validateOptions ... parses and validates the command line options
|
||||
// validateOptions parses and validates the command line options
|
||||
func validateOptions(cfg *config) error {
|
||||
// step: validate the vault url
|
||||
_, err := url.Parse(cfg.vaultURL)
|
||||
|
|
22
utils.go
22
utils.go
|
@ -36,7 +36,7 @@ func init() {
|
|||
rand.Seed(int64(time.Now().Nanosecond()))
|
||||
}
|
||||
|
||||
// showUsage ... prints the command usage and exits
|
||||
// showUsage prints the command usage and exits
|
||||
// message : an error message to display if exiting with an error
|
||||
func showUsage(message string, args ...interface{}) {
|
||||
flag.PrintDefaults()
|
||||
|
@ -48,14 +48,14 @@ func showUsage(message string, args ...interface{}) {
|
|||
os.Exit(0)
|
||||
}
|
||||
|
||||
// randomWait ... wait for a random amount of time
|
||||
// randomWait waits for a random amount of time
|
||||
// min : the minimum amount of time willing to wait
|
||||
// max : the maximum amount of time willing to wait
|
||||
func randomWait(min, max int) <-chan time.Time {
|
||||
return time.After(time.Duration(getRandomWithin(min, max)) * time.Second)
|
||||
}
|
||||
|
||||
// hasKey ... checks to see if a key is present
|
||||
// hasKey checks to see if a key is present
|
||||
// key : the key we are looking for
|
||||
// data : a map of strings to something we are looking at
|
||||
func hasKey(key string, data map[string]interface{}) bool {
|
||||
|
@ -63,7 +63,7 @@ func hasKey(key string, data map[string]interface{}) bool {
|
|||
return found
|
||||
}
|
||||
|
||||
// getKeys ... retrieve a list of keys from the map
|
||||
// getKeys retrieves a list of keys from the map
|
||||
// data : the map which you wish to extract the keys from
|
||||
func getKeys(data map[string]interface{}) []string {
|
||||
var list []string
|
||||
|
@ -73,7 +73,7 @@ func getKeys(data map[string]interface{}) []string {
|
|||
return list
|
||||
}
|
||||
|
||||
// readConfigFile ... read in a configuration file
|
||||
// readConfigFile read in a configuration file
|
||||
// filename : the path to the file
|
||||
func readConfigFile(filename string) (map[string]string, error) {
|
||||
// step: check the file exists
|
||||
|
@ -95,7 +95,7 @@ func readConfigFile(filename string) (map[string]string, error) {
|
|||
return nil, fmt.Errorf("unsupported config file format: %s", suffix)
|
||||
}
|
||||
|
||||
// readJsonFile ... read in and unmarshall the data into a map
|
||||
// readJsonFile read in and unmarshall the data into a map
|
||||
// filename : the path to the file container the json data
|
||||
func readJSONFile(filename string) (map[string]string, error) {
|
||||
data := make(map[string]string, 0)
|
||||
|
@ -113,7 +113,7 @@ func readJSONFile(filename string) (map[string]string, error) {
|
|||
return data, nil
|
||||
}
|
||||
|
||||
// readYAMLFile ... read in and unmarshall the data into a map
|
||||
// readYAMLFile read in and unmarshall the data into a map
|
||||
// filename : the path to the file container the yaml data
|
||||
func readYAMLFile(filename string) (map[string]string, error) {
|
||||
data := make(map[string]string, 0)
|
||||
|
@ -129,14 +129,14 @@ func readYAMLFile(filename string) (map[string]string, error) {
|
|||
return data, nil
|
||||
}
|
||||
|
||||
// randomInt ... generate a random integer between min and max
|
||||
// randomInt generate a random integer between min and max
|
||||
// min : the smallest number we can accept
|
||||
// max : the largest number we can accept
|
||||
func getRandomWithin(min, max int) int {
|
||||
return rand.Intn(max-min) + min
|
||||
}
|
||||
|
||||
// getEnv ... checks to see if an environment variable exists otherwise uses the default
|
||||
// getEnv checks to see if an environment variable exists otherwise uses the default
|
||||
// env : the name of the environment variable you are checking for
|
||||
// value : the default value to return if the value is not there
|
||||
func getEnv(env, value string) string {
|
||||
|
@ -147,7 +147,7 @@ func getEnv(env, value string) string {
|
|||
return value
|
||||
}
|
||||
|
||||
// fileExists ... checks to see if a file exists
|
||||
// fileExists checks to see if a file exists
|
||||
// filename : the full path to the file you are checking for
|
||||
func fileExists(filename string) (bool, error) {
|
||||
if _, err := os.Stat(filename); err != nil {
|
||||
|
@ -160,7 +160,7 @@ func fileExists(filename string) (bool, error) {
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// writeResourceContent ... is responsible for generating the specific content from the resource
|
||||
// writeResourceContent is responsible for generating the specific content from the resource
|
||||
// rn : a point to the vault resource
|
||||
// data : a map of the related secret associated to the resource
|
||||
func writeResource(rn *VaultResource, data map[string]interface{}) error {
|
||||
|
|
32
vault.go
32
vault.go
|
@ -29,17 +29,17 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// VaultAuth ... the method to use when authenticating to vault
|
||||
// VaultAuth the method to use when authenticating to vault
|
||||
VaultAuth = "method"
|
||||
)
|
||||
|
||||
// AuthInterface .. the auth interface
|
||||
// AuthInterface is the authentication interface
|
||||
type AuthInterface interface {
|
||||
// Create and handle renewals of the token
|
||||
Create(map[string]string) (string, error)
|
||||
}
|
||||
|
||||
// VaultService ... is the main interface into the vault API - placing into a structure
|
||||
// VaultService is the main interface into the vault API - placing into a structure
|
||||
// allows one to easily mock it and two to simplify the interface for us
|
||||
type VaultService struct {
|
||||
// the vault client
|
||||
|
@ -48,14 +48,13 @@ type VaultService struct {
|
|||
config *api.Config
|
||||
// the token to authenticate with
|
||||
token string
|
||||
|
||||
// the listener channel - technically we only have the one listener but there a long term reasons for adding this
|
||||
listeners []chan VaultEvent
|
||||
// a channel to inform of a new resource to processor
|
||||
resourceChannel chan *watchedResource
|
||||
}
|
||||
|
||||
// VaultEvent ... the definition which captures a change
|
||||
// VaultEvent is the definition which captures a change
|
||||
type VaultEvent struct {
|
||||
// the resource this relates to
|
||||
Resource *VaultResource
|
||||
|
@ -63,7 +62,7 @@ type VaultEvent struct {
|
|||
Secret map[string]interface{}
|
||||
}
|
||||
|
||||
// NewVaultService ... creates a new implementation to speak to vault and retrieve the resources
|
||||
// NewVaultService creates a new implementation to speak to vault and retrieve the resources
|
||||
// url : the url of the vault service
|
||||
func NewVaultService(url string) (*VaultService, error) {
|
||||
var err error
|
||||
|
@ -74,10 +73,11 @@ func NewVaultService(url string) (*VaultService, error) {
|
|||
service.config.Address = url
|
||||
service.listeners = make([]chan VaultEvent, 0)
|
||||
|
||||
// step: setup and generate the tls options
|
||||
service.config.HttpClient.Transport, err = service.getHttpTransport()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// step: skip the cert verification if requested
|
||||
if options.tlsVerify {
|
||||
service.config.HttpClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
|
||||
// step: create the service processor channels
|
||||
|
@ -130,13 +130,13 @@ func (r *VaultService) AddListener(ch chan VaultEvent) {
|
|||
r.listeners = append(r.listeners, ch)
|
||||
}
|
||||
|
||||
// Watch ... add a watch on a resource and inform, renew which required and inform us when
|
||||
// Watch adds a watch on a resource and inform, renew which required and inform us when
|
||||
// the resource is ready
|
||||
func (r VaultService) Watch(rn *VaultResource) {
|
||||
r.resourceChannel <- &watchedResource{resource: rn}
|
||||
}
|
||||
|
||||
// vaultServiceProcessor ... is the background routine responsible for retrieving the resources, renewing when required and
|
||||
// vaultServiceProcessor is the background routine responsible for retrieving the resources, renewing when required and
|
||||
// informing those who are watching the resource that something has changed
|
||||
func (r *VaultService) vaultServiceProcessor() {
|
||||
go func() {
|
||||
|
@ -319,7 +319,7 @@ func (r VaultService) upstream(item *watchedResource) {
|
|||
}
|
||||
}
|
||||
|
||||
// renew ... attempts to renew the lease on a resource
|
||||
// renew attempts to renew the lease on a resource
|
||||
// rn : the resource we wish to renew the lease on
|
||||
func (r VaultService) renew(rn *watchedResource) error {
|
||||
glog.V(4).Infof("attempting to renew the lease: %s on resource: %s", rn.secret.LeaseID, rn.resource)
|
||||
|
@ -343,7 +343,7 @@ func (r VaultService) renew(rn *watchedResource) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// revoke ... attempt to revoke the lease of a resource
|
||||
// revoke attempts to revoke the lease of a resource
|
||||
// lease : the lease lease which was given when you got it
|
||||
func (r VaultService) revoke(lease string) error {
|
||||
glog.V(3).Infof("attemping to revoking the lease: %s", lease)
|
||||
|
@ -357,7 +357,7 @@ func (r VaultService) revoke(lease string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// get ... retrieve a secret from the vault
|
||||
// get retrieves a secret from the vault
|
||||
// rn : the watched resource
|
||||
func (r VaultService) get(rn *watchedResource) (err error) {
|
||||
var secret *api.Secret
|
||||
|
@ -367,7 +367,7 @@ func (r VaultService) get(rn *watchedResource) (err error) {
|
|||
case "pki":
|
||||
secret, err = r.client.Logical().Write(fmt.Sprintf("%s/issue/%s", rn.resource.resource, rn.resource.name),
|
||||
map[string]interface{}{
|
||||
"common_name": rn.resource.options[OptionCommonName],
|
||||
"common_name": rn.resource.options[optionCommonName],
|
||||
})
|
||||
case "aws":
|
||||
secret, err = r.client.Logical().Read(fmt.Sprintf("%s/creds/%s", rn.resource.resource, rn.resource.name))
|
||||
|
|
|
@ -24,20 +24,20 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// OptionFilename ... option to set the filename of the resource
|
||||
OptionFilename = "fn"
|
||||
// OptionFormat ... option to set the output format (yaml, xml, json)
|
||||
OptionFormat = "fmt"
|
||||
// OptionCommonName ... use by the PKI resource
|
||||
OptionCommonName = "cn"
|
||||
// OptionTemplatePath ... the full path to a template
|
||||
OptionTemplatePath = "tpl"
|
||||
// OptionRenewal ... a duration to renew the resource
|
||||
OptionRenewal = "rn"
|
||||
// OptionRevoke ... revoke an old lease when retrieving a new one
|
||||
OptionRevoke = "rv"
|
||||
// OptionUpdate ... override the lease of the resource
|
||||
OptionUpdate = "up"
|
||||
// optionFilename option to set the filename of the resource
|
||||
optionFilename = "fn"
|
||||
// optionFormat ... option to set the output format (yaml, xml, json)
|
||||
optionFormat = "fmt"
|
||||
// optionCommonName ... use by the PKI resource
|
||||
optionCommonName = "cn"
|
||||
// optionTemplatePath ... the full path to a template
|
||||
optionTemplatePath = "tpl"
|
||||
// optionRenewal ... a duration to renew the resource
|
||||
optionRenewal = "rn"
|
||||
// optionRevoke ... revoke an old lease when retrieving a new one
|
||||
optionRevoke = "rv"
|
||||
// optionUpdate ... override the lease of the resource
|
||||
optionUpdate = "up"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -64,7 +64,7 @@ func defaultVaultResource() *VaultResource {
|
|||
}
|
||||
}
|
||||
|
||||
// VaultResource ... the structure which defined a resource set from vault
|
||||
// VaultResource is the structure which defined a resource set from vault
|
||||
type VaultResource struct {
|
||||
// the namespace of the resource
|
||||
resource string
|
||||
|
@ -82,17 +82,17 @@ type VaultResource struct {
|
|||
options map[string]string
|
||||
}
|
||||
|
||||
// GetFilename ... generates a resource filename by default the resource name and resource type, which
|
||||
// GetFilename generates a resource filename by default the resource name and resource type, which
|
||||
// can override by the OPTION_FILENAME option
|
||||
func (r VaultResource) GetFilename() string {
|
||||
if path, found := r.options[OptionFilename]; found {
|
||||
if path, found := r.options[optionFilename]; found {
|
||||
return path
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s", r.name, r.resource)
|
||||
}
|
||||
|
||||
// IsValid ... checks to see if the resource is valid
|
||||
// IsValid checks to see if the resource is valid
|
||||
func (r *VaultResource) IsValid() error {
|
||||
// step: check the resource type
|
||||
if _, found := validResources[r.resource]; !found {
|
||||
|
@ -112,15 +112,15 @@ func (r *VaultResource) IsValid() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// isValidResource ... validate the resource meets the requirements
|
||||
// isValidResource validates the resource meets the requirements
|
||||
func (r *VaultResource) isValidResource() error {
|
||||
switch r.resource {
|
||||
case "pki":
|
||||
if _, found := r.options[OptionCommonName]; !found {
|
||||
if _, found := r.options[optionCommonName]; !found {
|
||||
return fmt.Errorf("pki resource requires a common name specified")
|
||||
}
|
||||
case "tpl":
|
||||
if _, found := r.options[OptionTemplatePath]; !found {
|
||||
if _, found := r.options[optionTemplatePath]; !found {
|
||||
return fmt.Errorf("template resource requires a template path option")
|
||||
}
|
||||
}
|
||||
|
@ -128,39 +128,39 @@ func (r *VaultResource) isValidResource() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// isValidOptions ... iterates through the options, converts the options and so forth
|
||||
// isValidOptions iterates through the options, converts the options and so forth
|
||||
func (r *VaultResource) isValidOptions() error {
|
||||
// check the filename directive
|
||||
for opt, val := range r.options {
|
||||
switch opt {
|
||||
case OptionFormat:
|
||||
if matched := resourceFormatRegex.MatchString(r.options[OptionFormat]); !matched {
|
||||
return fmt.Errorf("unsupported output format: %s", r.options[OptionFormat])
|
||||
case optionFormat:
|
||||
if matched := resourceFormatRegex.MatchString(r.options[optionFormat]); !matched {
|
||||
return fmt.Errorf("unsupported output format: %s", r.options[optionFormat])
|
||||
}
|
||||
r.format = val
|
||||
case OptionUpdate:
|
||||
case optionUpdate:
|
||||
duration, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the update option: %s is not value, should be a duration format", val)
|
||||
}
|
||||
r.update = duration
|
||||
case OptionRevoke:
|
||||
case optionRevoke:
|
||||
choice, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the revoke option: %s is invalid, should be a boolean", val)
|
||||
}
|
||||
r.revoked = choice
|
||||
case OptionRenewal:
|
||||
case optionRenewal:
|
||||
choice, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the renewal option: %s is invalid, should be a boolean", val)
|
||||
}
|
||||
r.renewable = choice
|
||||
case OptionFilename:
|
||||
case optionFilename:
|
||||
// @TODO need to check it's valid filename / path
|
||||
case OptionCommonName:
|
||||
case optionCommonName:
|
||||
// @TODO need to check it's a valid hostname
|
||||
case OptionTemplatePath:
|
||||
case optionTemplatePath:
|
||||
if exists, _ := fileExists(val); !exists {
|
||||
return fmt.Errorf("the template file: %s does not exist", val)
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ func (r *VaultResource) isValidOptions() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// String ... a string representation of the struct
|
||||
// String returns a string representation of the struct
|
||||
func (r VaultResource) String() string {
|
||||
return fmt.Sprintf("%s/%s", r.resource, r.name)
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ func TestResourceFilename(t *testing.T) {
|
|||
options: map[string]string{},
|
||||
}
|
||||
assert.Equal(t, "test_secret.secret", rn.GetFilename())
|
||||
rn.options[OptionFilename] = "credentials"
|
||||
rn.options[optionFilename] = "credentials"
|
||||
assert.Equal(t, "credentials", rn.GetFilename())
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ func TestIsValid(t *testing.T) {
|
|||
assert.NotNil(t, resource.IsValid())
|
||||
resource.resource = "pki"
|
||||
assert.NotNil(t, resource.IsValid())
|
||||
resource.options[OptionCommonName] = "common.example.com"
|
||||
resource.options[optionCommonName] = "common.example.com"
|
||||
assert.Nil(t, resource.IsValid())
|
||||
|
||||
}
|
||||
|
|
|
@ -27,13 +27,13 @@ var (
|
|||
resourceOptionsRegex = regexp.MustCompile("([\\w\\d]{2,3})=([\\w\\d\\/\\.\\-_]+)[,]?")
|
||||
)
|
||||
|
||||
// VaultResources ... a collection of type resource
|
||||
// VaultResources is a collection of type resource
|
||||
type VaultResources struct {
|
||||
// an array of resource to retrieve
|
||||
items []*VaultResource
|
||||
}
|
||||
|
||||
// Set ... implementation for the parser
|
||||
// Set is the implementation for the parser
|
||||
func (r *VaultResources) Set(value string) error {
|
||||
rn := defaultVaultResource()
|
||||
|
||||
|
@ -67,7 +67,7 @@ func (r *VaultResources) Set(value string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// String ... returns a string representation of the struct
|
||||
// String returns a string representation of the struct
|
||||
func (r VaultResources) String() string {
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -53,11 +53,11 @@ func TestResources(t *testing.T) {
|
|||
assert.Equal(t, "secret", rn.resource)
|
||||
assert.Equal(t, "test", rn.name)
|
||||
assert.Equal(t, 2, len(rn.options))
|
||||
assert.Equal(t, "filename.test", rn.options[OptionFilename])
|
||||
assert.Equal(t, "yaml", rn.options[OptionFormat])
|
||||
assert.Equal(t, "filename.test", rn.options[optionFilename])
|
||||
assert.Equal(t, "yaml", rn.options[optionFormat])
|
||||
rn = items.items[1]
|
||||
assert.Equal(t, "secret", rn.resource)
|
||||
assert.Equal(t, "test", rn.name)
|
||||
assert.Equal(t, 1, len(rn.options))
|
||||
assert.Equal(t, "fileame.test", rn.options[OptionFilename])
|
||||
assert.Equal(t, "fileame.test", rn.options[optionFilename])
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ const (
|
|||
renewalMaximum = 0.95
|
||||
)
|
||||
|
||||
// watchedResource ... is a resource which is being watched - i.e. when the item is coming up for renewal
|
||||
// watchedResource is a resource which is being watched - i.e. when the item is coming up for renewal
|
||||
// lets grab it and renew the lease
|
||||
type watchedResource struct {
|
||||
// the resource itself
|
||||
|
@ -43,7 +43,7 @@ type watchedResource struct {
|
|||
secret *api.Secret
|
||||
}
|
||||
|
||||
// notifyOnRenewal ... creates a trigger and notifies when a resource is up for renewal
|
||||
// notifyOnRenewal creates a trigger and notifies when a resource is up for renewal
|
||||
func (r *watchedResource) notifyOnRenewal(ch chan *watchedResource) {
|
||||
go func() {
|
||||
// step: check if the resource has a pre-configured renewal time
|
||||
|
@ -60,7 +60,7 @@ func (r *watchedResource) notifyOnRenewal(ch chan *watchedResource) {
|
|||
}()
|
||||
}
|
||||
|
||||
// calculateRenewal ... calculate the renewal between
|
||||
// calculateRenewal calculate the renewal between
|
||||
func (r watchedResource) calculateRenewal() time.Duration {
|
||||
return time.Duration(getRandomWithin(
|
||||
int(float64(r.secret.LeaseDuration)*renewalMinimum),
|
||||
|
|
Reference in a new issue