2020-12-21 00:32:39 +00:00
package main
import (
2021-09-22 13:36:45 +00:00
_ "embed"
2023-07-14 14:15:58 +00:00
"encoding/base64"
"encoding/hex"
2021-04-09 16:14:44 +00:00
"fmt"
"io"
2020-12-21 00:32:39 +00:00
"os"
2021-04-09 16:14:44 +00:00
"path"
2023-07-14 14:15:58 +00:00
"strings"
2020-12-21 00:32:39 +00:00
"time"
2021-09-22 13:36:45 +00:00
"github.com/gofrs/uuid/v3"
2020-12-21 00:32:39 +00:00
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
2023-07-14 14:15:58 +00:00
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
2023-09-11 17:51:38 +00:00
"gopkg.in/irc.v4"
2023-06-30 15:38:33 +00:00
"gopkg.in/yaml.v3"
2021-11-25 22:48:16 +00:00
2022-12-18 13:48:57 +00:00
"github.com/Luzifer/go_helpers/v2/str"
2022-11-02 21:38:14 +00:00
"github.com/Luzifer/twitch-bot/v3/plugins"
2020-12-21 00:32:39 +00:00
)
2021-09-22 13:36:45 +00:00
const expectedMinConfigVersion = 2
2021-04-09 16:14:44 +00:00
2021-09-22 13:36:45 +00:00
var (
//go:embed default_config.yaml
defaultConfigurationYAML [ ] byte
hashstructUUIDNamespace = uuid . Must ( uuid . FromString ( "3a0ccc46-d3ba-46b5-ac07-27528c933174" ) )
2022-10-07 17:10:22 +00:00
errSaveNotRequired = errors . New ( "save not required" )
2021-09-22 13:36:45 +00:00
)
type (
2021-10-23 15:22:58 +00:00
configAuthToken struct {
Hash string ` json:"-" yaml:"hash" `
Modules [ ] string ` json:"modules" yaml:"modules" `
Name string ` json:"name" yaml:"name" `
Token string ` json:"token" yaml:"-" `
}
2021-09-22 13:36:45 +00:00
configFileVersioner struct {
ConfigVersion int64 ` yaml:"config_version" `
}
configFile struct {
2021-10-23 15:22:58 +00:00
AuthTokens map [ string ] configAuthToken ` yaml:"auth_tokens" `
AutoMessages [ ] * autoMessage ` yaml:"auto_messages" `
BotEditors [ ] string ` yaml:"bot_editors" `
Channels [ ] string ` yaml:"channels" `
GitTrackConfig bool ` yaml:"git_track_config" `
HTTPListen string ` yaml:"http_listen" `
PermitAllowModerator bool ` yaml:"permit_allow_moderator" `
PermitTimeout time . Duration ` yaml:"permit_timeout" `
RawLog string ` yaml:"raw_log" `
2023-09-02 11:49:15 +00:00
ModuleConfig plugins . ModuleConfig ` yaml:"module_config" `
2021-10-23 15:22:58 +00:00
Rules [ ] * plugins . Rule ` yaml:"rules" `
Variables map [ string ] interface { } ` yaml:"variables" `
2021-09-22 13:36:45 +00:00
rawLogWriter io . WriteCloser
configFileVersioner ` yaml:",inline" `
}
)
2021-05-10 22:24:36 +00:00
func newConfigFile ( ) * configFile {
return & configFile {
2021-10-23 15:22:58 +00:00
AuthTokens : map [ string ] configAuthToken { } ,
2020-12-21 00:32:39 +00:00
PermitTimeout : time . Minute ,
}
}
func loadConfig ( filename string ) error {
var (
2021-09-22 13:36:45 +00:00
configVersion = & configFileVersioner { }
err error
tmpConfig = newConfigFile ( )
2020-12-21 00:32:39 +00:00
)
2021-09-22 13:36:45 +00:00
if err = parseConfigFromYAML ( filename , configVersion , false ) ; err != nil {
return errors . Wrap ( err , "parsing config version" )
}
2021-05-24 15:36:16 +00:00
2021-09-22 13:36:45 +00:00
if configVersion . ConfigVersion < expectedMinConfigVersion {
2022-01-31 01:14:36 +00:00
return errors . Errorf ( "config version too old: %d < %d - Please have a look at the documentation" , configVersion . ConfigVersion , expectedMinConfigVersion )
2021-05-24 15:36:16 +00:00
}
2021-09-22 13:36:45 +00:00
if err = parseConfigFromYAML ( filename , tmpConfig , true ) ; err != nil {
2021-05-24 15:36:16 +00:00
return errors . Wrap ( err , "parsing config" )
2020-12-21 00:32:39 +00:00
}
2022-12-18 13:48:57 +00:00
if err = tmpConfig . runLoadChecks ( ) ; err != nil {
return errors . Wrap ( err , "running load-checks on config" )
2021-09-22 13:36:45 +00:00
}
2020-12-21 00:32:39 +00:00
configLock . Lock ( )
defer configLock . Unlock ( )
2021-04-21 18:03:25 +00:00
tmpConfig . updateAutoMessagesFromConfig ( config )
2021-05-24 15:36:16 +00:00
tmpConfig . fixDurations ( )
2021-09-22 13:36:45 +00:00
tmpConfig . fixMissingUUIDs ( )
2023-07-14 14:15:58 +00:00
if err = tmpConfig . fixTokenHashStorage ( ) ; err != nil {
return errors . Wrap ( err , "applying token hash fixes" )
}
2021-04-21 18:03:25 +00:00
2021-04-09 16:14:44 +00:00
switch {
case config != nil && config . RawLog == tmpConfig . RawLog :
tmpConfig . rawLogWriter = config . rawLogWriter
case tmpConfig . RawLog == "" :
if err = config . CloseRawMessageWriter ( ) ; err != nil {
return errors . Wrap ( err , "closing old raw log writer" )
}
tmpConfig . rawLogWriter = writeNoOpCloser { io . Discard }
default :
if err = config . CloseRawMessageWriter ( ) ; err != nil {
return errors . Wrap ( err , "closing old raw log writer" )
}
2021-06-28 22:05:11 +00:00
if err = os . MkdirAll ( path . Dir ( tmpConfig . RawLog ) , 0 o755 ) ; err != nil { //nolint:gomnd // This is a common directory permission
2021-04-09 16:14:44 +00:00
return errors . Wrap ( err , "creating directories for raw log" )
}
2021-06-28 22:05:11 +00:00
if tmpConfig . rawLogWriter , err = os . OpenFile ( tmpConfig . RawLog , os . O_APPEND | os . O_CREATE | os . O_WRONLY , 0 o644 ) ; err != nil { //nolint:gomnd // This is a common file permission
2021-04-09 16:14:44 +00:00
return errors . Wrap ( err , "opening raw log for appending" )
}
}
2021-05-10 22:24:36 +00:00
config = tmpConfig
2023-06-16 20:00:46 +00:00
timerService . UpdatePermitTimeout ( tmpConfig . PermitTimeout )
2021-05-10 22:45:27 +00:00
log . WithFields ( log . Fields {
"auto_messages" : len ( config . AutoMessages ) ,
"rules" : len ( config . Rules ) ,
"channels" : len ( config . Channels ) ,
} ) . Info ( "Config file (re)loaded" )
2021-09-22 13:36:45 +00:00
// Notify listener config has changed
2023-07-14 14:15:58 +00:00
frontendNotifyHooks . Ping ( frontendNotifyTypeReload )
2021-09-22 13:36:45 +00:00
2020-12-21 00:32:39 +00:00
return nil
}
2021-09-22 13:36:45 +00:00
func parseConfigFromYAML ( filename string , obj interface { } , strict bool ) error {
2021-05-24 15:36:16 +00:00
f , err := os . Open ( filename )
if err != nil {
2021-09-22 13:36:45 +00:00
return errors . Wrap ( err , "open config file" )
2021-05-24 15:36:16 +00:00
}
defer f . Close ( )
2021-09-22 13:36:45 +00:00
decoder := yaml . NewDecoder ( f )
2023-06-30 15:38:33 +00:00
decoder . KnownFields ( strict )
2021-09-22 13:36:45 +00:00
return errors . Wrap ( decoder . Decode ( obj ) , "decode config file" )
}
2021-10-22 15:09:39 +00:00
func patchConfig ( filename , authorName , authorEmail , summary string , patcher func ( * configFile ) error ) error {
2021-05-24 15:36:16 +00:00
var (
2021-09-22 13:36:45 +00:00
cfgFile = newConfigFile ( )
err error
)
if err = parseConfigFromYAML ( filename , cfgFile , true ) ; err != nil {
return errors . Wrap ( err , "loading current config" )
}
cfgFile . fixMissingUUIDs ( )
2023-07-14 14:15:58 +00:00
if err = cfgFile . fixTokenHashStorage ( ) ; err != nil {
return errors . Wrap ( err , "applying token hash fixes" )
}
2021-09-22 13:36:45 +00:00
2022-10-07 17:10:22 +00:00
err = patcher ( cfgFile )
switch {
case errors . Is ( err , nil ) :
// This is fine
case errors . Is ( err , errSaveNotRequired ) :
// This is also fine but we don't need to save
return nil
default :
2021-09-22 13:36:45 +00:00
return errors . Wrap ( err , "patching config" )
}
2023-04-02 12:55:27 +00:00
if err = cfgFile . runLoadChecks ( ) ; err != nil {
return errors . Wrap ( err , "checking config after patch" )
}
2021-09-22 13:36:45 +00:00
return errors . Wrap (
2021-10-22 15:09:39 +00:00
writeConfigToYAML ( filename , authorName , authorEmail , summary , cfgFile ) ,
2021-09-22 13:36:45 +00:00
"replacing config" ,
2021-05-24 15:36:16 +00:00
)
2021-09-22 13:36:45 +00:00
}
2021-10-22 15:09:39 +00:00
func writeConfigToYAML ( filename , authorName , authorEmail , summary string , obj * configFile ) error {
2022-09-05 22:34:30 +00:00
tmpFile , err := os . CreateTemp ( path . Dir ( filename ) , "twitch-bot-*.yaml" )
2021-09-22 13:36:45 +00:00
if err != nil {
return errors . Wrap ( err , "opening tempfile" )
}
tmpFileName := tmpFile . Name ( )
2021-05-24 15:36:16 +00:00
2021-10-22 15:09:39 +00:00
fmt . Fprintf ( tmpFile , "# Automatically updated by %s using Config-Editor frontend, last update: %s\n" , authorName , time . Now ( ) . Format ( time . RFC3339 ) )
2021-09-22 13:36:45 +00:00
if err = yaml . NewEncoder ( tmpFile ) . Encode ( obj ) ; err != nil {
tmpFile . Close ( )
return errors . Wrap ( err , "encoding config" )
}
tmpFile . Close ( )
2021-10-22 15:09:39 +00:00
if err = os . Rename ( tmpFileName , filename ) ; err != nil {
return errors . Wrap ( err , "moving config to location" )
}
if ! obj . GitTrackConfig {
return nil
}
git := newGitHelper ( path . Dir ( filename ) )
if ! git . HasRepo ( ) {
log . Error ( "Instructed to track changes using Git, but config not in repo" )
return nil
}
2021-09-22 13:36:45 +00:00
return errors . Wrap (
2021-10-22 15:09:39 +00:00
git . CommitChange ( path . Base ( filename ) , authorName , authorEmail , summary ) ,
"committing config changes" ,
2021-09-22 13:36:45 +00:00
)
}
2021-05-24 15:36:16 +00:00
2021-09-22 13:36:45 +00:00
func writeDefaultConfigFile ( filename string ) error {
f , err := os . Create ( filename )
if err != nil {
return errors . Wrap ( err , "creating config file" )
2021-05-24 15:36:16 +00:00
}
2021-09-22 13:36:45 +00:00
defer f . Close ( )
2021-05-24 15:36:16 +00:00
2021-09-22 13:36:45 +00:00
_ , err = f . Write ( defaultConfigurationYAML )
return errors . Wrap ( err , "writing default config" )
2021-05-24 15:36:16 +00:00
}
2023-07-14 14:15:58 +00:00
func ( c configAuthToken ) validate ( token string ) error {
switch {
case strings . HasPrefix ( c . Hash , "$2a$" ) :
return errors . Wrap (
bcrypt . CompareHashAndPassword ( [ ] byte ( c . Hash ) , [ ] byte ( token ) ) ,
"validating bcrypt" ,
)
case strings . HasPrefix ( c . Hash , "$argon2id$" ) :
var (
flds = strings . Split ( c . Hash , "$" )
t , m uint32
p uint8
)
if _ , err := fmt . Sscanf ( flds [ 3 ] , "m=%d,t=%d,p=%d" , & m , & t , & p ) ; err != nil {
return errors . Wrap ( err , "scanning argon2id hash params" )
}
salt , err := base64 . RawStdEncoding . DecodeString ( flds [ 4 ] )
if err != nil {
return errors . Wrap ( err , "decoding salt" )
}
if flds [ 5 ] == base64 . RawStdEncoding . EncodeToString ( argon2 . IDKey ( [ ] byte ( token ) , salt , t , m , p , argonHashLen ) ) {
return nil
}
return errors . New ( "hash does not match" )
default :
return errors . New ( "unknown hash format found" )
}
}
2021-04-09 16:14:44 +00:00
func ( c * configFile ) CloseRawMessageWriter ( ) error {
if c == nil || c . rawLogWriter == nil {
return nil
}
return c . rawLogWriter . Close ( )
}
2021-11-11 13:59:08 +00:00
func ( c configFile ) GetMatchingRules ( m * irc . Message , event * string , eventData * plugins . FieldCollection ) [ ] * plugins . Rule {
2020-12-21 00:32:39 +00:00
configLock . RLock ( )
defer configLock . RUnlock ( )
2021-08-19 13:33:56 +00:00
var out [ ] * plugins . Rule
2020-12-21 00:32:39 +00:00
for _ , r := range c . Rules {
2022-09-10 11:39:07 +00:00
if r . Matches ( m , event , timerService , formatMessage , twitchClient , eventData ) {
2020-12-21 00:32:39 +00:00
out = append ( out , r )
}
}
return out
}
2021-04-09 16:14:44 +00:00
func ( c configFile ) LogRawMessage ( m * irc . Message ) error {
_ , err := fmt . Fprintln ( c . rawLogWriter , m . String ( ) )
return errors . Wrap ( err , "writing raw log message" )
}
2021-04-21 18:03:25 +00:00
2021-05-24 15:36:16 +00:00
func ( c * configFile ) fixDurations ( ) {
// General fields
c . PermitTimeout = c . fixedDuration ( c . PermitTimeout )
// Fix rules
for _ , r := range c . Rules {
2022-03-12 00:28:20 +00:00
r . ChannelCooldown = c . fixedDurationPtr ( r . ChannelCooldown )
2021-05-24 15:36:16 +00:00
r . Cooldown = c . fixedDurationPtr ( r . Cooldown )
2022-03-12 00:28:20 +00:00
r . UserCooldown = c . fixedDurationPtr ( r . UserCooldown )
2021-05-24 15:36:16 +00:00
}
}
func ( configFile ) fixedDuration ( d time . Duration ) time . Duration {
if d > time . Second {
return d
}
return d * time . Second
}
func ( configFile ) fixedDurationPtr ( d * time . Duration ) * time . Duration {
2022-03-12 00:28:20 +00:00
if d == nil || * d >= time . Second {
2021-05-24 15:36:16 +00:00
return d
}
fd := * d * time . Second
return & fd
}
2021-09-22 13:36:45 +00:00
func ( c * configFile ) fixMissingUUIDs ( ) {
for i := range c . AutoMessages {
if c . AutoMessages [ i ] . UUID != "" {
continue
}
c . AutoMessages [ i ] . UUID = uuid . NewV5 ( hashstructUUIDNamespace , c . AutoMessages [ i ] . ID ( ) ) . String ( )
}
for i := range c . Rules {
if c . Rules [ i ] . UUID != "" {
continue
}
c . Rules [ i ] . UUID = uuid . NewV5 ( hashstructUUIDNamespace , c . Rules [ i ] . MatcherID ( ) ) . String ( )
}
}
2023-07-14 14:15:58 +00:00
func ( c * configFile ) fixTokenHashStorage ( ) ( err error ) {
for key := range c . AuthTokens {
auth := c . AuthTokens [ key ]
if strings . HasPrefix ( auth . Hash , "$" ) {
continue
}
rawHash , err := hex . DecodeString ( auth . Hash )
if err != nil {
return errors . Wrap ( err , "reading hash" )
}
auth . Hash = string ( rawHash )
c . AuthTokens [ key ] = auth
}
return nil
}
2022-12-18 13:48:57 +00:00
func ( c * configFile ) runLoadChecks ( ) ( err error ) {
if len ( c . Channels ) == 0 {
log . Warn ( "Loaded config with empty channel list" )
}
if len ( c . Rules ) == 0 {
log . Warn ( "Loaded config with empty ruleset" )
}
var seen [ ] string
for _ , r := range c . Rules {
if r . UUID != "" && str . StringInSlice ( r . UUID , seen ) {
return errors . New ( "duplicate rule UUIDs found" )
}
seen = append ( seen , r . UUID )
}
if err = c . validateRuleActions ( ) ; err != nil {
return errors . Wrap ( err , "validating rule actions" )
}
return nil
}
2021-04-21 18:03:25 +00:00
func ( c * configFile ) updateAutoMessagesFromConfig ( old * configFile ) {
for idx , nam := range c . AutoMessages {
// By default assume last message to be sent now
// in order not to spam messages at startup
nam . lastMessageSent = time . Now ( )
if ! nam . IsValid ( ) {
log . WithField ( "index" , idx ) . Warn ( "Auto-Message configuration is invalid and therefore disabled" )
}
if old == nil {
// Initial config load, do not update timers
continue
}
for _ , oam := range old . AutoMessages {
if nam . ID ( ) != oam . ID ( ) {
continue
}
// We disable the old message as executing it would
// mess up the constraints of the new message
oam . lock . Lock ( )
oam . disabled = true
nam . lastMessageSent = oam . lastMessageSent
nam . linesSinceLastMessage = oam . linesSinceLastMessage
2021-05-10 23:01:12 +00:00
oam . lock . Unlock ( )
2021-04-21 18:03:25 +00:00
}
}
}
2021-09-22 13:36:45 +00:00
func ( c configFile ) validateRuleActions ( ) error {
2022-10-31 16:26:53 +00:00
var hasError bool
2021-09-22 13:36:45 +00:00
for _ , r := range c . Rules {
logger := log . WithField ( "rule" , r . MatcherID ( ) )
2022-10-31 16:26:53 +00:00
if err := r . Validate ( validateTemplate ) ; err != nil {
logger . WithError ( err ) . Error ( "Rule reported invalid config" )
hasError = true
}
2021-09-22 13:36:45 +00:00
for idx , a := range r . Actions {
actor , err := getActorByName ( a . Type )
if err != nil {
logger . WithField ( "index" , idx ) . WithError ( err ) . Error ( "Cannot get actor by type" )
2022-10-31 16:26:53 +00:00
hasError = true
continue
2021-09-22 13:36:45 +00:00
}
2022-10-31 16:26:53 +00:00
if err = actor . Validate ( validateTemplate , a . Attributes ) ; err != nil {
2021-09-22 13:36:45 +00:00
logger . WithField ( "index" , idx ) . WithError ( err ) . Error ( "Actor reported invalid config" )
2022-10-31 16:26:53 +00:00
hasError = true
2021-09-22 13:36:45 +00:00
}
}
}
2022-10-31 16:26:53 +00:00
if hasError {
return errors . New ( "config validation reported errors, see log" )
}
2021-09-22 13:36:45 +00:00
return nil
}