sftpgo-mirror/internal/dataprovider/configs.go

650 lines
17 KiB
Go
Raw Normal View History

// Copyright (C) 2019 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dataprovider
import (
"bytes"
"encoding/json"
"fmt"
"image/png"
"net/url"
"slices"
"golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
// Supported values for host keys, KEXs, ciphers, MACs
var (
supportedHostKeyAlgos = []string{ssh.KeyAlgoRSA}
supportedPublicKeyAlgos = []string{ssh.KeyAlgoRSA, ssh.InsecureKeyAlgoDSA}
supportedKexAlgos = []string{
ssh.KeyExchangeDH16SHA512, ssh.InsecureKeyExchangeDH14SHA1, ssh.InsecureKeyExchangeDH1SHA1,
ssh.InsecureKeyExchangeDHGEXSHA1,
}
supportedCiphers = []string{
ssh.InsecureCipherAES128CBC, ssh.InsecureCipherAES192CBC, ssh.InsecureCipherAES256CBC,
ssh.InsecureCipherTripleDESCBC,
}
supportedMACs = []string{
ssh.HMACSHA512ETM, ssh.HMACSHA512,
ssh.InsecureHMACSHA1, ssh.InsecureHMACSHA196,
}
)
// SFTPDConfigs defines configurations for SFTPD
type SFTPDConfigs struct {
HostKeyAlgos []string `json:"host_key_algos,omitempty"`
PublicKeyAlgos []string `json:"public_key_algos,omitempty"`
KexAlgorithms []string `json:"kex_algorithms,omitempty"`
Ciphers []string `json:"ciphers,omitempty"`
MACs []string `json:"macs,omitempty"`
}
func (c *SFTPDConfigs) isEmpty() bool {
if len(c.HostKeyAlgos) > 0 {
return false
}
if len(c.PublicKeyAlgos) > 0 {
return false
}
if len(c.KexAlgorithms) > 0 {
return false
}
if len(c.Ciphers) > 0 {
return false
}
if len(c.MACs) > 0 {
return false
}
return true
}
// GetSupportedHostKeyAlgos returns the supported legacy host key algos
func (*SFTPDConfigs) GetSupportedHostKeyAlgos() []string {
return supportedHostKeyAlgos
}
// GetSupportedPublicKeyAlgos returns the supported legacy public key algos
func (*SFTPDConfigs) GetSupportedPublicKeyAlgos() []string {
return supportedPublicKeyAlgos
}
// GetSupportedKEXAlgos returns the supported KEX algos
func (*SFTPDConfigs) GetSupportedKEXAlgos() []string {
return supportedKexAlgos
}
// GetSupportedCiphers returns the supported ciphers
func (*SFTPDConfigs) GetSupportedCiphers() []string {
return supportedCiphers
}
// GetSupportedMACs returns the supported MACs algos
func (*SFTPDConfigs) GetSupportedMACs() []string {
return supportedMACs
}
func (c *SFTPDConfigs) validate() error {
var hostKeyAlgos []string
for _, algo := range c.HostKeyAlgos {
if algo == ssh.CertAlgoRSAv01 {
continue
}
if !slices.Contains(supportedHostKeyAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported host key algorithm %q", algo))
}
hostKeyAlgos = append(hostKeyAlgos, algo)
}
c.HostKeyAlgos = hostKeyAlgos
var kexAlgos []string
for _, algo := range c.KexAlgorithms {
if algo == "diffie-hellman-group18-sha512" || algo == ssh.KeyExchangeDHGEXSHA256 {
continue
}
if !slices.Contains(supportedKexAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported KEX algorithm %q", algo))
}
kexAlgos = append(kexAlgos, algo)
}
c.KexAlgorithms = kexAlgos
for _, cipher := range c.Ciphers {
if !slices.Contains(supportedCiphers, cipher) {
return util.NewValidationError(fmt.Sprintf("unsupported cipher %q", cipher))
}
}
for _, mac := range c.MACs {
if !slices.Contains(supportedMACs, mac) {
return util.NewValidationError(fmt.Sprintf("unsupported MAC algorithm %q", mac))
}
}
for _, algo := range c.PublicKeyAlgos {
if !slices.Contains(supportedPublicKeyAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported public key algorithm %q", algo))
}
}
return nil
}
func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
hostKeys := make([]string, len(c.HostKeyAlgos))
copy(hostKeys, c.HostKeyAlgos)
publicKeys := make([]string, len(c.PublicKeyAlgos))
copy(publicKeys, c.PublicKeyAlgos)
kexs := make([]string, len(c.KexAlgorithms))
copy(kexs, c.KexAlgorithms)
ciphers := make([]string, len(c.Ciphers))
copy(ciphers, c.Ciphers)
macs := make([]string, len(c.MACs))
copy(macs, c.MACs)
return &SFTPDConfigs{
HostKeyAlgos: hostKeys,
PublicKeyAlgos: publicKeys,
KexAlgorithms: kexs,
Ciphers: ciphers,
MACs: macs,
}
}
func validateSMTPSecret(secret *kms.Secret, name string) error {
if secret.IsRedacted() {
return util.NewValidationError(fmt.Sprintf("cannot save a redacted smtp %s", name))
}
if secret.IsEncrypted() && !secret.IsValid() {
return util.NewValidationError(fmt.Sprintf("invalid encrypted smtp %s", name))
}
if !secret.IsEmpty() && !secret.IsValidInput() {
return util.NewValidationError(fmt.Sprintf("invalid smtp %s", name))
}
if secret.IsPlain() {
secret.SetAdditionalData("smtp")
if err := secret.Encrypt(); err != nil {
return util.NewValidationError(fmt.Sprintf("could not encrypt smtp %s: %v", name, err))
}
}
return nil
}
// SMTPOAuth2 defines the SMTP related OAuth2 configurations
type SMTPOAuth2 struct {
Provider int `json:"provider,omitempty"`
Tenant string `json:"tenant,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret *kms.Secret `json:"client_secret,omitempty"`
RefreshToken *kms.Secret `json:"refresh_token,omitempty"`
}
func (c *SMTPOAuth2) validate() error {
if c.Provider < 0 || c.Provider > 1 {
return util.NewValidationError("smtp oauth2: unsupported provider")
}
if c.ClientID == "" {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: client id is required"),
util.I18nErrorClientIDRequired,
)
}
if c.ClientSecret == nil || c.ClientSecret.IsEmpty() {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: client secret is required"),
util.I18nErrorClientSecretRequired,
)
}
if c.RefreshToken == nil || c.RefreshToken.IsEmpty() {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: refresh token is required"),
util.I18nErrorRefreshTokenRequired,
)
}
if err := validateSMTPSecret(c.ClientSecret, "oauth2 client secret"); err != nil {
return err
}
return validateSMTPSecret(c.RefreshToken, "oauth2 refresh token")
}
func (c *SMTPOAuth2) getACopy() SMTPOAuth2 {
var clientSecret, refreshToken *kms.Secret
if c.ClientSecret != nil {
clientSecret = c.ClientSecret.Clone()
}
if c.RefreshToken != nil {
refreshToken = c.RefreshToken.Clone()
}
return SMTPOAuth2{
Provider: c.Provider,
Tenant: c.Tenant,
ClientID: c.ClientID,
ClientSecret: clientSecret,
RefreshToken: refreshToken,
}
}
// SMTPConfigs defines configuration for SMTP
type SMTPConfigs struct {
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
From string `json:"from,omitempty"`
User string `json:"user,omitempty"`
Password *kms.Secret `json:"password,omitempty"`
AuthType int `json:"auth_type,omitempty"`
Encryption int `json:"encryption,omitempty"`
Domain string `json:"domain,omitempty"`
Debug int `json:"debug,omitempty"`
OAuth2 SMTPOAuth2 `json:"oauth2"`
}
// IsEmpty returns true if the configuration is empty
func (c *SMTPConfigs) IsEmpty() bool {
return c.Host == ""
}
func (c *SMTPConfigs) validate() error {
if c.IsEmpty() {
return nil
}
if c.Port <= 0 || c.Port > 65535 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid port %d", c.Port))
}
if c.Password != nil && c.AuthType != 3 {
if err := validateSMTPSecret(c.Password, "password"); err != nil {
return err
}
}
if c.User == "" && c.From == "" {
return util.NewI18nError(
util.NewValidationError("smtp: from address and user cannot both be empty"),
util.I18nErrorSMTPRequiredFields,
)
}
if c.AuthType < 0 || c.AuthType > 3 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid auth type %d", c.AuthType))
}
if c.Encryption < 0 || c.Encryption > 2 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid encryption %d", c.Encryption))
}
if c.AuthType == 3 {
c.Password = kms.NewEmptySecret()
return c.OAuth2.validate()
}
c.OAuth2 = SMTPOAuth2{}
return nil
}
// TryDecrypt tries to decrypt the encrypted secrets
func (c *SMTPConfigs) TryDecrypt() error {
if c.Password == nil {
c.Password = kms.NewEmptySecret()
}
if c.OAuth2.ClientSecret == nil {
c.OAuth2.ClientSecret = kms.NewEmptySecret()
}
if c.OAuth2.RefreshToken == nil {
c.OAuth2.RefreshToken = kms.NewEmptySecret()
}
if err := c.Password.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp password: %w", err)
}
if err := c.OAuth2.ClientSecret.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp oauth2 client secret: %w", err)
}
if err := c.OAuth2.RefreshToken.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp oauth2 refresh token: %w", err)
}
return nil
}
func (c *SMTPConfigs) prepareForRendering() {
if c.Password != nil {
c.Password.Hide()
if c.Password.IsEmpty() {
c.Password = nil
}
}
if c.OAuth2.ClientSecret != nil {
c.OAuth2.ClientSecret.Hide()
if c.OAuth2.ClientSecret.IsEmpty() {
c.OAuth2.ClientSecret = nil
}
}
if c.OAuth2.RefreshToken != nil {
c.OAuth2.RefreshToken.Hide()
if c.OAuth2.RefreshToken.IsEmpty() {
c.OAuth2.RefreshToken = nil
}
}
}
func (c *SMTPConfigs) getACopy() *SMTPConfigs {
var password *kms.Secret
if c.Password != nil {
password = c.Password.Clone()
}
return &SMTPConfigs{
Host: c.Host,
Port: c.Port,
From: c.From,
User: c.User,
Password: password,
AuthType: c.AuthType,
Encryption: c.Encryption,
Domain: c.Domain,
Debug: c.Debug,
OAuth2: c.OAuth2.getACopy(),
}
}
// ACMEHTTP01Challenge defines the configuration for HTTP-01 challenge type
type ACMEHTTP01Challenge struct {
Port int `json:"port"`
}
// ACMEConfigs defines ACME related configuration
type ACMEConfigs struct {
Domain string `json:"domain"`
Email string `json:"email"`
HTTP01Challenge ACMEHTTP01Challenge `json:"http01_challenge"`
// apply the certificate for the specified protocols:
//
// 1 means HTTP
// 2 means FTP
// 4 means WebDAV
//
// Protocols can be combined
Protocols int `json:"protocols"`
}
func (c *ACMEConfigs) isEmpty() bool {
return c.Domain == ""
}
func (c *ACMEConfigs) validate() error {
if c.Domain == "" {
return nil
}
if c.Email == "" && !util.IsEmailValid(c.Email) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("acme: invalid email %q", c.Email)),
util.I18nErrorInvalidEmail,
)
}
if c.HTTP01Challenge.Port <= 0 || c.HTTP01Challenge.Port > 65535 {
return util.NewValidationError(fmt.Sprintf("acme: invalid HTTP-01 challenge port %d", c.HTTP01Challenge.Port))
}
return nil
}
// HasProtocol returns true if the ACME certificate must be used for the specified protocol
func (c *ACMEConfigs) HasProtocol(protocol string) bool {
switch protocol {
case protocolHTTP:
return c.Protocols&1 != 0
case protocolFTP:
return c.Protocols&2 != 0
case protocolWebDAV:
return c.Protocols&4 != 0
default:
return false
}
}
func (c *ACMEConfigs) getACopy() *ACMEConfigs {
return &ACMEConfigs{
Email: c.Email,
Domain: c.Domain,
HTTP01Challenge: ACMEHTTP01Challenge{Port: c.HTTP01Challenge.Port},
Protocols: c.Protocols,
}
}
// BrandingConfig defines the branding configuration
type BrandingConfig struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Logo []byte `json:"logo"`
Favicon []byte `json:"favicon"`
DisclaimerName string `json:"disclaimer_name"`
DisclaimerURL string `json:"disclaimer_url"`
}
func (c *BrandingConfig) isEmpty() bool {
if c.Name != "" {
return false
}
if c.ShortName != "" {
return false
}
if len(c.Logo) > 0 {
return false
}
if len(c.Favicon) > 0 {
return false
}
if c.DisclaimerName != "" && c.DisclaimerURL != "" {
return false
}
return true
}
func (*BrandingConfig) validatePNG(b []byte, maxWidth, maxHeight int) error {
if len(b) == 0 {
return nil
}
// DecodeConfig is more efficient, but I'm not sure if this would lead to
// accepting invalid images in some edge cases and performance does not
// matter here.
img, err := png.Decode(bytes.NewBuffer(b))
if err != nil {
return util.NewI18nError(
util.NewValidationError("invalid PNG image"),
util.I18nErrorInvalidPNG,
)
}
bounds := img.Bounds()
if bounds.Dx() > maxWidth || bounds.Dy() > maxHeight {
return util.NewI18nError(
util.NewValidationError("invalid PNG image size"),
util.I18nErrorInvalidPNGSize,
)
}
return nil
}
func (c *BrandingConfig) validateDisclaimerURL() error {
if c.DisclaimerURL == "" {
return nil
}
u, err := url.Parse(c.DisclaimerURL)
if err != nil {
return util.NewI18nError(
util.NewValidationError("invalid disclaimer URL"),
util.I18nErrorInvalidDisclaimerURL,
)
}
if u.Scheme != "http" && u.Scheme != "https" {
return util.NewI18nError(
util.NewValidationError("invalid disclaimer URL scheme"),
util.I18nErrorInvalidDisclaimerURL,
)
}
return nil
}
func (c *BrandingConfig) validate() error {
if err := c.validateDisclaimerURL(); err != nil {
return err
}
if err := c.validatePNG(c.Logo, 512, 512); err != nil {
return err
}
return c.validatePNG(c.Favicon, 256, 256)
}
func (c *BrandingConfig) getACopy() BrandingConfig {
logo := make([]byte, len(c.Logo))
copy(logo, c.Logo)
favicon := make([]byte, len(c.Favicon))
copy(favicon, c.Favicon)
return BrandingConfig{
Name: c.Name,
ShortName: c.ShortName,
Logo: logo,
Favicon: favicon,
DisclaimerName: c.DisclaimerName,
DisclaimerURL: c.DisclaimerURL,
}
}
// BrandingConfigs defines the branding configuration for WebAdmin and WebClient UI
type BrandingConfigs struct {
WebAdmin BrandingConfig
WebClient BrandingConfig
}
func (c *BrandingConfigs) isEmpty() bool {
return c.WebAdmin.isEmpty() && c.WebClient.isEmpty()
}
func (c *BrandingConfigs) validate() error {
if err := c.WebAdmin.validate(); err != nil {
return err
}
return c.WebClient.validate()
}
func (c *BrandingConfigs) getACopy() *BrandingConfigs {
return &BrandingConfigs{
WebAdmin: c.WebAdmin.getACopy(),
WebClient: c.WebClient.getACopy(),
}
}
// Configs allows to set configuration keys disabled by default without
// modifying the config file or setting env vars
type Configs struct {
SFTPD *SFTPDConfigs `json:"sftpd,omitempty"`
SMTP *SMTPConfigs `json:"smtp,omitempty"`
ACME *ACMEConfigs `json:"acme,omitempty"`
Branding *BrandingConfigs `json:"branding,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
}
func (c *Configs) validate() error {
if c.SFTPD != nil {
if err := c.SFTPD.validate(); err != nil {
return err
}
}
if c.SMTP != nil {
if err := c.SMTP.validate(); err != nil {
return err
}
}
if c.ACME != nil {
if err := c.ACME.validate(); err != nil {
return err
}
}
if c.Branding != nil {
if err := c.Branding.validate(); err != nil {
return err
}
}
return nil
}
// PrepareForRendering prepares configs for rendering.
// It hides confidential data and set to nil the empty structs/secrets
// so they are not serialized
func (c *Configs) PrepareForRendering() {
if c.SFTPD != nil && c.SFTPD.isEmpty() {
c.SFTPD = nil
}
if c.SMTP != nil && c.SMTP.IsEmpty() {
c.SMTP = nil
}
if c.ACME != nil && c.ACME.isEmpty() {
c.ACME = nil
}
if c.Branding != nil && c.Branding.isEmpty() {
c.Branding = nil
}
if c.SMTP != nil {
c.SMTP.prepareForRendering()
}
}
// SetNilsToEmpty sets nil fields to empty
func (c *Configs) SetNilsToEmpty() {
if c.SFTPD == nil {
c.SFTPD = &SFTPDConfigs{}
}
if c.SMTP == nil {
c.SMTP = &SMTPConfigs{}
}
if c.SMTP.Password == nil {
c.SMTP.Password = kms.NewEmptySecret()
}
if c.SMTP.OAuth2.ClientSecret == nil {
c.SMTP.OAuth2.ClientSecret = kms.NewEmptySecret()
}
if c.SMTP.OAuth2.RefreshToken == nil {
c.SMTP.OAuth2.RefreshToken = kms.NewEmptySecret()
}
if c.ACME == nil {
c.ACME = &ACMEConfigs{}
}
if c.Branding == nil {
c.Branding = &BrandingConfigs{}
}
}
// RenderAsJSON implements the renderer interface used within plugins
func (c *Configs) RenderAsJSON(reload bool) ([]byte, error) {
if reload {
config, err := provider.getConfigs()
if err != nil {
providerLog(logger.LevelError, "unable to reload config overrides before rendering as json: %v", err)
return nil, err
}
config.PrepareForRendering()
return json.Marshal(config)
}
c.PrepareForRendering()
return json.Marshal(c)
}
func (c *Configs) getACopy() Configs {
var result Configs
if c.SFTPD != nil {
result.SFTPD = c.SFTPD.getACopy()
}
if c.SMTP != nil {
result.SMTP = c.SMTP.getACopy()
}
if c.ACME != nil {
result.ACME = c.ACME.getACopy()
}
if c.Branding != nil {
result.Branding = c.Branding.getACopy()
}
result.UpdatedAt = c.UpdatedAt
return result
}