161 lines
5.6 KiB
Go
161 lines
5.6 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"golang.org/x/crypto/ssh"
|
|
"net/netip"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type GatewayAccount struct {
|
|
TargetID string `json:"targetId,omitempty"`
|
|
TargetAccountID string `json:"targetAccountId,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
RotationReminderDays int `json:"rotationReminderDays,omitempty"`
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
AccessMode string `json:"accessMode,omitempty"`
|
|
AllowCIDRs []string `json:"allowCIDRs,omitempty"`
|
|
AuthorizedKeys []string `json:"authorizedKeys,omitempty"`
|
|
AuthorizedKeysAssetID string `json:"authorizedKeysAssetId,omitempty"`
|
|
}
|
|
|
|
// Redis and MongoDB gateways with no client accounts accept downstream
|
|
// connections without a local authentication step. Every other gateway,
|
|
// requires gateway authentication.
|
|
func (p Proxy) RequiresGatewayAuthentication() bool {
|
|
return (p.Type != "redis" && p.Type != "mongodb") || len(p.GatewayAccounts) > 0
|
|
}
|
|
|
|
func (p Proxy) GatewayAccount(username string) (GatewayAccount, bool) {
|
|
for _, a := range p.GatewayAccounts {
|
|
if !a.Disabled && !credentialExpired(a.ExpiresAt, time.Now()) && a.Username == username {
|
|
return a, true
|
|
}
|
|
}
|
|
// Redis AUTH password authenticates the protocol's default user. An empty
|
|
// configured username is the explicit password-only form of that identity.
|
|
if p.Type == "redis" && username == "default" {
|
|
for _, a := range p.GatewayAccounts {
|
|
if !a.Disabled && !credentialExpired(a.ExpiresAt, time.Now()) && a.Username == "" {
|
|
return a, true
|
|
}
|
|
}
|
|
}
|
|
return GatewayAccount{}, false
|
|
}
|
|
func (a GatewayAccount) Apply(p Proxy) Proxy {
|
|
if a.TargetID != "" {
|
|
t, ok := p.Targets[a.TargetID]
|
|
if !ok && a.TargetID != p.TargetID {
|
|
p = (Target{}).Apply(p)
|
|
p.TargetCredentialError = ErrCredentialUnavailable
|
|
return p
|
|
}
|
|
p.TargetAccountID = ""
|
|
if ok {
|
|
p = t.Apply(p)
|
|
}
|
|
}
|
|
p = p.selectTargetAccount(a.TargetAccountID)
|
|
if a.Disabled || credentialExpired(a.ExpiresAt, time.Now()) {
|
|
p.TargetCredentialError = ErrCredentialUnavailable
|
|
}
|
|
p.GatewayUser, p.GatewayPassword = a.Username, a.Password
|
|
p.AccessMode = a.AccessMode
|
|
if p.AccessMode == "" && !IsSSH(p.Type) {
|
|
p.AccessMode = "readwrite"
|
|
}
|
|
p.SSH.AuthorizedKeys = a.AuthorizedKeys
|
|
return p
|
|
}
|
|
|
|
func (p Proxy) validateGatewayAccounts() error {
|
|
if len(p.GatewayAccounts) > 32 {
|
|
return errors.New("at most 32 gateway accounts")
|
|
}
|
|
ids, users := map[string]bool{}, map[string]bool{}
|
|
for _, a := range p.GatewayAccounts {
|
|
if err := validateCredentialExpiry(a.ExpiresAt, a.RotationReminderDays); err != nil {
|
|
return err
|
|
}
|
|
if !accountIDPattern.MatchString(a.ID) || ids[a.ID] {
|
|
return errors.New("invalid or duplicate gateway account id")
|
|
}
|
|
ids[a.ID] = true
|
|
passwordOnlyRedis := p.Type == "redis" && a.Username == "" && a.Password != ""
|
|
usernameKey := a.Username
|
|
if p.Type == "redis" && usernameKey == "" {
|
|
usernameKey = "default"
|
|
}
|
|
if (strings.TrimSpace(a.Username) == "" && !passwordOnlyRedis) || len(a.Username) > 128 || strings.ContainsAny(a.Username, "\r\n\x00") || users[usernameKey] {
|
|
return errors.New("invalid or duplicate gateway username")
|
|
}
|
|
users[usernameKey] = true
|
|
if a.Password == "" && (!IsSSH(p.Type) || (len(a.AuthorizedKeys) == 0 && a.AuthorizedKeysAssetID == "")) {
|
|
return errors.New("gateway account password is required")
|
|
}
|
|
if a.AccessMode != "" && a.AccessMode != "readonly" && a.AccessMode != "readwrite" && a.AccessMode != "unrestricted" {
|
|
return errors.New("invalid gateway account accessMode")
|
|
}
|
|
if a.AccessMode == "unrestricted" && p.Type != "mysql" {
|
|
return errors.New("unrestricted accessMode is supported only for MySQL")
|
|
}
|
|
if a.AccessMode == "readonly" && p.Type != "mysql" && p.Type != "mongodb" && p.Type != "redis" && p.Type != "rabbitmq" {
|
|
return errors.New("readonly is supported only for MySQL, MongoDB, Redis and RabbitMQ")
|
|
}
|
|
if IsSSH(p.Type) && a.AccessMode != "" {
|
|
return errors.New("SSH has no accessMode")
|
|
}
|
|
for _, cidr := range a.AllowCIDRs {
|
|
if _, e := netip.ParsePrefix(cidr); e != nil {
|
|
if _, e = netip.ParseAddr(cidr); e != nil {
|
|
return errors.New("invalid account source IP/CIDR")
|
|
}
|
|
}
|
|
}
|
|
for _, key := range a.AuthorizedKeys {
|
|
if _, _, _, _, e := ssh.ParseAuthorizedKey([]byte(key)); e != nil {
|
|
return errors.New("invalid gateway authorized key")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func mergeGatewaySecrets(current Proxy, patch map[string]any, out *Proxy) error {
|
|
raw, present := patch["gatewayAccounts"]
|
|
if !present {
|
|
return nil
|
|
}
|
|
entries, ok := raw.([]any)
|
|
if !ok || (len(entries) == 0 && out.Type != "tcp" && out.Type != "redis" && out.Type != "mongodb") {
|
|
return errors.New("gatewayAccounts must contain at least one account")
|
|
}
|
|
byID := map[string]GatewayAccount{}
|
|
for _, a := range current.GatewayAccounts {
|
|
byID[a.ID] = a
|
|
}
|
|
for i, entry := range entries {
|
|
fields, ok := entry.(map[string]any)
|
|
if !ok {
|
|
return errors.New("invalid gateway account")
|
|
}
|
|
if err := exactFields(fields, reflect.TypeOf(GatewayAccount{})); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := fields["password"]; !ok {
|
|
out.GatewayAccounts[i].Password = byID[out.GatewayAccounts[i].ID].Password
|
|
}
|
|
old := byID[out.GatewayAccounts[i].ID]
|
|
if old.AuthorizedKeysAssetID != "" && old.AuthorizedKeysAssetID != out.GatewayAccounts[i].AuthorizedKeysAssetID {
|
|
out.GatewayAccounts[i].AuthorizedKeys = nil
|
|
}
|
|
}
|
|
out.GatewayUser, out.GatewayPassword = "", ""
|
|
return nil
|
|
}
|