Files
LinkPort/internal/model/gateway_account.go
T

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
}