Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions free/ci/internal/nodes/registry/age_recipient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package registry

import (
"context"
"errors"
"time"

"github.com/nself-org/plugins/free/ci/internal/model"
)

// AgeRecipient returns the recipient pinned by the first hello, if any.
func (r *Registry) AgeRecipient(ctx context.Context, id string) (*string, error) {
n, err := r.Get(ctx, id)
if err != nil {
return nil, err
}
return n.Capability.Identity.AgeRecipient.Value, nil
}

// CheckAgeRecipient pins once, then flags a changed recipient without replacing it.
func (r *Registry) CheckAgeRecipient(ctx context.Context, id, presented, actor string) error {
if presented == "" {
return invalid("age recipient required")
}
n, err := r.Get(ctx, id)
if err != nil {
return err
}
if n.Capability.Identity.AgeRecipient.Value != nil && *n.Capability.Identity.AgeRecipient.Value != presented {
_, err = r.SetState(ctx, id, "maintenance", "age_recipient_changed", actor)
if err != nil {
return err
}
return &Error{Code: "E660", Reason: "age_recipient_changed"}
}
if n.Capability.Identity.AgeRecipient.Value != nil {
return nil
}
_, err = r.mutate(ctx, id, actor, "capability", func(n *Node) error {
if n.Capability.Identity.AgeRecipient.Value != nil {
if *n.Capability.Identity.AgeRecipient.Value != presented {
return &Error{Code: "E660", Reason: "age_recipient_changed"}
}
return nil
}
n.Capability.Identity.AgeRecipient = model.Fact[string]{Value: &presented, Source: "self", ObservedAt: time.Now().UTC(), Confidence: "known"}
return nil
})
var changed *Error
if errors.As(err, &changed) && changed.Code == "E660" {
_, flagErr := r.SetState(ctx, id, "maintenance", "age_recipient_changed", actor)
if flagErr != nil {
return flagErr
}
}
return err
}
52 changes: 52 additions & 0 deletions free/ci/internal/nodes/registry/policy_guards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package registry

import (
"strings"

"github.com/nself-org/plugins/free/ci/internal/model"
)

// PolicyActorPrefix marks a coordinator-side policy actor (the burst provider is "policy:burst"). Only such an
// actor may set or clear a node's hosted location or its nself.* labels; a node report may not (D-1402), because
// a hosted runner skips the AuthorizedProjects check in sched eligibility.
const PolicyActorPrefix = "policy:"

func isHosted(f model.Fact[string]) bool { return f.Value != nil && *f.Value == "hosted" }

// keepHostedKind returns the reported location kind, except that a report cannot make a node hosted or stop it
// being hosted: then the stored kind stays.
func keepHostedKind(old, reported model.Fact[string]) model.Fact[string] {
if isHosted(old) != isHosted(reported) {
return old
}
return reported
}

// keepProviderLabels returns the reported labels with the nself.* ones replaced by the stored nself.* ones.
func keepProviderLabels(old, reported []string) []string {
const prefix = "nself."
var out []string
for _, l := range reported {
if !strings.HasPrefix(l, prefix) {
out = append(out, l)
}
}
for _, l := range old {
if strings.HasPrefix(l, prefix) {
out = append(out, l)
}
}
if reported == nil && out == nil {
return nil
}
return out
}

func isolationRank(i model.Isolation) int {
for n, v := range model.EnumValues("Isolation") {
if v == string(i) {
return n
}
}
return -1
}
93 changes: 0 additions & 93 deletions free/ci/internal/nodes/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,51 +171,6 @@ func (r *Registry) UpdateCapability(ctx context.Context, id string, in model.Cap
return n, drift, err
}

// PolicyActorPrefix marks a coordinator-side policy actor (the burst provider is "policy:burst"). Only such an
// actor may set or clear a node's hosted location or its nself.* labels; a node report may not (D-1402), because
// a hosted runner skips the AuthorizedProjects check in sched eligibility.
const PolicyActorPrefix = "policy:"

func isHosted(f model.Fact[string]) bool { return f.Value != nil && *f.Value == "hosted" }

// keepHostedKind returns the reported location kind, except that a report cannot make a node hosted or stop it
// being hosted: then the stored kind stays.
func keepHostedKind(old, reported model.Fact[string]) model.Fact[string] {
if isHosted(old) != isHosted(reported) {
return old
}
return reported
}

