Files

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
}
}
}