package rosa import ( "compress/gzip" "context" "crypto/ed25519" "crypto/sha512" "errors" "io" "io/fs" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "unique" "hakurei.app/internal/pkg" "hakurei.app/message" ) // Remote is an authenticated cache mirror. type Remote struct { // Mirror URL. url *url.URL // Trusted public key. pub ed25519.PublicKey // For requests to the mirror. c *http.Client } // NewRemote returns a populated [Remote]. func NewRemote(c *http.Client, base string, pub ed25519.PublicKey) (Remote, error) { u, err := url.Parse(base) return Remote{u, pub, c}, err } // get makes a [http.MethodGet] request and returns the response, or nil if // the response StatusCode is [http.StatusNotFound]. func (r Remote) get(ctx context.Context, elem ...string) (*http.Response, error) { if r.url == nil || len(r.pub) != ed25519.PublicKeySize || r.c == nil { return nil, os.ErrInvalid } req, err := http.NewRequestWithContext( ctx, http.MethodGet, r.url.JoinPath(elem...).String(), nil, ) if err != nil { return nil, err } req.Header.Set("User-Agent", "Rosa/1.1") var resp *http.Response if resp, err = r.c.Do(req); err != nil { return nil, err } switch resp.StatusCode { case http.StatusOK: return resp, nil case http.StatusNotFound: return nil, resp.Body.Close() default: _ = resp.Body.Close() return nil, pkg.ResponseStatusError(resp.StatusCode) } } const ( // dirArtifact holds signed artifact outcome checksums. dirArtifact = "artifact" // dirOutcome holds outcome archives by their checksum. dirOutcome = "outcome" // dirStatus holds signed status files. dirStatus = "status" ) // An OutcomeBadSizeError describes a mirror outcome with unexpected size. type OutcomeBadSizeError struct { Ident unique.Handle[pkg.ID] Size int64 } func (e OutcomeBadSizeError) Error() string { if e.Size < 0 { return "remote did not return outcome size for " + pkg.Encode(e.Ident.Value()) } return "outcome size " + strconv.FormatInt(e.Size, 10) + " invalid for " + pkg.Encode(e.Ident.Value()) } // An OutcomeAuthError describes a mirror outcome with invalid signature. type OutcomeAuthError unique.Handle[pkg.ID] func (e OutcomeAuthError) Error() string { return "invalid outcome signature for " + pkg.Encode(unique.Handle[pkg.ID](e).Value()) } // Artifact fetches and authenticates an outcome. func (r Remote) Artifact( ctx context.Context, id unique.Handle[pkg.ID], ) (*pkg.Checksum, error) { if len(r.pub) != ed25519.PublicKeySize || r.c == nil { return nil, os.ErrInvalid } resp, err := r.get(ctx, dirArtifact, pkg.Encode(id.Value())) if err != nil || resp == nil { return nil, err } var buf [ed25519.SignatureSize + 2*len(pkg.Checksum{})]byte if resp.ContentLength != int64(len(buf)) { _ = resp.Body.Close() return nil, OutcomeBadSizeError{id, resp.ContentLength} } if _, err = io.ReadFull(resp.Body, buf[:]); err != nil { return nil, errors.Join(err, resp.Body.Close()) } else if err = resp.Body.Close(); err != nil { return nil, err } if !ed25519.Verify( r.pub, buf[ed25519.SignatureSize:], buf[:ed25519.SignatureSize], ) { return nil, OutcomeAuthError(id) } else if unique.Make((pkg.ID)(buf[ed25519.SignatureSize:])) != id { return nil, OutcomeAuthError(id) } return (*pkg.Checksum)(buf[ed25519.SignatureSize+len(pkg.Checksum{}):]), nil } // Checksum returns an artifact satisfying checksum. func (r Remote) Checksum(checksum unique.Handle[pkg.Checksum]) pkg.Artifact { return pkg.NewArchive(pkg.NewHTTPGet( r.c, r.url.JoinPath(dirOutcome, pkg.Encode(checksum.Value())).String(), checksum.Value(), )) } // A StatusBadSizeError describes a mirror status with unexpected size. type StatusBadSizeError unique.Handle[pkg.ID] func (e StatusBadSizeError) Error() string { return "status payload too short for " + pkg.Encode(unique.Handle[pkg.ID](e).Value()) } // A StatusAuthError describes a mirror status with invalid signature. type StatusAuthError unique.Handle[pkg.ID] func (e StatusAuthError) Error() string { return "invalid status signature for " + pkg.Encode(unique.Handle[pkg.ID](e).Value()) } // Status authenticates the checksum of a status file and returns its // corresponding measured reader. func (r Remote) Status( ctx *pkg.RContext, id unique.Handle[pkg.ID], ) (io.ReadCloser, error) { resp, err := r.get(ctx.Unwrap(), dirStatus, pkg.Encode(id.Value())) if err != nil || resp == nil { return nil, err } var buf [ed25519.SignatureSize + 2*len(pkg.Checksum{})]byte if _, err = io.ReadFull(resp.Body, buf[:]); err != nil { if errors.Is(err, io.ErrUnexpectedEOF) { err = StatusBadSizeError(id) } return nil, err } if !ed25519.Verify( r.pub, buf[ed25519.SignatureSize:], buf[:ed25519.SignatureSize], ) { _ = resp.Body.Close() return nil, StatusAuthError(id) } else if unique.Make((pkg.ID)(buf[ed25519.SignatureSize:])) != id { return nil, StatusAuthError(id) } return ctx.NewMeasuredReader( resp.Body, unique.Make((pkg.Checksum)(buf[ed25519.SignatureSize+len(pkg.Checksum{}):])), ), nil } // Mirror services requests made by [Remote]. type Mirror struct { // Cache on-filesystem format. fsys fs.FS // Signs payloads. key ed25519.PrivateKey // Verbose output. msg message.Msg } // NewMirror returns a populated [Mirror] for servicing mirror requests. func NewMirror( msg message.Msg, fsys fs.FS, key ed25519.PrivateKey, ) Mirror { return Mirror{fsys, key, msg} } // writeArtifact writes a signed dirArtifact response. func (m Mirror) writeArtifact( buf *[2 * len(pkg.Checksum{})]byte, w io.Writer, header http.Header, ) error { ids := pkg.Encode((pkg.Checksum)(buf[:len(pkg.Checksum{})])) if linkname, err := fs.ReadLink(m.fsys, filepath.Join( "identifier", ids, )); err != nil { return err } else if err = pkg.Decode( (*pkg.Checksum)(buf[len(pkg.Checksum{}):]), filepath.Base(linkname), ); err != nil { return err } m.msg.Verbosef("serving artifact %s", ids) if header != nil { header.Set( "Content-Length", strconv.Itoa(ed25519.SignatureSize+len(buf)), ) } _, err := w.Write(append(ed25519.Sign(m.key, buf[:]), buf[:]...)) return err } // writeOutcome writes a dirOutcome response. func (m Mirror) writeOutcome( buf *pkg.Checksum, w io.Writer, header http.Header, ) error { checksums := pkg.Encode(*buf) rel := filepath.Join("checksum", checksums) if _, err := fs.Lstat(m.fsys, rel); err != nil { return err } m.msg.Verbosef("serving outcome %s", checksums) _fsys, err := fs.Sub(m.fsys, rel) if err != nil { return err } var gw *gzip.Writer if gw, err = gzip.NewWriterLevel(w, gzip.BestCompression); err != nil { return err } if header != nil { header.Set("Content-Encoding", "gzip") } if err = pkg.Write(_fsys, ".", gw); err != nil { _ = gw.Close() return err } return gw.Close() } // writeStatus writes a signed dirStatus response. func (m Mirror) writeStatus( buf *[2 * len(pkg.Checksum{})]byte, w io.Writer, _ http.Header, ) error { ids := pkg.Encode((pkg.Checksum)(buf[:len(pkg.Checksum{})])) f, err := m.fsys.Open(filepath.Join( "status", ids, )) if err != nil { return err } s, ok := f.(io.Seeker) if !ok { return &fs.PathError{Op: "seek", Path: ids, Err: fs.ErrInvalid} } m.msg.Verbosef("serving status %s", ids) h := sha512.New384() if _, err = io.Copy(h, f); err != nil { _ = f.Close() return err } h.Sum(buf[len(pkg.Checksum{}):len(pkg.Checksum{})]) if _, err = w.Write(append(ed25519.Sign(m.key, buf[:]), buf[:]...)); err != nil { _ = f.Close() return err } else if _, err = s.Seek(0, io.SeekStart); err != nil { _ = f.Close() return err } else if _, err = io.Copy(w, f); err != nil { _ = f.Close() return err } return f.Close() } // writeF is a helper for writing a mirror response with error handling. func writeF[B any]( m Mirror, w http.ResponseWriter, buf B, f func( buf B, w io.Writer, header http.Header, ) error, ) { err := f(buf, w, w.Header()) if err == nil { return } if errors.Is(err, os.ErrNotExist) { w.WriteHeader(http.StatusNotFound) return } m.msg.GetLogger().Println(err) w.WriteHeader(http.StatusInternalServerError) } // Register configures an [http.ServeMux] for servicing mirror requests. func (m Mirror) Register(mux *http.ServeMux) { const identName = "ident" mux.HandleFunc("/"+dirArtifact+"/{"+identName+"}", func( w http.ResponseWriter, req *http.Request, ) { var buf [2 * len(pkg.Checksum{})]byte if err := pkg.Decode( (*pkg.Checksum)(buf[:len(pkg.Checksum{})]), req.PathValue(identName), ); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeF(m, w, &buf, m.writeArtifact) }) mux.HandleFunc("/"+dirOutcome+"/{"+identName+"}", func( w http.ResponseWriter, req *http.Request, ) { if !strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") { w.WriteHeader(http.StatusNotAcceptable) return } var buf pkg.Checksum if err := pkg.Decode( &buf, req.PathValue(identName), ); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeF(m, w, &buf, m.writeOutcome) }) mux.HandleFunc("/"+dirStatus+"/{"+identName+"}", func( w http.ResponseWriter, req *http.Request, ) { var buf [2 * len(pkg.Checksum{})]byte if err := pkg.Decode( (*pkg.Checksum)(buf[:len(pkg.Checksum{})]), req.PathValue(identName), ); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeF(m, w, &buf, m.writeStatus) }) }