mirror of
https://github.com/ergochat/ergo.git
synced 2024-11-24 12:59:33 +01:00
1769 lines
54 KiB
Go
1769 lines
54 KiB
Go
// Copyright (c) 2012-2014 Jeremy Latt
|
|
// Copyright (c) 2014-2015 Edmund Huber
|
|
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
|
|
// released under the MIT license
|
|
|
|
package irc
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.cloudfoundry.org/bytefmt"
|
|
"github.com/ergochat/irc-go/ircfmt"
|
|
"gopkg.in/yaml.v2"
|
|
|
|
"github.com/ergochat/ergo/irc/caps"
|
|
"github.com/ergochat/ergo/irc/cloaks"
|
|
"github.com/ergochat/ergo/irc/connection_limits"
|
|
"github.com/ergochat/ergo/irc/custime"
|
|
"github.com/ergochat/ergo/irc/email"
|
|
"github.com/ergochat/ergo/irc/isupport"
|
|
"github.com/ergochat/ergo/irc/jwt"
|
|
"github.com/ergochat/ergo/irc/languages"
|
|
"github.com/ergochat/ergo/irc/logger"
|
|
"github.com/ergochat/ergo/irc/modes"
|
|
"github.com/ergochat/ergo/irc/mysql"
|
|
"github.com/ergochat/ergo/irc/passwd"
|
|
"github.com/ergochat/ergo/irc/utils"
|
|
)
|
|
|
|
// here's how this works: exported (capitalized) members of the config structs
|
|
// are defined in the YAML file and deserialized directly from there. They may
|
|
// be postprocessed and overwritten by LoadConfig. Unexported (lowercase) members
|
|
// are derived from the exported members in LoadConfig.
|
|
|
|
// TLSListenConfig defines configuration options for listening on TLS.
|
|
type TLSListenConfig struct {
|
|
Cert string
|
|
Key string
|
|
Proxy bool // XXX: legacy key: it's preferred to specify this directly in listenerConfigBlock
|
|
}
|
|
|
|
// This is the YAML-deserializable type of the value of the `Server.Listeners` map
|
|
type listenerConfigBlock struct {
|
|
// normal TLS configuration, with a single certificate:
|
|
TLS TLSListenConfig
|
|
// SNI configuration, with multiple certificates:
|
|
TLSCertificates []TLSListenConfig `yaml:"tls-certificates"`
|
|
MinTLSVersion string `yaml:"min-tls-version"`
|
|
Proxy bool
|
|
Tor bool
|
|
STSOnly bool `yaml:"sts-only"`
|
|
WebSocket bool
|
|
HideSTS bool `yaml:"hide-sts"`
|
|
}
|
|
|
|
type HistoryCutoff uint
|
|
|
|
const (
|
|
HistoryCutoffDefault HistoryCutoff = iota
|
|
HistoryCutoffNone
|
|
HistoryCutoffRegistrationTime
|
|
HistoryCutoffJoinTime
|
|
)
|
|
|
|
func historyCutoffToString(restriction HistoryCutoff) string {
|
|
switch restriction {
|
|
case HistoryCutoffDefault:
|
|
return "default"
|
|
case HistoryCutoffNone:
|
|
return "none"
|
|
case HistoryCutoffRegistrationTime:
|
|
return "registration-time"
|
|
case HistoryCutoffJoinTime:
|
|
return "join-time"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func historyCutoffFromString(str string) (result HistoryCutoff, err error) {
|
|
switch strings.ToLower(str) {
|
|
case "default":
|
|
return HistoryCutoffDefault, nil
|
|
case "none", "disabled", "off", "false":
|
|
return HistoryCutoffNone, nil
|
|
case "registration-time":
|
|
return HistoryCutoffRegistrationTime, nil
|
|
case "join-time":
|
|
return HistoryCutoffJoinTime, nil
|
|
default:
|
|
return HistoryCutoffDefault, errInvalidParams
|
|
}
|
|
}
|
|
|
|
type PersistentStatus uint
|
|
|
|
const (
|
|
PersistentUnspecified PersistentStatus = iota
|
|
PersistentDisabled
|
|
PersistentOptIn
|
|
PersistentOptOut
|
|
PersistentMandatory
|
|
)
|
|
|
|
func persistentStatusToString(status PersistentStatus) string {
|
|
switch status {
|
|
case PersistentUnspecified:
|
|
return "default"
|
|
case PersistentDisabled:
|
|
return "disabled"
|
|
case PersistentOptIn:
|
|
return "opt-in"
|
|
case PersistentOptOut:
|
|
return "opt-out"
|
|
case PersistentMandatory:
|
|
return "mandatory"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func persistentStatusFromString(status string) (PersistentStatus, error) {
|
|
switch strings.ToLower(status) {
|
|
case "default":
|
|
return PersistentUnspecified, nil
|
|
case "":
|
|
return PersistentDisabled, nil
|
|
case "opt-in":
|
|
return PersistentOptIn, nil
|
|
case "opt-out":
|
|
return PersistentOptOut, nil
|
|
case "mandatory":
|
|
return PersistentMandatory, nil
|
|
default:
|
|
b, err := utils.StringToBool(status)
|
|
if b {
|
|
return PersistentMandatory, err
|
|
} else {
|
|
return PersistentDisabled, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ps *PersistentStatus) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
var orig string
|
|
var err error
|
|
if err = unmarshal(&orig); err != nil {
|
|
return err
|
|
}
|
|
result, err := persistentStatusFromString(orig)
|
|
if err == nil {
|
|
if result == PersistentUnspecified {
|
|
result = PersistentDisabled
|
|
}
|
|
*ps = result
|
|
} else {
|
|
err = fmt.Errorf("invalid value `%s` for server persistence status: %w", orig, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func persistenceEnabled(serverSetting, clientSetting PersistentStatus) (enabled bool) {
|
|
if serverSetting == PersistentDisabled {
|
|
return false
|
|
} else if serverSetting == PersistentMandatory {
|
|
return true
|
|
} else if clientSetting == PersistentDisabled {
|
|
return false
|
|
} else if clientSetting == PersistentMandatory {
|
|
return true
|
|
} else if serverSetting == PersistentOptOut {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
type HistoryStatus uint
|
|
|
|
const (
|
|
HistoryDefault HistoryStatus = iota
|
|
HistoryDisabled
|
|
HistoryEphemeral
|
|
HistoryPersistent
|
|
)
|
|
|
|
func historyStatusFromString(str string) (status HistoryStatus, err error) {
|
|
switch strings.ToLower(str) {
|
|
case "default":
|
|
return HistoryDefault, nil
|
|
case "ephemeral":
|
|
return HistoryEphemeral, nil
|
|
case "persistent":
|
|
return HistoryPersistent, nil
|
|
default:
|
|
b, err := utils.StringToBool(str)
|
|
if b {
|
|
return HistoryPersistent, err
|
|
} else {
|
|
return HistoryDisabled, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func historyStatusToString(status HistoryStatus) string {
|
|
switch status {
|
|
case HistoryDefault:
|
|
return "default"
|
|
case HistoryDisabled:
|
|
return "disabled"
|
|
case HistoryEphemeral:
|
|
return "ephemeral"
|
|
case HistoryPersistent:
|
|
return "persistent"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// XXX you must have already checked History.Enabled before calling this
|
|
func historyEnabled(serverSetting PersistentStatus, localSetting HistoryStatus) (result HistoryStatus) {
|
|
switch serverSetting {
|
|
case PersistentMandatory:
|
|
return HistoryPersistent
|
|
case PersistentOptOut:
|
|
if localSetting == HistoryDefault {
|
|
return HistoryPersistent
|
|
} else {
|
|
return localSetting
|
|
}
|
|
case PersistentOptIn:
|
|
switch localSetting {
|
|
case HistoryPersistent:
|
|
return HistoryPersistent
|
|
case HistoryEphemeral, HistoryDefault:
|
|
return HistoryEphemeral
|
|
default:
|
|
return HistoryDisabled
|
|
}
|
|
case PersistentDisabled:
|
|
if localSetting == HistoryDisabled {
|
|
return HistoryDisabled
|
|
} else {
|
|
return HistoryEphemeral
|
|
}
|
|
default:
|
|
// PersistentUnspecified: shouldn't happen because the deserializer converts it
|
|
// to PersistentDisabled
|
|
if localSetting == HistoryDefault {
|
|
return HistoryEphemeral
|
|
} else {
|
|
return localSetting
|
|
}
|
|
}
|
|
}
|
|
|
|
type MulticlientConfig struct {
|
|
Enabled bool
|
|
AllowedByDefault bool `yaml:"allowed-by-default"`
|
|
AlwaysOn PersistentStatus `yaml:"always-on"`
|
|
AutoAway PersistentStatus `yaml:"auto-away"`
|
|
AlwaysOnExpiration custime.Duration `yaml:"always-on-expiration"`
|
|
}
|
|
|
|
type throttleConfig struct {
|
|
Enabled bool
|
|
Duration time.Duration
|
|
MaxAttempts int `yaml:"max-attempts"`
|
|
}
|
|
|
|
type ThrottleConfig struct {
|
|
throttleConfig
|
|
}
|
|
|
|
func (t *ThrottleConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
|
|
// note that this technique only works if the zero value of the struct
|
|
// doesn't need any postprocessing (because if the field is omitted entirely
|
|
// from the YAML, then UnmarshalYAML won't be called at all)
|
|
if err = unmarshal(&t.throttleConfig); err != nil {
|
|
return
|
|
}
|
|
if !t.Enabled {
|
|
t.MaxAttempts = 0 // limit of 0 means disabled
|
|
}
|
|
return
|
|
}
|
|
|
|
type MetadataKeyConfig struct {
|
|
AllowedKeys []string `yaml:"allowed-keys"`
|
|
AllowedKeysMatcher *regexp.Regexp
|
|
BlockedKeys []string `yaml:"blocked-keys"`
|
|
BlockedKeysMatcher *regexp.Regexp
|
|
RestrictedKeys []string `yaml:"restricted-keys"`
|
|
RestrictedKeysMatcher *regexp.Regexp
|
|
}
|
|
|
|
func (mkc *MetadataKeyConfig) compileMatchers() (err error) {
|
|
mkc.AllowedKeysMatcher, err = utils.CompileMasks(mkc.AllowedKeys)
|
|
if err != nil {
|
|
err = errors.New("allowed-keys")
|
|
}
|
|
mkc.BlockedKeysMatcher, err = utils.CompileMasks(mkc.BlockedKeys)
|
|
if err != nil {
|
|
err = errors.New("blocked-keys")
|
|
}
|
|
mkc.RestrictedKeysMatcher, err = utils.CompileMasks(mkc.RestrictedKeys)
|
|
if err != nil {
|
|
err = errors.New("restricted-keys")
|
|
}
|
|
return err
|
|
}
|
|
|
|
type MetadataConfig struct {
|
|
MaxKeys int `yaml:"max-keys"`
|
|
MaxSubs int `yaml:"max-subs"`
|
|
Users MetadataKeyConfig
|
|
Channels MetadataKeyConfig
|
|
}
|
|
|
|
type AccountConfig struct {
|
|
Registration AccountRegistrationConfig
|
|
AuthenticationEnabled bool `yaml:"authentication-enabled"`
|
|
AdvertiseSCRAM bool `yaml:"advertise-scram"` // undocumented, see #1782
|
|
RequireSasl struct {
|
|
Enabled bool
|
|
Exempted []string
|
|
exemptedNets []net.IPNet
|
|
} `yaml:"require-sasl"`
|
|
DefaultUserModes *string `yaml:"default-user-modes"`
|
|
defaultUserModes modes.Modes
|
|
LoginThrottling ThrottleConfig `yaml:"login-throttling"`
|
|
SkipServerPassword bool `yaml:"skip-server-password"`
|
|
LoginViaPassCommand bool `yaml:"login-via-pass-command"`
|
|
NickReservation struct {
|
|
Enabled bool
|
|
AdditionalNickLimit int `yaml:"additional-nick-limit"`
|
|
Method NickEnforcementMethod
|
|
AllowCustomEnforcement bool `yaml:"allow-custom-enforcement"`
|
|
// RenamePrefix is the legacy field, GuestFormat is the new version
|
|
RenamePrefix string `yaml:"rename-prefix"`
|
|
GuestFormat string `yaml:"guest-nickname-format"`
|
|
guestRegexp *regexp.Regexp
|
|
guestRegexpFolded *regexp.Regexp
|
|
ForceGuestFormat bool `yaml:"force-guest-format"`
|
|
ForceNickEqualsAccount bool `yaml:"force-nick-equals-account"`
|
|
ForbidAnonNickChanges bool `yaml:"forbid-anonymous-nick-changes"`
|
|
} `yaml:"nick-reservation"`
|
|
Multiclient MulticlientConfig
|
|
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
|
|
VHosts VHostConfig
|
|
AuthScript AuthScriptConfig `yaml:"auth-script"`
|
|
}
|
|
|
|
type ScriptConfig struct {
|
|
Enabled bool
|
|
Command string
|
|
Args []string
|
|
Timeout time.Duration
|
|
KillTimeout time.Duration `yaml:"kill-timeout"`
|
|
MaxConcurrency uint `yaml:"max-concurrency"`
|
|
}
|
|
|
|
type AuthScriptConfig struct {
|
|
ScriptConfig `yaml:",inline"`
|
|
Autocreate bool
|
|
}
|
|
|
|
// AccountRegistrationConfig controls account registration.
|
|
type AccountRegistrationConfig struct {
|
|
Enabled bool
|
|
AllowBeforeConnect bool `yaml:"allow-before-connect"`
|
|
Throttling ThrottleConfig
|
|
// new-style (v2.4 email verification config):
|
|
EmailVerification email.MailtoConfig `yaml:"email-verification"`
|
|
// old-style email verification config, with "callbacks":
|
|
LegacyEnabledCallbacks []string `yaml:"enabled-callbacks"`
|
|
LegacyCallbacks struct {
|
|
Mailto email.MailtoConfig
|
|
} `yaml:"callbacks"`
|
|
VerifyTimeout custime.Duration `yaml:"verify-timeout"`
|
|
BcryptCost uint `yaml:"bcrypt-cost"`
|
|
}
|
|
|
|
type VHostConfig struct {
|
|
Enabled bool
|
|
MaxLength int `yaml:"max-length"`
|
|
ValidRegexpRaw string `yaml:"valid-regexp"`
|
|
validRegexp *regexp.Regexp
|
|
}
|
|
|
|
type NickEnforcementMethod int
|
|
|
|
const (
|
|
// NickEnforcementOptional is the zero value; it serializes to
|
|
// "optional" in the yaml config, and "default" as an arg to `NS ENFORCE`.
|
|
// in both cases, it means "defer to the other source of truth", i.e.,
|
|
// in the config, defer to the user's custom setting, and as a custom setting,
|
|
// defer to the default in the config. if both are NickEnforcementOptional then
|
|
// there is no enforcement.
|
|
// XXX: these are serialized as numbers in the database, so beware of collisions
|
|
// when refactoring (any numbers currently in use must keep their meanings, or
|
|
// else be fixed up by a schema change)
|
|
NickEnforcementOptional NickEnforcementMethod = iota
|
|
NickEnforcementNone
|
|
NickEnforcementStrict
|
|
)
|
|
|
|
func nickReservationToString(method NickEnforcementMethod) string {
|
|
switch method {
|
|
case NickEnforcementOptional:
|
|
return "default"
|
|
case NickEnforcementNone:
|
|
return "none"
|
|
case NickEnforcementStrict:
|
|
return "strict"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func nickReservationFromString(method string) (NickEnforcementMethod, error) {
|
|
switch strings.ToLower(method) {
|
|
case "default":
|
|
return NickEnforcementOptional, nil
|
|
case "optional":
|
|
return NickEnforcementOptional, nil
|
|
case "none":
|
|
return NickEnforcementNone, nil
|
|
case "strict":
|
|
return NickEnforcementStrict, nil
|
|
default:
|
|
return NickEnforcementOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method)
|
|
}
|
|
}
|
|
|
|
func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
|
var orig string
|
|
var err error
|
|
if err = unmarshal(&orig); err != nil {
|
|
return err
|
|
}
|
|
method, err := nickReservationFromString(orig)
|
|
if err == nil {
|
|
*nr = method
|
|
} else {
|
|
err = fmt.Errorf("invalid value `%s` for nick enforcement method: %w", orig, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
|
|
var orig string
|
|
if err = unmarshal(&orig); err != nil {
|
|
return err
|
|
}
|
|
|
|
var result Casemapping
|
|
switch strings.ToLower(orig) {
|
|
case "ascii":
|
|
result = CasemappingASCII
|
|
case "precis", "rfc7613", "rfc8265":
|
|
result = CasemappingPRECIS
|
|
case "permissive", "fun":
|
|
result = CasemappingPermissive
|
|
default:
|
|
return fmt.Errorf("invalid casemapping value: %s", orig)
|
|
}
|
|
*cm = result
|
|
return nil
|
|
}
|
|
|
|
// OperClassConfig defines a specific operator class.
|
|
type OperClassConfig struct {
|
|
Title string
|
|
WhoisLine string
|
|
Extends string
|
|
Capabilities []string
|
|
}
|
|
|
|
// OperConfig defines a specific operator's configuration.
|
|
type OperConfig struct {
|
|
Class string
|
|
Vhost string
|
|
WhoisLine string `yaml:"whois-line"`
|
|
Password string
|
|
Fingerprint *string // legacy name for certfp, #1050
|
|
Certfp string
|
|
Auto bool
|
|
Hidden bool
|
|
Modes string
|
|
}
|
|
|
|
// Various server-enforced limits on data size.
|
|
type Limits struct {
|
|
AwayLen int `yaml:"awaylen"`
|
|
ChanListModes int `yaml:"chan-list-modes"`
|
|
ChannelLen int `yaml:"channellen"`
|
|
IdentLen int `yaml:"identlen"`
|
|
KickLen int `yaml:"kicklen"`
|
|
MonitorEntries int `yaml:"monitor-entries"`
|
|
NickLen int `yaml:"nicklen"`
|
|
TopicLen int `yaml:"topiclen"`
|
|
WhowasEntries int `yaml:"whowas-entries"`
|
|
RegistrationMessages int `yaml:"registration-messages"`
|
|
Multiline struct {
|
|
MaxBytes int `yaml:"max-bytes"`
|
|
MaxLines int `yaml:"max-lines"`
|
|
}
|
|
}
|
|
|
|
// STSConfig controls the STS configuration/
|
|
type STSConfig struct {
|
|
Enabled bool
|
|
Duration custime.Duration
|
|
Port int
|
|
Preload bool
|
|
STSOnlyBanner string `yaml:"sts-only-banner"`
|
|
bannerLines []string
|
|
}
|
|
|
|
// Value returns the STS value to advertise in CAP
|
|
func (sts *STSConfig) Value() string {
|
|
val := fmt.Sprintf("duration=%d", int(time.Duration(sts.Duration).Seconds()))
|
|
if sts.Enabled && sts.Port > 0 {
|
|
val += fmt.Sprintf(",port=%d", sts.Port)
|
|
}
|
|
if sts.Enabled && sts.Preload {
|
|
val += ",preload"
|
|
}
|
|
return val
|
|
}
|
|
|
|
type FakelagConfig struct {
|
|
Enabled bool
|
|
Window time.Duration
|
|
BurstLimit uint `yaml:"burst-limit"`
|
|
MessagesPerWindow uint `yaml:"messages-per-window"`
|
|
Cooldown time.Duration
|
|
}
|
|
|
|
type TorListenersConfig struct {
|
|
Listeners []string // legacy only
|
|
RequireSasl bool `yaml:"require-sasl"`
|
|
Vhost string
|
|
MaxConnections int `yaml:"max-connections"`
|
|
ThrottleDuration time.Duration `yaml:"throttle-duration"`
|
|
MaxConnectionsPerDuration int `yaml:"max-connections-per-duration"`
|
|
}
|
|
|
|
// Config defines the overall configuration.
|
|
type Config struct {
|
|
AllowEnvironmentOverrides bool `yaml:"allow-environment-overrides"`
|
|
|
|
Network struct {
|
|
Name string
|
|
}
|
|
|
|
Server struct {
|
|
Password string
|
|
passwordBytes []byte
|
|
Name string
|
|
nameCasefolded string
|
|
Listeners map[string]listenerConfigBlock
|
|
UnixBindMode os.FileMode `yaml:"unix-bind-mode"`
|
|
TorListeners TorListenersConfig `yaml:"tor-listeners"`
|
|
WebSockets struct {
|
|
AllowedOrigins []string `yaml:"allowed-origins"`
|
|
allowedOriginRegexps []*regexp.Regexp
|
|
}
|
|
// they get parsed into this internal representation:
|
|
trueListeners map[string]utils.ListenerConfig
|
|
STS STSConfig
|
|
LookupHostnames *bool `yaml:"lookup-hostnames"`
|
|
lookupHostnames bool
|
|
ForwardConfirmHostnames bool `yaml:"forward-confirm-hostnames"`
|
|
CheckIdent bool `yaml:"check-ident"`
|
|
CoerceIdent string `yaml:"coerce-ident"`
|
|
MOTD string
|
|
motdLines []string
|
|
MOTDFormatting bool `yaml:"motd-formatting"`
|
|
Relaymsg struct {
|
|
Enabled bool
|
|
Separators string
|
|
AvailableToChanops bool `yaml:"available-to-chanops"`
|
|
}
|
|
ProxyAllowedFrom []string `yaml:"proxy-allowed-from"`
|
|
proxyAllowedFromNets []net.IPNet
|
|
WebIRC []webircConfig `yaml:"webirc"`
|
|
MaxSendQString string `yaml:"max-sendq"`
|
|
MaxSendQBytes int
|
|
Compatibility struct {
|
|
ForceTrailing *bool `yaml:"force-trailing"`
|
|
forceTrailing bool
|
|
SendUnprefixedSasl bool `yaml:"send-unprefixed-sasl"`
|
|
AllowTruncation *bool `yaml:"allow-truncation"`
|
|
allowTruncation bool
|
|
}
|
|
isupport isupport.List
|
|
IPLimits connection_limits.LimiterConfig `yaml:"ip-limits"`
|
|
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
|
|
SecureNetDefs []string `yaml:"secure-nets"`
|
|
secureNets []net.IPNet
|
|
supportedCaps *caps.Set
|
|
supportedCapsWithoutSTS *caps.Set
|
|
capValues caps.Values
|
|
Casemapping Casemapping
|
|
EnforceUtf8 bool `yaml:"enforce-utf8"`
|
|
OutputPath string `yaml:"output-path"`
|
|
IPCheckScript ScriptConfig `yaml:"ip-check-script"`
|
|
OverrideServicesHostname string `yaml:"override-services-hostname"`
|
|
MaxLineLen int `yaml:"max-line-len"`
|
|
}
|
|
|
|
Roleplay struct {
|
|
Enabled bool
|
|
RequireChanops bool `yaml:"require-chanops"`
|
|
RequireOper bool `yaml:"require-oper"`
|
|
AddSuffix *bool `yaml:"add-suffix"`
|
|
addSuffix bool
|
|
}
|
|
|
|
Extjwt struct {
|
|
Default jwt.JwtServiceConfig `yaml:",inline"`
|
|
Services map[string]jwt.JwtServiceConfig `yaml:"services"`
|
|
}
|
|
|
|
Languages struct {
|
|
Enabled bool
|
|
Path string
|
|
Default string
|
|
}
|
|
|
|
languageManager *languages.Manager
|
|
|
|
Datastore struct {
|
|
Path string
|
|
AutoUpgrade bool
|
|
MySQL mysql.Config
|
|
}
|
|
|
|
Metadata MetadataConfig
|
|
|
|
Accounts AccountConfig
|
|
|
|
Channels struct {
|
|
DefaultModes *string `yaml:"default-modes"`
|
|
defaultModes modes.Modes
|
|
MaxChannelsPerClient int `yaml:"max-channels-per-client"`
|
|
OpOnlyCreation bool `yaml:"operator-only-creation"`
|
|
Registration struct {
|
|
Enabled bool
|
|
OperatorOnly bool `yaml:"operator-only"`
|
|
MaxChannelsPerAccount int `yaml:"max-channels-per-account"`
|
|
}
|
|
ListDelay time.Duration `yaml:"list-delay"`
|
|
InviteExpiration custime.Duration `yaml:"invite-expiration"`
|
|
}
|
|
|
|
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
|
|
|
|
Opers map[string]*OperConfig
|
|
|
|
// parsed operator definitions, unexported so they can't be defined
|
|
// directly in YAML:
|
|
operators map[string]*Oper
|
|
|
|
Logging []logger.LoggingConfig
|
|
|
|
Debug struct {
|
|
RecoverFromErrors *bool `yaml:"recover-from-errors"`
|
|
recoverFromErrors bool
|
|
PprofListener *string `yaml:"pprof-listener"`
|
|
}
|
|
|
|
Limits Limits
|
|
|
|
Fakelag FakelagConfig
|
|
|
|
History struct {
|
|
Enabled bool
|
|
ChannelLength int `yaml:"channel-length"`
|
|
ClientLength int `yaml:"client-length"`
|
|
AutoresizeWindow custime.Duration `yaml:"autoresize-window"`
|
|
AutoreplayOnJoin int `yaml:"autoreplay-on-join"`
|
|
ChathistoryMax int `yaml:"chathistory-maxmessages"`
|
|
ZNCMax int `yaml:"znc-maxmessages"`
|
|
Restrictions struct {
|
|
ExpireTime custime.Duration `yaml:"expire-time"`
|
|
// legacy key, superceded by QueryCutoff:
|
|
EnforceRegistrationDate_ bool `yaml:"enforce-registration-date"`
|
|
QueryCutoff string `yaml:"query-cutoff"`
|
|
queryCutoff HistoryCutoff
|
|
GracePeriod custime.Duration `yaml:"grace-period"`
|
|
}
|
|
Persistent struct {
|
|
Enabled bool
|
|
UnregisteredChannels bool `yaml:"unregistered-channels"`
|
|
RegisteredChannels PersistentStatus `yaml:"registered-channels"`
|
|
DirectMessages PersistentStatus `yaml:"direct-messages"`
|
|
}
|
|
Retention struct {
|
|
AllowIndividualDelete bool `yaml:"allow-individual-delete"`
|
|
EnableAccountIndexing bool `yaml:"enable-account-indexing"`
|
|
}
|
|
TagmsgStorage struct {
|
|
Default bool
|
|
Whitelist []string
|
|
Blacklist []string
|
|
} `yaml:"tagmsg-storage"`
|
|
}
|
|
|
|
Filename string
|
|
}
|
|
|
|
// OperClass defines an assembled operator class.
|
|
type OperClass struct {
|
|
Title string
|
|
WhoisLine string `yaml:"whois-line"`
|
|
Capabilities utils.StringSet // map to make lookups much easier
|
|
}
|
|
|
|
// OperatorClasses returns a map of assembled operator classes from the given config.
|
|
func (conf *Config) OperatorClasses() (map[string]*OperClass, error) {
|
|
fixupCapability := func(capab string) string {
|
|
return strings.TrimPrefix(strings.TrimPrefix(capab, "oper:"), "local_") // #868, #1442
|
|
}
|
|
|
|
ocs := make(map[string]*OperClass)
|
|
|
|
// loop from no extends to most extended, breaking if we can't add any more
|
|
lenOfLastOcs := -1
|
|
for {
|
|
if lenOfLastOcs == len(ocs) {
|
|
return nil, errors.New("OperClasses contains a looping dependency, or a class extends from a class that doesn't exist")
|
|
}
|
|
lenOfLastOcs = len(ocs)
|
|
|
|
var anyMissing bool
|
|
for name, info := range conf.OperClasses {
|
|
_, exists := ocs[name]
|
|
_, extendsExists := ocs[info.Extends]
|
|
if exists {
|
|
// class already exists
|
|
continue
|
|
} else if len(info.Extends) > 0 && !extendsExists {
|
|
// class we extend on doesn't exist
|
|
_, exists := conf.OperClasses[info.Extends]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Operclass [%s] extends [%s], which doesn't exist", name, info.Extends)
|
|
}
|
|
anyMissing = true
|
|
continue
|
|
}
|
|
|
|
// create new operclass
|
|
var oc OperClass
|
|
oc.Capabilities = make(utils.StringSet)
|
|
|
|
// get inhereted info from other operclasses
|
|
if len(info.Extends) > 0 {
|
|
einfo := ocs[info.Extends]
|
|
|
|
for capab := range einfo.Capabilities {
|
|
oc.Capabilities.Add(fixupCapability(capab))
|
|
}
|
|
}
|
|
|
|
// add our own info
|
|
oc.Title = info.Title
|
|
if oc.Title == "" {
|
|
oc.Title = "IRC operator"
|
|
}
|
|
for _, capab := range info.Capabilities {
|
|
oc.Capabilities.Add(fixupCapability(capab))
|
|
}
|
|
if len(info.WhoisLine) > 0 {
|
|
oc.WhoisLine = info.WhoisLine
|
|
} else {
|
|
oc.WhoisLine = "is a"
|
|
if strings.Contains(strings.ToLower(string(oc.Title[0])), "aeiou") {
|
|
oc.WhoisLine += "n"
|
|
}
|
|
oc.WhoisLine += " "
|
|
oc.WhoisLine += oc.Title
|
|
}
|
|
|
|
ocs[name] = &oc
|
|
}
|
|
|
|
if !anyMissing {
|
|
// we've got every operclass!
|
|
break
|
|
}
|
|
}
|
|
|
|
return ocs, nil
|
|
}
|
|
|
|
// Oper represents a single assembled operator's config.
|
|
type Oper struct {
|
|
Name string
|
|
Class *OperClass
|
|
WhoisLine string
|
|
Vhost string
|
|
Pass []byte
|
|
Certfp string
|
|
Auto bool
|
|
Hidden bool
|
|
Modes []modes.ModeChange
|
|
}
|
|
|
|
func (oper *Oper) HasRoleCapab(capab string) bool {
|
|
return oper != nil && oper.Class.Capabilities.Has(capab)
|
|
}
|
|
|
|
// Operators returns a map of operator configs from the given OperClass and config.
|
|
func (conf *Config) Operators(oc map[string]*OperClass) (map[string]*Oper, error) {
|
|
operators := make(map[string]*Oper)
|
|
for name, opConf := range conf.Opers {
|
|
var oper Oper
|
|
|
|
// oper name
|
|
name, err := CasefoldName(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not casefold oper name: %s", err.Error())
|
|
}
|
|
oper.Name = name
|
|
|
|
if opConf.Password != "" {
|
|
oper.Pass, err = decodeLegacyPasswordHash(opConf.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Oper %s has an invalid password hash: %s", oper.Name, err.Error())
|
|
}
|
|
}
|
|
certfp := opConf.Certfp
|
|
if certfp == "" && opConf.Fingerprint != nil {
|
|
certfp = *opConf.Fingerprint
|
|
}
|
|
if certfp != "" {
|
|
oper.Certfp, err = utils.NormalizeCertfp(certfp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Oper %s has an invalid fingerprint: %s", oper.Name, err.Error())
|
|
}
|
|
}
|
|
oper.Auto = opConf.Auto
|
|
oper.Hidden = opConf.Hidden
|
|
|
|
if oper.Pass == nil && oper.Certfp == "" {
|
|
return nil, fmt.Errorf("Oper %s has neither a password nor a fingerprint", name)
|
|
}
|
|
|
|
oper.Vhost = opConf.Vhost
|
|
if oper.Vhost != "" && !conf.Accounts.VHosts.validRegexp.MatchString(oper.Vhost) {
|
|
return nil, fmt.Errorf("Oper %s has an invalid vhost: `%s`", name, oper.Vhost)
|
|
}
|
|
class, exists := oc[opConf.Class]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Could not load operator [%s] - they use operclass [%s] which does not exist", name, opConf.Class)
|
|
}
|
|
oper.Class = class
|
|
if len(opConf.WhoisLine) > 0 {
|
|
oper.WhoisLine = opConf.WhoisLine
|
|
} else {
|
|
oper.WhoisLine = class.WhoisLine
|
|
}
|
|
modeStr := strings.TrimSpace(opConf.Modes)
|
|
modeChanges, unknownChanges := modes.ParseUserModeChanges(strings.Split(modeStr, " ")...)
|
|
if len(unknownChanges) > 0 {
|
|
return nil, fmt.Errorf("Could not load operator [%s] due to unknown modes %v", name, unknownChanges)
|
|
}
|
|
oper.Modes = modeChanges
|
|
|
|
// successful, attach to list of opers
|
|
operators[name] = &oper
|
|
}
|
|
return operators, nil
|
|
}
|
|
|
|
func loadTlsConfig(config listenerConfigBlock) (tlsConfig *tls.Config, err error) {
|
|
var certificates []tls.Certificate
|
|
if len(config.TLSCertificates) != 0 {
|
|
// SNI configuration with multiple certificates
|
|
for _, certPairConf := range config.TLSCertificates {
|
|
cert, err := loadCertWithLeaf(certPairConf.Cert, certPairConf.Key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certificates = append(certificates, cert)
|
|
}
|
|
} else if config.TLS.Cert != "" {
|
|
// normal configuration with one certificate
|
|
cert, err := loadCertWithLeaf(config.TLS.Cert, config.TLS.Key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
certificates = append(certificates, cert)
|
|
} else {
|
|
// plaintext!
|
|
return nil, nil
|
|
}
|
|
clientAuth := tls.RequestClientCert
|
|
if config.WebSocket {
|
|
// if Chrome receives a server request for a client certificate
|
|
// on a websocket connection, it will immediately disconnect:
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=329884
|
|
// work around this behavior:
|
|
clientAuth = tls.NoClientCert
|
|
}
|
|
result := tls.Config{
|
|
Certificates: certificates,
|
|
ClientAuth: clientAuth,
|
|
MinVersion: tlsMinVersionFromString(config.MinTLSVersion),
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func tlsMinVersionFromString(version string) uint16 {
|
|
version = strings.ToLower(version)
|
|
version = strings.TrimPrefix(version, "v")
|
|
switch version {
|
|
case "1", "1.0":
|
|
return tls.VersionTLS10
|
|
case "1.1":
|
|
return tls.VersionTLS11
|
|
case "1.2":
|
|
return tls.VersionTLS12
|
|
case "1.3":
|
|
return tls.VersionTLS13
|
|
default:
|
|
// tls package will fill in a sane value, currently 1.0
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func loadCertWithLeaf(certFile, keyFile string) (cert tls.Certificate, err error) {
|
|
// LoadX509KeyPair: "On successful return, Certificate.Leaf will be nil because
|
|
// the parsed form of the certificate is not retained." tls.Config:
|
|
// "Note: if there are multiple Certificates, and they don't have the
|
|
// optional field Leaf set, certificate selection will incur a significant
|
|
// per-handshake performance cost."
|
|
cert, err = tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
|
|
return
|
|
}
|
|
|
|
// prepareListeners populates Config.Server.trueListeners
|
|
func (conf *Config) prepareListeners() (err error) {
|
|
if len(conf.Server.Listeners) == 0 {
|
|
return fmt.Errorf("No listeners were configured")
|
|
}
|
|
|
|
conf.Server.trueListeners = make(map[string]utils.ListenerConfig)
|
|
for addr, block := range conf.Server.Listeners {
|
|
var lconf utils.ListenerConfig
|
|
lconf.ProxyDeadline = RegisterTimeout
|
|
lconf.Tor = block.Tor
|
|
lconf.STSOnly = block.STSOnly
|
|
if lconf.STSOnly && !conf.Server.STS.Enabled {
|
|
return fmt.Errorf("%s is configured as a STS-only listener, but STS is disabled", addr)
|
|
}
|
|
lconf.TLSConfig, err = loadTlsConfig(block)
|
|
if err != nil {
|
|
return &CertKeyError{Err: err}
|
|
}
|
|
lconf.RequireProxy = block.TLS.Proxy || block.Proxy
|
|
lconf.WebSocket = block.WebSocket
|
|
if lconf.WebSocket && !conf.Server.EnforceUtf8 {
|
|
return fmt.Errorf("enabling a websocket listener requires the use of server.enforce-utf8")
|
|
}
|
|
lconf.HideSTS = block.HideSTS
|
|
conf.Server.trueListeners[addr] = lconf
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (config *Config) processExtjwt() (err error) {
|
|
// first process the default service, which may be disabled
|
|
err = config.Extjwt.Default.Postprocess()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// now process the named services. it is an error if any is disabled
|
|
// also, normalize the service names to lowercase
|
|
services := make(map[string]jwt.JwtServiceConfig, len(config.Extjwt.Services))
|
|
for service, sConf := range config.Extjwt.Services {
|
|
err := sConf.Postprocess()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !sConf.Enabled() {
|
|
return fmt.Errorf("no keys enabled for extjwt service %s", service)
|
|
}
|
|
services[strings.ToLower(service)] = sConf
|
|
}
|
|
config.Extjwt.Services = services
|
|
return nil
|
|
}
|
|
|
|
// LoadRawConfig loads the config without doing any consistency checks or postprocessing
|
|
func LoadRawConfig(filename string) (config *Config, err error) {
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = yaml.Unmarshal(data, &config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return
|
|
}
|
|
|
|
// convert, e.g., "ALLOWED_ORIGINS" to "allowed-origins"
|
|
func screamingSnakeToKebab(in string) (out string) {
|
|
var buf strings.Builder
|
|
for i := 0; i < len(in); i++ {
|
|
c := in[i]
|
|
switch {
|
|
case c == '_':
|
|
buf.WriteByte('-')
|
|
case 'A' <= c && c <= 'Z':
|
|
buf.WriteByte(c + ('a' - 'A'))
|
|
default:
|
|
buf.WriteByte(c)
|
|
}
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func isExported(field reflect.StructField) bool {
|
|
return field.PkgPath == "" // https://golang.org/pkg/reflect/#StructField
|
|
}
|
|
|
|
// errors caused by config overrides
|
|
type configPathError struct {
|
|
name string
|
|
desc string
|
|
fatalErr error
|
|
}
|
|
|
|
func (ce *configPathError) Error() string {
|
|
if ce.fatalErr != nil {
|
|
return fmt.Sprintf("Couldn't apply config override `%s`: %s: %v", ce.name, ce.desc, ce.fatalErr)
|
|
}
|
|
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
|
|
}
|
|
|
|
func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) {
|
|
equalIdx := strings.IndexByte(envPair, '=')
|
|
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
|
|
if strings.HasPrefix(name, "ERGO__") {
|
|
name = strings.TrimPrefix(name, "ERGO__")
|
|
} else if strings.HasPrefix(name, "ORAGONO__") {
|
|
name = strings.TrimPrefix(name, "ORAGONO__")
|
|
} else {
|
|
return false, nil
|
|
}
|
|
pathComponents := strings.Split(name, "__")
|
|
for i, pathComponent := range pathComponents {
|
|
pathComponents[i] = screamingSnakeToKebab(pathComponent)
|
|
}
|
|
|
|
v := reflect.Indirect(reflect.ValueOf(config))
|
|
t := v.Type()
|
|
for _, component := range pathComponents {
|
|
if component == "" {
|
|
return false, &configPathError{name, "invalid", nil}
|
|
}
|
|
if v.Kind() != reflect.Struct {
|
|
return false, &configPathError{name, "index into non-struct", nil}
|
|
}
|
|
var nextField reflect.StructField
|
|
success := false
|
|
n := t.NumField()
|
|
// preferentially get a field with an exact yaml tag match,
|
|
// then fall back to case-insensitive comparison of field names
|
|
for i := 0; i < n; i++ {
|
|
field := t.Field(i)
|
|
if isExported(field) && field.Tag.Get("yaml") == component {
|
|
nextField = field
|
|
success = true
|
|
break
|
|
}
|
|
}
|
|
if !success {
|
|
for i := 0; i < n; i++ {
|
|
field := t.Field(i)
|
|
if isExported(field) && strings.ToLower(field.Name) == component {
|
|
nextField = field
|
|
success = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !success {
|
|
return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
|
|
}
|
|
v = v.FieldByName(nextField.Name)
|
|
// dereference pointer field if necessary, initialize new value if necessary
|
|
if v.Kind() == reflect.Ptr {
|
|
if v.IsNil() {
|
|
v.Set(reflect.New(v.Type().Elem()))
|
|
}
|
|
v = reflect.Indirect(v)
|
|
}
|
|
t = v.Type()
|
|
}
|
|
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
|
|
if yamlErr != nil {
|
|
return false, &configPathError{name, "couldn't deserialize YAML", yamlErr}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// LoadConfig loads the given YAML configuration file.
|
|
func LoadConfig(filename string) (config *Config, err error) {
|
|
config, err = LoadRawConfig(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if config.AllowEnvironmentOverrides {
|
|
for _, envPair := range os.Environ() {
|
|
applied, envErr := mungeFromEnvironment(config, envPair)
|
|
if envErr != nil {
|
|
if envErr.fatalErr != nil {
|
|
return nil, envErr
|
|
} else {
|
|
log.Println(envErr.Error())
|
|
}
|
|
} else if applied {
|
|
log.Printf("applied environment override: %s\n", envPair)
|
|
}
|
|
}
|
|
}
|
|
|
|
config.Filename = filename
|
|
|
|
if config.Network.Name == "" {
|
|
return nil, errors.New("Network name missing")
|
|
}
|
|
if config.Server.Name == "" {
|
|
return nil, errors.New("Server name missing")
|
|
}
|
|
if !utils.IsServerName(config.Server.Name) {
|
|
return nil, errors.New("Server name must match the format of a hostname")
|
|
}
|
|
config.Server.nameCasefolded = strings.ToLower(config.Server.Name)
|
|
if config.Datastore.Path == "" {
|
|
return nil, errors.New("Datastore path missing")
|
|
}
|
|
//dan: automagically fix identlen until a few releases in the future (from now, 0.12.0), being a newly-introduced limit
|
|
if config.Limits.IdentLen < 1 {
|
|
config.Limits.IdentLen = 20
|
|
}
|
|
if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 {
|
|
return nil, errors.New("One or more limits values are too low")
|
|
}
|
|
if config.Limits.RegistrationMessages == 0 {
|
|
config.Limits.RegistrationMessages = 1024
|
|
}
|
|
if config.Server.MaxLineLen < DefaultMaxLineLen {
|
|
config.Server.MaxLineLen = DefaultMaxLineLen
|
|
}
|
|
if config.Datastore.MySQL.Enabled {
|
|
if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength {
|
|
return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength)
|
|
}
|
|
}
|
|
|
|
if config.Server.CoerceIdent != "" {
|
|
if config.Server.CheckIdent {
|
|
return nil, errors.New("Can't configure both check-ident and coerce-ident")
|
|
}
|
|
if config.Server.CoerceIdent[0] != '~' {
|
|
return nil, errors.New("coerce-ident value must start with a ~")
|
|
}
|
|
if !isIdent(config.Server.CoerceIdent[1:]) {
|
|
return nil, errors.New("coerce-ident must be valid as an IRC user/ident field")
|
|
}
|
|
}
|
|
|
|
config.Server.supportedCaps = caps.NewCompleteSet()
|
|
config.Server.capValues = make(caps.Values)
|
|
|
|
err = config.prepareListeners()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare listeners: %v", err)
|
|
}
|
|
|
|
for _, glob := range config.Server.WebSockets.AllowedOrigins {
|
|
globre, err := utils.CompileGlob(glob, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid websocket allowed-origin expression: %s", glob)
|
|
}
|
|
config.Server.WebSockets.allowedOriginRegexps = append(config.Server.WebSockets.allowedOriginRegexps, globre)
|
|
}
|
|
|
|
if config.Server.STS.Enabled {
|
|
if config.Server.STS.Port < 0 || config.Server.STS.Port > 65535 {
|
|
return nil, fmt.Errorf("STS port is incorrect, should be 0 if disabled: %d", config.Server.STS.Port)
|
|
}
|
|
if config.Server.STS.STSOnlyBanner != "" {
|
|
for _, line := range strings.Split(config.Server.STS.STSOnlyBanner, "\n") {
|
|
config.Server.STS.bannerLines = append(config.Server.STS.bannerLines, strings.TrimSpace(line))
|
|
}
|
|
} else {
|
|
config.Server.STS.bannerLines = []string{fmt.Sprintf("This server is only accessible over TLS. Please reconnect using TLS on port %d.", config.Server.STS.Port)}
|
|
}
|
|
} else {
|
|
config.Server.supportedCaps.Disable(caps.STS)
|
|
config.Server.STS.Duration = 0
|
|
}
|
|
// set this even if STS is disabled
|
|
config.Server.capValues[caps.STS] = config.Server.STS.Value()
|
|
|
|
config.Server.lookupHostnames = utils.BoolDefaultTrue(config.Server.LookupHostnames)
|
|
|
|
// process webirc blocks
|
|
var newWebIRC []webircConfig
|
|
for _, webirc := range config.Server.WebIRC {
|
|
// skip webirc blocks with no hosts (such as the example one)
|
|
if len(webirc.Hosts) == 0 {
|
|
continue
|
|
}
|
|
|
|
err = webirc.Populate()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse WebIRC config: %s", err.Error())
|
|
}
|
|
newWebIRC = append(newWebIRC, webirc)
|
|
}
|
|
config.Server.WebIRC = newWebIRC
|
|
|
|
if config.Limits.Multiline.MaxBytes <= 0 {
|
|
config.Server.supportedCaps.Disable(caps.Multiline)
|
|
} else {
|
|
var multilineCapValue string
|
|
if config.Limits.Multiline.MaxLines == 0 {
|
|
multilineCapValue = fmt.Sprintf("max-bytes=%d", config.Limits.Multiline.MaxBytes)
|
|
} else {
|
|
multilineCapValue = fmt.Sprintf("max-bytes=%d,max-lines=%d", config.Limits.Multiline.MaxBytes, config.Limits.Multiline.MaxLines)
|
|
}
|
|
config.Server.capValues[caps.Multiline] = multilineCapValue
|
|
}
|
|
|
|
// confirm that we don't have both allowed and blocked metadata keys set
|
|
config.Server.capValues[caps.Metadata] = fmt.Sprintf("maxsub=%d,maxkey=%d", config.Metadata.MaxSubs, config.Metadata.MaxKeys)
|
|
|
|
if len(config.Metadata.Users.AllowedKeys) > 0 && len(config.Metadata.Users.BlockedKeys) > 0 {
|
|
return nil, errors.New("You can only set either allowed-keys or blocked-keys in metadata.users, not both")
|
|
}
|
|
if len(config.Metadata.Channels.AllowedKeys) > 0 && len(config.Metadata.Channels.BlockedKeys) > 0 {
|
|
return nil, errors.New("You can only set either allowed-keys or blocked-keys in metadata.channels, not both")
|
|
}
|
|
|
|
err = config.Metadata.Users.compileMatchers()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not compile metadata.users.%s", err.Error())
|
|
}
|
|
|
|
err = config.Metadata.Channels.compileMatchers()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not compile metadata.channels.%s", err.Error())
|
|
}
|
|
|
|
// handle legacy name 'bouncer' for 'multiclient' section:
|
|
if config.Accounts.Bouncer != nil {
|
|
config.Accounts.Multiclient = *config.Accounts.Bouncer
|
|
}
|
|
|
|
if !config.Accounts.Multiclient.Enabled {
|
|
config.Accounts.Multiclient.AlwaysOn = PersistentDisabled
|
|
} else if config.Accounts.Multiclient.AlwaysOn >= PersistentOptOut {
|
|
config.Accounts.Multiclient.AllowedByDefault = true
|
|
}
|
|
|
|
if !config.Accounts.NickReservation.Enabled {
|
|
config.Accounts.NickReservation.ForceNickEqualsAccount = false
|
|
}
|
|
|
|
if config.Accounts.NickReservation.ForceNickEqualsAccount && !config.Accounts.Multiclient.Enabled {
|
|
return nil, errors.New("force-nick-equals-account requires enabling multiclient as well")
|
|
}
|
|
|
|
// handle guest format, including the legacy key rename-prefix
|
|
if config.Accounts.NickReservation.GuestFormat == "" {
|
|
renamePrefix := config.Accounts.NickReservation.RenamePrefix
|
|
if renamePrefix == "" {
|
|
renamePrefix = "Guest-"
|
|
}
|
|
config.Accounts.NickReservation.GuestFormat = renamePrefix + "*"
|
|
}
|
|
config.Accounts.NickReservation.guestRegexp, config.Accounts.NickReservation.guestRegexpFolded, err = compileGuestRegexp(config.Accounts.NickReservation.GuestFormat, config.Server.Casemapping)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var newLogConfigs []logger.LoggingConfig
|
|
for _, logConfig := range config.Logging {
|
|
// methods
|
|
methods := make(map[string]bool)
|
|
for _, method := range strings.Split(logConfig.Method, " ") {
|
|
if len(method) > 0 {
|
|
methods[strings.ToLower(method)] = true
|
|
}
|
|
}
|
|
if methods["file"] && logConfig.Filename == "" {
|
|
return nil, errors.New("Logging configuration specifies 'file' method but 'filename' is empty")
|
|
}
|
|
logConfig.MethodFile = methods["file"]
|
|
logConfig.MethodStdout = methods["stdout"]
|
|
logConfig.MethodStderr = methods["stderr"]
|
|
|
|
// levels
|
|
level, exists := logger.LogLevelNames[strings.ToLower(logConfig.LevelString)]
|
|
if !exists {
|
|
return nil, fmt.Errorf("Could not translate log leve [%s]", logConfig.LevelString)
|
|
}
|
|
logConfig.Level = level
|
|
|
|
// types
|
|
for _, typeStr := range strings.Split(logConfig.TypeString, " ") {
|
|
if len(typeStr) == 0 {
|
|
continue
|
|
}
|
|
if typeStr == "-" {
|
|
return nil, errors.New("Encountered logging type '-' with no type to exclude")
|
|
}
|
|
if typeStr[0] == '-' {
|
|
typeStr = typeStr[1:]
|
|
logConfig.ExcludedTypes = append(logConfig.ExcludedTypes, typeStr)
|
|
} else {
|
|
logConfig.Types = append(logConfig.Types, typeStr)
|
|
}
|
|
}
|
|
if len(logConfig.Types) < 1 {
|
|
return nil, errors.New("Logger has no types to log")
|
|
}
|
|
|
|
newLogConfigs = append(newLogConfigs, logConfig)
|
|
}
|
|
config.Logging = newLogConfigs
|
|
|
|
if config.Accounts.Registration.EmailVerification.Enabled {
|
|
err := config.Accounts.Registration.EmailVerification.Postprocess(config.Server.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// TODO: this processes the legacy "callback" config, clean this up in 2.5 or later
|
|
// TODO: also clean up the legacy "inline" MTA config format (from ee05a4324dfde)
|
|
mailtoEnabled := false
|
|
for _, name := range config.Accounts.Registration.LegacyEnabledCallbacks {
|
|
if name == "mailto" {
|
|
mailtoEnabled = true
|
|
break
|
|
}
|
|
}
|
|
if mailtoEnabled {
|
|
config.Accounts.Registration.EmailVerification = config.Accounts.Registration.LegacyCallbacks.Mailto
|
|
config.Accounts.Registration.EmailVerification.Enabled = true
|
|
err := config.Accounts.Registration.EmailVerification.Postprocess(config.Server.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
config.Accounts.defaultUserModes = ParseDefaultUserModes(config.Accounts.DefaultUserModes)
|
|
|
|
if config.Server.Password != "" {
|
|
config.Server.passwordBytes, err = decodeLegacyPasswordHash(config.Server.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if config.Accounts.LoginViaPassCommand && !config.Accounts.SkipServerPassword {
|
|
return nil, errors.New("Using a server password and login-via-pass-command requires skip-server-password as well")
|
|
}
|
|
// #1634: accounts.registration.allow-before-connect is an auth bypass
|
|
// for configurations that start from default and then enable server.password
|
|
config.Accounts.Registration.AllowBeforeConnect = false
|
|
}
|
|
|
|
if config.Accounts.RequireSasl.Enabled {
|
|
// minor gotcha: Tor listeners will typically be loopback and
|
|
// therefore exempted from require-sasl. if require-sasl is enabled
|
|
// for non-Tor (non-local) connections, enable it for Tor as well:
|
|
config.Server.TorListeners.RequireSasl = true
|
|
}
|
|
config.Accounts.RequireSasl.exemptedNets, err = utils.ParseNetList(config.Accounts.RequireSasl.Exempted)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse require-sasl exempted nets: %v", err.Error())
|
|
}
|
|
|
|
config.Server.proxyAllowedFromNets, err = utils.ParseNetList(config.Server.ProxyAllowedFrom)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse proxy-allowed-from nets: %v", err.Error())
|
|
}
|
|
|
|
config.Server.secureNets, err = utils.ParseNetList(config.Server.SecureNetDefs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse secure-nets: %v\n", err.Error())
|
|
}
|
|
|
|
rawRegexp := config.Accounts.VHosts.ValidRegexpRaw
|
|
if rawRegexp != "" {
|
|
regexp, err := regexp.Compile(rawRegexp)
|
|
if err == nil {
|
|
config.Accounts.VHosts.validRegexp = regexp
|
|
} else {
|
|
log.Printf("invalid vhost regexp: %s\n", err.Error())
|
|
}
|
|
}
|
|
if config.Accounts.VHosts.validRegexp == nil {
|
|
config.Accounts.VHosts.validRegexp = defaultValidVhostRegex
|
|
}
|
|
|
|
saslCapValue := "PLAIN,EXTERNAL,SCRAM-SHA-256"
|
|
// TODO(#1782) clean this up:
|
|
if !config.Accounts.AdvertiseSCRAM {
|
|
saslCapValue = "PLAIN,EXTERNAL"
|
|
}
|
|
config.Server.capValues[caps.SASL] = saslCapValue
|
|
if !config.Accounts.AuthenticationEnabled {
|
|
config.Server.supportedCaps.Disable(caps.SASL)
|
|
}
|
|
|
|
if !config.Accounts.Registration.Enabled {
|
|
config.Server.supportedCaps.Disable(caps.AccountRegistration)
|
|
} else {
|
|
var registerValues []string
|
|
if config.Accounts.Registration.AllowBeforeConnect {
|
|
registerValues = append(registerValues, "before-connect")
|
|
}
|
|
if config.Accounts.Registration.EmailVerification.Enabled {
|
|
registerValues = append(registerValues, "email-required")
|
|
}
|
|
if config.Accounts.RequireSasl.Enabled {
|
|
registerValues = append(registerValues, "account-required")
|
|
}
|
|
if len(registerValues) != 0 {
|
|
config.Server.capValues[caps.AccountRegistration] = strings.Join(registerValues, ",")
|
|
}
|
|
}
|
|
|
|
maxSendQBytes, err := bytefmt.ToBytes(config.Server.MaxSendQString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not parse maximum SendQ size (make sure it only contains whole numbers): %s", err.Error())
|
|
}
|
|
config.Server.MaxSendQBytes = int(maxSendQBytes)
|
|
|
|
config.languageManager, err = languages.NewManager(config.Languages.Enabled, config.Languages.Path, config.Languages.Default)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Could not load languages: %s", err.Error())
|
|
}
|
|
config.Server.capValues[caps.Languages] = config.languageManager.CapValue()
|
|
|
|
if config.Server.Relaymsg.Enabled {
|
|
for _, char := range protocolBreakingNameCharacters {
|
|
if strings.ContainsRune(config.Server.Relaymsg.Separators, char) {
|
|
return nil, fmt.Errorf("RELAYMSG separators cannot include the characters %s", protocolBreakingNameCharacters)
|
|
}
|
|
}
|
|
config.Server.capValues[caps.Relaymsg] = config.Server.Relaymsg.Separators
|
|
} else {
|
|
config.Server.supportedCaps.Disable(caps.Relaymsg)
|
|
}
|
|
|
|
config.Debug.recoverFromErrors = utils.BoolDefaultTrue(config.Debug.RecoverFromErrors)
|
|
|
|
// process operator definitions, store them to config.operators
|
|
operclasses, err := config.OperatorClasses()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opers, err := config.Operators(operclasses)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config.operators = opers
|
|
|
|
// parse default channel modes
|
|
config.Channels.defaultModes = ParseDefaultChannelModes(config.Channels.DefaultModes)
|
|
|
|
if config.Accounts.Registration.BcryptCost == 0 {
|
|
config.Accounts.Registration.BcryptCost = passwd.DefaultCost
|
|
}
|
|
|
|
if config.Channels.MaxChannelsPerClient == 0 {
|
|
config.Channels.MaxChannelsPerClient = 100
|
|
}
|
|
if config.Channels.Registration.MaxChannelsPerAccount == 0 {
|
|
config.Channels.Registration.MaxChannelsPerAccount = 15
|
|
}
|
|
|
|
config.Server.Compatibility.forceTrailing = utils.BoolDefaultTrue(config.Server.Compatibility.ForceTrailing)
|
|
config.Server.Compatibility.allowTruncation = utils.BoolDefaultTrue(config.Server.Compatibility.AllowTruncation)
|
|
|
|
config.loadMOTD()
|
|
|
|
// in the current implementation, we disable history by creating a history buffer
|
|
// with zero capacity. but the `enabled` config option MUST be respected regardless
|
|
// of this detail
|
|
if !config.History.Enabled {
|
|
config.History.ChannelLength = 0
|
|
config.History.ClientLength = 0
|
|
config.Server.supportedCaps.Disable(caps.Chathistory)
|
|
config.Server.supportedCaps.Disable(caps.EventPlayback)
|
|
config.Server.supportedCaps.Disable(caps.ZNCPlayback)
|
|
}
|
|
|
|
if !config.History.Enabled || !config.History.Persistent.Enabled {
|
|
config.History.Persistent.Enabled = false
|
|
config.History.Persistent.UnregisteredChannels = false
|
|
config.History.Persistent.RegisteredChannels = PersistentDisabled
|
|
config.History.Persistent.DirectMessages = PersistentDisabled
|
|
}
|
|
|
|
if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled {
|
|
return nil, fmt.Errorf("You must configure a MySQL server in order to enable persistent history")
|
|
}
|
|
|
|
if config.History.ZNCMax == 0 {
|
|
config.History.ZNCMax = config.History.ChathistoryMax
|
|
}
|
|
|
|
if config.History.Restrictions.QueryCutoff != "" {
|
|
config.History.Restrictions.queryCutoff, err = historyCutoffFromString(config.History.Restrictions.QueryCutoff)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid value of history.query-restrictions: %w", err)
|
|
}
|
|
} else {
|
|
if config.History.Restrictions.EnforceRegistrationDate_ {
|
|
config.History.Restrictions.queryCutoff = HistoryCutoffRegistrationTime
|
|
} else {
|
|
config.History.Restrictions.queryCutoff = HistoryCutoffNone
|
|
}
|
|
}
|
|
|
|
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
|
|
|
|
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
|
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
|
if config.Datastore.MySQL.MaxConns == 0 {
|
|
// #1622: not putting an upper limit on the number of MySQL connections is
|
|
// potentially dangerous. as a naive heuristic, assume they're running on the
|
|
// same machine:
|
|
config.Datastore.MySQL.MaxConns = runtime.NumCPU()
|
|
}
|
|
|
|
config.Server.Cloaks.Initialize()
|
|
if config.Server.Cloaks.Enabled {
|
|
if !utils.IsHostname(config.Server.Cloaks.Netname) {
|
|
return nil, fmt.Errorf("Invalid netname for cloaked hostnames: %s", config.Server.Cloaks.Netname)
|
|
}
|
|
}
|
|
|
|
err = config.processExtjwt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// now that all postprocessing is complete, regenerate ISUPPORT:
|
|
err = config.generateISupport()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// #1428: Tor listeners should never see STS
|
|
config.Server.supportedCapsWithoutSTS = caps.NewSet()
|
|
config.Server.supportedCapsWithoutSTS.Union(config.Server.supportedCaps)
|
|
config.Server.supportedCapsWithoutSTS.Disable(caps.STS)
|
|
|
|
return config, nil
|
|
}
|
|
|
|
func (config *Config) getOutputPath(filename string) string {
|
|
return filepath.Join(config.Server.OutputPath, filename)
|
|
}
|
|
|
|
func (config *Config) isRelaymsgIdentifier(nick string) bool {
|
|
if !config.Server.Relaymsg.Enabled {
|
|
return false
|
|
}
|
|
|
|
for _, char := range config.Server.Relaymsg.Separators {
|
|
if strings.ContainsRune(nick, char) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// setISupport sets up our RPL_ISUPPORT reply.
|
|
func (config *Config) generateISupport() (err error) {
|
|
maxTargetsString := strconv.Itoa(maxTargets)
|
|
|
|
// add RPL_ISUPPORT tokens
|
|
isupport := &config.Server.isupport
|
|
isupport.Initialize()
|
|
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
|
|
isupport.Add("BOT", "B")
|
|
isupport.Add("CASEMAPPING", "ascii")
|
|
isupport.Add("CHANLIMIT", fmt.Sprintf("%s:%d", chanTypes, config.Channels.MaxChannelsPerClient))
|
|
isupport.Add("CHANMODES", chanmodesToken)
|
|
if config.History.Enabled && config.History.ChathistoryMax > 0 {
|
|
isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax))
|
|
}
|
|
isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen))
|
|
isupport.Add("CHANTYPES", chanTypes)
|
|
isupport.Add("ELIST", "U")
|
|
isupport.Add("EXCEPTS", "")
|
|
if config.Extjwt.Default.Enabled() || len(config.Extjwt.Services) != 0 {
|
|
isupport.Add("EXTJWT", "1")
|
|
}
|
|
isupport.Add("EXTBAN", ",m")
|
|
isupport.Add("FORWARD", "f")
|
|
isupport.Add("INVEX", "")
|
|
isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen))
|
|
isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes)))
|
|
isupport.Add("MAXTARGETS", maxTargetsString)
|
|
isupport.Add("MODES", "")
|
|
isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries))
|
|
isupport.Add("NETWORK", config.Network.Name)
|
|
isupport.Add("NICKLEN", strconv.Itoa(config.Limits.NickLen))
|
|
isupport.Add("PREFIX", "(qaohv)~&@%+")
|
|
if config.Roleplay.Enabled {
|
|
isupport.Add("RPCHAN", "E")
|
|
isupport.Add("RPUSER", "E")
|
|
}
|
|
isupport.Add("STATUSMSG", "~&@%+")
|
|
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
|
|
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))
|
|
if config.Server.Casemapping == CasemappingPRECIS {
|
|
isupport.Add("UTF8MAPPING", precisUTF8MappingToken)
|
|
}
|
|
if config.Server.EnforceUtf8 {
|
|
isupport.Add("UTF8ONLY", "")
|
|
}
|
|
isupport.Add("WHOX", "")
|
|
|
|
err = isupport.RegenerateCachedReply()
|
|
return
|
|
}
|
|
|
|
// Diff returns changes in supported caps across a rehash.
|
|
func (config *Config) Diff(oldConfig *Config) (addedCaps, removedCaps *caps.Set) {
|
|
addedCaps = caps.NewSet()
|
|
removedCaps = caps.NewSet()
|
|
if oldConfig == nil {
|
|
return
|
|
}
|
|
|
|
if oldConfig.Server.capValues[caps.Languages] != config.Server.capValues[caps.Languages] {
|
|
// XXX updated caps get a DEL line and then a NEW line with the new value
|
|
addedCaps.Add(caps.Languages)
|
|
removedCaps.Add(caps.Languages)
|
|
}
|
|
|
|
if !oldConfig.Accounts.AuthenticationEnabled && config.Accounts.AuthenticationEnabled {
|
|
addedCaps.Add(caps.SASL)
|
|
} else if oldConfig.Accounts.AuthenticationEnabled && !config.Accounts.AuthenticationEnabled {
|
|
removedCaps.Add(caps.SASL)
|
|
}
|
|
|
|
if oldConfig.Limits.Multiline.MaxBytes != 0 && config.Limits.Multiline.MaxBytes == 0 {
|
|
removedCaps.Add(caps.Multiline)
|
|
} else if oldConfig.Limits.Multiline.MaxBytes == 0 && config.Limits.Multiline.MaxBytes != 0 {
|
|
addedCaps.Add(caps.Multiline)
|
|
} else if oldConfig.Limits.Multiline != config.Limits.Multiline {
|
|
removedCaps.Add(caps.Multiline)
|
|
addedCaps.Add(caps.Multiline)
|
|
}
|
|
|
|
if oldConfig.Server.STS.Enabled != config.Server.STS.Enabled || oldConfig.Server.capValues[caps.STS] != config.Server.capValues[caps.STS] {
|
|
// XXX: STS is always removed by CAP NEW sts=duration=0, not CAP DEL
|
|
// so the appropriate notify is always a CAP NEW; put it in addedCaps for any change
|
|
addedCaps.Add(caps.STS)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// determine whether we need to resize / create / destroy
|
|
// the in-memory history buffers:
|
|
func (config *Config) historyChangedFrom(oldConfig *Config) bool {
|
|
return config.History.Enabled != oldConfig.History.Enabled ||
|
|
config.History.ChannelLength != oldConfig.History.ChannelLength ||
|
|
config.History.ClientLength != oldConfig.History.ClientLength ||
|
|
config.History.AutoresizeWindow != oldConfig.History.AutoresizeWindow ||
|
|
config.History.Persistent != oldConfig.History.Persistent
|
|
}
|
|
|
|
func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard, folded *regexp.Regexp, err error) {
|
|
if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 {
|
|
err = errors.New("guest format must contain 1 '*' and no '?'s")
|
|
return
|
|
}
|
|
|
|
standard, err = utils.CompileGlob(guestFormat, true)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
starIndex := strings.IndexByte(guestFormat, '*')
|
|
initial := guestFormat[:starIndex]
|
|
final := guestFormat[starIndex+1:]
|
|
initialFolded, err := casefoldWithSetting(initial, casemapping)
|
|
if err != nil {
|
|
return
|
|
}
|
|
finalFolded, err := casefoldWithSetting(final, casemapping)
|
|
if err != nil {
|
|
return
|
|
}
|
|
folded, err = utils.CompileGlob(fmt.Sprintf("%s*%s", initialFolded, finalFolded), false)
|
|
return
|
|
}
|
|
|
|
func (config *Config) loadMOTD() error {
|
|
if config.Server.MOTD != "" {
|
|
file, err := os.Open(config.Server.MOTD)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
contents, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lines := bytes.Split(contents, []byte{'\n'})
|
|
for i, line := range lines {
|
|
lineToSend := string(bytes.TrimRight(line, "\r\n"))
|
|
if len(lineToSend) == 0 && i == len(lines)-1 {
|
|
// if the last line of the MOTD was properly terminated with \n,
|
|
// there's no need to send a blank line to clients
|
|
continue
|
|
}
|
|
if config.Server.MOTDFormatting {
|
|
lineToSend = ircfmt.Unescape(lineToSend)
|
|
}
|
|
// "- " is the required prefix for MOTD
|
|
lineToSend = fmt.Sprintf("- %s", lineToSend)
|
|
config.Server.motdLines = append(config.Server.motdLines, lineToSend)
|
|
}
|
|
}
|
|
return nil
|
|
}
|