Files
LinkPort/internal/model/gateway_account.go
T
曾志威 9da95beb42
continuous-integration/drone Build is failing
feat: refine management UI, connection diagnostics and local startup
Migrate the workspace to React and Ant Design, improve account forms and responsive layouts, support outbound diagnostics and SSH fingerprint confirmation, and persist the management startup port. Preserve database migrations, remove S3 settings storage, and improve gateway audit handling.
2026-10-13 19:43:23 +08:00

161 lines
5.5 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" {
return errors.New("readonly is currently supported only for MySQL and MongoDB")
}
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
}