1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6 "crypto/hmac"
7 "errors"
8 "net/http"
9 "net/url"
10 "strconv"
11 "time"
12
13 "bnema.dev/ermite-runner/internal/actionscache"
14)
15
16// ArchivePath is the prefix of the action archive route: the host downloads the
17// archives of actions into its own cache (package actionscache) and the sandbox
18// reads them here instead of from the forge. It is not part of the toolkit's
19// protocol.
20const ArchivePath = "/_ermite/action-archives/"
21
22// ArchiveURL is the URL, as the guest sees it (its loopback, DefaultPort), from
23// which the job can read the archive of key for ttl. Like the toolkit's archive
24// URLs the signature is the credential: it is bound to the key, the job and the
25// expiry, so a URL that left its sandbox is useless on any other channel, and a
26// job can read only the archives the host resolved for it. It is "" when the
27// service holds no archive cache.
28func (s *Service) ArchiveURL(job, key string, ttl time.Duration) string {
29 if s.archives == nil || !actionscache.ValidKey(key) {
30 return ""
31 }
32 exp := s.now().Add(ttl).Unix()
33 q := url.Values{"job": {job}, "exp": {strconv.FormatInt(exp, 10)}, "sig": {b64.EncodeToString(s.sign.mac("action-archive", []byte(key), []byte(job), []byte(strconv.FormatInt(exp, 10))))}}
34 return "http://127.0.0.1:" + strconv.Itoa(DefaultPort) + ArchivePath + key + "?" + q.Encode()
35}
36
37func (s *Service) archiveOK(key, job string, exp int64, sig string) bool {
38 got, err := b64.DecodeString(sig)
39 if err != nil || !s.now().Before(time.Unix(exp, 0)) {
40 return false
41 }
42 return hmac.Equal(got, s.sign.mac("action-archive", []byte(key), []byte(job), []byte(strconv.FormatInt(exp, 10))))
43}
44
45func (s *Service) serveActionArchive(channelJob string) http.HandlerFunc {
46 return func(w http.ResponseWriter, r *http.Request) {
47 key := r.PathValue("key")
48 q := r.URL.Query()
49 exp, err := strconv.ParseInt(q.Get("exp"), 10, 64)
50 job := q.Get("job")
51 if s.archives == nil || err != nil || job == "" || (channelJob != "" && job != channelJob) || !actionscache.ValidKey(key) || !s.archiveOK(key, job, exp, q.Get("sig")) {
52 reply(w, http.StatusForbidden, "forbidden")
53 return
54 }
55 f, err := s.archives.Open(key)
56 if err != nil {
57 if !errors.Is(err, actionscache.ErrNotFound) {
58 s.log.Warn().Err(err).Msg("action archive")
59 }
60 reply(w, http.StatusNotFound, "no such archive")
61 return
62 }
63 defer func() { _ = f.Close() }()
64 rc := http.NewResponseController(w)
65 remaining := s.remaining(exp)
66 defer func() { _ = rc.SetWriteDeadline(deadline(s.progress, remaining)) }()
67 w.Header().Set("Content-Type", "application/gzip")
68 http.ServeContent(&progressWriter{ResponseWriter: w, rc: rc, idle: s.progress, remaining: remaining}, r, "", time.Time{}, f)
69 }
70}