From d6efefbb63d59ba0251c10b6031f6b630b184eaf Mon Sep 17 00:00:00 2001 From: Earl Warren Date: Sun, 9 Jul 2023 19:20:05 +0200 Subject: [PATCH] [CLI] implement forgejo-cli actions register (cherry picked from commit 2f95143000e4ccc94ef14332777b58fe778edbd6) (cherry picked from commit cf77f1e6d96f4456a25d7375012a7181fb1bcce4) (cherry picked from commit 0983b62ca007a6c270aff366c338a21ce6e105ce) --- cmd/forgejo/actions.go | 130 +++++++++++- models/actions/forgejo.go | 68 ++++++ models/actions/forgejo_test.go | 29 +++ models/actions/main_test.go | 18 ++ modules/private/forgejo_actions.go | 32 +++ routers/private/forgejo.go | 48 +++++ routers/private/internal.go | 1 + tests/integration/cmd_forgejo_actions_test.go | 196 +++++++++++++++++- tests/integration/cmd_forgejo_test.go | 2 +- 9 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 models/actions/forgejo.go create mode 100644 models/actions/forgejo_test.go create mode 100644 models/actions/main_test.go create mode 100644 modules/private/forgejo_actions.go create mode 100644 routers/private/forgejo.go diff --git a/cmd/forgejo/actions.go b/cmd/forgejo/actions.go index 74629ba025..224bc626b4 100644 --- a/cmd/forgejo/actions.go +++ b/cmd/forgejo/actions.go @@ -5,7 +5,11 @@ package forgejo import ( "context" + "encoding/hex" "fmt" + "io" + "os" + "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/private" @@ -21,6 +25,7 @@ func CmdActions(ctx context.Context) cli.Command { Subcommands: []cli.Command{ SubcmdActionsGenerateRunnerToken(ctx), SubcmdActionsGenerateRunnerSecret(ctx), + SubcmdActionsRegister(ctx), }, } } @@ -48,6 +53,129 @@ func SubcmdActionsGenerateRunnerSecret(ctx context.Context) cli.Command { } } +func SubcmdActionsRegister(ctx context.Context) cli.Command { + return cli.Command{ + Name: "register", + Usage: "Idempotent registration of a runner using a shared secret", + Action: func(cliCtx *cli.Context) error { return RunRegister(ctx, cliCtx) }, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "secret", + Usage: "the secret the runner will use to connect as a 40 character hexadecimal string", + }, + cli.StringFlag{ + Name: "secret-stdin", + Usage: "the secret the runner will use to connect as a 40 character hexadecimal string, read from stdin", + }, + cli.StringFlag{ + Name: "secret-file", + Usage: "path to the file containing the secret the runner will use to connect as a 40 character hexadecimal string", + }, + cli.StringFlag{ + Name: "scope, s", + Value: "", + Usage: "{owner}[/{repo}] - leave empty for a global runner", + }, + cli.StringFlag{ + Name: "labels", + Value: "", + Usage: "comma separated list of labels supported by the runner (e.g. docker,ubuntu-latest,self-hosted) (not required since v1.21)", + }, + cli.StringFlag{ + Name: "name", + Value: "runner", + Usage: "name of the runner (default runner)", + }, + cli.StringFlag{ + Name: "version", + Value: "", + Usage: "version of the runner (not required since v1.21)", + }, + }, + } +} + +func readSecret(ctx context.Context, cliCtx *cli.Context) (string, error) { + if cliCtx.IsSet("secret") { + return cliCtx.String("secret"), nil + } + if cliCtx.IsSet("secret-stdin") { + buf, err := io.ReadAll(ContextGetStdin(ctx)) + if err != nil { + return "", err + } + return string(buf), nil + } + if cliCtx.IsSet("secret-file") { + path := cliCtx.String("secret-file") + buf, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(buf), nil + } + return "", fmt.Errorf("at least one of the --secret, --secret-stdin, --secret-file options is required") +} + +func validateSecret(secret string) error { + secretLen := len(secret) + if secretLen != 40 { + return fmt.Errorf("the secret must be exactly 40 characters long, not %d: generate-secret can provide a secret matching the requirements", secretLen) + } + if _, err := hex.DecodeString(secret); err != nil { + return fmt.Errorf("the secret must be an hexadecimal string: %w", err) + } + return nil +} + +func RunRegister(ctx context.Context, cliCtx *cli.Context) error { + if !ContextGetNoInstallSignals(ctx) { + var cancel context.CancelFunc + ctx, cancel = installSignals(ctx) + defer cancel() + } + setting.MustInstalled() + + secret, err := readSecret(ctx, cliCtx) + if err != nil { + return err + } + if err := validateSecret(secret); err != nil { + return err + } + scope := cliCtx.String("scope") + labels := cliCtx.String("labels") + name := cliCtx.String("name") + version := cliCtx.String("version") + + // + // There are two kinds of tokens + // + // - "registration token" only used when a runner interacts to + // register + // + // - "token" obtained after a successful registration and stored by + // the runner to authenticate + // + // The register subcommand does not need a "registration token", it + // needs a "token". Using the same name is confusing and secret is + // preferred for this reason in the cli. + // + // The ActionsRunnerRegister argument is token to be consistent with + // the internal naming. It is still confusing to the developer but + // not to the user. + // + respText, extra := private.ActionsRunnerRegister(ctx, secret, scope, strings.Split(labels, ","), name, version) + if extra.HasError() { + return handleCliResponseExtra(ctx, extra) + } + + if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", respText); err != nil { + panic(err) + } + return nil +} + func RunGenerateSecret(ctx context.Context, cliCtx *cli.Context) error { setting.MustInstalled() @@ -74,7 +202,7 @@ func RunGenerateActionsRunnerToken(ctx context.Context, cliCtx *cli.Context) err respText, extra := private.GenerateActionsRunnerToken(ctx, scope) if extra.HasError() { - return handleCliResponseExtra(extra) + return handleCliResponseExtra(ctx, extra) } if _, err := fmt.Fprintf(ContextGetStdout(ctx), "%s", respText); err != nil { panic(err) diff --git a/models/actions/forgejo.go b/models/actions/forgejo.go new file mode 100644 index 0000000000..243262facd --- /dev/null +++ b/models/actions/forgejo.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "encoding/hex" + "fmt" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + gouuid "github.com/google/uuid" +) + +func RegisterRunner(ctx context.Context, ownerID, repoID int64, token string, labels []string, name, version string) (*ActionRunner, error) { + uuid, err := gouuid.FromBytes([]byte(token[:16])) + if err != nil { + return nil, fmt.Errorf("gouuid.FromBytes %v", err) + } + uuidString := uuid.String() + + var runner ActionRunner + + has, err := db.GetEngine(ctx).Where("uuid=?", uuidString).Get(&runner) + if err != nil { + return nil, fmt.Errorf("GetRunner %v", err) + } else if !has { + // + // The runner does not exist yet, create it + // + saltBytes, err := util.CryptoRandomBytes(16) + if err != nil { + return nil, fmt.Errorf("CryptoRandomBytes %v", err) + } + salt := hex.EncodeToString(saltBytes) + + hash := auth_model.HashToken(token, salt) + + runner = ActionRunner{ + UUID: uuidString, + TokenHash: hash, + TokenSalt: salt, + } + + if err := CreateRunner(ctx, &runner); err != nil { + return &runner, fmt.Errorf("can't create new runner %w", err) + } + } + + // + // Update the existing runner + // + name, _ = util.SplitStringAtByteN(name, 255) + + runner.Name = name + runner.OwnerID = ownerID + runner.RepoID = repoID + runner.Version = version + runner.AgentLabels = labels + + if err := UpdateRunner(ctx, &runner, "name", "owner_id", "repo_id", "version", "agent_labels"); err != nil { + return &runner, fmt.Errorf("can't update the runner %+v %w", runner, err) + } + + return &runner, nil +} diff --git a/models/actions/forgejo_test.go b/models/actions/forgejo_test.go new file mode 100644 index 0000000000..a8583c3d00 --- /dev/null +++ b/models/actions/forgejo_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +package actions + +import ( + "crypto/subtle" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestActions_RegisterRunner(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + ownerID := int64(0) + repoID := int64(0) + token := "0123456789012345678901234567890123456789" + labels := []string{} + name := "runner" + version := "v1.2.3" + runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, labels, name, version) + assert.NoError(t, err) + assert.EqualValues(t, name, runner.Name) + + assert.EqualValues(t, 1, subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))), "the token cannot be verified with the same method as routers/api/actions/runner/interceptor.go as of 8228751c55d6a4263f0fec2932ca16181c09c97d") +} diff --git a/models/actions/main_test.go b/models/actions/main_test.go new file mode 100644 index 0000000000..90d553efd4 --- /dev/null +++ b/models/actions/main_test.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +package actions_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", ".."), + }) +} diff --git a/modules/private/forgejo_actions.go b/modules/private/forgejo_actions.go new file mode 100644 index 0000000000..1295aa52f4 --- /dev/null +++ b/modules/private/forgejo_actions.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + + "code.gitea.io/gitea/modules/setting" +) + +type ActionsRunnerRegisterRequest struct { + Token string + Scope string + Labels []string + Name string + Version string +} + +func ActionsRunnerRegister(ctx context.Context, token, scope string, labels []string, name, version string) (string, ResponseExtra) { + reqURL := setting.LocalURL + "api/internal/actions/register" + + req := newInternalRequest(ctx, reqURL, "POST", ActionsRunnerRegisterRequest{ + Token: token, + Scope: scope, + Labels: labels, + Name: name, + Version: version, + }) + + resp, extra := requestJSONResp(req, &responseText{}) + return resp.Text, extra +} diff --git a/routers/private/forgejo.go b/routers/private/forgejo.go new file mode 100644 index 0000000000..97ae03468c --- /dev/null +++ b/routers/private/forgejo.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT + +package private + +import ( + "fmt" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/private" +) + +func ActionsRunnerRegister(ctx *context.PrivateContext) { + var registerRequest private.ActionsRunnerRegisterRequest + rd := ctx.Req.Body + defer rd.Close() + + if err := json.NewDecoder(rd).Decode(®isterRequest); err != nil { + log.Error("%v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + return + } + + owner, repo, err := parseScope(ctx, registerRequest.Scope) + if err != nil { + log.Error("%v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err.Error(), + }) + } + + runner, err := actions_model.RegisterRunner(ctx, owner, repo, registerRequest.Token, registerRequest.Labels, registerRequest.Name, registerRequest.Version) + if err != nil { + err := fmt.Sprintf("error while registering runner: %v", err) + log.Error("%v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: err, + }) + return + } + + ctx.PlainText(http.StatusOK, runner.UUID) +} diff --git a/routers/private/internal.go b/routers/private/internal.go index 407edebeed..ca0f942bad 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -56,6 +56,7 @@ func Routes() *web.Route { // Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers. r.Use(chi_middleware.RealIP) + r.Post("/actions/register", ActionsRunnerRegister) r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent) r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo) r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog) diff --git a/tests/integration/cmd_forgejo_actions_test.go b/tests/integration/cmd_forgejo_actions_test.go index e2fbb77dae..04841d74c7 100644 --- a/tests/integration/cmd_forgejo_actions_test.go +++ b/tests/integration/cmd_forgejo_actions_test.go @@ -3,9 +3,16 @@ package integration import ( + gocontext "context" "net/url" + "os" + "strings" "testing" + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" @@ -16,12 +23,191 @@ func Test_CmdForgejo_Actions(t *testing.T) { onGiteaRun(t, func(*testing.T, *url.URL) { defer test.MockVariable(&setting.Actions.Enabled, true)() - var output string + token, err := cmdForgejoCaptureOutput(t, []string{"forgejo-cli", "actions", "generate-runner-token"}) + assert.NoError(t, err) + assert.EqualValues(t, 40, len(token)) - output = cmdForgejoCaptureOutput(t, []string{"forgejo-cli", "actions", "generate-runner-token"}) - assert.EqualValues(t, 40, len(output)) + secret, err := cmdForgejoCaptureOutput(t, []string{"forgejo-cli", "actions", "generate-secret"}) + assert.NoError(t, err) + assert.EqualValues(t, 40, len(secret)) - output = cmdForgejoCaptureOutput(t, []string{"forgejo-cli", "actions", "generate-secret"}) - assert.EqualValues(t, 40, len(output)) + _, err = cmdForgejoCaptureOutput(t, []string{"forgejo-cli", "actions", "register"}) + assert.ErrorContains(t, err, "at least one of the --secret") + + for _, testCase := range []struct { + testName string + scope string + secret string + errorMessage string + }{ + { + testName: "bad user", + scope: "baduser", + secret: "0123456789012345678901234567890123456789", + errorMessage: "user does not exist", + }, + { + testName: "bad repo", + scope: "org25/badrepo", + secret: "0123456789012345678901234567890123456789", + errorMessage: "repository does not exist", + }, + { + testName: "secret length != 40", + scope: "org25", + secret: "0123456789", + errorMessage: "40 characters long", + }, + { + testName: "secret is not a hexadecimal string", + scope: "org25", + secret: "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + errorMessage: "must be an hexadecimal string", + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{"forgejo-cli", "actions", "register", "--secret", testCase.secret, "--scope", testCase.scope} + output, err := cmdForgejoCaptureOutput(t, cmd) + assert.ErrorContains(t, err, testCase.errorMessage) + assert.EqualValues(t, "", output) + }) + } + + secret = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + expecteduuid := "44444444-4444-4444-4444-444444444444" + + for _, testCase := range []struct { + testName string + secretOption func() string + stdin []string + }{ + { + testName: "secret from argument", + secretOption: func() string { + return "--secret=" + secret + }, + }, + { + testName: "secret from stdin", + secretOption: func() string { + return "--secret-stdin" + }, + stdin: []string{secret}, + }, + { + testName: "secret from file", + secretOption: func() string { + secretFile := t.TempDir() + "/secret" + assert.NoError(t, os.WriteFile(secretFile, []byte(secret), 0o644)) + return "--secret-file=" + secretFile + }, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{"forgejo-cli", "actions", "register", testCase.secretOption(), "--scope=org26"} + uuid, err := cmdForgejoCaptureOutput(t, cmd, testCase.stdin...) + assert.NoError(t, err) + assert.EqualValues(t, expecteduuid, uuid) + }) + } + + secret = "0123456789012345678901234567890123456789" + expecteduuid = "30313233-3435-3637-3839-303132333435" + + for _, testCase := range []struct { + testName string + scope string + secret string + name string + labels string + version string + uuid string + }{ + { + testName: "org", + scope: "org25", + secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + uuid: "41414141-4141-4141-4141-414141414141", + }, + { + testName: "user and repo", + scope: "user2/repo2", + secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + uuid: "42424242-4242-4242-4242-424242424242", + }, + { + testName: "labels", + scope: "org25", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + uuid: "43434343-4343-4343-4343-434343434343", + }, + { + testName: "insert a runner", + scope: "user3/repo5", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: secret, + uuid: expecteduuid, + }, + { + testName: "update an existing runner", + scope: "user5/repo4", + name: "runnerNameChanged", + labels: "label1,label2,label3,more,label", + version: "v1.2.3-suffix", + secret: secret, + uuid: expecteduuid, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{ + "forgejo-cli", "actions", "register", + "--secret", testCase.secret, "--scope", testCase.scope, + } + if testCase.name != "" { + cmd = append(cmd, "--name", testCase.name) + } + if testCase.labels != "" { + cmd = append(cmd, "--labels", testCase.labels) + } + if testCase.version != "" { + cmd = append(cmd, "--version", testCase.version) + } + // + // Run twice to verify it is idempotent + // + for i := 0; i < 2; i++ { + uuid, err := cmdForgejoCaptureOutput(t, cmd) + assert.NoError(t, err) + if assert.EqualValues(t, testCase.uuid, uuid) { + ownerName, repoName, found := strings.Cut(testCase.scope, "/") + action, err := actions_model.GetRunnerByUUID(gocontext.Background(), uuid) + assert.NoError(t, err) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: action.OwnerID}) + assert.Equal(t, ownerName, user.Name, action.OwnerID) + + if found { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: action.RepoID}) + assert.Equal(t, repoName, repo.Name, action.RepoID) + } + if testCase.name != "" { + assert.EqualValues(t, testCase.name, action.Name) + } + if testCase.labels != "" { + labels := strings.Split(testCase.labels, ",") + assert.EqualValues(t, labels, action.AgentLabels) + } + if testCase.version != "" { + assert.EqualValues(t, testCase.version, action.Version) + } + } + } + }) + } }) } diff --git a/tests/integration/cmd_forgejo_test.go b/tests/integration/cmd_forgejo_test.go index 20e04d0d03..079e1361fb 100644 --- a/tests/integration/cmd_forgejo_test.go +++ b/tests/integration/cmd_forgejo_test.go @@ -17,7 +17,7 @@ import ( "github.com/urfave/cli" ) -func cmdForgejoCaptureOutput(t *testing.T, args []string) (string, error) { +func cmdForgejoCaptureOutput(t *testing.T, args []string, stdin ...string) (string, error) { r, w, err := os.Pipe() assert.NoError(t, err) set := flag.NewFlagSet("forgejo-cli", 0)