package util import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path" "path/filepath" "reflect" "regexp" "strconv" "strings" "github.com/google/go-github/github" "github.com/gorilla/securecookie" ) // Cookie is a runtime generated secure cookie used for authentication var Cookie *securecookie.SecureCookie // WebHostURL is the public route to the semaphore server var WebHostURL *url.URL const ( DbDriverMySQL = "mysql" DbDriverBolt = "bolt" DbDriverPostgres = "postgres" ) type DbConfig struct { Dialect string `json:"-"` Hostname string `json:"host,omitempty" env:"SEMAPHORE_DB_HOST"` Username string `json:"user,omitempty" env:"SEMAPHORE_DB_USER"` Password string `json:"pass,omitempty" env:"SEMAPHORE_DB_PASS"` DbName string `json:"name,omitempty" env:"SEMAPHORE_DB"` Options map[string]string `json:"options,omitempty" env:"SEMAPHORE_DB_OPTIONS"` } type ldapMappings struct { DN string `json:"dn" env:"SEMAPHORE_LDAP_MAPPING_DN" default:"dn"` Mail string `json:"mail" env:"SEMAPHORE_LDAP_MAPPING_MAIL" default:"mail"` UID string `json:"uid" env:"SEMAPHORE_LDAP_MAPPING_UID" default:"uid"` CN string `json:"cn" env:"SEMAPHORE_LDAP_MAPPING_CN" default:"cn"` } func (p *ldapMappings) GetUsernameClaim() string { return p.UID } func (p *ldapMappings) GetEmailClaim() string { return p.Mail } func (p *ldapMappings) GetNameClaim() string { return p.CN } type oidcEndpoint struct { IssuerURL string `json:"issuer"` AuthURL string `json:"auth"` TokenURL string `json:"token"` UserInfoURL string `json:"userinfo"` JWKSURL string `json:"jwks"` Algorithms []string `json:"algorithms"` } const ( // GoGitClientId is builtin Git client. It is not require external dependencies and is preferred. // Use it if you don't need external SSH authorization. GoGitClientId = "go_git" // CmdGitClientId is external Git client. // Default Git client. It is use external Git binary to clone repositories. CmdGitClientId = "cmd_git" ) // // basic config validation using regex // /* NOTE: other basic regex could be used: // // ipv4: ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$ // ipv6: ^(?:[A-Fa-f0-9]{1,4}:|:){3,7}[A-Fa-f0-9]{1,4}$ // domain: ^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$ // path+filename: ^([\\/[a-zA-Z0-9_\\-${}:~]*]*\\/)?[a-zA-Z0-9\\.~_${}\\-:]*$ // email address: ^(|.*@[A-Za-z0-9-\\.]*)$ // // */ type RunnerConfig struct { Token string `json:"-" env:"SEMAPHORE_RUNNER_TOKEN"` TokenFile string `json:"token_file" env:"SEMAPHORE_RUNNER_TOKEN_FILE"` // OneOff indicates than runner runs only one job and exit. It is very useful for dynamic runners. // How it works? // Example: // 1) User starts the task. // 2) Semaphore found runner for task and calls runner's webhook if it provided. // 3) Your server or lambda handling the call and starts the one-off runner. // 4) The runner connects to the Semaphore server and handles the enqueued task(s). OneOff bool `json:"one_off,omitempty" env:"SEMAPHORE_RUNNER_ONE_OFF"` Webhook string `json:"webhook,omitempty" env:"SEMAPHORE_RUNNER_WEBHOOK"` MaxParallelTasks int `json:"max_parallel_tasks,omitempty" default:"1" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"` } // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL *DbConfig `json:"mysql,omitempty"` BoltDb *DbConfig `json:"bolt,omitempty"` Postgres *DbConfig `json:"postgres,omitempty"` Dialect string `json:"dialect,omitempty" default:"bolt" rule:"^mysql|bolt|postgres$" env:"SEMAPHORE_DB_DIALECT"` // Format `:port_num` eg, :3000 // if : is missing it will be corrected Port string `json:"port,omitempty" default:":3000" rule:"^:?([0-9]{1,5})$" env:"SEMAPHORE_PORT"` // Interface ip, put in front of the port. // defaults to empty Interface string `json:"interface,omitempty" env:"SEMAPHORE_INTERFACE"` // semaphore stores ephemeral projects here TmpPath string `json:"tmp_path,omitempty" default:"/tmp/semaphore" env:"SEMAPHORE_TMP_PATH"` // SshConfigPath is a path to the custom SSH config file. // Default path is ~/.ssh/config. SshConfigPath string `json:"ssh_config_path,omitempty" env:"SEMAPHORE_SSH_PATH"` GitClientId string `json:"git_client,omitempty" rule:"^go_git|cmd_git$" env:"SEMAPHORE_GIT_CLIENT" default:"cmd_git"` // web host WebHost string `json:"web_host,omitempty" env:"SEMAPHORE_WEB_ROOT"` // cookie hashing & encryption CookieHash string `json:"cookie_hash,omitempty" env:"SEMAPHORE_COOKIE_HASH"` CookieEncryption string `json:"cookie_encryption,omitempty" env:"SEMAPHORE_COOKIE_ENCRYPTION"` // AccessKeyEncryption is BASE64 encoded byte array used // for encrypting and decrypting access keys stored in database. AccessKeyEncryption string `json:"access_key_encryption,omitempty" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION"` // email alerting EmailAlert bool `json:"email_alert,omitempty" env:"SEMAPHORE_EMAIL_ALERT"` EmailSender string `json:"email_sender,omitempty" env:"SEMAPHORE_EMAIL_SENDER"` EmailHost string `json:"email_host,omitempty" env:"SEMAPHORE_EMAIL_HOST"` EmailPort string `json:"email_port,omitempty" rule:"^(|[0-9]{1,5})$" env:"SEMAPHORE_EMAIL_PORT"` EmailUsername string `json:"email_username,omitempty" env:"SEMAPHORE_EMAIL_USERNAME"` EmailPassword string `json:"email_password,omitempty" env:"SEMAPHORE_EMAIL_PASSWORD"` EmailSecure bool `json:"email_secure,omitempty" env:"SEMAPHORE_EMAIL_SECURE"` // ldap settings LdapEnable bool `json:"ldap_enable,omitempty" env:"SEMAPHORE_LDAP_ENABLE"` LdapBindDN string `json:"ldap_binddn,omitempty" env:"SEMAPHORE_LDAP_BIND_DN"` LdapBindPassword string `json:"ldap_bindpassword,omitempty" env:"SEMAPHORE_LDAP_BIND_PASSWORD"` LdapServer string `json:"ldap_server,omitempty" env:"SEMAPHORE_LDAP_SERVER"` LdapSearchDN string `json:"ldap_searchdn,omitempty" env:"SEMAPHORE_LDAP_SEARCH_DN"` LdapSearchFilter string `json:"ldap_searchfilter,omitempty" env:"SEMAPHORE_LDAP_SEARCH_FILTER"` LdapMappings *ldapMappings `json:"ldap_mappings,omitempty"` LdapNeedTLS bool `json:"ldap_needtls,omitempty" env:"SEMAPHORE_LDAP_NEEDTLS"` // Telegram, Slack, Rocket.Chat, Microsoft Teams and DingTalk alerting TelegramAlert bool `json:"telegram_alert,omitempty" env:"SEMAPHORE_TELEGRAM_ALERT"` TelegramChat string `json:"telegram_chat,omitempty" env:"SEMAPHORE_TELEGRAM_CHAT"` TelegramToken string `json:"telegram_token,omitempty" env:"SEMAPHORE_TELEGRAM_TOKEN"` SlackAlert bool `json:"slack_alert,omitempty" env:"SEMAPHORE_SLACK_ALERT"` SlackUrl string `json:"slack_url,omitempty" env:"SEMAPHORE_SLACK_URL"` RocketChatAlert bool `json:"rocketchat_alert,omitempty" env:"SEMAPHORE_ROCKETCHAT_ALERT"` RocketChatUrl string `json:"rocketchat_url,omitempty" env:"SEMAPHORE_ROCKETCHAT_URL"` MicrosoftTeamsAlert bool `json:"microsoft_teams_alert,omitempty" env:"SEMAPHORE_MICROSOFT_TEAMS_ALERT"` MicrosoftTeamsUrl string `json:"microsoft_teams_url,omitempty" env:"SEMAPHORE_MICROSOFT_TEAMS_URL"` DingTalkAlert bool `json:"dingtalk_alert,omitempty" env:"SEMAPHORE_DINGTALK_ALERT"` DingTalkUrl string `json:"dingtalk_url,omitempty" env:"SEMAPHORE_DINGTALK_URL"` // oidc settings OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty"` MaxTaskDurationSec int `json:"max_task_duration_sec,omitempty" env:"SEMAPHORE_MAX_TASK_DURATION_SEC"` MaxTasksPerTemplate int `json:"max_tasks_per_template,omitempty" env:"SEMAPHORE_MAX_TASKS_PER_TEMPLATE"` // task concurrency MaxParallelTasks int `json:"max_parallel_tasks,omitempty" default:"10" rule:"^[0-9]{1,10}$" env:"SEMAPHORE_MAX_PARALLEL_TASKS"` RunnerRegistrationToken string `json:"runner_registration_token,omitempty" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"` // feature switches PasswordLoginDisable bool `json:"password_login_disable,omitempty" env:"SEMAPHORE_PASSWORD_LOGIN_DISABLED"` NonAdminCanCreateProject bool `json:"non_admin_can_create_project,omitempty" env:"SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT"` UseRemoteRunner bool `json:"use_remote_runner,omitempty" env:"SEMAPHORE_USE_REMOTE_RUNNER"` IntegrationAlias string `json:"global_integration_alias,omitempty" env:"SEMAPHORE_INTEGRATION_ALIAS"` Apps map[string]App `json:"apps,omitempty" env:"SEMAPHORE_APPS"` Runner *RunnerConfig `json:"runner,omitempty"` } // Config exposes the application configuration storage for use in the application var Config *ConfigType // ToJSON returns a JSON string of the config func (conf *ConfigType) ToJSON() ([]byte, error) { return json.MarshalIndent(&conf, " ", "\t") } // ConfigInit reads in cli flags, and switches actions appropriately on them func ConfigInit(configPath string, noConfigFile bool) { fmt.Println("Loading config") Config = &ConfigType{} Config.Apps = map[string]App{} if !noConfigFile { loadConfigFile(configPath) } loadConfigEnvironment() loadConfigDefaults() fmt.Println("Validating config") validateConfig() var encryption []byte hash, _ := base64.StdEncoding.DecodeString(Config.CookieHash) if len(Config.CookieEncryption) > 0 { encryption, _ = base64.StdEncoding.DecodeString(Config.CookieEncryption) } Cookie = securecookie.New(hash, encryption) WebHostURL, _ = url.Parse(Config.WebHost) if len(WebHostURL.String()) == 0 { WebHostURL = nil } if Config.Runner != nil && Config.Runner.TokenFile != "" { runnerTokenBytes, err := os.ReadFile(Config.Runner.TokenFile) if err == nil { Config.Runner.Token = strings.TrimSpace(string(runnerTokenBytes)) } } } func loadConfigFile(configPath string) { if configPath == "" { configPath = os.Getenv("SEMAPHORE_CONFIG_PATH") } //If the configPath option has been set try to load and decode it //var usedPath string if configPath == "" { cwd, err := os.Getwd() exitOnConfigFileError(err) paths := []string{ path.Join(cwd, "config.json"), "/usr/local/etc/semaphore/config.json", "/etc/semaphore/config.json", } for _, p := range paths { _, err = os.Stat(p) if err != nil { continue } var file *os.File file, err = os.Open(p) if err != nil { continue } decodeConfig(file) break } exitOnConfigFileError(err) } else { p := configPath file, err := os.Open(p) exitOnConfigFileError(err) decodeConfig(file) } } func loadDefaultsToObject(obj interface{}) error { var t = reflect.TypeOf(obj) var v = reflect.ValueOf(obj) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldInfo := t.Field(i) fieldValue := v.Field(i) if !fieldValue.IsZero() && fieldInfo.Type.Kind() != reflect.Struct && fieldInfo.Type.Kind() != reflect.Map { continue } if fieldInfo.Type.Kind() == reflect.Struct { err := loadDefaultsToObject(fieldValue.Addr().Interface()) if err != nil { return err } continue } else if fieldInfo.Type.Kind() == reflect.Map { for _, key := range fieldValue.MapKeys() { val := fieldValue.MapIndex(key) if val.Type().Kind() != reflect.Struct { continue } newVal := reflect.New(val.Type()) pointerValue := newVal.Elem() pointerValue.Set(val) err := loadDefaultsToObject(newVal.Interface()) if err != nil { return err } fieldValue.SetMapIndex(key, newVal.Elem()) } continue } defaultVar := fieldInfo.Tag.Get("default") if defaultVar == "" { continue } setConfigValue(fieldValue, defaultVar) } return nil } func loadConfigDefaults() { err := loadDefaultsToObject(Config) if err != nil { panic(err) } } func castStringToInt(value string) int { valueInt, err := strconv.Atoi(value) if err != nil { panic(err) } return valueInt } func castStringToBool(value string) bool { var valueBool bool if value == "1" || strings.ToLower(value) == "true" || strings.ToLower(value) == "yes" { valueBool = true } else { valueBool = false } return valueBool } func CastValueToKind(value interface{}, kind reflect.Kind) (res interface{}, ok bool) { res = value switch kind { case reflect.Slice: if reflect.ValueOf(value).Kind() == reflect.String { var arr []string err := json.Unmarshal([]byte(value.(string)), &arr) if err != nil { panic(err) } res = arr ok = true } case reflect.String: ok = true case reflect.Int: if reflect.ValueOf(value).Kind() != reflect.Int { res = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value))) ok = true } case reflect.Bool: if reflect.ValueOf(value).Kind() != reflect.Bool { res = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value))) ok = true } case reflect.Map: if reflect.ValueOf(value).Kind() == reflect.String { mapValue := make(map[string]string) err := json.Unmarshal([]byte(value.(string)), &mapValue) if err != nil { panic(err) } res = mapValue ok = true } default: } return } func setConfigValue(attribute reflect.Value, value interface{}) { if attribute.IsValid() { value, _ = CastValueToKind(value, attribute.Kind()) attribute.Set(reflect.ValueOf(value)) } else { panic(fmt.Errorf("got non-existent config attribute")) } } func getConfigValue(path string) string { attribute := reflect.ValueOf(Config) nested_path := strings.Split(path, ".") for i, nested := range nested_path { attribute = reflect.Indirect(attribute).FieldByName(nested) lastDepth := len(nested_path) == i+1 if !lastDepth && attribute.Kind() != reflect.Struct && attribute.Kind() != reflect.Pointer || lastDepth && attribute.Kind() == reflect.Invalid { panic(fmt.Errorf("got non-existent config attribute '%v'", path)) } } return fmt.Sprintf("%v", attribute) } func validate(value interface{}) error { var t = reflect.TypeOf(value) var v = reflect.ValueOf(value) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldType := t.Field(i) fieldValue := v.Field(i) rule := fieldType.Tag.Get("rule") if rule == "" { continue } var strVal string if fieldType.Type.Kind() == reflect.Int { strVal = strconv.FormatInt(fieldValue.Int(), 10) } else if fieldType.Type.Kind() == reflect.Uint { strVal = strconv.FormatUint(fieldValue.Uint(), 10) } else { strVal = fieldValue.String() } match, _ := regexp.MatchString(rule, strVal) if match { continue } fieldName := strings.ToLower(fieldType.Name) if strings.Contains(fieldName, "password") || strings.Contains(fieldName, "secret") || strings.Contains(fieldName, "key") { strVal = "***" } return fmt.Errorf( "value of field '%v' is not valid: %v (Must match regex: '%v')", fieldType.Name, strVal, rule, ) } return nil } func validateConfig() { err := validate(Config) if err != nil { panic(err) } } func loadEnvironmentToObject(obj interface{}) error { var t = reflect.TypeOf(obj) var v = reflect.ValueOf(obj) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldType := t.Field(i) fieldValue := v.Field(i) if fieldType.Type.Kind() == reflect.Struct { err := loadEnvironmentToObject(fieldValue.Addr().Interface()) if err != nil { return err } continue } else if fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct { if fieldValue.IsZero() { newValue := reflect.New(fieldType.Type.Elem()) fieldValue.Set(newValue) } err := loadEnvironmentToObject(fieldValue.Interface()) if err != nil { return err } continue } envVar := fieldType.Tag.Get("env") if envVar == "" { continue } envValue, exists := os.LookupEnv(envVar) if !exists { continue } setConfigValue(fieldValue, envValue) } return nil } func loadConfigEnvironment() { err := loadEnvironmentToObject(Config) if err != nil { panic(err) } } func exitOnConfigError(msg string) { fmt.Println(msg) os.Exit(1) } func exitOnConfigFileError(err error) { if err != nil { exitOnConfigError("Cannot Find configuration! Use --config parameter to point to a JSON file generated by `semaphore setup`.") } } func decodeConfig(file io.Reader) { if err := json.NewDecoder(file).Decode(&Config); err != nil { fmt.Println("Could not decode configuration!") panic(err) } } func mapToQueryString(m map[string]string) (str string) { for option, value := range m { if str != "" { str += "&" } str += option + "=" + value } if str != "" { str = "?" + str } return } // FindSemaphore looks in the PATH for the semaphore variable // if not found it will attempt to find the absolute path of the first // os argument, the semaphore command, and return it func FindSemaphore() string { cmdPath, _ := exec.LookPath("semaphore") //nolint: gas if len(cmdPath) == 0 { cmdPath, _ = filepath.Abs(os.Args[0]) // nolint: gas } return cmdPath } func AnsibleVersion() string { bytes, err := exec.Command("ansible", "--version").Output() if err != nil { return "" } return string(bytes) } // CheckUpdate uses the GitHub client to check for new tags in the semaphore repo func CheckUpdate() (updateAvailable *github.RepositoryRelease, err error) { // fetch releases gh := github.NewClient(nil) releases, _, err := gh.Repositories.ListReleases(context.TODO(), "ansible-semaphore", "semaphore", nil) if err != nil { return } updateAvailable = nil if (*releases[0].TagName)[1:] != Version() { updateAvailable = releases[0] } return } func (d *DbConfig) IsPresent() bool { return d.GetHostname() != "" } func (d *DbConfig) HasSupportMultipleDatabases() bool { return true } func (d *DbConfig) GetDbName() string { dbName := os.Getenv("SEMAPHORE_DB_NAME") if dbName != "" { return dbName } return d.DbName } func (d *DbConfig) GetUsername() string { username := os.Getenv("SEMAPHORE_DB_USER") if username != "" { return username } return d.Username } func (d *DbConfig) GetPassword() string { password := os.Getenv("SEMAPHORE_DB_PASS") if password != "" { return password } return d.Password } func (d *DbConfig) GetHostname() string { hostname := os.Getenv("SEMAPHORE_DB_HOST") if hostname != "" { return hostname } return d.Hostname } func (d *DbConfig) GetConnectionString(includeDbName bool) (connectionString string, err error) { dbName := d.GetDbName() dbUser := d.GetUsername() dbPass := d.GetPassword() dbHost := d.GetHostname() switch d.Dialect { case DbDriverBolt: connectionString = dbHost case DbDriverMySQL: if includeDbName { connectionString = fmt.Sprintf( "%s:%s@tcp(%s)/%s", dbUser, dbPass, dbHost, dbName) } else { connectionString = fmt.Sprintf( "%s:%s@tcp(%s)/", dbUser, dbPass, dbHost) } options := map[string]string{ "parseTime": "true", "interpolateParams": "true", } for v, k := range d.Options { options[v] = k } connectionString += mapToQueryString(options) case DbDriverPostgres: if includeDbName { connectionString = fmt.Sprintf( "postgres://%s:%s@%s/%s", dbUser, url.QueryEscape(dbPass), dbHost, dbName) } else { connectionString = fmt.Sprintf( "postgres://%s:%s@%s", dbUser, url.QueryEscape(dbPass), dbHost) } connectionString += mapToQueryString(d.Options) default: err = fmt.Errorf("unsupported database driver: %s", d.Dialect) } return } func (conf *ConfigType) PrintDbInfo() { dialect, err := conf.GetDialect() if err != nil { panic(err) } switch dialect { case DbDriverMySQL: fmt.Printf("MySQL %v@%v %v\n", conf.MySQL.GetUsername(), conf.MySQL.GetHostname(), conf.MySQL.GetDbName()) case DbDriverBolt: fmt.Printf("BoltDB %v\n", conf.BoltDb.GetHostname()) case DbDriverPostgres: fmt.Printf("Postgres %v@%v %v\n", conf.Postgres.GetUsername(), conf.Postgres.GetHostname(), conf.Postgres.GetDbName()) default: panic(fmt.Errorf("database configuration not found")) } } func (conf *ConfigType) GetDialect() (dialect string, err error) { if conf.Dialect == "" { switch { case conf.MySQL.IsPresent(): dialect = DbDriverMySQL case conf.BoltDb.IsPresent(): dialect = DbDriverBolt case conf.Postgres.IsPresent(): dialect = DbDriverPostgres default: err = errors.New("database configuration not found") } return } dialect = conf.Dialect return } func (conf *ConfigType) GetDBConfig() (dbConfig DbConfig, err error) { var dialect string dialect, err = conf.GetDialect() if err != nil { return } switch dialect { case DbDriverBolt: dbConfig = *conf.BoltDb case DbDriverPostgres: dbConfig = *conf.Postgres case DbDriverMySQL: dbConfig = *conf.MySQL default: err = errors.New("database configuration not found") } dbConfig.Dialect = dialect return } // GenerateSecrets generates cookie secret during setup func (conf *ConfigType) GenerateSecrets() { hash := securecookie.GenerateRandomKey(32) encryption := securecookie.GenerateRandomKey(32) accessKeyEncryption := securecookie.GenerateRandomKey(32) conf.CookieHash = base64.StdEncoding.EncodeToString(hash) conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption) conf.AccessKeyEncryption = base64.StdEncoding.EncodeToString(accessKeyEncryption) } var appCommands = map[string]string{ "ansible": "ansible-playbook", "terraform": "terraform", "tofu": "tofu", "bash": "bash", } var appPriorities = map[string]int{ "ansible": 1000, "terraform": 900, "tofu": 800, "bash": 700, "powershell": 600, "python": 500, } func LookupDefaultApps() { for appID, cmd := range appCommands { if _, ok := Config.Apps[appID]; ok { continue } _, err := exec.LookPath(cmd) if err != nil { continue } if Config.Apps == nil { Config.Apps = make(map[string]App) } Config.Apps[appID] = App{ Active: true, } } for k, v := range appPriorities { app, _ := Config.Apps[k] if app.Priority <= 0 { app.Priority = v } Config.Apps[k] = app } } func PrintDebug() { envs := os.Environ() for _, e := range envs { fmt.Println(e) } b, _ := Config.ToJSON() fmt.Println(string(b)) }