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// environment is Environment, the default when a Config was built without
81// LoadConfig.
82func (c Config) environment() string {
83 if c.Environment == "" {
84 return EnvGitHubHosted
85 }
86 return c.Environment
87}
88
89var nameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
90
91// LoadConfig reads the host settings. hostname is the default runner name.
92// stateDir and capacity come from the sandbox configuration, so that both
93// agree.
94func LoadConfig(getenv func(string) string, hostname, stateDir string, capacity int, version string) (Config, error) {
95 c := Config{URL: strings.TrimSpace(getenv(EnvURL)), Name: strings.TrimSpace(getenv(EnvName)), StateDir: stateDir, Capacity: capacity, Version: version}
96 if c.Name == "" {
97 c.Name = sanitizeName(hostname)
98 }
99 switch v := strings.ToLower(strings.TrimSpace(getenv(EnvEnvironment))); v {
100 case "":
101 c.Environment = EnvGitHubHosted
102 case EnvGitHubHosted, EnvSelfHosted:
103 c.Environment = v
104 default:
105 return c, fmt.Errorf("%s: %q is not %s or %s", EnvEnvironment, getenv(EnvEnvironment), EnvGitHubHosted, EnvSelfHosted)
106 }
107 switch strings.ToLower(strings.TrimSpace(getenv(EnvInsecureHTTP))) {
108 case "", "0", "false", "no":
109 case "1", "true", "yes":
110 c.InsecureHTTP = true
111 default:
112 return c, fmt.Errorf("%s: %q is not 0 or 1", EnvInsecureHTTP, getenv(EnvInsecureHTTP))
113 }
114 if c.URL != "" {
115 if err := CheckURL(c.URL, c.InsecureHTTP); err != nil {
116 return c, fmt.Errorf("%s: %w", EnvURL, err)
117 }
118 }
119 if !nameRe.MatchString(c.Name) {
120 return c, fmt.Errorf("%s: %q is not a valid runner name (1 to 64 letters, digits, '.', '_' and '-')", EnvName, c.Name)
121 }
122 seen := map[string]bool{}
123 for _, l := range strings.Split(getenv(EnvLabels), ",") {
124 l = strings.TrimSpace(l)
125 if l == "" {
126 continue
127 }
128 if !nameRe.MatchString(l) {
129 return c, fmt.Errorf("%s: %q is not a valid label (1 to 64 letters, digits, '.', '_' and '-')", EnvLabels, l)
130 }
131 if k := strings.ToLower(l); !seen[k] {
132 seen[k] = true
133 c.Labels = append(c.Labels, l)
134 }
135 }
136 return c, nil
137}
138
139func sanitizeName(s string) string {
140 b := []byte(s)
141 for i, ch := range b {
142 if ok := ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'; !ok {
143 b[i] = '-'
144 }
145 }
146 if len(b) > 64 {
147 b = b[:64]
148 }
149 if len(b) == 0 {
150 return "runner"
151 }
152 return string(b)
153}
154
155// State is what register stores: the runner token and who it belongs to. The
156// file is mode 0600 and the token is never logged.
157type State struct {
158 URL string `json:"url"`
159 RunnerID string `json:"runner_id"`
160 Name string `json:"name"`
161 RunnerToken string `json:"runner_token"`
162}
163
164// StateFile is the path of the state file in dir.
165func StateFile(dir string) string { return filepath.Join(dir, "runner.json") }
166
167// ErrNotRegistered is returned by LoadState when no runner is registered.
168var ErrNotRegistered = errors.New("this runner is not registered: run `ermite-runner register` first")
169
170// SaveState writes the state atomically with mode 0600.
171func SaveState(dir string, s State) error {
172 if err := os.MkdirAll(dir, 0o700); err != nil {
173 return err
174 }
175 // MkdirAll leaves an existing directory as it is; the directory holds the
176 // runner token, so it is made private (the VM images live in it too, and
177 // the runner runs as root: nobody else needs to enter).
178 if err := os.Chmod(dir, 0o700); err != nil {
179 return fmt.Errorf("make the state directory private: %w", err)
180 }
181 b, err := json.MarshalIndent(s, "", " ")
182 if err != nil {
183 return err
184 }
185 tmp, err := os.CreateTemp(dir, ".runner-*.json")
186 if err != nil {
187 return err
188 }
189 defer func() { _ = os.Remove(tmp.Name()) }()
190 if err := tmp.Chmod(0o600); err != nil {
191 _ = tmp.Close()
192 return err
193 }
194 if _, err := tmp.Write(append(b, '\n')); err != nil {
195 _ = tmp.Close()
196 return err
197 }
198 if err := tmp.Sync(); err != nil {
199 _ = tmp.Close()
200 return err
201 }
202 if err := tmp.Close(); err != nil {
203 return err
204 }
205 return os.Rename(tmp.Name(), StateFile(dir))
206}
207
208// LoadState reads the state; it refuses a file readable by others, because the
209// runner token is a secret.
210func LoadState(dir string) (State, error) {
211 p := StateFile(dir)
212 st, err := os.Stat(p)
213 if errors.Is(err, os.ErrNotExist) {
214 return State{}, ErrNotRegistered
215 }
216 if err != nil {
217 return State{}, err
218 }
219 if st.Mode().Perm()&0o077 != 0 {
220 return State{}, fmt.Errorf("%s is accessible by other users (mode %o): chmod 600 it", p, st.Mode().Perm())
221 }
222 b, err := os.ReadFile(p)
223 if err != nil {
224 return State{}, err
225 }
226 var s State
227 if err := json.Unmarshal(b, &s); err != nil {
228 return State{}, fmt.Errorf("%s: %w", p, err)
229 }
230 if s.RunnerToken == "" {
231 return State{}, fmt.Errorf("%s has no runner token", p)
232 }
233 return s, nil
234}
235
236// ResolveRegisterToken returns the token for `register`, from the first source
237// that has one: standard input (--token-stdin, read to the end and trimmed),
238// the environment (ERMITE_RUNNER_REGISTER_TOKEN), then the --token flag. The
239// flag is last because the arguments of a process are visible to every user of
240// the host (ps, /proc/<pid>/cmdline); the other two are not.
241func ResolveRegisterToken(flagValue string, fromStdin bool, stdin io.Reader, getenv func(string) string) (string, error) {
242 if fromStdin {
243 b, err := io.ReadAll(io.LimitReader(stdin, 4096))
244 if err != nil {
245 return "", fmt.Errorf("reading the token from standard input: %w", err)
246 }
247 if t := strings.TrimSpace(string(b)); t != "" {
248 return t, nil
249 }
250 return "", errors.New("standard input has no token")
251 }
252 if t := strings.TrimSpace(getenv(EnvRegisterToken)); t != "" {
253 return t, nil
254 }
255 if t := strings.TrimSpace(flagValue); t != "" {
256 return t, nil
257 }
258 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)
259}