// keepProviderLabels returns the reported labels with the nself.* ones replaced by the stored nself.* ones.
func keepProviderLabels(old, reported []string) []string {
const prefix = "nself."
var out []string
for _, l := range reported {
if !strings.HasPrefix(l, prefix) {
out = append(out, l)
}
}
for _, l := range old {
if strings.HasPrefix(l, prefix) {
out = append(out, l)
}
}
if reported == nil && out == nil {
return nil
}
return out
}

func isolationRank(i model.Isolation) int {
for n, v := range model.EnumValues("Isolation") {
if v == string(i) {
return n
}
}
return -1
}

// SetTrust is the only authority for coordinator-assigned trust fields.
func (r *Registry) SetTrust(ctx context.Context, id string, t model.CapabilityTrust, authority store.AdminAuthority) (Node, error) {
return r.mutate(ctx, id, authority.Actor(), "trust", func(n *Node) error {
Expand Down Expand Up @@ -292,51 +247,3 @@ func (r *Registry) SetState(ctx context.Context, id, state, reason, actor string
return nil
})
}

// AgeRecipient returns the recipient pinned by the first hello, if any.
func (r *Registry) AgeRecipient(ctx context.Context, id string) (*string, error) {
n, err := r.Get(ctx, id)
if err != nil {
return nil, err
}
return n.Capability.Identity.AgeRecipient.Value, nil
}

// CheckAgeRecipient pins once, then flags a changed recipient without replacing it.
func (r *Registry) CheckAgeRecipient(ctx context.Context, id, presented, actor string) error {
if presented == "" {
return invalid("age recipient required")
}
n, err := r.Get(ctx, id)
if err != nil {
return err
}
if n.Capability.Identity.AgeRecipient.Value != nil && *n.Capability.Identity.AgeRecipient.Value != presented {
_, err = r.SetState(ctx, id, "maintenance", "age_recipient_changed", actor)
if err != nil {
return err
}
return &Error{Code: "E660", Reason: "age_recipient_changed"}
}
if n.Capability.Identity.AgeRecipient.Value != nil {
return nil
}
_, err = r.mutate(ctx, id, actor, "capability", func(n *Node) error {
if n.Capability.Identity.AgeRecipient.Value != nil {
if *n.Capability.Identity.AgeRecipient.Value != presented {
return &Error{Code: "E660", Reason: "age_recipient_changed"}
}
return nil
}
n.Capability.Identity.AgeRecipient = model.Fact[string]{Value: &presented, Source: "self", ObservedAt: time.Now().UTC(), Confidence: "known"}
return nil
})
var changed *Error
if errors.As(err, &changed) && changed.Code == "E660" {
_, flagErr := r.SetState(ctx, id, "maintenance", "age_recipient_changed", actor)
if flagErr != nil {
return flagErr
}
}
return err
}
146 changes: 0 additions & 146 deletions free/github-runner/internal/trust.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package internal

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -11,8 +10,6 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -160,149 +157,6 @@ func checkGroup(ctx context.Context, cfg TrustConfig) (*TrustResult, error) {
"Restrict the group to private repositories in the organisation settings, or set GITHUB_RUNNER_ALLOW_PUBLIC_REPOS=true to accept the risk", g.Name)}
}

// runnerRef identifies a runner: by id when GitHub issued one (the persisted
// registration), else by display name.
type runnerRef struct {
ID int64
Name string
}

func (r runnerRef) matches(o runnerRef) bool {
if r.ID != 0 {
return o.ID == r.ID
}
return r.Name != "" && strings.EqualFold(o.Name, r.Name)
}

func (r runnerRef) String() string {
if r.Name != "" {
return strconv.Quote(r.Name)
}
return "#" + strconv.FormatInt(r.ID, 10)
}

