Semaphore/util/config.go

679 lines
17 KiB
Go
Raw Normal View History

2016-01-05 00:32:53 +01:00
package util
import (
2021-12-18 14:16:34 +01:00
"context"
"encoding/base64"
2016-01-05 00:32:53 +01:00
"encoding/json"
2020-11-28 22:49:44 +01:00
"errors"
2016-01-05 00:32:53 +01:00
"fmt"
"io"
"net/url"
2016-03-16 22:49:43 +01:00
"os"
2021-12-18 14:16:34 +01:00
"os/exec"
2016-04-24 20:11:43 +02:00
"path"
2021-12-18 14:16:34 +01:00
"path/filepath"
"reflect"
"regexp"
"strconv"
2023-08-06 11:01:24 +02:00
"strings"
"github.com/google/go-github/github"
"github.com/gorilla/securecookie"
2016-01-05 00:32:53 +01:00
)
// Cookie is a runtime generated secure cookie used for authentication
var Cookie *securecookie.SecureCookie
// WebHostURL is the public route to the semaphore server
2017-05-20 16:14:36 +02:00
var WebHostURL *url.URL
2016-01-05 00:32:53 +01:00
2020-11-28 22:49:44 +01:00
const (
2023-09-14 19:55:09 +02:00
DbDriverMySQL = "mysql"
DbDriverBolt = "bolt"
DbDriverPostgres = "postgres"
2020-11-28 22:49:44 +01:00
)
type DbConfig struct {
2023-09-14 19:55:09 +02:00
Dialect string `json:"-"`
Hostname string `json:"host" env:"SEMAPHORE_DB_HOST"`
Username string `json:"user" env:"SEMAPHORE_DB_USER"`
Password string `json:"pass" env:"SEMAPHORE_DB_PASS"`
DbName string `json:"name" env:"SEMAPHORE_DB"`
Options map[string]string `json:"options"`
2016-01-05 00:32:53 +01:00
}
type ldapMappings struct {
DN string `json:"dn"`
Mail string `json:"mail"`
UID string `json:"uid"`
CN string `json:"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"`
}
2023-04-16 23:57:56 +02:00
type oidcProvider struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
DisplayName string `json:"display_name"`
AutoDiscovery string `json:"provider_url"`
Endpoint oidcEndpoint `json:"endpoint"`
UsernameClaim string `json:"username_claim"`
NameClaim string `json:"name_claim"`
EmailClaim string `json:"email_claim"`
2023-04-16 23:57:56 +02:00
}
2023-07-23 16:18:02 +02:00
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.
2023-09-14 13:27:41 +02:00
GoGitClientId = "go_git"
2023-07-23 16:18:02 +02:00
// CmdGitClientId is external Git client.
// Default Git client. It is use external Git binary to clone repositories.
2023-09-14 13:27:41 +02:00
CmdGitClientId = "cmd_git"
2023-07-23 16:18:02 +02:00
)
// // 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 RunnerSettings struct {
2023-09-14 19:23:00 +02:00
ApiURL string `json:"api_url" env:"SEMAPHORE_RUNNER_API_URL"`
RegistrationToken string `json:"registration_token" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"`
ConfigFile string `json:"config_file" env:"SEMAPHORE_RUNNER_CONFIG_FILE"`
2023-09-12 19:40:22 +02:00
// OneOff indicates than runner runs only one job and exit
2023-09-14 19:23:00 +02:00
OneOff bool `json:"one_off" env:"SEMAPHORE_RUNNER_ONE_OFF"`
}
// ConfigType mapping between Config and the json file that sets it
type ConfigType struct {
2021-09-22 05:43:19 +02:00
MySQL DbConfig `json:"mysql"`
BoltDb DbConfig `json:"bolt"`
Postgres DbConfig `json:"postgres"`
2020-11-28 22:49:44 +01:00
2023-09-14 19:55:09 +02:00
Dialect string `json:"dialect" rule:"^mysql|bolt|postgres$" env:"SEMAPHORE_DB_DIALECT"`
2016-01-05 00:32:53 +01:00
// Format `:port_num` eg, :3000
2018-05-14 21:37:07 +02:00
// if : is missing it will be corrected
2023-09-14 18:56:28 +02:00
Port string `json:"port" default:":3000" rule:"^:([0-9]{1,5})$" env:"SEMAPHORE_PORT"`
2016-01-05 00:32:53 +01:00
2018-05-14 21:37:07 +02:00
// Interface ip, put in front of the port.
// defaults to empty
2023-09-14 19:23:00 +02:00
Interface string `json:"interface" env:"SEMAPHORE_INTERFACE"`
2018-05-14 21:37:07 +02:00
// semaphore stores ephemeral projects here
2023-09-14 19:23:00 +02:00
TmpPath string `json:"tmp_path" default:"/tmp/semaphore" env:"SEMAPHORE_TMP_PATH"`
2023-07-23 16:18:02 +02:00
// SshConfigPath is a path to the custom SSH config file.
// Default path is ~/.ssh/config.
2023-09-14 19:23:00 +02:00
SshConfigPath string `json:"ssh_config_path" env:"SEMAPHORE_TMP_PATH"`
2023-07-23 16:18:02 +02:00
2023-09-14 13:27:41 +02:00
GitClientId string `json:"git_client" rule:"^go_git|cmd_git$" env:"SEMAPHORE_GIT_CLIENT" default:"cmd_git"`
2023-07-24 16:04:03 +02:00
// web host
2023-09-14 19:23:00 +02:00
WebHost string `json:"web_host" env:"SEMAPHORE_WEB_ROOT"`
2023-07-24 16:04:03 +02:00
// cookie hashing & encryption
CookieHash string `json:"cookie_hash" env:"SEMAPHORE_COOKIE_HASH"`
CookieEncryption string `json:"cookie_encryption" env:"SEMAPHORE_COOKIE_ENCRYPTION"`
2022-01-26 08:14:56 +01:00
// AccessKeyEncryption is BASE64 encoded byte array used
// for encrypting and decrypting access keys stored in database.
AccessKeyEncryption string `json:"access_key_encryption" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION"`
2017-02-22 09:46:42 +01:00
2017-04-18 16:36:09 +02:00
// email alerting
2023-09-14 19:23:00 +02:00
EmailAlert bool `json:"email_alert" env:"SEMAPHORE_EMAIL_ALERT"`
EmailSender string `json:"email_sender" env:"SEMAPHORE_EMAIL_SENDER"`
EmailHost string `json:"email_host" env:"SEMAPHORE_EMAIL_HOST"`
EmailPort string `json:"email_port" rule:"^(|[0-9]{1,5})$" env:"SEMAPHORE_EMAIL_PORT"`
EmailUsername string `json:"email_username" env:"SEMAPHORE_EMAIL_USERNAME"`
EmailPassword string `json:"email_password" env:"SEMAPHORE_EMAIL_PASSWORD"`
EmailSecure bool `json:"email_secure" env:"SEMAPHORE_EMAIL_SECURE"`
2017-04-18 16:36:09 +02:00
// ldap settings
2023-09-14 19:23:00 +02:00
LdapEnable bool `json:"ldap_enable" env:"SEMAPHORE_LDAP_ENABLE"`
LdapBindDN string `json:"ldap_binddn" env:"SEMAPHORE_LDAP_BIND_DN"`
LdapBindPassword string `json:"ldap_bindpassword" env:"SEMAPHORE_LDAP_BIND_PASSWORD"`
LdapServer string `json:"ldap_server" env:"SEMAPHORE_LDAP_SERVER"`
LdapSearchDN string `json:"ldap_searchdn" env:"SEMAPHORE_LDAP_SEARCH_DN"`
LdapSearchFilter string `json:"ldap_searchfilter" env:"SEMAPHORE_LDAP_SEARCH_FILTER"`
LdapMappings ldapMappings `json:"ldap_mappings"`
2023-09-14 18:56:28 +02:00
LdapNeedTLS bool `json:"ldap_needtls" env:"SEMAPHORE_LDAP_NEEDTLS"`
2017-04-04 13:49:00 +02:00
2023-07-24 16:04:03 +02:00
// telegram and slack alerting
2023-09-14 19:23:00 +02:00
TelegramAlert bool `json:"telegram_alert" env:"SEMAPHORE_TELEGRAM_ALERT"`
TelegramChat string `json:"telegram_chat" env:"SEMAPHORE_TELEGRAM_CHAT"`
TelegramToken string `json:"telegram_token" env:"SEMAPHORE_TELEGRAM_TOKEN"`
SlackAlert bool `json:"slack_alert" env:"SEMAPHORE_SLACK_ALERT"`
SlackUrl string `json:"slack_url" env:"SEMAPHORE_SLACK_URL"`
2022-04-11 10:29:48 +02:00
2023-07-24 16:04:03 +02:00
// oidc settings
OidcProviders map[string]oidcProvider `json:"oidc_providers"`
// task concurrency
2023-09-14 19:04:17 +02:00
MaxParallelTasks int `json:"max_parallel_tasks" rule:"^[0-9]{1,10}$" env:"SEMAPHORE_MAX_PARALLEL_TASKS"`
2023-09-14 19:23:00 +02:00
RunnerRegistrationToken string `json:"runner_registration_token" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"`
// feature switches
2023-09-14 19:23:00 +02:00
PasswordLoginDisable bool `json:"password_login_disable" env:"SEMAPHORE_PASSWORD_LOGIN_DISABLED"`
NonAdminCanCreateProject bool `json:"non_admin_can_create_project" env:"SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT"`
2023-09-14 19:23:00 +02:00
UseRemoteRunner bool `json:"use_remote_runner" env:"SEMAPHORE_USE_REMOTE_RUNNER"`
Runner RunnerSettings `json:"runner"`
2016-01-05 00:32:53 +01:00
}
// 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")
2022-01-26 08:14:56 +01:00
}
// ConfigInit reads in cli flags, and switches actions appropriately on them
2021-08-25 22:12:19 +02:00
func ConfigInit(configPath string) {
fmt.Println("Loading config")
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)
2017-05-20 16:14:36 +02:00
WebHostURL, _ = url.Parse(Config.WebHost)
2017-05-20 16:25:41 +02:00
if len(WebHostURL.String()) == 0 {
WebHostURL = nil
}
2016-01-05 00:32:53 +01:00
}
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",
}
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++ {
fieldType := t.Field(i)
fieldValue := v.Field(i)
if !fieldValue.IsZero() {
continue
}
if fieldType.Type.Kind() == reflect.Struct {
err := loadDefaultsToObject(fieldValue.Addr())
if err != nil {
return err
}
continue
}
defaultVar := fieldType.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" {
valueBool = true
} else {
valueBool = false
}
return valueBool
}
func setConfigValue(attribute reflect.Value, value interface{}) {
if attribute.IsValid() {
switch attribute.Kind() {
case reflect.Int:
if reflect.ValueOf(value).Kind() != reflect.Int {
value = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value)))
}
case reflect.Bool:
if reflect.ValueOf(value).Kind() != reflect.Bool {
value = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value)))
}
}
attribute.Set(reflect.ValueOf(value))
2023-08-06 11:01:24 +02:00
} 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 || 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
}
2023-09-14 18:56:28 +02:00
var value string
if fieldType.Type.Kind() == reflect.Int {
value = strconv.FormatInt(fieldValue.Int(), 10)
} else if fieldType.Type.Kind() == reflect.Uint {
value = strconv.FormatUint(fieldValue.Uint(), 10)
} else {
value = fieldValue.String()
}
match, _ := regexp.MatchString(rule, value)
if !match {
return fmt.Errorf(
"value of field '%v' is not valid! (Must match regex: '%v')",
fieldType.Name, 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 {
2023-09-14 19:04:17 +02:00
err := loadEnvironmentToObject(fieldValue.Addr().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
}
2021-12-18 14:16:34 +01:00
// 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() != ""
2020-11-28 22:49:44 +01:00
}
func (d *DbConfig) HasSupportMultipleDatabases() bool {
2020-12-04 09:39:56 +01:00
return true
2020-11-28 22:49:44 +01:00
}
func (d *DbConfig) GetDbName() string {
2023-01-27 19:54:46 +01:00
dbName := os.Getenv("SEMAPHORE_DB_NAME")
if dbName != "" {
return dbName
2023-01-27 19:54:46 +01:00
}
return d.DbName
}
2023-01-27 19:54:46 +01:00
func (d *DbConfig) GetUsername() string {
username := os.Getenv("SEMAPHORE_DB_USER")
if username != "" {
return username
2023-01-27 19:54:46 +01:00
}
return d.Username
}
2023-01-27 19:54:46 +01:00
func (d *DbConfig) GetPassword() string {
password := os.Getenv("SEMAPHORE_DB_PASS")
if password != "" {
return password
2023-01-27 19:54:46 +01:00
}
return d.Password
}
2023-01-27 19:54:46 +01:00
func (d *DbConfig) GetHostname() string {
hostname := os.Getenv("SEMAPHORE_DB_HOST")
if hostname != "" {
return hostname
2023-01-27 19:54:46 +01:00
}
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()
2023-01-27 19:54:46 +01:00
2020-11-28 22:49:44 +01:00
switch d.Dialect {
case DbDriverBolt:
2023-01-27 19:54:46 +01:00
connectionString = dbHost
2020-11-28 22:49:44 +01:00
case DbDriverMySQL:
if includeDbName {
connectionString = fmt.Sprintf(
"%s:%s@tcp(%s)/%s",
2023-01-27 19:54:46 +01:00
dbUser,
dbPass,
dbHost,
dbName)
2020-11-28 22:49:44 +01:00
} else {
connectionString = fmt.Sprintf(
"%s:%s@tcp(%s)/",
2023-01-27 19:54:46 +01:00
dbUser,
dbPass,
dbHost)
2020-11-28 22:49:44 +01:00
}
options := map[string]string{
"parseTime": "true",
"interpolateParams": "true",
}
for v, k := range d.Options {
options[v] = k
}
connectionString += mapToQueryString(options)
2021-08-24 17:20:34 +02:00
case DbDriverPostgres:
if includeDbName {
connectionString = fmt.Sprintf(
"postgres://%s:%s@%s/%s",
2023-01-27 19:54:46 +01:00
dbUser,
url.QueryEscape(dbPass),
dbHost,
dbName)
2021-08-24 17:20:34 +02:00
} else {
connectionString = fmt.Sprintf(
"postgres://%s:%s@%s",
2023-01-27 19:54:46 +01:00
dbUser,
url.QueryEscape(dbPass),
dbHost)
2021-08-24 17:20:34 +02:00
}
connectionString += mapToQueryString(d.Options)
2020-11-28 22:49:44 +01:00
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"))
}
}
2023-09-14 19:55:09 +02:00
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
}
2020-11-28 22:49:44 +01:00
func (conf *ConfigType) GetDBConfig() (dbConfig DbConfig, err error) {
2023-09-14 19:55:09 +02:00
var dialect string
dialect, err = conf.GetDialect()
if err != nil {
return
}
switch dialect {
case DbDriverBolt:
dbConfig = conf.BoltDb
case DbDriverPostgres:
2021-08-24 17:20:34 +02:00
dbConfig = conf.Postgres
case DbDriverMySQL:
dbConfig = conf.MySQL
2020-11-28 22:49:44 +01:00
default:
err = errors.New("database configuration not found")
}
2021-08-28 18:24:54 +02:00
dbConfig.Dialect = dialect
2020-11-28 22:49:44 +01:00
return
}
// GenerateSecrets generates cookie secret during setup
2021-08-31 01:02:41 +02:00
func (conf *ConfigType) GenerateSecrets() {
hash := securecookie.GenerateRandomKey(32)
encryption := securecookie.GenerateRandomKey(32)
2021-08-31 01:02:41 +02:00
accessKeyEncryption := securecookie.GenerateRandomKey(32)
2016-01-05 00:32:53 +01:00
conf.CookieHash = base64.StdEncoding.EncodeToString(hash)
conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption)
2021-08-31 01:02:41 +02:00
conf.AccessKeyEncryption = base64.StdEncoding.EncodeToString(accessKeyEncryption)
2016-01-05 00:32:53 +01:00
}