diff --git a/cloudconfig/cloudconfig.go b/cloudconfig/cloudconfig.go index e90baffc..bf0d3d58 100644 --- a/cloudconfig/cloudconfig.go +++ b/cloudconfig/cloudconfig.go @@ -15,6 +15,7 @@ package cloudconfig import ( + "crypto/x509" "encoding/base64" "fmt" "garm/config" @@ -73,6 +74,29 @@ type CloudInit struct { SystemInfo *SystemInfo `yaml:"system_info,omitempty"` RunCmd []string `yaml:"runcmd,omitempty"` WriteFiles []File `yaml:"write_files,omitempty"` + CACerts CACerts `yaml:"ca-certs,omitempty"` +} + +type CACerts struct { + RemoveDefaults bool `yaml:"remove-defaults"` + Trusted []string `yaml:"trusted"` +} + +func (c *CloudInit) AddCACert(cert []byte) error { + c.mux.Lock() + defer c.mux.Unlock() + + if cert == nil { + return nil + } + + roots := x509.NewCertPool() + if ok := roots.AppendCertsFromPEM(cert); !ok { + return fmt.Errorf("failed to parse CA cert bundle") + } + c.CACerts.Trusted = append(c.CACerts.Trusted, string(cert)) + + return nil } func (c *CloudInit) AddSSHKey(keys ...string) { diff --git a/cloudconfig/templates.go b/cloudconfig/templates.go index 0a4f203c..c68c88ef 100644 --- a/cloudconfig/templates.go +++ b/cloudconfig/templates.go @@ -52,7 +52,16 @@ function fail() { } sendStatus "downloading tools from {{ .DownloadURL }}" -curl -L -o "/home/runner/{{ .FileName }}" "{{ .DownloadURL }}" || fail "failed to download tools" + +TEMP_TOKEN="" + + + +if [ ! -z "{{ .TempDownloadToken }}" ]; then + TEMP_TOKEN="Authorization: Bearer {{ .TempDownloadToken }}" +fi + +curl -L -H "${TEMP_TOKEN}" -o "/home/runner/{{ .FileName }}" "{{ .DownloadURL }}" || fail "failed to download tools" mkdir -p /home/runner/actions-runner || fail "failed to create actions-runner folder" @@ -84,16 +93,17 @@ success "runner successfully installed" $AGENT_ID ` type InstallRunnerParams struct { - FileName string - DownloadURL string - RunnerUsername string - RunnerGroup string - RepoURL string - GithubToken string - RunnerName string - RunnerLabels string - CallbackURL string - CallbackToken string + FileName string + DownloadURL string + RunnerUsername string + RunnerGroup string + RepoURL string + GithubToken string + RunnerName string + RunnerLabels string + CallbackURL string + CallbackToken string + TempDownloadToken string } func InstallRunnerScript(params InstallRunnerParams) ([]byte, error) { diff --git a/cmd/garm-cli/cmd/credentials.go b/cmd/garm-cli/cmd/credentials.go index 4ecd8f47..a4986b8b 100644 --- a/cmd/garm-cli/cmd/credentials.go +++ b/cmd/garm-cli/cmd/credentials.go @@ -63,10 +63,10 @@ func init() { func formatGithubCredentials(creds []params.GithubCredentials) { t := table.NewWriter() - header := table.Row{"Name", "Description"} + header := table.Row{"Name", "Description", "Base URL", "API URL", "Upload URL"} t.AppendHeader(header) for _, val := range creds { - t.AppendRow(table.Row{val.Name, val.Description}) + t.AppendRow(table.Row{val.Name, val.Description, val.BaseURL, val.APIBaseURL, val.UploadBaseURL}) t.AppendSeparator() } fmt.Println(t.Render()) diff --git a/config/config.go b/config/config.go index dc72937f..8c76441e 100644 --- a/config/config.go +++ b/config/config.go @@ -18,7 +18,6 @@ import ( "crypto/tls" "crypto/x509" "fmt" - "io/ioutil" "log" "net" "os" @@ -66,7 +65,13 @@ const ( // of time and no new updates have been made to it's state, it will be removed. DefaultRunnerBootstrapTimeout = 20 + // DefaultGithubURL is the default URL where Github or Github Enterprise can be accessed GithubBaseURL = "https://github.com" + + // defaultBaseURL is the default URL for the github API + defaultBaseURL = "https://api.github.com/" + // uploadBaseURL is the default URL for guthub uploads + uploadBaseURL = "https://uploads.github.com/" ) var ( @@ -190,15 +195,69 @@ func (d *Default) Validate() error { // Github hold configuration options specific to interacting with github. // Currently that is just a OAuth2 personal token. type Github struct { - Name string `toml:"name" json:"name"` - Description string `toml:"description" json:"description"` - OAuth2Token string `toml:"oauth2_token" json:"oauth2-token"` + Name string `toml:"name" json:"name"` + Description string `toml:"description" json:"description"` + OAuth2Token string `toml:"oauth2_token" json:"oauth2-token"` + APIBaseURL string `toml:"api_base_url" json:"api-base-url"` + UploadBaseURL string `toml:"upload_base_url" json:"upload-base-url"` + BaseURL string `toml:"base_url" json:"base-url"` + // CACertBundlePath is the path on disk to a CA certificate bundle that + // can validate the endpoints defined above. Leave empty if not using a + // self signed certificate. + CACertBundlePath string `toml:"ca_cert_bundle" json:"ca-cert-bundle"` +} + +func (g *Github) APIEndpoint() string { + if g.APIBaseURL != "" { + return g.APIBaseURL + } + return defaultBaseURL +} + +func (g *Github) CACertBundle() ([]byte, error) { + if g.CACertBundlePath == "" { + // No CA bundle defined. + return nil, nil + } + if _, err := os.Stat(g.CACertBundlePath); err != nil { + return nil, errors.Wrap(err, "accessing CA bundle") + } + + contents, err := os.ReadFile(g.CACertBundlePath) + if err != nil { + return nil, errors.Wrap(err, "reading CA bundle") + } + + roots := x509.NewCertPool() + if ok := roots.AppendCertsFromPEM(contents); !ok { + return nil, fmt.Errorf("failed to parse CA cert bundle") + } + + return contents, nil +} + +func (g *Github) UploadEndpoint() string { + if g.UploadBaseURL == "" { + if g.APIBaseURL != "" { + return g.APIBaseURL + } + return uploadBaseURL + } + return g.UploadBaseURL +} + +func (g *Github) BaseEndpoint() string { + if g.BaseURL != "" { + return g.BaseURL + } + return GithubBaseURL } func (g *Github) Validate() error { if g.OAuth2Token == "" { return fmt.Errorf("missing github oauth2 token") } + return nil } @@ -372,7 +431,7 @@ func (t *TLSConfig) TLSConfig() (*tls.Config, error) { var roots *x509.CertPool if t.CACert != "" { - caCertPEM, err := ioutil.ReadFile(t.CACert) + caCertPEM, err := os.ReadFile(t.CACert) if err != nil { return nil, err } diff --git a/params/params.go b/params/params.go index 2f1ea43e..8f1f78ba 100644 --- a/params/params.go +++ b/params/params.go @@ -98,6 +98,8 @@ type BootstrapInstance struct { // provider supports it. SSHKeys []string `json:"ssh-keys"` + CACertBundle []byte `json:"ca-cert-bundle"` + OSArch config.OSArch `json:"arch"` Flavor string `json:"flavor"` Image string `json:"image"` @@ -141,6 +143,9 @@ type Internal struct { ControllerID string `json:"controller_id"` InstanceCallbackURL string `json:"instance_callback_url"` JWTSecret string `json:"jwt_secret"` + // GithubCredentialsDetails contains all info about the credentials, except the + // token, which is added above. + GithubCredentialsDetails GithubCredentials `json:"gh_creds_details"` } type Repository struct { @@ -186,8 +191,12 @@ type ControllerInfo struct { } type GithubCredentials struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + BaseURL string `json:"base_url"` + APIBaseURL string `json:"api_base_url"` + UploadBaseURL string `json:"upload_base_url"` + CABundle []byte `json:"ca_bundle,omitempty"` } type Provider struct { diff --git a/runner/common/pool.go b/runner/common/pool.go index 5da97e95..ca4e31e2 100644 --- a/runner/common/pool.go +++ b/runner/common/pool.go @@ -27,7 +27,9 @@ const ( PoolConsilitationInterval = 5 * time.Second PoolReapTimeoutInterval = 5 * time.Minute - PoolToolUpdateInterval = 3 * time.Hour + // Temporary tools download token is valid for 1 hour by default. + // Set this to less than an hour so as not to run into 401 errors. + PoolToolUpdateInterval = 50 * time.Minute ) type PoolManager interface { diff --git a/runner/pool/organization.go b/runner/pool/organization.go index 546d28b2..4044e6e4 100644 --- a/runner/pool/organization.go +++ b/runner/pool/organization.go @@ -20,7 +20,6 @@ import ( "strings" "sync" - "garm/config" dbCommon "garm/database/common" runnerErrors "garm/errors" "garm/params" @@ -35,7 +34,7 @@ import ( var _ poolHelper = &organization{} func NewOrganizationPoolManager(ctx context.Context, cfg params.Organization, cfgInternal params.Internal, providers map[string]common.Provider, store dbCommon.Store) (common.PoolManager, error) { - ghc, err := util.GithubClient(ctx, cfgInternal.OAuth2Token) + ghc, err := util.GithubClient(ctx, cfgInternal.OAuth2Token, cfgInternal.GithubCredentialsDetails) if err != nil { return nil, errors.Wrap(err, "getting github client") } @@ -57,6 +56,7 @@ func NewOrganizationPoolManager(ctx context.Context, cfg params.Organization, cf quit: make(chan struct{}), done: make(chan struct{}), helper: helper, + credsDetails: cfgInternal.GithubCredentialsDetails, } return repo, nil } @@ -89,7 +89,7 @@ func (r *organization) UpdateState(param params.UpdatePoolStateParams) error { r.cfg.WebhookSecret = param.WebhookSecret - ghc, err := util.GithubClient(r.ctx, r.GetGithubToken()) + ghc, err := util.GithubClient(r.ctx, r.GetGithubToken(), r.cfgInternal.GithubCredentialsDetails) if err != nil { return errors.Wrap(err, "getting github client") } @@ -138,7 +138,7 @@ func (r *organization) ListPools() ([]params.Pool, error) { } func (r *organization) GithubURL() string { - return fmt.Sprintf("%s/%s", config.GithubBaseURL, r.cfg.Name) + return fmt.Sprintf("%s/%s", r.cfgInternal.GithubCredentialsDetails.BaseURL, r.cfg.Name) } func (r *organization) JwtToken() string { diff --git a/runner/pool/pool.go b/runner/pool/pool.go index 92c9f00c..160da187 100644 --- a/runner/pool/pool.go +++ b/runner/pool/pool.go @@ -58,7 +58,8 @@ type basePool struct { quit chan struct{} done chan struct{} - helper poolHelper + helper poolHelper + credsDetails params.GithubCredentials mux sync.Mutex } @@ -454,6 +455,7 @@ func (r *basePool) addInstanceToProvider(instance params.Instance) error { Image: pool.Image, Labels: labels, PoolID: instance.PoolID, + CACertBundle: r.credsDetails.CABundle, } var instanceIDToDelete string diff --git a/runner/pool/repository.go b/runner/pool/repository.go index 6416efe5..8f0ed6d1 100644 --- a/runner/pool/repository.go +++ b/runner/pool/repository.go @@ -20,7 +20,6 @@ import ( "strings" "sync" - "garm/config" dbCommon "garm/database/common" runnerErrors "garm/errors" "garm/params" @@ -35,7 +34,7 @@ import ( var _ poolHelper = &repository{} func NewRepositoryPoolManager(ctx context.Context, cfg params.Repository, cfgInternal params.Internal, providers map[string]common.Provider, store dbCommon.Store) (common.PoolManager, error) { - ghc, err := util.GithubClient(ctx, cfgInternal.OAuth2Token) + ghc, err := util.GithubClient(ctx, cfgInternal.OAuth2Token, cfgInternal.GithubCredentialsDetails) if err != nil { return nil, errors.Wrap(err, "getting github client") } @@ -57,6 +56,7 @@ func NewRepositoryPoolManager(ctx context.Context, cfg params.Repository, cfgInt quit: make(chan struct{}), done: make(chan struct{}), helper: helper, + credsDetails: cfgInternal.GithubCredentialsDetails, } return repo, nil } @@ -91,7 +91,7 @@ func (r *repository) UpdateState(param params.UpdatePoolStateParams) error { r.cfg.WebhookSecret = param.WebhookSecret - ghc, err := util.GithubClient(r.ctx, r.GetGithubToken()) + ghc, err := util.GithubClient(r.ctx, r.GetGithubToken(), r.cfgInternal.GithubCredentialsDetails) if err != nil { return errors.Wrap(err, "getting github client") } @@ -140,7 +140,7 @@ func (r *repository) ListPools() ([]params.Pool, error) { } func (r *repository) GithubURL() string { - return fmt.Sprintf("%s/%s/%s", config.GithubBaseURL, r.cfg.Owner, r.cfg.Name) + return fmt.Sprintf("%s/%s/%s", r.cfgInternal.GithubCredentialsDetails.BaseURL, r.cfg.Owner, r.cfg.Name) } func (r *repository) JwtToken() string { diff --git a/runner/runner.go b/runner/runner.go index 63664b7c..808edefd 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -190,11 +190,24 @@ func (p *poolManagerCtrl) getInternalConfig(credsName string) (params.Internal, return params.Internal{}, runnerErrors.NewBadRequestError("invalid credential name (%s)", credsName) } + caBundle, err := creds.CACertBundle() + if err != nil { + return params.Internal{}, fmt.Errorf("fetching CA bundle for creds: %w", err) + } + return params.Internal{ OAuth2Token: creds.OAuth2Token, ControllerID: p.controllerID, InstanceCallbackURL: p.config.Default.CallbackURL, JWTSecret: p.config.JWTAuth.Secret, + GithubCredentialsDetails: params.GithubCredentials{ + Name: creds.Name, + Description: creds.Description, + BaseURL: creds.BaseURL, + APIBaseURL: creds.APIBaseURL, + UploadBaseURL: creds.UploadBaseURL, + CABundle: caBundle, + }, }, nil } @@ -219,8 +232,11 @@ func (r *Runner) ListCredentials(ctx context.Context) ([]params.GithubCredential for _, val := range r.config.Github { ret = append(ret, params.GithubCredentials{ - Name: val.Name, - Description: val.Description, + Name: val.Name, + Description: val.Description, + BaseURL: val.BaseEndpoint(), + APIBaseURL: val.APIEndpoint(), + UploadBaseURL: val.UploadEndpoint(), }) } return ret, nil diff --git a/util/util.go b/util/util.go index 993beace..f2ee210b 100644 --- a/util/util.go +++ b/util/util.go @@ -19,10 +19,13 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/tls" + "crypto/x509" "encoding/base64" "fmt" "io" "io/ioutil" + "net/http" "os" "path" "regexp" @@ -160,14 +163,33 @@ func OSToOSType(os string) (config.OSType, error) { return osType, nil } -func GithubClient(ctx context.Context, token string) (common.GithubClient, error) { +func GithubClient(ctx context.Context, token string, credsDetails params.GithubCredentials) (common.GithubClient, error) { + var roots *x509.CertPool + if credsDetails.CABundle != nil && len(credsDetails.CABundle) > 0 { + roots = x509.NewCertPool() + ok := roots.AppendCertsFromPEM(credsDetails.CABundle) + if !ok { + return nil, fmt.Errorf("failed to parse CA cert") + } + } + httpTransport := &http.Transport{ + TLSClientConfig: &tls.Config{ + ClientCAs: roots, + }, + } + httpClient := &http.Client{Transport: httpTransport} + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, ) - tc := oauth2.NewClient(ctx, ts) - ghClient := github.NewClient(tc) + // ghClient := github.NewClient(tc) + ghClient, err := github.NewEnterpriseClient(credsDetails.APIBaseURL, credsDetails.UploadBaseURL, tc) + if err != nil { + return nil, errors.Wrap(err, "fetching github client") + } return ghClient.Actions, nil } @@ -176,16 +198,17 @@ func GetCloudConfig(bootstrapParams params.BootstrapInstance, tools github.Runne cloudCfg := cloudconfig.NewDefaultCloudInitConfig() installRunnerParams := cloudconfig.InstallRunnerParams{ - FileName: *tools.Filename, - DownloadURL: *tools.DownloadURL, - GithubToken: bootstrapParams.GithubRunnerAccessToken, - RunnerUsername: config.DefaultUser, - RunnerGroup: config.DefaultUser, - RepoURL: bootstrapParams.RepoURL, - RunnerName: runnerName, - RunnerLabels: strings.Join(bootstrapParams.Labels, ","), - CallbackURL: bootstrapParams.CallbackURL, - CallbackToken: bootstrapParams.InstanceToken, + FileName: *tools.Filename, + DownloadURL: *tools.DownloadURL, + TempDownloadToken: *tools.TempDownloadToken, + GithubToken: bootstrapParams.GithubRunnerAccessToken, + RunnerUsername: config.DefaultUser, + RunnerGroup: config.DefaultUser, + RepoURL: bootstrapParams.RepoURL, + RunnerName: runnerName, + RunnerLabels: strings.Join(bootstrapParams.Labels, ","), + CallbackURL: bootstrapParams.CallbackURL, + CallbackToken: bootstrapParams.InstanceToken, } installScript, err := cloudconfig.InstallRunnerScript(installRunnerParams) @@ -198,6 +221,12 @@ func GetCloudConfig(bootstrapParams params.BootstrapInstance, tools github.Runne cloudCfg.AddRunCmd("/install_runner.sh") cloudCfg.AddRunCmd("rm -f /install_runner.sh") + if bootstrapParams.CACertBundle != nil && len(bootstrapParams.CACertBundle) > 0 { + if err := cloudCfg.AddCACert(bootstrapParams.CACertBundle); err != nil { + return "", errors.Wrap(err, "adding CA cert bundle") + } + } + asStr, err := cloudCfg.Serialize() if err != nil { return "", errors.Wrap(err, "creating cloud config")