diff --git a/.dredd/hooks/helpers.go b/.dredd/hooks/helpers.go index e4e83e622f..366c5a7004 100644 --- a/.dredd/hooks/helpers.go +++ b/.dredd/hooks/helpers.go @@ -314,6 +314,7 @@ func addIntegrationMatcher() *db.IntegrationMatcher { func addRunner() *db.Runner { runner, err := store.CreateRunner(db.Runner{ + Token: db.GenerateRunnerToken(), ProjectID: &userProject.ID, Name: "ITRN-" + getUUID(), Active: true, @@ -329,6 +330,7 @@ func addRunner() *db.Runner { func addGlobalRunner() *db.Runner { runner, err := store.CreateRunner(db.Runner{ + Token: db.GenerateRunnerToken(), ProjectID: nil, Name: "ITGRN-" + getUUID(), Active: true, diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 2f06bd070e..18ee235a65 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -39,7 +39,7 @@ jobs: - name: Add PRO implementation run: | - git clone -b main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl + git clone -b feat/main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl go work init . ./pro_impl - name: Run build diff --git a/.github/workflows/pro_selfhosted_beta.yml b/.github/workflows/pro_selfhosted_beta.yml index fde9d20245..859cf34f35 100644 --- a/.github/workflows/pro_selfhosted_beta.yml +++ b/.github/workflows/pro_selfhosted_beta.yml @@ -1,4 +1,4 @@ -name: Pro Self-Hosted Release +name: Pro Self-Hosted Beta 'on': push: @@ -39,7 +39,7 @@ jobs: - name: Add PRO implementation run: | - git clone -b main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl + git clone -b feat/main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl go work init . ./pro_impl - name: Install deps diff --git a/.github/workflows/pro_selfhosted_release.yml b/.github/workflows/pro_selfhosted_release.yml index 08ff52cc37..207d34b7ce 100644 --- a/.github/workflows/pro_selfhosted_release.yml +++ b/.github/workflows/pro_selfhosted_release.yml @@ -38,7 +38,7 @@ jobs: - name: Add PRO implementation run: | - git clone -b main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl + git clone -b feat/main https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl go work init . ./pro_impl - name: Install deps diff --git a/api-docs.yml b/api-docs.yml index abc9c25305..dbc59a0263 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -1204,6 +1204,9 @@ definitions: type: string format: date-time x-nullable: true + registered: + type: boolean + description: Whether the runner has been registered (has an auth token). RunnerRequest: type: object @@ -1223,6 +1226,14 @@ definitions: type: array items: type: string + registered: + type: boolean + description: > + Defaults to true. When false, the runner is created with no credentials + at all (no auth token and no registration token) and stays inactive. A + one-time registration token must then be generated for it (see the + registration-token endpoint) and used to register the runner. Useful for + tools like Terraform that create the runner up front. RunnerWithToken: allOf: @@ -1242,6 +1253,20 @@ definitions: active: type: boolean + RunnerRegistrationToken: + type: object + properties: + registration_token: + type: string + description: One-time, short-lived token used to register the runner. + runner_id: + type: integer + readOnly: true + project_id: + type: integer + description: Owning project. Null for global runners. + x-nullable: true + RunnerTag: type: object properties: @@ -3320,6 +3345,22 @@ paths: 400: description: Invalid request body + /project/{project_id}/runners/{runner_id}/registration-token: + parameters: + - $ref: "#/parameters/project_id" + - $ref: "#/parameters/runner_id" + post: + tags: + - runner + summary: Regenerate the one-time registration token of an unregistered project runner + responses: + 200: + description: New registration token + schema: + $ref: "#/definitions/RunnerRegistrationToken" + 400: + description: Runner is already registered + /project/{project_id}/runners/{runner_id}/cache: parameters: - $ref: "#/parameters/project_id" @@ -3432,6 +3473,21 @@ paths: 400: description: Invalid request body + /runners/{runner_id}/registration-token: + parameters: + - $ref: "#/parameters/global_runner_id" + post: + tags: + - runner + summary: Regenerate the one-time registration token of an unregistered global runner + responses: + 200: + description: New registration token + schema: + $ref: "#/definitions/RunnerRegistrationToken" + 400: + description: Runner is already registered + /runners/{runner_id}/cache: parameters: - $ref: "#/parameters/global_runner_id" diff --git a/api/api_test.go b/api/api_test.go index 19bc170117..2bd8b4f547 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -29,6 +29,7 @@ func TestApiPing(t *testing.T) { nil, nil, nil, + nil, ) r.ServeHTTP(rr, req) diff --git a/api/router.go b/api/router.go index 54b9687464..1399073067 100644 --- a/api/router.go +++ b/api/router.go @@ -93,6 +93,7 @@ func Route( accessKeyService server.AccessKeyService, environmentService server.EnvironmentService, subscriptionService pro_interfaces.SubscriptionService, + runnerService server.RunnerService, ) *mux.Router { projectController := &projects.ProjectController{ProjectService: projectService} @@ -108,7 +109,8 @@ func Route( userController := NewUserController(subscriptionService) usersController := NewUsersController(subscriptionService) subscriptionController := proApi.NewSubscriptionController(store, store, store, terraformStore) - projectRunnerController := proProjects.NewProjectRunnerController(subscriptionService) + projectRunnerController := proProjects.NewProjectRunnerController(subscriptionService, runnerService) + globalRunnerController := NewGlobalRunnerController(runnerService) taskController := projects.NewTaskController(ansibleTaskRepo) rolesController := proApi.NewRolesController(store) templateController := projects.NewTemplateController(store, store) @@ -217,9 +219,9 @@ func Route( adminAPI.Path("/cluster/tasks").HandlerFunc(getClusterTasks).Methods("GET", "HEAD") adminAPI.Path("/cluster/tasks").HandlerFunc(clearClusterTasks).Methods("DELETE") - adminAPI.Path("/runners").HandlerFunc(getAllRunners).Methods("GET", "HEAD") - adminAPI.Path("/runners").HandlerFunc(addGlobalRunner).Methods("POST", "HEAD") - adminAPI.Path("/runner_tags").HandlerFunc(getGlobalRunnerTags).Methods("GET", "HEAD") + adminAPI.Path("/runners").HandlerFunc(globalRunnerController.GetRunners).Methods("GET", "HEAD") + adminAPI.Path("/runners").HandlerFunc(globalRunnerController.AddRunner).Methods("POST", "HEAD") + adminAPI.Path("/runner_tags").HandlerFunc(globalRunnerController.GetRunnerTags).Methods("GET", "HEAD") adminAPI.Path("/roles").HandlerFunc(rolesController.GetRoles).Methods("GET", "HEAD") adminAPI.Path("/roles").HandlerFunc(rolesController.AddRole).Methods("POST", "HEAD") @@ -227,12 +229,13 @@ func Route( adminAPI.Path("/cache").HandlerFunc(clearCache).Methods("DELETE", "HEAD") globalRunnersAPI := adminAPI.PathPrefix("/runners").Subrouter() - globalRunnersAPI.Use(globalRunnerMiddleware) - globalRunnersAPI.Path("/{runner_id}").HandlerFunc(getGlobalRunner).Methods("GET", "HEAD") - globalRunnersAPI.Path("/{runner_id}").HandlerFunc(updateGlobalRunner).Methods("PUT", "POST") - globalRunnersAPI.Path("/{runner_id}/active").HandlerFunc(setGlobalRunnerActive).Methods("POST") - globalRunnersAPI.Path("/{runner_id}").HandlerFunc(deleteGlobalRunner).Methods("DELETE") - globalRunnersAPI.Path("/{runner_id}/cache").HandlerFunc(clearGlobalRunnerCache).Methods("DELETE") + globalRunnersAPI.Use(globalRunnerController.RunnerMiddleware) + globalRunnersAPI.Path("/{runner_id}").HandlerFunc(globalRunnerController.GetRunner).Methods("GET", "HEAD") + globalRunnersAPI.Path("/{runner_id}").HandlerFunc(globalRunnerController.UpdateRunner).Methods("PUT", "POST") + globalRunnersAPI.Path("/{runner_id}/active").HandlerFunc(globalRunnerController.SetRunnerActive).Methods("POST") + globalRunnersAPI.Path("/{runner_id}/registration-token").HandlerFunc(globalRunnerController.RegenerateRegistrationToken).Methods("POST") + globalRunnersAPI.Path("/{runner_id}").HandlerFunc(globalRunnerController.DeleteRunner).Methods("DELETE") + globalRunnersAPI.Path("/{runner_id}/cache").HandlerFunc(globalRunnerController.ClearRunnerCache).Methods("DELETE") rolesAPI := adminAPI.PathPrefix("/roles").Subrouter() rolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.GetGlobalRole).Methods("GET", "HEAD") @@ -342,6 +345,7 @@ func Route( projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.GetRunner).Methods("GET", "HEAD") projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.UpdateRunner).Methods("PUT", "POST") projectRunnersAPI.Path("/{runner_id}/active").HandlerFunc(projectRunnerController.SetRunnerActive).Methods("POST") + projectRunnersAPI.Path("/{runner_id}/registration-token").HandlerFunc(projectRunnerController.RegenerateRegistrationToken).Methods("POST") projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.DeleteRunner).Methods("DELETE") projectRunnersAPI.Path("/{runner_id}/cache").HandlerFunc(projectRunnerController.ClearRunnerCache).Methods("DELETE") diff --git a/api/runners.go b/api/runners.go index c3da13e2be..958c41398f 100644 --- a/api/runners.go +++ b/api/runners.go @@ -1,17 +1,32 @@ package api import ( - "bufio" - "bytes" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" - "github.com/semaphoreui/semaphore/util" + "github.com/semaphoreui/semaphore/services/server" log "github.com/sirupsen/logrus" ) -func getAllRunners(w http.ResponseWriter, r *http.Request) { +type runnerWithToken struct { + db.Runner + Token string `json:"token"` + PrivateKey string `json:"private_key"` +} + +// GlobalRunnerController handles CRUD for global (non-project) runners. +type GlobalRunnerController struct { + runnerService server.RunnerService +} + +func NewGlobalRunnerController(runnerService server.RunnerService) *GlobalRunnerController { + return &GlobalRunnerController{ + runnerService: runnerService, + } +} + +func (c *GlobalRunnerController) GetRunners(w http.ResponseWriter, r *http.Request) { runners, err := helpers.Store(r).GetAllRunners(false, false, db.RunnerFilterIgnoreTags, nil) if err != nil { @@ -22,16 +37,14 @@ func getAllRunners(w http.ResponseWriter, r *http.Request) { result = append(result, runners...) - helpers.WriteJSON(w, http.StatusOK, result) -} + for i := range result { + result[i].Registered = result[i].IsRegistered() + } -type runnerWithToken struct { - db.Runner - Token string `json:"token"` - PrivateKey string `json:"private_key"` + helpers.WriteJSON(w, http.StatusOK, result) } -func addGlobalRunner(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) AddRunner(w http.ResponseWriter, r *http.Request) { var runner db.Runner if !helpers.Bind(w, r, &runner) { return @@ -39,30 +52,7 @@ func addGlobalRunner(w http.ResponseWriter, r *http.Request) { runner.ProjectID = nil - var privateKey []byte - - if runner.PublicKey == nil { - var b bytes.Buffer - privateKeyFile := bufio.NewWriter(&b) - - publicKey, err := util.GeneratePrivateKey(privateKeyFile) - if err != nil { - helpers.WriteError(w, err) - return - } - - err = privateKeyFile.Flush() - if err != nil { - helpers.WriteError(w, err) - return - } - - privateKey = b.Bytes() - - runner.PublicKey = &publicKey - } - - newRunner, err := helpers.Store(r).CreateRunner(runner) + newRunner, privateKey, err := c.runnerService.CreateRunner(runner) if err != nil { log.Warn("Runner is not created: " + err.Error()) @@ -73,11 +63,11 @@ func addGlobalRunner(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusCreated, runnerWithToken{ Runner: newRunner, Token: newRunner.Token, - PrivateKey: string(privateKey), + PrivateKey: privateKey, }) } -func globalRunnerMiddleware(next http.Handler) http.Handler { +func (c *GlobalRunnerController) RunnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { runnerID, err := helpers.GetIntParam("runner_id", w, r) @@ -104,13 +94,15 @@ func globalRunnerMiddleware(next http.Handler) http.Handler { }) } -func getGlobalRunner(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) GetRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) + runner.Registered = runner.IsRegistered() + helpers.WriteJSON(w, http.StatusOK, runner) } -func updateGlobalRunner(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) UpdateRunner(w http.ResponseWriter, r *http.Request) { oldRunner := helpers.GetFromContext(r, "runner").(*db.Runner) var runner db.Runner @@ -133,7 +125,7 @@ func updateGlobalRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func clearGlobalRunnerCache(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) ClearRunnerCache(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) @@ -148,7 +140,7 @@ func clearGlobalRunnerCache(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func deleteGlobalRunner(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) DeleteRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) @@ -163,7 +155,23 @@ func deleteGlobalRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func getGlobalRunnerTags(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) RegenerateRegistrationToken(w http.ResponseWriter, r *http.Request) { + runner := helpers.GetFromContext(r, "runner").(*db.Runner) + + token, err := c.runnerService.RegenerateRegistrationToken(*runner) + + if err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) + return + } + + helpers.WriteJSON(w, http.StatusOK, map[string]any{ + "registration_token": token, + "runner_id": runner.ID, + }) +} + +func (c *GlobalRunnerController) GetRunnerTags(w http.ResponseWriter, r *http.Request) { tags, err := helpers.Store(r).GetGlobalRunnerTags() if err != nil { @@ -174,7 +182,7 @@ func getGlobalRunnerTags(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, tags) } -func setGlobalRunnerActive(w http.ResponseWriter, r *http.Request) { +func (c *GlobalRunnerController) SetRunnerActive(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) diff --git a/api/runners/runners.go b/api/runners/runners.go index 6bfa4912e5..88ab5b43a0 100644 --- a/api/runners/runners.go +++ b/api/runners/runners.go @@ -344,34 +344,56 @@ func RegisterRunner(w http.ResponseWriter, r *http.Request) { return } - if util.Config.RunnerRegistrationToken == "" || register.RegistrationToken != util.Config.RunnerRegistrationToken { + if register.RegistrationToken == "" { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid registration token", }) return } - runner, err := helpers.Store(r).CreateRunner(db.Runner{ - Webhook: register.Webhook, - Name: register.Name, - Tags: register.Tags, - MaxParallelTasks: register.MaxParallelTasks, - Active: register.Enabled, - PublicKey: register.PublicKey, - ProjectID: register.ProjectID, - }) + store := helpers.Store(r) + + var runner db.Runner + var err error + + if util.Config.RunnerRegistrationToken != "" && register.RegistrationToken == util.Config.RunnerRegistrationToken { + // The shared, global registration token creates a brand-new runner. + runner, err = store.CreateRunner(db.Runner{ + Token: db.GenerateRunnerToken(), + Webhook: register.Webhook, + Name: register.Name, + Tags: register.Tags, + MaxParallelTasks: register.MaxParallelTasks, + Active: register.Enabled, + PublicKey: register.PublicKey, + ProjectID: register.ProjectID, + }) - if err != nil { + if err != nil { + log.WithError(err).WithFields(log.Fields{ + "context": "runner", + }).Error("Can't create runner") - log.WithError(err).WithFields(log.Fields{ - "runner_id": runner.ID, - "context": "runner", - }).Error("Can't create runner") + helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Unexpected error", + }) + return + } + } else { + // Otherwise the value is a one-time registration token issued for a specific + // unregistered runner. The global token cannot be used to register it. + runner, err = store.RegisterRunner( + server.HashRunnerRegistrationToken(register.RegistrationToken), + register.PublicKey, + register.Enabled, + ) - helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Unexpected error", - }) - return + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Invalid registration token", + }) + return + } } log.WithFields(log.Fields{ diff --git a/cli/cmd/root.go b/cli/cmd/root.go index dbbafd97ef..1326bd66fe 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -98,6 +98,7 @@ func runService() { secretStorageService := server.NewSecretStorageService(store, store, accessKeyService, encryptionService) secretStorageSyncScheduler := server.NewSecretStorageSyncScheduler(store, secretStorageService) environmentService := server.NewEnvironmentService(store, encryptionService) + runnerService := server.NewRunnerService(store) subscriptionService := proServer.NewSubscriptionService(store, store, store, terraformStore) logWriteService := proServer.NewLogWriteService() @@ -208,6 +209,7 @@ func runService() { accessKeyService, environmentService, subscriptionService, + runnerService, ) route.Use(func(next http.Handler) http.Handler { diff --git a/db/Migration.go b/db/Migration.go index 03a67e118c..75d1902ee0 100644 --- a/db/Migration.go +++ b/db/Migration.go @@ -126,6 +126,7 @@ func GetMigrations(dialect string) []Migration { {Version: "2.18.2"}, {Version: "2.18.4"}, {Version: "2.18.5"}, + {Version: "2.18.7"}, } return append(initScripts, commonScripts...) diff --git a/db/Runner.go b/db/Runner.go index c32f2a8c5b..4438c8863b 100644 --- a/db/Runner.go +++ b/db/Runner.go @@ -1,8 +1,11 @@ package db import ( + "encoding/base64" "slices" "time" + + "github.com/gorilla/securecookie" ) type RunnerState string @@ -30,6 +33,28 @@ type Runner struct { CleaningRequested *time.Time `db:"cleaning_requested" json:"cleaning_requested"` PublicKey *string `db:"public_key" json:"-"` + + // Registered is a transient flag (never persisted) used at creation time to + // request a runner without an auth token. Such a runner gets a one-time, + // short-lived registration token instead and must be registered later by + // presenting that token to `semaphore runner register`. + Registered bool `db:"-" json:"registered"` + + // RegistrationTokenHash is the stored SHA-256 hash of the one-time registration + // token (the plaintext is never persisted). It is issued on demand via + // RegenerateRegistrationToken, not at creation time. + RegistrationTokenHash *string `db:"registration_token" json:"-"` + RegistrationTokenExpiresAt *time.Time `db:"registration_token_expires_at" json:"-"` +} + +// IsRegistered reports whether the runner has been registered (has a token). +func (r Runner) IsRegistered() bool { + return r.Token != "" +} + +// GenerateRunnerToken creates a new runner authentication token. +func GenerateRunnerToken() string { + return base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) } // HasTag reports whether the runner is tagged with the given tag. diff --git a/db/Store.go b/db/Store.go index 566f399978..143c3e0800 100644 --- a/db/Store.go +++ b/db/Store.go @@ -458,6 +458,16 @@ type RunnerManager interface { DeleteGlobalRunner(runnerID int) error UpdateRunner(runner Runner) error CreateRunner(runner Runner) (Runner, error) + // RegisterRunner finalizes a previously created tokenless ("unregistered") + // runner, looked up by the hash of the one-time registration token it presents: + // it generates the runner's auth token, stores its public key, activates it and + // clears the registration token. It fails if no matching runner exists, the + // token has expired, or the runner is already registered. + RegisterRunner(registrationTokenHash string, publicKey *string, active bool) (Runner, error) + // ResetRunnerRegistration moves a runner (back) to the unregistered state: it + // clears the auth token, public key and active flag, and stores a new one-time + // registration token hash and its expiry. + ResetRunnerRegistration(runnerID int, registrationTokenHash string, expiresAt time.Time) error TouchRunner(runner Runner) (err error) ClearRunnerCache(runner Runner) (err error) GetRunnerTags(projectID int) ([]RunnerTag, error) diff --git a/db/bolt/global_runner.go b/db/bolt/global_runner.go index f79a38927b..475d8bee84 100644 --- a/db/bolt/global_runner.go +++ b/db/bolt/global_runner.go @@ -1,9 +1,9 @@ package bolt import ( - "encoding/base64" + "fmt" + "time" - "github.com/gorilla/securecookie" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "go.etcd.io/bbolt" @@ -130,9 +130,25 @@ func (d *BoltDb) UpdateRunner(runner db.Runner) (err error) { }) } -func (d *BoltDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { - runner.Token = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) +func (d *BoltDb) ResetRunnerRegistration(runnerID int, registrationTokenHash string, expiresAt time.Time) (err error) { + return d.db.Update(func(tx *bbolt.Tx) error { + var runner db.Runner + + e := d.getObjectTx(tx, 0, db.GlobalRunnerProps, intObjectID(runnerID), &runner) + if e != nil { + return e + } + + runner.Token = "" + runner.PublicKey = nil + runner.RegistrationTokenHash = ®istrationTokenHash + runner.RegistrationTokenExpiresAt = &expiresAt + return d.updateObjectTx(tx, 0, db.GlobalRunnerProps, runner) + }) +} + +func (d *BoltDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { res, err := d.createObject(0, db.GlobalRunnerProps, runner) if err != nil { @@ -141,3 +157,42 @@ func (d *BoltDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) newRunner = res.(db.Runner) return } + +func (d *BoltDb) RegisterRunner(registrationTokenHash string, publicKey *string, active bool) (runner db.Runner, err error) { + err = d.db.Update(func(tx *bbolt.Tx) error { + runners := make([]db.Runner, 0) + + e := d.getObjectsTx(tx, 0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(i any) bool { + r := i.(db.Runner) + return r.RegistrationTokenHash != nil && *r.RegistrationTokenHash == registrationTokenHash + }, &runners) + + if e != nil { + return e + } + + if len(runners) == 0 { + return db.ErrNotFound + } + + runner = runners[0] + + if runner.IsRegistered() { + return fmt.Errorf("runner is already registered") + } + + if runner.RegistrationTokenExpiresAt == nil || !runner.RegistrationTokenExpiresAt.After(tz.Now()) { + return fmt.Errorf("registration token expired") + } + + runner.Token = db.GenerateRunnerToken() + runner.PublicKey = publicKey + runner.Active = active + runner.RegistrationTokenHash = nil + runner.RegistrationTokenExpiresAt = nil + + return d.updateObjectTx(tx, 0, db.GlobalRunnerProps, runner) + }) + + return +} diff --git a/db/bolt/global_runner_test.go b/db/bolt/global_runner_test.go index d58e210d5e..dbf6b67011 100644 --- a/db/bolt/global_runner_test.go +++ b/db/bolt/global_runner_test.go @@ -2,16 +2,20 @@ package bolt import ( "testing" + "time" "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/tz" + "github.com/semaphoreui/semaphore/services/server" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_GetRunnerByToken_ReturnsGlobalRunnerWhenTokenExists(t *testing.T) { store := CreateTestStore() - testRunner, err := store.CreateRunner(db.Runner{}) - assert.NoError(t, err) + testRunner, err := store.CreateRunner(db.Runner{Token: db.GenerateRunnerToken()}) + require.NoError(t, err) _, err = store.GetRunnerByToken(testRunner.Token) assert.NoError(t, err) @@ -21,10 +25,10 @@ func Test_GetRunnerByToken_ReturnsRunnerWhenTokenExists(t *testing.T) { store := CreateTestStore() project, err := store.CreateProject(db.Project{}) - assert.NoError(t, err) + require.NoError(t, err) - testRunner, err := store.CreateRunner(db.Runner{ProjectID: &project.ID}) - assert.NoError(t, err) + testRunner, err := store.CreateRunner(db.Runner{ProjectID: &project.ID, Token: db.GenerateRunnerToken()}) + require.NoError(t, err) _, err = store.GetRunnerByToken(testRunner.Token) assert.NoError(t, err) @@ -42,3 +46,76 @@ func Test_GetGlobalRunner_ReturnsErrorWhenTryingGetProjectRunner(t *testing.T) { _, err = store.GetGlobalRunner(testRunner.ID) assert.ErrorIs(t, err, db.ErrNotFound) } + +// CreateRunner is a pure persister: it stores the registration-token hash and +// expiry it is given without altering them. Credential generation itself lives +// in the RunnerService. +func Test_CreateRunner_PersistsRegistrationTokenFields(t *testing.T) { + store := CreateTestStore() + + hash := server.HashRunnerRegistrationToken("plaintext-token") + expiresAt := tz.Now().Add(time.Hour) + + testRunner, err := store.CreateRunner(db.Runner{ + RegistrationTokenHash: &hash, + RegistrationTokenExpiresAt: &expiresAt, + }) + require.NoError(t, err) + assert.Empty(t, testRunner.Token) + + stored, err := store.GetGlobalRunner(testRunner.ID) + require.NoError(t, err) + assert.Empty(t, stored.Token) + require.NotNil(t, stored.RegistrationTokenHash) + assert.Equal(t, hash, *stored.RegistrationTokenHash) + assert.NotNil(t, stored.RegistrationTokenExpiresAt) +} + +func Test_RegisterRunner_SetsTokenAndActivates(t *testing.T) { + store := CreateTestStore() + + hash := server.HashRunnerRegistrationToken("sms_test") + expiresAt := tz.Now().Add(time.Hour) + testRunner, err := store.CreateRunner(db.Runner{ + RegistrationTokenHash: &hash, + RegistrationTokenExpiresAt: &expiresAt, + }) + require.NoError(t, err) + + publicKey := "test-public-key" + registered, err := store.RegisterRunner(hash, &publicKey, true) + require.NoError(t, err) + assert.NotEmpty(t, registered.Token) + assert.True(t, registered.Active) + assert.Equal(t, publicKey, *registered.PublicKey) + + stored, err := store.GetGlobalRunner(testRunner.ID) + require.NoError(t, err) + assert.Equal(t, registered.Token, stored.Token) + assert.True(t, stored.Active) + // The one-time registration token is cleared after use. + assert.Nil(t, stored.RegistrationTokenHash) + assert.Nil(t, stored.RegistrationTokenExpiresAt) +} + +func Test_RegisterRunner_FailsWhenTokenUnknown(t *testing.T) { + store := CreateTestStore() + + _, err := store.RegisterRunner(server.HashRunnerRegistrationToken("sms_nope"), nil, true) + assert.ErrorIs(t, err, db.ErrNotFound) +} + +func Test_RegisterRunner_FailsWhenExpired(t *testing.T) { + store := CreateTestStore() + + hash := server.HashRunnerRegistrationToken("sms_expired") + expiresAt := tz.Now().Add(-time.Hour) + _, err := store.CreateRunner(db.Runner{ + RegistrationTokenHash: &hash, + RegistrationTokenExpiresAt: &expiresAt, + }) + require.NoError(t, err) + + _, err = store.RegisterRunner(hash, nil, true) + assert.Error(t, err) +} diff --git a/db/sql/global_runner.go b/db/sql/global_runner.go index 640dbe2b06..ee61066dd8 100644 --- a/db/sql/global_runner.go +++ b/db/sql/global_runner.go @@ -1,11 +1,10 @@ package sql import ( - "encoding/base64" "fmt" + "time" "github.com/Masterminds/squirrel" - "github.com/gorilla/securecookie" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" ) @@ -172,20 +171,80 @@ func (d *SqlDb) UpdateRunner(runner db.Runner) (err error) { return } -func (d *SqlDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { - token := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) +func (d *SqlDb) RegisterRunner(registrationTokenHash string, publicKey *string, active bool) (runner db.Runner, err error) { + runners := make([]db.Runner, 0) + + err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(builder squirrel.SelectBuilder) squirrel.SelectBuilder { + return builder.Where("registration_token=?", registrationTokenHash) + }, &runners) + + if err != nil { + return + } + + if len(runners) == 0 { + err = db.ErrNotFound + return + } + + runner = runners[0] + + if runner.IsRegistered() { + err = fmt.Errorf("runner is already registered") + return + } + + if runner.RegistrationTokenExpiresAt == nil || !runner.RegistrationTokenExpiresAt.After(tz.Now()) { + err = fmt.Errorf("registration token expired") + return + } + + token := db.GenerateRunnerToken() + + _, err = d.exec( + "update `runner` set `token`=?, `public_key`=?, `active`=?, `registration_token`=null, `registration_token_expires_at`=null where id=?", + token, + publicKey, + active, + runner.ID) + + if err != nil { + return + } + + runner.Token = token + runner.PublicKey = publicKey + runner.Active = active + runner.RegistrationTokenHash = nil + runner.RegistrationTokenExpiresAt = nil + err = d.loadRunnerTagsSingle(&runner) + return +} + +func (d *SqlDb) ResetRunnerRegistration(runnerID int, registrationTokenHash string, expiresAt time.Time) (err error) { + _, err = d.exec( + "update `runner` set `token`='', `active`=false, `public_key`=null, `registration_token`=?, `registration_token_expires_at`=? where id=?", + registrationTokenHash, + expiresAt, + runnerID) + return +} + +func (d *SqlDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { insertID, err := d.insert( "id", - "insert into `runner` (project_id, token, webhook, max_parallel_tasks, `name`, `active`, `is_default`, public_key) values (?, ?, ?, ?, ?, ?, ?, ?)", + "insert into `runner` (project_id, token, webhook, max_parallel_tasks, `name`, `active`, `is_default`, public_key, registration_token, registration_token_expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", runner.ProjectID, - token, + runner.Token, runner.Webhook, runner.MaxParallelTasks, runner.Name, runner.Active, runner.IsDefault, - runner.PublicKey) + runner.PublicKey, + runner.RegistrationTokenHash, + runner.RegistrationTokenExpiresAt) if err != nil { return @@ -193,7 +252,6 @@ func (d *SqlDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) newRunner = runner newRunner.ID = insertID - newRunner.Token = token newRunner.Tags = normalizeTags(runner.Tags) if err = d.replaceRunnerTags(newRunner.ID, newRunner.Tags); err != nil { diff --git a/db/sql/migrations/v2.18.7.err.sql b/db/sql/migrations/v2.18.7.err.sql new file mode 100644 index 0000000000..0040088775 --- /dev/null +++ b/db/sql/migrations/v2.18.7.err.sql @@ -0,0 +1,3 @@ +drop index if exists `runner__registration_token`; +alter table `runner` drop column `registration_token`; +alter table `runner` drop column `registration_token_expires_at`; diff --git a/db/sql/migrations/v2.18.7.sql b/db/sql/migrations/v2.18.7.sql new file mode 100644 index 0000000000..b299291b81 --- /dev/null +++ b/db/sql/migrations/v2.18.7.sql @@ -0,0 +1,3 @@ +alter table `runner` add column `registration_token` varchar(255); +alter table `runner` add column `registration_token_expires_at` datetime; +create index `runner__registration_token` on `runner` (`registration_token`); diff --git a/deployment/docker/runner/Dockerfile b/deployment/docker/runner/Dockerfile index ea14a20d19..655386db5e 100644 --- a/deployment/docker/runner/Dockerfile +++ b/deployment/docker/runner/Dockerfile @@ -19,7 +19,7 @@ ARG TARGETARCH ARG GH_TOKEN RUN if [ -n "$APP_BUILD_TYPE" ]; then \ - git clone -b main https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ + git clone -b feat/main https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ go work init . ./pro_impl; \ fi diff --git a/deployment/docker/server/Dockerfile b/deployment/docker/server/Dockerfile index fe6db76cd7..c224e6b30b 100644 --- a/deployment/docker/server/Dockerfile +++ b/deployment/docker/server/Dockerfile @@ -19,7 +19,7 @@ ARG TARGETARCH ARG GH_TOKEN RUN if [ -n "$APP_BUILD_TYPE" ]; then \ - git clone -b main https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ + git clone -b feat/main https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ go work init . ./pro_impl; \ fi diff --git a/pro/api/projects/runners.go b/pro/api/projects/runners.go index 4b2e2a5e3f..ea79d89c8c 100644 --- a/pro/api/projects/runners.go +++ b/pro/api/projects/runners.go @@ -5,16 +5,24 @@ import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/pro_interfaces" + "github.com/semaphoreui/semaphore/services/server" ) // NewProjectRunnerController creates a new ProjectRunnerController instance. -func NewProjectRunnerController(subscriptionService pro_interfaces.SubscriptionService) pro_interfaces.ProjectRunnerController { +func NewProjectRunnerController( + subscriptionService pro_interfaces.SubscriptionService, + runnerService server.RunnerService, +) pro_interfaces.ProjectRunnerController { return &ProjectRunnerControllerImpl{} } type ProjectRunnerControllerImpl struct { } +func (c *ProjectRunnerControllerImpl) RegenerateRegistrationToken(w http.ResponseWriter, r *http.Request) { + helpers.WriteJSON(w, http.StatusCreated, map[string]interface{}{}) +} + func (c *ProjectRunnerControllerImpl) GetRunners(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []any{}) } diff --git a/pro_interfaces/project_runner_ctl.go b/pro_interfaces/project_runner_ctl.go index 6169c883e1..24a9a9906d 100644 --- a/pro_interfaces/project_runner_ctl.go +++ b/pro_interfaces/project_runner_ctl.go @@ -12,4 +12,5 @@ type ProjectRunnerController interface { SetRunnerActive(w http.ResponseWriter, r *http.Request) ClearRunnerCache(w http.ResponseWriter, r *http.Request) GetRunnerTags(w http.ResponseWriter, r *http.Request) + RegenerateRegistrationToken(w http.ResponseWriter, r *http.Request) } diff --git a/services/export/Runner.go b/services/export/Runner.go index e30f53e15e..a48d384c6a 100644 --- a/services/export/Runner.go +++ b/services/export/Runner.go @@ -34,6 +34,7 @@ func (e *RunnerExporter) restoreValue(val EntityObject[db.Runner], store db.Stor return err } + old.Token = db.GenerateRunnerToken() newObj, err := store.CreateRunner(old) if err != nil { return err diff --git a/services/project/restore.go b/services/project/restore.go index 2d6ed034ed..c6adf2b06b 100644 --- a/services/project/restore.go +++ b/services/project/restore.go @@ -462,6 +462,7 @@ func (e BackupRunner) Verify(backup *BackupFormat) error { func (e BackupRunner) Restore(store db.Store, b *BackupDB) error { runner := e.Runner runner.ProjectID = &b.meta.ID + runner.Token = "" // Unregistered runner newRunner, err := store.CreateRunner(runner) if err != nil { return err diff --git a/services/runners/types.go b/services/runners/types.go index 05353652b8..81b2e1a18a 100644 --- a/services/runners/types.go +++ b/services/runners/types.go @@ -56,6 +56,9 @@ type JobProgress struct { } type RunnerRegistration struct { + // RegistrationToken is either the shared global registration token (which + // creates a new runner) or a one-time token issued for a specific unregistered + // runner (which registers that runner). RegistrationToken string `json:"registration_token" binding:"required"` Webhook string `json:"webhook,omitempty"` Name string `json:"name,omitempty"` diff --git a/services/server/runner_svc.go b/services/server/runner_svc.go new file mode 100644 index 0000000000..3a1c2dfc85 --- /dev/null +++ b/services/server/runner_svc.go @@ -0,0 +1,125 @@ +package server + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "time" + + "github.com/gorilla/securecookie" + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/tz" + "github.com/semaphoreui/semaphore/util" +) + +// runnerRegistrationTokenTTL is how long a one-time registration token issued for +// an unregistered runner stays valid. +const runnerRegistrationTokenTTL = time.Hour + +// RunnerRegistrationTokenPrefix prefixes every one-time registration token so it +// is easy to recognize (e.g. in cloud-init scripts or logs). +const RunnerRegistrationTokenPrefix = "smrs_" + +// generateRunnerRegistrationToken creates a new one-time registration token and +// returns the plaintext token (handed to the caller once) together with its hash +// (stored in the database, never the plaintext). +func generateRunnerRegistrationToken() (token string, hash string) { + token = RunnerRegistrationTokenPrefix + base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) + hash = HashRunnerRegistrationToken(token) + return +} + +// HashRunnerRegistrationToken hashes a registration token for storage and lookup. +// It is deterministic (SHA-256) so a runner can be found by the token it presents. +func HashRunnerRegistrationToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// RunnerService owns the creation of runners for both global and project scopes: +// it decides and generates a runner's credentials (auth token or one-time +// registration token) and, when needed, its key pair, before persisting it. +type RunnerService interface { + // CreateRunner generates the runner's credentials and persists it. The returned + // privateKey is non-empty only when the service generated a key pair for the + // runner (and must be handed to the caller exactly once). + CreateRunner(runner db.Runner) (newRunner db.Runner, privateKey string, err error) + + // RegenerateRegistrationToken issues a fresh one-time registration token and + // returns its plaintext (handed to the caller once). If the runner was already + // registered, it is reset to the unregistered state (auth token and key pair + // cleared, deactivated) so it can be registered again. + RegenerateRegistrationToken(runner db.Runner) (registrationToken string, err error) +} + +type RunnerServiceImpl struct { + runnerRepo db.RunnerManager +} + +func NewRunnerService(runnerRepo db.RunnerManager) RunnerService { + return &RunnerServiceImpl{ + runnerRepo: runnerRepo, + } +} + +func (s *RunnerServiceImpl) CreateRunner(runner db.Runner) (newRunner db.Runner, privateKey string, err error) { + if runner.Registered { + runner.Token = db.GenerateRunnerToken() + + if runner.PublicKey == nil { + privateKey, err = s.generateKeyPair(&runner) + if err != nil { + return + } + } + } else { + // An unregistered runner is created with no credentials at all: no auth token + // and no registration token. It is inactive and has no key pair. A one-time + // registration token is issued later on demand via RegenerateRegistrationToken. + runner.Token = "" + runner.Active = false + runner.PublicKey = nil + runner.RegistrationTokenHash = nil + runner.RegistrationTokenExpiresAt = nil + } + + newRunner, err = s.runnerRepo.CreateRunner(runner) + return +} + +func (s *RunnerServiceImpl) RegenerateRegistrationToken(runner db.Runner) (registrationToken string, err error) { + token, hash := generateRunnerRegistrationToken() + expiresAt := tz.Now().Add(runnerRegistrationTokenTTL) + + // This works for both unregistered and already-registered runners: a registered + // runner is reset to the unregistered state (its auth token and key pair are + // cleared and it is deactivated) and gets a fresh one-time registration token. + if err = s.runnerRepo.ResetRunnerRegistration(runner.ID, hash, expiresAt); err != nil { + return + } + + registrationToken = token + return +} + +// generateKeyPair generates an RSA key pair for the runner, sets its public key +// and returns the PEM-encoded private key. +func (s *RunnerServiceImpl) generateKeyPair(runner *db.Runner) (privateKey string, err error) { + var b bytes.Buffer + w := bufio.NewWriter(&b) + + publicKey, err := util.GeneratePrivateKey(w) + if err != nil { + return + } + + if err = w.Flush(); err != nil { + return + } + + runner.PublicKey = &publicKey + privateKey = b.String() + return +} diff --git a/services/server/runner_svc_test.go b/services/server/runner_svc_test.go new file mode 100644 index 0000000000..e6389ee48f --- /dev/null +++ b/services/server/runner_svc_test.go @@ -0,0 +1,102 @@ +package server + +import ( + "strings" + "testing" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/db/bolt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunnerService_CreateRunner_Registered(t *testing.T) { + svc := NewRunnerService(bolt.CreateTestStore()) + + runner, privateKey, err := svc.CreateRunner(db.Runner{Name: "r1", Registered: true}) + require.NoError(t, err) + + // A normal runner gets an auth token and a server-generated key pair. + assert.NotEmpty(t, runner.Token) + assert.True(t, runner.IsRegistered()) + assert.NotEmpty(t, privateKey) + assert.NotNil(t, runner.PublicKey) + assert.Nil(t, runner.RegistrationTokenHash) +} + +func TestRunnerService_CreateRunner_RegisteredWithProvidedPublicKey(t *testing.T) { + svc := NewRunnerService(bolt.CreateTestStore()) + + pub := "provided-public-key" + runner, privateKey, err := svc.CreateRunner(db.Runner{PublicKey: &pub, Registered: true}) + require.NoError(t, err) + + // When the caller provides a public key, the service does not generate one. + assert.NotEmpty(t, runner.Token) + assert.Empty(t, privateKey) + require.NotNil(t, runner.PublicKey) + assert.Equal(t, pub, *runner.PublicKey) +} + +func TestRunnerService_CreateRunner_Unregistered(t *testing.T) { + svc := NewRunnerService(bolt.CreateTestStore()) + + runner, privateKey, err := svc.CreateRunner(db.Runner{Registered: false, Active: true}) + require.NoError(t, err) + + // An unregistered runner is created with no credentials at all: no auth token, + // no registration token, no key pair, and inactive. + assert.Empty(t, runner.Token) + assert.False(t, runner.IsRegistered()) + assert.False(t, runner.Active) + assert.Empty(t, privateKey) + assert.Nil(t, runner.PublicKey) + assert.Nil(t, runner.RegistrationTokenHash) + assert.Nil(t, runner.RegistrationTokenExpiresAt) +} + +func TestRunnerService_RegenerateRegistrationToken(t *testing.T) { + store := bolt.CreateTestStore() + svc := NewRunnerService(store) + + runner, _, err := svc.CreateRunner(db.Runner{Registered: false}) + require.NoError(t, err) + // Created without any registration token. + require.Nil(t, runner.RegistrationTokenHash) + + token, err := svc.RegenerateRegistrationToken(runner) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(token, RunnerRegistrationTokenPrefix)) + + stored, err := store.GetGlobalRunner(runner.ID) + require.NoError(t, err) + assert.Empty(t, stored.Token) + require.NotNil(t, stored.RegistrationTokenHash) + assert.Equal(t, HashRunnerRegistrationToken(token), *stored.RegistrationTokenHash) + assert.NotNil(t, stored.RegistrationTokenExpiresAt) +} + +func TestRunnerService_RegenerateRegistrationToken_ResetsRegisteredRunner(t *testing.T) { + store := bolt.CreateTestStore() + svc := NewRunnerService(store) + + runner, privateKey, err := svc.CreateRunner(db.Runner{Registered: true, Active: true}) + require.NoError(t, err) + require.True(t, runner.IsRegistered()) + require.NotEmpty(t, privateKey) + + token, err := svc.RegenerateRegistrationToken(runner) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(token, RunnerRegistrationTokenPrefix)) + + stored, err := store.GetGlobalRunner(runner.ID) + require.NoError(t, err) + // The runner is reset to the unregistered state. + assert.Empty(t, stored.Token) + assert.False(t, stored.IsRegistered()) + //assert.False(t, stored.Active) + assert.Nil(t, stored.PublicKey) + require.NotNil(t, stored.RegistrationTokenHash) + assert.Equal(t, HashRunnerRegistrationToken(token), *stored.RegistrationTokenHash) + assert.NotNil(t, stored.RegistrationTokenExpiresAt) +} diff --git a/web/src/components/RunnerForm.vue b/web/src/components/RunnerForm.vue index 98e95ca885..e281ff7a5f 100644 --- a/web/src/components/RunnerForm.vue +++ b/web/src/components/RunnerForm.vue @@ -47,12 +47,28 @@ - - - + + + + + + + + + + + + + + + {{ $t('unregisteredRunnerHint') }} + Enterprise edition.', ha_only_enterprise: 'High availability are only available in Enterprise edition.', diff --git a/web/src/views/Runners.vue b/web/src/views/Runners.vue index f2a1d1823f..72f51c4663 100644 --- a/web/src/views/Runners.vue +++ b/web/src/views/Runners.vue @@ -31,11 +31,131 @@ :max-width="600" v-model="newRunnerTokenDialog" :save-button-text="null" - :title="$t('newRunnerToken')" + :title="isUnregisteredRunner ? $t('runnerRegistrationToken') : $t('newRunnerToken')" hide-buttons >