continuous-integration/drone Build is failing
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.
179 lines
5.6 KiB
Go
179 lines
5.6 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Accounts share the target's endpoint, transport and host identity.
|
|
type TargetAccount struct {
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
RotationReminderDays int `json:"rotationReminderDays,omitempty"`
|
|
ID string `json:"id"`
|
|
Remark string `json:"remark"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password,omitempty"`
|
|
AuthMethod string `json:"authMethod,omitempty"`
|
|
PrivateKey string `json:"privateKey,omitempty"`
|
|
PrivateKeyAssetID string `json:"privateKeyAssetId,omitempty"`
|
|
CertificateAssetID string `json:"certificateAssetId,omitempty"`
|
|
Certificate string `json:"certificate,omitempty"`
|
|
Passphrase string `json:"passphrase,omitempty"`
|
|
}
|
|
|
|
func (a TargetAccount) Apply(p Proxy) Proxy {
|
|
if a.AuthMethod == "none" {
|
|
a = a.withoutCredentials()
|
|
}
|
|
p.TargetCredentialExpiresAt = a.ExpiresAt
|
|
p.TargetCredentialError = nil
|
|
p.TargetUser, p.TargetPassword = a.Username, a.Password
|
|
p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase = a.AuthMethod, a.PrivateKey, a.Passphrase
|
|
p.SSH.PrivateKeyAssetID, p.SSH.Certificate = a.PrivateKeyAssetID, a.Certificate
|
|
if p.SSH.AuthMethod == "" {
|
|
p.SSH.AuthMethod = "password"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (t Target) HasAccount(id string) bool {
|
|
if id == "" {
|
|
return false
|
|
}
|
|
for _, a := range t.Accounts {
|
|
if a.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidAccountSelection accepts an empty selection only for protocols whose
|
|
// target service is explicitly account-free. Redis and MongoDB become
|
|
// account-free only when they have no configured accounts; adding any account
|
|
// requires selecting it.
|
|
func (t Target) ValidAccountSelection(id string) bool {
|
|
if id == "" {
|
|
return len(t.Accounts) == 0 && (t.Type == "tcp" || t.Type == "redis" || t.Type == "mongodb")
|
|
}
|
|
if t.Type == "tcp" {
|
|
return false
|
|
}
|
|
return t.HasAccount(id)
|
|
}
|
|
|
|
func (p Proxy) ValidTargetAccountSelection() bool {
|
|
return p.TargetID != "" && (Target{Type: p.Type, Accounts: p.TargetAccounts}).ValidAccountSelection(p.TargetAccountID)
|
|
}
|
|
|
|
func (t Target) Resolve(p Proxy) (Proxy, error) {
|
|
if p.TargetAccountID == "" && t.ValidAccountSelection("") {
|
|
return t.Apply(p), nil
|
|
}
|
|
if !t.HasAccount(p.TargetAccountID) {
|
|
return Proxy{}, errors.New("target account not found")
|
|
}
|
|
p = t.Apply(p)
|
|
if err := p.CheckTargetCredential(); err != nil {
|
|
return Proxy{}, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
var accountIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
|
|
|
func (t *Target) ValidateAccounts() error {
|
|
if len(t.Accounts) > 32 {
|
|
return errors.New("at most 32 target accounts")
|
|
}
|
|
seen := map[string]bool{}
|
|
for i := range t.Accounts {
|
|
a := &t.Accounts[i]
|
|
if a.AuthMethod == "none" {
|
|
if !SupportsUnauthenticatedTarget(t.Type) {
|
|
return errors.New("target protocol does not support unauthenticated accounts")
|
|
}
|
|
*a = a.withoutCredentials()
|
|
} else if !IsSSH(t.Type) {
|
|
if a.AuthMethod != "" && a.AuthMethod != "password" {
|
|
return errors.New("target protocol does not support the selected authentication method")
|
|
}
|
|
if a.PrivateKey != "" || a.PrivateKeyAssetID != "" || a.Certificate != "" || a.CertificateAssetID != "" || a.Passphrase != "" {
|
|
return errors.New("SSH target credentials are supported only for SSH and SFTP")
|
|
}
|
|
}
|
|
if err := validateCredentialExpiry(a.ExpiresAt, a.RotationReminderDays); err != nil {
|
|
return err
|
|
}
|
|
a.Remark = strings.TrimSpace(a.Remark)
|
|
if !accountIDPattern.MatchString(a.ID) || seen[a.ID] {
|
|
return errors.New("invalid or duplicate target account id")
|
|
}
|
|
seen[a.ID] = true
|
|
if len(a.Remark) > 100 {
|
|
return errors.New("target account remark must be at most 100 bytes")
|
|
}
|
|
p := a.Apply(t.Runtime())
|
|
p.ListenPort, p.GatewayUser, p.GatewayPassword = 1, "validation", "validation"
|
|
if err := p.Validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Omitted credentials retain their previous value by stable account ID; explicit
|
|
// empty strings clear credentials. Never match credentials by array position.
|
|
func mergeAccountSecrets(current []TargetAccount, patch map[string]any, out *Target) error {
|
|
raw, present := patch["accounts"]
|
|
if !present {
|
|
return nil
|
|
}
|
|
entries, ok := raw.([]any)
|
|
if !ok {
|
|
return errors.New("accounts must be an array")
|
|
}
|
|
byID := map[string]TargetAccount{}
|
|
for _, a := range current {
|
|
byID[a.ID] = a
|
|
}
|
|
for i, entry := range entries {
|
|
fields, ok := entry.(map[string]any)
|
|
if !ok {
|
|
return errors.New("invalid account")
|
|
}
|
|
if err := exactFields(fields, reflect.TypeOf(TargetAccount{})); err != nil {
|
|
return err
|
|
}
|
|
old := byID[out.Accounts[i].ID]
|
|
if _, exists := fields["certificate"]; !exists {
|
|
out.Accounts[i].Certificate = old.Certificate
|
|
}
|
|
if _, exists := fields["password"]; !exists {
|
|
out.Accounts[i].Password = old.Password
|
|
}
|
|
if _, exists := fields["privateKey"]; !exists {
|
|
out.Accounts[i].PrivateKey = old.PrivateKey
|
|
}
|
|
if _, exists := fields["passphrase"]; !exists {
|
|
out.Accounts[i].Passphrase = old.Passphrase
|
|
}
|
|
if old.PrivateKeyAssetID != "" && old.PrivateKeyAssetID != out.Accounts[i].PrivateKeyAssetID {
|
|
out.Accounts[i].PrivateKey = ""
|
|
}
|
|
if old.CertificateAssetID != "" && old.CertificateAssetID != out.Accounts[i].CertificateAssetID {
|
|
out.Accounts[i].Certificate = ""
|
|
}
|
|
if fields["authMethod"] == "password" {
|
|
a := &out.Accounts[i]
|
|
a.PrivateKeyAssetID, a.CertificateAssetID, a.PrivateKey, a.Certificate, a.Passphrase = "", "", "", "", ""
|
|
}
|
|
if out.Accounts[i].AuthMethod == "none" {
|
|
out.Accounts[i] = out.Accounts[i].withoutCredentials()
|
|
}
|
|
}
|
|
return nil
|
|
}
|