From 27599631f11f306bfd8bf702783af10255325af2 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 20 Sep 2026 20:12:03 +0000 Subject: [PATCH] feat: run official executor behind SPIFFE mTLS --- cmd/gitea-dynamic-runner/main.go | 37 ++++++++++++ container/runner.Dockerfile | 23 +++++++- internal/podbackend/backend.go | 2 +- internal/podbackend/backend_test.go | 4 +- internal/runnerfacade/facade.go | 8 ++- internal/runnerfacade/facade_test.go | 49 ++++++++++++++++ internal/runnerfacade/server.go | 84 ++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 cmd/gitea-dynamic-runner/main.go create mode 100644 internal/runnerfacade/server.go diff --git a/cmd/gitea-dynamic-runner/main.go b/cmd/gitea-dynamic-runner/main.go new file mode 100644 index 0000000..71b99a6 --- /dev/null +++ b/cmd/gitea-dynamic-runner/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/runnerbootstrap" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + if len(os.Args) != 2 { + return errors.New("usage: gitea-dynamic-runner executor") + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + switch os.Args[1] { + case "executor": + config, err := runnerbootstrap.ExecutorConfigFromEnvironment() + if err != nil { + return err + } + return runnerbootstrap.RunExecutor(ctx, config) + default: + return fmt.Errorf("unknown command %q", os.Args[1]) + } +} diff --git a/container/runner.Dockerfile b/container/runner.Dockerfile index cc5aa4a..c9e7182 100644 --- a/container/runner.Dockerfile +++ b/container/runner.Dockerfile @@ -2,6 +2,14 @@ FROM ghcr.io/spiffe/spire-agent:1.15.3@sha256:41b0dcd8b258a69db9e2768292a060766f FROM docker.io/gitea/runner:3.5.0@sha256:66b7da94dc7dcadb2e076bec6928221336a9a637196399281c4b766fe1288242 AS runner +FROM docker.io/library/golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS controller +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-dynamic-runner ./cmd/gitea-dynamic-runner + # The runner daemon image is intentionally minimal and does not contain the # Node.js runtime required by JavaScript actions such as actions/checkout. # Run the daemon in Gitea's Ubuntu workflow image so host-mode jobs and their @@ -9,13 +17,22 @@ FROM docker.io/gitea/runner:3.5.0@sha256:66b7da94dc7dcadb2e076bec6928221336a9a63 FROM docker.io/gitea/runner-images:ubuntu-latest@sha256:fd911d7417bfbf0f454530e447da95b58001e1df41bbc5e1a8dd35d432575aae USER root +RUN groupadd --gid 2000 runner \ + && useradd --uid 2000 --gid 2000 --groups docker --create-home --shell /bin/bash runner \ + && printf 'runner ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/runner \ + && chmod 0440 /etc/sudoers.d/runner \ + && install -d -o 2000 -g 2000 /data + COPY --from=runner /usr/local/bin/gitea-runner /usr/local/bin/gitea-runner -COPY --from=runner /usr/local/bin/run.sh /usr/local/bin/run.sh +COPY --from=controller /out/gitea-dynamic-runner /usr/local/bin/gitea-dynamic-runner COPY --from=spire /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent COPY config/runner.yaml /etc/gitea-runner/config.yaml COPY --chmod=0755 scripts/gitea-job-started /usr/local/libexec/gitea-job-started COPY --chmod=0755 scripts/gitea-opensandbox-runner /usr/local/libexec/gitea-opensandbox-runner VOLUME ["/data"] -WORKDIR / -ENTRYPOINT ["/usr/local/bin/run.sh"] +ENV HOME=/home/runner +USER 2000:2000 +WORKDIR /home/runner +ENTRYPOINT ["/usr/local/bin/gitea-dynamic-runner"] +CMD ["executor"] diff --git a/internal/podbackend/backend.go b/internal/podbackend/backend.go index 98e8f01..8d27e4a 100644 --- a/internal/podbackend/backend.go +++ b/internal/podbackend/backend.go @@ -107,7 +107,7 @@ func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignmen Annotations: clone(launch.Metadata.Annotations), Image: b.Config.Image, ServiceAccount: b.Config.ServiceAccount, - Args: append(append([]string{}, b.Config.ExecutorArgs...), assignment.ID), + Args: append([]string{}, b.Config.ExecutorArgs...), Environment: clone(launch.Environment), }) if err != nil { diff --git a/internal/podbackend/backend_test.go b/internal/podbackend/backend_test.go index 201f90f..17d64ca 100644 --- a/internal/podbackend/backend_test.go +++ b/internal/podbackend/backend_test.go @@ -44,7 +44,7 @@ func (a *fakeAPI) DeleteIdentityEntry(_ context.Context, name string) error { func backend(api API) Backend { return Backend{API: api, Config: Config{ Namespace: "gitea-actions", Image: "zot/ci-executor:main", - ServiceAccount: "gitea-task-executor", ExecutorArgs: []string{"execute"}, + ServiceAccount: "gitea-task-executor", ExecutorArgs: []string{"executor"}, TrustDomain: "ddupan.top", SPIRECluster: "homelab", SPIREClass: "spire-mgmt-spire", ExecutorUID: 2000, }} @@ -90,7 +90,7 @@ func TestCreateUsesDeterministicNameAndRecoveryMetadata(t *testing.T) { if api.created.Environment["CI_RUNNER_CAPABILITY"] != "capability" { t.Fatalf("environment = %#v", api.created.Environment) } - if len(api.created.Args) != 2 || api.created.Args[1] != "gitea-task-42" || executor.IdentityTarget != "pod-uid" { + if len(api.created.Args) != 1 || api.created.Args[0] != "executor" || executor.IdentityTarget != "pod-uid" { t.Fatalf("args=%v executor=%#v", api.created.Args, executor) } } diff --git a/internal/runnerfacade/facade.go b/internal/runnerfacade/facade.go index d0560ce..c6cc84e 100644 --- a/internal/runnerfacade/facade.go +++ b/internal/runnerfacade/facade.go @@ -68,10 +68,14 @@ func (f *Facade) Register(context.Context, *connect.Request[runnerv1.RegisterReq } func (f *Facade) Declare(ctx context.Context, request *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) { - if _, _, err := f.authenticate(ctx, request); err != nil { + assignmentID, _, err := f.authenticate(ctx, request) + if err != nil { return nil, err } - return connect.NewResponse(&runnerv1.DeclareResponse{}), nil + return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{ + Uuid: assignmentID, Name: assignmentID, Status: runnerv1.RunnerStatus_RUNNER_STATUS_IDLE, + Version: request.Msg.GetVersion(), Labels: append([]string(nil), request.Msg.GetLabels()...), Ephemeral: true, + }}), nil } func (f *Facade) FetchTask(ctx context.Context, request *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) { diff --git a/internal/runnerfacade/facade_test.go b/internal/runnerfacade/facade_test.go index 16c5d78..e8eaa2f 100644 --- a/internal/runnerfacade/facade_test.go +++ b/internal/runnerfacade/facade_test.go @@ -2,11 +2,17 @@ package runnerfacade import ( "context" + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "net/url" "testing" "connectrpc.com/connect" "gitea.dev/actionslib/pkg/protocol" runnerv1 "gitea.dev/actionslib/runner/v1" + "gitea.dev/actionslib/runner/v1/runnerv1connect" "google.golang.org/protobuf/types/known/structpb" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment" @@ -81,6 +87,49 @@ func TestFacadeReturnsOnlyPreassignedTaskAndSignalsClaim(t *testing.T) { } } +func TestFacadeDeclareReturnsOfficialRunnerMetadata(t *testing.T) { + facade, assignment, token := testFacade(t) + ctx := WithSPIFFEID(context.Background(), assignment.Identity.SPIFFEID) + response, err := facade.Declare(ctx, authenticatedRequest(&runnerv1.DeclareRequest{ + Version: "v3.5.0", Labels: []string{"self-hosted", "pod"}, + }, assignment.ID, token)) + if err != nil { + t.Fatal(err) + } + runner := response.Msg.GetRunner() + if runner.GetUuid() != assignment.ID || runner.GetName() != assignment.ID || runner.GetVersion() != "v3.5.0" || !runner.GetEphemeral() { + t.Fatalf("runner = %#v", runner) + } + if len(runner.GetLabels()) != 2 || runner.GetLabels()[0] != "self-hosted" || runner.GetLabels()[1] != "pod" { + t.Fatalf("labels = %#v", runner.GetLabels()) + } +} + +func TestAPIHandlerMatchesOfficialRunnerBasePath(t *testing.T) { + facade, assignment, token := testFacade(t) + identityURL, err := url.Parse(assignment.Identity.SPIFFEID) + if err != nil { + t.Fatal(err) + } + handler := APIHandler(facade) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + request.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{URIs: []*url.URL{identityURL}}}} + handler.ServeHTTP(response, request) + })) + defer server.Close() + client := runnerv1connect.NewRunnerServiceClient(server.Client(), server.URL+APIBasePath) + request := authenticatedRequest(&runnerv1.DeclareRequest{ + Version: "v3.5.0", Labels: []string{"self-hosted", "pod"}, + }, assignment.ID, token) + response, err := client.Declare(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if response.Msg.GetRunner().GetUuid() != assignment.ID { + t.Fatalf("runner = %#v", response.Msg.GetRunner()) + } +} + func TestFacadeRejectsWrongIdentityOrCapability(t *testing.T) { facade, assignment, token := testFacade(t) wrongIdentity := WithSPIFFEID(context.Background(), "spiffe://ddupan.top/ci/owner/repo/other") diff --git a/internal/runnerfacade/server.go b/internal/runnerfacade/server.go new file mode 100644 index 0000000..717c9a6 --- /dev/null +++ b/internal/runnerfacade/server.go @@ -0,0 +1,84 @@ +package runnerfacade + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig" + "github.com/spiffe/go-spiffe/v2/workloadapi" +) + +const APIBasePath = "/api/actions" + +// APIHandler exposes the facade at the base path used by the official Runner. +// SPIFFE middleware runs after the TLS listener has authenticated the peer. +func APIHandler(facade *Facade) http.Handler { + path, handler := facade.Handler() + mux := http.NewServeMux() + mux.Handle(APIBasePath+path, http.StripPrefix(APIBasePath, SPIFFEMiddleware(handler))) + return mux +} + +type Server struct { + Facade *Facade + ListenAddress string + TrustDomain string + WorkloadAPIAddr string +} + +// Run serves the RunnerService facade with workload-to-workload mTLS. Any +// identity in the local trust domain may complete TLS; the facade then requires +// the exact logical task identity stored in its assignment registry. +func (s Server) Run(ctx context.Context) error { + if s.Facade == nil || s.ListenAddress == "" || s.TrustDomain == "" { + return errors.New("runner facade, listen address, and trust domain are required") + } + trustDomain, err := spiffeid.TrustDomainFromString(s.TrustDomain) + if err != nil { + return fmt.Errorf("parse facade trust domain: %w", err) + } + options := []workloadapi.X509SourceOption{} + if s.WorkloadAPIAddr != "" { + options = append(options, workloadapi.WithClientOptions(workloadapi.WithAddr(s.WorkloadAPIAddr))) + } + source, err := workloadapi.NewX509Source(ctx, options...) + if err != nil { + return fmt.Errorf("open facade SPIFFE Workload API X509 source: %w", err) + } + defer source.Close() + + listener, err := net.Listen("tcp", s.ListenAddress) + if err != nil { + return fmt.Errorf("listen for runner facade: %w", err) + } + defer listener.Close() + tlsListener := tls.NewListener(listener, tlsconfig.MTLSServerConfig( + source, source, tlsconfig.AuthorizeMemberOf(trustDomain), + )) + httpServer := &http.Server{Handler: APIHandler(s.Facade), ReadHeaderTimeout: 10 * time.Second} + serverErrors := make(chan error, 1) + go func() { serverErrors <- httpServer.Serve(tlsListener) }() + + select { + case <-ctx.Done(): + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownErr := httpServer.Shutdown(shutdownContext) + cancel() + serverErr := <-serverErrors + if errors.Is(serverErr, http.ErrServerClosed) { + serverErr = nil + } + return errors.Join(shutdownErr, serverErr) + case err := <-serverErrors: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve runner facade: %w", err) + } +}