1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15
16 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
17)
18
19// Environment variables of the host process. The sandbox settings
20// (ERMITE_RUNNER_ISOLATION, _CAPACITY, _IMAGE, _JOB_*) are read by package
21// sandbox.
22const (
23 EnvURL = "ERMITE_RUNNER_URL"
24 EnvName = "ERMITE_RUNNER_NAME"
25 EnvLabels = "ERMITE_RUNNER_LABELS"
26 // EnvEnvironment is the runner.environment value that jobs see: see
27 // Config.Environment.
28 EnvEnvironment = "ERMITE_RUNNER_ENVIRONMENT"
29 EnvRegisterToken = "ERMITE_RUNNER_REGISTER_TOKEN"
30 // EnvInsecureHTTP allows a plain http server URL that is not loopback. The
31 // runner token and the job tokens then travel in clear: tests only.
32 EnvInsecureHTTP = "ERMITE_RUNNER_INSECURE_HTTP"
33)
34
35// The values of Config.Environment, as GitHub spells them.
36const (
37 EnvGitHubHosted = "github-hosted"
38 EnvSelfHosted = "self-hosted"
39)
40
41// Config is the host part of the runner configuration.
42type Config struct {
43 // URL is the base URL of the ermite server.
44 URL string
45 // Name is the runner name: 1 to 64 letters, digits, ".", "_" and "-".
46 Name string
47 // Labels are the extra labels of the runner.
48 Labels []string
49 // StateDir holds the runner token (mode 0600).
50 StateDir string
51 // Capacity is the number of jobs in parallel.
52 Capacity int
53 // Version is the version of the runner binary.
54 Version string
55 // Environment is the "runner.environment" value of the jobs and what decides
56 // whether "Set up job" prints the runner and machine name: EnvGitHubHosted
57 // (the default) or EnvSelfHosted. The runners of ermite stand in for the
58 // GitHub-hosted ones: workflows written for github.com test
59 // runner.environment == 'github-hosted' and the recordings of github.com
60 // (ermite-conformance) carry that value, so it is the default; an operator
61 // who wants jobs to see a self-hosted runner sets ERMITE_RUNNER_ENVIRONMENT.
62 Environment string
63 // InsecureHTTP allows a non-loopback http URL (ERMITE_RUNNER_INSECURE_HTTP=1).
64 InsecureHTTP bool
65 // GuestForgeOrigin is the origin (scheme://host[:port]) of ERMITE_RUNNER_URL
66 // when ERMITE_RUNNER_GUEST_FORGE=1, "" otherwise. The server URLs of every
67 // job are rewritten to it, see RewriteServerURLs. Set by the caller after
68 // sandbox.ResolveGuestForge.
69 GuestForgeOrigin string
70}
71
72// Client returns the client for a token, honouring InsecureHTTP.
73func (c Config) Client(url, token string) ermiteconnect.RunnerServiceClient {
74 if c.InsecureHTTP {
75 return NewInsecureClient(url, token, http.DefaultTransport)
76 }
77 return NewClient(url, token, http.DefaultTransport)
78}
79
80// ArtifactClient is the JobArtifactService client of a job token.
81func (c Config) ArtifactClient(url, token string) ermiteconnect.JobArtifactServiceClient {
82 return NewArtifactClient(url, token, http.DefaultTransport, c.InsecureHTTP)
83}
84
85// environment is Environment, the default when a Config was built without
86// LoadConfig.
87func (c Config) environment() string {
88 if c.Environment == "" {
89 return EnvGitHubHosted
90 }
91 return c.Environment
92}
93
94var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
95
96// LoadConfig reads the host settings. hostname is the default runner name.
97// stateDir and capacity come from the sandbox configuration, so that both
98// agree.
99func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity int, version string) (Config, error) {
100 c := Config{URL: strings.TrimSpace(getenv(EnvURL)), Name: strings.TrimSpace(getenv(EnvName)), StateDir: stateDir, Capacity: capacity, Version: version}
101 if c.Name == "" {
102 c.Name = sanitizeName(hostname)
103 }
104 switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
105 case "":
106 c.Environment = EnvGitHubHosted
107 case EnvGitHubHosted, EnvSelfHosted:
108 c.Environment = v
109 default:
110 return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
111 }
112 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
113 case "", "0", "false", "no":
114 case "1", "true", "yes":
115 c.InsecureHTTP = true
116 default:
117 return c, fmt.Errorf("%s: %q is not 0 or 1", EnvInsecureHTTP, getenv(EnvInsecureHTTP))
118 }
119 if c.URL != "" {
120 if err := CheckURL(c.URL, c.InsecureHTTP); err != nil {
121 return c, fmt.Errorf("%s: %w", EnvURL, err)
122 }
123 }
124 if !nameRe.MatchString(c.Name) {
125 return c, fmt.Errorf("%s: %q is not a valid runner name (1 to 64 letters, digits, '.', '_' and '-')", EnvName, c.Name)
126 }
127 seen := map[string]bool{}
128 for _, l := range strings.Split(getenv(EnvLabels), ",") {
129 l = strings.TrimSpace(l)
130 if l == "" {
131 continue
132 }
133 if !nameRe.MatchString(l) {
134 return c, fmt.Errorf("%s: %q is not a valid label (1 to 64 letters, digits, '.', '_' and '-')", EnvLabels, l)
135 }
136 if k := strings.ToLower(l); !seen[k] {
137 seen[k] = true
138 c.Labels = append(c.Labels, l)
139 }
140 }
141 return c, nil
142}
143
144func sanitizeName(s string) string {
145 b := []byte(s)
146 for i, ch := range b {
147 if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
148 b[i] = '-'
149 }
150 }
151 if len(b) > 64 {
152 b = b[:64]
153 }
154 if len(b) == 0 {
155 return "runner"
156 }
157 return string(b)
158}
159
160// State is what register stores: the runner token and who it belongs to. The
161// file is mode 0600 and the token is never logged.
162type State struct {
163 URL string `json:"url"`
164 RunnerID string `json:"runner_id"`
165 Name string `json:"name"`
166 RunnerToken string `json:"runner_token"`
167}
168
169// StateFile is the path of the state file in dir.
170func StateFile(dir string) string { return filepath.Join(dir, "runner.json") }
171
172// ErrNotRegistered is returned by LoadState when no runner is registered.
173var ErrNotRegistered = errors.New("this runner is not registered: run `ermite-runner register` first")
174
175// SaveState writes the state atomically with mode 0600.
176func SaveState(dir string, s State) error {
177 if err := os.MkdirAll(dir, 0o700); err != nil {
178 return err
179 }
180 // MkdirAll leaves an existing directory as it is; the directory holds the
181 // runner token, so it is made private (the VM images live in it too, and
182 // the runner runs as root: nobody else needs to enter).
183 if err := os.Chmod(dir, 0o700); err != nil {
184 return fmt.Errorf("make the state directory private: %w", err)
185 }
186 b, err := json.MarshalIndent(s, "", " ")
187 if err != nil {
188 return err
189 }
190 tmp, err := os.CreateTemp(dir, ".runner-*.json")
191 if err != nil {
192 return err
193 }
194 defer func() { _ = os.Remove(tmp.Name()) }()
195 if err := tmp.Chmod(0o600); err != nil {
196 _ = tmp.Close()
197 return err
198 }
199 if _, err := tmp.Write(append(b, '\n')); err != nil {
200 _ = tmp.Close()
201 return err
202 }
203 if err := tmp.Sync(); err != nil {
204 _ = tmp.Close()
205 return err
206 }
207 if err := tmp.Close(); err != nil {
208 return err
209 }
210 return os.Rename(tmp.Name(), StateFile(dir))
211}
212
213// LoadState reads the state; it refuses a file readable by others, because the
214// runner token is a secret.
215func LoadState(dir string) (State, error) {
216 p := StateFile(dir)
217 st, err := os.Stat(p)
218 if errors.Is(err, os.ErrNotExist) {
219 return State{}, ErrNotRegistered
220 }
221 if err != nil {
222 return State{}, err
223 }
224 if st.Mode().Perm()&0o077 != 0 {
225 return State{}, fmt.Errorf("%s is accessible by other users (mode %o): chmod 600 it", p, st.Mode().Perm())
226 }
227 b, err := os.ReadFile(p)
228 if err != nil {
229 return State{}, err
230 }
231 var s State
232 if err := json.Unmarshal(b, &s); err != nil {
233 return State{}, fmt.Errorf("%s: %w", p, err)
234 }
235 if s.RunnerToken == "" {
236 return State{}, fmt.Errorf("%s has no runner token", p)
237 }
238 return s, nil
239}
240
241// ResolveRegisterToken returns the token for `register`, from the first source
242// that has one: standard input (--token-stdin, read to the end and trimmed),
243// the environment (ERMITE_RUNNER_REGISTER_TOKEN), then the --token flag. The
244// flag is last because the arguments of a process are visible to every user of
245// the host (ps, /proc/<pid>/cmdline); the other two are not.
246func ResolveRegisterToken(flagValue string, fromStdin bool, stdin io.Reader, getenv func(string) string) (string, error) {
247 if fromStdin {
248 b, err := io.ReadAll(io.LimitReader(stdin, 4096))
249 if err != nil {
250 return "", fmt.Errorf("reading the token from standard input: %w", err)
251 }
252 if t := strings.TrimSpace(string(b)); t != "" {
253 return t, nil
254 }
255 return "", errors.New("standard input has no token")
256 }
257 if t := strings.TrimSpace(getenv(EnvRegisterToken)); t != "" {
258 return t, nil
259 }
260 if t := strings.TrimSpace(flagValue); t != "" {
261 return t, nil
262 }
263 return "", fmt.Errorf("give the token in %s or on standard input (--token-stdin); --token works too but is visible in the process list", EnvRegisterToken)
264}