365 lines
13 KiB
Go
365 lines
13 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/netip"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
func Defaults() Proxy {
|
|
return Proxy{Project: DefaultProject, Environment: DefaultEnvironment, ListenHost: "0.0.0.0", MaxConnections: 100, TargetTLS: true, AccessMode: "readwrite", AuthSource: "admin", Outbound: Outbound{Mode: "direct", Auth: "none"}, SSH: SSHConfig{AuthMethod: "password"}}
|
|
}
|
|
func (p *Proxy) Validate() error {
|
|
if p.ManagementListenPort < 0 || p.ManagementListenPort > 65535 || p.ManagementPort < 0 || p.ManagementPort > 65535 {
|
|
return errors.New("RabbitMQ management ports must be between 0 and 65535")
|
|
}
|
|
if p.Type != "rabbitmq" && (p.ManagementListenPort != 0 || p.ManagementPort != 0 || p.ManagementTLS) {
|
|
return errors.New("management ports are supported only for RabbitMQ")
|
|
}
|
|
if p.ManagementListenPort != 0 && p.ManagementListenPort == p.ListenPort {
|
|
return errors.New("RabbitMQ management and AMQP listen ports must differ")
|
|
}
|
|
if err := p.ValidateTCPForward(); err != nil {
|
|
return err
|
|
}
|
|
if p.Type == "tcp" {
|
|
if len(p.GatewayAccounts) != 0 || len(p.TargetAccounts) != 0 || p.GatewayUser != "" || p.GatewayPassword != "" || p.TargetUser != "" || p.TargetPassword != "" || p.TargetAccountID != "" {
|
|
return errors.New("TCP forwarding does not use gateway or target accounts")
|
|
}
|
|
if p.TargetTLS || p.TargetCA != "" || p.TargetCAAssetID != "" || (p.AccessMode != "" && p.AccessMode != "readwrite") {
|
|
return errors.New("TCP forwarding passes TLS through and does not support TLS termination or accessMode")
|
|
}
|
|
p.AccessMode, p.AuthSource = "", ""
|
|
}
|
|
if err := p.ValidateConnectionOptions(); err != nil {
|
|
return err
|
|
}
|
|
if len(p.Database) > 128 || strings.ContainsAny(p.Database, "\r\n\x00") {
|
|
return errors.New("invalid default database")
|
|
}
|
|
if p.Database != "" {
|
|
if p.Type != "mysql" && p.Type != "redis" {
|
|
return errors.New("database is supported only for MySQL and Redis; use authSource for other database protocols")
|
|
}
|
|
if p.Type == "redis" {
|
|
n, err := strconv.ParseUint(p.Database, 10, 31)
|
|
if err != nil || strconv.FormatUint(n, 10) != p.Database {
|
|
return errors.New("Redis database must be an integer from 0 to 2147483647")
|
|
}
|
|
if p.Cluster.Mode == "redis-cluster" && n != 0 {
|
|
return errors.New("Redis Cluster only supports database 0")
|
|
}
|
|
}
|
|
}
|
|
if err := ValidateScope(p.Project, p.Environment); err != nil {
|
|
return err
|
|
}
|
|
if err := p.ValidateCluster(); err != nil {
|
|
return err
|
|
}
|
|
if len(p.GatewayAccounts) > 0 {
|
|
if err := p.validateGatewayAccounts(); err != nil {
|
|
return err
|
|
}
|
|
probe := *p
|
|
probe.GatewayUser = p.GatewayAccounts[0].Username
|
|
probe.GatewayPassword = p.GatewayAccounts[0].Password
|
|
probe.SSH.AuthorizedKeys = p.GatewayAccounts[0].AuthorizedKeys
|
|
probe.GatewayAccounts = nil
|
|
if err := probe.Validate(); err != nil {
|
|
return err
|
|
}
|
|
p.AccessMode = probe.AccessMode
|
|
p.TargetCompression = probe.TargetCompression
|
|
p.AuthSource = probe.AuthSource
|
|
p.Outbound = probe.Outbound
|
|
return nil
|
|
}
|
|
if p.TargetCompression == "" {
|
|
p.TargetCompression = "none"
|
|
}
|
|
if p.TargetCompression != "none" && p.TargetCompression != "zlib" {
|
|
return errors.New("targetCompression must be none or zlib")
|
|
}
|
|
if p.Type != "mysql" && p.Type != "mongodb" && p.TargetCompression != "none" {
|
|
return errors.New("target compression is supported only for MySQL and MongoDB")
|
|
}
|
|
if strings.TrimSpace(p.Name) == "" || len(p.Name) > 100 {
|
|
return errors.New("name must contain 1-100 bytes")
|
|
}
|
|
if len(p.Group) > 100 || strings.ContainsAny(p.Group, "\r\n\x00") {
|
|
return errors.New("group must contain at most 100 bytes")
|
|
}
|
|
if p.Type != "tcp" && p.Type != "mysql" && p.Type != "mongodb" && !IsSSH(p.Type) && p.Type != "redis" && p.Type != "rabbitmq" && p.Type != "pgsql" && p.Type != "http" && p.Type != "https" && p.Type != "sqlserver" {
|
|
return errors.New("unsupported proxy type")
|
|
}
|
|
if net.ParseIP(p.ListenHost) == nil {
|
|
return errors.New("listenHost must be an IP address")
|
|
}
|
|
if !port(p.ListenPort) || !port(p.TargetPort) {
|
|
return errors.New("port must be 1-65535")
|
|
}
|
|
if !host(p.TargetHost) {
|
|
return errors.New("invalid target host")
|
|
}
|
|
for _, s := range p.AllowCIDRs {
|
|
if _, err := netip.ParsePrefix(s); err != nil {
|
|
if _, err = netip.ParseAddr(s); err != nil {
|
|
return errors.New("invalid source IP/CIDR")
|
|
}
|
|
}
|
|
}
|
|
if p.MaxConnections < 1 || p.MaxConnections > 10000 {
|
|
return errors.New("maxConnections must be 1-10000")
|
|
}
|
|
unauthenticatedTarget := SupportsUnauthenticatedTarget(p.Type) && p.SSH.AuthMethod == "none"
|
|
unauthenticatedGateway := !p.RequiresGatewayAuthentication()
|
|
redisPasswordOnlyTarget := p.Type == "redis" && p.TargetUser == "" && p.TargetPassword != ""
|
|
redisPasswordOnlyGateway := p.Type == "redis" && p.GatewayUser == "" && p.GatewayPassword != ""
|
|
targetUsernameMissing := !unauthenticatedTarget && !redisPasswordOnlyTarget && strings.TrimSpace(p.TargetUser) == ""
|
|
gatewayUsernameMissing := !unauthenticatedGateway && !redisPasswordOnlyGateway && strings.TrimSpace(p.GatewayUser) == ""
|
|
if p.Type != "tcp" && (targetUsernameMissing || gatewayUsernameMissing) {
|
|
return errors.New("target and gateway usernames are required")
|
|
}
|
|
if IsSSH(p.Type) {
|
|
if p.AccessMode != "" {
|
|
return errors.New("SSH has no accessMode")
|
|
}
|
|
if p.GatewayPassword == "" && len(p.SSH.AuthorizedKeys) == 0 {
|
|
return errors.New("SSH requires a gateway password or authorized key")
|
|
}
|
|
if p.SSH.AuthMethod != "password" && p.SSH.AuthMethod != "privatekey" {
|
|
return errors.New("SSH authMethod must be password or privatekey")
|
|
}
|
|
if p.SSH.AuthMethod == "privatekey" && p.SSH.PrivateKey == "" && p.SSH.PrivateKeyAssetID == "" {
|
|
return errors.New("SSH private key is required")
|
|
}
|
|
} else if p.Type != "tcp" {
|
|
if p.AccessMode == "" {
|
|
p.AccessMode = "readwrite"
|
|
}
|
|
if p.AccessMode != "readwrite" && p.AccessMode != "readonly" && p.AccessMode != "unrestricted" {
|
|
return errors.New("invalid accessMode")
|
|
}
|
|
if p.AccessMode == "unrestricted" && p.Type != "mysql" {
|
|
return errors.New("unrestricted accessMode is supported only for MySQL")
|
|
}
|
|
if p.Type != "mysql" && p.Type != "mongodb" && p.Type != "redis" && p.Type != "rabbitmq" && p.AccessMode == "readonly" {
|
|
return errors.New("readonly is supported only for MySQL, MongoDB, Redis and RabbitMQ")
|
|
}
|
|
if p.GatewayPassword == "" && !unauthenticatedGateway {
|
|
return errors.New("gateway password is required")
|
|
}
|
|
if p.Type == "mongodb" && p.AuthSource == "" {
|
|
p.AuthSource = "admin"
|
|
}
|
|
}
|
|
if p.Outbound.Mode == "" {
|
|
p.Outbound.Mode = "direct"
|
|
}
|
|
if p.Outbound.Mode != "direct" && p.Outbound.Mode != "socks5" && p.Outbound.Mode != "ssh" && p.Outbound.Mode != "sing-box" && p.Outbound.Mode != "k8s" {
|
|
return errors.New("invalid outbound mode")
|
|
}
|
|
if p.Outbound.Mode == "sing-box" {
|
|
canonical, outboundType, err := ValidateSingBoxOutbound(p.Outbound.SingBoxOutbound)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Outbound.SingBoxOutbound, p.Outbound.SingBoxType = canonical, outboundType
|
|
p.Outbound.Host, p.Outbound.Port, p.Outbound.Auth = "", 0, ""
|
|
p.Outbound.Username, p.Outbound.Password = "", ""
|
|
p.Outbound.HostFingerprint, p.Outbound.SkipHostKeyVerify = "", false
|
|
p.Outbound.KubeConfig, p.Outbound.KubeConfigContent, p.Outbound.KubeConfigAssetID = "", "", ""
|
|
p.Outbound.KubeContext, p.Outbound.Namespace, p.Outbound.Resource = "", "", ""
|
|
} else {
|
|
p.Outbound.SingBoxOutbound, p.Outbound.SingBoxType = "", ""
|
|
}
|
|
if p.Outbound.Mode == "k8s" {
|
|
if err := p.Outbound.ValidateKubernetes(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.Outbound.Mode == "socks5" {
|
|
o := p.Outbound
|
|
if !host(o.Host) || !port(o.Port) {
|
|
return errors.New("invalid SOCKS5 address")
|
|
}
|
|
if o.Auth != "none" && o.Auth != "password" {
|
|
return errors.New("invalid SOCKS5 auth")
|
|
}
|
|
if o.Auth == "password" && (len(o.Username) < 1 || len(o.Username) > 255 || len(o.Password) < 1 || len(o.Password) > 255) {
|
|
return errors.New("SOCKS5 credentials must be 1-255 UTF-8 bytes")
|
|
}
|
|
}
|
|
if p.Outbound.Mode == "ssh" {
|
|
o := p.Outbound
|
|
if !host(o.Host) || !port(o.Port) {
|
|
return errors.New("invalid SSH forwarding address")
|
|
}
|
|
if o.Auth != "password" || o.Username == "" || o.Password == "" {
|
|
return errors.New("SSH forwarding requires username and password")
|
|
}
|
|
fingerprint, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(o.HostFingerprint, "SHA256:"))
|
|
if !o.SkipHostKeyVerify && (!strings.HasPrefix(o.HostFingerprint, "SHA256:") || err != nil || len(fingerprint) != 32) {
|
|
return errors.New("SSH forwarding requires a valid SHA256 host fingerprint")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (p *Proxy) ValidateGateway() error {
|
|
if err := p.ValidateTCPForward(); err != nil {
|
|
return err
|
|
}
|
|
probe := *p
|
|
probe.Cluster = ClusterConfig{}
|
|
probe.TargetHost, probe.TargetPort, probe.TargetUser = "validation", 1, "validation"
|
|
if p.Type == "tcp" {
|
|
probe.TargetUser = ""
|
|
}
|
|
probe.TargetCompression = "none"
|
|
probe.Outbound = Outbound{Mode: "direct"}
|
|
probe.OutboundID = ""
|
|
probe.SSH.AuthMethod = "password"
|
|
if err := probe.Validate(); err != nil {
|
|
return err
|
|
}
|
|
p.AccessMode = probe.AccessMode
|
|
return nil
|
|
}
|
|
func port(n int) bool { return n >= 1 && n <= 65535 }
|
|
|
|
func host(s string) bool {
|
|
return len(s) > 0 && len(s) <= 253 && !strings.ContainsAny(s, " /\\\t\r\n\x00")
|
|
}
|
|
func (p Proxy) Redacted() (Proxy, map[string]bool) {
|
|
p.Targets, p.TargetDialer = nil, nil
|
|
p.PreparedTarget = nil
|
|
p.TargetAccounts = nil
|
|
p.TargetCredentialError = nil
|
|
p.TargetCredentialExpiresAt = ""
|
|
flags := map[string]bool{"targetPassword": p.TargetPassword != "", "gatewayPassword": p.GatewayPassword != "", "socksPassword": p.Outbound.Password != "", "singBoxOutbound": p.Outbound.SingBoxOutbound != "", "sshPrivateKey": p.SSH.PrivateKey != "", "sshPassphrase": p.SSH.Passphrase != ""}
|
|
p.TargetPassword = ""
|
|
p.GatewayPassword = ""
|
|
p.Outbound.Password = ""
|
|
p.Outbound.SingBoxOutbound = ""
|
|
flags["kubeConfigContent"] = p.Outbound.KubeConfigContent != ""
|
|
p.Outbound.KubeConfigContent = ""
|
|
p.SSH.PrivateKey = ""
|
|
p.SSH.Certificate = ""
|
|
if p.TargetCAAssetID != "" {
|
|
p.TargetCA = ""
|
|
}
|
|
p.SSH.Passphrase = ""
|
|
p.SSH.HostKey = ""
|
|
p.GatewayAccounts = append([]GatewayAccount(nil), p.GatewayAccounts...)
|
|
for i := range p.GatewayAccounts {
|
|
a := &p.GatewayAccounts[i]
|
|
flags["gatewayAccount:"+a.ID+":password"] = a.Password != ""
|
|
a.Password = ""
|
|
if a.AuthorizedKeysAssetID != "" {
|
|
a.AuthorizedKeys = nil
|
|
}
|
|
}
|
|
return p, flags
|
|
}
|
|
func Merge(current Proxy, patch map[string]any) (Proxy, error) {
|
|
if err := exactFields(patch, reflect.TypeOf(Proxy{})); err != nil {
|
|
return Proxy{}, err
|
|
}
|
|
raw, _ := json.Marshal(current)
|
|
var base map[string]any
|
|
_ = json.Unmarshal(raw, &base)
|
|
if fields, ok := patch["tcpForward"].(map[string]any); ok && current.TCPForward != nil {
|
|
if mode, ok := fields["mode"].(string); ok && (mode == "proxy" || mode == "socks5" || mode == "k8s") && current.TCPForward.mode() != mode {
|
|
// Shared-proxy and legacy SOCKS5 mappings use the same reference
|
|
// fields. Only inline Kubernetes owns a different field set.
|
|
if mode == "k8s" || current.TCPForward.mode() == "k8s" {
|
|
base["tcpForward"] = map[string]any{}
|
|
}
|
|
}
|
|
}
|
|
merge(base, patch)
|
|
raw, _ = json.Marshal(base)
|
|
var out Proxy
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.DisallowUnknownFields()
|
|
err := dec.Decode(&out)
|
|
if err == nil {
|
|
err = mergeGatewaySecrets(current, patch, &out)
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// encoding/json otherwise accepts case-insensitive aliases, which could bypass
|
|
// server-managed field guards. Configuration patches use exact JSON names only.
|
|
func exactFields(patch map[string]any, t reflect.Type) error {
|
|
return exactFieldsAt(patch, t, "")
|
|
}
|
|
|
|
func exactFieldsAt(patch map[string]any, t reflect.Type, prefix string) error {
|
|
fields := map[string]reflect.Type{}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
name := strings.Split(f.Tag.Get("json"), ",")[0]
|
|
if name != "-" && name != "" {
|
|
fields[name] = f.Type
|
|
}
|
|
}
|
|
for k, v := range patch {
|
|
path := prefix + k
|
|
ft, ok := fields[k]
|
|
if !ok {
|
|
return &ConfigurationFieldError{Path: path, Reason: "unknown configuration field"}
|
|
}
|
|
if v == nil {
|
|
if ft.Kind() == reflect.Ptr {
|
|
continue
|
|
}
|
|
return &ConfigurationFieldError{Path: path, Reason: "configuration fields cannot be null"}
|
|
}
|
|
if ft.Kind() == reflect.Ptr {
|
|
ft = ft.Elem()
|
|
}
|
|
if ft.Kind() == reflect.Struct {
|
|
sub, ok := v.(map[string]any)
|
|
if !ok {
|
|
return &ConfigurationFieldError{Path: path, Reason: "configuration object expected"}
|
|
}
|
|
if err := exactFieldsAt(sub, ft, path+"."); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.Struct {
|
|
if items, ok := v.([]any); ok {
|
|
for index, item := range items {
|
|
if sub, ok := item.(map[string]any); ok {
|
|
if err := exactFieldsAt(sub, ft.Elem(), path+"["+strconv.Itoa(index)+"]."); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func merge(a, b map[string]any) {
|
|
for k, v := range b {
|
|
if sub, ok := v.(map[string]any); ok {
|
|
dst, ok := a[k].(map[string]any)
|
|
if !ok {
|
|
dst = map[string]any{}
|
|
}
|
|
merge(dst, sub)
|
|
a[k] = dst
|
|
} else {
|
|
a[k] = v
|
|
}
|
|
}
|
|
}
|