// persistedRunner reads the registration the runner binary wrote to
// RunnerDir/.runner. Absent means not registered (nil, nil). A file that
// exists but cannot be read or parsed is an error: the guard fails closed
// rather than trust the configured name alone.
func persistedRunner(dir string) (*runnerRef, error) {
if dir == "" {
return nil, nil
}
raw, err := os.ReadFile(filepath.Join(dir, ".runner"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", filepath.Join(dir, ".runner"), err)
}
var f struct {
AgentID json.Number `json:"agentId"`
AgentName string `json:"agentName"`
}
// The runner writes the file with a UTF-8 BOM.
if err := json.Unmarshal(bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")), &f); err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Join(dir, ".runner"), err)
}
ref := runnerRef{Name: f.AgentName}
if f.AgentID != "" {
if ref.ID, err = f.AgentID.Int64(); err != nil {
return nil, fmt.Errorf("parse agentId in %s: %w", filepath.Join(dir, ".runner"), err)
}
}
if ref.ID == 0 && ref.Name == "" {
return nil, fmt.Errorf("%s names no runner", filepath.Join(dir, ".runner"))
}
return &ref, nil
}

// verifyRegisteredGroup refuses a runner that GitHub has registered outside
// the configured group: the guard reads the configured group, so a runner left
// in another (possibly public) group by a config change would otherwise pass.
// Both the configured name and the registration persisted in the runner dir
// are checked, so renaming the runner (GITHUB_RUNNER_NAME or the hostname) does
// not hide the installed one. A runner that is not registered at all passes (it
// is about to be).
func verifyRegisteredGroup(ctx context.Context, cfg TrustConfig, g *runnerGroup) error {
var refs []runnerRef
if cfg.RunnerName != "" {
refs = append(refs, runnerRef{Name: cfg.RunnerName})
}
persisted, err := persistedRunner(cfg.RunnerDir)
if err != nil {
return &TrustError{Code: CodeTrustUnverifiable, Status: http.StatusBadGateway,
Message: fmt.Sprintf("the installed runner registration cannot be read, so its runner group cannot be verified: %v", err)}
}
if persisted != nil {
refs = append(refs, *persisted)
}
if len(refs) == 0 {
return nil
}
if g.ID == 0 {
return &TrustError{Code: CodeTrustUnverifiable, Status: http.StatusBadGateway,
Message: fmt.Sprintf("GitHub did not report the id of runner group %q", g.Name)}
}
org := url.PathEscape(cfg.Org)
inGroup, err := listRunners(ctx, cfg, fmt.Sprintf("%s/orgs/%s/actions/runner-groups/%d/runners", githubAPIBase, org, g.ID))
if err != nil {
return err
}
var inOrg []runnerRef
orgLoaded := false
for _, ref := range refs {
if anyMatch(ref, inGroup) {
continue
}
if !orgLoaded {
if inOrg, err = listRunners(ctx, cfg, fmt.Sprintf("%s/orgs/%s/actions/runners", githubAPIBase, org)); err != nil {
return err
}
orgLoaded = true
}
if !anyMatch(ref, inOrg) {
continue
}
return &TrustError{Code: CodeRunnerGroupMismatch, Status: http.StatusConflict,
Message: fmt.Sprintf("runner %s is registered in a different runner group from the configured %q, so the check above does not cover it. "+
"Re-register the runner (install again) so it joins %q, or set GITHUB_RUNNER_GROUP to the group it is in", ref, g.Name, g.Name)}
}
return nil
}

func anyMatch(ref runnerRef, list []runnerRef) bool {
for _, r := range list {
if ref.matches(r) {
return true
}
}
return false
}

// listRunners pages through a runner list.
func listRunners(ctx context.Context, cfg TrustConfig, endpoint string) ([]runnerRef, error) {
var all []runnerRef
for page := 1; page <= maxGroupPages; page++ {
var body struct {
Runners []struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"runners"`
}
if err := githubGet(ctx, cfg.PAT, fmt.Sprintf("%s?per_page=%d&page=%d", endpoint, groupsPageSize, page), &body); err != nil {
return nil, err
}
for _, r := range body.Runners {
all = append(all, runnerRef{ID: r.ID, Name: r.Name})
}
if len(body.Runners) < groupsPageSize {
return all, nil
}
}
return nil, &TrustError{Code: CodeTrustUnverifiable, Status: http.StatusBadGateway,
Message: "the runner list is too long to search"}
}

// stopsRunner reports whether a refusal with code must stop a running runner.
func stopsRunner(code string) bool {
return code == CodeRunnerGroupPublic || code == CodeRunnerGroupMismatch
Expand Down
Loading
Loading